【技术分享】 Android SO 高阶黑盒利用

http://p2.qhimg.com/t01e16e13995dcbb879.jpg

作者:小无名

预估稿费:500RMB

投稿方式:发送邮件至linwei#360.cn,或登陆网页版在线投稿

前言

Android 的开发者喜欢将一些核心认证算法写在SO中,以此来增加黑客利用其业务的难度。比起DEX文件,SO确实有不少优势。

优势一:SO中为原生ARM汇编,难以还原原始代码。DEX文件很容易被各种反编译工具直接还原成通俗易懂的Java代码。

优势二:SO调试成本高,而Java写的程序更容易被调试,如使用SmaliIdea、Jeb、IDA、Xposed、插桩打日志等多种方式。

优势三:SO难以在x86生产环境中黑盒调用,而DEX文件可转换成class文件,在生产环境中使用JNI直接传参调用。

综上所述,如果APP的核心算法采用C/C++编写并编译为SO文件,那么可以稍微增加点难度。但是这种难度对于经验丰富的逆向人员来说,仅仅是给他们增加点生活费。

实际上,SO还有各种保护,比如反调试、区块加密、OLLVM混淆、ARM VMP。OLLVM混淆是逆向人员的噩梦,这招确实能有效提高SO代码的安全性。ARM VMP 兼容性问题比较多,还无法商业化。

OLLVM混淆是噩梦,是本文的重点,那么我们要怎么利用被OLLVM混淆过的代码呢? 笔者将介绍一种无需逆向并且能在x86生产环境高效利用SO的小窍门。我以前就不断的思考,是否可以像调用jar文件那样调用ARM的SO文件呢?

众所周知,我们的目标SO一般有且只有ARM指令集的SO文件,如果有x86的SO文件,是可以直接黑盒利用。如果只有ARM指令集,可以采用本文介绍的方式进行黑盒利用或者采用逆向的方法直接还原源代码。很显然,对于Ollvm混淆过的程序来说,还原源代码是相当困难的。

如何调用ARM版本的SO文件?SO文件就是一个黑盒子,我们无须知道其内部原理~


unicorn引擎

unicorn引擎是一款跨平台跨语言的CPU仿真库,支持ARM,ARM64….所以我采用这个库来调用难以逆向的SO文件。

unicorn git地址:https://github.com/unicorn-engine/unicorn 

unicorn库是支持Windows编译的,我是在Mac系统下完成的编译。

编译unicorn很简单,在unicorn根目录下执行./make.sh 即可,不是直接make。

默认情况下,make.sh脚本编译的是动态库,如果需要静态库的小伙伴可以在config.mk修改UNICORN_STATIC属性为YES.

unicorn学习方法?unicorn开发者并不喜欢写太多的开发文档!所以你在README.md中可能仅仅只能看到如何编译Unicorn,至于unicorn提供了哪些API、宏都是没有说明的。实际上最好的开发文档常在unicorn.h头文件中,另外demo也很丰富。

有了unicorn,我们就有了一颗完全可以操控的虚拟ARM CPU,可以通过API实现寄存器读写、流程控制、内存映射、接管中断等等,完全可以将SO的代码加载到unicorn中去运行。

我们通过一段代码学习unicorn基本使用方法。

#define THUMB_CODE "x83xb0" // sub sp, #0xc

static void test_thumb(void)
{
    uc_engine *uc;
    uc_err err;
    uc_hook trace1, trace2;
    int sp = 0x1234;     // R0 register
    printf("Emulate THUMB coden");
    // 初始化模拟器为Thmub模式
    err = uc_open(UC_ARCH_ARM, UC_MODE_THUMB, &uc);
    if (err) {
        printf("Failed on uc_open() with error returned: %u (%s)n",
                err, uc_strerror(err));
        return;
    }
    // 给模拟器映射2M内存,UC_PROT_ALL为所有内存权限(读写执行)
    uc_mem_map(uc, ADDRESS, 2 * 1024 * 1024, UC_PROT_ALL);
    // 填充机器码到虚拟内存中。
    uc_mem_write(uc, ADDRESS, THUMB_CODE, sizeof(THUMB_CODE) - 1);
    // 初始化寄存器
    uc_reg_write(uc, UC_ARM_REG_SP, &sp);
    // 设置回调函数
    uc_hook_add(uc, &trace1, UC_HOOK_BLOCK, hook_block, NULL, 1, 0);

    //设置一个指令执行回调用,该回调函数会在指令执行前被调用
    uc_hook_add(uc, &trace2, UC_HOOK_CODE, hook_code, NULL, ADDRESS, ADDRESS);

   
    // 开始运行虚拟CPU,因为是Thumb模式,所以地址的最低位需要置位。
    err = uc_emu_start(uc, ADDRESS | 1, ADDRESS + sizeof(THUMB_CODE) -1, 0, 0);
    if (err) {
        printf("Failed on uc_emu_start() with error returned: %unerror:%sn", err,uc_strerror(err));
    }
    // 输出结果寄存器
    printf(">>> Emulation done. Below is the CPU contextn");
    uc_reg_read(uc, UC_ARM_REG_SP, &sp);
    printf(">>> SP = 0x%xn", sp);
    uc_close(uc);
}

上述代码已经非常详细介绍了unicorn大概使用方法,具体信息可以参考unicorn.h 中的注释。从代码可以看出,unicorn的控制粒度非常细,灵活的API可以让我们掌控虚拟机的各种信息,特别是Unicorn的Hook功能(类似于异常处理机制,可以借助这个实现断点之类的)。

