Medium chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Compositing
DescriptionImproper input validation in Compositing
ComponentCompositing
Sub-componentCc/paint
Bug ClassInput Validation
Tracker499520020
Fix commit6e08eecbc295 (chromium/src) +61/-9
CISA KEVNot listed
CreditedWerner Kasselman
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
cc/paint/paint_op_reader.cc
modified
TEST
cc/paint/paint_op_buffer_unittest.cc
modified

Files Changed

  • cc/paint/paint_op_buffer_unittest.cc
  • gpu/command_buffer/service/raster_decoder.cc
  • cc/paint/paint_op_reader.cc
From 6e08eecbc295c42742af477a4e1db689ccc4b33b Mon Sep 17 00:00:00 2001
From: Victor Miura <vmiura@chromium.org>
Date: Mon, 21 Sep 2026 08:38:06 -0700
Subject: [PATCH] cc/paint: Deserialize slugs from the scratch buffer

PaintOpReader stages the payload of complex types (SkPath,
SkColorSpace, gfx::HDRMetadata, SkString) into the scratch
buffer and parses them from there, so parsing is independent
of the input buffer, which can be backed by cross-process
shared memory whose contents may change while an op is being
deserialized. The slug read was the one remaining complex
type parsed in place from the input buffer; stage it in the
scratch buffer like the other types.

Also type the RasterCHROMIUM paint buffer span as volatile in
RasterDecoderImpl, matching the font buffer in the same
function, to make the shared memory backing explicit.

Adds a regression test verifying that slug data is staged
into the caller-provided scratch buffer during
deserialization.

TAG=agy
CONV=f490a249-6dc0-42c6-bcb1-9c19e4032e03

Bug: 499520020
Change-Id: I0971531ec64e368a94a56b1e3cb5115f41db45a0
Fixed: 499520020
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8433804
Reviewed-by: Jonathan Ross <jonross@chromium.org>
Commit-Queue: Jonathan Ross <jonross@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1701785}
---
diff --git a/cc/paint/paint_op_reader.cc b/cc/paint/paint_op_reader.cc
index be981bc..f4eac2c5 100644
--- a/cc/paint/paint_op_reader.cc
+++ b/cc/paint/paint_op_reader.cc
@@ -633,11 +633,10 @@
     return;
   }
 
-  auto span = remaining_.first(data_bytes);
-  *slug =
-      sktext::gpu::Slug::Deserialize(const_cast<const uint8_t*>(span.data()),
-                                     span.size(), options_.strike_client);
-  DidRead(span.size());
+  base::span<uint8_t> scratch = CopyScratchSpace(data_bytes);
+  *slug = sktext::gpu::Slug::Deserialize(scratch.data(), scratch.size(),
+                                         options_.strike_client);
+  DidRead(data_bytes);
 
   if (!*slug) {
     SetInvalid(DeserializationError::kSlugDeserializeFailure);
diff --git a/gpu/command_buffer/service/raster_decoder.cc b/gpu/command_buffer/service/raster_decoder.cc
index 2049367e..a050c488 100644
--- a/gpu/command_buffer/service/raster_decoder.cc
+++ b/gpu/command_buffer/service/raster_decoder.cc
@@ -2969,16 +2969,16 @@
   }
   DCHECK(transfer_cache());
 
-  auto paint_buffer_opt =
-      GetSharedMemoryAsSpan(raster_shm_id, raster_shm_offset, raster_shm_size);
+  auto paint_buffer_opt = GetSharedMemoryAsSpan<volatile uint8_t>(
+      raster_shm_id, raster_shm_offset, raster_shm_size);
   if (paint_buffer_opt.value_or({}).empty()) {
     LOCAL_SET_GL_ERROR(GL_INVALID_VALUE, "glRasterCHROMIUM",
                        "Can not read paint buffer.");
     return;
   }
-  base::span<uint8_t> paint_buffer = *paint_buffer_opt;
+  base::span<volatile uint8_t> paint_buffer = *paint_buffer_opt;
 
