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

Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Skia
DescriptionUse after free in Skia
ComponentSkia
Bug ClassUAF
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.
Tracker498989348
Fix commitd3934f5b07cc (skia) +34/-15
CWECWE-416 (Use-after-free)
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-02 (per vendor advisory)
Fix landed2026-04-27 (public git)
Disclosed2026-05-05
Patch gap8 days — fix public in source before the advisory

Background

`SkRuntimeEffect`
a Skia object representing a compiled SkSL runtime effect (shader/color-filter/blender) that can be executed on either the GPU or CPU backend.
`fBaseProgram` / `SkSL::Program`
the parsed/compiled SkSL program owned by the effect and shared between backends.
`SkSL::RP::Program` (SkRasterPipeline program)
the CPU-backend lowering of the effect, produced lazily on the first CPU call and cached in fRPProgram.
`SkOnce` (`fCompileRPProgramOnce`)
a one-time-initialization guard that runs its lambda exactly once to build the raster-pipeline program.

Root Cause Analysis

Per the commit, the CPU-backend path that lazily builds the raster-pipeline program (guarded by fCompileRPProgramOnce, i.e., getRPProgram) optimized the program by mutating the shared fBaseProgram in place — calling compiler.runInliner(*fBaseProgram) and looping SkSL::Transform::EliminateDeadFunctions(*fBaseProgram). If the GPU backend was using that same compiled runtime effect concurrently with the CPU backend’s first getRPProgram() call, the in-place mutation changed fBaseProgram (the “fBaselineProgram”) out from under the GPU backend’s version, a data race that can free/replace program sub-objects still in use, producing the use-after-free.

The fix stops mutating the shared object: when optimization is needed it builds a fresh optimizedCopy via compiler.convertProgram(fBaseProgram->fConfig->fKind, *fBaseProgram->fSource, settings) with fOptimize and fInlineThreshold set, selects its main via getFunction("main"), and passes programToUse/mainToUse to MakeRasterPipelineProgram so fBaseProgram is never inlined/mutated. To enforce immutability, fBaseProgram and fRPProgram are changed to std::unique_ptr<const SkSL::Program> / const SkSL::RP::Program in the header, so the shared program is pointer-to-const and cannot be mutated in place. This is a concurrency/ownership fix (make-a-copy), not a bounds-check tighten or relax.

Key insight
The core mistake was mutating a program object shared between the GPU and CPU backends during lazy CPU-side optimization; the fix recompiles an optimized local copy instead and makes fBaseProgram pointer-to-const so it can no longer be mutated out from under a concurrent backend.

Attack Path

  1. Create and use a runtime effect on the GPU Content supplies an SkSL runtime effect that the GPU backend compiles and begins using.
  2. Trigger the first CPU-side raster-pipeline build The same effect is driven down the CPU backend, invoking getRPProgram()’s fCompileRPProgramOnce lambda for the first time.
  3. Mutate the shared program in place Under the old code the lambda runs runInliner and EliminateDeadFunctions directly on the shared fBaseProgram, altering the program the GPU backend is concurrently reading.
  4. Race into freed/replaced state The GPU backend’s in-use view of the program (e.g., functions removed by dead-function elimination) is mutated/freed underneath it, the use-after-free; the specific freed sub-object is not pinpointed in the diff (inferred from the commit's "mutated out from under" description).
  5. Escalation Any read/write primitive or code execution beyond the UAF is (inferred) and not demonstrated by the patch.

Impact Assessment

Content that supplies an SkSL runtime effect and causes it to be exercised on the GPU and CPU backends concurrently can trigger a use-after-free in the process running Skia, rated high severity by the vendor advisory. The precondition is concurrent GPU and first-time CPU use of the same compiled runtime effect so the in-place mutation races the GPU reader. No in-the-wild exploitation is reported, and escalation beyond the UAF is (inferred) rather than established.

Changed Functions

FunctionChangeNotes
if
src/core/SkRuntimeEffect.cpp
modified

Files Changed

  • include/effects/SkRuntimeEffect.h
  • src/core/SkRuntimeEffect.cpp

Audit Directions

  • Lazy initialization that mutates shared state
    Audit SkOnce/one-time builders that modify an object shared across threads or backends; such lazy work should operate on a copy, not the shared source.
  • Const-of-pointer vs pointer-to-const
    Look for std::unique_ptr<T> members intended to be immutable but still mutable through the pointee (the commit notes only the pointer was const); promoting to unique_ptr<const T> prevents accidental in-place mutation.
  • GPU/CPU backend sharing
    Review places where GPU and CPU backends share compiled artifacts (programs, pipelines, shaders) and verify neither backend mutates a resource the other may be reading concurrently.
From d3934f5b07ccbdb8e36e733c12d00f4f53811097 Mon Sep 17 00:00:00 2001
From: Kaylee Lubick <kjlubick@google.com>
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 <michaelludwig@google.com>
Commit-Queue: Kaylee Lubick <kjlubick@google.com>
---

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 vm...@google.com

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