
Elude i watermark testuali degli LLM iniettando selettori di variazione Unicode; include il generatore SynthID, il rilevatore mean-g, le difese di normalizzazione ed esperimenti sull'entropia.
Hola, sono Alosh ✌🏻
Sì, ma funziona solo una famiglia di attacchi, e non è quella che tutti presumono.
I variation selector Unicode (categoria Mn, da U+FE00 a U+FE0F e da U+E0100 a U+E01EF) portano il rilevatore sotto la soglia e restano lì. Ogni altro attacco a caratteri invisibili che ho provato viene completamente annullato da una riga di normalizzazione dell'input. I variation selector no, perché sono codepoint significativi (presentazione emoji, varianti CJK) che NFKC non piega e non dovrebbe piegare.
Riprodotto su tre modelli, due domini:
| modello | dominio | z di base | dopo vs16_30 | tasso di modifica |
|---|---|---|---|---|
| gpt-oss-20b | prosa | 45.03 | 0.72 | 57% |
| gpt-oss-20b | codice | 37.24 | 0.68 | 58% |
| Qwen3.8-27B | prosa | 35.50 | -0.67 | 57% |
La soglia è z = 2.33. Tutti e tre finiscono sotto di essa e vi restano dopo la normalizzazione (0.09, 0.45 e -0.78 rispettivamente). Il testo si presenta in modo identico a un lettore umano.
Il secondo risultato reale non richiede alcun attacco: il testo a bassa entropia è appena filigranato già di per sé. La generazione di codice di Qwen3.8-27B ha una baseline pulita di z = 4.31, già vicina alla soglia senza aver fatto nulla.
Anthropic (e Google DeepMind prima di loro, con il paper SynthID-Text) filigranano il testo generato alterando il campionamento dei token con un torneo con chiave. Il segnale sta in quali token sono stati scelti, non in qualche carattere nascosto. Volevo sapere quanto fosse fragile di fronte a una persona motivata con un trova-e-sostituisci, così ho costruito il generatore più un rilevatore mean-g non addestrato e gli ho lanciato contro degli attacchi.
La soglia del rilevatore è z = 2.33 (tasso di falsi positivi dell'1%). Sopra quella soglia = filigrana rilevata. Ho generato testo filigranato, l'ho attaccato, ri-tokenizzato e valutato. Ogni tabella dei risultati contiene un controllo roundtrip (testo filigranato non attaccato) così puoi vedere che il rilevatore sta effettivamente funzionando in quella riga. Due volte nel corso di questo studio un bug ha fatto crollare ogni z a zero, e il controllo è stata l'unica cosa che l'ha scovato.
La superficie d'attacco si divide nettamente in base alla categoria Unicode, che si è rivelata essere tutta la storia.
Funziona (categoria Mn, segni senza spaziatura):
Non funziona (categoria Cf, caratteri di formato):
I caratteri Cf sono pura formattazione senza contenuto semantico, quindi un normalizzatore può rimuoverli del tutto senza perdere nulla. I variation selector Mn portano significato reale nel testo emoji e CJK, quindi rimuoverli indiscriminatamente corromperebbe documenti legittimi. È questa asimmetria il motivo per cui una famiglia è difendibile e l'altra no.
La portata pratica: serve che circa il 20% o più dei caratteri venga toccato per spingere una baseline forte sotto la soglia, ma i caratteri inseriti sono invisibili, quindi il testo si legge normalmente a qualsiasi tasso di modifica. È verificato come visivamente identico, vedi src/fidelity_check.py. Quattro famiglie inseriscono solo caratteri che non renderizzano nulla; gli homoglyph sono l'eccezione onesta (dipendono dal font, non è garantito che siano invisibili).
La mia paura iniziale era quella banale: che i trattini emme, la sostituzione di "and" con "&" e la rimozione del markdown funzionassero e basta, e che la filigrana si rivelasse un teatro della sicurezza. Molte persone online presumono esattamente questo.
Non funziona. L'intera scala di modifiche superficiali su gpt-oss-20b, fino a 32k token:
Su tre modelli (0.8B, 4B, 20B = 294 celle), esattamente una cella ha superato la soglia: la cancellazione del 39% di ogni parola alla lunghezza più breve sul 4B. Questo non è un attacco, è fare a pezzi il documento.
Due cose mi hanno sorpreso:
Meccanismo completo e tabelle per singolo attacco in docs/FINDINGS.md.
Prova la versione interattiva → Veri campioni dello studio con un toggle prima/dopo, più un playground per eseguire la trasformazione dell'attacco sul tuo testo. Non ti dirà se un testo incollato a caso è davvero filigranato (servirebbe una chiave che non abbiamo), e lo dichiara; vedi site/ per lo script del generatore.
La filigrana si basa sull'incertezza per-token del modello. Dove il modello è sicuro del token successivo, il torneo non ha spazio per influenzarlo, quindi non entra alcun segnale. Questo significa che la marca è debole sul testo a bassa entropia, e il codice è a bassa entropia.
Qwen3.5-4B, prosa vs codice, campioni da 512 token, nessun attacco:
| dominio | entropia | z |
|---|---|---|
| prosa | 1.19 bits/tok | 11.1 |
| codice | 0.55 bits/tok | 5.0 |
Il rapporto delle z è 0.45x, il rapporto dell'entropia è 0.46x: si muovono insieme, ed è il meccanismo che traspare. 3 campioni di codice su 8 sono scesi alla soglia di rilevamento o sotto di essa da soli. Il più estremo (un algoritmo spoglio, 0.2 bit/token) ha ottenuto 1.7, un mancato rilevamento.
Diventa più estremo su larga scala. Z di base senza alcun attacco:
| modello | prosa | codice | rapporto |
|---|---|---|---|
| gpt-oss-20b | 45.03 | 37.24 | 0.83 |
| Qwen3.8-27B | 35.50 | 4.31 | 0.12 |
L'output di codice di Qwen3.8-27B è così basato su template che la filigrana pulita e non attaccata si assesta a z = 4.31, appena sopra la soglia di 2.33. Nessun avversario richiesto.
Dice questo: una singola soglia di confidenza su tutti i domini non è sicura, e i frammenti di codice brevi sono quasi impossibili da filigranare. Si generalizza a JSON, config, estrazione strutturata, boilerplate.
Se distribuisci uno di questi rilevatori, normalizzare l'input ti porta per la maggior parte della strada, ma non tutta:
I passi 3 e 4 sono quelli che un normalizzatore ingenuo si perde.
Per essere onesti sulle lacune.
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 accetta prose, code o mixed. FAST_WM=1 usa un bridge di filigranatura numpy (più veloce su modelli a vocabolario piccolo con una CPU potente), FAST_WM=0 usa il processore GPU di HF (molto più veloce su modelli a vocabolario grande; su un H100 è stata la differenza tra 0% e 46% di utilizzo della GPU).
La filigranatura richiede l'intera distribuzione del token successivo, quindi passa attraverso transformers (CUDA nativo MXFP4, o MPS/CPU). Ollama e llama.cpp non possono farlo: non espongono i logits a generazione in corso. Su Apple Silicon, src/synthid_mlx.py collega la generazione MLX alla matematica della filigrana; è validato come bit-identico al riferimento HF.

(il meme che ha dato inizio a tutto. a quanto pare servono i variation selector, non un trova-e-sostituisci.)
Nota: ho usato molto Claude Code per l'implementazione e per rieseguire gli esperimenti su quattro macchine (un Mac, la mia 4090, una 3090 noleggiata e un H100). Il disegno degli esperimenti, gli attacchi che volevo provare e le scelte di inquadramento sono miei. Claude ha insistito per misurare ogni attacco contro la sua stessa difesa, ed è per questo che le tabelle stego hanno una colonna grezza e una normalizzata invece che solo quella grezza; è quello che ha trasformato "i caratteri invisibili la rompono" nel risultato reale, cioè che solo quelli di categoria Mn sopravvivono a un normalizzatore.
| attacco | cosa fa | tasso di modifica | sopravvive alla normalizzazione? |
|---|
vs16_30 | variation selector dopo ~30% dei caratteri | 57% | sì |
vs16 | variation selector dopo ~10% dei caratteri | 23% | sì (z 3.46) |
vs_supp | selector del piano supplementare (U+E0100+) | 24% | sì (z 3.40) |
homoglyph | а cirillica per a latina (categoria Ll) | 9% | sì, ma effetto debole |
| attacco | z grezzo | z normalizzato | esito |
|---|
zwsp_30 | -0.09 | 35.68 | completamente annullato |
combo | 0.92 | 35.68 | completamente annullato |
bidi | 24.37 | 35.68 | completamente annullato |
nbsp | 41.04 | 46.51 | lo sposta appena |
| attacco | tasso di modifica | z @ 1k | z @ 32k |
|---|
| roundtrip (controllo) | 0% | 25.7 | 104.3 |
| da trattino emme a trattino | ~0% | 26.4 | 113.7 |
| rimuovi tutto il markdown | 13.6% | 27.2 | 103.3 |
| da AmE a BrE + abbreviazioni | 1.3% | ~28 | ~100 |
| cancella il 40% di ogni parola | 38% | 4.9 | 25.4 |