← All reports

[4] WebPasteboardProxy accepted remote frame IDs outside the copied subtree

MediumWebKit UIProcessCrossOrigin

A frame ID is a name, not a permission — the pasteboard disagreed.

Severity: Medium | Component: WebKit UIProcess WebPasteboardProxy | 69aa562

Medium 등급입니다. Site Isolation이 지키려는 경계가 바로 이 취약점으로 깨집니다. 손상된 renderer가 피해자 프로세스에서 별도의 memory-safety 버그 없이도 다른 사이트의 직렬화된 DOM을 얻을 수 있습니다. 다만 사전 compromise가 전제 조건이고 결과도 disclosure에 그치며 memory-corruption primitive로 이어지지 않기 때문에 High band에는 들지 않습니다.

Site Isolation 환경에서는 서로 다른 사이트의 frame이 각기 별도의 content process에 호스팅되므로, 페이지 전체의 DOM을 한 프로세스가 모두 갖고 있지 않습니다. 이런 분할 경계를 넘나드는 페이지를 복사하려면 UI process가 aggregator 역할을 맡아야 합니다. 전송하는 프로세스는 자신이 로컬로 갖고 있는 frame들의 archive와 out-of-process frame들의 식별자 목록을 제출하고, UI process가 나머지를 모아옵니다. FrameIdentifier는 프로세스 전역 handle로, UI process가 브라우저의 모든 frame을 아우르는 registry를 통해 이를 resolve할 수 있습니다. 여기서 지켜져야 할 보장은, 한 프로세스가 이 aggregator를 통해 접근할 수 있는 frame이 자신이 실제로 복사 중인 subtree에 한정되어야 한다는 점입니다.

관전 포인트: 손상된 renderer가 브라우저 안의 살아있는 아무 frame이나 자신의 복사 작업의 remote subframe인 것처럼 지정하면, UI process는 그 frame의 DOM을 attacker가 지정한 pasteboard에 직렬화해 써넣게 됩니다.

기존 WebPasteboardProxy는 전달받은 remote frame ID에 대해 subtree 검사를 전혀 수행하지 않았습니다. 그 결과 무관한 cross-site remote frame의 콘텐츠가 pasteboard에 기록될 수 있었습니다. 이를 해결하기 위해, WebPasteboardProxy 내부에서 사용되는 모든 remote frame ID가 전달된 root frame ID의 하위 노드인지 확인하는 message check가 추가되었습니다.

Source/WebKit/UIProcess/Cocoa/WebPasteboardProxyCocoa.mm

+// Only allow exploring frames that descend from the provided root frame.
+static bool validateFrameIdentifiers(FrameIdentifier rootFrameIdentifier, const HashMap<FrameIdentifier, Ref<WebCore::LegacyWebArchive>>& localFrameArchives, const Vector<FrameIdentifier>& remoteFrameIdentifiers)
+{
+ auto isInSubtree = [&](WebFrameProxy& frame) {
+ for (RefPtr ancestor = &frame; ancestor; ancestor = ancestor->parentFrame()) {
+ if (ancestor->frameID() == rootFrameIdentifier)
+ return true;
+ }
+ return false;
+ };
+
+ auto isAllowed = [&](FrameIdentifier identifier) {
+ if (identifier == rootFrameIdentifier)
+ return true;
+ RefPtr frame = WebFrameProxy::webFrame(identifier);
+ return !frame || isInSubtree(*frame);
+ };
+
+ for (auto& [frameIdentifier, archive] : localFrameArchives) {
+ if (!isAllowed(frameIdentifier))
+ return false;
+ Ref protectedArchive = archive;
+ if (auto archiveFrameIdentifier = protectedArchive->frameIdentifier(); archiveFrameIdentifier && !isAllowed(*archiveFrameIdentifier))
+ return false;
+ for (auto subframeIdentifier : protectedArchive->subframeIdentifiers()) {
+ if (!isAllowed(subframeIdentifier))
+ return false;
+ }
+ }
+
+ for (auto identifier : remoteFrameIdentifiers) {
+ if (!isAllowed(identifier))
+ return false;
+ }
+
+ return true;
+}
...
// WebPasteboardProxy::writeWebContentToPasteboard
+ MESSAGE_CHECK(validateFrameIdentifiers(*rootFrameIdentifier, content.localFrameArchives, content.remoteFrameIdentifiers), connection);
 
