OpenVPN 辅助二进制文件在 Android 10 上失败:权限被拒绝
Android 10 拒绝执行应用写入自身存储的辅助程序。读懂 logcat,把辅助程序作为原生库分发,并修复 bundle 构建。
应用在 Android 9 上运行正常,升级到 Android 10 后却在同一部手机上崩溃。你的配置文件没动过,证书也没变,崩溃发生在任何认证流量之前。logcat 先显示一个空指针,往上几行则是 Cannot run program ...: error=13, Permission denied。那条消息说的是执行文件,而不是读取你的配置。
错误 13 到底拒绝了什么
Android 10(API 级别 29)禁止应用执行自己写入数据目录的文件。这条规则针对的是先可写、后执行的存储,而这正是从 assets 复制出辅助程序的启动器所做的事。ics-openvpn 核心的旧版本会在首次运行时从 APK 解出可执行文件 openvpn,并用 ProcessBuilder 启动它。解包仍然成功,被拒绝的是执行。以 API 29 为目标的应用即使运行在出厂版本更老的设备上,也会继承这条规则,所以一次更新就可能弄坏没人碰过的构建。
拒绝与具体文件夹名无关。应用在其数据目录下创建的文件,无论路径如何都被视为不可执行,因此 getFilesDir()、getCacheDir() 以及自定义子目录都以同样方式失败。
先把辅助程序崩溃与配置问题分开
不要从头滚动日志,而是读进程启动前后的部分。要搜索的签名是 error=13 这一行,它上面的堆栈通常能指出调用 ProcessBuilder 的启动器方法:
adb logcat -d | grep -n -i -E "cannot run program|permission denied|startOpenVPN"如果辅助程序从未启动,你根本看不到 OpenVPN 的日志输出:没有加密协商,没有下推路由,也没有 DNS 配置。若这些行出现了、失败发生在更晚,说明二进制跑起来了,问题在配置、服务器或路由层。仅凭这一区分,就能省下大量花在修改从未有错的 .ovpn 文件上的时间。它还能告诉你下面两种修复里你真正需要哪一种。
还要检查被测设备的 API 级别,而不是构建配置里的那个。API 28 的模拟器镜像复现不了任何东西,因为这条限制从 29 起才生效。同时检查构建文件里的目标 SDK:把它调高正是引入该规则的原因。
adb shell getprop ro.build.version.sdk为什么把二进制复制到别处没有用
显而易见的绕法,也就是把辅助程序写到另一个可写目录,失败原因与原始做法相同。Android 仍允许执行安装到应用原生库目录的共享库,因为这些由安装器打包,而不是运行时写入。那个目录才是启动器唯一应该指向的地方。你在运行时自行解出的任何东西,无论叫什么名字,都仍属于被拒绝的类别。
外部存储更糟:它以 noexec 挂载,放在那里的辅助程序任何应用都无法启动。清除应用数据也恢复不了旧行为,因为下次启动只会重建同一个不可执行的副本。
让启动器指向随包分发的库
把辅助程序改名为共享库名,并按你支持的每个 ABI 放在 jniLibs 下,然后让启动器从 API 28 起返回已安装库的路径:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return new File(context.getApplicationInfo().nativeLibraryDir,
"libovpnexec.so").getPath();
}要让该路径包含真实文件,库必须在安装时被解出,所以在 android:extractNativeLibs="true" 元素上设置 <application>。如果清单合并器带入了别的值,就在紧邻处加上 tools:replace="android:extractNativeLibs"。application 元素参考 记录了该属性控制什么。
手动给启动器打补丁时,注意重复的局部变量。旧源码会在同一方法的后面声明 nativeAPI,而第二次声明是编译错误而非运行时错误,所以构建会立刻失败,错误也容易发现。让 Gradle 块里的 ABI 列表与你新增的文件夹保持一致。
让发布产物与测试构建一致
调试版 APK 能建立隧道,发布版 bundle 却仍然崩溃,因为 bundle 以不同形式打包原生库,还可能从归档中压缩提供。若只有 bundle 派生的构建失败,就把这一行加到 gradle.properties 里并重新构建:
android.bundle.enableUncompressedNativeLibs=false然后确认已安装的包报告的是你期望的 ABI;如果两种都发布,就在 32 位和 64 位设备上各重复一次检查。包管理器会列出它选择的 ABI,所以回退到 32 位的设备会出现在这里,而不是崩溃报告里:
adb shell dumpsys package com.example.openvpn | grep -i primaryCpuAbi某个 ABI 缺少库只会在那台设备上重现原来的权限错误,所以一台测试机成功并不算证明。清单标志、库名和 bundle 设置要一起发布;改一个忘掉其他,就会回到同样的崩溃。长久的解法是迁移到仍在维护的核心版本,而不是永远带着补丁。