0x00 PWN工具

pwntools

  • send(payload) 发送payload
  • sendline(payload) 发送payload,并进行换行(末尾\n
  • sendafter(some_string, payload) 接收到 some_string 后, 发送你的 payload
  • recv(N) 接受 N(数字) 字符
  • recvline() 接收一行输出
  • recvlines(N) 接收 N(数字) 行输出
  • recvuntil(some_string) 接收到 some_string 为止
  • remote("一个域名或者ip地址", 端口) 会连接到我们指定的地址及端口
  • process 通过你声明的二进制文件路径在本地创建新的进程
  • p64()会把64位整数转换为8字节字符串
  • u64()会把8字节字符串转换为64位整数
  • interactive()切换到直接交互模式

int(“参数”,16),参数是样子为16进制格式(如0x4ab010或去掉0x)的字符串
u64(参数),参数是地址的机器码(如/x /x)

zio

1
2
3
4
5
6
7
from zio import *
p=zio(ip,port)
p.read() #直接从远程服务器读取数据
p.readline() #从远程服务器读取一行数据
p.read_until(pattern) #从远程服务器读取数据,直到遇到pattern字符串。
p.write() #直接向远程服务器写数据
p.writeline() #向远程服务器写数据(在数据末尾自动添加换行符)

ROPgadget

ROPgadget --binary rop --string '/bin/sh'

1
2
3
4
5
6
root@kali:~/桌面# ROPgadget --binary rop --only 'int'
Gadgets information
============================================================
0x08049421 : int 0x80

Unique gadgets found: 1

0x01 PWN基础

Linux下的漏洞缓解措施

  1. NX

所有可以被修改写入shellcode的内存都不可执行

  1. Stack Canary(可以通过读取绕过)

用于保护栈溢出。在函数执行前,在返回地址前写入一个字长的随机数据,函数返回时校验该值是否被改变,被改变就会直接终止程序。

image-20210904201332894

image-20210904201655192

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  High
Address | |
+-----------------+
| args |
+-----------------+
| return address |
+-----------------+
rbp => | old ebp |
+-----------------+
rbp-8 => | canary value |
+-----------------+
| local variables |
Low | |
Address

当程序启用 Canary 编译后,在函数序言部分会取 fs 寄存器 0x28 处的值,存放在栈中 %ebp-0x8 的位置。

在函数返回之前,会将该值取出,并与 fs:0x28 的值进行异或。如果异或的结果为 0,说明 Canary 未被修改,函数会正常返回,这个操作即为检测是否发生栈溢出。

在一个程序的同一次运行时,不同函数在运行中使用的canary值是相同的

canary的最后两位是00

  1. ASLR

将程序堆栈地址和动态链接库的加载地址进行随机化,降低攻击者对程序内存结构的了解。即使攻击者布置了shellcode并可以控制跳转,仍然无法执行。

  1. PIE

随机化elf,开启之后将无法通过ROPgadget解决问题

image-20210904204842899

image-20210904205421189

image-20210904205449136

你可以看到只有最后3位地址是确定的,有一种方案是把返回地址修改成后门函数地址,然后只修改后4位(因为不能只修改3位),进行爆破。

开启PIE的程序下相对偏移断点

pwndbg的一个姿势(程序运行状态有效,程序运行后输入CTRL+C即可)

1
b *$rebase(offset)  offset替换为断点的相对偏移
  1. Full Relro

禁止.GOT和.PLT和其他相关内存读写

PLT和GOT

image-20210527210645387

08048460是plt,804A010是got。执行gets函数使用plt地址,想知道gets函数真实地址,计算libc基址用got地址。

程序会先到plt寻找外部函数的地址,第一次调用时,程序会通过got表再次跳转到plt表,运行地址解析程序确定函数的确切地址,然后覆盖got表的初始值,然后进行函数调用。

第二次调用外部函数时,程序仍然首先从plt跳转到got,但是此时的got已经存有函数的内存地址,可以直接跳转函数所在地。

这么做的原因是为了效率,不用花时间去解析不用的函数。

bss段

寻找bss段地址

1
readelf -S ret2libc2 | grep bss

检测bss段权限

1
2
3
gdb -q ret2libc2
start
vmmap

32位函数

32位函数正常调用时,会有一个对应的返回地址。这里以’bbbb’ 作为虚假的地址,其后参数对应的参数内容。

1
payload='a' * 112, p32(system_plt), 'b' * 4, p32(binsh_addr)

system堆栈平衡

有些64位的glibc的payload调用system函数需要堆栈对齐16位。

一些其他的基础知识

image-20210830160003015

section会根据权限被合并成segment

image-20210831223617461

image-20210901154901930

c程序的运行过程

c程序入口点是main()函数,但是在执行main函数之前其实还会做一些别的事的,比如加载so等等,这个过程是怎样的呢?大概是这样的:

  1. execve 开始执行
  2. execve 内部会把bin程序加载后,就把.interp指定的动态加载器加载
  3. 动态加载器把需要加载的so都加载起来,特别的把 libc.so.6 加载
  4. 调用到libc.so.6里的__libc_start_main函数,真正开始执行程序
  5. __libc_start_main初始化后,调用到main函数
  6. 调用main函数结束后,会调用libc_start_main_ret退出

从这里也可以看出,为什么即使一个int main(){return 0;}这样的最简c程序,也会用到libc.so.6。就因为必然用到__libc_start_main,而__libc_start_main在libc.so.6里面呢。

0x02 栈溢出

以32位栈举例:

初始状态:

1
2
3
4
5
 esp--->+-----------------+低地址0x001
| |
| |
| |
ebp--->+-----------------+高地址0x011

然后我们调用一个函数的过程是:

1
int put(1,2,3)
  1. 首先进行参数从右到左依次压栈
  2. 然后将下一条指令的返回地址压入栈
  3. 跳转到执行函数入口
  4. 保存栈帧push %ebp,然后把栈帧切换成新的mov %esp, %ebp,然后再抬高栈顶sub $esp, xx

此时栈的情况如下

1
2
3
4
5
6
7
8
9
10
11
    esp--->+-----------------+低地址
| |
| |
| |
| |
new ebp-->| |
| ret_addr |
| 1 |
| 2 |
| 3 |
old ebp-->+-----------------+高地址0x011

然后新ebp上面的就是局部变量,下面的是参数。

然后是出栈

  1. 保存返回值到%eax
  2. mov %ebp,%esp降低栈顶
  3. pop %ebp恢复栈帧
  4. 返回下一条指令地址ret

危险函数

  • 输入gets()直接读取一行,忽略\x00
  • scanf(),不检查长度
  • 输出sprintf()将格式化后的内容写入缓冲区,但是不检查缓冲区长度
  • 字符串strcpy()遇到\x00暂停,不检查长度,字符串复制
  • 字符串strcat()遇到\x00暂停,不检查长度,字符串拼

计算偏移

image-20211013181056213

cyclic会生成一段有序的填充,我们把它输入到溢出点。

image-20211013181433066

然后执行到返回点,32位直接计算返回地址就行,64位需要取后4位。

然后计算出的偏移是缓冲区+ebp。

1
payload='a'*23+return_addr

stack smash

程序开启了canary保护之后,如果我们覆盖了buffer对应的值,程序就会报错。我们可以利用报错信息得到我们想要的内容。因为程序如果发现canary被修改,就会调用__stack_chk_fail函数打印argv[0]指针指向的字符串。我们只要通过栈溢出覆盖argv[0],就能输出任意信息。

1
2
3
4
5
6
7
8
9
10
11
void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
__fortify_fail ("stack smashing detected");
}
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>");
}

