[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() 함수

https://git.openssl.org/?p=openssl.git;a=blob;f=crypto/poly1305/poly1305.c;h=7c9f302bfc1689ea4b3d8d8d94f8842ca0dc17d6;hb=e87c056745845ecaa6a884fa9cf0dc0c404f0c46

위 경로로 가서 보시면 편합니다. 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