Spoofing / Shader registry collision leading to potential bypass of engine internals

HIGH
flutter/flutter
Commit: 3e4e8b379e1d
Affected: < v1.16.3
2026-05-13 21:46 UTC

Description

This commit addresses a security vulnerability in Impeller's shader registry where user-supplied shaders (Flutter GPU user shaders, FragmentProgram/RuntimeEffect shaders, and Impeller's own shaders) were registered into a shared ShaderLibrary keyed only by (entrypoint name, stage). If a user shader exposed an entrypoint with the same name as an engine-internal entrypoint, a collision could occur, enabling eviction or shadowing of engine-internal pipelines via the hot-reload/dirty-stage path. The fix namespaces user shader entrypoints with a scope-prefixed name (MakeUserScopedName) and routes all RegisterFunction / UnregisterFunction / GetFunction calls through this scoped key. It also caches the scoped name per RuntimeEffectContents and ensures engine-internal entrypoints (which are compile-time constants) remain unscoped. Tests are added for MakeUserScopedName and GetLibraryId to validate behavior. In short, this is a hardening against spoofing/collisions in the shader registry that could otherwise allow a user shader to evict or shadow engine internals.

Proof of Concept

Proof-of-concept (conceptual, not runnable here): 1) Pre-fix scenario: The engine registers an internal fragment entrypoint named "Main" in the shared ShaderLibrary for an internal pipeline. 2) A user shader asset (e.g., a FragmentProgram) defines an entrypoint also named "Main". Since the registration key is only (name, stage), both the internal and user entrypoints collide in the same registry entry. 3) A hot-reload / dirty-stage event occurs (e.g., shader recompilation). The code path evicts the existing entry for that key, potentially removing or shadowing the engine-internal function with the user-provided one. 4) The render pipeline that relied on the engine-internal entrypoint now executes the user shader (or misbehaves due to the eviction), potentially bypassing safeguards or changing behavior. This PoC illustrates the collision path that the patch fixes by namespacing user entrypoints and preventing collisions with engine-internal ones. The key mechanics involve: - Engine-internal entrypoints remain unprefixed and cannot collide with user-scoped names. - User shader entrypoints are prefixed via ShaderKey::MakeUserScopedName(scope, library_id, entrypoint) to form a unique key like "<scope>:<library_id>:<entrypoint>". - All registration/lookup sites in user-shader paths are routed through the scoped name, preventing spoofing via name collisions. Prerequisites: access to the shader registry during runtime (hot reload or shader recompilation), and a user shader asset whose entrypoint matches an engine-internal one. Note: The exact exploit variants depend on the engine usage of the entrypoints; the PoC demonstrates how a collision could occur and why the scoped naming prevents it.

Commit Details

Author: Brandon DeRosier

Date: 2026-05-13 17:55 UTC

Message:

