07CTF 2026
I played 07CTF 2026 with Hor1zon and came away with three very different solves. Glitched chains x86 edge cases into a five-stage verifier; Sloppow Knight turns movement bugs into complete, server-accepted replays; Discord Friend only falls into place after historical DNS connects the disk evidence to a missing HTTP response.
| Challenge | Main idea | Reproduction files |
|---|---|---|
| Glitched | Encrypted code, x86 edge cases, signal handlers | Solver and notes |
| Sloppow Knight | WASM/native simulation, replay search, Any% and Low% | Routes and verifier scripts |
| Discord Friend | Disk forensics, response timing, NCACHE1 decryption | Blob and offline decryptor |
All screenshots below come from local reruns of the saved files. Scores and other online observations are from the original September 19–20 solve.
Glitched
The attachment is a single ELF named glitch. It prompts for stage 1>, then stage 2>, and exits on a wrong answer. What first looked like four independent checks is really a key chain: each answer decrypts the next code layer, and stage 4 opens a much larger fifth verifier whose signal handlers are part of the computation. The original solve and native check were completed on September 19; the solver screenshot near the end is an offline rerun made for this post.
Triage
| Property | Value |
|---|---|
| File | glitch |
| Size | 207176 bytes |
| Format | ELF64, little-endian, x86-64, ET_EXEC |
| Entry point | 0x401320 |
| Main function | 0x401040 |
| Environment checks | SSSE3; 4096-byte memory pages |
| Analysis tools | IDA Pro 8.4, Python, Z3, Capstone |
| Native verification | WSL Ubuntu 24.04, glibc 2.39 |
| SHA-256 | f18ae7d5232bfe904b1ed31b7921e19ca491bb052a6bcd4ab06bbf7022481188 |
There is no plaintext flag to extract from the initial file. Visible strings include stage 1> through stage 4>, locked, and messages about CPU features and page size. Imports such as sigaction, sigaltstack, mprotect, munmap, and mincore suggest that exception context and memory mapping will matter later.
The main routine reads hexadecimal input, calls the current validator, and derives a key to decrypt the next layer. Consequently, disassembling the initial bytes at 0x405000 and later encrypted regions produces instructions from ciphertext, not the actual program.

The four visible stages unlock the hidden fifth stage. A root record ultimately controls flag release.
| Input | Validator | Manifest | Decrypted regions: VA / length |
|---|---|---|---|
| Stage 1: 16 bytes | 0x404000 |
0x430700 |
0x405000 / 0x1000 |
| Stage 2: 24 bytes | 0x405000 |
0x430560 |
0x406000 / 0x1000 |
| Stage 3: 8 bytes | 0x406000 |
0x4303C0 |
0x407000 / 0x1000, 0x408000 / 0x1000 |
| Stage 4: 17 bytes | 0x407000 |
0x430220 |
0x409000 / 0x10000, 0x419000 / 0x1000, 0x41A000 / 0x14000, 0x42E000 / 0x1000 |
Addresses in this writeup are virtual addresses in this ET_EXEC sample. For the regions accessed by the solver, file_offset = VA - 0x400000. This mapping is a property of this binary, not a general ELF rule.
Recovering each code layer
After decrypting each layer, I wrote the recovered bytes into a separate analysis copy and reloaded it in IDA so the next validator had normal disassembly and cross-references.
Stage 5 needed a control-flow correction. The UD2 at 0x419008 raises SIGILL; its handler advances the saved RIP, and the sequence eventually returns. IDA treated the call as non-returning and hid much of the caller after 0x409804. Marking the entry as returning and reanalyzing the caller recovered it. This changed only the IDB; the native check used the original ELF.
Extracting the loader
0x402FA0 performs SHA-256-based domain-separated hashing. The loader at 0x402B10 walks a segment manifest and calls the authenticated-decryption wrapper at 0x4019F0. The constants, rounds, and rotations in 0x401B60 identify ChaCha20; 0x402410 and 0x402120 implement the associated Poly1305 authentication operations.
Let I_i be the input to stage i and T_i its serialized machine observations. The key chain is:
1 | |
|| means concatenation of raw bytes. Domain strings have no implicit NUL terminator; integers are little-endian. Input bytes, observed register values, and flags must remain distinct. Hashing their printable hexadecimal representations would produce different keys.

Each segment is authenticated independently. The AAD binds its address, type, length, and stage metadata.
A manifest begins with four u32 values: version, stage, count, reserved. Each following entry is 48 bytes:
| Entry offset | Size | Meaning |
|---|---|---|
0x00 |
8 | Target virtual address |
0x08 |
4 | Length |
0x0C |
1 | Segment type |
0x0D |
3 | Reserved |
0x10 |
12 | Nonce |
0x1C |
16 | Poly1305 tag |
0x2C |
4 | Reserved |
The AAD constructed at 0x402AA0 is exactly 45 bytes:
1 | |
prefix16 comes from 0x430890. Authentication is also a candidate filter: an input can satisfy the visible arithmetic yet produce an incorrect key if the observed flags or transcript were reconstructed incorrectly.
Stage 1: PABSD and INT_MIN
The helper at 0x404180 loads four 32-bit words, executes pabsd, and uses movmskps to extract the four sign bits. The caller requires mask 4, meaning that only lane 2 keeps its top bit set.
For an ordinary negative signed integer, taking the absolute value clears that bit. The exceptional 32-bit value is:
1 | |
Therefore x2 = 0x80000000. Write Ai = abs32(xi). The remaining constraints are:
1 | |
All equations are modulo 2^32. The second equation uses A3, not A2: IDA’s local-variable numbering is not the vector-lane numbering.
The two SIMD operations can be expressed in Python. Truncating each lane to 32 bits is essential:
1 | |
Negating and truncating 0x80000000 leaves the same bit pattern, which explains lane 2.

INT_MIN keeps the sign bit in lane 2; arithmetic constrains the other three lanes.
Z3 bit-vectors retain the required machine semantics:
1 | |
For each model, reconstruct the actual ADD flags CF/PF/AF/ZF/SF/OF. Serialize the four absolute values, mask, and flags, then attempt Stage 2 authentication. The accepted result is:
1 | |
Here x3 = 0xDB1F383A and A3 = 0x24E0C7C6. The program also derives H1 = SHA256("GLITCH/TRANSCRIPT1" || I1 || T1) for Stage 2.
Stage 2: making CMPXCHG fail
Stage 2 reads three u64 values and constructs:
1 | |
The important instruction at 0x405240 is lock cmpxchg [rdi], rdx. Its initial state is RAX=x0, [rdi]=S, and RDX=x1. The validator requires the returned RAX, original memory, and final memory all to equal S.
If the comparison succeeds, CMPXCHG sets ZF=1 and writes the replacement value. The caller’s flag check disallows ZF. The intended route is x0 != S: the comparison fails, S is copied into RAX, memory stays S, and ZF=0.

