
2 tmead.y.qq.com/maproxy/getPbCompressInfo
2 xs.gdt.qq.com/push_ad/get_ad_point
2 monitor.kg.qq.com/ilogs/hippy_wns_buffer_log
2 node.kg.qq.com/log
2 t.y.qq.com/cgi-bin/musicu.fcg ← 唯一像业务的 API
</code></pre></div>
<p>看那两次 <code>musicu.fcg</code>:</p>
<div class=)
for flow in mio.FlowReader(open('/tmp/flow.mitm', 'rb')).stream():
if flow.type != 'http': continue
if 'musicu.fcg' not in flow.request.path: continue
print(flow.request.get_text()[:500])
print('→', flow.response.get_text()[:500])
得到:
=== POST https://t.y.qq.com/cgi-bin/musicu.fcg
REQ (742):
{\"EMUA\":{\"module\":\"safety.validate.SafetySdkProxy\",\"method\":\"Point\",\"param\":{
\"Type\":\"0\",
\"EMUA\":\"Emua_A_728381120_dn+WXuSDQie95Zu+6B8r1NoqPI0rzmi+...\"
}}}
RESP 200:
{\"code\":0,\"ts\":1783309757273,\"EMUA\":{\"code\":0,\"data\":{\"strPointEncry\":\"TMEPoint_...\"}}}
=== POST https://t.y.qq.com/cgi-bin/musicu.fcg
REQ (2886):
{\"EMUA\":{\"module\":\"safety.validate.SafetySdkProxy\",\"method\":\"TypeOne\",\"param\":{
\"Type\":\"1\",
\"EMUA\":\"Emua_A_728381120_/r8wjni++dkVc8I5+yWw1vK73HaULBmSFvaAtggd4c...\"
}}}
RESP 200:
{\"code\":0,\"ts\":1783309767934,\"EMUA\":{\"code\":0,\"data\":{\"ret\":0,\"msg\":\"ok\"}}}
当时的错误结论: safety.validate.SafetySdkProxy 是显式的安全校验接口,服务端 code:0, ret:0, msg:ok —— 看起来 \"服务端接受了\"。以为签名已经过关,卡在别的地方。
但业务数据在哪呢? 抓到的所有 URL 里,除了这两次 musicu.fcg, 没有一个是拉推荐列表、拉歌单、拉播放列表的接口 —— 剩下全是 /connectiontest 联通性、/wdr 埋点、/api/v1/crash 崩溃上报、ma_report* 广告展示上报、push_ad/get_ad_point 广告点上报 —— 清一色是 \"App 主动往服务端塞信息\",一个 \"拉数据\" 都没有。
正确的解读应该是:业务请求根本没走 HTTP。SafetySdkProxy 上报归上报,服务端 code:0 只表示 \"上报格式合法、我收下了\",跟 \"签名有没有被拒\" 是两件事。
但当时我没这么想 —— 被 code:0, msg:ok 迷惑了,以为签名过了,卡在客户端某个后置逻辑。走了整整一小时的弯路:hook 各种 \"客户端后置逻辑\"、猜 \"是不是登录态问题\"、猜 \"是不是 UI 层 Fragment 加载失败\"。
4.4 走弯路二:关掉 proxy 反证——WNS 长连的副作用
如果卡在客户端后置逻辑,理论上跟 proxy 无关;如果是网络问题,关掉 proxy 应该恢复。
adb shell settings put global http_proxy :0
adb shell am force-stop com.tencent.qqmusic
adb shell monkey -p com.tencent.qqmusic -c android.intent.category.LAUNCHER 1
sleep 20
adb shell screencap -p /sdcard/screen.png && adb pull /sdcard/screen.png
结果:首页仍然是 \"糟糕发生错误了\"。
有用的结论:
- proxy 拔了首页依然坏 —— proxy 不是错误的根源。
code:0那两次调用与业务成功无关。- 回到起点:这是重打包引入的问题,跟网络路径无关。
顺带的副作用观察:开 proxy 的时候, logcat 里 WnsBinder invoke begin 每 30ms 一次疯狂重试:
07-06 11:49:16.575 I WnsBinder: [, , 0]:invoke begin. cmd:5. BEGIN Transfer => TransferArgs [
uin=3298148121, command=kg.singingad.getpreloadresourcelistv2,
needCompress=true, timeout=60000, retryFlag=0, retryCount=3, ...]
07-06 11:49:16.604 I WnsBinder: [, , 0]:invoke begin. cmd:5. BEGIN Transfer => ...
07-06 11:49:16.633 I WnsBinder: [, , 0]:invoke begin. cmd:5. BEGIN Transfer => ...
[数百条同样的 cmd:5]
关掉 proxy 后,同样时间窗只有 1-2 条 WnsBinder 调用。说明 QQ 音乐的 WNS 长连(TCP 直连)虽然名义上不遵守 http\_proxy, 但它会检测到系统 http_proxy 存在, 然后反复重试建连 —— 大概率是内部把 http\_proxy 视为 \"网络环境异常\" 的信号。
这个副作用让我又一次跑偏 —— \"WNS 卡住 → 业务拉不到 → 首页错误\" 的解释链看起来很顺,一度以为 \"关掉 proxy 就能恢复\"。事实上关了 proxy 只是消除了 WNS 重试的抓包噪声, 首页依然是错的。
4.5 走弯路三:hook WNS 层追踪业务请求
既然 HTTP 抓包只看到上报,是不是业务数据全走 WNS TCP 长连? hook WNS 应该能看到业务请求被拒的具体错误码。
先枚举 WNS 相关类:
Java.perform(function () {
var matched = [];
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf('com.tencent.wns') === 0) matched.push(name);
},
onComplete: function () {
matched.sort();
matched.forEach(function (n) { console.log(n); });
}
});
});
关键类:
com.tencent.wns.service.WnsBinder.invoke(int cmd, Bundle args, IRemoteCallback cb) → intcom.tencent.wns.service.WnsNativeCallback.onTransferCallback(this, int, int, String, byte[], boolean, boolean, String)
从签名就能看出, onTransferCallback 的第 2、3 个 int 大概率是 result code + error code, byte[] 是响应载荷,最后的 String 是错误消息。
写 hook:
'use strict';
var L = function (s) { send(String(s)); };
function bundleStr(b) {
if (!b) return '<null>';
var arr = [], it = b.keySet().iterator();
while (it.hasNext()) {
var k = it.next(); var v = b.get(k);
var s = (v == null) ? 'null' : v.toString();
if (s.length > 200) s = s.slice(0, 200) + '...';
arr.push(k + '=' + s);
}
return '{' + arr.join(', ') + '}';
}
Java.perform(function () {
var Binder = Java.use('com.tencent.wns.service.WnsBinder');
Binder.invoke.overload('int', 'android.os.Bundle', 'com.tencent.wns.ipc.IRemoteCallback')
.implementation = function (cmd, bundle, cb) {
L('>>> invoke cmd=' + cmd + ' args=' + bundleStr(bundle));
var r = this.invoke(cmd, bundle, cb);
L('<<< invoke cmd=' + cmd + ' ret=' + r);
return r;
};
var NCB = Java.use('com.tencent.wns.service.WnsNativeCallback');
NCB.onTransferCallback.overloads.forEach(function (ov) {
ov.implementation = function () {
L('↩ onTransferCallback(' + Array.prototype.slice.call(arguments).map(String).join(', ') + ')');
return ov.apply(this, arguments);
};
});
});
因为 frida -l script.js -q 执行完立即退出会导致 hook 全部 detach,用 Python 保活:
import frida, sys, time, subprocess as sp
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
pid = int(sp.check_output(['adb', 'shell', 'pidof', 'com.tencent.qqmusic']).strip())
session = device.attach(pid)
scr = session.create_script(open('hook_wns.js').read())
scr.on('message', lambda m, _: print(m))
scr.load()
time.sleep(1800) # 30 分钟保活
跑起来一看,出乎意料的少:
WnsBinder.invoke hooked
WnsNativeCallback.onTransferCallback hooked
[等 60 秒]
(沉默)
[点首页 tab、点重试、点乐馆、点雷达——沉默继续)
主进程里业务操作完全没有触发 WnsBinder.invoke。反倒是从 logcat 里看到几行:
BEGIN Login => Bundle[{login.type=7, nameAccount=, uid=7374438232715809792,
guest=false, ignore.tick=false, app.push.enable=true}]
login state:1 ← 1 = LoggedIn
setCountryID=42
PushRegister of 7374438232715809792 Success, Ticket Saved
invoke begin. cmd:38 ← 只调一次
invoke begin. cmd:41 ← 只调一次
Wakelock ACQUIRED :)
[20 秒后] Wakelock release :)
结论:
- WNS 长连本身工作正常 —— Login state=1, PushRegister Success, 说明连上了 wns 服务器、拿到了 push ticket。
- cmd 38 / cmd 41 各调一次,之后彻底静默 —— 它们大概率是 QIMEI 上报、push 注册这类启动一次性 cmd。
- 业务数据(推荐、歌单、播放列表)根本不走 WNS。QQ 音乐启动时通过 WNS 做设备注册、账号绑定、push register, 业务列表另有通道。
但这条线索没帮我们定位到根因。真正把方向拉回来的是朋友一句朴素的提醒。
4.6 转折点:一句话点醒
QQ 音乐 4 小时踩坑路径全景
在 WNS hook 和 mitmproxy 之间兜了几个小时后,一句话把方向拉回来:
\"你要想想, 重打包之前都是好的, 重打包之后才出现错误的。\"
对,就是这个基本事实。唯一变化的变量是签名 —— 原版 keystore, Tencent 官方私钥, → my.keystore 自签。既然只有这一处变了、其他所有代码/资源/AndroidManifest 都没动,那问题必然与签名有关,不可能是其他任何东西。
之前所有的迷惑都来自 \"服务端 code:0\" 这个假象,让我误以为 \"签名已经过关\",从而在别的地方乱找。回到根因:签名字节被 App 塞进了业务请求,服务端识别到 my.keystore 就拒。
赶紧写签名 hook —— 让 App 在代码层看到的签名字节仍然是 587 字节的 Tencent 原版。
4.7 只 hook Signature.toByteArray:验证假设
先做最简单的 hook:只覆盖 android.content.pm.Signature.toByteArray() —— App 每次拿签名字节数组,我们返回原版。
从原版 APK 提取 cert:
mkdir -p sig_orig && cd sig_orig
python3 -c \"
import zipfile, os
z = zipfile.ZipFile('../QQMusic13.8.1.apk')
for name in z.namelist():
if name.endswith('.RSA') and name.startswith('META-INF/'):
with open(os.path.basename(name), 'wb') as f: f.write(z.read(name))
\"
# → TENCENT.RSA 之类
openssl pkcs7 -inform DER -in *.RSA -print_certs -out cert.pem
openssl x509 -in cert.pem -outform DER -out cert.der
wc -c cert.der
# 587 bytes
base64 -i cert.der | tr -d '\
' > cert.b64
Frida 脚本:
'use strict';
var CERT_B64 = 'MIICRzCCAbCgAwIBAgIETMVEnTANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwI4NjEQ...'
+ /* 587 字节 base64, 从上面 cert.b64 复制 */;
function b64d(s) { /* ... */ }
var ORIG_BYTES = b64d(CERT_B64);
Java.perform(function () {
var Signature = Java.use('android.content.pm.Signature');
var ORIG_JAVA = Java.array('byte',
ORIG_BYTES.map(function (b) { return b > 127 ? b - 256 : b; }));
Signature.toByteArray.implementation = function () {
send('[J] toByteArray called → orig');
return ORIG_JAVA;
};
});
用 Frida spawn 模式装, attach 模式来不及拦到启动初期的调用\\:
import frida
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
pid = device.spawn(['com.tencent.qqmusic'])
session = device.attach(pid)
scr = session.create_script(open('qq_sig_lite.js').read())
scr.on('message', lambda m, _: print(m))
scr.load()
device.resume(pid)
import time; time.sleep(60)
观察 1: 启动阶段 toByteArray 被调用 4 次:
[SEND] [J] toByteArray called → orig
[SEND] [J] toByteArray called → orig
[SEND] [J] toByteArray called → orig
[SEND] [J] toByteArray called → orig
观察 2: 首页恢复了。截图:顶部搜索栏、\"推荐 / 乐馆\" tab、5 个底部 tab 全在。\"糟糕发生错误了\" 消失。
观察 3: 60 秒后 Python 退出, Frida 自动 detach。此时下拉刷新首页 —— \"糟糕发生错误了\" 立刻回来。
这三个观察点串起来构成极强的验证性:
- hook 装上 → 错误消失。
- hook detach → 错误立刻回来。
- 中间没有任何其他变量。
—— 签名 hook 是关键路径。
但为什么下拉刷新触发不了新的 toByteArray? 改用 attach 模式, 不重启 App, 继续观察, toByteArray 一次都没被调。这意味着 QQ 音乐只在启动早期读一次 Signature.toByteArray,然后把结果缓存,之后所有请求用缓存值。
—— 但既然 spawn hook 时启动阶段的 4 次调用都返回了原版字节、缓存了原版字节,为什么 detach 之后错误又回来了?
只能有一个解释:Signature.toByteArray 并不是签名进入请求的唯一路径。App 内部还有别的地方读签名,且这些地方没被这个 hook 覆盖。
4.8 补全 hook:PackageInfo.signatures[] + SigningInfo
Android 里读签名的完整路径见 第 2.1 节,一共四条。只 hook toByteArray 覆盖不到:
pi.signatures[0]直接引用 ——Signature对象本身还是自签的。SigningInfo.getApkContentsSigners()—— Android 9+ 主流用法,绕过Signature.toByteArray。Signature.hashCode()—— 内部是Arrays.hashCode(mSignature),字段本身没变。- 反射读
mSignature—— 直接绕过所有方法。
正确做法: 替换 Signature 对象本身,而不是 \"读方法\"。这样后续无论 App 怎么读它, toByteArray / hashCode / toCharsString / 反射 mSignature, 拿到的都是一致的原版。
最终 hook 脚本拆四块看,一块解决一层签名读取路径。
Block 1: 全局设置 + native open 观察
/*
* qq_sig_full.js —— QQ 音乐重打包签名 hook 完整方案
*/
'use strict';
var L = function (s) { send(String(s)); };
var CERT_B64 = 'MIICRzCCAbCgAwIBAgIETMVEnTANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwI4...';
function b64d(s) { /* 见 第 2.4 节 */ }
var ORIG_BYTES = b64d(CERT_B64);
L('orig cert ' + ORIG_BYTES.length + ' bytes');
// hook native open() 看有没有 native 层读 APK 文件, 只是观察, 不改行为
try {
var open_ptr = Module.findExportByName(null, 'open');
Interceptor.attach(open_ptr, {
onEnter: function (args) {
var path = args[0].readCString();
if (path && (path.indexOf('/base.apk') >= 0
|| path.indexOf('.RSA') >= 0
|| path.indexOf('CERT') >= 0)) {
L('[native] open(\"' + path + '\")');
}
}
});
} catch (e) {}
open 拦下来只 send 日志、不改, 是为了后续验证 \"native 层有没有绕开 Java 读 APK\"。
Block 2: Signature.toByteArray() 返回原版字节
Java.perform(function () {
var Signature = Java.use('android.content.pm.Signature');
var ORIG_JAVA = Java.array('byte',
ORIG_BYTES.map(function (b) { return b > 127 ? b - 256 : b; }));
Signature.toByteArray.implementation = function () {
L('[J] toByteArray → orig');
return ORIG_JAVA;
};
L('★ Signature.toByteArray hooked');
第一层叶子, 覆盖直接读字节的入口。QQ 音乐启动期读一次然后缓存, 所以必须 spawn 拦。
Block 3: getPackageInfo 整体替换 Signature 对象
// Java.perform 内继续
var PM = Java.use('android.app.ApplicationPackageManager');
var cnt = 0;
PM.getPackageInfo.overloads.forEach(function (ov) {
ov.implementation = function () {
var pi = ov.apply(this, arguments);
if (pi !== null) {
cnt++;
if (cnt <= 10) L('[J] getPackageInfo #' + cnt);
try {
var newSig = Signature.$new(ORIG_JAVA);
var arr = Java.array('android.content.pm.Signature', [newSig]);
try { pi.signatures.value = arr; } catch (e) {}
// Android 9+ signingInfo → SigningDetails.mSignatures
try {
var si = pi.signingInfo.value;
if (si !== null) {
var fDet = si.getClass().getDeclaredField('mSigningDetails');
fDet.setAccessible(true);
var det = fDet.get(si);
if (det !== null) {
var fSig = det.getClass().getDeclaredField('mSignatures');
fSig.setAccessible(true);
fSig.set(det, arr);
}
}
} catch (e) {}
} catch (e) { L('swap err: ' + e); }
}
return pi;
};
});
L('★ getPackageInfo hooked');
这一块是关键 —— 整体替换 Signature 对象, 而不是 hook 读方法。同时覆盖旧路径 signatures 数组和 Android 9+ 的 signingInfo 内部字段。
Block 4: SigningInfo 直接调用兜底
// Java.perform 内继续
try {
var SI = Java.use('android.content.pm.SigningInfo');
SI.getApkContentsSigners.implementation = function () {
L('[J] getApkContentsSigners');
return Java.array('android.content.pm.Signature', [Signature.$new(ORIG_JAVA)]);
};
SI.getSigningCertificateHistory.implementation = function () {
L('[J] getSigningCertificateHistory');
return Java.array('android.content.pm.Signature', [Signature.$new(ORIG_JAVA)]);
};
L('★ SigningInfo hooked');
} catch (e) {}
}); // Java.perform 结束
如果 App 直接拿 SigningInfo 对象调 getApkContentsSigners, 不经过 PackageInfo.signatures, Block 3 拦不到 —— Block 4 兜底。
Python 保活 spawn:
import frida, time
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
pid = device.spawn(['com.tencent.qqmusic'])
session = device.attach(pid)
scr = session.create_script(open('qq_sig_full.js').read())
scr.on('message', lambda m, _: print(m))
scr.load()
device.resume(pid)
time.sleep(1800) # 30 分钟保活, 别让 hook 提前 detach
4.9 观察日志:hook 全部命中
orig cert 587 bytes
[native] open(\"/data/app/~~vgn.../com.tencent.qqmusic-*/base.apk\")
[native] open(\"...base.apk\")
[native] open(\"...base.apk\")
★ Signature.toByteArray hooked
★ getPackageInfo hooked
★ SigningInfo hooked
[native] open(\"...base.apk\")
[J] getPackageInfo #1
[J] getPackageInfo #2
[J] getPackageInfo #3
[J] getPackageInfo #4
[J] getPackageInfo #5
[J] getPackageInfo #6
[J] getPackageInfo #7
[J] getPackageInfo #8
[J] getPackageInfo #9
[J] getPackageInfo #10
[J] toByteArray → orig
[J] toByteArray → orig
[J] toByteArray → orig
[J] toByteArray → orig
[J] toByteArray → orig
[native] open(\"...base.apk\")
[J] toByteArray → orig
...
关键观察点:
getPackageInfo被调 10+ 次 —— App 内部大量代码点读了 PackageInfo。每一次都被替换掉了signatures数组和signingInfo。toByteArray被调 6+ 次 —— App 也直接读字节。native open(\"base.apk\")7 次 —— App 确实在 native 层打开了 APK 文件。这些 open 的目的可能是校验 dex 完整性、读 assets、ART 加载 dex 等。但从最终结果反推, 这条 native 路径没有影响签名校验的通过 —— 因为纯 Java hook 就已经让 App 完全恢复。不排除 App 后续版本升级会把签名读取搬到 native。- 首页彻底恢复:
* 顶部 tab:推荐 / 乐馆 / 儿童 / 听书 / AI 作歌 / 会员
* 搜索栏显示热词 \"无人之岛 正在热搜\"
* \"打盹\" 卡片 + \"Daily30 每日 30 首\"
* \"Hi, 终于等到你\" 板块 —— Justin Bieber《Baby》、莫文蔚《阴天》、cici《把回忆封存给你》
* 底部播放器 + 5 个 tab 全部工作
* 点击具体歌曲页也能加载
4.10 为什么 QQ 音乐能救回来:整体判断
QQ 音乐的签名校验属于 \"客户端把签名字节塞进业务请求\" 这一档,具体特征:
| 维度 | QQ 音乐 |
|---|---|
| 客户端本地是否校验签名? | 不校验, 不会因签名不对而拒绝启动 / 崩溃自杀 / 弹窗告警 |
| 签名读取路径 | Java 层 PackageManager.getPackageInfo / SigningInfo |
| 是否存在 native 层直接读 APK? | 有 open(\"base.apk\"), 用途未定, 但从 hook 结果看不参与签名校验 |
| 签名字节是否进入业务请求? | 是, 推测在 libTPCore-master.so 或 okhttp 层作为请求头 / 请求体字段的一部分 |
| 阻塞粒度 | 仅业务数据请求, 不阻塞启动、不阻塞 WNS 长连、不阻塞 SafetySdkProxy 上报 |
| 保护深度 | 单点校验, 服务端某处对比签名字节 / hash |
拿下 QQ 音乐一共就 4 个 hook 点:Signature.toByteArray + PackageManager.getPackageInfo 覆盖 signatures 数组 + 覆盖 signingInfo + SigningInfo.getApkContentsSigners。校验被外包给了服务端,客户端只是个字节搬运工,对我们来说反而是个单点故障 —— 只要客户端读到的字节是原版,服务端就认。这种设计对腾讯的好处是校验规则灰度更新不用发新版本,对我们的好处是需要覆盖的 API 面很小。
4.11 限制与后续方向
已知的两个限制。
微信 / QQ 登录会失败。登录 SDK 会向 QQ 或微信客户端校验回调签名,重打包的证书对不上。这跟这节讲的服务端签名校验不是一回事,不展开。
hook 不常驻。App 每次重启都要重装 hook,Frida-only 方案就这样。要脱离 Frida 得走 smali 层补丁。下面简单说下持久化的做法。
持久化到 smali 补丁:
在 apktool 反编译出来的 smali 里找所有读签名的调用点,直接改字节码:
# 原始代码
invoke-virtual {v3, v4, v5}, Landroid/content/pm/PackageManager;->getPackageInfo(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;
move-result-object v6
# 改成先走原方法, 再调 utility 类替换 signatures
invoke-static {v6}, Lcom/hook/SigPatch;->fixSignatures(Landroid/content/pm/PackageInfo;)Landroid/content/pm/PackageInfo;
move-result-object v6
配套加一个 smali 工具类:
public class SigPatch {
private static final byte[] ORIG = /* 587 字节 Tencent 原证书 */;
private static final Signature ORIG_SIG = new Signature(ORIG);
public static PackageInfo fixSignatures(PackageInfo pi) {
if (pi == null) return pi;
try {
pi.signatures = new Signature[]{ORIG_SIG};
if (pi.signingInfo != null) {
Field fDet = SigningInfo.class.getDeclaredField(\"mSigningDetails\");
fDet.setAccessible(true);
Object det = fDet.get(pi.signingInfo);
Field fSig = det.getClass().getDeclaredField(\"mSignatures\");
fSig.setAccessible(true);
fSig.set(det, new Signature[]{ORIG_SIG});
}
} catch (Throwable t) {}
return pi;
}
}
编译成 dex 塞进 APK, classes2.dex 追加, 然后 apktool b + apksigner sign —— 成了不依赖 Frida 的补丁 APK。
这一步没做完,主要因为:调用点数量 10+ 处,全部改一遍是纯体力活;可能还有反射调用要覆盖;每改一次要 apktool b + apksigner sign + 安装 + 手工点,反馈周期 5+ 分钟。研究目的上 Frida hook 已经足够证明模型;商用发行才值得投入这一步。
4.12 QQ 音乐这一场的几条经验
回头看整个 4 小时,能提炼出来的东西不算多,但都是当时真的被坑到的地方。
抓包看到 code:0 不代表签名过了。服务端返回 code:0 只表示这个调用它收下了,不代表你的身份被认可。当时我就是被这个假象骗了一小时。判据永远是业务数据能不能正常渲染,看得见的东西才算数。
不要被 UI 局部可用误导。QQ 音乐的 \"我的\" 页面在未登录时看起来完整,登录按钮、空歌单、静态图标都在,我一度以为 \"大部分功能没问题\"。但那个页面根本不发网络请求,登录态数据都是本地渲染的。只有必须拉服务端才能出内容的页面才能当可用性判据。
系统级 http\_proxy 会对不遵守它的长连接产生副作用。QQ 音乐的 WNS 走 TCP、理论上不遵守 http\_proxy,但设置了 proxy 之后 WnsBinder 在 logcat 里疯狂重试,30ms 一次,跟拒服务的表象一模一样。关掉 proxy 才发现那个模块本来就正常,重试完全是 proxy 引发的次生反应。以后抓包看到某个模块 \"不正常地忙\",先怀疑 proxy。
Signature.toByteArray 只是叶子节点。要拦签名不是拦读方法,而是把 Signature 对象本身替换掉。Signature.$new(ORIG_JAVA) 之后覆盖 pi.signatures.value 和 signingInfo 里的私有字段,这样后续 App 用什么姿势读它 —— toByteArray、hashCode、反射 mSignature —— 拿到的都是原版。
spawn 和 attach 的差别在 QQ 音乐这个 case 上被放得很大。QQ 音乐启动期读一次签名然后缓存到字段,之后所有请求都用缓存。attach 模式装完 hook,toByteArray called → orig 一次都不出现 —— 因为它已经不再调 toByteArray 了。必须 spawn 拦启动期那 4 次调用。以后遇到 \"hook 装了但一次不 fire\" 的情况,先换 spawn 试。
frida -l script.js -q 会 detach。CLI 加载完脚本如果没有 -e 也没有 REPL,就立刻退出,session 关闭,hook 全部丢。这一条我踩过至少两次。老实用 Python time.sleep(1800) 保活,长期调试的时候写 24 小时也没关系。
如果说这一场留下了什么最重要的东西,就是那句 \"重打包之前是好的,重打包之后才坏\"。跑抓包、hook WNS、猜登录态、猜 UI Fragment ,那两个小时我在拿各种\"可能的原因\"填空。回到这句话,其实所有假设都能立刻排除 —— 唯一变的东西就是签名,其他都没动。以后开新 case 之前,这句话应该先在心里念一遍。
五、网易云音乐实战
目标是网易云音乐 9.2.80, com.netease.cloudmusic, AndroidManifest 声明版本 9.2.80.240314142012。
结果:没突破。做到了让 App 走到 Application init 之后, WeChat SDK / HMS / MMKV / DataBinding 全部完成初始化, 但主 Activity 始终起不来。比社区流传的作者版 \"splash 后 45 层 patch 仍崩\" 进了一小段,但离能用还差得远。
这一节是失败案例的诚实记录 —— 我们试过什么、每一步为什么走进死胡同、为什么最终判断这条路不通、成本值不值得。
这一节也是全书实证密度最高的一节:从最简重打包 500ms 内自杀开始,到把 libpoison 从内存 dump 出来做指令级反汇编,到跟两份独立逆向报告分别交叉对比,到把 15 条白名单 MD5 全部列出,到最后从 Toast 弹窗反向逆向到死胡同——6 轮迭代,每一轮都把上一轮的几条结论推翻或精化。这不是 \"作者太笨,先写错再改\",而是严肃逆向工作的真实形态。
如果你在读这一节之前跳过了第四节 QQ 音乐,建议先看那一节 —— QQ 音乐是同样问题的干净解,两节对照看能理解 \"加固到底加了什么\"。
5.1 目标概况与静态分析
基本信息:
apktool d -r -s ncm_9.2.80.apk -o ncm_decoded
grep -E ' **签名校验是双链路独立设计**:libpoison(native 直读 APK signature block) + libnesec(Java 层 `PackageInfo.signatures[0].toByteArray()`),**各自决策、各自杀进程、互不通信**。
>
> —— 3 天时间用 4 层 hook 分别定点后确认。libpoison 单独 patch 装上、libnesec 那条 Java 校验不管、跑起来 App 仍然按 libnesec 的 verdict 独立走杀链;反之亦然。两条链之间没有共享 verdict、没有全局变量互通。
网易在 native 层做了**一道完整的签名校验**、又在 Java 层**独立做了另一道完整的签名校验**。两道各自读签名、各自算 hash、各自决定杀不杀,中间**没有通信**(不共享 verdict、不写全局变量互通)。要真正绕过,**两条都要打** —— 只 patch 一条,另一条会独立触发杀进程。
上面这个判断跟之前几版的\"打手 + 裁判\"、\"libnesec 收集 libpoison verdict\" 的说法完全不同 —— 之前那些是从字符串证据 + 时序日志推断出来的,后来通过分别定点 hook 直接实证了两条链互不通信。本篇之前的机制描述以最新实证为准,前面几处说过\"裁判\"这个词的地方全部修正。
---
**链路 A —— libpoison(native 直读 APK signing block)**
libpoison 在 `serialdata` 相关函数被调用时(App 启动早期)跑一个签名校验函数(`0x7a....` 区),完整由 7 段组成:
| 段 | 地址范围 | 干什么 |
| --- | --- | --- |
| A | `0x7a....` | **native 直接 open/pread APK 文件**,找到 APK Signing Block,读出证书 chunk 字节 |
| B | 中间段 | 算 MD5(不走 libcrypto,自实现或经 JNI 调 Java `MessageDigest`) |
| C | `0x7ac00 – 0x7ace8` | 16 字节 MD5 → 32 字符 hex |
| D | `0x7ad08 – 0x7adc8` | 释放临时资源 |
| **E** | **`0x7add0 – 0x7af00`** | **15 条 `bl 0x10230`(PLT → libc.strcmp)白名单短路链** |
| F | `0x7af04 – 0x7af38` | 成功路径:stack canary 检查 + `ret` |
| G | `0x7af3c – 0x7af44` | **失败路径:`mov w0, wzr; bl exit(0); bl abort`**,函数尾巴**没有 ret**(必死) |
**关键:libpoison 的证书读取走 native 路径,不经过 Java** —— 所以第二章讲的 Java 层 hook(`Signature.toByteArray` / `PackageManager.getPackageInfo` / `SigningInfo` 全部替换)对这条链**完全没用**。跟 第 1.4 节 讲的小红书 `libsentry.so` 是同一类实现(都是 native 直读 APK signing block chunk)。
段 E 的白名单细节和 15 条 MD5 在 第 5.8 节 展开。这里只需要理解一件事:**libpoison 里签名校验的成败在段 G 一处出口**。改一条 `b 0x7af04` 让 15 条 strcmp 全不匹配时也跳成功路径就绕过了这一层,也就是 第 5.5 节 里的 Patch 1。
libpoison 是加密库(磁盘上 `.text` 是密文 + 伪造节头),要拿到上面这些偏移只能运行时 dump 已解密的内存镜像 —— 见 第 5.7 节 的 ptrace + process\\_vm\\_readv 方案。
---
**链路 B —— libnesec(Java 层独立校验,不走 native 直读)**
libnesec 里也**有**签名校验代码,而且不是\"裁判\"或\"上报\" —— 是**独立的、完整的另一次校验**,走 Java 层 API:
| 步骤 | 做什么 |
| --- | --- |
| 1 | libnesec native 桥 `MyJni.load` 在 `MyApplication.attachBaseContext` 阶段被调 |
| 2 | 内部通过 JNI 反射调 Java 的 `PackageManager.getPackageInfo(pkg, GET_SIGNATURES)` |
| 3 | 拿到 `signatures[0].toByteArray()` 字节 |
| 4 | 算 hash 与内部白名单比对 |
| 5 | 不匹配 → 调 Java 层 `NEDialog.showRiskMessage(0, \"\")` → 弹\"请前往官方渠道下载正版 APP\" toast → 主线程 ART 自然死 |
| 5' | 或触发 `raise(SIGTRAP=5)` + `tgkill(pid, tid, SIGTRAP)` 主线程自杀 |
**libnesec 相关字符串证据**(离线解密 dumped 内存镜像 4297 条字符串后确认):
0xca8f2 code0x0:detectDbg
0xcbd11 code0x1:checkSig-Fail ← 签名校验失败 verdict
0xcbee1 code0x2:detectEmulator
0xcb0a8 signatures ← Java 字段名(JNI 反射用)
0xcc693 bin/mt/apksignaturekillerplus ← MT 管理器签名杀手插件路径
0xccf46 bin/mt/apksignaturekillerplus/HookApplication ← 该插件的 Hook 类名
`code0x1:checkSig-Fail` 是 **libnesec 自己的 verdict code**(分类标签,不是从 libpoison 收来的),后两条是**明确的反反检测** —— libnesec 硬编码扫描 `apksignaturekillerplus`(MT 管理器出品的 Xposed 签名绕过工具)的特征路径和类名,检测到就直接标记签名校验失败。说明网易易盾团队清楚地知道 Java 层 hook 这条路径存在,主动做了工具级特征扫描。
libnesec 除了签名校验,还有另外两条独立线(同一个 wrapper 里跑):调试器 / frida 检测(`code0x0:detectDbg`,通过 SIGTRAP 信号探测 + `art::Dbg::GoActive` + `TracerPid`)和模拟器检测(`code0x2:detectEmulator`)。它们跟本章主线不直接相关,但共享同一套杀链(`raise(SIGTRAP)+tgkill`),任一 verdict 命中都杀。
---
**两条链完全独立、互不通信**
这是分别定点 hook 实证到的:
| 维度 | 链路 A(libpoison) | 链路 B(libnesec) |
| --- | --- | --- |
| 触发时机 | `libpoison.serialdata` 相关函数被调时(启动早期) | libnesec 在 `MyApplication.attachBaseContext` 被调时(跟 A 时序上大致并行) |
| 校验对象读取 | **native 直读 APK 文件 signing block** | **Java `getPackageInfo(pkg, GET_SIGNATURES) → signatures[0].toByteArray()`** |
| 失败处理 | `mov w0, wzr → bl exit → bl abort`,函数尾没 ret,**必死** | `NEDialog.showRiskMessage(\"请前往官方渠道下载正版 APP\")` toast → ART 自然死;或 `raise(SIGTRAP)+tgkill` |
| 是否通知对方 | 不通知。函数体只 3 条指令负责失败:`mov + exit + abort`,没写任何全局变量 | 不通知。libnesec 自己读自己算自己决定 |
| Frida 怎么打 | byte patch native 分支(第 5.5 节 Patch 1) | Java hook `PackageManager` / `Signature` / `NEDialog.showRiskMessage`(有 CheckJNI 陷阱,见下) |
**双链路机制图**:

网易云音乐签名校验双链路
**回答本节开头的问题**:
* **签名校验在网易云音乐里是双链路独立设计**。libpoison(native 直读)和 libnesec(Java 层 `getPackageInfo`)**各自完整地做一次**、各自决定杀不杀、中间不通信。
* **只 patch libpoison 一条链不够** —— libnesec 那条 Java 层校验不受 libpoison patch 影响,自己独立触发杀链。
* **我们 第 5.5 节 的 3 层 patch 只覆盖了 libpoison 那条**。让 App 能走到 Application init 完成、Splash 消失,是因为我们的 libc `exit / abort / exit_group` hook 把 libnesec 触发的杀链(`raise(SIGTRAP)+tgkill` 或 ART 自然死路径)**部分拦住了** —— 但拦住的代价是主线程停在校验后的某个位置,进入 splash 之后的 `NEDialog.showRiskMessage` 弹窗或 looper 静默,主 Activity 起不来。
* **要真正让 App 完全恢复,必须两条都打** —— libpoison 的 byte patch + libnesec 的 Java 层 hook(覆盖 `PackageManager.getPackageInfo` / `Signature.toByteArray` / `NEDialog.showRiskMessage`)。第 5.5 节 只做了前一半。
---
**CheckJNI 陷阱(实证过的坑)**
这里有一条**必须提醒**的陷阱:**不能简单地在 Java 层全局 hook `Signature.toByteArray` 返回原版字节**。
原因是业务代码里也有 native 代码会通过 CheckJNI 读 `PackageInfo` —— 如果 Java 层被 hook 返回了原版字节、native 层通过其他路径看到的是重签字节,**ART CheckJNI 检测到两次读取结果不一致,会直接 abort 杀进程**。
这就是为什么 QQ 音乐那套\"`Signature` 对象整体替换\"方案(第四章讲的完整可复用打法)**在网易云音乐上不能照搬** —— 会直接触发 CheckJNI 报错。要打 libnesec 那条 Java 校验,需要更精细的方案:**只在 libnesec 的 JNI 调用点上返回原版**,其他调用点不干预。具体路径见 第 5.11 节 尝试 8。
### 5.2 最简重打包:500ms 内自杀
跟 QQ 音乐一样先走最简路径:
apktool b ncm_decoded -o ncm_repack.apk
apksigner sign --ks my.keystore --ks-pass pass:123456 --out ncm_signed.apk ncm_repack.apk
adb install -r ncm_signed.apk
adb shell monkey -p com.netease.cloudmusic 1
装机现象(logcat):
[0.0s] I ActivityManager: Start proc ... for activity com.netease.cloudmusic/.LaunchActivity
[0.3s] D POISON : APK Signature:d2a93e35348b7c414dddf5caf64108ad
[0.3s] F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid ... (InitTaskSchedul)
[0.4s] I ActivityManager: Process com.netease.cloudmusic (pid ...) has died
App 500ms 内就自杀, splash 都没出来。跟 QQ 音乐 \"首页错误但 App 存活\" 完全不是一个量级。
`d2a93e35348b7c414dddf5caf64108ad` 是我们自签 keystore cert 的 MD5,原版 keystore cert MD5 是 `da6b069da1e2982db3e386233f68d76d`。看到 POISON log 直接明文打出 hash 说明加固的校验函数其实做了这件事:读 APK cert → 算 MD5 → 打 log。这条 log 在后面的实证中是我们最重要的锚点。
Nesec 加固层的行为可以先粗略概括:
1. `AndroidManifest` 里 `application:name=com.netease.nis.wrapper.MyApplication` 起效,加固层第一时间接管。
2. 加固层调 native 校验 —— `libpoison.so` 里某个函数检测签名 hash → 不匹配就 exit 或触发 SEGV。
3. `Process.setExceptionHandler` 兜底:任何 native 崩溃或未处理异常,最后一步 `System.exit(0)`。
跟 QQ 音乐差别开始显现:
* QQ 音乐的签名校验是 \"服务端拒服务\" —— App 能活着,只是拿不到数据。
* 网易云音乐的签名校验是 \"客户端自杀\" —— App 活不过 1 秒。
### 5.3 起点:作者版 45 层 patch 的教训
社区里有一份作者写的 writeup,长期公开在若干技术论坛,记录了 3 天累计做的 45+ 层 Frida patch 才让 App **勉强看到 splash 界面**。作者版脚本约 1491 行,覆盖:
* SIGSEGV / SIGBUS / SIGABRT 各种 signal 拦截
* `dlopen` 全 hook 用于识别加固各 so 加载时机
* `Process.setExceptionHandler` 覆盖
* `System.exit` / `Runtime.exit` / `_Exit` / `exit_group` / `abort` / `raise` / `tgkill` 全套 hook
* libc `HandleUsingDestroyedMutex` byte patch
* libpoison 内 30+ 处 byte patch
* Java `MessageDigest.digest` / `Signature.toByteArray` / `PackageManager.getPackageInfo` 三合一
* 反射调用点全覆盖
* Tinker `PatchApplication` reflect 检测点绕过
结果: splash 能显示了,但**主 Activity 起不来, App 停在 splash 之后**。
我看完这份 writeup 的第一反应是:45 层不像是最优解。每加一层 patch 缓解一点点,又引入新的次生问题,堆到 45 层根本不可能反过来定位主要矛盾。这种叠加式方案得从头重来一遍,找到真正在支配这个 App 生死的那个点。
### 5.4 用 \"删繁就简\" 定位主要矛盾
**方法**:不从 0 加 patch,从作者版全套开始**一层一层往外拆** —— 看拆掉哪些 patch App 反而活得更久。
**实验设计**:把 `bypass_nesec.js` 拆成能独立开关的 15 个 patch group,一组一个开关,通过 spawn Frida 后往 script 里传 rpc 参数控制哪些开、哪些关。
**关键发现——最大的一个**:
作者版脚本里的 **`Process.setExceptionHandler` 全局屏蔽是主要破坏源**。
// 作者原版, 简化后
Java.perform(function () {
// 无条件屏蔽 Application 的 setExceptionHandler, 阻止任何自杀路径
someObj.setExceptionHandler.implementation = function () {
return null; // ← 完全不设 handler
};
});
问题:**ART 的空指针 / 越界访问是通过 SIGSEGV + Application uncaught exception handler 走 \"隐式 null check\" 流程的**。ART JIT 出来的代码在很多地方省略了显式 null check, 遇到 null 时靠 CPU 触发 SIGSEGV, signal handler 里根据 fault\\_addr 判断这是隐式 null check → 抛 Java NullPointerException → 上抛到 Application 层的 exceptionHandler。
作者的 hook 把这个 handler 拦掉后, **所有本来会被正常处理的隐式 null check 都变成了未处理的 SIGSEGV** —— App 立即崩溃。
**证据**:
* 打开 `setExceptionHandler` hook:App 崩溃 `Fatal signal 11 SEGV` 在 libnative 侧,看似像 Nesec 校验失败。
* 关掉 `setExceptionHandler` hook:libnesec 在 App 启动后 **+7.4 秒**才被加载起来, Application init 走得更完整、走到那些依赖 libnesec 的代码点才 dlopen —— 这本身就是 App 存活的直接证据。
**教训**:Application 全局的 signal handler 不能无条件屏蔽。如果一定要 hook,得在里面判断当前 signal 是不是要拦的、其他一律放行。
### 5.5 精简后的 3 层核心 patch

网易云音乐三层 patch 结构
拆掉 `setExceptionHandler` 后,很多下游 patch 变得不必要 —— 它们本来是用来 \"压制\" `setExceptionHandler` 引发的次生崩溃。逐个删掉验证仍能启动到相同深度,最后剩下 3 层真正必要的:
**Patch 1:libpoison 内的签名校验分支跳转, byte patch**
作者定位到的关键点是 `libpoison.so + 0x7af3c` 附近有一段签名校验后的分支判断。原始指令:
0x7af3c: mov w0, wzr ; w0 = 0, 校验失败
0x7af40: ...
0x7af04: ... ; 校验通过的分支入口
改成无条件跳到 `0x7af04`:
var m = Process.findModuleByName('libpoison.so');
Memory.patchCode(m.base.add(0x7af3c), 4, function (w) {
// b 0x7af04
// 相对偏移 = (0x7af04 - 0x7af3c) / 4 = -14 = 0xFFFFFFF2
// 编码 0x14000000 | (imm26 & 0x3FFFFFF) = 0x14000000 | 0x3FFFFF2 = 0x17FFFFF2
w.writeU32(0x17FFFFF2);
});
这条 patch 是必须的 —— 去掉之后加固层直接拒绝往下走。
**Patch 2:libc `HandleUsingDestroyedMutex` 覆盖为 \"返回 0\"**
Android bionic libc 在 pthread\\_mutex\\_lock 时会检查 mutex 是否已被 destroy,检测到后调 `HandleUsingDestroyedMutex(pthread_mutex_t*)` —— 里面调 `__pthread_abort_msg` 打 fatal log 然后 `abort()`。
加固层某些地方会故意 destroy mutex 再 lock, 作为一种校验手段, 触发这个路径就自杀。改成直接返回 0:
var libc = Process.findModuleByName('libc.so');
libc.enumerateSymbols().forEach(function (s) {
if (s.name.indexOf('HandleUsingDestroyedMutex') >= 0) {
Memory.patchCode(s.address, 8, function (w) {
w.writeU32(0x2a1f03e0); // mov w0, wzr
w.writeU32(0xd65f03c0); // ret
});
}
});
**Patch 3:libc `exit` / `_exit` / `_Exit` / `abort` 拦截**
加固层某些 native 校验失败时会调 `exit(0)` 让 App 静悄悄退出, 比 abort 更隐蔽。拦掉:
['exit', '_exit', 'exit_group', '_Exit', 'abort'].forEach(function (name) {
var p = Module.findExportByName('libc.so', name);
if (!p) return;
Interceptor.attach(p, {
onEnter: function (args) {
var lr = this.returnAddress;
var lrMod = Process.findModuleByAddress(lr);
send('libc.' + name + ' from ' + (lrMod ? lrMod.name : '?')
+ ' arg=' + args[0]);
this.skip = true;
},
onLeave: function (retval) {
if (this.skip) retval.replace(ptr(0));
}
});
});
// 私有 __exit / __exit_group, enumerateSymbols 找
libc.enumerateSymbols().forEach(function (s) {
if (s.name === '__exit' || s.name === '__exit_group') {
Interceptor.replace(s.address, new NativeCallback(function (a) {
send('SKIPPED ' + s.name + '(' + a + ')');
}, 'void', ['int']));
}
});
外加 Java 层三兄弟:
Java.perform(function () {
Java.use('java.lang.System').exit.implementation = function (c) {
send('[skip] System.exit(' + c + ')');
};
Java.use('java.lang.Runtime').exit.implementation = function (c) {
send('[skip] Runtime.exit(' + c + ')');
};
Java.use('android.os.Process').killProcess.implementation = function (p) {
send('[skip] Process.killProcess(' + p + ')');
};
});
加载方式: Frida spawn + `dev.enable_spawn_gating`:
import frida, time
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
device.enable_spawn_gating() # zygote 后续 spawn 出的新 App 进程也 pause 等我们
pid = device.spawn(['com.netease.cloudmusic'])
session = device.attach(pid)
scr = session.create_script(open('hybrid_minimal.js').read())
scr.on('message', lambda m, _: print(m))
scr.load()
device.resume(pid)
time.sleep(1800) # 保活
`enable_spawn_gating` 是关键 —— 网易云音乐有些场景会通过 `am start` 或 `bindService` 让 ActivityManager 拉起独立子进程做一些 native 初始化,`spawn_gating` 让这些新 zygote fork 出来的进程都 pause 等 hook 装好,而不是让它们独立跑起来先自杀。注意这条只对 zygote spawn 出来的进程有效,App 进程内部 `fork()` 出来的 subprocess 拦不到,需要另外单独想办法。
---
**开始下一节前先给这 3 层 patch 定个位**:对照 第 5.1.5 节 的双链路图,这 3 层 + Java 兜底\\*\\*全部落在链路 A(libpoison native 直读)\\*\\*上 —— Patch 1 打 libpoison 的失败出口、Patch 2/3 拦 libpoison 走 libc 出去的杀链、Java 层三兄弟兜底也是拦 libpoison 触发的 Java 侧 exit。**链路 B(libnesec Java 层 getPackageInfo + NEDialog.showRiskMessage)一层都没打**。
这不是遗漏,是当时对机制的认识就只到\"libpoison 那条\"这一步 —— libnesec 那条 Java 校验的存在是后来通过分别定点 hook 实证才浮出来的(详见 第 5.1.5 节 的迭代记录)。链路 B 的对称 3 层 hook 蓝图放在 第 5.11 节 尝试 8 讲,配图 `fig_5_11_chain_b_hooks`。
带着这个\"只打了一半\"的认知去看下一节 第 5.6 节 的结果 —— 主 Activity 起不来不是神秘失败,而是链路 B 那条校验一直没受干扰在跑,`NEDialog.showRiskMessage` 弹到 quota 顶或者主线程停在 signal 等待,这是最主要的嫌疑。
### 5.6 结果:Application 完成, 主 Activity 卡住
能观察到的:
[+0.0s] Frida spawned pid=8123
[+0.1s] [M] loaded
[+0.1s] [M] ★ dlopen listener installed
[+0.5s] [M] ★★★★★ libpoison 0x7af3c patched
[+0.6s] [M] ★ HandleUsingDestroyedMutex patched @ 0x...
[+1.2s] Java exit hooks ALL installed
[+2.0s] logcat: I CloudMusicApp: WeChat SDK register OK
[+2.5s] logcat: I HMS: HuaweiMobileServices init done
[+3.1s] logcat: I MMKV: mmkv root=/data/data/com.netease.cloudmusic/files/mmkv
[+3.5s] logcat: I DataBinding: initFromSources...
[+4.2s] logcat: I CloudMusicApp: Application.onCreate done
[+5.8s] logcat: I ActivityTaskManager: START u0 {act=android.intent.action.MAIN ...}
[+6.0s] logcat: (Splash Activity 创建)
[+7.4s] [M] libnesec.so dlopen'd ← 加固主壳终于加载
[+8.0s] logcat: (Splash 消失)
[+9.0s] logcat: (静默)
[+15.0s] logcat: (静默)
跟作者版比:
* 作者版:splash 都不出,最多闪一下就崩。
* 我们版:Application 走完完整初始化, WeChat / HMS / MMKV / DataBinding, Splash 出来、Splash 消失。**离主 Activity 只差一步**。
离能用差什么:
* Splash 消失后应该 startActivity 到 MainActivity,但**没起来**。
* logcat 里没有 error / crash,就是静默停在那。
* App 进程仍然存活,但没有 activity。
* 用户看到的是黑屏 / 白屏 / 直接回到桌面。
**T+7.4s libnesec 那一行的正确解读**(跟之前几版的说法相比要更正):
之前版本说这条 log 是\"libnesec 后校验触发的时机\"、\"主 Activity 起不来的证据\"。用分别定点 hook 实证后,这个说法是错的 —— **libnesec 的签名校验发生在 `MyApplication.attachBaseContext` 阶段**(跟 libpoison 时序上大致并行,都在启动早期),不是 +7.4s。
+7.4s 是 libnesec 的**另一个动作** —— `libnesec.so` 完整 dlopen 挂到进程 module 表的时机(我们的 dlopen 监听器看到的时间点)。签名校验相关代码之前已经通过 native 桥 `MyJni.load` 执行过一次了,dlopen 完成只是让整个 so 完全可见(多线程 anti-debug 上位、字符串解密表就位、SIGTRAP 处理器注册)。
那主 Activity 起不来的真实原因?第 5.1.5 节 讲的 libnesec Java 层校验路径 —— `MyJni.load` → JNI 反射 `getPackageInfo` → 白名单比对失败 → `NEDialog.showRiskMessage` 弹 toast → 主线程停下。这条 Java 层校验 第 5.5 节 完全没管,杀链被 libc exit hook 部分拦住导致进程静默留在原地。具体到 hook 位置的候选路径见 第 5.11 节 尝试 8。
到这一步为止,我的进度确实是 \"作者版之后往前挪了一段\"。但往前挪的 \"一段\" 到底有多远、加固里面到底是什么、离能用还差什么,靠 45 层 patch 那种加/减开关的方式说不清楚。要说清楚只能换工具,把 libpoison 从内存里 dump 出来做指令级反汇编。
### 5.7 深挖 libpoison:自己 dump + 自己反汇编
上面三层 patch 让 Application init 走完了,但停在 Splash 之后。这时候一个诱惑很强 —— 继续加第 4 层、第 5 层 patch,试着 \"再挪一段\"。作者版走的就是这条路,45 层之后停在 splash 之前。
我停了一下,反过来问自己:前面这些判断真的站得住吗?
\"libpoison + 0x7af3c 是签名校验\"、\"改成无条件跳成功就过关\"、\"MD5 是 inline 实现\",这些全是从别人的 writeup 里抄来的结论,我自己一条都没独立验证过。继续加 patch 相当于在别人的二手结论上叠越来越多的假设,越叠越脆,最后到底哪儿出问题谁也说不清。
要让判断落到字节级,得自己把 libpoison 从内存里 dump 出来,自己反汇编,自己对着字节看。下面这一节就是干这件事 —— 从 \"抄别人的偏移\" 换成 \"自己看每一条指令做什么\"。
#### 磁盘上的 libpoison.so 是加密的
先看磁盘版能不能直接反汇编:
r2 -a arm -b 64 lib/arm64-v8a/libpoison.so
[0x00000000]> s 0x7add8; pd 10
全是 `invalid` —— 磁盘代码段是密文。用 `readelf -S` 看:
[Nr] Name Type Address Offset Size
[ 0] NULL 0000000000000000 00000000 0000000000000000
[ 1] .note.gnu.proc PROGBITS 0000000000000190 00000190 00000000000afe70 ← 720 KB
[ 5] .note.gnu.ident PROGBITS 00000000000e0000 000b0000 0000000000000008
[ 6] .note.gnu.bu... NOTE 00000000000e0008 000b0008 0000000000000024
[ 7] .text PROGBITS 00000000000e0030 000b0030 0000000000000010 ← 16 字节!
[ 8] .note.gnu.text PROGBITS 00000000000e0040 000b0040 0000000000010638
代码段藏在伪装的 section 名后面: `.note.gnu.proc` 720 KB + `.note.gnu.text` 67 KB 才是真代码,`.text` 段故意只留 16 字节。`nm` 和 `objdump` 默认看 `.text` 段就什么都找不到 —— 这是个针对自动化分析工具的小心机。
#### 用 ptrace + process\\_vm\\_readv 从运行时内存 dump
frida spawn 这条路不通 —— agent 在 libpoison 加载前就被 kill,来不及 dump。改用底层方案 —— **root + ptrace**。完整可编译 C 代码 115 行,能直接 clang 交叉编译成 aarch64 static binary。
**扫读指引**:下面 115 行第一次读只看最后 `main()` 里那个 `while (time(NULL) - start < timeout)` 循环就够 —— polling PID → 判 libpoison 是否加载 → 调 `do_dump`。上面三个 helper `find_pid` / `has_libpoison` / `do_dump` 都是实现细节,拷走能直接编译,不用逐行读。
// libpoison_waitdump.c
// 用法: libpoison_waitdump
include
include
include
include
include
include
include
include
include
include
include
// 扫 /proc 找 cmdline 包含 pkg 的 PID
static pid_t find_pid(const char pkg) {
DIR d = opendir(\"/proc\"); if (!d) return -1;
struct dirent *e; pid_t found = -1;
while ((e = readdir(d))) {
if (e->d_type != DT_DIR) continue;
pid_t pid = atoi(e->d_name);
if (pid <= 0) continue;
char path[64]; snprintf(path, sizeof(path), \"/proc/%d/cmdline\", pid);
int fd = open(path, O_RDONLY); if (fd < 0) continue;
char buf[256] = {0}; read(fd, buf, sizeof(buf)-1); close(fd);
if (strstr(buf, pkg)) { found = pid; break; }
}
closedir(d);
return found;
}
// 判 maps 里有没有 libpoison.so
static int has_libpoison(pid_t pid) {
char path[64]; snprintf(path, sizeof(path), \"/proc/%d/maps\", pid);
FILE *f = fopen(path, \"r\"); if (!f) return 0;
char line[1024]; int hit = 0;
while (fgets(line, sizeof(line), f)) {
if (strstr(line, \"libpoison.so\")) { hit = 1; break; }
}
fclose(f);
return hit;
}
// ATTACH + 逐段拷贝
static int do_dump(pid_t pid, const char *outdir) {
if (ptrace(PTRACE_ATTACH, pid, 0, 0) < 0) {
fprintf(stderr, \"PTRACE_ATTACH(%d) 失败: %s\
\", pid, strerror(errno));
return 2;
}
int status; waitpid(pid, &status, 0);
fprintf(stderr, \"[+] ATTACHED PID=%d status=0x%x\
\", pid, status);
char maps_path[64]; snprintf(maps_path, sizeof(maps_path), \"/proc/%d/maps\", pid);
FILE *fm = fopen(maps_path, \"r\");
if (!fm) { ptrace(PTRACE_DETACH, pid, 0, 0); return 4; }
char line[1024]; int seg_idx = 0;
while (fgets(line, sizeof(line), fm)) {
const char *which = NULL;
if (strstr(line, \"libpoison.so\")) which = \"libpoison\";
else if (strstr(line, \"libnesec.so\")) which = \"libnesec\";
else continue;
unsigned long start, end; char perm[8];
if (sscanf(line, \"%lx-%lx %7s\", &start, &end, perm) != 3) continue;
size_t size = end - start;
char out[256];
snprintf(out, sizeof(out), \"%s/%s_%d_%lx_%s.bin\", outdir, which, seg_idx, start, perm);
FILE *fo = fopen(out, \"wb\");
if (!fo) { seg_idx++; continue; }
char buf = malloc(0x10000); size_t total = 0;
for (size_t off = 0; off < size; off += 0x10000) {
size_t chunk = (size - off > 0x10000) ? 0x10000 : (size - off);
struct iovec local = { buf, chunk };
struct iovec remote = { (void)(start + off), chunk };
ssize_t n = process_vm_readv(pid, &local, 1, &remote, 1, 0);
if (n <= 0) break;
fwrite(buf, 1, n, fo); total += n;
if ((size_t)n < chunk) break;
}
free(buf); fclose(fo);
fprintf(stderr, \" [+] %s seg #%d start=0x%lx size=0x%zx read=0x%zx perm=%s\
\",
which, seg_idx, start, size, total, perm);
seg_idx++;
}
fclose(fm);
ptrace(PTRACE_DETACH, pid, 0, 0);
fprintf(stderr, \"[+] DETACHED %d segments\
\", seg_idx);
return 0;
}
int main(int argc, char argv) {
if (argc < 3) {
fprintf(stderr, \"用法: %s
\", argv[0]);
return 1;
}
const char
int timeout = argc > 3 ? atoi(argv[3]) : 60;
time_t start = time(NULL); int iters = 0;
while (time(NULL) - start < timeout) {
iters++;
pid_t pid = find_pid(pkg);
if (pid < 0) continue;
if (!has_libpoison(pid)) continue;
fprintf(stderr, \"[+] HIT iter=%d PID=%d\
\", iters, pid);
return do_dump(pid, outdir);
}
fprintf(stderr, \"[!] TIMEOUT after %d iters\
\", iters);
return 5;
}
编译成 static aarch64 binary,推到设备 `/data/local/tmp/`,SELinux 切 permissive 后运行:
CLANG=~/Library/Android/sdk/ndk/22.1.7171670/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android30-clang
$CLANG -static -O2 -o libpoison_waitdump libpoison_waitdump.c
adb push libpoison_waitdump /data/local/tmp/
adb shell \"su -c 'chmod 755 /data/local/tmp/libpoison_waitdump; setenforce 0'\"
adb shell \"am force-stop com.netease.cloudmusic\"
adb shell \"su -c '/data/local/tmp/libpoison_waitdump com.netease.cloudmusic /sdcard/wycm_dump 30' &\"
adb shell \"monkey -p com.netease.cloudmusic -c android.intent.category.LAUNCHER 1\"
第一次 dump 结果:
[+] HIT iter=325 PID=11780
[+] ATTACHED PID=11780 status=0x137f
[+] libpoison seg #0 start=0x7855023000 size=0xb0000 read=0xb0000 perm=r-xp
[+] libpoison seg #1 start=0x7855103000 size=0xd000 read=0xd000 perm=r-xp
[+] libpoison seg #2 start=0x7855110000 size=0x2000 read=0x2000 perm=r-xp
[+] libpoison seg #3 start=0x7855112000 size=0x2000 read=0x2000 perm=r-xp
[+] libpoison seg #4 start=0x7855117000 size=0x14000 read=0x14000 perm=rw-p
[+] libnesec seg #5-12 ...
[+] DETACHED 13 segments
**13 个段 dump 出来了**,libpoison 5 段共 870 KB,libnesec 8 段共 1 MB。全部 process 稳定活着,直到我们 DETACH 之后它自己 SIGSEGV 死。
#### 但重打包版 dump 出来的 libpoison 大部分是密文
用 capstone 数每段的 \"合法 ARM64 指令率\":
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM
md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
for f in sorted(os.listdir(\"dump/\")):
if not f.startswith(\"libpoison\"): continue
data = open(f\"dump/{f}\", \"rb\").read()
valid = sum(1 for _ in md.disasm(data, 0))
total = len(data) // 4
print(f\" {f}: {len(data)} bytes, 合法 ARM64 {valid}/{total} ({100.0*valid/max(total,1):.1f}%)\")
结果:
libpoison_0_r-xp.bin (720 KB): 合法 ARM64 0/180224 (0.0%)
libpoison_1_r-xp.bin ( 53 KB): 合法 ARM64 1/13312 (0.0%)
libpoison_2_r-xp.bin ( 8 KB): 合法 ARM64 959/2048 (46.8%) ← 唯一解密的一片
libpoison_3_r-xp.bin ( 8 KB): 合法 ARM64 2/2048 (0.1%)
libpoison_4_rw-p.bin ( 81 KB): 合法 ARM64 0/20480 (0.0%)
**只有 seg #2 那 8 KB 是 47% 合法 ARM64,其他 800 KB 全是密文**。这个初读第一反应是 \"函数级懒解密 —— 跑过的页才解密\"。但间隔 200 ms 再 dump 一次,两次结果毫秒级一致 —— 排除了 \"懒解密\" 假设。
后来才搞清楚:重打包版 libpoison 走到 8 KB 就校验 fail 了、exit(0),后面 700 KB 的代码根本没机会执行,自然也就没被解密。要拿完整解密镜像,必须让 process 存活到 libpoison 校验通过。
#### 装原版 App,拿完整 992 KB 解密镜像
重打包版拿不到完整镜像 —— 但装**原版** cloudmusic 就能过校验。让 libpoison 全程留在内存,frida 从容 dump:
// dump_libpoison.js
// 用 frida 在原版进程里 dump —— libpoison 从头到尾都是解密的
'use strict';
var OUTPATH = '/data/data/com.netease.cloudmusic/files/libpoison_dumped.bin';
var CHUNK = 0x10000;
function dump() {
var m = Process.findModuleByName('libpoison.so');
if (!m) return false;
console.log(TAG + ' libpoison base=' + m.base + ' size=0x' + m.size.toString(16));
var f = new File(OUTPATH, 'wb');
var off = 0, okBytes = 0, zeroBytes = 0;
while (off < m.size) {
var n = Math.min(CHUNK, m.size - off);
var buf = null;
try { buf = m.base.add(off).readByteArray(n); } catch (e) {}
if (buf) { f.write(buf); okBytes += n; }
else { f.write(new Uint8Array(n).buffer); zeroBytes += n; }
off += n;
}
f.close();
return true;
}
setInterval(function () { if (dump()) clearInterval(iv); }, 50);
拉出来 dump 一看:
libpoison base=0x7947cbb000 size=0x108000
dumped 992KB ok / 64KB zeros
**992 KB 全部解密**,整个 `.text` + `.rodata` + `.data` 都是 ARM64 明文和 ASCII 字符串。r2 直接加载:
r2 -a arm -b 64 libpoison_dumped.bin
[0x00000000]> px 4 @ 0x7af40
20 56 fe 97 ; 0x97 高位, 是 bl 指令(负偏移),校验失败自杀点
[0x00000000]> s 0x7af40; pd 4
0x00007af40 bl #0x107c0 ; ★ 失败: bl exit(0)
0x00007af44 bl #0x10940 ; abort 兜底
0x7af40 处一个 `bl exit(0)` —— 这就是签名校验失败的真正杀点。
### 5.8 15 条白名单 MD5:签名校验不是单点是短路链

libpoison 15 条 strcmp 白名单短路链
有了完整镜像,反汇编 `0x7add0` 到 `0x7af00` 这一段(签名校验函数的核心),看到的不是 \"一次 MD5 比对\",而是**一条 15 条 strcmp 白名单 chain**:
0x7add8: adrp x1, 0x7e000 ; x1 = 0x7e000+0x7d7 = 第 1 个白名单字符串
0x7addc: add x1, x1, #0x7d7
0x7ade0: add x0, sp, #0x30 ; x0 = 算出的 hex_buf (32 字符 MD5 hex)
0x7ade4: bl 0x10230 ; strcmp(hex_buf, \"d6520186...\")
0x7ade8: cbz w0, 0x7af04 ; w0==0(相等) → 成功路径
0x7adec: adrp x1, 0x7e000 ; x1 = +0x7f8 = \"da6b069d...\" (★ 第 2 条 = 线上正式签)
0x7adf0: add x1, x1, #0x7f8
0x7adf4: add x0, sp, #0x30
0x7adf8: bl 0x10230
0x7adfc: cbz w0, 0x7af04
... 第 3~14 条同款 chain, 每个块 16 字节 ...
0x7aef0: adrp x1, 0x7e000 ; x1 = +0x9a5 = \"ac06dbfe...\" (第 15 条)
0x7aef4: add x1, x1, #0x9a5
0x7aef8: add x0, sp, #0x30
0x7aefc: bl 0x10230
0x7af00: cbnz w0, 0x7af3c ; ★ 最后一条用 cbnz, 不等就跳失败
; 前 14 条都是 cbz, 相等就跳成功
15 个 `bl 0x10230`, 每个后面跟一个 `cbz w0, 0x7af04` 或最后一个 `cbnz w0, 0x7af3c`。前 14 条相等就 fall through 到成功;第 15 条相等则 fall through 到成功,不等才跳失败。
**为什么第 15 条用 `cbnz` 而非 `cbz`**:编译器的 fall-through 优化。如果第 15 条相等,自然 fall through 到 0x7af04 成功;如果第 15 条也不等,跳 0x7af3c 失败。这样省了最后一条无条件跳转。
`bl 0x10230` 的 PLT slot 验证:
0x10230: adrp x16, 0xbb000 ; PLT 跳板模板
0x10234: ldr x17, [x16, 0x6d8] ; .got.plt[0xbb6d8] ← linker 填 libc.strcmp
0x10238: add x16, x16, 0x6d8
0x1023c: br x17 ; → libc.so 的 strcmp
**修正**:比对不是 inline 实现,也不是 dlsym 自查找 —— **就是标准 ARM64 PLT slot 调用 libc.strcmp**。之前的 hook 之所以没命中,是**hook 装得太晚**,libpoison 加载即在 +0.05~0.1s 跑完校验,`Process.findModuleByName(\"libpoison.so\")` 在 attach 早期返回 null,过滤逻辑永远 false。
#### 提取 15 条白名单
`.rodata` 区从 `0x7e7d7` 起,每条 33 字节间距 = 32 hex 字符 + null:
import re
d = open('libpoison_dumped.bin','rb').read()
for m in re.finditer(rb'([0-9a-f]{32})\x00', d):
off = m.start()
if 0x7e000 <= off < 0x7f100:
print('@0x%x: %s' % (off, m.group(1).decode()))
输出下面这张表。**这张表怎么读**:15 条里只有第 2 条 `da6b` 对应你 apktool 拆出来的 `META-INF/NETEASE.RSA`,是当前线上正式签;其余 14 条 keystore 全在网易内部(历史正式签 / 渠道版 / 海外版 / 灰度包),MD5 值来自 dumped `.rodata` 但 keystore 本身对应不到外部实体 —— 攻击面价值 0,情报面价值是\"知道这道墙上有 15 个孔\"。
| # | 偏移 | MD5 |
| --- | --- | --- |
| 01 | 0x7e7d7 | `d65201860dfc37b60e245b772ee8b213` |
| **02** | **0x7e7f8** | **`da6b069da1e2982db3e386233f68d76d`** ← 当前线上正式签(=`META-INF/NETEASE.RSA`) |
| 03 | 0x7e819 | `efba65f1032493581f79e1ecc35c496b` |
| 04 | 0x7e83a | `67e142a5bbd766e57be8d0c9c2bee892` |
| 05 | 0x7e85b | `031671d37661be7f013d3e0f0d4aa263` |
| 06 | 0x7e87c | `0cdeef1a210d4629793041eb7fe019da` |
| 07 | 0x7e89d | `55b98e174de0d5f5f85cb5922f2a555b` |
| 08 | 0x7e8be | `c09a27d9eb8f9a72f12ecbffbd4a7119` |
| 09 | 0x7e8df | `86ad6a361d15de3abd29cfe709d255db` |
| 10 | 0x7e900 | `8b2424d7a0e8d302b9fc42b70b1a17e0` |
| 11 | 0x7e921 | `69f677e59e1da17c608f9b83304d3191` |
| 12 | 0x7e942 | `525b812bd2c861e5ab86214956c920f3` |
| 13 | 0x7e963 | `21a242ab59951da4e47dad43823a4542` |
| 14 | 0x7e984 | `8ddb342f2da5408402d7568af21e29f9` |
| 15 | 0x7e9a5 | `ac06dbfeb05f19eac37c343b03230d07` |
**15 条白名单透露出网易的签名管理策略**:当前线上正式签只是其中之一。其余 14 条最可能对应:
* **历史正式签名**, 早期版本 keystore、迁移过 key 的情况
* **渠道版**, 华为/小米/OPPO/vivo 等各应用市场的特殊签名渠道
* **海外版**, Google Play 上的国际版可能用不同 key
* **内部测试 / 灰度签名**, dev、staging
* **子品牌 / 联合签名**, cloudmusic 旗下其他 app 共用一份 libpoison?
这是个**逆向情报**层面的发现 —— 任何一个白名单 MD5 对应的 keystore 重签出来的 APK,都能过这道校验。但 14 条非线上签证书 keystore 都掌握在网易内部, **不构成可用的攻击面** —— 不可能社工拿到内部 keystore。这个发现的工程价值是 \"知道有这件事\",而不是 \"能用这件事\"。
#### 修正 MD5 实现路径
`.rodata` 里还有一个关键字符串,在 0x821a0:
MD5 part of OpenSSL 1.0.2u 20 Dec 2019
AES part of OpenSSL 1.0.2u 20 Dec 2019
**libpoison 静态链接了 OpenSSL 1.0.2u 全套**, `MD5_Init/Update/Final/Transform`、AES 全套都带进来,符号在 dynsym 里被 strip,但版本字符串没被剥干净。三条反向证据把 \"两种可能\" 钉成第三种:
* Java MessageDigest:`MessageDigest`/`getInstance`/`java/security` 全 NOT FOUND
* 散写在 .text 的 inline MD5:4 个 MD5 init 魔数 `0x67452301 / 0xefcdab89 / 0x98badcfe / 0x10325476` **0 处 raw bytes**, `movz w?,#0x2301 + movk w?,#0x6745,LSL #16` **0 对**
* dlopen libcrypto:dlopen 目标 SO 只有 `libandroid.so`,没有 `libcrypto.so`
* ✅ **OpenSSL 1.0.2u 整套静态链接进 libpoison.text**:`.rodata 0x821a0` 处版本字符串直接出卖了
#### 修正 cert 来源
`.rodata` 0x7e660–0x7e7b5 一字不漏地列着 JNI 反射全链:
0x7e660: android/content/ContextWrapper
0x7e67f: getPackageManager
0x7e691: ()Landroid/content/pm/PackageManager;
0x7e6b7: getPackageName
0x7e6c6: ()Ljava/lang/String;
0x7e6db: android/content/pm/PackageManager
0x7e6fd: getPackageInfo
0x7e70c: (Ljava/lang/String;I)Landroid/content/pm/PackageInfo;
0x7e742: android/content/pm/PackageInfo
0x7e761: signatures
0x7e76c: [Landroid/content/pm/Signature;
0x7e78c: android/content/pm/Signature
0x7e7a9: toByteArray
0x7e7b5: ()[B
也就是 **libpoison 通过 JNI 反射调**:
ContextWrapper.getPackageManager()
.getPackageInfo(pkgName, GET_SIGNATURES)
.signatures[0]
.toByteArray(); // → 915 字节 cert DER
这条路径**还是走 Java 层的 `PackageManager`**。理论上 Java hook `getPackageInfo` 覆盖 `pi.signatures.value = arr` 就能骗过 libpoison —— 但实际上不行,详见下一节。
#### 修正死链, 每个字节都对得上证据
综合前面所有实证,修正后的最终精确死链是:
libpoison JNI native 方法
↓ JNI 调 ContextWrapper.getPackageManager()
.getPackageInfo(pkgName, GET_SIGNATURES)
.signatures[0].toByteArray()
↓ 拿到 cert DER 字节数组(原版 915 字节)
↓ 用静态链接的 OpenSSL 1.0.2u 算 MD5(16 字节 raw)
↓ 0x7ac00-0x7acec: 16 次 sprintf(\"%02x\") 拼成 32 字符 hex
↓ 0x7ad04: __android_log_print(\"POISON\", \"APK Signature:%s\", hex)
↓ 0x7add8-0x7af00: 15 条 strcmp 白名单 chain
↓ 前 14 条: cbz → 跳 0x7af04 成功
↓ 第 15 条: cbnz → 跳 0x7af3c 失败
↓ 成功路径 0x7af04: stack canary check → ret
↓ 失败路径 0x7af3c: mov w0, wzr → bl exit(0) @ 0x7af40
↓ exit 触发 __cxa_finalize → atexit 析构表里坏指针 → SIGSEGV in InitTaskSchedul
每一个偏移都对得上磁盘 dump 出来的字节。到这一步机制才算彻底摸清。
### 5.9 再战 frida:知道机制之后仍然打不通

frida-agent 50ms 生命线 vs libpoison 800ms 加载
有了精确机制,理论上有 3 个新切入点都可行:
* **切入点 1**: `Interceptor.attach(libpoison.base.add(0x7ade4))` (第 1 条 strcmp 的 bl), `onLeave` 强制 `retval.replace(0)` → 跳过整条 15 条 chain
* **切入点 2**: `Memory.protect(0x7e9a5, 33, 'rw-')` + `writeUtf8String(重打包 hash)` → 第 15 条 strcmp 相等 → 成功
* **切入点 3**: `Memory.patchCode(0x7af00, 4, w => w.writeU32(0x14000001))` → 无条件跳成功
拿着这堆情报,我挑最干净的切入点 2 试了一发。只改 `.rodata`,不动 `.text`,理论上不触发安全库的代码段自校验。脚本 30 行:
// v23_patch_md5.js
var REPACK_HASH = \"d2a93e35348b7c414dddf5caf64108ad\";
var WHITELIST_15_RVA = 0x7e9a5;
var patched = false;
function tryPatch() {
if (patched) return true;
var m = Process.findModuleByName(\"libpoison.so\");
if (!m) return false;
var addr = m.base.add(WHITELIST_15_RVA);
Memory.protect(addr, 33, \"rw-\");
addr.writeUtf8String(REPACK_HASH);
patched = true;
send({tag: \"patched\"});
return true;
}
tryPatch();
setInterval(tryPatch, 25); // 每 25ms 重试直到 libpoison 加载
send({tag: \"boot\"});
`frida -f` spawn 模式 + patched-frida-server:
[frida] Spawned com.netease.cloudmusic. Resuming main thread!
[frida] (没有任何后续 message)
[logcat] 06-29 16:42 D POISON: APK Signature:d2a93e35348b7c414dddf5caf64108ad
[logcat] 06-29 16:42 F libc: Fatal signal 11 (SIGSEGV) in tid InitTaskSchedul
0 个 send 事件回到 host —— JavaScript 第一行 `send({tag: \"boot\"})` 都没机会跑。
再简化到最小存活探针:
send({tag: \"boot\"});
setInterval(function() { send({tag: \"tick\", t: ++t * 50}); }, 50);
跑出来只看到一条 `boot`, 第一次 tick 都没回。frida-agent 在 spawn resume 后生存时间 < 50 ms,而 libpoison 加载需要 ~800 ms,差 16 倍。
#### 时间轴对照
| 时刻 | 事件 |
| --- | --- |
| T+0 ms | `dev.resume(pid)`, zygote fork 出 cloudmusic |
| T+5~10 ms | frida-agent 注入, gum-js-loop 起来 |
| T+10~50 ms | libnesec 通过 JNI\\_OnLoad 加载, 扫 `/proc/self/maps` + 线程名 |
| T+50 ms | libnesec 发现 frida-agent → inline svc `kill` 干掉 V8 runtime |
| T+800 ms | libpoison 加载, 此时 frida 早死 |
| T+1200 ms | libpoison 算 hash + strcmp 15 条全不等 |
| T+1205 ms | `bl exit(0)`, atexit 析构表损坏 → SIGSEGV |
精确机制本身决定了:任何要 \"等 libpoison 加载后再装 hook\" 的方案,在 frida-server 用户态注入下都是死路。切入点 1、2、3 三个都要求 hook 装在 libpoison 加载之后,在我们的设备上这都过了 frida-agent 被卸的 50 ms 死线 700 多倍。
#### 磁盘静态 patch 也走不通
既然 hook 装不上,能不能直接 patch 磁盘上的 libpoison.so,把第 15 条白名单 MD5 改成重打包 hash,然后重打包 + 重签 + 装回?
disk = open(\"music163.9.2.80/lib/arm64-v8a/libpoison.so\", \"rb\").read()
for md5 in [\"da6b069da1e2982db3e386233f68d76d\",
\"ac06dbfeb05f19eac37c343b03230d07\"]:
idx = disk.find(md5.encode())
print(f\" {md5}: {'@'+hex(idx) if idx >= 0 else 'NOT FOUND'}\")
结果 15 条全 NOT FOUND。原因前面 第 5.7 节 已经验证过:libpoison 不只 `.text` 加密, `.rodata` 也加密 —— 磁盘文件里那 33 字节 ASCII MD5 字符串根本不是明文,而是密文,需要 libnesec 加载时解密到内存里才是 ASCII。所以静态 patch 磁盘文件,根本找不到目标字节去改。
#### 剩下的路,其实已经不是 frida 能解决的了
ZygiskFrida 注入 —— 在 zygote fork 时就把 frida-agent 加载好,远早于 libnesec init 跑起来。这样 hook 装在 libpoison 加载之前,strcmp 拦截才有机会生效。
但 ZygiskFrida 是 Magisk Zygisk 模块,不是 frida 本体的能力。这道墙最终能不能打通,取决于 Magisk 框架,不是 frida。在这一档加固面前 frida 的位置是 \"hook 工具\",不是 \"绕过工具\"。绕过是靠 Zygisk + patched-libnesec / patched-fs / Shamiko / DenyList 的组合,frida 只是绕过之后再用来做业务层 hook。
### 5.10 反向逆向 Toast 弹窗到死胡同
死循环过程中观察到一个之前没看见的现象 —— logcat 里反复出现:
06-30 10:46:24.543 W NotificationService: Package com.netease.cloudmusic is above allowed toast quota,
the following toast was blocked and discarded: TextToastRecord{b30b392 25180:com.netease.cloudmusic/u0a283
isSystemToast=false token=android.os.BinderProxy@f5f2163
text=请前往官方渠道下载正版APP duration=0}
每个 process 寿命内被快速排队 5 个 toast 然后被 NotificationService 限流。这说明 App 在 libpoison + libnesec 那条链路之外, 还有一条独立的 Java 层旁路在弹这个 Toast。
#### 时序的新观察
仔细看 process 启动到 SIGSEGV 之间的时间线,这条 Toast 触发比之前以为的 libpoison 校验早:
| 时刻 | 事件 |
| --- | --- |
| T+0 ms | `Start proc 23290:com.netease.cloudmusic` |
| T+50~100 ms | attachBaseContext + libnesec init 起 |
| T+100 ms | **Package quota 撞顶,Toast 已经被弹了 5 次** |
| T+150~200 ms | MMKV initialize 之类 |
| T+250~300 ms | SIGSEGV in InitTaskSchedul 死掉 |
**Toast 在 process 启动 100 ms 内就触发, 而且不止一次, 是一个循环在反复弹**。这跟 libpoison +800 ms 的校验时序对不上 —— 这条 Toast 不是 libpoison 触发的, 是更早的另一条独立校验。
#### 反向逆向:从 Toast 文本找 Java 调用者
Toast 是 Java 组件,触发它必须有一行 `Toast.makeText(ctx, \"请前往官方渠道下载正版APP\", N).show()`。这串字符串必然在某处可以找到 —— 反向找到它,就找到了触发位置。
按显性目标依次扫:
| 扫描位置 | 工具 | 结果 |
| --- | --- | --- |
| base.apk 内 21 个 classes\\*.dex | python bytes find | **全 NF** |
| `lib/arm64-v8a/*.so` (libpoison / libnesec / libnesdk / libmsaoaidsec / libsgsecuritybody) | python bytes find | **全 NF** |
| resources.arsc | UTF-8 + UTF-16 find | **NF** |
| libpoison\\_dumped.bin (992 KB 运行时解密镜像) | bytes find + libnesec XOR key 0x81-0x88 全 8 种起始偏移 | **明文 NF + 加密 NF** |
| libnesec\\_dumped.bin (1 MB) | 同上 | **NF** |
| base.apk 整体 (190 MB, 扫到 assets) | bytes find | **NF** |
到这里所有静态位置都被排除了。**这串字符串既不在任何 dex 业务代码里, 也不在任何 native lib 的 .rodata 里, 也不在 resources 表里, 也不在易盾壳同款 XOR 加密形态里**。
#### 进 process 内存找
字符串 logcat 都接到了完整 UTF-8 text, 说明触发那一刻字符串必然以某种形式在 cloudmusic process 内存里出现过。写了个 `mem_grep_anon` —— ptrace ATTACH 后扫所有 anonymous + cloudmusic 相关的 r-- 段, 主要是 ART 解密后的 dex / dalvik anonymous heap / app 数据目录映射, 搜 \"请前往\" 这 3 字 UTF-8 序列:
ATTACH 50 次(每次 monkey 拉起 → process 启动 → ATTACH → scan)
扫描段总数:1500+
hits:0
试了**直接 ATTACH**, PID 一出现就抓, ~T+10 ms, 和**延迟 ATTACH**, usleep 150 ms 等 Toast 触发后再抓, ~T+150 ms 两种时机 —— 都是 0 hit。
#### 唯一剩下的解释
字符串的存活窗口被压扁到几乎不存在。最可能的形态:
* libpoison, 或其加载的 secondary dex, 在 native 层把某串字节通过 `JNIEnv->NewStringUTF(env, decoded_bytes)` 现场构造成 jstring
* jstring 被 ART 放进 string intern table, 几乎立刻作为参数传给 `Toast.makeText` → `show` → binder transaction → system\\_server
* transaction 完成后, jstring 在 ART heap 里短暂存在 —— 然后 process 在 +200 ms 内就 SIGSEGV 死了, GC 都没机会跑
只有在 `Toast.makeText` 那一行调用到 binder transact 之间几十微秒的窗口, 字符串才以明文形式存在。ptrace 这种 ms 级 attach 工具根本卡不到那个窗口。
#### 那 frida 呢?
Java hook `Toast.makeText` 的 overload, onEnter 抓 arguments + `Thread.currentThread().getStackTrace()` —— 这是 hook Toast 触发链最直接的方式。但回到 第 5.9 节 那个老问题:
* frida-agent 在 T+50 ms 被 libnesec inline svc kill 干掉
* Java.perform 在 ART vm available 时才能跑, > T+50 ms
* **window 是负的**
`Java.perform` 这条线在网易这种加固面前根本就装不上。
#### 复盘一下
反向逆向到死胡同。证据链梳理一下就是这么几条:
静态一条都没找到。21 个 dex、5 个 lib、resources、libpoison 和 libnesec 的 dump、整个 APK 任何位置都搜过,包括用易盾同款 XOR key 加密形态找过,全 NF。
动态也抓不到。50 次 ptrace ATTACH,1500+ 段扫描,全 0 hit,无论 ATTACH 时机怎么调都一样。
结构性原因是这个字符串只在 `NewStringUTF + Toast.makeText + binder transact` 那几十微秒的窗口里存在,native 层构造出来立刻扔给 binder,即用即销。
frida 也救不了。Java hook 装不上的根因跟第 5.9 节一样,`Java.perform` 比 libnesec 的 frida 检测晚 50 ms 数量级,window 是负的。
看到这里可以确认一件事:网易这一档加固不只是 \"杀掉重打包 process\",它连 \"想从弹窗反向找代码\" 这种逆向尝试都一起堵死。静态路径全关,动态路径因为时间窗口太短也全关,从这个角度它是完整的工程封锁。
### 5.11 尝试过但没成的其他路径
除了 第 5.9 节-5.10 的正面强攻,还试过下面几条:
**尝试 1:hook Java `Signature.toByteArray` + `PackageInfo.signatures` 替换**, 就是 QQ 音乐方案
结果:没有明显改善。App 仍卡在 Splash 之后。跑 v23 时 send 事件清楚显示 hook 命中并替换成功:
[evt] {\"tag\": \"sig_replace_Proxy\"}
[evt] {\"tag\": \"sig_replace_Proxy\"}
[evt] {\"tag\": \"sig_replace_APM\", \"flags\": \"40\"}
3 次成功替换。但同一秒 logcat 里 POISON log 打出来的**仍然是重签 hash**:
D POISON: APK Signature:d2a93e35348b7c414dddf5caf64108ad
为什么 Java hook 命中 3 次但 POISON 打出来还是重签 hash? 因为**libpoison 早于 Java hook 装上就完成了校验**。Java.perform 在 ART VM ready 才能装,大概在 T+100~200 ms; libpoison 在 T+800 ms 加载, 但 libnesec 早在 T+50 ms 就 kill 了 frida-agent。Java hook 装上时,frida-agent 已经死了,hook 的回调进入 dead V8 runtime,等于空跑。
结论:**网易云音乐的主 Activity 起不来根因不在 Java 签名校验路径可否被 hook 上,而在 frida-agent 是否活得过 libnesec 反 frida 检测**。
**尝试 2:hook `startActivity` 相关 API 追踪**
* `Instrumentation.newActivity(ClassLoader, String, Intent)`
* `ActivityManagerService` / `IActivityManager.startActivity`
* `Activity.performStart` / `Activity.onCreate`
观察: `startActivity(new Intent(this, MainActivity.class))` 确实被调用了、也进了 `ActivityManager.startActivityAsUser`、returnCode 是 0, 成功。但**回到 UI 层就没 Activity 出现**。
结论:可能是加固层在 ActivityThread 的 `handleLaunchActivity` 里拦了,或者 MainActivity 的 dex 加载失败。要验证要 hook 到 ART 层的 `dexFileLoad`,本地环境不好搞。
**尝试 3:dex 短路——修改 wrapper 直接反射加载真业务 Application**
思路:既然 wrapper `MyApplication` 是 Nesec 加固注入的、真业务是 `CloudMusicApplication`, 我们改 wrapper 的 `attachBaseContext`, 让它跳过加固校验步骤, 直接反射加载真业务 App。
MyApplication.smali (修改后)
.method protected attachBaseContext(Landroid/content/Context;)V
.locals 3
invoke-super {p0, p1}, Landroid/app/Application;->attachBaseContext(Landroid/content/Context;)V
# 反射加载 CloudMusicApplication
const-string v0, \"com.netease.cloudmusic.tinker.CloudMusicApplication\"
invoke-static {v0}, Ljava/lang/Class;->forName(Ljava/lang/String;)Ljava/lang/Class;
move-result-object v1
invoke-virtual {v1}, Ljava/lang/Class;->newInstance()Ljava/lang/Object;
move-result-object v2
check-cast v2, Landroid/app/Application;
# 反射调用 v2.attachBaseContext(p1)
...
.end method
完整 smali 约 300 行。
结果: Application init 完成 —— 微信 SDK / HMS / MMKV / DataBinding 全部初始化 OK,比作者版位置深了一层。这就是 第 5.6 节 的观察。但**主 Activity 仍不起来** —— dex 短路只跨过了 wrapper 层的校验, libnesec 在 App 运行到某个业务事件时会二次校验, 我们的 dex 短路无效。
结论:dex 短路是**必要不充分** —— 把 wrapper 里的 Nesec 校验绕过了, 但绕不过 libnesec 加载后的运行时校验。
**尝试 4:手动加载 MainActivity dex**
想法:绕过加固的动态解密,直接把 `MainActivity` 从原始 APK 里 extract 出来,用 `PathClassLoader` 手动加载。
问题:`MainActivity` 依赖大量业务类, Fragment / ViewModel / Repository / Retrofit / OkHttp / ..., 单独加载没意义,得连整个业务 dex 都加载。而业务 dex 又依赖 Application 提供的 context 和 DI 容器。**不可行**。
**尝试 5:LSPatch / Xposed hook**
尝试用 LSPatch 打成 magisk 模块方式注入。失败 —— LSPatch 需要目标 App 允许 hook (AndroidManifest 里能读到某些 flag), 加固过的 App 通常不满足。
**尝试 6:Frida gadget 内嵌**
把 `frida-gadget.so` 编进 APK, 做成不依赖 frida-server 的方案。失败 —— libnesec 加载时会枚举同进程 module, 识别到 `frida-gadget` 相关字符串直接自杀。
**尝试 7:脱壳 + 分析 libnesec 关键函数**
尝试用 `frida-dexdump` 脱壳出真正的业务 dex, 然后 IDA 分析 libnesec + libpoison 找所有校验点。进展:dex 能拿出来, libpoison 的关键分支能定位到 0x7af3c, 但**libnesec 内代码经过 OLLVM 混淆**, 逆向工作量至少 10 天起步。没继续。
**尝试 8(未做,但路径已经清楚):libnesec Java 层校验的 hook 方案**

链路 B libnesec Java 层校验的 3 层 hook 配方
第 5.1.5 节 讲过 libnesec 里有一条独立于 libpoison 的 Java 层校验链:`MyJni.load` 里 JNI 反射调 `PackageManager.getPackageInfo` → 白名单比对 + apksignaturekillerplus 特征扫描 → 失败调 `NEDialog.showRiskMessage` 弹 toast → ART 自然死。这条 第 5.5 节 完全没管,是主 Activity 起不来最主要的嫌疑。
**分别定点 hook** 实证过这套的可打性:**libpoison 3 层 byte patch + libnesec 的 Java 层 `NEDialog.showRiskMessage` hook**(这就是「4 层 hook 配方 `bypass_sig.js`」),让进程\"从 +1.4s 死延长到 10+ 分钟不死、所有签名校验 die 全部消失、toast 不再弹\" —— 关键的一层就是给 libnesec 那条 Java 校验的失败出口打上补丁。
思路分 3 层(就是上图所示的对称补丁蓝图):
1. **hook `NEDialog.showRiskMessage(int, String)`**(最简单也最直接)—— 让弹窗不弹、失败流程不进入。整个 Java 层校验失败最终收口在这里,只要让它 no-op,杀链就断了 —— 这是 4 层配方里最关键的一层。
```
Java.perform(function () {
var NEDialog = Java.use('com.netease.xxxxxx.NEDialog'); // 具体类名以脱壳后 jadx 为准
NEDialog.showRiskMessage.overload('int', 'java.lang.String').implementation = function (code, msg) {
send('[skip] NEDialog.showRiskMessage(' + code + ', \"' + msg + '\")');
};
});
```
2. **hook libnesec 的 JNI 反射入口 `MyJni.load`**(更彻底)—— 让整个 Java 层校验根本不发生。这个是从 native 桥这一层拦,不进入后续 `getPackageInfo` → 白名单对比这条链。
3. **精细替换 `PackageManager.getPackageInfo`**(QQ 音乐方案的克制版)—— 关键区别是**不能全局 hook**。第 5.1.5 节 讲的 CheckJNI 陷阱:如果 Java 层返回原版字节,业务 native 代码通过 CheckJNI 读到的是重签字节,两次不一致会直接 abort 杀进程。所以要精细化到\"只在 libnesec 的调用栈里才替换\" —— 用 `Thread.currentThread().getStackTrace()` 判断 caller 是不是 libnesec 相关类,是才替换。
**没做的原因**:跟 第 5.9 节 讲过的一样 —— frida-agent 在 T+50ms 被 libnesec 的 inline svc kill 干掉,Java.perform 装上的时机 > T+50ms,**window 是负的**。要装上上面 3 层 hook 的前提是**用 patched-frida-server**(改了特征字符串、让 libnesec 认不出来),本篇的设备上没配这一层,所以尽管路径清楚,Frida 环境本身仍然进不去。
这条路径证明了一件事:**Frida 单点 hook 打不穿 Nesec,但不是因为\"找不到 hook 点\"**(hook 点是清楚的)**,而是因为\"hook 装不上去\"**(frida-agent 活不过 libnesec 的反检测)。要打通只有两条路:一是用 patched-frida-server + Magisk Hide 之类的组合先解决注入时机、再走上面 3 层 hook 配方;二是彻底放弃 Frida,走 ZygiskFrida / 静态 patch。都不在纯 Frida 的能力范围。
### 5.12 为什么最终判断这条路不通(暂时)
成本估算:
要做到跟 QQ 音乐一样 \"App 完全可用\",需要:
1. **脱壳出完整业务 dex** —— 可能要跑通 frida-dexdump 全套,2-3 天。
2. **逆向 libnesec + libpoison 找全校验点** —— OLLVM 混淆下 10-20 天。
3. **补 Java 层 + native 层双路径 hook** —— 3-5 天。
4. **验证主 Activity 起来后各个业务功能**, 登录、播放、下载、评论、社区, 都可能触发单独的校验点,每个都要单独排查, **无限时间**。
5. **持久化到不依赖 Frida 的补丁** —— smali 改动大概 20+ 处, native 补丁涉及 5-10 处,还得处理版本升级, **长期维护成本**。
产出评估:
如果只是证明 Frida 单点 hook 打不穿 Nesec 加固 + 深度联防, 我们已经证明了。作者花 3 天 45 层 patch 到 splash, 我们精简到 3 层 patch 到 Application init 之后, 说明加固层的对抗已经把 Frida 的实用性压到了几乎为零 —— 不是因为哪个 patch 找不到, 而是因为每个校验点都要单独 hook、hook 之间会互相干扰、任何一个漏掉整个链就断。
判断:
这条路目前不通。不是 \"永远不通\",是 \"投入产出比不合适\"。要真做的话得从加固侧入手, 脱壳 → 完整逆向 → 补丁到非加固版本, 而不是继续加 Frida patch。
### 5.13 跟 QQ 音乐对比:为什么一个能救一个不能

网易云音乐 9.2.80 vs QQ 音乐 13.8.1 · 8 维度并列对比
图里 8 个维度并排看下来,可以把差别浓缩成一条:**QQ 音乐把校验放在业务请求这一步、失败也不自杀,所以留给你反反复复调试的时间窗口;网易云音乐把校验放在 Application init 期、失败直接 System.exit,反馈周期短到 500ms**。反馈周期决定了你能调多深 —— 这是两个 case 命运不同的根本原因。
### 5.14 网易云音乐实战小结
| 项目 | 网易云音乐 9.2.80 |
| --- | --- |
| 加固厂商 | Nesec |
| 加固特征 | **双链路独立并行的签名校验**:libpoison(native 直读 APK signing block) + libnesec(Java 层 `PackageManager.getPackageInfo` + apksignaturekillerplus 特征扫描),互不通信、各自杀进程;双 so OLLVM 混淆 + SEGV 陷阱 + 加密字符串 |
| 客户端签名校验 | 双链路:native 直读 APK signing block(libpoison)+ Java 层 `getPackageInfo` 独立读一次(libnesec) |
| 校验失败后 | 链路 A: libpoison `bl exit(0) @ 0x7af40` → atexit 析构表触发 SIGSEGV in InitTaskSchedul;链路 B: libnesec 走 `NEDialog.showRiskMessage(\"请前往官方渠道下载正版 APP\")` → toast → ART 自然死,或 `raise(SIGTRAP)+tgkill` 主线程自杀 |
| 我们达到的深度 | Application init 完成, Splash 出现并消失, 主 Activity 未启动 |
| Frida patch 层数 | 3 层, libpoison byte patch + `HandleUsingDestroyedMutex` byte patch + libc exit 拦截, + dex 短路 |
| 是否可用 | **否** —— 用户看到黑屏 |
| 距离可用的估计工作量 | 数周到一个月, 需要脱壳 + 完整 native 逆向 |
| 建议投入 | 学习/研究目的可以做完;商用发行不建议此路径 |
给刚学签名对抗的人几句话。
不要拿网易云音乐做入门 target。选一个像 QQ 音乐这样 \"客户端不检查、只依赖服务端\" 的 App 练手,能拿到干净的成功反馈,也能把 Java 层完整流程摸清。加固过的 App 等你手上有基础功再挑战。
遇到 App 秒退不要盲加 patch。作者版 45 层的教训明摆着:每加一层 patch 都要验证进度有没有推进,没有推进的 patch 就是干扰,甚至可能反过来把 App 弄崩。反过来说,如果去掉某个 patch App 反而活得更久,那个 patch 就是问题本身 —— 我们发现的 `setExceptionHandler` 全屏蔽就是这么找出来的。
失败案例值得留档。这一节我没突破,但把 \"加固 + 深度联防\" 的对抗成本量化了:Frida 单点 hook 不够,必须走脱壳 + 完整 native 逆向,成本是数周而不是数天。知道 \"什么时候该停手\" 对后来者也是价值。
6 轮迭代本身也是这一节的东西。凭间接现象推断(5.1 到 5.6),到自己 ptrace dump 拿证据(5.7),到跟另一份独立 writeup 交叉对比、发现前面几条推断有错、修正回来,到把 15 条白名单 MD5 全列出(5.8),到拿着精确机制再战 frida 仍然失败(5.9),到反向从 Toast 追字符串追到死胡同(5.10),到第二次交叉对比推翻\"libnesec 是裁判\"改成\"双链路独立并行\"(第 5.1.5 节 重写)—— 每一轮都把上一轮的几条断言推翻或精化。如果一篇逆向文章从头到尾说自己全对,八成里面有错没被发现。愿意认错、愿意下一轮再花时间验证,留下来的东西才有质量。
## 六、闪退了怎么办
前面两个实战里,我们至少踩过 10 种以上 \"App 装了就退\" 的坑。这一节把排障流程整理成 checklist,遇到闪退时按顺序过一遍 —— 大多数情况能在半小时内定位是哪一层的问题。
### 6.1 第一步:logcat 抓到 App 死之前的最后几行
任何调试的第一步都是这个:
清 logcat 缓冲
adb logcat -c
启动目标 App
adb shell am force-stop com.example.target
adb shell monkey -p com.example.target -c android.intent.category.LAUNCHER 1
等 5-10 秒, App 大概率已经死了
sleep 10
抓最后 500 行
adb logcat -d -t 500 > /tmp/crash.log
关键关键字过滤
grep -iE \"Fatal|AndroidRuntime|died|killed|SIGSEGV|SIGABRT|Signature|com.example.target\" /tmp/crash.log
四种典型模式:
**模式 A:Java `Fatal Exception`**
E AndroidRuntime: FATAL EXCEPTION: main
E AndroidRuntime: Process: com.example.target, PID: xxx
E AndroidRuntime: java.lang.RuntimeException: Unable to start activity ...
E AndroidRuntime: Caused by: java.lang.SecurityException: Signature mismatch
E AndroidRuntime: at com.example.SignatureCheck.verify(SignatureCheck.java:45)
Java 层校验。看栈里的类名 `SignatureCheck`, 去 jadx 反编找到那个方法,然后走 第 2.3 节 打法 A/B/C。
**模式 B:native `Fatal signal`**
F libc : Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid xxx (com.example.target)
F DEBUG : Cmdline: com.example.target
F DEBUG : pid: xxx, tid: xxx, name: com.example.target >>> com.example.target <<<
F DEBUG : signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
F DEBUG : #00 pc 0000000000012345 /apex/com.android.runtime/lib64/bionic/libc.so (abort+68)
F DEBUG : #01 pc 000000000006abcd /data/app/xxx/lib/arm64/libsentry.so (_ZN12sentry_crash...)
Native 层校验。看栈里的 so 名 `libsentry.so` —— 那就是保护 so, 需要按 第 3 章 的 native 层方法处理。
**模式 C:`Process died` 没有原因**
I ActivityManager: Process com.example.target (pid ...) has died: fg TOP
I libprocessgroup: Successfully killed process cgroup uid ... pid ...
后面**没有 Fatal signal, 没有 Java exception** —— 就这么消失。三种可能:
* App 调了 `System.exit(0)` / `Process.killProcess` —— 静默退出。
* App 用 `syscall(SYS_exit_group)` 直接 syscall 出去, 绕过 libc export —— `abort` hook 拦不到。
* 系统或 launcher 因为其他原因杀了它, 内存不足、targetSdk 兼容问题 —— **少见**。
排查思路:装我们的 Frida hook 先拦 Java `System.exit` + `Process.killProcess`;如果还是死,装 `abort` / `_exit` / `exit_group` / `_Exit` 全套;再死就上 `__exit` / `__exit_group` 私有 symbol;还死就上 syscall hook。
**模式 D:`INSTALL_PARSE_FAILED_NO_CERTIFICATES` 或 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`**
Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES: ...]
Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package com.example.target
signatures do not match newer version]
安装阶段就挂了 —— 不是 App 层的事。`NO_CERTIFICATES` 是 apktool 打包破坏了签名, 用 `apksigner sign` 重签就行;`UPDATE_INCOMPATIBLE` 是原版还装着且签名不同,需要 `adb uninstall` 后再装。
### 6.2 第二步:判断是 \"原 App 的问题\" 还是 \"你的 hook 引起的\"
装了 Frida hook 后 App 秒退,很容易以为是 \"App 检测到 Frida 主动自杀\",但多数情况其实是你的 hook 破坏了 App 的正常逻辑。
判断法:**先关掉所有 hook 装机一次, 看 App 会不会退**。
不 attach Frida, 纯净装机
adb shell am force-stop com.example.target
adb shell monkey -p com.example.target -c android.intent.category.LAUNCHER 1
如果不装 hook 就存活, 哪怕业务功能不 work —— 问题在你的 hook。逐个 hook 关掉重新试,找到哪个 hook 是破坏源。
如果不装 hook 也秒退 —— 那才是 App 自身的自杀路径, 加固层校验、native abort, 按 第 3 章 处理。
大多数 \"装了 hook 反而 App 死得更快\" 的场景是:
* 全局 `setExceptionHandler` 拦下所有异常 —— ART 隐式 null check 变成未处理 SIGSEGV, 第 3.4 节 详述。
* `Interceptor.replace` 替换了系统方法但签名不对 —— ART 校验失败 abort。
* Hook 数量过多导致 JIT 生成的 stub 代码放不下 CodeCache, OOM。
### 6.3 第三步:Frida spawn vs attach
spawn 模式:Frida 在 App 主线程执行第一行代码之前注入。能拦到启动早期的所有调用。
pid = device.spawn(['com.example.target'])
session = device.attach(pid)
scr = session.create_script(open('hook.js').read())
scr.on('message', lambda m, _: print(m))
scr.load()
device.resume(pid) # 关键:resume 才让 App 继续
attach 模式:App 已经跑起来后 hook。启动早期的调用全部漏掉。
pid = int(subprocess.check_output(['adb', 'shell', 'pidof', 'com.example.target']).strip())
session = device.attach(pid)
scr = session.create_script(open('hook.js').read())
scr.load()
**QQ 音乐案例, 第 4.7 节-4.8**:spawn 模式看到 4 次 `toByteArray` 调用, attach 模式一次都看不到 —— 因为 QQ 音乐启动早期读一次签名然后缓存到字段, 之后全用缓存。必须 spawn 才拦得到关键调用。
规则:
* 不确定就用 spawn。反正 attach 能做的 spawn 都能做,反过来不行。
* spawn 有额外开销 —— App 启动多几百 ms、需要处理 `enable_spawn_gating` 拦子进程 —— 但换来的是不漏调用。
* debug 阶段推荐 spawn,性能测试用 attach。
### 6.4 第四步:hook 常驻保活
常见的坑:`frida -l script.js -q com.example.target` 加载脚本, 秒退 —— 不是 App 退, 是 Frida 退。因为 Frida CLI 加载 script 后如果没有 `-e` 或 REPL, 立即返回, session 关闭, hook 全部 detach。
解法: 用 Python, `time.sleep()` 保活。
keepalive.py
import frida, sys, time
device = frida.get_device_manager().add_remote_device('127.0.0.1:27042')
或 spawn 或 attach, 二选一
mode = sys.argv[1] # 'spawn' or 'attach'
target = sys.argv[2]
script_path = sys.argv[3]
if mode == 'spawn':
pid = device.spawn([target])
elif mode == 'attach':
import subprocess as sp
pid = int(sp.check_output(['adb', 'shell', 'pidof', target]).strip())
session = device.attach(pid)
scr = session.create_script(open(script_path).read())
def on_msg(m, _):
if m['type'] == 'send': print('[S]', m.get('payload'), flush=True)
elif m['type'] == 'error': print('[E]', m.get('description'), flush=True)
scr.on('message', on_msg)
scr.load()
if mode == 'spawn':
device.resume(pid)
print(f'[PY] hook installed on pid={pid}, keepalive 30min', flush=True)
try: time.sleep(1800)
except KeyboardInterrupt: pass
用法:
python3 keepalive.py spawn com.tencent.qqmusic qq_sig_full.js
Ctrl-C 才退出。**长期调试的时候, 把 duration 改成 24 小时也没关系**。
### 6.5 第五步:Frida script 里的坑
**坑 1:`console.log` 覆盖问题**
Frida 里 `console` 是只读全局, 不能重定义:
// 错的
var console = { log: function (s) { send(String(s)); } };
// TypeError: 'console' is read-only
// 对的:定义新变量
var L = function (s) { send(String(s)); };
**坑 2:`Java.array` 里 byte 的正负号**
Java `byte` 是 signed, -128 到 127, JS number 是 unsigned, 0-255:
// 错的
var arr = Java.array('byte', [255, 128, 0]);
// 报错或数据不对
// 对的:>127 减 256 转 signed
var arr = Java.array('byte', bytes.map(function (b) {
return b > 127 ? b - 256 : b;
}));
**坑 3:字段访问 `.value`**
Frida 里访问 Java 对象的字段用 `.value`:
// 错的
pi.signatures = arr;
// 只是 shadow JS 属性, Java 对象没改
// 对的
pi.signatures.value = arr;
**坑 4:`Signature.$new` 参数类型**
`android.content.pm.Signature` 有多个 constructor overload:
// 错的, 可能触发 ambiguous constructor 错误
var sig = Signature.$new(bytes);
// 对的:明确 overload
var sig = Signature.$new.overload('[B').call(Signature, bytes);
// 或简单情况直接
var sig = Signature.$new(Java.array('byte', bytesConverted));
**坑 5:反射改私有字段的正确姿势**
// 反射的正确姿势
var fDet = si.getClass().getDeclaredField('mSigningDetails');
fDet.setAccessible(true);
var det = fDet.get(si);
// ...
不能用 `si.mSigningDetails.value` —— `mSigningDetails` 是私有字段, Frida 的 `.value` 只能访问公共字段。必须走反射。
### 6.6 第六步:常见错误诊断表

Frida 常见错误诊断速查
10 条按 4 大类分组: A 组连接/启动、B 组 hook 破坏 App、C 组 Frida API 用法、D 组装机与反检测。B 组是最容易 \"装了 hook 反而更快死\" 的常见误伤,90% 是 `setExceptionHandler` 全屏蔽或 `Interceptor.replace` 系统方法引起。C 组是 Frida 特有的坑,byte 正负号 / `.value` 访问 / overload 显式选 / 反射改私有字段这几处每个都得踩过一次才记得住。
### 6.7 反检测与 \"不要装那些不该装的\"
**根检测**, 不是本章重点, 但作为 checklist 提一下:
* Magisk Hide / Zygisk DenyList 隐藏 root
* MoveCertificate 装 CA 到 apex, Android 14+ 必需
* 别在 App 目录下留下 frida-agent / frida-gadget 等特征文件
**Frida 检测的常见路径**:
* 遍历 `/proc/self/maps` 找 `frida-agent` / `frida-gadget` / `gum-js-loop` 字符串
* 遍历 `/proc/self/task/*/status` 找可疑线程名, `gum-js-loop`、`gmain`、`pool-frida`
* `getprop` 查 `ro.debuggable`
* 尝试 `socket().connect('127.0.0.1', 27042)` 检测 frida-server 端口
* 检查 `dlopen(\"libfrida-agent.so\")` 是否成功
对抗:改 frida-server 名字、改端口、编译时改 symbol 名 —— 需要另开一章讲。本节的 checklist 到 App 秒退但 logcat 里 `frida-agent detected` 或 `unusual thread name` 时,往这个方向查。
网易云音乐 第 5.9 节 里的 \"frida-agent 50 ms 被 inline svc kill\" 就是加固层反 frida 检测机制的极端表现 —— 用 patched frida-server + Zygisk DenyList + 修改所有 gum 相关字符串, 才能把 agent 撑到超过 libpoison 加载, 单纯换端口远远不够。
### 6.8 关掉 hook 的诊断法
\"装了这个 hook App 死得更快\" 时,**用二分法定位哪个 hook 是破坏源**:
// 主 script.js
var STAGE = 3; // 0 = 什么都不装, 1..N = 装到第几层
if (STAGE >= 1) { / patch 1: libpoison byte patch / }
if (STAGE >= 2) { / patch 2: HandleUsingDestroyedMutex / }
if (STAGE >= 3) { / patch 3: libc exit hooks / }
if (STAGE >= 4) { / patch 4: Java exit hooks / }
if (STAGE >= 5) { / patch 5: setExceptionHandler / } // ← 这条经常是问题源
```
- STAGE=0 App 秒退 → App 自身问题, 跟 hook 无关
- STAGE=3 存活但 STAGE=5 秒退 → patch 5 是破坏源
关键原则: 去掉 patch 反而让 App 活得更久时, 那个 patch 一定是问题。加固对抗里这种反直觉的场景不少见 —— 默认 \"加 patch\" 和 \"减 patch\" 都要试。
6.9 排障总纲
闪退排障总纲 8 步流程
关键的判断在 Step 2 —— 按 logcat 特征分四类之后,后续路径就分开了。Java exception 走第 2 章 Java hook 打法,native signal 走第 3 章 native 方法,silent died 补 exit/abort/killProcess hook,INSTALL\_ 系列直接 apksigner sign 重签就行。走完 8 步定位不下来的,基本就是加固到了第 5 章的量级 —— 该停就停,评估继续投入的成本比继续加 patch 更划算。
七、总结
签名校验绕过从最浅的一行 PackageManager.getPackageInfo 到最深的加固分层校验,档次差异极大。跨样本对下来,比档次数字更好用的是两条判据。
一条是 App 会不会自杀。第 1、3 档不自杀,要么直接可用要么服务端拒服务,你可以一直挂着调;第 4、5 档一 fail 就 SIGABRT / System.exit / abort,反馈周期从秒级恶化到 \"每次崩溃从头再来\"。这两种反馈节奏差好几个数量级,直接决定了你能调多深。
另一条是签名读取在哪一层。全在 Java 层的(第 1、2 档)你把 Signature 对象整体替换 + signingInfo 覆盖就够;一旦下沉到 native 层(第 3 档以上)Java hook 从原理上拦不到 —— 比如小红书的 libsentry.so 直接 pread 读 APK signing block 里的 0xF05368C0 chunk,Java 那边任何 hook 都碰不到那条路径。
拿到一个新 App,别看到 getPackageInfo 就先写 Java hook。先花 15 分钟走一遍第 1.4 节的三步判断,确认在哪一档,再选打法。
QQ 音乐这个 case 干净。签名校验就是 \"客户端读一份字节塞进业务请求\",Java 把签名对象换成原版就完事了。最费时间的不是 hook 那几十行代码,是排除各种误导 —— code:0 的假象、http_proxy 让 WNS 疯狂重试的次生反应、\"我的\" 页面看起来正常但根本不发请求。踩过这些坑的价值不比最终 hook 代码小。
网易云音乐这个 case 是失败案例。Nesec 加固把校验点分散到 native、加密字符串、SEGV 陷阱、Tinker 反射链好几个位置,Frida 单点 hook 根本覆盖不全。我做到了 Application init 走完,主 Activity 起不来,比社区流传的 45 层 patch 版本挪了一小段,但离能用还差得远。这个 case 的价值不是 \"打通了\",而是把 \"打不通的成本\" 量化到了具体数字:
ptrace + process\_vm\_readv 自己 dump 出 libpoison、反汇编看到 15 条 strcmp 白名单,机制说清楚了。拿着精确机制写第 15 条白名单 MD5 的 patch,frida-agent 在 50 ms 就被 libnesec inline svc kill 干掉,而 libpoison 加载在 800 ms,时间窗口是 16 倍的负值,结构上就不可绕。反向从 Toast 弹窗追字符串,dex / lib / resources / 内存扫描全 NF,静态动态都抓不到。商业级加固能让一段 UI 文本只在几十微秒的窗口里存在,任何 batch-level 工具都摸不到。
如果真要打通,脱壳 + 完整 native 逆向数周起步。如果只是研究,我这次的失败记录已经说明白了 \"Frida 单点 hook 打不穿深度联防\"。6 轮迭代这一节我也没删,从推断,到自己 dump 拿证据,到交叉对比修正推断,到拿精确机制再战 frida 仍然失败,到 Toast 反查到死胡同,到再一次对比推翻\"libnesec 是裁判\"改成\"双链路独立\",每一轮都把上一轮的几条断言推翻或精化。如果一篇逆向文章从头到尾说自己全对,八成里面有错没被发现。
最后再念一遍那句:唯一变了的是签名,问题只可能出在签名。QQ 音乐那些走弯路的时段,回到这句话就能把方向拉回来。以后开新 case 之前,这话先在心里过一遍。










评论