Introduction#
On October 30th, Google Threat Intelligence and Mandiant published ARTEX and Friends: Enter Agentic Offensive Frameworks, about threat actors using open-source, LLM-driven pentesting frameworks (ARTEX, PentAGI, Hexstrike and CAIRN) in real intrusions. Some details stuck with me: files named artex-probe- plus five digits, accounts with ArtexProbe in the name, bursts of activity lasting a few seconds.
Moreover, between October and early November, Microsoft, Wiz, CrowdStrike, Zscaler, Unit 42, AhnLab, Nextron, Censys and others wrote about ARTEX. I came across 62 distinct published indicators in 11 sources: user agents, made-up test strings, ports, page titles, HTTP headers. However, almost none of them say where the indicator comes from; and I think that is relevant because an agentic framework is made of 3 components:
- the framework’s code
- a language model, deciding what to do
- ordinary tools (
curl,nmap,python, etc.) actually talking to the target
Strings hard-coded in the framework can survive a model swap, but something the model made up may not survive the next run.
This blog was created by cloning ARTEX, reading its code and its git history, checking 73 of its forks, and testing what I could in a lab. Along the way, 1 of those forks deleted a fingerprint from the reports a few days after it was published.
TLDR
- Most target-side “ARTEX” IOCs aren’t in ARTEX’s code: User agents,
ArtexProbeand other made-up test values come from the model or from the tools it runs. - ARTEX’s own four user agents are weak signals: 1 comes from a library also used by other agents, 1 is only sent if the model runs a script, 1 only talks to GitHub, and the last one is dead code.
- Fingerprints don’t last: 4 days after Unit 42 published the realm header, the most popular fork replaced it and stripped its name from the health endpoint.
- In the lab: ARTEX’s optional traffic recorder makes most tools’ TLS handshake into Go’s, and the pauses between bursts are just the LLM thinking.
And a disclaimer:
- The code analysis is read-only, with git hooks disabled.
- In the lab I ran some ARTEX components in containers on a network with no internet access, against my own listener and a fake LLM.
How I did it#
The original Autumn-27/ARTEX repository is not available anymore, but the fork Hinln/ARTEX still keeps its original history. Every reference to ARTEX in this post is a reference to the upstream’s last release, 0.3.15 (e6ec569).
Shameless plug of repopsy#
Some of my questions in this post were about the history of the tool: when did this string appear? Was this function ever called? Did a fork remove something after a vendor wrote about it?
Such questions can be answered easily with some bash magic and repopsy, the tool I already plugged in the KawaiiGPT post, which got a big update (v2.2) while I was working on this:
A mirror clone also brings the pull-request refs, which is where we can find third-party code. repopsy then writes a record for every commit (author, signature, changed files, anomalies) and, with --track, tells you where a string first appeared and every commit that changed it, across all refs at once. We will use it to answer 2 questions below in the post.
The lab#
I built a small harness from ARTEX’s own code (the part that runs the model’s commands), and connected it to a fake LLM that answers with scripted “run this curl” instructions, and pointed it at a listener that records every request and its TLS handshake.
Lab details, for the curious:
- The harness runs Norma’s agent loop with the network options ARTEX’s worker sets (source): ARTEX’s own proxy settings, its LLM client and its traffic recorder.
- The runtime image copies the base of ARTEX’s Dockerfile (source):
python:3.12-slim-bookworm, so curl 7.88.1 on OpenSSL 3.0.22 and Node 20. I added python-requests 2.34.2, which ARTEX’s image doesn’t include, but it somehow shows up in the Microsoft and Unit 42 reports. - The fake LLM uses the OpenAI chat API, and can wait a configurable number of seconds before answering.
- The target listens on 80 and 443, logs headers and records a pcap; tshark computes the JA4 TLS fingerprint. Its certificate is self-signed, so clients that verify certificates fail after sending their ClientHello.
- Everything ran on a Docker network created with
--internal.
Where ARTEX’s traffic really comes from#
ARTEX is made of a single Go binary with a web console. It includes a planner agent that turns the “hacker”’s goal into “intents”, and worker agents pick them up and run tools, mostly a Bash tool (the model writes the command) and a WebFetch tool. The agent’s inner workings are derived from Norma, an agent SDK written by the same original author of ARTEX.
ARTEX’s code includes 4 user agents, but only 2 of them can potentially reach the target:
| User agent | Set by | Who receives it | When |
|---|---|---|---|
norma/0.4 | the Norma SDK’s WebFetch tool | targets | when the model uses WebFetch |
Mozilla/5.0 (spa-api-recon) and (spa-api-recon spider) | 2 scripts in ARTEX’s bundled api-recon skill | targets | only if the model decides to run them |
artex-selfupdate | ARTEX’s updater | GitHub only | on console page loads, cached for 30 minutes |
artex-enrich/1.0 | ARTEX’s enrichment engine | nobody | never (see the next section) |
norma/0.4 comes from the SDK (source), so other Norma-based agents will send it. Nextron’s blog notes that Norma “is neither exclusive to ARTEX nor malicious on its own”. This string has been the same in all 16 Norma versions ARTEX ever used, so I’d consider it very stable for now.
The spa-api-recon strings are listed by AhnLab and Theori, but I haven’t seen them in other reports.
artex-selfupdate (source) can be found in a machine running ARTEX, but it would be invisible to victims.
Everything else is based on whatever the tools send: ARTEX gives the model’s commands proxy settings, but no user agent (source), so a target machine could see curl’s UA, python-requests’, or whatever header the model decided to use for that task. I believe that’s why 3 reports listing “ARTEX user agents” have almost nothing in common:
| Source | User agents listed | Where they really come from |
|---|---|---|
| Microsoft | Python-urllib/3.12, python-requests/2.31.0 and 2.34.2, curl/7.88.1, azure-toolkit/1.0 | Python and curl, model-written |
| Wiz | norma/0.4, boto3, aws-cli, truncated Chrome strings, ReconProbe/1.0 | Norma, AWS SDK, model-written |
| Unit 42 | curl/8.18.0, python-requests/2.32.5, ffuf, Chrome 126/127, security-testing; authorized, recon-audit | tools, model-written |
Apart from norma/0.4, none of these strings is in ARTEX’s code, prompts or skills, and ARTEX has no user-agent rotation code! The “rotating user agents” are probably the model writing different headers, or different tools. I think this excerpt of Wiz’s post confirms it: many of its UAs are “not unique to this actor, and are expected to be noisy if hunted independently”.
The user agent that never calls#
ARTEX’s community defence guide in the “artex”-global fork says that when ARTEX enriches an asset, “it sends a direct GET to the target with this User-Agent”, artex-enrich/1.0; the guide then includes Suricata rules for it. However AhnLab’s analysis says that “the section calling the artex-enrich function was not connected”.
…And - from what I saw in the code - AhnLab’s post is right! The enrichment engine is there (source): with 4 workers and handed to every agent worker through an interface with two methods, ProbeSite and ResolveDomain (source). However, the workers store it and never use it: nothing in the code base calls either method!
But wait, maybe it was linked before? If you want to follow along, a mirror clone plus one repopsy run answers this, for every branch and pull request at once:
$ git clone --mirror https://github.com/Hinln/ARTEX.git
$ repopsy --all-refs --dedupe --no-tree --track 'ProbeSite(' -o artex ARTEX.git
$ cat artex/STRINGS.txt"ProbeSite("
First appears: 0ef8561 2026-07-26T04:27:45-04:00 Autumn-27
chore: ARTEX 初始化
Changes: 1 commit(s)
0ef8561 2026-07-26T04:27:45-04:00 +2 Autumn-27 chore: ARTEX 初始化
agent/tools.go 0 -> 1
enrich/enrich.go 0 -> 1
At ref tips:
main: 2
refs/pull/1/head: 2
...So we have 2 occurrences, the interface and the method itself, both in the very first public commit (“ARTEX initialisation”), and unchanged on every ref since. I assume the 4 workers have been waiting since July for a call that never comes (LOL to vibecoding, I guess?).
None of the 73 forks I checked calls it either, but several renamed it (antifox-enrich/1.0, argus-enrich/1.0, zerox-enrich/1.0…) and left it just as unwired! To be fair to the guide, its description of what the engine would do matches the code. It just never runs.
The fingerprint that vanished#
ARTEX is much easier to spot on the “hacker”’s side: the console listens on :8787. Censys finds consoles by their page title (ARTEX — 自主渗透测试控制台 upstream), and there’s an unauthenticated health endpoint that introduces itself (source):
writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion})64 of the 73 ARTEX code bases I checked answered with "service": "artex", including rebrands that do not match the regex Censys wrote to target the title of the page.
Then there’s a header from the Unit 42 report: one source server answered with Server: nginx/1.28.3 (Ubuntu) and WWW-Authenticate: Basic realm="ARTEX".
However, while upstream ARTEX has no Basic authentication at all (--track 'Basic realm' doesn’t match anything), RuoJi6/ARTEX (the most popular continuation of the project) added an optional Basic Auth gate that matches exactly (source). 4 forks of that line still send it, and artex-full has its own implementation, but with the same realm.
So when did the realm appear in RuoJi6/ARTEX, and is it still there? Same trick as before:
$ git clone --mirror https://github.com/RuoJi6/ARTEX.git RuoJi6_ARTEX.git
$ repopsy --all-refs --dedupe --no-tree \
--track 'Basic realm="ARTEX"' --track '"service": "artex"' \
-o ruoji6 RuoJi6_ARTEX.git
$ cat ruoji6/STRINGS.txt"Basic realm=\"ARTEX\""
First appears: 04c8c54 2026-10-09T10:11:57+08:00 ji ruo
feat(auth): add optional HTTP Basic Auth access gate
Changes: 4 commit(s)
04c8c54 2026-10-09T10:11:57+08:00 +2 ji ruo feat(auth): add optional HTTP Basic Auth access gate
444cf6e 2026-10-09T11:05:40+08:00 +2 RuoJi6 Merge pull request #1 from RuoJi6/codex/http-basic-auth
5203176 2026-11-03T14:48:41+08:00 -2 ji ruo fix: remove application fingerprints from public responses
6353a66 2026-11-02T23:14:25-08:00 -2 RuoJi6 Merge pull request #114 from RuoJi6/codex/hide-public-fingerprints
"\"service\": \"artex\""
First appears: 0ef8561 2026-07-26T04:27:45-04:00 Autumn-27
chore: ARTEX 初始化
Changes: 3 commit(s)
0ef8561 2026-07-26T04:27:45-04:00 +1 Autumn-27 chore: ARTEX 初始化
5203176 2026-11-03T14:48:41+08:00 -1 ji ruo fix: remove application fingerprints from public responses
6353a66 2026-11-02T23:14:25-08:00 -1 RuoJi6 Merge pull request #114 from RuoJi6/codex/hide-public-fingerprintsI removed the file lists and the ref tips to keep it short.
Here is a timeline:
October 7
Upstream ARTEX’s last release, 0.3.15.October 9
RuoJi6/ARTEX is created and, the same day, adds an optional Basic Auth gate:Basic realm="ARTEX".October 30
Unit 42 publishesWWW-Authenticate: Basic realm="ARTEX"as an indicator.November 3
RuoJi6/ARTEX commits “fix: remove application fingerprints from public responses” and, 8 minutes later, “fix: require Basic Auth for health checks”.November 5
Censys publishes its ARTEX detection.
The first commit (source) changes the realm to Restricted, moves the version behind authentication, and empties the health endpoint. The second (source) puts the health endpoint behind the Basic Auth gate too, when the gate is on:
// health requires the optional Basic Auth gate but no admin JWT, and exposes no application fingerprints.
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, 200, map[string]any{"ok": true})
}So a fresh deployment of the most popular fork no longer shows the realm from the report, but page title, ports and Next.js assets are still there.
There is also another operator-side string, which can be useful if investigating a host or an LLM gateway: when ARTEX’s guard blocks a command, it tells the model 【ARTEX 平台管控·非目标防御】此调用被平台拦截 (“ARTEX platform control, not a target defence: this call was blocked by the platform”, source). It travels in the prompts sent to the LLM provider and sits in the in-memory audit log served by GET /api/audit, as written in the community guide.
curl is actually Go#
ARTEX includes an optional man-in-the-middle proxy, go-mitmproxy; in this way “hackers” can record their workers’ traffic as evidence. It’s off by default, and has been since the first commit (source). When it’s on, the tools’ traffic goes through it, and the proxy doesn’t pass the tool’s TLS connection along, but it ends it and opens a new one with Go’s TLS library (source). So the target sees curl’s user agent on top of Go’s handshake:
The proxied handshake matches, item for item, the one from Norma’s own Go client in the same capture, including a post-quantum key share that this curl can’t produce. So curl/7.88.1 arriving with a Go handshake is a decent flag for “ARTEX with recording on”. There are 2 exceptions: Node 20 (the version in ARTEX’s image) ignores the proxy settings, so node traffic keeps its own fingerprint; and curl over plain HTTP leaks a Proxy-Connection: Keep-Alive header to the target.
The JA4 fingerprints, for the TLS people:
| Client (via ARTEX’s Bash tool) | JA4, recording off | JA4, recording on |
|---|---|---|
| curl 7.88.1 | t13d3112h2_e8f1e7e78f70_b26ce05bbdd6 | t13d2012h2_314f1408a5a6_ab7e3b40a677 |
| python-requests 2.34.2 | t13d1812h1_85036bcba153_b26ce05bbdd6 | t13d1112h1_df48822fadde_ab7e3b40a677 |
| Python urllib 3.12 | t13d181100_85036bcba153_d41ae481755e | t13d111100_df48822fadde_ab7e3b40a677 |
Node 20 fetch | t13d5911h1_a33745022dd6_1f22a2ca17c4 | t13d5911h1_a33745022dd6_1f22a2ca17c4 (unchanged) |
Norma WebFetch (norma/0.4) | t13d1312h2_f57a46bbacb6_ab7e3b40a677 | t13d131100_f57a46bbacb6_ab7e3b40a677 |
With recording on, every tool that goes via the proxy ends in ab7e3b40a677, the same last component as Go’s own client (Norma’s WebFetch), but the cipher part still follows the tool.
The pauses are the model’s#
In the blog posts by Unit 42 and Google, ARTEX is described as acting in an irregular rhythm. In particular, Unit 42 describes a “think-then-act cadence”: “bursts of 1-14 requests at about one per second”, “separated by regular 25-31 second pauses”, which they interpret as the agent waiting for the model. I wanted to see how much of that rhythm ARTEX adds, so I made the fake LLM wait 0, 5, 15 or 30 seconds before answering, and ran six steps with 1 and with 3 workers.
As visible by the data below, ARTEX adds almost nothing, 10 to 20 milliseconds per step. Whatever delay I gave the fake LLM, the target saw the same pause:
| Fake LLM answers after | Pause between requests at the target (median of 6 steps) |
|---|---|
| 0 s | 0.01 s |
| 5 s | 5.02 s |
| 15 s | 15.02 s |
| 30 s | 30.02 s |
Here’s what the 15-second run looked like from the target:
Each worker asks the LLM what to do, waits for the answer, and sends its request. My fake LLM took exactly 15 seconds for everyone, so with 3 workers the three requests arrived together: in my lab, more workers meant bigger bursts.
It’s true that real life is messier: a real model takes a different time for every answer, and one step can run a scanner that sends many requests at once. But the main point holds, as Unit 42 wrote: those 25-31 second pauses are the time the operator’s model (and its API gateway) takes to answer. Therefore, a faster model would mean shorter pauses.
Nobody wrote ArtexProbe#
Google reports that ARTEX “generated files of various extension types with the filename artex-probe-*, where * carried five digit somewhat-incremental numbers”, and that the actor created accounts “which included ArtexProbe in the naming convention”. Other reports list similar made-up values, which the agent used as test inputs: Unit 42’s zz_no_such_user_9f3a@, Wiz’s /zzz-nonexistent-xyz123 and a forged token for [email protected].
None of them is in ARTEX’s code, prompts or skills. I tracked artex-probe and ArtexProbe with repopsy across every commit of every ref of upstream (PR refs included) and of 4 forks, and grepped all 73 code bases: nothing, apart from a CI build artifact called /tmp/artex-probe. The prompts don’t even name the product at all. So ArtexProbe is either the model branding its own work, or a private build (I assumed the second, since Google mentions a commercial “ARTEX PRO”).
The forks#
ARTEX has dozens of derivatives. I searched GitHub for markers that could survive a rename (the Norma import, artex-enrich, artex-selfupdate, the guard string), cloned everything, and ran the same checks over 73 ARTEX code bases:
The title count is out of the 63 code bases where I found a title.
Upstream doesn’t ship a fingerprint that reaches victims, but some forks do:
| Fork | What it adds | What victims see |
|---|---|---|
| panda0x123/artex-ymh | webshell sessions, tunnels, file staging | User-Agent: Mozilla/5.0 (ARTEX) (source), the probe command echo …%ARTEX_PROBE_UNSET%… (source), files under /tmp/.artex_* |
| 200210xsb/artex-full | nuclei-based fingerprinting, its own out-of-band listener | Mozilla/5.0 (compatible; ARTEX-Nuclei-Fingerprint/1.0) (source), callbacks to ports 8889, 18666 or 18667 on paths like /t/<token> |
| luojiaping/ARTEX | a “credential-hunting pipeline” that checks the LLM API keys it finds, and their remaining balance | ARTEX-CredHunter/2.0, sent to LLM providers (source) |
So if you work on an LLM provider’s abuse team, maybe it’s worth checking for ARTEX-CredHunter/2.0 🙃
How the published IOCs hold up#
I checked all 62 published indicators against the code:
Unfortunately, most of the “confirmed” ones are operator-side (console title, port 8787, database schema, environment variables, the guard string, the forks’ realm).
The “model-dependent” ones are ArtexProbe, the other made-up test values and the self-describing user agents.
“Not in any code” are operators’ infrastructure, and for almost all cases the blog posts describe them as such. The only claim the code contradicts is artex-enrich/1.0 reaching targets, which Theori’s detection pack also rates as High severity and “block-eligible”. While a rule for it costs nothing, it will simply never fire as we saw above; the rest of the pack matches what I found, so I believe it’s worth a look if you need some rules.
So, what should defenders build on?#
As for other cases, the rule of thumb should be: ask what has to change for an indicator to disappear.
Build on what only disappears if the attacker changes how they work: the agent’s behaviour, like:
- grabbing JS, source maps and OpenAPI specs, then calling the routes within seconds
- logging in as a made-up user, then as
admin - writing something and reading it back right away; changing approach after 4xx errors
Other good candidates can be strings you only see with host or LLM-provider access, like the guard string, the database schema and environment variable names.
These rules catch an agent, not ARTEX, and that’s fine: as Nextron puts it, “the framework is not the payload”.
Use as supporting evidence what one commit or one setting can remove: norma/0.4 (any Norma agent), spa-api-recon, the console title and ports, /api/health and Basic realm="ARTEX" (already gone in the most popular fork), the forks’ post-exploitation strings, a Go handshake under a curl UA. Precise when you see them, meaningless when you don’t.
Don’t rely on what changes every run or every operator: generic tool user agents and versions, model-made names and test values like ArtexProbe, IPs, gateway domains and timings.
Limits of the analysis#
- As said before, the lab ran ARTEX’s network code against my own listener with a fake LLM, not the whole platform driven by a real model.
- I read 5 forks in depth and surveyed 73 with scripts, as they looked in early November. I could not get access to private and commercial builds of ARTEX.
What’s next#
One question is still open: is ArtexProbe a model habit? The only way to find out is to give several real models the same mock target, with and without the framework’s name in sight, and count the *probe* accounts they create. That needs real models and some budget, so if you have a lab and want to try, ping me!
Sources#
Reports
- Google GTIG & Mandiant: ARTEX and Friends: Enter Agentic Offensive Frameworks (Oct 30, 2026)
- Microsoft: AI-driven, large-scale nation-state campaign using ARTEX enables supply chain compromise (Oct 21, 2026)
- Wiz: ARTEX in the Wild: Storm-0940 Expands Targeting of IT and Software Vendors (Oct 22, 2026)
- CrowdStrike: Unknown Threat Actor Uses AI-Driven ARTEX to Target South Korean Finance (Oct 7, 2026)
- Zscaler ThreatLabz: Inside a Global AI-Assisted Hacking Campaign (Oct 21, 2026)
- Unit 42: ARTEX Agentic AI Framework Used to Abuse Exposed APIs and Probe Identity Providers (Oct 30, 2026)
- Censys: ARTEX: Agentic Asbestos (Nov 5, 2026)
- AhnLab ASEC: The Threat of AI-Driven Autonomous Penetration Testing, as Examined by ARTEX (Oct 14, 2026)
- Nextron Systems: Beyond Framework Signatures: Understanding AI-Driven Intrusions (Oct 29, 2026)
- artex-global: Defending Against and Detecting Autonomous AI Attacks (community defence guide)
- Xint: ARTEX Case Study and Defense Guide (gated)
- Theori: AIPT detection pack
Code (permalinks to the exact commits)
- ARTEX v0.3.15: Hinln/ARTEX@e6ec569
- Norma: Autumn-27/[email protected]
- go-mitmproxy: lqqyt2423/[email protected]
- RuoJi6/ARTEX: @04c8c54 (Basic Auth), @5203176 (fingerprints removed) and @f053931 (health behind Basic Auth)
- artex-ymh: panda0x123/artex-ymh@48adc7f
- artex-full: 200210xsb/artex-full@97d4960
- luojiaping/ARTEX: luojiaping/ARTEX@7baca5d



