首页
社区
课程
招聘
[原创]记录第一次的so文件逆向分析
发表于: 13小时前 195

[原创]记录第一次的so文件逆向分析

13小时前
195

某对 AI 登录 sign 分析:从定位 native 到纯算法还原

说明:本文由 AI 生成和整理,但里面的分析逻辑是我自己一步步跟下来的。

1. 介绍

目标 app:某对 AI
版本:7.8.0 (1820)
参数:登录接口 sign

2. 找加密位置

此 app 没有加壳,所以直接使用 jadx 打开 apk,搜索 sign=。

可以看到 nativeGetSign,这个就是签名的函数,hook 验证一下。

定位 nativeGetSign

这里怀疑有检测,我使用的是 hluda 的 frida,一上 Java 层 hook 就直接退出了。具体退出原因没有继续定位,直接问 AI,告诉我可以先不使用 Java bridge。

那就改走纯 native 路径。下面脚本完全不调用 Java.perform,只监听 JNI 注册,找到 nativeGetSign 的真实函数地址,打印它的输入和返回字符串。

3. 纯 native hook 验证

先把脚本贴出来。这里把前面没有调用的辅助 hook 去掉了,修正了末尾多余的大括号,符号枚举也兼容了一下新旧 Frida API。下面是整理后的版本,只做了语法检查,没有重新上手机跑;截图保留的是前面实际运行时的结果。

'use strict';

const hooked = {};

function readJniString(env, obj) {
    if (obj.isNull()) {
        return "<null>";
    }

    const table = env.readPointer();
    const ps = Process.pointerSize;

    const getUtf8 = new NativeFunction(
        table.add(169 * ps).readPointer(),
        "pointer",
        ["pointer", "pointer", "pointer"]
    );

    const releaseUtf8 = new NativeFunction(
        table.add(170 * ps).readPointer(),
        "void",
        ["pointer", "pointer", "pointer"]
    );

    const p = getUtf8(env, obj, ptr(0));
    if (p.isNull()) {
        return "<null>";
    }

    try {
        return p.readUtf8String();
    } finally {
        releaseUtf8(env, obj, p);
    }
}

function hookNative(fn) {
    // 当前样本是 ARM32,去掉 Thumb 标志后计算代码偏移。
    const code = Process.arch === "arm"
        ? fn.and(ptr("0xfffffffe"))
        : fn;
    const mod = Process.findModuleByAddress(code);

    console.log(
        "[+] native fn = " + fn +
        " module = " + (mod ? mod.name : "?") +
        " offset = " + (mod ? code.sub(mod.base) : "?")
    );

    Interceptor.attach(fn, {
        onEnter: function (args) {
            this.env = args[0];

            console.log(
                "[+] nativeGetSign entered" +
                " env=" + args[0] +
                " jstring=" + args[2]
            );

            try {
                console.log("[+] input = " + readJniString(args[0], args[2]));
            } catch (e) {
                console.log("[-] input decode failed: " + e);
            }
        },

        onLeave: function (retval) {
            console.log("[+] nativeGetSign returned " + retval);

            try {
                console.log("[+] output = " + readJniString(this.env, retval));
            } catch (e) {
                console.log("[-] output decode failed: " + e);
            }
        }
    });
}

const art = Process.getModuleByName("libart.so");
const symbols = typeof art.enumerateSymbols === "function"
    ? art.enumerateSymbols()
    : Module.enumerateSymbolsSync("libart.so");
const reg = symbols.find(function (s) {
    return s.name.indexOf("RegisterNatives") !== -1 &&
           s.name.indexOf("CheckJNI") === -1;
});

if (!reg) {
    throw new Error("RegisterNatives not found");
}

console.log("[+] RegisterNatives @ " + reg.address);

Interceptor.attach(reg.address, {
    onEnter: function (args) {
        const methods = args[2];
        const count = args[3].toInt32();
        const ps = Process.pointerSize;

        for (let i = 0; i < count; i++) {
            const entry = methods.add(i * ps * 3);
            const name = entry.readPointer().readUtf8String();

            if (name !== "nativeGetSign") {
                continue;
            }

            const sig = entry.add(ps).readPointer().readUtf8String();
            const fn = entry.add(ps * 2).readPointer();

            console.log("[+] " + name + sig + " -> " + fn);

            const key = fn.toString();
            if (!hooked[key]) {
                hookNative(fn);
                hooked[key] = true;
            }
        }
    }
});

这里要在目标方法注册之前把脚本挂上,通常用 spawn 方式启动。要是 app 已经注册完了再 attach,就可能什么也抓不到。不同 Android 版本的 RegisterNatives 符号也可能不止一个,上面是当前定位过程的基础写法,附件里还放了一份增加方法描述符和模块检查的版本。

当我们点击登录,打印信息如下:

点击登录后的输入和签名输出

下一步就是分析这个 native 函数。我们在开始已经 hook 到了函数的地址,把运行时地址减去模块基址,得到模块内偏移,再去 IDA 里面跳转即可。

native 函数地址和模块偏移

IDA Pro 打开这个 so 文件,跳转到 0x23bc。

IDA 中的 0x23bc

这里可以使用 unidbg 补环境去还原参数,可以参考这篇文章,比我写的好多了。我也是参考的这篇文章。我这里主要是分析纯算法的还原。

后面就按这个顺序往下走:Java 调用链 → JNI 入口 → 数据流 → 算法特征 → 中间值对照 → 独立实现。先看输入从哪里来,再看 so 里面怎么处理,最后拿原始 so 的结果和自己写的代码对照。

4. signA、signB 从哪来的,先回 jadx 把这条线找到

前面已经定位到了 libbaseutil.so 的 nativeGetSign,看起来下一步应该直接分析汇编。不过这里还有个问题:这个函数是不是只靠我们传进去的 Base64 就能算出 sign?如果它还读取了别的数据,那些数据又是谁准备的?

先别把 signA、signB 当成已经知道的东西。我们就从前面找到的 nativeGetSign 开始,一步步找到它们。

4.1 从 nativeGetSign 的调用方找到初始化入口

在 jadx 里打开 com.zuoyebang.baseutil.NativeHelper,选中 nativeGetSign,右键“查找用法”。跟进去是 com.zuoyebang.baseutil.a 的 e(List<String> list)。删去日志后,输入的构造过程可以整理成下面这样:

list.add("_t_=" + DateUtils.getApproximateServerTimeMillis() / 1000);
list.add("kakorrhaphiophobia=" + initializedElapsedTime);
Collections.sort(list);

String joined = TextUtils.join("", list);
String input = Base64.encodeToString(joined.getBytes("UTF-8"), Base64.NO_WRAP);
String sign = NativeHelper.nativeGetSign(input);

这里有三个细节:列表中已经是 key=value 形式的字符串,排序后用空字符串连接,中间没有 &;Base64 使用 NO_WRAP,不会插入换行;native 收到的是编码后的完整 String。

所以前面 hook 到的一长串 Base64,可以先解码看看里面有哪些参数。但是自己调用 nativeGetSign 时,还是要传 hook 到的原文,不能把解码后的内容塞进去,不然算的就不是同一个东西了。

Java 调用方还会把 native 返回的 sign 和两个时间参数组合起来。本文分析的是 native 返回的那段十六进制签名,不能把外层组合后的字符串当作 MD5 输出。

接下来对这个 e() 再查找用法,可以看到同一个类的 d(List<String> list)。这里很关键,原代码是:

public static synchronized String d(List<String> list) {
    if (f58497b) {
        return e(list);
    }
    f58496a.i("AntiSpan Not Inited,Try init once", new Object[0]);
    f();
    if (!f58497b) {
        return "init_error";
    }
    return e(list);
}

f58497b 虽然被混淆了,但分支和日志都还在:为真就计算签名,为假就先调用 f(),失败了还会返回 init_error。所以我们有依据把它理解成“是否初始化成功”。顺着 f() 跳进去,signA、signB 就在这里。

4.2 signA 是 nativeInitBaseUtil 的返回值

f() 先拿到 application 和 cuid,再读两个缓存。下面保留主进程分支的原变量名,方便对照 jadx:

Application application = InitApplication.getApplication();
String cuid = InitApplication.getCuid();
strB = PreferenceUtils.getString(LibPreference.KEY_ANTISPAM_SIGN_A);
strB2 = PreferenceUtils.getString(LibPreference.KEY_ANTISPAM_SIGN_B);

if (TextUtils.isEmpty(strB) || TextUtils.isEmpty(strB2)) {
    ty.e eVar = f58496a;
    eVar.a("nativeInitBaseUtil start, cuid=%s", cuid);
    String strNativeInitBaseUtil = NativeHelper.nativeInitBaseUtil(application, cuid);
    eVar.a("nativeInitBaseUtil end, signA=%s", strNativeInitBaseUtil);
    PlutoAntispam plutoAntispamB = e.b(strNativeInitBaseUtil);
    if (plutoAntispamB != null) {
        strB2 = plutoAntispamB.data;
    } else {
        m("type", "1");
    }
    eVar.a("nativeInitBaseUtil start, signB=%s", strB2);
    strB = strNativeInitBaseUtil;
}

这里不用猜命名,日志直接写着 nativeInitBaseUtil end, signA=%s,后面的参数就是 strNativeInitBaseUtil。也就是说:

nativeInitBaseUtil(application, cuid) 的返回字符串 → signA

strB 对应 signA,strB2 对应 signB。这是 app 自己的日志和缓存键告诉我们的名字,不是看到两段密文后随便起的。注意上面另一句日志虽然也写着 nativeInitBaseUtil start,下一行实际没有再次调用这个 native 方法;判断来源得看赋值和调用,不能只看日志标题。

4.3 signB 来自服务端响应的 data

