iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
AI Security

AI 寫的程式,安全嗎?完工不是結束,是攻擊倒數的起點系列 第 6

D06 實戰題 1:替 AI 生成的待辦清單做 threat modeling

  • 分享至 

  • xImage
  •  

實戰題 1:替 AI 生成的待辦清單做 threat modeling

這週的破口,沒有一個需要高深的技術才能發現,只需要在動手之前問對問題。

大綱

  • 第一週回顧
  • 靶場介紹:生成提示詞原文、AI 當時交出的規劃、兩組對照組、如何在本機啟動
  • 題目:先設計審查,再動手驗證
    • 第一關:畫資料流與 trust boundary
    • 第二關:列 abuse case、畫權限表
    • 第三關:用瀏覽器驗證你的推測
    • 進階題:比對 AI 的規劃與 AI 的 threat model、寫成可測試的安全需求、查資料庫連線的角色
  • 提示(分三級)
  • 解答(D07 開頭公布)

第一週回顧

這週我們停在「寫第一句提示詞之前」:

  • 第 1 天:AI 把一個功能的開發生命週期壓縮成一個下午,被壓掉的是安全活動
  • 第 2 天:用 abuse case、五個思考模型和 STRIDE,把不能發生的事寫下來
  • 第 3 天:瀏覽器看得到的全都是公開的,trust boundary 在伺服器
  • 第 4 天:把服務的規則寫成權限表,AI 寫的程式要逐格對答案
  • 第 5 天:前端直接連資料庫時,Row Level Security 是唯一的一道牆

今天換你動手。

靶場介紹

這個靶場是我用下面這段對話,讓 AI 一次生成的。我沒有手動埋任何漏洞,AI 產出什麼就是什麼。

第一句(用規劃模式,只產規劃、不寫程式):

幫我做一個會員制的待辦清單網站。使用者可以註冊登入、新增待辦、把清單分享給朋友。首頁要有一個「AI 幫我整理今天待辦」的按鈕,每個會員每天可以用 3 次。註冊要用手機簡訊驗證,簡訊先用假的就好。

用 Next.js 做,要能用 Docker 在我自己的電腦上跑起來。

AI 交出規劃之後,第二句是:

照計畫做

就這樣。中間沒有任何補充說明,AI 規劃最後問我的四個問題我也沒有回答,直接核准。

  • 生成工具與模型:Claude Code 2.1.278(claude -p --safe-mode)、claude-opus-5
  • --safe-mode 的用意:不載入我自己的 CLAUDE.md、skills、hooks、MCP,避免我的個人設定影響產出
  • 生成日期:2026-09-20
  • 技術選擇是 AI 自己決定的:Next.js(App Router)+ PostgreSQL + Prisma + Server Actions,自建 session,密碼用 bcrypt
  • 實作花了大約 19 分鐘、91 個步驟,AI 自己寫了 34 個測試,全部綠燈
  • 原始碼:【待補:GitHub repo 連結】,tag week1-originalweek1-design/app/ 一個字都沒改

repo 裡另外保留了三份文件,做進階題會用到:

檔案 是什麼
week1-design/PROMPT.md 生成紀錄:提示詞原文、工具版本、兩組對照組
week1-design/AI_PLAN.md AI 開始寫程式前交出的規劃原文
week1-design/AI_THREAT_MODEL.md 對照組:同一句需求,後面加上第 2 天的 threat model 提示詞,AI 交出的東西

生成過程還有一個意外發現:我第一次是在 ai-dev-security-lab 這個資料夾裡生成的,AI 的規劃第二段自己寫著「這個 repo 叫 ai-dev-security-lab,所以設計時把安全當成正式需求處理」,產出明顯比較謹慎。資料夾名稱就足以改變 AI 的行為,所以我換到一個中性的空資料夾重來,那次的規劃留在 generation-log/discarded-run-1/

安全聲明

  • 這個靶場只能在自己的電腦上執行。請用下面的指令啟動,它會把所有服務限制在 127.0.0.1,只有你自己的電腦連得到
  • 裡面的漏洞是真的,不要部署到網路上,也不要填入真的 API 金鑰或簡訊服務帳號
  • 不要拿這裡學到的方法去測試不屬於你的網站。在台灣,無故入侵他人電腦或其相關設備,可能觸犯刑法第 358 條(三年以下有期徒刑、拘役或科或併科三十萬元以下罰金);無故取得、刪除或變更他人電腦的電磁紀錄且致生損害,是第 359 條(五年以下);無故干擾他人電腦致生損害,是第 360 條(三年以下)。這三條依第 363 條須告訴乃論——意思是對方提告才會追訴,不是「不會被告」

