← Verdict cards Cutsign
Hunt / IR lane Active campaign Storm-0940 (China-aligned) No CVE — identity abuse as of 2026-10-27

ConnectWise Cloud Backup supply chain compromise

Stolen vendor app credentials for ConnectWise (ex-SkyKick) multi-tenant M365 applications were used from October 15 to mint Graph tokens across ~2,250 tenants (Microsoft; Huntress counted 300+ successful of ~1,000 observed). Microsoft attributed the campaign to Storm-0940 on October 21, operating the ARTEX agentic-AI framework.

Verdict: hunt now if the ConnectWise app IDs ever touched your tenant — including former customers. This is credential abuse of a vendor's multi-tenant apps, not a patchable CVE: there is nothing to scan for, only logs to review. Sophos reports the token sweep reached tenants with no active backup subscription, so past use counts. ConnectWise disabled the seven app identities, but previously issued tokens may remain usable until they expire, and attacker persistence (added credentials, role grants) survives the vendor-side fix.

30-second read

Password spraying (151,000+ failures, Oct 10–14) preceded abuse of stolen ConnectWise application credentials from Oct 15: service-principal sign-ins from attacker infrastructure minting Graph tokens in customer tenants. Follow-on activity where sign-ins succeeded: directory reconnaissance, new client secrets planted on other applications (persistence beyond the ConnectWise apps), and privileged role changes including a service principal self-assigning Global Administrator. Conditional Access policies scoped to users often do not evaluate multi-tenant service-principal sign-ins — check yours rather than assuming coverage.

The seven application IDs — match on appId, never display name

ApplicationappId
ConnectWise Cloud Backup506e08f9-6e9a-4da4-942b-a099a03644ae
ConnectWise Cloud Management2399d300-6fca-4e6a-a686-399fefe75b83
Cloud Management — MS Teams24205314-2b89-4e07-a2a4-579e898a5640
Cloud Management — Exchange Onlineb78f6a77-d4e5-46fb-87e5-d1648bd02519
Cloud Management — MS Graph0de14c77-b9b0-4137-9360-34878a929827
Cloud Management — Partner Center412dcf96-176d-4b3a-a1b0-4981ad66971e
Cloud Management — SharePoint Onlineb2b55f5b-7758-4719-8d8f-88ae172e6bdb

Exposed if

  • Any of the seven appIds present or consented in the tenant — now or ever (ex-customers included)
  • Conditional Access does not evaluate multi-tenant service-principal sign-ins
  • Service-principal sign-in or Entra audit retention shorter than the Oct 1 → now window

Not exposed if

  • None of the seven appIds appear in tenant service principals or consent history (verified by appId)

ConnectWise notes checklist completion does not guarantee a tenant was unaffected — absence of the apps is the only clean verdict.

Infrastructure & discriminators

Detection coverage — as of Oct 25

