selftests/exec: test interpreters bound to a 'B' entry

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>
This commit is contained in:
Christian Brauner
2026-07-30 15:34:10 +02:00
parent 6ec7c96bee
commit 7404b1472b
4 changed files with 349 additions and 9 deletions

View 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 $@

View File

@@ -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;
}

View 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
@@ -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

View File

@@ -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",
};