本稿では、簡単なログイン機能のSQL呼び出し例を用いてColumn SQL Truncationを説明したいと思います。
認証用テーブル定義の説明
認証に用いる会員テーブルを下記とします。ご覧のように、ログイン名を示す列 username には一意制約がありません。(追記)一意制約はふつうあるだろと思われるでしょうが、CMS四天王の中で一意制約がついているのはDrupalのみで、WordPress、Joomla、MovableTypeには一意制約がついていません。(追記終わり)まず、管理者 admin を登録しておきます。CREATE TABLE users ( id int NOT NULL AUTO_INCREMENT, /* 内部ID */ username varchar(8) NOT NULL, /* ログイン名 */ password varchar(64) NOT NULL, /* パスワードのSHA-1ハッシュ値 */ super boolean NOT NULL, /* 管理者フラグ */ PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
これで準備が出来ましたので、以下攻撃者の視点で攻撃に用いるSQLの呼び出しを見ていきましょう。mysql> INSERT INTO users VALUES(NULL, 'admin', SHA1('ax8z!hz6'), true); Query OK, 1 row affected (0.00 sec)
攻撃者が会員登録を行う
次に、攻撃者がセルフサービスの会員登録機能を用いて、'admin x' (空白は3個)というログイン名を登録します。以下に示すのはmysqlコマンドにより、アプリケーション内部のSQL呼び出しを再現するものです(攻撃者が操作するのはあくまでWebアプリケーションです)。以上のように、既存ユーザのチェック時点では 'admin x'はテーブル上に存在しないため重複チェックをすりぬけますが、テーブルにINSERTした時点で 8文字に切り詰められるので、'admin ' (adminの後に空白3個)が会員として登録されます。mysql> SET autocommit=0; # オートコミットをOFFに mysql> LOCK TABLES users WRITE; # 簡単のためテーブルロックを用いて排他制御 mysql> SELECT * FROM users WHERE username='admin x'; # 'admin x'は既存ユーザか? Empty set (0.00 sec) # Empty すなわち既存ではない # 以下、会員登録する mysql> INSERT INTO users VALUES(NULL, 'admin x', SHA1('123456'), false); Query OK, 1 row affected, 1 warning (0.00 sec) # 登録成功 警告は usernameの切り詰め mysql> SELECT id, username, hex(username), super FROM users; +----+----------+------------------+-------+ | id | username | hex(username) | super | +----+----------+------------------+-------+ | 1 | admin | 61646D696E | 1 | | 2 | admin | 61646D696E202020 | 0 |← 'admin 'が登録されている +----+----------+------------------+-------+ 2 rows in set (0.00 sec) mysql> COMMIT; mysql> UNLOCK TABLES;
攻撃者が admin でログインする
次に攻撃者は ID: admin / パスワード: 123456 でログインします。なんと、ログインできてしまいます。これができる理由は、MySQLは基本的に文字列末尾のスペースを比較時に無視するからです。mysql> SELECT * FROM users WHERE username='admin' and password=sha1('123456'); +----+----------+------------------------------------------+-------+ | id | username | password | super | +----+----------+------------------------------------------+-------+ | 2 | admin | 7c4a8d09ca3762af61e59520943dc26494f8941b | 0 | +----+----------+------------------------------------------+-------+ 1 row in set (0.00 sec)
MySQL のすべての照合順序は、PADSPACE 型のものです。これは、MySQL 内のすべての CHAR、VARCHAR、および TEXT 値が、末尾のスペースに関係なく比較されることを意味します。しかし、この段階では、superフラグは 0 (false)になっています。
MySQL :: MySQL 5.6 リファレンスマニュアル :: 11.4.1 CHAR および VARCHAR 型より引用
adminでログインした状態で権限を確認する
ログインした後は通常ログイン済みのログイン名をセッション変数に記憶しておく形でログイン状態を保持します。そして、権限が必要になる都度データベースに問い合わせると想定します。以下のSQL文を用います。idとusernameは処理の上では必要ありませんが、説明のために追加しています。上記のように、adminに対する権限(super)は1と0が返りますが、最初にヒットした 1(true) が使われます。すなわち、攻撃者は管理者の権限を得ることに成功しました。mysql> SELECT id, username, hex(username), super FROM users WHERE username='admin'; +----+----------+------------------+-------+ | id | username | hex(username) | super | +----+----------+------------------+-------+ | 1 | admin | 61646D696E | 1 | ← こちらの権限が使用される | 2 | admin | 61646D696E202020 | 0 | +----+----------+------------------+-------+ 2 rows in set (0.00 sec)
脆弱性の原因
脆弱性の原因は、元々ユニークであることを想定していたusername列に、同一とみなされる名前が登録されることにあります。その原因は、列の最大長を超える文字列をINSERTする際に、文字列が切り詰められ、インサート後の文字列がチェックの時とは別の文字列に変わることにあります。Column SQL Truncationの対策
Column SQL Truncationの対策は、文字列の切り詰めが発生しないようにするか、文字列の切り詰めが発生しても列のユニーク性が保証される方法をとることで、具体的には以下の方法があります。- usernameの最大長をバリデーションにより確認し、列の最大長を超えていたら処理を中止する
- MySQLの採用をやめ、切り詰めが発生しないPostgreSQL、Oracle、MS SQL Server等を使用する
- MySQLのsql_modeをstrictモードにする(追記)
- username列に一意制約を指定する
(* 以下追記 *)
また、MySQLを使う場合でも、sql_modeにSTRICT_TRANS_TABLES等を指定する方法でも、列長を超えたINSERTをエラーにすることができます。MySQL5.6からこれがデフォルトになったとのことですが、私がテストに使用したUbuntu15.04ではMySQL5.6.24なのにstrictモードになっていなかったので、Linuxディストリビューションによる違いがあるかもしれません。
ネットを検索すると、このデフォルト設定変更によりMySQL5.6でアプリが動かなくなったという報告を見かけるので、strictな方が安全であることは間違いありませんが、設定変更にあたってはテストを入念した方が良いと思われます。
(* 追記終わり *)
まとめ
Column SQL Truncation脆弱性について説明しました。前回のエントリで私は、「バリデーションの目的は、予期しない入力値によりデータベースの不整合その他の不具合を予防することにある」と書きました。そして、データベースの不整合を悪用してなりすましログインをする手法がColumn SQL Truncation攻撃であると言えます。
これを防ぐ方法の一つとして、バリデーションはデータベースの不整合の発生が起きにくくする効果があり、結果としてセキュリティ上も有効です。それと同じように、一意性が要求されるデータベースカラムに一意制約を指定することも、データベースの不整合を起きにくくする効果があり、結果としてセキュリティ上の効果もあります。
このように、バグの発生を防ぐためのプログラミング上の様々な施策には、一般的にセキュリティを高める効果があります。なんといっても脆弱性はバグの一種ですからね。だからと言って、「一意制約の指定はセキュリティ対策です」と言われると、違和感を持つ人が多いのではないでしょうか。私が「バリデーションはセキュリティ対策である」という言葉に違和感があるのも同じ理由です。
蛇足
私は拙著P347に以下のように書きました。◆ 事例2:ユーザIDに一意制約をつけられないサイトColumn SQL Truncationは、まさにログイン名の重複を意図的に作り出すという、データベースの不整合の悪用によるなりすまし手法と言えます。
筆者が脆弱性診断を担当したサイトで、特殊な操作により同一のユーザIDで複数のアカウントが作成できることが分かりました。サイト管理者に「テーブル定義でユーザIDの列に一意制約(UNIQUE)をつけた方がいいですよ」とアドバイスしましたが、ユーザの削除を論理削除(データベース上で削除フラグをつけて「消したことにする」こと)にしているので、一意制約はつけられないとのことでした。
このようなサイトは他にもあると思われますが、アプリケーションのバグ(排他制御不備など)により、重複したユーザIDが登録される潜在的なリスクが残ります。
事例1のように同一ユーザIDで別のアカウントが作成できると、誤って別ユーザとしてログインする可能性があります。データベース上でユーザIDを保存する列には、データベースの一意制約をつけることが望ましいでしょう。それができない場合、アプリケーション側でユーザIDの重複を防ぐ必要がありますが、その際は排他制御などに細心の注意を払ってユーザIDが重複しないように実装する必要があります。
【HASHコンサルティング広告】
HASHコンサルティング株式会社は、セキュリティエンジニアを募集しています。
興味のある方は、twitterやfacebookのメッセージ、あるいは問い合わせページからお問い合わせください。
以下のように、sql_mode 設定すれば問題がないようです。
返信削除tokuhirom:~/ $ mysql -uroot tokumaru [11:05:32]
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 789
Server version: 5.6.25 MySQL Community Server (GPL)
Copyright (c) 2000, 2015, Oracle and/or its affiliates. All rights reserved.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> CREATE TABLE users (
-> id int NOT NULL AUTO_INCREMENT, /* 内部ID */
-> username varchar(8) NOT NULL, /* ログイン名 */
-> password varchar(64) NOT NULL, /* パスワードのSHA-1ハッシュ値 */
-> super boolean NOT NULL, /* 管理者フラグ */
-> PRIMARY KEY (id)
-> ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
Query OK, 0 rows affected (0.02 sec)
mysql> INSERT INTO users VALUES(NULL, 'admin', SHA1('ax8z!hz6'), true);
Query OK, 1 row affected (0.02 sec)
mysql> SET SESSION sql_mode='TRADITIONAL';
Query OK, 0 rows affected (0.00 sec)
mysql> SET autocommit=0;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM users WHERE username='admin x';
Empty set (0.00 sec)
mysql> INSERT INTO users VALUES(NULL, 'admin x', SHA1('123456'), false);
ERROR 1406 (22001): Data too long for column 'username' at row 1