The instruction’s comparison must fail for the stage’s validation to succeed.
The remaining 64-bit modular constraints are:
1 | |
The variable rotation uses only the low six bits of its count, as on x86-64. Solve the first two relations, derive x0, and check the final relation and authentication tag:
1 | |
Stage 3: building a quiet NaN
The decompilation at 0x406170 obscures where the flags come from, so I worked from the instructions:
1 | |
The eight input bytes are interpreted as a double bit pattern. Comparing a NaN with zero produces an unordered result, setting CF=PF=ZF=1. The following CVTTSD2SI does not replace these integer flags. With the program’s MXCSR=0x1F80, the invalid floating-point conversion is masked and EAX becomes the integer-indefinite value 0x80000000.
The caller additionally requires a positive sign, an all-ones exponent, a set quiet bit, and a nonzero low 51-bit payload. An arbitrary NaN is therefore insufficient.

The unordered comparison supplies 0x45; the masked invalid conversion supplies 0x80000000.
Split the low 51-bit payload as follows:
1 | |
Enumerate high=0..15, use the modular inverse of 21271 to find mid, then calculate low and discard values that do not fit in 31 bits. Authentication selects:
1 | |
This unlocks both the Stage 4 validator and the page containing its indirect-call targets.
Stage 4: changing one pointer byte
At 0x407190, a 16-byte local buffer is immediately followed by a function pointer initialized to 0x408042. A rep movsb copies 17 bytes into the buffer.
The first 16 bytes fill the buffer; byte 17 replaces the pointer’s least significant byte. On this little-endian machine, a final input byte of 0x93 changes the pointer to 0x408093. That address jumps to 0x408180, which returns the required constant 0xAD96B638CDA28056.
Keeping just the stack layout and copy operation gives this equivalent model:
1 | |
The first 16 bytes still satisfy the arithmetic below, while the last byte independently selects the call target. I solved the two parts separately.

The overwritten object is a local function pointer, not a return address. Its upper seven bytes are preserved.
The first 16 input bytes, interpreted as four u32 words a, b, c, d, must satisfy:
1 | |
The second relation has an important width detail. The assembly zero-extends c into RAX, performs a 64-bit rol rax,3, and consumes EAX. Its low 32 bits are equivalent to c << 3, not ROL32(c,3).
Solve the four words with Z3 and append 0x93:
1 | |
This stage derives both K5 and H4 = SHA256("GLITCH/TRANSCRIPT4" || K4 || I4 || T4). K5 unlocks the final code and data. H4 participates in the runtime selector calculation.
Stage 5: following the signal handlers
The final verifier begins at 0x4094A0; its decrypted data includes stage 5>, /proc/self/stat, /proc/self/maps, and several GLITCH/STAGE5/... domains. The tangled decompilation becomes manageable once split into three parts: a fixed SIGILL tape, a runtime-dependent input mask, and a Feistel network reconstructed offline for each selector.
A SIGILL instruction tape
Beginning at 0x419008 are twenty eight-byte records. Each starts with 0F 0B, the UD2 encoding, followed by six encrypted instruction bytes. The SIGILL handler at 0x4147A0 verifies the fault location and sequence number, decodes the record, updates four 32-bit state words, and advances the saved RIP by eight bytes.
A decoded record contains an opcode, a 32-bit immediate, and a checksum byte. Opcodes 1, 2, 3, and 4 perform addition, XOR, rotation, and multiplication by an odd value; the last opcode is 0x7F. The chained byte decoding is:
1 | |
The seed at 0x427D20 also supplies the initial four state words. After executing all twenty records:
1 | |

SIGILL generates the fixed tape. SIGSEGV synthesizes bytes for selected memory-page accesses.
Thirty-two pages, three access behaviors
The program creates 32 pages of 4096 bytes, or 0x20000 bytes in total. A 64-bit xorshift generator fills them, after which 13520 encoded entries at 0x41A940 overwrite selected bytes. A 32-byte table at 0x427CC0 assigns page classes:
| Class | Mapping state | Source of the read value |
|---|---|---|
| 0 | Readable | Actual initialized page data |
| 1 | PROT_NONE |
Permission fault; handler computes a virtual byte |
| 2 | Unmapped with munmap |
Mapping fault; handler computes a virtual byte |
The SIGSEGV handler at 0x414980 does more than suppress a crash. It checks the round, access class, fault address, and faulting instruction, calculates a value, and modifies the saved RAX and RIP. Making every page readable would change the computation.
Runtime selector and input mask
Let seed be the 16 bytes at 0x427D30. The program uses its PID, the starttime field of /proc/self/stat, and the starting address of the [stack] mapping shifted right by 12. The PID transformation reverses its decimal digits and multiplies by ten: PID 473 becomes 3740.
1 | |
The fifth input is process-dependent. Offline recovery only needs to enumerate 32 selectors; native verification must calculate the input for the current process.
Eight reversible ARX rounds
The normalized input is split into four u32 words a, b, c, d. Sixteen constants at 0x427CE0 provide k0 and k1 for each of eight rounds:
1 | |
To invert, visit the round constants in reverse order:
1 | |
The 32-round Feistel function
The ARX output becomes two u64 halves L and R. Round n has the standard Feistel form:
1 | |
Reversing the network is easy; F is the complicated part. The solver calls its four variants mix(n, x, y, z, domain), selected by n % 4. The attached implementation contains every constant and truncation rule.
The main data flow within a round is:
1 | |
All 64-bit arithmetic wraps modulo 2^64. PAGE, FAULT, ROUND, and SHARE are distinct domain constants. The memory-read helpers are at 0x415590, 0x4156A0, 0x4157B0, and 0x4158C0. During analysis, GDB round observations were compared with Python for the page, offset, class, tk, packed value, tag, and returned byte.
Reverse the target, then replay the observations
Given the next state, reversing a round requires only:
1 | |
Starting from the target, walk backward through all 32 rounds; there is no need to invert mix or brute-force 128 bits. Replaying that state forward produces the transcript and shares used by the release key.

