❮ Back to incident watch

Tenant Spray - Wider SaaS Supply Chain Targeting Beyond ConnectWise

In mid-October, an attacker used stolen ConnectWise credentials to request access tokens for Microsoft 365 tenants, including many that never had the app installed. Huntress, Todyl, and ConnectWise have published their findings. On October 21 Microsoft attributed the activity to Storm-0940, a China-state-aligned actor.

Obsidian Security analyzed the campaign using our own Entra ID and Okta data across impacted customers. Most of what we observed lines up with the public reporting, but our wide network intelligence and detection around SaaS supply chain adds additional findings and context.

  • The threat actor gained additional unauthorized access to the integration credential belonging to the vendor Atera.
  • A new technique we call “tenant spray” is used to amplify the impact of the integration compromise.
  • The same infrastructure was password spraying Okta five days before the token activity.
  • One of the actor’s IP addresses was still active as of October 21.
  • Insights on how to read the Entra service principal sign-in logs from this incident, since several of the most important events show up as failures.
October 23, 2026

Threat Overview

The campaign is notable for three recurring elements:

  • Vendor app credentials used directly. The actor authenticated as the vendor’s application with no user involved, sending requests to tenants that had the app installed as well as to many that didn’t. Entra provides different responses in each case - which provide context about what the actor held.
  • More than one vendor. Alongside ConnectWise, the actor tried app credentials belonging to Atera with a valid secret, and a legal-sector IT provider, which failed because the secrets were invalid. The Atera attempts went to the same organizations, in the same order, as one of the ConnectWise passes.
  • Password spraying from the same infrastructure. The actor sprayed Okta and Entra from the same hosts it used for the app credentials, starting October 10 and continuing as late as October 21.

Attacker Infrastructure

All of the activity we attribute to the threat actor came from four IP addresses at Frantech Solutions (AS14956). None of them appear anywhere in our telemetry before the dates below, and during the campaign they carried nothing but the actor’s scripted tooling.

IP Active (UTC) Activity Result
144[.]172[.]64[.]99 Oct 10–13 Okta password spraying and probing All attempts failed
144[.]172[.]64[.]128 Oct 13–15 Okta password spraying, legal-IT provider app credentials, ConnectWise app credentials Several ConnectWise tokens issued, 1 with permissions; all other attempts failed
144[.]172[.]64[.]128 Oct 21 Okta and Entra password spraying at a new organization No successful sign-ins
144[.]172[.]64[.]60 Oct 16–18 Legal-IT provider app credentials All failed; secrets were invalid
172[.]86[.]125[.]73 Oct 18–19 Atera app credential All refused because Atera wasn’t installed; the credential itself was valid

Tenant Spray

Tenant spray is what we’re calling the core technique in this campaign. The actor takes a credential for a multi-tenant app, a client secret or certificate it shouldn’t have, and uses it to request tokens from one tenant after another. It’s a password spray turned around: instead of one password tried against many accounts, it’s one app credential tried against many organizations, including ones that never installed the app.

This is different from Salesloft Drift and Klue, where attackers used OAuth tokens that had already been issued. With the app credential itself, the actor can mint new tokens in any tenant where the app is installed and enabled, and can test the credential against any tenant. These are app-only sign-ins, so there’s no user and no MFA prompt. Conditional Access doesn’t help either, as user policies don’t apply to service principals, and Microsoft’s Conditional Access for workload identities only covers single-tenant service principals registered in your own tenant. For a vendor’s app, the only native control a tenant has is disabling it. Tenant spray is also cheap to run at scale: Huntress identified almost 1,000 Entra tenants with sign-in activity from 144[.]172[.]64[.]128

What Each Response Tells The Attacker

A tenant spray doesn’t have to succeed to be useful. Entra’s token endpoint returns the error code to whoever made the request, so every attempt tells the actor something about the credential and the tenant. The same result lands in the tenant’s service principal sign-in logs. Two fields carry most of it: the servicePrincipalId, which is all zeros when the app has no service principal in the tenant, and the error code.

Service principal ID Error code What happened What the attacker learns
Present 0 Token issued with whatever permissions the app holds in this tenant They can access the tenant
All zeros 0 Token issued, but the app isn’t installed, so the token has no app roles (an SP-less token) The credential works
All zeros AADSTS7000229 Refused because the app has no service principal in this tenant The credential works; the app isn’t installed here
All zeros AADSTS7000215 Refused because the client secret is invalid The credential doesn’t work; unknown if the app is installed

The error code that matters most is 7000229. It looks like a failure, but Entra checks the credential before it checks for a service principal. In our testing, a valid secret returned 7000229 and an invalid one returned 7000215. So when 7000229 comes from infrastructure that isn’t the vendor’s, someone else holds a working credential. That’s how we know the Atera secret is valid, even though none of the Atera attempts produced a token.

An important note is that SP-less tokens on their own aren’t a sign of compromise. Third-party apps receive about 3,000 a day across our customers on average. The ConnectWise ones stood out because of where they came from, the client that requested them, and the fact that the app was never installed.

Tenant Spray in This Campaign

  • ConnectWise: one credential sprayed across many tenants in three scripted passes. It produced every result above except 7000215, including one token with real permissions.
  • Atera: a second vendor’s credential, sprayed against the same target list as one of the ConnectWise passes. Every attempt returned 7000229, so the actor learned the secret works.
  • A legal-sector IT provider: related credential testing rather than a spray. Five of the provider’s app credentials were tried against two law firms, and every attempt returned 7000215.

Each one is covered in the Incident Breakdown below. From inside any single tenant, a spray looks like one token request or a few failed sign-ins. We come back to why that makes it hard to catch in the Detection and Hunting section.

Incident Breakdown

We analyzed the campaign across affected customers using Entra ID and Okta telemetry. The activity is broken out below in the order it happened.

User Credential Spray (October 10–13)

The first activity we tie to the actor started on October 10 at 17:00 UTC, when 144[.]172[.]64[.]99 began password spraying one organization’s Okta tenant. It tried 51 usernames, once each, first with curl/7.88.1 and then with a spoofed Chrome user agent. Okta ThreatInsight blocked the spray, and the actor came back about 12 hours later with a different spoofed user agent. Nothing succeeded.

On October 13, the same IP probed a second organization’s Okta tenant with failed logins, failed OAuth authorizations, and a recovery token attempt. Between 19:09 and 19:29 UTC, 144[.]172[.]64[.]128 hit the same tenant at the same time. Two days later, that IP is the one that requested the ConnectWise tokens.

Microsoft reported this same IP spraying Microsoft 365 accounts across eight organizations. We only observed it in Okta. Both of these targets federate authentication to Okta, so a failed Okta login never produces an Entra sign-in. If your Microsoft 365 tenant is federated to Okta, check the Okta System Log for the IPs in this post.

Integration Tenant Spray

First Victim: ConnectWise  (October 15)

On October 15, 144[.]172[.]64[.]128 used the ConnectWise credential in three passes:

  • Pass 1 (07:24–08:28 UTC, Python-urllib/3.12): several tenants. One had ConnectWise Cloud Backup installed and received a token with the app’s permissions. The others received SP-less Graph tokens.
  • Pass 2 (09:25–10:08 UTC, & 12:33 UTC, python-requests/2.31): additional tenants, all SP-less Graph tokens. One single outlier tenant was hit at 12:33.
  • Pass 3 (13:53–14:22 UTC): the same tenants as pass 2, six resources each, about one request per second: Exchange Online, SharePoint Online, Teams, Azure Resource Manager, Azure AD Graph, and the Office 365 Management API. All requests were refused with 7000229, even though the same credential had received Graph tokens in the same tenants a few hours earlier.

Passes 2 and 3 line up with what Huntress describes as the second targeted wave. Passes 1 and 3 worked through tenants in ascending tenant ID order, consistent with the sorted crawl other researchers described.

Additional Attempts: A Legal-Sector IT Provider (October 14–18)

On October 14, 144[.]172[.]64[.]128 tried app credentials belonging to a legal-sector IT provider against a law firm. On October 16 and 18, 144[.]172[.]64[.]60 came back to the same firm and a second one, adding two more of the provider’s apps. Across 24 attempts and five apps, every one failed with 7000215. The actor knew the app IDs but didn’t have working secrets.

