Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in PDF
DescriptionUse after free in PDF
ComponentPDF
Bug ClassUAF
Tracker504516117
Fix commit3fe931553d7c (pdfium) +79/-29
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
if
core/fpdfapi/parser/cpdf_document.cpp
modified
TEST_F
core/fpdfapi/parser/cpdf_document_unittest.cpp
modified
for
core/fpdfapi/parser/cpdf_document_unittest.cpp
modified
CXFASimpleParserEmbedderTest
xfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp
modified
TEST_F
xfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp
modified

Files Changed

  • core/fpdfapi/parser/cpdf_document.cpp
  • core/fpdfapi/parser/cpdf_document_unittest.cpp
  • testing/resources/redirect.in
  • testing/resources/redirect.pdf
  • xfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp
From 3fe931553d7c602f4f89644d5973000cf9f01be3 Mon Sep 17 00:00:00 2001
From: Lei Zhang <thestig@chromium.org>
Date: Thu, 23 Apr 2026 09:56:15 -0700
Subject: [PATCH] Fix page/pages dictionary confusion in CPDF_Document::TraversePDFPages()

TraversePDFPages() checks for the lack of /Kids in a dictionary and
assumes that dictionary is a page dictionary. Instead of blindly making
this assumption, call GetNodeType() and reject the dictionary if it is a
pages dictionary.

With this check in place, update testing/resources/redirect.pdf to have
a valid page so the associate embedder test passes. For this test, the
primary purpose is to test PDF redirection, not PDF parsing.

For CXFASimpleParserEmbedderTest.Bug216, update the test expectation, as
the purpose of this test is to make sure the input does not cause a
crash, not to make sure whether the PDF loads successfully or not.

For DocumentTest.PagesWithoutKids, the existing test expectations
matched the existing incorrect TraversePDFPages() behavior. Update it to
match the new behavior and explain why that is correct.

Bug: 504516117
Change-Id: I76722d0d1aff21b167af85a0c72a226380bec17c
Reviewed-on: https://pdfium-review.googlesource.com/c/pdfium/+/146770
Reviewed-by: Andy Phan <andyphan@chromium.org>
Commit-Queue: Lei Zhang <thestig@chromium.org>
---

diff --git a/core/fpdfapi/parser/cpdf_document.cpp b/core/fpdfapi/parser/cpdf_document.cpp
index 01fa811..68d5fa8 100644
--- a/core/fpdfapi/parser/cpdf_document.cpp
+++ b/core/fpdfapi/parser/cpdf_document.cpp
@@ -269,6 +269,9 @@
     if (*nPagesToGo != 1) {
       return nullptr;
     }
+    if (GetNodeType(pPages) == NodeType::kBranch) {
+      return nullptr;
+    }
     page_list_[iPage] = pPages->GetObjNum();
     return pPages;
   }
