2010年7月1日木曜日

ぼくがPDOを採用しなかったわけ(Shift_JISによるSQLインジェクション)

補足

この記事は旧徳丸浩の日記からの転載です。元URLアーカイブはてなブックマーク1はてなブックマーク2
備忘のため転載いたしますが、この記事は2010年7月1日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり


PHPのデータベース・アクセス・ライブラリPDOは、DB接続時の文字エンコーディング指定ができないため、文字エンコーディングの選択によっては、プレースホルダを使っていてもSQLインジェクション脆弱性が発生します。

追記(2011/06/19) ここに来て急にブクマが追加されはじめていますが、このエントリを書いてから状況が改善しています。PHP5.3.6(2011/03/17)にて、PDOでもデータベース接続の文字エンコーディングを指定できるようになりました。この版で、UNIX版のPHPでは解決しましたが、Windows版のPHPではバグがあり、解決には至っていませんでした。その後、千葉征弘さん(@nihen)により原因が判明し、PHP5.3.7RC1では修正されていることを確認しています。したがって、PHP5.3.7の正式版が出れば、この問題は解決すると思われます。(追記終わり)

はじめに

本を書いています。SQLインジェクションの節から書き始めて、初稿はレビュアーの方々にお送りしましたところ、さっそく有意義なコメントを多数頂戴しています。ありがとうございます。
その原稿の中で、DBアクセスに使用するPHPのライブラリとしてMDB2を紹介していたところ、PDOを紹介すべきではという意見を複数頂戴しました。そこで、現時点でPDOを採用していない理由を報告したいと思います。これは、「安全なSQLの呼び出し方」でPDOを取り上げていない理由でもあります。
PDOは接続時に文字エンコーディングを指定できない(指定しても無視される)ので、データベースへの接続時の文字エンコーディングはLatin1が暗黙に指定されます。すると、日本語の読み書きで文字化けが生じるため、「SET NAMES SJIS」により、文字エンコーディングを接続後に指定するようなプログラミングがなされているようです(MySQLの場合)。
そのようなプログラム例を示します。このプログラムでは、内部文字エンコーディングとしてShift_JISを利用しているという想定です。PHP5.3.0とMySQL5.1.37の組み合わせで確認しました。
<?php
  $dbh = new PDO('mysql:host=localhost;dbname=test;charset=sjis', 'username', 'password');
  $dbh->query("SET NAMES sjis");
  $sth = $dbh->prepare("select * from test WHERE name=?");
  $sth->setFetchMode(PDO::FETCH_NUM);
  $name = ...
  $sth->execute(array($name));
  while ($data = $sth->fetch()) {
    var_dump($data);
  }
PDO+MySQLの場合、プレースホルダは「動的プレースホルダ」あるいは、俗に「クライアントサイドのプリペアードステートメント」などと呼ばれる実装になっています*1
このため、$nameとして「ソ' OR 1=1#」という文字列を指定した場合、MySQLに対して以下のようなクエリが送信されます(Wiresharkによるダンプ)。

0x83は「ソ」の先頭バイトです。これをPDOではLatin1(ISO-8859-1)として扱うので、0x83はNBHという制御文字を意味し、1バイト文字になります。PDOは、続く「\'」を「\\\'」とエスケープします。
一方、MySQLサーバー側では、受け取ったSQLをShift_JISと想定しているので「ソ\\'」という並びと解釈します。「\\」は「\」をエスケープしたものなので、後続の「'」は文字列リテラルの終端に使われ、残りの「 OR 1=1#'」はSQL文の一部とみなされます。SQL文が注入できたので、SQLインジェクション脆弱性があるということになります。ここまでの説明を下図にまとめました。


my.cnfの修正でもダメ

「set names」がセキュリティ上問題があることが広く知られるようになったせいか、PDOの文字化けを別の方法で対処するようにすすめているエントリもあります。たとえばこのエントリでは、my.cnf(my.ini)の修正でPDOの文字化けに対応しているのですが、Shift_JISを使う場合はこれもダメです*2。その理由は、「PDOはLatin1として処理したものを、MySQLはShift_JISとして解釈する」状態には変わらないからです。

まとめ

  • PDO+MySQLでは、動的プレースホルダ(なんちゃってプリペアード・ステートメント)が使われる
  • PDOでは文字エンコーディングが指定できない
  • PDO+MySQL+Shift_JISでは、プレースホルダを使ってもSQLインジェクション脆弱性となる

解決策・回避策

PDOの文字化け問題とSQLインジェクション脆弱性問題を解決するにはどうすればよいでしょうか。私が思いつくのは以下の2つの方法です。
  1. PDOではなくMDB2など文字エンコーディングを正しく扱えるライブラリを使用する(根本的対策)
  2. アプリケーションからDBまで一貫してUTF-8で処理し、set names utf8により文字化けを回避する(回避策)
2.で具体的な問題が起こるかどうかは分かりませんが、文字エンコーディングを正しく扱えないことで潜在的なリスクがあると考えます。そのため、書いている本の中では1.を採用したという訳です。
しかし、私自身PHPを熟知しているわけではないので、ひょっとすると、PDOの使い方などでもっとよい方法があるかもしれません。識者のご指摘をいただければ幸いです。

追記(2010/07/01 22:20)

id:nihenさんが、PDOに対して文字エンコーディングを設定する方法を調べてくださいました。nihenさん、どうもありがとうございます。以下に引用します。
PDOからよばれるreal_escape_stringで文字コードを考慮させたい場合は
$dbh = new PDO('mysql:host=localhost;dbname=sandbox;charset=cp932', 'sandbox', 'sandbox', array(
    PDO::MYSQL_ATTR_READ_DEFAULT_FILE => '/etc/mysql/my.cnf',
    PDO::MYSQL_ATTR_READ_DEFAULT_GROUP => 'client',
));
もしくはserver-side-prepareを使う場合(文字コード気にしなくておk)
$dbh = new PDO('mysql:host=localhost;dbname=sandbox;charset=cp932', 'sandbox', 'sandbox', array(
    PDO::ATTR_EMULATE_PREPARES => false,
));
ちなむと前者は接続時に使われるオプションなのでnew PDOしたあとにあとから
$dbh->setAttribute
しても、ダメ。後者はおk。
gist:459499 - GitHubより引用
ご覧のように、MySQLサーバーへの接続時に、MySQL APIに渡すパラメータとして、my.cnfのファイル名を指定しています。上記の例ではGROUPを「client」に設定していますので、my.cnfに以下の設定を追加することになります。
[client]
default-character-set=sjis
ただし、Windows版のPHPでは、PDO::MYSQL_ATTR_READ_DEFAULT_FILEが使えないようです。このため、サーバー接続時に文字エンコーディングを指定する方法がありません。このため、Windows版のPHPを使う場合は、PDO::ATTR_EMULATE_PREPARESをfalseにする(真のプリペアード・ステートメントを使う)ことで対策することになります。


*1 高木浩光氏のいわれる「なんちゃってプリペアド・ステートメント
*2 元エントリはUTF-8なので問題はおきませんが、逆に言えば、set names utf8でも問題ないことになります


