🛡️ pledge and unveil

A program promises what it will do and which files it will touch, and the kernel holds it to that. Real programs that are killed, refused and audited, with a Linux comparison.

Video: pledge and unveil (OpenBSD video)
Prefer to watch? This short video runs this page's main commands on a real OpenBSD 8.0 machine, with on-screen captions and a synthetic voiceover (an AI voice trained on the author's own recording). Click to play, or watch it on YouTube.
pledge("stdio rpath", NULL);   /* what kinds of calls I will make */
unveil("/etc/motd", "r");        /* which files I can see */
./p1; echo $?                    /* break the promise: killed, status 134 */

On Linux a hacked program can do whatever its user can. OpenBSD programs take away what they do not need, while they run, with two small system calls: pledge for the kinds of operations and unveil for the files. This page tries both in short C programs, shows what happens when a promise is broken, and how to see it afterwards with ktrace, kdump and lastcomm.

Every example ran for real on OpenBSD 8.0 in a virtual machine, as an ordinary user, with root commands marked doas. The output you see is the output the machine gave.

Two promises a program makes

On Linux, a program that gets hacked can do whatever its user can do. OpenBSD takes away what the program does not need, while it runs, and the program itself asks for it. There are two system calls for this. pledge(2) says which kinds of system calls the program will use from now on, such as reading files or using the network. unveil(2) says which parts of the file system it can see at all. A program calls them once, near the start, and after that the kernel enforces them. Break a promise and the program is killed.

Most of the OpenBSD base system does this already, from ssh to ls. The calls are small enough that you can try them in a few lines of C, and the compiler is part of the base system. Every example here ran for real on OpenBSD 8.0 in a virtual machine, as an ordinary user called bsd. Check the tools first:

$ uname -sr
OpenBSD 8.0
$ cc --version | head -n 1
OpenBSD clang version 22.1.6
$ man -k pledge | head -n 2
open, __pledge_open, openat(2) - open or create a file for reading or writing
pledge(2) - restrict system operations

Nothing needs to be installed. The man pages for pledge and unveil, section 2, are the reference.

pledge: say what you will do

Here is a small program. It promises stdio and rpath, which means basic input and output, and reading files. It then reads a file, which is allowed, and tries to create one, which is not. The messages go to standard error with warnx, so they appear even when the program is killed:

$ cat /tmp/p1.c
#include <stdio.h>
#include <unistd.h>
#include <err.h>

int
main(void)
{
	if (pledge("stdio rpath", NULL) == -1)
		err(1, "pledge");
	warnx("pledged stdio and rpath");
	if (fopen("/etc/motd", "r") == NULL)
		err(1, "fopen");
	warnx("reading /etc/motd is allowed");
	fopen("/tmp/new.txt", "w");
	warnx("not reached");
	return 0;
}
$ cc -Wall -o /tmp/p1 /tmp/p1.c
$ /tmp/p1; echo "exit status $?"
p1: pledged stdio and rpath
p1: reading /etc/motd is allowed
Abort trap (core dumped)
exit status 134

The program died with Abort trap at the first thing it did not promise, which is creating a file, and it never printed the last line. On a terminal you also see a message from the system, like p1[1234]: pledge "wpath", syscall 5, which names the missing promise at once. It does not show up here, because the examples ran without a terminal. The exit status 134 is 128 plus 6, the number of the abort signal. ktrace and kdump record what the kernel saw, and name the promise that was missing:

$ ktrace -f /tmp/k.out /tmp/p1 2>/dev/null || true
$ kdump -f /tmp/k.out | grep -E 'PLDG|PSIG'
 33384 p1       PLDG  open, "wpath", errno 1 Operation not permitted
 33384 p1       PSIG  SIGABRT SIG_DFL

PLDG is the pledge violation. The kernel says the call was open, and that it needed wpath, the promise for opening a file for writing. That one line usually tells you what to add.

The promises

There are about thirty promises, and the man page lists them all. A few cover most programs:

$ man pledge | col -b | grep -E '^[[:space:]]+(stdio|rpath|wpath|cpath|inet|dns|proc|exec|unveil|error|tty)[[:space:]]+[A-Z]' | cut -c1-76
	   stdio      This promise provides access to basic memory management,
	   rpath      A number of system calls are allowed which allow path
	   wpath      Similar to rpath, but files can be opened for write.
	   cpath      Similar to wpath, but files can also be created or
	   inet	      The following system calls are allowed to operate in the
	   dns	      Some low-level behaviours required by the DNS resolver
	   tty	      In addition to allowing read-write operations on
	   proc	      Allows the following process relationship operations:
	   exec	      Allows a process to call execve(2).  Coupled with the
	   unveil     Allow unveil(2) to be called.
	   error      Rather than killing the process upon violation, indicate

stdio is the basic set: reading and writing open descriptors, memory, time. Every program needs it. rpath, wpath and cpath are read, write and create on files. inet is network sockets, dns is looking up names, and exec lets the program start another one. A program can only ever reduce its promises, never add any back, so you make the list as short as you can.

By default a broken promise kills the program, which is what you want for a security feature. While you are working out the right list, add the promise error. Then a forbidden call fails with an error and the program keeps running:

$ cat /tmp/p3.c
#include <stdio.h>
#include <unistd.h>
#include <err.h>

int
main(void)
{
	if (pledge("stdio rpath error", NULL) == -1)
		err(1, "pledge");
	if (fopen("/tmp/new.txt", "w") == NULL)
		warn("fopen /tmp/new.txt");
	warnx("still running");
	return 0;
}
$ cc -Wall -o /tmp/p3 /tmp/p3.c
$ /tmp/p3; echo "exit status $?"
p3: fopen /tmp/new.txt: Function not implemented
p3: still running
exit status 0

The error message is Function not implemented. Take error out again before you ship, so that a violation is not quietly ignored.

unveil: say what you will touch

pledge limits the kind of operation. unveil limits the files. Before the first call, the whole file system is visible. After it, only the paths you unveil are, each with its own permissions: r read, w write, x execute, c create. Calling unveil with two NULLs locks the list, so that nothing more can be added:

$ cat /tmp/p2.c
#include <stdio.h>
#include <unistd.h>
#include <err.h>

int
main(void)
{
	FILE *f;

	if (unveil("/etc/motd", "r") == -1)
		err(1, "unveil");
	if (unveil(NULL, NULL) == -1)
		err(1, "unveil lock");
	f = fopen("/etc/motd", "r");
	warnx("/etc/motd: %s", f ? "opened" : "failed");
	f = fopen("/etc/passwd", "r");
	warnx("/etc/passwd: %s", f ? "opened" : "failed");
	f = fopen("/etc/motd", "w");
	warnx("/etc/motd for writing: %s", f ? "opened" : "failed");
	return 0;
}
$ cc -Wall -o /tmp/p2 /tmp/p2.c
$ /tmp/p2; echo "exit status $?"
p2: /etc/motd: opened
p2: /etc/passwd: failed
p2: /etc/motd for writing: failed
exit status 0

This time the program is not killed. The calls fail, and the program decides what to do. The file that was not unveiled does not exist as far as the program is concerned, so the error is ENOENT. The unveiled file opens for reading, but not for writing, which is a permission error. Unveil is about hiding, and the program cannot even tell that /etc/passwd is there.

Using both

The usual pattern is unveil first and then pledge, because pledge with a short list takes away the unveil call itself. Here is a program that may read one file, and may do nothing else:

$ cat /tmp/p4.c
#include <stdio.h>
#include <unistd.h>
#include <err.h>

int
main(void)
{
	char line[128];
	FILE *f;

	if (unveil("/etc/motd", "r") == -1)
		err(1, "unveil");
	if (pledge("stdio rpath", NULL) == -1)
		err(1, "pledge");
	if ((f = fopen("/etc/motd", "r")) == NULL)
		err(1, "fopen");
	if (fgets(line, sizeof line, f) != NULL)
		printf("%s", line);
	return 0;
}
$ cc -Wall -o /tmp/p4 /tmp/p4.c
$ /tmp/p4; echo "exit status $?"
OpenBSD 8.0 (GENERIC.MP) #84: Tue Oct  6 23:13:49 MDT 2026
exit status 0

It reads the first line of the file and exits. If someone found a hole in this program, they could read that one file and nothing else. They could not write, start a program, or reach the network, and the kernel would stop them. That is the idea for any daemon: do the setup, then pledge and unveil, then handle the requests.

Seeing violations

Programs that are killed leave a trace in the process accounting, if it is switched on. Turn it on, run two of the programs from above, and look. The flag P means a pledge violation and U means a blocked unveil:

$ doas accton /var/account/acct
$ /tmp/p1 >/dev/null 2>&1; /tmp/p2 >/dev/null 2>&1; sleep 1
$ lastcomm p1 p2 | awk '{print $1, $2}' | sed 's/\[[0-9]*\]//'
p2 -U
p1 -DXP
$ man lastcomm | col -b | grep -E 'pledge|unveil'
	   P	   The command was terminated due to a pledge(2) violation.
		   unveil(2).
$ doas accton

D means a core dump and X means the program was killed by a signal, so p1 shows -DXP. The last command switches the accounting off again. On a real server this is also a way to audit what the base system itself does. ls calls pledge, and so do the other tools you use every day:

$ ktrace -f /tmp/ls.out ls /etc >/dev/null
$ kdump -f /tmp/ls.out | grep pledge | cut -c1-60
 98114 ls       CALL  pledge(0xb6b11193aef,0)
 98114 ls       RET   pledge 0

The promises appear as an address, because ktrace records the pointer, but the call is there: ls pledges itself before it reads a directory.

Linux to OpenBSD side by side

There is no exact match, but the ideas line up:

 - seccomp filters: pledge. The program lists broad groups, not single system calls, and the kernel has the list of groups.
 - Landlock, or a chroot: unveil. A program hides the rest of the file system from itself.
 - AppArmor and SELinux: profiles written by an administrator, outside the program. On OpenBSD the program restricts itself, in its own source, and the profile cannot drift away from the code.
 - strace: ktrace, and kdump to read the result.
 - auditd: process accounting with accton and lastcomm.
 - A sandboxed service: systemd units with ProtectSystem and SystemCallFilter. On OpenBSD a daemon does this itself, as httpd, sshd and ntpd all do.

The big difference is who decides. On Linux an administrator or a distribution adds a sandbox around a program. On OpenBSD the author of the program adds it, and so it is there on every install.

When something goes wrong

The errors are short. A promise with a name that does not exist fails at once with Invalid argument. A promise you forgot kills the program, and kdump names it:

$ cat /tmp/p5.c
#include <unistd.h>
#include <err.h>

int
main(void)
{
	if (pledge("nosuchpromise", NULL) == -1)
		err(1, "pledge");
	return 0;
}
$ cc -o /tmp/p5 /tmp/p5.c
$ /tmp/p5; echo "exit status $?"
p5: pledge: Invalid argument
exit status 1
$ sed 's/"stdio rpath"/"stdio"/' /tmp/p4.c > /tmp/p6.c
$ cc -o /tmp/p6 /tmp/p6.c
$ /tmp/p6; echo "exit status $?"
Abort trap (core dumped)
exit status 134
$ ktrace -f /tmp/k6.out /tmp/p6 2>/dev/null; kdump -f /tmp/k6.out | grep PLDG
 71504 p6       PLDG  open, "rpath", errno 1 Operation not permitted

The second program is the combined one with rpath taken out, so it may no longer open the file it unveiled. The error is the same: the call, and the promise it needed. Add the promise to the list, run again, and repeat until it works.