stack pivoting

stack pivoting可以通过劫持栈指针控制程序的内存,然后再在相应的位置进行ROP。

我们可以在以下情况使用stack pivoting

  • 可以控制的栈溢出字节较少,难以构造较长的ROP链;
  • 开启了PIE保护,栈地址未知,我们可以将栈劫持到已知的区域;
  • 将栈劫持到堆空间,从而在堆上写rop以及进行堆漏洞利用。

frame faking

可以参考ciscn_2019_es_2理解下文

frame faking的攻击原理是构造虚假的栈帧来控制程序的执行流

一般来说,payload如下所示

1
buffer padding|fake ebp|leave ret addr|

frame faking的攻击方式是把返回地址覆盖成leave ret的地址,这样经过巧妙构造可以成功构成利用。

leave指令可以用mov esp ebp ; pop ebp替代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 正常的函数
push ebp
mov ebp, esp
mov esp, ebp
pop ebp
retn
# exp之后
# fake ebp指向存放shellcode的栈地址
push ebp # 覆盖的是栈上的这个ebp
mov ebp, esp
mov esp, ebp
pop ebp # pop的是fake ebp
pop eip # retn leave
mov esp, ebp # esp = fake ebp
pop ebp # 随便给个ebp地址
pop eip # 因为此时的esp地址已经被改变,所以可以直接返回到后门函数

