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.

Layered code recovery

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
2
3
4
K2 = SHA256("GLITCH/STAGE2" || I1 || T1)
K3 = SHA256("GLITCH/STAGE3" || K2 || I2 || T2)
K4 = SHA256("GLITCH/STAGE4" || K3 || I3 || T3)
K5 = SHA256("GLITCH/STAGE5" || K4 || I4 || T4)

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

Manifest and AAD layout

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
2
3
4
5
aad = prefix16 + struct.pack("<4I", version, stage, count, index)
aad += entry[:13]
cipher = ChaCha20_Poly1305.new(key=key, nonce=entry[16:28])
cipher.update(aad)
plain = cipher.decrypt_and_verify(ciphertext, entry[28:44])

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
abs32(INT_MIN) = abs32(0x80000000) = 0x80000000

Therefore x2 = 0x80000000. Write Ai = abs32(xi). The remaining constraints are:

1
2
3
A0 + ROR32(A1, 8)             = -909149039
A3 - 796738173 * A1 = 398218440
x1 + ROR32(x0 XOR x3, 10) = -1635755907

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
2
3
4
5
6
7
def pabsd_word(x):
x &= 0xffffffff
return (-x if x & 0x80000000 else x) & 0xffffffff

A = [pabsd_word(x) for x in (x0, x1, x2, x3)]
mask = sum(((a >> 31) & 1) << i for i, a in enumerate(A))
assert mask == 4

Negating and truncating 0x80000000 leaves the same bit pattern, which explains lane 2.

PABSD lanes and arithmetic constraints

INT_MIN keeps the sign bit in lane 2; arithmetic constrains the other three lanes.

Z3 bit-vectors retain the required machine semantics:

1
2
3
4
5
6
u, v, w = BitVecs("u v w", 32)  # x0, x1, x3
au, av, aw = [If(x < 0, -x, x) for x in (u, v, w)]
s.add(au >= 0, av >= 0, aw >= 0)
s.add(au + RotateRight(av, 8) == -909149039)
s.add(aw - 796738173 * av == 398218440)
s.add(v + RotateRight(u ^ w, 10) == -1635755907)

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
2
I1 = 70a6c3735621d60b000000803a381fdb
T1 = LE32(A0,A1,A2,A3,4) || LE64(0x84)

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
2
S = LE64(H1[0:8]) XOR 0x2F16A71D0260F516
= 0x468398E77EE4F49C

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.

CMPXCHG branches

The instruction’s comparison must fail for the stage’s validation to succeed.

The remaining 64-bit modular constraints are:

1
2
3
4
x1 XOR ROR64(x2,14) = 0x2978AF29C9548C50
x2 XOR ROL64(x1 XOR S,20) = 0x16D8E97A72F7D6BC
x0 XOR x1 XOR ROR64(S,22) = 0xCE7565C05A6AAF51
0x7B390F90E6761923*x2 + ROL64(x1 XOR S,x2) = 0x1023CF1B116E621C

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
2
I2 = 7b66dcb437621462d154d5e0ed76b33fb2452076600a71f6
T2 = LE64(S,S,S,0x10)

Stage 3: building a quiet NaN

The decompilation at 0x406170 obscures where the flags come from, so I worked from the instructions:

1
2
3
4
5
6
7
movq       xmm0, rdi
pxor xmm1, xmm1
ucomisd xmm0, xmm1
cvttsd2si eax, xmm0
pushfq
pop rdx
and rdx, 0x45

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.

NaN fields and instruction sequence

The unordered comparison supplies 0x45; the masked invalid conversion supplies 0x80000000.

Split the low 51-bit payload as follows:

1
2
3
4
payload = (high << 47) | (mid << 31) | low
high: 4 bits, mid: 16 bits, low: 31 bits
high - 21271*mid = 0xC6D6 (mod 2^16)
low XOR ROL32(mid, high) = 576113586

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
2
3
4
5
high = 10
mid = 0x60EC
raw_double_bits = 0x7FFD307623D57BB2
I3 = b27bd5237630fd7f
T3 = pack("<QQIBH", payload, 0x45, 0x80000000, high, mid)

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
2
3
4
5
local = bytearray(24)  # 16-byte buffer + 8-byte function pointer
local[16:24] = (0x408042).to_bytes(8, "little")
local[:17] = input_bytes[:17] # rep movsb, count = 17
target = int.from_bytes(local[16:24], "little")
# input_bytes[16] == 0x93 => target == 0x408093

