CVE-2026-8557
Overview
Background
- `AutomationTreeManagerOwner`
- Owns and manages accessibility tree wrappers keyed by
AXTreeID, exposing them to the automation/V8 bindings used by extensions and ChromeVox. - `AXTree` unserialization
- Applying an
AXTreeUpdateto an existing tree; it can fail partway (e.g., an update references a non-existent node id), leaving the tree in a corrupted, partially-mutated state. - Client wrapper / static caches
- The per-tree wrapper and its nested static caches hold pointers into tree nodes; if the tree is corrupted, those pointers can dangle.
- `DestroyAccessibilityTree` / `GetTreeIDToTreeMap`
- The call that drops and destroys a tree wrapper, and the map of live tree wrappers the manager consults on subsequent events.
Root Cause Analysis
In the serialization/event-handling path (the enclosing function name is not shown in the @@ -1075 hunk header), when an AXTree fails to fully unserialize the code logged the error and called GetAutomationV8Bindings()->SendTreeSerializationError(tree_id) and then simply returned, leaving the corrupted tree_wrapper and its nested static caches in GetTreeIDToTreeMap(). Because the failed update may have partially mutated the tree and left caches pointing at nodes that no longer exist, any subsequent event or lookup for that tree_id would dereference the corrupted, dangling pointers — a use-after-free.
The fix adds DestroyAccessibilityTree(tree_id) immediately before the return, dropping and destroying the corrupted wrapper and its caches so the manager can no longer reference them. The regression test UnserializeFailureClearsCachedTree confirms the invariant: after a bad update whose node adds a non-existent child_id, GetTreeIDToTreeMap() must be empty (or, in AX_FAIL_FAST_BUILD, the operation death-tests).
DestroyAccessibilityTree(tree_id) on the failure path.Attack Path
- Send a malformed tree update
An attacker-influenced source of accessibility updates delivers an
AXTreeUpdatewhose node references a non-existent node id, as the regression test’sbad_node_data.child_ids.push_back(invalid_node_id)shows. - Unserialize fails, tree left cached
Unserialization fails; pre-patch the code sends a serialization error and returns, leaving the corrupted wrapper and its static caches in
GetTreeIDToTreeMap(). - Subsequent access dereferences dangling state
A later accessibility event or lookup for the same
tree_idreaches the corrupted wrapper/caches whose pointers dangle, a use-after-free. - Corruption/control (inferred) Leveraging the UAF for disclosure or control is (inferred); the diff demonstrates the dangling-state condition and its removal, not an exploit primitive.
Impact Assessment
AutomationTreeManagerOwner holding a corrupted tree whose later use is a use-after-free, which the advisory classifies as “Use after free in Accessibility” at high severity. The effect is in the process hosting the automation tree manager (e.g., browser/renderer accessibility, relevant to ChromeOS/extension automation); the precondition is reaching the unserialize-failure path, and any escalation beyond the UAF is (inferred). The advisory states no exploited-in-the-wild status.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc |
modified |
Files Changed
ui/accessibility/platform/automation/automation_tree_manager_owner.ccui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc
Audit Directions
- Error paths that log-and-returnAudit failure branches (especially after “send error” notifications) that leave a half-constructed/half-mutated object registered in a long-lived map; they should tear the object down before returning.
- Partial-unserialize / partial-apply stateWherever an update can fail midway (tree/DOM/graph deserialization), verify the target is either rolled back or destroyed, never left cached in a corrupted state.
- Caches pointing into mutable treesLook for static/nested caches holding raw pointers into nodes of a tree that can be corrupted or resized; confirm they are invalidated whenever the backing tree enters an error state.
Patch
From a72c80884d88cb68fba22a552ec795f3b552f1a1 Mon Sep 17 00:00:00 2001 From: Addison Luh <[email protected]> Date: Tue, 28 Apr 2026 18:12:04 -0700 Subject: [PATCH] Reland "Prevent invalid tree state in AutomationTreeManagerOwner" This is a reland of commit d2ab7831eecf3a1c192ae64b05c2c6a797b556e9 Fixed tree id in the test. Original change's description: > Prevent invalid tree state in AutomationTreeManagerOwner > > If an AXTree fails to fully unserialize in the automation tree manager > owner, drop and destroy the corrupted client wrapper and its nested > static caches immediately. This prevents the tree manager from > referencing the corrupted, dangling pointers subsequently. > > Bug: b:502978647 > Test: out/Default/accessibility_unittests --gtest_filter=AutomationTreeManagerOwnerTest.UnserializeFailureClearsCachedTree > Change-Id: Idbcbdab0e7b7a46d8d094a38f3ba4a32cf301ce7 > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7793837 > Reviewed-by: David Tseng <[email protected]> > Auto-Submit: Addison Luh <[email protected]> > Commit-Queue: David Tseng <[email protected]> > Cr-Commit-Position: refs/heads/main@{#1621369} Cq-Include-Trybots: luci.chrome.try:linux-chromeos-chrome Bug: b:502978647 Change-Id: I720b30a6baa76570ea54b072e1e9bc0ad6c50f0a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7802331 Auto-Submit: Addison Luh <[email protected]> Reviewed-by: David Tseng <[email protected]> Commit-Queue: Addison Luh <[email protected]> Cr-Commit-Position: refs/heads/main@{#1622148} --- diff --git a/ui/accessibility/platform/automation/automation_tree_manager_owner.cc b/ui/accessibility/platform/automation/automation_tree_manager_owner.cc index d6a36fce..17f0803 100644 --- a/ui/accessibility/platform/automation/automation_tree_manager_owner.cc +++ b/ui/accessibility/platform/automation/automation_tree_manager_owner.cc @@ -1075,6 +1075,7 @@ mouse_location)) { DLOG(ERROR) << tree_wrapper->ax_tree()->error(); GetAutomationV8Bindings()->SendTreeSerializationError(tree_id); + DestroyAccessibilityTree(tree_id); return; } diff --git a/ui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc b/ui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc index a16a96d..693b5e5f 100644 --- a/ui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc +++ b/ui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc @@ -1000,4 +1000,48 @@ EXPECT_TRUE(tree_destroyed); } +TEST_F(AutomationTreeManagerOwnerTest, UnserializeFailureClearsCachedTree) { + EXPECT_TRUE(GetTreeIDToTreeMap().empty()); + + const AXTreeID tree_id = AXTreeID::CreateNewAXTreeID(); + const int valid_node_id = 1; + const int invalid_node_id = 2; + + std::vector<AXTreeUpdate> updates; + updates.emplace_back(); + auto& tree_update = updates.back(); + auto& tree_data = tree_update.tree_data; + tree_data.tree_id = tree_id; + tree_update.has_tree_data = true; + tree_update.root_id = valid_node_id; + tree_update.nodes.emplace_back(); + auto& node_data = tree_update.nodes.back(); + node_data.role = ax::mojom::Role::kDesktop; + node_data.id = valid_node_id; + std::vector<AXEvent> events; + SendAccessibilityEvents(tree_id, updates, gfx::Point(), events); + + ASSERT_EQ(1U, GetTreeIDToTreeMap().size()); + + updates.clear(); + updates.emplace_back(); + auto& bad_update = updates.back(); + bad_update.has_tree_data = true; + bad_update.tree_data.tree_id = tree_id; + bad_update.root_id = valid_node_id; + bad_update.nodes.emplace_back(); + auto& bad_node_data = bad_update.nodes.back(); + bad_node_data.id = valid_node_id; + // Adds a non-existent node id. + bad_node_data.child_ids.push_back(invalid_node_id); + +#if AX_FAIL_FAST_BUILD() + EXPECT_DEATH_IF_SUPPORTED( + SendAccessibilityEvents(tree_id, updates, gfx::Point(), events), ""); +#else + SendAccessibilityEvents(tree_id, updates, gfx::Point(), events); + EXPECT_TRUE(GetTreeIDToTreeMap().empty()); +#endif +} + } // namespace ui
Regression Test / PoC
diff --git a/ui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc b/ui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc
index a16a96d..693b5e5f 100644
--- a/ui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc
+++ b/ui/accessibility/platform/automation/automation_tree_manager_owner_unittest.cc
@@ -1000,4 +1000,48 @@
EXPECT_TRUE(tree_destroyed);
}
+TEST_F(AutomationTreeManagerOwnerTest, UnserializeFailureClearsCachedTree) {
+ EXPECT_TRUE(GetTreeIDToTreeMap().empty());
+
+ const AXTreeID tree_id = AXTreeID::CreateNewAXTreeID();
+ const int valid_node_id = 1;
+ const int invalid_node_id = 2;
+
+ std::vector<AXTreeUpdate> updates;
+ updates.emplace_back();
+ auto& tree_update = updates.back();
+ auto& tree_data = tree_update.tree_data;
+ tree_data.tree_id = tree_id;
+ tree_update.has_tree_data = true;
+ tree_update.root_id = valid_node_id;
+ tree_update.nodes.emplace_back();
+ auto& node_data = tree_update.nodes.back();
+ node_data.role = ax::mojom::Role::kDesktop;
+ node_data.id = valid_node_id;
+ std::vector<AXEvent> events;
+ SendAccessibilityEvents(tree_id, updates, gfx::Point(), events);
+
+ ASSERT_EQ(1U, GetTreeIDToTreeMap().size());
+
+ updates.clear();
+ updates.emplace_back();
+ auto& bad_update = updates.back();
+ bad_update.has_tree_data = true;
+ bad_update.tree_data.tree_id = tree_id;
+ bad_update.root_id = valid_node_id;
+ bad_update.nodes.emplace_back();
+ auto& bad_node_data = bad_update.nodes.back();
+ bad_node_data.id = valid_node_id;
+ // Adds a non-existent node id.
+ bad_node_data.child_ids.push_back(invalid_node_id);
+
+#if AX_FAIL_FAST_BUILD()
+ EXPECT_DEATH_IF_SUPPORTED(
+ SendAccessibilityEvents(tree_id, updates, gfx::Point(), events), "");
+#else
+ SendAccessibilityEvents(tree_id, updates, gfx::Point(), events);
+ EXPECT_TRUE(GetTreeIDToTreeMap().empty());
+#endif
+}
+
} // namespace ui
Original Bug Report
Use-after-free in extension renderer via AutomationAXTreeWrapper state poisoning
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: The extension renderer’s AutomationAXTreeWrapper fails to reset its internal AXTree when Unserialize fails. A compromised content renderer can send an update referencing child ID 0, poisoning the tree’s internal map. A subsequent update for the same tree ID causes a collision during node creation, resulting in a persistent Use-After-Free that bypasses MiraclePtr.
Affected files:
ui/accessibility/platform/automation/automation_ax_tree_wrapper.ccui/accessibility/ax_tree.ccui/accessibility/platform/automation/automation_tree_manager_owner.cc
Estimated timestamp from git blame: 2025-06-25
Summary
A potential Use-After-Free (UAF) vulnerability exists in the extension renderer process due to improper state management in AutomationAXTreeWrapper. When an accessibility tree update fails to unserialize, the wrapper maintains its corrupted AXTree state instead of resetting it. An attacker can exploit this by poisoning the tree’s node map to cause an ID collision on a subsequent update. This collision causes node creation to fail, returning a dangling pointer that is then stored persistently in the tree structure. This vulnerability bypasses MiraclePtr mitigations.
Prerequisites
This vulnerability requires an active extension utilizing the chrome.automation API (e.g., ChromeVox or Select-to-Speak on ChromeOS).
When an extension uses this API, the browser sets the accessibility mode to kAXModeWebContentsOnly, which lacks the kNativeAPIs flag. Consequently, the browser_accessibility_manager_ in the Browser process remains null. This architectural quirk causes the Browser to skip running AXTree::Unserialize during RenderFrameHostImpl::SendAccessibilityEventsToManager, effectively forwarding malicious accessibility updates from a compromised content renderer directly to the extension renderer without native validation.
Potential Exploitation Steps
(Note: These are suggested steps based on source code analysis; a working proof-of-concept has not been executed).
1. Poisoning Phase
A compromised content renderer sends an AXTreeUpdate where a node (e.g., ID 1) includes a child with ID 0 (kInvalidAXNodeID).
- During
AXTree::Unserialize,GetFromId(0)is called. Because 0 is hardcoded as invalid,GetFromIdexplicitly returnsnullptrwithout checking theid_map_. - Assuming node 0 is new, the code calls
CreateNode(0). In official production builds,SANITIZER_CHECK(id != kInvalidAXNodeID)resolves to a no-opDCHECK. CreateNode(0)successfully inserts the newly allocated node into the tree’sid_map_viatry_emplace(0, ...).- The update eventually fails validation (e.g., in
ValidatePendingChangesComplete) because no data was provided for node 0. Unserializereturnsfalse. However,AutomationAXTreeWrapper::OnAccessibilityEventsreturns without clearing or resetting theax_tree_. The tree remains poisoned with ID 0 inid_map_.
2. Collision and UAF Phase
The attacker sends a second AXTreeUpdate for the same tree_id, again referencing child ID 0.
Unserializeis called on the poisoned tree.CreateNewChildVectorcallsGetFromId(0). It still explicitly returnsnullptr, forcing the code to callCreateNode(0)again.- Inside
CreateNode(0), a newAXNodeis allocated in astd::unique_ptr. id_map_.try_emplace(0, ...)is called. This time, the call fails because ID 0 is already present in the map from the first update.- Because insertion fails, the
unique_ptris not moved. The function returns the raw pointer (node_raw) to the newly created node. - The
unique_ptrgoes out of scope and theAXNodeis destroyed, leavingnode_rawas a dangling pointer. - In
CreateNewChildVector,update_state->pending_node_ids.insert(child->id());executes, immediately dereferencing the dangling pointer to readdata_.id. new_children->push_back(child)constructs araw_ptrfrom the dangling pointer.UpdateNodecallsnode->SwapChildren(&new_children), persistently storing the dangling pointer in the parent node’schildren_array.
MiraclePtr Bypass
This flow bypasses MiraclePtr (BackupRefPtr). At the time the AXNode is destroyed in CreateNode, it is exclusively owned by a std::unique_ptr and has no active raw_ptr references. Therefore, PartitionAlloc’s BRP refcount is 0, and the memory is freed immediately without quarantine. When new_children->push_back(child) constructs the raw_ptr, it increments the refcount from 0 to 1 on already freed memory, bypassing the mitigation and allowing a stable UAF.
Suggested Fix
- Prevent dangling pointer return: Modify
AXTree::CreateNodeso that iftry_emplacefails (i.e.,inserted == false), it does not returnnode_raw. Instead, it should returnnullptror crash, as the tree state is invalid. - Reset state on failure: In
AutomationAXTreeWrapper::OnAccessibilityEvents, ifax_tree_->Unserialize(update)returnsfalse, theax_tree_instance should be completely reset or destroyed. The tree is in an untrusted, partially updated state and should not be reused for subsequent updates. - Strengthen ID validation: Ensure that
AXTree::Unserializestrictly rejects updates containingkInvalidAXNodeIDacross all build configurations, not just whenAX_FAIL_FAST_BUILD()is enabled.
Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09
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.
Raised in root component due to access or custom field issues on 1279373