上面有一行 e.b(strNativeInitBaseUtil),继续跳进去,位置是 com.zuoyebang.baseutil.e.b(String str):

static PlutoAntispam b(String str) throws ExecutionException, InterruptedException {
    return (PlutoAntispam) Net.postSync(
        PlutoAntispam.Input.buildInput(str), PlutoAntispam.class);
}

到这里就能看到它发了网络请求。再进入 PlutoAntispam.Input,几个关键位置如下,省略了热修复判断:

public static final String PATH = "/pluto/app/antispam";
public String data;

private Input(String str) {
    this.__aClass = PlutoAntispam.class;
    this.__url = PATH;
    this.__pid = "antispam";
    this.__method = 1;
    this.data = str;
}

public Map<String, Object> getParams() {
    HashMap map = new HashMap();
    map.put("data", this.data);
    return map;
}

把这几次传参接起来:传给 e.b() 的是 signA,buildInput() 又把它放进请求的 data 参数;Net.postSync() 返回 PlutoAntispam 对象,f() 取这个对象的 data 字段赋给 strB2。所以:

请求的 data = signA
响应对象的 data = signB

这里的路径是 /pluto/app/antispam。完整地址还会经过 host 配置或自定义地址处理,本文先跟参数流,不把路径写成固定域名。服务端内部怎么计算 signB,客户端这几行代码没有告诉我们;目前能确认的是它由这次请求返回。

4.4 两段字符串为什么会传进 so

回到 a.f(),往下就是这一行:

f58497b = NativeHelper.nativeSetToken(application, cuid, strB, strB2);

把刚刚对应出来的变量名换进去,就是:

isInitSuccess = NativeHelper.nativeSetToken(application, cuid, signA, signB);

调用成功后,主进程分支会保存它们:

PreferenceUtils.setString(LibPreference.KEY_ANTISPAM_SIGN_A, strB);
PreferenceUtils.setString(LibPreference.KEY_ANTISPAM_SIGN_B, strB2);

这样两个分支就接上了:第一次没有缓存,先生成 signA,再请求得到 signB;后面有配套缓存,就跳过这次交换,直接把缓存交给 nativeSetToken。这里讨论的是尚未完成初始化时进入 f() 的流程,已经初始化成功时不会每次签名都重新解密缓存。

flowchart TD
    A["a.d:准备计算登录 sign"] --> B{"已初始化?"}
    B -->|是| H["a.e → nativeGetSign"]
    B -->|否| C["a.f:读取 signA / signB 缓存"]
    C --> D{"两个缓存都非空?"}
    D -->|否| E["nativeInitBaseUtil 返回 signA"]
    E --> F["POST /pluto/app/antispam,请求 data = signA"]
    F --> G["响应对象 data → signB"]
    D -->|是| I["nativeSetToken(context, cuid, signA, signB)"]
    G --> I
    I -->|成功,保存缓存| H
    I -->|失败| J["返回 init_error"]

现在先记住这三件事就够了:signA 由本地 native 生成,signB 从服务端取回,nativeSetToken 接收这一对字符串做初始化。它们里面具体装了什么,要进入这个 native 函数后再分析,不能在这里先假定已经知道。

4.5 本次分析用到的 signA、signB 是怎么取出来的

上面讲的是 app 的正常流程。这次 Unidbg 用的数据,则是从测试设备已有的缓存里读出来的,没有让 Unidbg 重新请求服务端。

文件名也可以从代码追出来:LibPreference.getNameSpace() 返回 LibPreference,PreferenceUtils 用 com.baidu.homework.Preference. 加上这个命名空间,调用 getSharedPreferences()。因此本次主进程缓存文件是:

/data/user/0/com.kuaiduizuoye.scan/shared_prefs/com.baidu.homework.Preference.LibPreference.xml

里面对应两个键,下面省略真实值:

<string name="KEY_ANTISPAM_SIGN_A">这里是缓存的 signA</string>
<string name="KEY_ANTISPAM_SIGN_B">这里是缓存的 signB</string>

工程里的 scripts/capture_init.py 读取这两个键,写进 captures/init.properties。设备需要能通过 su 读取 app 私有目录。这个脚本里的 cuid 是从本次已抓到的 Base64 入参中提取的,最后由原始 nativeSetToken 检查是否和缓存配套。不要随便拿另一个设备的 cuid 与当前缓存拼在一起。

这里还有一种观察方法:hook nativeInitBaseUtil 的返回值可以看到新 signA,hook nativeSetToken 的入参可以同时看到 cuid、signA、signB。如果走的是缓存恢复分支,可能不会调用 nativeInitBaseUtil,但仍然会调用 nativeSetToken。

看到这里,就知道为什么只加载 so、执行 JNI_OnLoad,再直接调用 nativeGetSign,会返回 error 了。JNI 方法注册好了,不代表它需要的业务数据也初始化好了。

几个容易混的名字先分清楚,后面看 IDA 时按这个表对应:

名字 来源 用在哪里
登录 sign nativeGetSign 的返回值 登录请求签名
signA nativeInitBaseUtil 的返回值,或已保存的 A 缓存 发往初始化接口,也传给 nativeSetToken
signB 初始化接口响应对象的 data,或已保存的 B 缓存 传给 nativeSetToken
T 后面从 nativeSetToken 中分析出的内部签名材料,暂用这个名字表示 供 nativeGetSign 计算摘要

登录入参中还有一个叫 token 的 HTTP 字段,它和表里的 T 不能因为名字相似就当成同一个值。

<!-- 配图 1:JADX 中 a.e() 的排序、拼接、Base64 和 nativeGetSign 调用。 --> <!-- 配图 2:JADX 中 a.f() 读取 signA/signB 缓存并调用 nativeSetToken 的分支。 --> <!-- 补图:a.d() 中 f() → e() 的初始化检查;e.b() 的 Net.postSync;PlutoAntispam.Input 的 PATH 与 data 字段。 -->

5. 把 JNI 入口和参数对应起来

5.1 为什么 hook 地址是 0x23bd,IDA 看的是 0x23bc

本次分析的是 ARM32 库。RegisterNatives 表里的函数指针包含 Thumb 标志,因此可能看到:

注册地址:module.base + 0x23bd
代码位置:module.base + 0x23bc

最低位的 1 表示 Thumb 状态,并不是代码真的多偏移一个字节。

用 Frida Interceptor.attach 时保留注册表中的原始函数指针。查看模块内代码位置时,对 ARM32 去掉最低位,然后减去模块基址。

const code = Process.arch === 'arm'
    ? fn.and(ptr('0xfffffffe'))
    : fn;
const offset = code.sub(module.base);

不要把上面的 32 位掩码直接用于 ARM64,它会截断高位地址。本文所有后续代码偏移都对应这一份 ARM32 so,并且没有包含 Thumb 标志。

SHA256:
7da84ec17cdf83e55eda0e127ef7e317055a50fb44131f7538e4fe781b8f9c9e

IDA 默认以 0 为该库的 image base 时,按 G 输入 0x23bc 即可。若数据库被 rebase,需要使用 IDA 当前 image base 加该偏移。这里讨论的是模块内虚拟地址,不能在其他 ELF 中默认把它当成文件偏移。

5.2 不要把 jstring 当成 char 指针

JADX 中可以确认声明是:

static native String nativeGetSign(String input);

对应的 native 原型可以写成:

jstring nativeGetSign(JNIEnv *env, jclass clazz, jstring input);

在这个 ARM32 样本的入口处,参数关系如下:

寄存器 / Frida 参数 含义
r0 / args[0] JNIEnv 指针
r1 / args[1] jclass,因为 Java 方法是 static
r2 / args[2] 输入的 jstring 引用

jstring 是 JNI 对象引用,不能直接 args[2].readUtf8String()。必须通过 JNI 的 GetStringUTFChars 得到字符指针,读取后再配对调用 ReleaseStringUTFChars。

到了 IDA 里面,经常看不到这些 JNI 函数名,只看到一串强制类型转换。例如这份伪代码里是:

v4 = *a1;
v6 = (*(int (__fastcall **)(int *, int, _DWORD))(v4 + 676))(a1, a3, 0);

这里的 a1 是 JNIEnv *env,*a1 取出来的是 JNI 函数表的地址。表里面按固定顺序保存指针,可以先这样理解:

a1,也就是 env
    ↓ 解引用一次
*a1:JNI 函数表起始地址
    ├── 索引 0
    ├── 索引 1
    ├── ...
    ├── 索引 167:NewStringUTF
    ├── 索引 168:GetStringUTFLength
    ├── 索引 169:GetStringUTFChars
    └── 索引 170:ReleaseStringUTFChars

当前是 ARM32,每个指针占 4 字节。索引从 0 开始,所以第 169 号槽位距离表头的偏移就是 169 × 4 = 676 字节。反过来看到 +676,就用 676 / 4 = 169 算出索引。

但这里要分清楚:除法只能算出索引,函数名要查 JNI 标准函数表。 打开 JDK 的 jni.h 找 JNINativeInterface_,或者 Android NDK 头文件中的 JNINativeInterface,也可以查看 JNI 官方规范中各函数的 LINKAGE 说明。表头的 4 个 reserved 槽位也要计数,不能从第一个有名字的函数开始数 0。

字节偏移:十进制 / 十六进制 除以 4 后的索引 查 JNI 表得到的函数
652 / 0x28c 163 NewString
656 / 0x290 164 GetStringLength
660 / 0x294 165 GetStringChars
664 / 0x298 166 ReleaseStringChars
668 / 0x29c 167 NewStringUTF
672 / 0x2a0 168 GetStringUTFLength
676 / 0x2a4 169 GetStringUTFChars
680 / 0x2a8 170 ReleaseStringUTFChars

676 和 0x2a4 是同一个数的两种写法:2 × 256 + 10 × 16 + 4 = 676,不涉及别的转换。

