GHSA-W3F4-8PJ2-599W
Vulnerability from github – Published: 2026-09-17 17:16 – Updated: 2026-09-17 17:16Reported by: Nihad Huseynli (@nihaddhuseynli (https://github.com/nihaddhuseynli)) — nihadd.huseynli@gmail.com
▎ Note: I attempted to report this via security@getgrav.org 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).
- Outside the Grav webroot (one directory up), place a distinguishable "secret" image: a solid red 200x200 PNG, secret_outside_root.png.
- 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:

- Any anonymous visitor requests the page: GET /poc. Grav renders an
tag pointing at a cached, public derivative URL, e.g. /images/b/2/8/2/2/b282200a65ce979377963180629babd2335212ba-carrier.png.
- 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.
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "getgrav/grav"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.10"
},
{
"fixed": "2.0.11"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.0.10"
]
}
],
"aliases": [
"CVE-2026-69089"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-17T17:16:56Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "Reported by: Nihad Huseynli (@nihaddhuseynli (https://github.com/nihaddhuseynli)) \u2014 nihadd.huseynli@gmail.com\n\n\u258e Note: I attempted to report this via security@getgrav.org first, per SECURITY.md, but the email bounced with 550 5.1.1 Address does not exist. Filing directly here instead.\n\nPath Traversal in ImageMedium::watermark() leading to arbitrary file disclosure via publicly-served images\n\nSummary\n\nThe 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 \u2014 i.e. any file outside Grav\u0027s media sandbox that happens to be a decodable image becomes visible to anonymous visitors, not just to the attacker.\n\nAffected version\n\n- Grav CMS, develop/2.0 line, commit db8c1fcd63aaaf6d6b244bc6b4cfa5f7b96bbc7f (tip of 2.0.11 post-release).\n- Root cause lives in the pinned dependency rockettheme/toolbox v2.x-dev @ c569a53304cd7d95ff21bffa6fc590adcf0be83d (per composer.lock), specifically RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator.\n- Not yet fixed as of this commit; unrelated to the four GHSA-* advisories already patched in 2.0.7\u20132.0.11 (which addressed arbitrary method-name dispatch, not this parameter-content issue).\n\nRoot cause\n\nUniformResourceLocator::normalize() (ResourceLocator/src/UniformResourceLocator.php:261) cleans ../. segments purely as string manipulation against $this-\u003ebase:\n\nforeach ($parts as $i =\u003e $part) {\n if ($part === \u0027..\u0027) {\n $part = array_pop($list);\n if ($part === null || $part === \u0027\u0027 || (!$list \u0026\u0026 strpos($part, \u0027:\u0027))) {\n return false; // only refuses once popped past the leading sentinel\n }\n } ...\n}\n\nGiven enough ../ segments to match the depth of $this-\u003ebase, 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:\n\nif ($scheme === \u0027file\u0027) {\n if (!$all \u0026\u0026 !file_exists($file)) {\n $this-\u003ecache[$key] = $array ? [] : false;\n } else {\n $this-\u003ecache[$key] = $array ? [$file] : $file; // \u003c-- returned as-is\n }\n}\n\nUnlike the else branch (find()), which re-glues resolved filenames onto a registered scheme root, the file:// branch performs no containment check.\n\nImageMedium::watermark() (system/src/Grav/Common/Page/Medium/ImageMedium.php:367) feeds attacker-influenced input straight into this resolver:\n\npublic function watermark($image = null, $position = null, $scale = null)\n{\n ...\n $args = func_get_args();\n $file = $args[0] ?? \u00271\u0027;\n $file = $file === \u00271\u0027 ? $config-\u003eget(\u0027system.images.watermark.image\u0027) : $args[0];\n\n $watermark = $locator-\u003efindResource($file); // no path validation\n $watermark = ImageFile::open($watermark); // decoded \u0026 composited\n ...\n}\n\nwatermark is on Grav\u0027s 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[\u0027method\u0027]], $args) for allow-listed methods \u2014 watermark\u0027s own parameter is never checked for path-safety anywhere in that chain.\n\nThreat model\n\nPer Grav\u0027s own SECURITY.md trust-boundary rubric: a publisher/editor (page-edit rights, no admin panel super-user access required) authors ordinary page content \u2014 the same trust tier already covered by the project\u0027s 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.\n\nImpact is not limited to the editor\u0027s 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.\n\nProof of Concept\n\nReproduced 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).\n\n1. Outside the Grav webroot (one directory up), place a distinguishable \"secret\" image: a solid red 200x200 PNG, secret_outside_root.png.\n2. 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:\n\n3. Any anonymous visitor requests the page: GET /poc. Grav renders an \u003cimg\u003e tag pointing at a cached, public derivative URL, e.g. /images/b/2/8/2/2/b282200a65ce979377963180629babd2335212ba-carrier.png.\n4. Fetching that URL (again unauthenticated) and sampling pixels confirms the composited output contains the secret file\u0027s content:\ncorner pixel (from carrier.png): RGB(0, 0, 255) \u2014 blue, expected\ncenter pixel (from secret_outside_root.png): RGB(255, 0, 0) \u2014 red, exfiltrated\n\n(Test images and the exfiltrated output are attached separately \u2014 let me know if you need them regenerated.)\n\nSuggested fix\n\n- In UniformResourceLocator::findCached()\u0027s file:// branch, resolve the candidate path with realpath() and verify it remains inside $this-\u003ebase before returning it \u2014 mirroring the containment that already exists implicitly in the non-file branch (find()).\n- 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().\n\nSuggested severity\n\nHigh \u2014 a lower-privilege actor\u0027s stored content results in exfiltration of data outside that actor\u0027s granted scope, and the exfiltrated data is exposed to anonymous third parties via a public cache URL, not just back to the attacker.",
"id": "GHSA-w3f4-8pj2-599w",
"modified": "2026-09-17T17:16:56Z",
"published": "2026-09-17T17:16:56Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/security/advisories/GHSA-w3f4-8pj2-599w"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-69089"
},
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/commit/b282200a65ce979377963180629babd2335212ba"
},
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/commit/c569a53304cd7d95ff21bffa6fc590adcf0be83d"
},
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/commit/db8c1fcd63aaaf6d6b244bc6b4cfa5f7b96bbc7f"
},
{
"type": "PACKAGE",
"url": "https://github.com/getgrav/grav"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/grav-cms-before-path-traversal-via-watermark"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Grav: Path Traversal in ImageMedium::watermark() \u2014 arbitrary file disclosure via publicly-cached images"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.