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 + ceither completes a compare for positionlen(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:
Checkpointwithtarget_hitcount = len(flag)+1ignores the compare hits for all positions before the one we're solving, then, on exactly that hit, evaluatesal == 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-stringrun <<< {flag}{c}pipes the candidate into the inferior's stdin — shorter and reliable than generator tricks. - Silent breakpoints:
silent = Trueplusstop()returningFalsemeans 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 onpins the PIE base so the hardcoded runtime addresses (0x5655598e,0x56555a71) remain valid. These addresses are specific to the released build — re-derive them fromdisas 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.