※本記事は筆者RyotaKが英語で執筆した記事を、弊社セキュリティエンジニアShion1305が日本語に翻訳したものになります。
はじめに
こんにちは、Flatt SecurityでセキュリティエンジニアをしているRyotaK(@ryotkak)です。
2023年にPortSwigger社のJames Kettle氏は、同社の記事でSingle-packet attack
という新しい攻撃手法を提案しました。これはネットワークのジッター値に関係なくレースコンディションを悪用できるというものです。
最近私は、同時に約10,000件のリクエストを送信することで安定して成立するレースコンディションを発見し、Single-packet attackを適用しようとしました。しかし、通常のSingle-packet attackでは送信できるリクエストの合計サイズが約1,500バイトに制限されていたため、実際に攻撃を成立させることはできませんでした。
そこで、この制限を回避する方法を模索した結果、1パケットあたり1,500バイトの制限、さらにはTCPの65,535バイトの制限を突破する手法を発見しました。
本記事では、従来の手法の制限を突破した手法とその可能性について紹介します。
- はじめに
- TL;DR
- Single-packet attackの制限
- IPパケットの断片化
- TCPとシーケンス番号
- シーケンス番号を利用したTCPパケットの同期 First Sequence Sync
- テクニックの組み合わせ
- First Sequence Syncの制限要因
- First Sequence Syncの検証実験
- 改善点
- まとめ
- お知らせ
TL;DR
Single-packet attackの制限を回避するために、IPパケットの断片化とTCPシーケンス番号の並べ替えを利用しました。
IPパケットの断片化を利用することで、1つのTCPパケットを複数のIPパケットに分割することができ、TCPのウィンドウサイズを最大限活用できます。また、TCPシーケンス番号を並び替えることで、最後のパケットを送信するまでサーバーのTCPパケットの処理を待機させられるようにしました。
この手法により、軽微なレースコンディションにおいても、ワンタイムトークン認証のバイパスといった重大な脆弱性へ発展する可能性が示されました。
検証環境では、166msで10,000件のリクエストを送信できることが確認されています。
Single-packet attackの制限
James氏の記事では、Single-packet attackで同時に送信できるパケット数は20~30に制限されると述べています。
TCP has a soft limit of 1,500 bytes as well and I never explored how to push beyond this because 20-30 requests is sufficient for most race conditions.
この制限により、例えば数字数桁で構成されたワンタイムトークンの認証におけるレートリミットにレースコンディションがある場合においても、Single-packet attackを適用することが困難になります。
IPパケットの断片化
Single-packet attackの1,500バイト制限を説明するために、イーサネットフレーム、IPパケット、TCPパケットの関係について理解する必要があります。
イーサネット上にTCPパケットを送信した時、TCPパケットはIPパケットに内包され、IPパケットはイーサネットフレームに内包されます。
イーサネットフレームの最大サイズはイーサネットヘッダー(14バイト)とフレームチェックシーケンス(4バイト)を含めて1,518バイトです。したがって、1つのイーサネットフレームに入るIPパケットの最大サイズは1,500バイトになります1。
これが、James氏がTCPの制限として1,500バイトを挙げた理由です。しかしながら、IPパケットが1,500バイトに制限されているにも関わらず、TCPパケットの最大サイズが65,535バイトに設定されているのはなぜでしょうか?
これは、IPパケットがフラグメンテーション(断片化)をサポートしているためです。RFC791では以下のような記述があります。
https://datatracker.ietf.org/doc/html/rfc791
Fragmentation of an internet datagram is necessary when it originates in a local net that allows a large packet size and must traverse a local net that limits packets to a smaller size to reach its destination.
このIPパケットの断片化を用いると、元のIPパケットは複数の小さなIPパケットに分割され、それぞれ異なるイーサネットフレームに含まれます。
断片化されたIPパケットは、すべてのフラグメントが受信されるまでTCP層に渡されないため、大きなTCPパケットが複数のIPパケットに分割されても、問題なく送信することができます。
TCPとシーケンス番号
IPパケットの断片化を利用することで、最大65,535バイト2のTCPパケットを送信できるようになりました。しかし、多数のリクエストを同時に送信するにはまだ不十分です。
単一のTCPパケットではTCPウィンドウサイズ以上のデータは送信できません。そこで、複数のTCPパケットを同時に送信する方法を考えます。
TCPパケットの順序はシーケンス番号によって管理されます。
サーバーはTCPパケットを受信すると、パケットを一時的に保持した上で、シーケンス番号に基づいてパケットの順序を再編成します。(RFC9293)
https://datatracker.ietf.org/doc/html/rfc9293#section-3.10-8
A natural way to think about processing incoming segments is to imagine that they are first tested for proper sequence number (i.e., that their contents lie in the range of the expected "receive window" in the sequence number space) and then that they are generally queued and processed in sequence number order.
シーケンス番号を利用したTCPパケットの同期 First Sequence Sync
シーケンス番号による順序保証を用いることで、複数のTCPパケットを同時に処理させることが可能です。 例えば、次のようなTCPパケットを送信する場合を考えてみましょう。
パケット | シーケンス番号 |
---|---|
A | 1 |
B | 2 |
C | 3 |
サーバーはシーケンス番号の順序でパケットを処理します。そのため、B、C、Aの順序で受信した場合、パケットAを受信するまでパケットの処理を開始することができません。
つまり、最初のシーケンス番号のパケットを最後に送信することで、サーバーのパケット処理を待機させることが可能です。言い換えると、最初のシーケンス番号を持つパケットを受信した際に、サーバーに対して複数のパケットを強制的に同時処理させることが可能です。
テクニックの組み合わせ
これまでに説明したテクニックを組み合わせることで、リクエストのサイズに関係なく大量のリクエストを同時に送信することが可能です。
まず、クライアントは、サーバーとTCP接続を確立してHTTP/2のストリームを開始します。次に、それぞれのリクエストについて最終バイトを除いたデータを送信します。アプリケーションは全てのバイトを受信するまで待機するため、この時点ではリクエストは処理されません。
そして、クライアントはそれぞれのリクエストの最終バイトが含まれたTCPパケットを、IPパケットの断片化を用いて3送信します。この時、サーバーはTCPパケットを誤った順序で受信するため、全てを受信するまでパケットの処理を待機します。
最後に、送信された全てのパケットをサーバーが受け取った後、クライアントは最初のシーケンス番号のTCPパケットを送信します。これでサーバーは全てのリクエストの処理を同時に行います。
First Sequence Syncの制限要因
これまでに説明したFirst Sequence Syncは上手く機能するように見えますが、同時に送信できるリクエスト数が制限される要因が複数あります。
1つ目はサーバーのTCPバッファサイズです。サーバーは受信したパケットを再編成するまでバッファに溜め込む必要があります。そのため、サーバーには、誤った順序で受け取ったパケットを溜め込むために十分なバッファサイズが必要となります。
幸いなことに、最近のサーバーは通常、大容量のRAMを備えており、ほとんどのOSにはデフォルトでパケットを格納するのに十分なバッファがあります。そのため、バッファサイズが問題になることはほとんどありません。
2つ目は、同時に開くことができるストリーム数の設定です。HTTP/2では、SETTINGS_MAX_CONCURRENT_STREAMS
というパラメータによって制限されます。たとえば、サーバーがSETTINGS_MAX_CONCURRENT_STREAMS
を100に設定している場合、サーバーは1つのTCP接続で同時に100のリクエストしか処理できません。
これは、First Sequence Syncにおいては大きな問題となります。シーケンス番号によるTCPパケットの同期を行うためには、1つのTCP接続でリクエストを送信する必要があるからです4。
残念ながら、ApacheやNginxといった一般的なHTTPサーバーは、SETTINGS_MAX_CONCURRENT_STREAMS
に厳しいデフォルト値を設定しています。
実装 | SETTINGS_MAX_CONCURRENT_STREAMS のデフォルト値 |
---|---|
Apache httpd | 100 |
Nginx | 128 |
Go | 250 |
とはいえ、RFC 9113ではSETTINGS_MAX_CONCURRENT_STREAMS
の初期値は無制限となっていて、一部のフレームワークでは緩いデフォルト値が設定されています。
実装 | SETTINGS_MAX_CONCURRENT_STREAMS のデフォルト値 |
---|---|
nghttp2 | 4294967295 |
Node.js | 42949672955 |
そのため、本手法はサーバーが使用するHTTP/2の実装によっては、非常に強力なものとなる可能性があります。
First Sequence Syncの検証実験
First sequence Syncはどのような効果があるのか、どのようにレースコンディションに対して悪用できるのかを検証しました。検証実験には以下のような環境を用いました。
サーバー | クライアント | |
---|---|---|
プラットフォーム | AWS EC2 | AWS EC2 |
OS | Amazon Linux 2023 | Amazon Linux 2023 |
カーネルバージョン | 6.1.91 | 6.1.91 |
インスタンスタイプ | c5a.4xlarge | t2.micro |
リージョン | sa-east-1 | ap-northeast-1 |
これらのサーバーは地理的にほぼ地球の反対側に位置しており、サーバー間のネットワーク遅延は約250msです。
クライアントマシンにiptables
を設定し、サーバーへのRSTパケットの送信を防ぎました:
iptables -A OUTPUT -p tcp --tcp-flags RST RST -s [IP] -j DROP
まず、First sequence Syncを使用して10,000件のリクエストを同期し、リクエスト送信にかかる時間を計測します。
ベンチマークに使用したコードは、リポジトリのrc-benchmarkフォルダにあります6。
ベンチマークの結果は以下の通りです。
メトリクス | 値 |
---|---|
総時間 | 166460500ns |
リクエスト間の平均時間 | 16647ns |
リクエスト間の最大時間 | 553627ns |
リクエスト間の中央値 | 14221ns |
リクエスト間の最小時間 | 220ns |
約166msで10,000件のリクエストを送信することができました7。これは1リクエストあたり0.0166msに相当します。サーバー間のネットワーク遅延が約250msであることを考えると、非常に高速です。
次に、レースコンディションのあるワンタイムトークン認証に対する攻撃をシミュレーションします。
この検証に使用したコードは、リポジトリのrc-pin-bypassフォルダにあります8。
サーバーのプログラムでは認証試行回数を最大5回に制限していましたが、レースコンディションにより1,000回の試行を行うことができ、レートリミットを回避することができました。
last byte sync
を用いて同じ攻撃を行った場合に約10回しか試行できなかったことを考えると、この攻撃手法は非常に信頼性が高く、効率的です。
改善点
前例では、First Sequence Syncの有用性が確認できましたが、攻撃手法の信頼性と効率性を高めるためにまだ改善点はあります。
HTTPS対応: 現在のPoCでは、プレーンテキスト接続でのHTTP/2のサポートを必要とします。しかし、ブラウザはTLS経由でのHTTP/2のみをサポートするため、一部の実装ではプレーンテキストをサポートしていないことがあります。より広いケースに適用するためには、TLSに対応したPoCを実装する必要があります。
ターゲットサーバーがTCPウィンドウを更新するケースへの対応: 現在のPoCでは、ターゲットサーバーがリクエスト送信中にTCPウィンドウを更新するケースに対応していません。そのため、TCPウィンドウが更新されてしまうと攻撃が失敗する可能性が高いです。
既存のプロキシツールとの統合: 現在のPoCは柔軟性に欠けており、ヘッダーの追加やリクエストボディの修正にはコードの変更が必要です。Burp SuiteやCaidoなどの既存のプロキシツールと統合することで、リクエストやヘッダーを簡単に変更できるようになります。
- しかし、プロキシツールはOSI参照モデルにおける第7層で動作するため、第3/4層でのこのテクニックを組み合わせることは困難である可能性があります。
まとめ
この記事では、Single-packet attackの限界を突破するための手法として、First Sequence Sync
を紹介しました。本手法は、サーバーで使用されるHTTP/2の実装によっては非常に強力なものとなる可能性があり、従来の技術では攻撃が困難な脆弱性に対しても有効です。
まだこのテクニックは改善の余地があるものの、従来の方法では悪用が不可能だった脆弱性において非常に有用であると考えています。すでに、前述で紹介した例では有用であることが示されており、このテクニックを用いた他の事例が報告されることを願っています。また、このテクニックを応用した新しいツールの開発されることも期待しています。
お知らせ
※以降は元記事の翻訳ではありません。
株式会社Flatt Securityでは本記事で紹介したようなリサーチ活動の成果を社内に積極的に還元し、セキュリティ診断・ペネトレーションテストといったサービスの専門性を高め続けています。また、セキュリティ診断業務に従事するセキュリティエンジニアを積極募集中です。弊社の環境で専門性を高めていくことにご興味のある方は、ぜひ以下のフォームからカジュアル面談をお申し込みください。
非常に高い採用ハードルが設定されていると思われることもありますが、新卒採用も含め熱意のある方をサポートし徐々に実務へ参加していく仕組みもございますので、セキュリティ診断の実務経験をお持ちの方は気負わずご連絡ください。開発からセキュリティ未経験でキャリアチェンジしたメンバーも在籍しています。
その他、Flatt Securityでの働き方やメンバー、待遇や福利厚生については以下よりご覧ください。
ここまでお読みいただきありがとうございました。
- ジャンボフレームは例外ですが、通信経路上のすべてのネットワークデバイスがこれをサポートする必要があるため、本記事では考慮しません。↩
- 実際は、サーバーで設定されているTCPウィンドウサイズに依存します。TCPウィンドウサイズのデフォルトの最大値は65,535バイトですが、RFC7323ではTCPウィンドウサイズの最大値を1 GiBまで拡張できるWindow Scale Optionが定義されています。しかし、サーバーにこのオプションを使用するよう強制することはできません。私が検証環境においてテストした限りでは、通常約62,000〜63,000バイトに設定されていることが多かったです。↩
- 理論上は、IPパケットによる断片化は必要ありません。クライアントはパケットを順序関係なく送信できるため、複数のTCPパケットを好きなだけ同期させることが可能であるはずです。しかし、実際にIPパケットによる断片化を用いずに検証をしたところ、攻撃が安定しなかったため、IPパケットによる断片化を使用しています。↩
-
last byte sync
を用いずにリクエストを同期することは可能ですが、同時に処理されるリクエスト数はSETTINGS_MAX_CONCURRENT_STREAMS
によって制限されてしまいます。↩ -
Node.jsは内部でnghttp2を使用しており、
SETTINGS_MAX_CONCURRENT_STREAMS
の設定をnghttp2から引き継いでいます。↩ -
MaxConcurrentStreams
のGoにおけるデフォルト値は250であるため、本実験では意図的に10,000に設定しています。MaxConcurrentStreams
のデフォルト値は実装によって異なり、現実世界では大きな制限値が設定されている場合も珍しくありません。↩ - この値は受信サーバーの性能に大きく依存します。t2.microインスタンスで試した際は、完了までに約500msかかりました。↩
- この実装では、攻撃を確実に再現するために3桁のPINを使用しています。同じ実装で4桁のPINを使用した際には約2,000回の試行が可能でした。4桁のPINでも攻撃は可能ですが、全てのPINの組み合わせを試すことはできませんでした。↩