[WARNING] 本文是对CSAPP附带的Buffer Lab的究极指北,PWN小白趁机来练习使用pwntools和gdb && 用老朋友IDA查看程序逻辑(可以说是抄小路了x。
LAB链接:CSAPP - Buffer Lab
任务说明书:buflab.pdf
真正的指南:Bufbomb缓冲区溢出攻击实验详解-CSAPP - 云+社区 - 腾讯云
本文环境相关:
- IDA pro 7.5
- Python 2.7.17
- gdb 8.1.1 (插件使用pwndbg)
- pwntools 4.4.0 (Python2的库)
Overview
任务说明
BufBomb
分为5个关卡:
Level 0
: Candle- Your task is to get
BUFBOMB
to execute the code forsmoke
whengetbuf
executes its return statement, rather than returning totest. If you succeed in doing that, you will “light up the candle” and see the “smoke” of it. - 通过缓冲区溢出使
getbuf()
返回时不是返回到test()
,而是去执行smoke()
。
- Your task is to get
Level 1
: Sparkler- Similar to Level 0, your task is to get
BUFBOMB
to execute the code forfizz
rather than returning totest
. In this case, however, you must make it appear tofizz
as if you have passed your cookie as its argument. How can you hear thefizz
of your sparkler? - 通过缓冲区溢出使
getbuf()
返回时带参执行fizz()
,参数为用户的cookie
。
- Similar to Level 0, your task is to get
Level 2
: Firecracker- Similar to Levels 0 and 1, your task is to get
BUFBOMB
to execute the code forbang
rather than returning totest
. Before this, however, you must set global variableglobal_value
to your userid’s cookie. Your exploit code should setglobal_value
, push the address ofbang
on the stack, and then execute aret
instruction to cause a jump to the code forbang
. - 通过缓冲区溢出使
getbuf()
返回时执行bang()
,执行时需满足global_value == cookie
。
- Similar to Levels 0 and 1, your task is to get
Level 3
: Dynamite- Your job for this level is to supply an exploit string that will cause
getbuf
to return your cookie back totest
, rather than the value 1. You can see in the code fortest
that this will cause the program to go"Boom!."
. Your exploit code should set your cookie as the return value, restore any corrupted state, push the correct return location on the stack, and execute aret
instruction to really return totest
. - 通过缓冲区溢出使
getbuf()
的返回值为用户的cookie
而不是1,并且能正常返回到test()
中,需注意old ebp
的保存和复原。
- Your job for this level is to supply an exploit string that will cause
Level 4
: Nitroglycerin- Your task is identical to the task for the Dynamite level. Once again, your job for this level is to supply an exploit string that will cause
getbufn
to return your cookie back to test, rather than the value 1. You can see in the code for test that this will cause the program to go"KABOOM!."
. Your exploit code should set your cookie as the return value, restore any corrupted state, push the correct return location on the stack, and execute aret
instruction to really return totestn
. - 需要进行五次攻击,每一次的场景与
Level 3
大致相同,只是每次的栈地址会发生改变。
- Your task is identical to the task for the Dynamite level. Once again, your job for this level is to supply an exploit string that will cause
BufBomb使用
打开二进制文件./bufbomb
可以看到help
需要输入对应参数,其中:
-u <userid>
为必填,但可以随便填(最好一直用同一个userid)。-n
开启最终关卡Level 4-s
为lab自带的提交系统,本地做可以不用管-h
打印help information
也就是说:前面做Level 0
~`Level 3的时候,启动程序的命令为
./bufbomb -u xxx(其中xxx是userid,可以任选),到
Level 4的时候命令则是
./bufbomb -u xxx -n`,来开启Nitroglycerin关卡。
(用IDA逆向时可以看到还有个参数是-g
,加了这个参数会限定时间。不过这里help没提就不管了x)
主流程解析
如果只是为了完成这个Lab的5个Level,本部分可直接跳过,这里只是解析一下程序的启动过程,方便理解后续操作。
用IDA打开bufbomb
,从main()
看起。
参数分发
这里的switch-case
部分是参数的分发,而必须执行的case u
部分是将输入的userid
作为参数传进gencookie()
中来生成cookie
,gencookie()
里是:
大致逻辑是用userid
的hash值作为srand()
的种子,然后用rand()
生成合法的cookie
。
由此可见,对于同一个userid来说,cookie
是相同的。
还有一个需要关注的地方是case n
,这是一个用来打开Level 4
的开关,设置v10=1;v3=5;
,在后面的分析中可以知道v10
是用来看此时状态是否为Level 4
的标志(1为打开Level 4
,默认是0);v3
则是用来控制栈地址的个数的,在后面的分析中会详细说明。
case s
是我们完全不用关心的分支(lab的提交系统),notify
标志着是否有输入这个参数,所以在后面的分析中,notify == 1
相关的分支我们可以不用理会。
usage()
是输出help信息,依然可以忽略。
对实验场景的初始化
回到main函数往下看,initialize_bomb()
就是系统的初始化作用,主要是notify == 1
的处理,不用考虑。
然后输出了userid
和cookie
。
再下面的逻辑中,用cookie
作为srandom()
的种子,然后用random()
生成随机数依次给变量v9
和v5
数组赋值。
这里可以看到,v9
的范围是[0x100,0x10f0)
,v5[i]
的范围是[-0x70,0x80)
。
v5
数组是用来保存栈基址偏移的int32数组,默认情况下v3=1
,即只有一个栈基址偏移(第一个为0),这样就能保证栈基址偏移不变;Level 4
情况下v3=5
,保存了5个栈基址偏移,并且需要执行5次launcher()
。
最后传进launcher()
里的是v5[i]+v9
。
由此可见,虽然题目里说**Level 4
的五次攻击栈基址是不同的,但因为random()
的种子是cookie
,所以实际上是可以全部算出来的**。于是在打Level 4
的时候就可以完全照搬Level 3
的办法,只用改栈基址就好。
总结:主函数后面的部分就是初始化要传进launcher()
里的参数,然后走launcher()
。
launcher()
解析
a1
就是主函数里的v10
,这里传给global_nitro
,这个全局变量就是用来标志是否为Level 4
场景的,1则是0则否。
a2
则是主函数的v5[i]+v9
,传给了全局变量global_offset
,用来在后面的launch()
函数中设置栈基址。
mmap()
这里是把reserved
开头的 0x100000
字节开了可读可写可执行的权限,即[0x55586000,0x55686000)
。这段内存是用来做栈空间的,因为按照实验目的来说这个实验需要在堆栈固定的情况下才能实现,所以为了克服linux下文件的堆栈随机化,直接开辟了一个固定地址的栈空间出来,**launcher()
主要做的就是栈迁移的工作**。
stack_top
实际上就是这块空间的最后8byte
(这里我也不知道为什么预留了8byte
而不是4byte
,摊手),标志着整块空间的最高地址。
开了内存,接下来就要把esp
挪过去了,这里需要去看汇编:
这一段是把程序正常的esp
保存在ds:global_save_stack
中,然后将esp
迁移到stack_top
处,然后call launch()
,返回时从ds:global_save_stack
复原原来的esp
。
程序正常的esp
:esp -> eax -> edx -> ds:global_save_stack -> call luanch()
-> eax -> esp
实验场景中的esp
:offset unk_55685FF8(即stack_top) -> edx -> esp -> call luanch()
至此把esp
挪到了这块空间的最高地址处。
至于为什么不把ebp
也一并挪了……一般函数开头不都是经典两句:
顺便就保存ebp+挪了ebp啊(
接下来使用的栈空间就是这块reserved
了。
launch()
解析
这就是我们实验的主函数:
a1
就是global_nitro
,标志是否为Level 4
;若为Level 4
则走testn()
,否则走test()
。
a2
是传进来的栈基址偏移,即global_offset
,在alloca()
中起到让esp偏移的作用(alloca()
申请的内存在栈上,所以((&savedregs-72)&0x3FF0)+a2+15
越大即a2
越大,则申请到的空间越大,栈顶指针esp指向的地址越低)。这就是Level 4
模式下栈基址不同的来源(此模式下a2
不同,esp
也不同,就是说进到testn()
时的栈底地址也不同)。
而memset
的作用是把这一段全部用0xF4
填充,也就是说一旦执行到这一块会引发一个段错误(?)。
至于具体的原理看源码可以看到,是一个对栈调整的小技巧:
接下来就可以看各个Level
的任务了。
其余函数作用概括
test()
:Level 0-3
的主函数。testn()
:是Level 4
的主函数,与test()
差不多,唯一的区别在于输入调用了getbufn()
而不是getbuf()
。getbuf()
:创建一个40 bytes
的空间用来放输入。其中Gets()
中除了输入字符串以外(末尾换行符置0),还做了一些对notify == 1
时提交到评分系统的处理,因为这是我们完全可以忽略的,所以可以当成C标准库里的gets()
来用。getbufn()
:同getbuf()
,不过这里是用一个520 bytes
的空间来存输入。uniqueval()
:用当前进程号作为srandom()
的种子,返回一个random()
随机数。不过在同一个程序执行时,这个返回的数应该是一样的(摊手)。用在test()
/testn()
中起到一个自制canary
的作用,防止test()
和testn()
的栈溢出(正常的溢出应该控制在getbuf()
/getbufn()
里)。validate()
:走到这个函数就说明你的这一关卡成功了(Ohhhhhhh),调用的时候是calidate(x)
就说明通过了第x关,这里只是在进行收尾工作。让success=1
,并且计算每一关需要通关的次数及是否达到(前面四关为1,最后一关为5)。notify
相关照例不用理会。
该解释的都解释完了,现在开始做题(冲!
Level 0
Level 0
是一个最基础的缓冲区溢出,只要操控返回地址就可。
我们知道,在函数调用过程中(比如进到getbut()
里时),栈的情况是:
(这里用四字节为一个单位,数组的标注形式用的是Python
里的切片 /指前闭后开)
在汇编走到
call
的时候,程序会自动将下一条指令的地址压进栈里,然后跳到这个call
的函数。在函数开始时,一半会有经典两句push ebp; mov ebp, esp
来保存上一个栈帧的ebp
,也就是图里画的old ebp
。
我们需要关注的是高亮的这块ret addr
,只要让输入的v1
足够长到覆盖这里就可,很容易看出v1需要输入:40个字节覆盖v1
+4个字节覆盖old ebp
+4个字节的返回地址(这里需要使用smoke()
的地址即0x8048B50
,这样就能操控ip返回到这个函数了)。
因为smoke()
最后直接用exit(0)
退出程序,不必回到上层函数,所以也不用管栈平衡和复原ebp
的问题。
最后用python2
的pwntools
写exp有:(省略了import
和主函数部分,这里只贴关键代码)
1 | def level0(): |
然后调用level0()
即可。
Level 1
跟Level 0
的区别是fizz()
是一个带参执行函数:
只有在调用fizz()
的时候传入参数cookie
才能通关。
这里需要知道Linux x86
的函数调用方式是依次将参数从右往左入栈,也就是说是从栈上取参数的。
正常函数call
的时候会把返回地址压栈,所以取参数是从栈顶下一个单元开始取的。
即foo(arg0,arg1)
调用时的栈情况为:
而在这道题里,fizz()
是直接改了ip跳过去的(相当于jmp
),并没有将返回地址压栈,但是取参数的时候仍然是按照这种规律来取,所以要空一个单元再放参数。
所以需要构造栈的分布为:
在这里因为fizz()
是通过exit(0)
直接退出程序的,所以依然不用管栈平衡。不过一般来说中间的这个随便填单元可以是rop
链,这里选用了pop ebx; ret
(地址在0x0804875d
处,类似的这种可以用ROPgadget
等工具找)。
这样就可以在返回的时候调用这两条语句,进而将压进去的cookie
值从栈上清掉,回到正常的函数流程。
反正这里不必这么麻烦,随便填单元可以随便填,但是习惯来说还是用这个pop ebx; ret
的地址填上了(也就是exp里的pop_ebx
)。
关键代码如下:
1 | def level1(): |
Level 2
Level 2
需要跳转的函数也是无参函数,跟Level 0
的区别在要让全局变量global_value == cookie
才能过关。
因为是改全局变量,所以考虑写shellcode,直接用mov
来改。
shellcode为:(这里只是用来说明思路,用&
表示取地址,不符合汇编语法)
1 | mov dword ptr [&global_value],cookie |
先让global_value=cookie
,然后把bang()
的地址压栈,这样在下一步ret的时候就会返回到栈顶存的地址即跳到bang()
函数。
因为这里栈空间开的权限是rwx
(可读可写可执行),所以这段shellcode可以直接放在栈上,现在需要的就是让前面的ret addr
等于这段shellcode的首地址,这样就能跳到shellcode处执行。
需要构造的栈空间分布是:
现在要填的内容只差shellcode_addr
是没拿到的。
这里可以用pwntools里提供的gdb接口进行调试来拿(发送的payload为'a'*43
,这样可以很容易找到返回地址和后面的地址在哪里,注意pwntools的sendline()
自带末尾换行符也会被输进去,所以要少输一个字节)。
执行到getbuf()
时用hexdump
看esp
地址往后的十六进制,蓝框处是我们的ret addr
该填的地方,绿框开始的部分就可以用来填shellcode(首地址为0x55683928
)。
也可以直接用stack
看栈布局:
同样可以看到0x55683928
这个地址是可以开始填shellcode
的地方。
于是写exp有:
1 | def level2(): |
Level 3
Level 3
的关键点在:
- 让
getbuf()
的返回值为cookie
。 - 维持堆栈平衡,注意
old ebp
的复原。
所以程序流程是:getbuf()
-> shellcode
(把放函数返回值的寄存器即eax
的值改成cookie
) -> 回到test()
里调用getbuf()
的下一行(返回地址用ret_addr
记录)。
shellcode_addr
跟Level 2
的一样,ret_addr
可以在IDA中看到是0x8048CD6
:
现在就差需要复原的old ebp
是未知量,用gdb调就可以拿到。
跟Level 2
的调试流程同,不过payload只输'a'*39
就好(同之前一样,pwntools的sendline()
自带一个换行符,被Gets()
置0了),因为要拿到覆盖前的old ebp
。
蓝框处就是old ebp
,用p/x
把这个值的十六进制打印出来是0x55683950
。
所以填进exp:
1 | def level3(): |
Level 4
Level 4
的要求和Level 3
大致相同,除了要攻击5次,并且这5次的栈基址会发生变化。
从前面的分析可以知道,栈基址的变化是通过事先用random()
生成5个随机数然后分别传值实现的(保存在main()
的v5
中),那我们可以通过同样的方式生成这五个随机数,在Level 3
的基础上把地址稍作改变就可。
编写生成这样五个随机数的rand.c
有:
1 |
|
用gcc rand.c -o rand
编译,得到二进制文件rand
。
在exp中就可以用pexpect
模块进行交互,将cookie
输入并拿到输出的五个随机数。
与Level 3
的exp相比,shellcode完全可以复用(与栈基址无关),而程序流程完全相同,只有栈基址发生了变化(以及预期输入的长度从40变到了520),所以只要相应地改变old ebp
和shellcode_addr
就好。
同样是从前面对launch()
分析中可以知道,其他关卡的基址和Level 4
的第一次是一样的,栈基址是通过申请空间的大小来操控,申请的空间与v5[i]
有关,v5[i]
越大申请的空间越大,栈基址就越低。
所以可以通过倒推得到这个加上随机数之前的base
。
1 | def level4(): |
最后五个关卡的exp汇总有:
1 | #!/usr/bin/env python |