Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Skia
DescriptionOut of bounds read in Skia
ComponentSkia
Bug ClassOOB
Tracker490254124
Fix commit22ef657fd3be (skia) +28/-4
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-03-18

Changed Functions

FunctionChangeNotes
if
src/core/SkDescriptor.cpp
modified

Files Changed

  • src/core/SkDescriptor.cpp
  • tests/DescriptorTest.cpp
From 22ef657fd3be7042d3253295c7c01d66b0fec404 Mon Sep 17 00:00:00 2001
From: Florin Malita <[email protected]>
Date: Tue, 10 Mar 2026 22:49:27 -0400
Subject: [PATCH] Validate SkDescriptor length alignment

SkDescriptor expects all lengths to be 4 byte aligned, but the
deserialization code path doesn't enforce this.

Update isValid() checks to reject unaligned lengths.

Bug: 490254124
Change-Id: Ie179750307df29a98aba67b008fa29fdce488bc4
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1184276
Reviewed-by: Kaylee Lubick <[email protected]>
Commit-Queue: Florin Malita <[email protected]>
---

diff --git a/src/core/SkDescriptor.cpp b/src/core/SkDescriptor.cpp
index e2fa826..b5a3605 100644
--- a/src/core/SkDescriptor.cpp
+++ b/src/core/SkDescriptor.cpp
@@ -18,7 +18,7 @@
 #include <cstring>
 
 std::unique_ptr<SkDescriptor> SkDescriptor::Alloc(size_t length) {
-    SkASSERT(length >= sizeof(SkDescriptor) && SkAlign4(length) == length);
+    SkASSERT(length >= sizeof(SkDescriptor) && SkIsAlign4(length));
     void* allocation = ::operator new(length);
     return std::unique_ptr<SkDescriptor>(new (allocation) SkDescriptor{});
 }