Unicorn 与 黑盒利用的关系

Unicorn 是一款很轻便的CPU虚拟库,我将使用Unicorn来运行我难以逆向的算法。

黑盒调用需要解决哪些问题?

1、SO装载

2、内存映射

3、栈分配

4、API调用

5、返回值读取


虚拟内存与真实内存

Unicorn 基于qemu,所以拥有完善的内存管理机制。 虚拟机内部内存和外部内存是完全隔离开的,也就是说虚拟机内访问0x400不等于外部地址0x400。虚拟机中的内存最初情况是一片空白,需用调用uc_mem_map映射内存。

映射内存之后就需要通过uc_mem_write向虚拟机内存中写入真实数据(比如代码、参数等)。

注意:uc_mem_map的大小和基地址都需要4kb对齐,否者会映射失败。

SO的装载

我们需要写一个Loader来加载要黑盒利用的SO吗?完全没有必要!最简单的方法是将SO文件直接载入到内存中,然后在虚拟机中偏移为0处,映射一段能装下这个SO文件的内存,最后将SO的数据拷贝到虚拟机内存。

这样做有什么好处呢?我们能直接利用IDA中的函数地址了!

如果想用Unicorn运行一个函数,那么一定要解决的问题就是栈。解决方案是调用uc_mem_map映射一段固定内存地址作为运行栈。映射之后再将地址通过uc_reg_write写到SP寄存器中。

参考代码如下:

uint32_t sp = 0x10000; // 分配Stack
    uint32_t sp_start = sp + 0x200;
    uc_mem_map(uc,sp,sp_start - sp,UC_PROT_ALL);
    uc_reg_write(uc,UC_ARM_REG_SP,&sp);

有了栈才能保证SO的正常运行。

Unicorn 的回调机制

Unicorn 强大在于它拥有粒度极细的回调机制,大概有内存访问前、后、访问异常、代码异常、代码执行等等回调,所以功能比目前Linux or Windows的调试API还完善,通过Unicorn完完全全是控制CPU。

添加回调的函数是:uc_hook_add

不同类型的回调有不同类型的回调声明,具体可以参考unicorn.h 这个头文件。

本文主要介绍指令执行回调(类似于断点功能),该回调会在指令执行前调用,我一般用来打印关键点上下文信息接管API调用

uc_hook trace1,trace2;
uc_hook_add(uc, &trace2, UC_HOOK_CODE, (void *)hook_code, NULL,0, function_start + function_size);
static void hook_code(uc_engine *uc, uint64_t address, uint32_t size, void *user_data)
{//address 为当前执行位置、size为指令长度,user——data可忽略
  switch(address)
  {
    case 0x1234:    // process...    break;
    case 0x1223:    break:
    default:
    xxxxxx;
  }
    set_breakpoint(uc,0x83f4); //如果address地址是83f4就输出寄存器信息
    set_breakpoint(uc,0x853a);
}
//////////////////////////////////////////////
static void set_breakpoint(uc_engine *uc,uint address)
{
    uint pc;
    uc_reg_read(uc,UC_ARM_REG_PC,&pc);    if(pc == address)
    {        printf("========================n");        printf("Break on 0x%xn",pc);
        uint values;
        uc_reg_read(uc,UC_ARM_REG_R0,&values);        printf("R0 = 0x%x n",values);
        uc_reg_read(uc,UC_ARM_REG_R1,&values);        printf("R1 = 0x%x n",values);
        uc_reg_read(uc,UC_ARM_REG_R2,&values);        printf("R2 = 0x%x n",values);
        uc_reg_read(uc,UC_ARM_REG_R3,&values);        printf("R3 = 0x%x n",values);
        uc_reg_read(uc,UC_ARM_REG_R4,&values);        printf("R4 = 0x%x n",values);
        uc_reg_read(uc,UC_ARM_REG_R5,&values);        printf("R5 = 0x%x n",values);        printf("========================n");
    }
}

上述代码看出,通过在每条指令上设置执行回调来监控状态,还能通过检查CPU是否执行到我感兴趣的地址(断点地址)来输出寄存器信息。其中的switch address是做什么用的呢?这个主要是用于执行到bl 或 blx到其它动态库代码时接管处理。

接管API调用

如果我们想运行的程序会调用不属于这个模块的代码的情况怎么处理呢?比如调用JNI的函数或者libc的函数,怎么办呢?

完全虚拟一个这样的环境当然没问题,但是不划算。所以我采用HOOK的方式实现函数调用。我一般会提前确认目标函数调用了哪些外部的函数,做一个统计。一般外部的函数都是系统库的函数,在网上能查到其用法。

在上一段代码中已经提到,switch address是用来处理跨库调用的,address一般为bl / blx 或plt中函数桥接的第一行地址。这样我们能及时拦截到函数执行时,控制权交还外部回调代码,在回调中根据不同函数实现不同功能。

比如我要处理JNI中GetStringUTFCharts这个函数,那么我可能需要加一个断点在blx r3这个位置,当程序执行到这里时,会陷入回调。回调中根据address分流处理。比如这个函数就是转换字符串,那么我们就模拟完成这个操作,按效果来说,在vm中分配一段内存,然后写入字符串,最后修改R0和PC寄存器即可。

防范建议

避免使用纯算法,增加核心算法的上下文依赖,防止黑盒调用。

(完)