1. 缓冲区溢出攻击基础入门
第一次接触缓冲区溢出攻击时,我完全被那些专业术语吓到了。什么栈帧、返回地址、ROP链,听起来就像天书一样。但当我真正动手操作后才发现,这些概念其实就像搭积木一样简单直观。
缓冲区溢出本质上就是"数据装多了"。想象你有一个容量固定的水杯(缓冲区),如果一直往里倒水(输入数据),超过容量的部分就会溢出来,流到不该去的地方。在计算机中,这个"不该去的地方"往往就是存储着关键程序信息的内存区域。
AttackLab实验中的getbuf函数就是典型的缓冲区漏洞案例:
unsigned getbuf() { char buf[BUFFER_SIZE]; Gets(buf); return 1; }这个函数使用不安全的Gets方法读取输入,它不会检查输入长度是否超过缓冲区大小(BUFFER_SIZE)。就像服务员不问你要多少水,直接往杯子里倒,直到你说停为止 - 但攻击者永远不会说"停"。
在x86-64架构中,栈是从高地址向低地址增长的。当函数被调用时,会在栈上分配空间存储局部变量(如buf),然后是保存的寄存器值,最后是返回地址。攻击者精心构造的超长输入可以覆盖这个返回地址,从而控制程序执行流程。
我第一次尝试phase1时,用objdump反汇编ctarget:
objdump -S ctarget > ctarget.s在ctarget.s中查找getbuf函数,发现它分配了0x28(40)字节的缓冲区空间。这意味着我们需要构造一个至少48字节的字符串(40字节填满缓冲区 + 8字节覆盖返回地址)。
2. 阶段一:最简单的返回地址劫持
phase1是整个实验的"Hello World",它教会我们最基本的攻击模式 - 通过溢出修改返回地址。目标是将getbuf的返回地址从原本的test函数改为touch1函数。
具体操作就像玩填字游戏:
- 先用40个任意字符填满buf缓冲区(我习惯用00)
- 接着写入touch1的地址000000000040185d
- 注意x86是小端序,所以要倒着写:5d 18 40 00 00 00 00 00
把这段十六进制码保存为phase_1.txt,然后用实验提供的hex2raw工具转换:
./hex2raw < phase_1.txt > test.txt ./ctarget < test.txt -q看到"Touch1!: You called touch1"的瞬间,我激动得差点从椅子上跳起来。这种亲手操控程序执行流程的感觉,比看十篇理论文章都来得深刻。
调试时有个实用技巧:在gdb中设置断点观察栈变化
gdb ./ctarget (gdb) break *getbuf (gdb) run -q < test.txt (gdb) x/20x $rsp这个命令可以查看栈内存的前20个字,能清晰看到我们的攻击字符串是如何覆盖返回地址的。
3. 阶段二:注入可执行代码
phase2增加了难度要求:不仅要跳转到touch2,还要传递参数。这就需要在栈上注入可执行代码,就像在数据区偷偷藏了个小程序。
x86-64架构中,第一个参数通过rdi寄存器传递。所以我们的攻击代码需要:
- 将cookie值(如0x5134f5ad)存入rdi
- 跳转到touch2(地址000000000040188b)
汇编代码attack1.s大致长这样:
mov $0x5134f5ad, %rdi push $0x40188b ret编译后用objdump查看机器码:
gcc -c attack1.s objdump -d attack1.o关键是要确定这段代码在栈上的位置。通过gdb调试,在getbuf函数内打印$rsp的值就是buf的起始地址。把这个地址作为返回地址,程序就会执行我们注入的代码。
这里有个坑:现代系统默认开启了NX(不可执行栈)保护,但ctarget特意关闭了这个保护,所以我们的代码才能执行。实际环境中这种直接注入代码的方式已经很难奏效了。
4. 阶段三:传递字符串参数
phase3要求传递字符串形式的cookie作为参数。这就像phase2的升级版,需要考虑字符串存储位置和内存布局。
首先要把cookie 0x5134f5ad转换成ASCII码:35 31 33 34 66 35 61 64(注意末尾还要加\0)。字符串可以放在getbuf的栈帧上方,也就是test的栈帧里,这样不会被后续操作覆盖。
攻击代码需要:
- 计算字符串地址(通常是getbuf的rsp + 偏移量)
- 将地址存入rdi
- 跳转到touch3
通过gdb调试,我发现test的栈帧起始于getbuf的rsp+0x28。所以字符串可以放在rsp+0x30处,对应的攻击代码:
mov $0x5562fce8, %rdi # 字符串地址 push $0x4019a2 # touch3地址 ret这个阶段最考验耐心,因为地址计算必须精确到字节。我失败了七八次才发现问题出在字符串末尾忘了加\0终止符。
5. ROP攻击原理与实践
phase4和phase5引入了ROP(Return-Oriented Programming)技术,这是现代绕过NX保护的经典方法。它就像用乐高积木拼装程序 - 从现有代码中找出有用的片段(gadget),通过ret指令把它们串起来。
每个gadget通常以ret(0xc3)结尾,形如:
58 pop %rax c3 ret这样的代码片段可以从farm.c提供的机器码中挖掘。使用objdump反汇编rtarget后,在start_farm和end_farm之间搜索:
- 首先找pop %rax的gadget(机器码58 c3)
- 然后找mov %rax,%rdi的gadget(48 89 c7 c3)
- 最后跳转到touch2
构造ROP链就像写购物清单:
[填充40字节] [gadget1地址] # pop %rax [cookie值] # 会被pop到rax [gadget2地址] # mov %rax,%rdi [touch2地址]实际调试时,我花了三小时才找到可用的gadget组合。关键技巧是在gdb中单步执行,观察每个gadget执行后寄存器的变化。
6. 高级ROP链构造技巧
phase5是终极挑战,需要构造更复杂的ROP链来传递字符串参数。由于ASLR(地址随机化)的关系,我们无法直接知道字符串在栈上的绝对地址,必须通过相对计算得到。
解决方案是:
- 用mov %rsp,%rax获取当前栈指针
- 通过加法计算字符串偏移量(lea指令)
- 最终传递到rdi
从farm中挖掘的gadget链如下:
401ad1: mov %rsp,%rax 401a82: lea (%rdi,%rsi,1),%rax # 需要设置rsi为偏移量 401a4d: mov %rax,%rdi字符串偏移量需要精心计算。在我的实验中,字符串距离rsp初始位置0x37字节,所以构造:
[填充40字节] [gadget1] # mov %rsp,%rax [gadget2] # lea (%rdi,%rsi,1),%rax [占位符] # 实际是pop %rsi的gadget [0x37] # 偏移量 [gadget3] # mov %rax,%rdi [cookie字符串]这个阶段让我深刻理解了ROP的精髓 - 就像玩俄罗斯方块,要把各种形状的gadget完美拼接,才能达成目标。每次失败后调整gadget顺序和参数的过程,就是对计算机系统理解不断加深的过程。