啟動方式

需要 Docker Desktop(Compose v2.24 以上)。

git clone 【待補:GitHub repo 連結】
cd ai-dev-security-lab/week1-design/app
cp .env.example .env
docker compose -f docker-compose.yml -f ../compose.localhost.yml up --build

打開 http://localhost:3000

  • 簡訊是假的:驗證碼會直接顯示在驗證頁上,也會印在 docker compose logs -f app

  • AI 也是假的compose.localhost.yml 會啟動一個假的 AI 服務,不需要 API 金鑰,也不會產生任何費用。它可以切換行為:

    curl "http://127.0.0.1:8787/__mode?set=truncated"  # 讓每次呼叫都失敗
    curl "http://127.0.0.1:8787/__stats"               # 看它「收費」了多少 token
    
  • 關閉並清空資料:docker compose -f docker-compose.yml -f ../compose.localhost.yml down -v

注意兩件事:docker-compose.yml 是 AI 原始產出,compose.localhost.yml 和假 AI 服務是我為了安全另外加的,都放在 app/ 外面。為什麼需要那個覆寫檔,本身就是其中一個破口,等一下會用到。

題目

這個服務有會員註冊(手機簡訊驗證)、待辦清單、分享清單給朋友、「AI 幫我整理今天待辦」按鈕(每人每天 3 次)。

這週的題目順序和後面三週的實戰題不一樣:**先設計審查,再動手驗證。**你要先在紙上推測破口在哪裡,再打開瀏覽器確認。

第一關:畫資料流與 trust boundary

先不要看程式碼,也先不要點任何按鈕。用第 1 天的架構提示詞請 AI 說明這個專案,然後畫出:

  • 使用者、前端、Next.js 伺服器、資料庫、簡訊服務、AI 服務之間的箭頭
  • trust boundary 的位置
  • 每一條穿過 trust boundary 的箭頭上,傳遞了什麼資料

畫完之後,在圖上多標兩件事:

  • 哪幾條箭頭會花到錢(簡訊、AI)
  • 哪幾條箭頭是會員 A 的資料流到會員 B 面前(分享功能)

第二關:列 abuse case、畫權限表

  1. 對四個功能(註冊、清單、分享、AI 整理)各寫至少一個 abuse case(第 2 天)
  2. 畫出權限表:動作 × 身分(未登入、已登入、被分享的人(僅檢視)、被分享的人(可編輯)、清單擁有者)(第 4 天)
  3. 權限表不要只寫「能不能改」,也要寫「看得到什麼」:看得到清單名稱嗎?看得到其他成員的名字嗎?看得到其他成員的手機號碼嗎?
  4. 在每一個你覺得「AI 可能沒做」的格子上打星號

第三關:用瀏覽器驗證你的推測

