上一篇我們加上了身分驗證,至少不是任何人都能動我們的資料了。
但安全不是只有「誰可以進來」而已。
今天要看的是一個很經典、而且到現在都還一直在發生的問題:
SQL Injection(SQL 注入)。
先說一件很重要的事:
今天的內容是在自己的電腦、自己建立的練習資料庫上示範,目的是理解原理,才知道要怎麼防。
拿這些方法去測試別人的網站是違法的,不要這樣做。
回想 Day 18,我們是這樣跟資料庫溝通的:
SELECT * FROM tasks WHERE id = 1;
SQL 是一段「文字」,資料庫收到之後會去解析它,判斷哪些是指令、哪些是資料。
而我們寫程式的時候,常常會需要把使用者輸入的東西放進 SQL 裡。
如果是用字串拼接的方式:
sql = "SELECT * FROM task WHERE name = '" + name + "'"
或是用 f-string:
sql = f"SELECT * FROM task WHERE name = '{name}'"
問題就來了。
因為使用者輸入的內容,會直接變成 SQL 的一部分。
本來我們以為使用者會輸入「資料」,但他其實可以輸入「語法」。
新增一個練習用的檔案:
injection_demo.py
import sqlite3
conn = sqlite3.connect(':memory:') # 只存在記憶體,執行完就沒了
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE user (
id INTEGER PRIMARY KEY,
username TEXT,
password TEXT
)
""")
cursor.execute("INSERT INTO user (username, password) VALUES ('jolin', 'my-password')")
cursor.execute("INSERT INTO user (username, password) VALUES ('admin', 'super-secret')")
conn.commit()
def login(username, password):
# 危險示範:把輸入直接拼進 SQL(實際開發絕對不要這樣寫)
sql = f"SELECT * FROM user WHERE username = '{username}' AND password = '{password}'"
print("實際執行的 SQL:")
print(sql)
result = cursor.execute(sql).fetchall()
if result:
print("登入成功:", result)
else:
print("登入失敗")
print("-" * 50)
print("【情況 1】正常登入")
login("jolin", "my-password")
print("【情況 2】密碼打錯")
login("jolin", "wrong-password")
print("【情況 3】在密碼欄位動手腳")
login("jolin", "' OR '1'='1")
conn.close()
這裡故意把 SQL 印出來,因為重點就是看「拼出來的 SQL 長什麼樣子」。
執行:
python injection_demo.py
執行 injection_demo.py 的完整輸出

情況 1,正常登入,SQL 是:
SELECT * FROM user WHERE username = 'jolin' AND password = 'my-password'
登入成功: [(1, 'jolin', 'my-password')]
沒問題。
情況 2,密碼打錯:
SELECT * FROM user WHERE username = 'jolin' AND password = 'wrong-password'
登入失敗
也正常。
情況 3 就是重點了。
我們在密碼欄位輸入:
' OR '1'='1
拼出來的 SQL 會變成:
SELECT * FROM user WHERE username = 'jolin' AND password = '' OR '1'='1'
結果是:
登入成功: [(1, 'jolin', 'my-password'), (2, 'admin', 'super-secret')]
明明密碼是錯的,卻登入成功了。
而且仔細看回傳的內容會發現更可怕的事:
它不只讓我們登入成功,還一次把整張 user 表的資料都撈出來了,
包含 admin 的密碼 super-secret。
我們只是在密碼欄位多打了幾個字而已。
把那句 SQL 拆開來看:
原本我們想要的是:
username = 'jolin' 而且 password = '(使用者輸入的密碼)'
但使用者輸入的第一個字元是單引號 ',
這個單引號把「密碼字串」提早結束掉了。
後面的 OR '1'='1 就不再是資料,而是變成 SQL 的語法。
整句的意思變成:
(username = 'jolin' 而且 password = '')或者('1' = '1')
而 '1'='1' 永遠成立。
所以不管前面怎麼樣,整個條件都會是真,資料庫就把「所有」資料回傳了。
這就是 SQL Injection 的核心:
使用者輸入的「資料」被當成「指令」執行了。
這個例子只是登入繞過,實際上可以做的事情更多:
看到不該看的資料
剛剛就示範了,整張使用者表被撈出來。如果裡面是真實帳號,那就是整批外洩。
用 UNION 把其他表的資料接進來
例如原本只是查商品,卻被接上使用者表,把帳號密碼一起查出來。
修改或刪除資料
如果程式執行的是 UPDATE 或 DELETE,就可能被改成刪掉整張表。
有一張很有名的漫畫(xkcd「Little Bobby Tables」),
講一個小孩的名字叫做:
Robert'); DROP TABLE students;--
學校把名字直接拼進 SQL,結果整張學生表就被刪掉了。
用「猜」的把資料問出來
就算畫面上看不到資料,也可以透過「回應有沒有變化」、「反應快不快」來一個字元一個字元試出內容。
這叫做 Blind SQL Injection。
好消息是:目前沒有。
因為我們用的是 ORM:
db.session.scalars(db.select(Task).filter_by(name=name)).all()
SQLAlchemy 會自動把 name 當成「參數」處理,不會拼進 SQL 語法裡。
但是要注意,用了 ORM 不代表一定安全。
如果哪天寫出這種程式:
from sqlalchemy import text
# 危險寫法
sql = text(f"SELECT * FROM task WHERE name = '{name}'")
db.session.execute(sql)
一樣會有 SQL Injection 的風險。明天會實際跑給大家看。
所以真正的重點不是「有沒有用 ORM」,而是:
有沒有把使用者的輸入直接拼進 SQL 字串裡。
這是很多人第一個想到的做法,但並不可靠。
原因有幾個:
情況比想像中多
數字欄位不需要單引號也能注入,例如 /tasks?id=1 OR 1=1。
繞過的方法很多
不同資料庫的跳脫方式、編碼方式都不一樣,
可以用註解('--'、'/**/')、用不同編碼、用大小寫變形來閃過檢查。
自己寫的規則很難周全
今天想到單引號,明天想到分號,永遠會漏掉某一種。
所以正確的方向不是「把壞字元挑掉」,
而是從一開始就讓資料庫知道「這是資料,不是語法」。
這就是下一篇要講的參數化查詢。
Day 24 有提過,錯誤訊息不要直接丟給使用者。
在 SQL Injection 的情境下,這件事特別重要。
因為如果程式把資料庫的錯誤原文回傳出去,例如:
no such column: xxx
near "'": syntax error
攻擊者就可以從這些訊息推測出:
用的是哪一種資料庫
表格和欄位叫什麼名字
SQL 是怎麼組出來的
等於一步一步被引導著找到攻擊方式。
所以 Day 24 那句話再說一次:
詳細的錯誤記在伺服器裡自己看,回給使用者的只要一句「發生錯誤」就好。
SQL Injection 的原理:使用者的輸入被當成 SQL 語法執行
用 ' OR '1'='1 實際做了一次登入繞過,而且整張表都被撈出來
危害包含資料外洩、資料被改、被刪
ORM 預設是安全的,但只要用字串拼 SQL,一樣會中
過濾特殊字元不是正確的解法
錯誤訊息也可能變成線索
原理知道了,接下來就是怎麼防。
下一篇就來看參數化查詢、ORM,還有輸入驗證要怎麼搭配使用。