← All reports

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

MediumWebKit UIProcess iOS keyboard eventsUAF

cb90ddf

Medium. UIProcess 내부에서 event가 해제되거나 재배치되면 memory safety 문제에 해당합니다. 다만 regression test는 UIKit 쪽 private method를 swizzling해야만 재진입에 도달합니다. web content에서 유발하는 경로도 아직 확인되지 않았습니다. crash 이상으로 확장하려면 해제된 Deque buffer까지 제어할 수 있어야 합니다.

원소에 대한 reference를 외부로 전달하는 event queue가 있습니다. 이런 queue는 reference가 살아 있는 동안 queue가 변경되지 않아야만 안전합니다. iOS에서 WebPageProxy(UIProcess에서 웹 페이지 하나를 나타내는 객체)는 하드웨어 키 입력을 keyEventQueue에 쌓아 둡니다. 이 queue는 NativeWebKeyboardEvent(platform key event를 감싼 wrapper)를 담는 Deque입니다. WebContent process가 UI 쪽에 키 해석을 요청하면, front event를 대상으로 UIKit text-input 처리가 동기적으로 수행됩니다. 이때 해당 호출이 반환될 때까지 front event가 유효하고 위치도 유지된다는 전제가 깔려 있습니다.

관전 포인트: 키보드 처리 도중 web view로 재진입하는 코드가 있으면, UI process가 아직 해석 중인 key event가 해제되거나 재배치될 수 있습니다. 그 결과 host app이 crash합니다. 그 이상의 결과로 이어지려면 buffer 재사용을 제어할 수 있어야 합니다.

WebPageProxy::interpretKeyEvent에서 key event를 해석하기 전에 front event를 복사합니다. 기존 함수는 keyEventQueue의 front 원소에 대한 reference를 중첩된 동기 해석 호출에 전달했습니다. 이 queue는 NativeWebKeyboardEvent를 값으로 저장하는 Deque입니다. 해당 호출은 UIKit text-input 처리를 수행하는데, 이 과정에서 WebPageProxy로 재진입할 수 있습니다. 이때 keyEventHandlingCompleted()는 takeFirst()로 front 원소를 제거합니다. 한편 handleKeyboardEvent()는 원소를 추가하면서 backing store를 재할당할 수 있습니다. UPDATE: main에서는 320030@main (Bug 322699)이 이미 다른 방식으로 이 문제를 수정했습니다. keyEventQueue는 이제 Ref<NativeWebKeyboardEvent>를 보관하고, protect(firstQueuedKeyEvent())가 중첩 호출 동안 event를 유지합니다. 또한 NativeWebKeyboardEvent는 더 이상 복사할 수 없습니다. 이번에는 regression test만 merge 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); // _interpretKeyEvent에 도달했음을 증명합니다. queue에 있는 event에서 ASan use-after-free가 없으면 통과합니다.
+}

이 commit은 regression test만 반영하는 backport입니다. production fix는 이미 main에 들어가 있습니다. diff에서 수정된 파일은 하나뿐입니다. 먼저 private 클래스인 WKKeyboardScrollViewAnimator와 그 -beginWithEvent:scrollView: method가 forward declaration되었고, 이어서 ReentrantKeyEventDuringInterpretKeyEventDoesNotCrash가 추가되었습니다. 테스트는 autofocus된 input에 focus를 둔 상태에서 -[WKKeyboardScrollViewAnimator beginWithEvent:scrollView:]를 swizzling합니다. 이 method는 중첩된 해석 과정 안에서 실행됩니다. swizzling된 block은 처음 호출될 때 -[WKWebView handleKeyEvent:completion:]를 32번 호출합니다. 원래의 front event가 아직 해석되는 도중에, 각 호출이 keyEventQueue에 event를 하나씩 추가하게 됩니다. 테스트는 didReenter를 검사해 중첩 경로에 도달했음을 확인하고, use-after-free 탐지는 ASan에 맡깁니다.

두 fix는 접근 방식이 다릅니다. commit message에 따르면, 원래의 safari-7625 fix (316606.241)는 호출 전에 front NativeWebKeyboardEvent를 stack에 복사했습니다. 반면 main fix는 queue를 Ref<NativeWebKeyboardEvent> 기반으로 바꾸고 front 원소를 protect합니다.

Container를 변경할 수 있는 동기 재진입 callback 동안, 크기가 늘어나는 by-value container의 원소 reference를 계속 보유하는 패턴.

이 코드가 있는 위치. WebKit iOS 키보드 pipeline 중 UIProcess 쪽 절반에 해당하며, host application 자체에서 실행됩니다. sandbox가 적용된 WebContent process에 속한 코드가 아닙니다.

Key event 흐름. 하드웨어 키 입력은 -[WKWebView handleKeyEvent:completion:]로 들어오고, 이 method가 WebPageProxy::handleKeyboardEvent()를 호출합니다. 여기서 event가 keyEventQueue에 추가되고 WebContent process로 전송됩니다. WebContent process가 UI 쪽에 키 해석(text input, editing command, 키보드 스크롤)을 요청하는 경우가 있습니다. 이때 WebPageProxy::interpretKeyEvent()가 WKContentView/UIKit text-input 처리로 동기 호출을 수행합니다. 키보드 스크롤은 WKKeyboardScrollViewAnimator를 거칩니다. 이후 WebContent process가 처리 완료를 알리면, keyEventHandlingCompleted()가 front event를 제거하고 해당 completion을 호출합니다. commit message에 따르면 제거에는 takeFirst()가 사용됩니다.

