Chrome · V8 › Deserializer
CVE-2026-110919
UAF in V8 (Deserializer)
Overview
High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/snapshot/code-serializer.cc |
modified | |
CodeSerializersrc/snapshot/code-serializer.h |
modified | |
SerializedCodeDatasrc/snapshot/code-serializer.h |
modified | |
TEST_Ftest/unittests/api/deserialize-unittest.cc |
modified |
Files Changed
test/unittests/api/deserialize-unittest.ccsrc/codegen/compiler.ccsrc/snapshot/code-serializer.hsrc/codegen/compiler.hsrc/snapshot/code-serializer.cc
Patch
From 9d9ccafe3b95e4658806c1d7514257d6042a1ba4 Mon Sep 17 00:00:00 2001 From: Leszek Swirski <[email protected]> Date: Wed, 07 Oct 2026 16:52:46 +0200 Subject: [PATCH] [deserializer] Check source hash before starting BG merge Off-thread code cache deserialization happens before source text is available, so we can't verify the cache's source hash up front. When source text becomes available, SourceTextAvailable previously set up a background merge with a matching Script from the compilation cache without checking whether the serialized code cache's source hash (including module vs. classic origin options) matched the incoming script. If a module code cache was handed to a classic script load of the same URL, the background merge would merge incompatible scripts before FinishOffThreadDeserialize rejected the cache. Check the cached data's source hash via CodeSerializer::NotifySourceTextAvailable before setting up the background merge task, and record the result in OffThreadDeserializeData::source_sanity_check_result so FinishOffThreadDeserialize does not recompute it. Populate OffThreadDeserializeData in-place in StartDeserializeOffThread and avoid reading data->sanity_check_result or mutating cached_data->Reject() in NotifySourceTextAvailable so the main-thread source check cannot race with or be clobbered by concurrent background deserialization. TAG=agy CONV=d5ba0f6d-8783-4ff9-b6c1-1b9dffa490f4 Fixed: 568831769 Change-Id: Id8d350af7ef0a4f52fb954ad16902b1cfab364ea Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8526067 Auto-Submit: Leszek Swirski <[email protected]> Reviewed-by: Toon Verwaest <[email protected]> Commit-Queue: Toon Verwaest <[email protected]> Cr-Commit-Position: refs/heads/main@{#110449} --- diff --git a/src/codegen/compiler.cc b/src/codegen/compiler.cc index 6e4bf13..d1b2bec 100644 --- a/src/codegen/compiler.cc +++ b/src/codegen/compiler.cc @@ -2875,8 +2875,8 @@ LocalHandleScope handle_scope(&isolate); DirectHandle<SharedFunctionInfo> inner_result; - off_thread_data_ = - CodeSerializer::StartDeserializeOffThread(&isolate, &cached_data_); + CodeSerializer::StartDeserializeOffThread(&isolate, &cached_data_, + &off_thread_data_); if (off_thread_data_.HasResult()) { Tagged<Script> script = *off_thread_data_.GetOnlyScript(isolate.heap()); script_type_timer.set_histogram( @@ -2898,6 +2898,11 @@ Isolate* isolate, Handle<String> source_text, const ScriptDetails& script_details) { DCHECK_EQ(isolate, isolate_for_local_isolate_); + if (!CodeSerializer::NotifySourceTextAvailable(isolate, &off_thread_data_, + &cached_data_, source_text, + script_details)) { + return; + } LanguageMode language_mode = construct_language_mode(v8_flags.use_strict); background_merge_task_.SetUpOnMainThread(isolate, source_text, script_details, language_mode); diff --git a/src/codegen/compiler.h b/src/codegen/compiler.h index af0057d..003b30a 100644 --- a/src/codegen/compiler.h +++ b/src/codegen/compiler.h @@ -722,7 +722,7 @@ private: Isolate* isolate_for_local_isolate_; AlignedCachedData cached_data_; - CodeSerializer::OffThreadDeserializeData off_thread_data_; + OffThreadDeserializeData off_thread_data_; BackgroundMergeTask background_merge_task_; TimedHistogram* timer_; int64_t background_time_in_microseconds_ = 0; diff --git a/src/snapshot/code-serializer.cc b/src/snapshot/code-serializer.cc index 9dcff86..e1bfe5d 100644 --- a/src/snapshot/code-serializer.cc +++ b/src/snapshot/code-serializer.cc @@ -346,8 +346,8 @@ LocalIsolate local_isolate(isolate_, ThreadKind::kBackground); UnparkedScope unparked_scope(&local_isolate); LocalHandleScope handle_scope(&local_isolate); - off_thread_data_ = - CodeSerializer::StartDeserializeOffThread(&local_isolate, cached_data_); + CodeSerializer::StartDeserializeOffThread(&local_isolate, cached_data_, + &off_thread_data_); } MaybeDirectHandle<SharedFunctionInfo> Finalize( @@ -361,7 +361,7 @@ private: Isolate* isolate_; AlignedCachedData* cached_data_; - CodeSerializer::OffThreadDeserializeData off_thread_data_; + OffThreadDeserializeData off_thread_data_; }; void FinalizeDeserialization(Isolate* isolate, @@ -555,8 +555,7 @@ return scope.CloseAndEscape(result); } -DirectHandle<Script> CodeSerializer::OffThreadDeserializeData::GetOnlyScript( - LocalHeap* heap) { +DirectHandle<Script> OffThreadDeserializeData::GetOnlyScript(LocalHeap* heap) { std::unique_ptr<PersistentHandles> previous_persistent_handles = heap->DetachPersistentHandles(); heap->AttachPersistentHandles(std::move(persistent_handles)); @@ -574,32 +573,47 @@ return script; } -CodeSerializer::OffThreadDeserializeData -CodeSerializer::StartDeserializeOffThread(LocalIsolate* local_isolate, - AlignedCachedData* cached_data) { - OffThreadDeserializeData result; - +void CodeSerializer::StartDeserializeOffThread(LocalIsolate* local_isolate, + AlignedCachedData* cached_data, + OffThreadDeserializeData* data) { DCHECK(!local_isolate->heap()->HasPersistentHandles()); const SerializedCodeData scd = SerializedCodeData::FromCachedDataWithoutSource( - local_isolate, cached_data, &result.sanity_check_result); - if (result.sanity_check_result != SerializedCodeSanityCheckResult::kSuccess) { + local_isolate, cached_data, &data->sanity_check_result); + if (data->sanity_check_result != SerializedCodeSanityCheckResult::kSuccess) { // Exit early but don't report yet, we'll re-check this when finishing on // the main thread DCHECK(cached_data->rejected()); - return result; + return; } MaybeDirectHandle<SharedFunctionInfo> local_maybe_result = OffThreadObjectDeserializer::DeserializeSharedFunctionInfo( - local_isolate, &scd, &result.scripts); + local_isolate, &scd, &data->scripts); - result.maybe_result = + data->maybe_result = local_isolate->heap()->NewPersistentMaybeHandle(local_maybe_result); - result.persistent_handles = local_isolate->heap()->DetachPersistentHandles(); + data->persistent_handles = local_isolate->heap()->DetachPersistentHandles(); +} - return result; +bool CodeSerializer::NotifySourceTextAvailable( + Isolate* isolate, OffThreadDeserializeData* data, + const AlignedCachedData* cached_data, DirectHandle<String> source, + const ScriptDetails& script_details) { + HandleScope scope(isolate); + + // Only check the source hash against the immutable cached_data header and + // write the main-thread-only data->source_sanity_check_result field here. Do + // not read/write data->sanity_check_result or mutate cached_data->Reject(), + // as StartDeserializeOffThread may still be running concurrently on a + // background thread. Full rejection and histogram reporting happen in + // FinishOffThreadDeserialize. + SerializedCodeData scd(cached_data); + SerializedCodeSanityCheckResult sanity_check_result = + scd.SanityCheckJustSource(isolate, source, script_details); + data->source_sanity_check_result = sanity_check_result; + return sanity_check_result == SerializedCodeSanityCheckResult::kSuccess; } MaybeDirectHandle<SharedFunctionInfo> @@ -615,11 +629,6 @@ HandleScope scope(isolate); - DirectHandle<FixedArray> wrapped_arguments; - if (!script_details.wrapped_arguments.is_null()) { - wrapped_arguments = script_details.wrapped_arguments.ToHandleChecked(); - } - // Do a source sanity check now that we have the source. It's important for // FromPartiallySanityCheckedCachedData call that the sanity_check_result // holds the result of the off-thread sanity check. @@ -627,11 +636,9 @@ data.sanity_check_result; const SerializedCodeData scd = SerializedCodeData::FromPartiallySanityCheckedCachedData( - cached_data, - SerializedCodeData::SourceHash(source, wrapped_arguments, - script_details.origin_options, - isolate), + isolate, cached_data, data, source, script_details, &sanity_check_result); + USE(scd); if (sanity_check_result != SerializedCodeSanityCheckResult::kSuccess) { // The only case where the deserialization result could exist despite a // check failure is on a source mismatch, since we can't test for this @@ -773,6 +780,17 @@ } SerializedCodeSanityCheckResult SerializedCodeData::SanityCheckJustSource( + Isolate* isolate, DirectHandle<String> source, + const ScriptDetails& script_details) const { + DirectHandle<FixedArray> wrapped_arguments; + if (!script_details.wrapped_arguments.is_null()) { + wrapped_arguments = script_details.wrapped_arguments.ToHandleChecked(); + } + return SanityCheckJustSource(SourceHash( + source, wrapped_arguments, script_details.origin_options, isolate)); +} + +SerializedCodeSanityCheckResult SerializedCodeData::SanityCheckJustSource( SourceHash expected_source_hash) const { if (size_ < kHeaderSize) { return SerializedCodeSanityCheckResult::kInvalidHeader; @@ -918,7 +936,7 @@ return base::Vector<const uint8_t>(payload, length); } -SerializedCodeData::SerializedCodeData(AlignedCachedData* data) +SerializedCodeData::SerializedCodeData(const AlignedCachedData* data) : SerializedData(const_cast<uint8_t*>(data->data()), data->length()) {} SerializedCodeData SerializedCodeData::FromCachedData( @@ -953,12 +971,14 @@ } SerializedCodeData SerializedCodeData::FromPartiallySanityCheckedCachedData( - AlignedCachedData* cached_data, SourceHash expected_source_hash, + Isolate* isolate, AlignedCachedData* cached_data, + const OffThreadDeserializeData& data, DirectHandle<String> source, + const ScriptDetails& script_details, SerializedCodeSanityCheckResult* rejection_result) { - DisallowGarbageCollection no_gc; // The previous call to FromCachedDataWithoutSource may have already rejected // the cached data, so reuse the previous rejection result if it's not a // success. + *rejection_result = data.sanity_check_result; if (*rejection_result != SerializedCodeSanityCheckResult::kSuccess) { // FromCachedDataWithoutSource doesn't check the source, so there can't be // a source mismatch. @@ -968,7 +988,14 @@ return SerializedCodeData(nullptr, 0); } SerializedCodeData scd(cached_data); - *rejection_result = scd.SanityCheckJustSource(expected_source_hash); + if (data.source_sanity_check_result.has_value()) { + DCHECK_EQ(*data.source_sanity_check_result, + scd.SanityCheckJustSource(isolate, source, script_details)); + *rejection_result = *data.source_sanity_check_result; + } else { + *rejection_result = + scd.SanityCheckJustSource(isolate, source, script_details); + } if (*rejection_result != SerializedCodeSanityCheckResult::kSuccess) { // This check only checks the source, so the only possible failure is a // source mismatch. diff --git a/src/snapshot/code-serializer.h b/src/snapshot/code-serializer.h index 2caad19..315751a 100644 --- a/src/snapshot/code-serializer.h +++ b/src/snapshot/code-serializer.h @@ -6,6 +6,7 @@ #define V8_SNAPSHOT_CODE_SERIALIZER_H_ #include <array> +#include <optional> #include "src/base/macros.h" #include "src/codegen/script-details.h" @@ -62,6 +63,22 @@ class CodeSerializer; +struct OffThreadDeserializeData { + public: + bool HasResult() const { return !maybe_result.is_null(); } + DirectHandle<Script> GetOnlyScript(LocalHeap* heap); + + private: + friend class CodeSerializer; + friend class SerializedCodeData; + MaybeIndirectHandle<SharedFunctionInfo> maybe_result; + std::vector<IndirectHandle<Script>> scripts; + std::unique_ptr<PersistentHandles> persistent_handles; + SerializedCodeSanityCheckResult sanity_check_result = + SerializedCodeSanityCheckResult::kSuccess; + std::optional<SerializedCodeSanityCheckResult> source_sanity_check_result; +}; + // Wrapper around ScriptData to provide code-serializer-specific functionality. class SerializedCodeData : public SerializedData { public: @@ -122,7 +139,9 @@ // FromCachedDataWithoutSource. The rejection result from that call should be // passed into this one. static SerializedCodeData FromPartiallySanityCheckedCachedData( - AlignedCachedData* cached_data, SourceHash expected_source_hash, + Isolate* isolate, AlignedCachedData* cached_data, + const OffThreadDeserializeData& data, DirectHandle<String> source, + const ScriptDetails& script_details, SerializedCodeSanityCheckResult* rejection_result); // Used when producing. @@ -135,7 +154,8 @@ base::Vector<const uint8_t> Payload() const; private: - explicit SerializedCodeData(AlignedCachedData* data); + friend class CodeSerializer; + explicit SerializedCodeData(const AlignedCachedData* data); SerializedCodeData(const uint8_t* data, int size) : SerializedData(const_cast<uint8_t*>(data), size) {} @@ -151,6 +171,9 @@ uint32_t expected_ro_snapshot_checksum, SourceHash expected_source_hash) const; SerializedCodeSanityCheckResult SanityCheckJustSource( + Isolate* isolate, DirectHandle<String> source, + const ScriptDetails& script_details) const; + SerializedCodeSanityCheckResult SanityCheckJustSource( SourceHash expected_source_hash) const; SerializedCodeSanityCheckResult SanityCheckWithoutSource( uint32_t expected_ro_snapshot_checksum) const; @@ -160,19 +183,6 @@ public: using SourceHash = SerializedCodeData::SourceHash; - struct OffThreadDeserializeData { - public: - bool HasResult() const { return !maybe_result.is_null(); } - DirectHandle<Script> GetOnlyScript(LocalHeap* heap); - - private: - friend class CodeSerializer; - MaybeIndirectHandle<SharedFunctionInfo> maybe_result; - std::vector<IndirectHandle<Script>> scripts; - std::unique_ptr<PersistentHandles> persistent_handles; - SerializedCodeSanityCheckResult sanity_check_result; - }; - CodeSerializer(const CodeSerializer&) = delete; CodeSerializer& operator=(const CodeSerializer&) = delete; V8_EXPORT_PRIVATE static ScriptCompiler::CachedData* Serialize( @@ -186,9 +196,14 @@ DirectHandle<String> source, const ScriptDetails& script_details, MaybeDirectHandle<Script> maybe_cached_script = {}); - V8_WARN_UNUSED_RESULT static OffThreadDeserializeData - StartDeserializeOffThread(LocalIsolate* isolate, - AlignedCachedData* cached_data); + static void StartDeserializeOffThread(LocalIsolate* isolate, + AlignedCachedData* cached_data, + OffThreadDeserializeData* data); + + V8_WARN_UNUSED_RESULT static bool NotifySourceTextAvailable( + Isolate* isolate, OffThreadDeserializeData* data, + const AlignedCachedData* cached_data, DirectHandle<String> source, + const ScriptDetails& script_details); V8_WARN_UNUSED_RESULT static MaybeDirectHandle<SharedFunctionInfo> FinishOffThreadDeserialize( diff --git a/test/unittests/api/deserialize-unittest.cc b/test/unittests/api/deserialize-unittest.cc index ecd607e..fb9fb99 100644 --- a/test/unittests/api/deserialize-unittest.cc +++ b/test/unittests/api/deserialize-unittest.cc @@ -1333,4 +1333,148 @@ } } +TEST_F(MergeDeserializedCodeTest, ModuleCacheClassicScriptMismatch) { + i::v8_flags.merge_background_deserialized_script_with_compilation_cache = + true; + std::unique_ptr<v8::ScriptCompiler::CachedData> cached_data; + IsolateAndContextScope scope(this); + i::Isolate* i_isolate = reinterpret_cast<i::Isolate*>(isolate()); + PersistentScriptOrigin default_origin( + isolate(), NewString("http://example.com/test.js")); + + constexpr char kPolyglotSource[] = + "let L0 = 1;\n" + "var z, o;\n" + "function g() { throw 1; }\n" + "function K(a, b) { var t = globalThis.top; (t.__keep = t.__keep || " + "[]).push(a, b); }\n" + "z = await /1; function lazyOuter() { function inner() {} } { try { g() " + "} catch /* 1, o = { a: function () {}, 'NAMENAMENAMENAME' //*/\n" + "(e) { K(() => e, function later() { return e }) } }\n"; + + // Setup: define `await` and `top` in global scope for classic script parse. + { + v8::HandleScope handle_scope(isolate()); + Local<Script> setup = + Script::Compile( + context(), NewString("var await = 6; globalThis.top = globalThis;")) + .ToLocalChecked(); + CHECK(!setup->Run(context()).IsEmpty()); + } + + // Step 1: Compile and run as a classic script, populating the Isolate + // compilation cache and retaining catch-block closures in globalThis.__keep. + // Also compile as a module with the same resource name and create a module ... (file diff truncated)
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/test/unittests/api/deserialize-unittest.cc b/test/unittests/api/deserialize-unittest.cc
index ecd607e..fb9fb99 100644
--- a/test/unittests/api/deserialize-unittest.cc
+++ b/test/unittests/api/deserialize-unittest.cc
@@ -1333,4 +1333,148 @@
}
}
+TEST_F(MergeDeserializedCodeTest, ModuleCacheClassicScriptMismatch) {
+ i::v8_flags.merge_background_deserialized_script_with_compilation_cache =
+ true;
+ std::unique_ptr<v8::ScriptCompiler::CachedData> cached_data;
+ IsolateAndContextScope scope(this);
+ i::Isolate* i_isolate = reinterpret_cast<i::Isolate*>(isolate());
+ PersistentScriptOrigin default_origin(
+ isolate(), NewString("http://example.com/test.js"));
+
+ constexpr char kPolyglotSource[] =
+ "let L0 = 1;\n"
+ "var z, o;\n"
+ "function g() { throw 1; }\n"
+ "function K(a, b) { var t = globalThis.top; (t.__keep = t.__keep || "
+ "[]).push(a, b); }\n"
+ "z = await /1; function lazyOuter() { function inner() {} } { try { g() "
+ "} catch /* 1, o = { a: function () {}, 'NAMENAMENAMENAME' //*/\n"
+ "(e) { K(() => e, function later() { return e }) } }\n";
+
+ // Setup: define `await` and `top` in global scope for classic script parse.
+ {
+ v8::HandleScope handle_scope(isolate());
+ Local<Script> setup =
+ Script::Compile(
+ context(), NewString("var await = 6; globalThis.top = globalThis;"))
+ .ToLocalChecked();
+ CHECK(!setup->Run(context()).IsEmpty());
+ }
+
+ // Step 1: Compile and run as a classic script, populating the Isolate
+ // compilation cache and retaining catch-block closures in globalThis.__keep.
+ // Also compile as a module with the same resource name and create a module
+ // code cache, then age and flush the classic script's top-level bytecode.
+ {
+ v8::HandleScope handle_scope(isolate());
+ ScriptOrigin classic_origin = default_origin.AsScriptOrigin();
+ Local<Script> script =
+ Script::Compile(context(), NewString(kPolyglotSource), &classic_origin)
+ .ToLocalChecked();
+ CHECK(!script->Run(context()).IsEmpty());
+
+ ScriptOrigin module_origin(NewString("http://example.com/test.js"), 0, 0,
+ false, -1, Local<Value>(), false, false,
+ /*is_module=*/true);
+ ScriptCompiler::Source module_source(NewString(kPolyglotSource),
+ module_origin);
+ Local<Module> module =
+ ScriptCompiler::CompileModule(isolate(), &module_source)
+ .ToLocalChecked();
+ cached_data.reset(
+ ScriptCompiler::CreateCodeCache(module->GetUnboundModuleScript()));
+
+ i::SharedFunctionInfo::EnsureOldForTesting(GetSharedFunctionInfo(script));
+ }
+
+ // Step 2: Flush the classic script's top-level bytecode via major GC.
+ {
+ i::DisableConservativeStackScanningScopeForTesting no_css_scope(
+ i_isolate->heap());
+ InvokeMajorGC(i_isolate);
+ InvokeMajorGC(i_isolate);
+ }
+
+ // Step 3a: Start off-thread consumption of the MODULE code cache, then notify
+ // SourceTextAvailable with the CLASSIC ScriptOrigin after Run() completes.
+ {
+ auto task_cached_data = std::make_unique<ScriptCompiler::CachedData>(
+ cached_data->data, cached_data->length,
+ ScriptCompiler::CachedData::BufferNotOwned);
+ DeserializeThread deserialize_thread(
+ ScriptCompiler::StartConsumingCodeCache(
+ isolate(), std::make_unique<ScriptCompiler::CachedData>(
+ cached_data->data, cached_data->length,
+ ScriptCompiler::CachedData::BufferNotOwned)));
+ CHECK(deserialize_thread.Start());
+ deserialize_thread.Join();
+
+ std::unique_ptr<ScriptCompiler::ConsumeCodeCacheTask> task =
+ deserialize_thread.TakeTask();
+ task->SourceTextAvailable(isolate(), NewString(kPolyglotSource),
+ default_origin.AsScriptOrigin());
+ CHECK(!task->ShouldMergeWithExistingScript());
+
+ v8::HandleScope handle_scope(isolate());
+ ScriptCompiler::Source source(NewString(kPolyglotSource),
+ default_origin.AsScriptOrigin(),
+ task_cached_data.release(), task.release());
+ Local<Script> script =
+ ScriptCompiler::Compile(context(), &source,
+ ScriptCompiler::kConsumeCodeCache)
+ .ToLocalChecked();
+ CHECK(!script.IsEmpty());
+ CHECK(source.GetCachedData()->rejected);
+
+ // Age and flush the recompiled classic script's top-level bytecode again so
+ // Step 3b also sees a flushed Script in the Isolate compilation cache.
+ i::SharedFunctionInfo::EnsureOldForTesting(GetSharedFunctionInfo(script));
+ }
+ {
+ i::DisableConservativeStackScanningScopeForTesting no_css_scope(
+ i_isolate->heap());
+ InvokeMajorGC(i_isolate);
+ InvokeMajorGC(i_isolate);
+ }
+
+ // Step 3b: Also test calling SourceTextAvailable on the main thread BEFORE
+ // Run() executes on the background thread.
+ {
+ auto task_cached_data = std::make_unique<ScriptCompiler::CachedData>(
+ cached_data->data, cached_data->length,
+ ScriptCompiler::CachedData::BufferNotOwned);
+ std::unique_ptr<ScriptCompiler::ConsumeCodeCacheTask> task(
+ ScriptCompiler::StartConsumingCodeCache(
+ isolate(), std::make_unique<ScriptCompiler::CachedData>(
+ cached_data->data, cached_data->length,
+ ScriptCompiler::CachedData::BufferNotOwned)));
+ task->SourceTextAvailable(isolate(), NewString(kPolyglotSource),
+ default_origin.AsScriptOrigin());
+
+ DeserializeThread deserialize_thread(task.release());
+ CHECK(deserialize_thread.Start());
+ deserialize_thread.Join();
+ task = deserialize_thread.TakeTask();
+ CHECK(!task->ShouldMergeWithExistingScript());
+
+ v8::HandleScope handle_scope(isolate());
+ ScriptCompiler::Source source(NewString(kPolyglotSource),
+ default_origin.AsScriptOrigin(),
+ task_cached_data.release(), task.release());
+ Local<Script> script =
+ ScriptCompiler::Compile(context(), &source,
+ ScriptCompiler::kConsumeCodeCache)
+ .ToLocalChecked();
+ CHECK(!script.IsEmpty());
+ CHECK(source.GetCachedData()->rejected);
+
+ Local<Script> run_kept =
+ Script::Compile(context(),
+ NewString("for (const f of globalThis.__keep) f();"))
+ .ToLocalChecked();
+ CHECK(!run_kept->Run(context()).IsEmpty());
+ }
+}
+
} // namespace v8
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page