乐于分享
好东西不私藏

某音乐 App 逆向(三):calc签名算法完整还原

某音乐 App 逆向(三):calc签名算法完整还原

摘要:本文记录了对某知名音乐应用(已脱敏)libmer.so 中 calc() 函数的完整逆向分析过程。分析历经 7 个阶段、约 20 小时,最终还原出核心哈希算法为标准 HMAC-SHA1,并完成 17/17 测试向量全部验证。文章详细记录了 Frida Stalker 的失败尝试、CFF 控制流平坦化的对抗、以及最终通过 Hook 底层密码学原语获得突破的完整思路。


核心结论

hash = HMAC-SHA1(key = "XXXXXXXXXXXX", message = reverse(base64(content)))
  • 算法:标准 HMAC-SHA1
  • 密钥:固定 12 字节 ASCII 字符串(已脱敏,下文以 XXXXXXXXXXXX 表示)
  • 消息预处理:对 content 做 base64 编码后反转字符串
  • 验证:17/17 已知测试向量全部通过
  • Python 实现:3 行核心代码,无需设备即可离线计算

目标信息

项目
应用
某音乐应用(已脱敏)v20.x.x
目标库
libmer.so(2.3MB,ARM64,stripped)
编译器
tmesec_llvm(clang 6.0.0)— 目标安全团队定制 LLVM
混淆
Control Flow Flattening(CFF)、内联加密、动态常量生成
目标函数 MERJni.calc(byte[] content, byte[] params) → String
设备
Pixel 6 Pro,Android 15,arm64-v8a,root
工具
Frida 17.8.2,radare2,Python 3.10,Unicorn,Capstone
分析日期
2026-03-17 ~ 2026-03-19

一、分析背景

1.1 前序工作

在开始哈希算法逆向之前,已完成以下工作:

  1. 请求/响应编解码:M-Encoding m1 = Deflate 压缩 + 5 字节随机前缀(已完全还原)
  2. 签名调用链CgiRequest.g0() → SignRequestHelper.c() → MERJni.calc()
  3. calc I/O 格式calc(content, params) → "sign_b64 mask_b64"
    • sign = Base64(12B 前缀 + 20B 哈希) = 44 字符
    • mask = Base64(104B 数据) = 140 字符
  4. Frida 桥接方案:通过 AttachCurrentThread 实现 RPC 直调,约 3ms/call

1.2 本次目标

完全还原 20 字节哈希的生成算法,实现纯 Python 离线计算。

1.3 挑战

libmer.so 使用了多重保护措施来对抗逆向:

保护措施
描述
对分析的影响
CFF 混淆
控制流平坦化,636 个基本块,单函数 14,784 字节
无法直接阅读反编译代码
内联加密
SHA-1 实现完全内联,不调用 OpenSSL 导出 API
搜索 API 调用无法定位加密逻辑
动态常量
SHA-1 轮常量通过 mov/movk 指令对动态生成
二进制搜索 .rodata 找不到密码学常量
混淆谓词
使用全局变量(0x266328/0x26632c,均=0)的条件分支
静态分析难以判断分支走向
去符号
stripped,无调试信息,函数名为 sub_xxxx
无语义信息辅助理解
手动 padding
SHA-1 的 padding 不用库函数,逐字节 Update
观察数据流时产生大量噪声

二、分析流程全景

逆向分析流程全景(7 阶段)

下图以垂直流水线的形式展示了 7 个分析阶段及其最终成果。值得注意的是,阶段 2(Frida Stalker)的彻底失败和阶段 3/4 的受挫,并非时间的浪费,而是逐步明确了”不可行路径”——这直接促成了阶段 6 的正确切入点。

各阶段的时间与产出汇总如下:

阶段
方法
耗时
成果
1
静态分析(radare2)
~4h
函数定位、CFF 结构
2
Frida Stalker 追踪
~6h
全部失败,明确边界
3
CFF 去混淆
~3h
27/46 转换,不完整
4
Unicorn 模拟执行
~2h
验证 CFF 调度器,崩溃
5
假设驱动测试
~3h
17 组 I/O,缩小范围
6 SHA-1 内部追踪 ~2h 完整算法(关键突破)
7
算法验证
~0.5h
17/17 全部通过

总计:~20 小时,约 35 个脚本文件,~4,000 行代码


三、阶段 1:静态分析

3.1 函数定位

calc 函数通过 JNI RegisterNatives 动态注册,不在导出表中。通过 Frida Hook RegisterNatives 捕获到函数地址:

