3. SQL 注入防御

SQL 注入防御 学习卡片

漏洞代码:字符串拼接的登录查询

下面这段 Python + SQLite 登录逻辑是典型的注入入口。它把用户输入直接拼进 SQL 文本,数据库无法区分“数据”和“代码”,攻击者只要让拼接结果改变 SQL 语义,就能绕过密码校验。

import sqlite3

def login_unsafe(username, password):
    conn = sqlite3.connect("app.db")
    cur = conn.cursor()
    sql = "SELECT id, role FROM users WHERE username = '" + username + \
          "' AND password = '" + password + "'"
    cur.execute(sql)          # 危险:输入被当作 SQL 语法解析
    row = cur.fetchone()
    conn.close()
    return row                # 非 None 即登录成功

关键问题在 cur.execute(sql):传入的是已经拼好的完整 SQL 字符串,参数值参与了语法解析。只要 username 或 password 中含单引号、OR、注释符等,SQL 结构就会被改写。

注入原理:数据如何变成代码

以输入 username = admin' --、password = 任意值 为例,拼接后的 SQL 变为:

SELECT id, role FROM users WHERE username = 'admin' --' AND password = '任意值'

-- 是 SQL 行注释,其后的密码条件被整段注释掉,查询退化为“只要 username 为 admin 就返回记录”。若 username 输入 ' OR '1'='1,则条件恒真,可能返回任意一行用户记录。两种 payload 的共同点是:它们都改变了 WHERE 子句的逻辑结构,而不是作为普通字符串值参与比较。

字符串拼接(不安全) 用户输入 admin' -- SQL 模板 ... username = ' ? ' 拼接 SELECT id, role FROM users WHERE username = 'admin' --' AND password = '任意值' 密码条件被注释掉 WHERE 逻辑被改写 → 绕过登录 结果:返回 admin 记录 攻击者无需正确密码 参数化查询(安全) 用户输入 admin' -- SQL 模板 ... username = ? 绑定 SELECT id, role FROM users WHERE username = ? AND password = ? 输入作为纯数据绑定 单引号、-- 不参与语法解析 结果:无匹配记录 注入 payload 失效

改造:用占位符绑定参数

SQLite 的 ? 是参数占位符。数据库先编译带占位符的语句结构,再把参数值作为数据填入,因此参数内容永远不会被当作 SQL 语法。改造后的登录函数如下:

import sqlite3

def login_safe(username, password):
    conn = sqlite3.connect("app.db")
    cur = conn.cursor()
    sql = "SELECT id, role FROM users WHERE username = ? AND password = ?"
    cur.execute(sql, (username, password))   # 参数以元组传入,不参与拼接
    row = cur.fetchone()
    conn.close()
    return row

注意两点:一是 SQL 字符串中不再出现任何用户输入;二是参数必须通过 execute 的第二个参数传入,而不是用 %、format 或 + 拼进 SQL 文本。若使用命名占位符,可写成 WHERE username = :u AND password = :p,并传入 {"u": username, "p": password}。

构造 payload 验证攻击失效

准备一个测试库,插入一条已知用户记录,然后用同一组注入 payload 分别调用 login_unsafe 和 login_safe,对比返回结果。

def setup():
    conn = sqlite3.connect("app.db")
    conn.execute("DROP TABLE IF EXISTS users")
    conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT, role TEXT)")
    conn.execute("INSERT INTO users (username, password, role) VALUES (?, ?, ?)",
                 ("admin", "S3cret!", "admin"))
    conn.commit()
    conn.close()

payloads = [
    ("admin' --", "whatever"),
    ("' OR '1'='1", "' OR '1'='1"),
    ("admin' OR 1=1 --", "x"),
]

setup()
for u, p in payloads:
    unsafe = login_unsafe(u, p)
    safe = login_safe(u, p)
    print(f"payload={u!r:22} unsafe={unsafe}  safe={safe}")

预期输出(示例):

payload="admin' --"            unsafe=(1, 'admin')  safe=None
payload="' OR '1'='1"          unsafe=(1, 'admin')  safe=None
payload="admin' OR 1=1 --"     unsafe=(1, 'admin')  safe=None

判读标准:unsafe 返回了记录,说明拼接版本被绕过;safe 返回 None,说明参数化版本把 payload 当作普通字符串比较,未匹配到任何用户,注入失效。若 safe 也返回了记录,应检查是否误用了字符串格式化、是否把参数拼进了 SQL 文本,或是否在别处仍存在拼接查询。

参数化查询不仅适用于登录,也适用于所有带用户输入的 SELECT、INSERT、UPDATE、DELETE。表名、列名、排序方向等无法用占位符绑定的部分,应使用白名单映射,而不是直接拼接。
不要用“转义单引号”替代参数化。手工转义容易遗漏反斜杠、宽字符、注释符等边界情况,且不同数据库转义规则不一致。参数化是数据库层面区分代码与数据的根本手段。