← 文章 / AI技术
Hacker News 2小时前 · 2026-08-25 03:25:13 · 2 阅读

微软画图和Photos:本地AI生成图像竟隐藏GUID水印

  • AI 生成的图像只能保存为保留 C2PA 信息的格式:PNG、JPEG、GIF,以及

Paint 将用户提示发送给微软的审核服务器,接收经审核的提示和水印 GUID,本地生成图像,并把 GUID 嵌入最终图像的像素中

这项研究的起因是我对画图(Paint)的好奇。最近我在研究一些不太被关注的 Windows 特性时有所收获,比如 UCPDWHESCVC,而且我早就知道微软在画图应用里塞了一堆 AI 功能。我不确定是否真有人用画图 + AI 来生成图像,但我想搞清楚图像生成究竟是如何运作的。

开始动手之前,我本以为它只是调用一个远程 API 来完成图像生成。然而,当我配好 Binary Ninja MCP 配合 Codex 开始分析后,很快就发现微软实际上是把本地模型作为 Copilot 的一部分直接内置到了 Windows 里。

画图应用位于以下路径(没错,现在它们全都是 Windows Apps 了):

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 的格式 之前就有人解析过:用字符串 Microsoft_2023 与之做 XOR 运算,就能还原成正常的 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 个节点,输入包含 embeddings、点和掩码;输出 masks
mager.onnx 15,284 个节点,输入为图像和掩码;输出 output

可见水印

在浏览这些文件的过程中,我发现了 Watermarker.dll

Microsoft Paint 自带的 Watermarker.dll 属性

对此我并不感到特别意外,因为在体验 Paint 应用时,我已经发现它有一个设置,可以在生成的图片上嵌入一个可见水印

Paint 为可见水印提供了「从不」「始终」和「每次询问」三个选项

这个可见水印只是图片右下角一个小小的 Copilot 标志,完全正常。

然后,我毫无征兆地决定让 AI 分析一下这个 DLL,看看它是否会同时嵌入一个不可见水印。这是我作为逆向工程师的一种直觉——因为这个文件大小有 1.67 MB,对于这样简单的功能来说异常庞大(可见水印甚至未必需要单独一个 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);

顺着调用链追踪,可以看到本地 Stable Diffusion 图像生成之后会调用 WmkWriteWatermark。而一旦 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;

有意思的是,当 payload 太短或太长时,代码用了两个不同的错误码。该函数接着忽略 length 参数,复制 payload 时使用的是写死的循环边界:

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)
  |
  +-- 校验指针、格式、stride 和载荷长度
  +-- 要求 width >= 192 且 height >= 192
  +-- 构造载荷
  |     `-- 0x4c || GUID || 字节和校验
  +-- 将 18 字节展开为 144 个独立比特
  +-- 将可用尺寸向下取整到 8 像素边界
  +-- 扫描/选取合适的图像块
  +-- 根据每个比特对所选块/矩阵进行量化
  +-- 要求每个比特至少成功嵌入三次
  |     |
  |     `-- 容量不足 -> 返回 -8
  `-- 将 RGB 像素重建到输出缓冲区

嵌入循环会对选中的图像块执行小幅量化修改,其中包含 3×5 矩阵运算和一个矩阵分解例程,用到的常量包括 24.00.250.50.2。看起来这是一种内容自适应的块域 SVD 风格水印。

我并非图像水印领域的专家,但有一点是显而易见的——这是一种不可见水印!AI 甚至写了一些代码直接调用该函数,并在一张合成的 512×512 BGRA 图像上进行了测试——添加水印后,262,144 个像素中有 193,376 个发生了变化。

这引出了下一个问题:水印的输入究竟来自哪里?

来自远程提示词审核的 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": "..."
}

响应解析器期望的格式为:

{
  "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
}

我还试了另一条提示词 a portrait of a smiling person wearing a blue hat。这次响应里的 GUID 换成了一对不同的值,同时 containsHumanReferencetrue。这说明该字段是服务端对「提示词是否涉及人物」的分类结果。Paint 会解析并存储它以及那些 ID,不过我没发现它控制水印步骤本身的证据。

ParseModerateResponse 会把这两个 ID 字符串都解析为 GUID,对零值分别抛出 InvalidPromptGenerationIdInvalidWatermarkId 错误。最终嵌入生成图像的正是服务器返回的 watermarkId

PaintUI.dll
  `-- IPromptModerationService
        `-- PaintAIManager.dll
              `-- AIServices.dll!ModerateAsync(...)
                    |
                    +-- 构建 JSON
                    |     +-- prompt
                    |     +-- style
                    |     `-- lastPromptGenerationId
                    |
                    +-- HTTPS POST /v1/paint-cocreator/moderate-prompt
                    |
                    `-- AIServices.dll!ParseModerateResponse(response)
                          +-- revisedPrompt
                          +-- promptGenerationId -> 解析为 GUID
                          +-- watermarkId        -> 解析为 GUID
                          `-- containsHumanReference
                                |
                                `-- PaintUI 存储 WatermarkId
                                      `-- StableDiffusionHelpers::GenerateAsync(..., watermarkId, ...)
                                            `-- 本地 Stable Diffusion 结果
                                                  `-- Paint::AI::AddWatermark(bitmap, winrt::guid const&)
                                                        `-- WmkWriteWatermark(..., guid, 16, ...)
                                                              `-- 修改后的 RGB 像素

换句话说,"本地生成"并不意味着整个操作都在本地完成。Microsoft 会接收并审核 prompt,然后下发唯一的 GUID,Paint 将其嵌入到本地生成的图像中。Paint 在下一次审核请求时还会把上一次的 promptGenerationId 作为 lastPromptGenerationId 一并发送,从而可以把连续的请求显式关联起来。

C2PA 元数据中也包含同一个水印 GUID

事情还有另一层。Paint 做的不仅仅是修改像素,它还会向保存的文件附加 C2PA Content Credentials。负责这部分逻辑的代码位于 ProvenanceHelper.dll,底层依赖 provenancesdk.dll

对于本地 Stable Diffusion 这条路径,流程如下:

本地 Stable Diffusion 生成结果
  |
  +-- 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
        |     |     `-- 审核分数
        |     `-- imageToSign.jpg
        |
        `-- ParseProvenanceResponse(...)
              `-- 服务器返回的 C2PA manifest
                    `-- ProvenanceHelper::InsertManifestIngredient(...)
                          `-- AuthoringFinalizeOutputToBufferAsync(...)
                                `-- 带 C2PA 元数据的最终图片

