
Обходит текстовые водяные знаки LLM, внедряя селекторы вариантов Unicode; включает генератор SynthID, детектор mean-g, защиту от нормализации и эксперименты с энтропией.
Привет, это Alosh ✌🏻
Да, но работает только одно семейство атак, и это не то, которое все предполагают.
Unicode селекторы вариантов начертания (категория Mn, U+FE00 to U+FE0F and U+E0100 to U+E01EF) уводят детектор ниже порога и остаются там. Любая другая атака с невидимыми символами, которую я пробовал, полностью откатывается одной строкой нормализации ввода. Селекторы вариантов — нет, потому что это значимые кодовые точки (презентация эмодзи, варианты CJK), которые NFKC не сворачивает и не должен сворачивать.
Воспроизведено на трёх моделях и двух доменах:
| модель | домен | базовое z | после vs16_30 | доля правок |
|---|---|---|---|---|
| gpt-oss-20b | проза | 45.03 | 0.72 | 57% |
| gpt-oss-20b | код | 37.24 | 0.68 | 58% |
| Qwen3.8-27B | проза | 35.50 | -0.67 | 57% |
Порог — z = 2.33. Все три значения оказываются ниже него и остаются ниже после нормализации (0.09, 0.45 и -0.78 соответственно). Для читателя-человека текст выглядит идентично.
Второй настоящий результат не требует вообще никакой атаки: низкоэнтропийный текст изначально почти не содержит водяного знака. Генерация кода у Qwen3.8-27B даёт чистый базовый уровень z = 4.31, который уже находится вблизи порога без какого-либо воздействия.
Anthropic (а до них Google DeepMind в статье SynthID-Text) наносила водяной знак на генерируемый текст, смещая сэмплирование токенов с помощью турнира с ключом. Сигнал живёт в том, какие токены были выбраны, а не в каком-то скрытом символе. Мне хотелось понять, насколько это уязвимо перед мотивированным человеком с функцией поиска и замены, поэтому я собрал генератор, необученный детектор mean-g и обрушил на него атаки.
Порог детектора — z = 2.33 (1% ложноположительных срабатываний). Выше него = водяной знак обнаружен. Я генерировал текст с водяным знаком, атаковал его, заново токенизировал и оценивал. В каждой таблице результатов есть контроль roundtrip (неатакованный текст с водяным знаком), чтобы видно было, что детектор в этой строке действительно работает. Дважды за время исследования баг обнулял все z, и только контроль позволил это заметить.
Поверхность атаки чётко делится по категориям Unicode, и в этом, как оказалось, вся суть.
Работает (категория Mn, непробельные знаки):
Не работает (категория Cf, символы форматирования):
Символы категории Cf — это чистое форматирование без семантического содержания, поэтому нормализатор может вычистить их целиком и ничего не потерять. Селекторы вариантов Mn несут реальный смысл в тексте с эмодзи и CJK, поэтому их массовое удаление испортило бы легитимные документы. Именно эта асимметрия объясняет, почему одно семейство можно вычищать, а другое нельзя.
Практический охват: чтобы сдвинуть сильный базовый уровень ниже порога, нужно затронуть примерно 20% символов и больше, но вставленные символы невидимы, поэтому текст читается нормально при любой доле правок. Визуальная идентичность подтверждена, см. src/fidelity_check.py. Четыре семейства вставляют только символы, которые не отображаются; гомоглифы — честное исключение (зависит от шрифта, невидимость не гарантирована).
Моим первоначальным страхом был скучный сценарий: что длинные тире, замена «and» на «&» и удаление разметки просто сработают, и водяной знак окажется театральной защитой. Многие в интернете предполагают именно это.
Это не работает. Вся лестница поверхностных правок на gpt-oss-20b вплоть до 32k токенов:
На трёх моделях (0.8B, 4B, 20B = 294 ячейки) ровно одна ячейка пересекла порог: удаление 39% каждого слова при самой короткой длине на модели 4B. Это не атака, это уничтожение документа.
Меня удивили две вещи:
Полный механизм и таблицы по каждой атаке — в docs/FINDINGS.md.
Попробуйте интерактивную версию → Реальные образцы из исследования с переключателем «до/после», плюс песочница, в которой можно применить атакующее преобразование к своему тексту. Она не скажет вам, действительно ли произвольный вставленный текст содержит водяной знак (для этого нужен ключ, которого у нас нет), и прямо об этом говорит; см. site/ — там скрипт генератора.
Водяной знак держится на неуверенности модели по каждому токену. Там, где модель уверена в следующем токене, у турнира нет возможности сместить выбор, поэтому сигнал не попадает в текст. Это значит, что знак слаб на низкоэнтропийном тексте, а код как раз низкоэнтропиен.
Qwen3.5-4B, проза против кода, выборки по 512 токенов, без какой-либо атаки:
| домен | энтропия | z |
|---|---|---|
| проза | 1.19 бит/ток | 11.1 |
| код | 0.55 бит/ток | 5.0 |
Отношение z — 0.45x, отношение энтропии — 0.46x, они движутся вместе, и в этом проявляется сам механизм. 3 из 8 образцов кода сами по себе опустились до порога обнаружения или ниже него. Самый низкоэнтропийный из них (голый алгоритм, 0.2 бит/токен) набрал 1.7 — мимо.
В масштабе это становится ещё более выраженным. Базовый уровень z вообще без атак:
| модель | проза | код | отношение |
|---|---|---|---|
| gpt-oss-20b | 45.03 | 37.24 | 0.83 |
| Qwen3.8-27B | 35.50 | 4.31 | 0.12 |
Кодовый вывод Qwen3.8-27B настолько шаблонен, что чистый, неатакованный водяной знак оказывается на уровне z = 4.31, едва выше порога 2.33. Противник не нужен.
Это говорит о том, что единый порог уверенности для всех доменов небезопасен, а короткие фрагменты кода почти нельзя пометить водяным знаком. Это обобщается на JSON, конфиги, структурированное извлечение и шаблонный код.
Если вы внедряете один из таких детекторов, нормализация входа закрывает бо́льшую часть пути, но не весь:
Именно шаги 3 и 4 пропускает наивный нормализатор.
Честно о пробелах.
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 принимает значения prose, code или mixed. FAST_WM=1 использует numpy-мост для нанесения водяного знака (быстрее на моделях с небольшим словарём и мощным CPU), FAST_WM=0 использует GPU-процессор от HF (намного быстрее на моделях с большим словарём; на H100 это была разница между 0% и 46% загрузки GPU).
Для нанесения водяного знака нужно полное распределение следующего токена, поэтому используется transformers (CUDA native MXFP4 или MPS/CPU). Ollama и llama.cpp так не могут: они не отдают логиты в процессе генерации. На Apple Silicon src/synthid_mlx.py интегрирует генерацию MLX в математику водяного знака; проверено, что результат побитово идентичен эталону HF.

