TL;DR

  • Microsoft Paint поддерживает как локальную, так и облачную генерацию изображений
  • Paint и Photos также используют локальные AI-модели
  • Оба приложения отправляют промпт на удалённый сервер для модерации
  • Сервер возвращает GUID вместе с отредактированным промптом
  • Этот GUID встраивается в локально сгенерированное изображение как невидимый водяной знак
  • Отдельная настройка видимого водяного знака никак не влияет на невидимый
  • На Copilot+ PC генерация изображения происходит локально, но модерация промпта остаётся удалённой
  • Microsoft раскрывает, что Paint добавляет метаданные C2PA к сгенерированным изображениям
  • Сохранение AI-изображений ограничено форматами, поддерживающими C2PA: PNG, JPEG, GIF и .paint

Любопытный взгляд на Microsoft Paint

Исследование началось с интереса к внутреннему устройству Paint. После успешного изучения менее известных функций Windows, таких как UCPD и WHESCVC, было давно известно, что Microsoft добавила в Paint набор AI-функций. Неизвестно, пользуется ли кто-то реально генерацией изображений в Paint, но хотелось разобраться, как именно она устроена.

Изначально предполагалось, что приложение просто вызывает удалённый API для генерации. Однако после настройки Binary Ninja MCP совместно с Codex и начала анализа быстро выяснилось, что Microsoft на самом деле поставляет локальные модели прямо в составе Windows как часть Copilot.

Приложение Paint расположено по следующему пути (да, теперь все они — 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 был известен ранее: если применить к нему XOR со строкой Microsoft_2023, получается обычный файл ONNX. Однако формат трёх других файлов .onnxe на первый взгляд отличался.

Оказалось, что Microsoft не поменяла алгоритм — только ключ. В segapi.dll содержится небольшой реестр ключей:

ps_enc_key.1.0.80-main -> "Microsoft_2023"
ps_enc_key.1.0.81-main -> строка из 4096 алфавитно-цифровых символов

После расшифровки на всех файлах успешно отрабатывает onnx.checker.check_model():

Модель Граф
seg.onnx 1094 узла, вход input_image, выход output
inseg_enc.onnx 1014 узлов, выход image_embeddings
inseg_dec.onnx 1133 узла, входы для эмбеддингов, точек и масок; выход masks
mager.onnx 15 284 узла, входы изображения/маски; выход output

Видимый водяной знак

При просмотре этих файлов обнаружился Watermarker.dll:

Свойства файла Watermarker.dll, входящего в состав Microsoft Paint

Это не стало большим сюрпризом, поскольку при работе с Paint уже было известно о настройке добавления видимого водяного знака на сгенерированные изображения:

Paint предлагает варианты «Никогда», «Всегда» и «Спрашивать каждый раз» для видимого AI-водяного знака

Видимый водяной знак — это просто небольшой логотип Copilot в правом нижнем углу изображения, что вполне ожидаемо.

Затем возникла идея попросить AI проанализировать эту DLL и проверить, не встраивает ли она заодно и невидимый водяной знак. Такая интуиция реверс-инженера была подкреплена тем, что файл весит 1,67 МБ — необычно много для такой тривиальной функциональности (можно даже сказать, что видимый водяной знак вообще не требует отдельной DLL). Свою роль также сыграло недавнее объявление Anthropic о текстовых водяных знаках в 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);

Прослеживая дерево вызовов, видно, что WmkWriteWatermark вызывается после локальной генерации изображения через Stable Diffusion. Если 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. При этом функция игнорирует параметр длины и использует жёстко заданную границу цикла при копировании данных:

for (size_t i = 0; i < 16; i++)
    message.push_back(payload[i]);

Пока неизвестно, что представляет собой 16-байтовый payload, но, как выяснится позже, это GUID! WmkWriteWatermark не встраивает GUID напрямую — его обёртка формирует следующее 18-байтовое (144-битное) сообщение:

0x4c || GUID[0..15] || (sum of the 16 GUID bytes modulo 256)

Основной кодировщик округляет полезные размеры изображения вниз до кратных восьми и хранит 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.

Экспертизы в области водяных знаков для изображений нет, но одно понятно точно — это невидимый водяной знак! AI даже написал код для прямого вызова этой функции и протестировал её на синтетическом изображении 512×512 BGRA — после добавления водяного знака изменилось 193 376 пикселей из 262 144.

Дальше встал вопрос: откуда берётся вход для водяного знака?

GUID от удалённой модерации промпта

На границе WmkWriteWatermark payload — это лишь указатель и длина. То, что он должен быть равен 16 байтам, — подсказка, но 16 байт могут означать многое. Пришлось идти обратным путём через вызывающие функции. Непосредственная обёртка в PaintAIManager.dll имеет такую сигнатуру:

Paint::AI::AddWatermark(
    Gdiplus::Bitmap& image,
    winrt::guid const& watermarkId);

winrt::guid — вот это уже интереснее! Теперь понятно, что 16-байтовый payload водяного знака — действительно GUID.

Дальнейшее отслеживание источника показывает, что GUID приходит из сетевого запроса. Перед запуском локальной модели генерации изображения AIServices.dll отправляет промпт и стиль по адресу:

https://apsaiservices-a0fqcjc6bzbhgdcd.b02.azurefd.net/
v1/paint-cocreator/moderate-prompt

Запрос в формате JSON содержит как минимум следующие поля:

{
  "prompt": "...",
  "style": "...",
  "lastPromptGenerationId": "..."
}

Парсер ответа ожидает:

{
  "revisedPrompt": "...",
  "promptGenerationId": "...",
  "watermarkId": "...",
  "containsHumanReference": false
}

Статический анализ — это хорошо, но захотелось увидеть реальный ответ сервера. Была переиспользована аутентифицированная сессия самого Paint, и через endpoint модерации был отправлен следующий промпт:

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. Таким образом, это поле — серверная классификация того, относится ли промпт к человеку. Paint парсит и сохраняет это значение вместе с идентификаторами, хотя доказательств того, что оно как-то влияет на сам процесс встраивания водяного знака, найдено не было.

ParseModerateResponse парсит обе строки идентификаторов как GUID и отклоняет нулевые значения с ошибками InvalidPromptGenerationId или InvalidWatermarkId. Именно серверный watermarkId становится частью сгенерированного изображения:

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

Иными словами, «сгенерировано локально» вовсе не означает, что вся операция происходит локально. Microsoft получает и модерирует промпт, а затем выдаёт уникальный GUID, который Paint встраивает в локально сгенерированное изображение. Кроме того, Paint отправляет предыдущий promptGenerationId как lastPromptGenerationId в следующем запросе модерации, что позволяет явно связывать последовательные запросы между собой.

Тот же GUID водяного знака в метаданных C2PA

Есть ещё одна важная деталь. Paint не только меняет пиксели — он также прикрепляет к сохранённому файлу C2PA Content Credentials. За это отвечает код в ProvenanceHelper.dll, опирающийся на provenancesdk.dll.

Для пути локальной генерации через Stable Diffusion цепочка выглядит так:

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

Обратите внимание: запрос на подпись отправляет PromptGenerationId, тогда как изображение уже содержит отдельно возвращённый watermarkId. Оба значения были назначены сервером во время модерации, поэтому он может связать запрос на подпись с водяным знаком, уже присутствующим в переданных пикселях.

Далее было сохранено реальное изображение прямо из Image Creator в Paint и проверены его PNG-чанки. Сразу после IHDR обнаружился чанк caBX размером 18 979 байт, содержащий подписанный манифест 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"
      }
    ]
  }
}

В более читаемом виде манифест сообщает следующее:

  • Генератор: 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.soft-binding.value в C2PA — это одно и то же значение для конкретной генерации.

Это соответствие важно. В терминологии C2PA такое называется soft binding (мягкая привязка): значение, извлечённое из контента или встроенное в него, чтобы контент можно было сопоставить с записью о происхождении даже после удаления файлового манифеста. Для водяного знака как мягкой привязки поле value — это идентификатор содержимого водяного знака. Microsoft криптографически подписывает это утверждение.

Почему Paint ставит водяной знак локально?

На этом этапе становится понятнее, зачем нужен Watermarker.dll. У Paint есть два довольно разных пути генерации.

Функция Image Creator, протестированная выше, использует Azure OpenAI ImageGen. Генерация, наложение водяного знака и упаковка происхождения могут полностью происходить в облаке Microsoft, а Paint просто получает готовое изображение, уже содержащее и невидимый водяной знак, и манифест C2PA:

Image Creator
  `-- Microsoft cloud
        +-- content filtering
        +-- Azure OpenAI ImageGen
        +-- invisible watermark
        +-- C2PA manifest
        `-- completed image returned to Paint

Cocreator устроен иначе. На поддерживаемых Copilot+ PC Microsoft заявляет, что NPU генерирует изображение локально, при этом онлайн-сервисы Azure по-прежнему выполняют проверки безопасности. Функция поэтому требует и учётной записи Microsoft, и подключения к интернету, хотя сам вывод Stable Diffusion выполняется на устройстве:

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 приходится самостоятельно изменять локально сгенерированные пиксели. Это же объясняет, почему сбой WmkWriteWatermark трактуется как сбой всей генерации, а не тихо возвращает немаркированное изображение.

Есть ещё один заметный признак того, что Microsoft спроектировала путь сохранения именно вокруг механизма происхождения. При сохранении результата прямо из панели Image Creator Paint предлагает ровно один формат: PNG.

Paint предлагает только PNG при прямом сохранении результата генерации AI

После применения результата AI к холсту Paint доступные форматы по-прежнему ограничены PNG, JPEG, GIF и собственным форматом .paint. BMP — классический формат Paint — заметно отсутствует в списке.

Это согласуется с форматами, поддерживаемыми C2PA. PNG хранит манифест в чанке caBX, JPEG использует один или несколько сегментов маркера APP11, а GIF имеет собственное представление расширения приложения для C2PA. Формат .paint контролируется Microsoft и может сохранять любое необходимое состояние происхождения. В то же время спецификация C2PA явно называет BMP классическим форматом, который не может встраивать произвольные данные манифеста без использования внешнего манифеста. Если бы Paint позволял экспортировать изображение напрямую в BMP, файловый манифест C2PA попросту исчезал бы.

