【转载】android app 加密参数分析研究 hash aes

转载 17 / 25

说在前面

本文转载自龙哥星球,资料等相关内容,可加入星球下载


一、前言

一个新的开始。

本篇的阅读基础是 Unidbg Hook 大全

二、Unidbg 模拟执行

首先模拟执行,先搭个架子

package com.douyu;

import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.memory.Memory;

import java.io.File;

public class DouYu extends AbstractJni {
    private final AndroidEmulator emulator;
    private final VM vm;
    private final Module module;

    public DouYu() {
        emulator = AndroidEmulatorBuilder.for32Bit().build();
        Memory memory = emulator.getMemory();
        memory.setLibraryResolver(new AndroidResolver(23));
        vm = emulator.createDalvikVM(new File("unidbg-android/src/test/resources/douyu/douyu.apk"));
        vm.setVerbose(true);
        vm.setJni(this);
        DalvikModule dm = vm.loadLibrary(new File("unidbg-android/src/test/resources/douyu/libmakeurl2.5.0.so"), true);
        dm.callJNI_OnLoad(emulator);
        module = dm.getModule();
    }

    public static void main(String[] args) {
        DouYu douyu = new DouYu();
    }
}

运行

很多朋友看到 ”不合法的JNI版本“,就不知所措,其实这个报错并非直意——JNI版本错误,而只意味 JNIOnLoad 运行过程中出了错。至于具体出错的原因有很多,一个常见的问题是SO的依赖库缺失。即程序调用依赖库中某个函数时,因为这个依赖库没加载到Unidbg虚拟内存中,进而发生寻址错误,比如上图就是 0x1664 地址访问失败。

不知道大家是否记得,星球的样本二,存在和此处一模一样的问题。在Unidbg日志的第一行我们看到,libc++_shared.so加载失败,即库缺失报错。

我们的目标SO依赖了 libc++_shared.so,这个库是C++的支持库,但不在Unidbg默认支持的SO里。我们要在apk的lib里把它拷贝出来。

然后放在目标SO的同级目录,Unidbg会主动来这里找依赖库。

再次运行

现在一切顺利了。有的朋友可能会记不住上面这个模式,还有另一个更通用的办法。当报错时,就把traceCode打开,记录执行流,看最后在哪儿停下来。

比如此处,我在JNI_OnLoad前面打开traceCode,在IDA中跳转到最后一条运行地址 0x177C

可以发现正是C++的标准库函数,符合上面的猜测。

不妨完整列一下,当Unidbg需要外部依赖库的处理办法。

  • 简单的直接加载,比如此处
  • 复杂的用虚拟模块,比如传感器相关的SO信息获取
  • 图省事用Hook和Patch

对上面几种办法不熟悉但又遇到相关需求的朋友,可以看前面的文章。

虽然现在JNIOnLoad 顺利运行完了,但我们发现,Unidbg日志显示,JAVA Native函数通过RegisterNative动态绑定,但是,IDA跳转到 JNI OnLoad,F5 找不到 RegisterNative 相关代码。

jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
  jint v2; // r5
  int v3; // r8
  int v4; // r8
  int v6; // [sp+8h] [bp-38h]

  v3 = -1341282144;
  v6 = ((int (__fastcall *)(JavaVM *))(*vm)->GetEnv)(vm);
  while ( 1 )
  {
    if ( v3 > -817103993 )
    {
      v4 = 193423971;
      goto LABEL_9;
    }
    if ( v3 == -1801756530 )
      break;
    v3 = -817103992;
    if ( v6 )
      v3 = -1801756530;
  }
  do
  {
    v2 = -1;
    v4 = 1934838363;
LABEL_9:
    ;
  }
  while ( v4 <= 1203334524 );
  return v2;
}

这其实是IDA反编译的问题,F5仅供参考,在汇编界面我们可以看到相关调用逻辑。

off_48004

下面就到了今天的主角——native_makeUrl函数,Unidbg中先call它,参数又臭又长,我构造的很随意,因为我们只是学习用途。

package com.douyu;

import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.array.ArrayObject;
import com.github.unidbg.memory.Memory;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

public class DouYu extends AbstractJni {
    private final AndroidEmulator emulator;
    private final VM vm;
    private final Module module;

    public DouYu() {
        emulator = AndroidEmulatorBuilder.for32Bit().build();
        Memory memory = emulator.getMemory();
        memory.setLibraryResolver(new AndroidResolver(23));
        vm = emulator.createDalvikVM(new File("unidbg-android/src/test/resources/douyu/douyu.apk"));
        vm.setVerbose(true);
        vm.setJni(this);
        DalvikModule dm = vm.loadLibrary(new File("unidbg-android/src/test/resources/douyu/libmakeurl2.5.0.so"), true);
        dm.callJNI_OnLoad(emulator);
        module = dm.getModule();
    }