0x03 ROP

one_gadget

1
2
3
4
# one gadget
one_gadget_16_04_32 = [0x3ac5c,0x3ac5e,0x3ac62,0x3ac69,0x5fbc5,0x5fbc6]
one_gadget_16_04_64 = [0x45216,0x4526a,0xf02a4,0xf1147]
one_gadget_18_04_64 = [0x4f2c5,0x4f322,0x10a38c]

rop接收返回地址

32位:

1
addr = u32(p.recvuntil("\xf7")[-4:])

64位:

1
addr = u64(p.recvuntil("\x7f")[-6:].ljust(8, "\x00"))

上网查为什么是这个x7f都没有正确的解答,还有一些扯的乱七八糟的,最后发现这个原来是大部分的函数地址的开头都是这个。

1
2
比如32位地址,0xf7e7f6f0在内存中是这么存储的:
'\xf0\xf6\xe7\xf7'

ropchain

对于静态生成的程序可以使用ropper或者ROPgadget直接生成ropchain

1
2
3
ropper --file a --chain execve

ROPgadget --binary a --ropchain

image-20210911211941703

1
ROPgadget --binary calc --only 'pop|ret'

ret2csu

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
.text:00000000004005C0 ; void _libc_csu_init(void)
.text:00000000004005C0 public __libc_csu_init
.text:00000000004005C0 __libc_csu_init proc near ; DATA XREF: _start+16o
.text:00000000004005C0 push r15
.text:00000000004005C2 push r14
.text:00000000004005C4 mov r15d, edi
.text:00000000004005C7 push r13
.text:00000000004005C9 push r12
.text:00000000004005CB lea r12, __frame_dummy_init_array_entry
.text:00000000004005D2 push rbp
.text:00000000004005D3 lea rbp, __do_global_dtors_aux_fini_array_entry
.text:00000000004005DA push rbx
.text:00000000004005DB mov r14, rsi
.text:00000000004005DE mov r13, rdx
.text:00000000004005E1 sub rbp, r12
.text:00000000004005E4 sub rsp, 8
.text:00000000004005E8 sar rbp, 3
.text:00000000004005EC call _init_proc
.text:00000000004005F1 test rbp, rbp
.text:00000000004005F4 jz short loc_400616
.text:00000000004005F6 xor ebx, ebx
.text:00000000004005F8 nop dword ptr [rax+rax+00000000h]
.text:0000000000400600
.text:0000000000400600 loc_400600: ; CODE XREF: __libc_csu_init+54j
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call qword ptr [r12+rbx*8]
.text:000000000040060D add rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600
.text:0000000000400616
.text:0000000000400616 loc_400616: ; CODE XREF: __libc_csu_init+34j
.text:0000000000400616 add rsp, 8
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
.text:0000000000400624 __libc_csu_init endp

