等值能走索引,前缀要看排序规则,包含一定全表扫——三种写法,三种命运。
ORDER BY 后面的东西不能参数化,只能拼字符串,所以它天然是注入点,必须用白名单。
多条件查询的正确姿势:条件和参数同增同减,总数和数据用同一份条件。
教材 24.8 讲前台商品信息查询:模块概述、技术分析、商品搜索与前台其他查询。 这一节几乎全是拼 SQL,而拼 SQL 的地方就是注入的地方。 技术分析那一小节讲的是 Hibernate 的 HQL 和分页 API,本质还是把「条件」拼成字符串, 所以这一篇的结论对 HQL 同样成立。 演示里量了等值、前缀、包含三种写法的耗时和执行计划, 用同一个注入串的不同条件把「布尔盲注」跑出来,最后演示多条件加可变参数的拼法。
一、三种写法,三种命运
| 写法 | 能不能走索引 | 五万行的耗时 |
|---|---|---|
cat_id = 7 |
能 | 约零点零五毫秒 |
LIKE '商品0001%' |
默认不能,改排序规则后能 | 约五点六毫秒 |
LIKE '%00012%' |
不能 | 约六点四毫秒 |
第二行值得单独说:前缀匹配理论上能走索引,但 LIKE 默认不区分大小写,
而索引是按原样排序的 B 树——不区分大小写就没法用它。
把「区分大小写」打开之后,计划立刻从 SCAN 变成 SEARCH ... gname>? AND gname<?,
耗时掉到零点零四毫秒。
第三行在任何数据库上都走不了索引:前后都有百分号,它必须逐行看一遍才知道匹配不匹配。
所以「搜索框」这个功能天生就慢,要快只有三条路:先按分类缩小范围、要求至少输入两个字、
或者上专门的全文检索。
二、排序参数不能拼进 SQL
WHERE 的值可以用 ? 占位,但 ORDER BY 后面的列名不行——它不是一个值,
是语句结构的一部分。
所以只能拼字符串,而拼字符串就意味着只要原样拼用户输入,就一定能注入。
| sort 参数 | 实际执行的排序 | 取到的头三条 |
|---|---|---|
price_desc |
price DESC |
最大的三个 |
| 注入 · 条件为真 | CASE WHEN upass LIKE 'h%' |
最大的三个 |
| 注入 · 条件为假 | CASE WHEN upass LIKE 'zzz%' |
最小的三个 |
第二、三行是同一个注入串,只改了条件里的两个字符,取到的顺序完全反过来。 这说明攻击者不需要「把数据打印出来」——他只看顺序有没有变,就能判断一个条件真假。 一次判断一个 bit,问上几百次,整张表的数据都能被猜出来。这叫布尔盲注。 唯一正确的做法是白名单:用户传的是「键」,代码里换成「值」。 白名单挡住的不是「坏字符串」,是「不在名单里的键」——所以它不需要理解注入语法, 也就不会因为「没想到某种写法」而被绕过。
三、多条件 + 分页:条件与参数同增同减
教材常用的写法是 WHERE 1=1,然后每加一个条件就 AND ...。
这样写的好处是不用判断「这是不是第一个条件」,坏处是 1=1 看着像口诀。
更稳的写法是把条件塞进列表,最后 ' AND '.join()——条件和参数永远同增同减。
where, args = [u"is_on = 1"], []
if cat:
where.append(u"cat_id = ?"); args.append(cat)
if kw:
where.append(u"gname LIKE ?"); args.append(u"%" + kw + u"%")
w = u" AND ".join(where)
total = q(u"SELECT COUNT(*) FROM tb_goods WHERE " + w, args)
rows = q(u"SELECT * FROM tb_goods WHERE " + w + u" LIMIT ? OFFSET ?",
args + [size, (page - 1) * size])
注意 COUNT 那条也用了同一份 w 和 args。
总数据错了,分页就会错——这是「条件拼错」最常见的一种表现:页面上写「共 0 条」,
下面却列着五条数据。
Hibernate 里对应的是 setFirstResult 和 setMaxResults——不拼 LIMIT。
它们的意思是「从第几条开始、要几条」,教材的技术分析讲的就是这两个方法。
条件和参数仍然是「同增同减」的关系,一个字都没变。
四、动手 Demo:前台查询:条件拼接、模糊搜索、排序白名单
这一段给五万件商品建上分类和名称两个索引,量等值、前缀、包含三种条件写法; 再用同一个注入串的两个条件把排序翻转出来,最后演示多条件加可变参数的拼法。
import re, sqlite3, time
def timed(sql, n=5):
best = 1e9
for _ in range(n):
t = time.perf_counter()
c.execute(sql).fetchall()
best = min(best, time.perf_counter() - t)
return best * 1000
for label, cond in ((u"等值", u"cat_id = 7"),
(u"前缀", u"gname LIKE '商品0001%'"),
(u"包含", u"gname LIKE '%00012%'")):
sql = u"SELECT id FROM tb_goods WHERE " + cond + u" LIMIT 20"
plan = c.execute(u"EXPLAIN QUERY PLAN " + sql).fetchone()[3]
print(u"%s %.3f ms %s" % (label, timed(sql), plan))
c.execute(u"PRAGMA case_sensitive_like = ON")
sql = u"SELECT id FROM tb_goods WHERE gname LIKE '商品0001%' LIMIT 20"
print(u"前缀·区分大小写 %.3f ms %s"
% (timed(sql), c.execute(u"EXPLAIN QUERY PLAN " + sql).fetchone()[3]))
c.execute(u"PRAGMA case_sensitive_like = OFF")
# 排序注入:同一个串,只改条件
tpl = (u"SELECT id FROM tb_goods ORDER BY (SELECT CASE WHEN %s THEN price "
u"ELSE -price END FROM tb_member WHERE id = 1) DESC LIMIT 3")
print(u"条件为真:%s" % [r[0] for r in c.execute(tpl % u"upass LIKE 'h%'")])
print(u"条件为假:%s" % [r[0] for r in c.execute(tpl % u"upass LIKE 'zzz%'")])
# 白名单
white = {u"price_desc": u"price DESC", u"new": u"id DESC"}
sort = u"price DESC, (SELECT 1)"
print(u"白名单后:%s" % white.get(sort, white[u"new"]))
# 多条件 + 分页
def search(cat=None, kw_=None, page=1, size=5):
where, args = [u"is_on = 1"], []
if cat:
where.append(u"cat_id = ?"); args.append(cat)
if kw_:
where.append(u"gname LIKE ?"); args.append(u"%" + kw_ + u"%")
w = u" AND ".join(where)
total = c.execute(u"SELECT COUNT(*) FROM tb_goods WHERE " + w, args).fetchone()[0]
rows = c.execute(u"SELECT id FROM tb_goods WHERE " + w +
u" ORDER BY id LIMIT ? OFFSET ?",
args + [size, (page - 1) * size]).fetchall()
return total, len(rows)
print(search(cat=7))
print(search(cat=7, kw_=u"00"))
print(search(kw_=u"商品0001"))
实跑输出:
--------------------------------------------------------------------
前台查询:条件拼接、模糊搜索、排序白名单
--------------------------------------------------------------------
**教材 24.8 讲前台商品信息查询:模块概述、技术分析、商品搜索与前台其他查询。**
**这一节全部是拼 SQL,而拼 SQL 的地方就是注入的地方。**
① 三种写法,三种命运
--------------------------------------------------------------------
条件写法 耗时 计划
--------------------------------------------------------------
等值:`cat_id = 7` 0.045 ms SEARCH tb_goods USING COVE
前缀:`LIKE '商品0001%'` 5.875 ms SCAN tb_goods USING COVERI
包含:`LIKE '%00012%'` 6.282 ms SCAN tb_goods USING COVERI
**第一行走索引,第二、三行都是全表扫。**
**前缀匹配理论上能走索引,这里为什么不能?**
**因为 `LIKE` 默认不区分大小写,而索引是按原样排序的 B 树——不区分大小写就没法用。**
前缀·区分大小写 0.041 ms SEARCH tb_goods USING COVERING
**打开区分大小写之后,**
**前缀匹配立刻走索引了——计划从 `SCAN` 变成了 `SEARCH ... gname>? AND gname<?`。**
**这就是「同一个 LIKE,索引用不用得上取决于排序规则」的实际含义。**
**包含匹配(前后都有百分号)在任何数据库上都走不了索引:它必须逐行看一遍才知道匹配不匹配。**
**所以「搜索框」这个功能天生就慢。**
**要快只有三条路:先按分类缩小范围、要求至少输入两个字、或者上专门的全文检索。**
② 排序参数不能拼进 SQL
--------------------------------------------------------------------
sort 参数 实际执行的 ORDER BY 取到的头三条
-----------------------------------------------------------------
price_desc `price DESC` [899, 1799, 2699]
注入 · 条件为真 `CASE WHEN upass LIKE 'h%'` [899, 1799, 2699]
注入 · 条件为假 `CASE WHEN upass LIKE 'zzz%'` [900, 1800, 2700]
**第二、三行是同一个注入串,只改了条件里的 `h%` 和 `zzz%`,取到的顺序完全反过来。**
**这就是最关键的一点:`ORDER BY` 后面的东西【不能参数化】,只能拼字符串,**
**所以只要原样拼用户输入,就一定能注入。**
**而且它不需要「把数据打印出来」——攻击者只看「顺序有没有变」这一个 bit,**
**就能判断一个条件真假;问上几百次,整张表的数据都能被猜出来。这叫布尔盲注。**
**第三行是唯一正确的做法:白名单。用户传的是「键」,代码里换成「值」。**
**白名单挡住的不是「坏字符串」,是「不在名单里的键」——所以它不需要理解注入语法。**
③ 多条件 + 分页:拼 SQL 的正确姿势
--------------------------------------------------------------------
条件 总数 第一页几条
-------------------------------------
只在分类 7 2500 5
分类 7 + 名字含 00 115 5
只在名字含 商品0001 10 5
**三种条件共用一份 `where` 列表,参数用 `?` 一个个追加——这样「条件」和「参数」永远同增同减,**
**不会出现「写了三个条件、只传了两个参数」这种错。**
**教材里常用的 `WHERE 1=1` 也是同一个思路:让第一个条件前面直接跟 `AND`,**
**不用判断「是不是第一个」。**
**注意 `COUNT` 那条也用了同一份 `where` 和 `args`。**
**总数据错了,分页就会错——这是「条件拼错」最常见的一种表现:页面上显示「共 0 条」,**
**但下面有数据。**
三种写法那三行,耗时差了上百倍:等值零点零五毫秒,前缀和包含都在五毫秒以上。
计划的第一个词就能看出来:SEARCH 是走索引,SCAN 是全表扫。
「前缀·区分大小写」那一行是这一篇最有价值的实测:同一个 LIKE '商品0001%',
只因为打开了区分大小写,计划就从 SCAN 变成了 SEARCH,耗时掉到零点零四毫秒。
所以「LIKE 能不能用索引」不是一个能背下来的结论,它取决于列的排序规则。
排序注入那两行只差三个字符(h% 和 zzz%),结果顺序完全相反。
这就是布尔盲注的样子:不需要看到数据,看到「顺序变了」就够了。
最后三行多条件查询,三条都用了同一份 where 和 args——这就是「同增同减」的实际效果。
30 秒实验
- 把
PRAGMA case_sensitive_like那两行注释掉,看前缀那一行的计划会不会变回去。 - 在
white里加一个price_asc,然后用sort=price_asc跑一次,看排序对不对。 - 给
search()加一个价格区间条件,看COUNT和数据两条是不是还一致。
常见误区
- 把排序参数直接拼进
ORDER BY。这是最典型的注入点,而且它连「报错」都不会, 只在数据顺序上留痕迹。必须走白名单。 - 以为
LIKE '%关键字%'加个索引就能快。前后带百分号,任何索引都用不上。 要快只能缩小范围或上全文检索。 COUNT用一套条件,取数据用另一套。总数和列表对不上,页码就是错的。 两份必须共用同一个where和args。
自测三问(点开对答案)
1. 为什么前缀 `LIKE` 有时能走索引有时不能? —— 取决于列的排序规则。默认 `LIKE` 不区分大小写,用不上按原样排序的 B 树; 打开区分大小写之后,计划就从全表扫变成范围查找。 2. `ORDER BY` 为什么不能参数化? —— 因为它后面跟的不是「值」而是「列名或表达式」,属于语句结构。 占位符只能替代值,替代不了结构,所以只能拼字符串——也就只能靠白名单挡。 3. 多条件查询怎么写才不容易出错? —— 把条件和参数放进两个列表,最后 `' AND '.join()` 拼条件、参数按顺序传。 总数和数据共用同一份,避免「条件变了总数没变」。查询会写了,下一篇进购物车:车里的东西放 Session 还是放库、 下单为什么要在一个事务里同时扣库存和写订单。
- ☐ (本篇完,下一篇 24.9 购物车模块设计)