High watch CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in PDF
DescriptionUse after free in PDF
ComponentPDF
Sub-componentPDF Ink Signatures
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker494644471
Fix commit96187fc8ea0e (chromium/src) +49/-15
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
CreditedSyn4pse
Reported2026-03-21 (per vendor advisory)
Fix landed2026-03-25 (public git)
Disclosed2026-03-31
Patch gap6 days — fix public in source before the advisory

Background

`PDFiumEngine`
Chromium’s PDF renderer wrapper over PDFium, running in the sandboxed PDF (PPAPI/plugin) process; it handles Ink signatures (strokes and shapes drawn onto pages).
`stroked_pages_unload_preventers_`
a map from page index to a PDFiumPage::ScopedUnloadPreventer that keeps a page’s PDFium objects loaded in memory as long as the engine still references them (strokes or shapes).
`ink_stroke_data_` vs. V2 Ink shapes
ink_stroke_data_ holds dynamically drawn strokes, while “V2 Ink paths/shapes” are static shapes parsed out of the PDF by LoadV2InkPathsForPage(); both can cause the engine to hold live page objects.
`pages_with_loaded_v2_ink_shapes_`
the set (repurposed from the former DCHECK-only pages_with_loaded_v2_ink_paths_) that now records which pages have loaded shapes.

Root Cause Analysis

LoadV2InkPathsForPage() installs a ScopedUnloadPreventer into stroked_pages_unload_preventers_ whenever a page has loaded shapes, so those page objects stay alive. However, DiscardStroke() decided whether to erase that unload-preventer entry by checking only for remaining strokes (std::ranges::any_of(ink_stroke_data_, ...)), ignoring loaded shapes. On a page that had shapes but whose last stroke was discarded (the classic apply-then-undo flow), page_still_has_strokes evaluated false, so stroked_pages_unload_preventers_.erase(page_index) removed the only thing keeping the page loaded — even though the engine was still holding shape objects tied to that page. The page could then be unloaded/freed while those objects remained referenced, yielding a use-after-free.

The fix adds pages_with_loaded_v2_ink_shapes_.contains(page_index) to the condition (now page_still_has_shapes_or_strokes) so the preventer is erased only when the page has neither strokes nor shapes, and repurposes the old debug-only set to track shape presence cheaply.

Key insight
The unload-preventer is installed for both strokes and shapes but was torn down based on strokes alone; the fix makes DiscardStroke() also consult pages_with_loaded_v2_ink_shapes_ so a page holding static Ink shapes is never prematurely allowed to unload.

Attack Path

  1. Load a PDF with V2 Ink shapes Open a crafted PDF whose page contains V2 Ink annotation shapes, so LoadV2InkPathsForPage() installs a ScopedUnloadPreventer for that page and records it.
  2. Apply then discard a stroke Draw a stroke on that page (ApplyStroke) and immediately discard it (DiscardStroke), the apply-then-undo sequence the regression test encodes.
  3. Premature unload-preventer removal With no strokes left, pre-patch DiscardStroke() erases stroked_pages_unload_preventers_[page_index], dropping protection even though shape objects for the page are still held.
  4. Use-after-free A subsequent page unload frees the page’s PDFium objects while the engine still references them; the next access to those objects is the UAF. The specific freed object and any read/write primitive are (inferred); the diff only shows the missing shape check.

Impact Assessment

An attacker who gets a user to open and lightly interact with (or script interaction on) a malicious PDF containing V2 Ink shapes can drive the PDF process to free page objects that are still in use, causing a use-after-free in the sandboxed PDF renderer. The vendor advisory rates this high severity and describes it as a use-after-free in PDF; it does not report in-the-wild exploitation. Any escalation to controlled memory reuse, information disclosure, or code execution is (inferred) and not demonstrated by the patch.

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

Audit Directions

  • Asymmetric install/teardown conditions
    Look for resources (unload preventers, ref-holders, pins) that are installed under condition A || B but released under only A; the forgotten branch leaves a dangling or prematurely-freed object.
  • Debug-only invariants doing real work
    Audit DCHECK_IS_ON()-gated sets/maps whose semantics change; relying on them (or their absence) in release builds, or repurposing them, can silently drop a safety check.
  • Static vs. dynamic lifetime tracking
    Review components that track two kinds of content (dynamic strokes, static parsed shapes) keeping the same object alive, and verify every content source is consulted before releasing the shared keep-alive.
From 96187fc8ea0e2bcf3a316c88a309398783c600f7 Mon Sep 17 00:00:00 2001
From: Lei Zhang <[email protected]>
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 <[email protected]>
Commit-Queue: Lei Zhang <[email protected]>
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 [email protected]

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