失效的假设 “只要输入用了参数化,就没有 SQL 注入了”
所属组 W1 · 注入
前置 第 1 篇:注入的一般形式。本篇直接用那里的三个角色(解析器 / 拼接 / 你设计的结构)和三层防御
复现环境 Python 3 标准库,无外部依赖

一、假设你已经全用了参数化

你听了上一篇的话,把项目里每一条 SQL 都换成了参数化,没有一处字符串拼接。

然后还是被注入了。

这一篇给你三个场景,每一个里参数化都用对了,攻击照样成立。

先别急着不服气。我们花一分钟,把"参数化到底做了什么"确认清楚——因为那三个漏洞,恰恰都藏在这句话没说到的地方。

参数化把那个恶意值放哪儿去了

上一篇用"表格"打了个比方:数据填进格子,格子的位置是印死的。现在看看格子里真塞进一段"恶意 SQL"会怎样:

import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT, role TEXT)")

evil = "x'); DROP TABLE users;--"
db.execute("INSERT INTO users(name,role) VALUES (?, 'user')", (evil,))

stored = db.execute("SELECT name FROM users ORDER BY id DESC LIMIT 1").fetchone()[0]
tables = [r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
print(f"存进去的值  : {stored!r}")
print(f"users 表还在: {'users' in tables}")
存进去的值  : "x'); DROP TABLE users;--"
users 表还在: True

存进去的值,一个字符没变——x'); DROP TABLE users;-- 原原本本躺在 name 字段里。users 表也还在,那句 DROP TABLE 从来没有作为 SQL 执行过。

这就是参数化的全部效果:那段文字被当成一个值,而不是一段代码。 它去了数据的世界,没进语法的世界。

打个比方

参数化像给危险品贴标签装箱:这一趟运输里,它被当成货物,不会被当成指令。

但标签是贴在这一趟运输上的,不是贴在这件东西上的。它下了这趟车,进了仓库,明天换另一辆车拉走——那辆车上没有人知道它是什么。

⭐ 所以请注意这句话的边界:参数化保证的是"在这一次 execute 里,这个值不被当代码"。它没有、也无法保证这个值以后不会被别人拼进另一句 SQL。

下面两节,就是"以后"。

二、反例一:恶意值是从你自己的库里来的

上一节那个值安安静静躺在数据库里。现在有另一段代码,把它取出来用。

场景是每个系统都有的"改密码":

import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT, pw TEXT)")
db.execute("INSERT INTO users(name,pw) VALUES ('admin','s3cret-admin-pw')")

# 第一步:攻击者注册。用户名用参数化存入 —— 无法在这一步注入。
evil_name = "admin'--"
db.execute("INSERT INTO users(name,pw) VALUES (?, ?)", (evil_name, "whatever"))

before = db.execute("SELECT pw FROM users WHERE name='admin'").fetchone()[0]

# 第二步:攻击者用「改我的密码」功能。后台从会话拿到他的用户名(即库里那个),
# 拼进 UPDATE —— 这里犯了错。
name = db.execute("SELECT name FROM users WHERE name=?", (evil_name,)).fetchone()[0]
sql = f"UPDATE users SET pw='pwned' WHERE name='{name}'"
print(f"后台拼出 : {sql}")
db.execute(sql)

after = db.execute("SELECT pw FROM users WHERE name='admin'").fetchone()[0]
print(f"admin 密码: {before!r} -> {after!r}")
后台拼出 : UPDATE users SET pw='pwned' WHERE name='admin'--'
admin 密码: 's3cret-admin-pw' -> 'pwned'

看最后一行。攻击者改的是他自己的密码,admin 的密码却被改成了 pwned

发生了什么?后台把这句 SQL 拼成了:

UPDATE users SET pw='pwned' WHERE name='admin'--'
                                        |----|  |
                                     真正的条件  后面被注释掉

那个 --WHERE 之后本该限定"只改我自己"的部分注释掉了,于是这条 UPDATE 命中了 admin

这叫二阶注入(second-order injection),它阴险在于每一步单独看都对:

第一步  注册,用户名走参数化           OK 无懈可击
第二步  从库里取出用户名,拼进 UPDATE   BUG 漏洞在这

写第二步的人心里大概是这么想的:“这个用户名是我从我自己的数据库里查出来的,又不是用户直接传进来的,能有什么问题?”

⚠️ 这就是那条失效的假设:以为"来自数据库的数据"是可信的。 可数据库里那个用户名,最初也是别人写进去的。它从用户那里出发,在库里睡了一觉,换了个身份回来——但它还是那段文字。

回到第 1 篇的三个角色:危险不是数据的属性。一个值危不危险,只取决于它此刻被拼进了哪个语法位置,跟它从哪儿来、在数据库里躺过没有,毫无关系

三、反例二:接口什么都不回显

再退一步。也许你会说:就算注入了,我的接口很克制,从不把查询结果显示给用户,也不返回数据库报错——攻击者看不到任何东西,能偷走什么?

看下面这个。它模拟一个"最克制"的接口:无论你问什么,只回一个 TrueFalse

import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE secrets(k TEXT, v TEXT)")
db.execute("INSERT INTO secrets VALUES ('api_key','SK-7F3A')")

def oracle(cond):                      # 一个「安全」接口:只回成/不成,不回显任何数据
    q = f"SELECT 1 FROM secrets WHERE k='api_key' AND ({cond})"
    return db.execute(q).fetchone() is not None

L = 1                                  # 先问出长度
while not oracle(f"length(v)={L}"):
    L += 1

out = ""                               # 再二分每一位
for i in range(1, L+1):
    lo, hi = 32, 126
    while lo < hi:
        mid = (lo+hi)//2
        if oracle(f"unicode(substr(v,{i},1))>{mid}"):
            lo = mid+1
        else:
            hi = mid
    out += chr(lo)
print(f"长度      : {L}")
print(f"逐位还原出: {out!r}")
长度      : 7
逐位还原出: 'SK-7F3A'

一个 7 位的密钥,被完整还原了出来——而接口从头到尾只回答过"是"或"否"。

原理是把每一位拆成一连串是非题,用二分法逼近:

"第 3 位的 ASCII 码 > 79 吗?"   -> 否
"第 3 位的 ASCII 码 > 47 吗?"   -> 是
"第 3 位的 ASCII 码 > 63 吗?"   -> 否
...每问一次,可能的范围减半,7 次锁定一个字符

⚠️ 这类攻击叫盲注(blind injection)。它推翻的假设是"不回显数据 = 安全"。真相是:只要攻击者能让查询的行为随他的注入而变化——哪怕只是成功与失败的区别——他就有了一个能逐位读取整个数据库的信道。 连时间都行:把条件换成"若成立则 sleep(5)",看响应快慢就能读出那一位,这叫时间盲注。

所以"我把错误信息藏起来"“我不显示查询结果"这类措施,能提高攻击成本,但拦不住攻击。它们是减速带,不是墙。

四、退一步:这三个场景在说同一件事

把前面三节并排看:

参数化存值   那段恶意串没被当代码执行            —— 参数化确实有效
二阶注入     它后来被另一段代码拼进了 SQL        —— 有效范围只到"这一次 execute"
盲注         就算不回显,一个布尔位也能搬空数据库 —— 隐藏不是防御

它们指向同一句话,这句话是这一整篇要你记住的:

参数化保护的是「拼接的那一个点」,不是「数据的入口」。

你以为你在"给这个输入做了防护”,其实你只是在"这一次查询里"把它关进了格子。同一个值流到下一个拼接点,防护不会跟着它走——防护是贴在拼接点上的,不是贴在数据上的

想通这一点,二阶注入就不再意外:数据的"来源"(用户 / 数据库 / 缓存 / 另一个服务)根本不影响它危不危险,唯一相关的是它接下来被拼进哪个语法位置。盲注也不再意外:注入成不成立,与你回不回显是两回事。

它出现过的地方

SQL 注入是最老、也最能"熬"的一类漏洞——它上过每一版 OWASP Top 10。几个有公开技术细节的,展开在事件分析里:

2008   Heartland Payments      SQL 注入起手,约 1.3 亿张银行卡
2011   Sony Pictures           单点 SQL 注入拖库,百万级账号
2023   MOVEit Transfer         CVE-2023-34362,一个 SQL 注入 0-day 波及数千家机构

⭐ 注意 2008 和 2023 之间隔了十五年。参数化这个解法一直都在,这类漏洞至今没绝迹,靠的不是没人知道怎么防,而是"总有一个拼接点被漏掉"。 这恰好呼应上一节:防护贴在点上,而点很多,漏一个就够。

五、防御

第 1 篇给了三层防御。这一篇不推翻它们,只把"该贴在哪儿"说清楚。

① 在每一个拼接点参数化,而不是在入口消毒

错误的心智模型              正确的心智模型
----------------------------------------------------
把用户输入'洗干净'再放行     不'洗'数据,
之后就当它是安全的           而是在【每一次】拼进 SQL 时都用占位符
                            —— 不管这个值这一刻从哪来

具体到二阶注入:修复点不在注册那一步(那步已经对了),而在"改密码"那条 UPDATE。把它改回参数化即可:

db.execute("UPDATE users SET pw=? WHERE name=?", (new_pw, name))

判断规则:看到一个值被拼进 SQL 文本,就地参数化。不要问"这个值可信吗"“它是不是已经在别处校验过了”——那些问题都在"数据"上找答案,而危险在"拼接点"上。

② 动态标识符用白名单映射

参数化管不到列名、表名、排序方向(下一节详述)。这些位置沿用第 1 篇的 ③:把用户输入映射到一个你写死的集合,而不是拼进去。

SORT = {"time": "created_at", "amount": "total"}   # 外部名 -> 真实列名
col = SORT.get(user_input)
if col is None:
    raise ValueError("bad sort key")               # 进去的永远是你自己写的字符串

③ 纵深,但别把纵深当主防御

下面这些都该做,但作用是"万一主防御漏了一个点,限制损失",不能替代 ①:

最小权限        应用连库的账号只给它用得到的表和操作,别用 db_owner
关错误信息      不回显数据库报错(能减速盲注,见 §三,但拦不住)
监控与告警      大批量 sleep、异常长的查询是盲注的指纹

⚠️ 特别提醒:§三 已经证明"关错误信息"挡不住盲注。它是减速带。把减速带当成墙,是这类系统最常见的误判。

六、参数化管不到的地方

不写这一节,你会以为"全参数化"是可达的终点。它不是——有些位置在定义上就不能参数化。

① 动态标识符:静默失效。 列名、表名、排序方向是 SQL 语法的一部分,必须在计划确定前固定,所以不能当参数绑定。危险的是它不报错

db.execute("SELECT * FROM t ORDER BY ? DESC", ("col",))
   -> 不报错,但按常量'col'排序,等于没排。测试会通过,排序悄悄失效。

只能用 §五 ② 的白名单。

LIKE 的通配符。 参数化会转义引号,但不会转义 %_

db.execute("... WHERE name LIKE ?", (user_input + "%",))
   用户传单个 % ,匹配所有行。这不是注入,是逻辑漏洞——但一样能拖垮查询或泄露范围。

要按字面匹配 %,得自己转义并配 ESCAPE 子句。

IN (...) 的变长列表。 多数驱动不能把一个列表绑成一个参数。正确做法是按列表长度动态生成占位符,而不是把列表拼进字符串:

ph = ",".join(["?"] * len(ids))        # ids=[3,7,9] -> "?,?,?"
db.execute(f"SELECT * FROM t WHERE id IN ({ph})", ids)

拼进 SQL 的是 ?,?,?(占位符个数由列表长度决定,与内容无关),值仍然全部走参数。

④ ORM 不等于安全。 ORM 默认参数化,但每个 ORM 都留了逃逸口,一旦用到就回到裸拼接:

.raw() / .extra() / 字符串形式的 filter 条件 / 手写 SQL 片段 / order_by(用户输入)

⑤ 存储过程内部。 在应用层参数化,不代表存储过程内部没有 EXEC('...' + @p) 这种动态拼接。注入可以发生在数据库里面。

关于时效:这些边界来自 SQL 语法本身,稳定,不随版本变。会变的是各驱动的默认设置(是否允许一次执行多条语句、LIKE 的默认 ESCAPE 行为),涉及具体框架时要带版本号去核实——本篇不给"某驱动默认安全"这类结论。

七、本篇小结

上一篇:别过滤,用参数化。
这一篇:参数化用对了,也只保护「拼接的那一个点」,不保护「数据入口」。

⭐ 一个值危不危险,只看它此刻被拼进哪个语法位置,
   与它从哪来(用户 / 数据库 / 缓存)无关
   => 二阶注入:来自数据库的值,拼进 SQL 一样出事
   => 修复点在"拼接处",不在"入口处"

⭐ 注入成不成立,和你回不回显是两回事
   => 盲注:只回 True/False,也能逐位搬空数据库
   => 藏错误、不回显是减速带,不是墙

参数化到不了的地方(动态列名 / LIKE / IN / ORM 逃逸口 / 存储过程),
用白名单和动态占位符补,别退回字符串拼接。

思考题

  1. 二阶注入的例子里,如果注册时对用户名"过滤单引号",能挡住第二步吗?除了第 1 篇 §五 第 2 条(过滤跑在解码之前),再从"拼接点在别处"的角度解释。
  2. §三 的盲注每个字符最多问 7 次(二分 32–126)。若密钥是 32 位十六进制,总共约需多少次请求?据此说明为什么"限流"能提高成本却不能根治。
  3. 把 §三 的布尔盲注改成时间盲注:oracle 里那条 SQL 该怎么写(提示:让"成立"这一支变慢)?为什么时间盲注比布尔盲注更难用监控发现?
  4. IN (...) 那段,如果 ids 来自用户且长度不限,除了注入还有什么风险?(提示:占位符个数与查询计划缓存。)
  5. 有人主张"所有查询都走存储过程就能防注入"。用 §六 ⑤ 反驳,并说明存储过程在什么写法下才真正安全。
  6. 团队想加一条 CI 规则来防"漏掉的拼接点"。你会让它检查什么模式?这条规则的漏报和误报会在哪里?
  7. §四 说"防护贴在拼接点上,不贴在数据上"。ORM 在多大程度上把"每个拼接点都参数化"变成了默认?它把风险集中到了哪几个方法上(对照 §六 ④)?

相关第 1 篇:注入的一般形式 · Web 安全机制篇 · 事件分析