从CTF题ciscn_2019_n_1入门栈溢出漏洞原理与利用实战
1. 项目概述:从一道经典CTF题看栈溢出实战
最近在复盘一些经典的CTF逆向题目,发现ciscn_2019_n_1这道题虽然年份不算新,但它作为栈溢出漏洞的入门教学案例,其设计之精巧、知识点覆盖之全面,至今仍极具学习价值。很多刚接触二进制安全的朋友,一听到“栈溢出”、“控制流劫持”这些词就觉得头大,感觉是高手才能玩转的东西。其实不然,这道题就是一个绝佳的起点。它没有复杂的保护机制,漏洞点清晰,利用链直接,非常适合用来建立对栈溢出攻击最直观的认知。说白了,这就是一个“教科书式”的漏洞,你能在这里看到漏洞如何产生、如何发现、又如何被利用的全过程。
我之所以想详细拆解这道题,是因为我发现网上很多分析文章要么过于简略,跳过了关键的思考步骤;要么堆砌术语,让新手看得云里雾里。我的目标是,即使你之前只学过一点C语言,对汇编和调试器只有模糊的概念,也能跟着这篇文章,自己动手把这道题“跑”一遍,真正理解每一步在做什么、为什么这么做。我们会用到的主力工具是IDA Pro,这是逆向分析的“瑞士军刀”,但别怕,我们不会去深究它的所有复杂功能,只聚焦于解题所必需的那些操作。整个过程,我会像带着一位新同事做项目复盘一样,把每个判断、每个操作背后的逻辑都讲清楚。最终,你不光能解出这道题,更能掌握一套发现和利用简单栈溢出漏洞的通用方法论。
2. 核心漏洞原理与程序逻辑静态分析
2.1 栈溢出漏洞的“第一性原理”
在动手分析之前,我们必须先抛开那些复杂的术语,用最直白的方式理解栈溢出到底是什么。你可以把程序的栈内存想象成一摞叠起来的盘子(栈帧),每个函数调用就像往这摞盘子上放一个新的盘子(创建新的栈帧)。这个盘子里会整齐地摆放几样东西:函数执行完后要回到哪里(返回地址)、调用者的栈底位置(ebp)、以及函数内部使用的局部变量。
问题的关键就在于“局部变量”。当我们在函数里定义一个数组,比如char buf[10],编译器就会在当前的这个“盘子”(栈帧)里划出一块刚好10字节的区域来存放它。栈溢出漏洞的本质,就是程序向这块预定好的区域里,写入了超过其容量的数据。比如,你本意是往一个10字节的“小杯子”里倒水,结果却连接上了一个“大水桶”,水(数据)漫过了杯沿,淹没了旁边存放的“返回地址”盘子。
一旦返回地址被我们精心构造的数据覆盖,函数执行完毕时,就不会跳回原本正确的地方,而是跳转到我们覆盖的地址去执行代码。这就是“控制流劫持”。ciscn_2019_n_1这道题,就是一个非常典型的、因为使用了不安全的字符串读取函数而导致的栈溢出。
2.2 使用IDA进行初步侦察与逻辑梳理
拿到一个陌生二进制文件,第一步绝不是直接扔进调试器跑。静态分析,即在不运行程序的情况下阅读其代码逻辑,是构建整体认知的关键。我们用IDA Pro打开题目提供的可执行文件。
首先映入眼帘的是IDA的图形视图,这比看纯汇编文本直观得多。我们快速定位到main函数。在反汇编窗口,按空格键可以在图形视图和文本视图间切换,图形视图能清晰展示代码的分支逻辑。通过浏览main函数,我们发现它似乎没有太多复杂操作,关键逻辑很可能在它调用的子函数中。
这时,我们需要关注字符串引用。按下Shift + F12打开字符串窗口,这里列出了程序中的所有硬编码字符串。一个非常醒目的字符串是“What's your name?”。在CTF题中,这种提示用户输入的字符串往往是突破点。我们双击它,IDA会自动跳转到该字符串在代码段中被引用的位置。
果然,我们来到了一个名为func的函数内部。这个func函数,就是我们的主战场。在图形视图下,func的函数体结构一目了然:
- 函数序幕:标准的
push ebp; mov ebp, esp,保存旧的栈帧指针并建立新的。 - 开辟栈空间:
sub esp, 0x28。这条指令告诉我们,这个函数为自己的局部变量和缓冲区在栈上开辟了0x28(十进制40)字节的空间。 - 关键调用:紧接着,我们看到了对
gets函数的调用。gets函数的参数是我们刚刚在栈上开辟的空间里的一个地址(通常是ebp减去某个偏移量)。这里就是漏洞点!gets是一个极度危险的函数,它从标准输入读取字符串,直到遇到换行符或EOF,但它不会检查目标缓冲区的大小。无论用户输入多长,它都会照单全收地写进去。 - 后续逻辑:在
gets调用之后,函数进行了一些变量比较和判断,最后打印结果。
我们的初步侦察得出结论:程序在func函数中,使用gets向一个大小有限的栈缓冲区写入数据,存在明显的栈溢出漏洞。下一步,我们需要精确计算,到底需要多少字节才能覆盖到那个关键的“返回地址”。
2.3 栈帧布局计算与溢出点定位
静态分析的优势在于,我们可以像做数学题一样,精确计算内存布局。我们需要知道从我们输入的缓冲区起始位置,到函数返回地址存储位置之间的距离。这个距离,就是我们构造攻击载荷时需要填充的“垃圾数据”的长度。
在func函数的汇编代码中,我们看到:
sub esp, 0x28:开辟了40字节的栈空间。- 通常,局部变量和缓冲区就在这块空间里。
gets的参数(缓冲区的起始地址)通常是[ebp - 0x30]或类似的地址(具体取决于编译器优化和变量定义顺序)。我们需要在IDA中确认这一点。
查看gets调用那一行,通常是lea eax, [ebp+var_30]然后push eax作为参数。这里的var_30是IDA给局部变量起的名字,其偏移量是-0x30(十进制-48)。
那么,栈帧布局从上到下(高地址到低地址)大概是这样的:
ebp(旧的ebp值,被保存的帧指针):位于ebp。- 返回地址:位于
ebp + 4。 - 可能的对齐空间...
- 局部变量/缓冲区:起始于
ebp - 0x30。
所以,从缓冲区起始 (ebp - 0x30) 到返回地址 (ebp + 4) 的偏移量计算为:(ebp - 0x30)到ebp的距离是0x30(48) 字节。 再从ebp到返回地址是 4 字节(在32位程序中)。 因此,总偏移量 =0x30 + 4 = 0x34(十进制52) 字节。
注意:这是一个经典的计算模型。但在实际中,我们还需要考虑调用
gets时,其参数(缓冲区地址)本身也是压栈的,但这发生在新的栈帧建立之后,不影响我们计算的从缓冲区到本帧返回地址的距离。最稳妥的方法是结合动态调试验证。
这意味着,我们需要先输入52个字节的任意数据(通常用‘A’或‘x90’这样的占位符,称为“padding”或“junk”),从第53个字节开始,我们写入的4个字节(32位地址)就会覆盖掉原本的返回地址,从而控制程序的下一步执行流。
3. 动态调试验证与利用链构造
3.1 配置调试环境与关键断点设置
理论计算需要实践验证。我们使用IDA自带的调试器,或者配合GDB(如gdb-peda)进行动态分析。这里以IDA调试为例。
首先,用IDA打开程序,切换到调试器模式(Debugger -> Select debugger -> Local Windows debugger)。在func函数中,我们找到两个最关键的地址下断点:
- 调用
gets函数的指令地址:目的是在程序即将读取我们输入之前暂停,方便我们输入测试数据。 - 函数末尾的
retn指令地址:目的是在函数即将返回、使用被我们可能覆盖的返回地址之前暂停,让我们可以检查栈上的状态。
设置好断点后,启动调试(F9)。程序运行起来,打印出“What's your name?”,然后在gets处停下。此时,栈内存的状态还是“干净”的。
3.2 发送测试数据与观察栈状态
在IDA的调试输出窗口或专门的输入窗口,我们发送第一轮测试数据:52个‘A’(十六进制0x41)加上4个‘B’(0x42)。即payload = ‘A’*52 + ‘BBBB’。
发送后,让程序继续执行(F9),直到在retn指令处再次停下。现在,我们来检查栈内存。
在IDA的栈视图(Stack view)中,找到EBP寄存器指向的位置。向上看(低地址方向),应该是一片被‘A’(0x41)覆盖的区域。找到EBP+4的位置,这里原本应该存放着返回地址。现在,我们看到的是0x42424242(‘BBBB’的ASCII码)。这完美验证了我们的计算:52字节的填充后,接下来的4字节确实覆盖了返回地址。
同时,我们还需要注意函数中的一个关键逻辑。在func函数里,gets之后往往有一个判断,比如比较一个局部变量是否等于某个特定值(例如0x11)。如果相等,就会打印出flag或调用一个胜利函数。我们静态分析时可能已经看到,有一个局部变量v2被初始化为0,然后与0x11比较。但是,我们通过gets溢出,不仅可以覆盖返回地址,也可以覆盖这个局部变量v2,使其变为0x11。这样,我们就有两条潜在的利用路径:一是直接覆盖返回地址跳转到打印flag的代码块;二是覆盖变量v2使其满足条件,让程序逻辑自己走向胜利。
3.3 确定最终利用策略与载荷构造
通过动态调试,我们精确确认了偏移量。现在需要决定最终的利用方式。我们重新审视func函数的汇编代码。
在gets调用之后,通常有这样的代码:
cmp [ebp+var_??], 11h ; var_?? 是那个关键局部变量 jnz short loc_xxxxxxx ; 如果不等于0x11,就跳转到正常返回 ... ; 否则,执行system("cat flag")或类似操作我们需要知道这个var_??的偏移量。假设IDA告诉我们它是[ebp+var_C],那么它的地址就是ebp - 0xC。
我们的缓冲区起始于ebp - 0x30。所以,到这个变量var_C的偏移量是0x30 - 0xC = 0x24(十进制36)字节。
那么,我们的攻击载荷可以这样构造:
- 前36个字节:任意填充(‘A’)。
- 第37到40字节(4字节):写入
0x00000011(注意x86是小端序,内存中应为\x11\x00\x00\x00)。这正好覆盖var_C,使其值从0变为0x11。 - 第41到52字节:继续填充‘A’,补足到52字节。
- 第53到56字节:覆盖返回地址。这里我们可以选择覆盖为那个打印flag的代码块的地址(假设地址是
0x0804865B,同样需要转为小端序\x5B\x86\x04\x08)。
这样,当函数执行时:
gets读入我们的长字符串。- 局部变量
var_C被覆盖为0x11。 - 随后的
cmp指令发现相等,程序流程不会跳转到正常返回,而是执行打印flag的代码。 - 打印flag的代码块执行完毕后,函数依然会走到
retn指令。 - 此时,返回地址已被我们覆盖为打印flag代码块的地址(或其它地址)。但注意,因为我们已经通过条件判断进入了胜利分支,这个返回地址可能用不上了,甚至可能因为栈不平衡导致崩溃。但我们的目标(拿到flag)在崩溃前已经达成。
这是一种“条件触发”式的利用。更直接粗暴的利用方式是,不理会那个局部变量判断,直接用52字节填充+目标地址覆盖返回地址,让程序直接跳转到打印flag的代码块(即system("cat flag")或puts(&flag)的地址)。这需要我们知道那个代码块的具体地址,可以通过IDA静态查看。
实操心得:在实战中,两种方式都可以尝试。第一种方式更贴合程序原有逻辑,有时能绕过一些简单的检测。第二种方式更直接。动态调试时,可以在执行到判断语句时,手动修改
ZF标志位或者直接修改var_C的内存值,来测试胜利分支是否有效,从而确定目标地址。
4. 编写自动化利用脚本与问题排查
4.1 使用Python与Pwntools构造攻击载荷
手动输入测试固然可以,但为了稳定利用和后续扩展,编写一个自动化脚本是标准操作。我们使用Python的pwntools库,它专门为CTF的漏洞利用开发设计,非常方便。
from pwn import * # 设置上下文,指明是32位程序 context(arch='i386', os='linux') # 启动本地进程或连接远程服务 # p = process('./ciscn_2019_n_1') # 本地 p = remote('node4.buuoj.cn', 29482) # 示例远程地址,根据题目修改 # 计算好的偏移量 offset_to_var = 36 # 到关键变量 var_C 的偏移 offset_to_ret = 52 # 到返回地址的偏移 # 构造payload payload = b'A' * offset_to_var payload += p32(0x11) # 覆盖 var_C 为 0x11 payload += b'A' * (offset_to_ret - offset_to_var - 4) # 补充填充到返回地址前 # 假设通过IDA找到的打印flag的代码地址是 0x0804865B flag_addr = 0x0804865B payload += p32(flag_addr) # 覆盖返回地址 # 发送payload p.sendlineafter(b"What's your name?", payload) # 接收并打印输出,这里应该包含flag print(p.recvall().decode()) p.close()脚本关键点解析:
p32():pwntools的函数,将整数打包成小端序的32位字节串。这是处理内存数据的关键,绝对不能错。sendlineafter(): 等待接收到指定的字符串(这里是提示语)后,再发送我们的payload,并自动加上换行符。这比简单的send()更稳健。recvall(): 接收程序直到连接关闭的所有输出。
4.2 动态调试与脚本执行中的常见问题排查
即使理论计算和静态分析都正确,第一次运行脚本也可能失败。以下是几个常见的坑和排查思路:
问题1:脚本发送payload后,程序直接崩溃,没有输出flag。
- 排查思路:
- 检查偏移量:这是最常见的问题。使用调试器,在
gets函数返回后、retn指令执行前,仔细查看栈内存。确认EBP+4处的值是否确实被我们payload中预期的地址覆盖了。如果不是,重新计算偏移。有时编译器优化或栈对齐会导致布局有细微差别。 - 检查地址有效性:确认你覆盖的返回地址(
flag_addr)是否确实指向有效的、可执行的代码。在IDA中,按C键确保该地址被反汇编为有意义的指令(如push offset aCatFlag; "cat flag")。跳转到数据区或不可执行区域会导致段错误。 - 检查栈平衡:如果我们的利用方式是先修改变量触发条件,然后仍然依赖被覆盖的返回地址,可能会因为胜利分支的代码改变了栈指针(ESP)而导致
ret时栈顶不是我们覆盖的地址。可以在调试器中单步跟踪胜利分支的代码,观察ESP变化。
- 检查偏移量:这是最常见的问题。使用调试器,在
问题2:程序输出了一些乱码或异常,但没有崩溃,也没看到flag。
- 排查思路:
- 检查输入处理:程序在
gets之后是否有其他处理我们输入的逻辑?比如将输入转换为整数、进行字符串过滤等。这可能会破坏我们的payload。查看gets后面的代码,确保我们的payload字节都是“安全”的。 - 检查接收输出:可能是flag已经输出,但被缓冲或者夹杂在其他输出中。尝试使用
p.recvline()、p.recvuntil('}')(如果flag用花括号包裹)等更精确的方法接收,或者直接print(p.recvall())查看原始字节输出。
- 检查输入处理:程序在
问题3:本地成功,远程失败。
- 排查思路:
- 环境差异:远程服务器和本地的程序版本、libc版本可能不同,导致内存地址有偏移。但本题是静态编译(通常CTF的pwn题会给静态编译的二进制文件)或地址固定(没有开启PIE),所以一般不存在这个问题。可以通过
file命令和checksec命令检查程序属性。 - 网络与交互:远程网络可能有延迟。确保使用
sendlineafter或recvuntil来同步交互,避免发送和接收的时序问题。增加超时时间timeout。 - 服务状态:确认远程题目服务是否正常启动。
- 环境差异:远程服务器和本地的程序版本、libc版本可能不同,导致内存地址有偏移。但本题是静态编译(通常CTF的pwn题会给静态编译的二进制文件)或地址固定(没有开启PIE),所以一般不存在这个问题。可以通过
避坑技巧:在开发exp脚本时,强烈建议分阶段测试。先写一个只发送偏移量计算用的pattern(如
cyclic 100来自pwntools)的脚本,然后在调试器中观察崩溃时覆盖返回地址的具体内容,再用cyclic_find()计算精确偏移。这是最可靠的方法。
5. 漏洞利用的扩展思考与防御浅谈
5.1 从本题看栈溢出利用的演进
ciscn_2019_n_1是一个最基础、保护全关的栈溢出。在现代系统中,这种“裸奔”的程序几乎不存在了。但理解它是理解所有高级攻击技术的基础。本题的利用直接覆盖返回地址为已知的代码地址,这要求攻击者事先知道这个地址(通常通过静态分析获得)。如果程序开启了地址空间布局随机化(ASLR),代码段的基址会变化,这种简单跳转就失效了。
那么攻击者会如何进化?一个常见的技术是Return-Oriented Programming (ROP)。即使代码地址随机化,程序本身的代码段(text段)内部包含大量以ret结尾的短指令序列(gadgets)。攻击者可以覆写返回地址为第一个gadget地址,这个gadget执行一些操作(如pop ebx; ret)后,又会ret到栈上的下一个地址,从而执行第二个gadget,形成一条“指令链”。通过精心组合这些gadget,攻击者可以在内存随机化的情况下,依然完成复杂的操作,比如调用system(“/bin/sh”)。
5.2 开发者视角:如何避免此类漏洞
从这道题里,我们更应该学到的是如何写出安全的代码。
- 绝对禁止使用不安全的函数:
gets、strcpy、sprintf(不带长度限制)、scanf的%s等函数是万恶之源。它们应该从你的编码词典里删除。 - 使用安全的替代品:
- 用
fgets(buf, sizeof(buf), stdin)代替gets。 - 用
strncpy(并注意终止符)或更安全的snprintf代替strcpy/sprintf。 - 用
scanf时指定宽度,如scanf(“%10s”, buf)。
- 用
- 进行边界检查:任何从不可信源(网络、文件、用户输入)读取数据到缓冲区的操作,都必须先检查数据长度是否小于缓冲区容量。
- 启用编译器和操作系统的保护机制:
- 栈保护(Stack Canary):编译器(如GCC的
-fstack-protector)会在栈上返回地址前插入一个随机值(canary),函数返回前检查该值是否被改变,若改变则立即终止程序。本题若开启此保护,我们的覆盖行为会被检测到。 - 数据执行保护(DEP/NX):将数据所在的内存页标记为不可执行,防止攻击者将shellcode放在栈上并跳转执行。这会迫使攻击者转向ROP。
- 地址空间布局随机化(ASLR):随机化栈、堆、库的加载地址,增加预测目标地址的难度。
- 栈保护(Stack Canary):编译器(如GCC的
这道ciscn_2019_n_1就像二进制安全世界里的“Hello World”。它用最简洁的方式,展示了漏洞从发现、分析到利用的完整链条。通过亲手完成它,你获得的不仅仅是一个flag,更是一套面对陌生二进制文件时,如何抽丝剥茧、定位漏洞、并最终掌控程序的思维方法和实操技能。记住这个感觉,在分析更复杂的、开启了各种保护机制的程序时,你总会回到这些最基础的概念上来:控制流、内存布局、数据覆盖。
