test: the attacker the device suite never had
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.
This commit is contained in:
@@ -342,6 +342,7 @@ pub fn build(b: *std.Build) void {
|
|||||||
"protocol-registry-test", // drives the registrar: ungranted bind, collision, restart
|
"protocol-registry-test", // drives the registrar: ungranted bind, collision, restart
|
||||||
"protocol-denied-test", // restriction stage one: an ungranted open answers as absence
|
"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
|
"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| {
|
}) |fixture| {
|
||||||
const package = b.lazyDependency(fixture, .{}) orelse
|
const package = b.lazyDependency(fixture, .{}) orelse
|
||||||
@panic("a test fixture package is missing under test/system/services");
|
@panic("a test fixture package is missing under test/system/services");
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
.@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true },
|
.@"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-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true },
|
||||||
.@"protocol-conformance-test" = .{ .path = "test/system/services/protocol-conformance-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 <url>` for a command-line interface for adding dependencies.
|
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
|
||||||
//.example = .{
|
//.example = .{
|
||||||
// // When updating this field to a new URL, be sure to delete the corresponding
|
// // When updating this field to a new URL, be sure to delete the corresponding
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ that cannot safely run in user space.**
|
|||||||
| Step | What | State |
|
| Step | What | State |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| D1 | `device_transfer(device_id, task_id)` — the holder gives a device away | **done** — syscall 54; a move, not a copy |
|
| 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 |
|
| 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 |
|
| 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 |
|
| D5 | The other four claimants converted: `pci-bus`, `ps2-bus`, `virtio-gpu`, `acpi` | not started |
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
|
|||||||
apertureTest();
|
apertureTest();
|
||||||
} else if (eql(case, "device-transfer")) {
|
} else if (eql(case, "device-transfer")) {
|
||||||
deviceTransferTest(boot_information);
|
deviceTransferTest(boot_information);
|
||||||
|
} else if (eql(case, "device-authority")) {
|
||||||
|
deviceAuthorityTest(boot_information);
|
||||||
} else if (eql(case, "device-manager")) {
|
} else if (eql(case, "device-manager")) {
|
||||||
deviceManagerTest(boot_information);
|
deviceManagerTest(boot_information);
|
||||||
} else if (eql(case, "protocol-registry")) {
|
} 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
|
/// The fixture's `protocol-denied: ok` is the marker; each step prints its own
|
||||||
/// line, which the harness's ordered regex reads.
|
/// 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 {
|
fn protocolDeniedTest(boot_information: *const BootInformation) void {
|
||||||
log("DANOS-TEST-BEGIN: protocol-denied\n", .{});
|
log("DANOS-TEST-BEGIN: protocol-denied\n", .{});
|
||||||
if (boot_information.initial_ramdisk_len == 0) {
|
if (boot_information.initial_ramdisk_len == 0) {
|
||||||
|
|||||||
@@ -1029,6 +1029,15 @@ CASES = [
|
|||||||
{"name": "device-transfer",
|
{"name": "device-transfer",
|
||||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
"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
|
# 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
|
# 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.
|
# keeps its own binding. A long-running driver never reaches this teardown path.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 = .{""},
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user