Ref senderProcess = WebProcessProxy::fromConnection(connection);
auto localFrameArchives = content.localFrameArchives;
createOneWebArchiveFromFrames(senderProcess.get(), *rootFrameIdentifier, WTF::move(localFrameArchives), content.remoteFrameIdentifiers, ...);
...
-void WebPasteboardProxy::writeWebArchiveToPasteBoard(..., CompletionHandler<void(int64_t)>&& completionHandler)
+void WebPasteboardProxy::writeWebArchiveToPasteBoard(..., CompletionHandler<void(WriteWebArchiveToPasteBoardResult, int64_t)>&& completionHandler)
{
 
- MESSAGE_CHECK_COMPLETION(!pasteboardName.isEmpty(), connection, completionHandler(0));
+ MESSAGE_CHECK_COMPLETION(!pasteboardName.isEmpty(), connection, completionHandler(WriteWebArchiveToPasteBoardResult::FailureOther, 0));
+ MESSAGE_CHECK_COMPLETION(validateFrameIdentifiers(rootFrameIdentifier, localFrameArchives, remoteFrameIdentifiers), connection, completionHandler(WriteWebArchiveToPasteBoardResult::FailureDueToInvalidFrameIdentifiers, 0));

LayoutTests/http/tests/ipc/resources/write-web-archive-frame-ancestry-check-iframe.html

+ let myFrameID = IPC.frameID[0];
+ let parentFrameID = await new Promise((resolve) => { ... parent.postMessage({ getParentFrameID: true }, '*'); });
+
+ // Forge a WriteWebArchiveToPasteBoard IPC that names an out-of-process frame ID (the parent
+ // frame) as a remote subframe. This should fail with the FailureDueToInvalidFrameIdentifiers
+ // error to prevent writing a cross-process DOM to the pasteboard.
+ let r = IPC.sendSyncMessage('UI', 0, IPC.messages.WebPasteboardProxy_WriteWebArchiveToPasteBoard.name, 1000, [
+ S(pbName),
+ FrameID(myFrameID),
+ u64(0n),
+ u64(1n), FrameID(parentFrameID),
+ ]);

이번 변경은 ancestry validator를 새로 추가하고 이를 두 pasteboard-write 진입점 모두에 연결한 뒤, 거부 여부가 테스트에서 관찰 가능하도록 sync reply 구조를 다시 짰습니다.

새로 추가된 file-static validateFrameIdentifiers()는 두 개의 lambda로 구성됩니다. isInSubtreeWebFrameProxy::parentFrame()을 따라 위로 올라가며 root를 찾고, isAllowed는 해당 identifier가 root와 같거나, WebFrameProxy::webFrame(identifier)가 아무것도 resolve하지 못하거나, resolve된 frame이 root의 subtree 안에 있을 때 이를 허용합니다. pasteboard 경로에 도달하는 모든 identifier가 이 검증을 거칩니다. localFrameArchives의 각 key, 각 archive 자신의 frameIdentifier(), archive의 subframeIdentifiers() 각 항목, 그리고 remoteFrameIdentifiers의 모든 항목이 대상입니다. writeWebContentToPasteboard에는 createOneWebArchiveFromFrames 호출 직전에 MESSAGE_CHECK가 추가되었고, writeWebArchiveToPasteBoard에는 MESSAGE_CHECK_COMPLETION이 추가되었습니다.

거부 여부를 구분할 수 있도록, WriteWebArchiveToPasteBoard의 sync reply가 (int64_t changeCount)에서 (enum:uint8_t WriteWebArchiveToPasteBoardResult result, int64_t changeCount)로 바뀌었습니다. 이를 위해 새로운 Shared/WriteWebArchiveToPasteBoardResult.h.serialization.in이 추가되어 Success / FailureDueToInvalidFrameIdentifiers / FailureOther를 정의합니다. handler 내부의 모든 early return과 그 안에 중첩된 completion lambda들이 이제 result code를 함께 전달하며, WebPlatformStrategies::writeWebArchive는 두 값짜리 reply를 destructure하되 newChangeCount만 반환하도록 유지됩니다. 부수적으로 새 serialization 파일에 대한 빌드 시스템 등록이 이루어졌고, RemotePageProxy::RemotePageProxy에 테스트 전용 hook이 추가되어 ENABLE(IPC_TESTING_API) 하에서 ipcTestingAPIEnabled()ignoreInvalidMessageWhenIPCTestingAPIEnabled() preference가 모두 설정된 경우 setIgnoreInvalidMessageForTesting()을 호출합니다. 이를 통해 layout test가 site-isolated subframe process에서 의도적으로 invalid한 message를 보내더라도 해당 프로세스가 강제 종료되지 않도록 합니다.

호출자가 넘긴 global identifier를 그 자체로 entitlement의 증거로 신뢰하며, 해당 객체가 호출자에게 권한이 있는 subtree 안에 있는지 확인하지 않는 패턴입니다.

Site Isolation. 서로 다른 사이트의 frame은 각각 별도의 WebContent process에 호스팅됩니다. 하나의 process는 자신의 frame에 대한 DOM만 갖고 있으며, cross-site subframe은 로컬에서는 placeholder 형태의 "remote frame"으로 나타납니다.

FrameIdentifier. frame을 가리키는 프로세스 전역 identifier입니다. UI process에서는 WebFrameProxy::webFrame(identifier)가 어떤 identifier든 해당 WebFrameProxy로 resolve할 수 있고, WebFrameProxy::parentFrame()은 UI process가 모든 WebContent process를 아울러 관리하는 authoritative frame tree를 따라 이동합니다.

LegacyWebArchive. WebKit의 직렬화된 페이지 포맷으로, main resource와 subresource, 중첩된 subframe archive로 구성됩니다. 자신이 온 frame의 identifier(frameIdentifier())와 자식 frame들의 identifier(subframeIdentifiers())를 함께 기록하므로, IPC로 전달되는 archive는 message의 top-level vector뿐 아니라 archive 내부에도 frame identifier를 담고 있습니다.

Multi-process archive assembly. 복사 대상이 site-isolated frame tree에 걸쳐 있을 경우, 전송하는 프로세스는 자신의 로컬 frame에 대한 archive와 out-of-process frame들의 FrameIdentifier 목록을 함께 제출합니다. 나머지 조각들은 WebPasteboardProxy::createOneWebArchiveFromFrames가 모읍니다. LegacyWebArchiveCallbackAggregatoraddResult()를 통해 이들을 수집하고, 소멸 시점에 completeFrameArchive(rootFrameIdentifier)를 호출해 subframe archive들을 상위 archive에 결합한 뒤 completion handler를 호출합니다.

MESSAGE_CHECK / MESSAGE_CHECK_COMPLETION. WebKit의 IPC 검증 macro입니다. 검사가 실패하면 해당 message는 malformed로 취급되어 보낸 쪽 connection이 종료됩니다. _COMPLETION variant는 sync reply가 유실되지 않도록 먼저 전달된 completion expression을 실행합니다.

ENABLE(IPC_TESTING_API). 빌드 및 preference로 gate되는 기능으로, layout test에서 JavaScript에 IPC 객체를 노출시켜 손상된 WebContent process를 흉내낼 수 있게 합니다. setIgnoreInvalidMessageForTesting()MESSAGE_CHECK 실패로 인해 테스트 프로세스가 강제 종료되는 것을 막습니다.

Pasteboard naming. pasteboard write는 호출자가 지정한 pasteboardName 문자열로 키가 결정되며, WebPasteboardProxy는 WebContent process를 위한 read 측 기능도 함께 제공합니다.

이 취약점은 IPC로 전달된 identifier에 대한 authorization 누락입니다. memory-safety 버그가 아니라 confused-deputy 성격의 site-isolation bypass이며, 결과적으로 cross-origin information disclosure로 이어집니다.

  WebContent A (127.0.0.1)          WebContent B (localhost)
  ------------------------          ------------------------
  main frame  [F1]                  iframe  [F2]  (child of F1)
                                       |
                                       | WriteWebArchiveToPasteBoard
                                       |   root = F2, remote = [F1]
                                       v
                        UI process: WebPasteboardProxy
                          pre-fix : webFrame(F1) resolves -> archive F1 -> pasteboard
                          post-fix: isInSubtree(F1, root=F2) == false -> reject

패치 이전에 이루어지던 검증은 pasteboard 이름이 비어 있지 않은지, 그리고 webFrame(rootFrameIdentifier)가 null이 아닌지 확인하는 정도였습니다. FrameIdentifier가 전역 registry를 통해 resolve되기 때문에, sender가 message에 적어 넣은 identifier는 그것을 보낸 프로세스나 frame tree상 위치와 무관하게 브라우저 어딘가에 있는 frame으로 resolve됩니다. 위 다이어그램에서 볼 수 있듯, attacker가 지정한 identifier는 선언된 root의 조상일 수도, sibling일 수도 있습니다. 심지어 지정된 frame이 같은 페이지에 속해야 한다는 제약도 없었으므로, 완전히 다른 tab의 frame일 수도 있었습니다. 이 경우 createOneWebArchiveFromFrames는 해당 frame을 resolve해 그 직렬화된 DOM을 attacker가 지정한 pasteboard에 기록되는 archive에 병합했습니다.

이 경로는 일반적인 웹 콘텐츠에서는 도달할 수 없습니다. JavaScript에는 rootFrameIdentifierremoteFrameIdentifiers vector를 직접 선택할 방법이 없으며, 정상적인 producer인 WebPlatformStrategies::writeWebArchive는 두 값 모두 실제로 복사 중인 frame tree로부터 도출합니다. 따라서 이 취약한 상태에 도달하려면 WebContent→UIProcess connection 상에서 임의의 IPC를 보낼 수 있는 능력이 필요합니다. layout test에서는 이 능력을 IPCTestingAPIEnabled=true 설정으로 대체합니다.

이런 능력이 주어졌다는 전제 하에서는, 공격에 별도의 memory grooming이 필요하지 않습니다. attacker가 임의로 정한 pasteboardName, 손상된 process가 정당하게 소유한 frame으로 설정한 rootFrameIdentifier, 빈 localFrameArchives map, 그리고 cross-site 피해자 frame의 identifier를 담은 remoteFrameIdentifiers를 넣어 WriteWebArchiveToPasteBoard를 보내면 됩니다. regression test도 정확히 이 형태를 따릅니다. 127.0.0.1:8000의 최상위 문서가 localhost:8000 iframe을 embed하고 있는데, Site Isolation 하에서 이 iframe은 다른 process에 위치하게 됩니다. iframe은 postMessage를 통해 부모에게 IPC.frameID[0]을 요청한 뒤, sync message를 [String pbName, FrameID(myFrameID), u64(0), u64(1), FrameID(parentFrameID)] 형태로 직접 인코딩해 보냅니다. 여기서 지정된 remote frame은 선언된 root의 조상에 해당하므로, isInSubtree는 parent로부터 위쪽으로 탐색하며 child의 frameID()를 결코 만나지 못합니다. 결과적으로 isAllowed는 false를 반환하고, handler는 cross-process archive를 조립하는 대신 FailureDueToInvalidFrameIdentifiers를 응답하게 됩니다.

결과를 읽어오는 부분은 별도로 gate되어 있습니다. WebPasteboardProxy::accessType()/canAccessPasteboardData()는 사전에 이루어진 grantAccess() 호출이나, 해당 process 안의 페이지가 domPasteAllowed()javaScriptCanAccessClipboard()를 모두 만족하는지에 의존합니다. 따라서 archive를 process 내부에서 다시 읽어오려면 이 gate를 통과해야 합니다. 반면 system pasteboard로의 disclosure, 그리고 사용자가 이후 어딘가에 붙여넣는 시점까지 이어지는 유출 자체는 이 gate의 영향을 받지 않습니다. Escalation은 disclosure 이상으로 이어지지 않는 것으로 보입니다. attacker가 제어할 수 있는 length, offset, buffer-size 필드가 전혀 없으므로, 이 취약점이 memory-corruption primitive로 전환될 가능성은 확인되지 않습니다.

이 취약점은 UI process가 지켜야 할 Site Isolation trust boundary를 약화시킵니다. 여기서 지켜져야 할 전제는, WebContent process가 UI process로 하여금 자신이 정당하게 호스팅하거나 복사 중인 frame의 하위에 있는 frame에 대해서만 동작하도록 만들 수 있다는 것입니다. 패치 이전에는 신뢰할 수 없는 sender가 보낸 raw FrameIdentifier가 브라우저 전역 registry의 어떤 frame에든 도달할 수 있는 충분한 entitlement로 취급되었습니다. 한 WebContent process에서 code execution을 확보한 attacker라면, 피해자 process에서 별도의 memory-safety 버그를 트리거하지 않고도 UI process가 무관한 cross-site frame의 DOM을 직렬화하도록 만들 수 있었습니다. 이는 그 frame에 렌더링된 콘텐츠와 그 안의 각종 secret을 포함합니다.

Insight: 이 취약점은 Site Isolation retrofit에서 전형적으로 나타나는 형태의 버그입니다. isolation 도입 이전부터 존재하던 IPC message가 FrameIdentifier를 순수한 데이터로 실어 날랐는데, 이 message가 multi-process frame tree를 기술하도록 확장되면서 identifier들이 대응하는 entitlement check를 얻지 못한 채 그 자체로 capability가 되어버린 셈입니다. 이번 fix에서 눈여겨볼 지점이 두 가지 있습니다. 먼저, 검증 대상이 message signature에 명시된 identifier뿐 아니라 직렬화된 payload 내부에 중첩된 identifier까지 포함합니다. 이는 completeFrameArchive가 재귀적으로 subframeIdentifiers()를 확장해 더 많은 frame을 끌어오기 때문에 필요한 조치입니다. 또한 isAllowed는 UI process가 resolve할 수 없는 identifier에 대해서는 fail-open으로 동작합니다 (return !frame || isInSubtree(*frame)). 즉 실제로 제공되는 보장은 "살아 있고 resolve 가능하며 subtree 밖에 있는 frame은 끌어올 수 없다"는 것이지, "subtree 안의 frame만 지정할 수 있다"는 더 강한 보장은 아닙니다. commit message의 표현보다는 다소 약한 post-condition이며, teardown race를 함께 고려할 때 유의할 부분입니다.