The first 16 bytes still satisfy the arithmetic below, while the last byte independently selects the call target. I solved the two parts separately.

One-byte pointer overwrite

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
2
3
4
a + ROR32(b,8)       = 0xFF4BD221
b XOR (c << 3) = 0xB8B2E923
d - 374046331*c = 0x03660B87
b + ROR32(a XOR d,7) = 0xE8B5FC18

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
2
3
I4 = 832183fb039eb0c8e44e002e132b2cf993
T4 = LE64(0x408042,0x408093) || 0x93
|| LE64(0xAD96B638CDA28056) || K4[0:8]

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
2
plain[j] = ciphertext[j] ^ previous
previous = ROL8(ciphertext[j] ^ (110*record_index + 61*j), 1)

The seed at 0x427D20 also supplies the initial four state words. After executing all twenty records:

1
2
tape = BLAKE2s("GLITCH/STAGE5/TAPE" || LE32(state[0..3]))
= 71760cf00bbf5f82d61c7436d1b9ff72b226229d2d9ed316cb996b675f141960

Signal handlers as algorithm components

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
2
3
4
5
6
7
8
runtime = BLAKE2s(
"GLITCH/STAGE5/RUNTIME" || seed
|| LE64(reverse_decimal(PID)*10, starttime, stack_start>>12)
|| H4)

selector = runtime[0] & 31
mask = BLAKE2s("GLITCH/STAGE5/INPUT" || seed || runtime || byte(selector))
normalized_input = I5 XOR mask[0:16]

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
2
3
4
5
d2 = a + k0 + ROL32(b, 5)
c2 = ROR32(d2 ^ d, 7)
b2 = c2 + c + k1
a2 = ROL32(b2 ^ b, 11)
a, b, c, d = a2, b2, c2, d2 # modulo 2^32

To invert, visit the round constants in reverse order:

1
2
3
4
old_b = ROR32(a, 11) ^ b
old_c = b - c - k1
old_d = ROL32(c, 7) ^ d
old_a = d - k0 - ROL32(old_b, 5)

The 32-round Feistel function

The ARX output becomes two u64 halves L and R. Round n has the standard Feistel form:

1
2
L_next = R
R_next = L XOR F_n(R)

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
2
3
4
5
6
7
8
9
10
11
tk      = LE64(tape[8*(n mod 4) : 8*(n mod 4)+8])
class = (tape[n] + 5*selector + 7*n) mod 3
tag = selector | (n<<8) | (class<<16)
page = PAGE-domain mixing selects from pages of the required class
offset = six rounds on two 5-bit halves, then two low bits from R/tape
packed = page | (offset<<8) | (class<<24) | (selector<<32)
value = actual page byte, or the low byte of FAULT-domain mixing
mixed = mix(n, R, packed, value XOR tk XOR (class<<56), ROUND)
share = mix(n, mixed, R, packed XOR value, SHARE)
h = ROL64(mixed XOR tk XOR R, 7+n mod 23) + (mixed|1)*(tk|1)
F_n(R) = h XOR ROR64(h, 11+n mod 17)

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
2
R = L_next
L = R_next ^ F_n(L_next)

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.

Feistel inversion and transcript replay

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
2
3
record = struct.pack("<BBBBH5Q",
n, page, class_id, value, offset,
R, mixed, R, R_next, share)

R genuinely appears twice in the binary’s serialization. Preserve both copies. The two digests are:

1
2
trace  = BLAKE2s("GLITCH/STAGE5/TRANSCRIPT" || record0 || ... || record31)
shares = BLAKE2s("GLITCH/STAGE5/SHARES" || LE64(share0) || ... || LE64(share31))

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
2
3
def kdf48(domain, a, b, selector):
return (blake2s(domain + b"\x00" + a + b + bytes([selector]))
+ blake2s(domain + b"\x01" + a + b + bytes([selector])))[:48]

Here blake2s denotes a function returning the 32-byte digest. Read stored[i] = memory[0x41A320 + 32*i + selector], then derive:

1
2
3
4
5
6
7
tape_mask   = kdf48("GLITCH/STAGE5/RELEASE/SHARD/TAPE", tape, empty, selector)
target_mask = kdf48("GLITCH/STAGE5/RELEASE/SHARD/TARGET/V2", tape, classes, selector)
target = stored[0:16] XOR tape_mask[0:16] XOR target_mask[0:16]