把上面那条长调用拆开,其实只有三步:

函数表地址 + 676 → 找到索引 169 的槽位
读取槽位中的指针 → 得到 GetStringUTFChars 的函数地址
用 a1、a3、0 调用这个函数

用常见的 JNI C++ 写法表达就是:

const char *input = env->GetStringUTFChars(inputString, nullptr);

a1 是 env,a3 是输入的 jstring,最后的 0 表示不接收 isCopy 信息。原伪代码中的 v4 被识别成整数地址,所以 v4 + 676 是直接加 676 字节;如果后来改成了带类型的指针表达式,还要注意 C 的指针加法会按元素大小计算。

如果换成 ARM64,每个指针是 8 字节,要用对应的字节偏移除以 8。JNI 索引仍然是 167、169、170,对应偏移会变成 1336、1352、1360,不能照搬 ARM32 的数值。

日志中的 returned 0x15、returned 0x19 同样是 JNI 引用值。它们不是签名内容,签名要通过这个引用继续读取。

5.3 在 IDA 里把三个 native 名字和函数地址接上

Java 这边已经看到了三个方法,接下来要确认它们分别对应 so 里的哪个函数。不能看到 sub_21B0 就直接说它是 nativeSetToken,需要有注册表或运行日志作为依据。

在 IDA 中按 Shift+F12 打开 Strings,搜索 nativeSetToken。双击这个字符串,再选中字符串地址按 X 查看交叉引用。当前文件中能找到 JNI 注册表里引用它的地方。ARM32 的 JNINativeMethod 每项是 12 字节,结构如下:

struct JNINativeMethod {
    const char *name;       // +0,方法名指针
    const char *signature;  // +4,JNI 描述符指针
    void *fnPtr;           // +8,函数指针
};

这份文件中,三项内容可以对应成:

注册表项起点 name 指针 方法名 fnPtr 原始值 实际代码位置
0x6984 0x0f40 nativeInitBaseUtil 0x2009 0x2008
0x6990 0x0f53 nativeSetToken 0x21b1 0x21b0
0x699c 0x11ce nativeGetSign 0x23bd 0x23bc

这里的地址以 IDA image base 为 0 为前提。表里的最后一个数带 Thumb 标志,右边一列才是我们按 G 跳转时使用的代码地址。若 IDA 没有自动列出交叉引用,可以直接跳到这份样本的 0x6990,按三个 32 位值检查方法名、描述符、函数指针;这个地址不能拿去套别的版本。

确认之后,在三个函数入口按 N 重命名,名称加个 jni_ 前缀也行,例如 jni_nativeSetToken。名字是我们根据注册关系补上的,IDA 原先可能只显示 sub_21B0。

接着看 NativeHelper 里的声明:

static native String nativeInitBaseUtil(Context context, String str);
static native boolean nativeSetToken(Context context, String str, String str2, String str3);

把 Java 调用方已经确认的参数含义补进去,第二个函数可以理解成:

jboolean nativeSetToken(JNIEnv *env, jclass clazz,
                        jobject context, jstring cuid,
                        jstring signA, jstring signB);

注意它有 6 个 native 参数:Java 里的 4 个显式参数前面,还要加上 env、clazz。在 ARM32 刚进入函数、还没执行 push 时,位置是:

位置 参数
r0 env
r1 clazz
r2 context
r3 cuid
[sp] signA 的 jstring 引用
[sp+4] signB 的 jstring 引用

如果沿用前面的 Frida JNI 字符串读取函数,入口处就分别用 readJniString(args[0], args[3])、readJniString(args[0], args[4])、readJniString(args[0], args[5]) 读 cuid、signA、signB。nativeGetSign 的输入才是 args[2],不能把它的位置直接照搬到初始化函数。

IDA 反编译可能没有自动识别这么多参数,需要结合汇编确认。不要把反编译器推测出来的四参数原型当成最终结论。这里先把对应关系记下来,第 8 节会实际跟踪栈上的 signA、signB。

<!-- 补图:IDA 的 nativeSetToken 字符串、0x6990 注册表项,以及去掉 Thumb 标志后的 0x21b0 函数。 -->

6. 从数据流拆出 nativeGetSign 的主体

接下来回到 IDA,按 F5 看伪代码。类型不对或者变量看不明白,就切回汇编确认。这里我先看输入经过了哪些函数、返回值又传给了谁,不用上来就把所有指令翻译一遍。

下面保留这次 IDA 输出里的变量名,只省略局部变量声明,方便跟自己窗口里的代码一一对应:

int __fastcall sub_23BC(int *a1, int a2, int a3)
{
  // v4、v6 等局部变量声明省略。
  if ( !dword_7B5C )
  {
LABEL_5:
    v4 = *a1;
    return (*(int (__fastcall **)(int *, const char *))(v4 + 668))(a1, "error");
  }
  v4 = *a1;
  if ( dword_7B60 )
  {
    v6 = (*(int (__fastcall **)(int *, int, _DWORD))(v4 + 676))(a1, a3, 0);
    if ( v6 )
    {
      v7 = v6;
      memset(v14, 0, 33);
      v8 = sub_5050((char *)dword_7B60, (int)v14);
      v9 = (char *)sub_45B4("%s[%s]@%s", byte_7B68, v8, v7, 0);
      memset(v13, 0, 33);
      v10 = sub_5050(v9, (int)v13);
      v11 = (*(int (__fastcall **)(int *, int))(*a1 + 668))(a1, v10);
      free(v9);
      (*(void (__fastcall **)(int *, int, int))(*a1 + 680))(a1, a3, v7);
      return v11;
    }
    goto LABEL_5;
  }
  return (*(int (__fastcall **)(int *, const char *))(v4 + 668))(a1, "error");
}

前面查过 JNI 表,现在就能把三个间接调用读懂:+676 取输入字符串,+668 创建返回字符串,+680 释放借出的字符指针。v7 = v6,所以 v7 就是输入字符指针;v9 是中间拼接出来的字符串,最后用 free(v9) 释放。

原变量 当前能确认的含义
a1 / a2 / a3 env / clazz / 输入 jstring
v6、v7 GetStringUTFChars 返回的输入字符指针
v14 第一次 sub_5050 的输出缓冲区
v8 第一次 sub_5050 返回的输出指针
v9 sub_45B4 返回的拼接字符串
v13、v10 第二次 sub_5050 的输出缓冲区及返回指针
v11 NewStringUTF 创建的 Java 字符串引用

就算暂时完全不知道 signA、signB,也可以从这里开始分析。 先发现第一次处理的数据来自全局变量,再去找它的写入者。第 4 节是提前把 Java 侧的来源讲清楚,不是说分析这个函数之前就必须猜到两个缓存的名字。

按 G 跳到 0x23bc,按 F5。先看返回 error 的分支,再看成功分支的两次 sub_5050。函数开头会检查初始化相关状态和一个全局指针是否为空,不满足条件时返回 error。

然后在汇编里定位 0x2402:这是 JNI GetStringUTFChars 的间接调用。它返回后,0x240e 的 mov r6, r0 把输入字符指针保存到 r6。后面 0x2434 又把 r6 传给拼接函数,所以这一条是我们传进来的 Base64。

再看 0x241e 的 ldr r0, [r5], #8:第一次 sub_5050 用的是从全局位置加载的数据,并不是刚刚存到 r6 的输入。此时我们还不知道这个全局数据的内容,先标成 global_material。别一上来就把它命名成 HTTP token,等找到写入者后再确定。

把释放内存、栈保护这些先放一边,核心数据流整理下来就是下面这样。这里的伪代码是为了说明流程重新整理的,变量也起了方便理解的名字,不是直接复制的 IDA 输出:

// 为了说明数据流,省略释放、栈保护和异常路径。
const char *input = GetStringUTFChars(env, inputObject, NULL);

char inner[33] = {0};
char output[33] = {0};

sub_5050(global_material, inner);
char *message = sub_45B4("%s[%s]@%s", global_prefix, inner, input, NULL);
sub_5050(message, output);

return NewStringUTF(env, output);

看到这里,先记下几个问题:sub_5050 到底做了什么,sub_45B4 是不是只负责拼字符串,byte_7B68 的内容是什么,以及 dword_7B60 是谁写进去的。接下来逐个跟。

dword_7B60 指向的数据
    ↓ sub_5050
第一次结果
    ↓ 与 byte_7B68、原始 input 拼接
完整 message
    ↓ sub_5050
最终 sign

对应汇编中,下面几处最有价值:

0x242a  bl  sub_5050      ; 第一次处理全局签名材料
0x242e  mov r2, r0       ; 返回值作为后续调用的第三个参数
...
0x2432  mov r1, r5       ; 固定前缀
0x2434  mov r3, r6       ; 原始输入字符串
...
0x243c  bl  sub_45B4     ; 拼接
0x2440  ...              ; 此时 r0 是拼接结果
...
0x2456  mov r0, r5       ; 拼接结果作为下一次调用的输入
0x2458  bl  sub_5050     ; 第二次处理

ARM32 普通函数调用的前四个参数通过 r0~r3 传递。因此在 0x243c 调用之前,看这四个寄存器就能知道拼接函数拿到了哪些数据。

注意:断点停在调用指令之前,r0 表示入参;停在调用之后,r0 才表示返回值。把这两种位置混淆,很容易把错误的地址当成字符串读取。

<!-- 配图 3:nativeGetSign 的伪代码,标出两次 sub_5050 和中间的 sub_45B4。 -->

7. 确认 MD5 和拼接公式

7.1 为什么判断 sub_5050 是 MD5

前面两个输出缓冲区都清了 33 字节,看起来像“32 个十六进制字符 + 一个结束符”。这可以作为怀疑 MD5 的线索,但单靠长度还不能确认。

