From: Andrea Parri <parri.andrea@gmail.com>
To: Miroslav Benes <mbenes@suse.cz>
Cc: Andrea Parri <parri.andrea@gmail.com>,
Josh Poimboeuf <jpoimboe@kernel.org>,
Jiri Kosina <jikos@kernel.org>, Petr Mladek <pmladek@suse.com>,
Joe Lawrence <joe.lawrence@redhat.com>,
Song Liu <song@kernel.org>, Peter Zijlstra <peterz@infradead.org>,
live-patching@vger.kernel.org, linux-kernel@vger.kernel.org,
stable@vger.kernel.org, petr.pavlu@suse.com
Subject: Re: [PATCH] livepatch: Fix races between stack check and module load/unload
Date: Wed, 14 Oct 2026 15:35:51 +0200 [thread overview]
Message-ID: <20261014133553.102324-1-parri.andrea@gmail.com> (raw)
In-Reply-To: <alpine.LSU.2.21.2610141410370.16733@pobox.suse.cz>
Hi Miroslav,
On Wed, Oct 14, 2026 at 02:23:50PM +0200, Miroslav Benes wrote:
> Interesting. So to sum it up... you load a live patch which contains a fix
> for a module and while the transition is on, you remove the module and it
> crashes
Yes. Loading such a module during the transition races too:
klp_patch_func() adds to klp_ops while the scheduler walks it.
> (btw, I do not think that those traces are necessary here. More
> btw, it would be nice to have a human readable changelog instead of what
> looks like AI generated description of the patch itself).
Fair enough, I'll rewrite it and drop the traces.
> How did you find the issue? Is it a real problem in production?
An LLM found it while auditing kernel/livepatch/. It is not a
production issue. The reproducer was written by an LLM as well; it is
below, apply it with "git apply -p0". It keeps a transition stuck on
a few kthreads and reloads a patched module in a loop. On v7.3-rc7 it
faults in klp_find_ops() within a few minutes with KCSAN_STRICT=y.
> I wonder whether we could just decline delete_module() in this case and
> return -EBUSY. It should be a rare operation anyway. Alternatively we
> could increase refcounts of all patched modules during a transition and
> decrease them after.
I don't think either covers all the paths. klp_module_going() is also
called when a module's init fails, or when its load is aborted after
klp_module_coming(), and neither -EBUSY nor a module reference prevents
that. Loading a patched module also adds to klp_ops under the walk.
And klp_find_ops() walks the global klp_ops list, so it's the modules of
all patches that matter, not just the transition patch.
> I am not saying we should not do the RCU protection but it feels like a
> can of worms a bit so if there is an easier way...
I could split it: one patch RCU-protecting klp_ops, which fixes the
crash, and a second one for the ordering on module reload, which was found
by inspection and has not been reproduced. Would that be easier to review?
Thanks,
Andrea
diff --git klp-race-repro/README klp-race-repro/README
new file mode 100644
--- /dev/null
+++ klp-race-repro/README
@@ -0,0 +1,108 @@
+Livepatch klp_ops race reproducer
+=================================
+
+This directory contains a reproducer for a use-after-free in
+klp_find_ops(): the function walks the global klp_ops list from the
+scheduler (__klp_sched_try_switch() -> klp_try_switch_task() ->
+klp_check_stack() -> klp_check_stack_func()) without klp_mutex, while
+klp_patch_func()/klp_unpatch_func() add, remove and free klp_ops entries
+under klp_mutex when a to-be-patched module is loaded or unloaded.
+
+The fix ("livepatch: Fix races between stack check and module load/unload")
+RCU-protects the klp_ops list.
+
+Files
+-----
+ klp_race_target_a.c target module A: four kthreads looping in
+ ra_target_a_loop(), which sleeps with
+ TASK_FREEZABLE. The livepatch transition can not
+ switch them, so it stays in progress and the
+ scheduler keeps calling klp_find_ops().
+ klp_race_target_b.c target module B: 32 patchable no-op functions.
+ klp_race_patch.c livepatch patching ra_target_a_loop() (A) and all
+ ra_target_b_func*() (B).
+ run.sh sets up the stuck transition and then insmod/rmmods
+ module B in a loop.
+ Makefile builds the three modules against a kernel tree.
+
+Requirements
+------------
+ CONFIG_LIVEPATCH=y, CONFIG_MODULES=y, CONFIG_MODULE_UNLOAD=y,
+ CONFIG_FUNCTION_TRACER=y, CONFIG_DYNAMIC_FTRACE_WITH_REGS=y,
+ CONFIG_KALLSYMS_ALL=y, CONFIG_KCSAN=y and CONFIG_KCSAN_STRICT=y.
+
+ CONFIG_KCSAN_STRICT is important: the default KCSAN configuration
+ (CONFIG_KCSAN_REPORT_VALUE_CHANGE_ONLY=y) does not report this race.
+
+ Developed on a WSL2 host; vng needs "--force-9p" there (virtiofsd is
+ not available).
+
+Building
+--------
+ KDIR=/path/to/kernel/tree
+
+ cd $KDIR
+ # enable the options above, then:
+ make -j4
+
+ cd $KDIR/repro
+ make -j4 KERNELDIR=$KDIR
+
+Settings
+--------
+ run.sh reads these environment variables:
+
+ ITER maximum number of unload/reload cycles of module B
+ (default 20000)
+ DURATION maximum length of the loop in seconds (default 1800);
+ the loop stops at whichever of ITER and DURATION is
+ reached first, and run.sh prints both numbers
+ OUT if set, run.sh saves the full dmesg to $OUT/dmesg.txt
+ at the end; the grep it prints can miss lockdep and RCU
+ reports. Make the directory writable in the guest,
+ e.g. with vng --rwdir=$OUT
+
+Run unpatched (expect a crash)
+------------------------------
+ Revert/invert the fix first and rebuild the kernel, then:
+
+ vng -r $KDIR --cpus 4 --memory 4G --force-9p --verbose \
+ --exec "ITER=100000 sh $KDIR/repro/run.sh $KDIR/repro"
+
+ Within a few minutes the guest reports a data race between
+ klp_find_ops() and klp_unpatch_func(), and then a general protection
+ fault in klp_find_ops():
+
+ BUG: KCSAN: data-race in klp_find_ops / klp_unpatch_func
+ ...
+ Oops: general protection fault, probably for non-canonical address
+ 0xdead000000000110
+ RIP: 0010:klp_find_ops+0x55/0x90
+ ...
+ __klp_sched_try_switch
+ __schedule
+
+ 0xdead000000000110 is LIST_POISON1 (0xdead000000000100) plus the offset
+ of func_stack in struct klp_ops: the reader walked a list node that
+ klp_unpatch_func() had just list_del()'d.
+
+Run patched (expect no klp report)
+----------------------------------
+ With the fix applied and the kernel rebuilt, run the same command.
+ Unrelated KCSAN races (9p, kernfs, ...) may still be reported, but
+ there must be no klp_* report and no fault. The transition also
+ completes normally once the kthreads are stopped:
+
+ livepatch: 'klp_race_patch': patching complete
+
+Notes
+-----
+ - The transition is kept incomplete on purpose: the kthreads sleep
+ inside a function the livepatch wants to patch, so the consistency
+ model refuses to switch them and klp_resched_enable() keeps the
+ scheduler hook active.
+ - Module B is unloaded/reloaded to race klp_unpatch_func() and
+ klp_patch_func() against the scheduler walk; the 32 functions widen
+ the walk.
+ - Reproduces probabilistically; run it for a few minutes.
+ KCSAN_STRICT usually reports the race well before the GPF.
diff --git klp-race-repro/Makefile klp-race-repro/Makefile
new file mode 100644
--- /dev/null
+++ klp-race-repro/Makefile
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: GPL-2.0
+KERNELDIR ?= /lib/modules/$(shell uname -r)/build
+PWD := $(shell pwd)
+
+obj-m += klp_race_target_a.o
+obj-m += klp_race_target_b.o
+obj-m += klp_race_patch.o
+
+all:
+ $(MAKE) -C $(KERNELDIR) M=$(PWD) modules
+
+clean:
+ $(MAKE) -C $(KERNELDIR) M=$(PWD) clean
diff --git klp-race-repro/run.sh klp-race-repro/run.sh
new file mode 100644
--- /dev/null
+++ klp-race-repro/run.sh
@@ -0,0 +1,56 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-2.0
+#
+# Reproducer for the livepatch klp_ops race.
+#
+# klp_check_stack_func() runs from the scheduler (__klp_sched_try_switch() ->
+# klp_try_switch_task() -> klp_check_stack() -> klp_check_stack_func()) without
+# klp_mutex and calls klp_find_ops(), which walks the global klp_ops list.
+# klp_patch_func()/klp_unpatch_func() add/remove/free those entries under
+# klp_mutex when a to-be-patched module is loaded/unloaded.
+#
+# The target A kthread sleeps freezably inside a patched function, so the
+# transition never completes and the scheduler keeps walking klp_ops. The
+# target B module is then loaded and unloaded in a loop to free/reallocate
+# klp_ops entries concurrently.
+
+set -u
+
+DIR=${1:-$(pwd)}
+ITER=${ITER:-20000}
+DURATION=${DURATION:-1800}
+OUT=${OUT:-}
+
+echo "== livepatch klp_ops race reproducer =="
+
+insmod "$DIR/klp_race_target_a.ko" || exit 1
+insmod "$DIR/klp_race_target_b.ko" || exit 1
+insmod "$DIR/klp_race_patch.ko" || exit 1
+
+# Let the transition get stuck on the target A kthread.
+sleep 1
+echo -n "transition: "
+cat /sys/kernel/livepatch/klp_race_patch/transition
+
+i=0
+start=$(date +%s)
+end=$((start + DURATION))
+while [ "$i" -lt "$ITER" ] && [ "$(date +%s)" -lt "$end" ]; do
+ rmmod klp_race_target_b || break
+ insmod "$DIR/klp_race_target_b.ko" || break
+ i=$((i + 1))
+done
+
+echo "iterations: $i in $(( $(date +%s) - start ))s"
+
+# Unblock and tear down.
+rmmod klp_race_target_a 2>/dev/null
+sleep 1
+echo 0 > /sys/kernel/livepatch/klp_race_patch/enabled 2>/dev/null
+rmmod klp_race_patch 2>/dev/null
+rmmod klp_race_target_b 2>/dev/null
+
+echo "== result =="
+dmesg | grep -iE "KCSAN|BUG:|use-after-free|list_(add|del)|klp_(find_ops|unpatch_func|patch_func)|ra_target" | tail -n 120
+
+[ -n "$OUT" ] && dmesg > "$OUT/dmesg.txt"
diff --git klp-race-repro/klp_race_patch.c klp-race-repro/klp_race_patch.c
new file mode 100644
--- /dev/null
+++ klp-race-repro/klp_race_patch.c
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Livepatch for the klp_ops race reproducer.
+ *
+ * It patches ra_target_a_loop() (target A) and the ra_target_b_func*()
+ * family (target B). Loading it starts a transition which can not complete
+ * because the target A kthread sleeps inside ra_target_a_loop().
+ */
+
+#include <linux/module.h>
+#include <linux/livepatch.h>
+
+static void ra_patch_a(void)
+{
+}
+
+static void ra_patch_b(void)
+{
+}
+
+static struct klp_func funcs_a[] = {
+ { .old_name = "ra_target_a_loop", .new_func = ra_patch_a, },
+ { }
+};
+
+#define RA_PATCH_B_ENTRY(n) \
+ { .old_name = "ra_target_b_func" #n, .new_func = ra_patch_b, },
+
+static struct klp_func funcs_b[] = {
+ RA_PATCH_B_ENTRY(0)
+ RA_PATCH_B_ENTRY(1)
+ RA_PATCH_B_ENTRY(2)
+ RA_PATCH_B_ENTRY(3)
+ RA_PATCH_B_ENTRY(4)
+ RA_PATCH_B_ENTRY(5)
+ RA_PATCH_B_ENTRY(6)
+ RA_PATCH_B_ENTRY(7)
+ RA_PATCH_B_ENTRY(8)
+ RA_PATCH_B_ENTRY(9)
+ RA_PATCH_B_ENTRY(10)
+ RA_PATCH_B_ENTRY(11)
+ RA_PATCH_B_ENTRY(12)
+ RA_PATCH_B_ENTRY(13)
+ RA_PATCH_B_ENTRY(14)
+ RA_PATCH_B_ENTRY(15)
+ RA_PATCH_B_ENTRY(16)
+ RA_PATCH_B_ENTRY(17)
+ RA_PATCH_B_ENTRY(18)
+ RA_PATCH_B_ENTRY(19)
+ RA_PATCH_B_ENTRY(20)
+ RA_PATCH_B_ENTRY(21)
+ RA_PATCH_B_ENTRY(22)
+ RA_PATCH_B_ENTRY(23)
+ RA_PATCH_B_ENTRY(24)
+ RA_PATCH_B_ENTRY(25)
+ RA_PATCH_B_ENTRY(26)
+ RA_PATCH_B_ENTRY(27)
+ RA_PATCH_B_ENTRY(28)
+ RA_PATCH_B_ENTRY(29)
+ RA_PATCH_B_ENTRY(30)
+ RA_PATCH_B_ENTRY(31)
+ { }
+};
+
+static struct klp_object objs[] = {
+ { .name = "klp_race_target_a", .funcs = funcs_a, },
+ { .name = "klp_race_target_b", .funcs = funcs_b, },
+ { }
+};
+
+static struct klp_patch patch = {
+ .mod = THIS_MODULE,
+ .objs = objs,
+};
+
+static int __init ra_patch_init(void)
+{
+ return klp_enable_patch(&patch);
+}
+
+static void __exit ra_patch_exit(void)
+{
+}
+
+module_init(ra_patch_init);
+module_exit(ra_patch_exit);
+MODULE_LICENSE("GPL");
+MODULE_INFO(livepatch, "Y");
+MODULE_DESCRIPTION("livepatch klp_ops race reproducer: patch");
diff --git klp-race-repro/klp_race_target_a.c klp-race-repro/klp_race_target_a.c
new file mode 100644
--- /dev/null
+++ klp-race-repro/klp_race_target_a.c
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Target module A for the livepatch klp_ops race reproducer.
+ *
+ * A kthread repeatedly calls ra_target_a_loop(), which goes to sleep with
+ * TASK_FREEZABLE. While the livepatch transition is in progress the task
+ * can not be switched (it sleeps inside the to-be-patched function), so the
+ * transition never completes and __klp_sched_try_switch() keeps walking the
+ * global klp_ops list from the scheduler.
+ */
+
+#include <linux/module.h>
+#include <linux/kthread.h>
+#include <linux/sched.h>
+#include <linux/delay.h>
+
+void ra_target_a_loop(void);
+
+noinline void ra_target_a_loop(void)
+{
+ set_current_state(TASK_INTERRUPTIBLE | TASK_FREEZABLE);
+ if (!kthread_should_stop())
+ schedule_timeout(msecs_to_jiffies(1));
+ __set_current_state(TASK_RUNNING);
+}
+
+#define RA_NR_THREADS 4
+
+static struct task_struct *ra_threads[RA_NR_THREADS];
+
+static int ra_thread_fn(void *unused)
+{
+ while (!kthread_should_stop())
+ ra_target_a_loop();
+
+ return 0;
+}
+
+static int __init ra_a_init(void)
+{
+ int i;
+
+ for (i = 0; i < RA_NR_THREADS; i++) {
+ int err;
+
+ ra_threads[i] = kthread_run(ra_thread_fn, NULL, "ra_target_a/%d", i);
+ if (IS_ERR(ra_threads[i])) {
+ err = PTR_ERR(ra_threads[i]);
+ while (--i >= 0)
+ kthread_stop(ra_threads[i]);
+ return err;
+ }
+ }
+
+ return 0;
+}
+
+static void __exit ra_a_exit(void)
+{
+ int i;
+
+ for (i = 0; i < RA_NR_THREADS; i++)
+ kthread_stop(ra_threads[i]);
+}
+
+module_init(ra_a_init);
+module_exit(ra_a_exit);
+MODULE_LICENSE("GPL");
+MODULE_DESCRIPTION("livepatch klp_ops race reproducer: target A");
diff --git klp-race-repro/klp_race_target_b.c klp-race-repro/klp_race_target_b.c
new file mode 100644
--- /dev/null
+++ klp-race-repro/klp_race_target_b.c
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Target module B for the livepatch klp_ops race reproducer.
+ *
+ * It provides a set of functions which are patched by klp_race_patch. The
+ * module is unloaded and loaded in a loop while the livepatch transition is
+ * stuck on target A, so that klp_unpatch_func()/klp_patch_func() free and
+ * allocate klp_ops entries while the scheduler walks the list.
+ */
+
+#include <linux/module.h>
+#include <linux/compiler.h>
+
+#define RA_TARGET_B_FUNCS(X) \
+ X(0) X(1) X(2) X(3) X(4) X(5) X(6) X(7) \
+ X(8) X(9) X(10) X(11) X(12) X(13) X(14) X(15) \
+ X(16) X(17) X(18) X(19) X(20) X(21) X(22) X(23) \
+ X(24) X(25) X(26) X(27) X(28) X(29) X(30) X(31)
+
+#define RA_PROTO_B(n) void ra_target_b_func##n(void);
+RA_TARGET_B_FUNCS(RA_PROTO_B)
+
+#define RA_DEF_B(n) noinline void ra_target_b_func##n(void) { barrier(); }
+RA_TARGET_B_FUNCS(RA_DEF_B)
+
+static int __init ra_b_init(void)
+{
+ return 0;
+}
+
+static void __exit ra_b_exit(void)
+{
+}
+
+module_init(ra_b_init);
+module_exit(ra_b_exit);
+MODULE_LICENSE("GPL");
+MODULE_DESCRIPTION("livepatch klp_ops race reproducer: target B");
next prev parent reply other threads:[~2026-10-14 13:36 UTC|newest]
Thread overview: 6+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-12 14:05 [PATCH] livepatch: Fix races between stack check and module load/unload Andrea Parri
2026-10-12 14:49 ` Peter Zijlstra
2026-10-12 15:07 ` Andrea Parri
2026-10-14 12:23 ` Miroslav Benes
2026-10-14 13:35 ` Andrea Parri [this message]
2026-10-14 14:30 ` Petr Mladek
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261014133553.102324-1-parri.andrea@gmail.com \
--to=parri.andrea@gmail.com \
--cc=jikos@kernel.org \
--cc=joe.lawrence@redhat.com \
--cc=jpoimboe@kernel.org \
--cc=linux-kernel@vger.kernel.org \
--cc=live-patching@vger.kernel.org \
--cc=mbenes@suse.cz \
--cc=peterz@infradead.org \
--cc=petr.pavlu@suse.com \
--cc=pmladek@suse.com \
--cc=song@kernel.org \
--cc=stable@vger.kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox