CVE-2026-7353
Overview
Background
- `SkRegion`
- Skia’s representation of an arbitrary rectilinear area, stored internally as a run-length-encoded (RLE) list of horizontal strips (scanlines).
- RLE run format
- Per the patch’s new documentation, the data is
[Top-Y]followed by strips[Bottom-Y, Interval-Count(N), Left/Right pairs..., X-Sentinel]ending with aY-Sentinel(SkRegion_kRunTypeSentinel=0x7FFFFFFF). - Empty slice
- A strip with
Interval-Count == 0, representing a vertical gap between strips; the serializer’s invariant is that two empty slices never appear back-to-back (gaps are always coalesced into one). - `readFromMemory` validation
- The deserialization path that checks an untrusted run array before building a region from it.
Root Cause Analysis
The run-validation loop (the enclosing function name is not shown in the @@ -1218/1238 hunk header; it is the file-local run-validation routine on the SkRegion::readFromMemory path) accepted serialized regions in which two empty slices (xIntervals == 0) appeared consecutively. That byte sequence can never be produced by Skia’s own serializer, which always merges adjacent gaps, so downstream code — iteration (SkRegion::Iterator::next) and region operations that walk the runs — assumes it cannot occur. The violated invariant is “no two empty spans in a row,” and because validation did not enforce it, maliciously crafted runs that pass validation later drive the RLE walker off the end of the buffer, producing the heap buffer overflow described by the advisory (the specific OOB sink is not in this diff).
The fix adds a prevWasEmpty flag, initialized to true so a leading empty slice is also rejected, and returns false from validation whenever an empty slice follows another empty slice, so the corrupt encoding is rejected before any region is constructed from it. This is a validation tightening.
prevWasEmpty and rejects back-to-back (or leading) empty spans so corrupt RLE is refused up front.Attack Path
- Deliver a crafted region
An attacker supplies a serialized
SkRegionblob (e.g. via a content path that deserializes regions withreadFromMemory) whose run array contains two empty slices in a row. - Pass the weak validation
Pre-patch, the loop’s per-strip checks (
xIntervalsrange and sentinel checks) did not reject consecutivexIntervals == 0strips, so the malformed run array is accepted as valid. - Build and walk the region The accepted runs are later traversed by iteration/region operations that rely on the no-consecutive-empties invariant to bound their advance through the run buffer.
- Heap buffer overflow
Walking the malformed runs reads (and per the advisory, overflows) past the allocated run buffer; the exact OOB read/write location is downstream and not shown in this diff
(inferred).
Impact Assessment
SkRegion can cause a heap buffer overflow in the process hosting Skia (the renderer in the common Chrome case), which the vendor advisory rates high severity. The precondition is reaching a readFromMemory-style path with attacker-controlled run bytes. The advisory does not report in-the-wild exploitation, and escalation of the overflow to memory corruption beyond an out-of-bounds access or to code execution is (inferred), not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
SK_APIinclude/core/SkRegion.h |
modified | |
ifsrc/core/SkRegion.cpp |
modified | |
switchsrc/core/SkRegion.cpp |
modified |
Files Changed
include/core/SkRegion.hsrc/core/SkRegion.cpp
Audit Directions
- Serializer-invariant gaps in deserializersAudit
readFromMemory/fromBytespaths that trust structural invariants the serializer guarantees (coalesced gaps, ordering, uniqueness) without re-checking them on untrusted input. - RLE / variable-length walkersReview code that advances through run-length or TLV-style buffers using counts embedded in the data, confirming every advance is bounded by the buffer
endunder all count combinations, including zero counts. - Degenerate/empty-element handlingLook for parsers that handle “empty” or zero-length elements as benign; test consecutive and leading empties, which often bypass per-element checks that only validate non-empty elements.
Patch
From 63dbf37c3700b34b5050d418d7cc9a3cb1708d5b Mon Sep 17 00:00:00 2001 From: Kaylee Lubick <[email protected]> Date: Fri, 24 Apr 2026 14:04:56 +0000 Subject: [PATCH] Reject SkRegions with multiple empty slices in a row This also adds some documentation that I found helpful when poking through this file. I added SkRegion_Iterator_StepsThroughAllScanlines to make sure I understood how the SkRegion class works and what iteration is supposed to do. Then, I made SkRegion_ReadFromMemory_ConsecutiveEmptySlices_Invalid which reproduced the linked bug (e.g. crash in debug and ASAN issue in release). I added the fix and a final test to make sure the empty regions still work. Bug: b/498809718 Change-Id: Idc68540170a3440f45f8f58890e36e81a36a05ba Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1217257 Reviewed-by: Florin Malita <[email protected]> --- diff --git a/include/core/SkRegion.h b/include/core/SkRegion.h index 22a7216..041cd96 100644 --- a/include/core/SkRegion.h +++ b/include/core/SkRegion.h @@ -447,8 +447,9 @@ #endif /** \class SkRegion::Iterator - Returns sequence of rectangles, sorted along y-axis, then x-axis, that make - up SkRegion. + Goes through the region one rectangle at a time. For each "strip" of one or more contiguous + Y values (scanlines) in ascending order, the iterator returns each rectangle in that strip + (from left to right) before advancing to the next strip (which may or may not have a gap). */ class SK_API Iterator { public: @@ -494,9 +495,12 @@ bool done() const { return fDone; } /** Advances SkRegion::Iterator to next SkIRect in SkRegion if it is not done. - - example: https://fiddle.skia.org/c/@Region_Iterator_next - */ + * This moves to the next rectangle to the right within the current + * horizontal strip. If the end of the strip is reached, it automatically + * advances to the first rectangle in the next strip, skipping any vertical gaps. + * + * example: https://fiddle.skia.org/c/@Region_Iterator_next + */ void next(); /** Returns SkIRect element in SkRegion. Does not return predictable results if SkRegion @@ -514,6 +518,7 @@ private: const SkRegion* fRgn; + // See top of SkRegion.cpp for more on the RLE encoding const SkRegion::RunType* fRuns; SkIRect fRect = {0, 0, 0, 0}; bool fDone; diff --git a/src/core/SkRegion.cpp b/src/core/SkRegion.cpp index f8e7c62..64bdcb3 100644 --- a/src/core/SkRegion.cpp +++ b/src/core/SkRegion.cpp @@ -23,14 +23,30 @@ using namespace skia_private; -/* Region Layout +/* SkRegion Run-Length Encoding (RLE) Format: * - * TOP + * A region is stored as a series of non-overlapping horizontal strips (scanlines). + * All rectangles in a strip share the same Top and Bottom Y-coordinates. * - * [ Bottom, X-Intervals, [Left, Right]..., X-Sentinel ] - * ... + * Data layout: + * [Top-Y] + * [Bottom-Y, Interval-Count (N), Left-1, Right-1, ..., Left-N, Right-N, X-Sentinel] + * [Bottom-Y, Interval-Count (M), Left-1, Right-1, ..., Left-M, Right-M, X-Sentinel] + * ... + * [Y-Sentinel] * - * Y-Sentinel + * - Sentinels are 0x7FFFFFFF (SkRegion_kRunTypeSentinel). + * - Interval-Count can be 0 (representing a vertical gap between strips). + * - Strips are sorted by Y; Intervals within a strip are sorted by X. + * + * Example: Two rects [10, 10, 20, 20] and [30, 10, 40, 20] on one line, + * then a gap until Y=30, then [15, 30, 25, 40] would be: + * + * 10 // Global Top-Y + * 20, 2, 10, 20, 30, 40, S // Strip 1: Y 10-20, 2 rects, X-Sentinel + * 30, 0, S // Strip 2: Y 20-30, 0 rects (gap), X-Sentinel + * 40, 1, 15, 25, S // Strip 3: Y 30-40, 1 rect, X-Sentinel + * S // Y-Sentinel */ ///////////////////////////////////////////////////////////////////////////////////////////////// @@ -98,6 +114,10 @@ return const_cast<SkRegionPriv::RunType*>(runs); } +static inline void assert_sentinel(int32_t value, bool isSentinel) { + SkASSERT(SkRegionValueIsSentinel(value) == isSentinel); +} + bool SkRegion::RunsAreARect(const SkRegion::RunType runs[], int count, SkIRect* bounds) { assert_sentinel(runs[0], false); // top @@ -299,7 +319,7 @@ if (runs[3] == SkRegion_kRunTypeSentinel) { // should be first left... runs += 3; // skip empty initial span runs[0] = runs[-2]; // set new top to prev bottom - assert_sentinel(runs[1], false); // bot: a sentinal would mean two in a row + assert_sentinel(runs[1], false); // bot: a sentinel would mean two in a row assert_sentinel(runs[2], false); // intervalcount assert_sentinel(runs[3], false); // left assert_sentinel(runs[4], false); // right @@ -879,7 +899,7 @@ int flush() { (*fArray)[fStartDst] = fTop; - // Previously reserved enough for TWO sentinals. + // Previously reserved enough for TWO sentinels. SkASSERT(fArray->count() > SkToInt(fPrevDst + fPrevLen)); (*fArray)[fPrevDst + fPrevLen] = SkRegion_kRunTypeSentinel; return (int)(fPrevDst - fStartDst + fPrevLen + 1); @@ -1050,20 +1070,18 @@ // swith to using pointers, so we can swap them as needed const SkRegion* rgna = &rgnaOrig; const SkRegion* rgnb = &rgnbOrig; - // after this point, do not refer to rgnaOrig or rgnbOrig!!! - // collaps difference and reverse-difference into just difference + // collapse difference and reverse-difference into just difference if (kReverseDifference_Op == op) { - using std::swap; - swap(rgna, rgnb); + std::swap(rgna, rgnb); op = kDifference_Op; } SkIRect bounds; - bool a_empty = rgna->isEmpty(); - bool b_empty = rgnb->isEmpty(); - bool a_rect = rgna->isRect(); - bool b_rect = rgnb->isRect(); + bool a_empty = rgna->isEmpty(); + bool b_empty = rgnb->isEmpty(); + bool a_rect = rgna->isRect(); + bool b_rect = rgnb->isRect(); switch (op) { case kDifference_Op: @@ -1188,9 +1206,11 @@ } SkSafeMath safeMath; int sum = 2; + // 3 bytes per ySpan (stop Y, how many intervals, sentinel) sum = safeMath.addInt(sum, ySpanCount); sum = safeMath.addInt(sum, ySpanCount); sum = safeMath.addInt(sum, ySpanCount); + // 2 bytes per interval (startX, stop X) sum = safeMath.addInt(sum, intervalCount); sum = safeMath.addInt(sum, intervalCount); return safeMath && sum == runCount; @@ -1218,6 +1238,7 @@ const int32_t* const end = runs + runCount; SkIRect bounds = {0, 0, 0 ,0}; // calulated bounds SkIRect rect = {0, 0, 0, 0}; // current rect + bool prevWasEmpty = true; // If we start with an empty slice, that's corrupted data. rect.fTop = *runs++; if (rect.fTop == SkRegion_kRunTypeSentinel) { return false; // no rect can contain SkRegion_kRunTypeSentinel @@ -1246,6 +1267,15 @@ if (xIntervals < 0 || xIntervals > intervalCount || runs + 1 + 2 * xIntervals > end) { return false; } + if (xIntervals == 0) { + if (prevWasEmpty) { + // back to back empty spans are invalid; our serialization always has them together + return false; + } + prevWasEmpty = true; + } else { + prevWasEmpty = false; + } intervalCount -= xIntervals; bool firstInterval = true; int32_t lastRight = 0; // check that x-intervals are distinct and ordered. @@ -1263,7 +1293,7 @@ bounds.join(rect); } if (*runs++ != SkRegion_kRunTypeSentinel) { - return false; // required check sentinal. + return false; // required check sentinel.
Original Bug Report
Heap OOB write in GPU process via SkRegion::Iterator desynchronization
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 without the security team.
Overview: A logic error in SkRegion::Iterator causes it to desynchronize when parsing a region with empty spans, yielding more rectangles than its reported interval count. When such a crafted region is sent to the GPU process and drawn using RegionOp, these excess rectangles cause an out-of-bounds write in the VertexWriter. This leads to a potential heap-based buffer overflow and RCE in the privileged GPU process.
Affected files:
third_party/skia/src/gpu/ganesh/ops/RegionOp.cppthird_party/skia/src/core/SkRegion.cppthird_party/skia/src/gpu/BufferWriter.h
Estimated timestamp from git blame: 2021-10-26
Summary
A vulnerability in Skia’s SkRegion deserialization and iteration logic allows a compromised renderer process to trigger a heap-based out-of-bounds write in the highly privileged GPU process.
By crafting a serialized SkRegion containing empty y-spans, an attacker can cause SkRegion::Iterator to desynchronize during iteration. This desynchronization results in the iterator yielding more rectangles than the region’s reported total interval count. The Ganesh RegionOp uses the trusted interval count to allocate its vertex buffer, and the subsequent iteration with the desynchronized iterator overflows this buffer.
Vulnerability Details
1. Incomplete Validation in SkRegion::readFromMemory
The validate_run function in third_party/skia/src/core/SkRegion.cpp, called during readFromMemory, checks that the number of x-intervals in each span is within bounds. However, it does not prevent a region from containing consecutive empty y-spans (where xIntervals == 0). While a single empty span is a valid structural element in Skia to represent vertical gaps, consecutive empty spans or empty spans at the end of a region can trigger parsing bugs downstream.
2. Iterator Desynchronization in SkRegion::Iterator::next()
The SkRegion::Iterator::next() function (third_party/skia/src/core/SkRegion.cpp:1409) has a critical logic flaw when handling empty spans.
if (runs[0] < SkRegion_kRunTypeSentinel) { // valid Y value
int intervals = runs[1];
if (0 == intervals) { // empty line
fRect.fTop = runs[0];
runs += 3; // Advance past the empty span
} else {
fRect.fTop = fRect.fBottom;
}
// BUG: Execution unconditionally falls through to here.
fRect.fBottom = runs[0];
assert_sentinel(runs[2], false);
// ...
When it encounters an empty span (intervals == 0), it sets fRect.fTop and advances the run pointer by 3 (runs += 3) to skip it. However, it fails to return or continue. It unconditionally proceeds to read the “next” span’s data within the same next() call. Because runs was already advanced, it misinterprets the subsequent structural metadata (like sentinels or the next span’s headers) as rectangle coordinates. The iterator yields a “phantom” rectangle and its internal state becomes completely desynchronized from the true run array structure.
3. OOB Write in Ganesh RegionOp
In third_party/skia/src/gpu/ganesh/ops/RegionOp.cpp, RegionOpImpl::onPrepareDraws calculates the required vertex buffer size using computeRegionComplexity(), which returns the region’s fIntervalCount (trusted from the deserialized header). It then allocates a vertex buffer for exactly this number of quads using a QuadHelper.
Subsequently, it iterates through the region using SkRegion::Iterator in a while (!iter.done()) loop, calling vertices.writeQuad() for each rectangle yielded.
Because the iterator desynchronizes on empty spans, it consumes multiple structural elements to produce single phantom rectangles, failing to terminate correctly. Consequently, it yields more rectangles than the originally allocated fIntervalCount.
VertexWriter (third_party/skia/src/gpu/BufferWriter.h) relies entirely on SkASSERT for bounds checking, which compiles to a no-op in Release builds. When writeQuad is called for the excess rectangles, it writes attacker-controlled floating-point data past the end of the allocated vertex buffer on the heap.
Potential Attack Steps
Note: These steps are based on static analysis, as our tooling agent does not currently run exploit code.
- Initial Compromise: An attacker gains arbitrary code execution in the sandboxed Renderer process.
- Crafting the Payload: The attacker crafts a serialized
SkRegionbyte array. The header specifies a smallintervalCount(e.g., 1). The run array is structured with empty spans followed by carefully chosen data designed to be misinterpreted as attacker-controlled rectangle coordinates. - Embedding in IPC: The attacker embeds this
SkRegioninto anAlphaThresholdPaintFilteras part of acc::PaintOpBufferstream. - Triggering the Bug: The compromised Renderer sends this
PaintOpBufferto the GPU process for Out-of-Process Rasterization (OOP-R) via thekRasterCHROMIUMcommand. - Execution: The GPU process deserializes the
PaintOpBuffer, instantiates the filter, and attempts to draw the region using Ganesh. The iterator desynchronizes, yielding excess rectangles, and theVertexWriteroverflows the heap buffer, leading to Remote Code Execution (RCE) in the GPU process.
Suggested Fix
- Fix the Iterator: In
SkRegion::Iterator::next()(third_party/skia/src/core/SkRegion.cpp), modify the empty line handling block to either loop to find the next valid interval or return immediately after advancing the pointer.
if (0 == intervals) { // empty line
fRect.fTop = runs[0];
runs += 3;
fRuns = runs;
// RECURSE OR LOOP HERE to find the next valid rectangle instead of falling through.
this->next();
return;
}
- Harden Validation: Update
validate_runto explicitly reject consecutive empty spans or empty spans at the end of a region if they are considered malformed.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.