]> git.ipfire.org Git - thirdparty/linux.git/commitdiff
selftests/exec: test interpreters bound to a 'B' entry
authorChristian Brauner <brauner@kernel.org>
Thu, 30 Jul 2026 13:34:10 +0000 (15:34 +0200)
committerChristian Brauner <brauner@kernel.org>
Mon, 3 Aug 2026 17:15:44 +0000 (19:15 +0200)
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.

Link: https://patch.msgid.link/20260730-work-binfmt_misc-preopen-v1-8-4a0b0da71f16@kernel.org
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
tools/testing/selftests/exec/Makefile
tools/testing/selftests/exec/binfmt_bind_interp.c [new file with mode: 0644]
tools/testing/selftests/exec/binfmt_misc_bpf.c
tools/testing/selftests/exec/interp_bind.bpf.c [new file with mode: 0644]

index ec7894a802e0c2eb36ba2ab900391e5c2fae3608..410c93606a0c3bb5de48f558f91371bf5b28eab0 100644 (file)
@@ -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 (file)
index 0000000..06d6506
--- /dev/null
@@ -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;
+}
index 71bb6d8b4517a700c64412eb226f5f73a85f7cc2..2c7b63075f1d97be35f3534c56e02b41d84044ef 100644 (file)
@@ -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
  *      (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.
 #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 @@ fail:
        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 (file)
index 0000000..1ce45cc
--- /dev/null
@@ -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",
+};