highCVE-2026-69089Sep 17, 2026

CVE-2026-69089: Grav CMS Path Traversal via ImageMedium::watermark()

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A Grav CMS editor can read image files anywhere on the server by pointing the watermark action at a relative path like ../secret.png, and the composited result ends up cached at a public URL any…

Packagegetgrav/grav
Ecosystemcomposer
Affected= 2.0.10
Fixed in2.0.11

The problem

Grav 2.0.10 lets editors use the watermark query parameter in Markdown image syntax to composite a second image onto a carrier. The $image argument is passed directly to UniformResourceLocator::findResource() with no path validation.

The file:// scheme branch of that resolver collapses ../ segments with pure string math against $this->base, then calls file_exists() on the result and returns it as-is. There is no realpath() call and no check that the resolved path stays inside the media sandbox.

Any image-decodable file the web-server process can read becomes reachable, and the composited output is saved to a public, unauthenticated cache URL, exposing the content to anonymous visitors, not just the attacker.

Proof of concept

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

text
![carrier](carrier.png?watermark=../secret_outside_root.png)

watermark is on Grav's own ALLOWED_ACTIONS list, so Excerpts::processMediaActions() dispatches it via call_user_func_array() without any parameter inspection. Inside watermark(), $args[0] feeds straight into $locator->findResource(), which in the file:// branch performs only lexical ../ collapsing (UniformResourceLocator::normalize()) and no containment check before returning the path.

The number of ../ segments needed equals the depth of the page's media directory below the webroot, typically two or three. Once the traversal resolves to a valid image, ImageFile::open() decodes and composites it, and the result is written to the public /images/ cache.

The patch (commit b282200) adds a containment guard in watermark() so editor-supplied paths are restricted to the site's own media tree; operator-configured watermarks and stream URIs are unaffected. Root cause is CWE-22.

The fix

Upgrade to Grav CMS 2.0.11. The fix (commits b282200, c569a53, db8c1fc) adds a path-containment check in ImageMedium::watermark() so editor-supplied watermark paths are confined to the site's media directories. As a temporary workaround, restrict all accounts to read-only page access and remove page-edit permissions until the upgrade is applied.

Reported by Nihad Huseynli (@nihaddhuseynli).

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

Related research