SourceStatus
ConnectWiseActed + guide published — seven app identities disabled 07:00 UTC Oct 17 (Trust Center); Cloud Backup / SaaS Security / Cloud Management suspended. The Oct 18 technical investigation & remediation guide is the vendor playbook: per-tenant checklist covering SP sign-in review (window Oct 13 → now, extend to Oct 1), Entra audit, Conditional Access review, federated credentials on locally registered apps, and Exchange/Purview audit — run it in every tenant including dormant connections; it covers resold/managed tenants too (reviewing your own tenant does not review the customer's). Live help exists: technical webinars every two hours since Oct 20 18:00 UTC (walkthrough of Entra sign-in/audit logs, app registrations, credentials, permissions — register and pick a session; share the invite with customer admins), plus a video walkthrough of guide steps. The Trust Center advisory updates several times daily (20 updates Oct 16–23). Caveats: completing the checklist does not establish a tenant was unaffected; previously issued tokens may remain valid until expiry; escalation route SecurityPartnerSupport@ConnectWise.com (tenant/app IDs, UTC timestamps, correlation IDs — no secrets)
MicrosoftPublished — Defender XDR alerts ("Application self-assigned Global Administrator role", "Unusual Microsoft Graph activity associated with Compromised ConnectWise Application") plus KQL for OAuthAppInfo, EntraIdSpnSignInEvents, GraphAPIAuditEvents and Sentinel SigninLogs in the Oct 21 Storm-0940 post (tier A)
Microsoft — Entra platform docsPublished (platform semantics, tier A) — the all-zeros ServicePrincipalId is documented SP-less authentication; admin-center procedure: Sign-in logs → Service principal sign-ins tab → filter Service principal ID = 00000000-0000-0000-0000-000000000000 → read the Application ID per entry. Documented blocking control: "create a service principal for the app, and then disable the service principal. Disabling the app's service principal will block all future sign-in and authentication attempts by that app in your tenant" — applicable per appId in tenants that never consented Cloud Backup. The same page says this brings "the ability to target these apps individually with Conditional Access policies", but Microsoft's workload-identity Conditional Access page says policy "can be applied to single tenant service principals that are registered in your tenant" and that "multitenant apps, are not covered", so for a vendor app the disabled service principal is the control, not Conditional Access. Retirement timeline per the Entra blog: most resource apps frozen April 2025; traffic seen Feb 11–Mar 11 2025 allowed until March 31, 2026; low-volume clients notified with a month to register. No published list of exempt third-party apps; first-party Microsoft apps are exempt (Microsoft's verification page: f8cdef31-a31e-4b4a-93e4-5f571e91255a = "the Microsoft Service's Microsoft Entra tenant ID", 72f988bf-86f1-41af-91ab-2d7cd011db47 = the tenant of "Microsoft tenant-owned applications", listed as "some, but not all"; the same page notes first-party apps "don't always result in a service principal that's created in your tenant"). Publisher field, cross-checked Oct 27: the Azure Monitor table AADServicePrincipalSignInLogs carries AppOwnerTenantId ("the tenant identifier of the owner of the application"), and the Graph signIn resource exposes appOwnerTenantId on beta only (v1.0 does not return it); the MicrosoftGraphActivityLogs table has no owner or publisher column. Naming an owner tenant: findTenantInformationByTenantId is v1.0, needs only CrossTenantInformation.ReadBasic.All and no directory role, and returns displayName and defaultDomainName. Caveat: a volunteer-moderator Q&A answer (Oct 2025) notes an app can appear under Enterprise Applications yet still log zeros when not properly provisioned/consented — presence in the app list is not proof of a service principal
HuntressPublished — Entra review procedure: SP sign-ins by appId vs non-Azure source IPs, audit events (Add member to role / Update application / Add service principal credentials), secrets added since Oct 13, CA exclusions; disable (do not delete) the apps. Oct 26 follow-up ("scripts, people, and AI agents", tier A, from Entra sign-in + directory audit logs; Graph activity logs were not available to them): persistence observed as add → remove → add churn of an application secret and of the Global Administrator role (03:42–03:57 UTC Oct 15), a certificate credential upload that failed on a thumbprint mismatch and was retried to success 55 s later, the Teams application identity self-assigning Global Administrator via Graph (T1098.003), a refused GA removal answered by granting a different role to a sibling service principal, and service-principal deletion afterwards (Teams SP deleted 03:57). No queries printed; no appIds listed. Recommendation: enable Microsoft Graph activity logs. Confidence that an AI agent was in the loop: low–medium on their telemetry alone, medium–high with Microsoft's attribution
Chorus CyberPublished (MSSP advisory, Oct 19, tier B) — sign-in interpretation: a successful sign-in for the Cloud Backup app from the attacker IP can appear even where the product was never used; entries carry an all-zeros ServicePrincipalId and their queries exclude them, because an unconsented app has no permissions in the tenant. Artefacts to review beyond sign-ins: new secrets or certificates on enterprise applications and app registrations, directory roles held by ConnectWise/SkyKick apps, Conditional Access exclusions for those apps. Activity from Oct 13; credential use from Oct 15. Caveats: no queries printed ("our detection rule identifies this activity"); the IOC table is an image, not machine-readable; the seven appIds are referenced but not listed
CISANo advisory found as of Oct 23; no KEV entry (no CVE exists)
VM scanners / EDRNot applicable — nothing to scan; coverage is Entra/Graph log review and the Microsoft/Huntress content above

