Two Birds with One Stone: An Introduction to V8 and JIT Exploitation

In this special blog series, ZDI Vulnerability Researcher Hossein Lotfi looks at the exploitation of V8 – Google’s open-source high-performance JavaScript and WebAssembly engine – through the lens of a bug used during Pwn2Own Vancouver 2021. The contest submission from Bruno Keith and Niklas Baumstark exploited both Google Chrome and Microsoft Edge (Chromium) with the same bug, which earned them $100,000 during the event. This bug was subsequently found in the wild prior to being patched by Google. This blog series provides an introduction to V8, a look at the root cause of the bug, and details on exploitation during the contest and beyond.

At our Pwn2Own Vancouver contest this year, the web browser category included the Google Chrome and Microsoft Edge (Chromium) browsers as targets. For this year’s event, a successful demonstration no longer required a sandbox escape. There was also a special bonus for exploits that worked against both Chrome and Edge. On Day Two of the event, Bruno Keith and Niklas Baumstark successfully demonstrated their V8 JIT vulnerability on both the Chrome and Microsoft Edge renderers with a single exploit. This earned them $100,000 USD and 10 Master of Pwn points.

In this blog series, we’ll be covering this exploit in three separate entries:

1 - Two Birds with One Stone: An Introduction to V8 and JIT Exploitation

2 - Understanding the Root Cause of CVE-2021-21220 – A Chrome Bug from Pwn2Own 2021

3 - Exploitation of CVE-2021-21220 – From Pwn2Own to Active Exploit

We’ll begin with the basics of V8 and JIT exploitation.

Gathering Information

This vulnerability has been addressed by Google. More information about the bug can be found on the ZDI advisory page as ZDI-21-411, where there is a link to the Google fix:

          <img alt="" src="https://images.squarespace-cdn.com/content/v1/5894c269e4fcb5e65a1ed623/acbc2b6f-feb8-4f8c-84c0-40fbae1e294d/Picture1.png?format=1000w" />

This provides us with the Chromium bug entry amongst other details. There are some details provided by the researchers and the actual exploit tested on Chrome 89.0.4389.114 and Edge version 89.0.774.63 (which we will cover in-depth in the final blog in this series). You can see the developers fixed this issue in a commit by making changes in just one file. There is also a proof of concept (PoC) for us to review. Great! Now that we have a PoC, we can have a deeper look at the vulnerability, but we need to set up our analysis environment first.

Setting Up the Environment

It was possible to exploit both the Google Chrome and Microsoft Edge (Chromium) renderer processes with one exploit since both are using V8 as the JavaScript and WebAssembly engine. V8 is developed by Google in C++ and runs on Windows 7 or later, macOS 10.12 and newer, and Linux systems that use x64, IA-32, ARM, or MIPS processors.

V8 is an open-source project. This means you can compile it from the source code. Usually, it is easier to compile such projects on Linux. Thus, I am going to use Ubuntu 18.04.5 to compile V8 (see below):

          <img alt="" src="https://images.squarespace-cdn.com/content/v1/5894c269e4fcb5e65a1ed623/d41de51e-94d3-43e8-b723-606c697fd10f/Picture2.png?format=1000w" />

You can use any other supported operating system you want. The official build document is pretty good and provides abundant detail.

To begin, we need to install a package of scripts called depot_tools to manage checkouts and other tasks:

We then add “depot_tools” path to the list of available paths:

It is now time to download the V8 source code, which may take a while based on your internet speed. After the download is complete, there will be a new folder called v8. You will need to navigate to this directory to make it the working directory:

This gives us the latest version of V8. However, for this blog series, we need the vulnerable version of V8. We need to first find the affected version of Google Chrome which was available in the Chromium bug entry: 89.0.4389.114.

Cool. Now that we have an affected version of Google Chrome, we can look up information about that version in a service called omahaproxy. Just enter 89.0.4389.114 in the lookup field and press enter:

          <img alt="" src="https://images.squarespace-cdn.com/content/v1/5894c269e4fcb5e65a1ed623/a084fedc-6d02-483c-930e-4cc111fcd8a3/Picture3.png?format=1000w" />

It gives us some information, including the affected V8 commit:

          <img alt="" src="https://images.squarespace-cdn.com/content/v1/5894c269e4fcb5e65a1ed623/1e306aa0-d86e-44b7-a0a1-a233218e655c/Picture4.png?format=1000w" />

Now that we have the affected V8 commit, we can checkout that version. You may want to take a snapshot of the latest version of V8 first:

Now it is time to build V8. You can have a release or a debug build. A release build will give you a clean, optimized build that is faster but provides fewer details when running commands. A debug build is an unoptimized, slower build. However, it provides a lot of debug information that can help us to understand this vulnerability. Thus, we are going to choose the debug build:

If all went well, there will be an executable called “d8” in the “out/x64.debug” directory:

You should see this:

          <img alt="" src="https://images.squarespace-cdn.com/content/v1/5894c269e4fcb5e65a1ed623/bac0de32-e154-4e77-8458-5fa136e42cb7/Screen+Shot+2021-12-07+at+11.09.25+AM.png?format=1000w" />

V8 is an astonishing piece of engineering that has tons of documentation and details. We can’t go too much into all these details of course, but some concepts need to be covered as they are relevant to this blog series.

