Low patch-now ✔︎ verified CVSS 8.8 chrome UAF 🔧 Commit mapped

Overview

Low
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in PDF
DescriptionUse after free in PDF
ComponentPDF
Sub-componentPDF
Bug ClassUAF
Action tierpatch-now — corruption primitive, file-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: none; diff defers PDFiumPage destruction via deferred_page_deletions_/CreateScopedDeferredPageUnload and invalidates stale index state, matching the claimed page-shrink-during-callback UAF.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker513688690
Fix commit0b4fb3cf2e42 (chromium/src) +209/-33
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-15 (per vendor advisory)
Fix landed2026-07-07 (public git)
Disclosed2026-08-25
Patch gap49 days — fix public in source before the advisory

Background

`PDFiumEngine`
Chrome’s PDF plugin engine that owns the pages_ vector of PDFiumPage objects 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 PDFiumPage object 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.

Key insight
The core mistake was destroying 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

  1. 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.
  2. 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.
  3. Delete pages from a callback From within that callback the document mutates and the page count shrinks (the test simulates FPDFPage_Delete + InvalidateAllPages), causing LoadPageSizes to run its removal loop.
  4. Immediate destruction of an in-use page Pre-patch, LoadPageSizes unloads and resizes away the PDFiumPage still referenced by the outer frame, freeing it while the stack pointer remains live.
  5. Use-after-free on return When control returns to the outer operation and dereferences the freed PDFiumPage (or uses a stale last_focused_page_/selection_ index), a use-after-free occurs in the PDF process. (inferred)

Impact Assessment

An attacker who convinces a user to open a crafted PDF can trigger a use-after-free of a 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

FunctionChangeNotes
for
pdf/pdfium/pdfium_engine.cc
modified
if
pdf/pdfium/pdfium_engine.cc
modified
PDFiumEnginePageMutationTest
pdf/pdfium/pdfium_engine_unittest.cc
modified
TEST_P
pdf/pdfium/pdfium_engine_unittest.cc
modified
if
pdf/pdfium/pdfium_engine_unittest.cc
modified
PDFiumEngineTabbingTest
pdf/pdfium/pdfium_engine_unittest.cc
modified

Files Changed

  • pdf/pdfium/pdfium_engine.h
  • pdf/pdfium/pdfium_engine_unittest.cc
  • pdf/pdfium/pdfium_engine.cc

Audit Directions

  • Raw page/object pointers held across re-entrant callbacks
    Audit PDFium engine paths that keep a PDFiumPage* (or index into pages_) on the stack while calling into PDFium/JS/XFA, which can mutate pages_; ensure each is guarded by a deferral/preventer.
  • Container mutation invalidating cached indices
    Search for state that stores page/selection indices (last_focused_page_, most_visible_page_, selection_, find results) and confirm every path that resizes pages_ resets or bounds-checks those indices.
  • Synchronous destruction under RAII lifetime
    Review 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.
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;
Loading diff…

Regression Test / PoC

shipped with the fix
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;
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.