2015年2月12日木曜日

PHPのbasename関数でマルチバイトのファイル名を用いる場合の注意

まずは以下のサンプルをご覧ください。サーバーはWindowsで、内部・外部の文字エンコーディングはUTF-8です。UTF-8のファイル名を外部から受け取り、Windowsなのでファイル名をShift_JISに変換してファイルを読み込んでいます。basename関数を通すことにより、ディレクトリトラバーサル対策を施しています。
<?php
  header('Content-Type: text/plain; charset=UTF-8');

  $file_utf8 = basename($_GET['file']);
  $file_sjis = mb_convert_encoding($file_utf8, 'cp932', 'UTF-8');
  $path = './data/' . $file_sjis;
  var_dump($path);
  readfile($path);

しかし、ディレクトリトラバーサル対策は十分でなく、このスクリプトには脆弱性があります。下図は、ディレクトリトラバーサル攻撃により、このスクリプトの中身を読み出しているところです。


以下、このスクリプトの問題点、さらにはbasename関数を用いる際の注意点について説明します。

basename関数はマルチバイト対応していないという誤解

ネットの記事を見ていますと、basename関数はマルチバイト対応していないという主張をよく見かけます。例えば、下記の記事。
basename関数はパスからディレクトリ情報を削除してベース名(「test.txt」など)を取得するための関数ですが、PHP5(使用バージョン:PHP 5.3.1)ではパスに日本語が含まれていると失敗します。

▼失敗するbasename関数
<?php
$a = "/dir/テスト.txt";
echo basename($a);
?>
ファイル名などに日本語が含まれるパスでbasename関数が失敗するバグより引用
このスクリプトをWindowsサーバー上で動かすと、確かに下図のようにファイル名が化けます。下図ではファイル名の16進数表記を参考のためつけています。「テ」の先頭1バイト0x83が欠落しています。



localeに注意

しかし、このスクリプトはbasename関数の使い方に問題があります。basenameのマニュアルには以下の注意書きがあります。
注意:
basename() はロケールに依存します。 マルチバイト文字を含むパスで正しい結果を得るには、それと一致するロケールを setlocale() で設定しておかなければなりません。
これに従い、先のスクリプトを修正してみます。
<?php
$a = "/dir/テスト.txt";
setlocale(LC_CTYPE, 'Japanese_Japan.932'); // 追加
echo basename($a);
こうすると、下図のように正しい結果が得られます。マルチバイト環境でbasename関数を利用するにはlocaleの設定が必要であることが分かります。


冒頭の脆弱なスクリプトへの攻撃法

ここで、冒頭の脆弱なサンプルがなぜ問題かを説明します。このスクリプトにもlocaleの指定がありませんが、それが根本原因ではありません。
まず、攻撃に用いる文字列を示します。以下のクエリ文字列により攻撃が可能です。
..%C2%A5vulbasename.php
%C2%A5はUnicodeの円記号(U+00A5)のUTF-8表記をパーセントエンコードしたものです。これをデコードすると以下の通りです。
..¥vulbasename.php
「¥」は前述のとおり円記号U+00A5です。さらに、これをcp932に変換することになりますが、この際に「¥」U+00A5がバックスラッシュ「\」0x5Cに変換されます。
..\vulbasename.php
このため、組み立てられるパス名は以下の通りとなります。これは典型的なディレクトリトラバーサル攻撃の文字列ですね。
./data/..\vulbasename.php

文字エンコーディング変換するタイミングに注意

basename関数を通しているにもかかわらず攻撃が成立してしまう理由は、以下の通りです。
  • 円記号U+00A5は、basenameの処理対象の文字ではない
  • basenameの処理の後に、文字エンコーディング変換によりU+00A5が0x5Cに変化する
正しい手順は下記のとおりです。
  • まず文字エンコーディングを変換する
  • その後にbasename関数を通す
スクリプトとしては下記のとおりです。
$file_utf8 = $_GET['file'];
$file_sjis = mb_convert_encoding($file_utf8, 'cp932', 'UTF-8'); // 文字エンコーディング変換
setlocale(LC_CTYPE, 'Japanese_Japan.932'); // locale設定
$file = basename($file_sjis);
$path = './data/' . $file;
このスクリプトに対して先の攻撃をかけても、以下のようにスクリプトの中身は表示されません。


basenameはファイル名として妥当な文字種のチェックをしない

basename関数のソースを読むとすぐ分かりますが、basename関数がチェックするのはスラッシュ(全プラットフォーム共通)、バックスラッシュとコロン(Windows等)のみです。特にWindowsの場合ファイル名に使える文字に制約がありますが、basenameはそのチェックはしません。長さのチェックもしません。
このため、ファイル名を外部から受け取る場合、ファイル名の仕様として以下を決めておくとよいでしょう。
  • ファイル名に用いる文字の種類
  • ファイル名を表現する文字エンコーディング
  • ファイル名の長さの最小値・最大値
特に、外部から受け取ったファイル名でファイルを新規作成する場合は、受け取ったファイル名が仕様を満たすことをバリデーションで確認する必要があります。既存のファイルをオープンするだけであれば、バリデーションが必須かどうかは悩ましいところですが、一般論として入力値が前提条件(仕様)を満たすことのチェックとしてバリデーションはしておくべきだと思います。

まとめ

PHPのbasename関数を用いる上での注意点を説明しました。まとめると以下のようになります。
  • 文字エンコーディングの変換はbasenameを通す前に行うこと
  • basename関数を呼ぶ前にlocaleを設定すること
  • ファイル名の仕様を決める
  • ファイル名が仕様を満たすかどうかバリデーションにより確認する

0 件のコメント:

コメントを投稿

フォロワー