为了一个 sign,我跟一段 ARM 汇编对视了两天
先说结果:算法还原出来了,Python 复现对得上。
过程嘛……中间换了三次思路,崩了七次 App,有两次是手机自己重启的。
写下来,省得下次再走一遍。
起因
Java 层就一行:
public static native String getSign(String str);
没了。
没有别的实现,没有备用路径,全在 so 里。
so 是 arm64 的,没有 x86 版本,所以跑不了 PC 上的模拟器。
我电脑又是台 Intel 的老笔记本,也没法跑 arm 模拟器。
只能真机 + frida,或者 unidbg。
第一步:先别急着开 IDA
我见过不少人拿到 so 直接扔进 IDA,然后对着三千行反汇编发呆。
先花五分钟搞清楚它的骨架。
file libnative-lib.so
ELF 64-bit LSB shared object, ARM aarch64, dynamically linked, stripped
stripped。
nm -D libnative-lib.so | head -40
导出的符号就那几个标准的:JNI_OnLoad?没有。
nm -D libnative-lib.so | grep -i onload
也没有。
这时候基本可以确定:动态注册,而且 JNI_OnLoad 被隐藏了(或者被 strip 掉了)。
那就别在静态文件里找了,直接在运行时抓。
抓注册表
RegisterNatives 是 JNI 的标准函数,所有动态注册都得走它。
function hookReg() {
var libart = Process.findModuleByName('libart.so');
var addr = null;
libart.enumerateSymbols().forEach(function (s) {
if (s.name.indexOf('RegisterNatives') >= 0 && s.name.indexOf('CheckJNI') < 0) {
addr = s.address;
}
});
if (!addr) { console.log('没找到'); return; }
Interceptor.attach(addr, {
onEnter: function (args) {
var count = args[3].toInt32();
if (count <= 0 || count > 300) return;
var mod = Process.findModuleByAddress(this.returnAddress);
if (!mod || mod.name.indexOf('native-lib') < 0) return;
console.log('\n=== ' + mod.name + ' 注册了 ' + count + ' 个 ===');
for (var i = 0; i < count; i++) {
var p = args[2].add(i * Process.pointerSize * 3);
console.log(' ' + p.readPointer().readCString()
+ p.add(Process.pointerSize).readPointer().readCString()
+ ' -> ' + p.add(Process.pointerSize * 2).readPointer());
}
}
});
}
hookReg();
第一次跑,控制台刷屏。
三屏半,全是系统的类,java.lang.String、android.os.Build、kotlin.jvm.internal……
我要找的那条被埋了。
这里是我第一个坑:RegisterNatives 全系统都在调,必须按调用者模块过滤。
加了 Process.findModuleByAddress(this.returnAddress) 之后,世界安静了:
=== libnative-lib.so 注册了 6 个 ===
getSign(Ljava/lang/String;)Ljava/lang/String; -> 0x7c3a1234
nativeInit()V -> 0x7c3a1560
nativeDecrypt(Ljava/lang/String;)Ljava/lang/String; -> 0x7c3a1720
...
拿到绝对地址,减基址:
var base = Process.findModuleByName('libnative-lib.so').base;
console.log('offset = 0x' + (addr.sub(base)).toString(16));
offset = 0x1a534。
扔进 IDA,G 跳过去。
静态:先看骨架,别看细节
函数几百行。
如果你一行行读,三天也读不完。
我的办法是先看调用关系。
getSign 的伪代码大致是:
SUB SP, SP, #0x90
STP X29, X30, [SP,#0x60]
MOV X0, #0x3A
BL sub_1A2C0 ; 常量解密
...
BL sub_1D3F0 ; MD5
BL sub_1E700 ; hex
RET
四五个调用,结构就出来了。
剩下的问题是:这几个 sub 各干什么?
判断算法靠常量表,不靠脑子
这一招我用了很多年,比看汇编快十倍。
MD5 的实现里一定有这四个初值:
0x67452301 0xefcdab89 0x98badcfe 0x10325476
在 Hex View 里搜 little-endian 的字节序列:
01 23 45 67 89 AB CD EF FE DC BA 98 76 54 32 10
搜到就是 MD5。
SHA256 是:
6a09e667 bb67ae85 3c6ef372 a54ff53a
AES 的 S 盒开头:
63 7c 77 7b f2 6b 6f c5 30 01 67 2b fe d7 ab 76
RC4 更好认:找一段 256 字节的循环填充,00 01 02 03 ... FF。
我在这个 so 里同时搜到了 MD5 的 IV 和一个 RC4 的 S 盒初始化痕迹。
基本能确定:MD5 + RC4。
还要一个常量解密(sub_1A2C0),那是 key 的来源。
静态看不动的,交给 frida
静态只能告诉你「调了 MD5」,不能告诉你「MD5 的输入是什么」。
这部分只能动态。
把每个 sub 都 attach 上:
var base = Process.findModuleByName('libnative-lib.so').base;
function trace(offset, name) {
Interceptor.attach(base.add(offset), {
onEnter: function (args) {
console.log('\n[' + name + '] 进来');
for (var i = 0; i < 3; i++) {
try {
var s = args[i].readCString();
if (s && s.length < 512) console.log(' arg' + i + ' = ' + s);
} catch (e) {}
}
},
onLeave: function (ret) {
try { console.log(' out = ' + ret.readCString()); } catch (e) {}
}
});
}
trace('0x1a2c0', 'const_dec');
trace('0x1b550', 'rc4');
trace('0x1d3f0', 'md5');
第一次跑,App 崩了。
Fatal signal 11 (SIGSEGV), code 1
readCString 对着一个非字符串指针读,直接段错误。
这是我第二个坑。
改成先查内存区间:
function safeRead(p, maxLen) {
try {
var range = Process.findRangeByAddress(p);
if (!range) return null;
if (range.protection.indexOf('r') < 0) return null;
return p.readUtf8String(Math.min(maxLen || 256, range.size));
} catch (e) { return null; }
}
再跑,稳了,输出:
[const_dec] 进来
arg0 = 3A
out = 1f2e3d4c5b6a7988
常量解出来了,key 是 1f2e3d4c5b6a7988。
然后卡了两天
按理说到这应该顺利了。
结果 attach 上去跑了不到一分钟,App 退了。
Fatal signal 5 (SIGTRAP), code 1
SIGTRAP。
int3 或者 brk 指令。
反调试。
第一层:ptrace
nm -D libnative-lib.so | grep ptrace
objdump -d libnative-lib.so | grep -c ptrace
ptrace 被 import 了,调用点用了 GOT 跳转,静态数不出来。
hook 掉:
var ptrace = Module.findExportByName(null, 'ptrace');
Interceptor.replace(ptrace, new NativeCallback(function (req, pid, addr, data) {
console.log('[ptrace] 拦了,req=' + req);
return 0;
}, 'long', ['int', 'int', 'pointer', 'pointer']));
ptrace 是 PTRACE_TRACEME 自己 trace 自己,你把它弄成永远返回 0,它以为成功了,继续往下跑。
过了。
第二层:TracerPid
读 /proc/self/status,看 TracerPid 是不是 0。
frida 挂上去之后这个值不是 0。
var open = Module.findExportByName(null, 'open');
Interceptor.attach(open, {
onEnter: function (args) {
this.path = safeRead(args[0], 128);
},
onLeave: function (ret) {
if (this.path && this.path.indexOf('/proc/self/status') >= 0) {
console.log('[open] 开了 status,fd=' + ret);
}
}
});
确认之后,最暴力的解法是 hook fgets 把那一行改掉。
不过我这个更狠,它读的是 maps。
第三层:maps 扫描
扫 /proc/self/maps 里有没有 frida、gum。
frida 挂在进程里,maps 一定会露馅。
这个绕不干净,只能:
- 改名 frida-server
- 用魔改版(把
frida、gum 字符串替换掉)
- magisk + Shamiko 隐藏
我三个一起上,才稳定。
第四层:时间差
这个最阴。
t1 = clock_gettime();
[i]// 你的关键代码
t2 = clock_gettime();
if (t2 - t1 > threshold) { 检测到调试 }
frida 的 Interceptor 会带来额外开销,两三百微秒。它设的阈值可能就是五十微秒。
hook clock_gettime 把差调小:
var cgt = Module.findExportByName(null, 'clock_gettime');
var g_lastTp = 0;
Interceptor.attach(cgt, {
onEnter: function (args) { this.tp = args[1]; },
onLeave: function (ret) {
[i]// 简单处理:返回的时间戳统一减掉一个固定值
[i]// 复杂处理:记录调用频率,模拟正常节奏
}
});
说实话这个绕不干净,只能降概率。
我在这层卡了整整两天。
中间有一次我以为搞定了,跑了四十分钟,结果它在一段睡眠后醒来又检测了一遍,直接退出。
那时候我盯着屏幕,真的有点想砸手机。
换路:unidbg
frida 一直不稳,我转头试了 unidbg。
思路是:不跟你玩动态对抗了,我直接在 PC 上用 unicorn 模拟执行 ARM 指令,把 JNI 环境模拟出来,直接调 getSign。
好处是快、可批量、能下断点,而且它不在设备上跑,你的反调试全是废纸。
坏处是环境得自己补。
搭骨架
public class SignEmulator extends AbstractJni {
private final AndroidEmulator emulator;
private final VM vm;
private final Module module;
private final DvmClass nativeHelper;
public SignEmulator() {
emulator = AndroidEmulatorBuilder.for64Bit().build();
Memory memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
vm = emulator.createDalvikVM(new File("app.apk"));
vm.setJni(this);
vm.setVerbose(false);
DalvikModule dm = vm.loadLibrary(new File("libnative-lib.so"), false);
dm.callJNI_OnLoad(emulator);
module = dm.getModule();
nativeHelper = vm.resolveClass("com/example/app/NativeHelper");
}
public String getSign(String input) {
return nativeHelper.callStaticJniMethodObject(
emulator, "getSign(Ljava/lang/String;)Ljava/lang/String;", input
).getValue().toString();
}
}
跑一下(其实就是 new SignEmulator().getSign("test"))。
报错:
java.lang.UnsupportedOperationException:
com/example/app/NativeHelper->getPackageName()Ljava/lang/String;
意料之中。
补环境
@Override
public DvmObject<?> callObjectMethodV(BaseVM vm, DvmObject<?> obj, String sig, VaList vaList) {
switch (sig) {
case "android/content/Context->getPackageName()Ljava/lang/String;":
return new StringObject(vm, "com.example.app");
case "android/content/Context->getPackageManager()Landroid/content/pm/PackageManager;":
return vm.resolveClass("android/content/pm/PackageManager").newObject(null);
case "android/telephony/TelephonyManager->getDeviceId()Ljava/lang/String;":
return new StringObject(vm, "123456789012345");
}
return super.callObjectMethodV(vm, obj, sig, vaList);
}
再跑,又报错,缺 Build.MODEL。
补:
@Override
public DvmObject<?> getStaticObjectField(BaseVM vm, DvmClass clz, String sig) {
switch (sig) {
case "android/os/Build->MODEL:Ljava/lang/String;":
return new StringObject(vm, "Pixel 4");
case "android/os/Build->BRAND:Ljava/lang/String;":
return new StringObject(vm, "google");
case "android/os/Build->FINGERPRINT:Ljava/lang/String;":
return new StringObject(vm, "google/flame/flame:13/TQ3A.230805.001/10316531:user/release-keys");
}
return super.getStaticObjectField(vm, clz, sig);
}
再跑,又报错。
再补。
这样来回补了四十多次。
小技巧:把 vm.setVerbose(true) 打开,所有 JNI 调用都打出来,缺什么一目了然,比看报错快。
跑通了
public static void main(String[] args) {
SignEmulator e = new SignEmulator();
System.out.println(e.getSign("test_input"));
}
输出:
a1b2c3d4e5f6...
跟真机抓包对一下,一致。
那一刻是凌晨一点多,我泡了第二包方便面。
但 unidbg 只能算,不能解释
它让你能算,不代表你懂了。
要真正还原,还是得回到 IDA。
这时候 unidbg 有个巨大优势:能下断点,能看每个函数的入参出参,不用跟反调试斗智斗勇。
emulator.getBackend().addBreakPoint(module.base + 0x1B550, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
RegisterContext ctx = emulator.getContext();
System.out.println("[rc4] in = " + ctx.getPointerArg(0).getString(0));
return true;
}
});
配合前面 frida 已经确认的算法组合,逐个验证:
sub_1A2C0 → 异或解密常量,得到 RC4 key
sub_1B550 → RC4 加密明文
sub_1D3F0 → MD5(密文 + 时间戳)
sub_1E700 → 转 hex 大写
最终结构:
sign = MD5( RC4(key, 参数串) + 时间戳 ).toUpperCase()
key = 每次启动时用异或解出来的常量
Python 复现
[i]import hashlib
KEY = bytes.fromhex("1f2e3d4c5b6a7988")
def rc4(key, data):
S = list(range(256))
j = 0
[i]for i [i]in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
i = j = 0
out = bytearray()
[i]for c [i]in data:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
out.append(c ^ S[(S[i] + S[j]) % 256])
[i]return bytes(out)
def get_sign(raw, ts):
enc = rc4(KEY, raw.encode())
[i]return hashlib.md5(enc + str(ts).encode()).hexdigest().upper()
验一遍抓包,对上了。
收工。
踩过的坑,一条条列
RegisterNatives 不加过滤等于自杀。 全系统都在调,你要找的那条会淹在几百条里。按 returnAddress 查模块。
readCString 会崩。 参数可能不是字符串指针。先 findRangeByAddress 查可读性。
JNI_OnLoad 不一定导出。 被 strip 或者 visibility("hidden") 就找不到,只能靠 hook dlopen/dlsym 追。
常量搜索比读汇编快十倍。 MD5 IV、SHA256 IV、AES S 盒、RC4 的 0-255 填充,都是可以盲搜的特征。
unidbg 补环境别贪全。 跑到哪报错补哪,一次补四十个方法纯属自虐。
frida 和 unidbg 交叉验证。 同一条输入两边算出一样的结果,你才能确定还原对了。
反调试先解决再分析。 带着反调试硬跑,你永远分不清崩溃是反调试还是自己 hook 错了。这一点我吃了两天的亏。
最后
三天里真正花在「理解算法」上的时间,大概四小时。
剩下两天半都在跟反调试和崩溃搏斗。
所以你要问我这行最难的是什么,不是逆向能力,是耐心。
以及知道什么时候该换条路走。
frida 走不通就上 unidbg,别死磕。
细节可能有记岔的地方,欢迎指正。