init: /protocol replaces the ServiceId registry

A protocol is reached by name now, not by a compile-time integer. Init is
PID 1 and already knows which binary it started, so init serves /protocol
as a vfs backend: bind claims a contract with the provider's endpoint
attached, open answers with that endpoint as the reply's capability, and
readdir lists what is bound with the task and binary behind it. The kernel
reserves the prefix — nothing may mount over it, under it, or unmount it —
and ServiceId, ipc_register and ipc_lookup are gone, their syscall numbers
left vacant.

A bind is authorized by who the caller *is*: the kernel-stamped binary
together with the supervising task's identity, matched against
/system/configuration/protocol.csv. Identity, not spelling — spawn is
ungated, so an attacker can run any bundled binary, and a name-only rule
would have let it launder grants through an init of its own making. A name
a live process holds is refused to everyone else; a dead one's is released.

Three review rounds against a hostile ring-3 process found what 108 green
tests could not, because the suite contains no attacker. Publishing init's
supervision endpoint as the registry put PID 1's mailbox in every process's
hands, where two forged bytes reached the shutdown path: privileged traffic
is now believed only from the task that holds the contract it speaks for.
A capability arriving on a request outlived every path that ignored it,
one handle per call until the table was full — in init, and in the harness
ten services share — so the arriving capability is owned by the turn and
released unless a handler says otherwise. And the kernel let anyone holding
an endpoint handle aim signals, timers, exit notices and interrupts at it:
binding now requires having created it.

