Introduction
UAF vulnerability has been discovered in the instruction optimization on x64 platforms in Chromium v8. Successful exploitation of this vulnerability could allow an attacker to execute arbitrary code in the context of the browser.
This vulnerability occurs in the instruction selection stage, where the wrong instruction has been selected and resulting in memory access exception.
The vulnerability fixed at this commit:
https://chromium.googlesource.com/v8/v8/+/71a9fcc950f1b8efb27543961745ab0262cda7c4
This writeup used the commit:
https://chromium.googlesource.com/v8/v8/+/dc9ed94efdac30bdbe88e81f4cf08783c1dc952f
Proof of Concept
function foo() {
const a = new Int16Array(0x10000);//step 1. 创建一个typed array,
const b = a[0]!= 0; // setp 2
%DebugPrint(a);
for (let i = 0; i < 1; i++) {
gc(); // step 3
}
if (b) { //step 4 ← — — — — — — BOOM
print(“boom”);
}
}
%PrepareFunctionForOptimization(foo);
foo();
foo();
%OptimizeFunctionOnNextCall(foo);
foo();
Listing 1: PoC
The following explains the code execution flow shown in Listing 1:
- Step 1: Create a Int16 typed array with size of 0x10000
- Step 2: Determine whether a[0] value is not equal to zero, then assign the results to b
- Step 3: Then performs garbage collection
- Step 4: If b variable is TRUE, print the string boom
Figure 1 shown the output results after running the PoC code:
The address of the access violation occurred in the data_ptr of a: 0x7f71576a0000 (Figure 2)
When the PoC code first calls the runtime function gc(), and then cmpw [r8], 0 will be compared as shown in Figure 3.
Cause of the Vulnerability
When running the PoC code for the first 2 times, v8 knows that the use of array a whose scope is local to the block in which it is declared, and the array a will not be accessed subsequently. Therefore, during the optimization compilation stage, gc() reclaims the memory stored in the array of v8, and the memory of array a was released.
However, in this case, v8 generated the ambiguous instruction for the x64 platform: cmp [r8], 0(r8 points directly to the memory address of array a). Refer to Listing 1, this instruction corresponds to the statement: if(b), under normal circumstances, when step 2 is executed, b is a boolean value by default, and there is no need to access the memory of array a again at step 4, but due to the change for optimization in v8, in order to reduce the lookup of the address registry, in this case, it is directly compared with the memory location.
The optimization process:
if(b)=>if(a[0]!=0)=>Word32Equal(a[0],0) =>[cmp [r8],0 ; jnz xxxx]
Thus, when executing Step 4, it is accessing the same place that has been reclaimed, causing a memory access exception at the address 0x7f6e29984162, where the memory of the array a that has been released is accessed again, resulting in memory corruption and crash.
Vulnerability Code Fix Analysis
The fix has removed the use of CanCoverForCompareZero function, and restored the use of CanCover function.
Let’s analyze the line of codes that fixed the issue:
https://chromium.googlesource.com/v8/v8/+/71a9fcc950f1b8efb27543961745ab0262cda7c4%5E%21/#F0
-// Used instead of CanCover in VisitWordCompareZero: even if CanCover(user,
-// node) returns false, if |node| is a comparison, then it does not require any
-// registers, and can thus be covered by |user|.
-bool CanCoverForCompareZero(InstructionSelector* selector, Node* user,
— Node* node) {
— if (selector->CanCover(user, node)) {
— return true;
— }
— // Checking if |node| is a comparison. If so, it doesn’t required any
— // registers, and, as such, it can always be covered by |user|.
— switch (node->opcode()) {
-#define CHECK_CMP_OP(op) \
— case IrOpcode::k##op: \
— return true;
— MACHINE_COMPARE_BINOP_LIST(CHECK_CMP_OP)
-#undef CHECK_CMP_OP
— default:
— break;
— }
— return false;
-}
-
} // namespace
// Shared routine for word comparison against zero.
@@ -2516,7 +2494,7 @@
cont->Negate();
}
— if (CanCoverForCompareZero(this, user, value)) {
+ if (CanCover(user, value)) {
switch (value->opcode()) {
case IrOpcode::kWord32Equal:
cont->OverwriteAndNegateIfEqual(kEqual);
@@ -2536,7 +2514,7 @@
case IrOpcode::kWord64Equal: {
cont->OverwriteAndNegateIfEqual(kEqual);
Int64BinopMatcher m(value);
— if (m.right().Is(0) && CanCover(user, value)) {
+ if (m.right().Is(0)) {
// Try to combine the branch with a comparison.
Node* const eq_user = m.node();
Node* const eq_value = m.left().node();
@@ -2646,6 +2624,7 @@
break;
}
}
+
// Branch could not be combined with a compare, emit compare against 0.
VisitCompareZero(this, user, value, kX64Cmp32, cont);
}
Many will ask why the previous function was not giving the correct results? CanCoverForCompareZero was used to determine whether the location can generate an optimized compare-to-zero sequence of instructions. It is equivalent to the enhanced version of canCover in the function VisitWordCompareZero. Handling this condition in VisitWordCompareZero comparison operation location, v8 developers believe that if canCover returns false, the location is used for comparison operations, then no other registries are needed.
In normal circumstances, this is not a problem. However, the problem occurs when generated instruction does not obtain the comparison result first, and then uses the comparison result to compare, but accesses the memory of the array a again, and then executes CMP. In this situation, the memory that accessing has been released by gc and resulting UAF issue.
The following shows the code fragment of the function call to access the array buffer of a:
And below shows the code fragment that trigger the specific instructions:
Because the vulnerable code takes the path where CanCoverForCompareZero is true, which results in different instruction sequences being generated(vulnerable version VS fixed version ).
Detailed Analysis of the Sequence of Instructions
After analyzing the optimization stage of Turbofan, we know that the problem occurs in the last two stages:
In the schedule phase, the node information is completely consistent, but after that it became different, we found the relevant instruction sequence.
In fact, the corresponding js code is const b=a[0]!=0;
Before fix:
After fix:
At first glance, it seems to be exactly the same, but due to the vulnerability, the code generated here is different (note that the left side of 114, before the fix is a dot, and after the fix is a circle and a dot, which means that at 114 lines before fix are not the instructions generated directly, but are instead taken from 33 input to generate an optimized sequence of instructions — instruction folding.
In the vulnerable code, when VisitWordCompareZero is called, CanCoverForCompareZero will return true because the Word32Equal node is a comparison-type location. Since the subsequent access is a CMP instruction, v8 does not need to store the result of the comparison in the registry but assumes that the subsequent access (that is, the if(b) statement) can directly access the memory of array a again, and eventually generates optimized code that contains fewer instruction sequences.
This is what happen before the registry allocation:
Before fix:
Related directives generated before fix:
· ba xorl r9,r9
· bd cmpw [r8],0x0 # X64cmp16 generates cmpw instructions on x64 platforms
· c2 setnzl r9l #set flag
After fix:
Relevant instructions generated after fix:
25 lines instruction sequence generated:
· ba cmpw [r8],0x0 # X64cmp16 generates cmpw instructions on x64 platform
· bf setzl r8l #set flag
· c3 movzxbl r8,r8 # the result of the comparison stored in r8
26 lines of instruction sequence generated:
· c7 xorl r9,r9
· ca cmpl r8,0x0 # x64cmp32 generates cmpl
· ce setzl r9l #set flag
Comparing the 2 sets of instructions before and after the fix, the difference in the conditional instruction is the opposite like so setnzl VS setzl, because the code before the fix executes cont->OverwriteAndNegateIfEqual(kEqual) in the function VisitWordCompareZero.
The following shows the TurboFan assembly instructions after vulnerability is fixed (notice that the generated code instruction sequence is longer than before fix):
In Figure 5, we can see that comparison result is placed on the stack [rbp-0x28] first, and then [rbp-0x28] is compared with 0, in this case, it will not be accessing the memory in the gc, thus there will be no UAF issue.
Exploitation Idea
This vulnerability can be further exploited using the heap spraying technique, and then leads to “type confusion” vulnerability. The vulnerability allows attackers to control the function pointers or write code into arbitrary locations in memory, and ultimately leads to code execution.
To learn more about how you can avoid and patch vulnerabilites, reach out to us here.
About the Author
Weibo Wang (Nolan) is a security researcher currently working in Singapore-based cyber security company Numen Cyber Technology. He has discovered many critical vulnerabilities on famous blockchain projects, such as ETH, EOS, Ripple, TRON, and popular products of APPLE, Microsoft, Google, etc
Numen Cyber Labs is committed to facilitating the safe development of Web 3.0. We are dedicated to the security of the blockchain ecosystem, as well as operating systems & browser/mobile security. We regularly disseminate analyses on topics such as these, please stay tuned for more!
This blog was originally published on our Medium Account.