    public String getMakeUrl(){
        // args list
        List<Object> list = new ArrayList<>(10);
        // arg1 env
        list.add(vm.getJNIEnv());
        // arg2 jobject/jclazz 一般用不到,直接填0
        list.add(0);

        DvmObject<?> context = vm.resolveClass("android/content/Context").newObject(null);
        list.add(vm.addLocalObject(context));

        list.add(vm.addLocalObject(new StringObject(vm, "")));

        StringObject input3_1 = new StringObject(vm, "aid");
        StringObject input3_2 = new StringObject(vm, "client_sys");
        StringObject input3_3 = new StringObject(vm, "time");

        vm.addLocalObject(input3_1);
        vm.addLocalObject(input3_2);
        vm.addLocalObject(input3_3);

        list.add(vm.addLocalObject(new ArrayObject(input3_1, input3_2, input3_3)));

        StringObject input4_1 = new StringObject(vm, "android1");
        StringObject input4_2 = new StringObject(vm, "android");
        StringObject input4_3 = new StringObject(vm, "1638452332");

        vm.addLocalObject(input4_1);
        vm.addLocalObject(input4_2);
        vm.addLocalObject(input4_3);

        list.add(vm.addLocalObject(new ArrayObject(input4_1, input4_2, input4_3)));

        StringObject input5_1 = new StringObject(vm, "");
        StringObject input5_2 = new StringObject(vm, "");
        StringObject input5_3 = new StringObject(vm, "");
        StringObject input5_4 = new StringObject(vm, "");
        StringObject input5_5 = new StringObject(vm, "");
        StringObject input5_6 = new StringObject(vm, "");
        StringObject input5_7 = new StringObject(vm, "");
        StringObject input5_8 = new StringObject(vm, "");
        StringObject input5_9 = new StringObject(vm, "");
        StringObject input5_10 = new StringObject(vm, "");
        StringObject input5_11 = new StringObject(vm, "");
        StringObject input5_12 = new StringObject(vm, "");
        StringObject input5_13 = new StringObject(vm, "");

        vm.addLocalObject(input5_1);
        vm.addLocalObject(input5_2);
        vm.addLocalObject(input5_3);
        vm.addLocalObject(input5_4);
        vm.addLocalObject(input5_5);
        vm.addLocalObject(input5_6);
        vm.addLocalObject(input5_7);
        vm.addLocalObject(input5_8);
        vm.addLocalObject(input5_9);
        vm.addLocalObject(input5_10);
        vm.addLocalObject(input5_11);
        vm.addLocalObject(input5_12);
        vm.addLocalObject(input5_13);

        list.add(vm.addLocalObject(new ArrayObject(input5_1, input5_2, input5_3,input5_4, input5_5, input5_6,input5_7, input5_8, input5_9,input5_10, input5_11, input5_12,input5_13)));

        StringObject input6_1 = new StringObject(vm, "");
        StringObject input6_2 = new StringObject(vm, "");
        StringObject input6_3 = new StringObject(vm, "");
        StringObject input6_4 = new StringObject(vm, "");
        StringObject input6_5 = new StringObject(vm, "");
        StringObject input6_6 = new StringObject(vm, "");
        StringObject input6_7 = new StringObject(vm, "");
        StringObject input6_8 = new StringObject(vm, "");
        StringObject input6_9 = new StringObject(vm, "");
        StringObject input6_10 = new StringObject(vm, "");

        vm.addLocalObject(input6_1);
        vm.addLocalObject(input6_2);
        vm.addLocalObject(input6_3);
        vm.addLocalObject(input6_4);
        vm.addLocalObject(input6_5);
        vm.addLocalObject(input6_6);
        vm.addLocalObject(input6_7);
        vm.addLocalObject(input6_8);
        vm.addLocalObject(input6_9);
        vm.addLocalObject(input6_10);
        list.add(vm.addLocalObject(new ArrayObject(input6_1, input6_2, input6_3,input6_4, input6_5, input6_6,input6_7, input6_8, input6_9,input6_10)));
        list.add(0);
        list.add(1);
        // 参数准备完成
        // call function
        Number number = module.callFunction(emulator, 0x2f91, list.toArray());
        return vm.getObject(number.intValue()).getValue().toString();
    }

    public static void main(String[] args) {
        DouYu douyu = new DouYu();
        System.out.println("result:"+douyu.getMakeUrl());
    }
}

运行后直接出结果

可以发现,结果由四部分组成,前三个参数是我们 input4 传进去的内容,所以需要分析的只有auth的来源。

多次运行我们意识到,auth 的值,恒为 5e60a3002273d85bee3b9ad0893e9c37,长度 32 位。

三、算法分析

