開發者核准的是一行完全無害的「git branch」——問題是,git 啟動時讀到的 PAGER,早在幾分鐘前就被換成別的東西。CVE-2026-22708 說的正是這件事:沒有人做錯什麼,允許清單卻還是被繞過。
你核准過的指令,被 export 一行改了意思
開發者只要打開 Cursor 的 Auto-Run 模式,agent 執行指令前就會先比對一份允許清單——清單上的指令直接放行,其餘一律停下來問人。這個設計的邏輯很直覺:清單越乾淨,代理能亂動的空間就越小。2026 年 1 月 14 日,Pillar Security 揭露的 CVE-2026-22708 打破了這個直覺。出問題的不是清單設得太寬鬆,而是清單根本沒看到出事的那一步。
允許清單能流行起來,是因為它解決了一個很實際的麻煩。沒有它,每一個 agent 想執行的指令都要人盯著螢幕逐一按同意,一天下來按到手軟;有了它,像 git status、ls、npm test 這種天天都要打的指令可以自動放行,人只需要盯著真正陌生的東西。這筆交易划算的前提,是清單真的攔得住陌生的東西——而 CVE-2026-22708 說明的正是這個前提站不住腳的其中一種方式。
「git branch」也能被劫持
Cursor 把這個弱點評為 High,在 2.3 版修補;Pillar 在 2025 年 8 月回報,修補到隔年 1 月才出。中間這段時間差不是重點,重點是攻擊本身有多平凡。評級會落在 High,不是因為攻擊手法多精巧,而是因為它幾乎不需要開發者多做什麼——不用騙人跑一個看起來可疑的指令,不用等一次操作失誤,只要讓 agent 讀到一段文字,剩下的就交給一個任何人都會核准的日常操作去完成。
示範用的兩行指令長這樣:第一行是 export PAGER="...",寫在一份依賴套件的 postinstall 腳本,或是一則 issue 留言裡;agent 讀到這段文字就照做,沒有跳出任何核准提示。第二行是再普通不過的 git branch——任何開發者看到都會按下同意。差別在於,第二行執行的時候,git 啟動去讀取 PAGER,讀到的已經不是系統原本的 less,而是攻擊者塞進去的程式。
第一行之所以能悄悄溜過去,跟它藏身的位置有關:能把文字放到 agent 眼前的地方很多——一份 README、一個依賴套件、一則 issue 留言——任何一個都能用來悄悄改寫環境變數。agent 只是把「讀一段文字、照著做」這件事做得比人更快、更頻繁,agent 為了完成任務本來就會去讀這些內容。
拖曳時間軸看環境變數何時被改寫 · 4 個時間點
三個時間點:export 悄悄改寫 PAGER、git branch 被核准、git 讀到被換掉的設定
把時間軸拖到中段之後,PAGER 已經被 export 改寫;git branch 送出核准的那一刻,讀到的其實是攻擊者放的程式,不是原本的 less。
Docker 這篇文章把整起事件的弔詭講得很直接:允許清單是 Cursor 該做的事,他們做了;核准 git branch 是任何開發者都會做的事,開發者也做了;檢查指令是不是在清單上是檢查器的職責,它也確實做了——三個環節都沒有失職,攻擊還是成功了。這種「每個人都做對了自己的那一份工作」的說法,通常是拿來替失敗找台階下;這次卻是字面上的事實。
兩個排除的念頭很自然:問題是不是清單設得太鬆?還是被核准的那個程式本身有洞?
如果允許清單收得更緊,擋得住嗎?
直覺的第一個修法是把清單收緊——只放最少的指令,其餘一律要人核准。這個修法對其他攻擊面或許有用,對這一次沒用。Pillar 在報告裡特別指出,這個繞過就算把清單清空到最嚴格的設定,也就是完全沒有任何指令被預先核准,依然成立。清單裡放了什麼、放了多少,從頭到尾都不是這次攻擊卡進來的位置——清單再嚴,也擋不住一個從來沒被清單看見的東西。
這個結果比「清單設得太鬆」更難處理。清單鬆了,緊一點就好,是一次性的調整;清單再緊都攔不住,代表問題不在清單這個機制的鬆緊刻度上,而在檢查器根本沒有機會看到某一整類動作。往下追這一類動作長什麼樣子,才是這次調查真正要解的題目。
那會不會是 git 自己出了漏洞?
第二個念頭是懷疑 git——一個已核准的指令,行為卻被外部設定左右,聽起來像是輸入處理沒做乾淨。這個念頭也站不住腳。Git 在啟動時讀取 PAGER 來決定用什麼程式顯示輸出,是設計好的行為,不是意外——開發者自己把 PAGER 換成 less -F 或某個自訂的分頁工具,是再平常不過的個人化設定,正因為平常,才沒有人會去懷疑它已經被動過。同樣的道理放在 Python 身上也一樣,Python 在啟動流程裡讀取 PYTHONWARNINGS 也是正常設定的一部分。Pillar 後來示範的串連版本,把 PYTHONWARNINGS、BROWSER、PERL5OPT 一起改寫,讓那台機器上之後每一次 python3 呼叫都可能被劫持——這也不是 Python 的錯,只是同一個手法換了個舞台。git 和 Python 都沒做錯任何事,問題顯然不在被呼叫的那個程式身上。
合理的推測是:如果真的是 git 或 Python 自己的漏洞,修補應該發生在 git 或 Python 那一層,會一次修好所有用到它們的地方。實際的修補卻只發生在 Cursor 這一端,這本身就暗示問題出在 Cursor 判斷該不該核准的那一步,而不是任何一個被核准後才執行的程式。
內建指令從來不是「磁碟上的程式」
兩個念頭都排除之後,剩下的位置只有一個——環境變數是怎麼被改的。Docker 這篇文章把原因講得很直接:「built-ins are not programs sitting on disk, and the checker was looking for programs on disk, so they went through without ever being surfaced」。Pillar 的研究點名的是三個內建指令:export、typeset、declare。它們不是安裝在磁碟上、有檔名可以比對的執行檔,而是殼(shell)自己內建的行為——執行的時候不會產生一個「程式名稱」讓允許清單去核對,自然也就從來沒被清單檢查過。
合理的推測是,這種比對方式本身不是偷懶:逐一核對指令名稱是不是在磁碟上的某個檔案,是一件可以快速、可靠完成的事;真的要攔住這次的攻擊,檢查器得先看懂整段殼腳本在做什麼——變數在哪裡被寫入、哪些後續指令會讀到它、讀到之後行為會不會改變——這已經不是比對一個字串,而是要理解一整個殼語言的語意。允許清單選擇比對名稱,換到的是速度與簡單;付出的代價,就是對名稱以外的世界視而不見。
完整串起來,這變成一條四段的鏈子:惡意文字先進來,內建指令趁機默默改寫環境變數,允許清單只顧著核對指令名稱因而毫無察覺,最後一個完全合法、也真的被核准的指令,啟動時讀到的卻是被動過手腳的設定。
點擊任一環節看它的責任邊界 · 4 個角色
攻擊鏈上的四個角色
被感染的文字 · 責任邊界
只是等著被讀取的文字,不知道之後誰會把它當成要執行的指令,也不知道自己會不會被信任。
export / typeset / declare · 責任邊界
不是安裝在磁碟上、有檔名可以核對的執行檔,從一開始就不在允許清單比對的範圍裡,甚至不知道自己正被排除在檢查之外。
允許清單檢查器 · 責任邊界
忠實核對指令名稱是否在清單上,不知道幾分鐘前這個殼的環境變數被誰、用什麼方式動過。
已核准的指令 · 責任邊界
Git 讀 PAGER 來決定用什麼程式顯示輸出,這是設計好的行為,不是漏洞,也不知道 PAGER 早就不是原本那個值。
這四張卡片排在一起,看得出來的不是哪一個角色比較笨,而是沒有一個角色的職責範圍涵蓋得了全貌——文字只管被讀,內建指令只管改變數,檢查器只管比對名單,被核准的指令只管做自己該做的事。四個「只管」疊起來,中間剛好留出一條沒有人負責巡邏的走廊。
PAGER 只是示範用的其中一個切入點。同一批內建指令能碰到的環境變數還有好幾個,各自劫持的是不同程式的啟動行為。單獨改一個變數,劫持的是那一個變數的下一次讀取;把好幾個一起改,劫持的範圍就從「下一次 git branch」擴大成「機器上之後每一次符合條件的呼叫」——Pillar 串連 PYTHONWARNINGS、BROWSER、PERL5OPT 這個版本,示範的正是攻擊面怎麼從一次性的巧合,變成長期存在、只等下一次觸發的埋伏。
| 環境變數 | 誰在啟動時讀它 | 在這次事件裡的角色 |
|---|---|---|
| PAGER | Git | 示範攻擊實際劫持的一個——git branch 讀到的顯示程式已經被換掉。 |
| PYTHONWARNINGS | Python | Pillar 串連示範的一環,讓之後每一次 python3 都可能被劫持。 |
| BROWSER | 與另外兩個一起被串連改寫 | 同一次串連示範裡的其中一個變數,用來擴大波及的後續指令範圍。 |
| PERL5OPT | 與另外兩個一起被串連改寫 | 同一次串連示範裡的其中一個變數,用來擴大波及的後續指令範圍。 |
Cursor 的修法讓 parser 判斷不出來的東西一律得先過人工核准,關掉了這次示範用的路徑;但補的是這幾條已知路徑,不是「允許清單可以被繞過」這種形狀。
修補只補了這一個洞,形狀還在
Cursor 在 2.3 版修補了 CVE-2026-22708,但廠商自己的文件已經把允許清單改稱為「best-effort」,並且警告繞過仍然可能發生。這不是特例。Docker 這篇文章把它和系列文章第四篇提到的 s1ngularity 攻擊擺在一起看,指出兩者是同一個形狀:s1ngularity 裡,一個被動過手腳的套件借用的是一個已經登入、已經拿到權限的 agent 身分;這一次,被動過手腳的文字借用的是一個已經核准、已經拿到放行的指令。兩起事件都沒有破壞任何東西,只是把原本就發出去的信任,用在發出去的人沒想到的地方。
Pillar 的研究還往回追到 2020 年 Elttam 對環境變數的研究,那種把環境變數變成任意執行的手法,寫下來之後安靜躺了六年,直到 coding agent 把「幫我讀一段文字、幫我打一行指令」變成日常操作,才把門檻一次拆光——六年前要湊齊條件得自己一步一步手動來,現在只要有一個會自動讀文字、自動打指令的代理,條件就自己湊齊了。
拖曳時間軸看每個節點的細節 · 4 個節點
2026 年 7 月,Pillar 又發表了一批橫跨四個 coding agent 的沙箱逃逸研究。當同一件事發生在四個不同的產品上,共通點已經不是某一家的允許清單寫得不夠嚴謹,而是這種手法本身的形狀:問題從來不是沙箱被打破,而是沙箱裡寫出來的一個檔案,後來被沙箱外的某個工具直接信任。允許清單失守,是因為它相信「看起來合法的指令名稱」;這批沙箱逃逸失守,是因為外部工具相信「沙箱裡已經寫好的檔案」。信任被放錯位置的手法在換,換的只是被信任的那個東西是什麼。
Docker 自己選的因應方式不是把允許清單寫得更聰明,而是換一個問題來解:與其判斷每一行指令該不該信任,不如限制 agent 能碰到什麼。這正是 Docker Sandboxes 的邏輯——agent 被關進獨立的 microVM,各自有自己的 kernel、檔案系統,網路預設全部擋下。就算前面那條鏈子照樣跑完,文字照樣被讀進來,export 照樣默默改了環境變數,git branch 照樣被核准,攻擊者這次要找的東西,已經不在同一台機器上了。要在自己的環境重現這道邊界,門檻寫得很具體:Docker Sandboxes 0.37.0 以上,加上設定好的 SSH 存取,以及安裝好 Cursor 的 Remote SSH 支援套件。
點觸底線詞看完整說明 · 4 個名詞
Docker Sandboxes 把 agent 關進各自的 microVM,靠 deny-by-default network HTTP 與 HTTPS 只能透過 host 上的 proxy 出去,proxy 會照規則檢查每一個請求;其餘走 TCP 的連線都需要一條指名位址與埠號的規則;UDP 與 ICMP 直接擋下。 、刻意保留的 SSH agent forwarding Sandbox 會把 SSH agent socket 轉進去,讓 git push 之類的日常操作能繼續動;sandbox 裡的程式可以借用這個 agent 去驗證身分,拿不走金鑰本身,但能在 sandbox 存活期間借去用。 、預設共享的 workspace Workspace 預設是即時掛載在 host 上的,Git hooks 和 Makefile 目標都摸得到,一個被動過手腳的 hook 甚至不會出現在 git diff 裡;用 --clone 可以讓 agent 拿到自己的副本。 之間的取捨,撐住最後一道防線,再交給 Docker AI Governance 網路與檔案系統規則由管理員統一設定,透過既有的登入帳號套用到每個開發者身上;每一次規則被觸發,事件會帶著使用者、時間戳記與觸發的規則,送進既有的 SIEM。 留下稽核紀錄。
這道牆不是完全密封的,兩個洞是刻意留下的:SSH agent forwarding 讓 sandbox 內的程式借得到已登入的憑證,workspace 預設共享讓 Git hooks 和 Makefile 目標依然摸得到。合理的推測是,攻擊者就算真的鑽進這兩個洞,能做的事也被鎖在 sandbox 存活的那段時間裡——借來的身分可以拿去做一次 git push,但拿不走金鑰本身,sandbox 一結束,借來的權限也跟著收回;真正想留住東西,得靠共享的 workspace,而那正是 --clone 這個選項存在的原因。這句話換個角度講就是:就算攻擊真的鑽出了這兩個洞,它也不再是一次無聲的入侵——每一次規則被觸發,都留下使用者、時間戳記和觸發的規則。判斷每一行指令該不該信任,是一個隨著 agent 越來越強只會越來越難的問題;限制 agent 能碰到什麼,是一個早就解決過的問題。
把整起事件收斂成一份檢查清單,其實只有三句話:先確認允許清單檢查器比對的是磁碟上的程式名稱,還是這個殼此刻的完整狀態;再確認它看不看得到 export、typeset、declare 這類內建指令的變更;最後問一句——就算把清單鎖到最嚴,這條路還堵得住嗎。三句話裡只要有一句答不上來,允許清單能擋住的就只是攻擊者懶得換一種寫法的那部分。
What we missed:稽核 agent 的允許清單時,別只數清單上有哪些指令——去問清單比對的到底是磁碟上的程式名稱,還是這個殼此刻真正的狀態;攻擊者永遠會找還沒被人問過的那一半。