GHSA-8PV3-29PP-PF8F

Vulnerability from github – Published: 2026-04-14 23:22 – Updated: 2026-04-14 23:22
VLAI?
Summary
WWBN AVideo has Stored XSS via Unanchored Duration Regex in Video Encoder Receiver
Details

Summary

The isValidDuration() regex at objects/video.php:918 uses /^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}/ without a $ end anchor, allowing arbitrary HTML/JavaScript to be appended after a valid duration prefix. The crafted duration is stored in the database and rendered without HTML escaping via echo Video::getCleanDuration() on trending pages, playlist pages, and video gallery thumbnails, resulting in stored cross-site scripting.

Details

Input entry point: objects/aVideoEncoderReceiveImage.json.php:208

// Line 203-211
if (!empty($_REQUEST['duration'])) {
    $video->setDuration($_REQUEST['duration']);
}

Insufficient validation: objects/video.php:918

static function isValidDuration($duration) {
    // ...
    return preg_match('/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}/', $duration);
    //     Missing $ anchor here -----------------------------------^
}

The regex matches 00:00:01 at the start of the string but ignores everything after it. A payload like 00:00:01</time><img src=x onerror=alert(1)><time> passes validation.

No sanitization in output function: objects/video.php:3463-3480

public static function getCleanDuration($duration = "") {
    $durationParts = explode(".", $duration);
    $duration = $durationParts[0];
    $durationParts = explode(':', $duration);
    if (count($durationParts) == 1) {
        return '0:00:' . static::addZero($durationParts[0]);
    } elseif (count($durationParts) == 2) {
        return '0:' . static::addZero($durationParts[0]) . ':' . static::addZero($durationParts[1]);
    }
    return $duration; // Returns full string unmodified for 3+ colon parts
}

With the payload 00:00:01</time><img src=x onerror=alert(1)><time>, exploding by : yields 3+ parts, so the full unsanitized string is returned.

Unescaped output sinks:

  1. view/trending.php:72:
<time class="duration"><?php echo Video::getCleanDuration($value['duration']); ?></time>
  1. view/include/playlist.php:159:
<time class="duration"><?php echo Video::getCleanDuration(@$value['duration']); ?></time>
  1. objects/video.php:7200 (gallery thumbnail generation):
$img .= "<time class=\"duration\"...>" . $duration . "</time>";

No Content-Security-Policy headers are set. The application uses raw PHP templates with no auto-escaping framework.

PoC

  1. Authenticate as a user with upload permission and obtain a video_id_hash for a video (visible in encoder API responses or via the upload flow).

  2. Send the malicious duration:

curl -X POST "https://target/objects/aVideoEncoderReceiveImage.json.php" \
  -d "videos_id=VIDEO_ID" \
  -d "video_id_hash=HASH" \
  -d 'duration=00:00:01</time><img src=x onerror=alert(document.cookie)><time>'
  1. The isValidDuration() regex matches the 00:00:01 prefix and allows the full string to be stored.

  2. Visit the trending page (/view/trending.php) or any playlist containing the poisoned video. The injected HTML breaks out of the <time> tag and the onerror handler executes JavaScript in the victim's browser context.

Impact

  • Session hijacking: Attacker can steal session cookies of any user (including administrators) who views a page listing the poisoned video (trending, playlists, search results, channel pages).
  • Account takeover: Stolen admin session cookies grant full platform control.
  • Phishing: Attacker can inject fake login forms or redirect users to malicious sites.
  • Worm potential: Since the XSS fires on commonly-visited listing pages (trending), it can propagate without targeted delivery — any visitor is a victim.

The attack requires only upload-level permissions (low privilege) and impacts all users who view any page rendering the poisoned video's duration (high blast radius).

Recommended Fix

Fix 1 — Anchor the regex (objects/video.php:918):

- return preg_match('/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}/', $duration);
+ return preg_match('/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}(\.[0-9]+)?$/', $duration);

Fix 2 — HTML-escape all duration output (defense in depth):

In view/trending.php:72:

- <time class="duration"><?php echo Video::getCleanDuration($value['duration']); ?></time>
+ <time class="duration"><?php echo htmlspecialchars(Video::getCleanDuration($value['duration']), ENT_QUOTES, 'UTF-8'); ?></time>

In view/include/playlist.php:159:

- <time class="duration"><?php echo Video::getCleanDuration(@$value['duration']); ?></time>
+ <time class="duration"><?php echo htmlspecialchars(Video::getCleanDuration(@$value['duration']), ENT_QUOTES, 'UTF-8'); ?></time>

In objects/video.php:7200:

- $img .= "<time class=\"duration\"...>" . $duration . "</time>";
+ $img .= "<time class=\"duration\"...>" . htmlspecialchars($duration, ENT_QUOTES, 'UTF-8') . "</time>";

Both fixes should be applied: the regex fix prevents storage of invalid data, and the output escaping provides defense in depth against any other code path that might store unvalidated durations.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "wwbn/avideo"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "29.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-14T23:22:21Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe `isValidDuration()` regex at `objects/video.php:918` uses `/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}/` without a `$` end anchor, allowing arbitrary HTML/JavaScript to be appended after a valid duration prefix. The crafted duration is stored in the database and rendered without HTML escaping via `echo Video::getCleanDuration()` on trending pages, playlist pages, and video gallery thumbnails, resulting in stored cross-site scripting.\n\n## Details\n\n**Input entry point:** `objects/aVideoEncoderReceiveImage.json.php:208`\n\n```php\n// Line 203-211\nif (!empty($_REQUEST[\u0027duration\u0027])) {\n    $video-\u003esetDuration($_REQUEST[\u0027duration\u0027]);\n}\n```\n\n**Insufficient validation:** `objects/video.php:918`\n\n```php\nstatic function isValidDuration($duration) {\n    // ...\n    return preg_match(\u0027/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}/\u0027, $duration);\n    //     Missing $ anchor here -----------------------------------^\n}\n```\n\nThe regex matches `00:00:01` at the start of the string but ignores everything after it. A payload like `00:00:01\u003c/time\u003e\u003cimg src=x onerror=alert(1)\u003e\u003ctime\u003e` passes validation.\n\n**No sanitization in output function:** `objects/video.php:3463-3480`\n\n```php\npublic static function getCleanDuration($duration = \"\") {\n    $durationParts = explode(\".\", $duration);\n    $duration = $durationParts[0];\n    $durationParts = explode(\u0027:\u0027, $duration);\n    if (count($durationParts) == 1) {\n        return \u00270:00:\u0027 . static::addZero($durationParts[0]);\n    } elseif (count($durationParts) == 2) {\n        return \u00270:\u0027 . static::addZero($durationParts[0]) . \u0027:\u0027 . static::addZero($durationParts[1]);\n    }\n    return $duration; // Returns full string unmodified for 3+ colon parts\n}\n```\n\nWith the payload `00:00:01\u003c/time\u003e\u003cimg src=x onerror=alert(1)\u003e\u003ctime\u003e`, exploding by `:` yields 3+ parts, so the full unsanitized string is returned.\n\n**Unescaped output sinks:**\n\n1. `view/trending.php:72`:\n```php\n\u003ctime class=\"duration\"\u003e\u003c?php echo Video::getCleanDuration($value[\u0027duration\u0027]); ?\u003e\u003c/time\u003e\n```\n\n2. `view/include/playlist.php:159`:\n```php\n\u003ctime class=\"duration\"\u003e\u003c?php echo Video::getCleanDuration(@$value[\u0027duration\u0027]); ?\u003e\u003c/time\u003e\n```\n\n3. `objects/video.php:7200` (gallery thumbnail generation):\n```php\n$img .= \"\u003ctime class=\\\"duration\\\"...\u003e\" . $duration . \"\u003c/time\u003e\";\n```\n\nNo Content-Security-Policy headers are set. The application uses raw PHP templates with no auto-escaping framework.\n\n## PoC\n\n1. Authenticate as a user with upload permission and obtain a `video_id_hash` for a video (visible in encoder API responses or via the upload flow).\n\n2. Send the malicious duration:\n\n```bash\ncurl -X POST \"https://target/objects/aVideoEncoderReceiveImage.json.php\" \\\n  -d \"videos_id=VIDEO_ID\" \\\n  -d \"video_id_hash=HASH\" \\\n  -d \u0027duration=00:00:01\u003c/time\u003e\u003cimg src=x onerror=alert(document.cookie)\u003e\u003ctime\u003e\u0027\n```\n\n3. The `isValidDuration()` regex matches the `00:00:01` prefix and allows the full string to be stored.\n\n4. Visit the trending page (`/view/trending.php`) or any playlist containing the poisoned video. The injected HTML breaks out of the `\u003ctime\u003e` tag and the `onerror` handler executes JavaScript in the victim\u0027s browser context.\n\n## Impact\n\n- **Session hijacking**: Attacker can steal session cookies of any user (including administrators) who views a page listing the poisoned video (trending, playlists, search results, channel pages).\n- **Account takeover**: Stolen admin session cookies grant full platform control.\n- **Phishing**: Attacker can inject fake login forms or redirect users to malicious sites.\n- **Worm potential**: Since the XSS fires on commonly-visited listing pages (trending), it can propagate without targeted delivery \u2014 any visitor is a victim.\n\nThe attack requires only upload-level permissions (low privilege) and impacts all users who view any page rendering the poisoned video\u0027s duration (high blast radius).\n\n## Recommended Fix\n\n**Fix 1 \u2014 Anchor the regex** (`objects/video.php:918`):\n\n```php\n- return preg_match(\u0027/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}/\u0027, $duration);\n+ return preg_match(\u0027/^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}(\\.[0-9]+)?$/\u0027, $duration);\n```\n\n**Fix 2 \u2014 HTML-escape all duration output** (defense in depth):\n\nIn `view/trending.php:72`:\n```php\n- \u003ctime class=\"duration\"\u003e\u003c?php echo Video::getCleanDuration($value[\u0027duration\u0027]); ?\u003e\u003c/time\u003e\n+ \u003ctime class=\"duration\"\u003e\u003c?php echo htmlspecialchars(Video::getCleanDuration($value[\u0027duration\u0027]), ENT_QUOTES, \u0027UTF-8\u0027); ?\u003e\u003c/time\u003e\n```\n\nIn `view/include/playlist.php:159`:\n```php\n- \u003ctime class=\"duration\"\u003e\u003c?php echo Video::getCleanDuration(@$value[\u0027duration\u0027]); ?\u003e\u003c/time\u003e\n+ \u003ctime class=\"duration\"\u003e\u003c?php echo htmlspecialchars(Video::getCleanDuration(@$value[\u0027duration\u0027]), ENT_QUOTES, \u0027UTF-8\u0027); ?\u003e\u003c/time\u003e\n```\n\nIn `objects/video.php:7200`:\n```php\n- $img .= \"\u003ctime class=\\\"duration\\\"...\u003e\" . $duration . \"\u003c/time\u003e\";\n+ $img .= \"\u003ctime class=\\\"duration\\\"...\u003e\" . htmlspecialchars($duration, ENT_QUOTES, \u0027UTF-8\u0027) . \"\u003c/time\u003e\";\n```\n\nBoth fixes should be applied: the regex fix prevents storage of invalid data, and the output escaping provides defense in depth against any other code path that might store unvalidated durations.",
  "id": "GHSA-8pv3-29pp-pf8f",
  "modified": "2026-04-14T23:22:21Z",
  "published": "2026-04-14T23:22:21Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/WWBN/AVideo/security/advisories/GHSA-8pv3-29pp-pf8f"
    },
    {
      "type": "WEB",
      "url": "https://github.com/WWBN/AVideo/commit/bcba324644df8b4ed1f891462455f1cd26822a45"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/WWBN/AVideo"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "WWBN AVideo has Stored XSS via Unanchored Duration Regex in Video Encoder Receiver"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

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…