Neven Marić
FortID 2026

PwnFortID 2026

GDB SaaS

A service disassembles any uploaded ELF and returns only stdout. GDB's source-interleaved disassembly opens a file named in the binary's debug info, so naming that file flag.txt makes GDB print the service's secret as source.

  • gdb
  • dwarf
  • source-path
  • file-read
  • sandboxing

What this one teaches

  • That disassemble /s in GDB reads real source files named in a binary's DWARF debug info, resolved through GDB's own search path (compilation directory, then GDB's current working directory), so a crafted binary can make GDB open a file it was never asked for by name.
  • Why GDB's auto-load safe-path default blocks the flashier .debug_gdb_scripts auto-exec vector on a stock install, and why a dev box with pwndbg or gef installed is misleading here: those tools relax that same setting in their own init file.
  • Why capturing only a subprocess's stdout (and discarding stderr) can hide the exact warning that would have told you an attack path was blocked, making a dead end look identical to a working one.

Task description

Get your mains disassembled for free. Upload an ELF, name a function, and we’ll run GDB on it for you.

The handout has no task.txt; the line above is the service’s own tagline.

What the service does

The handout is a single server.py. For each connection it reads a base64-encoded ELF, a function name, copies the service’s own flag.txt into a fresh temp directory alongside the uploaded file, and runs GDB against it:

if len(contents) >= 30*1024: ...            # ELF capped under 30 KB (30720 chars b64)
if len(function_name) >= 30 or any(c in BAD_CHARS for c in function_name): ...
tmp_dir = tempfile.mkdtemp(dir=".")
shutil.copy("flag.txt", os.path.join(tmp_dir, "flag.txt"))
tmp_path = os.path.join(tmp_dir, "a.out")
open(tmp_path, "wb").write(data); os.chmod(tmp_path, 0o755)
cmd = ["gdb", "-ex", f"disassemble {function_name}", "-ex", "quit", tmp_path]
result = subprocess.check_output(cmd)   # stdout only
print(result.decode("utf-8"))

Two facts about this shape the whole solve. First, subprocess.check_output returns only stdout; anything GDB prints to stderr, including its own warnings, never reaches me. Second, GDB’s own banner tells me where it is running: it reports Reading symbols from /tmp/tmpXXXXXXXX/a.out, and since the temp directory is created with mkdtemp(dir="."), the service’s working directory is /tmp itself, which is also where the original flag.txt lives (the shutil.copy line reads it from that directory and drops a copy into the random temp directory as a side effect).

function_name goes straight into disassemble {function_name} as one argv element, filtered against BAD_CHARS. The real set in server.py is:

BAD_CHARS = set("\x00\r\n" "\t" ";|&" "`$" "<>" "\\'\"" "!" "#" "," "()[]{}")

There is no shell involved and no way to add a second -ex, but the filter happens to allow both a space and a / (neither is in BAD_CHARS), so a value like /s main is legal and only 7 characters, well under the length-30 limit.

What DWARF, source paths, and `disassemble /s` are, for a reader coming from another specialism

When a C program is compiled with -g, the compiler records debug information in DWARF format inside the ELF: which machine instructions came from which source line, the names and types of variables, and the name and directory of each source file (DW_AT_name and DW_AT_comp_dir). A debugger uses this to show you source alongside assembly.

disassemble /s <func> is GDB’s “source-interleaved” disassembly: for each block of instructions it prints the source lines they came from. To do that GDB has to open the actual source file. It does not have the file’s original absolute path reliably, so it searches a path: $cdir (the compilation directory recorded in DWARF) first, then $cwd (GDB’s own current working directory). If the compilation directory does not exist on the machine running GDB, GDB falls back to looking for the file by its basename in $cwd. That fallback is the entire vulnerability: the attacker controls the basename GDB will look for.

Two routes, one of them a trap

Running GDB on an attacker-supplied ELF opens two behaviours that both look like they could leak a file:

  • .debug_gdb_scripts auto-loading: an ELF section can carry an inline Python script that GDB runs automatically when it loads the object file. From that Python, gdb.current_objfile().filename would give the exact temp path, and the flag copy sitting next to it would be trivial to read.
  • source-interleaved disassembly: disassemble /s <func> prints the source lines next to each instruction, reading them from the file path recorded in the binary’s DWARF.

The first one does not work against this service. GDB gates all auto-loading behind auto-load safe-path, and the stock Ubuntu default is $debugdir:$datadir/auto-load, a path /tmp/tmpXXXX/a.out is never under. GDB declines the script and prints a warning, but that warning goes to stderr, which the harness discards, so a declined auto-load payload looks identical to a working one from the outside: a normal disassembly dump, no flag, no visible error. A development box with pwndbg or gef installed makes the same payload appear to work locally, because both tools relax auto-load safe-path to / in their own init file; reproducing the server honestly means running a clean gdb -nx with the default safe-path, which is what the live box runs.

The second route needs no auto-loading at all, so the restrictive safe-path never comes into play.

The two routes side by side
Route Primitive Gated by auto-load safe-path? Works on the stock server?
.debug_gdb_scripts type 0x04 arbitrary GDB-Python Yes (declined by default) No (decline warning goes to stderr, which is dropped)
disassemble /s main + DWARF flag.txt read $cwd/flag.txt as source No Yes

The auto-load route is strictly more powerful but needs a permissive safe-path; the source route is weaker (just a file read) but needs nothing beyond default GDB behaviour and a known $cwd, which the service’s banner hands over.

The working route

GDB looks for source files along $cdir:$cwd (the binary’s recorded compilation directory, then GDB’s own current directory). GDB’s $cwd here is the service’s working directory, /tmp, exactly where the original flag.txt sits. So if the uploaded binary’s DWARF names its source file flag.txt, then disassemble /s main:

  1. passes the filter, since /s main is a space, a slash, and seven characters total;
  2. tells GDB to look up a source file literally named flag.txt;
  3. GDB resolves that name through $cwd and opens /tmp/flag.txt, the real flag;
  4. GDB prints that file’s contents as the “source” for main, on stdout, which the harness returns.

GDB does not search the directory the object file itself lives in for source, so the copy of flag.txt next to the uploaded a.out is irrelevant to this path. It does not need to be, since the original in /tmp is exactly where $cwd points.

Building the payload

The ELF just needs a source file whose name is flag.txt, with enough statements that the resulting DWARF line table covers several lines, so /s prints more than the file’s first line:

int main(){
  volatile int a=1; volatile int b=2; volatile int c=3;
  volatile int d=4; volatile int e=5;
  return a+b+c+d+e-15;
}

make_payload.py compiles this file, named flag.txt on disk inside a throwaway build directory, with gcc -g -gdwarf-4 -O0. I verified the resulting a.out in the handout directly: it is a 16728-byte ELF, its base64 is 22304 characters (under the 30720 cap), payload.b64 decodes to exactly those bytes, and the string flag.txt appears three times inside it (the DWARF DW_AT_name, the .debug_line file table, and the string table). The recorded compilation directory is DW_AT_comp_dir = /tmp/tmp_pumt72d/build:

$ python3 -c "import base64; raw=open('a.out','rb').read(); \
print(len(raw), base64.b64decode(open('payload.b64').read())==raw, raw.count(b'flag.txt'))"
16728 True 3

Since that throwaway /tmp/tmp_pumt72d/build path will not exist on the server, GDB falls back from $cdir to $cwd, which is exactly the fallback this exploit depends on.

Delivering it

The solver sends the base64 ELF at the first prompt and the string /s main as the function name, over TLS. One extra detail was needed to get a response at all: the endpoint sits behind a TLS front-end that routes by hostname (its certificate CN matches the instance hostname), so the client has to send SNI (server_hostname=host). Without it the handshake completes but no banner ever arrives, since the front-end has nowhere to route the connection; openssl s_client sends SNI by default, which is why testing with it looked fine while a naive Python client hung.

The flag comes back as a literal source line inside the disassembly dump, with no special marker, so the client reads until End of assembler dump. and pattern-matches FortID{...} out of the text:

$ python solve.py <instance-host> 1337 --ssl
...
Reading symbols from /tmp/tmprqtb86pd/a.out...
Dump of assembler code for function main:
flag.txt:
1	FortID{N0_5h3ll_n33d3D...
   0x0000000000001129 <+0>:	endbr64
   ...

GDB opened /tmp/flag.txt as the “source” for main and printed it on stdout as part of the disassembly. The flag’s wording (“no shell needed, just main”) matches the intended route being the plain disassemble /s main source read rather than the louder .debug_gdb_scripts Python vector. The live disassembly dump and the flag string come from the live run; the payload ELF, its DWARF, and the server logic above are verified directly from the handout files.

Defence

Disassembling an untrusted binary is not a read-only operation: disassemble /s opens whatever source file the binary’s DWARF names, resolved through GDB’s own working directory, so a crafted DW_AT_name turns “just show me the disassembly” into reading an arbitrary file in that directory. Running the analysis from a scratch directory with no secrets in it, or pinning a controlled source path, closes this off. Keeping auto-load safe-path at its restrictive stock default is still worth doing even though it is not the route that worked here, since it blocks the more powerful .debug_gdb_scripts vector; just do not let a pwndbg or gef dev box convince you that vector works everywhere, since those tools quietly loosen the same setting. And discarding a subprocess’s stderr, the way this harness discards GDB’s, throws away exactly the signal (a declined auto-load warning, here) that would have told an operator a dangerous path was blocked rather than simply unused.

The flagRevealFortID{N0_5h3ll_n33d3D_jU57_m41N_f101a10f3c}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.