The User/Kernel Copy Boundary

July 16, 2026 ยท View on GitHub

When the kernel reads or writes a capsule's memory, syscall arguments, an IPC payload, a buffer a driver passed down, it never dereferences the user pointer directly. It validates the range against a pure policy, walks the page tables to confirm every page is present with the right permission, and then transfers the bytes through the physical direct map, with interrupts disabled so the mapping the walk approved cannot change before the copy uses it. This is the surface the Kani proofs exercise for panic-freedom and bounds. The module is layered so that each concern lives in one file, and the code is under src/usercopy/.

Dereferencing a raw user pointer in kernel mode is the classic way a kernel is compromised. The pointer could be null, unmapped, non-canonical, or aimed at the kernel's own address range, and following it either faults in kernel context or turns the kernel into a confused deputy writing where a capsule could not. The boundary here removes the raw dereference entirely: the user virtual address is a value to be validated, and the actual memory access goes through the kernel's direct map at the physical frame the page tables resolve to.

The range policy

The innermost layer is a pure function over an address and a length, with no page walking and no permission decision (src/usercopy/policy.rs:35):

  USER_SPACE_END = 0x0000_7FFF_FFFF_FFFF
  MAX_COPY_SIZE  = 64 MiB

  check_range(addr, len):
      if addr == 0                       -> NullPointer
      if len > MAX_COPY_SIZE              -> SizeTooLarge
      if len == 0                        -> Ok(None)
      end = addr.checked_add(len - 1)    -> AddressOverflow on wrap
      if end > USER_SPACE_END             -> InvalidAddress
      Ok(Some(UserRange { start_page, end_page }))    aligned down to pages

Five things are decided here and nowhere else, so every caller errors the same way: a null base is rejected, a length above the 64 MiB cap is rejected, a zero length is a successful no-op, an address plus length that overflows a u64 is rejected before it can wrap, and a range whose last byte lies above the canonical user limit is rejected. That last check is the one that keeps a user copy inside user space: the end of the range must be at or below USER_SPACE_END, so a buffer that would run off the top of user memory into the non-canonical gap or the kernel half never passes. The function returns the page-aligned range for the next layer to walk, or None when there is nothing to copy.

The per-page walk

The next layer applies the range policy and then walks the pages (src/usercopy/validate.rs:34):

  validate(addr, len, need_write):
      range = check_range(addr, len)?     None -> Ok, nothing to copy
      for each page from range.start_page to range.end_page:
          if need_write: translate_write(page)?
          else:          translate_read(page)?

validate_user_read calls this with need_write = false and validate_user_write with true. For every page the range touches, it asks the walker (src/usercopy/walk/) to resolve the page and confirm it is present and carries the required permission: translate_read for a read, translate_write for a write. A page that is not present, or is present but lacks the needed permission, fails the walk and the whole validation fails. The page loop advances with a saturating add and breaks if the address wraps to zero, so it terminates even at the very top of the address space. After validate returns Ok, every page of the range is known to be a present, correctly-permissioned user page.

The copy

The outer layer validates and then transfers, and it never touches the user pointer as a pointer (src/usercopy/copy.rs:27):

  copy_from_user(user_ptr, dst):
      run_without_interrupts(||
          validate_user_read(user_ptr, dst.len())?
          copy_from_user_directmap(user_ptr, dst))

  copy_to_user(user_ptr, src):
      run_without_interrupts(||
          validate_user_write(user_ptr, src.len())?
          copy_to_user_directmap(user_ptr, src))

The transfer goes through copy_from_user_directmap and copy_to_user_directmap (src/usercopy/direct.rs), which the module documentation states copy through the direct map after validation has cleared the range and access policy, so the user virtual address is never dereferenced. The kernel reaches the user's bytes at the physical frame the walk resolved, through the direct map, not by following the user pointer in the current address space.

Why interrupts are disabled

The whole validate-then-copy sequence runs inside run_without_interrupts. That is what closes the time-of-check-to-time-of-use gap. If a timer interrupt could fire between the page walk and the copy, the faulting process could be preempted and its mappings changed, so the page the walk approved might not be the page the copy touches. Holding interrupts off across both steps means the mapping validated is the mapping used. It also keeps the copy from being interrupted partway and re-entering the paging paths, matching the interrupt discipline the paging manager uses.

Errors and the wider module

The error type is UsercopyError (src/usercopy/error.rs): the range policy returns NullPointer, SizeTooLarge, AddressOverflow, and InvalidAddress, and the walk adds its presence and permission failures. Around the byte-slice copies documented here, the module also provides typed value copies, string copies with the same range rules (src/usercopy/string.rs), and the low-level direct-map helpers, all built on the same check_range and walk foundation so they all agree on what a valid user range is.

Security analysis

Dereferencing a raw user pointer in kernel mode is the classic kernel-compromise primitive, and this boundary exists to remove it entirely. Four properties draw the bound.

The range is confined to user space, before any access. check_range (policy.rs:35) is a pure function that rejects a null base (NullPointer), a length over the 64 MiB cap (SizeTooLarge), and an addr + len that overflows a u64 (AddressOverflow) before it can wrap, and requires the last byte to lie at or below USER_SPACE_END (0x0000_7FFF_FFFF_FFFF), else InvalidAddress. That last check is what keeps a copy inside user space: a buffer that would run off the top of user memory into the non-canonical gap or the kernel half never passes. Because this is one pure function, every caller, byte copies, typed copies, string copies, errors the same way.

Every page is confirmed present and correctly permissioned. validate (validate.rs:34) walks each page of the range and asks the walker to resolve it and confirm the required access: translate_read for a read, translate_write for a write. A page that is not present fails (PageNotMapped), a page not accessible from userspace fails (PageNotUser), and a write to a non-writable page fails (PageNotWritable). So the kernel never writes where the capsule itself could not, which is the confused-deputy case this closes. The walk uses the faulting process's own page-table root (walk/root.rs), so it is checking the capsule's real mappings, not the kernel's.

The access goes through the direct map, never through the user pointer. After validation, copy_from_user_directmap and copy_to_user_directmap (direct.rs) reach the user's bytes at the physical frame the walk resolved, addressed through the kernel direct map. The user virtual address is a value that was validated, never a pointer that is dereferenced. This is also what SMAP requires: with CR4.SMAP set by the hardening subsystem, a direct kernel-mode load from a user address would fault, so the direct-map route is not just cleaner, it is the route SMAP leaves open.

The check and the copy are one atomic unit. The whole validate-then-copy runs inside run_without_interrupts (copy.rs:27), which closes the time-of-check-to-time-of-use gap: if a timer interrupt could fire between the walk and the copy, the process could be preempted and its mappings changed, so the page the walk approved might not be the page the copy touches. Holding interrupts off means the mapping validated is the mapping used. The honest boundary: the walk resolves the mapping as it stands at validation time under interrupts-off, so within the copy it is stable, but this boundary protects the kernel from a hostile user pointer; it does not by itself serialise a user thread racing its own buffer on another CPU, which is the caller's concern.

Debugging the copy boundary

Every rejection is a UsercopyError (error.rs) with a Display string and an errno, so a failed copy tells you which stage refused it and what the syscall returns:

  NullPointer        "null pointer"                          -14 (EFAULT)   addr == 0
  InvalidAddress     "invalid user address"                  -14            last byte above USER_SPACE_END
  AddressOverflow    "address overflow"                      -14            addr + len wrapped
  MisalignedAddress  "misaligned user address"               -14
  PageNotMapped      "page not mapped"                       -14            a page in the range is absent
  PageNotUser        "page not accessible from userspace"    -14            a kernel-only page in range
  PageNotWritable    "page not writable"                     -14            a write to a read-only page
  PageTableCorrupt   "page table outside directmap"          -14            a walk hit a bad table pointer
  PageFault          "page fault during access"              -14
  NoProcessContext   "no process context"                   -3  (ESRCH)    no current process to walk
  SizeTooLarge       "copy size too large"                  -12 (ENOMEM)   len > MAX_COPY_SIZE (64 MiB)
  InvalidUtf8        "invalid UTF-8 string"                 -22 (EINVAL)   a string copy that is not UTF-8

Almost everything collapses to EFAULT (-14) at the syscall boundary, which is deliberate: a capsule passing a bad pointer gets a single "bad address" answer regardless of how it was bad, so the precise reason is for the kernel-side log, not the capsule. The variants that distinguish a policy rejection from a walk rejection are the tell: NullPointer, InvalidAddress, AddressOverflow, and SizeTooLarge come from check_range before any page is touched, so they mean the range itself was illegal (a null, an out-of-user-space, or an oversize buffer). PageNotMapped, PageNotUser, and PageNotWritable come from the per-page walk, so they mean the range was legal but the capsule's mappings did not back it with the required permission, which is the usual "capsule handed a buffer it had not faulted in or had mapped read-only" case. PageTableCorrupt ("page table outside directmap") is the one that signals something deeper: a page-table pointer that does not lie in the direct map, which is a corrupted or hostile page-table state rather than an ordinary bad buffer. NoProcessContext means the copy was attempted with no current process to resolve a root against, an ordering bug in the caller.

Verification

This boundary is one of the surfaces the verification stack proves rather than just tests. The Kani harnesses in the kernel proof crates check that the validation and copy paths are panic-free and undefined-behaviour-free over bounded inputs, and the runnable proofs check the range invariants, so the guarantee that a user copy stays within user space and never dereferences an unvalidated pointer is machine-checked, not only asserted here.

Source map

  src/usercopy/policy.rs    check_range, the pure range rules and the limits
  src/usercopy/validate.rs  validate_user_read / validate_user_write, the page walk
  src/usercopy/copy.rs      copy_from_user / copy_to_user
  src/usercopy/direct.rs    the direct-map transfer helpers
  src/usercopy/walk/        the page-table walk and permission decision
  src/usercopy/error.rs     UsercopyError, its Display strings and errnos

Every reference above is verified against those trees. The SMAP bit that forces the direct-map route is set on the hardening page, the interrupt discipline matches the paging manager's, and the Kani proofs are on the verification stack page.