乐于分享
好东西不私藏

某股票分析软件 APK 脱壳、协议分析与实时采集技术文档

某股票分析软件 APK 脱壳、协议分析与实时采集技术文档

目标包名: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
运行时获得的业务 DEX,共 9 个
jadx_runtime/sources/
运行时 DEX 的 JADX 反编译结果
libprotector_a64_full.asm
加固 SO 完整 AArch64 反汇编,指令数量已模糊处理
libprotector_a64_disassembly_report.md
SO 字符串、PLT 和调用交叉引用报告
analyze_protector.py
LIEF + Capstone 自动分析脚本
hook_realtime_ranking.js
已验证可工作的 Frida 明文 Hook
decode_protobuf_log.py
protobuf 八进制 UTF-8 日志转换脚本
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 复现步骤

  1. 确认 ADB 与设备:
adb devices -ladb shell pm list packages | grep xxx.xxx.stockappadb shell pidof xxx.xxx.stockappadb shell getprop ro.product.cpu.abi
  1. 确认 Frida 服务端和客户端版本一致:
frida --versionfrida-ps -H 127.0.0.1:PORT -ai
  1. 如果手机端 Frida 监听自定义端口,建立转发:
adb forward tcp:PORT tcp:PORT
  1. 在应用完成壳初始化、业务页面可以正常打开后,再执行 DEX dump。这样能确保 Tinker 和延迟加载类已经进入 ART。
  2. 对输出 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 无法加载它。

解决办法:

  1. 使用 LIEF 根据 program header 和 dynamic table 恢复可执行段、动态符号与重定位;
  2. 使用 Capstone 直接反汇编 RX PT_LOAD
  3. 识别 AArch64 PLT 模板并恢复 GOT → 导入函数的映射;
  4. 扫描 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”    ):        continue    got_address = adrp.operands[1].imm + ldr.operands[1].mem.disp    if 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\261

Frida 内置转换:

function decodeProtobufOctalUtf8(text) {    return text.replace(/(?:\\[0-3][0-7][0-7])+/gfunction (run) {        const bytes = [];        run.replace(/\\([0-3][0-7][0-7])/gfunction (_, octal) {            bytes.push(parseInt(octal, 8));            return _;        });        return new TextDecoder(”utf-8”, { fatalfalse })            .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(),适合验证但日志量很大。生产采集应:

  1. 只提取需要字段;
  2. 使用 send(object) 将 JSON 交给宿主 Python;
  3. 不在 App 主线程做文件 I/O;
  4. 设置有界队列和丢弃策略;
  5. 每批写入,避免逐字段 console.log()
  6. 对相同 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() {            @Override            protected 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() {            @Override            protected 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 的完整字段映射:

  1. 从业务映射方法确认转换关系;
  2. 从动态表头配置和 ViewHolder 确认显示名称;
  3. 对照手机页面验证单位、百分号、颜色和四舍五入;
  4. 选取至少三只股票和三种 quotaType 交叉验证;
  5. 保留未知字段,不凭单条样本猜测语义。

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 测试环境为准。