[iOS] Fix potential use-after-free in a11y bridge channel handler (#189637)
`AccessibilityBridge`'s constructor registers a message handler Obj-C
block on `accessibility_channel_` (the "flutter/accessibility" channel)
that calls the class's `HandleEvent(...)` method directly:
[accessibility_channel_ setMessageHandler:^(id message, FlutterReply
reply) {
HandleEvent((NSDictionary*)message); // Implicit this capture.
}];
This implicitly captures `this` as a raw C++ pointer. ARC manages the
block and any captured Obj-C objects, but has no idea about the raw C++
`this` pointer and we do nothing to keep the `AccessibilityBridge` alive
or to null out `this` when it's destroyed. If a message arrives on this
channel while or after the bridge is being torn down (e.g. during engine
shutdown), we'll dereference the (now garbage) dangling
`AccessibilityBridge*`.
We fix this by capturing `fml::WeakPtr<AccessibilityBridge> weak_self =
GetWeakPtr()` by value in the block instead, and checking it before
calling `HandleEvent`. The `weak_factory_` is already declared last in
the `AccessibilityBridge` class member list, so it'll be invalidated
first in the class destructor, so we're not at risk of using the bridge
in a half torn-down state. Messages arriving anytime after the start of
the destructor are now handled as a no-op.
I've added a test which captures the message handler registered on the
channel, destroys the `AccessibilityBridge`, and invokes the captured
handler to confirm it no longer touches dealloc'ed memory.
Issue: b/525528671
Related: https://github.com/flutter/flutter/pull/189630
<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->
## 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.mddiff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm
index 202d54d..38cf124 100644
--- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm
+++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm
@@ -56,8 +56,11 @@
initWithName:@"flutter/accessibility"
binaryMessenger:platform_view->GetOwnerViewController().engine.binaryMessenger
codec:[FlutterStandardMessageCodec sharedInstance]];
+ fml::WeakPtr<AccessibilityBridge> weak_self = GetWeakPtr();
[accessibility_channel_ setMessageHandler:^(id message, FlutterReply reply) {
- HandleEvent((NSDictionary*)message);
+ if (weak_self) {
+ weak_self->HandleEvent((NSDictionary*)message);
+ }
}];
}
diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm
index 6f92997..1074e02 100644
--- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm
+++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm
@@ -5,7 +5,9 @@
#import <OCMock/OCMock.h>
#import <XCTest/XCTest.h>
+#import "flutter/fml/message_loop.h"
#import "flutter/fml/thread.h"
+#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterBinaryMessenger.h"
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterPlatformViews.h"
#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner+FML.h"
@@ -2519,4 +2521,69 @@
XCTAssertNil([rootObject _accessibilityHitTest:CGPointZero withEvent:nil]);
}
+- (void)testAccessibilityChannelCallbackAfterBridgeDestruction {
+ flutter::MockDelegate mock_delegate;
+ // PlatformViewIOS/AccessibilityBridge need to be called on the platform thread.
+ // Since the platform thread and UI thread are merged; we use the test's main thread.
+ fml::MessageLoop::EnsureInitializedForCurrentThread();
+ auto thread_task_runner = fml::MessageLoop::GetCurrent().GetTaskRunner();
+ flutter::TaskRunners runners(/*label=*/self.name.UTF8String,
+ /*platform=*/thread_task_runner,
+ /*raster=*/thread_task_runner,
+ /*ui=*/thread_task_runner,
+ /*io=*/thread_task_runner);
+ auto platform_view = std::make_unique<flutter::PlatformViewIOS>(
+ /*delegate=*/mock_delegate,
+ /*rendering_api=*/mock_delegate.settings_.enable_impeller
+ ? flutter::IOSRenderingAPI::kMetal
+ : flutter::IOSRenderingAPI::kSoftware,
+ /*platform_views_controller=*/nil,
+ /*task_runners=*/runners,
+ /*worker_task_runner=*/nil,
+ /*is_gpu_disabled_sync_switch=*/std::make_shared<fml::SyncSwitch>());
+
+ id mockEngine = OCMClassMock([FlutterEngine class]);
+ id mockFlutterViewController = OCMClassMock([FlutterViewController class]);
+ id mockBinaryMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
+
+ OCMStub([mockFlutterViewController engine]).andReturn(mockEngine);
+ OCMStub([mockEngine binaryMessenger]).andReturn(mockBinaryMessenger);
+
+ // AccessibilityBridge destructor calls clearState, which reads viewIfLoaded on the view
+ // controller. Stub it so bridge.reset() below doesn't hit an unstubbed selector.
+ OCMStub([mockFlutterViewController viewIfLoaded]).andReturn(nil);
+
+ // Prevent SetOwnerViewController from taking the attachView path, which touches the real
+ // view/CALayer/IOSSurface mechanics and isn't relevant here, since we only care about the channel
+ // handler.
+ OCMStub([mockFlutterViewController isViewLoaded]).andReturn(NO);
+
+ __block FlutterBinaryMessageHandler capturedHandler = nil;
+ OCMStub([mockBinaryMessenger setMessageHandlerOnChannel:@"flutter/accessibility"
+ binaryMessageHandler:[OCMArg checkWithBlock:^BOOL(id obj) {
+ if (obj) {
+ capturedHandler = [obj copy];
+ }
+ return YES;
+ }]]);
+
+ platform_view->SetOwnerViewController(mockFlutterViewController);
+
+ auto bridge = std::make_unique<flutter::AccessibilityBridge>(
+ /*view_controller=*/mockFlutterViewController,
+ /*platform_view=*/platform_view.get(),
+ /*platform_views_controller=*/nil);
+
+ XCTAssertNotNil(capturedHandler);
+
+ // Destroy the AccessibilityBridge then invoke the message handler block to ensure no-op.
+ bridge.reset();
+ NSDictionary* event = @{@"type" : @"announce", @"data" : @{@"message" : @"test"}};
+ NSData* messageData = [[FlutterStandardMessageCodec sharedInstance] encode:event];
+ if (capturedHandler) {
+ capturedHandler(messageData, ^(NSData* reply){
+ });
+ }
+}
+
@end