本日のツッコミ(全7件)

_ kanehama (2010年07月01日 12:23)
再現しなかったので、もう少し現象が再現できる設定を教えていただけると幸いです。

私は下記のような環境で試しました。

■PHPソースコード(Shift_JIS)
mb_internal_encoding('sjis');
echo mb_internal_encoding() . "\n";
$dbh = new PDO('mysql:host=localhost;dbname=test', 'root', '');

$sth = $dbh->prepare("SHOW VARIABLES LIKE 'char%'");
$sth->setFetchMode(PDO::FETCH_NUM);
$sth->execute();
while ($data = $sth->fetch()) {
    echo $data[0] . ' ' . $data[1] . "\n";
}

$sth = $dbh->prepare("select ?");
$sth->setFetchMode(PDO::FETCH_NUM);
$name = urldecode("%83%5C'%20OR%201=1#"); // ソ' OR 1=1#
$sth->execute(array($name));
while ($data = $sth->fetch()) {
    var_dump($data);
}


■実行結果
SJIS
character_set_client latin1
character_set_connection latin1
character_set_database sjis
character_set_filesystem binary
character_set_results latin1
character_set_server sjis
character_set_system utf8
character_sets_dir /usr/local/mysql/share/mysql/charsets/
array(1) {
  [0]=>
  string(11) "ソ' OR 1=1#"
}

■バージョン
 MySQL:5.1.34
 PHP:5.3.2

ちなみにPHPのソースコード、内部エンコーディングなどはShift_JISで統一しています。
今回のSQLインジェクションが成功するのであれば
var_dumpの結果は「1」になる予定でSQLを変更しています。

よろしくお願いします。

_ 徳丸浩 (2010年07月01日 13:26)
kanehamaさん、こんにちは
再現テストをしていただき、ありがとうございます。
当方では、SQLインジェクションとなるようです。
頂戴した結果ですが、

> character_set_client latin1
> character_set_connection latin1

この状態ですと、データベースの日本語に対して正常にアクセスできないのではないでしょうか。
当方の条件を変えて、上記エンコーディングをlatin1になるように調整すると、SQLインジェクションは出ない代わりに、日本語が文字化けします。
お手数ですが、いったんテーブルに格納した日本語が正常に読み書きできるかご確認いただけないでしょうか。

_ 心は萌え (2010年07月01日 15:01)
時間があったら試してみたいと思いますが そもそもlatin1などがなぜ出てくるのでしょうか?
日本語の場合はMysqlをビルドするときに--with-charset=で利用する文字コードをsjisなりutf8なりに固定したほうが良いのではないでしょうか?元々英語圏が基本でビルドされたmysqlを使っていませんか?

_ kanehama (2010年07月01日 15:09)
徳丸浩さんありがとうございます。

