diff --git a/BUILD.gn b/BUILD.gn index e025aad7d..0cab78493 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -341,6 +341,7 @@ test("pdfium_unittests") { "xfa/fgas/crt:unittests", "xfa/fgas/font:unittests", "xfa/fgas/layout:unittests", + "xfa/fwl:unittests", "xfa/fxfa:unittests", "xfa/fxfa/formcalc:unittests", "xfa/fxfa/parser:unittests", diff --git a/xfa/fwl/BUILD.gn b/xfa/fwl/BUILD.gn index 15a0d2f6c..dc775e028 100644 --- a/xfa/fwl/BUILD.gn +++ b/xfa/fwl/BUILD.gn @@ -133,3 +133,14 @@ pdfium_embeddertest_source_set("embeddertests") { ] pdfium_root_dir = "../../" } + +pdfium_unittest_source_set("unittests") { + sources = [ "cfwl_caret_timer_uaf_unittest.cpp" ] + configs = [ "//v8:external_startup_data" ] + deps = [ + ":fwl", + "../../fxjs:gc", + "//v8:v8_libplatform", + ] + pdfium_root_dir = "../../" +} diff --git a/xfa/fwl/cfwl_caret_timer_uaf_unittest.cpp b/xfa/fwl/cfwl_caret_timer_uaf_unittest.cpp new file mode 100644 index 000000000..01c9e4895 --- /dev/null +++ b/xfa/fwl/cfwl_caret_timer_uaf_unittest.cpp @@ -0,0 +1,298 @@ +// Copyright 2024 The PDFium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Demonstrates that CFWL_Caret / CFWL_ScrollBar own a CFX_Timer whose +// deregistration is tied to the C++ destructor (sweep), not PreFinalize(). +// Because PDFium's cppgc heap is configured for kIncrementalAndConcurrent +// sweeping, a window exists between PreFinalize and the destructor in which +// the embedder can fire the timer, calling OnTimerFired() on a dead widget +// whose `outer_` Member<> may already point at swept (poisoned) memory. + +#include + +#include "core/fxcrt/cfx_timer.h" +#include "fxjs/gc/heap.h" +#include "testing/fxgc_unittest.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/v8_test_environment.h" +#include "v8/include/cppgc/allocation.h" +#include "v8/include/cppgc/explicit-management.h" +#include "v8/include/cppgc/garbage-collected.h" +#include "v8/include/cppgc/persistent.h" +#include "v8/include/cppgc/prefinalizer.h" +#include "v8/include/libplatform/libplatform.h" +#include "xfa/fwl/cfwl_app.h" +#include "xfa/fwl/cfwl_caret.h" +#include "xfa/fwl/cfwl_widget.h" +#include "xfa/fwl/cfwl_widgetmgr.h" +#include "xfa/fwl/ifwl_themeprovider.h" + +namespace pdfium { +namespace { + +// --------------------------------------------------------------------------- +// Fake embedder timer handler — captures the registered callback so the test +// can fire it at a chosen moment, exactly like Chrome's base::RepeatingTimer +// would on the PDF-process main thread. +// --------------------------------------------------------------------------- +class FakeTimerHandler final : public CFX_Timer::HandlerIface { + public: + int32_t SetTimer(int32_t uElapse, TimerCallback fn) override { + int32_t id = ++next_id_; + last_id_ = id; + fn_ = fn; + set_calls_++; + return id; + } + void KillTimer(int32_t id) override { kill_calls_++; } + + void Fire() { + if (fn_) { + fn_(last_id_); + } + } + + int set_calls_ = 0; + int kill_calls_ = 0; + int32_t next_id_ = 0; + int32_t last_id_ = 0; + TimerCallback fn_ = nullptr; +}; + +// --------------------------------------------------------------------------- +// Minimal fake CFWL_App adapter so we can construct a real CFWL_App + widgets +// without pulling in the entire CXFA_FFApp stack. +// --------------------------------------------------------------------------- +class FakeAppAdapter final + : public cppgc::GarbageCollected, + public CFWL_App::AdapterIface, + public CFWL_WidgetMgr::AdapterIface { + public: + CONSTRUCT_VIA_MAKE_GARBAGE_COLLECTED; + ~FakeAppAdapter() override = default; + + void Trace(cppgc::Visitor* visitor) const override {} + + // CFWL_App::AdapterIface + CFWL_WidgetMgr::AdapterIface* GetWidgetMgrAdapter() override { return this; } + CFX_Timer::HandlerIface* GetTimerHandler() override { return handler_; } + IFWL_ThemeProvider* GetThemeProvider() override { return nullptr; } + cppgc::Heap* GetHeap() override { return heap_; } + + // CFWL_WidgetMgr::AdapterIface + void RepaintWidget(CFWL_Widget* pWidget) override { repaint_calls_++; } + bool GetPopupPos(CFWL_Widget* pWidget, + float fMinHeight, + float fMaxHeight, + const CFX_RectF& rtAnchor, + CFX_RectF* pPopupRect) override { + return false; + } + + cppgc::Heap* heap_ = nullptr; + FakeTimerHandler* handler_ = nullptr; + int repaint_calls_ = 0; + + private: + FakeAppAdapter() = default; +}; + +// Sentinel that lets us observe whether PreFinalize has run during a GC cycle. +class PreFinalizeSentinel final + : public cppgc::GarbageCollected { + CPPGC_USING_PRE_FINALIZER(PreFinalizeSentinel, PreFinalize); + + public: + PreFinalizeSentinel() = default; + ~PreFinalizeSentinel() { dtor_count_++; } + void PreFinalize() { prefinalize_count_++; } + void Trace(cppgc::Visitor*) const {} + static int prefinalize_count_; + static int dtor_count_; +}; +int PreFinalizeSentinel::prefinalize_count_ = 0; +int PreFinalizeSentinel::dtor_count_ = 0; + +// Large garbage object used to push HeapGrowing past limit_for_atomic_gc_, +// which schedules a GC with sweeping_type == kIncrementalAndConcurrent. +class Bloater final : public cppgc::GarbageCollected { + public: + void Trace(cppgc::Visitor*) const {} + uint8_t bloat_[65536]; +}; + +} // namespace + +class CFWLCaretTimerUAFTest : public FXGCUnitTest { + public: + void SetUp() override { + FXGCUnitTest::SetUp(); + PreFinalizeSentinel::prefinalize_count_ = 0; + PreFinalizeSentinel::dtor_count_ = 0; + CFX_Timer::InitializeGlobals(); + } + void TearDown() override { + CFX_Timer::DestroyGlobals(); + FXGCUnitTest::TearDown(); + } + + // Run exactly one foreground task (e.g. the GCTask posted by GCInvoker). + bool PumpOne() { + return v8::platform::PumpMessageLoop( + V8TestEnvironment::GetInstance()->platform(), + V8TestEnvironment::GetInstance()->isolate()); + } + + FakeTimerHandler timer_handler_; +}; + +// ----------------------------------------------------------------------------- +// Part 1 — root cause: the embedder timer is *not* deregistered by +// CFWL_Widget::PreFinalize(). After an incremental GC has marked the caret +// dead and run its prefinalizer, the CFX_Timer entry in g_pwl_timer_map is +// still live and can be fired by the embedder. +// ----------------------------------------------------------------------------- +TEST_F(CFWLCaretTimerUAFTest, TimerSurvivesPreFinalize) { + // Drain any startup tasks so the first task we observe is the GCTask. + while (PumpOne()) { + } + + cppgc::Persistent adapter = + cppgc::MakeGarbageCollected(heap()->GetAllocationHandle()); + adapter->heap_ = heap(); + adapter->handler_ = &timer_handler_; + + cppgc::Persistent app = cppgc::MakeGarbageCollected( + heap()->GetAllocationHandle(), adapter.Get()); + + // Parent (the eventual UAF target) and child caret with outer_ == parent. + cppgc::Persistent parent = + cppgc::MakeGarbageCollected(heap()->GetAllocationHandle(), + app.Get(), + CFWL_Widget::Properties(), + nullptr); + cppgc::Persistent child = + cppgc::MakeGarbageCollected(heap()->GetAllocationHandle(), + app.Get(), + CFWL_Widget::Properties(), + parent.Get()); + + child->ShowCaret(); // Arms the 600ms blink timer via FakeTimerHandler. + ASSERT_EQ(1, timer_handler_.set_calls_); + ASSERT_EQ(0, timer_handler_.kill_calls_); + + // Sentinel that will tell us when PreFinalize has executed for this cycle. + cppgc::MakeGarbageCollected( + heap()->GetAllocationHandle()); + + // Drop *all* roots so the entire CFWL graph becomes unreachable, mirroring + // the state when CPDFXFA_Context releases its Persistent. + parent.Clear(); + child.Clear(); + app.Clear(); + adapter.Clear(); + + // Drive HeapGrowing past limit_for_atomic_gc_ so GCInvoker posts a GCTask + // (kNoConservativeStackScan forces the posted-task path). + for (int i = 0; i < 200 && PreFinalizeSentinel::prefinalize_count_ == 0; + ++i) { + for (int j = 0; j < 32; ++j) { + cppgc::MakeGarbageCollected(heap()->GetAllocationHandle()); + } + // Run exactly one task. The first one to flip the sentinel is the GCTask: + // it performs atomic marking, runs PreFinalizers, starts the + // kIncrementalAndConcurrent sweeper, then returns *without* running any + // destructors. + PumpOne(); + } + ASSERT_EQ(1, PreFinalizeSentinel::prefinalize_count_) + << "GCTask never ran; could not enter incremental-sweep window"; + + // ----- This is the bug ----- + // PreFinalize has run on every dead CFWL_Widget, but ~CFWL_Caret() — and + // therefore ~CFX_Timer() / KillTimer() — has *not* run yet. The embedder's + // base::RepeatingTimer is still armed and pointing at the dead caret. + EXPECT_EQ(0, PreFinalizeSentinel::dtor_count_) + << "destructor ran inside GCTask — sweep was not incremental"; + EXPECT_EQ(0, timer_handler_.kill_calls_) + << "would only be safe if PreFinalize had reset the timer"; + + // Fire the embedder timer in the [PreFinalize, sweep) window. This calls + // CFX_Timer::TimerProc -> caret->OnTimerFired() on a *dead* (unmarked, + // pre-finalized) cppgc object, walking its outer_ chain — exactly the + // UAF described in the report. + timer_handler_.Fire(); + + // Finish sweeping so TearDown is clean. + ForceGCAndPump(); + EXPECT_EQ(1, timer_handler_.kill_calls_); // ~CFX_Timer finally ran. +} + +// ----------------------------------------------------------------------------- +// Part 2 — UAF sink, deterministic ASAN repro. +// +// In production this state is reached when the foreground IncrementalSweepTask +// finalizes the page containing the caret's `outer_` (running its destructor +// and ASAN-poisoning / zeroing its bytes) before the page containing the +// caret itself. Here we reproduce that exact post-sweep state with the +// public cppgc primitive that performs `Finalize() + SetMemoryInaccessible()` +// on a single object — the same operations the sweeper applies — so that +// firing the still-registered timer reads through the poisoned `outer_`. +// +// Under ASAN this test produces: +// AddressSanitizer: use-after-poison +// #0 CFWL_Widget::GetOuter() ... <-- read of swept outer_->outer_ +// #1 CFWL_WidgetMgr::RepaintWidget(...) +// #2 CFWL_Widget::RepaintRect(...) +// #3 CFWL_Caret::OnTimerFired() +// #4 CFX_Timer::TimerProc(...) +// ----------------------------------------------------------------------------- +TEST_F(CFWLCaretTimerUAFTest, TimerFiresOnSweptOuter) { + cppgc::Persistent adapter = + cppgc::MakeGarbageCollected(heap()->GetAllocationHandle()); + adapter->heap_ = heap(); + adapter->handler_ = &timer_handler_; + + cppgc::Persistent app = cppgc::MakeGarbageCollected( + heap()->GetAllocationHandle(), adapter.Get()); + + cppgc::Persistent parent = + cppgc::MakeGarbageCollected(heap()->GetAllocationHandle(), + app.Get(), + CFWL_Widget::Properties(), + nullptr); + cppgc::Persistent child = + cppgc::MakeGarbageCollected(heap()->GetAllocationHandle(), + app.Get(), + CFWL_Widget::Properties(), + parent.Get()); + + child->ShowCaret(); // Arms timer; g_pwl_timer_map[id] -> child. + ASSERT_EQ(1, timer_handler_.set_calls_); + + // Drop the parent from the widget-manager map (RemoveWidget recursively + // erases the child too) so nothing but `child->outer_` references it — + // the same reachability shape as the post-PreFinalize incremental-sweep + // window. + app->GetWidgetMgr()->RemoveWidget(parent.Get()); + + // Finalize + poison the parent slot, exactly as InlinedFinalizationBuilder + // does for a swept page (header->Finalize(); SetMemoryInaccessible(...)). + CFWL_Caret* raw_parent = parent.Get(); + parent.Clear(); + cppgc::subtle::FreeUnreferencedObject(heap()->GetHeapHandle(), *raw_parent); + + // The child's CFX_Timer is still registered (its destructor has not run). + ASSERT_EQ(0, timer_handler_.kill_calls_); + + // Embedder timer tick. Under ASAN this is a use-after-poison reading + // parent->outer_ from CFWL_WidgetMgr::RepaintWidget(); under release it + // is a vptr/Member read from zeroed-then-reused cppgc memory. + timer_handler_.Fire(); + + // Cleanup so TearDown's atomic GC doesn't trip dangling UnownedPtr checks. + child->HideCaret(); +} + +} // namespace pdfium