ログインデモをリクエスト
ブログに戻る

約16分 · 2026年11月10日

Cognitoの偵察アラートからユーザーデータベースのスクレイピングへ:Exaforceによる追跡調査

Exaforce MDR調査ファイル:8つのゲストID、604回の要求、172件のプロフィール露出。CloudTrailの先にあった活動を追跡します。

ExaFiles:Cognitoの偵察アラートからユーザーデータベースのスクレイピングへ。警告アイコンとユーザーを調べる虫眼鏡のイラスト。

アラートが捉えたのは、CloudTrailで確認できる範囲でした。1つのゲストIDによる23回のAWS呼び出しのうち、22回は拒否されていました。しかし、その先の出来事はCloudTrailにはまったく記録されていませんでした。同じ仕組みで発行された別の7つのゲストIDが、顧客のユーザーディレクトリを順に調べていたのです。利用されたAPIは、要求されたレコードが呼び出し元のものかを確認せず、どのユーザーのレコードでも返していました。

概要

攻撃者は、アプリケーションが要求者なら誰にでも渡す匿名ゲスト認証情報を使い、公開APIから顧客の172人分のプロフィールを取得しました。返されたデータには、60人分の従来型セッション値も含まれていました。パスワードの窃取やアカウントの乗っ取りはありませんでしたが、同じAPIを使えば、ゲストが任意のユーザーのパスワードを変更することも可能でした。エクスプロイトは使われていません。アプリケーションの設定そのものが、この活動を可能にしていました。

一連の流れは、短く機械的なものでした。

  1. 匿名でのアクセス。Cognito IDプールが、ログインしていないゲストに一時的なAWS認証情報を発行しました。これは設計どおりの動作です。問題は、その認証情報に対応するゲストロールに、プロフィールAPIを呼び出す権限があったことでした。
  2. 呼び出し元を信頼するAPI。指定されたIDのユーザーについて、呼び出し元にアクセス権があるかを確認せず、プロフィールを返していました。これは、以前はIDORと呼ばれていた、Broken Object Level Authorizationの問題です。
  3. 起点の取得。攻撃者は、有効なユーザーIDの集合から始めました。検索によって少数のメールアドレスがIDに変換されましたが、残りのIDの入手元は確認されていません。
  4. 列挙。ユーザーIDは連番だったため、攻撃者は起点の周辺範囲を1つずつ調べました。8つのゲストIDを使い、合計401個のユーザーIDを要求しました。
  5. 収集。APIは172件のプロフィールを返し、そのうち60件には、サインイン状態を維持するためのセッション値が含まれていました。
  6. 再利用の試み。攻撃者は4人分のセッション値を再送し、そのうち1人にはパスワードリセットメールも要求しました。いずれもアクセスの獲得には至りませんでした。

CognitoユーザープールとIDプール

このインシデントはAWS Cognitoの2種類のプールに関係していましたが、それぞれの役割は異なります。

ユーザープールは、アカウントのディレクトリです。ユーザーアカウントを保持し、サインアップとサインインを処理し、プロフィール属性を保存し、本人であることを証明するトークンを発行します。攻撃者が登録した7つのアカウントも、ユーザープールに作成されました。

IDプールの役割は別です。アプリケーションがユーザーの代理でAWSサービスを呼び出せるよう、一時的なAWS認証情報を渡します。AWS STSのAssumeRoleWithWebIdentityというアクションを通じて発行し、要求者をIAMロールに対応付けます。IDプールは、サインインなしで誰にでも認証情報を発行するよう設定することもできます。これがゲストID、つまり未認証IDです。プールに到達できる人は誰でも、単一の未認証ロールに対応する一時的な認証情報を取得できます。そのロールが実行できることは、匿名の呼び出し元にも実行できます。

