A quick look at __pledge_open(2)
Thu 02 April 2026 — download

A recent article of the OpenBSD journal caught me attention: Pledge changes in 7.9-beta (archive.org mirror as it's currently offline).

The quoted message starts with:

Previously under certain promises it was possible to open certain files or devices even if the program didn't pledge "rpath" or "wpath". This behavior has gone away in 7.9-beta; libc uses the special __pledge_open(2) syscall which cannot be used outside of libc.

So a new syscall, bypassing pledge/unveil, interesting. The "cannot be used outside of libc" is likely referring to pinsyscall, which is an indirect call away. Let's check if this is indeed a sandbox escape. The function setservent_r has a call to __pledge_open, so let's jump directly on the call to please pinsyscall:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <signal.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/ptrace.h>
#include <string.h>

#define F "/tmp/pwned.txt"

typedef int unrestricted_open(const char *, int, ...);

static void on_sigtrap(int sig, siginfo_t *si, void *ctx) {
    // Catch the int3 of setprotoent's epilogue function
    (void)sig; (void)si; (void)ctx;
    write(STDERR_FILENO, "caught SIGTRAP\n", 15);
    char out[20];
    int fd = 4;
    printf("Trying to read fd: %d\n", fd);
    if (read(fd, out, sizeof(out)) < 0 ){
        puts("can't read");
    } else {
        puts(out);
    }

    exit(0);
}

int main(int argc, char** argv) {
    unveil("/home/", "r");
    unveil(NULL, NULL);

    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_sigaction = on_sigtrap;
    sa.sa_flags = SA_SIGINFO;
    sigemptyset(&sa.sa_mask);

    if (sigaction(SIGTRAP, &sa, NULL) == -1) {
        perror("sigaction");
        return 1;
    }

    int fd = open(F, O_RDONLY);
    printf("Got fd for open: %d\n", fd);

    open(F, O_RDONLY);
    // offsets for -current valid at 2026-04-02
    size_t daemon_addr = &daemon;
    // get the address of the call to __pledge_open in setprotoent
    size_t unrestricted_open_addr = daemon_addr - (0x00076980 - 0x000E0A07);
    unrestricted_open* unrestricted_open_fcn = (unrestricted_open*)unrestricted_open_addr;
    unrestricted_open_fcn(F, O_RDONLY);

    return 0;
}

Unfortunately:

openbsd$ clang ./test.c  && ./a.out
1 warning generated.
Got fd for open: -1
caught SIGTRAP
Trying to read fd: 4
can't read
openbsd$

This is because there is a check in the form of pledge_namei, with hardcoded paths:

/*
 * Need to make it more obvious that one cannot get through here
 * without the right flags set
 */
int
pledge_namei(struct proc *p, struct nameidata *ni, char *path)
{
    // […]

    /*
     * In specific promise situations, __pledge_open() can open
     * specific paths and ignores rpath, wpath, or unveil restrictions.
     */
    if (ni->ni_unveil & UNVEIL_PLEDGEOPEN) {
#ifdef SMALL_KERNEL
        /* To save ramdisk space, we trust the libc provided paths */
        ni->ni_cnd.cn_flags |= BYPASSUNVEIL;
#else
        int item;

        item = checkpledgepaths(path);
        if (item == 0 &&
            strncmp(path, "/usr/share/zoneinfo/",
            sizeof("/usr/share/zoneinfo/") - 1) == 0) {
            const char *cp;

            item = PLEDGEPATH_ZONEINFO;
            for (cp = path + sizeof("/usr/share/zoneinfo/") - 2;
                *cp; cp++) {
                if (cp[0] == '/' &&
                    cp[1] == '.' && cp[2] == '.' &&
                    (cp[3] == '/' || cp[3] == '\0')) {
                    item = 0;   /* bad path */
                    break;
                }
            }
        }

        switch (item) {
        case 0:
            /* Invalid path provided to __pledge_open */
            return (pledge_fail(p, EACCES, (nip & ~ple)));

        /* "stdio" - for daemon(3) or other such functions */
        case PLEDGEPATH_NULL:
            if ((nip & ~(PLEDGE_RPATH | PLEDGE_WPATH)) == 0)
                ni->ni_cnd.cn_flags |= BYPASSUNVEIL;
            break;

        /* "tty" - readpassphrase(3), getpass(3) */
        case PLEDGEPATH_TTY:
            if ((ple & PLEDGE_TTY) &&
                (nip & ~(PLEDGE_RPATH | PLEDGE_WPATH)) == 0)
                ni->ni_cnd.cn_flags |= BYPASSUNVEIL;
            break;

        /* "getpw" requirements */
        case PLEDGEPATH_SPWD:
            /* XXX should remove nip check! */
            if ((ple & PLEDGE_GETPW) && (nip == PLEDGE_RPATH))
                return (EPERM);
            break;
        case PLEDGEPATH_PWD:
            /* FALLTHROUGH */
        case PLEDGEPATH_GROUP:
            /* FALLTHROUGH */
        case PLEDGEPATH_NETID:
            if ((ple & PLEDGE_GETPW) && (nip == PLEDGE_RPATH))
                ni->ni_cnd.cn_flags |= BYPASSUNVEIL;
            break;

        /* "dns" requirements */
        case PLEDGEPATH_RESOLVCONF:
            /* FALLTHROUGH */
        case PLEDGEPATH_HOSTS:
            /* FALLTHROUGH */
        case PLEDGEPATH_SERVICES:
            /* FALLTHROUGH */
        case PLEDGEPATH_PROTOCOLS:
            if ((ple & PLEDGE_DNS) && (nip == PLEDGE_RPATH))
                ni->ni_cnd.cn_flags |= BYPASSUNVEIL;
            break;

        /* tzset() often happen late in programs */
        case PLEDGEPATH_LOCALTIME:
            /* FALLTHROUGH */
        case PLEDGEPATH_ZONEINFO:
            if (nip == PLEDGE_RPATH)
                ni->ni_cnd.cn_flags |= BYPASSUNVEIL;
            break;

        default:
            panic("pledgepaths table is broken");
        }
#endif /* SMALL_KERNEL */
    }
// […]

Maybe it's worth reading emails in their entirety after all, instead of only the first paragraph, as it ended with:

The list of promises and the special paths which could previously be opened under that promise is:

stdio /dev/null (rpath or wpath) /etc/localtime /usr/share/zoneinfo

tty /dev/tty (rpath or wpath)

dns /etc/resolv.conf /etc/hosts /etc/services /etc/protocols

getpw /etc/group /etc/netid /etc/pwd.db (the .db files really should be left to the system) /etc/spwd.db (could not open, but returned EPERM)

As a small consolation, it might still be a valid bypass on OpenBSD with a compiled with SMALL_KERNEL, but OpenBSD being what it is, -current doesn't compile with the SMALL_KERNEL option, and I can't be arsed to fix it:

openbsd# make
cc -g -Werror -Wall -Wimplicit-function-declaration  -Wno-pointer-sign  -Wframe-larger-than=2047 -Wno-address-of-packed-member -Wno-constant-conversion  -Wno-unused-but-set-variable -Wno-gnu-folding-constant -mcmodel=kernel -mno-red-zone -mno-sse2 -mno-sse -mno-3dnow  -mno-mmx -msoft-float -fno-omit-frame-pointer -ffreestanding -fno-pie -msave-args -mno-retpoline -fcf-protection=none -Oz  -pipe -nostdinc -I/sys -I/usr/src/sys/arch/amd64/compile/GENERIC/obj -I/sys/arch  -I/sys/dev/pci/drm/include  -I/sys/dev/pci/drm/include/uapi  -I/sys/dev/pci/drm/amd/include/asic_reg  -I/sys/dev/pci/drm/amd/include  -I/sys/dev/pci/drm/amd/amdgpu  -I/sys/dev/pci/drm/amd/display  -I/sys/dev/pci/drm/amd/display/include  -I/sys/dev/pci/drm/amd/display/dc  -I/sys/dev/pci/drm/amd/display/amdgpu_dm  -I/sys/dev/pci/drm/amd/pm/inc  -I/sys/dev/pci/drm/amd/pm/legacy-dpm  -I/sys/dev/pci/drm/amd/pm/swsmu  -I/sys/dev/pci/drm/amd/pm/swsmu/inc  -I/sys/dev/pci/drm/amd/pm/swsmu/smu11  -I/sys/dev/pci/drm/amd/pm/swsmu/smu12  -I/sys/dev/pci/drm/amd/pm/swsmu/smu13  -I/sys/dev/pci/drm/amd/pm/swsmu/smu14  -I/sys/dev/pci/drm/amd/pm/powerplay/inc  -I/sys/dev/pci/drm/amd/pm/powerplay/hwmgr  -I/sys/dev/pci/drm/amd/pm/powerplay/smumgr  -I/sys/dev/pci/drm/amd/pm/swsmu/inc  -I/sys/dev/pci/drm/amd/pm/swsmu/inc/pmfw_if  -I/sys/dev/pci/drm/amd/display/dc/inc  -I/sys/dev/pci/drm/amd/display/dc/inc/hw  -I/sys/dev/pci/drm/amd/display/dc/clk_mgr  -I/sys/dev/pci/drm/amd/display/dc/dccg  -I/sys/dev/pci/drm/amd/display/dc/dio  -I/sys/dev/pci/drm/amd/display/dc/dpp  -I/sys/dev/pci/drm/amd/display/dc/dsc  -I/sys/dev/pci/drm/amd/display/dc/dwb  -I/sys/dev/pci/drm/amd/display/dc/hubbub  -I/sys/dev/pci/drm/amd/display/dc/hpo  -I/sys/dev/pci/drm/amd/display/dc/hwss  -I/sys/dev/pci/drm/amd/display/dc/hubp  -I/sys/dev/pci/drm/amd/display/dc/dml2  -I/sys/dev/pci/drm/amd/display/dc/dml2/dml21  -I/sys/dev/pci/drm/amd/display/dc/dml2/dml21/inc  -I/sys/dev/pci/drm/amd/display/dc/dml2/dml21/src/dml2_core  -I/sys/dev/pci/drm/amd/display/dc/dml2/dml21/src/dml2_dpmm  -I/sys/dev/pci/drm/amd/display/dc/dml2/dml21/src/dml2_mcg  -I/sys/dev/pci/drm/amd/display/dc/dml2/dml21/src/dml2_pmo  -I/sys/dev/pci/drm/amd/display/dc/dml2/dml21/src/dml2_standalone_libraries  -I/sys/dev/pci/drm/amd/display/dc/dml2/dml21/src/inc  -I/sys/dev/pci/drm/amd/display/dc/mmhubbub  -I/sys/dev/pci/drm/amd/display/dc/mpc  -I/sys/dev/pci/drm/amd/display/dc/opp  -I/sys/dev/pci/drm/amd/display/dc/optc  -I/sys/dev/pci/drm/amd/display/dc/pg  -I/sys/dev/pci/drm/amd/display/dc/resource  -I/sys/dev/pci/drm/amd/display/modules/inc  -I/sys/dev/pci/drm/amd/display/modules/hdcp  -I/sys/dev/pci/drm/amd/display/dmub/inc  -I/sys/dev/pci/drm/i915 -DDDB -DDIAGNOSTIC -DKTRACE -DACCOUNTING -DKMEMSTATS -DPTRACE -DPOOL_DEBUG -DCRYPTO -DSYSVMSG -DSYSVSEM -DSYSVSHM -DUVM_SWAP_ENCRYPT -DFFS -DFFS2 -DUFS_DIRHASH -DQUOTA -DEXT2FS -DMFS -DNFSCLIENT -DNFSSERVER -DCD9660 -DUDF -DMSDOSFS -DFIFO -DFUSE -DSOCKET_SPLICE -DTCP_ECN -DTCP_SIGNATURE -DINET6 -DIPSEC -DPPP_BSDCOMP -DPPP_DEFLATE -DPIPEX -DMROUTING -DMPLS -DBOOT_CONFIG -DUSER_PCICONF -DAPERTURE -DMTRR -DNTFS -DSUSPEND -DHIBERNATE -DSMALL_KERNEL -DPCIVERBOSE -DUSBVERBOSE -DWSDISPLAY_COMPAT_USL -DWSDISPLAY_COMPAT_RAWKBD -DWSDISPLAY_DEFAULTSCREENS="6" -DX86EMU -DI915 -DONEWIREVERBOSE -DMAXUSERS=80 -D_KERNEL -MD -MP  -c /sys/dev/acpi/acpibtn.c
/sys/dev/acpi/acpibtn.c:292:12: error: use of undeclared identifier 'pwr_action'
  292 |                         switch (pwr_action) {
      |                                 ^
1 error generated.
*** Error 1 in /usr/src/sys/arch/amd64/compile/GENERIC (Makefile:2680 'acpibtn.o')
openbsd#

So who knows.

(Thanks to K3 for looking into this with me.)