High watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Skia
DescriptionOut of bounds read in Skia
ComponentSkia
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker490254124
Fix commit22ef657fd3be (skia) +28/-4
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-03-06 (per vendor advisory)
Fix landed2026-03-11 (public git)
Disclosed2026-03-18
Patch gap7 days — fix public in source before the advisory

Background

`SkDescriptor`
a Skia variable-length blob (header with checksum, fLength, fCount, followed by tagged Entry records each with fLen bytes of data) that identifies a glyph strike/scaler configuration.
`SkDescriptor::isValid()`
the validation routine applied to descriptors reconstructed from untrusted serialized data (the test exercises it via SkAutoDescriptor::MakeFromBuffer on an SkReadBuffer).
4-byte alignment invariant
per the commit message, SkDescriptor expects every length to be a multiple of 4; SkIsAlign4(x) tests this.
Word-wise comparison
a descriptor comparison routine reads both descriptors as uint32_t words from the start up to this + fLength, which only stays in bounds if fLength is 4-aligned.

Root Cause Analysis

Locally constructed descriptors enforce alignment only through SkASSERT in SkDescriptor::Alloc() and SkDescriptor::addEntry(), but the deserialization path validates with SkDescriptor::isValid(), which before the patch checked only that fLength >= sizeof(SkDescriptor) and that each entry->fLen fit in the remaining length — not that either was 4-aligned. A crafted descriptor such as the test’s (fLength = sizeof(SkDescriptor) + 11, one entry with fLen = 3) therefore passed validation. Code that assumes alignment then over-reads: the comparison routine in the hunk at line 82 (the @@ header does not name the enclosing function; it compares this against other word by word) computes stop as aa + fLength and loops *aa++ != *bb++ in uint32_t steps, so with an unaligned fLength its last 4-byte read extends past the end of the allocation, an out-of-bounds read consistent with the advisory.

The fix tightens isValid() to return false when fLength or any entry->fLen is not 4-aligned, so malformed descriptors are rejected at deserialization (the test asserts MakeFromBuffer yields no value); the added SkASSERT(SkIsAlign4(fLength)) in the comparison and the SkAlign4(length) == length → SkIsAlign4(length) rewrites in Alloc()/addEntry() are debug-only and equivalent, respectively.

Key insight
Deserialization validation (isValid()) did not enforce the 4-byte length alignment that the rest of SkDescriptor assumes, letting an unaligned descriptor drive word-sized reads past its end; the fix rejects unaligned header and entry lengths during validation.

Attack Path

  1. Craft serialized descriptor an attacker who controls bytes that Skia deserializes into an SkDescriptor sets fLength and/or an entry’s fLen to a non-multiple of 4, with a matching checksum and otherwise consistent lengths.
  2. Pass validation SkAutoDescriptor::MakeFromBuffer accepts the blob because the pre-patch isValid() only checked sizes, not alignment.
  3. Word-wise processing the descriptor is later compared against another (e.g. during strike/cache lookup) using the routine that walks uint32_t words up to this + fLength (the specific consumer in Chrome is (inferred)).
  4. Out-of-bounds read the final word read extends up to 3 bytes beyond the descriptor allocation; whether this leaks data or only crashes is (inferred).

Impact Assessment

The patch demonstrates an out-of-bounds read of a few bytes past a heap-allocated SkDescriptor in whichever process deserializes it. In Chrome, serialized descriptors are most plausibly exchanged as part of cross-process glyph/strike transport, so the likely precondition is a compromised renderer feeding crafted data to a more privileged process such as the GPU process (inferred; the diff shows only the Skia deserialization check, not the Chrome call site). The vendor rates this as high severity; the advisory provided does not state in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
if
src/core/SkDescriptor.cpp
modified

Files Changed

  • src/core/SkDescriptor.cpp
  • tests/DescriptorTest.cpp

Audit Directions

  • Debug-only invariants on untrusted data
    look for other Skia types whose construction path enforces an invariant with SkASSERT but whose deserialization/isValid() path does not (e.g. other SkReadBuffer/SkFlattenable consumers).
  • Word-sized walks over byte lengths
    audit SkDescriptor checksum computation, findEntry(), and other routines that cast to uint32_t* and iterate up to a byte length, to ensure they cannot overrun with lengths that pass validation.
  • Cross-process glyph transport
    review Skia’s remote glyph cache / strike deserialization and Chrome’s GPU-side consumers for additional fields read from the wire that are trusted without validation.
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