Reaching the target is an intermediate result. The release key also binds every round’s observed path.
Each transcript record is exactly 46 bytes:
1 | |
R genuinely appears twice in the binary’s serialization. Preserve both copies. The two digests are:
1 | |
Finding the root record that passes
The table at 0x41A320 contains 48-by-32 interleaved bytes, with one column per selector. The 48-byte derivation routine at 0x409320 reduces to:
1 | |
Here blake2s denotes a function returning the 32-byte digest. Read stored[i] = memory[0x41A320 + 32*i + selector], then derive:
1 | |
So matching the Feistel target is not sufficient. A selector may yield a target and input while its reconstructed root key still fails the later checks.
There are three root records. Their lengths are at 0x41A140; their bytes are interleaved three ways at 0x41A160. The program selects a record with:
1 | |
Use BLAKE2s with digest_size=8. Truncating a default 32-byte BLAKE2s digest is different because the digest length affects initialization.
Each encrypted record has layout nonce[12] || aad_len:u16 || ct_len:u16 || AAD || ciphertext || tag[16]. Decryption yields this 44-byte structure:
1 | |
The program requires magic 0x35524C47 (little-endian ASCII GLR5), version 1, flags 0, a digest matching the 32 bytes at 0x41A120, and flag_length 35.

Three selectors can authenticate some root record; only selector 28 yields zero flags. The native program still uses the SELECT hash and checks every required field.
| Selector | Authenticated record | Version | Flags | Result |
|---|---|---|---|---|
| 11 | 2 | 1 | 2 | Rejected: nonzero flags |
| 30 | 1 | 1 | 1 | Rejected: nonzero flags |
| 28 | 0 | 1 | 0 | Accepted; continue to flag decryption |
I enumerated all 32 selectors and tested every root candidate with authenticated decryption; plausible-looking plaintext alone was not enough.
Decrypting the flag
These selector-28 values are useful checkpoints for an independent implementation:
1 | |
The final key binds the root key, target, both execution digests, and root digest:
1 | |
The 35-byte flag ciphertext is at 0x41A080, its 12-byte nonce at 0x41A100, its 60-byte AAD at 0x41A0C0, and its 16-byte tag at 0x41A070. decrypt_and_verify returns:
1 | |
Running the complete solver
Offline recovery
The attached solve_glitch.py reads the original ELF, authenticates each layer with the recovered stage inputs and transcripts, then completes stage 5 and flag decryption offline. It needs neither Z3 nor IDA and does not execute the ELF.
1 | |
The relevant output is:
1 | |
To derive the first four inputs instead of reusing them, run the included derive_stages.py, adapted from the original analysis script; it additionally requires z3-solver.

Direct execution of the original program
Final native verification used no GDB input injection, code patch, or process-memory edit. The runner started an unmodified ELF copy, sent the four fixed inputs, waited for stage 5>, and read that child’s /proc state. It ended its own attempts whose selector was not 28. For selector 28, it inverted Feistel and ARX, applied the current runtime mask, and sent the resulting input through standard input.
The saved successful run was attempt 32, PID 473, exit status 0, with fifth input 457aadb75ddb69a9e2ae4b73681cf71b. These are observations from one execution, not values that will necessarily recur.
Gotchas
- Preserve 32-bit semantics for
abs(INT_MIN)instead of using mathematical absolute value on an arbitrary-precision integer. - Match signed bit-vector comparisons, word truncation, and rotation-count masks to the actual instructions.
- Stage 1 uses A3 in its second equation; Stage 4 rotates a zero-extended 64-bit value before reading its low 32 bits.
- Stage 3 observes flags from
UCOMISD, not from the conversion instruction. - Transcripts are exact binary layouts. Repeated fields, flags, and byte order all contribute to the keys.
- UD2 and page faults participate in normal execution. Revisit the decompiler’s non-returning-function assumptions after reading the handlers.
- Matching a Feistel target, authenticating a root record, and accepting its flags are separate conditions.
- Eight-byte BLAKE2s output is not a truncated 32-byte BLAKE2s output.
- The fifth input depends on the current process. Reusing the single recorded input will generally fail.
Appendix: addresses and intermediate values
| Address | Role |
|---|---|
0x401410 |
Read and parse hexadecimal input |
0x402FA0 |
Domain-separated SHA-256 derivation |
0x402AA0 |
Construct 45-byte segment AAD |
0x4019F0 |
ChaCha20-Poly1305 decryption wrapper |
0x4147A0 |
SIGILL tape handler |
0x414980 |
SIGSEGV virtual-read handler |
0x416360 / 0x4163F0 / 0x4164A0 |
BLAKE2s initialization, update, finalization |
0x409320 |
48-byte shard derivation |
0x41A320 |
48-by-32 interleaved release shards |
0x4144AA |
Final flag-decryption call site |
1 | |
Download the Glitched reproduction files.
Sloppow Knight
Sloppow Knight is a small platformer with a browser build, a WASM module, and a native verifier. The server scores button replays: Any% rewards speed, while Low% rewards fewer input changes. I extracted the simulation and searched routes. The final Any% replay finishes in 5.992 seconds; Low% uses 61 moves and 26.067 seconds. Both were first at my last check on September 20, with Low% tied at 61 moves.
The attachment
The README in sloppow-knight-player.rar states that the same files serve two KOTH leaderboards and one ordinary jeopardy challenge. It also explicitly gives the server seed: seed = 1. Challenge site: Sloppow Knight KOTH.
| Supplied file | Role in the analysis |
|---|---|
index.html, app.js |
Local interface, button mapping, fixed-step loop, and rendering calls |
pkg/koth_render.js |
wasm-bindgen wrapper |
pkg/koth_render_bg.wasm |
58,120-byte browser-side game simulation module |
verifier |
407,448-byte ELF64 / x86-64 native replay verifier |
server.py |
Local static HTTP server, not the scoring backend |
textures.js, audio.js |
Graphics and audio resources |
Rust source paths and runtime traces survive in the binary, but no Rust source was supplied. The JavaScript wrapper exposes the callable interface, and room_json() gives exact room geometry; I then compared the WASM behavior with the native ELF.

