
Contourne les filigranes de texte des LLM en injectant des sélecteurs de variantes Unicode ; inclut le générateur SynthID, le détecteur mean-g, les défenses par normalisation et des expériences sur l'entropie.
Salut, ici Alosh ✌🏻
Oui, mais une seule famille d’attaques fonctionne, et ce n’est pas celle que tout le monde suppose.
Les sélecteurs de variante Unicode (catégorie Mn, U+FE00 à U+FE0F et U+E0100 à U+E01EF) font passer le détecteur sous le seuil et l’y maintiennent. Chaque autre attaque par caractères invisibles que j’ai essayée est entièrement annulée par une ligne de normalisation d’entrée. Les sélecteurs de variante, eux, ne le sont pas, car ce sont des points de code porteurs de sens (présentation des emoji, variantes CJK) que NFKC ne peut pas et ne doit pas réduire.
Reproduit sur trois modèles, deux domaines :
| 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% |
Le seuil est z = 2.33. Les trois tombent en dessous et y restent après normalisation (0.09, 0.45 et -0.78 respectivement). Le texte s’affiche de manière identique pour un lecteur humain.
La seconde vraie découverte n’a besoin d’aucune attaque : le texte à faible entropie est à peine filigrané dès le départ. La génération de code de Qwen3.8-27B a une référence propre de z = 4.31, déjà proche du seuil sans rien y faire.
Anthropic (et Google DeepMind avant eux, dans l’article SynthID-Text) filigrane les textes générés en biaisant l’échantillonnage des jetons avec un tournoi à clé. Le signal réside dans quels jetons ont été choisis, pas dans un caractère caché. Je voulais savoir à quel point cela résiste à une personne motivée armée d’un rechercher-remplacer, alors j’ai construit le générateur ainsi qu’un détecteur mean-g non entraîné et je lui ai lancé des attaques.
Le seuil du détecteur est z = 2.33 (taux de faux positifs de 1 %). Au-dessus, le filigrane est détecté. J’ai généré du texte filigrané, je l’ai attaqué, re-tokenisé puis scoré. Chaque table de résultats comporte un témoin roundtrip (texte filigrané non attaqué) afin qu’on puisse vérifier que le détecteur fonctionne bien dans cette ligne. Deux fois au cours de cette étude, un bug a fait s’effondrer tous les z à zéro, et le témoin est la seule chose qui ait permis de le repérer.
La surface d’attaque se divise nettement par catégorie Unicode, ce qui s’est avéré être toute l’histoire.
Fonctionne (catégorie Mn, marques sans chasse) :
Ne fonctionne pas (catégorie Cf, caractères de format) :
Les caractères Cf sont de la pure mise en forme, sans contenu sémantique ; un normaliseur peut donc les supprimer en bloc sans rien perdre. Les sélecteurs de variante Mn portent un sens réel dans les textes emoji et CJK ; les supprimer systématiquement corromprait des documents légitimes. C’est cette asymétrie qui fait qu’une famille est défendable et l’autre non.
La portée pratique : il faut toucher environ 20 % ou plus des caractères pour faire passer une référence solide sous le seuil, mais les caractères insérés sont invisibles, donc le texte se lit normalement quel que soit le taux d’édition. L’identité visuelle est vérifiée, voir src/fidelity_check.py. Quatre familles n’insèrent que des caractères qui ne rendent rien ; les homoglyphes sont l’exception honnête (dépendants de la police, invisibilité non garantie).
Ma crainte initiale était la version ennuyeuse : que les tirets cadratins, le remplacement de « and » par « & » et le retrait du markdown fonctionnent tout simplement, et que le filigrane se révèle être du théâtre de sécurité. Beaucoup de gens en ligne supposent exactement cela.
Ça ne fonctionne pas. Toute l’échelle d’éditions de surface sur gpt-oss-20b, jusqu’à 32 000 jetons :
Sur trois modèles (0.8B, 4B, 20B = 294 cellules), exactement une cellule a franchi le seuil : la suppression de 39 % de chaque mot à la longueur la plus courte sur le 4B. Ce n’est pas une attaque, c’est déchiqueter le document.
Deux choses m’ont surpris :
Mécanisme complet et tables par attaque dans docs/FINDINGS.md.
Essayez la version interactive → De vrais échantillons de l’étude avec un bouton avant/après, plus un terrain de jeu pour appliquer la transformation d’attaque sur votre propre texte. Il ne vous dira pas si un texte collé arbitraire est réellement filigrané (cela nécessite une clé que nous n’avons pas), et il le précise ; voir site/ pour le script du générateur.
Le filigrane repose sur l’incertitude par jeton du modèle. Là où le modèle est confiant quant au prochain jeton, le tournoi n’a aucune marge pour le biaiser, donc aucun signal n’est injecté. Cela signifie que la marque est faible sur les textes à faible entropie, et le code est à faible entropie.
Qwen3.5-4B, prose vs code, échantillons de 512 jetons, aucune attaque :
| domain | entropy | z |
|---|---|---|
| prose | 1.19 bits/tok | 11.1 |
| code | 0.55 bits/tok | 5.0 |
Rapport de z 0.45x, rapport d’entropie 0.46x, ils évoluent ensemble, ce qui est le mécanisme qui transparaît. 3 des 8 échantillons de code sont tombés sous le seuil de détection, ou à ce seuil, d’eux-mêmes. Le plus serré (un algorithme nu, 0.2 bit/jeton) a obtenu 1.7, un échec.
Cela devient plus extrême à l’échelle. z de référence sans aucune attaque :
| model | prose | code | ratio |
|---|---|---|---|
| gpt-oss-20b | 45.03 | 37.24 | 0.83 |
| Qwen3.8-27B | 35.50 | 4.31 | 0.12 |
La sortie de code de Qwen3.8-27B est si stéréotypée que le filigrane propre, non attaqué, se situe à z = 4.31, tout juste au-dessus du seuil de 2.33. Aucun adversaire requis.
Cela nous dit : un seuil de confiance unique pour tous les domaines n’est pas sûr, et les courts extraits de code sont presque impossibles à filigraner. Cela se généralise au JSON, à la configuration, à l’extraction structurée, au code passe-partout.
Si vous déployez ce genre de détecteur, normaliser l’entrée vous mène l’essentiel du chemin, mais pas tout :
Les étapes 3 et 4 sont celles qu’un normaliseur naïf omet.
Soyons honnêtes sur les lacunes.
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 accepte prose, code ou mixed. FAST_WM=1 utilise un pont de filigrane numpy (plus rapide sur les modèles à petit vocabulaire avec un CPU puissant), FAST_WM=0 utilise le processeur GPU de HF (beaucoup plus rapide sur les modèles à grand vocabulaire ; sur un H100, c’est la différence entre 0 % et 46 % d’utilisation GPU).
Le filigranage a besoin de la distribution complète du prochain jeton, il passe donc par transformers (MXFP4 natif CUDA, ou MPS/CPU). Ollama et llama.cpp ne peuvent pas le faire, ils n’exposent pas les logits en cours de génération. Sur Apple Silicon, src/synthid_mlx.py fait le pont entre la génération MLX et les calculs du filigrane ; il est validé comme étant bit-identique à la référence HF.