Like many other Linux executables, you can pass “--help” to the compiled “d8” to provide you with a long list of all supported options. For this blog post, we are interested in just two of them:

        1 - allow_natives_syntax: By adding this as an argument when running d8, you can access special runtime functions that can be called from JavaScript using the % prefix. To find all supported runtime functions, just go to the “src/runtime” directory and grep for the string “RUNTIME_FUNCTION”. We are just interested in two of them, both of which are available in the “src/runtime/runtime-test.cc” file:

        PrepareFunctionForOptimization: Prepares a specified JavaScript function for JIT optimization. As we will explain below, JIT optimization has certain prerequisites: the function being optimized must first have been translated to bytecodes, and the engine must have collected data regarding runtime type informtion..

        OptimizeFunctionOnNextCall: This function marks the target function so that the JIT engine will compile the function into an optimized form immediately before the next execution of the target function.

We will detail how these two are used in our next blog. If you do not want to use these two runtime functions, it is usually enough to call the target function many times in a loop.

        2 - trace-turbo-graph: This argument can be used to trace the generated graph (see below) when it goes through various optimizations. We will see this in action in the second blog.

When the V8 engine loads a JavaScript file, it parses the input and builds an Abstract Syntax Tree (AST). The V8 engine interpreter called “Ignition” generates bytecode from this syntax tree. Check the header file “bytecodes.h” (located inside the “src/interpreter” directory) for a complete list of V8 bytecodes. These bytecodes are then executed (interpreted) by Ignition handlers (check src/interpreter/interpreter-generator.cc). The interpreter has little to do with our vulnerability and thus we do not discuss it any further. There are lots of resources available if you want to study this topic more.

If a function is called many times, or optimization is explicitly requested using runtime functions as described above, the V8 engine will optimize (compile) that function. Optimization is heavily dependent upon information that the engine has previously collected during interpreted executions of the function, especially concerning the data types found in variables. Note that variables in JavaScript are not strongly typed, and to achieve meaningful optimizations, the engine needs to speculate that the types that were encountered in variables during interpreted execution will usually be the same as the types encountered in the future.

The optimizing compiler’s first step is to convert the bytecode into an intermediate representation, which has the form of a graph. This step is performed in PipelineImpl::CreateGraph, found within src/compiler/pipeline.cc:

          <img alt="" src="https://images.squarespace-cdn.com/content/v1/5894c269e4fcb5e65a1ed623/8d30162c-7504-41d0-82e2-c750aac42e51/Picture6.png?format=1000w" />

As you can see, the graph creation has 3 main phases:

         1 - GraphBuilderPhase: A graph is generated by visiting bytecodes previously generated.

         2 - InliningPhase: An initial attempt is made to optimize the generated graph by eliminating dead code, reducing calls, inlining, etc.

         3 - EarlyGraphTrimmingPhase: This phase removes dead->live edges from the graph.

More sophisticated optimizations are performed by PipelineImpl::OptimizeGraph, found in src/compiler/pipeline.cc:

          <img alt="" src="https://images.squarespace-cdn.com/content/v1/5894c269e4fcb5e65a1ed623/d0cf830a-fb5b-4ff4-9fae-ff60d3b34ad0/Picture7.png?format=1000w" />

Discussing all the optimizations implemented by V8 is out of scope for this blog series. Instead, we’ll just cover some of the ones we will see in the second blog in this series:

1 -   Typer: The nodes in the graph will get a type which covers possible values of that node. For example, a variable that has values like false or true is typed as a Boolean. As another example, a numeric value that is known to always equal 1 will have a type of range(1, 1).

2 -   Simplified lowering: Some operations are lowered (reduced) to a simplified series of nodes. The example below shows how the Math.abs operation is lowered:

          <img alt="" src="https://images.squarespace-cdn.com/content/v1/5894c269e4fcb5e65a1ed623/2d01a503-2c36-4a35-a051-1fe67a6c1b2c/Picture8.png?format=1000w" />

3 -  Early Optimization:  Various optimizations are done in this stage, which is clear when looking at the EarlyOptimizationPhase struct:

          <img alt="" src="https://images.squarespace-cdn.com/content/v1/5894c269e4fcb5e65a1ed623/7cec7239-a1fc-42ff-bda0-c614fb3d7ed2/Picture9.png?format=1000w" />

As you can see, further optimizations are done in this phase including dead code elimination, redundancy elimination, and something called the MachineOperatorReducer. In the next blog, we will detail how the MachineOperatorReducer plays a major role in this vulnerability.

After all optimizations are completed on the graph, the compiler translates the graph to assembler. All future calls to the optimized function will invoke the assembly version and not the interpreted (bytecode) version. As explained above, though, optimization is performed using speculated assumptions. As a result, the assembly version of the function must contain guards to detect all possible situations where an assumption has been violated. In that circumstance, the assembly version falls back to the interpreter again. This is known as a “bailout”.

This way the V8 engine can run any (optimized) function much faster. Please note this blog is a simplification of the process, and the whole procedure is much more complex. The V8 turbofan documentation is a good starting point if you want to explore it any further.

Conclusion of Part One

In this blog, we set up the V8 environment and played a bit with some of its features. In the next blog, we will analyze the vulnerability used at Pwn2Own. Expect to see that blog in just two days from now.

Until then, you can find me on Twitter at @hosselot and follow the team for the latest in exploit techniques and security patches.

Article Link: Zero Day Initiative — Two Birds with One Stone: An Introduction to V8 and JIT Exploitation