The access_ok Check and User Pointer Validation

access_ok(ptr, size) is the cheap, branch-predictable gate the kernel runs before it touches a userspace-supplied pointer. Its single job is a range check: confirm that the half-open interval [ptr, ptr + size) lies entirely within the portion of the virtual address space that userspace is allowed to name — never reaching up into kernel addresses. It is the first line of defence against the classic attack where a malicious or buggy program hands the kernel a pointer like 0xffffffff81000000 (a kernel text address) and tricks a syscall handler into reading or, far worse, writing there with kernel privilege. Crucially, access_ok() is necessary but not sufficient: it proves the pointer is in the user range, not that the page behind it is mapped, present, or accessible. The actual fault-tolerance — turning a dereference of an unmapped-but-in-range user page into a clean -EFAULT instead of a kernel oops — is the job of the uaccess exception table, not access_ok(). This note traces the x86-64 implementation as it stands in Linux 6.12 LTS (released 2024-11-17), per the primary sources include/asm-generic/access_ok.h and arch/x86/include/asm/uaccess_64.h.

This note owns the range-check mechanics. The copy primitives that call access_ok() before moving bytes are its sibling copy_to_user and copy_from_user; the single-value accessors are get_user and put_user Accessors; the fault-recovery half of the safety story is The uaccess Exception Table and Fault Handling; the deeper Spectre treatment of why the check is followed by a speculation barrier lives in Speculation Barriers and Spectre Hardening at the Syscall Boundary. This note cross-links to all four rather than re-deriving them.


Mental Model — A Fence, Not a Lock

flowchart TB
  subgraph VA["Virtual address space (x86-64, 4-level paging)"]
    direction TB
    LOW["User range<br/>0x0 .. USER_PTR_MAX<br/>(≈ 0x00007fff_ffffffff)"]
    GAP["Non-canonical gap<br/>(huge hole, guard page below)"]
    HIGH["Kernel range<br/>0xffff8000_00000000 .. top"]
  end
  PTR["User-supplied pointer ptr<br/>(from a syscall argument)"] --> CHK{"access_ok(ptr, size)<br/>= valid_user_address(ptr+size)?"}
  CHK -->|"ptr in LOW: TRUE"| OK["proceed to copy<br/>(still may fault → exception table)"]
  CHK -->|"ptr in GAP or HIGH: FALSE"| EFAULT["return -EFAULT<br/>before touching memory"]
  LOW -.-> CHK
  HIGH -.-> CHK

The conceptual job of access_ok() on x86-64. What it shows: the address space is split into a low user range and a high kernel range separated by an enormous non-canonical gap; access_ok() is a fence that admits only pointers landing in the user range, rejecting kernel and gap addresses up front. The insight to take: access_ok() is a fence around an address range, not a lock on a page. Passing it means “this address is one userspace is entitled to name,” NOT “this address is backed by mapped memory” — the latter is discovered only when the access actually faults, and recovered by the exception table.


Why the check has to exist

The structural problem is the unified address space. On Linux/x86-64 the kernel and the currently running user process share one set of page tables: the kernel occupies the high half of the canonical address space and every user process maps into the low half. This is what makes a syscall handler able to dereference a user pointer with a plain mov instruction at all — there is no segment switch, no separate address space to attach. The exception-tables documentation puts it plainly: “Thanks to the unified address space we can just access the address in user memory.”

That convenience is also the danger. When a process calls, say, read(fd, buf, count), buf is a pointer chosen entirely by userspace. The handler runs in ring 0 with the privilege to read and write any address in the unified space, kernel memory included. If the handler blindly dereferenced buf, a program could pass a kernel address and have the kernel either leak kernel data into its own buffer (an information disclosure) or, on a write path like write()/copy_from_user’s mirror, corrupt kernel memory directly (a privilege-escalation primitive). The whole point of the user/kernel boundary collapses if user-controlled pointers are trusted.

Historically (pre-2.x and into the 2.0 era) the kernel ran a function verify_area() that walked the process’s virtual memory areas (VMAs) to confirm the target region was mapped and had the right permissions. As the exception-tables doc recounts, profiling showed this VMA walk “used up a considerable amount of time” for what is almost always a valid pointer. Linus Torvalds’s decision was to stop verifying mappings in software and instead let the MMU do that part: keep only a trivial range test (access_ok(), the successor to verify_area()) and let any subsequent fault on an in-range-but-unmapped page be caught by the exception table. That division of labour — cheap range test up front, hardware fault catch behind it — is the architecture of every user access in the kernel today.


The generic implementation — TASK_SIZE_MAX and the range test

Most architectures use the portable definition in include/asm-generic/access_ok.h:

static inline int __access_ok(const void __user *ptr, unsigned long size)
{
	unsigned long limit = TASK_SIZE_MAX;
	unsigned long addr = (unsigned long)ptr;
 
	if (IS_ENABLED(CONFIG_ALTERNATE_USER_ADDRESS_SPACE) ||
	    !IS_ENABLED(CONFIG_MMU))
		return true;
 
	return (size <= limit) && (addr <= (limit - size));
}

Walking it symbol by symbol:

  • TASK_SIZE_MAX is the architecture’s constant upper bound on a user address — the first address userspace may not name. On a 64-bit kernel running a 64-bit task this is the top of the user half (the canonical user range ceiling); TASK_SIZE itself varies for 32-bit compat tasks, so TASK_SIZE_MAX is the fixed worst-case ceiling used here so the comparison is against a compile-time constant.
  • CONFIG_ALTERNATE_USER_ADDRESS_SPACE covers architectures (m68k, s390, parisc, sparc64) where user and kernel live in separate address spaces — there a user pointer can never alias a kernel address, so the check is vacuously true. Likewise !CONFIG_MMU (no memory protection at all): nothing to check.
  • The return expression is the careful part. The naive test addr + size <= limit can overflow: if addr is near ULONG_MAX and size is large, addr + size wraps around to a small number and passes a check it should fail. Rewriting it as (size <= limit) && (addr <= limit - size) is overflow-safe: limit - size is computed first (and size <= limit guarantees it does not underflow), then addr is compared against it. The comment notes this formulation, contributed by Jonas Bonn for OpenRISC, compiles to a single comparison when size is a compile-time constant — which it almost always is for get_user/put_user.

The macro wrapper adds branch-prediction guidance:

#define access_ok(addr, size) likely(__access_ok(addr, size))

likely() tells the compiler the check normally passes (correct programs pass valid pointers), so the failure path is laid out off the hot path. This is a recurring theme: the user/kernel boundary is crossed billions of times for every rare hostile pointer, so every primitive is optimized for the legitimate case.


The x86-64 implementation — valid_user_address and a boot-patched immediate

x86-64 does not use the generic version; it overrides __access_ok in arch/x86/include/asm/uaccess_64.h:

#define valid_user_address(x) \
	((__force unsigned long)(x) <= runtime_const_ptr(USER_PTR_MAX))
 
static inline bool __access_ok(const void __user *ptr, unsigned long size)
{
	if (__builtin_constant_p(size <= PAGE_SIZE) && size <= PAGE_SIZE) {
		return valid_user_address(ptr);
	} else {
		unsigned long sum = size + (__force unsigned long)ptr;
 
		return valid_user_address(sum) && sum >= (__force unsigned long)ptr;
	}
}
#define __access_ok __access_ok

There are three ideas here worth unpacking.

1. One comparison against a boot-patched constant. valid_user_address(x) is simply x <= USER_PTR_MAX. runtime_const_ptr(USER_PTR_MAX) is not a memory load — USER_PTR_MAX is a virtual variable (the header says “there’s no actual backing store for this”) whose value is patched directly into the instruction stream at boot via the runtime-const mechanism. So the comparison is a cmp reg, $imm against an immediate baked into the code — no cache line touched, no register spilled. This is visible in the assembly version in arch/x86/lib/getuser.S, where check_range loads a placeholder movq $0x0123456789abcdef,%rdx and a runtime_ptr_USER_PTR_MAX relocation rewrites that immediate at boot.

2. The size check is usually free. For the common case — size is a compile-time constant ≤ one page — only the start pointer is checked (valid_user_address(ptr)), with no addition at all. The header comment explains why this is safe: “we always have at least one guard page between the max user address and the non-canonical gap, allowing us to ignore small sizes entirely.” A small access starting at a valid user address cannot cross into kernel space because the guard page absorbs the slop. Only for large or runtime-sized accesses does the else branch compute sum = ptr + size and additionally require sum >= ptr (the overflow guard, mirroring the generic version).

3. Where USER_PTR_MAX comes from — and the LAM caveat. USER_PTR_MAX is initialized in arch/x86/kernel/cpu/common.c:

if (IS_ENABLED(CONFIG_X86_64)) {
	unsigned long USER_PTR_MAX = TASK_SIZE_MAX-1;
 
	/*
	 * Enable this when LAM is gated on LASS support
	if (cpu_feature_enabled(X86_FEATURE_LAM))
		USER_PTR_MAX = (1ul << 63) - PAGE_SIZE - 1;
	 */
	runtime_const_init(ptr, USER_PTR_MAX);

In 6.12, USER_PTR_MAX is TASK_SIZE_MAX - 1. The Linear Address Masking (LAM) branch that would relax this to (1<<63) - PAGE_SIZE - 1 is commented out — the code comment says it must wait until LAM is gated on LASS (Linear Address Space Separation) hardware support. So in 6.12, the range ceiling is the conventional user-half top regardless of whether the CPU advertises LAM.

Uncertain

Verify: whether a later LTS (e.g. 6.18, released 2025-11-30) un-comments the LAM branch and lets USER_PTR_MAX rise to (1<<63) - PAGE_SIZE - 1 when LAM+LASS are present. Reason: the 6.12 source shows the branch disabled pending LASS gating; this is fast-moving x86 hardware-enablement work. To resolve: grep USER_PTR_MAX in arch/x86/kernel/cpu/common.c at the v6.18 tag. uncertain


Tag bits and tolerating non-canonical addresses

The uaccess_64.h comment above __access_ok explains a subtlety introduced by LAM and pointer tagging: “User pointers can have tag bits on x86-64. This scheme tolerates arbitrary values in those bits rather than masking them off.” The check is deliberately imprecise. Rather than canonicalize or mask a tagged pointer before comparing, x86-64 enforces just two rules — the pointer must be in the user part of the space, and ptr+size must not overflow into kernel addresses — and relies on the guard page plus the sign-boundary slop. arch/x86/mm/extable.c makes the rationale explicit in gp_fault_address_ok: “we end up being imprecise with access_ok(), and allow non-canonical user addresses to make the range comparisons simpler, and to not have to worry about LAM being enabled. In fact, we allow up to one page of ‘slop’ at the sign boundary.” The cost of imprecision is bounded (a page of slop), and any genuinely bad access still faults and gets handled — so trading exactness for a single cheap comparison is a clear win.


masked_user_access_begin — the speculation-safe alternative

There is a second pattern in uaccess_64.h that exists because a plain access_ok() branch is a Spectre-v1 gadget. After access_ok() returns true, a CPU can still speculatively execute the subsequent access with an out-of-range pointer before the branch resolves, leaking data through cache side channels. The classic fix is a speculation barrier (barrier_nospec, an lfence) after the check — visible as __uaccess_begin_nospec() in uaccess.h and ASM_BARRIER_NOSPEC in the __get_user_nocheck_* paths in getuser.S. The barrier is correct but costs a pipeline stall.

The newer alternative removes the branch entirely by masking the pointer:

static inline void __user *mask_user_address(const void __user *ptr)
{
	unsigned long mask;
	asm("cmp %1,%0\n\t"
	    "sbb %0,%0"
		:"=r" (mask)
		:"r" (ptr),
		 "0" (runtime_const_ptr(USER_PTR_MAX)));
	return (__force void __user *)(mask | (__force unsigned long)ptr);
}
#define masked_user_access_begin(x) ({				\
	__auto_type __masked_ptr = (x);				\
	__masked_ptr = mask_user_address(__masked_ptr);		\
	__uaccess_begin(); __masked_ptr; })

The trick is branchless arithmetic. The inline asm is AT&T syntax: cmp %1,%0 with %0 = USER_PTR_MAX and %1 = ptr computes USER_PTR_MAX - ptr, setting the carry flag iff ptr > USER_PTR_MAX. The following sbb mask, mask (subtract-with-borrow of a register from itself) materializes that carry as a full-width mask: it produces mask = 0 when ptr <= USER_PTR_MAX and mask = 0xffff...ffff (all ones) when ptr is out of range. Then mask | ptr leaves a valid pointer unchanged but forces an out-of-range pointer to ~0 (the top of the address space), where the access is guaranteed to fault and be caught by the exception table. Because there is no conditional branch, there is nothing for the CPU to mis-speculate — the masking is the check, and it cannot be speculated past. The deep Spectre context lives in Speculation Barriers and Spectre Hardening at the Syscall Boundary; the point here is that masked_user_access_begin is a drop-in, fence-free substitute for the if (!access_ok(...)) return -EFAULT; idiom on “dense accesses starting at the address.”


SMAP is complementary, not a simplification

It is tempting to say that hardware SMAP (Supervisor Mode Access Prevention) “simplified” access_ok(). It did not — they solve different problems and work together. access_ok() is a software range check that stops the kernel from following a user-supplied pointer that names a kernel address. SMAP is a hardware backstop: when enabled (CR4.SMAP), any attempt by ring-0 code to access a user-range page faults unless the AC (Alignment Check / Access-Control) flag is set. The kernel sets AC only inside an explicit user-access region using the STAC instruction and clears it with CLAC, via the stac()/clac() helpers in arch/x86/include/asm/smap.h:

#define __uaccess_begin() stac()
#define __uaccess_end()   clac()

You can see this bracketing in getuser.S: ASM_STAC immediately before the user mov, ASM_CLAC immediately after. The two mechanisms are orthogonal:

  • access_ok() catches a user pointer that points up into kernel space (SMAP would not fire — that is a kernel-range access, which ring 0 is allowed to make).
  • SMAP catches a stray kernel access to a user-range page that happens outside a stac/clac window — a kernel bug dereferencing a user pointer it forgot to go through copy_*_user for. Such a bug would silently work without SMAP; with SMAP it faults loudly.

The actual simplification of access_ok() on x86-64 — the single compare against USER_PTR_MAX with a page of slop — comes from the unified-address-space layout and the sign-boundary guard page, not from SMAP. Conflating the two is a common error.


The historical set_fs/KERNEL_DS removal

For most of Linux’s life, the user-range limit was not a constant but a per-thread, mutable value held in thread_info->addr_limit and manipulated with set_fs(USER_DS) / set_fs(KERNEL_DS) plus get_fs(). access_ok() compared against this current limit. The mechanism let kernel code temporarily raise the limit to KERNEL_DS (all addresses valid) so that the same copy_*_user primitives could be pointed at kernel buffers — used by helpers like the old kernel_read/kernel_write and sys_read re-entry from kernel threads.

This was a notorious security liability. If a code path returned to userspace, or was hijacked, with addr_limit still set to KERNEL_DS, then access_ok() would wave through any address — turning every subsequent copy_to_user/copy_from_user into an arbitrary kernel read/write primitive (the “address-limit confusion” class of bug). The kernel community removed set_fs() for exactly this hardening reason: per LWN’s “Saying goodbye to set_fs()” (2020), Christoph Hellwig drove a multi-release campaign to eliminate it, replacing the KERNEL_DS use cases with dedicated kernel-buffer helpers (e.g. iov_iter with ITER_KVEC) so that access_ok() could compare against an immutable constant that userspace and stray returns can never widen.

Uncertain

Verify: that named historical exploits (not just the general bug class) abused set_fs/addr_limit confusion. Reason: the LWN article motivates removal as hardening but I did not pin a specific CVE in this task. To resolve: search the CVE database / commit messages for “addr_limit” exploitation. uncertain

Empirically, on x86 the removal had completed by Linux 5.10 (released 2020-12-13): set_fs/KERNEL_DS/USER_DS appear in arch/x86/include/asm/uaccess.h at the v5.4 tag but are absent from v5.10 onward (verified by grepping the pinned-tag blobs). In 6.12 there is no addr_limit field on x86 and no set_fs() at all — access_ok() is the constant-limit check described above, with no mutable per-thread state behind it.

Uncertain

Verify: the precise mainline release in which the last architecture’s set_fs() was removed (making CONFIG_SET_FS unselectable kernel-wide), as opposed to x86’s removal which the blobs pin to ≤5.10. Reason: the campaign landed arch-by-arch over several releases; the generic include/linux/uaccess.h at v5.18/v6.12 still shows 2 residual force_uaccess* mentions. To resolve: check when CONFIG_SET_FS was dropped from arch/Kconfig in the mainline history. uncertain


Failure modes and common misunderstandings

access_ok() passed, so the pointer is safe to dereference.” Wrong, and the single most important thing to internalize. access_ok() proves only that the address is in the user range. The page behind it may be unmapped, swapped out, write-protected (a read-only page targeted by a write), or in a guard region. Dereferencing it can still fault. What makes the dereference safe is that it happens inside a uaccess region whose faulting instructions are registered in the exception table, so the fault is turned into -EFAULT rather than an oops. Using access_ok() and then dereferencing with a plain pointer (not through copy_*_user/get_user/put_user) is a bug: the access is in-range so SMAP may not catch it, there is no exception-table entry, and an unmapped page oopses the kernel.

TOCTOU on the pointer. access_ok() validates the pointer value, but the memory it names can change between the check and the access (another thread munmaps it, or remaps it). This is why the kernel never trusts a user buffer to be stable: it copies once into kernel memory and operates on the copy, and re-validates indices. A handler that access_ok()s a struct and then dereferences fields in place across a sleep is exploitable.

Forgetting it on the __-prefixed accessors. get_user/put_user/copy_*_user run access_ok() internally. The double-underscore variants (__get_user, __copy_from_user) skip it — they are for code that has already validated the range (e.g. after one access_ok() covering several fields). Calling a __ variant without a prior access_ok() reintroduces the kernel-pointer hole.

Assuming it checks permissions. access_ok() takes no read/write argument on modern kernels — it does not distinguish a read from a write, and does not consult VMA permissions. Write-protection violations and unmapped pages are discovered at access time via the fault path, not by access_ok().


See Also