Delegation moves out of onHello and into the spawn itself. The manager holds the hardware and names it in the call that creates the driver; the kernel checks the device is the caller's to give, then hands it over as part of making the child. The reason is the window. A transfer after spawning always leaves an interval in which the child is running and does not yet hold its device. It would close on QEMU every time and open occasionally on a machine with different core counts and timing — the exact failure shape this track exists to delete, and not one worth introducing while removing the others. Fused into the spawn there is no interval: the child does not exist until it holds the device. Ownership is checked BEFORE the child is created, so a refusal leaves nothing running rather than a driver without the hardware it was spawned for. The IOMMU confinement moves with the device, as it does on the transfer path. systemCall6 is added for the sixth argument; r9 was free, and abi gains a no_device sentinel matching the protocol's. No driver had to change to receive a device, which is what makes this better than requiring every driver to hello: ps2-bus keeps its legacy status, and discovery — which has no assignment at all, since it is what produces the device tree — is unaffected. The attacker fixture now tries the spawn as a back door: name someone else's device, and both the spawn and any child must be refused. Verifying that assertion exposed a bug in the fixture itself. The kernel case's pass marker was "device-authority: ok", which matches the FIRST per-assertion line, so its wait loop exited before any failure was printed — the case would have passed with failures in it, and had been able to since D2. The verdict lines now carry a distinct VERDICT prefix, and with the ownership check removed the case genuinely fails. A green test that cannot go red is worse than no test. Suite 118/118.
101 lines
4.9 KiB
Zig
101 lines
4.9 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.
|
|
//!
|
|
//! **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;
|
|
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);
|
|
}
|
|
|
|
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();
|
|
}
|