In Android Native development or some cross-platform porting projects, we sometimes need to package compiled C/C++ command line tools (such as ffmpeg, iperf3, etc.) into applications and directly launch them for execution at runtime.
A long time ago, the executable file was directly placed in the assets directory. When the application started, it was copied to the application’s private storage path (such as context.filesDir), and then executed chmod +x to grant run permissions, and finally passed Runtime.getRuntime().exec() to execute.
However, since Android 10 (API 29), the system has strengthened the security mechanism, and this conventional approach has become invalid. This article will introduce the principle of this restriction and how to run native executable files in a root-free environment.
W^X (Write or Execute) limitations of SELinux
In Android 10 and above systems, if you write a binary file to a writable path such as /data/data/package_name/files and try to run it, even if it has been authorized, you will still encounter the following error:
java.io.IOException: Cannot run program "/data/user/0/com.example.app/files/mytool": error=13, Permission denied
This is because Android has strengthened the W^X (Write or Execute) restriction for ordinary non-system applications (untrusted_app) in the SELinux policy:
- For applications, any directory that is writable by and is absolutely unexecutable by.
- Any directory where executable is unable to write to.
Because the application private data directory is fully writable by the application itself, it is prohibited from executing any binaries. This restriction can effectively block the malicious behavior of applications dynamically downloading an executable binary file through the network and running it locally.
Bypass strategy: Use jniLibs automatic decompression mechanism
Since a writable directory cannot be executed, we need to find a directory that the system allows execution but that the application itself cannot directly write to.
In Android, the application’s Native Library directory just meets this feature:
- Read-only: The application does not have permission to write or modify any files directly to this directory during runtime.
- Executability: As an exclusive directory for storing system dynamic link libraries (.so), the SELinux policy explicitly grants execution permission to files in this path.
Because when the system package manager (PackageManager) decompresses the APK, it only filters out the .so library through the file suffix name and releases it to the native library directory (usually located in the /data/app/.../lib/ directory), without strictly verifying whether the file content is a legal ELF shared library.
Therefore, our bypass idea is:
- Rename the binary program that needs to be executed (such as
mytool) tolibmytool.so. - Place it in the
src/main/jniLibs/<ABI>/(such asarm64-v8a) directory of the Android project. - When the application is installed, the system will automatically decompress it and release it to the Native library directory, and automatically grant it executable permissions.
- The application locates this path at runtime and executes it as an executable file.
Implementation steps
The specific steps are demonstrated below through a simple example.
1. Prepare binary files
Write a simple C language test code main.c:
#include <stdio.h>
int main(int argc, char *argv[]) {
printf("Hello from native executable!\n");
if (argc > 1) {
printf("Received argument: %s\n", argv[1]);
}
return 0;
}
Use the Android NDK cross-compilation tool chain to compile it into an executable program for the corresponding architecture mytool.
After compilation is complete, rename the file to libmytool.so.
2. Place it in the jniLibs directory
In the directory structure of the Android module, create the corresponding directory and place the renamed file in it:
app/
└── src/
└── main/
└── jniLibs/
├── arm64-v8a/
│ └── libmytool.so
└── armeabi-v7a/
└── libmytool.so
3. Java/Kotlin locate and call
Since the decompression path of the Native library will change with each APK installation and upgrade, we must obtain the path dynamically.
import android.content.Context
import java.io.File
object NativeExecutor {
/**
* 获取可执行文件的真实绝对路径
*/
fun getExecutablePath(context: Context, binaryName: String): String? {
val libraryDir = context.applicationInfo.nativeLibraryDir
val file = File(libraryDir, "lib${binaryName}.so")
return if (file.exists()) {
file.absolutePath
} else {
null
}
}
}
Asynchronous stream reading and avoiding process blocking
When using ProcessBuilder or Runtime.getRuntime().exec() to execute an external process, if the output content of the sub-process is large and the main process does not consume it in time, the pipeline buffer will be full and the sub-process will be permanently stuck.
When actually starting the process, we need to open an independent background thread and asynchronously read the standard output (stdout) and error output (stderr) of the child process.
Here is an example of a robust Kotlin wrapper:
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStreamReader
suspend fun runNativeTool(context: Context, args: List<String>): String = withContext(Dispatchers.IO) {
val binPath = NativeExecutor.getExecutablePath(context, "mytool")
?: return@withContext "Error: Binary not found"
val command = mutableListOf<String>().apply {
add(binPath)
addAll(args)
}
try {
val process = ProcessBuilder(command).start()
val outputBuilder = StringBuilder()
val errorBuilder = StringBuilder()
// 启动异步线程或协程,分别消费标准输出和错误输出,防止管道被写满导致卡死
val outReader = BufferedReader(InputStreamReader(process.inputStream))
val errReader = BufferedReader(InputStreamReader(process.errorStream))
// 读取标准输出
var line: String?
while (outReader.readLine().also { line = it } != null) {
outputBuilder.append(line).append("\n")
}
// 读取错误输出
var errLine: String?
while (errReader.readLine().also { errLine = it } != null) {
errorBuilder.append(errLine).append("\n")
}
val exitCode = process.waitFor()
if (exitCode == 0) {
outputBuilder.toString()
} else {
"Exit Code: $exitCode\nError: $errorBuilder"
}
} catch (e: Exception) {
e.printStackTrace()
"Exception: ${e.message}"
}
}
Google Play policy compliance tips
According to Google Play Console’s Dynamic Code Execution (DCE - Dynamic Code Execution) policy, we need to ensure safe and compliant execution:
- Compliance situation: If your binary program (
libmytool.so) is packaged with the APK installation package and has been officially signed and released by the app store, this solution is fully compliant with the policy and can be put on the shelves normally. - Violation: If your app downloads an executable binary file from a third-party server and dynamically decompresses it to the Native Library path for execution while your app is running, this will be judged as malware by Google Play and will result in removal.
Summary
Taking advantage of the feature of decompressing jniLibs during the Android system installation and granting execution permissions, we can disguise the binary executable file into the .so format, thereby elegantly bypassing the W^X security restrictions of Android 10+. Coupled with asynchronous stream consumption during Process processing, native command line tools can be called stably in a root-free environment.