權杖就在同一個堆積裡
Accomplish 的 Oren Yomtov 兩度逃出 OpenAI 的 Codex 沙箱。其中一次是從最嚴格的 read-only 模式逃出去的:不受信任的 JavaScript 呼叫了 v8.getHeapSnapshot(),從兩者共用的那個堆積裡,把受信任上下文的授權權杖讀了出來。另一次則讓 apply_patch 自行把寫入權限擴張到檔案系統的根目錄,接著在 .zshrc 後面加上一行。OpenAI 八天內修好了兩個。但催生它們的那個性質沒有修補程式可打:守衛與它所守衛的程式碼,是同一個行程裡的兩個變數。Bromure Agentic Coding 讓守衛跑在你的 Mac 上,讓代理程式跑在 VM 裡。
不受信任的那一側從來沒有攻破沙箱。它只是向 Node 要了一份堆積的快照, 發現沙箱自己的授權權杖以一個再普通不過的字串躺在那裡,然後用了它。
你把 Codex 指向一個別人寄給你的儲存庫。你很謹慎,所以用 read-only 跑它,
那是代理程式能看不能碰的模式。代理程式讀了那些程式碼。其中一部分是酬載,
幾秒之後,一道指令就在你的 Mac 上跑了起來,在沙箱之外,螢幕上沒有任何提示。
那就是 Heapjack, Accomplish 的 Oren Yomtov 這個月公開的 兩個 Codex 沙箱逃逸之一。 BleepingComputer 在 9 月 20 日做了報導。 Yomtov 在 8 月 12 日把兩個都回報給 OpenAI,OpenAI 在八天內修好:Heapjack 修在 Codex Desktop build 26.818.21641,另一個修在 Codex CLI 0.149.0。OpenAI 的聲明 屬於簡短的那一類:「我們感謝研究人員與我們聯繫並分享他們的發現。我們在八月處理了 這兩個問題,但我們會持續不斷地強化我們的沙箱。」
第一個臭蟲為什麼會存在,那才是比修補程式活得更久的部分。
一個行程,一個堆積,一個權杖
Codex Desktop 有個叫 node_repl 的工具,用來執行 JavaScript。它在裡面保有兩個
上下文:一個受信任,獲准請求父行程去做真正的事;一個不受信任,執行代理程式
當下正在求值的任何東西。這兩者之間的隔閡,就是沙箱。
兩個上下文活在同一個 Node 行程裡。用 Yomtov 的話說:「一個 Node 行程,一個 V8 isolate,一個被兩個上下文共用的堆積。權杖就是那個堆積裡的一個字串。」
權杖就是父行程用來分辨兩者的辦法。帶著正確權杖從管線抵達的請求就是受信任的, 會被執行;沒帶的就不是。它是一個隨機 UUID,只要你要瞞著的那段程式碼讀不到你的 記憶體,它就是個相當不錯的祕密。
它讀得到。v8.getHeapSnapshot() 是 Node 的公開 API,一個剖析用的工具,它會回傳
堆積上每一個物件的傾印,字串也在內。不受信任的上下文呼叫它,濾出形狀像 UUID
的東西,最後得到一小把候選。要從中挑出真的那個還得多一步:拿每一個去向父行程
試,看回來的是什麼。錯的權杖回你「未經授權」。對的那個,回你一句對請求其餘部分
的抱怨。Yomtov 留下了產生第二種回覆的那個字串。
權杖到手之後,不受信任的上下文就把請求寫進受信任那一側在用的同一條 stdout
管線。父行程核對權杖,認定有效,然後在沙箱外執行那道指令。從那裡開始:透過
open 下系統指令、Docker socket、修改設定檔。
這一切都是從 read-only 出發的。再引一次 Yomtov:「這一切都跑在 read-only
裡,最嚴格的沙箱模式,代理程式理應什麼都寫不了的地方。」
一個會長大的寫入權限
第二個逃逸 Overpatch 就樸素多了。Codex CLI 有個編輯檔案的 apply_patch 工具,
它會從交給它的修補檔裡所指名的路徑,推算自己需要多少寫入權限。那些路徑來自
模型,而模型正在讀那個儲存庫。
於是一份修補檔在某個項目裡指名了 /tmp,附帶一個什麼也不做的變更。變更失敗了。
但它所隱含的權限授予並沒有失敗:寫入權限一路擴張到檔案系統的根目錄。同一份
修補檔裡的第二個項目,接著就透過一個符號連結寫進 $HOME,在 .zshrc 後面
加上一行。
到這裡還沒有任何東西逃出去。逃逸發生在後來,在你打開終端機、你的 shell 以你的
身分執行那一行的時候,那條路徑上沒有任何沙箱。這一個跑的是 workspace-write,
日常的那個模式。
兩個不同的臭蟲,兩個不同的產品,文章裡有一句話同時涵蓋了它們:「負責執行規範的 那個東西,就坐在被規範的那個東西裡面。」
守衛與被守衛者,同處一個位址空間
一個與它所約束的程式碼活在同一個行程裡的沙箱,是一種約定。它撐得住,直到有人 認真地盯著它看。權杖在堆積裡。權限的計算讀的是攻擊者提供的路徑。管線是同一條 管線。這其中每一項都可以修,OpenAI 也都在八天內修好了。下一個這種類型的臭蟲, 會是同一個堆積裡的另一個字串。
Yomtov 自己的結論則是架構層面的:「這正是 Accomplish 把整個代理程式跑在 VM 裡的原因……我們把客體裡的一切都當成不受信任的,含 root 在內,因為決定什麼被 允許的東西,沒有一樣跟著跑在裡面。」
那正是 Bromure Agentic Coding 出貨的設計。代理程式跑在一個 Ubuntu 24.04 客體 裡,底下是 Apple 的 Virtualization.framework,macOS 自己就提供的那個 hypervisor。客體在構造上就不受信任,含 root 在內。決定什麼被允許的程式碼, 以主機行程的身分跑在一條 vsock 的另一側,而客體內部沒有任何權杖能授權任何 事情,因為值得授權的一切都坐在客體之外。
每一種逃逸換成在這裡會遇到什麼
Overpatch 比較具體,就從它開始。這個手法的結尾,是往 $HOME 裡的 .zshrc
追加內容。在 Bromure 的工作區裡,客體的 $HOME 是 home.img,一個掛在那台 VM
上的 ext4 映像檔。你 Mac 的家目錄不在裡面。客體在你 Mac 上構得到的,是你分享
進去的那些專案資料夾:最多八個,由你挑選,列在工作區編輯器裡。你打開終端機時
shell 會讀的那些 dotfiles 不在其中,除非你自己把它們放進去。
Heapjack 是比較難的那一個。Bromure 裡沒有任何東西會偵測客體內部的堆積快照 手法,也不打算偵測。改變的是逃逸抵達的地方。在 Codex 裡,逃出來的程式碼落在 那個握著你 SSH 金鑰與雲端憑證的帳號裡。在 Bromure 的工作區裡,它落進一台這樣的 Linux VM:
憑證都是誘餌
sk-ant-api03-brm-…、一個形狀像 ghp_ 的字串、一個 secret 無效的 ~/.aws
設定檔。它們保留結構,所以工具會接受,但在上游一文不值。真正的值活在主機上
代理伺服器的記憶體裡,在出去的路上被換上,範圍限定在它們所屬的那台主機或其
子網域。
把它帶出去就會觸線
代理伺服器會掃描每一個外送請求的標頭與內容,找的就是這個工作區自己的那些 誘餌。一個誘餌若前往它不是為之鑄造的地方,就會被 HTTP 451 擋下,一個位元組 都不會轉發。VM 暫停,一列紅色的 憑證仲介 落進安全時間軸。
網路是一份你寫的清單
出口是一組有順序的規則集,結尾是 default allow 或 default deny。虛擬
交換器把 VM 在連接埠 80 與 443 上的流量轉進主機的代理伺服器,其中沒有任何
環境變數介入,也沒有任何客體關得掉的東西。編輯會立即對執行中的工作階段生效。
push 是一個問題,在 Mac 上問
Git 託管平台的憑證帶著一份寫入政策,而新工作區的預設是 寫入前先詢問。
git push 就是寫入。對話框會指名那個確切的操作,並繪製在你的桌面上。VM
裡的攻擊者按不到它。
真出事的時候,你靠一個選單項目就能復原。入侵處理流程會抹掉工作區的磁碟與家 目錄,並保留你的設定、權杖與 SSH 金鑰。真正的憑證從來沒離開過你的 Mac,所以 你不用輪換任何東西。重設磁碟 會重新複製一份系統映像檔。Bromure 的任何動作 都不會碰你分享出去的專案資料夾,提示上也是這麼寫的。
最嚴格的那個模式,才是輸掉的那個
這個故事裡的開發者什麼都做對了。他們刻意選了那個工具所提供最嚴格的設定,因為
那個儲存庫不是他們的。read-only 是一個真實的功能,也做了真實的工作,直到
共用堆積裡的一個字串,把它變成了一句建議。
模式、允許清單與核可提示都值得存在,而它們每一個都預設了:執行這些規範的程式 碼,坐在它所限制的程式碼構不到的地方。那是一個關於記憶體配置的主張,而你可以 去查證它。Yomtov 查了 Codex 兩次,橫跨兩個產品,兩次得到的答案都是否定的。
所以,把代理程式跑在那個答案不再要緊的地方。給它 root,讓它愛裝什麼就裝什麼, 讓它去讀那個別人寄給你的儲存庫,然後把每一份憑證、每一個目的地的決定、每一次 push 的確認,都留在一層 hypervisor 的另一側——下一次逃逸抵達的,會是一台你 本來就願意丟掉的機器。