
Contorna marcas d'água de texto de LLM injetando seletores de variação Unicode; inclui o gerador SynthID, detector mean-g, defesas de normalização e experimentos de entropia.
Olá, Alosh aqui ✌🏻
Sim, mas apenas uma família de ataque funciona, e não é a que todo mundo assume.
Os seletores de variação do Unicode (categoria Mn, U+FE00 a U+FE0F e U+E0100 a U+E01EF) levam o detector para abaixo do limiar e ficam lá. Qualquer outro ataque de caractere invisível que tentei é totalmente revertido por uma linha de normalização de entrada. Os seletores de variação não, porque são pontos de código significativos (apresentação de emoji, variantes CJK) que o NFKC não colapsa e não deve colapsar.
Replicado em três modelos, dois domínios:
| modelo | domínio | z da linha de base | após vs16_30 | taxa de edição |
|---|---|---|---|---|
| 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% |
O limiar é z = 2.33. Todos os três ficam abaixo dele e permanecem abaixo após a normalização (0.09, 0.45, -0.78, respectivamente). O texto é renderizado de forma idêntica para um leitor humano.
A segunda descoberta real não precisa de ataque nenhum: texto de baixa entropia quase não é marcado para começar. A geração de código do Qwen3.8-27B tem uma linha de base limpa de z = 4.31, já perto do limiar sem que nada seja feito.
A Anthropic (e o Google DeepMind antes dela, no artigo SynthID-Text) marca texto gerado ao enviesar a amostragem de tokens com um torneio chaveado. O sinal vive em quais tokens foram escolhidos, não em qualquer caractere oculto. Eu queria saber o quão frágil isso é para uma pessoa motivada com localizar-e-substituir, então construí o gerador mais um detector mean-g não treinado e joguei ataques nele.
O limiar do detector é z = 2.33 (taxa de falsos positivos de 1%). Acima disso = marca d'água detectada. Eu gerei texto com marca d'água, ataquei-o, re-tokenizei e pontuei. Toda tabela de resultados traz um controle roundtrip (texto com marca d'água não atacado) para você ver que o detector está realmente funcionando naquela linha. Duas vezes neste estudo, um bug fez todos os z colapsarem para zero, e o controle foi a única coisa que o pegou.
A superfície de ataque se divide claramente por categoria Unicode, o que acabou sendo toda a história.
Funciona (categoria Mn, marcas sem espaçamento):
Não funciona (categoria Cf, caracteres de formato):
Caracteres Cf são formatação pura, sem conteúdo semântico, então um normalizador pode removê-los por completo sem perder nada. Seletores de variação Mn carregam significado real em texto de emoji e CJK, então removê-los de forma indiscriminada corromperia documentos legítimos. Essa assimetria é o motivo pelo qual uma família é defensável e a outra não.
O escopo prático: isso precisa que aproximadamente 20% ou mais dos caracteres sejam tocados para empurrar uma linha de base forte para baixo do limiar, mas os caracteres inseridos são invisíveis, então o texto é lido normalmente em qualquer taxa de edição. Está verificado como visualmente idêntico; veja src/fidelity_check.py. Quatro famílias inserem apenas caracteres que não renderizam nada; os homóglifos são a exceção honesta (dependem da fonte, não são garantidamente invisíveis).
Meu medo inicial era o óbvio: que travessões e "and" virando "&" e remover markdown simplesmente funcionassem, e a marca d'água acabasse sendo teatro de segurança. Muita gente online assume exatamente isso.
Não funciona. Toda a escada de edições superficiais no gpt-oss-20b, até 32k tokens:
Em três modelos (0.8B, 4B, 20B = 294 células), exatamente uma célula cruzou o limiar: apagar 39% de cada palavra no menor comprimento no 4B. Isso não é um ataque, é triturar o documento.
Duas coisas me surpreenderam:
Mecanismo completo e tabelas por ataque em docs/FINDINGS.md.
Experimente a versão interativa → Amostras reais do estudo com um botão de revelar antes/depois, além de um playground para rodar a transformação do ataque no seu próprio texto. Ele não vai dizer se um texto colado arbitrário está realmente marcado (isso exige uma chave que não temos), e ele mesmo avisa; veja site/ para o script do gerador.
A marca d'água se apoia na incerteza por token do modelo. Onde o modelo está confiante sobre o próximo token, o torneio não tem espaço para enviesá-lo, então nenhum sinal entra. Isso significa que a marca é fraca em texto de baixa entropia, e código é de baixa entropia.
Qwen3.5-4B, prosa vs código, amostras de 512 tokens, sem ataque algum:
| domínio | entropia | z |
|---|---|---|
| prosa | 1.19 bits/tok | 11.1 |
| código | 0.55 bits/tok | 5.0 |
A razão de z 0.45x, a razão de entropia 0.46x; eles se movem juntos, o que é o mecanismo transparecendo. 3 de 8 amostras de código caíram para o limiar de detecção ou abaixo dele sozinhas. A mais apertada (um algoritmo puro, 0.2 bits/token) pontuou 1.7, um erro.
Fica mais extremo em escala. Linha de base z sem ataque algum:
| modelo | prosa | código | razão |
|---|---|---|---|
| gpt-oss-20b | 45.03 | 37.24 | 0.83 |
| Qwen3.8-27B | 35.50 | 4.31 | 0.12 |
A saída de código do Qwen3.8-27B é tão modelizada que a marca d'água limpa, não atacada, fica em z = 4.31, pouco acima do limiar de 2.33. Nenhum adversário é necessário.
Isso diz: um único limiar de confiança entre domínios é inseguro, e trechos curtos de código estão perto de ser impossíveis de marcar. Generaliza para JSON, config, extração estruturada, boilerplate.
Se você disponibiliza um desses detectores, normalizar a entrada te leva quase até lá, mas não todo o caminho:
Os passos 3 e 4 são os que um normalizador ingênuo deixa passar.
Sendo honesto sobre as lacunas.
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 aceita prose, code ou mixed. FAST_WM=1 usa uma ponte de marca d'água numpy (mais rápida em modelos de vocabulário pequeno com uma CPU forte), FAST_WM=0 usa o processador de GPU da HF (muito mais rápido em modelos de vocabulário grande; num H100 essa foi a diferença entre 0% e 46% de utilização da GPU).
A marca d'água precisa da distribuição completa do próximo token, então ela roda por transformers (CUDA nativo MXFP4, ou MPS/CPU). Ollama e llama.cpp não conseguem fazer isso; eles não expõem logits no meio da geração. No Apple Silicon, src/synthid_mlx.py faz a ponte da geração MLX para a matemática da marca d'água; é validado como bit idêntico à referência HF.

(o meme que começou tudo. no fim das contas, você precisa de seletores de variação, não de localizar-e-substituir.)
Nota: usei o Claude Code intensamente para a implementação e para reexecutar experimentos em quatro máquinas (um Mac, minha própria 4090, uma 3090 alugada e um H100). O desenho do experimento, os ataques que eu queria tentar e as decisões de enquadramento são meus. O Claude insistiu em medir cada ataque contra a sua própria defesa, e é por isso que as tabelas de esteganografia têm uma coluna bruta e uma normalizada, em vez de só a bruta; foi isso que transformou "caracteres invisíveis quebram isso" na descoberta real, que é que apenas os da categoria Mn sobrevivem a um normalizador.
| ataque | o que faz | taxa de edição | sobrevive à normalização? |
|---|
vs16_30 | seletor de variação após ~30% dos caracteres | 57% | sim |
vs16 | seletor de variação após ~10% dos caracteres | 23% | sim (z 3.46) |
vs_supp | seletores de plano suplementar (U+E0100+) | 24% | sim (z 3.40) |
homoglyph | cirílico а no lugar do latino a (categoria Ll) | 9% | sim, mas efeito fraco |
| ataque | z bruto | z normalizado | veredito |
|---|
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 | quase não o move |
| ataque | taxa de edição | z @ 1k | z @ 32k |
|---|
| roundtrip (controle) | 0% | 25.7 | 104.3 |
| travessão para hífen | ~0% | 26.4 | 113.7 |
| remover todo o markdown | 13.6% | 27.2 | 103.3 |
| inglês americano para britânico + abreviações | 1.3% | ~28 | ~100 |
| apagar 40% de cada palavra | 38% | 4.9 | 25.4 |