KLOET.NET

Bubblewrapping Everything

March 13, 2026

Table of Contents

  1. Preface Why the second article
  2. Designing Scripts How to start sandboxing a new app
    1. Default Policies An aggressive policy is a good policy
    2. Application Scripts Loosening the default policy
  3. Fun Usage Elegant solutions to make it even stricter
  4. Conclusion It's all been wrapped...

Preface

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.

— William Blake, Milton

Designing Scripts

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].

Default Policies

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.

Application Scripts

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.

Fun Usage

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.

Conclusion

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.