//nefariousplan

CVE-2025-22457: Ivanti's End-of-Life Branch Was UNC5221's Lab

patterns

cve

proof of concept

Ivanti released ICS 22.7R2.6 on February 11, 2025, fixing a buffer overflow in the X-Forwarded-For header parser. The advisory called it a low-risk denial of service: the input is constrained to characters matching [0-9.], "evaluated and determined not to be exploitable as remote code execution and didn't meet the requirements of denial of service." The same code, unfixed, runs on the 9.x branch (Pulse Connect Secure), because 9.x is end of life.

Five weeks later UNC5221 was inside customers on both branches.

strlcpy's third argument is the size of the destination buffer

watchTowr's binary diff against 22.7R2.5 disclosed the pre-patch code path in WebRequest::dispatchRequest:

headerValue = httpHeader->Value;
ipLen = strspn(headerValue, "01234567890.");
v58 = alloca(ipLen + 2);
strlcpy(s, headerValue, ipLen + 1);

The dispatcher reads the value of an inbound HTTP header named by the CUSTOM_IP_FIELD configuration setting, which DSDefaults.pm initializes to X-Forwarded-For. It counts the prefix of that value that consists of digits and dots, storing the count in ipLen. It allocates alloca(ipLen + 2) bytes on the stack into v58, which is the right sizing for a destination derived from the input. It then calls strlcpy(s, headerValue, ipLen + 1) into a different destination, s, a fixed-size stack buffer that has nothing to do with ipLen.

strlcpy(dest, src, n) copies at most n - 1 bytes and NUL-terminates. The third argument is the size of the destination buffer. The bug is the third argument: dispatchRequest passes a value derived from the source, not from the destination. The alloca on the line above is the developer remembering that headerValue is variable-length. The strlcpy on the next line is the same developer forgetting it. There is no upper bound on ipLen, because strspn walks the string until it finds a non-matching byte. Send X-Forwarded-For: followed by two thousand dots, and s overflows by approximately two thousand bytes of attacker-chosen [0-9.] content.

The character constraint [0-9.] is real, and it is also why filtering the source bytes does not fix the destination. The filter is on the bytes you can write. The bug is on how many of them you write. The 22.7R2.6 patch is described by watchTowr as a complete rewrite of WebRequest::dispatchRequest. A single-call fix would have been one argument: replace ipLen + 1 with sizeof(s). The vendor chose to rewrite the function. That is the size of their own audit response to the bug they had just rated low-severity.

The character set was the rating

Ivanti's February advisory said the bug was "a buffer overflow with characters limited to periods and numbers, evaluated and determined not to be exploitable as remote code execution and didn't meet the requirements of denial of service." That was the public risk assessment, baked into a binary diff and shipped without an embargo. It was correct that constrained input makes exploitation harder. It was wrong that it makes it impossible.

The character set is what UNC5221 spent the budget on. It did not buy customers safety. It bought UNC5221 five weeks: the gap between February 11 (the patch) and mid-March (the earliest in-wild exploitation Mandiant reports), during which the patch existed, the working exploit did not yet, and the customer fleet had been told to schedule the fix as routine maintenance.

Mandiant's April 3 assessment, after the breach reports came in: "We assess it is likely the threat actor studied the patch for the vulnerability in ICS 22.7R2.6 and uncovered through a complicated process, it was possible to exploit 22.7R2.5 and earlier to achieve remote code execution."

The patch was the disclosure. The disclosure was a public risk assessment. The risk assessment was wrong. This is disclosure-after-exploitation with the disclosure baked into a binary diff: patch first, exploit second, breach third, advisory fourth, in that order, with the customer fleet learning the rating was wrong by reading their incident-response reports.

The bug is in the dispatcher, not in any endpoint

The PoC scanner from securekomodo demonstrates the crash on /dana-na/auth/url_default/welcome.cgi and falls back to /dana-na/setup/psaldownload.cgi. The README says the welcome path can be renamed by Ivanti administrators, but psaldownload "cannot be modified, and any .cgi endpoint appears potentially affected." That is correct, and it follows from where the bug lives.

WebRequest::dispatchRequest runs on every inbound HTTP request before any handler is selected. The header read happens at the outermost layer of request processing, where the appliance is deciding whose IP to log this request as. There is no endpoint to disable. There is no path to firewall. The attack surface is the act of accepting an HTTP request that has an X-Forwarded-For header, which every inbound request can carry.

The customer who renamed welcome.cgi to obscure their fingerprint did not reduce attack surface. They reduced one attacker's reconnaissance and left the bug in the dispatcher. The bug is not in welcome.cgi. The bug is in the function welcome.cgi reaches first, and so does every other request the appliance accepts.

The 9.x branch was the lab

Pulse Connect Secure 9.x ships the same X-Forwarded-For handler in the same web process. 9.x is end of life: no February patch, no rewritten dispatchRequest, and per the watchTowr binary analysis, no exploit mitigations on the binary. The 22.7R2.6 patch closed the bug AND turned on stack canaries and other mitigations the previous build had been compiled without. 9.x carries none of those.

The mitigations missing on 9.x map directly to the work an exploit developer would have to do without them:

  • No stack canary. The strlcpy overflow rewrites the saved frame pointer and return address with no __stack_chk_fail guard to abort. No canary leak required, no second bug to defeat the canary.
  • No PIE/ASLR on the web binary. Code addresses are predictable across runs. ROP gadgets resolve to fixed offsets, no information leak required.
  • No NX on the relevant region or no full RELRO. Either gives a writable target. The constrained [0-9.] character set still has to be threaded through, but on a fixed-address binary the gadget search is a one-time job, not a per-target one.
  • No FORTIFY_SOURCE. The strlcpy with a source-derived bound passes through without a runtime length-checking variant catching it.

The 22.7R2.6 patch turned all of those on. The 22.7R2.5 binary was compiled without them. The 9.x binary was compiled without them. After the patch, the 22.x branch had two layers of defense: the rewritten dispatchRequest (the bug is gone) and the mitigations (a future bug in the same process is harder to weaponize). The 9.x branch had neither.

If you were developing this exploit, you would prefer the unmitigated branch. The constrained character set is the same on both branches. The mitigations are not. ROP gadgets are easier to find in a binary without ASLR. Stack writes do not collide with a canary that is not present. Mandiant identified active exploitation against ICS 9.X and 22.7R2.5 simultaneously. The chronology and the branch coverage are consistent with an actor reverse-engineering the February patch on 22.7R2.6 to locate the bug, validating the primitive on the easier of the two unfixed branches, and porting from there to where the customers were.

End-of-life is normal industry practice. The operational consequence is also normal: a branch the vendor has stopped patching is a branch the bug stays alive on, with no protections added. End-of-life is not a vendor-internal calendar event. It is a published statement of which branches will hold the next bug class indefinitely. The patch that landed on 22.x was, by omission, also an admission that the same bug was alive on a branch Ivanti had told customers to migrate off of and had not.

Three Ivanti web-process buffer overflows in fourteen months

CVE-2024-21887 (January 2024, command injection reachable through the same web layer). CVE-2025-0282 (January 2025, stack overflow in the IKEv2 authentication handler). CVE-2025-22457 (April 2025, stack overflow in dispatchRequest). Three CVEs, three different functions, three different protocols, one substrate: Ivanti's web process, parsing untrusted HTTP and IKEv2 inputs into fixed-size stack buffers across a shared codebase that ships to ICS, IPS, ZTA, and Pulse Connect Secure.

This is the Ivanti instance of Design Debt Driver, and it has its own editorial post on this blog from January, when CVE-2025-0282 was the latest installment. CVE-2025-22457 is the next exhibit. Same component, different copy function, same shape, same operator.

UNC5221's post-exploitation kit deploys TRAILBLAZE, an in-memory dropper, and BRUSHFIRE, a passive backdoor, by injecting into the running /home/bin/web process. The web process is the substrate that produced the bug. The web process is also the persistence host. The function with the unsafe strlcpy is the function the implant lives in. SPAWN, SPAWNANT, SPAWNMOLE, SPAWNSNAIL, the family the same operator has been deploying across Ivanti CVEs since UNC5221 was first attributed in 2024, all run inside the same process this CVE delivers code execution to. The attribution chain is not "the actor used the bug." It is "the actor's persistent infrastructure was already designed for the process this bug delivers to."

The February patch closed the dispatchRequest instance. UNC5221's tempo, one Ivanti CVE every six months across CVE-2024-21887, CVE-2024-21893, CVE-2024-8190, CVE-2024-8963, CVE-2025-0282, and now CVE-2025-22457, is the substrate's natural metabolism. The next CVE in this class will land in the same process, in a different function the same shared codebase produces.

Ivanti called it a DoS. UNC5221 called it homework.

Two readers of the February patch held different vocabularies. The vendor wrote "buffer overflow with characters limited to periods and numbers, evaluated and determined not to be exploitable as remote code execution." UNC5221 read "buffer overflow." The character set that gave Ivanti the low rating gave UNC5221 the complexity budget. The end-of-life branch that Ivanti had stopped patching gave UNC5221 a development environment without mitigations. The build flags Ivanti turned on alongside the patch were a confession that the prior binary had been compiled without them, while a customer fleet of approximately four thousand internet-exposed appliances was running it.

April 3 is when Ivanti updated the rating to CVSS 9.0. April 4 is when CISA added it to KEV. Mid-March is when customers had already paid for the rating that was current. The CVE record now reads attack complexity high, a designation that names UNC5221's five weeks as a public fact about the bug.

PoC: securekomodo/CVE-2025-22457. The script is a crash detector; the no-response check on the POST is the buffer-overflow signature. The exploit primitive that Mandiant attributes to UNC5221 is not in the public record.

End-of-life is a vendor calendar. UNC5221 read it as a target list.