このケースでは、未認証ロールに顧客のAppSyncプロフィールAPIを呼び出す権限がありました。つまり、インターネット上の誰でも、アカウントもパスワードもなしにゲスト認証情報を要求し、それをこのAPIで使用できました。要求のたびに、有効期間の短いアクセスキーを持つ新しい一時IDが発行されました。攻撃者は、その認証情報を8組取得しました。

権限は、次のようなステートメントに相当します。

json
{
  "Effect": "Allow",
  "Action": "appsync:GraphQL",
  "Resource": "arn:aws:appsync:us-east-1:5821xxxxxxxx:apis/q7m2v9-xxxx/types/*/fields/*"
}

匿名ゲストがプロフィールAPIを呼び出せる状態で、その後も、読み取るレコードが誰のものかは確認されていませんでした。

このロールには、別のAPI Gatewayのすべてのステージとメソッドでexecute-api:Invokeを実行する権限もありました。顧客は未使用だと説明していましたが、ログがないため確認はできません。

APIはIAM認可を使用していました。AppSyncはAPIキー、Cognitoユーザープール、OIDC、IAMなど複数の方式をサポートしています。IAM方式が確認するのは、「呼び出し元のプリンシパルに、このAPIでappsync:GraphQLを許可するポリシーがあるか」という点です。誰のデータを閲覧してよいかは確認しません。レコードごとの確認はリゾルバーの責任ですが、このケースでは行われていませんでした。

ゲストアクセスは、訪問者がログインせずにアプリを使うための意図された機能です。一方、ゲストIDの背後には、通常の行動を比較する基準となる継続的なユーザーがいません。認証情報は毎回新しく匿名で、一連のアクセスに使われた後は破棄されます。そのため、ロールの不正利用をIDベースの検知で捉える手掛かりは、ほとんど残りません。

匿名の認証情報から、172件のプロフィールの一括取得へ。

列挙の仕組み

プロフィールAPIは、オブジェクト単位の認可を行わず、IDで指定された任意のユーザーレコードを返していました。リゾルバーはuserIdを引数として受け取り、呼び出し元がそのユーザー本人か、そのアカウントと何らかの関係があるかを確認せずに、プロフィール全体を返していました。有効なゲスト認証情報と任意のuserIdさえあれば、そのユーザーのデータを取得できたのです。これは典型的な安全でない直接オブジェクト参照(IDOR)です。要求内のオブジェクト識別子だけがアクセス制御として機能し、呼び出し元との関係は検証されていません。OWASPは同じ欠陥をBroken Object Level Authorization(オブジェクトレベルの認可の不備)として分類し、API Security Top 10の第1位に挙げています。

唯一の実質的な障害は、どのIDを要求すればよいかを知ることでした。攻撃者は9つの起点IDから始めました。メールアドレスを使ったcheckUsernameの検索で、そのうち4つが返されました。しかし、その4つのうち2つは、検索が行われる15分以上前に、攻撃者自身のアカウント属性にすでに書き込まれていました。この方法で新たに把握したIDは、多くても2つだったことになります。

検索は、次のようなクエリでした。

graphql
query {
  checkUsername(email: "admin@customer.example") {
    userId
    hasLogin
  }
}

このクエリは、そのメールアドレスに対応する内部ユーザーIDと、アカウントにログインがあるかどうかを示すフラグを返しました。こうして、メールアドレスが探索の起点となるIDに変わりました。

そこから先は、追加の検索は不要でした。ユーザーIDは連番だったため、実在するIDを1つ入手すれば、その前後のIDは容易に推測できました。攻撃者は起点の周辺のID範囲を1つずつたどり、少数の検索を大量のデータ取得へとつなげました。

攻撃者は401個の実際のユーザーIDを要求し、そのうち384個は起点IDの周辺にある13の連続した範囲に収まっていました。連続する要求の大部分では、IDがちょうど1ずつ変化していました。全体の活動は約30分間で、8組のゲスト認証情報を使い、合計604回の要求が行われました。そのうち360回は、1組の認証情報だけによるものでした。大半はPython-urllibクライアントから送信され、残りは攻撃者自身のサインアップ時のブラウザーユーザーエージェントをコピーしていました。

APIが返したデータ

APIは要求されたIDのうち172個についてプロフィールを返し、残る229個には何も返しませんでした。返された各プロフィールと攻撃者の関係は、要求と実行の記録によって確認できます。以下は、リゾルバーが実際に返したデータを、フィールドごとに集計したものです。

返されたフィールドユーザー数
ユーザー名(すべてメールアドレス)172
名163
姓161
業種132
職務131
会社名122
国87
郵便番号80
従来型セッション値(core_u、core_x)60
会社URL41

職務分野や職位など、ほかにもいくつかのフィールドが返されましたが、いずれも対象は10人未満でした。

パスワードハッシュ、サインアップ元IPアドレス、内部の追跡用フィールドは返されませんでした。GraphQLスキーマとリゾルバーのレスポンスマッピングがこれらを除外しているため、バックエンドの外に出ることはありませんでした。流出した中で最も機微性が高かったのは、60人分の従来型セッション値の組です。これは、アプリケーションがユーザーのサインイン状態を維持するために使っていた値でした。

攻撃者によるデータの利用

攻撃者はデータの取得だけでは止まらず、その利用も試みました。セッション値を収集したユーザーのうち4人について、アプリケーションの認証エンドポイントにその値を再送し、セッションの再開を試みました。その4人のうち1人については、取得したメールアドレスを使い、パスワードリセットメールの送信も要求しました。

いずれもアクセスの獲得には至りませんでした。1人の値は、セッションキャッシュとすでに一致していませんでした。別の2人には、再開できるキャッシュ済みセッションがありませんでした。4人目の値は受け入れられましたが、その後のトークン更新に失敗し、何も発行されませんでした。パスワードリセットでは実際のユーザーにメールが送られましたが、その日の終わりまでに手続きは完了していませんでした。セッションは発行されず、アカウントの乗っ取りも起きませんでした。

その他の活動

列挙と並行して、攻撃者はほかにも注目すべき活動を行っていました。Cognitoユーザープールに7つのアカウントを自己登録し、そのうち5つは確認手続きを終えてサインインしていました。1つのアカウントでは、内部管理者のユーザーIDを指すプロフィール属性を設定していました。アプリケーションがそのアカウントを管理者として扱うか、試していたとみられます。サインインエンドポイントでは、そのような扱いにはなりませんでした。ただし、その属性を信頼する下流のアプリケーションが欺かれる可能性については、評価されていません。

最初のアラートの対象となったゲストIDは、AWSの偵察も行っていました。DynamoDB、S3、IAM、Secrets Manager、Cognitoなどのサービスに対して23回の呼び出しを行い、22回は拒否されました。成功したのはGetCallerIdentityだけでした。この呼び出しは権限を必要とせず、認証情報がどのIDに対応するかを確認するだけです。

活動全体には、ペネトレーションテストを示す明確な特徴がありました。第三者ドメインのアドレス、「Pentest Eval」というラベル、Kali Linuxのユーザーエージェント、規則的な列挙です。分析時点ではテストの許可が記録されていなかったため、顧客が確認するまでは、許可されていない活動として扱われました。

根本原因

盗まれた認証情報も、ソフトウェアのエクスプロイトも必要ありませんでした。アプリケーションの設定によって可能になったもので、特に次の4点が挙げられます。

  • 誰にでも発行されるゲスト認証情報。プールでは未認証IDが有効になっており、対応するゲストロールにはプロフィールAPIを呼び出す権限がありました。これだけで、そのAPIにインターネット全体から到達できる状態になっていました。
  • 呼び出し元の入力を信頼するAPI。リゾルバーは、要求内のuserIdで指定されたプロフィールを返し、呼び出し元にアクセス権があるかを確認していませんでした。認可の問いは「このAPIを呼び出せるか」であって、「これは自分のデータか」ではありませんでした。ゲストが到達できるAPIでは、この2つは別の問いです。同じ問題は書き込みにも及んでいました。同じゲスト認証情報で到達できるupdateUserProfileミューテーションは、リセットメールなしで、パスワードを含む任意のユーザーのプロフィールを変更できました。攻撃者はこのミューテーションを呼び出しておらず、確認可能な90日間のログでも、誰も呼び出していませんでした。
  • 誰でも可能な自己登録。ユーザープールには誰でもアカウントを作成できたため、攻撃者は活動用のアカウントを取得し、属性の改変を試す場所を得ることができました。
  • プロフィールレスポンスに含まれるセッション値。APIは、アプリケーションの従来型セッション値を通常のプロフィールフィールドとして返していました。セッション情報はプロフィールレスポンスに含めるべきではありませんが、このケースでは60人分が含まれていました。

セキュリティチームが得るべき教訓

設定が逸脱できないガードレールで、最小権限を支える。これは検知ではなく、予防の話です。目標は、ゲストロールからユーザーデータAPIへ、そもそも到達できないようにすることです。未認証ロールにはインターネット上の誰でも到達できます。そのため、ゲストロールがユーザーデータを返すAPIを呼び出せるなら、誰でも呼び出せると考える必要があります。ゲストロールの権限は、ゲスト向け機能が実際に使うフィールドに限定します。このケースの権限はAPI上のすべての型とフィールドを許可していました。フォーム設定の検索だけに絞ると、次のようになります。

json
{
  "Effect": "Allow",
  "Action": "appsync:GraphQL",
  "Resource": "arn:aws:appsync:us-east-1:5821xxxxxxxx:apis/q7m2v9-xxxx/types/Query/fields/getTrxProfile"
}

次に、アカウント内の誰も権限の上限を引き上げられないよう、アカウントの上位にサービスコントロールポリシーを配置します。未認証ロールの作成時にexposure=publicタグを付け、publicロールによるデータプレーン操作は短い許可リスト以外すべて拒否し、そのタグ自体も保護します。

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyPublicRoleDataApisOutsideAllowlist",
      "Effect": "Deny",
      "Action": [
        "appsync:GraphQL",
        "execute-api:Invoke"
      ],
      "NotResource": [
        "arn:aws:appsync:us-east-1:5821xxxxxxxx:apis/q7m2v9-xxxx/types/Query/fields/getTrxProfile"
      ],
      "Condition": {
        "StringEquals": {
          "aws:PrincipalTag/exposure": "public"
        }
      }
    },
    {
      "Sid": "ProtectExposureTag",
      "Effect": "Deny",
      "Action": [
        "iam:TagRole",
        "iam:UntagRole"
      ],
      "Resource": "*",
      "Condition": {
        "ForAnyValue:StringEquals": {
          "aws:TagKeys": [
            "exposure"
          ]
        },
        "ArnNotLike": {
          "aws:PrincipalArn": "arn:aws:iam::*:role/<platform-pipeline-role>"
        }
      }
    }
  ]
}

上限は組織レベルで一度設定され、個々のアカウントで後から設定を誤っても、その上限を超えることはできません(SCPは管理アカウント自体には適用されません)。これに、未認証アクセスが有効なIDプールを検出するAWS Configのマネージドルールcognito-identity-pool-unauth-access-check(Security HubコントロールCognito.2)を組み合わせれば、同じ設定ミスが気付かれずに再発することを防げます。検知も引き続き重要ですが、唯一の防御ではなく、第2の防御線になります。

エンドポイントだけでなく、データへのアクセスを認可する。ガードレールはゲストによるAPIアクセスを防ぎますが、APIそのものを修正するわけではありません。ログインせずに到達できるAPIは、レコードを返したり変更したりする前に、呼び出し元がそのレコードの所有者であることを確認する必要があります。呼び出し元に応じた認可があれば、攻撃者がいくらuserIdを試しても、それらは役に立たなかったはずです。

セッション情報をAPIレスポンスに含めない。このケースで返された従来型セッション値は、プロフィールフィールドとして扱われるべきものではありませんでした。それを返したことが、単なるデータ露出を、セッション窃取の試みへと発展させました。

大量の連番ID要求は、スクレイピングの兆候である。単一の送信元がユーザーIDを1つずつたどる活動は、それだけでアラートに値します。特にゲスト認証情報が使われている場合は重要です。個々の要求が正常に見えても、同じことが言えます。

Exabotが検知したもの

このケースの出発点は、CloudTrailで確認できた唯一の部分でした。CognitoのゲストIDが未認証ロールを引き受けると、数秒以内に同じ認証情報を使い、ホスティング事業者のインフラからDynamoDB、S3、IAM、Secrets Managerの探索が始まりました。クライアントはKali Linux上で動作するBoto3(Boto3/1.43.103 … os/linux#6.18.12+kali-cloud-amd64)でした。新しいゲストIDが現れただけでは、何も意味しません。未認証プールは訪問者ごとにIDを発行するため、新規性に基づくルールは常に発火するか、まったく発火しないかのどちらかになります。Exabotはそれを想定内として扱い、その後の行動を評価しました。貸し出しインフラから、発行後9秒以内に認証情報が検証され、広範な偵察に使用されたのです。Exabotは指示されなくても、ホスティング事業者、そのアドレスの過去の不正利用報告、同じIDをKali Linuxのペネトレーションテスト用ツールとしてすでに検出していた2件の未解決GuardDuty検出結果などの文脈を取り込み、高い確信度で調査が必要だと判断しました。

そのアラートが対象としたのは、8つのゲストIDのうち1つだけでした。残りの7つは、CloudTrailに記録されるAWS APIを呼び出さず、AppSyncのプロフィールAPIを直接利用していました。CloudTrail上では、これらのIDはCognitoによるAssumeRoleWithWebIdentityイベントとして現れるだけで、その後の行動は分かりません。残りを把握するには、CloudTrailに含まれないログを読む必要がありました。

誰かがIDプールにゲスト認証情報を要求すると、Cognitoは匿名IDを作成し、呼び出し元の代理でAWS STSを呼び出します。CloudTrailは、そのロールの引き受けを呼び出し元のアドレスではなくCognito自身のアドレスからのものとして記録します。このイベントが、各認証情報の発行時点の記録になります。AppSyncは、その認証情報で何が要求されたかを記録しますが、それにはリクエストログの有効化が必要です。どちらか一方だけでは不十分で、ゲスト認証情報の不正利用の全体像を把握するには、次の4つの情報源が必要です。

  • CloudTrail:各認証情報の発行時点を示すCognito IDプールとSTSのイベント、およびSignUpやInitiateAuthなどのユーザープールイベント。
  • CloudWatch上のAppSyncリクエストログ(フィールドレベルのログを少なくともERRORに設定):その認証情報による各要求、送信元アドレス、ユーザーエージェント。
  • リゾルバーLambdaログ:各要求が何を返したか。
  • CloudTrailのLambda呼び出しデータイベントとAPI Gatewayのアクセスログ:ゲストロールとログイン済みロールが到達できるその他の経路。このケースでは、どちらのログもありませんでした。

発行時刻と各要求の実行時間帯を使ってこれらを結び付けることで、8回の匿名ロール引き受けから、604回の要求と172件の返却プロフィールを、それぞれの活動に紐付けることができました。

Exabotが検知したのは、ロール引き受け後の異常でした。通常は新しいIDがノイズとなる環境で、発行から数秒後に攻撃者のように振る舞うゲスト認証情報を見抜いたのです。そのアラートは、出発点にすぎませんでした。Cognitoの記録とAppSyncのログを併せて読むことで、残る7組の認証情報が何を行い、APIが何を返したかが明らかになりました。