安卓端某音乐类 APP 逆向分享(三):params 参数加密分析——上
脱敏说明:本文涉及的类名、包名、so 库文件名均已脱敏,真实的加密密钥、盐值、前后缀字符串以
****代替,不包含任何可直接用于攻击真实服务的敏感信息。本文仅用于逆向工程技术原理的学习与记录。
前言
在上一篇协议分析中,我们确认了歌曲搜索接口的 POST body 中只有一个字段 params,其值是一串十六进制大写密文。本篇(上)将分析 params 的生成全流程,从 Java 层调用链定位出发,一步步深入到 Native 层的 so 文件,最终在静态分析阶段发现加密算法线索(AES + MD5)。
下一篇(下)将继续通过 IDA Pro 动态调试、unidbg 模拟执行和汇编指令流分析,完整还原加密算法的每个细节。

一、Java 层代码分析
1.1 搜索 params 关键字
使用反编译工具 jadx(Java Decompiler X)搜索字符串 "params",结果多达 1286 条,无法直接判断哪一处负责生成 POST body 中的 params 字段。
由于 params 作为参数名一般以 ("params", 的形式被 put() 或 add() 到某个数据结构中,可以改为搜索 ("params", 这个更精确的字符串,大幅减少结果数量:

技巧补充:类似的精确搜索策略在 jadx 中非常实用。如果参数是通过 put 添加到 HashMap 或 Bundle 的,可以直接搜索形如 ("key_name", 的字符串;如果是通过 add 添加到 List,则可结合字段名进行过滤。
1.2 定位拦截器函数
通过上一步的精确搜索,分析得出 params 参数是在以下函数中生成的:
com.example.music.network.retrofit.q.a.intercept
该函数是一个 OkHttp 拦截器(Interceptor)的 intercept 方法,在 HTTP 请求发出前对请求体进行修改,将明文参数替换为加密后的 params 字段:
1.3 定位核心加密函数:serialdata
继续追踪调用链,最终定位到核心函数:
// 核心加密函数,接收请求路径和请求体,返回加密后的 params 字符串
com.example.music.utils.MusicUtils.serialdata(String str1, String str2)
jadx 显示该函数带有 native 关键字修饰,说明其实现在某个动态链接库(.so 文件)中,Java 层通过 JNI 调用它:

为什么用 Native 实现加密:将核心加密逻辑下沉到 Native 层(.so 文件)是 Android 应用常见的安全加固手段。Native 代码以二进制形式存储,比 Java 字节码更难被反编译和理解;同时,可以通过 so 文件加固(加壳、混淆)进一步提升分析难度。
1.4 Frida Hook serialdata 函数
既然已知 serialdata 是产生 params 的关键,可以直接用 Frida Hook 这个函数,观察它接收的参数和返回值,无需立即分析 Native 层:
// Frida 脚本:Hook serialdata 函数,打印入参和返回值
Java.perform(function() {
// 获取 MusicUtils 类的引用
var utils = Java.use("com.example.music.utils.MusicUtils");
// 重写 serialdata 方法(指定参数类型以区分重载)
utils.serialdata.overload("java.lang.String", "java.lang.String")
.implementation = function(str1, str2) {
// 先执行原始方法,获取加密结果
var res = this.serialdata(str1, str2);
// 打印入参和结果
console.log("=== serialdata called ===");
console.log("str1 (path):", str1);
console.log("str2 (body):", str2);
console.log("res (enc) :", res);
console.log("\n");
return res; // 必须返回原始结果,否则会影响 App 正常运行
};
});
用 objection 工具同样可以 Hook 该函数,下图展示了 Hook 得到的函数入参和返回值:

参数 1(str1):请求路径
/api/search/song/page
参数 2(str2):请求体 JSON(明文)
{
"offset": "0",
"limit": "20",
"channel": "typing",
"keyword": "天空之城",
"rqrefer": "[F:63][1667359577935#545#8.8.50#221010200836][e][3][8]...",
"scene": "normal",
"header": "{}",
"e_r": true
}
返回值:加密后的十六进制字符串(即 params 的值)
74A595527B7A1647174ADDB4F261E92FF2AFA5F42E4694960F9C5A3746841C9B...
1.5 现阶段可行方案:函数服务化
至此,已经可以通过以下几种方式将 serialdata 函数服务化——即封装为 HTTP API,传入路径和请求体,返回加密结果,无需深入理解加密算法本身:
|
|
|
|
|
|---|---|---|---|
| Frida RPC |
serialdata |
|
|
| Xposed 模块 |
|
|
|
| unidbg |
.so 文件 |
|
|
本文选择继续深入分析 serialdata 的具体实现,还原完整的加密算法,最终在 Python 中独立复现,彻底摆脱对设备环境的依赖。
二、Native 层代码分析
2.1 定位 serialdata 函数在哪个 .so 文件中实现
2.1.1 通过 loadLibrary 搜索(效果有限)
通常 Java 层使用 System.loadLibrary("xxx") 加载 .so 文件。用 jadx 搜索 loadLibrary,发现结果较多,难以快速定位是哪个库实现了 serialdata:

2.1.2 Hook RegisterNatives(精准定位)
RegisterNatives 是 JNI 中的关键函数,其作用是将 Java 层声明的 native 方法与 Native 层的实现函数进行绑定。调用格式如下:
RegisterNatives(JNIEnv*, jclass clazz, const JNINativeMethod* methods, jint nMethods)
其中 methods 数组的每个元素包含三个字段:name(方法名)、signature(签名)、fnPtr(函数指针)。通过 Hook 这个函数,可以读出每个 native 方法对应的函数指针,再通过 Process.findModuleByAddress 确定其所在 .so 文件和相对偏移。
以下是基于 Frida 实现的 Hook RegisterNatives 脚本:
// 在 libart.so 中查找 ART 的 RegisterNatives 函数地址
functionfind_RegisterNatives() {
// 枚举 libart.so 的所有符号,找到 RegisterNatives
let symbols = Module.enumerateSymbolsSync("libart.so");
let addrRegisterNatives = null;
for (let i = 0; i < symbols.length; i++) {
let symbol = symbols[i];
// 匹配条件:包含 art + JNI + RegisterNatives,排除 CheckJNI 变体
if (symbol.name.indexOf("art") >= 0 &&
symbol.name.indexOf("JNI") >= 0 &&
symbol.name.indexOf("RegisterNatives") >= 0 &&
symbol.name.indexOf("CheckJNI") < 0) {
addrRegisterNatives = symbol.address;
console.log("RegisterNatives found at:", symbol.address, symbol.name);
hook_RegisterNatives(addrRegisterNatives);
}
}
}
// Hook RegisterNatives,打印每个注册的 native 方法信息
functionhook_RegisterNatives(addrRegisterNatives) {
if (addrRegisterNatives == null) return;
Interceptor.attach(addrRegisterNatives, {
onEnter: function(args) {
let java_class = args[1];
let methods_ptr = ptr(args[2]);
let method_count = parseInt(args[3]);
// 获取 Java 类名
let class_name = Java.vm.tryGetEnv().getClassName(java_class);
console.log("[RegisterNatives] class:", class_name, "method_count:", method_count);
// 遍历所有注册的方法,每条记录 3 个指针:name / signature / fnPtr
for (let i = 0; i < method_count; i++) {
let name_ptr = Memory.readPointer(methods_ptr.add(i * 3 * Process.pointerSize));
let sig_ptr = Memory.readPointer(methods_ptr.add((i * 3 + 1) * Process.pointerSize));
let fnPtr = Memory.readPointer(methods_ptr.add((i * 3 + 2) * Process.pointerSize));
let name = Memory.readCString(name_ptr);
let sig = Memory.readCString(sig_ptr);
// 通过函数指针找到所属 .so 文件及基址
letmodule = Process.findModuleByAddress(fnPtr);
console.log(
" name:", name,
"sig:", sig,
"fnPtr:", fnPtr,
"module:", module.name,
"base:", module.base,
"offset:", ptr(fnPtr).sub(module.base)
);
}
}
});
}
setImmediate(find_RegisterNatives);
执行脚本:
frida -U -l hook_RegisterNatives.js -f com.example.music --no-pause
输出结果如下图所示:

关键信息:
-
serialdata注册在libmusic_core.so中,偏移地址为0x4e041 -
deserialdata(响应解密函数)同样在libmusic_core.so中,偏移地址为0x4e125
记住 0x4e041 这个偏移地址,后面分析 so 文件时会用到。
2.2 libmusic_core.so 文件修复
2.2.1 直接用 IDA Pro 打开——失败
用 IDA Pro 直接打开 APK 包中的 libmusic_core.so 文件,提示文件已损坏,无法正常加载:

这说明 App 对该 so 文件进行了加固处理,在 APK 打包时人为破坏了文件头(ELF magic number)或修改了某些关键字段,使静态分析工具无法正常解析。而在 App 运行时,系统会在加载前动态修复这个文件,因此不影响实际运行。
常见的 so 加固手段:修改/清空 ELF 文件头的 magic number(\x7fELF)、破坏 Section Header Table、加密函数体、使用 .init_array 在运行时自解密等。
解决方案:从运行中的进程内存中 dump 出已被系统修复的 so 文件。
2.2.2 查看进程 ID 和内存映射
首先查看目标 App 的进程 ID:

读取该进程的 /proc/<pid>/maps 文件,找到 libmusic_core.so 在内存中的起始和结束地址,例如 0xbf942000 到 0xbf9da000:

2.2.3 Frida + Python 内存 Dump 脚本
以下脚本通过 Frida 的 Memory.protect 获取 so 文件的读权限,再用 readByteArray 读出完整内存,写入本地文件:
import frida
import sys
defon_message(message, data):
"""Frida 消息回调(本脚本不需要处理消息)"""
pass
deffrida_script():
"""返回注入到目标进程的 Frida JS 脚本字符串"""
return"""
rpc.exports = {
// 查找模块信息(基址 + 大小)
findmodule: function(so_name) {
return Process.findModuleByName(so_name);
},
// 从内存中读出整个 .so 文件
dumpmodule: function(so_name) {
var libso = Process.findModuleByName(so_name);
if (libso == null) {
return -1; // 模块未加载
}
// 先将内存页权限改为可读可写可执行,确保 readByteArray 不会权限报错
Memory.protect(ptr(libso.base), libso.size, 'rwx');
// 读出整个模块内容作为 ArrayBuffer 返回
var libso_buffer = ptr(libso.base).readByteArray(libso.size);
return libso_buffer;
},
};
"""
defdump(so_name: str) -> None:
"""
连接前台应用,通过 Frida 从内存中 dump 指定 .so 文件
:param so_name: 目标 .so 文件名(不含路径),如 "libmusic_core.so"
"""
device = frida.get_usb_device()
pid = device.get_frontmost_application().pid # 获取前台应用 PID
session = device.attach(pid)
script = session.create_script(frida_script())
script.on('message', on_message)
script.load()
# 获取模块基址和大小
module_info = script.exports.findmodule(so_name)
base = module_info["base"]
size = module_info["size"]
print(f"base: {base} size: {size}")
# 从内存读出 so 内容并写入文件
module_buffer = script.exports.dumpmodule(so_name)
if module_buffer != -1:
dump_so_name = so_name + ".dump.so"
with open(dump_so_name, "wb") as f:
f.write(module_buffer)
print(f"Dumped to: {dump_so_name}")
else:
print("Error: module not found in memory")
if __name__ == "__main__":
dump("libmusic_core.so")
执行后打印出 base: 0xbf942000, size: 622592,与 maps 文件中的内存区间完全对应。导出文件命名为 libmusic_core.so.dump.so。
2.2.4 使用 SoFixer 修复文件头
用 IDA Pro 尝试打开 libmusic_core.so.dump.so,发现仍然是纯二进制内容,无法以 ELF 格式解析。这说明内存 dump 出的文件的 ELF 文件头仍然是被篡改过的状态。
使用开源工具 SoFixer 修复文件头,将文件和工具推送到手机:
adb push libmusic_core.so.dump.so /data/local/tmp
adb push SoFixer /data/local/tmp
在手机上执行修复命令(-m 参数指定内存基址):
./SoFixer -m 0xbf942000 -s ./libmusic_core.so.dump.so -o ./libmusic_core.so.fix.so
SoFixer 报错:文件头中的 ELF 魔数(magic number)无效,文件头损坏:

手动修复魔数:使用 010 Editor 打开原始 APK 包中的 libmusic_core.so,将其文件头的前 64 字节(ELF Header)复制,粘贴覆盖到 libmusic_core.so.dump.so 的文件头位置:

ELF 文件头结构简介:标准 ELF 文件的前 16 字节为 e_ident,其中前 4 字节必须是 \x7fELF(魔数)。加固处理通常将这 4 字节改为无效值,使 IDA 等工具拒绝加载。将正确的文件头覆盖后,SoFixer 可以利用运行时地址信息重建 Section Header Table。
修复魔数后再次执行 SoFixer,成功导出 libmusic_core.so.fix.so:

2.3 静态分析 libmusic_core.so.fix.so
2.3.1 偏移 0x4e041 处无法反编译
用 IDA Pro 跳转到偏移 0x4e041(即 serialdata 函数入口),发现该函数无法被 IDA 的 F5 伪代码功能反编译,只能看到原始汇编指令:

这说明该函数的代码段可能经过了混淆或反反编译处理(如使用不规则的 THUMB/ARM 混合指令、插入无效指令序列、修改调用约定等),增加了静态分析的难度。
2.3.2 发现 AES 和 MD5 函数声明
虽然 serialdata 函数本身难以直接分析,但通过 IDA Pro 的 Exports 窗口(导出函数列表),可以看到该 so 文件声明了多个明文可见的加密函数:

关键函数及其偏移地址:
|
|
|
|
|---|---|---|
MD5_Init |
0x0000BA0C |
|
MD5_Update |
0x0000C688 |
|
MD5_Final |
0x0000C7A0 |
|
MD5_Transform |
0x0000C798 |
|
md5_block_data_order |
0x0000BA58 |
|
AES_set_encrypt_key |
0x0000C96C |
|
AES_encrypt |
0x0000CEC0 |
|
AES_set_decrypt_key |
0x0000C970 |
|
AES_decrypt |
0x0000DA00 |
|
private_AES_set_encrypt_key |
0x0000D100 |
|
这些函数名直接以明文形式暴露在导出表中,强烈暗示 serialdata 的加密逻辑涉及 AES 和 MD5。
2.3.3 加密算法特征魔数识别
当 so 文件经过深度混淆、函数名无法看到时,可以通过特征魔数(Magic Constants)识别使用了哪种加密算法:
MD5 特征魔数(初始化向量,固定值):
0x67452301 0xefcdab89 0x98badcfe 0x10325476
0xd76aa478 0xe8c7b756 ...(T 表常量)
AES 特征魔数(S-Box 相关):
0xd66b6bbd 0xde6f6fb1 0x91c5c554 0x60303050 0x2010103 ...
在 IDA 中使用 Search → Immediate Value 搜索这些魔数,即便函数名已被混淆,也能八九不离十地识别出所使用的加密算法。这是逆向工程中定位加密逻辑的通用技巧。
本篇小结
|
|
|
|---|---|
|
|
params
q.a.intercept 生成,最终调用 MusicUtils.serialdata(path, body) |
|
|
|
|
|
serialdata
libmusic_core.so 中,偏移 0x4e041 |
|
|
|
|
|
|
下一篇将通过 IDA Pro 动态调试、unidbg 模拟执行 和 汇编指令流逐步追踪,还原 AES 加密的明文组成方式、密钥、以及 MD5 的输入内容,最终给出可独立运行的 Python 加密还原实现。
夜雨聆风
