Scenario
Rogat lost a blood-price dispute his clan should have won, and the rival warband that beat them didn't settle it themselves. They hired Rovan Kest's Iron Vultures to hold him and asked a ransom Sythra Crow-Eater's order was never built to pay, because paying it would mean her hostages have a price again, the exact thing she spent years teaching the southern clans to stop believing. She can't storm the camp without breaking her own rules in front of every clan watching to see if they still hold. She can't leave him either. So she does what she's never done before and asks someone outside her clans to make the problem disappear quietly. Rin gets inside the Iron Vultures' camp before Rogat gets sold to whoever bids highest for his silence. He isn't chained the way a giant should be. The rig holding him is old work, something built by a border people who don't have a name anymore, the kind of craft Garran Voss grew up around on the winter keep before Crownspire ever took him in. He taught her the shape of it once, half as history, half as a joke about a dead trade nobody would ever need again. She needs every piece of that joke to be true. Get Rogat out clean, and Sythra owes her people something no coin ever bought from her before: trust.
Enumeration
We begin by inspecting the challenge folder contents and identifying binary properties and protections using foundational CLI diagnostics: file, checksec, and ld.so --version.
Checking the version of the provided GNU C library reveals:
Initial Observations:
- Full Protections: The executable has Full RELRO, Stack Canaries, NX (No-Execute), and PIE (Position Independent Executable) enabled. It is also completely stripped, removing symbol names and debugging information.
- Custom Runtime Environment: The binary specifies
./glibc/ld-linux-x86-64.so.2as its runtime dynamic linker and embeds a customRUNPATH. It runs against Ubuntu GLIBC 2.39. - Memory Corruption Mitigations: Because Full RELRO and NX are present, overriding Global Offset Table (GOT) entries or executing raw buffer shellcode on traditional heap/stack allocations is strictly prohibited. We must find an intended executable memory allocation or Code Reuse / ROP vector.
Reverse Engineering & Code Analysis
To uncover the mechanics of the stripped executable, we perform structural analysis and static decompilation using rizin and objdump -M intel. Identifying string references inside .rodata provides instant context regarding the internal routines:
The string artifacts allow us to map and categorize every internal routine within the binary:
A. Anti-Analysis & Anti-Debugging Subsystems
Before processing user interaction, the program validates execution transparency across multiple helper functions:
| Address | Assigned Name | Behavior & Protection Mechanism |
|---|---|---|
0x1279 | read_tsc() | Executes x86 rdtsc (Read Time-Stamp Counter), assembling EDX:EAX into a 64-bit integer timestamp returned in rax. |
0x1295 | anti_debug() | 1. Checks getenv("LD_PRELOAD") and getenv("LD_AUDIT"). Exits if either string is found.2. Opens /proc/self/status, reads lines until finding "TracerPid:". If the trailing integer is non-zero (indicating gdb/strace/ltrace attachment), prints "Debugger detected!" and exits.3. Runs an empty calibration loop for 50,000 cycles using read_tsc(). If total cycles exceed 0x1dcd6500 (~500M cycles), prints "Timing anomaly detected!" and exits. |
0x1445 | timing_check() | Performs a secondary read_tsc() timing validation over a 10,000-iteration loop. Exits if cycle delta exceeds 0xbebc200 (~200M cycles). |
B. Shellcode Input Verification & Restrictions
When user inputs are delivered to executable pages, they must pass strict integrity validations handled by three dedicated verification routines:
Payload Constraints: All executed stages restrict shellcode inputs to exactly 5 bytes. The instructions cannot contain NUL bytes (0x00), newlines (0x0a), or breakpoints (0xcc). Furthermore, the very first byte of the instruction is strictly clamped to a stage-specific x86_64 branching opcode (0xe8 for CALL or 0xe9 for JMP).
C. Runtime Memory Introspection (get_libc_base)
At offset 0x159e, the binary implements a self-introspection function designed to identify its own dynamically linked libc base address in memory:
Vulnerability Analysis
The overarching core logic resides inside main (0x16d5). Rather than containing an accidental memory corruption flaw (e.g., buffer overflow or format string vulnerability), the challenge is meticulously structured as a two-stage state machine designed to evaluate precision exploitation.
Why the Architecture is Exploitable:
1. Deterministic Page Placement in Stage 1
During Stage 1 (stage == 0), main computes an mmap address hint equal to main + 0x10000 (PIE_base + 0x116d5) and requests a 4KB allocation with permissions PROT_READ | PROT_WRITE | PROT_EXEC (7).
- Because the binary footprint spans only
0x0000through0x5000in virtual memory, the kernel rounds the requested hint down to the nearest page boundary (0x116d5 & ~0xfff=0x11000). - Since
PIE_base + 0x11000is guaranteed to be completely unallocated in memory, Linux consistently allocates our executable staging buffer at exactlyPIE_base + 0x11000. - Consequently, the exact distance between our allocated staging buffer and any code within the PIE binary is completely static and independent of ASLR!
2. Handcrafted Register Conditioning in Stage 2
When main prepares to branch to our Stage 2 payload, it explicitly clears and assigns critical registers and stack addresses:
We inspect the available one-gadgets inside ./glibc/libc.so.6 using one_gadget:
Notice the author's deliberate register state manipulation! Setting rax = 1 and r12d = 0xdead intentionally invalidates gadgets 0x583ec, 0xef4ce, and 0xef52b. However, explicitly setting rcx = 0, rbx = 0, and rsp & 0xf == 0 precisely fulfills 100% of the preconditions required to execute gadget 0x583f3!
Exploitation
Stage 1 Payload: Returning to Main
In Stage 1, we must provide a 5-byte instruction starting with 0xe8 (call rel32).
- If we execute a normal system or library routine,
callpushes the return address (mmap_addr + 5) onto the stack. Becausemmapinitializes anonymous memory to zeroes, returning tommap_addr + 5will execute0x00 0x00(add BYTE PTR [rax], al), resulting in a fatal segment violation (SIGSEGV) at page boundaries. - Therefore, we must branch to a target that re-hijacks execution flow without returning.
- By executing a relative
callback to the entry point ofmain(PIE_base + 0x16d5), the program executesmaina second time. Becausefork_flag(0x502c) andstage(0x5030) were both toggled to1during Stage 1,mainbypasses the initialfork()routine and directly transitions into the Stage 2 handler!
Calculating Stage 1 Relative Offset:
Representing as a 32-bit unsigned two's complement integer yields 0xffff06d0.
- In little-endian encoding, our 5-byte instruction becomes:
\xe8\xd0\x06\xff\xff. - Integrity Check: The bytes
0xe8,0xd0,0x06,0xff,0xffcontain zero occurrences of forbidden bytes0x00,0x0a, or0xcc!
Stage 2 Payload: Leaping to one_gadget across PID Entropy
When main enters Stage 2, it dynamically resolves libc_base and maps an executable page with MAP_FIXED at:
- In this stage, our instruction opcode must begin with
0xe9(jmp rel32). - We desire our jump target to land exactly on our validated
one_gadgetatlibc_base + 0x583f3. - Let us construct the mathematical equation for the required 32-bit relative offset:
Notice how
libc_basemathematically cancels out on both sides of the subtraction:
Overcoming ASLR Entropy
Because the target address calculation incorporates (getpid() & 7), the exact relative jump distance depends on the lower 3 bits of the freshly spawned child Process ID.
- There are precisely 2^3 \= 8 possible variations for
(pid & 7), spanning integers0through7. - None of the 8 potential relative offsets produce forbidden bytes in little-endian notation:
- When
pid & 7 == 0Offset0x010583eePayload\xe9\xee\x83\x05\x01 - When
pid & 7 == 7Offset0x0105f3eePayload\xe9\xee\xf3\x05\x01
- When
- Because 3 bits representing 1-in-8 odds is trivial, our automated solver script simply connects in a retry loop, cycling assumptions for
pid & 7until execution succeeds and pops an interactive/bin/shshell!
Exploit Script (exploit.py)
Execution
Running the solver against the official remote Hack The Box instance successfully attains code execution and displays the flag: