OSSに空いたセキュリティーホールと「脆弱性のふさぎかた」 その対策方法を調べる

第1回cURLの脆弱性 ~ヒープバッファオーバーフロー~

はじめに

みなさん、こんにちは。平和なサイバー空間の実現に向けて日々奮闘しているセキュリティ研究者、中島明日香です。今月号から「ソフトウェアの脆弱性」をテーマに、その原理と修正方法について解説する連載を始めます。

「脆弱性の原理と修正方法なんて、教科書的でつまらなそう」⁠実践的ではなさそう」と思う方も多いと思います。そこで本連載では、その不安を払拭するべく、著名なオープンソースのソフトウェアにおいて過去発見された実際の脆弱性を題材に、その原理と実際の修正方法について紹介します。実際の例を通じて実践的な知見を身につけていただき、そして願わくば、みなさんがソフトウェアを開発される際に、その知見をお役立ていただけたら幸いです。

連載の第1回目となる今回は、有名なデータ転送用のコマンドラインツールである「cURL」において、2019年に発見・修正された脆弱性を取り上げます。

脆弱性とは何か?

連載初回であることもふまえ、最初に「そもそも脆弱性とは何か?」について説明します。定義はさまざまあるのですが、脆弱性とは簡単に言えば「第三者が悪用可能なバグ」のことを指します。

たとえば、ユーザーから任意の数値の入力を受け付け、その入力された数値をもとにとある計算を行い、結果を表示するプログラムがあるとします。ここでもし、計算式の実装に間違いがあり、それゆえに計算結果が間違って表示される場合、単なるバグにあたります。ですが、その計算結果の数値が、内部でプログラムの実行を進めるための処理(確保するメモリのサイズなど)に利用される仕様だとどうでしょう。このプログラムでユーザー側(第三者側)で入力する数値をうまく調整することで、最終的にプログラムの制御を乗っ取ることが可能な場合、脆弱性にあたります。

脆弱性はサイバー攻撃の根本的な原因の1つであり、可能な限り作り込まないようにするべきものです。ただし、現状で完全になくすのは現実的には難しいものでもあり、著名なソフトウェアであっても頻繁に発見されています[1]

もし脆弱性を発見した場合、現在は脆弱性調整機関にその情報を届け出ることが推奨されています[2]。届け出られた脆弱性は、脆弱性調整機関を通じてベンダーや開発者に報告され、修正するよう促されます。そして一般的に、脆弱性が修正された場合、個々の脆弱性を一意に識別するための識別子(脆弱性情報データベース上の管理番号)である「CVE番号」が付与されます。

今回の脆弱性:CVE-2019-5482

今回は、CVE番号で表すと「CVE-2019-5482」にあたる脆弱性を取り上げます。これは、2019年にcURL(curlコマンド)で発見・修正された、ヒープバッファオーバーフローの脆弱性にあたります。具体的には、ファイル送受信のプロトコルであるTFTP実装部分のミスによって作り込まれた脆弱性です。本稿では、とくにファイルデータ受信部分の周辺に着目して脆弱性を解説していきます。

どこに脆弱性があるのか?

読者の中には、cURLコマンドを用いて、TFTPサーバ上に存在するファイルを自身のコンピュータにダウンロードしたことがある方もいると思います。

$ curl tftp://サーバのアドレス/test.jpg --output test.jpg
(..略..)
$ ls
test.jpg

今回の脆弱性は、このようにデータを受信(送受信)する際に「--tftp-blksize」オプションを用いて、デフォルトサイズである512バイトを下回るサイズ(下記例の場合100バイト)を指定した場合に発現するものです。

$ curl --tftp-blksize 100 tftp://サーバのアドレス/test.jpg --output test.jpg

ただし、のちほど詳しく解説しますが、これは必ず発現するというわけではありません。条件として、TFTPサーバ側で指定のオプション(今回の場合blksize)が利用可能なことを返答する「OACK」をクライアントに送信する際図1に、何らかの要因でOACK内にblksizeオプションが含まれない場合に限ります。

図1 OACKのパケット構造(blksizeオプションが含まれる場合)
図1

「blksizeオプションが含まれていない」事象はどういう場合に発生するのか、についてですが、脆弱性の報告者によりますと、cURLの別のバグを使えば、オプション付きのRRQを送る際に、blksizeの情報を欠落させたまま、RRQを送ることができるようです。そしてサーバ側は、クライアント側から送られたRRQ(に含まれているオプション)から返答するOACKを構成します。クライアントからそもそもblksizeオプションが入ってなかった場合、サーバから返答されるOACKの中にblksizeが含まれなくなります。

ほかにも実装により、blksizeオプションを欠落させてOACKを返答するサーバがあれば、今回の脆弱性が発現します。現実的にそのようなサーバがある可能性はかなり低いですが、攻撃者がそういった悪意あるサーバを用意することは可能かとは思います。

話を戻しますが、ではなぜデフォルトサイズの512バイトより小さいサイズを、オプションを使って指定することで脆弱性が発現するのでしょうか? 具体的な脆弱性の解説に移る前に、まず「ヒープバッファオーバーフロー」とは何かについて説明します。

ヒープバッファオーバーフローとは

まずバッファオーバーフローとは、⁠コンピュータのメモリ上で確保されたある領域(バッファ)に対して、その大きさ以上のデータを書き込めてしまうこと」に起因する脆弱性です。これは開発者による実装ミスなどで作り込まれます。

メモリには用途に応じてさまざまな種類の領域があり、その中でも、malloc関数などで動的に確保可能なメモリの領域のことを「ヒープ領域」と呼びます。そして、このヒープ領域上で生じるバッファオーバーフローのことを「ヒープバッファオーバーフロー」と呼びます。

ここで、⁠確保したバッファの大きさ以上に、データが書き込めてしまうことの、いったい何が問題なのか?」と疑問に思った方もいるかと思います。これは簡単に言えば、図2に示すように、もしプログラムの正常な続行に必要な「大事なデータ」がバッファの付近に存在した場合、上書きできてしまうのが問題なのです。図の場合は、バッファサイズを超える大量の「A」という文字を、バッファへの入力データとして与えた結果、大事なデータが上書きされてしまった様子です。

図2 ヒープバッファオーバーフローの概念
図2

たとえばここで、確保されたバッファの近くに関数ポインタのデータが存在していた場合どうなるでしょうか。図3①のように、関数ポインタが示すアドレスを上書きすることができてしまいます。そして、もし関数ポインタのアドレスが上書きされた場合は、図3②のように、正常にその関数を呼び出せなくなってしまうのです。

図3 ヒープバッファオーバーフローを悪用して正常な実行を妨げる
図3

さらに、もしこの関数ポインタのアドレスを「AAAAA……」という文字列で上書きするのではなく、攻撃者が指定したアドレスに書き換えることができればどうなるでしょうか。その場合は関数が呼び出された際、攻撃者の指定したアドレスに処理が移り、実行されます。そして、もしその指定したアドレスに、攻撃者が用意した悪意あるコードがあれば、それが実行されてしまうのです[3]。これが、いわゆる「脆弱性を悪用する」手法の一例です。

実際の脆弱性箇所について

脆弱性の概要が理解できたところで、実際の脆弱性箇所を見ていきます。今回は脆弱性の種類がヒープバッファオーバーフローとわかっているので、最初に注目すべきは「どのバッファに対して、バッファを上回るサイズのデータが、どこで書き込まれてしまうのか?」です。

脆弱性が発現する箇所

今回は、親切にもcURLの公式Webサイトに、この情報が掲載されていました。読んでみると、tftp.c中で実装されているtcp_receive_packet関数内で、recvfrom関数を実行した際にヒープバッファオーバーフローが起きるそうです。該当の箇所を抜粋したのがリスト1です。

リスト1 ヒープバッファオーバーフローの脆弱性が発現する箇所
static CURLcode tftp_receive_packet(struct connectdata *conn)
{
  struct Curl_sockaddr_storage fromaddr;
  curl_socklen_t        fromlen;
  CURLcode              result = CURLE_OK;
  struct Curl_easy  *data = conn->data;
  tftp_state_data_t    *state = (tftp_state_data_t *)conn->proto.tftpc;
  struct SingleRequest *k = &data->req;

  /* Receive the packet */
  fromlen = sizeof(fromaddr);
  state->rbytes = (int)recvfrom(state->sockfd,
                               (void *)state->rpacket.data,
                               state->blksize + 4,
                               0,
                               (struct sockaddr *)&fromaddr,
                               &fromlen);

recvfromは簡単に言えば、指定されたソケット上のデータを受信しそれを指定のバッファに格納する関数で、リスト2表1のように引数を取ります。

リスト2 recvfrom関数の定義
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr,
socklen_t *addrlen);
表1 recvfrom関数の各引数の意味(recvfromのmanページより抜粋)
番号 引数 意味
第1引数 int sockfd ソケット記述子
第2引数 void *buf 受信したデータを格納するためのバッファへのポインタ
第3引数 size_t len 受信するデータのサイズ
第4引数 int flags フラグ(各種パラメータを設定できる)
第5引数 struct sockaddr *src_addr 送信元アドレス
第6引数 socklen_t *addrlen 送信元アドレス長

引数は6つありますが、ここで大事なのは、recvfrom関数ではソケットを通じてデータを受信する際に、第2引数で指定したバッファに対し、第3引数で指定したサイズでデータを受信して格納するということです。リスト1では、第2引数で指定しているバッファはstate->rpacket.dataで、第3引数で指定しているデータサイズはstate->blksize + 4です。

では、このバッファはいったいどこで確保されているのでしょうか? そして受信するデータサイズは、どのようにしてstate->blksizeに指定されているのでしょうか?

オーバーフローするバッファ

解析した結果、同じくtftp.cの中のtftp_connect関数内で、このバッファが確保されていることがわかりましたリスト3⁠。具体的には、指定したバイト分のメモリを指定個分だけ確保するcalloc関数を利用してblksize + 2 + 2にあたるサイズのメモリをヒープ領域から確保しています。

