Purgatory
Erlang hot code loading — the worker's local calls stay pinned to old.beam, but the fully-qualified `Mod:Fun` call is late-bound and lands in new.beam
NNS{50Ft_PUr6e_would_hav3_R34l1Y_5aved_y0U_7H3re} This challenge is part of a set written up together in
ctf/writeup.txt. Shared context for that set:All 6 “beginner” tier Reverse Engineering challenges were solved via pure static analysis (radare2 / objdump / Python), no interactive debugging required. Each binary encodes/XOR-obfuscates the flag with a small pseudo-random-number-generator (LCG) keystream, seeded and iterated with constants recovered from the disassembly. Recovering the flag is simply a matter of re-implementing the same PRNG in Python and applying it to the encoded bytes pulled straight out of the binary. Files: purgatory_runner.erl, releases/old.beam, releases/new.beam (networked instance, TCP+TLS, “passphrase>” prompt; correct answer prints the flag from the FLAG env var).
purgatory_runner.erl does, in order: load(OldPath) % code:load_binary(purgatory, old.beam) Worker = purgatory:boot() % spawns a worker process running old code load(NewPath) % code:load_binary(purgatory, new.beam) Worker ! {check, self(), Passphrase} % ask the running worker to validate
This is Erlang’s hot code loading in action (“purgatory” = stuck between
the old and new module version). The worker process was spawned while
only old.beam was loaded, so any local call it makes (validate/1,
first_half/1, check/2, the anonymous fun mask/2 closure it builds)
keeps running old.beam’s code forever, even after new.beam is loaded.
But purgatory:second_half(Input, Fun) inside old.beam’s own validate/1
is a fully-qualified module call (Mod:Fun(...)), which is always
late-bound and therefore dispatches to whatever is currently loaded —
i.e. new.beam’s second_half, once it’s been loaded. “We hot patched the
validator during business hours. Nothing crashed” = exactly this trick.
Disassembled both .beam files with beam_disasm:file/1 inside a disposable
erlang:28-alpine Docker container (no debug_info in the release, but the
raw BEAM bytecode is very readable). validate/1 requires a 25-byte binary,
splits it into first_half (bytes 0-12) and second_half (bytes 13-24), and
each half runs every byte through the same check:
(A * SafeFun(Idx) + B) mod 256 == C for a per-index tuple {Idx,A,B,C}
which is invertible: SafeFun(Idx) = (C - B) * modinv(A, 256) mod 256.
- first_half (always old.beam, confirmed via
dbg:tpl/3process tracing — old code is untraceable through a trace set on the “current” version, so no trace hits at all is itself diagnostic) uses SafeFun(Idx) = Input[Idx] directly (byte value, no mask) against OLD’s 13 {Idx,A,B,C} tuples. - second_half runs via a
call_extdispatch, and tracing confirms it’s NEW.beam’s version — it uses NEW’s 12 {Idx,A,B,C} tuples for bytes 13-24, but SafeFun(Idx) = Input[Idx] XOR mask(Idx), where mask(Idx) comes from a localfun mask/2closure created back inside OLD’s validate/1 — so the masking XOR table is OLD.beam’s mask/2 list, not new.beam’s, even though the affine constants being checked are NEW’s. This mixed old-mask/new-constants combination only becomes obvious by tracing a live simulation of the exact boot-then-check sequence locally (dbg:tpl on every purgatory:* function, watching which calls actually fire and what they return) rather than by static reasoning alone — the first guess (old first_half + new second_half entirely, using new.beam’s own mask list) gets “denied”.
Recovering all 25 bytes this way gives the passphrase
0ld_c0d3_w1n5_1n_th3_3nd!, which the live server accepts.
Flag: NNS{50Ft_PUr6e_would_hav3_R34l1Y_5aved_y0U_7H3re}
================================================= Remaining challenges (not solved)
- Small Guy (0xle, ~102 pts by the time of writing, decayed from 228 as more
teams solved it): Uses an extremely unusual obfuscation technique — the
real transform logic is hidden inside .eh_frame DWARF Call Frame
Information (CFI) “val_expression” opcodes, executed only as a side
effect of C++ exception stack-unwinding through 256 recursive frames.
Confirmed via
readelf --debug-dump=framesand pyelftools (down to raw DWARF opcode bytes): each of 32 “identical-looking” recursive call sites carries a distinct DWARF expression (add/mul/xor/rotate/embedded PRNG loops) that mutates one of rbx/r12/r13/r14 as the exception propagates, seeded by an “identity load” of the raw 32-byte input at the deepest frame. Fully reimplemented the algorithm (forward + inverse) in Python, but the simulator’s output does not match the real binary’s — pinning down the exact discrepancy needs live single-step tracing (gdb/ptrace), which is unavailable in this environment (Docker on Apple Silicon runs x86-64 binaries via QEMU user-mode emulation, and ptrace is not implemented there — confirmed via both strace and gdb attempts, even with —privileged). A fitting pun either way: “a small guy” = a dwarf. - Harald Blåtann (simen, 119 pts): nRF5340 (Zephyr/nRF Connect SDK) Bluetooth LE firmware image (Intel HEX, base 0x01000000, non-secure application core). Contains a decoy flag string and BLE scan/advertising logic (looking for a specific service UUID). Not solved — would need either real/emulated nRF52/nRF5340 hardware or a full BLE host stack simulation to interact with it “over the air” as the challenge intends.
Tools used: radare2, GNU objdump/binutils (greadelf via Homebrew), Python 3 for all keystream/XOR re-implementations and DWARF CFI interpretation, Docker (QEMU x86_64 emulation on Apple Silicon; erlang:28-alpine for BEAM disassembly and live process tracing via dbg:tpl) + a small LD_PRELOAD memcmp() shim for black-box oracle testing on Small Guy, openssl s_client for the TLS-wrapped Purgatory service.