Memory safety / Use-after-free in iOS AccessibilityBridge handling (SemanticsObject bridge lifetime)

HIGH
flutter/flutter
Commit: 7ea98d8a61c7
Affected: v1.16.3 and earlier
2026-07-17 05:46 UTC

Description

This Flutter iOS engine patch mitigates a potential memory-safety vulnerability in the SemanticsObject/AccessibilityBridge integration. Previously, code could hold a raw bridge pointer or dereference the bridge after the AccessibilityBridgeIos object had been destroyed (e.g., during engine teardown, view controller swaps, or shutdown while VoiceOver still references the accessibility tree). This could yield use-after-free or undefined behavior when calling bridge-related APIs (such as bridge->view(), DispatchSemanticsAction, hit-testing, or coordinate conversions). The patch replaces the internal bridge storage (fml::WeakPtr<AccessibilityBridgeIos>) with safe accessors (bridge and bridgeView) that return a raw pointer or UIView* only when the bridge is still alive, and updates call sites to fetch the pointer once and guard against nullptr, ensuring no access occurs after destruction. It also marks the relevant properties as nullable and wraps the corresponding access paths accordingly. A test was added to destroy the AccessibilityBridge and verify that semantics accessors, actions, geometry conversion, and hit-testing do not touch freed memory afterward.

Proof of Concept

PoC reproduction (conceptual): 1) Run a Flutter iOS app with VoiceOver enabled. The app has a SemanticsObject associated with a FlutterViewController and an active AccessibilityBridgeIos. 2) During teardown (e.g., FlutterViewController swap, engine shutdown), destroy the underlying AccessibilityBridgeIos while VoiceOver holds references to SemanticsObject instances. 3) Trigger an accessibility operation (hit-test, coordinate conversion, or an action dispatch) that would previously dereference the bridge pointer without a guard, such as: // Before fix pattern (vulnerable): AccessibilityBridgeIos* bridge = semanticsObject.bridge; // raw pointer could be stale after destruction if (bridge->isVoiceOverRunning()) { ... } // After fix pattern (safe): AccessibilityBridgeIos* bridge = semanticsObject.bridge; if (!bridge) { /* bridge dead; do nothing */ } if (bridge && bridge->isVoiceOverRunning()) { ... } 4) Observe that in the vulnerable path a use-after-free could crash or exhibit undefined behavior. The patch ensures bridge is fetched once, checked for nullptr, and then used within a scoped region, preventing access after destruction. Note: The provided PoC is conceptual and intended to illustrate the use-after-free risk; the actual Flutter test harness would implement a controlled teardown to reliably provoke the condition and verify stability.

Commit Details

Author: Chris Bracken

Date: 2026-07-17 04:53 UTC

Message:

