Under Three Hours From Target to Privileged Access: What Storm-0940's Agentic Attack Changes

·13 min read·Evergreen Tools Team

On October 21, 2026, Microsoft's threat intelligence team published an account of Storm-0940, a China-state aligned actor active since 2021, using an open-source agentic penetration testing framework called ARTEX to run attacks against hundreds of organizations across aerospace and defense, automotive, chemicals, energy, financial services, government, legal, manufacturing, media, military, semiconductor, software and think tanks. The line that should stop you is a timing detail: in multiple instances, the time from initial targeting to privileged access to cloud infrastructure was under three hours. Microsoft also assesses Storm-0940 was responsible for the recently disclosed ConnectWise supply chain compromise of a multi-tenant application. This is not a story about a cleverer exploit. It is a story about what happens when the reconnaissance and the credential spraying loop stops needing a human to sleep.

Under Three Hours From Target to Privileged Access: What Sto

Under Three Hours From Target to Privileged Access: Wha

1. What Microsoft Actually Published

The actor's history is ordinary: initial access through password spraying and exploitation of internet-facing applications, then lateral movement with compromised accounts, then persistence in cloud environments by adding credentials to existing OAuth applications. That last step is the one to internalize, because it turns an application into an identity that keeps working after the password changes. What is new is the automation layer. ARTEX is described as an open-source penetration testing framework that can drive any AI model to automate offensive workflows, and it is a multi-agent LLM system written in Go. Storm-0940 used it for scanning and reconnaissance, password spraying, and OAuth application abuse at downstream victims of the ConnectWise compromise. Microsoft states the activity did not result from a compromise of or vulnerability in Microsoft corporate systems, products or services.

# Inventory pass 1: applications that gained credentials recently.
# Read-only. App registration needs Directory.Read.All.
import json, urllib.request, datetime as dt

TOKEN = "<graph token from az account get-access-token --resource https://graph.microsoft.com>"
HDR = {"Authorization": "Bearer " + TOKEN}

def get(url):
    req = urllib.request.Request(url, headers=HDR)
    return json.load(urllib.request.urlopen(req, timeout=60))

cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=30)
apps = get("https://graph.microsoft.com/v1.0/applications?$select=id,appId,displayName,passwordCredentials,keyCredentials")

for a in apps["value"]:
    for cred in a.get("passwordCredentials") or []:
        start = cred.get("startDateTime")
        if not start:
            continue
        when = dt.datetime.fromisoformat(start.replace("Z", "+00:00"))
        if when >= cutoff:
            print("NEW SECRET |", a["displayName"], "|", a["appId"],
                  "| added", start, "| expires", cred.get("endDateTime"))
            print("            hint:", (cred.get("hint") or "")[:4] + "...")

# A secret added in the last 30 days with no change ticket is the cheapest
# signal in this whole report. Chase that number until it is boring.

2. Why Agents Change the Arithmetic, Not Just the Speed

Microsoft's own framing is precise: agents are not constrained by factors affecting humans, they can work around the clock, weekends and holidays included, and they can attempt multiple breach paths simultaneously. Do that arithmetic on a normal red team engagement. A human operator picks a path, tests it, backs off when it stalls. Twenty parallel attempts do not get twenty times better, they get a different statistical outcome, because the attacker only needs one path to work and the defender needs all of them closed. That is why the sub-three-hour number is not a marketing figure. It is what happens when the expensive part of an intrusion, the patient sequential probing, becomes parallel and free.

# Inventory pass 2: service principal sign-ins, oldest-touched first.
# AuditLog.Read.All required. /auditLogs is beta-only on Graph.
import json, urllib.request, collections

TOKEN = "<graph token>"
HDR = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}

url = ("https://graph.microsoft.com/beta/auditLogs/signIns"
       "?$filter=signInEventTypes/any(t:t eq 'servicePrincipal')"
       "&$top=500")

req = urllib.request.Request(url, headers=HDR)
rows = json.load(urllib.request.urlopen(req, timeout=90))["value"]

