From 477911df97ed9cefb8172fa9eb217c78268936d7 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 1 Oct 2026 12:33:35 -0300 Subject: [PATCH] kernel: lockdown at confidentiality and the BPF LSM The guest's root is not the kernel's. The kernel runs lockdown, forced to confidentiality, and the BPF LSM, and builds neither /dev/mem nor /proc/kcore: root can neither read nor rewrite the kernel, and a consumer can enforce policy in the guest with BPF LSM programs it loads before the tenant's init. kernel/Dockerfile fails a build whose config lost any of it after olddefconfig. `task boot:lockdown` asks a booted guest for its LSMs and its lockdown level, and checks root cannot lower it. The logind check now shares that test's boot. No boot cost that can be told from noise (kernel/README.md). Co-Authored-By: Claude Opus 5.5 (1M context) --- boot/Taskfile.yml | 9 +++ boot/console_test.go | 102 ++++++++++++++++++++++++++++++++ boot/lockdown_test.go | 32 ++++++++++ boot/logind_test.go | 89 ++-------------------------- boot/testdata/lockdown-check.sh | 24 ++++++++ kernel/Dockerfile | 15 +++++ kernel/README.md | 29 ++++++++- kernel/config-7.3-rc5-x86_64 | 16 +++-- 8 files changed, 223 insertions(+), 93 deletions(-) create mode 100644 boot/console_test.go create mode 100644 boot/lockdown_test.go create mode 100644 boot/testdata/lockdown-check.sh diff --git a/boot/Taskfile.yml b/boot/Taskfile.yml index f7280a8..df58bed 100644 --- a/boot/Taskfile.yml +++ b/boot/Taskfile.yml @@ -69,6 +69,15 @@ tasks: cmds: - SPIN_LOGIND_TEST=1 go test ./boot/ -run '^TestLogindSessions$' -count=1 -v -timeout 5m + lockdown: + desc: >- + Check the guest kernel runs lockdown at confidentiality and the BPF LSM, and has no + /dev/mem or /proc/kcore, in a disposable guest. Needs a built release; uses KVM when + available, otherwise TCG. + deps: [':tools'] + cmds: + - SPIN_LOCKDOWN_TEST=1 go test ./boot/ -run '^TestTheGuestKernelIsLockedDown$' -count=1 -v -timeout 5m + initcalls: desc: >- Where the kernel's own boot goes, initcall by initcall, as a p50 over REPS boots. diff --git a/boot/console_test.go b/boot/console_test.go new file mode 100644 index 0000000..d1ecc48 --- /dev/null +++ b/boot/console_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package boot_test + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spin-stack/spin-machine/machine" +) + +// checkOnConsole boots a disposable 1 GiB guest of rel on root, whose unit prints ok or failed on +// the serial console, and fails the test unless ok comes first. It returns what the console said. +// Under TCG where there is no /dev/kvm: what it checks is behaviour, not time. +func checkOnConsole(t *testing.T, out string, rel *machine.Release, root *rawRoot, ok, failed string) string { + t.Helper() + spec := rel.Spec() + spec.BootCPUs = 2 + spec.Memory.SizeMB = 1024 + spec.Disks = []machine.Disk{{Path: root.path, Format: "raw"}} + spec.Serial = "stdio" + c := machine.DefaultCmdline() + c.Root = "/dev/vda" + c.Init = "/sbin/init" + spec.Cmdline = c + args, err := spec.Args() + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat("/dev/kvm"); err != nil { + spec.QEMU = filepath.Join(out, "bin/qemu-system-x86_64-tcg") + for i := range args { + if args[i] == "-accel" { + args[i+1] = "tcg" + } + if strings.HasPrefix(args[i], "host,migratable=on") { + args[i] = "max" + strings.TrimPrefix(args[i], "host") + } + } + t.Log("TCG: checking functionality only") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, spec.QEMU, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { + cancel() + _ = cmd.Wait() // killed by the cancel above; the console is the result + }() + var console bytes.Buffer + buf := make([]byte, 4096) + for { + n, err := stdout.Read(buf) + console.Write(buf[:n]) + if strings.Contains(console.String(), ok) { + return console.String() + } + if err != nil || strings.Contains(console.String(), failed) { + t.Fatalf("guest check failed: %v\n%s\n%s", err, &console, &stderr) + } + } +} + +// oneshotAtBoot has root run script as a oneshot unit three seconds after boot, its output on +// the console. +func oneshotAtBoot(t *testing.T, root *rawRoot, name, script, env string) { + t.Helper() + content, err := os.ReadFile(filepath.Join("testdata", script)) + if err != nil { + t.Fatal(err) + } + root.write("/"+script, string(content)) + root.write("/etc/systemd/system/"+name+".service", `[Unit] +Description=A check in a disposable guest +After=multi-user.target +[Service] +Type=oneshot +`+env+` +ExecStart=/bin/sh /`+script+` +StandardOutput=journal+console +StandardError=journal+console +`) + root.write("/etc/systemd/system/"+name+".timer", `[Timer] +OnBootSec=3s +AccuracySec=100ms +`) + root.link("/etc/systemd/system/timers.target.wants/"+name+".timer", "/etc/systemd/system/"+name+".timer") +} diff --git a/boot/lockdown_test.go b/boot/lockdown_test.go new file mode 100644 index 0000000..d01dc5f --- /dev/null +++ b/boot/lockdown_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package boot_test + +import ( + "os" + "testing" + + "github.com/spin-stack/spin-machine/machine" +) + +// The guest's root is not the kernel's: the kernel runs lockdown at confidentiality and the BPF +// LSM, and has no /dev/mem and no /proc/kcore. A consumer that enforces policy in the guest +// with BPF LSM programs relies on root being unable to read or rewrite the kernel they live in; +// kernel/Dockerfile fails a build whose config lost these, and this asks the kernel that boots. +func TestTheGuestKernelIsLockedDown(t *testing.T) { + if os.Getenv("SPIN_LOCKDOWN_TEST") != "1" { + t.Skip("set SPIN_LOCKDOWN_TEST=1 to check the guest kernel's lockdown in a built image") + } + out := releaseTree(t) + rel, err := machine.OpenRelease(out) + if err != nil { + t.Fatal(err) + } + base, err := rel.Rootfs() + if err != nil { + t.Fatal(err) + } + root := newRawRoot(t, out, base) + oneshotAtBoot(t, root, "lockdown-check", "lockdown-check.sh", "") + t.Log(checkOnConsole(t, out, rel, root, "LOCKDOWN_CHECK_OK", "LOCKDOWN_CHECK_FAILED")) +} diff --git a/boot/logind_test.go b/boot/logind_test.go index e9f1ba6..0d0b310 100644 --- a/boot/logind_test.go +++ b/boot/logind_test.go @@ -3,14 +3,8 @@ package boot_test import ( - "bytes" - "context" "os" - "os/exec" - "path/filepath" - "strings" "testing" - "time" "github.com/spin-stack/spin-machine/machine" ) @@ -45,86 +39,11 @@ func TestLogindSessions(t *testing.T) { if os.Getenv("SPIN_LOGIND_NO_SEATS") == "1" { root.remove("/etc/systemd/system/systemd-logind-varlink.socket.d/10-seats.conf") } - for _, name := range []string{"logind-check.sh", "logind-session.sh"} { - content, err := os.ReadFile(filepath.Join("testdata", name)) - if err != nil { - t.Fatal(err) - } - root.write("/"+name, string(content)) - } - root.write("/etc/systemd/system/logind-check.service", `[Unit] -Description=Check on-demand sessions in a disposable guest -After=multi-user.target -[Service] -Type=oneshot -Environment=LOGIND_EXPECT=`+expectedState+` -ExecStart=/bin/sh /logind-check.sh -StandardOutput=journal+console -StandardError=journal+console -`) - root.write("/etc/systemd/system/logind-check.timer", `[Timer] -OnBootSec=3s -AccuracySec=100ms -`) - root.link("/etc/systemd/system/timers.target.wants/logind-check.timer", "/etc/systemd/system/logind-check.timer") - spec := rel.Spec() - spec.BootCPUs = 2 - spec.Memory.SizeMB = 1024 - spec.Disks = []machine.Disk{{Path: root.path, Format: "raw"}} - spec.Serial = "stdio" - c := machine.DefaultCmdline() - c.Root = "/dev/vda" - c.Init = "/sbin/init" - spec.Cmdline = c - args, err := spec.Args() - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat("/dev/kvm"); err != nil { - spec.QEMU = filepath.Join(out, "bin/qemu-system-x86_64-tcg") - for i := range args { - if args[i] == "-accel" { - args[i+1] = "tcg" - } - if strings.HasPrefix(args[i], "host,migratable=on") { - args[i] = "max" + strings.TrimPrefix(args[i], "host") - } - } - t.Log("TCG: checking functionality only") - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - cmd := exec.CommandContext(ctx, spec.QEMU, args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - stdout, err := cmd.StdoutPipe() + content, err := os.ReadFile("testdata/logind-session.sh") if err != nil { t.Fatal(err) } - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - waited := false - stop := func() { - cancel() - if !waited { - _ = cmd.Wait() - waited = true - } - } - defer stop() - var console bytes.Buffer - buf := make([]byte, 4096) - for { - n, err := stdout.Read(buf) - console.Write(buf[:n]) - if strings.Contains(console.String(), "LOGIND_CHECK_OK") { - t.Log(console.String()) - return - } - if err != nil || strings.Contains(console.String(), "LOGIND_CHECK_FAILED") { - stop() - t.Fatalf("guest session check failed: %v\n%s\n%s", err, &console, &stderr) - } - } + root.write("/logind-session.sh", string(content)) + oneshotAtBoot(t, root, "logind-check", "logind-check.sh", "Environment=LOGIND_EXPECT="+expectedState) + t.Log(checkOnConsole(t, out, rel, root, "LOGIND_CHECK_OK", "LOGIND_CHECK_FAILED")) } diff --git a/boot/testdata/lockdown-check.sh b/boot/testdata/lockdown-check.sh new file mode 100644 index 0000000..29c9b03 --- /dev/null +++ b/boot/testdata/lockdown-check.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +set -eu +trap 'echo LOCKDOWN_CHECK_FAILED' EXIT + +# The LSMs the kernel runs, in its own words, and the lockdown level it enforces: the brackets +# mark the one in force. +echo "lsm: $(cat /sys/kernel/security/lsm)" +echo "lockdown: $(cat /sys/kernel/security/lockdown)" +# capability is always there, whatever CONFIG_LSM says: the kernel puts it first among the rest. +test "$(cat /sys/kernel/security/lsm)" = "lockdown,capability,bpf" +grep -q '\[confidentiality\]' /sys/kernel/security/lockdown + +# Root cannot read the kernel's memory, nor lower the level. +test ! -e /dev/mem +test ! -e /proc/kcore +if echo none > /sys/kernel/security/lockdown 2>/dev/null; then + echo "root lowered lockdown" + exit 1 +fi +grep -q '\[confidentiality\]' /sys/kernel/security/lockdown + +trap - EXIT +echo LOCKDOWN_CHECK_OK diff --git a/kernel/Dockerfile b/kernel/Dockerfile index 78f6ed8..18cfa05 100644 --- a/kernel/Dockerfile +++ b/kernel/Dockerfile @@ -230,6 +230,21 @@ RUN <