| 失效的假设 | “只要输入用了参数化,就没有 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 篇的三个角色:危险不是数据的属性。一个值危不危险,只取决于它此刻被拼进了哪个语法位置,跟它从哪儿来、在数据库里躺过没有,毫无关系。
三、反例二:接口什么都不回显
再退一步。也许你会说:就算注入了,我的接口很克制,从不把查询结果显示给用户,也不返回数据库报错——攻击者看不到任何东西,能偷走什么?
看下面这个。它模拟一个"最克制"的接口:无论你问什么,只回一个 True 或 False。
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 篇 §五 第 2 条(过滤跑在解码之前),再从"拼接点在别处"的角度解释。
- §三 的盲注每个字符最多问 7 次(二分 32–126)。若密钥是 32 位十六进制,总共约需多少次请求?据此说明为什么"限流"能提高成本却不能根治。
- 把 §三 的布尔盲注改成时间盲注:
oracle里那条 SQL 该怎么写(提示:让"成立"这一支变慢)?为什么时间盲注比布尔盲注更难用监控发现? IN (...)那段,如果ids来自用户且长度不限,除了注入还有什么风险?(提示:占位符个数与查询计划缓存。)- 有人主张"所有查询都走存储过程就能防注入"。用 §六 ⑤ 反驳,并说明存储过程在什么写法下才真正安全。
- 团队想加一条 CI 规则来防"漏掉的拼接点"。你会让它检查什么模式?这条规则的漏报和误报会在哪里?
- §四 说"防护贴在拼接点上,不贴在数据上"。ORM 在多大程度上把"每个拼接点都参数化"变成了默认?它把风险集中到了哪几个方法上(对照 §六 ④)?
相关:第 1 篇:注入的一般形式 · Web 安全机制篇 · 事件分析