首先,确认函数执行流的汇编长度,如果上千万甚至上亿行,那我们就徐徐图之。如果几十万行,那就重拳出击。

public void traceLength(){
    emulator.getBackend().hook_add_new(new CodeHook() {
        int count = 0;
        @Override
        public void hook(Backend backend, long address, int size, Object user) {
            count += 1;
            System.out.println(count);
        }

        @Override
        public void onAttach(UnHook unHook) {

        }

        @Override
        public void detach() {

        }
    }, module.base, module.size+module.base, null);
}

public static void main(String[] args) {
    DouYu douyu = new DouYu();
    douyu.traceLength();
    System.out.println("result:"+douyu.getMakeUrl());
}

运行计数共九十多万行,这是很棒的结果,不超过100w行的执行流,要么程序没怎么混淆,要么逻辑不太复杂。两者任意一个复杂度高一些,都不会只有100w行汇编以内。

再确认一下样本大概使用了哪些加密算法

SO中至少存在 AES/BASE64,至于我们的函数中用了什么?这得具体分析,毕竟Findcrypt只是一个静态的、加密特征匹配插件。

  • 目标函数可能用了AES/Base64,说”可能“是上述算法可能用于SO中其他函数而非目标函数。
  • 目标函数可能用了AES和Base64之外的其他加密算法,因为FIndCrypt提供了静态的、有限的分析,很容易遗漏。

使用Unidbg处理算法,一般而言,自下而上分析更省时省力,这得益于Unidbg两方面的能力

  • 强大方便的内存读写监控

  • 无地址随机化

这让我们可以逆流而上,自结果推来源,分析算法和数据块十分轻松。

运算结果来自于NewStringUTF,这个JString从哪里来的?

JNIEnv->NewStringUTF("aid=android1&client_sys=android&time=1638452332&auth=5e60a3002273d85bee3b9ad0893e9c37") was called from RX@0x4000336f[libmakeurl.so]0x336f

日志提示调用处在0x336f,这个地址实际上是LR(返回地址),所以NewStringUTF函数调用是 0x336f 的上一条 0x336C。

在 0x336C 下断点

回顾一下NewStringUTF 这个JNI方法,数据来源就是参数二字符数组

jstring NewStringUTF(JNIEnv *env, const char *bytes);

数据从地址0x402d20a0开始,数一下auth的32个字节所处的地址,监控对它的写入。

emulator.traceWrite(0x402d20d5,0x402d20d5+0x20);

从下往上寻找对内存最晚的操作,可以发现,这32个字节的赋值发生在libc里。一般数据在libc里赋值,指的是调用了libc中memcpy等库函数做拷贝、转换、比较等处理,而非数据生成的第一现场。

不要慌,把Unidbg的libc.so拷贝一份出来,扔到IDA里。搜索0x17d3e,看具体是哪个函数。我们发现是在strcat函数里,即做字符串拼接。

hook strcat 函数,进行追踪

char *strcat(char *dest, const char *src)
  • dest -- 指向目标数组,该数组包含了一个 C 字符串,且足够容纳追加后的字符串。
  • src -- 指向要追加的字符串,该字符串不会覆盖目标字符串。
public void hookStrCat(){
    emulator.attach().addBreakPoint(module.findSymbolByName("strcat", true).getAddress(), new BreakPointCallback() {
        @Override
        public boolean onHit(Emulator<?> emulator, long address) {
            UnidbgPointer r1 = emulator.getContext().getPointerArg(1);
            System.out.println("strcat:"+ r1);
            System.out.println(r1.getString(0));
            return true;
        }
    });
}

运行,可以发现结果的四个字段就是strcat 逐步拼接的结果。

对来源 0xbffff69bL 做traceWrite,千万记得加后缀L。

emulator.traceWrite(0xbffff69bL,0xbffff69bL+0x20);

发现依然来自于libc,这不是好事,说明我们还没到第一现场。

IDA中跳过去

void __fastcall _memcpy_base(int a1, char *a2, unsigned int a3, int a4, int a5, int a6)

它应该是memcpy函数内部的子函数,我们Hook一下memcpy,其原型如下

void *memcpy(void *str1, const void *str2, size_t n)
  • str1 -- 指向用于存储复制内容的目标数组,类型强制转换为 void* 指针。
  • str2 -- 指向要复制的数据源,类型强制转换为 void* 指针。
  • n -- 要被复制的字节数。

我们打印str2,长度为n

public void hookMemcpy(){
    emulator.attach().addBreakPoint(module.findSymbolByName("memcpy", true).getAddress(), new BreakPointCallback() {
        @Override
        public boolean onHit(Emulator<?> emulator, long address) {
            UnidbgPointer r1 = emulator.getContext().getPointerArg(1);
            int length = emulator.getContext().getIntArg(2);
            System.out.println("memcpy");
            Inspector.inspect(r1.getByteArray(0, length), r1.toString());
            return true;
        }
    });
}

