iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
AI Security

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

Day 5|AI 找得到自己寫的洞,是我的驗證方法騙了我

  • 分享至 

  • xImage
  •  

昨天結尾留了一個問題:如果找漏洞的跟寫程式的是同一個模型,它找得到自己的錯嗎?

今天實際跑一次。先講結論:它列得出來,而且比我以為的多。但「列出來」跟「找到了」中間隔著兩道關,兩道都得你自己走。今天出包的就在第二道,而且出包的不是它,是我。我拿一條打不穿的攻擊字串去驗收,看到畫面沒反應就準備收工。

挑一段它寫的,然後問錯問題

我從範例專案挑了一段最近由 AI 生成、已經合進去的程式碼。一個聊天介面,把模型的回答顯示在畫面上:

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const question = form.querySelector("input[name=q]").value;
  output.innerHTML = "<p>思考中…</p>";
  const answer = await ask(question);
  output.innerHTML = `<div class="answer">${answer}</div>`;
});

七行,跑起來沒問題,我當初 diff 掃一眼就合進去了。

我把它整段貼回去問:「這段程式碼安全嗎?」

它回了六段:注意 XSS、建議做輸入驗證、可以考慮上 CSP、提醒我金鑰不要放前端。每一句都對,每一句都是通論,沒有一句指得出這七行裡的哪一行。

不死心,我換個問法再問一次:「這段有沒有安全問題?」這次它說主要風險在 innerHTML,如果 answer 的來源不可信就可能被注入。

看起來答對了。但我心裡清楚:第二次問的時候我已經知道答案了,我是在確認,不是在偵測。

先擋掉一個你可能卡住的地方:你分不出哪段是 AI 寫的,沒關係。 我挑得出來是因為它是範例專案;你那個跑了八個月的專案沒有標記,commit 作者也都是你。等一下那張表是逐檔掃的,不在乎哪一行是誰寫的,從最近改過的檔案或對外的入口開始就行。

語法這關解決了,安全那關沒有跟上

先講一下這不是我這段特別爛。

Veracode 7 月底那份報告摘要(2026-08 查證)說,現在的模型產生語法正確的程式碼幾乎百分之百,語法這關算是解決了,但跨模型的平均安全通過率只有 56%。分弱點類型有四類:SQL injection 83%、密碼學 87%、跨站腳本 15%、log injection 12%。跨站腳本落在最低的那兩類之一,這就是今天挑它的理由。

這是 Veracode 自己出的跑分報告,它賣的就是應用程式安全檢測,判準也是它自己定的,所以這些數字的讀法是「在這家廠商的判準下」,不是套到你這段程式碼上的機率。真正值得帶走的是它自己下的結論,而那句剛好就是今天要走的路:這份資料裡沒有任何一個模型,安全到可以讓你不用驗,換模型減得掉工作量,減不掉風險。它另外點名了現在跑最前面的那個模型,然後自己補一句:那也代表最前面那個,安全任務還是每三題錯一題。

跑得起來跟沒有洞,本來就是兩件事。

這條路走一次,後面每天都是同一個形狀

今天真正要交出去的不是一個修好的檔案,是一條可以重跑的路。後面每一次「用 AI 做防禦」都是這四步:

四步閉環:AI 偵測、人確認、修、行為驗證,驗完的攻擊集下次改動再打一次。綠色框的第二步跟第四步是不能交給 AI 的那兩步

圖上綠色的那兩格是重點:第二步跟第四步不能交出去。 第一步跟第三步 AI 做得比你快,中間那兩步交出去的話,這條閉環就退化成它自己跟自己講話。

還有那條虛線。攻擊集不是做完就收起來的,它是下次改動之後重跑的輸入,所以第四步要留成腳本或筆記,不能留在腦子裡。

換一種問法:把基準從外面帶進去

先說一條更快的路,免得你照著我走冤枉路:如果你已經知道要找什麼名字,直接 grep 更快。

grep -rn "innerHTML\|dangerouslySetInnerHTML\|v-html" src/

三秒鐘,什麼都不用送出去。下面這條 AI 路徑是用在你不知道要找什麼的時候:說得出「我擔心有洞」,說不出那個洞叫什麼名字。知道名字就搜,不知道就往下讀。

回到剛才那個沒用的問句。

問題不在模型不夠強,在那個問句的形狀。「安全嗎」是一個是非題,而是非題只有兩個答案:回「有問題」它得指得出來,回「沒問題」它不用付出任何代價。你要它評的又剛好是它自己的產出,沒有外部基準可以對照,它漏掉的正是它不知道自己不知道的那些

所以改成給它一張固定的類別表,要它逐檔比對,而且每一條都要落在你查得到的具體位置上:

以下面四類逐檔檢查這個目錄。每一條都要給檔名,並把那一行的原文逐字引出來,
我要能拿它回檔案裡搜得到。說明在什麼輸入下會出事。
找不到就寫「這一類沒有」,不要補通論建議。

1. 寫死的憑證或祕密
2. 該驗沒驗的輸入(路徑、識別碼、外部字串)
3. 權限開太大(誰都能讀到別人的東西)
4. 危險渲染(把字串當成標記交給瀏覽器執行)

這四類是今天挑的,不是一份安全檢查基準。 CSRF、SSRF、工作階段管理、反序列化、相依套件的已知漏洞、競態,全都不在裡面。它的用途是把「安全嗎」這個是非題換成四個你認得的格子,不是把你的專案掃乾淨。

要原文不要行號。 我第一版寫「給檔名跟行號」,那個要求沒有真值可以對:它拿到的可能只是你貼過去的片段,本來就沒有穩定行號;就算讓 agent 自己讀目錄,你改幾行它就漂掉了。原文不一樣,原文你可以貼回編輯器搜,搜不到就是它編的。

差別在哪?「安全嗎」問的是它的整體判斷,這張表把判斷切成四個有名字的格子。它還是得判斷(「這個輸入該不該驗」沒辦法純比對),但每一條都落在你認得的類別裡,而且附著回得去查的原文,所以你核得動。

餵之前先過一遍昨天那份 AI-SHARING.md。第三段「需要看內容才知道」的那些正好是今天要逐行讀的,打開看一眼裡面有沒有寫死的內部網址或客戶名。第一段那些不要進去。

昨天的規矩沒有變:清單本身跟檔案列表不要送出去。今天送的是程式碼,那是另一件事,但代價一樣要自己算過。你叫它掃一個目錄,它翻到的每一條路徑都會進到模型那邊,而路徑本身也是資訊。省下的時間跟送出去的目錄結構,自己權衡,這條界線昨天畫在哪,今天就在哪。

它列了六條,今天只有一條是今天的事

先講一件前提:這一步需要一個讀得到你檔案的工具,編輯器裡的 AI 或命令列 agent 都行。手邊只有網頁對話框的話,逐檔掃不了,改成一次貼一個檔案問,類別表照用,只是要自己控制貼出去的範圍(那是昨天那份清單管的事)。

它回的每一條長這樣:

src/render.js
  原文:output.innerHTML = `<div class="answer">${answer}</div>`;
  類別:4 危險渲染
  什麼輸入下會出事:answer 裡帶著 HTML 標記的時候

範例專案跑出來六條,壓成一張表:

