Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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
LanguageC99 + talloc + GNU makeRust 2024, libc-only, Cargo
unsafethe whole languageone audited module (src/sys.rs) + tiny documented islands
Global statestatic variables, static mut-styleatomics + thread_local! only
Extensions(callback, config, sysnums) triples, untyped payloadsAnyExtension enum + typed Event variants
Syscall tablegenerated sysnums.hgenerated sysnum.rs via build.rs + data/sysnums-*.txt
Test contracttests/ suitethe 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 execve emulation: ELF parsing, PT_INTERP rewriting, shebang (#!) interpretation, AT_* auxv synthesis
  • ABI-aware syscall translation (x86-64, x32, i386, ARM, AArch64, SH4 syscall tables)
  • Extensions: -0/-i fake identity, --link2symlink, -H, -k kompat, -p port switch, -L link-size fix, --sysvipc, mountinfo rewriting
  • Seccomp-accelerated filtering; --kill-on-exit; verbose levels -v

Reading this book

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.toml pins stable and pulls in rustfmt + clippy automatically. Edition 2024 is in use.
  • Kernel: any reasonably modern Linux. process_vm_* (≥ 3.2) is preferred for tracee memory access; the PTRACE_PEEK/POKE fallback covers older kernels. Seccomp acceleration needs seccomp filtering support (≥ 3.5 realistically, with SECCOMP_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 dir plus a recommended set of binds (/etc/host*, /etc/passwd, /etc/resolv.conf, /dev, /sys, /proc, /tmp, /run, $HOME, and the rootfs itself).
  • -S dir ≈ -R plus -0 (fake root), with a smaller bind list chosen so package managers cannot scribble on host files. Use -S to 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.

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

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 (or PROOT_VERBOSE=N): -1 silent-but-fatal, 0 errors, 1 key decisions, up to 9 full syscall tracing.
  • --kill-on-exit: kill all tracees when the main command exits — without it, orphaned/detached processes keep proot alive waiting.
  • -w dir: initial working directory inside the guest.

Environment variables

VariableEffect
PROOT_VERBOSEsame as -v
PROOT_TMP_DIR / TMPDIRdirectory for internal temp files (default /tmp)
PROOT_NO_SECCOMPdisable the seccomp accelerator (debugging)
PROOT_ASSUME_NEW_SECCOMPassume kernel ≥ 4.8 seccomp semantics
PROOT_NO_MOUNTINFOdisable /proc/self/mountinfo rewriting
PROOT_IGNORE_MISSING_BINDINGSdon’t fail if a -b source is missing
PROOT_DONT_POLLUTE_ROOTFSstore link2symlink meta-files outside the rootfs
PROOT_LOADERoverride the injected execve loader stub
PROOT_USE_LOADER_FOR_QEMUroute QEMU execs through the loader stub
PROOT_FORCE_FOREIGN_BINARYforce foreign-ABI handling (testing)
PROOT_FORCE_KOMPATforce-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 /proc means host /proc shows up at guest /proc.
  • execve of a script re-enters translation for its shebang interpreter — -b applies there too.
  • Files written into a bound directory land on the host path. There is no copy-on-write layer.
  • sudo, ping, real mount(2), setuid binaries: still bound by host privileges. -0 only 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.

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 support openat-family syscalls.
  • -l stores emulated hard links as .l2s.*/.proot.l2s.* symlinks.
  • -0/-i record fake ownership/modes in .proot-meta-file.*.
  • -H hides 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 execve is 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-rootfs inside 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_PATH and 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 guest ld.so — the -E/‑U argument 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:

  1. ELF header parse → native vs foreign ABI (execve/elf.rs)
  2. #! shebang → recurse with the interpreter (execve/shebang.rs)
  3. PT_INTERP → the dynamic linker is rewritten to a translated path or QEMU’s ld.so path list (execve/ldso.rs)
  4. 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. -r only rewrites how paths resolve — a guest that opens ../../etc/shadow still lands wherever the host puts it (bound paths notwithstanding, every host file reachable to the user is reachable to the guest).
  • -0 fakes 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 ptrace themselves (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_* with PTRACE_PEEK/POKE fallback), and all kernel-boundary calls are funneled through src/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)

SiteWhat it isWhy it must exist
src/sys.rs (~98 blocks)thin libc wrappersthe FFI boundary; the only file allowed to call libc
src/execve/elf.rs (8)as32/as64 union accessorsELF-class-validated reads on all-POD C-layout headers
src/tracee/event.rs (3)siginfo_t/utsname field readskernel-provided pointers in signal handlers
src/syscall/netlink.rs (1)CStr::from_ptrbounded interface-name buffer
loader/ (10)naked fns + asm! syscall stubsfreestanding no_std code injected into tracees

Enforced invariants:

  • No static mut. Event flags are Atomic*; the pipe-shadow table is thread_local!; netlink probe caching is AtomicI32.
  • No transmute. POD serialization goes through sys::as_bytes/as_bytes_mut; cross-field reads use offset_of!.
  • unsafe_op_in_unsafe_fn is denied crate-wide — even inside unsafe fn, each operation needs its own explicit block.
  • Every unsafe block carries a // SAFETY: invariant comment.
  • ptrace/process_vm_* take usize words, 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

ModulePortsResponsibility
src/main.rsmain()Entry; --shm-helper dispatch; atexit cleanup
src/cli/cli.cOption 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.rspath/binding.c, path/path.c helpersFixedPath: 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.cBPF program building, seccomp filter installation, SIGSYS trapping
src/sysnum.rs + data/sysnums-*.txtsyscall/sysnums-*.list + genPer-ABI syscall-number tables, code-generated at build time
src/arch.rsarch.hHost word size, reg layouts
src/sys.rsall of libcThe FFI boundary — the only module allowed to call libc directly
src/note.rscli/note.cnote!/verbose! diagnostics with severity levels
src/util.rsmiscSmall shared helpers
loader/loader/no_std position-independent stub mapped into tracees to finish execve

A syscall’s journey

  1. Stop. tracee/event.rs runs waitpid(-1, …, __WALL) in a loop. On each PTRACE_EVENT_STOP/syscall-stop/SIGTRAP it looks up the Tracee in the registry and dispatches on stop reason. The first SIGTRAP is consumed for ptrace option setup (deliver_sigtrap semantics — later SIGTRAPs re-inject so guest handlers see them).

  2. Enter. syscall/enter.rs::translate_sysenter reads the syscall number + args via tracee/reg.rs (PTRACE_GETREGS). The sysnum is decoded ABI-aware (sysnum.rs + data/sysnums-*.txt for x86_64/x32/i386/arm/arm64/sh4). Extensions get SysEnterStart, then the per-syscall translator runs; extensions get SysEnterEnd.

  3. Path translation. Syscalls carrying paths go through path/canon.rs (guest→host canonicalization honoring bindings, cwd, and /proc/<pid>/fd), then path/binding.rs picks a host substitution, path/glue.rs handles sub-reconfiguration, and TranslatedPath/SymlinkDeref/HostPath/GuestPath events let extensions further rewrite. Result is written into tracee memory via tracee/mem.rs (process_vm_writev → PTRACE_POKEDATA fallback) and the register is repointed.

  4. Emulation or pass-through. Some syscalls are answered entirely in the tracer (getresuid under -0, uname under -k, all SysV IPC under --sysvipc, netlink dumps, ptrace itself for nested tracing). Others proceed with rewritten args. socket.rs/netlink.rs/pipe_shadow.rs/rlimit.rs handle the special families; chain.rs handles “one syscall, several guest semantics” cases.

  5. Exit. syscall/exit.rs::translate_sysexit reads the result register, post-processes (fake_id0 pokes fabricated uid/gid buffers, link2symlink fabricates st_nlink, kompat adjusts uname fields, mountinfo rewrites read buffers), then writes the final guest-visible result back.

  6. Special stops. SIGSYS (from our seccomp filter) routes to handle_sigsys; PTRACE_EVENT_EXEC enters execve/enter.rs; EXEC_EXIT unpoisons via execve/exit.rs; clone/fork stops drive inherit_extensions and the tracee registry.

execve: the hardest syscall

execve gets its own subsystem because it replaces the tracee:

  • execve/enter.rs parses the new ELF, decides native vs QEMU, computes load bias, maps the loader/ stub into tracee memory, and plants a SIGTRAP that fires when the kernel finishes execve — because ptrace loses control across the exec boundary.
  • execve/elf.rs is a self-contained ELF32/ELF64 parser (the only other place unsafe legitimately lives — union accessors for C-layout headers).
  • execve/ldso.rs rewrites PT_INTERP and assembles LD_LIBRARY_PATH/ld.so arguments; shebang.rs expands #!; auxv.rs/aoxp.rs rebuild 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, 0 transmute, 0 libc::syscall outside sys
  • ~98 unsafe blocks, 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:

  • 0 from InheritParent → shared config (e.g. Kompat uses one Rc<RefCell<Config>> for all tracees — a child’s setdomainname is visible to siblings, matching C)
  • > 0 → per-child cloned config (FakeId0 deep-clones)
  • < 0 → not inherited

Built-in extensions

ExtensionCLIWhat it does
FakeId0-0, -i u:gFakes uid/gid/cap answers; intercepts chown, chmod, mknod, setxid, faccessat, statx; persists metadata in .proot-meta-file.*; emulates chroot success
Link2Symlink-llink(2) → creates .l2s.*/.proot.l2s.* symlinks; fakes st_nlink; handles rename/unlink of emulated links (largest extension — boxed in the enum)
HiddenFiles-HFilters .proot* helper names out of getdents results
Kompat-kRewrites uname/setdomainname-adjacent state; emulates missing newer-kernel syscalls; config is shared across tracees
PortSwitch-pbind/connect to localhost ports < 1024 get +1024
FixSymlinkSize-LCorrects st_size/st_ino/nlink for emulated links (Bionic)
Mountinfoalways onRewrites /proc/self/mountinfo reads so bindings look like real mounts
Sysvipc--sysvipcshmget/shmat/shmdt/shmctl, sem*, msg* implemented in-tracer; --shm-helper daemon holds backing fds

Writing an extension

  1. Create src/extension/my_ext.rs with a struct holding per-tracee (or shared) state.
  2. Add a variant to AnyExtension and implement notify — pattern- match the Events you need, ignore the rest.
  3. Implement clone_for_child deciding shared vs per-child state.
  4. Wire CLI: parse an option in cli/mod.rs and call extension::initialize_extension(tracee, AnyExtension::MyExt(..), arg).
  5. For syscall filtering, use the StatxSyscall/SigsysOcc-style events or the filtered_sysnums dispatch 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/PrintUsage events feed --help/diagnostics.
  • PROOT_FORCE_KOMPAT, PROOT_DONT_POLLUTE_ROOTFS, PROOT_NO_MOUNTINFO toggle specific extensions for testing.

Development guide

Toolchain

  • Rust 1.85+ (MSRV), edition 2024 — rust-toolchain.toml pins stable + rustfmt + clippy.
  • Single crate, single dependency (libc). The loader/ sub-crate is built by build.rs with a direct rustc --edition=2024 invocation (it is no_std/freestanding, not a Cargo workspace member).
  • Syscall-number tables are generated by build.rs from data/sysnums-*.txt into OUT_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:

  1. Find the C counterpart — the reference checkout is the spec.
  2. Keep C-identical errno semantics: innermost -errno returns, sys::clear_errno() before calls judged by errno == 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).
  3. Keep C’s verdicts for weird cases — they are almost always load-bearing (e.g. deliver_sigtrap, INHERIT_PARENT → shared, reconf.tracee == NULL runner resolution).

The unsafe policy

Where unsafe may live, in descending preference:

  1. src/sys.rs — the only home for libc calls. New kernel boundary? Add a wrapper, // SAFETY: note, keep C status/errno return.
  2. Documented islands — execve/elf.rs union accessors, tracee/event.rs kernel-struct reads, syscall/netlink.rs CStr.
  3. 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

  1. Find its sysnum entry in data/sysnums-<abi>.txt (regen happens at build time).
  2. Implement translate_xxx_enter/_exit in syscall/enter.rs or exit.rs (or a focused module like socket.rs).
  3. Route args via tracee::mem (peek/poke primitives); translate paths via path::canon.
  4. 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_VERBOSE equivalent.
  • 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

LayerWhatWhereCommand
UnitPure logic (path canonicalizer, FixedPath, compare_paths)#[cfg(test)] in src/cargo test
SmokeBinary boots, binds, fakes rootCI smoke test stepproot -0 id -u, proot -b …
Reference suiteThe C implementation’s full integration suite run against the Rust binaryproot/tests/ in the C checkoutmake -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 — FixedPath NUL semantics, push/pop/chop, substitute_prefix branches
  • path/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)
  • -v verbosity levels and note! severity model
  • Extension semantics including inheritance (Kompat shared config, FakeId0 per-child)
  • QEMU -q contract: runner resolved on the host, -E/-U env pairs emitted in C’s order, /host-rootfs binding
  • .proot-meta-file.*/.l2s.* helper-file formats — a rootfs written by C proot reads correctly under BoxKernel and vice versa
  • ptrace/wait emulation for guest debuggers

Known differences

DifferenceDetailStatus
--ashmem-memfdAndroid-only memfd_create-via-ashmem emulation; needs Bionic libandroid-shmem machinerynot ported
Internal structureC’s (callback,config,sysnums) triples → typed Event/AnyExtensiondeliberate — same dispatch order
Global stateC static vars → atomics/thread_local!deliberate — same semantics
Memory allocationtalloc hierarchy → owned values/Rc/Boxdeliberate — C ownership graph preserved
Build systemGNU make + libtallocCargo; build.rs runs rustc for loader/
Version stringproot 5.1.0BoxKernel 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_PATH pass on ours.
  • No static mut, no transmute → whole classes of memory bugs eliminated rather than audited-around.
  • Typed extension events → the C intptr_t data1/data2 payloads (whose meaning depended on event type) become structured enums — mismatched-payload bugs are compile errors now.
  • src/sys.rs boundary → 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.md with 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.1 manual page
    • CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, CHANGELOG.md
    • GitHub issue templates and pull-request template
    • docs CI workflow: cargo doc plus 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/-i fake identity, --link2symlink, -H hidden files, -k kernel-release kompat, -p port switch, -L symlink-size fix, --sysvipc (shm/sem/msg + helper daemon), mountinfo rewriting
    • Seccomp-accelerated syscall filtering
    • --kill-on-exit, -w, -v handling identical to C
  • src/sys.rs FFI 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 for execve completion, matching the C loader’s role.

  • Modern Rust toolchain: edition 2024, MSRV 1.85, rustfmt/clippy enforcement, unsafe_op_in_unsafe_fn denied, cargo-deny policy, GitHub Actions CI, Dependabot.

Fixed (relative to naive ports)

  • errno is cleared on process_vm_* fast paths, matching the C convention — stale errno previously poisoned getresuid-style emulations and corrupted wait-status delivery.
  • SIGTRAP delivery semantics: only the first SIGTRAP is consumed for ptrace option setup; subsequent ones reach the guest handler.
  • -q runner resolution happens against the host filesystem (matching C’s reconf.tracee == NULL path), so the QEMU binary is found via the host PATH rather than the guest rootfs.
  • Kompat extension configuration is shared across tracees (Rc<RefCell<…>>), matching C’s INHERIT_PARENT → shared model.
  • Misaligned &mut *bytes.cast::<libc::stat>() uses replaced with typed reads — a genuine alignment-UB fix.
  • No static mut anywhere: event flags are atomics, the pipe-shadow table is thread_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 libtalloc dependency).
  • 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.