注意,签名请求会发送 PromptGenerationId,而图片本身已包含单独返回的 watermarkId。这两个值都由服务器在审核阶段分配,因此它能将签名请求与已存在于提交像素中的水印关联起来。

随后我直接从 Paint 的 Image Creator 保存了一张真实图片,并检查了其 PNG chunks。在 IHDR 之后紧跟一个 18,979 字节的 caBX chunk,其中包含一份已签名的 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 将其称为软绑定:将一个由内容派生或嵌入内容中的值保留下来,这样即使文件级清单被移除,也能继续将内容与其溯源记录匹配起来。对于水印软绑定来说,value 就是水印的内容标识符。Microsoft 对这条声明进行了加密签名。

为什么 Paint 要在本地添加水印?

到这里,Watermarker.dll 的存在就更容易理解了。Paint 实际上有两条截然不同的生成路径。

我在上文测试的 Image Creator 功能使用的是 Azure OpenAI ImageGen。图像生成、水印添加和溯源信息封装都可以在 Microsoft 的云端完成,Paint 只需接收一张已经同时包含隐形水印和 C2PA 清单的成品图片:

Image Creator
  `-- Microsoft cloud
        +-- 内容过滤
        +-- Azure OpenAI ImageGen
        +-- 隐形水印
        +-- C2PA 清单
        `-- 将成图返回 Paint

Cocreator 则不同。Microsoft 表示,在受支持的 Copilot+ PC 上,图像由 NPU 在本地生成,而安全检查仍由 Azure 在线服务执行。因此,即使实际的 Stable Diffusion 推理是在设备上运行的,该功能仍同时需要 Microsoft 账户和互联网连接:

Copilot+ PC 上的 Cocreator
  |
  +-- 提示词 -> Microsoft 内容审核服务
  |                 +-- revisedPrompt
  |                 +-- promptGenerationId
  |                 `-- watermarkId
  |
  +-- revisedPrompt + 草图 -> 本地 NPU 生成
  |
  +-- Watermarker.dll -> 在本地嵌入 watermarkId
  |
  `-- 在线溯源签名 -> 最终 C2PA 清单

这大概就是 Paint 必须实现本地水印功能的原因。云端生成器可以在返回输出前为图片添加水印,而本地生成器无法依赖云端完成这一步,因此 Paint 必须自行修改本地生成的像素。这也解释了为什么 Paint 会将 WmkWriteWatermark 的失败视为整个生成过程失败,而不是静默返回一张没有水印的图片。

还有一个相当直观的迹象,表明微软在设计保存路径时就考虑到了来源溯源。当我从 Image Creator 面板直接保存生成结果时,Paint 只提供一种格式:PNG。

