今天我們來學SQL注入攻擊,這是Web最經典的攻擊方式之一。
SQL Injection(SQLi)是指攻擊者在使用者輸入處(例如表單、查詢字串、HTTP 標頭等)插入惡意的 SQL 片段,使得後端資料庫執行非預期的 SQL 語句。若程式把使用者輸入直接拼接到 SQL 字串中(而非使用參數化查詢),就可能被當成 SQL 語法執行,造成資料外洩、繞過驗證、資料刪改甚至取得系統權限。
最經典的例子:登入繞過
假設網站登入程式碼為
SELECT * FROM users
WHERE username = '使用者輸入的帳號'
AND password = '使用者輸入的密碼';
若使用者輸入 username = izumi、password = 2006,實際執行:
SELECT * FROM users
WHERE username = 'izumi'
AND password = '2006';
若攻擊者把帳號輸入為(密碼隨便填):
' OR 1=1--
則 SQL 會變成:
SELECT * FROM users
WHERE username = '' OR 1=1--'
AND password = '任意';
很神奇吧!這樣就能繞過登入系統,可以直接登入了,那麼我們來解釋一下為什麼這樣能直接登入。同上,我們先假設網站程式碼長
SELECT * FROM users
WHERE username = '使用者輸入的帳號'
AND password = '使用者輸入的密碼';
使用者輸入自己的帳號與密碼,伺服器就會尋找符合的資料,如果找到符合條件的資料就能登入成功。而惡意輸入會變成在使用者輸入' OR 1=1--
,密碼則可以任意輸入,SQL會變成
SELECT * FROM users
WHERE username = '' OR 1=1--'
AND password = '任意';
username = '',這條件不成立,因為資料表沒有空帳號,OR 1=1當中OR 的意思則是「或」而1=1 永遠為真,所以整個條件變成:username='' OR TRUE 整個條件永遠為真,--是SQL的註解符號,後面的AND password=就會被忽略導致密碼完全失效。
而除了示範的這種,SQLi還有好幾種類型,我們簡單來介紹一下:
1.錯誤型(Error-based SQLi)
利用資料庫回傳的錯誤訊息來推斷資料庫結構與內容,例如故意注入會造成語法錯誤或型別錯誤的 payload,從錯誤訊息中取得資訊。
2.盲注(Blind SQLi)
當應用不回傳錯誤或資料內容,但會根據查詢結果回傳不同的頁面/布林值(True/False)或行為(有/無),攻擊者透過一系列判定(例如逐位元測試)來推斷資料。
又可細分為:布林盲注(Boolean-based)與時間盲注(Time-based)。
3.聯合查詢(Union-based SQLi)
透過 UNION SELECT把攻擊者想要的資料合併到原本的查詢結果中,若應用將查詢結果直接顯示在頁面上,就能直接讀出資料。
4.時間盲注(Time-based SQLi)
透過觸發資料庫的延遲(SLEEP()、pg_sleep()等)來判斷某個條件是否為真,適用於無錯誤訊息、且頁面只回傳同一結果的情況。
5.堆疊查詢(Stacked queries/Multiple statements)
某些 DB(如MS SQL)允許同一請求中執行多條 SQL(例如 ; DROP TABLE users;),攻擊者可利用此特性直接執行破壞性的語句(現代應用常禁用或不支援多語句執行)。
6.基於檔案/存儲程序的攻擊(視DB與環境)
利用資料庫的檔案寫入/外部函數呼叫或存儲程序漏洞進一步攻擊系統(例如寫入 webshell)。
防禦方法:
參數化查詢/Prepared Statements/Parameterized Queries:最常見的的防禦方式,把 SQL 與資料分離,資料會被視為純值,而不會被解讀為 SQL 語法片段
最小權限原則:資料庫使用的帳號應只有必要權限(例如只允許SELECT而非DROP)
輸入驗證(Input Validation):白名單檢查(例如長度、格式檢查),但單靠驗證不足以防 SQLi(仍應參數化)
錯誤訊息處理:不要向使用者回傳詳細的 DB錯誤(會洩露結構),應記錄在伺服端但向使用者顯示泛化錯誤訊息。
今天的實作目的是要讓大家能親自觀察「登入繞過」與「UNION-based資料洩漏」的SQLi運作過程,然後學習如何修復。
1.準備測試環境
先將SQLi.py存下來
# SQLi.py
from flask import Flask, request, g, render_template_string
import sqlite3
import os
DB = 'sqli_demo.db'
app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev-key-only-for-lab'
# --- DB helper ---
def get_db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect(DB)
db.row_factory = sqlite3.Row
return db
@app.teardown_appcontext
def close_db(exc):
db = getattr(g, '_database', None)
if db is not None:
db.close()
# --- setup (init) ---
def init_db():
if os.path.exists(DB):
os.remove(DB)
conn = sqlite3.connect(DB)
c = conn.cursor()
# users table
c.execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, password TEXT);')
c.execute("INSERT INTO users (username, password) VALUES ('izumi', 'izumipw');")
c.execute("INSERT INTO users (username, password) VALUES ('saber', 'saberpw');")
# products table
c.execute('CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);')
c.execute("INSERT INTO products (name) VALUES ('Oriflamme');")
c.execute("INSERT INTO products (name) VALUES ('Excalibur');")
conn.commit()
conn.close()
# --- templates (tiny) ---
form_tpl = """
<h2>{{title}}</h2>
<form method="post" action="{{action}}">
username: <input name="username"><br>
password: <input name="password"><br>
<button type="submit">Login</button>
</form>
<pre>{{result}}</pre>
<hr>
<p>Note: Only for local testing.</p>
"""
search_tpl = """
<h2>Product Search (vulnerable)</h2>
<form method="get" action="/search">
Search: <input name="q" value="{{q}}"><button type="submit">Search</button>
</form>
<pre>{{result}}</pre>
"""
# --- vulnerable login (unsafe string concatenation) ---
@app.route('/vuln_login', methods=['GET', 'POST'])
def vuln_login():
result = ''
if request.method == 'POST':
u = request.form.get('username','')
p = request.form.get('password','')
db = get_db()
# DANGEROUS: string formatting directly into SQL
sql = "SELECT * FROM users WHERE username = '%s' AND password = '%s';" % (u, p)
cur = db.execute(sql)
rows = cur.fetchall()
result = f"Executed SQL:\n{sql}\n\nRows returned: {len(rows)}\n"
for r in rows:
result += dict(r).__repr__() + "\n"
return render_template_string(form_tpl, title='VULNERABLE LOGIN', action='/vuln_login', result=result)
# --- safe login (parameterized query) ---
@app.route('/safe_login', methods=['GET', 'POST'])
def safe_login():
result = ''
if request.method == 'POST':
u = request.form.get('username','')
p = request.form.get('password','')
db = get_db()
# SAFE: parameterized query (the ? placeholders)
sql = "SELECT * FROM users WHERE username = ? AND password = ?;"
cur = db.execute(sql, (u, p))
rows = cur.fetchall()
result = f"Executed SQL (parameterized):\n{sql}\n\nRows returned: {len(rows)}\n"
for r in rows:
result += dict(r).__repr__() + "\n"
return render_template_string(form_tpl, title='SAFE LOGIN', action='/safe_login', result=result)
# --- vulnerable search demonstrating UNION-based exfil ---
@app.route('/search', methods=['GET'])
def search():
q = request.args.get('q','')
db = get_db()
# Vulnerable: naive LIKE with direct interpolation
sql = f"SELECT name FROM products WHERE name LIKE '%{q}%';"
cur = db.execute(sql)
rows = cur.fetchall()
result = f"Executed SQL:\n{sql}\n\nRows:\n"
for r in rows:
result += str(dict(r)) + "\n"
return render_template_string(search_tpl, q=q, result=result)
# --- index ---
@app.route('/')
def index():
return "<h3>SQLi demo (local only)</h3><ul>" + \
"<li><a href='/vuln_login'>VULNERABLE Login</a></li>" + \
"<li><a href='/safe_login'>SAFE Login</a></li>" + \
"<li><a href='/search'>Vulnerable Search (UNION demo)</a></li>" + \
"</ul>"
if __name__ == '__main__':
init_db()
app.run(port=5000, debug=False)
2.執行並在瀏覽器輸入http://127.0.0.1:5000
應該會看到跟我一樣的畫面
3.測試脆弱版登入系統
3-1.
點擊第一個選項VULNERABLE LOGIN
會進到這個頁面
3-2.輸入「' OR 1=1--」,密碼隨便都行
3-3.觀察結果,我這邊輸入的是「' OR 1=1--」跟0430,附圖如下
可以看到我們成功登入系統,且回傳給我們所有使用者與他們的密碼,這表示我們攻擊成功(這邊是因為我們程式設定攻擊成功後顯示所有使用者,並非所有網站攻擊成功後都是這種輸出方式)
4.測試安全版
4-1.
點擊第二個選項
進入這個頁面
4-2.輸入與剛才一模一樣的payload
4-3.觀察結果,發現不再顯示所有使用者資訊
這是因為執行的是參數化查詢(SELECT ... WHERE username = ? AND password = ?),此時資料庫把 '? OR 1=1--' 視為純字串,比對失敗導致無法繞過
5.UNION-based資料洩漏(/search範例)
5-1.
點擊第三個選項
進入這個頁面
5-2.payload我們輸入' UNION SELECT username || ':' || password FROM users--
5-3.觀察結果,發現連使用者都跑出來了!如圖
解說:透過 UNION SELECT,攻擊者把users表的資料「合併」到原本的查詢結果中,若應用直接把查詢結果顯示給使用者,就能看到敏感資料
由此我們可以發現,SQL注入很容易可以竊取資料,因此防禦很重要,通常一定會加上如第四步驟演示的參數化查詢,這大大減少了被SQL注入的機會
今天我們學了基本的SQLi類以及實作,也了解到為什麼要防禦SQLi的重要性,明天我們將會介紹TLS