At a glance: fixes and indicators
Written for organizations and the teams who defend them. IoC = evidence it already happened (an address, an account, a log line). IoA = the behavior of an attack in progress. Network IoCs are defanged: remove the square brackets before using them. Indicators age fast, so re-check the source before blocking.
| Item | Exploited? · Fix | Do now | Top IoCs | Top IoAs |
|---|---|---|---|---|
| 1. Citrix NetScaler ADC / Gateway zero-days | Yes, per watchTowr · No patch, no CVE | Take management off the internet, preserve logs, name who can approve taking an appliance offline | None published | A shell or unexpected child process under the appliance's web server; config changes nobody made |
| 2. Kyverno CVE-2026-100706 and four more | Not reported · 1.19.1 | Upgrade to 1.19.1; review who can create namespaced policies | None published | MutatingWebhookConfiguration created by a non-admin identity; PolicyException in the kyverno namespace |
| 3. Flowise CVE-2026-100606 / -100607 · Budibase CVE-2026-100684 | Not reported · Flowise: no patch · Budibase 3.45.0 | Flowise: one SSO provider with verified email, local passwords off. Budibase: upgrade | None published | SSO logins that activate pending invitations; one account reached through two identity providers |
| 4. MediaWiki ExternalData CVE-2026-100382 | Not reported · 3.7 | Update the extension; restrict who can edit | None published | The web server's PHP process spawning a shell |
| 5. Lunex stealer | Active campaign · No patch — delivery is social | Block the published indicators; enforce a vulnerable-driver blocklist | 193.178.159[.]128, uasputnik[.]com, task psychedelicloveUtils |
A symbol-file download for the Windows kernel by a non-developer process, then a driver load |
Highest payoff today — analysis. If you run NetScaler, the most valuable thing you can do before Citrix speaks is preserve evidence. Export appliance logs and configuration, and take a disk image or core dump if your support arrangement allows it, before any patch or reboot. When indicators arrive — and for earlier NetScaler campaigns they came days after disclosure — you will want to know whether you were inside the window, and a patched, rebooted appliance may no longer be able to tell you.
How to use this. A published-IoC hit means compromise: handle it under that item's defined response. An IoA hit alone is a lead to validate (PIVCCERL™'s Validation stage). An empty IoC row is not reassurance; for four of these five items nobody has published victim-side artifacts yet.
1. Two NetScaler zero-days, exploited before any fix existed
watchTowr reported on 26 September two unpatched remote code execution flaws in Citrix NetScaler ADC and NetScaler Gateway, exploited in the wild and found during forensic investigations. No CVE, no affected-build list, no Citrix bulletin and no indicators exist yet; watchTowr expects Citrix fixes early in the week of 28 September. Do now: confirm no NetScaler management interface faces the internet, preserve logs and configuration, and decide in advance who can approve taking an appliance offline if the bulletin says so. Skip if: you do not run NetScaler ADC or Gateway, on-premises or as a virtual appliance. If your remote access is a different vendor, this one does not apply to you.
Indicators
| Type | Indicator | Where to look | Source |
|---|---|---|---|
| IoC | None published. No vendor, national CERT or researcher had published victim-side artifacts for these flaws as of 27 September | — | The Hacker News, 27 Sep 2026, relaying watchTowr |
| IoA | A shell, scripting interpreter or unexpected child process started by the appliance's web or management services | Appliance shell history, process listings, forensic image | Ours |
| IoA | Files in the appliance's web-served directories that do not match a known-good build | Appliance filesystem, disk image | Ours — the pattern of earlier NetScaler campaigns |
| IoA | Configuration changes, new local accounts or new SSH keys nobody made; outbound connections from the appliance to hosts outside its normal set | ns.conf history, audit logs, flow logs |
Ours |
| IoA | Gateway sessions reused from a new address or country mid-session | Gateway session and authentication logs | Ours |
What
watchTowr's posts on 26 September, as carried by The Hacker News on 27 September, describe "two vulnerabilities, both remote code execution, both unpatched, exploited before any fix existed, discovered during forensic investigations." watchTowr also said: "While details are scarce, the information is credible." The Hacker News reports that Citrix "had published nothing about the new flaws as of Sunday morning", that "with no bulletin, there is no vendor workaround", and that it is not known whether the August builds 14.1-73.32 and 13.1-63.21, or newer, are affected. Cyber Kendra, reporting on 26 September, relays watchTowr's statement that the new issue is a different vulnerability from CVE-2026-19490, and an expectation of Citrix fixes early the following week.
For context, CVE-2026-19490 — the August NetScaler flaw — is an authentication bypass (CWE-288), CVSS 4.0 9.3, published 19 August 2026, whose CISA assessment rates exploitation active. CISA's fields: added to the Known Exploited Vulnerabilities catalog 9 September, due 12 September. Citrix's own compromise-response article, CTX694799, states: "The NetScaler Management Services should never be exposed to the public internet."
The sources are not yet in agreement on certainty. Nothing here comes from Citrix. We plan on the watchTowr reporting — it rests on forensic work, not scanning, and edge-appliance zero-days have consistently been confirmed after this kind of early warning — but treat versions, scope and attack surface as unknown until the bulletin lands.
So what
A gateway sits in front of everything else: code execution there means session tokens, the credentials of every user who authenticates through it, and a foothold with a line of sight into the internal network. "Exploited before any fix existed" means patching when the fix arrives will not tell you whether you were already hit — the same lesson as every NetScaler wave since 2019. The trap is treating "we patched in August" as closure; this is a different flaw.
Now what — P → I (Preparation, moving to Identification once indicators or a bulletin arrive; analysis)
- Configuration. Confirm no management address (NSIP) or management service is reachable from the internet; restrict it to an administrative network. Where the Gateway's public surface is not needed over the weekend, reduce it — the decision to take an appliance offline belongs to someone who owns the business consequence, so name that person now rather than at 2 a.m. Watch Citrix's bulletin page and apply the fix the day it ships.
- Blast radius. Assume anything the appliance holds is at stake if it was compromised: LDAP or RADIUS bind credentials, certificates and private keys, and active session tokens. Prepare the rotation list now — a rotation you have scripted in advance takes an hour instead of a week. Check that the appliance cannot initiate connections to your internal estate beyond what it proxies.
- Detection with a defined response. Export and retain appliance logs and configuration now, and take a disk image or core dump if your support arrangement allows. The Dutch NCSC's published NetScaler check scripts, built for earlier campaigns, are a reasonable first look — a clean result is not a clearance. Defined response: a web shell, an unexpected process, or a configuration change nobody made is a confirmed compromise. Isolate the appliance, preserve the image before anything else, kill all active sessions, rotate every credential and key it held, and rebuild from a clean image rather than cleaning in place.
- Communication. Tell whoever owns remote access for staff and third parties now that a mid-week outage is possible, before the bulletin forces the conversation. Reporting routes vary by country — CISA (US), the Canadian Centre for Cyber Security, the UK's NCSC, Australia's ACSC.
Hunt pack — run it, then act on what it says
As of the 27 September 2026 publication of this item, no indicator of compromise had been published for these flaws, so these hunts implement only the behaviors this item lists as indicators of attack: configuration changes, new accounts or keys nobody made, files in web-served directories that do not match a known-good build, outbound connections to hosts outside the appliance's normal set, and gateway sessions reused from a new address or country. Those patterns are our analysis, not a vendor-published method. The item's first indicator (a shell or unexpected child process under the appliance's web service) needs shell access or a forensic image and is not coded here. The bash script was run against synthetic exports (clean, hit, compressed, empty and unreadable inputs); the Splunk and KQL queries were reviewed, not executed against a live platform, so test them on a non-production index first. We are not offering a Sigma, Suricata, YARA, osquery or PowerShell hunt for this item: there is no published string, hash or address to key a rule to, and a rule written from a guess would be counted as a control it is not. As of 27 September, affected builds were unknown, so there is no version check here; see the 28 September follow-up and Citrix's bulletin for affected builds, and check the appliance against them. Field names vary by connector, so adjust them to your logging.
#!/usr/bin/env bash
# Usage: ./ns-hunt.sh conf BASELINE_CONF CURRENT_CONF (plain or .gz exports)
# ./ns-hunt.sh files KNOWN_GOOD_MANIFEST DIRECTORY (manifest = sha256sum format)
# conf prints lines that are added or changed in CURRENT (lines deleted since the baseline are not reported).
# Read-only. Exit: 0 nothing found | 1 found | 2 usage | 3 nothing readable (NOT a clean result)
set -euo pipefail
usage() { echo "usage: $0 conf BASELINE CURRENT | files MANIFEST DIR" >&2; exit 2; }
[ $# -eq 3 ] || usage
case "$1" in conf|files) ;; *) usage ;; esac
python3 - "$@" <<'PY'
import sys, gzip, hashlib, os, re
mode, a, b = sys.argv[1:4]
def read_lines(p):
with open(p, 'rb') as f:
gz = f.read(2) == b'\x1f\x8b'
op = gzip.open if gz else open
with op(p, 'rt', errors='replace') as f:
return [l.rstrip('\n') for l in f]
try:
if mode == 'conf':
base = [l.strip() for l in read_lines(a) if l.strip()]
cur = [l.strip() for l in read_lines(b) if l.strip()]
if not base or not cur:
print(f"baseline_lines={len(base)} current_lines={len(cur)} - empty input, NOT a clean result")
sys.exit(3)
known = set(base)
added = [l for l in cur if l not in known]
for l in added:
# secret values are redacted first, then the line is truncated; still review before sharing output
l = re.sub(r'(-\w*(?:pass|pwd|key|kek|secret|community|token|cred)\w*\s+)(?:"[^"]*"|\S+)', r'\1<redacted>', l, flags=re.I)
print("+ " + l[:80])
print(f"baseline_lines={len(base)} current_lines={len(cur)} added={len(added)}")
sys.exit(1 if added else 0)
else:
known = set()
for l in read_lines(a):
m = re.match(r'^([0-9a-fA-F]{64})\s', l)
if m:
known.add(m.group(1).lower())
if not known:
print("manifest has no sha256 entries - NOT a clean result")
sys.exit(3)
scanned = unknown = errors = 0
walk_errs = [] # directories that could not be listed count as unreadable, not as clean
for root, _, names in os.walk(b, followlinks=False, onerror=walk_errs.append):
for n in names:
p = os.path.join(root, n)
if os.path.islink(p) or not os.path.isfile(p):
continue # symlinks and special files are skipped
try:
h = hashlib.sha256()
with open(p, 'rb') as f:
for chunk in iter(lambda: f.read(1 << 20), b''):
h.update(chunk)
except OSError:
errors += 1
continue
scanned += 1
if h.hexdigest() not in known:
unknown += 1
print(f"NOT-IN-MANIFEST {p} {h.hexdigest()}")
errors += len(walk_errs)
print(f"known_hashes={len(known)} scanned={scanned} not_in_manifest={unknown} unreadable={errors}")
if unknown:
sys.exit(1)
sys.exit(3 if (errors or scanned == 0) else 0)
except (OSError, EOFError) as e:
print(f"cannot read input: {e.__class__.__name__} - NOT a clean result")
sys.exit(3)
PY
index=<your_gateway_index> sourcetype=<your_gateway_session_sourcetype> earliest=-30d
| eval user=coalesce(user, username), src=coalesce(src_ip, src, client_ip), sid=coalesce(session_id, sessionid)
| where isnotnull(user) AND isnotnull(sid) AND isnotnull(src)
| iplocation src
| stats dc(src) AS addresses, dc(Country) AS countries, values(src) AS addresses_seen, values(Country) AS countries_seen, min(_time) AS first_seen, max(_time) AS last_seen BY user, sid
| where addresses > 1 OR countries > 1
| convert ctime(first_seen) ctime(last_seen)
| sort - countries - addresses
let appliances = dynamic(["<appliance-address-1>", "<appliance-address-2>"]);
let normal = CommonSecurityLog
| where TimeGenerated between (ago(37d) .. ago(7d))
| where SourceIP in (appliances)
| distinct DestinationIP;
CommonSecurityLog
| where TimeGenerated > ago(7d)
| where SourceIP in (appliances)
| where DestinationIP !in (normal)
| summarize connections = count(), ports = make_set(DestinationPort, 10), first_seen = min(TimeGenerated), last_seen = max(TimeGenerated) by SourceIP, DestinationIP
| order by connections desc
| If you see | Do this |
|---|---|
The conf mode prints + lines for a new local account, a new SSH key, or any change nobody made; or the files mode prints NOT-IN-MANIFEST for a file in a web-served directory that the running build's own manifest does not explain |
Treat it as the item's Defined response: a web shell, an unexpected process, or a configuration change nobody made is a confirmed compromise. Isolate the appliance, preserve the image before anything else, kill all active sessions, rotate every credential and key it held (bind credentials, certificates and private keys, session tokens), and rebuild from a clean image rather than cleaning in place. Before you call a files hit, confirm the manifest was built from the same build that is running |
| A gateway session that changes address or country mid-session (SPL), or the appliance connecting to a destination outside its normal set (KQL) | Not a confirmation by itself. Ask the user or the network owner. If nobody can explain it, end that session, reset the user's credentials, preserve appliance logs and configuration, and run the conf and files checks above before deciding on isolation |
| A session or destination you can explain: a user roaming between mobile and office networks, a new monitoring or update destination the network owner approved | Expected. Record the explanation and add the destination to the normal set so it does not hide a later one |
Exit code 3, scanned=0, an empty export, or a query that returns nothing because the logs were never ingested |
Not a clean result. Fix the export or the log source and run it again. Export and retain appliance logs and configuration now, because evidence you did not keep cannot be hunted later |
| No hits | A good sign, not an exoneration. As of the item's 27 September publication no indicator of compromise had been published for these flaws, so only the behaviors listed in the item were searched. Appliance logs rotate and a baseline taken after a compromise would treat the change as normal. A web shell can sit in a place the manifest or your copy did not cover, and an attacker who got in through a route you did not export logs for leaves nothing here. Keep the preserved logs and configuration, and re-run once you have Citrix's bulletin and any published indicators (see the 28 September follow-up) |
2. Kyverno: a namespace tenant can climb to cluster admin
Kyverno, the Kubernetes policy engine, has five flaws published 26 September and fixed in 1.19.1. The worst, CVE-2026-100706 (CVSS 3.1 9.9), lets a tenant who can create namespaced policies use percent-encoded path traversal to act as Kyverno's own service account and reach cluster admin. Do now: upgrade to 1.19.1. Skip if: you do not run Kyverno, or you are a single-team cluster where only cluster administrators can write Kyverno policies — then the risk is smaller, but still upgrade.
Indicators
| Type | Indicator | Where to look | Source |
|---|---|---|---|
| IoC | None published. No exploitation has been reported | — | CVE records; Kyverno advisory GHSA-5qq8-67g6-4h2w |
| IoA | apiCall entries in namespaced policies whose URL path targets cluster-scoped resources, or contains %2e%2e |
Kubernetes audit log, policy manifests in Git | Kyverno advisory (mechanism and monitoring); pattern is ours |
| IoA | A MutatingWebhookConfiguration created by a non-admin service account |
Kubernetes audit log | Kyverno advisory GHSA-5qq8-67g6-4h2w |
| IoA | PolicyException objects created in the kyverno namespace by tenant workloads |
Kubernetes audit log | Kyverno advisory GHSA-5qq8-67g6-4h2w |
| IoA | Kyverno making outbound requests to cloud metadata endpoints or internal services | Egress and flow logs for the Kyverno namespace | Ours — derived from CVE-2026-100705 |
What
All five CVE records were published 26 September 2026 and name 1.19.1 as the fix; Kyverno's advisory for the lead flaw was published 10 September and lists no workaround. CVE-2026-100706 — "Privilege Escalation via Policy apiCall urlPath", CVSS 3.1 9.9 / 4.0 9.4, CWE-441, all versions before 1.19.1. Per the advisory, the path is cleaned for validation but the raw URL is executed, so %2e%2e slips past the namespace restriction; exploitation needs permission to create Kyverno policies in a namespace. CVE-2026-100705 — server-side request forgery through the legacy apiCall executor, which lacks the egress block applied to the newer CEL library; CVSS 3.1 7.6 / 4.0 8.3. CVE-2026-100703 (cross-namespace data access via globalcontext library), CVE-2026-100704 (image-verification exception bypass) and CVE-2026-100707 (namespace isolation bypass via percent-encoded path) are each rated CVSS 3.1 7.7 / 4.0 8.3.
So what
Multi-tenant clusters rely on the admission controller being the one thing tenants cannot bend. When the policy engine itself is the confused deputy, every namespace boundary built on it is conditional. The image-verification bypass matters even in single-team clusters: a signature check that quietly stops checking is worse than none, because it is counted as a control.
Now what — P (Preparation; analysis)
- Configuration. Upgrade to 1.19.1. Until then, limit who can create or edit namespaced Kyverno policies — many clusters grant it by default to anyone with namespace edit rights.
- Blast radius. Review what Kyverno's service accounts can do and whether the admission controller really needs cluster-wide create rights for everything it holds. Block its egress to cloud metadata endpoints with a network policy.
- Detection with a defined response. Alert on the audit patterns above. Defined response: a
MutatingWebhookConfigurationor aPolicyExceptionin thekyvernonamespace that no administrator created is a confirmed escalation — delete it, rotate service-account tokens and any secrets readable in affected namespaces, and review what pods were admitted while it existed.
Hunt pack — run it, then act on what it says
These hunts implement the monitoring patterns in Kyverno's advisory (a MutatingWebhookConfiguration created by a non-admin service account, a PolicyException created in the kyverno namespace) and one pattern we derived from the advisory's mechanism (a %2e%2e path in a policy apiCall), plus the fixed-release check for 1.19.1. They are our analysis of those patterns. No indicator of compromise has been published and no exploitation has been reported. The two bash scripts were run on synthetic data: the audit-log script against JSON-lines files (clean, hit, compressed, empty and non-JSON inputs), and the cluster script against a stand-in kubectl that returned synthetic JSON, so the real kubectl calls and the Kyverno resource names were not exercised against a live cluster. The two Sigma rules were parsed and converted to Splunk, Kusto and Lucene queries with pySigma; the Splunk and KQL queries were reviewed, not executed against a live platform, so test them on a non-production system first. Audit queries use only the standard Kubernetes audit event fields (verb, objectRef.resource, objectRef.namespace, user.username, requestURI, responseStatus.code); a %2e%2e string sits inside the policy body, so it appears in audit data only if your audit policy records request bodies. We are not offering a Suricata, YARA, osquery or PowerShell hunt: the traffic is inside the cluster and no network or file string is published. We also do not offer a query for the metadata-endpoint egress pattern, because no endpoint list is published; use your flow logs for the Kyverno namespace. Kyverno's own webhook configurations always print REVIEW in the cluster script, and its own service account (system:serviceaccount:kyverno:...) will hit in the audit script unless you add it to EXPECTED_USERS. The cluster script also lists policyexceptions.policies.kyverno.io when that resource exists (that resource name is unverified; the legacy kyverno.io/v2 PolicyException is deprecated in 1.19 and removed in 1.20), does not cover Kyverno's newer CEL policy kinds, does not scan initContainers, and reads a tag such as 1.19.1-rc1 as 1.19.1. The audit script counts only ResponseComplete events, so one request is not counted twice when your audit policy also logs other stages. The dotted field names in the Splunk rename commands and the plural resource names kubectl uses for the newer PolicyException kind were not tested against a live system.
#!/usr/bin/env bash
# Usage: [EXPECTED_USERS='^system:serviceaccount:ops:|^admin@example$'] ./kyverno-audit-hunt.sh audit.log* (JSON lines, plain or .gz)
# Fields used are the standard Kubernetes audit event fields: verb, objectRef.resource, objectRef.namespace,
# user.username, requestURI, responseStatus.code, stage. Read-only.
# Only ResponseComplete events are counted (events with any other stage are skipped, so one request is not counted twice).
# Kyverno's own service account (system:serviceaccount:kyverno:...) will hit unless EXPECTED_USERS matches it.
# Exit: 0 scanned, nothing unexpected | 1 found | 2 usage | 3 nothing readable or parsed, or any input unreadable and no hit (NOT a clean result)
set -euo pipefail
[ $# -ge 1 ] || { echo "usage: $0 AUDITLOG..." >&2; exit 2; }
bad=$(mktemp); trap 'rm -f "$bad"' EXIT
rc=0
{ for f in "$@"; do zcat -f -- "$f" 2>/dev/null || { echo "unreadable input: $f" >&2; echo x >>"$bad"; }; done; } | EXPECTED_USERS="${EXPECTED_USERS:-}" python3 -c '
import sys, os, re, json
sys.stdin.reconfigure(errors="replace")
exp = re.compile(os.environ["EXPECTED_USERS"]) if os.environ["EXPECTED_USERS"] else None
lines = parsed = counted = skipped = hits = expected = 0
for n, raw in enumerate(sys.stdin, 1):
if not raw.strip():
continue
lines += 1
try:
e = json.loads(raw)
except ValueError:
continue
parsed += 1
if e.get("stage") not in (None, "ResponseComplete"):
skipped += 1
continue
counted += 1
verb = e.get("verb", "")
res = (e.get("objectRef") or {}).get("resource", "")
ns = (e.get("objectRef") or {}).get("namespace", "")
user = (e.get("user") or {}).get("username", "")
code = (e.get("responseStatus") or {}).get("code", "")
rules = []
if verb == "create" and res == "mutatingwebhookconfigurations":
rules.append("mutatingwebhookconfiguration-created")
if verb == "create" and res == "policyexceptions" and ns == "kyverno":
rules.append("policyexception-created-in-kyverno-ns")
if verb in ("create", "update", "patch") and re.search(r"%2e%2e", raw, re.I):
rules.append("percent-encoded-dotdot-in-a-write")
for r in rules:
ok = bool(exp and exp.search(user)) and r != "percent-encoded-dotdot-in-a-write"
if ok:
expected += 1
else:
hits += 1
tag = "EXPECTED" if ok else "HIT"
print(f"{n}: {tag} {r} user={user[:80]} verb={verb} ns={ns} code={code}")
print(f"events={lines} parsed={parsed} counted={counted} skipped_other_stage={skipped} hits={hits} expected={expected}", file=sys.stderr)
sys.exit(3 if counted == 0 else (1 if hits else 0))
' || rc=$?
if [ -s "$bad" ]; then
echo "unreadable input - NOT a clean result" >&2
[ "$rc" -eq 1 ] || rc=3
fi
exit "$rc"
#!/usr/bin/env bash
# Usage: [NS=kyverno] ./kyverno-cluster-hunt.sh (uses your current kubectl context; read-only "get" calls only)
# Checks: (1) Kyverno image tags older than the fixed release, (2) namespaced Kyverno policies containing %2e%2e,
# (3) lists MutatingWebhookConfigurations and PolicyExceptions in the Kyverno namespace for a human to review.
# Resource names (policies.kyverno.io, policyexceptions.kyverno.io) assume the standard Kyverno CRDs - adjust if yours differ.
# policyexceptions.kyverno.io is the legacy kyverno.io/v2 kind (deprecated in 1.19, removed in 1.20). policyexceptions.policies.kyverno.io
# is also listed if that CRD exists; that resource name is unverified. Newer CEL policy kinds are not covered. initContainers are not scanned.
# An image tag such as 1.19.1-rc1 is read as 1.19.1. Kyverno's own webhook configurations always print REVIEW.
# Exit: 0 nothing found | 1 found | 3 could not read the cluster, or could not tell an image version (digest, latest, 1.19 only) (NOT a clean result)
set -uo pipefail
NS="${NS:-kyverno}"
command -v kubectl >/dev/null 2>&1 || { echo "kubectl not found - NOT a clean result" >&2; exit 3; }
dep=$(kubectl get deploy -n "$NS" -o json 2>/dev/null) || { echo "cannot read deployments in $NS - NOT a clean result" >&2; exit 3; }
pol=$(kubectl get policies.kyverno.io -A -o json 2>/dev/null) || { echo "cannot read policies - NOT a clean result" >&2; exit 3; }
mwc=$(kubectl get mutatingwebhookconfigurations -o json 2>/dev/null) || { echo "cannot read webhook configurations - NOT a clean result" >&2; exit 3; }
pex=""
if kubectl get crd policyexceptions.kyverno.io >/dev/null 2>&1; then
pex=$(kubectl get policyexceptions.kyverno.io -n "$NS" -o json 2>/dev/null) || { echo "cannot read policy exceptions in $NS - NOT a clean result" >&2; exit 3; }
else
echo "policyexceptions.kyverno.io not found or not readable - not checked (removed in Kyverno 1.20)" >&2
fi
pex2=""
if kubectl get crd policyexceptions.policies.kyverno.io >/dev/null 2>&1; then
pex2=$(kubectl get policyexceptions.policies.kyverno.io -n "$NS" -o json 2>/dev/null) || { echo "cannot read policyexceptions.policies.kyverno.io in $NS - NOT a clean result" >&2; exit 3; }
else
echo "policyexceptions.policies.kyverno.io not found or not readable - not checked" >&2
fi
DEP="$dep" POL="$pol" MWC="$mwc" PEX="$pex" PEX2="$pex2" python3 -c '
import os, re, json, sys
hits = review = images = unknown = 0
def load(k):
try:
return json.loads(os.environ[k])
except ValueError:
print("cannot parse kubectl output for " + k + " - NOT a clean result")
sys.exit(3)
d = load("DEP")
for it in d.get("items", []):
for c in it["spec"]["template"]["spec"].get("containers", []):
img = c.get("image", "")
images += 1
m = re.search(r":v?(\d+)\.(\d+)\.(\d+)", img)
if m and tuple(int(x) for x in m.groups()) < (1, 19, 1):
hits += 1
nm = it["metadata"]["name"]
print(f"HIT image older than the fixed release: {nm} {img}")
elif not m:
review += 1
unknown += 1
print(f"REVIEW image tag not a plain version: {img}")
print(f"images={images}")
p = load("POL")
for it in p.get("items", []):
if re.search(r"%2e%2e", json.dumps(it), re.I):
hits += 1
print("HIT percent-encoded dot-dot in policy: " + it["metadata"].get("namespace", "") + "/" + it["metadata"]["name"])
print("namespaced_policies=" + str(len(p.get("items", []))))
for it in load("MWC").get("items", []):
review += 1
print("REVIEW MutatingWebhookConfiguration: " + it["metadata"]["name"] + " created " + it["metadata"].get("creationTimestamp", "?"))
for env in ("PEX", "PEX2"):
if not os.environ.get(env):
continue
for it in load(env).get("items", []):
review += 1
print("REVIEW PolicyException in namespace: " + it["metadata"]["name"] + " created " + it["metadata"].get("creationTimestamp", "?"))
print(f"hits={hits} review={review}")
sys.exit(1 if hits else (3 if (images == 0 or unknown) else 0))
'
title: MutatingWebhookConfiguration created
id: e41cc0b3-1589-45ea-a7da-749566626a54
status: experimental
description: A MutatingWebhookConfiguration was created. Kyverno's advisory lists creation by a non-admin service account as a sign of the privilege escalation in CVE-2026-100706. Filter your own administrators and GitOps accounts.
references:
- https://github.com/kyverno/kyverno/security/advisories/GHSA-5qq8-67g6-4h2w
author: Threat Vector Management
date: 2026-10-06
logsource:
product: kubernetes
service: audit
detection:
selection:
verb: create
objectRef.resource: mutatingwebhookconfigurations
condition: selection
falsepositives:
- Cluster administrators, GitOps controllers and operators that install webhooks; exclude them by user.username
level: medium
---
title: Kyverno PolicyException created in the kyverno namespace
id: bee3d606-7258-4bdf-b1eb-97a095890978
status: experimental
description: A PolicyException was created in the kyverno namespace. Kyverno's advisory lists creation by tenant workloads as a sign of the privilege escalation in CVE-2026-100706. Filter your own administrators.
references:
- https://github.com/kyverno/kyverno/security/advisories/GHSA-5qq8-67g6-4h2w
author: Threat Vector Management
date: 2026-10-06
logsource:
product: kubernetes
service: audit
detection:
selection:
verb: create
objectRef.resource: policyexceptions
objectRef.namespace: kyverno
condition: selection
falsepositives:
- Cluster administrators who maintain exceptions on purpose; exclude them by user.username
level: medium
index=<your_k8s_audit_index> sourcetype=<your_k8s_audit_sourcetype> earliest=-30d
( (verb=create AND objectRef.resource=mutatingwebhookconfigurations)
OR (verb=create AND objectRef.resource=policyexceptions AND objectRef.namespace=kyverno) )
| rename objectRef.resource AS resource, objectRef.namespace AS ns, user.username AS actor, responseStatus.code AS code
| table _time actor verb resource ns requestURI code
| sort - _time
index=<your_k8s_audit_index> sourcetype=<your_k8s_audit_sourcetype> earliest=-30d (verb=create OR verb=update OR verb=patch)
| regex _raw="(?i)%2e%2e"
| rename objectRef.resource AS resource, objectRef.namespace AS ns, user.username AS actor, responseStatus.code AS code
| table _time actor verb resource ns requestURI code
K8sAudit_CL
| where TimeGenerated > ago(30d)
| extend e = parse_json(Event)
| extend verb = tostring(e.verb), resource = tostring(e.objectRef.resource), ns = tostring(e.objectRef.namespace), actor = tostring(e.user.username), code = toint(e.responseStatus.code)
| where (verb == "create" and (resource == "mutatingwebhookconfigurations" or (resource == "policyexceptions" and ns == "kyverno")))
or (verb in ("create", "update", "patch") and tostring(Event) matches regex @"(?i)%2e%2e")
| project TimeGenerated, actor, verb, resource, ns, code
| order by TimeGenerated desc
| If you see | Do this |
|---|---|
A MutatingWebhookConfiguration or a PolicyException in the kyverno namespace that no administrator created (audit hit with a 2xx response code and an unexpected user, or the cluster script's REVIEW line you cannot explain) |
This is the item's Defined response: a confirmed escalation. Save a copy for evidence, delete the object, rotate service-account tokens and any secrets readable in the affected namespaces, and review which pods were admitted while it existed. Then upgrade to 1.19.1 if you have not |
%2e%2e in a write (the audit hunts match any resource; check the resource field) or in a namespaced policy found by the cluster script |
If it is a Kyverno policy: save a copy for evidence first, then remove it, find out who wrote it, and upgrade to 1.19.1. Then check for the two objects in the row above, because the attempt may have worked |
| The cluster script prints an image older than 1.19.1 | Upgrade to 1.19.1. Until then, limit who can create or edit namespaced Kyverno policies, since many clusters grant that to anyone with namespace edit rights. Then run the audit hunts back over the period the old version ran |
| A create by your own administrator, GitOps controller or an operator you installed on purpose | Expected. Add the account to EXPECTED_USERS or to the rule's filter so it does not bury a later unexpected one |
| Requests with a 403 response code, or a policy that was rejected | Attempt only. Find the owner of the account, and tighten who can create namespaced policies |
Exit code 3, parsed=0, or cannot read messages |
Not a clean result. Check that audit logging is on and shipped, that your account can read the resources, and run it again |
| No hits | A good sign, not an exoneration. Only the published patterns were searched. Audit logs rotate, and an audit policy that does not record request bodies cannot show the %2e%2e string at all. An object that was created and then deleted leaves only its audit entries, and a tenant who reached admin through a route we did not search leaves no entry here. The other four CVEs in the item have no indicator published, so they are covered only by the version check (and, for CVE-2026-100705, the egress indicator of attack that we do not code) |
3. Flowise and Budibase: single sign-on that trusts an email address
Three flaws published 26 September share one mistake: the application trusts the email address an identity provider asserts and matches it to an existing account or a pending invitation. Flowise through 3.1.4 (CVE-2026-100606, CVE-2026-100607) has no patched release. Budibase 3.41.0 up to 3.45.0 (CVE-2026-100684) is fixed in 3.45.0. Do now: Flowise — one SSO provider with verified email, local passwords off; Budibase — upgrade. Skip if: you run neither, or run them without SSO. The lesson applies to any self-hosted application that offers "Sign in with…".
Indicators
| Type | Indicator | Where to look | Source |
|---|---|---|---|
| IoC | None published. No exploitation has been reported | — | CVE records; GitHub advisories GHSA-cffm-583c-vffr, GHSA-vf3j-89vf-r697, GHSA-35ch-57g2-3g98 |
| IoA | A pending invitation activated through an SSO login rather than the emailed invitation link | Application user and audit logs | Ours — derived from the CVE records |
| IoA | One user account signed into through two different identity providers, or through SSO after being created with a local password | Application authentication logs, identity provider logs | Flowise advisory (attack scenarios); pattern is ours |
| IoA | New API keys, credentials or exported flows on an account shortly after its first SSO login | Flowise or Budibase audit data | Ours |
What
Flowise. CVE-2026-100607, "Authentication Bypass via Email-Only SSO": through 3.1.4, Flowise lacks provider and subject binding, so an attacker who can claim a victim's email at any configured SSO provider gains the account — including chatflows, stored credentials and API keys. CVE-2026-100606, "Authentication Bypass via SSO Email Match": an SSO callback matching a pending invitee's email activates the invitation without the emailed token, for up to 24 hours. Both CVSS 4.0 9.2 / 3.1 7.7, published 26 September; the underlying advisories, GHSA-cffm-583c-vffr and GHSA-vf3j-89vf-r697 (both 10 September), list no patched version. The first advisory's mitigations: use a single SSO provider with strict email verification, disable local-password authentication when SSO is enabled, restrict providers to verified domain ownership, audit accounts, and rotate sensitive credentials and API keys.
Budibase. CVE-2026-100684, "Budibase Server 3.41.0 before 3.45.0 Authentication Bypass via OIDC": an attacker registered at a trusted identity provider asserts a victim's invited email and claims the pending invite — including administrator invites — without the invite code or email verification. CVSS 4.0 9.2 / 3.1 8.1; fixed in 3.45.0; published 26 September.
So what
An email address is a contact detail, not an identity. An identity provider can vouch for "this person controls this login at this provider"; whether the address is verified depends on the provider and its settings, and some let anyone type any address. Flowise matters more than its size suggests because it is where teams wire AI agents to internal data — an account there often holds the API keys for everything else.
Now what — P (Preparation; analysis)
- Configuration. Flowise: apply the advisory's mitigations now, and put the instance behind a VPN or access broker if it does not need to face the internet. Budibase: upgrade to 3.45.0. Both: cancel stale pending invitations — each is an open door until it expires.
- Blast radius. Assume a compromised Flowise account exposes every credential stored in its flows. Scope those credentials to the minimum each flow needs and plan their rotation.
- Detection with a defined response. Review accounts for the patterns above. Defined response: an account reached through an identity provider its owner does not use is a confirmed takeover — disable it, rotate every credential and API key it could read, and review what it changed or exported.
Hunt pack — run it, then act on what it says
No indicator of compromise has been published and no exploitation has been reported, so these hunts check the published version ranges (Flowise through 3.1.4; Budibase 3.41.0 up to, not including, 3.45.0) and look for the three behaviors this item lists as indicators of attack: a pending invitation activated through an SSO login, one account signed in through two identity providers, and new API keys, credentials or exports soon after an account's first SSO login in the query window. Those patterns are our analysis, derived from the CVE records and the Flowise advisory. Both bash scripts were run against synthetic data (version boundaries, and clean, hit, compressed, custom-field, empty and unusable authentication logs); the Splunk and KQL queries were reviewed, not executed against a live platform, so test them on a non-production system first. We are not offering a Sigma, Suricata, YARA, osquery or PowerShell hunt: neither application has a standard log schema, so a portable rule would rest on invented field names, and no network or file string is published. Field names vary by application, version and log pipeline, so every query below uses placeholders you must rename. The first indicator (an invitation claimed through SSO) is not searched at all, because we cannot name the event that records it without guessing; if your application logs invitation acceptance, search for acceptances with no preceding emailed-link event.
#!/usr/bin/env bash
# Usage: ./sso-version-check.sh flowise|budibase VERSION e.g. ./sso-version-check.sh budibase 3.44.0
# Published ranges: Flowise through 3.1.4 (no patched release listed); Budibase 3.41.0 up to, not including, 3.45.0.
# Read-only; takes the version you read from your own deployment. Exit: 0 outside the published range | 1 inside it | 2 usage | 3 version unreadable
set -euo pipefail
[ $# -eq 2 ] || { echo "usage: $0 flowise|budibase VERSION" >&2; exit 2; }
case "$1" in flowise|budibase) ;; *) echo "usage: $0 flowise|budibase VERSION" >&2; exit 2 ;; esac
python3 - "$1" "$2" <<'PY'
import sys, re
prod, ver = sys.argv[1], sys.argv[2]
m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", ver.strip())
if not m:
print(f"cannot parse version '{ver[:40]}' - NOT a clean result")
sys.exit(3)
v = tuple(int(x) for x in m.groups())
if prod == "flowise":
inside = v <= (3, 1, 4)
else:
inside = (3, 41, 0) <= v < (3, 45, 0)
print(f"product={prod} version={'.'.join(map(str, v))} inside_published_range={'yes' if inside else 'no'}")
if prod == "flowise":
print("note: no fixed Flowise release is published, so 'no' is not proof the instance is safe")
sys.exit(1 if inside else 0)
PY
#!/usr/bin/env bash
# Usage: [USER_FIELD=user] [IDP_FIELD=provider] ./sso-idp-hunt.sh authlog* (JSON lines, plain or .gz)
# Lists accounts that signed in through more than one identity provider value. Field names vary by application and
# log pipeline: set USER_FIELD and IDP_FIELD to the names in YOUR logs (dotted paths such as actor.email are accepted).
# If your logs record password logins with a provider value such as "local", those accounts surface here too.
# Read-only. Exit: 0 scanned, none found | 1 found | 2 usage | 3 no usable events or any input unreadable and no hit (NOT a clean result)
set -euo pipefail
[ $# -ge 1 ] || { echo "usage: $0 AUTHLOG..." >&2; exit 2; }
bad=$(mktemp); trap 'rm -f "$bad"' EXIT
rc=0
{ for f in "$@"; do zcat -f -- "$f" 2>/dev/null || { echo "unreadable input: $f" >&2; echo x >>"$bad"; }; done; } | USER_FIELD="${USER_FIELD:-user}" IDP_FIELD="${IDP_FIELD:-provider}" python3 -c '
import sys, os, json
sys.stdin.reconfigure(errors="replace")
def get(d, path):
for k in path.split("."):
if not isinstance(d, dict) or k not in d:
return None
d = d[k]
return d
uf, pf = os.environ["USER_FIELD"], os.environ["IDP_FIELD"]
seen = {}
lines = usable = 0
for raw in sys.stdin:
if not raw.strip():
continue
lines += 1
try:
e = json.loads(raw)
except ValueError:
continue
u, p = get(e, uf), get(e, pf)
if u is None or p is None:
continue
usable += 1
seen.setdefault(str(u)[:80], set()).add(str(p)[:40])
multi = {u: ps for u, ps in seen.items() if len(ps) > 1}
for u, ps in sorted(multi.items()):
print(u + " -> " + ", ".join(sorted(ps)))
print(f"events={lines} usable={usable} accounts={len(seen)} multi_provider={len(multi)}", file=sys.stderr)
sys.exit(3 if usable == 0 else (1 if multi else 0))
' || rc=$?
if [ -s "$bad" ]; then
echo "unreadable input - NOT a clean result" >&2
[ "$rc" -eq 1 ] || rc=3
fi
exit "$rc"
index=<your_app_auth_index> sourcetype=<your_app_auth_sourcetype> earliest=-30d
| eval user=coalesce(user, actor, email), provider=coalesce(provider, idp, auth_provider)
| where isnotnull(user) AND isnotnull(provider)
| stats dc(provider) AS providers, values(provider) AS providers_seen, min(_time) AS first_seen, max(_time) AS last_seen BY user
| where providers > 1
| convert ctime(first_seen) ctime(last_seen)
index=<your_app_audit_index> sourcetype=<your_app_audit_sourcetype> earliest=-30d
| eval user=coalesce(user, actor, email), action=coalesce(action, event_type)
| eval kind=case(match(action,"(?i)sso|oidc|saml|oauth"), "sso_login", match(action,"(?i)api.?key|credential|export"), "sensitive")
| where isnotnull(kind) AND isnotnull(user)
| eval sso_time=if(kind="sso_login", _time, null())
| eventstats min(sso_time) AS first_sso BY user
| where kind="sensitive" AND _time >= first_sso AND _time <= first_sso + 3600
| table _time user action first_sso
| convert ctime(first_sso)
// Query 1: accounts seen through more than one provider (run each query separately)
AppAudit_CL
| where TimeGenerated > ago(30d)
| where isnotempty(User) and isnotempty(Provider)
| summarize providers = dcount(Provider), providers_seen = make_set(Provider, 10), first_seen = min(TimeGenerated), last_seen = max(TimeGenerated) by User
| where providers > 1
// Query 2: a key, credential or export within an hour of the account's first SSO login in the window
let firsts = AppAudit_CL
| where TimeGenerated > ago(30d)
| where Action matches regex @"(?i)sso|oidc|saml|oauth"
| summarize first_sso = min(TimeGenerated) by User;
AppAudit_CL
| where TimeGenerated > ago(30d)
| where Action matches regex @"(?i)api.?key|credential|export"
| join kind=inner firsts on User
| where TimeGenerated between (first_sso .. (first_sso + 1h))
| project TimeGenerated, User, Action, first_sso
| If you see | Do this |
|---|---|
| The version check exits 1 | Flowise: apply the advisory's mitigations now (one SSO provider with strict email verification, local-password authentication off while SSO is on, providers restricted to verified domain ownership), audit accounts, rotate API keys, and put the instance behind a VPN or access broker if it does not need to face the internet. Budibase: upgrade to 3.45.0. For both, cancel stale pending invitations, since each is an open door until it expires |
| An account reached through an identity provider its owner does not use | This is the item's Defined response: a confirmed takeover. Disable the account, rotate every credential and API key it could read, and review what it changed or exported |
| A pending invitation that was activated by an SSO login rather than the emailed link | Ask the invitee. If they did not do it, treat it as the takeover in the row above. Either way, cancel any invitation still pending |
| An account that created an API key, credential or export soon after its first SSO login in the window | A lead (ours, not the item's Defined response). Ask the owner; if they cannot explain it, handle it as the first row above |
| An account that appears under two providers because its owner moved from a local password to SSO on purpose, or an administrator migrated it | Expected. Note it, and check the account again after you turn local passwords off |
Exit code 3, usable=0, an empty result from a query, or a version string the script could not read |
Not a clean result. Check the field names, the log source and the time range and run it again. A log that does not record the provider cannot answer this hunt |
| No hits | A good sign, not an exoneration. A successful email-match login looks like an ordinary SSO login, so the flaw itself may leave no unusual line. Application logs rotate and often do not record the provider or how an invitation was claimed. Only the published patterns were searched, and Flowise has no patched release, so a clean result today says nothing about next week until the mitigations are in. Rotate credentials stored in Flowise flows regardless |
Email is not an identity — the controls that hold
Item 3 is a pattern, not two bugs; it recurs wherever an application links sign-in by email address. These controls work whether or not a patch exists. Do now: check the first row on every self-hosted application with SSO. Skip if: every application you run federates through one identity provider that enforces verified email — then only the last two rows apply.
| Control | Works before a patch? | Do this |
|---|---|---|
| Bind accounts to provider and subject, not email | Only if the application supports it | Prefer applications that key users on the identity provider's subject identifier; ask vendors directly |
| One identity provider per application, with verified email enforced | Yes | Remove extra "Sign in with…" options nobody uses; confirm the provider rejects unverified addresses |
| Turn off local passwords once SSO is on | Yes | One way in is one thing to secure; a local password alongside SSO is a second, weaker door |
| Short-lived invitations, cancelled when stale | Yes | Expire invitations within a day and review the pending list weekly |
| Keep self-hosted builders off the open internet | Yes | Put AI-agent and app-builder platforms behind a VPN or access broker |
| Store credentials outside the application where possible | Yes — limits what a takeover yields | Use a secrets manager reference instead of pasting API keys into flows |
4. MediaWiki ExternalData: command injection through wikitext
CVE-2026-100382 is an unauthenticated OS command injection through wikitext in the ExternalData extension for MediaWiki before 3.7, rated CVSS 4.0 10.0. Do now: update the extension to 3.7 or later, or disable it until you can. Skip if: you run no MediaWiki, or your MediaWiki does not have ExternalData installed.
Indicators
| Type | Indicator | Where to look | Source |
|---|---|---|---|
| IoC | None published. No exploitation has been reported | — | CVE record; Wikimedia Phabricator T434961 |
| IoA | The web server's PHP process spawning a shell or system utilities | EDR, process accounting | Ours |
| IoA | Page edits, especially anonymous or from new accounts, that add or change ExternalData parser-function calls | MediaWiki recent changes, edit logs | Ours — derived from the CVE record's "through wikitext" |
What
The CVE record, "Unauthenticated remote code execution through wikitext in ExternalData": improper neutralization of special elements in OS commands allows command injection in the ExternalData extension before 3.7. CVSS 4.0 10.0, CWE-78, published 25 September 2026 at 21:04 UTC. The CISA assessment in the record: exploitation none, automatable yes, technical impact total. References: Wikimedia Phabricator T434961 and the fix in Wikimedia's code review.
So what
"Through wikitext" means the attack surface is anyone who can save a page — on a wiki that allows anonymous editing or open registration, that is anyone. Internal wikis are often left unpatched for years precisely because they are internal; this is a reason to check which extensions yours carries.
Now what — P (Preparation; analysis)
- Configuration. Update ExternalData to 3.7 or later. Restrict editing to authenticated accounts, and consider whether the extension is needed at all.
- Blast radius. Run the web server under an account that cannot read database credentials beyond MediaWiki's own, and keep internal wikis off the internet.
- Detection with a defined response. Watch for the patterns above. Defined response: a shell spawned by the web server is a confirmed compromise — isolate the host, rotate the database and any API credentials in
LocalSettings.php, and restore from a backup predating the change.
Hunt pack — run it, then act on what it says
No indicator of compromise has been published and no exploitation has been reported, so these hunts check the published fix (ExternalData 3.7 or later) and look for the two behaviors this item lists as indicators of attack: the web server's PHP process spawning a shell or system utilities, and page edits that add or change ExternalData calls. The parent and child process names are our analysis; the page does not name them, so tune the lists. The two bash scripts were run against synthetic data (an install tree with old, fixed, absent and unreadable versions, and clean, hit, compressed, empty and missing text exports); the two Sigma rules were parsed and converted to Splunk, Kusto and Lucene queries with pySigma; the osquery statement was run in osqueryi on a Mac with a positive-control process pair to confirm it parses and joins, not on Linux or Windows hosts; the KQL query was reviewed, not executed against a live platform. Test everything on a non-production system first. The extension check assumes the standard MediaWiki layout and an extension.json file with a version, so adjust it if your install differs. We are not offering a Suricata or YARA hunt: no request pattern or file string is published, and the page does not say which parser function carries the injection. Splunk is not offered separately: the Sigma rules convert to it.
#!/usr/bin/env bash
# Usage: ./mw-ext-check.sh /path/to/mediawiki (the directory that contains LocalSettings.php and extensions/)
# Looks for an ExternalData extension directory and reads "version" from its extension.json. This assumes the standard
# MediaWiki layout; if your install keeps extensions elsewhere, point the script at that parent directory instead.
# Published range: ExternalData before 3.7. Read-only.
# Exit: 0 extension absent or at/after the fixed release | 1 present and older | 2 usage | 3 could not tell (NOT a clean result)
set -euo pipefail
[ $# -eq 1 ] || { echo "usage: $0 MEDIAWIKI_DIR" >&2; exit 2; }
root="$1"
[ -d "$root/extensions" ] || { echo "no extensions/ directory under $root - NOT a clean result" >&2; exit 3; }
python3 - "$root" <<'PY'
import sys, os, json, re
root = sys.argv[1]
found = old = unknown = 0
ext = os.path.join(root, "extensions")
try:
names = sorted(os.listdir(ext))
except OSError as e:
print(f"cannot list extensions/ ({e.__class__.__name__}) - NOT a clean result")
sys.exit(3)
for n in names:
if n.lower() != "externaldata":
continue
found += 1
try:
d = json.load(open(os.path.join(ext, n, "extension.json")))
v = d.get("version", "") if isinstance(d, dict) else ""
except (OSError, ValueError):
v = ""
m = re.match(r"^v?(\d+)\.(\d+)", str(v))
if not m:
unknown += 1
print(f"{n}: version not readable from extension.json - check it by hand")
elif (int(m.group(1)), int(m.group(2))) < (3, 7):
old += 1
print(f"{n}: version {v} is before 3.7 - update or disable")
else:
print(f"{n}: version {v} is at or after 3.7")
ls = os.path.join(root, "LocalSettings.php")
mentions = 0
if os.path.isfile(ls):
try:
mentions = sum(1 for l in open(ls, errors="replace") if "ExternalData" in l and not l.lstrip().startswith(("#", "//")))
except OSError as e:
print(f"cannot read LocalSettings.php ({e.__class__.__name__}) - NOT a clean result")
sys.exit(3)
print(f"extension_dirs_scanned={len(names)} externaldata_found={found} older_than_fixed={old} unreadable={unknown} localsettings_mentions={mentions}")
if old:
sys.exit(1)
if unknown or (found == 0 and mentions > 0):
sys.exit(3)
sys.exit(0)
PY
#!/usr/bin/env bash
# Usage: ./mw-text-hunt.sh export.xml revisions.txt ... (any text export of page content or edit history; plain or .gz)
# Prints lines mentioning ExternalData (case-insensitive). The page does not name the extension's parser functions;
# add the function names from the extension's own documentation to PATTERN if you want a narrower match.
# A hit is a pointer for a human to read the edit and its author - documentation pages also match.
# Prints up to 200 characters of each matching line; review before sharing output.
# Read-only. Exit: 0 scanned, no match | 1 match | 2 usage | 3 nothing readable or any input unreadable and no match (NOT a clean result)
set -euo pipefail
[ $# -ge 1 ] || { echo "usage: $0 EXPORT..." >&2; exit 2; }
PATTERN='ExternalData'
bad=$(mktemp); trap 'rm -f "$bad"' EXIT
rc=0
{ for f in "$@"; do zcat -f -- "$f" 2>/dev/null || { echo "unreadable input: $f" >&2; echo x >>"$bad"; }; done; } | PATTERN="$PATTERN" python3 -c '
import sys, os, re
sys.stdin.reconfigure(errors="replace")
pat = re.compile(os.environ["PATTERN"], re.I)
lines = hits = 0
for n, raw in enumerate(sys.stdin, 1):
lines += 1
if pat.search(raw):
hits += 1
print(f"{n}: {raw.rstrip()[:200]}")
print(f"scanned={lines} hits={hits}", file=sys.stderr)
sys.exit(3 if lines == 0 else (1 if hits else 0))
' || rc=$?
if [ -s "$bad" ]; then
echo "unreadable input - NOT a clean result" >&2
[ "$rc" -eq 1 ] || rc=3
fi
exit "$rc"
title: Web server PHP process spawning a shell or system utility (Linux)
id: 40c93cf1-a2f0-429f-9b26-1c617dcd2698
status: experimental
description: A PHP or web server process started a shell or a basic system utility. The ExternalData command injection (CVE-2026-100382) would show as the web server's PHP process spawning a shell or system utilities. The process lists are our analysis.
references:
- https://phabricator.wikimedia.org/T434961
author: Threat Vector Management
date: 2026-10-06
logsource:
category: process_creation
product: linux
detection:
selection_parent:
ParentImage|endswith:
- '/php'
- '/php-cgi'
- '/php-fpm'
- '/php7.4'
- '/php8.0'
- '/php8.1'
- '/php8.2'
- '/php8.3'
- '/php8.4'
- '/php-fpm7.4'
- '/php-fpm8.0'
- '/php-fpm8.1'
- '/php-fpm8.2'
- '/php-fpm8.3'
- '/php-fpm8.4'
- '/apache2'
- '/httpd'
selection_child:
Image|endswith:
- '/sh'
- '/bash'
- '/dash'
- '/whoami'
- '/id'
- '/uname'
- '/curl'
- '/wget'
condition: selection_parent and selection_child
falsepositives:
- MediaWiki itself commonly shells out routinely (image thumbnailing, diff3, PDF and math rendering, maintenance scripts) through /bin/sh or bash; baseline your wiki's normal child commands first
- Scheduled jobs that run PHP scripts from cron
level: medium
---
title: PHP or IIS worker process spawns a shell
id: 46dabafb-a85d-451b-9262-7211d637f6de
status: experimental
description: A PHP or IIS worker process started a shell or a basic system utility. The ExternalData command injection (CVE-2026-100382) would show as the web server's PHP process spawning a shell or system utilities. The process lists are our analysis.
references:
- https://phabricator.wikimedia.org/T434961
author: Threat Vector Management
date: 2026-10-06
logsource:
category: process_creation
product: windows
detection:
selection_parent:
ParentImage|endswith:
- '\php-cgi.exe'
- '\php.exe'
- '\w3wp.exe'
selection_child:
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
- '\pwsh.exe'
- '\whoami.exe'
condition: selection_parent and selection_child
falsepositives:
- MediaWiki itself commonly shells out routinely (image thumbnailing, diff3, PDF and math rendering, maintenance scripts); baseline your wiki's normal child commands first
- Administrative scripts run through IIS
level: medium
DeviceProcessEvents
| where Timestamp > ago(30d)
| where InitiatingProcessFileName startswith "php" or InitiatingProcessFileName in~ ("apache2", "httpd", "w3wp.exe")
| where FileName in~ ("sh", "bash", "dash", "whoami", "id", "uname", "curl", "wget", "cmd.exe", "powershell.exe", "pwsh.exe", "whoami.exe")
| project Timestamp, DeviceName, InitiatingProcessFileName, InitiatingProcessCommandLine, FileName, ProcessCommandLine, AccountName
| order by Timestamp desc
SELECT p.pid, p.name, p.path, p.cmdline, pp.pid AS parent_pid, pp.name AS parent_name, pp.cmdline AS parent_cmdline
FROM processes p JOIN processes pp ON p.parent = pp.pid
WHERE (pp.name LIKE 'php%' OR pp.name IN ('apache2', 'httpd', 'w3wp.exe'))
AND p.name IN ('sh', 'bash', 'dash', 'whoami', 'id', 'uname', 'curl', 'wget', 'cmd.exe', 'powershell.exe', 'pwsh.exe', 'whoami.exe');
| If you see | Do this |
|---|---|
| A shell (sh, bash, dash, cmd, powershell) spawned by the web server's PHP process, in the Sigma, KQL or osquery results, that no maintenance job explains | This is the item's Defined response: a confirmed compromise. Isolate the host, rotate the database and any API credentials in LocalSettings.php, and restore from a backup that predates the change |
| A utility such as whoami, id, uname, curl or wget spawned by the web server's PHP process, that no maintenance job explains | A lead (ours; the item's Defined response names a shell). Validate it, and if it stays unexplained treat it as the row above |
| The extension check exits 1 | Update ExternalData to 3.7 or later, or disable it until you can. Restrict editing to authenticated accounts, and then run the process hunts at least back to 25 September, when the CVE was published |
| The text hunt prints an edit that adds or changes an ExternalData call, especially from an anonymous or new account | Read the edit and its author. If it came from an anonymous or new account and you did not expect it, treat it as an attempt: revert the page, restrict editing, update the extension, and check the process hunts for the same time window. If it is a known editor or a documentation page, it is expected |
| A shell spawned by a wiki feature or a scheduled maintenance script you can name | Expected. Add the exact parent and command line to a filter so it does not hide a later one |
Exit code 3, scanned=0, a missing extensions/ directory, or a query that returns nothing because the host has no process telemetry |
Not a clean result. Point the script at the right directory or export and run it again, and confirm the host actually sends process events |
| No hits | A good sign, not an exoneration. Only the two published behaviors were searched, and the process lists are ours. Process telemetry rotates, and the osquery snapshot sees only what is running now. An injected command that does not start a shell or one of the listed utilities will not match, and the text search finds only edits that contain the literal word ExternalData. Check the extension version either way, because no exploitation has been reported yet and that can change |
5. Lunex stealer: endpoint protection left running, but blind
Ontinue documented on 24 September a new information stealer, Lunex, delivered through fake CAPTCHA pages that trick users into running an installer. It loads a vulnerable AMD driver to zero out kernel security callbacks, leaving endpoint protection running but unable to see, then steals browser credentials, cookies and cryptocurrency wallets. Do now: block the published indicators and confirm a vulnerable-driver blocklist is enforced. Skip if: no Windows endpoints — though the fake-CAPTCHA delivery is cross-platform.
Indicators
| Type | Indicator | Where to look | Source |
|---|---|---|---|
| IoC | 193.178.159[.]128 — exfiltration API (port 8080) and operator panel (port 8000); 107.175.82[.]242 port 9000 — payload delivery |
Egress firewall, proxy and flow logs | Ontinue, 24 Sep 2026 |
| IoC | uasputnik[.]com — delivery domain, serving elita.msi |
DNS resolver logs, proxy logs | Ontinue, 24 Sep 2026 |
| IoC | SHA-256 38e90affe37342ee36917cdc535fe9bf04589afa8430eb8d1ba1016adcfc1878 (elita.msi), bf14cd6c3328ebd08e940478b5d1da04e9e5aa576d045d41950bf4f1e2456dd8 (loader), 06f434695f93d7fd11eeff71358ff69fed79d310a66d993bbcc4ff979c117c90 (stealer) |
EDR, file hash telemetry | Ontinue, 24 Sep 2026 |
| IoC | SHA-256 6e8b49cf70bf854e8c59c7d27cefa89406caf8978461190dabb86dafcd8554e1 — PDFWKRNL.sys, the vulnerable AMD driver (CVE-2023-20598) |
Driver load events | Ontinue, 24 Sep 2026 |
| IoC | Scheduled task psychedelicloveUtils (logon trigger); registry key com.lunex.explorer under HKCU\Software\Google\Chrome\NativeMessagingHosts and the Edge equivalent |
EDR, registry telemetry | Ontinue, 24 Sep 2026 |
| IoC | Lookalike phishing domains account-sams-club[.]com, teamwork-recover-password[.]com, namshi-uae[.]com, whatsappbusineses[.]com, ibraq-perfumes[.]com, and api-goo-drivehosting[.]com; the full list, including more than two dozen panel addresses, is in Ontinue's published file |
DNS resolver logs, proxy logs | Ontinue IoC list, 2026 |
| IoA | A non-developer process downloading Windows kernel symbol files, then a driver load from a temporary directory | EDR process, network and driver telemetry | Ontinue, 24 Sep 2026 |
| IoA | Browser processes killed by taskkill followed by access to browser credential databases |
EDR | Ontinue, 24 Sep 2026 |
| IoA | COM elevation through CMSTPLUA, CLSID 3E5FC7F9-9A51-4367-9063-A120244FBEC7 |
EDR | Ontinue, 24 Sep 2026 |
What
Ontinue (researcher Rhys Downing): Lunex arrives as a bogus MSI installer delivered through fake CAPTCHA (ClickFix-style) pages, from attacker-registered infrastructure. It loads AMD's PDFWKRNL.sys, vulnerable under CVE-2023-20598, and uses Microsoft's own symbol files to locate and zero kernel callbacks. In Ontinue's words, as carried by The Hacker News, this "represents a quieter approach to EDR neutralisation that leaves security products running but blind." It targets seven Chromium-based browsers, five desktop and four browser-extension cryptocurrency wallets, cookies and history. Ontinue describes phishing aimed at Ukrainian-speaking users and further panels impersonating Western and Middle Eastern brands. The Hacker News reported the research on 26 September. We do not list Microsoft's symbol server as an indicator — it is legitimate infrastructure being misused.
So what
An EDR console showing every agent healthy is exactly what this technique produces. The fix is not better detection on the endpoint after the driver loads; it is refusing the driver in the first place. And stolen browser cookies are session tokens: they bypass multi-factor authentication for whatever the user was signed into, including business applications.
Now what — P → I (Preparation, moving to Identification if an indicator appears in historical logs; analysis)
- Configuration. Enforce Microsoft's vulnerable driver blocklist or an application-control policy that denies known-vulnerable drivers, and confirm it covers
PDFWKRNL.sysby hash. Block the published addresses and domains. - Blast radius. Assume any account used in the browser of an infected machine is taken, including sessions protected by multi-factor authentication. Keep browser password storage out of use for business accounts where a managed password manager exists.
- Detection with a defined response. Hunt the indicators and behaviors above back to mid-September. Defined response: the scheduled task, the
com.lunex.explorerregistry key or a load of this driver on a machine with no AMD software to explain it is a confirmed compromise. Isolate and reimage — do not trust the machine's own EDR to have seen it — then revoke sessions and rotate credentials for every account used in its browsers. - Communication. One sentence prevents most of this: no legitimate website asks you to run anything to prove you are human.
Hunt pack — run it, then act on what it says
The indicators below are Ontinue's, published 24 September; the queries are our analysis of how to find them. The bash script was run against synthetic logs (clean, near-miss, hit, compressed, empty and missing inputs). The Sigma rules were parsed and converted to Splunk, Kusto and Lucene queries with pySigma. The PowerShell, Splunk, KQL, Elastic ES|QL, osquery and Suricata hunts were reviewed, not executed against a live platform or a Windows host, so test them on a non-production system first. Defanged values in the code (a dot wrapped in square brackets) must have the brackets removed before use, except in the bash script, which does it for you (and which matches domains and addresses as whole names, so lookalikes such as notuasputnik.com do not match), and in the ES|QL regular expression, where the bracketed dot already matches a literal dot. Two limits: the lookalike domains listed here are the six the item names, not the more than two dozen panel addresses in Ontinue's published file, so load that file into your own blocklist and searches; and the behavioral indicators (symbol-file downloads by a non-developer process, browsers killed by taskkill, the CMSTPLUA COM elevation) are not coded here because they need a baseline of your own environment to avoid noise. A name match on PDFWKRNL.sys alone can be a legitimate AMD driver, and the scripts that print log lines (the bash script prints up to 160 characters of each) can echo URLs or user names, so review output before sharing it. We are not offering a YARA rule: no byte strings are published, only hashes, which the other hunts already match.
#!/usr/bin/env bash
# Usage: ./lunex-hunt.sh LOGFILE... (proxy, DNS, firewall/flow or EDR text exports; plain or .gz)
# Searches for the indicators Ontinue published on 24 Sep 2026. Values are stored defanged and refanged at run time.
# Domains and addresses are matched as whole names (notuasputnik.com, uasputnik.com.evil.test and an address followed by a dot and a letter do not match; subdomains do).
# Prints up to 160 characters of each matching log line; review before sharing output. A PDFWKRNL.sys name match can be a legitimate AMD driver.
# Read-only. Exit: 0 scanned, nothing found | 1 found | 2 usage | 3 nothing readable or any input unreadable and no hit (NOT a clean result)
set -euo pipefail
[ $# -ge 1 ] || { echo "usage: $0 LOGFILE..." >&2; exit 2; }
bad=$(mktemp); trap 'rm -f "$bad"' EXIT
rc=0
{ for f in "$@"; do zcat -f -- "$f" 2>/dev/null || { echo "unreadable input: $f" >&2; echo x >>"$bad"; }; done; } | python3 -c '
import sys, re
sys.stdin.reconfigure(errors="replace")
def refang(s): return s.replace("[.]", ".")
ips = ["193.178.159[.]128", "107.175.82[.]242"]
domains = ["uasputnik[.]com", "account-sams-club[.]com", "teamwork-recover-password[.]com", "namshi-uae[.]com",
"whatsappbusineses[.]com", "ibraq-perfumes[.]com", "api-goo-drivehosting[.]com"]
hashes = {"38e90affe37342ee36917cdc535fe9bf04589afa8430eb8d1ba1016adcfc1878": "elita.msi",
"bf14cd6c3328ebd08e940478b5d1da04e9e5aa576d045d41950bf4f1e2456dd8": "loader",
"06f434695f93d7fd11eeff71358ff69fed79d310a66d993bbcc4ff979c117c90": "stealer",
"6e8b49cf70bf854e8c59c7d27cefa89406caf8978461190dabb86dafcd8554e1": "PDFWKRNL.sys"}
names = ["elita.msi", "psychedelicloveUtils", "com.lunex.explorer", "PDFWKRNL.sys"]
checks = []
for v in ips:
checks.append(("ip", v, re.compile(r"(?<![\d.])" + re.escape(refang(v)) + r"(?!\d|\.\d|\.[A-Za-z])")))
for v in domains:
checks.append(("domain", v, re.compile(r"(?<![A-Za-z0-9_-])" + re.escape(refang(v)) + r"(?![A-Za-z0-9_-]|\.[A-Za-z0-9])", re.I)))
for h, label in hashes.items():
checks.append(("sha256:" + label, h, re.compile(h, re.I)))
for v in names:
checks.append(("name", v, re.compile(re.escape(v), re.I)))
lines = hits = 0
for n, raw in enumerate(sys.stdin, 1):
lines += 1
for kind, shown, rx in checks:
if rx.search(raw):
hits += 1
print(f"{n}: {kind} {shown} :: {raw.rstrip()[:160]}")
print(f"scanned={lines} hits={hits}", file=sys.stderr)
sys.exit(3 if lines == 0 else (1 if hits else 0))
' || rc=$?
if [ -s "$bad" ]; then
echo "unreadable input - NOT a clean result" >&2
[ "$rc" -eq 1 ] || rc=3
fi
exit "$rc"
# Usage: .\lunex-hunt.ps1 [-SearchPath 'C:\Windows\System32\drivers','C:\Users']
# Read-only host check for the Lunex artifacts Ontinue published on 24 Sep 2026. Run elevated to see every profile.
# Recursion is limited to -Depth 6 under the paths you give it. Loaded user hives only: a signed-out user's hive is not checked.
# Folders that could not be read (access denied) make a no-hit run exit 3. Not executed here (no pwsh available).
# Exit: 0 nothing found | 1 found | 3 could not look or folders unreadable (NOT a clean result)
param([string[]]$SearchPath = @("$env:SystemRoot\System32\drivers", "$env:SystemDrive\Users"))
$ErrorActionPreference = 'Stop'
$publishedSha = @{
'38e90affe37342ee36917cdc535fe9bf04589afa8430eb8d1ba1016adcfc1878' = 'elita.msi'
'bf14cd6c3328ebd08e940478b5d1da04e9e5aa576d045d41950bf4f1e2456dd8' = 'loader'
'06f434695f93d7fd11eeff71358ff69fed79d310a66d993bbcc4ff979c117c90' = 'stealer'
'6e8b49cf70bf854e8c59c7d27cefa89406caf8978461190dabb86dafcd8554e1' = 'PDFWKRNL.sys'
}
$hits = 0; $review = 0; $hives = 0; $files = 0; $gcErr = @()
try {
# 1. scheduled task
if (Get-ScheduledTask -TaskName 'psychedelicloveUtils' -ErrorAction SilentlyContinue) {
$hits++; Write-Host 'HIT scheduled task psychedelicloveUtils exists'
}
# 2. native messaging host registry key (Chrome and Edge), every loaded user hive
$subs = @('Software\Google\Chrome\NativeMessagingHosts\com.lunex.explorer',
'Software\Microsoft\Edge\NativeMessagingHosts\com.lunex.explorer')
foreach ($h in Get-ChildItem 'Registry::HKEY_USERS') {
$hives++
foreach ($s in $subs) {
if (Test-Path -LiteralPath ("Registry::{0}\{1}" -f $h.Name, $s)) { $hits++; Write-Host ("HIT registry key {0}\{1}" -f $h.Name, $s) }
}
}
# 3. files by name, then by published hash
$scannedPaths = 0
foreach ($p in $SearchPath) {
if (-not (Test-Path -LiteralPath $p)) { continue }
$scannedPaths++
foreach ($n in 'PDFWKRNL.sys', 'elita.msi') {
Get-ChildItem -LiteralPath $p -Filter $n -Recurse -Depth 6 -File -Force -ErrorAction SilentlyContinue -ErrorVariable +gcErr | ForEach-Object {
$files++
$sha = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLower()
if ($publishedSha.ContainsKey($sha)) { $hits++; Write-Host ("HIT published hash ({0}): {1}" -f $publishedSha[$sha], $_.FullName) }
else { $review++; Write-Host ("REVIEW name matches, hash does not: {0}" -f $_.FullName) }
}
}
}
$unreadable = @($gcErr | Where-Object { $_.Exception -is [System.UnauthorizedAccessException] -or $_.CategoryInfo.Category -eq 'PermissionDenied' }).Count
} catch {
Write-Host ("hunt failed: {0} - this is NOT a clean result" -f $_.Exception.Message); exit 3
}
Write-Host ("hives_checked={0} search_paths_found={1} files_named={2} hits={3} review={4} unreadable_folders={5}" -f $hives, $scannedPaths, $files, $hits, $review, $unreadable)
if ($hives -eq 0 -or $scannedPaths -eq 0) { Write-Host 'nothing was scanned - NOT a clean result'; exit 3 }
if ($hits -gt 0) { exit 1 }
if ($unreadable -gt 0) { Write-Host 'some folders could not be read - NOT a clean result'; exit 3 }
exit 0
title: Lunex stealer delivery or lookalike domain queried
id: f228e842-33ea-434b-85d7-1b846589dce1
status: experimental
description: A DNS query for a domain Ontinue published as Lunex delivery infrastructure or as a lookalike phishing domain. The full list is in Ontinue's published file.
references:
- https://www.ontinue.com/resource/lunex-unmasked-a-new-information-stealer-deployed-through-byovd/
author: Threat Vector Management
date: 2026-10-06
logsource:
category: dns_query
product: windows
detection:
selection_sub:
QueryName|endswith:
- '.uasputnik[.]com'
- '.account-sams-club[.]com'
- '.teamwork-recover-password[.]com'
- '.namshi-uae[.]com'
- '.whatsappbusineses[.]com'
- '.ibraq-perfumes[.]com'
- '.api-goo-drivehosting[.]com'
selection_exact:
QueryName:
- 'uasputnik[.]com'
- 'account-sams-club[.]com'
- 'teamwork-recover-password[.]com'
- 'namshi-uae[.]com'
- 'whatsappbusineses[.]com'
- 'ibraq-perfumes[.]com'
- 'api-goo-drivehosting[.]com'
condition: selection_sub or selection_exact
falsepositives:
- Security staff or researchers resolving the domains deliberately
level: high
---
title: Lunex stealer infrastructure address contacted
id: f550fbec-36d8-48ce-800f-30efcc43d969
status: experimental
description: An outbound connection to an address Ontinue published as Lunex exfiltration, operator panel or payload delivery infrastructure.
references:
- https://www.ontinue.com/resource/lunex-unmasked-a-new-information-stealer-deployed-through-byovd/
author: Threat Vector Management
date: 2026-10-06
logsource:
category: network_connection
product: windows
detection:
selection:
DestinationIp:
- '193.178.159[.]128'
- '107.175.82[.]242'
condition: selection
falsepositives:
- Security staff or researchers testing the address deliberately
level: high
---
title: Lunex vulnerable AMD driver loaded by published hash
id: 1cf0b732-431b-42bd-b2fa-a4ef0b1870a5
status: experimental
description: The AMD PDFWKRNL.sys driver (CVE-2023-20598) with the SHA-256 Ontinue published was loaded. A load on a machine with no AMD software to explain it is the item's confirmed-compromise condition.
references:
- https://www.ontinue.com/resource/lunex-unmasked-a-new-information-stealer-deployed-through-byovd/
author: Threat Vector Management
date: 2026-10-06
logsource:
category: driver_load
product: windows
detection:
selection:
Hashes|contains: '6e8b49cf70bf854e8c59c7d27cefa89406caf8978461190dabb86dafcd8554e1'
condition: selection
falsepositives:
- A legitimate AMD installation that carries the same driver build; check for AMD software on the machine
level: medium
---
title: Driver loaded from a temporary directory
id: 293223b7-eb93-4cc3-be48-b2482793f97f
status: experimental
description: A kernel driver was loaded from a temporary directory, which Ontinue describes as part of the Lunex sequence. Expect some noise from installers, so treat it as a lead.
references:
- https://www.ontinue.com/resource/lunex-unmasked-a-new-information-stealer-deployed-through-byovd/
author: Threat Vector Management
date: 2026-10-06
logsource:
category: driver_load
product: windows
detection:
selection:
ImageLoaded|contains: '\Temp\'
ImageLoaded|endswith: '.sys'
condition: selection
falsepositives:
- Software installers and hardware utilities that unpack drivers into a temporary directory
level: medium
---
title: Lunex native messaging host registry key
id: 40e55a4e-835b-46e7-b2f2-6ff74cd066f7
status: experimental
description: The registry key com.lunex.explorer was written under the Chrome or Edge NativeMessagingHosts path, as Ontinue published. This rule watches value writes (registry_set); a key created with no value written would not fire, so a registry_add rule or the host hunts above cover that.
references:
- https://www.ontinue.com/resource/lunex-unmasked-a-new-information-stealer-deployed-through-byovd/
author: Threat Vector Management
date: 2026-10-06
logsource:
category: registry_set
product: windows
detection:
selection:
TargetObject|contains: 'NativeMessagingHosts\com.lunex.explorer'
condition: selection
falsepositives:
- None expected
level: high
---
title: Lunex scheduled task or loader and stealer hash executed
id: cb650fba-ab2a-422a-ada1-f668cd4c0d44
status: experimental
description: A command line naming the scheduled task psychedelicloveUtils, or a process whose hash matches the loader or stealer SHA-256 Ontinue published.
references:
- https://www.ontinue.com/resource/lunex-unmasked-a-new-information-stealer-deployed-through-byovd/
author: Threat Vector Management
date: 2026-10-06
logsource:
category: process_creation
product: windows
detection:
selection_task:
CommandLine|contains: 'psychedelicloveUtils'
selection_hash:
Hashes|contains:
- 'bf14cd6c3328ebd08e940478b5d1da04e9e5aa576d045d41950bf4f1e2456dd8'
- '06f434695f93d7fd11eeff71358ff69fed79d310a66d993bbcc4ff979c117c90'
condition: selection_task or selection_hash
falsepositives:
- Researchers handling the samples
level: high
index=<your_dns_proxy_firewall_edr_indexes> earliest=-30d
("193.178.159[.]128" OR "107.175.82[.]242"
OR "uasputnik[.]com" OR "account-sams-club[.]com" OR "teamwork-recover-password[.]com" OR "namshi-uae[.]com"
OR "whatsappbusineses[.]com" OR "ibraq-perfumes[.]com" OR "api-goo-drivehosting[.]com"
OR "38e90affe37342ee36917cdc535fe9bf04589afa8430eb8d1ba1016adcfc1878"
OR "bf14cd6c3328ebd08e940478b5d1da04e9e5aa576d045d41950bf4f1e2456dd8"
OR "06f434695f93d7fd11eeff71358ff69fed79d310a66d993bbcc4ff979c117c90"
OR "6e8b49cf70bf854e8c59c7d27cefa89406caf8978461190dabb86dafcd8554e1"
OR "psychedelicloveUtils" OR "com.lunex.explorer" OR "elita.msi")
| stats count, min(_time) AS first_seen, max(_time) AS last_seen BY index, sourcetype, host
| convert ctime(first_seen) ctime(last_seen)
| sort - count
let ips = dynamic(["193.178.159[.]128", "107.175.82[.]242"]);
let domains = dynamic(["uasputnik[.]com", "account-sams-club[.]com", "teamwork-recover-password[.]com", "namshi-uae[.]com", "whatsappbusineses[.]com", "ibraq-perfumes[.]com", "api-goo-drivehosting[.]com"]);
let hashes = dynamic(["38e90affe37342ee36917cdc535fe9bf04589afa8430eb8d1ba1016adcfc1878", "bf14cd6c3328ebd08e940478b5d1da04e9e5aa576d045d41950bf4f1e2456dd8", "06f434695f93d7fd11eeff71358ff69fed79d310a66d993bbcc4ff979c117c90", "6e8b49cf70bf854e8c59c7d27cefa89406caf8978461190dabb86dafcd8554e1"]);
union
(DeviceNetworkEvents | where Timestamp > ago(30d) | where RemoteIP in (ips) or RemoteUrl has_any (domains)
| project Timestamp, DeviceName, Kind = "network", Detail = strcat(RemoteIP, " ", RemoteUrl)),
(DeviceFileEvents | where Timestamp > ago(30d) | where SHA256 in (hashes) or FileName in~ ("elita.msi", "PDFWKRNL.sys")
| project Timestamp, DeviceName, Kind = "file", Detail = strcat(FolderPath, " ", SHA256)),
(DeviceImageLoadEvents | where Timestamp > ago(30d) | where SHA256 in (hashes) or FileName =~ "PDFWKRNL.sys"
| project Timestamp, DeviceName, Kind = "image-load", Detail = strcat(FolderPath, " ", SHA256)),
(DeviceRegistryEvents | where Timestamp > ago(30d) | where RegistryKey has "com.lunex.explorer"
| project Timestamp, DeviceName, Kind = "registry", Detail = RegistryKey),
(DeviceEvents | where Timestamp > ago(30d) | where ActionType == "ScheduledTaskCreated" and AdditionalFields has "psychedelicloveUtils"
| project Timestamp, DeviceName, Kind = "scheduled-task", Detail = tostring(AdditionalFields))
| order by Timestamp desc
FROM logs-*, filebeat-*
| WHERE @timestamp > NOW() - 30 days
| WHERE TO_STRING(destination.ip) IN ("193.178.159[.]128", "107.175.82[.]242")
OR dns.question.name RLIKE "(.*[.])?(uasputnik|account-sams-club|teamwork-recover-password|namshi-uae|whatsappbusineses|ibraq-perfumes|api-goo-drivehosting)[.]com[.]?"
OR url.domain RLIKE "(.*[.])?(uasputnik|account-sams-club|teamwork-recover-password|namshi-uae|whatsappbusineses|ibraq-perfumes|api-goo-drivehosting)[.]com[.]?"
OR file.hash.sha256 IN ("38e90affe37342ee36917cdc535fe9bf04589afa8430eb8d1ba1016adcfc1878", "bf14cd6c3328ebd08e940478b5d1da04e9e5aa576d045d41950bf4f1e2456dd8", "06f434695f93d7fd11eeff71358ff69fed79d310a66d993bbcc4ff979c117c90", "6e8b49cf70bf854e8c59c7d27cefa89406caf8978461190dabb86dafcd8554e1")
OR registry.path LIKE "*com.lunex.explorer*"
OR process.command_line LIKE "*psychedelicloveUtils*"
| KEEP @timestamp, host.name, event.dataset, source.ip, destination.ip, dns.question.name, url.domain, file.path, file.hash.sha256, registry.path
| SORT @timestamp DESC
| LIMIT 200
SELECT name, action, path, enabled FROM scheduled_tasks WHERE name LIKE '%psychedelicloveUtils%';
SELECT path, name, data FROM registry
WHERE path LIKE 'HKEY_USERS\%\Software\Google\Chrome\NativeMessagingHosts\com.lunex.explorer%'
OR path LIKE 'HKEY_USERS\%\Software\Microsoft\Edge\NativeMessagingHosts\com.lunex.explorer%';
SELECT f.path, h.sha256 FROM file f JOIN hash h ON f.path = h.path
WHERE f.path LIKE 'C:\Windows\System32\drivers\%.sys'
AND h.sha256 = '6e8b49cf70bf854e8c59c7d27cefa89406caf8978461190dabb86dafcd8554e1';
SELECT pid, path, remote_address, remote_port FROM process_open_sockets
WHERE remote_address IN ('193.178.159[.]128', '107.175.82[.]242');
alert dns $HOME_NET any -> any any (msg:"Lunex published domain in DNS query"; dns.query; pcre:"/(^|\.)(uasputnik|account-sams-club|teamwork-recover-password|namshi-uae|whatsappbusineses|ibraq-perfumes|api-goo-drivehosting)\.com$/i"; sid:9000001; rev:1;)
alert tls $HOME_NET any -> any any (msg:"Lunex published domain in TLS server name"; tls.sni; pcre:"/(^|\.)(uasputnik|account-sams-club|teamwork-recover-password|namshi-uae|whatsappbusineses|ibraq-perfumes|api-goo-drivehosting)\.com$/i"; sid:9000002; rev:1;)
alert ip $HOME_NET any -> 193.178.159[.]128 any (msg:"Lunex published exfiltration and panel address"; sid:9000003; rev:1;)
alert ip $HOME_NET any -> 107.175.82[.]242 any (msg:"Lunex published payload delivery address"; sid:9000004; rev:1;)
| If you see | Do this |
|---|---|
The scheduled task psychedelicloveUtils, the com.lunex.explorer registry key, or PDFWKRNL.sys with the published hash loaded on a machine with no AMD software to explain it |
This is the item's Defined response: a confirmed compromise. Isolate and reimage the machine, and do not trust its own EDR to have seen it. Then revoke sessions and rotate credentials for every account used in its browsers, including sessions that multi-factor authentication protected |
The loader, stealer or elita.msi hash on a machine, with none of the three artifacts above |
Treat the file as the delivered malware. Check whether it ran (process, task and registry hunts), and if you cannot show that it did not, handle the machine as in the row above |
| A DNS query or connection to a published address or domain, with nothing found on the host | The user reached or tried to reach the infrastructure. Identify the machine and the person, block the address or domain if you have not, check the host for the three artifacts above, and ask the person what page they were on. If the connection was blocked, it is an attempt only (our reading; the item's Defined response names only the task, the registry key and the driver) |
| PDFWKRNL.sys present or loaded with a hash that is not the published one, or the published hash on a machine that has AMD software | PDFWKRNL.sys with a hash that is not the published one: not this item's indicator; check it against your vulnerable-driver blocklist. The published hash on a machine that has AMD software: not a confirmed compromise by itself (the item's condition is no AMD software to explain it), but it is the vulnerable driver, so check the machine for the task and registry key, and confirm your blocklist or application-control policy denies this hash. |
Exit code 3, scanned=0, a registry or task check that could not run, or a query that returns nothing because the telemetry does not exist |
Not a clean result. Fix the export, the permissions or the log source and run it again. A machine whose security software was blinded may also send no telemetry at all |
| No hits | A good sign, not an exoneration. Only the indicators printed in this item were searched, not the full panel list in Ontinue's file or any infrastructure the operators have changed since. Logs and EDR history rotate, and the technique is built to leave security products running but blind, so missing telemetry on a machine is not proof it is clean. The scripts see only what exists now or what you exported, and a stealer that already ran may have left nothing but stolen sessions. Keep the driver blocklist enforced either way |
Sector signal — only what a source actually said
We do not infer sector exposure from products. No source in this window attributed an item to a named sector. Ontinue's Lunex reporting names a language community and impersonated brands, not a targeted industry. Absence of sector-attributable reporting is not absence of risk.
How this brief is put together
Facts are reported as their named source published them; CVE data, versions and catalog dates come from the CVE and NVD records or the projects' own advisories directly. Where sources are not yet in agreement — the NetScaler report has no vendor confirmation — we show it and say which reading we plan on; figures nobody has published, such as NetScaler affected builds, are marked unknown rather than guessed. IoCs appear only as a named source published them, defanged and dated; IoAs are cited or marked ours. PIVCCERL™ is Threat Vector Management's incident lifecycle — Preparation, Identification, Validation, Containment, Communication, Eradication, Recovery, Lessons Learned; an unaffected organization is in Preparation, and a second stage appears where a hunt is warranted. ATT&CK: no source in this window published technique mappings for these items; ours would be inference and are omitted. This brief publishes when something warrants it, not on a schedule.
Threat Vector Management helps organizations and the MDR and SOC teams that defend them turn briefs like this into action — getting edge appliances ready for a bulletin that has not landed yet, and tightening how self-hosted applications decide who a user is. Start a conversation →
More editions: Threat Brief archive.
Threat Vector Management is not affiliated with any company operating under the name “ThreatVector.”
Sources
- The Hacker News — Two unpatched Citrix NetScaler RCE zero-days under active exploitation (relaying watchTowr), 27 Sep 2026
- Cyber Kendra — Unpatched NetScaler zero-days exploited, watchTowr says, 26 Sep 2026
- Citrix — CTX694799, steps to take if NetScaler ADC is suspected to be compromised
- NCSC-NL — NetScaler compromise check scripts
- CVE record — CVE-2026-19490 (NetScaler, August 2026)
- Kyverno — advisory GHSA-5qq8-67g6-4h2w, 10 Sep 2026
- CVE records — Kyverno CVE-2026-100703, CVE-2026-100704, CVE-2026-100705, CVE-2026-100706, CVE-2026-100707
- Flowise — advisory GHSA-cffm-583c-vffr, 10 Sep 2026
- Flowise — advisory GHSA-vf3j-89vf-r697, 10 Sep 2026
- CVE records — CVE-2026-100606, CVE-2026-100607 (Flowise) and CVE-2026-100684 (Budibase)
- Budibase — advisory GHSA-35ch-57g2-3g98
- CVE record — MediaWiki ExternalData CVE-2026-100382, 25 Sep 2026
- Wikimedia Phabricator — T434961
- Ontinue — Lunex unmasked: a new information stealer deployed through BYOVD, 24 Sep 2026
- Ontinue — Lunex indicator list
- The Hacker News — Lunex stealer abuses AMD driver, 26 Sep 2026
Provenance: compiled 27 September 2026 (morning) by Threat Vector Management from a sweep of vendor, government and researcher sources, with research and fact-checking support from Claude (Anthropic). Every CVE identifier, version string, score, catalog date and indicator was checked against its cited primary record or researcher publication on the day of publication.