
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 명령의 매개변수 같은 것들을 추가했습니다.