来源: https://xusheng.dev/posts/reversing/mspaint_invisible_watermark/main/
逆向工程揭示了 Paint 和 Photos 如何将服务器颁发的 GUID 嵌入到本地生成的 AI 图像的像素中。
TL;DR
- Microsoft Paint 支持本地和云端图像生成。
- Paint 和 Photos 也提供本地 AI 模型
- 这两个应用程序会将提示发送到远程服务器进行审核。
- 服务器返回一个 GUID 以及经过审核的提示信息。
- GUID 以不可见水印的形式嵌入到本地生成的图像中。
- 单独的可见水印设置无法控制此不可见水印
- 在 Copilot+ PC 上,图像生成在本地进行,但实时审核仍然是远程的。
- 微软披露,画图程序会将C2PA元数据添加到人工智能生成的图像中。
- AI生成的图像仅保存为符合C2PA标准的格式:PNG、JPEG、GIF和
.paint
好奇地看看微软画图
这项研究源于我对画图程序的好奇。我最近在研究一些鲜为人知的Windows功能方面取得了一些进展,例如UCPD和WHESCVC,而且我早就知道微软在画图程序中添加了许多人工智能功能。我不知道是否有人真的使用画图程序+人工智能来生成图像,但我很想看看图像生成究竟是如何运作的。
在开始之前,我以为它只是简单地调用远程 API 来生成图像。然而,在用 Codex 配置好 Binary Ninja MCP并开始分析后,我很快意识到微软实际上在 Windows 系统中内置了本地模型,作为 Copilot 的一部分。
画图应用程序位于以下路径(是的,现在它们都是Windows 应用程序了):
C:\Program Files\WindowsApps\Microsoft.Paint_11.2605.71.0_x64__8wekyb3d8bbwe\PaintApp\
存在四个.onnxe 扩展名为 .model 的明显模型文件:
seg.onnxe 23.1 MB
inseg_enc.onnxe 28.0 MB
inseg_dec.onnxe 16.5 MB
mager.onnxe 302.4 MB
之前已知seg.onnxe 该文件的格式,即,当它与字符串进行异或运算后,会变成一个标准的 ONNX 文件。然而,其他三个文件的格式最初看起来有所不同。Microsoft_2023 .onnxe
事实证明,微软并没有更改算法,只是更改了密钥。segapi.dll 其中包含一个小型密钥注册表:
ps_enc_key.1.0.80-main -> "Microsoft_2023"
ps_enc_key.1.0.81-main -> a 4,096-byte alphanumeric string
解密后,onnx.checker.check_model() 对所有这些文件都有效:
| Model | Graph |
|---|---|
seg.onnx |
1,094 nodes, input input_image, output output |
inseg_enc.onnx |
1,014 nodes, output image_embeddings |
inseg_dec.onnx |
1,133 nodes, inputs for embeddings, points and masks; output masks |
mager.onnx |
15,284 nodes, image/mask inputs; output output |
可见水印
在浏览这些文件时,我发现了一个Watermarker.dll :
这对我来说并不感到特别惊讶,因为在使用 Paint 应用的过程中,我已经发现它有一个设置,可以在生成的图像中嵌入可见的水印:
可见的水印只是图片右下角的一个小小的 Copilot 标志,这完全正常。
然后,我突然想到,应该让AI分析一下这个DLL文件,看看它是否也嵌入了一个不可见的 水印。这源于我作为逆向工程师的直觉,因为这个文件有1.67MB,对于如此简单的功能来说,这个文件大小异常之大(可以说,可见的水印甚至不需要单独的DLL文件)。显然,最近Claude Code发布的关于文本水印的公告也促使我思考这种可能性。
一个无形的 水印
首先,通过以下方式添加可见水印AddPerceptibleWatermark :
CPBDoc::Save(...)
|
`-- perceptible-watermark save helper(bitmap, WatermarkSetting)
|
+-- WatermarkSetting::Never
| `-- return the original bitmap
|
+-- WatermarkSetting::AskEveryTime
| `-- show the Yes / No confirmation popup
| +-- No: return the original bitmap
| `-- Yes: continue
|
`-- Always or confirmed Yes
+-- Paint::AI::GetPerceptibleWatermarkSvg()
`-- Paint::AI::AddPerceptibleWatermark(bitmap, SVG stream)
`-- composite the visible Copilot logo
此外,还有另一种WmkWriteWatermark 功能:
Watermarker.dll!WmkWriteWatermark(
output_pixels,
payload,
payload_length,
width,
height,
stride,
input_pixels,
pixel_format);
通过追踪调用树,我们可以看到 PaintWmkWriteWatermark 函数是在局部稳定扩散图像生成之后调用的。如果WmkWriteWatermark 调用失败,Paint 函数会将整个生成过程转换为错误,而不是返回不包含该图像的图像:
CocreatorViewModel::GenerateImageAsync(...)
|
`-- Paint::AI::StableDiffusionHelpers::GenerateAsync(..., watermarkId, ...)
|
`-- Microsoft.ImageCreation.ImageGenerator
|
`-- NPU-generated image result
|
+-- output safety/moderation checks
|
+-- Paint::AI::AddWatermark(bitmap, watermarkId)
| |
| `-- Watermarker.dll!WmkWriteWatermark(...)
| |
| +-- success: return the watermarked bitmap
| `-- failure: turn generation into an error
|
`-- construct successful 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] || (sum of the 16 GUID bytes modulo 256)
核心编码器将可用图像尺寸向下取整到 8 的倍数,并维护 144 个计数器,每个计数器对应一个比特。它要求每个比特至少放置三次。
编码器本身可以概括为:
WmkWriteWatermark(output, guid, 16, width, height, stride, input, format)
|
+-- validate pointers, format, stride, and payload length
+-- require width >= 192 and height >= 192
+-- construct payload
| `-- 0x4c || GUID || byte-sum checksum
+-- expand 18 bytes into 144 individual bits
+-- round usable dimensions down to 8-pixel boundaries
+-- scan/select suitable image blocks
+-- quantize selected block/matrix values according to each bit
+-- require at least three successful placements per bit
| |
| `-- insufficient capacity -> return -8
`-- reconstruct RGB pixels into the output buffer
嵌入循环对选定的图像块执行微小的量化更改。它包含 3×5 矩阵运算和矩阵分解例程,并使用包括24.0 、0.25 、0.5 和 在内的常量0.2 。这看起来像是一个内容自适应的块域、奇异值分解 (SVD) 风格的水印。
我不是图像水印方面的专家,但有一点应该很清楚——这是一个不可见的水印!人工智能甚至编写了一些代码直接调用此函数,并用一张合成的 512×512 BGRA 图像进行了测试——添加水印后,262144 个像素中有 193376 个发生了变化。
这就引出了下一个问题:水印的输入数据来自哪里?
来自远程提示审核的 GUID
在WmkWriteWatermark 边界处,有效载荷仅包含一个指针和一个长度。知道它必须是 16 字节是一个线索,但很多东西都可以是 16 字节。因此,我开始反向追溯它的调用者。直接包装器PaintAIManager.dll 具有以下符号化签名:
Paint::AI::AddWatermark(
Gdiplus::Bitmap& image,
winrt::guid const& watermarkId);
winrt::guid 糟糕!现在我们知道,16 字节的水印有效载荷确实是一个 GUID。
进一步追踪来源,我们发现 GUID 实际上来自网络请求。在 Paint 运行本地图像模型之前,AIServices.dll 它会将提示符和样式发送到:
https://apsaiservices-a0fqcjc6bzbhgdcd.b02.azurefd.net/
v1/paint-cocreator/moderate-prompt
请求格式为 JSON,至少包含以下字段:
{
"prompt": "...",
"style": "...",
"lastPromptGenerationId": "..."
}
JSON
响应解析器期望:
{
"revisedPrompt": "...",
"promptGenerationId": "...",
"watermarkId": "...",
"containsHumanReference": false
}
静态分析固然不错,但此时我想看看服务器的实际响应。我重用了 Paint 自身的认证会话,并通过审核端点发送了以下提示:
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
}
JSON
我还尝试了该提示a portrait of a smiling person wearing a blue hat 。这次响应包含一对不同的 GUID,并且containsHumanReference 是true 。因此,该字段是服务器端对提示是否指代人类的分类。Paint 会解析并将其与 ID 一起存储,但我没有发现任何证据表明它控制着水印添加步骤本身。
ParseModerateResponse 将两个 ID 字符串解析为 GUID,并拒绝零值(使用 NULLInvalidPromptGenerationId 或 NULLInvalidWatermarkId 表示)。服务器的watermarkId ID 将成为生成图像的一部分:
PaintUI.dll
`-- IPromptModerationService
`-- PaintAIManager.dll
`-- AIServices.dll!ModerateAsync(...)
|
+-- build JSON
| +-- prompt
| +-- style
| `-- lastPromptGenerationId
|
+-- HTTPS POST /v1/paint-cocreator/moderate-prompt
|
`-- AIServices.dll!ParseModerateResponse(response)
+-- revisedPrompt
+-- promptGenerationId -> parse as GUID
+-- watermarkId -> parse as GUID
`-- containsHumanReference
|
`-- PaintUI stores WatermarkId
`-- StableDiffusionHelpers::GenerateAsync(..., watermarkId, ...)
`-- local Stable Diffusion result
`-- Paint::AI::AddWatermark(bitmap, winrt::guid const&)
`-- WmkWriteWatermark(..., guid, 16, ...)
`-- modified RGB pixels
换句话说,“本地生成”并不意味着整个操作都在本地完成。微软会接收并审核提示,然后生成一个唯一的 GUID,Paint 会将这个 GUID 嵌入到本地生成的图像中。Paint 还会将之前的 GUID 与下一个审核请求一起发送promptGenerationId ,lastPromptGenerationId 从而允许后续请求显式关联。
C2PA 元数据中相同的水印 GUID
这个故事还有另一部分。Paint 的功能不仅限于修改像素,它还会将C2PA 内容凭证附加到保存的文件上。负责此功能的代码位于 [此处应填写代码库名称] ProvenanceHelper.dll ,并由 [此处应填写代码库名称] 提供支持provenancesdk.dll 。
对于局部稳定扩散路径,流动情况如下:
local Stable Diffusion result
|
+-- Paint::AI::AddWatermark(bitmap, watermarkId)
| `-- Watermarker.dll!WmkWriteWatermark(..., watermarkId, 16, ...)
|
`-- AIServices.dll!SignIngredientOnlineAsync(..., promptGenerationId, image, ...)
|
+-- POST /v1/paint-cocreator/image-sign
| +-- imageMetadata
| | +-- PromptGenerationId
| | +-- GenerationSeed
| | +-- CreativityLevel
| | +-- AIFVersion
| | `-- moderation scores
| `-- imageToSign.jpg
|
`-- ParseProvenanceResponse(...)
`-- server-supplied C2PA manifest
`-- ProvenanceHelper::InsertManifestIngredient(...)
`-- AuthoringFinalizeOutputToBufferAsync(...)
`-- final image with C2PA metadata
请注意,签名请求会发送 <watermark> PromptGenerationId ,而图像中已经包含了单独返回的 watermarkId <watermark>。服务器在审核过程中会分配这两个值,以便将签名请求与提交像素中已存在的水印关联起来。
然后我直接从画图程序的图像创建器保存了一张真实图像,并检查了它的 PNG 数据块。紧接着IHDR 是一个 18,979 字节的数据caBX 块,其中包含一个已签名的 C2PA 清单。有趣的是:
{
"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"
}
]
}
}
经过解码,这份清单的内容更易于理解:
- Generator:
Microsoft Responsible AI Provenance - AI system:
Azure OpenAI ImageGen - Action:
c2pa.watermarked - Algorithm:
com.microsoft.invismark.1 - Watermark value:
83424621-03cb-40e3-9808-a9fae837156d - Description:
Content watermarked by Microsoft Responsible AI
服务器的watermarkId 标识符(嵌入像素中的标识符)和 C2PAc2pa.soft-binding.value 是每一代都相同的值。
这种关系至关重要。C2PA 将其称为软绑定 :一个源自内容或嵌入内容的值,以便在移除文件级清单后,内容仍然可以与其来源记录匹配。对于水印软绑定,该值value 是水印的内容标识符。微软已对该断言进行了加密签名。
为什么 Paint 会在本地添加水印?
这时,它的存在Watermarker.dll 开始变得更有意义了。Paint 实际上有两种截然不同的生成路径。
我上面测试的图像创建器功能使用了Azure OpenAI ImageGen ……生成、水印和来源信息打包都可以在微软云端完成,而画图程序只需接收一张已经包含不可见水印和 C2PA 清单的成品图像即可:
Image Creator
`-- Microsoft cloud
+-- content filtering
+-- Azure OpenAI ImageGen
+-- invisible watermark
+-- C2PA manifest
`-- completed image returned to Paint
Cocreator 则有所不同。微软表示,在支持的 Copilot+ PC 上,NPU 会在本地生成镜像,而 Azure 在线服务仍然会执行安全检查。因此,即使实际的稳定扩散推理是在设备上运行的,该功能也需要微软帐户和互联网连接。
Cocreator on a Copilot+ PC
|
+-- prompt -> Microsoft moderation service
| +-- revisedPrompt
| +-- promptGenerationId
| `-- watermarkId
|
+-- revisedPrompt + sketch -> local NPU generation
|
+-- Watermarker.dll -> embed watermarkId locally
|
`-- online provenance signing -> final C2PA manifest
这大概就是 Paint 需要本地水印实现的原因。云端生成器可以在返回输出图像前添加水印,而本地生成器无法依赖此功能,因此 Paint 必须自行修改本地生成的像素。这也解释了为什么 Paint 会将本地生成器的失败视为WmkWriteWatermark 整个生成过程的失败,而不是静默地返回一张未加水印的图像。
还有一个令人惊讶的明显迹象表明,微软在设计保存路径时考虑到了出处。当我直接从“图像创建器”面板保存生成的结果时,“画图”只提供一种格式:PNG。
将 AI 生成的结果应用到 Paint 画布后,可用的格式仍然仅限于 PNG、JPEG、GIF 和 Paint 自身的
.paint 格式。经典的 Paint 格式 BMP 却明显缺失。
这与 C2PA 支持的格式一致。PNG 将其清单存储在一个caBX 数据块中,JPEG 使用一个或多个APP11 标记段,而 GIF 则有其自身的 C2PA 应用程序扩展表示形式。该.paint 格式由微软控制,可以保留 Paint 所需的任何来源信息。相比之下,C2PA 规范明确指出 BMP是一种经典格式,如果不使用外部清单,则无法嵌入任意清单数据。如果 Paint 允许将图像直接导出为 BMP 格式,则文件级别的 C2PA 清单将不复存在。
这种分离也引出了一个关于云路径的有趣安全问题。如果底层远程镜像生成端点能够在添加水印和来源信息打包之前返回生成的镜像(或者它有一个内部选项可以抑制这些步骤),那么就有可能获得一个不包含任何水印或来源信息的云生成镜像。
如何对这种路径进行分类完全取决于微软的设计目标。如果底层服务允许返回原始生成结果,而 Paint 仅负责应用溯源层,那么这可能是预期行为。如果微软忽略了用户可能直接调用 API 并绕过 Paint 的水印步骤,那么这可能是产品缺陷。或者,如果微软将水印视为强制性的滥用预防或溯源控制措施,并且端点可以被绕过,那么这可能构成安全漏洞。在不了解预期的信任边界的情况下,这三种可能性都存在。
照片应用也会出现同样的问题。
在查找Watermarker.dll 磁盘上的文件时,我偶然发现 Microsoft Photos 中包含一个同名的 DLL 文件:
C:\Program Files\WindowsApps\
Microsoft.Windows.Photos_2026.11060.2004.0_x64__8wekyb3d8bbwe\Watermarker.dll
文本
照片的“图像创建器”和“图像重新样式”功能背后也存在本地稳定扩散操作。两者都会生成相同的水印包装器:
Photos Image Creator
`-- PerformSDTextToImageAndWatermarkAsync(..., promptGenerationId, ...)
+-- run the local text-to-image model
`-- ApplyWatermark(image, promptGenerationId)
+-- parse promptGenerationId as a GUID
+-- ConvertGUIDtoContiguousByteArray()
+-- convert RGBA to ARGB
+-- Watermarker.dll!WmkWriteWatermark(..., guid, 16, ...)
`-- convert ARGB back to RGBA
文本
重新设计图像采取了并行的方法:
Photos Restyle Image
`-- PerformSDSketchToImageAndWatermarkAsync(..., promptGenerationId, ...)
`-- ApplyWatermark(image, promptGenerationId)
`-- Watermarker.dll!WmkWriteWatermark(..., guid, 16, ...)
文本
Photos 和 Paint 之间的一个细微差别在于错误处理方式。如果水印编码器返回错误,其代码会记录以下日志:
ApplyWatermark encountered error: ... - watermark will not be applied.
文本
然后,它似乎会继续返回生成的图像。但 Paint 会将水印失败视为图像生成失败,并且不会将图像返回给用户。
微软披露的信息
经过分析,我发现微软在其映像创建器支持页面上披露了系统的一些相关部分。关于内容过滤,页面上写道:
“我们采用内容过滤来防止生成图像”
同一页面还提到生成的图像:
“将包含 C2PA 清单,帮助用户识别它是 AI 生成的图像。”
报告还解释说,Image Creator 使用了 Azure 在线服务,并指出微软会收集用户和设备标识符,同时还会提供滥用预防和监控提示。这相当于对远程过滤和 C2PA 元数据进行了有意义的披露。
该页面并未解释的是,C2PA 清单中包含一个用于标识不可见像素水印的 GUID,也没有解释 Paint 的本地生成路径会从远程提示审核中获取水印 GUID。将此功能称为“内容凭据”是准确的,但这并不能让 Windows 用户清楚地了解这个与提示相关的标识符
结论
据我所知,这是首个记录和分析 Paint 和 Photos 中不可见水印行为的研究。人工智能生成的图像上的可见水印并非新鲜事物——微软在Microsoft 365和Bing Image Creator中都有相关文档——不可见的像素水印,例如谷歌的 SynthID和Bing 的隐藏水印,也并非新鲜事物。
微软确实披露了 Paint 使用了远程内容过滤功能并添加了 C2PA 内容凭证。新证据表明,这些元数据并非仅仅是一个无关的文件级 AI 标签:其签名c2pa.soft-binding 断言中包含 Microsoft InvisMark 的名称,并记录了不可见像素水印所携带的标识符。文件级清单和像素级水印是同一溯源系统的两层。
本地和云端路径也解释了这种不寻常的分工。云端镜像创建器可以返回已添加水印和签名的镜像,而镜像创建器则必须在本地 NPU 推断后嵌入服务器颁发的标识符。在这两种情况下,“本地”并不意味着离线:请求仍然会发送给微软进行审核,最终的本地结果也会经过在线溯源签名。
这可能与欧盟《人工智能法案》第50条有关,该条款的透明度规定于2026年8月2日生效,要求人工智能生成的内容必须带有可检测的、机器可读的标记——但并非特定于提示的GUID。微软披露了C2PA元数据的存在,但我未能找到任何解释服务器颁发的水印GUID、其与提示审核的关联,或其在像素中的存在方式的披露信息。这些细节显然涉及隐私和知情权问题。
似乎也可以修改画图或照片程序来绕过提示审核和水印功能。但这并没有提供任何新功能:任何人都可以直接运行 Stable Diffusion,而无需任何机制。



