
Last Update: July 25, 2026
BY
eric
Keywords
We're building an Android VPN app that needs a Go binary — a packet-forwarding proxy that runs as a subprocess, owns the TUN file descriptor, and handles all the network I/O. Simple enough in theory: cross-compile Go for arm64, pack it into the APK, exec it at runtime.
In practice we hit three separate problems, each invisible until it wasn't. This post documents all three: what the failure looked like, why it happens, and exactly what to change.
Trap 1: GOOS=linux produces a binary Android can't run
The first sign of trouble is a logcat line that reads something like:
java.io.IOException: Cannot run program "/path/to/binary": error=2, No such file or directory
The binary is there. The path is correct. The file exists. Android still says it doesn't.
The reason is the ELF interpreter line. Every dynamically-linked ELF binary on Linux carries a PT_INTERP segment that names the dynamic linker the OS should use to start the program. On a Linux server or desktop, that path is typically:
/lib/ld-linux-aarch64.so.1
That path doesn't exist on Android. Android ships its own linker at:
/system/bin/linker64
When the OS tries to exec your binary, it reads the interpreter path from the ELF header. If that path doesn't resolve, execve returns ENOENT — "no such file or directory" — even though the binary itself is sitting right there. The error is about the linker, not the binary.
The fix: use GOOS=android, not GOOS=linux, when cross-compiling for arm64.
CGO_ENABLED=0 GOOS=android GOARCH=arm64 \
go build -trimpath -buildmode=pie \
-ldflags="-s -w" \
-o libreachproxy.so .
With GOOS=android, Go produces an ELF with /system/bin/linker64 in the interpreter slot. Android finds it, loads it, and your binary starts.
You can verify before deploying:
readelf -l your_binary | grep interpreter
# Good: [Requesting program interpreter: /system/bin/linker64]
# Bad: [Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
What about x86_64?
GOOS=android GOARCH=amd64 doesn't work without CGO external linking — which requires the Android NDK. If you don't have the NDK in your build environment, you're stuck. For x86_64 (emulators only; no real Android phone ships x86_64), GOOS=linux is acceptable — the emulator kernel is lenient enough. Just don't use it for arm64 on real devices.
Trap 2: Android 16 requires 16 KB page alignment
Android 16 — shipping on Samsung Galaxy S25 and other devices with newer Arm chips — increased the memory page size from 4 KB to 16 KB. If your native binary isn't aligned to 16 KB page boundaries, Android 16 won't load it.
Google Play enforces this at upload time too. You'll see a warning (soon an error) in the Play Console if any .so in your APK or AAB fails the alignment check.
For arm64: Go's PIE binaries built with GOOS=android GOARCH=arm64 default to 64 KB alignment, which already satisfies the 16 KB requirement. No extra flag needed.
For x86_64: Go's default alignment is 4 KB. You need to force 64 KB (which also satisfies 16 KB) via the linker flag -R 0x10000:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -buildmode=pie \
-ldflags="-s -w -R 0x10000" \
-o libreachproxy_x86_64.so .
The -R 0x10000 flag sets the ELF segment alignment to 65536 bytes (64 KB). Play Store's checker accepts anything ≥ 16 KB.
Why -buildmode=pie?
Android requires all natively-executed code to be position-independent. Without -buildmode=pie, Go produces an ET_EXEC (fixed-address) binary, which Android won't exec. With -buildmode=pie, you get an ET_DYN (position-independent) executable that Android's linker is happy to load anywhere in the address space.
Trap 3: Play Store AAB delivery may not extract your .so to disk
Here's the sneaky one.
The standard approach for shipping a native binary in an APK is to put it in jniLibs/<abi>/, rename it with a .so extension (so the build system includes it), and read its path from applicationInfo.nativeLibraryDir at runtime.
val binary = "${applicationInfo.nativeLibraryDir}/libreachproxy.so"
ProcessBuilder(binary, ...).start()
This works fine when you sideload the APK directly. On older Android versions it also works for Play Store installs — the system extracts .so files to nativeLibraryDir during installation.
On Android 16 with Play Store's AAB (App Bundle) delivery, the system may skip that extraction entirely. The .so is never written to nativeLibraryDir. Your File(binary).exists() check passes the sanity check but the file is empty, or the path simply doesn't exist at all. The exec fails silently.
First fix: useLegacyPackaging = true in your build.gradle.kts. This tells the build system to store the .so uncompressed in the APK, so Android can extract it on install:
packaging {
jniLibs {
useLegacyPackaging = true
}
}
This restores the old extraction behaviour for direct APK installs.
Second fix: memfd_create fallback for Play Store AAB installs where extraction is still skipped. Bundle the arm64 binary in assets/ and load it into an anonymous memory file using Os.memfd_create. Execute it via /proc/self/fd/<n>:
private fun ensureBinary(): String {
val nativePath = "${applicationInfo.nativeLibraryDir}/libreachproxy.so"
if (File(nativePath).exists()) return nativePath
// Play Store AAB on Android 16: nativeLibraryDir wasn't populated.
// Load from assets into a memfd and exec via /proc/self/fd/<n>.
val bytes = assets.open("libreachproxy").use { it.readBytes() }
val fd = Os.memfd_create("libreachproxy", 0)
var offset = 0
while (offset < bytes.size) {
offset += Os.write(fd, bytes, offset, bytes.size - offset)
}
val fdInt = FileDescriptor::class.java
.getDeclaredField("descriptor")
.apply { isAccessible = true }
.getInt(fd)
return "/proc/${Os.getpid()}/fd/$fdInt"
}
memfd_create creates an anonymous file in memory — no filesystem, no SELinux exec restriction. The /proc/self/fd/<n> path is a valid executable path that the kernel accepts for execve. The fd reference is kept alive for the lifetime of the service so the kernel doesn't reclaim the memory.
Os.memfd_create requires API level 30 (Android 11). If you still support API 26–29, you'll need a fallback for that too (writing to codeCacheDir and calling chmod 700, accepting that Samsung SELinux may still block it on some devices).
Putting it together
A minimal build script that avoids all three traps:
#!/usr/bin/env bash
set -euo pipefail
OUT="app/src/main/jniLibs"
mkdir -p "$OUT/arm64-v8a" "$OUT/x86_64"
# arm64: GOOS=android for the correct ELF interpreter.
# 64KB alignment is the default — satisfies Android 16's 16KB requirement.
CGO_ENABLED=0 GOOS=android GOARCH=arm64 \
go build -trimpath -buildmode=pie -ldflags="-s -w" \
-o "$OUT/arm64-v8a/libreachproxy.so" .
# x86_64: GOOS=linux (android/amd64 requires CGO external linking without NDK).
# -R 0x10000 forces 64KB alignment to satisfy Play Store's 16KB check.
# x86_64 is emulator-only — no real device uses it.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -buildmode=pie -ldflags="-s -w -R 0x10000" \
-o "$OUT/x86_64/libreachproxy.so" .
# Also copy arm64 binary to assets/ for the memfd fallback path.
cp "$OUT/arm64-v8a/libreachproxy.so" app/src/main/assets/libreachproxy
And in build.gradle.kts:
packaging {
jniLibs {
useLegacyPackaging = true
}
}
Quick reference
None of these are obvious until you've hit them. The GOOS=linux trap is particularly nasty because the binary runs perfectly in your CI and on the emulator — it only fails on a real device with a strict kernel, which is usually the last place you test.





Comments (0)
Leave a Comment