walk_mask = kdf48("GLITCH/STAGE5/RELEASE/SHARD/WALK", trace, shares, selector)
key_mask = BLAKE2s("GLITCH/STAGE5/KEY" || target || trace || shares || byte(selector))
root_key = stored[16:48] XOR tape_mask[16:48] XOR walk_mask[16:48] XOR key_mask

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
2
index = LE64(BLAKE2s-8(
"GLITCH/STAGE6/SELECT/V1" || seed || byte(selector) || target || trace)) mod 3

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
magic:u32 | version:u16 | flags:u16 | digest[32] | flag_length:u32

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.

Authenticated root candidates

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
2
3
4
5
target   = 316eac1296c22a73b76365333bc97de4
trace = 2895fd53e0e599ad92cd7a302e0495bb21e157e1ce85cb59814f2b670d99145b
shares = 9c934e597765a8c5f58c815c514e2e7eb32f48340c30babb97dd1224e2710b5f
root_key = 59b6c490235f8d85d22697c3aa49d21bbd65a0b227ec559d24000872f55537ba
flag_key = 1e75ef437b8c62d6e88cb049295c8a6aa19306d8255feba2b01fc5a037c85a85

The final key binds the root key, target, both execution digests, and root digest:

1
flag_key = BLAKE2s("GLITCH/FLAG/KEY/V1" || root_key || target || trace || shares || root_digest)

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
07CTF{gl1tchh_1nnnn_th3_m4tr1xxxx!}

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
2
python -m pip install pycryptodome
python solve_glitch.py /path/to/glitch

The relevant output is:

1
2
3
4
5
6
7
Stage 1: 70a6c3735621d60b000000803a381fdb
Stage 2: 7b66dcb437621462d154d5e0ed76b33fb2452076600a71f6
Stage 3: b27bd5237630fd7f
Stage 4: 832183fb039eb0c8e44e002e132b2cf993
Stage 5 selector: 28
All authentication tags verified.
07CTF{gl1tchh_1nnnn_th3_m4tr1xxxx!}

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.

September 22 JupyterLab rerun against the original ELF. Every authentication tag passes.

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
2
3
4
5
K2 = eaa9c0e9aae95a08777c652e46a8ccf966a8dfbe0017d28754a713e2d7bd400b
K3 = 04e72052d5154f1bfdb5a38746d0abe756df96ed131d8e95ad607b8e27371892
K4 = ee27b07986a4ddf49d78e6d101c1b93dcdd7b4a7b1fa195b19889ecbd04c1033
K5 = 7e958b803f6c146c0b3f9f4bca917fa1ce99f502e1f95688fc1cc4a4cee386e8
H4 = 67f8c43b1a4869cd842de5ca4200c1156a38aa4b7390191f055b07f6a4483776

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.

The local game. Process screenshots replay the WASM with corrected enemy spawning; The WASM/native differences are explained below.

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
2
3
Per-frame inputs:  2 2 2 10 10 0
Input runs: (2 × 3), (10 × 2), (0 × 1)
Input frames: 6 RLE runs: 3 Changes: 2

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
0 → 1 → 2 → 4 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 15

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.

BIN file layout

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.

Opening Dash reversal speeds

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
vx_next = new_direction * (abs(intermediate_vx) + 160)

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

Exact-contact update order

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.

Actual Spring Vault trajectory

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.

After input 679: exact platform contact, with horizontal velocity still at 1358.

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.

Actual trajectory through the last two rooms

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.

After input 713: x = 8506, beyond the first wall.

Finished after input 720. The local HUD shows 6.00 s; native finish tick 719 gives the official 5.992 s.

Low%: every input change counts

Low% input-run distribution

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

  1. Carry one mask across room boundaries. For example, 10×187 spans rooms 0→1, 10×358 spans 1→2, 18×219 spans 2→4, and 6×305 spans 9→10. Passing a door does not itself require a new run.
  2. 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.
  3. Deliberately trade time for fewer changes. Room 11 includes 26×337. Long waits and movement are acceptable when they save input runs.
  4. 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

Low% trajectory through 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

Recorded Any% progress

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
2
3
4
5
6
7
frontier = {real starting state or real replay prefix}
for each search layer:
Expand allowed buttons/durations; run deterministic simulation
Discard states that violate the current task constraints
Preserve differences in world state, input history, and facing
Select the next layer by objective and physical-state diversity
On completion: reconstruct full input → native replay → encode → server

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
2
3
4
python encode_route.py any-route.json any.bin
python encode_route.py low-route.json low.bin
python verify_native_route.py any-route.json
python verify_native_route.py low-route.json

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
2
3
chmod +x challenge/verifier
./challenge/verifier 1 any.bin
./challenge/verifier 1 low.bin

