CTF-Pwn丨栈溢出入门题目思路解析
Pwn作为CTF比赛中的必考题型,用一句话概括就是难以入门。
Pwn确实难,难点主要在于知识点不系统、考点太高深、对新手不友好。
其实只要我们多做多练,了解出题套路,掌握答题思路,还是能够快速入门的。
i 春秋论坛作家「SkYe231」表哥总结了几种Pwn入门栈溢出题目的解题思路,旨在为大家提供更多的学习方法与技能技巧,文章仅供学习参考。
PS:栈溢出入门题目可能不局限下面总结的,诸如利用栈溢出修改局部变量触发某些条件getshell等没有总结到位,感兴趣的小伙伴可以在i春秋官网进行系统学习。
Web端看课体验更佳,看课地址:https://www.ichunqiu.com/newRelease/webaqgcs
ip根据题目位数不同分别代指eip或rip ,bp及sp同理。以下小结均是基于程序为动态链接。
完整后门&溢出长度大于等于ip
非常基础入门题目,用于了解学习栈溢出控制寄存器ip ,关键代码如下:
int vul()
{
char s; // [esp+Ch] [ebp-6Ch]
puts("input:");
gets(&s); //栈溢出漏洞,溢出长度不限
return puts("OK,Bye!");
}
通过分析找到后门函数,且这个后门是一个完整可用的后门,就是只要我们调用这个后门函数,不需要其他额外的操作就能getshell,通常是system('/bin/sh'),write(1,'flag',32)等形式。
int getshell()
{
return system("/bin/sh");
}
思路:
有完整可用后门的情况下,只要溢出长度能够覆盖ip即可。直接利用栈溢出将getshell函数地址写入到eip位置,结束当前函数后就会去运行getshell函数payload结构:
payload = [填充] + [get_shell_addr]
残缺后门&溢出长度远大于ip
栈溢出漏洞函数如下(和上一个小结一样):
int vul()
{
char s; // [esp+Ch] [ebp-6Ch]
puts("input:");
gets(&s); //栈溢出漏洞,溢出长度不限
return puts("OK,Bye!");
}
这里指的远大于ip,意思是可以溢出覆盖除了ip以外的3个以上的(一般情况,实际情况实际分析)机器字长(32位4 bit;64位8bit)。
残缺后门的形式多种多样:提供诸如system等关键函数;flag、/bin/sh等字符串;某些gadget等。这里就整理前两种情况,刚好对应上面的完整后门。
提供关键函数&字符串
一般给出的是system函数,在ida中一般呈现形式:
int getshell()
{
return system("echo no no no!!!");
}
这样就能通过system.plt调用system函数。
字符串/bin/sh可以在ida中字符串界面可以查询到(shift+f12),存放在bss区,在源码中通过定义变量来提供。通过地址来调用这个字符串参数。
思路:
对比完整后门发现这里是将函数和参数分割,但是两者都在程序中,所以可以通过栈溢出自行构造完整后门,这里就涉及传参方式方面知识:32位栈传参;64位前6个参数寄存器传参,后续更多参数遵循栈传参。
自行构造完整后门(调用链)对溢出长度有要求了,至少要能溢出除ip以外再多2个机器字长,用于写入调用步骤。
32位程序:
payload=[填充]+[system@plt]+[4bit填充]+[参数]
64位程序:
64位系统寄存器传参需要用到gadget,可以使用ROPgadget查找程序乃至libc库(需要泄露基地址)。一般来说前两个参数rdi rsi的gadget比较常见容易得到的。
payload=[填充]+[pop_rdi_ret]+[参数]+[system@plt]
提供关键函数
关键函数不单单指的system、puts、read、write等我们用于写入读取的辅助函数一定程度上也算是关键函数,但是为了方便总结这里的关键函数指的是system这类函数。
system函数在ida中呈现方式同上小结。与上面小结相比缺少的是字符串/bin/sh,对比完整后门来说就只剩下system。
这里结合上面小结对比缺少一个/bin/sh,那就先构造一个输入的利用链将字符串输入到内存,然后再和上面一样构造system利用链,这里就涉及ROP利用思想。system利用链不再重复,与上面小结一致。输入利用链构根据题目出现输入函数不同而实际分析,一般用的都是read,也有scanf、gets 。
根据溢出长度不同还能细分为两种情况:一次性利用链;ROP利用链。
一次性利用链
这种情况适用于溢出长度比较大,一般能有7个机器字长左右,具体情况具体分析。这种利用的思想是用输入函数read将/bin/sh写入到bss段后调用system('/bin/sh') 。
32位程序:
payload=[填充]+[read@plt]+[system@plt]+[参数1]+[binsh写入地址]+[参数3]+[binsh写入地址]
64位程序很少用,因为寄存器传参需要用到gadget,这样会导致需要非常大的溢出空间,构造起来和32位思想一样不再重复。
ROP利用链
和一次性利用链思想上最大区别就是运行完自构写入函数后,返回main(或其他)函数,相当于让程序重新运行一次,有第二次利用漏洞机会,写入getshell利用链。
相比之下优势:payload长度变短,有效应对溢出长度不足情况。
32位程序:
payload1=[填充]+[read@plt]+[main]+[参数1]+[binsh写入地址]+[参数3]
payload2=[填充]+[system@plt]+[4bit填充]+[binsh写入地址]
64位程序:
pop_rdi都会有单独的gadget,rsi多数以pop_rsi_r15形式出现,rdx少有gadget,这里假设gadget情况如下,实际做题实际调整:
- pop_rdi_ret
- pop_rsi_r15_ret
- pop_rdx_ret
payload1=[填充]+[pop_rdi]+[参数1]+[pop_rdi_r15]+[binsh写入地址]+[8bit填充]+[pop_rdx]+[参数3]+[read@plt]
payload2=[填充]+[pop_rdi]+[binsh写入地址]+[system@plt]
可以看到payload1还是长的,且rdx gadget不一定有。这是可以尝试不传第三个参数,用寄存器rdx的原值,这个值有可能符合需要(若需要控制前三个参数了解一下ret2csu),payload简化如下:
payload1=[填充]+[pop_rdi]+[参数1]+[pop_rdi_r15]+[binsh写入地址]+[8bit填充]+[read@plt]
payload2=[填充]+[pop_rdi]+[binsh写入地址]+[system@plt]
提供字符串
就是给/bin/sh,其实这样等于没给,需要用到的函数都需要去libc中找,等同于没有后门,故这里不小结,思路和下面无后门一样。
无后门&溢出长度远大于ip
从这种题目开始就接近实际题目了。首先是无后门,也就是既没有system也没有/bin/sh等条件,只有一个栈溢出的漏洞。这个小结中溢出长度定义是远大于ip,就继续沿用上面的漏洞函数gets,实际中更多的是用read函数输入。
int vul()
{
char s; // [esp+Ch] [ebp-6Ch]
puts("input:");
gets(&s); //栈溢出漏洞,溢出长度不限
return puts("OK,Bye!");
}
思路:
既然程序中没有system就到libc中找,也就是需要泄露libc基地址。方法是泄露一个位于libc中的函数的真实地址,将真实地址减去函数偏移得到基地址。/bin/sh也能在libc中找到不再赘述。
泄露地址需要一个(程序中出现过的)输入函数,常见的有puts、write等。泄露完成后需要第二次利用漏洞的机会,所以也就是涉及ROP利用思想。
32位程序:
puts
payload1=[填充]+[puts@plt]+[main]+[某函数@got]
payload2=[填充]+[system@libc_addr]+[4bit填充]+[binsh@libc_addr]
write
payload1=[填充]+[puts@plt]+[main]+[某函数@got]
payload2=[填充]+[system@libc_addr]+[4bit填充]+[binsh@libc_addr]
64位程序:
puts
payload1=[填充]+[pop_rdi]+[某函数@got]+[puts@plt]+[main]
payload2=[填充]+[pop_rdi]+[binsh@libc_addr]+[system@libc_addr]
write
稳妥来说需要控制3个寄存器:write(1,[输出地址],[输出长度]) 。如果找不到rdx的gadget和[上面小结](#ROP利用链)一样尝试看看rdx原值是否符合需求,如果要不满足就得用ret2csu技巧控制前三个寄存器。
无后门&溢出长度等于ip
溢出只能够刚好覆盖完ip就不能再溢出更多字节,显然是不能写入完整的利用链,这种情况可以考虑栈迁移。栈迁移就是将原本写在ip后面的利用链写到其他地方,通过控制bp、ip将栈迁移到利用链的位置,运行提前布置的利用链。
首先这种题目会有一到两次输入的机会,输入内容可能会是局部变量或者全部变量(bss段)。栈迁移需要明确的迁移地址,基于存放位置不同难度也不同:
全局变量
写到bss段就比较简单了,迁移地址直接到ida查全局变量地址即可。
局部变量
局部变量在栈上,栈地址是动态的,所以要先泄露栈地址才能进行栈迁移,但是也有用sub esp,xx这种神奇gadget将栈抬高的操作。如果用迁移到栈上一般会有两次输入一次,一次是泄露栈地址,一次是写入利用链。
需要注意一点:假如写入地址和迁移地址为addr,有效利用链需要从addr+一个机器周期开始写入,具体自行调试查看sp变化。
思路
为了便于总结假设一个32位程序有两次输入,第一次向全局变量var_1(地址为:addr)写入,第二次向局部变量s写入。
// 32位程序
int vul()
{
char s; // [esp+Ch] [ebp-6Ch]
puts("input:");
gets(&var_1); //栈溢出漏洞,溢出长度不限
puts("input:");
read(0,&s,0x74); //栈溢出漏洞,溢出长度有限
return puts("OK,Bye!");
}
提前用ROPgadget找到leave;ret gadget,然后构造payload如下:
payload1='a'*4+p32(puts@plt)+p32(main)+p32(puts@got)
payload2='a'*0x6c+p32(addr)+p32(leave;ret)
泄露地址后返回main函数,第二次运行程序。后续getshell可以用onegadget或者system(/bin/sh)。溢出ip为onegadget就能getshell。system(/bin/sh)需要再次栈迁移。
onegadget
payload3=[任意内容]
payload4='a'*0x6c+'a'*4+p32(onegadget)
system(/bin/sh)
payload3='a'*4+p32(system@libc_addr)+p32(main)+p32(binsh@libc_addr)
payload4='a'*0x6c+p32(addr)+p32(leave;ret)
对于32位程序如果输出函数只有write影响不大,而64位程序如果输出函数只有write没有puts等少参数的函数,可能会比较棘手,原因还是在于第三个参数rdx缺少gadget,需要实际调试查看rdx的原值是否需求,如果不符合需要赋值就要ret2csu,将csu利用链写到迁移地址上。
总结
这里归纳了几种入门题目常见套路,但是套路远远不局限于上文,比如栈溢出修改局部变量,绕过PIE保护等等,后续会分享相关内容,大家敬请关注。