檔案 原文(可搜的片段) 它說的問題 我的判斷
src/render.js output.innerHTML = 模型回答直接進 innerHTML 真問題,今天修
server/files.js path.join(UPLOAD_DIR, req.params.name) 接使用者給的檔名,跳得出上層目錄 真問題,但不是今天的題目
server/tools.js await execAsync( exec 用字串拼網域名 真問題,Day 19 展開
server/orders.js db.query("SELECT * FROM orders WHERE id = ?" 查訂單沒有比對擁有者 真問題,Day 16 展開
src/api.js import.meta.env.VITE_MODEL_API_KEY 金鑰在前端 Day 1 搬走的就是它,範例專案裡刻意留著有洞的版本
test/setup.js sk-proj-staging- 寫死的 staging 金鑰 Day 2 已經處理過,這裡是刻意留的練習標的

第二欄放的是可以直接拿去搜的前半段,表格塞不下整行。完整那行留在它的原始輸出裡,別丟掉。

填第四欄之前先做一件機械的事:每一條都把第二欄貼回編輯器搜一次。 花不到兩分鐘,而它擋掉的是整張表裡最貴的一種錯:格式完整、位置具體,但那一行不存在。要原文不要行號就是為了這一步,行號沒得搜。

搜不到的怎麼處理,方向只有一邊,這一點跟今天其他地方一樣。搜不到只證明那句引文不可靠,不證明那個檔案沒問題。 所以標成「定位不到」,不計進已確認的那幾條,也不要當成已經排除。牽涉到高風險資料流的那幾個檔,還是自己打開看一眼。

這一欄只有你填得出來。 同一份候選清單套到別人的專案,最後兩列的結論就不一樣了:那把 staging 金鑰在我這裡是教材,在你那裡可能是還活著的憑證。模型看不到這個差別,它只知道那一行長得像祕密。

最後兩列還有另一個作用:前四天做過的事會在這裡回頭現身。 你會知道那幾天的處置真的落地了,也會知道 AI 不記得你處理過什麼,每一次它都會重列一遍。

四條真問題今天只修一條,這是刻意的。權限那條留到 Day 16、指令注入留到 Day 19,那兩天各有一整篇。今天的目的是把這條路走通,不是把洞補完。

那為什麼挑 XSS,不挑同樣落在低的那兩類的 log injection?一半是因為這個專案真的踩到的是它。另一半更貼題:這裡的髒東西不是使用者打進來的,是模型吐出來的。

一般 XSS 教材的污染源是輸入框。這一段的污染源是 answer,而 answer 來自模型,模型的回答又可能來自它讀到的網頁、工具回傳的內容、或別人塞給它的一段文字。輸入框你至少知道要防,模型的回答在你腦子裡的標籤是「我自己系統的輸出」。

修:一行的事

innerHTML 會把字串當成標記解析,textContent 不會。這條分界就是修法:

// 「思考中」是我自己寫死的字串,不危險,但一起換掉
output.textContent = "思考中…";

const answer = await ask(question);
const box = document.createElement("div");
box.className = "answer";
box.textContent = answer;
output.replaceChildren(box);

第一段那個「思考中」值得說一句。它塞的是我自己寫死的字串,不是外部來的,留著 innerHTML 也不會出事。我還是換掉了,理由是一致比逐個判斷省事:這個檔案裡從此沒有 innerHTML,下次 grep 一遍就知道乾不乾淨,不用每次都停下來想「這一個是安全的那種嗎」。

你的專案如果用 React 或 Vue,要找的東西不叫這個名字,兩家官方文件都寫得很直白(2026-08 查證)。Vue 說雙大括號是把資料當純文字,要輸出真正的 HTML 得用 v-htmlReact 那邊對應的是 dangerouslySetInnerHTML,文件自己說它覆蓋的就是 DOM 節點的 innerHTML,而且要極度小心,內容不可信就是在製造一個 XSS。

搜不到 innerHTML 不代表你沒事,代表你要搜的是另一個名字。

換掉之後有代價,講清楚免得你以為沒有:模型回的 markdown 跟程式碼區塊會變成純文字顯示。

換行要講細一點,因為它是最容易讓人改回去的那一格。textContent 保得住字串裡的換行字元,但 CSS 的 white-space 預設值會把連續空白摺起來(MDN,2026-08 查證),所以畫面上看到的還是連在一起。那一格補一句 white-space: pre-wrap 就回來了,不用碰 innerHTML

還有一條回頭路要先擋住:不要把 markdown renderer 的產出直接丟進 innerHTMLoutput.innerHTML = marked(answer) 這一行看起來像在用函式庫,不像在自己組 HTML,所以它躲得過「不要自己拼 HTML」這種提醒。但 markdown 的規格本來就允許內嵌 HTML,那一行等於把洞原樣裝回去。真要渲染,renderer 後面得接一個成熟的 sanitizer,而且連 href 能用哪些 scheme 都要限制,那不是今天做得完的。

今天先讓它顯示成文字,因為「顯示得不夠漂亮」跟「別人的字串在你的頁面上執行」不在同一個量級。

非渲染不可的話,今天的產出換一個

上面那兩條路你可能一條都走不了:使用者現在看得到模型回的表格跟程式碼區塊,明天改成純文字就有人在群組裡問;而 sanitizer 今天做不完。

那就別勉強改。你今晚要拿走的不是那行 textContent,是裝完 sanitizer 之後有東西可以驗它。

裝了 sanitizer 之後,真正的問題不是選得對不對,是你憑什麼說它有效。第四步照跑,但判準要跟著修法換,不能照抄上面那條

這個我自己也差點寫錯。DOMPurify 的 README 就擺著一個例子(原文,2026-08 查證):

DOMPurify.sanitize('<img src=x onerror=alert(1)//>'); // becomes <img src="x">

img 留著,被拿掉的是 onerror。你要是照抄「img 不在 DOM」那條,會把一個修對了的 sanitizer 判成失敗。

兩條路的判準長得不一樣:

  • textContent 路線img 不在 DOM,整串原字串以文字顯示。
  • sanitizer 路線:判準看你的允許清單。允許圖片的話 img 可以在,但事件屬性必須消失:
const img = document.getElementById("answer").querySelector("img");
console.assert(img === null || !img.hasAttribute("onerror"));

產品規則本來就不准出現圖片,才把「img 不存在」當判準。

還有一句要講清楚:這兩條輸入不足以宣告一個 sanitizer 有效。 危險的 URL scheme、SVG、srcdoc,凡是你的產品允許的上下文都得另外測。

換掉 innerHTML 是這個範例專案的修法,「修之前要先失敗過」那條紀律才是能帶走的東西。

打進去,什麼都沒發生

修完我照直覺驗收:在輸入框打 <script>alert(1)</script>,看它跳不跳。

沒跳。

我差點就在這裡收工了。回頭想想不對勁:我修之前沒有先打過一次。

於是把修改復原,再打同一串。還是沒跳。

沒修之前也不跳,那我剛才在驗什麼?

打開 DevTools 看那個容器的內容,<script> 標籤好端端躺在 DOM 裡面。它進去了,只是不執行。

這件事 MDN 自己寫在 innerHTML 的安全考量那一段(原文,2026-08 查證)。那句話的關鍵在轉折:does prevent <script> elements from executing,然後接一個 but,說攻擊者還有很多其他方式可以組出會跑 JavaScript 的 HTML。它接著給的例子就是圖片標籤,因為 src 不是一個有效的圖片網址,錯誤處理器會被執行。

換成那條,畫面立刻跳出來:

<img src=x onerror=alert(1)>

所以綠燈是假的。 我拿的那條字串,在有洞的版本跟修好的版本上表現一模一樣,它分不出這兩種狀態,那它就不是一個驗證。

這個形狀不是今天才冒出來的。前面四天,每一天都遇到過一次。

Day 1grep 掃金鑰:找得到代表確定洩漏,找不到不代表安全。Day 2 種誘餌問 AI:它答得出來代表規則對它沒用,答不出來什麼都不代表。Day 3 查用量:用量正常不能證明沒被用過,只能證明沒被大量用過。Day 4 驗忽略檔,那天自己就寫了一句「這個判準昨天前天都用過」。

今天是第五次,方向一樣只有一邊:跳了代表有洞,沒跳什麼都不代表。

寫任何一個驗證步驟之前,先問它一句:**這個檢查在事情沒做的時候,會不會也顯示成功?**會的話它就是假的,換一個。事後補驗證抓不到這種假通過,因為那時候你已經知道答案,會挑一個會過的。

而且我還打錯了地方

換成那條圖片標籤之後跳出來了,我以為這下驗完了。

要把它寫成可以重跑的腳本時才發現沒有。那次會跳,是因為模型剛好把我打進去的字原樣覆述了出來。

回頭看這個洞的資料流:髒東西進來的位置是 answer,不是輸入框。你在輸入框打的字要先繞一圈才碰得到 innerHTML,中間隔著模型:它決定要不要覆述、用什麼形式覆述。

那一圈不歸你管。 它可能拒答、可能改寫成別的講法、可能把角括號換成 &lt; 這種實體。所以「打進輸入框沒跳」有兩種解釋:洞修好了,或者那串字根本沒走到 innerHTML

這裡有一個我原本猜錯的:模型把它包進程式碼圍籬,擋不住。 我以為 markdown 的三個反引號或行內反引號會讓它變安全,五種回法各跑一次才知道不會。反引號是 markdown 的語法,不是 HTML 的,字串交給 innerHTML 的時候它就只是幾個普通字元,標籤照樣被解析出來。只有模型自己把角括號 escape 掉那一種真的擋住。bash verify.sh 6 自己跑一次,recipe 05 就是第四步留成的那支腳本。)

這件事值得記住的原因不是它冷門,是它的方向:如果你以為圍籬會擋,那麼「這次沒跳,大概是模型幫我包起來了」就變成一個聽起來合理的解釋,而你又放過一次假綠燈。

同一個形狀又來一次,這次我連自己剛立的判準都沒套上去。

要驗這一格,payload 必須確定抵達 answer。最省事的做法是把模型端暫時換成回聲:

// src/api.js
export async function ask(q) {
  return q; // DO-NOT-COMMIT 回聲模式,驗完刪掉
}

一個檔、一行,不用裝任何東西。改完在輸入框打什麼,answer 就是什麼,走的還是應用自己那條 submit → ask → 渲染,只是中間那一段不再看模型的臉色。回聲比寫死 payload 好的地方是換攻擊字串不用再改檔。

那行 DO-NOT-COMMIT 不是裝飾。我第一版寫的是「測完改回來」,寫完自己愣了一下:改回來靠的是我記不記得,而這篇從頭到尾在講不要依賴那個。

Day 3 那支 pre-commit hook 在這裡有第二個用場,但它掃的是 gitleaks 的祕密樣式,不認得這個標記,要自己加一條。在那支 hook 裡補上:

# 排除 .githooks/ 自己,否則這一行會擋住它自己
if git diff --cached -U0 -- . ':(exclude).githooks/*' | grep -q '^+.*DO-NOT-COMMIT'; then
  echo "staged 內容裡有 DO-NOT-COMMIT 標記,commit 中止"
  exit 1
fi

那個 exclude 是踩出來的。第一版沒有它,結果是這條規則擋住了「把這條規則 commit 進去」,訊息還跟真的攔截一模一樣。

沒有那支 hook 的話,這個標記就只是一行註解,還是靠你記得。那就至少讓它是 git diff 一眼看得到的字串,不要只留一行看起來很正常的 return q

這不是作弊,它正好在模擬這個洞真正的觸發條件:模型的回答裡帶著標記。 它讀進去的網頁、工具回傳的內容、別人塞給它的一段文字,都可能帶著角括號進來,然後被你的程式碼當成「自己系統的輸出」放行。

你自己跑一次:讓 payload 確定走到那一行

ask() 換成上面那段回聲,在輸入框打那條會執行的 payload(攻擊集第 01 條),送出。

不要只看 alert 跳不跳,看那個標籤有沒有真的變成畫面上的元素:

console.log("img 進到畫面上了嗎:", !!document.getElementById("answer").querySelector("img"));

answer 是我這個範例的容器 id,換成你的:在 DevTools 裡對著模型回答那塊右鍵檢查,抓那個字串真正被放進去的元素,不是外層的 wrapper。React 特別容易抓錯,選到外層的話 querySelector("img") 永遠回 false,那又是一個假綠燈。

修之前印 true,修之後印 false

修之前那一趟別急著走完,多打一條。 同一個還沒修的版本,換成攻擊集第 02 條 <script>alert(1)</script>,然後看這行:

console.log("script 標籤進 DOM 了嗎:", !!document.getElementById("answer").querySelector("script"));

alert 不跳,這行印 true

停一下看清楚:版本沒變,洞還在,只是換了一條輸入,結果就從「跳」變成「不跳」。

這就是前面我自己踩的那個坑,現在它在你手上跑過一次,不是我講給你聽。

回頭看第 01 條那行 img 的斷言。它比「跳不跳」多分得出一種情況:你的頁面如果有 CSP 而且沒放行 inline 事件處理器,onerror 會被瀏覽器擋掉,alert 不跳,但那個 img 元素還在 DOM 裡。我實測過(bash verify.sh 7 自己跑),同一段程式碼放在兩種頁面上:

沒有 CSP                                fired=true   imgInDOM=true
default-src 'none'; script-src 'self'   fired=false  imgInDOM=true

所以判準是這樣讀的:沒跳而且元素不在,是你修好了;沒跳但元素還在,是環境替你擋掉這一條 payload。 元素還在就證明這一層仍然把不可信的字串解析成 HTML,那個洞沒補。至於換一條 payload 能不能執行,要看你完整的 CSP 跟注入的上下文,那得另外測,不能從這裡推。CSP 是另外一層,不是這一層的替代品。

攻擊集的第一批:兩條,不是一條

現在把這兩條存下來。開一個 attack-set.md 放專案裡:

# 攻擊集
# 涵蓋範圍:把外部字串當成 HTML 內容整段塞進元素的那些位置(innerHTML 這一類)。
# 字串進到 href、src、style、srcdoc 或 SVG 是別種上下文,這兩條打不到。

01  <img src=x onerror=alert(1)>
    打哪裡:讓它確定抵達 answer。最省事是把 ask() 暫時改成回聲(return q),
            再從輸入框打進去。不要直接打輸入框就算數,模型不一定原樣覆述,
            而且它包 markdown 圍籬也擋不住,只有它自己 escape 才會擋住
    判準:看 #answer 裡有沒有 img 元素,不要只看 alert 跳不跳
    修之前:img 在;頁面沒限制 inline handler 的話 alert 也會跳
    textContent 修之後:img 不在,整串以文字顯示
    沒跳但 img 還在 = 頁面的 CSP 擋掉 onerror 而已,洞沒補

02  <script>alert(1)</script>        錯誤判準的對照輸入
    修之前:alert 不跳,但 script 是 DOM 元素
    textContent 修之後:script 不是 DOM 元素,整串以文字顯示
    用途:證明「只看 alert」在有洞的時候也會給你綠燈

它證明不了你的程式碼有沒有問題,但它證明得了你的判準有沒有辨識力。 哪天第一條的 img 還在、事件卻沒發生,先分清楚是真的擋住了,還是又拿到一個跟第二條一樣的綠燈。

這兩條 Day 14 會長成一整組,那天要拿它比較「改了防護 prompt 之後有沒有比較好」。能比較的前提是攻擊集固定,所以現在就要寫成檔案。

收工前點一下,你手上多了三樣

一份標注過的疏漏清單,就是上面那張表:AI 列的候選,加上你自己填的那一欄。那一欄是它的價值所在,沒有它就只是一份掃描結果。

至少一條真的修掉了,而且是用行為證明的,不是用「AI 說修好了」證明的。

一個 attack-set.md,裡面有兩條。 一條會執行的,一條不會執行的對照組。

回頭看昨天那個問題:找漏洞的跟寫程式的是同一個模型,它找得到自己的錯嗎?

這個問題要拆成三件事才答得準,而三件事的主體不一樣。

列候選:給它一張從外面帶進去的類別表,它列得出來,每一條還附著你回得去搜的原文。這一步它比你快。

是不是真問題:由專案脈絡決定,不在它手上。那把 staging 金鑰是活的還是教材,只有你知道。

修好了沒有:由行為測試決定,而且那個測試要在修之前先失敗過。今天整篇講的就是這件事。

所以答案不是「找得到」或「找不到」。是:它幫你把可疑的地方列出來,但沒有一步的判定權在它身上。 Day 1 說讓 AI 宣告安全等於沒驗,Day 3 說舊鑰要你親手打一次,Day 4 說你能驗證的只有自己這一端。今天換一個場景,講的還是它。

你的攻擊集現在有 2 條。

明天

今天修的是它寫進你專案裡的東西,你有機會先讀過再合進去。

明天要處理它叫你直接跑的東西:一段「執行這個就修好了」的腳本、一行 curl | sh。那類東西沒辦法先打一次看看,因為執行本身就是後果,而它跑起來的時候讀得到你整個家目錄。

所以問題會變成:它在哪裡跑。


上一篇:Day 4|你說不出它知道多少,因為沒人定義過什麼算敏感

今天這條閉環,還有那兩條攻擊輸入:recipe 05|範例專案:github.com/cyh7789/ai-security


上一篇
Day 4|你說不出它知道多少,因為沒人定義過什麼算敏感
系列文
你該防的不是駭客,是你自己的 AI:在本機驗證你的防線5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言