微软画图与照片应用为本地AI图片隐写GUID水印
微软画图与照片应用为本地生成图片隐写GUID水印
做AI内容安全或版权追踪的同学必看,这篇逆向工程把微软隐写水印的完整机制扒了个底朝天,从模型文件到GUID嵌入流程都有细节,赶紧研究下你的生成链路有没有类似设计。
Microsoft Paint and Photos Embed Server-Issued GUIDs as Invisible Watermarks in Locally-Generated Images
微软画图与照片应用将服务器签发的 GUID 作为不可见水印嵌入本地生成的图像中
Reverse engineering reveals how Paint and Photos embed a server-issued GUID into the pixels of locally generated AI images.
逆向工程揭示了画图与照片应用如何将服务器签发的 GUID 嵌入到本地生成的 AI 图像的像素中。
TL;DR
TL;DR
- Microsoft Paint supports both local and cloud image generation
- Paint and Photos also ship local AI models
- The two apps send the prompt to a remote server for moderation
- The server returns a GUID along with the moderated prompt
- The GUID is embedded into the locally generated image as an invisible watermark
- A separate visible-watermark setting does not control this invisible watermark
- On Copilot+ PCs, image generation is local but prompt moderation remains remote
- Microsoft discloses that Paint adds C2PA metadata to AI-generated images
- AI-generated image saves limited to C2PA-preserving formats: PNG, JPEG, GIF, and .paint
- 微软画图支持本地和云端图像生成
- 画图与照片应用还内置了本地 AI 模型
- 这两个应用会将提示词发送到远程服务器进行审核
- 服务器返回一个 GUID 以及审核后的提示词
- 该 GUID 作为不可见水印嵌入到本地生成的图像中
- 单独的可见水印设置并不控制这个不可见水印
- 在 Copilot+ PC 上,图像生成是本地进行的,但提示词审核仍远程进行
- 微软披露画图应用会向 AI 生成的图像添加 C2PA 元数据
- AI 生成的图像保存仅限于支持 C2PA 的格式:PNG、JPEG、GIF 和 .paint
A curious look at Microsoft Paint
对微软画图的好奇探索
This research started with my curiosity about Paint. I recently had some success looking into less-explored Windows features like UCPD, WHESCVC, and I have long known that Microsoft added a bunch of AI features into the Paint app. I do not know if anyone actually uses Paint + AI to generate images, but I wanted to see how exactly the image generation works.
这项研究始于我对画图应用的好奇。我最近在探索一些较少被关注的 Windows 功能(如 UCPD、WHESCVC)上取得了一些成功,而且我早就知道微软在画图应用中添加了一系列 AI 功能。我不知道是否真的有人使用画图加 AI 来生成图像,但我想看看图像生成究竟是如何工作的。
Before I started, I expected that it simply called a remote API to do the image generation. However, after I set up Binary Ninja MCP with Codex and started the analysis, I soon realized that Microsoft actually shipped local models in Windows as part of Copilot.
在开始之前,我预期它只是调用远程 API 进行图像生成。然而,在我设置好 Binary Ninja MCP 与 Codex 并开始分析后,我很快意识到微软实际上在 Windows 中作为 Copilot 的一部分内置了本地模型。
The Paint App is sitting in the following path (yes, they are all Windows Apps now):
画图应用位于以下路径(是的,它们现在都是 Windows 应用):
C:\Program Files\WindowsApps\Microsoft.Paint_11.2605.71.0_x64__8wekyb3d8bbwe\PaintApp\C:\Program Files\WindowsApps\Microsoft.Paint_11.2605.71.0_x64__8wekyb3d8bbwe\PaintApp\And there are four apparent model files with the .onnxe extension:
并且有四个明显的模型文件,扩展名为 .onnxe:
seg.onnxe 23.1 MB
inseg_enc.onnxe 28.0 MB
inseg_dec.onnxe 16.5 MB
mager.onnxe 302.4 MBseg.onnxe 23.1 MB
inseg_enc.onnxe 28.0 MB
inseg_dec.onnxe 16.5 MB
mager.onnxe 302.4 MBThe format of seg.onnxe was previously known, i.e., when it is XORed with the string Microsoft_2023, it becomes a normal ONNX file. However, the format of the other three .onnxe files initially looked different.
seg.onnxe 的格式此前已知,即当它与字符串 Microsoft_2023 进行异或运算后,会变成一个正常的 ONNX 文件。然而,其他三个 .onnxe 文件的格式最初看起来有所不同。
It turned out that Microsoft had not changed the algorithm, only the key. segapi.dll contains a small key registry:
事实证明,微软并没有改变算法,只是更改了密钥。segapi.dll 包含一个小型密钥注册表:
ps_enc_key.1.0.80-main -> "Microsoft_2023"
ps_enc_key.1.0.81-main -> a 4,096-byte alphanumeric stringps_enc_key.1.0.80-main -> "Microsoft_2023"
ps_enc_key.1.0.81-main -> a 4,096-byte alphanumeric stringAfter decryption, onnx.checker.check_model() works on all of them:
解密后,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 |
| 模型 | 图 |
|---|---|
| seg.onnx | 1,094 个节点,输入 input_image,输出 output |
| inseg_enc.onnx | 1,014 个节点,输出 image_embeddings |
| inseg_dec.onnx | 1,133 个节点,输入为嵌入、点和掩码;输出掩码 |
| mager.onnx | 15,284 个节点,图像/掩码输入;输出 output |
A visible watermark
可见水印
While walking through these files, I found a Watermarker.dll:
在浏览这些文件时,我找到了一个 Watermarker.dll:
This is not super surprising to me, because while I interacted with the Paint app, I already discovered that it has a setting to embed a visible watermark to the image that it produces:
这对我来说并不太令人惊讶,因为在我与画图应用交互时,我已经发现它有一个设置,可以在生成的图像中嵌入可见水印:
The visible watermark is just a small Copilot logo at the bottom right of the image, which is totally normal.
可见水印只是图像右下角的一个小Copilot标志,这完全正常。
Then, out of nowhere, I decided to ask AI to analyze the DLL and see if it could also be embedding an invisible watermark. This is part of my intuition as a reverse engineer, because the file is 1.67 MB in size, which is unusually large for such trivial functionality (arguably, the visible watermark does not even require a separate DLL). Apparently, the recent Claude Code text-watermark announcement also played a role in prompting me to think about this possibility.
然后,我突发奇想,决定让AI分析这个DLL,看看它是否也在嵌入不可见水印。这源于我作为逆向工程师的直觉,因为该文件大小为1.67 MB,对于如此简单的功能来说异常庞大(可以说,可见水印甚至不需要单独的DLL)。显然,最近关于Claude Code文本水印的公告也促使我考虑这种可能性。
An invisible watermark
不可见水印
To begin with, the visible watermark is added by AddPerceptibleWatermark:
首先,可见水印是由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 logoCPBDoc::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 logoThen there is also a different WmkWriteWatermark function:
此外,还有一个不同的WmkWriteWatermark函数:
Watermarker.dll!WmkWriteWatermark(
output_pixels,
payload,
payload_length,
width,
height,
stride,
input_pixels,
pixel_format);Watermarker.dll!WmkWriteWatermark(
output_pixels,
payload,
payload_length,
width,
height,
stride,
input_pixels,
pixel_format);Tracing the call tree, we can see WmkWriteWatermark is called after a local Stable Diffusion image generation. And if WmkWriteWatermark fails, Paint converts the entire generation into an error rather than returning the image without it:
追踪调用树,我们可以看到WmkWriteWatermark在本地Stable Diffusion图像生成后被调用。如果WmkWriteWatermark失败,画图会将整个生成过程转为错误,而不是返回没有水印的图像:
| ``` | ||
|---|---|---|
| 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 | ||
| ``` |
| ``` | ||
|---|---|---|
| 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 | ||
| ``` |
Then it is natural to ask what the incoming payload actually is. It quickly becomes apparent that it must be 16 bytes:
那么自然要问传入的有效载荷到底是什么。很快就能看出它必须是16字节:
if (payload_length < 16)
return -6;
if (payload_length > 16)
return -5;if (payload_length < 16)
return -6;
if (payload_length > 16)
return -5;It is funny to me that the code is using two different error codes when the payload is too short or too long. The function then ignores the length parameter and uses a hard-coded loop bound when it copies the payload:
有趣的是,当有效载荷太短或太长时,代码使用了两种不同的错误代码。然后,该函数忽略长度参数,在复制有效载荷时使用硬编码的循环边界:
for (size_t i = 0; i < 16; i++)
message.push_back(payload[i]);for (size_t i = 0; i < 16; i++)
message.push_back(payload[i]);We do not yet know what the 16-byte payload is, but as we will see later, it is a GUID! WmkWriteWatermark does not embed the GUID directly. Its wrapper constructs the following 18-byte (144-bit) message:
我们还不知道这16字节的有效载荷是什么,但稍后我们会看到,它是一个GUID!WmkWriteWatermark并不直接嵌入GUID。它的包装器构造了以下18字节(144位)的消息:
0x4c || GUID[0..15] || (sum of the 16 GUID bytes modulo 256)0x4c || GUID[0..15] || (sum of the 16 GUID bytes modulo 256)The core encoder rounds the usable image dimensions down to multiples of eight and keeps 144 counters, one for each bit. It requires every bit to be placed at least three times.
核心编码器将可用的图像尺寸向下取整为8的倍数,并保留144个计数器,每个位一个。它要求每个位至少放置三次。
The encoder itself can be summarized as:
编码器本身可以概括为:
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 bufferWmkWriteWatermark(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 bufferThe embedding loop performs small quantized changes over selected image blocks. It contains 3-by-5 matrix operations and a matrix-decomposition routine, and it uses constants including 24.0, 0.25, 0.5, and 0.2. This looks like a content-adaptive block-domain, SVD-style watermark.
嵌入循环在选定的图像块上执行小的量化更改。它包含3×5矩阵运算和矩阵分解例程,并使用包括24.0、0.25、0.5和0.2在内的常量。这看起来像是一种内容自适应的块域SVD风格水印。
I am not an expert in image watermarking, but one thing should be clear – this is an invisible watermark! AI even wrote some code to call this function directly and tested it with a synthetic 512-by-512 BGRA image – 193,376 of the 262,144 pixels changed after adding the watermark.
我不是图像水印方面的专家,但有一点应该清楚——这是一个不可见水印!AI甚至编写了一些代码直接调用此函数,并用合成的512×512 BGRA图像进行了测试——添加水印后,262,144个像素中有193,376个发生了变化。
That led to the next question. Where does the input of the watermark come from?
这引出了下一个问题。水印的输入来自哪里?
a GUID from remote prompt moderation
来自远程提示审核的GUID
更进一步:量化金融体系
看懂新闻只是起点——沿量化金融路径,把它变成能交付的工程能力