运行发现,程序逻辑上逐两个字节进行拷贝

实在是看的人头昏眼花,所以我尝试性的搜索了下5e60a3002273d85bee3b9ad0893e9c37,期待某次memcpy可以看到它,那么我们就能找到它的产生之处了。

踏破铁鞋无觅处,竟然在这里发现了!打印memcpy的str2时,我采用了Unidbg的Inspect API,它会在打印内存块时,顺带打印数据的MD5值,这个设计主要是为了比较两个内存块是否全然等值,但这里却帮到了我们。

auth 的值,竟然就是上图这个内存块的MD5结果。实在是太巧了。免得我们继续推理了。 如果样本并非使用MD5,或者我们没恰好发现memcpy这一情况,那该怎么分析?总不能每次都靠偶遇吧?那下次遇到了再说咯。逆向过程本就充满灵感和试错。

0000: 61 69 64 3D 61 6E 64 72 6F 69 64 31 26 63 6C 69    aid=android1&cli
0010: 65 6E 74 5F 73 79 73 3D 61 6E 64 72 6F 69 64 26    ent_sys=android&
0020: 74 69 6D 65 3D 31 36 33 38 34 35 32 33 33 32 76    time=1638452332v
0030: 71 34 37 48 64 39 4A 55 67 66 44 43 79 74 43       q47Hd9JUgfDCytC

vq47Hd9JUgfDCytC 这十个字节是未知的,其余三个字段是传进来的,我们结合上面的MD5会产生一种明悟,这不就是加盐MD5吗?传进来的参数拼接后加上”vq47Hd9JUgfDCytC“,MD5后传出去。

那么现在问题就变成了,vq47Hd9JUgfDCytC是哪里来的?

对0xbffff500L 做traceWrite

[16:26:36 783] Memory WRITE at 0xbffff500, data size = 1, data value = 0x76, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff501, data size = 1, data value = 0x71, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff502, data size = 1, data value = 0x34, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff503, data size = 1, data value = 0x37, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff504, data size = 1, data value = 0x48, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff505, data size = 1, data value = 0x64, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff506, data size = 1, data value = 0x39, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff507, data size = 1, data value = 0x4a, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff508, data size = 1, data value = 0x55, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff509, data size = 1, data value = 0x67, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff50a, data size = 1, data value = 0x66, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff50b, data size = 1, data value = 0x44, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff50c, data size = 1, data value = 0x43, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff50d, data size = 1, data value = 0x79, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff50e, data size = 1, data value = 0x74, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d
[16:26:36 783] Memory WRITE at 0xbffff50f, data size = 1, data value = 0x43, PC=RX@0x40008a9e[libmakeurl.so]0x8a9e, LR=unidbg@0x33ae5f8d

看一下来源0x8a9e,十六个字节都来自这里

前面我们用过Findcrypt,它自动将0x8a9e所位于的函数中,一个数组标记为AES的S逆盒。这告诉我们,十六字节的生成处是AES的运算逻辑。换而言之,这十六字节大概率是AES加密或解密的输出。

可是,样本使用了Ollvm,比如0x8a9e这一行,就是Ollvm中的指令替换。

不识庐山真面目,只缘身在此山中。我们先不要陷入函数的细节里,因为如果是标准AES,那根本不用分析加密程序的内部,自然也就不用考虑这些混淆了。

0x8a9e 位于 sub_8228 函数内,Hook sub_8228,顺利断下

观察两个参数

int __fastcall sub_8228(unsigned __int64 a1, _QWORD *a2)

参数2像是buffer,存放加密结果。blr用于在函数返回处下断点,然后c继续跑,在函数运行结束后再次查看参数2指向的内存。

跑到函数结束的地方

可以发现确实是我们要分析的十六个字节。至于参数1是什么意思,硬看似乎看不出来。

因此可以判断,sub_8228生成了我们要分析的十六个字节,而且它像AES的执行逻辑。

AES 加密还是解密?什么工作模式?明文是什么?Key是什么?一概不知。我们得到sub_8228上层去看看。

重新运行程序,bt 打印调用栈

跳到0x8ba7看看

