curlでヒープオーバーフローが生まれた経緯
(daniel.haxx.se)- curl 8.4.0の公開とともに明らかになった CVE-2023-38545 は、SOCKS5プロキシ処理中に発生したヒープバッファオーバーフローで、curlのセキュリティ問題の中では珍しい HIGH 重大度の脆弱性
- 問題は2020年にSOCKS5接続コードをブロッキング呼び出しから ノンブロッキング状態マシン に変更する過程で入り込み、curl 7.69.0以降が影響を受ける
- リモート名前解決モードで、255バイトを超えるホスト名をローカル解決へ切り替えていた従来のロジックが状態マシンの再呼び出しと重なり、長すぎるホスト名 が小さなバッファにコピーされる可能性があった
- 攻撃が成立するには、libcurlクライアントがSOCKS5の proxy-resolver-mode と自動リダイレクトを使用し、攻撃者が制御するHTTPSサーバーが16KB超64KB以下のホスト名を含むHTTP 30x
Location:を返す必要がある - curl 8.4.0以降では、長すぎるホスト名に対してリモート解決をローカル解決に切り替えずエラーを返し、同じシナリオを防ぐテストが追加された
CVE-2023-38545とcurl 8.4.0
- curl 8.4.0 のリリースとともに、CVE-2023-38545 のセキュリティ勧告と詳細が公開された
- この問題は、curlで久々に出た最も深刻なセキュリティ問題と評価され、重大度は HIGH に指定された
- 中核となる欠陥は、SOCKS5プロキシ接続処理中に特定条件で発生する ヒープバッファオーバーフロー である
SOCKS5と名前解決方式
- curlは2002年8月から SOCKS5 をサポートしている
- SOCKS5は、中間サーバーを介してネットワーク通信を確立するプロキシプロトコルである
- Tor経由の通信設定に使われることがある
- 組織や企業の内部からインターネットへアクセスする際にも使われることがある
- SOCKS5にはホスト名の解決方式が2つある
- クライアントがホスト名を ローカルで解決 したうえで、解決済みアドレスをプロキシに渡す
- クライアントがホスト名全体をプロキシに渡し、プロキシが リモートで解決 する
脆弱性が入り込んだ2020年の変更
- 2020年初め、SOCKS5プロキシへ接続する関数がブロッキング呼び出しから ノンブロッキング状態マシン に変更された
- この変更は2020年2月14日にmasterへ入り、curl 7.69.0 に含まれた
- curl 7.69.0はこの改善が入った最初のリリースであり、結果としてCVE-2023-38545に対して脆弱な最初のリリースになった
- 変更の目的は、多数の並列転送がすべてSOCKS5を通過する場合に、より目立った改善をもたらすことだった
状態マシンで壊れた解決モード
- 状態マシン関数は、接続が確立されるまで、ネットワークデータが追加で入るたびに繰り返し呼び出される
- 関数冒頭の
socks5_resolve_localローカル変数は、curlがホスト名を自分で解決するか、名前をプロキシに渡すかを表す - この変数は、状態マシンが実行されるたびに、関数呼び出しの初期段階でプロキシモードに基づいて再設定される
- INIT状態の条件が問題を生んだ
- SOCKS5のホスト名フィールドは最大 255バイト までしか許容されない
- ホスト名が255バイトを超えると、SOCKS5プロキシはそれを解決できない
- 既存のcurlコードは、リモート解決モードで長すぎるホスト名に遭遇すると、
socks5_resolve_localをTRUEに変更して ローカル解決モード に切り替えていた
- ユーザーがリモート解決を要求しているなら、curlはモードを変えず失敗すべきだったが、以前に追加された切り替え動作がそのまま残っていた
ヒープオーバーフローが発生する流れ
- SOCKS5サーバーが十分に速くなく、状態マシンがさらに進むためのネットワークデータを受け取れない場合、関数は戻る
- その後データが来ると、同じ状態マシン関数が再び呼び出される
- 再呼び出し時、
socks5_resolve_localは関数冒頭でプロキシモードに基づいて再設定される- 前回の呼び出しで長すぎるホスト名のために
TRUEに変更された値は維持されない - 値は再び プロキシがリモートで名前を解決すべき状態 になる
- 前回の呼び出しで長すぎるホスト名のために
- curlは、プロキシへ送るプロトコルフレームをメモリバッファ内に作り、宛先情報をコピーする
- 誤った状態値のために長すぎるホスト名をそのまま渡そうとすると、割り当て済みの宛先バッファを超えて隣接する ヒープメモリ まで上書きする可能性がある
バッファサイズとホスト名の制約
- curlは、プロキシへ送るプロトコルフレームを作る際、通常のダウンロードバッファを再利用する
- ダウンロードバッファサイズの条件は次の通り
- デフォルト値は 16KB
- curlツールはバッファサイズを 100KB に設定する
- アプリケーションの要求に応じて別のサイズを使用できる
- 許容される最小サイズは 1024バイト
- バッファサイズが 65541バイト未満 の場合、このオーバーフローが可能になる
- バッファが小さいほど、可能なオーバーフローサイズは大きくなる
- URLのホスト名には実質的なサイズ制限はないが、libcurlのURLパーサーは 65535バイト を超える名前を拒否する
- DNSは最大 253バイト までのホスト名しか許容しない
- 253バイトを超える合法的な名前はまれで、1024バイトを超える実在の名前は事実上ほとんど見られないため、攻撃には意図的に非常に長いホスト名が必要になる
- URLのホスト名フィールドには一部のオクテットしか入れられず、不正なバイト値はURLパーサーが拒否する
- libcurlがIDNライブラリを使うようにビルドされている場合、そのライブラリも無効なホスト名を拒否する可能性がある
攻撃が成立する条件
- 攻撃者は、libcurlを使うクライアントがSOCKS5プロキシを proxy-resolver-mode で経由してアクセスするHTTPSサーバーを制御している必要がある
- 攻撃サーバーは、HTTP 30x 応答で細工したリダイレクトを返すことができる
- リダイレクトの
Location:ヘッダーには、次の形式の非常に長いホスト名を入れられるLocation: https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/- ホスト名の長さは 16KB超から64KB以下
- libcurlクライアントで自動リダイレクト追跡が有効で、SOCKS5プロキシがこのローカル変数の問題を引き起こすほど「十分に遅い」場合、細工されたホスト名が小さすぎるバッファにコピーされる
- この条件がそろうと、隣接するヒープメモリまで書き込みが発生し、ヒープバッファオーバーフロー が成立する
修正とバグバウンティ
- curl 8.4.0 以降では、長すぎるホスト名が原因でリモート解決からローカル解決へモードを切り替えることなく、エラーを返す
- 同じシナリオ向けの専用テストケースも追加された
- この問題はJay Satiroが報告、分析、パッチ作成を行った
- この脆弱性には、これまでcurlで支払われた中で最大のバグバウンティが支払われた
- 報告者に 4,660ドル
- IBB policy に基づき、curlプロジェクトに 1,165ドル
Cとメモリ安全な言語
- curlがCではなく メモリ安全な言語 で書かれていたなら、この系統の欠陥は発生しなかったはずである
- しかし、curlを別の言語へポートすることは現在の議題にはない
- 現実的なアプローチは2つに絞られる
- メモリ安全な言語で書かれた依存関係をより多く許容し、使用し、サポートする
- hyper の導入のように、curlの一部を段階的に置き換える
- こうした開発は現在非常にゆっくり進んでおり、関連する難点も明確に表れている
- curlは近い将来も Cで書かれた状態 のまま残るだろう
- curl 8.4.0で報告された最新のCVE 2件を含めると、これまでcurlで見つかったセキュリティ脆弱性のうち 41% は、メモリ安全な言語を使っていれば発生しなかった可能性がある
- ただし、C関連問題の初期の約 80% が入り込んだ時期には、Rustはこの目的での実用的な選択肢ではなかった
1315日後に見つかった欠陥
- この欠陥はコード内に 1315日 残っていた
- より良いテストセットがあれば発見できた可能性がある
- curlは複数の静的コード解析器を繰り返し実行しているが、この関数の問題はどの解析器も見つけられなかった
- 200億を超えるインストール環境に入ったコードでヒープオーバーフローを配布した経験は、推奨できるものではなかった
- 公開前の報告と対応プロセスは HackerOne report で確認できる
1件のコメント
Hacker News のコメント
これほど多くの機器が、事実上 ほとんどを一人が書いたライブラリに依存しているというのは、今でも驚き。負担は途方もなかったはずで、下の文章にもそれが表れている
「今コードを読むと、バグが見えないわけがない。自分がこのミスに気づかず、その欠陥が1315日間コードの中で発見されなかったという事実を受け入れるのは本当につらい。申し訳ない。私はただの人間にすぎない。」
Daniel がこれを見ているなら、一生懸命取り組んでくれてありがとう、謝る必要はまったくないと思う。結局、ソースは誰でも読んでレビューできるよう公開されていた
https://www.buzzfeed.com/chrisstokelwalker/the-internet-is-b...
「この報告は完全に正しそうで、心の底からつらい。」
ブログの「20億を超えるインストール環境に入ったコードにヒープオーバーフローを配布する経験はおすすめしたくない」もそうだし、これほど大きな責任をそれほど少ない報酬で背負わなければならないのは過酷だ
個人的には、どれだけ集中しようとしても、どれほど多くのことを見落としているのかをよく考えてきた。コードレビューのような活動は、こうした部分、つまり注意力の訓練や全体的・文脈的な認識を育てる良い方法に見える。例えば、何年も頻繁に通っていた場所に誰かがきれいに手入れした花壇があり、初めて気づいてから1年ほどは立ち止まって眺めていたのだが、数か月前、そのほんの数フィート横に対になるような別の花壇があることに初めて気づいた。その花壇は最初からあった可能性が高いが、存在自体を知らなかったので確かには分からない
これを人類全体の知識に広げて考えると、時にはたった一人が何かに気づき、その観察が行動を変え、私たち全員に広がり始めるように思える。時には何度もの試みと長い時間が必要になる。まだ誰も気づいていない 低いところに実った果実 がどれほど多いのかも気になるし、すでに注意を向けてきた人たちが築いた単純で根本的なベストプラクティスを取り込むだけでも、全員の最低水準を引き上げられると思う
Julia Evans の https://jvns.ca/blog/2023/10/06/new-talk--making-hard-things... と Dan Luu の https://danluu.com/p95-skill/ もこれに通じる。結論として、Stenberg と curl、そして毎日当たり前のように使っているインターネットインフラのさまざまな部分を作った人たちに大きな感謝を感じる
時に「壊れていないなら直すな」という原則が「壊れるまで支援するな」と解釈され、かなり不愉快な驚きを生む
持続可能性の問題はあるだろうが、こうしたプロジェクトは今後も続いていくと思うし、Daniel のように自分自身のためだけでなくコミュニティのために働くすべての人に敬意を表する
メモリ安全性と メモリ安全な言語 に関する結論は非常に合理的。要点はこうだ
curl が C ではなくメモリ安全な言語で書かれていたなら、この種の欠陥は起こり得なかったはずだ
その方向で実現可能かつ合理的だと考えられるアプローチは、メモリ安全な言語で書かれた依存関係をより許容・利用・支援し、hyper の導入のように curl の一部を段階的に少しずつ置き換えていくことだ
ただし、このような開発は現在ほとんど氷河のように遅く進んでおり、関連する難題を痛いほど鮮明に示している。curl は予測可能な将来も C のままだろう。気に入らない人は自分で腕まくりして作業すればよい
curl 8.4.0 で報告された最新の CVE 2件まで含めると、これまで curl で見つかったセキュリティ脆弱性の累計のうち41%は、メモリ安全な言語を使っていればおそらく発生しなかったはずだ。ただし Rust は、C 関連問題の最初の80%ほどが導入されていた時期には、この用途に実用的に使える選択肢ではなかった
複数の静的コード解析器を繰り返し実行したが、この関数で何の問題も見つけられなかった
すばらしい分析記事。ただしCVEまで読んでも、どの状況で影響を受けるのかはまだ不確か。自分の理解では、以下の条件を満たすと影響を受ける。
SOCKS5プロキシを使い、そのプロキシでホスト名を解決し、バッファサイズがデフォルトの100KBから65541バイト未満に変更されていて、SOCKS5プロキシがリクエストを即座に処理するには遅すぎる場合。
訂正: バッファに関する説明は正確ではない。libcurlはダウンロードバッファを再利用し、デフォルトは16KBだが、curl自体は
--limit-rateを使わない限り手動で100KBに設定するとのこと。また遅延条件も間違っていた。CVEによると、通常のサーバー遅延だけでもこのバグを引き起こすのに十分「遅い」可能性が高く、攻撃者がサービス拒否やSOCKSサーバーの制御によって影響を与える必要はないという。なので攻撃経路はかなり狭く見えるのだが、何か見落としているのか気になる。
root@1aac5e228e16:/build/curl-7.74.0# curl -vvv -x socks5h://host.docker.internal:9050 $(python3 -c "print(('A'10000), end='')")Trying 192.168.65.254:9050...* SOCKS5: server resolving disabled for hostnames of length > 255 [actual len=10000]* SOCKS5 connect to AAAAA...* Send failure: Bad file descriptor* Failed to send SOCKS5 connect request.Segmentation faulthttps://gist.github.com/xen0bit/0dccb11605abbeb6021963e2b1a8...
docker-proxyというシェルスクリプト群を作った。openconnect VPNを実行するDockerコンテナでSOCKS5トンネルを作ってくれる。複数の顧客企業の異なるVPNを扱っていて、それぞれのVPNがマシンの全トラフィックを自分側へ送ることを要求する場合に便利。https://github.com/carlosonunez/docker-proxy
DNS解決がリモートで行われるように設計されているので、このCVEはここに直接当てはまる。
これまで読んだCVE分析記事の中でも最高と言える。現代の中核的ソフトウェアの一つを作った人でありながら、記事全体を通じて謙虚な姿勢を示している点も称賛せざるを得ない。本当に尊敬に値する。
if(!socks5_resolve_local && hostname_len > 255) {socks5_resolve_local = TRUE;}これは本当にまずい考えだ。検閲回避ツールでプライバシーを守っている人にとっては、DNSを通じて身元が漏れる可能性がある。
著者も同じ考えを持っている。
https://hackerone.com/reports/2187833
「curlがCではなくメモリ安全な言語で書かれていれば、この種の欠陥は不可能だったはず」と言うが、不可能だったかもしれない、という程度だ。言語の仮想マシンの壁を抜ける方法があるかどうかは常に分からない。ただし著者はRFC1123に明記されたDNSホスト名の制限を明らかに無視しており、この制限はJavaライブラリにもハードコードされている。
https://www.rfc-editor.org/rfc/rfc1123
「URLのホスト名には実質的なサイズ制限はないが、libcurlのURLパーサーは65535バイトより長い名前を拒否する。DNSは最大253バイトのホスト名しか許可しない。したがって253バイトより長い合法的な名前はまれだ。1024バイトより長い実在の名前は、事実上聞いたことがない。」
DNSは今日では99.9%の場合に使われているとはいえ、ホスト名をアドレスに解決する唯一のメカニズムではない。
RFCがホスト名サイズの上限をどこで定めているのか見つけられない。
コンパイラが、メモリが境界外でアクセスされず、明確な所有権なしにアクセスされず、基盤となるオブジェクトの生存期間内でのみアクセスされることを証明しようとする、という意味でもあり得る。たとえばRustがそうしている。
もちろんコンパイラが重要な箇所に内部の失敗チェックや保護機構を追加することもあり得る。Rustはそうしていないが、レジスタ内のビット反転のようなものを心配しなければならないシステムでは、あるとよいかもしれない。たとえばX線スキャナーのような高放射線環境で、ECCメモリでは捕捉されない場合などだ。
悪用するには非常に具体的な条件が必要な案件にしては、誇張やドラマがかなり多い
昨日出た Windows 10 のセキュリティ更新プログラムには、新しい curl バージョンは含まれていなかった。Windows に入っている curl はまだ 8.0.1 のまま
長くて興味深い記事だが、「つまりシステムライブラリは安全な言語で書かれるか、正確性が証明されるべきだ」と要約できる
現代最高の、最も親切で透明性の高い C プログラマーの一人がこういう文章を書いているなら、注意を払うべきだ