Files
Daniel Samson df9c1ed827 device-manager: hold the seeded hardware so none is left lying around
A device nobody holds can be claimed by anyone, so the manager now takes
every firmware-discovered device that carries mappable resources, whether or
not a driver wants it. The real gap was the HPET: an MMIO window, an IRQ, no
user-space driver, and there for the taking. Held by the manager it is
inert; unheld it was a way into physical memory.

Two deliberate exclusions. The loader's framebuffer, which the compositor
claims and which the manager must not take because it starts first. And
anything with no resources, which grants nothing worth holding.

Scope is the boot snapshot. A device reported later and matched to no driver
stays claimable — pci-cap-test and iommu-fault-test both reach an unmatched
NIC that way, so narrowing it is a separate change with those fixtures in
scope. Recorded in the plan rather than left implied.

The attacker fixture gains the assertion deferred since D2: after the system
settles, nothing with resources may be taken.

That assertion defeated itself twice before it worked, and both failures are
worth remembering. First it swept at 0.029 while the manager did not bind
its protocol until 0.047, so it reported a hole that closed a millisecond
later. The retry loop that "fixed" that was worse: the first pass TAKES the
device, so the second finds it unavailable because this process now holds
it, and concludes all is well — it passed with the manager's claiming
removed entirely. It now settles once and sweeps once, and fails when the
claiming is removed.

Suite 118/118.
2026-08-08 22:42:42 +01:00

125 lines
6.5 KiB
Zig

//! 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.
//!
//! **Why there is no "cannot take a delegated device" assertion here.** The
//! hole this fixture was written for is closed, but not by a refusal it could
//! observe. A device that was given to someone is *held*, so an attempt to
//! take it is refused as `AlreadyClaimed` — the same answer as before. What
//! changed is what happens when the holder dies: the device returns to
//! whoever lent it instead of becoming free, so the window in which a
//! stranger could take it no longer exists. There is no moment to catch.
const std = @import("std");
const device = @import("driver");
const logging = @import("logging");
const process = @import("process");
const time = @import("time");
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;
var process_table: [64]process.ProcessDescriptor = undefined;
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);
// 4. **The spawn is not a second way in.** A device now rides system_spawn, which
// would be a fine back door if the kernel checked ownership any less carefully
// there than it does in transfer: spawn a child, name someone else's device, and
// the child holds hardware nobody gave it. The refusal must happen before the
// child exists, so nothing is left running either.
if (seen != 0) {
const before = process.processes(&process_table);
const spawned = process.spawnSupervisedWithDevice("/test/system/services/device-authority-test", &.{}, null, table[0].id);
check("spawning with a device the caller does not hold is refused", spawned == null);
check("and no child was left behind by the refusal", process.processes(&process_table) == before);
}
// 5. **Nothing with mappable resources is left lying around.** A device nobody
// holds can be claimed by anyone, so the manager takes every seeded device that
// carries resources — the HPET above all, which has an MMIO window and an IRQ
// and no user-space driver. The one exception is the loader's framebuffer,
// which the compositor claims. So from here, a resource-bearing device should
// refuse to be taken, and the reason should be that someone already has it.
// Settle first, then sweep **once**. The manager is still starting when this
// fixture is spawned, so an immediate sweep finds hardware unheld and reports a
// hole that closes a millisecond later. Retrying until the sweep comes back
// empty is worse than useless: the first pass *takes* the device, so the second
// finds it unavailable — because this process now holds it — and concludes all
// is well. One sweep, after a wait long enough for the manager to have claimed.
time.sleepMillis(1500);
var takeable: usize = 0;
for (table[0..seen]) |descriptor| {
if (descriptor.resource_count == 0) continue;
if (descriptor.class == @intFromEnum(device.DeviceClass.display)) continue;
device.claim(descriptor.id) catch continue; // refused, as it should be
takeable += 1;
}
check("no resource-bearing device is left for the taking", takeable == 0);
if (failures == 0) {
line("device-authority: VERDICT ok ({d} devices, none of them mine)\n", .{seen});
} else {
line("device-authority: VERDICT 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();
}