[学习笔记] pwn
0x00 PWN工具
pwntools
send(payload)
发送payloadsendline(payload)
发送payload,并进行换行(末尾\n)sendafter(some_string, payload)
接收到 some_string 后, 发送你的 payloadrecv(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 | from zio import * |
ROPgadget
ROPgadget --binary rop --string '/bin/sh'
1 | root@kali:~/桌面# ROPgadget --binary rop --only 'int' |
0x01 PWN基础
Linux下的漏洞缓解措施
- NX
所有可以被修改写入shellcode的内存都不可执行
- Stack Canary(可以通过读取绕过)
用于保护栈溢出。在函数执行前,在返回地址前写入一个字长的随机数据,函数返回时校验该值是否被改变,被改变就会直接终止程序。
1 | High |
当程序启用 Canary 编译后,在函数序言部分会取 fs 寄存器 0x28 处的值,存放在栈中 %ebp-0x8 的位置。
在函数返回之前,会将该值取出,并与 fs:0x28 的值进行异或。如果异或的结果为 0,说明 Canary 未被修改,函数会正常返回,这个操作即为检测是否发生栈溢出。
在一个程序的同一次运行时,不同函数在运行中使用的canary值是相同的
canary的最后两位是00
- ASLR
将程序堆栈地址和动态链接库的加载地址进行随机化,降低攻击者对程序内存结构的了解。即使攻击者布置了shellcode并可以控制跳转,仍然无法执行。
- PIE
随机化elf,开启之后将无法通过ROPgadget解决问题
你可以看到只有最后3位地址是确定的,有一种方案是把返回地址修改成后门函数地址,然后只修改后4位(因为不能只修改3位),进行爆破。
开启PIE的程序下相对偏移断点
pwndbg的一个姿势(程序运行状态有效,程序运行后输入CTRL+C即可)
1 | b *$rebase(offset) offset替换为断点的相对偏移 |
- Full Relro
禁止.GOT和.PLT和其他相关内存读写
PLT和GOT
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 | gdb -q ret2libc2 |
32位函数
32位函数正常调用时,会有一个对应的返回地址。这里以’bbbb’ 作为虚假的地址,其后参数对应的参数内容。
1 | payload='a' * 112, p32(system_plt), 'b' * 4, p32(binsh_addr) |
system堆栈平衡
有些64位的glibc的payload调用system函数需要堆栈对齐16位。
一些其他的基础知识
section会根据权限被合并成segment
c程序的运行过程
c程序入口点是main()函数,但是在执行main函数之前其实还会做一些别的事的,比如加载so等等,这个过程是怎样的呢?大概是这样的:
- execve 开始执行
- execve 内部会把bin程序加载后,就把
.interp
指定的动态加载器加载 - 动态加载器把需要加载的so都加载起来,特别的把 libc.so.6 加载
- 调用到libc.so.6里的
__libc_start_main
函数,真正开始执行程序 __libc_start_main
初始化后,调用到main函数- 调用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 | esp--->+-----------------+低地址0x001 |
然后我们调用一个函数的过程是:
1 | int put(1,2,3) |
- 首先进行参数从右到左依次压栈
- 然后将下一条指令的返回地址压入栈
- 跳转到执行函数入口
- 保存栈帧
push %ebp
,然后把栈帧切换成新的mov %esp, %ebp
,然后再抬高栈顶sub $esp, xx
此时栈的情况如下
1 | esp--->+-----------------+低地址 |
然后新ebp上面的就是局部变量,下面的是参数。
然后是出栈
- 保存返回值到%eax
mov %ebp,%esp
降低栈顶pop %ebp
恢复栈帧- 返回下一条指令地址
ret
危险函数
- 输入
gets()
直接读取一行,忽略\x00
scanf()
,不检查长度- 输出
sprintf()
将格式化后的内容写入缓冲区,但是不检查缓冲区长度 - 字符串
strcpy()
遇到\x00
暂停,不检查长度,字符串复制 - 字符串
strcat()
遇到\x00
暂停,不检查长度,字符串拼
计算偏移
cyclic会生成一段有序的填充,我们把它输入到溢出点。
然后执行到返回点,32位直接计算返回地址就行,64位需要取后4位。
然后计算出的偏移是缓冲区+ebp。
1 | payload='a'*23+return_addr |
stack smash
程序开启了canary保护之后,如果我们覆盖了buffer对应的值,程序就会报错。我们可以利用报错信息得到我们想要的内容。因为程序如果发现canary被修改,就会调用__stack_chk_fail
函数打印argv[0]
指针指向的字符串。我们只要通过栈溢出覆盖argv[0]
,就能输出任意信息。
1 | void __attribute__ ((noreturn)) __stack_chk_fail (void) |
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 | # 正常的函数 |
0x03 ROP
one_gadget
1 | # one gadget |
rop接收返回地址
32位:
1 | addr = u32(p.recvuntil("\xf7")[-4:]) |
64位:
1 | addr = u64(p.recvuntil("\x7f")[-6:].ljust(8, "\x00")) |
上网查为什么是这个x7f都没有正确的解答,还有一些扯的乱七八糟的,最后发现这个原来是大部分的函数地址的开头都是这个。
1 | 比如32位地址,0xf7e7f6f0在内存中是这么存储的: |
ropchain
对于静态生成的程序可以使用ropper或者ROPgadget直接生成ropchain
1 | ropper --file a --chain execve |
1 | ROPgadget --binary calc --only 'pop|ret' |
ret2csu
1 | .text:00000000004005C0 ; void _libc_csu_init(void) |
0x04 格式化字符串漏洞
常见的有格式化字符串函数有
- 输入
- scanf
- 输出
函数 | 基本介绍 |
---|---|
printf | 输出到 stdout |
fprintf | 输出到指定 FILE 流 |
vprintf | 根据参数列表格式化输出到 stdout |
vfprintf | 根据参数列表格式化输出到指定 FILE 流 |
sprintf | 输出到字符串 |
snprintf | 输出指定字节数到字符串 |
vsprintf | 根据参数列表格式化输出到字符串 |
vsnprintf | 根据参数列表格式化输出指定字节到字符串 |
setproctitle | 设置 argv |
syslog | 输出日志 |
err, verr, warn, vwarn 等 | 。。。 |
1 | int printf(const char* format, ...); |
1 | printf("%d",10); |
%d
被称为格式化字符串,占用符用于指明输出的参数值如何格式化。
占位符的语法:
1 | %[parameter][flags][field width][.precision][length]type |
parameter可以忽略或者为n$
,n表示此占位符传入的第几个参数:
1 | printf("%2$d %1$d\n",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或者ex、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。
格式化字符串利用方式
- 利用 %s 来获取变量所对应地址的内容,只不过有零截断。如果提供了一个不可访问的地址,程序就会崩溃。所以让程序崩溃的最简单方式,就是输入多个%s。
- 利用 %n$p来获取对应栈的内存
- 利用%n写数据。例如
%10$4n
就是将4写入偏移10处指针所指向的地址。我们可以通过修改偏移10处的地址,达到任意写。 - %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 | global _start |
1 | nasm -f elf64 x64.asm |
32位
1 | global _start |
1 | nasm -f elf32 i386.asm |
纯字符的shellcode
因为有些时候程序会对用户输入的字符进行限制,比如只允许输入可见字符,这时就需要用到纯字符的shellcode了。
1 | from pwn import * |
1 | python3 ./ALPHA3.py x64 ascii mixedcase rax --input="sc.bin" |
1 | [Usage] ALPHA3.py [ encoder settings | I/O settings | flags ] |
二进制
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 | 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 | -fno-stack-protector 关闭canary保护 |
0x08 SROP
signal是unix中进程之间相互传递信息的一种方法。
- kernel向某个进程发送signal,进程会暂时挂起,进入内核
- 内核会保存进程的上下文(将所有寄存器压入栈,压入signal信息,指向sigreturn的系统调用地址),这一部分在用户的地址空间,所以用户可以读写。
- 然后内核会跳转到signal handler中处理相应的signal
- signal handler执行完毕,内核为该进程恢复之前保存的上下文
- 恢复执行
linux下,内核会帮用户进程将其上下文保存在该进程的栈上,然后在栈底填上一个signal return地址,程序在执行完signal handler之后,就会让esp/rsp寄存器指向到signal return的地方。
x86
1 | struct sigcontext |
x64
1 | struct _fpstate |
因为sigreturnframe一般比较大,能用SROP的,基本上都能常规溢出,因此考这个就不会放libc。
利用条件:
1.可以栈溢出(控制signal frame)
2.找到syscall地址
3.找到mov rax,oxf(即sigreturn的系统调用号)
4.获得栈上的地址
- “/bin/sh”
- Signal Frame
0x09 如何从libc中获取栈地址
- 在libc中包含了
__environ
函数,它存储当前进程的环境变量 - 在内存中他们的相对位置不变,可以通过
__environ
函数对栈中参数的偏移量对任意变量进行访问。