AI, Scripts, Operator - Inside the ConnectWise Attack

Key Takeaways

  • After Huntress initially reported the abuse of a ConnectWise Cloud Backup credential and subsequent downstream impact last week, Microsoft attributed the activity to Storm-0940 using ARTEX, an AI agent framework. 

  • Our telemetry is consistent with Microsoft's findings, though we cannot verify it independently. Our working hypothesis points to an AI agent leveraging scripted tooling, with occasional human intervention.

  • On October 15, we saw three modes of operation: scripted enumeration, hands-on operator work, and tooling that kept firing even after its target was gone. Based on our telemetry alone we are at low-to-medium confidence that an LLM was involved, and at medium-to-high with Microsoft's attribution.

  • The potential indicators of AI use we saw, linked to patterns around cadence, retry loops, and user agents, will fade as these tools improve. The best method for defenders moving forward is to detect what the activity does, not how it paces itself.

Acknowledgments: Special thanks to Eric Stride and Andrea Hatcher for their contributions to this investigation and writeup.

Background

On October 16, Huntress first reported the abuse of a ConnectWise Cloud Backup credential to access Microsoft 365 tenants, and since then we have been continuing to investigate threat actor behavior related to this incident.

Now, we're pulling on a different thread: who, or what, was doing the typing during the unauthorized activity we observed. Microsoft has published its own findings, attributing the activity to a threat actor it tracks as Storm-0940, which it says used ARTEX, an open-source multi-agent AI framework, to abuse the compromised ConnectWise application credentials and sign in to downstream Microsoft 365 tenants at machine speed. Our telemetry aligns with Microsoft's findings, but does not independently confirm them.  

In the activity on the tenants we examined, we observed the tooling change in under a minute between runs and a retry loop appear in the run after a failure. Some actions were tested in one tenant before being run in batches, a pattern we saw three times across two tools. 

Taken together, these observations form our working hypothesis: scripted tooling did most of the heavy lifting in this incident, while something adapted the tooling between runs, quickly. That something may very well be an agent. Below, we're providing details about what we saw, and leaving it to the audience to draw their own conclusions.

What we can and cannot see

Our view comes from Entra Sign-in logs and directory audit logs in the Microsoft 365 tenants we monitor, plus the fleet-wide sweeps we ran during the incident window. That gives us the timing, order, and shape of what the actor caused Entra to record. However, it leaves some large gaps:

  • Microsoft Graph reads are not recorded in the Entra audit log, and no victim we know of had Graph activity logging enabled, so the scope and method of any Graph access cannot be seen in the logs available to us.

  • Application-only audit events carry no source IP address, so we cannot tie some of the activity, such as the credential changes, to a specific IP.

  • We have no visibility into the actor's own systems, so we cannot see the tooling itself, or whether any model provider sat behind it.

Lastly, we cannot say whether AI was used to plan the operation, develop target lists, or write the tooling.

One day, three modes of operation

Most of what we can see happened on October 15, in a little under 14 hours. Laid out in a single timeline, the activity falls into three distinct behaviors: 

  • Scripted loops

  • Hands-on operator work (an "operator" being whoever steered the tooling, whether that's a person, automation, or an AI agent)

  • A stretch where the tooling kept running after the thing it was targeting was already gone

Figure 1: Activity on October 15 in Huntress-monitored tenants, grouped by behavior. Operator-driven means a person, or a model acting for one, was steering the tooling between runs.

How scripts, people, and AI agents look different in logs

The logs Huntress analyzes record behavior and help us figure out what occurred: how fast things happen, what happens after something fails, and how much the requests vary. The logs may show different types of fingerprints depending on if the behavior comes from a script, a person, or an LLM agent. Below is a table of possible behavioral signals between the three, though these are general characteristics and may have substantial overlap.

Signal

Scripted automation

Human operator

LLM agent in the loop

Pacing

Machine-regular, sub-second or fixed intervals

Uneven, with pauses that look like reading output

Steady, gated by model inference

Response to errors

None unless coded, repeats the same failure

Reads the error, adjusts, or gives up

Reads the error and changes approach

Variety of requests

Identical request shape, repeated

Efficient, goes to what it wants

Exploratory, retries with variations

User agent

One fixed string per tool

Varies with the tools the person picks up

Often SDK defaults (python-requests, httpx)

These categories are Huntress analyst heuristics for telling the three apart, not an industry standard. A tricky nuance here is that this isn't a black-or-white determination: no single row completely settles what's primarily behind the attack. For instance, a script can carry a sleep timer and a list of user agents, which could make its behaviors in the logs look more human than it is. One password spray against a single tenant may at first read like a person, but a fixed beat in the timing and a rotating list of user agents would indicate otherwise. When investigating these signals, part of what matters is the mix and whether the behavior changes in response to what just happened. A script repeats itself; a person or an agent adjusts.

Two limits apply to everything in our analysis below: 

  • A person pasting commands from a chatbot into a terminal looks like a person in these logs, because the model never touches the API. 

  • Logs show who ran the tooling, not who wrote it; a model can write a fixed script that looks like any other script.

What we saw 

The loops: scripts at scale

The logs showed us that on October 15, between roughly 06:13 and 08:46 UTC, the credential was used against almost 1,000 tenants, one after another, in ascending tenant ID order. That is a loop over a sorted list: one token request per tenant, one to six minutes apart, with the same user agent (Python-urllib/3.12) every time. When the loop stopped partway through the list, it looked like an exhausted list or an interrupted process (as opposed to a concerted decision to stop). 

The logs also showed that nine minutes earlier, around 06:04 UTC, about 100 failed token requests had landed within 10 seconds against the first 100 tenants of that same list of 1,000 tenants. Every request failed the same way, and nothing changed between attempts. This is a signature of a thread pool: a fixed set of worker threads pulling tenants off a shared list and firing requests in parallel, each blind to what the others get back. Nothing in the loop reads a response before the next request goes out.

The same thread-pool tool then ran a second time against 100 tenants, using a different application's key, at 13 to 21 attempts per second, and failed the same way. Both runs used application secrets that belong to a single tenant, which cannot authenticate anywhere else, and the tool kept firing regardless. Whoever built it, or at least launched it, did not account for that difference.

Here is what that does and does not tell us: 10 requests a second is faster than anything that reads each response before deciding its next move, whether that is a person or a model working step by step. So this was most likely one script, run at least twice. It does not tell us who or what wrote the script. A model can write a thread pool as easily as a person can, so while this rules out a model making each call, it does not rule out a model writing the tool.

Trial and error

On October 15 between 03:42 and 03:57 UTC, the logs indicated a pattern of trial and error in the unauthorized activity, with the tooling changing in less than a minute between runs. The activity outlined in the logs looked like an attempt to establish persistent access by adding credentials: 

  1. A secret was added, removed, and added again. 

  2. A certificate was uploaded, but it failed because the thumbprint did not match. 

  3. The operator fell back to a secret again before granting, then removing, the Global Administrator role twice. 

The same sequence ran in a second tenant 47 seconds later, with both keys added in a single run. 

During that window the tooling changed in response to results at least three times. The run after the failed certificate upload added a retry loop, the sequence repeated in a second tenant with both keys in a single run, and when that tenant refused a Global Administrator removal, the actor granted a different role to a sibling principal, and the original service principal was then deleted.

Based on this activity, it's possible that the credential planting and the targeted probe were steered by an operator adapting between runs. However, we can't determine whether that operator was a person, automation, or an LLM model.

The hand-picked targets: pilot, then batch 

Between roughly 09:25 and 12:35 UTC on October 15, one sweep touched 17 tenants, with one Graph token request each in a hand-ordered sequence with irregular spacing over three hours. A second sweep at 13:54 UTC ran a fixed probe of six resources per tenant, in sorted order, with four seconds per tenant. Someone appears to have chosen the targets, checked them one at a time, and then handed the list to a script. 

Where it started to look like an agent

No single observation here proves anything, and we are not going to pretend otherwise. But several of them pointed in the same direction, and the longer we looked, the harder it became to explain them away as a person at a keyboard or a script left to run.

First, there's the 55-second gap between retries that we described above. The certificate upload that failed on a thumbprint mismatch at 03:48:24 UTC ended that run with no retry. The next run, 55 seconds later, did retry the same error, at 3.5 seconds and then 7.1 seconds, until it worked, and it carried the same retry into the second tenant. 

Figure 2: How the tooling changed

So what does this actually tell us about what was going on behind the scenes? Either someone edited the code in under a minute, or a prepared tool had a retry option nobody had used yet. A script left alone does not rewrite itself between runs. A person, a person relaying to a model, or an agent was likely adapting it as an operator. The speed of the change is consistent with an agent loop: read the failure, rewrite the call, run it again.

The retry logic is particularly revealing. Doubling the wait between attempts is textbook defensive error handling. This is something that generated code tends to add unprompted, and in this incident showed up in a tool that had none just a minute earlier. The tooling was also more elaborate than the job required: the 100-tenant burst used a thread pool, where a simple loop over the list would have done the same work.

Another notable pattern that we outlined above: An operator tried an action against one tenant first, then ran it as a batch, again and again. We saw that three times across two sets of tooling. That points to the habits of an operator, not a quirk of one script. Microsoft describes ARTEX as building a graph of the assets it discovers and running parallel breach paths, which has the same shape: probe, learn, and then fan out.

Finally, there is the speed. Our first enumeration activity started around 01:05 UTC, and the credential changes in the first tenant were complete by 03:50 UTC, in line with Microsoft's report of under three hours from targeting to privileged access in multiple environments. Speed alone does not separate an agent from a well-prepared script, but it is the kind of compression Microsoft describes agent frameworks delivering. Some of the user agent strings also look hand-set rather than library defaults. One tool identified itself as python-requests/2.31, where the library sends python-requests/2.31.0 by default, and on October 13 a browser string was missing its standard "(KHTML, like Gecko)" segment. Both fit a model producing user agents from memory, and both fit a person copying them from documentation.

Put together, these factors make up our working hypothesis: scripted tooling is doing the heavy lifting in this incident, with something adapting it between runs, quickly. That something may well be an agent. The next section is about how sure we are, and where the evidence runs out.

What the telemetry can confirm

A working hypothesis is only worth sharing if its limits are clear. Here are ours.

The best evidence against a fully autonomous agent is what happened after 03:57 UTC. The Teams service principal was deleted, and for the next 22 minutes requests using it kept firing every four to eight seconds. These were rejected every time, and subsequent requests didn't change. An agent watching its own output would be expected to stop. A multi-agent framework could keep unattended fan-out tasks running while other agents carry on, which could explain this behavior. So too could a person who launched some background loops and walked away. However we don't know which one played out in this situation.

Another consideration: curl/7.88.1 is the version Debian 12 ships with, and the official python:3.12 container image is built on Debian 12. That points to how the tooling was possibly packaged, since the user agent doesn't align with the system's external fingerprints.

The biggest gap is the missing Graph request logs. They would show query variety, the clearest difference between a script and an agent, and nobody we know of collected them.

So, how sure are we? We are highly confident that an operator, whether a person or an LLM model, adapted the tooling between runs. On our own telemetry alone, we put our confidence that an LLM was involved in the escalation tooling at low to medium. Taking Microsoft's attribution into account, we put it at medium to high. 

What would potentially help settle it is evidence we do not hold: the Graph activity logs from a victim that had them enabled, the tooling itself, or traffic from the actor's systems to a model provider's API.

What Microsoft says: Storm-0940 and ARTEX

In its October 21 report Microsoft attributes the ConnectWise credential abuse to Storm-0940, a threat actor they say has been active since 2021. Microsoft asserts that the actor targets organizations across industries for data theft, and has historically gotten in through password spraying and internet-facing applications, then kept access by adding credentials to existing OAuth applications.

What is new, according to Microsoft, is the tooling. They report that from at least September 2026 the actor used ARTEX, an open-source framework in which LLM-driven agents run a penetration test from reconnaissance through data exfiltration with little needed human interaction, building a graph of the assets and weaknesses they discover as they go. ARTEX can use any AI model, and Microsoft says it has seen other actors using it in several regions.

How does Microsoft connect that to this incident? The reasoning they've shared points to infrastructure, attributing a large password spray from 144.172.64[.]99 between October 10 and 14 to Storm-0940, and noting that the same IP, and the surrounding range, appear in the ConnectWise advisory. The report does not describe how Microsoft identified ARTEX, or what links the earlier spray to Storm-0940. We have not independently verified either.  However, Microsoft may know how to identify this particular tooling, which is why they have mentioned its usage in this campaign.

How our findings line up with Microsoft's report

Our telemetry is consistent with Microsoft's account in several ways.

  • We saw the same infrastructure (144.172.64[.]128) and user agents (Python-urllib/3.12, curl/7.88.1), and the same sequence: compromised application credentials, then Graph follow-on with credential additions and privileged role changes, including the Teams application assigning itself Global Administrator.

  • Our telemetry also corroborates the password spray Microsoft attributes to 144.172.64[.]99. 

  • Scale is where we differ, by design. Microsoft counts more than 2,250 tenants and we see only the tenants we protect, so our numbers are only a subset, not a contradiction. We saw python-requests/2.34.2 from 144.172.64[.]99 in the October 10 to 14 password spray, but not azure-toolkit/1.0, and we saw no identity or group changes in the two tenants where we hold directory audit logs.

Why this matters

Whoever, or whatever was behind it, this operation was not polished. Over the course of our investigation we saw: 

  • Secrets added, removed, and added again. 

  • A certificate that failed to upload. 

  • Secrets for one tenant sprayed at a hundred others, where they would never work. 

Yet, as we saw in the two tenants where we hold audit logs, it took under three hours from the first enumeration activity to privileged role changes, in line with Microsoft's report across multiple environments, because the stolen application credential did much of the work.

It's clumsy, but it worked. If an agent was in the loop here, it was an early one. It already ran things in parallel, never needed a break, and left defenders a few hours. Tools like this get better quickly: they stop making the mistakes we saw, and they are cheap to point at hundreds of tenants at once. Microsoft makes the same point: agents can work around the clock and attempt multiple breach paths simultaneously.

Defenders' visibility is also in flux. In our view, the signals we relied on here (a retry that showed up 55 seconds after a failure, a fixed beat between requests, and user agent strings customized away from the library default) are the first things a better tool will smooth away. Cadence will not be a reliable signal for long. What will hold up is what the activity does, which is where the detections below point.

What defenders can do

Speed is the biggest concern here. This activity moves faster than a person can triage. This is useful detection guidance regardless of the actor:

  • Alert when a service principal assigns itself Global Administrator or Privileged Role Administrator, deletes itself, or when one vendor principal grants a role to a sibling.

  • Alert on credential additions to high-privilege applications from unexpected sources.

  • Baseline each vendor application's normal user agent (kiota-dotnet for the ConnectWise applications), source ranges, and cadence. The clearest early signal here was a vendor application behaving differently from its baseline.

  • Turn on Microsoft Graph activity logs where licensing allows, and keep raw user agent strings and sub-second timestamps. Cadence and retry timing were our most useful signals, and normalization destroys both.

  • Watch across tenants, not only within one. A trickle in any single tenant can be one campaign running at thousands of attempts per minute across the estate.

We will update this post as ConnectWise, Microsoft, and others share more.

Indicators of Compromise (IOCs)

Item

Description

144.172.64[.]128

Source of application sign-ins and Graph activity (ConnectWise advisory, our telemetry)

144.172.64[.]99

Cross-tenant password spray October 10 to 14 (ConnectWise advisory, Microsoft, our telemetry)

194.165.16[.]21

Possible link based on re-use of python-requests/2.31 in a password spray during the same timeframe

144.172.64[.]60

Listed in the ConnectWise advisory and by Microsoft

Python-urllib/3.12

User agent, enumeration and escalation tooling

curl/7.88.1

User agent, consistent with a python:3.12 container

python-requests/2.31 (Microsoft lists 2.31.0)

User agent, targeted sweeps. Also listed as python-requests/2.31 in the ConnectWise advisory.

python-requests/2.34.2, azure-toolkit/1.0

User agents listed by Microsoft; not observed in our telemetry

MITRE ATT&CK

Tactic

Technique Mapped

Context

Resource Development

Obtain Capabilities: Tool (T1588.002)

The threat actor obtained open-source software and standard utility packages to execute operations, likely leveraging containerized tooling such as the official Debian 12-based python:3.12 image. 

Obtain Capabilities: Artificial Intelligence (T1588.007)

The threat actor obtained and integrated ARTEX to automate the attack lifecycle with minimal human intervention.

Acquire Infrastructure: Virtual Private Server (T1583.003)

The threat actor obtained VPS (144.172.64[.]X) hosting to execute multi-stage targeting and obfuscate operational origin across distinct operational windows.

Initial Access

Valid Accounts: Cloud Accounts (T1078.004)

The threat actor authenticated to downstream cloud environments using ConnectWise app-only tokens from 144.172.64[.]128.

Trusted Relationship (T1199)

The threat actor subverted trusted SaaS partner connections by leveraging compromised ConnectWise app credentials.

Persistence

Account Manipulation: Additional Cloud Credentials (T1098.001)

The threat actor established persistent cloud access through rapid credential manipulation on victim application identities. After an initial failure to upload an authentication certificate, the actor adapted tooling within 55 seconds to incorporate retry logic, successfully establishing secondary credentials before replicating the technique across a second tenant 47 seconds later.

Privilege Escalation

Account Manipulation: Additional Cloud Roles (T1098.003)

The threat actor leveraged Microsoft Graph API calls to execute privilege escalation, having the Teams application identity assign itself the Global Administrator role. To secure redundant administrative access across victim tenants, the actor also granted elevated roles to sibling service principals originating from the same operational infrastructure.

Stealth

Masquerading (T1036)

The threat actor manipulated network request metadata to blend into legitimate traffic, deploying hand-configured user-agent strings that deviated slightly from standard software defaults

Credential Access

Brute Force: Password Spraying (T1110.003)

Between October 10 and 14, the threat actor conducted broad password spraying campaigns originating from 144.172.64[.]99 to harvest valid credentials (ConnectWise credential acquisition method unknown).

Discovery

Account Discovery: Cloud Account (T1087.004)

The threat actor probed for valid cloud identities by utilizing scripted automation and high-speed, multi-threaded worker pools firing 13 to 21 requests per second, the actor attempted to discover accessible environments by replaying application credentials in rapid succession across the targeted tenant list.

Cloud Service Discovery (T1526)

The threat actor enumerated cloud infrastructure through a pilot-then-batch progression, shifting from broad automated loops across nearly 1,000 tenants to targeted reconnaissance of specific cloud resources via Microsoft Graph token requests. Following initial token checks across 17 selected organizations over a three-hour window, the actor deployed scripted tooling to execute an automated, sorted sweep probing 6 distinct cloud resources in 4 second bursts per tenant.

References

You might also like