OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-69089: Grav: Path Traversal in ImageMedium::watermark() — arbitrary file disclosure via publicly-cached images

GitHub Advisories · officialPublished Sep 17, 2026Risk 37/100

Reported by: Nihad Huseynli (@nihaddhuseynli (https://github.com/nihaddhuseynli)) — [email protected] ▎ Note: I attempted to report this via [email protected] first, per SECURITY.md, but the email bounced with 550 5.1.1 Address does not exist. Filing directly here instead. Path Traversal in ImageMedium::watermark() leading to arbitrary file disclosure via publicly-served images Summary The watermark media action, documented and allow-listed for use in editor-authored Markdown image syntax, passes its $image argument unsanitized into UniformResourceLocator::findResource(). That resolver only lexically collapses .. segments (no realpath()/containment check) and, for the default file:// scheme, resolves straight to file_exists() with no re-validation against the registered stream root. A relative-path traversal string therefore resolves to an arbitrary absolute path on disk. If that path is a valid image, its pixel content is composited into the carrier image and the result is cached and served from a public, unauthenticated URL — i.e. any file outside Grav's media sandbox that happens to be a decodable image becomes visible to anonymous visitors, not just to the attacker. Affected version - Grav CMS, develop/2.0 line, commit db8c1fcd63aaaf6d6b244bc6b4cfa5f7b96bbc7f (tip of 2.0.11 post-release). - Root cause lives in the pinned dependency rockettheme/toolbox v2.x-dev @ c569a53304cd7d95ff21bffa6fc590adcf0be83d (per composer.lock), specifically RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator. - Not yet fixed as of this commit; unrelated to the four GHSA-* advisories already patched in 2.0.7–2.0.11 (which addressed arbitrary method-name dispatch, not this parameter-content issue). Root cause UniformResourceLocator::normalize() (ResourceLocator/src/UniformResourceLocator.php:261) cleans ../. segments purely as string manipulation against $this->base: foreach ($parts as $i => $part) { if ($part === '..') { $part = array_pop($list); if ($part === null || $part === '' || (!$list && strpos($part, ':'))) { return false; // only refuses once popped past the leading sentinel } } ... } Given enough ../ segments to match the depth of $this->base, this legitimately resolves to any absolute path on the filesystem, as string math. The file://-scheme branch of findCached() (UniformResourceLocator.php:476-493) then trusts that normalized path directly: if ($scheme === 'file') { if (!$all && !file_exists($file)) { $this->cache[$key] = $array ? [] : false; } else { $this->cache[$key] = $array ? [$file] : $file; // <-- returned as-is } } Unlike the else branch (find()), which re-glues resolved filenames onto a registered scheme root, the file:// branch performs no containment check. ImageMedium::watermark() (system/src/Grav/Common/Page/Medium/ImageMedium.php:367) feeds attacker-influenced input straight into this resolver: public function watermark($image = null, $position = null, $scale = null) { ... $args = func_get_args(); $file = $args[0] ?? '1'; $file = $file === '1' ? $config->get('system.images.watermark.image') : $args[0]; $watermark = $locator->findResource($file); // no path validation $watermark = ImageFile::open($watermark); // decoded & composited ... } watermark is on Grav's own documented allow-list of Markdown image actions (Medium::ALLOWED_ACTIONS), so it is directly reachable through Excerpts::processMediaActions() (system/src/Grav/Common/Page/Markdown/Excerpts.php:262), which parses the querystring of any Markdown image reference and dispatches call_user_func_array([$medium, $action['method']], $args) for allow-listed methods — watermark's own parameter is never checked for path-safety anywhere in that chain. Threat model Per Grav's own SECURITY.md trust-boundary rubric: a publisher/editor (page-edit rights, no admin panel super-user access required) authors ordinary page content — the same trust tier already covered by the project's last four security advisories (GHSA-fj2p-qj2f-74v5, GHSA-c4wf-2xxc-68qm, GHSA-xwv3-2mv2-w33x, GHSA-ffmg-hfvg-jhg9). This is a new instance of that same "editor escapes their content sandbox" bug family, via an image-processing parameter rather than method-name dispatch. Impact is not limited to the editor's own session: once the page is saved, any anonymous site visitor who requests the page causes the traversal to execute (if not already cached), and the resulting composited image is served from a public, unauthenticated cache URL. Proof of Concept Reproduced end-to-end against a clean local install of the affected commit (PHP 8.4.22, PHP built-in server, composer install --no-dev, bin/grav install). 1. Outside the Grav webroot (one directory up), place a distinguishable "secret" image: a solid red 200x200 PNG, secret_outside_root.png. 2. As an editor account (page-edit permission only, no admin.super), create a page with a solid blue 200x200 PNG carrier.png alongside it, and page content: ![carrier](carrier.png?watermark=../secret_outside_root.png) 3. Any anonymous visitor requests the page: GET /poc. Grav renders an <img> tag pointing at a cached, public derivative URL, e.g. /images/b/2/8/2/2/b282200a65ce979377963180629babd2335212ba-carrier.png. 4. Fetching that URL (again unauthenticated) and sampling pixels confirms the composited output contains the secret file's content: corner pixel (from carrier.png): RGB(0, 0, 255) — blue, expected center pixel (from secret_outside_root.png): RGB(255, 0, 0) — red, exfiltrated (Test images and the exfiltrated output are attached separately — let me know if you need them regenerated.) Suggested fix - In UniformResourceLocator::findCached()'s file:// branch, resolve the candidate path with realpath() and verify it remains inside $this->base before returning it — mirroring the containment that already exists implicitly in the non-file branch (find()). - Independently, in ImageMedium::watermark(), restrict $image to a filename (reject any value containing /, \, or resolving outside user/pages/**/media and the configured watermark image root) before calling findResource(). Suggested severity High — a lower-privilege actor's stored content results in exfiltration of data outside that actor's granted scope, and the exfiltrated data is exposed to anonymous third parties via a public cache URL, not just back to the attacker.

Upgrade affected packages to a patched version: getgrav/grav 2.0.11.

Vendor
Not specified
Product
getgrav/grav
Exploitation
none known
Evidence
official

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source