AWS IoTの「モノのクエリ」でNoSQLインジェクションしてみた
はじめに
サーバーレス開発部@大阪の岩田です。 AWS IoTのフリートインデックスを使う機会があったので、NoSQLインジェクションやエスケープ処理について調べてみました。
このブログに掲載しているソースコードは脆弱性があります。本番環境に流用しないようにご注意下さい
やること
API Gateway × Lambdaという定番の組み合わせを使ってAWS IoTのモノを検索するAPIを作成します。 このAPIはAWS IoTのフリートインデックス機能を使って
- 属性ownerの値にcm-iwataが設定されている
- 属性attr1の値がクエリストリングで指定された文字列に一致する
という条件に合致したモノの一覧を返す仕様とします。
実際のアプリを作る場合は、API GatewayのオーソライザーにCognito等を指定し、「属性ownerの値がオーソライザーから取得した情報に一致する」と行った条件に変更する想定ですが、まずはお試しなのでcm-iwata決め打ちとします。
事前準備
テスト用に事前にモノを作成しておきます。 モノの名前の他に属性としてowner,attr1,attr2という属性を設定します。
thingName | owner | attr1 | attr2 |
---|---|---|---|
thing1 | cm-iwata | hoge | fuga |
thing2 | cm-iwata | -fuga | hoge |
thing3 | other-user1 | hoge | fuga |
thing4 | other-user2 | fuga | hoge |
ソースコード
こんな感じで実装してみます。
import boto3 import decimal import json iot = boto3.client('iot',region_name='us-east-1') def lambda_handler(event, context): query = 'attributes.attr1:' query += event['queryStringParameters']['attr1'] query += ' AND attributes.owner:cm-iwata' res = iot.search_index(indexName='AWS_Things',queryString=query) return { 'statusCode': 200, 'body': json.dumps(res['things'], cls=DecimalEncoder) } class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, decimal.Decimal): if o % 1 > 0: return float(o) else: return int(o) return super(DecimalEncoder, self).default(o)
特にバリデーションもせず、クエリストリングのattr1
に設定された値を使ってクエリを組み立てて実行するダメダメな実装です。
試してみる
一旦実装できたのでテストしてみます。
正常系
attr1がhogeのモノを検索してみます。
curl 'https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/?attr1=hoge'
thing1が返却されるのが期待値です。
[{"thingName": "thing1", "thingId": "66a5288a-5afe-41f4-ba74-771e19f4b603", "attributes": {"attr1": "hoge", "attr2": "fuga", "owner": "cm-iwata"}}]
thing1が返却されました。ちゃんと動いてそうです。
特殊文字が含まれている場合
次にattr1が-fugaのモノを検索します。
curl 'https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/?attr1=-fuga'
thing2が返却されるのが期待値です。
{"message": "Internal server error"}(
500エラーになりました。
フリートインデックスのクエリ言語はLuceneがベースになっているのですが、-
はLuceneにおける特殊文字となっており、エスケープが必要になります。
Luceneのドキュメントを確認したところ下記の文字が特殊文字として定義されているようです。
+ - && || ! ( ) { } [ ] ^ " ~ * ? : \ /
また、フリートインデックスの挙動を確認した限り、=
についても特殊文字として認識しているようだったので、=
もエスケープ対象に加えつつ、コードを修正してみます。
import boto3 import decimal import json iot = boto3.client('iot',region_name='us-east-1') def lambda_handler(event, context): query = 'attributes.attr1:' attr1 = event['queryStringParameters']['attr1'] escaped = attr1.translate(str.maketrans({ '+': r'\+', '-': r'\-', '&': r'\&&', '|': r'\||', '!': r'\!', '(': r'\(', ')': r'\)', '{': r'\{', '}': r'\}', '[': r'\[', ']': r'\]', '^': r'\^', "'": r"\'", '~': r'\~', '*': r'\*', '?': r'\?', ':': r'\:', '\\': '\\\\', '/': r'\/', '=': r'\=' })) query += escaped query += ' AND attributes.owner:cm-iwata ' res = iot.search_index(indexName='AWS_Things',queryString=query) return { 'statusCode': 200, 'body': json.dumps(res['things'], cls=DecimalEncoder) } class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, decimal.Decimal): if o % 1 > 0: return float(o) else: return int(o) return super(DecimalEncoder, self).default(o)
再度チャレンジします。
curl 'https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/?attr1=-fuga'
[{"thingName": "thing2", "thingId": "17dcfe37-2356-4c67-8d42-e85e5b029097", "attributes": {"attr1": "-fuga", "attr2": "hoge", "owner": "cm-iwata"}}]
無事にthing2が返却されました!
NoSQLインジェクションされた場合
さらに意地悪してみます。
curl -X GET 'https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/?attr1=hoge%20OR%20hoge%20OR%20hoge'
[{"thingName": "thing3", "thingId": "0d150ebf-c502-4811-82b2-3703ce63423c", "attributes": {"attr1": "hoge", "attr2": "fuga", "owner": "otheruser1"}}{"thingName": "thing2", "thingId": "17dcfe37-2356-4c67-8d42-e85e5b029097", "attributes": {"attr1": "-fuga", "attr2": "hoge", "owner": "cm-iwata"}}, {"thingName": "thing4", "thingId": "4b3726a4-4a7d-4ab9-bcaf-64d8a8a8ff93", "attributes": {"attr1": "fuga", "attr2": "hoge", "owner": "otheruser2"}}, {"thingName": "thing1", "thingId": "66a5288a-5afe-41f4-ba74-771e19f4b603", "attributes": {"attr1": "hoge", "attr2": "fuga", "owner": "cm-iwata"}}]
ownerがcm-iwataでないモノの情報を含め全件返ってきてしまいました。。。NoSQLインジェクション成功です。 もう少し詳細を見ていきます。
先ほどのリクエストのクエリストリングはURLエンコードされていますが、URLエンコードする前は下記のような文字列です。
hoge OR hoge OR hoge
よって、先ほどのリクエストが実行されるとAWS IoTに対して発行されるクエリは下記のようなクエリになります。
attributes.attr1:hoge OR hoge OR hoge AND attributes.owner:cm-iwata
分かりやすくするために()を付けるとこうなります。
attributes.attr1:hoge OR hoge OR (hoge AND attributes.owner:cm-iwata)
つまりこのクエリの実行結果は
- attr1という属性がhogeのモノ
- 任意の属性がhogeのモノ
- 任意の属性がhogeかつ、ownerという属性がcm-iwataのモノ
の和集合となります。よって登録済みのデータ全件が返却されたのです。
NoSQLインジェクション対策について
このようなNoSQLインジェクション対策はどうやって防げば良いのでしょうか? SQLインジェクションの場合はプリペアードクエリとプレイスホルダを使うのが定番の対策となります。
残念ながらAWS IoTの「モノのクエリ」にはプリペアードクエリのような機能は存在しないようです。 完璧なサニタイズ処理を自前で実装するのはハードルが高いので、クエリストリングをバリデーションして、不正なリクエストにはさっさとエラーを返してしまいましょう。
AWS IoTではモノの属性値に指定できるの文字は英数字と-_.,:/@#のみとなっています。 そのため、クエリストリングにこれらの文字以外が指定された場合はさっさとエラーにしてしまうのが良さそうです。
※なおSQLインジェクションの対策はプリペアードクエリとプレイスホルダだけやってれば完璧という訳ではないです。 この辺の話は大垣さんのブログが非常に参考になります。 コードで学ぶセキュアコーディング 〜 SQLインジェクション編
まとめ
主にセキュリティの観点からAWS IoTの「モノのクエリ」について調べてみました。 NoSQLインジェクションはSQLインジェクションほどメジャーではないかもしれませんが、立派な脆弱性です。 サーバーレスの開発ではSQLを使わないことが多いので、セキュリティ対策が頭から抜けがちになってしまうのですが、サーバーレス開発であってもアプリケーションのセキュリティレベルは開発者が担保する必要があります。 サーバーレス固有のセキュリティリスクや攻撃手法についてもしっかりと追いかけていきたいと思います。