Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in PDF
DescriptionUse after free in PDF
ComponentPDF
Bug ClassUAF
Tracker494644471
Fix commit96187fc8ea0e (chromium/src) +49/-15
CISA KEVNot listed
CreditedSyn4pse
Disclosed2026-03-31

Changed Functions

FunctionChangeNotes
if
pdf/pdfium/pdfium_engine.cc
modified
LoadV2InkPathsForPage
pdf/pdfium/pdfium_engine.cc
modified
TEST_P
pdf/pdfium/pdfium_engine_unittest.cc
modified

Files Changed

  • pdf/pdfium/pdfium_engine.cc
  • pdf/pdfium/pdfium_engine.h
  • pdf/pdfium/pdfium_engine_unittest.cc
From 96187fc8ea0e2bcf3a316c88a309398783c600f7 Mon Sep 17 00:00:00 2001
From: Lei Zhang <thestig@chromium.org>
Date: Wed, 25 Mar 2026 11:15:27 -0700
Subject: [PATCH] [PDF Ink Signatures] Fix page unloading prevention for shapes

In PDFiumEngine, `stroked_pages_unload_preventers_` prevents page
unloading if a page has strokes or shapes. However, DiscardStroke() only
checks for strokes before removing `stroked_pages_unload_preventers_`
entries. Add in the missing check for shapes.

To help DiscardStroke() determine which pages have shapes, repurpose the
existing DCHECK-only `pages_with_loaded_v2_ink_paths_` set. Change its
semantics so LoadV2InkPathsForPage() only adds to the set when a page
has shapes. The sanity check that `pages_with_loaded_v2_ink_paths_` was
performing was not very useful anyway.

Bug: 494644471
Change-Id: I6a29720cd5464d1e7cb04fb0ed51e66d4435c32b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7699593
Reviewed-by: Andy Phan <andyphan@chromium.org>
Commit-Queue: Lei Zhang <thestig@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1604965}
---

diff --git a/pdf/pdfium/pdfium_engine.cc b/pdf/pdfium/pdfium_engine.cc
index e993056..1e69739e 100644
--- a/pdf/pdfium/pdfium_engine.cc
+++ b/pdf/pdfium/pdfium_engine.cc
@@ -20,7 +20,6 @@
 #include "base/compiler_specific.h"
 #include "base/containers/flat_map.h"
 #include "base/containers/span.h"
-#include "base/dcheck_is_on.h"
 #include "base/feature_list.h"
 #include "base/functional/bind.h"
 #include "base/i18n/rtl.h"
@@ -5089,11 +5088,12 @@
   }
   ink_stroke_data_.erase(it);
 
-  bool page_still_has_strokes =
+  bool page_still_has_shapes_or_strokes =
+      pages_with_loaded_v2_ink_shapes_.contains(page_index) ||
       std::ranges::any_of(ink_stroke_data_, [page_index](const auto& it) {
         return it.second.page_index == page_index;
       });
-  if (!page_still_has_strokes) {
+  if (!page_still_has_shapes_or_strokes) {
     stroked_pages_unload_preventers_.erase(page_index);
   }
 }
