BUUCTF get_started_3dsctf_2016 wp
1.使用checksec命令查看文件的保护机制开启情况:
checksec --file=./get_started_3dsctf_2016
显示除了NX保护均未开启或完全启用 ,说明此题难度系数不高,而NX开启说明数据区域(如栈、堆)不可执行,是防止将 shellcode 写入数据区后跳转执行,所以可优先考虑不利用shellcode注入且简单直接的方式寻找漏洞。
2.利用IDA静态分析:
main函数中出现了危险函数gets,提示此题为栈溢出类型。且v4偏移量为 56 + 4(真的吗?)。
get_flag函数中出现了目标文件 "flag.txt" ,并与 fopen 搭配使用。而根据对伪代码的逻辑分析可知,后续代码逻辑就是通过循环逐字符打印 flag.txt 中的字符串(即flag)。所以,只需要满足进入核心逻辑的判断条件 a1 == 814536271 && a2 == 425138641 即可:由函数定义 void __cdecl get_flag(int a1, int a2) 可知,a1、a2是作为两个参数在函数调用时传入,那么便可以在后续构造payload时,讲符合判断条件的a1、a2连同函数起始地址一同传入进行覆盖。
从get_flag函数的汇编详情界面可获得函数的起始地址0x080489A0,以及判断条件指定值对应的16进制数:a1--814536271--308CD64F,a2--425138641--195719D1。
3.编写python脚本:
from pwn import *
r = remote('node5.buuoj.cn', 29558)offset = 56 + 4
get_flag_addr = 0x080489A0payload = b'A' * offset + p32(get_flag_addr) # 实现栈溢出覆盖
payload += p32(0) # 填充返回地址
payload += p32(0x308CD64F) + p32(0x195719D1) # 注入满足判断条件的参数值r.sendline(payload)
r.interactive()
详细解读:
1. 为什么需要在p32(get_flag_addr)
后传入一个返回地址(p32(0)
)?
这是由x86架构的函数调用约定和栈帧结构决定的。当发生栈溢出并覆盖返回地址时,栈的布局必须模拟一次正常的函数调用过程。
-
正常函数调用(call指令):
当程序执行
call get_flag
时,会先将返回地址(即call下一条指令的地址)压入栈顶,然后跳转到get_flag
函数。函数内部通过ret
指令结束时,会从栈顶弹出这个返回地址,并跳转回去继续执行。 -
溢出攻击中的模拟:
payload通过溢出覆盖了原返回地址,将其改为
get_flag_addr
。但get_flag
函数执行完毕后,同样需要执行ret
指令,此时它会从栈顶读取一个值作为返回地址。因此,你必须在get_flag_addr
之后立即放置一个有效的返回地址,否则程序会跳转到非法地址导致崩溃。(此题由于当get_flag
函数执行完毕后,flag已被打印出来,并不需要关注函数后续跳转到哪里)——这对吗?
2.为什么a1
和a2
的值需要用16进制且由p32()
打包?
这与数据表示方式和架构要求相关,并非必须用16进制,但需要正确转换为字节序列。
-
参数值的本质:
a1 == 814536271
和a2 == 425138641
是十进制整数,但在内存中参数以二进制字节形式存储。16进制(如0x308CD64F
)只是更便捷的表示方式,本质上与十进制等价。 -
p32()
的作用:p32()
是pwntools提供的函数,用于将32位整数打包为小端序的4字节序列。因为x86架构使用小端序(低位字节在前),直接传入十进制数会导致字节顺序错误。例如:-
814536271
的十六进制是0x308CD64F
,p32(0x308CD64F)
会输出字节序列\x4f\xd6\x8c\x30
。 -
如果直接传入十进制数
814536271
,打包结果相同,但16进制更易读且便于调试。
-
-
为什么不能直接传入字符串?
参数是整型(
int
),而非字符串。栈上的参数必须按内存布局直接写入原始字节,不能以字符串形式传递(否则会被解释为ASCII码,导致值错误)
3.x86架构(32位)下的参数传递与Payload顺序
在x86架构中,所有函数参数都通过栈传递,且压栈顺序是从右向左(即先压入最右边的参数)。
-
正常函数调用时的栈布局(以
get_flag(a1, a2)
为例):-
先将参数
a2
(第二个参数)压栈。 -
再将参数
a1
(第一个参数)压栈。 -
执行
call get_flag
指令,该指令会将返回地址压栈。由于栈是从高地址向低地址增长的,压栈完成后,栈上的布局从低地址到高地址依次为:返回地址 → a1 → a2。也就是说,第一个参数
a1
在内存中的位置反而更靠近低地址(在返回地址之后),第二个参数a2
在更高地址。
-
-
栈溢出攻击中的Payload布局:
通过溢出覆盖返回地址后,需要模拟上述正常的栈帧结构。因此,在覆盖了新的返回地址(即
get_flag_addr
)之后,栈上的布局必须是:[get_flag_addr] + [返回地址(如exit_addr)] + [a1] + [a2]
这里的关键是:
-
虽然压栈顺序是从右向左(先
a2
,后a1
),但在内存中的存储顺序是从左向右(先a1
,后a2
)。 -
所以Payload中
a1
在a2
之前,正是为了符合函数内部通过ebp+8
访问第一个参数a1
,通过ebp+12
访问第二个参数a2
的内存布局要求。
-
4.运行脚本:
????????为什么会报错呢????????
错误信息解读:
-
timeout
: 远程服务在等待你的exploit执行完成时超时。 -
the monitored command dumped core
: 你发送的payload导致目标程序出现严重错误(如段错误),操作系统终止了进程并生成了core dump文件。
这个错误尤其常见于64位架构的pwn题。你的exploit可能在本地测试成功,但在远程打不通,主要原因往往与栈对齐问题有关。
But,此题是32位架构,为什么会出现此错误呢?——看来是payload出了问题。。。
payload = b'A' * offset + p32(get_flag_addr) # 实现栈溢出覆盖
payload += p32(0) # 填充返回地址
payload += p32(0x308CD64F)
payload += p32(0x195719D1) # 注入满足判断条件的参数值
注入满足判断条件的参数值这一步肯定是没有问题的,由以上种种分析可知,由于栈帧规则等限定,该步骤两个参数的传入形式和顺序都是正确的。那么,对于第一步实现栈溢出的覆盖,如果有错误的话,一定是偏移量offset的问题;对于第二步填充返回地址,有可能此题不能随便填一个地址,可能有约束条件。
错误分析:
1.对于第二步填充返回地址,重新对伪代码进行分析:
1. fopen函数:
此程序中的特殊之处就是使用了fopen,与文件产生链接,对于fopen函数:
数据的存储位置:
当你使用 fopen
打开一个文件时,数据实际上涉及两个主要的存储位置:
-
最终归宿:磁盘
文件本身的内容,也就是你希望长期保存的数据,始终存储在硬盘等外部存储设备上。
fopen
的作用是建立一条从你的程序到磁盘上这个文件的通道。 -
高速中转站:内存缓冲区
为了提升读写效率,C标准库在内存中开辟了一块区域作为I/O缓冲区。当你用
fprintf
、fwrite
等函数写入数据时,数据通常先被放入这个缓冲区,而不是立即写入硬盘。当缓冲区满了,或遇到特定条件(如程序正常结束、主动刷新)时,数据才会被一次性批量写入磁盘,这大大减少了直接操作磁盘的次数。 -
控制中心:FILE结构体
fopen
返回的FILE*
指针,指向一个FILE
类型的结构体。这个结构体可以理解为文件流的“控制中心”,它记录了文件描述符、缓冲区的位置和状态、当前读写位置、错误标志等关键信息。这个FILE
结构体本身是在fopen
函数内部通过malloc
等函数在堆上动态分配内存的,因此你需要用fclose
来释放这块内存。
因此,fopen函数读入数据丢失的风险主要来自于数据还滞留在“中转站”(内存缓冲区)而未到达“最终归宿”(磁盘)。
主要原因包括:
-
程序异常终止:如果程序因为崩溃、被强制杀死(如按下Ctrl+C)或断电而突然结束,缓冲区中的数据将来不及写入磁盘,从而丢失。
-
未关闭文件:
fclose
函数在执行时,会先将缓冲区中的剩余数据写入磁盘,再释放资源。如果忘记调用fclose
,不仅可能导致数据丢失,还会造成内存泄漏。 -
缓冲区未刷新:对于需要实时确保数据落盘的场景(如记录关键日志),如果仅写入缓冲区而没有主动刷新,在下次定时刷新之前发生异常,数据就会丢失。
综上:正确关闭文件会自动刷新该文件对应的缓冲区 + 程序正常终止时,会自动清理并刷新所有已打开的文件缓冲区,然后关闭文件 ——> 可防止数据丢失,保证正常回显。
核心概念 | 关键操作 | 作用与说明 |
---|---|---|
文件缓冲区 |
| 最推荐、最根本的方法。关闭文件会自动刷新该文件对应的缓冲区,确保数据写入磁盘。这是任何文件操作完成后都应执行的步骤。 |
| 强制刷新指定文件的缓冲区,将数据立即写入磁盘,但文件保持打开状态。适用于需要实时持久化数据又不想关闭文件的场景。 | |
程序退出 |
| 程序正常终止时,会自动清理并刷新所有已打开的文件缓冲区,然后关闭文件。 |
| 效果与调用 | |
异常终止 (如崩溃) | 缓冲区不会被刷新,数据可能丢失。 |
再次对伪代码进行分析,可以发现,虽然函数末尾存在 fclose ,但其上游代码do-while循环的终止条件是 v5 != 255 ,这意味着当getc
函数读取到字符值等于255时,循环便会退出。而在C语言中,getc
函数在遇到文件结束或发生错误时会返回EOF
。EOF
是一个宏,其值通常被定义为-1(这取决于编译器,但常见实现是-1)。那么这里将v5
(getc
的返回值,类型是int
) 与255
进行比较:如果EOF
的值确实是-1
(0xFFFFFFFF
),那么它不可能等于255
(0xFF
)。如果文件顺利读完,循环会在遇到EOF
后继续尝试读取,因为-1 != 255
,这会导致无限循环。所以,函数末尾的fclose大概率因无限循环无法跳出而失效,导致运行脚本后的 timeout
: the monitored command dumped core
超时报错。
那么,便需要让程序正常终止,但对于正常逻辑 return
(从main函数退出) 这种退出方法,对于此题并不适用,因为 payload 注入后,main函数返回地址已被覆盖为 get_flag 的地址,遭到破坏。
注入payload后的栈空间结构(从高地址到低地址)
地址方向 | 内存内容 | 大小 | 值(示例) | 说明 |
---|---|---|---|---|
高地址 |
| 4字节 |
|
|
↑ |
| 4字节 |
|
|
↑ | 返回地址(用于 | 4字节 | ------- | 模拟 |
↑ | main的返回地址(被覆盖为 | 4字节 |
| 原为main返回到libc的地址,现被覆盖为 |
↑ | main的保存的EBP(被覆盖为垃圾数据) | 4字节 |
| 原为调用main函数的帧指针,溢出后被覆盖为任意值(如 |
低地址 |
| 56字节 |
| 覆盖main的局部变量 |
因此,想要程序正常终止,我们只能尝试 exit() 函数。
很幸运,我们发现程序中给出了exit函数,并获得了起始地址0x804E6A0。
但是,经过搜索,程序中还有一个 _exit函数,这又是什么呢?(最终尝试使用_exit函数的地址进行payload构造,结果失败;而exit函数成功)
2.exit() 与 _exit() 的根本区别
它们最核心的区别在于对标准I/O缓冲区的处理方式不同。
特性 |
|
|
---|---|---|
缓冲区处理 | 刷新所有标准I/O缓冲区 | 不刷新任何标准I/O缓冲区 |
终止处理函数 | 调用由 | 不调用任何终止处理函数 |
头文件 |
|
|
本质 | C标准库函数,是 | 直接调用同名系统调用,立即进入内核 |
当程序使用printf
等标准I/O函数输出时,数据通常先存放在内存的缓冲区里,直到缓冲区满、遇到换行符\n
或文件关闭时,才真正写入目标(如屏幕)。exit()
会在进程终止前执行清理工作,包括将缓冲区中的数据“刷新”到目的地;而_exit()
则直接关闭进程,丢弃缓冲区中的所有数据。
——为什么CTF中必须使用 exit()
已此题为例,成功执行get_flag()
函数后,是通过putchar这个标准I/O函数将flag内容打印到标准输出。如果payload中让程序跳转到_exit()
,会发生以下情况:
-
get_flag()
函数成功读取并准备输出flag。 -
程序流程跳转到
_exit()
。 -
_exit()
立即终止进程,存在于缓冲区中的flag内容将被丢弃,无法显示在终端上。 -
攻击者看不到flag,攻击“成功”但“无效”。
而使用exit()
时:
-
同样成功执行
get_flag()
。 -
程序流程跳转到
exit()
。 -
exit()
首先刷新stdout等所有打开的I/O缓冲区,确保flag内容被发送到终端。 -
然后才终止进程。
-
攻击者能在屏幕上看到flag,攻击真正成功。
2.对于第一步实现栈溢出的覆盖中的偏移量重新进行计算验证:
使用GDB(搭配增强插件pwndbg) + pwntools(提供cyclic
等命令)的动态调试精确计算程序运行时v4的实际偏移量:
1.生成模式字符串
使用pwntools的cyclic
生成一个唯一且长度足够的字符串(确保能覆盖返回地址):
cyclic 100
输出示例:
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaab
关键:长度需大于缓冲区大小(这里64字节),建议为缓冲区大小+至少40字节以确保覆盖关键数据。
2.GDB动态调试触发崩溃
-
启动GDB调试程序:
gdb ./get_started_3dsctf_2016
-
运行程序并输入模式字符串:
在GDB中执行:
run
程序等待输入时,粘贴步骤1生成的模式字符串,回车后程序会因溢出而崩溃。
-
记录崩溃时的关键值:
这里的
0x6161616l
就是覆盖EIP/RIP的值(32位系统看EIP,64位看RIP)。记下这个值(示例中为0x6161616c
)。
3.计算精确偏移量
使用cyclic
的-l
选项反推偏移:
cyclic -l 0x6161616c
56就是精确的实际的偏移量,表示从缓冲区起始到返回地址的距离。
——这跟先前静态分析的结果(56 + 4)并不一致,是为什么呢?
——差了ebp本身的4位(突破口:)
【新知识点——内平栈与外平栈】
此知识点详解文章请见:https://blog.csdn.net/ankanglcy/article/details/152230540?spm=1001.2014.3001.5501
可以发现,无论是main函数还是get_flag函数,在retn返回语句前都调用了 add esp, X
:add esp, X
是 x86 汇编语言中一条用于调整栈顶位置的指令,直接操作栈指针寄存器 ESP。它的核心作用是提升栈顶指针 ESP 的值,从而缩小栈空间,通常是为了"清理"栈上不再需要的数据。简单来说,add esp, X
就像是在栈上用完一些东西后进行的"打扫",把栈顶指针移回合适的位置。——这是明显的外平栈标志,因此,相应偏移量计算并不需要加上ebp本身的4位。
5.重构脚本:
经过以上分析,将修复这两处错误:
from pwn import *r = remote('node5.buuoj.cn', 26078)offset = 56
get_flag_addr = 0x080489A0
exit_addr = 0x0804E6A0
a1_value = 0x308CD64F
a2_value = 0x195719D1payload = b'A' * offset + p32(get_flag_addr)
payload += p32(exit_addr)
payload += p32(a1_value)
payload += p32(a2_value)r.sendline(payload)
r.interactive()
6.获取flag:
此次运行脚本后,终于成功了!
Congratulations!