CVE-2026-69089High▾ TwilightGrav: Path Traversal in ImageMedium::watermark() — arbitrary file disclosure via publicly-cached images
▾ Twilight zone — High severity, or a signal on a lesser flaw
impact 41.3 · likelihood 0.1 · exploitation 0
Need a working PoC? Pro members can cast a request and our team develops one — it lands right here.
Exploit-prediction probability, daily snapshots since Sep 17.
Disclosure to exploitation, from the record and what we observed since indexing it.
Disclosed via GHSA
0.4%
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
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).

(Test images and the exfiltrated output are attached separately — let me know if you need them regenerated.)
Suggested fix
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.
getgrav/grav = 2.0.10Upgrade to a patched release:
getgrav/grav 2.0.11Connected by shared product, vendor, weakness, or advisory.
GHSA-mmwh-j75q-gxp8High· 7.5Duplicate Advisory: Grav: Path Traversal in ImageMedium::watermark() — arbitrary file disclosure via publicly-cached images
CVE-2026-72697High· 6.5Grav: media_directory() Twig function allows filesystem path traversal and file content disclosure from sandboxed page content
GHSA-896w-cw95-xq7wHigh· 8.1Duplicate Advisory: Grav: Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion
CVE-2026-72695High· 8.1Grav: Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion
GHSA-2rhw-8953-48q3High· 5.9Duplicate Advisory: Grav: Unauthenticated Path Traversal via Missing Directory-Boundary Check in `plugin-asset-map.php` Static Asset Server (`index.php`)
GHSA-rj4c-4q9x-543xHigh· 6.5Duplicate Advisory: Grav: media_directory() Twig function allows filesystem path traversal and file content disclosure from sandboxed page content