재진입. UIKit text-input 처리는 바깥 호출이 반환되기 전에 WKWebView와 WebPageProxy로 동기적으로 다시 진입할 수 있습니다. 예를 들어 key event를 추가로 dispatch하거나, 대기 중인 event를 완료 처리하는 경우가 이에 해당합니다.

WTF::Deque<T>와 NativeWebKeyboardEvent. Deque는 ring buffer 기반 container로, 원소를 하나의 heap backing store 안에 inline으로 저장합니다. capacity를 넘어 원소를 추가하면 더 큰 buffer를 할당하고, 원소를 옮긴 뒤 기존 buffer를 해제합니다. 한편 takeFirst()는 front 원소를 move로 꺼낸 뒤 해당 slot을 파괴합니다. NativeWebKeyboardEvent는 platform keyboard event를 감싸는 wrapper입니다. iOS에서는 cross-platform WebKeyboardEvent 필드와 함께 RetainPtr<::WebEvent>(m_nativeEvent)를 보관합니다.

Ref와 protect(). Ref<T>는 WebKit의 non-null, reference-counted smart pointer입니다. protect(x)는 임시 strong reference를 생성해, refcount 기반 객체가 해당 scope 동안 해제되지 않도록 유지합니다.

commit message에 따르면, interpretKeyEvent()는 keyEventQueue의 front 원소에 대한 reference를 중첩된 해석 호출에 전달했습니다. 영향받는 branch에서는 이 queue가 NativeWebKeyboardEvent를 값으로 저장합니다. 즉 container 원소에 대한 dangling reference를 통해 use-after-free가 발생하는 유형입니다. 다만 제공된 source에서 interpretKeyEvent의 패치 이전 본문과 keyEventQueue 선언은 잘린 부분에 속해 있습니다. 그래서 값 저장 방식은 commit message의 내용을 그대로 인용합니다. 한편 제공된 NativeWebKeyboardEvent.h는 main의 설계를 반영하고 있습니다. 모든 platform 생성자가 static Ref<NativeWebKeyboardEvent> create(...) 형태이고, iOS 변형은 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

두 재진입 경로 중 어느 쪽이든 결과는 같습니다. 해석 frame이 아직 stack에 남아 있는 동안, 사용 중인 reference가 파괴된 객체나 해제된 buffer를 가리키게 됩니다.

regression test는 그중 append 경로를 검증합니다.

  1. a에 대한 key-down이 handleKeyEvent:completion:를 통해 autofocus된 input으로 전송됩니다. WebPageProxy는 이를 queue에 넣고 해석을 시작합니다.
  2. 해석 과정이 swizzling된 -beginWithEvent:scrollView:에 도달합니다. 테스트 주석에 따르면 이는 _interpretKeyEvent에 동기적으로 도달했음을 나타냅니다.
  3. block은 처음 진입할 때 key-down 32개를 추가로 전송합니다. 원래 event가 아직 해석되는 중에 각 event가 keyEventQueue에 추가됩니다.
  4. queue가 WTF::Deque의 최소 capacity(약 16) 근처에서 시작한다면, 32번의 추가는 capacity를 초과해 backing store 재할당을 강제합니다. 실제로 초과하는지는 재진입 시점의 queue 상태에 달려 있습니다.
  5. ASan 환경에서는 stale event에 대한 이후 접근이 테스트를 중단시킵니다. 또한 didReenter는 재진입 없이 테스트가 무의미하게 통과하는 경우를 막습니다.

web content에서 이 재진입에 도달하는 경로는 아직 확인되지 않았습니다. 테스트는 swizzling된 WKKeyboardScrollViewAnimator method를 통해 재진입을 유발합니다. 직접적인 영향은 UIProcess crash, 즉 app 수준의 denial of service입니다. 그 이상으로 확장하려면 재진입 시의 key sequence를 제어해야 합니다. 동시에 해제된 Deque buffer를 attacker 영향 아래의 데이터로 재점유할 수 있어야 합니다.

UIProcess는 WebKit에서 권한이 가장 높은 process이자 host application 그 자체입니다. 이 vulnerability는 바로 그 UIProcess의 memory safety를 약화시킵니다. 여기서 깨진 invariant는, 해석 중인 event 객체가 해석 구간 전체에 걸쳐 살아 있고 위치도 유지되어야 한다는 것입니다.

Insight: WebKit은 이 문제를 두 번 수정했고, 두 방식의 차이는 참고할 만합니다. commit message에 따르면 branch fix는 event를 stack에 snapshot합니다. 이는 sendKeyEvent() 전에 keyEventQueue.first()를 복사하는 keyEventHandlingCompleted()의 방식을 따른 것입니다. 반면 main fix는 queue 원소를 heap 기반 refcount 객체로 바꿨습니다. 그래서 재할당이 일어나도 원소가 이동하지 않고, protect()는 takeFirst() 이후에도 유효합니다. commit message에는 NativeWebKeyboardEvent를 더 이상 복사할 수 없다는 내용도 있습니다. 이 변경은 by-value queue가 다시 도입되는 것을 막아 줄 것으로 보입니다. touch 경로는 이미 이 모델을 따르고 있었습니다(WebPageProxyInternals.h의 QueuedTouchEvents가 Ref<NativeWebTouchEvent>를 보관). 결국 키보드 queue만 뒤처져 있었던 셈입니다.