The encoder’s core is below. Input JSON is a per-frame array of masks from 0..31, not state snapshots.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import struct

def uleb(n):
out = bytearray()
while n >= 128:
out.append((n & 127) | 128)
n >>= 7
out.append(n)
return out

def encode(seq):
runs = []
for mask in seq:
assert 0 <= mask <= 31
if runs and runs[-1][1] == mask:
runs[-1][0] += 1
else:
runs.append([1, mask])
data = bytearray(struct.pack(
'<4sHIQII', b'KSRP', 1, 1,
len(seq), 0, len(runs)))
for frames, mask in runs:
data += uleb(frames) + bytes([mask])
return bytes(data)

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.

September 22 rerun: encode both replays and verify the native game functions. Finish ticks are 719 and 3128.

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
2
3
4
5
6
7
8
26×79, 6×29, 10×187, 22×18, 10×358, 21×1, 8×1, 22×16,
0×3, 18×1, 4×2, 18×14, 9×1, 18×219, 6×16, 16×1,
5×34, 25×42, 5×33, 24×4, 18×14, 6×18, 10×23, 6×21,
10×208, 6×19, 10×55, 6×32, 10×35, 6×305, 10×27, 6×18,
8×21, 5×15, 8×1, 2×1, 1×1, 6×33, 26×100, 22×69,
9×17, 5×40, 26×337, 6×26, 10×144, 16×1, 8×1, 22×45,
9×1, 2×39, 10×16, 6×24, 10×1, 1×1, 2×68, 4×13,
1×4, 6×13, 10×18, 6×58, 1×18, 10×169

This count checks the difference between runs and moves:

1
2
3
4
5
6
7
import json
from itertools import groupby
seq = json.load(open('low-route.json'))
runs = [(b, len(list(g))) for b, g in groupby(seq)]
assert len(seq) == 3129
assert len(runs) == 62
assert sum(a != b for a, b in zip(seq, seq[1:])) == 61

Any%’s 374 runs are better reproduced from any-route.json than transcribed manually. The two submitted files have these SHA-256 values:

1
2
3
4
5
Hor1zon-anypercent.bin  /  774 bytes
12ce7b91c7498d270dc89e450488cd3264a41c94090dbd3b555440828b1a31e2

Hor1zon-lowpercent.bin / 158 bytes
1b932654f32668ad2d8e0dc5cd1f243cc978172470d1144206078e7ec38d3b56

Both first-place dialogs displayed the same flag; the saved record contains no separate second flag:

1
07CTF{sl0pp0w_kn1ght_>>>_h0ll0w_kn1gh7}

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
a861f50f3bc484643b5313282b4650961d0d3e4409ffc135e68fd20e29fb3248

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.

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
https://c2.bg2.in/install

Locally rendered recovered chat HTML

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
703b90861d3ac82eb113a6b771d53ef8eedfd5ecac3a9caec5e535c5cf4afa4a

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.

UTC incident timeline

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
c2.bg2.in  →  34.56.224.40

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
sleep "${FORGESYNC_MIRROR_DELAY:-4.0}"

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.

Normal reading compared with paused reading

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
2
Clean:      703b90861d3ac82eb113a6b771d53ef8eedfd5ecac3a9caec5e535c5cf4afa4a
Malicious: 46afd7e874c29220ea034e84a01eaaf037435787def753fc2aa41bd8029dcba7

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.

Comparing the responses saved on September 20: identical lengths, with the malicious tail starting at byte 6296745.

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
2
Generator: 909bcc6a111e5402e31a6dfc312a7db09c887c5de0a6f48f0f2cc4b8e4b60e58
Helper: 86f1231e51bfc80c3ae0a2638754bd9e88d879a790c19b7f167903ef6593ccdb

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
2
3
4
home/alice/.config/forgehub/credentials.json
home/alice/.config/cosign/release.key
home/alice/src/constellation/.env.production
dev/shm/.ncache-result

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
2
3
partition start + filesystem block × block size
= 1,074,790,400 + 570,963 × 4,096
= 3,413,454,848

The offset applies after EWF decoding, not to physical bytes in .E01. This Dissect example performs the equivalent read-only carve:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pathlib import Path
from dissect.evidence.ewf import EWF
from dissect.volume.disk import Disk
import hashlib

