给 Android 测试机装入代理 CA 后,浏览器访问正常、应用却握手失败,首先要查的是:目标进程究竟从哪里读取信任锚?系统大版本、Conscrypt 模块、证书源实现和挂载命名空间,共同决定了这个答案。
下面以 2025 年 6 月前的 Pixel 3a 记录为线索,对照固定版本源码,拆开“模块带有证书”“进程看得见证书”和“应用接受证书”三个不同条件。设备记录不是所有同版本手机的兼容性承诺。
记录两条更新链
Mainline 允许部分系统组件独立于完整 OTA 更新。只记“Android 13”,会漏掉 Google Play 系统更新(GPSU)和活动 Conscrypt 版本。设置页中的三个字段就可能来自不同时间点:
| 字段 | 记录值 | 用途 |
|---|---|---|
| Android 版本 | 13 | 平台大版本 |
| Android 安全更新 | 2023-09-01 | 系统补丁级别 |
| Google Play 系统更新 | 2025-04-01 | Mainline 更新日期 |
这组设置页信息与下文 Pixel 3a 的跨版本记录分开保存,不将两者拼成同一设备基线。设备侧还应采集以下只读信息;读取权限随构建而异:
adb shell getprop ro.build.fingerprint
adb shell getprop ro.build.version.sdk
adb shell ls -ld /apex/com.android.conscrypt*
adb shell cat /apex/apex-info-list.xml模块记录要看 isActive,而不只是目录里最新的文件名。下面是活动项的字段节选:
<apex-info
moduleName="com.android.conscrypt"
modulePath="/data/apex/decompressed/com.android.conscrypt@331411000.decompressed.apex"
preinstalledModulePath="/system/apex/com.google.android.conscrypt.apex"
versionCode="331411000"
isFactory="true"
isActive="true" />isFactory="true" 提醒我们:位于 /data/apex/decompressed 不等于已经安装了后续更新。压缩的预装 APEX 也会解压到该位置;需要结合活动标志、预装路径和版本判断。这个行为由 APEX 启动与解压机制解释。
先识别目录选择类
传统路径通常是 /system/etc/security/cacerts;模块路径是 /apex/com.android.conscrypt/cacerts。但“哪个存在就读哪个”并不完整,甚至两个相似的选择函数也属于不同层。
| 类与版本 | 显式 SDK 门槛 | APEX 目录被选择的其他条件 |
|---|---|---|
Conscrypt TrustedCertificateStore,提交 a1971d3 |
成功取得 SDK,且 ≥ 34 | Java 属性不是字符串 true;目录存在且非空 |
Android framework SystemCertificateSource,android-14.0.0_r1 |
该函数内没有 | Java 属性不是字符串 true;目录存在且非空 |
这里比较的是不同类和源码树,不是声称两个函数是同一个模块的前后版本。framework 类随平台交付;安装一个新 Conscrypt APEX,也不代表它同时替换了 framework 的实现。
对可正常列举的目录,Conscrypt 的关键条件可整理为以下等价示意。真实源码通过 getSdkVersion() 获取运行时 SDK:
boolean useApex(Integer sdk, String property, boolean exists, int entries) {
return sdk != null
&& sdk >= 34
&& !"true".equals(property)
&& exists
&& entries > 0;
}Java 的 System.getProperty("system.certs.enabled") 与 Android 属性服务是不同机制。在 shell 中设置同名 setprop 或 resetprop 值,不足以证明这里读到的 Java 属性已经变化。源码做的是精确字符串比较,"True" 与 "true" 也不是同一输入。
检查设备时应确定实际执行的是哪一个类、来自哪个构建,再读取它的条件。对应实现见 Conscrypt 固定提交与 Android 14 framework 证书源。
目录存在不等于启用
Pixel 3a 的记录把模块布局与运行时选择分开了:
| 环境 | Conscrypt 版本 | 观察 |
|---|---|---|
Android 11,RP1A.200720.009 |
300900703 |
APEX 内没有 cacerts;目标更新未成功取得 |
Android 12,SP1A.210812.015,更新前 |
310727000 |
APEX 内没有 cacerts |
| 同一 Android 12,GPSU 显示 2025-04-01 | 351412000 |
APEX 内出现 cacerts |
Android 11,RQ3A.211001.001,手动安装该模块后 |
351412000 |
目录存在;记录中的选择逻辑仍指向传统路径 |
这些观察支持“新布局可以出现在较旧平台上”,却没有证明 Android 11、12 会普遍启用模块根证书。Android 13 还有一项依赖 APEX 目录的记录,但旧模块版本未留存,也未完成同版本复现,因此不据此扩大结论。
版本 351412000 的包元数据摘录如下。包名是 com.google.android.conscrypt,与 APEX 模块名 com.android.conscrypt 不同:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.android.conscrypt"
android:versionCode="351412000">
<uses-sdk android:minSdkVersion="30" android:targetSdkVersion="35" />
<application android:hasCode="false" />
</manifest>minSdkVersion="30" 描述包兼容性下限,不会取消内部根目录选择函数的 SDK 34 门槛;hasCode="false" 也不表示整个 APEX 不含 JAR 或原生库。这里展示的是安装包元数据,而不是 APEX 载荷的完整清单。
跨设备复制或回滚模块还受签名、架构和系统接受条件约束。staged 安装应在重启后重新核对活动版本;一次把文件按 .apk 处理所触发的 INSTALL_FAILED_DUPLICATE_PACKAGE,也不是所有非 staged 安装行为的证明。
在目标进程中检查挂载
确认实现会选择 APEX 路径后,才进入文件可见性这一层。下面的图是排查顺序,不是一次完整 TLS 调用栈:
flowchart TD accTitle: 从实际实现走到应用信任结果 accDescr: 先确定证书源,再检查目标命名空间内的文件,最后检查用户禁用状态与应用策略。 A["实际执行的证书源类"] --> B["选定系统或 APEX 路径"] B --> C["目标进程挂载命名空间"] C --> D["可见文件与子挂载"] D --> E["用户证书、禁用标记与缓存"] E --> F["应用信任配置与额外校验"]
根管理模块可以先合并证书并覆盖传统目录,再把该目录绑定到 APEX 路径。然而当前 shell 的挂载视图,不是所有应用的视图。挂载传播属性、zygote 的状态,以及应用进程创建时机都应分别记录。
以下命令是在设备端 shell 中使用的只读检查模板。PID 应替换为已核验的目标进程号;权限和 nsenter 支持情况需要先确认:
readlink /proc/PID/ns/mnt
cat /proc/PID/mountinfo
nsenter --mount=/proc/PID/ns/mnt -- \
ls -l /apex/com.android.conscrypt/cacerts只修改 zygote 后,应分别检查新建应用和已存在的进程;只重启应用,也应记录它是否仍由预期的 zygote 派生。不要将“路径在 shell 中存在”直接写成“应用已使用这些证书”。
普通绑定可能漏掉子挂载
Android 15 的一项记录中,系统证书文件本身就是独立子挂载。相关路径包括 bf64f35b.0、5acf816d.0 和 d41b5e2a.0,均位于 /system/etc/security/cacerts 下。这里保留文件名用于对应挂载记录,不据此推断证书主体。
| 状态 | 设置界面观察 | 仍需检查 |
|---|---|---|
| 正常视图 | 系统页显示多个证书条目 | 目标应用实际读取的路径 |
| 异常视图 | 系统页未显示证书条目 | 文件内容、子挂载、读取权限与缓存 |
空白界面仅证明条目未被显示,不等于设备上已安装证书总数为零。若源目录中的内容依赖子挂载,普通 --bind 不会递归复制这些挂载;--rbind 才处理整棵挂载子树,但其中的 unbindable 子树会被剪除。详见 Linux shared subtree 语义。
相应的操作形式如下,仅适用于已检查源目录与传播关系的测试设备;命令工具的位置和选项以设备实际提供的实现为准:
nsenter --mount=/proc/PID/ns/mnt -- \
mount --rbind /system/etc/security/cacerts \
/apex/com.android.conscrypt/cacerts这是挂载子树问题的针对性修正,不是通用抓包开关。执行前保存目标命名空间的挂载基线,执行后逐项比较子挂载和实际文件;回滚也应恢复已记录的挂载层级,而不是反复叠加绑定。SELinux 标签、证书编码或应用独立信任库的问题仍需另查。
禁用标记与证书缓存
系统 CA 文件留在只读位置,并不妨碍用户禁用它。在固定 Conscrypt 实现中,deleteCertificateEntry() 对系统条目写入删除记录目录,保存的是证书内容;getTrustAnchor() 对候选系统项检查删除记录。对用户新增条目的处理则是另一条分支。
framework 的 SystemCertificateSource 使用当前 Android 用户配置目录中的 cacerts-removed,并检查与候选 CA 文件同名的标记。UserCertificateSource 读取对应的 cacerts-added。用户 0 的常见路径如下,其他用户应按实际用户 ID 确认:
/data/misc/user/0/cacerts-added
/data/misc/user/0/cacerts-removed不要把这些路径机械套到任意独立 Conscrypt 实例:固定提交的默认用户根目录是 $ANDROID_DATA/misc/keychain,并提供 setDefaultUserDirectory()。最终路径取决于调用方配置。
缓存也属于证据链。Android 14 的 DirectoryCertificateSource 缓存枚举结果,handleTrustStorageUpdate() 清空该集合,但不会因此重新选择构造时的目录。排查时区分清空证书集合与重建证书源对象,避免把一次路径或属性修改当成所有旧对象立即更新。
形成可复核的验证闭环
离线检查使用 javac 13-ea:提取固定提交的 shouldUseApex(),仅替换 SDK 查询入口;framework 函数只将路径重定向到本地夹具。测试覆盖 5 种 SDK 输入、4 种属性值与 3 种目录状态;可列举的非空目录中放置标记文件,不模拟有效证书。
PASS Conscrypt source method: 60 cases
PASS framework source method with redirected fixture paths: 12 cases这是选择分支验证,不是重新运行 Pixel 设备、挂载修改或 TLS 握手。设备上的最终检查应继续落实到应用:
- 1
冻结基线
保存构建号、SDK、GPSU 日期、活动 APEX、实际类来源与进程启动时间。
- 2
确认文件视图
在目标命名空间内比较证书指纹、目录与子挂载,并记录权限和用户 ID。
- 3
核对信任状态
区分系统根、用户新增项、禁用记录和已缓存对象;重启前后分别记录。
- 4
验证相同请求
固定主机名、证书链与应用输入,对比调整前后结果,再恢复基线验证原行为。
现代应用是否信任用户 CA,要看目标 API 和 Network Security Configuration;自定义 TrustManager、原生网络栈和证书固定又可能增加额外条件。能修改自有应用时,调试构建中明确配置所需信任锚,比依赖整机挂载变化更可控。目录可见性是必要排查项,而不是最终信任结果。
参考资料
- AOSP:Mainline 模块架构
- AOSP:Conscrypt 模块与根证书更新
- AOSP:APEX 格式、安装与激活
- Google Conscrypt:固定版本 TrustedCertificateStore
- AOSP Android 14:SystemCertificateSource
- AOSP Android 14:UserCertificateSource
- AOSP Android 14:DirectoryCertificateSource
- Linux:Shared subtrees
- Android Developers:Network Security Configuration