← 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 — the diff turns a root-node check into a tree walk, which is the whole tell: the pre-fix validator inspected one node of an attacker-supplied structure and vouched for all of them. A compromised WebContent process is the required position, so this is sandbox-escape surface rather than a drive-by.

Session history is one of the few pieces of navigation state a sandboxed renderer is allowed to mutate in the privileged UI process, so every field it sends across that boundary has to be re-validated on arrival. WebBackForwardList — the UI-process object that owns the authoritative back/forward list for a page — accepts item additions and updates over IPC and gates them behind a message check that rejects URLs the renderer should not be able to claim, file: chief among them. A history item is not a flat record, though: a FrameState node carries its own urlString and originalURLString plus a children vector of further FrameState nodes, one per subframe.

The angle: a compromised WebContent process can hide a file:/// URL in a subframe node of a forged history item, pass the root-only check, and get it committed into the UI process's authoritative back/forward list.

Pre-fix, messageCheckItemURLs() validated exactly two strings, both from the root node:

Source/WebKit/UIProcess/WebBackForwardList.cpp

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

messageCheckItemURLs() is converted from a flat, root-only validator into a recursive walk over the FrameState tree, applying the same file-URL policy to every node's urlString and originalURLString rather than only the root's. Because recursion over an attacker-chosen structure is itself a hazard, the walk is bounded: nesting deeper than WebCore::Page::maxFrameDepth fails the check. That bound is a MESSAGE_CHECK — process-terminating — rather than a silent clamp.

Recursive attacker-supplied structure validated only at its root node, with the check's verdict applied to the whole tree.

Where this lives. WebKit splits the browser across processes, and the UI process holds state the sandboxed WebContent process must not be able to forge. WebBackForwardList in the UI process owns the authoritative session history for a WebPageProxy and accepts history-item mutations — BackForwardAddItem, BackForwardSetChildItem, BackForwardUpdateItem — over IPC from the renderer.

MESSAGE_CHECK. This is WebKit's IPC-boundary assertion family: when a message field violates an invariant the sender should not have been able to violate, the check logs, marks the message invalid, and terminates the offending connection. It is used for conditions that cannot arise from an honest renderer, which is why it kills the process rather than recovering.

FrameState is a tree. A history item describes a whole frame hierarchy, not a single page. Each FrameState node holds its own URL strings and a children vector of subframe FrameStates. The recursive shape is visible elsewhere in the same file: setBackForwardItemIdentifiers() walks frameState.children to stamp identifiers on every node.

Page::maxFrameDepth. WebCore caps how deeply frames may nest. The cap exists as an engine-wide resource limit, which is why it doubles as a validity signal here — a tree deeper than the engine can legitimately produce is evidence of a forged message on its own.

The missing invariant is that every URL-bearing node in an IPC-supplied history tree must satisfy the same policy as the root. The bug class is incomplete input validation at a process trust boundary — a message-check bypass and a logic error, not a memory-safety defect.

  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

A compromised renderer builds a FrameState whose root urlString is an ordinary permitted web URL and hangs a child — or a chain of children — carrying file:///... beneath it. The root passes, the function returns true without ever looking at children, and the whole subtree lands in the UI process's authoritative list. The commit message names this explicitly as an incomplete fix of WebKit bug 315528, whose original patch introduced the root-only check.

The depth bound is the second half of the fix and is worth reading as more than recursion hardening. Making the validator recursive converts attacker-chosen children nesting depth directly into UI-process call-stack depth, so the cap is necessary the moment the walk becomes recursive. But the cap is also a real policy check: a frame tree deeper than maxFrameDepth cannot arise from legitimate engine behavior, so its presence is itself evidence of forgery — hence MESSAGE_CHECK rather than a clamp.

Exploitability requires an already-compromised WebContent process; this is a link in a sandbox-escape chain rather than a standalone bug. What it yields is the ability to plant renderer-chosen file: URLs in privileged navigation state, which is precisely the kind of state the UI process later acts on as trusted.

This vulnerability weakens the renderer-to-UI-process trust boundary by letting a compromised content process write URLs into privileged session history that the boundary check was specifically written to reject.