Jails, Not Containers: A CTFer’s PWN Environment with Nix and Bwrap
My story of migrating from docker containers to a more hacky bwrap + nix based jail for isolated, low-friction, reproducible security research environments.
Preface
A few months ago, I was using pwntainer - a docker based reproducible isolated environment for CTFs and PWN for most of the CTF challenges (pwn category), and it had quite a few rough edges:
Dangerous capabilities and privileges
I had to run the container with
--privilegedfor gettinggdb-with-qemuand other services to work inside the container./sysand/devwas exposed inside the container - nothing was stopping a rogue binary from doingBASH
1sudo cat /dev/urandom > /dev/nvme0n1
Cumbersome package management
Adding pacakages was cumbersome
I was using different package managers for different utilities - I installed
yaziwith sourcefzfwith githubROPGadget(and hell a lot of other python packages) withuvonegadgetandseccomp-toolswithgem
Everytime I wanted a new package, it was becoming more unwieldy to install - I had to use a specific command for installing that package, and installing it in one container didnt make it available in another container - I had to download the packages again.
Adding new tools to the image was more of a chore.
If I wanted to add a new python package, say,
angrto the list of python deps, I had to edit theuv addline in my dockerfile - which meant anything below it would have to be rebuilt from scratch.The situation is worse for adding packages installed through
apt, that would result in the ENTIRE image being rebuilt.
The partial solution - nix and bubblewrap
All of the shortcomings with pwntainer can be mitigated by using nix with bwrap -
but it ended up highlighting features of docker that I took for granted. (Check Challenge)
Package management with nix
The nix package manager, is by-design, used to create reproducible systems.
It allowed specifying all necessary pacakges in a single file, and it would cache package installs.
As of 2026, It has around 120,000 packages, and it also includes language specific packages.
That means no longer using three different package managers - nix alone is enough for installing and using packages. A simple example:
NIX
| |
Adding new packages becomes less of a chore, since nix caches package downloads and stores them inside /nix/store.
That means adding angr does not trigger rebuild of entire system - it just fetches angr and places it in PYTHONPATH.
Isolation with bwrap
Bubblewrap (argv[0] = bwrap) is tiny, no-setuid binary that can be used to create “jails”.
It provides isolation by creating new kernel namespaces for things like processes, mounts, hostname, etc.
It provides complete filesystem isolation by mounting a directory as root, and allows mounting shared directories using overlayfs.
For pwnix, I used the following bwrap setup:
- Mount a static rootfs as an overlayfs (
lowerdir=immutable rootfs,upperdir=mountpoint inside jail,workdir=empty dir in host)
--overlay-src "$PWNIX_ROOTFS/"
--overlay "$PWNIX_UPPER_DIR" "$PWNIX_WORK_DIR" /- Create completely separated
/dev,/proc,/tmp
--dev /dev --proc /proc --tmpfs /tmp \- Mount cwd (R/W) into jail
--bind "$PWD" /root/work \- Unshare all namespaces (except net) and setup netowrking
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--unshare-all
--share-netThe challenge
In my old pwntainer workflow, each running container was like a VM - I could freely detach from it, and attach to it as neeeded, and it just worked.
But bwrap and nix by themselves, dont have any such features - which meant I had to come up with something by myself for getting VM like functionality.
Enter pwnixctl
To mitigate above challenges, I created a simple python script, which stored a bunch of metadata about jail during startup. It included the following metadata:
- jail pid and namespaces
- path of
zshexecutable inside nix store
Using the above metadata, I was able to create, start, stop, resume and dispose off jails at will with nix, bwrap, and nsenter.
On create, I initialized flake.nix for that specific jail.
On start
- I started the bwrap jail with
sleep infinityand wrote metadata (jail pid, zsh path) - Wrote nix environment variables (PATH, TERMINFO_DB, etc.) to
/etc/zprofileso it can be accessed by future login shells. (Becausensenterhas no way to inherit env vars from the jail process)
- I started the bwrap jail with
On attach, I used
nsenter, jail pid, and zsh path to get a shell inside the jail.On stop, I simply killed the process group of the jail process.
Closing thoughts
Sure, one can say this as a over-engineered hafl-baked reimplementation of a few features of docker - but I now understand why those features exist in the first place.
Namespaces, overlayfs, environment inheritance, process groups - docker abstracts all of this away, hiding it behind a pleasant docker run.
Building pwnix forced me to reason about each layer explicitly, and now my threat model is something I actually understand rather than something I just hope docker handles correctly.
Besides CTF PWN, I used pwnix to setup more elevated jails fine-tuned for certain situations. For example, I setup a pwnix jail for reverse-engineering firmware of Kai-OS devices, by providing
access ONLY to /dev/ttyUSB0 (used for qualcomm EDL communication) and hiding everything else from an untrusted EDL.py script that would usually require elevated privileges for working properly.