@@ -5123,12 +5123,6 @@
 PDFiumEngine::LoadV2InkPathsForPage(int page_index) {
   CHECK(PageIndexInBounds(page_index));
 
-#if DCHECK_IS_ON()
-  const bool inserted =
-      pages_with_loaded_v2_ink_paths_.insert(page_index).second;
-  CHECK(inserted);
-#endif  // DCHECK_IS_ON()
-
   std::map<InkModeledShapeId, ink::PartitionedMesh> page_shape_map;
 
   PDFiumPage* page = pages_[page_index].get();
@@ -5148,9 +5142,12 @@
   // page unloads and reloads, then the loaded V2 Ink path will no longer match
   // the PDF object, and any updates to the Ink path will not be visible in the
   // PDF.
+  // Also remember the associated page has loaded shapes, so DiscardStroke()
+  // will know not to erase the `stroked_pages_unload_preventers_` entry.
   if (!page_shape_map.empty()) {
     stroked_pages_unload_preventers_.insert(
         {page_index, PDFiumPage::ScopedUnloadPreventer(page)});
+    pages_with_loaded_v2_ink_shapes_.insert(page_index);
   }
 
   return page_shape_map;
diff --git a/pdf/pdfium/pdfium_engine.h b/pdf/pdfium/pdfium_engine.h
index 0d1e554..d88ddfd 100644
--- a/pdf/pdfium/pdfium_engine.h
+++ b/pdf/pdfium/pdfium_engine.h
@@ -18,7 +18,6 @@
 
 #include "base/containers/flat_map.h"
 #include "base/containers/span.h"
-#include "base/dcheck_is_on.h"
 #include "base/functional/callback.h"
 #include "base/memory/raw_ptr.h"
 #include "base/memory/weak_ptr.h"
@@ -1392,11 +1391,11 @@
   // stroke changes.
   std::set<int> ink_stroked_pages_needing_regeneration_;
 
-#if DCHECK_IS_ON()
-  // Used to keep track of LoadV2InkPathsForPage() calls as a sanity check.
-  // Stores the 0-based page indices for pages that have been loaded.
-  std::set<int> pages_with_loaded_v2_ink_paths_;
-#endif  // DCHECK_IS_ON()
+  // Stores the 0-based page indices for pages that have loaded shapes.
+  // Unlike `ink_stroke_data_`, which is dynamic, the loaded shapes data is
+  // static. So just store this data separately from `ink_modeled_shape_map_` to
+  // make searches faster.
+  std::set<int> pages_with_loaded_v2_ink_shapes_;
 
   // Used to hand out unique IDs of type InkModeledShapeId for the V2 Ink paths
   // read out of the PDF. It is stored here as the raw type to simplify
diff --git a/pdf/pdfium/pdfium_engine_unittest.cc b/pdf/pdfium/pdfium_engine_unittest.cc
index 625a072d..71e97c6 100644
--- a/pdf/pdfium/pdfium_engine_unittest.cc
+++ b/pdf/pdfium/pdfium_engine_unittest.cc
@@ -2850,6 +2850,44 @@
             1);
 }
 
+TEST_P(PDFiumEngineInkDrawTest, LoadedV2InkPathsAndApplyAndDiscardStroke) {
+  TestClient client(/*use_skia_renderer=*/GetParam());
+  std::unique_ptr<PDFiumEngine> engine =
+      InitializeEngine(&client, FILE_PATH_LITERAL("ink_v2.pdf"));
+  ASSERT_TRUE(engine);
+  ASSERT_EQ(1, engine->GetNumberOfPages());
+
+  // Check the initial loaded PDF.
+  constexpr int kPageIndex = 0;
+  std::map<InkModeledShapeId, ink::PartitionedMesh> ink_shapes =
+      engine->LoadV2InkPathsForPage(kPageIndex);
+  ASSERT_EQ(1u, ink_shapes.size());
+  ASSERT_EQ(GetPdfMarkObjCountForTesting(engine->doc(),
+                                         kInkAnnotationIdentifierKeyV2),
+            1);
+  ASSERT_TRUE(engine->stroked_pages_unload_preventers_for_testing().contains(
+      kPageIndex));
+
+  // Draw a stroke and immediately discard the stroke to undo.
+  auto brush = std::make_unique<PdfInkBrush>(PdfInkBrush::Type::kPen,
+                                             SK_ColorRED, /*size=*/4.0f);
+  constexpr auto kInputs0 = std::to_array<PdfInkInputData>({
+      {{5.0f, 5.0f}, base::Seconds(0.0f)},
+      {{50.0f, 5.0f}, base::Seconds(0.1f)},
+  });
+  std::optional<ink::StrokeInputBatch> batch = CreateInkInputBatch(kInputs0);
+  ASSERT_TRUE(batch.has_value());
+  ink::Stroke stroke0(brush->ink_brush(), batch.value());
+  constexpr InkStrokeId kStrokeId(1);
+  engine->ApplyStroke(kPageIndex, kStrokeId, stroke0);
+  engine->DiscardStroke(kPageIndex, kStrokeId);
+
+  // The page at `kPageIndex` should still not be allowed to unload, since
+  // `engine` is holding onto page objects within that page.
+  EXPECT_TRUE(engine->stroked_pages_unload_preventers_for_testing().contains(
+      kPageIndex));
+}
+
 TEST_P(PDFiumEngineInkDrawTest, ThumbnailsDoNotContainStrokes) {
   TestClient client(/*use_skia_renderer=*/GetParam());
   std::unique_ptr<PDFiumEngine> engine =
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 625a072d..71e97c6 100644
--- a/pdf/pdfium/pdfium_engine_unittest.cc
+++ b/pdf/pdfium/pdfium_engine_unittest.cc
@@ -2850,6 +2850,44 @@
             1);
 }
 
