iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
AI Security

你該防的不是駭客,是你自己的 AI:在本機驗證你的防線系列 第 19

Day 19|AI 擋住了命令注入,卻把功能一起刪了

  • 分享至 

  • xImage
  •  

叫 AI 修一個資安問題,它交回來,測試全綠。那份修法可能是把整個功能刪掉了,而你的測試看不出差別。

今天在自己的 daemon 上找到一個那樣的洞,順手叫模型修同一個形狀 24 次,裡面就有 5 份。昨天結尾說「那段文字你不是拿去送,是拿去執行」,今天就是那條。

那行有加引號,還是被打穿了

我的 daemon 會把收到的圖片轉成文字。轉之前先量寬度,程式長這樣(變數名換過了):

const info = execSync(`sips -g pixelWidth "${imagePath}"`, { encoding: "utf-8" });

那條路徑裡有一段是附件的原始檔名,而檔名是外面給的。

這裡有一半是對的:路徑外面有雙引號,所以檔名裡就算有分號,shell 也不會把它當成下一個指令。我拿一個玩具版本實際打過:

$ node probe.mjs vuln.mjs
打得穿的	subst,backtick
無害輸入	ok
判定	vuln

名單上沒有分號那條。引號有效。

但同一行裡的另外兩條進去了。$( ) 和反引號在雙引號裡照樣會被展開,那是 shell 的規則。所以一個叫做 x$(touch /tmp/PWNED).png 的檔名,先幫你跑了 touch,才輪到 sips

判準不是我讀程式碼讀出來的。是那個標記檔出現在暫存目錄裡。

而「引號擋得住分號」這句還有一個更窄的範圍,是我事後才量到的:它只在輸入本身不含雙引號時成立

檔名裡放一個 " 先把引號關掉,分號就進去了。更難看的是關掉引號之後改用 >:它把輸出重導向到另一個檔案,那個檔案就被蓋掉了。這一路沒有建立任何標記檔,我那三條輸入一條都看不到。

這不是玩具會遇到的問題

CVE-2025-59834(2025 年 9 月公開,CVSS 3.1 為 9.8,評等 Critical)是同一個形狀:adb-mcp 這支 MCP server 把工具參數拼進 adb 指令再交給 exec,而那個參數可能在 LLM 被提示注入之後被填進惡意字串(advisory,2026-08 查證)。

值得停一下的是那個參數的定義:z.string().optional(),一個 Zod 型別驗證過的欄位。驗證通過了。 它確實是合法字串,然後它進了 shell。

驗證回答的是「格式對不對」,不是「帶進執行環境安不安全」。

我今天把它修了

順序照 bug 那套走:先寫一個會紅的測試。

const hostilePath = join(tmpdir(), `nope$(touch ${marker}).png`);
resizeImage(hostilePath);
assert.equal(existsSync(marker), false, "路徑裡的 touch 被執行了");

兩個位置都紅,標記檔真的被建出來(那兩個函式本來就把 sips 的失敗吞掉往下走,所以斷言一定跑得到)。第三處是圖片超過寬度才會跑的那行縮圖指令,測試沒碰到它,我是照同一個形狀一起改的,這一處沒有紅過。三處都換成 execFileSync 加參數陣列:

const info = execFileSync("sips", ["-g", "pixelWidth", imagePath], { encoding: "utf-8" });

綠。指令釘死在第一個參數,路徑是陣列裡的一個元素。Node 文件說 execFile does not spawn a shell by default,執行檔直接被開成新行程;exec 是先開一個 shell 再把整行交給它(官方文件,2026-08 查證)。

SQL 那類用預備語句,道理是同一個:不要組一個字串出來讓別人解讀。路徑那類要另外處理,它的問題不在解讀而在走到哪裡去。

Day 17 那天的結論是「意圖一致不等於使用者授權」,今天這條是「格式合法不等於帶進執行安全」。兩次都是判準看錯了東西。

流程圖:同一段字,經過 shell 與不經過 shell 的兩條路

這張圖畫的是玩具版,指令是 echo 不是 sips。要看的是中間那兩個菱形:一條有 shell 讀過整行,一條沒有。最上面那個框特別寫了「驗證通過」,因為驗證擋不到這件事。

那我怎麼知道 AI 修的也是對的

我自己修的那份我讀得懂。叫模型改的那些,讀起來也都很像對的。

所以我把那個玩具檔案丟給模型,叫它修 24 次。修完不看程式碼,直接把每一份丟進同一個 probe 實跑。

24 份,一份都沒被打穿。兩種問法都是 0 / 12。

(我原本還想比「有沒有附上攻擊輸入」差在哪,那組比較是白做的:附上的那臂拿到的就是等一下要拿來評它的三條輸入。把測試當規格交給模型本來就可以,錯在同一組輸入又當了評分集,那只證明得了它會不會通過已知的測試。)

我沒有補跑到出現失敗為止,三種結果各要怎麼寫都登記在 recipe 的 README 裡。要說的話只有一句:這 24 個「過」只涵蓋我這三條輸入。

前兩關全過,我還是去讀了那 24 份

然後就看到了不對勁的東西。

其中 5 份長這樣:

export function inspectDevice(device) {
  return `dump ${device}`;
}

它把子行程整個刪掉了,直接回傳字串。

我的判準當時只有兩關:標記檔有沒有出現、無害輸入還回不回得到正確結果。這 5 份兩關都過,因為玩具裡那個指令是 echo,回音跟自己組一個字串看起來一模一樣。

換成我真正的那支 sips,這叫做功能沒了。而我的量尺看不到。