確認してみると確かに書き込み/読み込みで文字化けていました。(確認が足りなくて申し訳ありません。。
読み書きを正しく処理できるようにMySQLの設定を変更すると
徳丸浩さんが書かれているようにSQLインジェクションが起きました!!
# MySQLの文字コード関連の設定は下記のようになっています。
character_set_client sjis
character_set_connection sjis
character_set_database sjis
character_set_filesystem binary
character_set_results sjis
character_set_server sjis
character_set_system utf8

ただし、同じPHPソースを別環境で実行すると、SQLインジェクションは起きませんでした。
確認した組み合わせは PHP 5.2.3 と MySQL 5.0.37
# MySQLの文字コード設定は下記のようになっています。
character_set_client sjis
character_set_connection sjis
character_set_database sjis
character_set_filesystem binary
character_set_results sjis
character_set_server sjis
character_set_system utf8

PHP 5.3から何か変更あったのですかね。。
念のためあとでPHP 5.3.2が入ってる環境のPHPのバージョンを5.2.3に変えて確認してみます。

_ bero (2010年07月01日 18:19)
> PDO+MySQLの場合、プレースホルダは「動的プレースホルダ」あるいは、俗に「クライアントサイドのプリペアードステートメント」などと呼ばれる実装になっています

mysqlがよほど古いバージョンでもなければサーバサイドのを使うと思うんですが

ext/pdo_mysql/mysql_statement.c
#if HAVE_MYSQL_STMT_PREPARE
...
mysql_stmt_bind_param(...)

php 5.1.6と5.3.1で確認

_ bero (2010年07月01日 18:37)
上記mysql->libmysql

ドキュメントにも書いてた
http://php.net/manual/ja/ref.pdo-mysql.php
> PDO_MYSQL は、MySQL 4.1 以降に存在するプリペアドステートメントを ネイティブにサポートしているという利点があります。 古いバージョンの mysql クライアントライブラリを使用している場合は、 PDO がこの機能をエミュレートします。

_ carrot (2010年07月01日 19:41)
PHP5.2.1から、PDO_MYSQLは、デフォルトではクライアントサイドのプリペアードステートメントになったようですね。

http://www.php.net/releases/5_2_1.php に
PDO_MySQL now uses buffered queries by default and emulates prepared statements to bypass limitations of MySQL's prepared statement API.
とあります。

プリペアードステートメントだと、MySQLのクエリキャッシュが効かないためのようです。

こちらに動作検証された方がいらっしゃいます。
http://d.hatena.ne.jp/do_aki/20100221/1266746673

2010年4月6日火曜日

PROXY(プロキシ)経由でのDNSリバインディングと対策

補足

この記事は旧徳丸浩の日記からの転載です。元URLアーカイブはてなブックマーク1はてなブックマーク2
備忘のため転載いたしますが、この記事は2010年4月6日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり


このエントリでは、PROXY経由でWebアクセスしている場合のDNSリバインディング対策について考察する。
PROXY(プロキシ)経由でWebアクセスしている場合、DNSによる名前解決はブラウザではなくPROXYにより行われる。したがって、 ブラウザ側ではDNS Pinningできないので、PROXY側での対策が重要になる。
広く使われているPROXYサーバーであるsquidの場合、DNS Pinningに相当するパラメータは、negative_dns_ttlであり、デフォルト値は1分間である。したがって、最初のアクセスから1分あけてXMLHttpRequestすれば、DNSリバインディングが成立する。これは実験で確認した。Internet ExplorerやOperaは、他のブラウザと比べてDNS Pinningがしっかりされている印象があるが、PROXY経由の場合は、これらブラウザでも比較的簡単にDNSリバインディングが成立するので注意が必要だ。
先のエントリで説明したように、JavaアプレットによるDNSリバインディングでも、URLクラスを使いかつPROXY経由でのアクセスの場合は、やはりPROXYでのDNS Pinningが問題となる。PROXY経由でのアクセスでは、Javaアプレットの場合でも、JavaScriptのXMLHttpRequestの場合同様、1分以上間隔をあけてやればDNS Rebindingが成立する。ただし、攻撃に使用できるプロトコルはHTTPのみであるので、JavaアプレットとJavaScriptでは、リスクは変わらない。
negative_dns_ttlの値を大きくすれば、squidがDNSキャッシュする最低時間を延ばすことが可能だが、二つの点で問題がある。
第一の問題は、negative_dns_ttlを大きくすることの副作用だ。negative_dns_ttlは元々、DNSアクセスに失敗した場合のキャッシュ保持時間を意味するからだ。以下に、negative_dns_ttlのリファレンスを引用する。
Time-to-Live (TTL) for negative caching of failed DNS lookups. This also sets the lower cache limit on positive lookups. Minimum value is 1 second, and it is not recommendable to go much below 10 seconds.
[squid : negative_dns_ttl configuration directiveより引用]
このため、一例として、negative_dns_ttlを6時間に設定した状態でDNS参照に失敗した場合(参照先DNSサーバーが一時的に停止した場合など)も6時間はDNSを再アクセスしない。すなわち、DNSのエラーが発生した場合は、negative_dns_ttlで設定した時間内は、当該サイトにアクセスできなくなるのだ。このため、negative_dns_ttlの値をあまり大きくすると、サイト閲覧に支障がでる可能性がある。
第二の問題は、negative_dns_ttlを長くしてもDNSリバインディングの根本対策にはならないことだ。 その背景には、PROXYサーバーをマルチユーザで使う場合が多いことや、タブブラウザの普及などがある。 やはりnegative_dns_ttlを6時間に設定した場合で説明しよう。AさんとBさんが同じPROXYサーバーを共有しているとする。Aさんが10:00にワナサイトにアクセスした場合、PROXYは16:00までワナサイトのIPアドレスをキャッシュする。したがって、Bさんが15:59にワナサイトを閲覧すると、そこから一分間でDNSキャッシュが無効になり、Bさんのブラウザ上動作するXMLHttpRequestがプライベートアドレスにアクセスすることを許してしまうかもしれない。 また、シングルユーザで考えても、タブブラウザのユーザはタブを開きっぱなしにする傾向があるので、6時間後の攻撃が成立する可能性はある。

アクセス制御による対策

結局negative_dns_ttlによる対策はうまくいかないので、別の方法を考える必要がある。有効な対策は、proxyのアクセス制御(ACL)により、squidにプライベートアドレスを中継させないことだ。以下に、192.168.0.0/16に対してアクセスを禁止する場合のsquid.confの設定例を示す。
acl localnet dst 192.168.0.0/16
http_access deny localnet
この場合、プライベートアドレスに対してはPROXY経由でアクセスできなくなるので、ブラウザのPROXY設定の例外設定をお忘れなく。下図に例外設定の例を示す。



このACLによる対策の効果であるが、JavaScriptとJavaによるアクセスに関しては完全に対策できると考える。当初Javaアプレットが対策から漏れることを心配していたが、先のエントリで検証したように問題ないことがわかった。Flashについては未検証なので今後検証したい…ところだが、私はFlashのコンテンツを書いたことがない。どなたか教えてください。

まとめ

PROXY経由でWebアクセスしている場合のDNSリバインディング対策について検討し、PROXYのACLによりプライベートアドレスを中継禁止することで対策可能であることを示した。DNSリバインディング対策のまとめについては別稿にて報告する。

2010年3月29日月曜日

DNSリバインディングによる無線LANパスフレーズの読み出しに成功

補足

この記事は旧徳丸浩の日記からの転載です。元URLアーカイブはてなブックマーク1はてなブックマーク2
備忘のため転載いたしますが、この記事は2010年03月29日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり

DNSリバインディング攻撃により、無線LANアクセスポイント(AP)に設定したWPAのパスフレーズ(PSK)を読み出す実験に成功したので報告する。

先のエントリではDNSリバインディング攻撃によるルータ設定変更の実験を報告したが、エントリでも触れたように設定変更についてはCSRF脆弱性を悪用することによっても可能だ*1。両手法の違いとして、CSRFは攻撃先の情報を読み出すことはできないのに対して、DNSリバインディングでは読み出しも可能だ。DNSリバインディングによるセッションCookieの読み出し例については、「ケータイtwitter(twtr.jp)においてDNS Rebinding攻撃に対する脆弱性を発見・通報し、即座に修正された」でも言及した。

無線LANアクセスポイントに対するDNSリバインディングのリスクを考える上で、もちろん設定変更もリスクではあるが、設定変更された場合、元々の所有者が無線LANにアクセスできなくなるなどの副作用が想定される。一方、パスフレーズの読み出しが行えれば、被害者のユーザに気づかれないままに攻撃される可能性が高く、それだけリスクも大きくなる。

また、Wi-Fiの普及に伴い、ゲーム機や携帯電話などでも広く無線LANが利用できるようになったことを受けて、無線LAN機器メーカー各社は無線LANの簡易設定方式を提唱・実現している。これはよいことだが、一方で、誰も無線LANの設定画面を見たこともなく、APの管理用パスワード(WEPやWPAのパスフレーズではない)がデフォルトのままになっているケースが大半であると予想される。これは危険な状態ではないか。

AOSSによる簡単設定

筆者が実験用に所有しているAPは、株式会社バッファローのWLA2-G54Cである。auの携帯電話biblioのWi-Fi機能を評価するために購入したものだ。バッファロー製APの特徴としてAOSSという簡単設定機能により、ボタン一つで無線LANの設定が可能である。その一端を以下に示そう。 APの設定でまず行うことは、APのIPアドレスの設定だ。ブラウザでもできなくはないが、APのユーティリティを使用すれば簡単だ。



上図でパスワードを入力する欄があるが、これは現在の管理パスワードを入力する欄でデフォルトは空である。ここで管理パスワードを設定できるわけではない。その後、バッファローの接続ユーティリティからAOSSによるプロファイルの追加を指定すると以下のような画面となる。



この状態でAPのAOSSボタンを長押しすると、APとクライアントのネゴシエーションが始まり、SSIDや暗号化の方法、パスフレーズなどが自動的に設定される。



安全な設定が簡単に行えるのだが、この手順に従うと、APの管理画面を一度も起動することなく、またAPの管理パスワードを設定する機会もないのだ。AOSSはPC以外に、ゲーム機(ニンテンドーDSなど)や携帯電話でも利用できるもので、おそらくバッファロー製無線LAN製品のユーザの大半が、管理パスワードを設定しないままに使用していると予想される。

APの管理画面からパスフレーズが見える

この状態で、APの管理画面をブラウザから表示させる。無線LANのメニューを表示すると以下のような画面だ。



これを見ていやな予感のしたあなたの勘は正しい。HTMLソースを表示させると以下のような部分がある(一部マスク表示にした)。
<input name="wl_wpa_psk" type="password" maxlength="64" size="56"
value="7c188cXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX67">
すなわち、管理画面が起動できれば、パスフレーズを見ることができるのだ。実は、これはこれで便利な面もあって、AOSSで設定したパスフレーズを確認することにより、AOSS対応でないクライアントを手動設定できるのだが、そういう利用方法を意図したものとも思えない。ともかく、ここまで紹介した内容から以下のことが分かる。
  • AOSSによる設定では管理画面を見る必要がない
  • 管理画面のデフォルトパスワードは空である
  • 管理画面にログインすると無線LAN暗号化のパスフレーズが参照できる
これらの事実から、バッファロー製AP WLA2-G54Cは、DNSリバインディング攻撃により無線LAN暗号化パスフレーズが読み出せると判断し、実験してみることにした。BCNの調査によると、バッファロー社は無線LAN分野の2009年のシェアが53.0%ということで、影響も大きいと思った。

侵入実験の実際

以下に、侵入実験に用いたスクリプトの一部を示す。ドメインは仮のものに置き換えた
// リクエスタのセット
var requester = new XMLHttpRequest();
requester.open('GET', 'http://root:@wlan.example.com/advance/ad-lan-wireless_sec_g.htm');
...
// パスフレーズの切り出し
var response = requester.responseText;
var regexp = /wl_wpa_psk.*value="([^"]+)"/m;
if (response.search(regexp) != -1) {
    pass = RegExp.$1;
}
このスクリプトはインターネット上のワナサイトに置かれる。このコンテンツが読み出された直後に上記FQDNに対するIPアドレスをAPのプライベートアドレスに書き換える。コンテンツ読み出しから4分後に上記リクエストが送出されるようにスクリプトを作成した。実行例を以下に示す。



