A working, no-root, no-Termux-dependency recipe for chrooting into a Linux rootfs from your own Android app. This documents every wall hit along the way and the actual fix for each, since most of the failure modes here are non-obvious and under-documented.
hello from alpine
Linux localhost 4.19.157-perf+ #1 SMP PREEMPT ... aarch64 Linux
...printed from inside a proot'd Alpine chroot, launched via ProcessBuilder
from a plain Kotlin Activity.
Android enforces W^X (write XOR execute) on app-private storage. Since Android 10,
nothing under filesDir/cacheDir can be execve()'d, regardless of file
permission bits — see Google's tracking issue.
The only location Android grants execute permission on is nativeLibraryDir,
populated at install time from jniLibs/.
This means: your rootfs's binaries (busybox, sh, etc.) can't be executed directly
from wherever you extract them. proot exists specifically to solve chroot-without-root,
but proot itself is a binary that needs to be exec'd — so it has to live in
nativeLibraryDir too, and it needs a specific companion binary (the loader,
see below) to bring up guest binaries without hitting the same wall itself.
You need, minimum:
prootproot's loader (critical, see Step 4 — this was the actual missing piece)libtalloc.so(proot's only real runtime dependency)libandroid-shmem.so(proot's shared-memory shim for Android)
Easiest source: pull straight from Termux's package files if you have Termux installed on the same device.
pkg install prootLocate everything:
which proot
# /data/data/com.termux/files/usr/bin/proot
find /data/data/com.termux/files/usr -iname "*talloc*"
find /data/data/com.termux/files/usr -iname "*shmem*"
ls /data/data/com.termux/files/usr/libexec/proot/
# loader loader32(No Termux available? Pull the equivalent .deb packages directly from
https://packages-cf.termux.dev/apt/termux-main/pool/main/, extract with
ar x + tar xf.)
Android only auto-extracts lib*.so files from jniLibs/<ABI>/ into the
exec-permitted nativeLibraryDir. So every binary — even ones that aren't
technically shared libraries — gets renamed to fit that pattern:
cd app/src/main/jniLibs/arm64-v8a
cp $(which proot) libproot.so
cp /data/data/com.termux/files/usr/lib/libtalloc.so.2 libtalloc.so
cp /data/data/com.termux/files/usr/lib/libandroid-shmem.so libandroid-shmem.so
cp /data/data/com.termux/files/usr/libexec/proot/loader libproot-loader.soUse cp -L if any source is a symlink (you want the real file copied, not a
dangling link once it leaves Termux's filesystem).
Modern AGP defaults to keeping native libs compressed inside the APK
(extractNativeLibs=false), which breaks ProcessBuilder/execve even though
dlopen()-based usage would be fine. Force real on-disk extraction:
// app/build.gradle.kts
android {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}Renaming libtalloc.so.2 → libtalloc.so doesn't update what libproot.so
has hardcoded as its expected dependency name (DT_NEEDED entry). Fix with
patchelf (pkg install patchelf in Termux):
patchelf --replace-needed libtalloc.so.2 libtalloc.so libproot.so
patchelf --print-needed libproot.soWalk the dependency tree — repeat for every non-system .so referenced
(libc.so, liblog.so, libdl.so, etc. are always present on-device and
don't need bundling):
libproot.so → libtalloc.so, libandroid-shmem.so, libc.so
libandroid-shmem.so → liblog.so, libc.so (both system libs — done)
Note: libproot-loader.so will show:
patchelf: cannot find section '.dynamic'. The input file is most likely statically linked
This is expected and fine — the loader is intentionally statically linked (it has to run standalone, before any dynamic linking context exists inside the target rootfs). Nothing to patch here.
This was the actual blocker after everything else was correctly bundled and permissioned. Without it, you'll see:
proot error: execve("/bin/sh"): Permission denied
proot info: possible causes:
*** the program is a script but its interpreter (eg. /bin/sh) was not found;
*** the program is an ELF but its interpreter (eg. ld-linux.so)...
proot doesn't call execve() on guest binaries directly — it uses ptrace to
inject and run a separate loader binary first, and the loader (running
from the one location Android grants exec permission on) brings up the guest
binary through a different mechanism than a raw kernel execve. Point proot
at it via an environment variable:
env["PROOT_LOADER"] = "$nativeLibDir/libproot-loader.so"Get a minimal rootfs (Alpine is small, ~4 MB compressed, good for testing).
Easiest source if proot-distro is available in Termux:
pkg install proot-distro
proot-distro install alpine
cd /data/data/com.termux/files/usr/var/lib/proot-distro/containers/alpine
tar czf app/src/main/assets/alpine-rootfs.tar.gz rootfsGotcha: AAPT auto-decompresses .gz-suffixed assets at build time and
drops the .gz extension (alpine-rootfs.tar.gz → alpine-rootfs.tar inside
the built APK). Either read the asset as alpine-rootfs.tar with no
GZIPInputStream wrapper (simplest — this is AAPT doing free work for you),
or explicitly opt out with android.androidResources.noCompress += "gz" if
you need to preserve the exact original file.
FileOutputStream + copyTo does not preserve tar entry file modes —
extracted binaries land on disk with -rw------- unless you set the bit
explicitly:
FileOutputStream(outFile).use { output -> tarStream.copyTo(output) }
val mode = entry.mode
if (mode and 0b001_001_001 != 0) outFile.setExecutable(true, false)Handle symlinks explicitly too (Alpine's /bin/sh -> /bin/busybox etc.
depends on this):
entry.isSymbolicLink -> {
outFile.parentFile?.mkdirs()
try {
Files.createSymbolicLink(outFile.toPath(), Paths.get(entry.linkName))
} catch (_: Exception) { /* log rather than crash */ }
}private fun runInProot(rootfsDir: File, command: String): String {
val nativeLibDir = applicationInfo.nativeLibraryDir
val prootPath = "$nativeLibDir/libproot.so"
val loaderPath = "$nativeLibDir/libproot-loader.so"
val tmpDir = File(filesDir, "tmp").apply { mkdirs() }
val processBuilder = ProcessBuilder(
prootPath,
"--rootfs=${rootfsDir.absolutePath}",
"--bind=/dev", "--bind=/proc", "--bind=/sys",
"-0", // fake root (uid 0) inside the chroot
"-w", "/root",
"/bin/sh", "-c", command
)
processBuilder.redirectErrorStream(true)
val env = processBuilder.environment()
env["LD_LIBRARY_PATH"] = "$filesDir:$nativeLibDir"
env["PROOT_TMP_DIR"] = tmpDir.absolutePath
env["PROOT_LOADER"] = loaderPath
env["HOME"] = "/root"
env["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
val process = processBuilder.start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
return output.ifBlank { "No output (exit code: ${process.exitValue()})" }
}Cannot run program: No such file or directory— native libs still zip-compressed in the APK; adduseLegacyPackaging = true, clean rebuild.CANNOT LINK EXECUTABLE: library "libX.so.N" not found— rename +patchelf --replace-neededto strip version suffixes; walk the full dependency tree.Permission deniedon direct exec fromfilesDir— Android's W^X policy; this is why the rootfs binaries themselves are never exec'd directly, only proot (fromnativeLibraryDir) touches them.'/bin/sh' is not executable'/execve(...): No such file or directory— usually broken extraction: missing exec bit or failed symlink creation; audit both explicitly (see Step 7).execve("/bin/sh"): Permission deniedeven with everything else correct — missingPROOT_LOADER; this is the non-obvious final piece (Step 5).
- Some OEM kernels restrict
ptrace(which proot depends on) via SELinux — test on real hardware, behavior varies by manufacturer/Android version. - Licensing: proot and talloc are GPL/LGPL. Redistributing them inside a closed-source app may carry obligations (source availability, attribution) depending on distribution model — check before shipping.
- Performance is meaningfully worse than a real chroot; every syscall is intercepted via ptrace.
- Termux — the origin of most of
these binaries and the
PROOT_LOADERmechanism. - proot-distro — prebuilt rootfs tarballs for many distros.
- AnLinux — a popular app in this space; worth noting it does not solve app-private exec restrictions itself — it requires Termux to already be installed and only scripts what runs inside it.