在上一篇文章分析了q参数之后,我们继续深入探讨sign参数的生成逻辑。与q参数的定位方式类似,可以通过堆栈回溯,或者对getByte、HashMap等关键方法进行Hook来找到线索。

最终,调用链指向一个名为a的方法,其中核心是md5_crypt方法。通过Hook其传入参数,可以快速验证其输出结果。确定加密点后,便可以编写Unidbg代码进行模拟调用。

public void md5_crypt() {
DvmClass CryptoHelper = vm.resolveClass("com/luckincoffee/safeboxlib/CryptoHelper");
byte[] bytes = "hello".getBytes();
String a2 = "u7Su25kSE9PxcTgQZkRgL0kJ+lDaV2IQcqdsfGGuNDs=";
byte[] bytes2 = Base64.getDecoder().decode(a2.replace('-', '+').replace('_', '/').getBytes());
ByteArray reval = CryptoHelper.callStaticJniMethodObject(emulator, "md5_crypt([BI[B)[B", bytes, 1, bytes2);
System.out.println("md5_crypt:" + new String(reval.getValue()));
}
=================================================
JNIEnv->FindClass(com/luckincoffee/safeboxlib/CryptoHelper) was called from RX@0x400450c8[libcryptoDD.so]0x450c8
...
Find native function Java_com_luckincoffee_safeboxlib_CryptoHelper_md5_1crypt => RX@0x40043bbc[libcryptoDD.so]0x43bbc
...
md5_crypt:7391766891689134774185162898388901200
成功打印出md5_crypt函数的结果。我们跟进到0x43bbc地址,这就是计算sign的核心函数。该函数经过了OLLVM混淆,但代码量不大,其中主要包含两个关键子函数:sub_4559C 和 sub_4095C。

首先需要修正JNIEnv相关结构,以便IDA能正确识别部分JNI函数。

代码中出现了两个关键函数。我们先Hook 0x4559C函数,观察其参数。可以发现从Java层传入的a5(即Base64解码后的值)被传递给了此函数。运行Hook并打印其输入与输出。
public void HookByConsoleDebugger() {
emulator.attach().addBreakPoint(module.base + 0x4559C, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
RegisterContext context = emulator.getContext();
UnidbgPointer input = context.getPointerArg(1);
DvmObject<?> dvmbytes = vm.getObject(input.toIntPeer());
byte[] result = (byte[]) dvmbytes.getValue();
System.out.println("传入数据: " + bytesTohexString(result));
emulator.attach().addBreakPoint(context.getLRPointer().peer, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
RegisterContext returnContext = emulator.getContext();
UnidbgPointer v12Value = returnContext.getPointerArg(0);
DvmObject<?> v12Obj = vm.getObject(v12Value.toIntPeer());
byte[] v12Data = (byte[]) v12Obj.getValue();
System.out.println("结果: " + new String(v12Data));
return true;
}
});
return true;
}
});
}

Hook结果显示,传入参数是bbb4aedb991213d3f17138106644602f4909fa50da57621072a76c7c61ae343b,这正是Base64字符串解码后的Hex表示。函数输出结果为ooL1Bqoa8lF6ZE54。
接着分析sub_4095C。该函数有三个参数,Hook后观察:第一个参数是hello与4559C函数输出结果的拼接,即helloooL1Bqoa8lF6ZE54。4095C的运行结果与md5_crypt函数的最终输出一致。因此,需要继续深入4095C函数内部。
public void HookByConsoleDebugger1() {
emulator.attach().addBreakPoint(module.base + 0x4095C, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
RegisterContext context = emulator.getContext();
UnidbgPointer input = context.getPointerArg(0);
UnidbgPointer output = context.getPointerArg(2);
System.out.println("传入数据: " + input.getString(0));
emulator.attach().addBreakPoint(context.getLRPointer().peer, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
byte[] v27Data = output.getPointer(0).getByteArray(0, 37);
System.out.println("结果: " + new String(v27Data));
return true;
}
});
return true;
}
});
}

进入函数后,跟踪入参a1,发现它被传入了0x41084方法。根据上下文,a1是输入数据,a2是长度。我们在该函数返回时Hook第三个参数。

public void HookByConsoleDebugger2() {
emulator.attach().addBreakPoint(module.base + 0x41084, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
RegisterContext context = emulator.getContext();
UnidbgPointer input = context.getPointerArg(0);
UnidbgPointer output = context.getPointerArg(2);
System.out.println("传入数据: " + input.getString(0));
emulator.attach().addBreakPoint(context.getLRPointer().peer, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
Inspector.inspect(output.getByteArray(0, 16), "0x41084 arg3");
return true;
}
});
return true;
}
});
}

