CVE-2026-4460
Overview
Background
- `SkDescriptor`
- a Skia variable-length blob (header with checksum,
fLength,fCount, followed by taggedEntryrecords each withfLenbytes 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::MakeFromBufferon anSkReadBuffer). - 4-byte alignment invariant
- per the commit message,
SkDescriptorexpects every length to be a multiple of 4;SkIsAlign4(x)tests this. - Word-wise comparison
- a descriptor comparison routine reads both descriptors as
uint32_twords from the start up tothis + fLength, which only stays in bounds iffLengthis 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.
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
- Craft serialized descriptor
an attacker who controls bytes that Skia deserializes into an
SkDescriptorsetsfLengthand/or an entry’sfLento a non-multiple of 4, with a matching checksum and otherwise consistent lengths. - Pass validation
SkAutoDescriptor::MakeFromBufferaccepts the blob because the pre-patchisValid()only checked sizes, not alignment. - Word-wise processing
the descriptor is later compared against another (e.g. during strike/cache lookup) using the routine that walks
uint32_twords up tothis + fLength(the specific consumer in Chrome is (inferred)). - 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
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
| Function | Change | Notes |
|---|---|---|
ifsrc/core/SkDescriptor.cpp |
modified |
Files Changed
src/core/SkDescriptor.cpptests/DescriptorTest.cpp
Audit Directions
- Debug-only invariants on untrusted datalook for other Skia types whose construction path enforces an invariant with
SkASSERTbut whose deserialization/isValid()path does not (e.g. otherSkReadBuffer/SkFlattenableconsumers). - Word-sized walks over byte lengthsaudit
SkDescriptorchecksum computation,findEntry(), and other routines that cast touint32_t*and iterate up to a byte length, to ensure they cannot overrun with lengths that pass validation. - Cross-process glyph transportreview 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.
Patch
From 22ef657fd3be7042d3253295c7c01d66b0fec404 Mon Sep 17 00:00:00 2001
From: Florin Malita <fmalita@google.com>
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 <kjlubick@google.com>
Commit-Queue: Florin Malita <fmalita@google.com>
---
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());
+ }
+
}
Original Bug Report
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:
- Let the renderer serialize a legitimate descriptor normally (producing a valid, 4-aligned SkDescriptor).
- Locate the serialized descriptor in the shared-memory command buffer (the format is deterministic:
writePendingGlyphswrites typeface ID, handle ID, then the descriptor viawritePad32). - Overwrite the
fLengthfield (offset 4 in the SkDescriptor header) to a non-4-aligned value (e.g., 125). - Optionally append a trailing entry with a non-aligned
fLento make the structure passisValid(). - Recompute the checksum over the modified descriptor (the checksum algorithm is
SkChecksum::Hash32over 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.