Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Printing
DescriptionOut of bounds read in Printing
ComponentPrinting
Sub-componentPrinting
Bug ClassOOB
Tracker517804731
Fix commit91c5000a563a (chromium/src) +63/-5
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
printing/emf_win.cc
modified
TEST
printing/emf_win_unittest.cc
modified

Files Changed

  • printing/emf_win_unittest.cc
  • printing/emf_win.cc
From 91c5000a563ae5bd2fe97e20c0c62db229d3d039 Mon Sep 17 00:00:00 2001
From: Lei Zhang <thestig@chromium.org>
Date: Sat, 12 Sep 2026 00:11:52 -0700
Subject: [PATCH] Printing: Verify record size in PostScriptMetaFile::SafePlayback()

In PostScriptMetaFile::SafePlayback(), EMR_GDICOMMENT records could
potentially be too small:
- Emf::Enumerator::EnhMetaFileProc() only enforces record->nSize >= 8
  (kMinEnhMetaRecordSize), but an EMRGDICOMMENT structure requires 12
  bytes for its header before comment Data starts.
- SafePlayback() directly dereferenced a 16-bit payload length from
  comment->Data without checking if the record or comment contained at
  least 2 bytes.
- comment->cbData was not validated against the actual record size,
  which could allow ExtEscape() to read past the end of the record.

Fix this by:
- Adding a GetGdiCommentData() helper that validates that the record
  span is at least sizeof(EMRGDICOMMENT) and that comment->cbData fits
  within the record span.
- Using base::span in SafePlayback() to verify that comment data
  contains at least 2 bytes before decoding the payload length.
- Using base::U16FromNativeEndian() to safely read the 16-bit payload
  size without alignment hazards.
- Validating that the expected data size matches the comment data span
  size before passing it to ExtEscape().
- Adding unit test coverage in PostScriptMetaFileTest for undersized
  comment records.

TAG=agy
CONV=33d756ea-4edb-43ab-a6c9-b245813aa16c

Bug: 517804731
Change-Id: Ie6e13aad70a2bc0753cd0e4ecb0cd7b41c00337d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8395682
Reviewed-by: Will Harris <wfh@chromium.org>
Commit-Queue: Lei Zhang <thestig@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1696826}
---
diff --git a/printing/emf_win.cc b/printing/emf_win.cc
index 64e60935..3db474d 100644
--- a/printing/emf_win.cc
+++ b/printing/emf_win.cc
@@ -11,6 +11,8 @@
 
 #include "base/check_op.h"
 #include "base/compiler_specific.h"
+#include "base/containers/span.h"
+#include "base/numerics/byte_conversions.h"
 #include "base/numerics/checked_math.h"
 #include "base/numerics/safe_conversions.h"
 #include "printing/mojom/print.mojom.h"
@@ -93,6 +95,23 @@
   return record_span.subspan(sdib_record->offBitsSrc, sdib_record->cbBitsSrc);
 }
 
+base::span<const uint8_t> GetGdiCommentData(
+    base::span<const uint8_t> record_span) {
+  if (record_span.size() < sizeof(EMRGDICOMMENT)) {
+    return {};
+  }
+
+  const auto* comment =
+      reinterpret_cast<const EMRGDICOMMENT*>(record_span.data());
+  base::CheckedNumeric<uint32_t> end_data = offsetof(EMRGDICOMMENT, Data);
+  end_data += comment->cbData;
+  if (!end_data.IsValid() || end_data.ValueOrDie() > record_span.size()) {
+    return {};
+  }
+
+  return record_span.subspan(offsetof(EMRGDICOMMENT, Data), comment->cbData);
+}
+
 }  // namespace
 
 Emf::Emf() = default;
@@ -238,21 +257,31 @@
       continue;
     }
 
-    const auto* comment = reinterpret_cast<const EMRGDICOMMENT*>(emf_record);
-    const char* data = reinterpret_cast<const char*>(comment->Data);
+    // SAFETY: `Emf::Enumerator::EnhMetaFileProc()` verified that `nSize` fits
+    // within the remaining metafile buffer.
+    base::span<const uint8_t> record_span = UNSAFE_BUFFERS(base::span(
+        reinterpret_cast<const uint8_t*>(emf_record), emf_record->nSize));
+    base::span<const uint8_t> data_span = GetGdiCommentData(record_span);
+    if (data_span.size() < sizeof(uint16_t)) {
+      continue;
+    }
+
     // First uint16_t element in `data` holds the size of the rest of `data`,
     // which is the actual PostScript data. Windows requires this payload size +
     // PS data structure.
-    const uint16_t ps_payload_size = *reinterpret_cast<const uint16_t*>(data);
+    const uint16_t ps_payload_size =
+        base::U16FromNativeEndian(data_span.first<sizeof(uint16_t)>());
     const uint32_t data_size = 2 + ps_payload_size;
     // Assume value used in PDFium's core/fxge/win32/cpsoutput.cpp is not going
     // to change.
     static constexpr uint16_t kExpectedPdfiumMax = 1024 + 2;
