你好,我是 Alosh ✌🏻
是的,但只有一种攻击类型有效,而且不是大家以为的那种。
Unicode 变体选择符(类别 Mn,U+FE00 到 U+FE0F 以及 U+E0100 到 U+E01EF)能将检测器拉到阈值以下,并保持在那里。我试过的所有其他不可见字符攻击,都会被一行输入归一化完全还原。变体选择符不会,因为它们是承载意义的码位(emoji 呈现、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 论文)通过使用带密钥的锦标赛偏置 token 采样来为生成的文本加水印。信号存在于选择了哪些 token中,而不是任何隐藏字符中。我想知道这种机制对于一个有动机、会用查找和替换的人来说有多脆弱,于是我构建了生成器和一个未经训练的 mean-g 检测器,并对它发起了攻击。
检测器阈值为 z = 2.33(1% 误报率)。高于该值即检测到水印。我生成带水印文本、发起攻击、重新分词并评分。每张结果表都带有一个 roundtrip 对照(未受攻击的带水印文本),以便你看到该行中检测器确实在工作。本研究中有两次 bug 使所有 z 值归零,而只有这个对照能发现它。
攻击面按 Unicode 类别清晰划分,事实证明这一点就是关键。
有效(类别 Mn,非间距标记):
无效(类别 Cf,格式字符):
Cf 字符纯粹是格式字符,没有语义内容,因此归一化器可以整体删除它们而不会损失任何信息。Mn 变体选择符在 emoji 和 CJK 文本中承载真实含义,因此一刀切地删除它们会破坏合法文档。这种不对称性正是为什么一类攻击是有防御理由的,而另一类没有。
实际可用范围: 这需要大约 20% 或更多的字符被改动,才能将强基线压到阈值以下;但由于插入的字符不可见,任何编辑率下文本读起来都正常。已验证视觉上完全一致,见 src/fidelity_check.py。四个家族只插入不渲染任何内容的字符;同形字攻击是坦诚的例外(依赖字体,不保证不可见)。
我最初的担忧很老套:担心把破折号换成连字符、把 "and" 换成 "&"、去掉 markdown 之类的操作就能奏效,那样水印就会沦为安全表演。网上很多人恰恰就是这么假设的。
这些并不奏效。在 gpt-oss-20b 上的整个表面编辑阶梯,直到 32k token:
在三个模型(0.8B、4B、20B = 294 个单元格)中,恰好一个单元格越过了阈值:在 4B 模型上以最短长度删除每个词 39% 的字符。那不算攻击,那是把文档撕碎。
有两件事让我意外:
完整机制和逐攻击表见 docs/FINDINGS.md。
试试交互式版本 → 包含真实研究样本和前后对照显示开关,以及一个可对你自己文本运行攻击变换的游乐场。它不会告诉你任意粘贴的文本是否真的带水印(这需要我们没有的密钥),而且它也明确说明了这一点;生成脚本见 site/。
水印寄生于模型对每个 token 的不确定性。当模型对下一个 token 很有信心时,锦标赛就没有空间去偏置它,因此不会有信号注入。这意味着水印在低熵文本上很弱,而代码就是低熵的。
Qwen3.5-4B,散文与代码,512-token 样本,完全没有攻击:
| 领域 | 熵 | z |
|---|---|---|
| 散文 | 1.19 bits/tok | 11.1 |
| 代码 | 0.55 bits/tok | 5.0 |
z 比值为 0.45 倍,熵比值为 0.46 倍,两者同步变化,这正是底层机制在显现。**8 个代码样本中有 3 个自行落到了检测阈值或以下。**最紧凑的那个(一个裸算法,0.2 bits/token)得分为 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 使用 HF 的 GPU 处理器(在词表大的模型上快得多;在 H100 上,这决定了 GPU 利用率是 0% 还是 46%)。
水印需要完整的下一个 token 分布,因此它通过 transformers 运行(CUDA 原生 MXFP4,或 MPS/CPU)。Ollama 和 llama.cpp 做不到这一点,它们不会在生成过程中暴露 logits。在 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 |
| 去除所有 markdown | 13.6% | 27.2 | 103.3 |
| 美式英语转英式英语 + 缩写 | 1.3% | ~28 | ~100 |
| 删除每个词 40% 的字符 | 38% | 4.9 | 25.4 |