Skip to content
LinuxDeep Dive Published Updated 8 min readViews unavailable

Linux x86 Protection Keys: Per-Thread PKRU Policy for Data Pages

Apply Linux x86-64 protection keys to selected mappings, manage per-thread PKRU rights, and handle allocation, signals, threads, and kernel-access caveats.

Linux x86-64 protection keys, often called PKU or pkeys, add a fast-changing access-control layer to page-based memory protection. A mapping is tagged with a key; each thread’s PKRU register says whether data access associated with each key is disabled or write-disabled for that thread. This can help a program keep frequently changing in-process access modes without repeatedly changing page-table permissions.

It is not a replacement for ordinary virtual-memory protections, and it is not an isolation boundary against arbitrary code already executing in the same process. The same process can use the user-accessible PKRU instructions to change its own rights. Use pkeys as one component in a carefully controlled program design, not as a substitute for process separation, privilege boundaries, or memory-safety engineering.

Separate the page tag from the thread’s rights

On x86-64, the kernel documentation describes four page-table-entry bits used to select one of 16 protection-key values. PKRU is a 32-bit user-accessible register with two bits for each key: Access Disable and Write Disable. In this architecture, the register is thread-local, so two threads in the same address space can have different PKRU rights for the same tagged mapping. The x86 permissions apply to data access; they do not restrict instruction fetch.

That distinction creates two independent pieces of state:

  1. The mapping carries the protection key, assigned with pkey_mprotect().
  2. The current thread’s PKRU value controls whether that key’s data may be read or written, changed through interfaces such as pkey_set() or the underlying WRPKRU instruction.

The ordinary mapping protection still matters. A key does not make a PROT_NONE page readable or writable; the key layer is useful for imposing additional restrictions on mappings whose normal PROT_* permissions are otherwise sufficient. It is therefore possible for a program to get a fault because the VMA permission denies access, or because PKRU denies it. The kernel reports a protection-key violation as SIGSEGV, distinguishing it from an ordinary access-permission violation. There is a naming discrepancy worth catching in diagnostics: the kernel documentation prose spells the signal code SEGV_PKERR, while the exported Linux UAPI header defines the C macro as SEGV_PKUERR. These are not interchangeable C identifiers; code should compare si_code with the header’s SEGV_PKUERR macro.

Do not infer that “16 key values” means an application should choose an integer and hardcode it. Key 0 is the default for untagged mappings, and the kernel or another library may consume keys, so the number available to one application can be lower than the hardware’s encoding limit. Allocate a key at runtime and handle allocation failure. Feature availability depends on both the CPU and kernel. A program should retain a correct fallback path if pkeys cannot be allocated.

Assign a key to a mapping and change the current thread’s rights

The kernel documentation presents the lifecycle as allocation, mapping assignment, a scoped rights change, unmapping, and only then freeing the key. This example shows that sequence for one anonymous page. It deliberately does not write to the page while writes are disabled; such a test is expected to raise SIGSEGV and should be performed only in a child process or a test harness designed to catch that signal.

#define _GNU_SOURCE
#include <sys/mman.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    long page_size = sysconf(_SC_PAGESIZE);
    if (page_size <= 0)
        return EXIT_FAILURE;

    const size_t length = (size_t)page_size;
    int key = pkey_alloc(0, 0);
    if (key == -1)
        return EXIT_FAILURE; /* This demo stops; production fallback is separate. */

    char *page = mmap(NULL, length, PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (page == MAP_FAILED) {
        (void)pkey_free(key);
        return EXIT_FAILURE;
    }

    int result = EXIT_FAILURE;
    if (pkey_mprotect(page, length, PROT_READ | PROT_WRITE, key) == -1)
        goto out;

    page[0] = 'A';
    if (pkey_set(key, PKEY_DISABLE_WRITE) == -1)
        goto out;

    /* Reads remain allowed for this key; writes are disabled in this thread. */
    volatile char observed = page[0];
    (void)observed;

    if (pkey_set(key, 0) == -1)
        goto out;
    page[0] = 'B';
    result = EXIT_SUCCESS;

out:
    /* Restore this thread's rights before cleanup and avoid stale policy. */
    if (pkey_set(key, 0) == -1)
        result = EXIT_FAILURE;
    int unmapped = munmap(page, length) == 0;
    if (!unmapped)
        result = EXIT_FAILURE;
    /* Do not free a key unless no live mapping can still refer to it. */
    if (unmapped && pkey_free(key) == -1)
        result = EXIT_FAILURE;
    return result;
}

Compile on a supported Linux x86-64 environment with a current C library that exposes the GNU pkey interfaces, for example:

cc -Wall -Wextra -O2 pkey-demo.c -o pkey-demo
./pkey-demo

The example checks each operation that changes a mapping or rights. A real application should preserve the error details for diagnostics, but never reinterpret an unsupported feature or exhausted key set as permission to skip an essential security check. If pkeys are an optimization or defense-in-depth feature, a separately tested fallback such as ordinary mprotect() may be appropriate only if the changed process-wide semantics still meet the threat model. If per-thread rights are required for correctness or security, fail closed with a useful error rather than silently substituting a weaker control.

The key must be allocated before it is assigned to the range. Keep ownership information with the mapping metadata, and unmap all ranges that use a key before calling pkey_free(). The pkey_free(2) manual says that freeing a key still assigned to an in-use address range has undefined behavior and may return an error. The pkeys(7) overview adds that current kernels may allow this misuse without checking every mapping, but that is not supported application behavior or a safety guarantee. If the key is later reused while an old mapping still carries it, a rights change can unexpectedly affect that mapping as well as the new data. Do not recycle a numeric value based on assumptions about which key the kernel will allocate next.

Model threads, signals, and kernel-mediated accesses

Because PKRU is thread-local, changing it on one thread does not create a process-wide lock. If another thread can reach the same address while its own PKRU state allows access, that thread can still read or modify the data. Linux threads inherit the parent’s protection-key rights at thread creation, so choose the parent’s state deliberately or initialize each new thread before it touches protected data. Define the rights every relevant thread should hold, and test thread creation, worker reuse, callbacks, and error paths. Treat a key transition as part of the program’s concurrency protocol; it does not replace a mutex or ownership discipline.

Signal paths need explicit consideration as well. An intentional write to a write-disabled page faults. On x86, Linux temporarily gives a signal handler a new default set of protection-key rights that overrides the interrupted context; the interrupted rights are restored when the handler returns. A handler that needs a different policy must establish it explicitly. A test that intentionally faults should isolate the expected signal from the test runner and verify si_code == SEGV_PKUERR using the exported Linux UAPI macro instead of masking arbitrary memory faults.

One less-obvious boundary is access performed on the process’s behalf by a kernel thread. The kernel documentation warns that a kthread, with io_uring as an example, uses a default protection-key register value; that access may not match the userspace thread’s PKRU state or the apparent mprotect() behavior. Do not use a userspace PKRU setting as the sole authorization check for data that asynchronous kernel work can access. Review the exact I/O path and kernel behavior for the deployed kernel.

Choose the mechanism for the threat model

Pkeys are useful when a trusted runtime wants to switch read/write access for selected data regions at a finer operational granularity than changing a mapping on every transition. The kernel documentation notes that changing a protection domain with pkeys does not require modifying page tables. That property may make a design attractive, but it is not a promise of a particular speedup: measure the real workload, compiler output, syscall costs elsewhere in the path, and synchronization overhead.

PKRU is directly accessible to userspace through RDPKRU and WRPKRU on x86-64. Code that can execute arbitrary instructions in the process can attempt to change the current thread’s rights; code with access to the same mapping can often use other mapping-management interfaces too. Consequently, pkeys do not isolate mutually hostile code in one address space. Put hostile plugins, parsers, or extensions behind a process or other enforceable boundary when the threat model requires resistance to arbitrary code execution.

Pkeys also do not provide execute restrictions on x86. They are about data access for mappings bearing the selected key. Keep W^X policy, ordinary PROT_EXEC decisions, process credentials, and syscall filtering independent and explicit.

Troubleshoot and review the lifecycle

When a pkey operation fails, preserve the operation and error code: key allocation, page assignment, and changing rights are distinct steps with different failure causes. First confirm the target is x86-64 and the running CPU/kernel expose the feature; then confirm the allocation succeeded; then verify the mapping range and its ordinary PROT_* flags; finally confirm the calling thread has the rights expected at the point of access. A SIGSEGV whose si_code is SEGV_PKUERR indicates the key layer rejected the data access, not that the mapping is necessarily unmapped or corrupted.

Test at least: allocation unavailable or exhausted; successful mapping assignment; expected read and write behavior in each rights state; rights restoration after early returns; concurrent access from a second thread; signal handling; cleanup of every mapping before key release; and the exact kernel-mediated operations used by the application. Keep a fallback path and never silently turn a required access restriction into a no-op.

Related:

Sources:

Comments