[Impeller] Namespace user-supplied shaders to prevent entrypoint collisions (#186332) Resolves https://github.com/flutter/flutter/issues/175464 Flutter GPU user shaders, FragmentProgram (RuntimeEffect) shaders, and Impeller's own entity-contents shaders all register into the same per-Context `impeller::ShaderLibrary`, keyed only on `(entrypoint name, stage)`. Because impellerc derives entrypoint names from source file stems with no scope prefix, a user-supplied shader whose `.frag` source file matches an Impeller-internal one will collide. In the FragmentProgram case the collision is worse than a silent shadow: `RuntimeEffectContents::RegisterShader` actively calls `UnregisterFunction` on the existing entry when a new dirty stage arrives at the same key (this eviction path was added intentionally to support shader hot reload), so a user could in principle evict an engine pipeline function. This change introduces a small `ShaderKey::MakeUserScopedName(scope, library_id, entrypoint)` helper that prefixes user-shader entrypoints with `<scope>:<library_id>:` before they go into the shared registry. Engine-internal entrypoints stay unprefixed and remain unspoofable: impellerc-generated names are valid identifiers and cannot contain `:`. All `RegisterFunction` / `UnregisterFunction` / `GetFunction` sites in the user-shader paths (and the runtime-effect pipeline cache key in `ContentContext`) are routed through the helper. Engine-internal lookup sites (`PipelineBuilder`, `ComputePipelineBuilder`, the runtime-effect vertex stage in `RuntimeEffectContents::CreatePipeline`) are unchanged because they already use compile-time constant entrypoint names. The `library_id` is anchored to a stable per-source identifier so that hot reload semantics are preserved: * `FragmentProgram::initFromAsset` calls `RuntimeStage::SetLibraryId(asset_name)`. Hot reload of the same `.frag` asset produces a new `RuntimeStage` with the same `library_id`, so the existing eviction path at `RuntimeEffectContents::RegisterShader` continues to evict and replace at the same scoped key. * `flutter::gpu::ShaderLibrary::MakeFromAsset` plumbs the asset name into `MakeFromFlatbuffer`, which propagates it to each owned `flutter::gpu::Shader`. A future hot reload story for Flutter GPU can land its own dirty/evict logic against this same scoped key. * For both paths, when no asset name is supplied (tests, future in-memory APIs) a process-unique fallback id is generated so different decoded stages or bundles cannot collide with each other either. This also covers the multi-package case for free: Flutter's asset manifest already disambiguates package-shipped assets via the `packages/<pkg>/...` prefix, so two packages each shipping a shader with the same internal name land at distinct asset paths and therefore distinct `library_id` values. ## Tests * Unit tests for `ShaderKey::MakeUserScopedName` (correct format, contains the `:` separator that prevents engine-internal collisions, different library ids and different scopes do not collide). * Tests for `RuntimeStage::GetLibraryId` (auto-assigned and unique by default, overridable via `SetLibraryId`). All added under `impeller/runtime_stage/runtime_stage_unittests.cc`. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

Triage Assessment

Vulnerability Type: Spoofing / Shader registry collision leading to potential bypass of engine internals

Confidence: HIGH

Reasoning:

The change namespaces user-supplied shader entrypoints to prevent collisions with engine-internal ones, addressing a vulnerability where a user shader could shadow or evict engine internals via shared shader registry. This hardening prevents spoofing/collision leaks that could enable bypassing protections or triggering unintended behavior, which is security-relevant.

Verification Assessment

Vulnerability Type: Spoofing / Shader registry collision leading to potential bypass of engine internals

Confidence: HIGH

Affected Versions: < v1.16.3

Code Diff

diff --git a/engine/src/flutter/impeller/entity/contents/runtime_effect_contents.cc b/engine/src/flutter/impeller/entity/contents/runtime_effect_contents.cc index bacf49bbc5548..740a3b19bafd0 100644 --- a/engine/src/flutter/impeller/entity/contents/runtime_effect_contents.cc +++ b/engine/src/flutter/impeller/entity/contents/runtime_effect_contents.cc @@ -20,6 +20,7 @@ #include "impeller/renderer/pipeline_library.h" #include "impeller/renderer/render_pass.h" #include "impeller/renderer/shader_function.h" +#include "impeller/renderer/shader_key.h" #include "impeller/renderer/vertex_descriptor.h" namespace impeller { @@ -80,6 +81,18 @@ const Geometry* RuntimeEffectContents::GetGeometry() const { void RuntimeEffectContents::SetRuntimeStage( std::shared_ptr<RuntimeStage> runtime_stage) { runtime_stage_ = std::move(runtime_stage); + // Precompute the scoped registry name now so that the hot per-frame + // `Render` path does not re-allocate it on every call. The name is keyed + // on the stage's library id and entrypoint, both of which are stable for + // a given `runtime_stage_` value, so this stays valid until the next + // `SetRuntimeStage`. + if (runtime_stage_) { + scoped_fragment_name_ = ShaderKey::MakeUserScopedName( + ShaderKey::kScopeRuntimeEffect, runtime_stage_->GetLibraryId(), + runtime_stage_->GetEntrypoint()); + } else { + scoped_fragment_name_.clear(); + } } void RuntimeEffectContents::SetUniformData( @@ -150,18 +163,17 @@ bool RuntimeEffectContents::RegisterShader( const std::shared_ptr<Context>& context = renderer.GetContext(); const std::shared_ptr<ShaderLibrary>& library = context->GetShaderLibrary(); - std::shared_ptr<const ShaderFunction> function = library->GetFunction( - runtime_stage_->GetEntrypoint(), ShaderStage::kFragment); + std::shared_ptr<const ShaderFunction> function = + library->GetFunction(scoped_fragment_name_, ShaderStage::kFragment); //-------------------------------------------------------------------------- /// Resolve runtime stage function. /// if (function && runtime_stage_->IsDirty()) { - renderer.ClearCachedRuntimeEffectPipeline(runtime_stage_->GetEntrypoint()); + renderer.ClearCachedRuntimeEffectPipeline(scoped_fragment_name_); context->GetPipelineLibrary()->RemovePipelinesWithEntryPoint(function); - library->UnregisterFunction(runtime_stage_->GetEntrypoint(), - ShaderStage::kFragment); + library->UnregisterFunction(scoped_fragment_name_, ShaderStage::kFragment); function = nullptr; } @@ -171,8 +183,7 @@ bool RuntimeEffectContents::RegisterShader( auto future = promise.get_future(); library->RegisterFunction( - runtime_stage_->GetEntrypoint(), - ToShaderStage(runtime_stage_->GetShaderStage()), + scoped_fragment_name_, ToShaderStage(runtime_stage_->GetShaderStage()), runtime_stage_->GetCodeMapping(), fml::MakeCopyable([promise = std::move(promise)](bool result) mutable { promise.set_value(result); @@ -184,8 +195,8 @@ bool RuntimeEffectContents::RegisterShader( return false; } - function = library->GetFunction(runtime_stage_->GetEntrypoint(), - ShaderStage::kFragment); + function = + library->GetFunction(scoped_fragment_name_, ShaderStage::kFragment); if (!function) { VALIDATION_LOG << "Failed to fetch runtime effect function immediately after " @@ -216,8 +227,8 @@ RuntimeEffectContents::CreatePipeline(const ContentContext& renderer, desc.SetLabel("Runtime Stage"); desc.AddStageEntrypoint( library->GetFunction(VS::kEntrypointName, ShaderStage::kVertex)); - desc.AddStageEntrypoint(library->GetFunction(runtime_stage_->GetEntrypoint(), - ShaderStage::kFragment)); + desc.AddStageEntrypoint( + library->GetFunction(scoped_fragment_name_, ShaderStage::kFragment)); std::shared_ptr<VertexDescriptor> vertex_descriptor = std::make_shared<VertexDescriptor>(); @@ -351,7 +362,7 @@ bool RuntimeEffectContents::Render(const ContentContext& renderer, [&](ContentContextOptions options) { // Pipeline creation callback for the cache handler to call. return renderer.GetCachedRuntimeEffectPipeline( - runtime_stage_->GetEntrypoint(), options, [&]() { + scoped_fragment_name_, options, [&]() { return CreatePipeline(renderer, options, /*async=*/false); }); }; diff --git a/engine/src/flutter/impeller/entity/contents/runtime_effect_contents.h b/engine/src/flutter/impeller/entity/contents/runtime_effect_contents.h index 31f3e4eb81e10..c32c89906aefe 100644 --- a/engine/src/flutter/impeller/entity/contents/runtime_effect_contents.h +++ b/engine/src/flutter/impeller/entity/contents/runtime_effect_contents.h @@ -66,6 +66,10 @@ class RuntimeEffectContents final : public ColorSourceContents { const Geometry* geometry_ = nullptr; std::shared_ptr<RuntimeStage> runtime_stage_; + // Scoped registry name for `runtime_stage_`'s fragment function. Stable + // across the lifetime of a single `runtime_stage_` value, so we compute + // it once in `SetRuntimeStage` rather than per `Render`. + std::string scoped_fragment_name_; std::shared_ptr<std::vector<uint8_t>> uniform_data_; std::vector<TextureInput> texture_inputs_; }; diff --git a/engine/src/flutter/impeller/renderer/BUILD.gn b/engine/src/flutter/impeller/renderer/BUILD.gn index 0340164b956f2..29d9b14a234b4 100644 --- a/engine/src/flutter/impeller/renderer/BUILD.gn +++ b/engine/src/flutter/impeller/renderer/BUILD.gn @@ -95,7 +95,10 @@ impeller_component("renderer") { public_deps += [ ":compute_shaders" ] } - deps = [ "//flutter/fml" ] + deps = [ + "//flutter/fml", + "//third_party/abseil-cpp/absl/strings", + ] } template("renderer_unittests_component") { diff --git a/engine/src/flutter/impeller/renderer/backend/metal/shader_library_mtl.h b/engine/src/flutter/impeller/renderer/backend/metal/shader_library_mtl.h index b38cd6139d30d..30c19e39a23ac 100644 --- a/engine/src/flutter/impeller/renderer/backend/metal/shader_library_mtl.h +++ b/engine/src/flutter/impeller/renderer/backend/metal/shader_library_mtl.h @@ -56,7 +56,14 @@ class ShaderLibraryMTL final : public ShaderLibrary { id<MTLDevice> GetDevice() const; - void RegisterLibrary(id<MTLLibrary> library); + // Adds the just-compiled `library` to `libraries_` and caches its + // stage-matching function under `ShaderKey(name, stage)` in `functions_` + // so that namespaced registration names (e.g. `re:<library_id>:<entry>`) + // resolve via the cache rather than via the MSL function-name lookup that + // `GetFunction` falls back to. + void RegisterLibraryAndCacheFunction(id<MTLLibrary> library, + const std::string& name, + ShaderStage stage); ShaderLibraryMTL(const ShaderLibraryMTL&) = delete; diff --git a/engine/src/flutter/impeller/renderer/backend/metal/shader_library_mtl.mm b/engine/src/flutter/impeller/renderer/backend/metal/shader_library_mtl.mm index a1aedd9a54857..ab3d3e3fa16d0 100644 --- a/engine/src/flutter/impeller/renderer/backend/metal/shader_library_mtl.mm +++ b/engine/src/flutter/impeller/renderer/backend/metal/shader_library_mtl.mm @@ -97,8 +97,8 @@ static MTLFunctionType ToMTLFunctionType(ShaderStage stage) { } // |ShaderLibrary| -void ShaderLibraryMTL::RegisterFunction(std::string name, // unused - ShaderStage stage, // unused +void ShaderLibraryMTL::RegisterFunction(std::string name, + ShaderStage stage, std::shared_ptr<fml::Mapping> code, RegistrationCallback callback) { if (!callback) { @@ -137,7 +137,7 @@ static MTLFunctionType ToMTLFunctionType(ShaderStage stage) { return; } reinterpret_cast<ShaderLibraryMTL*>(strong_this.get()) - ->RegisterLibrary(library); + ->RegisterLibraryAndCacheFunction(library, name, stage); failure_callback->Release(); callback(true); }]; @@ -145,10 +145,28 @@ static MTLFunctionType ToMTLFunctionType(ShaderStage stage) { // |ShaderLibrary| void ShaderLibraryMTL::UnregisterFunction(std::string name, ShaderStage stage) { - ReaderLock lock(libraries_mutex_); + WriterLock lock(libraries_mutex_); - // Find the shader library containing this function name and remove it. + ShaderKey key(name, stage); + + // Cache-first path: the cache, populated at RegisterFunction time, holds + // the mapping from registration name to MTLLibrary. Scoped registration + // names (e.g. `re:<library_id>:<entry>`) don't match any MSL function name, + // so the library-iteration fallback below cannot find them. + if (auto found = functions_.find(key); found != functions_.end()) { + id<MTLLibrary> target_library = + ShaderFunctionMTL::Cast(*found->second).library_; + if (target_library) { + [libraries_ removeObject:target_library]; + } + functions_.erase(found); + return; + } + // Fallback: look the function up in the libraries by its MSL name. Used + // when the cache was not populated (e.g. for engine-bundled libraries that + // were registered via the multi-library constructor rather than through + // RegisterFunction). bool found_library = false; for (size_t i = [libraries_ count] - 1; i >= 0; i--) { id<MTLFunction> function = @@ -163,24 +181,30 @@ static MTLFunctionType ToMTLFunctionType(ShaderStage stage) { VALIDATION_LOG << "Library containing function " << name << " was not found, so it couldn't be unregistered."; } - - // Remove the shader from the function cache. - - ShaderKey key(name, stage); - - auto found = functions_.find(key); - if (found == functions_.end()) { - VALIDATION_LOG << "Library function named " << name - << " was not found, so it couldn't be unregistered."; - return; - } - - functions_.erase(found); } -void ShaderLibraryMTL::RegisterLibrary(id<MTLLibrary> library) { +void ShaderLibraryMTL::RegisterLibraryAndCacheFunction(id<MTLLibrary> library, + const std::string& name, + ShaderStage stage) { WriterLock lock(libraries_mutex_); [libraries_ addObject:library]; + + // Find the function in the newly compiled library matching the requested + // stage and cache it under the registration name. Subsequent + // `GetFunction(name, stage)` calls then resolve via the cache, which lets + // namespaced registration names (e.g. `re:<library_id>:<entry>`) that don't + // match any MSL function name still resolve to the right function. + const MTLFunctionType expected = ToMTLFunctionType(stage); + for (NSString* function_name in [library functionNames]) { + id<MTLFunction> mtl_function = [library newFunctionWithName:function_name]; + if (mtl_function && mtl_function.functionType == expected) { + ShaderKey key(name, stage); + functions_[key] = + std::shared_ptr<ShaderFunctionMTL>(new ShaderFunctionMTL( + library_id_, mtl_function, library, name, stage)); + break; + } + } } } // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/shader_key.cc b/engine/src/flutter/impeller/renderer/shader_key.cc index c27e1e5c3f449..7e3db675a872e 100644 --- a/engine/src/flutter/impeller/renderer/shader_key.cc +++ b/engine/src/flutter/impeller/renderer/shader_key.cc @@ -4,8 +4,22 @@ #include "impeller/renderer/shader_key.h" +#include <atomic> +#include <cstdint> + +#include "third_party/abseil-cpp/absl/strings/str_cat.h" + namespace impeller { -// +std::string ShaderKey::MakeFallbackLibraryId() { + static std::atomic<uint64_t> counter{0}; + return absl::StrCat("auto:", counter.fetch_add(1, std::memory_order_relaxed)); +} + +std::string ShaderKey::MakeUserScopedName(std::string_view scope, + std::string_view library_id, + std::string_view entrypoint) { + return absl::StrCat(scope, ":", library_id, ":", entrypoint); +} } // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/shader_key.h b/engine/src/flutter/impeller/renderer/shader_key.h index 09f3a9cb3bfdd..35124691b35e9 100644 --- a/engine/src/flutter/impeller/renderer/shader_key.h +++ b/engine/src/flutter/impeller/renderer/shader_key.h @@ -7,6 +7,7 @@ #include <memory> #include <string> +#include <string_view> #include <unordered_map> #include "flutter/fml/hash_combine.h" @@ -32,6 +33,36 @@ struct ShaderKey { return k1.stage == k2.stage && k1.name == k2.name; } }; + + /// Build a registry name for a user-supplied shader so it cannot collide + /// with engine-internal names that share the same source-derived + /// entrypoint. + /// + /// `scope` is a short tag identifying the source kind (see kScope* + /// constants below). `library_id` is an opaque identifier that is stable + /// across reloads of the same logical user shader (typically the asset + /// path that loaded the shader). `entrypoint` is the original shader + /// entrypoint name as produced by impellerc. + /// + /// Engine-internal entrypoints generated by impellerc are valid + /// identifiers and therefore cannot contain ':', so the colon-separated + /// format makes user-scoped names unspoofable from the engine namespace. + static std::string MakeUserScopedName(std::string_view scope, + std::string_view library_id, + std::string_view entrypoint); + + /// Scope tag for `RuntimeStage`-backed shaders (FragmentProgram / + /// RuntimeEffect). + static constexpr std::string_view kScopeRuntimeEffect = "re"; + + /// Scope tag for Flutter GPU user shader bundles. + static constexpr std::string_view kScopeFlutterGPU = "fg"; + + /// Returns a process-unique library id for user shader sources that were + /// not constructed via an asset-bearing entry point (e.g. tests, future + /// in-memory APIs). Stable for the lifetime of the source but distinct + /// across sources, so the user-scoped registry name cannot collide. + static std::string MakeFallbackLibraryId(); }; class ShaderFunction; diff --git a/engine/src/flutter/impeller/runtime_stage/runtime_stage.cc b/engine/src/flutter/impeller/runtime_stage/runtime_stage.cc index 6a87b09fab54e..51bc6c52840da 100644 --- a/engine/src/flutter/impeller/runtime_stage/runtime_stage.cc +++ b/engine/src/flutter/impeller/runtime_stage/runtime_stag ... [truncated]
← Back to Alerts View on GitHub →