Files
Daniel Samson 89d4592777 block: close the range-clamp overflow — a confined caller could wrap into the neighbour
The naive bound `lba + count > r.count` wraps for an lba near u64 max: the
sum overflows to a small value, sails under the check, and `base + lba`
wraps to an absolute block OUTSIDE the range. Calibrated, it is a real
confinement escape — a process confined to [1,3) reads absolute block 0
(the boot sector) with lba = maxInt(u64), since base + lba wraps to 0.

The bound is rewritten as two subtractions that cannot overflow: lba within
the range, and count within what remains. block-range gains a wrap-refused
assertion calibrated to be exploitable against the naive form — it FAILS
against the old bound (reads block 0) and passes against the fix (verified
by reverting the clamp). Caught pre-emptively before the V2 boundary review.
2026-08-09 17:40:45 +01:00

155 lines
6.9 KiB
Zig

//! 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");
// The wrap attack, calibrated to be exploitable against a naive bound: this
// process is confined with base 1, so a volume-relative LBA of maxInt(u64)
// makes base + lba wrap to absolute block 0 — a real, readable block OUTSIDE
// the range (the boot sector). A naive `lba + count > count` check also
// wraps to 0 and waves it through; the overflow-safe bound refuses it.
verdict(!device.read(std.math.maxInt(u64), 1, bounce.physical), "wrap-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");
}