双击 sub_5050,继续进入它调用的初始化和轮运算函数,可以看到下面这些初始化常量:

67452301  efcdab89  98badcfe  10325476

这几个常量看着就很像 MD5。不过不能看到常量就直接下结论,我们继续往下看:按 64 字节分块更新,填充到块内第 56 字节附近,追加 8 字节长度,最后把 16 字节摘要用 %02x 一个个输出。

再把它实际收到的输入拿出来,用 Python 的 hashlib.md5 算一下,输出也能对上。这样静态特征和实际结果都有了,就可以把 sub_5050 改名为 md5_hex,后面看起来也方便。

这里要区分:digest() 是 16 字节二进制,hexdigest() 是 32 字符十六进制文本。后续拼接使用的是小写 hexdigest()。

7.2 sub_45B4 的作用

sub_45B4 先计算字符串长度并分配内存,再通过 vsnprintf 格式化,最后返回构造的字符串。

签名路径传入的格式是:

%s[%s]@%s

它的三个 %s 分别对应固定前缀、第一次 MD5 的结果和原始输入。

7.3 固定前缀怎么还原

在 byte_7B68 上按 X 找写入位置,再跟到初始化函数。这里并不是直接摆着一个字符串,而是用立即数写出来的。IDA 按 G 跳到 0x46ec,可以看到:

