From df9c1ed82743977e1a98ee05e7e8a1236fbaebed Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:42:42 +0100 Subject: [PATCH] device-manager: hold the seeded hardware so none is left lying around MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/bounds-track-plan.md | 15 ++++++++++-- system/kernel/tests.zig | 5 ++++ .../device-manager/device-manager.zig | 22 ++++++++++++++++++ .../services/device-authority-test/build.zig | 2 +- .../device-authority-test.zig | 23 +++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/bounds-track-plan.md b/docs/bounds-track-plan.md index c57192c..2e37820 100644 --- a/docs/bounds-track-plan.md +++ b/docs/bounds-track-plan.md @@ -135,14 +135,25 @@ giver, and its comment says so rather than implying a protection it is not provi | E1 | Record a giver per device; `device_transfer` and the spawn grant set it — **done** | | E2 | On task death a device reverts to its giver if alive, else its claim clears — **done** | | E3 | `device_claim` refuses a device that has a giver — **done**, but unreachable: E2 already closed the window | -| E4 | The manager claims every resource-bearing device at boot, so nothing is left takeable | -| E5 | The attacker fixture gains the claim half it has been waiting for since D2 | +| E4 | The manager claims every resource-bearing device at boot, so nothing is left takeable — **done** (boot snapshot only; see below) | +| E5 | The attacker fixture gains the claim half it has been waiting for since D2 — **done with E4** | | E6 | Delete the delegated-set scaffolding — every driver is delegated now | Ordering: E1 alone changes no behaviour. E2 must precede E3, or a restart cannot re-acquire. E4 must precede E5, or the attacker will find takeable devices and the assertion will be wrong about why. E6 is cleanup. +### E4's scope, stated + +It covers the **boot snapshot**. A device *reported* later and matched to no driver +stays claimable — `pci-cap-test` and `iommu-fault-test` both rely on that to reach an +unmatched NIC. Narrowing it further is a separate change with those fixtures in scope. + +The real gap it closed was the **HPET**: an MMIO window, an IRQ, no user-space driver, +and claimable by anyone. Excluded on purpose: the loader's framebuffer (the manager +starts before display, so taking it would break the boot screen) and anything with no +resources, which grants nothing. + ### Not in this run, and not blocking it **Zero-resource devices stay in the kernel.** Moving them out needs an answer to who diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 7681ce4..7d03c72 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -4334,6 +4334,11 @@ fn deviceAuthorityTest(boot_information: *const BootInformation) void { }; process.setInitialRamdisk(image); + // The manager must be up: it is what holds the seeded hardware, and without it + // every device would be lying around unheld and the last assertion would have + // nothing to observe — a test that cannot fail. + check("registry (init) spawned", spawnRegistry(rd)); + check("device-manager spawned", spawnNamed(rd, "device-manager")); check("device-authority-test spawned", spawnNamedWithArg(rd, "device-authority-test", "run")); // The VERDICT prefix matters: the fixture prints one "device-authority: ok " diff --git a/system/services/device-manager/device-manager.zig b/system/services/device-manager/device-manager.zig index a374cff..daf0c19 100644 --- a/system/services/device-manager/device-manager.zig +++ b/system/services/device-manager/device-manager.zig @@ -433,6 +433,28 @@ fn initialise(endpoint: ipc.Handle) bool { } } + // **Hold the firmware-discovered hardware, so none of it is left lying around.** + // A device nobody holds can be claimed by anyone, so every seeded device that + // carries mappable resources is taken here whether or not a driver wants it — the + // HPET most of all, which has an MMIO window and an IRQ and no user-space driver. + // Held by the manager it is inert; unheld it was there for the taking. + // + // Two deliberate exclusions: + // - the loader's framebuffer, which the compositor claims and which is not + // hardware anyone is delegated (the manager starts before display, so taking + // it here would break the boot screen); + // - anything with no resources, which grants nothing and so is not worth holding. + // + // This covers the boot snapshot only. A device *reported* later and matched to no + // driver stays claimable — the pci-cap and iommu-fault fixtures rely on exactly + // that to reach an unmatched NIC. Narrowing it further is a separate change with + // those fixtures in scope. + for (buffer[0..count]) |descriptor| { + if (descriptor.resource_count == 0) continue; + if (descriptor.class == @intFromEnum(device.DeviceClass.display)) continue; + device.claim(descriptor.id) catch continue; // already held, or not ours to take + } + var matched: usize = 0; for (buffer[0..count]) |descriptor| { if (descriptor.class == @intFromEnum(device.DeviceClass.pci_host_bridge)) { diff --git a/test/system/services/device-authority-test/build.zig b/test/system/services/device-authority-test/build.zig index 1b19cfd..b5dabc0 100644 --- a/test/system/services/device-authority-test/build.zig +++ b/test/system/services/device-authority-test/build.zig @@ -9,7 +9,7 @@ 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" }, + .imports = &.{ "driver", "logging", "process", "time" }, }); b.installArtifact(exe); } diff --git a/test/system/services/device-authority-test/device-authority-test.zig b/test/system/services/device-authority-test/device-authority-test.zig index 8dc10f5..01fd317 100644 --- a/test/system/services/device-authority-test/device-authority-test.zig +++ b/test/system/services/device-authority-test/device-authority-test.zig @@ -37,6 +37,7 @@ 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; @@ -88,6 +89,28 @@ fn run() void { 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 {