吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1313|回复: 8
收起左侧

[Android 原创] so里的加密算法怎么啃,unidbg+frida实操

  [复制链接]
Liyunpeng 发表于 2026-10-24 21:55

为了一个 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 一定会露馅。
这个绕不干净,只能:

  1. 改名 frida-server
  2. 用魔改版(把 frida、gum 字符串替换掉)
  3. 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,别死磕。

    细节可能有记岔的地方,欢迎指正。

免费评分

参与人数 8威望 +1 吾爱币 +25 热心值 +7 收起 理由
正己 + 1 + 20 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
lovFlov + 1 + 1 谢谢@Thanks!
jhdgff + 1 + 1 我很赞同!
AG6 + 1 热心回复!
芽衣 + 1 热心回复!
helian147 + 1 + 1 热心回复!
无名 + 1 + 1 用心讨论,共获提升!
HonDmOon + 1 用心讨论,共获提升!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

loveru 发表于 2026-10-26 12:37
感谢分享,学习到了!
wx16302 发表于 2026-10-26 14:30
wasm2023 发表于 2026-10-26 20:54
addis2579 发表于 2026-10-27 09:22
学习学习
niheibie 发表于 2026-10-27 21:52
学习了!!!
qaz222520222 发表于 2026-10-28 13:29
unidbg 是不是可以直接抽算法出来rpc调用
jackem 发表于 2026-10-28 14:15
值得学习借鉴
lovFlov 发表于 2026-11-8 15:24
收藏了,随时借鉴学习
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-11-11 07:19

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表