Insights / Threat Brief / 26 Sep 2026 · Morning

Threat Brief • 26 September 2026 • Morning Edition

The Bug You Patched in June: ShinyHunters Return to PeopleSoft, and Three Catalog Deadlines Land This Week

A flaw Oracle fixed in June is being mass-exploited again, and a June patch says nothing about May. Plus: three catalog deadlines this week, a destroyed Azure estate traced to a deleted secret, and two compromised GitHub Actions back online.

By Fezz · Threat Vector Management · September 26, 2026

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

Status as of publication on Saturday, 26 September 2026, US Eastern. Items 1, 4, 8 and 10 are active campaigns; where anything here differs from a vendor’s own advisory to you, follow the advisory.

: standardized the audience and indicator key above the at-a-glance table.

: 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. PeopleSoft CVE-2026-35273 Yes, renewed wave · Fixed June 2026 Patch, then hunt — patching does not clear a prior intrusion x.jsp, u.jsp, tunnel.jsp under PSEMHUB.war Requests to /PSEMHUB/ or encoded variants; a shell spawned by the WebLogic Java process
2. WordPress core CVE-2026-87902 Yes · 7.1.2 and branch back-ports Update core today; deadline 28 September PHP files in /tmp or /var/tmp pagename carrying %2e%2e; any request naming pearcmd
3. WSO2 CVE-2026-5430 · Adobe Commerce CVE-2026-71362 Yes, both catalogued · Patched May (WSO2) / Aug (Adobe) 2026 Patch both; deadline 27 September None published A JWT accepted with an unexpected signing algorithm
4. Storm-3168 cloud destruction Yes, targeted · No patch — credential hygiene Rotate every secret ever public, deleted or not 45.131.66[.]106 A service principal enumerating subscriptions at machine speed
5. Roundcube CVE-2026-48842 Reported exploited · 1.6.16 / 1.7.1 Upgrade; treat mail credentials as exposed None published Unauthenticated login requests with SQL metacharacters
6. Elementor 4.3.0–4.3.1 Not observed · 4.3.2 Update, then audit administrator accounts None published REST requests with elementor/v1/events/ in a query string
7. Grav CVE-2026-42608 Used against a third party · 2.0 branch Move to a patched release — take the version from Grav None published Traversal in a form-flash parameter; stray index.yaml files
8. Re-enabled GitHub Actions Yes, payload re-executing · Pin or remove Replace tags with commit SHAs, rotate workflow secrets actions-cool/issues-helper Workflow runs since 16 September with odd durations
9. Storm-2570 tradecraft Active · No single patch Decide which remote-management tools are sanctioned None published meshagent64-[organization name].exe; @ip.txt; rdp.bat
10. Two macOS campaigns Active · No patch — delivery is social Treat developer Macs as production toria.apple03cloudstore[.]com A terminal command pasted from a browser; a new LaunchAgent

Highest payoff today — analysis. If you run PeopleSoft, item 1 is worth an hour before anything else. It is the only item whose published indicators answer a question you cannot otherwise answer: were you compromised before you patched in June? Everything else here is a patch or a configuration change. That one is a look-back, and look-backs get harder every week as logs roll off.

How to use this. Look back to the start of each campaign's reported activity, not to yesterday — for PeopleSoft, back to the June disclosure. 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 six items the behaviors are all anyone has published.

1. ShinyHunters restart mass exploitation of PeopleSoft

Bottom line

Google's Threat Intelligence Group reported on 25 September a renewed mass-exploitation campaign against CVE-2026-35273 in PeopleSoft Enterprise PeopleTools — fixed by Oracle in June, catalogued by CISA as exploited on 12 June. Attackers plant JSP web shells in the Environment Management Hub, deploy a backdoor and install a legitimate open-source remote-management agent (MeshAgent). Do now: confirm you are patched, then hunt the paths and log patterns below; a June patch does not tell you whether someone was inside in May. Skip if: you do not run PeopleSoft — or it is vendor-managed, in which case ask your provider for hunt results, not patch status.

Indicators

Type Indicator Where to look Source
IoC 5.199.162[.]157 — attack controller, scanner and HTTP callback receiver Perimeter flow logs, web access logs Google Threat Intelligence Group, 25 Sep 2026
IoC 104.219.234[.]138 — exfiltration staging and remote management Perimeter flow logs, egress firewall Google Threat Intelligence Group, 25 Sep 2026
IoC 162.219.30[.]165 — command and control for the SIDEEYE backdoor Perimeter flow logs, egress firewall Google Threat Intelligence Group, 25 Sep 2026
IoC winmanage-me[.]network on 104.219.234[.]138 — MeshAgent staging and management infrastructure DNS resolver logs, proxy logs Google Threat Intelligence Group, 25 Sep 2026
IoC x.jsp, u.jsp, tunnel.jsp, tunnel.jspx, Ple64.exe under <PS_CFG_HOME>/webserv/<domain>/applications/peoplesoft/PSEMHUB.war/ Application server filesystem Google Threat Intelligence Group, 25 Sep 2026
IoC SHA-256 48b4a0827da7bbfce9fb52464f8a659dea7a035189c52c506c0bfb4b1c3fe494 (x.jsp), 2bee941fb40519d0d1ec52bd79a8f63fc65aac6455c8f2d6b668e3360dfdb5d7 (u.jsp), 419c571ee38b7e7266d130c4b6bbc4dd0ef44d6e5f3bc02cc2cf73b762f07c86 (tunnel.jsp), ba14419beb2ec0bb94cab6298c14d7fb3e1d819366fe378290c0c2a4d97f7e07 (tunnel.jspx), 3ba215692665513abfffd4e815c5c45f2d41e5dcc4283a2a3b740930c5c417c3 (Ple64.exe) EDR, file integrity monitoring Google Threat Intelligence Group, 25 Sep 2026
IoA Requests to /PSEMHUB/ and non-normalized variants such as /%50SEMHUB/; the source advises blocking on the normalized path WebLogic PIA access logs, WAF logs Google Threat Intelligence Group, 25 Sep 2026
IoA POST to /hub with a body from an external source; unexpected .jsp or .jspx files under PSEMHUB or PORTAL WebLogic access logs, filesystem Google Threat Intelligence Group, 25 Sep 2026
IoA A shell spawned by the WebLogic Java process, especially invoking base64 -d, curl, /dev/tcp, tasklist or start /b EDR process telemetry Google Threat Intelligence Group, 25 Sep 2026
IoA Large .tar, .tar.gz or .zst archives in temporary or web-accessible directories; tar, zstd, rsync, sshpass or curl spawned by PeopleSoft or WebLogic service accounts; bulk queries against human-resources, payroll and student-records tables Filesystem, EDR, database audit logs Google Threat Intelligence Group, 25 Sep 2026

What

Oracle's CVE record: an easily exploitable flaw in the Updates Environment Management component of PeopleSoft Enterprise PeopleTools lets an unauthenticated attacker with HTTP network access compromise PeopleTools. CVSS 3.1 9.8, complete confidentiality, integrity and availability impact, classed as missing authentication for a critical function. Affected: PeopleTools 8.61 and 8.62. Published 11 June 2026; the CISA assessment in the record rates exploitation active and automatable with total technical impact; catalogued 12 June.

The wave is new, not the flaw. Google attributes it to ShinyHunters and documents JSP and JSPX web shells written into PSEMHUB.war, a C++ backdoor it tracks as SIDEEYE, the Neo-reGeorg tunnelling toolkit, and MeshAgent — legitimate remote-management software — for persistence. It documents the path /%50SEMHUB/, a percent-encoded /PSEMHUB/, and advises assuming any percent-encoded, mixed-case or non-normalized variant.

So what

Three months catalogued as exploited means three months in which a reachable instance could have been taken, and the June patch closes the door without telling you who is inside. The source's own hunting guidance names the data at risk — human-resources, payroll and student records. A fresh wave three months on says the unpatched population is still large. And a rule matching the literal /PSEMHUB/ misses /%50SEMHUB/ — a silently failing rule is worse than none, because it gets counted as a control.

Now what — P → I (Preparation, moving to Identification because a look-back is warranted; analysis)

Hunt pack — run it, then act on what it says

These hunts implement the paths, file hashes, addresses, domain and behaviors Google Threat Intelligence Group published on 25 September (the Indicators table above); the queries are our analysis of where to look for them. Executed against synthetic data: both bash scripts (hit, clean, empty, missing, unreadable (a chmod 000 file and a chmod 000 directory), compressed, truncated-compressed, corrupt-compressed and look-alike inputs, including domains that merely contain the published name and a PSEMHUB.war that is a file; printed lines have password, token, session and cookie values replaced by REDACTED (pattern-based, not exhaustive); the hash branch was exercised by swapping one published hash for the hash of a test file in a copy of the script) and the osquery query (run with osqueryi on macOS against a synthetic directory tree; Linux and Windows path forms were not run). Converted, not run on a platform: the four Sigma rules parse and convert with pySigma to Splunk, Kusto and Lucene queries. Reviewed, not executed: the PowerShell, SPL and KQL, so test them on a non-production system first; Splunk's urldecode() and in() in particular were not checked against a Splunk instance. Which table row an indicator belongs to is our reading, not the source's: we treat an inbound request from the first address as an attempt, and an internal host that connects to the other two addresses or resolves the domain as probably compromised. We are not offering Suricata or YARA: the source publishes no network signature beyond the addresses and domain (and our assumption is that PeopleSoft web traffic is normally TLS), and it publishes hashes but no file strings to match. We have no published hash or path for MeshAgent, so the only MeshAgent hunt here is the staging domain. Defanged values (a dot wrapped in square brackets) must have the brackets removed in anything you paste; the scripts that run as-is do it for you. Adjust field and table names to your logging.

bash — web, WAF, proxy, DNS or flow logs (plain or .gz); exit 0 = no hits, 1 = hits, 2 = usage, 3 = nothing scanned, or a file unreadable, corrupt or truncated (not a clean result)
#!/usr/bin/env bash
# Usage: ./peoplesoft-log-hunt.sh LOGFILE...   (web, WAF, proxy, DNS or flow logs; plain or .gz)
# Exit: 0 = scanned, no hits | 1 = hits | 2 = usage | 3 = nothing scanned, or a file unreadable/corrupt/truncated and no hits (NOT a clean result)
# Matching lines are printed with password, token, session and cookie values replaced by REDACTED (pattern-based, not exhaustive); still treat the output as sensitive.
set -uo pipefail
[ $# -ge 1 ] || { echo "usage: $0 LOGFILE..." >&2; exit 2; }
python3 - "$@" <<'PY'
import gzip, re, sys, zlib
from urllib.parse import unquote
fang = lambda s: s.replace("[.]", ".")                      # refang at run time
IPS = [fang(x) for x in ("5.199.162[.]157", "104.219.234[.]138", "162.219.30[.]165")]
DOMAIN = fang("winmanage-me[.]network")
IP_RE = [re.compile(r"(?<![\d.])" + re.escape(i) + r"(?![\d])") for i in IPS]
JSP_RE = re.compile(r"/(x\.jsp|u\.jsp|tunnel\.jspx?)(?=[?\s\"]|$)", re.I)
DOM_RE = re.compile(r"(?<![a-z0-9-])" + re.escape(DOMAIN) + r"(?![a-z0-9-]|\.[a-z0-9])")   # the name or a subdomain, not a look-alike
POST_HUB = re.compile(r"\bPOST\s+/hub(?=[\s?/]|$)", re.I)
RED = re.compile(r'(?i)((?:\w*pass\w*|pwd|\w*token\w*|\w*secret\w*|api_?key|auth\w*|cookie|\w*session\w*)=)[^&\s"]*|((?:authorization|cookie)\s*:\s*)[^"\r\n]*|("(?:\w*pass\w*|pwd|\w*token\w*|\w*secret\w*|api_?key|authorization|cookie|\w*session\w*)"\s*:\s*")[^"]*')
def show(s, n=300):   # printed lines have password, token, session and cookie values replaced (pattern-based, not exhaustive)
    return RED.sub(lambda m: (m.group(1) or m.group(2) or m.group(3)) + "REDACTED", s.rstrip())[:n]
cats = dict.fromkeys(["ioc_ip", "ioc_domain", "psemhub_path", "post_hub", "shell_name"], 0)
files = lines = hits = unreadable = 0
for path in sys.argv[1:]:
    try:
        with open(path, "rb") as fh:
            gz = fh.read(2) == b"\x1f\x8b"
        fh = gzip.open(path, "rt", errors="replace") if gz else open(path, "r", errors="replace")
        with fh:
            files += 1
            for n, raw in enumerate(fh, 1):
                lines += 1
                d = unquote(unquote(raw))                    # up to two URL-decode passes
                found = []
                if any(r.search(raw) for r in IP_RE): found.append("ioc_ip")
                if DOM_RE.search(raw.lower()): found.append("ioc_domain")
                if "psemhub" in d.lower(): found.append("psemhub_path")   # covers /%50SEMHUB/ after decoding
                if POST_HUB.search(d): found.append("post_hub")
                if JSP_RE.search(d): found.append("shell_name")
                if found:
                    hits += 1
                    for c in found: cats[c] += 1
                    print(f"{path}:{n}: [{','.join(found)}] {show(raw)}")
    except (OSError, EOFError, UnicodeError, zlib.error) as e:
        unreadable += 1
        print(f"WARN cannot read {path} fully: {getattr(e, 'strerror', None) or type(e).__name__}", file=sys.stderr)
print(f"files={files} lines={lines} hits={hits} unreadable={unreadable} " + " ".join(f"{k}={v}" for k, v in cats.items()), file=sys.stderr)
sys.exit(1 if hits else (3 if lines == 0 or unreadable else 0))
PY
bash — application server filesystem; finds the published file names and SHA-256 values inside PSEMHUB.war and PORTAL.war; lists any other .jsp, .jspx or .exe there as REVIEW (compare with a known-good copy); exit 3 = no such directory found, or files could not be read (not a clean result)
#!/usr/bin/env bash
# Usage: ./peoplesoft-fs-hunt.sh DIR...   (e.g. the PS_CFG_HOME webserv directory; read-only)
# Looks inside directories named PSEMHUB.war or PORTAL.war for the published file names and SHA-256 values.
# A published file name needs no file content to match, so an unreadable one is still reported (hash not checked).
# Exit: 0 = scanned, nothing found | 1 = something found or to review | 2 = usage
#       3 = no PSEMHUB.war/PORTAL.war directory found, or files could not be read, and nothing found (NOT a clean result)
set -uo pipefail
[ $# -ge 1 ] || { echo "usage: $0 DIR..." >&2; exit 2; }
python3 - "$@" <<'PY'
import hashlib, os, sys, time
NAMES = {"x.jsp", "u.jsp", "tunnel.jsp", "tunnel.jspx", "ple64.exe"}
HASHES = {
 "48b4a0827da7bbfce9fb52464f8a659dea7a035189c52c506c0bfb4b1c3fe494": "x.jsp",
 "2bee941fb40519d0d1ec52bd79a8f63fc65aac6455c8f2d6b668e3360dfdb5d7": "u.jsp",
 "419c571ee38b7e7266d130c4b6bbc4dd0ef44d6e5f3bc02cc2cf73b762f07c86": "tunnel.jsp",
 "ba14419beb2ec0bb94cab6298c14d7fb3e1d819366fe378290c0c2a4d97f7e07": "tunnel.jspx",
 "3ba215692665513abfffd4e815c5c45f2d41e5dcc4283a2a3b740930c5c417c3": "Ple64.exe"}
EXT = (".jsp", ".jspx", ".exe")
wars = checked = named = hashed = review = unreadable = 0
def onerr(e):
    global unreadable
    unreadable += 1
    print(f"WARN cannot read {e.filename}: {e.strerror}", file=sys.stderr)
def sha(p):
    h = hashlib.sha256()
    with open(p, "rb") as f:
        for b in iter(lambda: f.read(1 << 20), b""): h.update(b)
    return h.hexdigest()
def packed(p):
    global unreadable
    unreadable += 1
    print(f"WARN {p} is a file (a packed war), not opened: unpack it and scan the directory", file=sys.stderr)
def war_dirs(root):
    if os.path.basename(os.path.normpath(root)).upper() in ("PSEMHUB.WAR", "PORTAL.WAR"):
        if os.path.isdir(root): yield root; return
        if os.path.isfile(root): packed(root); return
    for dp, dn, fl in os.walk(root, onerror=onerr):
        for f in fl:
            if f.upper() in ("PSEMHUB.WAR", "PORTAL.WAR"): packed(os.path.join(dp, f))
        for d in list(dn):
            if d.upper() in ("PSEMHUB.WAR", "PORTAL.WAR"):
                yield os.path.join(dp, d); dn.remove(d)
for root in sys.argv[1:]:
    for war in war_dirs(root):
        wars += 1
        for dp, _, fs in os.walk(war, onerror=onerr):
            for f in fs:
                checked += 1
                p = os.path.join(dp, f)
                nm, ext = f.lower(), os.path.splitext(f.lower())[1]
                if nm not in NAMES and ext not in EXT: continue
                try:
                    mt = time.strftime("%Y-%m-%d %H:%M", time.localtime(os.path.getmtime(p)))
                    h = sha(p)
                except OSError as e:
                    unreadable += 1
                    why = e.strerror or type(e).__name__
                    if nm in NAMES: named += 1; print(f"NAME-MATCH-UNREADABLE {p} ({why}); hash not checked")
                    else: review += 1; print(f"REVIEW-UNREADABLE {p} ({why}); hash not checked")
                    continue
                if h in HASHES: hashed += 1; print(f"HASH-MATCH {HASHES[h]} {p}")
                elif nm in NAMES: named += 1; print(f"NAME-MATCH {p} mtime={mt} sha256={h}")
                else: review += 1; print(f"REVIEW {p} mtime={mt}")
print(f"wars={wars} files_checked={checked} hash_match={hashed} name_match={named} review={review} unreadable={unreadable}", file=sys.stderr)
sys.exit(1 if hashed + named + review else (3 if wars == 0 or unreadable else 0))
PY
PowerShell — Windows application servers, run elevated (Ple64.exe is a Windows file); read-only; reviewed, not executed
# Usage: .\peoplesoft-fs-hunt.ps1 -Path D:\psoft\cfg\webserv   (exit 0 none, 1 found or to review, 3 no PSEMHUB.war/PORTAL.war found or paths unreadable: not a clean result)
param([Parameter(Mandatory)][string[]]$Path)
$names  = 'x.jsp','u.jsp','tunnel.jsp','tunnel.jspx','Ple64.exe'
$hashes = '48b4a0827da7bbfce9fb52464f8a659dea7a035189c52c506c0bfb4b1c3fe494',
          '2bee941fb40519d0d1ec52bd79a8f63fc65aac6455c8f2d6b668e3360dfdb5d7',
          '419c571ee38b7e7266d130c4b6bbc4dd0ef44d6e5f3bc02cc2cf73b762f07c86',
          'ba14419beb2ec0bb94cab6298c14d7fb3e1d819366fe378290c0c2a4d97f7e07',
          '3ba215692665513abfffd4e815c5c45f2d41e5dcc4283a2a3b740930c5c417c3'
$errs = @()
$wars = foreach ($p in $Path) {
  if (-not (Test-Path -LiteralPath $p)) { Write-Warning "cannot read $p"; $errs += "missing: $p"; continue }
  if ((Split-Path $p -Leaf) -in 'PSEMHUB.war','PORTAL.war') { Get-Item -LiteralPath $p }
  Get-ChildItem -LiteralPath $p -Recurse -Directory -Force -ErrorAction Continue -ErrorVariable +errs | Where-Object { $_.Name -in 'PSEMHUB.war','PORTAL.war' }
}
if (-not $wars) { Write-Output 'wars=0 (not a clean result)'; exit 3 }
$found = foreach ($w in $wars) {
  Get-ChildItem -LiteralPath $w.FullName -Recurse -File -Force -ErrorAction Continue -ErrorVariable +errs |
    Where-Object { $_.Name -in $names -or $_.Extension -in '.jsp','.jspx','.exe' } | ForEach-Object {
      $hr = Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 -ErrorAction Continue -ErrorVariable +errs
      if (-not $hr) {   # a file name match needs no content, so an unreadable file is still reported
        [pscustomobject]@{ Verdict = $(if ($_.Name -in $names) {'NAME-MATCH-UNREADABLE'} else {'REVIEW-UNREADABLE'}); Path = $_.FullName; Modified = $_.LastWriteTime; SHA256 = '(hash not checked)' }
        return
      }
      $h = $hr.Hash.ToLower()
      [pscustomobject]@{ Verdict = $(if ($h -in $hashes) {'HASH-MATCH'} elseif ($_.Name -in $names) {'NAME-MATCH'} else {'REVIEW'}); Path = $_.FullName; Modified = $_.LastWriteTime; SHA256 = $h }
    }
}
"wars=$(@($wars).Count) matches_or_review=$(@($found).Count) unreadable=$(@($errs).Count)"
if ($found) { $found | Format-Table -AutoSize; exit 1 }
if (@($errs).Count -gt 0) { Write-Output 'unreadable paths (not a clean result)'; exit 3 }
exit 0
Sigma — four rules (web request path, shell spawned by Java, outbound connection, DNS); remove the brackets; map field names to your pipeline
title: PeopleSoft Environment Management Hub path requested, including the percent-encoded variant
id: 43e097ee-2b3e-4a1d-b0a9-a154fbe43daa
status: experimental
description: Requests naming PSEMHUB, including the /%50SEMHUB/ variant Google Threat Intelligence Group documented. Decode and normalize before matching if your pipeline can; other encodings or mixed-case variants that decode to the same path will only match if your pipeline decodes them.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: webserver
detection:
  selection:
    c-uri|contains:
      - '/PSEMHUB/'
      - '/%50SEMHUB/'
  condition: selection
falsepositives:
  - Legitimate use of the Environment Management Hub from an internal management network
level: medium
---
title: Shell spawned by a Java process (PeopleSoft WebLogic web shell activity)
id: 41043c6f-e081-400a-b810-9fe576e8ba36
status: experimental
description: A shell started by the WebLogic Java process is the behavior the source names as an indicator. Narrow ParentImage to the WebLogic path to cut noise.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: process_creation
detection:
  parent:
    ParentImage|endswith:
      - '/java'
      - '\java.exe'
  child:
    Image|endswith:
      - '/sh'
      - '/bash'
      - '\cmd.exe'
      - '\powershell.exe'
  condition: parent and child
falsepositives:
  - Any Java application that launches a shell (build agents, Jenkins, IDEs); scope ParentImage or the host to the PeopleSoft/WebLogic servers before alerting
level: medium
---
title: Connection to infrastructure named in the PeopleSoft campaign report
id: d078babe-7457-4c1e-a1fc-0fc53ca6f2b2
status: experimental
description: Outbound connections to the three addresses published by Google Threat Intelligence Group on 25 September 2026. The first address was also reported as a scanner, so check inbound web logs for it too.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: network_connection
detection:
  selection:
    DestinationIp:
      - '5.199.162[.]157'
      - '104.219.234[.]138'
      - '162.219.30[.]165'
  condition: selection
falsepositives:
  - None expected
level: high
---
title: DNS query for the MeshAgent staging domain named in the PeopleSoft campaign report
id: 361cccae-85e3-4c29-8dee-77a45cf36393
status: experimental
description: Resolution of the domain Google Threat Intelligence Group published as MeshAgent staging and management infrastructure.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: dns
detection:
  selection:
    query|endswith: '.winmanage-me[.]network'
  exact:
    query: 'winmanage-me[.]network'
  condition: selection or exact
falsepositives:
  - None expected
level: high
Splunk SPL — two searches; assumes web or proxy logs with a client address field and a flow or DNS source with src_ip, dest_ip and query; field names vary by sourcetype and add-on; remove the brackets from defanged values; urldecode() and in() were not checked against a Splunk instance; reviewed, not executed
index=<your_web_or_proxy_index>
| eval p=lower(urldecode(urldecode(_raw)))
| where like(p,"%psemhub%") OR match(p,"post\\s+/hub([\\s?/]|$)") OR match(p,"/(x\\.jsp|u\\.jsp|tunnel\\.jspx?)([\\s?\"]|$)")
| stats count min(_time) as first max(_time) as last values(host) as host by clientip
| convert ctime(first) ctime(last)

index=<your_network_or_dns_index> (src_ip IN ("5.199.162[.]157","104.219.234[.]138","162.219.30[.]165") OR dest_ip IN ("5.199.162[.]157","104.219.234[.]138","162.219.30[.]165") OR query="winmanage-me[.]network" OR url="*winmanage-me[.]network*")
| stats count min(_time) as first max(_time) as last by src_ip, dest_ip, query, url
KQL — Microsoft Defender XDR advanced hunting (device tables); run each query separately; remove the brackets; SHA256 is often empty in DeviceFileEvents, so a hash-only miss proves little; reviewed, not executed
// 1) Published file names inside PSEMHUB.war, or any published SHA-256 anywhere
let hashes = dynamic(["48b4a0827da7bbfce9fb52464f8a659dea7a035189c52c506c0bfb4b1c3fe494","2bee941fb40519d0d1ec52bd79a8f63fc65aac6455c8f2d6b668e3360dfdb5d7","419c571ee38b7e7266d130c4b6bbc4dd0ef44d6e5f3bc02cc2cf73b762f07c86","ba14419beb2ec0bb94cab6298c14d7fb3e1d819366fe378290c0c2a4d97f7e07","3ba215692665513abfffd4e815c5c45f2d41e5dcc4283a2a3b740930c5c417c3"]);
DeviceFileEvents
| where Timestamp > ago(30d)
| where SHA256 in~ (hashes) or (FolderPath has "PSEMHUB.war" and FileName in~ ("x.jsp","u.jsp","tunnel.jsp","tunnel.jspx","Ple64.exe"))
| project Timestamp, DeviceName, FolderPath, FileName, SHA256, ActionType, InitiatingProcessFileName

// 2) A shell started by a Java process
// scope DeviceName to the PeopleSoft application servers; any Java parent matches
DeviceProcessEvents
| where Timestamp > ago(30d)
| where InitiatingProcessFileName in~ ("java","java.exe") and FileName in~ ("sh","bash","cmd.exe","powershell.exe")
| project Timestamp, DeviceName, InitiatingProcessCommandLine, FileName, ProcessCommandLine, AccountName

// 3) Published addresses and domain
let ips = dynamic(["5.199.162[.]157","104.219.234[.]138","162.219.30[.]165"]);
DeviceNetworkEvents
| where Timestamp > ago(30d)
| where RemoteIP in (ips) or RemoteUrl has "winmanage-me[.]network"
| project Timestamp, DeviceName, RemoteIP, RemoteUrl, RemotePort, InitiatingProcessFileName
osquery — Linux and Windows application servers; set <PS_CFG_HOME> first; executed with osqueryi on macOS against a synthetic directory tree (Linux and Windows path forms not run; on Windows replace / with \ in the paths)
-- Replace <PS_CFG_HOME> with the real directory (no trailing slash). %% recurses; % matches one level.
SELECT f.path, f.filename, f.mtime, h.sha256,
       h.sha256 IN ('48b4a0827da7bbfce9fb52464f8a659dea7a035189c52c506c0bfb4b1c3fe494',
                    '2bee941fb40519d0d1ec52bd79a8f63fc65aac6455c8f2d6b668e3360dfdb5d7',
                    '419c571ee38b7e7266d130c4b6bbc4dd0ef44d6e5f3bc02cc2cf73b762f07c86',
                    'ba14419beb2ec0bb94cab6298c14d7fb3e1d819366fe378290c0c2a4d97f7e07',
                    '3ba215692665513abfffd4e815c5c45f2d41e5dcc4283a2a3b740930c5c417c3') AS published_hash
FROM file f JOIN hash h ON h.path = f.path
WHERE (f.path LIKE '<PS_CFG_HOME>/webserv/%/applications/peoplesoft/PSEMHUB.war/%%'
    OR f.path LIKE '<PS_CFG_HOME>/webserv/%/applications/peoplesoft/PORTAL.war/%%')
  AND f.type = 'regular'
  AND (f.filename LIKE '%.jsp' OR f.filename LIKE '%.jspx' OR f.filename LIKE '%.exe');
If you see Do this
HASH-MATCH or NAME-MATCH from the filesystem script or PowerShell, a published hash in EDR or osquery, or a shell spawned by the WebLogic Java process on the PeopleSoft server Confirmed compromise. Isolate the server at the network. Preserve the filesystem and logs first: copy the war directory and the access logs to evidence storage before you touch anything. Rotate every credential the server holds (application service account, database, integration broker, directory bind), and rebuild rather than clean. Then look at database audit logs for bulk reads of human-resources, payroll and student records, and bring in whoever owns the notification decision.
REVIEW lines: other .jsp, .jspx or .exe files in PSEMHUB.war or PORTAL.war Compare each one with a known-good copy of the deployed war or your deployment records (the hunt cannot know your baseline, and PORTAL.war may hold legitimate JSP files). A file you cannot account for is treated as a name match: the row above applies. Files you can account for: note the evidence and move on.
Requests to a PSEMHUB path in any encoding, a POST to /hub from an external source, or an inbound request from 5.199.162[.]157 (attack controller/scanner), but no file and no shell Treat as attempts that need a look-back, not as nothing. Check the status code and response size of each request, find out whether that instance was unpatched and reachable at the time, and run the filesystem and process hunts on that server. If it was, plan rotation of the credentials in its configuration. Either way, patch, take the Environment Management Hub off the internet, and test that your WAF or proxy normalizes the path with an encoded variant.
An internal host connecting to 104.219.234[.]138 or 162.219.30[.]165, or resolving winmanage-me[.]network Treat that host as probably compromised, not as an attempt. Isolate it at the network, preserve its logs and memory, and run the filesystem and process hunts on it. The first row's rotation and rebuild steps apply unless you can show the host is a false positive.
A hit that is an internal management address you expect Record it as expected, and confirm from outside that the hub is not reachable from the internet.
A script exit code of 3, lines=0, wars=0, an empty or erroring query, or logs that cover less time than the exposure A broken or empty hunt is not a clean result. Fix the input (correct path, rights to read it, a truncated or corrupt .gz file replaced, rotated and .gz logs included, a log source that really receives the traffic) and run it again. If the instance is vendor-managed, ask your provider for hunt results, not patch status.
No hits A good sign, not an exoneration. Only the published indicators were searched, and an actor can rename files and move infrastructure. Web and EDR logs roll over, so a compromise from before your retention window leaves nothing to find. A web shell can be removed after use while access continues through a backdoor, a remote-management agent or stolen credentials, none of which these file hunts see. The script and the SPL decode a request twice; a variant that needs more passes, or a proxy that rewrites the path before it is logged, can slip past. A rebuilt server has no evidence left. EDR hash fields are often blank, so a hash search can silently miss. And a June patch tells you nothing about whether someone was inside in May.

2. WordPress core: exploited within hours of the patch, deadline Monday

Bottom line

CVE-2026-87902 lets an unauthenticated request make WordPress include a local PHP file, which can reach code execution depending on how the server is built. CISA catalogued it on 25 September, due 28 September. Do now: update core, and check /tmp and /var/tmp for PHP files — the clearest evidence of a successful attack. Skip if: you run no WordPress. If "marketing owns the website", it is still your incident when it becomes one.

Indicators

Type Indicator Where to look Source
IoC Scanning and exploitation addresses 43.250.53[.]42, 180.251.159[.]243, 195.178.110[.]247, 107.189.14[.]87, 45.61.184[.]170, 92.246.130[.]76 Web access logs, WAF logs Patchstack, 22 Sep 2026, updated 23 Sep
IoC User agents cve-2026-87902-poc/1.0 and nuclei-cve-2026-87902/1.0 Web access logs Patchstack, 22 Sep 2026
IoC Dropped files wp-pear-rce-flag.php, poc87902.php, luci_<random>.php, zeta_<random>.php, written to /tmp and /var/tmp Host filesystem — a PHP file in either directory indicates a successful write Patchstack, 22 Sep 2026; SOCFortress, 24 Sep 2026
IoA A pagename parameter containing %2e%2e or %252e%252e, or templates%2f followed by traversal Web access logs, WAF logs Patchstack, 22 Sep 2026
IoA pagename paired with page_id against the site root or /index.php Web access logs Patchstack, 22 Sep 2026
IoA Any request naming pearcmd, +config-show or +config-create, or probing /usr/local/lib/php/pearcmd.php, /usr/share/php/pearcmd.php, /usr/share/pear/pearcmd.php Web access logs, WAF logs Patchstack, 22 Sep 2026

What

The NVD record: an unauthenticated attacker exploits get_page_template() to include a readable local .php file from outside active theme directories, which under specific server and theme conditions may enable remote code execution — CWE-98. Published 22 September 2026, modified 25 September. CISA's fields: added 25 September, due 28 September, required action to apply mitigations per vendor guidance under binding operational directive 26-04 or discontinue use.

The sources disagree on severity and scope. NVD carries CVSS 3.1 8.1 from a secondary scorer, high attack complexity, affecting WordPress before 7.1.2. Patchstack, which published the exploitation analysis, scores it 9.2 with a vulnerable range of 4.7.0 through 7.1.1, fixed in 7.1.2, 7.0.6, 6.9.9, 6.8.10 and back-ports to 4.7.37. We plan on the wider range — "before 7.1.2" does not help a site on an older branch that needs its own fix. Patchstack's timeline: first exploitation attempt 22 September 11:49 UTC; first attempt to write a file to disk through PHP's PEAR command-line tool (pearcmd) 15:34 UTC the same day; public Nuclei template 23 September. SOCFortress (24 September) reported the same addresses and added that PHP files in /tmp or /var/tmp indicate successful compromise.

So what

Under a day from patch to exploitation, and public tooling on day two, removes skill as a barrier. High attack complexity is real — this needs particular server and theme conditions — but you cannot easily tell from outside whether yours qualify, and someone is already checking for you.

Now what — P → I (analysis)

Hunt pack — run it, then act on what it says

These hunts implement the addresses, user agents, request patterns and dropped-file locations Patchstack (22 September, updated 23 September) and SOCFortress (24 September) published, plus the fixed versions listed in the item; the logic is our analysis. Executed against synthetic data: both bash scripts (hit, clean, empty, missing, unreadable (a chmod 000 log file; a chmod 000 temporary subdirectory for the host script), compressed, truncated-compressed and corrupt-compressed log inputs, a literal .. that must not match, a missing WP_ROOT, a double-quoted version string, a password parameter in a printed line, and nine version cases) and the osquery query (run with osqueryi on macOS against a synthetic directory, with its path swapped in a copy; the real /tmp and /var/tmp paths were not run). Converted, not run on a platform: the five Sigma rules parse and convert with pySigma to Splunk, Kusto and Lucene queries. Reviewed, not executed: the SPL and KQL, so test them on a non-production system first; Splunk's urldecode() and in() and the DeviceFileEvents ActionType values (FileCreated, FileModified, FileRenamed) were not checked against a live system. The published request patterns are the percent-encoded forms; a literal .. in the parameter is not in the published list, so these hunts do not flag it on their own. The version check knows only the fix numbers the item lists (7.1.2, 7.0.6, 6.9.9, 6.8.10 and 4.7.37); for any other branch it says so and sends you to the vendor. We are not offering Suricata or YARA: we assume most WordPress traffic is TLS, and the sources publish no file content to match. Remove the brackets from defanged values in anything you paste; the scripts that run as-is do it for you. Adjust field and table names to your logging.

bash — the web server itself, run as root; lists PHP files in /tmp and /var/tmp and classifies each WordPress root's core version; give at least one WP_ROOT, or the version check does not run; exit 3 = a temporary directory or root could not be read, or no root was given (not a clean result)
#!/usr/bin/env bash
# Usage: ./wp-host-hunt.sh WP_ROOT...   run as root on the web server (read-only)
# 1) lists PHP files in /tmp and /var/tmp (override with HUNT_TMP_DIRS="dir1:dir2"); 2) classifies each WP_ROOT's core version.
# Give at least one WP_ROOT, or the version check does not run (a warning is printed and a clean result becomes exit 3).
# Exit: 0 = nothing found | 1 = PHP file found or version not confirmed fixed | 2 = usage
#       3 = temp directories or a WP_ROOT not readable, or no WP_ROOT given, and nothing found (NOT a clean result)
set -uo pipefail
[ "${1:-}" = "-h" ] && { echo "usage: $0 WP_ROOT..." >&2; exit 2; }
python3 - "$@" <<'PY'
import fnmatch, os, re, sys, time
tmpdirs = [d for d in os.environ.get("HUNT_TMP_DIRS", "/tmp:/var/tmp").split(":") if d]
NAMED = ["wp-pear-rce-flag.php", "poc87902.php", "luci_*.php", "zeta_*.php"]
FIXED = {(7, 1): 2, (7, 0): 6, (6, 9): 9, (6, 8): 10, (4, 7): 37}   # patch level that fixes each branch, per the page
readable = unreadable = phpfiles = named = flagged = roots = badroots = 0
noroots = not sys.argv[1:]
if noroots: print("WARN no WP_ROOT given: the version check did not run (not a clean result)", file=sys.stderr)
def onerr(e):
    global unreadable; unreadable += 1; print(f"WARN cannot read {e.filename}: {e.strerror}", file=sys.stderr)
for t in tmpdirs:
    if not os.path.isdir(t): print(f"WARN {t} is not a directory", file=sys.stderr); unreadable += 1; continue
    readable += 1
    for dp, _, fs in os.walk(t, onerror=onerr):
        for f in fs:
            if f.lower().endswith(".php"):
                p = os.path.join(dp, f); phpfiles += 1
                tag = "NAMED" if any(fnmatch.fnmatch(f, n) for n in NAMED) else "PHP"
                named += tag == "NAMED"
                try: st = os.lstat(p); info = f"size={st.st_size} mtime={time.strftime('%Y-%m-%d %H:%M', time.localtime(st.st_mtime))}"
                except OSError: info = "stat-failed"
                print(f"{tag} {p} {info}")
for r in sys.argv[1:]:
    vf = os.path.join(r, "wp-includes", "version.php")
    try: m = re.search(r"\$wp_version\s*=\s*['\"]([^'\"]+)['\"]", open(vf, errors="replace").read())
    except OSError as e: print(f"WARN {vf}: {e.strerror}", file=sys.stderr); badroots += 1; continue
    roots += 1
    v = m.group(1) if m else None
    q = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?", v or "")
    if not q: st = "UNPARSEABLE-check-by-hand"
    else:
        a, b, c = int(q.group(1)), int(q.group(2)), int(q.group(3) or 0)
        if (a, b) in FIXED: st = "FIXED" if c >= FIXED[(a, b)] else "VULNERABLE"
        elif (a, b) > (7, 1): st = "NEWER-than-page-ranges-confirm-with-vendor"
        elif (a, b) >= (4, 7): st = "IN-RANGE-no-fix-number-for-this-branch-on-the-page-confirm-with-vendor"
        else: st = "BELOW-4.7-outside-published-range-confirm-with-vendor"
    if not st.startswith(("FIXED", "NEWER")): flagged += 1
    print(f"VERSION {r} {v} {st}")
print(f"tmp_dirs_readable={readable} unreadable={unreadable} php_files={phpfiles} named={named} wp_roots={roots} unreadable_roots={badroots} version_flags={flagged}", file=sys.stderr)
if phpfiles or flagged: sys.exit(1)
sys.exit(3 if (readable == 0 or unreadable or badroots or noroots) else 0)
PY
bash — web access or WAF logs (plain or .gz); exit 0 = no hits, 1 = hits, 3 = nothing scanned, or a file unreadable, corrupt or truncated (not a clean result)
#!/usr/bin/env bash
# Usage: ./wp-log-hunt.sh LOGFILE...   (web access or WAF logs; plain or .gz)
# Matches only the percent-encoded traversal forms the sources published; a literal ".." is NOT flagged.
# Exit: 0 = scanned, no hits | 1 = hits | 2 = usage | 3 = nothing scanned, or a file unreadable/corrupt/truncated and no hits (NOT a clean result)
# Matching lines are printed with password, token, session and cookie values replaced by REDACTED (pattern-based, not exhaustive); still treat the output as sensitive.
set -uo pipefail
[ $# -ge 1 ] || { echo "usage: $0 LOGFILE..." >&2; exit 2; }
python3 - "$@" <<'PY'
import gzip, re, sys, zlib
from urllib.parse import unquote
fang = lambda s: s.replace("[.]", ".")
IPS = [fang(x) for x in ("43.250.53[.]42", "180.251.159[.]243", "195.178.110[.]247", "107.189.14[.]87", "45.61.184[.]170", "92.246.130[.]76")]
IP_RE = [re.compile(r"(?<![\d.])" + re.escape(i) + r"(?![\d])") for i in IPS]
UAS = ["cve-2026-87902-poc/1.0", "nuclei-cve-2026-87902/1.0"]
TRAV = re.compile(r"pagename=[^&\s\"]*(%2e%2e|%252e%252e)|templates%2f[^&\s\"]*(%2e%2e|%252e%252e)")
PEAR = ["pearcmd", "+config-show", "+config-create"]
RED = re.compile(r'(?i)((?:\w*pass\w*|pwd|\w*token\w*|\w*secret\w*|api_?key|auth\w*|cookie|\w*session\w*)=)[^&\s"]*|((?:authorization|cookie)\s*:\s*)[^"\r\n]*|("(?:\w*pass\w*|pwd|\w*token\w*|\w*secret\w*|api_?key|authorization|cookie|\w*session\w*)"\s*:\s*")[^"]*')
def show(s, n=300):   # printed lines have password, token, session and cookie values replaced (pattern-based, not exhaustive)
    return RED.sub(lambda m: (m.group(1) or m.group(2) or m.group(3)) + "REDACTED", s.rstrip())[:n]
cats = dict.fromkeys(["ioc_ip", "user_agent", "pagename_traversal", "pagename_page_id", "pearcmd"], 0)
files = lines = hits = unreadable = 0
for path in sys.argv[1:]:
    try:
        with open(path, "rb") as fh: gz = fh.read(2) == b"\x1f\x8b"
        fh = gzip.open(path, "rt", errors="replace") if gz else open(path, "r", errors="replace")
        with fh:
            files += 1
            for n, raw in enumerate(fh, 1):
                lines += 1
                low = raw.lower(); dec = unquote(unquote(raw)).lower(); found = []
                if any(r.search(raw) for r in IP_RE): found.append("ioc_ip")
                if any(u in low for u in UAS): found.append("user_agent")
                if TRAV.search(low): found.append("pagename_traversal")
                if "pagename=" in low and "page_id=" in low: found.append("pagename_page_id")
                if any(p in low or p in dec for p in PEAR): found.append("pearcmd")
                if found:
                    hits += 1
                    for c in found: cats[c] += 1
                    print(f"{path}:{n}: [{','.join(found)}] {show(raw)}")
    except (OSError, EOFError, UnicodeError, zlib.error) as e:
        unreadable += 1
        print(f"WARN cannot read {path} fully: {getattr(e, 'strerror', None) or type(e).__name__}", file=sys.stderr)
print(f"files={files} lines={lines} hits={hits} unreadable={unreadable} " + " ".join(f"{k}={v}" for k, v in cats.items()), file=sys.stderr)
sys.exit(1 if hits else (3 if lines == 0 or unreadable else 0))
PY
Sigma — five rules (scanning addresses, user agents, traversal in pagename, pearcmd, PHP written to a temp directory); remove the brackets; map field names to your pipeline
title: Request from an address published as WordPress CVE-2026-87902 scanning or exploitation
id: 7875802f-5086-4493-86d8-c606347423d4
status: experimental
description: Source addresses published by Patchstack (22 September 2026, updated 23 September) and repeated by SOCFortress.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: webserver
detection:
  selection:
    c-ip:
      - '43.250.53[.]42'
      - '180.251.159[.]243'
      - '195.178.110[.]247'
      - '107.189.14[.]87'
      - '45.61.184[.]170'
      - '92.246.130[.]76'
  condition: selection
falsepositives:
  - Addresses can be reassigned; check the date of the request
level: medium
---
title: WordPress CVE-2026-87902 proof-of-concept and Nuclei user agents
id: a48a4198-5252-47f7-a130-40873e5b17d4
status: experimental
description: User agents published by Patchstack for the proof of concept and the public Nuclei template.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: webserver
detection:
  selection:
    c-useragent|contains:
      - 'cve-2026-87902-poc/1.0'
      - 'nuclei-cve-2026-87902/1.0'
  condition: selection
falsepositives:
  - Your own authorized Nuclei scan using the public template sends this user agent
level: high
---
title: WordPress pagename parameter with encoded traversal
id: dc37d8a2-9184-4446-9d4c-e446e472dd17
status: experimental
description: A request whose URI contains encoded traversal (%2e%2e or %252e%252e) and names pagename= or templates%2f anywhere in the URI, per the Patchstack patterns. This is broader than the log script, which needs the traversal inside the pagename or templates%2f value. A literal dot-dot is not in the published pattern.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: webserver
detection:
  param:
    c-uri|contains: 'pagename='
  template:
    c-uri|contains: 'templates%2f'
  traversal:
    c-uri|contains:
      - '%2e%2e'
      - '%252e%252e'
  condition: traversal and (param or template)
falsepositives:
  - Unlikely; confirm by reading the full request (the encoded traversal and the pagename= may belong to different parameters)
level: high
---
title: WordPress request naming pearcmd or its config commands
id: afbe03c1-b81f-463a-bc06-b86c2f5ec812
status: experimental
description: Requests naming pearcmd, +config-show or +config-create, or probing the pearcmd.php locations Patchstack published.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: webserver
detection:
  selection:
    c-uri|contains:
      - 'pearcmd'
      - '+config-show'
      - '+config-create'
  condition: selection
falsepositives:
  - Scanners, and ordinary pages or URLs that contain the word pearcmd
level: high
---
title: PHP file written under /tmp or /var/tmp on a web server
id: 784cfbc7-84b1-4aa9-8405-ac53acb0e31f
status: experimental
description: Per Patchstack and SOCFortress, a PHP file in either directory indicates a successful write. Needs file-creation telemetry on the web server.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: file_event
  product: linux
detection:
  selection:
    TargetFilename|startswith:
      - '/tmp/'
      - '/var/tmp/'
    TargetFilename|endswith: '.php'
  condition: selection
falsepositives:
  - Software that stages PHP in a temporary directory by design; the item treats any such file on a web server as confirmed compromise until explained
level: high
Splunk SPL — web access logs; assumes a client address field named clientip; field names vary by sourcetype and add-on; remove the brackets from defanged values; urldecode() and in() were not checked against a Splunk instance; reviewed, not executed
index=<your_web_index> sourcetype=<your_access_log_sourcetype>
| eval l=lower(_raw), d=lower(urldecode(urldecode(_raw)))
| eval reason=case(
    match(l,"pagename=[^&\\s]*(%2e%2e|%252e%252e)") OR match(l,"templates%2f.*(%2e%2e|%252e%252e)"), "pagename_traversal",
    match(l,"pearcmd|\\+config-show|\\+config-create") OR match(d,"pearcmd|\\+config-show|\\+config-create"), "pearcmd",
    match(l,"cve-2026-87902-poc/1\\.0|nuclei-cve-2026-87902/1\\.0"), "user_agent",
    like(l,"%pagename=%") AND like(l,"%page_id=%"), "pagename_page_id",
    in(clientip,"43.250.53[.]42","180.251.159[.]243","195.178.110[.]247","107.189.14[.]87","45.61.184[.]170","92.246.130[.]76"), "ioc_ip")
| where isnotnull(reason)
| stats count min(_time) as first max(_time) as last values(reason) as reason by clientip
| convert ctime(first) ctime(last)
KQL — Microsoft Defender XDR advanced hunting; needs Defender for Endpoint on the Linux web server; the ActionType values were not checked against a tenant; reviewed, not executed
DeviceFileEvents
| where Timestamp > ago(30d)
| where ActionType in ("FileCreated","FileModified","FileRenamed")
| where FolderPath startswith "/tmp/" or FolderPath startswith "/var/tmp/"
| where FileName endswith ".php"
| extend NamedInSources = FileName in~ ("wp-pear-rce-flag.php","poc87902.php") or FileName startswith "luci_" or FileName startswith "zeta_"
| project Timestamp, DeviceName, FolderPath, FileName, NamedInSources, InitiatingProcessFileName, InitiatingProcessAccountName
osquery — Linux web servers; executed with osqueryi on macOS against a synthetic directory with the paths swapped in a copy (the real /tmp and /var/tmp paths were not run)
SELECT path, filename, size, uid, datetime(mtime, 'unixepoch') AS modified
FROM file
WHERE (path LIKE '/tmp/%%' OR path LIKE '/var/tmp/%%')
  AND type = 'regular'
  AND filename LIKE '%.php';
If you see Do this
A PHP file in /tmp or /var/tmp on a web server (PHP or NAMED lines from the script, an osquery or KQL row, or the Sigma file rule) Confirmed compromise. Copy the file, with its hash, owner and modified time, to evidence storage before anything else. Isolate the site. Rotate database and application credentials, and, if the site was reachable and unpatched since 22 September, WordPress salts and keys and any API tokens in wp-config.php. Restore from a backup that predates 22 September; do not delete the file and move on.
VULNERABLE, IN-RANGE, BELOW or UNPARSEABLE from the version check Update core to the fixed release for your branch (7.1.2, 7.0.6, 6.9.9, 6.8.10, or 4.7.37 for the 4.7 branch). If the item lists no fix number for your branch, confirm it with the vendor rather than assuming. Then run the log and file hunts back to 22 September. As independent layers, remove pearcmd.php if PEAR is unused, set open_basedir, and make the temporary directories non-executable for the web user.
Log hits for a published address, user agent, encoded traversal in pagename or a pearcmd request, but no PHP file Attempts, not yet a result. Check the status code and response size of each, confirm the site is patched, and look in the temporary directories of every server behind that log. Blocking the addresses at the edge is a stopgap, not the fix. If any request returned 200 on an unpatched site, widen the look-back and treat the site as suspect until the file hunt is clean.
Only pagename_page_id lines Weak signal: the item lists the pairing, but ordinary links can carry both. Check what else the same client requested; if there is no traversal, pearcmd or published address, record it and rely on the patch.
A script exit code of 3, lines=0, unreadable above zero, a host-script warning that no WP_ROOT was given, an empty or erroring query, or logs that start after 22 September A broken or empty hunt is not a clean result. Re-run as root, include rotated and .gz logs, and confirm the log source really receives this site's traffic.
No hits A good sign, not an exoneration. The temporary directories are checked as they are today; a file can be removed, and many systems clear them on reboot, so an empty directory does not show what was there on 22 September. A web server in a container or chroot has its own /tmp, which the host-side scan does not see; run the script inside it. Only the published addresses, user agents and encoded patterns were searched, so a different scanner, a literal traversal or a proxy that normalizes the request before logging will not match. Logs rotate. A site that is unpatched and has no hits is still unpatched.

3. Two more catalog entries come due tomorrow: WSO2 and Adobe Commerce

Bottom line

CISA catalogued WSO2's CVE-2026-5430 and Adobe Commerce's CVE-2026-71362 on 24 September, both due 27 September. Both unauthenticated, both critical, both with vendor fixes available for weeks or months — WSO2 since May, Adobe since August. Do now: patch before the weekend is out. Skip if: your API management is not WSO2 and your storefront is not Adobe Commerce or Magento Open Source.

Indicators

Type Indicator Where to look Source
IoC None published. Neither CVE record nor either vendor advisory carries victim-side artifacts, and no researcher write-up with indicators was found in window — WSO2 and Adobe advisories; CVE records
IoA A JWT accepted by the gateway whose header names a signing algorithm the deployment does not use API gateway authentication logs Ours — derived from the flaw class in the CVE record
IoA Authentication successes for privileged gateway accounts with no corresponding credential use Gateway and identity provider logs Ours
IoA Administrative or privileged Commerce operations correlated with sessions that never authenticated Commerce application and web server logs Ours
IoA Recently modified checkout templates or unexpected inline script in storefront pages Filesystem, file integrity monitoring Ours — this platform's history is payment-skimming implants

What

CVE-2026-5430 (WSO2). NVD: CVSS 3.1 10.0, CWE-347 improper verification of a cryptographic signature — a token signed with an algorithm the implementation does not support but incorrectly validates bypasses JWT authentication, leading to unauthorized access and potential compromise of administrative accounts. Affected: API Manager 4.1.0 through 4.6.0; API Control Plane 4.5.0 to 4.5.0.57 and 4.6.0 to 4.6.0.21; Traffic Manager 4.5.0 to 4.5.0.55 and 4.6.0 to 4.6.0.20; Universal Gateway 4.5.0 to 4.5.0.56 and 4.6.0 to 4.6.0.20. Vendor advisory published 3 May 2026; CVE record published 6 August 2026. The catalog entry is titled "WSO2 Multiple Products Path Traversal Vulnerability" while the CVE record describes a JWT signature failure — act on the technical description; it is one advisory, not two.

CVE-2026-71362 (Adobe Commerce). CVSS 3.1 9.1, CWE-863 incorrect authorization — privilege escalation with no user interaction; Adobe says an attacker could gain elevated access to sensitive resources. Affected: Adobe Commerce up to 2.4.9-2026-jul including 2.4.4 through 2.4.9, July 2026 releases and earlier; Adobe Commerce B2B up to 1.5.3-2026-jul; Magento Open Source up to 2.4.9-2026-jul. Patched August 2026 in APSB26-92; published 11 August 2026.

So what

A gateway that accepts a forged token is the authentication decision for everything behind it, so CVSS 10.0 looks right rather than inflated. A storefront privilege escalation on a platform with a long skimming history tends to end in card data, not downtime. Adobe’s fix sat for six weeks and WSO2’s for nearly five months before being catalogued — evidence a large population did not apply them.

Now what — P → I (analysis)

Hunt pack — run it, then act on what it says

No source has published victim-side artifacts for either flaw (the Indicators table says so), so these hunts check version and patch state and look for the behaviors the item lists, which are our analysis derived from the CVE records, not published indicators. Executed against synthetic data: the two bash scripts (hit, clean, boundary, empty, missing and unreadable cases, apim update levels, and a docroot whose own path contains "checkout"). Reviewed, not executed: the SPL and KQL, so test them on a non-production system first; Splunk's in() and the DeviceFileEvents ActionType values were not checked against a live system. The SPL queries are templates: every ALL-CAPS placeholder in angle brackets is something only your deployment knows, because the sources publish behavior and not field names; if your gateway does not log the token's signing algorithm, the WSO2 hunt cannot run, and that gap is a finding, not a clean result. We are not offering Sigma, Suricata, osquery or YARA for this item: a rule built on "an unexpected algorithm" or "a changed checkout file" needs fields and paths the sources do not publish, and a rule written from a guess would be counted as a control it is not. There is no version script for Adobe Commerce: the item gives affected releases as dated labels (for example up to 2.4.9-2026-jul) and the fix as APSB26-92, so confirm that patch against the advisory itself. For WSO2 the script reads nothing from the host: you give it the product and version, and it compares them with the ranges the item lists (it treats the listed upper bounds as inclusive and says so when you sit on one; for apim it compares only the first three parts and reports any update level as in range, because the page gives apim no update levels).

bash — WSO2 products; you supply PRODUCT and VERSION pairs (apim, control-plane, traffic-manager, universal-gateway); exit 0 = nothing inside a published range (not a statement that the version is fixed), 1 = inside, 2 = usage
#!/usr/bin/env bash
# Usage: ./wso2-version-check.sh PRODUCT VERSION [PRODUCT VERSION ...]
#   PRODUCT = apim | control-plane | traffic-manager | universal-gateway ; VERSION as shown in your product (3 or 4 numeric parts)
# Compares against the affected ranges published for CVE-2026-5430. It reads nothing from the host: you supply the versions.
# apim is compared on its first three parts only (the page gives no update levels for it); a fourth part is reported as in range.
# Exit: 0 = nothing inside a published range (NOT a statement that the version is fixed) | 1 = at least one pair is inside (or on the boundary of) a published range | 2 = usage or unparseable version
set -uo pipefail
[ $# -ge 2 ] && [ $(( $# % 2 )) -eq 0 ] || { echo "usage: $0 PRODUCT VERSION [PRODUCT VERSION ...]" >&2; exit 2; }
python3 - "$@" <<'PY'
import re, sys
# (low, high) inclusive; tuples are (major, minor, patch[, update-level])
R = {
 "apim": [((4,1,0), (4,6,0))],
 "control-plane": [((4,5,0), (4,5,0,57)), ((4,6,0), (4,6,0,21))],
 "traffic-manager": [((4,5,0), (4,5,0,55)), ((4,6,0), (4,6,0,20))],
 "universal-gateway": [((4,5,0), (4,5,0,56)), ((4,6,0), (4,6,0,20))]}
pad = lambda t: (lambda u: u + (0,) * (4 - len(u)))(tuple(t))
a = sys.argv[1:]; inside = bad = 0
for prod, ver in zip(a[0::2], a[1::2]):
    if prod not in R or not re.fullmatch(r"\d+(\.\d+){2,3}", ver):
        print(f"ERROR cannot use '{prod}' '{ver}'", file=sys.stderr); bad += 1; continue
    parts = len(ver.split("."))
    n = 3 if prod == "apim" else 4                          # apim: first three parts only
    v = pad(int(x) for x in ver.split("."))[:n]
    hit = None
    for lo, hi in R[prod]:
        if pad(lo)[:n] <= v <= pad(hi)[:n]: hit = (lo, hi); break
    if hit is None: print(f"OUTSIDE {prod} {ver}: not in the published affected ranges (confirm the fixed level against the vendor advisory)")
    else:
        inside += 1
        if prod == "apim" and parts == 4:
            print(f"IN-RANGE apim {ver}: the page lists API Manager 'through 4.6.0'; update levels are not addressed, treat as in range and confirm with the vendor")
        else:
            edge = " AT-PUBLISHED-UPPER-BOUND (the page's 'to' is not explicit about inclusive/exclusive)" if v == pad(hit[1])[:n] else ""
            print(f"IN-RANGE {prod} {ver}: inside a published affected range{edge}")
print(f"pairs={len(a)//2} in_range={inside} errors={bad}", file=sys.stderr)
sys.exit(2 if bad else (1 if inside else 0))
PY
bash — Adobe Commerce or Magento Open Source web root; lists files whose path below the web root contains "checkout" and that changed in the last N days; exit 3 = unreadable or empty (not a clean result)
#!/usr/bin/env bash
# Usage: ./commerce-recent-files.sh DOCROOT DAYS   (read-only; run as the web user or root)
# Lists files whose path BELOW DOCROOT contains "checkout" (any case) and that changed in the last DAYS days.
# It cannot tell a legitimate deployment from an implant: compare each line with your change records.
# Exit: 0 = none changed | 1 = some changed (review) | 2 = usage | 3 = DOCROOT unreadable or empty (NOT a clean result)
set -uo pipefail
[ $# -eq 2 ] && [[ "$2" =~ ^[0-9]+$ ]] || { echo "usage: $0 DOCROOT DAYS" >&2; exit 2; }
[ -d "$1" ] || { echo "cannot read directory: $1" >&2; exit 3; }
python3 - "$1" "$2" <<'PY'
import os, sys, time
root, days = sys.argv[1], int(sys.argv[2]); cut = time.time() - days * 86400
scanned = recent = unreadable = 0
def onerr(e):
    global unreadable; unreadable += 1; print(f"WARN cannot read {e.filename}: {e.strerror}", file=sys.stderr)
for dp, _, fs in os.walk(root, onerror=onerr):
    for f in fs:
        p = os.path.join(dp, f); scanned += 1
        if "checkout" not in os.path.relpath(p, root).lower(): continue
        try: m = os.lstat(p).st_mtime
        except OSError: unreadable += 1; continue
        if m >= cut: recent += 1; print(f"CHANGED {time.strftime('%Y-%m-%d %H:%M', time.localtime(m))} {p}")
print(f"scanned={scanned} unreadable={unreadable} recent_checkout_files={recent}", file=sys.stderr)
sys.exit(1 if recent else (3 if scanned == 0 or unreadable else 0))
PY
Splunk SPL — two template searches; replace every placeholder in angle brackets; field names vary by connector; uses in() and like(), which were not checked against a Splunk instance; if the first search returns only FIELD-NOT-LOGGED your gateway does not record the algorithm and that hunt cannot run; a session that began before your search window shows no login in the second, so widen the window; reviewed, not executed
index=<GATEWAY_INDEX> sourcetype=<GATEWAY_SOURCETYPE> <FILTER_FOR_SUCCESSFUL_AUTHENTICATIONS>
| eval alg=coalesce(lower(<FIELD_HOLDING_THE_TOKEN_SIGNING_ALGORITHM>), "FIELD-NOT-LOGGED")
| where NOT in(alg, <THE_ALGORITHMS_YOUR_DEPLOYMENT_SIGNS_WITH_AS_QUOTED_LOWER_CASE_STRINGS>)
| stats count min(_time) as first max(_time) as last by alg, <USER_FIELD>, <SOURCE_ADDRESS_FIELD>

index=<COMMERCE_WEB_INDEX> (uri_path="<YOUR_ADMIN_PATH>*" OR <FILTER_FOR_SUCCESSFUL_ADMIN_LOGINS>)
| eval kind=if(like(uri_path,"<YOUR_ADMIN_PATH>%"),"admin_action","login_ok")
| stats count(eval(kind="admin_action")) as admin_actions count(eval(kind="login_ok")) as logins min(_time) as first by <SESSION_FIELD>
| where admin_actions>0 AND logins=0
KQL — Microsoft Defender XDR advanced hunting; needs Defender for Endpoint on the Commerce web server; set the docroot fragment; the ActionType values were not checked against a tenant; reviewed, not executed
DeviceFileEvents
| where Timestamp > ago(30d)
| where ActionType in ("FileCreated","FileModified","FileRenamed")
| where FolderPath has "checkout" and FolderPath has "<YOUR_DOCROOT_FRAGMENT>"
| project Timestamp, DeviceName, FolderPath, FileName, ActionType, InitiatingProcessFileName, InitiatingProcessAccountName
| order by Timestamp desc
If you see Do this
A WSO2 token accepted with a signing algorithm your deployment does not use, or a privileged Commerce action in a session with no login Successful bypass. Preserve the logs. WSO2: rotate the gateway signing keys and every service credential the gateway holds, apply the vendor fix, and restrict accepted algorithms to an explicit allow-list. Commerce: rotate admin, integration and payment-module credentials (our reading of the source's 'rotate keys and credentials'), remove admin accounts nobody recognizes (our addition), and apply the August fix (APSB26-92).
OUTSIDE from the WSO2 version check The page's ranges do not cover your version or update level. Confirm the fixed update level against WSO2's own advisory before relying on this.
IN-RANGE from the WSO2 version check, or a Commerce release you cannot show carries the August fix Patch now. Until it is done, keep the management and admin consoles on an administrative network; on Commerce use a non-default admin path, reachable only from known addresses, with two-factor on every admin account.
CHANGED lines or KQL rows for files under a checkout path Compare each with your deployment records. A change nobody made is a possible implant: open the file, look for injected script, preserve it, and treat card data as possibly exposed until shown otherwise. Hand the exposure question to whoever makes the acquirer and card-brand call. A change that matches a deployment: record the evidence and move on.
A script exit code of 3, scanned=0, a FIELD-NOT-LOGGED result, an empty or erroring query, or a placeholder you could not fill A broken or empty hunt is not a clean result. Say in your notes that the hunt could not run, fix the input or the logging gap, and run it again.
No hits A good sign, not an exoneration. No indicators were published, so a clean result only tests the behaviors we derived. A gateway that does not log the token algorithm cannot show a forged token, and a bypass session can look like a normal administrator. The Commerce file hunt sees only paths containing "checkout" and only modified times, which an attacker can set, and a skimmer can live elsewhere. Logs rotate. An unpatched system with no hits is still unpatched, and both fixes have been available for weeks or months.

4. Storm-3168: a deleted secret, an edit history, and a destroyed Azure estate

Bottom line

Microsoft reported on 25 September that an actor it tracks as Storm-3168, also JADEPUFFER, took Azure service-principal credentials that had been posted in a public GitHub issue, edited out, and left readable in the issue's edit history — then used them to enumerate and destroy cloud resources. Do now: rotate every secret that was ever publicly visible, deleted or not. Skip if: nothing. There is no product dependency; if a credential has ever sat in a public repository, issue, gist, pull request or CI log, this is about you.

Indicators

Type Indicator Where to look Source
IoC 45.131.66[.]106 — probed Azure App Service paths and issued malicious Resource Manager requests Azure Activity log, sign-in logs Microsoft Threat Intelligence, 25 Sep 2026
IoC 34.153.223[.]102 Azure Activity log, sign-in logs Microsoft Threat Intelligence, 25 Sep 2026
IoC 64.20.53[.]230 Azure Activity log, sign-in logs Microsoft Threat Intelligence, 25 Sep 2026
IoA A service principal enumerating virtual machines, subscriptions and resource groups faster than any human workflow — the reported case covered two subscriptions in five seconds Azure Activity log Microsoft Threat Intelligence, 25 Sep 2026
IoA Bulk storage-account deletion attempts followed by repeated successful ListKeys calls Azure Activity log, Defender for Cloud alerts Microsoft Threat Intelligence, 25 Sep 2026
IoA Attempts to remove or weaken backup and recovery protection Azure Activity log, backup service logs Microsoft Threat Intelligence, 25 Sep 2026 (T1490)
IoA ATT&CK techniques named by the source: T1190, T1078.004, T1526, T1485, T1490 — Microsoft Threat Intelligence, 25 Sep 2026

What

Microsoft: the credentials were exposed in plaintext in a public GitHub issue, and "the issue was later edited to remove the secret, but the secret remained accessible through the issue's public edit history." Its guidance, quoted because the wording matters: "Removing or redacting an exposed secret does not invalidate it; credentials exposed in any public internet location should be treated as compromised and promptly revoked or rotated."

Sequence, early June 2026: one service principal made over 300 successful read operations across 15.5 hours; ninety minutes later a second enumerated two subscriptions in five seconds; sixteen hours later, an attempt to enumerate App Service configuration. The destructive phase ran 35 minutes — more than 150 destructive and credential-collection operations, about seven minutes of actual deletions including over 100 storage-account deletion attempts — followed thirty minutes later by more than 30 successful storage-key requests. Storm-3168 infrastructure also repeatedly probed App Service paths.

So what

The initial access is not exotic and not an AI problem — it is a secret in a public place. The speed removes the human window: five-second enumeration and a 35-minute destructive phase leave no time for an alert to reach someone who reads it and acts. That argues for automated response on a few high-confidence signals, not more dashboards. And scanners that watch a repository's current state miss issue edit histories, force-pushed commits and CI logs rotated out of the interface. "We removed it" has never meant "we rotated it".

Now what — P → I (analysis)

Hunt pack — run it, then act on what it says

These hunts implement the addresses, operations and sequence Microsoft Threat Intelligence published on 25 September; the queries and the thresholds are our analysis. Executed against synthetic data: the bash script (hit, clean, single-delete, bulk, threshold-override, one delete by one identity plus a ListKeys line by another, a non-numeric BULK, a token in a printed line, empty, missing, unreadable, compressed, truncated-compressed and corrupt-compressed inputs). Converted, not run on a platform: the two Sigma rules parse and convert with pySigma to Splunk, Kusto and Lucene queries; Azure field names differ between connectors and callerIpAddress and operationName are unverified against any of them, so map them before you rely on them, and a single event is not "bulk", so count per identity in your SIEM. Reviewed, not executed: the KQL (Microsoft Sentinel and Log Analytics table family) and the PowerShell, so test them on a non-production tenant first. The PowerShell is an inventory of application credentials by age, not a detection: it points at the secrets to rotate, and it cannot tell you which one was ever posted publicly. We are not offering SPL, osquery, Suricata or YARA: this is a cloud control-plane incident, the source publishes no host or file artifact, and Azure export field names vary too much between Splunk add-ons for us to write a query we could stand behind. The Azure Activity log typically records writes, deletes and actions rather than reads, so the enumeration indicator may not be visible there at all. The thresholds are ours, not Microsoft's: the bulk threshold of 20 (script BULK, KQL query 2), the ListKeys count of 3 in KQL query 2 and the burst threshold of 50 in KQL query 4. The script counts lines, and an operation can log several lines (Started, Succeeded, Failed); it also reads the whole export rather than one identity, so its matched result is a candidate to check per identity, not a finding. Remove the brackets from defanged values in anything you paste.

bash — Azure Activity or sign-in log exports as JSON lines, JSON or CSV (plain or .gz); counts are lines, not events, and an operation can log several lines (Started, Succeeded, Failed); the result covers the whole export, not one identity; exit 0 = no match, 1 = match, 3 = nothing scanned, or a file unreadable, corrupt or truncated (not a clean result)
#!/usr/bin/env bash
# Usage: ./azure-export-hunt.sh EXPORT...   (Azure Activity log or sign-in log exports as JSON lines, JSON or CSV; plain or .gz)
# Counts are LINES, not events: one operation can log several lines (Started/Succeeded/Failed), and a pretty-printed JSON event can span
# several lines, so prefer JSON-lines or CSV exports. The script reads the WHOLE export, not one identity: "matched" is a candidate only.
# Split the export by identity (or use KQL query 2) before treating a match as bulk deletion by one service principal.
# Exit: 0 = scanned, nothing matched | 1 = IP match, or storage deletes together with ListKeys, or deletes at/above BULK (default 20, our threshold), or any loose backup line
#       2 = usage | 3 = nothing scanned, or a file unreadable/corrupt/truncated and nothing matched (NOT a clean result)
# Matching lines are printed with password, token, session and cookie values replaced by REDACTED (pattern-based, not exhaustive); still treat the output as sensitive.
# BULK must be a positive integer, otherwise exit 2.
set -uo pipefail
[ $# -ge 1 ] || { echo "usage: $0 EXPORT..." >&2; exit 2; }
python3 - "$@" <<'PY'
import gzip, os, re, sys, zlib
fang = lambda s: s.replace("[.]", ".")
IPS = [fang(x) for x in ("45.131.66[.]106", "34.153.223[.]102", "64.20.53[.]230")]
IP_RE = [re.compile(r"(?<![\d.])" + re.escape(i) + r"(?![\d])") for i in IPS]
DEL = re.compile(r"storageaccounts/delete", re.I)
KEYS = re.compile(r"listkeys", re.I)
BKP = re.compile(r"(backup|recoveryservices|dataprotection)", re.I)
CHG = re.compile(r"/(delete|write)\b", re.I)                 # as in KQL query 3: disabling soft delete is a write
RED = re.compile(r'(?i)((?:\w*pass\w*|pwd|\w*token\w*|\w*secret\w*|api_?key|auth\w*|cookie|\w*session\w*)=)[^&\s"]*|((?:authorization|cookie)\s*:\s*)[^"\r\n]*|("(?:\w*pass\w*|pwd|\w*token\w*|\w*secret\w*|api_?key|authorization|cookie|\w*session\w*)"\s*:\s*")[^"]*')
def show(s, n=300):   # printed lines have password, token, session and cookie values replaced (pattern-based, not exhaustive)
    return RED.sub(lambda m: (m.group(1) or m.group(2) or m.group(3)) + "REDACTED", s.rstrip())[:n]
try:
    BULK = int(os.environ.get("BULK", "20"))
    if BULK < 1: raise ValueError
except ValueError:
    print("usage: BULK must be a positive integer", file=sys.stderr); sys.exit(2)
c = dict(ip=0, storage_delete=0, listkeys=0, backup_change_loose=0); files = lines = unreadable = 0
for path in sys.argv[1:]:
    try:
        with open(path, "rb") as fh: gz = fh.read(2) == b"\x1f\x8b"
        fh = gzip.open(path, "rt", errors="replace") if gz else open(path, "r", errors="replace")
        with fh:
            files += 1
            for n, raw in enumerate(fh, 1):
                lines += 1
                if any(r.search(raw) for r in IP_RE): c["ip"] += 1; print(f"{path}:{n}: [ip] {show(raw)}")
                if DEL.search(raw): c["storage_delete"] += 1
                if KEYS.search(raw): c["listkeys"] += 1
                if BKP.search(raw) and CHG.search(raw): c["backup_change_loose"] += 1; print(f"{path}:{n}: [backup+delete/write, loose match] {show(raw, 200)}")
    except (OSError, EOFError, UnicodeError, zlib.error) as e:
        unreadable += 1
        print(f"WARN cannot read {path} fully: {getattr(e, 'strerror', None) or type(e).__name__}", file=sys.stderr)
print(f"files={files} lines={lines} unreadable={unreadable} " + " ".join(f"{k}={v}" for k, v in c.items()) + f" bulk_threshold={BULK}", file=sys.stderr)
bad = c["ip"] > 0 or (c["storage_delete"] > 0 and c["listkeys"] > 0) or c["storage_delete"] >= BULK or c["backup_change_loose"] > 0
if bad: print("RESULT: matched - candidate only; check per identity (see the decision table)")
sys.exit(1 if bad else (3 if lines == 0 or unreadable else 0))
PY
KQL — Microsoft Sentinel / Log Analytics (AzureActivity, AADServicePrincipalSignInLogs, SigninLogs); run each query separately; remove the brackets; SigninLogs holds interactive user sign-ins only (service principals are in AADServicePrincipalSignInLogs); in AzureActivity, Caller for a service principal is its object-ID GUID, not its name; reviewed, not executed
// 1) The three published addresses in activity and sign-in data
let ips = dynamic(["45.131.66[.]106","34.153.223[.]102","64.20.53[.]230"]);
union isfuzzy=true
  (AzureActivity | where CallerIpAddress in (ips) | project TimeGenerated, Source="AzureActivity", Who=Caller, IP=CallerIpAddress, What=OperationNameValue, Status=ActivityStatusValue),
  (AADServicePrincipalSignInLogs | where IPAddress in (ips) | project TimeGenerated, Source="SPSignIn", Who=ServicePrincipalName, IP=IPAddress, What=ResourceDisplayName, Status=tostring(ResultType)),
  (SigninLogs | where IPAddress in (ips) | project TimeGenerated, Source="SignIn", Who=UserPrincipalName, IP=IPAddress, What=ResourceDisplayName, Status=tostring(ResultType))
| sort by TimeGenerated asc

// 2) Storage-account deletes together with successful ListKeys, per identity and hour
AzureActivity
| where TimeGenerated > ago(30d)
| summarize deletes = countif(tolower(OperationNameValue) endswith "storageaccounts/delete"),
            listkeys_ok = countif(tolower(OperationNameValue) has "listkeys" and ActivityStatusValue in~ ("Success","Succeeded")),
            first = min(TimeGenerated), last = max(TimeGenerated)
    by Caller, bin(TimeGenerated, 1h)
| where (deletes >= 1 and listkeys_ok >= 3) or deletes >= 20   // 3 and 20 are our thresholds, not Microsoft's

// 3) Backup or recovery protection deleted or changed (loose match; review each; disabling soft delete is a write)
AzureActivity
| where TimeGenerated > ago(30d)
| where tolower(OperationNameValue) has_any ("recoveryservices", "dataprotection") or tolower(OperationNameValue) contains "backup"
| where tolower(OperationNameValue) endswith "/delete" or tolower(OperationNameValue) endswith "/write"
| project TimeGenerated, Caller, CallerIpAddress, OperationNameValue, ActivityStatusValue, ResourceGroup, Resource

// 4) A burst of operations by one identity (threshold 50 per minute is ours; two or more subscriptions in a minute is worth a look)
AzureActivity
| where TimeGenerated > ago(30d)
| summarize ops = count(), subscriptions = dcount(SubscriptionId) by Caller, bin(TimeGenerated, 1m)
| where ops >= 50 or subscriptions >= 2
Sigma — two rules (published addresses, storage-account deletion); map the Azure field names to your connector (callerIpAddress and operationName are unverified against any connector); remove the brackets
title: Azure activity from an address published in the Storm-3168 report
id: c4122f9f-72de-4f00-8261-d0d67970de2b
status: experimental
description: Activity or sign-in events from the three addresses Microsoft Threat Intelligence published on 25 September 2026 (the source ties only the first to Resource Manager requests, and the others appear in activity and sign-in logs). Field names vary by connector.
author: Threat Vector Management
date: 2026/10/06
logsource:
  product: azure
  service: activitylogs
detection:
  selection:
    callerIpAddress:
      - '45.131.66[.]106'
      - '34.153.223[.]102'
      - '64.20.53[.]230'
  condition: selection
falsepositives:
  - Addresses can be reassigned; check the date of the activity
level: high
---
title: Azure storage account deletion (count per identity to find the bulk pattern)
id: 99b40f01-05e0-42e6-a4f3-0831f792e046
status: experimental
description: A storage-account delete operation. The Storm-3168 report describes over 100 storage-account deletion attempts within about seven minutes; one event is not that pattern, so aggregate per caller in your SIEM.
author: Threat Vector Management
date: 2026/10/06
logsource:
  product: azure
  service: activitylogs
detection:
  selection:
    operationName: 'MICROSOFT.STORAGE/STORAGEACCOUNTS/DELETE'
  condition: selection
falsepositives:
  - Planned decommissioning of storage accounts
level: medium
PowerShell — Azure PowerShell (Az.Resources), run as an identity that can read app registrations; inventory of application credentials by age (app registrations only, not service principal credentials); read-only; the credential property names (StartDateTime, EndDateTime, KeyId) are unverified because the cmdlet page does not list them; reviewed, not executed
# Usage: .\app-credential-age.ps1 [-MaxAgeDays 90]   (exit 0 none over the limit, 1 some over, 3 not signed in, could not list, or could not read some credentials and none over: not a clean result)
param([int]$MaxAgeDays = 90)   # 90 is the example lifetime cap in the item; set your own
if (-not (Get-AzContext)) { Write-Error 'Not signed in (Connect-AzAccount): not a clean result'; exit 3 }
try { $apps = @(Get-AzADApplication -ErrorAction Stop) } catch { Write-Error $_; exit 3 }
$now = Get-Date
$credErrs = @()
$rows = foreach ($a in $apps) {
  foreach ($c in @(Get-AzADAppCredential -ObjectId $a.Id -ErrorAction SilentlyContinue -ErrorVariable +credErrs)) {
    [pscustomobject]@{ App = $a.DisplayName; AppId = $a.AppId; KeyId = $c.KeyId; Start = $c.StartDateTime; End = $c.EndDateTime
                       AgeDays = $(if ($c.StartDateTime) { [int]($now - $c.StartDateTime).TotalDays } else { $null }) }
  }
}
$over = @($rows | Where-Object { $null -eq $_.AgeDays -or $_.AgeDays -gt $MaxAgeDays })
"apps=$($apps.Count) credentials=$(@($rows).Count) over_limit_or_unknown_age=$($over.Count)"
if (@($credErrs).Count -gt 0) { Write-Warning "could not read credentials for $(@($credErrs).Count) app registration(s)" }
if ($over) { $over | Sort-Object AgeDays -Descending | Format-Table -AutoSize; exit 1 }
if (@($credErrs).Count -gt 0) { Write-Warning 'not a clean result'; exit 3 }
exit 0
If you see Do this
Bulk storage-account delete attempts by one service principal (KQL query 2 with deletes >= 20, or a script run on a single identity's export that reaches the bulk threshold), especially followed by repeated successful ListKeys calls Confirmed compromise of that identity. Disable the service principal now and accept a failed pipeline as the cost, then investigate: revoke its credentials, list what it deleted and which storage accounts had keys listed, rotate those keys, and check backup protection and resource locks. If storage holding regulated data had keys listed or was accessed, that is potential unauthorized access, so involve whoever owns the notification decision.
Script result matched with only one delete, or a ListKeys line, or a loose backup line Candidate, not confirmed. Split the export by identity, or run KQL query 2, and look at the caller, the time gap and whether a pipeline run explains it. Treat as the first row only if one identity shows the bulk or delete-then-ListKeys pattern.
A published address in Activity or sign-in data Treat the identity that used it as compromised: disable it, rotate its secret, then review everything it did. Find where the secret was exposed (a public repository, issue and its edit history, gist, pull request or CI log) and rotate every secret that was ever publicly visible, deleted or not.
Loose backup delete or write rows Check each in the portal against change records. One nobody approved gets the first row's treatment for the identity that made it (our escalation; the source defines that response for the bulk storage-delete case); then put locks and immutability on the backup and recovery infrastructure and separate the identity that writes backups from the one that can delete them.
A burst of operations from one identity (KQL query 4) with no pipeline run to explain it Compare with the pipeline schedule. With no explanation, treat as the first row (our escalation: the source defines the confirmed-compromise response for a bulk storage-delete attempt by a service principal, and the 50-per-minute threshold is ours). With one, record it and consider whether that deployment identity needs this much privilege.
Credentials over the age limit or of unknown age (PowerShell) Rotate them, cap their lifetime, and replace secrets with workload identity federation where it exists. Matching a credential to a public exposure is a human job: look at the repositories, issues and logs where it could have appeared.
A script exit code of 3, lines=0, an empty or erroring query, or exports that do not reach back to the date of exposure A broken or empty hunt is not a clean result. Fix the export or workspace retention and run it again.
No hits A good sign, not an exoneration. Three addresses were searched, and an actor can change them. Exports and workspace retention are finite, so activity from before your window is invisible. The Activity log may not record the read-heavy enumeration at all. Most important, no log hunt can tell you whether a secret was ever posted in a public place, and a secret that leaked but has not yet been used leaves no trace: rotation is the control, and a clean log does not replace it.

A published secret is already spent — the controls that hold

Bottom line · analysis

Items 4 and 8, and many incidents that never make a page, start with a credential that was public for a while. These controls are ordered by payoff and none require knowing which secret leaked. Do now: pick the first row you have not done. Skip if: you are on short-lived federated credentials everywhere — then only the last two rows apply.

Control Helps when you don't know what leaked? Do this
Short-lived or federated credentials instead of static secrets Yes — a leaked secret expires on its own Use workload identity federation for CI and cloud service identities; where static secrets remain, set the shortest lifetime the consumer tolerates
Rotate on exposure, never redact-and-move-on Yes — this is the whole lesson of item 4 Treat any secret that reached a public surface as compromised: rotate it, then confirm the old value fails
Scan history, not just the current tree Yes — edit histories and force-pushed commits are where the misses live Run history-aware secret scanning across commits, issues, pull requests, gists and CI logs; enable push protection to stop the next one
Least privilege on machine identities Yes — caps the damage of any credential Review what each service principal, runner and deploy key can do today; remove delete and key-read permissions no pipeline uses
Pin third-party code by digest, not by tag Yes — a tag can be repointed under you Pin GitHub Actions to commit SHAs and container images to digests; this is precisely what item 8 exploits
Separate backup deletion from backup writing Yes — protects recovery regardless of the flaw Different identity, resource locks, and immutability or object-lock on backup targets
Automated containment on high-confidence machine-identity signals Yes — buys back the response window Auto-disable a service principal on bulk-delete or mass-enumeration; accept the false-positive cost

5. Roundcube: reported exploited, and the records do not yet agree

Bottom line

CVE-2026-48842 is a pre-authentication SQL injection in Roundcube Webmail's virtuser_query plugin, fixed in May in 1.6.16 and 1.7.1. Reporting in this window describes exploitation in the wild. Do now: upgrade, and treat mail credentials as exposed on any internet-facing instance that ran a vulnerable build. Skip if: your webmail is hosted (Microsoft 365, Google Workspace) or you do not use virtuser_query — though upgrading is cheaper than proving you do not.

Indicators

Type Indicator Where to look Source
IoC None published. No source in window published victim-side artifacts for this flaw — CVE record; Canadian Centre for Cyber Security
IoA Unauthenticated requests reaching the login path with SQL metacharacters and backslash sequences, consistent with the preg_replace() escape bypass the CVE record describes Roundcube and web server logs, WAF logs Ours — derived from the CVE record's technical description
IoA Mail-forwarding rules or filters on accounts that match no user request — the durable foothold after mail compromise, and it survives a password change Roundcube filter settings, mail server configuration Ours
IoA Database errors or unusually large result sets on unauthenticated requests Roundcube and database logs Ours

What

The CVE record: "Roundcube Webmail 1.6.x before 1.6.16 and 1.7.x before 1.7.1 has Pre-authentication SQL injection in the virtuser_query plugin via a preg_replace() backslash escape bypass." CVSS 3.1 8.1, high attack complexity, CWE-89. Affected 1.6.0 through 1.6.15 and 1.7.0; fixed in 1.6.16 and 1.7.1. Published 25 May 2026, updated 25 September 2026.

The records disagree. The CISA assessment in the CVE record still rates exploitation none and the flaw not automatable, with total technical impact. The Canadian Centre for Cyber Security, citing open-source reporting, describes it as exploited, and SentinelOne, quoted by The Hacker News, says unauthenticated attackers can inject SQL through the plugin and potentially expose mail credentials and stored messages. We plan on the exploitation reporting — a stale "no known exploitation" field is far more common than a national centre inventing a campaign. Exposure counts circulating this week could not be verified against a primary telemetry source and are not quoted.

So what

Webmail is the highest-value, lowest-attention system in most estates. An injection against the account-lookup path reaches the credentials and message bodies an actor wants for business email compromise, password resets elsewhere and credible pretexts. The fix has existed since May; the real question is whether anything happened before you applied it.

Now what — P → V (Preparation, moving to Validation because a log review is warranted; analysis)

Hunt pack — run it, then act on what it says

No source has published victim-side artifacts for this flaw (the Indicators table says so), so these hunts check version state against the ranges in the CVE record and look for the behaviors the item lists; the patterns and thresholds are our analysis, not published signatures. Executed against synthetic data: the three bash scripts (affected, fixed, outside-range and unparseable versions; hit, clean, empty, missing, unreadable, compressed, truncated-compressed and corrupt-compressed log inputs, a login request carrying a password parameter; candidate, clean, missing and empty filter exports, UTF-16 and binary filter files, an over-1-MB file). Converted, not run on a platform: the Sigma rule parses and converts with pySigma to Splunk, Kusto and Lucene queries. Reviewed, not executed: the SPL, so test it on a non-production index first; Splunk's urldecode() was not checked against a Splunk instance. Roundcube login credentials normally travel in a POST body, which access logs do not record, so the log hunts see only what reached the URL or a WAF log that keeps bodies. We are not offering KQL, osquery, Suricata or YARA: Roundcube is usually self-hosted, no standard Microsoft table exists for its logs and we will not guess one; the source publishes no package name or file path to query; and an injection over TLS has no published network pattern. The filter hunt does not know where your mail server stores filters: you export them and point the script at the export, and it flags lines where redirect or forward starts the line or follows { or ; (Sieve-style filters, including a rule written on one line) for you to compare with what users asked for.

bash — you supply the version(s); exit 0 = fixed or outside the listed branches, 1 = affected (1.6.0 through 1.6.15, or 1.7.0), 2 = usage or unparseable
#!/usr/bin/env bash
# Usage: ./roundcube-version-check.sh VERSION [VERSION ...]   (e.g. the value shown on the Roundcube About page)
# Published for CVE-2026-48842: affected 1.6.0 through 1.6.15 and 1.7.0; fixed in 1.6.16 and 1.7.1.
# Exit: 0 = every version is fixed or outside the published branches | 1 = at least one affected | 2 = usage or unparseable version
set -uo pipefail
[ $# -ge 1 ] || { echo "usage: $0 VERSION [VERSION ...]" >&2; exit 2; }
python3 - "$@" <<'PY'
import re, sys
aff = bad = 0
for v in sys.argv[1:]:
    m = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", v)
    if not m: print(f"ERROR cannot parse '{v}' (expected major.minor.patch)", file=sys.stderr); bad += 1; continue
    t = tuple(map(int, m.groups()))
    if (1, 6, 0) <= t <= (1, 6, 15) or t == (1, 7, 0): aff += 1; print(f"AFFECTED {v}: upgrade to 1.6.16 (1.6 branch) or 1.7.1 or later")
    elif t >= (1, 6, 16) and t < (1, 7, 0) or t >= (1, 7, 1): print(f"FIXED {v}")
    else: print(f"OUTSIDE {v}: not in the 1.6.x or 1.7.x ranges the record names (the record says nothing about this branch)")
print(f"versions={len(sys.argv)-1} affected={aff} errors={bad}", file=sys.stderr)
sys.exit(2 if bad else (1 if aff else 0))
PY
bash — web access, WAF, Roundcube or database logs (plain or .gz); two heuristics of ours (the database-error wording also matches ordinary URLs or text that contain, say, db-error); exit 0 = no hits, 1 = hits, 3 = nothing scanned, or a file unreadable, corrupt or truncated (not a clean result)
#!/usr/bin/env bash
# Usage: ./roundcube-log-hunt.sh LOGFILE...   (web access, WAF, Roundcube or database logs; plain or .gz)
# Our heuristics, not published signatures: (a) a request line that mentions login AND carries a backslash (or %5c) AND a quote, semicolon, "--" or "/*";
# (b) database-error wording anywhere on a line (this also matches ordinary URLs or text that contain, say, "db-error"; read the line).
# POST bodies are normally NOT in access logs, so (a) only sees what reached the URL.
# Exit: 0 = scanned, no hits | 1 = hits | 2 = usage | 3 = nothing scanned, or a file unreadable/corrupt/truncated and no hits (NOT a clean result)
# Matching lines are printed with password, token, session and cookie values replaced by REDACTED (pattern-based, not exhaustive); still treat the output as sensitive.
set -uo pipefail
[ $# -ge 1 ] || { echo "usage: $0 LOGFILE..." >&2; exit 2; }
python3 - "$@" <<'PY'
import gzip, re, sys, zlib
from urllib.parse import unquote
RED = re.compile(r'(?i)((?:\w*pass\w*|pwd|\w*token\w*|\w*secret\w*|api_?key|auth\w*|cookie|\w*session\w*)=)[^&\s"]*|((?:authorization|cookie)\s*:\s*)[^"\r\n]*|("(?:\w*pass\w*|pwd|\w*token\w*|\w*secret\w*|api_?key|authorization|cookie|\w*session\w*)"\s*:\s*")[^"]*')
def show(s, n=300):   # printed lines have password, token, session and cookie values replaced (pattern-based, not exhaustive)
    return RED.sub(lambda m: (m.group(1) or m.group(2) or m.group(3)) + "REDACTED", s.rstrip())[:n]
REQ = re.compile(r'"([A-Z]+ [^"]*)"')
META = re.compile(r"""['";]|--|/\*""")
DBERR = re.compile(r"(sql|database|db)[ _-]?(error|exception)|syntax error", re.I)
c = dict(login_backslash_meta=0, db_error_wording=0); files = lines = hits = unreadable = 0
for path in sys.argv[1:]:
    try:
        with open(path, "rb") as fh: gz = fh.read(2) == b"\x1f\x8b"
        fh = gzip.open(path, "rt", errors="replace") if gz else open(path, "r", errors="replace")
        with fh:
            files += 1
            for n, raw in enumerate(fh, 1):
                lines += 1
                m = REQ.search(raw); req = unquote(unquote(m.group(1) if m else raw))
                found = []
                if "login" in req.lower() and "\\" in req and META.search(req): found.append("login_backslash_meta")
                if DBERR.search(raw): found.append("db_error_wording")
                if found:
                    hits += 1
                    for k in found: c[k] += 1
                    print(f"{path}:{n}: [{','.join(found)}] {show(raw)}")
    except (OSError, EOFError, UnicodeError, zlib.error) as e:
        unreadable += 1
        print(f"WARN cannot read {path} fully: {getattr(e, 'strerror', None) or type(e).__name__}", file=sys.stderr)
print(f"files={files} lines={lines} hits={hits} unreadable={unreadable} " + " ".join(f"{k}={v}" for k, v in c.items()), file=sys.stderr)
sys.exit(1 if hits else (3 if lines == 0 or unreadable else 0))
PY
bash — exported mail filters or a mail-server configuration directory; lists candidate forwarding rules and .forward files; exit 3 = no readable text file scanned, or a file could not be read or was skipped (not a clean result)
#!/usr/bin/env bash
# Usage: ./forward-rules-hunt.sh PATH...   (files or directories holding exported mail filters, e.g. Sieve scripts; read-only)
# Lists lines where "redirect" or "forward" (any case) starts the line or follows "{" or ";" (so a rule written on one line is caught),
# and files named .forward. Roundcube's filter storage location depends on your mail server, so YOU point this at the export.
# Every line is a candidate: compare with what users asked for.
# Exit: 0 = scanned, none | 1 = candidates listed | 2 = usage | 3 = no readable text file scanned, or a file could not be read or was skipped (over 1 MB, binary), and no candidates (NOT a clean result)
set -uo pipefail
[ $# -ge 1 ] || { echo "usage: $0 PATH..." >&2; exit 2; }
python3 - "$@" <<'PY'
import os, re, sys
PAT = re.compile(r"(^|[{;])\s*(redirect|forward)\b", re.I)
files = hits = unreadable = 0
def onerr(e):
    global unreadable
    unreadable += 1; print(f"WARN cannot read {e.filename}: {e.strerror}", file=sys.stderr)
def scan(p):
    global files, hits, unreadable
    try:
        if os.path.getsize(p) > 1_000_000: unreadable += 1; print(f"WARN skipped (over 1 MB, not scanned) {p}", file=sys.stderr); return
        data = open(p, "rb").read()
    except OSError as e: unreadable += 1; print(f"WARN cannot read {p}: {e.strerror}", file=sys.stderr); return
    if data[:2] in (b"\xff\xfe", b"\xfe\xff"): text = data.decode("utf-16", "replace")
    elif b"\0" in data:
        unreadable += 1; print(f"WARN skipped (binary, or UTF-16 without a byte-order mark; not scanned) {p}", file=sys.stderr); return
    else: text = data.decode("utf-8", "replace")
    files += 1
    if os.path.basename(p) == ".forward": hits += 1; print(f"{p}: [.forward file present]")
    for n, line in enumerate(text.splitlines(), 1):
        if PAT.search(line): hits += 1; print(f"{p}:{n}: {line.strip()[:200]}")
for a in sys.argv[1:]:
    if os.path.isdir(a):
        for dp, _, fs in os.walk(a, onerror=onerr):
            for f in fs: scan(os.path.join(dp, f))
    else: scan(a)
print(f"files_scanned={files} candidates={hits} unreadable={unreadable}", file=sys.stderr)
sys.exit(1 if hits else (3 if files == 0 or unreadable else 0))
PY
Sigma — one rule (login request carrying a backslash and SQL metacharacters in the URL); noisy by design, level low; holds no defanged values
title: Roundcube login request with a backslash and SQL metacharacters in the URL
id: 55bd4794-e5c8-4888-980f-c3a3697254c6
status: experimental
description: Our heuristic for the pre-authentication SQL injection described in the CVE record (a preg_replace() backslash escape bypass in the virtuser_query plugin). Not a published signature. The login credentials are normally in a POST body, which a web server log does not hold, so this sees only what reached the URL or a WAF log that keeps request data.
author: Threat Vector Management
date: 2026/10/06
logsource:
  category: webserver
detection:
  login:
    c-uri|contains: 'login'
  backslash:
    c-uri|contains:
      - '%5c'
      - '\'
  meta:
    c-uri|contains:
      - '%27'
      - ''''
      - '%22'
      - '%3b'
      - ';'
      - '--'
      - '/*'
  condition: login and backslash and meta
falsepositives:
  - Unusual but legitimate links or scanners that carry these characters; read the full request
level: low
Splunk SPL — web or WAF logs that keep the request line; urldecode() was not checked against a Splunk instance; reviewed, not executed
index=<your_web_or_waf_index> sourcetype=<your_access_log_sourcetype>
| rex field=_raw "\"(?<req>[A-Z]+ [^\"]*)\""
| eval r=urldecode(urldecode(coalesce(req, _raw)))
| where like(lower(r),"%login%") AND match(r,"\\\\") AND match(r,"['\";]|--|/\\*")
| stats count min(_time) as first max(_time) as last values(r) as request by clientip
| convert ctime(first) ctime(last)

index=<your_app_or_database_index> ("sql error" OR "database error" OR "db error" OR "syntax error")
| stats count min(_time) as first max(_time) as last by host, source
If you see Do this
A forwarding rule or filter on an account that nobody asked for (candidates from the filter script, or one you find in the mail server) A confirmed compromise on its own. Export or screenshot the rule as evidence first, then remove it. Force password resets, review sent items for messages nobody sent, and treat the mail credential store as disclosed: rotate the Roundcube database credential and its des_key. A password reset alone does not remove the rule, which is why it is the durable foothold.
AFFECTED from the version check Upgrade to 1.6.16 (on the 1.6 branch) or 1.7.1 or later. If webmail need not face the whole internet, put it behind a VPN or access broker, and confirm the Roundcube database account cannot reach beyond its own schema. If the instance was internet-reachable while on an affected version, then run the log and filter hunts and plan the forced reset and rotation above.
login_backslash_meta or db_error_wording lines An attempt or a possible injection (db_error_wording also matches ordinary URLs or text that contain wording such as db-error, so read the line). Look at the status code and response size, and at the database log for the same minute. On a version that was affected at the time, with database errors or large responses, treat it as a lead, not proof: look for evidence that the injection succeeded (the source's defined response is for evidence of successful injection), and if you find it take the first row's actions (our escalation beyond that). On a version that was already fixed, record it.
OUTSIDE from the version check The CVE record names only the 1.6.x and 1.7.x ranges and says nothing about other branches; check your version against Roundcube's own release notes before you rely on it.
A script exit code of 3, lines=0, files_scanned=0, an empty or erroring query, or logs that start after the vulnerable build was deployed A broken or empty hunt is not a clean result. Fix the path or the export, include rotated and .gz logs, and run it again.
No hits A good sign, not an exoneration. Login credentials normally travel in a POST body that access logs do not keep, so an injection can leave no unusual log line at all. Our patterns are heuristics, not published signatures, and a quiet query returns data without a database error. Logs rotate, and the fix has existed since May, so the exposure window can be longer than your retention. The filter hunt only reads what you exported; a rule held in a mail-server setting you did not export will not appear. The records disagree about whether this is exploited, so absence of evidence here proves little.

6. Elementor: one click by a logged-in administrator creates an attacker's admin account

Bottom line

Elementor 4.3.0 and 4.3.1 have a cross-site request forgery flaw: a crafted link clicked by a logged-in administrator creates a new WordPress administrator. Fixed in 4.3.2; no CVE assigned yet. Do now: update, then check the administrator list against your records. Skip if: no WordPress, or Elementor is already 4.3.2 or later.

Indicators

Type Indicator Where to look Source
IoC None published. No exploitation has been reported, so there are no victim-side artifacts yet — Patchstack via The Hacker News
IoA A REST request whose URI contains elementor/v1/events/ anywhere, including inside a query string, that performs a user-creation or user-modification action Web access logs, WordPress audit plugin Patchstack (mechanism); pattern is ours
IoA A new administrator account whose creation matches no change record WordPress user list, audit log Ours
IoA New application passwords or API keys on administrator accounts WordPress user profiles Ours

What

Per Patchstack's disclosure (researcher Saggre), carried by The Hacker News on 26 September: the Editor Events module skips the REST nonce check for cookie-authenticated requests whenever elementor/v1/events/ appears anywhere in the URI. Query strings are attacker-controlled, so the string can be appended to any REST request, extending the bypass across the REST API. Result: an unauthenticated attacker's link, clicked by a logged-in administrator, creates a rogue administrator — no form, no JavaScript, deliverable by email, chat or site comment. Patchstack scores it 8.8; the article says it "has yet to be assigned a CVE identifier". Affected 4.3.0 and 4.3.1; fixed in 4.3.2, released earlier in the week. Elementor is reported on over 10 million sites, more than 2 million on the affected versions. No exploitation reported.

So what

The window is narrow and patched, but 2 million sites is a large target now the mechanism is public. The broader lesson is the bug class: a security check that decides whether to apply itself by pattern-matching the URI can be switched off by whoever controls the URI. Worth grepping your own code for. It also needs a click, which makes it a phishing problem — and marketing-site administrators are rarely the best-trained population.

Now what — P (Preparation; analysis)

Hunt pack — run it, then act on what it says

Patchstack published no indicators for this flaw, so these hunts implement the one published mechanism (the string elementor/v1/events/ anywhere in a request URI, including a query string) and the version and account checks the item describes. The matching logic and the account checks are our analysis. The two bash scripts were run against synthetic data (the WP-CLI script against a stand-in wp command, so that run tests the script's logic and not WP-CLI itself); the Sigma rule was parsed and converted to Splunk, Kusto and Lucene queries with pySigma; the SPL and KQL queries were reviewed, not executed against a live platform, so test them on a non-production index first. The string match cannot tell normal editor traffic (our assumption; the disclosure does not say what normal use looks like) from an attack: a hit says the string was present, not that an action succeeded, so the account checks carry the weight. The log script tests the request field of combined-format logs only, so the string in a Referer or User-Agent is not counted (a line with no quoted request field is searched whole). The Sigma rule lists both c-uri and c-uri-query; how your backend maps those two field names is unverified. The WP-CLI script treats a pre-release version such as 4.3.1-beta as that version (our reading) and, on a multisite install, tells you to check super admins separately. We are not offering Suricata, Snort or YARA: WordPress admin traffic is normally TLS, and no file or sample was published. Adjust field and table names to your logging. The scripts print up to 300 characters of each matching line, and logs can carry tokens in URLs: redact before sharing output.

bash — web access logs (Apache, nginx or similar, plain or .gz); exit 0 = no hits, 1 = hits, 3 = nothing readable (not a clean result)
#!/usr/bin/env bash
# Usage: ./elementor-hunt.sh /path/to/access.log*   (plain or .gz)
set -uo pipefail
export LC_ALL=C   # byte-wise matching: a stray non-UTF-8 byte must not stop awk
[ $# -ge 1 ] || { echo "usage: $0 LOGFILE..." >&2; exit 2; }
files=(); skipped=0
for f in "$@"; do
  if [ -f "$f" ] && [ -r "$f" ]; then files+=("$f"); else echo "skipped (not a readable file): $f" >&2; skipped=$((skipped + 1)); fi
done
[ ${#files[@]} -gt 0 ] || { echo "scanned=0 hits=0 (no readable input: NOT a clean result)"; exit 3; }
gzip -dcf -- "${files[@]}" | awk '
  { n++; k = split($0, a, "\""); r = tolower((k >= 3) ? a[2] : $0); p = index(r, "elementor/v1/events/") }
  p > 0 {
    h++
    q = index(r, "?")
    where = (q > 0 && q < p) ? "query-string" : "path"
    split(a[3], s, " ")
    st = (k >= 3 && s[1] ~ /^[0-9][0-9][0-9]$/) ? s[1] : "?"
    if (substr(st, 1, 1) == "2") ok2++
    printf "HIT status=%s where=%s %s\n", st, where, substr($0, 1, 300)
  }
  END {
    printf "scanned=%d hits=%d hits_2xx=%d\n", n, h, ok2
    if (n == 0) exit 3
    if (h > 0) exit 1
    exit 0
  }'
st=("${PIPESTATUS[@]}")
if [ "${st[0]}" -ne 0 ] && [ "${st[1]}" -eq 0 ]; then
  echo "one or more inputs could not be decompressed: NOT a clean result" >&2; exit 3
fi
if [ "${st[1]}" -eq 0 ] && [ "$skipped" -gt 0 ]; then
  echo "$skipped input(s) skipped: NOT a clean result" >&2; exit 3
fi
exit "${st[1]}"
bash — a WordPress install with WP-CLI, run as the site's user; read-only; exit 1 = affected Elementor version, 0 = version not in the published affected set (still compare the lists it prints), 3 = nothing could be read
#!/usr/bin/env bash
# Usage: ./elementor-wp-state.sh /path/to/wordpress
set -uo pipefail
[ $# -eq 1 ] || { echo "usage: $0 WP_PATH" >&2; exit 2; }
command -v wp >/dev/null 2>&1 || { echo "wp-cli not found: nothing checked (NOT a clean result)"; exit 3; }
W=(wp --path="$1" --skip-plugins --skip-themes)
ver=$("${W[@]}" plugin get elementor --field=version 2>/dev/null) || {
  echo "could not read the Elementor version. If Elementor is not installed this item does not apply; otherwise this is NOT a clean result."; exit 3; }
echo "elementor_version=$ver"
affected=0
case "$ver" in 4.3.0|4.3.0-*|4.3.1|4.3.1-*) affected=1; echo "AFFECTED: 4.3.0 and 4.3.1 are the published affected versions (a pre-release suffix is treated as that version); update to 4.3.2 or later";; esac
if "${W[@]}" core is-installed --network >/dev/null 2>&1; then echo "multisite: check super admins separately (wp super-admin list); the list below is this site's administrators only"; fi
admins=$("${W[@]}" user list --role=administrator --fields=ID,user_login,user_registered --format=csv 2>/dev/null) || {
  echo "could not list administrators: NOT a clean result"; exit 3; }
echo "administrators (compare every row with your change records):"
echo "$admins" | tail -n +2
echo "administrator_count=$(echo "$admins" | tail -n +2 | grep -c .)"
echo "application passwords on administrators:"
apw=0; apwfail=0
for id in $(echo "$admins" | tail -n +2 | cut -d, -f1); do
  rows=$("${W[@]}" user application-password list "$id" --fields=name,created,last_used --format=csv 2>/dev/null) || { apwfail=$((apwfail + 1)); continue; }
  rows=$(echo "$rows" | tail -n +2)
  [ -n "$rows" ] && { echo "user $id:"; echo "$rows"; apw=$((apw + $(echo "$rows" | grep -c .))); }
done
echo "application_password_count=$apw"
if [ "$apwfail" -gt 0 ]; then
  echo "could not list application passwords for $apwfail administrator(s): NOT a clean result"
  [ "$affected" -eq 1 ] || exit 3
fi
exit "$affected"
Sigma — web server logs (the rule matches the string only; it is a lead, not a verdict)
title: Elementor Editor Events REST path in a request URI (CSRF nonce bypass, 4.3.0 and 4.3.1)
id: 54541ffb-bf38-496f-88d8-a6342abe5f64
status: experimental
description: A request whose URI contains elementor/v1/events/ anywhere, including inside a query string. Per Patchstack's disclosure, that string makes the affected versions skip the REST nonce check for cookie-authenticated requests. A match is a lead to review, not proof of an attack.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: webserver
detection:
  # Backend mapping of c-uri and c-uri-query is unverified; keep whichever your connector provides
  selection:
    - c-uri|contains: 'elementor/v1/events/'
    - c-uri-query|contains: 'elementor/v1/events/'
  condition: selection
falsepositives:
  - Normal use of the Elementor editor by your own administrators (our assumption; the disclosure does not say what normal use looks like)
  - Vulnerability scanners you run
level: medium
Splunk SPL — web access logs in an index named web with access_combined-style extractions (clientip, method, uri_path, uri_query, status); change names to match yours
index=web "elementor/v1/events/"
| eval where=if(match(coalesce(uri_query,""), "(?i)elementor/v1/events/"), "query-string", "path")
| fillnull value="-" clientip uri_path
| stats count min(_time) as first max(_time) as last values(method) as methods values(status) as statuses by clientip uri_path where
| eval first=strftime(first, "%F %T"), last=strftime(last, "%F %T")
| sort - count
Microsoft Sentinel / Log Analytics KQL — IIS logs (W3CIISLog); for other web servers change the table and the field names
W3CIISLog
| where TimeGenerated > ago(30d)
| where csUriStem contains "elementor/v1/events/" or csUriQuery contains "elementor/v1/events/"
| extend Where = iff(csUriQuery contains "elementor/v1/events/", "query-string", "path")
| summarize Requests = count(), First = min(TimeGenerated), Last = max(TimeGenerated),
            Methods = make_set(csMethod), Statuses = make_set(scStatus)
            by cIP, csUriStem, Where
| order by Requests desc
If you see Do this
The version check reports 4.3.0 or 4.3.1 Update to 4.3.2 or later now, then run the account checks: the update closes the flaw but does not remove an account that was already created.
An administrator that matches no change record, or an application password on an administrator that matches no record Confirmed compromise for the administrator. Treating an application password with no record the same way is our extension. Remove it, rotate all administrator passwords and the site's salts, audit plugins and theme files against a known-good state (not only the account), and review what the account did.
Log hits, but every administrator and application password matches your records Treat as probing or normal editor traffic (our assumption; the disclosure does not say what normal use looks like) until shown otherwise, because the match is on a string and not on an outcome. Update, keep the logs, and compare plugin and theme files with a known-good copy.
Hits that come from your own administrators' editor sessions or from a scanner you operate Confirm with the owner, record it, and exclude that source next time.
The script exits 3, no web logs exist, or WP-CLI cannot load the site This is not a clean result. Find where the logs went, read the administrator list in the dashboard by hand, and rely on the update.
No hits A good sign, not an exoneration. Only the published mechanism was searched: encoded spellings of the string are not matched, the log script reads the request field only (a string that appears only in another field is not counted), a proxy or CDN may log a different URI than the one WordPress received, logs rotate, and a rogue account that was created and then removed leaves no account to find. Update and review administrators anyway.

7. Grav: an unauthenticated file write, and a version number we will not guess at

Bottom line

CVE-2026-42608 is an unauthenticated path traversal and arbitrary file write in the FormFlash component of the Grav flat-file CMS, back in the news because it was reportedly used against a third party's site. Do now: move to a patched release, taking the exact version from Grav's own releases page. Skip if: your CMS is not Grav.

Indicators

Type Indicator Where to look Source
IoC None published. Public reporting describes the flaw's use against a third party and publishes no indicators — CVE record; press reporting
IoA POST requests containing directory-traversal sequences in a form-flash identifier parameter. The CVE record names __form-flash-id; some coverage named a different parameter, so match on traversal in any form-flash parameter rather than on one spelling Web access logs, WAF logs CVE record (parameter); pattern is ours
IoA index.yaml files, or directories, appearing outside the expected Grav content tree Filesystem, file integrity monitoring Ours — derived from the CVE record's described impact
IoA Traversal sequences in POST bodies specifically, not only query strings WAF logs Ours — many rule sets inspect only the query string

What

The CVE record (GitHub CNA), "Grav: Unauthenticated Path Traversal & Arbitrary File Write in FormFlash component": an unauthenticated attacker manipulates __form-flash-id in POST requests to traverse the filesystem, create arbitrary directories and write index.yaml files with attacker-controlled content — unauthorized modification, loss of integrity, service disruption. CVSS 4.0 8.8, CWE-22. Affected: Grav below 2.0.0-beta.2; patched from 2.0.0-beta.2. Published 11 May 2026.

One figure we are not quoting. Grav’s own advisory (GHSA-hmcx-ch82-3fv2) lists 1.7.53.4 as the patched 1.7-branch release, but that release does not appear on Grav’s releases page, whose latest 1.7 release is 1.7.53.2 (30 June). Press coverage also named a different parameter; the advisory explains that __form-flash-id maps to the session ID. On the 1.7 branch, confirm the release you install actually contains the fix before you rely on it.

So what

A pre-authentication write on a flat-file CMS, where content and configuration are both files, is most of the way to persistence. And an advisory that lists the fix only on a beta of the next major version leaves stable-branch users without an obvious action for months — "patched" does not always mean patched on your version.

Now what — P (Preparation; analysis)

Hunt pack — run it, then act on what it says

These hunts implement the published indicators for CVE-2026-42608: traversal sequences in a form-flash identifier parameter (the CVE record names __form-flash-id; some coverage named another spelling, so the hunts match form-flash and traversal anywhere in the line), and index.yaml files outside the expected content tree and outside Grav's own temporary form-state folder (tmp/forms, where we believe Grav keeps FormFlash state; that path is from memory of the source and unconfirmed, so check your install and pass it to the file script as an extra expected directory once you have). The matching logic is our analysis. The log script flags the whole line, so a hit can be in a Referer or another field, not only in the request: read the line. The Sigma rule lists both c-uri and c-uri-query; how your backend maps those names is unverified. The log script was run against synthetic data on hit, clean, empty, unreadable, compressed and truncated-archive cases; the file script on inside, outside, missing-directory, unreadable-folder, failed-Git and Git cases; the Sigma rule was parsed and converted to Splunk, Kusto and Lucene queries with pySigma; the SPL and KQL queries were reviewed, not executed against a live platform, so test them on a non-production index first. Two limits apply to every log hunt here: a POST body appears in a log only if your WAF or application records bodies, and the item's own table says many rule sets inspect only the query string. We do not offer a version-check script: the page does not quote a confirmed fix for the 1.7 branch, so the version comes from Grav's releases page, not from us. We are not offering Suricata, Snort or YARA: traversal in a POST body is usually inside TLS, and no sample or string was published. Adjust field and table names to your logging. The scripts print up to 300 characters of each matching line, and logs can carry tokens in URLs: redact before sharing output.

bash — web server or WAF logs (plain or .gz); exit 0 = no hits, 1 = hits, 3 = nothing readable (not a clean result)
#!/usr/bin/env bash
# Usage: ./grav-hunt.sh /path/to/access-or-waf.log*   (plain or .gz)
set -uo pipefail
[ $# -ge 1 ] || { echo "usage: $0 LOGFILE..." >&2; exit 2; }
files=(); skipped=0
for f in "$@"; do
  if [ -f "$f" ] && [ -r "$f" ]; then files+=("$f"); else echo "skipped (not a readable file): $f" >&2; skipped=$((skipped + 1)); fi
done
[ ${#files[@]} -gt 0 ] || { echo "scanned=0 hits=0 (no readable input: NOT a clean result)"; exit 3; }
gzip -dcf -- "${files[@]}" | python3 -c '
import re, sys
from urllib.parse import unquote
trav = re.compile(r"\.\.[/\\]|[/\\]\.\.")     # ".." next to / or \
lines = flash = hits = 0
for raw in sys.stdin.buffer:
    lines += 1
    s = raw.decode("utf-8", "replace")
    d = unquote(unquote(s))                    # up to two decoding passes
    if "form-flash" not in d.lower():
        continue
    flash += 1
    if trav.search(d):
        hits += 1
        print("HIT " + s.strip()[:300])
print("scanned=%d form_flash_lines=%d hits=%d" % (lines, flash, hits))
sys.exit(3 if lines == 0 else (1 if hits else 0))'
st=("${PIPESTATUS[@]}")
if [ "${st[0]}" -ne 0 ] && [ "${st[1]}" -eq 0 ]; then
  echo "one or more inputs could not be decompressed: NOT a clean result" >&2; exit 3
fi
if [ "${st[1]}" -eq 0 ] && [ "$skipped" -gt 0 ]; then
  echo "$skipped input(s) skipped: NOT a clean result" >&2; exit 3
fi
exit "${st[1]}"
bash — the Grav install on disk; lists index.yaml files outside the expected content directories, then any uncommitted changes if the install is under Git; exit 0 = none outside, 1 = some outside, 3 = nothing scanned (not a clean result)
#!/usr/bin/env bash
# Usage: ./grav-yaml-hunt.sh GRAV_ROOT EXPECTED_CONTENT_DIR...
# Example: ./grav-yaml-hunt.sh /path/to/grav /path/to/grav/<your content directory>
# Grav's own temporary form-state folder (tmp/forms; FormFlash state, from memory of the source, unconfirmed: check your install) holds index.yaml files too.
# To exclude it, pass it as another expected directory:
#   ./grav-yaml-hunt.sh /path/to/grav /path/to/grav/<your content directory> /path/to/grav/tmp/forms
# Anything an attacker wrote under an excluded directory is then not listed.
set -uo pipefail
[ $# -ge 2 ] || { echo "usage: $0 GRAV_ROOT EXPECTED_CONTENT_DIR..." >&2; exit 2; }
root=$1; shift
[ -d "$root" ] && [ -r "$root" ] || { echo "scanned=0 hits=0 (root not readable: NOT a clean result)"; exit 3; }
exp=()
for d in "$@"; do
  r=$(cd "$d" 2>/dev/null && pwd -P) || { echo "expected dir not readable: $d (NOT a clean result)" >&2; exit 3; }
  exp+=("$r/")
done
scanned=0; hits=0
err=$(mktemp) || exit 3
while IFS= read -r -d '' f; do
  scanned=$((scanned + 1))
  dir=$(cd "$(dirname "$f")" && pwd -P)/
  inside=0
  for e in "${exp[@]}"; do case "$dir" in "$e"*) inside=1;; esac; done
  if [ "$inside" -eq 0 ]; then hits=$((hits + 1)); echo "OUTSIDE $f"; fi
done < <(find "$root" -type f -name index.yaml -print0 2>"$err")
unread=$(grep -c . "$err"); [ "$unread" -eq 0 ] || { echo "unreadable paths (first 5):" >&2; head -5 "$err" >&2; }
rm -f "$err"
echo "scanned=$scanned hits=$hits unreadable=$unread"
gitfail=0
if [ -e "$root/.git" ] && command -v git >/dev/null 2>&1; then
  gs=$(git --no-optional-locks -C "$root" status --porcelain --untracked-files=all 2>&1); grc=$?
  if [ "$grc" -ne 0 ]; then echo "git status failed (exit $grc): NOT a clean result"; echo "$gs" | head -3; gitfail=1
  else echo "git status (review every line against a known change):"; echo "$gs" | head -200; fi
fi
[ "$hits" -eq 0 ] || exit 1
[ "$scanned" -gt 0 ] || { echo "no index.yaml found under the root: wrong root? NOT a clean result"; exit 3; }
[ "$unread" -eq 0 ] && [ "$gitfail" -eq 0 ] || { echo "part of the tree or the git check could not be read: NOT a clean result"; exit 3; }
exit 0
Sigma — web server or WAF logs; matches URI and query string only (traversal inside a POST body needs a body field from your WAF)
title: Traversal sequence in a Grav form-flash request parameter (CVE-2026-42608)
id: 948aab2a-ad9a-4714-823a-6edc54100548
status: experimental
description: A request whose URI or query string contains form-flash together with a traversal sequence, plain or percent-encoded. The CVE record names the __form-flash-id parameter; some coverage named another spelling, so the rule matches form-flash anywhere.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: webserver
detection:
  # Backend mapping of c-uri and c-uri-query is unverified; keep whichever your connector provides
  selection_param:
    - c-uri|contains: 'form-flash'
    - c-uri-query|contains: 'form-flash'
  selection_traversal:
    - c-uri|contains:
        - '../'
        - '..\'
        - '/..'
        - '..%2f'
        - '..%5c'
        - '%2e%2e'
        - '..%252f'
        - '%252e%252e'
    - c-uri-query|contains:
        - '../'
        - '..\'
        - '/..'
        - '..%2f'
        - '..%5c'
        - '%2e%2e'
        - '..%252f'
        - '%252e%252e'
  condition: selection_param and selection_traversal
falsepositives:
  - Vulnerability scanners you run
level: medium
Splunk SPL — a web or WAF index named web whose events keep the raw request line (and the body, if the WAF logs it); change index and field names to yours
index=web "form-flash"
| eval decoded=urldecode(urldecode(_raw))
| where match(decoded, "(?i)form-flash") AND match(decoded, "\.\.[/\\\\]|[/\\\\]\.\.")
| fillnull value="-" host clientip
| stats count min(_time) as first max(_time) as last values(status) as statuses by host clientip
| eval first=strftime(first, "%F %T"), last=strftime(last, "%F %T")
Microsoft Defender XDR / Sentinel KQL — advanced hunting (DeviceFileEvents, Linux and Windows onboarded hosts): index.yaml created or changed outside the expected content tree; replace the placeholder path
let expected = dynamic(["/replace/with/your/content/directory/"]);
DeviceFileEvents
| where Timestamp > ago(30d)
| where FileName == "index.yaml"
| where ActionType in ("FileCreated", "FileModified")
| where not(FolderPath has_any (expected)) and FolderPath !has "/tmp/forms/"   // Grav's form-state folder; verify the path on your install (Windows paths use backslashes)
| project Timestamp, DeviceName, FolderPath, FileName, ActionType, InitiatingProcessFileName, InitiatingProcessCommandLine
| order by Timestamp desc
If you see Do this
An index.yaml outside the expected content tree and outside Grav's own temporary form-state folder (tmp/forms), or (our extension) a Git change nobody made Treat as a confirmed unauthorized write. Preserve it, restore the tree from source control, and rotate anything the configuration held.
A traversal sequence next to form-flash in a log line, with no unexplained file Treat as an attempt. Confirm the install is on a release the project identifies as patched (take the version from Grav's releases page), stop the web user writing outside the directories Grav needs, and confirm the WAF decodes and rejects traversal in POST bodies as well as query strings.
An index.yaml outside the expected tree that your own deployment process created Record the process and the path, add the path to the expected list, and run the hunt again.
A scanner you operate sending traversal strings Confirm with its owner, record it, and exclude that source next time.
The scripts exit 3, the logs hold no bodies, or no logs exist This is not a clean result. Find where logs go, turn on body logging in the WAF if you can, and rely on the tree diff against source control and on the update.
No hits A good sign, not an exoneration. Logs rotate, most logs never contain a POST body, so a traversal in a body can pass through a clean-looking log, a different parameter spelling may exist than the ones we match, and an attacker can overwrite or remove what they wrote. Only the published indicators were searched. Patch state and the content diff against source control are the stronger evidence.

8. Two compromised GitHub Actions came back online with their malicious tags intact

Bottom line

Socket reported on 24 September that actions-cool/issues-helper and actions-cool/maintain-one-comment, disabled by GitHub in May after a supply-chain compromise, were re-enabled on 16 September with release tags still pointing at the May payload — so workflows referencing them by tag resumed executing it. Do now: find either action in your workflows, replace tags with commit SHAs predating 18 May or remove them, and rotate every secret those workflows could reach. Skip if: you use neither — though the pinning lesson applies to every third-party action.

Indicators

Type Indicator Where to look Source
IoC Actions actions-cool/issues-helper and actions-cool/maintain-one-comment referenced by a version tag; all release tags resolved to the 18 May 2026 malicious content as of publication Workflow files, Actions run history Socket, 24 Sep 2026
IoC Exfiltration domain t.m-kosche[.]com — listed in Socket’s May analysis of the Mini Shai-Hulud payload; tied to these actions by The Hacker News DNS resolver logs, runner egress logs The Hacker News, 25 Sep 2026
IoA Payload execution via bun run $GITHUB_ACTION_PATH/index.js within either action Actions run logs Socket, 24 Sep 2026
IoA Workflow runs since 16 September 2026 with anomalous duration, or commits you cannot account for, especially from 16 September onward Actions run history, repository audit log Socket, 24 Sep 2026

What

Socket's timeline: payload introduced 18 May 2026; repositories disabled 19 May; re-enabled 16 September 2026 between 09:09 and 16:16 UTC. In Socket's words, "They still point to the malicious content introduced on May 18, so any workflow that references either action by a version tag resumed downloading and executing the payload on its next run." The payload runs via bun run $GITHUB_ACTION_PATH/index.js. As of publication, every release tag for both resolved to malicious content. Socket's post publishes no network indicators or hashes and refers readers to its May analysis; the exfiltration domain above comes from The Hacker News, with that caveat.

So what

Nothing was published or reconfigured — the repositories became reachable again and a mutable reference did what mutable references do. @v1 is a promise from whoever controls the tag, not a property of the code. For four months, GitHub's disablement protected those who did nothing, and that protection ended without notice. A malicious action runs with whatever the workflow can reach — often registry tokens, cloud deployment credentials and publishing rights — so treat any run between 16 September and the day you pin as having leaked its secrets.

Now what — P → I (analysis)

Hunt pack — run it, then act on what it says

These hunts implement what Socket and The Hacker News published: the two action names, the bun run $GITHUB_ACTION_PATH/index.js execution, the exfiltration domain, and the 16 September re-enable date. The matching logic is our analysis. The domain comes from The Hacker News, which tied it to these actions from Socket's May analysis; Socket's own post published no network indicators or hashes. Both bash scripts were run against synthetic data (hit, clean, empty and unreadable inputs, a compressed log, a truncated archive, a log holding an invalid UTF-8 byte, a folder holding an unextracted zip, and folders and files that cannot be read); the two Sigma rules were parsed and converted to Splunk, Kusto and Lucene queries with pySigma; the PowerShell, SPL and KQL were reviewed, not executed against a live platform, 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 in queries; the scripts that run as-is do it for you. No script here can tell whether a pinned commit predates 18 May 2026: that check is yours, against the repository's history. The workflow-file script also counts a commented-out uses: line, and it cannot see a composite action in another repository that calls either action. In the run-log script only the literal payload command, or bun run with an actions-cool path and index.js, counts as a hit; any other bun run with index.js is printed as a weak REVIEW line and is not counted. The expanded-path match is our inference from how runners lay out downloaded actions; the published string is only the literal. We are not offering Suricata, Snort, osquery or YARA: egress to the domain is covered by the DNS and network queries, and no payload hash or string was published. The scripts print up to 300 characters of each matching line, and logs can carry tokens in URLs: redact before sharing output.

bash — repositories or checkouts on any OS; finds workflow references to either action and says whether each is pinned to a commit or to a mutable tag or branch; exit 0 = none found, 1 = found, 3 = nothing scanned (not a clean result)
#!/usr/bin/env bash
# Usage: ./actions-hunt.sh REPO_DIR...
# Limits: a commented-out `uses:` line still counts as a hit; composite actions in other repositories are not visible here.
set -uo pipefail
[ $# -ge 1 ] || { echo "usage: $0 REPO_DIR..." >&2; exit 2; }
scanned=0; hits=0; mutable=0; skipped=0
err=$(mktemp) || exit 3
for dir in "$@"; do
  [ -d "$dir" ] && [ -r "$dir" ] || { echo "skipped (not a readable directory): $dir" >&2; skipped=$((skipped + 1)); continue; }
  while IFS= read -r -d '' f; do
    [ -r "$f" ] || { echo "skipped (unreadable file): $f" >&2; skipped=$((skipped + 1)); continue; }
    scanned=$((scanned + 1))
    while IFS= read -r m; do
      [ -n "$m" ] || continue
      hits=$((hits + 1))
      ref=${m#*@}
      if [ "$ref" = "$m" ]; then kind="NO-REF"
      elif echo "$ref" | grep -Eq '^[0-9a-f]{40}$'; then kind="SHA-PINNED (verify it predates 18 May 2026)"
      else kind="MUTABLE tag or branch '$ref'"; mutable=$((mutable + 1)); fi
      echo "HIT $f: ${m%%@*}  $kind"
    done < <(grep -hioE 'actions-cool/(issues-helper|maintain-one-comment)(@[^[:space:]"'"'"']*)?' "$f" 2>/dev/null)
  done < <(find "$dir" \( -name node_modules -o -name .git \) -prune -o -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 2>>"$err")
done
n=$(grep -c . "$err"); rm -f "$err"
[ "$n" -eq 0 ] || { echo "$n unreadable path(s) while searching (permission denied)" >&2; skipped=$((skipped + n)); }
echo "scanned=$scanned hits=$hits mutable=$mutable skipped=$skipped"
[ "$hits" -eq 0 ] || exit 1
[ "$scanned" -gt 0 ] || { echo "no workflow files read: NOT a clean result"; exit 3; }
[ "$skipped" -eq 0 ] || { echo "$skipped input(s) skipped: NOT a clean result"; exit 3; }
exit 0
bash — downloaded GitHub Actions run logs (plain text or .gz, files or folders); finds the payload command, the exfiltration domain and either action in a run; exit 0 = none, 1 = found, 3 = nothing scanned, or any file was unreadable, truncated or a zip (not a clean result)
#!/usr/bin/env bash
# Usage: ./actions-logs-hunt.sh LOG_OR_DIR...   (extract any downloaded .zip log archive first)
set -uo pipefail
export LC_ALL=C   # byte-wise matching: a stray non-UTF-8 byte must not stop awk
[ $# -ge 1 ] || { echo "usage: $0 LOG_OR_DIR..." >&2; exit 2; }
dom='t.m-kosche[.]com'; dom=${dom//\[.\]/.}      # refang at run time
scanned=0; hits=0; review=0; skipped=0
list=()
for p in "$@"; do
  if [ -d "$p" ] && [ -r "$p" ]; then
    while IFS= read -r -d '' d; do
      { [ -r "$d" ] && [ -x "$d" ]; } || { echo "skipped (unreadable folder): $d" >&2; skipped=$((skipped + 1)); }
    done < <(find "$p" -type d -print0 2>/dev/null)
    while IFS= read -r -d '' f; do
      if [ -r "$f" ]; then list+=("$f"); else echo "skipped (unreadable file): $f" >&2; skipped=$((skipped + 1)); fi
    done < <(find "$p" -type f -print0 2>/dev/null)
  elif [ -f "$p" ] && [ -r "$p" ]; then list+=("$p")
  else echo "skipped (not readable): $p" >&2; skipped=$((skipped + 1)); fi
done
for f in ${list[@]+"${list[@]}"}; do
  if [ "$(head -c 4 < "$f" 2>/dev/null | od -An -tx1 | tr -d ' \n')" = "504b0304" ]; then
    echo "skipped (zip archive, extract first: NOT a clean result): $f" >&2; skipped=$((skipped + 1)); continue
  fi
  out=$(gzip -dcf -- "$f" 2>/dev/null | awk -v F="$f" -v dom="$dom" '
    { n++; l = tolower($0); s = 0 }
    l ~ /bun run[^|;&]*github_action_path[}]?\/index\.js/ { h++; s = 1; printf "HIT payload-literal %s:%d: %s\n", F, NR, substr($0, 1, 200) }
    s == 0 && l ~ /bun run/ && l ~ /actions-cool/ && l ~ /index\.js/ { h++; s = 1; printf "HIT payload-actions-cool-path %s:%d: %s\n", F, NR, substr($0, 1, 200) }
    s == 0 && ((l ~ /bun run/ && l ~ /index\.js/) || index(l, "github_action_path/index.js") > 0) { w++; printf "REVIEW weak-payload-match (any Bun project looks like this) %s:%d: %s\n", F, NR, substr($0, 1, 200) }
    { q = l; off = 0; found = 0
      while ((p = index(q, dom)) > 0) {
        pos = off + p; e = pos + length(dom)
        if ((pos == 1 || substr(l, pos - 1, 1) !~ /[a-z0-9-]/) && (e > length(l) || substr(l, e, 1) !~ /[a-z0-9-]/)) { found = 1; break }
        q = substr(q, p + 1); off += p } }
    found { h++; printf "HIT exfil-domain %s:%d: %s\n", F, NR, substr($0, 1, 200) }
    l ~ /actions-cool\/(issues-helper|maintain-one-comment)/ { h++; printf "HIT action-in-run %s:%d: %s\n", F, NR, substr($0, 1, 200) }
    END { printf "COUNT %d %d %d\n", n, h, w }'; echo "GZ ${PIPESTATUS[0]}")
  gz=$(echo "$out" | grep '^GZ ' | tail -n 1 | cut -d' ' -f2)
  [ "${gz:-1}" = "0" ] || { echo "cannot read to the end (corrupt or truncated archive): $f" >&2; skipped=$((skipped + 1)); }
  c=$(echo "$out" | grep '^COUNT ' | tail -n 1)
  [ -n "$c" ] || { echo "scan did not finish (awk error): $f" >&2; skipped=$((skipped + 1)); }
  echo "$out" | grep -vE '^(COUNT|GZ) '
  n=$(echo "$c" | cut -d' ' -f2); h=$(echo "$c" | cut -d' ' -f3); w=$(echo "$c" | cut -d' ' -f4)
  scanned=$((scanned + ${n:-0})); hits=$((hits + ${h:-0})); review=$((review + ${w:-0}))
done
echo "scanned=$scanned hits=$hits review=$review skipped=$skipped"
[ "$hits" -eq 0 ] || exit 1
[ "$scanned" -gt 0 ] || { echo "no log lines read: NOT a clean result"; exit 3; }
[ "$skipped" -eq 0 ] || { echo "$skipped input(s) skipped or unreadable: NOT a clean result"; exit 3; }
exit 0
PowerShell — Windows developer or build hosts with repository checkouts; same check as the first bash script (reviewed, not executed); exit 0 = none, 1 = found, 3 = nothing scanned
# Usage: .\actions-hunt.ps1 -Path C:\src
param([Parameter(Mandatory)][string[]]$Path)
$pat = 'actions-cool/(issues-helper|maintain-one-comment)(@[^\s"'']*)?'
$files = 0; $hits = 0; $mutable = 0; $skipped = 0; $gce = @(); $sse = @()
foreach ($p in $Path) {
  if (-not (Test-Path -LiteralPath $p -PathType Container)) { Write-Warning "skipped (not a folder): $p"; $skipped++; continue }
  Get-ChildItem -LiteralPath $p -Recurse -File -Include *.yml, *.yaml -ErrorAction SilentlyContinue -ErrorVariable +gce |
    Where-Object { $_.FullName -notmatch '[\\/](node_modules|\.git)[\\/]' } | ForEach-Object {
      $files++
      foreach ($m in (Select-String -LiteralPath $_.FullName -Pattern $pat -AllMatches -ErrorAction SilentlyContinue -ErrorVariable +sse)) {
        foreach ($x in $m.Matches) {
          $hits++
          $ref = if ($x.Value -match '@(.+)$') { $Matches[1] } else { $null }
          $kind = if (-not $ref) { 'NO-REF' } elseif ($ref -match '^[0-9a-f]{40}$') { 'SHA-PINNED (verify it predates 18 May 2026)' } else { $mutable++; "MUTABLE tag or branch '$ref'" }
          '{0}:{1}: {2}  {3}' -f $_.FullName, $m.LineNumber, ($x.Value -split '@')[0], $kind
        }
      }
    }
}
Write-Host "scanned=$files hits=$hits mutable=$mutable"
$skipped += @($gce).Count + @($sse).Count
Write-Host "skipped=$skipped"
if ($files -eq 0 -or ($hits -eq 0 -and $skipped -gt 0)) { exit 3 } elseif ($hits -gt 0) { exit 1 } else { exit 0 }
Sigma — DNS query logs (resolver, EDR or Sysmon-style DNS events; field names vary by connector); remove the brackets
# Remove the brackets before use: [.] becomes .
title: DNS lookup of the exfiltration domain tied to the re-enabled actions-cool actions
id: 5e7ab776-095f-4b0d-95d7-c0633eab2d0e
status: experimental
description: A DNS query for t.m-kosche[.]com (remove the brackets), the domain The Hacker News tied to actions-cool/issues-helper and actions-cool/maintain-one-comment from Socket's May analysis. A single published domain; a match is a strong lead, and no match proves little. This is second-hand attribution. The domain is listed in Socket's May analysis of the Mini Shai-Hulud payload and was tied to these actions by The Hacker News, and Socket's post on these actions published no network indicators.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: dns
detection:
  selection:
    - query: 't.m-kosche[.]com'
    - query|endswith: '.t.m-kosche[.]com'
  condition: selection
falsepositives:
  - Researchers or security tools resolving the domain on purpose
level: medium
Sigma — process creation on self-hosted runners (hosted runners are not visible to you; Bun in normal use will also match the second selection, so review the path)
title: Bun running an index.js from the actions-cool actions
id: 4176eecc-1421-4022-8597-8064c6cb219a
status: experimental
description: The published payload execution is bun run $GITHUB_ACTION_PATH/index.js inside either action. The variable may be expanded before the process starts, so the rule matches both the literal and, as our inference from how runners lay out downloaded actions, a command line that names an actions-cool path with index.js. The published string is only the literal.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: process_creation
detection:
  selection_literal:
    CommandLine|contains|all:
      - 'bun run'
      - 'GITHUB_ACTION_PATH/index.js'
  selection_expanded:
    CommandLine|contains|all:
      - 'bun run'
      - 'actions-cool'
      - 'index.js'
  condition: selection_literal or selection_expanded
falsepositives:
  - Any legitimate action under an actions-cool path that runs an index.js with Bun.
level: medium
Splunk SPL — DNS or proxy data with a query or url field (field names vary by connector); remove the brackets from the domain before use (a comment line cannot go inside this block)
index=* "t.m-kosche[.]com"
| eval looked_up=coalesce(query, dest_host, url)
| where match(looked_up, "(?i)(^|[^a-z0-9-])t\.m-kosche\.com([^a-z0-9.-]|$)")
| fillnull value="-" src host
| stats count min(_time) as first max(_time) as last values(looked_up) as looked_up by src host
| eval first=strftime(first, "%F %T"), last=strftime(last, "%F %T")
Microsoft Defender XDR / Sentinel KQL — advanced hunting (DeviceNetworkEvents); Sentinel DnsEvents alternative in the comment; remove the brackets from the domain
// Remove the brackets before use: [.] becomes .
// Sentinel alternative: DnsEvents | where Name == "t.m-kosche[.]com" or Name endswith ".t.m-kosche[.]com" | project TimeGenerated, Computer, ClientIP, Name
// Broader alternative (any subdomain of the parent domain): | where RemoteUrl has "m-kosche[.]com"
DeviceNetworkEvents
| where Timestamp > ago(30d)
| where RemoteUrl has "t.m-kosche[.]com"
| project Timestamp, DeviceName, RemoteUrl, RemoteIP, InitiatingProcessFileName, InitiatingProcessCommandLine
| order by Timestamp desc
If you see Do this
Either action in a workflow, referenced by a tag or branch Replace it with a commit SHA you have verified predates 18 May 2026, or remove it. Then treat every run of it since 16 September as a secret disclosure (next row).
A completed run of either action after 16 September, in a workflow log or in run history A secret disclosure, not a possible one. Rotate first, investigate second: registry and package tokens, cloud credentials, deploy keys and signing keys reachable by that workflow. Then audit for commits you cannot account for and tell consumers of any package that workflow built.
The payload command bun run $GITHUB_ACTION_PATH/index.js (or its expansion under an actions-cool path) in a workflow log or on a runner, or a lookup of the exfiltration domain on a resolver Same response as above (our extension for the domain-lookup case, which rests on second-hand attribution), and preserve the runner's logs. A weak REVIEW line (any other bun run with index.js) is an ordinary Bun project until the path says otherwise. Find every other workflow and runner that resolved or ran the same thing.
A reference pinned to a SHA Check the commit against the repository history. Keep it only if it predates 18 May 2026; otherwise treat it as the tag case.
A lookup of the domain from a security tool or researcher you operate Confirm with the owner, record it, and exclude that source next time.
The scripts exit 3, run logs have expired, or no DNS data exists This is not a clean result. Fall back on the workflow files in source control and on the run history, and rotate anyway if a workflow could have run either action.
No hits A good sign, not an exoneration. Run logs expire, a workflow file may have been changed back after a run, only one domain and two action names were published and searched, hosted runners produce no process or DNS telemetry you can see, and the payload may have used other infrastructure. If any workflow referenced either action since 16 September, rotating its secrets is still the safe course.

9. Storm-2570: a ransomware affiliate that brings its own remote-management tools

Bottom line

Microsoft profiled Storm-2570 on 24 September — an affiliate tracked since April 2025 that has deployed Qilin, DragonForce, Anubis and BERT ransomware. There are no indicators to block; the value is a hunt list. Do now: decide which remote-management tools are sanctioned and alert on every other one. Skip if: nothing, though a mature software inventory means most of the work is done.

Indicators

Type Indicator Where to look Source
IoC None published. Microsoft's report is deliberately behavioral and discloses no hashes, addresses or domains — Microsoft Threat Intelligence, 24 Sep 2026
IoA MeshAgent binaries renamed after the victim organization, in the pattern meshagent64-[organization name].exe EDR, software inventory Microsoft Threat Intelligence, 24 Sep 2026
IoA A host list named @ip.txt used with PsExec, and a batch script named rdp.bat that enables TCP 3389 Filesystem, EDR, firewall change logs Microsoft Threat Intelligence, 24 Sep 2026
IoA A Cloudflare Tunnel service configured to run as LocalSystem; base64-encoded command obfuscation Service inventory, EDR Microsoft Threat Intelligence, 24 Sep 2026
IoA Unsanctioned remote-management agents — the report names Atera, MeshAgent, ScreenConnect, Splashtop, Remotely_Agent and NinjaRMM — alongside NetScan, Nmap, Mimikatz, LaZagne, pypykatz, ntdsutil, PsExec, Impacket, NetExec, s5cmd and Rclone EDR, software inventory, egress logs Microsoft Threat Intelligence, 24 Sep 2026

What

Microsoft: "the method through which Storm-2570 gains initial access remains unconfirmed"; observed chains show remote-management tooling and hands-on-keyboard activity. By stage: remote management via the agents in the table; discovery and lateral movement via NetScan, Nmap, PsExec, Impacket, NetExec and RDP scripts; credential theft via Mimikatz, LaZagne, pypykatz and ntdsutil, including dumping NTDS.dit; exfiltration via s5cmd and Rclone. No hashes, addresses or domains. Microsoft recommends tamper protection, MFA on approved remote-management systems, attack-surface-reduction rules and automatic attack disruption.

So what

Everything on the list is legitimate software, so hash blocking does nothing and inventory does everything. The renaming is the most useful detail: an agent carrying your organization's name is built to survive an administrator's glance and is trivial to catch by pattern. Rotation across four ransomware families means hunting "Qilin indicators" looks at the wrong layer — the tradecraft is stable; the payload is not.

Now what — P → I (analysis)

Hunt pack — run it, then act on what it says

Microsoft's report is behavioral and publishes no hashes, addresses or domains, so these hunts search for the behaviors it names: MeshAgent binaries renamed in the pattern meshagent64-<organization name>.exe, a host list @ip.txt used with PsExec, a batch script rdp.bat, a Cloudflare Tunnel service running as LocalSystem, and the named remote-management agents and tools. Every tool on that list is legitimate software, so the named-tool hunts show candidates to compare with your own sanctioned list; they are not verdicts. The matching logic is our analysis. Name matching: an agent name matches when no letter sits directly before it (so NinjaRMMAgent.exe, AteraAgent.exe and SplashtopStreamer.exe match, and lateral and unmapped do not); short tool names such as nmap need a non-letter on both sides; the Sigma rule matches a name that starts a path component (after a backslash). Name matching misses a renamed binary, a name with a letter directly in front of the agent name, and any agent whose file name carries none of the listed strings. The bash script was run against synthetic listings (hit, clean, look-alike names, the three real agent file names, empty, unreadable, compressed, a line holding an invalid UTF-8 byte); the five Sigma rules were parsed and converted to Splunk, Kusto and Lucene queries with pySigma; the PowerShell, SPL, KQL and osquery were reviewed, not executed against a live platform (osquery's Windows tables cannot run on the Mac we built on), so test them on a non-production system first. We are not offering YARA or Suricata: no sample, string or network value was published, and we are not offering a rule for base64-encoded commands, which is too common in ordinary administration to hunt on its own. The scripts print up to 300 characters of each matching line, and logs can carry tokens in URLs: redact before sharing output.

bash — text exports on any OS (file listings, software inventory, process lists, EDR exports; plain or .gz); set SANCTIONED to a regular expression of agent names you allow; exit 0 = no hits, 1 = hits, 3 = nothing readable (not a clean result)
#!/usr/bin/env bash
# Usage: SANCTIONED='ninjarmm|splashtop' ./storm2570-hunt.sh LISTING...
# Note: a line that matches SANCTIONED is not reported at all, even if it also names a tool (for example a sanctioned agent running mimikatz.exe).
set -uo pipefail
export LC_ALL=C   # byte-wise matching: a stray non-UTF-8 byte must not stop awk
[ $# -ge 1 ] || { echo "usage: $0 LISTING..." >&2; exit 2; }
files=(); skipped=0
for f in "$@"; do
  if [ -f "$f" ] && [ -r "$f" ]; then files+=("$f"); else echo "skipped (not a readable file): $f" >&2; skipped=$((skipped + 1)); fi
done
[ ${#files[@]} -gt 0 ] || { echo "scanned=0 hits=0 (no readable input: NOT a clean result)"; exit 3; }
gzip -dcf -- "${files[@]}" | awk -v sanc="${SANCTIONED:-^$}" '
  BEGIN {
    rmm  = "(^|[^a-z])(atera|meshagent|screenconnect|splashtop|remotely_agent|ninjarmm)"
    tool = "(^|[^a-z])(netscan|nmap|mimikatz|lazagne|pypykatz|ntdsutil|psexec|impacket|netexec|s5cmd|rclone)([^a-z]|$)"
  }
  { n++; l = tolower($0) }
  l ~ /meshagent64-/                       { m++; printf "MARKER renamed-meshagent: %s\n", substr($0, 1, 250); next }
  index(l, "@ip.txt") > 0                  { m++; printf "MARKER host-list: %s\n", substr($0, 1, 250); next }
  l ~ /(^|[^a-z0-9_])rdp\.bat/             { m++; printf "MARKER rdp-script: %s\n", substr($0, 1, 250); next }
  l ~ rmm  && l !~ sanc                    { r++; printf "RMM-AGENT (not in SANCTIONED): %s\n", substr($0, 1, 250); next }
  l ~ tool && l !~ sanc                    { t++; printf "NAMED-TOOL: %s\n", substr($0, 1, 250) }
  END {
    printf "scanned=%d markers=%d rmm_agents=%d named_tools=%d\n", n, m, r, t
    if (n == 0) exit 3
    if (m + r + t > 0) exit 1
    exit 0
  }'
st=("${PIPESTATUS[@]}")
if [ "${st[0]}" -ne 0 ] && [ "${st[1]}" -eq 0 ]; then
  echo "one or more inputs could not be decompressed: NOT a clean result" >&2; exit 3
fi
if [ "${st[1]}" -eq 0 ] && [ "$skipped" -gt 0 ]; then
  echo "$skipped input(s) skipped: NOT a clean result" >&2; exit 3
fi
exit "${st[1]}"
PowerShell — a Windows host, elevated; bounded to the roots listed (edit them); read-only; reviewed, not executed; exit 0 = none, 1 = found, 3 = nothing could be checked
# Usage: .\storm2570-hunt.ps1 [-Roots C:\Users,C:\ProgramData] [-Sanctioned 'ninjarmm|splashtop']
param([string[]]$Roots = @('C:\Users', 'C:\ProgramData'), [string]$Sanctioned = '^$')
$hits = 0; $checked = 0; $gce = @()
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Warning 'not elevated: NOT a clean result'; exit 3 }
$rmm  = '(^|[^a-z])(atera|meshagent|screenconnect|splashtop|remotely_agent|ninjarmm)'
$tool = '(^|[^a-z])(netscan|nmap|mimikatz|lazagne|pypykatz|ntdsutil|psexec|impacket|netexec|s5cmd|rclone)([^a-z]|$)'

# 1) Marker files: renamed MeshAgent, rdp.bat, @ip.txt
foreach ($r in $Roots) {
  if (-not (Test-Path -LiteralPath $r)) { Write-Warning "skipped: $r"; continue }
  $checked++
  Get-ChildItem -LiteralPath $r -Recurse -Force -File -ErrorAction SilentlyContinue -ErrorVariable +gce |
    Where-Object { $_.Name -like 'meshagent64-*.exe' -or $_.Name -ieq 'rdp.bat' -or $_.Name -ieq '@ip.txt' } |
    ForEach-Object { $hits++; "MARKER file: $($_.FullName)  $($_.LastWriteTime)" }
}

# 2) Services: a Cloudflare Tunnel service as LocalSystem; remote-management agents
$svc = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue
if ($svc) {
  $checked++
  foreach ($s in $svc) {
    $text = "$($s.Name) $($s.DisplayName) $($s.PathName)"
    if ($text -match 'cloudflare' -and $s.StartName -match 'LocalSystem') { $hits++; "SERVICE cloudflare-as-LocalSystem: $($s.Name) $($s.PathName)" }
    elseif ($text -match $rmm -and $text -notmatch $Sanctioned) { $hits++; "SERVICE rmm-agent (not sanctioned): $($s.Name) $($s.PathName)" }
  }
}

# 3) Running processes by name
$procs = Get-Process -ErrorAction SilentlyContinue
if ($procs) {
  $checked++
  $procs | Where-Object { ($_.ProcessName -match $tool -or $_.ProcessName -match $rmm -or $_.ProcessName -like 'meshagent64-*') -and $_.ProcessName -notmatch $Sanctioned } |
    ForEach-Object { $hits++; "PROCESS: $($_.ProcessName) (pid $($_.Id))" }
}
$unread = @($gce).Count
Write-Host "checks_run=$checked hits=$hits unreadable=$unread"
if ($checked -eq 0 -or ($hits -eq 0 -and $unread -gt 0)) { exit 3 } elseif ($hits -gt 0) { exit 1 } else { exit 0 }
Sigma — process creation, Windows: a MeshAgent binary renamed to carry an organization name
title: MeshAgent binary renamed with a name suffix (Storm-2570 pattern)
id: 5700a31c-0728-488c-8c03-011d476e1854
status: experimental
description: A process whose image name starts meshagent64- and ends .exe, the pattern Microsoft Threat Intelligence described for MeshAgent copies renamed after the victim organization.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: process_creation
  product: windows
detection:
  selection:
    Image|contains: '\meshagent64-'
    Image|endswith: '.exe'
  condition: selection
falsepositives:
  - A MeshAgent you deploy yourself under that naming
level: high
Sigma — file creation, Windows: the rdp.bat script and the @ip.txt host list
title: rdp.bat or @ip.txt created on a host (Storm-2570 pattern)
id: 59e8aa93-ef8d-431d-8197-767c0c1fcc10
status: experimental
description: Creation of a file named rdp.bat (a batch script that enables TCP 3389) or @ip.txt (a host list used with PsExec), both named in Microsoft Threat Intelligence's Storm-2570 report. File names alone are weak; review the content and the process that wrote them.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: file_event
  product: windows
detection:
  selection:
    TargetFilename|endswith:
      - '\rdp.bat'
      - '\@ip.txt'
  condition: selection
falsepositives:
  - An administrator's own script that happens to use one of these names
level: medium
Sigma — process creation, Windows: PsExec used with the @ip.txt host list
title: PsExec run against an @ip.txt host list (Storm-2570 pattern)
id: 0f9c3665-606c-413d-a73b-e991bbae6c99
status: experimental
description: PsExec started with a command line that references @ip.txt, the host list Microsoft Threat Intelligence described for lateral movement.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: process_creation
  product: windows
detection:
  selection:
    Image|contains: 'psexec'
    CommandLine|contains: '@ip.txt'
  condition: selection
falsepositives:
  - An administrator's own PsExec job that uses a host list with this name
level: high
Sigma — Windows service installation: a Cloudflare Tunnel service running as LocalSystem (field names vary by connector)
title: Cloudflare tunnel service installed to run as LocalSystem (Storm-2570 pattern)
id: 890bc36e-aebc-4628-82c0-c3891306fdc7
status: experimental
description: A new service (System log event 7045) whose name or image path mentions cloudflare and whose account is LocalSystem, as Microsoft Threat Intelligence described for Storm-2570.
author: Threat Vector Management
date: 2026-10-06
logsource:
  product: windows
  service: system
detection:
  selection_event:
    Provider_Name: 'Service Control Manager'
    EventID: 7045
    AccountName|contains: 'LocalSystem'
  selection_name:
    - ServiceName|contains: 'cloudflare'
    - ImagePath|contains: 'cloudflare'
  condition: selection_event and selection_name
falsepositives:
  - A Cloudflare tunnel you installed as a service on purpose
  - Any cloudflared service installed with defaults (it normally runs as LocalSystem; inferred from the systemprofile config path, not confirmed)
level: medium
Sigma — process creation, Windows: remote-management agents and named tools (low fidelity; remove the agents you sanction)
title: Remote-management agent or named tool from the Storm-2570 report
id: f0c4c03d-fde6-4d3b-8753-61c14b69399a
status: experimental
description: Process images whose names match the remote-management agents and tools Microsoft Threat Intelligence named for Storm-2570. All are legitimate software; the rule is an inventory prompt, so remove the agents you sanction before alerting on it. Each name must start a path component (a backslash before it), so a renamed binary or a name with a letter in front of it is missed. The rule does not search for Impacket, which the bash, SPL and KQL versions do; search command lines for it separately.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: process_creation
  product: windows
detection:
  selection_rmm:
    Image|contains:
      - '\atera'
      - '\meshagent'
      - '\screenconnect'
      - '\splashtop'
      - '\remotely_agent'
      - '\ninjarmm'
  selection_tools:
    Image|contains:
      - '\netscan'
      - '\nmap'
      - '\mimikatz'
      - '\lazagne'
      - '\pypykatz'
      - '\ntdsutil'
      - '\psexec'
      - '\netexec'
      - '\s5cmd'
      - '\rclone'
  condition: selection_rmm or selection_tools
falsepositives:
  - Sanctioned remote-management agents and administrator tools
level: low
Splunk SPL — process-creation events with Image, CommandLine and host fields (Sysmon-style; field names vary by connector)
index=endpoint Image=*
| eval img=lower(Image), cmd=lower(CommandLine)
| eval finding=case(
    match(img, "meshagent64-"), "renamed MeshAgent",
    match(img, "psexec") AND match(cmd, "@ip\.txt"), "PsExec with @ip.txt",
    match(img, "(^|[^a-z])(atera|meshagent|screenconnect|splashtop|remotely_agent|ninjarmm)"), "remote-management agent (compare with your sanctioned list)",
    match(img, "(^|[^a-z])(netscan|nmap|mimikatz|lazagne|pypykatz|ntdsutil|psexec|netexec|s5cmd|rclone)([^a-z]|$)") OR match(cmd, "impacket"), "named tool",
    true(), null())
| where isnotnull(finding)
| fillnull value="-" host
| stats count min(_time) as first max(_time) as last values(Image) as images by host finding
| eval first=strftime(first, "%F %T"), last=strftime(last, "%F %T")
| sort finding, - count
Microsoft Defender XDR / Sentinel KQL — advanced hunting (DeviceProcessEvents and DeviceFileEvents); contains_any matches inside words (for example `lateral` for atera), so treat those as noise
let rmm = dynamic(["atera", "meshagent", "screenconnect", "splashtop", "remotely_agent", "ninjarmm"]);
let tools = dynamic(["netscan", "nmap", "mimikatz", "lazagne", "pypykatz", "ntdsutil", "psexec", "netexec", "s5cmd", "rclone"]);
union
( DeviceProcessEvents
  | where Timestamp > ago(30d)
  | extend Finding = case(
      FileName startswith "meshagent64-", "renamed MeshAgent",
      FileName contains "psexec" and ProcessCommandLine contains "@ip.txt", "PsExec with @ip.txt",
      FileName contains_any (rmm), "remote-management agent (compare with sanctioned list)",
      FileName contains_any (tools) or ProcessCommandLine contains "impacket", "named tool",
      "")
  | where Finding != ""
  | project Timestamp, DeviceName, Finding, FileName, ProcessCommandLine ),
( DeviceFileEvents
  | where Timestamp > ago(30d)
  | where FileName in~ ("rdp.bat", "@ip.txt") or FileName startswith "meshagent64-"
  | project Timestamp, DeviceName, Finding = "marker file", FileName, ProcessCommandLine = FolderPath )
| order by Timestamp desc
osquery — Windows hosts (Windows paths need the backslashes shown; how LIKE treats backslashes in Windows paths is unverified; the file query walks C:\Users, so run it on a schedule or limit the path; the services query is the better route for agents)
SELECT name, display_name, path, user_account, start_type FROM services
 WHERE (name LIKE '%cloudflare%' OR display_name LIKE '%cloudflare%' OR path LIKE '%cloudflare%')
   AND user_account LIKE '%LocalSystem%';

SELECT name, version, publisher FROM programs
 WHERE name LIKE '%Atera%' OR name LIKE '%MeshAgent%' OR name LIKE '%ScreenConnect%'
    OR name LIKE '%Splashtop%' OR name LIKE '%Remotely%' OR name LIKE '%NinjaRMM%'
    OR name LIKE '%Mesh Agent%';  -- the Mesh Agent spelling is unverified

SELECT pid, name, path, cmdline FROM processes
 WHERE name LIKE 'meshagent64-%' OR cmdline LIKE '%@ip.txt%';

SELECT path, filename, size, mtime FROM file
 WHERE path LIKE 'C:\Users\%%'
   AND (filename LIKE 'meshagent64-%' OR filename LIKE 'rdp.bat' OR filename LIKE '@ip.txt');
If you see Do this
An unsanctioned remote-management agent on a server, or a Cloudflare tunnel service running as LocalSystem that you did not install An active intrusion, not a policy violation. Our analysis: isolate the host, hunt for the same agent and file names elsewhere, and assume credential theft has happened: review Tier 0 account use and plan a domain-wide credential reset.
rdp.bat or @ip.txt on a host with no change record, PsExec used with @ip.txt, or a renamed meshagent64- binary Our analysis: treat as the same intrusion until the owner of the host explains it. Preserve the file and the writing process before you remove anything.
A discovery tool from the list (NetScan, Nmap) outside an approved scan Our analysis: treat as reconnaissance until its owner explains it. Find the account and the process that ran it and what it scanned, and check whether credential tools ran on the same host.
A credential tool from the list (Mimikatz, LaZagne, pypykatz, ntdsutil, Impacket, NetExec) outside an approved test Our analysis: treat as credential theft in progress. Isolate the host, rotate the credentials it could have touched, and check for NTDS.dit access on domain controllers.
s5cmd or Rclone on a server with no reason to reach cloud storage Our analysis: treat as staging for exfiltration. Isolate, find what it reached, and block outbound cloud-storage access from servers that do not need it.
A sanctioned agent or an administrator's approved tool Record the owner and the host, add it to the sanctioned list, and exclude it next time.
A remote-management agent on a workstation that is not on the sanctioned list Confirm with the user and the owner; if nobody claims it, remove or block it, and hunt for the same agent on other hosts.
The script exits 3, the inventory is empty, or the endpoint tool has no data This is not a clean result. Fix the inventory or the telemetry first; until then rely on application control and the sanctioned list.
No hits A good sign, not an exoneration. Microsoft published no hashes, addresses or domains, and says initial access is unconfirmed, so only named behaviors were searched. Every listed tool is renamable, name matching misses a renamed binary and a name with a letter directly in front of the string, and attackers can use a remote-management product that is not on the list. Inventory and endpoint data only go back as far as you keep them, and a finished intrusion may have removed its tools. A sanctioned-list review is stronger evidence than a clean search.

10. Two macOS campaigns converge on the same target: the developer's laptop

Bottom line

Kaspersky's Securelist documented a new MacSync stealer version on 24 September; the SANS Internet Storm Center analysed the Macfinger ClickFix campaign on 25 September. Both target macOS, both reach developers, and both begin with someone pasting a command into a terminal. Do now: treat developer Macs as production systems — they hold production credentials. Skip if: no macOS in the estate — but the delivery pattern is cross-platform.

Indicators

Type Indicator Where to look Source
IoC MacSync command-and-control hosts docsend.appstore[.]com[.]mx and toria.apple03cloudstore[.]com. Roughly 21 MD5 hashes across the infection stages are published in the source and are not reproduced here DNS resolver logs, proxy logs, EDR Securelist (Kaspersky), 24 Sep 2026
IoC Macfinger domain hollow-badger-moasfraum[.]life; loader host 45.131.215[.]56; command and control at 95.163.153[.]80 port 8133. File hashes are published in the source DNS resolver logs, proxy logs, egress firewall SANS Internet Storm Center, 25 Sep 2026
IoA A LaunchAgent named com.apple.finder.agent; injection into .zshrc; Git hook injection into pre-commit or post-checkout; termination of BTMNotificationAgent or NotificationCenter macOS endpoint telemetry, developer workstation EDR Securelist (Kaspersky), 24 Sep 2026
IoA A fake verification page instructing the user to paste a command into Terminal, then a shell script fetching an architecture-specific Mach-O binary, LaunchAgent persistence, and HTTP POST plus websocket traffic to /api/credentials and /api/t Browser history, macOS endpoint telemetry, egress logs SANS Internet Storm Center, 25 Sep 2026
IoA PT_DENY_ATTACH ptrace flag use and virtual-machine detection via sysctl — anti-analysis behavior, useful as a supporting signal macOS endpoint telemetry Securelist (Kaspersky), 24 Sep 2026

What

Securelist describes MacSync as a cryptocurrency and information stealer with backdoor capability aimed at "crypto enthusiasts and developers", delivered via malicious DMGs posing as legitimate apps, ClickFix-style social engineering, a fake crypto wallet app with its own website promoted on X and Telegram, and cracked software. It abuses iCloud Calendar for intermediate payload delivery — we do not list Apple's CalDAV endpoint as an indicator, since it is legitimate infrastructure being misused.

Brad Duncan's SANS diary analyses Macfinger and disputes an earlier assessment that it is an AMOS Stealer variant, citing differences in data collection and exfiltration. The chain: a fake CAPTCHA has the user run a clipboard-injected command; a shell loader fetches architecture-specific Mach-O binaries; the malware requests extensive system permissions, then exfiltrates credentials over websocket and HTTP POST.

So what

Git hook injection turns a personal-device problem into an organizational one: a pre-commit or post-checkout hook runs inside the repository workflow with the developer's tokens, and hooks are not reviewed, scanned or usually inventoried. With item 4, the path from compromised laptop to compromised cloud estate is short and well travelled. Paste-a-command delivery is now the dominant initial-access pattern for endpoint malware on every platform — no exploit, nothing to patch. The control is people and policy, with endpoint telemetry as backstop.

Now what — P → I (analysis)

Hunt pack — run it, then act on what it says

These hunts implement the indicators Securelist and the SANS Internet Storm Center published: the three domains, the two addresses and port 8133, the LaunchAgent named com.apple.finder.agent, .zshrc and Git hook injection, the termination of two notification processes, and the /api/credentials and /api/t request paths. The Sigma, SPL and KQL address rules require port 8133 for the second address; the bash script and the osquery query match it on any port. File hashes are published in the sources and are not reproduced on the page, so we do not hunt them; take them from the sources. The matching logic is our analysis. Both bash scripts were run on a Mac against synthetic data (hit, clean, empty and unreadable inputs, a compressed log, a truncated archive, a log holding an invalid UTF-8 byte, a single-line file with no trailing newline, a mixed-case LaunchAgent name, and another user's LaunchAgents folder that could not be read); the four Sigma rules were parsed and converted to Splunk, Kusto and Lucene queries with pySigma; the osquery queries were run on a Mac to confirm they are valid for the tables used, and returned only what that Mac holds; the SPL, KQL and Suricata were reviewed, not executed against a live platform, 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 in queries and rules; the scripts that run as-is do it for you. Git hooks, .zshrc changes and a notification process being stopped are all common in ordinary use, so those checks list items for you to compare with known changes and do not count them as hits. We are not offering YARA: no sample or string was published. The scripts print up to 300 characters of each matching line, and logs can carry tokens in URLs: redact before sharing output.

bash — a Mac, run as the user (add sudo to read other users' LaunchAgents); finds the LaunchAgent, lists `.zshrc` details and Git hooks for review; exit 0 = no hit, 1 = hit, 3 = nothing readable (not a clean result)
#!/usr/bin/env bash
# Usage: ./macos-host-hunt.sh [REPO_DIR...]     (REPO_DIR: folders that hold Git repositories)
set -uo pipefail
users=${USERS_DIR:-/Users}; syslib=${SYS_LAUNCHAGENTS:-/Library/LaunchAgents}
dirs=(); [ -d "$syslib" ] && dirs+=("$syslib")
unread=0
for h in "$users"/*; do
  [ -d "$h" ] || continue
  if [ -d "$h/Library/LaunchAgents" ]; then dirs+=("$h/Library/LaunchAgents")
  else
    lsout=$(LC_ALL=C ls -d "$h/Library" 2>&1)
    if { [ -d "$h/Library" ] && [ ! -x "$h/Library" ]; } || [ "${lsout#*Permission denied}" != "$lsout" ]; then echo "cannot read $h/Library (run with sudo): NOT a clean result" >&2; unread=$((unread + 1)); fi
  fi
done
[ ${#dirs[@]} -gt 0 ] || { echo "no LaunchAgents folder readable: NOT a clean result"; exit 3; }
hits=0; scanned=0; review=0
for d in "${dirs[@]}"; do
  [ -r "$d" ] || { echo "not readable: $d" >&2; unread=$((unread + 1)); continue; }
  for f in "$d"/*; do
    [ -e "$f" ] || continue
    scanned=$((scanned + 1))
    case "$(basename "$f" | tr 'A-Z' 'a-z')" in com.apple.finder.agent*) hits=$((hits + 1)); echo "HIT LaunchAgent: $f";; esac
  done
done
for h in "$users"/*; do
  z="$h/.zshrc"
  [ -f "$z" ] && { review=$((review + 1)); echo "REVIEW .zshrc: $z modified $(date -r "$z" '+%F %T') lines=$(wc -l < "$z" | tr -d ' ')"; }
done
for r in "$@"; do
  [ -d "$r" ] || { echo "skipped (not a folder): $r" >&2; continue; }
  while IFS= read -r -d '' hk; do
    review=$((review + 1)); echo "REVIEW git hook: $hk modified $(date -r "$hk" '+%F %T')"
  done < <(find "$r" -path '*/.git/hooks/*' -type f \( -name pre-commit -o -name post-checkout \) -print0 2>/dev/null)
done
echo "launchagents_scanned=$scanned hits=$hits review=$review"
[ "$scanned" -gt 0 ] || { echo "no LaunchAgents read: NOT a clean result"; exit 3; }
[ "$hits" -eq 0 ] || exit 1
[ "$unread" -eq 0 ] || { echo "$unread LaunchAgents folder(s) unreadable: NOT a clean result"; exit 3; }
exit 0
bash — proxy, DNS or firewall logs exported as text (plain or .gz); exit 0 = no hits, 1 = hits, 3 = nothing readable (not a clean result)
#!/usr/bin/env bash
# Usage: ./macos-ioc-hunt.sh /path/to/log*   (plain or .gz)
set -uo pipefail
export LC_ALL=C   # byte-wise matching: a stray non-UTF-8 byte must not stop awk
[ $# -ge 1 ] || { echo "usage: $0 LOGFILE..." >&2; exit 2; }
re() { local s=${1//\[.\]/.}; echo "${s//./\\.}"; }           # refang, then escape dots
d1=$(re 'docsend.appstore[.]com[.]mx'); d2=$(re 'toria.apple03cloudstore[.]com'); d3=$(re 'hollow-badger-moasfraum[.]life')
i1=$(re '45.131.215[.]56'); i2=$(re '95.163.153[.]80')
dom="(^|[^a-z0-9-])($d1|$d2|$d3)\.?([^a-z0-9.-]|$)"
ip="(^|[^0-9.])($i1|$i2)([^0-9]|$)"
paths='/api/(credentials|t)([^a-z0-9_]|$)'
files=(); skipped=0
for f in "$@"; do
  if [ -f "$f" ] && [ -r "$f" ]; then files+=("$f"); else echo "skipped (not a readable file): $f" >&2; skipped=$((skipped + 1)); fi
done
[ ${#files[@]} -gt 0 ] || { echo "scanned=0 hits=0 (no readable input: NOT a clean result)"; exit 3; }
scanned=0; hits=0; review=0
for f in "${files[@]}"; do
  gzip -dcf -- "$f" >/dev/null 2>&1 || { echo "cannot decompress: $f" >&2; skipped=$((skipped + 1)); }
  n=$(gzip -dcf -- "$f" 2>/dev/null | grep -c ''); scanned=$((scanned + n))
  while IFS= read -r line; do hits=$((hits + 1)); echo "HIT $f: ${line:0:250}"; done < <(gzip -dcf -- "$f" 2>/dev/null | grep -iE "$dom|$ip")
  while IFS= read -r line; do review=$((review + 1)); echo "REVIEW path $f: ${line:0:250}"; done < <(gzip -dcf -- "$f" 2>/dev/null | grep -iE "$paths" | grep -ivE "$dom|$ip")
done
echo "scanned=$scanned hits=$hits review=$review"
[ "$scanned" -gt 0 ] || { echo "no log lines read: NOT a clean result"; exit 3; }
[ "$hits" -eq 0 ] || exit 1
[ "$skipped" -eq 0 ] || { echo "$skipped input(s) skipped: NOT a clean result"; exit 3; }
exit 0
Sigma — DNS query logs (resolver, EDR or Sysmon-style DNS events; field names vary by connector); remove the brackets
title: DNS lookup of MacSync or Macfinger command-and-control domains
id: c4105149-7b52-4078-bcad-d2bf9c82fb6f
status: experimental
description: A DNS query for one of the domains Securelist (MacSync) or the SANS Internet Storm Center (Macfinger) published. Defanged here; remove the brackets before use.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: dns
detection:
  selection:
    query|endswith:
      - 'docsend.appstore[.]com[.]mx'
      - 'toria.apple03cloudstore[.]com'
      - 'hollow-badger-moasfraum[.]life'
  condition: selection
falsepositives:
  - Researchers or security tools resolving the domains on purpose
level: high
Sigma — network connection logs (firewall, proxy or EDR); remove the brackets
title: Connection to the Macfinger loader or command-and-control address
id: 327ef21a-64ed-4129-9b8f-fca5c05a3743
status: experimental
description: A connection to the Macfinger loader host, or to the Macfinger command-and-control address on port 8133, as published by the SANS Internet Storm Center. Defanged here; remove the brackets before use.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: network_connection
detection:
  selection_loader:
    DestinationIp: '45.131.215[.]56'
  selection_c2:
    DestinationIp: '95.163.153[.]80'
    DestinationPort: 8133
  condition: selection_loader or selection_c2
falsepositives:
  - Researchers or security tools contacting the address on purpose
level: high
Sigma — macOS file events: the LaunchAgent named in the Securelist report
title: LaunchAgent named com.apple.finder.agent created (MacSync)
id: 5b257a10-da4b-448b-9da4-53326f71a675
status: experimental
description: A file named com.apple.finder.agent in a LaunchAgents folder, the persistence name Securelist reported for MacSync. The name imitates an Apple label; a match with no software installation behind it is a lead worth acting on.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: file_event
  product: macos
detection:
  selection:
    TargetFilename|contains: 'LaunchAgents/com.apple.finder.agent'
  condition: selection
falsepositives:
  - None known
level: high
Sigma — macOS process creation: the notification processes Securelist said MacSync terminates (a supporting signal only)
title: BTMNotificationAgent or NotificationCenter terminated by command (MacSync behavior)
id: 5b30e9e6-fea7-49a9-b12a-94516d754290
status: experimental
description: killall or pkill naming BTMNotificationAgent or NotificationCenter. Securelist lists the termination of these processes as MacSync behavior; the same command is also used in ordinary administration, so treat a match as supporting evidence only. Termination by process ID is not visible to this rule.
author: Threat Vector Management
date: 2026-10-06
logsource:
  category: process_creation
  product: macos
detection:
  selection_tool:
    Image|endswith:
      - '/killall'
      - '/pkill'
  selection_target:
    CommandLine|contains:
      - 'BTMNotificationAgent'
      - 'NotificationCenter'
  condition: selection_tool and selection_target
falsepositives:
  - Administrators or scripts that restart the notification service
level: low
Splunk SPL — DNS, proxy or firewall data (field names vary by connector); remove the brackets
index=* (query="*docsend.appstore[.]com[.]mx" OR query="*toria.apple03cloudstore[.]com" OR query="*hollow-badger-moasfraum[.]life"
      OR url="*docsend.appstore[.]com[.]mx*" OR url="*toria.apple03cloudstore[.]com*" OR url="*hollow-badger-moasfraum[.]life*"
      OR dest_ip="45.131.215[.]56" OR (dest_ip="95.163.153[.]80" dest_port=8133))
| eval seen=coalesce(query, url, dest_ip)
| fillnull value="-" src host
| stats count min(_time) as first max(_time) as last values(seen) as seen by src host
| eval first=strftime(first, "%F %T"), last=strftime(last, "%F %T")
Microsoft Defender XDR / Sentinel KQL — advanced hunting (DeviceNetworkEvents, DeviceFileEvents; macOS onboarded devices); remove the brackets
let domains = dynamic(["docsend.appstore[.]com[.]mx", "toria.apple03cloudstore[.]com", "hollow-badger-moasfraum[.]life"]);
union
( DeviceNetworkEvents
  | where Timestamp > ago(30d)
  | where RemoteUrl has_any (domains) or RemoteIP == "45.131.215[.]56" or (RemoteIP == "95.163.153[.]80" and RemotePort == 8133)
  | project Timestamp, DeviceName, Finding = "network", Detail = strcat(RemoteUrl, " ", RemoteIP, ":", RemotePort), InitiatingProcessFileName ),
( DeviceFileEvents
  | where Timestamp > ago(30d)
  | where FolderPath has "LaunchAgents" and FileName startswith "com.apple.finder.agent"
  | project Timestamp, DeviceName, Finding = "LaunchAgent", Detail = FolderPath, InitiatingProcessFileName ),
( DeviceProcessEvents
  | where Timestamp > ago(30d)
  | where FileName in~ ("killall", "pkill") and ProcessCommandLine has_any ("BTMNotificationAgent", "NotificationCenter")
  | project Timestamp, DeviceName, Finding = "notification process stopped (supporting signal)", Detail = ProcessCommandLine, InitiatingProcessFileName )
| order by Timestamp desc
osquery — macOS hosts; remove the brackets from the addresses
SELECT path, filename, mtime FROM file
 WHERE (path LIKE '/Users/%/Library/LaunchAgents/%' OR path LIKE '/Library/LaunchAgents/%')
   AND filename LIKE 'com.apple.finder.agent%';

SELECT label, path, program, program_arguments, run_at_load FROM launchd
 WHERE label = 'com.apple.finder.agent';

SELECT p.pid, p.name, p.path, s.remote_address, s.remote_port FROM process_open_sockets s
 JOIN processes p USING (pid)
 WHERE s.remote_address IN ('45.131.215[.]56', '95.163.153[.]80');

SELECT path, mtime, size FROM file WHERE path LIKE '/Users/%/.zshrc';
Suricata — network sensor that sees DNS and plain HTTP; blind to TLS unless placed behind termination; remove the brackets; local SIDs
alert dns $HOME_NET any -> any any (msg:"TVM hunt MacSync domain lookup"; dns.query; content:"docsend.appstore[.]com[.]mx"; nocase; sid:9006101; rev:1;)
alert dns $HOME_NET any -> any any (msg:"TVM hunt MacSync domain lookup"; dns.query; content:"toria.apple03cloudstore[.]com"; nocase; sid:9006102; rev:1;)
alert dns $HOME_NET any -> any any (msg:"TVM hunt Macfinger domain lookup"; dns.query; content:"hollow-badger-moasfraum[.]life"; nocase; sid:9006103; rev:1;)
alert ip $HOME_NET any -> 45.131.215[.]56 any (msg:"TVM hunt Macfinger loader host"; sid:9006104; rev:1;)
alert tcp $HOME_NET any -> 95.163.153[.]80 8133 (msg:"TVM hunt Macfinger C2 port 8133"; sid:9006105; rev:1;)
alert http $HOME_NET any -> any any (msg:"TVM hunt POST to /api/credentials (plain HTTP only; low fidelity)"; http.method; content:"POST"; http.uri; content:"/api/credentials"; sid:9006106; rev:1;)
If you see Do this
A com.apple.finder.agent LaunchAgent, or a LaunchAgent or .zshrc change that matches no software installation or change record (a Git hook change with no record is our extension; treat it the same) Confirmed compromise. Isolate the Mac, rotate every credential it held (cloud, registry and signing tokens included), and reimage it. Do not clean a stealer off a machine with production access.
A DNS lookup or connection to a listed domain or address, or traffic to port 8133 on the listed address Treat the Mac as compromised until shown otherwise. Isolate it, find the process that made the request, and follow the row above; hunt every other host for the same values.
A /api/credentials or /api/t request with no other indicator Low fidelity, because any application can use those paths. Check which process and which destination made the request before acting.
Git hooks, .zshrc edits or a stopped notification process that your own tooling explains Record the source, move hooks to a managed and reviewed location if you can, and exclude them next time.
The scripts exit 3, the Mac has no LaunchAgents folder you can read, or the logs are empty This is not a clean result. Re-run with the right privileges or find where the logs went, and rely on endpoint management and credential hygiene meanwhile.
No hits A good sign, not an exoneration. Only the published names, domains, addresses and paths were searched, hashes were not, and campaign infrastructure changes. Logs rotate, DNS and proxy data may not cover a Mac off the corporate network, and a stealer does not need persistence to have taken credentials in its first run. Reducing what a stolen laptop can reach is the stronger control.

Sector signal — only what a source actually said

We do not infer sector exposure from products; a guess presented as intelligence is wrong in a way no reader can check. No source in this window attributed an item to a named sector. The closest is Google's PeopleSoft hunting guidance naming human-resources, payroll and student-records tables — a statement about the data on the system, not about who is targeted. 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, catalog and due dates come from the CVE and NVD records or vendor advisories directly. Where sources disagree (WordPress severity and range, Roundcube exploitation, the WSO2 catalog title) we show it and say which reading we plan on; figures we could not confirm (the Grav back-port version, Roundcube exposure counts) are left out and marked in place. 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: Microsoft maps Storm-3168 to T1190, T1078.004, T1526, T1485 and T1490; mappings for other items would be ours and are omitted. This brief publishes when something warrants it, not on a schedule; a separate brief on personal privacy and individual security is planned.

Threat Vector Management helps organizations and the MDR and SOC teams that defend them turn briefs like this into action — hunting for a prior intrusion after a late patch, and getting secrets and machine identities under control before the next leak. Start a conversation →

More editions: Threat Brief archive.

Threat Vector Management is not affiliated with any company operating under the name “ThreatVector.”


Sources

  1. Google Threat Intelligence Group on ShinyHunters and PeopleSoft
  2. CVE records for CVE-2026-35273, CVE-2026-87902, CVE-2026-5430, CVE-2026-71362, CVE-2026-48842 and CVE-2026-42608
  3. Patchstack on CVE-2026-87902
  4. WSO2 security advisory
  5. Adobe advisory APSB26-92
  6. Microsoft Threat Intelligence on Storm-3168 and on Storm-2570
  7. Socket on the re-enabled GitHub Actions
  8. The Hacker News on the Elementor flaw
  9. Securelist on MacSync
  10. SANS Internet Storm Center, A Closer Look at Malware From the Macfinger ClickFix Campaign, 25 Sep 2026
  11. Grav releases
  12. SOCFortress — WordPress exploitation surge: CVE-2026-87902 attack analysis, 24 Sep 2026
  13. The Hacker News — Compromised GitHub Actions came back online, 25 Sep 2026
  14. Socket — AntV packages compromised (Mini Shai-Hulud payload indicators), 19 May 2026
  15. Patchstack — Cross-site request forgery in Elementor, 25 Sep 2026
  16. Canadian Centre for Cyber Security — AV26-503 Roundcube security advisory, update 1, 21 Sep 2026
  17. Roundcube — Security updates 1.6.16 and 1.7.1, 24 May 2026
  18. The Hacker News — Roundcube pre-auth SQL injection flaw, 25 Sep 2026
  19. Grav — security advisory GHSA-hmcx-ch82-3fv2

Provenance: compiled 26 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 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