0x04 格式化字符串漏洞

常见的有格式化字符串函数有

  • 输入
    • scanf
  • 输出
函数 基本介绍
printf 输出到 stdout
fprintf 输出到指定 FILE 流
vprintf 根据参数列表格式化输出到 stdout
vfprintf 根据参数列表格式化输出到指定 FILE 流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
setproctitle 设置 argv
syslog 输出日志
err, verr, warn, vwarn 等 。。。
1
2
3
4
int printf(const char* format, ...);
int fprintf(FILE* stream, const char* format, ...);
int sprintf(char* str, const char* format, ...);
int snprintf(char* str, size_t size, const char* format, ...);
1
printf("%d",10);

%d被称为格式化字符串,占用符用于指明输出的参数值如何格式化。

占位符的语法:

1
%[parameter][flags][field width][.precision][length]type

parameter可以忽略或者为n$,n表示此占位符传入的第几个参数:

1
2
3
4
printf("%2$d %1$d\n",1,2);
printf("%d %d\n",1,2);
// 2,1
// 1,2

flag可为0或者多个,主要包含:

flags 含义
+ 总是表示有符号数值的 + 或 - 号,默认忽略正数的符号。仅适用于数值类型。
空格 有符号数的输出如果没有正负号或者输出0个字符,则以1个空格作为前缀
- 左对齐,默认是右对齐
# 对于g与G,不删除尾部0以表示精度;对于f、F、e、E、g、G,总是输出小数点;对于o、x、X,在非0数值前分别输出前缀0、0x和0X,表示数制。
0 在宽度选项前,表示用0填充

field width给出显示数值的最小宽度,若实际位数多于width,则按照实际输出,若小于,则补空格或0。如果域宽为*,则由对应的函数参数的值为当前域宽。

.precision指明输出的最大长度:

  • 对于d、i、u、x、o的整型数值,指最小数字位数,不足的在左侧补0
  • 对于a、A、e、E、f、F的浮点数值,指小数点右边显示的位数
  • 对于g、G的浮点数值,指有效数字的最大位数
  • 对于s的字符串类型,指输出的字节上限
  • 如果域宽为*,则由对应的函数参数的值为当前域宽。如果仅给出了小数点,则域宽为0。

length指出浮点型参数或整型参数的长度:

  • hh匹配char的整型参数
  • h匹配short的整型参数
  • l匹配long的整型参数。对于浮点类型匹配double大小的参数。对于字符串s类型,匹配wchar_t指针参数。对于字符c类型,匹配wint_t类型。
  • ll匹配long long的整型参数
  • L匹配long double的整型参数
  • z匹配size_t的整型参数
  • j匹配intmax_t的整型参数
  • t匹配ptrdiff_t的整型参数

type:

  • d、i 有符号十进制int值
  • u 十进制unsigned int值
  • f、F 十进制double值
  • e、E double值,输出形式为十进制的[-]d.ddd e[+/-]ddd
  • g、G double型数值,根据数值大小自动选f或者e
  • x、X 十六进制unsigned int值
  • o 八进制unsigned int值
  • s 字符串,以\x00结尾
  • c 一个char类型字符
  • p void*指针类型,这个能打印多少位建议自己在函数中进行测试。可能是16位或者32位或者64位
  • a、A double类型十六进制,[-]0xh.hhhh p±d,指数部分为10进制表示形式
  • n 把已经成功输出的字符个数写入对应的整形指针参数所指的变量
  • % 字面值,不接受任何flags, width, precision or length。

