CVE-2026-79119
Overview
Background
- `PDFiumEngine`
- Chrome’s PDF plugin engine that owns the
pages_vector ofPDFiumPageobjects and drives rendering, input handling, and text/annotation scanning over a PDFium document. - `PDFiumPage::Unload()` / page deletion
- Releases a page’s underlying PDFium page; a page may be re-loaded lazily, but destroying the
PDFiumPageobject while a raw pointer to it is live on the stack causes a use-after-free. - `defer_page_unload_` / `deferred_page_unloads_`
- A flag and index list that postpone page unloading during callbacks from PDFium (where the current page can change while PDFium still holds a pointer), unloaded later in a batch.
- `ScopedPageUnloadPreventer` / `base::ScopedClosureRunner`
- RAII helpers: the former marks a page as in-use so it must not be unloaded; the latter runs a closure on scope exit, here used to restore
defer_page_unload_and run cleanup deterministically.
Root Cause Analysis
During operations that hold raw PDFiumPage* pointers on the stack — input-event handling (HandleInputEvent), most-visible-page calculation (GetMostVisiblePage), and annotation scanning (LoadTextAnnotationsFromPdf) — JavaScript or XFA form logic can mutate the document and shrink the page count, causing LoadPageSizes to run its “remove pages that no longer exist” loop. The pre-patch LoadPageSizes called pages_[i]->Unload() and then pages_.resize(new_page_count), which destroyed the PDFiumPage objects immediately even when an outer frame (an active unload preventer, or defer_page_unload_ set during input handling) still referenced them, and it left index-based state (last_focused_page_, most_visible_page_, deferred_page_unloads_, selection_, find results) pointing at now-invalid indices.
The fix defers destruction: when defer_page_unload_ is set or Unload() reports the page cannot yet be freed, the PDFiumPage is moved into deferred_page_deletions_ instead of being destroyed, and it is later destroyed by CleanUpDeferredPages (invoked synchronously when the CreateScopedDeferredPageUnload runner exits and via a posted task as a fallback). It also invalidates the now-stale index state after resizing (last_focused_page_/most_visible_page_ reset to -1, deferred_page_unloads_.clear(), selection_.clear(), saved_selection_.clear(), RemoveTextFragments(), ClearFindResults()), so nothing dereferences a freed page or an out-of-bounds index. The old HandleInputEvent manual defer_page_unload_ = true/false plus batch-unload block is replaced by the RAII CreateScopedDeferredPageUnload() so the deferral is always balanced and cleanup always runs.
PDFiumPage objects synchronously inside LoadPageSizes while outer stack frames still held raw pointers to them (and leaving index state dangling); the fix moves such pages into deferred_page_deletions_ and defers their destruction until all preventers/deferrals clear, while resetting the stale indices.Attack Path
- Open a malicious PDF with active script
The victim opens an attacker-crafted PDF (e.g. an XFA form) whose JavaScript/form logic can delete pages during callbacks, and interacts with it (e.g. types into a field), entering
HandleInputEvent. - Enter a page-holding operation
Handling the input event (or scanning annotations / computing the most-visible page) proceeds with a raw
PDFiumPage*held on the stack while calling into PDFium, e.g.FORM_OnChar. - Delete pages from a callback
From within that callback the document mutates and the page count shrinks (the test simulates
FPDFPage_Delete+InvalidateAllPages), causingLoadPageSizesto run its removal loop. - Immediate destruction of an in-use page
Pre-patch,
LoadPageSizesunloads and resizes away thePDFiumPagestill referenced by the outer frame, freeing it while the stack pointer remains live. - Use-after-free on return
When control returns to the outer operation and dereferences the freed
PDFiumPage(or uses a stalelast_focused_page_/selection_index), a use-after-free occurs in the PDF process. (inferred)
Impact Assessment
PDFiumPage in Chrome’s PDF/renderer process by deleting pages while the engine holds live pointers during input or scanning operations. The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.8. The patch and its regression tests demonstrate the crash/deferral requirement, but any escalation to controlled read/write or code execution is not shown by the diff. (inferred) The precondition is opening/interacting with attacker-controlled PDF content that can mutate page count during a callback.Changed Functions
| Function | Change | Notes |
|---|---|---|
forpdf/pdfium/pdfium_engine.cc |
modified | |
ifpdf/pdfium/pdfium_engine.cc |
modified | |
PDFiumEnginePageMutationTestpdf/pdfium/pdfium_engine_unittest.cc |
modified | |
TEST_Ppdf/pdfium/pdfium_engine_unittest.cc |
modified | |
ifpdf/pdfium/pdfium_engine_unittest.cc |
modified | |
PDFiumEngineTabbingTestpdf/pdfium/pdfium_engine_unittest.cc |
modified |
Files Changed
pdf/pdfium/pdfium_engine.hpdf/pdfium/pdfium_engine_unittest.ccpdf/pdfium/pdfium_engine.cc
Audit Directions
- Raw page/object pointers held across re-entrant callbacksAudit PDFium engine paths that keep a
PDFiumPage*(or index intopages_) on the stack while calling into PDFium/JS/XFA, which can mutatepages_; ensure each is guarded by a deferral/preventer. - Container mutation invalidating cached indicesSearch for state that stores page/selection indices (
last_focused_page_,most_visible_page_,selection_, find results) and confirm every path that resizespages_resets or bounds-checks those indices. - Synchronous destruction under RAII lifetimeReview other resize/removal sites that destroy objects immediately and verify they honor active
ScopedPageUnloadPreventer/defer_page_unload_state by deferring destruction rather than freeing in-use objects.
Patch
From 0b4fb3cf2e4263c1778eea6f0bf803d7353e3b1a Mon Sep 17 00:00:00 2001 From: Andy Phan <[email protected]> Date: Tue, 07 Jul 2026 14:01:37 -0700 Subject: [PATCH] [PDF] Defer page deletion and invalidate selections to prevent UAFs During event handling (e.g., input events in XFA PDFs) or page-scanning operations (e.g., `PDFiumEngine::LoadTextAnnotationsFromPdf()`), JavaScript or document updates can modify the document structure and delete pages. If these pages are destroyed immediately while still referenced in the call stack, it can lead to UAF crashes. To prevent this: - Introduce `CreateScopedDeferredPageUnload()` returning a `base::ScopedClosureRunner` to manage the `defer_page_unload_` state and trigger `CleanUpDeferredPages()` deterministically on scope exit. - Consolidate existing deferred page unload logic into the callback in `CreateScopedDeferredPageUnload()`. - Defer the destruction of deleted `PDFiumPage` objects by storing them in `deferred_page_deletions_` if they have active unload preventers. - Apply the scoped deferred page unload in `LoadTextAnnotationsFromPdf()` to prevent temporary memory bloat by cleaning up after each iteration. - Invalidate some index-based state if the page count changes. Bug: 513688690 Change-Id: Ib8a5c69fdbf6086e41e9ac0680e6987a208ccfda Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7869473 Reviewed-by: Lei Zhang <[email protected]> Commit-Queue: Andy Phan <[email protected]> Cr-Commit-Position: refs/heads/main@{#1658273} --- diff --git a/pdf/pdfium/pdfium_engine.cc b/pdf/pdfium/pdfium_engine.cc index ee2cd319..fd008596 100644 --- a/pdf/pdfium/pdfium_engine.cc +++ b/pdf/pdfium/pdfium_engine.cc @@ -1482,8 +1482,7 @@ } bool PDFiumEngine::HandleInputEvent(const blink::WebInputEvent& event) { - DCHECK(!defer_page_unload_); - defer_page_unload_ = true; + base::ScopedClosureRunner unload_preventer = CreateScopedDeferredPageUnload(); bool rv = false; switch (event.GetType()) { case blink::WebInputEvent::Type::kMouseDown: @@ -1532,18 +1531,6 @@ break; } - DCHECK(defer_page_unload_); - defer_page_unload_ = false; - - // Store the pages to unload away because the act of unloading pages can cause - // there to be more pages to unload. We leave those extra pages to be unloaded - // on the next go around. - std::vector<int> pages_to_unload; - std::swap(pages_to_unload, deferred_page_unloads_); - for (int page_index : pages_to_unload) { - pages_[page_index]->Unload(); - } - return rv; } @@ -2623,6 +2610,20 @@ return &find_results_[current_find_index_.value()]; } +void PDFiumEngine::ClearFindResults() { + FindResultChangeInvalidator find_change_invalidator(this); + + find_results_.clear(); + next_page_to_search_ = -1; + last_page_to_search_ = -1; + last_char_index_to_search_ = -1; + current_find_index_.reset(); + current_find_text_.clear(); + + UpdateTickMarks(); + find_weak_factory_.InvalidateWeakPtrs(); +} + bool PDFiumEngine::SelectFindResult(bool forward) { if (find_results_.empty()) { return false; @@ -2669,17 +2670,7 @@ } void PDFiumEngine::StopFind() { - FindResultChangeInvalidator find_change_invalidator(this); - - find_results_.clear(); - next_page_to_search_ = -1; - last_page_to_search_ = -1; - last_char_index_to_search_ = -1; - current_find_index_.reset(); - current_find_text_.clear(); - - UpdateTickMarks(); - find_weak_factory_.InvalidateWeakPtrs(); + ClearFindResults(); } std::vector<gfx::Rect> PDFiumEngine::GetAllScreenRectsUnion( @@ -3138,11 +3129,11 @@ return *in_flight_visible_page_; } - // We can call GetMostVisiblePage through a callback from PDFium. We have - // to defer the page deletion otherwise we could potentially delete the page - // that originated the calling JS request and destroy the objects that are + // GetMostVisiblePage() can be called through a callback from PDFium. Defer + // the page deletion, otherwise the page that originated the calling JS + // request could potentially be deleted and destroy the objects that are // currently being used. - base::AutoReset<bool> defer_page_unload_guard(&defer_page_unload_, true); + base::ScopedClosureRunner unload_preventer = CreateScopedDeferredPageUnload(); CalculateVisiblePages(); return most_visible_page_; } @@ -3467,16 +3458,81 @@ // Remove pages that do not exist anymore. if (pages_.size() > new_page_count) { + const size_t deferred_count_before = deferred_page_deletions_.size(); for (size_t i = new_page_count; i < pages_.size(); ++i) { - pages_[i]->Unload(); + if (defer_page_unload_ || !pages_[i]->Unload()) { + deferred_page_deletions_.push_back(std::move(pages_[i])); + } } pages_.resize(new_page_count); + + // Reset index-based state that is now out of bounds. + if (last_focused_page_ >= static_cast<int>(new_page_count)) { + last_focused_page_ = -1; + } + if (most_visible_page_ >= static_cast<int>(new_page_count)) { + most_visible_page_ = -1; + } + // Clear deferred unloads to prevent stale, out-of-bounds indices. This is + // guaranteed to be repopulated by the subsequent CalculateVisiblePages() + // call. + deferred_page_unloads_.clear(); + + // Clear selections and highlights that might now point to invalid page + // indices. + selection_.clear(); + saved_selection_.clear(); + RemoveTextFragments(); + ClearFindResults(); + + if (deferred_page_deletions_.size() > deferred_count_before) { + // Post a task to clean up the deferred page deletions. This ensures that + // even if no `CreateScopedDeferredPageUnload()` scope is active to + // trigger synchronous cleanup, or if the page was deferred due to + // temporary stack-allocated preventers that outlive the active scope, + // destruction of the pages will be attempted once the stack unwinds. + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::BindOnce(&PDFiumEngine::CleanUpDeferredPages, + weak_factory_.GetWeakPtr())); + } } return page_sizes; } +void PDFiumEngine::CleanUpDeferredPages() { + if (defer_page_unload_) { + return; + } + + // Unload pages that were deferred from unloading. + std::vector<int> pages_to_unload; + std::swap(pages_to_unload, deferred_page_unloads_); + for (int page_index : pages_to_unload) { + if (page_index < static_cast<int>(pages_.size())) { + pages_[page_index]->Unload(); + } + } + + // Destroy pages that were deleted. + std::erase_if(deferred_page_deletions_, + [](const auto& page) { return page->Unload(); }); +} + +base::ScopedClosureRunner PDFiumEngine::CreateScopedDeferredPageUnload() { + bool prev_defer_page_unload = defer_page_unload_; + defer_page_unload_ = true; + return base::ScopedClosureRunner(base::BindOnce( + [](base::WeakPtr<PDFiumEngine> engine, bool prev_defer_page_unload) { + if (engine) { + engine->defer_page_unload_ = prev_defer_page_unload; + engine->CleanUpDeferredPages(); + } + }, + weak_factory_.GetWeakPtr(), prev_defer_page_unload)); +} + void PDFiumEngine::LoadBody() { DCHECK(doc()); if (doc_loader_->IsDocumentComplete()) { @@ -5583,6 +5639,8 @@ DocumentInkTextBoxesMap PDFiumEngine::LoadTextAnnotationsFromPdf() { DocumentInkTextBoxesMap document_textboxes; for (size_t i = 0; i < pages_.size(); ++i) { + base::ScopedClosureRunner unload_preventer = + CreateScopedDeferredPageUnload(); PDFiumPage* page = pages_[i].get(); std::vector<ReadInkTextResult> page_results = ReadInkTextAnnotationsFromPage(page->GetPage()); diff --git a/pdf/pdfium/pdfium_engine.h b/pdf/pdfium/pdfium_engine.h index b9d342e0..bbad2d9 100644 --- a/pdf/pdfium/pdfium_engine.h +++ b/pdf/pdfium/pdfium_engine.h @@ -19,6 +19,7 @@ #include "base/containers/flat_map.h" #include "base/containers/span.h" #include "base/functional/callback.h" +#include "base/functional/callback_helpers.h" #include "base/memory/raw_ptr.h" #include "base/memory/weak_ptr.h" #include "base/time/time.h" @@ -769,6 +770,7 @@ friend class FormFillerTest; friend class PDFiumDrawSelectionTestBase; + friend class PDFiumEnginePageMutationTest; friend class PDFiumEngineTabbingTest; friend class PDFiumEngineTest; friend class PDFiumFormFiller; @@ -830,6 +832,15 @@ std::vector<gfx::Size> LoadPageSizes( const DocumentLayout::Options& layout_options); + // Cleans up active pages that were deferred from unloading. This is a + // best-effort cleanup; pages with active unload preventers will remain + // deferred. + void CleanUpDeferredPages(); + + // Defers page unloading and triggers CleanUpDeferredPages() when the returned + // runner goes out of scope. + base::ScopedClosureRunner CreateScopedDeferredPageUnload(); + void LoadBody(); void LoadPages(); @@ -890,6 +901,9 @@ // no find selection. const PDFiumRange* GetFindSelection() const; + // Clears find results and resets the search state variables. + void ClearFindResults(); + // Search a page ourself using ICU. void SearchUsingICU(const std::u16string& term, bool case_sensitive, @@ -1249,12 +1263,20 @@ // The indexes of the pages pending download. std::vector<uint32_t> pending_pages_; - // During handling of input events we don't want to unload any pages in - // callbacks to us from PDFium, since the current page can change while PDFium - // code still has a pointer to it. + // Set to true to prevent unloading of pages during operations where the stack + // may hold raw pointers to them (e.g. during input event handling or text + // annotation loading). Managed via `CreateScopedDeferredPageUnload()`. bool defer_page_unload_ = false; + + // Page indices that are deferred from unloading. std::vector<int> deferred_page_unloads_; + // If `defer_page_unload_` is true, or if there is an active page unload + // preventer, pages deleted in `LoadPageSizes()` cannot be destroyed + // immediately. They are moved here to defer their destruction until the + // deferrals/preventers are cleared. + std::vector<std::unique_ptr<PDFiumPage>> deferred_page_deletions_; + // Used for text selection, but does not include text within form text areas. // There could be more than one range if selection spans more than one page. std::vector<PDFiumRange> selection_; diff --git a/pdf/pdfium/pdfium_engine_unittest.cc b/pdf/pdfium/pdfium_engine_unittest.cc index 4fe9232..4dd228e 100644 --- a/pdf/pdfium/pdfium_engine_unittest.cc +++ b/pdf/pdfium/pdfium_engine_unittest.cc @@ -83,6 +83,7 @@ #include "pdf/test/pdf_ink_test_helpers.h" #include "third_party/ink/src/ink/strokes/input/stroke_input_batch.h" #include "third_party/ink/src/ink/strokes/stroke.h" +#include "third_party/pdfium/public/fpdf_edit.h" #endif namespace chrome_pdf { @@ -1812,6 +1813,101 @@ INSTANTIATE_TEST_SUITE_P(All, PDFiumEngineDeathTest, testing::Bool()); +class PDFiumEnginePageMutationTest : public PDFiumEngineTest { + protected: + FPDF_FORMHANDLE GetFormHandle(PDFiumEngine* engine) { return engine->form(); } + + FPDF_DOCUMENT GetDoc(PDFiumEngine* engine) { return engine->doc(); } + + void InvalidateAllPages(PDFiumEngine* engine) { + engine->InvalidateAllPages(); + } + + void SetLastFocusedPage(PDFiumEngine* engine, int page_index) { + engine->last_focused_page_ = page_index; + } +}; + +// Simulates an XFA page deletion when handling a char event. +TEST_P(PDFiumEnginePageMutationTest, PageCountShrinkOnHandleInputEvent) { + NiceMock<MockTestClient> client(/*use_skia_renderer=*/GetParam()); + std::unique_ptr<PDFiumEngine> engine = InitializeEngine( + &client, FILE_PATH_LITERAL("annotation_form_fields.pdf")); + ASSERT_TRUE(engine); + ASSERT_EQ(2, engine->GetNumberOfPages()); + ASSERT_TRUE(GetFormHandle(engine.get())); + + bool test_triggered = false; + bool in_on_char = false; + + // Invalidate() is called during form changes. + EXPECT_CALL(client, Invalidate(_)).WillRepeatedly([&](const gfx::Rect& rect) { + if (in_on_char && !test_triggered) { + test_triggered = true; + FPDFPage_Delete(GetDoc(engine.get()), 1); + InvalidateAllPages(engine.get()); + } + }); + + engine->PluginSizeUpdated({1024, 4096}); + + // Put focus on an annotation on page 2. + { + constexpr int kPageIndex = 1; + constexpr int kAnnotIndex = 0; + PDFiumPage& page = GetPDFiumPage(*engine, kPageIndex); + ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.GetPage(), kAnnotIndex)); + ASSERT_TRUE(annot); + engine->UpdateFocus(/*has_focus=*/true); + ASSERT_TRUE(FORM_SetFocusedAnnot(GetFormHandle(engine.get()), annot.get())); + SetLastFocusedPage(engine.get(), kPageIndex); + } + + // Trigger OnChar() on page 2. This executes FORM_OnChar(). + blink::WebKeyboardEvent char_event( + blink::WebInputEvent::Type::kChar, blink::WebInputEvent::kNoModifiers, + blink::WebInputEvent::GetStaticTimeStampForTests()); + char_event.text[0] = 'a'; + + // HandleInputEvent() should complete without crashing. + in_on_char = true; + engine->HandleInputEvent(char_event); + in_on_char = false; + + EXPECT_TRUE(test_triggered); +} + +TEST_P(PDFiumEnginePageMutationTest, DeferPageDestructionWithPreventer) { + NiceMock<MockTestClient> client(/*use_skia_renderer=*/GetParam()); + std::unique_ptr<PDFiumEngine> engine = InitializeEngine( + &client, FILE_PATH_LITERAL("annotation_form_fields.pdf")); + ASSERT_TRUE(engine); + ASSERT_EQ(2, engine->GetNumberOfPages()); + + { + // Get page 2. + constexpr int kPageIndex = 1; + PDFiumPage& page = GetPDFiumPage(*engine, kPageIndex); + PDFiumPage::ScopedPageUnloadPreventer preventer(&page); + + // Delete page 2 in the document. + FPDFPage_Delete(GetDoc(engine.get()), kPageIndex); + + // Trigger a layout update. Since the preventer is active, page 2's + // destruction must be deferred. + InvalidateAllPages(engine.get()); + + // Page 2 is removed from engine's active pages list. + EXPECT_EQ(1, engine->GetNumberOfPages()); + + // `preventer` is still holding a raw pointer to `page`. `page` should be + // kept alive, and its destruction should be deferred. This should complete + // without crashing. + } +} + +INSTANTIATE_TEST_SUITE_P(All, PDFiumEnginePageMutationTest, testing::Bool()); + class PDFiumEngineTabbingTest : public PDFiumTestBase { public: PDFiumEngineTabbingTest() = default;
Regression Test / PoC
diff --git a/pdf/pdfium/pdfium_engine_unittest.cc b/pdf/pdfium/pdfium_engine_unittest.cc
index 4fe9232..4dd228e 100644
--- a/pdf/pdfium/pdfium_engine_unittest.cc
+++ b/pdf/pdfium/pdfium_engine_unittest.cc
@@ -83,6 +83,7 @@
#include "pdf/test/pdf_ink_test_helpers.h"
#include "third_party/ink/src/ink/strokes/input/stroke_input_batch.h"
#include "third_party/ink/src/ink/strokes/stroke.h"
+#include "third_party/pdfium/public/fpdf_edit.h"
#endif
namespace chrome_pdf {
@@ -1812,6 +1813,101 @@
INSTANTIATE_TEST_SUITE_P(All, PDFiumEngineDeathTest, testing::Bool());
+class PDFiumEnginePageMutationTest : public PDFiumEngineTest {
+ protected:
+ FPDF_FORMHANDLE GetFormHandle(PDFiumEngine* engine) { return engine->form(); }
+
+ FPDF_DOCUMENT GetDoc(PDFiumEngine* engine) { return engine->doc(); }
+
+ void InvalidateAllPages(PDFiumEngine* engine) {
+ engine->InvalidateAllPages();
+ }
+
+ void SetLastFocusedPage(PDFiumEngine* engine, int page_index) {
+ engine->last_focused_page_ = page_index;
+ }
+};
+
+// Simulates an XFA page deletion when handling a char event.
+TEST_P(PDFiumEnginePageMutationTest, PageCountShrinkOnHandleInputEvent) {
+ NiceMock<MockTestClient> client(/*use_skia_renderer=*/GetParam());
+ std::unique_ptr<PDFiumEngine> engine = InitializeEngine(
+ &client, FILE_PATH_LITERAL("annotation_form_fields.pdf"));
+ ASSERT_TRUE(engine);
+ ASSERT_EQ(2, engine->GetNumberOfPages());
+ ASSERT_TRUE(GetFormHandle(engine.get()));
+
+ bool test_triggered = false;
+ bool in_on_char = false;
+
+ // Invalidate() is called during form changes.
+ EXPECT_CALL(client, Invalidate(_)).WillRepeatedly([&](const gfx::Rect& rect) {
+ if (in_on_char && !test_triggered) {
+ test_triggered = true;
+ FPDFPage_Delete(GetDoc(engine.get()), 1);
+ InvalidateAllPages(engine.get());
+ }
+ });
+
+ engine->PluginSizeUpdated({1024, 4096});
+
+ // Put focus on an annotation on page 2.
+ {
+ constexpr int kPageIndex = 1;
+ constexpr int kAnnotIndex = 0;
+ PDFiumPage& page = GetPDFiumPage(*engine, kPageIndex);
+ ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.GetPage(), kAnnotIndex));
+ ASSERT_TRUE(annot);
+ engine->UpdateFocus(/*has_focus=*/true);
+ ASSERT_TRUE(FORM_SetFocusedAnnot(GetFormHandle(engine.get()), annot.get()));
+ SetLastFocusedPage(engine.get(), kPageIndex);
+ }
+
+ // Trigger OnChar() on page 2. This executes FORM_OnChar().
+ blink::WebKeyboardEvent char_event(
+ blink::WebInputEvent::Type::kChar, blink::WebInputEvent::kNoModifiers,
+ blink::WebInputEvent::GetStaticTimeStampForTests());
+ char_event.text[0] = 'a';
+
+ // HandleInputEvent() should complete without crashing.
+ in_on_char = true;
+ engine->HandleInputEvent(char_event);
+ in_on_char = false;
+
+ EXPECT_TRUE(test_triggered);
+}
+
+TEST_P(PDFiumEnginePageMutationTest, DeferPageDestructionWithPreventer) {
+ NiceMock<MockTestClient> client(/*use_skia_renderer=*/GetParam());
+ std::unique_ptr<PDFiumEngine> engine = InitializeEngine(
+ &client, FILE_PATH_LITERAL("annotation_form_fields.pdf"));
+ ASSERT_TRUE(engine);
+ ASSERT_EQ(2, engine->GetNumberOfPages());
+
+ {
+ // Get page 2.
+ constexpr int kPageIndex = 1;
+ PDFiumPage& page = GetPDFiumPage(*engine, kPageIndex);
+ PDFiumPage::ScopedPageUnloadPreventer preventer(&page);
+
+ // Delete page 2 in the document.
+ FPDFPage_Delete(GetDoc(engine.get()), kPageIndex);
+
+ // Trigger a layout update. Since the preventer is active, page 2's
+ // destruction must be deferred.
+ InvalidateAllPages(engine.get());
+
+ // Page 2 is removed from engine's active pages list.
+ EXPECT_EQ(1, engine->GetNumberOfPages());
+
+ // `preventer` is still holding a raw pointer to `page`. `page` should be
+ // kept alive, and its destruction should be deferred. This should complete
+ // without crashing.
+ }
+}
+
+INSTANTIATE_TEST_SUITE_P(All, PDFiumEnginePageMutationTest, testing::Bool());
+
class PDFiumEngineTabbingTest : public PDFiumTestBase {
public:
PDFiumEngineTabbingTest() = default;