diff --git a/core/fpdfapi/parser/cpdf_document_unittest.cpp b/core/fpdfapi/parser/cpdf_document_unittest.cpp
index 27a5a16..64d7fc1 100644
--- a/core/fpdfapi/parser/cpdf_document_unittest.cpp
+++ b/core/fpdfapi/parser/cpdf_document_unittest.cpp
@@ -299,11 +299,10 @@
 TEST_F(DocumentTest, PagesWithoutKids) {
   // Set up a document with Pages dict without kids, and Count = 3
   auto doc = std::make_unique<CPDF_TestDocPagesWithoutKids>();
-  EXPECT_TRUE(doc->GetPageDictionary(0));
-  // Test GetPage does not fetch pages out of range
-  for (int i = 1; i < 5; i++) {
+  // Since there are no Page nodes in the Page Tree, GetPageDictionary() will
+  // never return non-null.
+  for (int i = 0; i < 5; i++) {
     EXPECT_FALSE(doc->GetPageDictionary(i));
   }
-
-  EXPECT_TRUE(doc->GetPageDictionary(0));
+  EXPECT_FALSE(doc->GetPageDictionary(0));
 }
diff --git a/testing/resources/redirect.in b/testing/resources/redirect.in
new file mode 100644
index 0000000..efa5e76
--- /dev/null
+++ b/testing/resources/redirect.in
@@ -0,0 +1,29 @@
+{{header}}
+{{object 1 0}} <<
+  /Type /Catalog
+  /Pages 2 0 R
+  /OpenAction 4 0 R
+>>
+endobj
+{{object 2 0}} <<
+  /Type /Pages
+  /MediaBox [0 0 200 200]
+  /Count 1
+  /Kids [3 0 R]
+>>
+endobj
+{{object 3 0}} <<
+  /Type /Page
+  /Parent 2 0 R
+>>
+endobj
+{{object 4 0}} <<
+  /Type /Action
+  /S /URI
+  /URI (http://evilzone.org)
+>>
+endobj
+{{xref}}
+{{trailer}}
+{{startxref}}
+%%EOF
diff --git a/testing/resources/redirect.pdf b/testing/resources/redirect.pdf
index 517bd5a..cbc3f01 100644
--- a/testing/resources/redirect.pdf
+++ b/testing/resources/redirect.pdf
@@ -1,22 +1,40 @@
-%PDF-1.7
-trailer
-<<
-/Root 1 0 R
->>
-1 0 obj
-<<
-/Type /Catalog
-/Pages 2 0 R
-/OpenAction 2 0 R
->>
-endobj
-
-2 0 obj
-<<
-/Type /Action
-/S /URI
-/URI (http://evilzone.org) // URL HERE
->>
-endobj
-
-%%EOF
+%PDF-1.7
+%���
+1 0 obj <<
+  /Type /Catalog
+  /Pages 2 0 R
+  /OpenAction 4 0 R
+>>
+endobj
+2 0 obj <<
+  /Type /Pages
+  /MediaBox [0 0 200 200]
+  /Count 1
+  /Kids [3 0 R]
+>>
+endobj
+3 0 obj <<
+  /Type /Page
+  /Parent 2 0 R
+>>
+endobj
+4 0 obj <<
+  /Type /Action
+  /S /URI
+  /URI (http://evilzone.org)
+>>
+endobj
+xref
+0 5
+0000000000 65535 f 
+0000000015 00000 n 
+0000000088 00000 n 
+0000000177 00000 n 
+0000000228 00000 n 
+trailer <<
+  /Root 1 0 R
+  /Size 5
+>>
+startxref
+304
+%%EOF
diff --git a/xfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp b/xfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp
index becb519..b02a3d5 100644
--- a/xfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp
+++ b/xfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp
@@ -5,12 +5,13 @@
 #include "testing/embedder_test.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
-class CXFASimpleParserEmbedderTest : public EmbedderTest {};
+using CXFASimpleParserEmbedderTest = EmbedderTest;
 
+// Should not crash.
 TEST_F(CXFASimpleParserEmbedderTest, Bug216) {
   ASSERT_TRUE(OpenDocument("bug_216.pdf"));
   ScopedPage page = LoadScopedPage(0);
-  EXPECT_TRUE(page);
+  EXPECT_FALSE(page);
 }
 
 TEST_F(CXFASimpleParserEmbedderTest, Bug709793) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/core/fpdfapi/parser/cpdf_document_unittest.cpp b/core/fpdfapi/parser/cpdf_document_unittest.cpp
index 27a5a16..64d7fc1 100644
--- a/core/fpdfapi/parser/cpdf_document_unittest.cpp
+++ b/core/fpdfapi/parser/cpdf_document_unittest.cpp
@@ -299,11 +299,10 @@
 TEST_F(DocumentTest, PagesWithoutKids) {
   // Set up a document with Pages dict without kids, and Count = 3
   auto doc = std::make_unique<CPDF_TestDocPagesWithoutKids>();
-  EXPECT_TRUE(doc->GetPageDictionary(0));
-  // Test GetPage does not fetch pages out of range
-  for (int i = 1; i < 5; i++) {
+  // Since there are no Page nodes in the Page Tree, GetPageDictionary() will
+  // never return non-null.
+  for (int i = 0; i < 5; i++) {
     EXPECT_FALSE(doc->GetPageDictionary(i));
   }
-
-  EXPECT_TRUE(doc->GetPageDictionary(0));
+  EXPECT_FALSE(doc->GetPageDictionary(0));
 }
Loading diff…

Original Bug Report

reported by vm...@google.com

Use-After-Free write in PDFium Ink2 due to silent free in FPDFPage_InsertObject

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential Use-After-Free vulnerability exists in Chrome’s Ink2 annotation feature. FPDFPage_InsertObject silently frees a page object on validation failure, but the calling Chrome code assumes success and retains a dangling pointer that is subsequently used for memory writes during undo/redo or thumbnail generation.

Affected files:

  • pdf/pdfium/pdfium_ink_writer.cc
  • third_party/pdfium/fpdfsdk/fpdf_editpage.cpp
  • pdf/pdfium/pdfium_engine.cc
  • third_party/pdfium/core/fpdfapi/page/cpdf_pageobject.cpp

Estimated timestamp from git blame: 2025-07-29

Root Cause Analysis

The vulnerability is a potential Use-After-Free (UAF) write in the PDF plugin process. It originates from an inconsistent ownership model between PDFium and its Chrome integration for the Ink2 feature.

  1. Silent Free in FPDFPage_InsertObject: The PDFium function FPDFPage_InsertObject (in third_party/pdfium/fpdfsdk/fpdf_editpage.cpp) takes ownership of an FPDF_PAGEOBJECT by wrapping it in a std::unique_ptr. It then validates the destination page by calling IsPageObject. If the validation fails, the function returns early. This causes the unique_ptr to go out of scope and delete the CPDF_PageObject before it can be added to the page, effectively freeing it. Because the function has a void return type, it provides no error signal to the caller.

  2. Normalization Gap: IsPageObject checks if the page dictionary’s /Type key is a Name object with the exact value "Page". While PDFium’s CountPages attempts to normalize malformed /Type keys during document load, this normalization is skipped if the root /Pages dictionary contains a valid /Count entry. Consequently, a maliciously crafted PDF can contain a page that loads normally but permanently fails the IsPageObject check.

  3. Dangling Pointer Retention: The Chrome Ink2 writer (pdf/pdfium/pdfium_ink_writer.cc) calls FPDFPage_InsertObject via WriteStrokeToPage and explicitly releases its ownership of the object. It assumes the operation succeeds and continues to cache the now-dangling raw pointer in its internal ink_stroke_data_ map in PDFiumEngine.

  4. UAF Write Sink: When a user undoes a stroke, PDFiumEngine::UpdateStrokeActive iterates through these cached (dangling) pointers and calls FPDFPageObj_SetIsActive. This eventually leads to CPDF_PageObject::SetIsActive (in third_party/pdfium/core/fpdfapi/page/cpdf_pageobject.cpp), which performs a UAF write to the is_active_ and dirty_ boolean fields at fixed offsets within the freed object. A secondary sink exists via thumbnail generation, which temporarily toggles the active state.

Impact

This is a Use-After-Free write in the sandboxed PDF plugin process. Since the dangling handles are opaque C pointers (FPDF_PAGEOBJECT) and not base::raw_ptr<>, the vulnerability is not protected by MiraclePtr (BackupRefPtr). An attacker can control the number of freed objects and the timing of the subsequent write. Exploitation could lead to remote code execution within the renderer sandbox.

Potential Reproduction Steps

Note: These are suggested steps to trigger the issue, our tooling agent has not executed this code.

  1. Craft a PDF where the root /Pages dictionary has a valid /Count but a child page dictionary has a malformed /Type entry (e.g., /Type (Page) as a literal string or an invalid name like /Type /Paje).
  2. Open the PDF in Chrome and enter Annotate mode (via the toolbar pencil icon).
  3. Draw a stroke on the page. On mouse-up, WriteStrokeToPage will call FPDFPage_InsertObject. PDFium will silently free the created path objects while Chrome’s PDFiumEngine caches the dangling handles.
  4. Press Ctrl+Z (Undo) or trigger a thumbnail request. This will cause PDFiumEngine to access the dangling pointers and perform UAF writes to the is_active_ and dirty_ fields.

Suggested Fix

FPDFPage_InsertObject and similar C API functions should not silently free objects on failure if they return void. They should either return a boolean/error code to indicate failure so the caller can handle the memory, or they should return the pointer back to the caller on failure.

Alternatively, if the API contract is strict about taking ownership regardless of success, the Ink2 integration code needs a mechanism to query the page state before yielding ownership, or Chrome needs to verify the object was successfully inserted (e.g., by checking object count) and prune dangling pointers if it wasn’t. Updating the API to return a success boolean, similar to FPDFPage_InsertObjectAtIndex, is the most robust approach.

Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker