Search This Blog

Tuesday, April 6, 2021

Turboflan PicoCTF 2021 Writeup (v8 + introductory turbofan pwnable)

This year, picoCTF 2021 introduced a series of browser pwns. The first of the series was a simple shellcoding challenge, the second one was another baby v8 challenge with unlimited OOB indexing (about the same difficulty as the v8 pwnable from my Rope2 writeup - I recommend you to read this if you are unfamiliar with v8 exploitation), but what really caught my attention was the last browser pwnable, turboflan, which involved a bug in the turbofan JIT optimizer in Chromium. For those unfamiliar with turbofan, the following post from Jeremy Fetiveau is a nice read. I myself am still quite new to turbofan vulnerabilities, so please let me know if I made a mistake in my explanations.

Looking at the patch file, we see the following changes:

The most important change is the first part in LowerCheckMaps(). When running through code, v8 first generates ignition bytecode, and if it runs for many more times, Turbofan JIT compiles the code based on the types it sees used previously. When the optimized function encounters a new type, it should usually deoptimize to avoid the dangers of type confusion.

In the patch above, the challenge author specifically removed that deoptimization condition for when the map is different. This can easily lead to a bug by confusing 64 bit float arrays and object arrays (which consist of 32 bit pointers due to pointer compression).

Let's now try to trigger the bug in the provided d8 (with --allow-natives-syntax --trace-turbo --trace-opt --trace-deopt).

The first POC can be as simple as this:

Running in a d8 shell results in the following:

The type confusion did not exist here; in fact, there doesn't even seem to be an optimization option here. Doing a bit more digging with %DisassembleFunction in an up to date debug d8, bug() simply got inlined. Turbofan most likely caught this type change early on (in fact, around or before the ByteCode Graph Builder phase according to Turbolyzer), hence causing it to just deoptimize as soon as the loop was finished.

Knowing this, my next goal was to prevent inlining, and intuitively, it makes sense to just make the bugged function more complex to prevent such compiler behavior. I just tossed in a random for loop and it went away.

This time, we can see a type confusion in action:

In fact, just out of curiosity, let us compare the Turbolyzer outputs (this graph analysis tool was really not necessary for this challenge, but becomes very important in any harder Turbofan bug) between this bugged d8 and a normal d8.

In a normal d8, in TFEffectLinearization, the graph looks like the following:

In the bugged d8, the following is seen:

Notice how there is one less deoptimize condition node. One of the DeoptimizeUnless nodes in normal d8 is precisely where a check for a wrong map occurs. 

Now, let's begin to build some primitives that will eventually lead us to the addrof and fakeobj primitives. I created the following functions to help trigger the JIT bug:

Note that we are simply abusing type confusing between 64 bit float arrays and 32 bit object arrays (as pointers are now 32 bit due to pointer compression) here; we are not going out of bounds of the array length, as it will trigger a deoptimization due to other checks. Now, if we try to access idx 1 from an object array of 2 elements, it will actually hit arr[2] in the actual object array because the JIT code has been optimized for 64 bit arrays (similar to the Rope2 bug mentioned in a previous writeup).

Knowing the above behavior of the bug, we can easily leak the map of an object array (as well as the property pointer of fixed arrays as a confused 64 bit read/write will encounter both when going out of bounds). In at least all pointer compression based versions of v8, the float array map is at a constant offset from the object array map (0x50 specifically), so we will also now have a float array map address.

At this point, addrof and fakeobj are trivial to achieve. To grab the address of any objects, we fill an object array of size 2 with the target object. Then, using the confused_write method, we can OOB write and replace the object array map pointer with a float array map pointer (while also preserving the fixed array property, though this step isn't necessary most of the times). Now, returning indices from our array would leak the object addresses (one should restore the original array state afterwards for stability's sake).

fakeobj is even simpler. We can just use confused_write without OOB to directly change the addresses in the object array due to the type confusion, and return the new objects.

Now, arb read and arb write can be achieved. Unlike my previous Rope2 writeup, I have since discovered that map pointers can constantly be reused, making these primitives much easier to build. For both primitives, you create a float array with the first element holding a map pointer. Then you create a fake object over the part of memory holding those values, edit the array (with the original array object) to modify where the length and the element pointer would be, and now indexing into the fake object will give you arb read and arb write.

The rest of the exploit will become a generic Chrome exploit procedure without sandbox: initialize a wasm instance to create a rwx page, get the address of the wasm instance object, arb read its 64 bit pointer to the rwx page, and write shellcode outside the v8 heap (into the wasm page) by changing the 64 bit backing store pointer of an ArrayBuffer (use DataView to write to it). Then, calling the wasm function should trigger the shellcode. Sadly, the remote wasn't set up with an actual Chrome (it only used a d8), had strict firewall rules (so no reverse shells or bind shells), and would only let us see the stdout and stderr after running d8 (so no shell popping either); I just had to do a open read write shellcode :(

Here is my final exploit:

Overall, this was a very nice browser challenge to interest people into turbofan related bugs; thanks to the author wparks for making this pwnable and helping me clear up a few v8 related questions post-solve and my teammate pottm for the thorough proofread! These tasks were a refreshing change from the usual heap notes PicoCTF is famous for (though there were more format string tasks, which honestly are a crime against the category of pwn at this point and should just be removed from CTFs).  For those interested in more advanced and realistic Turbofan challenges, I highly recommend the challenge Modern Typer made by Faith on HackTheBox.


  1. Thanks for putting this write-up together! From what I've seen thus far, teams used a range of different approaches for this, and I'm hopefully they'll all be made public.

    FWIW, I did have a full chain problem planned for after turboflan, where the second stage would have been something straightforward, but didn't get it done in time.

    1. Thanks for the comment! I look forward to the challenges next year (could be cool to see the fullchain you had planned then)!

    2. Maybe, but unlikely. My recent pico problems (v8 this year, and the ML "dog or frog" problem in 2018) have all been related to what I've been working on as a personal project at the time. We'll see what I'll be working on during the next pico round!