int __fastcall sub_8B3C(char *s, int a2, int a3)
{
  signed int i; // r0
  unsigned __int64 v7; // r0
  int v8; // r1
  int v9; // r1
  signed int v11; // [sp+4h] [bp-144h]
  signed int v12; // [sp+Ch] [bp-13Ch]
  char v13[280]; // [sp+10h] [bp-138h] BYREF
  int v14; // [sp+128h] [bp-20h]

  v11 = (strlen(s) + 15) >> 4;
  sub_72BC(v13, a2, 128);
  for ( i = 0; ; i = v12 + 1 )
  {
    v8 = 1590846758;
    while ( 1 )
    {
      v9 = v8 & 0x7FFFFFFF;
      if ( v9 != 1590846758 )
        break;
      v12 = i;
      v8 = 131555431;
      if ( i < v11 )
        v8 = 1574041125;
    }
    if ( v9 == 131555431 )
      break;
    if ( v9 != 1574041125 )
    {
      while ( 1 )
        ;
    }
    HIDWORD(v7) = a3 + 16 * v12;
    LODWORD(v7) = v13;
    sub_8228(v7, &s[16 * v12]);
  }
  return _stack_chk_guard - v14;
}

我一眼就认出 sub_72BC 是密钥编排函数,可能有读者觉得我是先做了具体分析,然后从答案推过程了。其实不是的。我做这个判断,有重要依据和线索。

首先我们知道,sub_8228是AES的具体运算程序,刚才Hook确认了这一点。而密钥编排一般发生在具体运算前面,即早于sub_8228。整个函数体内,就只有sub_72BC 一个函数了,有读者会说,那也可能在sub_8B3C外层啊,完全没说服力。更重要的线索是它的参数3,128。AES 存在128/192/256 三种密钥的规格,这里就是在指定AES的规格,并生成对应的轮密钥。char v13[280]; v13是一个较大的数组,用于存放生成轮密钥的结果。那么sub_8B3C的a2就是十六字节长的AES-128密钥。进而参数1就是十六字节的输入。

加密过程对的上吗?

from Crypto.Cipher import AES

key = "30292827262524232221000000000000"

key = bytes.fromhex(key)
cipher = AES.new(key, AES.MODE_ECB)
res = cipher.encrypt(bytes.fromhex("A7488462036F15054005472D6F487C67"))
print(res.hex())

结果并不一致,再试试解密过程

from Crypto.Cipher import AES

key = "30292827262524232221000000000000"

key = bytes.fromhex(key)
cipher = AES.new(key, AES.MODE_ECB)
res = cipher.decrypt(bytes.fromhex("A7488462036F15054005472D6F487C67"))
print(res.hex())

结果为 767134374864394a5567664443797443,正是我们在追的那十六个字节。

为什么不用CyberChef?因为它默认且强制填充,很烦人。举个例子

我们输入了一个字节,它会首先填充成分组长度,运算出一个分组的密文结果。在一般情况下这是合理的,但我们目前分析的这个函数,明显是不考虑填充的,是一个纯粹的AES加密或解密过程,输入十六字节,输出十六字节。

那么如果我们在CyberChef中输入十六字节,它会自动按照PKCS7约定,再次填充一个分组的长度,输出也是两个分组的结果。

如果是验证加密,我们少看一个分组的密文结果即可。而在解密时,问题就很大。

比如此处的解密,就会解密失败。因为在得到十六进制解密结果767134374864394a5567664443797443后,它默认这个明文结果是”明文+填充“的组合,Cyberchef 试图解析并去除填充部分,进而解密失败。

所以此处我们用Python类库验证。

我们回顾一下AES,如果对AES没很好的了解,可以看我的《白盒加密》系列文章中的DFA攻击原理一文,第二节有较为详细的AES原理阐述,这里不做多讲,下面的讨论默认大家看过相关内容

我们这里做一个讨论,如何从一个小的线索点,分析出AES的全貌。这对于OLLVM混淆大行其道的今天,其实很有意义。

sub_72BC(v13, a2, 128);为例,我们猜测它是密钥编排函数,那么如何快速验证呢?

我Hook 入参时a2指向的十六字节,以及函数结束后v13指向的176字节(因为是AES-128,所以轮密钥是4*44)。

public void hook72bc(){
    emulator.attach().addBreakPoint(module.base + 0x72bc, new BreakPointCallback() {
        UnidbgPointer v13;
        @Override
        public boolean onHit(Emulator<?> emulator, long address) {
            RegisterContext registerContext = emulator.getContext();
            UnidbgPointer a2 = registerContext.getPointerArg(1);
            v13 = registerContext.getPointerArg(0);
            Inspector.inspect(a2.getByteArray(0, 0x10), "Key "+a2.toString());
            emulator.attach().addBreakPoint(registerContext.getLRPointer().peer, new BreakPointCallback() {
                @Override
                public boolean onHit(Emulator<?> emulator, long address) {
                    Inspector.inspect(v13.getByteArray(0, 176), "Round Key "+v13.toString());
                    return false;
                }
            });
            return true;
        }
    });
}

结果和预料有偏差,但又没偏太多。

