這週的破口,沒有一個需要高深的技術才能發現,只需要在動手之前問對問題。
這週我們停在「寫第一句提示詞之前」:
今天換你動手。
這個靶場是我用下面這段對話,讓 AI 一次生成的。我沒有手動埋任何漏洞,AI 產出什麼就是什麼。
第一句(用規劃模式,只產規劃、不寫程式):
幫我做一個會員制的待辦清單網站。使用者可以註冊登入、新增待辦、把清單分享給朋友。首頁要有一個「AI 幫我整理今天待辦」的按鈕,每個會員每天可以用 3 次。註冊要用手機簡訊驗證,簡訊先用假的就好。
用 Next.js 做,要能用 Docker 在我自己的電腦上跑起來。
AI 交出規劃之後,第二句是:
照計畫做
就這樣。中間沒有任何補充說明,AI 規劃最後問我的四個問題我也沒有回答,直接核准。
claude -p --safe-mode)、claude-opus-5--safe-mode 的用意:不載入我自己的 CLAUDE.md、skills、hooks、MCP,避免我的個人設定影響產出week1-original,week1-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,只有你自己的電腦連得到需要 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 次)。
這週的題目順序和後面三週的實戰題不一樣:**先設計審查,再動手驗證。**你要先在紙上推測破口在哪裡,再打開瀏覽器確認。
先不要看程式碼,也先不要點任何按鈕。用第 1 天的架構提示詞請 AI 說明這個專案,然後畫出:
畫完之後,在圖上多標兩件事:
只用瀏覽器(加上一兩行 curl),驗證你打星號的格子。註冊兩個帳號,例如 0911111111(小明)和 0933333333(小華):
curl http://127.0.0.1:8787/__stats 會告訴你)(第 3 天)truncated 模式,讓每次呼叫都失敗,然後連按 8 次。看「今天剩幾次」,再看 __stats 裡的 billed_output_tokens(第 2 天 abuse case).next/static 裡的 JS,搜尋 sk-。找不到的話,改搜尋 createServerReference(第 3 天)http://你的電腦IP:3000。連得上嗎?改成連 :5432 呢?(第 5 天)每找到一個,記下:**你在第二關有沒有預測到它?**預測到的比例,就是你這週 threat modeling 的命中率。
AI_PLAN.md,比對 AI 當時交出的規劃和你的 threat model。它提到了哪些安全需求?規劃裡提到的,程式碼真的做了嗎?(這題有一個很有趣的答案)AI_THREAT_MODEL.md,這是同一句需求、同一個模型,只是在動手前多要求了一份 threat model。把你找到的每一個破口,去這份文件裡找有沒有對應的項目。找完之後你會明白這週想講的是什麼docker compose exec db psql -U todo -d todo -c "\du",看應用程式用的是哪個角色。**如果哪天有人把這組連線字串貼給 AI agent,它能做到什麼?git log -p 看看 AI 生成過程中,有沒有哪個 commit 曾經出現過機密想不出來再往下看。
六個破口裡,三個跟簡訊有關,一個跟錢有關,一個跟「誰看得到誰」有關,還有一個根本不在網頁裡。
在你第一關畫的圖上,找那兩條「會花到錢」的箭頭:伺服器在什麼條件下會走這兩條線?誰能讓它走?走幾次?
要看的地方只有四個:
app/docker-compose.yml 的 ports 那幾行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。
歡迎在留言分享你找到幾個、預測命中率多少。找到超過六個的人,請告訴我,我會補進解答並註明是你發現的。
寫靶場規劃時,我依照第 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.0:docker-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 和名稱列出來:login、shareList、deleteList、organizeToday⋯⋯這是 Next.js 的正常行為,但它提醒你:伺服器端有哪些入口,攻擊者一定知道 |
| 權限被擋下時會噴 500 | 用瀏覽器強迫「僅檢視」的帳號勾選待辦,伺服器確實擋下來了(AccessDeniedError),但這個例外沒有被接住,回給使用者的是 500 錯誤頁 |
進階題 1:AI_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 天(費用上限與警示)——但那時候破口已經在正式環境裡跑了一段時間了。