safexec runs a fixed set of command-line tools on behalf of a less-trusted
caller. It is installed setuid root. It checks the request against a
compile-time allowlist, pins the tool to a trusted absolute path, scrubs the
environment, places the process in its own cgroup v2 group, drops privileges,
and then execs the tool. It never starts a shell and never runs a tool as root.
Typical callers are web applications, schedulers, and job runners that must
invoke tools such as curl, tar, or ffmpeg without a shell and without
inheriting the caller's environment or file descriptors.
safexec [wrapper ...] <tool> [args ...]
safexec --kill=<pid>
safexec --help | -h
safexec --version | -v
safexec processes a request in four steps.
- Allowlist. The tool is matched by basename against the allowlist.
Shells (
sh,bash,dash,ash,zsh,ksh,fish) are rejected in the prelude. Anything not on the list is refused. - Pinning. The tool and every bare-name wrapper are resolved to absolute
paths under trusted directories. The caller's
PATHand working directory are never consulted. - Isolation. With effective uid 0, the environment is cleared, the process joins a fresh cgroup v2 group (or falls back to rlimits), and the process state is hardened (see Privileged mode).
- Drop and exec. Privileges are dropped,
PR_SET_NO_NEW_PRIVSis set, inherited descriptors are closed, and the tool is executed.
There is no shell in the exec path. Characters such as ;, | and `
inside an argument reach the tool literally.
Applies whenever the effective uid is 0, that is, when the binary is setuid root or is run by root. In order:
- the environment is cleared;
PATHis set to a fixed value and a UTF-8 locale is set (C.UTF-8, thenen_US.UTF-8, thenC) umaskis set to077andPR_SET_DUMPABLEto0- the process joins
<cgroup root>/safexec-run/safexec-run.<pid>; if cgroup v2 is unavailable it falls back to rlimits - supplementary groups are cleared and the uid/gid are set to
nobody(or to the directory owner forrg, see below) PR_SET_NO_NEW_PRIVSis set and all file descriptors above 2 are closed- the tool is executed
If the drop to the target user fails, safexec falls back to the invoking
user's real uid/gid. If the effective uid is still 0 after that, it aborts.
If the effective uid is not 0 (no setuid bit, nosuid mount, or the binary was
copied without its mode), only the allowlist and path pinning apply. The
environment is not sanitized, privileges are not dropped, and no isolation is
set up. The tool runs as the invoking user. A notice is printed to stderr.
Always available:
| Group | Programs |
|---|---|
| Search | rg |
| Fetch | wget, curl |
| Archives | tar, gzip, gunzip, xz, unxz, zip, unzip |
| Checksums | sha256sum, sha512sum, shasum, b2sum, cksum |
| Media | ffmpeg, ffprobe, magick, convert, identify |
| Documents | wkhtmltopdf, pdftk, pandoc |
Optional groups, enabled at compile time (see Build):
| Macro | Adds |
|---|---|
SAFEXEC_WITH_GS |
gs |
SAFEXEC_WITH_POPPLER |
pdfinfo, pdftoppm, pdftocairo |
SAFEXEC_WITH_DB |
mysqldump, mysql, mariadb-dump, mariadb, pg_dump, pg_restore, psql, redis-cli |
SAFEXEC_WITH_RSYNC_GIT |
rsync, git |
gs has a long history of sandbox-escape vulnerabilities. rsync and git
can start ssh, which is outside the closed set of tools the allowlist assumes.
Enable these groups only if you control how the tools are invoked.
A tool that is on the allowlist but not installed is refused with
cannot resolve trusted path. On Debian, pg_dump, pg_restore and psql
are symlinks to /usr/share/postgresql-common/pg_wrapper, which is outside the
trusted directories, so they are refused there.
Tokens before the tool name form the prelude. Each token must be one of:
- A wrapper:
nohup,nice,timeout,stdbuf,ionice,taskset,setsid,chrt,time. A bare name is pinned like the tool. An explicit path must be exactly<trusted directory>/<name>;..and extra path components are rejected. - An option: a token starting with
-, or--. An option containing a/is rejected, sotimeout 5 -dir/progcannot smuggle a path. - A number or value: a signed integer, a duration (
5,1.5,2m), a CPU mask (0x3,0,1,0-3). - A proxy assignment:
NAME=VALUEwhereNAMEisHTTP_PROXY,HTTPS_PROXY,ALL_PROXY,NO_PROXYor the lowercase form. At most 16 are accepted. They are removed fromargvand exported to the tool.
Any other assignment (PATH=, LD_*, DYLD_*, IFS=, PYTHONPATH=, ...) is
rejected. Wrapper options that take a separate value word are not recognized:
write timeout -sKILL 20, not timeout -s KILL 20.
# Fetch through the allowlist
safexec curl -fsS -o /var/tmp/page.html https://example.com/
# Wrapper chain and a proxy variable
safexec HTTP_PROXY=http://127.0.0.1:3128 nice -n 10 timeout 30 curl -fsSL https://example.com/
# Checksum
safexec sha256sum /srv/data/archive.tar.gz
# Refused: not on the allowlist
safexec ls /
# Error: 'ls' is not allowed by safexec.
# Refused: shell in the prelude
safexec /bin/sh -c id
# Info: rejecting shell interpreter before tool: '/bin/sh'
# Error: 'sh' is not allowed by safexec.In privileged mode, diagnostics go to stderr:
Info: pinned tool 'sha256sum' -> '/usr/bin/sha256sum'
Info: using cgroup v2 child /sys/fs/cgroup/safexec-run/safexec-run.<pid>
Summary: user=65534:65534 (ruid=65534 rgid=65534) cwd=... tool=/usr/bin/sha256sum
Summary: no_new_privs=on
Summary: cgroup=/sys/fs/cgroup/safexec-run/safexec-run.<pid>
Set SAFEXEC_QUIET=1 to suppress them.
The last argument must be an absolute path to a directory. It must not be a
symlink and must not be owned by root. rg runs as the owner of that
directory, so it can read exactly what that user can read. If the owner is
already the invoking user, no drop is performed. A relative or missing path, a
non-directory, a symlink, a root-owned directory, or an owner absent from the
passwd database is refused with exit status 3.
safexec rg -m 1 --text 'pattern' /home/alice/projectIf -P /tmp is given and /tmp is not writable by the final user, it is
rewritten to /tmp/safexec-work/<uid>. /tmp/safexec-work is root-owned with
mode 1777; the per-user subdirectory has mode 0700.
safexec --kill=<pid>
Sends SIGTERM to a process that safexec started. The target must:
- be owned by
nobody, and - be in a
safexec-runcgroup, or, when cgroup delegation is unavailable, haveNoNewPrivsset.
The signal is sent with pidfd_send_signal where available, otherwise with
kill(2). Because nobody-owned processes can only be signalled by a
privileged sender, use it through the setuid binary. Linux only.
| Status | Meaning |
|---|---|
| tool's status | safexec replaces itself with the tool, so its status is returned unchanged |
| 3 | Refused or failed before exec: usage, not allowed, unresolvable path, rejected prelude |
--kill |
0 on success, 1 otherwise |
Status 3 is also a valid exit status of some tools, so callers that need to distinguish the two should also inspect stderr.
These are read before the environment is cleared.
| Variable | Values | Default | Effect |
|---|---|---|---|
SAFEXEC_DETACH |
auto, cgv2, rlimits, off |
auto |
Isolation mode. auto uses cgroup v2 and falls back to rlimits. Read with secure_getenv() on glibc, so it is ignored when an unprivileged user runs the setuid binary. |
SAFEXEC_QUIET |
0, 1 |
0 |
Suppress informational messages on stderr. |
SAFEXEC_SAFE_CWD |
-1, 0, 1 |
-1 |
If the working directory is not writable, change to /tmp (or /). -1 does this only when stdio is attached to a terminal. |
Compile-time defaults: -DSAFEXEC_QUIET_DEFAULT=0|1,
-DSAFEXEC_SAFE_CWD_DEFAULT=-1|0|1.
Isolation groups and tracks the process; by default it does not cap CPU,
memory or I/O. Resource ceilings are set in safexec_default_limits() in
safexec.c and take effect after rebuilding.
Requirements: a C compiler, make and install. Linux is fully supported.
Other POSIX systems get the allowlist, pinning, rlimits and descriptor
closing, without cgroups, pidfd or --kill. The Linux kernel headers
(linux-libc-dev, linux-headers) are optional: without <linux/ioprio.h>
the I/O priority support is compiled out.
make # build/safexec
make EXTRA_CPPFLAGS="-DSAFEXEC_WITH_POPPLER -DSAFEXEC_WITH_DB"
make cleanCC, CPPFLAGS, CFLAGS, LDFLAGS and LDLIBS are honored, from the
environment or the command line.
If CPPFLAGS, CFLAGS and LDFLAGS are not set, hardened defaults are
used: PIE, stack protector, stack-clash protection, RELRO, -z now,
_FORTIFY_SOURCE=2, and CET (x86) or BTI/PAC (aarch64) when the compiler
supports them for its target. If they are set, they replace these defaults.
Only -D_GNU_SOURCE, -fno-strict-overflow and
-fno-delete-null-pointer-checks are always added.
make CC=aarch64-linux-gnu-gccArchitecture-specific hardening options are probed with the selected
compiler, not with the build host. make check runs the built binary and
cannot be used in a cross build.
Build natively on a musl system (for example Alpine) with plain make, or
with make CC=musl-gcc elsewhere.
make static # build/safexec-x86_64-linux-musl
make static-aarch64 # build/safexec-aarch64-linux-muslThese require zig cc (make ZIG="python3 -m ziglang" static to use another
invocation). They are intended for upstream releases, not for distribution
packages. musl does not load NSS modules, so users provided by LDAP or SSSD
are not resolved; this affects the rg owner drop.
sudo make install
sudo chown root:root /usr/local/sbin/safexec
sudo chmod 4755 /usr/local/sbin/safexecmake install installs the binary to $(SBINDIR) and the manual page to
$(MAN1DIR). It needs no root privileges and never changes ownership. Do not
install on a nosuid mount. To limit who can invoke the binary, use mode
4750 with a dedicated group.
| Variable | Default | Meaning |
|---|---|---|
DESTDIR |
empty | Staging root |
PREFIX |
/usr/local |
Installation prefix |
SBINDIR |
$(PREFIX)/sbin |
Binary directory |
DATADIR |
$(PREFIX)/share |
|
MANDIR |
$(DATADIR)/man |
Manual page root |
MAN1DIR |
$(MANDIR)/man1 |
Section 1 directory |
BINMODE |
0755 |
Binary mode; 4755 for setuid |
BINOWN |
unset | Passed to install -o when set |
BINGRP |
unset | Passed to install -g when set |
sudo make uninstallThe default make install does not set the setuid bit, so that unprivileged
package builds work. The package must set it, either with
make install BINMODE=4755 under fakeroot, or in the package metadata.
Without it safexec runs in pass-through mode.
Debian's dh_fixperms removes setuid bits, so restore the mode afterwards:
override_dh_auto_install:
dh_auto_install -- PREFIX=/usr
override_dh_fixperms:
dh_fixperms
chmod 4755 debian/safexec/usr/sbin/safexecArch Linux (sbin is a symlink to bin):
package() {
cd "$pkgname-$pkgver"
make DESTDIR="$pkgdir" PREFIX=/usr SBINDIR=/usr/bin BINMODE=4755 install
}Gentoo:
inherit toolchain-funcs
src_compile() {
emake CC="$(tc-getCC)"
}
src_install() {
emake DESTDIR="${D}" PREFIX="${EPREFIX}/usr" install
fperms 4755 /usr/sbin/safexec
}Alpine (makedepends="linux-headers"):
package() {
make DESTDIR="$pkgdir" PREFIX=/usr BINMODE=4755 install
}make check runs tests/run.sh. It needs no root and skips the privilege
drop test when not run as root against a setuid binary. Skip it when cross
compiling. The end-to-end suite in tests/run-all.sh modifies the host and
must not be run from a package build.
make checkRuns tests/run.sh against build/safexec: argument handling, allowlist and
prelude rejection, path pinning, and basic execution. The privilege-drop test
runs only when the tests are run as root against a setuid-root binary;
otherwise it is skipped.
The full end-to-end suite builds a default and a full-bucket binary, runs the
parser fuzzer (400,000 iterations per run, plus an ASan/UBSan build), installs
setuid binaries, and exercises every allowlisted tool, privilege dropping,
environment scrubbing, rg owner-drop, --kill, the wget fallback, and
concurrency.
SAFEXEC_TEST_CONTAINER=1 bash tests/run-all.shThis suite modifies the host. It installs packages with apt-get, creates
the users tester and alice, writes under /srv and /home, and installs
setuid-root binaries in /usr/local/bin. It refuses to run unless
SAFEXEC_TEST_CONTAINER=1 is set and the effective uid is 0. Run it only in a
disposable container or VM with a writable cgroup v2 hierarchy, for example
docker run --privileged --cgroupns=host --init ubuntu:24.04.
| Variable | Default | Meaning |
|---|---|---|
SXTEST_RESULTS |
/tmp/safexec-test-results |
Directory for logs |
FUZZ_RUNS |
3 |
Parser-fuzz repetitions |
The exit status is non-zero if any stage (deps, build, fuzz, fixtures, smoke,
functional) fails. CI runs the same suite (.github/workflows/tests.yml).