AWS Security Agent 検証 — GraphQL API に対するペネトレーションテストの検出能力
目次
はじめに
第1回では REST API に対するペネトレーションテストで 5 件中 4 件の脆弱性を検出し、第2回ではソースコード提供により検出数が 2.6〜5.6 倍に増加することを確認した。
本記事では GraphQL API に対する検出能力を検証する。AWS Security Agent の公式ドキュメント(Security Considerations FAQ)には「Can AWS Security Agent test both REST and GraphQL APIs?」という FAQ があり、回答は「Yes, AWS Security Agent can test API endpoints」である。しかし GraphQL は REST とは根本的に異なるアーキテクチャを持つ。単一の POST エンドポイント、スキーマによる自己記述、ネストクエリ、Alias、バッチクエリなど、REST にはない攻撃面が存在する。Security Agent はこれらの GraphQL 固有の攻撃面をどこまで理解してテストできるのか。
4つの条件でペンテストを実行し、「何が検出の分岐点か」を段階的に特定した。
前提条件:
- 第1回で作成した Agent Space・IAM ロール・VPC Config を再利用
- 検証リージョン: ap-northeast-1(東京)
- 料金: $50/タスク時間(秒単位課金)
検証環境
GraphQL API は REST API と以下の点で攻撃面が異なる。
| 観点 | REST API | GraphQL API |
|---|---|---|
| エンドポイント | 複数(/users、/posts 等) | 単一(/graphql) |
| レスポンス形式 | サーバー側で固定 | クライアントがフィールドを選択 |
| スキーマ | 外部ドキュメント(OpenAPI 等) | Introspection で自己記述 |
| リレーション | 個別リクエスト | ネストクエリで一度に取得 |
| バッチ処理 | 個別リクエスト | 配列 / Alias で単一リクエスト内に複数クエリ |
この違いを踏まえ、GraphQL 固有の脆弱性を 5 つ埋め込んだ Flask + Ariadne アプリを EC2(t3.small)にデプロイした。Introspection は有効にしてある(Agent がスキーマを自力取得できる状態)。
| # | 脆弱性 | GraphQL 固有度 | 検出難易度(予想) |
|---|---|---|---|
| 1 | ネストクエリによる過剰データ取得(Depth Limit なし) | ★★★ | 中 |
| 2 | フィールドレベル認可不備(他ユーザーの email/role が取得可能) | ★★★ | 高 |
| 3 | SQL Injection(searchPosts Query の引数が SQL に直接渡る) | ★☆☆ | 低 |
| 4 | Alias ベースの認証ブルートフォース(レート制限なし) | ★★★ | 中 |
| 5 | Mutation 戻り値での機密情報漏洩(createUser が password_hash を返す) | ★★☆ | 中 |
GraphQL アプリのソースコード(app.py 全文)
Flask + Ariadne で構築。検証 1 では / が JSON、/graphql が Playground HTML を返す構成。検証 2 では / を HTML リンクページに変更し、/graphql の GET に HTML フォームを追加、REST 風ルート(/api/users、/api/posts、/api/search)を追加した。
"""
Deliberately vulnerable GraphQL application for Security Agent penetration testing.
WARNING: Do NOT deploy in production environments.
"""
import os, sqlite3, hashlib, json
from flask import Flask, request, jsonify, g
from ariadne import (
QueryType, MutationType, ObjectType,
make_executable_schema, graphql_sync, snake_case_fallback_resolvers,
)
app = Flask(__name__)
DATABASE = "/tmp/graphql_vuln.db"
# --- Schema ---
type_defs = """
type Query {
me: User
user(id: ID!): User
users: [User!]!
post(id: ID!): Post
posts: [Post!]!
searchPosts(keyword: String!): [Post!]!
}
type Mutation {
login(username: String!, password: String!): AuthPayload!
createUser(username: String!, password: String!, email: String!, role: String): User!
createPost(title: String!, body: String!): Post!
}
type AuthPayload { token: String; success: Boolean!; message: String }
type User {
id: ID!; username: String!; email: String!; role: String!
password_hash: String! # VULN #5: 機密フィールドがスキーマに露出
posts: [Post!]!
}
type Post { id: ID!; title: String!; body: String!; author: User!; comments: [Comment!]! }
type Comment { id: ID!; text: String!; post: Post!; author: User! }
"""
query = QueryType()
mutation = MutationType()
# VULN #2: フィールドレベル認可なし — 全ユーザーのデータが取得可能
@query.field("users")
def resolve_users(*_):
db = get_db()
return [dict(r) for r in db.execute("SELECT * FROM users").fetchall()]
# VULN #3: SQL Injection — f-string で直接 SQL に埋め込み
@query.field("searchPosts")
def resolve_search_posts(_, info, keyword):
db = get_db()
sql = f"SELECT * FROM posts WHERE title LIKE '%{keyword}%' OR body LIKE '%{keyword}%'"
return [dict(r) for r in db.execute(sql).fetchall()]
# VULN #4: レート制限なし + トークンが平文ユーザー名
@mutation.field("login")
def resolve_login(_, info, username, password):
db = get_db()
pw_hash = hashlib.md5(password.encode()).hexdigest()
row = db.execute(
"SELECT * FROM users WHERE username = ? AND password_hash = ?",
(username, pw_hash),
).fetchone()
if row:
return {"token": row["username"], "success": True, "message": "Login successful"}
return {"token": None, "success": False, "message": "Invalid credentials"}
# VULN #5: createUser が password_hash を含む完全な User オブジェクトを返す
# + role パラメータを検証なしで受け入れ(Mass Assignment)
@mutation.field("createUser")
def resolve_create_user(_, info, username, password, email, role=None):
db = get_db()
pw_hash = hashlib.md5(password.encode()).hexdigest()
db.execute(
"INSERT INTO users (username, password_hash, email, role) VALUES (?, ?, ?, ?)",
(username, pw_hash, email, role or "user"),
)
db.commit()
row = db.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
return dict(row) # password_hash を含む
# VULN #1: ネストリゾルバに深さ制限なし
# User -> posts -> comments -> author -> posts -> ... (無限再帰可能)
user_type = ObjectType("User")
user_type.set_field("posts", lambda user, *_: [dict(r) for r in get_db().execute(
"SELECT * FROM posts WHERE author_id = ?", (user["id"],)).fetchall()])
post_type = ObjectType("Post")
post_type.set_field("author", lambda post, *_: dict(get_db().execute(
"SELECT * FROM users WHERE id = ?", (post["author_id"],)).fetchone()))
post_type.set_field("comments", lambda post, *_: [dict(r) for r in get_db().execute(
"SELECT * FROM comments WHERE post_id = ?", (post["id"],)).fetchall()])
comment_type = ObjectType("Comment")
comment_type.set_field("author", lambda c, *_: dict(get_db().execute(
"SELECT * FROM users WHERE id = ?", (c["author_id"],)).fetchone()))
comment_type.set_field("post", lambda c, *_: dict(get_db().execute(
"SELECT * FROM posts WHERE id = ?", (c["post_id"],)).fetchone()))
schema = make_executable_schema(
type_defs, query, mutation, user_type, post_type, comment_type,
snake_case_fallback_resolvers
)EC2 デプロイ・ペンテスト作成手順
第1回で作成した Agent Space・IAM ロール・VPC を再利用する。以下は今回固有の手順のみ記載。
REGION=ap-northeast-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
AGENT_SPACE_ID=<your-agent-space-id>
SERVICE_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/SecurityAgentPentestRole"
# デフォルト VPC・サブネット取得
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" \
--query "Vpcs[0].VpcId" --output text --region $REGION)
SUBNET_ID=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" \
--query "Subnets[0].SubnetId" --output text --region $REGION)
# セキュリティグループ作成(ポート 80 を VPC CIDR から許可)
SG_ID=$(aws ec2 create-security-group \
--group-name "graphql-vuln-app-sg" \
--description "Security group for GraphQL vulnerable app" \
--vpc-id $VPC_ID --region $REGION \
--query "GroupId" --output text)
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID --protocol tcp --port 80 \
--cidr 172.31.0.0/16 --region $REGION
# EC2 起動(Amazon Linux 2023、t3.small)
AMI_ID=$(aws ec2 describe-images --owners amazon \
--filters "Name=name,Values=al2023-ami-2023.*-x86_64" "Name=state,Values=available" \
--query "sort_by(Images, &CreationDate)[-1].ImageId" \
--output text --region $REGION)
INSTANCE_ID=$(aws ec2 run-instances \
--image-id $AMI_ID --instance-type t3.small \
--subnet-id $SUBNET_ID --security-group-ids $SG_ID \
--tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=graphql-vuln-app-target}]" \
--region $REGION --query "Instances[0].InstanceId" --output text)
aws ec2 wait instance-running --instance-ids $INSTANCE_ID --region $REGION
PRIVATE_DNS=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID \
--query "Reservations[0].Instances[0].PrivateDnsName" --output text --region $REGION)
# SSM 経由でアプリデプロイ
aws ssm send-command --instance-ids $INSTANCE_ID \
--document-name "AWS-RunShellScript" \
--parameters commands='[
"sudo dnf install -y python3.11 python3.11-pip",
"sudo python3.11 -m pip install flask==3.1.0 ariadne==0.24.0 gunicorn",
"sudo mkdir -p /opt/graphql-vuln-app"
]' --region $REGION
# app.py を転送(base64 エンコード経由)
APP_B64=$(base64 -w0 app.py)
aws ssm send-command --instance-ids $INSTANCE_ID \
--document-name "AWS-RunShellScript" \
--parameters commands="[
\"echo '$APP_B64' | base64 -d | sudo tee /opt/graphql-vuln-app/app.py > /dev/null\",
\"cd /opt/graphql-vuln-app && sudo python3.11 -c \\\"exec(open('app.py').read().split('if __name__')[0]); init_db()\\\"\",
\"sudo systemd-run --unit=graphql-vuln-app --working-directory=/opt/graphql-vuln-app gunicorn --bind 0.0.0.0:80 --workers 4 --timeout 120 app:app\"
]" --region $REGION# Target Domain 登録
TARGET_DOMAIN_ID=$(aws securityagent create-target-domain \
--target-domain-name "$PRIVATE_DNS" \
--verification-method HTTP_ROUTE \
--region $REGION --query "targetDomainId" --output text)
aws securityagent verify-target-domain \
--target-domain-id $TARGET_DOMAIN_ID --region $REGION
# プライベート DNS は UNREACHABLE になるが、VPC Config 経由のペンテストでは問題ない
# Agent Space 更新(新しい Target Domain を追加)
aws securityagent update-agent-space \
--agent-space-id $AGENT_SPACE_ID \
--name "pentest-verification" \
--aws-resources "{\"vpcs\": [{\"vpcArn\": \"$VPC_ID\", \"securityGroupArns\": [\"$SG_ID\"], \"subnetArns\": [\"$SUBNET_ID\"]}], \"iamRoles\": [\"$SERVICE_ROLE_ARN\"]}" \
--target-domain-ids "$TARGET_DOMAIN_ID" \
--region $REGION
# ペンテスト作成(条件 A: URL のみ)
LOG_GROUP="/aws/securityagent/graphql-pentest"
aws logs create-log-group --log-group-name $LOG_GROUP --region $REGION 2>/dev/null
PENTEST_ID=$(aws securityagent create-pentest \
--title "graphql-vuln-app-pentest-url-only" \
--agent-space-id $AGENT_SPACE_ID \
--assets "{\"endpoints\": [{\"uri\": \"http://${PRIVATE_DNS}\"}]}" \
--service-role $SERVICE_ROLE_ARN \
--log-config "{\"logGroup\": \"$LOG_GROUP\"}" \
--vpc-config "{\"vpcArn\": \"$VPC_ID\", \"securityGroupArns\": [\"$SG_ID\"], \"subnetArns\": [\"$SUBNET_ID\"]}" \
--region $REGION --query "pentestId" --output text)
# ペンテスト実行
aws securityagent start-pentest-job \
--pentest-id $PENTEST_ID \
--agent-space-id $AGENT_SPACE_ID \
--region $REGION
# ステータス確認
aws securityagent batch-get-pentest-jobs \
--pentest-job-ids "<pentest-job-id>" \
--agent-space-id $AGENT_SPACE_ID \
--region $REGION \
--query "pentestJobs[0].{Status:status}" --output table条件 B〜D は create-pentest の --assets パラメータを変更して実行する。
# S3 にスキーマ・API ドキュメントをアップロード
# schema.graphql はソースコード内の type_defs と同じ内容
# api-documentation.md は各 Query/Mutation の説明と認証要件を記載
S3_BUCKET="security-agent-graphql-docs-${ACCOUNT_ID}"
aws s3 mb "s3://${S3_BUCKET}" --region $REGION
aws s3 cp schema.graphql "s3://${S3_BUCKET}/" --region $REGION
aws s3 cp api-documentation.md "s3://${S3_BUCKET}/" --region $REGION
# Agent Space に S3 バケットを登録
aws securityagent update-agent-space \
--agent-space-id $AGENT_SPACE_ID --name "pentest-verification" \
--aws-resources "{\"vpcs\": [...], \"iamRoles\": [...], \"s3Buckets\": [\"$S3_BUCKET\"]}" \
--region $REGION
# ペンテスト作成(documents 付き)
aws securityagent create-pentest \
--title "graphql-vuln-app-pentest-with-docs" \
--agent-space-id $AGENT_SPACE_ID \
--assets "{\"endpoints\": [{\"uri\": \"http://${PRIVATE_DNS}\"}], \"documents\": [{\"s3Location\": \"s3://${S3_BUCKET}/schema.graphql\"}, {\"s3Location\": \"s3://${S3_BUCKET}/api-documentation.md\"}]}" \
--service-role $SERVICE_ROLE_ARN \
--log-config "{\"logGroup\": \"/aws/securityagent/graphql-pentest-docs\"}" \
--vpc-config "{...}" --region $REGION# endpoints の uri を /graphql に変更
aws securityagent create-pentest \
--title "graphql-vuln-app-pentest-explicit-endpoint" \
--agent-space-id $AGENT_SPACE_ID \
--assets "{\"endpoints\": [{\"uri\": \"http://${PRIVATE_DNS}/graphql\"}], \"documents\": [...]}" \
--service-role $SERVICE_ROLE_ARN \
--log-config "{\"logGroup\": \"/aws/securityagent/graphql-pentest-explicit\"}" \
--vpc-config "{...}" --region $REGION条件 D はアプリを改修した後、条件 B と同じパラメータで実行する(エンドポイントは http://<host>、documents 付き)。
検証 1: JSON レスポンスのみのアプリ(3条件)
最初に、GraphQL API のみを持つアプリに対して 3 条件でテストした。このアプリでは GET / が JSON({"service": "GraphQL Vulnerable App", "graphql_endpoint": "/graphql"})を返し、GET /graphql が GraphQL Playground(外部 CDN の JavaScript で動的にレンダリングされるインタラクティブ IDE)の HTML を返す。POST /graphql が実際の GraphQL クエリを受け付けるエンドポイントである。
条件 A: URL のみ
エンドポイントを http://<private-dns> として指定し、ドキュメントなしで実行した。
結果: Finding 0 件(実行時間 19 分)
CloudWatch ログを確認すると、SCANNER フェーズで「1 new endpoints found」と記録された後、重複排除で「0 unique endpoints found」となっていた。各攻撃カテゴリは 4 イベントのみで早期終了し、PENTEST フェーズは約 1 分で完了した。Agent は /graphql エンドポイントに一度もアクセスしていない。 / のレスポンスに "graphql_endpoint": "/graphql" が JSON で含まれていたが、Agent はこの情報を解析して /graphql に遷移しなかった。
条件 B: URL + GraphQL スキーマ・API ドキュメント提供
GraphQL スキーマ定義(SDL)と API ドキュメントを S3 にアップロードし、documents として提供した。
結果: Finding 0 件(実行時間 19 分)
この条件からアクセスログを有効化した。記録されたのは GET /、/robots.txt、/sitemap.xml、/favicon.ico への GET リクエストのみで、Agent は /graphql に一度もアクセスしなかった。ドキュメントを提供しても Agent の挙動は変わらなかった。
条件 C: /graphql を明示指定 + ドキュメント提供
エンドポイントを http://<private-dns>/graphql と明示的に指定した。
結果: Finding 0 件(実行時間 21 分)
アクセスログに変化があった。
| リクエスト | 回数 |
|---|---|
GET /graphql | 5 |
GET /favicon.ico | 1 |
Agent は /graphql にアクセスしたが、全て GET リクエストだった。GraphQL クエリは POST で送信する必要があるが、Agent は GET で返された Playground HTML を受け取っただけで、POST リクエストを送信しなかった。
3 条件の分析
3 条件全てで Finding 0 件。原因は明確である。
- 条件 A/B: 条件 A/B では Agent が
/graphqlに一度もアクセスしなかった。/の JSON レスポンスに含まれる"graphql_endpoint": "/graphql"を解析してリンク先に遷移する挙動は観測されなかった。検証 2 で HTML リンクを追加した途端に/graphqlを発見したことから、Agent の SCANNER は HTML の<a>タグやフォームを起点にクローリングしていると推測される - 条件 C: エンドポイントを明示指定すると Agent は
/graphqlに GET でアクセスしたが、POST は送信しなかった。検証 1 のアプリではGET /graphqlが GraphQL Playground(外部 CDN の JavaScript で動的にレンダリングされるインタラクティブ IDE)を返す構成だった。Agent がこの JavaScript を実行できなかった、あるいは Playground の UI を操作する手段を持たなかった可能性がある。検証 2 で静的な HTML フォーム(<form method="POST">)に変更したところ POST が送信されたことが、この推測を裏付けている - ドキュメント提供の効果なし: SDL と API ドキュメントを
documentsとして提供しても、条件 B の挙動は条件 A と同じだった。documentsは PENTEST フェーズのコンテキストとして使われるとされるが、SCANNER/CRAWLER の段階でエンドポイントが発見されなければ、PENTEST フェーズに攻撃対象が渡らなかったと考えられる
PENTEST フェーズの実行時間が全条件で約 1 分だったことが、攻撃対象が見つからず早期終了したことを裏付けている。
検証 2: HTML リンク構造を追加したアプリ
検証 1 の結果から、Agent がエンドポイントを発見するには HTML リンク構造が必要だと仮説を立てた。アプリを以下のように改修し、ドキュメント(SDL + API doc)も引き続き提供した状態でペンテストを実行した。
/を HTML ページに変更 —<a>タグで/graphql、/api/users、/api/posts、/api/searchへのリンクを配置/graphqlの GET レスポンスに HTML フォームを追加 —<form method="POST" action="/graphql">で GraphQL クエリを送信できるフォームを配置- REST 風ルートを追加 —
/api/users、/api/posts、/api/search?q=を追加(/api/searchは GraphQL のsearchPostsと同じ SQL Injection 脆弱性を持つ) - フォームエンコードの POST も受付 —
application/x-www-form-urlencodedでも GraphQL クエリを実行可能に
検証 2 のアプリ改修箇所(ルート定義のみ抜粋)
@app.route("/")
def index():
return """<!DOCTYPE html>
<html><head><title>Vulnerable App</title></head>
<body>
<h1>GraphQL Vulnerable App</h1>
<ul>
<li><a href="/graphql">GraphQL API Explorer</a></li>
<li><a href="/api/users">Users API</a></li>
<li><a href="/api/posts">Posts API</a></li>
<li><a href="/api/search?q=test">Search API</a></li>
</ul>
</body></html>"""
@app.route("/graphql", methods=["GET"])
def graphql_playground():
return """<!DOCTYPE html>
<html><head><title>GraphQL API</title></head>
<body>
<h1>GraphQL API Explorer</h1>
<form method="POST" action="/graphql" enctype="application/x-www-form-urlencoded">
<textarea name="query" rows="6" cols="60">{ users { id username email role } }</textarea><br>
<input type="submit" value="Execute Query">
</form>
</body></html>"""
@app.route("/graphql", methods=["POST"])
def graphql_server():
if request.content_type and "json" in request.content_type:
data = request.get_json()
else:
data = {"query": request.form.get("query", "")}
success, result = graphql_sync(schema, data, context_value={"request": request})
return jsonify(result), 200 if success else 400
# REST 風ルート(クローラーの発見用)
@app.route("/api/users")
def api_users():
db = get_db()
users = [dict(r) for r in db.execute("SELECT id, username, email, role FROM users").fetchall()]
return jsonify({"users": users})
@app.route("/api/search")
def api_search():
q = request.args.get("q", "")
db = get_db()
sql = f"SELECT * FROM posts WHERE title LIKE '%{q}%' OR body LIKE '%{q}%'"
return jsonify({"results": [dict(r) for r in db.execute(sql).fetchall()]})この改修により、アクセスログ上では Agent が全エンドポイント(/graphql、/api/users、/api/posts、/api/search)にアクセスし、フォーム経由で POST リクエストを送信したことが確認できた。なお、GraphQL スキーマと埋め込んだ 5 つの脆弱性自体は変更していない。変更したのはエンドポイントの発見可能性(HTML リンク + フォーム)と、REST 風ルートの追加のみである。
結果: 8 件の Finding(実行時間 2 時間 25 分)
| # | Finding | リスクタイプ | レベル | 信頼度 |
|---|---|---|---|---|
| 1 | Critical Authentication Bypass via Token Forgery | PRIVILEGE_ESCALATION | CRITICAL | HIGH |
| 2 | Password Hash Exposure in GraphQL Schema | INFORMATION_DISCLOSURE | CRITICAL | HIGH |
| 3 | 500 Internal Server Error on Null Byte Injection | SERVER_ERROR_500 | MEDIUM | HIGH |
| 4 | Input Parsing Error Causing Application Crashes | CODE_INJECTION | UNKNOWN | FALSE_POSITIVE |
| 5 | Critical SQL Injection in Search Endpoint | SQL_INJECTION | CRITICAL | HIGH |
| 6 | Unauthenticated Password Hash Exposure via GraphQL Nested Queries | INFORMATION_DISCLOSURE | HIGH | HIGH |
| 7 | Mass Assignment Privilege Escalation via User Registration | PRIVILEGE_ESCALATION | CRITICAL | HIGH |
| 8 | Critical IDOR - Unauthenticated Access to All User Data | INSECURE_DIRECT_OBJECT_REFERENCE | HIGH | HIGH |
アクセスログの分析
合計 26,120 行のアクセスログが記録された。検証 1(条件 A〜C)では数行〜十数行だったのとは桁違いである。
| リクエスト | 回数 |
|---|---|
POST /graphql | 3,601 |
GET /api/users?... | 2,766 |
GET /api/posts?... | 440 |
GET / | 245 |
GET /graphql | 141 |
GET /api/search?q=... | 95 |
Agent は Introspection クエリ(GET /graphql?query={__schema{types{name kind description}}})を実行してスキーマを取得し、3,601 回の POST リクエストで GraphQL クエリを送信した。
埋め込み脆弱性との対応
| # | 埋め込んだ脆弱性 | 検出 | 対応する Finding |
|---|---|---|---|
| 1 | ネストクエリによる過剰データ取得 | ✅ | #6 Nested Queries 経由の password_hash 漏洩 |
| 2 | フィールドレベル認可不備 | ✅ | #7 Mass Assignment、#8 IDOR |
| 3 | SQL Injection | ✅ | #5(ただし /api/search 経由で検出) |
| 4 | Alias ブルートフォース | ❌ | 直接的な検出なし(#1 Token Forgery は別アプローチ) |
| 5 | Mutation 戻り値の機密情報漏洩 | ✅ | #2 Password Hash Exposure |
5 件中 4 件を検出(偽陽性 1 件)。第1回の REST テスト(URL のみ、4/5 検出)と同等の検出率である。
なお、Finding #3(Null Byte Injection)と #4(Input Parsing Error)は埋め込んだ 5 つの脆弱性に含まれない追加発見である。Agent が独自に発見した入力バリデーション不備であり、#4 は Agent 自身が FALSE_POSITIVE と判定している。
脆弱性 #4(Alias ブルートフォース)は未検出だった。Finding #1(Token Forgery)は認証の脆弱性を検出しているが、アプローチが異なる。Alias ブルートフォースは「単一 GraphQL リクエスト内で Alias を使い複数の login を同時実行してレート制限を回避する」という GraphQL プロトコル固有の攻撃だが、Agent が検出したのは「login Mutation が返すトークンが平文ユーザー名であり、パスワードなしで任意のユーザーになりすませる」という認証設計の欠陥である。
また、SQL Injection(#5)は GraphQL の searchPosts Query ではなく、REST 風ルートの /api/search 経由で検出された。両者は同じ脆弱なコード(f-string による SQL 組み立て)を共有しているが、Agent は REST エンドポイントの方をテストしやすいと判断した可能性がある。GraphQL 経由でも同じ SQL Injection は存在するが、Agent が GraphQL Query のパラメータに対してインジェクションペイロードを送信したかは、アクセスログからは判別できなかった。
Agent は GraphQL 固有のテストを実施したか
Finding の詳細から、Agent が GraphQL 固有のテスト手法を使ったか分析した。
実施が確認できたもの:
- Introspection クエリ —
__schemaクエリでスキーマ構造を取得 - ネストクエリの探索 —
post(id)→author→password_hashというリレーションを辿って情報漏洩を検出 - Mutation パラメータ分析 —
createUserのroleパラメータが任意の値を受け入れることを検出 - フィールド選択の理解 — User 型の
password_hashフィールドが選択可能であることを検出 - 認証トークン分析 —
loginMutation のレスポンスを分析し、トークンが平文ユーザー名であることを発見
実施が確認できなかったもの:
- Alias ベースのブルートフォース — 単一クエリ内で Alias を使った複数 login 実行は未確認
- Depth Limit の検証 — ネストの深さ自体の問題は指摘されていない
- バッチクエリ — 配列での複数クエリ送信は未確認
Finding の内容から推測すると、Agent のアプローチは「GraphQL スキーマを Introspection で読み取り、各 Query/Mutation に対して従来の攻撃パターン(IDOR、情報漏洩、権限昇格)を適用する」というものである。少なくとも今回の検証では、GraphQL プロトコル固有の攻撃手法(Alias、バッチ、Depth 攻撃)の使用は確認できなかった。
4 条件比較
| 条件 | エンドポイント | ドキュメント | HTML リンク | Finding | PENTEST 時間 | 推定コスト |
|---|---|---|---|---|---|---|
| A: URL のみ | http://<host> | なし | なし | 0 | ~1 分 | ~$16 |
| B: URL + docs | http://<host> | SDL + API doc | なし | 0 | ~1 分 | ~$16 |
| C: 明示指定 | http://<host>/graphql | SDL + API doc | なし | 0 | ~1 分 | ~$18 |
| D: HTML 構造 | http://<host> | SDL + API doc | あり | 8 | 1 時間 30 分 | ~$120 |
検出の分岐点は明確である。今回の検証では HTML リンク構造の有無が結果を決めた。ドキュメント提供やエンドポイント明示指定では Finding が得られず、HTML の <a> タグやフォームを追加した条件 D でのみ 8 件の Finding が検出された。
まとめ
- Security Agent は GraphQL を理解してテストする能力を持っている — Introspection の実行、ネストクエリの探索、Mutation パラメータの分析など、GraphQL 固有のテストが確認できた。5 件中 4 件の脆弱性を検出し、REST テスト(第1回)と同等の検出率を達成した
- ただし、HTML リンク構造がないと能力を発揮できない — JSON のみのレスポンスではエンドポイントを発見できず、ドキュメント提供やエンドポイント明示指定でも改善しなかった。GraphQL API のみのアプリ(SPA のバックエンド等)では、Agent がテスト対象を認識できない可能性が高い
- GraphQL プロトコル固有の攻撃手法は未確認 — Alias ブルートフォース、バッチクエリ、Depth Limit 攻撃の使用は確認できなかった。Finding の内容から判断すると、Agent は GraphQL のスキーマ構造を理解した上で、REST と共通の攻撃パターンを適用しているように見える
- 実用上の対策: HTML エントリポイントを用意する — GraphQL API をテストする場合、今回の検証結果から、HTML ページ(リンクやフォームを含む)をエントリポイントとして用意することで検出能力を引き出せると考えられる。テスト専用の HTML ページを一時的にデプロイするアプローチが現実的である
クリーンアップ
リソース削除手順
Agent Space は第1回と共有しているため削除しない。今回作成したリソースのみ削除する。
REGION=ap-northeast-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
# EC2 インスタンス削除
aws ec2 terminate-instances --instance-ids i-0c33e55680c014e35 --region $REGION
aws ec2 wait instance-terminated --instance-ids i-0c33e55680c014e35 --region $REGION
# セキュリティグループ削除
aws ec2 delete-security-group --group-id sg-015279938a1092769 --region $REGION
# S3 バケット削除
aws s3 rb s3://security-agent-graphql-docs-${ACCOUNT_ID} --force --region $REGION
# CloudWatch ロググループ削除
for LG in graphql-pentest graphql-pentest-docs graphql-pentest-explicit graphql-pentest-v2; do
aws logs delete-log-group --log-group-name /aws/securityagent/$LG --region $REGION 2>/dev/null
done
# Target Domain 削除
aws securityagent delete-target-domain \
--target-domain-id td-038a9d82-90d2-5897-bf51-44b88ece712f --region $REGION