← All reports

Missing validation for incoming file paths from web content process when attachment elements are enabled

HighWebKit UIProcess — attachment element IPC surfaceSandboxEscape

CVE: CVE-2026-28962 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may disclose sensitive user information Apple's description: This issue was addressed with improved access restrictions. Credit: Luke Francis, Vaagn Vardanian, kwak kiyong / kakaogames, Vitaly Simonovich, Adel Bouachraoui, greenbynox

c6cd4e0 | Bugzilla 309698

High. 특권 측에서는 attachment identifier가 well-formed인지만 검증했을 뿐, 그 file path가 어디서 왔는지는 전혀 묻지 않았습니다. Sandbox 경계를 넘나드는 전형적인 confused deputy 패턴에 해당합니다. Full file read로 확장될 수 있는지는 caller가 어떤 attachment read-back flow에 도달할 수 있는지에 달려 있지만, 특권 측에서의 file-open 자체는 어느 경우든 그대로 일어납니다.

Multi-process 브라우저는 권한을 의도적으로 분리합니다. Renderer는 filesystem 접근 권한이 거의 없는 상태로 악의적인 콘텐츠를 파싱하고, 별도의 privileged UI process가 사용자의 실제 권한을 쥔 채 사용자가 무언가를 붙여넣거나 드래그할 때마다 파일 단위로 좁은 권한을 내어줍니다. WebKit의 attachment element — 편집 가능한 콘텐츠 안에 파일을 담는 <attachment> 노드 — 는 정확히 이 경계선 위에 놓여 있습니다. Renderer는 항상 불투명한 identifier만 쥐고 있고, 실제 backing file은 UI process가 보유하기 때문입니다. 이 구조가 안전하려면, renderer에서 UI process로 넘어가는 path는 반드시 UI process 자신이 먼저 건네준 path여야 한다는 invariant가 지켜져야 합니다.

관전 포인트: attachment 등록 메시지를 직접 구동할 수 있는 페이지라면 사용자가 읽을 수 있는 어떤 파일이든 이름을 지정할 수 있고, 특권 process는 그 파일을 열어 attachment를 바인딩하게 됩니다.

Source/WebKit/UIProcess/WebPageProxy.cpp