// Hook RegisterNatives,遍历方法数组找 "calc"
// 注意:JNI 方法结构体每项占 24 字节:name(8) + sig(8) + fnPtr(8)
Interceptor.attach(regAddr, {
onEnterfunction(args{
var nMethods = args[3].toInt32();  // 方法数量
var methods = args[2];             // 方法数组指针
for (var i = 0; i < nMethods; i++) {
var name = methods.add(i * 24).readPointer().readUtf8String();
if (name === "calc") {
// 找到 calc,偏移 +16 处是函数指针
                calcFnPtr = methods.add(i * 24 + 16).readPointer();
// 结果:calcFnPtr = base + 0x6e098
            }
        }
    }
});

结果calc JNI 函数入口在 libmer.so + 0x6e098

3.2 核心函数链

通过 radare2 对 calc 入口的交叉引用分析,确定了完整调用链:

calc (0x6e098)
  └→ core_function (0x8bbb8, 36,868B, 580 BBs)
       └→ hash_function (0x13bfd0, 14,784B, 636 BBs)   ← CFF 混淆的核心
            ├→ SHA1_Init      (0xbbf1c, 140B)            ← 标准 SHA-1 初始化
            ├→ SHA1_Update    (0xbbfa8)                  ← 喂数据
            ├→ SHA1_Transform (0xbc378)                  ← 块压缩变换
            ├→ SHA1_compress  (0xb9a60, 9,404B)          ← 压缩函数展开
            ├→ HMAC_wrapper   (0xbc978, 2,500B)          ← HMAC 两阶段封装
            ├→ XOR_copy       (0xa52f8, 200B)            ← ipad/opad XOR 操作
            └→ base64_encode  (0xb130c)                  ← base64 编码

这个调用链已经暗示了 HMAC-SHA1,但在当时被 CFF 混淆掩盖,未能立即意识到。

3.3 CFF 混淆结构

hash_function(0x13bfd0)被 CFF 混淆成调度器模式,下图完整展示了其结构:

CFF 混淆结构:hash_function (0x13bfd0)

关键特征

  • 状态寄存器w20——保存当前”要执行哪个基本块”的标识
  • 调度器地址:0x13c0dc——每次执行完一个基本块,都强制跳回这里
  • 状态匹配模式
    • 标准:cmp w8, w9; b.eq target
    • fall-through:cmp w8, w9; mov w20, w8; b.ne dispatcher(后者是 CFF 去混淆的难点)
  • 混淆谓词:全局变量 0x266328 和 0x26632c 运行时均为 0,通过 ccmp/csel 构造看似条件但实际恒定的分支

为什么 CFF 难以直接阅读?

传统反编译器(如 Ghidra、IDA Pro)会将控制流重建为高级语言结构(if/else/loop)。但 CFF 通过”所有分支都先经过调度器”的模式,将线性逻辑打散成 636 个独立碎片,反编译器无法重建原始控制流,只能输出 14,784 字节的 switch 语句迷宫。

3.4 密码学常量搜索(误判)

初始的静态分析尝试在 .rodata 中搜索已知密码学常量:

搜索目标
偏移
找到?
初始判断
MD5 IV 67452301
0x216410, 0x2162b8
以为是 MD5
MD5 IV EFCDAB89
附近
加强了 MD5 假设
SHA-1 K0 5A827999
排除了 SHA-1
SHA-1 K1 6ED9EBA1
排除了 SHA-1
SHA-1 h4 C3D2E1F0
排除了 SHA-1

误判原因:SHA-1 和 MD5 前四个初始向量值完全相同(01234567 89ABCDEF FEDCBA98 76543210)。搜索到的”MD5 IV”实际上是 SHA-1 的标准 IV。而第五个 SHA-1 IV C3D2E1F0 通过动态 mov/movk 指令生成,不在数据段中,因此被误判为”无 SHA-1″。这个误判导致在”MD5 变体”假设上浪费了大量时间。


四、阶段 2:动态追踪失败记录

为了理解 CFF 混淆后的实际执行流,尝试了 8 种 Frida Stalker 方案,全部失败:

#
方案
脚本
结果
根本原因
1
Stalker + NativeFunction
stalker_hash.js
0 BBs
Frida 文档:”Calls to NativeFunction will not be followed”
2
Stalker 纯模式
stalker_pure.js
0 BBs
同上,RPC 调用底层仍经过 NativeFunction
3
ARM64 跳板 + Stalker
stalker_cmodule.js
0 BBs
跳板通过 NativeFunction 调用,仍被排除
4
Interceptor + Stalker
stalker_hash_v2.js
崩溃
“breakpoint triggered”——两者使用冲突的代码修改方式
5
Stalker.exclude + Interceptor
stalker_exclude.js
崩溃
即使排除范围也无法避免冲突
6
Stalker events 模式
stalker_events.js
TypeError
Stalker.parse API 在 Frida 17.8.2 中存在兼容问题
7
Java bridge Stalker
stalker_java.js
错误
typeof Java === "undefined"

——frida-server 构建问题
8
pthread_create
stalker_pthread.js
部分
线程运行但 Stalker 无法与之集成

4.1 根本性限制分析

限制 1:NativeFunction 排除
  Frida 的 Stalker 使用 JIT 编译来追踪代码执行路径
  但通过 NativeFunction 发起的调用被显式排除
  因为 NativeFunction 自身使用了与 Stalker 冲突的执行机制

限制 2:Interceptor/Stalker 冲突
  Interceptor 通过替换指令为断点 (BRK) 工作
  Stalker 通过 JIT 重编译整个代码块工作
  两者无法在同一代码区域共存——后者会破坏前者的断点

限制 3:Java bridge 不可用
  frida-server 构建可能缺少 Java bridge 支持
  typeof Java === "undefined"
  回退方案:使用纯 native 的 JNI 调用(后续阶段采用)

教训:这 8 次失败消耗了约 6 小时,但明确了 Frida Stalker 的能力边界,迫使转向其他方法。不尝试就无法知道它不行——这些”失败”是必要的探索成本。


五、阶段 3 & 4:CFF 去混淆与模拟执行

5.1 CFF 静态去混淆(cff_deobf_v2.py)

使用 Capstone 反汇编引擎解析 sub_13bfd0 的 14,784 字节代码,核心逻辑如下:

# CFF 去混淆核心思路(cff_deobf_v2.py,466 行)
# 步骤 1:将代码分割为基本块(以 b/bl/ret 等分支指令为边界)
# 步骤 2:在每个基本块中查找 w20 的赋值(即下一个要执行的状态)
# 步骤 3:解析混淆谓词(加载全局变量 0x266328/0x26632c,已知运行时为 0)
# 步骤 4:解析 csel 指令(条件选择 → 已知条件结果后变为确定值)
# 步骤 5:构建状态转换图:state_A → 执行的代码 → state_B

import capstone
from capstone import arm64_const

cs = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM)
cs.detail = True

defextract_next_state(bb_bytes, base_addr):
"""
    从基本块字节中提取 w20 赋值(下一个调度状态)。
    支持直接 mov 和 csel 两种模式。
    """

for insn in cs.disasm(bb_bytes, base_addr):
if insn.mnemonic == 'mov'and insn.reg_name(insn.operands[0].reg) == 'w20':
return insn.operands[1].imm   # 直接状态常量
if insn.mnemonic == 'csel':
# 混淆谓词:根据全局变量(已知=0)选择分支
# 分析 ccmp 前置条件,确定 csel 的确定性选择
return resolve_csel_with_predicate(insn, base_addr)
returnNone# fall-through 模式(b.ne dispatcher),需要单独处理

结果

  • 找到 136 个 w20 状态赋值(30 个唯一直接赋值,其余为 csel/条件保持)
  • 成功提取 27/46+ 个状态转换
  • 链条在第 1 步(状态 0x12d803bf)断裂——该状态通过 b.ne fall-through 模式设置,解析器未能正确处理