(тот самый мем, с которого всё началось. оказалось, нужны селекторы вариантов начертания, а не «найти и заменить».)
Примечание: я активно использовал Claude Code для реализации и повторного запуска экспериментов на четырёх машинах (Mac, моя собственная 4090, арендованная 3090 и H100). Дизайн эксперимента, атаки, которые я хотел проверить, и решения по подаче материала — мои. Claude настоял на том, чтобы каждую атаку измерять против её собственной защиты, поэтому в таблицах стего-атак есть сырая и нормализованная колонка, а не только сырая; именно это превратило «невидимые символы ломают защиту» в настоящий вывод: нормализатор переживают только символы категории Mn.
| атака | что делает | доля правок | переживает нормализацию? |
|---|
vs16_30 | селектор вариантов начертания после ~30% символов | 57% | да |
vs16 | селектор вариантов начертания после ~10% символов | 23% | да (z 3.46) |
vs_supp | селекторы дополнительной плоскости (U+E0100+) | 24% | да (z 3.40) |
homoglyph | кириллическая а вместо латинской a (категория Ll) | 9% | да, но эффект слабый |
| атака | сырое z | нормализованное z | вердикт |
|---|
zwsp_30 | -0.09 | 35.68 | полностью откатывается |
combo | 0.92 | 35.68 | полностью откатывается |
bidi | 24.37 | 35.68 | полностью откатывается |
nbsp | 41.04 | 46.51 | едва сдвигает |
| атака | доля правок | z @ 1k | z @ 32k |
|---|
| roundtrip (контроль) | 0% | 25.7 | 104.3 |
| длинное тире на дефис | ~0% | 26.4 | 113.7 |
| удалить всю разметку | 13.6% | 27.2 | 103.3 |
| амер. англ. на брит. англ. + аббревиатуры | 1.3% | ~28 | ~100 |
| удалить 40% каждого слова | 38% | 4.9 | 25.4 |