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

sha1md5的亲兄弟,比md5多了一个初始化链接常量;

A=0x67452301,B=0xefcdab89,C=0x98badcfe,D=0x10325476,E=0xCA62C1D6

k

md5 k值取值 (64个,对应64步运算)

constTable=[
0xd76aa4780xe8c7b7560x242070db, 0xc1bdceee, 0xf57c0faf,
0x4787c62a0xa83046130xfd4695010x698098d80x8b44f7af,
0xffff5bb10x895cd7be, 0x6b9011220xfd9871930xa679438e,
0x49b408210xf61e25620xc040b3400x265e5a510xe9b6c7aa,
0xd62f105d0x24414530xd8a1e6810xe7d3fbc80x21e1cde6,
0xc33707d60xf4d50d870x455a14ed, 0xa9e3e9050xfcefa3f8,
0x676f02d90x8d2a4c8a, 0xfffa39420x8771f6810x6d9d6122,
0xfde5380c0xa4beea440x4bdecfa90xf6bb4b600xbebfbc70,
0x289b7ec60xeaa127fa, 0xd4ef30850x4881d050xd9d4d039,
0xe6db99e50x1fa27cf80xc4ac56650xf42922440x432aff97,
0xab9423a70xfc93a0390x655b59c30x8f0ccc920xffeff47d,
0x85845dd10x6fa87e4f, 0xfe2ce6e00xa30143140x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391]

sha1 k值取值(4k值,每个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就是sha1k值之一,所以说这个函数有sha1的特征

而在工程标准化中,哈希加密一般分为Init、Update、Final三步

简单来说:

  1. Init是一个初始化函数,初始化核心变量

  2. Update是主计算过程

  3. Final整理和填写输出结果

tjcreate函数可以看作是第一步的Init;但是其他四个常量哪去了呢?

看汇编就知道了(这些汇编划到最下面有解释)

为啥还是少了一个幻数

因为ida2C70识别成函数了,我们手动修改它的数据类型

鼠标移到sub_2C70处,按d

再按d转成word

再按d转成dword

然后找下面的UpdateFinal

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的字符串排序

  1. 字符串反转

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]))
            },
              onLeavefunction (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,
          {
              onEnterfunction (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是参数2r3是参数3

怎么查看r0的值呢

输入:mr0

也可以输入地址:m0xbffff65c

默认size112,可以指定长度

所以这里的参数1tjhchk

返回值怎么看呢,根据arm汇编的约定,返回值会放到r0

这下面几个地址都可以

0x310C加个断点,命令:b0x310C

然后按c(跳到下一个断点)

然后查看r0的值

验证后发现是标准的base64

还有一种方法能获取返回值

0x291C下个断点(函数具体实现的地址)

debugger.addBreakPoint(module.base+0x291C);

重新运行程序

这时r0一样是tjhchk

然后输入命令blrblr会在函数返回的地方下一个断点

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

所以总结下来就是,

  1. 每个参数和固定的key分别异或0x21(有个参数要排序)

  2. 拼接

  3. 翻转(j_tjsplittxt)

  4. 异或0x21(tjtxtutf8)

  5. base64编码(tjtxtutf8)

  6. 异或0x21(tjtxtutf8)

  7. 异或0x21(j_tjreset)

  8. sha1(j_tjreset)

发现没有,4次异或0x21,等于就是没有异或。。所以可以简化成3

  1. 参数拼接(有个参数要排序)

  2. 翻转

  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技术的处理器都会配备了3264位的寄存器和16128位的寄存器,它们分别被标识为(D0-D31),(Q0-Q15)
vld1.64 {d16, d17}, [r1]r1内存中的数据映射到d16, d17寄存器上面。这样就可以直接通过d16, d17寄存器来操作数据,64表示一个寄存器有64位(816进制)

在这里就是把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字节

R11unidbg中是sp

依次把FE DC BA 98放到R2; 76 54 32 10放到R3

s

可以看到R2R3已经被重新赋值(小端)

其他的地方可以自己动态调试看看

上一页
下一页