void WebPageProxy::registerAttachmentIdentifierFromFilePath(IPC::Connection& connection, ...)
{
MESSAGE_CHECK_BASE(protect(preferences())->attachmentElementEnabled(), connection);
MESSAGE_CHECK_BASE(IdentifierToAttachmentMap::isValidKey(identifier), connection);
+ MESSAGE_CHECK_BASE(WebProcessProxy::fromConnection(connection)->isAllowedAttachmentFilePath(filePath), connection);
 
if (attachmentForIdentifier(identifier))
return;
...
void WebPageProxy::performDragOperation(DragData& dragData, ...)
{
if (!protectedThis->m_mainFrame)
return;
+#if ENABLE(ATTACHMENT_ELEMENT)
+ for (auto& filename : dragData.fileNames())
+ protect(protectedThis->legacyMainFrameProcess())->addAllowedAttachmentFilePath(filename);
+#endif
...
void WebPageProxy::registerAttachmentsFromSerializedData(IPC::Connection& connection, ...)
for (auto& serializedData : data) {
+ MESSAGE_CHECK_BASE(IdentifierToAttachmentMap::isValidKey(serializedData.identifier), connection);
auto identifier = WTF::move(serializedData.identifier);

Source/WebKit/UIProcess/WebProcessProxy.cpp

+#if ENABLE(ATTACHMENT_ELEMENT)
+void WebProcessProxy::addAllowedAttachmentFilePath(const String& filePath)
+{
+ if (!filePath.isEmpty())
+ m_allowedAttachmentFilePaths.add(filePath);
+}
+
+bool WebProcessProxy::isAllowedAttachmentFilePath(const String& filePath) const
+{
+ return m_allowedAttachmentFilePaths.contains(filePath);
+}
+#endif

Source/WebKit/UIProcess/Cocoa/WebPasteboardProxyCocoa.mm

+#if ENABLE(ATTACHMENT_ELEMENT)
+
+static void addAllowedAttachmentFilePaths(const IPC::Connection& connection, std::optional<WebPageProxyIdentifier> pageID, const Vector<String>& paths)
+{
+ if (!pageID)
+ return;
+
+ RefPtr page = WebProcessProxy::webPage(*pageID);
+ if (!page)
+ return;
+
+ for (auto& path : paths)
+ WebProcessProxy::fromConnection(connection)->addAllowedAttachmentFilePath(path);
+}
+
+#endif // ENABLE(ATTACHMENT_ELEMENT)

Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKAttachmentTests.mm

+TEST(WKAttachmentTestsMac, RegisterAttachmentIdentifierFromFilePathWithUnauthorizedPathTerminatesProcess)
+{
+ [webView evaluateJavaScript:
+ @"IPC.sendMessage('UI', IPC.webPageProxyID, IPC.messages.WebPageProxy_RegisterAttachmentIdentifierFromFilePath.name, ["
+ " {type: 'String', value: 'fake-identifier'},"
+ " {type: 'String', value: 'application/octet-stream'},"
+ " {type: 'String', value: '/etc/passwd'}"
+ "])"
+ completionHandler:nil];
+
+ [navigationDelegate waitForWebContentProcessDidTerminate];
+}

이 commit은 두 부분으로 구성되어 있고, 이 둘은 함께 있어야 비로소 의미가 통합니다. 하나는 redemption table이고, 다른 하나는 UI process가 path를 정당하게 건네주는 모든 지점에서 그 table을 채워 넣는 작업입니다.

Table은 WebProcessProxy — 하나의 WebContent process를 대표하는 UI-process 객체 — 위에 자리합니다. 여기에 HashSet<String> m_allowedAttachmentFilePaths 멤버와 accessor 두 개가 추가되었으며, 둘 다 ENABLE(ATTACHMENT_ELEMENT)로 감싸져 있습니다. addAllowedAttachmentFilePath()는 비어 있지 않은 path를 삽입하고, isAllowedAttachmentFilePath() const는 단순히 set membership을 검사합니다. Entry를 제거하는 경로는 없습니다.

Redemption은 WebPageProxy::registerAttachmentIdentifierFromFilePath에서 이루어지는데, 기존에 있던 두 개의 MESSAGE_CHECK_BASE에 세 번째가 추가되었습니다. 순서가 흥미로운 부분입니다.

  Before:                                  After:
  attachmentElementEnabled()?              attachmentElementEnabled()?
    └─► isValidKey(identifier)?              └─► isValidKey(identifier)?
          └─► [filePath unchecked]                 └─► isAllowedAttachmentFilePath(filePath)?
                └─► ensureAttachment()                   └─► ensureAttachment()
                      └─► open(filePath)                       └─► open(filePath)

기존의 두 check는 모두 형태 검사에 불과합니다. Feature flag 하나와 identifier에 대한 well-formedness 테스트일 뿐, filePath가 어디서 왔는지에 대해서는 아무 말도 하지 않습니다. 새로 추가된 check는 provenance 검사이며, 실패하면 메시지를 보낸 web process가 종료됩니다.

Population은 path가 정당하게 발생하는 두 지점에 나뉘어 이루어집니다. Drag-drop: WebPageProxy::performDragOperation이 이제 dragData.fileNames()를 순회하며, Messages::WebPage::PerformDragOperation을 전달하기 전에 각 파일을 해당 frame의 process에 등록합니다. Pasteboard: WebPasteboardProxyCocoa.mm에는 static helper addAllowedAttachmentFilePaths(connection, pageID, paths)가 추가되었습니다. 이 helper는 WebPageProxyIdentifier로부터 page를 조회해 liveness check를 수행한 뒤, WebProcessProxy::fromConnection(connection)에 각 path를 등록합니다. 이 helper는 반환되는 pathnames에 대해 getPasteboardPathnamesForType에서 호출되고, 각 item의 pathsForFileUpload에 대해서는 allPasteboardItemInfoinformationForItemAtIndex에서 호출됩니다.

Diff의 대부분은 pasteboard-info 함수들에 몰려 있는데, 각 함수가 completionHandler에 이르는 경로를 네 가지씩 가지고 있고 그 모든 경로에 호출이 필요했기 때문입니다. 두 개의 early return (!allInfo || !process, 빈 transcodingInfo), PLATFORM(IOS_FAMILY)가 아닌 분기, 그리고 비동기 HEIC-transcoding round trip이 그 대상입니다. 마지막 경로는 lambda의 signature 수정까지 필요했습니다. sharedImageTranscodingQueueSingleton()으로의 dispatch와 main run loop로 돌아오는 지점 모두 protectedConnection = Ref { connection }pageID capture를 추가로 갖게 되었는데, 이는 순전히 transcode 이후의 대체 path — pasteboard가 원래 보고했던 path와는 다른 — 를 completion handler가 실행되기 전에 allowlist에 넣기 위한 것입니다.

이와는 별개로, registerAttachmentsFromSerializedData에는 루프 내부에 isValidKey(serializedData.identifier) check가 추가되었습니다. 단일 path를 다루는 형제 함수는 ensureAttachment()를 호출하기 전부터 늘 identifier를 검증해 왔던 것과 균형을 맞춘 셈입니다.

Process split. 현대 WebKit은 filesystem이나 system pasteboard, drag pasteboard에 스스로 접근할 수 없는 sandbox된 WebContent process에서 웹 콘텐츠를 실행합니다. 별도의 UI process가 사용자의 전체 권한으로 실행되며 broker 역할을 담당합니다. WebContent가 요청하면 UI process가 판단하고, 좁혀진 결과를 돌려줍니다.

Sandbox extension. 표준적인 narrowing 메커니즘은 privileged 측이 발급하는 이전 가능한 token으로, sandbox 측에 특정 path 하나에 대한 접근 권한을 부여합니다. SandboxExtension::createHandle(filename, SandboxExtension::Type::ReadOnly)가 pasteboard 코드 전반에 등장하는 이유가 바로 이것입니다. Web process는 사용자가 선택한 파일들에 대해서만 read 권한을 받고, 그 외에는 아무것도 받지 않습니다.

Attachment element. WebKit의 <attachment> element는 편집 가능한 콘텐츠 안에 파일을 담습니다. 사용자가 mail composer에 PDF를 붙여넣을 때 생기는 형태가 바로 이것입니다. Web process는 파일 자체를 절대 쥐지 않고, 불투명한 identifier 문자열만 갖습니다. 실제 API::Attachment는 UI process가 해당 identifier를 key로 하는 IdentifierToAttachmentMap에 보관합니다.

RegisterAttachmentIdentifierFromFilePath. WebContent가 UI process에 보내는 IPC 메시지로, (identifier, contentType, filePath)를 담아 filePath에 있는 파일을 backing store로 하는 attachment를 생성해 달라고 요청합니다.

MESSAGE_CHECK_BASE(assertion, connection). Privileged 측에서 쓰이는 WebKit의 IPC-validation macro입니다. Assertion이 실패하면 해당 메시지는 malformed로 간주되어 sender가 종료됩니다. Attacker가 영향을 줄 수 있는 모든 IPC argument에 precondition을 강제하는 표준 idiom이며, 실패 모드가 일부러 요란하게 설계된 이유는 불가능한 메시지를 보내는 web process는 이미 compromise된 것으로 간주하기 때문입니다.

WebProcessProxy::fromConnection(). 들어오는 IPC connection을 그 connection에 해당하는 WebContent process를 대표하는 UI-process 객체로 매핑합니다. Grant, capability, bookkeeping 같은 per-process 상태는 이 객체에 매달려 있으므로, fromConnection은 receiver가 "이 sender는 지금까지 무엇을 부여받았는가"를 묻는 방식에 해당합니다.

Pasteboard와 drag brokering. WebContent는 WebPasteboardProxy에 pathname (getPasteboardPathnamesForType)이나 pathsForFileUpload를 포함한 item별 정보 (allPasteboardItemInfo, informationForItemAtIndex)를 요청합니다. Drag-drop도 방향만 반대일 뿐 같은 형태를 취합니다. 플랫폼이 fileNames()를 가진 DragDataWebPageProxy::performDragOperation에 전달하면, 이 함수가 내부로 forward합니다.

HEIC transcoding. iOS-family 플랫폼에서는 site-specific quirk에 해당하는 pasteboard image가 main thread 밖의 shared queue에서 transcode되고, 그 결과로 생성된 임시 path가 completion handler가 실행되기 전에 pathsForFileUpload 내부의 원본을 대체합니다. 따라서 이 분기를 통해 web process에 도달하는 path는 pasteboard가 원래 보고했던 path와는 다릅니다.

IPC testing API. Test build에서 JavaScript가 IPC.sendMessage를 통해 임의의 IPC 메시지를 합성할 수 있게 해주는 preference-gated 기능(IPCTestingAPIEnabled)입니다. Regression test는 이를 이용해 compromise된 web process가 보낼 수 있는 메시지를 흉내 냅니다.

이것은 confused deputy 패턴입니다. Privileged 측이 unprivileged 측으로부터 받은 리소스 이름capability로 취급했습니다.

  WebContent (sandboxed)        │  UIProcess (user's full FS authority)
  ──────────────────────────────┼──────────────────────────────────────
  paste / drop  ────ask────────►│  WebPasteboardProxy → path + extension
                ◄───grant───────│  (this is the only legitimate origin)
                                │
  RegisterAttachmentIdentifier  │  attachmentElementEnabled()?   ✓ shape
  FromFilePath("/etc/passwd") ─►│  isValidKey(identifier)?       ✓ shape
                                │  [ no provenance check ]  ◄── the gap
                                │  ensureAttachment() → open("/etc/passwd")

메시지가 넘어가는 경계는 다이어그램 가운데 세로선이며, 그 선이 그어진 이유는 비대칭성 때문입니다. 오른쪽 column은 사용자가 열 수 있는 어떤 파일이든 열 수 있지만, 왼쪽 column은 부여받은 것에만 도달할 수 있어야 합니다. Fix 이전에는 registerAttachmentIdentifierFromFilePath에 guard가 두 개 있었지만, 어느 쪽도 왼쪽을 바라보지 않았습니다. attachmentElementEnabled()는 설정에 관한 질문이고, IdentifierToAttachmentMap::isValidKey(identifier)는 문자열이 well-formed한 hash key인지를 묻습니다. filePath 인자 — 규칙상 privileged 측의 리소스를 지칭하는 유일한 parameter — 는 아무 검증 없이 그대로 통과했습니다.

그 결과 deputy는 요청받은 그대로 수행합니다. 문자열을 받아 API::Attachment를 바인딩하고, UI-process 권한으로 파일을 감싸고 읽습니다. 이 흐름 어디에도 memory-unsafe한 지점은 없습니다. 모든 객체는 정확히 refcount되고, 모든 문자열은 well-formed합니다. 깨진 invariant는 lifetime이나 bound가 아니라, sandbox된 process는 broker가 이미 부여한 filesystem path만 지칭할 수 있어야 한다는 sandbox 규칙 그 자체입니다.

Regression test는 이 구조를 모호함 없이 그대로 보여줍니다.

1. Enable attachment elements + IPC testing API on the configuration.
2. Load editable markup so the page is a plausible attachment host.
3. From page JavaScript, IPC.sendMessage the UI process:
     RegisterAttachmentIdentifierFromFilePath(
       identifier:   "fake-identifier"      ← never vended by anyone
       contentType:  "application/octet-stream"
       filePath:     "/etc/passwd"          ← never vended by anyone
     )
4. Pre-fix: accepted. Post-fix: MESSAGE_CHECK_BASE fails, process dies.
5. waitForWebContentProcessDidTerminate.

Synthetic identifier와 사용자가 손댄 적 없는 absolute path만으로 구성된 요청이며, fix 이전의 receiver는 둘 중 어느 것도 거부할 근거가 없었습니다. 이 등록 이후 무엇이 벌어지는지는 이 문제가 얼마나 확장되는지를 결정합니다. Attachment는 이제 sandbox가 원래 차단했어야 할 파일에 바인딩된 채로 UI process 안에 존재하게 되고, attachment의 내용을 표면화하는 downstream flow — 렌더링이나 재직렬화를 위해 다시 전달되는 데이터, drag-out, 또는 upload를 위해 network process에 부여되는 file access — 는 무엇이든 그 내용을 외부로 실어 나릅니다. 이것이 바로 보고된 impact입니다. 조작된 웹 콘텐츠를 처리하는 과정에서 민감한 사용자 정보가 노출될 수 있습니다. Sandbox 경계를 넘나드는 일반적인 arbitrary-user-readable-file read primitive로까지 이어지는지는 caller의 위치에서 어떤 read-back flow에 도달 가능한지에 달려 있으며, 특권 측에서의 file-open 자체는 조건 없이 일어납니다. 이미 WebContent process를 장악하고 있어 원하는 filePath로 메시지를 직접 보낼 수 있는 공격자에게는 도달 가능성이 가장 확실하고, content-only 경로로 이 취약점에 도달하려면 WebCore sender가 관여해야 하는데 이는 제공된 context에 포함되어 있지 않습니다.

Fix는 이름을 redeemable하게 만들어 invariant를 복원합니다. UI process가 정당하게 건네주는 모든 path — pasteboard pathname, transcode된 대체본을 포함한 pasteboard file-upload path, drag-drop filename — 는 이제 전송되기 전에 수신 측 WebProcessProxy에 기록되고, receiver는 처리에 앞서 membership을 검사합니다. /etc/passwd는 어떤 broker로부터도 vend된 적이 없으므로 이 set에 들어 있지 않고, 결과적으로 메시지는 malformed로 처리됩니다. 여기서 보장의 방향에 주목할 필요가 있습니다. 이 check는 그 path가 현재 이 operation에 대해 authorize되어 있는지를 검증하지 않습니다. 검증하는 것은 그 path가 이 process의 lifetime 동안 어느 시점엔가 broker에 의해 vend된 적이 있는지 뿐입니다. 즉 capability filter가 아니라 provenance filter에 해당합니다.

registerAttachmentsFromSerializedData의 부차적인 변경은 같은 영역에 있는 또 다른, 더 작은 gap입니다. 이 loop는 형제 함수가 계속 identifier를 검증해 온 것과 달리, 검증되지 않은 HashMap key로 ensureAttachment(identifier)를 호출하고 있었습니다. 최악의 경우라 해봐야 controlled abort일 뿐 corruption은 아니지만, 같은 map으로 들어가는 두 진입점 사이의 이런 비대칭이야말로 몇 년씩 눈에 띄지 않고 살아남는 부류의 문제입니다.

특권 process는 attachment identifier가 well-formed한 hash key인지는 검증했지만, 열라고 지시받은 file path를 스스로 vend한 적이 있는지는 전혀 묻지 않았습니다.

새로 추가된 allowlist의 범위는 처음 보이는 것보다 훨씬 성긴 편이라 주의 깊게 볼 필요가 있습니다. m_allowedAttachmentFilePaths는 page 단위도 operation 단위도 아닌 WebProcessProxy 단위로 존재하며, 제거 경로가 없는 HashSet<String>입니다. 즉 어떤 page에서의 paste 한 번으로 authorize된 path는 그 process의 lifetime 내내, 그리고 그 process가 호스팅하는 다른 모든 page에서도 계속 authorize된 상태로 남습니다. 이 성김은 우연이라기보다는 의도된 것으로 보입니다. addAllowedAttachmentFilePathspageID로 page를 조회하는 것을 순전히 liveness check용으로만 쓰고, 등록 자체는 어차피 process 단위로 수행합니다. 이는 이미 WebPasteboardProxy::accessType에 담긴 논리 — 같은 process의 page들은 IPC를 통해 서로를 사칭할 수 있으므로 page 단위 scoping이 보이는 것만큼의 이득을 주지 못한다는 판단 — 와 맞닿아 있습니다. 다만 이 check가 답할 수 있는 질문의 범위를 정하는 요소이니만큼 알아둘 가치는 있습니다.

Diff가 함께 보여주는 또 하나의 사실은 이런 retrofit이 얼마나 넓은 표면을 커버해야 하는지입니다. Pasteboard 함수 세 개, 각각 네 개씩의 completion 분기, 그리고 post-transcode path까지 allowlist에 넣기 위해 새로운 protectedConnectionpageID capture가 필요했던 비동기 transcoding round trip까지 포함됩니다. 이 분기 수 자체가 grant-tracking gap이 애초에 어떻게 방치되는지를 가늠하게 해주는 지표라 할 수 있습니다.