>-----------------------------------------------------------------------------<
[11:05:24 117]Key unidbg@0xbffff3d8, md5=037ff8eefc91404afaed9fa22e282e3f, hex=30292827262524232221000000000000
size: 16
0000: 30 29 28 27 26 25 24 23 22 21 00 00 00 00 00 00    0)('&%$#"!......
^-----------------------------------------------------------------------------^

>-----------------------------------------------------------------------------<
[11:05:24 121]Round Key unidbg@0xbffff290, md5=159aaceb29acbcbd8d87b091ce103546, 
size: 176
0000: 0A 00 00 00 98 F2 FF BF 30 29 28 27 26 25 24 23    ........0)('&%$#
0010: 22 21 00 00 00 00 00 00 52 4A 4B 44 74 6F 6F 67    "!......RJKDtoog
0020: 56 4E 6F 67 56 4E 6F 67 7F E2 CE F5 0B 8D A1 92    VNogVNog........
0030: 5D C3 CE F5 0B 8D A1 92 26 D0 81 DE 2D 5D 20 4C    ].......&...-] L
0040: 70 9E EE B9 7B 13 4F 2B 53 54 70 FF 7E 09 50 B3    p...{.O+STp.~.P.
0050: 0E 97 BE 0A 75 84 F1 21 1C F5 8D 62 62 FC DD D1    ....u..!...bb...
0060: 6C 6B 63 DB 19 EF 92 FA E3 BA A0 B6 81 46 7D 67    lkc..........F}g
0070: ED 2D 1E BC F4 C2 8C 46 86 DE FA 09 07 98 87 6E    .-.....F.......n
0080: EA B5 99 D2 1E 77 15 94 F3 87 D8 7B F4 1F 5F 15    .....w.....{.._.
0090: 1E AA C6 C7 00 DD D3 53 29 E1 35 18 DD FE 6A 0D    .......S).5...j.
00A0: C3 54 AC CA C3 89 7F 99 B8 33 DB 36 65 CD B1 3B    .T.......3.6e..;
^-----------------------------------------------------------------------------^

RoundKey 的结果像是一个结构体,两个int组成,第一个是0x0000000a,即代表了AES-128的十轮运算,第二个是指针,值为0xbffff298,是v13往后偏移八个字节。

我们不妨修改一下hook72bc,看一下0xbffff298具体打印什么

    public void hook72bc(){
        emulator.attach().addBreakPoint(module.base + 0x72bc, new BreakPointCallback() {
            UnidbgPointer v13;
            @Override
            public boolean onHit(Emulator<?> emulator, long address) {
                RegisterContext registerContext = emulator.getContext();
                UnidbgPointer a2 = registerContext.getPointerArg(1);
                v13 = registerContext.getPointerArg(0);
                Inspector.inspect(a2.getByteArray(0, 0x10), "Key "+a2.toString());
                emulator.attach().addBreakPoint(registerContext.getLRPointer().peer, new BreakPointCallback() {
                    @Override
                    public boolean onHit(Emulator<?> emulator, long address) {
//                        Inspector.inspect(v13.getByteArray(0, 176), "Round Key "+v13.toString());
                        Inspector.inspect(v13.getByteArray(8, 176), "Round Key "+v13.toString());
                        return true;
                    }
                });
                return true;
            }
        });
    }

结果如下

>-----------------------------------------------------------------------------<
[11:27:59 989]Key unidbg@0xbffff3d8, md5=037ff8eefc91404afaed9fa22e282e3f, hex=30292827262524232221000000000000
size: 16
0000: 30 29 28 27 26 25 24 23 22 21 00 00 00 00 00 00    0)('&%$#"!......
^-----------------------------------------------------------------------------^

>-----------------------------------------------------------------------------<
[11:27:59 992]Round Key unidbg@0xbffff290, md5=1a32868f8f948e426e209b5995588178
size: 176
0000: 30 29 28 27 26 25 24 23 22 21 00 00 00 00 00 00    0)('&%$#"!......
0010: 52 4A 4B 44 74 6F 6F 67 56 4E 6F 67 56 4E 6F 67    RJKDtoogVNogVNog
0020: 7F E2 CE F5 0B 8D A1 92 5D C3 CE F5 0B 8D A1 92    ........].......
0030: 26 D0 81 DE 2D 5D 20 4C 70 9E EE B9 7B 13 4F 2B    &...-] Lp...{.O+
0040: 53 54 70 FF 7E 09 50 B3 0E 97 BE 0A 75 84 F1 21    STp.~.P.....u..!
0050: 1C F5 8D 62 62 FC DD D1 6C 6B 63 DB 19 EF 92 FA    ...bb...lkc.....
0060: E3 BA A0 B6 81 46 7D 67 ED 2D 1E BC F4 C2 8C 46    .....F}g.-.....F
0070: 86 DE FA 09 07 98 87 6E EA B5 99 D2 1E 77 15 94    .......n.....w..
0080: F3 87 D8 7B F4 1F 5F 15 1E AA C6 C7 00 DD D3 53    ...{.._........S
0090: 29 E1 35 18 DD FE 6A 0D C3 54 AC CA C3 89 7F 99    ).5...j..T......
00A0: B8 33 DB 36 65 CD B1 3B A6 99 1D F1 65 10 62 68    .3.6e..;....e.bh
^-----------------------------------------------------------------------------^

