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:
- TLS 栈用的是 Go 标准库
crypto/tls 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 | for key, value := range f.Request.Header { |
对比一下 Go 标准库:httputil.ReverseProxy 会把 Proxy-Connection 删掉(它就在 hopHeaders 列表里),go-mitmproxy 是自己拷头,漏了这一下;http.Transport 也不会替它清理,所以这个头原样出现在源站。
判定要三个条件同时成立:
1 | scheme == "http" |
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 | serverTlsConfig := &tls.Config{ |
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 | 14 00 00 0c f9 bf 43 31 ac c8 25 eb 0c a8 48 ea |
Go 客户端同样的位置就是八个零。
实现上有三个坑,都要处理,否则会误判:
- 只判 TLS 1.2。TLS 1.3 里 nonce 由
IV XOR 序列号派生,完全不上线,而且加密记录外层全是application_data(23),连 Finished 都定位不到。服务端要主动把客户端降到 1.2,克隆配置时必须清掉GetConfigForClient回调,否则HelloRetryRequest会重复进入。 - 套件列表里不能留 ChaCha20。TLS 1.2 的 ChaCha20-Poly1305(RFC 7905)没有显式 nonce,前 8 字节直接是密文,照旧比对会把任何客户端都判成 OpenSSL,所以只保留 6 个 AES-GCM 套件。
- 会话复用要以 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 | scheme == "https" |
两个伪特征
norma/0.4:代码里有,但模型不用
norma@v0.4.3/tool/webfetch.go:188,195 无条件设置:
1 | req.Header.Set("user-agent", "norma/0.4") |
路径本身没有不确定性,只要 WebFetch 打到目标必然命中。顺带澄清一个常见误解,WebFetch 和 web_search 开关无关:
1 | EnableWebFetch: true, // 硬编码,永远启用 |
问题在触发率,模型压倒性地选择 Bash + curl:
| 场景 | WebFetch 调用 | 打目标的方式 |
|---|---|---|
| 其中一次任务 | 1 次(查 OpenSSL 文档,没打目标) | 全部 curl |
| 另一次任务 | 0 次 | 全部 curl |
留着当辅助信号可以,指望它抓到 ARTEX,等于指望模型改掉用 curl 的习惯。
artex-enrich/1.0:代码里有,但无法触发
字符串确实在,enrich/enrich.go:233:
1 | func (e *Engine) doHTTP(id int64, rawURL string) { |
但触发入口是死代码:
1 | // enrich/enrich.go:122 —— 唯一的入队方法 |
全仓库搜索 t.enrich.,零个调用点:
1 | grep -rn "t\.enrich\." --include=*.go . |
insert_assets 不会触发 HTTP 探活,doHTTP 永远不会执行,这个 UA 在真实流量里不可能出现。保留一条永不触发的规则只会在文档里制造「我们有覆盖」的错觉,直接从规则里删掉。
对抗:如果攻击方要改进
如果继续用流量捕获,修改点有三个:
- 转发时剥离
Proxy-Connection - 用 uTLS 伪装 TLS 指纹
- 修正 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 |
— | 无效,别用 |