
Evades LLM text watermarks by injecting Unicode variation selectors; includes the SynthID generator, mean-g detector, normalization defenses, and entropy experiments.
Hola, Alosh here ✌🏻
Yes, but only one family of attack works, and it isn't the one everyone assumes.
Unicode variation selectors (category Mn, U+FE00 to U+FE0F and U+E0100 to U+E01EF) drive the detector below threshold and stay there. Every other invisible-character attack I tried gets fully reverted by one line of input normalization. Variation selectors don't, because they're meaningful codepoints (emoji presentation, CJK variants) that NFKC will not and should not fold away.
Replicated on three models, two domains:
| model | domain | baseline z | after vs16_30 | edit rate |
|---|---|---|---|---|
| gpt-oss-20b | prose | 45.03 | 0.72 | 57% |
| gpt-oss-20b | code | 37.24 | 0.68 | 58% |
| Qwen3.8-27B | prose | 35.50 | -0.67 | 57% |
Threshold is z = 2.33. All three land under it, and stay under after normalization (0.09, 0.45, -0.78 respectively). Text renders identically to a human reader.
The second real finding needs no attack at all: low-entropy text is barely watermarked to begin with. Qwen3.8-27B code generation has a clean baseline of z = 4.31, already near threshold with nothing done to it.
Anthropic (and Google DeepMind before them, the SynthID-Text paper) watermark generated text by biasing token sampling with a keyed tournament. The signal lives in which tokens got picked, not in any hidden character. I wanted to know how fragile that is to a motivated person with a find-and-replace, so I built the generator plus an untrained mean-g detector and threw attacks at it.
Detector threshold is z = 2.33 (1% false positive rate). Above that = watermark detected. I generated watermarked text, attacked it, re-tokenized, and scored. Every results table carries a roundtrip control (unattacked watermarked text) so you can see the detector is actually working in that row. Twice this study a bug made every z collapse to zero, and the control is the only thing that caught it.
The attack surface splits cleanly by Unicode category, which turned out to be the whole story.
Works (category Mn, nonspacing marks):
Doesn't work (category Cf, format chars):
Cf characters are pure formatting with no semantic content, so a normalizer can strip them wholesale and lose nothing. Mn variation selectors carry real meaning in emoji and CJK text, so blanket-stripping them would corrupt legitimate documents. That asymmetry is why one family is defensible and the other isn't.
The practical scope: this needs roughly 20% or more of characters touched to push a strong baseline under threshold, but the inserted characters are invisible, so the text reads normally at any edit rate. It is verified visually identical, see src/fidelity_check.py. Four families insert only characters that render nothing; homoglyphs are the honest exception (font-dependent, not guaranteed invisible).
My starting fear was the boring one: that em-dashes and "and" to "&" and stripping markdown would just work, and the watermark would turn out to be security theater. A lot of people online assume exactly that.
It doesn't work. The whole surface-edit ladder on gpt-oss-20b, out to 32k tokens:
Across three models (0.8B, 4B, 20B = 294 cells), exactly one cell crossed threshold: deleting 39% of every word at the shortest length on the 4B. That's not an attack, that's shredding the document.
Two things surprised me:
Full mechanism and per-attack tables in docs/FINDINGS.md.
Try the interactive version → Real study samples with a before/after reveal toggle, plus a playground to run the attack transform on your own text. It won't tell you if arbitrary pasted text is really watermarked (that needs a key we don't have), and it says so; see site/ for the generator script.
The watermark rides on the model's per-token uncertainty. Where the model is confident about the next token, the tournament has no room to bias it, so no signal goes in. That means the mark is weak on low-entropy text, and code is low-entropy.
Qwen3.5-4B, prose vs code, 512-token samples, no attack at all:
| domain | entropy | z |
|---|---|---|
| prose | 1.19 bits/tok | 11.1 |
| code | 0.55 bits/tok | 5.0 |
z ratio 0.45x, entropy ratio 0.46x, they move together, which is the mechanism showing through. 3 of 8 code samples fell to or below the detection threshold on their own. The tightest one (a bare algorithm, 0.2 bits/token) scored 1.7, a miss.
It gets more extreme at scale. Baseline z with no attack whatsoever:
| model | prose | code | ratio |
|---|---|---|---|
| gpt-oss-20b | 45.03 | 37.24 | 0.83 |
| Qwen3.8-27B | 35.50 | 4.31 | 0.12 |
Qwen3.8-27B's code output is so templated that the clean, unattacked watermark sits at z = 4.31, barely above the 2.33 threshold. No adversary required.
It says: a single confidence threshold across domains is unsafe, and short code snippets are close to unwatermarkable. Generalizes to JSON, config, structured extraction, boilerplate.
If you ship one of these detectors, normalizing input gets you most of the way, but not all of it:
Steps 3 and 4 are the ones a naive normalizer misses.
Being honest about the gaps.
src/synthid_robustness.py generator + attack ladder + mean-g detector + normalizer
src/code_vs_prose.py the entropy experiment (with per-token entropy tap)
src/fidelity_check.py proves the stego attacks are visually identical
src/synthid_mlx.py watermarking bridge for Apple Silicon (MLX), validated vs HF
src/prompts_code.py prose / code / mixed prompt sets
src/build_report_data.py assembles results/ into the tables in FINDINGS.md
results/ the JSON this is all computed from
docs/FINDINGS.md every table, the defense hierarchy, the bugs I caught
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
# generate watermarked docs + run the full attack ladder on a model
MODEL=Qwen/Qwen3.5-4B LENGTHS=1024,2048,4096,8192 N_DOCS=2 \
DOCS=docs_4b.json OUT=res_4b.json python src/synthid_robustness.py
# the entropy experiment (code vs prose)
python src/code_vs_prose.py --model Qwen/Qwen3.5-4B --out res_cvp_4b.json
# the variation-selector / stego attacks, scored raw AND post-normalization
ATTACK_SET=desync DEFENSE=1 PROMPT_SET=prose MODEL=Qwen/Qwen3.5-4B \
DOCS=docs_4b.json OUT=res_defense.json python src/synthid_robustness.py
PROMPT_SET takes prose, code, or mixed. FAST_WM=1 uses a numpy watermark bridge (faster on small-vocab models with a strong CPU), FAST_WM=0 uses HF's GPU processor (much faster on big-vocab models; on an H100 this was the difference between 0% and 46% GPU utilisation).
Watermarking needs the full next-token distribution, so it runs through transformers (CUDA native MXFP4, or MPS/CPU). Ollama and llama.cpp can't do it, they don't expose logits mid-generation. On Apple Silicon, src/synthid_mlx.py bridges MLX generation into the watermark math; it's validated bit-identical to the HF reference.

