From 33376f24abbf6a23d5e3ab9ebdf0df664e893bc6 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:23:17 +0100 Subject: [PATCH] test: the attacker the device suite never had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit's sharpest finding was structural, not a bug: a fully green suite had hidden six real defects because it contains no attacker. Every device case asserts that a driver handed its own hardware can drive it. None asked what a process handed NOTHING can do. device-authority-test is that process. It is spawned with no device and asserts what it therefore cannot do: it cannot give away a device another task holds, nor a free one, because the kernel's rule is that you may give away what you hold and the device's state is irrelevant to a process holding nothing. Asserted across every device the machine actually has, so it cannot pass by accident of which one happened to be free at boot — six on QEMU, none of them its. A positive control runs first. device_enumerate works from this process, so the refusals below it are decisions rather than a syscall path that is simply broken here; without it, "everything failed" would read identically to "the assertions are meaningless". A nonexistent device is refused as NoSuchDevice rather than NotHeld, because a refusal that cannot name its own rule is what cost a debugging session on the Ryzen. What it deliberately does not assert, and says so in its header: device_claim is still first-come-first-served at this point in the run. That is the hole D6 closes, and the claim half of the invariant joins this fixture then. Asserting it now would be writing a test that documents the bug. Verified to discriminate: removing the holder check flips "every transfer by a non-holder is refused" while the positive control keeps passing. Suite 117 -> 118. --- build.zig | 1 + build.zig.zon | 1 + docs/bounds-track-plan.md | 2 +- system/kernel/tests.zig | 45 ++++++++++ test/qemu_test.py | 9 ++ .../services/device-authority-test/build.zig | 15 ++++ .../device-authority-test/build.zig.zon | 15 ++++ .../device-authority-test.zig | 87 +++++++++++++++++++ 8 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 test/system/services/device-authority-test/build.zig create mode 100644 test/system/services/device-authority-test/build.zig.zon create mode 100644 test/system/services/device-authority-test/device-authority-test.zig diff --git a/build.zig b/build.zig index 52c34ec..a3dc24d 100644 --- a/build.zig +++ b/build.zig @@ -342,6 +342,7 @@ pub fn build(b: *std.Build) void { "protocol-registry-test", // drives the registrar: ungranted bind, collision, restart "protocol-denied-test", // restriction stage one: an ungranted open answers as absence "protocol-conformance-test", // the reserved verbs, asked of every provider the boot bound + "device-authority-test", // the attacker: a process handed no device, asserting what it cannot do }) |fixture| { const package = b.lazyDependency(fixture, .{}) orelse @panic("a test fixture package is missing under test/system/services"); diff --git a/build.zig.zon b/build.zig.zon index bc568ab..6f7adc4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -79,6 +79,7 @@ .@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true }, .@"protocol-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true }, .@"protocol-conformance-test" = .{ .path = "test/system/services/protocol-conformance-test", .lazy = true }, + .@"device-authority-test" = .{ .path = "test/system/services/device-authority-test", .lazy = true }, // See `zig fetch --save ` for a command-line interface for adding dependencies. //.example = .{ // // When updating this field to a new URL, be sure to delete the corresponding diff --git a/docs/bounds-track-plan.md b/docs/bounds-track-plan.md index bd4c2f9..83da3ec 100644 --- a/docs/bounds-track-plan.md +++ b/docs/bounds-track-plan.md @@ -40,7 +40,7 @@ that cannot safely run in user space.** | Step | What | State | |---|---|---| | D1 | `device_transfer(device_id, task_id)` — the holder gives a device away | **done** — syscall 54; a move, not a copy | -| D2 | Adversarial case: a process handed nothing is refused, on a held device and a free one | not started | +| D2 | Adversarial case: a process handed nothing is refused, on a held device and a free one | **done** — `device-authority-test`; the claim half joins it at D6 | | D3 | The manager claims the seeded devices at boot, before any driver is spawned | not started | | D4 | `usb-xhci-bus` receives its controller in the `hello` reply instead of claiming argv[1] | not started | | D5 | The other four claimants converted: `pci-bus`, `ps2-bus`, `virtio-gpu`, `acpi` | not started | diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index f4ea81e..d1849f0 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -263,6 +263,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { apertureTest(); } else if (eql(case, "device-transfer")) { deviceTransferTest(boot_information); + } else if (eql(case, "device-authority")) { + deviceAuthorityTest(boot_information); } else if (eql(case, "device-manager")) { deviceManagerTest(boot_information); } else if (eql(case, "protocol-registry")) { @@ -4219,6 +4221,49 @@ fn protocolRegistryTest(boot_information: *const BootInformation) void { /// /// The fixture's `protocol-denied: ok` is the marker; each step prints its own /// line, which the harness's ordered regex reads. +/// The attacker the device suite never had. The audit's finding was that a fully +/// green suite had missed six real defects because it *contains no attacker* — every +/// device case asserts a driver handed its hardware can drive it, and none asks what a +/// process handed **nothing** can do. +/// +/// The fixture is spawned with no device and asserts what it therefore cannot do. It +/// runs without the device manager on purpose: nothing here needs a driver, and a boot +/// with fewer moving parts makes the refusals unambiguous. +fn deviceAuthorityTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: device-authority\n", .{}); + if (boot_information.initial_ramdisk_len == 0) { + check("bootloader handed over an initial_ramdisk", false); + result(); + return; + } + const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len]; + const rd = initial_ramdisk.Reader.init(image) orelse { + check("initial_ramdisk image is valid", false); + result(); + return; + }; + + process.setInitialRamdisk(image); + check("device-authority-test spawned", spawnNamedWithArg(rd, "device-authority-test", "run")); + + const pass_marker = "device-authority: ok"; + const fail_marker = "device-authority: FAIL"; + scheduler.setPriority(1); + const deadline = architecture.millis() + 20000; + var saw_pass = false; + var saw_fail = false; + while (architecture.millis() < deadline and !saw_pass and !saw_fail) { + if (bufferHas(pass_marker)) saw_pass = true; + if (bufferHas(fail_marker)) saw_fail = true; + scheduler.yield(); + } + scheduler.setPriority(4); + + check("no authority assertion failed", !saw_fail); + check("the attacker completed every assertion", saw_pass); + result(); +} + fn protocolDeniedTest(boot_information: *const BootInformation) void { log("DANOS-TEST-BEGIN: protocol-denied\n", .{}); if (boot_information.initial_ramdisk_len == 0) { diff --git a/test/qemu_test.py b/test/qemu_test.py index c9516ce..b8b14af 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -1029,6 +1029,15 @@ CASES = [ {"name": "device-transfer", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # The attacker the device suite never had. The audit's finding was that a fully + # green suite missed six real defects because it contains no attacker: every device + # case asserts a driver handed its hardware can drive it, and none asks what a + # process handed NOTHING can do. This fixture is that process - it holds no device + # and asserts it can give none away, with a positive control first so the refusals + # are decisions rather than a broken syscall path. + {"name": "device-authority", + "expect": r"DANOS-TEST-RESULT: PASS", + "fail": r"DANOS-TEST-RESULT: FAIL"}, # IRQ teardown: an exiting driver's line is masked and its slot cleared (so no # ISR notifies a freed endpoint), and a sibling owner sharing that endpoint # keeps its own binding. A long-running driver never reaches this teardown path. diff --git a/test/system/services/device-authority-test/build.zig b/test/system/services/device-authority-test/build.zig new file mode 100644 index 0000000..1b19cfd --- /dev/null +++ b/test/system/services/device-authority-test/build.zig @@ -0,0 +1,15 @@ +//! The device-authority-test fixture as a binary package (docs/build-packages-plan.md): +//! this file names the binary and EXACTLY the modules its source imports — +//! build-support resolves each name from the domains this zon declares. + +const std = @import("std"); +const build_support = @import("build-support"); + +pub fn build(b: *std.Build) void { + const exe = build_support.userBinary(b, .{ + .name = "device-authority-test", + .root_source_file = b.path("device-authority-test.zig"), + .imports = &.{ "driver", "logging", "process" }, + }); + b.installArtifact(exe); +} diff --git a/test/system/services/device-authority-test/build.zig.zon b/test/system/services/device-authority-test/build.zig.zon new file mode 100644 index 0000000..08eed1e --- /dev/null +++ b/test/system/services/device-authority-test/build.zig.zon @@ -0,0 +1,15 @@ +.{ + .name = .device_authority_test, + .version = "0.0.0", + .fingerprint = 0x4acbba0c105a1462, // Changing this has security and trust implications. + .minimum_zig_version = "0.16.0", + .dependencies = .{ + // build-support supplies the shared recipe; kernel is implicit in + // every binary (the root shim + link script live there). The rest + // are exactly the homes of this binary's declared imports. + .@"build-support" = .{ .path = "../../../../build-support" }, + .kernel = .{ .path = "../../../../library/kernel" }, + .device = .{ .path = "../../../../library/device" }, + }, + .paths = .{""}, +} diff --git a/test/system/services/device-authority-test/device-authority-test.zig b/test/system/services/device-authority-test/device-authority-test.zig new file mode 100644 index 0000000..ee965b3 --- /dev/null +++ b/test/system/services/device-authority-test/device-authority-test.zig @@ -0,0 +1,87 @@ +//! device-authority-test — the attacker the device suite never had. +//! +//! The audit behind [docs/fixed-bounds-audit.md] found six real defects that a +//! fully green suite had missed, and the reason was structural: *the suite +//! contains no attacker*. Every device case asserts that a driver handed its +//! own hardware can drive it. None asks what a process that was handed +//! **nothing** can do. +//! +//! This binary is that process. It is spawned with no device, holds no device, +//! and asserts what it therefore cannot do +//! ([docs/os-development/device-authority.md]): +//! +//! 1. **A positive control first.** `device_enumerate` works from here, so +//! the refusals below are decisions rather than a syscall path that is +//! simply broken for this process. Without this, "everything failed" would +//! read identically to "the assertions are meaningless". +//! 2. **It cannot give away a device it does not hold** — not one another +//! task holds, and not a free one either. The kernel's whole rule is *you +//! may give away what you hold*, so the state of the device is irrelevant: +//! a process holding nothing can transfer nothing. That is asserted across +//! several ids precisely so it cannot pass by accident of which device +//! happened to be free at boot. +//! 3. **A device that does not exist is refused differently** — `NoSuchDevice` +//! rather than `NotHeld`. A refusal that cannot say which rule refused it +//! is what cost a debugging session on the Ryzen, so the distinction is +//! part of the contract and is tested as such. +//! +//! **What this fixture cannot yet claim.** `device_claim` is still +//! first-come-first-served at this point in the run — that is the hole D6 +//! closes. So the claim half of the invariant ("a process holds what it was +//! handed and cannot name its way into holding more") is deliberately NOT +//! asserted here; it is added to this fixture at D6, when it becomes true. +//! Asserting it now would mean writing a test that documents the bug. + +const std = @import("std"); +const device = @import("driver"); +const logging = @import("logging"); +const process = @import("process"); + +fn line(comptime format: []const u8, arguments: anytype) void { + var buffer: [160]u8 = undefined; + _ = logging.write(std.fmt.bufPrint(&buffer, format, arguments) catch return); +} + +var failures: usize = 0; + +fn check(name: []const u8, ok: bool) void { + if (!ok) failures += 1; + line("device-authority: {s} {s}\n", .{ if (ok) "ok" else "FAIL", name }); +} + +fn run() void { + // 1. The positive control: this process can reach the device syscalls at all. + var table: [64]device.DeviceDescriptor = undefined; + const total = device.enumerate(&table); + check("enumerate works from an unprivileged process", total > 0); + const seen = @min(total, table.len); + + // 2. Holding nothing, it can give nothing away — whatever the device's state. + // Every id the machine actually has, so this cannot pass by luck. + var refused: usize = 0; + var wrong_reason: usize = 0; + for (table[0..seen]) |descriptor| { + device.transfer(descriptor.id, process.taskId()) catch |e| { + refused += 1; + if (e != error.NotHeld) wrong_reason += 1; + continue; + }; + } + check("every transfer by a non-holder is refused", refused == seen); + check("each refusal says NotHeld, not something vaguer", wrong_reason == 0); + + // 3. A device that does not exist is a different refusal, and says so. + const absent = if (device.transfer(0xFFFF_FFFF, process.taskId())) |_| false else |e| e == error.NoSuchDevice; + check("a device that does not exist is refused as absent", absent); + + if (failures == 0) { + line("device-authority: ok ({d} devices, none of them mine)\n", .{seen}); + } else { + line("device-authority: FAILED {d} assertion(s)\n", .{failures}); + } +} + +pub fn main(startup: process.Init) void { + const role = startup.arguments.get(1) orelse return; // bare (ramdisk sweep): stay silent + if (std.mem.eql(u8, role, "run")) run(); +}