最近笔者分析了一个chrome v8引擎的漏洞chromium821137,虽然这是一个老的漏洞,但是从漏洞分析利用中我们还是可以学习到v8漏洞利用的一些基础知识,对于入门学习浏览器漏洞利用具有较高的研究价值。
环境搭建
拉取代码
因为众所周知的原因,拉取v8代码需要使用非常规的方法,具体的搭建过程可以参考文末的链接。环境搭建和拉取旧的commit过程中我遇到的主要的坑是代理的问题,需要使用sock5全局代理,并且在使用谷歌的gclient sync命令的时候需要在根目录写一个.boto的配置文件才能使之运行时使用配置的代理;另外一个很重要的点是linux要使用ubuntu的镜像(笔者使用的是ubuntu 18.04),使用其他发行版可能会遇到奇奇怪怪意想不到的问题。大家在配置的过程如果遇到问题可以查找是不是上述步骤出现问题。
调试环境搭建
v8调试环境可以使用v8安装目录下的/tools/gdbinit并将它加入根目录下.gdbinit配置里,修改.gdbinit配置
sudo gedit ~/.gdbinit
添加配置(可以配合其他gdb插件如pwndbg使用),
source path/to/gdbinit
使用gdb调试时可以先加载要调试的d8文件,然后设置启动参数
set args --allow-natives-syntax xxx.js
其中xxx.js可以在要调试的地方设置输出点和断点
%DebugPrint(obj) // 输出对象地址 %SystemBreak() // 触发调试中断
在gdb中使用job addr命令可以很清晰的看到addr处的数据结构。
漏洞环境搭建
我们从漏洞的issue链接https://bugs.chromium.org/p/chromium/issues/detail?id=821137
找到修复的commit链接https://chromium.googlesource.com/v8/v8.git/+/b5da57a06de8791693c248b7aafc734861a3785d
,可以看到漏洞信息、存在漏洞的上一个版本(parent)、diff修复信息和漏洞poc(test/mjsunit/regress/regress-821137.js)
回退到漏洞存在的commit,分别编译debug和release版。(其中ninja构建系统是非google系的,需要自行安装,可以参考v8环境搭建的链接)
git reset --hard 1dab065bb4025bdd663ba12e2e976c34c3fa6599 gclient sync tools/dev/v8gen.py x64.debug ninja -C out.gn/x64.debug d8 tools/dev/v8gen.py x64.relase ninja -C out.gn/x64.relase d8
漏洞分析
我们从poc出发来分析漏洞的原理,poc如下
let oobArray = []; let maxSize = 1028 * 8; Array.from.call(function() { return oobArray }, {[Symbol.iterator] : _ => ( { counter : 0, next() { let result = this.counter++; if (this.counter > maxSize) { oobArray.length = 0; return {done: true}; } else { return {value: result, done: false}; } } } ) }); oobArray[oobArray.length - 1] = 0x41414141;
poc主要是定义了一个数组和一个smi(small int)值,然后调用了一个方法Array.from.call,最后给定义的数组偏移[长度-1]的位置赋值时v8崩溃了。
poc中Array.from.call这个方法需要关注下,Array是一个js数据类型,from是Array类型的一个方法,Array.from整体相当于一个function,这个function又调用了call方法,这是js的一种调用方式Function.prototype.call(),语法是function.call(thisArg, arg1, arg2, …)。通过搜索MDN了解到Function.prototype.call()可以使用一个指定的this值和单独给出的一个或多个参数来调用一个函数即Function,而且参数可以是一个参数列表。
凭经验我们可以猜到应该是Array.from方法出现了问题,我们在/v8/src/目录下查找问题代码。(PS:v8的js函数实现方法一般在src目录下,搜索命令中r表示递归查找,R表示查找包含子目录的所有文件,n表示显示出现的行数)
r00t@5n1p3r0010:~$ grep -rRn "array.from" -r /home/r00t/v8/src/ /home/r00t/v8/src/builtins/builtins-definitions.h:245: /* ES6 #sec-array.from */ /home/r00t/v8/src/builtins/builtins-array-gen.cc:1996:// ES #sec-array.from
找到Array.from方法实现的位置和行数/home/r00t/v8/src/builtins/builtins-array-gen.cc:1996:// ES #sec-array.from并跟进。
v8中js原生函数的实现是用c++写的,为了在各种cpu架构下做到性能优化的极致,google把这些重载过的c++代码实现的js原型函数用汇编器CodeStubAssembler生成了汇编代码。重载过的c++代码根据函数名字能大致猜到函数的功能(分析这个漏洞我们可以暂时不把主要精力放在分析这些重载的方法上,当然你要是像lokihardt那样“看一眼函数名字就知道哪有漏洞”当我没说;b),其中一些常用的重载方法如下
label 定义和bind绑定的标签 bind 绑定声明的label和代码块,bind绑定的代码块并不以{}作为分割,绑定的是两个bind或者bind到当前函数结束之间的代码 goto 跳转到代码块执行 branch 相当于if的三目运算,即if(flag)? a;b,根据条件flag是否成立跳转到指定的label代码块a或b
我们来分析一下v8的array.from实现,
TF_BUILTIN(ArrayFrom, ArrayPopulatorAssembler) { TNode<Context> context = CAST(Parameter(BuiltinDescriptor::kContext)); TNode<Int32T> argc = UncheckedCast<Int32T>(Parameter(BuiltinDescriptor::kArgumentsCount)); CodeStubArguments args(this, ChangeInt32ToIntPtr(argc)); TNode<Object> map_function = args.GetOptionalArgumentValue(1); // If map_function is not undefined, then ensure it's callable else throw. //判断arg[1]即mapFn类型是否为undefined、smi、callable,否则报错 { Label no_error(this), error(this); GotoIf(IsUndefined(map_function), &no_error); GotoIf(TaggedIsSmi(map_function), &error); Branch(IsCallable(map_function), &no_error, &error); BIND(&error); ThrowTypeError(context, MessageTemplate::kCalledNonCallable, map_function); BIND(&no_error); }
首先判断了arg[1]的类型,我们通过查找MDN得知array.from的函数原型是Array.from(arrayLike[, mapFn[, thisArg]]),所以这里的arg[1]对应mapFn,同理GetOptionalArgumentValue()得到的其他参数对应方式类似。
//判断[symbol.iterator]方法是否定义 IteratorBuiltinsAssembler iterator_assembler(state()); Node* iterator_method = iterator_assembler.GetIteratorMethod(context, array_like); Branch(IsNullOrUndefined(iterator_method), ¬_iterable, &iterable); //使用自定义的[symbol.iterator]方法 BIND(&iterable); { TVARIABLE(Number, index, SmiConstant(0));//定义Number类型变量index,值为0 TVARIABLE(Object, var_exception); Label loop(this, &index), loop_done(this), on_exception(this, Label::kDeferred), index_overflow(this, Label::kDeferred); // Check that the method is callable. //判断自定义的[symbol.iterator]方法是否是callable类型 { Label get_method_not_callable(this, Label::kDeferred), next(this); GotoIf(TaggedIsSmi(iterator_method), &get_method_not_callable); GotoIfNot(IsCallable(iterator_method), &get_method_not_callable); Goto(&next); BIND(&get_method_not_callable); ThrowTypeError(context, MessageTemplate::kCalledNonCallable, iterator_method); BIND(&next); } // Construct the output array with empty length. //创建length为empty的数组 array = ConstructArrayLike(context, args.GetReceiver()); // Actually get the iterator and throw if the iterator method does not yield // one. IteratorRecord iterator_record = iterator_assembler.GetIterator(context, items, iterator_method); TNode<Context> native_context = LoadNativeContext(context); TNode<Object> fast_iterator_result_map = LoadContextElement(native_context, Context::ITERATOR_RESULT_MAP_INDEX); Goto(&loop);
然后判断array.from的第一个参数是否定义了迭代器方法iterator,若iterator方法非undefined、null使用自定义的迭代器方法,poc中的数组oobArray定义了iterator,会执行BIND(&iterable)中的代码。这里需要关注的一点是在执行自定义的iterator时使用了变量index去记录迭代的次数。在判断完iterator方法是否是callable类型后poc中的代码会执行BIND(&next)处的代码,在next中首先创建了一个长度为0的数组,然后跳转到loop处继续执行。
BIND(&loop)主要是调用CallJS执行了自定义的Array.from(arrayLike[, mapFn[, thisArg]])中的mapFn方法,返回值存储在thisArg中,并用index记录迭代的次数。
source path/to/gdbinit
0
iterator执行完成之后会跳转到loop_done处,index的value赋值给length,继续跳转到finished处。在finished处调用了GenerateSetLength设置生成的array的长度,注意这里的第三个参数length.value()实际上是自定义的iterator执行的次数。
继续跟进GenerateSetLength
source path/to/gdbinit
1
在GenerateSetLength中首先判断了array是否包含fast elements(具体快元素和字典元素的区别可以查阅参考链接)。poc中oobarray不包含configurable为false的元素是快元素,执行BIND(&fast)的代码。在fast中把GenerateSetLength的第三个参数length转化赋值给length_smi,array的length转化赋值给old_length,然后比较length_smi和old_length的大小,若length_smi小于old_length则进行内存缩减跳转到runtime设置array的length为length_smi。
代码的逻辑看起来似乎没问题,就是对Array.from(arrayLike[, mapFn[, thisArg]])方法中的arrayLike对象执行自定义的迭代方法index次,创建一个空的array并在执行自定义迭代方法时设置它的长度为index.value,并最后检查根据index.value是否小于array的实际长度来决定设置array的长度为index.value或实际长度。但开发者似乎忽略了一个问题就是迭代方法是我们自己定义的,我们可以在迭代方法中设置Array.from(arrayLike[, mapFn[, thisArg]])中arrayLike对象的实际长度;如poc中我们在最后一轮迭代时设置oobArray的实际长度为0,在执行完maxSize次迭代后调用GenerateSetLength,这时oobArray迭代次数index>设置的实际长度0,并不会跳转到runtime设置oobArray的长度为我们设置的实际长度0,这样我们在实际长度为0的oobArray里拥有迭代次数index大小长度的访问权,就造成了越界访问。
patch中修改SmiLessThan为SmiNotEqual,这样在迭代次数>迭代函数中设置的实际长度时也会跳转到runtime执行设置数组的长度为迭代函数中设置的实际长度,就避免了oob的发生。
source path/to/gdbinit
2
v8数据存储形式
在js中number都是double型的,v8为了节约存储内存和加快性能,实现的时候加了smi(small int)型。32位系统中smi的范围是31位有符号数,64位smi范围是32位带符号数。大于2^32 v8会用float存储整型。
为了加快垃圾回收的效率需要区分number和指针,v8的做法是使用低位为标志位对它们进行区分。由于32位、64位系统的指针会字节对齐,指针的最低位一定为0,v8利用这一点最低位为1视为指针,最低位为0视为number,smi在32位系统中只有高31位是有效数据位。
漏洞利用
总体思路
通过前面的分析我们得知这是一个越界访问漏洞,如果我们想通过这个越界访问漏洞达到任意代码执行的效果,容易想到的一种方式是通过越界访问达到任意地址写,再到劫持控制流进而任意代码执行。
任意地址写
v8中达到任意地址读写的方法一般是控制一个JSArrayBuffer对象,之后的分析我们会看到JSArrayBuffer对象有一个成员域backing_store,backing_store指向初始化JSArrayBuffer时用户申请大小的堆,如果我们控制了一个JSArrayBuffer相当于一个指针和指针的内容可以同时改写。这样我们改写backing_store读取控制的JSArrayBuffer的内容就是任意地址读;我们改写backing_store修改控制的JSArrayBuffer的内容就是任意地址写。
获得可控JSArrayBuffer
接下来的问题是如何得到可控的JSArrayBuffer对象,因为我们最后的目的是使得JSArrayBuffer的backing_store指针和指针的内容可写,所以这里需要JSArrayBuffer落到一个释放的oobArray里,这一步可以通过gc实现。触发gc可以通过删除对象引用实现,需要注意的一点是为了避免oobArray被gc完全回收,在最后一轮迭代后要设置oobArray.length为大于0的数如1。
source path/to/gdbinit
3
信息泄露
gc触发之后某个JSArrayBuffer会落在某个oobArray里,下一步就是确定JSArrayBuffer对象和用于泄露信息的objGen的位置。这里可以通过搜索自定义的标志位0xbeef和0xdead实现,
source path/to/gdbinit
4
其中JSArrayBuffer和objGen在内存中的存储如下
source path/to/gdbinit
5
利用wasm执行任意代码
搜索得到可控的JSArrayBuffer对象后就获得了任意地址读写的能力,任意代码执行可以通过堆利用中常规的构造unsorted bin泄露libc,进而修改malloc_hook劫持控制流;对于v8也可以通过wasm获得一块rwx的内存,把shellcode写进这块内存再调用wasm的接口就可以执行shellcode了。
我们实例化一个wasm的对象funcAsm,通过读取前面控制的JSArrayBuffer的内容可以得到funcAsm的地址。funcAsm实际上是一个JSFunction类型的对象,实际执行的代码位于一块rwx的内存中,通过任意地址写修改这块rwx内存的内容再调用funcAsm就可以执行任意代码了。
source path/to/gdbinit
6
不同版本的v8中这块rwx的内存位置可能不同,在这个版本中调试发现位于wasmInstance.exports.main->shared_info->code->code+0x70的位置。
source path/to/gdbinit
7
完整exp
source path/to/gdbinit
8
总结
这篇文章分析了chromium821137漏洞的原理,介绍了v8的一些基础数据结构,并通过chromium821137学习了v8利用的基础知识,希望读者通过阅读调试能有所收获。
参考链接
漏洞地址https://bugs.chromium.org/p/chromium/issues/detail?id=821137
v8环境搭建https://www.cnblogs.com/snip3r/p/12290133.html
v8快元素https://blog.crimx.com/2018/11/25/v8-fast-properties/
v8内存模型https://zhuanlan.zhihu.com/p/28780798
v8垃圾回收http://www.jayconrod.com/posts/55/a-tour-of-v8-garbage-collection