0xmrerror@terminal:~/writeups

Easy as GDB — Scripted Character-by-Character Brute Force

The flag is validated one character at a time, so automate it: script GDB to brute-force the key space inside a single session.

HardpicoCTFReverse engineering · 17 min readgdbscriptingbrute-forcebruteforcepicoctfreveng

A single challenge description line — "The flag has got to be checked somewhere..." — is almost the whole solution. The attached 32-bit ELF (brute) asks for a flag candidate, encodes it on the fly, then walks the encoded bytes against the stored encoded flag one position at a time, aborting at the first mismatch. That per-byte compare is an oracle you can mine directly: set a breakpoint on the compare instruction, count hits, and read AL/DL to decide each character. This is the challenge that GDB's Python scripting API was made for.

1 · First Run — Meet the Oracle

The program is a key checker, not a vault:

chmod +x brute
./brute
input the flag: test
checking solution...
Incorrect.

strings brute yields only UI literals (input the flag:, checking solution..., Correct!/Incorrect.) — no plaintext flag, because the flag is stored encoded. The description is literal: the flag really is "checked somewhere", and that somewhere is a compare loop.

2 · Static Pass in Ghidra

Decompiling main shows the pipeline: read up to 0x200 bytes, measure the stored encoded flag, pre-encode our input, then hand everything to check_flag:

user_input = calloc(0x200,1);
printf("input the flag: ");
fgets(user_input,0x200,stdin);
encoded_flag_len = strnlen(&g_encoded_flag,0x200);
user_input = FUN_0001082b(user_input,encoded_flag_len);
FUN_000107c2(user_input,encoded_flag_len,1);
iVar1 = check_flag(user_input,encoded_flag_len);
if (iVar1 == 1) puts("Correct!"); else puts("Incorrect.");

g_encoded_flag is a blob of bytes at 0x12008 that makes no sense as text (7a 2e 6e 68 1d 65 16 7c 6d 43 6f 36 63 62 14 47 ...). You are meant to recover the input value that encodes into it, not read it off.

3 · check_flag Aborts at the First Mismatch

undefined4 check_flag(char *user_input, uint encoded_flag_len)
{
  __dest   = calloc(encoded_flag_len + 1, 1);
  strncpy(__dest, user_input, encoded_flag_len);
  FUN_000107c2(__dest, encoded_flag_len, -1);
  __dest_00 = calloc(encoded_flag_len + 1, 1);
  strncpy(__dest_00, &g_encoded_flag, encoded_flag_len);
  FUN_000107c2(__dest_00, encoded_flag_len, -1);
  puts("checking solution...");
  i = 0;
  while (true) {
    if (encoded_flag_len <= i) return 1;            # all matched
    if (__dest[i] != __dest_00[i]) break;           # first mismatch → abort
    i = i + 1;
  }
  return 0xffffffff;                              # i.e. -1
}

Three things follow, and all of them come back to the same property: the loop stops the moment it finds a wrong byte.

  • The check returns only once the entire prefix is correct — a wrong byte anywhere stops the loop, so a run with prefix flag + c either completes a compare for position len(flag) or dies earlier.
  • Both buffers get the same post-transform before comparison, so you never need to invert the encoding — the byte that must equal yours is computed inside the loop, sitting in a register at the compare.
  • Correct guesses make the loop go one position further. That's the oracle.

4 · Where the Compare Lives

56555978  MOV EDX,[EBP + local_14]      # base of buffer A (from g_encoded_flag)
5655597b  MOV EAX,[EBP + i]
5655597e  ADD EAX,EDX
56555980  MOVZX EDX, byte [EAX]         # DL = expected byte for position i
56555983  MOV ECX,[EBP + local_10]      # base of buffer B (our encoded input)
56555986  MOV EAX,[EBP + i]
56555989  ADD EAX,ECX
5655598b  MOVZX EAX, byte [EAX]         # AL = our encoded byte for position i
5655598e  CMP DL, AL                    # ← break here and read both bytes
56555990  JZ   LAB_5655599b             # equal → i++
56555992  MOV  [EBP + local_1c], 0xffffffff
56555999  JMP  LAB_565559a7             # mismatch → return -1
5655599b  ADD  [EBP + i], 0x1
5655599f  MOV  EAX,[EBP + i]
565559a2  CMP  EAX,[EBP + encoded_flag_len]   # loop limit → exposes flag length
565559a5  JC   LAB_56555978

Note the MOVZX loads: at 5655598e both the expected byte and our byte are live in DL and AL. One register read leaks the answer for the current position. The compare at 565559a2 doubles as a length oracle — breaking there shows the flag is 30 bytes.

5 · The Encoding, for Reference