(the meme that started it. turns out you need variation selectors, not a find-and-replace.)
Note: I used Claude Code heavily for the implementation and to rerun experiments across four machines (a Mac, my own 4090, a rented 3090, and an H100). The experiment design, the attacks I wanted tried, and the calls on framing are mine. Claude insisted on measuring every attack against its own defense, which is why the stego tables have a raw and a normalized column instead of just the raw one; that's what turned "invisible characters break it" into the actual finding, which is that only the Mn-category ones survive a normalizer.
| attack | what it does | edit rate | survives normalization? |
|---|
vs16_30 | variation selector after ~30% of chars | 57% | yes |
vs16 | variation selector after ~10% of chars | 23% | yes (z 3.46) |
vs_supp | supplementary-plane selectors (U+E0100+) | 24% | yes (z 3.40) |
homoglyph | Cyrillic а for Latin a (category Ll) | 9% | yes, but weak effect |
| attack | raw z | normalized z | verdict |
|---|
zwsp_30 | -0.09 | 35.68 | fully reverted |
combo | 0.92 | 35.68 | fully reverted |
bidi | 24.37 | 35.68 | fully reverted |
nbsp | 41.04 | 46.51 | barely moves it |
| attack | edit rate | z @ 1k | z @ 32k |
|---|
| roundtrip (control) | 0% | 25.7 | 104.3 |
| em-dash to hyphen | ~0% | 26.4 | 113.7 |
| strip all markdown | 13.6% | 27.2 | 103.3 |
| AmE to BrE + abbreviations | 1.3% | ~28 | ~100 |
| delete 40% of every word | 38% | 4.9 | 25.4 |