Exploitation of CVE-2021-21220 – From Incorrect JIT Behavior to RCE

December 16, 2021 | Hossein Lotfi

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.

You can find Part One of this series here and Part Two here.


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 ArrayPrototypePop and 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 shift JavaScript method is relatively slow, the optimizer replaces the call with a series of operations that can be performed at the assembly level. As you may know, "Array.shift" removes the first element from an array and returns that removed element. After removing that element, the JIT-produced code computes the new array length by subtracting 1 from the original array length:

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 addrof primitive leaks the address of an arbitrary JavaScript object. The fakeobj primitive performs the reverse action: it injects into the engine an arbitrary value that the engine will interpret as a pointer to a JavaScript object.

3 - Use fakeobj to forge a JavaScript array object whose data buffer field is an arbitrary attacker-specified address. The attacker can then use the forged array to read or write arbitrary memory addresses. (Compare with the OOB access of step 1 above, which only permits access to arbitrary specified offsets past the start of the original array.)

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 wasm function.

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 (after_dbl)
• This is followed in memory by a PACKED_ELEMENTS array (after_obj)
• This is followed in memory by another PACKED_DOUBLE_ELEMENTS (after_dbl2)

Using the out-of-bounds access via the array with length -1, it then increases the length of the after_dbl and after_obj:

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 addrof and fakeobj primitives, which is done as follows:

• The 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.

• The 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 after_obj, the exploit obtains a reference to a “fake” JavaScript object at the specified address.

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:

Here is a demo video:

Conclusion

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.