On November 10, Fortinet published advisory FG-IR-26-194, which tracks CVE-2026-121529. The bug is an origin validation error in a local API endpoint shipped with FortiClient for Windows and macOS and with the dedicated FortiClient Windows PAM agent. It landed in a very crowded patch cycle and got less attention than it should have. The exploitation premise is plain: a user visits a web page, and attacker-supplied code runs on their machine. The victim needs no attachment, macro, installer, or privilege prompt. The page is the delivery vehicle, and the component that executes the payload is a security product the organization’s own IT department installed.
What is Fortinet2Shell?
Kevin Joensen, principal security consultant at the Danish research firm Baldur Security, found the flaw, reported it under responsible disclosure, and named it Fortinet2Shell. He describes the root cause bluntly: “The root cause is a CORS misconfiguration that allows attacker-controlled web pages to communicate with a local service and reliably execute arbitrary code.”
Fortinet classifies the defect as CWE-346, an origin validation error, under the API component. It notes that exploitation requires interaction with the victim, meaning a visit to an attacker-controlled or compromised page. The bug involves no memory corruption, heap grooming, or cryptographic break. A local HTTP service trusted the browser to police its callers, and the service’s own configuration told the browser to trust everyone.
At the time of writing, neither Fortinet nor Baldur had published the vulnerable endpoint or a proof of concept. This analysis therefore draws on the advisory, Joensen’s public statements, and Fortinet’s own documentation of the affected architecture rather than on exploit code.
Affected FortiClient versions and fixed releases
The advisory is short, and every line of it matters. Fortinet describes an unauthorized remote attacker executing unauthorized code or commands, lists the affected component as API, and lists the impact as escalation of privilege.
| Branch | Affected products | Affected versions | Fixed in |
|---|---|---|---|
| FortiClient 7.4 | macOS, Windows, Windows PAM | 7.4.0 through 7.4.7 | 7.4.8 |
| FortiClient 7.2 | Windows, Windows PAM | 7.2.0 through 7.2.14 | 7.2.15 |
| FortiClient 8.0 | None | Not affected | n/a |
FortiClient for Mac 7.2 is also explicitly out of scope. The advisory’s virtual patch field reads “No,” so there is no FortiGuard IPS signature or other network-side compensation for an unpatched endpoint. The upgrade is the fix.
Part of Fortinet’s largest patch batch of the year
SecurityWeek reported that Fortinet dropped 89 advisories on Tuesday, November 10, covering nearly 100 vulnerabilities, roughly 17 of them critical. The batch arrived three weeks after the company announced it had integrated frontier AI models into its product security testing. Fortinet explained the gap by saying it wanted to give customers “time to prioritize upgrades before additional technical details become public, which matters even more as AI shortens the time between disclosure and exploitation.”
The patched FortiClient builds were therefore available before the technical narrative existed in public. That runway is gone now that the mechanism is described. Fortinet reports no exploitation in the wild for any flaw in this batch, which describes the past and guarantees nothing about the next few weeks.
Why FortiClient runs a web server on port 9191
To see how a web page reaches code execution on an endpoint, you have to know why FortiClient exposes an HTTP listener at all. FortiPAM is Fortinet’s privileged access management product. Administrators and engineers authenticate to its web portal, check out secrets from a vault, and launch privileged sessions to sensitive targets over RDP, SSH, or web consoles. The browser is deliberately sandboxed away from the operating system, so a page in a tab cannot start PuTTY, inject credentials into a native RDP client, build a ZTNA tunnel, or record the screen for session auditing. Something native has to do that work on the browser’s behalf. Fortinet’s documented design assigns it to two FortiClient daemons: fortivrs.exe, described in Fortinet’s process reference as the FortiClient Video Record Daemon that “provides PAM functionality,” and FortiTcs.exe, the FortiClient ZTNA service.
Fortinet’s administration guides spell out the deployment. The Privileged Access Agent feature is enabled per endpoint profile in EMS, under Endpoint Profiles and System Settings, with a port field whose default is 9191. The same port must be configured on the FortiPAM server under System, Settings, Client Port, and both sides can be customized. Fortinet’s FortiPAM documentation states that “FortiClient listens on port 9191” and explains how to change it.
📬 Stay Ahead of Cyber Threats
Get the latest cybersecurity news, critical vulnerabilities, threat intelligence, tutorials, and exclusive giveaways delivered straight to your inbox. No spam. Unsubscribe anytime.
A browser extension, the Fortinet Privileged Access Agent (also called the FortiPAM password filler extension), covers web-based secrets. When an engineer clicks Launch Secret in the portal, the local agent brokers the session. FortiPAM’s configuration uses a ZTNA access proxy with an API gateway that maps /pam to the PAM service and /tcp to TCP forwarding. FortiClient launches the appropriate native client with credentials injected so the user never types a password, and the agent records session video and sends it to FortiPAM until the session ends.
Two details from the debugging documentation matter later. The fortivrs.exe daemon writes debug logs by default, with filenames like fortivrs_session_0_1.log in the FortiClient trace folder. The ZTNA rules it applies are stored as JSON at C:\Users\Public\FortiClient\ztna\config.json.
Strip away the vulnerability and the service is an HTTP API, running on a privileged user’s workstation, whose documented purpose is to accept session parameters and invoke native binaries with them. That is a code execution primitive by design. Only the origin validation and request authentication separate a legitimate caller from a malicious one, and in the affected builds that validation failed.
How the CORS misconfiguration leads to code execution
The browser’s same-origin policy defines an origin as the tuple of scheme, host, and port. A script served from https://attacker.example that calls fetch() against http://127.0.0.1:9191 differs in all three, so the request is cross-origin. Many developers of native daemons assume the same-origin policy stops such requests from being sent. In fact, simple cross-origin requests (GET, HEAD, or POST with form-style content types) are dispatched normally, and the policy only stops the calling script from reading the response. That gap is why CSRF exists, and it means a localhost service that changes state on GET or simple POST requests is exposed even without any CORS headers. Mixed content rules do not help either, because browsers treat loopback addresses as potentially trustworthy origins, so an HTTPS page can call http://127.0.0.1 without triggering a mixed content block.
Cross-Origin Resource Sharing is the mechanism by which a server relaxes the read restriction. For non-simple requests (a JSON body, a custom header, a PUT or DELETE), the browser first sends an OPTIONS preflight carrying the Origin header along with Access-Control-Request-Method and Access-Control-Request-Headers. This is the service’s chance to refuse: inspect Origin, compare it against an allowlist containing the enterprise’s actual FortiPAM portal, and decline to return matching CORS headers. The browser then aborts before the state-changing request is ever transmitted.
Services with this defect class usually fail in one of two ways. They answer with Access-Control-Allow-Origin: * unconditionally, or they reflect whatever Origin arrived, often paired with Access-Control-Allow-Credentials: true. Either answer tells the browser the caller is authorized, so the browser delivers the attacker’s request and hands the full response body back to attacker-controlled JavaScript. CWE-346 is precisely this failure to verify that the source of a communication is valid. The affected FortiClient service conflated two different facts: that a request arrived over loopback, and that it came from the company’s own portal.
The step from cross-origin API access to code execution follows from the service’s purpose. An API that orchestrates privileged sessions exists to invoke processes, pass command-line parameters, and write configuration. If its callers are not authenticated and its parameters are not constrained, anyone who can talk to it can steer that machinery. Joensen confirmed reliable arbitrary code execution, and Fortinet’s advisory states the outcome as execution of unauthorized code or commands. The exact vulnerable route and payload have not been published, and remediation does not depend on knowing them. The browser here is a confused deputy: sandboxed JavaScript cannot spawn a process, but it can ask a local service that can, and the service’s CORS configuration told the browser the request was welcome.
CVSS score for CVE-2026-121529: 9.6 or 9.1?
Two different numbers are circulating, and both trace back to Fortinet. The PSIRT advisory page lists a CVSS score of 9.1. The CVE record Fortinet submitted as CNA, displayed on the NVD entry, carries the vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H with a base score of 9.6, while NVD’s own analysis is still pending. Tenable’s record shows the same 9.6.
The arithmetic confirms the vector. With confidentiality, integrity, and availability all High, the impact sub-score is 0.915. The changed-scope impact formula yields 6.05, exploitability computes to 2.84, and the 1.08 scope multiplier rounds the result up to 9.6. Both numbers sit firmly in the critical tier, so the discrepancy changes nothing about urgency. Teams filtering dashboards by score should still key on the vector, because the vector encodes the operational reality.
Reading it metric by metric:
Attack Vector is Network even though the vulnerable socket is bound to loopback, because the delivery mechanism is a web server anywhere on the internet. The attacker needs no presence on the victim’s network.
Attack Complexity is Low and Privileges Required is None. The attack is a deterministic HTTP request, with no race window to win, no memory layout to defeat, and no VPN, domain, or FortiPAM credentials needed.
User Interaction is Required, and it is the single metric keeping this from a perfect score. With UI:N the same vector computes to 10.0. In practice the interaction is a page visit, satisfiable through a phishing link, a malvertising iframe, a compromised forum, or a URL pasted into a chat window, with no file download and no UAC or macOS consent prompt.
Scope is Changed, which reflects the architecture. The vulnerable component is a local API service, and the impacted component is the operating system’s security context. The attack crosses an authority boundary, from a browser tab’s sandbox into native process execution, with high impact on confidentiality, integrity, and availability.
Why FortiPAM administrators are the real targets
The exploit requires FortiPAM to be enabled, or the dedicated FortiClient Windows PAM agent to be installed. In most vulnerability assessments, a non-default feature prerequisite shrinks the exposed population and softens the risk. Here the logic inverts. FortiPAM is not deployed to kiosk machines or seasonal contractors. It is deployed to the people who administer the estate: domain administrators, database administrators, infrastructure and DevOps engineers, and security operations staff.
Those workstations are effectively administrative jump hosts. They initiate authenticated RDP and SSH sessions into Tier-0 infrastructure, run the credential-injection and session-recording flows described in Fortinet’s own guides, and hold the browser sessions and tokens that go with that work. An attacker who lands code on one of these machines skips the weeks of noisy reconnaissance and lateral movement, because the foothold is already adjacent to the crown jewels. A product built to control privileged access became, in the affected builds, the shortest path to it.
How to detect vulnerable FortiClient installs
Joensen’s guidance, as reported by Cyber Security News, starts with the version string. On Windows it adds two live indicators: a service listening on TCP port 9191 and a running FortiVRS.exe process. Both suggest exposure, and neither indicates compromise. Version inventory should come from EMS or MDM where available, since the affected ranges are precise. For endpoint-level triage, these checks are simple enough to run through any RMM or EDR response channel:
Get-Process -Name FortiVRS -ErrorAction SilentlyContinueGet-NetTCPConnection -LocalPort 9191 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, OwningProcessGet-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' | Where-Object DisplayName -match 'FortiClient' | Select-Object DisplayName, DisplayVersion
On macOS, read the bundle version with defaults read /Applications/FortiClient.app/Contents/Info.plist CFBundleShortVersionString and check listeners with sudo lsof -nP -iTCP:9191 -sTCP:LISTEN. One caution for accuracy: 9191 is the default, and Fortinet documents how to change the client port. A fleet scan keyed only on 9191 can miss customized deployments, so process presence and version inventory are the more reliable signals.
Detection ideas while patching is in flight
Detection engineering should focus on the two phases of the chain that leave host telemetry.
The first is browser processes (chrome.exe, msedge.exe, firefox.exe, Safari) connecting to 127.0.0.1:9191 or [::1]:9191. In a legitimate workflow, that connection correlates with an authenticated session to the organization’s known FortiPAM portal. The same connection right after a visit to an unfamiliar external domain, with no concurrent portal traffic, is a high-signal anomaly.
The second is process lineage. fortivrs.exe exists to record sessions and broker launches, so a child process like cmd.exe, powershell.exe, rundll32.exe, mshta.exe, or a shell on macOS deserves a high-severity alert. These are hunting hypotheses rather than published indicators of compromise, but they follow directly from the documented architecture. The fortivrs debug logs, enabled by default in the trace folder, and the ZTNA rule file under C:\Users\Public\FortiClient\ztna\ are forensic artifacts worth preserving on any suspicious endpoint.
How to patch CVE-2026-121529, and what compensating controls can do
The remediation is the upgrade: 7.4.8 or later on the 7.4 branch, 7.2.15 or later on the 7.2 branch, or migration to the unaffected 8.0 line. For EMS-managed fleets, stage the installer as a deployment package and push it first to the endpoint groups where FortiPAM is enabled, because those combine the vulnerable service with the most valuable user context.
The advisory’s “Virtual Patch: No” is not boilerplate. It reflects where the attack traffic lives. The victim’s browser fetches the malicious page over ordinary TLS, then opens a connection through the operating system’s loopback interface. That second connection never touches the physical NIC, never crosses the Wi-Fi access point, and never enters a FortiGate, IPS, or SASE tunnel. A perimeter control cannot inspect what never reaches it, and host firewall rules are no substitute, since Windows Firewall does not filter loopback traffic.
Where FortiPAM is genuinely unused, the feature is profile-controlled in EMS. Verify that the Privileged Access Agent is disabled on endpoints that do not need it, which removes the listener at its source. That is a configuration change, not a patch, and it should be verified rather than assumed.
What browser defenses cover
Browser vendors have been building defenses against this pattern. Chrome began shipping a Local Network Access permission prompt in version 142 in late 2025. It gates requests from public sites to loopback and private addresses behind explicit per-site user approval, with enforcement still expanding across versions and manageable through enterprise policy. That is real progress, but only a partial layer. Coverage differs across browsers and enterprise configurations, users approve prompts habitually, and a compromised legitimate site that already holds the permission, such as the PAM portal itself or any trusted internal tool, passes through the gate. Once the browser’s check is satisfied, only the service’s own origin validation stands between the page and the API, which is why the server-side fix is the one that counts.
Web and DNS filtering reduce the odds of reaching a hostile page, and user awareness adds friction. For a flaw where the entire user interaction is “opened a web page,” neither is a defensible primary control. The patch is the control.
Baldur Security’s third Fortinet disclosure in under a year
Fortinet2Shell is Joensen’s third public strike against Fortinet’s privileged-access and management surfaces in under a year, and the sequence reads like a systematic audit.
In January, Baldur published CVE-2025-59922, a FortiClient EMS chain that began with control of a single img tag’s src attribute and survived a strict CSP by staying on-host. It then found a GET-based SQL injection in the /api/v1/malware/threats endpoint and converted it to remote code execution through PostgreSQL’s COPY FROM PROGRAM feature. The bug had been reported in July 2025 and was patched on January 14, 2026.
In August, Baldur disclosed CVE-2026-84388 in the Privileged Access Agent Chrome extension. A malicious website could register itself as a trusted FortiPAM server without valid JWT authentication, change the browser’s proxy, open an attacker-chosen tab, auto-approve the extension’s consent prompt, and stream a recording of that tab to an attacker-controlled server. Fortinet’s September advisory, FG-IR-26-168, required a coordinated fix across FortiPAM itself (1.9.1 or 1.8.4) and the extension (8.0.1.123 or above). It credited Joensen alongside researchers from Target, Shelltrail, and Bay Area Labs.
Fortinet2Shell completes the trilogy: the EMS console, then the browser extension, now the local daemon, each a browser-reachable surface holding privileged capability.
Why no exploitation reports should comfort nobody
FortiClient EMS spent 2026 under fire. CVE-2026-35616, an improper access control flaw in EMS 7.4.5 through 7.4.6, was exploited as a zero-day from March 31 and drew an emergency hotfix in early April. By May, Arctic Wolf was documenting a cluster abusing that flaw to push an infostealer, dubbed EKZ, disguised as a Fortinet patch to managed endpoints. In October, a public proof of concept appeared for CVE-2026-106173, a critical FortiSIEM flaw rated 9.4.
In this ecosystem, the interval between public technical detail and working attacker tooling has repeatedly been measured in days. A CORS-to-localhost technique, once understood, is far easier to replicate than a memory corruption exploit, because an HTTP request has nothing nondeterministic about it.
Localhost services and the wider industry pattern
The pattern belongs to the whole industry. Desktop software has spent a decade replacing native IPC with HTTP and WebSocket servers on 127.0.0.1, because a loopback listener is cross-platform and trivially reachable from web portals and extensions. The precedent to remember is Zoom’s 2019 Mac client, which ran a local web server on port 19421 that let any website force a user into a call with the camera active, and which survived the application’s uninstall until Apple pushed a silent removal.
The engineering rules that prevent this class are not exotic:
- Validate Origin against an exact, administratively provisioned allowlist, and never emit a wildcard or reflected Access-Control-Allow-Origin.
- Require a short-lived cryptographic token bound to the authenticated enterprise session on state-changing requests, so Origin alone is never the authorization.
- Check the Host header to blunt DNS rebinding, the sibling technique that defeats naive localhost trust even without CORS.
- Never pass parameters received over the socket to a shell interpreter unparameterized.
- Where a browser bridge is not actually required, use named pipes with explicit DACLs on Windows or XPC services on macOS, which keep the capability off the web’s attack surface entirely.
What to do now about CVE-2026-121529
Fortinet gave its customers three weeks of quiet to upgrade before the details went public, and the organizations that used that window are done. Everyone else is now patching against a published mechanism, on machines that belong to the most privileged users they have, with no network-side safety net. Check the version, check the listener, prioritize the PAM endpoints, and treat the loopback interface with the suspicion you would give an internet-facing edge appliance, because any web page a user opens can reach it.