図示したように、パスフレーズが読み出されていることが分かる。この後、ワナサイトにこの値をPOSTして、攻撃は完成である。

対策

バッファロー製AP WLA2-G54Cをマニュアルの指示通りに設定した状態において、DNSリバインディングにより暗号化パスフレーズ(PSK)を読み出すことに成功した。ユーザ側の対策としては、以下に尽きるだろう。
  • 管理パスワードを適切に設定する
しかし、コンシューマ向け製品ということを考慮すると、現在の製品仕様のままでは全てのユーザに管理パスワードを徹底させることも難しいだろう。したがって、製品提供者側の対策が今後は重要となる。以下のような対策案が考えられる。
  • APの管理パスワードの初期値を機器毎に違うものにする(根本的対策)
  • 製品設定の流れの中で管理パスワードの変更を強制する(根本的対策)
  • 管理画面URLのホスト部は数値IPアドレスのみを許容する(保険的対策)

まとめ

バッファロー製AP WLA2-G54Cを対象として、DNSリバインディング攻撃により暗号化パスフレーズ(PSK)を外部から読み出せることを確認した。他社製品の場合は、パスフレーズが読み出せるかどうかは不明であるが、マニュアルを読む限りは管理パスワードの初期値が空で、変更も要求されないものが多いよう*2なので、仮に暗号化パスフレーズが読み出せないとしても、設定変更は可能である可能性が高い。この場合、APの設定を「暗号化なし」に設定変更することにより、内部ネットワークへの侵入や盗聴などが行える。

無線LANの簡単設定機能は、AOSS以外にも「らくらく無線スタート」やWPSなどがある。これらは、安全な無線LAN設定を簡略化するという意味で社会的な意義のあるものだと思うが、基本的な想定として、「管理画面にはインターネットからアクセスできないので管理パスワードは設定しなくても大丈夫」と考えられているように見受ける。しかし、DNSリバインディングやクロスサイト・リクエスト・フォージェリ(CSRF)などの受動的攻撃により、インターネット越しに管理画面にアクセスすることは可能だ。ルータにCSRF脆弱性が多数指摘されてきた歴史は、「site:jvn.jp クロスサイト・リクエスト・フォージェリ ルータ」でGoogle検索してみると直ぐ分かる。DNSリバインディングについては、パスワードの設定さえすれば対策できるため、製品の脆弱性としては指摘しにくいが、今後十分発生し得る脅威として、メーカー側には対策を要望する。

*1 ルータにCSRF脆弱性があればという前提だが
*2 NECアクセステクニカ株式会社製のAtermシリーズは例外的にパスワードの変更が設定手順上で要求される

2010年3月25日木曜日

DNSリバインディングによるルータへの侵入実験

補足

この記事は旧徳丸浩の日記からの転載です。元URLアーカイブはてなブックマーク1はてなブックマーク2
備忘のため転載いたしますが、この記事は2010年03月25日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり


このエントリでは、DNSリバインディング攻撃によりブロードバンドルーターの設定を外部からの侵入を許すように変更してみたので報告する。

DNSリバインディングに対する関心が高まってきている。本年1月12日には、読売新聞夕刊一面トップで、iモードブラウザ2.0のDNSリバインディングによる不正アクセスが報じられた。3月17日のcomputerworld.jpでは、『2010年に最も警戒すべきセキュリティ脅威は「DNSリバインディング」』という翻訳記事が紹介された。実は「最も警戒すべきセキュリティ脅威」というタイトルは誤報だったわけだが*1、DNSリバインディングというマニアックな攻撃手法が注目を浴びるきっかけにはなったと思う。

そこで、専門家の方にはいまさらかもしれないが、DNSリバインディング攻撃により、自宅のブロードバンドルーターの設定を変更してみた。思いの外簡単にできたが、対策も容易であるので、その両方を説明しよう。

侵入実験の前提

まず一般的なDNSリバインディングの概要説明は、「DNS Rebinding」を参照頂きたい。この説明では、イントラネット内のサーバーへの侵入の例を挙げているが、今回の実験では、ブロードバンドルータへの侵入となる。実験に用いた自宅のルータは、プライベートアドレス(内部ネットワーク)からは設定が変更できるが、インターネット側からは設定できないという仕様となっている。そのため、DNSリバインディング攻撃が有効だ。この種のルータに対する攻撃手法としてはクロスサイト・リクエスト・フォージェリ(CSRF)という選択肢も考えられるが、このエントリでは言及しない。

実験では、まず、ルータの設定変更に必要なHTTPリクエストを調査した。その結果、POSTリクエストを3回送信すれば、外部からの侵入を許すように変更できることがわかった。

次に、インターネット上にワナサイトを用意して、ワナサイトをリクエストした後にDNSの内容を書き換え、一定時間置いた後に上記3つのPOSTリクエストを送出するようにした。実験に用いたブラウザはFirefox 3.6.2であり、最初のリクエストから4分間の間隔をとれば、DNSリバインディングが成立することがわかった*2。このため、3回のリクエストはそれぞれ、4分後、4分10秒後、4分20後に送出されるように、JavaScriptによるスクリプトを作成した。

ここで問題になるのが、ルータの認証をどうやってすり抜けるかだ。他の攻撃手法、たとえばCSRFの場合は、ユーザが認証している状態を悪用するわけだが、DNSリバインディングではこの方法は使えない。なぜなら、DNSリバインディングによるリクエストのHost:フィールドはワナサイトのドメインになっているからだ。このため、実験では、攻撃者がルータのパスワードを推測できるという想定にした。以下の説明では、ユーザIDとパスワードがともに「admin」になっていると想定する。これはそれほど突飛な想定ではない。この種のルータの初期設定はパスワードなしか、固定の初期パスワードになっていることが多いからだ。

侵入実験の実際

