本篇文章记录了对CTF题目"babystack"的完整分析和漏洞利用过程。该题目是一道经典的栈溢出题,主要考察对栈结构的理解和变量覆盖技术。
核心发现:
漏洞类型:栈缓冲区溢出 → 变量覆盖
漏洞成因:第二次read()允许输入256字节,可覆盖栈上的检查变量
关键偏移:0xf8字节(248字节)精确覆盖
成功条件:将v3变量从0xabc1337覆盖为0x1337abc
最终效果:获得远程shell访问权限
研究方法:
静态分析(objdump、strings、checksec)
动态调试(GDB断点跟踪)
偏移计算(栈布局分析)
本地验证(Python快速测试)
Exploit开发(pwntools自动化)
题目名称:babystack
题目类型:PWN(二进制漏洞利用)
题目描述:拿到属于你的shell吧 / Get your own shell
首先,我们使用file命令查看文件类型:
$ file babystack
babystack: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=4649ed5a3ce2485cb0c8ad02daa27011db41ae3f, not stripped
解读:
ELF 64-bit:这是一个Linux下的64位可执行文件
LSB executable:小端序(Little-Endian)架构
x86-64:运行在64位x86处理器上
dynamically linked:动态链接,依赖共享库
not stripped:未去除符号表,便于调试和分析
使用checksec工具检查二进制文件的安全保护机制:
$ checksec babystack
[*] '/mnt/hgfs/share/NRT/PWN/babystack/babystack'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
保护机制详解:
Partial RELRO(部分重定位只读)
GOT表部分可写,但本题不涉及GOT劫持
No canary found(无栈保护)
这是关键!没有Stack Canary(栈金丝雀)保护
意味着可以随意进行栈溢出,不会被检测到
栈金丝雀是编译器在栈上放置的特殊值,函数返回前会检查这个值是否被修改
NX enabled(栈不可执行)
栈上的数据不能作为代码执行
无法直接在栈上放置shellcode并执行
需要使用ROP或者其他技术
No PIE(地址不随机化)
程序加载地址固定在0x400000
函数地址、字符串地址等都是固定的,便于利用
Not Stripped(未去除符号)
保留了函数名、变量名等调试信息
方便我们分析程序逻辑
安全总结:这是一道入门级题目,只开启了NX保护,其他保护全部关闭,利用难度很低。
使用objdump反汇编main函数:
0000000000400819 <main>:
400819: push rbp
40081a: mov rbp,rsp
40081d: mov rax,QWORD PTR [rip+0x20086c] # 601090 <stdin@GLIBC_2.2.5>
...
40087c: call 400746 <pwn> # 调用pwn函数
400881: mov eax,0x0
400886: pop rbp
400887: ret
分析:main函数主要做了三件事:
设置标准输入/输出/错误流的缓冲模式(setvbuf)
调用pwn()函数(核心逻辑)
返回0
pwn函数是漏洞所在,我们逐步分析:
0000000000400746 <pwn>:
; 函数序言
400746: push rbp # 保存旧的rbp
400747: mov rbp,rsp # 设置新的栈帧
40074a: sub rsp,0x120 # 分配0x120(288)字节栈空间
; 初始化缓冲区
400751: lea rax,[rbp-0x120] # 缓冲区起始地址
400758: mov edx,0x110 # memset大小:0x110(272)字节
40075d: mov esi,0x0 # 填充值:0
400762: mov rdi,rax # 目标地址
400765: call 400600 <memset@plt> # 清零缓冲区
; 初始化检查变量
40076a: mov QWORD PTR [rbp-0x10],0xabc1337 # 关键!设置v3=0xabc1337
; 第一次输入
400772: mov edi,0x40092e # "Enter your flag1:"
400777: mov eax,0x0
40077c: call 4005f0 <printf@plt> # 打印提示
400781: lea rax,[rbp-0x120] # 输入目标:buf
400788: mov edx,0x18 # 读取大小:0x18(24)字节
40078d: mov rsi,rax
400790: mov edi,0x0 # stdin
400795: call 400610 <read@plt> # 第一次read
; 第二次输入
40079a: mov edi,0x400940 # "Enter your flag2:"
40079f: mov eax,0x0
4007a4: call 4005f0 <printf@plt> # 打印提示
4007a9: lea rax,[rbp-0x120]
4007b0: add rax,0x18 # 输入目标:buf+0x18
4007b4: mov edx,0x100 # 读取大小:0x100(256)字节 漏洞点!
4007b9: mov rsi,rax
4007bc: mov edi,0x0 # stdin
4007c1: call 400610 <read@plt> # 第二次read
; 打印输入内容
4007c6: lea rax,[rbp-0x120]
4007cd: lea rdx,[rax+0x18] # flag2
4007d1: lea rax,[rbp-0x120] # flag1
4007d8: mov rsi,rax
4007db: mov edi,0x400952 # "Nice!, %s, your flag2 is %s."
4007e0: mov eax,0x0
4007e5: call 4005f0 <printf@plt>
; 检查变量是否被修改
4007ea: mov rax,QWORD PTR [rbp-0x10] # 读取v3的值
4007ee: cmp rax,0x1337abc # 比较:v3 == 0x1337abc?
4007f4: jne 40080c <pwn+0xc6> # 不等则跳转到失败分支
; 成功分支:获得shell
4007f6: mov edi,0x400970 # "GIMME GIMME SHELL /bin/sh"
4007fb: call 4005d0 <puts@plt>
400800: mov edi,0x400989 # "/bin/sh"
400805: call 4005e0 <system@plt> # 执行system("/bin/sh") 目标!
40080a: jmp 400816 <pwn+0xd0>
; 失败分支
40080c: mov edi,0x400991 # "you are a good boy."
400811: call 4005d0 <puts@plt>
400816: nop
400817: leave
400818: ret
为了更清晰地理解程序逻辑,我们将汇编代码转换为C伪代码:
void pwn() {
char buf[0x110]; // 缓冲区,位于[rbp-0x120]
long long v3; // 检查变量,位于[rbp-0x10]
memset(buf, 0, 0x110); // 清零缓冲区
v3 = 0xabc1337; // 初始化检查变量
// 第一次输入
printf("Enter your flag1:");
read(0, buf, 0x18); // 读取24字节到buf
// 第二次输入
printf("Enter your flag2:");
read(0, buf + 0x18, 0x100); // 读取256字节到buf+0x18 危险!
// 打印输入
printf("Nice!, %s, your flag2 is %s.", buf, buf + 0x18);
// 检查v3是否被修改为目标值
if (v3 == 0x1337abc) {
puts("you are also a good boy.");
system("/bin/sh"); // 获得shell
} else {
puts("you are a good boy.");
}
}
正常运行(未利用漏洞):
$ echo -e "test1\ntest2" | ./babystack
Enter your flag1:Enter your flag2:Nice!, test1
, your flag2 is test2
.
you are a good boy.
关键观察:
程序接收两次输入
打印输入内容
由于v3保持初始值0xabc1337,不等于0x1337abc
输出"you are a good boy."(失败分支)
成功利用后的输出:
$ python3 exploit.py
Enter your flag1:Enter your flag2:Nice!, aaaaaa..., your flag2 is aaaaaa...
you are also a good boy.
$ # 获得shell
注意区别:
失败:you are a good boy.
成功:you are also a good boy.+ shell访问权限
在64位程序中,栈是从高地址向低地址增长的。pwn函数的栈布局如下:
高地址
+------------------+
| 返回地址 | rbp+0x8
+------------------+
| 保存的rbp | rbp (函数序言push rbp)
+------------------+
| ... | rbp-0x8
+------------------+
| v3变量 | rbp-0x10 目标变量!初始值0xabc1337
+------------------+
| ... |
+------------------+
| buf+0x18开始 | rbp-0x108 第二次read的起点
| (第二次输入) |
| ... |
+------------------+
| buf开始 | rbp-0x120 第一次read的起点
| (第一次输入) |
+------------------+
低地址
关键问题:第二次read()存在栈溢出漏洞!
让我们计算一下:
第二次read的起始位置:rbp-0x120+0x18 = rbp-0x108
第二次read的大小:0x100 = 256字节
v3变量的位置:rbp-0x10
从read起点到v3的距离:0x108 - 0x10 = 0xf8 = 248字节
问题所在:
第二次read允许输入256字节
但从起点rbp-0x108到v3变量只有248字节的距离
因此,我们可以通过第二次输入,覆盖掉v3变量的值!
攻击思路:
第一次输入随意填充(因为只是普通输入,不影响漏洞利用)
第二次输入构造特殊payload:
前248字节:垃圾数据(填充buf到v3之间的空间)
第249-256字节:将v3覆盖为0x1337abc
为什么要改成0x1337abc而不是0xabc1337?
仔细观察汇编代码:
40076a: mov QWORD PTR [rbp-0x10],0xabc1337 # 初始化
4007ee: cmp rax,0x1337abc # 检查
这里有个有趣的点:
初始化时设置的是0xabc1337
但检查时比较的是0x1337abc
这是出题人故意设置的小陷阱(或者是初始化时的bug)
我们需要覆盖为检查时的值