CVE-2026-11303
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
CJXListEmbedderTestfxjs/xfa/cjx_list_embeddertest.cpp |
modified | |
TEST_Ffxjs/xfa/cjx_list_embeddertest.cpp |
modified | |
forfxjs/xfa/cjx_list_embeddertest.cpp |
modified | |
fortesting/resources/bug_504416752.in |
modified | |
iftesting/resources/bug_504416752.in |
modified |
Files Changed
fxjs/xfa/cfxjse_engine.cppfxjs/xfa/cjx_list_embeddertest.cpptesting/resources/bug_504416752.intesting/resources/bug_504416752.pdf
Patch
From 5553c74255c2d41c5d68d8bed33925c0c73ae827 Mon Sep 17 00:00:00 2001 From: Tom Sepez <[email protected]> Date: Tue, 21 Apr 2026 13:49:20 -0700 Subject: [PATCH] Keep persistent mapping in CFXJSE_Engine::NewNormalXFAObject(). -- Update embedder test framework for both timers and CPPGC. Bug: 504416752 Change-Id: Iac5c0ca57fdf3cca8ea9a3703180bed847abe1c5 Reviewed-on: https://pdfium-review.googlesource.com/c/pdfium/+/146630 Commit-Queue: Tom Sepez <[email protected]> Reviewed-by: Lei Zhang <[email protected]> --- diff --git a/fxjs/xfa/cfxjse_engine.cpp b/fxjs/xfa/cfxjse_engine.cpp index a7725a6..49df538 100644 --- a/fxjs/xfa/cfxjse_engine.cpp +++ b/fxjs/xfa/cfxjse_engine.cpp @@ -927,11 +927,7 @@ return v8::Local<v8::Object>::New(GetIsolate(), iter->second); } - v8::Local<v8::Object> binding = pCJXObject->NewBoundV8Object( - GetIsolate(), js_class_->GetTemplate(GetIsolate())); - - map_object_to_object_[pCJXObject].Reset(GetIsolate(), binding); - return binding; + return NewNormalXFAObject(pObject); } void CFXJSE_Engine::SetNodesOfRunScript( @@ -964,7 +960,9 @@ v8::Local<v8::Object> CFXJSE_Engine::NewNormalXFAObject(CXFA_Object* obj) { v8::EscapableHandleScope scope(GetIsolate()); - v8::Local<v8::Object> object = obj->JSObject()->NewBoundV8Object( + CJX_Object* js_object = obj->JSObject(); + v8::Local<v8::Object> binding = js_object->NewBoundV8Object( GetIsolate(), GetJseNormalClass()->GetTemplate(GetIsolate())); - return scope.Escape(object); + map_object_to_object_[js_object].Reset(GetIsolate(), binding); + return scope.Escape(binding); } diff --git a/fxjs/xfa/cjx_list_embeddertest.cpp b/fxjs/xfa/cjx_list_embeddertest.cpp index a9b5427..f793e7e 100644 --- a/fxjs/xfa/cjx_list_embeddertest.cpp +++ b/fxjs/xfa/cjx_list_embeddertest.cpp @@ -2,7 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +#include "testing/embedder_test_environment.h" +#include "testing/embedder_test_timer_handling_delegate.h" #include "testing/gtest/include/gtest/gtest.h" +#include "testing/v8_test_environment.h" #include "testing/xfa_js_embedder_test.h" class CJXListEmbedderTest : public XFAJSEmbedderTest {}; @@ -13,3 +16,22 @@ EXPECT_FALSE(Execute("nodes.insert($form,$)")); } + +// Should not crash. +TEST_F(CJXListEmbedderTest, Bug504416752) { + EmbedderTestTimerHandlingDelegate delegate; + SetDelegate(&delegate); + + ASSERT_TRUE(OpenDocument("bug_504416752.pdf")); + ScopedPage page = LoadScopedPage(0); + ASSERT_TRUE(page); + DoOpenActions(); + + ForceCppGarbageCollection(); + + for (int i = 0; i < 1000; ++i) { + delegate.AdvanceTime(10); + V8TestEnvironment::PumpPlatformMessageLoop( + V8TestEnvironment::GetInstance()->isolate()); + } +} diff --git a/testing/resources/bug_504416752.in b/testing/resources/bug_504416752.in new file mode 100644 index 0000000..6b8e37d --- /dev/null +++ b/testing/resources/bug_504416752.in @@ -0,0 +1,67 @@ +{{header}} +{{include xfa_catalog_1_0.fragment}} +{{include xfa_object_2_0.fragment}} +{{include xfa_preamble_3_0.fragment}} +{{include xfa_config_4_0.fragment}} +{{object 5 0}} << + {{streamlen}} +>> +stream +<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"> + <subform layout="tb" name="my_doc"> + <pageSet id="page" relation="orderedOccurrence"> + <occur initial="1" max="1" min="1"/> + <pageArea id="Page1" name="Page1"> + <occur max="1" min="1"/> + <contentArea h="100mm" w="200mm" x="0.25in" y="0.25in"/> + </pageArea> + </pageSet> + <subform name="S"> + <proto> + <subform name="vchild"/> + </proto> + </subform> + <event activity="docReady" ref="$host"> + <script contentType="application/x-javascript"><![CDATA[ + try { + var ts = xfa.resolveNode("$template.my_doc.S"); + var L = ts.nodes; // CXFA_AttachNodeList + // Obtain the victim via list.item() which goes through + // CFXJSE_Engine::NewNormalXFAObject. + var v = null; + for (var i = 0; i < L.length; ++i) { + var c = L.item(i); + if (c.className == "proto") { v = c; break; } + } + L.remove(v); + // Stash the unsafe wrapper on the AcroForm global so it survives + // across script turns (the v8 wrapper is rooted, the CJX_Object is not). + __stash__ = v; + __pump__ = "for (var j = 0; j < 30000; ++j) xfa.template.createNode('subform','p');"; + __hit__ = "try { __stash__.className; } catch(e) {}"; + for (var k = 0; k < 25; ++k) { + app.setTimeOut(__pump__, 100 + k * 120); + app.setTimeOut(__hit__, 150 + k * 120); + } + } catch (e) { + app.alert("setup error: " + e); + } + ]]></script> + </event> + </subform> +</template> +endstream +endobj +{{include xfa_locale_6_0.fragment}} +{{include xfa_postamble_7_0.fragment}} +{{include xfa_pages_8_0.fragment}} +{{object 9 0}} << + /Type /Page + /Parent 8 0 R + /MediaBox [0 0 612 792] +>> +endobj +{{xref}} +{{trailer}} +{{startxref}} +%%EOF diff --git a/testing/resources/bug_504416752.pdf b/testing/resources/bug_504416752.pdf new file mode 100644 index 0000000..e83b172 --- /dev/null +++ b/testing/resources/bug_504416752.pdf @@ -0,0 +1,276 @@ +%PDF-1.7 +%��� +1 0 obj << + /AcroForm 2 0 R + /Extensions << + /ADBE << + /BaseVersion /1.7 + /ExtensionLevel 8 + >> + >> + /NeedsRendering true + /Pages 8 0 R + /Type /Catalog +>> +endobj +2 0 obj << + /XFA [ + (preamble) + 3 0 R + (config) + 4 0 R + (template) + 5 0 R + (localeSet) + 6 0 R + (postamble) + 7 0 R + ] +>> +endobj +3 0 obj << + /Length 123 +>> +stream +<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/" timeStamp="2018-02-23T21:37:11Z" uuid="21482798-7bf0-40a4-bc5d-3cefdccf32b5"> +endstream +endobj +4 0 obj << + /Length 641 +>>
Original Bug Report
Use-After-Free in PDFium XFA due to unrooted V8 wrappers
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 go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A potential Use-After-Free vulnerability exists in PDFium’s XFA implementation when V8 wrappers for cppgc-managed objects are created without proper rooting. If the underlying C++ object is removed from the DOM and garbage collected, the V8 wrapper retains a raw pointer to freed memory. Accessing this wrapper from JavaScript triggers a virtual call on the freed object, potentially leading to Remote Code Execution.
Affected files:
third_party/pdfium/fxjs/xfa/cfxjse_engine.cppthird_party/pdfium/fxjs/xfa/cfxjse_context.cppthird_party/pdfium/fxjs/xfa/cjx_list.cppthird_party/pdfium/fxjs/xfa/cjx_tree.cpp
Estimated timestamp from git blame: 2022-06-10
Summary
A potential Use-After-Free (UAF) vulnerability exists in PDFium’s XFA engine. The issue arises because certain code paths, such as CFXJSE_Engine::NewNormalXFAObject, create V8 wrappers that hold raw C++ pointers to CJX_Object (which are cppgc::GarbageCollected objects) without ensuring they are rooted.
Because PDFium’s cppgc heap is standalone (not unified with V8) and configured with kNoConservativeStackScan, the raw pointers hidden in V8 internal fields are not traced. If an XFA node is removed from the DOM, it loses its C++ Member<> references. If no Persistent<> roots exist, the object will be collected during the next cppgc cycle, even if the JavaScript environment still holds the V8 wrapper.
(Note: This vulnerability is gated by the kPdfXfaSupport feature, which is currently disabled by default without active field trials. The following steps are potential and have not been executed, as our tooling cannot currently run code.)
Root Cause Analysis
In third_party/pdfium/fxjs/xfa/cfxjse_engine.cpp, the safe path for creating bindings is GetOrCreateJSBindingFromMap. This method ensures that any created V8 binding is stored in map_object_to_object_. This map uses cppgc::Persistent<CJX_Object> as a key, effectively rooting the object and preventing garbage collection while the V8 wrapper is alive.
However, CFXJSE_Engine::NewNormalXFAObject completely bypasses this map:
v8::Local<v8::Object> CFXJSE_Engine::NewNormalXFAObject(CXFA_Object* obj) {
v8::EscapableHandleScope scope(GetIsolate());
v8::Local<v8::Object> object = obj->JSObject()->NewBoundV8Object(
GetIsolate(), GetJseNormalClass()->GetTemplate(GetIsolate()));
return scope.Escape(object);
}
NewBoundV8Object calls FXJSE_UpdateObjectBinding, which stashes the raw C++ pointer in a V8 internal field (GetAlignedPointerFromInternalField(1)). Unsafe callers that utilize this unrooted path include CJX_List::item and CJX_Tree::nodes. Nodes obtained via these methods receive a V8 wrapper, but the engine does not track them with a Persistent handle.
Potential Steps to Trigger
An attacker could potentially trigger this vulnerability by following these steps:
- Load a malicious PDF: The user opens a PDF with XFA support enabled containing attacker-controlled JavaScript.
- Stash an unrooted wrapper: The script accesses an XFA list and retrieves a node using
myList.item(0). This routes toCJX_List::itemand creates an unrooted V8 wrapper. The script stores this wrapper in a global variable. - Sever DOM references: The script removes the node from the DOM (e.g.,
myList.remove(myNode)). This callsGCedTreeNodeMixin::RemoveChild, which sets thecppgc::Membertree pointers (likeparent_andnext_sibling_) tonullptr. - Trigger Garbage Collection: The script allocates a large amount of memory to create allocation pressure. Since
cppgccannot see the V8 wrapper’s internal fields, and the node has no remainingMemberorPersistentroots,cppgcfrees theCJX_Object. - Use-After-Free: The script reads a property on the globally stashed wrapper (e.g.,
stashedNode.className). V8 dispatches this toCFXJSE_Engine::NormalPropertyGetter, which retrieves the dangling raw pointer from the internal field and callspHostObj->AsCJXObject(). This virtual call on freed memory could be leveraged for Remote Code Execution in the renderer process.
Suggested Fix
Modify CFXJSE_Engine::NewNormalXFAObject (and any similar unsafe V8 wrapper creation paths) to ensure that the newly created bindings are properly registered in map_object_to_object_, or explicitly establish a cppgc::Persistent root for the CJX_Object before returning the wrapper to JavaScript.
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.