← Back to Writeups
CUSTOMEasyForensics

Forenser Retaddr

XESXOR9/2/20266 min read
#forensics#custom#easy#argv_envp_recognition#auxv_parsing#frame_pointer_reconstruction#glibc_startup_semantics#qword_alignment_scan#saved_rbp_chain_walking#stack_memory_analysis#linux

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
0x7ffc1efd66f0argc = 1
0x7ffc1efd66f8argv[0] -> 0x7ffc1efd8473 → string "./tickets.elf"
0x7ffc1efd6700NULL (end of argv)
0x7ffc1efd6708..0x7ffc1efd88a0envp[] 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
result = 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

  1. Parse the kernel-built image at the top to confirm layout and find AT_PHDR (binary base).
  2. Scan the dump for 8-byte-aligned qword pairs (rbp_slot, ret_slot) where:
    • rbp_slot looks like a stack address pointing upward (higher than the current slot), AND
    • ret_slot looks like a code pointer (into libc 0x7f.. or into the ELF 0x40....).
  3. The outermost such frame (highest rbp in the chain) whose return address lands in libc is main's frame.
  4. 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
curl -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 _start inside 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_main did not save a chained rbp for main.
    • [rbp + 8] = REDACTED — main's saved return address, pointing into libc (right after the call *%rax inside __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
#!/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)

Framerbpsaved rettarget
inner helper0x7ffc1efd65a00x401f39ELF (user code)
main0x7ffc1efd6600REDACTEDlibc (__libc_start_call_main)
(saved at)0x7ffc1efd66d80x40097aELF _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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