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:
| |
start.sh is the classic kernel-CTF QEMU launcher:
| |
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:
| |
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
| |
Two files immediately stand out:
| |
/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:
| |
So /chal:
- creates a flag map and pins it at
/sys/fs/bpf/flag, - loads a program (“flag printer”) and pins it at
/sys/fs/bpf/prog, - creates a map pinned at
/sys/fs/bpf/prog_map, - updates that map with the program,
- 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:
| |
→ 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:
| |
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:
| |
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
| |
→ 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:
| |
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:
Can an unprivileged user load eBPF at all? Check the sysctl inside the VM:
1 2$ cat /proc/sys/kernel/unprivileged_bpf_disabled 0Yes — unprivileged BPF is enabled. As
ebpfwe can callbpf(BPF_PROG_LOAD, ...).What program type must our loader be? Tail calls require the calling program and the target program in the
PROG_ARRAYto be the same type. The flag printer isSOCKET_FILTER, so our loader must also beSOCKET_FILTER. That’s convenient: socket filters are exactly the program type you’re allowed to load and attach unprivileged, andbpf_tail_call(helper #12) andbpf_map_lookup_elem(#1) are both available to them.
So the plan:
BPF_OBJ_GET("/sys/fs/bpf/prog_map")→ fd for the tail-call map (it’s0666).BPF_OBJ_GET("/sys/fs/bpf/flag")→ fd for the flag map.Load our own
SOCKET_FILTER:1 2 3 4 5r2 = prog_map_fd ; lddw (pseudo map fd) r3 = 0 ; index 0 call bpf_tail_call ; -> runs the flag printer r0 = 0 exitAttach it to a socket with
SO_ATTACH_BPFand send one packet to trigger it.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.
| |
Build it freestanding and tiny:
| |
Two implementation footguns worth calling out:
- At
_startthe stack is 16-byte aligned, but GCC’s vectorized array zeroing assumes the post-callalignment (rsp % 16 == 8). Without theandq $-16, %rspfixup you get an instantmovapsSIGSEGV. Aligning the stack (orforce_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.
| |
Running it:
| |
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
| |
8. Takeaways
- A
PROG_ARRAYis 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 withbpf_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 = 0is the enabler. With it set to1/2this path would be closed and the challenge would need a different angle.- Socket filters attached over an
AF_UNIX SOCK_DGRAMpair run synchronously inwrite(), giving a clean, race-free trigger — just don’t block reading the (dropped) packet afterward.