Introduction
BoxKernel is a clean-room Rust re-implementation of
Termux PRoot 5.1.0 — a user-space
implementation of chroot, mount --bind, and binfmt_misc.
Users need no privileges or setup to:
- use an arbitrary directory as a new root filesystem (the guest rootfs),
- make host files visible elsewhere in the filesystem hierarchy (bindings),
- execute programs built for another CPU architecture transparently through QEMU user-mode, and
- instrument Linux processes through a modular extension mechanism.
How it works, in one paragraph
BoxKernel launches a child process under ptrace(2). Every system call
the child attempts is intercepted at syscall-enter and syscall-exit.
BoxKernel reads the syscall arguments out of tracee memory
(process_vm_readv, falling back to PTRACE_PEEKDATA), rewrites path
arguments according to the configured rootfs and bindings, writes them
back, and lets the syscall proceed — or emulates the syscall entirely
and fabricates its return value. Where the kernel supports it, a
seccomp filter pre-selects which syscalls trap into the tracer so
uninteresting syscalls run at full speed. Nothing is ever done in the
guest kernel — there is no guest kernel; everything is emulation in
user space on the host.
Relationship to C PRoot
| C PRoot 5.1.0 (reference) | BoxKernel | |
|---|---|---|
| Language | C99 + talloc + GNU make | Rust 2024, libc-only, Cargo |
unsafe | the whole language | one audited module (src/sys.rs) + tiny documented islands |
| Global state | static variables, static mut-style | atomics + thread_local! only |
| Extensions | (callback, config, sysnums) triples, untyped payloads | AnyExtension enum + typed Event variants |
| Syscall table | generated sysnums.h | generated sysnum.rs via build.rs + data/sysnums-*.txt |
| Test contract | tests/ suite | the same suite — 113 checks green |
The binary produced by this crate is named proot so existing
consumers (e.g. proot-distro scripts) can use it as a drop-in
replacement.
Feature checklist
- User-space
chroot(-r,-R,-S) and per-process working dir (-w) - Virtual bind mounts (
-b,-m), including!no-deref bindings - QEMU user-mode mixed execution (
-q) with/host-rootfs - Full
execveemulation: ELF parsing,PT_INTERPrewriting, shebang (#!) interpretation,AT_*auxv synthesis - ABI-aware syscall translation (x86-64, x32, i386, ARM, AArch64, SH4 syscall tables)
- Extensions:
-0/-ifake identity,--link2symlink,-H,-kkompat,-pport switch,-Llink-size fix,--sysvipc, mountinfo rewriting - Seccomp-accelerated filtering;
--kill-on-exit; verbose levels-v
Reading this book
- Using it? Start with Installation then Usage and recipes; keep the command-line reference at hand.
- Hacking on it? Architecture is the map; Development guide covers the toolchain and the unsafe/parity rules; Testing shows how the C suite is the behavioral contract.
- Evaluating it? Security model explains what it does not do, and Compatibility lists every known deviation from C PRoot.
Installation
From source (the only supported method today)
BoxKernel is a single-crate Rust workspace with one dependency
(libc). There is nothing to configure and no system libraries to
install beyond Rust itself.
$ git clone https://github.com/BoxOSLab/BoxKernel.git
$ cd BoxKernel
$ cargo build --release
$ ./target/release/proot --version
BoxKernel 5.1.0
Requirements:
- Linux. BoxKernel instruments
ptrace(2),seccomp,process_vm_readv/writev, and/proc/<pid>/fd. It does not run on macOS or Windows; Android works the same way it does for the C implementation (through Termux). - Rust 1.85 or newer.
rust-toolchain.tomlpinsstableand pulls inrustfmt+clippyautomatically. Edition 2024 is in use. - Kernel: any reasonably modern Linux.
process_vm_*(≥ 3.2) is preferred for tracee memory access; thePTRACE_PEEK/POKEfallback covers older kernels. Seccomp acceleration needsseccompfiltering support (≥ 3.5 realistically, withSECCOMP_MODE_FILTER).
Installing the binary
$ cargo install --path . # installs `proot` into ~/.cargo/bin
# or
$ install -Dm755 target/release/proot ~/.local/bin/proot
The binary is deliberately named proot — it is a drop-in replacement
for the C implementation. If you already have C proot installed, pick
a different destination or rename (e.g. proot-rs).
Installing the manual page
$ install -Dm644 doc/proot.1 ~/.local/share/man/man1/proot.1
$ man proot
Verifying the install
$ proot -0 /usr/bin/id -u
0
$ proot -b /etc/hostname /bin/cat /etc/hostname
<your hostname>
$ proot -v 1 /bin/true # verbose banner, one line
Cross-compiling / Android
For Android/Termux use the same build inside the Termux Rust package;
all Termux-specific options (-0, -l, --sysvipc, -p, -L,
-H, --kill-on-exit) are implemented except --ashmem-memfd, which
is Android-Bionic-specific and not yet ported — see
Compatibility.
Usage and recipes
proot [option] … [command]
Options come first, then the command to launch (/bin/sh when none is
given). This chapter collects practical recipes; the per-option detail
lives in the command-line reference.
A chroot equivalent
Give proot a guest rootfs and a command:
$ proot -r ~/alpine-rootfs /bin/cat /etc/os-release
NAME="Alpine Linux"
Omitting the command gives an interactive shell — the shortest way to confine a shell and all its children:
$ proot -r ~/alpine-rootfs
/ #
Programs inside are still ordinary host processes (same PID namespace, same network); only their view of the filesystem is rewritten.
mount --bind equivalent
-b (or -m) relocates a host path into the guest view:
$ proot -b ~/alternate_hosts:/etc/hosts /bin/cat /etc/hosts
By default the guest side of a binding is dereferenced — binding over
/bin/sh actually lands on /bin/dash if /bin/sh is a symlink. Add
! to pin the bind to the link itself:
$ proot -b /bin/bash:/bin/sh! /bin/sh -c 'echo $0'
/bin/sh # but executing bash, as a file named /bin/sh
-R and -S: recommended/protected binding sets
A bare guest rootfs cannot see /etc/resolv.conf, /dev, /proc, …
-R dir≈-r dirplus a recommended set of binds (/etc/host*,/etc/passwd,/etc/resolv.conf,/dev,/sys,/proc,/tmp,/run,$HOME, and the rootfs itself).-S dir≈-Rplus-0(fake root), with a smaller bind list chosen so package managers cannot scribble on host files. Use-Sto install packages into a guest rootfs.
$ proot -R ~/alpine-rootfs /bin/sh
$ proot -S ~/alpine-rootfs /sbin/apk add build-base
Fake root: -0 and -i uid:gid
$ proot -0 /usr/bin/id
uid=0(root) gid=0(root) groups=0(root)
-0 intercepts identity syscalls (getuid, geteuid, getresuid,
setuid-family, chown-family, chmod/mknod/faccessat checks) and
reports success. -i 1000:1000 does the same with explicit ids.
Recorded ownership/mode changes persist in .proot-meta-file.* helper
files next to the affected files.
Hiding helper files: -H
-0/-i and -l create .proot-meta-file.* and .proot.l2s.* files
inside the guest rootfs. -H filters them out of getdents results so
guest programs (and * expansions) never see them.
Hard links that aren’t: --link2symlink
Some filesystems (SELinux-confined app storage on Android, FAT,
some FUSE mounts) deny link(2). --link2symlink records emulated
hard links as symlinks named .proot.l2s.* and reports them to guest
programs as real links.
Protected ports: -p
Kernels with paranoid networking refuse bind() to ports < 1024 for
unprivileged users. -p remaps bind/connect calls to port + 1024 on
localhost sockets — a guest binding :80 actually binds :1080.
Spoofing the kernel release: -k string
Old rootfs + new glibc occasionally hits FATAL: kernel too old, or a
program inspects uname. -k 6.1.0 rewrites the release field and
emulates selected newer-kernel syscalls (kompat extension).
Symlink sizes on Bionic: -L
Android’s lstat reports misleading symlink sizes; combined with -l,
-L rewrites st_size/st_ino/link counts so emulated links look
plausible to tar, ls -l, etc.
System V IPC: --sysvipc
shmget/semget/msgget families are emulated inside the tracer (a
detached --shm-helper process backs shared memory with real fds).
Each proot instance is its own IPC namespace — two proot sessions do
not share SysV objects.
Debugging runs
-v N(orPROOT_VERBOSE=N):-1silent-but-fatal,0errors,1key decisions, up to9full syscall tracing.--kill-on-exit: kill all tracees when the main command exits — without it, orphaned/detached processes keepprootalive waiting.-w dir: initial working directory inside the guest.
Environment variables
| Variable | Effect |
|---|---|
PROOT_VERBOSE | same as -v |
PROOT_TMP_DIR / TMPDIR | directory for internal temp files (default /tmp) |
PROOT_NO_SECCOMP | disable the seccomp accelerator (debugging) |
PROOT_ASSUME_NEW_SECCOMP | assume kernel ≥ 4.8 seccomp semantics |
PROOT_NO_MOUNTINFO | disable /proc/self/mountinfo rewriting |
PROOT_IGNORE_MISSING_BINDINGS | don’t fail if a -b source is missing |
PROOT_DONT_POLLUTE_ROOTFS | store link2symlink meta-files outside the rootfs |
PROOT_LOADER | override the injected execve loader stub |
PROOT_USE_LOADER_FOR_QEMU | route QEMU execs through the loader stub |
PROOT_FORCE_FOREIGN_BINARY | force foreign-ABI handling (testing) |
PROOT_FORCE_KOMPAT | force-enable the kompat extension (testing) |
LD_* | glibc loader variables are parsed and forwarded by the ldso emulation |
Things that will surprise you once
- Bindings apply to the guest path —
-b /procmeans host/procshows up at guest/proc. execveof a script re-enters translation for its shebang interpreter —-bapplies there too.- Files written into a bound directory land on the host path. There is no copy-on-write layer.
sudo,ping, realmount(2),setuidbinaries: still bound by host privileges.-0only fakes reports of privilege.
Command-line reference
The command line is two parts: proot options, then the command to
launch (/bin/sh when none is given). This page documents the options
compiled into the Rust binary — it mirrors proot --help and
man proot (doc/proot.1).
Filesystem view
-r path, --rootfs=path
Use path as the new guest root filesystem (default /).
The path typically contains a Linux distribution. With the default /,
the bind mechanism alone relocates host files — see -b. Prefer -R
or -S for real rootfs work.
-b path, --bind=path · -m path, --mount=path
Make path accessible inside the guest rootfs. Forms:
-b /host/dir— bound at the same guest path-b /host/dir:/guest/loc— bound at an explicit guest location-b /host/dir:/guest/loc!—!suppresses dereferencing of a guest symlink target
Multiple -b options compose; later binds can shadow earlier ones.
-q command, --qemu=command
Execute guest programs through the QEMU user-mode command command
(it may contain arguments, e.g. -q "qemu-arm -g 1234").
Each guest execve is wrapped with the QEMU command; host programs
still run natively, and the whole host rootfs appears at
/host-rootfs inside the guest — see
Mixed mode & QEMU. The runner itself is resolved on
the host filesystem via the host PATH, so -q qemu-aarch64
works even when no QEMU exists inside the rootfs.
-w path, --pwd=path, --cwd=path
Set the tracee’s initial working directory (guest path). Equivalent to
cd before the command runs; PWD is updated accordingly.
-R path
Alias: -r path plus a recommended bind list:
/etc/host.conf, /etc/hosts, /etc/hosts.equiv, /etc/mtab,
/etc/netgroup, /etc/networks, /etc/passwd, /etc/group,
/etc/nsswitch.conf, /etc/resolv.conf, /etc/localtime, /dev/,
/sys/, /proc/, /tmp/, /run/,
/var/run/dbus/system_bus_socket, $HOME, and path itself.
-S path
Alias: -0 -r path plus a minimal protected bind list safe for
package installation:
/etc/host.conf, /etc/hosts, /etc/nsswitch.conf,
/etc/resolv.conf, /dev/, /sys/, /proc/, /tmp/, /run/shm,
$HOME, and path itself.
Identity & compat extensions
-0, --root-id
Make the current user appear as root and fake its privileges:
identity syscalls report uid/gid 0; chown, chmod, mknod,
faccessat, capset-adjacent checks report success. Persisted changes
are recorded in .proot-meta-file.* helpers. Weaker than fakeroot —
it only fakes reports.
-i string, --change-id=string
Like -0 but with an explicit uid:gid — -0 is -i 0:0.
-l, --link2symlink
Emulate link(2) with symlinks (.proot.l2s.* files) for filesystems
that forbid hard links (SELinux-confined storage, FAT, some FUSE).
-H
Hide every .proot* helper file from getdents listings so guests
never see metadata created by -0/-i/-l.
-k string, --kernel-release=string
Make uname report string as the kernel release and enable the
kompat extension’s emulation of newer-kernel syscalls missing from the
host (e.g. running a glibc that wants a newer kernel).
-p
Remap privileged ports: bind/connect to localhost ports < 1024 are
shifted by +1024 (Android/paranoid-networking kernels).
-L
Correct lstat sizes/inodes for links emulated by -l
(Bionic reports misleading symlink sizes).
--sysvipc
Handle System V IPC (shm*, sem*, msg*) inside the tracer. Each
proot instance is an independent IPC namespace; a detached helper
process (proot --shm-helper) backs shared memory with real fds.
Process control & diagnostics
--kill-on-exit
Kill all tracee processes when the main command exits. Without it,
proot waits for orphaned/detached tracees to finish.
-v value, --verbose=value
Debug verbosity to stderr (also PROOT_VERBOSE). -1 quiet, 0
errors only, 1 milestones, higher values dump syscall-level detail
(9 is firehose).
-V, --version, --about
Print version, license, and contact, then exit.
-h, --help, --usage
Print version and usage, then exit.
Exit status
Non-zero on internal proot errors; otherwise the exit status of the
last terminated program. To tell them apart, look at the error
message or run with -v.
Files
/proc/<pid>/fd/links are read to supportopenat-family syscalls.-lstores emulated hard links as.l2s.*/.proot.l2s.*symlinks.-0/-irecord fake ownership/modes in.proot-meta-file.*.-Hhides all.proot*helpers from directory listings.
Options present in C PRoot but not ported
--ashmem-memfd (Android-Bionic-only memfd_create emulation via
ashmem) is not implemented; see
Compatibility.
Mixed mode & QEMU user-mode
Foreign-architecture rootfs
When the guest rootfs targets a different CPU than the host, -q
wraps every guest execve with a QEMU user-mode command:
$ proot -R ~/armhf-rootfs -q qemu-arm /bin/uname -m
armv7l
The option takes a whole command line, not just a binary:
$ proot -R ~/armhf-rootfs -q "qemu-arm -g 1234" /usr/bin/gdbserver-less-app
How the runner is found
The QEMU command is resolved on the host — against the host PATH
and host filesystem — even though the wrapped programs run in the
guest namespace. This matches the C implementation’s
reconf.tracee == NULL behavior: the runner is a host binary by
definition, so resolving it inside the guest rootfs would find nothing
(or worse, a foreign-architecture QEMU).
Practical consequence: -q qemu-aarch64 requires qemu-aarch64 on
your host PATH, not inside the rootfs.
Mixed execution
With -q active, host and guest programs interoperate:
- Every ELF
execveis class-checked. A foreign binary is wrapped in the QEMU command; a host binary (e.g. under/host-rootfs) runs natively. - The entire host rootfs is bound at
/host-rootfsinside the guest. - Host tools can be bound over guest paths to accelerate builds
(cross-compilers, host
make, host interpreters):
$ proot -R ~/armhf-rootfs -q qemu-arm -b /usr/bin/make
$ make --version # executes the *host* x86-64 make at native speed
LD_LIBRARY_PATHand friends are managed separately per side: the execve/ldso emulation restores the guest value when a host program execs a foreign one and rewrites it when QEMU must find the guestld.so— the-E/‑Uargument pairs QEMU consumes are emitted in the same order C produces.
PROOT_USE_LOADER_FOR_QEMU
Normally QEMU handles ELF loading itself. Setting
PROOT_USE_LOADER_FOR_QEMU routes the exec through the injected
loader stub instead — useful for QEMU builds without -lm-free
binfmt handling or when the ELF PT_INTERP must be rewritten by
proot before QEMU sees it.
ELF and shebang pipeline
execve handling (see Architecture)
classifies the target first:
- ELF header parse → native vs foreign ABI (
execve/elf.rs) #!shebang → recurse with the interpreter (execve/shebang.rs)PT_INTERP→ the dynamic linker is rewritten to a translated path or QEMU’sld.sopath list (execve/ldso.rs)- auxv is rebuilt (
AT_PHDR,AT_BASE,AT_SYSINFO_EHDR, …) so the guest sees a coherent address space (execve/auxv.rs)
file-style inspection shows the split clearly:
$ file /bin/echo # guest: ARM
$ file /host-rootfs/bin/echo # host: x86-64
$ /host-rootfs/bin/echo mixed # runs natively, sees guest view
Performance notes
- QEMU adds its own per-syscall cost on top of ptrace interception; syscall-heavy guest programs are the slowest configuration.
- The seccomp accelerator (
src/syscall/seccomp.rs) still applies — it filters host syscalls the wrapped programs issue, so mixed mode keeps most of its speedup. - Prefer binding a host build tool (
-b /usr/bin/make,-b /usr/bin/cc) over running the guest copy under QEMU whenever correctness allows it.
Security model
BoxKernel is not a sandbox
This is the single most important thing to understand, and it is inherited verbatim from PRoot:
PRoot confuses programs; it does not confine them.
- Every tracee runs with the invoker’s uid, capabilities, and
filesystem access.
-ronly rewrites how paths resolve — a guest that opens../../etc/shadowstill lands wherever the host puts it (bound paths notwithstanding, every host file reachable to the user is reachable to the guest). -0fakes reports of root. It grants nothing the user didn’t already have, and conversely grants the guest everything the user already has.- Deliberately adversarial guests can escape the emulated view: they
can invoke
ptracethemselves (nested tracing is emulated, but the emulation is a compat feature, not a containment), use syscalls or ABIs the filter doesn’t translate, or write through a binding. - Do not run untrusted code under BoxKernel. For confinement use
mount namespaces (bubblewrap,
unshare), containers, or VMs.
What the design does protect
- Tracer integrity. A tracee cannot corrupt the tracer through
syscall arguments: every pointer read/write goes through
src/tracee/mem.rs(process_vm_*withPTRACE_PEEK/POKEfallback), and all kernel-boundary calls are funneled throughsrc/sys.rs. - Host CLI argument integrity. QEMU env pairs (
-E/-U) and exec argument vectors are rebuilt in tracer-owned memory, never edited in place in tracee memory. - No ambient root. Nothing in BoxKernel requires or grants privilege; there is no setuid helper.
The unsafe-code policy (for auditors)
| Site | What it is | Why it must exist |
|---|---|---|
src/sys.rs (~98 blocks) | thin libc wrappers | the FFI boundary; the only file allowed to call libc |
src/execve/elf.rs (8) | as32/as64 union accessors | ELF-class-validated reads on all-POD C-layout headers |
src/tracee/event.rs (3) | siginfo_t/utsname field reads | kernel-provided pointers in signal handlers |
src/syscall/netlink.rs (1) | CStr::from_ptr | bounded interface-name buffer |
loader/ (10) | naked fns + asm! syscall stubs | freestanding no_std code injected into tracees |
Enforced invariants:
- No
static mut. Event flags areAtomic*; the pipe-shadow table isthread_local!; netlink probe caching isAtomicI32. - No
transmute. POD serialization goes throughsys::as_bytes/as_bytes_mut; cross-field reads useoffset_of!. unsafe_op_in_unsafe_fnis denied crate-wide — even insideunsafe fn, each operation needs its own explicit block.- Every
unsafeblock carries a// SAFETY:invariant comment. ptrace/process_vm_*takeusizewords, not raw pointers — the machine-word semantics are kernel ABI, not pointer provenance.
Auditing dependencies
libc is the only dependency. cargo deny check (CI-enforced)
verifies licenses, advisories, and banned crates; Cargo.lock is
committed.
Reporting
See SECURITY.md — private GitHub advisories preferred, no public issues for vulnerabilities.
Architecture
BoxKernel is ~22,000 lines of Rust in one crate (boxkernel) producing
a proot binary, plus a freestanding loader/ sub-crate injected into
tracees. This chapter is the map: where each concern lives and how a
syscall flows through the system.
The big picture
┌─────────────────────────── proot process ───────────────────────────┐
│ │
argv ──► cli/mod.rs ──► tracee registry ◄── waitpid ──► tracee/event.rs (loop) │
│ │ │ │
│ ptrace attach PTRACE_SYSCALL stops │
│ │ │ │
│ syscall pipeline: sysenter ──► translate ──► sysexit │
│ │ │ │
│ ┌────────────┼───────────┐ │ │
│ ▼ ▼ ▼ ▼ │
│ path/* syscall/* extension/* execve/* │
│ (canon, (enter/exit, (typed (ELF, ldso, │
│ bindings, chain, events) shebang, auxv) │
│ glue, /proc) sockets) │
│ │ │
│ tracee/mem.rs ◄── process_vm_* / PTRACE_PEEK/POKE │
│ │ │
└─────────────────────┼───────────────────────────────────────────────┘
▼
src/sys.rs — every libc call, the only unsafe home
Module map
| Module | Ports | Responsibility |
|---|---|---|
src/main.rs | main() | Entry; --shm-helper dispatch; atexit cleanup |
src/cli/ | cli.c | Option parsing, binding/extension setup, launch_process, run |
src/tracee/ | tracee/ | Tracee registry & lifecycle, the waitpid event loop, register access, tracee memory I/O, ABI word-size, seccomp attach |
src/ptrace/ | ptrace/ | Emulation of the ptrace API itself — guest programs that call ptrace (gdb, strace inside the guest) get a virtualized view; also wait emulation |
src/syscall/ | syscall/ | The translation pipeline: enter/exit hooks, sysnum chain rewrite, socket/sockaddr translation, netlink dump emulation, pipe-shadow trick, rlimit emulation, seccomp filter build, ABI heap |
src/path/ | path/ | Canonicalization (canon), binding list (binding), /proc glue (proc_emul), sub-reconfiguration (glue), temp files, f2fs workaround |
src/fpath.rs | path/binding.c, path/path.c helpers | FixedPath: PATH_MAX-bounded path buffer with C char* semantics preserved (NUL, component ops) |
src/execve/ | execve/ | The execve machine: ELF header/program-header parsing, PT_INTERP rewriting, shebang expansion, auxv (AT_*) construction, aoxp auxv-pointer fixups, exit-time stack unpoisoning |
src/extension/ | extension/ | Typed Event enum + AnyExtension dispatcher + the 8 built-in extensions |
src/syscall/ (seccomp) | syscall/seccomp.c | BPF program building, seccomp filter installation, SIGSYS trapping |
src/sysnum.rs + data/sysnums-*.txt | syscall/sysnums-*.list + gen | Per-ABI syscall-number tables, code-generated at build time |
src/arch.rs | arch.h | Host word size, reg layouts |
src/sys.rs | all of libc | The FFI boundary — the only module allowed to call libc directly |
src/note.rs | cli/note.c | note!/verbose! diagnostics with severity levels |
src/util.rs | misc | Small shared helpers |
loader/ | loader/ | no_std position-independent stub mapped into tracees to finish execve |
A syscall’s journey
-
Stop.
tracee/event.rsrunswaitpid(-1, …, __WALL)in a loop. On eachPTRACE_EVENT_STOP/syscall-stop/SIGTRAP it looks up theTraceein the registry and dispatches on stop reason. The first SIGTRAP is consumed forptraceoption setup (deliver_sigtrapsemantics — later SIGTRAPs re-inject so guest handlers see them). -
Enter.
syscall/enter.rs::translate_sysenterreads the syscall number + args viatracee/reg.rs(PTRACE_GETREGS). The sysnum is decoded ABI-aware (sysnum.rs+data/sysnums-*.txtfor x86_64/x32/i386/arm/arm64/sh4). Extensions getSysEnterStart, then the per-syscall translator runs; extensions getSysEnterEnd. -
Path translation. Syscalls carrying paths go through
path/canon.rs(guest→host canonicalization honoring bindings, cwd, and/proc/<pid>/fd), thenpath/binding.rspicks a host substitution,path/glue.rshandles sub-reconfiguration, andTranslatedPath/SymlinkDeref/HostPath/GuestPathevents let extensions further rewrite. Result is written into tracee memory viatracee/mem.rs(process_vm_writev→PTRACE_POKEDATAfallback) and the register is repointed. -
Emulation or pass-through. Some syscalls are answered entirely in the tracer (
getresuidunder-0,unameunder-k, all SysV IPC under--sysvipc, netlink dumps,ptraceitself for nested tracing). Others proceed with rewritten args.socket.rs/netlink.rs/pipe_shadow.rs/rlimit.rshandle the special families;chain.rshandles “one syscall, several guest semantics” cases. -
Exit.
syscall/exit.rs::translate_sysexitreads the result register, post-processes (fake_id0pokes fabricated uid/gid buffers,link2symlinkfabricatesst_nlink,kompatadjusts uname fields, mountinfo rewrites read buffers), then writes the final guest-visible result back. -
Special stops.
SIGSYS(from our seccomp filter) routes tohandle_sigsys;PTRACE_EVENT_EXECentersexecve/enter.rs;EXEC_EXITunpoisons viaexecve/exit.rs; clone/fork stops driveinherit_extensionsand the tracee registry.
execve: the hardest syscall
execve gets its own subsystem because it replaces the tracee:
execve/enter.rsparses the new ELF, decides native vs QEMU, computes load bias, maps theloader/stub into tracee memory, and plants aSIGTRAPthat fires when the kernel finishesexecve— because ptrace loses control across the exec boundary.execve/elf.rsis a self-contained ELF32/ELF64 parser (the only other placeunsafelegitimately lives — union accessors for C-layout headers).execve/ldso.rsrewritesPT_INTERPand assemblesLD_LIBRARY_PATH/ld.soarguments;shebang.rsexpands#!;auxv.rs/aoxp.rsrebuild the auxv vector on the new stack.
The extension framework
C’s (callback, config, filtered_sysnums) triple becomes:
#![allow(unused)]
fn main() {
pub enum Event<'a> { GuestPath{..}, TranslatedPath{..},
SysEnterStart, SysExitEnd{status}, InheritParent{..}, … }
pub enum AnyExtension { FakeId0(..), Link2Symlink(Box<..>),
Kompat(..), Sysvipc(..), … }
}
AnyExtension::notify(&mut self, &mut Tracee, Event) -> i32 is the
single dispatch point. Inheritance (fork/clone) is explicit:
clone_for_child per variant — Kompat shares one
Rc<RefCell<Config>> across all tracees (matching C’s
INHERIT_PARENT → shared), while FakeId0 clones per-child.
The sys boundary
Every libc call — open, stat, ptrace, process_vm_readv,
waitpid, socketpair, execvp, errno — goes through
src/sys.rs as a thin safe wrapper that preserves C-identical
errno semantics (raw status return, caller inspects errno). This
is the reason the codebase can claim:
- 0
static mut, 0transmute, 0libc::syscalloutsidesys - ~98
unsafeblocks, all in one auditable file - no raw pointers in public safe signatures (machine words,
usize)
See Security model for the enforced invariants.
The loader/ sub-crate
execve needs code running inside the tracee at exec-completion
time (to hand registers/auxv back to the tracer). loader/ is a
no_std, position-independent, naked-function crate built as a raw
binary and include_bytes!’d into the main crate. Its unsafe is
inherent (assembly syscall stubs) and intentionally isolated from the
safe code base.
Seccomp acceleration
Where seccomp filtering is available, syscall/seccomp.rs builds a
BPF program that only traps syscalls needing translation; everything
else runs uninterrupted. tracee/seccomp.rs attaches it to tracees;
SIGSYS events route back into the translator. The C “seccomp
acceleration” design is preserved, including the
PROOT_ASSUME_NEW_SECCOMP / PROOT_NO_SECCOMP switches.
Extension framework
Extensions are BoxKernel’s mechanism for policy on top of
translation. Everything the C code did with
(callback, config, filtered_sysnums) triples becomes a typed
Event + AnyExtension dispatch in src/extension/mod.rs.
The interface
#![allow(unused)]
fn main() {
pub enum Event<'a> {
GuestPath { base: &'a mut FixedPath, path: &'a [u8] },
HostPath { path: &'a mut FixedPath, is_final: bool },
SymlinkDeref{ link: &'a FixedPath, referree: &'a mut FixedPath },
TranslatedPath { path: &'a mut FixedPath },
SysEnterStart, SysEnterEnd { status: i32 },
SysExitStart, SysExitEnd { status: i32 },
NewStatus { status: i32 },
InheritParent { child_pid: i32, clone_flags: Word },
InheritChild { clone_flags: Word },
ChainedEnter, ChainedExit,
Initialization { arg: &'a str },
Removed, PrintConfig, PrintUsage { detailed: bool },
SigsysOcc,
Link2SymlinkRename{..}, Link2SymlinkUnlink{..},
StatxSyscall{..}, ReadlinkProcFd{..}, ExecveProcExe{..},
}
}
One dispatch method per extension:
#![allow(unused)]
fn main() {
impl AnyExtension {
pub fn notify(&mut self, tracee: &mut Tracee, event: Event) -> i32;
pub fn clone_for_child(&self, clone_flags: Word) -> AnyExtension;
}
}
Return value convention follows C: < 0 aborts the operation,
0 continues, > 0 requests the child-side inherit callback.
Inheritance model
fork/clone stops call inherit_extensions, which for each
extension runs InheritParent then InheritChild:
0fromInheritParent→ shared config (e.g.Kompatuses oneRc<RefCell<Config>>for all tracees — a child’ssetdomainnameis visible to siblings, matching C)> 0→ per-child cloned config (FakeId0deep-clones)< 0→ not inherited
Built-in extensions
| Extension | CLI | What it does |
|---|---|---|
FakeId0 | -0, -i u:g | Fakes uid/gid/cap answers; intercepts chown, chmod, mknod, setxid, faccessat, statx; persists metadata in .proot-meta-file.*; emulates chroot success |
Link2Symlink | -l | link(2) → creates .l2s.*/.proot.l2s.* symlinks; fakes st_nlink; handles rename/unlink of emulated links (largest extension — boxed in the enum) |
HiddenFiles | -H | Filters .proot* helper names out of getdents results |
Kompat | -k | Rewrites uname/setdomainname-adjacent state; emulates missing newer-kernel syscalls; config is shared across tracees |
PortSwitch | -p | bind/connect to localhost ports < 1024 get +1024 |
FixSymlinkSize | -L | Corrects st_size/st_ino/nlink for emulated links (Bionic) |
Mountinfo | always on | Rewrites /proc/self/mountinfo reads so bindings look like real mounts |
Sysvipc | --sysvipc | shmget/shmat/shmdt/shmctl, sem*, msg* implemented in-tracer; --shm-helper daemon holds backing fds |
Writing an extension
- Create
src/extension/my_ext.rswith a struct holding per-tracee (or shared) state. - Add a variant to
AnyExtensionand implementnotify— pattern- match theEvents you need, ignore the rest. - Implement
clone_for_childdeciding shared vs per-child state. - Wire CLI: parse an option in
cli/mod.rsand callextension::initialize_extension(tracee, AnyExtension::MyExt(..), arg). - For syscall filtering, use the
StatxSyscall/SigsysOcc-style events or thefiltered_sysnumsdispatch in the translator.
The event set is deliberately a superset of what any single extension
uses — the C code has the same shape (one extension.c handler
switching on an ExtensionEvent enum).
Debugging extensions
-v 2+ prints extension callbacks;PrintConfig/PrintUsageevents feed--help/diagnostics.PROOT_FORCE_KOMPAT,PROOT_DONT_POLLUTE_ROOTFS,PROOT_NO_MOUNTINFOtoggle specific extensions for testing.
Development guide
Toolchain
- Rust 1.85+ (MSRV), edition 2024 —
rust-toolchain.tomlpinsstable+rustfmt+clippy. - Single crate, single dependency (
libc). Theloader/sub-crate is built bybuild.rswith a directrustc --edition=2024invocation (it isno_std/freestanding, not a Cargo workspace member). - Syscall-number tables are generated by
build.rsfromdata/sysnums-*.txtintoOUT_DIR/sysnums.rs.
$ cargo build # debug
$ cargo build --release # release → target/release/proot
$ cargo test # unit tests
Quality gates (CI-enforced)
$ cargo fmt --all -- --check
$ cargo clippy --all-targets --locked -- -D warnings
$ cargo test --locked
$ cargo deny check
$ cargo build --release --locked
$ cargo doc --no-deps --document-private-items
rustfmt.toml: edition 2024, 100 columns, Unix newlines.
Cargo.toml [lints]: unsafe_op_in_unsafe_fn = "deny" +
clippy::all warn (CI: -D warnings). deny.toml: license
allow-list, advisory DB, banned sources.
The parity rule
Every src/ file is a named port of a C unit (comments say so:
// port of tracee/event.c). When changing behavior:
- Find the C counterpart — the reference checkout is the spec.
- Keep C-identical errno semantics: innermost
-errnoreturns,sys::clear_errno()before calls judged byerrno == 0,process_vm_*fast-path success must clear a potentially stale errno (the C code does this inside the mem primitives — a subtle rule that already bit us once). - Keep C’s verdicts for weird cases — they are almost always
load-bearing (e.g.
deliver_sigtrap,INHERIT_PARENT → shared,reconf.tracee == NULLrunner resolution).
The unsafe policy
Where unsafe may live, in descending preference:
src/sys.rs— the only home for libc calls. New kernel boundary? Add a wrapper,// SAFETY:note, keep C status/errno return.- Documented islands —
execve/elf.rsunion accessors,tracee/event.rskernel-struct reads,syscall/netlink.rsCStr. loader/— freestanding, inherently unsafe.
Hard rules: no static mut (atomics/thread_local!), no
transmute (sys::as_bytes, offset_of!), no raw pointers in safe
signatures (use usize words), // SAFETY: on every block, and
unsafe_op_in_unsafe_fn is denied crate-wide.
Project layout
src/
cli/mod.rs option parsing, launch, run loop
tracee/ registry, event loop, regs, mem, seccomp, ABI
ptrace/ ptrace + wait *emulation* (guest ptrace API)
syscall/ enter/exit pipeline, sockets, netlink, seccomp
path/ canon, bindings, glue, proc_emul, temp, f2fs
execve/ ELF, ldso, shebang, auxv, aoxp, enter/exit
extension/ Event/AnyExtension + 8 extensions (sysvipc sub-mod)
fpath.rs FixedPath — PATH_MAX buffer with C semantics
sys.rs all libc wrappers (the unsafe home)
sysnum.rs per-ABI syscall tables (generated)
arch.rs note.rs util.rs
loader/ no_std execve-completion stub
data/ sysnums-*.txt tables
tests/ (empty — unit tests are #[cfg(test)] in-file;
integration = upstream C suite)
docs/ this book
doc/proot.1 man page
Adding a syscall
- Find its sysnum entry in
data/sysnums-<abi>.txt(regen happens at build time). - Implement
translate_xxx_enter/_exitinsyscall/enter.rsorexit.rs(or a focused module likesocket.rs). - Route args via
tracee::mem(peek/pokeprimitives); translate paths viapath::canon. - Add an integration test upstream and/or a
#[cfg(test)]unit test for pure logic.
Adding an extension
See Extension framework → Writing an extension.
Debugging
-v 9= full syscall trace on stderr;PROOT_VERBOSEequivalent.PROOT_NO_SECCOMP=1— rule out the accelerator when a stop is misbehaving.strace -f -e ptrace ./proot …— watch the tracer’s own ptrace.RUST_BACKTRACE=1— panics (should not happen — report them).
Commit & PR conventions
- Small focused commits, imperative subject; why in the body.
- CI must be green: fmt, clippy, tests, deny, docs.
- Behavioral changes need the upstream suite green (see Testing).
Testing
Three layers
| Layer | What | Where | Command |
|---|---|---|---|
| Unit | Pure logic (path canonicalizer, FixedPath, compare_paths) | #[cfg(test)] in src/ | cargo test |
| Smoke | Binary boots, binds, fakes root | CI smoke test step | proot -0 id -u, proot -b … |
| Reference suite | The C implementation’s full integration suite run against the Rust binary | proot/tests/ in the C checkout | make -C tests |
The third layer is the real contract — BoxKernel exists to be a drop-in replacement, so the C suite is the specification.
Running the reference suite
You need a checkout of the C reference next to this repo:
$ git clone https://github.com/termux/proot.git ../proot
$ cargo build --release
$ cd ../proot/tests
$ make test PROOT=/abs/path/to/BoxKernel/target/release/proot
Expected result (x86-64 host):
- 113 tests
ok, 0 failed - 8
skipped— tests requiring QEMU/rootfs artifacts not present - 9
xfail/xpass— upstream-expected anomalies, identical to C
A change is done only when this suite is green — the same suite the C implementation passes.
Unit tests
In-file #[cfg(test)] modules cover the pieces with real
specifications of their own:
fpath.rs—FixedPathNUL semantics,push/pop/chop,substitute_prefixbranchespath/canon.rs—compare_paths(binding-prefix matching rules)
Add tests next to the code; keep them safe and fast. cargo test
runs in CI on every push/PR.
Debugging a failing suite test
Each test is a shell or C program asserting a guest-visible behavior. Workflow:
$ cd ../proot/tests
$ ./test-0228fbe7.sh # see the failure directly
$ PROOT=/path/to/proot ./test-0228fbe7.sh
$ PROOT_VERBOSE=9 … # syscall-level trace if needed
Then bisect the pipeline: tracee/event stop → syscall/enter
translation → path/canon → write-back → syscall/exit post-
processing. The C source is the oracle for what each stage should
produce.
Performance sanity
No formal benchmark yet. Quick sanity:
$ time proot -r rootfs /bin/sh -c 'for i in $(seq 1000); do /bin/true; done'
$ time proot -r rootfs /bin/sh -c 'cat large_file >/dev/null'
I/O-bound work should be near-native; syscall storms show the ptrace cost (seccomp helps where enabled).
Compatibility with C PRoot
BoxKernel targets behavioral parity with Termux PRoot 5.1.0 — not just feature parity, but identical observable behavior: same option parsing, same errno paths, same exit statuses, same verbose output shape.
What’s identical
- The entire CLI option set except
--ashmem-memfd(below) - Guest-visible results of translated syscalls — the reference suite validates this end-to-end
- Exit status convention (last terminated program’s status)
-vverbosity levels andnote!severity model- Extension semantics including inheritance (
Kompatshared config,FakeId0per-child) - QEMU
-qcontract: runner resolved on the host,-E/-Uenv pairs emitted in C’s order,/host-rootfsbinding .proot-meta-file.*/.l2s.*helper-file formats — a rootfs written by C proot reads correctly under BoxKernel and vice versaptrace/waitemulation for guest debuggers
Known differences
| Difference | Detail | Status |
|---|---|---|
--ashmem-memfd | Android-only memfd_create-via-ashmem emulation; needs Bionic libandroid-shmem machinery | not ported |
| Internal structure | C’s (callback,config,sysnums) triples → typed Event/AnyExtension | deliberate — same dispatch order |
| Global state | C static vars → atomics/thread_local! | deliberate — same semantics |
| Memory allocation | talloc hierarchy → owned values/Rc/Box | deliberate — C ownership graph preserved |
| Build system | GNU make + libtalloc | Cargo; build.rs runs rustc for loader/ |
| Version string | proot 5.1.0 | BoxKernel 5.1.0 (binary still named proot) |
Things the Rust port does better (and why)
- No libtalloc → fully static-able; the two QEMU-runner suite
tests that fail on the C binary under
unset LD_LIBRARY_PATHpass on ours. - No
static mut, notransmute→ whole classes of memory bugs eliminated rather than audited-around. - Typed extension events → the C
intptr_t data1/data2payloads (whose meaning depended on event type) become structured enums — mismatched-payload bugs are compile errors now. src/sys.rsboundary → a C-identical errno contract is enforced by type, not by convention.
Filing parity bugs
If the Rust binary produces different guest-visible behavior than the C binary for the same command/environment, that’s a parity bug — report it with both outputs:
$ proot-c -v 2 <cmd> 2>c.log
$ proot -v 2 <cmd> 2>rust.log
$ diff -u c.log rust.log
Include the suite test name if a reference test exposes it.
Changelog
All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to the version numbering of the PRoot release it re-implements (Termux PRoot 5.1.0).
Unreleased
Added
- Complete open-source documentation system:
README.mdwith feature overview, quick start, and badges- mdBook documentation site under
docs/(user guide, CLI reference, architecture, extensions, mixed-mode/QEMU, security model, development and testing guides) doc/proot.1manual pageCONTRIBUTING.md,CODE_OF_CONDUCT.md,SECURITY.md,CHANGELOG.md- GitHub issue templates and pull-request template
docsCI workflow:cargo docplus mdBook build and GitHub Pages deployment
Changed
- Expanded crate-level and module-level rustdoc coverage.
5.1.0 — clean-room Rust rewrite of PRoot 5.1.0
Added
-
Full clean-room Rust port of Termux PRoot 5.1.0:
- User-space
chroot(-r,-R,-S) - Virtual bind mounts (
-b,-m) with symlink-deref control - QEMU user-mode mixed execution (
-q) with/host-rootfs - ABI-aware syscall translation (x86-64, x32, i386, arm, arm64, sh4
tables in
data/) - ELF parsing, interpreter (
PT_INTERP) rewriting, loader stub, and shebang (#!) interpretation - Extensions:
-0/-ifake identity,--link2symlink,-Hhidden files,-kkernel-release kompat,-pport switch,-Lsymlink-size fix,--sysvipc(shm/sem/msg + helper daemon), mountinfo rewriting - Seccomp-accelerated syscall filtering
--kill-on-exit,-w,-vhandling identical to C
- User-space
-
src/sys.rsFFI boundary: every libc/syscall call is a thin audited wrapper; the rest of the codebase contains no direct FFI. -
Freestanding
loader/sub-crate (no_std, position-independent) injected into tracees forexecvecompletion, matching the C loader’s role. -
Modern Rust toolchain: edition 2024, MSRV 1.85, rustfmt/clippy enforcement,
unsafe_op_in_unsafe_fndenied, cargo-deny policy, GitHub Actions CI, Dependabot.
Fixed (relative to naive ports)
errnois cleared onprocess_vm_*fast paths, matching the C convention — stale errno previously poisonedgetresuid-style emulations and corrupted wait-status delivery.SIGTRAPdelivery semantics: only the first SIGTRAP is consumed for ptrace option setup; subsequent ones reach the guest handler.-qrunner resolution happens against the host filesystem (matching C’sreconf.tracee == NULLpath), so the QEMU binary is found via the hostPATHrather than the guest rootfs.- Kompat extension configuration is shared across tracees
(
Rc<RefCell<…>>), matching C’sINHERIT_PARENT → sharedmodel. - Misaligned
&mut *bytes.cast::<libc::stat>()uses replaced with typed reads — a genuine alignment-UB fix. - No
static mutanywhere: event flags are atomics, the pipe-shadow table isthread_local!, netlink probing uses an atomic cache.
Validation
- Upstream C integration suite: 113 checks pass, 0 failures
(8 environment skips, 9 expected failures — identical to the C
baseline; two QEMU-runner tests actually pass better than C because
the Rust binary has no
libtallocdependency). cargo fmt --check,cargo clippy --all-targets -D warnings,cargo test,cargo deny check, debug + release builds all clean.
Manual page
The proot(1) manual page is maintained at
doc/proot.1
in roff format.
View it:
$ man ./doc/proot.1
Install it:
$ install -Dm644 doc/proot.1 ~/.local/share/man/man1/proot.1
$ man proot
The option reference on this site mirrors the man page — see Command-line reference.