首先我们就可以确定,这就是密钥编排的结果,这是我们根据AES-128的编排性质推断出来的。

  • 轮密钥的前十六个字节就是主密钥,完全符合
  • 十六个字节后面的编排规则,以行为单位看的话,前四个字节较为复杂,后十二字节只是简单异或。如下验证

C:\Users\13352>python
Python 3.7.6 (tags/v3.7.6:43364a7ae0, Dec 19 2019, 00:42:30) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> hex(0x26 ^ 0x52)
'0x74'
>>> hex(0x25 ^ 0x4a)
'0x6f'
>>>

确实符合编排的规律。

因此可以认定 72bc 就是密钥编排函数,并确定了密钥。怎么仅从这个线索,推出输入呢?

如果是加密,那么对K0做traceRead可以定位到算法的输入,对K10做traceRead,其运算结果就是算法的输出。

如果是解密,那么对K0做traceRead可以定位算法的输出,对K10做traceRead,其运算结果就是算法的输入。

换个情况,如果只知道算法的输入,该怎么确认密钥呢?

如果是加密,那么对算法的输入做traceRead,可以定位到K0,在AES-128上意味着主密钥;如果是CBC模式,那么定位到IV。

如果是解密,那么对算法的输入做traceRead,可以定位到K10,使用stark 逆推主密钥。

再换个情况,如果只知道算法的输出,该怎么确认其他要素?

如果是加密过程,对算法的输出做traceWrite,运算的双方中有一方是K10。

如果是解密过程,对算法的输出做traceWrite,运算的双方中有一方是K0。

在重度OLLVM的情况中,上述AES规律可以帮助快速还原各种关键要素。

下面考虑Key和密文哪里来的

traceWrite发现都位于 sub_A298

int __fastcall sub_A298(void *a1)
{
  int v1; // r9
  int v3; // r11
  int i; // r0
  int v6; // [sp+0h] [bp-78h]
  int v7; // [sp+4h] [bp-74h]
  _QWORD v8[2]; // [sp+8h] [bp-70h] BYREF
  char v9[20]; // [sp+18h] [bp-60h] BYREF
  char s[8]; // [sp+30h] [bp-48h] BYREF
  char v11[28]; // [sp+38h] [bp-40h] BYREF
  int v12; // [sp+58h] [bp-20h]

  v3 = 0;
  *(_QWORD *)s = unk_45688;
  strcpy(v11, "`%g\rOh\\G                ");
  v8[0] = unk_45CF0;
  v8[1] = unk_45CF8;
  strcpy(v9, "                ");
  memset(a1, 0, 0x100u);
  for ( i = 1282341844; ; i = 1282341844 )
  {
    while ( i != 967467364 )
    {
      if ( i == 1282341844 )
      {
        v6 = v3;
        i = 1618205161;
        if ( v3 < 32 )
          i = -1314423687;
        if ( i <= 967467363 )
          goto LABEL_15;
      }
      else
      {
        v1 = 0;
LABEL_14:
        i = 967467364;
      }
    }
    v7 = v1;
    i = -688078044;
    if ( v1 < 32 )
      i = -1194610101;
LABEL_15:
    if ( i != -1314423687 )
      break;
    *((_BYTE *)v8 + v6) = (*((_BYTE *)v8 + v6) & 0x8E | ~*((_BYTE *)v8 + v6) & 0x71) ^ 0x51;
    v3 = v6 + 1;
  }
  if ( i == -1194610101 )
  {
    s[v7] = (~s[v7] & 0xE9 | s[v7] & 0x16) ^ 0xC9;
    v1 = v7 + 1;
    goto LABEL_14;
  }
  sub_8B3C(s, (int)v8, (int)a1);
  return _stack_chk_guard - v12;
}

其中s