-    if (data_size != comment->cbData || data_size > kExpectedPdfiumMax) {
+    if (data_size != data_span.size() || data_size > kExpectedPdfiumMax) {
       continue;
     }
 
-    int ret = ExtEscape(hdc, PASSTHROUGH, data_size, data, 0, nullptr);
+    int ret =
+        ExtEscape(hdc, PASSTHROUGH, static_cast<int>(data_size),
+                  reinterpret_cast<const char*>(data_span.data()), 0, nullptr);
     DCHECK_EQ(ps_payload_size, ret);
   }
   return true;
diff --git a/printing/emf_win_unittest.cc b/printing/emf_win_unittest.cc
index d749287..61276bdd 100644
--- a/printing/emf_win_unittest.cc
+++ b/printing/emf_win_unittest.cc
@@ -203,4 +203,33 @@
   EXPECT_EQ(remaining_size, 0u);
 }
 
+TEST(PostScriptMetaFileTest, SafePlaybackUndersizedComment) {
+  std::vector<char> data;
+  {
+    PostScriptMetaFile emf;
+    EXPECT_TRUE(emf.Init());
+    EXPECT_TRUE(emf.context());
+    // Add `EMR_GDICOMMENT` records where the comment size field is deliberately
+    // wrong/undersized (< sizeof(uint16_t)). A valid PostScript comment
+    // requires at least 2 bytes for the payload size header, so a size of 0 or
+    // 1 cannot hold a valid payload size.
+    EXPECT_TRUE(::GdiComment(emf.context(), 0, nullptr));
+    uint8_t byte = 0;
+    EXPECT_TRUE(::GdiComment(emf.context(), 1, &byte));
+    ::Rectangle(emf.context(), 10, 10, 190, 190);
+    EXPECT_TRUE(emf.FinishDocument());
+    uint32_t size = emf.GetDataSize();
+    EXPECT_GT(size, 0u);
+    EXPECT_TRUE(emf.GetDataAsVector(&data));
+  }
+
+  PostScriptMetaFile emf;
+  EXPECT_TRUE(emf.InitFromData(base::as_byte_span(data)));
+  base::win::ScopedCreateDC hdc(CreateCompatibleDC(nullptr));
+  ASSERT_TRUE(hdc.is_valid());
+  // `SafePlayback()` should safely skip the undersized comment records and must
+  // not crash from an out-of-bounds read.
+  EXPECT_TRUE(emf.SafePlayback(hdc.get()));
+}
+
 }  // namespace printing
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/printing/emf_win_unittest.cc b/printing/emf_win_unittest.cc
index d749287..61276bdd 100644
--- a/printing/emf_win_unittest.cc
+++ b/printing/emf_win_unittest.cc
@@ -203,4 +203,33 @@
   EXPECT_EQ(remaining_size, 0u);
 }
 
+TEST(PostScriptMetaFileTest, SafePlaybackUndersizedComment) {
+  std::vector<char> data;
+  {
+    PostScriptMetaFile emf;
+    EXPECT_TRUE(emf.Init());
+    EXPECT_TRUE(emf.context());
+    // Add `EMR_GDICOMMENT` records where the comment size field is deliberately
+    // wrong/undersized (< sizeof(uint16_t)). A valid PostScript comment
+    // requires at least 2 bytes for the payload size header, so a size of 0 or
+    // 1 cannot hold a valid payload size.
+    EXPECT_TRUE(::GdiComment(emf.context(), 0, nullptr));
+    uint8_t byte = 0;
+    EXPECT_TRUE(::GdiComment(emf.context(), 1, &byte));
+    ::Rectangle(emf.context(), 10, 10, 190, 190);
+    EXPECT_TRUE(emf.FinishDocument());
+    uint32_t size = emf.GetDataSize();
+    EXPECT_GT(size, 0u);
+    EXPECT_TRUE(emf.GetDataAsVector(&data));
+  }
+
+  PostScriptMetaFile emf;
+  EXPECT_TRUE(emf.InitFromData(base::as_byte_span(data)));
+  base::win::ScopedCreateDC hdc(CreateCompatibleDC(nullptr));
+  ASSERT_TRUE(hdc.is_valid());
+  // `SafePlayback()` should safely skip the undersized comment records and must
+  // not crash from an out-of-bounds read.
+  EXPECT_TRUE(emf.SafePlayback(hdc.get()));
+}
+
 }  // namespace printing
Loading diff…

Original Bug Report

reported by vm...@google.com

Heap OOB Read in PostScriptMetaFile::SafePlayback due to missing record bounds check

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential heap out-of-bounds read exists in PostScriptMetaFile::SafePlayback when parsing malformed GDI comment records from an EMF stream. By manipulating the record’s physical size (nSize) relative to its claimed comment data size (cbData), a compromised utility process could trigger an out-of-bounds read of up to 1022 bytes. This could lead to a crash of the privileged browser process (Denial of Service) or an information leak on Windows.