下図に侵入前のルータの設定を示す。赤い四角で囲った部分に、新たな設定を追加する。



次に、以下は侵入実験に用いたスクリプトのごく一部だ。このルータはBASIC認証が使われているので、以下のようにURLの中にBASIC認証のIDとパスワードを記述した。スクリプトキディが真似するとまずいので、スクリプトの全容は非公開とする。また、router.example.comはワナサイトのドメインだが、仮にものに置き換えた。
var requester = new XMLHttpRequest();
requester.open('POST', 'http://admin:admin@router.example.com/entry_ipnat_main_bottom.html');
このスクリプトを含むワナが閲覧されたら直ぐに、router.example.comのIPアドレスを192.168.0.1(ルータのアドレス)に書き換える。その後3回のリクエストが正常にルータに送信されると、設定を反映するためにルータがリセットされた。攻撃成功である。
攻撃成功後のルータの設定を下図に示す。



ご覧のように、192.168.0.10の端末に対して、インターネットからTCP3389ポートを受け付けるようになった。3389はWindowsのリモートデスクトップが使用するポートなので、この設定変更により外部からの侵入が可能になった。

対策

ルータやファイアウォールの設定変更は、DNSリバインディングのデモとしては定番と言えるものだが、実際の結果を見るとギョッとした人も多いのではないか。しかし、幸いなことに、対策は容易である。前述のように、DNSリバインディング攻撃は、ユーザの認証状態を引き継ぐことができないので、認証がかかっているサイトの場合、パスワードが分からないと侵入できない。従って、良質のパスワードを設定するだけで、侵入を食い止めることができる。

しかし、問題は、これだけブロードバンドのインターネットが普及した状況下で、ブロードバンドルータの多くがデフォルトパスワードのまま(パスワードなしも含めて)の設定と予想されることだ。従って、DNSリバインディングによる攻撃はいつ起こっても不思議ではないし、既に起こっているかもしれない。

従来、ブロードバンドルータのパスワード初期設定がいい加減だった理由は、「外部からは設定変更できないから安全だ」という意識が働いていたからだと推測する。しかし、ルータに対する外部からの侵入手法は、DNSリバインディングの他、ルータのCSRF脆弱性を悪用するなど複数存在する。一方で、ブラウザ側でDNSリバインディングを完璧に対策するのは困難だ。その理由は、IPアドレスの変更は、DNSの正式な仕様として認められた挙動であるからだ。このため、ルータやファイアウォールの初期パスワードやパスワードの管理について、さらなる工夫が求められる。

さらに網羅的なDNSリバインディング対策については、稿を改めて説明する。

追記(2010/03/31)

本エントリと関連して、「DNSリバインディングによる無線LANパスフレーズの読み出しに成功」を書きましたのであわせてお読みください。

脚注
*1 原題は「Top Ten Web Hacking Techniques」なので脅威という訳もおかしいが
*2 4分間が長すぎると感じる人もいると思うが、この時間を短くするAnti-DNS Pinningという手法が存在するし、ワナサイト上でYoutubeの映像でも流しておけば4分くらいはワナサイト上にとどまるユーザもいるだろう

2009年10月14日水曜日

htmlspecialchars/htmlentitiesはBMP外の文字を正しく扱えない

補足

この記事は旧徳丸浩の日記からの転載です(元URLアーカイブはてなブックマーク1はてなブックマーク2)。
備忘のため転載いたしますが、この記事は2009年10月14日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり

PHPの安定版(PHP5.3.0、PHP5.2.11)のhtmlspecialcharsおよびhtmlentitiesには、Unicodeの基本多言語面 (BMP)範囲外の文字、すなわち、U+10000以降の文字を正しく扱えない問題があります。
もっともシンプルな再現コードを以下に示します。
<?php
  $c = "\xF0\x90\x80\xBC"; // U+1003C
  $a = htmlspecialchars($c, ENT_QUOTES, 'UTF-8');
  echo bin2hex($a) . ':' . $a;

【処理結果】
266c743b:&lt;
U+1003Cは、Wikipediaの説明によると、大昔のギリシャの「線文字B」を表すそうで、小なり記号とは関係ないので、本来そのまま出力しなければならないものです。htmlspecialcharsおよびhtmlentitiesの内部処理で、コードポイントの下位16ビットしかみていないようで、このような結果となります。
線文字Bを扱う人口は少ないと思われますので、もう少し身近な例を探してみました。
<?php
  $c = "\xF0\xA2\x89\xBF";  // U+2227F(𢉿 …マダレに馬という字)
  $a = htmlentities($c, ENT_QUOTES, 'UTF-8');
  echo bin2hex($a) . ':' . $a;

【処理結果】
26736373696d3b:&scsim;
𢉿は、京都府長岡京市の地名で𢉿子ケ岳(からねがたけ)に使われている文字です(参考:稀少地名漢字リストGoogle Map)。一方、&scsim;はSGMLの文字実体参照のマッピングに出てくる記号で、≿(カーブした大なり記号の下に~)を表します。この実体参照形式はFireFoxなどのブラウザでは表示できませんが、そのような文字実体参照に変換される経緯は、id:moriyoshiさんの「PHPのhtmlentities()で (HTML4.0的に) 余計に実体参照に変換されてしまう文字の一覧」に説明されています。
BMP外の文字を扱う機会は少ないとは思いますが、正常系のデータが正しく扱えないという意味では、先の文字エンコーディングのチェック不備よりも重い問題だと考えます。幸い、id:moriyoshiさんが先に気づかれて、文字エンコーディングの問題と合わせて修正されていますので、おそらくPHP5.3.2からは修正されるものと思われます。PHPの最新のスナップショットにて修正ずみであることを確認しています。

影響を受けるケース

この問題を受けるのは、文字エンコーディングとしてUTF-8を使用している場合に、BMP範囲外の文字が与えられた場合です。

対策

