$RodHat_
Console Tips

prctl: the per-process security levers worth knowing

Published by

prctl: the per-process security levers worth knowing
Photo: AI-generated — no human photographer / RodHat AI Cover

The prctl(2) man page lists north of forty operations. Most are noise. Four of them are what you actually need when you’re writing a daemon that drops privileges and you want it to stay dropped.

This is that list.

PR_SET_NO_NEW_PRIVS

Added in Linux 3.5 (2012). Single most useful bit in the table.

Once set, the process cannot gain new privileges via execve, regardless of what’s on the binary: setuid root, setgid, file capabilities. All of it ignored. The restriction is permanent and inherited: you cannot unset it, and every thread and child process from this point forward shares it.

#include <sys/prctl.h>

if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
    perror("prctl(PR_SET_NO_NEW_PRIVS)");
    exit(1);
}

This is also the precondition for installing a seccomp-bpf filter without CAP_SYS_ADMIN. The kernel refuses to let an unprivileged process install a seccomp filter unless NO_NEW_PRIVS is already set, because a process that could subsequently exec a setuid binary and become root should not be able to install kernel-level syscall filters before doing so.

Call order matters. The right sequence: bind ports and do anything requiring elevated capabilities first, then drop capabilities, then call PR_SET_NO_NEW_PRIVS, then install your seccomp filter. If you set NO_NEW_PRIVS before dropping caps you haven’t broken anything, but the principle is: finish your privileged work, then lock the door.

Check the current state:

int nnp = prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0);
/* 1 if set, 0 if not */

You can also check it from outside the process:

grep NoNewPrivs /proc/<pid>/status

PR_SET_DUMPABLE

When a process calls setresuid or otherwise drops privileges, the kernel automatically flips it to not dumpable. Dumpable controls two things: whether the kernel will write a core file on crash, and whether /proc/PID/{mem,maps,environ,fd,...} are readable by processes running with different credentials.

In production, leave it at 0. A non-dumpable process’s /proc/PID subtree is readable only by root. A process running as your daemon’s service user cannot read /proc/PID/mem to extract key material, cannot read /proc/PID/maps to find where you loaded the sensitive region, cannot attach ptrace.

/* Explicit is better than relying on the automatic flip from setresuid */
if (prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) < 0) {
    perror("prctl(PR_SET_DUMPABLE)");
}

/* Read the current state */
int d = prctl(PR_GET_DUMPABLE, 0, 0, 0, 0);
/* 0: not dumpable, 1: dumpable, 2: dumpable only to subreaper */

Value 2, added in Linux 3.6, means core dumps go to the subreaper (your init system or service manager) but /proc/PID stays restricted to root. Useful when you want crash reports in production without exposing the address space.

The one gotcha: if you drop privileges via setresuid and then need to attach a debugger in development, you have to either run gdb as root or temporarily set dumpable to 1. This surprises people. The fix in dev is prctl(PR_SET_DUMPABLE, 1, ...) at the top of main, guarded behind a debug flag.

PR_SET_PDEATHSIG

A forked worker that becomes an orphan keeps running. If your supervisor crashes, the child inherits PID 1 as its parent and nobody’s watching it. PR_SET_PDEATHSIG fires a signal at the child when its parent exits.

/* In the child, immediately after fork, before anything else */
if (prctl(PR_SET_PDEATHSIG, SIGTERM, 0, 0, 0) < 0) {
    perror("prctl(PR_SET_PDEATHSIG)");
    exit(1);
}

Call it in the child before exec. Two caveats.

First: the signal fires when the parent’s PID exits, not when any thread in the parent exits. For a multi-threaded parent, this means the signal fires only when the last thread of the parent process exits. This is usually what you want.

Second: execve clears it. If your child calls exec, the PR_SET_PDEATHSIG does not survive into the exec’d binary. Re-call it early in the exec’d process if you need it there.

Third: it’s the current parent at the time of the event. If the child gets reparented to a subreaper before the original parent dies, the signal fires on the original parent’s death anyway. That’s correct but can confuse you if you’re watching the process tree and wondering why the child got SIGTERM.

/* Get the currently set death signal */
int sig;
prctl(PR_GET_PDEATHSIG, &sig, 0, 0, 0);
/* sig is 0 if not set */

PR_SET_VMA: labeling anonymous mappings

Linux 5.17 added PR_SET_VMA with one subcommand: PR_SET_VMA_ANON_NAME. It attaches a string to an anonymous mmap region. The string shows up in /proc/PID/smaps.

#include <sys/prctl.h>
#include <sys/mman.h>

void *ring = mmap(NULL, 1 << 20, PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME,
      (unsigned long)ring, 1 << 20,
      (unsigned long)"io_ring_buffer");

In /proc/PID/smaps:

7f3a00000000-7f3a00100000 rw-p 00000000 00:00 0  [anon:io_ring_buffer]

This is a debugging aid, nothing more. It does not change protection, does not interact with memfd_secret, does not affect anything the kernel does with the region. But when you’re staring at a smaps dump for a 4 GB process trying to figure out which anonymous chunk is the input queue versus the output queue versus the TLS session cache, having names beats reading offsets.

Name limit is 80 bytes. Cannot contain [ or null. Returns EINVAL on those. Returns ENODATA on kernels before 5.17, which you want to swallow rather than treating as fatal.

Putting it together

A daemon that handles long-lived secrets, drops privileges at startup, and wants minimal exposure:

#include <unistd.h>
#include <sys/prctl.h>
#include <sys/capability.h>  /* libcap-dev / libcap2-dev */
#include <signal.h>

/* Call after binding ports but before exec of any worker */
void harden_process(void) {
    /* Cannot gain new privs via exec from this point on */
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
        perror("PR_SET_NO_NEW_PRIVS");
        exit(1);
    }

    /* Drop all capabilities */
    cap_t empty = cap_init();
    if (cap_set_proc(empty) < 0)
        perror("cap_set_proc");
    cap_free(empty);

    /* /proc/PID subtree now root-only */
    prctl(PR_SET_DUMPABLE, 0, 0, 0, 0);

    /* Seccomp filter goes here, after NO_NEW_PRIVS */
    /* install_seccomp_filter(); */
}

/* In fork()'d worker, before exec */
void worker_init(void) {
    prctl(PR_SET_PDEATHSIG, SIGTERM, 0, 0, 0);
}

None of these is sufficient alone. PR_SET_NO_NEW_PRIVS does nothing about the current process’s memory being readable. PR_SET_DUMPABLE does nothing about privilege re-escalation via exec. They stack. The seccomp-bpf post covers the syscall filter layer that goes on top. The memfd_secret post covers removing your key material from the kernel’s direct map. The mlock post covers keeping it out of swap.

prctl is the thin glue between the privilege you need at startup and the minimal surface you want at runtime. Run the privileged work, set these bits, then stop being interesting.

Kerrisk’s The Linux Programming Interface covers capabilities and prctl with the depth the man page refuses to. Chapter 39 on capabilities is the one to read before touching capset or PR_CAP_AMBIENT. Stevens’ Advanced Programming in the UNIX Environment is older but still accurate on the process credential model that all of this sits on top of.