ARTEX 是一个开源的 AI 渗透智能体,模型在容器里自己拼 Bash 命令去探测目标。它的流量形态和普通扫描器、运维脚本都不太一样,能不能在网络侧认出来,是下面要讲的问题。

以下涉及的数据来自一次真实的 HVV:网络侧 19.2 万条真实留档请求,ARTEX 侧 4,190 个任务转写(138,751 次工具调用)。

结论先行

类别 特征 可靠性 说明
真特征 HTTP + Proxy-Connection + curl/python UA 高 前提是开着流量捕获
真特征 HTTPS + 非 OpenSSL + curl/python UA 高 同上
伪特征 norma/0.4 低 只有 WebFetch 才发,而模型几乎都用 curl
伪特征 artex-enrich/1.0 无效 代码在,但触发入口是死代码,UA 永远发不出

两条强特征依赖同一个前提:攻击方开没开流量捕获(MITM 代理)。代理一关,它在传输层和普通 curl 没有任何区别。

另外,两条特征的痕迹都来自 ARTEX 依赖的流量捕获库 go-mitmproxy:

  1. TLS 栈用的是 Go 标准库 crypto/tls
  2. Proxy-Connection 是它转发请求时没有剥离的逐跳头

ARTEX 平时怎么发请求

只挑对后面两条特征有用的部分,统计口径是全部 4,190 个任务转写(1.1 GB)里的工具调用:

工具 调用次数 占比
Bash 64,460 46.4%
WebFetch 2,520 1.8%
其他(元工具、文件读写等) 71,771 51.7%

Bash 占近一半,所以要看的其实是 Bash 里跑的 HTTP 客户端(一条命令可能命中多个,占比之和会超过 100%):

客户端 次数 占 Bash 调用
python(python3,含 -c 与脚本) 24,404 37.9%
curl 23,422 36.3%
与 HTTP 无关(ls / grep / cat…) 19,488 30.2%
扫描器(nmap / ffuf / nikto / sqlmap / nuclei…) 1,431 2.2%

curl 加 python 合计 74.2%,后面两条真特征都以它们的 UA 为前提。python 的用法还要再拆一层:

形式 次数 说明
python3 -c "..." 直接执行 7,087 其中 1,013 条显式用了 requests/urllib
python3 foo.py 跑脚本 6,296 常先 Write 一个脚本再执行
其他(-m、交互式等) 11,021

第二种形式是关键。脚本里的 requests 用库默认配置,默认 UA 是 python-requests/x.y.z(urllib 则是 Python-urllib/x.y),也会默认读环境变量里的代理,后面两条特征的 python 分支都是从这来的。

WebFetch 只占 1.8%,目标还基本是 bing / github / crt.sh / urlscan.io 这类资料站,几乎不打渗透目标,norma/0.4 的覆盖率注定很低。

特征一:HTTP + Proxy-Connection + curl/python UA

这条特征抓的是一个本不该出现的头。

Proxy-Connection 是逐跳头(hop-by-hop),按规范只该发给代理,不该到源站。

所以源站的 HTTP 请求里出现 Proxy-Connection,就说明这条请求经过了代理。

这个头能一路带到源站,同样要记在 go-mitmproxy 头上:它转发请求时把客户端的请求头整份拷进上游请求,没有剥离逐跳头,代码在 proxy/attacker.go:575(v1.9.2):

1
2
3
4
5
for key, value := range f.Request.Header {
for _, v := range value {
proxyReq.Header.Add(key, v)
}
}

对比一下 Go 标准库:httputil.ReverseProxy 会把 Proxy-Connection 删掉(它就在 hopHeaders 列表里),go-mitmproxy 是自己拷头,漏了这一下;http.Transport 也不会替它清理,所以这个头原样出现在源站。

判定要三个条件同时成立:

1
2
3
scheme == "http"
AND Proxy-Connection 头存在
AND (UA 匹配 ^curl/ OR UA 匹配 ^python)