Это разделение поднимает интересный вопрос безопасности в отношении облачного пути. Если удалённый endpoint генерации изображений можно заставить вернуть сгенерированное изображение до наложения водяного знака и упаковки происхождения — или если у него есть внутренняя опция, отключающая эти этапы — теоретически можно получить сгенерированное в облаке изображение без обоих сигналов.

Как классифицировать такой сценарий, полностью зависит от изначального замысла Microsoft. Это может быть предусмотренным поведением, если базовому сервису разрешено возвращать необработанные генерации, а Paint лишь отвечает за применение слоёв происхождения. Это может быть багом продукта, если Microsoft не учла возможность прямого вызова API в обход механизма водяных знаков Paint. А может быть и уязвимостью безопасности, если Microsoft считает водяной знак обязательным элементом защиты от злоупотреблений или контроля происхождения, а endpoint можно заставить его обойти. Без знания предполагаемых границ доверия все три варианта остаются открытыми.

Приложение Photos делает то же самое

При попытке найти Watermarker.dll на диске выяснилось, что Microsoft Photos содержит DLL с тем же именем:

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

За функциями Image Creator и Restyle Image в Photos тоже стоят локальные операции Stable Diffusion. Оба пути ведут к одной и той же обёртке водяного знака:

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

Restyle Image идёт параллельным путём:

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

Есть тонкое различие между Photos и Paint в поведении при ошибке. Если кодировщик водяного знака возвращает ошибку, код Photos логирует:

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

После этого приложение, судя по всему, всё равно возвращает сгенерированное изображение. Paint же, напротив, считает сбой наложения водяного знака сбоем всей генерации, и изображение пользователю не возвращается.

Что раскрывает Microsoft

После проведённого анализа выяснилось, что Microsoft всё же раскрывает некоторые смежные части системы на странице поддержки Image Creator. О фильтрации контента там сказано:

«we apply content filtering to prevent the generation of images»

Там же говорится, что сгенерированные изображения:

«will contain C2PA manifest helping users identify that it is an AI generated image.»

Также поясняется, что Image Creator использует онлайн-сервисы Azure, и указывается, что Microsoft собирает идентификаторы пользователя и устройства вместе с промптами для предотвращения злоупотреблений и мониторинга. Это довольно содержательное раскрытие информации об удалённой фильтрации и метаданных C2PA.

Чего страница не объясняет, так это того, что манифест C2PA содержит GUID, идентифицирующий невидимый пиксельный водяной знак, и что путь локальной генерации Paint получает этот GUID именно от удалённой модерации промпта. Название функции «Content Credentials» точное, но оно не делает этот привязанный к промпту идентификатор очевидным для обычного пользователя Windows.

Заключение

Насколько известно, это первое исследование, документирующее и анализирующее поведение невидимых водяных знаков в Paint и Photos. Видимые водяные знаки на изображениях, сгенерированных AI, — не новость: Microsoft описывает их для Microsoft 365 и Bing Image Creator. Не новость и невидимые пиксельные водяные знаки — например, SynthID от Google и скрытый водяной знак Bing.

Microsoft действительно раскрывает, что Paint использует удалённую фильтрацию контента и добавляет C2PA Content Credentials. Новые данные показывают, что эти метаданные — не просто не связанная с водяным знаком файловая AI-метка: подписанное утверждение c2pa.soft-binding прямо называет Microsoft InvisMark и фиксирует тот же идентификатор, что несёт невидимый пиксельный водяной знак. Файловый манифест и пиксельный водяной знак — это два слоя одной и той же системы происхождения контента.

Разделение на локальный и облачный пути также объясняет необычное распределение обязанностей. Облачный Image Creator может вернуть уже помеченное водяным знаком и подписанное изображение, тогда как Cocreator вынужден встраивать выданный сервером идентификатор уже после локального вывода на NPU. В обоих случаях «локально» не означает «офлайн»: промпт всё равно уходит к Microsoft на модерацию, а готовый локальный результат проходит через онлайн-подпись происхождения.

Это может быть связано со Статьёй 50 Акта ЕС об AI, чьи правила прозрачности вступили в силу 2 августа 2026 года и требуют, чтобы сгенерированный AI контент нёс обнаруживаемую машиночитаемую метку — но не специфичный для промпта GUID. Microsoft раскрывает сам факт наличия метаданных C2PA, но раскрытия, объясняющего серверный GUID водяного знака, его связь с модерацией промпта или его присутствие в пикселях, найти не удалось. Эти детали несут очевидные последствия для приватности и права пользователя на информацию.

Также похоже, что Paint или Photos можно модифицировать так, чтобы обойти и модерацию промпта, и наложение водяного знака. Впрочем, это не даёт какой-то принципиально новой возможности: любой желающий и так уже может запустить Stable Diffusion напрямую, без обоих этих механизмов.