Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in PDFium
DescriptionUse after free in PDFium
ComponentPDFium
Bug ClassUAF
Tracker504416752
Fix commit5553c74255c2 (pdfium) +377/-7
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
CJXListEmbedderTest
fxjs/xfa/cjx_list_embeddertest.cpp
modified
TEST_F
fxjs/xfa/cjx_list_embeddertest.cpp
modified
for
fxjs/xfa/cjx_list_embeddertest.cpp
modified
for
testing/resources/bug_504416752.in
modified
if
testing/resources/bug_504416752.in
modified

Files Changed

  • fxjs/xfa/cfxjse_engine.cpp
  • fxjs/xfa/cjx_list_embeddertest.cpp
  • testing/resources/bug_504416752.in
  • testing/resources/bug_504416752.pdf
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
+>>
Loading diff…

Original Bug Report

reported by [email protected]

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.cpp
  • third_party/pdfium/fxjs/xfa/cfxjse_context.cpp
  • third_party/pdfium/fxjs/xfa/cjx_list.cpp
  • third_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:

  1. Load a malicious PDF: The user opens a PDF with XFA support enabled containing attacker-controlled JavaScript.
  2. Stash an unrooted wrapper: The script accesses an XFA list and retrieves a node using myList.item(0). This routes to CJX_List::item and creates an unrooted V8 wrapper. The script stores this wrapper in a global variable.
  3. Sever DOM references: The script removes the node from the DOM (e.g., myList.remove(myNode)). This calls GCedTreeNodeMixin::RemoveChild, which sets the cppgc::Member tree pointers (like parent_ and next_sibling_) to nullptr.
  4. Trigger Garbage Collection: The script allocates a large amount of memory to create allocation pressure. Since cppgc cannot see the V8 wrapper’s internal fields, and the node has no remaining Member or Persistent roots, cppgc frees the CJX_Object.
  5. Use-After-Free: The script reads a property on the globally stashed wrapper (e.g., stashedNode.className). V8 dispatches this to CFXJSE_Engine::NormalPropertyGetter, which retrieves the dangling raw pointer from the internal field and calls pHostObj->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.

View on issue tracker