MS Paint 和 Photos 在即使本地生成的输出中隐形添加 GUID 水印

Hacker News Top 新闻

摘要

逆向工程揭示,Microsoft Paint 和 Photos 在本地生成的 AI 图像中嵌入服务器颁发的 GUID 作为隐形水印,即使图像生成是本地的,但提示词审核仍由远程处理。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/08/24 16:48

# 微软画图与照片应用在本地生成的图片中嵌入服务器签发的GUID作为隐形水印 来源:https://xusheng.dev/posts/reversing/mspaint_invisible_watermark/main/ 逆向工程揭示画图和照片如何将服务器签发的GUID嵌入到本地生成AI图像的像素中。 ## 核心摘要 - 微软画图支持本地和云端图像生成 - 画图和照片也内置了本地AI模型 - 这两个应用会将提示词发送至远程服务器进行审核 - 服务器返回审核后的提示词及一个GUID - 该GUID作为隐形水印被嵌入本地生成的图像像素中 - 一个独立的可见水印设置并不控制此隐形水印 - 在Copilot+ PC上,图像生成是本地的,但提示词审核仍需远程进行 - 微软披露画图为AI生成图像添加了C2PA元数据 - AI生成图像的保存格式限制为保留C2PA的格式:PNG、JPEG、GIF和`.paint` 画图将用户提示词发送至微软审核服务器,接收审核后的提示词和水印GUID,在本地生成图像,并将GUID嵌入最终图像像素中 ## 对微软画图的深入探究 这项研究始于我对画图的好奇。我最近在探索Windows中一些较少被研究的功能(如UCPD (https://binary.ninja/2026/08/04/ucpd-dynamic-rules.html)、WHESCVC (https://xusheng.dev/posts/reversing/whesvc/main/))方面取得了一些进展,并且我早就知道微软在画图应用中加入了大量AI功能 (https://support.microsoft.com/en-us/windows/ai/ai-apps/use-copilot-pc-features-in-paint)。我不确定是否真有人使用画图+AI来生成图像,但我想看看图像生成究竟是如何工作的。 在开始之前,我原以为它只是简单地调用远程API进行图像生成。然而,在我设置好Binary Ninja MCP (https://dev-docs.binary.ninja/guide/mcp.html)并使用Codex开始分析后,我很快意识到微软实际上在Windows中内置了本地模型,作为Copilot的一部分。 画图应用位于以下路径(是的,它们现在都是Windows应用 (https://learn.microsoft.com/en-us/windows-app/overview)了): ``` C:\Program Files\WindowsApps\Microsoft.Paint_11.2605.71.0_x64__8wekyb3d8bbwe\PaintApp\ ``` 其中有四个明显的、扩展名为`.onnxe`的模型文件: ``` seg.onnxe 23.1 MB inseg_enc.onnxe 28.0 MB inseg_dec.onnxe 16.5 MB mager.onnxe 302.4 MB ``` `seg.onnxe`的格式是已知的 (https://itstechbased.com/new-windows-11-build-25947-new-23h2-news-new-paint-and-microsoft-store-and-fixes-canary/),即当它与字符串`Microsoft_2023`进行异或运算后,就变成了一个普通的ONNX文件。然而,另外三个`.onnxe`文件的格式起初看起来不同。 事实证明,微软并未更改算法,只是更换了密钥。`segapi.dll`包含一个小的密钥注册表: ``` ps_enc_key.1.0.80-main -> "Microsoft_2023" ps_enc_key.1.0.81-main -> 一个4,096字节的字母数字字符串 ``` 解密后,`onnx.checker.check_model()`函数对所有模型都能正常工作: | 模型 | 图节点数 | 输入 | 输出 | | :--- | :--- | :--- | :--- | | `seg.onnx` | 1,094 | `input_image` | `output` | | `inseg_enc.onnx` | 1,014 | | `image_embeddings` | | `inseg_dec.onnx` | 1,133 | 用于嵌入、点和掩码的输入 | `masks` | | `mager.onnx` | 15,284 | 图像/掩码输入 | `output` | ## 可见水印 在浏览这些文件时,我发现了一个`Watermarker.dll`: 微软画图自带的Watermarker.dll文件属性 这并不让我感到意外,因为在我与画图应用交互时,我已经发现它有一个设置,可以将可见水印 (https://support.microsoft.com/en-us/topic/include-a-watermark-when-content-from-microsoft-365-is-ai-generated-b00a656e-ae61-4692-8086-67d004421030)嵌入其生成的图像中: 画图为其可见AI水印提供了“从不”、“总是”和“每次询问”选项 可见水印只是图像右下角的一个小Copilot标志,这完全正常。 然后,不知何故,我决定让AI分析这个DLL,看看它是否也嵌入了*隐形*水印。这是我作为逆向工程师的直觉的一部分,因为该文件大小为1.67 MB,对于如此简单的功能来说异常大(可以说,可见水印甚至不需要单独的DLL)。显然,最近的Claude代码文本水印公告 (https://www.anthropic.com/news/claude-text-watermark)也促使我思考这种可能性。 ## 一个*隐形*水印 首先,可见水印是由`AddPerceptibleWatermark`添加的: ``` CPBDoc::Save(...) | `-- 可见水印保存辅助函数(位图, WatermarkSetting) | +-- WatermarkSetting::Never | `-- 返回原始位图 | +-- WatermarkSetting::AskEveryTime | `-- 显示是/否确认弹窗 | +-- 否:返回原始位图 | `-- 是:继续 | `-- 总是 或 已确认是 +-- Paint::AI::GetPerceptibleWatermarkSvg() `-- Paint::AI::AddPerceptibleWatermark(位图, SVG流) `-- 合成可见的Copilot标志 ``` 然后还有一个不同的`WmkWriteWatermark`函数: ``` Watermarker.dll!WmkWriteWatermark( output_pixels, 输出像素 payload, 载荷 payload_length, 载荷长度 width, 宽度 height, 高度 stride, 步长 input_pixels, 输入像素 pixel_format); 像素格式 ``` 追踪调用树,我们可以看到`WmkWriteWatermark`是在本地Stable Diffusion图像生成之后被调用的。如果`WmkWriteWatermark`失败,画图会将整个生成过程视为错误,而不是返回没有水印的图像: ``` CocreatorViewModel::GenerateImageAsync(...) | `-- Paint::AI::StableDiffusionHelpers::GenerateAsync(..., watermarkId, ...) | `-- Microsoft.ImageCreation.ImageGenerator | `-- NPU生成的图像结果 | +-- 输出安全/审核检查 | +-- Paint::AI::AddWatermark(位图, watermarkId) | | | `-- Watermarker.dll!WmkWriteWatermark(...) | | | +-- 成功:返回带有水印的位图 | `-- 失败:将生成过程转为错误 | `-- 构建成功的StableDiffusionResult ``` 接下来很自然地会问传入的`payload`到底是什么。很快就能明显看出它必须是16字节: ``` if (payload_length < 16) return -6; if (payload_length > 16) return -5; ``` 有趣的是,当载荷太短或太长时,代码使用了两个不同的错误代码。然后该函数在复制载荷时忽略了长度参数,而是使用硬编码的循环边界: ``` for (size_t i = 0; i < 16; i++) message.push_back(payload[i]); ``` 我们还不知道这16字节的载荷是什么,但正如我们稍后将看到的,它是一个GUID!`WmkWriteWatermark`并不直接嵌入GUID。其包装函数构建了以下18字节(144位)的消息: ``` 0x4c || GUID[0..15] || (16个GUID字节之和模256) ``` 核心编码器将可用图像尺寸向下舍入为8的倍数,并保留144个计数器,每个位一个。它要求每个位至少被放置三次。 编码器本身可以总结为: ``` WmkWriteWatermark(output, guid, 16, width, height, stride, input, format) | +-- 验证指针、格式、步长和载荷长度 +-- 要求宽度 >= 192 且 高度 >= 192 +-- 构建载荷 | `-- 0x4c || GUID || 字节和校验和 +-- 将18字节扩展为144个独立位 +-- 将可用尺寸向下舍入到8像素边界 +-- 扫描/选择合适的图像块 +-- 根据每个位量化所选块/矩阵值 +-- 要求每个位至少有三次成功放置 | | | `-- 容量不足 -> 返回 -8 `-- 将RGB像素重建到输出缓冲区中 ``` 嵌入选段在选定的图像块上执行微小的量化更改。它包含3x5矩阵操作和一个矩阵分解程序,并使用了包括`24.0`、`0.25`、`0.5`和`0.2`在内的常量。这看起来像是一种基于内容自适应块域、SVD风格的水印。 我不是图像水印方面的专家,但有一点很清楚——这是一个隐形水印!AI甚至编写了一些代码直接调用此函数,并用一张合成的512x512 BGRA图像进行了测试——在添加水印后,262,144个像素中有193,376个发生了变化。 这就引出了下一个问题。水印的输入来自哪里? ## 来自远程提示词审核的GUID 在`WmkWriteWatermark`的边界处,载荷只是一个指针和一个长度。知道它必须是16字节是一个线索,但很多东西都可以是16字节。因此,我开始回溯它的调用者。`PaintAIManager.dll`中的直接包装器有这个符号化的签名: ``` Paint::AI::AddWatermark( Gdiplus::Bitmap& 图像, winrt::guid const& watermarkId); ``` `winrt::guid`,天哪!现在我们知道16字节的水印载荷确实是一个GUID。 进一步追踪来源,我们发现这个GUID实际上来自一个网络请求。在画图运行本地图像模型之前,`AIServices.dll`会将提示词和风格发送至: ``` https://apsaiservices-a0fqcjc6bzbhgdcd.b02.azurefd.net/ v1/paint-cocreator/moderate-prompt ``` 请求是JSON格式,至少包含以下字段: ``` { "prompt": "...", "style": "...", "lastPromptGenerationId": "..." } ``` 响应解析器期望: ``` { "revisedPrompt": "...", "promptGenerationId": "...", "watermarkId": "...", "containsHumanReference": false } ``` 静态分析很好,但此时我想看看来自服务器的真实响应。我复用了画图自身已认证的会话,通过审核端点发送了以下提示词: ``` a cobalt blue circle above a tiny orange square ``` 服务器返回HTTP 200: ``` { "revisedPrompt": "a cobalt blue circle above a tiny orange square", "promptGenerationId": "74d9e06b-adea-43ce-85fe-186a26e2e34a", "watermarkId": "83424621-03cb-40e3-9808-a9fae837156d", "containsHumanReference": false } ``` 我还尝试了提示词`a portrait of a smiling person wearing a blue hat`。这次响应包含了一对不同的GUID,且`containsHumanReference`为`true`。因此,该字段是服务器端对提示词是否涉及人类的分类。画图会解析并存储它以及ID,尽管我没有发现它控制水印步骤本身的证据。 `ParseModerateResponse`将两个ID字符串都解析为GUID,并使用`InvalidPromptGenerationId`或`InvalidWatermarkId`拒绝零值。服务器的`watermarkId`成为生成图像的一部分: ``` PaintUI.dll `-- IPromptModerationService `-- PaintAIManager.dll `-- AIServices.dll!ModerateAsync(...) | +-- 构建JSON | +-- prompt | +-- style | `-- lastPromptGenerationId | +-- HTTPS POST /v1/paint-cocreator/moderate-prompt | `-- AIServices.dll!ParseModerateResponse(响应) +-- revisedPrompt +-- promptGenerationId -> 解析为GUID +-- watermarkId -> 解析为GUID `-- containsHumanReference | `-- PaintUI 存储 WatermarkId `-- StableDiffusionHelpers::GenerateAsync(..., watermarkId, ...) `-- 本地 Stable Diffusion 结果 `-- Paint::AI::AddWatermark(位图, winrt::guid const&) `-- WmkWriteWatermark(..., guid, 16, ...) `-- 修改后的 RGB 像素 ``` 换句话说,“本地生成”并不意味着整个操作都是本地的。微软接收并审核提示词,然后签发一个唯一的GUID,画图将其嵌入到本地生成的图像中。画图还会在下一个审核请求中将之前的`promptGenerationId`作为`lastPromptGenerationId`发送,从而显式地将连续的请求关联起来。 这个故事还有另一部分。画图所做的不仅仅是修改像素。它还会将C2PA内容凭证 (https://c2pa.org/)附加到保存的文件中。负责此功能的代码位于`ProvenanceHelper.dll`中,并由`provenancesdk.dll`支持。 对于本地Stable Diffusion路径,流程如下: ``` 本地 Stable Diffusion 结果 | +-- Paint::AI::AddWatermark(位图, watermarkId) | `-- Watermarker.dll!WmkWriteWatermark(..., watermarkId, 16, ...) | `-- AIServices.dll!SignIngredientOnlineAsync(..., promptGenerationId, 图像, ...) | +-- POST /v1/paint-cocreator/image-sign | +-- imageMetadata | | +-- PromptGenerationId | | +-- GenerationSeed | | +-- CreativityLevel | | +-- AIFVersion | | `-- 审核分数 | `-- imageToSign.jpg | `-- ParseProvenanceResponse(...) `-- 服务器提供的 C2PA manifest `-- ProvenanceHelper::InsertManifestIngredient(...) `-- AuthoringFinalizeOutputToBufferAsync(...) `-- 包含 C2PA 元数据的最终图像 ``` 请注意,签名请求发送的是`PromptGenerationId`,而图像已经包含了单独返回的`watermarkId`。服务器在审核期间分配了这两个值,因此它可以将签名请求与提交像素中已存在的水印关联起来。 然后我直接从画图的图像创建器保存了一张真实图像,并检查了它的PNG块。紧接在`IHDR`之后是一个18,979字节的`caBX`块,其中包含一个签名的C2PA manifest。有趣的部分是: ``` { "c2pa.soft-binding": { "alg": "com.microsoft.invismark.1", "blocks": [ { "scope": "the entire image", "value": "83424621-03cb-40e3-9808-a9fae837156d" } ] }, "c2pa.actions.v2": { "actions": [ { "action": "c2pa.watermarked", "description": "Content watermarked by Microsoft Responsible AI" } ] } } ``` 解码为更可读的格式,该manifest声明: - 生成器:`Microsoft Responsible AI Provenance` - AI系统:`Azure OpenAI ImageGen` - 操作:`c2pa.watermarked` - 算法:`com.microsoft.invismark.1` - 水印值:`83424621-03cb-40e3-9808-a9fae837156d` - 描述:`Content watermarked by Microsoft Responsible AI` 服务器的`watermarkId`、嵌入像素中的标识符以及C2PA的`c2pa.soft-binding.value`是每个生成唯一的相同值。 这种关系很重要。C2PA称其为*软绑定*:一个从内容派生或嵌入内容的值,使得即使在文件级manifest被移除后,内容仍能与其来源记录匹配。对于水印软绑定,`value`就是水印的内容标识符。微软已对此断言进行了密码学签名。 ## 为什么画图进行本地水印处理? 此时,`Watermarker.dll`的存在开始变得更加合理。画图实际上有两个相当不同的生成路径。 我上面测试的“图像创建器”功能使用`Azure OpenAI ImageGen`(即远程API)。然而,画图还有一个名为“协同创作”的本地图像生成功能,它使用的是内置的Stable Diffusion模型。 从代码流程来看: - **协同创作(本地)**:提示词 -> 服务器审核(返回watermarkId) -> 本地Stable Diffusion生成 -> 添加水印(使用watermarkId) -> 签署C2PA(关联promptGenerationId) - **图像创建器(远程)**:提示词 -> 服务器审核 -> 服务器生成图像 -> (可能)添加水印 -> 签署C2PA 关键区别在于,即使图像生成本身是本地的,微软仍然通过审核服务器控制着用于水印和C2PA溯源的唯一标识符的签发。这确保了无论是本地生成还是云端生成的AI图像,都可以通过微软的系统进行追踪和验证。这种设计可能旨在平衡用户隐私(本地处理数据)与内容溯源(通过服务器签发的唯一ID)的需求。

相似文章

AI生成内容中的水印

Reddit r/artificial

关于强制在AI生成内容中添加水印以防止诈骗的讨论,提及谷歌在图片中嵌入的隐形水印。

资源 - AI文本水印:工作原理与规避方法

Reddit r/artificial

本文介绍了一个教育资源,详细说明了AI文本水印的工作原理以及规避方法,内容由Anthropic和European Commission近期关于标记AI生成内容的公告触发。

AI文本水印的工作原理

Hacker News Top

一份温和的视觉化讲解,说明统计水印如何通过微妙地偏向令牌选择,在AI生成的文本中隐藏秘密标记,以及编辑如何能抹除它。