某问 AI 抓包:绕过内置 Chromium 证书校验的一次记录
纯记录贴,自己下次翻笔记用。叙述水平和文笔有限,各位将就看。技术仅供学习、以及对自己有授权的设备和流量做分析,别拿去干别的,出事概不负责。
第二十七条:任何个人和组织不得从事非法侵入他人网络、干扰他人网络正常功能、窃取网络数据等危害网络安全的活动;不得提供专门用于从事侵入网络、干扰网络正常功能及防护措施、窃取网络数据等危害网络安全活动的程序和工具;明知他人从事危害网络安全的活动,不得为其提供技术支持、广告推广、支付结算等帮助任何个人和组织使用网络应当遵守宪法法律,遵守公共秩序,尊重社会公德,不得危害网络安全,不得利用网络从事危害国家安全、荣誉和利益,煽动颠覆国家政权、推翻社会主义制度,煽动分裂国家、破坏国家统一,宣扬恐怖主义、极端主义,宣扬民族仇恨、民族歧视,传播暴力、淫秽色情信息,编造、传播虚假信息扰乱经济秩序和社会秩序,以及侵害他人名誉、隐私、知识产权和其他合法权益等活动。
起因
某问之前的老版本抓包挺顺的,抓包工具的证书一装完事。有次更新之后突然不行了,Reqable 里一堆握失败的连接,明文一条看不见。
翻 logcat,报错是这种:
unet : ssl_client_socket_impl.cc(1011) handshake failed; returned -1, SSL error code 1, net_error -202
unet.upaas: UPaasChannel::OnLoginResponse(...) error(net::ERR_UNET_FATAL_CERT_VERIFY_ERROR)
new_unet: Sync handleError code: -1601, ... url: https://open-cms-api.xxx.com/open-cms?params=...&no_decrypt=false
这里有两个信息值得注意。
一是 net_error -202,也就是 ERR_CERT_AUTHORITY_INVALID,证书的 CA 不被信任。按理说我当时已经把 Reqable 的证书塞进系统证书库了,普通 app 这时候早该放行,它还在报 CA 不被信任——这基本可以断定它不是走 Android 系统的证书校验,而是自带了一套。
二是报错里的源码路径 net/socket/ssl_client_socket_impl.cc、后面还看到 net/cert/cert_verify_proc.cc,这是 Chromium 网络栈那套代码结构,不是 Android 那个 Java TLS(Conscrypt/OkHttp)。
先排除几种常规情况
解压缩 APK 看 lib/arm64-v8a/,so 有一堆,但既没有 libflutter.so 也没有 libapp.so,先排除 Flutter。
有意思的是里面躺着一个 8.6M 的 libunet.so。一个网络库 8.6M 明显不对劲,八成是把什么东西编译进去了。strings 扫一下:
strings libunet.so | grep -iE 'boringssl|tls_record|ssl_client_socket|cert_verify'
命中了一堆 boringssl、tls_record、ssl_client_socket_impl、cert_verify_proc。再看它的依赖:
# libunet.so 只依赖 libandroid/libc/libm/libdl/liblog
也就是说它把 Chromium 的网络栈 + BoringSSL 静态编译进了自己身体里,还自己 patch 过 Chromium 的证书校验代码(报错路径里有 unet/patch/net/cert/cert_verify_proc*.cc 这种字样)。这套东西走的是 Chrome Root Store / Chromium 自带的校验器,根本不鸟系统证书库。所以前面证书装系统里也没用,白忙活。
顺便说一句,这类自带 Chromium 网络栈的 app 其实不少,思路是通用的,能直接套到别的目标上。
定位要改的函数
libunet.so 符号表被 strip 了,得靠 IDA 手工找。思路就是拿报错字符串反着追。
先挑几个锚点(这个 so 的 VA 和文件偏移是一一对应的,imagebase 是 0):
| 偏移 |
字符串 |
是啥 |
0xc896a |
handshake failed; returned |
DoHandshake 里的报错日志 |
0x72434 |
HandleVerifyResult |
证书校验结果处理函数的名字 |
0xa3ef6 |
UNET_FATAL_CERT_VERIFY_ERROR |
致命错误串 |
在 IDA 里对 HandleVerifyResult 做交叉引用,跳到 sub_6B4278。反编译一看,函数内部还有一段日志在打 "HandleVerifyResult" 和 "../../unet/patch/net/socket/ssl_client_socket_impl.cc", 1346——对上 Chromium 源码里的 SSLClientSocketImpl::HandleVerifyResult,就是它了。
再往上追一层调用链:
sub_6B3EF8 ← 自定义证书校验回调(BoringSSL 握手时调它)
└─ sub_6B3F30 ← VerifyCert
└─ sub_6B4278 ← HandleVerifyResult(三个出口全 tail-call 到这里)
VerifyCert 里所有出口都是 return HandleVerifyResult(...),所以这个函数是「校验结果 → 过/不过」的唯一汇合点,改它一个点就够了。
改成什么样
它返回值就是 BoringSSL 那套枚举:0=通过、1=同步失败、2=异步进行中。反汇编里对应三个 return:
6B43A0 MOV W0, #1 ; return 1 失败
6B43A8 MOV W0, #2 ; return 2 异步中
6B43C0 MOV W0, WZR ; return 0 通过
最省事的改法:函数一进来就返回 0,后面整段校验逻辑全跳过。
入口 0x6B4278 原始是两个指令的 prologue:
6B4278 SUB SP, SP, #0x60 ; FF 83 01 D1
6B427C STP X29, X30, [SP,#...] ; FD 7B 03 A9
替换成:
MOV W0, WZR ; E0 03 1F 2A
RET ; C0 03 5F D6
Python 上操作,就是改 8 个字节:
src = open('libunet.so', 'rb').read()
out = bytearray(src)
out[0x6B4278:0x6B4280] = bytes.fromhex('E0 03 1F 2A C0 03 5F D6')
open('libunet_patched.so', 'wb').write(out)
# md5: c9df0c8961e34f0060f57873d8ab3c77
改完记得用 capstone 反汇编确认一下入口确实是 mov w0, wzr; ret,别手滑写错。
换文件 + 重启
设备得 root(我用的 Magisk)。先备份再换:
P="/data/app/~~xxxx==/com.xxx.yyy-xxxx==/lib/arm64/libunet.so"
adb shell am force-stop com.xxx.yyy
# 备份
adb shell "su -c 'cp $P /data/local/tmp/libunet.so.bak'"
# 推文件(Windows 下 Git Bash 记得套 MSYS_NO_PATHCONV,不然 /data 会被转成 D:/ 之类)
MSYS_NO_PATHCONV=1 adb push libunet_patched.so /data/local/tmp/libunet_patched.so
# 替换 + 改回属主/权限/SELinux
adb shell "su -c 'cp /data/local/tmp/libunet_patched.so $P && \
chown system:system $P && chmod 755 $P && \
chcon u:object_r:apk_data_file:s0 $P'"
adb shell "monkey -p com.xxx.yyy -c android.intent.category.LAUNCHER 1"
这里最容易翻车的是 SELinux。直接 cp 过去之后文件上下文会变成 shell_data_file 之类的,app 加载 so 会 avc: denied 然后闪退,必须 chcon u:object_r:apk_data_file:s0 改回来。也别用 mount --bind,那玩意也会改文件上下文。
验证的话看 logcat:
adb logcat -d | grep -iE 'retcode|ERR_UNET_FATAL_CERT'
证书报错消失、出现 response ... retcode=0 这种正常收包日志,Reqable 上就能看到明文了。
一个反直觉的坑(差点以为没绕过)
这里记一笔,因为一开始我把问题想简单了。
我第一反应是「改得越精准越好」,于是只把 0x6B43A0 那个 MOV W0,#1(return 失败)改成 MOV W0,WZR(return 通过),0x6B43A8 的 return 2(异步中)保留原样。结果:证书报错确实没了,握手表面过了,可 Reqable 还是抓不到明文,业务请求也没了。
后来才想明白,这货的校验是异步的:
- 回调第一次返回
retry(2),BoringSSL 就挂起握手,等异步校验结果;
- 异步校验是真跑的,它会拒绝抓包证书,然后把 error 写进会话的
cert_status / verify_result;
- 等异步回来,就算这里返回 0 也没用——后面还有个第二层
SSL_get_verify_result(或者 app 自己对 cert_status 的检查)读到了已经被写进去的错误,照样判失败。
所以光改最终返回值没用。入口直接 ret 0 是对的路子:不进 retry → 异步校验压根不跑 → 错误码从来没写进去 → 后面读到的都是干净值 → 抓包成功。
这个「retry 要原样返回、后面还有第二层 verify_result」的现象,在同类 Chromium 内核(抖音那套 TTNet/Cronet 也是这个德行)上是一样的,算个通用规律,记下来下次少走弯路。
小结
回头看整个流程其实就三板斧:
- 抓到
-202 CA 不被信任 + 系统证书已装 → 基本是自带校验栈,别在证书上浪费时间;
- strings 找报错串、IDA 反着追到「校验结果处理函数」这个汇合点;
- 入口 patch 成
ret 0,跳过整段校验(别学我去改单个 return,会踩异步 retry 的坑)。
完。