Skip to content

Instantly share code, notes, and snippets.

@Jobians
Created August 3, 2026 07:22
Show Gist options
  • Select an option

  • Save Jobians/001995df6a1ce032a28f169de31af923 to your computer and use it in GitHub Desktop.

Select an option

Save Jobians/001995df6a1ce032a28f169de31af923 to your computer and use it in GitHub Desktop.

Running a real Linux userland (Alpine via proot) inside a standalone Android app

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.

End result

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.

Why this is hard

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.

Step 1 — Get the binaries

You need, minimum:

  • proot
  • proot'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 proot

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

Step 2 — Place everything in jniLibs/

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

Use cp -L if any source is a symlink (you want the real file copied, not a dangling link once it leaves Termux's filesystem).

Step 3 — Force native lib extraction (uncompressed on-disk files)

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
        }
    }
}

Step 4 — Fix dynamic linker names with patchelf

Renaming libtalloc.so.2libtalloc.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.so

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

Step 5 — The critical piece: PROOT_LOADER

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"

Step 6 — Extract a rootfs

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 rootfs

Gotcha: AAPT auto-decompresses .gz-suffixed assets at build time and drops the .gz extension (alpine-rootfs.tar.gzalpine-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.

Step 7 — Extraction code, preserving exec bits

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 */ }
}

Step 8 — The actual proot invocation

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()})" }
}

Debugging checklist, in the order these actually surface

  1. Cannot run program: No such file or directory — native libs still zip-compressed in the APK; add useLegacyPackaging = true, clean rebuild.
  2. CANNOT LINK EXECUTABLE: library "libX.so.N" not found — rename + patchelf --replace-needed to strip version suffixes; walk the full dependency tree.
  3. Permission denied on direct exec from filesDir — Android's W^X policy; this is why the rootfs binaries themselves are never exec'd directly, only proot (from nativeLibraryDir) touches them.
  4. '/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).
  5. execve("/bin/sh"): Permission denied even with everything else correct — missing PROOT_LOADER; this is the non-obvious final piece (Step 5).

Known limitations

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

References / prior art

  • Termux — the origin of most of these binaries and the PROOT_LOADER mechanism.
  • 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment