【CVE分析】CVE-2018-20250 WinRAR目录穿越漏洞

WinRAR 目录穿越漏洞

漏洞详情

该漏洞是由于 WinRAR 所使用的一个陈旧的动态链接库 UNACEV2.dll 所造成的,该动态链接库在 2006 年被编译,没有任何的基础保护机制 ( ASLR,DEP 等)。该动态链接库的作用是处理 ACE 格式文件。而在解压处理过程中存在一处目录穿越漏洞,允许解压过程写入文件至开机启动项,导致代码执行。

简单来说,在早期,存在一种压缩文件格式,称为 Ace 格式。unacev2.dll 提供了对 ace 格式的压缩文件进行解压缩的接口,但是由于过于陈旧,一直都没有更新。安全分析人员通过 fuzz 技术发现了

漏洞复现

复现环境

操作系统:Windows XP SP3

软件版本:WinRAR 5.50(32 位)

EXP:

复现流程

从 github 下载 exp 目录如下:

image-20210809204713238

exp.py 中:

1
2
3
4
...
# The decompression path you want, such shown below
target_filename = r"C:\C:C:../AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\hi.exe"
...

指定了将目录中的 calc.exe 复制到指定目录下,重命名为 hi.exe。

执行 exp.py,生成 test.rar 如下:

image-20210809205055192

在 Windows XP 中打开 test.rar 如下:

image-20210809205449633

可以看到,有一项不正常的内容。解压这个 rar(实际上是 ace 格式)之后,hi.exe 将被解压到某个目录:(因为用的是 XP 系统,所以 AppData 及后续目录不是正确的启动项。但是无所谓,这已经足以表明解压的时候在异常的位置创建了文件,并可能在启动时运行。)

image-20210809205816269

漏洞分析

根据漏洞提交团队的博客来看,他们是使用了 fuzz 来挖掘出该漏洞的。所以后面我也想尝试使用 winafl 来测试。使用 winafl fuzz 需要编写一个 harness,可以参考 【模糊测试】如何编写一个Harness? 。总而言之编写了一个 harness:

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
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>

#include "STRUCS.H"
#include "UNACEFNC.H"

