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

Grav's watermark media action passes unsanitized paths to UniformResourceLocator, letting an editor traverse outside the media sandbox and expose arbitrary image files via public cache URLs.

- Payout: Research
- Severity: high
- Program: Grav
- Found by: nihaddhuseynli
- Disclosed: 2026-09-17
- CVE: CVE-2026-69089
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-w3f4-8pj2-599w
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-w3f4-8pj2-599w
- Tags: area:web domain:web impact:data-leak kind:research platform:ghsa sev:high tech:php technique:chained technique:source-review vuln:info-disclosure vuln:path-traversal

**Target:** Grav CMS image watermark media action / UniformResourceLocator resolver
**Root cause:** UniformResourceLocator::normalize() collapses ../ lexically and the file:// branch of findCached() returns the resolved path without realpath()/containment check; ImageMedium::watermark() feeds attacker-controlled Markdown querystring args into findResource() with no path validation.
**Steps:**
1. Place a secret image (e.g. secret_outside_root.png) one directory above the Grav webroot.
2. As an editor (page-edit only), create a page with a carrier.png and content `![carrier](carrier.png?watermark=../secret_outside_root.png)`.
3. Anonymous visitor requests the page; Grav renders an <img> pointing at a cached derivative URL like `/images/b/2/8/2/2/b282200a65ce979377963180629babd2335212ba-carrier.png`.
4. Fetch that URL unauthenticated and sample pixels: center pixel RGB(255,0,0) confirms the secret file was composited in.
**How found:** Reporter traced the allow-listed watermark action through Excerpts::processMediaActions into ImageMedium::watermark(), noticed findResource() lacked validation, then read UniformResourceLocator::normalize()/findCached() and observed the file:// branch trusts the lexically-normalized path without realpath() containment, unlike find().
**Exploitation:** Editor stores a Markdown image with a watermark querystring containing ../ traversal; on page render Excerpts::processMediaActions dispatches watermark(), which resolves the traversal to an arbitrary absolute path, decodes it as an image, composites it into the carrier, and caches the result at a public unauthenticated URL any visitor can fetch.
**Escalation:** Impact extends beyond the editor's session: the composited secret is served to anonymous third parties from a public cache URL, not just back to the attacker.
**Insight:** Allow-listed media actions that take a filename argument are a separate attack surface from method-name dispatch; check whether the resolver's file:// branch does realpath() containment while the scheme-root branch re-glues — that asymmetry is the bug.

## Proof of concept (copied exactly from the original report)

**Markdown image with watermark path traversal payload**

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

