CVE-2026-7920
Overview
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.
fBaseProgram pointer-to-const so it can no longer be mutated out from under a concurrent backend.Attack Path
- Create and use a runtime effect on the GPU Content supplies an SkSL runtime effect that the GPU backend compiles and begins using.
- Trigger the first CPU-side raster-pipeline build
The same effect is driven down the CPU backend, invoking
getRPProgram()’sfCompileRPProgramOncelambda for the first time. - Mutate the shared program in place
Under the old code the lambda runs
runInlinerandEliminateDeadFunctionsdirectly on the sharedfBaseProgram, altering the program the GPU backend is concurrently reading. - 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). - Escalation
Any read/write primitive or code execution beyond the UAF is
(inferred)and not demonstrated by the patch.
Impact Assessment
(inferred) rather than established.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/core/SkRuntimeEffect.cpp |
modified |
Files Changed
include/effects/SkRuntimeEffect.hsrc/core/SkRuntimeEffect.cpp
Audit Directions
- Lazy initialization that mutates shared stateAudit
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-constLook 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 tounique_ptr<const T>prevents accidental in-place mutation. - GPU/CPU backend sharingReview 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.
Patch
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;
Original Bug Report
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.cppthird_party/skia/src/sksl/SkSLInliner.cppthird_party/skia/src/sksl/codegen/SkSLPipelineStageCodeGenerator.cppthird_party/skia/src/sksl/transform/SkSLEliminateDeadFunctions.cppthird_party/skia/src/gpu/graphite/ShaderCodeDictionary.cppthird_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
- Shared Singletons: Built-in runtime effects like
SkLumaColorFilterare implemented via a leaked, globally sharedSkRuntimeEffectsingleton (SkKnownRuntimeEffects::GetKnownRuntimeEffect). This singleton holds the parsed SkSL AST infBaseProgram. - In-place Mutation & Deallocation: When evaluated for a constant input,
GrSkSLFP::constantOutputForConstantInputcallsfEffect->getRPProgram()to run the effect on the CPU. InsidegetRPProgram(), anSkOnceblock lazily optimizes the AST by invokingSkSL::Compiler::runInliner. The inliner replaces nodes likeFunctionCallwith their inlined equivalents. Crucially,SkRuntimeEffectexplicitly 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. - Unsynchronized Concurrent Reads: While
SkOnceensures the compilation only happens once, it provides zero protection against concurrent reads. Other components, such as Graphite’sGenerateRuntimeShaderPreamble(which runsSkSL::PipelineStage::ConvertProgram), deeply traversefBaseProgramto generate GPU shaders without acquiring any read locks. - Exploitation Primitive:
SkSL::IRNodeand 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()orexpr.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:
- Heap Grooming: The attacker sends a sequence of IPC messages (e.g., allocating specific WebGL textures or SharedImages) to groom the GPU process heap.
- Task B (Read Trigger): The attacker embeds a
DrawRectOpusingSkLumaColorFilter(reachable via CSSmask-mode: luminanceor canvas) and a variable shader input into aCompositorFrame. This frame is submitted to the Viz service, targeting theviz_compositorthread. If Graphite is enabled, this forcesGenerateRuntimeShaderPreambleto traverse theSkLumaColorFilterAST. - Task A (Mutation Trigger): Concurrently, the attacker dispatches a
DrawRectOpwithSkLumaColorFilterand a constant solid color through theRasterDecoder(OOP-R), targeting thegpu_mainthread. Ganesh’s optimization pass (GrColorFragmentProcessorAnalysis) evaluates the constant output, invokinggetRPProgram()and triggering the inliner mutation. - Race Condition: If timed correctly, the
gpu_mainthread deletes theFunctionCallnode while theviz_compositorthread holds a reference to it. - Hijack: The attacker’s groomed data reclaims the freed memory block and supplies a fake vtable pointer. The
viz_compositorthread 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:
- Deep Copy: Have
getRPProgramcreate a deep clone offBaseProgrambefore running the inliner and dead-code elimination, rather than mutating the shared AST in-place. - AOT Optimization: Run the inliner and dead-code elimination ahead-of-time when the
SkRuntimeEffectis initially created, removing the need to mutate it lazily ingetRPProgram. - Read/Write Lock: Protect
fBaseProgramaccesses with abase::subtle::AtomicWordor anstd::shared_mutexallowing 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.