PHP側の対応が完了するまでの間は以下のようにすればよいと思います。htmlentitiesよりはhtmlspecialcharsの方が影響が少ないこと、通常htmlentitiesを使う理由はない(参考:htmlspecialcharsと不正な文字の話 )ことから、htmlspecialcharsを使った上で、影響のある文字を扱わなければならない場合は個別に手当するしかないでしょう。Unicode5.1の範囲で、htmlspecialcharsにより不正に変換される文字は、以下の13種です。htmlentitiesを使用すると616種に増えます。𢉿もその一つです。
U+10022 線文字B音節文字
U+10026 線文字B音節文字
U+1003C 線文字B音節文字
U+20022 𠀢
U+20026 𠀦
U+20027 𠀧
U+2003C 𠀼
U+2003E 𠀾
U+E0022 言語タグ
U+E0026 言語タグ
U+E0027 言語タグ
U+E003C 言語タグ
U+E003E 言語タグ
個別に対応する方法を考えてみましたが、簡単な方法は思いつきません。いったん他の文字列に置き換えておいて、htmlspecialcharsの処理結果から、元の文字に戻す方法があると思いますが、面倒な処理になります。あるいは、影響を受ける文字を数値文字参照(&#x20022;など)に変換しておいて、htmlspecialcharsの第四パラメータ$double_encodeを0にして実行する方法もありますが、$double_encodeを0にする副作用もあります。元々実体参照や数値文字参照の形になってる文字列を変換しなくなるからです。このため現状では上記問題を許容した上で、PHP側で対応されるのを待った方がよい場合が多いような気がします。

まとめ

PHPのhtmlspecialcharsおよびhtmlentitiesには、Unicodeの基本多言語面 (BMP)範囲外の文字を正しく扱えません。次バージョン(PHP5.2.12、PHP5.3.2)では修正されると思われます。それまでの間は以下の方法で対処可能です。
  • htmlentitiesではなくhtmlspecialcharsを使用する
  • htmlspecialcharsでも影響を受ける13文字は個別に対応する、あるいは許容する

2009年10月9日金曜日

htmlspecialcharsのShift_JISチェック漏れによるXSS回避策

補足

この記事は旧徳丸浩の日記からの転載です(元URLアーカイブはてなブックマーク1はてなブックマーク2)。
備忘のため転載いたしますが、この記事は2009年10月9日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり

このエントリでは、PHPのhtmlspecialchars関数の文字エンコーディングチェック不備をついたクロスサイト・スクリプティング(XSS)脆弱性について、PHP側のパッチが提供されない状況での回避策について説明します。

何が問題か

PHPにおいて、XSS対策にはhtmlspecialcharsによって記号をエスケープすることが行われますしかし、htmlspecialcharsを利用していても、Shift_JISの先行バイトを利用して、XSSが発生する場合があります。 例えば、以下のようなINPUTがあり、外部から属性値を変更できる箇所が2カ所以上ある(以下の例では、AAAとBBB)とします。
<INPUT name="AAA" value="BBB">

ここで、AAAとBBBにそれぞれ以下のような値を与えます。文字エンコーディングはShift_JISとします。
AAA: %F1
BBB: onmouseover%3dalert(document.cookie);//
この場合、\xF1と後続の「"」が、合わせて一文字と見なされ(「■」で表します)以下のようなHTMLが与えられたとブラウザにより認識されます。
<INPUT name="■ value="onmouseover=alert(document.cookie);//">

すなわち、value=で与えたはずの「onmouseover=...」が属性値をはみ出し、イベントハンドラと見なされます。これにより、XSSが発生すると言う問題です。
一般的に、このような「Shift_JISの先行バイト」を用いたXSSは、\xF1以外にも、0x81~0x9Fおよび0xE0~0xFCの範囲のバイトが使用できます。htmlspecialchars(PHP5.2.5以降)は、これら単独の先行バイトをチェックしますが、なぜか0xF0~0xFCについてはチェックされず素通しになっています。このチェックもれが問題になっています。

今までの流れ

最近この問題が話題になっている流れを時系列に示します。
  1. 元々、htmlspecialcharsは文字エンコーディングの妥当性チェックをほとんど行っていませんでした。その当時の事情は、寺田さんの調査「htmlspecialcharsと不正な文字の話」に詳しく書かれています。
  2. その後、最近になって、「PHP5.2.5以降では文字エンコーディングのチェックが入っているけど、ちょっと中途半端だ」という調査結果を私が「htmlspecialcharsは不正な文字エンコーディングをどこまでチェックするか」にて指摘しました。
  3. このブログで、「XSSの攻撃に対する抜けが生じるかと言えば、突破の方法はちょっと思いつきません」と書いていたところ、id:t_komuraさんが「Shift_JIS では、htmlspecialchars() を使用しても XSS が可能な場合がある」にて、その突破方法を報告して下さっています。
  4. これに対して、id:IwamotoTakashiさんが、「htmlspecialcharsのパッチ私案」にて対策パッチを公開され、バグレポートを提出されましたが、「htmlspecialcharsに関する残念なお知らせ」で報告されているように、現時点ではPHP開発チームから却下されている状況です。
  5. このあたりから、一連の流れが広く知られるようになって、「もっと効果的な訴求方法があるよ」とか、海老原昂輔さんからもバグレポートが投稿されるなどの働きかけが始まっているようです。海老原さんのレポートには私のエントリも英訳されていて、本当にありがとうございます。
  6. さて、PHP本体が修正されるのが一番よいのですが、このエントリでは、PHP側の修正前に、どのようにこの問題に対策すればよいかを説明します(←イマココ)

問題が発生する条件

幸いなことに、この問題が発生するためには、色々条件がつきます。それを以下に示します。すべてAND条件です。
  1. PHPが内部で扱う文字エンコーディングがShift_JISである
  2. 入力から出力までの過程で文字エンコーディングが変換されない
  3. 入力値のバリデーションとして文字エンコーディングを検査していない
すなわち、1.と2.を合わせますと、入口・処理・出口まで一貫してShift_JISで扱っている、という条件が攻撃には必須ということになります。
入口(HTTP Request)と出口(HTTP Response)がShift_JISというサイトは珍しくなくて、ケータイサイトは大抵こうなっています。問題は、内部処理がShift_JISというところです。PHPはShift_JISの文字列リテラルをうまく扱えないので、いわゆる5C問題が発生します。そのため、「PHPの内部エンコーディングではShift_JISを避けよう」というノウハウがかなり普及しているのではないかと思います。このあたりの詳しい説明は、「Shift_JISを利用することの是非」や「第8回■主要言語の文字エンコーディングの対応状況を押さえる(ITpro)」をご覧下さい。

既存サイトの回避策

既存サイトで、一貫してShift_JISで処理している場合もやはりあるでしょう。その場合にどうこの問題に対処すればよいでしょうか。
既に稼働しているサイトの文字エンコーディングを変更するとなると、改修もさることながら、サイト全体に対するテストをしっかりやらないといけないので、そう簡単にはできないでしょう。また、文字エンコーディングのバリデーション処理を追加するのは、とてもよいことではありますが、やはりコードの改修・追加とテストが大変です。
最終的には文字エンコーディングのバリデーション処理の追加を推奨しますが、それがすぐにできない場合の暫定対応として、入口でShift_JIS→Shift_JISの変換をする、という方法があります。php.iniに以下のような設定をすることにより、入力データをShift_JIS→Shift_JISの変換を指示します。
[mbstring]
mbstring.language = Japanese
mbstring.internal_encoding = sjis-win
mbstring.http_input = sjis-win
mbstring.http_output = sjis-win
mbstring.encoding_translation = On
Shift_JIS→Shift_JISの変換というと、何もしないのではないかと思われるかもしれませんが、この指定により、不正なShift_JISに相当するバイトは除去されます。このため、Shift_JISの先行バイトを使用したXSS攻撃も防止することができます。
PHPによるアプリケーション開発に、そもそもShift_JISを使うこと自体が好ましくありませんので、あくまで暫定的・緊急に問題を回避するための手法として紹介します。また、設定変更後はサイトの動作検証を行って下さい。

新規開発する場合はどうか

これから新規開発するサイトの場合はどうでしょうか。この場合は、ぜひ以下の二点を実施して下さい
  1. アプリケーション仕様として適切な文字エンコーディングを選択する
  2. 文字エンコーディングのバリデーションを実施する
これらの内容は既にITproに詳しく書いていますので、そちらを参照して下さい。
また、これらの回に先立ち、なぜそうすべきかも説明しているので合わせてご覧いただければと思います。

まとめ

PHPのhtmlspecialcharsがShift_JISの先行バイトをきちんとチェックしていないために、半端な先行バイトを悪用したXSSが可能となることが指摘されています。これに対して、問題が発生する条件と、暫定的な対策、根本的な対策を説明しました。
文字エンコーディングの問題は、後から対策しようとするとやっかいですが、上流工程で考慮しておけば大幅に労力を削減することができます。この機会に、文字エンコーディングの問題に関心を持っていただければ幸いです。

[PR]Webアプリケーションのセキュリティ対策はHASHコンサルティングまで

本日のツッコミ(全1件)

□ 海老原昂輔 (2009年10月10日 05:35)
ご紹介いただいてありがとうございます。今回僕が行動できたのも、徳丸さんのわかりやすいエントリがあってこそです。こちらでも改めてお礼を言わせていただきます、本当にありがとうございました。

2009年9月30日水曜日

htmlspecialcharsは不正な文字エンコーディングをどこまでチェックするか

補足

この記事は旧徳丸浩の日記からの転載です。元URLアーカイブはてなブックマーク1はてなブックマーク2
備忘のため転載いたしますが、この記事は2009年9月30日に公開されたもので、当時の徳丸の考えを示すものを、基本的に内容を変更せずにそのまま転載するものです。
補足終わり


このエントリでは、PHPのhtmlspecialchars関数の第三パラメータ(文字エンコーディング指定)により、どこまで文字エンコーディングの妥当性チェックをしているかを報告します。 2007年4月に、寺田氏(id:teracc)の素晴らしいエントリ「htmlspecialcharsと不正な文字の話」により、htmlspecialcharsは、第三パラメータの文字コードを実質的に無視しており、不正な文字エンコーディングが指定された場合、その文字はチェックされずにすり抜けてしまうという結果が報告されています。

寺田氏のエントリには、検証に使用したPHPのバージョンが明記されていませんが、エントリが書かれた時期から考えて、PHP5.2.1またはそれ以前のバージョンと考えられます。以下は、PHPに同梱されるnews.txtからの引用です。
08 Feb 2007, PHP 5.2.1
03 May 2007, PHP 5.2.2
しかし、その後PHP5.2.5に至って、文字エンコーディングのチェックがなされるように改善されました。PHP5.2.5のnews.txtから引用します。
08 Nov 2007, PHP 5.2.5
【中略】
 - Fixed htmlentities/htmlspecialchars not to accept partial multibyte sequences. (Stas)
部分的なマルチバイト・シーケンスを受け付けないようにしたとのことですので、本当にそうなっているかを以下のコードを用いて検証しました。
<?php
function test($s, $enc) {
  $e = htmlspecialchars($s, ENT_QUOTES, $enc);
  echo bin2hex($s) . ':' . $s . ' -> ';
  echo bin2hex($e) . ':' . $e . "\n";
}

  echo phpversion() . "\n";

  test("\xC0\xAF", 'UTF-8');                  // 「/」の冗長表現(2バイト)
  test("\xE0\x80\xAF", 'UTF-8');              // 「/」の冗長表現(3バイト)
  test("\xF0\x80\x80\xAF", 'UTF-8');          // 「/」の冗長表現(4バイト)
  test("\xF8\x80\x80\x80\xAF", 'UTF-8');      // 「/」の冗長表現(5バイト)
  test("\xFC\x80\x80\x80\x80\xAF", 'UTF-8');  // 「/」の冗長表現(6バイト)
echo "-------------------------------\n";
  test("\xC0\xBC", 'UTF-8');                  // 「<」の冗長表現(2バイト)
  test("\xE0\x80\xBC", 'UTF-8');              // 「<」の冗長表現(3バイト)
  test("\xF0\x80\x80\xBC", 'UTF-8');          // 「<」の冗長表現(4バイト)
  test("\xF8\x80\x80\x80\xBC", 'UTF-8');      // 「<」の冗長表現(5バイト)
  test("\xFC\x80\x80\x80\x80\xBC", 'UTF-8');  // 「<」の冗長表現(6バイト)
echo "-------------------------------\n";
  test("A\xC2", 'UTF-8');                     // C2 (2バイトパターンなのに1バイト)
  test("A\xC2<", 'UTF-8');                    // C2 に < が続く
  test("A\xC2/", 'UTF-8');                    // C2 に / が続く
  test("A\xE6\xBC", 'UTF-8');                 // E6 BC (3バイトパターンなのに2バイト)
  test("A\xE6\xBC<", 'UTF-8');                // E6 BC に < が続く
  test("A\xE6\xBC/", 'UTF-8');                // E6 BC に / が続く
echo "-------------------------------\n";
  test("A\x8A", 'Shift_JIS');                 // 8A (Shift_JISの先行バイト)
  test("A\x8A/", 'Shift_JIS');                // 8A / が続く
  test("A\x8A<", 'Shift_JIS');                // 8A < が続く
echo "-------------------------------\n";
  test("A\xB4", 'EUC-JP');                    // B4 (EUC-JPの先行バイト)
  test("A\xB4/", 'EUC-JP');                   // B4 に / が続く
  test("A\xB4<", 'EUC-JP');                   // B4 に < が続く
このスクリプトをPHP4.4.9(PHP4系の最終バージョン)、PHP5.2.0~PHP5.2.4で実行した結果は以下のようになります。バージョン表示以外は同じ結果です。
5.2.4
c0af:/ -> c0af:/
e080af:/ -> e080af:/
f08080af:/ -> f08080af:/
f8808080af:/ -> f8808080af:/
fc80808080af:/ -> fc80808080af:/
 -------------------------------
c0bc:< -> 266c743b:&lt;
e080bc:< -> 266c743b:&lt;
f08080bc:< -> 266c743b:&lt;
f8808080bc:< -> 266c743b:&lt;
fc80808080bc:< -> 266c743b:&lt;
 -------------------------------
41c2:A■ -> 41c200:A■ 
41c23c:A■< -> 41266c743b:A&lt;
41c22f:A■/ -> 41c22f:A■/
41e6bc:A■ -> 41e6bc00:A■ 
41e6bc3c:A■< -> 41266c743b:A&lt;
41e6bc2f:A■/ -> 41e6bc2f:A■/
 -------------------------------
418a:A■ -> 418a:A■
418a2f:A■/ -> 418a2f:A■/
418a3c:A■< -> 418a266c743b:A■&lt;
 -------------------------------
41b4:A■ -> 41b4:A■
41b42f:A■/ -> 41b42f:A■/
41b43c:A■< -> 41b4266c743b:A■&lt;
不正な文字エンコーディングは■で表示しています。UTF-8の冗長表現の場合でも記号がエスケープされているので、文字エンコーディング指定がまったく無視されているわけではありませんが、Shift_JISやEUC-JPに関しては、文字エンコーディングISO-8859-1が指定されているかのような動作です*1。ISO-8859-1はhtmlspecialcharsのマニュアルによれば、文字エンコーディング指定が省略された時のデフォルト値ですから、「htmlspecialcharsの第三パラメータは指定しても意味がない」と思う人が出ても不思議ではありません。続いて、PHP5.2.5~PHP5.2.11、PHP5.3.0の結果を示します(PHP5.2.7は欠番になったので試していません)。やはり、バージョンの表示以外は同じ結果です。
5.2.5
c0af:/ -> c0af:/
e080af:/ -> e080af:/
f08080af:/ -> f08080af:/
f8808080af:/ -> f8808080af:/
fc80808080af:/ -> fc80808080af:/
 -------------------------------
c0bc:< -> 266c743b:&lt;
e080bc:< -> 266c743b:&lt;
f08080bc:< -> 266c743b:&lt;
f8808080bc:< -> 266c743b:&lt;
fc80808080bc:< -> 266c743b:&lt;
 -------------------------------
41c2:A■ -> :
41c23c:A■< -> :
41c22f:A■/ -> :
41e6bc:A■ -> :
41e6bc3c:A■< -> :
41e6bc2f:A■/ -> :
 -------------------------------
418a:A■ -> :
418a2f:A■/ -> 418a2f:A■/
418a3c:A■< -> 418a266c743b:A■&lt;
 -------------------------------
41b4:A■ -> :
41b42f:A■/ -> 41b42f:A■/
41b43c:A■< -> 41b4266c743b:A■&lt;
確かに一部の■が取り除かれていますが、全てではありません。上記結果より、以下のような処理となっていることが伺えます。
  1. UTF-8の冗長表現は許容している
  2. UTF-8の冗長表現であっても、記号のエスケープは行われる
  3. UTF-8として不正な文字(冗長表現は別)が1カ所でもあれば、出力は空になる、
  4. Shift_JIS、EUC-JPの「半端な先行バイト」が入力の末尾にあると、出力は空になる
  5. Shift_JIS、EUC-JPの先行バイトに続くバイトが不正の場合、先行バイトは単独の文字として扱われる
個人的には、どうしてこんな中途半端な仕様にしたのだろうと思いますが、上記の仕様でXSSの攻撃に対する抜けが生じるかと言えば、突破の方法はちょっと思いつきません(上記第4項が防御に貢献しています)。しかしながら、半端な先行バイトやUTF-8の冗長表現を許容する点で危なっかしいことも確かですし、不正なシーケンスを出力する必要は全くないわけですから、以下のような仕様にすべきだと私は考えます。
  • 冗長なUTF-8は不正なエンコーディングとして扱う(出力を空にする)
  • Shift_JIS、EUC-JPの2バイト目が不正な場合も、エラーとして出力を空にする

htmlspecialcharsの変遷

htmlspecialcharsの仕様の変化を時系列で整理します。PHPのマニュアルによると、htmlspecialcharsの第三パラメータが追加されたのはPHP4.1.0(2001年12月)、そのパラメータが実際に機能しだしたのは、前述のようにPHP5.2.5(2007年11月)です。その他も含め、以下にhtmlspecialcharsの変遷をまとめました。
PHP3.0    1998/06/06    htmlspecialcharsの提供
PHP4.0.3  2000/10/11    第2パラメータ(quote_style)追加
PHP4.1.0  2001/12/10    第3パラメータ(charset)追加
                        UTF-8、Shift_JIS、EUC-JPもサポートされている
PHP5.2.5  2007/11/08    文字エンコーディングの検査が追加(完全ではない)
ごらんのように、htmlspecialcharsは、PHP3からサポートされている*2由緒正しい関数で、早い時期に文字エンコーディング指定ができるようになりました。しかし、文字コードのセキュリティという観点から言えば、当初の仕様では役にたたず、2007年11月のPHP5.2.5に至ってようやく最低限のチェックがなされるようなりました。

PHPプログラマの意識はどうか

次に、現場のPHPプログラマの意識について考察してみます。長い間charsetパラメータが有効でなかったため、「htmlspecialcharsのcharsetパラメータは指定しても無意味」という意識が、一部のPHPプログラマに定着しているのではないかと感じています。例えば、過去にはてなの日記「Shift_JISを利用することの是非」にて取り上げた「はじめてのPHPプログラミング 基本編5.3対応」という書籍には、以下のような記述があります。
(htmlspecialcharsは)通常は第2引数まで指定すればほぼ問題ありませんが、念には念を入れるのであれば第3引数(文字エンコーディング)まで指定した方が良いでしょう。 「はじめてのPHPプログラミング基本編5.3対応」P203より引用
これを読んだ読者は、第3引数は指定する必要はないのだなと思うことでしょう。また、同じエントリのコメント欄も興味深い内容です。
テストしましたがPHPに元々入っているhtmlspecialcharsに関してはSJISを第三引数に指定しても全く意味がないようです。 SJISとして認識できない半端文字の場合はSJIS指定しても貫通する様子なのですがどうでしょう。
[id:arrayさんのコメントより引用]
統計をとったわけではありませんが、この意見が現状のPHPプログラマの意識を象徴しているように感じます。ですが、現在のhtmlspecialcharsは不完全ながら文字エンコーディングをチェックしていますので、意識を変えていかなければいけませんね。

まとめ

htmlspecialcharsの第三パラメータにより文字エンコーディングを指定することにより、不正な文字エンコーディングによる攻撃をある程度防御していることが分かりました。しかし、前述のように中途半端な挙動であるため、入口でのバリデーションにより文字エンコーディングの妥当性チェックをしておくべきでしょう。また、PHP5.2.5以降を使用することが必須で、特別な理由がない限りPHPの最新バージョンを使うべきです。 また、今回のテーマに関連して、id:t_komuraさんの素晴らしいプレゼンテーションが公開されていますので、あわせて参考になさってください。私が指摘していない内容についても言及されています。

追記(2009/10/05)

id:t_komura さんがこの件に関して検証して下さいました。そのブログエントリ「Shift_JIS では、htmlspecialchars() を使用しても XSS が可能な場合がある」によると、
PHP の htmlspecialchars() では、SJIS(Shift_JIS) の場合、\xf0 - \xfc を単独で指定しても排除しない(PHP 5.3.0 で確認)
だそうです。 まぁ、入力・内部処理・出力を全てShift_JISで通す場合に問題になるものですので、通常このような文字エンコーディングの選択はしない方がよいとは思いますが、あり得ないことではないので注意が必要ですね。 文字コードの選択には、ITproに書いた解説をご参照ください。また、Shift_JISを通して使うことの問題については、はてなダイアリーに書いた「Shift_JISを利用することの是非」をご参照ください。

*1 UTF-8の際に不正な文字エンコーディングがあると末尾にナル文字がついていますが、これは単純なバグでしょうね。
*2 PHP/FIにはHtmlSpecialCharsという関数があったようですが

フォロワー

ブログ アーカイブ