Cheater Cheater
Cheater Cheater
Platform: Dawgctf | Category: Reversing | Type: Challenge | Difficulty: Easy | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-10 | Status: Solved Techniques: aes_cbc_decryption, bigi_to_hex_string_trick, identifying_decoy_strings, java_decompilation_with_jadx, reconstructing_key_derivation_from_bytecode
Summary
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}.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
dawgctf| ID:20260410_dawgctf_cheater_cheater - Tags: java, jadx, aes, aes_cbc, reverse, jar, dawgctf, swing, bigi_arith, decoy_fields
- Indicators: Challenge ships a single Java JAR (PacManForCTF.jar) whose manifest points at a Swing GUI (HacMan / SimplePacMan), Task description literally tells you the game is impossible and asks you to help cheat — 'cheater' hint is explicit, Game advertises an unreachable highscore of 6,942,069 and has a hardcoded trap where score == 64000 →
YOU LOSEandSystem.exit(0)in 5 s with the text 'In order to win, you need to cheat!, Decompiled source contains an obvious decoyprivate final String flag = \"THIS IS NOT HOW YOU ARE SUPPOSED TO DO THE CHALLENGE...\", Aprotected static final String pacVelocityZfield in SimplePacMan holds a 48-byte base64 blob — it is actually the AES ciphertext of the flag - Source:
20260410_dawgctf_cheater_cheater.md
Foothold
Vulnerability / Misconfiguration
- Aes_cbc_decryption
- Bigi_to_hex_string_trick
- Identifying_decoy_strings
- Java_decompilation_with_jadx
- Reconstructing_key_derivation_from_bytecode
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- aes_cbc_decryption
- bigi_to_hex_string_trick
- identifying_decoy_strings
- java_decompilation_with_jadx
- reconstructing_key_derivation_from_bytecode
- Tags: java, jadx, aes, aes_cbc, reverse, jar, dawgctf, swing, bigi_arith, decoy_fields
Original Writeup
<details><summary>Click to expand original content</summary>Description
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.
Recon
$ 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
Analysis
1. SimplePacMan — the decoys
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 itsnameto the decimal string of the final score ("6942069").revalidate()is explicitly called on the first child (barbecue). BecauseJTextBasketoverridesrevalidate, this is a disguised function call.
Then whatever barbecue ends up being called is displayed on screen next to "Or is it? ".
2. JTextBasket — the decryption routine
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:
- The variable names are deliberately swapped. The local called
threeis the AES key (derived fromN.toString()), while the local calledkeyis actually the IV (derived from the reversed string). hexStringToByteArraytakes an arbitrary decimal string and interprets each pair of decimal digits as a hex byte. Because decimal digits0-9are all valid hex nibbles, the call never throws, but the resulting bytes only contain nibbles0-9(neverA-F).- The check
rin.getName() == "javacode"uses==(reference equality), not.equals(). After the winner branch runssetName(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, afterrevalidate()returns, thepaintComponentcode can findbarbecueviaArrays.stream(getComponents()).filter(w -> w.isEnabled()).findFirst():barbecuewas disabled ingenerateMaze(), andrevalidate()re-enables it so it will show up in the "first enabled child" lookup.
3. Key derivation math
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.
4. AES decrypt → flag
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.
Solution
Python solver
#!/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}
Alternative (not needed) — cheat the running JVM
If you did want to hand the flag to the game itself, you could:
- patch the bytecode so
scorestarts at6942069, or - attach with a Java agent / debugger and write
score = 6942069beforeactionPerformedruns, or - patch
actionPerformedto remove theif (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.
TL;DR
jadxthe JAR →SimplePacMan+JTextBasket.- Ignore the decoy
flagfield; the real ciphertext isSimplePacMan.pacVelocityZ. - The game rigs you to lose at score 64000 and wants "winner" at score ≥ 6,942,069.
- On win,
paintComponentcallssetName("6942069")andrevalidate()on the first child (aJTextBasket), 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]).- AES/CBC/PKCS5Padding decrypt of
pacVelocityZgives the flag.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR