preloader
post-thumb

Last Update: July 25, 2026


BYauthor-thumberic

|Loading...

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.

sh
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:

sh
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:

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

kotlin
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:

kotlin
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>:

kotlin
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:

sh
#!/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:

kotlin
packaging {
    jniLibs {
        useLegacyPackaging = true
    }
}

Quick reference

Problem
Symptom
Fix
Wrong GOOS for arm64
error=2 ENOENT on exec even though the file exists
GOOS=android GOARCH=arm64
4 KB page alignment
Play Store upload warning; crash on Android 16
-buildmode=pie (arm64 default is fine); -R 0x10000 for x86_64
AAB delivery skips extraction
nativeLibraryDir is empty after Play Store install
useLegacyPackaging=true + memfd_create fallback

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
Your email won't be published. We'll only use it to notify you of replies to your comment.
Loading comments...
Previous Article
post-thumb

Oct 03, 2021

Setting up Ingress for a Web Service in a Kubernetes Cluster with NGINX Ingress Controller

A simple tutorial that helps configure ingress for a web service inside a kubernetes cluster using NGINX Ingress Controller

Next Article
post-thumb

Jul 13, 2026

Introducing TYO Reach: A Travel Companion for Your Browser

TYO Reach is a lightweight traffic proxy for a single browser — a handy travel companion for reaching the everyday sites and apps you rely on at home when they quietly stop working abroad.

agico

We transform visions into reality. We specializes in crafting digital experiences that captivate, engage, and innovate. With a fusion of creativity and expertise, we bring your ideas to life, one pixel at a time. Let's build the future together.

Copyright ©  2026  TYO Lab · v0.0.10