@@ -34,7 +34,7 @@
 
 void* SkDescriptor::addEntry(uint32_t tag, size_t length, const void* data) {
     SkASSERT(tag);
-    SkASSERT(SkAlign4(length) == length);
+    SkASSERT(SkIsAlign4(length));
     SkASSERT(this->findEntry(tag, nullptr) == nullptr);
 
     Entry* entry = (Entry*)((char*)this + fLength);
@@ -82,6 +82,7 @@
     //       remove the aa < stop test in the loop...
     const uint32_t* aa = (const uint32_t*)this;
     const uint32_t* bb = (const uint32_t*)&other;
+    SkASSERT(SkIsAlign4(fLength));
     const uint32_t* stop = (const uint32_t*)((const char*)aa + fLength);
     do {
         if (*aa++ != *bb++)
@@ -111,7 +112,7 @@
 bool SkDescriptor::isValid() const {
     uint32_t count = fCount;
     size_t lengthRemaining = this->fLength;
-    if (lengthRemaining < sizeof(SkDescriptor)) {
+    if (lengthRemaining < sizeof(SkDescriptor) || !SkIsAlign4(lengthRemaining)) {
         return false;
     }
     lengthRemaining -= sizeof(SkDescriptor);
@@ -125,7 +126,7 @@
 
         const Entry* entry = (const Entry*)(reinterpret_cast<const char*>(this) + offset);
 
-        if (lengthRemaining < entry->fLen) {
+        if (lengthRemaining < entry->fLen || !SkIsAlign4(entry->fLen)) {
             return false;
         }
         lengthRemaining -= entry->fLen;
diff --git a/tests/DescriptorTest.cpp b/tests/DescriptorTest.cpp
index cc4d03b..7e9330f 100644
--- a/tests/DescriptorTest.cpp
+++ b/tests/DescriptorTest.cpp
@@ -8,6 +8,7 @@
 #include "include/core/SkData.h"
 #include "include/core/SkRefCnt.h"
 #include "include/core/SkTypes.h"
+#include "src/core/SkChecksum.h"
 #include "src/core/SkDescriptor.h"
 #include "src/core/SkReadBuffer.h"
 #include "src/core/SkScalerContext.h"
@@ -195,4 +196,26 @@
         auto ad = SkAutoDescriptor::MakeFromBuffer(reader);
         REPORTER_ASSERT(r, !ad.has_value());
     }
+
+    {  // unaligned length
+        // SkDescriptor + entry tag + entry len + entry data(3 bytes only)
+        static constexpr uint32_t datalen = sizeof(SkDescriptor) + 11;
+        uint32_t data32[] = {
+            // SkDescriptor
+            0,       // checksum
+            datalen, // desc.fLength
+            1,       // desc.fCount
+
+            // payload
+            1,       // entry tag
+            3,       // entry len
+            0,       // entry data
+        };
+        data32[0] = SkChecksum::Hash32(data32 + 1, datalen - 4);
+
+        SkReadBuffer reader{data32, sizeof(data32)};
+        auto ad = SkAutoDescriptor::MakeFromBuffer(reader);
+        REPORTER_ASSERT(r, !ad.has_value());
+    }
+
 }
Loading diff…

Original Bug Report

reported by [email protected]

Heap OOB read in SkDescriptor::operator== via non-aligned fLength from Remote Glyph Cache IPC leads to GPU process crash

Title

Heap OOB read in SkDescriptor::operator== via non-aligned fLength from Remote Glyph Cache IPC leads to GPU process crash

Summary

A heap out-of-bounds read occurs in Skia’s SkDescriptor::operator== when the descriptor’s fLength field is not 4-byte aligned. The function compares memory in uint32_t steps, reading up to 3 bytes past the end of a heap allocation. A compromised renderer can trigger this in the GPU process by injecting a crafted descriptor through the Remote Glyph Cache IPC channel. The vulnerability affects all platforms. No specific GPU hardware is required.

Root Cause

SkDescriptor::operator== compares two descriptors by casting this and the other descriptor to const uint32_t*, then iterating in 4-byte increments until a stop pointer derived from fLength:

// third_party/skia/src/core/SkDescriptor.cpp:78-91
bool SkDescriptor::operator==(const SkDescriptor& other) const {
    const uint32_t* aa = (const uint32_t*)this;
    const uint32_t* bb = (const uint32_t*)&other;
    const uint32_t* stop = (const uint32_t*)((const char*)aa + fLength);
    do {
        if (*aa++ != *bb++)
            return false;
    } while (aa < stop);
    return true;
}

When fLength is not a multiple of 4, stop points into the middle of the last 4-byte word. Because the loop condition is aa < stop, the final iteration dereferences a full uint32_t that extends past fLength, reading ceil(fLength/4)*4 - fLength bytes beyond the allocation.

The alignment invariant is assumed but never enforced at the security boundary. SkDescriptor::isValid() walks the entry list and verifies structural consistency, but it does not check whether fLength or any entry->fLen is 4-byte aligned:

// third_party/skia/src/core/SkDescriptor.cpp:111-142
bool SkDescriptor::isValid() const {
    uint32_t count = fCount;
    size_t lengthRemaining = this->fLength;
    if (lengthRemaining < sizeof(SkDescriptor)) { return false; }
    lengthRemaining -= sizeof(SkDescriptor);
    // ...walks entries checking tag/len consistency...
    return lengthRemaining == 0 && count == 0;
}

SkAutoDescriptor::MakeFromBuffer, which is the GPU-side deserialization entry point, validates the checksum and calls isValid(), but neither check rejects non-aligned lengths:

// third_party/skia/src/core/SkDescriptor.cpp:175-206
std::optional<SkAutoDescriptor> SkAutoDescriptor::MakeFromBuffer(SkReadBuffer& buffer) {
    // ...reads header, checks bounds...
    if (SkDescriptor::ComputeChecksum(ad.getDesc()) != ad.getDesc()->fChecksum) { return {}; }
    if (!ad.getDesc()->isValid()) { return {}; }
    return {ad};
}

The only alignment enforcement in the codebase is through SkASSERT calls in SkDescriptor::Alloc and addEntry, which are compiled out in Release and ASAN builds.

Normal descriptor construction through SkScalerContext::AutoDescriptorGivenRecAndEffects always produces 4-aligned lengths, so this bug cannot be triggered by ordinary web content. It requires a compromised renderer that can manipulate the serialized data sent to the GPU process.

The Remote Glyph Cache is the relevant IPC channel. When the renderer rasterizes text via Canvas 2D or other Skia-backed paths, SkStrikeServer collects glyph requests and serializes them through RemoteStrike::writePendingGlyphs, which calls SkDescriptor::flatten to write the strike descriptor into a flat byte buffer. This buffer is embedded in the RasterCHROMIUM GPU command and sent to the GPU process over the GPU Command Buffer shared-memory transport. On the GPU side, gpu::raster::RasterDecoderImpl::DoRasterCHROMIUM invokes gpu::ServiceFontManager::Deserialize, which calls SkStrikeClientImpl::readStrikeData to parse the buffer and look up or create strike cache entries.

Under the compromised renderer threat model, the attacker has arbitrary read/write in the renderer process. The serialized descriptor sits in the GPU Command Buffer’s shared-memory region before IPC dispatch. An attacker can:

  1. Let the renderer serialize a legitimate descriptor normally (producing a valid, 4-aligned SkDescriptor).
  2. Locate the serialized descriptor in the shared-memory command buffer (the format is deterministic: writePendingGlyphs writes typeface ID, handle ID, then the descriptor via writePad32).
  3. Overwrite the fLength field (offset 4 in the SkDescriptor header) to a non-4-aligned value (e.g., 125).
  4. Optionally append a trailing entry with a non-aligned fLen to make the structure pass isValid().
  5. Recompute the checksum over the modified descriptor (the checksum algorithm is SkChecksum::Hash32 over bytes [4, fLength), which is a simple public hash).

No recompilation is needed — this is a pure data-level manipulation of the IPC buffer in the renderer’s address space. The attached patch.diff simulates this by modifying the serialization code at the source level for reproducibility, but a real attacker would perform the equivalent overwrites in memory at runtime.

When the GPU process receives this crafted descriptor, MakeFromBuffer allocates exactly fLength bytes on the heap (125 bytes in the PoC), validates the checksum and isValid() (both pass), and then SkStrikeCache::findStrike calls operator== to compare it against cached descriptors. The comparison reads 128 bytes (ceil(125/4)*4) from the 125-byte allocation, producing a 3-byte heap OOB read.

Reproduce

The reproduction was tested on Chromium commit 4e910e2277470c4576177b37937569fa4151abdc running on Windows. This is a GPU process sandbox escape vulnerability that requires a compromised renderer, so the sandbox must remain enabled during testing.

Check out the correct commit and apply the attached patch, which modifies the renderer-side serialization code in Skia’s Remote Glyph Cache to inject a crafted SkDescriptor with a non-4-byte-aligned fLength. The patch targets third_party/skia/src/text/gpu/SkChromeRemoteGlyphCache.cpp and only affects code that runs in the renderer process.

cd D:\chromium\src
git checkout 4e910e2277470c4576177b37937569fa4151abdc
git -C third_party/skia apply patch.diff

Configure the ASAN build with the following args.gn in out/asan-release:

is_asan = true
is_debug = false
is_component_build = false
symbol_level = 1

Build Chrome:

autoninja -C out/asan-release chrome

Launch Chrome with the PoC page:

set ASAN_OPTIONS=detect_odr_violation=0
out\asan-release\chrome.exe --user-data-dir=%TEMP%\poc --enable-logging=stderr poc.html

The PoC page renders text via Canvas 2D, which triggers the Remote Glyph Cache IPC path. The patched renderer serializes a crafted SkDescriptor whose fLength is 125 (not 4-byte aligned). When the GPU process deserializes this descriptor and looks it up in the strike cache, SkDescriptor::operator== performs a uint32_t-granularity comparison loop that reads ceil(125/4)*4 = 128 bytes from a 125-byte heap allocation, producing an ASAN heap-buffer-overflow report in the GPU process at SkDescriptor::operator== in SkDescriptor.cpp.

After testing, revert the patch:

git -C third_party/skia checkout src/text/gpu/SkChromeRemoteGlyphCache.cpp

ASAN output from the GPU process (full log in asan.log):

==14200==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x11d6cfe386fc at pc 0x7ffaa799fb88 bp 0x00539abfe0c0 sp 0x00539abfe108
READ of size 4 at 0x11d6cfe386fc thread T0
    #0 0x7ffaa799fb87 in SkDescriptor::operator== D:\chromium\src\third_party\skia\src\core\SkDescriptor.cpp:87
    #1 0x7ffaa7c0b26c in SkStrikeCache::internalFindStrikeOrNull D:\chromium\src\third_party\skia\src\core\SkStrikeCache.cpp:105
    #2 0x7ffaa7c0c583 in SkStrikeCache::findStrike D:\chromium\src\third_party\skia\src\core\SkStrikeCache.cpp:94
    #3 0x7ffabafd4dd6 in SkStrikeClientImpl::readStrikeData D:\chromium\src\third_party\skia\src\text\gpu\SkChromeRemoteGlyphCache.cpp:750
    #4 0x7fface66691f in gpu::ServiceFontManager::Deserialize D:\chromium\src\gpu\command_buffer\service\service_font_manager.cc:240
    #5 0x7ffacb0440cd in gpu::raster::RasterDecoderImpl::DoRasterCHROMIUM D:\chromium\src\gpu\command_buffer\service\raster_decoder.cc:2992
    #6 0x7ffacb02decd in gpu::raster::RasterDecoderImpl::HandleRasterCHROMIUM D:\chromium\src\gpu\command_buffer\service\raster_decoder.cc:3047
    #7 0x7ffacb037ced in gpu::raster::RasterDecoderImpl::DoCommandsImpl<0> D:\chromium\src\gpu\command_buffer\service\raster_decoder.cc:1512
    #8 0x7ffaaa8679bb in gpu::CommandBufferService::Flush D:\chromium\src\gpu\command_buffer\service\command_buffer_service.cc:267
    #9 0x7ffabc078dc1 in gpu::CommandBufferStub::OnAsyncFlush D:\chromium\src\gpu\ipc\service\command_buffer_stub.cc:504
    #10 0x7ffabc077cd3 in gpu::CommandBufferStub::ExecuteDeferredRequest D:\chromium\src\gpu\ipc\service\command_buffer_stub.cc:173
    #11 0x7ffac174e061 in gpu::GpuChannel::ExecuteDeferredRequest D:\chromium\src\gpu\ipc\service\gpu_channel.cc:833
    ...
    #23 0x7ffabdeb91f3 in content::GpuMain D:\chromium\src\content\gpu\gpu_main.cc:479

0x11d6cfe386fd is located 0 bytes after 125-byte region [0x11d6cfe38680,0x11d6cfe386fd)
allocated by thread T0 here:
    #0 0x7ffaef70e46f in operator new (clang_rt.asan_dynamic-x86_64.dll)
    #2 0x7ffaa79a0e6b in SkAutoDescriptor::MakeFromBuffer D:\chromium\src\third_party\skia\src\core\SkDescriptor.cpp:205
    #3 0x7ffabafd4b0d in SkStrikeClientImpl::readStrikeData D:\chromium\src\third_party\skia\src\text\gpu\SkChromeRemoteGlyphCache.cpp:717

SUMMARY: AddressSanitizer: heap-buffer-overflow D:\chromium\src\third_party\skia\src\core\SkDescriptor.cpp:87 in SkDescriptor::operator==

Command line: "D:\chromium\src\out\asan-release\chrome.exe" --type=gpu-process ...

The crash occurs in the GPU process as shown by the --type=gpu-process in the command line and the content::GpuMain entry point in the stack trace.

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker