← Back to Writeups
HTBN/AReversing

Cheater Cheater

XESXOR8/23/202610 min read
#reversing#htb#n/a

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
PortServiceVersionNotes
<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 LOSE and System.exit(0) in 5 s with the text 'In order to win, you need to cheat!, Decompiled source contains an obvious decoy private final String flag = \"THIS IS NOT HOW YOU ARE SUPPOSED TO DO THE CHALLENGE...\", A protected static final String pacVelocityZ field 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

  1. Aes_cbc_decryption
  2. Bigi_to_hex_string_trick
  3. Identifying_decoy_strings
  4. Java_decompilation_with_jadx
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

  1. setName(String) on the JPanel sets its name to the decimal string of the final score ("6942069").
  2. 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? ".

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 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).
  • The check 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.

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 score starts at 6942069, or
  • attach with a Java agent / debugger and write score = 6942069 before actionPerformed runs, or
  • patch actionPerformed 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.

TL;DR

  1. jadx the JAR → SimplePacMan + JTextBasket.
  2. Ignore the decoy flag field; the real ciphertext is SimplePacMan.pacVelocityZ.
  3. The game rigs you to lose at score 64000 and wants "winner" at score ≥ 6,942,069.
  4. On win, paintComponent calls setName("6942069") and revalidate() on the first child (a JTextBasket), which derives AES key/IV from the parent's name.
  5. N = (6942069*10+1)^4 = 23225000336468054454242927385361; key = hex(str(N)), iv = hex(str(N)[::-1]).
  6. AES/CBC/PKCS5Padding decrypt of pacVelocityZ gives the flag.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR