March 13, 2026
This is a follow-up to my previous article. At the time, I hadn't yet explored bubblewrap's full power, focusing instead on its theoretical uses. Since then, I have taken up the project of applying strong bubblewrap policies to every installed service on one of my Linux servers on my LAN. I now have a setup that I am comfortable is both maintainable and secure.
Importantly, the Linux distribution I am using for my server is Alpine
Linux which uses musl as its libc and consequently doesn't have separate
/lib and /lib64 directories. The only benefit
of this is I only have to bind one shared library directory instead of
two, though this also means that the scripts I am writing will not
work on any arbitrary distribution. Bubblewrap will probably
always require a bit of elbow grease to get working when moving
scripts from computer to computer.
O how can I with my gross tongue that cleaveth to the dust,
Tell of the Four-fold Man, in starry numbers fitly ordered
Or how can I with my cold hand of clay! But thou O Lord
Do with me as thou wilt! For I am nothing, and vanity.
If thou chose to elect a worm, it shall remove the mountains.
Scripts should be owned by root so that they cannot be modified by
unprivileged users. Also, to avoid conflicts I standardize the prefix of
adding bwrap-[PROGNAME].
balderdash:~$ ls -lah /usr/local/bin/bwrap-*
-rwxr-xr-x 1 root root 175 Mar 11 01:01 /usr/local/bin/bwrap-defaults
-rwxr-xr-x 1 root root 448 Mar 11 00:23 /usr/local/bin/bwrap-i2pd
-rwxr-xr-x 1 root root 504 Mar 11 00:24 /usr/local/bin/bwrap-monerod
-rwxr-xr-x 1 root root 560 Mar 11 01:02 /usr/local/bin/bwrap-navidrome
-rwxr-xr-x 1 root root 255 Mar 11 00:29 /usr/local/bin/bwrap-p2pool
-rwxr-xr-x 1 root root 335 Mar 11 00:41 /usr/local/bin/bwrap-socat
-rwxr-xr-x 1 root root 409 Mar 6 23:57 /usr/local/bin/bwrap-tor
Following a "Hub and Spoke" model, I define some default policies that I source in all the other scripts:
balderdash:~$ cat /usr/local/bin/bwrap-defaults
#!/bin/sh
DEFAULTS="\
--unshare-all \
--clearenv \
--cap-drop ALL \
--new-session \
--die-with-parent \
--hostname host \
--dev /dev \
--tmpfs /tmp"
This is the tightest sandbox I want to define, all of my other
bwrap-* scripts will only loosen these policies for example
by mounting directories or sharing the network.
--new-session is used to protect against sandbox escapes
via TIOCSTI ioctl (CVE-2017-5226)
Though, this shouldn't be an issue as of Linux 6.2 where that ioctl now
requires kernel administrative permissions.
At one point I had --proc defined in my defaults but that does
bleed some system info via
/proc/{cpuinfo,meminfo,version,cmdline} and such. I only
have one program that depends on access to /proc/self/exe
which I can just easily fake by making a symlink back to the right
binary, more on that later.
Here is one of my application scripts as an example:
balderdash:~$ cat /usr/local/bin/bwrap-monerod
#!/bin/sh
. /usr/local/bin/bwrap-defaults
exec bwrap \
$DEFAULTS \
--share-net \
--ro-bind /lib/ld-musl-aarch64.so.1 /lib/ld-musl-aarch64.so.1 \
--ro-bind /usr/lib /usr/lib \
--ro-bind /usr/bin/monerod /usr/bin/monerod \
--ro-bind /etc/monero/monerod.conf /etc/monero/monerod.conf \
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--bind /var/log/monero/ /var/log/monero/ \
--bind /mnt/tank1/monerod /data \
/usr/bin/monerod "$@"
Here we intentionally weaken the default policy we set above by sharing
whatever the application needs to operate, in this case it needs
--share-net since monerod needs to make outbound
connections. We also share /etc/resolv.conf since monerod
uses DNS.
$@ is a special shell parameter that expands to all
positional parameters meaning I could run bwrap-monerod --help
and it would pass that argument to monerod directly inside the
sandbox. This is an incredibly convenient way to do it because it
usually means there are very little changes needed for the already
existing system service to work with the bubblewrapped version. Here is
the original OpenRC service file with the changes I had to make to get
monerod to work
$ diff -U100 monerod.orig monerod
--- monerod.orig 2026-03-12 16:52:24.318868260 -0400
+++ monerod 2026-03-12 16:54:13.280858804 -0400
@@ -1,32 +1,34 @@
#!/sbin/openrc-run
# Copyright 2020-2025 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2
name="Monero P2P Daemon"
description="Connects to the Monero P2P network"
-command=/usr/bin/monerod
+supervisor="supervise-daemon"
+
+command=/usr/local/bin/bwrap-monerod
pidfile=/run/${RC_SVCNAME}/${RC_SVCNAME}.pid
command_args="--non-interactive ${MONEROD_ARGS}"
-command_args_background="--detach --pidfile $pidfile"
+command_args_background="--detach"
command_progress=1
command_user="${MONEROD_USER:=monero}:${MONEROD_GROUP:=monero}"
retry="SIGTERM/30/SIGKILL/5"
depend() {
after net
need localmount
}
start_pre() {
checkpath --directory --owner ${command_user} --mode 0755 \
/var/lib/monero /var/log/monero $(dirname ${pidfile})
}
status() {
if supervise_status; then
monerod status
fi
}
I heavily recommend using OpenRC's supervise-daemon to handle the PID for you. Since we're making a new PID namespace, the PID that your daemon reports itself having will not match what it is on your host, causing your init system to have a really hard time trying to figure out how to stop your daemon. Instead, supervise-daemon will take the PID of bubblewrap, which is exactly what we want.
And if you're lucky, that's it! Just make sure your paths in your app config match whatever you've bound it to in your application script. Check the logs it spits out if it crashes and decipher what you've missed binding.
Some services need to listen and respond to connections but don't need
to make any of their own outbound connections. In this case if the
service supports listening on a
Unix socket,
you can have your cake and eat it too! As an example, I have a music streaming server
(Navidrome)
that I connect to over HTTP, but I didn't have to specify the
--share-net argument in bwrap because Navidrome can listen
on a socket I create at /run/navidrome/navidrome.sock
balderdash:~$ cat /usr/local/bin/bwrap-navidrome
#!/bin/sh
source /usr/local/bin/bwrap-defaults
exec bwrap \
$DEFAULTS \
--symlink /usr/local/bin/navidrome /proc/self/exe \
--ro-bind /usr/lib /usr/lib \
--ro-bind /lib/ld-musl-aarch64.so.1 /lib/ld-musl-aarch64.so.1 \
--ro-bind /usr/local/bin/navidrome /usr/local/bin/navidrome \
--ro-bind /usr/bin/ffmpeg /usr/bin/ffmpeg \
--ro-bind /mnt/tank1/music/flac /music \
--ro-bind /etc/navidrome.toml /etc/navidrome.toml \
--bind /var/log/navidrome.log /var/log/navidrome.log \
--bind /var/lib/navidrome /var/lib/navidrome \
--bind /run/navidrome /run/navidrome \
/usr/local/bin/navidrome "$@"
Note the symlink I made for /proc/self/exe, Navidrome
depends on this file existing, but we don't need to mount the entire
/proc virtfs for this. Then, I made another service that
uses socat
(bubblewrapped, of course), to forward the socket to HTTP.
balderdash:~$ cat /etc/init.d/socat-navidrome
#!/sbin/openrc-run
name="socat-navidrome"
description="Bridge Navidrome Socket to TCP Port 4533"
supervisor="supervise-daemon"
command="/usr/local/bin/bwrap-socat"
command_args="TCP-LISTEN:4533,fork,reuseaddr \
UNIX-CONNECT:/run/navidrome/navidrome.sock"
command_user="navidrome:navidrome"
pidfile="/run/${RC_SVCNAME}.pid"
depend() {
need localmount navidrome
}
With this setup, Navidrome is completely incapable of initiating its own outbound TCP connections. Welcome to the fun zone buddy.
By shifting from a monolithic security model to one where every service is "siloed" by default, you significantly limit the blast radius of any potential exploit. Even if a vulnerability is found in a service like Navidrome, the attacker finds themselves trapped in an environment with no network access, no filesystem to poke at, and a whole lot more of nothing.
If you're looking to implement this yourself, my parting advice is
simple: start strict and loosen only when necessary. Use the
default policies to deny everything, and then incrementally add back
only the specific files and capabilities the application needs to
function. It turns system administration into a puzzle of "least
privilege," and as we've seen with the socat trick, the
solutions can be quite elegant.
Happy sandboxing—may your containers be constricting and your host stay clean.