[iOS] Fix missing nil checks and improve SemanticsObject bridge API (#189630) UIKit and VoiceOver can retain `SemanticsObject`/`SemanticsObjectContainer` instances after the engine's `AccessibilityBridge` that created them has been destroyed, e.g. during engine shutdown, view controller teardown, or a `FlutterViewController` being swapped out while VoiceOver still holds a reference to its accessibility tree (e.g. flutter/flutter#43795). Existing code already guarded *most* `UIAccessibilityElement`/`UIAccessibilityContainer` overrides for this with an `isAccessibilityBridgeAlive` check followed by a separate `self.bridge` re-read, but a few call sites had no guard at all. In debug builds, `fml::WeakPtr`'s debug-mode assertion will trigger an `abort()` in such cases, but this is undefined behaviour in release builds. This patch replaces the `fml::WeakPtr<AccessibilityBridgeIos> bridge` property on `SemanticsObject` with `- (AccessibilityBridgeIos*)bridge` and `- (UIView*)bridgeView` accessor methods. `bridge` resolves the weak pointer and hands back a raw `AccessibilityBridgeIos*` or nullptr. Callers fetch it once into a local, check it against nullptr, and use it synchronously within that same scope. This simplifies the previous two-step pattern (an `isAccessibilityBridgeAlive` check followed by a separate `self.bridge` re-read, which could in theory observe different liveness between the two) into one step, and keeps the C++ smart pointer type out of the Objective-C API. Similar to the previous code (when we did the two-step process to get the raw pointer at each call site), these pointers should never escape the scope in which they're created. All overrides that dereferenced the bridge have been updated to the new API, including the ones that previously had no check at all: * `showOnScreen` * `onCustomAccessibilityAction:` * `ConvertPointToGlobal` * `ConvertRectToGlobal` * `_accessibilityHitTest:withEvent:` (which VoiceOver calls directly during touch exploration), * the `TextInputSemanticsObject` overrides `bridgeView` hands back the ARC-managed `UIView*` itself. Once a caller holds a strong reference to it, the view is memory-safe independent of the bridge's C++ lifetime. That view can still outlive the bridge, (it's owned separately by the view hierarchy), so it's longer driven by the engine once the bridge is gone. Both accessors return nil once the bridge is dead. This allows us to delete the redundant `_bridge` ivar from `SemanticsObjectContainer` in favor of deriving `bridgeView` as needed from its `semanticsObject`, so there's a single source of truth for bridge liveness. I've also wrapped `SemanticsObject.h` in `NS_ASSUME_NONNULL_BEGIN`/`END` now that `bridge`/`bridgeView` are nullable, and made `parent` and `SemanticsObjectContainer.semanticsObject` `weak` accordingly. Added a test that destroys an `AccessibilityBridge` then confirms that `SemanticsObject`/ `SemanticsObjectContainer` accessors, actions, geometry conversion, and hit-testing no longer touch freed memory afterward. Issue: b/525528671 ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

Triage Assessment

Vulnerability Type: Memory safety / Use-after-free

Confidence: HIGH

Reasoning:

The commit fixes memory safety issues related to use-after-free/dangling references by ensuring bridge objects are nil-checked and not accessed after the bridge is destroyed. It replaces a weak pointer usage with safe accessor patterns and guards across multiple call sites, preventing potential crashes or undefined behavior when the accessibility bridge is torn down (which could be exploited in edge cases).

Verification Assessment

Vulnerability Type: Memory safety / Use-after-free in iOS AccessibilityBridge handling (SemanticsObject bridge lifetime)

Confidence: HIGH

Affected Versions: v1.16.3 and earlier

Code Diff

diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm index 0ce00b56d55fa..a39b6a9c4663f 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm @@ -30,11 +30,12 @@ - (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { // UIScrollView class, the base class. - (BOOL)isAccessibilityElement { - if (![self.semanticsObject isAccessibilityBridgeAlive]) { + flutter::AccessibilityBridgeIos* bridge = self.semanticsObject.bridge; + if (!bridge) { return NO; } - if ([self.semanticsObject bridge]->isVoiceOverRunning()) { + if (bridge->isVoiceOverRunning()) { return self.semanticsObject.accessibilityLabel.length > 0; } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm index c21012fc40cdf..b5984fedf94bf 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm @@ -61,7 +61,7 @@ - (void)didUpdateFocusInContext:(UIFocusUpdateContext*)context - (id<UIFocusEnvironment>)parentFocusEnvironment { // The root SemanticsObject node's parent is the FlutterView. - return self.parent.focusItem ?: ([self isAccessibilityBridgeAlive] ? self.bridge->view() : nil); + return self.parent.focusItem ?: [self bridgeView]; } - (NSArray<id<UIFocusEnvironment>>*)preferredFocusEnvironments { @@ -89,7 +89,7 @@ - (BOOL)canBecomeFocused { // See also the `coordinateSpace` implementation. // TODO(LongCatIsLooong): use CoreGraphics types. - (CGRect)frame { - if (![self isAccessibilityBridgeAlive]) { + if (!self.bridge) { return CGRectZero; } SkPoint quad[4] = {SkPoint::Make(self.node.rect.left(), self.node.rect.top()), @@ -134,7 +134,8 @@ - (CGRect)frame { // UIFocusItem is not inside of a scroll view). // // Screen can be nil if the FlutterView is covered by another native view. - CGFloat scale = (self.bridge->view().window.screen ?: UIScreen.mainScreen).scale; + UIView* view = self.bridgeView; + CGFloat scale = (view.window.screen ?: UIScreen.mainScreen).scale; return CGRectMake(unscaledRect.origin.x / scale, unscaledRect.origin.y / scale, unscaledRect.size.width / scale, unscaledRect.size.height / scale); } @@ -162,8 +163,7 @@ - (CGRect)frame { - (id<UICoordinateSpace>)coordinateSpace { // A regular SemanticsObject uses the same coordinate space as its parent. - return self.parent.coordinateSpace - ?: ([self isAccessibilityBridgeAlive] ? self.bridge->view() : nil); + return self.parent.coordinateSpace ?: [self bridgeView]; } @end @@ -214,7 +214,8 @@ - (void)setContentOffset:(CGPoint)contentOffset { [super setContentOffset:contentOffset]; // Do no send flutter::SemanticsAction::kScrollToOffset if it's triggered // by a framework update. - if (![self.semanticsObject isAccessibilityBridgeAlive] || !self.isDoingSystemScrolling) { + flutter::AccessibilityBridgeIos* bridge = self.semanticsObject.bridge; + if (!bridge || !self.isDoingSystemScrolling) { return; } @@ -222,9 +223,9 @@ - (void)setContentOffset:(CGPoint)contentOffset { FlutterStandardTypedData* offsetData = [FlutterStandardTypedData typedDataWithFloat64:[NSData dataWithBytes:&offset length:sizeof(offset)]]; NSData* encoded = [[FlutterStandardMessageCodec sharedInstance] encode:offsetData]; - self.semanticsObject.bridge->DispatchSemanticsAction( - self.semanticsObject.uid, flutter::SemanticsAction::kScrollToOffset, - fml::MallocMapping::Copy(encoded.bytes, encoded.length)); + bridge->DispatchSemanticsAction(self.semanticsObject.uid, + flutter::SemanticsAction::kScrollToOffset, + fml::MallocMapping::Copy(encoded.bytes, encoded.length)); } - (BOOL)canBecomeFocused { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h index 2c9e0516112ea..a6e627337165a 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h @@ -13,6 +13,8 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h" #import "flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_ios.h" +NS_ASSUME_NONNULL_BEGIN + constexpr int32_t kRootNodeId = 0; // This can be arbitrary number as long as it is bigger than 0. constexpr float kScrollExtentMaxForInf = 1000; @@ -39,17 +41,7 @@ constexpr float kScrollExtentMaxForInf = 1000; * The parent of this node in the node tree. Will be nil for the root node and * during transient state changes. */ -@property(nonatomic, weak, readonly) SemanticsObject* parent; - -/** - * The accessibility bridge that this semantics object is attached to. This - * object may use the bridge to access contextual application information. A weak - * pointer is used because the platform view owns the accessibility bridge. - * If you are referencing this property from an iOS callback, be sure to - * use `isAccessibilityBridgeActive` to protect against the case where this - * node may be orphaned. - */ -@property(nonatomic, readonly) fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge; +@property(nonatomic, weak, readonly, nullable) SemanticsObject* parent; /** * The semantics node used to produce this semantics object. @@ -71,7 +63,7 @@ constexpr float kScrollExtentMaxForInf = 1000; * Direct children of this semantics object in hit test order. Each child's `parent` property * must be equal to this object. */ -@property(nonatomic, copy) NSArray<SemanticsObject*>* childrenInHitTestOrder; +@property(nonatomic, copy, nullable) NSArray<SemanticsObject*>* childrenInHitTestOrder; /** * The UIAccessibility that represents this object. @@ -86,12 +78,20 @@ constexpr float kScrollExtentMaxForInf = 1000; * Due to the fact that VoiceOver may hold onto SemanticObjects even after it shuts down, * there can be situations where the AccessibilityBridge is shutdown, but the SemanticObject * will still be alive. If VoiceOver is turned on again, it may try to access this orphaned - * SemanticObject. Methods that are called from the accessiblity framework should use - * this to guard against this case by just returning early if its bridge has been shutdown. + * SemanticObject. Methods that are called from the accessibility framework should use + * `bridge` (or `bridgeView`) to guard against this case by just returning early if its bridge + * has been shutdown. * * See https://github.com/flutter/flutter/issues/43795 for more information. + * + * Returns a raw pointer to the AccessibilityBridge if it is still alive, or nullptr if destroyed. */ -- (BOOL)isAccessibilityBridgeAlive; +- (nullable flutter::AccessibilityBridgeIos*)bridge; + +/** + * Returns the underlying UIView from the bridge if alive, or nil if destroyed. + */ +- (nullable UIView*)bridgeView; /** * Updates this semantics object using data from the `node` argument. @@ -104,11 +104,11 @@ constexpr float kScrollExtentMaxForInf = 1000; - (BOOL)nodeWillCauseScroll:(const flutter::SemanticsNode*)node; -- (BOOL)nodeShouldTriggerAnnouncement:(const flutter::SemanticsNode*)node; +- (BOOL)nodeShouldTriggerAnnouncement:(nullable const flutter::SemanticsNode*)node; - (void)collectRoutes:(NSMutableArray<SemanticsObject*>*)edges; -- (NSString*)routeName; +- (nullable NSString*)routeName; - (BOOL)onCustomAccessibilityAction:(FlutterCustomAccessibilityAction*)action; @@ -228,12 +228,12 @@ constexpr float kScrollExtentMaxForInf = 1000; - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; - (instancetype)initWithAccessibilityContainer:(id)container NS_UNAVAILABLE; -- (instancetype)initWithSemanticsObject:(SemanticsObject*)semanticsObject - bridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)bridge - NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithSemanticsObject:(SemanticsObject*)semanticsObject NS_DESIGNATED_INITIALIZER; -@property(nonatomic, weak) SemanticsObject* semanticsObject; +@property(nonatomic, weak, nullable) SemanticsObject* semanticsObject; @end +NS_ASSUME_NONNULL_END + #endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_SEMANTICSOBJECT_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm index 1a82fd580714c..44dadba8f7ebc 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm @@ -50,20 +50,28 @@ SkPoint ApplyTransform(SkPoint& point, const SkM44& transform) { } CGPoint ConvertPointToGlobal(SemanticsObject* reference, CGPoint local_point) { + UIView* containerView = [reference bridgeView]; + if (!containerView) { + return CGPointZero; + } SkM44 globalTransform = GetGlobalTransform(reference); SkPoint point = SkPoint::Make(local_point.x, local_point.y); point = ApplyTransform(point, globalTransform); // `rect` is in the physical pixel coordinate system. iOS expects the accessibility frame in // the logical pixel coordinate system. Therefore, we divide by the `scale` (pixel ratio) to // convert. - UIScreen* screen = reference.bridge->view().window.screen; + UIScreen* screen = containerView.window.screen; // Screen can be nil if the FlutterView is covered by another native view. CGFloat scale = (screen ?: UIScreen.mainScreen).scale; auto result = CGPointMake(point.x() / scale, point.y() / scale); - return [reference.bridge->view() convertPoint:result toView:nil]; + return [containerView convertPoint:result toView:nil]; } CGRect ConvertRectToGlobal(SemanticsObject* reference, CGRect local_rect) { + UIView* containerView = [reference bridgeView]; + if (!containerView) { + return CGRectZero; + } SkM44 globalTransform = GetGlobalTransform(reference); SkPoint quad[4] = { @@ -84,12 +92,12 @@ CGRect ConvertRectToGlobal(SemanticsObject* reference, CGRect local_rect) { // `rect` is in the physical pixel coordinate system. iOS expects the accessibility frame in // the logical pixel coordinate system. Therefore, we divide by the `scale` (pixel ratio) to // convert. - UIScreen* screen = reference.bridge->view().window.screen; + UIScreen* screen = containerView.window.screen; // Screen can be nil if the FlutterView is covered by another native view. CGFloat scale = (screen ?: UIScreen.mainScreen).scale; auto result = CGRectMake(rect.x() / scale, rect.y() / scale, rect.width() / scale, rect.height() / scale); - return UIAccessibilityConvertFrameToScreenCoordinates(result, reference.bridge->view()); + return UIAccessibilityConvertFrameToScreenCoordinates(result, containerView); } } // namespace @@ -126,7 +134,7 @@ - (NSString*)accessibilityValue { self.nativeSwitch.on = self.node.flags.isToggled == flutter::SemanticsTristate::kTrue || self.node.flags.isChecked == flutter::SemanticsCheckState::kTrue; - if (![self isAccessibilityBridgeAlive]) { + if (!self.bridge) { return nil; } else { return self.nativeSwitch.accessibilityValue; @@ -156,7 +164,10 @@ - (instancetype)initWithBridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)br [_scrollView setShowsVerticalScrollIndicator:NO]; [_scrollView setContentInset:UIEdgeInsetsZero]; [_scrollView setContentInsetAdjustmentBehavior:UIScrollViewContentInsetAdjustmentNever]; - [self.bridge->view() addSubview:_scrollView]; + UIView* containerView = self.bridgeView; + if (containerView) { + [containerView addSubview:_scrollView]; + } } return self; } @@ -189,7 +200,7 @@ - (id)nativeAccessibility { // private methods - (float)scrollExtentMax { - if (![self isAccessibilityBridgeAlive]) { + if (!self.bridge) { return 0.0f; } float scrollExtentMax = self.node.scrollExtentMax; @@ -202,7 +213,7 @@ - (float)scrollExtentMax { } - (float)scrollPosition { - if (![self isAccessibilityBridgeAlive]) { + if (!self.bridge) { return 0.0f; } float scrollPosition = self.node.scrollPosition; @@ -256,6 +267,7 @@ @interface SemanticsObject () @end @implementation SemanticsObject { + fml::WeakPtr<flutter::AccessibilityBridgeIos> _weakBridge; NSMutableArray<SemanticsObject*>* _children; BOOL _inDealloc; } @@ -270,10 +282,10 @@ - (instancetype)initWithBridge:(fml::WeakPtr<flutter::AccessibilityBridgeIos>)br // The UIView will not necessarily be accessibility parent for this object. // The bridge informs the OS of the actual structure via // `accessibilityContainer` and `accessibilityElementAtIndex`. - self = [super initWithAccessibilityContainer:bridge->view()]; + self = [super initWithAccessibilityContainer:bridge ? bridge->view() : nil]; if (self) { - _bridge = bridge; + _weakBridge = bridge; _uid = uid; _children = [[NSMutableArray alloc] init]; _childrenInHitTestOrder = [[NSArray alloc] init]; @@ -300,6 +312,10 @@ - (void)dealloc { #pragma mark - Semantic object property accesser +- (NSArray<SemanticsObject*>*)children { + return [_children copy]; +} + - (void)setChildren:(NSArray<SemanticsObject*>*)children { for (SemanticsObject* child in _children) { child.parent = nil; @@ -326,8 +342,13 @@ - (BOOL)hasChildren { #pragma mark - Semantic object method -- (BOOL)isAccessibilityBridgeAlive { - return self.bridge.get() != nil; +- (flutter::AccessibilityBridgeIos*)bridge { + return _weakBridge.get(); +} + +- (UIView*)bridgeView { + flutter::AccessibilityBridgeIos* bridge = [self bridge]; + return bridge ? bridge->view() : nil; } - (void)setSemanticsNode:(const flutter::SemanticsNode*)node { @@ -432,13 +453,17 @@ - (NSAttributedString*)createAttributedStringFromString:(NSString*)string } - (void)showOnScreen { - self.bridge->DispatchSemanticsAction(self.uid, flutter::SemanticsAction::kShowOnScreen); + flutter::AccessibilityBridgeIos* bridge = self.bridge; + if (!bridge) { + return; + } + bridge->DispatchSemanticsAction(self.uid, flutter::SemanticsAction::kShowOnScreen); } #pragma mark - UIAccessibility overrides - (BOOL)isAccessibilityElement { - if (![self isAccessibilityBridgeAlive]) { ... [truncated]
← Back to Alerts View on GitHub →