Hook结果表明,0x41084函数是一个标准的MD5算法实现,但其输出结果与我们最终得到的sign不同,说明MD5的结果后续还经过了其他处理。
为了方便分析,在IDA中将0x41084函数重命名为md5。继续分析,发现几个可疑的函数。其中0x4108C也调用了0x3D1F4。

// attributes: thunk
__int64 __fastcall sub_4108C(__int64 a1, __int64 a2) {
return sub_3D1F4(a1, a2);
}
__int64 __fastcall sub_4109C(__int64 result) {
if (result >= 0)
return result;
else
return -result;
}
分析发现,程序会调用sub_3D1F4方法,每次处理MD5结果的4个字节。运算结果经过sub_4109C进行取绝对值(或根据与0的比较结果决定取本身还是取反)操作,然后格式化输出,最后通过strcat拼接得到最终结果。因此,核心逻辑在sub_3D1F4函数中。
v17 = sub_3D1F4(v32, 0LL);
v19 = sub_3D1F4(v32, 4LL);
v10 = sub_3D1F4(v32, 8LL);
v13 = sub_3D1F4(v32, 12LL);
来到sub_3D1F4,代码简短但逻辑不直观,使用Debugger进行动态分析。


断点后可以看到,第一个参数x1是MD5的结果,第二个参数是0,对应代码v17 = sub_3D1F4(v32, 0LL);。在函数返回处(BLR指令后)继续执行,观察x0寄存器,发现其值为0xd3f10f0f(对应十进制-739176689),而739176689正是最终sign结果的第一部分。
继续Debug,执行v19 = sub_3D1F4(v32, 4LL);。

观察返回结果x0=0x64ae26b6,转换为十进制1689134774,是sign结果的第二部分。后续两次调用结果分别为0x0b095c92和0x172e2950,转换为十进制185162898和388901200。
将四部分拼接:739176689、1689134774、185162898、388901200,与最终结果7391766891689134774185162898388901200对比,发现最后两部分被合并了(185162898 + 388901200 => 185162898388901200)。

通过动态调试发现,如果函数返回值为负,则最终结果会发生变化;如果为正值,结果就是MD5对应4字节转成的十进制数。为了彻底弄清其逻辑,在函数执行时进行汇编指令级别的Trace。
public void HookByConsoleDebugger2() {
emulator.attach().addBreakPoint(module.base + 0x3D1F4, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
emulator.traceCode(module.base, module.base + module.size);
RegisterContext context = emulator.getContext();
int a2 = context.getIntArg(1);
System.out.println("===========入参=================");
System.out.println("传入数据: " + a2);
emulator.attach().addBreakPoint(context.getLRPointer().peer, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
RegisterContext returnContext = emulator.getContext();
int Value = returnContext.getIntArg(0);
System.out.println("============出参================" + Value);
return true;
}
});
return true;
}
});
}
(Trace日志较长,此处仅截取关键部分)
传入数据: 0
...
[19:36:50 573] [1f000071] 0x4004109c: "cmp w0, #0" => nzcv: N=1, Z=0, C=1, V=0 w0=0xd3f10f0f
[19:36:50 573] [0054805a] 0x400410a0: "cneg w0, w0, mi" nzcv: N=1, Z=0, C=1, V=0 w0=0xd3f10f0f => w0=0x2c0ef0f1
[19:36:50 575] [c0035fd6] 0x400410a4: "ret"
重点关注这三行ARM汇编指令。其逻辑是:
cmp w0, #0:比较w0寄存器(存储着MD5的4字节数据)与0。
cneg w0, w0, mi:当条件为mi(负数,即N=1)时,对w0的值进行“条件取反”(计算其二进制补码,即按位取反后加1)。
0xd3f10f0f为负数,因此执行cneg后变为0x2c0ef0f1(即-739176689的补码表示)。

至此,算法逻辑已清晰:对MD5结果的每4个字节作为一个有符号整数进行判断,若为负数,则取其补码(绝对值)对应的无符号整数;若为正数,则直接使用。最后将所有结果转换为十进制字符串并拼接。
使用Python复现该逻辑。首先对拼接后的字符串计算MD5,得到9f8b2056b6cb87149b196da5b305059a,然后进行上述运算,结果与Unidbg运行结果一致。
hex_str = "9f8b2056b6cb87149b196da5b305059a"
result = ''.join(str(int(hex_str[i:i+8],16) if int(hex_str[i:i+8],16) < 0x80000000 else (~int(hex_str[i:i+8],16)+1) & 0xFFFFFFFF) for i in range(0,32,8))
print(result)

此案例虽然使用了OLLVM混淆,但整体代码量不大,算法逻辑清晰,非常适合作为使用Unidbg进行Android Native层逆向分析的入门练习。它完整展示了从定位加密点、动态调试分析到算法还原的整个过程。
