在 Android 的 Native 开发或一些跨平台移植项目里,我们有时需要把编译好的 C/C++ 命令行工具(比如 ffmpeg、iperf3 等)打包进应用,并在运行时直接拉起执行。
在很久以前直接把可执行文件放在 assets 目录下,应用启动时将其拷贝至应用的私有存储路径(例如 context.filesDir),然后执行 chmod +x 赋予运行权限,最后通过 Runtime.getRuntime().exec() 即可执行。
但是自从 Android 10(API 29)开始,系统强化了安全机制,这种常规的做法已经失效。本文将介绍该限制的原理以及在免 Root 环境下如何实现运行 native 可执行文件。
SELinux 的 W^X (Write or Execute) 限制
在 Android 10 及以上系统,如果将二进制文件写入 /data/data/包名/files 等可写路径并尝试运行时,哪怕已经赋权,依然会遭遇以下报错:
java.io.IOException: Cannot run program "/data/user/0/com.example.app/files/mytool": error=13, Permission denied
这是因为 Android 在 SELinux 策略中对普通非系统应用(untrusted_app)强化了 W^X(Write or Execute) 限制:
- 凡是对于应用而言可写的目录,就绝对不可执行。
- 凡是可执行的目录,应用就不可写。
因为应用私有数据目录对应用自身而言是完全可写的,因此被禁止执行任何二进制文件。该限制能有效阻断应用通过网络动态下载一段可执行二进制文件并在本地运行的恶意行为。
绕过策略:利用 jniLibs 自动解压机制
既然可写目录无法执行,我们就需要找一个系统允许执行、但应用自身无法直接写入的目录。
在 Android 中,应用的 Native Library 目录 刚好满足这一特性:
- 只读性:应用在运行期无权向该目录直接写入或修改任何文件。
- 可执行性:作为存放系统动态链接库(.so)的专属目录,SELinux 策略显式赋予了该路径下文件的执行权限。
由于系统包管理器(PackageManager)在解压 APK 时,仅仅通过文件后缀名过滤出 .so 库释放到 native 库目录(通常位于 /data/app/.../lib/ 目录下),而不会去严格校验文件内容是否是合法的 ELF 共享库。
因此,我们的绕过思路是:
- 将需要执行的二进制程序(如
mytool)重命名为libmytool.so。 - 将其放置在 Android 项目的
src/main/jniLibs/<ABI>/(如arm64-v8a)目录下。 - 应用安装时,系统会将其自动解压释放到 Native 库目录下,并自动赋予其可执行权限。
- 应用在运行时定位该路径并将其作为可执行文件执行。
实现步骤
下面通过一个简单的例子演示具体步骤。
1. 准备二进制文件
编写一个简单的 C 语言测试代码 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;
}
使用 Android NDK 交叉编译工具链将其编译为对应架构的可执行程序 mytool。
编译完成后,将该文件重命名为 libmytool.so。
2. 放置到 jniLibs 目录
在 Android 模块的目录结构中,创建对应的目录并将重命名后的文件放入其中:
app/
└── src/
└── main/
└── jniLibs/
├── arm64-v8a/
│ └── libmytool.so
└── armeabi-v7a/
└── libmytool.so
3. Java/Kotlin 定位并调用
由于 Native 库的解压缩路径会随每次 APK 安装、升级而变化,因此我们必须动态获取路径。
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
}
}
}
异步流读取与避免进程阻塞
在使用 ProcessBuilder 或 Runtime.getRuntime().exec() 执行外部进程时,如果子进程的输出内容较多,而主进程没有及时消费,会导致管道缓冲区爆满进而使得子进程永久卡死。
在实际拉起进程时,我们需要开辟独立的后台线程,异步读取子进程的标准输出(stdout)和错误输出(stderr)。
以下是一个健壮的 Kotlin 封装示例:
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 政策合规提示
根据 Google Play Console 的动态代码执行 (DCE - Dynamic Code Execution) 政策,我们需要确保执行行为的安全合规:
- 合规的情况:如果你的二进制程序(
libmytool.so)是随 APK 安装包一同打包,并经过应用商店官方签名发布的,此方案完全符合政策,可以正常上架。 - 违规的情况:如果你的应用在运行期间,从第三方服务器下载了可执行二进制文件并动态解压到 Native Library 路径中执行,这会被 Google Play 判定为恶意软件并导致下架。
总结
利用 Android 系统安装期解压 jniLibs 并赋予执行权限的特性,我们可以将二进制可执行文件伪装为 .so 格式,从而优雅地绕过 Android 10+ 的 W^X 安全限制。配合 Process 处理时的异步流消费,能够稳定地在免 Root 环境下调用 native 命令行工具。