[PATCH] bpf: net_sched: reject bpf_throw() in qdisc teardown callbacks

From: shaos

Date: Tue Nov 03 2026 - 12:33:27 EST


Hello,

A BPF qdisc's generated reset/destroy epilogue cancels its watchdog
timer. bpf_throw() unwinds directly to the exception callback and
skips that epilogue. If the timer was armed, qdisc deletion frees the
qdisc while the embedded timer remains queued. Later timer-queue
activity accesses the freed qdisc, producing a KASAN slab-use-after-free
and a kernel crash.

The reproducer registers a BPF qdisc whose dequeue callback arms the
watchdog and whose reset/destroy callbacks throw. Two fresh KASAN boots
reported use-after-free in rb_insert_color on an object allocated by
qdisc_alloc() and later freed by qdisc teardown. An otherwise identical
no-throw control completed deletion cleanly. The required BPF and
network-administration capabilities make this a privileged local kernel
crash; no privilege escalation or code execution is demonstrated.

HEAD commit: c03c1a14507dfaf8b856c2608f9ac216021fc222
Git tree: 30fd2ef82912c26cf3a58ad0ba6afc375fa851ac
Source version: Linux 7.3
Compiler: gcc 11.4.0, GNU ld 2.38, clang 20 (BPF PoC)

The console log, full KASAN kernel config, and BPF C reproducer are
available from one fixed-revision Gist:

Console output:
https://gist.githubusercontent.com/shaos26/9908a8ddac69e303cb5dcc709958537f/raw/3fa043e43683907bcccacdc80a71b150920049da/console-output.txt
Kernel config:
https://gist.githubusercontent.com/shaos26/9908a8ddac69e303cb5dcc709958537f/raw/3fa043e43683907bcccacdc80a71b150920049da/kernel-config.txt
PoC source:
https://gist.githubusercontent.com/shaos26/9908a8ddac69e303cb5dcc709958537f/raw/3fa043e43683907bcccacdc80a71b150920049da/poc.c

Run this as root in a disposable VM booted with the target kernel,
CONFIG_DEBUG_INFO_BTF=y, bpftool, iproute2, clang 20, and libbpf-dev.
Build each variant against that kernel's BTF; test them on separate
fresh boots. With libbpf headers installed under /usr/include:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
CLANG=/usr/lib/llvm-20/bin/clang
LIBBPF_INC=/usr/include
"$CLANG" -target bpf -D__TARGET_ARCH_x86 -O2 -g -Wall -Werror \
-Wno-missing-declarations -I. -I"$LIBBPF_INC" \
-DVARIANT_THROW=1 -c poc.c -o qdisc-throw.bpf.o
"$CLANG" -target bpf -D__TARGET_ARCH_x86 -O2 -g -Wall -Werror \
-Wno-missing-declarations -I. -I"$LIBBPF_INC" \
-DVARIANT_THROW=0 -c poc.c -o qdisc-control.bpf.o

Mount bpffs if it is not already mounted, register the matching object,
attach its qdisc to loopback, send one packet to arm the watchdog, and
delete the qdisc:

mkdir -p /sys/fs/bpf
mount -t bpf bpffs /sys/fs/bpf 2>/dev/null || true
bpftool struct_ops register qdisc-throw.bpf.o /sys/fs/bpf
ip link set lo up
tc qdisc add dev lo root handle 1: agent31_uaf
ping -c 1 -W 1 127.0.0.1
tc qdisc del dev lo root
sleep 10

For the separate control boot, register qdisc-control.bpf.o and use
qdisc kind agent31_ctl in the same sequence.

On the vulnerable kernel, the two throw runs reproduced the KASAN UAF;
the no-throw control survived. With the patch below, the verifier
rejects bpf_throw() from reset/destroy with -EACCES, before qdisc
registration. The patched control object registered, attached, armed
and deleted normally, then survived the watchdog interval.

Fixes: 7a2dafda950b ("bpf: net_sched: Add a qdisc watchdog timer")
Signed-off-by: shaos <shawnq1@xxxxxxx>
---
net/sched/bpf_qdisc.c | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)

diff --git a/net/sched/bpf_qdisc.c b/net/sched/bpf_qdisc.c
index 098ca02aed89c..61f0dc93d3856 100644
--- a/net/sched/bpf_qdisc.c
+++ b/net/sched/bpf_qdisc.c
@@ -12,6 +12,7 @@
#define QDISC_MOFF_IDX(moff) (moff / sizeof(void (*)(void)))

static struct bpf_struct_ops bpf_Qdisc_ops;
+static const struct bpf_verifier_ops bpf_qdisc_verifier_ops;

struct bpf_sched_data {
struct qdisc_watchdog watchdog;
@@ -131,6 +132,7 @@ static int bpf_qdisc_btf_struct_access(struct bpf_verifier_log *log,
}

BTF_ID_LIST_SINGLE(bpf_qdisc_init_prologue_ids, func, bpf_qdisc_init_prologue)
+BTF_ID_LIST_SINGLE(bpf_qdisc_throw_ids, func, bpf_throw)

static int bpf_qdisc_gen_prologue(struct bpf_insn *insn_buf, bool direct_write,
const struct bpf_prog *prog)
@@ -307,20 +309,31 @@ enum qdisc_ops_kf_flags {
QDISC_OPS_KF_COMMON = 0,
QDISC_OPS_KF_ENQUEUE = 1 << 0,
QDISC_OPS_KF_DEQUEUE = 1 << 1,
+ QDISC_OPS_KF_TEARDOWN = 1 << 2,
};

static const u32 qdisc_ops_context_flags[] = {
[QDISC_OP_IDX(enqueue)] = QDISC_OPS_KF_ENQUEUE,
[QDISC_OP_IDX(dequeue)] = QDISC_OPS_KF_DEQUEUE,
[QDISC_OP_IDX(init)] = QDISC_OPS_KF_COMMON,
- [QDISC_OP_IDX(reset)] = QDISC_OPS_KF_COMMON,
- [QDISC_OP_IDX(destroy)] = QDISC_OPS_KF_COMMON,
+ [QDISC_OP_IDX(reset)] = QDISC_OPS_KF_TEARDOWN,
+ [QDISC_OP_IDX(destroy)] = QDISC_OPS_KF_TEARDOWN,
};

static int bpf_qdisc_kfunc_filter(const struct bpf_prog *prog, u32 kfunc_id)
{
u32 moff, flags;

+ /* bpf_throw() bypasses the generated watchdog-cancellation epilogue. */
+ if (kfunc_id == bpf_qdisc_throw_ids[0] &&
+ prog->aux->st_ops &&
+ prog->aux->st_ops->verifier_ops == &bpf_qdisc_verifier_ops) {
+ moff = prog->aux->attach_st_ops_member_off;
+ flags = qdisc_ops_context_flags[QDISC_MOFF_IDX(moff)];
+ if (flags & QDISC_OPS_KF_TEARDOWN)
+ return -EACCES;
+ }
+
if (!btf_id_set8_contains(&qdisc_kfunc_ids, kfunc_id))
return 0;

--
2.34.1