Do now

  • Query service-principal sign-ins for all seven appIds back to Oct 1 (review window Oct 13 → now at minimum)
  • Entra audit: credential additions, new owners, app-role assignments, consent grants, privileged role changes since Oct 13 — on all apps, not just ConnectWise ones
  • Pull user agents on any SP sign-ins found; Python/curl UAs from non-Azure IPs are the campaign shape
  • Verify tenant presence by Graph lookup per appId; confirm Conditional Access evaluates multi-tenant SPs
  • Review federated credentials on locally registered applications (guide §4) — issuer/subject trusts are persistence that survives secret rotation
  • Former customers: run the same checks — stale consents were swept too
  • Never a customer: filter the Service principal sign-ins tab for the all-zeros ID and the seven appIds; if present, apply Microsoft's documented block (create the service principal for that appId, then disable it) — defense in depth now that ConnectWise has disabled the identities, and it brings the app under Conditional Access

Escalation triggers

  • Any successful SP sign-in from the listed IPs or with the Python/curl UAs
  • Service principal self-assigning a directory role, or credentials added to unrelated apps
  • Credential or role churn on one app inside an hour (add → remove → add), a role granted to a sibling principal after a refused removal, or a service principal deleted after such changes — the Oct 26 Huntress persistence shape; also an anti-forensics tell
  • New IoCs or appIds published by ConnectWise / Microsoft
  • CISA advisory or broader MSP-tooling expansion of the campaign

Inferred detections — no Sentinel? same hunt, generic

The published queries above come first — Microsoft's KQL and the ConnectWise guide checklist supersede everything here. This is the fallback translation for environments without Defender XDR / Sentinel tables, over exported Entra logs in any SIEM or even a spreadsheet:

Service-principal sign-ins — the core question, tool-agnostic:


# exported Entra SP sign-in logs, Oct 1 → now
appId IN (the seven appIds above)
AND NOT (source = Azure private IPv6 AND user_agent = kiota-dotnet)
→ review every remaining row; Python/curl UAs from VPS space is the campaign

Persistence audit — a service principal doing admin things is the tell:


# Entra audit log, Oct 13 → now
operation IN ("Add service principal credentials", "Add member to role",
  "Update application", "Consent to application")
AND initiated_by is a service principal (not a user)
→ every hit gets a human review; self-role-assignment = incident


# Splunk (Azure Monitor diagnostic export shape: category / properties.*; tune field
# names to your add-on). Includes the REMOVE and DELETE operations: Huntress's Oct 26
# telemetry shows the persistence as churn and cleanup, not just additions.

index=* category=AuditLogs earliest=-30d@d
    operationName IN ("Add service principal credentials", "Remove service principal credentials",
      "Update application – Certificates and secrets management",
      "Add member to role", "Remove member from role",
      "Add app role assignment to service principal",
      "Update application", "Consent to application",
      "Remove service principal", "Delete application")
    properties.initiatedBy.app.servicePrincipalId=*
| rename properties.initiatedBy.app.displayName as actor_app,
    properties.initiatedBy.app.servicePrincipalId as actor_sp,
    properties.targetResources{}.displayName as target
| stats count values(target) as targets min(_time) as first max(_time) as last
    by actor_app actor_sp operationName

# churn: the same target gets an add AND a remove of credentials or roles inside 60 min

index=* category=AuditLogs earliest=-30d@d
    operationName IN ("Add service principal credentials", "Remove service principal credentials",
      "Add member to role", "Remove member from role")
| rename properties.targetResources{}.displayName as target, properties.initiatedBy.app.displayName as actor_app
| eval verb=if(match(operationName, "^Add"), "add", "remove")
| transaction target maxspan=60m
| where mvcount(mvdedup(verb))=2
| table _time duration target actor_app operationName
→ any churn pair is a hands-on operator (or an agent retrying); a failed cert upload followed by
  a successful one within a minute is the same tell

