Overview

High
Severity
β€”
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Skia
DescriptionUse after free in Skia
ComponentSkia
Bug ClassUAF
Tracker498989348
Fix commitd3934f5b07cc (skia) +34/-15
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
if
src/core/SkRuntimeEffect.cpp
modified

Files Changed

  • include/effects/SkRuntimeEffect.h
  • src/core/SkRuntimeEffect.cpp
From d3934f5b07ccbdb8e36e733c12d00f4f53811097 Mon Sep 17 00:00:00 2001
From: Kaylee Lubick <[email protected]>
Date: Mon, 27 Apr 2026 18:23:31 +0000
Subject: [PATCH] Make local optimized copy of program when creating SkRP version

As per the linked bug, there could be a problem if the gpu backend
tried to use a compiled runtime effect at the same time as the
cpu backend made its first call to getRPProgram(). This could result
in the fBaselineProgram being mutated out from under the gpu backend's
version.

This makes an optimized copy of the existing program to then convert
to SkRasterPipeline. By setting optimize to true and a non-zero
inlining limit, compiler.convertProgram will call the inliner and
remove unused functions (as well as other things like unused
local/global variables).

To prevent further mutation of fBaseProgram, I made it be pointer
to const (I'd initially been puzzled how a const getRPProgram could
be mutating it, but only the pointer was const).

Performance change looks negligible (parsing is pretty quick):
```
$ out/Release/nanobench_baseline --match sksl_skrp
Timer overhead: 23.5ns
curr/maxrss	loops	min	median	mean	max	stddev	samples   	config	bench
  92/90  MB	3	5.35Β΅s	5.38Β΅s	5.38Β΅s	5.45Β΅s	0%	β–ˆβ–…β–β–ƒβ–ƒβ–„β–„β–‚β–ƒβ–ƒ	nonrendering	sksl_skrp_tiny
  92/90  MB	3	14.7Β΅s	14.9Β΅s	15.1Β΅s	17.5Β΅s	6%	β–ˆβ–‚β–β–β–β–β–β–β–β–	nonrendering	sksl_skrp_small
  92/90  MB	1	125Β΅s	132Β΅s	135Β΅s	164Β΅s	9%	β–ˆβ–…β–ƒβ–‚β–‚β–β–‚β–β–β–„	nonrendering	sksl_skrp_medium
  92/90  MB	1	321Β΅s	338Β΅s	339Β΅s	364Β΅s	5%	β–ˆβ–†β–ƒβ–‚β–ˆβ–„β–‚β–β–…β–‚	nonrendering	sksl_skrp_large

$ out/Release/nanobench_with_change --match sksl_skrp
Timer overhead: 23.5ns
curr/maxrss	loops	min	median	mean	max	stddev	samples   	config	bench
  92/90  MB	3	5.62Β΅s	5.64Β΅s	5.64Β΅s	5.69Β΅s	0%	β–ˆβ–‚β–„β–„β–ƒβ–ƒβ–„β–β–…β–ƒ	nonrendering	sksl_skrp_tiny
  92/90  MB	4	14.8Β΅s	14.9Β΅s	15.3Β΅s	17.1Β΅s	5%	β–ˆβ–‚β–β–β–…β–β–β–β–β–	nonrendering	sksl_skrp_small
  92/90  MB	2	125Β΅s	130Β΅s	131Β΅s	154Β΅s	7%	β–ˆβ–ƒβ–‚β–‚β–‚β–β–β–‚β–β–ƒ	nonrendering	sksl_skrp_medium
  92/90  MB	1	322Β΅s	344Β΅s	342Β΅s	369Β΅s	5%	β–ˆβ–†β–…β–ƒβ–‚β–β–‡β–„β–‚β–ƒ	nonrendering	sksl_skrp_large
```

Bug: b/498989348
Change-Id: Idf32b817f5d3de823bb95f588e69e4e8a58ead80
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1219876
Reviewed-by: Michael Ludwig <[email protected]>
Commit-Queue: Kaylee Lubick <[email protected]>
---

diff --git a/include/effects/SkRuntimeEffect.h b/include/effects/SkRuntimeEffect.h
index e36f92f9..a53e721 100644
--- a/include/effects/SkRuntimeEffect.h
+++ b/include/effects/SkRuntimeEffect.h
@@ -324,8 +324,8 @@
     uint32_t fStableKey = 0;
     SkString fName;
 
-    std::unique_ptr<SkSL::Program> fBaseProgram;
-    std::unique_ptr<SkSL::RP::Program> fRPProgram;
+    std::unique_ptr<const SkSL::Program> fBaseProgram;
+    std::unique_ptr<const SkSL::RP::Program> fRPProgram;
     mutable SkOnce fCompileRPProgramOnce;
     const SkSL::FunctionDefinition& fMain;
     std::vector<Uniform> fUniforms;
diff --git a/src/core/SkRuntimeEffect.cpp b/src/core/SkRuntimeEffect.cpp
index ee4bfcb..be07904 100644
--- a/src/core/SkRuntimeEffect.cpp
+++ b/src/core/SkRuntimeEffect.cpp
@@ -222,30 +222,48 @@
     fCompileRPProgramOnce([&] {
         // We generally do not run the inliner when an SkRuntimeEffect program is initially created,
         // because the final compile to native shader code will do this. However, in SkRP, there's
-        // no additional compilation occurring, so we need to manually inline here if we want the
-        // performance boost of inlining.
-        if (!(fFlags & kDisableOptimization_Flag)) {
-            SkSL::Compiler compiler;
-            fBaseProgram->fConfig->fSettings.fInlineThreshold = SkSL::kDefaultInlineThreshold;
-            compiler.runInliner(*fBaseProgram);
+        // no additional compilation occurring, so we need to optimize/inline here if we want the
+        // performance boost of inlining. Since fBaseProgram is a shared (const) object, we can't
+        // mutate it in-place (e.g. calling compiler.runInliner). If optimization is neccesary,
+        // we re-compile the program from source with inlining and optimization enabled to get a
+        // freshly optimized copy (it's pretty cheap to re-compile and there's no easy way to copy
+        // an SkSL::Program).
+        const SkSL::Program* programToUse = fBaseProgram.get();
+        const SkSL::FunctionDefinition* mainToUse = &fMain;
 
-            // After inlining, the program is likely to have dead functions left behind.
-            while (SkSL::Transform::EliminateDeadFunctions(*fBaseProgram)) {
-                // Removing dead functions may cause more functions to become unreferenced.
+        std::unique_ptr<SkSL::Program> optimizedCopy;
+        bool shouldOptimize = !(fFlags & kDisableOptimization_Flag);
+        SkSL::ProgramSettings settings = fBaseProgram->fConfig->fSettings;
+        bool needsOptimization = !settings.fOptimize ||
+                                  settings.fInlineThreshold < SkSL::kDefaultInlineThreshold;
+        if (shouldOptimize && needsOptimization) {
+            SkSL::Compiler compiler;
+            settings.fOptimize = true;
+            settings.fInlineThreshold = SkSL::kDefaultInlineThreshold;
+            optimizedCopy = compiler.convertProgram(
+                    fBaseProgram->fConfig->fKind, *fBaseProgram->fSource, settings);
+            SkASSERT(optimizedCopy);
+            if (optimizedCopy) {
+                const auto* mainDecl = optimizedCopy->getFunction("main");
+                SkASSERT(mainDecl);
+                if (mainDecl) {
+                    programToUse = optimizedCopy.get();
+                    mainToUse = mainDecl->definition();
+                }
             }
         }
 
         SkSL::DebugTracePriv tempDebugTrace;
         if (debugTrace) {
             const_cast<SkRuntimeEffect*>(this)->fRPProgram = MakeRasterPipelineProgram(
-                    *fBaseProgram, fMain, debugTrace, /*writeTraceOps=*/true);
+                    *programToUse, *mainToUse, debugTrace, /*writeTraceOps=*/true);
         } else if (kRPEnableLiveTrace) {
             debugTrace = &tempDebugTrace;
             const_cast<SkRuntimeEffect*>(this)->fRPProgram = MakeRasterPipelineProgram(
-                    *fBaseProgram, fMain, debugTrace, /*writeTraceOps=*/false);
+                    *programToUse, *mainToUse, debugTrace, /*writeTraceOps=*/false);
         } else {
             const_cast<SkRuntimeEffect*>(this)->fRPProgram = MakeRasterPipelineProgram(
-                    *fBaseProgram, fMain, /*debugTrace=*/nullptr, /*writeTraceOps=*/false);
+                    *programToUse, *mainToUse, /*debugTrace=*/nullptr, /*writeTraceOps=*/false);
         }
 
         if (kRPEnableLiveTrace) {
@@ -450,8 +468,9 @@
 }
 
 SkSL::ProgramSettings SkRuntimeEffect::MakeSettings(const Options& options) {
+    constexpr int kDisableSKSLInlining = 0;
     SkSL::ProgramSettings settings;
-    settings.fInlineThreshold = 0;
+    settings.fInlineThreshold = kDisableSKSLInlining;
     settings.fForceNoInline = options.forceUnoptimized;
     settings.fOptimize = !options.forceUnoptimized;
     settings.fMaxVersionAllowed = options.maxVersionAllowed;
Loading diff…

Original Bug Report

reported by [email protected]

UAF in SkRuntimeEffect due to data race on fBaseProgram IR

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 potential data race exists in Skia’s SkRuntimeEffect where getRPProgram() mutates the global fBaseProgram AST via inlining while other threads concurrently read it. If triggered by a compromised renderer executing operations concurrently on the gpu_main and viz_compositor threads, this race can lead to a Use-After-Free of SkSL IR nodes. Exploitation could lead to Remote Code Execution in the GPU process.

Affected files:

  • third_party/skia/src/core/SkRuntimeEffect.cpp
  • third_party/skia/src/sksl/SkSLInliner.cpp
  • third_party/skia/src/sksl/codegen/SkSLPipelineStageCodeGenerator.cpp
  • third_party/skia/src/sksl/transform/SkSLEliminateDeadFunctions.cpp
  • third_party/skia/src/gpu/graphite/ShaderCodeDictionary.cpp
  • third_party/skia/src/gpu/ganesh/effects/GrSkSLFP.cpp

Estimated timestamp from git blame: 2024-07-29

Summary

There is a potential data race and Use-After-Free (UAF) vulnerability in Skia’s SkRuntimeEffect affecting the Chrome GPU process. The issue stems from unsynchronized access to the fBaseProgram Abstract Syntax Tree (AST). While SkRuntimeEffect::getRPProgram() lazily optimizes this AST in-place using an SkOnce block, other threads can concurrently read the AST during GPU shader generation. Because SkRuntimeEffect disables memory pooling, SkSL AST node replacements directly trigger standard heap deallocations, allowing a concurrent reader to dereference a dangling pointer and invoke virtual methods on freed memory.

Note: The exploitation steps below are suggested/potential steps derived from codebase analysis; our tooling agent does not yet have the ability to run code to provide a working proof-of-concept.

Technical Details

  1. Shared Singletons: Built-in runtime effects like SkLumaColorFilter are implemented via a leaked, globally shared SkRuntimeEffect singleton (SkKnownRuntimeEffects::GetKnownRuntimeEffect). This singleton holds the parsed SkSL AST in fBaseProgram.
  2. In-place Mutation & Deallocation: When evaluated for a constant input, GrSkSLFP::constantOutputForConstantInput calls fEffect->getRPProgram() to run the effect on the CPU. Inside getRPProgram(), an SkOnce block lazily optimizes the AST by invoking SkSL::Compiler::runInliner. The inliner replaces nodes like FunctionCall with their inlined equivalents. Crucially, SkRuntimeEffect explicitly disables SkSL memory pools (fUseMemoryPool = false), meaning the destroyed nodes are immediately freed to the system heap via ::operator delete. Skia structures do not use MiraclePtr (BRP), so these allocations are unprotected.
  3. Unsynchronized Concurrent Reads: While SkOnce ensures the compilation only happens once, it provides zero protection against concurrent reads. Other components, such as Graphite’s GenerateRuntimeShaderPreamble (which runs SkSL::PipelineStage::ConvertProgram), deeply traverse fBaseProgram to generate GPU shaders without acquiring any read locks.
  4. Exploitation Primitive: SkSL::IRNode and its subclasses are highly polymorphic. A concurrent reader thread traversing the AST can hold a dangling pointer to a node just deleted by the inliner on another thread. When the reader invokes a virtual method on this node (e.g., expr.description() or expr.type().matches()), it dereferences the vtable, providing a strong primitive for arbitrary code execution if the heap is groomed.

Potential Exploitation Steps

An attacker with a compromised renderer process could attempt to exploit this via the following sequence:

  1. Heap Grooming: The attacker sends a sequence of IPC messages (e.g., allocating specific WebGL textures or SharedImages) to groom the GPU process heap.
  2. Task B (Read Trigger): The attacker embeds a DrawRectOp using SkLumaColorFilter (reachable via CSS mask-mode: luminance or canvas) and a variable shader input into a CompositorFrame. This frame is submitted to the Viz service, targeting the viz_compositor thread. If Graphite is enabled, this forces GenerateRuntimeShaderPreamble to traverse the SkLumaColorFilter AST.
  3. Task A (Mutation Trigger): Concurrently, the attacker dispatches a DrawRectOp with SkLumaColorFilter and a constant solid color through the RasterDecoder (OOP-R), targeting the gpu_main thread. Ganesh’s optimization pass (GrColorFragmentProcessorAnalysis) evaluates the constant output, invoking getRPProgram() and triggering the inliner mutation.
  4. Race Condition: If timed correctly, the gpu_main thread deletes the FunctionCall node while the viz_compositor thread holds a reference to it.
  5. Hijack: The attacker’s groomed data reclaims the freed memory block and supplies a fake vtable pointer. The viz_compositor thread resumes execution, calls a virtual method on the freed node, and jumps to the attacker’s ROP chain, achieving RCE in the GPU process.

Suggested Fix

The root cause is that fBaseProgram is treated as a conceptually const object but is physically mutated during lazy optimization. Potential fixes include:

  1. Deep Copy: Have getRPProgram create a deep clone of fBaseProgram before running the inliner and dead-code elimination, rather than mutating the shared AST in-place.
  2. AOT Optimization: Run the inliner and dead-code elimination ahead-of-time when the SkRuntimeEffect is initially created, removing the need to mutate it lazily in getRPProgram.
  3. Read/Write Lock: Protect fBaseProgram accesses with a base::subtle::AtomicWord or an std::shared_mutex allowing multiple concurrent readers but taking an exclusive lock during the one-time lazy compilation.

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