本来去年暑假开始学习windows pwn,奈何需要备战考研所以搁置了,现在重新捡起来开始学习,记录一下BUUOJ上做的几个windows pwn题,总的来说windows pwn相对于linux pwn会略显复杂,机制更为繁琐,但是两者仍有一些共通之处。
此外,有关windows的一些保护机制以及绕过方式,将会结合题目一起提到而并不打算单独拎出来做总结。其实这方面的内容网上也有非常多的参考资料,整理得也相当好了,我也就不做过多得重复工作,遇到的时候再稍做记录效率会高一些。
[Windows][inCTF2019]warmup
题目分析
逻辑很简单,程序先是提供了一个格式化字符串漏洞:
1 | int fmt_str() |
后面直接给了个栈溢出:
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
此外还提供了一个后门,直接读flag:
1 | int backdoor() |
利用思路
- 首先利用格式化字符串漏洞,读ebp,cookie和程序返回地址,从而得到程序加载的基地址。
- 由于栈溢出的字节数不够用,所以利用栈溢出劫持返回地址到
0x00406D3D
的位置:这样,可以通过控制栈上传递给1
2
3
4
5
6
7
8.text:00406D34 push 0 ; lpOverlapped
.text:00406D36 push 0 ; lpNumberOfBytesRead
.text:00406D38 push 60h ; '`' ; nNumberOfBytesToRead
.text:00406D3A lea eax, [ebp+Buffer]
.text:00406D3D push eax ; lpBuffer
.text:00406D3E mov ecx, stdin
.text:00406D44 push ecx ; hFile
.text:00406D45 call ds:ReadFileReadFile
的lpOverlapped
、lpNumberOfBytesRead
和nNumberOfBytesToRead
(主要是这个)参数,实现再次栈溢出的效果,这样就可以读入一段更长的ROP。 - 但是由于远程环境中的flag是放在
flag.txt
里面的(简直坑爹),而后门里读的是/flag文件,显然直接跳到后门执行根本拿不到flag。 - 所以需要通过ROP把.data段上存放的”/flag”字符串给改成”./flag.txt”,再跳到后门执行即可。
exp
1 | from winpwn import * |
[Windows][Others]BabyROP
题目分析
整个程序逻辑很简单,也没开GS保护:
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
首先输入的name没有末尾补0,所以可以利用这个leak出栈上残留的数据。
其次后面直接给了一次栈溢出的机会。
利用思路
- name的buffer后偏移为0x64的地方存有MSVCR100.dll中函数的地址,利用这个将MSVCR100.dll的基地址算出来,并得到MSVCR100.dll中的
system
和”cmd.exe”的地址。 - 利用栈溢出直接劫持返回地址,布置ROP执行
system("cmd.exe")
。
exp
1 | from winpwn import * |
[Windows][HITBGSEC]BABYSHELLCODE
题目分析
程序开始的时候,先做一个简单的初始化操作:
1 | int init() |
这里调用了一个自实现的scmgr.dll
中的scmgr_init
函数,以及利用init_scmgr
的地址初始化了一个int[32]
的buffer。
其中scmgr_init
实现如下:
1 | int init_scmgr() |
也就是分配出了20个page的内存供后续使用。
后面菜单提供add
,show
,delete
,run
四个基本功能,顾名思义,就是添加、打印、删除、执行shellcode的操作。
比较关键的是run
这里:
1 | int run() |
在enabled == 1
的情况下(其实总是1),调用memcpy(Src, v3, v1->length);
向栈上的Src
复制了一段shellcode,而这个shellcode的长度显然可以超过Src
的长度100,所以这里存在一个栈溢出;其次,后面((void (__thiscall *)(int))v1->code)(v1->code);
执行shellcode前,*v3 = -1;
首先将shellcode的前4 bytes置了0xFFFFFFFF
,所以肯定会触发错误,从而陷入到错误处理函数中执行,这里就涉及到windows的SEH机制。
SEH (Structured Exception Handling)
SEH(Structured Exception Handling)结构化异常处理是windows的一种异常处理机制,C语言中主要通过try & catch
实现。
在代码层面,windows相应的函数栈上也会布置一种特殊的结构:
1 | typedef struct CPPEH_RECORD |
其中:
prev
指向下一个EXCEPTION_FRAME
。handler
为异常处理函数_except_handler4
。scopetable
是一个指针,指向PSCOPETABLE_ENTRY
,在这里开启GS保护的情况下,它是:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16struct _EH4_SCOPETABLE {
DWORD GSCookieOffset;
DWORD GSCookieXOROffset;
DWORD EHCookieOffset;
DWORD EHCookieXOROffset;
_EH4_SCOPETABLE_RECORD ScopeRecord[1];
};
struct _EH4_SCOPETABLE_RECORD {
DWORD EnclosingLevel;
long (*FilterFunc)();
union {
void (*HandlerAddress)();
void (*FinallyFunc)();
};
};TryLevel
可以视为对相应_EH4_SCOPETABLE
中_EH4_SCOPETABLE_RECORD
的一个索引。
从栈的布局上看(借用一张图),为:
1 | ExceptionPointers |
检查_EH3_EXCEPTION_REGISTRATION->prev
合法之后,会通过调用_EH3_EXCEPTION_REGISTRATION->ExceptionHandler
,也就是_except_handler4
函数进入异常处理流程:
1 | void __cdecl ValidateLocalCookies(void (__fastcall *cookieCheckFunction)(unsigned int), _EH4_SCOPETABLE *scopeTable, char *framePointer) |
简单描述一下这个流程:
- 首先通过xor解密
_EH3_EXCEPTION_REGISTRATION->scopetable
,得到相应的地址。 - 获取栈上存放的old ebp的位置(也就是当前栈的ebp,即framePointer),进行如下check:
- 如果
scopeTable->GSCookieOffset != -2
,则保证ebp ^ cookie == __security_cookie
,这里ebp就是当前函数栈的ebp,cookie也是当前栈的cookie,与函数返回前对GS进行check的逻辑一致。 ebp ^ *(ebp - scopetable->EHCookieOffset) == __security_cookie
。
- 如果
- 获取栈上存放的
_EH3_EXCEPTION_REGISTRATION->tryLevel
,检查该TryLevel != -2
的情况下找到对应的_EH4_SCOPETABLE->_EH4_SCOPETABLE_RECORD
。 - 如果
_EH4_SCOPETABLE_RECORD->FilterFunc
不为空,则执行FilterFunc
,返回值大于0则继续执行HandlerFunc
。
利用思路
- 首先利用程序最开始输入name再打印name,leak出栈上存放的ebp,cookie和return address,这样可以计算出
__security_cookie
、栈地址和程序基地址。 - 由于在
init
中,程序获取了scmgr_init
的地址,并利用它生成了一个int[32]
的buffer,如果调用5号功能,可以得到加密后的buffer内容,因此只要获得明文的buffer内容,我们就可以拿到scmgr_init
的地址,进而获得scmgr.dll
的基地址,获得其中存在的后门test_getshell
的地址。 - 考虑该加密应该是单向散列,所以逆推不太可能;此外,
scmgr_init
的地址为scmgr.dll + 0x1100
,scmgr.dll
的基址低2 bytes为0,所以只需要爆破高2 bytes即可,速度很快。 - 拿到
scmgr.dll
的基址,就可以利用run
功能中的栈溢出,布置栈上的内容;只要伪造_EH3_EXCEPTION_REGISTRATION->ExceptionHandler
为后门地址,加上_EH3_EXCEPTION_REGISTRATION->prev
指向原来的地方,即可再触发异常的时候执行该后门getshell。
exp
1 | from winpwn import * |
[Windows][HITBGSEC]BABYSTACK & [Windows][第五空间 2019 决赛]PWN9 & [Windows][SUCTF 2019]BabyStack
题目分析
首先该程序开启了SafeSEH保护(虽然winchecksec没识别出来)。
其次该程序逻辑十分简单:
1 | // positive sp value has been detected, the output may be wrong! |
最开始的时候,main
地址和栈地址都给出了;后面又给了10次任意地址读的机会,如果输入”no”,则会提供一次栈溢出的机会;此外,程序还提供了getshell的后门:
1 | .text:001B138D push offset Command ; "cmd" |
不过由于开了SafeSEH,如果用BABYSHELLCODE的方法覆盖exception_handler
,则无法通过check,所以这里需要了解一下SafeSEH在普通SEH基础上添加了什么。
SafeSEH
SafeSEH简单来说,就是在SEH的基础上,添加了额外的check。
当异常发生时,异常处理过程RtlDispatchException
首先检查异常处理节点是否在栈上, 如果不在栈上程序将终止异常处理, 其次检查异常处理Handler
是否在栈上, 如果在栈上程序将止异常处理. 最后检测调用RtlIsValidHandler
检测Handler
有效性:
1 | BOOL RtlIsValidHandler(handler) |
上面伪代码里的ExecuteDispatchEnable
和ImageDispatchEnable
标志用来控制Handler
在不可执行内存或者不在异常模块的映像内时, 是否可以执行。默认情况下, 如果进程DEP开启, 两位为0, DEP关闭, 两位为1。
利用思路
- 利用任意地址读,把栈上的cookie给leak出来。
- 利用栈溢出,在栈上伪造一个
_EH4_SCOPETABLE
结构,使得FilterFunc
为backdoor;同时绕过所有的check,保证异常处理流程正确执行到FilterFunc
上。
从上面SEH原理分析
_except_handler4_common
的流程时,注意到先会检查FilterFunc
是否为空再依次执行FilterFunc
和HandlerFunc
;但在实际调试过程中发现,即使FilterFunc == NULL
,设置HandlerFunc = backdoor
最终也能getshell,只是相比之下会延迟一段时间。
由于_except_handler4_common
代码也是借用其他师傅博客里的,中间省略的部分也不知从何获得,所以暂时还不知道原因。
exp
1 | from winpwn import * |
[Windows][Insomni’hack Teaser 2017]Easywin
题目分析
首先winchecksec一下:
1 | ➜ winchecksec.exe .\easywin.exe |
需要注意的是这里开启了CFG保护,至于CFG保护的原理后文会提到。
题目逻辑比较简单,主要是add
中:
1 | // ... |
这里在偏移0x200的位置放置了一个函数指针,在attack
功能中会被调用:
1 | // ... |
首先,这个调用的参数正好书buffer本身;其次,其buffer中包含格式化字符串。
同时,很容易发现,在edit
中:
1 | v2 = (__int64)fgets(v0, 0x208, v1); |
这里是可以写入0x208 bytes数据的,也就是说直接给了一个覆盖函数指针的操作;同时配合attack
中的格式化字符串,还可以进行一次leak,需要注意的是,windows下是无法通过”%n”这种操作完成任意地址写的,”$”也无法使用。
Windows CFG(Control Flow Gaurd)
简单来说,CFG通过在间接跳转前插入校验代码,检查目标地址是否合法,从而可以组成程序控制流被劫持到非预期的地方。
而从细节上讲,比如:
1 | .text:00007FF6862A17BD mov rbx, [rdi+200h] |
这里需要call rbx
,不过首先需要通过__guard_check_icall_fptr
对rbx
的位置进行一个校验。
而在win10里,这个__guard_check_icall_fptr
的实现其实就是ntdll!LdrpValidateUserCallTarget
:
1 | unsigned __int64 __fastcall LdrpValidateUserCallTarget(unsigned __int64 func_addr) |
简单来说,这个check流程就是:
func_addr >> 9
得到对应CFGBitmap
数组的下标,这里的原因是:CFGBitmap
原理是8 bytes对应1 bit,换句话说,就是8 bytes的虚拟地址空间是用CFGBitmap
中的1 bit标记的;所以这里需要右移3。CFGBitmap
数组是以QWORD
单位存的,故bitmap
也是以8 bytes也就是64 bit取的,进行判断的时候下标最大为2^6-1
,即需要6 bit表示下标;所以这里需要右移6。所以最后
func_addr >> 9
才得到对应CFGBitmap
数组的下标。综上,被check的
func_addr
实际上被分为了三个部分:1
2
3
4
5+---------------------+------------------+--------+
| 55 bits | 6 bits | 3 bits |
+---------------------+------------------+--------+
| offset in CFGBitmap | offset in bitmap | left |
+---------------------+------------------+--------+
如果
func_addr
的低4 bits不为0,也就是func_addr
不是0x10对齐的,就会同时检查两个bit,一个是bit_offset & 0xFFFFFFFFFFFFFFFE
,一个是bit_offset | 1
,举个例子:如果
func_addr = 0x101
,那么检查bitoffset = 0x20
以及``bitoffset = 0x21`;如果
func_addr = 0x10F
,那么检查bitoffset = 0x20
以及``bitoffset = 0x21`;换句话说,在
func_addr
没有0x10对齐的情况下,最后判断的相应的bitmap
中的bit是同样的,故结果也是同样的。
且只有在这两个bit均为1的情况下,才能通过检查,否则都会判为无效,从而转入异常处理,也不会进行跳转。
如果
func_addr
的低4 bits为0,也就是func_addr
是0x10对齐的,那么会先后检查bit_offset
和bit_offset | 1
,举个例子:- 如果
func_addr = 0x100
,那么检查bitoffset = 0x20
;如果此时bit_offset
对应的bit为0,则再给一次机会,检查bit_offset | 1
是否为1;如果为1,那么目标地址有效,否则无效。
换句话说,在
func_addr
是0x10对齐的情况下,只要bit_offset
或bit_offset | 1
的其中一个对应的bit是1,那么目标地址都是有效的。- 如果
至于为何这里涉及了两个bit的检查,就不得而知了。
而至于CFGBitmap是如何生成的,涉及到比较复杂的细节。
对于动态链接库dll中的一些导出函数,其在CFGBitmap
中对应的bit都是共享的,也就是说要么都合法,要么都不合法。
而若仅仅针对当前进程,则有几个比较重要的相关结构,在_load_config_usedv
中可以看到
1 | .rdata:00007FF6862A3830 dq offset __guard_check_icall_fptr ; GuardCFCheckFunctionPointer |
__guard_check_icall_fptr
:前面已经提到过,实际指向ntdll!LdrpValidateUserCallTarget
,做具体的检查。__guard_dispatch_icall_fptr
:实际上就只是jmp rax
,不知作何用。__guard_fids_table
:该进程合法的跳转地址,也就是一个函数指针表,在程序加载的时候会完成从该RVA列表到具体CFGBitmap中对应bit的转化。GuardCFFunctionCount
:即RVA列表中函数指针的个数。
利用思路
- 首先利用上述提到的格式化字符串漏洞,leak出
ucrtbase
的基地址。注意由于windows程序和linux不一样,其运行的加载基址貌似在相当长的一段时间里是不会变化的(或许是不重启机器就不会变),dll的加载地址也是如此;所以一次leak,全程受用。 - 之后利用
edit
的溢出,覆盖函数指针为system
,并布置buffer为”type,pwn\westworld.txt”,调用attack
就会将flag打印出来。这里直接system("cmd.exe")
弹了shell后无法交互,所以只能直接读flag了。
这里还有个点就是,因为我们是没法输入空格的,所以”type flag.txt”这样的字符串是输不进去的,所以利用了一个小小的trick,也就是”type,flag.txt”中”,”在这里可以等同于空格来使用,完成这个bypass。 - 这里可以通过调试,跟到
LdrpValidateUserCallTarget
中进行判断,system
确实是合法地址;而至于为什么system
是合法地址,这里猜测dll的导出函数可能都会是合法的跳转地址,但是无法进行验证;这样,CFG的保护在这里似乎就没有什么作用了。
exp
1 | from winpwn import * |
[Windows][OGeek2019]BabyHeap & [Windows][ASIS 2017]Babyheap
题目分析
传统的菜单题,其中add
,delete
和show
都没有问题,只有edit
:
1 | else if (chunk_status[dwBytes]) |
显然存在一个heap overflow。
利用思路
- 由于
edit
功能没有自动在末尾补”\x00”,所以可以通过这个leak出下一个chunk头,它是被Encoding xor加密过的,我们可以通过手动还原出原chunk头然后跟加密过的头进行xor,从而得到Encoding;同样地可以leak出heap地址。因为这个heap是通过hHeap = HeapCreate(1u, 0x2000u, 0x2000u);
单独申请的,所以仅供当前线程使用,因此布局可预测,比较简单。 - 不同在于,linux下heap overflow通过覆盖fd,来劫持fastbin或者tcache bin的链表,达到任意地址写的目的。windows下的freelist是一个双向链表,所以要通过类似于unsorted bin的unlink attack进行利用,从而获得一个指向自己的指针。
- 通过提供的
shoot
功能,我们要将该chunk对应的chunk_status
中的标志置为1,从而使得可写,进而达到任意地址读写的目的。 - 有了任意地址读写,就可以利用如下的一条leak链: 从而找到main函数的返回地址在栈上的位置。
1
IAT ==> kernel32 ==> ntdll ==> ntdll!PebLdr - 0x44 ==> PEB ==> TEB ==> StackBase ==> return address
- 接下来就是覆盖返回地址执行
system("cmd.exe")
了。
exp
1 | from winpwn import * |
[Windows][HITCON 2018]Windows Land
题目分析
典型的菜单题,但是比较繁杂,总的来说,就是有五种操作:
1 | create |
以及五个操作对象:
1 | 1) Teacher |
由于是C++程序,仔细分析可以发现,五种对象都是以vector
形式存储的,增删通过相应的push_back
和erase
进行。
而其中比较与众不同的是Engineer
,它的成员里包含一个动态分配的指针,也就是language
成员变量:
1 | // create function |
这里先malloc
再free
的是一个局部变量,在进行push_back
中会进行拷贝,也就是会再分配一个同样大小的buffer作为language
,并把0x18 bytes的数据复制进去。
而漏洞存在于:
1 | // edit function |
也就是在edit
该Engineer->language
的时候,如果第一个字节为”\x00”,那么就会直接触发free
,但是并不会置为0(当然正常地edit
也是可以的,但是只能写0x18 bytes到buffer里面)。所以,这里存在UAF可以利用。
利用思路
- 由于windows的heap比较复杂,而且经过不断地调试发现,其布局是不可预测的,所以不太可能像linux下一样通过偏移直接定位到目标chunk;再加上程序刚开始读了一个随机数(0x00 ~ 0xF0),每次对
Engineer->language
进行malloc
的时候,都会在0x200的基础上加上该随机数,使得heap布局更加无法预测。经过长时间地调试发现,可以通过其他类型的操作对象(如teacher
等)先对heap上的碎片进行占位,考虑到vector
的增长方式是1、2、3、4、6、9、13、19、28、...
(capacity),对应的size为0x30、0x60、0x90、0xc0、0x120、0x1B0、0x270、0x390、0x540、...
,因此将相应的几个vector
占到0x270 bytes大小的chunk,之后对Engineer
进行操作的时候,heap布局就会稍微稳定一些:在1
2
3
4
5
6
70000021d873d7110 0028 0013 [00] 0000021d873d7120 00270 - (busy) ==> vector doctor
0000021d873d7390 0028 0028 [00] 0000021d873d73a0 00270 - (busy) ==> vector athlete
0000021d873d7610 0028 0028 [00] 0000021d873d7620 00270 - (free) ==> vector engineer (old old)
0000021d873d7890 0028 0028 [00] 0000021d873d78a0 00270 - (busy) ==> vector teacher
0000021d873d7b10 003a 0028 [00] 0000021d873d7b20 00390 - (free) ==> vector engineer (old)
0000021d873d7eb0 0055 003a [00] 0000021d873d7ec0 00540 - (busy) ==> vector engineer
0000021d873d8400 00bc 0055 [00] 0000021d873d8410 00bb0 - (free)0x0000021d873d7ec0
这个chunk还没有拿到的时候是一个大的freed
状态的块,其Blink
指向的是0x0000021d873d7620
。 - 同时发现,
Engineer->name
是没有自动在结尾补”\x00”的,再加上其位置为chunk的Flink所在位置,紧挨着Blink,而且进行拷贝构造的时候,Blink也会拷贝到这个vector
中,所以可以通过Engineer->name
来对heap进行一个leak,从而找到目标Engineer
的vector
的准确位置(也就是leak出来的Blink + 0x280 + 0x280 + 0x3a0 = Blink + 0x8a0
)。 - 由于
vector Engineer
的size达到0x540的时候,其成员个数在20 ~ 28
变化不会改变vector
的大小,所以可以利用这几个成员对Engineer->language
进行操作,比如这里我们申请出7个language
的buffer进行后续利用(实际上用不上这么多):这里显示的是依次1
2
3
4
5
6
7
8
9
10
11
12
13000001fbc7797fa0 0028 0028 [00] 000001fbc7797fb0 00270 - (free)
000001fbc7798220 0028 0028 [00] 000001fbc7798230 00270 - (busy)
000001fbc77984a0 003a 0028 [00] 000001fbc77984b0 00390 - (free)
000001fbc7798840 0055 003a [00] 000001fbc7798850 00540 - (busy) ==> vector engineer
000001fbc7798d90 002c 0055 [00] 000001fbc7798da0 002b0 - (busy) ==> language[20]
000001fbc7799050 002c 002c [00] 000001fbc7799060 002b0 - (busy) ==> language[21]
000001fbc7799310 002c 002c [00] 000001fbc7799320 002b0 - (free) ==> language[22]
000001fbc77995d0 002c 002c [00] 000001fbc77995e0 002b0 - (busy) ==> language[23]
000001fbc7799890 002c 002c [00] 000001fbc77998a0 002b0 - (free) ==> language[24]
000001fbc7799b50 002c 002c [00] 000001fbc7799b60 002b0 - (busy) ==> language[25]
000001fbc7799e10 002c 002c [00] 000001fbc7799e20 002b0 - (busy) ==> language[26]
000001fbc779a0d0 00ef 002c [00] 000001fbc779a0e0 00ee0 - (free)
000001fbc779afc0 0004 00ef [00] 000001fbc779afd0 00030 - (busy)free(language[22]); free(language[24]);
的状态,然后利用UAF改掉language[22]
的Flink
和Blink
,进行unlink attack。
这样,vector engineer
相应成员的language
(记为victim_1
)就指向了自己,从而可以借此进行任意地址读写。 - 这里采用先让该
victim
指向另一个未被删除的language
(记为victim_2
)的位置,从而避免了如果直接修改victim_1
本身,那么完成了一次任意地址写之后,就控不回victim_1
的情况了;另外,由于language
是不会被打印出来的,这里要通过修改engineer
的另一个类成员的title
指针,使其指向另一个未被修改的title
指针,从而在list
的时候将text
的地址打印出来。同样的方法,可以leak任意可读地址的数据。 - 需要注意的是,由于我们后续的目标是执行
system("cmd.exe")
,并且该调用会触发内存分配的操作,所以要求heap结构处于合法的状态,否则会被检测从而报错退出。因此,只要将victim_1
的原Flink
和Blink
的双向链表进行修复即可。 - 之后就是一个标准操作,通过binary的IAT表,leak出
ntdll
和ucrtbase
的基址,并通过ntdll!PebLdr
上方存在的PEB
相关地址leak出PEB
并且计算出TEB
的位置(两者偏移固定),然后将TEB->StackLimit
给leak出来得到该进程的栈地址,最后通过暴力搜索找到main
函数的返回地址。 - 最后通过任意地址写在
main
函数返回地址处写入ROP,从而在返回时触发调用system("cmd.exe")
。
虽然windows的heap还是具有一定的不确定性,成功率不是100%,主要在于最开始以0x8a0作为偏移这里可能会有不确定性,但是本地的交互速度以及准确率还是很可观的,马上就能有结果。
比较恶心的,本地尚且无法100%,加上远程环境毕竟和我本地的win10不同,所以实际在打远程的时候一度让我怀疑人生,一直都是失败,最后终于是成功了一次。
虽然我曾花了很大的功夫,企图提高成功率,但终究无果,不知道有没有更好的方法。
exp
1 | from winpwn import * |
[Windows][WCTF 2019]LazyFragmentationHeap
这题靶机上没有”magic.txt”,所以没法打通,因此只在本地复现了一下,学到不少东西。
题目分析
首先题目提供了一个菜单,常规的create
、edit
、show
、delete
一个chunk的功能,以及一个额外的open
、read
一个”magic.txt`文件的内容:
1 | 1. Allocate buffer for File |
需要注意的是:
这里我把相关的结构体定义一下:
1
2
3
4
5
6
700000000 file_buf struc ; (sizeof=0x28, mappedto_36)
00000000 exist_status dq ?
00000008 size dq ?
00000010 id dq ?
00000018 edit_status dq ?
00000020 buffer dq ?
00000028 file_buf endscreate
限制大小在0x80 ~ 0x2000
之间;edit
限制同一个chunk只能进行两次,因为每次edit
之前:1
2
3
4
5
6
7if ( !array[v16].buffer
|| array[v18].edit_status != 0xDDAABEEF1ACDi64
|| array[v18].exist_status != 0xDDAABEEF1ACDi64 )
{
puts("Error !");
exit(-3);
}都会检查
edit_status
,并且edit
之后:1
array[v18].edit_status ^= 0xFACEB00CA4DADDAAui64;
都会更改这个
edit_status
,因此下次就不能再写了。但是
edit
里面存在一个典型的通过strlen
获取写入长度的漏洞,因此可以覆盖到下一个chunk的头(6 bytes),构造chunk overlap。delete
限制只能使用两次。LazyFileHandler
里面open
次数不受限制,但是read
只能两次,但是可以任意size读(只要不超过文件大小)。
利用思路
- 首先
create
几个比较大的chunk,因为是在default heap上分配的,所以碎片比较多,但是size大的chunk不受影响,并且相邻:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17+-----------+
create(0x3F8, 1) ---> | 0x400 |
+-----------+ -----
create(0x408, 2) ---> | 0x410 | |
+-----------+ |
create(0x4E8, 3) ---> | 0x4F0 | |
+-----------+ 0x1000
create(0x378, 4) ---> | 0x380 | | ---> to be freed
+-----------+ |
create(0x378, 5) ---> | 0x380 | | ---> victim chunk
+-----------+ -----
create(0x3F8, 6) ---> | 0x400 | ---> gap
+-----------+
create(0x378, 7) ---> | 0x380 |
+-----------+
create(0x378, 8) ---> | 0x380 |
+-----------+ - 然后通过
LazyFileHandler
提供的open
和read
读取0x3F8 bytes的内容到chunk 1中,这样在show
chunk 1的时候,可以leak出chunk 2的header,但是被encode过了;不过只要手动还原出原始头然后decode,就能得到_HEAP->Encoding
了。 - 由于
edit
的逻辑是如果strlen
更长的话,读入的size就变长:因此对chunk 1进行1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18v19 = -1i64;
v20 = (_BYTE *)array[v18].buffer;
v21 = array[v18].size;
do
++v19;
while ( v20[v19] );
if ( v19 > v21 && array[v18].edit_status == 0xDDAABEEF1ACDi64 )
{
v21 = -1i64;
do
++v21;
while ( v20[v21] );
}
if ( read(0, v20, v21) <= 0 )
{
puts("read error");
_exit(1);
}edit
,就可以覆盖到chunk 2的header,将其size修改为0x1000即可覆盖chunk 2、3、4、5。 - 由于
LazyFileHandler
提供的open
可以无限调用,并且每次都会申请一个0x60大小的chunk存放FILE
结构体;再加上LFH(LowFragmentationHeap)的机制,到一定程度的时候,会开启LFH,即
分配一个大的chunk用作userblock(这里Slides里面提到的大小是0x1000,可能我本机环境不同,实际上只有0x810)。
然而实际调试中,先于0x60的chunk,0x20的chunk会先开启LFH,所以同样会从上面构造的0x1000的overlapped chunk中割出0x410作为userblock,这样FILE
结构体就落在overlapped chunk + 0x410的位置了。 - 此时chunk 3的位置正好存放了一个堆地址,这是原来chunk的Flink,通过show chunk 3,我们可以得到堆地址。(这里地址会有微小的变化,从而影响了后面需要用到堆地址的部分,也造成了最后的exp概率性失败,不过成功率依然很高)。
- 既然
FILE
结构体落在chunk 3、4的空间内,由于LFH位置的不确定性,具体FILE
落在userblock的哪个chunk里未知,但是我们可以全部填满伪造的FILE
结构体。
这里主要是要伪造FILE
的fd = 0
,以及buffer = 0xBEEFDAD0000 + 0x28 * 5 + 0x20
,即第六个file_buf
结构体的buffer
处,从而类似于Linux下_IO_FILE
的利用,后面调用LazyFileHandler
提供的fread_s((void *)array[v9].buffer, v10, 1ui64, v10, Stream);
,实际上是从标准输入中读到0xBEEFDAD0000 + 0x28 * 5 + 0x20
这个位置,从而可以覆盖掉file_buf->buffer
指针,达到任意地址读的功能。
由于windows的特性,无论是程序本身,还是加载的dll,在一定时间内其基址都是不变的,因此虽然程序运行一次只能调用read file两次,即只能完成一次任意地址读(或写),但是可以多次运行程序读不同的地址即可。
所以这里先把程序基址和ucrtbase的地址给leak出来了。 - 当然仅仅是任意地址读显然是不够得,我们得劫持程序控制流读出flag来。
注意到在FILE
所用的userblock被分配出来之后,我们之前构造的overlapped chunk仍然有0x3E0的空间留下来,而这个0x3E0的空间把chunk 5的header和Flink、Blink给overlap了。
所以可以分配出这个0x3E0的chunk,修改chunk 5的header为freed状态,以及修改Flink和Blink满足unlink check,再free chunk 4完成unlink attack,在0xBEEFDAD0000 + 0x28 * 4 + 0x20
写入了一个指向本身的指针。
另外,由于chunk 5本身是没有在Freelist或者ListHints中的,所以这些链表都没有corruption,只是可能存在被overlapped的LFH userblock被破坏了的问题,不过不影响后续的利用。 - 完成上述利用之后,我们可以构造出这样的primitive: 因此只要不断调用以上的primitive,就可以实现任意次数的任意地址读和写。
1
2
3
4
5
6
7
8
9
10
11file_buf[6]->buffer = &file_buf[6]->edit_status;
// then use chunk 6 to edit
file_buf[7]->edit_status = 0xDDAABEEF1ACD;
file_buf[7]->buffer = arbitrary_addr;
file_buf[8]->edit_status = 00xDDAABEEF1ACD;
file_buf[8]->buffer = &file_buf[6]->edit_status;
// then use chunk 7 to arbitrary read or write
....
// then use chunk 8 to edit, make chunk 6 can edit again
file_buf[6]->buffer = &file_buf[6]->edit_status;
file_buf[6]->edit_status = 00xDDAABEEF1ACD; - 但是存在的问题是,
p64(0xDDAABEEF1ACD)
中存在”\x1A”字符,在windows中,这个字符备用表示字符串流的结尾,因此如果输入中存在这个字符,那么它后面的字符都不会被接受,那我们上面构造的primitive就用不了了。
然而注意到,我们做leak和做最后利用的时候是分开的,也就是说read file的功能还只用了一次,于是我们可以同上面提到的任意地址读(或写)一次,将ucrtbase!_pioinfo[0] + 0x38
上1 byte的flag标志置为0x09
,这样可以把输入流模式从字符流改为二进制流,这样任意字符都可以读入了。
需要注意的是ucrtbase!_pioinfo[0] + 0x38
是个堆地址,所以其偏移是相对稳定的(有时会稍微变化),再加上堆地址我们早就得到了,所以完全可以预测到它的位置。 - 那么承接使用上面的primitive,我们可以:完成一条leak链,然后从stack搜索返回地址。
1
kernel32.ll -> ntdll.dll -> ntdll!PebLdr - 0x78 --> PEB --> TEB --> stack_end
- 注意到
main
函数是不会返回的,只会直接exit
,所以这里采用劫持_read
函数的返回地址的方法。
这是因为我们最后写ROP的时候,一定是通过edit
功能写的,而归根到底是通过_read
写的,那么覆盖_read
的返回地址就可以在返回的时候劫持到程序控制流了。
至于怎么定位_read
返回地址的位置,由于我们是通过show
功能进行leak的,最终也是通过调用printf
打印的,而printf
和_read
使用的栈帧是同样的,即返回地址存放在栈上的地址是一致的。
故我们只要以printf
的返回地址作为标志,搜索栈内存空间,即可同样定位到_read
的返回地址所在的位置。 - 此外有一个需要注意的点是,虽然AngelBoy的Slides中提到了Child Process Policy,在Ex师傅的博客地下的评论中也有”新版的Windows API新增 PROCESS_MITIGATION_CHILD_PROCESS_POLICY 的功能”这样一句话,但是实际在本地调试中并没有感受到它的存在,虽然最后写
system
的ROP时候并没有getshell,但其原因貌似在于LFH userblock corruption了,至于实际情况还有待考证。 - 因此这里采用Slides中的做法,也是最稳健的做法,即通过任意地址写在
.data
段上写入一段orw的shellcode,然后ROP调用VirtualProtect
修改.data
段的权限为RWX,最后跳转到shellcode执行即可。
exp
1 | from winpwn import * |
参考链接
- https://whereisk0shl.top/hitb_gsec_ctf_babyshellcode_writeup.html
- https://bbs.pediy.com/thread-221016.htm
- http://www.jbox.dk/sanos/source/win32/msvcrt/except.c.html
- https://whereisk0shl.top/post/hitb_gsec_ctf_babystack_writeup
- https://www.anquanke.com/post/id/188170
- https://www.anquanke.com/post/id/188170#h3-4
- https://www.cnblogs.com/lanrenxinxin/p/4631836.html
- https://b0ldfrev.gitbook.io/note/windows_operating_system/windowsseh-li-yong
- http://sjc1-te-ftp.trendmicro.com/assets/wp/exploring-control-flow-guard-in-windows10.pdf
- https://xz.aliyun.com/t/2587
- https://github.com/scwuaptx/LazyFragmentationHeap
- http://blog.eonew.cn/archives/1253#more-1253