Two different objectives
Any% minimizes the native simulation’s finish_tick / 120. For normal replays, Low% minimizes changes in the input mask, rather than distance, frame count, or individual button presses.
1 | |
Holding one mask for 300 frames is still one Low% run. Any% can spend changes to gain speed; Low% may accept hundreds of frames to save one. A faster 61-move replay does not score higher. The next scoring improvement needs 60 moves or fewer.
Both final routes visit rooms in the same order:
1 | |
The route skips room 3 (Cellar) and room 5 (Shaft). Crossroads’ hidden left exit drops the player near the right side of Depths, which makes the later compression possible.
Replay format
A replay is a 26-byte little-endian header followed by variable-length RLE records. Each record holds a button mask and its duration; the file contains no game-memory edits.
| Offset | Length | Type | Value used and meaning |
|---|---|---|---|
| 0x00 | 4 | bytes | ASCII KSRP |
| 0x04 | 2 | u16 LE | Version 1 |
| 0x06 | 4 | u32 LE | Recorded seed, set to 1 |
| 0x0A | 8 | u64 LE | Declared total input frames, such as 720 |
| 0x12 | 4 | u32 LE | Metadata, set to 0; original meaning unconfirmed |
| 0x16 | 4 | u32 LE | Number of RLE records, such as 374 |
| 0x1A | Variable | records | ULEB128(duration) + u8(buttons) |
Durations use ULEB128: seven data bits per byte, with the high bit marking continuation. Thus 169 encodes as A9 01, and Right+Dash (mask 10) for 169 frames becomes A9 01 0A. Records are not fixed-width.
| Bit | Value | Action | Common combination |
|---|---|---|---|
| 0 | 1 | Left | 5 = Left+Jump |
| 1 | 2 | Right | 6 = Right+Jump |
| 2 | 4 | Jump | 10 = Right+Dash |
| 3 | 8 | Dash | 14 = Right+Jump+Dash |
| 4 | 16 | Interact / Attack | 26 = Right+Dash+Interact |
The native decoder at 0x192a0 checks magic, version, truncation, and trailing bytes. Limits are 1 MiB per file, 65,536 records, and 1,000,000 frames per duration; masks cannot exceed 0x1f. I found no CRC or content-hash check, so the unknown field at 0x12 should not be labelled one. The SHA-256 values below identify our output files only.
The header seed does not select the world: World::new receives the verifier’s command-line seed. Editing the replay header cannot choose a different server world.
WASM and native disagree
For enumeration, I called wasmgame_new, wasmgame_set_input, and wasmgame_step directly from Node instead of driving browser keys. Each candidate starts from a known state; saved WASM memory lets a branch resume without replaying from frame one.
Browser completion does not guarantee server acceptance. With seed 1, native room 2 spawns three enemies, while the original browser WASM behaves differently; routes that ignore this can fail natively.
Local patch_wasm.py adjusts only that enemy-spawn branch and produces native_like.wasm; it changes neither the server nor the upload. Because this does not prove full equivalence, final candidates still pass the native and server checks below.
| Layer | What it establishes | Limitation |
|---|---|---|
| Adjusted local WASM | Fast screening, frame inspection, and broad search | No proof of full native-program equivalence |
| Original ELF machine-code replay | Completion using the real native world initialization and step | The function-level script omits full CLI decoding and timeout logic |
| Server upload verification | Acceptance of the actual BIN, with tick, moves, and historical rank | Other teams’ submissions can change the ranking |
On Windows, Unicorn loads the original ELF’s segments and relocations and executes World::new at 0x1a170 and World::step at 0x1a330, with stubs only for allocation and memory operations. The checker reads completion at native world +0x125, finish tick at +8, HP at +0xc0, and deaths at +0x120; it does not reimplement the physics in Python.
Search state must capture more than the picture
| WASM world offset | Field | Why it matters |
|---|---|---|
| +32 / +36 | x / y | Collision and door position |
| +40 / +44 | vx / vy | Momentum carried across rooms |
| +48 / +64 | facing / Dash direction | Distinct values affecting later velocity updates |
| +52 / +56 | coyote / jump buffer | Delayed and automatically triggered jumps |
| +60 / +68 | Dash / cooldown | Dash and speed-preservation timing |
| +84 / +112 | air / RNG | Ground/air speed branches and later random spawns |
These are local WASM offsets from base = g + 8, not native-world offsets. My first deduplication used only buttons & 12; it merged states with different facing and discarded a useful braking route. The search key must retain input history, facing, coyote state, and world state.
Dash reversals
The game runs at 120 Hz. In simulation coordinates, the player’s collision box is 600×900. Ordinary directional ground movement is roughly capped at |vx| ≤ 52; gravity adds 30 per frame, a normal jump sets vy = -480, and a spring sets vy = -720. Dash lasts 18 steps and has a 36-step cooldown.
Starting a Dash in the current direction adds approximately 165 to existing horizontal velocity before friction. For example, starting from vx=950 gives about 1114, rather than resetting velocity to a fixed Dash speed.
Reversing early during a Dash includes an update of this form, with the sign matching the new direction:
1 | |
After same-frame friction, a typical early reversal adds about 159 to absolute speed. The window corresponds to an internal Dash count of at least 13; later reversals enter delayed-turning and braking branches instead.
For example, the final route’s third endgame Dash starts from vx=1316. It reaches 1480 on frame 699, then reverses on frames 700, 701, 703, and 704, ending at 2115. Frame 702 continues holding Right. This is the actual replay timing, not unconditional reversal every frame.
High speed alone is insufficient
Directional ground input clamps velocity, while air input and neutral ground coasting take different branches. If air is clear when Dash expires, four-digit velocity can collapse to 52. Doors reset position but retain some movement state, so ranking by x alone loses routes; height, velocity, air state, and cooldown all matter.
Jump, Dash, and Interact trigger on rising edges. A held button is not a fresh press, which matters for both Low% counting and switches.
Coyote jumps and landing without losing speed
Coyote-jump state survives leaving a platform
When air=0 and coyote>0, the world update skips the airborne branch, but coyote time only counts down inside that branch. The player can therefore walk off a platform and fall while retaining air=0,coyote=7 until Jump is pressed.
The state flag is now detached from geometry; both native code at 0x1a73c-0x1a76a and WASM show it. Neutral input loses only 8 horizontal speed per frame, while directional input can invoke the ground clamp. The final route coasts off the platform before jumping, preserving upward velocity into the next room.
Landing must match both frame and coordinate
The relevant order is: update jump buffer → update Dash horizontal velocity and decrement its counter → if Dash expires, clamp using the old air flag → process Jump → perform world collision → write ground-contact state.
| State or timing | Outcome |
|---|---|
| Start with air=0, Dash=1; press Jump that frame | Velocity first clamps to 52, then the jump occurs; too late |
| Jump with Dash=2 or earlier | Establishes airborne state before expiration, avoiding the ground clamp |
| Start with air=1, Dash=1; feet contact the platform exactly after expiration | Can preserve high speed and gain air=0, coyote=7 |
| Contact one frame early | The next expiration reads air=0 and clamps speed |
| y is 1 unit above the target | A later gravity-driven landing triggers a different clamp path |
Room 13’s platform surface is at y=-1500; with a 900-high player, the top-left coordinate must be exactly y=-2400. Contact at vy=0 avoids the “new vertical landing” clamp. This works at that exact state, not on arbitrary landings.
I used synthetic states only to isolate update order and compare native with WASM. The submitted replay reaches the same conditions from the real seed-1 start using ordinary inputs.
Connecting the route
The table gives the number of inputs already executed when each room begins. The last row is an input count, not the official finish tick. Any%’s first 596 frames reach Spring Vault; the final 124 clear rooms 13–15.
| ID / Room | Any entry frame | Low entry frame | Route role |
|---|---|---|---|
| 0 Foyer | 0 | 0 | Establish initial jump and Dash state |
| 1 Hub | 34 | 216 | Take the upper route into Barracks |
| 2 Barracks | 93 | 535 | Fight the native seed-1 enemy configuration |
| 4 Crossroads | 140 | 729 | Use the hidden left exit |
| 6 Depths | 221 | 1059 | Spawn near the exit for a quick transition |
| 7 Sanctum | 237 | 1066 | Continue the main route |
| 8 Zigzag Ascent | 292 | 1170 | Link platform movement and Dash state |
| 9 Pit Gauntlet | 329 | 1394 | Cross the pits |
| 10 Tower Ascent | 387 | 1750 | Manage vertical routing and velocity |
| 11 Switch Crossroads | 474 | 1937 | Activate the required switch and enter Arena |
| 12 Arena | 569 | 2469 | Fight and prepare the next room’s Dash state |
| 13 Spring Vault | 596 | 2654 | Obtain the spring; the routes then diverge |
| 14 Hollow | 692 | 2744 | Control height above the floor hazard |
| 15 Throne | 709 | 2815 | Use tunneling or climbing for the final room |
| Complete | 720 | 3129 | Native finish ticks: 719 / 3128 |
Independently optimal rooms do not necessarily compose
Doors change position, but velocity, Dash frames, cooldown, air state, and current input carry forward. An entry one frame earlier with vx clamped to 52 can be far worse than a later one with four-digit speed.
Low% adds another boundary cost: continuing the same mask through a door is free, while releasing every button adds a run. Splice states must retain position, velocity, air, Dash, cooldown, coyote, jump buffer, previous input, switches, enemies, and RNG.
Any%: two Dashes in the spring room
The numbers here are from the final 720-frame file. An earlier branch hit the spring and landed at 611/678; the saved route uses 612/679.
Room 13 begins at frame 596 with vx=637, Dash=10, CD=28. Late-Dash direction changes brake the approach so that the spring activates only after Dash ends. Otherwise, Dash would quickly erase the spring’s upward velocity.
| Frame | Key state | Purpose |
|---|---|---|
| 612 | x3151, y-900, vx0, vy-720, CD12 | Actually acquire the spring’s vertical velocity |
| 625 | x3515, y-7530, vx52, CD0 | Accelerate right for 13 frames while cooldown expires |
| 626-643 | First Dash, ending at vx-358 | Freeze height and establish leftward velocity |
| 644-661 | Hold Left in free fall for 18 frames | Reach y-2400, vx-412, CD0 |
| 662-679 | Second Dash with early reversals | Touch the first platform on its last frame at vx1358 |
During collision-free spring ascent, displacement after t ordinary frames is -15·t·(47-t). At t=13, y moves from -900 to -7530. Dash then freezes height for 18 frames; 18 gravity frames add 15·18·19 = 5130, landing exactly at -2400.
The second Dash starts left and uses early reversals to finish facing right. On frame 679 it first touches the one-way platform exactly as Dash changes from 1 to 0: x9150,y-2400,vx1358,air0,coyote7.

