← All reports

[2] Root-only URL validation in the back/forward list message check

HighWebKit UIProcess back/forward listSandboxEscape

The history check read one node and vouched for the entire tree.

7f183f2

High — root 노드 하나만 보던 검사가 트리 전체를 순회하는 형태로 바뀐 점이 핵심 단서입니다. 패치 이전 validator는 공격자가 구성한 구조에서 노드 하나만 확인한 뒤 나머지 전부를 함께 통과시켰습니다. 다만 선행 조건으로 WebContent process가 이미 장악된 상태가 필요하므로, drive-by가 아니라 sandbox escape 표면에 해당합니다.

Sandbox 안의 renderer가 권한을 가진 UI process의 navigation state를 직접 변경할 수 있는 경로는 많지 않은데, session history가 그 몇 안 되는 통로 중 하나입니다. 그래서 이 경계를 넘어오는 필드는 도착 시점에 다시 검증되어야 합니다. WebBackForwardList는 페이지의 권위 있는 back/forward list를 소유하는 UI process 객체입니다. IPC로 들어오는 item 추가와 갱신을 받아들이되, 그 앞단에 message check를 두고 renderer가 주장해서는 안 되는 URL을 걸러냅니다. 대표적인 대상이 file: scheme입니다. 다만 history item은 평평한 레코드가 아닙니다. FrameState 노드 하나는 자신의 urlString과 originalURLString을 갖고, 여기에 subframe마다 하나씩 대응하는 FrameState들의 children vector를 함께 보유합니다.

관전 포인트: 장악된 WebContent process는 위조한 history item의 subframe 노드 안에 file:/// URL을 숨길 수 있습니다. root만 보는 검사는 그대로 통과하고, 해당 URL은 UI process의 권위 있는 back/forward list에 그대로 반영됩니다.

패치 이전의 messageCheckItemURLs()가 검사하던 문자열은 정확히 두 개였고, 둘 다 root 노드에서 가져온 값이었습니다:

Source/WebKit/UIProcess/WebBackForwardList.cpp

URL itemURL { frameState->urlString };
URL itemOriginalURL { frameState->originalURLString };

messageCheckItemURLs()는 root만 보던 평면적인 validator에서 FrameState 트리를 재귀적으로 순회하는 형태로 변경되었습니다. 이제 root뿐 아니라 모든 노드의 urlString과 originalURLString에 동일한 file URL 정책이 적용됩니다. 한편 공격자가 구성한 구조를 재귀로 따라가는 동작 자체가 위험 요소이므로, 순회에는 깊이 제한이 함께 들어갔습니다. 중첩이 WebCore::Page::maxFrameDepth를 넘어서면 검사가 실패합니다. 이 상한은 값을 조용히 잘라내는 clamp가 아니라 MESSAGE_CHECK이며, 위반 시 process가 종료됩니다.

공격자가 제공한 재귀적 구조를 root 노드에서만 검증하고, 그 판정을 트리 전체에 적용한 패턴.

이 코드가 있는 위치. WebKit은 브라우저를 여러 process로 분리하며, sandbox 안의 WebContent process가 위조할 수 없어야 하는 state는 UI process가 보유합니다. UI process의 WebBackForwardList는 WebPageProxy에 대한 권위 있는 session history를 소유합니다. 그리고 renderer로부터 IPC로 전달되는 history item 변경 요청 — BackForwardAddItem, BackForwardSetChildItem, BackForwardUpdateItem — 을 받아 처리합니다.

MESSAGE_CHECK. WebKit이 IPC 경계에서 사용하는 assertion 계열입니다. sender가 깨뜨릴 수 없어야 할 invariant를 message 필드가 위반하면, 로그를 남기고 해당 message를 invalid로 표시한 뒤 문제의 connection을 종료합니다. 정상적인 renderer라면 만들어낼 수 없는 조건에만 쓰이기 때문에, 복구가 아니라 process 종료를 택하는 것입니다.

트리 구조인 FrameState. history item 하나가 기술하는 대상은 단일 페이지가 아니라 frame 계층 전체입니다. FrameState 노드마다 자신의 URL 문자열과 subframe FrameState들의 children vector를 함께 보유합니다. 이런 재귀적 형태는 같은 파일의 다른 코드에서도 확인됩니다. setBackForwardItemIdentifiers()는 frameState.children을 순회하면서 모든 노드에 identifier를 부여합니다.

Page::maxFrameDepth. WebCore는 frame이 중첩될 수 있는 깊이에 상한을 둡니다. 원래는 엔진 전역의 리소스 제한으로 존재하는 값인데, 여기서는 유효성 판단 신호 역할까지 겸하게 됩니다. 엔진이 정상적으로 만들어낼 수 있는 깊이를 넘어선 트리라면, 그 자체로 위조된 message라는 근거가 되기 때문입니다.

누락된 invariant는 IPC로 전달된 history 트리에서 URL을 가진 모든 노드가 root와 동일한 정책을 만족해야 한다는 것입니다. 버그 유형으로 보면 process 신뢰 경계에서의 불완전한 입력 검증에 해당합니다. memory safety 결함이 아니라 message check 우회이자 로직 오류입니다.

  WebContent (sandboxed)          |   UIProcess (privileged)
  ──────────────────────          |   ──────────────────────
  FrameState {                    |   messageCheckItemURLs()
    urlString: "https://ok"  ─────┼──►  checks root  ✔
    children: [                   |     returns true
      { urlString:                |          │
        "file:///etc/passwd" }    |          ▼  never inspected
    ]                             |   committed to WebBackForwardList
  }                               |
                                  ▲ trust boundary crossed here

장악된 renderer는 root의 urlString을 허용된 평범한 web URL로 채운 FrameState를 구성합니다. 그리고 그 아래에 file:///...를 담은 자식 노드를, 혹은 자식들의 체인을 붙입니다. root는 검사를 통과하고, 함수는 children을 한 번도 들여다보지 않은 채 true를 반환합니다. 결과적으로 subtree 전체가 UI process의 권위 있는 list에 들어갑니다. commit message는 이 문제를 WebKit bug 315528에 대한 불완전한 수정이라고 명시하고 있습니다. root만 검사하는 로직을 도입한 것이 바로 그 원래 패치였습니다.

깊이 상한은 이번 수정의 나머지 절반인데, 단순한 재귀 hardening 이상으로 읽을 필요가 있습니다. validator를 재귀로 만드는 순간, 공격자가 정하는 children 중첩 깊이가 그대로 UI process의 call stack 깊이로 전환됩니다. 그래서 순회가 재귀가 되는 시점부터 상한은 필수 요소가 됩니다. 다만 이 상한은 동시에 실질적인 정책 검사이기도 합니다. maxFrameDepth보다 깊은 frame 트리는 정상적인 엔진 동작으로는 만들어질 수 없습니다. 그런 트리의 존재 자체가 위조의 근거인 셈이며, clamp 대신 MESSAGE_CHECK을 쓴 이유도 여기에 있습니다.

exploit하려면 WebContent process가 이미 장악되어 있어야 합니다. 단독으로 성립하는 버그라기보다 sandbox escape 체인의 한 고리에 해당합니다. 여기서 얻어지는 것은 renderer가 정한 file: URL을 권한 있는 navigation state에 심을 수 있는 능력입니다. UI process가 이후 신뢰된 값으로 간주하고 처리하는 state가 정확히 이 영역입니다.

결과적으로 renderer와 UI process 사이의 신뢰 경계가 약화됩니다. 경계 검사가 애초에 거부하려고 작성된 URL을, 장악된 content process가 권한 있는 session history에 기록할 수 있게 되기 때문입니다.