微软画图和照片应用会给本地生成的图片偷偷嵌入 GUID 隐形水印

查看原文 HN 讨论

文章摘要

逆向工程师 Xusheng Li 出于好奇拆解了 Windows 自带的画图(Paint)应用,想搞清楚它的 AI 生图功能到底怎么工作。他原本以为不过是调个远程 API,结果发现微软真的把本地模型塞进了 Windows:画图目录下有四个 .onnxe 扩展名的模型文件(seg、inseg_enc、inseg_dec、mager,最大的 302MB)。这些文件的加密方式已知一半——用字符串 Microsoft_2023 做 XOR 就能还原成正常 ONNX,而另外三个用的是不同的密钥;他在 segapi.dll 里找到了一个小小的密钥表,第二个密钥是一段 4096 字节的字母数字串。解密后所有模型都能通过 ONNX 的模型校验。

真正的发现来自一个叫 Watermarker.dll 的文件。画图确实有一个用户可见的水印设置——在图片右下角叠一个小小的 Copilot 徽标,这很正常。但作者的逆向直觉告诉他不对劲:这个 DLL 有 1.67MB,对于「贴个小图标」这种琐碎功能来说大得离谱(何况叠图标根本不需要单独一个 DLL)。他坦言,Claude Code 前不久宣布文本水印的新闻也促使他往这个方向想。

拆开之后,可见水印走的是 AddPerceptibleWatermark,受用户设置(从不 / 每次询问 / 总是)控制。但同一个 DLL 里还有一个完全不同的函数 WmkWriteWatermark,调用链显示它在本地 Stable Diffusion 生成图片之后被调用——而且如果它失败了,画图会把整次生成直接判定为错误,而不是把没水印的图返回给用户。这个函数要求载荷严格等于 16 字节(短了返回 -6,长了返回 -5,作者觉得用两个不同错误码挺有意思),然后忽略长度参数、用硬编码的循环边界拷贝 16 字节。包装层构造出一条 18 字节(144 位)的消息:0x4c + 16 字节 GUID + 16 个字节之和对 256 取模的校验和。编码器把可用图像尺寸向下取整到 8 的倍数,维护 144 个计数器(每位一个),要求每一位至少被放置三次;嵌入循环包含 3×5 矩阵运算和矩阵分解例程,用到 24.0、0.25、0.5、0.2 这些常数——看起来是一种内容自适应的块域 SVD 式水印。作者让 AI 写代码直接调用这个函数,用合成的 512×512 BGRA 图测试,加水印后 262,144 个像素里有 193,376 个发生了变化。这毫无疑问是隐形水印。

那这 16 字节从哪来?顺着调用者往回走,包装函数的签名是 Paint::AI::AddWatermark(Gdiplus::Bitmap&, winrt::guid const&)——所以确实是个 GUID。再往上走就是全文最关键的发现:这个 GUID 来自一次网络请求。在运行本地图像模型之前,AIServices.dll 会把提示词和风格发到微软 Azure 前门的一个「提示词审核」端点(/v1/paint-cocreator/moderate-prompt),请求体含 prompt、style 和上一次的 lastPromptGenerationId;服务器返回 revisedPromptpromptGenerationIdwatermarkIdcontainsHumanReference。作者复用画图自己的已认证会话实际发了一次请求验证,服务器返回 HTTP 200 和一对真实 GUID;他换成含人物的提示词时,containsHumanReference 变成了 true,说明这是服务端对提示词是否涉及人物的分类。换句话说,「本地生成」并不意味着整个操作都在本地:微软接收并审核你的提示词,然后签发那个被嵌进本地生成图片的唯一 GUID。更进一步,画图会把上一次的 promptGenerationId 随下一次审核请求一起发出去,让连续的多次请求可以被显式串联起来。

故事还有下半段。画图不只改像素,还会给保存的文件附上 C2PA「内容凭证」。作者保存了一张真实生成的图并检查 PNG 分块,发现紧跟 IHDR 之后有一个 18,979 字节的 caBX 块,里面是签名过的 C2PA 清单。清单中的 c2pa.soft-binding 字段写着算法 com.microsoft.invismark.1,作用域是整张图,而它的值——正是服务器返回的那个 watermarkId,与嵌进像素里的完全一致。C2PA 把这称为「软绑定」:一个从内容中派生或嵌入内容的值,使得即便文件级清单被剥掉,内容仍能与其溯源记录匹配上。微软对这条断言做了密码学签名。

作者随后解释了为什么画图需要本地水印实现:画图有两条生成路径。Image Creator 走 Azure OpenAI ImageGen,生成、加水印、打包溯源全在微软云端完成,画图收到的已是成品;而 Cocreator 在 Copilot+ PC 上由 NPU 本地生成——云端生成器可以在返回前加水印,本地生成器不行,所以画图必须自己动手改本地像素。这也解释了为什么水印失败会导致整次生成失败。另一个佐证是保存格式:从 Image Creator 面板直接保存只提供 PNG;把 AI 结果应用到画布后,可选格式仍被限制为 PNG、JPEG、GIF 和画图自有的 .paint——经典的 BMP 明显缺席。这与 C2PA 支持的格式完全吻合,因为 C2PA 规范明确指出 BMP 无法内嵌任意清单数据。作者还提出一个安全问题:如果云端生图端点能被诱导在加水印和打包溯源之前返回图片,就可能拿到两种标记都没有的云端生成图;这究竟算预期行为、产品 bug 还是安全漏洞,取决于微软自己的信任边界设计,三种可能都还开着。

最后作者发现微软照片应用(Photos)里有同名的 Watermarker.dll,其 Image Creator 和 Restyle Image 两个功能同样在本地 Stable Diffusion 之后调用同一个水印函数。一个微妙差别是失败行为:照片应用在编码器报错时只记一句日志「水印将不会被应用」然后照常返回图片,而画图会把整次生成算作失败。至于微软的披露,官方支持页确实提到会做内容过滤、生成的图片会带 C2PA 清单、Image Creator 使用 Azure 在线服务且会收集用户与设备标识符用于滥用防范——但页面没有说明的是:这个 C2PA 清单里含有一个标识隐形像素水印的 GUID,而画图的本地生成路径的水印 GUID 来自远程提示词审核。作者的结论是,把这个功能叫作「内容凭证」并没有说谎,但它没让 Windows 用户意识到这里有一个与提示词绑定的标识符。

HN 评论精华

这条帖子拿到 859 分,是当周最热的技术帖之一。讨论几乎一边倒地把焦点从「AI 检测」转向了「唯一标识符与匿名性」,同时也有一支不小的声音在纠正标题的误导性。