※本記事は筆者RyotaKが英語で執筆した記事を、弊社セキュリティエンジニアkoyuriが日本語に翻訳したものになります。
はじめに
こんにちは、Flatt SecurityでセキュリティエンジニアをしているRyotaK( @ryotkak )です。 先日、特定の条件を満たした場合に攻撃者がWindows上でコマンドインジェクションを実行できる、いくつかのプログラミング言語に対する複数の脆弱性を報告しました。
本日(2024/04/09(訳者注: これは英語版記事の公開日です))、影響を受けるベンダーがこれらの脆弱性に関するアドバイザリーを公表しました。 その影響は限定的なもののCVSSスコアは非常に高く、混乱が予想されるため、脆弱性に関する詳細を本記事にまとめます。
TL;DR
BatBadButは、CreateProcess
関数に間接的に依存するWindowsアプリケーションにおいて、特定の条件を満たした場合に攻撃者がコマンドインジェクションを実行できるという脆弱性です。
CreateProcess()
は、バッチファイル(.bat
、.cmd
など)の実行時にアプリケーションがコマンドラインで指定していなくても、暗黙的にcmd.exe
を実行します。
ここで問題になってくるのが、cmd.exe
にはコマンド引数に関して複雑なパースルールがあり、プログラミング言語のランタイムがコマンド引数を適切にエスケープできないことです。
このため、バッチファイルのコマンド引数の部分を操作できれば、コマンドインジェクションが可能です。
例えば、次のシンプルなNode.jsのコードは、サーバー上でcalc.exe
を実行します:
const { spawn } = require('child_process'); const child = spawn('./test.bat', ['"&calc.exe']);
この事象は、CreateProcess()
に渡されるコマンドラインにバッチファイルが明示的に指定されている場合にのみ発生し、.exe
ファイルが指定されている場合は発生しません。
しかしながら、WindowsではデフォルトでPATHEXT
環境変数に.bat
や.cmd
ファイルが含まれているため、開発者が実行しようとしたコマンドと同じ名前のバッチファイルが存在すると、ランタイムが開発者の意図に反してバッチファイルを実行してしまうことがあります。そのため、以下のコードのように.bat
や.cmd
の拡張子が明示的に指定されていない場合には、任意のコマンドを実行してしまう可能性があります:
cmd := exec.Command("test", "<your-input-here>") cmd.Run()
この挙動を引き起こすためには、以下の条件が必要です:
- アプリケーションがWindowsでコマンドを実行している
- アプリケーションがコマンドの拡張子を指定していない、または拡張子が
.bat
や.cmd
である - 実行されているコマンドが、引数の一部としてユーザー入力を受け取る
- プログラミング言語のランタイムが
cmd.exe
のコマンド引数を適切にエスケープできない1
これらの挙動が悪用されると、任意コマンド実行が可能になる恐れがあります。 みなさんのアプリケーションがこの脆弱性の影響を受けるかどうか判断できるようにフローチャートを作成したので、影響を受けるかどうかわからない場合はAppendix Aを、影響を受けるプログラミング言語の状況についてはAppendix Bを参照してください。
CVSSスコア
本題に入る前に、ライブラリの脆弱性に対するCVSSスコアをそのままアプリケーションに適用するのは避けるべきである、ということに言及しなければなりません。CVSS v3.1のユーザーガイドには、「ライブラリのCVSSスコアは最悪のシナリオを想定して計算されるべき」と記されています。つまり、特定の条件が揃わないと問題が発生しない場合でも、高いスコアがついてしまうことがあるのです。
実際のアプリケーションにCVSSスコアを適用する際は、ライブラリのスコアをそのまま適用するのではなく、アプリケーションの仕様に基づいてスコアを再計算する必要があります: https://www.first.org/cvss/v3.1/user-guide#3-7-Scoring-Vulnerabilities-in-Software-Libraries-and-Similar
技術詳解
筆者はインターネットの広範囲に影響を与える脆弱性でない限り、名前を付けるのはあまり好きではないのですが、ダジャレが大好きです。この脆弱性はバッチ (BATch) ファイルに関するもので、確かに悪い (BAD) ものではありますが (BUT)、最悪というほどではありません。そこで、この脆弱性を「BatBadBut」と名付けました。
ここからは、BatBadButの技術的な側面と、なぜコマンドインジェクションが可能なのかについて説明していきます。 ただし、いくつかのコードスニペットは最新バージョンのランタイムでは動作しないことに注意してください。
根本原因
BatBadButの根本的な原因は、WindowsのCreateProcess
関数で見逃されてきた動作にあります。
Windowsはcmd.exe
無しでバッチファイルを実行できないため、CreateProcess
関数でバッチファイルを実行すると、Windowsは暗黙的にcmd.exe
を呼び出します。
例えば次のコードでは、バッチファイルtest.bat
を実行するためにC:\Windows\System32\Command.exe /c .\test.bat
というコマンドが使用されます:
wchar_t arguments[] = L".\\test.bat"; STARTUPINFO si{}; PROCESS_INFORMATION pi{}; CreateProcessW(nullptr, arguments, nullptr, nullptr, false, 0, nullptr, nullptr, &si, &pi);
これ自体は問題でないものの、プログラミング言語がCreateProcess
関数をラップしコマンド引数のエスケープ機構を追加したときに問題が発生します。
CreateProcess
のラッパー
ほとんどのプログラミング言語は、CreateProcess
関数をラップした、コマンドを実行する用のインタフェースを提供しています。
例えば、Node.js2のchild_process
モジュールはCreateProcess
関数をラップしており、以下のように引数を指定することでコマンドを実行できるようになっています:
const { spawn } = require('child_process'); const child = spawn('echo', ['hello', 'world']);
上のコードを見てわかるように、spawn
関数はコマンドとその引数を別々の引数として受け取ります。
その後、引数を内部でエスケープしてCreateProcess
関数に渡します。
/* * Quotes command line arguments * Returns a pointer to the end (next char to be written) of the buffer */ WCHAR* quote_cmd_arg(const WCHAR *source, WCHAR *target) { [...] /* * Expected input/output: * input : hello"world * output: "hello\"world" * input : hello""world * output: "hello\"\"world" * input : hello\world * output: hello\world * input : hello\\world * output: hello\\world * input : hello\"world * output: "hello\\\"world" * input : hello\\"world * output: "hello\\\\\"world" * input : hello world\ * output: "hello world\\" */ *(target++) = L'"'; start = target; quote_hit = 1; for (i = len; i > 0; --i) { *(target++) = source[i - 1]; if (quote_hit && source[i - 1] == L'\\') { *(target++) = L'\\'; } else if(source[i - 1] == L'"') { quote_hit = 1; *(target++) = L'\\'; } else { quote_hit = 0; } } target[0] = L'\0'; _wcsrev(start); *(target++) = L'"'; return target; }
多くの開発者は、spawn
関数がコマンド引数を適切にエスケープしてくれると期待することでしょう。
実際、ほとんどの場合は開発者の期待通りに動作します3。
しかし先ほど述べたように、バッチファイルを実行する場合、CreateProcess
関数は暗黙的にcmd.exe
を呼び出します。
そして厄介なことに、cmd.exe
は通常のエスケープ機構とは異なるエスケープ規則を持っています。
cmd.exe
のパース規則
Unixライクなシステムのほとんどのシェルには似たような(あるいは同じ)エスケープ規則があり、バックスラッシュ(\
)がエスケープ文字として使われます。
例えば、ダブルクォートで囲まれた文字列中のダブルクォート("
)をエスケープしたい場合は、次のようにバックスラッシュを使用します:
echo "Hello \"World\""
バックスラッシュをエスケープ文字として使用するのはデファクトスタンダードであり、JSONやYAMLといった他の形式でも使用されています。
しかし、次のコマンドをコマンドプロンプトで実行すると、calc.exe
が実行されてしまいます:
echo "\"&calc.exe"
これは、コマンドプロンプトがバックスラッシュをエスケープ文字として扱わず、代わりにキャレット(^
)を使うためです4。
child_process
の例に戻ると、コマンド引数のダブルクォート("
)をバックスラッシュ(\
)でエスケープしています。
前述のcmd.exe
のエスケープ規則により、バッチファイルを実行する場合はこのエスケープだと不十分です。次のスニペットでは、引数が適切に区切られシェルオプション5が有効になっていないにもかかわらず、calc.exe
が実行されます:
const { spawn } = require('child_process'); const child = spawn('./test.bat', ['"&calc.exe']);
この挙動によって、悪意のあるコマンドライン引数を使ったコマンドインジェクションが発生するリスクがあります。 これがBatBadButの主な問題です。
対策
"
をエスケープするには?
ここでの問題は、ダブルクォートで囲まれた文字列が、文字列の中にあるダブルクォートによって壊れてしまうことです。
よって、ダブルクォート("
)をキャレット(^
)でエスケープすれば、コマンドインジェクションを防ぐのに十分なように思えます6。
しかし実際には、それだけではコマンドインジェクションを防ぐことはできません。
なんとコマンドプロンプトは、他のどのパースよりも先に変数(例えば、%PATH%
)をパースし展開するのです。
つまり次のコマンドでは、&calc.exe
がダブルクォートで囲まれた文字列の中にあるにも関わらずcalc.exe
が実行されてしまうのです:
SET VAR=^" echo "%VAR%&calc.exe"
Windowsの環境変数は通常、値にダブルクォート("
)を含みません。しかし、CMDCMDLINE
という特別な変数には、現在のコマンドプロンプトセッションを開始するために使用されたコマンドラインが格納されています。
PowerShellで以下のコマンドを実行したとすると、"C:\WINDOWS\system32/cmd.exe" /c "echo %CMDCMDLINE%"
と出力されるはずです:
cmd.exe /c "echo %CMDCMDLINE%"
そして、コマンドプロンプトで変数の部分文字列抽出を利用することで、この変数からダブルクォート("
)を取り出すことができます。
つまり、以下のコマンドをPowerShell上で実行すると、calc.exe
が実行されます:
cmd.exe /c 'echo "%CMDCMDLINE:~-1%&calc.exe"'
この挙動により、ダブルクォートをキャレットでエスケープするだけではバッチファイル実行時のコマンドインジェクションを防ぐには不十分であり、さらなるエスケープが必要となります。 これについては次のセクションで説明します。
開発者として
すべてのプログラミング言語がこの問題を修正したわけではないので[^2]、Windows上でコマンドを実行する際には注意が必要です。
Windows上でコマンドを実行したいがバッチファイルは実行したくないという場合であれば、常にコマンドのファイル拡張子を指定すべきです。
例えば次のコードスニペットでは、ユーザーがPATH
環境変数に含まれるディレクトリにtest.bat
を置くと、test.exe
ではなくtest.bat
が実行されてしまう可能性があります:
cmd := exec.Command("test", "arg1", "arg2")
これを防ぐには、次のようにコマンドの拡張子を常に指定するべきです:
cmd := exec.Command("test.exe", "arg1", "arg2")
バッチファイルを実行したいがランタイムがバッチファイルのコマンド引数を適切にエスケープしない場合は、コマンド引数として使用する前にユーザー入力をエスケープしなければなりません。
スペースはダブルクォートで囲まれた文字列の外側では正しくエスケープされないため7、コマンド引数をダブルクォートで囲む必要があります。
しかし、ダブルクォートで囲まれた文字列の中では、%
が正しくエスケープされません8。
この状況を打開するためには、次のようなトリッキーなエスケープが必要になります:
- ランタイムによるバックスラッシュを用いた自動エスケープを無効化する。
- 以下の処理をそれぞれの引数に対して適用する:
%
を%%cd:~,%
で置換する。"
の前にある\
を\\
で置換する。"
を""
で置換する。- 改行文字
\n
を取り除く。 - 引数を
"
で囲う。
%
を%%cd:~,%
に置き換えると、%cd:~,%
は空文字列に展開され、コマンドプロンプトは実際の変数を展開できなくなるので、%
は通常の文字として扱われるようになります。
なお、レジストリ値DelayedExpansion
で遅延展開が有効になっている場合は、cmd.exe
を呼び出す際に明示的に/V:OFF
オプションを付けることで無効化する必要があることに注意してください。
また、%
のエスケープにはコマンド拡張が有効になっている必要があることにも注意してください。レジストリ値EnableExtensions
によって無効になっている場合は、/E:ON
オプションで有効にする必要があります。
ユーザーとして
バッチファイルの意図しない実行を防ぐためには、バッチファイルをPATH
環境変数に含まれないディレクトリに移動することを検討すべきです。
これにより、フルパスを指定しない限り実行されなくなるため、誤って実行されるリスクを効果的に回避できます。
ランタイムのメンテナとして
もしプログラミング言語のランタイムを開発しているのであれば、バッチファイル用に追加のエスケープ機構を実装することをお勧めします。 もしランタイム層での修正が難しい場合でも、この問題はあまり知られていないので、少なくともこの問題をドキュメント化してユーザーに適切な警告を出すべきです。
まとめ
今回の記事では、特定の条件が満たされた場合に攻撃者がWindows上でコマンドインジェクションを実行できる脆弱性、BatBadButの技術的な詳細について説明してきました。 この記事で何度か述べたように、この問題はほとんどのアプリケーションには影響しませんが、万が一影響を受ける場合は、コマンド引数を手動で適切にエスケープする必要があります。
この記事が、BatBadButの重大性を理解し、問題を適切に対策するのに役立てば幸いです。
お知らせ
Flatt Security ではWebアプリケーションをはじめとする、様々なプロダクトへのセキュリティ診断サービスを提供しています。仕様・実装に不安のある方はぜひお気軽にお問い合わせください。
下記バナーより料金に関する資料もダウンロード可能です。
また、Flatt Security はセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式X のフォローをぜひお願いします!
では、ここまでお読みいただきありがとうございました。
Appendix
Appendix A: アプリケーションが影響を受けるかどうかのフローチャート
Appendix B: 影響を受ける言語の状況
言語 | 状況 |
---|---|
Erlang | ドキュメント更新 |
Go | ドキュメント更新 |
Haskell | パッチ公開 |
Java | 修正予定無し |
Node.js | パッチ公開 |
PHP | パッチ公開 |
Python | ドキュメント更新 |
Ruby | ドキュメント更新 |
Rust | パッチ公開 |
- 影響を受けるプログラミング言語の状況はAppendix Bを参照してください。↩
- 筆者は主にNode.jsを使っているので、ここではNode.jsを例にしました。しかし、この問題はNode.jsに限ったことではなく、他のプログラミング言語にも影響します。↩
- 実際、多くのプログラミング言語では、コマンド引数が適切にエスケープされることを保証しているか、あるいはシェルを使用していません。↩
- バックスラッシュをキャレットに置き換えても、ダブルクォートは正しくエスケープされないので、さらにエスケープが必要になることに注意してください。↩
-
シェルオプションを無効にすると、
child_process
モジュールはcmd.exe
を実行せず、代わりにコマンドを直接実行します。しかし、Windowsはバッチファイルを実行する際に暗黙的にcmd.exe
を起動するので、バッチファイルを実行するときはシェルオプションは無視されます。↩ - もちろん、キャレット自身のエスケープも必要です。↩
-
.\test.bat arg1^ arg2
を実行すると、arg1
とarg2
は別々の引数として認識されます。↩ -
.\test.bat "100^%"
の引数は100%
ではなく100^%
と認識されます。↩