只用瀏覽器(加上一兩行 curl),驗證你打星號的格子。註冊兩個帳號,例如 0911111111(小明)和 0933333333(小華):

  • [ ] 註冊頁輸入一支已經註冊過的號碼,畫面回什麼?換一支沒註冊過的呢?(第 2、4 天)
  • [ ] 用小明建立清單,分享給一支沒註冊的號碼,再分享給小華,兩次的訊息差在哪裡?(第 4 天)
  • [ ] 小華有沒有「接受」這個步驟?他下次登入會看到什麼?(第 2 天 abuse case)
  • [ ] 小華只有「僅檢視」權限,清單頁上他看得到小明的什麼?(第 4 天)
  • [ ] 打開開發者工具,把「僅檢視」的 checkbox 從 disabled 改成可按,然後按下去。發生什麼事?(第 3 天)
  • [ ] AI 按鈕用完 3 次會變灰。把它改成可按再按一次,第 4 次有沒有真的送到 AI 服務?(curl http://127.0.0.1:8787/__stats 會告訴你)(第 3 天)
  • [ ] 把假 AI 切到 truncated 模式,讓每次呼叫都失敗,然後連按 8 次。看「今天剩幾次」,再看 __stats 裡的 billed_output_tokens(第 2 天 abuse case)
  • [ ] 在註冊流程按「沒收到?重新發送」。接著打開開發者工具的 Application → Cookies,看看有什麼,改掉它再按一次(第 3 天)
  • [ ] 打開 .next/static 裡的 JS,搜尋 sk-。找不到的話,改搜尋 createServerReference(第 3 天)
  • [ ] 用同一個網段的另一台裝置(手機也行),連 http://你的電腦IP:3000。連得上嗎?改成連 :5432 呢?(第 5 天)

每找到一個,記下:**你在第二關有沒有預測到它?**預測到的比例,就是你這週 threat modeling 的命中率。

進階題

  1. 打開 AI_PLAN.md,比對 AI 當時交出的規劃和你的 threat model。它提到了哪些安全需求?規劃裡提到的,程式碼真的做了嗎?(這題有一個很有趣的答案)
  2. 對照組:打開 AI_THREAT_MODEL.md,這是同一句需求、同一個模型,只是在動手前多要求了一份 threat model。把你找到的每一個破口,去這份文件裡找有沒有對應的項目。找完之後你會明白這週想講的是什麼
  3. 把你找到的每個破口,寫成第 2 天格式的「可測試的安全需求」
  4. 每一個破口,回答:是哪一個設計決定造成的?(例如「分享時直接把對方加進清單,不用對方同意」「AI 呼叫失敗就把次數退回去」)
  5. 這個靶場沒有用 Supabase,前端也不直接連資料庫,所以沒有第 5 天的 RLS 問題。那就換個問法:**跑 docker compose exec db psql -U todo -d todo -c "\du",看應用程式用的是哪個角色。**如果哪天有人把這組連線字串貼給 AI agent,它能做到什麼?
  6. 加分題:git log -p 看看 AI 生成過程中,有沒有哪個 commit 曾經出現過機密

提示

想不出來再往下看。

六個破口裡,三個跟簡訊有關,一個跟錢有關,一個跟「誰看得到誰」有關,還有一個根本不在網頁裡

在你第一關畫的圖上,找那兩條「會花到錢」的箭頭:伺服器在什麼條件下會走這兩條線?誰能讓它走?走幾次?

要看的地方只有四個:

  • 註冊流程裡的「沒收到?重新發送」按鈕
  • 清單頁下方的「👥 共用成員」區塊
  • 首頁的 AI 按鈕(尤其是失敗的時候
  • app/docker-compose.ymlports 那幾行
  • 開發者工具 → Application → Cookies,找 reg_phone。它存的是什麼?是誰決定的?把它改成另一支號碼,再回到驗證頁重新整理
  • curl "http://127.0.0.1:8787/__mode?set=truncated" 之後,連按 AI 按鈕 8 次,然後同時看畫面上的「今天剩 N 次」和 curl http://127.0.0.1:8787/__stats
  • 清單頁的「共用成員」,用被分享的那個帳號(僅檢視)再看一次

解答

解答在第 7 天開頭公布,也會更新到靶場的 FINDINGS.md

歡迎在留言分享你找到幾個、預測命中率多少。找到超過六個的人,請告訴我,我會補進解答並註明是你發現的。

參考資料


【解答草稿】(D07 開頭公布,發布本篇時移除)

先講最意外的部分:我原本預期的五個破口,四個沒出現

寫靶場規劃時,我依照第 2 到第 5 天的主題,預期 AI 會犯這五個錯。實際產出是這樣:

原本預期 實際 為什麼
前端 bundle 含 AI 服務 API 金鑰 沒出現 AI 把 AI 呼叫放在 Server Action,AI 模組第一行就是 import "server-only"。搜遍 .next/static 只找得到「尚未設定 ANTHROPIC_API_KEY」這句 UI 文字
AI 按鈕次數只在前端限制 沒出現 額度用一條 SQL 原子扣除(UPDATE ... WHERE used < 3 RETURNING used),AI 還自己寫了一個「5 個請求同時送出只能成功 3 個」的測試。把變灰的按鈕改成可按再按一次,第 4 次根本沒送到 AI 服務
簡訊驗證無 rate limit 部分出現 同一支手機有 60 秒冷卻、每天 5 封的限制。但限制只綁在「手機號碼」這一個條件上,換號碼、換管道就繞過去了(見破口 1、2)
改清單 id 可讀寫他人清單 沒出現 每一個讀寫清單或待辦的 action 都先呼叫 requireListAccess(),沒權限一律回 404,連「這個清單存不存在」都問不出來。在瀏覽器裡把「僅檢視」的 checkbox 改成可按,按下去伺服器直接擋掉
資料表未啟用 RLS 或 using (true) 不適用 AI 沒有選 Supabase,它選了 Prisma+Server Actions,前端從頭到尾沒有直連資料庫,所以沒有 RLS 這一層。不過連線用的角色是 superuser(見「其他發現」)

照實寫下來的意義:2026 年的 AI 已經不太會犯 2023 年那種「金鑰寫在前端」的錯了。但它仍然會漏掉東西——漏掉的位置從「技術細節」往上移到了**「這個功能會被怎麼濫用」**。

六個實際的破口

# 破口 造成它的設計決定 怎麼發現(不看程式碼) 修補方向 AI 的 threat model 有沒有想到?
1 分享功能就是一台免費簡訊發送機:分享清單給任何號碼都會寄出一封簡訊,內容包含攻擊者自己取的清單名稱(最多 50 字),而且完全沒有任何次數限制 「分享要通知對方」寄的是簡訊,但沒有人問過「誰能讓伺服器寄簡訊、寄幾封」 建一個清單,名字取成廣告詞,分享給任意號碼,看 docker compose logs app 分享通知也要算進簡訊預算;每人每天有邀請上限;訊息內容不可帶使用者輸入 ✅ 2.8 邀請轟炸、4.1 簡訊轟炸與盜刷
2 驗證碼可以寄給任何號碼,包括別人的:註冊流程把「正在註冊的號碼」存在 reg_phone cookie 裡。改掉這個 cookie 再按「重新發送」,伺服器就照寄——對象可以是已經註冊過的號碼(繞過註冊頁的檢查),也可以是 +81 開頭的國際號碼(繞過台灣手機格式檢查)。更糟的是開發模式下驗證頁會直接顯示「這支號碼收到的最後一封簡訊」,等於能讀別人的驗證碼 用 cookie 記住「進行中的註冊」,然後信任它。cookie 是瀏覽器送上來的東西,和表單欄位沒有兩樣 開發者工具改 reg_phone cookie,重新整理驗證頁 進行中的註冊狀態存在伺服器端(或至少簽章);每次都重新正規化並檢查號碼;SMS_DEV_SHOW_CODE 不能預設為 true ✅ 4.1、4.7 假簡訊把 OTP 洩漏、4.8 預設值不安全
3 會員列舉:註冊頁輸入別人的號碼會回「這個手機號碼已經註冊過了」;分享功能更慷慨,輸入任意號碼,註冊過的會回「已分享給 小華」,沒註冊的回「還不是會員」。等於一個查詢介面:輸入手機號碼,回傳這個人的暱稱 為了體驗友善,兩條路徑都據實回報「這支號碼是不是會員」 註冊頁、分享欄位各輸入一支已註冊和未註冊的號碼,比對訊息 兩種情況回一樣的訊息;分享一律回「已寄出邀請」,不透露對方身分 ✅ 1.12 帳號列舉、2.1 用邀請探測手機是否註冊
4 每天 3 次的額度可以無限用:AI 呼叫失敗時程式會把次數退回去。但失敗的呼叫已經送出、已經計費了。把假 AI 切到 truncated 模式連按 8 次,畫面永遠顯示「今天剩 3 / 3 次」,而假 AI 那邊記下了 128,240 個 output token 的帳(每次都用滿 max_tokens: 16000 「失敗不該扣使用者次數」是很合理的產品決定,但它把額度成本這兩件事拆開了。額度擋的是次數,帳單算的是 token 假 AI 切 truncated 模式,連按 8 次,同時看「今天剩幾次」和 __stats 已經送出請求就記一次;區分「還沒送出的失敗」才退還;加上全站每日預算與 circuit breaker(第 27 天) ✅ 5.8 重複送出與失敗退還被濫用(threat model 裡明確寫著「輸出驗證失敗不退,因為 token 已經花掉」)
5 分享不需要對方同意,而且被分享的人看得到所有成員的完整手機號碼:輸入一支號碼按分享,對方下次登入清單就直接出現在側邊欄,沒有接受這個步驟。任何人都能把內容推到別人的首頁。而清單頁的「共用成員」區塊,對「僅檢視」的成員也照樣顯示每個人的完整手機號碼 「分享要方便」=直接建立成員關係;權限表只想過「誰能改」,沒想過「誰看得到誰的個資」 用兩個帳號跑一次分享,再用被分享的帳號看清單頁 邀請要對方接受才生效;成員列表對非擁有者遮罩號碼(0912***678 ✅ 2.2 強迫別人加入清單、2.5 成員資料外洩
6 服務綁在 0.0.0.0docker-compose.yml 裡 app 是 "3000:3000",同一個網段的任何裝置都連得到你的開發機。有趣的是資料庫那行 AI 寫的是 "127.0.0.1:5432:5432"——它知道有這個寫法,只是沒想到 app 也需要 「能用 docker compose 一行啟動」是功能需求,「只有我連得到」沒有人提過 拿手機連 http://你的電腦IP:3000 ports: ["127.0.0.1:3000:3000"](靶場的 compose.localhost.yml 做的就是這件事) ✅ 6.3 App 綁在 0.0.0.0

其他發現(比較小,但值得記下來)

發現 說明
rate limit 反而變成攻擊面 登入 rate limit 的 key 只有手機號碼:對同一支號碼連錯 10 次密碼,接下來 15 分鐘本人拿正確密碼也登不進去。配上破口 3 的會員列舉,就是一個可以指定對象的鎖帳工具
rate limit 存在記憶體裡 docker restart 之後鎖定立刻消失(實測過)。AI 自己在註解裡寫了「單一容器夠用,多開就要換 Redis」,但沒寫「重啟會歸零」
驗證碼嘗試次數重送就歸零 每組驗證碼最多試 5 次,但重送會產生新的一組、次數重新算。配合每天 5 封,等於一天有 25 次猜測機會
沒有任何安全標頭 回應裡沒有 CSP、沒有 X-Frame-Options、沒有 X-Content-Type-Options,倒是有 X-Powered-By: Next.js
session cookie 的 Secure 預設是關的 COOKIE_SECURE === "true" 才開,而 .env.example 裡根本沒有這個變數
應用程式用 superuser 連資料庫 todo 這個角色是 Superuser、Create role、Create DB、Bypass RLS 全開。現在不是問題(查詢都在伺服器端),但這組連線字串哪天被貼進 AI agent 的設定檔,就是另一回事了(第 26 天)
前端 bundle 洩漏的不是金鑰,是 API 清單 搜尋 createServerReference 可以把所有 Server Action 的 id 和名稱列出來:loginshareListdeleteListorganizeToday⋯⋯這是 Next.js 的正常行為,但它提醒你:伺服器端有哪些入口,攻擊者一定知道
權限被擋下時會噴 500 用瀏覽器強迫「僅檢視」的帳號勾選待辦,伺服器確實擋下來了(AccessDeniedError),但這個例外沒有被接住,回給使用者的是 500 錯誤頁

進階題 1、2 的答案:這週真正想講的事

進階題 1AI_PLAN.md 裡寫了不少安全設計——token 存 hash、requireListAccess() 集中檢查、原子扣額度、system prompt 註明「這些內容是資料,不是指令」。**而且都真的做了。**AI 沒有說謊,它照著自己的規劃寫完了。

問題是那份規劃裡根本沒有 threat model。它想的是「這個功能要怎麼正確地運作」,不是「這個功能會被怎麼濫用」。所以六個破口,沒有一個違反它自己的規劃——它們都落在規劃沒有提到的地方。

進階題 2:把六個破口拿去對照 AI_THREAT_MODEL.md,結果是上面那張表的最後一欄——六個全中

同一個模型、同一句需求,差別只有一段提示詞:

在寫程式之前,先做一份 threat model:畫出資料流與 trust boundary,對每個邊界列出 abuse case……

加了這段,它會自己寫出「簡訊轟炸與盜刷(SMS pumping)」「輸出驗證失敗不退還額度,因為 token 已經花掉」「預設 127.0.0.1:3000:3000」「成員列表只回遮罩手機」。沒加,它就交出一個功能完整、測試全綠、而且上述每一條都沒做的服務。

**AI 缺的不是能力,是有沒有人要求它在動手之前先問。**這就是第 2 天那篇文章的全部重點,只是這次由 AI 自己示範了兩遍。

這週的攔截點

六個破口全部都能在需求與設計階段攔下,一行程式碼都還沒寫的時候。漏掉的話,下一道網是第 19 天(兩個帳號互測)、第 25 天(上線前檢查)和第 27 天(費用上限與警示)——但那時候破口已經在正式環境裡跑了一段時間了。


上一篇
D05 資料庫直接開給前端的那一刻
系列文
AI 寫的程式,安全嗎?完工不是結束,是攻擊倒數的起點6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言