Any%: the final two rooms
After exact contact, coast neutrally for 8 frames. Press only Jump on frame 688 to retain most horizontal velocity, then hold Right in the air. Room 14 begins at frame 692 with vx=1298, vy=-360, Dash=0, CD=5.
Room 14’s floor hazard starts at x3000, y=-500. With a 600×900 player, the feet must clear it before horizontal overlap; too much entry speed with too little upward velocity hits the hazard before the player rises far enough.
Continue right until frame 698 reaches vx=1316,y=-2430. Start the third Dash on frame 699. Its first six inputs are 10,1,2,2,1,2, reaching vx2115 at frame 704 and entering Throne at frame 709 with vx2110.
Crossing the first wall between collision endpoints
The first wall spans x7000..8500. Pressed against it, the player’s left coordinate is 7000-600=6400. If one update ends at or beyond the far edge, collision does not fully sweep the path. The step must cover at least 1500+600=2100; because friction acts first, starting vx=2100 is still insufficient.
At frame 712, the player is still at x6400 with vx2107, Dash4, and air0. Pressing Jump on frame 713 gives a horizontal displacement of 2106, putting the left coordinate at 8506, while y becomes -1380 and air1. The jump also establishes airborne state before Dash expires, preventing a clamp to 52 on frame 716.
The route passes under the second wall. Completion follows input 720, but the native finish tick is 719: 719 / 120 = 5.9916666667, displayed as 5.992 seconds. Using 720/120 = 6.000 seconds would be one frame off.


Low%: every input change counts
The final Low% replay has 3129 per-frame inputs but only 62 maximal runs, and it finishes inside the last run: 61 official changes. The native calculation compares finish tick with cumulative durations, so arbitrary files with trailing or redundant records cannot always use record_count - 1.
Four useful forms of compression
- Carry one mask across room boundaries. For example,
10×187spans rooms 0→1,10×358spans 1→2,18×219spans 2→4, and6×305spans 9→10. Passing a door does not itself require a new run. - Combine simultaneous actions in one mask. Mask 26 combines Right, Dash, and Interact; 22 combines Right, Jump, and Interact. This merges held-button states without turning edge-triggered actions into per-frame repetition.
- Deliberately trade time for fewer changes. Room 11 includes
26×337. Long waits and movement are acceptable when they save input runs. - Treat fresh rising edges as a resource. Room 13’s final mask6 begins on frame 2743 and continues into room 14, with Jump already held. Removing a run in the previous room can also remove a jump edge needed in the next one.
Low% still uses selected Dash reversals, but maximal speed rarely minimizes moves.
Combat and switches consume the same input budget
The final Low% route drops from HP3 to HP2 on frame 693 in Barracks, and to HP1 on frame 2640 in Arena, with no deaths. Required switch index 2 activates on frame 2133 from a fresh Interact edge. The player is then at top-left x3324, y-12400, while the switch center is (2500,-12200); the check uses the player’s center and a Manhattan-distance range of 1400.
Holding Attack while approaching a switch is unreliable: entering range without a new edge does nothing. Geometry, edges, and RLE boundaries have to line up.
Low%: overlapping platforms in the final room
The line tracks the player’s top-left corner. Its 600-unit width explains why the final x21946 already overlaps the finish region starting at x22500.
Throne’s one-way platform columns have a 500-unit gap, but the player is 600 wide. Keeping left x in (2400,2500) overlaps both columns. During ascent, the observed collision handling also snaps the player to higher platform surfaces and restores ground-contact state.
Frames 2842, 2851, 2857, and 2863 move y to -3900, -6400, -8900, and -11400. These jumps exceed ordinary vy integration while vy stays negative: collision handling is repositioning the player upward, rather than granting a normal “double jump.”
| Input after entering room15 | Duration | Absolute end frame | Purpose |
|---|---|---|---|
| 2 (Right) | 21 | 2836 | Extend the input already held on entry at no new-run cost |
| 4 (Jump) | 13 | 2849 | Reduce horizontal travel and enter the overlap zone |
| 1 (Left) | 4 | 2853 | Release Jump to prepare another rising edge |
| 6 (Right+Jump) | 13 | 2866 | Second jump, raising the next starting point |
| 10 (Right+Dash) | 18 | 2884 | Approach the first tall wall and release Jump |
| 6 (Right+Jump) | 58 | 2942 | Third jump, passing over the first wall |
| 1 (Left) | 18 | 2960 | Drop from the middle platform and reposition |
| 10 (Right+Dash) | 169 | 3129 | Pass under the second wall and reach the finish |
This 314-frame tail adds only seven runs. At frame 2900, x7276/y-15930 is above the first wall’s top at y=-13000, so Low% goes over the wall rather than using Any%’s endpoint tunneling.
The refinement cut the earlier 3169-frame route to 3129, still at 61 moves. It improved time, not score.
Searching, and the branches that failed
The search combined action macros, frame-level beam search, local input replacement, duration adjustment, and saved room-boundary states. Any% ranks completion frames; Low% ranks added runs, with time as a feasibility check and tiebreaker.
1 | |
The jump from 794 to 720 frames came from a composable sequence: real spring launch → exact contact on the last Dash frame → retained coyote state → delayed jump → high-speed wall crossing. Jumping earlier in the final room to avoid the Dash-expiration clamp saved the last two frames.
| Direction that did not yield a better result | Finding, not an optimality proof |
|---|---|
| Compare position while discarding facing | Incorrectly merges states with different futures and loses valid braking paths |
| Faster room14 entry | Can hit the hazard before rising enough |
| Reduce Low’s final 7 new runs to 6 | Two bounded searches found no complete route; two jumps were too low in the tested families |
| Save one run locally in room13 | Can remove room14’s required Jump edge and make the full route fail |
| Death or checkpoint transitions | Tested branches did not beat the final zero-death route |
| Re-encode the same Low trajectory | Still needs 62 runs; this does not bound all possible trajectories |
Later searches found Spring Vault entries at frame 596 with vx957, platform contacts near vx1789, and prefixes retaining HP2. None produced a faster verified finish. 720 frames and 61 moves are the best results I verified, not lower bounds.
Encode and verify the replay
The package’s repro/ directory contains both per-frame JSON routes, the encoder, and native function-level checker. Put the original ELF at repro/challenge/verifier, then run these commands from repro/. Encoding creates files locally; it does not submit them.
1 | |
Function-level verification requires unicorn and pyelftools. On Linux x86-64, the original CLI can also verify the BIN; its command-line seed must be 1:
1 | |
The encoder’s core is below. Input JSON is a per-frame array of masks from 0..31, not state snapshots.
1 | |
For the original submission, I entered Hor1zon on the challenge site and uploaded each .bin. The recorded server results were tick719/moves373 and tick3128/moves61.

The complete Low% route and flag
Notation is mask × duration in frames. Expanding the following 62 runs in order produces the entire 3129-frame Low% route, not just its final room.
1 | |
This count checks the difference between runs and moves:
1 | |
Any%’s 374 runs are better reproduced from any-route.json than transcribed manually. The two submitted files have these SHA-256 values:
1 | |
Both first-place dialogs displayed the same flag; the saved record contains no separate second flag:
1 | |
Reproduction files
The archive includes both final per-frame routes, encoded BIN files, the encoder, and a Unicorn checker for the original game functions. data/ contains the room geometry and plot traces. Supply the challenge’s original verifier separately.
Download the Sloppow Knight reproduction files.
Discord Friend
A friend offers a free month of an AI agent. The victim runs its installer and loses API keys; our evidence is a workstation image, a Brave profile, and registry audit logs.
I spent too long on the saved installer, its Go binary, and a Rickroll. None contained the malicious script. The author’s later explanation pointed me toward historical DNS and curl-pipe timing; that found the missing response, after which the solve became an NCACHE1 recovery and decryption job.
The network acquisition happened on September 20. The Jupyter screenshots are later reruns against those saved files.
The attachment
The supplied handout.zip contained five files:
| File | Investigative purpose |
|---|---|
README.txt |
Scenario description and confirmation that the incident data is synthetic |
SHA256SUMS |
Original integrity manifest |
workstation.E01 |
Ubuntu workstation disk image |
brave-profile.zip |
Brave browser profile containing the lure |
registry-audit.jsonl |
Package registry authentication and publishing events |
The E01 container’s SHA-256 matched the supplied manifest:
1 | |
The decoded disk was 12 GiB (12,884,901,888 bytes). Its ext4 root began at logical offset 1,074,790,400 (0x40100000) with 4,096-byte blocks. Logical offsets in this decoded stream are not physical offsets in the compressed E01 file.
Malware handling stayed static or receive-only: I saved downloaded bytes but ran no installer, helper, or exfiltration command, and uploaded nothing to a cloud sandbox. The reproduction archive contains the authenticated blob, carve records, recovered constants, and offline decryptor. Historical network observations refer only to September 20; they do not claim the C2 is still reachable.
The installer link in the browser cache
Brave History pointed to an ngrok page. A gzip-compressed body survived at offset 307200 in Cache/Cache_Data/data_2. It decompressed to a Chat page with Friend A, advertising a preview AI CLI and this installer:
1 | |

