test: block-range — the discrimination fixture for range confinement (V2a)
A process acquires a block channel the way a filesystem does (consumer-hello the device manager), confines ITSELF to blocks [1,3), then proves the clamp and the gate: volume-relative LBA 0 maps inside the range and reads; a read reaching past the range is refused; geometry reports the confined size; and a confined caller can no longer call define_range (no widening, no escape). It gates on argv so the ramdisk sweep leaves it silent in other boots, and coexists with fat (ranges are per-badge). Discrimination (verified by reverting usb-storage to pre-clamp f1bdce2~1): the unconfined read still succeeds but define_range returns ENOSYS, so the fixture cannot arm confinement and the case fails — exactly the property the clamp adds. With the clamp: block-range 1/1.
This commit is contained in:
@@ -343,6 +343,7 @@ pub fn build(b: *std.Build) void {
|
|||||||
"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
|
"device-authority-test", // the attacker: a process handed no device, asserting what it cannot do
|
||||||
|
"block-range-test", // confines itself to a block sub-range, then proves it cannot cross or widen it
|
||||||
}) |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");
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
.@"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 },
|
.@"device-authority-test" = .{ .path = "test/system/services/device-authority-test", .lazy = true },
|
||||||
|
.@"block-range-test" = .{ .path = "test/system/services/block-range-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
|
||||||
|
|||||||
@@ -265,6 +265,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
|
|||||||
deviceTransferTest(boot_information);
|
deviceTransferTest(boot_information);
|
||||||
} else if (eql(case, "device-authority")) {
|
} else if (eql(case, "device-authority")) {
|
||||||
deviceAuthorityTest(boot_information);
|
deviceAuthorityTest(boot_information);
|
||||||
|
} else if (eql(case, "block-range")) {
|
||||||
|
blockRangeTest(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")) {
|
||||||
@@ -3034,6 +3036,31 @@ fn fatMountTest(boot_information: *const BootInformation) void {
|
|||||||
result();
|
result();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-sender range confinement (V2a, docs/volume-manager-plan.md): boot the
|
||||||
|
/// full tree so the USB storage chain is up, then spawn block-range-test, which
|
||||||
|
/// acquires the block channel, confines ITSELF to a sub-range, and asserts it
|
||||||
|
/// cannot read past that range or widen it. The fixture's markers are the
|
||||||
|
/// assertion (the QEMU expect regex matches them); this only boots and spawns.
|
||||||
|
fn blockRangeTest(boot_information: *const BootInformation) void {
|
||||||
|
log("DANOS-TEST-BEGIN: block-range\n", .{});
|
||||||
|
if (boot_information.initial_ramdisk_len == 0) {
|
||||||
|
check("bootloader handed over the initial_ramdisk", false);
|
||||||
|
result();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
|
||||||
|
const rd = initial_ramdisk.Reader.init(ramdisk) orelse {
|
||||||
|
check("initial_ramdisk image is valid", false);
|
||||||
|
result();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
process.setInitialRamdisk(ramdisk);
|
||||||
|
const spawned = if (process.spawnBundled("/system/services/init")) true else |_| false;
|
||||||
|
check("init spawned (boots the USB storage chain)", spawned);
|
||||||
|
check("block-range-test spawned", spawnNamedWithArg(rd, "block-range-test", "run"));
|
||||||
|
result();
|
||||||
|
}
|
||||||
|
|
||||||
fn bootServiceTreeTest(boot_information: *const BootInformation, comptime label: []const u8) void {
|
fn bootServiceTreeTest(boot_information: *const BootInformation, comptime label: []const u8) void {
|
||||||
log("DANOS-TEST-BEGIN: " ++ label ++ "\n", .{});
|
log("DANOS-TEST-BEGIN: " ++ label ++ "\n", .{});
|
||||||
if (boot_information.initial_ramdisk_len == 0) {
|
if (boot_information.initial_ramdisk_len == 0) {
|
||||||
|
|||||||
@@ -1234,6 +1234,22 @@ CASES = [
|
|||||||
{"name": "device-authority",
|
{"name": "device-authority",
|
||||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||||
|
# Per-sender range confinement (V2a, docs/volume-manager-plan.md): a process
|
||||||
|
# confines ITSELF to a block sub-range (as the volume manager confines a
|
||||||
|
# filesystem), then proves it cannot read past the range nor widen it. The
|
||||||
|
# security assertions are named explicitly so the case cannot pass without
|
||||||
|
# them; a confined read crossing the range must be REFUSED and a confined
|
||||||
|
# define_range must be REFUSED. Against pre-clamp usb-storage the define_range
|
||||||
|
# verb does not exist, so the fixture fails to arm confinement at all.
|
||||||
|
{"name": "block-range",
|
||||||
|
"smp": 4,
|
||||||
|
"timeout": 150,
|
||||||
|
"expect": r"(?s)(?=.*block-range: ok in-range-read)"
|
||||||
|
r"(?=.*block-range: ok out-of-range-refused)"
|
||||||
|
r"(?=.*block-range: ok geometry-is-confined)"
|
||||||
|
r"(?=.*block-range: ok confined-cannot-redefine)"
|
||||||
|
r"(?=.*block-range: VERDICT done)",
|
||||||
|
"fail": r"block-range: FAILED|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,148 @@
|
|||||||
|
//! block-range-test — the discrimination fixture for per-sender range
|
||||||
|
//! confinement (V2a, docs/volume-manager-plan.md). It gets a block channel the
|
||||||
|
//! way a filesystem does (consumer-hello the device manager for the mass-storage
|
||||||
|
//! provider), then proves the two properties the clamp exists for:
|
||||||
|
//!
|
||||||
|
//! 1. an UNCONFINED caller may define a range on its own badge (the volume
|
||||||
|
//! manager is unconfined — this stands in for it);
|
||||||
|
//! 2. once confined, a transfer PAST the range is refused, and the volume
|
||||||
|
//! relative LBA 0 maps inside the range (the clamp translates + bounds);
|
||||||
|
//! 3. a CONFINED caller may NOT call define_range again (the gate — a
|
||||||
|
//! filesystem cannot widen its own range or escape).
|
||||||
|
//!
|
||||||
|
//! Against pre-clamp usb-storage the verb does not exist, so (1) already fails —
|
||||||
|
//! which is exactly the discrimination: the fixture cannot even arm confinement,
|
||||||
|
//! let alone see a transfer refused for crossing it.
|
||||||
|
//!
|
||||||
|
//! It coexists with the FAT service in the same boot: ranges are per-badge, so
|
||||||
|
//! confining THIS process touches nothing fat does on its own channel.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const channel = @import("channel");
|
||||||
|
const device_manager_protocol = @import("device-manager-protocol");
|
||||||
|
const driver = @import("driver");
|
||||||
|
const ipc = @import("ipc");
|
||||||
|
const block = @import("block");
|
||||||
|
const memory = @import("memory");
|
||||||
|
const logging = @import("logging");
|
||||||
|
const process = @import("process");
|
||||||
|
const time = @import("time");
|
||||||
|
const envelope = @import("envelope");
|
||||||
|
|
||||||
|
fn verdict(ok: bool, name: []const u8) void {
|
||||||
|
_ = logging.write("block-range: ");
|
||||||
|
_ = logging.write(if (ok) "ok " else "FAILED ");
|
||||||
|
_ = logging.write(name);
|
||||||
|
_ = logging.write("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mass-storage provider's block channel, via the device manager's tree —
|
||||||
|
/// the same lineage acquisition the FAT service uses (block is not a name).
|
||||||
|
fn acquireBlock() ?block.Device {
|
||||||
|
var tries: u32 = 0;
|
||||||
|
const manager = while (tries < 200) : (tries += 1) {
|
||||||
|
if (channel.openEndpoint("device-manager")) |h| break h;
|
||||||
|
time.sleepMillis(20);
|
||||||
|
} else return null;
|
||||||
|
|
||||||
|
// The whole USB storage chain (enumeration, bring-up) takes a few seconds to
|
||||||
|
// appear in the manager's tree, so retry the enumerate-and-hello with a pause
|
||||||
|
// between rounds — 500 x 20 ms ~ 10 s, well within the case timeout.
|
||||||
|
const Entry = device_manager_protocol.ChildEntry;
|
||||||
|
var attempt: u32 = 0;
|
||||||
|
while (attempt < 500) : (attempt += 1) {
|
||||||
|
var start: u64 = 0;
|
||||||
|
while (true) {
|
||||||
|
const enumerate = envelope.Header{ .operation = envelope.operation_enumerate, .target = start };
|
||||||
|
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||||
|
const length = ipc.call(manager, std.mem.asBytes(&enumerate), &reply) catch break;
|
||||||
|
const status = envelope.statusOf(reply[0..length]) orelse break;
|
||||||
|
if (status.status != 0) break;
|
||||||
|
const carried = @min(@as(usize, status.len), length -| envelope.prefix_size);
|
||||||
|
const tail = reply[envelope.prefix_size..][0..carried];
|
||||||
|
const count = tail.len / @sizeOf(Entry);
|
||||||
|
if (count == 0) break;
|
||||||
|
var index: usize = 0;
|
||||||
|
while (index < count) : (index += 1) {
|
||||||
|
const entry = std.mem.bytesToValue(Entry, tail[index * @sizeOf(Entry) ..][0..@sizeOf(Entry)]);
|
||||||
|
if (entry.device_id == device_manager_protocol.no_device) continue;
|
||||||
|
if ((entry.identity >> 16) & 0xff != 0x08 or (entry.identity >> 8) & 0xff != 0x06) continue;
|
||||||
|
const exchanged = driver.helloOn(manager, .consumer, entry.device_id, null, true) orelse break;
|
||||||
|
const provider = exchanged.channel orelse continue;
|
||||||
|
return .{ .endpoint = provider };
|
||||||
|
}
|
||||||
|
start += count;
|
||||||
|
}
|
||||||
|
time.sleepMillis(20);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn main(init: process.Init) void {
|
||||||
|
// Bundled fixtures are swept up and spawned bare on every boot; stay silent
|
||||||
|
// unless the kernel test explicitly runs us, or we would contend for the
|
||||||
|
// block channel and print markers into unrelated cases.
|
||||||
|
const arg = init.arguments.get(1) orelse return;
|
||||||
|
if (!std.mem.eql(u8, arg, "run")) return;
|
||||||
|
|
||||||
|
const device = acquireBlock() orelse {
|
||||||
|
verdict(false, "acquire-block");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
const geometry = device.geometry() orelse {
|
||||||
|
verdict(false, "geometry");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Need at least a few blocks to carve a range out of; every FAT image is far
|
||||||
|
// larger, so this only guards a nonsense device.
|
||||||
|
if (geometry.block_count < 4) {
|
||||||
|
verdict(false, "device-too-small");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A one-block DMA buffer for the positive-control read. Shareable so it can be
|
||||||
|
// attached under an enforcing IOMMU (a no-op success otherwise).
|
||||||
|
const bounce = memory.dmaAlloc(512, memory.dma_coherent | memory.dma_shareable) orelse {
|
||||||
|
verdict(false, "dma-alloc");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if (bounce.handle) |handle| {
|
||||||
|
if (!device.attach(handle)) {
|
||||||
|
verdict(false, "attach");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ = ipc.close(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baseline: an unconfined read of block 0 succeeds — so a later refusal is
|
||||||
|
// the clamp, not a broken read path.
|
||||||
|
verdict(device.read(0, 1, bounce.physical), "unconfined-read");
|
||||||
|
|
||||||
|
const me = process.taskId();
|
||||||
|
|
||||||
|
// (1) An unconfined caller confines itself to blocks [1, 3). Against pre-clamp
|
||||||
|
// usb-storage this verb does not exist and the call fails here.
|
||||||
|
if (!device.defineRange(me, 1, 2)) {
|
||||||
|
verdict(false, "define-range");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verdict(true, "define-range");
|
||||||
|
|
||||||
|
// (2) Confined now: volume-relative LBA 0 maps to device block 1 (inside the
|
||||||
|
// range) and succeeds; LBA 2 would reach device block 3, past the 2-block
|
||||||
|
// range, and must be refused.
|
||||||
|
verdict(device.read(0, 1, bounce.physical), "in-range-read");
|
||||||
|
verdict(!device.read(2, 1, bounce.physical), "out-of-range-refused");
|
||||||
|
|
||||||
|
// Geometry now reports the CONFINED size, not the device's.
|
||||||
|
const confined = device.geometry() orelse {
|
||||||
|
verdict(false, "confined-geometry");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
verdict(confined.block_count == 2, "geometry-is-confined");
|
||||||
|
|
||||||
|
// (3) The gate: a confined caller cannot define_range — no widening, no escape.
|
||||||
|
verdict(!device.defineRange(me, 0, geometry.block_count), "confined-cannot-redefine");
|
||||||
|
|
||||||
|
_ = logging.write("block-range: VERDICT done\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! The block-range-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 = "block-range-test",
|
||||||
|
.root_source_file = b.path("block-range-test.zig"),
|
||||||
|
.imports = &.{
|
||||||
|
"block", "channel", "device-manager-protocol", "driver",
|
||||||
|
"envelope", "ipc", "logging", "memory",
|
||||||
|
"process", "time",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
b.installArtifact(exe);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.{
|
||||||
|
.name = .block_range_test,
|
||||||
|
.version = "0.0.0",
|
||||||
|
.fingerprint = 0xa7f2045ed72783c3, // 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. device (block, driver) and protocol (device-manager-protocol,
|
||||||
|
// envelope) are the homes of this fixture's remaining imports.
|
||||||
|
.@"build-support" = .{ .path = "../../../../build-support" },
|
||||||
|
.kernel = .{ .path = "../../../../library/kernel" },
|
||||||
|
.device = .{ .path = "../../../../library/device" },
|
||||||
|
.protocol = .{ .path = "../../../../library/protocol" },
|
||||||
|
},
|
||||||
|
.paths = .{""},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user