BJDCTF 2nd secret Pwn题解析:栈溢出与栈对齐实战

发布时间:2026/10/16 20:08:10
BJDCTF 2nd secret Pwn题解析:栈溢出与栈对齐实战
1. 从一道Pwn题看栈溢出利用的完整链路拿到一道Pwn题最怕的不是保护全开而是保护没开全但你就是找不到利用点。BJDCTF 2nd的secret这题我第一次做的时候卡了整整一个下午不是因为技术难度有多高而是因为一个很隐蔽的细节——程序里藏了一个后门函数但它的触发条件不是简单的覆盖返回地址就行。这题后来我跟几个朋友交流发现不少人都在同一个地方翻车所以决定把完整的分析过程和踩坑记录整理出来。这篇文章适合已经了解栈溢出基本原理、会用pwntools写基本exp、但还不太熟悉复杂利用链构造的读者。我会从二进制保护检查开始一步步走到最终getshell把每一步的思考过程和踩坑经验都摊开来讲。如果你正在刷CTF Pwn题或者想系统梳理栈溢出利用的完整流程这篇内容应该能帮你省下不少时间。先交代一下这题的基本情况64位ELF动态链接保护情况需要实际检查。题目名字叫secret暗示程序里藏了什么东西——可能是后门函数可能是未初始化的变量也可能是某个特殊的判断逻辑。这类题目的核心考点通常不是复杂的堆利用或者格式化字符串而是对栈布局的精确理解和返回地址的巧妙控制。2. 保护检查与二进制信息收集2.1 用checksec快速摸清底细拿到二进制文件的第一件事永远是检查保护机制。我习惯用pwntools自带的checksecchecksec ./secret输出大概长这样Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这个结果信息量很大。No canary意味着栈溢出不会被检测可以直接覆盖返回地址。No PIE意味着代码段的地址是固定的后门函数的地址可以直接硬编码。NX enabled意味着不能直接在栈上执行shellcode必须用ROP或者调用现有函数。Partial RELRO说明GOT表可写但这里大概率用不上。注意checksec的输出格式在不同版本的pwntools中可能略有差异但关键字段是一样的。如果显示NX disabled那就可以考虑直接写shellcode但大多数现代题目都会开启NX。2.2 用file和readelf补充信息checksec给的是宏观保护情况还需要确认一些细节file ./secret readelf -h ./secret | grep Type readelf -s ./secret | grep FUNCfile命令确认是64位ELF、动态链接。readelf -h看ELF类型通常是EXEC非PIE或DYNPIE。readelf -s列出符号表这一步很关键——如果二进制没有strip可以直接看到函数名后门函数可能就藏在里面。我在这题里用readelf -s看到了一个名为get_flag或者类似名字的函数地址是固定的。这就是题目名字secret的暗示——程序里藏了一个正常情况下不会被调用的函数里面直接读取flag文件。2.3 用IDA或objdump做静态分析静态分析工具的选择看个人习惯。我一般先用objdump快速看一眼main函数的反汇编objdump -d -M intel ./secret | grep -A 50 main:但objdump的输出可读性一般真正要理清逻辑还是得上IDA或者Ghidra。用IDA打开后重点看几个地方main函数的栈帧大小、输入函数是什么read/gets/scanf、缓冲区到返回地址的偏移、有没有明显的后门函数。这题的main函数逻辑不复杂大概是这样int main() { char buf[64]; puts(Tell me your secret:); read(0, buf, 0x100); // 明显的栈溢出 return 0; }read函数读了0x100字节到64字节的缓冲区溢出是板上钉钉的事。但问题在于直接覆盖返回地址到后门函数能不能拿到shell这里就是第一个坑。3. 栈布局的精确计算与偏移量确定3.1 为什么偏移量不能靠猜很多人做栈溢出题喜欢用pattern_create和pattern_offset来定位偏移这本身没问题但前提是你得确认溢出确实能触发崩溃。这题我一开始用cyclic模式跑了一遍发现崩溃时的RIP确实是cyclic的值偏移量算出来是7264字节缓冲区8字节saved rbp。但这里有个细节如果程序在read之后还有其他操作比如调用puts输出什么栈上的数据可能会被修改导致偏移量变化。所以最稳妥的办法是结合静态分析和动态调试来确认。3.2 用GDBpeda/pwndbg精确观察栈帧我习惯用pwndbg在read函数调用前下断点gdb ./secret b *main0x?? # 在read调用后下断点 r然后查看栈布局stack 30或者用telescope $rsp查看栈上的数据。确认缓冲区起始地址到saved rbp的距离再加上8就是到返回地址的偏移。这题的实际偏移是72但我在第一次做的时候算成了80原因是把saved rbp的位置搞错了。后来用info frame确认了栈帧结构才纠正过来。提示不同编译器的栈对齐策略可能不同有时候会有额外的padding。不要完全依赖静态分析的数字一定要用GDB动态验证。3.3 后门函数的地址确认确认了偏移量之后下一步是找到后门函数的地址。如果二进制没有PIE直接用readelf或者IDA里看到的地址就行。这题的后门函数地址是0x400xxx具体数字记不清了但关键是这个函数里面做了什么。我反编译看了一下后门函数大概是这样的void secret_function() { system(/bin/sh); }看起来很简单直接覆盖返回地址到这个函数就能getshell。但实际跑的时候发现不行——程序崩溃了。这就是第二个坑栈对齐问题。4. 栈对齐一个容易被忽略的致命细节4.1 为什么调用system会崩溃x86_64架构下System V ABI要求调用函数时栈指针RSP必须是16字节对齐的。具体来说在call指令执行之前RSP必须是16的倍数call指令会把返回地址压栈所以进入函数体时RSP是16的倍数减8。如果直接覆盖返回地址到后门函数程序从main函数返回时RSP的值可能不满足对齐要求。当后门函数内部调用system时system内部的某些指令比如movaps要求16字节对齐不对齐就会触发SIGSEGV。这个坑我在很多题里都遇到过但每次遇到还是容易忘。判断方法很简单在GDB里跑到后门函数入口看RSP的值是不是16的倍数。如果不是就需要在ROP链里加一个ret gadget来调整栈。4.2 ret gadget的选取与使用ret gadget的地址很好找用ROPgadget或者ropperROPgadget --binary ./secret | grep ret$或者直接在IDA里找一个单独的ret指令的地址。这题里我找到了一个0x400xxx的ret gadget。构造payload的时候在返回地址之前插入这个ret gadget的地址payload bA * 72 p64(ret_addr) p64(backdoor_addr)这样程序先从main返回到ret gadgetret gadget执行一次ret相当于多弹了一次栈RSP增加8从而调整了对齐。注意有时候需要加多个ret gadget才能对齐具体加几个要看GDB里RSP的实际值。一般来说加一个就够了但极端情况下可能需要加两个。4.3 验证栈对齐是否成功调整之后在GDB里重新跑一遍在后门函数的system调用处下断点确认RSP的值是16的倍数。如果还是不对就再加一个ret gadget。我在这题里加了一个ret gadget就成功了。但有个朋友跟我说他加了两个才行原因是他的GDB版本和编译器的栈对齐策略略有不同。所以这一步一定要自己验证不要照抄别人的exp。5. 完整EXP的编写与调试过程5.1 pwntools模板的搭建我写exp习惯用一个固定的模板省得每次重新写from pwn import * context.log_level debug context.arch amd64 elf ELF(./secret) # libc ELF(./libc.so.6) # 这题不需要 if args.REMOTE: p remote(target, port) else: p process(./secret) # 偏移量 offset 72 # 后门函数地址 backdoor elf.symbols[secret_function] # 或者硬编码地址 # ret gadget ret 0x400xxx # 实际地址 payload bA * offset payload p64(ret) payload p64(backdoor) p.sendline(payload) p.interactive()这个模板的关键点是用elf.symbols自动获取函数地址如果符号表没被strip用args.REMOTE控制本地还是远程。5.2 本地调试中遇到的意外第一次跑exp的时候程序确实拿到了shell但输出的flag是空的。我一开始以为是后门函数的问题后来发现是本地没有flag文件。这种问题在本地调试时很常见解决办法是在本地创建一个假的flag文件echo flag{test_flag} /flag或者修改后门函数让它输出别的内容。但更稳妥的做法是直接看后门函数的反汇编确认它到底读了哪个文件。5.3 远程打通的注意事项远程打的时候首先要确认目标环境的libc版本和本地是否一致。这题因为直接调用了后门函数不涉及libc地址泄露所以不用管libc版本。但如果题目需要泄露libc基址再算system地址那就必须确认libc版本。另外远程连接可能会因为网络延迟导致交互超时。我一般会在sendline之后加一个短暂的sleep或者用p.recvuntil等待特定输出再发送payload。p.recvuntil(bTell me your secret:) p.sendline(payload)这样能确保payload在正确的时间点发送避免因为时序问题导致利用失败。6. 从这题延伸出的通用Pwn解题思路6.1 后门函数类题目的快速识别这类题目的特征很明显二进制里有一个不会被正常调用的函数函数内部直接调用了system(/bin/sh)或者execve(/bin/sh, ...)。识别方法就是看符号表里有没有名字可疑的函数比如get_flag、secret、backdoor、win之类的。如果符号表被strip了那就看函数列表里有没有哪个函数的反汇编特别短而且内部有system调用。用IDA的Function Window按地址排序通常后门函数会在main函数附近或者文件末尾。6.2 栈溢出偏移量的三种确认方法确认偏移量有三种方法我一般会交叉验证第一种是静态分析在IDA里看栈帧结构直接读出缓冲区到saved rbp的距离。这种方法最快但可能因为编译器优化而不准确。第二种是动态调试用cyclic模式跑崩溃看RIP的值。这种方法最准但需要程序确实能崩溃。第三种是源码审计如果有源码的话直接看数组大小和read的长度。CTF题一般不给源码但如果有这是最可靠的。三种方法的结果如果一致那就可以放心用。如果不一致以动态调试的结果为准。6.3 栈对齐问题的通用解决方案栈对齐问题不是这题独有的任何64位栈溢出题只要涉及到调用libc函数或者system都可能遇到。通用的解决方案就是在ROP链里加ret gadget。判断是否需要加ret gadget的方法在GDB里跑到目标函数入口执行p $rsp看值是不是16的倍数。如果不是就需要加。加几个ret gadget的判断方法加一个之后再看RSP如果还不是16的倍数就再加一个。因为每个ret gadget会让RSP增加8所以最多加一个就能从8 mod 16变成0 mod 16。6.4 本地调试环境的搭建建议本地调试Pwn题我建议装一个pwndbg或者peda能大幅提升效率。另外创建一个假的flag文件放在根目录方便验证后门函数是否真的能读到flag。如果题目依赖特定的libc版本可以用patchelf修改二进制的解释器和rpathpatchelf --set-interpreter ./ld-linux-x86-64.so.2 ./secret patchelf --set-rpath . ./secret这样就能用指定的libc运行程序避免因为libc版本不一致导致的偏移量差异。7. 我在实战中总结的几条经验做Pwn题最忌讳的就是拿到题目直接上exp模板不分析二进制。我见过太多人因为跳过了保护检查和静态分析导致在错误的方向上浪费大量时间。这题如果没注意到栈对齐问题可能跑十遍exp都是崩溃然后以为是偏移量算错了反复调整偏移量最后心态崩了。另一个经验是GDB的调试信息比任何猜测都可靠。偏移量、栈对齐、函数地址这些关键数据一定要在GDB里确认。静态分析给的是理论值动态调试给的是实际值两者不一致时以动态为准。还有一点关于exp的编写不要一次性写完整个exp再跑要分步验证。先确认能控制RIP再确认能跳到后门函数最后确认能拿到shell。每一步都验证通过之后再往下走这样出问题的时候能快速定位是哪一步错了。最后说一个这题的小细节后门函数的地址在有些版本里可能因为编译选项不同而变化所以不要硬编码地址尽量用elf.symbols自动获取。如果符号表被strip了那就用ROPgadget或者IDA找地址但一定要在GDB里验证地址是否正确。这题的整体难度不高但栈对齐这个点确实卡住了不少人。把这道题吃透之后以后再遇到类似的后门函数题目基本就是几分钟的事。关键是要养成严谨的分析习惯不放过任何一个细节。