Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in Skia
DescriptionOut of bounds write in Skia
ComponentSkia
Bug ClassOOB
Tracker500080194
Fix commit2b94912a4d8d (skia) +44/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
if
src/sksl/codegen/SkSLRasterPipelineBuilder.cpp
modified
DEF_TEST
tests/SkRuntimeEffectTest.cpp
modified
if
tests/SkRuntimeEffectTest.cpp
modified

Files Changed

  • src/sksl/codegen/SkSLRasterPipelineBuilder.cpp
  • tests/SkRuntimeEffectTest.cpp
From 2b94912a4d8d8576116046955167a1ca32c13fa1 Mon Sep 17 00:00:00 2001
From: Kaylee Lubick <[email protected]>
Date: Mon, 27 Apr 2026 15:55:24 +0000
Subject: [PATCH] Avoid removing too many stack entries in SkRP during discard_stack

Consider an sksl snippet like:
```
half4 main(float2 xy) {
    float4 v = float4(xy.x, -1.0, -2.0, -3.0);
    v = abs(v);
    return half4(v);
}
```

this could be turned into (unoptimized) instructions like [1]
```
store_src_rg                   xy = src.rg
init_lane_masks                CondMask = LoopMask = RetMask = true
copy_slot_unmasked             v(0) = xy(0)
copy_constant                  v(1) = 0xBF800000 (-1.0)
copy_constant                  v(2) = 0xC0000000 (-2.0)
copy_constant                  v(3) = 0xC0400000 (-3.0)
copy_4_slots_unmasked          $0..3 = v            # unnecessary
bitwise_and_imm_4_ints         $0..3 &= 0x7FFFFFFF
copy_4_slots_unmasked          v = $0..3
load_src                       src.rgba = $0..3
```
( the bitwise_and_imm_4_ints instruction is the abs() part, masking
off the sign bit)

We can optimize cases where we push to the stack (e.g.
the copy_4_slots_unmasked), do an operation (the &=) and then
pop the value off the stack into just doing the operation w/o
involving the stack. (We might have to push the result to the
stack for further use).
```
...
copy_constant                  v(3) = 0xC0400000 (-3.0)
bitwise_and_imm_4_ints         v &= 0x7FFFFFFF
copy_4_slots_unmasked          $0..3 = v
load_src                       src.rgba = $0..3
```