格式化字符串利用方式

  1. 利用 %s 来获取变量所对应地址的内容,只不过有零截断。如果提供了一个不可访问的地址,程序就会崩溃。所以让程序崩溃的最简单方式,就是输入多个%s。
  2. 利用 %n$p来获取对应栈的内存
  3. 利用%n写数据。例如%10$4n就是将4写入偏移10处指针所指向的地址。我们可以通过修改偏移10处的地址,达到任意写。
  4. %n是一次性写入4字节,%hn是一次性写入2字节,%hhn是一次性写入1字节

pwndbg的fmtarg可以计算格式化字符串的偏移。

当程序开启FORTIFY机制后(gcc -D_FORTIFY_SOURCE=2 -O1),程序编译时所有的printf函数都被_printf_chk替换了。他们两个的区别是:(1.)不能使用%n$p不连续打印,比如要使用%3$p,必须先使用%2$p和%1$p。(2.)在使用%n的时候会做一些检查。

开启FORTIFY机制后,如果可输入的字符有限,就不能用%p泄露地址了,这种情况下可以使用%a。

由于64位通过寄存器传参6个,所以最后一个寄存器r9就是%5$p,所以64位程序栈中的参数就是从%6$p开始的。

非栈上格式化字符串漏洞利用

0x05 shellcode

shellcode

调用execve的条件

  • 32位:edx=0 ecx=0 [ebx]='/bin/sh' eax=0xb int 0x80
  • 64位:rdx=0 rsi=0 [rdi]='/bin/sh' rax=0x3b syscall

64位

1
2
3
4
5
6
7
8
9
10
11
global _start
_start:
mov rbx,'/bin/sh'
push rbx
push rsp
pop rdi
xor esi,esi
xor edx,edx
push 0x3b
pop rax
syscall
1
2
3
nasm -f elf64 x64.asm
ld -m elf_x86_64 -o x64 x64.o
objdump -d x64

32位

1
2
3
4
5
6
7
8
9
global _start
_start:
push "/sh"
push "/bin"
mov ebx,esp
xor edx, edx
xor ecx, ecx
mov al, 0xb
int 0x80
1
2
3
nasm -f elf32 i386.asm
ld -m elf_i386 -o i386 i386.o
objdump -d i386

纯字符的shellcode

因为有些时候程序会对用户输入的字符进行限制,比如只允许输入可见字符,这时就需要用到纯字符的shellcode了。

1
2
3
4
5
6
from pwn import *
context.arch='amd64'
f=open("sc.bin",'wb')
payload = asm(shellcraft.sh())
f.write(payload)
f.close()
1
python3 ./ALPHA3.py x64 ascii mixedcase rax --input="sc.bin"
1
2
3
4
5
[Usage] ALPHA3.py [ encoder settings | I/O settings | flags ]
encoder: 处理器架构和字符编码,例如x86,x64,例如ascii
I/O: --input='file' 表示要编码的shellcode路径,--output='file' 表示接收编码的shellcode的文件路径。
mixedcase指的是大小写混合,可以单独设置仅大写或者小写
rax是用于编码的寄存器(shellcode基址)

二进制

1
shellcode=b'\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xb0\x0b\xcd\x80'

32位:

传参方式:首先将系统调用号传入eax,然后将参数从左到右依次存入 ebx,ecx,edx寄存器中,返回值存在eax寄存器。
调用方式: 使用 int 80h 中断进行系统调用

64位:

传参方式:首先将系统调用号传入rax,然后将参数从左到右依次存入 rdi,rsi,rdx寄存器中,返回值存在rax寄存器。
调用方式: 使用 syscall 进行系统调用

0x06 沙箱保护和orw

沙箱保护是一种内核的安全机制,可以禁用一些系统调用,这样可以使程序更加安全。

Seccomp(全称:secure computing mode)在2.6.12版本(2005年3月8日)中引入linux内核,将进程可用的系统调用限制为四种:read,write,_exit,sigreturn。在这种模式下除了已打开的文件描述符和允许的四种系统调用,如果尝试其他系统调用,内核就会使用SIGKILL或SIGSYS终止该进程。

