A fully patched VPN appliance, and root anyway
Here's the whole first move: a POST to the public Ivanti Connect Secure login page, welcome.cgi, which needs no session at all, carrying one extra header, X-DSCGI-PATH, set to the path of a different, normally-authenticated CGI file. The authentication check reads the URL. The header decides where the request actually goes. [1]
Volexity tracks the actor behind this as UTA0576, and by the time its technical writeup landed on 13 October 2026, the group had already used that header trick, chained with a second bug, to get unauthenticated code execution as the appliance's unprivileged cgi-server user — on a build with every available patch applied, 22.8R2.4. [1] Those two bugs are tracked together as CVE-2026-104935: CVSS 8.6, CWE-290 (authentication bypass by spoofing), affecting Ivanti Connect Secure 25.1.0.0 before 25.1.1.2, 25.1.2.0 before 25.1.3.0, and every 22.8R2 build through 22.8R2.4, fixed in 25.1.1.2, 25.1.3.0, 25.1.3.1 and 22.8R2.5 on 13 October 2026. [2] CISA added it to the Known Exploited Vulnerabilities catalog the same day, with a three-day deadline for federal agencies that has already passed. [2][3]
Code execution as cgi-server isn't the end of the story. Volexity also reconstructed a third stage, still without a CVE number or a patch, that gets from there to root, with no backdoor and no webshell. I haven't run any of this myself; what follows is built from Volexity's technical writeup and the CVE and KEV records, read on 18 October 2026. [1][2][3] The part worth your attention isn't that a VPN appliance got popped. It's that two of the three bugs are exactly the kind of thing a code reviewer catches, not the kind an incident responder discovers after the fact.
POST /dana-na/auth/url_default/welcome.cgi HTTP/1.1
Host: vpn.example.com
X-DSCGI-PATH: /home/webserver/htdocs/dana/fb/smb/wu.cgi
Content-Type: application/x-www-form-urlencoded
(welcome.cgi needs no session at all)
// Host and paths are illustrative, not turnkey.
// The URL says "public login page" -> the auth check reads the URL.
// The header says "run wu.cgi instead" -> routing reads the header.
// Nothing checks that the two agree.The login page checks the URL. A header decides which script runs
Every dispatcher like this one answers two separate questions for each request: is this caller allowed to be here, and which file actually handles it. ICS answers the first by checking the URL path, is this one of the public, unauthenticated endpoints, and the second by checking a routing header, X-DSCGI-PATH, that names the CGI script to invoke. [1] Splitting those into two questions is reasonable engineering. Nothing about that design is careless on its own.
It only breaks because nothing checks that the two answers agree. The dispatcher assumes that a request hitting the public login script is, by definition, a public request. The attacker controls a header that silently overrides which script actually handles it. Point that header at wu.cgi, a protected upload handler, and a request shaped like a hit on the login page is really a call into authenticated territory, running with whatever trust the dispatcher extends to anything it believes already passed the gate.
The upload handler trusts the clock, not the content
Getting routed to wu.cgi is step one. Step two turns that routing into code execution, and it's a timing bug, not a content bug. wu.cgi writes an uploaded file to /home/runtime/tmp/<MD5 of the filename you chose>, and only after it's on disk does the handler check whether the contents are actually a valid ZIP archive. If they aren't, it deletes the file. [1] Name a Perl script with a .zip extension, and for a short window, a script that will happily run sits at a path you already know, because you picked the filename and a predictable hash turned it into that path.
Volexity describes the attacker racing parallel requests against exactly that window: enough requests, routed through the stage-one header trick, aimed at that temp path, to catch the Perl script before the deletion wins the race. One request landing in time is all it takes. [1]
The payload Volexity recovered didn't stop at Perl. It chained into a Python script, delivered Base64-and-zlib-compressed and run in memory with exec() rather than written out as its own file. [1] The goal throughout: leave as little as possible on disk for anyone to find afterward.
// wu.cgi's order of operations, reconstructed from Volexity's writeup.
// This is the sequence, not a working exploit.
1. Attacker uploads "payload.zip" containing a Perl script, not a real ZIP.
2. wu.cgi writes it to /home/runtime/tmp/<MD5("payload.zip")> immediately.
3. wu.cgi then checks: is this actually a valid ZIP archive?
4. Check fails -> wu.cgi deletes the file.
// The gap between step 2 and step 4 is the whole bug.
// The attacker fires repeated requests (via the stage-one header trick)
// at the path from step 2, racing to execute it before step 4 wins.
// One request landing inside that window is enough.Two stages fixed. The third isn't, and it's the interesting one
Ivanti's fix for CVE-2026-104935, shipped 13 October 2026, closes stages one and two: 25.1.1.2, 25.1.3.0, 25.1.3.1 and 22.8R2.5 are the versions where this specific chain stops working. [2] If you're running an earlier build in any of those three lines, this isn't a 'schedule it for next sprint' item. The CVE record's own exploitation data already reads 'active', with 'total' technical impact, and CISA's three-day federal remediation deadline is behind us. [2][3]
Stage three is where the patch notes stop. Once an attacker has code execution as the unprivileged cgi-server user, Volexity found that UTA0576 retargeted dsinvoked, ICS's own root-level maintenance runner, into executing attacker-chosen code as root, by redirecting what an existing task points at, entirely inside the memory of an already-running process. [1] I'm not walking through the specific library or config details Volexity named. The mechanism is public; a patch for it isn't, and repeating exploit-level detail for an open escalation isn't something I do here regardless of who already published it.
What does matter regardless of patch status: the escalation reverts itself. It restores the config entry and the in-memory library bytes once it confirms root, by checking whether /etc/shadow is readable, so the device looks untouched afterward. Volexity states plainly that Ivanti's own Integrity Checker Tool doesn't catch this, because ICT checks what's on disk, and this never touches disk. [1] A clean ICT run today says nothing about whether stage three has hit a device, whether that happened before 13 October or could still happen now, since there's still no fix for it.
How I'd look for this in code that has nothing to do with Ivanti
Stages one and two aren't ICS-specific mistakes. They're two patterns I check for in any reverse proxy, API gateway or CGI-style dispatcher, in any stack. The first: does the component deciding if a caller is allowed in read the same field as the component deciding which handler runs? If authorization keys off the URL and routing keys off a header, a cookie or a query param the auth layer never looks at, that bug exists here too, just with different field names.
The second: does anything your service writes to a referenceable path exist, even briefly, before it's been validated? Upload handlers are the classic home for this, but it shows up anywhere a service persists first and checks second, a cache write, a temp file, a queue entry a worker can pick up before a validator has run. The two questions below are the ones I'd actually ask reading a handler like wu.cgi for the first time, and they don't need Ivanti's source to apply.
// Two questions I ask reading any dispatcher or upload handler,
// independent of what language or framework it's written in.
// 1. Auth-vs-routing mismatch
// - What field decides "is this caller allowed in"? (often the URL)
// - What field decides "which handler actually runs"? (sometimes a
// header, a cookie, a query parameter)
// - If those are two different fields, does anything enforce that
// they agree?
// 2. Persist-before-validate
// - Where does a handler write attacker-influenced content to a
// referenceable path (disk, cache, queue) before fully validating it?
// - Is there a window, however small, where something worse can
// reach what was just written before cleanup or validation runs?Is this your emergency? If you run Connect Secure, yes
Be honest about who this touches. If you don't run Ivanti Connect Secure, move on; this doesn't apply. If you do, there's no soft landing here the way there sometimes is with a low-severity finding padded into urgency. CVE-2026-104935 is pre-auth, its own exploitation data already reads 'active', and Volexity's forensic reconstruction found it hit an appliance with every available patch already applied. [1][2] 'We're patched' was true for that customer, and it didn't save them.
Three things set how worried to be. First, the build actually running on the box right now, not the one your change log says you meant to reach: 25.1.1.1-and-earlier, 25.1.2.2-and-earlier, and 22.8R2.4-and-earlier are all still open to stages one and two. [2] Second, whether CVE-2026-104935's patch is the end of your risk assessment or the start of it. It closes the door UTA0576 walked through, not the one Volexity found they'd already used once inside. Third, whether a clean Integrity Checker Tool result is standing in for proof of no compromise, which Volexity's own writeup says it isn't, for this chain. [1]
The 8.6 score is honest about stages one and two. It says nothing about stage three, because stage three doesn't have a score yet, and an attacker chaining all three doesn't stop caring about it just because CVSS does.
What to confirm before you call this closed
Patch first, obviously. But patching after the fact answers 'are we safe going forward', not 'were we already hit', and this chain's exploitation window didn't require you to be unpatched in any way you'd have noticed at the time. [1]
Volexity traces the attacker's infrastructure back to 30 September 2026, with exploitation observed from 3 October. [1] If the appliance ran an exposed build at any point from late September 2026 on, treat it as possibly compromised and hunt before you call it closed. The list below is the order I’d work in.
Before you call this closed:
[ ] Confirm the build actually RUNNING on the appliance, not the one
your change log says. 25.1.1.2 / 25.1.3.0 / 25.1.3.1 / 22.8R2.5 or
later closes CVE-2026-104935 (stages one and two only).
[ ] Pull logs for requests to welcome.cgi where X-DSCGI-PATH does not
match the URL path. That mismatch is this chain's fingerprint.
[ ] Do not close the incident on a clean Integrity Checker Tool run
alone. ICT checks on-disk state; stage three never touches disk.
[ ] If the appliance was internet-facing on a vulnerable build any time
from late September 2026 on, treat it as possibly compromised, not
just unpatched.
[ ] Track Ivanti's advisory for the still-missing privilege-escalation
fix and apply it the day it ships. There is no CVE for it yet.The CVE is the easy part
CVE-2026-104935 itself will fade from the news cycle soon enough. The two bug classes behind it won't, because they were never Ivanti's alone. An authorization check and a routing decision quietly trusting different fields of the same request, and a write path that persists before it validates: I find both in web and API codebases that have never touched a VPN appliance, usually in spots nobody looked at twice because the obvious check, is this endpoint authenticated, is this upload type checked, already passed.
That's the boundary a secure code review is built to trace: not whether a request reached a function, but whether every gate along the way is reading the same facts about that request. If you want someone to read your auth-and-routing layer with that question before it becomes someone else's zero-day writeup, that's exactly the work I do. Tell me what you're running and we'll scope it.
Sources
Need this checked in your own product?
Security code review