UA 这一支列出的是脚本类客户端的默认 UA:curl/7.88.1、python-requests/x.y.z、Python-urllib/x.y,后两个用不区分大小写的 ^python 前缀就能一起覆盖。

特征二:HTTPS + 非 OpenSSL + curl/python UA

这条特征要三个条件同时成立:HTTPS、TLS 指纹不是 OpenSSL、curl/python 的 UA。单独看任何一项都不够,合起来才说明流量中间夹了一层代理。

立论:curl/python 本该是 OpenSSL

Linux 发行版编译的 curl 链接的是 OpenSSL,requests 走 Python 的 ssl 模块,底层也是 OpenSSL。所以正常直连时,UA 说是谁,TLS 栈就该是谁:

路径 UA 声称 该 UA 默认的 TLS 栈 目标实际看到的
curl 直连 curl/7.88.1 OpenSSL OpenSSL(一致)
curl 经代理 curl/7.88.1 OpenSSL 非 OpenSSL,这里是 Go crypto/tls(矛盾)

UA 说是 curl/python,TLS 却不是它们本该有的 OpenSSL,说明中间有代理。UA 可以随手编,TLS 栈不是改个字符串就能换的。

顺带交代一句:Windows 版 curl 走的是 Schannel,不是 OpenSSL,后面那张对照表里会看到它。

矛盾的来源:go-mitmproxy

那个 Go 栈不是 ARTEX 自己写的,而是它依赖的捕获库 go-mitmproxy 留下的。库连上游目标时用 Go 原生 crypto/tls,配置里只透传了套件列表,顺序仍由 Go 决定,代码在 proxy/attacker.go:231(v1.9.2):

1
2
3
4
5
6
7
8
serverTlsConfig := &tls.Config{
InsecureSkipVerify: proxy.Opts.SslInsecure,
KeyLogWriter: helper.GetTlsKeyLogWriter(),
ServerName: clientHello.ServerName,
NextProtos: clientHello.SupportedProtos,
// CurvePreferences: clientHello.SupportedCurves, // todo: 如果打开会出错
CipherSuites: clientHello.CipherSuites,
}

ARTEX 只是把库嵌进去,既没换 TLS 栈也没改上游逻辑,所以这个指纹是库的固有行为,任何用 Go 原生 TLS 做上游的工具都一样。

explicit_nonce

不需要 JA3/JA4。TLS 1.2 + AES-GCM 下(RFC 5288 §3),每条加密记录的前 8 字节是明文传输的 explicit_nonce,由发送方自己选:

实现 显式 nonce
OpenSSL 随机起点 + 每条递增
Go crypto/tls 直接填内部序列号,首条是 0000000000000000
Schannel / Java 也是全零

实测真实握手,OpenSSL 客户端的 Finished 记录:

1
2
14 00 00 0c f9 bf 43 31 ac c8 25 eb 0c a8 48 ea
└─记录头──┘ └───前 8 字节───┘ ← 随机值

Go 客户端同样的位置就是八个零。

实现上有三个坑,都要处理,否则会误判:

  1. 只判 TLS 1.2。TLS 1.3 里 nonce 由 IV XOR 序列号 派生,完全不上线,而且加密记录外层全是 application_data(23),连 Finished 都定位不到。服务端要主动把客户端降到 1.2,克隆配置时必须清掉 GetConfigForClient 回调,否则 HelloRetryRequest 会重复进入。
  2. 套件列表里不能留 ChaCha20。TLS 1.2 的 ChaCha20-Poly1305(RFC 7905)没有显式 nonce,前 8 字节直接是密文,照旧比对会把任何客户端都判成 OpenSSL,所以只保留 6 个 AES-GCM 套件。
  3. 会话复用要以 CCS 为锚点。abbreviated handshake 里没有 ClientKeyExchange,严格校验握手顺序会把真实的 OpenSSL 客户端判成非 OpenSSL。正确做法是找到 ChangeCipherSpec,取它之后的第一条 handshake 记录当 Finished。

此处可参考 https://github.com/howmp/whatssl,也可以通过 https://whatssl.guage.cool/ 测试 OpenSSL 的识别。

session_ticket

non-openssl 不等于 Go:Windows 的 curl 走 Schannel,Java 的显式 nonce 也是全零,只判这一项会把它们全算成 ARTEX,所以还得从原始 ClientHello 里再拿一个特征:

特征 含义 Go OpenSSL Schannel 浏览器
session_ticket (0x0023) 是否发扩展 0x0023 无(未配 ClientSessionCache) 有 有 有

默认配置下只有 Go 不发这个扩展。四条路径实测对照:

客户端 nonce 0x0023 结论
ARTEX 经代理(Go) 全零 无 Go 指纹
Windows curl/8.21.0(Schannel) 全零 有 不是 Go
浏览器 — 有 不是 Go
Linux curl 直连(OpenSSL) 非零 有 OpenSSL

完整判据:

1
2
3
4
scheme == "https"
AND nonce 全零 ← 排除 OpenSSL
AND 无 0x0023 ← 排除浏览器 / Schannel / Java
AND (UA 匹配 ^curl/ OR UA 匹配 ^python)

两个伪特征

norma/0.4:代码里有,但模型不用

norma@v0.4.3/tool/webfetch.go:188,195 无条件设置:

1
2
3
req.Header.Set("user-agent", "norma/0.4")
// 代理失败后的直连重试也设
r2.Header.Set("user-agent", "norma/0.4")

路径本身没有不确定性,只要 WebFetch 打到目标必然命中。顺带澄清一个常见误解,WebFetch 和 web_search 开关无关:

1
2
EnableWebFetch:  true,                  // 硬编码,永远启用
EnableWebSearch: w.webSearch.Enabled, // 由开关控制

问题在触发率,模型压倒性地选择 Bash + curl:

场景 WebFetch 调用 打目标的方式
其中一次任务 1 次(查 OpenSSL 文档,没打目标) 全部 curl
另一次任务 0 次 全部 curl

留着当辅助信号可以,指望它抓到 ARTEX,等于指望模型改掉用 curl 的习惯。

artex-enrich/1.0:代码里有,但无法触发

字符串确实在,enrich/enrich.go:233:

1
2
3
4
5
func (e *Engine) doHTTP(id int64, rawURL string) {
...
req.Header.Set("User-Agent", "artex-enrich/1.0")
...
}

但触发入口是死代码:

1
2
3
4
5
// enrich/enrich.go:122 —— 唯一的入队方法
func (e *Engine) ProbeSite(id int64, rawURL string) { e.enqueue(job{jobHTTP, id, rawURL}) }

// agent/tools.go:221 —— 只把 engine 赋给字段,然后没有下文
func (t *ToolSet) SetEnrich(e EnrichTrigger) { t.enrich = e }

全仓库搜索 t.enrich.,零个调用点:

1
grep -rn "t\.enrich\." --include=*.go .

insert_assets 不会触发 HTTP 探活,doHTTP 永远不会执行,这个 UA 在真实流量里不可能出现。保留一条永不触发的规则只会在文档里制造「我们有覆盖」的错觉,直接从规则里删掉。

对抗:如果攻击方要改进

如果继续用流量捕获,修改点有三个:

  1. 转发时剥离 Proxy-Connection
  2. 用 uTLS 伪装 TLS 指纹
  3. 修正 curl/Python 的请求头,伪装成浏览器

如果关掉流量捕获,建议编译一个特制的 curl(BoringSSL 后端 + .curlrc 改头),让两层都像浏览器。

给防护方的清单

优先级 判据 实现成本 误报
1 HTTPS + nonce 全零 + 无 0x0023 + curl/python UA 中(要自己终结 TLS) 极低
2 HTTP + Proxy-Connection + curl/python UA 低 低(运维 curl -x 会中)
3 Sec-Fetch-* 全缺失 / Sec-Ch-Ua* 全缺失,但 UA 自称浏览器 低 低
4 无 Accept-Encoding、请求头总数 ≤ 3 低 低
5 norma/0.4 极低 极低(覆盖率也极低)
— artex-enrich/1.0 — 无效,别用