GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-W3F4-8PJ2-599W

Vulnerability from github – Published: 2026-09-17 17:16 – Updated: 2026-09-17 17:16
VLAI
Summary
Grav: Path Traversal in ImageMedium::watermark() — arbitrary file disclosure via publicly-cached images
Details

Reported 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).

  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
  3. 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.
  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.

Show details on source website

{
  "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![carrier](carrier.png?watermark=../secret_outside_root.png)\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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

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.


Loading…