# sibling-principal grant + cleanup: role granted to one principal, then another principal deleted

index=* category=AuditLogs earliest=-30d@d
    operationName IN ("Add member to role", "Remove service principal", "Delete application")
| rename properties.initiatedBy.app.displayName as actor_app
| transaction actor_app maxspan=30m
| where searchmatch("Add member to role") AND (searchmatch("Remove service principal") OR searchmatch("Delete application"))
| table _time duration actor_app operationName


// Elastic ES|QL (Azure Logs integration shape: azure.auditlogs.*; tune field names to your
// integration). Includes the REMOVE and DELETE operations: Huntress's Oct 26 telemetry shows
// the persistence as churn and cleanup, not just additions.

FROM logs-*
| WHERE @timestamp > NOW() - 30 days AND event.dataset == "azure.auditlogs"
    AND azure.auditlogs.operation_name IN ("Add service principal credentials", "Remove service principal credentials",
      "Update application – Certificates and secrets management",
      "Add member to role", "Remove member from role",
      "Add app role assignment to service principal",
      "Update application", "Consent to application",
      "Remove service principal", "Delete application")
    AND azure.auditlogs.properties.initiated_by.app.servicePrincipalId IS NOT NULL
| EVAL actor_app = azure.auditlogs.properties.initiated_by.app.displayName, actor_sp = azure.auditlogs.properties.initiated_by.app.servicePrincipalId,
    target = azure.auditlogs.properties.target_resources.0.display_name
| STATS count = COUNT(*), targets = VALUES(target), first = MIN(@timestamp), last = MAX(@timestamp)
    BY actor_app, actor_sp, azure.auditlogs.operation_name

// churn: the same target gets an add AND a remove of credentials or roles inside the same hour
// (ES|QL has no transaction; one-hour buckets stand in for the 60-minute span)

FROM logs-*
| WHERE @timestamp > NOW() - 30 days AND event.dataset == "azure.auditlogs"
    AND azure.auditlogs.operation_name IN ("Add service principal credentials", "Remove service principal credentials",
      "Add member to role", "Remove member from role")
| EVAL target = azure.auditlogs.properties.target_resources.0.display_name, actor_app = azure.auditlogs.properties.initiated_by.app.displayName,
    verb = CASE(STARTS_WITH(azure.auditlogs.operation_name, "Add"), "add", "remove"), window = DATE_TRUNC(1 hour, @timestamp)
| STATS verbs = COUNT_DISTINCT(verb), first = MIN(@timestamp), last = MAX(@timestamp), ops = VALUES(azure.auditlogs.operation_name), actors = VALUES(actor_app) BY target, window
| WHERE verbs == 2
| KEEP first, last, target, actors, ops
// any churn pair is a hands-on operator (or an agent retrying); a failed cert upload followed by
// a successful one within a minute is the same tell

// sibling-principal grant + cleanup: role granted to one principal, then another principal deleted

FROM logs-*
| WHERE @timestamp > NOW() - 30 days AND event.dataset == "azure.auditlogs"
    AND azure.auditlogs.operation_name IN ("Add member to role", "Remove service principal", "Delete application")
| EVAL actor_app = azure.auditlogs.properties.initiated_by.app.displayName, window = DATE_TRUNC(30 minutes, @timestamp)
| STATS grants = COUNT(*) WHERE azure.auditlogs.operation_name == "Add member to role",
    cleanups = COUNT(*) WHERE azure.auditlogs.operation_name != "Add member to role",
    first = MIN(@timestamp), last = MAX(@timestamp), ops = VALUES(azure.auditlogs.operation_name) BY actor_app, window
| WHERE grants > 0 AND cleanups > 0
| KEEP first, last, actor_app, ops

Issuance-then-use — tests the "no consent, no access" assumption instead of inheriting it (inferred; derived from the Chorus note above):


# stage 1: exported Entra SP sign-in logs, Oct 1 → now

service_principal_id = 00000000-0000-0000-0000-000000000000 AND result = success
→ set of appIds that obtained a token under an unresolved service principal

# stage 2: Graph activity logs, same window

appId IN (stage 1) AND response_status 200–299
→ any row = the app acted despite "no permissions"; review requestUri per appId

# prerequisite: Microsoft Graph activity logs must be ENABLED (diagnostic setting) — Huntress
# could not see them in affected tenants. If yours are off, stage 2 is a coverage gap, not a clean result
# noise: run stage 1 on service-principal (app-only) sign-ins ONLY — Microsoft first-party
# apps authenticate SP-less by design and surface in USER sign-in logs. The publisher is ON
# THE SIGN-IN RECORD: Azure Monitor table AADServicePrincipalSignInLogs has column
# AppOwnerTenantId, "the tenant identifier of the owner of the application" (confirmed against
# the Learn schema page Oct 27). Graph API: signIn.appOwnerTenantId exists on BETA only — a v1.0
# pull will not have it. Event-hub/Splunk key `properties.appOwnerTenantId` is inferred from the
# column name (Microsoft documents no sample record); tune to your add-on. Use it there — an
# SP-less app has no service principal in your tenant to look up, and the MicrosoftGraphActivityLogs
# schema has no owner or publisher column at all. Microsoft tenants (per Microsoft's own
# verification page, which lists them as "some, but not all"):
#   f8cdef31-a31e-4b4a-93e4-5f571e91255a  = "the Microsoft Service's Microsoft Entra tenant ID"
#                                            (Microsoft names no example apps for it)
#   72f988bf-86f1-41af-91ab-2d7cd011db47  = tenant of "Microsoft tenant-owned applications"
#                                            (Graph Explorer, Microsoft Graph Command Line Tools)
# These two are the documented ones; Microsoft operates more publisher tenants, so do not
# treat the pair as complete. The set grows ONLY by Microsoft documentation: a documented owner
# tenant, or an appId in Microsoft's service principal reference table
# (learn.microsoft.com/entra/identity/monitoring-health/reference-service-principal-table,
# "not exhaustive and will grow over time"). For any other owner tenant, name it with
#   GET /tenantRelationships/findTenantInformationByTenantId(tenantId='<appOwnerTenantId>')
# but a Microsoft-looking displayName is free text and promotes nothing: hold it, do not drop
# it. The daily-updated community list at github.com/merill/microsoft-info (keyed by appId)
# is the cross-check.


# Splunk (Azure Monitor diagnostic export shape: category / properties.*; tune field
# names to your add-on). Publisher filter lives in the sign-in subsearch, not in stage 2.

index=* category=MicrosoftGraphActivityLogs earliest=-30d@d latest=now
    [ search index=* category=ServicePrincipalSignInLogs resultType=0
        properties.servicePrincipalId="00000000-0000-0000-0000-000000000000"
        earliest=-30d@d latest=now
      | where NOT 'properties.appOwnerTenantId' IN ("f8cdef31-a31e-4b4a-93e4-5f571e91255a",
            "72f988bf-86f1-41af-91ab-2d7cd011db47")
      | stats count by properties.appId
      | fields properties.appId ]
| rename properties.appId as appId, properties.responseStatusCode as code,
    properties.requestUri as uri
| where tonumber(code)>=200 AND tonumber(code)<300
| stats count as ok2xx values(uri) as uris min(_time) as first max(_time) as last by appId


// Elastic ES|QL (Azure Logs integration shape: azure.signinlogs.* and azure.graphactivitylogs.*;
// tune field names to your integration). No subsearch: both stages are read in one pass and
// joined by aggregation on appId — a row needs at least one zeros sign-in AND one 2xx Graph call.
// The owner-tenant key on the sign-in record is inferred from the Azure Monitor column name, as above.

FROM logs-*
| WHERE @timestamp > NOW() - 30 days
| WHERE (event.dataset == "azure.signinlogs" AND azure.signinlogs.category == "ServicePrincipalSignInLogs" AND event.outcome == "success"
      AND azure.signinlogs.properties.service_principal_id == "00000000-0000-0000-0000-000000000000"
      AND NOT azure.signinlogs.properties.app_owner_tenant_id IN ("f8cdef31-a31e-4b4a-93e4-5f571e91255a", "72f988bf-86f1-41af-91ab-2d7cd011db47"))
   OR (event.dataset == "azure.graphactivitylogs" AND http.response.status_code >= 200 AND http.response.status_code < 300)
| EVAL app_id = COALESCE(azure.signinlogs.properties.app_id, azure.graphactivitylogs.properties.app_id)
| STATS zeros = COUNT(*) WHERE event.dataset == "azure.signinlogs", ok2xx = COUNT(*) WHERE event.dataset == "azure.graphactivitylogs",
    uris = VALUES(url.original), first = MIN(@timestamp), last = MAX(@timestamp) BY app_id
| WHERE zeros > 0 AND ok2xx > 0

SP-less inventory — the class this incident belongs to. Pull the app-only sign-ins with the all-zeros ID, group by appId and the owner tenant printed on the record, and sort into three lanes. The full procedure, as four jobs (scoping, baselining, alerting, investigating) with the diagram and queries, is the companion note SP-less triage loop; it opens with a plain-language summary for readers new to service principals.

Lane 1 · Microsoft publisher

Ignore

Microsoft's own apps authenticate without a service principal by design. The sign-in record's appOwnerTenantId identifies them when it is a tenant Microsoft documents, or the appId is in Microsoft's reference table. A Microsoft-looking tenant name admits nothing.

Lane 2 · Known vendor

Watch

A grandfathered app reaching your tenant with no consent to revoke — a ConnectWise-shaped exposure. Admission is the vendor's exact defaultDomainName or a published appId, not a recognisable name. Re-check every baselined appId daily across all its sign-ins, zeros or real SP: new credential keyId, new resource, new ASN, failures. Hunt zeros → 2xx Graph activity (issuance-then-use) and the roles/credentials it touches (persistence audit).

Lane 3 · Unknown vendor

Diff daily, then file or block

Alert only on a new appId. Look it up: known → lane 2; Microsoft-named but undocumented, null owner, or no lookup result → hold; nobody's, lookup answered, second analyst → create a service principal for the appId, then disable it, record its objectId. Undo is deleting that SP. Breaks loudly if something depended on it — which is the point.

"Lateral movement" in this class is identity-side — role grants, credentials added to other apps, mailbox and Graph access — not host-to-host. The list is finite: Microsoft announced a block on SP-less auth for non-Microsoft multitenant apps for March 31, 2026, and its FAQ still lists "a few Microsoft and non-Microsoft applications" as exceptions; anything still doing it is one of those, so a non-Microsoft owner here is rare and high-prior. A zeros "Success" is issuance only; Graph is the one resource that then evaluates the empty token per request and logs it, which is why issuance-then-use runs on Graph activity. Scope: a vendor app you consented to has a real service principal and never appears in the zeros view; only the per-appId re-check sees its stolen credential used from new infrastructure.


# service-principal (app-only) sign-ins ONLY — user-log zeros are first-party apps refreshing
# tokens for users and carry the user's device IP; ignore them

index=* category=ServicePrincipalSignInLogs earliest=-90d@d
    properties.servicePrincipalId="00000000-0000-0000-0000-000000000000"
| rename properties.appOwnerTenantId as owner_tenant, properties.appId as app_id,
    properties.appDisplayName as app_name
| fillnull value="-" owner_tenant app_name
| eval bucket=case(
    in(owner_tenant, "f8cdef31-a31e-4b4a-93e4-5f571e91255a", "72f988bf-86f1-41af-91ab-2d7cd011db47"), "microsoft",
    owner_tenant="-", "hold: no owner on record",
    true(), "resolve")
| stats count min(_time) as first max(_time) as last values(properties.resourceDisplayName) as resources
    by bucket owner_tenant app_id app_name
| sort bucket

# fillnull matters: stats "by" silently drops rows where any by-field is null, so without it the
#   no-owner rows never appear
# bucket "microsoft" = documented Microsoft owner: expected, file as lane 1 (cross-check a few by
#   appId against Microsoft's service principal reference table / github.com/merill/microsoft-info)
# bucket "resolve": name owner_tenant with findTenantInformationByTenantId. Vendor's exact domain
#   or published appId = lane 2 (its breach lands here with no consent to revoke). Microsoft-named
#   but undocumented, lookalike domain, or no lookup result = hold. Nobody's, lookup answered,
#   second analyst = lane 3: create a service principal for the appId, then disable it (Microsoft's
#   documented block); verify via Graph that it exists with accountEnabled=false
# no Get-MgServicePrincipal step: an SP-less app has no service principal in your tenant to read
# Microsoft's own inventory call (reports FAQ, beta required; v1.0 returns no SP sign-ins):
#   GET /beta/auditLogs/signIns?$filter=signInEventTypes/any(t: t eq 'servicePrincipal')
#       and servicePrincipalId eq '00000000-0000-0000-0000-000000000000'

→ three jobs. SCOPING (daily): this query; a NEW (owner_tenant, appId) gets a lane. BASELINING (once per
  laned appId): baseline 90 days of ALL its sign-ins, zeros or real SP — credential keyIds, resources,
  ASNs, first seen — into the kept list. ALERTING (daily): every baselined appId against its row; new
  credential keyId outranks new ASN; failures from a new ASN count; append to the baseline only when
  a case closes, never rebuild it from raw.


// service-principal (app-only) sign-ins ONLY — user-log zeros are first-party apps refreshing
// tokens for users and carry the user's device IP; ignore them. Elastic ES|QL over the Azure Logs
// integration (azure.signinlogs.*); the owner-tenant key is inferred from the Azure Monitor column
// name, as in the Splunk block. STATS BY keeps null keys, so no fillnull step is needed here; the
// "-" stands in only so the bucket label reads the same.

FROM logs-*
| WHERE @timestamp > NOW() - 90 days AND event.dataset == "azure.signinlogs" AND azure.signinlogs.category == "ServicePrincipalSignInLogs"
    AND azure.signinlogs.properties.service_principal_id == "00000000-0000-0000-0000-000000000000"
| EVAL owner_tenant = COALESCE(azure.signinlogs.properties.app_owner_tenant_id, "-"), app_id = azure.signinlogs.properties.app_id,
    app_name = COALESCE(azure.signinlogs.properties.app_display_name, "-")
| EVAL bucket = CASE(
    owner_tenant IN ("f8cdef31-a31e-4b4a-93e4-5f571e91255a", "72f988bf-86f1-41af-91ab-2d7cd011db47"), "microsoft",
    owner_tenant == "-", "hold: no owner on record",
    "resolve")
| STATS count = COUNT(*), first = MIN(@timestamp), last = MAX(@timestamp), resources = VALUES(azure.signinlogs.properties.resource_display_name)
    BY bucket, owner_tenant, app_id, app_name
| SORT bucket

// the buckets, the lanes and the three jobs (scoping, baselining, alerting) are as described under
// the Splunk block: same lanes, same daily diff, same baseline per appId

Network fallback — if Entra retention fails you, the infrastructure is still checkable:


# firewall / proxy / flow logs
dest IN (144.172.64.60, 144.172.64.99, 144.172.64.128, 144.172.94.243)
→ any match from identity infrastructure or egress since Oct 1

Timeline

Sources

Microsoft Security Blog — Storm-0940 / ARTEX, Oct 21 · Huntress — vendor credential abuse · Huntress — scripts, people, and AI agents (Oct 26) · BleepingComputer · The Hacker News — ARTEX attribution, Oct 22 · Brandefense — tenant checks · Chorus Cyber — advisory, Oct 19 · Microsoft — retirement of service principal-less authentication · Microsoft Entra blog — service principal required · ConnectWise Trust Center — live advisory

Public OSINT only — contains no internal data Marcus Liberto · source Lane and triggers per CIRT triage-flow draft Tracked story: Cutsign 10-19 → 10-22 (Storm-0940 attribution)