Suite 108/108. The new protocol-registry case asserts eleven properties,
each one an attack that must fail.
This commit is contained in:
Daniel Samson
2026-08-01 02:39:07 +01:00
parent 1ff0991452
commit 1379b699f3
66 changed files with 2191 additions and 392 deletions
+42 -13
View File
@@ -15,6 +15,7 @@
const std = @import("std");
const device = @import("driver");
const channel = @import("channel");
const ipc = @import("ipc");
const process = @import("process");
const service = @import("service");
@@ -68,19 +69,25 @@ const Open = struct {
};
var opens = [_]Open{.{}} ** 16;
fn recordOpen(device_token: u64, report_endpoint: usize) void {
/// Remember (or replace) the endpoint that reports for `device_token`. Returns
/// whether the table kept the handle — false means the caller still owns it and
/// must dispose of it. A re-open supersedes the previous endpoint, and the one
/// it displaced is closed here: the table holds exactly one reference per slot.
fn recordOpen(device_token: u64, report_endpoint: usize) bool {
for (&opens) |*open| {
if (open.used and open.device_token == device_token) {
if (open.report_endpoint != report_endpoint) _ = ipc.close(open.report_endpoint);
open.report_endpoint = report_endpoint;
return;
return true;
}
}
for (&opens) |*open| {
if (!open.used) {
open.* = .{ .used = true, .device_token = device_token, .report_endpoint = report_endpoint };
return;
return true;
}
}
return false; // table full: not kept
}
fn reportEndpointFor(device_token: u64) ?usize {
@@ -97,6 +104,22 @@ var controller_id: u64 = device_manager_protocol.no_device;
/// manager reads as "meant to stop" — a missing assignment is not a crash loop.
fn initialise(endpoint: ipc.Handle) bool {
service_endpoint = endpoint;
// The transfer contract, bound by hand rather than through the harness's
// `.service`, because **losing it is not fatal here**. One machine can carry
// several xHCI controllers and the driver model spawns one process per
// controller, so several processes provide the same contract for different
// hardware — and `/protocol` holds exactly one name, deliberately (addressing
// lives inside the protocol, never in the path). Whoever binds first is the
// one clients reach by name; a later instance still owns its controller,
// enumerates its bus, and reports its children to the device manager, so it
// keeps running. **Known gap:** a class driver behind a second controller
// cannot reach it — the transfer protocol has no controller field for
// `target`, and the fix is either one process multiplexing every controller
// or the spawner wiring the child's channel (P5), not a second name.
if (!channel.bindPatiently("usb-transfer", endpoint))
_ = logging.write("/system/drivers/usb-xhci-bus: /protocol/usb-transfer is another controller's; serving mine unnamed\n");
if (!device.claim(controller_id)) {
std.log.info("unable to claim controller device {d}", .{controller_id});
return false;
@@ -475,28 +498,29 @@ fn reportInterface(manager: ipc.Handle, port: u32, interface: library.InterfaceI
/// Serve the USB transfer protocol: a class driver opens its device, then issues
/// control / interrupt-subscribe / bulk requests against it.
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = sender;
if (message.len < 4) return 0;
const operation = std.mem.readInt(u32, message[0..4], .little);
return switch (operation) {
@intFromEnum(usb_transfer_protocol.Operation.open) => handleOpen(message, reply, capability),
@intFromEnum(usb_transfer_protocol.Operation.open) => handleOpen(message, reply, arrived),
@intFromEnum(usb_transfer_protocol.Operation.control) => handleControl(message, reply),
@intFromEnum(usb_transfer_protocol.Operation.interrupt_subscribe) => handleSubscribe(message, reply),
@intFromEnum(usb_transfer_protocol.Operation.bulk) => handleBulk(message, reply),
@intFromEnum(usb_transfer_protocol.Operation.dma_attach) => handleDmaAttach(message, reply, capability),
@intFromEnum(usb_transfer_protocol.Operation.dma_attach) => handleDmaAttach(message, reply, arrived),
else => 0,
};
}
/// dma_attach: bind the class driver's DMA-region capability into the controller's IOMMU
/// domain, so the controller may DMA to the physical addresses inside that buffer. The
/// binding holds its own kernel reference, so the forwarded capability is closed here.
fn handleDmaAttach(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize {
/// binding holds its own kernel reference, so this never claims the arriving handle —
/// the turn's `defer` in the harness is the close, on the failure paths as well as this
/// one.
fn handleDmaAttach(message: []const u8, reply: []u8, arrived: *ipc.Arrival) usize {
if (message.len < @sizeOf(usb_transfer_protocol.DmaAttachRequest)) return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 });
const handle = capability orelse return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 });
const handle = arrived.peek() orelse return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 });
const ok = device.dmaBind(controller_id, handle);
_ = ipc.close(handle);
return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = if (ok) 0 else -1 });
}
@@ -509,13 +533,17 @@ fn writeReply(reply: []u8, value: anytype) usize {
/// open: resolve the assigned device id to an interface, remember the caller's
/// endpoint (for interrupt reports), and answer with a device token + the
/// interface's endpoints so the class driver need not re-read the config.
fn handleOpen(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize {
fn handleOpen(message: []const u8, reply: []u8, arrived: *ipc.Arrival) usize {
if (message.len < @sizeOf(usb_transfer_protocol.OpenRequest)) return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 });
const request = std.mem.bytesToValue(usb_transfer_protocol.OpenRequest, message[0..@sizeOf(usb_transfer_protocol.OpenRequest)]);
const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 });
const found = engine.findInterface(request.device_id) orelse return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 });
if (capability) |endpoint| recordOpen(request.device_id, endpoint);
// The report endpoint is claimed only if the open table actually keeps it;
// a full table leaves it to the turn to close.
if (arrived.peek()) |endpoint| {
if (recordOpen(request.device_id, endpoint)) _ = arrived.take();
}
var open_reply = usb_transfer_protocol.OpenReply{
.status = 0,
@@ -673,7 +701,8 @@ pub fn main(init: process.Init) void {
return;
};
service.run(usb_transfer_protocol.message_maximum, .{
.service = .usb_bus,
// No `.service`: the contract is bound inside `initialise`, where losing
// it to another controller's driver is survivable rather than fatal.
.init = initialise,
.on_message = onMessage,
.on_notification = onNotification,