
Evade las marcas de agua de texto de LLM inyectando selectores de variación Unicode; incluye el generador SynthID, el detector mean-g, defensas de normalización y experimentos de entropía.
Hola, soy Alosh ✌🏻
Sí, pero solo funciona una familia de ataques, y no es la que todo el mundo supone.
Los selectores de variación Unicode (categoría Mn, U+FE00 a U+FE0F y U+E0100 a U+E01EF) llevan al detector por debajo del umbral y se quedan allí. Cualquier otro ataque de caracteres invisibles que probé queda totalmente revertido por una sola línea de normalización de entrada. Los selectores de variación no, porque son puntos de código con significado (presentación de emojis, variantes CJK) que NFKC no plegará ni debería plegar.
| modelo | dominio | z base | tras vs16_30 | tasa de edición |
|---|---|---|---|---|
| gpt-oss-20b | prosa | 45.03 | 0.72 | 57% |
| gpt-oss-20b | código | 37.24 | 0.68 | 58% |
| Qwen3.8-27B | prosa | 35.50 | -0.67 | 57% |
El umbral es z = 2.33. Los tres aterrizan por debajo y se mantienen por debajo tras la normalización (0.09, 0.45, -0.78 respectivamente). El texto se muestra de forma idéntica para un lector humano.
El segundo hallazgo real no necesita ningún ataque: el texto de baja entropía apenas está marcado de partida. La generación de código de Qwen3.8-27B tiene una línea base limpia de z = 4.31, ya cerca del umbral sin hacerle nada.
Anthropic (y Google DeepMind antes que ellos, en el artículo SynthID-Text) marca el texto generado sesgando el muestreo de tokens con un torneo con clave. La señal reside en qué tokens se eligieron, no en ningún carácter oculto. Quería saber cuán frágil es eso frente a una persona motivada con un buscar y reemplazar, así que construí el generador junto con un detector mean-g no entrenado y le lancé ataques.
El umbral del detector es z = 2.33 (1% de tasa de falsos positivos). Por encima = marca de agua detectada. Generé texto marcado, lo ataqué, lo re-tokenicé y lo puntué. Cada tabla de resultados incluye un control roundtrip (texto marcado sin atacar) para que puedas ver que el detector realmente funciona en esa fila. Dos veces durante este estudio un error hizo que todos los z colapsaran a cero, y el control fue lo único que lo detectó.
La superficie de ataque se divide claramente por categoría Unicode, que resultó ser toda la historia.
Funciona (categoría Mn, marcas sin espaciado):
No funciona (categoría Cf, caracteres de formato):
Los caracteres Cf son formato puro sin contenido semántico, así que un normalizador puede eliminarlos por completo sin perder nada. Los selectores de variación Mn tienen significado real en texto con emojis y CJK, por lo que eliminarlos de forma indiscriminada corrompería documentos legítimos. Esa asimetría es la razón por la que una familia es defendible y la otra no.
El alcance práctico: esto necesita que se toque aproximadamente un 20% o más de los caracteres para empujar una línea base fuerte por debajo del umbral, pero los caracteres insertados son invisibles, así que el texto se lee con normalidad a cualquier tasa de edición. Está verificado que es visualmente idéntico, ver src/fidelity_check.py. Cuatro familias insertan solo caracteres que no renderizan nada; los homoglifos son la excepción honesta (dependen de la fuente, no se garantiza que sean invisibles).
Mi miedo inicial era el aburrido: que los em-dashes, el cambio de "and" a "&" y eliminar el markdown funcionarían sin más, y la marca de agua resultaría ser teatro de seguridad. Mucha gente en internet asume exactamente eso.
No funciona. Toda la escalera de edición superficial sobre gpt-oss-20b, hasta 32k tokens:
En tres modelos (0.8B, 4B, 20B = 294 celdas), exactamente una celda cruzó el umbral: eliminar el 39% de cada palabra en la longitud más corta en el 4B. Eso no es un ataque, es destrozar el documento.
Dos cosas me sorprendieron:
Mecanismo completo y tablas por ataque en docs/FINDINGS.md.
Prueba la versión interactiva → Muestras reales del estudio con un conmutador de revelado antes/después, además de un campo de pruebas para ejecutar la transformación del ataque sobre tu propio texto. No te dirá si un texto pegado arbitrario está realmente marcado (eso requiere una clave que no tenemos), y así lo dice; ver site/ para el script del generador.
La marca de agua se apoya en la incertidumbre por token del modelo. Cuando el modelo está seguro sobre el siguiente token, el torneo no tiene margen para sesgarlo, así que no entra ninguna señal. Eso significa que la marca es débil en texto de baja entropía, y el código es de baja entropía.
Qwen3.5-4B, prosa vs. código, muestras de 512 tokens, sin ningún ataque:
| dominio | entropía | z |
|---|---|---|
| prosa | 1.19 bits/tok | 11.1 |
| código | 0.55 bits/tok | 5.0 |
La proporción de z es 0.45x, la proporción de entropía es 0.46x; se mueven juntas, lo que deja ver el mecanismo. 3 de las 8 muestras de código cayeron al umbral de detección o por debajo por sí solas. La más ajustada (un algoritmo escueto, 0.2 bits/token) obtuvo 1.7, un fallo.
Se vuelve más extremo a escala. Línea base de z sin ningún ataque:
| modelo | prosa | código | proporción |
|---|---|---|---|
| gpt-oss-20b | 45.03 | 37.24 | 0.83 |
| Qwen3.8-27B | 35.50 | 4.31 | 0.12 |
La salida de código de Qwen3.8-27B está tan basada en plantillas que la marca de agua limpia y sin atacar se sitúa en z = 4.31, apenas por encima del umbral de 2.33. No se requiere adversario.
Lo que dice: un único umbral de confianza entre dominios no es seguro, y los fragmentos de código cortos están cerca de ser imposibles de marcar. Se generaliza a JSON, config, extracción estructurada y texto repetitivo.
Si distribuyes uno de estos detectores, normalizar la entrada te lleva la mayor parte del camino, pero no todo:
Los pasos 3 y 4 son los que un normalizador ingenuo pasa por alto.
Siendo honesto sobre las carencias.
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 acepta prose, code o mixed. FAST_WM=1 usa un puente de marca de agua numpy (más rápido en modelos de vocabulario pequeño con una CPU potente), FAST_WM=0 usa el procesador GPU de HF (mucho más rápido en modelos de vocabulario grande; en un H100 esta fue la diferencia entre 0% y 46% de utilización de GPU).
El marcado de agua necesita la distribución completa del siguiente token, así que se ejecuta a través de transformers (MXFP4 nativo de CUDA, o MPS/CPU). Ollama y llama.cpp no pueden hacerlo; no exponen los logits a mitad de generación. En Apple Silicon, src/synthid_mlx.py conecta la generación MLX con las matemáticas de la marca de agua; está validado como bit-idéntico a la referencia de HF.

(el meme que lo empezó todo. resulta que necesitas selectores de variación, no un buscar y reemplazar).
Nota: usé Claude Code intensamente para la implementación y para re-ejecutar experimentos en cuatro máquinas (una Mac, mi propia 4090, una 3090 alquilada y un H100). El diseño del experimento, los ataques que quería probar y las decisiones de encuadre son míos. Claude insistió en medir cada ataque contra su propia defensa, que es por lo que las tablas de esteganografía tienen una columna bruta y una normalizada en lugar de solo la bruta; eso es lo que convirtió "los caracteres invisibles la rompen" en el hallazgo real, que es que solo los de la categoría Mn sobreviven a un normalizador.
| ataque | qué hace | tasa de edición | ¿sobrevive a la normalización? |
|---|
vs16_30 | selector de variación tras ~30% de los caracteres | 57% | sí |
vs16 | selector de variación tras ~10% de los caracteres | 23% | sí (z 3.46) |
vs_supp | selectores de plano suplementario (U+E0100+) | 24% | sí (z 3.40) |
homoglyph | а cirílica por a latina (categoría Ll) | 9% | sí, pero efecto débil |
| ataque | z bruto | z normalizado | veredicto |
|---|
zwsp_30 | -0.09 | 35.68 | totalmente revertido |
combo | 0.92 | 35.68 | totalmente revertido |
bidi | 24.37 | 35.68 | totalmente revertido |
nbsp | 41.04 | 46.51 | apenas lo mueve |
| ataque | tasa de edición | z @ 1k | z @ 32k |
|---|
| roundtrip (control) | 0% | 25.7 | 104.3 |
| em-dash a guion | ~0% | 26.4 | 113.7 |
| eliminar todo el markdown | 13.6% | 27.2 | 103.3 |
| AmE a BrE + abreviaturas | 1.3% | ~28 | ~100 |
| eliminar el 40% de cada palabra | 38% | 4.9 | 25.4 |