Deconstructing a Winning Webkit Pwn2Own Entry
August 24, 2017 | Jasiel SpelmanWhen Simon blogged about the risks of JavaScript a few weeks back, he mentioned that we've begun to see JIT vulnerabilities in submissions to the ZDI program and as part of Pwn2Own. Today, I'll expand on his blog post by covering a vulnerability in Webkit that was used as part of Tencent Team Sniper's Pwn2Own 2017 entry against Apple Safari. This vulnerability, CVE-2017-2547, exists thanks to the way bounds checks for Arrays are handled within one of the optimization layers in JavaScriptCore. Specifically, the issue occurs as a result of improperly optimizing away bounds checks. Amusingly enough, the vulnerability was also found by a former Pwn2Own contestant, lokihardt, a week after Pwn2Own.
There are multiple optimization layers within the JavaScript engine used by Webkit. After JavaScript is parsed, the Low-Level Interpreter (LLInt) is the first to execute any JavaScript. If a particular function or loop executes enough, there will be a transition to the baseline JIT. Next up in the execution sequence are the Data Flow Graph (DFG) JIT and the Faster Than Light (FTL) JIT. The architecture of JavaScriptCore's JIT engine is to have different tiers based on the number of executions. Thanks to recursion, it is even possible to have a call stack where the same function is represented by the different layers. This particular vulnerability comes to existence during the DFG JIT.
Let's start by looking at what happens if you run the proof-of-concept on a vulnerable version:
The proof-of-concept is pretty small, so let's look at it:
Looking at the proof of concept, the faulting address is 0x414141414145 and $r14 is 0x0000414141414140, but where is that value coming from? Within arr1, the value 3.5448480588962e-310 is used a couple times. This is an 8-byte double representing the value 0x414141414140.
The crash occurs within operationValueAdd, and the only time any addition is performed occurs within the bar function. If we look at that line, there is a negative index into the array that is used. That function itself is pretty innocuous, which makes sense since it will just operate on the values that are passed to it. Let’s instead look at some of the code that handles the array bounds check optimizations, as that will affect what ends up being sent to operationValueAdd.
The handleBlock function is bit unwieldly. I've only shown a small snippet of it here, but the gist is that nodes are visited to try to determine if the bounds of array accesses can be safely eliminated. As each node is visited, a range of minimum and maximum values is stored. When possible, this range of minimum and maximum values is hoisted to a node higher in the syntax tree, then finally, integer check nodes are removed when redundant. This is a fairly typical compiler example. Let’s say we are accessing an array at indexes four and five. We can then just check to make sure the array size is greater than five rather than also having to check to see if it is greater than four.
Let's look at this method again, but this time, we’ll look more closely at what happens before node elimination:
We are specifying an integer constant, so we end up entering the if block instead of the else block. As a result, minNode will get set to zero and will effectively be ignored, while maxNode is set to the largest value in the range. If you look at the else block, you'll see that minNode is set to a value that references range.m_minBound. Later, this will be used to insert a CheckInBounds node as well. This means that if an integer constant is provided that is negative along with another integer constant that is positive, a check to ensure it is within bounds will not occur.
Unfortunately, there is an issue even if the bounds check occurred. If the integer was to be treated as an unsigned integer during the comparison to the array size, this would be fine. However, the comparison is performed as a signed comparison. For example, here's how the FTL JIT handles CheckInBounds nodes:
This function will treat the values as signed integers and will only perform a signed integer comparison to ensure that an OutOfBounds exit will occur if the value in child1 is equal to or larger than child2. Going back to the bug, this means that a function that enters the DFG JIT with integer constant accesses into an array will not properly check bounds. This allows a negative value to access data before the bounds of the buffer being indexed. The end result is the ability to read and write arbitrary values to preceding buffers, resulting in code execution within the WebKit sandbox.
Apple patched this with commit f2476d46820b744450133f6b00a85e5265db1915 in the repository, which modified dfg/DFGIntegerCheckCombiningPhase.cpp. The patch issues a check against the minimum bounds such that if it is found to be negative, the JIT engine will force an exit from the DFG JIT to a lower layer where more checks will be performed on accesses.
Compiler-introduced vulnerabilities have been around for years, and I find it quite fascinating how the prevalence and proliferation of JavaScript is now resulting in compiler-introduced vulnerabilities that can be triggered remotely. Stay tuned for more in our series on JIT compilers and the bugs they introduce.
You can find me on Twitter at @WanderingGlitch, and follow the team for the latest in exploit techniques and security patches.