unidbg 算法还原术 · 某民宿 app 篇 · 中卷
本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
上回说到 EncodeHTTP 函数
伪代码又臭又长,看不懂没关系,从下往上看;找关键的函数;
而且之前猜测是 sha1 算法,那我们找找 sha1 的特征;
伪代码虽然很长,但是函数还是没几个的,都点进去看看
tjtxtutf8 看上去很像 base64,后边验证
tjcreate 函数里面有点东西
有个 sha1 的特征,为什么?
哈希算法最明显的特征就是初始化链接常量 (幻数) 和固定常数 K 值
初始化链接常量 (幻数)
md5 算法有 4 个初始化链接常量
A=0x01234567,B=0x89abcdef,C=0xfedcba98,D=0x76543210
但考虑到内存数据存储大小端的问题我们将其赋值为:
A=0x67452301,B=0xefcdab89,C=0x98badcfe,D=0x10325476
sha1 是 md5 的亲兄弟,比 md5 多了一个初始化链接常量;
A=0x67452301,B=0xefcdab89,C=0x98badcfe,D=0x10325476,E=0xCA62C1D6
k 值
md5 k 值取值 (64 个,对应 64 步运算)
constTable=[
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf,
0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af,
0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e,
0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
0xd62f105d, 0x2441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6,
0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8,
0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122,
0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x4881d05, 0xd9d4d039,
0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, 0x432aff97,
0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d,
0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391]
sha1 k 值取值(4 个 k 值,每个 k 值对应 20 步运算)
第1轮 0≤t≤19步 Kt=0x5A827999
第2轮 20≤t≤39步 Kt=0x6ED9EBA1
第3轮 40≤t≤59步 Kt=0x8F188CDC
第4轮 60≤t≤7步 Kt=0xCA62C1D6
回到 ida
这个 0xCA62C1D6 就是 sha1 的 k 值之一,所以说这个函数有 sha1 的特征
而在工程标准化中,哈希加密一般分为 Init、Update、Final 三步
简单来说:
-
Init 是一个初始化函数,初始化核心变量
-
Update 是主计算过程
-
Final 整理和填写输出结果
tjcreate 函数可以看作是第一步的 Init; 但是其他四个常量哪去了呢?
看汇编就知道了 (这些汇编划到最下面有解释)
为啥还是少了一个幻数
因为 ida 把 2C70 识别成函数了,我们手动修改它的数据类型
鼠标移到 sub_2C70 处,按 d
再按 d 转成 word
再按 d 转成 dword
然后找下面的 Update 和 Final
tjreset 函数可以看作是第二步的 Update
int __fastcall tjreset(signed __int64 a1, int a2)
{
int v2; // r4
_BYTE *v3; // r5
int v4; // r6
v2 = a2;
v3 = (_BYTE *)HIDWORD(a1);
v4 = a1;
while ( v2 )
{
*(_BYTE *)(v4 + *(_DWORD *)(v4 + 64)) = *v3;
*(_BYTE *)(v4 + *(_DWORD *)(v4 + 64)) ^= 0x21u;
LODWORD(a1) = *(_DWORD *)(v4 + 64) + 1;
*(_DWORD *)(v4 + 64) = a1;
if ( (_DWORD)a1 == 64 )
{
j_tjupdate(v4, v4);
*(_DWORD *)(v4 + 64) = 0;
a1 = *(_QWORD *)(v4 + 72) + 512LL;
*(_QWORD *)(v4 + 72) = a1;
}
++v3;
--v2;
}
return a1;
}
一般来说只要 hook 这个函数,拿到入参,就能拿到明文
我们先用 frida 来试试,
function myhexdump(name, hexdump_obj, len_obj){
console.log("-------------------"+ name.toString()+"-------------------\\n");
console.log(hexdump(hexdump_obj,{
length:len_obj
}) );
console.log("-------------------ENDEND-------------------\\n")
}
function tjreset() {
var pointer = Module.findBaseAddress("libtujia_encrypt.so").add(0x2C94 + 1);
Interceptor.attach(pointer,
{
onEnter: function (args) {
myhexdump("tjreset arg0:", args[0], 128)
this.buffer = args[0];
myhexdump('tjreset arg1:', args[1], parseInt(args[2]))
console.log('tjreset arg2:' + parseInt(args[2]));
},
onLeave: function (retval) {
myhexdump("tjreset ret:", this.buffer, 16)
}
}
);
}
注意:这里 ida 识别函数参数个数错了,实际应该是三个参数,看汇编就知道了
R0、R1、R2 分别对应参数 1、2、3
开始 hook
unidbg hook:
public void hook2c94(){
IHookZz hookZz = HookZz.getInstance(emulator); // 加载HookZz
hookZz.wrap(module.base + 0x2C94 + 1, new WrapCallback<HookZzArm32RegisterContext>() {
@Override
// 方法执行前
public void preCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
Pointer arg1 = ctx.getPointerArg(1);
int length = ctx.getIntArg(2);
Pointer out = ctx.getPointerArg(0);
ctx.push(out);
ctx.push(length);
Inspector.inspect(arg1.getByteArray(0, length),"tjreset arg1");
};
@Override
// 方法执行后
public void postCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
int length =ctx.pop();
Pointer output = ctx.pop();
byte[] outputhex = output.getByteArray(0, length);
Inspector.inspect(outputhex, "tjreset ret");
}
});
hookZz.disable_arm_arm64_b_branch();
}
public static void main(String[] args) throws FileNotFoundException {
Test test = new Test();
test.hook2c94();//要在函数调用之前hook,不然hook个p
test.call_encrypt();
// test.get_bodyencrypt();
}
unidbg 的输出跟 frida 是一样的:
一般情况来说 tjreset arg1 的值就是明文
打开逆向之友,去碰碰运气
https://gchq.github.io/CyberChef/
结果对不上,难道 sha1 魔改了??
莫急,冷静分析,再看下伪代码,和标准的有没有啥区别
发现 13 行有个异或不对劲,标准算法没这一步
*(_BYTE *)(v4 + *(_DWORD *)(v4 + 64)) ^= 0x21u;
这是把 tjreset arg1 先异或了 0x21 再进行下面的计算
那我们再用 CyberChef 试试
看看明文是啥呢
看着是 base64 编码,结合之前猜测 tjtxtutf8 就是 base64,那就解码看看吧
哦?明文出来了,茅塞顿开
所以这个明文组成就是:
1.#arg1_str#arg5# 固定字符串 #arg2_str#arg3_str 的字符串排序
- 字符串反转
3.base64 编码
不过
有人说了,我就是看不出这有个异或 0x21,你就说咋办吧。。而且这样逆向出来明文好像是靠蒙的,没有成就感啊
行,再露一手
来看看 18 行的 j_tjupdate 函数
int __fastcall tjupdate(int a1, int a2)
{
int i; // r3
int v3; // r1
char *v4; // r5
int v5; // r2
int v6; // r3
int v7; // r5
int v8; // r10
int v9; // r1
int v10; // r6
int v11; // r11
int v12; // r3
int v13; // r0
int v14; // r2
int v15; // r4
int v16; // r3
int v17; // r5
int v18; // r0
int v19; // r6
int v20; // r1
int v21; // r8
int v22; // r0
int v23; // r2
int v24; // r6
int v25; // r2
int v26; // r12
int v27; // r3
int v28; // r0
int v29; // r4
int v30; // r5
int v31; // r3
int v32; // r0
int v33; // r1
__int64 v34; // r3
int v36; // [sp+4h] [bp-17Ch]
int v37; // [sp+8h] [bp-178h]
int v38; // [sp+Ch] [bp-174h]
int v39; // [sp+10h] [bp-170h]
int v40; // [sp+14h] [bp-16Ch]
int v41; // [sp+1Ch] [bp-164h]
char v42[320]; // [sp+20h] [bp-160h]
char _70[320]; // [sp+70h] [bp-110h]
char _C0[320]; // [sp+C0h] [bp-C0h]
char _110[320]; // [sp+110h] [bp-70h]
int v46; // [sp+160h] [bp-20h]
for ( i = 0; i != 64; i += 4 )
*(_DWORD *)&v42[i] = bswap32(*(_DWORD *)(a2 + i));
v3 = 0;
while ( v3 != 256 )
{
v4 = &v42[v3];
v5 = *(_DWORD *)&v42[v3 + 8] ^ *(_DWORD *)&v42[v3 + 32] ^ *(_DWORD *)&v42[v3 + 52];
v6 = *(_DWORD *)&v42[v3];
v3 += 4;
*((_DWORD *)v4 + 16) = __ROR4__(v5 ^ v6, 31);
}
v8 = *(_QWORD *)(a1 + 80) >> 32;
v7 = *(_QWORD *)(a1 + 80);
v9 = *(_DWORD *)(a1 + 100);
v10 = 0;
v12 = *(_DWORD *)(a1 + 92);
v11 = *(_DWORD *)(a1 + 96);
v41 = a1;
v38 = *(_DWORD *)(a1 + 88);
v13 = *(_DWORD *)(a1 + 88);
v14 = v12;
v37 = v12;
v40 = v7;
v39 = v8;
v36 = v11;
while ( 1 )
{
v15 = v14;
v14 = v13;
v16 = v7;
if ( v10 == 20 )
break;
v17 = *(_DWORD *)&v42[4 * v10];
v18 = (v13 & v8 | v15 & ~v8) + v11 + __ROR4__(v16, 27) + v9;
++v10;
v11 = v15;
v7 = v17 + v18;
v13 = __ROR4__(v8, 2);
v8 = v16;
}
v19 = 0;
while ( 1 )
{
v20 = v15;
v15 = v14;
v21 = v16;
if ( v19 == 20 )
break;
v22 = *(_DWORD *)&_70[4 * v19++];
v23 = (v14 ^ v8 ^ v20) + __ROR4__(v16, 27) + v11;
v11 = v20;
v16 = v23 + *(_DWORD *)(v41 + 104) + v22;
v14 = __ROR4__(v8, 2);
v8 = v21;
}
v24 = 0;
while ( 1 )
{
v25 = v20;
v20 = v15;
v26 = v21;
if ( v24 == 20 )
break;
v15 = __ROR4__(v8, 2);
v27 = *(_DWORD *)&_C0[4 * v24];
v28 = (v25 & v20 ^ (v25 ^ v20) & v8) + v11 + __ROR4__(v21, 27) + *(_DWORD *)(v41 + 108);
++v24;
v8 = v21;
v21 = v28 + v27;
v11 = v25;
}
v29 = 0;
while ( 1 )
{
v30 = v25;
v25 = v20;
v31 = v26;
if ( v29 == 20 )
break;
v32 = *(_DWORD *)&_110[4 * v29++];
v33 = (v20 ^ v8 ^ v30) + __ROR4__(v26, 27) + v11;
v11 = v30;
v26 = v33 + *(_DWORD *)(v41 + 112) + v32;
v20 = __ROR4__(v8, 2);
v8 = v31;
}
LODWORD(v34) = v26 + v40;
HIDWORD(v34) = v39 + v8;
*(_QWORD *)(v41 + 80) = v34;
*(_DWORD *)(v41 + 88) = v20 + v38;
*(_DWORD *)(v41 + 92) = v37 + v30;
*(_DWORD *)(v41 + 96) = v36 + v11;
return _stack_chk_guard - v46;
}
这个函数就是 sha1 的一个明文分组计算过程,啥是明文分组呢,简单来说就是你要加密的明文如果很长很长,就要把明文分组,每一组是 512bit,也就是 64 字节长度,不足的要填充,填充规则如下
1 在信息的后面填充一个1和无数个0,直到满足上面的条件时才停止用0对信息的填充。
2 在这个结果后面附加一个以64位二进制表示的填充前信息长度(单位为Bit),如果二
进制表示的填充前信息长度超过64位,则取低64位。
经过这两步的处理,信息的位长=N*512+448+64=(N+1)*512,即长度恰好是512的整数倍。
这样做的原因是为满足后面处理中对信息长度的要求。
要讲明白还是有点难(为了不误人子弟)大家感兴趣的自己搜资料研究;
回到 tjupdate 函数
每一个分组进行 4 轮变换,每一轮计算 20 步,伪代码也很明显(78 行、94 行、109 行、125 行)
我们 hook 这个函数,就能知道每一轮的明文;
function tjupdate() {
var pointer = Module.findBaseAddress("libtujia_encrypt.so").add(0x2AB4 + 1);
Interceptor.attach(pointer,
{
onEnter: function (args) {
myhexdump("update arg0:", args[1], 64)
console.log("update arg0:"+Memory.readUtf8String(args[1]))
},
onLeave: function (retval) {
}
}
);
unidbg hook:
public void hook_2AB4(){
IHookZz hookZz = HookZz.getInstance(emulator);
hookZz.enable_arm_arm64_b_branch();
hookZz.wrap(module.base + 0x2AB4 + 1, new WrapCallback<HookZzArm32RegisterContext>() {
@Override
// 方法执行前
public void preCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
Pointer text = ctx.getPointerArg(1);
byte[] texthex = text.getByteArray(0, 64);
Inspector.inspect(texthex, "block");
};
@Override
// 方法执行后
public void postCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
}
});
hookZz.disable_arm_arm64_b_branch();
};
然后把每次的明文拼接起来,就是整个的明文
然后再 sha1,是不是一样?
话说回来,Final 还没看呢,Final 是最后整理输出的,
有些情况不参与计算,有些情况参与计算,也就是 tjget 函数了, 在这个案例里是参与了计算的
hook 一个
function tjget() {
var pointer = Module.findExportByName("libtujia_encrypt.so",'tjget');
console.log('case tjget:' + pointer);
Interceptor.attach(pointer,
{
onEnter: function (args) {
this.buffer = args[1];
},
onLeave: function (retval) {
myhexdump("tjget 结果:", this.buffer, 20)
}
}
);
}
unidbg hook:
public void hook_2CEA(){
IHookZz hookZz = HookZz.getInstance(emulator); // 加载HookZz
hookZz.wrap(module.base + 0x2CEA + 1, new WrapCallback<HookZzArm32RegisterContext>() {
@Override
// 方法执行前
public void preCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
Pointer arg1 = ctx.getPointerArg(1);
ctx.push(arg1);
};
@Override
// 方法执行后
public void postCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
Pointer output = ctx.pop();
byte[] outputhex = output.getByteArray(0, 20);
Inspector.inspect(outputhex, "tjget ret");
}
});
hookZz.disable_arm_arm64_b_branch();
}
hook 结果:
明文的生成
分析完最后一步的标准算法,回过头来分析明文的生成
看看具体实现的代码:
首先这里生成了固定的 key,
这里用 unidbg 的另一种 hook 方式
在这个地方下个断点
代码如下:
public void HookByConsoleDebugger() {
Debugger debugger = emulator.attach();
//在module.base+0x3108地址处添加一个断点
debugger.addBreakPoint(module.base+0x3108);
}
public static void main(String[] args) throws FileNotFoundException {
Test test = new Test();
//test.hook2c94();
// test.hook_2AB4();
test.HookByConsoleDebugger();
test.call_encrypt();
// test.get_bodyencrypt();
}
运行,程序会断下
这里的 r0 寄存器存放的是参数 1 的值,r1 是参数 2,r3 是参数 3
怎么查看 r0 的值呢
输入:mr0
也可以输入地址:m0xbffff65c
默认 size 是 112,可以指定长度
所以这里的参数 1 是 tjhchk
返回值怎么看呢,根据 arm 汇编的约定,返回值会放到 r0
这下面几个地址都可以
在 0x310C 加个断点,命令:b0x310C
然后按 c(跳到下一个断点)
然后查看 r0 的值
验证后发现是标准的 base64
还有一种方法能获取返回值
0x291C 下个断点 (函数具体实现的地址)
debugger.addBreakPoint(module.base+0x291C);
重新运行程序
这时 r0 一样是 tjhchk
然后输入命令 blr,blr 会在函数返回的地方下一个断点
c(跳到一下个断点)
查看 r0 的值
生成了 key;然后往下看
可以看到很多地方都在异或 0x21
这些都是把各个参数先异或 0x21,再拼接,最后是在 j_tjsplittxt 函数做字符串翻转
来打个断点瞧瞧
debugger.addBreakPoint(module.base+0x2DEC);
这个参数 1 是乱码,我们把他异或 0x21 试试呢
这里就已经拼接好了嘛
看看返回值,blr,c
这里的返回值在 r8,我猜是因为在 Thumb 程序中,只能使用 r4~r7 来保存局部变量
直接 mr8 是不行的,
所以我们 m0x40223180
或者输入 s,单步调试,让它把 r8 赋值到 r0
然后把这个值异或 0x21
然后又是 base64
上面那个调用第三个参数是 10,这里是 30,有何大咪咪呢?
进去看看
编码之前参数 3 不等于 10 的时候编码之前把明文异或 0x21
编码之后参数 3 不等于 10 的时候把明文异或 0x21
所以总结下来就是,
-
每个参数和固定的 key 分别异或 0x21(有个参数要排序)
-
拼接
-
翻转(j_tjsplittxt)
-
异或 0x21(tjtxtutf8)
-
base64 编码(tjtxtutf8)
-
异或 0x21(tjtxtutf8)
-
异或 0x21(j_tjreset)
-
sha1(j_tjreset)
发现没有,4 次异或 0x21,等于就是没有异或。。所以可以简化成 3 步
-
参数拼接(有个参数要排序)
-
翻转
-
sha1
剩下就是写代码了。。
下篇 body 分析再见
彩蛋
初始化链接常数加载汇编指令解析
0x2C48 下个断点 blr c 查看 r0
上面说了转成小端就是 sha1 五个幻数
0x67452301 0xEFCDAB89 0x98BADCFE 0x10325476 0xC3DEE1F0
看看汇编是怎么加载的
ADR R1, dword_2C80
adr 用来加载地址,而且是相对地址寻址
dword 修饰一个操作数为 Double Word,即 4 字节
即 0x2C80 地址放到 R1
看看内存中的值
然后就是下面的两行
VLD1.64 {D16-D17}, [R1]
VST1.64 {D16-D17}, [R0]
V 开头表示是 NEON 指令
什么是 NEON(抄的)
Arm NEON 技术是针对 Arm Cortex-A 系列和 Cortex-R52 处理器的高级 SIMD(单指令多数据)架构扩展。
具有NEON技术的处理器都会配备了32个64位的寄存器和16个128位的寄存器,它们分别被标识为(D0-D31),(Q0-Q15)
vld1.64 {d16, d17}, [r1]
将r1内存中的数据映射到d16, d17寄存器上面。这样就可以直接通过d16, d17寄存器来操作数据,64表示一个寄存器有64位(8个16进制)
在这里就是把 r1 中的 01 23 45 67 89 AB CD EF 放到 d16 寄存器,FE DC BA 98 76 54 32 10 放到 d17 寄存器
vst1.64 {d16, d17}, [r0]
将d16, d17寄存器中的数据映射回r0内存中,这样就可以通过打印r0来看到结果
在这里就是把 d16(01 23 45 67 89 AB CD EF) 放到 r0 寄存器,d17(FE DC BA 98 76 54 32 10) 放到 r0 寄存器
之后呢在 tjupdate 函数里,加载出来使用
汇编
重点说下这个地方
LDMIA.W R11, {R2,R3,R11}
LDMIA指令,IA表示每次传送后地址加4,W修饰一个操作数为Double Word,即4字节
R11 在 unidbg 中是 sp
依次把 FE DC BA 98 放到 R2; 76 54 32 10 放到 R3
按 s
可以看到 R2、R3 已经被重新赋值 (小端)
其他的地方可以自己动态调试看看