메뉴
HN
Hacker News • 32일 전

MS 페인트, 로컬 생성 이미지에도 보이지 않는 GUID 워터마크 삽입

IMP
7/10
핵심 요약

리버스 엔지니어링 결과, 마이크로소프트 페인트와 사진 앱이 이미지를 로컬에서 생성하더라도 프롬프트 검열을 위해 서버로 전송하고, 서버가 발급한 GUID를 보이지 않는 워터마크로 픽셀에 삽입하는 것으로 밝혀졌습니다. 사용자가 설정할 수 있는 '보이는 워터마크' 옵션은 이 숨겨진 워터마크와 무관하게 동작하며, Copilot+ PC에서도 생성은 로컬이지만 검열은 원격으로 이루어집니다. AI 생성 이미지 저장은 C2PA 메타데이터가 유지되는 PNG, JPEG, GIF, .paint 형식으로만 가능합니다.

번역된 본문

마이크로소프트 페인트와 사진 앱, 로컬 생성 AI 이미지에 서버 발급 GUID를 보이지 않는 워터마크로 삽입

리버스 엔지니어링을 통해 페인트와 사진 앱이 서버에서 발급한 GUID를 로컬 생성 AI 이미지의 픽셀에 삽입하는 방식이 밝혀졌습니다.

요약 (TL;DR)

  • 마이크로소프트 페인트는 로컬 및 클라우드 이미지 생성을 모두 지원함
  • 페인트와 사진 앱에는 로컬 AI 모델도 포함되어 있음
  • 두 앱은 프롬프트를 검열(모더레이션)을 위해 원격 서버로 전송함
  • 서버는 검열된 프롬프트와 함께 GUID를 반환함
  • 이 GUID는 로컬에서 생성된 이미지에 보이지 않는 워터마크로 삽입됨
  • 별도의 '보이는 워터마크' 설정은 이 보이지 않는 워터마크를 제어하지 못함
  • Copilot+ PC에서는 이미지 생성이 로컬로 이루어지지만 프롬프트 검열은 여전히 원격으로 수행됨
  • 마이크로소프트는 페인트가 AI 생성 이미지에 C2PA 메타데이터를 추가한다고 공개함
  • AI 생성 이미지 저장은 C2PA가 보존되는 형식(PNG, JPEG, GIF, .paint)으로만 가능함

마이크로소프트 페인트에 대한 호기심

이 연구는 페인트에 대한 호기심에서 시작되었습니다. 저는 최근 UCPD, WHESCVC 같이 덜 알려진 윈도우 기능들을 조사하면서 어느 정도 성과를 거두었고, 마이크로소프트가 페인트 앱에 다양한 AI 기능을 추가했다는 사실을 오래전부터 알고 있었습니다. 실제로 페인트+AI로 이미지를 생성하는 사람이 있는지는 모르겠지만, 이미지 생성이 정확히 어떻게 동작하는지 확인하고 싶었습니다.

분석을 시작하기 전에는 단순히 원격 API를 호출해 이미지를 생성할 것이라고 예상했습니다. 그러나 Binary Ninja MCP를 Codex와 함께 설정하고 분석을 시작하자마자, 마이크로소프트가 실제로 Copilot의 일부로 윈도우에 로컬 모델을 탑재했다는 사실을 곧 깨달았습니다.

페인트 앱은 다음 경로에 있습니다(네, 이제 모두 윈도우 앱입니다): 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개 노드, 임베딩·포인트·마스크 입력, 출력 masks
  • mager.onnx: 15,284개 노드, 이미지/마스크 입력, 출력 output

보이는 워터마크

이 파일들을 살펴보던 중 Watermarker.dll을 발견했습니다. 그다지 놀랍지는 않았습니다. 페인트 앱을 사용하면서 생성된 이미지에 보이는 워터마크를 삽입하는 설정이 있다는 것을 이미 알고 있었기 때문입니다. 보이는 워터마크는 이미지 우측 하단의 작은 Copilot 로고일 뿐이며, 이는 전혀 이상할 것이 없습니다.

그러다 문득 AI에게 이 DLL을 분석해서 보이지 않는 워터마크도 삽입하는지 확인해보자는 생각을 했습니다. 이것은 리버스 엔지니어로서의 직감의 일부인데, 이 파일의 크기가 1.67 MB로 이런 사소한 기능치고는 비정상적으로 크기 때문입니다(보이는 워터마크는 굳이 별도의 DLL이 필요하지도 않을 것입니다). 최근 Claude Code의 텍스트 워터마크 발표도 이 가능성을 생각하게 된 계기가 되었습니다.

보이지 않는 워터마크

먼저, 보이는 워터마크는 AddPerceptibleWatermark에 의해 추가됩니다:

CPBDoc::Save(...) |-- perceptible-watermark 저장 헬퍼(bitmap, WatermarkSetting) | +-- WatermarkSetting::Never | | -- 원본 비트맵 반환 | +-- WatermarkSetting::AskEveryTime | | -- 예/아니오 확인 팝업 표시 | | +-- 아니오: 원본 비트맵 반환 | | -- 예: 계속 진행 | -- 항상 또는 확인된 예 | +-- Paint::AI::GetPerceptibleWatermarkSvg() | -- Paint::AI::AddPerceptibleWatermark(bitmap, SVG 스트림) | -- 보이는 워터마크 합성

원문 보기
원문 보기 (영어)
Microsoft Paint and Photos Embed Server-Issued GUIDs as Invisible Watermarks in Locally-Generated Images Reverse engineering reveals how Paint and Photos embed a server-issued GUID into the pixels of locally generated AI images. 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 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. 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. The Paint App is sitting in the following path (yes, they are all Windows Apps now): C:\Program Files\WindowsApps\Microsoft.Paint_11.2605.71.0_x64__8wekyb3d8bbwe\PaintApp\ And there are four apparent model files with the .onnxe extension: seg.onnxe 23.1 MB inseg_enc.onnxe 28.0 MB inseg_dec.onnxe 16.5 MB mager.onnxe 302.4 MB The 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. It turned out that Microsoft had not changed the algorithm, only the key. segapi.dll contains a small key registry: ps_enc_key.1.0.80-main -> &#34;Microsoft_2023&#34; ps_enc_key.1.0.81-main -> a 4,096-byte alphanumeric string After decryption, onnx.checker.check_model() works on all of them: 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 A visible watermark While walking through these files, I found a 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. 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. An invisible watermark To begin with, the visible watermark is added by 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 Then there is also a different WmkWriteWatermark function: 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: 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: 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]); 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: 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. 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 buffer The 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. I am not an expert in image watermarking, but one thing should be clear &ndash; 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 &ndash; 193,376 of the 262,144 pixels changed after adding the watermark. That led to the next question. Where does the input of the watermark come from? a GUID from remote prompt moderation At the WmkWriteWatermark boundary, the payload is only a pointer and a length. Knowing that it must be 16 bytes was a clue, but many things can be 16 bytes. I therefore started walking backward through its callers. The immediate wrapper in PaintAIManager.dll has this symbolized signature: Paint :: AI :: AddWatermark( Gdiplus :: Bitmap & image, winrt :: guid const & watermarkId); winrt::guid , yikes! Now we know that the 16-byte watermark payload is indeed a GUID. Further tracking the source, we find that the GUID actually comes from a network request. Before Paint runs the local image model, AIServices.dll sends the prompt and style to: https://apsaiservices-a0fqcjc6bzbhgdcd.b02.azurefd.net/ v1/paint-cocreator/moderate-prompt The request is JSON and contains at least these fields: { &#34;prompt&#34; : &#34