+TEST_P(PDFiumEngineInkDrawTest, LoadedV2InkPathsAndApplyAndDiscardStroke) {
+  TestClient client(/*use_skia_renderer=*/GetParam());
+  std::unique_ptr<PDFiumEngine> engine =
+      InitializeEngine(&client, FILE_PATH_LITERAL("ink_v2.pdf"));
+  ASSERT_TRUE(engine);
+  ASSERT_EQ(1, engine->GetNumberOfPages());
+
+  // Check the initial loaded PDF.
+  constexpr int kPageIndex = 0;
+  std::map<InkModeledShapeId, ink::PartitionedMesh> ink_shapes =
+      engine->LoadV2InkPathsForPage(kPageIndex);
+  ASSERT_EQ(1u, ink_shapes.size());
+  ASSERT_EQ(GetPdfMarkObjCountForTesting(engine->doc(),
+                                         kInkAnnotationIdentifierKeyV2),
+            1);
+  ASSERT_TRUE(engine->stroked_pages_unload_preventers_for_testing().contains(
+      kPageIndex));
+
+  // Draw a stroke and immediately discard the stroke to undo.
+  auto brush = std::make_unique<PdfInkBrush>(PdfInkBrush::Type::kPen,
+                                             SK_ColorRED, /*size=*/4.0f);
+  constexpr auto kInputs0 = std::to_array<PdfInkInputData>({
+      {{5.0f, 5.0f}, base::Seconds(0.0f)},
+      {{50.0f, 5.0f}, base::Seconds(0.1f)},
+  });
+  std::optional<ink::StrokeInputBatch> batch = CreateInkInputBatch(kInputs0);
+  ASSERT_TRUE(batch.has_value());
+  ink::Stroke stroke0(brush->ink_brush(), batch.value());
+  constexpr InkStrokeId kStrokeId(1);
+  engine->ApplyStroke(kPageIndex, kStrokeId, stroke0);
+  engine->DiscardStroke(kPageIndex, kStrokeId);
+
+  // The page at `kPageIndex` should still not be allowed to unload, since
+  // `engine` is holding onto page objects within that page.
+  EXPECT_TRUE(engine->stroked_pages_unload_preventers_for_testing().contains(
+      kPageIndex));
+}
+
 TEST_P(PDFiumEngineInkDrawTest, ThumbnailsDoNotContainStrokes) {
   TestClient client(/*use_skia_renderer=*/GetParam());
   std::unique_ptr<PDFiumEngine> engine =
Loading diff…

Original Bug Report

reported by he...@gmail.com

UAF in UpdateShapeActive of PDF Ink V2

Summary

DiscardStroke() erases stroked_pages_unload_preventers_[page_index] when no ink_stroke_data_ entries remain for that page, without checking whether ink_modeled_shape_map_ still holds FPDF_PAGEOBJECT handles cached by LoadV2InkPathsForPage(). Once unpinned, the page can unload and free the underlying page-object storage. A subsequent Undo calls UpdateShapeActive(), which passes the now-dangling handle to FPDFPageObj_SetIsActive(), resulting in a renderer UAF.

Details

LoadV2InkPathsForPage() caches raw FPDF_PAGEOBJECT handles in ink_modeled_shape_map_ and pins the page via stroked_pages_unload_preventers_ so those handles stay valid:

PDFiumEngine::LoadV2InkPathsForPage(int page_index) {
...
  for (auto& read_result : read_results) {
    InkModeledShapeId id(next_ink_modeled_shape_id_++);
    page_shape_map[id] = std::move(read_result.shape);
    ink_modeled_shape_map_[id] = read_result.page_object; // cache raw handle
  }

...
  CHECK(!stroked_pages_unload_preventers_.contains(page_index));

...
  if (!page_shape_map.empty()) {
    stroked_pages_unload_preventers_.insert(
        {page_index, PDFiumPage::ScopedUnloadPreventer(page)}); // pin page
  }
}

DiscardStroke() removes the unload preventer by checking only ink_stroke_data_, never ink_modeled_shape_map_:

void PDFiumEngine::DiscardStroke(int page_index, InkStrokeId id) {
  CHECK(PageIndexInBounds(page_index));
  auto it = ink_stroke_data_.find(id);
  CHECK(it != ink_stroke_data_.end());
  for (FPDF_PAGEOBJECT page_object : it->second.page_objects) {
    bool result =
        FPDFPage_RemoveObject(pages_[page_index]->GetPage(), page_object);
    CHECK(result);

    // FPDFPage_RemoveObject() transferred ownership of `page_object` to the
    // caller. Free it since `page_object` is being discarded.
    FPDFPageObj_Destroy(page_object);
  }
  ink_stroke_data_.erase(it);

  bool page_still_has_strokes =
      std::ranges::any_of(ink_stroke_data_, [page_index](const auto& it) {
        return it.second.page_index == page_index;
      });
  if (!page_still_has_strokes) {
    stroked_pages_unload_preventers_.erase(page_index);  // <-- bug: ignores loaded V2 shapes
  }
}

ApplyUndoRedoDiscards() calls DiscardStroke for every stroke in the discarded redo tail. When the discarded stroke is the last user-added stroke on a page that still has preloaded V2 ink, the unload preventer is removed while ink_modeled_shape_map_ still holds cached handles.

Once unpinned, scrolling the page off-screen frees the underlying page-object storage. A subsequent Undo walks loaded_v2_shapes_ into UpdateShapeActive(), which forwards the now-dangling handle to FPDFPageObj_SetIsActive(), leading to the UAF:

void PDFiumEngine::UpdateShapeActive(int page_index,
                                     InkModeledShapeId id,
                                     bool active) {
  auto it = ink_modeled_shape_map_.find(id);
  bool result = FPDFPageObj_SetIsActive(it->second, active); // stale handle
}

Bisection

This issue is introduced by the commit https://chromium-review.googlesource.com/c/chromium/src/+/5997152, which introduce the vulnerable LoadV2InkPathsForPage function.

Reproduction

Download chrome from https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1602931.zip

Put the background.js, manifest.json, test.pdf under a directory as an extension.

Run with

./chrome --load-extension=/path/to/ext --no-sandbox --window-size=900,420 about:blank

You would observe the UAF shown in asan.txt

NOTE that we need the --window-size=900,420, since we hardcode the scroll down length in the extension. If we run against other window size, it needs the adjustment of the POC extension accordingly.

> This is also reproducible manually: open the bundled test.pdf in Chrome, enter draw mode (which loads the V2 ink paths), draw a stroke then Ctrl+Z, switch to eraser and make any erase gesture, scroll page 1 out of view, then Ctrl+Z. The extension automates the above sequence. Hence you just need to put three attached POC files under an extension directory.

Suggested Fix

chrome_pdf::PDFiumEngine::DiscardStroke() should only erase stroked_pages_unload_preventers_[page_index] when the page has neither remaining ink_stroke_data_ entries nor any loaded V2 shapes still represented in ink_modeled_shape_map_ / loaded_v2_shapes_.

We should also reject tale cached shape handles before chrome_pdf::PDFiumEngine::UpdateShapeActive() calls FPDFPageObj_SetIsActive(), so page unload cannot leave a dangling FPDF_PAGEOBJECT behind.

View on issue tracker