
Umgeht LLM-Textwasserzeichen durch Einfügen von Unicode-Variationsselektoren; enthält den SynthID-Generator, den mean-g-Detektor, Normalisierungsabwehrmaßnahmen und Entropie-Experimente.
Hallo, Alosh hier ✌🏻
Ja, aber nur eine Angriffsfamilie funktioniert – und es ist nicht die, die alle vermuten.
Unicode-Variationsselektoren (Kategorie Mn, U+FE00 bis U+FE0F und U+E0100 bis U+E01EF) drücken den Detektor unter die Schwelle und bleiben dort. Jeder andere Angriff mit unsichtbaren Zeichen, den ich versucht habe, wird durch eine einzige Zeile Eingabenormalisierung vollständig rückgängig gemacht. Variationsselektoren nicht, weil sie bedeutungstragende Codepoints sind (Emoji-Präsentation, CJK-Varianten), die NFKC weder entfernen wird noch sollte.
Reproduziert auf drei Modellen, zwei Domänen:
| Modell | Domäne | Basis-z | nach vs16_30 | Änderungsrate |
|---|---|---|---|---|
| gpt-oss-20b | Prosa | 45.03 | 0.72 | 57 % |
| gpt-oss-20b | Code | 37.24 | 0.68 | 58 % |
| Qwen3.8-27B | Prosa | 35.50 | -0.67 | 57 % |
Die Schwelle liegt bei z = 2,33. Alle drei landen darunter und bleiben nach der Normalisierung darunter (0,09, 0,45 bzw. -0,78). Der Text sieht für einen menschlichen Leser identisch aus.
Der zweite echte Befund braucht überhaupt keinen Angriff: Text mit niedriger Entropie ist von vornherein kaum mit Wasserzeichen versehen. Die Codegenerierung von Qwen3.8-27B hat eine saubere Basis von z = 4,31 – ohne dass irgendetwas damit gemacht wurde, liegt sie bereits nahe an der Schwelle.
Anthropic (und zuvor Google DeepMind, das SynthID-Text-Paper) markierte generierten Text, indem sie das Token-Sampling mit einem schlüsselbasierten Turnier verzerrten. Das Signal liegt darin, welche Tokens ausgewählt wurden, nicht in einem versteckten Zeichen. Ich wollte wissen, wie fragil das gegenüber einer motivierten Person mit Suchen-und-Ersetzen ist, also baute ich den Generator plus einen untrainierten Mean-g-Detektor und schleuderte Angriffe darauf.
Die Detektorschwelle liegt bei z = 2,33 (1 % Falschpositivrate). Darüber = Wasserzeichen erkannt. Ich erzeugte wasserzeichenmarkierten Text, griff ihn an, tokenisierte ihn neu und bewertete ihn. Jede Ergebnistabelle enthält eine roundtrip-Kontrolle (nicht angegriffener wasserzeichenmarkierter Text), damit man sieht, dass der Detektor in dieser Zeile tatsächlich funktioniert. Zweimal in dieser Studie ließ ein Bug alle z auf Null zusammenfallen, und nur die Kontrolle hat das aufgedeckt.
Die Angriffsfläche teilt sich sauber nach Unicode-Kategorie auf – und das war die ganze Geschichte.
Funktioniert (Kategorie Mn, nichtabstandbildende Zeichen):
Funktioniert nicht (Kategorie Cf, Formatzeichen):
Cf-Zeichen sind reine Formatierung ohne semantischen Inhalt, daher kann ein Normalisierer sie im Ganzen entfernen, ohne etwas zu verlieren. Mn-Variationsselektoren tragen echte Bedeutung in Emoji- und CJK-Text, daher würde ihr pauschales Entfernen legitime Dokumente beschädigen. Diese Asymmetrie ist der Grund, warum die eine Familie verteidigbar ist und die andere nicht.
Der praktische Rahmen: Es müssen grob 20 % oder mehr der Zeichen angefasst werden, um eine starke Basis unter die Schwelle zu drücken, aber die eingefügten Zeichen sind unsichtbar, sodass der Text bei jeder Änderungsrate normal lesbar ist. Es ist visuell identisch verifiziert, siehe src/fidelity_check.py. Vier Familien fügen nur Zeichen ein, die nichts rendern; Homoglyphen sind die ehrliche Ausnahme (schriftartabhängig, nicht garantiert unsichtbar).
Meine anfängliche Befürchtung war die langweilige: dass Gedankenstriche und die Ersetzung von „and“ durch „&“ sowie das Entfernen von Markdown einfach funktionieren würden und das Wasserzeichen sich als Sicherheitstheater herausstellen würde. Viele Leute online nehmen genau das an.
Es funktioniert nicht. Die gesamte Leiter oberflächlicher Edits auf gpt-oss-20b, bis zu 32k Tokens:
Über drei Modelle (0,8B, 4B, 20B = 294 Zellen) hat genau eine Zelle die Schwelle überschritten: das Löschen von 39 % eines jeden Worts bei der kürzesten Länge auf dem 4B. Das ist kein Angriff, das ist das Schreddern des Dokuments.
Zwei Dinge haben mich überrascht:
Vollständiger Mechanismus und Tabellen pro Angriff in docs/FINDINGS.md.
Probier die interaktive Version aus → Echte Studien-Stichproben mit einem Vorher/Nachher-Anzeige-Umschalter sowie eine Spielwiese, um die Angriffstransformation auf deinen eigenen Text anzuwenden. Es teilt dir nicht mit, ob beliebig eingefügter Text wirklich ein Wasserzeichen trägt (dafür bräuchte es einen Schlüssel, den wir nicht haben), und genau das sagt es auch; siehe site/ für das Generator-Skript.
Das Wasserzeichen reitet auf der Token-für-Token-Unsicherheit des Modells. Wo das Modell beim nächsten Token sicher ist, hat das Turnier keinen Raum, es zu verzerren, also geht kein Signal hinein. Das bedeutet: Die Markierung ist bei Text mit niedriger Entropie schwach, und Code hat niedrige Entropie.
Qwen3.5-4B, Prosa vs. Code, 512-Token-Stichproben, überhaupt kein Angriff:
| Domäne | Entropie | z |
|---|---|---|
| Prosa | 1.19 bits/tok | 11.1 |
| Code | 0.55 bits/tok | 5.0 |
z-Verhältnis 0,45x, Entropie-Verhältnis 0,46x, sie bewegen sich gemeinsam – der Mechanismus zeigt sich durch. 3 von 8 Code-Stichproben fielen von selbst auf oder unter die Erkennungsschwelle. Die engste (ein nackter Algorithmus, 0,2 Bits/Token) erreichte 1,7, ein Fehlschlag.
Im größeren Maßstab wird es extremer. Basis-z ganz ohne Angriff:
| Modell | Prosa | Code | Verhältnis |
|---|---|---|---|
| gpt-oss-20b | 45.03 | 37.24 | 0.83 |
| Qwen3.8-27B | 35.50 | 4.31 | 0.12 |
Die Codeausgabe von Qwen3.8-27B ist so stark vorlagenbasiert, dass das saubere, nicht angegriffene Wasserzeichen bei z = 4,31 sitzt, kaum über der 2,33-Schwelle. Kein Angreifer nötig.
Das bedeutet: Eine einzige Konfidenzschwelle über alle Domänen hinweg ist unsicher, und kurze Codeschnipsel sind nahezu unmarkierbar. Das gilt auch für JSON, Konfiguration, strukturierte Extraktion und Boilerplate.
Wenn du einen solchen Detektor auslieferst, bringt dich die Normalisierung der Eingabe einen großen Teil des Weges, aber nicht den gesamten:
Die Schritte 3 und 4 sind die, die ein naiver Normalisierer übersieht.
Ehrlich sein über die Lücken.
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 akzeptiert prose, code oder mixed. FAST_WM=1 verwendet eine numpy-Wasserzeichen-Brücke (schneller bei Modellen mit kleinem Vokabular und starker CPU), FAST_WM=0 verwendet den GPU-Prozessor von HF (viel schneller bei Modellen mit großem Vokabular; auf einer H100 war das der Unterschied zwischen 0 % und 46 % GPU-Auslastung).
Wasserzeichenmarkierung benötigt die vollständige Next-Token-Verteilung, also läuft sie über transformers (CUDA nativ MXFP4 oder MPS/CPU). Ollama und llama.cpp können das nicht; sie legen während der Generierung keine Logits offen. Auf Apple Silicon überbrückt src/synthid_mlx.py die MLX-Generierung in die Wasserzeichen-Mathematik; es ist als bit-identisch zur HF-Referenz validiert.