所以我補了第三關:把 PATH 前面插一個會記一筆的假 echo,那個外部指令到底有沒有被開起來,變成一個看得到的事實。拿留檔的 24 份重判一次(不用再問模型一次):

$ node summarise.mjs runs/2026-08-18b/results.tsv
臂         pass  vuln  noexec  broken  unusable  小計
plain      9     0     3       0       0         12
withtests  10    0     2       0       0         12

noexec 就是那 5 份(broken 是擋住了但功能壞掉、unusable 是交回來的東西載不進來,這批都沒有)。沒有一份是被攻擊輸入打穿的,被漏掉的是「它還有沒有在做事」。

這一關自己也會看走眼:寫絕對路徑 /bin/echo、或走 sh -cecho 剛好是 shell 內建,我插的假指令都攔不到,「其實還在做事」就被記成沒開。那份有洞的原版跑出來就是「有開外部指令:沒有」,而它功能一點都沒少。所以機器給的 5 只是上界,敢當確數是因為我打開那 5 份看過,一個子行程呼叫都沒有(recipe 第 13 條在驗這件事)。

那句「我有做驗證」為什麼不夠

多數人在這裡會做的是「我有做輸入驗證,所以沒問題」。上面那個 CVE 已經回答了:驗證管格式,格式合法的字串照樣帶得動 payload。清乾淨危險字元也不行,我在 recipe 裡留了一版清掉 ;&| 和反引號的修法,$( ) 照樣穿過去。正確做法是根本不拼字串,不是把字串清乾淨再拼。

還有一件免得你換一個坑:參數陣列擋的是 shell 那一層,不是全部。 字串形狀合法,照樣可能被那支程式當成選項讀。有些 CLI 可以用 -- 把後面的字釘成一般值,但要那支工具支援(sips 就不吃)。而 shell: true 一開,整個回到原點。

而今天真正學到的是另一半。「AI 幫我修好了」這句話有一個有效範圍,它等於:你那組攻擊輸入涵蓋的範圍,加上你那個功能判準涵蓋的範圍。 這批沒有一份被那三條輸入打穿,先出事的是功能判準那一半,24 個綠燈裡有 5 個不該算過,而那是我人工讀出來的。

Day 5 問的是「AI 說修好了,那份測試紅過嗎」。今天往前挪一格:那份測試本身量得對嗎。先審你的判準,再審它給的答案。

今天做什麼

一、把拼接點掃成一份位置清單。 先限定一個模組,讓 AI 找出「外部字串會被直譯器或有權限的 API 解讀」的候選位置:shell 指令、SQL、檔案路徑、系統呼叫。它做模式比對很快,但那是候選不是全部,也會有誤報。哪一個位置的字串真的來自外部,你自己看,這一欄它猜不出來。

二、挑那一個位置動手。 shell 走 execFile 的參數陣列、SQL 走預備語句,這兩類是把資料跟語法分開。路徑是另一種問題:要限定允許的根目錄再處理符號連結,參數化擋不了目錄穿越。改之前先寫一個會紅的測試,判準放在檔案系統上,而且跑在隔離的暫存目錄裡,不要拿正式資料當靶。攻擊輸入自己造,讓它只建一個標記檔;advisory 給的示範是刪檔案,它自己在旁邊寫了 be careful actually executing this payload,不要照抄。

三、修完實跑一次,驗兩件事。 攻擊輸入不能得逞,功能不能消失。第二件比你想的容易漏,我今天漏了 5 次。量法可以直接抄我的:把 PATH 前面插一個會記一筆的假指令,那支外部程式有沒有被開起來就變成看得到的事實。它會漏掉寫絕對路徑那種,所以不要只看這個自動判準,最後還是要打開檔案確認實際呼叫路徑。

這一輪答不了的

攻擊輸入那一半其實也不完整,只是這次沒輪到它。 我只量了 Node 的 child_process,SQL 與路徑那兩類沒量;自帶雙引號那條是事後補測才發現的,變數展開、萬用字元、跟 shell 無關的參數注入都還沒測。「這三條沒穿」的正確讀法是「雙引號在這一個位置擋住了指令分隔」,不是「這個位置安全」。

那 24 發也是同一顆模型(gpt-5.6-sol,走 Codex CLI)、同一天、同一個玩具檔案,是同一題重複抽樣,不是 24 個獨立案例。

收工

今天你手上會多一份候選位置清單、一個紅過綠過的參數化位置、一組固定的攻擊輸入,以及一個會檢查「功能還在不在」的判準。

攻擊集也從十九條變成二十條。第 20 條的判準跟前面十九條都不一樣:不在模型的回覆裡,也不在資料庫裡,在檔案系統上,那個標記檔出現了沒有。

明天

今天這份位置清單先留著,Day 27 那台掃描器要整批掃的就是這一類問題,到時候拿它對答案。

Part II 到今天為止,你手上有一整排防線:閘、白名單、授權核對、參數陣列。明天開始的問題不是再加一道,是這些東西上線兩個禮拜之後,你說不說得出它們到底擋過什麼、有沒有擋錯人。


上一篇:Day 18|同文字各判兩遍,36 組有 3 組答案不同

今天這一份:recipe 19|範例專案:github.com/cyh7789/ai-security


上一篇
Day 18|同文字各判兩遍,36 組有 3 組答案不同
下一篇
Day 20|同一個要求跑了 48 次,紀錄裡長出 48 種理由
系列文
你該防的不是駭客,是你自己的 AI:在本機驗證你的防線22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言