从CTF题ciscn_2019_n_1入门栈溢出漏洞原理与利用实战

发布时间:2026/7/4 11:04:44

从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打开字符串窗口这里列出了程序中的所有硬编码字符串。一个非常醒目的字符串是“Whats 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, [ebpvar_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。程序运行起来打印出“Whats your name?”然后在gets处停下。此时栈内存的状态还是“干净”的。3.2 发送测试数据与观察栈状态在IDA的调试输出窗口或专门的输入窗口我们发送第一轮测试数据52个‘A’十六进制0x41加上4个‘B’0x42。即payload ‘A’*52 ‘BBBB’。发送后让程序继续执行F9直到在retn指令处再次停下。现在我们来检查栈内存。在IDA的栈视图Stack view中找到EBP寄存器指向的位置。向上看低地址方向应该是一片被‘A’0x41覆盖的区域。找到EBP4的位置这里原本应该存放着返回地址。现在我们看到的是0x42424242‘BBBB’的ASCII码。这完美验证了我们的计算52字节的填充后接下来的4字节确实覆盖了返回地址。同时我们还需要注意函数中的一个关键逻辑。在func函数里gets之后往往有一个判断比如比较一个局部变量是否等于某个特定值例如0x11。如果相等就会打印出flag或调用一个胜利函数。我们静态分析时可能已经看到有一个局部变量v2被初始化为0然后与0x11比较。但是我们通过gets溢出不仅可以覆盖返回地址也可以覆盖这个局部变量v2使其变为0x11。这样我们就有两条潜在的利用路径一是直接覆盖返回地址跳转到打印flag的代码块二是覆盖变量v2使其满足条件让程序逻辑自己走向胜利。3.3 确定最终利用策略与载荷构造通过动态调试我们精确确认了偏移量。现在需要决定最终的利用方式。我们重新审视func函数的汇编代码。在gets调用之后通常有这样的代码cmp [ebpvar_??], 11h ; var_?? 是那个关键局部变量 jnz short loc_xxxxxxx ; 如果不等于0x11就跳转到正常返回 ... ; 否则执行system(cat flag)或类似操作我们需要知道这个var_??的偏移量。假设IDA告诉我们它是[ebpvar_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(archi386, oslinux) # 启动本地进程或连接远程服务 # p process(./ciscn_2019_n_1) # 本地 p remote(node4.buuoj.cn, 29482) # 示例远程地址根据题目修改 # 计算好的偏移量 offset_to_var 36 # 到关键变量 var_C 的偏移 offset_to_ret 52 # 到返回地址的偏移 # 构造payload payload bA * offset_to_var payload p32(0x11) # 覆盖 var_C 为 0x11 payload bA * (offset_to_ret - offset_to_var - 4) # 补充填充到返回地址前 # 假设通过IDA找到的打印flag的代码地址是 0x0804865B flag_addr 0x0804865B payload p32(flag_addr) # 覆盖返回地址 # 发送payload p.sendlineafter(bWhats your name?, payload) # 接收并打印输出这里应该包含flag print(p.recvall().decode()) p.close()脚本关键点解析p32():pwntools的函数将整数打包成小端序的32位字节串。这是处理内存数据的关键绝对不能错。sendlineafter(): 等待接收到指定的字符串这里是提示语后再发送我们的payload并自动加上换行符。这比简单的send()更稳健。recvall(): 接收程序直到连接关闭的所有输出。4.2 动态调试与脚本执行中的常见问题排查即使理论计算和静态分析都正确第一次运行脚本也可能失败。以下是几个常见的坑和排查思路问题1脚本发送payload后程序直接崩溃没有输出flag。排查思路检查偏移量这是最常见的问题。使用调试器在gets函数返回后、retn指令执行前仔细查看栈内存。确认EBP4处的值是否确实被我们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。服务状态确认远程题目服务是否正常启动。避坑技巧在开发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随机化栈、堆、库的加载地址增加预测目标地址的难度。这道ciscn_2019_n_1就像二进制安全世界里的“Hello World”。它用最简洁的方式展示了漏洞从发现、分析到利用的完整链条。通过亲手完成它你获得的不仅仅是一个flag更是一套面对陌生二进制文件时如何抽丝剥茧、定位漏洞、并最终掌控程序的思维方法和实操技能。记住这个感觉在分析更复杂的、开启了各种保护机制的程序时你总会回到这些最基础的概念上来控制流、内存布局、数据覆盖。

相关新闻