目标包名:
xxx.xxx.stockapp(已脱敏)测试设备:Google Pixel 6,arm64-v8a本机环境:macOS、ADB、Frida 17.16.4、JADX、LIEF、Capstone
1. 需求
把“实时龙虎榜”里的数据稳定拿出来,最好服务端一推,采集端马上就能收到,最后保存成能直接使用的结构化数据。
一开始并不知道它走的是 WebSocket、普通接口,还是自己封装的 TCP 长连接。APK 又套了 360 加固,Charles 里看不到想要的明文。所以这次从壳开始拆:先拿运行时 DEX,再顺着 socket、protobuf 和页面转换代码往下找,最后用 Frida 在业务明文层做验证。
这轮主要查清几件事:
壳怎么加载业务 DEX,运行时能不能拿到; libprotector_a64.so里有哪些反调试和杀进程逻辑; Frida 到底卡在哪里; 实时龙虎榜的数据落在哪个 Java 对象、哪个方法; 长连接是轮询、心跳带数据,还是先订阅再由服务端推送; 后续怎么改成长时间运行的采集器。
抓到一屏日志只能算链路打通。真正上线还得把字段、单位、去重、重连和落库慢慢磨顺。

2. 样本与产物清单
2.1 样本指纹
APK:
文件:target_stock_app.apkSHA-256:已隐藏包名:xxx.xxx.stockapp
主要加固 SO:
文件:assets/libprotector_a64.so(文中使用的脱敏名称)SHA-256:已隐藏架构:ELF64 / AArch64入口地址:已隐藏JNI_OnLoad:0x11fXXRX 代码段:0x0000XX–0x05edXX
2.2 已生成分析产物
runtime_artifacts_stock_app/tinker_dex/classes*.dex | |
jadx_runtime/sources/ | |
libprotector_a64_full.asm | |
libprotector_a64_disassembly_report.md | |
analyze_protector.py | |
hook_realtime_ranking.js | |
decode_protobuf_log.py | |
realtime_ranking_plaintext_utf8.log |
3. 明文层
分析遵循“从外到内、静态和动态交叉验证”的顺序:
APK 文件结构→ 壳与加载器识别→ 运行时 DEX 获取→ JADX 业务代码定位→ Native SO 反汇编→ Frida/反调试检测验证→ 网络业务对象定位→ Frida 动态 Hook→ 明文字段验证→ 长连接订阅模型确认→ 持久化采集方案
最省事的切入点在业务转换层。要是上来就怼 Socket.read(),后面还有拆包、解密、解压、protobuf 和命令路由,一层套一层,纯给自己加班。App 自己已经把这些活干完了,我们在页面拿数据之前截一下就行。
4. 加壳识别与脱壳分析流程
4.1 加壳判断依据
APK 中存在以下明显特征:
assets/libprotector_a64.soassets/libprotector.soassets/libprotector_x64.soassets/libprotector_x86.soxxx/xxx/StubApplication加固厂商相关域名(已隐藏)
这些特征已经把 360 系列加固写在脸上了。APK 里的原始 classes.dex 看不全业务代码,真正的代码要等壳在运行期间解密或加载。
此外,运行时还存在 Tinker 补丁 DEX,因此实际类加载链可能同时包含:
系统 PathClassLoader→ 壳创建/接管的 ClassLoader→ Tinker DexClassLoader→ 真实业务类
这也是直接 Java.use() 偶尔找不到业务类的主要原因。
4.2 脱壳目标
这里说的脱壳,重点是拿到 ART 实际加载的业务 DEX。文件能让 JADX 正常识别,类名还能和手机里的运行时对象对上,这才算有用。只导出一个名字叫 dump.dex、打开全是残片的文件,意义不大。
本次获得:
classes.dexclasses2.dex...classes9.dex
路径:
runtime_artifacts_stock_app/tinker_dex/4.3 复现步骤
确认 ADB 与设备:
adb devices -ladb shell pm list packages | grep xxx.xxx.stockappadb shell pidof xxx.xxx.stockappadb shell getprop ro.product.cpu.abi
确认 Frida 服务端和客户端版本一致:
frida --versionfrida-ps -H 127.0.0.1:PORT -ai
如果手机端 Frida 监听自定义端口,建立转发:
adb forward tcp:PORT tcp:PORT在应用完成壳初始化、业务页面可以正常打开后,再执行 DEX dump。这样能确保 Tinker 和延迟加载类已经进入 ART。 对输出 DEX 做基本校验:魔数、文件大小、类数量、JADX 是否能正常解析;随后按类名、方法名、protobuf 类型进行检索。
4.4 反编译命令
jadx -d jadx_runtime \runtime_artifacts_stock_app/tinker_dex/classes.dex \runtime_artifacts_stock_app/tinker_dex/classes2.dex \runtime_artifacts_stock_app/tinker_dex/classes3.dex \runtime_artifacts_stock_app/tinker_dex/classes4.dex \runtime_artifacts_stock_app/tinker_dex/classes5.dex \runtime_artifacts_stock_app/tinker_dex/classes6.dex \runtime_artifacts_stock_app/tinker_dex/classes7.dex \runtime_artifacts_stock_app/tinker_dex/classes8.dex \runtime_artifacts_stock_app/tinker_dex/classes9.dex
4.5 脱壳阶段常见误区
只看 APK 内原始 classes.dex:看到的可能主要是壳入口;应用刚启动就 dump:部分业务 DEX 尚未加载; 只使用默认 ClassLoader:加固/Tinker 类可能在其他 ClassLoader; 认为 JADX 反编译失败就是 DEX 无效:也可能是混淆、校验信息或局部字节码异常; 将“成功 dump”误认为“协议已经理解”:DEX 只是后续业务分析的基础。
5. 加固 SO 反汇编与反分析
5.1 为什么普通反汇编工具不够
目标 SO 已剥离符号,并对 ELF section header/字符串表做了破坏或混淆。部分依赖 section header 的工具无法正常解析。但 ELF program header、动态表和运行时加载所需结构仍然有效,否则 Android linker 无法加载它。
解决办法:
使用 LIEF 根据 program header 和 dynamic table 恢复可执行段、动态符号与重定位; 使用 Capstone 直接反汇编 RX PT_LOAD;识别 AArch64 PLT 模板并恢复 GOT → 导入函数的映射; 扫描 BL调用和字符串ADRP + ADD引用。
5.2 PLT 恢复关键代码
# AArch64 PLT 常见结构:# adrp x16, page# ldr x17, [x16, #got_offset]# add x16, x16, #offset# br x17got_names = {}for relocation in binary.pltgot_relocations:if relocation.has_symbol:got_names[relocation.address] = relocation.symbol.nameplt = {}for index inrange(len(instructions) - 3):adrp, ldr, add, br = instructions[index:index + 4]if (adrp.mnemonic, ldr.mnemonic, add.mnemonic, br.mnemonic) != (”adrp”, ”ldr”, ”add”, ”br”):continuegot_address = adrp.operands[1].imm + ldr.operands[1].mem.dispif got_address in got_names:plt[adrp.address] = got_names[got_address]
完整实现位于内部分析脚本第 28–55 行,这里隐藏了真实文件名。
5.3 反汇编结果
指令数量:97,106恢复 PLT 项:172动态符号数量:395
重点导入函数包括:
fopen / open / opendir / readdirstrstr / fnmatchdlopen / dlsympthread_create / prctlinotify_init / inotify_add_watchkill / raise / abort / _exitmprotect
导入表只能说明“它可能会用”,还得看调用点。后面继续恢复了直接调用位置和字符串交叉引用,免得看见一个 kill 就开始脑补。
5.4 /proc/self/maps 扫描
字符串地址:
/proc/self/maps → 0x4f8XX直接代码引用:
0x00a1XX0x00ceXX
其中一处反汇编:
0x00a1XX: adrp x0, #0x4f0XX0x00a1XX: adrp x1, #0x4f0XX0x00a1XX: add x0, x0, #0x875 ; ”/proc/self/maps”0x00a1XX: add x1, x1, #0xa55 ; fopen mode0x00a1XX: bl #0x9c50 ; fopen@plt0x00a1XX: cbz x0, #0xa3d0...0x00a1XX: bl #0x9a80 ; strstr@plt
这段代码确实会读进程映射,也会做字符串匹配。用途可能是找模块、辅助壳加载、做完整性检查,当然也可能盯注入模块。匹配目标没有以明文躺在文件里,所以现阶段只能把它记作“可疑检测点”,还不能硬扣一顶 Frida 检测的帽子。
5.5 inotify 监控与主动终止
直接调用点:
inotify_init 0x103XX, 0x103XXinotify_add_watch 0x103XX, 0x103XXprctl 0x105XXpthread_create 0x105XXkill 0x104XX, 0x12eXX, 0x12eXX
关键终止逻辑:
0x0104XX: bl #0xa9740x0104XX: tbz w0, #0, #0x104XX0x0104XX: add x0, sp, #0x5780x0104XX: bl #0x9e600x0104XX: cmp w0, #0x1c0x0104XX: b.gt #0x104XX0x0104XX: bl #0x96b0 ; getpid@plt0x0104XX: mov w1, #90x0104XX: bl #0x9990 ; kill@plt
语义为:条件命中后执行 kill(getpid(), SIGKILL)。结合 inotify、线程创建和 prctl,可判断存在 native 后台监控与主动终止机制。它可能承担反调试、防篡改、文件监控或环境检测。
5.6 有些 /proc 代码只是崩溃上报
以下字符串也被发现:
/proc/%d/comm/proc/%d/cmdlinesignal %d (%s), code %d (%s), fault addr %sCRASH_REPORT
这些引用集中在 0x27dXX–0x27fXX,前后代码都在拼 native crash report,读的是进程名、线程名和信号信息。这个锅不能甩给反 Frida,它就是出事以后写“案发报告”的。
6. Frida 检测:代码怎么看,手机上又发生了什么
6.1 静态特征扫描
检查了:
fridagum-js-loopgmainlinjectorLIBFRIDA/data/local/tmp2704227043
在目标 SO 中未发现这些明确明文特征。这意味着:
没有证据证明它采用最简单的 Frida 明文字符串扫描; 仍可能动态解密检测字符串; 也可能通过线程、端口、内存映射、文件、ptrace 状态或行为异常检测; /proc/self/maps、 strstr、inotify 和主动kill构成通用检测能力,但目标不一定专指 Frida。
6.2 实际连接故障及定位
最初使用:
frida -U -p PID -l hook_realtime_ranking.js报错:
Failed to attach: unable to connect to remote frida-server: closed当时应用进程还活着,frida-ps 也能列出进程。继续查才发现,手机上的 Frida 服务监听在:
0.0.0.0:PORT建立明确端口转发后:
adb forward tcp:PORT tcp:PORTfrida-ps -H 127.0.0.1:PORT -ai
可以正常连接。正确注入命令为:
frida -H 127.0.0.1:PORT \-n 某股票分析软件 \-l hook_realtime_ranking.js \-o realtime_ranking_plaintext_utf8.log
这次“连接关闭”最后是个乌龙:-U 走的默认 USB 通道没对上手机端的 自定义端口。端口转发一加,脚本立刻能挂进去。分析反调试最怕这种事,环境没配对,先把壳冤枉一遍。
6.3 运行稳定性注意事项
测试中碰到过一次 native SIGSEGV,栈落在 ART/Java Formatter 一带,时间点刚好卡在旧会话退出和重新加载附近。这个问题没有稳定复现,先记账,不急着判成壳在杀进程。重启应用、改走 自定义端口 后,明文连续抓取正常。
后续调试应记录:
注入前后 PID; tombstone、logcat 和 Frida 输出; 崩溃是否能稳定复现; 是否只在 detach、重复 Hook 或高频打印时发生; 是否因在热路径大量调用 toString()导致性能或并发问题。
7. 实时龙虎榜业务链路定位
7.1 关键类型
响应 protobuf:xxx.xxx.stockapp.socket.data.Market$RealtimeRankingResp业务转换类: xxx.xxx.stockapp.socket.mapper.ResponseMapper目标方法: ResponseMapper.convert(RealtimeRankingResp)
7.2 精确代码位置
文件:
jadx_runtime/sources/xxx/xxx/stockapp/socket/mapper/ResponseMapper.java方法位于第 87–121 行:
public static List convert(Market.RealtimeRankingResp response) {List itemsList = response.getItemsList();ArrayList rows = new ArrayList();for (int i = 0; i < itemsList.size(); i++) {Market.RankingItem item = (Market.RankingItem) itemsList.get(i);StockRow value = new StockRow();value.setStockId(item.getStockId());value.setStockName(item.getName());value.setStopTrade(item.getStatus() == 1);value.setLatestPrice(Float.parseFloat(item.getQuotas(1)));value.setGangName(item.getStockTag());value.setLabelType(item.getFinancingTag());String[] indexes = mapQuotaFields(item, false);indexes[34] = item.getQuotas(34);indexes[35] = item.getQuotas(35);indexes[36] = item.getQuotas(36);value.setIndexDatas(indexes);value.setWarnTag(item.getWarnTag());rows.add(value);}return rows;}
走到这个方法时,收包、解密、解压和 protobuf 解析都已经结束,下一步就是组装 UI 数据。这个位置很舒服:上游那些协议脏活 App 已经干完,下游页面又还没来得及格式化和丢字段。
7.3 已确认字段
代码里能直接对上的字段有:
stockId = item.getStockId()name = item.getName()status = item.getStatus()latestPrice = Float.parseFloat(item.getQuotas(1))stockTag = item.getStockTag()financingTag = item.getFinancingTag()warnTag = item.getWarnTag()
其余业务字段由内部映射方法将 quotas[] 转成页面索引。公开版把原方法名隐藏了,后续仍需结合动态表头和 ViewHolder 做完整映射。
7.4 “示例科技主力净额”验证
实测对象:
stockId: ”30XXXX”name: ”示例科技”quotas: ”股权转让、算力”quotas: ”97”quotas: ”20”...quotas: ”59688740”
59688740 以元计:
59688740 / 10000 = 5968.874 万App 页面格式化并四舍五入后显示“5969万”。所以字段并未漏抓,只是原始 protobuf 使用无名称的 quotas[] 数组,需要补充索引语义与单位格式化。
8. Frida 明文 Hook 实现
8.1 ClassLoader 处理
加固和 Tinker 会把类塞进不同的 ClassLoader。只调一次 Java.use() 很容易扑空,所以脚本会把当前 ClassLoader 都试一遍:
functiontryUseWithLoaders(className) {try {return Java.use(className);} catch(_) {const loaders = Java.enumerateClassLoadersSync();for(let i = 0; i < loaders.length; i++) {try {const factory = Java.ClassFactory.get(loaders[i]);return factory.use(className);} catch(_) {// 继续查找包含目标类的加固/Tinker ClassLoader}}}return null;}
目标类可能延迟加载,所以安装失败时每秒重试,并在成功后清除定时器。完整实现见 hook_realtime_ranking.js:54-145。
8.2 核心 Hook
const RESP_CLASS =”xxx.xxx.stockapp.socket.data.Market$RealtimeRankingResp”;const CONVERTER_CLASS =”xxx.xxx.stockapp.socket.mapper.ResponseMapper”;const Mapper = tryUseWithLoaders(CONVERTER_CLASS);const convert = Mapper.convert.overload(RESP_CLASS);convert.implementation = function (resp) {console.log(”========== Realtime LHB plaintext begin ==========”);console.log(safeString(resp));const items = resp.getItemsList();for (let i = 0; i < items.size(); i++) {console.log(”[items.” + i + ”] ” + safeString(items.get(i)));}const result = convert.call(this, resp);console.log(”========== Realtime LHB plaintext end ============”);return result;};
精确位置:hook_realtime_ranking.js:82-118。
8.3 protobuf 中文转码
protobuf Message.toString() 会将非 ASCII UTF-8 输出成八进制转义,例如:
\345\244\251\345\261\261Frida 内置转换:
function decodeProtobufOctalUtf8(text) {return text.replace(/(?:\\[0-3][0-7][0-7])+/g, function (run) {const bytes = [];run.replace(/\\([0-3][0-7][0-7])/g, function (_, octal) {bytes.push(parseInt(octal, 8));return _;});return new TextDecoder(”utf-8”, { fatal: false }).decode(new Uint8Array(bytes));});}
精确位置:hook_realtime_ranking.js:26-52。
历史日志离线转换:
OCTAL_RUN = re.compile(r”(?:\\[0-3][0-7][0-7])+”)OCTAL_BYTE = re.compile(r”\\([0-3][0-7][0-7])”)def decode_run(match):data = bytes(int(v, 8) for v in OCTAL_BYTE.findall(match.group(0)))return data.decode(”utf-8”, errors=”replace”)
完整脚本为 decode_protobuf_log.py。
8.4 已验证结果
[+] Hook installed:xxx.xxx.stockapp.socket.mapper.ResponseMapper.convert(xxx.xxx.stockapp.socket.data.Market$RealtimeRankingResp)
示例数据:
quotaType: 23sortType: 1limitType: 6count: 26total: 5204stockId: ”30XXXX”name: ”示例科技”quotas: ”股权转让、算力”quotas: ”97”quotas: ”20”...quotas: ”59688740”
9. 长连接、WebSocket 与订阅模型判断
9.1 WebSocket 与当前协议的区别
WebSocket 是建立在 TCP 上的标准消息协议,通常以 HTTP Upgrade 握手,具有标准帧结构和 Ping/Pong。当前应用更符合自定义 TCP 长连接:自定义包头、命令号、序列号、订阅 ID、protobuf 负载和重连恢复。
Charles 没显示标准 WebSocket 会话也正常。它就算能截到 TCP 流,里面多半也是二进制 protobuf,前面再套一层加密的话,看起来和乱码没啥区别。
9.2 订阅请求证据
文件:jadx_runtime/sources/xxx/SocketDispatcher.java:348-375。
if (request.a() == CMD_SUBSCRIBE && request.l() instanceof Protocol.SubscribeRequest) {for (Protocol.SubscribeRequest.Item item : request.l().getReqsList()) {Packet sub = Packet.builder().c(item.getCmd()).a();sub.e((byte) 2, Integer.valueOf(item.getSubId()));sub.g((short) item.getSeqId());sub.s(parse(item.getData(), item.getCmd()));pending.add(sub);}}
还发现:
Protocol.SubscribeRequest 订阅请求Protocol.UnsubscribeRequest 按订阅 ID 取消Protocol.ResubscribeNotice 重连后的恢复订阅通知
9.3 龙虎榜响应命令
文件:jadx_runtime/sources/xxx/SocketDispatcher.java:523-528。
if (cmd == CMD_RANKING) {return Market.RealtimeRankingResp.parseFrom(payload);}
到这里,两个命令号已经能对上:
cmd=CMD_SUBSCRIBE → 通用订阅请求cmd=CMD_RANKING → 实时龙虎榜 protobuf 响应
9.4 实际通信模型
进入实时龙虎榜页面→ 客户端发送 SubscribeRequest(cmd=CMD_SUBSCRIBE)→ 子项声明订阅业务 cmd=CMD_RANKING→ 服务端确认并返回当前完整快照→ 行情变化时继续推送 RealtimeRankingResp→ 离开页面发送 UnsubscribeRequest→ 断线重连后通过 ResubscribeNotice 恢复订阅
心跳只负责维持 TCP 会话,不等于龙虎榜业务推送。
9.5 时序实测限制
某次收盘后的静置测试中,Hook 挂好后等了 30 秒,没有新的 RealtimeRankingResp;进入或刷新页面,马上来了一批不同榜单类型的响应。不过那会儿已经收盘,数据本来就不怎么动。这 30 秒只能说明“页面动作会触发订阅或快照”,推送频率还得等交易时间再蹲。
已能够确定的是:客户端必须先订阅;服务端至少返回初始快照,并具备后续推送能力。交易时段需同时记录 cmd=CMD_SUBSCRIBE 发送和 cmd=CMD_RANKING 接收,才能精确计算更新间隔。
10. 后续采集方案
10.1 采集点先钉在这里
目前继续 Hook:
ResponseMapper.convert(RealtimeRankingResp)原因:
已完成网络收包; 已完成粘包/拆包; 已完成解密和解压; 已完成 protobuf 反序列化; 与页面实际消费的数据一致; 已通过动态测试验证。
第一版先别重写私有 TCP 客户端。鉴权、心跳、序列号、加密、订阅恢复、版本兼容,全是坑。让原 App 维持连接,我们只拿它已经解析好的结果,成本最低。
10.2 采集链路草案
目标 App→ 保持 socket 登录和订阅→ Frida/LSPosed Hook 明文对象→ 转换为结构化 JSON→ 本地接收器→ 去重→ JSONL 原始归档→ SQLite 查询库→ 可选 HTTP/MQ 推送到自有服务
10.3 建议记录结构
{”receive_time”: ”2026-08-18T22:51:46.158+08:00”,”server_timestamp”: 1787064095,”quota_type”: 23,”sort_type”: 1,”limit_type”: 6,”stock_id”: ”30XXXX”,”name”: ”示例科技”,”concept”: ”股权转让、算力”,”price”: 97.0,”change_percent”: 20.0,”main_net_yuan”: 59688740,”main_net_display”: ”5969万”,”raw_quotas”: [”...”],”source_cmd”: CMD_RANKING}
原始 quotas[] 必须保留,避免早期字段映射错误造成不可恢复的数据损失。
10.4 去重键
去重先做两层:
消息级:server_timestamp + quotaType + sortType + limitType记录级:server_timestamp + quotaType + stockId + hash(raw_quotas)
如果服务端 timestamp 粒度不足,则加入本地接收时间窗口和 payload 哈希。
10.5 存储选择
JSONL:原始审计、持续追加、故障恢复简单; SQLite:按股票、榜单类型、时间查询和去重; CSV:仅作为导出格式,不建议作为唯一主存储; 远端接口:应采用批量发送、失败重试和本地缓冲,避免网络异常阻塞 App 热路径。
10.6 性能优化
当前调试脚本对完整 protobuf 和每个 item 都执行 toString(),适合验证但日志量很大。生产采集应:
只提取需要字段; 使用 send(object)将 JSON 交给宿主 Python;不在 App 主线程做文件 I/O; 设置有界队列和丢弃策略; 每批写入,避免逐字段 console.log();对相同 payload 做哈希去重。
11. LSPosed/Xposed 常驻方案
Frida 已验证了类、方法和数据模型。长期运行可迁移到 LSPosed:
handleLoadPackage→ 过滤 xxx.xxx.stockapp→ Hook Application.attach(Context)→ 获得真实 ClassLoader→ 等待加固/Tinker 业务类加载→ Hook ResponseMapper.convert(RealtimeRankingResp)→ 提取字段并异步持久化
关键骨架:
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) {if (!”xxx.xxx.stockapp”.equals(lpparam.packageName)) return;XposedHelpers.findAndHookMethod(Application.class,”attach”,Context.class,new XC_MethodHook() {@Overrideprotected void afterHookedMethod(MethodHookParam param) {Context context = (Context) param.args[0];ClassLoader loader = context.getClassLoader();installRealtimeLhbHook(loader);}});}private void installRealtimeLhbHook(ClassLoader loader) {Class<?> resp = XposedHelpers.findClass(”xxx.xxx.stockapp.socket.data.Market$RealtimeRankingResp”,loader);Class<?> converter = XposedHelpers.findClass(”xxx.xxx.stockapp.socket.mapper.ResponseMapper”,loader);XposedHelpers.findAndHookMethod(converter,”convert”,resp,new XC_MethodHook() {@Overrideprotected void beforeHookedMethod(MethodHookParam param) {Object plaintext = param.args[0];enqueueForPersistence(plaintext);}});}
实际实现必须增加:多 ClassLoader 枚举、安装一次保护、Tinker 延迟加载重试、异步队列、进程名过滤、模块私有存储或 ContentProvider。
12. 调试方向与验证计划
12.1 交易时段推送频率测试
同时记录:
T_sub_send cmd=CMD_SUBSCRIBE 发出时间T_sub_ack 订阅确认时间T_snapshot 首个 cmd=CMD_RANKING 时间T_push[n] 后续 cmd=CMD_RANKING 时间T_unsub UnsubIdReq 时间T_heartbeat 心跳时间
由此计算:
首包延迟 = T_snapshot - T_sub_send推送间隔 = T_push[n] - T_push[n-1]
并在以下场景分别测试:页面前台、锁屏、应用后台、网络切换、断线重连、离开页面。
12.2 quotas[] 字段表建立
建立 0–N 的完整字段映射:
从业务映射方法确认转换关系; 从动态表头配置和 ViewHolder 确认显示名称; 对照手机页面验证单位、百分号、颜色和四舍五入; 选取至少三只股票和三种 quotaType交叉验证;保留未知字段,不凭单条样本猜测语义。
12.3 Frida 检测进一步验证
如果需要确定 native 监控目标,可在授权测试环境中记录而非直接修改:
fopen("/proc/self/maps")的调用栈和后续匹配参数; inotify_add_watch(fd, path, mask)的 path 与 mask; kill(pid, 9)之前的调用栈、线程名和判定返回值; pthread_create创建的监控线程入口; prctl的 option 与线程名; 应用正常运行和 Frida 注入时的差异。
只有获得具体监控路径、匹配字符串或触发条件后,才能严谨地将某条逻辑标记为“Frida 检测”。
12.4 稳定性验收
连续运行至少一个完整交易日; 断网恢复后订阅自动恢复; App 重启后采集自动恢复; 日志不会无限占满磁盘; 去重率与漏包率可量化; Hook 异常不影响 App 原方法执行; 数据时间、股票数量和页面显示抽样一致。
13. 现在做到哪了,后面还剩什么活
核心链路已经跑通了。
APK 用的是 360 系列加固,业务代码还叠了 Tinker。运行时 9 个 DEX 已经拿到,socket 路由和 protobuf 转换代码也能正常反编译。libprotector_a64.so 里确实有一些不好惹的东西:它会扫 /proc/self/maps,会用 inotify 盯路径,还留着后台线程和 kill(getpid(), 9)。至于这些逻辑里哪一段专门抓 Frida,目前证据还差临门一脚。
Frida 这条线已经实机验证。之前连不上是自定义端口的问题,转发后可以稳定 Hook ResponseMapper.convert(RealtimeRankingResp)。订阅和业务响应使用独立命令号,取消订阅和断线恢复的类型也都找到了。这套通信就是自定义 TCP 长连接上的 protobuf 订阅机制。
数据也对上了。比如示例股票页面显示“主力净额约 6000 万”,底层对象能抓到对应的元单位整数,换算和页面的四舍五入结果一致。说明 Hook 点没选歪,明文就在这里。公开版对股票、代码和精确数值做了扰动。
核心难题解决以后,后面基本都是脏活累活:把 quotas[] 每个下标和页面字段一项项对齐,交易时间蹲推送频率,处理重复包,补断线重连,控制日志体积,再把 JSONL、SQLite 和上传服务接起来。
下一阶段先做字段字典和交易时段时序测试。等这两件事稳定,再考虑 LSPosed 常驻。
14. 快速复现命令
# 1. 确认设备与应用adb devices -ladb shell pidof xxx.xxx.stockapp# 2. 转发手机端 Frida 自定义端口adb forward tcp:PORT tcp:PORT# 3. 确认应用可见frida-ps -H 127.0.0.1:PORT -ai | grep 某股票分析软件# 4. 注入明文 Hook 并保存日志frida -H 127.0.0.1:PORT \-n 某股票分析软件 \-l hook_realtime_ranking.js \-o realtime_ranking_plaintext_utf8.log# 5. 必要时转换旧版八进制日志python3 decode_protobuf_log.py \realtime_ranking_plaintext.log \realtime_ranking_plaintext_cn.log# 6. 重新生成 SO 反汇编与交叉引用报告.venv-so-analysis/bin/python analyze_libprotector.py
以上命令均以当前 macOS 测试环境为准。
夜雨聆风