kernel: user memory is reached only through a checked copy

A new user-memory module owns every kernel touch of a user buffer:
copyFromUser, the new copyToUser, and the resolve behind both. The walk
accumulates the U/S and writable bits down all four levels with the MMU's
own AND rule — folding a 2 MiB leaf in before it resolves and refusing a
1 GiB leaf outright — so a copy honours what ring 3 itself would be
allowed, closing the presence-only trust model the IPC layer carried since
bring-up. It then confirms the frame is physmap-backed, because that is how
the copy reaches it: an mmio_map'd BAR passes the permission walk and would
otherwise fault ring 0 on an alias the physmap never mapped, on the IPC path
as much as the new one.

The nine stragglers that dereferenced user pointers raw now route through
it, so a bad pointer returns -EFAULT where it used to fault the kernel.
The write direction restructures its callees around kernel bounce buffers:
scheduler and devices-broker enumerate from a slot cursor (a task exiting
between chunks can neither duplicate nor lose an entry), klog_read drains
the ring in chunks, and fs_node stages headers and names contiguously.
fs_resolve copies out before installing the endpoint handle, so a faulting
copy cannot strand a capability; its out-capacity bound no longer adds an
unbounded ring-3 length to the base, which wrapped and trapped the kernel's
own overflow check. debug_write reads the caller's message once.

Suite 107/107 (new user-memory case: seven bad pointers refused, each
paired with a sound call that must still succeed).
This commit is contained in:
Daniel Samson
2026-07-31 20:57:49 +01:00
parent c4f16a5448
commit 8d4a7cf240
16 changed files with 691 additions and 105 deletions
+100
View File
@@ -29,6 +29,7 @@ const process = @import("process.zig");
const initial_ramdisk = @import("initial-ramdisk");
const kernel_log = @import("log.zig");
const kernel_vfs = @import("vfs.zig");
const user_memory = @import("user-memory.zig");
/// Formatted test-marker write. Goes through the kernel log (not straight to
/// serial): the log lock is what keeps marker lines from interleaving with
@@ -141,6 +142,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
faultNull();
} else if (eql(case, "usermem")) {
userMemTest();
} else if (eql(case, "user-memory")) {
userMemoryTest(boot_information);
} else if (eql(case, "user-pf")) {
userPfTest();
} else if (eql(case, "fault-recovery")) {
@@ -1023,6 +1026,103 @@ fn userMemTest() void {
result();
}
/// The checked copy layer (system/kernel/user-memory.zig), against a scratch
/// address space built here rather than a live process — so the refusals can be
/// provoked exactly: a kernel-half address, an unmapped user page, and a user
/// page mapped read-only. Then the fixture proves the same refusals reach ring 3
/// as -errno instead of a kernel fault.
fn userMemoryTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: user-memory\n", .{});
const base_free = pmm.stats().free_frames;
const address_space = architecture.createAddressSpace() orelse {
check("created a scratch address space", false);
result();
return;
};
check("created a scratch address space", address_space != 0);
// Three consecutive pages: two writable, the third read-only — so a copy that
// straddles into the third proves the write check applies per page, not just
// to the first one the walk touches.
const writable_pages = 2;
const total_pages = 3;
const arena = process.heap_arena_base;
var frames: [total_pages]u64 = undefined;
var mapped: usize = 0;
while (mapped < total_pages) : (mapped += 1) {
frames[mapped] = pmm.alloc() orelse break;
architecture.mapUserPageInto(address_space, arena + mapped * abi.page_size, frames[mapped], mapped < writable_pages, false);
}
check("mapped two writable and one read-only user page", mapped == total_pages);
if (mapped == total_pages) {
const read_only = arena + writable_pages * abi.page_size;
const unmapped = arena + total_pages * abi.page_size;
const kernel_half: u64 = 0xFFFF_8000_0000_0000;
var out: [16]u8 = undefined;
const pattern = [_]u8{ 0xC0, 0xDE, 0xF0, 0x0D, 0xBA, 0xAD, 0xF0, 0x0D };
// A round trip through the writable page: what copyToUser placed is what
// copyFromUser brings back, and the frame really holds it.
const wrote = user_memory.copyToUser(address_space, arena + 32, &pattern);
const read_back = user_memory.copyFromUser(address_space, arena + 32, out[0..pattern.len]);
const frame_view: [*]const u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frames[0] + 32));
check("copyToUser/copyFromUser round trip", wrote and read_back and
eql(out[0..pattern.len], &pattern) and eql(frame_view[0..pattern.len], &pattern));
// Straddling the 4 KiB boundary between the two writable pages.
const straddle = arena + abi.page_size - 4;
check("a page-straddling round trip", user_memory.copyToUser(address_space, straddle, &pattern) and
user_memory.copyFromUser(address_space, straddle, out[0..pattern.len]) and
eql(out[0..pattern.len], &pattern));
// Kernel-half addresses are refused by the range check, before any walk.
check("copyToUser refuses a kernel-half address", !user_memory.copyToUser(address_space, kernel_half, &pattern));
check("copyFromUser refuses a kernel-half address", !user_memory.copyFromUser(address_space, kernel_half, out[0..pattern.len]));
check("a range running off the end of the user half is refused", !user_memory.copyToUser(address_space, user_memory.user_half_end - 4, &pattern));
// An unmapped-but-in-range page: the latent kernel fault H1 exists to kill.
check("copyToUser refuses an unmapped user page", !user_memory.copyToUser(address_space, unmapped, &pattern));
check("copyFromUser refuses an unmapped user page", !user_memory.copyFromUser(address_space, unmapped, out[0..pattern.len]));
// The leaf permission bits: a read-only user page may be read, never written.
check("copyToUser refuses a read-only user mapping", !user_memory.copyToUser(address_space, read_only, &pattern));
check("copyFromUser accepts a read-only user mapping", user_memory.copyFromUser(address_space, read_only, out[0..pattern.len]));
check("a write straddling into a read-only page is refused", !user_memory.copyToUser(address_space, read_only - 4, &pattern));
// The kernel's own address space is not a user address space.
check("copyToUser refuses address space 0", !user_memory.copyToUser(0, arena, &pattern));
check("copyFromUser refuses address space 0", !user_memory.copyFromUser(0, arena, out[0..pattern.len]));
}
var i: usize = 0;
while (i < mapped) : (i += 1) {
const va = arena + i * abi.page_size;
architecture.unmapUserPageInto(address_space, va);
pmm.free(frames[i]);
}
architecture.destroyAddressSpace(address_space);
check("no frames leaked (free count restored)", pmm.stats().free_frames == base_free);
// Now the ring-3 half: the fixture aims bad pointers at the converted system
// calls and must get failures back with the machine still running.
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("user-memory-test spawned", spawnNamed(rd, "user-memory-test"));
result();
}
// --- synchronous IPC --------------------------------------------------------
var ipc_endpoint: *ipcsync.Endpoint = undefined;