从 AI 生成结果直接保存时,Paint 只提供 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 水印步骤的可能性,那么这可能是一个产品缺陷。如果微软将水印视为强制性的滥用防范或溯源控制手段,而该端点又可以被绕过,那么这可能就是一个安全漏洞。在不了解预期信任边界的情况下,这三种可能性都无法排除。

Photos 应用也采取了同样的做法

在我试图在磁盘上定位 Watermarker.dll 时,偶然发现 Microsoft Photos 中也包含了一个同名 DLL:

C:\Program Files\WindowsApps\
  Microsoft.Windows.Photos_2026.11060.2004.0_x64__8wekyb3d8bbwe\Watermarker.dll

Photos 的 Image Creator 和 Restyle Image 功能背后也有本地 Stable Diffusion 操作。两者都走同一个水印包装层:

Photos Image Creator
  `-- PerformSDTextToImageAndWatermarkAsync(..., promptGenerationId, ...)
        +-- 运行本地文生图模型
        `-- ApplyWatermark(image, promptGenerationId)
              +-- 将 promptGenerationId 解析为 GUID
              +-- ConvertGUIDtoContiguousByteArray()
              +-- 将 RGBA 转为 ARGB
              +-- Watermarker.dll!WmkWriteWatermark(..., guid, 16, ...)
              `-- 将 ARGB 转回 RGBA

Restyle Image 走的是一条并行路径:

Photos Restyle Image
  `-- PerformSDSketchToImageAndWatermarkAsync(..., promptGenerationId, ...)
        `-- ApplyWatermark(image, promptGenerationId)
              `-- Watermarker.dll!WmkWriteWatermark(..., guid, 16, ...)

Photos 和 Paint 之间一个细微的差别在于失败时的行为。如果水印编码器返回错误,相关代码会记录:

ApplyWatermark encountered error: ... - watermark will not be applied.

之后看起来会继续把生成的图像返回出去。而 Paint 则把水印失败当作生成失败来处理,用户拿不到图像。

微软披露了些什么

做完这些分析之后,我发现微软在其 Image Creator 支持页面 上确实披露了系统的部分相关内容。关于内容过滤,它是这样说的:

「我们对生成内容应用过滤,以防止图像的生成」

同一页面还说,生成的图像:

「将包含 C2PA 清单,帮助用户识别其为 AI 生成的图像。」

页面还解释了 Image Creator 使用 Azure 在线服务,并说明微软会收集用户和设备标识符以及提示,用于滥用防范和监控。这算是对远程过滤和 C2PA 元数据的一项有意义的披露。

该页面没有说明的是,C2PA 清单中包含一个用于标识隐形像素水印的 GUID,而且 Paint 本地生成路径的水印 GUID 来自远程的提示词审核。将该功能称为"内容凭据"(Content Credentials)虽然准确,但 Windows 用户从中无法直观看出这个与提示词关联的标识符。

结论

据我所知,这是第一份记录并分析 Paint 和 Photos 隐形水印行为的研究。AI 生成图像上的可见水印并非新鲜事——Microsoft 在 Microsoft 365Bing Image Creator 中都有相关说明——隐形像素水印也同样如此,比如 Google 的 SynthIDBing 的隐藏水印

Microsoft 确实披露了 Paint 使用远程内容过滤并添加 C2PA 内容凭据。新的证据表明,这些元数据并非只是一个无关的文件级 AI 标签:其已签名的 c2pa.soft-binding 断言命名了 Microsoft InvisMark,并记录了由隐形像素水印携带的标识符。文件级清单和像素级水印是同一溯源系统的两个层面。

本地和云端路径也解释了这套与众不同的分工。云端 Image Creator 可以直接返回已完成水印嵌入和签名操作的图像,而 Cocreator 则必须在本地 NPU 推理之后嵌入服务器签发的标识符。在这两种情况下,"本地"并不意味着离线:提示词仍需发送到 Microsoft 进行审核,本地生成的最终结果也要经过在线的溯源签名流程。

这可能与欧盟 AI 法案第 50 条有关。该条款的透明度规则于 2026 年 8 月 2 日生效,要求 AI 生成内容必须带有可检测、机器可读的标记——但并不要求针对具体提示词的 GUID。微软披露了 C2PA 元数据的存在,但我没找到任何说明解释服务器签发的水印 GUID、它与提示词审核的关联,以及它嵌入像素中的方式。这些细节涉及明显的隐私权和知情权问题。

看起来也可以修改 Paint 或 Photos 来同时绕过提示词审核和水印机制。但这并不能带来新的能力——任何人都已经可以直接运行 Stable Diffusion,完全不经过这两层机制。

原始来源: Hacker News

评论 (0)