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

Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in Skia
DescriptionHeap buffer overflow in Skia
ComponentSkia
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker498809718
Fix commit63dbf37c3700 (skia) +167/-25
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-01 (per vendor advisory)
Fix landed2026-04-24 (public git)
Disclosed2026-04-28
Patch gap4 days — fix public in source before the advisory

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 a Y-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.

Key insight
The deserializer failed to enforce the serializer’s “empty strips are always coalesced” invariant, letting consecutive empty slices through validation; the fix tracks prevWasEmpty and rejects back-to-back (or leading) empty spans so corrupt RLE is refused up front.

Attack Path

  1. Deliver a crafted region An attacker supplies a serialized SkRegion blob (e.g. via a content path that deserializes regions with readFromMemory) whose run array contains two empty slices in a row.
  2. Pass the weak validation Pre-patch, the loop’s per-strip checks (xIntervals range and sentinel checks) did not reject consecutive xIntervals == 0 strips, so the malformed run array is accepted as valid.
  3. 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.
  4. 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

An attacker able to get Skia to deserialize an untrusted 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

FunctionChangeNotes
SK_API
include/core/SkRegion.h
modified
if
src/core/SkRegion.cpp
modified
switch
src/core/SkRegion.cpp
modified

Files Changed

  • include/core/SkRegion.h
  • src/core/SkRegion.cpp

Audit Directions

  • Serializer-invariant gaps in deserializers
    Audit readFromMemory/fromBytes paths that trust structural invariants the serializer guarantees (coalesced gaps, ordering, uniqueness) without re-checking them on untrusted input.
  • RLE / variable-length walkers
    Review code that advances through run-length or TLV-style buffers using counts embedded in the data, confirming every advance is bounded by the buffer end under all count combinations, including zero counts.
  • Degenerate/empty-element handling
    Look 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.
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.
Loading diff…

Original Bug Report

reported by [email protected]

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.cpp
  • third_party/skia/src/core/SkRegion.cpp
  • third_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.

  1. Initial Compromise: An attacker gains arbitrary code execution in the sandboxed Renderer process.
  2. Crafting the Payload: The attacker crafts a serialized SkRegion byte array. The header specifies a small intervalCount (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.
  3. Embedding in IPC: The attacker embeds this SkRegion into an AlphaThresholdPaintFilter as part of a cc::PaintOpBuffer stream.
  4. Triggering the Bug: The compromised Renderer sends this PaintOpBuffer to the GPU process for Out-of-Process Rasterization (OOP-R) via the kRasterCHROMIUM command.
  5. 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 the VertexWriter overflows the heap buffer, leading to Remote Code Execution (RCE) in the GPU process.

Suggested Fix

  1. 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;
        }
  1. Harden Validation: Update validate_run to 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.

View on issue tracker