CVE-2026-9957
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcore/fpdfapi/parser/cpdf_document.cpp |
modified | |
TEST_Fcore/fpdfapi/parser/cpdf_document_unittest.cpp |
modified | |
forcore/fpdfapi/parser/cpdf_document_unittest.cpp |
modified | |
CXFASimpleParserEmbedderTestxfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp |
modified | |
TEST_Fxfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp |
modified |
Files Changed
core/fpdfapi/parser/cpdf_document.cppcore/fpdfapi/parser/cpdf_document_unittest.cpptesting/resources/redirect.intesting/resources/redirect.pdfxfa/fxfa/parser/cxfa_document_builder_embeddertest.cpp
Patch
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) {
Regression Test / PoC
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));
}
Original Bug Report
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.ccthird_party/pdfium/fpdfsdk/fpdf_editpage.cpppdf/pdfium/pdfium_engine.ccthird_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.
-
Silent Free in
FPDFPage_InsertObject: The PDFium functionFPDFPage_InsertObject(inthird_party/pdfium/fpdfsdk/fpdf_editpage.cpp) takes ownership of anFPDF_PAGEOBJECTby wrapping it in astd::unique_ptr. It then validates the destination page by callingIsPageObject. If the validation fails, the function returns early. This causes theunique_ptrto go out of scope and delete theCPDF_PageObjectbefore it can be added to the page, effectively freeing it. Because the function has avoidreturn type, it provides no error signal to the caller. -
Normalization Gap:
IsPageObjectchecks if the page dictionary’s/Typekey is a Name object with the exact value"Page". While PDFium’sCountPagesattempts to normalize malformed/Typekeys during document load, this normalization is skipped if the root/Pagesdictionary contains a valid/Countentry. Consequently, a maliciously crafted PDF can contain a page that loads normally but permanently fails theIsPageObjectcheck. -
Dangling Pointer Retention: The Chrome Ink2 writer (
pdf/pdfium/pdfium_ink_writer.cc) callsFPDFPage_InsertObjectviaWriteStrokeToPageand explicitly releases its ownership of the object. It assumes the operation succeeds and continues to cache the now-dangling raw pointer in its internalink_stroke_data_map inPDFiumEngine. -
UAF Write Sink: When a user undoes a stroke,
PDFiumEngine::UpdateStrokeActiveiterates through these cached (dangling) pointers and callsFPDFPageObj_SetIsActive. This eventually leads toCPDF_PageObject::SetIsActive(inthird_party/pdfium/core/fpdfapi/page/cpdf_pageobject.cpp), which performs a UAF write to theis_active_anddirty_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.
- Craft a PDF where the root
/Pagesdictionary has a valid/Countbut a child page dictionary has a malformed/Typeentry (e.g.,/Type (Page)as a literal string or an invalid name like/Type /Paje). - Open the PDF in Chrome and enter Annotate mode (via the toolbar pencil icon).
- Draw a stroke on the page. On mouse-up,
WriteStrokeToPagewill callFPDFPage_InsertObject. PDFium will silently free the created path objects while Chrome’sPDFiumEnginecaches the dangling handles. - Press Ctrl+Z (Undo) or trigger a thumbnail request. This will cause
PDFiumEngineto access the dangling pointers and perform UAF writes to theis_active_anddirty_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.