seccomp-tools可以在pwn中分析沙箱保护。

1
2
seccomp-tools dump ./a.out 
# 可以查看过滤规则

seccomp在ctf中大多用于禁用execve函数,解决办法就是构造shellcode,用open->read->write的方式读flag

orw

标准输入:0

标准输出:1

标准错误:2

文件描述符一般会找到当前没有被使用的最小的下标作为新的文件描述符,所以常见的orw时,open返回的fd指针是3。

0x07 gcc编译环境

gcc

1
2
3
4
-fno-stack-protector  关闭canary保护
-fstack-protector 开启canary保护
-no-pie 关闭pie保护
-z execstack 关闭nx保护

0x08 SROP

signal是unix中进程之间相互传递信息的一种方法。

  1. kernel向某个进程发送signal,进程会暂时挂起,进入内核
  2. 内核会保存进程的上下文(将所有寄存器压入栈,压入signal信息,指向sigreturn的系统调用地址),这一部分在用户的地址空间,所以用户可以读写。
  3. 然后内核会跳转到signal handler中处理相应的signal
  4. signal handler执行完毕,内核为该进程恢复之前保存的上下文
  5. 恢复执行

linux下,内核会帮用户进程将其上下文保存在该进程的栈上,然后在栈底填上一个signal return地址,程序在执行完signal handler之后,就会让esp/rsp寄存器指向到signal return的地方。

x86

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
struct sigcontext
{
unsigned short gs, __gsh;
unsigned short fs, __fsh;
unsigned short es, __esh;
unsigned short ds, __dsh;
unsigned long edi;
unsigned long esi;
unsigned long ebp;
unsigned long esp;
unsigned long ebx;
unsigned long edx;
unsigned long ecx;
unsigned long eax;
unsigned long trapno;
unsigned long err;
unsigned long eip;
unsigned short cs, __csh;
unsigned long eflags;
unsigned long esp_at_signal;
unsigned short ss, __ssh;
struct _fpstate * fpstate;
unsigned long oldmask;
unsigned long cr2;
};

x64

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
struct _fpstate
{
/* FPU environment matching the 64-bit FXSAVE layout. */
__uint16_t cwd;
__uint16_t swd;
__uint16_t ftw;
__uint16_t fop;
__uint64_t rip;
__uint64_t rdp;
__uint32_t mxcsr;
__uint32_t mxcr_mask;
struct _fpxreg _st[8];
struct _xmmreg _xmm[16];
__uint32_t padding[24];
};

struct sigcontext
{
__uint64_t r8;
__uint64_t r9;
__uint64_t r10;
__uint64_t r11;
__uint64_t r12;
__uint64_t r13;
__uint64_t r14;
__uint64_t r15;
__uint64_t rdi;
__uint64_t rsi;
__uint64_t rbp;
__uint64_t rbx;
__uint64_t rdx;
__uint64_t rax;
__uint64_t rcx;
__uint64_t rsp;
__uint64_t rip;
__uint64_t eflags;
unsigned short cs;
unsigned short gs;
unsigned short fs;
unsigned short __pad0;
__uint64_t err;
__uint64_t trapno;
__uint64_t oldmask;
__uint64_t cr2;
__extension__ union
{
struct _fpstate * fpstate;
__uint64_t __fpstate_word;
};
__uint64_t __reserved1 [8];
};

因为sigreturnframe一般比较大,能用SROP的,基本上都能常规溢出,因此考这个就不会放libc。

利用条件:

1.可以栈溢出(控制signal frame)

2.找到syscall地址

3.找到mov rax,oxf(即sigreturn的系统调用号)

4.获得栈上的地址

  • “/bin/sh”
  • Signal Frame

0x09 如何从libc中获取栈地址

  1. 在libc中包含了__environ函数,它存储当前进程的环境变量
  2. 在内存中他们的相对位置不变,可以通过__environ函数对栈中参数的偏移量对任意变量进行访问。