5.2 Unicorn ARM64 模拟执行(emu_hash.py)

将 libmer.so 的 ELF 段加载到 Unicorn ARM64 模拟器中:

# emu_hash.py 内存布局设计
# 目标:在无 Android 运行时的环境中执行 hash_function
BASE  = 0x1_0000_0000   # SO 加载基址(避免与真实地址冲突)
STACK = 0x7_0000_0000   # 栈空间(1MB)
HEAP  = 0x8_0000_0000   # 堆空间(输入/输出缓冲区)
TLS   = 0x9_0000_0000   # TLS 区域(stack canary 存放处)

# 关键初始化步骤
# 1. 解析 ELF,加载所有 PT_LOAD 段到对应虚拟地址
# 2. 将混淆谓词全局变量(0x266328, 0x26632c)显式写为 0
# 3. 初始化 stack canary(避免 __stack_chk_fail)
# 4. 在 HEAP 区域写入测试输入(content="A",length=1)
# 5. 设置寄存器:x0=content_ptr, x1=1, x2=output_ptr
mu.mem_write(BASE + 0x266328b'\x00' * 4)   # 混淆谓词 = 0
mu.mem_write(BASE + 0x26632cb'\x00' * 4)   # 混淆谓词 = 0

结果

  • 成功执行前约 500 条指令,验证了 CFF 调度器的状态转换逻辑
  • 在 0x12bd3c 处崩溃:ldr x8, [x8, #0x30]——C++ 虚表调用,x8 指向空对象
  • 该函数(sub_12b838)需要完整的 C++ 运行时对象,模拟器中无法提供
  • 模拟执行的价值:虽然最终崩溃,但成功确认了 CFF 的调度机制,为后续分析提供了信心

六、阶段 5:假设驱动测试

6.1 测试向量收集

设计了系统化的测试方案,通过 Frida 桥接(AttachCurrentThread + JNI 直调)收集 17 组输入/输出对:

# collect_pairs.py 测试策略(218 行)
# 覆盖:空内容、单字节边界值、多字节、不同长度、不同字节序

tests = [
    ("empty",    b""),            # 空内容:验证算法是否处理 len=0
    ("0x00",     bytes([0x00])),  # 最小值边界
    ("0x01",     bytes([0x01])),  # 最小非零值
    ("0x41",     bytes([0x41])),  # ASCII 'A':常见测试字节
    ("0x42",     bytes([0x42])),  # ASCII 'B':雪崩效应测试
    ("0x43",     bytes([0x43])),  # ASCII 'C'
    ("0xff",     bytes([0xFF])),  # 单字节最大值
    ("0x0000",   bytes([0,0])),   # 双字节全零
    ("0x0001",   bytes([0,1])),   # 字节序测试
    ("0x0100",   bytes([1,0])),   # 同上(验证顺序影响)
    ("AA",       bytes([0x41,0x41])),
    ("AB",       bytes([0x41,0x42])),
    ("AAAA",     bytes([0x41]*4)),  # 4 字节重复
    ("A*8",      bytes([0x41]*8)),  # 8 字节(SHA-1 块大小影响测试)
    ("A*16",     bytes([0x41]*16)), # 16 字节
    ("A*32",     bytes([0x41]*32)), # 32 字节
    ("A*64",     bytes([0x41]*64)), # 64 字节(等于 HMAC block size)
]

# 额外:固定 content,变化 params 不同字段——确认哈希是否依赖 params
params_variations = ["改变 version""改变 uin""改变 platform"]

6.2 关键发现

从收集的数据中得出了三个重要结论:

发现 1:哈希仅依赖 content

# 验证:相同 content,不同 params,哈希完全相同
content = bytes([0x41])
params_v1 = build_params(uin="20010508", platform="android2")
params_v2 = build_params(uin="20010509", platform="android2")  # 改变 uin
params_v3 = build_params(uin="1234567890", platform="ios")     # 多项改变

hash_v1 = call_calc(content, params_v1)[0]  # 取 sign 中的 hash 部分
hash_v2 = call_calc(content, params_v2)[0]
hash_v3 = call_calc(content, params_v3)[0]

assert hash_v1 == hash_v2 == hash_v3  # 三者完全相同
# 结论:hash = f(content),与 params、时间戳、会话状态无关

发现 2:雪崩效应良好

# 验证雪崩效应:单字节差异 → 约 50% 的输出位翻转
hash_41 = get_hash(bytes([0x41]))  # 'A'
hash_42 = get_hash(bytes([0x42]))  # 'B'

xor_result = bytes(a ^ b for a, b in zip(hash_41, hash_42))
hamming_distance = sum(bin(b).count('1'for b in xor_result)
# 结果:汉明距离约 80 / 160 bits(约 50%)
# 符合密码学哈希的雪崩效应特征,排除了简单 CRC/校验和

发现 3:输出为 20 字节(160 位)

  • MD5 = 16 字节 → 不匹配
  • SHA-1 = 20 字节 → 长度匹配
  • RIPEMD-160 = 20 字节 → 长度匹配

6.3 算法假设测试(test_md5_hypothesis.py)

系统测试了 10 种假设:

#
假设
测试方法
结果
1
MD5(content) 子串
前 16/后 16 字节对比
✗ 不匹配
2
MD5(content + 常量)
尝试多种常量
✗ 不匹配
3
MD5 + CRC32(16+4=20)
拼接对比
✗ 不匹配
4
SHA-1(content)
直接对比
✗ 不匹配
5
RIPEMD-160(content)
直接对比
✗ 不匹配
6
所有 hashlib 算法
遍历 algorithms_available
✗ 无匹配
7
5-word MD5 变体
猜测第 5 个 IV
✗ 无法验证
8
HMAC-MD5/SHA1(简单密钥)
空/常见字符串密钥
✗ 不匹配
9
XOR 分析
不同输入的 XOR 差异
符合密码学哈希特征
10
长度扩展模式
相同字节不同长度
无明显模式

问题:测试 8 只尝试了少量简单密钥("""mer""musicapp""tmesec" 等),没有覆盖到实际密钥。HMAC 密钥空间太大,无法暴力搜索。这促使转向直接观察内部数据流的方法。


七、阶段 6:SHA-1 内部追踪(关键突破)

7.1 思路转变

前面的方法都在尝试从外部(输入/输出)推断算法,或者从宏观(CFF 控制流)理解逻辑。这次换一个角度:

核心洞见:不管 CFF 如何混淆控制流,底层的密码学原语(SHA-1 Init/Update/Transform)是不会被改变的。只要 Hook 这些原语,就能看到上层逻辑传入了什么数据。

这个思路的关键前提:

  1. 已通过 radare2 确认了 SHA-1 相关函数的偏移(IV 常量 + 调用图)
  2. 这些函数是”叶子函数”(底层原语),不受 CFF 混淆影响
  3. 通过 Frida Interceptor 可以安全地 Hook 它们(不需要 Stalker)

7.2 工具链问题排查

在编写追踪脚本时遇到了一系列工具链兼容性问题:

问题 1:Python 版本 / Frida 版本不匹配

# 系统默认 Python 与设备 frida-server 版本不一致
$ python3 --version && python3 -c "import frida; print(frida.__version__)"
Python 3.7.x
16.7.19          # 客户端 frida 版本

$ frida-server --version   # 设备上的 frida-server
17.8.2           # 版本不匹配!客户端需要也是 17.x

# 解决方案:发现系统上还安装了 python3.10,其中 frida 版本匹配
$ python3.10 -c "import frida; print(frida.__version__)"
17.8.2           # 匹配!后续脚本均使用 python3.10 执行

问题 2:Frida hx() 辅助函数的 API 兼容性

// 失败版本:readByteArray() 在某些 Frida 构建中返回 ArrayBuffer 导致 TypeError
functionhx(p, l{
var buf = p.readByteArray(l);  // TypeError: Cannot read properties of null
returnArray.from(newUint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
}

// 修复版本:改为逐字节读取,兼容所有 Frida 版本
functionhx(p, l{
var s = "";
for (var i = 0; i < l; i++) {
var b = p.add(i).readU8();
        s += (b < 16 ? "0" : "") + b.toString(16);
    }
return s;
}

问题 3:libmer.so 在 spawn 时未加载

// 失败:spawn 模式下 libmer.so 尚未被 JVM 加载
var mm = Process.findModuleByName("libmer.so");  // 返回 null!

// 解决:延迟到 RegisterNatives 回调中获取 base——此时 SO 已加载
Interceptor.attach(regAddr, {
onEnterfunction(args{
// ... 找到 calc 后 ...
var mm = Process.findModuleByName("libmer.so");
if (mm) {
            base = mm.base;
            setupSha1Hooks();   // 现在可以安全设置 SHA-1 Hook
        }
    }
});

问题 4:Hook 过多函数导致应用崩溃

早期版本尝试 Hook 19 个函数,导致应用崩溃。原因:某些 CFF 混淆函数的入口被 Interceptor 的断点替换后,CFF 调度器跳转到被修改的代码时产生异常。

// 解决:只 Hook 最底层的 4 个"叶子"函数——它们本身不受 CFF 保护
// 这 4 个函数是独立的、未被 CFF 混淆的工具函数
SHA1_Init      @ base + 0xbbf1c// 初始化 5 个 SHA-1 状态字(h0-h4)
SHA1_Update    @ base + 0xbbfa8// 向当前哈希状态喂入数据块
SHA1_Transform @ base + 0xbc378// 对完整的 64 字节块执行压缩变换
XOR_copy       @ base + 0xa52f8// 执行 key XOR ipad/opad 操作

7.3 追踪脚本架构

下图展示了最终追踪脚本(trace_sha1.py)的完整架构:

SHA-1 内部追踪脚本架构

追踪脚本的核心设计原则:

  • 范围限定:通过 inCalc 标志,只记录 calc() 调用期间的 SHA-1 操作,过滤掉应用其他地方的 SHA-1 调用(如 SSL 握手)
  • 最小 Hook:只 Hook 4 个叶子函数,避免与 CFF 调度器产生干扰
  • 完整记录:每次 SHA-1 操作都记录操作类型、输入数据和状态快照

7.4 追踪结果

运行 python3.10 trace_sha1.py 后,成功捕获了每次 calc() 调用的完整 SHA-1 操作序列。

空内容(content=””)的完整追踪:202 个 SHA-1 操作

阶段 1:HMAC 内层哈希(Inner Hash)
=====================================

[1] SHA1_Init
    state = 01234567 89abcdef fedcba98 76543210 f0e1d2c3
    → 标准 SHA-1 初始向量(h0-h4),确认使用标准 SHA-1

[2] SHA1_Update(64 bytes)  ← HMAC ipad 块
    data = [XX XX XX XX XX XX XX XX XX XX XX XX]  ← 密钥相关(已脱敏)
           36363636 36363636 36363636 36363636
           36363636 36363636 36363636 36363636
           36363636 36363636 36363636 36363636

    分析:前 12 字节 ≠ 0x36,后 52 字节 = 0x36
    → 这是 HMAC ipad = key_padded XOR 0x36
    → 非 0x36 的字节 = key_byte XOR 0x36(从此可还原密钥)

    state_after = 7df9c19e 2d9f9f98 8d95f6f4 71fa4168 707d7cb9

[3] SHA1_Update(0 bytes)  ← 空内容
    → message = ""(空字符串)

[5-60] SHA1_Update(1 byte each)  ← 手动逐字节 SHA-1 padding
    0x80, 0x00, 0x00, ..., 0x00  (55 个零)  ← 标准 SHA-1 padding 的零填充

[61] SHA1_Update(8 bytes)  ← 消息长度(大端序)
    data = 00000000 00000200  (= 512 bits = 64 bytes = ipad 块长度)

[61] SHA1_Transform → inner_hash 输出:
    inner_hash = 0ff33bd6 0a00ae88 d335ead5 de1f92f4 3222b7c7

阶段 2:HMAC 外层哈希(Outer Hash)
=====================================

[62] SHA1_Init
    state = 01234567 89abcdef fedcba98 76543210 f0e1d2c3
    → 重新初始化,开始外层哈希

[63] SHA1_Update(64 bytes)  ← HMAC opad 块
    data = [XX XX XX XX XX XX XX XX XX XX XX XX]  ← 密钥相关(已脱敏)
           5c5c5c5c 5c5c5c5c 5c5c5c5c 5c5c5c5c
           5c5c5c5c 5c5c5c5c 5c5c5c5c 5c5c5c5c
           5c5c5c5c 5c5c5c5c 5c5c5c5c 5c5c5c5c

    分析:前 12 字节 ≠ 0x5C,后 52 字节 = 0x5C
    → 这是 HMAC opad = key_padded XOR 0x5C

[64] SHA1_Update(20 bytes)  ← 内层哈希结果
    data = 0ff33bd6 0a00ae88 d335ead5 de1f92f4 3222b7c7

[66-101] SHA1_Update(1 byte each)  ← 手动 padding(35 个零)
    0x80, 0x00, ..., 0x00

[102] SHA1_Update(8 bytes)  ← 消息长度
    data = 00000000 000002a0  (= 672 bits = 84 bytes = 64 opad + 20 inner)

[102] SHA1_Transform → 最终结果:
hash = 3382f2de 07c5a7d0 7e33d579 bc7532fd 34f3f481

对比期望值(从 Frida 桥接直接获取的 calc 输出解码):

期望:3382f2de07c5a7d07e33d579bc7532fd34f3f481
实际:3382f2de07c5a7d07e33d579bc7532fd34f3f481
结果:完全匹配 ✓

这次追踪提供了三个关键信息:

  1. 两次 SHA1_Init:确认是 HMAC(内层 + 外层两次初始化)
  2. ipad/opad 块结构:确认是标准 HMAC(key XOR 0x36 / key XOR 0x5C)
  3. 密钥长度:12 字节(只有前 12 个字节与 0x36/0x5C 不同)

八、HMAC 密钥提取

8.1 从 ipad 块提取密钥

HMAC 标准定义:ipad_block = (key || 0x00...) XOR 0x36

因此:key[i] = ipad_block[i] XOR 0x36

从追踪数据中,前 12 字节的 ipad 块值(非 0x36 的部分)经过 XOR 0x36 运算后,得到:

ipad 块(前 12 字节,已脱敏为 XX):
位置:  0    1    2    3    4    5    6    7    8    9    10   11   12...
数据: XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   0x36...

XOR 0x36 → 密钥(已脱敏):
位置:  0    1    2    3    4    5    6    7    8    9    10   11   12...
key:  XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   0x00...
ASCII: [X]  [X]  [X]  [X]  [X]  [X]  [X]  [X]  [X]  [X]  [X]  [X]  NUL...

**密钥 = XXXXXXXXXXXX**(12 字节 ASCII 字符串,已脱敏)

8.2 从 opad 块交叉验证

HMAC 标准定义:opad_block = (key || 0x00...) XOR 0x5C

从追踪数据中,前 12 字节的 opad 块值(非 0x5C 的部分)经过 XOR 0x5C 运算后,与从 ipad 提取的密钥完全一致:

opad 块(前 12 字节,已脱敏为 XX):
位置:  0    1    2    3    4    5    6    7    8    9    10   11   12...
数据: XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   0x5c...

XOR 0x5C → 密钥(已脱敏,与 ipad 提取结果一致):
key:  XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   XX   0x00...

ipad 和 opad 提取的密钥完全一致 ✓

8.3 密钥性质分析

从结构特征来看,该密钥是一个 12 字符的十六进制样式字符串(只包含 0-9 和 A-F)。其二进制形态对应某个 6 字节值的十六进制表示。这个值可能来源于:

  • 编译时硬编码的常量
  • APK 签名证书哈希的某个衍生值
  • 某个内部标识符的哈希摘要前 6 字节

由于密钥在不同会话中保持不变,它很可能是硬编码在 SO 中的常量,通过 mov/movk 指令对在运行时动态组装,而非存储在 .rodata 段中(与 SHA-1 轮常量的存储方式相同)。


九、消息预处理发现

9.1 非空内容的数据流

对比空内容和非空内容的追踪数据:

content = b""(空):
  Phase 1: SHA1(ipad || "")                → inner_hash
  Phase 2: SHA1(opad || inner_hash)        → final_hash

content = b"\x41"(字母 A):
  SHA1_Update(4 bytes) data = 3d 3d 51 51 → "==QQ"(ASCII)
  Phase 1: SHA1(ipad || "==QQ")            → inner_hash
  Phase 2: SHA1(opad || inner_hash)        → final_hash

content = b"\x00"
  SHA1_Update(4 bytes) data = 3d 3d 41 41 → "==AA"(ASCII)
  Phase 1: SHA1(ipad || "==AA")            → inner_hash

在 content=b”\x41″ 的追踪中,ipad 后紧跟的 Update 数据不是原始 0x41,而是 4 字节 ASCII 字符串:

[3] SHA1_Update_enter: len=4 data=3d3d5151

9.2 解码预处理逻辑

将 3d 3d 51 51 解释为 ASCII 字符串:==QQ

# 逆向推导消息预处理过程:
# 观察到 content=b"\x41" 时,SHA1_Update 收到的是 b"==QQ"

# 步骤 1:尝试 base64 编码
import base64
b64 = base64.b64encode(b"\x41").decode()   # "QQ=="

# 步骤 2:检查是否是反转
b64[::-1]   # "==QQ"  ← 就是观察到的数据!

# 验证其他用例
assert base64.b64encode(b"\x00").decode()[::-1] == "==AA"# 观察到 3d3d4141
assert base64.b64encode(b"").decode()[::-1] == ""# 空内容,len=0
# 全部符合 ✓

9.3 消息预处理算法

# 完整的消息预处理(message preprocessing)逻辑
defpreprocess_message(content: bytes) -> bytes:
"""
    HMAC-SHA1 的消息预处理:base64 编码后反转字符串。
    目的:对直接搜索 content 原始字节的分析者形成干扰。
    """

if content:
        b64 = base64.b64encode(content).decode('ascii')   # 标准 base64 编码
        message = b64[::-1].encode('ascii')                # 反转字符串
else:
        message = b""# 空内容直接传空消息
return message

# 示例
preprocess_message(b"\x41")   # b"==QQ"(来自 base64("A") = "QQ==" 反转)
preprocess_message(b"\x00")   # b"==AA"(来自 base64("\x00") = "AA==" 反转)
preprocess_message(b"")        # b""(空内容)

这一步可能是一种简单的混淆手段——让直接在内存中搜索 content 原始字节的分析者更难定位数据。base64 + 反转是可逆操作,一旦在追踪中观察到数据,立即可以识别。


十、阶段 7:算法验证

10.1 Python 完整实现

import hmac
import hashlib
import base64

# 已脱敏:实际密钥请通过 SHA-1 内部追踪(追踪 ipad/opad 块)自行提取
HMAC_KEY = b"XXXXXXXXXXXX"# 12 字节固定密钥(已脱敏)

defcalc_content_hash(content: bytes) -> bytes:
"""
    计算 libmer.so calc() 函数的 20 字节内容哈希。

    算法:HMAC-SHA1(key=HMAC_KEY, message=reverse(base64(content)))

    Args:
        content: 原始请求内容字节

    Returns:
        20 字节哈希(即 sign 字段中去除前缀后的部分)
    """

if content:
# 步骤 1:base64 编码
        b64 = base64.b64encode(content).decode('ascii')
# 步骤 2:反转字符串
        message = b64[::-1].encode('ascii')
else:
# 空内容直接传空消息
        message = b""

# 步骤 3:标准 HMAC-SHA1
return hmac.new(HMAC_KEY, message, hashlib.sha1).digest()


defcalc_sign(content: bytes, prefix: bytes) -> str:
"""
    组装 sign 字段:Base64(12B 前缀 + 20B 哈希)。

    Args:
        content: 原始请求内容
        prefix:  12 字节随机前缀(实际应用中由 SO 其他逻辑生成)

    Returns:
        44 字符的 base64 字符串
    """

    hash_bytes = calc_content_hash(content)
    sign_raw = prefix + hash_bytes          # 12 + 20 = 32 字节
return base64.b64encode(sign_raw).decode('ascii')  # → 44 字符

10.2 完整验证结果

HMAC-SHA1 key: XXXXXXXXXXXX(已脱敏)
Testing 17 unique pairs

  ✓ content=(empty)     hash=3382f2de07c5a7d07e33d579bc7532fd34f3f481
  ✓ content=00          hash=04490685e1a84ff684dfa836fcd0d5d03aa47eea
  ✓ content=01          hash=a711df0b44cc29da61fd57a2411c5b154e731192
  ✓ content=41          hash=9bd44515e037cd55d7ad3a4486cb3126312dbf0b
  ✓ content=42          hash=4a75fb3632110fbc26132324f50e745bedc07905
  ✓ content=43          hash=d047840454bd8ec078444a7335423e38882f3211
  ✓ content=ff          hash=71d5047c2ef977f6ff6158ef152ccfd8a0f9ad61
  ✓ content=0000        hash=479427596eefc0873f679c4a450d159eac6f77db
  ✓ content=0001        hash=4bee7d20ca16583c231e362518be8e827eb12f94
  ✓ content=0100        hash=374407fe31fdd4e0e5ed8683e6b8f89335b58ee6
  ✓ content=4141        hash=1448f64fb5f0a47674f9dcdb2f64385fda503e82
  ✓ content=4142        hash=30437f1b0d7cf4310809437a27d9e0fbe34a3cde
  ✓ content=41414141    hash=6f7a0a0eb74bb0955838fd76d87bdf37387c2b2d
  ✓ content=41*8        hash=46fc25ccfcfe26c749852f6f9408f603461e3ca6
  ✓ content=41*16       hash=cf1bc2753966646dadc44348e3d7c834baaa3148
  ✓ content=41*32       hash=b1209951162a6b6672a79dc3fd4483379d973f6c
  ✓ content=41*64       hash=4bd3e65fc4b6bf970df36777bd9ac1ec63a4d68e

Result: 17/17 matched ✓

测试向量覆盖了:

  • 空内容(len=0)
  • 所有单字节边界值(0x00、0x01、0x41-0x43、0xFF)
  • 多字节及不同长度(2、4、8、16、32、64 字节)
  • 不同字节序组合(0x0001 vs 0x0100,确认顺序影响)
  • 不同 params 的验证(确认哈希不依赖 params)

十一、SHA-1 实现细节分析

11.1 非标准的 Padding 行为

标准 SHA-1 库通常在 SHA1_Final 中自动处理 padding。但 libmer.so 的实现采用手动逐字节追加的方式:

标准 SHA-1 流程(OpenSSL 等):
  SHA1_Init()
  SHA1_Update(data, len)
  SHA1_Final(hash_out)    ← 内部自动添加 0x80 + zeros + length(8B)

libmer.so 的流程(逐字节手动 padding):
  SHA1_Init()
  SHA1_Update(ipad, 64)
  SHA1_Update(message, msg_len)
  SHA1_Update(0x80, 1)          ← 手动 padding 起始字节
  SHA1_Update(0x00, 1) × N      ← 手动逐字节零填充(N 次单独调用!)
  SHA1_Update(length_field, 8)  ← 手动长度字段

这导致了 202 次 SHA1_Update 调用(空内容情况下),而标准实现只需 3 次。这种做法可能出于以下目的:

  1. 反追踪:增加追踪日志的噪声,每个 padding 字节都是单独的 Update 调用
  2. 避免导出符号:不调用 SHA1_Final,避免在导出表中暴露该符号
  3. CFF 兼容性:每个 Update 调用都经过 CFF 调度器,符合平坦化代码结构

11.2 SHA-1 轮常量的动态生成

标准 SHA-1 的四个轮常量:

K0 = 0x5A827999  (轮  0-19)
K1 = 0x6ED9EBA1  (轮 20-39)
K2 = 0x8F1BBCDC  (轮 40-59)
K3 = 0xCA62C1D6  (轮 60-79)

这些常量没有存储在 .rodata 段中,而是通过 ARM64 的 mov/movk 指令对在运行时动态生成:

; ARM64 立即数拆分技术:加载 K0 = 0x5A827999
mov  w8, #0x7999          ; w8 = 0x00007999(低 16 位)
movk w8, #0x5A82, lsl #16 ; w8 = 0x5A827999(高 16 位移位写入)

; 同理:K1 = 0x6ED9EBA1
mov  w9, #0xEBA1
movk w9, #0x6ED9, lsl #16

; 同理:K2 = 0x8F1BBCDC
mov  w10, #0xBCDC
movk w10, #0x8F1B, lsl #16

这就是为什么在二进制中搜索 \x99\x79\x82\x5A(K0 的小端序)找不到 SHA-1 特征的原因——常量从未以连续字节的形式存在于 SO 文件中。

11.3 SHA-1 初始向量验证

通过追踪 SHA1_Init 的输出确认了标准 IV,同时澄清了初期的 MD5 误判:

SHA1_Init 输出的 state(28 字节):
01 23 45 67 | 89 ab cd ef | fe dc ba 98 | 76 54 32 10 | f0 e1 d2 c3 | 00 00 00 00 00 00 00 00
├────────────────────────── h0-h4(20 字节)──────────────────────────┤├── 计数器(8 字节)──┤

h0 = 0x67452301  (存储字节序:01 23 45 67)→ 标准 SHA-1 IV
h1 = 0xEFCDAB89  (存储字节序:89 ab cd ef)→ 标准 SHA-1 IV
h2 = 0x98BADCFE  (存储字节序:fe dc ba 98)→ 标准 SHA-1 IV
h3 = 0x10325476  (存储字节序:76 54 32 10)→ 标准 SHA-1 IV
h4 = 0xC3D2E1F0  (存储字节序:f0 e1 d2 c3)→ 标准 SHA-1 IV(动态生成,不在 .rodata!)

误判解析:h0-h3 与 MD5 的前四个 IV 完全相同,这导致初期误认为是 MD5 算法。真正区分 SHA-1 与 MD5 的是第五个 IV h4 = 0xC3D2E1F0——MD5 没有第五个状态字,而该值在 SO 文件中通过动态指令生成,无法被静态搜索发现,由此造成了”SHA-1 常量不存在”的错误结论。


十二、完整算法流程图

算法完整流程:calc() 核心哈希

流程图完整呈现了从原始 content 字节到最终 sign 字段的完整计算路径,包含:

  1. 消息预处理:content 是否为空的分支处理
  2. HMAC 内层哈希:ipad XOR 密钥 + message 的 SHA-1
  3. HMAC 外层哈希:opad XOR 密钥 + inner_hash 的 SHA-1
  4. 签名组装:12B 随机前缀 + 20B 哈希 → Base64

十三、各阶段时间投入与价值评估

阶段
耗时
代码量
成果
价值评估
静态分析(radare2)
~4h
函数定位、CFF 结构
★★★★ 必要基础
Stalker 追踪(8 方案)
~6h
9 个 JS 文件
全部失败
★☆ 明确了不可行路径
CFF 去混淆
~3h
466 行 Python
27/46 转换
★★ 部分有用
Unicorn 模拟
~2h
370 行 Python
验证 CFF,崩溃
★★ 部分有用
假设测试
~2h
224 行 Python
排除标准算法
★★★ 缩小搜索范围
测试向量收集
~1h
218 行 Python
17 组 I/O 对
★★★★★ 关键数据
SHA-1 内部追踪 ~2h 311 行 Python 完整算法 ★★★★★ 决定性突破
算法验证
~0.5h
74 行 Python
17/17 通过
★★★★★ 最终确认

总计:~20 小时,约 35 个脚本文件,~4,000 行代码

关键教训

  • Stalker 方向虽然失败但不可跳过——不尝试就无法知道它不行,6 小时的代价换来了明确的边界认知
  • 最有效的方法是 Hook 底层密码学原语——CFF 混淆的是控制流,但无法改变数据流经密码学原语时的内容
  • 收集足够的测试向量是验证任何假设的前提——17 组 I/O 对让最终验证无可置疑

十四、遇到的问题与解决方案汇总

14.1 工具链问题

问题
现象
解决方案
Python/Frida 版本不匹配
python3

(3.7)的 frida 16.7.19 无法连接 frida-server 17.8.2
发现系统中 python3.10 安装了 frida 17.8.2
Memory.readByteArray

 TypeError
Frida 17.8.2 中 readByteArray 返回的 ArrayBuffer 不支持直接转换
改用逐字节 readU8() 循环
--no-pause

 不支持
Frida CLI 某些版本不支持该参数
移除该参数,使用 resume() API

14.2 动态分析问题

问题
现象
解决方案
libmer.so 未加载
spawn 模式下 Process.findModuleByName("libmer.so") 返回 null
延迟到 RegisterNatives 回调中获取 base
Stalker + NativeFunction
Stalker 无法追踪通过 NativeFunction 发起的调用
放弃 Stalker,转用 Interceptor Hook 原语
Interceptor + Stalker 冲突
两者使用不同的代码修改机制,同一代码区域无法共存
只使用 Interceptor
Hook 多函数崩溃
Hook 19 个函数导致 CFF 调度器跳转到被修改的代码
只 Hook 4 个底层叶子函数
Java bridge 不可用
typeof Java === "undefined"
使用纯 native 的 JNI 直调方案

14.3 分析方向问题

问题
现象
解决方案
MD5 IV 误判为 MD5 算法
SHA-1 前四个 IV 与 MD5 IV 值相同
SHA-1 内部追踪确认了第五个 IV C3D2E1F0
SHA-1 常量搜索失败
轮常量不在 .rodata
追踪确认常量由 mov/movk 动态生成
标准 HMAC 测试失败
测试了常见密钥("""mer" 等)不匹配
通过 ipad/opad 块直接提取真实密钥
“自定义非线性哈希”误判
输出不匹配任何标准算法的直接调用
实际是标准 HMAC-SHA1 + 固定密钥 + 消息预处理

十五、防护评估

15.1 保护有效性

保护措施
对静态分析
对动态分析
对算法还原
整体评估
CFF 混淆
★★★★★ 极有效
★★ 增加噪声
★★★ 延缓分析
主要障碍
内联加密
★★★★ 阻止 API 搜索
★★ 无法通过导出函数定位
★★★ 增加复杂度
有效
动态常量
★★★ 阻止二进制搜索
☆ 不影响运行
★★ 延缓确认
部分有效
手动 padding
☆ 无影响
★★ 增加追踪噪声
☆ 无影响
微弱
消息预处理(base64 反转)
★ 微弱混淆
★ 微弱混淆
★ 需额外分析
微弱

15.2 防护弱点

  1. 底层原语未保护:SHA1_Init/Update/Transform 虽然被内联,但仍作为独立函数存在,可以被 Hook
  2. HMAC 结构暴露:标准 HMAC 的两阶段结构(ipad/opad)通过追踪 SHA1_Init 的调用次数即可识别
  3. 密钥可从数据流提取:ipad/opad 块是明文传入 SHA1_Update 的,不需要逆向密钥的存储/生成方式
  4. 消息预处理太简单:base64 + 反转是可逆操作,追踪一次即可确认
  5. 固定密钥:密钥不依赖会话/设备/时间,一次提取永久有效

15.3 可能的加固建议

如果防御方希望增强保护强度:

  1. 将 SHA-1 原语也内联到 CFF 函数中,或使用自定义 SHA-1(修改轮函数/轮常量)
  2. 使用设备绑定的动态密钥而非固定密钥(如结合 Android Keystore 或设备指纹)
  3. 在 SHA-1 Update 中混入虚假数据(decoy writes),增加追踪分析难度
  4. 使用 anti-Frida 检测(检查 /proc/self/maps 中的 frida-agent 映射)
  5. 混淆消息预处理逻辑(当前 base64+反转过于简单,容易识别)
  6. 考虑引入与请求内容相关的动态参数(时间戳、序列号等),使密钥或消息在每次请求中变化

十六、最终成果

16.1 纯 Python 离线实现

import hmac
import hashlib
import base64

# 注意:HMAC_KEY 已脱敏,使用者需自行通过 SHA-1 内部追踪方法提取
HMAC_KEY = b"XXXXXXXXXXXX"# 12 字节固定密钥(已脱敏)


defcalc_content_hash(content: bytes) -> bytes:
"""
    计算 libmer.so calc() 的 20 字节内容哈希。

    算法全貌:
      1. 若 content 非空,先 base64 编码再反转字符串
      2. 用固定密钥对处理后的消息做标准 HMAC-SHA1
      3. 返回 20 字节摘要

    Args:
        content: 原始请求体字节(Deflate 压缩前的内容)

    Returns:
        bytes: 20 字节 HMAC-SHA1 摘要,对应 sign 字段的后 20 字节
    """

if content:
        b64 = base64.b64encode(content).decode('ascii')   # 标准 base64
        message = b64[::-1].encode('ascii')                # 反转字符串
else:
        message = b""

return hmac.new(HMAC_KEY, message, hashlib.sha1).digest()

16.2 完整验证数据表

Content (hex)
Hash (hex)
验证
(空)
3382f2de07c5a7d07e33d579bc7532fd34f3f481
00 04490685e1a84ff684dfa836fcd0d5d03aa47eea
01 a711df0b44cc29da61fd57a2411c5b154e731192
41 9bd44515e037cd55d7ad3a4486cb3126312dbf0b
42 4a75fb3632110fbc26132324f50e745bedc07905
43 d047840454bd8ec078444a7335423e38882f3211
ff 71d5047c2ef977f6ff6158ef152ccfd8a0f9ad61
0000 479427596eefc0873f679c4a450d159eac6f77db
0001 4bee7d20ca16583c231e362518be8e827eb12f94
0100 374407fe31fdd4e0e5ed8683e6b8f89335b58ee6
4141 1448f64fb5f0a47674f9dcdb2f64385fda503e82
4142 30437f1b0d7cf4310809437a27d9e0fbe34a3cde
41414141 6f7a0a0eb74bb0955838fd76d87bdf37387c2b2d
41

 x 8
46fc25ccfcfe26c749852f6f9408f603461e3ca6
41

 x 16
cf1bc2753966646dadc44348e3d7c834baaa3148
41

 x 32
b1209951162a6b6672a79dc3fd4483379d973f6c
41

 x 64
4bd3e65fc4b6bf970df36777bd9ac1ec63a4d68e

17/17 全部通过

16.3 文件清单

文件
用途
行数
calc_sign.py
主实现(HMAC-SHA1 + Frida 桥接)
663
verify_hmac.py
独立验证脚本
74
trace_sha1.py SHA-1 内部追踪器(关键突破工具)
311
collect_pairs.py
测试向量收集器
218
test_md5_hypothesis.py
算法假设测试
224
emu_hash.py
Unicorn 模拟执行器
370
cff_deobf_v2.py
CFF 去混淆器
466
calc_pairs.json
17 组测试向量(JSON 格式)
sha1_trace_results.json
SHA-1 追踪原始数据

16.4 待完成工作

项目
状态
优先级
20 字节哈希算法
✅ 已完成
12 字节前缀生成
❓ 未分析
104 字节 mask 生成
❓ 未分析
完整离线 calc()
❓ 依赖上述两项

十七、总结

本次逆向分析历经 7 个阶段、约 20 小时、产出约 35 个脚本文件,最终完全还原了 libmer.so 中 calc() 函数的核心哈希算法。

最关键的一步是改变分析思路:从试图理解 CFF 混淆后的控制流(自上而下),转变为直接 Hook 底层密码学原语来观察数据流(自下而上)。CFF 混淆可以让代码不可读,但无法改变数据流经密码学原语时的内容

算法最终被确认为标准 HMAC-SHA1,使用固定密钥和 base64 反转的消息预处理。整个保护方案的安全性主要依赖于 CFF 混淆带来的分析复杂度,而非算法本身的密码学强度。一旦找到正确的分析切入点(底层原语 Hook),算法的还原就变得相当直接。

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 某音乐 App 逆向(三):calc签名算法完整还原

猜你喜欢

  • 暂无文章