Local rendering of the original recovered HTML. This reproduces the cached page’s appearance; it is not a screenshot captured from the victim’s live browser.
The page called itself a Chat Demo. It establishes the lure and URL, not a real Discord identity; digits embedded in the ngrok hostname are not a C2 address.
Shell history recorded a download to ~/Downloads/forgesync-install.sh, followed by sha256sum, shellcheck, and less. An installation-review file preserved the hash of the 6,361,925-byte saved installer:
1 | |
That identifies the inspected bytes; it does not prove that a later request returned them.
The inspected installer contained plausible ForgeSync setup, telemetry, a vendor cache, and an update timer. Its installed Go program mostly printed fixed version/status text. Neither was the credential-stealing helper. Decrypting the embedded cache with constants from the clean installer yielded ordinary configuration JSON, not a flag.
One oddity remained: an early four-second sleep followed by roughly 6 MiB of comments. That padding ultimately explained how inspection and streamed execution could receive different content.
Two limits matter. Sudo logs recorded /usr/bin/bash, but not the complete pipeline, so the precise victim command is unknown. Brave History records 2026-09-18 22:18:04.303321 UTC, later than that morning’s Linux events; the cache corroborates the URL, but its mismatched clock cannot order the two systems.
A clean installer, but suspicious services
The system journal recorded a suspicious pair of units: network-cache.path and network-cache.service. Historical ext4 metadata identified their components:
| Component | Historical inode | Original size | Original filesystem block |
|---|---|---|---|
systemd-network-cache-generator |
264123 | 905 bytes | 570971 |
/usr/local/libexec/network-cache |
264124 | 2,240 bytes | 570972 |
The generator lived at /usr/local/lib/systemd/system-generators/systemd-network-cache-generator. Both original data blocks were unallocated and zeroed: their inode records survived, but their code did not.
The clearest sequence begins when Alice starts a constellation release. /home/alice/src/constellation/tools/release.sh creates .release-running just before logging; milliseconds after that log, systemd starts the suspicious service and three credential-related access times change. The log has a timestamp, but the preceding marker creation does not.
Timeline reconstructed from journal records, filesystem metadata, and registry audit events. All displayed incident times are UTC on September 18, 2026.
| UTC time | Observation |
|---|---|
| 08:12–08:13 | Installer downloaded and inspected |
| 08:14:16 | Two suspicious scripts created |
| 08:14:17.037232 | network-cache.path started |
| 09:29:00.625727 | Release process announced its start |
| 09:29:00.628488 | network-cache.service started |
| 09:29:00.648–.650 | Credential, signing-key, and environment-file access times changed |
| 09:29:00.681 | Recovered encrypted blob’s historical modification time |
| 09:29:00.932232 | Helper logged retirement of its one-shot components |
| 09:29:01.446371 | Service finished |
| 09:48–09:52 | External token authentication, abnormal publication, and tag update |
These access times suggested reads, but did not identify the reader or prove network delivery. The later decrypted archive contained byte-identical copies of all three files.
The registry audit shows a separate consequence. The local publish token’s SHA-256 prefix, aa44319a8959, matches external authentication, publication of constellation@2.7.4-hotfix.1, and a latest tag change. A mirror-read token has fingerprint dbef6401d332 and matches a failed login. Each fingerprint is the first 12 SHA-256 hex characters of the original UTF-8 token. These addresses and credentials are synthetic exercise data, not attribution.
The hint: historical DNS
During the solve, the domain root showed a Rickroll and public /install returned 404. That only described the endpoint reached then; it said nothing about historical origins.
The author’s explanation directed attention to DNS history. The DNS History record for c2.bg2.in showed an A record dated September 18, 2026:
1 | |
The successful capture requested https://34.56.224.40/install with the IP as Host and no TLS SNI. The certificate named c2.bg2.in, so normal IP-name verification fails. I disabled verification for this anonymous acquisition and preserved the certificate. Peer identification therefore rests on the DNS history, response comparison, and agreement with disk evidence rather than a valid TLS name check.
An older hosts-file version maps c2.bg2.in to 10.0.2.2 beside the exercise’s .test services. It explains the synthetic lab, but it is not the public origin from DNS history.
The analysis machine also used a Mihomo TUN. Earlier domain requests took a different path, so their Cloudflare 404 could not be attributed to the historical origin. The successful receive-only socket used WLAN without changing global routes or proxy settings.
Pretending to curl | bash
The hint linked Luke Spademan’s “The dangers of curl | bash”. A downloader keeps reading, while a shell may pause the stream to execute an early command. Enough padding fills intermediate buffers and exposes that pause to the sender.
This installer placed the following line at roughly 4.9 KB, before its large comment region:
1 | |
In a streamed execution, the shell reaches sleep before consuming the rest. Once pipe and network buffers fill, backpressure becomes visible and the server can choose a different tail. Inspecting a previously saved file therefore says nothing about a fresh streamed response.
Conceptual explanation of the observed response selection. Buffer sizes and server-side thresholds are not claimed to have been recovered from server source.
I reproduced only the read pattern: a socket receiver saved HTTP bytes, paused reads, resumed, and saved the rest without interpreting it. The successful acquisition began at 2026-09-20 12:39:04 UTC:
| Parameter | Captured value |
|---|---|
| Peer | 34.56.224.40:443 |
| Requested socket receive buffer | 65,536 bytes |
| Pause point | After 11,112 received HTTP bytes |
| Pause duration | 8 seconds |
| HTTP body length | 6,361,925 bytes |
| Downloaded code executed | False |
The 11,112-byte pause point includes 249 HTTP-header bytes, leaving 10,863 body bytes. This is a receiver measurement, not a shell-instruction offset. The response was never run, and the packaged blob makes further server access unnecessary.
An earlier paused attempt still got the clean installer. Both interface and pause placement changed before success, so this does not isolate proxy buffering or reveal the unrecovered server logic. It establishes only that one paused read returned a malicious variant consistent with the erased artifacts.
The normal response matched the installer in the image byte for byte. The malicious response had the same total length and ETag but a different SHA-256:
1 | |
The first difference is at zero-based body offset 6,296,745, where the malicious response replaces tail padding with installation code. Equal length and ETag were useless here.

