一次借助 GDB 逆向工程的实践(续)

1 前言

没想到这篇文章还会有后续。

自上次解密完一个 html 文件后,“想要更多” 的需求就被提了出来。

回想前一篇文章的操作步骤,如果针对每个 html 都如此操作,还是挺费时间的。

那么有没有什么快捷的方法呢?—— 当然是有的,于是有了这篇文章。

2 思路

前一篇文章抓取密钥的方法,通过 GDB 操作浏览器进程,强行开启特定函数库的日志,并把输出重定向到文件。

那么如果我直接写个程序,模拟浏览器加载文件的步骤,并开启日志,然后把日志输出到 STDOUT 不就行了?

为实现这个目标,需要如下前置条件:

  1. 能编译出可以在目标设备运行的程序的工具链。
  2. 找到目标浏览器加载文件的关键函数。

第一点已经具备,因为在前一篇文章中已经用工具链编译了 gdbserver,并成功运行在目标设备上。

第二点也通过前一次的下断点,找到了关键函数和所在 so 库:

也就是 ***_OpenDrmFile 这个函数。

那么接下来就是编写一个程序,依次调用启用日志的函数和加载文件的函数即可。

3 实现

3.1 分析函数参数

现在只知道函数的名字,并不知道它的调用参数,因此先得用 Cutter 分析一波:

可以看到,Cutter 判断这个函数只有两个参数。

为了保险起见,自己再对目标函数做一轮分析,验证一下参数数量。

推理过程如下:

  1. 目标程序为 ARM 32 位程序,会通过寄存器 r0 ~ r3 传递前 4 个参数。
  2. 0x000053a6 这个位置,r2 的寄存器的值被修改(这是为了 0x000053ac 这处调用设置参数)。
  3. r2 寄存器被修改之前,发生过三次调用:0x000053820x000053900x00005390
  4. 通过跟进前面三次调用的代码,并未发现读取 r2r3 寄存器的值。

因此可以得出结论:r2r3 寄存器的值并未被此函数使用,它只使用来自 r0r1 寄存器的两个参数。


知道参数的数量后,还需要知道这两个参数的意义,这样才能传入正确的值。

分析参数意义,主要通过以下两条路:

  1. 反编译调用函数的代码,看参数的赋值;
  2. 反编译函数和下层函数的代码,看参数的实际作用;

略过繁琐的分析,最终得到的结论如下:

  1. 参数 1 为一个可写指针,***_OpenDrmFile 会把一个数据写到其中。
  2. 参数 2 为一个字符串,应该是文件路径;

3.2 验证函数参数

最后再通过 GDB,对上述分析做一个佐证。

连接到 GDB server 后,对此函数下断点:

1
2
3
(gdb) handle all noprint nostop
(gdb) b ***_OpenDrmFile
(gdb) c

然后打开目标文件,等待断点被唤醒,执行 info r 查看寄存器,然后分别查看 r0r1 寄存器的值:

从 GDB 得到的信息来看,对 r1 的推测完全正确,r0 极大可能是一个 4 字节的缓冲区。

3.3 模拟程序 V1

模拟程序只需要做两件事情:

  1. 调用 ***_EnableLog 开启日志。
  2. 调用 ***_OpenDrmFile 读取加密的文件。

这里可以根据前面推测的函数原型,编写头文件,然后通过静态连接方式调用这个函数。或者用 dlsym 找到函数地址,直接调用。

由于后者更容易一些,这里就采用这个方案。

直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <cstdint>
#include <dlfcn.h>
#include <iostream>

typedef void (* EnableLogFunc)(int);
typedef int32_t (* OpenFileFunc) (int32_t *, const char *);

static const char FILE_LIB1[] = "/usr/lib/lib***_agent.so.1";
static const char FILE_LIB2[] = "/usr/lib/lib**_appdrm.so.1";

void printUsage(const char * prog_name) {
std::cout << "Usage: " << prog_name
<< " <target-html-file>" << std::endl;
}

void loadFile(const char * html_file) {
// Load so and find function addresses
auto * lib1 = dlopen(FILE_LIB1, RTLD_LAZY);
auto func_enable_log = (EnableLogFunc) dlsym(lib1, "***_EnableLog");
auto * lib2 = dlopen(FILE_LIB2, RTLD_LAZY);
auto func_open_file = (OpenFileFunc) dlsym(lib1, "***_OpenDrmFile");

// Enable log
func_enable_log(1);
// Open file
int32_t output;
func_open_file(&output, html_file);

// Close libraries
dlclose(lib1);
dlclose(lib2);
}

int main(int argc, char * argv[]) {
if(argc < 2) {
printUsage(argv[0]);
} else {
loadFile(argv[1]);
}
return 0;
}

编译后在目标设备上运行,虽然输出了一部分日志,但是接下来就提示了 Segmentation fault 并退出了。

3.4 模拟程序 V2

先用 GDB server 调试了一下模拟程序,发现错误出在 ***_OpenDrmFile 函数内部,具体原因是往空指针写东西。

重新使用 Cutter 反编译目标库,发现它导出了 ***_Initialize 函数,并在此函数内初始化了对应的内存,也就是说还要先调用这个初始化函数。

修改后的程序片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef void    (* Initialize)();

void loadFile(const char * html_file) {
// Load so and find function addresses
auto * lib1 = dlopen(FILE_LIB1, RTLD_LAZY);
auto func_enable_log = (EnableLogFunc) dlsym(lib1, "***_EnableLog");
auto * lib2 = dlopen(FILE_LIB2, RTLD_LAZY);
auto func_open_file = (OpenFileFunc) dlsym(lib1, "***_OpenDrmFile");
auto func_initialize = (Initialize) dlsym(lib1, "***_Initialize");

// Initialize
func_initialize();
// Enable log
func_enable_log(1);
// Open file
int32_t output;
func_open_file(&output, html_file);

// Close libraries
dlclose(lib1);
dlclose(lib2);
}

再次编译后运行,终于拿到了想要的输出,而且程序也不会崩溃了。

3.5 模拟程序 V3

既然已经能够完全模拟加载加密文件的过程,不如一步到位,直接读取解密后的内容。

目标库还有个 ***_ReadDrmFile 函数,看起来就是读文件内容的。

使用前面的手段分析出它的参数后,最终写成了完全脱离浏览器的解密程序。

这部分没有用到什么新鲜的手段,就不再赘述了,留给读者探索吧。

4 参考资料