-  if (!base::IsAligned(paint_buffer.data(), 16u)) {
+  if (!base::IsAligned(reinterpret_cast<uintptr_t>(paint_buffer.data()), 16u)) {
     LOCAL_SET_GL_ERROR(GL_INVALID_VALUE, "glRasterCHROMIUM",
                        "Buffer is not aligned with 16 bytes.");
     return;
diff --git a/cc/paint/paint_op_buffer_unittest.cc b/cc/paint/paint_op_buffer_unittest.cc
index 5eaa6f2..8e9773c 100644
--- a/cc/paint/paint_op_buffer_unittest.cc
+++ b/cc/paint/paint_op_buffer_unittest.cc
@@ -2421,6 +2421,59 @@
   EXPECT_EQ(buffer.size(), i);
 }
 
+TEST(PaintOpSerializationTest, DrawSlugDeserializationUsesScratchBuffer) {
+  PaintOpBuffer buffer;
+  SkFont font;
+  font.setTypeface(skia::DefaultTypeface());
+  SkTextBlobBuilder builder;
+  constexpr size_t kGlyphCount = 5u;
+  const auto& run = builder.allocRun(font, kGlyphCount, 1.2f, 2.3f);
+  // allocRun() allocates only the glyph buffer.
+  // SAFETY: SkTextBlobBuilder::allocRun allocates a glyph buffer of size
+  // kGlyphCount.
+  std::ranges::fill(UNSAFE_BUFFERS(base::span(run.glyphs, kGlyphCount)), 0);
+  buffer.push<DrawTextBlobOp>(builder.make(), 0.f, 0.f, PaintFlags());
+
+  size_t output_size = kSerializedBytesPerOp * buffer.size();
+  auto output = AllocateSerializedBuffer(output_size);
+  base::span<uint8_t> output_span = output.as_span();
+  TestOptionsProvider options_provider;
+  SimpleBufferSerializer serializer(output_span,
+                                    options_provider.serialize_options());
+  serializer.Serialize(buffer);
+
+  std::vector<uint8_t> strike_data;
+  options_provider.strike_server()->writeStrikeData(&strike_data);
+  if (!strike_data.empty()) {
+    options_provider.strike_client()->readStrikeData(strike_data.data(),
+                                                     strike_data.size());
+  }
+
+  // Deserialize with a caller-owned scratch buffer. Serialized slug data must
+  // be staged into the scratch buffer and parsed from there, like other
+  // externally-defined complex types (e.g. SkPath, SkColorSpace), rather than
+  // being parsed in place from the input buffer, whose backing memory may
+  // change while it is being parsed.
+  std::vector<uint8_t> scratch_buffer;
+  PaintOp::DeserializeOptions options{
+      .transfer_cache = options_provider.transfer_cache_helper(),
+      .paint_cache = options_provider.service_paint_cache(),
+      .strike_client = options_provider.strike_client(),
+      .scratch_buffer = scratch_buffer,
+      .is_privileged = true};
+
+  size_t deserialized_count = 0;
+  for (const PaintOp& base_written :
+       DeserializerIterator(output_span.first(serializer.written()), options)) {
+    ASSERT_EQ(PaintOpType::kDrawSlug, base_written.GetType());
+    EXPECT_TRUE(static_cast<const DrawSlugOp&>(base_written).slug);
+    ++deserialized_count;
+  }
+
+  EXPECT_EQ(buffer.size(), deserialized_count);
+  EXPECT_FALSE(scratch_buffer.empty());
+}
+
 TEST(PaintOpSerializationTest, SerializesNestedRecords) {
   PaintOpBuffer sub_buffer;
   sub_buffer.push<ScaleOp>(0.5f, 0.75f);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/cc/paint/paint_op_buffer_unittest.cc b/cc/paint/paint_op_buffer_unittest.cc
index 5eaa6f2..8e9773c 100644
--- a/cc/paint/paint_op_buffer_unittest.cc
+++ b/cc/paint/paint_op_buffer_unittest.cc
@@ -2421,6 +2421,59 @@
   EXPECT_EQ(buffer.size(), i);
 }
 
+TEST(PaintOpSerializationTest, DrawSlugDeserializationUsesScratchBuffer) {
+  PaintOpBuffer buffer;
+  SkFont font;
+  font.setTypeface(skia::DefaultTypeface());
+  SkTextBlobBuilder builder;
+  constexpr size_t kGlyphCount = 5u;
+  const auto& run = builder.allocRun(font, kGlyphCount, 1.2f, 2.3f);
+  // allocRun() allocates only the glyph buffer.
+  // SAFETY: SkTextBlobBuilder::allocRun allocates a glyph buffer of size
+  // kGlyphCount.
+  std::ranges::fill(UNSAFE_BUFFERS(base::span(run.glyphs, kGlyphCount)), 0);
+  buffer.push<DrawTextBlobOp>(builder.make(), 0.f, 0.f, PaintFlags());
+
+  size_t output_size = kSerializedBytesPerOp * buffer.size();
+  auto output = AllocateSerializedBuffer(output_size);
+  base::span<uint8_t> output_span = output.as_span();
+  TestOptionsProvider options_provider;
+  SimpleBufferSerializer serializer(output_span,
+                                    options_provider.serialize_options());
+  serializer.Serialize(buffer);
+
+  std::vector<uint8_t> strike_data;
+  options_provider.strike_server()->writeStrikeData(&strike_data);
+  if (!strike_data.empty()) {
+    options_provider.strike_client()->readStrikeData(strike_data.data(),
+                                                     strike_data.size());
+  }
+
+  // Deserialize with a caller-owned scratch buffer. Serialized slug data must
+  // be staged into the scratch buffer and parsed from there, like other
+  // externally-defined complex types (e.g. SkPath, SkColorSpace), rather than
+  // being parsed in place from the input buffer, whose backing memory may
+  // change while it is being parsed.
+  std::vector<uint8_t> scratch_buffer;
+  PaintOp::DeserializeOptions options{
+      .transfer_cache = options_provider.transfer_cache_helper(),
+      .paint_cache = options_provider.service_paint_cache(),
+      .strike_client = options_provider.strike_client(),
+      .scratch_buffer = scratch_buffer,
+      .is_privileged = true};
+
+  size_t deserialized_count = 0;
+  for (const PaintOp& base_written :
+       DeserializerIterator(output_span.first(serializer.written()), options)) {
+    ASSERT_EQ(PaintOpType::kDrawSlug, base_written.GetType());
+    EXPECT_TRUE(static_cast<const DrawSlugOp&>(base_written).slug);
+    ++deserialized_count;
+  }
+
+  EXPECT_EQ(buffer.size(), deserialized_count);
+  EXPECT_FALSE(scratch_buffer.empty());
+}
+
 TEST(PaintOpSerializationTest, SerializesNestedRecords) {
   PaintOpBuffer sub_buffer;
   sub_buffer.push<ScaleOp>(0.5f, 0.75f);
Loading diff…

Original Bug Report

reported by we...@kasselman.com.au

TOCTOU in Slug::Deserialize reads shared memory via const_cast volatile strip

VULNERABILITY DETAILS

TOCTOU race condition in GPU process: Slug::Deserialize() reads serialized text rendering data directly from renderer-controlled shared memory via an explicit const_cast that strips the volatile qualifier, while every other Skia deserializer in the same file copies to scratch space first.

In paint_op_reader.cc (cc/paint/paint_op_reader.cc), the PaintOpReader class holds memory_ as a const volatile uint8_t* pointer into renderer-controlled shared memory. At line 626:

*slug = sktext::gpu::Slug::Deserialize(
    const_cast&lt;const uint8_t*&gt;(memory_), data_bytes,
    options_.strike_client);

The const_cast<const uint8_t*>(memory_) explicitly strips the volatile qualifier from the shared memory pointer. Slug::Deserialize() then parses complex serialized text/glyph data directly from memory the renderer can modify concurrently.

CRITICAL INCONSISTENCY: Every other Skia complex-parser call in the same file copies to scratch space before parsing:

  • SkPath (line 329): CopyScratchSpace(path_bytes) -> SkPath::ReadFromMemory(scratch, …)
  • SkColorSpace (line 577): CopyScratchSpace(size) -> SkColorSpace::Deserialize(scratch, …)
  • SkRegion (line 1758): HeapArray + ReadData() (memcpy) -> readFromMemory(data.data(), …)
  • HDRMetadata (line 969): CopyScratchSpace(size) -> Mojo deserialization
  • SkString (line 1028): CopyScratchSpace(size) -> string construction

Slug::Deserialize is the ONLY Skia complex parser that does NOT copy first. The CopyScratchSpace() function exists and is designed for exactly this purpose but was not applied to the Slug path.

The transfer buffer uses base::UnsafeSharedMemoryRegion. The renderer retains full write access during GPU-side parsing. No synchronization exists.

Entry point: raster_decoder.cc DoRasterCHROMIUM (line 3026) calls PaintOp::Deserialize() which creates a PaintOpReader with memory_ pointing into the transfer buffer. When a DrawSlugOp is encountered, Read(sk_sp<Slug>*) at line 626 passes the shared memory directly to Slug::Deserialize(). This path is exercised by every web page that renders text.

VERSION Chrome Version: All versions with Slug text rendering support (current trunk and recent stable releases) Operating System: All platforms

REPRODUCTION CASE This vulnerability requires a compromised renderer process:

  1. Renderer sends RasterCHROMIUM command with paint ops containing DrawSlugOp
  2. The serialized slug data in the transfer buffer is initially valid
  3. A racing thread flips a length/offset field in the slug data
  4. GPU process deserializes via Slug::Deserialize() on the mutable memory
  5. TOCTOU: Skia validates small length, then reads large length -> heap OOB

Fix: Add CopyScratchSpace() before Slug::Deserialize(), matching the pattern used by all peer deserializers:

auto* scratch = CopyScratchSpace(data_bytes);
*slug = sktext::gpu::Slug::Deserialize(scratch, data_bytes,
                                       options_.strike_client);

CREDIT INFORMATION Reporter credit: [Werner Kasselman]

View on issue tracker