There was a bug in the optimization where we removed *all* the
slots for an instruction (4 in this case because it's a float4)
even though the call was discard_stack(1). This would be followed
up by a call to discard_stack(3) (the remainder of the previous
instruction's stack) and we'd underflow the stack.

Problematic sksl:
```
half4 main(float2 xy) {
  float4 v;
  v.x += xy.x;
  (v = abs(v)).xyz;  # drop 1 channel (the .w)
  return half4(v);
}
# This was in the output
bitwise_and_imm_4_ints         v &= 0x7FFFFFFF
copy_4_slots_unmasked          ExternalPtr(0..3) = v
load_src                       src.rgba = ExternalPtr(0..3)
```

where ExternalPtr is referring to memory outside any variables,
uniforms, or the temporary stack. Yikes! I'll keep that in mind
for the future.

Anyway, for the fix, we'll only remove N slots if the previous
call was N slots wide.

[1] e.g. bazel build //tools/skslc && bazel-bin/tools/skslc/skslc input.sksl output.skrp

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

diff --git a/src/sksl/codegen/SkSLRasterPipelineBuilder.cpp b/src/sksl/codegen/SkSLRasterPipelineBuilder.cpp
index 9df0201..cac2b68 100644
--- a/src/sksl/codegen/SkSLRasterPipelineBuilder.cpp
+++ b/src/sksl/codegen/SkSLRasterPipelineBuilder.cpp
@@ -387,7 +387,7 @@
             case BuilderOp::copy_stack_to_slots_unmasked: {
                 // Look for a pattern of `push, immediate-ops, pop` and simplify it down to an
                 // immediate-op directly to the value slot.
-                if (count == 1) {
+                if (count == lastInstruction->fImmA) {
                     if (this->simplifyImmediateUnmaskedOp()) {
                         return;
                     }
@@ -1374,7 +1374,7 @@
         current[stackID] += stack_usage(inst);
         largest[stackID] = std::max(current[stackID], largest[stackID]);
         // If we assert here, the generated program has popped off the top of the stack.
-        SkASSERTF(current[stackID] >= 0, "unbalanced temp stack push/pop on stack %d", stackID);
+        SkASSERTF_RELEASE(current[stackID] >= 0, "unbalanced temp stack push/pop on stack");
     }
 
     // Ensure that when the program is complete, our stacks are fully balanced.
diff --git a/tests/SkRuntimeEffectTest.cpp b/tests/SkRuntimeEffectTest.cpp
index ad285e3..54ff37e 100644
--- a/tests/SkRuntimeEffectTest.cpp
+++ b/tests/SkRuntimeEffectTest.cpp
@@ -1795,3 +1795,45 @@
     }
 }
 #endif
+
+DEF_TEST(SkRuntimeShader_b500080194, r) {
+    constexpr const char* kSkSL =
+        "half4 main(float2 xy) {"
+          "float4 v;"
+          "v.x += xy.x;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "(v = abs(v)).xyz;"
+          "return half4(v);"
+        "}";
+
+    auto [effect, err] = SkRuntimeEffect::MakeForShader(SkString(kSkSL));
+
+    if (!effect) {
+        REPORT_FAILURE(r, "SkSL compile failed", SkString("SkSL compile failed"));
+    } else {
+        sk_sp<SkShader> shader = effect->makeShader(/*uniforms=*/nullptr, {});
+        SkPaint paint;
+        paint.setShader(std::move(shader));
+        sk_sp<SkSurface> surface = SkSurfaces::Raster(SkImageInfo::MakeN32Premul(64, 64));
+        // This caused a crash before the patch.
+        surface->getCanvas()->drawPaint(paint);
+    }
+}
Loading diff…

Original Bug Report

reported by [email protected]

OOB write in SkSL Raster Pipeline via simplifyImmediateUnmaskedOp stack underflow

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 flaw in the SkSL Raster Pipeline builder allows a peephole optimization to incorrectly process multi-slot instructions during a partial stack discard. This results in a negative temporary stack depth, causing the stack pointer to walk backwards out-of-bounds. Subsequent instructions can then write attacker-controlled uniform data into adjacent memory, potentially leading to RCE in the GPU or Utility process.

Affected files:

  • third_party/skia/src/sksl/codegen/SkSLRasterPipelineBuilder.cpp

Estimated timestamp from git blame: 2023-06-28

The Vulnerability

In third_party/skia/src/sksl/codegen/SkSLRasterPipelineBuilder.cpp, the Builder::discard_stack(int32_t count) method attempts to optimize away unnecessary stack operations. When count == 1, it checks for a specific sequence (push, immediate-op, unmasked copy) by calling simplifyImmediateUnmaskedOp().

However, simplifyImmediateUnmaskedOp() contains a logic bug: it fails to verify that the count of slots being discarded matches the size of the multi-slot immediate operation it is optimizing. It only checks if immInstruction->fImmA == popInstruction->fImmA.

The Trigger

This can be triggered by an identity swizzle on a vectorized intrinsic that uses an immediate operation, such as float4 v; (v = abs(v)).xyz;.

  1. v = abs(v) evaluates to a sequence ending with push_slots (4 slots), bitwise_and_imm_int (4 slots), and copy_stack_to_slots_unmasked (4 slots).
  2. The .xyz swizzle requires discarding 1 tail element, calling discard_stack(1).
  3. simplifyImmediateUnmaskedOp() matches the 4-slot sequence. Because it ignores the count == 1 parameter, it erroneously proceeds: it subtracts 4 from the push_slots count (making it 0) and pops the copy_stack_to_slots_unmasked instruction off the list. It returns true, and discard_stack(1) returns early without emitting any instruction.
  4. The CodeGenerator must then discard the remaining 3 slots of the expression statement via discardExpression(3), calling discard_stack(3).
  5. Because the previous instructions were modified, the optimization no longer matches, and a raw discard_stack instruction with fImmA = 3 is appended to the stream.

This results in a net stack usage of -3 slots (0 slots pushed, 3 slots discarded).

Out-of-Bounds Write

By repeating this statement, an attacker can accumulate an arbitrarily large negative stack depth. During pipeline compilation (Program::makeStages):

  1. Assertions protecting stack depth (SkASSERTF(current[stackID] >= 0)) and pointer boundaries (SkASSERT(tempStackPtr >= slots.stack.data())) are compiled out in production Release builds.
  2. stack_usage() returns negative values for the mismatched discard_stack operations, causing tempStackPtr to linearly walk backwards, out of its designated memory slab and into adjacent memory allocated within the same SkArenaAlloc.
  3. The attacker can follow the underflow with an operation that writes to the stack using raw pointers, such as reading an attacker-controlled uniform variable (v = my_uniform;).
  4. The push_uniform instruction initializes a UniformCtx stage. Crucially, it populates ctx->dst by casting the out-of-bounds tempStackPtr directly to a raw float*.
  5. During pipeline execution, copy_uniforms writes the attacker’s uniform data to this raw pointer, bypassing the 32-bit SkRPOffset safety mechanism used by other pipeline instructions.

Impact

The SkArenaAlloc slab often houses critical objects prior to the SkSL stack allocation, including MatrixRec data, uniform arrays, and instances of SkRasterPipelineBlitter (which contain vtables). By carefully crafting the stack underflow depth and the uniform payload, an attacker could overwrite these objects, hijack execution flow, and achieve arbitrary Remote Code Execution (RCE) and a Sandbox Escape from a compromised Renderer to the GPU Process or Utility Process.

Proposed Fix

In third_party/skia/src/sksl/codegen/SkSLRasterPipelineBuilder.cpp, simplifyImmediateUnmaskedOp must be updated to verify that the count passed to discard_stack exactly matches the slot count of the immediate operation. Alternatively, discard_stack should simply pass count as an argument to simplifyImmediateUnmaskedOp() to ensure validation.

bool Builder::simplifyImmediateUnmaskedOp(int count) {
    // ...
    if (immInstruction->fImmA == popInstruction->fImmA && immInstruction->fImmA == count) {
        // ...

Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad


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