0x46ec  MOVS R1, #0x2A
0x46ee  STRB R1, [R0,#4]
0x46f0  MOVW R1, #0x2638
0x46f4  MOVT R1, #0x6425
0x46f8  STR  R1, [R0]
0x46fa  BX   LR

先把 R0 看成目标缓冲区地址。第一条把 R1 设成 0x2a,第二条 STRB 只写一个字节,写到 R0 + 4,相当于:

buffer[4] = 0x2a;  // 第 5 个字节,因为下标从 0 开始

再看 MOVW 和 MOVT。前者把立即数放进低 16 位,并将高 16 位清零;后者替换高 16 位,保留低 16 位。因此:

执行 MOVW R1, #0x2638 后:R1 = 0x00002638
执行 MOVT R1, #0x6425 后:R1 = 0x64252638

也就是:(0x6425 << 16) | 0x2638

最后 STR R1, [R0] 把这个 32 位整数的 4 个字节写进缓冲区。当前 ELF 使用小端序,低位字节放在低地址,所以按地址从低到高读到的是:

偏移:  +0  +1  +2  +3  +4
字节:  38  26  25  64  2A

这是按字节排列,不能把十六进制数字逐个倒着写。再查 ASCII 表:

内存字节 ASCII 字符
0x38 8
0x26 &
0x25 %
0x64 d
0x2a *

这样才得到 8&%d*,不是看到一个整数就直接猜字符串。

还有一步不能省:这个函数只说明它往 R0 写了数据,怎么确认 R0 就是 0x7b68?选中 sub_46EC 按 X 看调用者,当前调用点在 0x2186:

0x217e  ADD.W  R0, R4, #8
0x2182  STRB.W R11, [R4,#0xD]
0x2186  BL     sub_46EC

往前追可以确认这里 R4 为 0x7b60,R11 为 0。具体来说,0x2134 取出 literal 0x5a20,0x213c 加上当时的 PC 值 0x2140,得到 0x7b60;0x2106 则把 R11 清零,IDA 也可能将 R11 显示为 fp。

因此传进去的 R0 是 0x7b60 + 8 = 0x7b68,而 STRB.W 往 0x7b60 + 0xd = 0x7b6d 写了零。初始化完成后的内存就是:

地址:  7B68  7B69  7B6A  7B6B  7B6C  7B6D
字节:   38    26    25    64    2A    00
字符:    8     &     %     d     *    \0

最后那个 0 是 C 字符串结束符。静态打开 IDA 时这些初始化指令还没执行,目标位置可能仍显示零;我们是根据写入指令还原执行后的内容。动态读取拼接参数得到的前缀也与此一致。

这里的 %d 属于作为 %s 参数传入的数据,不会被 vsnprintf 再次解释成数字占位符。

将初始化状态中的 10 字节签名材料暂记为 T,原始 JNI 入参记为 I,就得到:

inner   = MD5(T) 的小写十六进制
message = "8&%d*[" + inner + "]@" + I
sign    = MD5(message) 的小写十六进制

对于本文的 ASCII/Base64 输入,可以直接写成:

import hashlib

def sign_ascii(raw_input: str, token: bytes) -> str:
    inner = hashlib.md5(token).hexdigest().encode('ascii')
    message = b'8&%d*[' + inner + b']@' + raw_input.encode('ascii')
    return hashlib.md5(message).hexdigest()

到这里签名公式就出来了,不过代码里还差一个 token。直接把 hook 到的值写死,确实能先跑起来,但这个值到底从哪里来的还没弄清楚。所以继续往前找初始化流程。

<!-- 配图 4:sub_5050 的 MD5 初值、长度填充和 %02x 输出。 --> <!-- 配图 5:0x46ec 的立即数写入,以及拼接函数的三个参数。 -->

8. 在 IDA 里接上 signA、signB 和签名函数

现在我们已经知道 signA、signB 是 Java 传给 nativeSetToken 的。但它俩和第一次 MD5 用到的全局数据有什么关系?这一节就把中间缺的这段接起来。

8.1 从 nativeGetSign 读全局数据的位置往回找

前面伪代码里已经出现了 dword_7B60,直接双击它,或者按 G 输入 0x7b60,就能跳到变量本身。这里的 dword 是 IDA 自动起名时标出的 4 字节数据,7B60 是地址。

但是这 4 字节保存的是指针,不是那 10 字节签名材料本身。要区分地址和地址里保存的值:

0x7b60:全局变量所在地址
    ↓ 读取这里保存的 4 字节指针
运行时分配的内存地址
    ↓
真正参与第一次 MD5 的字符串

所以 (char *)dword_7B60 是把变量里存的值当成字符指针。接下来选中变量名按 X 看引用,重点找赋值。几种代码含义不一样:

if (dword_7B60)                    // 检查它是否为空
sub_5050((char *)dword_7B60, out);  // 读取它指向的数据
dword_7B60 = buffer;               // 写入者,接下来要追这里

回到 nativeGetSign 的汇编,关注下面几条,省略号表示中间还有别的指令:

0x23e6  ldr r5, [pc, #0xec]   ; 从 0x24d4 的 literal 取出 0x5772
...
0x23ea  add r5, pc            ; 此处参与计算的 PC 值为 0x23ee
...
0x23ec  ldr r1, [r5]          ; 先检查这个全局指针
...
0x241e  ldr r0, [r5], #8      ; r0 = *(uint32_t *)r5,然后 r5 += 8
...
0x242a  bl  sub_5050          ; 第一次 MD5

这里有个地址计算,不要直接跳过:0x5772 + 0x23ee = 0x7b60。所以 r5 最开始指向 0x7b60,这里存着第一次 MD5 所用字符串的指针。ldr r0, [r5], #8 会先读指针,再把 r5 加 8;于是后面 r5 变成 0x7b68,正好是固定前缀的地址。

在 IDA 按 G 跳到 0x7b60,可以先命名为 g_sign_material_ptr,再按 X 看引用,重点找写入它的位置。这里是运行时状态,静态打开 so 看到零值,并不代表 app 运行时也为零;IDA 不会凭空显示服务端交换后才出现的数据。

ARM 的 PC 相对寻址有时不会生成完整的全局交叉引用。遇到这种情况,我们已经从注册表知道 nativeSetToken 在 0x21b0,可以进入它,用同样的方法核对地址。当前样本中有:

0x229c  ldr r4, [pc, #0x110]  ; 从 0x23b0 取出 0x58ba
...
0x22a2  add r4, pc            ; 0x58ba + 0x22a6 = 0x7b60
...
0x2392  str r0, [r4]          ; 把新分配的数据地址保存到 0x7b60

这样就有实际的读写关系了:nativeSetToken 写进 0x7b60 的指针,正是 nativeGetSign 后面取来计算 MD5 的指针。它们之间不是靠函数名猜出来的。

8.2 把 nativeSetToken 的两个栈参数认出来

前面已经确定入口 [sp] 是 signA,[sp+4] 是 signB。现在看 0x21b0 的函数开头:

0x21b0  push {r4, r5, r6, r7, lr}
0x21b2  add  r7, sp, #0xc

第一条压入 5 个寄存器,也就是 20 字节。设入口时的 sp 为 entry_sp,第二条执行后:

r7 = entry_sp - 20 + 12 = entry_sp - 8
[r7+8]   = [entry_sp]   = signA
[r7+0xc] = [entry_sp+4] = signB

往下正好看到对应加载:

0x21d2  ldr.w r8, [r7, #8]     ; signA 的 jstring 引用
...
0x21e2  blx r3                 ; GetStringUTFChars(env, signA, NULL)
...
0x2204  ldr.w r8, [r7, #0xc]   ; signB 的 jstring 引用
...
0x2214  blx r3                 ; GetStringUTFChars(env, signB, NULL)

JNI 返回的字符指针会被复制,然后释放 JNI 借出的字符串。后面使用的 signA 副本在 r5,signB 副本在 r11,IDA/反汇编器也可能把 r11 显示成 fp。现在才能有依据地给对应变量改名,不能看到某个长字符串指针就先猜它是 signA。

这次 IDA 伪代码里,相关变量叫 v8、v9、v10、v11:

v8 = GetStringUTFChars(a1, a5, 0);   // JNI 调用已按函数表翻译
v9 = (char *)sub_2878();
ReleaseStringUTFChars(a1, a5, v8);

v10 = GetStringUTFChars(a1, a6, 0);
v11 = (char *)sub_2878();
ReleaseStringUTFChars(a1, a6, v10);

sub_2878() 被显示成无参数,是反编译器的原型不完整。回汇编看,调用时 R0 里仍是刚取得的字符指针,函数会复制字符串。不能因为 F5 显示了空括号,就认为它没有输入。

对应起来就是 a5 → v8 → v9、a6 → v10 → v11。即使不知道 signA、signB 的名字,仍能先记成“第 5 个参数的副本”和“第 6 个参数的副本”,然后继续往下追。

8.3 先记调用关系,再给算法起名字

在这个函数里面先追 signA:它在 0x2236 传给 sub_2DA4,结果在 0x224a 传给 sub_435C。再追 signB:它在 0x2306 调用同一个 sub_2DA4,在 0x2318 调用同一个 sub_435C。

当前已确认的输入 第一层调用 第二层调用 第二层返回后的位置
signA 字符串 0x2236 → sub_2DA4 0x224a → sub_435C 0x224e,r0 是返回指针
signB 字符串 0x2306 → sub_2DA4 0x2318 → sub_435C 0x231c,r0 是返回指针

这里先把 sub_2DA4 记成“编码转换候选”,把 sub_435C 记成“解密候选”。原因是第一层输出了字节数据,第二层还接收一个 8 字节字符串作为参数,返回结果随后会被 strlen、strcmp 检查。此时还不能只凭这个就认定 DES,第 9 节才进入函数内部确认分组、轮数和表。

第一次调用 sub_435C 前,r1 来自只读数据地址 0x116e。在 IDA 按 G 跳过去,可以看到 @fG2SuLA,所以这是我们后面解 signA 使用的固定密钥来源。第二次的 r1 来自前面 sub_45B4 拼出来的字符串,怎么拼的放在 8.5 节展开。

两层处理之后,程序继续切字段、检查长度和比较字符串。结合下面的字段分析,以及第 9、10 节的算法和中间值验证,我们才把整个过程整理成下面的伪代码。这里是整理后的结果,不是刚按 F5 就能看到这些变量名:

cipher_a = decode_handshake(signA)
plain_a = decrypt_variant(cipher_a, b'@fG2SuLA')

challenge = plain_a[7:17]
key_b = challenge[:5] + b'#G4'

cipher_b = decode_handshake(signB)
plain_b = decrypt_variant(cipher_b, key_b)

assert plain_b[:10] == challenge
T = plain_b[12:22]

实际还包含 CUID、证书相关信息和长度等校验;这里先展示签名材料的来源。

8.4 从截断位置识别明文结构,并确认 T 被保存

在 sub_21B0 的伪代码里,最直接的写入证据就是下面这一段。这里先从末尾往前读:

v22 = sub_1F54(11);
if (v22)
{
    v23 = *(_QWORD *)(v20 + 12);
    *(_WORD *)(v22 + 8) = *(_WORD *)(v20 + 20);
    *(_QWORD *)v22 = v23;
    dword_7B60 = v22;
    sub_565C(v14);
    sub_565C(v20);
    return 1;
}

_QWORD 是 8 字节,_WORD 是 2 字节。它先取 v20 + 12 开始的 8 字节,再取 v20 + 20 开始的 2 字节,连续复制到新内存的偏移 0 和 8。因此可以整理为:

char *saved = sub_1F54(11);
// 这里先只整理分配和复制关系,分配函数的清零行为要另外核对。
memcpy(saved, (char *)v20 + 12, 10);
dword_7B60 = saved;

我们要找的材料,就是 v20[12:22]。再往前找 v20 的赋值:

v18 = sub_2DA4(v11);
v19 = strlen(v11);
v20 = sub_435C(v18, ptr, v19 >> 2);

这说明它来自第 6 个参数对应的字符串,经过编码转换和处理后得到的结果。v19 >> 2 是长度除以 4,与前面的缓存编码关系吻合。结合原代码中的 v14、v20,现在两条路径可以写成:

a5 → v9 → sub_2DA4 → sub_435C,使用固定密钥 "@fG2SuLA"
                           ↓
                          v14 → 截取一部分,再拼 "#G4" → ptr
                                                        ↓
a6 → v11 → sub_2DA4 → sub_435C,使用 ptr
                           ↓
                          v20 → 复制 [12:22] → dword_7B60 保存其地址

signA 解密完成后,代码会在明文偏移 0x11、0x33 处写入 NUL,使对应字段可以单独传给 strlen、strcmp。再结合 +7、+0x13、+0x35 等指针运算,可以推断出字段边界。

用运行时明文验证后,signA 的结构为:

8&%d*##challenge##certificateDigest##cuid
字节范围(左闭右开) 内容
[0:5] 固定前缀,5 字节
[5:7] ##
[7:17] challenge,10 字节
[17:19] ##
[19:51] 证书相关摘要,32 字节
[51:53] ##
[53:] cuid

signB 的结构则是:

challenge##T

长度为 10 + 2 + 10 = 22 字节。native 会比较它的前 10 字节与 signA 中的 challenge,随后把最后 10 字节复制到新分配的内存中,保存供 nativeGetSign 使用。

对应 IDA 里可以逐个核对:0x2336 检查总长度 0x16,0x2348 把明文指针加 0x0c 放入 r6,0x2352 检查后半段长度 0x0a。比较前半段成功后跳到 0x237c,申请 0x0b 字节空间,再用 0x2386 到 0x238e 的加载、存储指令复制这 10 字节,最后在 0x2392 保存指针。

到这里,前面暂时叫作 global_material 的数据就有来源了:它是 signB 明文偏移 12 开始的 10 字节。文中用 T 表示它。现在再回到 nativeGetSign 的第一次 MD5,就能明确说它算的是 MD5(T)。

同一个 nativeSetToken 中也会取 Android 安装包证书信息,比较 signA 的证书摘要和本地证书计算结果。此次 Python 还原面向已经由原始 APK 验证有效的缓存,校验 CUID 和 challenge,但没有重新实现 Android 包证书校验环境。

8.5 signB 的密钥在哪里

在 0x22f0 附近,程序暂时把 signA 明文 +0x0c 的位置写为 NUL,对应这段伪代码:

v17 = *(_BYTE *)(v14 + 12);        // 先保存原字符
*(_BYTE *)(v14 + 12) = 0;         // 临时截断
ptr = sub_45B4("%s%s", v14 + 7, "#G4", 0);
*(_BYTE *)(v14 + 12) = v17;        // 再恢复

从偏移 7 开始,到偏移 12 前停止,正好 12 - 7 = 5 字节。challenge 从 +7 开始,所以这里截取的是 challenge 的前 5 字节。

接着用 %s%s 拼上固定字符串 #G4,形成 8 字节密钥,再恢复原来的明文字节。

因此它不是完整的 10 字节 challenge 作为密钥,而是:

key_b = challenge[:5] + b'#G4'

这里就是先把字符串临时截断,用完再恢复。要是只看最后的字符串,很容易漏掉中间只取了 5 个字节这一步。

<!-- 配图 6:nativeSetToken 中 signA 解密、字符串截断和比较的位置。 --> <!-- 配图 7:截取 challenge 前五字节并拼接 #G4。 -->

9. 还原缓存编码和 DES 变体

9.1 看起来像 hex,但不能直接 bytes.fromhex

本次缓存中的 signA 长 352 字符,signB 长 96 字符。每两个字符只会是 00 到 0f,这与普通任意字节的 hex 表示不同。

进入 0x2da4 及其调用的 0x2ca8,可以看到:每两个字符取第二个字符,查出对应的四个 bit,然后把连续八个 bit 按低位优先组合成一个字节。

可以分两步理解:

每对 00..0f → 一个 nibble → 按高位到低位展开 4 个 bit
每 8 个 bit → bit[0] 放入字节最低位 → 合成一个 byte

例如人造输入 0001:

00 → 0000
01 → 0001
拼接的 bit 数组:[0,0,0,0,0,0,0,1]
按 LSB-first 打包:0x80

而普通 bytes.fromhex('0001') 会得到两个字节 00 01,两者显然不同。

对应 Python:

def decode_handshake(encoded):
    if not encoded or len(encoded) % 4:
        raise ValueError('编码长度错误')
    bits = []
    for pos in range(0, len(encoded), 2):
        pair = encoded[pos:pos + 2]
        if pair[0] != '0' or pair[1] not in '0123456789abcdef':
            raise ValueError('编码包含非法字符')
        value = int(pair[1], 16)
        bits.extend((value >> bit) & 1 for bit in (3, 2, 1, 0))
    return bytes(
        sum(bits[pos + bit] << bit for bit in range(8))
        for pos in range(0, len(bits), 8)
    )

所以 signA 解码后是 88 字节密文,signB 解码后是 24 字节密文。先验证这一层,再分析分组算法,可以避免把编码错误误判为密码算法错误。

9.2 如何识别 DES

这里不能直接写一句“看出来是 DES”就结束。我们从 sub_435C 往里面走,看每个判断的依据。

9.2.1 sub_435C 先给出线索,还不能单独下结论

根据上一层调用,三个参数可以先对应成:

sub_435C(a1, a2, a3);
// a1:转换后的数据地址
// a2:8 字节密钥的地址
// a3:数据长度

先看它怎么处理 a2:

v19[0] = *a2;
v19[1] = a2[1];

IDA 把 a2 推成了 int *,所以每次取 4 字节,两次共取 8 字节。这里不是说密钥本来就是两个整数,只是编译器按两个 32 位值搬运。

接下来的长循环一直在做 & 1、& 2、& 4 和移位。把那些指针表达式整理一下,就能看出它在拆 bit:

for (int i = 0; i < 8; i++) {
    for (int bit = 0; bit < 8; bit++) {
        keyBits[i * 8 + bit] = (key[i] >> bit) & 1;
    }
}

例如密钥中的一个字节是 0x01,得到的就是 [1,0,0,0,0,0,0,0],也就是 LSB-first。8 个密钥字节被展开成 64 个 0/1,每个逻辑 bit 用一个 byte 保存。

伪代码里可能把这块栈空间显示成 v23[7] 和一个单独的 v24,但循环实际连续写了 64 字节。这是局部变量边界没有恢复好,不能按 [7] 去理解算法,也不能仅凭这个显示就判断原程序越界。

然后是:

_BYTE v22[768];
sub_3DE4(v23, v22, v11);

这里的 768 很有特点:16 × 48 = 768。DES 正好有 16 轮,每轮用 48 bit 轮密钥;这份实现每个 bit 占一字节,因此轮密钥数组占 768 字节。现在可以怀疑 sub_3DE4 在生成轮密钥,但还要进去确认,不能只按数组大小认算法。反编译显示的第三个实参是否有意义,也要以被调函数实际读取哪些寄存器为准。

主循环则每次取两个 DWORD,交给 sub_406C,再写回两个 DWORD:

v20[0] = *(_DWORD *)(a1 + v12);
v20[1] = *(_DWORD *)(a1 + v12 + 4);
sub_406C(v20, v22, v21);
*(_DWORD *)&v6[v12] = v21[0];
*(_DWORD *)&v6[v12 + 4] = v21[1];
v12 += 8;

也就是每次处理一个 8 字节分组。8 字节密钥、8 字节分组、疑似 16×48 bit 轮密钥,几个线索放在一起就很像 DES。真正要确认,还得继续看两个子函数。

9.2.2 在 sub_3DE4 确认密钥调度

IDA 跳到 0x3de4,看几个循环的边界和访问的表:

指令位置 观察到的内容 对应过程
0x3e04~0x3e10 查表选取 0x38,也就是 56 个 bit PC1:64 bit 选出 56 bit
0x3e2a 附近 按轮数读取位移量 每轮左移 1 或 2 位
0x3e3a、0x3e48 等 反复出现 0x1c,也就是 28 左右两段各 28 bit,分别循环左移
0x3e86~0x3e92 通过另一张表选择 0x30,也就是 48 个 bit PC2:生成一轮 48 bit 密钥
0x3e98、0x3e9a 输出地址加 48,轮计数与 16 比较 生成 16 组轮密钥

轮转表内容是:

1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1

它和 DES 的轮转安排一致。再结合 PC1、PC2 的内容,就能把这部分识别成 DES 的密钥调度。注意“属于这个结构”和“每个表项都标准”是两个判断,PC2 的差异下一节单独比较。

9.2.3 sub_406C 开头的向量变量和初始置换

继续双击 sub_406C。它的参数是一个 8 字节输入、轮密钥数组、8 字节输出地址。这里不负责去填充。

刚打开会看到不少 int64x2_t、n128_u8、veorq_s64,先别被这些名字绕进去:

伪代码写法 含义
int64x2_t 一个 16 字节的 NEON 向量
n128_u8 按字节访问向量内容
veorq_s64(x, y) 对两个 16 字节向量做逐位异或

这里一个 byte 保存一个逻辑 bit,所以一次操作 16 字节,相当于同时处理 16 个逻辑 bit。向量指令只是编译器加快搬运和异或,不是另一种密码算法。

开头的循环还是把每个输入字节低位在前地拆开,共得到 64 个 bit。接着是:

for (i = 0; i != 64; ++i)
    v47.n128_u8[i] = v42.n128_u8[*(_DWORD *)&a9[4 * i]];

这里的 a9 是 IDA 数据标签,不是函数的第九个参数。4 * i 表示每个表项占 4 字节,整个操作相当于:

permuted[i] = inputBits[IP[i]];

在 IDA 双击 a9,这份样本对应 0x11f0。它的开头是:

样本:57, 49, 41, 33, 25, 17, 9, 1, ...
标准 DES 文档:58, 50, 42, 34, 26, 18, 10, 2, ...

样本每项比标准文档少 1,是因为数组从 0 开始编号。表内容能对上,才把它标成 IP 初始置换。

v42.n128_u8[i] 看起来只有一个 16 字节向量,却能访问到 63,也是反编译器拆分连续栈缓冲区导致的。按这份栈布局,后面可以把 v42、v43 对应的 32 字节看成左半 L,把 v44、v45、v46 对应的 32 字节看成右半 R。

9.2.4 为什么知道是 16 轮解密

轮循环里有这几行:

v9 = 15;
// ... 每轮 ...
v16 = (int64x2_t *)(a2 + 48 * v9);
// ...
if (!v9)
    break;
// ...
--v9;

第一个使用的是 a2 + 48 × 15,然后是第 14 组,最后是第 0 组。0 这一轮也执行,所以总共 16 轮。结合前面按正序生成的轮密钥,这里按 15 → 0 倒序使用,就是 DES 的解密方向。

9.2.5 每轮的 E 扩展、轮密钥异或和 S-box

每轮先把右半 R 复制到从 v39 开始的临时区域,再执行:

for (j = 0; j != 48; ++j)
    v47.n128_u8[j] = v39.n128_u8[dword_1AF0[j]];

也就是 expanded[j] = right[E[j]]。dword_1AF0 开头是:

31, 0, 1, 2, 3, 4, 3, 4, 5, 6, 7, 8, ...

这是零基下标的 E 扩展表,重复选取部分 bit,把 32 bit 扩展为 48 bit。后面三次 veorq_s64,每次 16 字节,共处理 48 字节:

v17 = veorq_s64(*v3, v16[1]);
v18 = veorq_s64(v49, v16[2]);
v39 = veorq_s64(v47, *v16);

整理后就是 expanded[j] ^= roundKey[j],即 E(R) XOR K。

紧接着调用 sub_3340(&v39)。函数名看不出用途,要继续进入看取位和查表。里面每次取 6 个 bit:

输入六位:b0 b1 b2 b3 b4 b5
行号 = b0 × 2 + b5
列号 = b1 × 8 + b2 × 4 + b3 × 2 + b4

查表可以表达成:

value = SBOX[box * 64 + row * 16 + col];

结果是 0~15,再从高位到低位展开成 4 个 bit。共处理 8 组,因此是 8 × 6 = 48 bit 输入、8 × 4 = 32 bit 输出。

表在 0x12f0,第一组 S-box 第一行是:

14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7

这些值和 DES S1 第一行一致,取行列的方式也一致。这是识别 S-box 的实际依据,不是看见一个查表函数就直接改名。

9.2.6 P 置换、左右异或与最后一轮交换

S-box 后还有一次 32 bit 的查表:

for (k = 0; k != 32; ++k)
    v47.n128_u8[k] = v39.n128_u8[dword_1BB0[k]];

表开头是 15, 6, 19, 20, 28, 11, 27, 16, ...,对应零基 DES P 表。后面的向量异或把这个结果与左半 L 异或,再通过一串临时变量赋值交换左右两半。

整个轮函数可以整理为:

F(R, K) = P(SBOX(E(R) XOR K))
L_new = R_old
R_new = L_old XOR F(R_old, K)

原代码在最后一轮 if (!v9) break,跳过了下面的交换。因此进入最终置换前的缓冲区,已经是所需的左右排列。写独立实现时也可以采用“每轮都交换,最后再交换一次”的常见写法,但不能在原流程上随手重复补一次交换。

9.2.7 FP 最终置换和 8 字节输出

最后一个 64 次循环:

for (m = 0; m != 64; ++m)
    v47.n128_u8[m] = v42.n128_u8[dword_1C30[m]];

dword_1C30 开头是 39, 7, 47, 15, 55, 23, 63, 31, ...,对应零基的 FP,也就是 IP 的逆置换。

再往下就是 2 * v35[1]、4 * v35[2]、v33 << 7 那一长串,整理后只是把 bit 打包回字节:

output[i] = bits[i * 8]
          | (bits[i * 8 + 1] << 1)
          | (bits[i * 8 + 2] << 2)
          | (bits[i * 8 + 3] << 3)
          | (bits[i * 8 + 4] << 4)
          | (bits[i * 8 + 5] << 5)
          | (bits[i * 8 + 6] << 6)
          | (bits[i * 8 + 7] << 7);

循环执行 8 次,得到 8 字节明文。函数最后的 return 0 不是明文,明文通过 a3 指向的缓冲区输出。

这些结构和表内容一起对上,才确认它属于 DES 算法族。原始名称可以逐步改成:

原名称 确认后的名称
sub_3DE4 des_make_round_keys
sub_406C des_variant_decrypt_block
sub_3340 des_sbox_substitute
a9、dword_1AF0、dword_1BB0、dword_1C30 DES_IP、DES_E、DES_P、DES_FP

回看 sub_435C,各分组独立调用这个核心,外层和单块函数都没有前一块密文的链式异或,因此当前路径按 ECB 方式处理。分组模式和尾部填充要与单块 DES 运算分开分析。

9.3 为什么标准 DES 库不能直接替换

认出了 DES 结构,下一步还得确认是不是标准实现。不能只看 16 轮和 8 字节分组,也不能只看 S-box 第一行。这里分三步:逐项比表、核对位序和运算、使用已知测试向量验证。

9.3.1 先统一编号,再比较整张表

标准文档通常从 1 编号,程序数组从 0 编号。例如 PC2 开头:

标准文档:14, 17, 11, 24, 1, 5, ...
程序下标:13, 16, 10, 23, 0, 4, ...

这种统一减 1 只是编号变化。要比较的是“样本表”和“标准置换表每项减 1 后的结果”。S-box 保存的是输出值,轮转表保存的是次数,它们不能跟着减 1。

样本的 PC2 位于 0x1e10,每项占 4 字节。差异在下标 35,也就是第 36 项:

地址 = 0x1e10 + 35 × 4 = 0x1e9c

IDA 按 G 跳到 0x1e9c,这里实际是 0x2e,十进制 46。标准 PC2 同一项在文档里是 48,转成零基后应该是 47,即 0x2f:

表项 样本实际值 标准值,已转零基
PC2[35] 46 47

还要看这个值怎么使用。sub_3DE4 中 0x3e86 读取表项,0x3e8a 直接按该下标取 bit,没有额外修正。因此它确实选了不同的位置,会影响轮密钥,不是显示格式造成的差异。

下面这段在 IDA Python 控制台执行,假定 image base 为 0。它读取整张 PC2 逐项比较:

import ida_bytes

standard = [
    14,17,11,24,1,5,3,28,15,6,21,10,
    23,19,12,4,26,8,16,7,27,20,13,2,
    41,52,31,37,47,55,30,40,51,45,33,48,
    44,49,39,56,34,53,46,42,50,36,29,32
]
for i, value in enumerate(standard):
    actual = ida_bytes.get_dword(0x1e10 + 4 * i)
    expected = value - 1
    if actual != expected:
        print(f'PC2[{i}] 地址={0x1e10 + 4*i:#x} '
              f'样本={actual} 标准={expected}')

结果是:

PC2[35] 地址=0x1e9c 样本=46 标准=47

同样的方法还要用来核对 IP、FP、E、P、PC1、完整 S-box 和轮转表。仅有几个开头值一致,可以辅助识别算法,不能证明所有内容都标准。

9.3.2 位序需要结合整条计算链判断

前面已经看到,密钥和输入分组都用 LSB-first 展开:

字节 0x01
样本展开:1 0 0 0 0 0 0 0
DES 文档的位编号顺序:0 0 0 0 0 0 0 1

但只看到 LSB-first,也不能马上说算法改了。内部实现可以用不同表示,只要相应调整置换表或增加转换,最终仍可能得到标准结果。所以要把密钥展开、PC1/PC2 索引、输入展开、IP/E/P/FP、输出打包连起来核对。

当前样本的位序处理不能直接交给常见标准 DES 字节接口。注意这里讲的是一个字节内部的 bit 顺序,第 7 节 STR 写整数讲的是多个字节之间的小端存储顺序,是两回事。

9.3.3 使用标准单分组向量做对照

选一组已知 DES 测试向量,全部按 hex 解码成原始字节,不加字符串编码和填充:

密钥:133457799BBCDFF1
明文:0123456789ABCDEF
密文:85E813540F0AB405

这里分析的是解密,所以输入密文和密钥,标准结果应该是上面的明文。要测的是 sub_406C 配合轮密钥生成的单分组路径,不能直接把无填充向量交给 sub_435C,否则它会把末字节当作填充长度裁掉。

下面是在工程根目录运行的对照代码,调用已经与原始 so 核对过的 Python 还原实现。第一遍保留样本行为,第二遍仅调整位序,第三遍同时恢复标准 PC2。所有修改只发生在测试进程中,最后恢复原表,不改 so 和源码文件。

from algorithm import kuaidui_sign as impl

standard_pc2 = tuple(value - 1 for value in (
    14,17,11,24,1,5,3,28,15,6,21,10,
    23,19,12,4,26,8,16,7,27,20,13,2,
    41,52,31,37,47,55,30,40,51,45,33,48,
    44,49,39,56,34,53,46,42,50,36,29,32
))
key = bytes.fromhex('133457799BBCDFF1')
ciphertext = bytes.fromhex('85E813540F0AB405')
expected = bytes.fromhex('0123456789ABCDEF')

def reverse_bits_each_byte(data):
    # 逐字节反转 bit,不改变字节之间的顺序。
    return bytes(int(f'{byte:08b}'[::-1], 2) for byte in data)

def normalized_decrypt():
    keys = impl._round_keys(reverse_bits_each_byte(key))
    result = impl._decrypt_block(reverse_bits_each_byte(ciphertext), keys)
    return reverse_bits_each_byte(result)

original_pc2 = impl.PC2
try:
    print('样本实现:', impl._decrypt_block(ciphertext, impl._round_keys(key)).hex())
    print('只调整位序:', normalized_decrypt().hex())
    impl.PC2 = standard_pc2
    result = normalized_decrypt()
    print('调整位序和 PC2:', result.hex())
    assert result == expected
finally:
    impl.PC2 = original_pc2

本次 Python 对照结果为:

测试条件 解密结果
样本实现 c6aaf1c6d8c3e26d
仅调整密钥、输入和输出的位序,保留样本 PC2 9ba15c58ffe17986
调整位序,并恢复标准 PC2 0123456789abcdef

这组结果与前面找到的差异相互印证。单组向量通过不能代替全部表和运算检查;向量不通过时,也要先排除输入格式、方向、填充等问题。对于当前样本,表项差异、实际取位代码和对照结果都有了,才把它归为 DES 变体。

做还原时仍然要保留样本行为。把 PC2 修成标准值,是为了验证差异来源,不能拿这个修改后的结果去算 app 的 signA/signB。

9.3.4 把样本实际使用的表提取出来

当前样本的表位置如下。它们是 little-endian 的 32 位整数数组,本文涉及的这些 .rodata 地址恰好与文件偏移相同:

表 偏移 元素数
IP 0x11f0 64
SBOX 0x12f0 512
EXPANSION 0x1af0 48
P 0x1bb0 32
FP 0x1c30 64
PC1 0x1d30 56
PC2 0x1e10 48
ROTATIONS 0x1ed0 16

例如提取 PC2:

from pathlib import Path
import struct

so = Path('../libbaseutil.so').read_bytes()
pc2 = struct.unpack_from('<48I', so, 0x1e10)
assert pc2[35] == 46

完整提取脚本为附件中的 scripts/extract_native_tables.py。它会检查 so 的 SHA256,再生成 algorithm/des_tables.py。生成后的算法运行不再需要 so。

9.4 轮密钥和单分组怎么写

下面代码使用已经提取好的八张表,可以在工程根目录执行:

from algorithm.des_tables import IP, FP, PC1, PC2, ROTATIONS, EXPANSION, P, SBOX

def bits_lsb(data):
    return [(byte >> bit) & 1 for byte in data for bit in range(8)]

def pack_lsb(bits):
    return bytes(sum(bits[pos + bit] << bit for bit in range(8))
                 for pos in range(0, len(bits), 8))

def permute(bits, table):
    return [bits[index] for index in table]

def round_keys(key):
    if len(key) != 8:
        raise ValueError('密钥必须为 8 字节')
    selected = permute(bits_lsb(key), PC1)
    left, right = selected[:28], selected[28:]
    keys = []
    for shift in ROTATIONS:
        left = left[shift:] + left[:shift]
        right = right[shift:] + right[:shift]
        keys.append(permute(left + right, PC2))
    return keys

def decrypt_block(block, keys):
    state = permute(bits_lsb(block), IP)
    left, right = state[:32], state[32:]
    for key in reversed(keys):
        expanded = [x ^ y for x, y in zip(permute(right, EXPANSION), key)]
        substituted = []
        for box in range(8):
            six = expanded[box * 6:box * 6 + 6]
            row = (six[0] << 1) | six[5]
            col = (six[1] << 3) | (six[2] << 2) | (six[3] << 1) | six[4]
            value = SBOX[box * 64 + row * 16 + col]
            substituted.extend((value >> bit) & 1 for bit in (3, 2, 1, 0))
        left, right = right, [x ^ y for x, y in zip(left, permute(substituted, P))]
    return pack_lsb(permute(right + left, FP))

解密时轮密钥倒序使用。每轮按照 L_new = R_old、R_new = L_old XOR F(R_old, key) 更新,最后交换并执行 FP。

这里还要注意:虽然输入字节是低位优先展开,但 S-box 的四个输出 bit 按高位到低位写入,不能把所有位处理都机械地统一为同一个方向。

9.5 填充同样要看实际数据

回到 sub_435C,末尾这几行负责裁去填充:

v16 = a3 - (unsigned __int8)v6[a3 - 1];
v17 = sub_5598(sub_5510, v16 + 1, nullsub_2);
j_memcpy(v17, v6, v16);
*((_BYTE *)v17 + v16) = 0;

最后一个字节是要裁去的长度,剩余内容复制到新缓冲区,再补字符串结束符。这几句本身不能证明填充是 PKCS#7,因为它没有检查其余填充字节。

最初按 PKCS#7 检查,signB 解密后会报填充错误。继续比较原始 so 的明文,发现有效部分之后是:

... [22 字节有效明文] 00 02

末尾 02 表示需要去掉两个字节,前一个填充字节是零。这与 ANSI X9.23 形式一致;PKCS#7 在这里应该是 02 02。

上层解密可以写成:

def decrypt_handshake(encoded, key):
    ciphertext = decode_handshake(encoded)
    if len(ciphertext) % 8:
        raise ValueError('密文必须按 8 字节分组')
    keys = round_keys(key)
    padded = b''.join(decrypt_block(ciphertext[i:i + 8], keys)
                      for i in range(0, len(ciphertext), 8))
    padding = padded[-1]
    if not 1 <= padding <= 8 or padded[-padding:-1] != b'\x00' * (padding - 1):
        raise ValueError('填充不匹配')
    return padded[:-padding]

原始函数主要按最后一个字节裁去长度,Python 版还检查零填充是否正确。这是为坏输入增加检查;不能据此声称两者在所有非法输入上的行为完全一致。

<!-- 配图 8:密钥生成函数的 28 bit 轮转和 PC2 表中的异常项。 --> <!-- 配图 9:signB 解密后末尾的 00 02,字段内容可打码。 -->

10. 用中间值定位错误,而不只看最终 sign

写完代码直接看最终 sign,能对上当然好。但如果对不上,光盯着两个不同的字符串,也看不出是哪一步错了。

所以这里我用 Unidbg 跑原始 so,把关键位置的输入输出记下来,再跟 Python 一步步比较:

断点 记录内容 用途
0x435c,解密函数入口 r0 指向的密文字节;r1 指向的 8 字节密钥;r2 的密文长度 检查编码转换和密钥来源
0x43fe,轮密钥生成之后 当前 sp+0x28 起的 768 字节 检查 16×48 个轮密钥 bit,每个 bit 在 native 中占一个 byte
0x224e r0 指向的 signA 解密明文 检查第一层解密
0x231c r0 指向的 signB 解密明文 检查第二层解密
0x242a,第一次 MD5 调用前 r0 指向的 T 检查保存的签名材料
0x2438 r1 指向的固定前缀 确认前缀字节
0x243c,拼接调用前 r2 指向的 MD5(T) 文本 确认第一次 MD5
0x2440,拼接调用后 r0 指向的完整 message 确认拼接规则

这些位置是针对当前函数布局选择的,尤其 sp+0x28 这样的栈偏移必须和当前指令位置配套,不能跨版本照抄。

例如 Unidbg 中记录 signA 解密结果:

debugger.addBreakPoint(module, 0x224e, (emulator, address) -> {
    RegisterContext ctx = emulator.getContext();
    String plainA = ctx.getPointerArg(0).getString(0);
    evidence.setProperty("plaintextA", plainA);
    return true; // 继续执行
});

记录密文则必须指定长度,不能调用 getString(),因为密文中可能包含 NUL:

debugger.addBreakPoint(module, 0x435c, (emulator, address) -> {
    RegisterContext ctx = emulator.getContext();
    int length = ctx.getIntArg(2);
    byte[] ciphertext = ctx.getPointerArg(0).getByteArray(0, length);
    byte[] key = ctx.getPointerArg(1).getByteArray(0, 8);
    // 保存 ciphertext 和 key,供 Python 比较。
    return true;
});

断点负责观察,不替换参数、轮密钥或返回值。探针必须在调用 nativeSetToken 之前安装,才能记录到初始化解密过程。

完整实现为 src/main/java/local/kuaidui/NativeAlgorithmProbe.java。Python 对照时依次检查:

自定义解码后的密文一致
→ 16 轮子密钥一致
→ signA 明文一致
→ signB 明文一致
→ T 一致
→ MD5 和拼接结果一致
→ 最终签名一致

如果密文字节就不一致,先修编码;如果密文一致但轮密钥不同,查位序、PC1/PC2 和轮转;如果明文主体相同而末尾错误,查填充;如果全部解密结果一致,最后签名仍然错误,查拼接、大小写和输入编码。

这套方法也帮助发现了前面的填充差异。已经观察到 signB 的有效明文和 native 完全相同,就不需要重新怀疑整套 DES 实现。

11. 组合为独立实现并验证

前面的解码、解密和 MD5 都有了,最后把它们串起来就行。下面还是按这次抓到的 ASCII 参数来写:

def recover_token(cuid, sign_a, sign_b):
    plain_a = decrypt_handshake(sign_a, b'@fG2SuLA')
    if (len(plain_a) < 53 or plain_a[5:7] != b'##'
            or plain_a[17:19] != b'##' or plain_a[51:53] != b'##'):
        raise ValueError('signA 结构错误')
    if plain_a[53:] != cuid.encode('ascii'):
        raise ValueError('CUID 不匹配')

    challenge = plain_a[7:17]
    plain_b = decrypt_handshake(sign_b, challenge[:5] + b'#G4')
    if len(plain_b) != 22 or plain_b[:10] != challenge or plain_b[10:12] != b'##':
        raise ValueError('signA/signB 不配对')
    return plain_b[12:22]

# 参数从本地配置读取,代码中不写死某个用户的签名材料。
from algorithm.kuaidui_sign import read_properties
config = read_properties('captures/init.properties')
vector = read_properties('vectors/user-sample.properties')
token = recover_token(config['cuid'], config['signA'], config['signB'])
print(sign_ascii(vector['input'], token))

工程中的正式入口已经封装好:

# 默认读取 vectors/user-sample.properties 的 input=
python .\sign.py

# 传入完整原始字符串
python .\sign.py --input '你的Base64入参'

# 固定样本和坏缓存检查,无需 JVM
python -m unittest algorithm.test_sign -v

# 启动原始 so 做对照,开发验证时使用
python .\scripts\verify_algorithm.py

纯算法运行只需要 Python 标准库、algorithm 目录和有效缓存配置,不需要 APK、so 或 Java。

固定回归共有 7 组抓包样本,全部通过。随后将这 7 组样本与 54 组构造输入一起交给原始 so 和 Python,总计 61 次对照,全部匹配。构造输入覆盖空串、ASCII 标点、Base64、MD5 分块边界和长字符串,最长 5464 字符。

其中前面抓到的三组样本,结果如下:

输入中的时间字段 实际结果,与抓包一致
t=1792748284 fc618dc1f2d216437da53eb31484a4f8
t=1792752537 0585429632a0ea7c8344ddb4cbf68e9b
t=1792752540 8ff138d849b57cfebd015656a0b027ea

前面看到后两次输出不一样,还以为是相同输入算出了不同结果。把 Base64 解开一看,里面的时间戳差了 3 秒,其余内容一致。时间戳也是签名输入的一部分,变了之后 sign 自然会变。再按 A、B、A、B 的顺序重复调用,同一份输入每次都能算出同一个结果。

11.1 最后跑一组前面抓到的样本

分析到这里,最后还是得跑一次,把结果放出来。这里就用前面时间字段为 _t_=1792748284 的那组完整输入,一共 716 个字符,抓包结果是 fc618dc1f2d216437da53eb31484a4f8。

这组原始输入已经保存在 algorithm/fixtures/captured-vectors.json,样本名是 user-1792748284。直接读取固定样本来验证,不受后来修改 vectors/user-sample.properties 的影响。在工程根目录运行下面这段 Python:

import json
from pathlib import Path
from algorithm.kuaidui_sign import load_signer

vectors = json.loads(
    Path('algorithm/fixtures/captured-vectors.json').read_text(encoding='utf-8')
)
vector = next(item for item in vectors if item['name'] == 'user-1792748284')
signer = load_signer('captures/init.properties')

actual = signer.sign(vector['input'])
print('name =', vector['name'])
print('input length =', len(vector['input']))
print('expected =', vector['expected'])
print('actual   =', actual)
assert actual == vector['expected'], '签名没有对上'
print('PASS')

本次实际运行输出:

name = user-1792748284
input length = 716
expected = fc618dc1f2d216437da53eb31484a4f8
actual   = fc618dc1f2d216437da53eb31484a4f8
PASS

到这里,这组抓包样本就对上了。整个计算走的是 signA/signB → 恢复 T → 两次 MD5 和拼接,运行这段 Python 不需要启动 Unidbg。

11.2 再放一个能直接运行的短输入

如果只是想先看入口怎么调用,可以用构造输入 MQ==。这四个字符就是完整的 native 入参,原样参与签名,不先解码。它是算法对照样例,不是一份完整登录请求。

在工程根目录执行:

python .\sign.py --input 'MQ=='

本次实际输出:

f62a65e67433bfdabd73f6030baa7f6d

这组输入也在之前原始 so 的对照记录中,位置是 captures/algorithm-native-results.tsv 的 base64-1 样例:

对照项 结果
完整入参 MQ==
原始 so 已保存的结果 f62a65e67433bfdabd73f6030baa7f6d
本次 Python 输出 f62a65e67433bfdabd73f6030baa7f6d
是否一致 PASS

这两组固定输出都依赖本次配套的 captures/init.properties,里面包含 cuid、signA、signB。换了一套缓存,T 可能变化,不能要求同一个输入还得到本文这组固定结果。这里验证的是本地签名计算与抓包、原始 so 结果一致。

这次完成的是“有效初始化缓存 → 恢复签名材料 → 计算 nativeGetSign”的纯算法链。服务端如何生成新的 signB、其他 native 方法如何实现,不在本次已验证范围内。完整 Python 版本也处理了 JNI Modified UTF-8 编码,但非 ASCII、NUL 和代理项尚未做真机对照,本文结果以实际测试的 ASCII/Base64 输入为准。

<!-- 配图 10:脱敏后的中间值比较结果,以及 7 组样本 / 61 次对照通过日志。 -->

附:代码文件

完整的置换表有点长,就不在正文贴几百个数字了,放在 algorithm/des_tables.py 里面。工程里几个主要文件如下:

sign.py                              纯算法入口
algorithm/kuaidui_sign.py            解码、DES 变体、缓存恢复、MD5 签名
algorithm/des_tables.py              从 so 提取的常量表
scripts/extract_native_tables.py     常量表提取脚本
scripts/verify_algorithm.py          原始 so 对照脚本
scripts/native-sign-article.js       补充 native hook 脚本
src/.../NativeAlgorithmProbe.java    本次实际使用的 Unidbg 观察代码

另外还整理了一份 scripts/native-sign-article.js,在前面基础 hook 的流程上增加了方法描述符、模块检查,以及两次 MD5 和字符串拼接的观察点,只在签名调用期间记录。默认打印长度,要看完整内容就把 SHOW_CONTENT 改成 true。

这份补充脚本也只做了语法检查,没有重新在手机上测试。上面纯算法的对照结果来自 Unidbg 执行原始 so,再与 Python 比较。

最后,求职

最后夹带一点私货,求职。本人精通 Web 端逆向,App 逆向中等偏上水平,想找一份逆向相关的工作。有合适的岗位或者内推机会,欢迎私信联系,具体可以聊。


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
点赞 ・0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回