From 8d4a7cf240ab35cdc8234b800ce06b9892109dc5 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:57:49 +0100 Subject: [PATCH] kernel: user memory is reached only through a checked copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- build.zig | 1 + build.zig.zon | 1 + docs/security-track-plan.md | 2 +- system/kernel/architecture/x86_64/cpu.zig | 10 + system/kernel/architecture/x86_64/paging.zig | 52 +++++ system/kernel/devices-broker.zig | 24 ++- system/kernel/ipc-synchronous.zig | 60 +++--- system/kernel/process.zig | 186 ++++++++++++++---- system/kernel/scheduler.zig | 85 +++++--- system/kernel/tests.zig | 100 ++++++++++ system/kernel/user-memory.zig | 117 +++++++++++ system/kernel/vfs.zig | 7 +- test/qemu_test.py | 10 + .../services/user-memory-test/build.zig | 15 ++ .../services/user-memory-test/build.zig.zon | 15 ++ .../user-memory-test/user-memory-test.zig | 111 +++++++++++ 16 files changed, 691 insertions(+), 105 deletions(-) create mode 100644 system/kernel/user-memory.zig create mode 100644 test/system/services/user-memory-test/build.zig create mode 100644 test/system/services/user-memory-test/build.zig.zon create mode 100644 test/system/services/user-memory-test/user-memory-test.zig diff --git a/build.zig b/build.zig index 5a3899a..49b83ec 100644 --- a/build.zig +++ b/build.zig @@ -332,6 +332,7 @@ pub fn build(b: *std.Build) void { "args-echo", "process-test", "thread-test", // the multi-threaded fixture (its package sets .threaded) + "user-memory-test", // aims deliberately bad user pointers at the checked copy layer }) |fixture| { const package = b.lazyDependency(fixture, .{}) orelse @panic("a test fixture package is missing under test/system/services"); diff --git a/build.zig.zon b/build.zig.zon index c1b043a..c6a19b9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -74,6 +74,7 @@ .@"args-echo" = .{ .path = "test/system/services/args-echo", .lazy = true }, .@"process-test" = .{ .path = "test/system/services/process-test", .lazy = true }, .@"thread-test" = .{ .path = "test/system/services/thread-test", .lazy = true }, + .@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true }, // See `zig fetch --save ` for a command-line interface for adding dependencies. //.example = .{ // // When updating this field to a new URL, be sure to delete the corresponding diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md index 5173b02..3e79d61 100644 --- a/docs/security-track-plan.md +++ b/docs/security-track-plan.md @@ -33,7 +33,7 @@ the logging/USB-lifecycle track). - [x] **Phase 0** — baseline: suite green on `main` (106/106, 2026-07-31; `zig build` + `zig build test` clean at 9a32380), plan committed - [x] **PM** — path-migration flag-day (`/etc`→`/system/configuration`, `/var/log`→`/system/logs`, `/mnt/usb`→`/volumes/usb`; vfs carve-out for the two writable `/system` subtrees, FAT's `/var` mount split in two; suite 106/106) -- [ ] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks +- [x] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so an `mmio_map`'d buffer cannot fault ring 0 — this also closes the same hazard on the IPC path; `fs_resolve`'s out-capacity bound made overflow-safe; suite 107/107) - [ ] **merge** group 1 → main, push - [ ] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` - [ ] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups) diff --git a/system/kernel/architecture/x86_64/cpu.zig b/system/kernel/architecture/x86_64/cpu.zig index d8fef1b..f0dbb83 100644 --- a/system/kernel/architecture/x86_64/cpu.zig +++ b/system/kernel/architecture/x86_64/cpu.zig @@ -257,6 +257,16 @@ pub fn translate(root: u64, virtual: u64) ?u64 { return paging.translateIn(root, virtual); } +/// `translate` for an address the kernel is about to touch *on a process's +/// behalf*: the walk additionally demands the permission ring 3 would need — the +/// leaf user-accessible (U/S set at every level), and writable (R/W at every +/// level) when `for_write`. Null means "the process itself could not do this", +/// which the checked copy layer (system/kernel/user-memory.zig) turns into +/// -EFAULT instead of a kernel dereference. +pub fn translateUser(root: u64, virtual: u64, for_write: bool) ?u64 { + return paging.translateUserIn(root, virtual, for_write); +} + /// Map a page accessible from ring 3 (U/S bit at every level). The caller keeps /// W^X: code read-only + executable, data writable + no-execute. pub fn mapUserPage(virtual: u64, physical: u64, writable: bool, executable: bool) void { diff --git a/system/kernel/architecture/x86_64/paging.zig b/system/kernel/architecture/x86_64/paging.zig index 5838185..b94022b 100644 --- a/system/kernel/architecture/x86_64/paging.zig +++ b/system/kernel/architecture/x86_64/paging.zig @@ -524,6 +524,58 @@ pub fn translateIn(pml4: u64, virtual: u64) ?u64 { return (pte & address_mask) | (virtual & (page_size - 1)); } +/// `translateIn` with the ring-3 permission bits enforced: the walk accumulates +/// the protection flags of every level it descends through and refuses the +/// translation unless the *effective* permission allows the access ring 3 would +/// be allowed — U/S set at every level, and (for `for_write`) R/W set at every +/// level too. A bit cleared anywhere on the path denies, which is exactly how +/// the MMU combines them, so a checked kernel copy sees the same permissions the +/// process itself does. +/// +/// This is the walk `system/kernel/user-memory.zig` copies through, and the +/// reason a kernel copy can never be steered at a kernel-only mapping or made to +/// write a read-only user page (a process's own text, say). +/// +/// Huge pages: a 2 MiB PDE leaf resolves like `translateIn`, with its own U/S and +/// R/W folded into the accumulator first. A PDPTE with PS set (a 1 GiB leaf) is +/// refused rather than descended into — danos never builds one, and denying is +/// the safe direction for a permission-checked walk. +pub fn translateUserIn(pml4: u64, virtual: u64, for_write: bool) ?u64 { + // Start all-ones and AND in each level: a cleared bit at any level denies. + var effective: u64 = ~@as(u64, 0); + + const pml4e = tableAt(pml4)[(virtual >> 39) & 0x1FF]; + if (pml4e & present == 0) return null; + effective &= pml4e; + + const pdpte = tableAt(pml4e & address_mask)[(virtual >> 30) & 0x1FF]; + if (pdpte & present == 0) return null; + if (pdpte & page_size_bit != 0) return null; // 1 GiB leaf: never built here, refuse + effective &= pdpte; + + const pde = tableAt(pdpte & address_mask)[(virtual >> 21) & 0x1FF]; + if (pde & present == 0) return null; + effective &= pde; + if (pde & page_size_bit != 0) { // 2 MiB huge leaf: frame base is bits 51:21 + if (!permits(effective, for_write)) return null; + return (pde & address_mask & ~@as(u64, huge_page_size - 1)) | (virtual & (huge_page_size - 1)); + } + + const pte = tableAt(pde & address_mask)[(virtual >> 12) & 0x1FF]; + if (pte & present == 0) return null; + effective &= pte; + if (!permits(effective, for_write)) return null; + return (pte & address_mask) | (virtual & (page_size - 1)); +} + +/// Whether accumulated walk flags allow a ring-3 access: user-accessible always, +/// and writable when the access is a store. +fn permits(effective: u64, for_write: bool) bool { + if (effective & user == 0) return false; + if (for_write and effective & writable == 0) return false; + return true; +} + fn invalidate(virtual: u64) void { // invlpg needs its operand via a register-indirect memory reference that Zig // inline asm won't form directly, so stage the address in a register first. diff --git a/system/kernel/devices-broker.zig b/system/kernel/devices-broker.zig index 001ea93..0b1bc91 100644 --- a/system/kernel/devices-broker.zig +++ b/system/kernel/devices-broker.zig @@ -132,13 +132,31 @@ fn record(node: *platform.Device, parent_id: u64) u64 { } /// Copy up to `out.len` device descriptors into `out`; returns the total count -/// available (which may exceed `out.len`). +/// available (which may exceed `out.len`). For kernel callers with a buffer big +/// enough to take the whole table in one go. pub fn enumerate(out: []device_abi.DeviceDescriptor) usize { - const n = @min(count, out.len); - @memcpy(out[0..n], devices[0..n]); + _ = enumerateFrom(0, out); return count; } +/// How many devices the table holds — the total `device_enumerate` reports back +/// however few of them fit in the caller's buffer. +pub fn deviceCount() usize { + return count; +} + +/// Copy up to `out.len` descriptors starting at table index `start`, returning how +/// many were filled (0 once `start` reaches the end). The chunked form: the +/// `device_enumerate` system call bounces the table out through a small kernel +/// buffer, one chunk at a time, because a descriptor is far too big to stage a +/// whole user-requested array of them on a 16 KiB kernel stack. +pub fn enumerateFrom(start: usize, out: []device_abi.DeviceDescriptor) usize { + if (start >= count) return 0; + const n = @min(count - start, out.len); + @memcpy(out[0..n], devices[start..][0..n]); + return n; +} + /// Take exclusive ownership of device `id` for task `owner`. Fails if the id is /// out of range or already claimed. pub fn claim(id: u64, owner: u32) bool { diff --git a/system/kernel/ipc-synchronous.zig b/system/kernel/ipc-synchronous.zig index 1b1323b..34cd43d 100644 --- a/system/kernel/ipc-synchronous.zig +++ b/system/kernel/ipc-synchronous.zig @@ -17,18 +17,20 @@ //! endpoint's own FIFO (threaded through the otherwise-idle `Task.next`); servers //! waiting for work use a normal WaitQueue. //! -//! Trust model (bring-up): copies honour only page presence and a user-half bound, -//! not the leaf U/S or R/W bits and not SMAP — a #PF-tolerant, permission-checked -//! copy is a later security-track item, matching the existing debug_write gap. +//! Trust model: every side of a copy that names a *user* address space goes +//! through system/kernel/user-memory.zig — user-half bound, page presence, and +//! the leaf permissions ring 3 itself would face (U/S to read, U/S + R/W to +//! write). A kernel-side buffer is trusted and translated as-is. An unmapped or +//! wrongly-permissioned page fails the operation; it never faults ring 0. const std = @import("std"); const boot_handoff = @import("boot-handoff"); const abi = @import("abi"); -const architecture = @import("architecture"); const scheduler = @import("scheduler.zig"); const sync = @import("sync.zig"); const heap = @import("heap.zig"); const pmm = @import("pmm.zig"); +const user_memory = @import("user-memory.zig"); const page_size = abi.page_size; const Task = scheduler.Task; @@ -83,8 +85,9 @@ const PostSlot = struct { bytes: [POST_MAXIMUM]u8 = undefined, }; -/// End of the user (low) canonical half — user buffers must lie below it. -const user_half_end: u64 = 0x0000_8000_0000_0000; +/// End of the user (low) canonical half — user buffers must lie below it. One +/// definition, in the module that owns the user-memory contract. +const user_half_end: u64 = user_memory.user_half_end; /// A rendezvous endpoint. Allocated from the kernel heap; referenced by handle /// (per process) and/or by a registry slot, counted by `refcount`. @@ -300,18 +303,22 @@ pub fn abandonSenderLocked(t: *Task) void { /// Copy `len` bytes from `source_va` in address space `source_as` to `destination_va` in /// `destination_as`, walking each side's page tables through the physmap (no CR3 switch). /// `*_as == 0` means the kernel address space (for kernel-task endpoints). User -/// buffers must lie in the low half. Returns false — never #PFs — if any page is -/// unmapped or out of range. Handles page-straddling buffers. +/// buffers must lie in the low half and carry the permission ring 3 would need for +/// their side of the copy — readable to send from, writable to receive into. +/// Returns false — never #PFs — if any page is unmapped, out of range, or +/// wrongly permissioned. Handles page-straddling buffers. +/// +/// This is the process↔process case, which `user-memory` deliberately does not +/// cover (it knows one user address space at a time); both sides resolve through +/// `user_memory.resolve`, so the permission rules are the same ones. fn copyAcross(source_as: u64, source_va: u64, destination_as: u64, destination_va: u64, len: usize) bool { - const source_root = if (source_as != 0) source_as else architecture.kernelPageTable(); - const destination_root = if (destination_as != 0) destination_as else architecture.kernelPageTable(); - if (source_as != 0 and (source_va >= user_half_end or source_va + len > user_half_end)) return false; - if (destination_as != 0 and (destination_va >= user_half_end or destination_va + len > user_half_end)) return false; + if (source_as != 0 and !user_memory.userRangeOk(source_va, len)) return false; + if (destination_as != 0 and !user_memory.userRangeOk(destination_va, len)) return false; var off: usize = 0; while (off < len) { - const s = architecture.translate(source_root, source_va + off) orelse return false; - const d = architecture.translate(destination_root, destination_va + off) orelse return false; + const s = user_memory.resolve(source_as, source_va + off, false) orelse return false; + const d = user_memory.resolve(destination_as, destination_va + off, true) orelse return false; const s_left = page_size - ((source_va + off) & (page_size - 1)); const d_left = page_size - ((destination_va + off) & (page_size - 1)); const n = @min(@min(s_left, d_left), len - off); @@ -323,27 +330,10 @@ fn copyAcross(source_as: u64, source_va: u64, destination_as: u64, destination_v return true; } -/// Copy `destination.len` bytes from `user_va` in address space `user_as` into the kernel -/// buffer `destination`, walking the user page tables through the physmap. Returns false if -/// the range escapes the user half or any source page is unmapped — so a bad user -/// pointer *fails the system_call* rather than faulting the kernel (danos has no -/// fault-recovering copy-in, so a raw dereference of an unmapped user page would halt -/// the machine). The correct way to pull a fixed-size struct in from user space, and -/// a single fetch: no TOCTOU against a hostile pointer. -pub fn copyFromUser(user_as: u64, user_va: u64, destination: []u8) bool { - if (user_as == 0) return false; // not a user address space - if (user_va >= user_half_end or user_va + destination.len > user_half_end) return false; - var off: usize = 0; - while (off < destination.len) { - const s = architecture.translate(user_as, user_va + off) orelse return false; - const s_left = page_size - ((user_va + off) & (page_size - 1)); - const n = @min(s_left, destination.len - off); - const source: [*]const u8 = @ptrFromInt(boot_handoff.physicalToVirtual(s)); - @memcpy(destination[off..][0..n], source[0..n]); - off += n; - } - return true; -} +/// The checked copy-in, re-exported from its home in `user-memory` so the many +/// `ipc.copyFromUser` call sites keep reading naturally. New code should reach +/// for `user-memory` directly — it is where the write direction lives too. +pub const copyFromUser = user_memory.copyFromUser; // --- the two IPC operations ------------------------------------------------- diff --git a/system/kernel/process.zig b/system/kernel/process.zig index 0b3c1b9..fc0d668 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -31,6 +31,7 @@ const scheduler = @import("scheduler.zig"); const console = @import("console.zig"); const sync = @import("sync.zig"); const ipc = @import("ipc-synchronous.zig"); +const user_memory = @import("user-memory.zig"); const devices_broker = @import("devices-broker.zig"); const irq = @import("irq.zig"); const iommu = @import("iommu.zig"); @@ -110,6 +111,14 @@ pub const maximum_arguments = 8; /// Ceiling on the `system_spawn` extra-arguments blob (argv[1..], NUL-separated). pub const maximum_argument_bytes = 256; +/// Longest path `fs_resolve` accepts, longest prefix `fs_mount`/`fs_unmount` +/// accept, and longest backend rewrite prefix. Each is also the size of the +/// kernel staging buffer the argument is copied into, which is why they are +/// named here rather than spelled as literals at the check. +pub const maximum_resolve_path = 224; +pub const maximum_mount_prefix = 64; +pub const maximum_mount_rewrite = 32; + /// Auxiliary-vector entry types (System V AMD64 process entry). Only what the /// kernel emits today; a C runtime scans the vector until the null terminator. const auxiliary_vector_null: u64 = 0; // AT_NULL — end of the vector @@ -378,6 +387,12 @@ fn systemIpcSend(state: *architecture.CpuState) void { /// device_enumerate(buffer, maximum) -> total: snapshot the device table into the caller's /// buffer (up to `maximum` entries), returning the total device count. +/// +/// The broker fills a small kernel chunk which `copyToUser` then places in the +/// caller's buffer: the kernel never stores through a user pointer, so a bad one +/// is -EFAULT instead of a ring-0 page fault. A DeviceDescriptor is a few hundred +/// bytes, so the chunk is deliberately tiny — the 16 KiB kernel stack could not +/// hold a whole user-requested array of them. fn systemDeviceEnumerate(state: *architecture.CpuState) void { const buffer_ptr = architecture.systemCallArg(state, 0); const maximum = architecture.systemCallArg(state, 1); @@ -385,8 +400,20 @@ fn systemDeviceEnumerate(state: *architecture.CpuState) void { if (t.address_space == 0 or buffer_ptr >= user_half_end) return fail(state); const sz = @sizeOf(device_abi.DeviceDescriptor); const cap = @min(maximum, (user_half_end - buffer_ptr) / sz); // clamp to the user half - const out: [*]device_abi.DeviceDescriptor = @ptrFromInt(buffer_ptr); - architecture.setSystemCallResult(state, devices_broker.enumerate(out[0..@intCast(cap)])); + + var chunk: [2]device_abi.DeviceDescriptor = undefined; + var copied: u64 = 0; + var start: usize = 0; + while (copied < cap) { + const filled = devices_broker.enumerateFrom(start, &chunk); + if (filled == 0) break; + start += filled; + const take = @min(@as(u64, filled), cap - copied); + const bytes = std.mem.sliceAsBytes(chunk[0..@intCast(take)]); + if (!user_memory.copyToUser(t.address_space, buffer_ptr + copied * sz, bytes)) return failErr(state, ipc.EFAULT); + copied += take; + } + architecture.setSystemCallResult(state, devices_broker.deviceCount()); } /// device_claim(id) -> 0/-1: take exclusive ownership of a device for this process. @@ -969,7 +996,17 @@ fn systemSpawn(state: *architecture.CpuState) void { const image = ramdisk_image orelse return fail(state); const rd = initial_ramdisk.Reader.init(image) orelse return fail(state); - const name = @as([*]const u8, @ptrFromInt(ptr))[0..len]; + // Both buffers come in through the checked copy layer, once. The lengths are + // already bounded above, so the staging arrays are small and fixed — and + // because the bytes are now the kernel's own, nothing below can be changed + // by another thread of the caller between validation and use. + var name_storage: [scheduler.maximum_task_name]u8 = undefined; + const name = name_storage[0..@intCast(len)]; + if (!user_memory.copyFromUser(t.address_space, ptr, name)) return failErr(state, ipc.EFAULT); + var argument_storage: [maximum_argument_bytes]u8 = undefined; + const arguments = argument_storage[0..@intCast(arguments_len)]; + if (arguments_len != 0 and !user_memory.copyFromUser(t.address_space, arguments_ptr, arguments)) return failErr(state, ipc.EFAULT); + // Exact path first, basename fallback second; either way argv[0] (and hence // the task name, and the log ring's attribution) is the stored full path. const item = rd.find(name) orelse return fail(state); // no bundled binary by that name @@ -977,8 +1014,7 @@ fn systemSpawn(state: *architecture.CpuState) void { argv[0] = item.name; var argc: usize = 1; if (arguments_len != 0) { - const blob = @as([*]const u8, @ptrFromInt(arguments_ptr))[0..arguments_len]; - var pieces = std.mem.tokenizeScalar(u8, blob, 0); + var pieces = std.mem.tokenizeScalar(u8, arguments, 0); while (pieces.next()) |piece| { if (argc == maximum_arguments) return fail(state); argv[argc] = piece; @@ -1129,8 +1165,27 @@ fn systemProcessEnumerate(state: *architecture.CpuState) void { if (t.address_space == 0 or buffer_ptr >= user_half_end) return fail(state); const sz = @sizeOf(abi.ProcessDescriptor); const cap = @min(maximum, (user_half_end - buffer_ptr) / sz); // clamp to the user half - const out: [*]abi.ProcessDescriptor = @ptrFromInt(buffer_ptr); - architecture.setSystemCallResult(state, scheduler.enumerate(out[0..@intCast(cap)])); + + // The scheduler describes a chunk of the table into kernel memory, then + // `copyToUser` places it — the kernel never stores through the user pointer. + // Once the caller's buffer is full the walk continues with an empty chunk, + // because the result is the true live count, not what fitted. + var chunk: [8]abi.ProcessDescriptor = undefined; + var copied: u64 = 0; + var total: u64 = 0; + var cursor: usize = 0; + while (true) { + const room: []abi.ProcessDescriptor = if (copied < cap) chunk[0..@intCast(@min(chunk.len, cap - copied))] else chunk[0..0]; + const found = scheduler.enumerateFrom(&cursor, room); + total += found.live; + if (found.filled != 0) { + const bytes = std.mem.sliceAsBytes(chunk[0..found.filled]); + if (!user_memory.copyToUser(t.address_space, buffer_ptr + copied * sz, bytes)) return failErr(state, ipc.EFAULT); + copied += found.filled; + } + if (found.done) break; + } + architecture.setSystemCallResult(state, total); } /// process_kill(id) -> 0 / -ESRCH / -EPERM: end the process `id`. Only its @@ -1682,9 +1737,10 @@ fn systemIrqAck(state: *architecture.CpuState) void { /// The pointer must lie in the user (low) half, so kernel addresses and /// non-canonical values fall outside it and the read below can't be steered at /// kernel data. Length is checked first so the upper-bound add can't overflow. -/// Known gap (fine for trusted user code): a pointer into an *unmapped* hole in -/// the user half passes the check and the read #PFs -> on_fault halts — a -/// self-DoS, not an isolation break. Fault-recovering copy-in is a later item. +/// The message then comes in ONCE through the checked copy layer: an unmapped +/// hole in the user half is -EFAULT rather than a kernel fault, and the bytes the +/// log stamps are the same bytes that were validated (the old code read the user +/// buffer twice — once to stage it, once again inside `log.append`). /// /// The emit runs under the kernel lock, so a message is atomic on the wire — two /// processes writing from different cores can interleave *messages*, never bytes. @@ -1697,7 +1753,6 @@ fn systemDebugWrite(state: *architecture.CpuState) void { const len = architecture.systemCallArg(state, 1); const level_raw = architecture.systemCallArg(state, 2); if (len <= write_buffer.len and ptr < user_half_end and ptr + len <= user_half_end) { - const source: [*]const u8 = @ptrFromInt(ptr); // Levels above the enum range clamp to raw — old two-arg callers land // there naturally (garbage in arg 2 stays harmless). const level: abi.KlogLevel = if (level_raw <= @intFromEnum(abi.KlogLevel.raw)) @@ -1707,14 +1762,16 @@ fn systemDebugWrite(state: *architecture.CpuState) void { const t = scheduler.current(); const flags = sync.enter(); defer sync.leave(flags); - @memcpy(write_buffer[0..len], source[0..len]); // keep the latest message + // One copy in, under the lock; `write_buffer` (the latest message, which + // the kernel tests assert on) doubles as the staging buffer the log reads. + if (!user_memory.copyFromUser(t.address_space, ptr, write_buffer[0..len])) return failErr(state, ipc.EFAULT); write_len = len; write_from_user = architecture.fromUser(state); write_count += 1; // The kernel stamps the sender's identity — attribution is structural, // not a prefix convention the payload could forge (and it is stamped // per line inside log.append). - log.append(t.id, t.name(), level, source[0..len]); + log.append(t.id, t.name(), level, write_buffer[0..len]); architecture.setSystemCallResult(state, len); } else { fail(state); @@ -1729,18 +1786,35 @@ fn systemDebugWrite(state: *architecture.CpuState) void { /// [KlogRecordHeader][name][message] frames out of the byte stream (abi.zig). /// /// The mirror of `debug_write`: the same overflow-safe user-half bounds check, -/// but the copy runs kernel -> user, under the log lock (inside log.readAt) so -/// the stream can't move underneath the copy. A read-only diagnostic. +/// but the copy runs kernel -> user. The ring is drained a chunk at a time into a +/// kernel staging buffer (each chunk read under the log lock, so the stream can't +/// move underneath it) and each chunk is then placed with `copyToUser` — a +/// reader may ask for a megabyte, and the kernel stack is 16 KiB. A partial +/// result is honest: the reader advances its cursor by what it got. A read-only +/// diagnostic. fn systemKlogRead(state: *architecture.CpuState) void { const offset = architecture.systemCallArg(state, 0); const ptr = architecture.systemCallArg(state, 1); const len = architecture.systemCallArg(state, 2); + const t = scheduler.current(); // Confine the whole destination span to the user (low) half. `len <= // user_half_end - ptr` bounds the length without an overflowing add. if (ptr < user_half_end and len <= user_half_end - ptr) { - const dest: [*]u8 = @ptrFromInt(ptr); - const n = log.readAt(offset, dest[0..len]) orelse return fail(state); - architecture.setSystemCallResult(state, n); + var chunk: [512]u8 = undefined; + var done: u64 = 0; + while (done < len) { + const want = @min(@as(u64, chunk.len), len - done); + const n = log.readAt(offset + done, chunk[0..@intCast(want)]) orelse { + // The cursor fell behind the ring's tail mid-drain. What was + // already placed stands; only a first-chunk miss fails the call. + if (done == 0) return fail(state); + break; + }; + if (n == 0) break; // caught up + if (!user_memory.copyToUser(t.address_space, ptr + done, chunk[0..n])) return failErr(state, ipc.EFAULT); + done += n; + } + architecture.setSystemCallResult(state, done); } else { fail(state); } @@ -1753,10 +1827,10 @@ fn systemKlogRead(state: *architecture.CpuState) void { fn systemKlogStatus(state: *architecture.CpuState) void { const ptr = architecture.systemCallArg(state, 0); const size = @sizeOf(abi.KlogStatus); + const t = scheduler.current(); if (ptr < user_half_end and size <= user_half_end - ptr) { var status = log.status(); - const dest: [*]u8 = @ptrFromInt(ptr); - @memcpy(dest[0..size], std.mem.asBytes(&status)[0..size]); + if (!user_memory.copyValueToUser(t.address_space, ptr, &status)) return failErr(state, ipc.EFAULT); architecture.setSystemCallResult(state, 0); } else { fail(state); @@ -1775,10 +1849,12 @@ fn systemFsResolve(state: *architecture.CpuState) void { const flags = architecture.systemCallArg(state, 2); const out_ptr = architecture.systemCallArg(state, 3); const out_cap = architecture.systemCallArg(state, 4); - if (path_len == 0 or path_len > 224 or path_ptr >= user_half_end or path_ptr + path_len > user_half_end) return fail(state); - if (out_cap != 0 and (out_ptr >= user_half_end or out_ptr + out_cap > user_half_end)) return fail(state); - const path = @as([*]const u8, @ptrFromInt(path_ptr))[0..path_len]; + if (path_len == 0 or path_len > maximum_resolve_path or path_ptr >= user_half_end or path_ptr + path_len > user_half_end) return fail(state); + if (out_cap != 0 and !user_memory.userRangeOk(out_ptr, @intCast(out_cap))) return fail(state); const t = scheduler.current(); + var path_storage: [maximum_resolve_path]u8 = undefined; + const path = path_storage[0..@intCast(path_len)]; + if (!user_memory.copyFromUser(t.address_space, path_ptr, path)) return failErr(state, ipc.EFAULT); const flags_lock = sync.enter(); defer sync.leave(flags_lock); @@ -1790,14 +1866,15 @@ fn systemFsResolve(state: *architecture.CpuState) void { .backend => |*backend| { // The rewritten path goes back in the out buffer behind a u16 // length prefix (a third result register would collide with r8's - // argument role in the userspace stub). + // argument role in the userspace stub). Both halves are placed with + // the checked copy, and *before* the handle is installed, so an + // -EFAULT never strands a capability in the caller's table. if (backend.path_len + 2 > out_cap) return fail(state); + const prefix = [2]u8{ @intCast(backend.path_len & 0xFF), @intCast(backend.path_len >> 8) }; + if (!user_memory.copyToUser(t.address_space, out_ptr, &prefix)) return failErr(state, ipc.EFAULT); + if (!user_memory.copyToUser(t.address_space, out_ptr + 2, backend.path[0..backend.path_len])) return failErr(state, ipc.EFAULT); const handle = ipc.installHandleDeduped(t, backend.endpoint); if (handle < 0) return fail(state); - const destination: [*]u8 = @ptrFromInt(out_ptr); - destination[0] = @intCast(backend.path_len & 0xFF); - destination[1] = @intCast(backend.path_len >> 8); - @memcpy(destination[2..][0..backend.path_len], backend.path[0..backend.path_len]); architecture.setSystemCallResult(state, abi.fs_route_backend); architecture.setSystemCallResult2(state, @intCast(handle)); }, @@ -1809,6 +1886,12 @@ fn systemFsResolve(state: *architecture.CpuState) void { /// kernel-backed node. read copies file bytes; status copies a FileAttributes; /// readdir copies [DirectoryEntryHeader][name] for the `offset`th child. Reads /// of the immutable initrd never take the kernel lock. +/// +/// Every result reaches the caller through `copyToUser`, never a store through +/// the user pointer. `read` stages the file bytes a chunk at a time — a caller +/// may ask for the 64 KiB ceiling, which no kernel stack could hold — so a +/// mid-way -EFAULT is possible; the call fails and the already-placed prefix is +/// meaningless, exactly as a failed read should be. fn systemFsNode(state: *architecture.CpuState) void { const operation = architecture.systemCallArg(state, 0); const node_token = architecture.systemCallArg(state, 1); @@ -1817,16 +1900,24 @@ fn systemFsNode(state: *architecture.CpuState) void { const buf_len = architecture.systemCallArg(state, 4); if (buf_ptr >= user_half_end or buf_len > user_half_end - buf_ptr) return fail(state); const capped = @min(buf_len, 64 * 1024); // bound any single copy - const destination: [*]u8 = @ptrFromInt(buf_ptr); + const t = scheduler.current(); switch (operation) { abi.fs_node_read => { - const n = vfs.nodeRead(node_token, offset, destination[0..capped]) orelse return fail(state); - architecture.setSystemCallResult(state, n); + var chunk: [512]u8 = undefined; + var done: u64 = 0; + while (done < capped) { + const want = @min(@as(u64, chunk.len), capped - done); + const n = vfs.nodeRead(node_token, offset + done, chunk[0..@intCast(want)]) orelse return fail(state); + if (n == 0) break; // end of file + if (!user_memory.copyToUser(t.address_space, buf_ptr + done, chunk[0..n])) return failErr(state, ipc.EFAULT); + done += n; + } + architecture.setSystemCallResult(state, done); }, abi.fs_node_status => { var attributes = vfs.nodeStatus(node_token) orelse return fail(state); if (capped < @sizeOf(abi.FileAttributes)) return fail(state); - @memcpy(destination[0..@sizeOf(abi.FileAttributes)], std.mem.asBytes(&attributes)); + if (!user_memory.copyValueToUser(t.address_space, buf_ptr, &attributes)) return failErr(state, ipc.EFAULT); architecture.setSystemCallResult(state, @sizeOf(abi.FileAttributes)); }, abi.fs_node_readdir => { @@ -1837,10 +1928,13 @@ fn systemFsNode(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, 0); // past the end return; }; - var header = result.header; + // Header and name are staged contiguously so one entry is one copy. + var entry: [@sizeOf(abi.DirectoryEntryHeader) + name_buffer.len]u8 = undefined; + const header = result.header; const total = header_size + @min(result.name_len, capped - header_size); - @memcpy(destination[0..header_size], std.mem.asBytes(&header)); - @memcpy(destination[header_size..total], name_buffer[0 .. total - header_size]); + @memcpy(entry[0..header_size], std.mem.asBytes(&header)); + @memcpy(entry[header_size..total], name_buffer[0 .. total - header_size]); + if (!user_memory.copyToUser(t.address_space, buf_ptr, entry[0..total])) return failErr(state, ipc.EFAULT); architecture.setSystemCallResult(state, total); }, else => fail(state), @@ -1857,12 +1951,19 @@ fn systemFsMount(state: *architecture.CpuState) void { const backend_handle = architecture.systemCallArg(state, 2); const rewrite_ptr = architecture.systemCallArg(state, 3); const rewrite_len = architecture.systemCallArg(state, 4); - if (prefix_len == 0 or prefix_len > 64 or prefix_ptr >= user_half_end or prefix_ptr + prefix_len > user_half_end) return fail(state); - if (rewrite_len > 32) return fail(state); + if (prefix_len == 0 or prefix_len > maximum_mount_prefix or prefix_ptr >= user_half_end or prefix_ptr + prefix_len > user_half_end) return fail(state); + if (rewrite_len > maximum_mount_rewrite) return fail(state); if (rewrite_len != 0 and (rewrite_ptr >= user_half_end or rewrite_ptr + rewrite_len > user_half_end)) return fail(state); const t = scheduler.current(); - const prefix = @as([*]const u8, @ptrFromInt(prefix_ptr))[0..prefix_len]; - const rewrite = if (rewrite_len == 0) "" else @as([*]const u8, @ptrFromInt(rewrite_ptr))[0..rewrite_len]; + // Both strings come in through the checked copy; `mountBackend` copies them + // again into the mount table, so these staging buffers only need to outlive + // this call. + var prefix_storage: [maximum_mount_prefix]u8 = undefined; + const prefix = prefix_storage[0..@intCast(prefix_len)]; + if (!user_memory.copyFromUser(t.address_space, prefix_ptr, prefix)) return failErr(state, ipc.EFAULT); + var rewrite_storage: [maximum_mount_rewrite]u8 = undefined; + const rewrite = rewrite_storage[0..@intCast(rewrite_len)]; + if (rewrite_len != 0 and !user_memory.copyFromUser(t.address_space, rewrite_ptr, rewrite)) return failErr(state, ipc.EFAULT); const flags = sync.enter(); defer sync.leave(flags); @@ -1879,8 +1980,11 @@ fn systemFsMount(state: *architecture.CpuState) void { fn systemFsUnmount(state: *architecture.CpuState) void { const prefix_ptr = architecture.systemCallArg(state, 0); const prefix_len = architecture.systemCallArg(state, 1); - if (prefix_len == 0 or prefix_len > 64 or prefix_ptr >= user_half_end or prefix_ptr + prefix_len > user_half_end) return fail(state); - const prefix = @as([*]const u8, @ptrFromInt(prefix_ptr))[0..prefix_len]; + if (prefix_len == 0 or prefix_len > maximum_mount_prefix or prefix_ptr >= user_half_end or prefix_ptr + prefix_len > user_half_end) return fail(state); + const t = scheduler.current(); + var prefix_storage: [maximum_mount_prefix]u8 = undefined; + const prefix = prefix_storage[0..@intCast(prefix_len)]; + if (!user_memory.copyFromUser(t.address_space, prefix_ptr, prefix)) return failErr(state, ipc.EFAULT); const flags = sync.enter(); defer sync.leave(flags); if (!vfs.unmount(prefix)) return fail(state); diff --git a/system/kernel/scheduler.zig b/system/kernel/scheduler.zig index b3b5c0f..8dcbbc2 100644 --- a/system/kernel/scheduler.zig +++ b/system/kernel/scheduler.zig @@ -1203,35 +1203,72 @@ pub fn destroyTaskLocked(t: *Task) void { /// Snapshot the task table into `out` (up to its length), returning the total /// number of live tasks — the kernel half of `process_enumerate`, mirroring /// devices_broker.enumerate. Kernel tasks are included (empty name, supervisor 0): -/// an honest `ps` shows the idle tasks too. `out` may be user memory: the caller's -/// address space is loaded during its system call, and the same bring-up trust -/// applies as for device_enumerate (an unmapped user page faults the kernel). +/// an honest `ps` shows the idle tasks too. `out` is always KERNEL memory: the +/// system call bounces it out to the caller through the checked copy layer +/// (system/kernel/user-memory.zig), so a bad user pointer fails the call instead +/// of faulting ring 0. pub fn enumerate(out: []abi.ProcessDescriptor) u64 { + var total: u64 = 0; + var cursor: usize = 0; + while (true) { + // Past the buffer, keep walking with an empty chunk: the total is the + // whole live count, however few descriptors the caller had room for. + const room = if (total < out.len) out[@intCast(total)..] else out[out.len..]; + const chunk = enumerateFrom(&cursor, room); + total += chunk.live; + if (chunk.done) return total; + } +} + +/// What one chunk of the task-table walk found. +pub const TaskChunk = struct { + /// Live tasks passed in this chunk, whether or not they fit in `out` — this + /// is what the running total (and hence `process_enumerate`'s result) counts. + live: usize, + /// How many of those were written into `out` (`@min(live, out.len)`). + filled: usize, + /// The cursor reached the end of the table: this was the last chunk. + done: bool, +}; + +/// One chunk of the task table: starting at slot `cursor` (advanced past +/// everything scanned), describe up to `out.len` live tasks into `out` — or, with +/// an empty `out`, just count the rest. `cursor == tasks.len` ends the walk. +/// +/// The chunked form exists so `process_enumerate` can stage each chunk in a small +/// kernel buffer and copy it out with `user_memory.copyToUser`, rather than +/// handing a user pointer to the kernel's own stores. A *slot* cursor, rather +/// than a "skip the first N live tasks" count, keeps chunks from duplicating or +/// losing an entry when a task exits between them. +pub fn enumerateFrom(cursor: *usize, out: []abi.ProcessDescriptor) TaskChunk { const flags = sync.enter(); defer sync.leave(flags); - var total: u64 = 0; - for (&tasks) |*t| { + var live: usize = 0; + var filled: usize = 0; + const limit = if (out.len == 0) tasks.len else out.len; // always makes progress + while (cursor.* < tasks.len and live < limit) { + const t = &tasks[cursor.*]; + cursor.* += 1; if (t.state == .free or t.state == .reaping) continue; // reaping = already exited - if (total < out.len) { - const d = &out[total]; - d.* = .{ - .id = t.id, - .supervisor = t.supervisor, - .leader = t.leader, - .state = @intFromEnum(@as(abi.ProcessState, switch (t.state) { - .ready => .ready, - .running => .running, - .blocked => .blocked, - .free, .reaping => unreachable, - })), - .priority = t.priority, - .name_length = t.name_length, - .name = t.name_buffer, - }; - } - total += 1; + live += 1; + if (filled == out.len) continue; + out[filled] = .{ + .id = t.id, + .supervisor = t.supervisor, + .leader = t.leader, + .state = @intFromEnum(@as(abi.ProcessState, switch (t.state) { + .ready => .ready, + .running => .running, + .blocked => .blocked, + .free, .reaping => unreachable, + })), + .priority = t.priority, + .name_length = t.name_length, + .name = t.name_buffer, + }; + filled += 1; } - return total; + return .{ .live = live, .filled = filled, .done = cursor.* >= tasks.len }; } /// Whether the running task is a user process (has its own address space). diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index b7cab8a..d783a1c 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -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; diff --git a/system/kernel/user-memory.zig b/system/kernel/user-memory.zig new file mode 100644 index 0000000..47af30c --- /dev/null +++ b/system/kernel/user-memory.zig @@ -0,0 +1,117 @@ +//! The single trusted door between ring 0 and a process's memory. +//! +//! Kernel code never dereferences a user virtual address. It walks that address +//! space's page tables through the physmap — kernel mappings throughout — and +//! moves the bytes there. Three properties fall out of that one decision: +//! +//! - **A bad pointer fails the system call.** danos has no fault-recovering +//! copy-in, so a raw dereference of an unmapped-but-in-range user page would +//! halt the machine. Here it is a `false` return and an `-EFAULT`. +//! - **The copy is a single fetch.** A struct pulled in once cannot be changed +//! underneath the checks that follow it — no TOCTOU against a hostile pointer. +//! - **It is SMAP-proof by construction.** No ring-0 access to a user-mapped +//! page ever happens, so the CR4.SMAP bit needs no `stac` window anywhere +//! (docs/os-development/smep-smap.md). There is no `stac` in this tree, and a +//! change that adds one is wrong by definition. +//! +//! The walk enforces the permissions ring 3 itself would face: a read needs the +//! leaf user-accessible (U/S at every level), a write needs it writable too +//! (R/W at every level). So a syscall argument cannot steer the kernel at a +//! kernel-only mapping, nor make it write a process's own read-only text — the +//! properties that matter once shared or copy-on-write mappings exist, and the +//! reason the "presence only" caveat that used to sit at the top of +//! ipc-synchronous.zig is gone. +//! +//! Scope: this module knows only about *user* address spaces. The kernel side of +//! a copy (an IPC reply staged in kernel memory, a bounce buffer) is trusted and +//! translated without permission checks — see `resolve`. + +const std = @import("std"); +const boot_handoff = @import("boot-handoff"); +const abi = @import("abi"); +const architecture = @import("architecture"); + +const page_size = abi.page_size; + +/// End of the user (low) canonical half. Every user buffer must lie below it, so +/// kernel addresses and non-canonical values are refused by the range check +/// alone, before any table is read. +pub const user_half_end: u64 = 0x0000_8000_0000_0000; + +/// Whether `[virtual, virtual + len)` lies wholly inside the user half. The +/// length is compared against the remaining span rather than added to the base, +/// so a huge `len` cannot wrap the check. +pub fn userRangeOk(virtual: u64, len: usize) bool { + if (virtual >= user_half_end) return false; + return len <= user_half_end - virtual; +} + +/// Resolve one address for a copy. `address_space == 0` means the kernel's own +/// tables — trusted, translated as-is. A real address space is a process's, and +/// the walk demands what ring 3 would need: user-accessible, plus writable when +/// this side of the copy is the destination. +/// +/// A user frame must also be reachable *through the physmap*, because that is how +/// the copy loops touch it. The physmap covers RAM only: `paging.init` skips every +/// `.mmio` region, while `mmio_map` hands a driver its device's BAR as an ordinary +/// user-accessible mapping. Such a page satisfies the permission walk and would +/// then fault ring 0 on the physmap alias — the very #PF this layer exists to make +/// impossible — so coverage is confirmed before the address is returned, and an +/// uncovered frame is refused like any other bad buffer. (Confirming coverage, +/// rather than testing the `device_grant` bit, is what keeps physmap-backed RAM +/// that merely carries that bit — a scanout surface — usable as a buffer.) +pub fn resolve(address_space: u64, virtual: u64, for_write: bool) ?u64 { + if (address_space == 0) return architecture.translate(architecture.kernelPageTable(), virtual); + const physical = architecture.translateUser(address_space, virtual, for_write) orelse return null; + if (architecture.translate(architecture.kernelPageTable(), boot_handoff.physicalToVirtual(physical)) == null) return null; + return physical; +} + +/// Copy `destination.len` bytes from `user_va` in address space `user_as` into the +/// kernel buffer `destination`. False — never a #PF — if the range escapes the +/// user half, or any source page is unmapped or not readable from ring 3. +/// Handles page-straddling buffers. +pub fn copyFromUser(user_as: u64, user_va: u64, destination: []u8) bool { + if (user_as == 0) return false; // not a user address space + if (!userRangeOk(user_va, destination.len)) return false; + var off: usize = 0; + while (off < destination.len) { + const physical = resolve(user_as, user_va + off, false) orelse return false; + const left = page_size - ((user_va + off) & (page_size - 1)); + const n = @min(left, destination.len - off); + const source: [*]const u8 = @ptrFromInt(boot_handoff.physicalToVirtual(physical)); + @memcpy(destination[off..][0..n], source[0..n]); + off += n; + } + return true; +} + +/// The write direction: copy the kernel buffer `source` out to `user_va` in +/// address space `user_as`. False — never a #PF, never a partial promise — if the +/// range escapes the user half, or any destination page is unmapped, kernel-only, +/// or read-only for ring 3. (A refusal mid-way may already have written earlier +/// pages; the caller fails the whole system call, so the buffer's contents are +/// meaningless either way.) +/// +/// The mirror of `copyFromUser`, and the only way kernel data reaches a user +/// buffer outside the IPC path's `copyAcross`. +pub fn copyToUser(user_as: u64, user_va: u64, source: []const u8) bool { + if (user_as == 0) return false; // not a user address space + if (!userRangeOk(user_va, source.len)) return false; + var off: usize = 0; + while (off < source.len) { + const physical = resolve(user_as, user_va + off, true) orelse return false; + const left = page_size - ((user_va + off) & (page_size - 1)); + const n = @min(left, source.len - off); + const destination: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(physical)); + @memcpy(destination[0..n], source[off..][0..n]); + off += n; + } + return true; +} + +/// `copyToUser` for any fixed-layout value — the shape most write-direction +/// system calls want (a `KlogStatus`, a `FileAttributes`). +pub fn copyValueToUser(user_as: u64, user_va: u64, value: anytype) bool { + return copyToUser(user_as, user_va, std.mem.asBytes(value)); +} diff --git a/system/kernel/vfs.zig b/system/kernel/vfs.zig index 951c490..830c3f1 100644 --- a/system/kernel/vfs.zig +++ b/system/kernel/vfs.zig @@ -184,11 +184,16 @@ fn installMount(prefix: []const u8, kind: MountKind, backend: ?*ipc.Endpoint, re // --- resolve ----------------------------------------------------------------- +/// Longest rewritten mount-relative path a backend resolution can carry — the +/// size `fs_resolve`'s caller has to have room for, so it is named rather than +/// spelled out at the one place that builds it. +pub const maximum_backend_path = maximum_rewrite + maximum_prefix + 160; + pub const Resolved = union(enum) { /// Kernel-served: a permanent node token. kernel_node: u64, /// Backend-served: the endpoint plus the rewritten mount-relative path. - backend: struct { endpoint: *ipc.Endpoint, path: [maximum_rewrite + maximum_prefix + 160]u8, path_len: usize }, + backend: struct { endpoint: *ipc.Endpoint, path: [maximum_backend_path]u8, path_len: usize }, not_found: void, }; diff --git a/test/qemu_test.py b/test/qemu_test.py index daa2bb5..d294005 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -343,6 +343,16 @@ CASES = [ {"name": "usermem", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # The checked copy layer (system/kernel/user-memory.zig): kernel-side unit + # checks for the bound, presence, and leaf U/S + writable refusals, then a + # fixture aiming unmapped-but-in-range pointers at klog_read/klog_status/ + # process_enumerate/device_enumerate/fs_resolve/debug_write. Each must come + # back as a wrapped -errno with the machine still running — before H1 every + # one of them dereferenced the bad page in ring 0 and halted it. + {"name": "user-memory", + "timeout": 60, + "expect": r"(?s)(?=.*DANOS-TEST-RESULT: PASS)(?=.*user-memory-test: ok)", + "fail": r"user-memory-test: FAIL|DANOS-TEST-RESULT: FAIL|CPU EXCEPTION|KERNEL PANIC"}, # Isolation: a ring-3 read of a kernel-only page must #PF with error code # 0x5 (present|user) at the user IP. ([\s\S] spans lines; `.` doesn't.) {"name": "user-pf", diff --git a/test/system/services/user-memory-test/build.zig b/test/system/services/user-memory-test/build.zig new file mode 100644 index 0000000..a5c46f4 --- /dev/null +++ b/test/system/services/user-memory-test/build.zig @@ -0,0 +1,15 @@ +//! The user-memory-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 = "user-memory-test", + .root_source_file = b.path("user-memory-test.zig"), + .imports = &.{ "abi", "system-call", "logging", "driver" }, + }); + b.installArtifact(exe); +} diff --git a/test/system/services/user-memory-test/build.zig.zon b/test/system/services/user-memory-test/build.zig.zon new file mode 100644 index 0000000..51fda27 --- /dev/null +++ b/test/system/services/user-memory-test/build.zig.zon @@ -0,0 +1,15 @@ +.{ + .name = .user_memory_test, + .version = "0.0.0", + .fingerprint = 0xde57dc97fd81a402, // 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 = .{""}, +} diff --git a/test/system/services/user-memory-test/user-memory-test.zig b/test/system/services/user-memory-test/user-memory-test.zig new file mode 100644 index 0000000..40ce4cb --- /dev/null +++ b/test/system/services/user-memory-test/user-memory-test.zig @@ -0,0 +1,111 @@ +//! user-memory-test — QEMU fixture for the kernel's checked copy layer +//! (system/kernel/user-memory.zig). +//! +//! Every check comes in a pair: the same system call is made once with a sound +//! buffer and once with a pointer that is *inside* the user half but mapped in no +//! process. The sound call must succeed and the unsound one must return a wrapped +//! -errno — proving the refusal is attributable to the pointer and not to the +//! call being impossible. Before H1 the unsound half of each pair dereferenced an +//! unmapped page in ring 0, which halts the machine; the fixture reaching its +//! final marker at all is the substance of the test. +//! +//! Prints `user-memory-test: ok` per pair and `user-memory-test: ok` at +//! the end, which the harness asserts on (test/qemu_test.py). + +const abi = @import("abi"); +const sc = @import("system-call"); +const logging = @import("logging"); +const device = @import("driver"); + +/// A user-half address that is mapped in no process: below the code image +/// (0x0000_7000_0000_0000) and far from every arena the kernel hands out. It +/// passes each system call's bounds check and then fails the page walk — the +/// exact shape the checked copy exists for. +const unmapped: usize = 0x0000_6000_0000_0000; + +/// The kernel returns failures as a small negative errno in the result register. +fn failed(r: usize) bool { + return r > ~@as(usize, 0) - 4095; +} + +var failures: u32 = 0; + +fn check(comptime name: []const u8, ok: bool) void { + if (ok) { + _ = logging.write("user-memory-test: " ++ name ++ " ok\n"); + } else { + failures += 1; + _ = logging.write("user-memory-test: FAIL " ++ name ++ "\n"); + } +} + +/// klog_read writes the ring's bytes out to the caller. Read from the ring's own +/// tail so the offset is certainly valid and the copy is what decides the call. +fn klogRead() void { + var status: abi.KlogStatus = undefined; + if (@as(isize, @bitCast(sc.systemCall1(.klog_status, @intFromPtr(&status)))) != 0) { + check("klog_status (sound out buffer accepted)", false); + return; + } + var buffer: [64]u8 = undefined; + const good = sc.systemCall3(.klog_read, status.tail, @intFromPtr(&buffer), buffer.len); + const bad = sc.systemCall3(.klog_read, status.tail, unmapped, buffer.len); + check("klog_read (bad out buffer refused)", !failed(good) and failed(bad)); + + // The status struct itself is a write-direction copy of its own. + const bad_status = sc.systemCall1(.klog_status, unmapped); + check("klog_status (bad out buffer refused)", failed(bad_status)); +} + +fn processEnumerate() void { + var table: [4]abi.ProcessDescriptor = undefined; + const good = sc.systemCall2(.process_enumerate, @intFromPtr(&table), table.len); + const bad = sc.systemCall2(.process_enumerate, unmapped, table.len); + check("process_enumerate (bad out buffer refused)", good != 0 and !failed(good) and failed(bad)); +} + +/// Descriptors are hundreds of bytes each — keep them off the stack. Four spans +/// more than one kernel chunk, so the chunked copy-out is genuinely exercised. +var descriptors: [4]device.DeviceDescriptor = undefined; + +fn deviceEnumerate() void { + const good = sc.systemCall2(.device_enumerate, @intFromPtr(&descriptors), descriptors.len); + const bad = sc.systemCall2(.device_enumerate, unmapped, descriptors.len); + check("device_enumerate (bad out buffer refused)", !failed(good) and failed(bad)); +} + +fn fsResolve() void { + var out: [256]u8 = undefined; + const path = "/system/services/init"; + const good = sc.systemCall5(.fs_resolve, @intFromPtr(path.ptr), path.len, 0, @intFromPtr(&out), out.len); + const bad = sc.systemCall5(.fs_resolve, unmapped, path.len, 0, @intFromPtr(&out), out.len); + check("fs_resolve (bad path buffer refused)", !failed(good) and failed(bad)); + + // The out-buffer capacity is unbounded ring-3 input: the range check must + // not add it to the base, or the sum wraps the user-half bound and traps + // the kernel's own overflow check. Surviving this call is the assertion. + const wrapping = sc.systemCall5(.fs_resolve, @intFromPtr(path.ptr), path.len, 0, @intFromPtr(&out), ~@as(usize, 0)); + check("fs_resolve (wrapping out capacity refused)", failed(wrapping)); +} + +/// debug_write reads the caller's message; a bad pointer must not take the +/// kernel down on the way to the log ring. +fn debugWrite() void { + const bad = sc.systemCall3(.debug_write, unmapped, 16, @intFromEnum(abi.KlogLevel.info)); + check("debug_write (bad message buffer refused)", failed(bad)); +} + +pub fn main() void { + klogRead(); + processEnumerate(); + deviceEnumerate(); + fsResolve(); + debugWrite(); + // Reaching here at all means seven bad user pointers failed their calls + // instead of faulting ring 0. + if (failures == 0) { + _ = logging.write("user-memory-test: ok\n"); + } else { + _ = logging.write("user-memory-test: FAILED\n"); + } +}