2017年9月7日木曜日

診断文字列を打ち込まずにPHPのバージョンを推測する

脆弱性診断においてApacheのバージョンを外部から調べる方法を複数の専門家がブログ記事に書いておられます。
いずれも大変興味深いものですが、ApacheでできるのであればPHPはどうだろうかと気になる方も多いと思います。これは人間の自然な感情だと思うのです。
このあたり、各診断会社の「秘伝のタレ」みたいなところもあるのでしょうが、私からも少し知見を披露したいと思います。

タイトルにも書いたように、診断文字列を打ち込まずに、言い換えれば、通常のウェブ閲覧の範囲で分かること、さらに言えばHTTPレスポンスヘッダから分かることについて書きます。こういうと、「X-Powered-Byヘッダを見れば一目瞭然www」みたいな反応も考えられますが、そういう自明なものは対象外とします。

(1) キャッシュ制御のヘッダ

PHPは、session_start()関数の実行によりセッション管理を有効にすると、キャッシュ制御用のレスポンスヘッダを自動的に生成します。この挙動は、session_cache_limiter() 関数により変更可能ですが、大半のサイトはデフォルトの nocache のまま使っていると思います。これは通常安全な設定ですが、CDNによっては注意が必要です。
このうちのCache-Controlヘッダですが、PHPのバージョンにより、以下のように変わります。

PHP 4.0.0~4.0.2 Cache-Control: no-cache, post-check=0, pre-check=0
PHP 4.0.3~5.6.x Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
PHP 7.0.0~  Cache-Control: no-store, no-cache, must-revalidate

PHP 4.0.0等を使っているサイトは今時多くないとは思いますが、PHP 5なのか、PHP 7なのかの違いには使えそうです。

(2) setcookie関数に空文字列を指定した場合の挙動

以前、ログアウト機能の目的と実現方法にて書きましたが、徳丸自身はログアウト時にセッションクッキーを削除する必要はないと考えていますが、PHP本家のマニュアルには、ログアウト時にはセッションIDのクッキーを削除しなければならない(原文は"the session cookie must be deleted")とあります。このため、ログアウト時にセッションクッキーを削除するサイトはそれなりに見かけます。前述のマニュアルには、以下のようにセッションクッキーの削除スクリプトの例まで紹介されているので、これをコピペして用いる場合も多いかと思います。
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
    $params["path"], $params["domain"],
    $params["secure"], $params["httponly"]
);
これ、パッと見は、42000秒(11時間40分)前のExpireを指定したSet-Cookieを発行するように見えますが、実はそうではない…ということは以前の記事に書きました。これは、setcookie関数の第2引数に空文字列を指定した場合の特別な挙動です。そして、この挙動がPHPのバージョンによって異なります。

PHP 4.0.0~4.0.1  Set-Cookie: PHPSESSID=deleted; expires=Tuesday, 06-Sep-16 07:46:47 GMT; path=/
PHP 4.0.2~4.2.x  Set-Cookie: PHPSESSID=deleted; expires=Tue, 06-Sep-16 07:46:47 GMT; path=/
PHP 4.3.0~5.3.6  Set-Cookie: PHPSESSID=deleted; expires=Tue, 06-Sep-2016 07:46:48 GMT; path=/
PHP 5.3.7~5.4.x  Set-Cookie: PHPSESSID=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/
PHP 5.5.0~       Set-Cookie: PHPSESSID=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0; path=/

expires属性の値と、Max-Age属性の有無によって、PHPのバージョンが推測できます。特に、PHP 5.3.6まで expiresが約1年前の日時だったのに対して、PHP 5.3.7以降では1970年1月1日になるところが使えそうですね。PHP 5.5以降でMax-Age=0; が付与されるのも有用です。

(3) setcookieに空文字列を指定されたのかを判別する

しかし、上記の方法には欠点があり、本当にsetcookie関数の第2引数に空文字列が指定されたのか、実は第2引数に deleted を明示したのかは簡単には分かりません。しかし、Set-Cookieの挙動をよく見ると、これらを区別できる場合があります。
まず、PHP 5.3.6までは「約1年前」のexpiresと前述しましたが、厳密には1年+1秒前のExpiresになります。PHP 5.3.6でのレスポンスヘッダの例を下記に示します。
HTTP/1.1 200 OK
Date: Wed, 06 Sep 2017 08:38:34 GMT
Server: Apache/2.2.31 (Unix) PHP/5.3.6
X-Powered-By: PHP/5.3.6
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Set-Cookie: PHPSESSID=deleted; expires=Tue, 06-Sep-2016 08:38:33 GMT; path=/
Content-Length: 5
Content-Type: text/html
Dateヘッダの時刻とSet-Cookieヘッダのexpires属性が1秒ずれていることがわかります。これがヒントになります。もちろん、明示的に1年+1秒前のexpiresを指定している可能性もありますが、通常はそういうことはしないでしょう。

また、setcookieヘッダの第2引数に空文字列以外を指定した場合は、過去日付のexpiresに対するMax-Ageの挙動が変わります。下記は、1年前のexpiresを指定した場合のSet-Cookieです。
PHP 5.5.0~7.0.18  Set-Cookie: PHPSESSID=deleted; expires=Tue, 06-Sep-2016 08:22:17 GMT; Max-Age=-31536000; path=/
PHP 7.0.19~       Set-Cookie: PHPSESSID=deleted; expires=Tue, 06-Sep-2016 08:22:17 GMT; Max-Age=0; path=/
上記のように、PHP 5.5.0~PHP 7.0.18までは、負のMax-Ageがセットされますが、PHP 7.0.19以降では、Max-Age=0がセットされます。setcookieの第2引数に空文字列を指定した場合と挙動が変わるのが興味深いですね。

どうやって調べたか

このような細かい調査をするには、実際にPoCを書いて動かしてみるのが確実ですが、PHPの全バージョンとなると200を超えるので、そう簡単ではありません。私は過去の記事で紹介した modphpall を用いて、実際に動かすことで調査しました。とある診断員さんのApacheの記事では以下のように書かれていますが、
以下のコマンド(以前検証した際、togakushiさんにご指導いただいたシェル芸を記載しています)を実行してApache HTTP Serverの2.2系を全部ソースインストールして、バージョンごとに別々のポートで起動させてみます。
僕が調べたApacheバージョン判定の小ネタ より引用
modphpallも、PHPのバージョン毎に別々のポートでApacheを起動するもので、似たようなことを考えるものだな思いました。

まとめ

診断文字列を打ち込まずにHTTPレスポンスヘッダだけからPHPのバージョンを推測する方法を紹介しました。本文中にも書いたように、アプリケーションの書き方によってはご判定の可能性もあり、機械的に上記の情報を適用してバージョンを推測することは危険ですが、複数の情報や顧客からのヒアリング内容等を組み合わせることにより、脆弱性診断に有用な情報が得られると考えます。

フォロワー