MS Paint 和 Photos 在即使本地生成的输出中隐形添加 GUID 水印
摘要
逆向工程揭示,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生成内容中的水印
关于强制在AI生成内容中添加水印以防止诈骗的讨论,提及谷歌在图片中嵌入的隐形水印。
@VraserX: AI 输出可以携带隐藏的水印和元数据。Mark Cleaner 可移除可移除的部分,如不可见 Unicode、EXIF/GPS…
Mark Cleaner 是一款开源工具,可移除 AI 生成输出中的可移除水印、元数据和隐藏痕迹,支持本地处理并与 Codex 集成。
为什么大多数“AI水印”在截图瞬间失效(以及实际能存活的层次化解决方案)
解释为何基于元数据的AI水印(如C2PA)在截图或重新编码时失效,并提出一种结合频域、神经网络和感知指纹的层次化水印方法,能够经受真实社交媒体传播的考验。
资源 - AI文本水印:工作原理与规避方法
本文介绍了一个教育资源,详细说明了AI文本水印的工作原理以及规避方法,内容由Anthropic和European Commission近期关于标记AI生成内容的公告触发。
AI文本水印的工作原理
一份温和的视觉化讲解,说明统计水印如何通过微妙地偏向令牌选择,在AI生成的文本中隐藏秘密标记,以及编辑如何能抹除它。