The flag isn't in the binary because g_encoded_flag is the flag put through a transform: a byte-lane XOR against a rolling constant starting at 0xabcf00d and striding by 0x1fab4d to 0xdeadbeef, each constant's four bytes xored into positions i&3, followed by an interleaving swap. Both buffers get the same transform before the compare, so inverting it in Python is a valid alternative solve. The GDB route skips all of it.

6 · The GDB Script

A custom gdb.Breakpoint subclass with a hit counter acts as a conditional breakpoint: for a candidate at position i we want the (i+1)-th hit of the compare (positions 0..i-1 each produce one hit first). A second breakpoint on the puts("Correct!") marks full success.

import gdb
import string
from queue import Queue, Empty

MAX_FLAG_LEN = 0x200

class Checkpoint(gdb.Breakpoint):
    def __init__(self, queue, target_hitcount, *args):
        super().__init__(*args)
        self.silent = True
        self.queue = queue
        self.target_hitcount = target_hitcount
        self.hit = 0

    def stop(self):
        self.hit += 1
        if self.hit == self.target_hitcount:
            al = gdb.parse_and_eval("$al")
            dl = gdb.parse_and_eval("$dl")
            self.queue.put(al == dl)      # our byte == expected byte?
        return False                      # keep running, never stop interactively

class Solvepoint(gdb.Breakpoint):
    def __init__(self, *args):
        super().__init__(*args)
        self.silent = True
        self.hit = 0

    def stop(self):
        self.hit += 1
        return False

gdb.execute("set disable-randomization on")   # keep the runtime addresses stable
gdb.execute("delete")
sp = Solvepoint("*0x56555a71")              # the push before puts("Correct!")
queue = Queue()

flag = ""
ALPHABET = string.ascii_letters + string.digits + "{}_"

for i in range(len(flag), MAX_FLAG_LEN):
    for c in ALPHABET:
        bp = Checkpoint(queue, len(flag) + 1, '*0x5655598e')
        gdb.execute("run <<< {}{}".format(flag, c))   # feed the candidate on stdin
        try:
            result = queue.get(timeout=1)
            bp.delete()
            if result:
                flag += c
                print("{}\n".format(flag))     # growing prefix, printed live
                break
        except Empty:
            print("Error: Empty queue!")
            gdb.execute("q")

    if sp.hit > 0:
        print("Found flag: {}".format(flag))
        gdb.execute("q")

7 · How the Mechanics Work

  • Hit counting, not conditions: Checkpoint with target_hitcount = len(flag)+1 ignores the compare hits for all positions before the one we're solving, then, on exactly that hit, evaluates al == dl. A wrong char never reaches that many hits (the loop aborted early), so it reports failure via an empty-queue timeout.
  • One process per probe: each guess is a fresh run, so the trace is clean and countable. The Bash here-string run <<< {flag}{c} pipes the candidate into the inferior's stdin — shorter and reliable than generator tricks.
  • Silent breakpoints: silent = True plus stop() returning False means GDB never drops into the interactive prompt; the script stays in control.
  • Success detection: when the prefix reaches the real flag, the solver still keeps probing characters, but the program now reaches the puts("Correct!") breakpoint (sp) — one hit means done.
  • Address stability: set disable-randomization on pins the PIE base so the hardcoded runtime addresses (0x5655598e, 0x56555a71) remain valid. These addresses are specific to the released build — re-derive them from disas main/an objdump if needed.

8 · Running It

gdb -n -q -ex "set pagination off" -ex "source solve.py" ./brute
# one run per candidate; the growing prefix prints live:
# pico … picoC … picoCT … picoCTF{ … etc.
Breakpoint 1196 at 0x5655598e
input the flag: checking solution...
Correct!

picoCTF{I_5D3_A11DA7_0db137a9}

Found flag: picoCTF{I_5D3_A11DA7_0db137a9}

The whole 30-character flag recovers in a couple of minutes of scripted probes. Compare that with manually stepping 1196 breakpoint hits.

9 · The Flag

picoCTF{I_5D3_A11DA7_<HASH>}
# prefix is constant; the trailing hex differs per instance build.
# public instances end e.g. _0db137a9 or _6aa8dd3b

10 · What Made It Work

The side channel here isn't timing, it's hit counting. A fail-fast compare tells you how many leading characters were right by how far it got before returning, which is a slower oracle than reading memory but doesn't require knowing the encoding at all. That's the point: the encoding in step 5 is a distraction you can skip entirely because the compare is sitting in a register either way.

GDB's Python API is what makes the brute force practical. A gdb.Breakpoint subclass with a hit counter turns "conditional breakpoint" into arbitrary logic evaluated at a given hit, so the loop over candidates stays inside one session instead of relaunching per guess.