Skip to contentSkip to conversation

arm64, riscv: release shadow stacks on failed fork

PATCH Superseded Iván Ezequiel Rodriguez 2 participants 13 messages Oct 16 – Oct 28

Subsystems (4)

v1 of 3 Superseded Latest is v3 →

lore
Share & tools
lore
#0 lore raw

cover arm64, riscv: release shadow stacks on failed fork

This is not a port of the x86 TLS fix i sent yesterday. That leak was inside copy_thread() when set_new_tls() failed, so copy_process() skipped exit_thread(). Here copy_thread() returns 0 and a later copy_process() step fails. The concrete hole is clone3() with CLONE_VM|CLONE_PIDFD and an unwritable pidfd pointer: put_user() returns -EFAULT after the architecture has mapped a shadow stack. exit_thread() does run, but arm64 and riscv did not implement it, so that mapping stays in the parent's mm.

Normal exit already frees via deactivate_mm(). The failed-fork path never reaches that. The free helpers already refuse a foreign mm and a cleared base, so wiring them to exit_thread() does not unmap the parent on !CLONE_VM or double-free on a normal exit. A successful clone3() child with the same flags exits through deactivate_mm() and then exit_thread(); that is the usual teardown order this series also enables.

riscv's shstk_release() comment already described this exit_thread() call. The architecture never selected HAVE_EXIT_THREAD, so the documented path was not connected.

Do not free on a copy_thread() allocation error: the child still aliases the parent's base, and copy_process() skips exit_thread() when copy_thread() fails. kernel_thread() uses CLONE_VM, so a failed copy_process() still shares the caller's mm and the mm check in the free helpers does not save us; the kthread path clears inherited tracking for that reason.

The selftests assume /proc is mounted. A clone3() with a valid pidfd must succeed and write a pidfd before the rejected calls count. ENOSYS, EINVAL, and EPERM from that clone skip; any other error fails the test.

Sixteen clone3() calls with CLONE_VM|CLONE_PIDFD and an unwritable pidfd pointer leaked 16 shadow-stack VMAs without the fix. With it, the count stayed unchanged and call/return on the parent still used the original mapping.

Base: 2f2f7eeef8b0 ("Merge tag 'soc-fixes-7.3-3' of
git://git.kernel.org/pub/scm/linux/kernel/git/soc/soc").

arm64: QEMU 11.1.2 -machine virt -cpu max, CONFIG_ARM64_GCS, one vCPU. Unpatched ss_vmas=1->17 (+512 kB). Patched ss_vmas=1->1.

riscv: QEMU 11.1.2 -machine virt
-cpu rv64,zimop=on,zcmop=on,zicfiss=on, CONFIG_RISCV_USER_CFI, one vCPU. Unpatched ss_vmas=1->17 (+1024 kB). Patched ss_vmas=1->1.

Assisted-by LLM

Iván Ezequiel Rodriguez (4):
arm64/gcs: release GCS on failed fork
riscv/shstk: release shadow stack on failed fork selftests/arm64: GCS must not leak on a failed clone3 selftests/riscv: shadow stack must not leak on a failed clone3

 arch/arm64/Kconfig                            |   1 +
 arch/arm64/kernel/process.c                   |  21 ++
 arch/riscv/Kconfig                            |   1 +
 arch/riscv/kernel/process.c                   |  18 +
 tools/testing/selftests/arm64/gcs/Makefile    |   3 +-
 .../selftests/arm64/gcs/gcs-clone-pidfd.c     | 355 ++++++++++++++++++
 tools/testing/selftests/riscv/cfi/Makefile    |   5 +-
 .../selftests/riscv/cfi/shstk-clone-pidfd.c   | 317 ++++++++++++++++
 8 files changed, 719 insertions(+), 2 deletions(-)
 create mode 100644 tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c
 create mode 100644 tools/testing/selftests/riscv/cfi/shstk-clone-pidfd.c
Signature · 1 line
2.43.0
#1 lore raw

PATCH 1/4 arm64/gcs: release GCS on failed fork

copy_thread() maps a GCS for a CLONE_VM child and can still return
0. A later copy_process() failure then goes through bad_fork_cleanup_thread, which calls exit_thread(). The hole this series hits is clone3(CLONE_VM|CLONE_PIDFD) when put_user() of the pidfd fails after that mapping. arm64 did not implement exit_thread(). deactivate_mm() is what normally calls gcs_free(), and it does not run on this path. Each rejected clone leaves one GCS VMA in the parent's mm until the mm dies.

Select HAVE_EXIT_THREAD and free the child's GCS from exit_thread(). gcs_free() already ignores a cleared base and a different mm, so the usual exit path (deactivate_mm() then this) is a no-op the second time. copy_thread_gcs() zeros the inherited tracking before a user-thread allocation, so an allocation error still skips exit_thread() with nothing to unmap.

A kthread copies the caller's GCS fields via arch_dup_task_struct(). kernel_thread() uses CLONE_VM, so during a failed copy_process() the child still shares the caller's mm and gcs_free()'s mm check does not save us. Clear the inherited tracking as part of kthread setup.

Tested with CONFIG_ARM64_GCS on QEMU 11.1.2 -machine virt -cpu max. Sixteen clone3() calls with CLONE_VM|CLONE_PIDFD and an unwritable pidfd pointer leaked 16 GCS VMAs without the fix. With it, the VMA count was unchanged and call/return on the parent still used the original GCS. A successful clone3 child exit with the same flags runs deactivate_mm() and then exit_thread().

Fixes 506496bcbb42 ("arm64/gcs: Ensure that new threads have a GCS")
Cc Mark Brown <[email protected]>
Assisted-by LLM
---
 arch/arm64/Kconfig          |  1 +
 arch/arm64/kernel/process.c | 21 +++++++++++++++++++++
 2 files changed, 22 insertions(+)
arch/arm64/Kconfig +1 -0
index cab741a695f5..c7fc79409f98 100644
@@ -177,6 +177,7 @@ config ARM64
 	select HAVE_ARM_SMCCC
 	select HAVE_ASM_MODVERSIONS
 	select HAVE_EBPF_JIT
+	select HAVE_EXIT_THREAD
 	select HAVE_C_RECORDMCOUNT
 	select HAVE_CMPXCHG_DOUBLE
 	select HAVE_CMPXCHG_LOCAL
arch/arm64/kernel/process.c +21 -0
index 581f80e9b9b7..eb5314b4b42b 100644
@@ -341,6 +341,16 @@ void flush_thread(void)
 	flush_gcs();
 }
 
+void exit_thread(struct task_struct *tsk)
+{
+	/*
+	 * copy_process() calls this after copy_thread() returns 0.
+	 * deactivate_mm() does not run on that failure path.
+	 * gcs_free() is a no-op once gcs_base is cleared.
+	 */
+	gcs_free(tsk);
+}
+
 void arch_release_task_struct(struct task_struct *tsk)
 {
 	fpsimd_release_task(tsk);
@@ -503,6 +513,17 @@ int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
 
 		p->thread.cpu_context.x19 = (unsigned long)args->fn;
 		p->thread.cpu_context.x20 = (unsigned long)args->fn_arg;
+#ifdef CONFIG_ARM64_GCS
+		/*
+		 * kernel_thread() uses CLONE_VM, so a failed
+		 * copy_process() still shares the caller's mm.
+		 * Inherited GCS tracking would then look like a
+		 * child mapping to gcs_free().
+		 */
+		p->thread.gcs_base = 0;
+		p->thread.gcs_size = 0;
+		p->thread.gcspr_el0 = 0;
+#endif
 
 		if (system_supports_poe())
 			p->thread.por_el0 = POR_EL0_INIT;
Signature · 1 line
2.43.0
#3 lore raw

PATCH 2/4 riscv/shstk: release shadow stack on failed fork

In reply to Iván Ezequiel Rodriguez

copy_thread() maps a shadow stack for a CLONE_VM child and can still return 0. A later copy_process() failure, including clone3() CLONE_PIDFD when put_user() fails, goes through bad_fork_cleanup_thread and exit_thread(). riscv did not select HAVE_EXIT_THREAD, so that free was a no-op. shstk_release() already documents this call; deactivate_mm() is the only path that actually reached it.

Select HAVE_EXIT_THREAD and call shstk_release() from exit_thread(). The helper already refuses a different mm and a cleared base, so CLONE_VFORK, !CLONE_VM, and a second call after deactivate_mm() stay no-ops.

Do not free when shstk_alloc_thread_stack() fails. That error returns from copy_thread() with the inherited parent base still in place, and copy_process() skips exit_thread() on that path.

A kthread copies the caller's tracking via arch_dup_task_struct(). kernel_thread() uses CLONE_VM, so during a failed copy_process() the child still shares the caller's mm and shstk_release()'s mm check does not save us. Clear the inherited tracking as part of kthread setup.

Fixes fd44a4a85516 ("riscv/shstk: If needed allocate a new shadow stack on clone")
Cc Deepak Gupta <[email protected]>
Assisted-by LLM
---
 arch/riscv/Kconfig          |  1 +
 arch/riscv/kernel/process.c | 18 ++++++++++++++++++
 2 files changed, 19 insertions(+)
arch/riscv/Kconfig +1 -0
index 5874ca30f4f6..7635f8cb0397 100644
@@ -169,6 +169,7 @@ config RISCV
 	select HAVE_FUNCTION_GRAPH_FREGS
 	select HAVE_FUNCTION_TRACER if HAVE_DYNAMIC_FTRACE
 	select HAVE_EBPF_JIT if MMU
+	select HAVE_EXIT_THREAD
 	select HAVE_GENERIC_TIF_BITS
 	select HAVE_GUP_FAST if MMU
 	select HAVE_FUNCTION_ARG_ACCESS_API
arch/riscv/kernel/process.c +18 -0
index 7cc5a6a5c020..def1f520d9ef 100644
@@ -12,6 +12,7 @@
 #include <linux/kernel.h>
 #include <linux/sched.h>
 #include <linux/sched/debug.h>
+#include <linux/sched/task.h>
 #include <linux/sched/task_stack.h>
 #include <linux/tick.h>
 #include <linux/ptrace.h>
@@ -213,6 +214,16 @@ void arch_release_task_struct(struct task_struct *tsk)
 		riscv_v_thread_free(tsk);
 }
 
+void exit_thread(struct task_struct *tsk)
+{
+	/*
+	 * shstk_release() documents this call for a failed CLONE_VM
+	 * clone. copy_process() reaches it only after copy_thread()
+	 * returns 0; deactivate_mm() does not run on that path.
+	 */
+	shstk_release(tsk);
+}
+
 int arch_dup_task_struct(struct task_struct *dst, struct task_struct *src)
 {
 	fstate_save(src, task_pt_regs(src));
@@ -261,6 +272,13 @@ int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
 		p->thread.s[0] = (unsigned long)args->fn;
 		p->thread.s[1] = (unsigned long)args->fn_arg;
 		p->thread.ra = (unsigned long)ret_from_fork_kernel_asm;
+		/*
+		 * kernel_thread() uses CLONE_VM, so a failed
+		 * copy_process() still shares the caller's mm.
+		 * Inherited tracking would then look like a child
+		 * mapping to shstk_release().
+		 */
+		set_shstk_base(p, 0, 0);
 	} else {
 		/* allocate new shadow stack if needed. In case of CLONE_VM we have to */
 		ssp = shstk_alloc_thread_stack(p, args);
Signature · 1 line
2.43.0
#4 lore raw

PATCH 3/4 selftests/arm64: GCS must not leak on a failed clone3

In reply to Iván Ezequiel Rodriguez

clone3(CLONE_VM|CLONE_PIDFD) with an unwritable pidfd pointer fails in put_user() after copy_thread() has mapped a GCS. copy_process() then calls exit_thread(). Without HAVE_EXIT_THREAD that free is a no-op and the VMA stays in the parent's mm.

Enable GCS, reject 16 such clones, and require the shadow-stack VMA count to stay unchanged. The original mapping must remain, and a call/return on that stack must succeed, so the cleanup cannot be unmapping the parent's GCS.

A clone3 with the same flags and a valid pidfd has to succeed first and write a pidfd. That child exits normally, so deactivate_mm() and exit_thread() both run. ENOSYS, EINVAL, and EPERM skip; any other error from that clone, or a success that leaves pidfd unset, fails the test. The rejected child's empty GCS is left with a raw exit syscall.

Skip when HWCAP has no GCS, or when the kernel returns EOPNOTSUPP, EINVAL, or EBUSY. Any other PR_SET_SHADOW_STACK_STATUS error fails the test. The test expects /proc already mounted, as in a normal kselftest run.

The prctl wrapper matches gcs-locking.c. The recursive call goes through an indirect call; a visible recursive body is optimized into a closed form and does not execute a BL/RET.

---
 tools/testing/selftests/arm64/gcs/Makefile    |   3 +-
 .../selftests/arm64/gcs/gcs-clone-pidfd.c     | 355 ++++++++++++++++++
 2 files changed, 357 insertions(+), 1 deletion(-)
 create mode 100644 tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c
tools/testing/selftests/arm64/gcs/Makefile +2 -1
index 1fbbf0ca1f02..f1365eea31c9 100644
@@ -6,7 +6,8 @@
 # nolibc.
 #
 
-TEST_GEN_PROGS := basic-gcs libc-gcs gcs-locking gcs-stress gcspushm gcsstr
+TEST_GEN_PROGS := basic-gcs libc-gcs gcs-locking gcs-stress gcspushm gcsstr \
+		  gcs-clone-pidfd
 TEST_GEN_PROGS_EXTENDED := gcs-stress-thread
 
 LDLIBS+=-lpthread
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +355 -0

355 lines not on this page · raw message

Signature · 1 line
2.43.0
#6 lore raw

PATCH 4/4 selftests/riscv: shadow stack must not leak on a failed clone3

In reply to Iván Ezequiel Rodriguez

clone3(CLONE_VM|CLONE_PIDFD) with an unwritable pidfd pointer fails in put_user() after copy_thread() has mapped a shadow stack. copy_process() then calls exit_thread(). Without HAVE_EXIT_THREAD that free is a no-op and the VMA stays in the parent's mm.

Enable user shadow stack, reject 16 such clones, and require the shadow-stack VMA count to stay unchanged. The original mapping must remain, and a call/return on that stack must succeed, so the cleanup cannot be unmapping the parent's shadow stack.

A clone3 with the same flags and a valid pidfd has to succeed first and write a pidfd. That child exits normally, so deactivate_mm() and exit_thread() both run. ENOSYS, EINVAL, and EPERM skip; any other error from that clone, or a success that leaves pidfd unset, fails the test. The rejected child's empty shadow stack is left with a raw exit syscall.

Skip when the kernel returns EOPNOTSUPP or EINVAL, or when the feature is locked. The test expects /proc already mounted, as in a normal kselftest run. The toolchain CFLAGS already request zicfiss/zicfilp; the Makefile skips this binary when the compiler cannot target them.

---
 tools/testing/selftests/riscv/cfi/Makefile    |   5 +-
 .../selftests/riscv/cfi/shstk-clone-pidfd.c   | 317 ++++++++++++++++++
 2 files changed, 321 insertions(+), 1 deletion(-)
 create mode 100644 tools/testing/selftests/riscv/cfi/shstk-clone-pidfd.c
tools/testing/selftests/riscv/cfi/Makefile +4 -1
index 418b4b5325a5..dbecb1fd3b82 100644
@@ -13,10 +13,13 @@ endif
 
 
 ifeq ($(shell $(CC) $(CFLAGS) -nostdlib -xc /dev/null -o /dev/null > /dev/null 2>&1; echo $$?),0)
-TEST_GEN_PROGS := cfitests
+TEST_GEN_PROGS := cfitests shstk-clone-pidfd
 
 $(OUTPUT)/cfitests: cfitests.c shadowstack.c
 	$(CC) -o $@ $(CFLAGS) $(LDFLAGS) $^
+
+$(OUTPUT)/shstk-clone-pidfd: shstk-clone-pidfd.c
+	$(CC) -o $@ $(CFLAGS) $(LDFLAGS) $^
 else
 
 $(shell echo "Toolchain doesn't support CFI, skipping CFI kselftest." >&2)
tools/testing/selftests/riscv/cfi/shstk-clone-pidfd.c new file +317 -0

317 lines not on this page · raw message

Signature · 1 line
2.43.0
#2 lore raw

Re: [PATCH 1/4] arm64/gcs: release GCS on failed fork

In reply to Iván Ezequiel Rodriguez
Quoted 6 lines · Iván Ezequiel Rodriguez

Select HAVE_EXIT_THREAD and free the child's GCS from exit_thread(). gcs_free() already ignores a cleared base and a different mm, so the usual exit path (deactivate_mm() then this) is a no-op the second time. copy_thread_gcs() zeros the inherited tracking before a user-thread allocation, so an allocation error still skips exit_thread() with nothing to unmap.

This makes sense and mirrors the handling on x86 which had an existing exit_thread(). There was a lot of pain finding the right places to hook in here, there's far too many routes through this code :/ .

Quoted 5 lines

A kthread copies the caller's GCS fields via arch_dup_task_struct(). kernel_thread() uses CLONE_VM, so during a failed copy_process() the child still shares the caller's mm and gcs_free()'s mm check does not save us. Clear the inherited tracking as part of kthread setup.

I'm not spotting an equivalent path on x86, it's entirely possible I'm missing something here but if I'm not then I expect they need the same fix? They use a similar memcpy() for the arch data and I'm not seeing a reset anywhere.

Quoted 5 lines
arch/arm64/kernel/process.c +1 -0
@@ -503,6 +513,17 @@ int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
 
 		p->thread.cpu_context.x19 = (unsigned long)args->fn;
 		p->thread.cpu_context.x20 = (unsigned long)args->fn_arg;
+#ifdef CONFIG_ARM64_GCS

arch/arm64/kernel/process.c int copy_thread(struct task_struct *p, const struct kernel_clone_args *args) · line 526

Quoted 10 lines
arch/arm64/kernel/process.c +10 -0
+		/*
+		 * kernel_thread() uses CLONE_VM, so a failed
+		 * copy_process() still shares the caller's mm.
+		 * Inherited GCS tracking would then look like a
+		 * child mapping to gcs_free().
+		 */
+		p->thread.gcs_base = 0;
+		p->thread.gcs_size = 0;
+		p->thread.gcspr_el0 = 0;
+#endif

I think it'd be a bit more idiomatic to have a function to reset the GCS state that gets an empty stub with !CONFIG_GCS

#5 lore raw

Re: [PATCH 3/4] selftests/arm64: GCS must not leak on a failed clone3

In reply to Iván Ezequiel Rodriguez
Quoted 6 lines · Iván Ezequiel Rodriguez

clone3(CLONE_VM|CLONE_PIDFD) with an unwritable pidfd pointer fails in put_user() after copy_thread() has mapped a GCS. copy_process() then calls exit_thread(). Without HAVE_EXIT_THREAD that free is a no-op and the VMA stays in the parent's mm.

Assisted-by LLM

This is pretty hard to follow, there's a lot of open coding, duplication and strange structures or tests. It makes it hard to follow what's actually being tested and why which is going to be a maintainability issue.

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 44

Quoted 2 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +2 -0
+/* Same value as KSFT_SKIP in kselftest.h. */
+#define KSFT_SKIP		4

Why are we not just including kselftest.h? It doesn't have the fun interactions with libc headers that some of the other things have. If we are going to cut'n'paste values why only KSFT_SKIP and not any of the other exit codes we use?

Quoted 10 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +10 -0
+/* Same wrapper as gcs-locking.c: extra prctl args must be zero. */
+#define my_syscall2(num, arg1, arg2)					\
+({									\
+	register long _num  __asm__ ("x8") = (num);			\
+	register long _arg1 __asm__ ("x0") = (long)(arg1);		\
+	register long _arg2 __asm__ ("x1") = (long)(arg2);		\
+	register long _arg3 __asm__ ("x2") = 0;				\
+	register long _arg4 __asm__ ("x3") = 0;				\
+	register long _arg5 __asm__ ("x4") = 0;				\
+									\

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 68

Quoted 10 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +10 -0
+	__asm__ volatile (						\
+		"svc #0\n"						\
+		: "=r"(_arg1)						\
+		: "r"(_arg1), "r"(_arg2),				\
+		  "r"(_arg3), "r"(_arg4),				\
+		  "r"(_arg5), "r"(_num)					\
+		: "memory", "cc"					\
+	);								\
+	_arg1;								\
+})

Factor this out.

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 72

Quoted 3 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +3 -0
+static void on_fault(int sig)
+{
+	static const char msg[] = "[FAIL]\tparent guarded control stack faulted\n";

This isn't KTAP format.

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 133

Quoted 6 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +6 -0
+		/*
+		 * An empty GCS leaves GCSPR at the exclusive end. If
+		 * the next VMA is contiguous that address is also the
+		 * start of the next range; prefer the mapping that
+		 * ends here.
+		 */

I'm not clear what you mean by exclusive end here?

Quoted 4 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +4 -0
+static int skip_enable(long rc)
+{
+	if (rc == -EOPNOTSUPP) {
+		printf("[SKIP]\tkernel has no GCS\n");

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 217

Quoted 10 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +10 -0
+	} else if (rc == -EINVAL) {
+		printf("[SKIP]\tkernel built without GCS\n");
+	} else if (rc == -EBUSY) {
+		printf("[SKIP]\tGCS is locked\n");
+	} else {
+		printf("[FAIL]\tPR_SET_SHADOW_STACK_STATUS returned %ld\n", rc);
+		return 1;
+	}
+	return KSFT_SKIP;
+}

This feels rather contorted, and ends up returning a raw 1 from main() rather than a KSFT_ exit code.

Quoted 4 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +4 -0
+int main(void)
+{
+	struct sigaction sa;
+	struct ss_span parent;

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 232

Quoted 10 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +10 -0
+	void *stack;
+	void *bad_pidfd;
+	unsigned long gcspr, gcspr_mid;
+	unsigned long gcs_mode;
+	long rc;
+	int pidfd;
+	int vmas0, kb0, vmas1, kb1;
+	int i, kept, d, cret;
+
+	setbuf(stdout, NULL);

There's also setvbuf(). Using kselftest.h and it's helpers would probably be better, it takes care of disabling buffering. I can't see any reason to avoid that.

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 268

Quoted 5 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +5 -0
+	gcspr = (unsigned long)get_gcspr();
+	if (!gcspr) {
+		printf("[FAIL]\tenable left GCSPR_EL0 at 0\n");
+		_exit(1);
+	}

Why are we using _exit(), and more of these raw 1s?

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 304

Quoted 6 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +6 -0
+	gcspr = (unsigned long)get_gcspr();
+	ss_scan(&vmas0, &kb0, gcspr, &parent, NULL, NULL);
+	if (!parent.start) {
+		printf("[FAIL]\tGCSPR %lx matches no shadow-stack VMA\n", gcspr);
+		_exit(1);
+	}

Why is this something we're validating, and how do we expect to be able to do function calls if it is the case?

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 339

Quoted 10 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +10 -0
+	if (vmas1 != vmas0 || kb1 != kb0) {
+		printf("[FAIL]\tleaked %d shadow-stack VMA(s), %d kB\n",
+		       vmas1 - vmas0, kb1 - kb0);
+		_exit(1);
+	}
+	if (!kept) {
+		printf("[FAIL]\tparent GCS %lx-%lx was unmapped\n",
+		       parent.start, parent.end);
+		_exit(2);
+	}

Why are some failures exiting with a different exit code?

tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c line 347

Quoted 4 lines
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +4 -0
+	if (d != 17) {
+		printf("[FAIL]\tparent call/return returned %d, want 17\n", d);
+		_exit(2);
+	}

...because?

#7 lore raw

v2 cover arm64, riscv: release shadow stacks on failed fork

In reply to Iván Ezequiel Rodriguez

This is not a port of the x86 TLS fix. That leak was inside copy_thread() when set_new_tls() failed, so copy_process() skipped exit_thread(). Here copy_thread() returns 0 and a later copy_process() step fails. The concrete hole is clone3() with CLONE_VM|CLONE_PIDFD and an unwritable pidfd pointer: put_user() returns -EFAULT after the architecture has mapped a shadow stack. exit_thread() does run, but arm64 and riscv did not implement it, so that mapping stays in the parent's mm.

Normal exit already frees via deactivate_mm(). The failed-fork path never reaches that. The free helpers already refuse a foreign mm and a cleared base, so wiring them to exit_thread() does not unmap the parent on !CLONE_VM or double-free on a normal exit. A successful clone3() child with the same flags exits through deactivate_mm() and then exit_thread(); that is the usual teardown order this series also enables.

riscv's shstk_release() comment already described this exit_thread() call. The architecture never selected HAVE_EXIT_THREAD, so the documented path was not connected.

Do not add cleanup for a copy_thread() allocation error:
copy_process() skips exit_thread() when copy_thread() fails. arm64 clears inherited allocation tracking before attempting the allocation; on riscv a failed allocation can still leave the inherited parent base in place. Tasks created through the args->fn path do not allocate a user shadow stack in copy_thread(); with CLONE_VM a later failed copy_process() still shares the caller's mm, so that path clears inherited tracking before return.

x86 does not need the same args->fn clear: copy_thread() runs shstk_alloc_thread_stack() before both the PF_KTHREAD and later args->fn return paths, so CLONE_VM replaces the inherited tracking first.

The selftests assume /proc is mounted. They use kselftest.h / KTAP. Feature and clone3 availability skips run before ksft_set_plan(). The plan is three tests: a valid clone3 writes a pidfd, sixteen bad-pidfd clones return -EFAULT, and the shadow-stack VMA count is unchanged.

Based on commit f47d223627f8 ("Merge tag 'vfs-7.4-rc1.sync_close' of
git://git.kernel.org/pub/scm/linux/kernel/git/vfs/vfs").

arm64: QEMU 11.1.2 -machine virt -cpu max, CONFIG_ARM64_GCS, one vCPU. Unpatched VMA count 1->17. Patched 1->1, 3/3 KTAP pass.

riscv: QEMU 11.1.2 -machine virt
-cpu rv64,zimop=on,zcmop=on,zicfiss=on, CONFIG_RISCV_USER_CFI, one vCPU. Unpatched VMA count 1->17. Patched 1->1, 3/3 KTAP pass.

Changes since v1:
- arm64: introduce gcs_reset_thread() (empty stub without CONFIG_ARM64_GCS). Document why x86 does not need the same args->fn clear. Comment the property of the args->fn path, not only kernel_thread().
- riscv: same args->fn wording; clarify that normal exit reaches shstk_release() through deactivate_mm() while failed-fork did not.
- selftests: rewrite on kselftest.h / KTAP. Drop open-coded [OK]/[FAIL], exclusive-end VMA matching, ss_kb, depth(16), and parent GCSPR/SSP ping checks. Count only VmFlags lines with " ss". Skip before ksft_set_plan(). Factor my_syscall2 into gcs-util.h.

Assisted-by LLM

Iván Ezequiel Rodriguez (4):
arm64/gcs: release GCS on failed fork
riscv/shstk: release shadow stack on failed fork selftests/arm64: GCS must not leak on a failed clone3 selftests/riscv: shadow stack must not leak on a failed clone3

 arch/arm64/Kconfig                            |   1 +
 arch/arm64/include/asm/gcs.h                  |   8 +
 arch/arm64/kernel/process.c                   |  19 ++
 arch/riscv/Kconfig                            |   1 +
 arch/riscv/kernel/process.c                   |  20 ++
 tools/testing/selftests/arm64/gcs/Makefile    |   3 +-
 .../selftests/arm64/gcs/gcs-clone-pidfd.c     | 168 +++++++++++++++++
 .../testing/selftests/arm64/gcs/gcs-locking.c |  20 --
 tools/testing/selftests/arm64/gcs/gcs-util.h  |  24 +++
 tools/testing/selftests/arm64/gcs/libc-gcs.c  |  20 --
 tools/testing/selftests/riscv/cfi/Makefile    |   5 +-
 .../selftests/riscv/cfi/shstk-clone-pidfd.c   | 173 ++++++++++++++++++
 12 files changed, 420 insertions(+), 42 deletions(-)
 create mode 100644 tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c
 create mode 100644 tools/testing/selftests/riscv/cfi/shstk-clone-pidfd.c

base-commit: f47d223627f80cb7a1eb14c585fe6cb35de5e984

Signature · 1 line
2.43.0
#8 lore raw

PATCH v2 1/4 arm64/gcs: release GCS on failed fork

copy_thread() maps a GCS for a CLONE_VM child and can still return
0. A later copy_process() failure then goes through bad_fork_cleanup_thread, which calls exit_thread(). The hole this series hits is clone3(CLONE_VM|CLONE_PIDFD) when put_user() of the pidfd fails after that mapping. arm64 did not implement exit_thread(). deactivate_mm() is what normally calls gcs_free(), and it does not run on this path. Each rejected clone leaves one GCS VMA in the parent's mm until the mm dies.

Select HAVE_EXIT_THREAD and free the child's GCS from exit_thread(). gcs_free() already ignores a cleared base and a different mm, so the usual exit path (deactivate_mm() then this) is a no-op the second time. copy_thread_gcs() zeros the inherited tracking before a user-thread allocation, so an allocation error still skips exit_thread() with nothing to unmap.

Tasks created through the args->fn path (kernel_thread(), create_io_thread(), and similar) do not allocate a user GCS in copy_thread(). They still inherit GCS tracking via arch_dup_task_struct(), and with CLONE_VM a later failed copy_process() still shares the caller's mm, so gcs_free()'s mm check does not save us. Clear that tracking with gcs_reset_thread(), an empty stub when !CONFIG_ARM64_GCS.

x86 does not need the same args->fn clear: copy_thread() runs shstk_alloc_thread_stack() before both the PF_KTHREAD and later args->fn return paths, so CLONE_VM replaces the inherited tracking first.

Tested with CONFIG_ARM64_GCS on QEMU 11.1.2 -machine virt -cpu max. Sixteen clone3() calls with CLONE_VM|CLONE_PIDFD and an unwritable pidfd pointer leaked 16 GCS VMAs without the fix. With it, the VMA count was unchanged.

Fixes 506496bcbb42 ("arm64/gcs: Ensure that new threads have a GCS")
Cc Mark Brown <[email protected]>
Assisted-by LLM
---
 arch/arm64/Kconfig           |  1 +
 arch/arm64/include/asm/gcs.h |  8 ++++++++
 arch/arm64/kernel/process.c  | 19 +++++++++++++++++++
 3 files changed, 28 insertions(+)
arch/arm64/Kconfig +1 -0
index cab741a695f5..c7fc79409f98 100644
@@ -177,6 +177,7 @@ config ARM64
 	select HAVE_ARM_SMCCC
 	select HAVE_ASM_MODVERSIONS
 	select HAVE_EBPF_JIT
+	select HAVE_EXIT_THREAD
 	select HAVE_C_RECORDMCOUNT
 	select HAVE_CMPXCHG_DOUBLE
 	select HAVE_CMPXCHG_LOCAL
arch/arm64/include/asm/gcs.h +8 -0
index 8fa0707069e8..f47aaad30d7d 100644
@@ -67,6 +67,13 @@ void gcs_preserve_current_state(void);
 unsigned long gcs_alloc_thread_stack(struct task_struct *tsk,
 				     const struct kernel_clone_args *args);
 
+static inline void gcs_reset_thread(struct task_struct *task)
+{
+	task->thread.gcs_base = 0;
+	task->thread.gcs_size = 0;
+	task->thread.gcspr_el0 = 0;
+}
+
 static inline int gcs_check_locked(struct task_struct *task,
 				   unsigned long new_val)
 {
@@ -167,6 +174,7 @@ static inline bool task_gcs_el0_enabled(struct task_struct *task)
 static inline void gcs_set_el0_mode(struct task_struct *task) { }
 static inline void gcs_free(struct task_struct *task) { }
 static inline void gcs_preserve_current_state(void) { }
+static inline void gcs_reset_thread(struct task_struct *task) { }
 static inline void put_user_gcs(unsigned long val, unsigned long __user *addr,
 				int *err) { }
 static inline void push_user_gcs(unsigned long val, int *err) { }
arch/arm64/kernel/process.c +19 -0
index 581f80e9b9b7..83d5a07a238f 100644
@@ -341,6 +341,16 @@ void flush_thread(void)
 	flush_gcs();
 }
 
+void exit_thread(struct task_struct *tsk)
+{
+	/*
+	 * copy_process() calls this after copy_thread() returns 0.
+	 * deactivate_mm() does not run on that failure path.
+	 * gcs_free() is a no-op once gcs_base is cleared.
+	 */
+	gcs_free(tsk);
+}
+
 void arch_release_task_struct(struct task_struct *tsk)
 {
 	fpsimd_release_task(tsk);
@@ -503,6 +513,15 @@ int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
 
 		p->thread.cpu_context.x19 = (unsigned long)args->fn;
 		p->thread.cpu_context.x20 = (unsigned long)args->fn_arg;
+		/*
+		 * Tasks created through the args->fn path do not
+		 * allocate a user GCS here. Clear inherited allocation
+		 * tracking so a later failed copy_process() cannot
+		 * release the caller's GCS when the mm is still shared
+		 * (CLONE_VM), as with kernel_thread() and
+		 * create_io_thread().
+		 */
+		gcs_reset_thread(p);
 
 		if (system_supports_poe())
 			p->thread.por_el0 = POR_EL0_INIT;
Signature · 1 line
2.43.0
#10 lore raw

PATCH v2 2/4 riscv/shstk: release shadow stack on failed fork

In reply to Iván Ezequiel Rodriguez

copy_thread() maps a shadow stack for a CLONE_VM child and can still return 0. A later copy_process() failure, including clone3() CLONE_PIDFD when put_user() fails, goes through bad_fork_cleanup_thread and exit_thread(). riscv did not select HAVE_EXIT_THREAD, so that free was a no-op. shstk_release() already documents this call. Normal task exit reaches it through deactivate_mm(), while the failed-fork path did not.

Select HAVE_EXIT_THREAD and call shstk_release() from exit_thread(). The helper already refuses a different mm and a cleared base, so CLONE_VFORK, !CLONE_VM, and a second call after deactivate_mm() stay no-ops.

Do not free when shstk_alloc_thread_stack() fails. That error returns from copy_thread() with the inherited parent base still in place, and copy_process() skips exit_thread() on that path.

Tasks created through the args->fn path (kernel_thread(), create_io_thread(), and similar) do not allocate a user shadow stack in copy_thread(). They still inherit tracking via arch_dup_task_struct(), and with CLONE_VM a later failed copy_process() still shares the caller's mm, so shstk_release()'s mm check does not save us. Clear that tracking with set_shstk_base().

Fixes fd44a4a85516 ("riscv/shstk: If needed allocate a new shadow stack on clone")
Cc Deepak Gupta <[email protected]>
Assisted-by LLM
---
 arch/riscv/Kconfig          |  1 +
 arch/riscv/kernel/process.c | 20 ++++++++++++++++++++
 2 files changed, 21 insertions(+)
arch/riscv/Kconfig +1 -0
index 5874ca30f4f6..7635f8cb0397 100644
@@ -169,6 +169,7 @@ config RISCV
 	select HAVE_FUNCTION_GRAPH_FREGS
 	select HAVE_FUNCTION_TRACER if HAVE_DYNAMIC_FTRACE
 	select HAVE_EBPF_JIT if MMU
+	select HAVE_EXIT_THREAD
 	select HAVE_GENERIC_TIF_BITS
 	select HAVE_GUP_FAST if MMU
 	select HAVE_FUNCTION_ARG_ACCESS_API
arch/riscv/kernel/process.c +20 -0
index 7cc5a6a5c020..b9b765264cc8 100644
@@ -12,6 +12,7 @@
 #include <linux/kernel.h>
 #include <linux/sched.h>
 #include <linux/sched/debug.h>
+#include <linux/sched/task.h>
 #include <linux/sched/task_stack.h>
 #include <linux/tick.h>
 #include <linux/ptrace.h>
@@ -213,6 +214,16 @@ void arch_release_task_struct(struct task_struct *tsk)
 		riscv_v_thread_free(tsk);
 }
 
+void exit_thread(struct task_struct *tsk)
+{
+	/*
+	 * shstk_release() documents this call for a failed CLONE_VM
+	 * clone. copy_process() reaches it only after copy_thread()
+	 * returns 0; deactivate_mm() does not run on that path.
+	 */
+	shstk_release(tsk);
+}
+
 int arch_dup_task_struct(struct task_struct *dst, struct task_struct *src)
 {
 	fstate_save(src, task_pt_regs(src));
@@ -261,6 +272,15 @@ int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
 		p->thread.s[0] = (unsigned long)args->fn;
 		p->thread.s[1] = (unsigned long)args->fn_arg;
 		p->thread.ra = (unsigned long)ret_from_fork_kernel_asm;
+		/*
+		 * Tasks created through the args->fn path do not
+		 * allocate a user shadow stack here. Clear inherited
+		 * allocation tracking so a later failed copy_process()
+		 * cannot release the caller's stack when the mm is
+		 * still shared (CLONE_VM), as with kernel_thread() and
+		 * create_io_thread().
+		 */
+		set_shstk_base(p, 0, 0);
 	} else {
 		/* allocate new shadow stack if needed. In case of CLONE_VM we have to */
 		ssp = shstk_alloc_thread_stack(p, args);
Signature · 1 line
2.43.0
#11 lore raw

PATCH v2 3/4 selftests/arm64: GCS must not leak on a failed clone3

In reply to Iván Ezequiel Rodriguez

clone3(CLONE_VM|CLONE_PIDFD) with an unwritable pidfd pointer fails in put_user() after copy_thread() has mapped a GCS. copy_process() then calls exit_thread(). Without HAVE_EXIT_THREAD that free is a no-op and the VMA stays in the parent's mm.

Use kselftest.h / KTAP. Skip paths that cannot run the regression run before ksft_set_plan(). Then confirm a valid clone3 writes a pidfd, reject 16 bad-pidfd clones with -EFAULT, and require the shadow-stack VMA count (VmFlags " ss") to stay unchanged.

ENOSYS, EINVAL, and EPERM from the valid clone skip; any other error, or a success that leaves pidfd unset, fails. The rejected child exits with a raw svc. The test expects /proc already mounted.

Move my_syscall2 into gcs-util.h and reuse it from gcs-locking.c and libc-gcs.c.

---
 tools/testing/selftests/arm64/gcs/Makefile    |   3 +-
 .../selftests/arm64/gcs/gcs-clone-pidfd.c     | 168 ++++++++++++++++++
 .../testing/selftests/arm64/gcs/gcs-locking.c |  20 ---
 tools/testing/selftests/arm64/gcs/gcs-util.h  |  24 +++
 tools/testing/selftests/arm64/gcs/libc-gcs.c  |  20 ---
 5 files changed, 194 insertions(+), 41 deletions(-)
 create mode 100644 tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c
tools/testing/selftests/arm64/gcs/Makefile +2 -1
index 1fbbf0ca1f02..f1365eea31c9 100644
@@ -6,7 +6,8 @@
 # nolibc.
 #
 
-TEST_GEN_PROGS := basic-gcs libc-gcs gcs-locking gcs-stress gcspushm gcsstr
+TEST_GEN_PROGS := basic-gcs libc-gcs gcs-locking gcs-stress gcspushm gcsstr \
+		  gcs-clone-pidfd
 TEST_GEN_PROGS_EXTENDED := gcs-stress-thread
 
 LDLIBS+=-lpthread
tools/testing/selftests/arm64/gcs/gcs-clone-pidfd.c new file +168 -0

168 lines not on this page · raw message

tools/testing/selftests/arm64/gcs/gcs-locking.c +0 -20
index 1e6abb136ffd..61789bdd2744 100644
@@ -18,26 +18,6 @@
 
 #include "gcs-util.h"
 
-#define my_syscall2(num, arg1, arg2)                                          \
-({                                                                            \
-	register long _num  __asm__ ("x8") = (num);                           \
-	register long _arg1 __asm__ ("x0") = (long)(arg1);                    \
-	register long _arg2 __asm__ ("x1") = (long)(arg2);                    \
-	register long _arg3 __asm__ ("x2") = 0;                               \
-	register long _arg4 __asm__ ("x3") = 0;                               \
-	register long _arg5 __asm__ ("x4") = 0;                               \
-	                                                                      \
-	__asm__  volatile (                                                   \
-		"svc #0\n"                                                    \
-		: "=r"(_arg1)                                                 \
-		: "r"(_arg1), "r"(_arg2),                                     \
-		  "r"(_arg3), "r"(_arg4),                                     \
-		  "r"(_arg5), "r"(_num)					      \
-		: "memory", "cc"                                              \
-	);                                                                    \
-	_arg1;                                                                \
-})
-
 /* No mode bits are rejected for locking */
 TEST(lock_all_modes)
 {
tools/testing/selftests/arm64/gcs/gcs-util.h +24 -0
index 7a81bb07ed4b..46f6ed9cf54d 100644
@@ -43,6 +43,30 @@
 #define GCS_CAP(x) (((unsigned long)(x) & GCS_CAP_ADDR_MASK) | \
 		    GCS_CAP_VALID_TOKEN)
 
+/*
+ * prctl(..., arg1, arg2) with unused registers forced to zero.  The
+ * kernel rejects non-zero unused args; libc may leave garbage there.
+ */
+#define my_syscall2(num, arg1, arg2)					\
+({									\
+	register long _num  __asm__ ("x8") = (num);			\
+	register long _arg1 __asm__ ("x0") = (long)(arg1);		\
+	register long _arg2 __asm__ ("x1") = (long)(arg2);		\
+	register long _arg3 __asm__ ("x2") = 0;				\
+	register long _arg4 __asm__ ("x3") = 0;				\
+	register long _arg5 __asm__ ("x4") = 0;				\
+									\
+	__asm__ volatile (						\
+		"svc #0\n"						\
+		: "=r"(_arg1)						\
+		: "r"(_arg1), "r"(_arg2),				\
+		  "r"(_arg3), "r"(_arg4),				\
+		  "r"(_arg5), "r"(_num)					\
+		: "memory", "cc"					\
+	);								\
+	_arg1;								\
+})
+
 static inline unsigned long *get_gcspr(void)
 {
 	unsigned long *gcspr;
tools/testing/selftests/arm64/gcs/libc-gcs.c +0 -20
index ddb0b2b20155..4e9fa9b82f0f 100644
@@ -24,26 +24,6 @@
 
 #include "gcs-util.h"
 
-#define my_syscall2(num, arg1, arg2)                                          \
-({                                                                            \
-	register long _num  __asm__ ("x8") = (num);                           \
-	register long _arg1 __asm__ ("x0") = (long)(arg1);                    \
-	register long _arg2 __asm__ ("x1") = (long)(arg2);                    \
-	register long _arg3 __asm__ ("x2") = 0;                               \
-	register long _arg4 __asm__ ("x3") = 0;                               \
-	register long _arg5 __asm__ ("x4") = 0;                               \
-	                                                                      \
-	__asm__  volatile (                                                   \
-		"svc #0\n"                                                    \
-		: "=r"(_arg1)                                                 \
-		: "r"(_arg1), "r"(_arg2),                                     \
-		  "r"(_arg3), "r"(_arg4),                                     \
-		  "r"(_arg5), "r"(_num)					      \
-		: "memory", "cc"                                              \
-	);                                                                    \
-	_arg1;                                                                \
-})
-
 static noinline void gcs_recurse(int depth)
 {
 	if (depth)
Signature · 1 line
2.43.0
#12 lore raw

PATCH v2 4/4 selftests/riscv: shadow stack must not leak on a failed clone3

In reply to Iván Ezequiel Rodriguez

clone3(CLONE_VM|CLONE_PIDFD) with an unwritable pidfd pointer fails in put_user() after copy_thread() has mapped a shadow stack. copy_process() then calls exit_thread(). Without HAVE_EXIT_THREAD that free is a no-op and the VMA stays in the parent's mm.

Use kselftest.h / KTAP. Skip paths that cannot run the regression run before ksft_set_plan(). Then confirm a valid clone3 writes a pidfd, reject 16 bad-pidfd clones with -EFAULT, and require the shadow-stack VMA count (VmFlags " ss") to stay unchanged.

ENOSYS, EINVAL, and EPERM from the valid clone skip. The rejected child exits with a raw ecall. The test expects /proc already mounted. The Makefile skips this binary when the compiler cannot target zicfiss/zicfilp.

---
 tools/testing/selftests/riscv/cfi/Makefile    |   5 +-
 .../selftests/riscv/cfi/shstk-clone-pidfd.c   | 173 ++++++++++++++++++
 2 files changed, 177 insertions(+), 1 deletion(-)
 create mode 100644 tools/testing/selftests/riscv/cfi/shstk-clone-pidfd.c
tools/testing/selftests/riscv/cfi/Makefile +4 -1
index 418b4b5325a5..dbecb1fd3b82 100644
@@ -13,10 +13,13 @@ endif
 
 
 ifeq ($(shell $(CC) $(CFLAGS) -nostdlib -xc /dev/null -o /dev/null > /dev/null 2>&1; echo $$?),0)
-TEST_GEN_PROGS := cfitests
+TEST_GEN_PROGS := cfitests shstk-clone-pidfd
 
 $(OUTPUT)/cfitests: cfitests.c shadowstack.c
 	$(CC) -o $@ $(CFLAGS) $(LDFLAGS) $^
+
+$(OUTPUT)/shstk-clone-pidfd: shstk-clone-pidfd.c
+	$(CC) -o $@ $(CFLAGS) $(LDFLAGS) $^
 else
 
 $(shell echo "Toolchain doesn't support CFI, skipping CFI kselftest." >&2)
tools/testing/selftests/riscv/cfi/shstk-clone-pidfd.c new file +173 -0

173 lines not on this page · raw message

Signature · 1 line
2.43.0
#9 lore raw

Re: [PATCH v2 1/4] arm64/gcs: release GCS on failed fork

In reply to Iván Ezequiel Rodriguez

arch/arm64/include/asm/gcs.h void gcs_preserve_current_state(void); · line 76

Quoted 12 lines · Iván Ezequiel Rodriguez

+++ b/arch/arm64/include/asm/gcs.h

arch/arm64/include/asm/gcs.h +7 -0
@@ -67,6 +67,13 @@ void gcs_preserve_current_state(void);
 unsigned long gcs_alloc_thread_stack(struct task_struct *tsk,
 				     const struct kernel_clone_args *args);
 
+static inline void gcs_reset_thread(struct task_struct *task)
+{
+	task->thread.gcs_base = 0;
+	task->thread.gcs_size = 0;
+	task->thread.gcspr_el0 = 0;
+}
+

The other process.c specific helpers are all in process.c. Otherwise this looks OK, though I'm still not clear about the analysis for why x86 is not also affected.

Note that since you sent this in reply to the old thread is was buried in my inbox.