#define NOP(x) \
int _nop##x(){ \
printf("==>nop%d called\n", x ); \
return (int)x; \
} \
int (*nop##x)(void) = _nop##x;

NOP(0)
NOP(1)
NOP(2)
NOP(3)
HMODULE module;
char unknowstr1[1024];
char unknowstr2[1024];
char passwd[1024];
char comment[0x2000];

int (*ACEInitDll_addr)(pACEInitDllStruc) = NULL;
int (*ACEExtract_addr)(LPSTR, pACEExtractStruc) = NULL;

void fuzzfunction(char *rarpath, char *extractpath);
BOOL isEmptyDirectory(const char * dir);
wchar_t* charToWChar(const char* text);

int main(int argc, char **argv)
{
if (argc < 2) {
printf("Usage: %s <ace-format-file>\n", argv[0]);
return 1;
}
wchar_t* PathName = charToWChar(argv[1]);

module = LoadLibrary("unacev2.dll");
if (module == NULL)
{
//printf("load unacev2.dll failed\n");
return -1;
}

ACEInitDll_addr = (int (*)(pACEInitDllStruc))GetProcAddress(module, "ACEInitDll");
ACEExtract_addr = (int (*)(LPSTR, pACEExtractStruc))GetProcAddress(module, "ACEExtract");

//printf("%p, %p\n", ACEInitDll_addr, ACEExtract_addr);
char filename[100] = { 0, };
_splitpath(argv[1], NULL, NULL, filename, NULL);
char extractpath[1024];
sprintf(extractpath, "C:\\tmp\\%s", filename);
puts(extractpath);
int i = CreateDirectory(extractpath, NULL);
printf("%d", &i);
fuzzfunction(argv[1], extractpath);

RemoveDirectory(extractpath);

FreeLibrary(module);
module = NULL;
//printf("successfully.\n");
return 0;
}

void fuzzfunction(char *rarpath, char *extractpath)
{
//puts(filename);

memset(unknowstr1, 0, 1024);
memset(passwd, 0, 1024);
memset(unknowstr2, 0, 1024);
memset(comment, 0, 0x2000);

char tmpdir[] = "C:\\tmp";
//nop2();

/* start calling ACEInitDll */
tACEInitDllStruc struc = {0,};
struc.GlobalData.MaxArchiveTestBytes = 0x3FFFF;
struc.GlobalData.MaxFileBufSize = 0x2FFFF;
struc.GlobalData.Comment.Buf = comment;
struc.GlobalData.Comment.BufSize = 0x2000;
struc.GlobalData.TempDir = tmpdir;

struc.GlobalData.InfoCallbackProc = (INT (__stdcall *)(pACEInfoCallbackProcStruc))nop0;
struc.GlobalData.ErrorCallbackProc = (INT (__stdcall *)(pACEErrorCallbackProcStruc))nop1;
struc.GlobalData.RequestCallbackProc = (INT (__stdcall *)(pACERequestCallbackProcStruc))nop2; // important
struc.GlobalData.StateCallbackProc = (INT (__stdcall *)(pACEStateCallbackProcStruc))nop3;

ACEInitDll_addr(&struc);
//printf("end calling ACEInitDll.\n");
/* end ACEInitDll */

/* start calling ACEExtract */
tACEExtractStruc extract;
char fl[] = "*";
//char dst[] = "C:\\tmp\\test";
//char rarpath[] = "C:\\tmp\\test.rar";


extract.Files.FileList = fl;
extract.ExcludePath = NULL;
extract.Files.SourceDir = (LPSTR)unknowstr1; // what's this?
extract.Files.ExcludeList = (LPSTR)unknowstr2; // what's this?
extract.Files.FullMatch = 1;
extract.DestinationDir = extractpath;
extract.DecryptPassword = passwd;
//printf("start calling ACEExtract.\n");
ACEExtract_addr(rarpath, &extract);
//printf("end calling ACEExtract.\n");
/* end ACEExtract */


//printf("end fuzzfunction.\n");
return;
// comment = NULL;
}

wchar_t* charToWChar(const char* text)
{
size_t size = strlen(text) + 1;
wchar_t* wa = new wchar_t[size];
mbstowcs(wa, text, size);
return wa;
}

用 harness 来调试至少看起来更简洁,虽然漏洞位于 unacev2.dll 里,跟 harness 也没关系。

IDA + WinDBG 动态调试配置准备

需要调试的程序是 unacev2.dll,但不能直接触发,需要通过执行编写的 harness 来调用 dll 中的函数。因此 IDA 中的设置如下:

image-20210810094950253
  • Application:设置成入口程序,就是我们编写的 harness;
  • Input file:需要被调试的程序,设置成 dll。

调试的时候我的 xp 虚拟机崩掉了,执行的时候总有 bug,所以后面就放到我的 Windows 7 上调试了。配置项都是一样的。

动静结合分析 unacev2.dll

harness 的主要的调用流程如下:

1
2
3
4
... ...
ACEInitDll_addr(&struc); // 初始化 ACE 结构
... ...
ACEExtract_addr(rarpath, &extract); // 进行解压

所以解压过程主要发生在函数 ACEExtract_addr 中。因此在该函数内下断点,主要分析这个函数:

image-20210810113023611

进行了简单的逆向。

  • GetAceFileName_guess :猜测是用于获取要解压的 ace 文件路径;
  • DealWithPaths :处理文件路径,解压路径等;
  • DealWithReg :进行注册表相关的操作,具体功能未知;
  • Maybe_CheckAndExtract :对 ace 文件进行检查,然后解压。

所以然后进入 Maybe_CheckAndExtract() 函数。

image-20210810164527921

可以看到第 8 行,Maybe_Check() 进行检查,检查通过之后执行内部的解压和检查操作。这里主要关注 Maybe_Extract() 函数。

通过追踪 Maybe_Extract() 函数的调用流程,找到位于 0038BF0F 的函数(重命名为 maybe_extract_all_files() ),该函数中的 for 循环会依次提取压缩包中的每个文件:

image-20210810203721769

所以接下来分析 do_extract() 函数(地址是 0035BADC),总体逻辑如下:

1
2
3
4
... ...
maybe_create_extract_file(); // 在指定路径下,创建文件被解压的文件(函数地址:0035CB48)
... ...
maybe_write_extract_file(); // 向前面创建的空白文件,写入内容(函数地址:0035D04D)

原则上,maybe_create_extract_file() 应该只能在我们指定的目录创建文件,但是恶意构造的 ace 文件可以向其他路径创建文件。所以漏洞应该发生在这个函数中。

我主要关注了解压 calc.exe 这个隐藏的恶意程序的时候,do_extract() 函数和 maybe_create_extract_file() 函数的执行流程。

在执行 do_extract() 函数之前,调用了 strcpy() 函数来获取当前要提取的文件名。解压 hello.txt 时内容正常,如下:

image-20210810233937320

但解压隐藏的恶意程序时,文件名如下:

image-20210810233517621

maybe_create_extract_file() 逻辑如下:

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
if (file_name[0] == '\\' && file_name[1] == '\\') {
// 没有命中这个 if,就不分析了
... ...
}

if (file_name[1] == ':' && file_name[2] == '\\') {
// 如果文件名是类似于:"X:\" 开头,即带有盘符信息开头的话
// 就截断盘符信息
strcpy(file_name, &filename[3]);
}

// 走到这里,理论上应该已经不携带盘符信息了
char *name = NULL;
if (file_name[0] != ':' || file_name[1] == '\\' ){
// 这个判断很迷惑,但是总之正常的路径都应该进入这个判断;

.loop:
char *fn = file_name;
char *substr = NULL;
// 个人理解:这个 while 循环删去了所有 ..\ 以避免绕过
while(substr = guess_strstr(fn, "..\\")){
// 寻找 file_name 中是否存在 "..\"
if (substr == file_name /* 以 ..\ 开头 */ || substr[-1] == '\\' /* dir1\..\dir2 */){
// 路径合法情况则删除 ..\ 这三个字节
strcpy(substr, &substr[3]);// 跳过 ../,得到后面的内容
}
else{ // 路径不合法情况,跳过1个字节
fn = substr + 1;
}
}
}
else {// 如果处理过之后还是带有盘符,则删除后进入 loop
file_name = &file_name[2];
goto .loop;
}

char *path;
// 如果解压路径是一个完整路径,那么就解压到完整路径,否则将文件解压到默认目录
if is_path(file_name) {
path = "";
}
else {
path = extract_dir; // 默认解压到指定的目录
}
sprintf(final_path, "%s%s", extract_dir, file_name);

最终创建目录的函数:0065CA78 final_create_path_ttttttt

经过上述处理之后,file_name 被处理为下面的字符串:(其实就是截断了两个 C:\

image-20210811143817457

通过逆向分析程序,可以看出,原程序的各种检查过滤了所有的 ..\ ,我认为是为了避免目录逃逸。但是从后面的逻辑来看,似乎又没有禁止使用绝对路径。(虽然前面也对盘符等绝对路径做了过滤)。错综复杂的逻辑最后得到了这样一个绝对路径。

接下来,调用了 final_create_path() 函数 (地址:0034CA78)来创建文件。里面又对目录做了处理(?为什么不能在一个函数处理完目录呢??),该函数逻辑如下:

image-20210811152757767

本质上 GetAceFileName_guess() 函数是调用了 GetFullPathNameA() 函数,可以从某个相对路径得到绝对路径。处理完之后的结果如下:

image-20210811152345439

image-20210811145302115

到这里就基本搞清楚是怎么实现目录穿越的了。进一步地进行分析,包括进行 exp 编写的话,就需要搞清楚 ace 的格式了。先挖个坑。