← All reports

[2] UIProcess keyEventQueue dangling reference during key-event interpretation

MediumWebKit UIProcess iOS keyboard eventsUAF

cb90ddf

Medium. A freed or relocated event inside the UIProcess is memory-safety territory. The regression test reaches the re-entry only by swizzling a private UIKit-side method, and no web-content trigger has been established. Going beyond a crash would also need control of the freed Deque buffer.

Event queues that hand out references to their elements are only safe if nothing mutates the queue while the reference is live. On iOS, WebPageProxy (the UIProcess object representing one web page) queues hardware key presses in keyEventQueue, a Deque of NativeWebKeyboardEvent (a wrapper around the platform key event). When the WebContent process needs the UI side to interpret a key, it drives UIKit text-input handling synchronously on the front event. The expectation is that the front event stays valid and stationary until that call returns.

The angle: code that re-enters the web view during keyboard handling can free or relocate the key event the UI process is still interpreting, which crashes the host app and would need buffer-reuse control to become anything more.

Copy the front key event before interpreting it in WebPageProxy::interpretKeyEvent. The function passed a reference to the front element of keyEventQueue, a Deque storing NativeWebKeyboardEvents by value, into a nested synchronous interpretation call. That call runs UIKit text-input handling, which can re-enter WebPageProxy: keyEventHandlingCompleted() removes the front element via takeFirst(), and handleKeyboardEvent() appends, which can reallocate the backing store. UPDATE: on main, 320030@main (Bug 322699) already fixed this differently. keyEventQueue now holds Ref<NativeWebKeyboardEvent>, protect(firstQueuedKeyEvent()) keeps the event alive across the nested call, and NativeWebKeyboardEvent can no longer be copied. Only the regression test is merged back.

Tools/TestWebKitAPI/Tests/WebKit/WKWebView/ios/KeyboardInputTestsIOS.mm

+@interface WKKeyboardScrollViewAnimator : NSObject
+- (BOOL)beginWithEvent:(id)event scrollView:(id)scrollView;
+@end
...
+TEST(KeyboardInputTests, ReentrantKeyEventDuringInterpretKeyEventDoesNotCrash)
+{
+ RetainPtr inputDelegate = adoptNS([TestInputDelegate new]);
+ RetainPtr webView = webViewWithAutofocusedInput(inputDelegate);
+ __block bool didReenter = false;
+ InstanceMethodSwizzler swizzler {
+ NSClassFromString(@"WKKeyboardScrollViewAnimator"),
+ @selector(beginWithEvent:scrollView:),
+ imp_implementationWithBlock(^BOOL(id, WebEvent *, id) {
+ if (std::exchange(didReenter, true))
+ return NO;
+ for (unsigned i = 0; i < 32; ++i) {
+ RetainPtr extraEvent = adoptNS([[WebEvent alloc] initWithKeyEventType:WebEventKeyDown ... characters:@"b" ...]);
+ [webView handleKeyEvent:extraEvent completion:^(WebEvent *, BOOL) { }];
+ }
+ return NO;
+ })
+ };
+ RetainPtr keyDown = adoptNS([[WebEvent alloc] initWithKeyEventType:WebEventKeyDown ... characters:@"a" ...]);
...
+ [webView handleKeyEvent:keyDown completion:^(WebEvent *, BOOL) {
+ done = true;
+ }];
+ TestWebKitAPI::Util::run(&done);
+ EXPECT_TRUE(didReenter); // Proves _interpretKeyEvent was reached; passes if no ASan use-after-free in the queued event.
+}

This commit is a backport that lands only the regression test; the production fix is already on main. The diff touches a single file. It forward-declares the private WKKeyboardScrollViewAnimator class and its -beginWithEvent:scrollView: method, then adds ReentrantKeyEventDuringInterpretKeyEventDoesNotCrash. The test focuses an autofocused input and swizzles -[WKKeyboardScrollViewAnimator beginWithEvent:scrollView:], which runs inside the nested interpretation. On its first invocation the swizzled block calls -[WKWebView handleKeyEvent:completion:] 32 times, each appending an event to keyEventQueue while the original front event is still being interpreted. The test asserts didReenter to prove the nested path was reached and relies on ASan to flag any use-after-free.

The two fixes differ. According to the commit message, the original safari-7625 fix (316606.241) copied the front NativeWebKeyboardEvent onto the stack before the call. The main fix converts the queue to Ref<NativeWebKeyboardEvent> and protects the front element.

Reference into a growable by-value container held across a synchronous re-entrant callback that can mutate the container.

Where this lives. This is the UIProcess half of WebKit's iOS keyboard pipeline, which runs in the host application itself. It is not in the sandboxed WebContent process.

