Seven days, five products with confirmed exploitation in the wild,
and one of them still has no patch. The lead is
Citrix NetScaler ADC and Gateway, where
CVE-2026-88771 (CVSS v4 9.5) gives an unauthenticated attacker
arbitrary command execution against every deployment in its
default configuration, no feature required, and Cloud Software Group
states plainly that exploitation of it and of CVE-2026-88772 has
been observed. CISA added both to KEV on September 27 with a
three-day federal deadline. Next, and more urgent per host:
FortiMail CVE-2026-104286 (CVSS 9.8) is an
unauthenticated path traversal that writes arbitrary files, Fortinet
marks it Known Exploited, and as of today the solution column says
"upcoming" for every affected branch. There is no fix to install;
there is a workaround and a published implant to hunt for. Third,
Zammad: DIVD published CVE-2026-102489 and
CVE-2026-102490 on September 29 after the chain was used to breach
DIVD itself on September 21, driven, by DIVD's own account, by an
autonomous AI agent. Both reached KEV on October 2 with an October 5
due date. Go to Zammad 7.2.0: DIVD says "version 7" and Zammad says 7.0
and later are unaffected by the remote code execution with its own
hardening in 7.2.0, so 7.2.0 satisfies both. The local privilege
escalation to root still has no published fix, though Zammad says it
needs prior access to the server.
Cisco Catalyst SD-WAN Manager CVE-2026-76504 (CVSS
9.8) rounds out the exploited set, an authentication bypass through
URI encoding that hands over the admin API. On the dependency side,
Next.js and satori disclosed an SVG
escaping flaw in next/og that Vercel rates Critical and
the CVE record rates Medium, PyJWT disclosed a
universal-forgery bypass of its own CVE-2022-29217 guard, and
vm2 had fifteen advisories land at once, ten of
them critical sandbox escapes. If you do one thing: get every
internet-facing NetScaler to 14.1-73.37 or 13.1-64.23, and treat any
appliance that was exposed and unpatched as suspect before
you reboot it into the fix.
Framing note. This edition covers September 26 to
October 2, 2026, the seven days of published activity not reached by
the September 25
catch-up or the September 28
KEV supplement, both of which stop at September 25. Unlike the
September 25 catch-up, which was built behind a network policy that
blocked almost everything, this edition had working network access,
as the September 28 supplement did: the CISA KEV
catalog was fetched in full (catalog version 2026.10.01, 1,731
entries, filtered on dateAdded), and NVD, the Fortinet
and Cisco PSIRTs, Citrix Knowledge Center, Apple's support site, DIVD
CSIRT, OSV, PyPI and the npm registry all answered. Two sources did not, and both
cost real content: the Citrix community blog that
carries the NetScaler indicators of compromise returns a Cloudflare
challenge to every request from this environment, so no NetScaler
IOC list appears below; and github.com is
restricted to this session's own repository, so GitHub advisory
pages could not be opened directly and the advisory text used here
came from the OSV mirror of the GitHub Advisory Database instead.
One consequence is worth naming here: the satori
companion GHSA referenced by the Next.js advisory could not be opened,
but the related CVE record carrying the same range could, so item 5
does give a satori floor and attributes it to that record
rather than to the GHSA. Items are ordered by confirmed exploitation
first, then by breadth of exposure for developers, ops engineers, AI/ML
infrastructure operators and self-hosters. Neither ordering is CVSS:
item 4 outranks item 5 because it is being exploited, not because more
readers run it. Every "as of" statement is anchored to
October 2, 2026, and all registry and KEV state was read live that
day. On dates for the dependency items: most of them disclose a
vulnerability whose fix shipped weeks earlier, and each section
gives both dates rather than implying the flaw is new.
Update, October 4, 2026. This edition was written on
October 2 and re-checked on October 4 before publication. Three things
changed and one was missed first time round, and all four are corrected
in place rather than appended. Both Zammad CVEs are now in
KEV, added October 2 with an October 5 due date, so item 3's
original statement that neither was listed is gone; it was true of the
catalog version this edition first fetched and false by the end of the
same day. Zammad published its own statement on October 1
disputing part of DIVD's account, and the first edition did not use it:
item 3 now carries both primaries, and the recommended floor is 7.2.0
rather than DIVD's "version 7". Fortinet revised
FG-IR-26-175: the surface to restrict is now the webmail
interface rather than the management interface, there is a new web
application firewall mitigation that names /ibe as the
vulnerable endpoint, and the seven file paths and fourteen hashes that
were in the advisory on October 2 have been removed from it. Item 2
reflects all three and keeps the withdrawn hashes clearly labelled as
withdrawn. Still unchanged: FortiMail has no fixed
build for any branch, and the Zammad root escalation has no published
fix.
(1) NetScaler, in this order: look, then patch. CVE-2026-88771 needs no feature enabled, so every ADC and Gateway below 14.1-73.37 or 13.1-64.23 is vulnerable, and Cloud Software Group says exploitation has been observed. If the appliance was reachable from the internet while unpatched, capture a VPX snapshot and a technical support bundle first: the upgrade warm-restarts the packet engine and destroys the memory state any later compromise decision depends on. Then run Citrix's indicator checks, from the community blog linked out of CTX697096, against that evidence, because this bulletin could not read that list for you. Only then install 14.1-73.37, 13.1-64.23, 14.1-73.37 FIPS, or 13.1-37.279 for FIPS and NDcPP. Secure Private Access hybrid deployments use NetScaler instances and need the same builds.
(2) FortiMail: hunt first, then apply the workaround,
because there is nothing to install. Fortinet's solution
column reads "upcoming" for 8.0.2, 7.6.7 and 7.4.9, and the 7.2
branch gets no fix at all. Start with the implant: Fortinet
published file hashes, two IP addresses and distinctive log lines,
all reproduced in item 2, and the log checks run against the logs
you already ship elsewhere rather than against the appliance, so
they cost minutes and change nothing. A hit means incident
response, and the configuration change below would then be a change
to a system you no longer control. With no hit, disable the IBE
feature (config system encryption ibe /
set status disable / end, or Encryption
then IBE then IBE Service off in the GUI) and get the
webmail interface off the internet, or block POST
requests to /ibe containing ../ at a web
application firewall. Fortinet's wording here changed after
October 2, from "management interface" to "webmail interface", so
check which surface you actually restricted.
(3) Sweep the dependency floors in one pass.
next 16.3.6 or later (16.3.8 is current),
satori 0.33.5 or later if you depend on it directly,
pyjwt 2.14.0 or later (2.15.1 is current),
vm2 3.11.7 or later (3.12.2 is current),
piscina 5.3.2 / 4.9.4 / 6.0.0-rc.5 depending on your
branch, and @xhmikosr/decompress 11.1.4 or 10.2.2.
The unmaintained decompress package has no fix and
needs replacing. Every one of those releases already existed
before this window opened, so for most readers this is a
re-resolve and a redeploy rather than a wait.
Priorities, by what you run
Items 1 to 4 come first because exploitation is confirmed, which is why Cisco Catalyst SD-WAN Manager precedes Next.js despite a much smaller footprint in this audience; items 5 to 10 are then ordered by breadth. "Higher" means broad footprint in this audience combined with either confirmed exploitation or an unauthenticated path from the network to code execution. "Medium" means a real fix to apply on your normal cadence, or a narrower precondition. "Lower" means the exposure depends on something else being wrong first. Five items here carry confirmed exploitation in the wild: items 1, 2 and 4 on their vendors' own statements, item 3 on DIVD's account of being breached through it, and item 8 on Apple's. Item 8 still sits at Medium because Apple's wording is narrow, a sophisticated attack against specifically targeted individuals rather than commodity exploitation, and because the update is a routine one. The other five items carry no exploitation claim, because none of their advisories makes one.
| Priority | Item | Who and what |
|---|---|---|
| Higher | 1. Citrix NetScaler CVE-2026-88771 and CVE-2026-88772, plus six more (KEV September 27) | Anyone self-hosting NetScaler ADC or Gateway, including Secure Private Access hybrid. Exploitation observed. Preserve evidence if it was exposed, then upgrade to 14.1-73.37, 13.1-64.23, 14.1-73.37 FIPS, or 13.1-37.279 FIPS/NDcPP. |
| Higher | 2. FortiMail CVE-2026-104286 (KEV October 1, no fix available) | Operators of FortiMail 8.0.0 to 8.0.1, 7.6.0 to 7.6.6, 7.4.0 to 7.4.8, 7.2.0 to 7.2.9. Known exploited, and still no released fix as of October 4. Disable IBE, restrict the webmail interface (the advisory's own wording changed from "management" after October 2), or block POST to /ibe containing ../ at a WAF, and hunt the indicators. |
| Higher | 3. Zammad CVE-2026-102489 and CVE-2026-102490 (DIVD, September 29) | Anyone self-hosting Zammad helpdesk at 6.3.0 up to but not including 6.5.4, and, per DIVD, every version from 1.5.0 for the root escalation. Used to breach DIVD on September 21; both CVEs reached KEV on October 2 with a October 5 due date. Run DIVD's log check, then upgrade to 7.2.0; the root escalation has no published fix and Zammad says it needs prior server access. |
| Higher | 4. Cisco Catalyst SD-WAN Manager CVE-2026-76504 (KEV September 30) | Anyone running on-prem Catalyst SD-WAN Manager (vManage). Unauthenticated admin API access, active exploitation, no workaround. Check the two log files Cisco names, then upgrade to the fixed release for your train. |
| Higher | 5. Next.js and satori, GHSA-vcvr-r3jv-pc5j and CVE-2026-94545 (advisories September 30) | Self-hosted Next.js 16.2.0 through 16.3.5 that renders attacker-controlled values into SVG through the Node.js ImageResponse. Upgrade to 16.3.6 or later; the Edge implementation is not affected. If you use satori directly, that is a separate floor of 0.33.5. Vercel rates it Critical and the CVE record rates it 5.3 Medium; both are in the item. |
| Medium | 6. PyJWT CVE-2026-102268 (advisory September 28) | Python services on pyjwt 2.13.0 or below. Higher if any jwt.decode allow-list mixes an HMAC algorithm with an asymmetric one, which makes it universal token forgery. Upgrade to 2.14.0 or later and split the allow-lists. |
| Medium | 7. vm2: fifteen advisories, ten critical (published October 1) | Anyone executing untrusted JavaScript in vm2 below 3.11.7, including AI agent and plugin sandboxes. Upgrade to 3.11.7 or later (3.12.2 is current), and stop treating vm2 as a security boundary. |
| Medium | 8. Apple CoreGraphics CVE-2026-86950 (KEV September 29) | macOS and iOS development workstations below macOS Sequoia 15.8.1, macOS Tahoe 26.7.1, or iOS/iPadOS 26.7.1. Apple says it may have been exploited against specific targeted individuals, which is narrower than commodity exploitation. Update on your normal workstation cadence, sooner if you are plausibly a target. |
| Medium | 9. decompress path traversal CVE-2026-101894 (NVD September 28) | Any Node service extracting archives it did not create. Upgrade @xhmikosr/decompress to 11.1.4 or 10.2.2. The upstream decompress package shares the flaw and will not be patched: replace it. |
| Lower | 10. piscina CVE-2026-102992 (advisory October 1) | Node services using the piscina worker pool that also have a prototype-pollution primitive somewhere in the dependency tree. Upgrade to 5.3.2, 4.9.4 or 6.0.0-rc.5 for your branch. |
Prioritization is this bulletin's own, based on how many readers in this audience are likely to run each component and how ordinary the triggering action is. It is not a vendor urgency rating. Where a vendor or CSIRT states exploitation, that is quoted in the item and attributed; where none is stated, no exploitation claim is made either way.
1. Citrix NetScaler ADC and Gateway: unauthenticated command execution against every default deployment, exploitation observed (CVE-2026-88771 and CVE-2026-88772, plus CVE-2026-88773 through CVE-2026-88778)
Cloud Software Group published CTX697096 on September 27, 2026, covering eight vulnerabilities in NetScaler ADC and NetScaler Gateway. The sentence that sets the priority is in the bulletin's own "What Customers Should Do" section: "Exploits of CVE-2026-88771 and CVE-2026-88772 on unmitigated NetScaler deployments have been observed." CISA added both to the Known Exploited Vulnerabilities catalog the same day, September 27, with a remediation due date of September 30, which is already past as of today.
CVE-2026-88771 is the one that makes this a drop-everything item. Its precondition, in Citrix's words, is "All NetScaler ADC and NetScaler Gateway deployments (Default configuration / No additional feature required)." There is no feature to switch off, no virtual server type that saves you, and no configuration audit that can clear an unpatched appliance. It is improper input validation (CWE-20) leading to arbitrary command execution by an unauthenticated attacker, scored CVSS v4.0 9.5 by Citrix and CVSS 3.1 9.8 by NVD. CVE-2026-88772 is a memory overflow leading to remote code execution or denial of service, and its precondition is DTLS, which Citrix notes is "Enabled by default on VPN vServer": a NetScaler Gateway is vulnerable unless DTLS was explicitly turned off. Citrix scores it CVSS v4.0 9.5; NVD scores the same issue CVSS 3.1 8.1, the difference being the high attack complexity NVD records and Citrix's v4 vector also carries as AC:H.
The other six are narrower but worth knowing, because they decide how much urgency a non-internet-facing appliance deserves: CVE-2026-88773 is HTTP request smuggling (CVSS v4 9.3) on any HTTP or SSL virtual server; CVE-2026-88774 is a feature policy bypass through HTTP URL based policy expressions (7.0); CVE-2026-88775 (8.8) is a memory overflow reachable through Gateway or AAA virtual servers; CVE-2026-88776 (8.8) needs a load balancing virtual server of type Oracle; CVE-2026-88777 (8.8) needs a non-HTTP layer 7 feature such as FTP, RTSP, DNS64 or NAT64 on an LB, CS or CGNAT deployment; and CVE-2026-88778 (8.8) is TCP initial sequence number prediction, which uniquely also needs a configuration change after the upgrade, enabling Enhanced ISN Generation.
13.1-37.279 while its "What Customers Should Do" list
and the NVD CPE records write it 13.1.37.279. They are
the same build. There are no indicators of compromise in
this item, and that is a gap, not a finding. Citrix
publishes a NetScaler IOC list in a community blog post linked from
CTX697096, and CISA's KEV note points at it as well ("Running the
provided IOCs in the NetScaler console may help identify indicators
of exploitation"). That blog returns a Cloudflare interstitial to
every request from the environment this bulletin was built in, so
the list could not be read and nothing is reproduced from it.
Fetch it yourself before you conclude an appliance is clean.
Am I affected?
# 1) The version is the whole answer for CVE-2026-88771. On the NetScaler CLI
# (this is a NetScaler command, not a shell one, hence commented out here):
# show ns version
# Compare against: 14.1-73.37 / 13.1-64.23 / 14.1-73.37 FIPS / 13.1-37.279
# Anything below the fix for your branch is affected regardless of config.
# 2) Which of the other six apply to you. Run from the appliance shell, or copy
# /nsconfig/ns.conf off the box and run these there. `show ns runningConfig`
# works too if you prefer the live config over the saved one.
NSCONF=${NSCONF:-/nsconfig/ns.conf} # override if you copied the file off the box
echo "== CVE-2026-88772: DTLS. A Gateway is vulnerable unless DTLS is OFF =="
# The trap: grepping for "DTLS" finds NOTHING on a vulnerable default Gateway,
# because the default is implicit. Look for the ABSENCE of -dtls OFF instead.
# Case-insensitive, and a later "set vpn vserver <name> -dtls OFF" also disables
# it, so collect both before deciding. Erring toward alarm is deliberate: a false
# "DTLS on" costs you a config read, a false "DTLS off" costs you the finding.
awk 'tolower($0) ~ /^add vpn vserver/ { name=$4; on[name]=1; line[name]=$0;
if (tolower($0) ~ /-dtls[[:space:]]+off/) delete on[name] }
tolower($0) ~ /^set vpn vserver/ && tolower($0) ~ /-dtls[[:space:]]+off/ { delete on[$4] }
END { for (n in on) print " DTLS enabled (not explicitly disabled): " line[n] }' "$NSCONF"
awk '/^add (lb|vpn|cs) vserver/ && /[[:space:]]DTLS[[:space:]]/ { print " virtual server of type DTLS: " $0 }' "$NSCONF"
echo "== CVE-2026-88773 and CVE-2026-88774: any HTTP or SSL virtual server =="
grep -cE '^add (lb|cs|vpn|authentication) vserver [^ ]+ (HTTP|SSL)([[:space:]]|$)' "$NSCONF"
echo "== CVE-2026-88775: Gateway (SSL VPN, ICA Proxy, CVPN, RDP Proxy) or AAA =="
grep -cE '^add (vpn|authentication) vserver ' "$NSCONF"
echo "== CVE-2026-88776: LB virtual server of type Oracle =="
grep -inE '^add lb vserver.*ORACLE' "$NSCONF"
echo "== CVE-2026-88777: non-HTTP L7 features =="
grep -inE '^add (lb|cs) vserver .* FTP|^add service .* FTP|^add lb monitor .* FTP(-EXTENDED)?|^set lsn group .* -rtspalg ENABLED|^add lb vserver .* DNS .* -dns64 ENABLED|^add dns policy64|^add nat64' "$NSCONF"
# FTP ALG is ON by default inside an LSN group unless explicitly DISABLED, so the
# same absence problem applies here:
awk '/^add lsn group /{grp[$4]=1} /^set lsn group /{if ($0 ~ /-ftp[[:space:]]+DISABLED/) off[$4]=1} END{for (g in grp) if (!(g in off)) print " FTP ALG enabled by default on lsn group: " g}' "$NSCONF"
echo "== CVE-2026-88778: Enhanced ISN Generation. On the CLI, not in ns.conf =="
# show ns tcpparam | grep "Enhanced ISN Generation"
# DISABLED, together with any virtual server of a TCP-derived type, means the
# precondition is met. This one needs a config change as well as the upgrade.
echo "== Is it reachable from outside? Decision rule is the connection, not the status =="
# Run this from a host OUTSIDE your perimeter against your own appliance only.
# Any HTTP status at all (200, 302, 401, 403, 404) means the port answered and the
# interface IS reachable. A non-zero curl exit means no HTTP answer came back:
# 7 is connection refused, 28 is a timeout (filtered or down), others are usually
# DNS or TLS. Run the same probe against a port you know is closed as a control.
for u in "https://your-netscaler.example.com/vpn/index.html" "https://your-netscaler.example.com:9/"; do
out=$(curl -sS -k -o /dev/null -m 10 -w '%{http_code}' "$u" 2>/dev/null); rc=$?
if [ "$rc" -eq 0 ] && [ "$out" != "000" ]; then echo "REACHABLE $u (HTTP $out)"; else echo "no answer $u (curl exit $rc)"; fi
done
The DTLS and FTP ALG checks are written to find an absence on purpose. Citrix documents both defaults as on-unless-disabled, so a grep for the feature name returns nothing on exactly the hosts that are vulnerable. That pattern is one of the commonest ways an "am I affected" check lies to you. The virtual server counts for CVE-2026-88773 through CVE-2026-88775 are preconditions, not verdicts: a non-zero count means the CVE applies to you, and a zero count means only that those particular CVEs do not. CVE-2026-88771 is unaffected by any of it.
Response
- If the appliance was internet-facing and unpatched at any point since September 27, preserve evidence before you touch it. Citrix's own CTX694799 puts evidence capture first and it is right to: for a VPX, take a hypervisor snapshot of the running instance; document system time, timezone and NTP configuration; pull logs from your remote syslog and NetScaler Console, not just the appliance. Generate a technical support bundle, and generate a Packet Engine core file if you are prepared for the warm restart it causes. For MPX and SDX hardware, work with your incident response team on disk imaging before power-down. The upgrade restarts the packet engine, so doing it first throws away the memory that would answer "was this box used". Then take the ADC or Gateway off the network if you have any reason to suspect it: an appliance that terminates your VPN and holds your LDAP service account is not a box to debug in place while it is still reachable.
- Actually look for evidence before you upgrade. Fetch Citrix's indicator list from the community blog linked out of CTX697096, which is the list CISA's KEV note points at and the one this bulletin could not read, and run it against the appliance and the evidence you preserved in step 1. Work CTX694799's checks on the same material. A hit means stay in the incident path: keep the evidence, keep the appliance isolated, rotate from a clean host and rebuild, and do not upgrade that appliance in place, because the upgrade is how you lose the rest of the picture. Only an appliance that comes through this clean goes to the next step.
- Then install the fixed build. 14.1-73.37, 13.1-64.23, 14.1-73.37 FIPS, or 13.1-37.279 for FIPS and NDcPP. Include Secure Private Access hybrid NetScaler instances in the same pass. Do not stop at the one appliance you were thinking of: CVE-2026-88771 needs no feature, so internal ADCs count too, they just queue behind the exposed ones.
- Rotate everything the appliance held, from a clean host. Citrix lists the set: LDAP and other service account passwords, RADIUS shared secrets, OAuth tokens, API keys, SNMP community strings, every user account that authenticated through a Gateway or AAA virtual server, and the certificates and private keys stored on the appliance, which should be revoked rather than reused. Do this from a host you trust, not from a session that traverses the appliance.
- If you find evidence of compromise, rebuild rather than clean. Citrix's guidance is replacement: erase and reinstall for MPX, replace and restore the instance for VPX, and treat VPX instances inside an SDX the same way. Then investigate what the appliance could reach, which Citrix specifically calls out as authentication servers, sensitive systems, web tier systems and management jump hosts. Enabling Enhanced ISN Generation for CVE-2026-88778 belongs at the end of this list, after the box is one you trust.
2. FortiMail: unauthenticated path traversal writing arbitrary files, exploited in the wild, and no released fix as of today (CVE-2026-104286)
Fortinet published FG-IR-26-175 on October 1, 2026 and CISA added it to KEV the same day with a due date of October 4. The advisory's own summary says "This has been reported to be exploited in the wild, customers are urged to apply the workaround below," and its metadata block reads Known Exploited: Yes, Severity: Critical, Attack Type: Unauthenticated, CVSSv3 9.8. It is a path traversal (CWE-22) combined with improper neutralization of a NULL byte (CWE-158) in the GUI component, letting an unauthenticated attacker write arbitrary files on the underlying system through crafted HTTP or HTTPS requests.
The part that changes your plan: there is nothing to install. As read on October 2, 2026, Fortinet's solution column says "Upgrade to upcoming 8.0.2 or above", "Upgrade to upcoming 7.6.7 or above" and "Upgrade to upcoming 7.4.9 or above". The 7.2 branch has no fix planned at all; its instruction is to move to 7.4 or above, which is a branch upgrade, and 7.4 does not yet have its fix either. So for every affected reader the available actions today are the workaround and the hunt, and the upgrade is a thing to watch for rather than a thing to do.
The published indicators make this one of the more answerable
incidents in this bulletin. Fortinet names an added
ld.so.preload and a planted liblog.so,
which together are a classic preload implant: every process on the
box loads the attacker's library. It also names a modified
/bin/smit, two added binaries, a modified
httpd.conf, a modified migadmin.tar.gz, two
IP addresses, and log lines including an exfiltration path set up as
a FortiMail "archive account" pointed at one of those IPs.
module=unknown submodule=unknown and spaces out the
bracketed fields for legibility. The greps absorb that difference and
were tested against the verbatim line. The admin logged out
from (null) line is a weak indicator on its own: it is the
shape of an administrative session that did not come from a normal
interface, and Fortinet lists it among the indicators, but it will
also appear benignly in some deployments. Treat the file hashes,
the two IP addresses and the archive-account line as the strong
ones. FortiMail does not give you a general shell, so hash
comparison on a live appliance normally needs Fortinet support;
the log checks below are the part you can run yourself today.
Am I affected?
# 1) Version, on the FortiMail CLI (a FortiMail command, not a shell one):
# get system status
# Look at the Version line and compare: 8.0.0-8.0.1, 7.6.0-7.6.6,
# 7.4.0-7.4.8 and 7.2.0-7.2.9 are all affected, and none of them has a
# released fix as of 2026-10-02.
# 2) Is the IBE feature on? GUI: Encryption -> IBE -> IBE Service.
# The advisory gives the disable commands but not a read command, so this
# bulletin does not invent one: read the setting in the GUI, or run
# `show system encryption ibe` and treat an absent `set status disable`
# line the same way the NetScaler DTLS check does, as "enabled by default".
# 3) Hunt the indicators in the logs you already ship elsewhere. Run this on
# your syslog collector or log store, NOT on the appliance. Set LOGS to
# wherever your FortiMail logs land.
LOGS=${LOGS:-/var/log/fortimail}
# Collect the files first and say how many, so that "no files found" and "files
# found, no hits" cannot look identical. Use zgrep throughout: a log store
# rotates and compresses, and a plain grep -r silently skips every .gz, which on
# this item would mean a clean-looking report on an implanted appliance.
FILES=$(find "$LOGS" -type f 2>/dev/null)
echo "log files scanned: $(echo "$FILES" | wc -w)"
if [ -z "$FILES" ]; then
echo "no files found under $LOGS: set LOGS to wherever your FortiMail logs land"
else
echo "== strong: the two IPs anywhere in FortiMail logs =="
zgrep -HnE '79\.141\.169\.187|45\.129\.0\.192' $FILES
echo "== strong: an archive account pointed at attacker infrastructure =="
zgrep -HnE "archive account.*remote-ip\[(79\.141\.169\.187|45\.129\.0\.192)\]" $FILES
# Broader form, in case the exfil target differs from the published IPs:
zgrep -HnE "Added '[^']+' to 'archive account'" $FILES
echo "== strong: the IBE decrypt exception the exploit path leaves behind =="
zgrep -HnF "Invalid Base64 Encoding at pos 0. Character=0x2a" $FILES
echo "== strong: the cron line that stages from /migadmin =="
zgrep -HnE "ui=cron.*CMD \(/bin/sh -c 'O=/migadmin" $FILES
echo "== strong: the IBE login failure Fortinet lists alongside it =="
zgrep -HnE "Internal user .*@.* failed to log in" $FILES
echo "== strong: traversal attempts against the endpoint the WAF rule names =="
# Fortinet's mitigation is to block POST requests to /ibe containing '../', which
# tells you the exploit path, so if you have web logs in front of FortiMail that
# is the request to look for. Do not grep for the literal '../' alone: the dots
# and the slash can each be percent-encoded independently, and '..%2f' (literal
# dots, encoded slash) is the form that defeats a naive pattern. The alternation
# below covers '../', '..\', '..%2f', '..%5c', '%2e%2e' and the mixed '.%2e' and
# '%2e.', and -i makes the uppercase encodings match too. Tested against all of
# them, including inside a .gz rotation.
zgrep -HniE '"[A-Z]+ [^" ]*/ibe[^" ]*(\.\.(/|\\|%2f|%5c)|%2e%2e|\.%2e|%2e\.)' $FILES
echo "== weak, assess against your own baseline =="
zgrep -HnE 'subtype=admin.*action=logout.*msg="User admin logged out from \(null\)' $FILES
fi
# 4) Is the WEBMAIL interface reachable from outside? Fortinet's revised
# workaround names the webmail interface, not the management one, so that is
# the surface to restrict. Same decision rule as item 1: the connection
# outcome decides, not the HTTP status code. Run from outside your perimeter,
# against your own appliance only. Adjust the paths to your deployment.
for u in "https://your-fortimail.example.com/ibe" "https://your-fortimail.example.com/webmail" "https://your-fortimail.example.com:9/"; do
out=$(curl -sS -k -o /dev/null -m 10 -w '%{http_code}' "$u" 2>/dev/null); rc=$?
if [ "$rc" -eq 0 ] && [ "$out" != "000" ]; then echo "REACHABLE $u (HTTP $out)"; else echo "no answer $u (curl exit $rc)"; fi
done
Note what the reachability probe does and does not tell you. A 401 or
a 403 means the port answered, which means it is reachable, which is
what matters here: an unauthenticated path traversal does not care that
the login form rejected you. The control request to a port you know is
closed exists so you can tell a real refusal from a network path that
silently drops everything. The paths are deliberately plural and
adjustable: Fortinet names /ibe in its WAF rule, and the
webmail root differs by deployment, so confirm your own rather than
trusting either example.
Response
- Run the log checks before you change anything. They run against the logs you already ship off the appliance, so they cost minutes and alter nothing. If the implant is present, your FortiMail is an incident, and the configuration change two steps down would be a change to a system you no longer control.
-
If any strong indicator hit, stop here and treat the
appliance as compromised. Do not apply the workaround to a
box in this state, and do not reboot or reconfigure it. An added
ld.so.preloadplus a plantedliblog.someans every process on the appliance was loading attacker code, so nothing the appliance reports about itself is trustworthy. Isolate it, preserve what you can, open a case with Fortinet for forensic support since you cannot image it yourself, rotate every credential it held (admin accounts, LDAP or RADIUS integration secrets, mail relay credentials, certificates and their private keys) from a clean host, and plan a rebuild rather than a cleanup. Also go looking for what was taken: an archive account pointed at a remote IP is a mail exfiltration channel, so the mail that passed through it is in scope. -
On appliances with no hit, disable IBE and restrict the
webmail interface.
config system encryption ibe/set status disable/end, or the GUI equivalent. If IBE is in production use for you, Fortinet now gives two alternatives, and note which surface the first one names: remove internet access to the webmail interface, or limit it to a trusted private network; or, if a web application firewall sits in front of FortiMail, block POST requests to/ibethat contain../. The advisory said "management interface" when this bulletin was first written and now says "webmail", so if you acted on the earlier wording, check that you restricted the surface that actually matters. With no fixed build available, doing this as well as disabling IBE is worth the trouble. - Watch FG-IR-26-175 for the builds to appear and install them when they do. 8.0.2, 7.6.7 and 7.4.9 are the numbers to wait for. If you are on 7.2, plan the branch move to 7.4 now so you are not doing a branch upgrade under pressure the day 7.4.9 ships.
- Do not treat "no hits" as clean. The indicators read as coming from a single intrusion and were published the day the advisory shipped; Fortinet does not say how many incidents they come from. A different operator using the same path traversal would leave different files. Absence of these indicators means these indicators are absent.
3. Zammad: a session flaw to remote code execution chained to a root escalation, used to breach DIVD, now in KEV, and the vendor disputes DIVD's scope (CVE-2026-102489 and CVE-2026-102490)
On September 21, 2026 the Dutch Institute for Vulnerability Disclosure was itself breached. DIVD made its first public statement on September 24, published the case file for the vulnerabilities (DIVD-2026-00015) on September 29 along with both CVE records at 20:00 UTC, and has been adding numbered statements to the incident case (DIVD-2026-00014) since September 24. Its own account of the attack is worth reading for what it says about method as much as mechanism: DIVD describes an agentic AI powered attack, with the attacker's scripts containing notes in which the agent justifies its own actions, and the chain running from unauthenticated to root "in seconds due to the agentic part of this hack". DIVD credits network segmentation and its incident response team for stopping the attacker going deeper, and states that volunteer data including DIVD email addresses and possibly contact details was exfiltrated.
CVE-2026-102489 is the entry point: a session hijack vulnerability in
Zammad that leads to remote code execution as the zammad
user. DIVD records it as affecting 6.3.0 up to but not including
6.5.4, and states it is "also present in version 7.0.0 to version
7.1.3, but not exploitable due to environment conditions". DIVD
scores the standalone issue CVSS v4.0 8.7 and the chained scenario
9.4, and the record carries E:A, exploitation Attacked.
CVE-2026-102490 is the escalation: the local zammad user
becomes root. DIVD scores it 8.5 standalone and 9.4 chained.
The two CVEs need different handling, which is the practical point of
this item. DIVD's recommendation is "Upgrade to Zammad version 7", and
its affected-products table lists 7.0.0 and later as unaffected by
CVE-2026-102489, while its text notes the code is still present in
7.0.0 to 7.1.3 and not exploitable there "due to environment
conditions". It does not address CVE-2026-102490: the CVE page is
titled "Undisclosed LPE in Zammad
v1.5.0 to v7.1.0-alpha", and its description reads "All versions of
Zammad including the latest alpha enable the local zammad user to
escalate privileges to root." DIVD's case file lists patch status
Available and workaround N/A while also stating "We have reported the
vulnerability to Zammad who are working on a fix", and the case
status is Open as of its last modification, October 1, 2026. Read
together: upgrade, and keep assuming that anything that gets code
execution as zammad gets root.
Zammad disputes part of this, and its statement is the second primary source on the item. On October 1, 2026 Zammad posted a statement to its own community forum, and this bulletin's first edition did not use it. On CVE-2026-102489 Zammad says it received a report in August 2026, that "Exploitation is only possible on Zammad 6.5 and older, because of the runtime environment those versions use", that those versions are already end of support, and that "Zammad 7.0 and later are not affected". It hardened the code anyway and says the change ships in 7.2.0, which is a later floor than DIVD's "version 7". On CVE-2026-102490 the sequence matters: at 12:16 UTC Zammad said DIVD had given it no technical details, that it "cannot verify a claim we have not been shown", and that it could not confirm the vulnerability, its scope or the affected versions; by 19:48 UTC it said it had received the details, was working on them, and added the one scoping claim that changes an operator's reading: "This issue cannot be exploited remotely on its own. An attacker would already need access to your server." It recommends updating to 7.2.0 and watching its GitHub security advisories. Zammad also objects to the disclosure process, noting DIVD's own timeline shows a report on September 24 followed by public scanning and disclosure on September 26.
Where they disagree, take the action that is safe under either. Zammad
says 7.0 and later are not affected by the RCE; DIVD says the code is
present through 7.1.3 but not exploitable there; both therefore point
above 6.5, and 7.2.0 satisfies both while also carrying the hardening,
so 7.2.0 is the floor this bulletin recommends rather than DIVD's
"version 7". On the root escalation, DIVD says every version from 1.5.0
and gives no fix; Zammad says it needs prior server access and, as of
today, has published no fixed version. Neither position lets you treat
the zammad user as contained, so the architectural step
stands whichever is right. What Zammad's "needs access to your server
already" does change is the shape of the risk: it is the second half of
a chain rather than a way in, which is exactly how it was used against
DIVD.
Am I affected?
# 1) Which version is installed. Whichever of these answers is your version;
# the last line makes sure you get an answer rather than a blank screen.
found=0
# Compare whatever you get against: 6.3.0 up to but not including 6.5.4 is the
# affected range for CVE-2026-102489; DIVD lists 7.0.0 and later as unaffected
# and puts anything below 6.3.0 in its Unknown column. CVE-2026-102490 (root)
# has no fixed version at all, so the version does not clear you of that one.
if [ -r /opt/zammad/VERSION ]; then echo "package or source install: $(cat /opt/zammad/VERSION)"; found=1; fi
v=$(dpkg-query -W -f='${Version}\n' zammad 2>/dev/null) && [ -n "$v" ] && { echo "deb package: $v"; found=1; }
v=$(rpm -q --qf '%{VERSION}\n' zammad 2>/dev/null) && [ -n "$v" ] && { echo "rpm package: $v"; found=1; }
if command -v docker >/dev/null 2>&1; then
d=$(docker ps --format '{{.Image}}\t{{.Names}}' 2>/dev/null | grep -i zammad)
[ -n "$d" ] && { echo "running containers:"; echo "$d" | sed 's/^/ /'; found=1; }
fi
[ "$found" -eq 1 ] || echo "no Zammad install found by any probe on $(hostname): run this on the Zammad host, or read your compose or helm image tag"
# 2) DIVD's indicator check, over rotated and gzipped logs. This is the pattern
# from DIVD's published script; prefer running their script itself if you can
# reach csirt.divd.nl, because they update it.
LOGS=$(find /var/log/zammad /var/log/nginx -type f \
\( -name 'production.log*' -o -name 'railsserver.log*' -o -name 'websocket.log*' \
-o -name 'scheduler.log*' -o -name 'nginx.log*' -o -name 'access.log*' \
-o -name 'error.log*' \) 2>/dev/null)
echo "log files scanned: $(echo "$LOGS" | wc -w)"
if [ -n "$LOGS" ]; then
zgrep -En 'ERROR -- :.*("Cookie"=>"|@clients=\{)' $LOGS 2>/dev/null
echo "-- distinct session cookies exposed in error output --"
zgrep -Eh 'ERROR -- :.*"Cookie"=>"' $LOGS 2>/dev/null | grep -oE '"Cookie"=>"[^"]*"' | sort -u
else
echo "no Zammad or nginx logs found at the default paths: point LOGS at yours"
fi
# 3) Is it reachable from the internet? A self-hosted helpdesk usually is, which
# is the point. Same decision rule as items 1 and 2.
for u in "https://your-zammad.example.com/" "https://your-zammad.example.com:9/"; do
out=$(curl -sS -k -o /dev/null -m 10 -w '%{http_code}' "$u" 2>/dev/null); rc=$?
if [ "$rc" -eq 0 ] && [ "$out" != "000" ]; then echo "REACHABLE $u (HTTP $out)"; else echo "no answer $u (curl exit $rc)"; fi
done
The version block deliberately tries four different install shapes
and then says so if none matched, because a Zammad that runs in
Docker leaves nothing at /opt/zammad and a check that
silently prints nothing on that host reads as "not affected". The log
scan reports how many files it looked at for the same reason: zero
files scanned and zero hits look identical otherwise.
Response
- Run the indicator check first, while the logs are still the ones the attacker would have touched. An upgrade rotates and restarts services. If you find session material in error output, you have a candidate compromise with a known escalation to root available on the same host, so stop and treat it as an incident: isolate the instance, preserve the logs and the filesystem, and work from a clean host.
-
If an indicator hit, assume root on that host and rotate
accordingly. CVE-2026-102490 means code execution as
zammadis code execution as root, with no fix to rely on, so the blast radius is the whole machine: database credentials, SMTP and IMAP credentials, API tokens, OAuth secrets, LDAP binds, any S3 or object storage keys, and every agent and customer session. Rebuild the host rather than cleaning it, and rotate from somewhere else. - Upgrade to Zammad 7.2.0. DIVD says "version 7" and lists 7.0.0 and later as unaffected by CVE-2026-102489; Zammad says 7.0 and later are not affected and that its hardening of the same code ships in 7.2.0. 7.2.0 satisfies both readings and is the current release, so it is the floor to take rather than the lowest version either source would accept. If you cannot upgrade promptly, DIVD's stated alternative is to take the instance offline, which for an internet-facing helpdesk is a real option worth costing rather than dismissing.
-
Do not treat the version 7 upgrade as closing the whole
chain. The root escalation has no fix as of October 2,
2026. Until it does, reduce what reaching the
zammaduser is worth: run Zammad on a host that holds nothing else, segment it away from your authentication and internal services the way DIVD credits its own segmentation for limiting this breach, and keep its outbound network access narrow. - Both are now in KEV, with a due date of October 5. CISA added CVE-2026-102489 and CVE-2026-102490 on October 2, 2026, so if you are bound by BOD 26-04 this is already overdue territory. For the root escalation, watch two places rather than one: DIVD's case page, and Zammad's GitHub security advisories, which Zammad itself names as where its fix will appear.
4. Cisco Catalyst SD-WAN Manager: one URL-encoded character bypasses authentication and hands over the admin API, exploitation confirmed (CVE-2026-76504)
Cisco published advisory cisco-sa-sdwan-webauth-xr8beuuU on September 30, 2026 at 13:00 GMT, rated Critical, CVSS 9.8, and says in its Exploitation and Public Announcements section: "In September 2026, the Cisco PSIRT became aware of active exploitation of this vulnerability." CISA added it to KEV the same day with a due date of October 3. There are no workarounds.
The mechanism is small enough to state exactly. An authentication rule
restricts access to a specific API endpoint, and the request handling
does not normalize URI encoding before that rule is applied
(CWE-177). Percent-encode any single character in the path and the
rule stops matching while the application still routes the request,
so an unauthenticated attacker reaches the API as the admin user. The
advisory's examples use %6a in place of the letter
j in j_security_check, and it is explicit
that this is only an example: "the vulnerability will allow any one
character that is encoded in the request to be used to exploit this."
That sentence is the difference between a detection that works and
one that does not, and the command block below is built around it.
%6a is one example of the encoding, not the signature.
Any single percent-encoded character anywhere in the request path
works, so a detection that greps literally for %6a will
miss /j_securit%79_check and every other variant. The
checks below therefore flag any percent-encoding in a request path
and then decode to confirm, which is why there are two passes rather
than one. The viptela-reserved- accounts are documented
Cisco system service accounts, so their appearance in a
j_security_check context is the anomaly, not the
account name itself.
Am I affected?
# 1) Version. In the vManage GUI this is under Help; from the vManage CLI
# (again a device command, not a shell one):
# show version
# Compare against the fixed release for your train: 20.9.10.1, 20.12.8.2,
# 20.15.6.1, 20.18.4.1, 26.1.2.1, 26.2.1. Anything earlier than 20.9 has to
# migrate. The flaw applies regardless of configuration, so the version is
# the whole answer.
ACCESS=/var/log/nms/containers/service-proxy/serviceproxy-access.log
SERVER=/var/log/nms/vmanage-server.log
# 2) Broad pass: ANY percent-encoded character in a request path, on any HTTP
# method. Do not pin the method list: an encoded path sent by OPTIONS, PATCH
# or DELETE would slip past a (GET|POST|HEAD|PUT) alternation. This pass will
# have false positives from legitimate encoded paths, which is the right
# trade for a first pass.
echo "== any percent-encoded request path, any method =="
grep -nE '"[A-Z]+ /[^" ]*%[0-9a-fA-F]{2}' "$ACCESS"
# 3) Precise pass: decode the path and report only the ones that resolve to the
# authentication endpoint while having been sent encoded. Very few false
# positives, and the same method-agnostic pattern.
echo "== decoded path is the auth endpoint but the request was encoded =="
python3 - "$ACCESS" <<'PY'
import re, sys, urllib.parse
pat = re.compile(r'"[A-Z]+ (\S+)')
for n, line in enumerate(open(sys.argv[1], errors="replace"), 1):
m = pat.search(line)
if not m:
continue
path = m.group(1).split("?", 1)[0]
if "%" not in path:
continue
if urllib.parse.unquote(path).rstrip("/") == "/j_security_check":
print(f"line {n}: ENCODED AUTH BYPASS raw={path}")
print(" " + line.rstrip()[:200])
PY
# 4) The server-side half: an encoded path inside the UserUtils map line, and
# any viptela-reserved- account appearing in that context.
echo "== encoded path recorded server side =="
grep -nE 'Request Stored in Map is \(/[^)]*%[0-9a-fA-F]{2}[^)]*\)' "$SERVER"
echo "== viptela-reserved- accounts in j_security_check handling =="
grep -nE 'Request Stored in Map is \([^)]*\) for user \(viptela-reserved-' "$SERVER"
# 5) Rotated and compressed copies. vManage rotates these logs, so for many
# readers the archive is where the evidence is. Use the SAME method-agnostic
# patterns as steps 2 and 4: a pattern anchored on %XX immediately before
# "security" would only match the advisory's own example position and would
# miss /j_securit%79_check and /j_security_chec%6b in the archive.
for f in "$ACCESS"* "$SERVER"*; do
case "$f" in
*.gz) zgrep -HnE '"[A-Z]+ /[^" ]*%[0-9a-fA-F]{2}|Request Stored in Map is \(/[^)]*%[0-9a-fA-F]{2}' "$f" 2>/dev/null ;;
esac
done
If step 3 prints anything, that is an authentication bypass attempt
against the admin API and the source addresses in the line are where
to start. If step 2 prints lines that step 3 does not, read them
rather than dismissing them: they are encoded requests to other
paths, which may be ordinary traffic or may be the same technique
aimed somewhere this bulletin did not anticipate. Note that
python3 is used for the decode because a shell pattern
cannot decode; if your vManage does not offer it, copy the log off
the appliance and run the decode where you can.
Response
-
Check the two logs before upgrading. The upgrade
restarts the service containers and rotates logs. Cisco's own
incident path starts with
request admin-techand a TAC case, which is far easier to do before you have replaced the running software. - If you see encoded authentication requests, treat the controller as compromised. An attacker with the admin API on an SD-WAN controller can reach the devices it manages, so the scope is your fabric, not one host. Isolate the controller's management access, preserve the admin-tech output, open the TAC case Cisco describes at Severity 3 with the CVE in the title, and rotate controller credentials and device onboarding secrets from a clean host before you rebuild.
- Take the controller off the internet now, before you schedule the upgrade. Cisco's mitigation for on-prem deployments is to restrict access from unsecured networks and put the control components behind a filtering device, allowing only known trusted hosts. It does not fix the flaw, and Cisco says so, but it removes the unauthenticated network attacker the advisory describes, and it takes minutes against a maintenance window you may not get today.
- Then upgrade to the fixed release for your train. 20.9.10.1, 20.12.8.2, 20.15.6.1, 20.18.4.1, 26.1.2.1 or 26.2.1. There is no workaround that fixes this, so the upgrade is the remediation and everything above it is containment.
5. Next.js and satori: improper SVG escaping in the Node.js ImageResponse from next/og, scored Critical by Vercel and Medium by the CVE record (GHSA-vcvr-r3jv-pc5j, CVE-2026-94545)
Vercel published GHSA-vcvr-r3jv-pc5j on September 30, 2026, rated
Critical with a CVSS v4 vector of
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H.
That GHSA carries no CVE alias of its own, but it has a
related CVE that covers the same flaw in both packages:
CVE-2026-94545, assigned by GitHub's CNA and
published the same day, which OSV and NVD both serve. The affected
range for next is 16.2.0 up to but not including 16.3.6,
per the advisory's own range, so a 15.x deployment is not in scope for
this issue.
The condition is specific and worth checking rather than assuming.
The advisory states that the Node.js ImageResponse
implementation from next/og is affected by an upstream
vulnerability, and that affected applications are the ones that "pass
attacker-controlled values into SVG content, attributes, or styles
during image generation". Its own example is an Open Graph image route
that drops a query-string parameter into an SVG
<title>. Two things are explicitly not affected:
applications using the Edge ImageResponse
implementation, and applications that do not route attacker-controlled
values into SVG. The workaround, if you cannot upgrade, is to stop
doing that.
The upstream component is satori, and the Next.js
advisory references a second advisory for it, GHSA-wx4j-mvgx-mqwp.
That GHSA page is on github.com and so could not be opened from this
environment, and it is not in OSV under its own identifier either.
It did not have to be opened, because CVE-2026-94545 carries
the satori range and this bulletin read that record instead.
Per CVE-2026-94545 as published by GitHub's CNA and served by NVD and
OSV: satori is affected from 0.0.27 up to but not including 0.33.5,
and "Version 0.33.5 contains a patch." npm confirms 0.33.5 exists,
published September 22, 2026 at 16:01 UTC, eighteen minutes before
next 16.3.6. So if you depend on satori
directly rather than only through next/og, your floor is
0.33.5, not the 16.3.6 that covers next.
The two primary records disagree about how bad this is, and the
disagreement is worth carrying rather than resolving. Vercel's
advisory calls it remote code execution and scores it Critical, with
VC:H/VI:H/VA:H on the vulnerable system. CVE-2026-94545
describes the same defect as improper escaping (CWE-116) that lets
"crafted values be interpreted as SVG markup", says "the impact
depends on how the generated SVG is consumed", and scores it
5.3 Medium with
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N,
that is, no impact on the vulnerable system and high impact on
subsequent ones. Both are reproduced here because this bulletin cannot
settle which is right from the outside, and the action is the same
under either reading: take 16.3.6 or later, take satori 0.33.5 or
later if you use it directly, and stop rendering attacker-controlled
values into SVG. The priorities table places this at Higher on
Vercel's reading and on the size of the installed base, not on the
CVE's score.
next range were read from
the OSV mirror of the GitHub Advisory Database, which carries the
advisory body verbatim, because github.com is not reachable from
this environment. The satori range, the quoted text and the Medium
score come from CVE-2026-94545 as served by both OSV and NVD. All
four release timestamps were read from the npm registry on
October 2, 2026. There are no indicators of compromise for this
item: successful exploitation looks like ordinary traffic to your
own Open Graph image route.
Am I affected?
# 1) Which next is actually installed, and is it in range. Run from the project
# root. require.resolve is pinned to process.cwd() deliberately: a bare
# require() resolves from the script's own directory and would report
# "not installed" on an affected project, which is the worst possible answer.
node -e '
const fs = require("fs"), path = require("path");
// Locate a package manifest WITHOUT require("pkg/package.json"). A package with an
// "exports" map does not export ./package.json, so that form throws, and a catch
// around it would report an AFFECTED project as "not installed". Walk node_modules
// upward from the cwd, then fall back to resolving the module entry and walking up
// from there, which covers pnpm and linked layouts.
function manifest(name, from) {
let dir = path.resolve(from);
for (;;) {
const p = path.join(dir, "node_modules", name, "package.json");
try { if (fs.statSync(p).isFile()) return p; } catch (e) {}
const up = path.dirname(dir); if (up === dir) break; dir = up;
}
try {
let d = path.dirname(require.resolve(name, { paths: [from] }));
for (;;) {
const p = path.join(d, "package.json");
try { if (fs.statSync(p).isFile() && JSON.parse(fs.readFileSync(p, "utf8")).name === name) return p; } catch (e) {}
const up = path.dirname(d); if (up === d) break; d = up;
}
} catch (e) {}
return null;
}
const RULES = {
"next": [{from:"16.2.0", fixed:"16.3.6"}],
"satori": [{from:"0.0.27", fixed:"0.33.5"}],
"vm2": [{from:"0.0.0", fixed:"3.11.7"}],
"piscina": [{from:"0.0.0", fixed:"4.9.4"},
{from:"5.0.0", fixed:"5.3.2"},
{from:"6.0.0-rc.1", fixed:"6.0.0-rc.5"}],
"@xhmikosr/decompress": [{from:"0.0.0", fixed:"10.2.2"},
{from:"11.0.0", fixed:"11.1.4"}],
"decompress": [{from:"0.0.0", fixed:null, note:"no fix exists: migrate to @xhmikosr/decompress"}]
};
const key = v => { const [c, p = ""] = String(v).split("-", 2);
const n = c.split(".").map(x => parseInt(x, 10) || 0); while (n.length < 3) n.push(0); return {n, p}; };
const cmp = (a, b) => { const A = key(a), B = key(b);
for (let i = 0; i < 3; i++) if (A.n[i] !== B.n[i]) return A.n[i] - B.n[i];
if (A.p === B.p) return 0; if (!A.p) return 1; if (!B.p) return -1; return A.p < B.p ? -1 : 1; };
for (const [pkg, ranges] of Object.entries(RULES)) {
const m = manifest(pkg, process.cwd());
if (!m) { console.log(pkg.padEnd(24), "no manifest found under " + process.cwd()); continue; }
const v = JSON.parse(fs.readFileSync(m, "utf8")).version;
const hit = ranges.find(r => cmp(v, r.from) >= 0 && (r.fixed === null || cmp(v, r.fixed) < 0));
console.log(pkg.padEnd(24), String(v).padEnd(12),
hit ? ">>> AFFECTED: " + (hit.fixed ? "upgrade to " + hit.fixed + " or later" : hit.note)
: "not in an affected range");
}'
# 2) Every copy in the tree, including duplicates at different depths. node -e
# above reports the copy that resolves from the project root; this reports all
# of them. Works for yarn and pnpm trees too, where grepping the lockfile does
# not.
npm ls next satori vm2 piscina decompress @xhmikosr/decompress --all 2>/dev/null
# 3) Do you actually use next/og, and on which runtime? Both matter: the Edge
# implementation is not affected.
grep -rEn "from ['\"]next/og['\"]|require\(['\"]next/og['\"]\)" \
--include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='*.mjs' \
--exclude-dir=node_modules --exclude-dir=.next --exclude-dir=.git .
echo "-- routes that opt into the Edge runtime (not affected) --"
grep -rEn "export const runtime[[:space:]]*=[[:space:]]*['\"]edge['\"]" \
--include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' \
--exclude-dir=node_modules --exclude-dir=.next --exclude-dir=.git .
# 4) Does request data reach the SVG? This is a reading aid, not a verdict: it
# lists the ImageResponse call sites so you can check each one by hand.
grep -rEn "new ImageResponse\(" \
--include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='*.mjs' \
--exclude-dir=node_modules --exclude-dir=.next --exclude-dir=.git .
Step 4 is explicitly not a decision procedure. Whether a value is attacker-controlled depends on where it came from several frames earlier, and no grep settles that. What the list gives you is a bounded set of call sites to read, which for most projects is one or two. If you cannot answer the question quickly for a given route, upgrade and move on: 16.3.6 or later removes the question.
Response
-
Upgrade
nextto 16.3.6 or later. 16.3.8 is current as of October 2, 2026. This is a patch-level move inside 16.3, so for most projects it is a lockfile re-resolve and a redeploy. A lockfile change that never reaches a running container has fixed nothing, so rebuild the image. -
While you are there, stop passing request data into SVG
from an image route. This is the advisory's own workaround
and it is also the durable fix: the route that renders a
user-supplied string into an SVG
<title>is doing template injection into a renderer, and the next renderer bug will land on the same code. Render from values you control, or escape and constrain hard. -
If you depend on
satoridirectly, raise it to 0.33.5 or later as a separate change. That floor comes from CVE-2026-94545, not from the Next.js advisory, and the two packages are on separate release schedules, so upgradingnextdoes nothing for a directsatoridependency and vice versa. Check both. The CVE says no complete workaround exists other than upgrading, and that applications which cannot upgrade should not render attacker-controlled content through satori. - Check whether 16.3.6 is actually what you deployed, not just what you resolved. Run the step 1 check inside the running container or on the deployed instance. Next.js projects frequently have more than one copy of the framework in a monorepo, which is what step 2 is for.
6. PyJWT: a whitespace-mutated PEM slips past the HMAC confusion guard, and the public key becomes the signing secret (CVE-2026-102268)
GHSA-ffc3-869f-jxw9 is an incomplete-guard bypass of the
CVE-2022-29217 family, rated Critical with
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N and CWE-347.
NVD published it on September 28, 2026 and the GitHub Advisory
Database reviewed it in on September 29. Every PyJWT release up to and
including 2.13.0 is affected; the fix is 2.14.0, which shipped on
September 11, 2026, more than two weeks before the advisory. 2.15.1 is
current as of today.
The mechanism is a mismatch between two parsers. PyJWT's
HMACAlgorithm.prepare_key refuses to use an asymmetric
key as an HMAC secret, but only when is_pem_format()
recognizes the key as PEM. That recognizer is a regular expression
requiring the BEGIN and END markers to sit directly after a newline
with mandatory LF line terminators, while
cryptography's load_pem_public_key()
accepts considerably more. Insert a tab before -----END,
use CR-only line terminators, or fold the PEM onto a single line, and
the guard does not fire while the key still loads. The public key is
then used directly as the HMAC secret, and anyone who knows the
public key, which is everyone, can mint a valid HS256 token. The
advisory calls it universal forgery and includes a reproduction on
2.13.0 with cryptography 49.0.0 showing each mutation
forging a token accepted as superadmin.
Both preconditions are deployment properties rather than things an
attacker chooses at request time, which is what keeps this at Medium
rather than Higher. First, the jwt.decode allow-list has
to mix an HMAC algorithm with an asymmetric one, for example
algorithms=["ES256", "HS256"], which is the RFC 8725
footgun the guard exists to backstop. Second, the key has to be passed
as raw PEM text or bytes on the non-PyJWK path in one of
those mutated forms. The advisory is specific about where such forms
come from in practice, and the list should be familiar: an indented
YAML or JSON block, a single-line environment variable, or a CR/LF
round-trip through config tooling. If your allow-list mixes families
and your key comes out of an environment variable, treat this as
Higher for you.
HS256 on an endpoint that should
only ever see asymmetric signatures.
Am I affected?
# 1) What is installed in the interpreter the service actually uses. Run this
# inside the container or virtualenv, not on the host.
python3 -c "
from importlib.metadata import version, PackageNotFoundError
for name, fixed in (('pyjwt','2.14.0'),):
try:
v = version(name)
bad = tuple(int(x) for x in v.split('.')[:3]) < tuple(int(x) for x in fixed.split('.'))
print(f'{name}: installed {v}, fixed in {fixed}:', 'AFFECTED' if bad else 'ok')
except PackageNotFoundError:
print(f'{name}: not installed in this interpreter')
"
# 2) Does any decode allow-list mix an HMAC algorithm with an asymmetric one?
# This is the precondition that turns the bug into universal forgery.
python3 - . <<'PY'
import os, re, sys
# Three shapes, because only matching algorithms=[...] at the call site misses
# the two most common real ones:
# 1. inline, list or TUPLE: algorithms=["ES256","HS256"] / ("ES256","HS256")
# 2. a module-level constant: ALGS = ["ES256","HS256"] ... algorithms=ALGS
# A list assembled at runtime (appends, a config lookup, a comprehension) matches
# none of these and still has to be read by hand: step 3 lists the decode sites.
INLINE = re.compile(r'algorithms\s*=\s*[\[(]([^\])]*)[\])]', re.S)
ASSIGN = re.compile(r'^[ \t]*([A-Za-z_][A-Za-z_0-9]*)\s*=\s*[\[(]([^\])]*)[\])]', re.M | re.S)
NAMED = re.compile(r'algorithms\s*=\s*([A-Za-z_][A-Za-z_0-9.]*)')
HMAC = re.compile(r'HS(256|384|512)')
ASYM = re.compile(r'(RS|ES|PS)(256|384|512)|EdDSA|Ed25519')
SKIP = {'.git', 'node_modules', '.venv', 'venv', '__pycache__', '.tox', 'site-packages'}
def mixed(body):
return bool(HMAC.search(body) and ASYM.search(body))
hits = 0
for root, dirs, files in os.walk(sys.argv[1]):
dirs[:] = [d for d in dirs if d not in SKIP]
for f in files:
if not f.endswith('.py'):
continue
p = os.path.join(root, f)
try:
s = open(p, errors='replace').read()
except OSError:
continue
for m in INLINE.finditer(s):
if mixed(m.group(1)):
line = s.count('\n', 0, m.start()) + 1
print(f'{p}:{line}: MIXED allow-list inline: {" ".join(m.group(1).split())}')
hits += 1
# constants that are mixed, reported when something passes them as the
# allow-list anywhere in the same file
names = {m.group(1): (s.count('\n', 0, m.start()) + 1, m.group(2))
for m in ASSIGN.finditer(s) if mixed(m.group(2))}
for m in NAMED.finditer(s):
ref = m.group(1).split('.')[-1]
if ref in names:
line = s.count('\n', 0, m.start()) + 1
dl, body = names[ref]
print(f'{p}:{line}: MIXED allow-list via {m.group(1)} '
f'(defined line {dl}): {" ".join(body.split())}')
hits += 1
print(f'-- {hits} mixed allow-list(s) found --')
print('-- a list built at runtime matches none of these: read the decode sites '
'that step 3 lists --')
PY
# 3) Where does the verification key come from? A PEM arriving through an
# environment variable or an indented YAML block is the shape that triggers
# this. These greps list candidates to read, they are not a verdict.
grep -rEn "BEGIN (PUBLIC KEY|RSA PUBLIC KEY|CERTIFICATE)" \
--include='*.yml' --include='*.yaml' --include='*.json' --include='*.env' \
--exclude-dir=.git --exclude-dir=node_modules .
grep -rEn "jwt\.decode\(" --include='*.py' --exclude-dir=.git --exclude-dir=node_modules .
The importlib.metadata check is the authoritative one for
a running service because it reports what the interpreter would
import. Step 2 prints a count on the last line on purpose: zero mixed
allow-lists found is a different statement from the scan not having
run, and without the count those two look identical.
Response
-
Raise the floor to
pyjwt>=2.14.0and re-resolve. 2.15.1 is current. Because the fix shipped on September 11, many lockfiles already satisfy it; confirm with the step 1 check inside the deployed environment rather than trusting the lockfile. - Split the mixed allow-lists, whatever version you end up on. An endpoint verifying asymmetric signatures should list only asymmetric algorithms. The guard this advisory bypasses exists only because mixed lists are accepted at all, and removing the mix removes the whole class, including the next bypass of the same guard.
-
Normalize or stop passing raw PEM. Prefer the
PyJWKpath, which does not reach the guard at all. If you must pass PEM text, load it from a file rather than an environment variable or an inline YAML block, and if it has been through config tooling, check that it still has LF terminators and no indentation next to the markers. - If you had both preconditions, treat issued sessions as suspect. Universal forgery means an attacker could mint any identity. Rotate the signing keypair, invalidate outstanding sessions, and look for HS256 tokens in your authentication logs on endpoints that should only ever have seen asymmetric ones.
7. vm2: fifteen advisories at once, ten of them critical sandbox escapes, all fixed in a release that shipped five weeks earlier (CVE-2026-92935 and fourteen others)
On October 1, 2026 fifteen advisories for the npm package
vm2 were published to the GitHub Advisory Database in a
single batch. Ten are rated Critical, two High, three Moderate. Every
one of them is fixed in vm2 3.11.7, which was published
to npm on August 24, 2026. The current release is 3.12.2, published
September 8, 2026. So the upgrade has been available for over a month
and the disclosure is what is new, which is the correct way to read
this item: if your scanners just lit up, the fix is a version bump you
could have taken in August.
Taken together they say something more useful than any one of them
does. The escapes come through node:sqlite, through
node:test.run() and its execArgv, through
the crypto builtin's setEngine loading attacker native
code, through a stale protector on Node.js 26, through an array-shaped
require that the nesting guard accepted, through
node:-prefixed builtin names bypassing the denylist to
reach child_process, through a custom module resolver
loading a colliding host package, and through fs/promises
despite -fs. Two more are not escapes but are arguably
worse in a shared process: the sandbox can replace the host process
TLS trust store (CVE-2026-92941) and can read host HTTPS credentials
and TLS traffic through globalAgent (CVE-2026-92940).
Three are allowlist, freeze and symbol-filtering weaknesses, and one,
CVE-2026-92950, says the vm2 CLI provides no sandbox isolation at
all, with host-realm require() reachable from sandboxed
scripts.
This audience hits vm2 most often in two places:
plugin or template evaluation in a self-hosted app, and the sandbox
somebody reached for when they needed to run model-generated or
user-supplied JavaScript. The second is the one worth being blunt
about. A batch like this is what a mature in-process JavaScript
sandbox looks like under sustained scrutiny, and the pattern of a
guard being bypassed by a shape the guard did not anticipate repeats
across eight of the fifteen. Upgrade, and then plan to stop relying
on an in-process sandbox as the boundary between untrusted code and
your host.
Am I affected?
# 1) Version check. The node -e block in item 5 covers vm2 as well; this is the
# single-package form if that is all you need.
node -e '
const fs = require("fs"), path = require("path");
// Same manifest walk as item 5, for the same reason: require("vm2/package.json")
// throws on any package with an exports map, and a silent catch reads as "clean".
let dir = path.resolve(process.cwd()), m = null;
for (;;) {
const p = path.join(dir, "node_modules", "vm2", "package.json");
try { if (fs.statSync(p).isFile()) { m = p; break; } } catch (e) {}
const up = path.dirname(dir); if (up === dir) break; dir = up;
}
if (!m) { console.log("no vm2 manifest found under " + process.cwd() + " (see the find fallback below)"); }
else {
const v = JSON.parse(fs.readFileSync(m, "utf8")).version;
const n = v.split("-")[0].split(".").map(Number);
const bad = n[0] < 3 || (n[0] === 3 && (n[1] < 11 || (n[1] === 11 && n[2] < 7)));
console.log("vm2", v, bad ? ">>> AFFECTED: upgrade to 3.11.7 or later (3.12.2 is current)"
: "not in an affected range");
}'
# 2) Every copy in the tree, including transitive ones you did not ask for.
# vm2 is frequently pulled in by something else, which is how it survives in
# projects that never chose it.
npm ls vm2 --all 2>/dev/null
# If npm ls is unavailable (pnpm store layouts, vendored trees), find the
# installed manifests directly:
find . -path '*/node_modules/vm2/package.json' -not -path '*/.git/*' \
-exec sh -c 'printf "%s " "$1"; grep -o "\"version\"[^,]*" "$1"' _ {} \;
# 3) Where is it used, and is the input untrusted? These are the call shapes.
grep -rEn "require\(['\"]vm2['\"]\)|from ['\"]vm2['\"]|new (VM|NodeVM|VMScript)\(" \
--include='*.js' --include='*.cjs' --include='*.mjs' --include='*.ts' \
--exclude-dir=node_modules --exclude-dir=.git .
# 4) Is the CLI in use anywhere? CVE-2026-92950 says it offers no isolation at all.
grep -rEn "(^|[^a-z])vm2($|[^a-z-])" --include='*.json' --include='*.sh' \
--include='Makefile' --include='*.yml' --include='*.yaml' \
--exclude-dir=node_modules --exclude-dir=.git . \
| grep -vE '"vm2"[[:space:]]*:|"resolved"|"integrity"|/vm2/-/vm2-|node_modules/vm2'
Step 2 matters more than usual here. vm2 is the kind of
dependency that arrives through a template engine or a rules engine
three levels down, so a project whose package.json has
never mentioned it can still ship it. The find fallback
is there because npm ls reports what npm installed, and
a pnpm or vendored tree can hold copies it does not see.
Response
- Upgrade to 3.11.7 or later. 3.12.2 is current as of October 2, 2026. One bump clears all fifteen, including the ten critical escapes. If the copy is transitive, raise it with an override or a resolution and confirm with step 2 afterwards rather than assuming the override took.
-
Work out what an escape would have reached, and rotate
it. Two of the critical issues are not escapes but
disclosure: CVE-2026-92940 exposes host HTTPS credentials and TLS
traffic through
globalAgent, and CVE-2026-92941 lets the sandbox replace the host trust store. If you ran untrusted code in vm2 below 3.11.7 in a process that also held API keys or made outbound TLS calls, those secrets should be treated as exposed and rotated, independently of whether you can show an escape happened. - Stop treating vm2 as the boundary for genuinely untrusted code. Eight of the fifteen are a guard being defeated by an input shape it did not anticipate, which is the failure mode of in-process sandboxing rather than a bug in this library. If you are running model-generated or user-supplied JavaScript, move it to a separate process with an OS-level boundary, or to a runtime designed for it, and keep vm2 for code you merely want to isolate from your own globals.
-
If you use the vm2 CLI, treat it as providing no isolation
at all. CVE-2026-92950 is explicit that host-realm
require()is reachable from scripts run through it. That is not a configuration to tighten, it is a tool to stop using for untrusted input.
8. Apple CoreGraphics: an out-of-bounds write exploited against targeted individuals, patched for macOS and iOS development machines (CVE-2026-86950)
Apple published security notes 149226, 149228 and 149229 on September 28, 2026 for iOS 26.7.1 and iPadOS 26.7.1, macOS Tahoe 26.7.1, and macOS Sequoia 15.8.1. Each carries a single CoreGraphics entry: an out-of-bounds write (CWE-787), fixed with improved bounds checking, where "Processing a maliciously crafted file may lead to arbitrary code execution." Apple adds the sentence that puts it in this bulletin: "Apple is aware of a report that this issue may have been exploited in an extremely sophisticated attack against specific targeted individuals on versions of iOS before iOS 27." The issue is credited to Meta Product Security. CISA added it to KEV on September 29 with a due date of October 2, which is today. NVD records a secondary CVSS 3.1 score of 8.8 with user interaction required.
This is in the bulletin because a large share of this audience does its work on a Mac, and a CoreGraphics file-parsing bug is reached by opening an image or a PDF, which is an ordinary thing to do with an attachment or a design asset. It sits at Medium rather than Higher because Apple's own characterization is a sophisticated attack against specific targeted individuals, not commodity exploitation, and because the update is a routine one most readers will take anyway. If you are a plausible target for that kind of attack, which for this audience usually means maintaining widely-depended-upon software or holding production signing keys, move it up your list.
Am I affected?
# macOS only. There is no /proc and no ss on a Mac, and nothing in this block
# applies on Linux. For iOS and iPadOS, read Settings, General, About, Software
# Version and compare against 26.7.1.
V="$(sw_vers -productVersion 2>/dev/null)"
if [ -z "$V" ]; then
echo "sw_vers not available: this check is macOS only, and this host is not a Mac"
else
case "$V" in
15.*) NEED=15.8.1; TRAIN="Sequoia" ;;
26.*) NEED=26.7.1; TRAIN="Tahoe" ;;
*) NEED=""; TRAIN="" ;;
esac
if [ -z "$NEED" ]; then
echo "macOS $V is on neither the Sequoia 15.x nor the Tahoe 26.x train that this CVE was patched on. Apple published no note for other trains in this set, so there is no listed fix for yours: check Apple's security releases page."
else
awk -v have="$V" -v need="$NEED" -v train="$TRAIN" 'BEGIN{
nh=split(have,h,"."); nn=split(need,n,".");
for(i=1;i<=3;i++){ hv=(i<=nh?h[i]+0:0); nv=(i<=nn?n[i]+0:0);
if(hv<nv){ printf "macOS %s (%s): AFFECTED, patched in %s\n", have, train, need; exit }
if(hv>nv){ printf "macOS %s (%s): at or above the patched %s\n", have, train, need; exit } }
printf "macOS %s (%s): at the patched version %s\n", have, train, need; }'
fi
fi
# What the update mechanism thinks is pending
softwareupdate -l 2>&1 | sed -n '1,20p'
# Fleet view: if you manage Macs with MDM, the same question asked once. For a
# handful of machines over ssh:
# for h in mac1 mac2 mac3; do printf '%s: ' "$h"; ssh "$h" sw_vers -productVersion; done
The version comparison is written out rather than left to the reader because the two supported trains need different targets and 26.7.1 sorts below 15.8.1 under a naive string comparison. The "neither train" branch deliberately does not say you are safe: Apple shipped notes for Sequoia, Tahoe, iOS and iPadOS only, so an older macOS has no listed fix rather than no exposure.
Response
- Update to macOS Sequoia 15.8.1, macOS Tahoe 26.7.1, or iOS and iPadOS 26.7.1. These are small security updates on existing trains, not a major version move, so they belong in this week's workstation cadence rather than a planning cycle.
- Prioritize the machines that hold signing keys, deploy credentials, or maintainer access. Apple's own framing is targeted attacks, and in this audience the people who get targeted are the ones whose workstation is a path into something widely used. A developer laptop with npm or PyPI publish rights is that machine.
- If you are a plausible target and cannot update immediately, consider Lockdown Mode. Apple's notes for this CVE name no mitigation, so this is this bulletin's suggestion and not a vendor recommendation: Lockdown Mode narrows the file-parsing surface that this class of bug lives in. It is not a patch, it has real functional costs, and it is a stopgap.
- Check the older Macs rather than assuming they inherited a fix. A machine on a train Apple did not publish a note for has no fix for this CVE. If it cannot move to Sequoia 15.8.1 or later, it should not be the machine opening untrusted attachments.
9. decompress: a symlink chain walks the extraction out of the output directory, and the package most projects actually depend on will never be fixed (CVE-2026-101894)
GHSA-hrh2-vp3x-79xf is rated Critical,
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N, CWE-22 and
CWE-59. NVD published it on September 28, 2026; the GitHub Advisory
Database reviewed it in on September 29. The fixes,
@xhmikosr/decompress 11.1.4 and 10.2.2, both shipped on
August 5, 2026, so like items 5, 6, 7 and 10 this is a disclosure landing
in this window rather than a new flaw.
The bug is a good illustration of why lexical path checks are not
enough. Extracting an untrusted archive with the default
decompress(input, output) API, an archive containing a
chain of symlink entries can make a later entry resolve outside
output. The containment check passes because it is
performed on the path as written, and then the kernel follows the
symlinks the earlier entries planted. The advisory notes this is a
bypass of the hardening in GHSA-mp2f-45pm-3cg9, and that overwriting
startup scripts or configuration gets you from arbitrary write to
remote code execution. Any application that extracts
attacker-controlled archives is affected, and the advisory says there
is no workaround beyond not extracting untrusted archives.
The part to act on is the second package. The advisory states plainly:
"The unmaintained upstream decompress package shares this
flaw and will not be patched." OSV records decompress with
a last_affected of 4.2.1, and 4.2.1 is still the
latest dist-tag on npm as of October 2, 2026, with 41
versions and nothing newer. decompress is a very common
transitive dependency, so the realistic finding for most readers is
not "we use decompress" but "something we use uses decompress", and
the remediation is a migration or a removal rather than a bump.
Am I affected?
# 1) Both package names, every copy in the tree. The node -e block in item 5
# covers both; this is the targeted form.
npm ls decompress @xhmikosr/decompress --all 2>/dev/null
# ...and the verdict, not just the inventory. Fixed: @xhmikosr/decompress
# 11.1.4 on the 11.x line and 10.2.2 below it; upstream decompress has no fix.
node -e '
const fs = require("fs"), path = require("path");
function manifest(name, from) {
let dir = path.resolve(from);
for (;;) {
const p = path.join(dir, "node_modules", name, "package.json");
try { if (fs.statSync(p).isFile()) return p; } catch (e) {}
const up = path.dirname(dir); if (up === dir) return null; dir = up;
}
}
const cmp = (a, b) => { const A = a.split(".").map(Number), B = b.split(".").map(Number);
for (let i = 0; i < 3; i++) if ((A[i]||0) !== (B[i]||0)) return (A[i]||0) - (B[i]||0); return 0; };
let m = manifest("@xhmikosr/decompress", process.cwd());
if (!m) console.log("@xhmikosr/decompress not found under " + process.cwd());
else {
const v = JSON.parse(fs.readFileSync(m, "utf8")).version;
const floor = cmp(v, "11.0.0") >= 0 ? "11.1.4" : "10.2.2";
console.log("@xhmikosr/decompress ", v,
cmp(v, floor) < 0 ? ">>> AFFECTED: upgrade to " + floor + " or later" : "not in an affected range");
}
m = manifest("decompress", process.cwd());
console.log("decompress ", m ? JSON.parse(fs.readFileSync(m, "utf8")).version
+ " >>> AFFECTED: no fix exists for this package, migrate to @xhmikosr/decompress"
: "not found under " + process.cwd());'
# 2) Fallback that does not depend on npm resolving the tree, for pnpm store
# layouts and vendored node_modules.
find . \( -path '*/node_modules/decompress/package.json' \
-o -path '*/node_modules/@xhmikosr/decompress/package.json' \) \
-not -path '*/.git/*' \
-exec sh -c 'printf "%s\n " "$1"; grep -oE "\"(name|version)\": *\"[^\"]*\"" "$1" | tr "\n" " "; echo' _ {} \;
# 3) Who pulled it in. For the unmaintained package this is the whole question,
# because you probably did not choose it.
npm why decompress 2>/dev/null || npm ls decompress --all 2>/dev/null
# 4) Do you extract archives you did not create? These are the call sites to read.
grep -rEn "require\(['\"](@xhmikosr/)?decompress['\"]\)|from ['\"](@xhmikosr/)?decompress['\"]" \
--include='*.js' --include='*.cjs' --include='*.mjs' --include='*.ts' \
--exclude-dir=node_modules --exclude-dir=.git .
# 5) Do NOT try to settle this with a hand-built archive. A single symlink entry
# pointing outside the output directory is rejected by BOTH the affected and the
# patched versions, with the same message, because that case was already closed
# by the earlier hardening in GHSA-mp2f-45pm-3cg9. This was tested against real
# @xhmikosr/decompress 11.1.3 and 11.1.4 while preparing this bulletin: both
# answered "Refusing to create a link pointing outside the output directory",
# and nothing escaped on either. The published flaw needs a CHAIN of symlink
# entries, which the advisory does not provide an archive for and this bulletin
# did not reconstruct. So a naive test tells you nothing, and reading it as
# "we are fine" is exactly the wrong conclusion. Use the version checks above.
Step 5 is a warning rather than a check, and it is there because the
obvious way to reassure yourself here does not work. The version
answer is the reliable one for this item: 11.1.4 or 10.2.2 on the
fork, and no fixed version at all on the unmaintained
decompress. If a transitive copy is the one your code
actually calls, step 3 is how you find out which, and step 2 is how
you find copies npm ls does not report.
Response
- Stop extracting untrusted archives on affected versions first. There is no workaround, so if a service takes uploaded archives and expands them, the cheap containment is to disable that path or queue the uploads until the upgrade lands. That is a smaller outage than the alternative.
- On a host that has extracted untrusted archives, capture the evidence before you redeploy. The impact the advisory names is writing outside the extraction directory, typically over startup scripts or configuration, and a container rebuild discards exactly that. At minimum record modification times for the plausible targets (init and unit files, cron directories, shell profiles, the app's own config) and compare them against your upload timeline. A match is a compromise of that host, not a corrupted file, and it goes down the incident path rather than the upgrade path.
-
Upgrade
@xhmikosr/decompressto 11.1.4, or to 10.2.2 if you are held on the v10 line. Both have been available since August 5, so for many projects this is already satisfied; confirm with step 1 rather than the lockfile. -
Replace the unmaintained
decompresspackage. It has no fix and will not get one. Where it is transitive, the options in order of preference are: upgrade the dependency that pulls it in, if a newer version has moved to the fork; add an override or resolution pointingdecompressat the fork, and test, because the fork's API is compatible but your dependency's expectations may not be; or drop the feature that needs it. - Treat a hit from the evidence step as a host compromise. An arbitrary write outside the extraction directory, on a service that expands uploads, is remote code execution as soon as the overwritten file runs. Isolate the host, keep what you captured, rotate the credentials that process held from a clean machine, and rebuild rather than repairing the overwritten file.
10. piscina: a prototype-pollution gadget reaches execArgv and preloads attacker code into every worker (CVE-2026-102992)
GHSA-67c8-pqhq-4rmx was published on October 1, 2026, rated
Critical with
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
and CWE-1321. The fixes, 5.3.2, 4.9.4 and 6.0.0-rc.5, all shipped on
August 28, 2026.
ThreadPool.options is built as a plain object, so it
inherits from Object.prototype, and any option that has
no explicit default in kDefaultOptions can therefore be
supplied through the prototype chain. The worst of the reachable
options is execArgv, which is passed straight to
new Worker(..., { execArgv }): setting
Object.prototype.execArgv = ['--require', '/tmp/attacker.js']
makes every worker preload the attacker's module on startup. Also
reachable are loadBalancer, an attacker-supplied function
called during task scheduling in the main thread, and
env, injected into each worker. The advisory is explicit
that this survived the fix for CVE-2026-55388, which hardened the
filename reads but left ThreadPool.options
without a null prototype.
It sits at Lower because it is a gadget, not an entry point: you need
a prototype-pollution primitive somewhere else in the application
first, typically a vulnerable merge() or an unguarded
deep-merge of parsed JSON. That is a real caveat and also a thin one,
because prototype pollution is among the most common findings in a
Node dependency tree, and this gadget upgrades it from "unexpected
behaviour" to code execution. Treat the combination as the risk rather
than piscina alone.
execArgv,
loadBalancer and env vectors; this bulletin
did not run it, and the mechanism above is taken from the advisory
text rather than from independent reproduction.
Am I affected?
# 1) Version, every copy. piscina is frequently transitive: build tooling and
# framework internals use it, so a project that never named it can ship it.
npm ls piscina --all 2>/dev/null
node -e '
const fs = require("fs"), path = require("path");
// Manifest walk, not require("piscina/package.json"): that form throws on any
// package with an exports map, and a silent catch would read as "not installed".
let dir = path.resolve(process.cwd()), m = null;
for (;;) {
const p = path.join(dir, "node_modules", "piscina", "package.json");
try { if (fs.statSync(p).isFile()) { m = p; break; } } catch (e) {}
const up = path.dirname(dir); if (up === dir) break; dir = up;
}
if (!m) console.log("no piscina manifest found under " + process.cwd());
else {
const v = JSON.parse(fs.readFileSync(m, "utf8")).version;
const n = v.split("-")[0].split(".").map(Number);
const pre = v.includes("-");
let floor;
if (n[0] >= 6) floor = "6.0.0-rc.5";
else if (n[0] === 5) floor = "5.3.2";
else floor = "4.9.4";
const cmp3 = (a, b) => { const A = a.split("-")[0].split(".").map(Number), B = b.split("-")[0].split(".").map(Number);
for (let i = 0; i < 3; i++) if ((A[i]||0) !== (B[i]||0)) return (A[i]||0) - (B[i]||0); return 0; };
let bad;
if (floor === "6.0.0-rc.5") bad = pre && (parseInt((v.split("rc.")[1] || "99"), 10) < 5);
else bad = cmp3(v, floor) < 0;
console.log("piscina", v, bad ? ">>> AFFECTED: upgrade to " + floor + " or later"
: "not below the fix for its branch (" + floor + ")");
}
'
# 2) The other half of the precondition: a prototype-pollution primitive. These
# are the usual shapes. Each hit is something to read, not a verdict.
grep -rEn "Object\.assign\(.*req\.(body|query|params)|_\.merge\(|_\.defaultsDeep\(|deepmerge\(|\.\.\.(JSON\.parse|req\.body)" \
--include='*.js' --include='*.cjs' --include='*.mjs' --include='*.ts' \
--exclude-dir=node_modules --exclude-dir=.git .
# 3) Let your auditor answer it: any advisory with CWE-1321 in the tree is the
# other half of this chain.
npm audit --json 2>/dev/null | python3 -c "
import json,sys
try: d=json.load(sys.stdin)
except Exception: print('npm audit emitted nothing parseable here'); raise SystemExit
# ENOLOCK is VALID json with exit 0, so this check cannot be left to the except
# above: without it, a project with no package-lock.json (pnpm, yarn) prints a
# clean count it never earned.
if 'error' in d:
print('npm audit did not run:', (d['error'] or {}).get('summary', d['error']))
print(' no lockfile means no audit: generate one with'
' npm i --package-lock-only, or use your own package manager audit')
raise SystemExit
vias=d.get('vulnerabilities',{})
found=0
for name,v in vias.items():
for via in v.get('via',[]):
if not isinstance(via,dict): continue
t=(via.get('title') or '')
if 'rototype' in t:
print('prototype pollution:', name, '->', t); found+=1
print(f'-- {found} prototype-pollution advisory/advisories in the tree --')
"
# 4) Confirm the gadget is closed on YOUR installed copy. This one does
# discriminate: it was run against real piscina 5.3.1 and 5.3.2 while preparing
# this bulletin, and 5.3.1 printed the canary line while 5.3.2 did not. Run it in
# a scratch directory, because it writes canary.js and worker.js into the cwd.
node -e '
const path = require("path"), fs = require("fs");
fs.writeFileSync("canary.js", "console.log(\"CANARY: inherited execArgv was used\");");
fs.writeFileSync("worker.js", "module.exports = () => 1;");
Object.prototype.execArgv = ["--require", path.join(process.cwd(), "canary.js")];
let mod;
try { mod = require(require.resolve("piscina", { paths: [process.cwd()] })); }
catch (e) { console.log("piscina not loadable from " + process.cwd() + ": " + (e.code || e.message)); process.exit(0); }
const P = mod.Piscina || mod.default || mod;
if (typeof P !== "function") { console.log("could not find the Piscina constructor on this export shape"); process.exit(0); }
const pool = new P({ filename: path.join(process.cwd(), "worker.js"), minThreads: 1, maxThreads: 1 });
pool.run(1)
.then(() => { console.log("pool ran: if no CANARY line appeared above, the inherited execArgv was NOT used"); return pool.destroy(); })
.catch(e => { console.log("error:", e.message); return pool.destroy(); });
'
Step 4 is the honest version of this check. A canary line means the
inherited execArgv reached the worker, which is the
vulnerability. No canary line means it did not on this code path, which
is good evidence and not a proof that every reachable option is closed:
the advisory lists a dozen of them and this exercises one. Run it in a
scratch directory, since it writes canary.js and
worker.js into the working directory.
Response
- Upgrade to the fix for your branch: 5.3.2, 4.9.4, or 6.0.0-rc.5. All three have been on npm since August 28, so a re-resolve is likely all this takes. Confirm with step 1, because a transitive copy will not move just because your direct dependency did.
-
Close the prototype-pollution primitive, which is the part
that actually matters. piscina was one gadget; fixing it
leaves the pollution reachable by whatever else inherits options
from
Object.prototype, and in a real dependency tree there is usually something else. Fix the merge, and preferObject.create(null)or aMapfor anything built from parsed input. -
If you had both halves, treat it as code execution and
rotate.
execArgvpreloading means arbitrary code in every worker, andloadBalancermeans arbitrary code in the main thread, so the credentials and tokens in that process are in scope. The advisory notes this class survived the earlier CVE-2026-55388 fix, so "we patched that one" is not the same as never having been exposed.
What this window actually shows
Five products with confirmed exploitation in seven days. Four of them are infrastructure, and in three of those four the attacker needed no credentials: NetScaler by default configuration, Cisco by one percent-encoded character, FortiMail by a crafted HTTP request. The fourth, Zammad, was reached unauthenticated by hijacking a session. The fifth is Apple's CoreGraphics bug, which is a workstation issue and, on Apple's own account, a targeted one. Three of the four infrastructure items are the boxes that sit at the edge of a self-hosted network and hold the credentials for everything behind them, which is why the response sections above spend more words on rotation and rebuild than on the upgrade itself. The upgrade is the easy part. The hard part is that a NetScaler holds your LDAP service account, a FortiMail holds your mail, and a Zammad holds your customers' correspondence and, thanks to CVE-2026-102490, root on its own host.
The second pattern is the one the dependency half of this bulletin is really about, and it is the opposite shape to the September 25 edition, which found advisories lagging their fixes by two to three months. Here the lag is still there but shorter and more uniform: vm2 fixed August 24 and disclosed October 1, piscina fixed August 28 and disclosed October 1, decompress fixed August 5 and disclosed September 28, PyJWT fixed September 11 and disclosed September 28, Next.js fixed September 22 and disclosed September 30. Five items, five fixes that already existed when the advisory appeared, with a median gap of around five weeks. If you keep your dependencies current you were already patched for all five before you heard about any of them. If you patch only when a scanner opens a pull request, your exposure window for each was the gap plus your own patch time, and the gap was not yours to control.
One more number from this window, for scale rather than action. OSV recorded 265 malicious package advisories published between September 26 and October 2, 2026: 243 in npm, 20 in PyPI, 2 in Go and none in crates.io. That is the background rate of typosquats and credential stealers in the public registries, roughly 38 a day, and it does not represent a single campaign. It is the reason an install from a registry is a trust decision and not a download.
On the window itself. The procedure this edition follows computes the window from the date of the top entry on the index, which is September 28, 2026. That would give a four-day window and a direction to stop. The September 28 entry is the KEV supplement, and it covers September 13 to 25, as does the September 25 catch-up before it, so content coverage ends September 25 and the uncovered period is September 26 to October 2, which is seven days. This edition uses the uncovered period. The KEV additions in it, none of them covered anywhere in this project before today, are the evidence that this was the right reading, but the deviation is stated here so it can be judged rather than discovered. There were five when this bulletin was written on October 2; the October 4 update brings it to seven, because CISA added both Zammad CVEs on October 2 itself, after the catalog version this edition first fetched.
Two sources could not be reached, and the first one costs
content. The Citrix community blog that carries the
NetScaler indicators of compromise returns a Cloudflare interstitial
to every request from this build environment, with and without a
browser user agent. That is origin bot protection rather than a
network policy denial: the proxy's own failure log records no
rejected connection for it, and support.citrix.com
answered normally, which is where CTX697096 itself was read from.
Item 1 therefore contains no indicators of compromise, and
that absence is a gap in this bulletin, not a finding about
NetScaler. CISA's KEV note states that running Citrix's
IOCs in the NetScaler console may help identify exploitation, so
fetch that blog yourself before concluding an appliance is clean.
Separately, github.com is restricted to this session's
own repository, so GitHub advisory pages could not be opened
directly; the advisory text, ranges, severities and CWEs for items 5,
6, 7, 9 and 10 were read from the OSV mirror of the GitHub Advisory
Database, which carries the advisory bodies verbatim, and the version
and date facts were independently confirmed against the npm registry
and the PyPI JSON API.
On the satori companion, and a correction made during
review. The Next.js advisory references GHSA-wx4j-mvgx-mqwp
for the upstream satori package. That GHSA page is on
github.com, which is restricted here, and the identifier is not in OSV
on its own: a direct lookup returns not found and an OSV query for
pkg:npm/satori returns zero vulnerabilities, both checked
October 2, 2026. An earlier draft of this bulletin concluded from those
two facts that the advisory could not be opened anywhere and gave no
satori floor. That conclusion was wrong, and the review
caught it: the OSV record for GHSA-vcvr-r3jv-pc5j carries a
related identifier, CVE-2026-94545, which both OSV and NVD
serve, which is assigned by GitHub's own CNA, and which carries the
satori range and the sentence "Version 0.33.5 contains a patch". The
one-hop link in a record this bulletin had already read was not
followed. Item 5 now states the satori floor of 0.33.5
and attributes it to CVE-2026-94545 rather than to the unopened GHSA.
The GHSA itself remains unread, so anything in it beyond what the CVE
record carries is not reflected here.
On dates, and on not implying anything is new. Items 5, 6, 7, 9 and 10 each disclose a vulnerability whose fix shipped before this window opened, in one case 54 days before. Each section gives both the advisory date and the release date of the fix, and none of them should be read as a vulnerability introduced in this window. Where the GitHub Advisory Database review date and the NVD publication date differ, both are stated: the review date is what gates GitHub-native scanners, and it is not the publication date of the underlying advisory. The Next.js GHSA has no CVE alias of its own, but its related CVE-2026-94545 is in both OSV and NVD, published 2026-09-30.
On exploitation claims, which are quoted rather than inferred. Item 1 rests on Citrix's sentence that exploits of CVE-2026-88771 and CVE-2026-88772 have been observed, and makes no exploitation claim for the other six CVEs in the same bulletin. Item 2 rests on Fortinet's "reported to be exploited in the wild" and its Known Exploited: Yes metadata. Item 3 rests on DIVD's own account of being breached on September 21, on the E:A in its CVSS v4 vectors, and on CISA adding both CVEs to KEV on October 2; Zammad does not dispute that the chain was used against DIVD, and on the root escalation says only that it cannot be exploited remotely on its own. Item 4 rests on Cisco PSIRT's statement of active exploitation in September 2026. Item 8 rests on Apple's narrower wording, "may have been exploited in an extremely sophisticated attack against specific targeted individuals", which is deliberately not upgraded here into commodity exploitation. Items 5, 6, 7, 9 and 10 carry no exploitation claim because none of their advisories makes one, and that is an absence of statements rather than a statement of absence.
On internal inconsistencies in primary sources, reproduced
rather than resolved. Citrix writes the FIPS and NDcPP fix
as 13.1-37.279 in its affected-versions list and
13.1.37.279 in its remediation list and NVD's CPE
records; these are the same build and both renderings appear above.
DIVD's CVE-2026-102490 page gives a structured affected range of
1.5.0 up to but excluding 7.1.0-alpha while its description says all
versions including the latest alpha, and its case file says patch
status Available and workaround N/A while also saying Zammad is
working on a fix. Larger than any of those, DIVD and Zammad
disagree with each other: DIVD puts the RCE in 6.3.0 to
6.5.4 and says it is present but not exploitable through 7.1.3, while
Zammad says exploitation is only possible on 6.5 and older because of
the runtime those versions use and that 7.0 and later are not
affected; and on the root escalation DIVD says every version from
1.5.0 while Zammad, having now received the details, says it cannot be
exploited remotely on its own and publishes no scope of its own. Item
3 reproduces both and recommends the action safe under either: 7.2.0,
which clears both readings of the RCE and carries Zammad's hardening,
and continuing to treat code execution as the zammad
user as equivalent to root. Citrix's CVSS v4 9.5
for CVE-2026-88772 and NVD's CVSS 3.1 8.1 for the same issue are
both given, as are DIVD's standalone (8.7, 8.5) and chained (9.4)
scores for the Zammad pair.
On the command blocks. Every block was syntax
checked with bash -n and executed against fixtures
representing both an affected and a patched system, including, where
the package exists on a public registry, against real installs of an
affected and a patched version rather than hand-built stand-ins. The
exception is item 8, whose version comparison was tested by feeding it
known version strings behind a stub sw_vers but which was
not run on a Mac, because this bulletin was not built on one.
Nine defects were found and fixed during that verification,
and they are named here because every one of them would have produced
a false all-clear. The Cisco detection first grepped the
advisory's literal %6a example and missed
/j_securit%79_check; then, after that was fixed in the
live passes, the same literal survived in the pass over rotated
.gz archives, which is where vManage keeps the evidence,
and missed it there too. Both passes are now method-agnostic and
position-agnostic, tested against the encoding in three positions and
under POST, OPTIONS and DELETE.
The FortiMail hunt used grep -r, which silently skips
every compressed rotation: against a log store holding only a
.gz, it printed five clean-looking headers on an
implanted appliance. It now uses zgrep, reports how many
files it read, and covers the fifth indicator Fortinet publishes.
Every Node version check read the installed version with
require("pkg/package.json"), which throws on a package
declaring an exports map: confirmed against real
@xhmikosr/decompress and piscina installs,
both of which would have reported an affected project as not
installed. All of them now walk node_modules and read the
manifest from disk. When the satori floor was added to
this item's prose it was not added to the check, so a project on
satori 0.33.4 read as entirely clean; it is now in the
rules and tested against real 0.33.4 and 0.33.5. The PyJWT
allow-list scanner matched only a bracketed literal at the call site
and reported zero on the two commonest real shapes, a tuple and a
named constant; it now covers both, and says so for a list built at
runtime, which no scanner can settle. Item 10's
npm audit parser treated a missing lockfile as a clean
tree, because npm audit emits valid JSON with an
ENOLOCK error and exit 0, which is the normal case for
pnpm and yarn projects. A behavioural test originally written for item
9 was removed rather than shipped: a hand-built archive with one
symlink pointing outside the output directory is rejected identically
by 11.1.3 and 11.1.4, because that case was closed by the earlier
hardening, so it could not distinguish and would have read as
reassurance. Item 10's canary was kept because it does distinguish,
confirmed firing on real piscina 5.3.1 and silent on 5.3.2.
Two blocks are deliberately not verdicts and say so in place: the
ImageResponse call-site listing in item 5 and the
prototype-pollution shapes in item 10 produce a set of lines to read,
because no pattern match can decide whether a value is
attacker-controlled. Port numbers and log paths are vendor defaults
and vary by deployment. The appliance CLI commands in items 1, 2 and 4
are commented out inside the blocks because they are device commands,
not shell ones. The reachability probes in items 1, 2 and 3 are to be
run against your own systems only, and their decision rule is the
connection outcome, not the HTTP status: a 401 or 403 means the
interface answered and is reachable. The canary in item 10 writes
files into the working directory and should be run in a scratch
directory.
What this bulletin did not do. It reproduced none of the published exploits. Item 10's canary exercises your installed copy rather than demonstrating the published exploit, and that advisory's linked third-party reproduction repository was not run. Item 9's symlink chain was not reconstructed at all, which is why that item offers no behavioural test. No FortiMail, NetScaler, vManage or Zammad appliance was available to test against, so every appliance-side command is validated as text against the vendor's own documented configuration syntax and log formats rather than against a live device. The 265 malicious-package figure is a count of OSV MAL advisories published in the window and is not a claim about how many distinct campaigns or maintainer compromises that represents.
On review. The adversarial review required by this
project ran as two independent reviewers in parallel, one on each of
two model families, because the Codex path is unavailable in a cloud
run. Two models from one vendor are two independent reads, not vendor
independence, and that is weaker than the usual arrangement. Both
returned substantive findings and each caught defects the other
missed. The document reviewer found the satori error
recorded above, a wrong KEV count, Apple's hedge upgraded into a
confirmation, and four items whose response steps put remediation
before containment. The verification reviewer, which ran every command
block against real packages and fixtures, found the five detection
defects named in the command-blocks caveat, plus two DIVD table
columns this bulletin had transposed and a missing introduced version
on one vm2 advisory. Every finding was checked against the primary
source before being applied, and none was rejected. The full record,
including the findings and what was done with each, is posted on the
pull request for this bulletin.
Between September 26 and October 2, 2026, five products were confirmed under exploitation: Citrix NetScaler CVE-2026-88771, which needs no feature enabled and so hits every unpatched ADC and Gateway, FortiMail CVE-2026-104286, which still has no released fix, Zammad CVE-2026-102489 chained to CVE-2026-102490, which breached DIVD, reached KEV on October 2, and whose root escalation is still unfixed, Cisco Catalyst SD-WAN Manager CVE-2026-76504, and Apple CoreGraphics CVE-2026-86950 on development workstations, which Apple describes narrowly as a targeted attack. Preserve evidence on any exposed NetScaler and then install 14.1-73.37 or 13.1-64.23; hunt FortiMail for the indicators, then disable its IBE feature and get its webmail interface off the internet, because there is still nothing to upgrade to; run DIVD's log check and move Zammad to 7.2.0 while assuming the zammad user is root; upgrade vManage to the fixed release for your train; and in one dependency pass raise next to 16.3.6 (and satori to 0.33.5 if you use it directly), pyjwt to 2.14.0, vm2 to 3.11.7, piscina and @xhmikosr/decompress to their branch fixes, and replace the unmaintained decompress. The NetScaler indicators of compromise are not in this bulletin because the Citrix blog that holds them was unreachable; fetch them yourself before calling an appliance clean.