0%

BPF逆向

BPF简介

BPF(Berkeley Packet Filter)是一个在Linux内核运行的安全计算系统 可以简单理解为一个专门为了计算过滤规则的虚拟机 通常配合prctl()seccomp()进行对系统调用的限制 有两个主要32位寄存器:

A(累加器, Accumulator):存储主要操作数据

X(索引寄存器, Index Register):通常用于索引计算或临时存储数据

以及16个32位内存单元mem[idx]

支持常见的数值和位运算 在使用seccomp或prctl添加了BPF过滤器后 每次进行系统调用时都会先对使用的系统调用号sys_number 系统ABIarch 以及传入的参数进行安全计算然后判断是否能够执行系统调用

BPF的使用

如上所说 BPF通常是用来进行对系统调用的过滤的 但是由于其内部虚拟机的功能的强大 也可以用来隐藏某些重要计算过程

其用户层的实现主要依靠两个结构体:

1
2
3
4
5
6
7
8
9
10
11
struct sock_filter {    /* Filter block */
__u16 code; /* Actual filter code */
__u8 jt; /* Jump true */
__u8 jf; /* Jump false */
__u32 k; /* Generic multiuse field */
};

struct sock_fprog { /* Required for SO_ATTACH_FILTER. */
unsigned short len; /* Number of filter blocks */
struct sock_filter __user *filter;
};

其中sock_fprog就是作为过滤器传入prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prog)seccomp(1, 0, &prog)的第三个参数 第二个成员.filter可以当作过滤器的字节码 每条指令长度都是8 bytes 若要构造的指令不是跳转指令 .jt.jf通常都是0 如果要自己编写BPF过滤器通常使用BPF_STMT(code, k)BPF_JUMP(code, k, jt, jf)两个宏来分别构造运算和跳转指令 在这里查询指令对应的字节码

BPF逆向

由于BPF指令长度固定 操作码种类少 可以自己写脚本来反汇编BPF的内容 这里使用seccomp tools来进行反汇编 可以使用dump选项来自动检测程序中的BPF过滤器并反汇编 缺点是如果加载了多个过滤器只会检测出一个

下面给出一个BPF逆向的例子

[IrisCTF 2024] Secure Computing

知道是BPF过滤器相关的题先在local type定义一下上述的两个结构体方便逆向:

image-20250108225954417

程序在.init_array段的全局构造函数中添加了8个filter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
unsigned __int64 init_()
{
__int64 v0; // rbx
struct sock_filter *v1; // rax
sock_fprog v3; // [rsp+0h] [rbp-48h] BYREF
unsigned __int64 v4; // [rsp+18h] [rbp-30h]

v4 = __readfsqword(0x28u);
ptrace(PTRACE_TRACEME, 0LL);
v0 = 0LL;
prctl(0x26, 1LL, 0LL, 0LL, 0LL);
do
{
v3.len = (unsigned __int16)(&len)[v0];
v1 = (&BPF_Filter)[v0++];
v3.filter = v1;
syscall(0x13DLL, 1LL, 0LL, &v3);
}
while ( v0 != 8 );
return __readfsqword(0x28u) ^ v4;
}

长度都是0xED3:

image-20250108230301280

然后在主函数对系统调用号0x1337进行了调用 将输入的flag内容分成6个QWORD传入作为参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__int64 __fastcall main(int a1, char **a2, char **a3)
{
_QWORD v4[7]; // [rsp+0h] [rbp-58h] BYREF
__int16 v5; // [rsp+38h] [rbp-20h]
unsigned __int64 v6; // [rsp+48h] [rbp-10h]

v6 = __readfsqword(0x28u);
__printf_chk(1LL, "Guess: ", a3);
v5 = 0;
memset(v4, 0, sizeof(v4));
if ( (unsigned int)__isoc99_scanf("%57s", v4) == 1
&& ~(strlen((const char *)v4) + 1) == -59LL
&& !strncmp((const char *)v4, "irisctf{", 8uLL)
&& (_BYTE)v5 == '}' )
{
syscall(0x1337LL, v4[1], v4[2], v4[3], v4[4], v4[5], v4[6]);
puts("Maybe? idk bro");
}
else
{
puts("Guess harder");
}
return 0LL;
}

确定了BPF在物理内存中的位置后就可以全部dump出来进行反汇编了:

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
# dumper.py
import subprocess

def excut(prog):
process = subprocess.Popen(prog, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
out, _ = process.communicate()
return out

start = 0x2060
lenth = 0x76a0

with open('./chal', 'rb') as f:
f.seek(start)
for i in range(8):
data = f.read(lenth)
with open('filter_%d' % i, 'wb') as d:
d.write(data)
excut(f'sudo seccomp-tools disasm ./filter_{i} > dump_{i}')
excut(f'rm ./filter_{i}')

data = ''
for i in range(8):
with open(f'dump_{i}', 'r') as f:
data = f.read()
data = data.replace('sys_number', '0x1337').replace('arch', '0xc000003e')
with open(f'dump_{i}', 'w') as f:
f.write(data.split('=================================\n')[1])

就能得到8个这样的dump-disasm文件:

image-20250108231049146

解释一下为什么arch替换成了0xc000003e 程序的框架是ARCH_X86_64是确定的 而BPF过滤器使用的是include/uapi/linux/audit.h下定义的框架宏作为框架对应值的 其定义为:

1
2
3
4
#define EM_X86_64       62      /* AMD x86-64 */
#define __AUDIT_ARCH_64BIT 0x80000000
#define __AUDIT_ARCH_LE 0x40000000
#define AUDIT_ARCH_X86_64 (EM_X86_64|__AUDIT_ARCH_64BIT|__AUDIT_ARCH_LE)

显然要用z3来解 看官方题解自己实现了一个模拟器来执行 实际上这些反汇编已经符合python语法 记录一下可能出现的指令样式直接用exec()来执行即可:

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
52
53
from z3 import *
import re

s = Solver()
A = 0
X = 0
mask = 0xFFFFFFFF
mem = [0] * 0x10
# args = [BitVec(f'arg_{i}', 64) for i in range(6)]
args = [BitVec(f'arg_{i}', 32) for i in range(6 * 2)]

# vars = {}
for i in range(8):
with open(f'dump_{i}', 'r') as f:
lines = f.readlines()
for line in lines:
code = line[34:-1]
if code[0] == 'A' or code[0] == 'X' or code[0] == 'm':
if 'args' not in code:
exec(code)
if type(A) == int:
A &= mask
if type(X) == int:
X &= mask
else:
res = re.match(r'A = args\[(\d+)\]', code)
idx = res.group(1)
if code[-5:] == '>> 32':
A = args[int(idx) * 2 + 1]
else:
A = args[int(idx) * 2]
elif code[0] == 'i':
res = re.match(r'if \(A != (\d+)\) goto (\d+)', code)
num = 0
if res:
num = int(res.group(1))
else:
try:
res = re.match(r'if \(A == (\d+)\) goto (\d+)', code)
num = int(res.group(1))
except:
continue
s.add(A == num)
# vars[code[0]] = 1

# print(vars)
if s.check() == sat:
m = s.model()
for i in range(6 * 2):
try:
print(m[args[i]].as_long().to_bytes(4, 'little').decode(), end='')
except:
print('*' * 4, end='')

其中vars用来探测可能出现的指令样式 发现只可能出现AXm(mem[i] = ...)开头的运算类操作, i(if(A ...)的跳转操作以及r(return ...)返回操作 其中运算操作可以直接执行 跳转操作需要添加累加器A中的值和目标值相等的约束 返回操作在输入正确时不可能触发所以直接跳过