$ cat writeup.md…
$ cat writeup.md…
dawgctf
A Java Swing Pac-Man clone called HacMan ships a decoy `flag` field and a deliberately unreachable highscore (6,942,069) with a trap at score==64,000 that kills the process. The real flag is an AES/CBC ciphertext stored in `SimplePacMan.pacVelocityZ` that the game only decrypts in the `winner` branch of `paintComponent`, which calls `setName(Integer.toString(score))` on the panel and then invokes `revalidate()` on the first child component (`barbecue`, a custom `JTextBasket`). `JTextBasket.revalidate()` reads the parent panel's name as a `BigInteger`, computes `N = (name*10+1)^4`, feeds `N.toString()` as a **hex** string to derive a 16-byte AES key, feeds the *reversed* decimal string as a 16-byte IV, and decrypts the base64 blob. You don't need to play or patch the game: replicating the derivation in Python with score `6942069` yields `DawgCTF{REDACTED}`.
There's this game called Hac-Man and I've been trying really hard to beat this guy's high score but I swear it's impossible! Can you help?
The flag will be in the format
DawgCTF{Anyth1ngIsP0ss1bl3!}File:
PacManForCTF.jar
The task title is the hint: cheat. The description even spells it out ("I swear it's impossible"). Running the JAR (java -jar PacManForCTF.jar) opens a 1920×2045 Swing window titled "HacMan" with a Prim's-algorithm maze, a yellow Pac-Man, and a red "Highscore: 6942069" — obviously unreachable if each dot only gives 10 points and the maze holds fewer than that many dots.
$ file PacManForCTF.jar
PacManForCTF.jar: Java archive data (JAR)
$ unzip -l PacManForCTF.jar
Length Date Time Name
--------- ---------- ----- ----
51 04-20-2023 00:59 META-INF/MANIFEST.MF
498 04-20-2023 00:58 SimplePacMan$1.class
971 04-20-2023 00:58 SimplePacMan$2.class
11749 04-20-2023 00:58 SimplePacMan.class
7292 04-20-2023 00:59 JTextBasket.class
$ cat META-INF/MANIFEST.MF
Manifest-Version: 1.0
Main-Class: SimplePacMan
Two relevant classes: SimplePacMan (the game) and JTextBasket (a suspicious "text basket" — the name is already a red flag for a custom swing component that shouldn't normally exist).
Decompile with jadx:
$ jadx -d decompiled PacManForCTF.jar
$ ls decompiled/sources/defpackage/
JTextBasket.java SimplePacMan.java
SimplePacMan extends JPanel implements ActionListener. Relevant highlights:
public class SimplePacMan extends JPanel implements ActionListener { private static final int numTiles = 80; private static final int tileSize = 24; // ... private int score; private JTextBasket barbecue; private JTextBasket barbecue2; // DECOY 1 — string tells you not to bother private final String flag = "THIS IS NOT HOW YOU ARE SUPPOSED TO DO THE CHALLENGE. YOU CAN IF YOU WANT " + "BUT IT'LL BE EASIER TO JUST CHEAT :) IF YOU DO REVERSE THIS, PLEASE DO A " + "WRITE UP! I'M VERY CURIOUS TO HEAR THE PROCESS"; // "velocity Z" — there is no Z axis in pac-man. This is actually the AES ciphertext. protected static final String pacVelocityZ = "6Ach6HiD0JmCc1L+RwxDRzhW3sC1kS6XydgSuWVFpxVXRU8EjfuMxIMoIzMwK/ii";
The constructor calls generateMaze(), which creates a JTextBasket named "javacode", disables it, and adds it as the first child of the panel:
private void generateMaze() { this.maze = new int[numTiles][numTiles]; ArrayList<Point> walls = new ArrayList<>(); Random rand = new Random(); this.barbecue = new JTextBasket(); this.barbecue.setName("javacode"); // <-- important, used later for a reference check this.barbecue.setEnabled(false); add(this.barbecue); // <-- this is getComponents()[0] this.maze = prims(walls, ...); }
The game loop caps/transforms the score in actionPerformed:
public void actionPerformed(ActionEvent e) { if (this.score >= 6942069) { // the impossible high score... this.winner = true; // ...but if you somehow reach it, you win this.score = 6942069; } else { // normal move: +10 per dot if (this.maze[mazeX][mazeY] == 1) { this.maze[mazeX][mazeY] = 2; this.score += 10; if (this.score == 64000) { // classic reverse-engineering trap this.loser = true; } } } repaint(); }
Score 64000 (a number that could realistically be reached if you played well) triggers loser = true. In paintComponent, the loser branch prints "In order to win, you need to cheat!" and schedules System.exit(0) 5 seconds later — a very loud hint.
The winner branch is the key piece:
if (this.winner) { // draw the big green "YOU WIN" banner ... setName(Integer.toString(this.score)); // (A) SimplePacMan.name = "6942069" getComponents()[0].revalidate(); // (B) barbecue.revalidate() runs g2.drawString( "Or is it? " + ((Component) Arrays.stream(getComponents()) .filter(w -> w.isEnabled()) // the first enabled child .findFirst().get()).getName(), 520, 780); }
Two things happen:
setName(String) on the JPanel sets its name to the decimal string of the final score ("6942069").revalidate() is explicitly called on the first child (barbecue). Because JTextBasket overrides revalidate, this is a disguised function call.Then whatever barbecue ends up being called is displayed on screen next to "Or is it? ".
public class JTextBasket extends JComponent { // DECOY: pretty-looking int array that is never read final int[] palindromes = {3, 4, 12, 3, 5, 6, 6, 6, 5, 21, 1, 4, 3}; // DECOY: never called anywhere in the win path; looks like it sets // a huge formatted name but is misdirection public void setSizes(int width, int height) { ... }
The real code is in the overridden revalidate():
public void revalidate() throws /* lots of checked crypto exceptions */ { invalidate(); Container rin = getParent(); // SimplePacMan rin.getName(); setEnabled(true); // (*) IMPORTANT - see step 4 if (rin.getName() == "javacode") { // reference equality! return; } // The 'key' is (rin.name*10 + 1)^4 in BigInteger, rendered in base-10 BigInteger N = new BigInteger(rin.getName()) .multiply(new BigInteger("10")) .add(new BigInteger("1")) .pow(4); // Misused hex decoder: decimal digits 0-9 are a subset of hex digits, // so bytes.fromhex(str(N)) succeeds and yields a 16-byte value byte[] three = hexStringToByteArray(String.valueOf(N)); byte[] key = hexStringToByteArray( new StringBuilder(N.toString()).reverse().toString()); byte[] decodedInput = Base64.getDecoder().decode( "6Ach6HiD0JmCc1L+RwxDRzhW3sC1kS6XydgSuWVFpxVXRU8EjfuMxIMoIzMwK/ii"); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(three, "AES"), new IvParameterSpec(key)); String decrypted = new String(cipher.doFinal(decodedInput), "UTF-8"); setName(decrypted); }
A few subtleties:
three is the AES key (derived from N.toString()), while the local called key is actually the IV (derived from the reversed string).hexStringToByteArray takes an arbitrary decimal string and interprets each pair of decimal digits as a hex byte. Because decimal digits 0-9 are all valid hex nibbles, the call never throws, but the resulting bytes only contain nibbles 0-9 (never A-F).rin.getName() == "javacode" uses == (reference equality), not .equals(). After the winner branch runs setName(Integer.toString(score)), the name is a freshly allocated string "6942069" — not a JVM-interned literal, and not content-equal to "javacode" either, so the comparison is false and the decryption proceeds.setEnabled(true) is called before the early return. This is why, after revalidate() returns, the paintComponent code can find barbecue via Arrays.stream(getComponents()).filter(w -> w.isEnabled()).findFirst(): barbecue was disabled in generateMaze(), and revalidate() re-enables it so it will show up in the "first enabled child" lookup.Plugging in rin.getName() == "6942069":
N = (6942069 * 10 + 1)^4
= 69420691^4
= 23225000336468054454242927385361 (32 decimal digits)
key (AES-128) = bytes.fromhex("23225000336468054454242927385361")
= 23 22 50 00 33 64 68 05 44 54 24 29 27 38 53 61
iv (AES-CBC) = bytes.fromhex("16358372924245445086463300052232") # reversed
= 16 35 83 72 92 42 45 44 50 86 46 33 00 05 22 32
N is exactly 32 decimal digits → exactly 16 bytes for both key and IV after hex-decoding. AES-128 wants 16-byte keys and CBC needs a 16-byte IV, so everything lines up. This is why the magic constant 6942069 was chosen: (6942069*10+1)^4 is the smallest exponent that produces a nicely-sized 32-digit BigInteger.
AES/CBC/PKCS5Padding on the 48-byte ciphertext yields 39 bytes of plaintext: DawgCTF{REDACTED}. The "pumpkin eater" line is a reference to the nursery rhyme "Peter Peter Pumpkin Eater", which echoes the task name Cheater Cheater.
#!/usr/bin/env python3 """ Solver for DawgCTF SP26 'Cheater Cheater'. Replicates JTextBasket.revalidate() from PacManForCTF.jar without running the game. """ import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import unpad # The game requires score >= 6942069; paintComponent then calls # setName(Integer.toString(score)) on the panel, so the parent.name becomes "6942069". parent_name = "6942069" # BigInteger(name).multiply(10).add(1).pow(4) N = (int(parent_name) * 10 + 1) ** 4 assert N == 69420691 ** 4 s = str(N) # 32 decimal digits # hexStringToByteArray(decimal string) -- works because 0-9 are valid hex nibbles key = bytes.fromhex(s) # 16 bytes (labelled 'three' in the bytecode) iv = bytes.fromhex(s[::-1]) # 16 bytes, reversed string (labelled 'key') ct = base64.b64decode("6Ach6HiD0JmCc1L+RwxDRzhW3sC1kS6XydgSuWVFpxVXRU8EjfuMxIMoIzMwK/ii") flag = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ct), AES.block_size).decode() print("N =", N) print("key =", key.hex()) print("iv =", iv.hex()) print("FLAG:", flag) # DawgCTF{REDACTED}
Output:
N = 23225000336468054454242927385361
key = 23225000336468054454242927385361
iv = 16358372924245445086463300052232
FLAG: DawgCTF{REDACTED}
If you did want to hand the flag to the game itself, you could:
score starts at 6942069, orscore = 6942069 before actionPerformed runs, oractionPerformed to remove the if (this.score == 64000) trap and then "play" the game with a macro that raises the score past 6,942,069.All of those just end up inside paintComponent's winner branch, which in turn runs JTextBasket.revalidate() — exactly the function we already reproduced in Python.
jadx the JAR → SimplePacMan + JTextBasket.flag field; the real ciphertext is SimplePacMan.pacVelocityZ.paintComponent calls setName("6942069") and revalidate() on the first child (a JTextBasket), which derives AES key/IV from the parent's name.N = (6942069*10+1)^4 = 23225000336468054454242927385361; key = hex(str(N)), iv = hex(str(N)[::-1]).pacVelocityZ gives the flag.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar