* [PATCH 0/2] exec: randomize the argument block's in-page offset
@ 2026-10-23 1:54 Brian Witte
2026-10-23 1:54 ` [PATCH 1/2] exec: randomize the argument block's offset within its page Brian Witte
2026-10-23 1:54 ` [PATCH 2/2] selftests/exec: test the argument block's in-page offset Brian Witte
0 siblings, 2 replies; 3+ messages in thread
From: Brian Witte @ 2026-10-23 1:54 UTC (permalink / raw)
To: Kees Cook
Cc: Brian Witte, Alexander Viro, Christian Brauner, Jan Kara,
Lorenzo Stoakes, Jann Horn, Helge Deller, Justin Stitt,
Daniel Micay, Shuah Khan, linux-fsdevel, linux-mm,
linux-hardening, linux-kselftest, linux-kernel
Patch 1 randomizes where the argv, envp and execfn strings sit within
the first stack page, as KSPP issue 283 asks; today their low address
bits never change. Patch 2 adds a selftest.
Patch 1 repeats bprm_fill_uid()'s set-id checks; I can send a shared
helper as a preparatory patch if preferred.
Tested in QEMU, compared with unpatched for-next/execve (b36b990c50b1),
on x86_64 with ia32, arm64 (4K pages with AArch32, and 64K pages), and
parisc 32-bit and 64-bit (STACK_GROWSUP):
- The selftest passes; unpatched, it fails.
- 10000 execs give 3709-3764 distinct offsets with 4K pages and 9259
with 64K, as a uniform draw predicts.
- Cost: about 0.5 more minor page faults per exec on average, and up
to a page of RLIMIT_STACK; exec time is within noise.
- No change in LTP execve and execveat, CRIU dump and restore, or
programs that rewrite argv[0] (setproctitle(), sshd, postgres), and
no KASAN, UBSAN or lockdep reports under exec and chmod races.
The series is based on mainline (864c07de0c7d), which now includes
for-next/execve and the const mnt_idmap conversion. On that base it adds
no W=1 warnings with GCC 14 or clang 19 on x86_64 and arm64, and the
selftest passes on x86_64.
Brian Witte (2):
exec: randomize the argument block's offset within its page
selftests/exec: test the argument block's in-page offset
fs/exec.c | 32 +++
tools/testing/selftests/exec/.gitignore | 3 +
tools/testing/selftests/exec/Makefile | 5 +
.../testing/selftests/exec/argblock-offset.c | 214 ++++++++++++++++++
.../testing/selftests/exec/argblock-report.c | 24 ++
5 files changed, 278 insertions(+)
create mode 100644 tools/testing/selftests/exec/argblock-offset.c
create mode 100644 tools/testing/selftests/exec/argblock-report.c
base-commit: 864c07de0c7dcbca58b61625dfa2e461ae63b242
--
2.47.3
^ permalink raw reply [flat|nested] 3+ messages in thread
* [PATCH 1/2] exec: randomize the argument block's offset within its page
2026-10-23 1:54 [PATCH 0/2] exec: randomize the argument block's in-page offset Brian Witte
@ 2026-10-23 1:54 ` Brian Witte
2026-10-23 1:54 ` [PATCH 2/2] selftests/exec: test the argument block's in-page offset Brian Witte
1 sibling, 0 replies; 3+ messages in thread
From: Brian Witte @ 2026-10-23 1:54 UTC (permalink / raw)
To: Kees Cook
Cc: Brian Witte, Alexander Viro, Christian Brauner, Jan Kara,
Lorenzo Stoakes, Jann Horn, Helge Deller, Justin Stitt,
Daniel Micay, Shuah Khan, linux-fsdevel, linux-mm,
linux-hardening, linux-kselftest, linux-kernel
bprm->p starts one word below STACK_TOP_MAX, and setup_arg_pages()
moves the stack only by whole pages, so the argv, envp and execfn
strings sit at the same offset within their page on every exec. Three
execs on x86_64 put argv[0] at 0x7ffeee30af9f, 0x7ffdf3709f9f and
0x7ffc871e0f9f.
Lower bprm->p by a random amount under a page before the strings are
copied: about 12 bits of entropy with 4K pages and 16 with 64K.
bprm->argmin is derived from bprm->p, so the argument size limit is
unchanged, but up to a page of RLIMIT_STACK is used.
ADDR_NO_RANDOMIZE disables the offset; PF_RANDOMIZE cannot be used,
since load_elf_binary() sets it after the strings are copied.
PER_CLEAR_ON_SETID also clears the flag for set-id execs only after the
copy, and moving the strings then would mean copying them all. So
bprm_fill_uid()'s set-id checks are repeated early, without its
inode_lock(), and a set-id exec gets the offset regardless of the flag.
A racing chmod or sysctl write can make the offset disagree with the ELF
loader but never affects the credentials. Only the file passed to
execve() is checked: set-id interpreters, file capabilities and LSM
transitions keep today's fixed offset under the flag. The checks need
bprm->file, so they run in bprm_mm_init(), not create_init_stack_vma().
The idea comes from PaX RANDUSTACK; linux-hardened carries a version by
Daniel Micay and Levente Polyak.
Suggested-by: Daniel Micay <danielmicay@gmail.com>
Link: https://github.com/KSPP/linux/issues/283
Signed-off-by: Brian Witte <brianwitte@mailfence.com>
---
fs/exec.c | 32 ++++++++++++++++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/fs/exec.c b/fs/exec.c
index 5d7b0fe32dbf..1070f0799b27 100644
--- a/fs/exec.c
+++ b/fs/exec.c
@@ -70,6 +70,7 @@
#include <linux/user_events.h>
#include <linux/rseq.h>
#include <linux/ksm.h>
+#include <linux/random.h>
#include <linux/uaccess.h>
#include <asm/mmu_context.h>
@@ -195,6 +196,33 @@ static bool valid_arg_len(struct linux_binprm *bprm, long len)
return len <= MAX_ARG_STRLEN;
}
+/*
+ * Called before credentials exist: treat ADDR_NO_RANDOMIZE as cleared for
+ * files bprm_fill_uid() will treat as set-id.
+ */
+static bool bprm_randomize_args(struct linux_binprm *bprm)
+{
+ struct file *file = bprm->file;
+ const struct mnt_idmap *idmap = file_mnt_idmap(file);
+ struct inode *inode = file_inode(file);
+ /* bprm->cred, not yet set, will be in the caller's namespace. */
+ struct user_namespace *ns = current_user_ns();
+ umode_t mode;
+
+ if (!READ_ONCE(randomize_va_space))
+ return false;
+ if (!(current->personality & ADDR_NO_RANDOMIZE))
+ return true;
+ if (!mnt_may_suid(file->f_path.mnt) || task_no_new_privs(current))
+ return false;
+ mode = READ_ONCE(inode->i_mode);
+ if (!(mode & S_ISUID) &&
+ (mode & (S_ISGID | S_IXGRP)) != (S_ISGID | S_IXGRP))
+ return false;
+ return vfsuid_has_mapping(ns, i_uid_into_vfsuid(idmap, inode)) &&
+ vfsgid_has_mapping(ns, i_gid_into_vfsgid(idmap, inode));
+}
+
#else
static inline void acct_arg_size(struct linux_binprm *bprm, unsigned long pages)
@@ -279,6 +307,10 @@ static int bprm_mm_init(struct linux_binprm *bprm)
err = create_init_stack_vma(bprm->mm, &bprm->vma, &bprm->p);
if (err)
goto err;
+
+ /* Randomize the strings' offset within their page; keep the top word. */
+ if (bprm_randomize_args(bprm))
+ bprm->p -= get_random_u32_below(PAGE_SIZE - sizeof(void *));
#endif
return 0;
--
2.47.3
^ permalink raw reply [flat|nested] 3+ messages in thread
* [PATCH 2/2] selftests/exec: test the argument block's in-page offset
2026-10-23 1:54 [PATCH 0/2] exec: randomize the argument block's in-page offset Brian Witte
2026-10-23 1:54 ` [PATCH 1/2] exec: randomize the argument block's offset within its page Brian Witte
@ 2026-10-23 1:54 ` Brian Witte
1 sibling, 0 replies; 3+ messages in thread
From: Brian Witte @ 2026-10-23 1:54 UTC (permalink / raw)
To: Kees Cook
Cc: Brian Witte, Alexander Viro, Christian Brauner, Jan Kara,
Lorenzo Stoakes, Jann Horn, Helge Deller, Justin Stitt,
Daniel Micay, Shuah Khan, linux-fsdevel, linux-mm,
linux-hardening, linux-kselftest, linux-kernel
Exec a helper, argblock-report, 64 times per case. Each case checks that
the in-page offset of argv[0] varies exactly when the stack top chosen
by the ELF loader does:
- under ADDR_NO_RANDOMIZE: fixed;
- by default: random;
- a set-user-ID copy under ADDR_NO_RANDOMIZE: random;
- that copy on a nosuid mount, under no_new_privs, or in a new user
namespace where its owner has no mapping: fixed.
The tests use the kselftest harness. The fixture keeps the copy in a
private temporary directory and removes it on teardown. The copy is
owned by the caller, so no privilege is needed.
Link: https://github.com/KSPP/linux/issues/283
Signed-off-by: Brian Witte <brianwitte@mailfence.com>
---
tools/testing/selftests/exec/.gitignore | 3 +
tools/testing/selftests/exec/Makefile | 5 +
.../testing/selftests/exec/argblock-offset.c | 214 ++++++++++++++++++
.../testing/selftests/exec/argblock-report.c | 24 ++
4 files changed, 246 insertions(+)
create mode 100644 tools/testing/selftests/exec/argblock-offset.c
create mode 100644 tools/testing/selftests/exec/argblock-report.c
diff --git a/tools/testing/selftests/exec/.gitignore b/tools/testing/selftests/exec/.gitignore
index e42ecd4c908d..991097f7fc43 100644
--- a/tools/testing/selftests/exec/.gitignore
+++ b/tools/testing/selftests/exec/.gitignore
@@ -9,6 +9,9 @@ execveat.ephemeral
execveat.denatured
non-regular
null-argv
+/argblock-offset
+/argblock-offset.*
+/argblock-report
/check-exec
/false
/inc
diff --git a/tools/testing/selftests/exec/Makefile b/tools/testing/selftests/exec/Makefile
index 91decfa8ff2c..4835c7ca4a20 100644
--- a/tools/testing/selftests/exec/Makefile
+++ b/tools/testing/selftests/exec/Makefile
@@ -21,6 +21,11 @@ TEST_GEN_PROGS += recursion-depth
TEST_GEN_PROGS += null-argv
TEST_GEN_PROGS += check-exec
+# The argument block's in-page offset must be random exactly when the
+# ELF loader randomizes the stack. Unprivileged.
+TEST_GEN_PROGS += argblock-offset
+TEST_GEN_FILES += argblock-report
+
# binfmt_misc must not be reachable as an exec source or as a stacking layer,
# or an 'F' entry can pin the instance that owns it. Unprivileged, no bpf.
TEST_GEN_PROGS += binfmt_misc_selfpin
diff --git a/tools/testing/selftests/exec/argblock-offset.c b/tools/testing/selftests/exec/argblock-offset.c
new file mode 100644
index 000000000000..6edf9e6aadaf
--- /dev/null
+++ b/tools/testing/selftests/exec/argblock-offset.c
@@ -0,0 +1,214 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * The argument block should land at a random offset within its page
+ * exactly when the ELF loader randomizes the stack: always, unless
+ * ADDR_NO_RANDOMIZE is set and the exec is not set-id. Each case
+ * execs argblock-report repeatedly and compares both.
+ *
+ * Runs unprivileged: the set-user-ID copy is owned by the caller and
+ * kept in a private directory.
+ */
+#define _GNU_SOURCE
+#include <sched.h>
+#include <sys/personality.h>
+#include <sys/prctl.h>
+#include <sys/stat.h>
+#include <sys/statvfs.h>
+
+#include "binfmt_misc_common.h" /* artifact_path(), copy_file() */
+#include "kselftest_harness.h"
+
+/*
+ * A page holds at least 4088 possible offsets, so 64 samples all landing
+ * on one value happen by chance with probability at most (1/4088)^63.
+ */
+#define SAMPLES 64
+
+/* How the helper is exec'd. */
+#define NO_RAND 0x1 /* under ADDR_NO_RANDOMIZE */
+#define NNP 0x2 /* under no_new_privs */
+#define USERNS 0x4 /* in a new user namespace with no mappings */
+
+FIXTURE(argblock) {
+ char report[PATH_MAX]; /* the helper */
+ char dir[32]; /* private directory for the set-id copy */
+ char setid[PATH_MAX]; /* set-user-ID copy of the helper */
+ bool have_dir, have_setid, nosuid;
+ int randomize_va_space;
+};
+
+static int read_randomize_va_space(void)
+{
+ FILE *f = fopen("/proc/sys/kernel/randomize_va_space", "r");
+ int v = -1;
+
+ if (f) {
+ if (fscanf(f, "%d", &v) != 1)
+ v = -1;
+ fclose(f);
+ }
+ return v;
+}
+
+FIXTURE_SETUP(argblock)
+{
+ struct statvfs sv;
+
+ ASSERT_EQ(artifact_path(self->report, sizeof(self->report),
+ "argblock-report"), 0);
+ self->randomize_va_space = read_randomize_va_space();
+
+ /* A private directory keeps the set-user-ID copy out of reach. */
+ strcpy(self->dir, "argblock-offset.XXXXXX");
+ self->have_dir = mkdtemp(self->dir);
+ if (!self->have_dir)
+ return;
+ snprintf(self->setid, sizeof(self->setid), "%s/setid", self->dir);
+ self->have_setid = !copy_file(self->report, self->setid) &&
+ !chmod(self->setid, 04755);
+ self->nosuid = !statvfs(self->dir, &sv) && (sv.f_flag & ST_NOSUID);
+}
+
+FIXTURE_TEARDOWN(argblock)
+{
+ if (!self->have_dir)
+ return;
+ unlink(self->setid);
+ rmdir(self->dir);
+}
+
+struct sample {
+ unsigned long offset; /* argv[0] within its page */
+ unsigned long stack_top; /* end of [stack], chosen by the loader */
+};
+
+static int sample(const char *path, int how, struct sample *s)
+{
+ int pfd[2], status;
+ char buf[64];
+ ssize_t n;
+ pid_t pid;
+
+ if (pipe(pfd))
+ return -1;
+ pid = fork();
+ if (pid < 0)
+ return -1;
+ if (pid == 0) {
+ char *argv[] = { (char *)path, NULL };
+ char *envp[] = { NULL };
+
+ if ((how & NO_RAND) && personality(ADDR_NO_RANDOMIZE) < 0)
+ _exit(2);
+ if ((how & NNP) && prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0))
+ _exit(2);
+ if ((how & USERNS) && unshare(CLONE_NEWUSER))
+ _exit(2);
+ dup2(pfd[1], STDOUT_FILENO);
+ execve(path, argv, envp);
+ _exit(3);
+ }
+ close(pfd[1]);
+ n = read(pfd[0], buf, sizeof(buf) - 1);
+ close(pfd[0]);
+ waitpid(pid, &status, 0);
+ if (n <= 0 || !WIFEXITED(status) || WEXITSTATUS(status))
+ return -1;
+ buf[n] = '\0';
+ return sscanf(buf, "%lx %lx", &s->offset, &s->stack_top) == 2 ? 0 : -1;
+}
+
+/*
+ * Exec @path SAMPLES times. The in-page offset must vary as @expect says,
+ * and vary exactly when the loader's stack top does.
+ */
+static void check(struct __test_metadata *_metadata, const char *path,
+ int how, bool expect)
+{
+ bool offset_varies = false, stack_varies = false;
+ struct sample first, s;
+ int i;
+
+ for (i = 0; i < SAMPLES; i++) {
+ ASSERT_EQ(sample(path, how, &s), 0)
+ TH_LOG("exec of %s failed", path);
+ if (!i)
+ first = s;
+ offset_varies |= s.offset != first.offset;
+ stack_varies |= s.stack_top != first.stack_top;
+ }
+ EXPECT_EQ(stack_varies, expect)
+ TH_LOG("the loader %s the stack",
+ stack_varies ? "randomized" : "did not randomize");
+ EXPECT_EQ(offset_varies, expect)
+ TH_LOG("the argument block offset is %s",
+ offset_varies ? "random" : "fixed");
+}
+
+/* Can this process create a user namespace? */
+static bool have_userns(void)
+{
+ int status;
+ pid_t pid = fork();
+
+ if (pid == 0)
+ _exit(unshare(CLONE_NEWUSER) ? 1 : 0);
+ return pid > 0 && waitpid(pid, &status, 0) == pid &&
+ WIFEXITED(status) && !WEXITSTATUS(status);
+}
+
+/* Without privilege, ADDR_NO_RANDOMIZE keeps the offset fixed. */
+TEST_F(argblock, addr_no_randomize_keeps_offset_fixed)
+{
+ check(_metadata, self->report, NO_RAND, false);
+}
+
+/* By default the offset is random. */
+TEST_F(argblock, offset_is_random)
+{
+ if (self->randomize_va_space <= 0)
+ SKIP(return, "randomize_va_space is 0");
+ check(_metadata, self->report, 0, true);
+}
+
+/* A set-id exec clears ADDR_NO_RANDOMIZE, so the offset is random again. */
+TEST_F(argblock, setid_ignores_addr_no_randomize)
+{
+ if (self->randomize_va_space <= 0)
+ SKIP(return, "randomize_va_space is 0");
+ if (!self->have_setid)
+ SKIP(return, "cannot create a set-user-ID copy");
+ if (self->nosuid)
+ SKIP(return, "set-user-ID copy is on a nosuid mount");
+ check(_metadata, self->setid, NO_RAND, true);
+}
+
+/* On a nosuid mount the exec gains nothing, so the flag holds. */
+TEST_F(argblock, setid_on_nosuid_keeps_offset_fixed)
+{
+ if (!self->have_setid)
+ SKIP(return, "cannot create a set-user-ID copy");
+ if (!self->nosuid)
+ SKIP(return, "set-user-ID copy is not on a nosuid mount");
+ check(_metadata, self->setid, NO_RAND, false);
+}
+
+/* no_new_privs makes the exec ignore the set-id bit. */
+TEST_F(argblock, setid_under_no_new_privs_keeps_offset_fixed)
+{
+ if (!self->have_setid)
+ SKIP(return, "cannot create a set-user-ID copy");
+ check(_metadata, self->setid, NO_RAND | NNP, false);
+}
+
+/* The copy's owner has no mapping there, so the set-id bit is ignored. */
+TEST_F(argblock, setid_in_user_namespace_keeps_offset_fixed)
+{
+ if (!self->have_setid)
+ SKIP(return, "cannot create a set-user-ID copy");
+ if (!have_userns())
+ SKIP(return, "cannot create a user namespace");
+ check(_metadata, self->setid, NO_RAND | USERNS, false);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/exec/argblock-report.c b/tools/testing/selftests/exec/argblock-report.c
new file mode 100644
index 000000000000..45744ec9bda9
--- /dev/null
+++ b/tools/testing/selftests/exec/argblock-report.c
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Helper for argblock-offset: print the in-page offset of argv[0] and the
+ * end of the [stack] VMA, which the ELF loader chooses.
+ */
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+int main(int argc, char **argv)
+{
+ unsigned long end = 0;
+ char line[256];
+ FILE *maps = fopen("/proc/self/maps", "r");
+
+ while (maps && fgets(line, sizeof(line), maps))
+ if (strstr(line, "[stack]"))
+ sscanf(line, "%*[0-9a-f]-%lx", &end);
+ if (argc < 1 || !end)
+ return 1;
+ printf("%lx %lx\n", (unsigned long)argv[0] & (sysconf(_SC_PAGESIZE) - 1),
+ end);
+ return 0;
+}
--
2.47.3
^ permalink raw reply [flat|nested] 3+ messages in thread
end of thread, other threads:[~2026-10-23 1:54 UTC | newest]
Thread overview: 3+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-10-23 1:54 [PATCH 0/2] exec: randomize the argument block's in-page offset Brian Witte
2026-10-23 1:54 ` [PATCH 1/2] exec: randomize the argument block's offset within its page Brian Witte
2026-10-23 1:54 ` [PATCH 2/2] selftests/exec: test the argument block's in-page offset Brian Witte
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®