From: Christian Brauner <brauner@kernel.org>
To: linux-fsdevel@vger.kernel.org
Cc: Alexander Viro <viro@zeniv.linux.org.uk>, Jan Kara <jack@suse.cz>,
Kees Cook <kees@kernel.org>,
linux-mm@kvack.org, bpf@vger.kernel.org,
Jonathan Corbet <corbet@lwn.net>,
Farid Zakaria <farid.m.zakaria@gmail.com>,
Daniel Borkmann <daniel@iogearbox.net>,
Alexei Starovoitov <ast@kernel.org>,
jannh@google.com, mail@johnericson.me,
"Christian Brauner (Amutable)" <brauner@kernel.org>
Subject: [PATCH 8/9] selftests/exec: test interpreters bound to a 'B' entry
Date: Thu, 30 Jul 2026 15:34:10 +0200 [thread overview]
Message-ID: <20260730-work-binfmt_misc-preopen-v1-8-4a0b0da71f16@kernel.org> (raw)
In-Reply-To: <20260730-work-binfmt_misc-preopen-v1-0-4a0b0da71f16@kernel.org>
One handler, one entry registered disabled, an interpreter per guest
architecture bound to a file one write at a time. The load program picks
one by name per exec:
- an aarch64 binary runs the interpreter bound as "first" and a riscv one
the interpreter bound as "second", from a single entry and a single
handler
- unlinking a bound interpreter and putting a different binary in its
place changes nothing, which is what the binding exists for
- the entry reports what it bound, under the names it bound them as
- a name the entry did not bind fails the exec with -ENOENT rather than
falling back to anything
- activating the entry refuses further binding with -EBUSY, a later
disable does not undo that, and an entry registered without 'D' never
accepted a '+' write to begin with
- a name binds one interpreter, and control characters are refused
- the command has to end at the write, bytes past an embedded nul are
refused
- an entry binds at most 100 interpreters, the next one is refused with
-ENOSPC
The test interpreter prints its argv[0], which is the path the kernel ran
that copy under, so one binary installed at two paths tells the harness
which of them the program picked.
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
tools/testing/selftests/exec/Makefile | 7 +-
tools/testing/selftests/exec/binfmt_bind_interp.c | 14 ++
tools/testing/selftests/exec/binfmt_misc_bpf.c | 261 +++++++++++++++++++++-
tools/testing/selftests/exec/interp_bind.bpf.c | 76 +++++++
4 files changed, 349 insertions(+), 9 deletions(-)
diff --git a/tools/testing/selftests/exec/Makefile b/tools/testing/selftests/exec/Makefile
index ec7894a802e0..410c93606a0c 100644
--- a/tools/testing/selftests/exec/Makefile
+++ b/tools/testing/selftests/exec/Makefile
@@ -56,8 +56,8 @@ HAVE_BPF_TOOLCHAIN ?= $(shell command -v $(CLANG) >/dev/null 2>&1 && \
ifeq ($(HAVE_BPF_TOOLCHAIN),y)
TEST_GEN_PROGS += binfmt_misc_bpf
TEST_GEN_FILES += bpf_interp.bpf.o nix_origin.bpf.o transparent.bpf.o
-TEST_GEN_FILES += loader.bpf.o
-TEST_GEN_FILES += binfmt_bpf_interp binfmt_bpf_app
+TEST_GEN_FILES += loader.bpf.o interp_bind.bpf.o
+TEST_GEN_FILES += binfmt_bpf_interp binfmt_bpf_app binfmt_bind_interp
else
$(info exec selftests: skipping binfmt_misc_bpf, needs clang, bpftool, vmlinux BTF and libbpf)
endif
@@ -127,6 +127,9 @@ $(OUTPUT)/binfmt_misc_bpf: binfmt_misc_bpf.c binfmt_misc_common.h
$(OUTPUT)/binfmt_bpf_interp: binfmt_bpf_interp.c
$(CC) $(CFLAGS) $(LDFLAGS) $< -o $@
+$(OUTPUT)/binfmt_bind_interp: binfmt_bind_interp.c
+ $(CC) $(CFLAGS) $(LDFLAGS) $< -o $@
+
$(OUTPUT)/binfmt_loader_payload: binfmt_loader_payload.c binfmt_misc_common.h
$(CC) $(CFLAGS) $(LDFLAGS) -fPIE -pie $< -o $@
diff --git a/tools/testing/selftests/exec/binfmt_bind_interp.c b/tools/testing/selftests/exec/binfmt_bind_interp.c
new file mode 100644
index 000000000000..06d65062856b
--- /dev/null
+++ b/tools/testing/selftests/exec/binfmt_bind_interp.c
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Test interpreter for the bound-interpreter case of the binfmt_misc_bpf
+ * selftest. Two copies are installed at different paths and bound to one
+ * entry under different names; printing argv[0] - the path the kernel ran
+ * this copy under - tells the harness which of them the load program picked.
+ */
+#include <stdio.h>
+
+int main(int argc, char **argv)
+{
+ printf("BIND_RAN %s\n", argc > 0 ? argv[0] : "");
+ return 0;
+}
diff --git a/tools/testing/selftests/exec/binfmt_misc_bpf.c b/tools/testing/selftests/exec/binfmt_misc_bpf.c
index 71bb6d8b4517..2c7b63075f1d 100644
--- a/tools/testing/selftests/exec/binfmt_misc_bpf.c
+++ b/tools/testing/selftests/exec/binfmt_misc_bpf.c
@@ -9,7 +9,7 @@
*
* echo ':name:B::::<handler>:' > /proc/sys/fs/binfmt_misc/register
*
- * Three self-contained cases are exercised:
+ * Five self-contained cases are exercised:
*
* 1. bpf_interp: the match program matches a synthetic aarch64 ELF header
* from the prefetched bprm->buf and the load program routes it to a
@@ -26,6 +26,11 @@
* (binfmt_loader_payload) runs as the main image with the selected
* interpreter substituted for its PT_INTERP and asserts the native
* identity from inside.
+ * 5. interp_bind: an entry registered disabled with 'D' is given its
+ * interpreters one write at a time, and the load program picks one by
+ * name per exec. Replacing what the path holds afterwards changes
+ * nothing, which is the point of binding a file rather than resolving
+ * a name at exec time. Enabling the entry seals it.
*
* The first two route to a test interpreter that prints BPF_INTERP_RAN,
* proving the program's chosen interpreter actually ran.
@@ -54,6 +59,12 @@
#define TRANS_EXPECT "TRANSPARENT_OK"
#define LOADER_INTERP "/tmp/binfmt_loader_interp"
#define LOADER_PATH "/tmp/binfmt_bpf_loader.ldrtest"
+#define BIND_FIRST "/tmp/binfmt_bind_first"
+#define BIND_SECOND "/tmp/binfmt_bind_second"
+#define BIND_ARM_PATH "/tmp/binfmt_bind_arm"
+#define BIND_RISCV_PATH "/tmp/binfmt_bind_riscv"
+#define BIND_EXPECT "BIND_RAN "
+#define BIND_MAX 100
/* A minimal 64-bit little-endian ELF header, padded to the read size. */
static int create_fake_elf(const char *path, unsigned short machine)
@@ -82,11 +93,17 @@ static int create_fake_elf(const char *path, unsigned short machine)
return 0;
}
-static int register_entry(const char *name, const char *handler)
+/*
+ * Register a 'B' entry for @handler. With @flags "D" the entry is created
+ * disabled, which is what leaves it open to being given interpreters.
+ */
+static int register_entry(const char *name, const char *handler,
+ const char *flags)
{
char rule[PATH_MAX];
- snprintf(rule, sizeof(rule), ":%s:B::::%s:", name, handler);
+ snprintf(rule, sizeof(rule), ":%s:B::::%s:%s", name, handler,
+ flags ? flags : "");
return write_reg(rule);
}
@@ -139,10 +156,12 @@ struct bpf_case {
/*
* Load @objfile, attach its struct_ops map @handler (which publishes the
- * handler) and activate a 'B' entry named @entry that references it.
+ * handler) and register a 'B' entry named @entry that references it, with
+ * @flags as the entry's register-string flags.
*/
-static int bpf_case_start(struct bpf_case *c, const char *objfile,
- const char *handler, const char *entry)
+static int bpf_case_start_flags(struct bpf_case *c, const char *objfile,
+ const char *handler, const char *entry,
+ const char *flags)
{
struct bpf_map *map;
@@ -172,7 +191,7 @@ static int bpf_case_start(struct bpf_case *c, const char *objfile,
c->link = NULL;
goto fail;
}
- if (register_entry(entry, handler)) {
+ if (register_entry(entry, handler, flags)) {
fprintf(stderr, "register 'B' entry '%s' failed\n", entry);
goto fail;
}
@@ -186,6 +205,12 @@ static int bpf_case_start(struct bpf_case *c, const char *objfile,
return -1;
}
+static int bpf_case_start(struct bpf_case *c, const char *objfile,
+ const char *handler, const char *entry)
+{
+ return bpf_case_start_flags(c, objfile, handler, entry, NULL);
+}
+
static void bpf_case_stop(struct bpf_case *c)
{
unregister(c->entry);
@@ -318,4 +343,226 @@ TEST_F(bpf_handler, loader_substitution)
unlink(LOADER_INTERP);
}
+/* The errno an exec of @path fails with, 0 if it succeeded. */
+static int exec_errno(const char *path)
+{
+ int status;
+ pid_t pid;
+
+ pid = fork();
+ if (pid == 0) {
+ execl(path, path, (char *)NULL);
+ _exit(errno);
+ }
+ if (pid < 0 || waitpid(pid, &status, 0) != pid || !WIFEXITED(status))
+ return -1;
+ return WEXITSTATUS(status);
+}
+
+/* Install a copy of the bound-interpreter test binary at @path. */
+static int install_interp(const char *path)
+{
+ char src[PATH_MAX];
+
+ if (artifact_path(src, sizeof(src), "binfmt_bind_interp"))
+ return -1;
+ return copy_file(src, path);
+}
+
+/* Bind @path to @entry under @name, the '+' command of a disabled entry. */
+static int entry_bind(const char *entry, const char *name, const char *path)
+{
+ char cmd[PATH_MAX];
+
+ snprintf(cmd, sizeof(cmd), "+%s %s\n", name, path);
+ return entry_command(entry, cmd);
+}
+
+FIXTURE(bound_interp) {
+ char obj[PATH_MAX];
+ struct bpf_case c;
+ bool started;
+};
+
+FIXTURE_SETUP(bound_interp)
+{
+ const char *why = bpf_handler_unsupported();
+
+ if (why)
+ SKIP(return, "%s", why);
+ if (!binfmt_flag_supported('D')) {
+ ASSERT_EQ(errno, EINVAL);
+ SKIP(return, "kernel without the 'D' flag");
+ }
+
+ ASSERT_EQ(install_interp(BIND_FIRST), 0);
+ ASSERT_EQ(install_interp(BIND_SECOND), 0);
+
+ ASSERT_EQ(artifact_path(self->obj, sizeof(self->obj),
+ "interp_bind.bpf.o"), 0);
+
+ /*
+ * Registered disabled, so it cannot be matched yet and can still be
+ * given interpreters. Each path is resolved once, by its write(2);
+ * from here on the entry holds the files themselves.
+ */
+ ASSERT_EQ(bpf_case_start_flags(&self->c, self->obj, "interp_bind",
+ "test_interp_bind", "D"), 0);
+ self->started = true;
+
+ ASSERT_EQ(entry_bind("test_interp_bind", "first", BIND_FIRST), 0);
+ ASSERT_EQ(entry_bind("test_interp_bind", "second", BIND_SECOND), 0);
+}
+
+FIXTURE_TEARDOWN(bound_interp)
+{
+ if (self->started)
+ bpf_case_stop(&self->c);
+ unlink(BIND_FIRST);
+ unlink(BIND_SECOND);
+ unlink(AARCH64_PATH);
+ unlink(BIND_RISCV_PATH);
+ unlink(BIND_ARM_PATH);
+}
+
+/* Enabling is what makes the configured entry matchable. */
+static int activate(const char *entry)
+{
+ return entry_command(entry, "1\n");
+}
+
+/* One entry, one interpreter per guest architecture, picked per exec. */
+TEST_F(bound_interp, selects_by_name)
+{
+ ASSERT_EQ(create_fake_elf(AARCH64_PATH, EM_AARCH64), 0);
+ ASSERT_EQ(create_fake_elf(BIND_RISCV_PATH, EM_RISCV), 0);
+
+ /* Disabled, so it does not match and no format claims the binary. */
+ EXPECT_EQ(exec_errno(AARCH64_PATH), ENOEXEC);
+
+ ASSERT_EQ(activate("test_interp_bind"), 0);
+ EXPECT_EQ(check_output(AARCH64_PATH, BIND_EXPECT BIND_FIRST), 0);
+ EXPECT_EQ(check_output(BIND_RISCV_PATH, BIND_EXPECT BIND_SECOND), 0);
+}
+
+/* What was bound is what runs, whatever the path holds afterwards. */
+TEST_F(bound_interp, path_no_longer_decides)
+{
+ char other[PATH_MAX];
+
+ ASSERT_EQ(create_fake_elf(AARCH64_PATH, EM_AARCH64), 0);
+ ASSERT_EQ(activate("test_interp_bind"), 0);
+
+ /* Bound interpreters are pinned against writes, exactly like 'F'. */
+ EXPECT_TRUE(write_denied(BIND_FIRST));
+
+ /* Replace the path with a different binary: a new file, new inode. */
+ ASSERT_EQ(artifact_path(other, sizeof(other), "binfmt_bpf_interp"), 0);
+ ASSERT_EQ(unlink(BIND_FIRST), 0);
+ ASSERT_EQ(copy_file(other, BIND_FIRST), 0);
+
+ EXPECT_EQ(check_output(AARCH64_PATH, BIND_EXPECT BIND_FIRST), 0);
+}
+
+/* The entry reports what it bound, under the names it bound them as. */
+TEST_F(bound_interp, entry_reports_bindings)
+{
+ EXPECT_TRUE(entry_shows("test_interp_bind",
+ "bpf-interpreter first " BIND_FIRST));
+ EXPECT_TRUE(entry_shows("test_interp_bind",
+ "bpf-interpreter second " BIND_SECOND));
+}
+
+/* Selecting a name the entry did not bind fails the exec. */
+TEST_F(bound_interp, unbound_name_fails)
+{
+ ASSERT_EQ(create_fake_elf(BIND_ARM_PATH, EM_ARM), 0);
+ ASSERT_EQ(activate("test_interp_bind"), 0);
+
+ EXPECT_EQ(exec_errno(BIND_ARM_PATH), ENOENT);
+}
+
+/* Activating seals it: what can be matched cannot be changed. */
+TEST_F(bound_interp, sealed_once_active)
+{
+ ASSERT_EQ(activate("test_interp_bind"), 0);
+
+ EXPECT_EQ(entry_bind("test_interp_bind", "third", BIND_SECOND), -EBUSY);
+ EXPECT_FALSE(entry_shows("test_interp_bind",
+ "bpf-interpreter third " BIND_SECOND));
+}
+
+/* The seal is for good: disabling the entry again reopens nothing. */
+TEST_F(bound_interp, disable_does_not_unseal)
+{
+ ASSERT_EQ(activate("test_interp_bind"), 0);
+ ASSERT_EQ(entry_command("test_interp_bind", "0\n"), 0);
+
+ EXPECT_EQ(entry_bind("test_interp_bind", "third", BIND_SECOND), -EBUSY);
+}
+
+/* An entry registered without 'D' is sealed from the start. */
+TEST_F(bound_interp, born_sealed)
+{
+ /* A second entry for the handler the fixture already published. */
+ ASSERT_EQ(register_entry("test_born_sealed", "interp_bind", NULL), 0);
+
+ EXPECT_EQ(entry_bind("test_born_sealed", "first", BIND_FIRST), -EBUSY);
+ unregister("test_born_sealed");
+}
+
+/* A name is bound once; a second use of it is refused. */
+TEST_F(bound_interp, duplicate_name_refused)
+{
+ EXPECT_EQ(entry_bind("test_interp_bind", "first", BIND_SECOND), -EEXIST);
+}
+
+/* A name is a printable word: the entry file reports 'name path' lines. */
+TEST_F(bound_interp, name_must_be_printable)
+{
+ /* A control character would forge a line into the entry file. */
+ EXPECT_EQ(entry_bind("test_interp_bind", "a\tb", BIND_FIRST), -EINVAL);
+ EXPECT_EQ(entry_bind("test_interp_bind", "a\nb", BIND_FIRST), -EINVAL);
+
+ /* A space cannot even be spelled: the path starts after the first one. */
+ EXPECT_EQ(entry_bind("test_interp_bind", "a b", BIND_FIRST), -EINVAL);
+}
+
+/* The command ends at the write: bytes past an embedded nul are refused. */
+TEST_F(bound_interp, trailing_bytes_refused)
+{
+ char cmd[PATH_MAX];
+ size_t len;
+ int fd;
+
+ /* entry_command() cannot spell a nul, so write the buffer raw. */
+ snprintf(cmd, sizeof(cmd), "+nul %s", BIND_FIRST);
+ len = strlen(cmd) + 1;
+ memcpy(cmd + len, "junk", sizeof("junk"));
+ len += sizeof("junk");
+
+ fd = open(BINFMT_DIR "/test_interp_bind", O_WRONLY | O_CLOEXEC);
+ ASSERT_GE(fd, 0);
+ EXPECT_EQ(write(fd, cmd, len), -1);
+ EXPECT_EQ(errno, EINVAL);
+ close(fd);
+
+ EXPECT_FALSE(entry_shows("test_interp_bind",
+ "bpf-interpreter nul " BIND_FIRST));
+}
+
+/* An entry binds at most BIND_MAX interpreters. */
+TEST_F(bound_interp, capped_bindings)
+{
+ char name[16];
+ int i;
+
+ /* The fixture bound "first" and "second" already. */
+ for (i = 2; i < BIND_MAX; i++) {
+ snprintf(name, sizeof(name), "n%d", i);
+ ASSERT_EQ(entry_bind("test_interp_bind", name, BIND_FIRST), 0);
+ }
+ EXPECT_EQ(entry_bind("test_interp_bind", "over", BIND_FIRST), -ENOSPC);
+}
+
TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/exec/interp_bind.bpf.c b/tools/testing/selftests/exec/interp_bind.bpf.c
new file mode 100644
index 000000000000..1ce45cca215f
--- /dev/null
+++ b/tools/testing/selftests/exec/interp_bind.bpf.c
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * binfmt_misc_ops handler for the selftest's bound-interpreter case: one
+ * handler, one entry, an interpreter per guest architecture - each bound to
+ * a file when the entry was registered rather than to a path resolved at
+ * exec time. The load program names the one it wants; a name the entry did
+ * not bind fails the exec, which the harness checks too.
+ */
+#include "vmlinux.h"
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_tracing.h>
+
+char _license[] SEC("license") = "GPL";
+
+#define EI_CLASS 4
+#define ELFCLASS64 2
+#define E_MACHINE_OFF 18
+#define EM_ARM 40
+#define EM_AARCH64 183
+#define EM_RISCV 243
+
+extern int bpf_binprm_select_interp(struct linux_binprm *bprm,
+ const char *name, size_t name__sz) __ksym;
+
+/* The guest architecture of a 64-bit ELF, or zero if it is not one. */
+static __u16 elf_machine(struct linux_binprm *bprm)
+{
+ if (bprm->buf[0] != 0x7f || bprm->buf[1] != 'E' ||
+ bprm->buf[2] != 'L' || bprm->buf[3] != 'F' ||
+ bprm->buf[EI_CLASS] != ELFCLASS64)
+ return 0;
+
+ /* Little-endian 16-bit field, read byte-wise for the verifier. */
+ return (__u8)bprm->buf[E_MACHINE_OFF] |
+ ((__u16)(__u8)bprm->buf[E_MACHINE_OFF + 1] << 8);
+}
+
+SEC("struct_ops.s/match")
+bool BPF_PROG(interp_bind_match, struct linux_binprm *bprm)
+{
+ __u16 machine = elf_machine(bprm);
+
+ return machine == EM_AARCH64 || machine == EM_RISCV ||
+ machine == EM_ARM;
+}
+
+SEC("struct_ops.s/load")
+int BPF_PROG(interp_bind_load, struct linux_binprm *bprm)
+{
+ /*
+ * Names, not paths: each one selects a file the entry pre-opened, so
+ * nothing is resolved here or later, in any namespace. The buffers
+ * are on the stack because the verifier rejects .rodata for a sized
+ * memory argument.
+ */
+ char first[] = "first";
+ char second[] = "second";
+ char unbound[] = "unbound";
+
+ switch (elf_machine(bprm)) {
+ case EM_AARCH64:
+ return bpf_binprm_select_interp(bprm, first, sizeof(first));
+ case EM_RISCV:
+ return bpf_binprm_select_interp(bprm, second, sizeof(second));
+ }
+
+ /* The entry bound nothing under this name: -ENOENT fails the exec. */
+ return bpf_binprm_select_interp(bprm, unbound, sizeof(unbound));
+}
+
+SEC(".struct_ops.link")
+struct binfmt_misc_ops interp_bind = {
+ .match = (void *)interp_bind_match,
+ .load = (void *)interp_bind_load,
+ .name = "interp_bind",
+};
--
2.53.0
next prev parent reply other threads:[~2026-07-30 13:35 UTC|newest]
Thread overview: 10+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-30 13:34 [PATCH 0/9] binfmt_misc: bind interpreters to a bpf-backed entry Christian Brauner
2026-07-30 13:34 ` [PATCH 1/9] binfmt_misc: let a register string create an entry disabled Christian Brauner
2026-07-30 13:34 ` [PATCH 2/9] selftests/exec: let binfmt_flag_supported() return a bool Christian Brauner
2026-07-30 13:34 ` [PATCH 3/9] selftests/exec: test registering an entry disabled Christian Brauner
2026-07-30 13:34 ` [PATCH 4/9] binfmt_misc: document " Christian Brauner
2026-07-30 13:34 ` [PATCH 5/9] selftests/exec: share the bpf handler preconditions Christian Brauner
2026-07-30 13:34 ` [PATCH 6/9] binfmt_misc: carry pre-opened interpreters in struct binfmt_misc_interp Christian Brauner
2026-07-30 13:34 ` [PATCH 7/9] binfmt_misc: let a 'B' entry bind its interpreters Christian Brauner
2026-07-30 13:34 ` Christian Brauner [this message]
2026-07-30 13:34 ` [PATCH 9/9] binfmt_misc: document interpreters bound by a 'B' entry Christian Brauner
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=20260730-work-binfmt_misc-preopen-v1-8-4a0b0da71f16@kernel.org \
--to=brauner@kernel.org \
--cc=ast@kernel.org \
--cc=bpf@vger.kernel.org \
--cc=corbet@lwn.net \
--cc=daniel@iogearbox.net \
--cc=farid.m.zakaria@gmail.com \
--cc=jack@suse.cz \
--cc=jannh@google.com \
--cc=kees@kernel.org \
--cc=linux-fsdevel@vger.kernel.org \
--cc=linux-mm@kvack.org \
--cc=mail@johnericson.me \
--cc=viro@zeniv.linux.org.uk \
/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;
as well as URLs for NNTP newsgroup(s).