安卓 APP 执行二进制程序实践

由于安卓沙盒模型不断更新,在 APP 中执行二进制变得越发困难和受限制。而受限于实际业务需求(或应用生态等),有时不得不需要在安卓平台上执行一些 Linux 二进制程序。鉴于相关领域的资料较少,因此将自己的相关经验做一下整理。

截至目前(API 版本 35,安卓 15),应用在 BinaryDir 中(一般位于 /data/app/<随机字符串>/<你的应用包名>-<随机字符串>/lib/)的二进制程序具有执行权限,可以由 Runtime.exec 等直接调用执行。

此目录中的二进制文件为只读,只能由开发者预先放置在 APK 安装包中,并在应用安装时由安装器释放到 APP 安装目录中,无法直接写入或热更新。这样的限制符合高版本安卓对应用的 W^X 限制要求,可以有效防止恶意代码的动态注入和执行。

你可以按照如下流程为安卓 APP 打包二进制程序,并在 APP 运行时对其进行调用:

1. 获取安卓 NDK 编译的二进制程序及其依赖

安卓使用非标准的 Bionic C 库,因此普通 Linux Arm64 二进制无法在安卓平台上直接运行,需要使用安卓 NDK 进行交叉编译

  • 如果你需要执行的东西比较热门,可以尝试在 Termux 中安装,然后将对应的二进制程序(通常在 /data/data/com.termux/files/usr/bin)和 so 依赖库(通常在 /data/data/com.termux/files/usr/lib)从 Termux 中复制出来备用,省去自己编译的麻烦
  • 建议寻找对应二进制程序的 NDK 静态编译版本,静态编译的二进制嵌入起来最为方便,不存在任何依赖库的问题

2. 放置二进制程序到应用中

将二进制程序(及其依赖库)放到安卓项目的 jniLibs/对应ABI/ 目录下,并且文件名格式必须遵循 libxxx.so 的格式。如图所示:

将二进制文件放置到jniLibs目录下

如果你的二进制文件没有遵循 libxxx.so 的命名格式,在最终安装 Release 版 APP 时这些文件将不会被安装,进而将导致程序无法正常工作。

这个特性源于安卓一个长久未修复的 BUG,参见:only-files-named-are-copied-by-enforced-for-api-level-35

3. 修改配置文件

放置完二进制文件后,开始修改配置文件:

  • AndroidManifest.xml 中为 <application> 标签增加 android:extractNativeLibs="true" 属性

  • build.gradle 中设置 NDK 支持的二进制 ABI,一般按如下设置即可:

android {
    ...
    defaultConfig {
        ndk {
            abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
        }
        ...
    }
    ...
}

4. 运行二进制程序

现在,就可以使用 Runtime.exec / ProcessBuilder 等 API 执行 BinaryDir 中的二进制程序了。

对于拥有动态链接依赖库的二进制程序,需要手动配置 LD_LIBRARY_PATH 环境变量,让链接器在运行时可以找到对应的依赖库。示例代码如下:

String binaryDir = getApplicationInfo().nativeLibraryDir
String executable = binaryDir + File.separator + "xxxx";
List<String> cmd = Arrays.asList(executable, "--parameter1", "--parameter2");
try {
    ProcessBuilder pb = new ProcessBuilder(cmd);
    Map<String, String> env = pb.environment();
    env.put("LD_LIBRARY_PATH", binaryDir);			// 设置 LD_LIBRARY_PATH
    Process process = pb.start();
    
    // ....
} catch (IOException | InterruptedException e) {
    Log.e("MyTag", "发生异常: " + e.getMessage());
}

5. 报错了?

5.1. 依赖库缺失

如果依赖库缺失,pb.start() 会抛出异常,在异常信息中可以看到缺失的依赖库名字。按照前面的方法在 jniLibs 中补全缺失的依赖库,然后重新编译安装 APP 运行即可。

5.2. 二进制文件命名问题

前面说了,jniLibs 中二进制文件名必须遵循 libxxx.so 的格式,否则会出现问题。

这个特性十分烦人,因为你所使用的程序和库往往并不能完全符合这种要求(比如会有程序依赖类似 libzstd.so.1 这种名字的动态库)

此时可以用符号链接来解决问题。具体而言,在每次 APP 开始运行前,到私有目录中(FilesDir)为 BinaryDir 下的所有二进制文件创建符号链接。由于符号链接可以自定义名字,因此你可以提前在 APP 私有目录中用符号链接还原原来的二进制依赖的文件名,随后直接在私有目录下用符号链接执行此二进制文件。

解包二进制到私有目录的辅助函数如下:

// 当前平台下对应架构的 libxxx.so 将会被链接到 {linkingDir}/libxxx.so,注意linkingDir必须在应用私有目录下!
// 如果fileNameMapping中有自定义名字映射,符号链接将创建为对应的设定好的文件名
private void linkBinariesToDir(File linkingDir, Map<String, String> fileNameMapping) {
    if(linkingDir.exists())
        deleteDir(linkingDir);
    linkingDir.mkdirs();
    File nativeLibsFolder = new File(getApplication().getApplicationInfo().nativeLibraryDir);
    File[] files = nativeLibsFolder.listFiles();
    if (files == null)
        return;
    for (File srcFile : files) {
        if (srcFile.isFile()) {
            String destName = srcFile.getName();
            if(fileNameMapping.containsKey(destName))
                destName = fileNameMapping.get(destName);
            File linkFile = new File(linkingDir, destName);
            try {
                Os.symlink(srcFile.getAbsolutePath(), linkFile.getAbsolutePath());
            } catch (ErrnoException e) {
                print("Fail to create symbolic link: " + e.getMessage());
            }
        }
    }
}

// 辅助函数,删除目录
public static boolean deleteDir(File f) {
    if(f == null)
        return true;
    if (f.isDirectory()) {
        String[] children = f.list();
        if (children != null) {
            for (String child : children) {
                boolean success = deleteDir(new File(f, child));
                if (!success) {
                    return false;
                }
            }
        }
    }
    return f.delete();
}

调用示例:

File linkingDir = new File(getApplication().getFilesDir(), "adb_dir")
linkBinariesToDir(linkingDir, Map.of(
                    "libz.so.1.3.1.so", "libz.so.1",
                    "libzstd.so.1.5.7.so", "libzstd.so.1",
                    "libadb.so", "adb"
                    ));

调用后查看目录 /data/user/0/<包名>/files/adb_dir,就可以看到里面的二进制文件和依赖库

创建的二进制文件的符号链接

并且二进制文件此时应该可以直接被执行。示例代码如下:

String executable = new File(linkingDir, "adb")
List<String> cmd = Arrays.asList(executable.getAbsolutePath(), "--version");
try {
    ProcessBuilder pb = new ProcessBuilder(cmd);
    Map<String, String> env = pb.environment();
    env.put("LD_LIBRARY_PATH", linkingDir);			// 设置 LD_LIBRARY_PATH 为 linkingDir
    Process process = pb.start();
    
    // ....
} catch (IOException | InterruptedException e) {
    Log.e("MyTag", "发生异常: " + e.getMessage());
}

6. Reference

Target SDK 29 后运行二进制文件的较好的实践

What path to put executable to run on Android 29? - Stack Overflow


安卓 APP 执行二进制程序实践
https://blog.openyq.top/posts/5876/
作者
yqs112358
许可协议