(le meme qui a tout déclenché. Il s’avère qu’il faut des sélecteurs de variante, pas un rechercher-remplacer.)
Note : j’ai beaucoup utilisé Claude Code pour l’implémentation et pour relancer les expériences sur quatre machines (un Mac, ma propre 4090, une 3090 louée et un H100). La conception des expériences, les attaques que je voulais tester et les choix de cadrage sont les miens. Claude a insisté pour mesurer chaque attaque contre sa propre défense, c’est pourquoi les tables stégo ont une colonne brute et une colonne normalisée au lieu d’une seule colonne brute ; c’est ce qui a transformé « les caractères invisibles la brisent » en la véritable découverte, à savoir que seuls ceux de la catégorie Mn survivent à un normaliseur.
| attack | what it does | edit rate | survives normalization? |
|---|
vs16_30 | sélecteur de variante après ~30 % des caractères | 57% | oui |
vs16 | sélecteur de variante après ~10 % des caractères | 23% | oui (z 3.46) |
vs_supp | sélecteurs du plan supplémentaire (U+E0100+) | 24% | oui (z 3.40) |
homoglyph | а cyrillique pour a latin (catégorie Ll) | 9% | oui, mais effet faible |
| attack | raw z | normalized z | verdict |
|---|
zwsp_30 | -0.09 | 35.68 | entièrement annulé |
combo | 0.92 | 35.68 | entièrement annulé |
bidi | 24.37 | 35.68 | entièrement annulé |
nbsp | 41.04 | 46.51 | le fait à peine bouger |
| attack | edit rate | z @ 1k | z @ 32k |
|---|
| roundtrip (témoin) | 0% | 25.7 | 104.3 |
| tiret cadratin en trait d’union | ~0% | 26.4 | 113.7 |
| supprimer tout le markdown | 13.6% | 27.2 | 103.3 |
| AmE → BrE + abréviations | 1.3% | ~28 | ~100 |
| supprimer 40 % de chaque mot | 38% | 4.9 | 25.4 |