seen = collections.Counter()
ipmap = collections.defaultdict(set)
for r in rows:
    name = (r.get("appDisplayName") or "unknown")
    seen[name] += 1
    ip = r.get("ipAddress") or "-"
    ipmap[name].add(ip)

for name, n in seen.most_common(25):
    print(f"{n:5d}  {name:<40}  ips={len(ipmap[name])}")
    if len(ipmap[name]) > 3:
        print("       ^ many source IPs for a non-interactive identity")

# Before you accept this output, check who owns each app. The finding is not
# the volume, it is the app nobody can name an owner for.

3. The Pivot That Matters for SaaS: an App With Consent

The downstream mechanism in the ConnectWise case is the part most teams have not modeled. Storm-0940 abused an already-compromised application to authenticate into Microsoft 365 customer environments through applications that had already been granted access, which meant no interactive user sign-in was required. In several environments, Microsoft observed AI-accelerated follow-on activity through Microsoft Graph: reconnaissance, application credential changes, and attempts to modify privileges to expand or persist access. Read that list again as a defender. Nothing in it is a malware detection. It is all identity and configuration, sitting inside the normal API surface your monitoring was tuned to ignore, executed by an entity that legitimately holds the permissions it is using.

# Inventory pass 3: grants that carry application roles, not user scopes.
# Application permissions act without a signed-in human. That is the pivot.
import json, urllib.request

TOKEN = "<graph token>"
HDR = {"Authorization": "Bearer " + TOKEN}

def get(u):
    return json.load(urllib.request.urlopen(urllib.request.Request(u, headers=HDR), timeout=60))

grants = get("https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$top=200")
high = []
for g in grants["value"]:
    scopes = (g.get("scope") or "").split()
    if not scopes:
        # Empty scope means application-level roles, the dangerous case.
        high.append((g.get("clientId"), g.get("resourceId"), "<app roles>"))
    else:
        risky = {"Mail.ReadWrite", "Files.ReadWrite.All", "Directory.ReadWrite.All",
                 "Sites.FullControl.All", "RoleManagement.ReadWrite.Directory"}
        if risky & set(scopes):
            high.append((g.get("clientId"), g.get("resourceId"), " ".join(sorted(risky & set(scopes)))))

for c, r, s in high:
    print("GRANT", c, "->", r, "|", s)
print("total grants:", len(grants["value"]), "flagged:", len(high))

# Every flagged row needs a name attached. If nobody can name it, remove it.
# That is a shorter conversation than an incident review.

4. What to Hunt, in the Order That Pays

Microsoft's guidance is short and worth following literally: review service principal sign-ins, investigate unexpected application credential or privilege changes, follow ConnectWise's published containment guidance, and preserve evidence. The ordering matters. Service principal sign-ins first, because that is where the activity shows up without a human in the loop, and most teams have never looked at that log with intent. Credential and privilege deltas second, because adding a secret to an OAuth app is how the actor persists, and a new secret with no ticket behind it is the cleanest single signal you can get. Evidence preservation third, and it is the one people skip under pressure; containment that deletes the audit trail also deletes your ability to know which tenants were touched.

// Defender XDR hunting: Graph activity that looks like post-compromise work.
// Run this before you need it, so the baseline exists.
AuditLogs
| where OperationName in ("Add service principal credentials.",
                          "Update application - Certificates and secrets management",
                          "Add delegated permission grant.",
                          "Add app role assignment to service principal.")
| extend Actor = tostring(InitiatedBy.user.userPrincipalName),
         App   = tostring(TargetResources[0].displayName)
| summarize Count = count(), FirstSeen = min(TimeGenerated),
            Actors = make_set(Actor, 5) by App
| where Count > 0
| order by FirstSeen desc

// Pair it with sign-ins where the app authenticated without a user:
// AADServicePrincipalSignInLogs
// | where ResultType == 0
// | summarize signins=count(), ips=dcount(IPAddress) by AppDisplayName
// | where ips > 3
// The pairing is the point: a credential change followed by new-IP
// authentication from that app is the sequence, not either event alone.

5. Query Your Own Tenant Before You Read the Next Report

The code below runs against Microsoft Graph with an app that has the minimum read permissions you need, Directory.Read.All and AuditLog.Read.All for the audit queries. It answers three questions: which service principals authenticated recently that nobody has touched in months, which applications had credentials added in the last 30 days, and which OAuth grants carry high-privilege application roles rather than delegated user scopes. None of these are detections. They are inventory statements, which is what you actually want, because the actor's advantage is that your inventory disagrees with reality and nobody has noticed yet. Run it read-only, keep the output, and diff it weekly.

# Give an agent its own identity, then prove the scope is small.
# Run against your own tenant; this is the pattern, not a magic command.
set -euo pipefail

APP_NAME="report-agent-$(date +%Y%m)"

# 1) A dedicated app registration, not a human's credentials
APP_ID=$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)
az ad sp create --id "$APP_ID" >/dev/null

# 2) One resource, one role. Start with read-only Mail, not Mail.ReadWrite.
az ad app permission add --id "$APP_ID" \
  --api 00000003-0000-0000-c000-000000000000 \
  --api-permissions e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope   # User.Read

# 3) A credential with a short life, tracked in your secret store
az ad app credential reset --id "$APP_ID" --years 1 --append \
  --display-name "rotation-$(date +%Y-%m)" --query endDateTime -o tsv

# 4) Prove it: list what this identity can actually reach
az ad app permission list --id "$APP_ID" -o table

# Write the owner and the rotation date into the app description. An identity
# with no owner is the finding, regardless of what else it can do.

6. If You Run Agents, You Now Own an Attack Surface With a Job Title

The uncomfortable symmetry is that the same properties that make agents useful in your own stack make them valuable to an intruder: long-lived credentials, broad scopes, the ability to act without a human click, and logs that mostly record success. Three practices follow. Give every agent its own identity instead of borrowing a human's, so revocation is one operation. Scope it to the tools it actually calls, and treat that tool list as a reviewed artifact rather than configuration drift. And log every tool invocation with the arguments, because when the question is what an agent did, tool-call logs answer it while model logs only suggest. Microsoft's report ends with containment guidance for a vendor product. The general lesson is narrower and applies to everyone: identity, scope, and invocation logs are now the perimeter.

Under Three Hours From Target to Privileged Access: What Sto

Under Three Hours From Target to Privileged Access: Wha

Under Three Hours From Target to Privileged Access: What Sto

Under Three Hours From Target to Privileged Access: Wha

📌 Frequently Asked Questions

What is ARTEX?

Microsoft describes ARTEX as an open-source penetration testing framework that can use any AI model to automate a range of offensive security workflows. It is a multi-agent LLM system written in Go. Storm-0940 used it for scanning and reconnaissance, password spraying, and OAuth application abuse against downstream victims of the ConnectWise compromise.

How fast were the intrusions?

In multiple instances the time from initial targeting to privileged access to an organization's cloud infrastructure was less than three hours, according to Microsoft. Agents are not constrained by human factors, so they can operate continuously and attempt multiple breach paths at once.

Why does OAuth app abuse matter more than the initial access?

Adding credentials to an existing OAuth application gives the actor an identity that keeps authenticating after passwords change, and it can access Microsoft 365 resources through applications that were already granted consent, with no interactive user sign-in required.

Was Microsoft itself compromised?

No. Microsoft states the activity did not result from a compromise of, or a vulnerability in, Microsoft corporate systems, products or services. The observed authentication and follow-on activity occurred in customer environments through affected applications, and presence of such activity does not by itself mean customer data was accessed or exfiltrated.

What should an administrator do first?

Review service principal sign-ins, investigate unexpected application credential or privilege changes, follow ConnectWise's published containment guidance, and preserve relevant evidence for investigation. Microsoft Defender XDR provides detections and hunting queries for suspicious Microsoft Graph activity.