Key event flow. Hardware key presses arrive at -[WKWebView handleKeyEvent:completion:], which calls WebPageProxy::handleKeyboardEvent(). That appends the event to keyEventQueue and sends it to the WebContent process. When the WebContent process needs the UI side to interpret the key (text input, editing commands, keyboard scrolling), WebPageProxy::interpretKeyEvent() drives a synchronous call into WKContentView/UIKit text-input handling. Keyboard scrolling goes through WKKeyboardScrollViewAnimator. When the WebContent process reports completion, keyEventHandlingCompleted() removes the front event (via takeFirst(), per the commit message) and invokes its completion.

Re-entrancy. UIKit text-input handling is free to call back into WKWebView and WebPageProxy synchronously, for example by dispatching further key events or completing pending ones, before the outer call returns.

WTF::Deque<T> and NativeWebKeyboardEvent. Deque is a ring-buffer container that stores elements inline in one heap backing store. Appending past capacity allocates a larger buffer, moves the elements and frees the old one, and takeFirst() moves the front element out and destroys its slot. NativeWebKeyboardEvent wraps a platform keyboard event; on iOS it holds a RetainPtr<::WebEvent> (m_nativeEvent) beside the cross-platform WebKeyboardEvent fields.

Ref and protect(). Ref<T> is WebKit's non-null, reference-counted smart pointer. protect(x) creates a temporary strong reference that keeps a refcounted object alive for a scope.

This is a use-after-free through a dangling container-element reference. Per the commit message, interpretKeyEvent() passed a reference to the front element of keyEventQueue into the nested interpretation call, and on the affected branch the queue stores NativeWebKeyboardEvent by value. The pre-fix body of interpretKeyEvent and the keyEventQueue declaration sit in the truncated part of the supplied source, so the by-value storage is relayed from the commit message. The NativeWebKeyboardEvent.h supplied reflects the main design: every platform constructor is a static Ref<NativeWebKeyboardEvent> create(...) and the iOS variant holds const RetainPtr<::WebEvent> m_nativeEvent.

  interpretKeyEvent()                         keyEventQueue (Deque, by value)
  ───────────────────                         ───────────────────────────────
  ref = &queue.first()  ───────────────────►  [E0][  ][  ]...   buffer B1
  call UIKit interpretation
     └─► beginWithEvent:scrollView: (re-entry)
           handleKeyboardEvent() x N ───────►  append past capacity: move to B2, free B1
           keyEventHandlingCompleted() ─────►  takeFirst(): E0 slot destroyed
  interpretation still dereferences ref ───►  ref -> freed B1 / dead slot   <-- failure window

Either re-entrant path leaves the in-flight reference pointing at a destroyed object or a freed buffer while the interpretation frame is still on the stack.

The regression test exercises the append path:

  1. A key-down for a is sent through handleKeyEvent:completion: to an autofocused input, and WebPageProxy enqueues it and drives interpretation.
  2. Interpretation reaches the swizzled -beginWithEvent:scrollView:. The test comment says this proves _interpretKeyEvent was reached synchronously.
  3. On first entry the block sends 32 more key-downs, each appending to keyEventQueue while the original event is still the one being interpreted.
  4. 32 appends exceed the queue's capacity if it starts near WTF::Deque's minimum capacity (about 16), forcing a backing-store reallocation. Whether they do depends on the queue's state at re-entry.
  5. Under ASan, any later access to the stale event aborts the test, and didReenter guards against a vacuous pass.

No web-content-reachable path into this re-entry has been established. The test drives it through a swizzled WKKeyboardScrollViewAnimator method. The immediate effect is a UIProcess crash, an app-level denial of service. Anything beyond that would require controlling the re-entrant key sequence and reclaiming the freed Deque buffer with attacker-influenced data.

This vulnerability weakens memory safety in the UIProcess, the most privileged WebKit process and the host application itself. The violated invariant is that an event object under interpretation stays alive and stationary for the whole interpretation window.

Insight: WebKit fixed this twice, and the difference is instructive. According to the commit message, the branch fix snapshots the event on the stack, following keyEventHandlingCompleted(), which copies keyEventQueue.first() before sendKeyEvent(). The main fix makes queue elements heap-refcounted, so reallocation can no longer move them and protect() survives a takeFirst(). The commit message also says NativeWebKeyboardEvent is no longer copyable, which would stop by-value queues from creeping back. The touch path already followed this model (QueuedTouchEvents holds Ref<NativeWebTouchEvent> in WebPageProxyInternals.h), so the keyboard queue was the laggard.