Mount namespaces and pivot_root, explained for sandboxing: how filesystem isolation really works
A mount namespace gives a process its own copy of the mount table. That is the entire feature, and almost every mistake people make with it comes from the two words in the middle: it is a copy of the table, not a copy of the data, and the copy stays connected to the original unless you explicitly cut the wires.
I'm Ajay; I build PandaStack, which runs untrusted and model-generated code in Firecracker microVMs. This post is about what filesystem isolation on Linux actually is: what CLONE_NEWNS creates, why mount propagation quietly undoes your work, why chroot was never a security boundary and pivot_root is a genuinely different thing, the exact sequence to build a root correctly, and then the honest list of what still leaks when you have done all of it right. Short version up front: mount namespaces are the correct tool for constructing a filesystem view, and they are completely orthogonal to the question of whether the code can attack your kernel.
What a mount namespace actually is
The kernel keeps a tree of mounts: which filesystem is attached at which point, with which flags, under which parent. A mount namespace is a private instance of that tree. When a process calls unshare(CLONE_NEWNS) or clone with that flag, the kernel copies the current tree into a new namespace and points the process at the copy. Afterwards, mount(2) and umount(2) issued by that process edit its own tree. Processes in different mount namespaces can be looking at completely different filesystem hierarchies built over exactly the same underlying storage.
The name is the oldest joke in the namespace API. CLONE_NEWNS was added in Linux 2.4.19, years before anyone imagined there would be six more of these, so it got the generic name: new namespace. It means mount namespace, and nothing about the constant tells you that. Every other flag is CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWUSER, and then there is this one, which reads like a typo and is not.
The distinction that matters for reasoning about security: nothing on disk changed. The blocks are the same blocks, the inodes are the same inodes, and one kernel is still parsing all of them. What changed is which paths a process can name. A mount namespace is a naming mechanism. Whether a naming mechanism is a security boundary depends entirely on whether naming is the only way to reach the thing, and on Linux it very often is not.
Mount propagation: where the bugs actually live
If you only remember one section of this post, make it this one. When the kernel copies the mount tree into your new namespace, it copies the propagation settings too. Propagation decides whether a mount or unmount event in one tree is replayed in another, and the copies you just made are, by default on a systemd system, peers of the originals. So you unshare, you mount, and the mount appears on the host. The namespace worked exactly as designed; you just did not tell it to stop talking.
- shared (MS_SHARED) - the mount belongs to a peer group, and mount/unmount events under it propagate in both directions between all members. This is what makes a USB stick mounted by one service visible to the rest of the system, and what makes your sandbox's tmpfs visible to the host.
- private (MS_PRIVATE) - no propagation in either direction. Events stop at the mount. This is what you almost always want in a sandbox, and it is not what you get by default.
- slave (MS_SLAVE) - a one-way link: the mount receives events from its former peer group but sends none back. Choose this deliberately when the host mounting something later should appear inside, and never the reverse.
- unbindable (MS_UNBINDABLE) - private, plus the mount cannot be used as the source of a bind mount at all. Its purpose is narrow and real: it stops the combinatorial mount explosion you get when you recursively bind a tree that contains a bind of itself.
Two more details. Propagation is a per-mount property, so setting it on / alone does nothing for the hundred mounts underneath; you need the recursive form, which is MS_REC in the syscall and the r in rprivate, rslave, rshared on the command line. And the reason you keep hitting shared in the first place is that systemd remounts / as rshared during early boot. The kernel's own default for a root handed over from an initramfs is private. This is a userspace policy decision that every sandbox author inherits and has to undo.
# ---- Terminal A: the host. First, what IS the host's root propagation? ------
findmnt -o TARGET,PROPAGATION /
# TARGET PROPAGATION
# / shared
#
# "shared" is not the kernel's own default -- a fresh root from an initramfs is
# private. systemd remounts / as rshared during early boot, so that (say) a USB
# stick mounted by one unit's namespace shows up everywhere else. Convenient on
# a laptop. A trap in a sandbox.
# ---- THE MISTAKE -----------------------------------------------------------
# util-linux's unshare(1) quietly runs `mount --make-rprivate /` for you, which
# hides this bug from anyone who only ever uses the CLI. `--propagation
# unchanged` turns that helper OFF and gives you exactly what the raw
# unshare(2) syscall gives you: a copy of the mount table, propagation intact.
sudo unshare --mount --propagation unchanged /bin/bash
findmnt -o TARGET,PROPAGATION /
# / shared <- still in the host root's peer group. We share events.
mkdir -p /mnt/leak
mount -t tmpfs tmpfs /mnt/leak
exit
# Back in the host shell:
findmnt -o TARGET,PROPAGATION /mnt/leak
# TARGET PROPAGATION
# /mnt/leak shared <- the mount we made "inside" is on the HOST, and it
# outlived the namespace, because a propagated copy is
# a real mount owned by the host's mount namespace.
sudo umount /mnt/leak # cleaning up a mess that was supposed to be impossible
# ---- THE FIX ---------------------------------------------------------------
sudo unshare --mount --propagation unchanged /bin/bash
# One line. Run it FIRST, before you mount, bind, or pivot anything.
mount --make-rprivate /
findmnt -o TARGET,PROPAGATION /
# / private <- no peer group. Nothing we do from here escapes.
mkdir -p /mnt/quiet && mount -t tmpfs tmpfs /mnt/quiet
findmnt -o TARGET,PROPAGATION /mnt/quiet
# /mnt/quiet private
exit
findmnt /mnt/quiet || echo "host never saw it"
# host never saw it
# The whole mount table at a glance, which is how you audit this:
findmnt -o TARGET,PROPAGATION --tree | head -20
# Want the other direction? `mount --make-rslave /` keeps RECEIVING the host's
# mount events -- useful if a later host mount of /media should appear inside --
# while still sending nothing back. rprivate is the safe default; rslave is the
# one you pick on purpose, knowing what it means.That first half is the single most common mistake in this whole area, and it is nastier than it looks because it is silent, it usually happens under sudo, and the evidence lands on the host rather than in your sandbox. Worse, the leak is not only cosmetic: a propagated mount is a real mount owned by the host's namespace, so it survives your namespace exiting, and a recursive bind you set up for convenience can end up pinning a filesystem the host wanted to unmount.
chroot is not a security boundary, and never claimed to be
chroot changes what / means for a process, which is a completely different promise from what most people heard. It sets one field in the process's filesystem context: the directory that path resolution treats as the top. It does not unmount anything, does not revoke any descriptor you already hold, and famously does not even move your current working directory. The rest of the filesystem is exactly where it was, still mounted, still reachable by any route that does not start by resolving a path from /.
The classic escape falls straight out of that. Open a descriptor on the current root. chroot into a subdirectory - so the new root is now below your unchanged cwd. fchdir back to that descriptor, which puts your cwd above your own root. Then walk up with chdir("..") as many times as you like: the kernel clamps .. at the root only for a path that is inside the root, and yours is not, so you climb to the real filesystem root and stop there. One more chroot(".") and you are simply out.
/* The textbook chroot escape. This is not a vulnerability and not a clever
* trick: chroot(2)'s own man page says a privileged process can break out, and
* has said so for decades. It is reproduced here because a surprising number of
* "sandboxes" are still a chroot plus optimism.
*
* cc -o escape escape.c
* sudo chroot /some/jail /escape # -> a shell on the REAL filesystem
*
* Precondition: the process holds CAP_SYS_CHROOT inside the jail -- i.e. it is
* "root in the container". That is the entire premise, and it is exactly the
* premise most container-ish setups start from.
*/
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void) {
/* 1. Grab a descriptor on the CURRENT root while we can still name it.
* A directory fd is a handle to an inode, not to a path -- chroot has
* no opinion about descriptors you already hold. */
int old_root = open("/", O_RDONLY | O_DIRECTORY);
if (old_root < 0) { perror("open /"); return 1; }
/* 2. chroot into any subdirectory. Note what chroot does NOT do: it does
* not move our current working directory, which now sits ABOVE the new
* root. The kernel will not fix that for you. */
if (mkdir("/nest", 0700) < 0 && access("/nest", F_OK) < 0) {
perror("mkdir /nest"); return 1;
}
if (chroot("/nest") < 0) { perror("chroot /nest"); return 1; }
/* 3. Walk back out. ".." is clamped at the root ONLY for a path that is
* inside the root; our cwd is not, so ".." keeps climbing until it hits
* the true filesystem root and then stops there harmlessly. */
if (fchdir(old_root) < 0) { perror("fchdir"); return 1; }
for (int i = 0; i < 1024; i++) {
if (chdir("..") < 0) { perror("chdir .."); return 1; }
}
/* 4. Adopt the real root as our root, and we are simply... out. */
if (chroot(".") < 0) { perror("chroot ."); return 1; }
close(old_root);
execl("/bin/sh", "sh", (char *)NULL);
perror("execl");
return 1;
}The precondition is CAP_SYS_CHROOT, which is to say the process is root inside the jail - the exact situation most people are trying to contain. This is documented behaviour in chroot(2), not a bug, and it has never been fixed because there is nothing to fix; the syscall does what it says. chroot remains genuinely useful for what it was built for: giving a cooperating process a different view of the tree, running a package manager against another root, building images. Use it for those. Do not use it as a wall.
pivot_root: replacing the root instead of relabelling it
pivot_root(2) does something structurally different. It moves the root mount of the calling process's mount namespace: the filesystem at new_root becomes /, and the old root is moved to put_old. It is a mount-tree operation, not a per-process field. And that is the whole point, because once the old root has been relocated to a path you control, you can unmount it - and after umount2("/old_root", MNT_DETACH) there is no longer a mount, anywhere in that namespace, corresponding to the old tree. The escape route from the chroot example does not fail because it is blocked; it fails because there is nothing on the other end of it.
The syscall has a short list of requirements, and every one of them exists for a reason worth knowing. new_root and put_old must be directories, put_old must be at or beneath new_root, new_root must itself be a mount point (which is why everyone bind-mounts the new root onto itself), and neither the current root nor the parent mount of new_root may be MS_SHARED - pivot_root returns EINVAL if they are. So the propagation section above is not merely good hygiene here; it is a hard precondition. You also need CAP_SYS_ADMIN in the user namespace that owns your mount namespace, which for unprivileged use means creating a user namespace first.
- unshare(CLONE_NEWNS) - get your own mount table. Add CLONE_NEWUSER first if you are unprivileged, and CLONE_NEWPID if you want /proc to mean anything.
- mount(NULL, "/", NULL, MS_REC|MS_PRIVATE, NULL) - cut propagation before touching anything else. This both stops leaks and satisfies pivot_root's MS_SHARED precondition.
- Bind-mount the new root onto itself with MS_BIND|MS_REC, so it becomes a real mount point rather than a directory.
- Prepare /proc, /sys and /dev under the new root while their paths are still addressable from outside, each with MS_NOSUID|MS_NODEV and sysfs read-only.
- mkdir a put_old directory inside the new root - /old_root is the conventional name, and its only job is to hold the old tree for the length of two more syscalls.
- syscall(SYS_pivot_root, new_root, put_old) - the move itself.
- chdir("/") - your cwd may still be an inode in the old tree, and a cwd above your root is precisely the hole the chroot escape walked through.
- Make /old_root rprivate, umount2 it with MNT_DETACH, and rmdir the empty directory. Now there is no path to the old root at all.
/* Enter a new mount namespace and pivot into a prepared root directory, with
* every step that people skip spelled out. Requires CAP_SYS_ADMIN in the mount
* namespace: run it as root, or under `unshare -Ur` so you own a user namespace
* (see the caveats below the listing about /proc and /sys in that case).
*
* cc -o pivot pivot.c && sudo ./pivot /var/lib/roots/alpine
*/
#define _GNU_SOURCE
#include <errno.h>
#include <limits.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
static void die(const char *what) {
fprintf(stderr, "%s: %s\n", what, strerror(errno));
exit(1);
}
/* pivot_root(2) has no glibc wrapper. Call it through syscall(2). */
static int pivot_root(const char *new_root, const char *put_old) {
return (int)syscall(SYS_pivot_root, new_root, put_old);
}
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "usage: %s <new-root-dir>\n", argv[0]);
return 2;
}
const char *new_root = argv[1];
char path[PATH_MAX];
/* 1. A private copy of the mount table. From here, mount(2) and umount(2)
* edit OUR table -- subject to propagation, which is step 2. */
if (unshare(CLONE_NEWNS) == -1) die("unshare(CLONE_NEWNS)");
/* 2. Cut every propagation link, recursively. Skip this and your mounts
* travel back to the host, AND pivot_root itself fails with EINVAL,
* because it refuses to run when the current root or the parent mount
* of new_root is MS_SHARED. */
if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) == -1)
die("mount --make-rprivate /");
/* 3. pivot_root requires new_root to BE a mount point. A plain directory
* is not one, so bind it onto itself. This is not a workaround; it is
* the documented way to satisfy the requirement. */
if (mount(new_root, new_root, NULL, MS_BIND | MS_REC, NULL) == -1)
die("bind-mount new_root onto itself");
/* 4. Prepare the API filesystems BEFORE pivoting, while the paths under
* new_root are still addressable from outside. Every one of them is
* nosuid+nodev; /proc must stay exec-able because plenty of tooling
* runs things out of /proc/self/fd. */
snprintf(path, sizeof path, "%s/proc", new_root);
if (mount("proc", path, "proc", MS_NOSUID | MS_NODEV, NULL) == -1)
die("mount /proc");
snprintf(path, sizeof path, "%s/sys", new_root);
if (mount("sysfs", path, "sysfs",
MS_NOSUID | MS_NODEV | MS_NOEXEC | MS_RDONLY, NULL) == -1)
die("mount /sys");
/* A tmpfs /dev that you populate yourself beats bind-mounting the host's:
* MS_NODEV means device nodes here are inert even if something creates
* one. Fill it with null/zero/random/urandom/tty via mknod or bind. */
snprintf(path, sizeof path, "%s/dev", new_root);
if (mount("tmpfs", path, "tmpfs",
MS_NOSUID | MS_NODEV | MS_NOEXEC, "mode=755,size=64k") == -1)
die("mount /dev");
/* 5. Somewhere to park the old root. It must live at or under new_root. */
snprintf(path, sizeof path, "%s/old_root", new_root);
if (mkdir(path, 0700) == -1 && errno != EEXIST) die("mkdir old_root");
/* 6. The actual move. After this, "/" is new_root and the old root is
* reachable only at /old_root. */
if (pivot_root(new_root, path) == -1) die("pivot_root");
/* 7. Our cwd may still be an inode in the old tree. Move it, deliberately.
* (Historic behaviour here has varied; the man page tells you to do
* this, so do it and stop thinking about it.) */
if (chdir("/") == -1) die("chdir(/)");
/* 8. Detach the old root. MNT_DETACH unmounts it lazily -- it disappears
* from the tree now, and the kernel tears it down once the last
* reference goes. Make it rprivate first so the unmount does not
* propagate anywhere unexpected. */
if (mount(NULL, "/old_root", NULL, MS_REC | MS_PRIVATE, NULL) == -1)
die("make /old_root rprivate");
if (umount2("/old_root", MNT_DETACH) == -1) die("umount2(/old_root)");
if (rmdir("/old_root") == -1) die("rmdir /old_root");
/* There is now no path, from anywhere in this namespace, to the old tree.
* Compare with chroot, where the old tree is still mounted and still
* reachable by anything holding a descriptor into it. */
execl("/bin/sh", "sh", (char *)NULL);
die("execl(/bin/sh)");
return 1;
}A few notes on that listing. pivot_root has no glibc wrapper, so it goes through syscall(2). runc and friends use a compact variant - chdir into the new root, then pivot_root(".", ".") and umount2(".", MNT_DETACH) - which works because put_old is allowed to be the new root itself and the old mount ends up stacked on top of it; it is elegant and it is harder to read, so I have written the explicit form here. Behaviour around cwd and around mounting proc and sysfs inside a user namespace has shifted across kernel versions, so check pivot_root(2), mount_namespaces(7) and user_namespaces(7) on the kernel you are actually shipping on rather than trusting any blog post, including this one.
About /proc, /sys and /dev
These three are where a carefully built root gets undone. Mounting a fresh procfs does not give you a filtered procfs: unless you are also in a new PID namespace, it lists every process on the host, with their command lines and their /proc/<pid>/ directories. sysfs exposes the host's device topology and a pile of tunables, and writable sysfs has a long history as an escape surface, so mount it read-only or not at all. And /dev should be a small tmpfs you populate deliberately, not a bind of the host's, because the host's contains nodes for the block devices holding everyone else's data.
What still leaks when you have done all of it correctly
Assume the perfect version: new mount namespace, rprivate, self-bind, prepared API filesystems, pivot_root, detached old root, sensible flags. Here is what an attacker still has, and none of these are exotic.
- procfs, even a fresh one - without a PID namespace it enumerates every process on the box. Even with one, entries like /proc/cpuinfo, /proc/kallsyms, /proc/sys/* and the scheduler's timing surfaces describe the host, and describing the host is the first half of attacking it.
- sysfs - device topology, driver state, module parameters, firmware interfaces. Read-only removes the worst of it. Not mounting it at all removes more, and most workloads genuinely do not need it.
- The shared kernel, which is the big one - the filesystem code parsing your mounted image is host kernel code running with host kernel privileges. ext4, xfs, squashfs and friends are written to handle corrupt images, not adversarial ones, and mounting an image a tenant uploaded means feeding attacker-controlled bytes to an in-kernel parser. No mount flag helps with that.
- Device nodes - a mount view controls paths, not access to hardware. A process that can create or reach a node for the underlying block device reads and writes the storage directly, entirely underneath your careful tree. MS_NODEV on everything writable, and no mknod capability.
- Inherited file descriptors - fd 3 pointing at a host directory is a hole straight through your namespace. openat(3, "etc/shadow", ...) never resolves a component through your new root, and the same applies to an inherited socket, an inherited /proc/<pid>/ns/mnt handle, or a descriptor passed over a unix socket after the fact. This is the leak that survives every other thing you did right.
- setuid binaries on a mount you forgot to nosuid - the workload writes a file, gets it owned by uid 0 by some route, sets the bit, and executes its way up. One missing flag on one bind mount is enough.
- The bind mounts you added for convenience - a socket, a cache directory, a credentials file. These are deliberate holes and they are fine, right up until the thing on the other end of the socket will do privileged work on request.
nosuid, nodev, noexec - and which of the three is real
Put MS_NOSUID | MS_NODEV | MS_NOEXEC on anything the workload can write to, and MS_NOSUID | MS_NODEV on essentially everything else. Two of those three are load-bearing: nosuid genuinely defeats a setuid escalation, and nodev genuinely defeats reaching raw storage through a device node. Note that on many kernels these flags are per-mount and a bind mount does not inherit changed flags from a single mount call, so setting them on a bind usually needs a second remount - one of several places where behaviour has changed over time and the man page is the authority.
# --- Flags on anything the workload can write ---------------------------------
# nosuid: a setuid binary here does not get its owner's uid. This is the one
# that actually stops a privilege escalation.
# nodev: device nodes here are inert. Without it, a mknod of the host's disk
# is a read/write handle to storage your mount view never mentioned.
# noexec: the kernel refuses execve() on files here. See the caveat below.
mount -o remount,bind,nosuid,nodev,noexec /work/scratch
# Audit the whole tree in one shot -- the mount you FORGOT is the interesting
# one, so read the output looking for absences, not presences:
findmnt -o TARGET,SOURCE,OPTIONS --tree
# Anything writable that is missing nosuid is a finding:
findmnt -no TARGET,OPTIONS | awk '$2 !~ /nosuid/ { print "no nosuid: " $1 }'
# --- Descriptors: the hole that is not in the mount table ---------------------
# A mount namespace controls PATHS. It has nothing to say about a descriptor
# that was already open when you crossed the boundary. fd 3 pointing at a host
# directory is a tunnel through the entire namespace: openat(3, "etc/shadow")
# never resolves a single path component through your new root.
ls -l /proc/self/fd # what did you actually inherit?
# So close them. O_CLOEXEC on every fd you open is the discipline; close_range
# is the backstop for the ones you did not open:
# close_range(3, ~0U, 0); /* Linux 5.9+ */
# and from the shell, before handing control to the workload:
exec 3>&- 4>&- 5>&-
# --- Why noexec is a speed bump ----------------------------------------------
cp /bin/id /work/scratch/id && /work/scratch/id
# bash: /work/scratch/id: Permission denied <- noexec did its job
echo 'print("hello from a noexec mount")' > /work/scratch/p.py
python3 /work/scratch/p.py
# hello from a noexec mount
#
# execve() was never called on p.py. An interpreter READS its input, and read
# permission is not execute permission. Same story for `sh script`, `node x.js`,
# and anything with a --eval flag. noexec raises the cost of one technique; it
# does not remove the ability to run code.noexec is the one people over-trust. It stops execve on files in that mount, which is a real and worthwhile obstacle, but an interpreter is perfectly happy to read a script from a noexec mount and run it, because reading is not executing. python3 script.py, sh script, node index.js, and every --eval flag ever shipped all sail past it. Treat noexec as a speed bump that raises the cost of one technique, not as a wall that prevents code from running.
chroot vs pivot_root vs microVM, property by property
- What it changes - chroot: one field in the calling process's filesystem context, the directory that path resolution calls /. pivot_root in a mount namespace: the root mount of a private mount tree, after which the old tree can be unmounted entirely. microVM: which kernel resolves the path at all.
- Is the old root still reachable - chroot: yes, it is still mounted and any inherited descriptor or a second chroot walks back to it. pivot_root in a mount namespace: no, once you umount2 with MNT_DETACH there is no mount to reach; inherited descriptors remain the exception. microVM: the question is not defined - the host tree was never in the guest's address space or its block device.
- Who parses the filesystem - chroot: the host kernel. pivot_root in a mount namespace: the host kernel, which is why mounting an untrusted image is handing attacker-controlled bytes to privileged host code. microVM: the guest kernel, on a block device that belongs to that one guest.
- What a filesystem driver bug costs - chroot: the host and every tenant on it. pivot_root in a mount namespace: the host and every tenant on it, identically. microVM: one guest kernel that was going to be deleted anyway.
- Leaks you must handle by hand - chroot: essentially all of them; it was never a containment tool. pivot_root in a mount namespace: propagation, procfs, sysfs, device nodes, inherited descriptors, setuid mounts. microVM: the device-emulation surface of the VMM, which is small, audited, and the thing hypervisor vendors spend their lives on.
- Privilege required - chroot: CAP_SYS_CHROOT. pivot_root in a mount namespace: CAP_SYS_ADMIN in the owning user namespace, which unprivileged code gets by creating one. microVM: KVM, plus a VMM process that can and should be de-privileged underneath it.
- Right job for it - chroot: giving cooperating software a different view of the tree. pivot_root in a mount namespace: building the filesystem a container or a service should see, and building it properly. microVM: code you have no reason to trust, running beside other people's.
What mount namespaces are genuinely right for
None of the above is an argument against the feature, and I want to be clear about that because the honest conclusion of this series is not that Linux primitives are bad. Mount namespaces are one of the most useful things in the kernel, they are cheap, and I use them constantly - including inside our guests. They are the right tool whenever the job is constructing a view.
- Building container root filesystems - every container runtime in existence is doing the pivot_root recipe above. It is the correct implementation, and getting it right is what separates a runtime from a shell script.
- Hardening your own services - systemd's ProtectSystem, ProtectHome, PrivateTmp, ReadWritePaths and friends are all mount namespaces with a nice interface. Turning them on for a daemon you wrote is one of the highest-value-per-minute things available on Linux.
- Hiding secrets from a subprocess - a bind mount over a directory, or simply not mounting it, is a much more reliable way to keep credentials out of a child's reach than hoping it never looks.
- Reproducible builds and test isolation - a per-test scratch tree, a read-only source tree, and a tmpfs where the build wants to scribble. Fast, cheap, and it turns a class of flaky test into an impossible test.
- Read-only-by-default views over shared data - one on-disk tree presented to many consumers with different mounts and different flags, without copying any of it.
- Constructing the root that a stronger boundary will then run - this is how we use them, and it is the pattern worth internalising. Mount namespaces build the filesystem; something else enforces the boundary.
A mount namespace decides which files a process can name. A hypervisor decides which kernel is listening when it asks. Those are different questions, and no amount of care with the first one answers the second.
What this looks like in practice
PandaStack runs every sandbox as a Firecracker microVM, so the filesystem the workload sees is not a view assembled on top of the host's tree - it is a block device attached to a guest with its own kernel. There is no host mount table in scope to leak into, no host procfs to enumerate, no inherited host descriptor to walk through, and a bug in the ext4 driver parsing the guest's disk is a bug in a kernel that belongs to that one sandbox and gets thrown away with it. The filesystem view is the boundary, rather than being a view constructed on top of a shared one. Inside the guest we still use everything in this post, because the boundary between tenants has no opinion at all about what a workload does to itself.
Where this leaves you
Three things to carry away. A mount namespace is a private copy of the mount table, created with the confusingly-named CLONE_NEWNS, and it partitions naming rather than storage. Mount propagation is where your bugs will be: make / rprivate immediately after unsharing, before anything else, or your mounts leak to the host and pivot_root refuses to run. And pivot_root is categorically better than chroot, because chroot leaves the old tree mounted and reachable while pivot_root lets you detach it until there is nothing on the other side.
Then the part that the whole series exists to say. Doing all of this perfectly gives you an excellent filesystem view and changes nothing about the fact that one kernel is still parsing every filesystem, answering every syscall, and standing as both the enforcer and the target. Procfs, sysfs, device nodes, inherited descriptors and in-kernel filesystem parsers are all still there. If the code is yours, or your team's, and the threat model is accidents, mount namespaces are the right answer and you should use them everywhere. If the code is attacker-controlled or was generated by a model ninety seconds ago and read by nobody, build the view with mount namespaces and put a hypervisor underneath it. For the adjacent layers, the identity axis is in /blog/user-namespaces-explained-for-sandboxing, the filesystem-access axis in /blog/landlock-lsm-explained, the shared-kernel argument in /blog/why-docker-is-not-a-sandbox, and the full ranking in /blog/code-isolation-hierarchy.
Frequently asked questions
What is the difference between chroot and pivot_root?
chroot changes one field in the calling process's filesystem context: the directory that path resolution treats as the top. Everything else stays exactly where it was, still mounted and still reachable, which is why the classic escape works - a process with CAP_SYS_CHROOT keeps an open directory descriptor, chroots into a subdirectory, fchdirs back to a cwd that is now above its own root, walks up with chdir("..") and chroots again. pivot_root operates on the mount tree instead: it makes new_root the root mount of the namespace and relocates the old root to a path you nominate, so you can then unmount that path with MNT_DETACH. After that there is no mount corresponding to the old tree at all. The escape does not get blocked; there is simply nothing to escape to.
Why do my mounts still show up on the host after unshare(CLONE_NEWNS)?
Because the new namespace inherited the propagation settings along with the mount table, and on a systemd system / is rshared. Your copy is a peer of the host's, so mount and unmount events replay in both directions and anything you mount appears on the host - where it also outlives your namespace, because the propagated copy is a real mount owned by the host. The fix is one line, run immediately after unsharing and before any other mount: mount --make-rprivate /, or MS_REC|MS_PRIVATE on / from the syscall. Note that util-linux's unshare(1) does this for you by default, which is why the bug tends to appear only once you write your own runtime against the raw syscall. Use rslave instead of rprivate if you deliberately want host mount events to keep arriving.
Does a mount namespace protect me from untrusted code?
It gives that code a filesystem view you control, which is worth having and is not the same as protection. The same kernel is still on the other side of every syscall it makes, and several things route around the mount table entirely: an inherited file descriptor resolves paths from an inode you already opened rather than through your new root, a device node reaches storage directly, procfs without a PID namespace enumerates the whole host, and mounting an image the workload supplied hands attacker-controlled bytes to an in-kernel filesystem parser that was written to survive corruption rather than malice. Use mount namespaces to build the view, close every inherited descriptor, set nosuid and nodev everywhere, and then decide separately what enforces the boundary.
Is mounting noexec enough to stop code from running?
No, and this is worth being blunt about because noexec gets cited as though it settled the question. The flag makes the kernel refuse execve on files in that mount, which does stop the straightforward drop-a-binary-and-run-it move and is genuinely worth setting. But an interpreter reads its input rather than executing it, and read permission is not execute permission, so python3 script.py, sh script, node index.js and every --eval flag work fine against a noexec mount. Anything with a JIT or a scripting engine already on the system is a bypass. Treat noexec as raising the cost of one technique. nosuid and nodev, by contrast, close real privilege paths and deserve much more of your confidence.
Do I need root to use pivot_root?
You need CAP_SYS_ADMIN in the user namespace that owns your mount namespace, which is not quite the same as needing to be real root. Create a user namespace first - unshare(CLONE_NEWUSER|CLONE_NEWNS), or unshare -Urm from a shell - and you hold a full capability set against the namespaces you own, including the mount namespace you just created, so pivot_root becomes legal for an unprivileged user. The caveats are that some distributions restrict or disable unprivileged user namespace creation entirely, and that mounting procfs and sysfs inside one has extra ownership rules which have changed across kernel releases. Check user_namespaces(7) and mount_namespaces(7) on the kernel you ship on rather than assuming.
Keep reading
- User namespaces, explained for sandboxing — The identity axis, and the namespace that makes unprivileged mount namespaces legal in the first place.
- Why Docker is not a sandbox — The shared-kernel argument in full, which is the thing every layer in this post has in common.
- Landlock, explained — Restricting filesystem access itself, rather than restricting which paths exist to be accessed.
- The code isolation hierarchy — Where mount namespaces sit relative to seccomp, capabilities, gVisor and hypervisors.
49ms p50 cold start. Fork, snapshot, and scale to zero.