Insights / Threat Brief / 27 Sep 2026 · Morning

Threat Brief • 27 September 2026 • Morning Edition

Exploited Before Anyone Could Patch: Two NetScaler Zero-Days, and Why an Email Address Is Not an Identity

Two NetScaler flaws were exploited before any fix existed, and Citrix has not yet published a bulletin. Plus: a Kyverno path to cluster admin, single sign-on that trusts an email address, a CVSS 10 in a MediaWiki extension, and a stealer that blinds endpoint protection instead of killing it.

By Fezz · Threat Vector Management · September 27, 2026

Research, fact-checking and graphics support by Claude (Anthropic)

Status as of publication on Sunday, 27 September 2026, US Eastern. Item 1 is developing: Citrix had published no bulletin, CVE or indicators at publication. Where anything here differs from a vendor’s own advisory to you, follow the advisory.

: added a hunt pack to every item — search queries and scripts, what each result means, and what to do. Packs were fact-checked twice against this page; the bash scripts were run on synthetic data and the Sigma rules were converted for three platforms. Everything else is labelled as reviewed, not run. No indicator, version or recommendation above changed.

BLUF

Bottom Line Up Front

How to read this. The table below gives every fix and the most huntable indicators. Each item opens with its own bottom line and indicator table, so you can act without reading further. Items are ranked by consequence if they reach you, not likelihood. Reported facts are cited; priority order, PIVCCERL™ staging and reasoning are our analysis and labeled.

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

Bottom line

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)

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.

bash — run on a workstation against exports you copied off the appliance; `conf` prints lines added or changed in the current export compared with the baseline (deleted lines are not reported; values after any flag whose name contains pass, pwd, key, kek, secret, community, token or cred are redacted), `files` checks a directory copy against a known-good sha256 manifest; exit 0 = nothing found, 1 = found, 3 = nothing readable (not a clean result)
#!/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
Splunk SPL — gateway session logs; sourcetype, index and field names are assumptions, rename to match your data
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
Microsoft Sentinel / Log Analytics KQL — firewall or flow data in CommonSecurityLog; column names vary by connector. Replace the placeholder addresses with your appliance's management and gateway 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

Bottom line

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)

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.

bash — Kubernetes audit log, JSON lines, plain or .gz; set EXPECTED_USERS to a regular expression matching your administrators, GitOps service accounts and Kyverno's own service account so their creates print as EXPECTED; counts ResponseComplete events only; exit 0 = nothing unexpected, 1 = found, 3 = nothing readable or any input unreadable (not a clean result)
#!/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"
bash — current kubectl context, read-only "get" calls; checks the Kyverno image tags against 1.19.1 (an image pinned by digest or tagged latest or 1.19 exits 3, not 0), searches namespaced policies for `%2e%2e`, and lists webhook configurations and PolicyExceptions for review (Kyverno's own webhooks always print REVIEW; see the notes above for what it does not cover)
#!/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))
'
Sigma — Kubernetes audit log; field names follow the standard audit event and need a pipeline for your SIEM
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
Splunk SPL — Kubernetes audit events as JSON; index and sourcetype are assumptions, and the field extractions follow the standard audit event; the dotted names in `rename` were not tested, so adjust them to your extraction
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
Microsoft Sentinel / Log Analytics KQL — assumes each audit event arrives as JSON in a column named Event in a custom table, so replace the table and column names with yours
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

Bottom line

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)

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.

bash — version check; you supply the product and the version you read from your own deployment; exit 0 = outside the published range, 1 = inside it, 3 = version unreadable (not a clean result)
#!/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
bash — application authentication log, JSON lines, plain or .gz; lists accounts seen through more than one provider value; set USER_FIELD and IDP_FIELD to the field names in your logs; exit 3 also when any input file is unreadable and nothing was found
#!/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"
Splunk SPL — application audit or authentication events; index, sourcetype and every field name are placeholders, and the action patterns are examples to rename
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)
Microsoft Sentinel / Log Analytics KQL — assumes a custom table AppAudit_CL with columns User, Provider and Action, so replace the table and column names with yours
// 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

Bottom line · analysis

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

Bottom line

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)

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.

bash — a MediaWiki install directory (the one that holds LocalSettings.php and extensions/); exit 0 = extension absent or at 3.7 or later, 1 = present and older, 3 = could not tell (not a clean result). Edge cases: a line inside a block comment (`/* ... */`) that names ExternalData still counts as a mention, so with the directory missing it exits 3; a lowercase mention in LocalSettings.php is ignored; a version such as 3.7.0-rc counts as 3.7
#!/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
bash — any text export of page content or edit history, plain or .gz; prints lines that mention ExternalData so a person can read the edit and its author; use it on page-content exports only, never on LocalSettings.php or other configuration, and review the output before sharing it; exit 3 also when any input file is unreadable and nothing matched
#!/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"
Sigma — process creation on Linux and on Windows; the parent and child lists are ours
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
Microsoft Defender XDR KQL — advanced hunting, DeviceProcessEvents on hosts running MediaWiki; the process lists are ours
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
osquery — Linux or Windows hosts running MediaWiki; a snapshot, so it only sees processes that are alive when it runs, and a scheduled query would be needed for history
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

Bottom line

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)

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.

bash — proxy, DNS, firewall, flow or EDR text exports, plain or .gz; matches the published addresses, domains, hashes and artifact names as whole names (lookalikes such as notuasputnik.com do not match; subdomains do), and prints up to 160 characters of each matching line, so review the output before sharing it; exit 0 = nothing found, 1 = found, 3 = nothing readable or any input unreadable (not a clean result)
#!/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"
PowerShell — a Windows host; read-only; checks the scheduled task, the native messaging registry key in every loaded user hive, and files named PDFWKRNL.sys or elita.msi against the published hashes
# 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
Sigma — Sysmon-style or equivalent telemetry on Windows. Remove the square brackets from the domains and addresses before converting; as printed they will not match. The lookalike-domain and address rules come from Ontinue's published indicators; the hash rules cover the driver, loader and stealer only (not the elita.msi installer hash or file name, which only the other hunts search), and the registry key and scheduled task rules cover those two artifacts; the temp-directory rule comes from its published behavior
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
Splunk SPL — raw-text search, so it does not depend on field names; remove the brackets from the defanged values; add your DNS, proxy, firewall and EDR indexes
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
Microsoft Defender XDR KQL — advanced hunting (Device tables); remove the brackets from the defanged values. SHA256 is often empty in these tables; if a hash line returns nothing, rely on file name and path, and hash by other means. Driver loads may not appear in DeviceImageLoadEvents on every sensor, so the file and name lines matter most. The `ScheduledTaskCreated` ActionType (and the AdditionalFields content it is matched on) and any driver-load ActionType such as `DriverLoad` are unverified, not checked against a live tenant. Terms match as whole terms or substrings, not anchored like the bash script, so check hits for lookalike names
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
Elastic ES|QL — ECS field names; index pattern is an assumption. A field missing from every mapped index makes ES|QL fail, so delete that predicate rather than the query. Remove the brackets from the addresses; leave `[.]` inside the regular expression. RLIKE and LIKE are case-sensitive here, so a differently cased value will not match. The RLIKE patterns match the whole name (a subdomain or the domain itself, with an optional trailing dot). RLIKE, and TO_STRING on an IP field, were not run against a live cluster
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
osquery — Windows hosts; a snapshot of current state, so it finds a task, key, driver file or live connection that exists now, not history. The registry table sees only loaded user hives, and how these Windows tables behave was not tested here. Remove the brackets from the addresses
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');
Suricata — network sensor with local SIDs in the 9000000 range; matches plaintext DNS queries, the plaintext TLS server name and the two published addresses. Remove the brackets from the addresses. Blind to encrypted DNS and encrypted client hello
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

  1. The Hacker News — Two unpatched Citrix NetScaler RCE zero-days under active exploitation (relaying watchTowr), 27 Sep 2026
  2. Cyber Kendra — Unpatched NetScaler zero-days exploited, watchTowr says, 26 Sep 2026
  3. Citrix — CTX694799, steps to take if NetScaler ADC is suspected to be compromised
  4. NCSC-NL — NetScaler compromise check scripts
  5. CVE record — CVE-2026-19490 (NetScaler, August 2026)
  6. Kyverno — advisory GHSA-5qq8-67g6-4h2w, 10 Sep 2026
  7. CVE records — Kyverno CVE-2026-100703, CVE-2026-100704, CVE-2026-100705, CVE-2026-100706, CVE-2026-100707
  8. Flowise — advisory GHSA-cffm-583c-vffr, 10 Sep 2026
  9. Flowise — advisory GHSA-vf3j-89vf-r697, 10 Sep 2026
  10. CVE records — CVE-2026-100606, CVE-2026-100607 (Flowise) and CVE-2026-100684 (Budibase)
  11. Budibase — advisory GHSA-35ch-57g2-3g98
  12. CVE record — MediaWiki ExternalData CVE-2026-100382, 25 Sep 2026
  13. Wikimedia Phabricator — T434961
  14. Ontinue — Lunex unmasked: a new information stealer deployed through BYOVD, 24 Sep 2026
  15. Ontinue — Lunex indicator list
  16. 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.

Get the next one first

New Threat Briefs and Deep Dives, delivered to your feed reader the moment they publish. No sign-up, no email, no tracking.

Follow Insights