Finally, the malicious scripts
The extra tail writes two Base64 strings to the generator and helper paths. Decoding them produces 905- and 2,240-byte scripts, exactly matching the historical ext4 sizes.
Their SHA-256 values were:
1 | |
The generator creates a path unit watching /home/alice/src/constellation/.release-running with Unit=network-cache.service. That one-shot service runs /usr/local/libexec/network-cache, explaining its near-immediate start after the marker appears.
The helper read a hexadecimal secret from /run/.systemd-netseed and packaged four paths into a ustar archive:
1 | |
It generated a salt and IV, derived a key using the workstation’s machine ID, encrypted the tar archive, and wrote a staged /var/tmp/.net-cache-$$ file. The default upload destination in the script was https://telemetry.forgesync.test/v1/diag. The helper then removed the staging file, seed, and result, logged the retirement message, and shredded both persistent scripts using two passes plus a final zeroing pass.
This explains the residue: the scripts were zeroed, while the staging file was merely unlinked. Logs show cleanup ran, and the encrypted package establishes its contents. No packet capture proves that the remote endpoint received every byte.
The helper writes the magic with octal escapes to avoid a second literal carving hit. A text search for NCACHE1 alone could therefore miss the writer.
Back to the image: recover NCACHE1
The decisive file was already sitting in the recovery set as 85122-461.bin. Revisiting it after the hint exposed the eight-byte NCACHE1\0 header: the script blocks were gone, but their output survived.
Historical inode 85122 in journal commit 461 had size 10,316 and an extent covering filesystem blocks 570963–570965. Its modification time, 09:29:00.681 UTC, fell between the credential accesses and cleanup log.
The inode was later reused for /etc/hosts. Inode numbers are reusable records, not permanent identities; the historical version, extent, size, and journal context identify this blob, not the inode’s current filename.
The logical disk offset is:
1 | |
The offset applies after EWF decoding, not to physical bytes in .E01. This Dissect example performs the equivalent read-only carve:
1 | |
The direct reread exactly matched the journal recovery, and the unused tail of the three-block extent was zero. Size and magic suggested a container; the helper supplied its actual format.
Rendered binary-layout and hex evidence excerpt. The disk offset belongs to the decoded disk, not to the E01 container’s physical byte positions.
The format and the key
The format was:
| Offset | Length | Meaning |
|---|---|---|
| 0 | 8 | Magic NCACHE1\0 |
| 8 | 4 | Big-endian ciphertext length: 0x2800, or 10,240 |
| 12 | 16 | Random salt |
| 28 | 16 | AES counter IV |
| 44 | 10,240 | AES-256-CTR ciphertext |
| 10,284 | 32 | HMAC-SHA256 over all preceding bytes |
The length check is 8 + 4 + 16 + 16 + 10240 + 32 = 10316; the magic ends in a NUL byte, not the printable characters \ and 0.
The installer hid the seed as two 32-byte hexadecimal values:
1 | |
XORing their decoded bytes reproduced the seed written to /run/.systemd-netseed:
1 | |
The machine ID from the image’s /etc/machine-id, with its newline removed, was 2e1481aad781941b9f4d4b1da0308022. The blob supplied:
1 | |
The helper’s key derivation was plain SHA-256 of an exact byte concatenation:
1 | |
Decode seed and salt from hex, but keep the machine ID as 32 ASCII characters. There is no newline or delimiter beyond the literal context string; hashing hex text produces the wrong key.
Diagram of the byte-level derivation recovered from the helper. Values are transcribed from the captured payload and image evidence.
The resulting 32-byte key was:
1 | |
The script uses this same key for AES and HMAC. The computed HMAC matched the final 32 bytes exactly:
1 | |
The HMAC match ties together the blob, seed, machine ID, salt, and authenticated-field layout. I only decrypted and parsed the archive after that check passed.
Decrypting the flag
This offline PyCryptodome example authenticates first and extracts only the expected regular-file member in memory, avoiding arbitrary archive paths:
1 | |
An empty PyCryptodome nonce plus the full 128-bit big-endian IV matches the helper’s OpenSSL counter layout. CTR preserves length, so the plaintext archive is also 10,240 bytes. Its SHA-256 is:
1 | |
| Decrypted member | Size |
|---|---|
home/alice/.config/forgehub/credentials.json |
283 bytes |
home/alice/.config/cosign/release.key |
165 bytes |
home/alice/src/constellation/.env.production |
204 bytes |
dev/shm/.ncache-result |
35 bytes |
The first three members matched their original image files byte for byte. The final member contained:
1 | |

For the packaged implementation, run from the package directory:
1 | |
The verified environment used PyCryptodome 3.18.0. To repeat the carve from the original E01, run:
1 | |
Use --raw for a decoded raw disk. The packaged carver reads this challenge’s known logical offset and verifies the blob hash. The partition-aware example above additionally needs dissect.volume.
The public solver uses the recovered seed constants, checks the HMAC, and reads only the flag member in memory; it never writes a plaintext credential archive.
Detours
The Rickroll’s secret.mp4 name suggested steganography. Container, media-packet, frame, and audio checks produced no valid flag. I eventually stopped following that lead and returned to the installer.
The saved installer and Go ELF belonged to the benign response path; no amount of reversing those files can recover a malicious tail they never contained.
Ext4 journal metadata preserved file history, not every overwritten data block. Recarving the zeroed script extents could not restore their text. The surviving staging blob only became useful after bringing in key material from the missing response.
I also overinterpreted public 404s. Historical DNS was the small OSINT step that connected the current domain to the useful origin; author-profile searches and guessed paths added little after that.
The useful chain is concrete: saved-installer hash → separate malicious response → decoded helper sizes matching historical inodes → the same blob carved two ways → valid HMAC → decrypted files identical to disk. That reaches the flag without executing the payload or inventing missing network evidence.
References and scripts
- 07CTF
- DNS History: c2.bg2.in
- The Dangers of curl | bash
- Yusuf Tas’s writeup of the same challenge, consulted while preparing this post. The measurements, scripts, and screenshots here come from my saved solve.
The reproduction archive contains NCACHE1.bin, decrypt_ncache.py, the E01 carving script, and the carve records. Supply the original challenge image separately. The complete malicious installer and decrypted credential files are not part of the website download.
