In this third and final blog in the series, ZDI Vulnerability Researcher Hossein Lotfi looks at the method of exploiting CVE-2021-21220 for code execution. This bug was used by Bruno Keith (@bkth_) and Niklas Baumstark (@_niklasb) of Dataflow Security (@dfsec_com) during Pwn2Own Vancouver 2021 to exploit both Chrome and Edge (Chromium) to earn $100,000 at the event. Today’s blog looks at the exploitation technique used at the contest.
Exploiting Incorrect Numeric Results in JIT
In the second blog in this series, we discussed how CVE-2021-21220 can be used to make the JIT generate code that produces an incorrect numeric result. We now need to explain how this can be leveraged to produce an effect that has a security impact, such as an out-of-bounds memory access.
In the past, turning an incorrect numeric result into an OOB memory access was often accomplished by abusing array bounds check elimination. This method was effective for a long time. Take a look at the following simplified sample:
The length of array
arr is 4, and we are returning an element of this array. V8 will perform run-time bounds checking to make sure that the last statement does not access memory outside the bounds of the array. During optimization of such a function, V8 might remove array bounds checking if it concluded that
typer_index is always zero (or, in general, if
typer_index * 10 is provably always inside the bounds of the array). This saves a few more CPU cycles during execution of the optimized function. In the event that JITted code produces an erroneous numeric result, though, it may be possible fool the V8 engine into thinking
typer_index must be zero, while in actuality it will be set to a different (erroneous) value. Then, when the array access is performed, it will trigger an out-of-bounds memory access.
This method was so successful that the V8 developers eventually decided to remove array-bounds-check elimination. See this blog for more information about this exploitation technique, as well as this blog for further discussion.
Since V8 mitigated the array bounds elimination exploitation technique, a new technique is necessary. At Pwn2Own, the contestants used a technique that produces out-of-bounds access via
ArrayPrototypeShift. I was able to trace this method back to late 2020 by searching the Chromium bug tracking system. It was mitigated a week after the Pwn2Own competition by adding a new
CheckBounds node. Here I provide you with a quick analysis of this method:
When a function undergoing optimization has calls to the
Array.shift method, the execution flow eventually reaches the function
JSCallReducer::ReduceArrayPrototypeShift function (see
src/compiler/js-call-reducer.cc). Since a call to the built-in
After subtracting 1, the JIT-produced code stores the result as the new array length. How can this be exploited? Well, it turns out that if we can abuse a JIT vulnerability to fool the engine into thinking that the array length is zero where it is not, it blindly subtracts one from zero. The integer underflow sets the array length to -1, which allows a subsequent OOB memory access to occur (array bounds checks are unsigned). This Chromium bug entry provides more information if you are interested.
Although the two exploitation techniques described above have now both been mitigated, new methods are still coming out using JIT vulnerabilities to cause side effects and achieve out-of-bounds memory access.
From Out-of-Bounds Access to Code Execution
The method of V8 exploitation after obtaining an OOB read/write primitive is well known. Here are the steps:
1 - Trigger the vulnerability and the side effect to get a “relative” out-of-bounds memory access to corrupt the length of one or more arrays sitting next to the original array.
2 - Make addrof/fakeobj primitives. The
3 - Use
4 - Use the
addrof primitive to leak the address of a
wasm function. This will be where we copy our shellcode. A
wasm function is a good choice because the memory it occupies is marked with RWX (Read-Write-Execute) permissions.
5 - Use the
fakeobj primitive to copy shellcode to the RWX page. To make copying the shellcode easier, an
ArrayBuffer that has an uncompressed
backing_store pointer is often used. This overwrites the
wasm function instructions with our shellcode.
6 - Execute the shellcode by calling the
Here is how it was actually done at Pwn2Own. The exploit starts by defining some helper functions to convert between floats and integers:
It then triggers the JIT vulnerability:
After triggering the vulnerability, the value of the “bad” variable is huge, and thus it goes into a series of
Math.max calls to achieve a smaller value (1). This confused value is then used to create an array, and a
shift on this array is used to produce an array having length -1. This allows the exploit to access memory at arbitrary offsets past the end of the array.
Setting up the
wasm RWX memory is the next step:
Note that the contents of the
wasm function is not important, as its instructions will be replaced with shellcode.
Next, the exploit allocates 3 arrays:
• A PACKED_DOUBLE_ELEMENTS array (
• This is followed in memory by a PACKED_ELEMENTS array (
• This is followed in memory by another PACKED_DOUBLE_ELEMENTS (
Using the out-of-bounds access via the array with length -1, it then increases the length of the
After the lengths have been altered, some of the data of
after_dbl overlaps with some of the data of
after_obj. Similarly, some of the data of
after_obj overlaps with some of the data of
after_dbl2. This will allow the exploit to perform type confusions.
Now the exploit is all ready to create the
fakeobj primitives, which is done as follows:
addrof primitive: To leak the address of an object, it first assigns it into index
0x2f of the
after_obj array. As mentioned above,
after_obj now partially overlaps with
after_dbl2. The exploit then read the pointer from
after_dbl. It is returned as a double, allowing the exploit to learn the numeric value of the object’s address.
fakeobj primitive: To inject an arbitrary pointer value, the exploit assigns it into
after_dbl. In a way similar to the operation of
addrof explained above, the data can then be read as a different type by reading it from a different (overlapping) array, in this case
after_obj. By fetching it from
From here, all that remains is to copy the shellcode to the leaked address of the
wasm function and execute it.
After the shellcode is run, the page is idle and will be subject to garbage collection. This may cause a crash of the renderer process. To handle this, the exploit developers tried to smooth over corruptions as much as possible to prevent a crash:
JIT vulnerabilities tend to be powerful, providing strong primitives and reliable exploitation methods. The inherent complexity of JIT compilation makes it very challenging for engine developers to correctly handle all corner cases, despite their impressive efforts. However, incorrect JIT behavior can impact security only if a technique is available to achieve an effect such as out-of-bounds memory access. This is one area where engine developers can focus by introducing additional hardening.
You can find me on Twitter at @hosselot and follow the team for the latest in exploit techniques and security patches.