(das Meme, mit dem alles anfing. Es stellt sich heraus, dass man Variationsselektoren braucht, kein Suchen-und-Ersetzen.)
Hinweis: Ich habe Claude Code intensiv für die Implementierung genutzt und um Experimente auf vier Maschinen erneut auszuführen (ein Mac, meine eigene 4090, eine gemietete 3090 und eine H100). Das Versuchsdesign, die Angriffe, die ich ausprobieren wollte, und die Entscheidungen zur Rahmung stammen von mir. Claude bestand darauf, jeden Angriff gegen seine eigene Verteidigung zu messen – deshalb haben die Stego-Tabellen eine rohe und eine normalisierte Spalte statt nur der rohen; genau das hat aus „unsichtbare Zeichen brechen es“ den eigentlichen Befund gemacht, nämlich dass nur die aus der Kategorie Mn einen Normalisierer überleben.
| Angriff | was er tut | Änderungsrate | übersteht Normalisierung? |
|---|
vs16_30 | Variationsselektor nach ~30 % der Zeichen | 57 % | ja |
vs16 | Variationsselektor nach ~10 % der Zeichen | 23 % | ja (z 3,46) |
vs_supp | Selektoren aus der Supplementary-Plane (U+E0100+) | 24 % | ja (z 3,40) |
homoglyph | kyrillisches а für lateinisches a (Kategorie Ll) | 9 % | ja, aber schwache Wirkung |
| Angriff | roh z | normalisiertes z | Ergebnis |
|---|
zwsp_30 | -0.09 | 35.68 | vollständig rückgängig gemacht |
combo | 0.92 | 35.68 | vollständig rückgängig gemacht |
bidi | 24.37 | 35.68 | vollständig rückgängig gemacht |
nbsp | 41.04 | 46.51 | bewegt es kaum |
| Angriff | Änderungsrate | z @ 1k | z @ 32k |
|---|
| roundtrip (Kontrolle) | 0 % | 25.7 | 104.3 |
| Gedankenstrich zu Bindestrich | ~0 % | 26.4 | 113.7 |
| gesamtes Markdown entfernen | 13.6 % | 27.2 | 103.3 |
| AmE zu BrE + Abkürzungen | 1.3 % | ~28 | ~100 |
| 40 % jedes Worts löschen | 38 % | 4.9 | 25.4 |