image = EWF(Path("workstation.E01"))
disk = Disk(image.open())
partition = disk.partitions[0]
assert partition.offset == 1_074_790_400
stream = partition.open()
stream.seek(570_963 * 4096)
extent = stream.read(3 * 4096)
blob = extent[:10_316]
assert blob[:8] == b"NCACHE1\0"
assert not any(extent[10_316:])
assert hashlib.sha256(blob).hexdigest() == (
"fa7d7753553d07395baa01f57c46e96ada2e1a9acfbf9d573516dd48e08ba83c"
)
Path("NCACHE1.bin").write_bytes(blob)

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.

NCACHE1 structure and carving provenance

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
2
p0 = 24a5fc36c1a0a128a68ff8b3d01a4ef990f13a555dc5e0bd3e2cb4d4a58bc51f
p1 = 729a6f698dfe8675acdd0f1ef04ed181a7add7bc5d06082fbad9c1d5754c3298

XORing their decoded bytes reproduced the seed written to /run/.systemd-netseed:

1
563f935f4c5e275d0a52f7ad20549f78375cede900c3e89284f57501d0c7f787

The machine ID from the image’s /etc/machine-id, with its newline removed, was 2e1481aad781941b9f4d4b1da0308022. The blob supplied:

1
2
salt = 7488222b0e2cb592852521e36a3b6795
IV = 876d09f1a918fbd9ecfed2aad75c199b

The helper’s key derivation was plain SHA-256 of an exact byte concatenation:

1
2
3
4
key = SHA256(seed_raw
|| machine_id_ASCII
|| ASCII("|forgesync-network-cache-v2|")
|| salt_raw)

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.

Key derivation and authenticated decryption

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
d01b8e579499eec454c10f97e9554ea886270e1ba3219163335697631ce51eb3

The script uses this same key for AES and HMAC. The computed HMAC matched the final 32 bytes exactly:

1
897ae66e90d8c5b6b234f19a6f5bbc51dac961a367946b21745b7c7b2a5cd536

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from pathlib import Path
import hashlib, hmac, io, struct, tarfile
from Crypto.Cipher import AES

blob = Path("NCACHE1.bin").read_bytes()
assert blob[:8] == b"NCACHE1\0"
length = struct.unpack_from(">I", blob, 8)[0]
assert len(blob) == 44 + length + 32

p0 = bytes.fromhex("24a5fc36c1a0a128a68ff8b3d01a4ef990f13a555dc5e0bd3e2cb4d4a58bc51f")
p1 = bytes.fromhex("729a6f698dfe8675acdd0f1ef04ed181a7add7bc5d06082fbad9c1d5754c3298")
seed = bytes(a ^ b for a, b in zip(p0, p1))
machine = b"2e1481aad781941b9f4d4b1da0308022"
salt, iv = blob[12:28], blob[28:44]
key = hashlib.sha256(
seed + machine + b"|forgesync-network-cache-v2|" + salt
).digest()
tag = hmac.new(key, blob[:-32], hashlib.sha256).digest()
assert hmac.compare_digest(tag, blob[-32:]), "Authentication failed"

plain = AES.new(
key, AES.MODE_CTR, nonce=b"",
initial_value=int.from_bytes(iv, "big")
).decrypt(blob[44:-32])
with tarfile.open(fileobj=io.BytesIO(plain), mode="r:") as archive:
member = archive.getmember("dev/shm/.ncache-result")
assert member.isfile() and 0 < member.size < 4096
result = archive.extractfile(member).read()
print(result.decode("ascii"))

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
6ccffbfb1a02050ad196e1ecd0eadbc60fe6b06b2f0434476300dfa7420df687
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
07CTF{d0_n07_p1p3_t0_b4sh_3v3r!!!}

JupyterLab rerun: inspect the NCACHE1 header, verify the HMAC, and recover the flag.

For the packaged implementation, run from the package directory:

1
2
python -m pip install pycryptodome
python decrypt_ncache.py

The verified environment used PyCryptodome 3.18.0. To repeat the carve from the original E01, run:

1
2
3
python -m pip install dissect.evidence
python scripts/carve_from_e01.py --image path/to/workstation.E01 --out recovered/NCACHE1.bin
python decrypt_ncache.py --blob recovered/NCACHE1.bin

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

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.

Download the Discord Friend reproduction files.


07CTF 2026
https://g1at.github.io/2026/09/22/07CTF2026/
Author
g0at
Posted on
September 22, 2026
Licensed under