.rodata:00045688 unk_45688       DCB 0x87                ; DATA XREF: sub_A298+E↑o
.rodata:00045688                                         ; sub_A298+18↑o ...
.rodata:00045689                 DCB 0x68 ; h
.rodata:0004568A                 DCB 0xA4
.rodata:0004568B                 DCB 0x42 ; B
.rodata:0004568C                 DCB 0x23 ; #
.rodata:0004568D                 DCB 0x4F ; O
.rodata:0004568E                 DCB 0x35 ; 5
.rodata:0004568F                 DCB 0x25 ; %
.rodata:00045690                 DCB 0x60 ; `
.rodata:00045691                 DCB 0x25 ; %
.rodata:00045692                 DCB 0x67 ; g
.rodata:00045693                 DCB  0xD
.rodata:00045694                 DCB 0x4F ; O
.rodata:00045695                 DCB 0x68 ; h
.rodata:00045696                 DCB 0x5C ; \
.rodata:00045697                 DCB 0x47 ; G

在逐字节经过如下处理后成为我们的密文

s[v7] = (~s[v7] & 0xE9 | s[v7] & 0x16) ^ 0xC9;

而密钥,前八个字节来自0x45CF0,后八个字节来自0x45CF8。因为这两个八字节是紧连着的,所以可以一并看。

  v8[0] = unk_45CF0;
  v8[1] = unk_45CF8;

如下

.rodata:00045CF0 unk_45CF0       DCB 0x10                ; DATA XREF: sub_A298+24↑o
.rodata:00045CF0                                         ; sub_A298+2C↑o ...
.rodata:00045CF1                 DCB    9
.rodata:00045CF2                 DCB    8
.rodata:00045CF3                 DCB    7
.rodata:00045CF4                 DCB    6
.rodata:00045CF5                 DCB    5
.rodata:00045CF6                 DCB    4
.rodata:00045CF7                 DCB    3
.rodata:00045CF8 unk_45CF8       DCB    2
.rodata:00045CF9                 DCB    1
.rodata:00045CFA                 DCB 0x20
.rodata:00045CFB                 DCB 0x20
.rodata:00045CFC                 DCB 0x20
.rodata:00045CFD                 DCB 0x20
.rodata:00045CFE                 DCB 0x20
.rodata:00045CFF                 DCB 0x20

它经过了如下逐字节的处理

*((_BYTE *)v8 + v6) = (*((_BYTE *)v8 + v6) & 0x8E | ~*((_BYTE *)v8 + v6) & 0x71) ^ 0x51;

看起来有些云里雾里的,这是Ollvm中指令替换的功劳。

真正功能上而言,只是SO中硬编码的两串十六进制字节,在异或0x20后,就成为了密文和Key,在运行时AES解密出明文,作为MD5的盐。

我们以Key为例,它的完整流程如下(下面均为十六进制字节)

首先,Key是 30 29 28 27 26 25 24 23 22 21 00 00 00 00 00 00,开发者不希望硬编码在SO里,所以先将它异或0x20,在SO中硬编码即 10 09 08 07 06 05 04 03 02 01 20 20 20 20 20 20。

然后在使用时,将这么一串异或0x20,因为异或两次等于自身,所以Key重新变成30 29 28 27 26 25 24 23 22 21 00 00 00 00 00 00,正常参与运算。

那么下面这两种运算,其功能都等价于单字节异或0x20,怎么变成这个样子了呢?

s[v7] = (~s[v7] & 0xE9 | s[v7] & 0x16) ^ 0xC9;
*((_BYTE *)v8 + v6) = (*((_BYTE *)v8 + v6) & 0x8E | ~*((_BYTE *)v8 + v6) & 0x71) ^ 0x51;

这就是指令替换的目的,将简单的加减乘除、异或、与等运算,替换成等价但更复杂的指令序列。

演示一下这个过程

S = A ^ B

异或0不影响结果

S = A ^ B ^ 0

0可以展开成C ^ C

S = A ^ B ^ C ^ C

做一下简单的分配

S = (A^C)^(B^C)

两数异或时可以等价替换如下,读者可以自行验证。

a ^ b => (~a & b) | (a & ~b)

那么S = (~A & C) | (A & ~C) ^ (~B & C) | (B & ~C)

回到 S = A ^ B,假设A 就是我们的待处理数据,B是0x20,即将数据和0x20异或,我们再选择C为0xE9

S = (~A & 0xE9) | (A & ~0xE9) ^ (~0x20 & 0xE9) | (0x20 & ~0xE9)

0xE9 在取反后即 0x16,而异或的另外一方,因为不存在未知数,编译器会直接优化计算出结果

S = (~A & 0xE9) | (A & 0x16) ^ 0xC9

A 代入 s[v7] 不就是 s[v7] = (~s[v7] & 0xE9 | s[v7] & 0x16) ^ 0xC9; 吗?

A 代入 *((_BYTE *)v8 + v6),C 为0x71时,就是 另一个式子。

本质上,两者都是逐字节与0x20异或。

四、尾声

做个总结,函数功能是加盐的MD5,盐来自AES解密的结果,本文讨论了AES在混淆情况下的处理以及ollvm中指令替换的基本内容。

暂无评论
本文作者:
本文链接: https://www.qinless.com/?p=1634
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 qinless 的博客!
100

发表评论

返回顶部