Affected files:

  • printing/emf_win.cc

Estimated timestamp from git blame: 2026-03-25

Summary

There is a potential Out-of-Bounds (OOB) heap read vulnerability in PostScriptMetaFile::SafePlayback inside printing/emf_win.cc. The function processes EMR_GDICOMMENT records from an EMF blob returned by the sandboxed PDF conversion utility process (kPdfConversion). However, it fails to perform proper bounds checking on the size of the record payload relative to the actual physical record boundary (emf_record->nSize). A compromised utility process could return a crafted EMF record that triggers a heap out-of-bounds read of up to 1022 bytes in the privileged browser process during print operations on Windows.

Vulnerability Analysis

In PostScriptMetaFile::SafePlayback (printing/emf_win.cc):

bool PostScriptMetaFile::SafePlayback(HDC hdc) const {
  Emf::Enumerator emf_enum(*this, nullptr, nullptr);
  for (const Emf::Record& record : emf_enum) {
    const ENHMETARECORD* emf_record = record.record();
    if (emf_record->iType != EMR_GDICOMMENT) {
      continue;
    }

    const auto* comment = reinterpret_cast<const EMRGDICOMMENT*>(emf_record);
    const char* data = reinterpret_cast<const char*>(comment->Data);
    const uint16_t ps_payload_size = *reinterpret_cast<const uint16_t*>(data);
    const uint32_t data_size = 2 + ps_payload_size;
    static constexpr uint16_t kExpectedPdfiumMax = 1024 + 2;
    if (data_size != comment->cbData || data_size > kExpectedPdfiumMax) {
      continue;
    }

    int ret = ExtEscape(hdc, PASSTHROUGH, data_size, data, 0, nullptr);
    DCHECK_EQ(ps_payload_size, ret);
  }
  return true;
}

The function reads ps_payload_size from comment->Data and compares data_size = 2 + ps_payload_size to comment->cbData and kExpectedPdfiumMax (1026). This is a consistency check on attacker-controlled values, not a physical bounds validation. Specifically, it never verifies that data_size (or comment->cbData) is less than or equal to emf_record->nSize - 12 (the actual space allocated for the payload inside the EMF record).

By default on Windows, out-of-process (OOP) printing is disabled because kEnableOopPrintDrivers is defined as FEATURE_DISABLED_BY_DEFAULT (see printing/printing_features.cc). Consequently, SafePlayback executes inside the privileged browser process.

A compromised utility process can return a crafted EMF blob with an EMR_GDICOMMENT record where nSize is small (e.g., 16 bytes, meaning comment->Data only has 4 bytes of valid allocation), but cbData is 1026 and ps_payload_size is 1024. All conditions in the validation check will be satisfied:

  • data_size == comment->cbData (1026 == 1026) -> True
  • data_size <= 1026 -> True

Subsequently, ExtEscape(hdc, PASSTHROUGH, 1026, data, 0, nullptr) is called, which instructs GDI to read 1026 bytes starting from data. Since the allocated record has nSize = 16, only the first 4 bytes are valid, leading to a heap out-of-bounds read of up to 1022 bytes beyond the record boundaries.

Potential Steps to Trigger

Note: The following are potential steps that an attacker could follow to trigger this vulnerability, as our automated analysis tooling cannot execute code dynamically.

  1. Compromise the kPdfConversion utility process (for example, through a separate parser exploit in PDFium).
  2. Lure the victim to trigger a print operation on a page to a PostScript-compatible printer (or a ‘Generic / Text Only’ driver) to ensure the printer mode is set to PostScript.
  3. Intercept the Mojo ConvertPage reply from the utility process to the browser, returning a crafted EMF blob inside the ReadOnlySharedMemoryRegion containing:
    • A valid ENHMETAHEADER
    • An EMR_GDICOMMENT record with iType = 70 (EMR_GDICOMMENT), nSize = 16, cbData = 1026, and the first 2 bytes of Data set to 0x0400 (little-endian uint16_t representing 1024).
    • A valid EMR_EOF record.
  4. When the browser process receives this EMF blob, it will invoke PostScriptMetaFile::SafePlayback. GDI parses the record using EnumEnhMetaFile and passes it to the safe playback loop.
  5. The browser process executes the consistent check, passes it, and calls ExtEscape with a size of 1026, triggering an out-of-bounds heap read of up to 1022 bytes in the browser process.

Suggested Fix

Ensure that the size of the comment data does not exceed the boundaries defined by the record size (nSize). Specifically, verify that emf_record->nSize is large enough to contain the EMRGDICOMMENT struct header and the claimed comment->cbData payload before accessing the structure’s fields:

if (emf_record->nSize < sizeof(EMRGDICOMMENT) ||
    comment->cbData > emf_record->nSize - offsetof(EMRGDICOMMENT, Data)) {
  continue;
}

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker