Forenser Retaddr
Forenser Retaddr
Platform: spbctf | Category: Forensics | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-04-18 | Status: Solved Techniques: argv_envp_recognition, auxv_parsing, frame_pointer_reconstruction, glibc_startup_semantics, qword_alignment_scan, saved_rbp_chain_walking, stack_memory_analysis
Summary
Task: given a raw x86_64 Linux stack VMA dump, find the return address of main. Solution: parse argv/envp/auxv to anchor the layout, walk the saved-rbp/return-address chain from the top of the live stack, and identify main's frame whose return address points into libc (__libc_start_call_main wrapper introduced in glibc 2.34), not into _start.
Forenser Retaddr — spbctf
Description
Привет! Держи файл: pid-4018-7ffc1efb8000-7ffc1efd9000 Это дамп стека процесса. Какой адрес возврата у функции, которая вернётся последней (т.е. у main)? Может быть странно и неожиданно
English summary (translated from Russian): we are given a raw memory dump of the stack region of a running Linux x86_64 process (PID 4018). The filename encodes the VA range 0x7ffc1efb8000–0x7ffc1efd9000, and the file size (0x21000) matches that range exactly — so the file is a straight copy of the [stack] VMA. The goal is to recover main's saved return address. The author hints that the answer will be "strange and surprising".
This challenge is the sibling of Forenser Canary (20260418_spbctf_forenser_canary) — it uses the exact same dump; only the target value differs.
Analysis
Confirming this is the process stack
The filename pattern pid-<PID>-<VAstart>-<VAend> is the canonical way to name a VMA extracted from /proc/<pid>/mem, and VAend - VAstart == 0x21000 == filesize confirms it. Reading the top of the dump we immediately see the kernel-built startup image that Linux lays out for every new process:
| VA (top of stack) | Content |
|---|---|
0x7ffc1efd66f0 | argc = 1 |
0x7ffc1efd66f8 | argv[0] -> 0x7ffc1efd8473 → string "./tickets.elf" |
0x7ffc1efd6700 | NULL (end of argv) |
0x7ffc1efd6708..0x7ffc1efd88a0 | envp[] pointer array |
0x7ffc1efd68b0+ | auxv (AT_SYSINFO_EHDR, AT_PAGESZ, AT_PHDR=0x400040, ...) |
0x7ffc1efd8458+ | string pool: env strings, _=./tickets.elf etc. |
AT_PHDR = 0x400040 tells us the ELF program-header table sits at 0x400040, i.e. the executable is loaded at 0x400000 and is non-PIE. So any return address that points into 0x400000..0x403??? lives inside the binary, anything in 0x7f........ lives in libc/ld, and anything in 0x7ffc........ is a stack pointer.
The startup call chain on modern glibc
The key insight is that since glibc 2.34 the startup sequence is:
_start
└── __libc_start_main (libc)
└── __libc_start_call_main ← NEW wrapper, added in 2.34
└── main
__libc_start_call_main is a tiny wrapper whose whole body is essentially:
c read-only • copy allowedresult = main(argc, argv, envp); exit(result);
It was introduced so that __libc_start_main doesn't keep a frame alive across main — this enables better noreturn handling and cleaner stack unwinding. The practical consequence for this challenge: main's saved return address points into libc, not into _start in the binary. That is the "strange and surprising" part.
Additionally, __libc_start_call_main is compiled without a frame pointer chain — it does not push %rbp; mov %rsp,%rbp in the classical way — so the 8-byte slot at [main_rbp+0] that would normally be "saved rbp of caller" contains arbitrary leftover data rather than a valid stack pointer.
Strategy
- Parse the kernel-built image at the top to confirm layout and find
AT_PHDR(binary base). - Scan the dump for 8-byte-aligned qword pairs
(rbp_slot, ret_slot)where:rbp_slotlooks like a stack address pointing upward (higher than the current slot), ANDret_slotlooks like a code pointer (into libc0x7f..or into the ELF0x40....).
- The outermost such frame (highest rbp in the chain) whose return address lands in libc is main's frame.
- Cross-check using the canary
0x7a46f2eaf04f8700(recovered from the companion challenge): it should sit at[main_rbp - 8], confirming we picked the right frame.
Solution
Step 1 — fetch the dump
bash read-only • copy allowedcurl -O https://pwn.spbctf.ru/files/canary/pid-4018-7ffc1efb8000-7ffc1efd9000 python3 -c "print(hex(0x7ffc1efd9000 - 0x7ffc1efb8000))" # 0x21000 wc -c pid-4018-7ffc1efb8000-7ffc1efd9000 # 135168 = 0x21000
Step 2 — anchor the layout with argv/envp/auxv
Reading argc at offset (0x7ffc1efd66f0 - BASE) gives 1, argv[0] points to a string ending ./tickets.elf, and auxv contains AT_PHDR=0x400040 — so binary base is 0x400000, non-PIE.
Step 3 — walk the rbp/ret chain
The 8-byte slot immediately before each frame's saved rbp is the saved return address of the previous frame. Between _start and main we find:
- At
0x7ffc1efd66d8→0x40097a— this is the saved return address of__libc_start_main's call, pointing back into_startinside the ELF. This is the "obvious" candidate that beginners mistake for main's return address. - Below that sits
__libc_start_call_main's frame (no rbp chain link — modern glibc doesn't maintain one here). - Below that sits main's frame:
rbp = 0x7ffc1efd6600[rbp + 0] = 0x402030— NOT a stack pointer; this is leftover data, because__libc_start_call_maindid not save a chained rbp for main.[rbp + 8] = REDACTED— main's saved return address, pointing into libc (right after thecall *%raxinside__libc_start_call_main).[rbp - 8] = 0x7a46f2eaf04f8700— the glibc terminator canary (matches the Forenser Canary answer), confirming this is a-fstack-protector-instrumented frame, i.e. main itself.
Step 4 — reproducible solver
python read-only • copy allowed#!/usr/bin/env python3 """Recover main's return address from a raw x86_64 stack dump.""" import struct BASE = 0x7ffc1efb8000 END = 0x7ffc1efd9000 with open("pid-4018-7ffc1efb8000-7ffc1efd9000", "rb") as f: data = f.read() assert len(data) == END - BASE def qread(addr: int) -> int: return struct.unpack_from("<Q", data, addr - BASE)[0] def in_stack(q): return BASE <= q < END def in_libc(q): return (q >> 40) == 0x7f and not in_stack(q) # Find all candidate frames: saved_rbp (-> stack, upward) followed by ret -> libc. candidates = [] for off in range(0, len(data) - 16, 8): rbp_val = struct.unpack_from("<Q", data, off)[0] ret_val = struct.unpack_from("<Q", data, off + 8)[0] rbp_here = BASE + off if in_stack(rbp_val) and rbp_val > rbp_here and in_libc(ret_val): candidates.append((rbp_here, rbp_val, ret_val)) # main is the outermost frame whose return goes to libc: highest rbp_here. main_frame = max(candidates, key=lambda c: c[0]) rbp, saved_rbp, ret = main_frame print(f"main's rbp = {rbp:#x}") print(f"main's saved_rbp = {saved_rbp:#x} (not a real chain link on glibc >= 2.34)") print(f"main's return addr = {ret:#x}") # -> main's return addr = REDACTED
Frame chain summary (deepest → outermost)
| Frame | rbp | saved ret | target |
|---|---|---|---|
| inner helper | 0x7ffc1efd65a0 | 0x401f39 | ELF (user code) |
| main | 0x7ffc1efd6600 | REDACTED | libc (__libc_start_call_main) |
| (saved at) | 0x7ffc1efd66d8 | 0x40097a | ELF _start (from __libc_start_main's call) |
| |
Why the answer is "strange and surprising"
Intuitively, main should return to _start. On pre-2.34 glibc it effectively did — __libc_start_main called main directly and main's return address was inside libc but very close to the obvious call main; mov %eax,%edi; call exit pattern. On glibc 2.34+ the call is routed through an extra wrapper __libc_start_call_main, whose whole purpose is to own main's frame so that __libc_start_main can be marked cleaner / noreturn-friendly. That wrapper:
- lives inside libc, so main's return address points to a
0x7f........libc address (not the binary), - does not maintain a classical rbp chain, so the slot at
[main_rbp+0]is not a valid saved rbp — it's whatever was there before (here:0x402030).
So the return address looks alien (not in the ELF, not in _start), the frame-pointer chain appears to "break" at main, and yet this is perfectly normal behavior on every recent distribution.