The client changed on nearly every run, from a bare Mozilla/5.0 to curl, Python, and a spoofed Chrome user agent, but the apps and the alternating order between the two firms stayed the same. The provider’s own automation calls one of the same apps daily from AWS and gets 7000229, which is what a valid secret looks like. Neither law firm saw any ConnectWise activity, which fits Huntress’s report of the actor spraying law firms on October 13 and 14. We don’t know how the actor got these app IDs, and we have no evidence the provider itself was compromised. However, this activity is a suggestion of how widespread the campaign was.

Atera - Compromised Integration Credential Use (October 18-19)

From 23:58 UTC on October 18 to 00:20 on October 19, 172[.]86[.]125[.]73 tried an Atera app credential against Microsoft Graph several organizations, in two bursts about 15 minutes apart, using curl/8.0. All ten attempts returned 7000229. The secret is valid, and the attempts only failed because Atera wasn’t installed in those tenants.

The target list ties this to the ConnectWise activity. The Atera sweep hit the same organizations, in the same order, as ConnectWise Pass 2, including a repeat of the first organization at the end. That order isn’t sorted, so it isn’t something a generic crawl would reproduce. The only Pass 2 tenant missing is the one hit separately at 12:33.

We didn’t see the Atera credential succeed in any of our monitored tenants.

Obsidian reached out to Atera on 20 October regarding the activity we observed. Atera was responsive to this communication - in response claiming that they were aware of the issue, had investigated it thoroughly, and that it was contained and necessary measures have been taken.

On 23 October, Atera provided the following statement: “Atera was aware of and was already investigating this activity and taking the necessary actions before being contacted by Obsidian, engaged external incident response specialists, and mitigated the attack. In addition, Atera services were not affected.”

Continued Activity (October 21)

On October 21, from 09:16 UTC until at least 22:22, 144[.]172[.]64[.]128 password sprayed Okta and Entra at an organization outside both the ConnectWise and Atera target lists. It used the same toolkit as the October 10 spray: curl/7.88.1, a bare Mozilla/5.0, and spoofed Chrome user agents. We saw no successful sign-ins. This IP was still in use a week after the ConnectWise apps were disabled, so it’s worth blocking, not just hunting for.

Other Deviations

Beyond timing and targets, a few other signals separated the actor from legitimate traffic. They were:

User Agent

Legitimate ConnectWise Cloud Backup sign-ins carry no user agent at all. One tenant logged roughly 378,000 sign ins within 119 days, every one blank. The actor’s sign-ins used Python-urllib/3.12 and python-requests/2.31, so any user agent on this app was a deviation.

In the Okta spraying and app credential attempts againt the law firms, The spoofed browser user agents had hand-written Chrome versions with only one or two parts, such as Chrome/126, Chrome/124.0 and Chrome/120.0. Real Chrome sends four parts, like Chrome/124.0.0.0 . It’s a useful pivot, but not unique to this actor, since other spray tools do the same thing.

Microsoft lists python-requests/2.34.2 and curl/7.88.1 as indicators. Both are common. python-requests/2.34.2 alone appeared in more than 129 million events across ~100 customers this month, so it won’t find much on its own. Microsoft’s other user agent indicator, azure-toolkit/1.0, is the opposite. We haven’t seen it anywhere across our customers in four months, so any hit is worth a look.

Infrastructure

Legitimate ConnectWise sign-ins came from Microsoft’s internal IPv6 space. The actor’s came from a VPS provider. A few other fields that look odd on these sign-ins, such as the 009ae85a- session ID prefix, an empty key ID, and the CAE flag, also appear on the legitimate ConnectWise traffic, so they don’t separate the actor from the vendor.

The three 187.15.x.x IPs in Microsoft’s report are NordVPN exit nodes. Real users at a number of our customers have signed in from them since August. They’re useful for hunting October 15 and 16, but blocking them will hit legitimate users.

How We Checked the Actor’s IPs

Frantech sells VPS and VPN hosting, so before tying everything on these four IPs to one actor, we wanted to rule out shared infrastructure.

We searched activity logs across all of our customers for any traffic from the four IPs, going back about four months for most customers. None of them appeared before the campaign, and the first event on each IP was already the actor’s tooling. We also checked a year of IP prevalence data and found no successful user activity from any of them.

To make sure that approach would catch a legitimate tenant, we ran the same checks against Frantech IPs that our customers’ users do connect from, including a corporate VPN. Those showed months of steady, multi-user activity and endpoint telemetry, and even single-day sessions showed up. During the campaign, every event from the actor’s four IPs came from scripted tooling, and two of them were used against the same tenant at the same time.

The IP ranges have been registered to Frantech since 2014, so we can’t say when these specific addresses were leased, and we can’t see how they were used outside our customer base. Within it, they carried the actor’s activity and nothing else.

Obsidian's Assessment

Microsoft attributes the ConnectWise compromise to Storm-0940, and ties the October 10 to 14 password spraying from 144[.]172[.]64[.]99 to the same actor. Our data agrees, and adds independent support from a different vantage point. Two of the actor’s IPs were used against the same Okta tenant at the same time. The IP that requested the ConnectWise tokens ran the same spray toolkit against a new target on October 21. The IP used against the law firms tried the same app IDs the token IP had tried days earlier. And the Atera sweep reused the target list and order from one of the ConnectWise passes. Huntress’s early reporting treated the spraying as likely unrelated, but Microsoft’s attribution and our data both point the other way.

We assess with moderate to high confidence that the password spraying, the ConnectWise token activity, the legal-IT provider attempts and the Atera sweep are the work of the same operator. The tooling did vary by task, curl for spraying and Python for tokens, so this could be one operator running several scripts, or access passed between people working together. Either way, it very likely indicates the same campaign.

Across our customers, over a dozen tenants received tokens from the ConnectWise credential, and one of those tokens carried the app’s permissions. We saw no post-authentication actions in the audit logs we collect. That differs from Microsoft’s reporting, which describes Graph reconnaissance, credential changes and role changes in other environments.

Taken together, the activity points to an actor that is collecting vendor app credentials and testing them against a prepared list of targets, rather than relying on a single compromise.

Detection and Hunting

Supply chain attacks are cross-tenant by nature, but each organization sees only its own slice. Inside a single tenant, this campaign showed up as a few isolated anomalies: SP-less tokens, 7000229 failures, and a vendor app signing in with an unusual user agent or from unusual infrastructure. On their own, these signals are noisy. In a 20-day backtest across our customers, a simple rule for each one fired hundreds of times a day, and the SP-less and 7000229 rule missed the one token that carried permissions. They also reveal little: most targeted tenants saw only a token request or a few failed sign-ins.

Obsidian builds a cross-org view of each supply chain integration, keyed on its app ID, which lets us combine these weak signals per app. For ConnectWise Cloud Backup, that view surfaced several deviations at once:

  • Infrastructure: sign-ins from new VPS IPs, where legitimate traffic had only ever come from Microsoft's network.
  • Client: Python user agents, on an app whose legitimate traffic has none.
  • Sign-in results: SP-less tokens and 7000229 failures across tenants that never installed the app.
  • Behavior: rapid requests for six Microsoft resources, sweeping through tenants in tenant ID order.

The one tenant with the app installed saw one sign-in from a new IP with a Python client, and nothing showed that the same credential from novel infrastructure was being used elsewhere. The tenants without the app saw an SP-less token or a few 7000229 failures, with no legitimate traffic to compare them against. Only a view across all of these tenants shows the sweep itself: over a dozen tenants in about seven hours. Combined on one app ID across organizations, these partial views add up to a strong, high-confidence signal. The cross-org baseline also gives each piece more weight on its own.

This is one of Obsidian's core advantages in supply chain detection. Signals that are noise in a single tenant become a more complete, higher-confidence detection when they're combined per integration across organizations. That detection also covers tenants where the app isn't installed.

For teams hunting in their own tenant, three signals matter most. The first is 7000229 failures from hosting or VPS providers, which mean someone outside the vendor holds a working credential. The second is SP-less tokens for vendor apps you never installed. The third is bursts of 7000215 across vendor apps from one IP. A user agent on an app whose legitimate traffic never has one is also worth a look. Give the most weight to signals that appear together for the same app. If your tenant is federated to Okta, also check the Okta System Log for the indicators below.

Indicators

Indicator Type Activity Action
144[.]172[.]64[.]128 IP ConnectWise token requests (Oct 15); password spraying (Oct 13–14, Oct 21); legal-IT provider app attempts Block
144[.]172[.]64[.]99 IP Password spraying against Okta and Microsoft 365 (Oct 10–14) Block
144[.]172[.]64[.]60 IP Vendor app credential attempts (Oct 16–18) Block
172[.]86[.]125[.]73 IP Atera app credential attempts (Oct 18–19) Block
187[.]15[.]88[.]186, 187[.]15[.]89[.]186, 187[.]15[.]90[.]217 IP Graph access through the compromised apps (Oct 15–16), per Microsoft. Shared NordVPN exits Hunt Oct 15–16 only; don’t block
Python-urllib/3.12 User agent ConnectWise token requests; app credential changes Context only
python-requests/2.31 User agent ConnectWise token requests Context only
curl/7.88.1 User agent Password spraying Context only
curl/8.0 User agent Atera sweep Context only
Chrome UA with a one- or two-part version (e.g. Chrome/124.0, Chrome/126) User agent Spoofed browser UA in spraying and app credential attempts Context only
python-requests/2.34.2 User agent Reported by Microsoft. Very common in normal traffic Context only
azure-toolkit/1.0 User agent Reported by Microsoft. Rare Hunt; any hit is worth a look
506e08f9-6e9a-4da4-942b-a099a03644ae App ID ConnectWise Cloud Backup. Credential used to obtain tokens Disable the service principal; don’t block the ID
24205314-2b89-4e07-a2a4-579e898a5640 App ID ConnectWise Cloud Management – MS Teams. Self-assigned Global Administrator in at least one tenant Disable the service principal
0de14c77-b9b0-4137-9360-34878a929827 App ID ConnectWise Cloud Management – MS Graph Disable the service principal
b78f6a77-d4e5-46fb-87e5-d1648bd02519 App ID ConnectWise Cloud Management – Exchange Online Disable the service principal
b2b55f5b-7758-4719-8d8f-88ae172e6bdb App ID ConnectWise Cloud Management – SharePoint Online Disable the service principal
412dcf96-176d-4b3a-a1b0-4981ad66971e App ID ConnectWise Cloud Management – Partner Center Disable the service principal
2399d300-6fca-4e6a-a686-399fefe75b83 App ID ConnectWise Cloud Management Disable the service principal
3f9cd7a0-127a-4ed4-bf06-2f830d5616b7 Tenant ID ConnectWise app owner tenant Hunt
90657e27-59e1-4bfa-86c3-03f59b731666 App ID Credential rotated by Atera. No longer valid. Review service principal sign-ins; don’t block the ID

Conclusion

The ConnectWise incident is another example of how quickly a trusted vendor credential becomes an attack path. Across the customers we analyzed, the signals were there: an app that only ever authenticated from Microsoft’s network suddenly signed in from a VPS provider, with a scripted client, against tenants that never installed it. Several of the most important events were logged as failures, and some of the related password spraying only showed up in Okta.

It also wasn’t limited to one vendor. The same actor tested credentials from a legal-sector IT provider and from Atera against an overlapping set of targets, and kept going after the ConnectWise apps were disabled. That pattern is the bigger risk. Every multi-tenant app with a client secret is a potential entry point if that secret leaks.

Vendor credentials will keep leaking. The question for security teams is whether they’ll know which of their integrations could be used against them, and whether they’ll see it when one is.

Obsidian helps teams do exactly that. Customers use Obsidian’s posture management and supply chain security to inventory the third-party apps and integrations connected to Microsoft 365 and other SaaS platforms, flag the ones holding high-risk permissions, and see which tenants and users are tied to each. Obsidian’s threat detection baselines how each integration normally behaves and flags deviations in infrastructure, clients and activity, across identity providers like Entra ID and Okta. When a vendor discloses a compromise, that makes it possible to answer the first questions quickly: are we exposed, did anything happen, and what do we disable?

Recommendations

For vendors, the work starts with the credentials themselves: rotate client secrets, remove ones that are no longer used, prefer certificates or managed identities where possible, and keep a clear record of where your app legitimately authenticates from so customers can tell when it doesn’t.

For customers, the priority is knowing which vendor apps can reach your Microsoft 365 tenant and what they can do once they’re in:

  • Inventory the third-party service principals in your Entra tenant and review the permissions each one holds. Application permissions to mail, files or the directory deserve the closest look.
  • For vendor apps you don’t use, create the service principal and disable it, so the app can’t get tokens in your tenant.
  • Review service principal sign-ins for 7000229 and 7000215 from unfamiliar infrastructure, not just successful sign-ins.
  • If your tenant is federated to Okta, check the Okta System Log as well as Entra.

‍