[EXPLOIT] OpenSSL OOB(Out-Of-Bound) Read DOS Vulnerability. Analysis CVE-2017-3731
사실 a2sv의 진단 모듈 추가건으로 알아보다가 이 포스팅을 시작하게 되었습니다. 취약점 특성 상 툴에 적용은 어려워 아쉬운점이 있지만 그래도 쭉 분석해보는 재미있는 시간이 되었네요.
오늘은 올해 나온 OpenSSL Truncated packet 취약점인 CVE-2017-3731에 대해 알아보고 코드단에서 어떤것들이 문제가있는지 알아보겠습니다.
CVE-2017-3731
이 취약점은 올해 5월 4일 공개된 취약점입니다. cvss부터 보면 DOS이기 때문에 가용성만 partial를 받았습니다. 공격 복잡도나 접근 범위등은 당연히 .. 아주 쉬워 NL이네요. 물론 SSL 취약점이기에 인증또한 없구요.
CVSS
- AV:N
- AC:L
- Au:N
- C:N
- I:N
- A:P
Rapid7의 취약점 정의에는 아래와 같이 써있습니다.
If an SSL/TLS server or client is running on a 32-bit host, and a specific cipher is being used, then a truncated packet can cause that server or client to perform an out-of-bounds read, usually resulting in a crash. For OpenSSL 1.1.0, the crash can be triggered when using CHACHA20/POLY1305; users should upgrade to 1.1.0d. For Openssl 1.0.2, the crash can be triggered when using RC4-MD5; users who have not disabled that algorithm should update to 1.0.2k.
32bit의 SSl/TLS 서버나 클라이언트에서 동작하고, CHACHA20/POLY1305를 사용하며, 취약 버전의 SSL일 경우 Crash를 발생시킬 수 있다고 써있네요. 아래에서 이야기하겠지만 OpenSSL의 CHACHA20, POLY1305의 문제입니다.
시작해볼까요?
What diffrent?
CVE-2017-3731 취약점이 패치된 OpenSSL과 취약 OpenSSL간에는 여러가지 차이가 있습니다. 그 중 e_chacha20_poly1305.c 파일에서 우리는 주목해야합니다. 바로 이 취약점에 대한 직접적인 패치 자리이기 때문이죠.
https://git.openssl.org/?p=openssl.git;a=commitdiff;h=2198b3a55de681e1f3c23edb0586afe13f438051
내용을 보면…
len = aad[EVP_AEAD_TLS1_AAD_LEN - 2] << 8 |
aad[EVP_AEAD_TLS1_AAD_LEN - 1];
if (!ctx->encrypt) { + if (len < POLY1305_BLOCK_SIZE) + return 0;
len -= POLY1305_BLOCK_SIZE; /* discount attached tag */
memcpy(temp, aad, EVP_AEAD_TLS1_AAD_LEN - 2);
aad = temp;
2줄이 추가되었습니다. POLY1305_BLOCK_SIZE와 len 값을 비교해서 Overflow를 방지한 것 같네요.
참고로 len은 unsigned int로 선언되고, POLY1305_BLOCK_SIZE의 값은 상수 16 입니다.
//len - e_chacha20_poly1305.c
unsigned int len;
// POLY1305_BLOCK_SIZE - poly1305,h
#define POLY1305_BLOCK_SIZE 16
아 그렇다면.. 우리가 봐야할 부분은 len 변수가 어떤 값의 길이인지, 그 값이 무엇인지, 그 값에 데이터를 쓰는 방법을 찾으면 거꾸로 풀어나갈 수 있을 것 같네요.
len은 어디서부터?
패치된 코드부터 len에 대해 찾아보면 아주 많이 사용됩니다.. 그 중 일반인 사용처를 제외하면 Poly1305_Update() 함수에 직접 들어가고, 그 부근에서도 많이 사용되죠.
if (in) { /* aad or text */
if (out == NULL) { /* aad */
Poly1305_Update(POLY1305_ctx(actx), in, len);
actx->len.aad += len;
actx->aad = 1;
return len;
} else { /* plain- or ciphertext */
if (actx->aad) { /* wrap up aad */
if ((rem = (size_t)actx->len.aad % POLY1305_BLOCK_SIZE))
Poly1305_Update(POLY1305_ctx(actx), zero,
POLY1305_BLOCK_SIZE - rem);
actx->aad = 0;
}
actx->tls_payload_length = NO_TLS_PAYLOAD_LENGTH;
if (plen == NO_TLS_PAYLOAD_LENGTH)
plen = len;
else if (len != plen + POLY1305_BLOCK_SIZE)
return -1;
if (ctx->encrypt) { /* plaintext */
chacha_cipher(ctx, out, in, plen);
Poly1305_Update(POLY1305_ctx(actx), out, plen);
in += plen;
out += plen;
actx->len.text += plen;
} else { /* ciphertext */
Poly1305_Update(POLY1305_ctx(actx), in, plen);
chacha_cipher(ctx, out, in, plen);
in += plen;
out += plen;
actx->len.text += plen;
}
}
}
사실 하나하나씩 찾아봐야했겠지만.. Mcafee의 보고서를 본 뒤라, 바로 Poly1305_Update 함수부터 볼 수 밖에 없네요. (덕분에 시간 단축)
일단 내부를 보기전에 plen 변수에는 len 값이 직접 들어가기 때문에 plen 사용구간에서도 동일하게 문제 발생이 있을 수 있습니다. 참고만해주세요.
그럼 len(plen 포함) 변수가 직접적으로 쓰이는 구간은 아래와 같습니다.
chacha_chipher() Poly1305_Update()
두 함수네요. 그리고 코드 아래쪽(237.242 줄)에 잘 보시면 Poly1305_Update() 함수에서도 plen이 사용됩니다.
Poly1305_Update(POLY1305_ctx(actx), in, len); Poly1305_Update(POLY1305_ctx(actx), in, plen);
어차피 두개 다 같은거니 뭐..
chacha_chipher() 함수
chacha_chipher 함수는 같은 파일에 정의되어 있습니다. 55번줄부터 123번줄까지입니다.
static int chacha_cipher(EVP_CIPHER_CTX * ctx, unsigned char *out,
const unsigned char *inp, size_t len)
{
EVP_CHACHA_KEY *key = data(ctx);
unsigned int n, rem, ctr32;
if ((n = key->partial_len)) {
while (len && n < CHACHA_BLK_SIZE) {
*out++ = *inp++ ^ key->buf[n++];
len--;
}
key->partial_len = n;
if (len == 0)
return 1;
if (n == CHACHA_BLK_SIZE) {
key->partial_len = 0;
key->counter[0]++;
if (key->counter[0] == 0)
key->counter[1]++;
}
}
rem = (unsigned int)(len % CHACHA_BLK_SIZE);
len -= rem;
ctr32 = key->counter[0];
while (len >= CHACHA_BLK_SIZE) {
size_t blocks = len / CHACHA_BLK_SIZE;
/*
* 1<<28 is just a not-so-small yet not-so-large number...
* Below condition is practically never met, but it has to
* be checked for code correctness.
*/
if (sizeof(size_t)>sizeof(unsigned int) && blocks>(1U<<28))
blocks = (1U<<28);
/*
* As ChaCha20_ctr32 operates on 32-bit counter, caller
* has to handle overflow. 'if' below detects the
* overflow, which is then handled by limiting the
* amount of blocks to the exact overflow point...
*/
ctr32 += (unsigned int)blocks;
if (ctr32 < blocks) {
blocks -= ctr32;
ctr32 = 0;
}
blocks *= CHACHA_BLK_SIZE;
ChaCha20_ctr32(out, inp, blocks, key->key.d, key->counter);
len -= blocks;
inp += blocks;
out += blocks;
key->counter[0] = ctr32;
if (ctr32 == 0) key->counter[1]++;
}
if (rem) {
memset(key->buf, 0, sizeof(key->buf));
ChaCha20_ctr32(key->buf, key->buf, CHACHA_BLK_SIZE,
key->key.d, key->counter);
for (n = 0; n < rem; n++)
out[n] = inp[n] ^ key->buf[n];
key->partial_len = rem;
}
return 1;
}
글에 넣고보니 좀 길어보이네요. 일단 len 존재부터 보면
static int chacha_cipher(EVP_CIPHER_CTX * ctx, unsigned char *out,
const unsigned char *inp, size_t len)
{
EVP_CHACHA_KEY *key = data(ctx);
unsigned int n, rem, ctr32;
위와 같이 size_t 형태로 len 값을 받아옵니다. len 값은 unsigned int란거 기억나시나요?
unsigned int > 부호제외 size_t 각 OS비트에서 가장 큰 사이즈를 담을 수 있는 unsigend data type
Poly1305_Update() 함수
위 경로로 가서 보시면 편합니다. 466줄~506줄입니다.
void Poly1305_Update(POLY1305 *ctx, const unsigned char *inp, size_t len)
{
#ifdef POLY1305_ASM
/*
* As documented, poly1305_blocks is never called with input
* longer than single block and padbit argument set to 0. This
* property is fluently used in assembly modules to optimize
* padbit handling on loop boundary.
*/
poly1305_blocks_f poly1305_blocks_p = ctx->func.blocks;
#endif
size_t rem, num;
if ((num = ctx->num)) {
rem = POLY1305_BLOCK_SIZE - num;
if (len >= rem) {
memcpy(ctx->data + num, inp, rem);
poly1305_blocks(ctx->opaque, ctx->data, POLY1305_BLOCK_SIZE, 1);
inp += rem;
len -= rem;
} else {
/* Still not enough data to process a block. */
memcpy(ctx->data + num, inp, len);
ctx->num = num + len;
return;
}
}
rem = len % POLY1305_BLOCK_SIZE;
len -= rem;
if (len >= POLY1305_BLOCK_SIZE) {
poly1305_blocks(ctx->opaque, inp, len, 1);
inp += len;
}
if (rem)
memcpy(ctx->data, inp, rem);
ctx->num = rem;
}
아까보다 짧아서 좋네요. 일단 len은 똑같이 size_t로 들어옵니다. 일단 size_t로 값이 들어왔기 때문에 아직까진 크게 문제있는 부분이 없습니다. 여기서 중점적으로 봐야할 부분은 poly1305_blocks 함수입니다. 인자값에 len을 사용하고 있죠.
Vulnerable point (poly1305_blocks function)
https://git.openssl.org/?p=openssl.git;a=blob;f=crypto/poly1305/poly1305.c;h=7c9f302bfc1689ea4b3d8d8d94f8842ca0dc17d6;hb=e87c056745845ecaa6a884fa9cf0dc0c404f0c46
아까랑 같은 파일 164번줄~219줄입니다.
static void
poly1305_blocks(void *ctx, const unsigned char *inp, size_t len, u32 padbit)
{
poly1305_internal *st = (poly1305_internal *)ctx;
u64 r0, r1;
u64 s1;
u64 h0, h1, h2, c;
u128 d0, d1;
r0 = st->r[0];
r1 = st->r[1];
s1 = r1 + (r1 >> 2);
h0 = st->h[0];
h1 = st->h[1];
h2 = st->h[2];
while (len >= POLY1305_BLOCK_SIZE) {
/* h += m[i] */
h0 = (u64)(d0 = (u128)h0 + U8TOU64(inp + 0));
h1 = (u64)(d1 = (u128)h1 + (d0 >> 64) + U8TOU64(inp + 8));
/*
* padbit can be zero only when original len was
* POLY1306_BLOCK_SIZE, but we don't check
*/
h2 += (u64)(d1 >> 64) + padbit;
/* h *= r "%" p, where "%" stands for "partial remainder" */
d0 = ((u128)h0 * r0) +
((u128)h1 * s1);
d1 = ((u128)h0 * r1) +
((u128)h1 * r0) +
(h2 * s1);
h2 = (h2 * r0);
/* last reduction step: */
/* a) h2:h0 = h2<<128 + d1<<64 + d0 */
h0 = (u64)d0;
h1 = (u64)(d1 += d0 >> 64);
h2 += (u64)(d1 >> 64);
/* b) (h2:h0 += (h2:h0>>130) * 5) %= 2^130 */
c = (h2 >> 2) + (h2 & ~3UL);
h2 &= 3;
h0 += c;
h1 += (c = CONSTANT_TIME_CARRY(h0,c)); /* doesn't overflow */
inp += POLY1305_BLOCK_SIZE;
len -= POLY1305_BLOCK_SIZE;
}
st->h[0] = h0;
st->h[1] = h1;
st->h[2] = h2;
}
아까 들어온 len 값은 POLY1305_BLOCK_SIZE와 비교 구문이 걸린 while을 통해 반복 수행이 이루어집니다.
POLY1305_BLOCK_SIZE 값은 16이였죠? 그럼 len이 16보다 큰 경우에 이 코드가 동작하게 되고 len이 16과 같거나 작아질때까지 계속 루프를 돌게됩니다.
while (len >= POLY1305_BLOCK_SIZE) {
/* h += m[i] */
h0 = (u64)(d0 = (u128)h0 + U8TOU64(inp + 0));
h1 = (u64)(d1 = (u128)h1 + (d0 >> 64) + U8TOU64(inp + 8));
/*
* padbit can be zero only when original len was
* POLY1306_BLOCK_SIZE, but we don't check
*/
h2 += (u64)(d1 >> 64) + padbit;
/* h *= r "%" p, where "%" stands for "partial remainder" */
d0 = ((u128)h0 * r0) +
((u128)h1 * s1);
d1 = ((u128)h0 * r1) +
((u128)h1 * r0) +
(h2 * s1);
h2 = (h2 * r0);
len 값의 변화는
213 len -= POLY1305_BLOCK_SIZE;
213번줄에 명시되어 있습니다. 루프를 돌면서 len에서 16을 계속 반복해서 빼주고 있습니다. 여기서 문제는 len 값이 size_t로 정의되기 때문에 아주 크다는 것입니다. len의 값이 크게 들어올수록 해당 코드는 계속 반복하며 16과 같거나 작아질때까지 로프를 도는데, 여기 while에 걸려있는 코드가..
static void
poly1305_blocks(void *ctx, const unsigned char *inp, size_t len, u32 padbit)
...snip...
h0 = (u64)(d0 = (u128)h0 + U8TOU64(inp + 0));
h1 = (u64)(d1 = (u128)h1 + (d0 >> 64) + U8TOU64(inp + 8));
/*
* padbit can be zero only when original len was
* POLY1306_BLOCK_SIZE, but we don't check
*/
h2 += (u64)(d1 >> 64) + padbit;
...snip...
inp += POLY1305_BLOCK_SIZE;
이런 형태인데요, U8TOU64()함수는 inp라는 변수의 값을 인자값으로 사용하는데, inp는 포인터입니다. 이 친구는 메모리값을 참조하고 있고 아래쪽 코드를 보면 212줄에서 inp의 값 또한 POLY1305_BLOCK_SIZE, 즉 16씩 계속 더해주게 됩니다.
만약 len값이 아주 크다면 어떤 일이 이루어질까요?
len이 16이 될때까지 반복적으로 루프가 돌것이며 그 순간 inp의 값(메모리)은 16씩 더해지며 U8TOU64()함수로 주소를 넘기게 됩니다. U8TOU64() 함수는 아래와 같이..
static u64 U8TOU64(const unsigned char *p)
{
return (((u64)(p[0] & 0xff)) |
((u64)(p[1] & 0xff) << 8) |
((u64)(p[2] & 0xff) << 16) |
((u64)(p[3] & 0xff) << 24) |
((u64)(p[4] & 0xff) << 32) |
((u64)(p[5] & 0xff) << 40) |
((u64)(p[6] & 0xff) << 48) |
((u64)(p[7] & 0xff) << 56));
}
받은 주소를 연산하여 처리하기 때문에 U8TOU64가 처리할 수 없는 메모리의 값이 들어오면 메모리 참조가 불안정해지는 문제가 발생합니다.
자 len값으로 인해 DOS가 되겠네요 :) OpenSSL의 DOS는 의외로 서비스에 큰 문제를 나타낼 수 있습니다. SSL이 정상적으로 동작하지 않으면 https에 접근이 불가능해지고, 안그래도 최근 https 서비스가 많은 마당에 굉장한 효과를 나타낼 수 있겠지요.
Attack!
취약 버전의 OpenSSL과 OpenSSL client가 있다면 쉽게 테스트가 가능합니다. 일단 연동된 https 서비스나 openssl로 ssl 서버를 동작시켜준 후 openssl client or 다른 프로그램 or 가내수공업프로그램.. 등등 여러가지 방법으로 16byte 미만의 handshake 메시지를 chacha20_poly1305 cipher로 전송합니다.
#> server.sh Start OpenSSL Server…
Program received signal SIGSEGV, Segmentation fault. 0xb45158a1 in U8TOU32 ( p=0x6b15210 <error: Cannot access memeory at address 0x6b15210>) at crypto/poly1305/poly1305.c:43 43 return (((unsigned int)(p[0] & 0xff)) |
구글에서 만든 퍼저인 honggfuzz에서도 이 취약점을 지원한다고 하는데요. 한번 테스트해보시면 좋을 것 같네요. https://github.com/google/honggfuzz
Conclusion
https에 대한 정책? 전체적인 변화로 최근 대다수 사이트가 https를 지원하고 있습니다. 이런 환경에서 간단한 DOS 취약점이여도 서비스를 쉽게 마비시킬 수 있기 때문에 OpenSSL에 대한 취약점은 굉장히 위험하다고 생각합니다.
Reference
https://securingtomorrow.mcafee.com/mcafee-labs/analyzing-cve-2017-3731-truncated-packets-can-cause-denial-service-openssl/?utm_source=twitter&utm_campaign=Labs#sf61252921 https://github.com/openssl/openssl/blob/master/crypto/poly1305/poly1305.c https://git.openssl.org/?p=openssl.git;a=blob;f=crypto/poly1305/poly1305.c;h=7c9f302bfc1689ea4b3d8d8d94f8842ca0dc17d6;hb=e87c056745845ecaa6a884fa9cf0dc0c404f0c46 https://www.rapid7.com/db/vulnerabilities/http-openssl-cve-2017-3731 https://github.com/google/honggfuzz