リスト3 calloc関数を利用してヒープ領域から指定サイズのバッファを割り当て
if(!state->rpacket.data) {
state->rpacket.data = calloc(1, blksize + 2 + 2);

では次に、メモリを確保する際に利用されたblksize変数とは何者なのかを見ていきます。まず、リスト4に示すように、blksizeはint型変数として定義され、最初はTFTP_BLKSIZE_DEFAULTという数値が格納されています。

リスト4 blksize変数
static CURLcode tftp_connect(struct connectdata *conn, bool *done)
{
  tftp_state_data_t *state;
  int blksize;
  
  blksize = TFTP_BLKSIZE_DEFAULT;

そしてこのTFTP_BLKSIZE_DEFAULTは、リスト5のように512であると、tftp.c内で定義されています。

リスト5 tftp.c内で定義
#define TFTP_BLKSIZE_DEFAULT 512
#define TFTP_BLKSIZE_MIN 8
#define TFTP_BLKSIZE_MAX 65464
#define TFTP_OPTION_BLKSIZE "blksize"

ここまで読み解いた結果だと、リスト3では「512+2+2」と、合計516バイトのバッファが確保されたように見えます。ですが、本当にそうなのでしょうか?念のため、さらにソースコードを追っていくと、calloc関数呼び出し部分の直前に、リスト6のような処理が存在していることがわかりました。この部分では、⁠もしset.tftp_blksizeが存在していた場合、blksize変数にその値を格納する」という処理をしています。そしてこのset.tftp_blksizeには、ユーザーがcurlコマンドを利用する際に--tftp-blksizeで指定したデータサイズ(ブロック・サイズ)が格納されています。

リスト6 --tftp-blksizeオプションで指定したデータサイズをblksizeに格納
/* alloc pkt buffers based on specified blksize */
if(conn->data->set.tftp_blksize) {
  blksize = (int)conn->data->set.tftp_blksize;
  if(blksize > TFTP_BLKSIZE_MAX ¦¦ blksize < TFTP_BLKSIZE_MIN)
    return CURLE_TFTP_ILLEGAL;
}

まとめると、curlコマンド実行時にtftp-blksizeオプションを利用した場合、オプションで指定したブロック・サイズが、最終的にblksize変数に格納されます。そしてこのblksize変数が、リスト3のcalloc関数で利用されているのです。

補足になりますが、calloc関数を用いて、バッファを確保する際にblksize + 2 + 2というサイズの指定になっているのは、実際にデータを送受信する際には、データの前に2バイトで表現される2つのヘッダ情報(合計4バイト)が付加されるためです。

state->blksizeの中身は?

引き続きtftp_connect関数を解析すると、次のような箇所を発見しました。ここではリスト3のcalloc関数で利用したblksizeを、そのままstate->blksizeに格納しています。

state->blksize = blksize;

このサイズが、最終的にrecvfrom関数(リスト1)の第3引数に利用される分には、問題が出るようには見えません。簡単に言えば、ユーザーが指定したサイズ分だけメモリを確保して、最終的にそのサイズ分だけデータを受信しているだけだからです。しかし「どこに脆弱性があるのか?」の項目でも述べたように、今回の脆弱性は、TFTPサーバがクライアント(cURL)に対してOACKを送信する際に、blksizeのオプションを含んでいなかった場合に限ります。

ではcURL側でこのようなOACKを処理する際、いったい何が起きているのでしょうか?これを探るため、同じくtftp.c内で実装されている、OACKオプションをパースするための関数であるtftp_parse_option_ack関数の中身を見てみますリスト7⁠。読み始めてすぐに目についたのが★部分のコメントです。これは「もしOACK内でblksizeオプションがなかった場合、デフォルトサイズである512が利用されるべきである」という意味になります。実際にこのコメント直後では、state->blksizeにデフォルトサイズ(512)を定義したTFTP_BLKSIZE_DEFAULTが代入されています。

リスト7 tftp_parse_option_ack関数
static CURLcode tftp_parse_option_ack(tftp_state_data_t *state,
                                      const char *ptr,
                                      int len)
{
  const char *tmp = ptr;
  struct Curl_easy *data = state->conn->data;
  
  /* if OACK doesn't contain blksize option, the default (512) must be used */ ←★
  state->blksize = TFTP_BLKSIZE_DEFAULT;

つまりこれは、ユーザーが指定したブロック・サイズ(今回の場合512未満のサイズ)が何であれ、サーバから送られるOACK内にblksizeオプションがなかった場合、state->blksizeの値が強制的に512となってしまうのです。


まとめると、ユーザーがデフォルトサイズ以外のブロック・サイズ(今回の場合は512未満)を指定したとき、tftp_connect関数内で指定のサイズのバッファが確保され、データの受信に利用されます。しかし、ここでもしサーバから送られたOACKの中に、blksizeオプションが含まれなかった場合、強制的にデータの送受信は512バイトずつになってしまいます。そのため、今回のように512未満のサイズのバッファが利用されていた場合、そのサイズ以上にデータが書き込まれてしまうことになるのです。これが今回の、ヒープバッファオーバーフローの原因です。

脆弱性の修正方法

では、この脆弱性が実際にどのように修正されたかを見ていきます。この脆弱性に対する修正は2019年9月に行われました[4]。修正はtftp.cのtftp_connet関数内のコードに対して行われています。各修正部分を見ていきましょう。補足になりますが、--tftp-blksizeオプションで512未満が指定された前提で、各修正の挙動を説明していきます。

確保するバッファのサイズをデフォルトサイズに

最初にtftp_connect関数では、新たにneed_blksizeというint型変数が定義されているのがわかりました。

int need_blksize;

そして、このneed_blksizeという変数は、新たに追加されたリスト8の部分で利用されています。では、ここではいったい何をしているのでしょうか?ここではまず、need_blksize変数にリスト6の部分で取得してきたblksizeの値を一度格納します。そのあと、もしその値がデフォルトのブロック・サイズである512を下回るようならば、need_blksizeの値を512に変更します。そして最終的に、calloc関数を用いてneed_blksize + 2 + 2のサイズで、ヒープ領域からメモリ(バッファ)を確保していますリスト9⁠。

リスト8 新規に追加されたチェック
need_blksize = blksize;
/* default size is the fallback when no OACK is received */
if(need_blksize < TFTP_BLKSIZE_DEFAULT)
  need_blksize = TFTP_BLKSIZE_DEFAULT;
リスト9 need_blksize変数を利用して確保するバッファサイズを計算
- state->rpacket.data = calloc(1, blksize + 2 + 2); ←削除
+ state->rpacket.data = calloc(1, need_blksize + 2 + 2); ←追加

ここで筆者が疑問に思ったのが「ユーザーがデフォルトより小さいサイズを指定した場合でも、最終的にデフォルトサイズを利用してバッファを確保するのは、メモリ利用効率の観点からは少し非効率な修正方法ではないか」ということです。

サーバからOACKが返ってこない場合も想定

その疑問を解決するヒントは、リスト8のコメント部分にありました。コメントには「default size is the fallback when no OACK is received」と書かれています。これは、そもそもサーバからOACKが返ってこない場合、ユーザー側でどのようなサイズを指定していたとしても、デフォルトのサイズである512が利用されることを示唆したコメントです。実際、TFTPのオプションの仕様を定義するRFC(RFC 2347)にも、サーバの実装などにより、ユーザーからのblksizeなどのオプション要求を棄却して、OACKなどを返さない場合もある(返さなくても仕様の範囲内である)ことが書かれています。そしてその場合は、オプションなしの通常のTFTPの通信になることも書かれています。

つまり修正者は、脆弱性の発現の要因であるOACK内のblksizeオプションの有無を気にする以前に、⁠そもそもサーバからOACKが返ってこない可能性があり、それも考慮した実装にするべきである」という意図から、リスト8、9のような修正にしたと考えられます。この修正がお作法的に良いかはさておき、この修正を施すことで、⁠1)そもそもOACKがサーバから返ってこなかった場合に加え、⁠2)OACKにblksizeオプションがなかった場合の両方に対応できるからです。

詳しく説明すると、⁠1)(2)が発生した場合、ユーザーがデフォルトより小さいブロック・サイズを指定していたとしても、結局は512バイトでデータが送受信されてしまいます。そこで、最初から確保するバッファをデフォルトサイズにしておけば、⁠1)⁠2)発生時でも問題なく処理を進められます。そして問題が発生しなかった場合は、デフォルトサイズのバッファを利用しつつ、送受信時のデータサイズはユーザー指定のサイズにすれば良いのです。

リスト10の箇所でも、上記の方針で修正されているのが見て取れます。具体的には(とくに(1)の発生をふまえて⁠⁠、tftp_connect関数内のstate->blksizeには、最初デフォルトサイズの512を格納していますが、これもサーバから問題なくOACKが(blksizeを含めて)返ってきた場合は、tftp_parse_option_ack関数にて、その値がユーザーから指定されたブロック・サイズに置き換わります。

リスト10 state->blksizeもここではTFTP_BLKSIZE_DEFALT(512)に
- state->blksize = blksize; ←削除
+ state->blksize = TFTP_BLKSIZE_DEFAULT; /* Unless updated by OACK response */ ←追加

まとめ

今回の脆弱性は、ユーザーがデフォルトのブロック・サイズを下回る値を「--tftp-blksize」で指定し、かつサーバから返ってくるOACK内にblksizeオプションがない場合に限り発現するというものでした。現実的には中々起こり得ないシチュエーションではありますが、脆弱性箇所がわかりやすいため、今回は取り上げました。また、この脆弱性は、cURLのバージョン「7.19.4」から「7.65.3」の間に存在します。該当するバージョンを利用している方は、最新版のcURLにアップデートすることをお勧めします。

さらに勉強したい人向け

さらに勉強したい方に、関連する書籍などを紹介します。まず「脆弱性とは何か?」についてさらに詳しく解説した書籍として、筆者の著書『サイバー攻撃』[5]があります。そして、そもそも脆弱性を生み出さないようなセキュアコーディングの手法については『C/C++セキュアコーディング 第2版』[6]が詳しいです。また今回のように、著名なソフトウェアを対象にどのようにして脆弱性を発見したかまでを含めた技術解説書として『Bugハンター日記』[7]があります。

それではみなさん、また次回お会いしましょう!

おすすめ記事

記事・ニュース一覧