🛢️ 第24章 Struts2+Spring+Hibernate+MySQL实现网络商城
📄 一句话概括

等值能走索引,前缀要看排序规则,包含一定全表扫——三种写法,三种命运。 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 秒实验

  1. 把 PRAGMA case_sensitive_like 那两行注释掉,看前缀那一行的计划会不会变回去。
  2. 在 white 里加一个 price_asc,然后用 sort=price_asc 跑一次,看排序对不对。
  3. 给 search() 加一个价格区间条件,看 COUNT 和数据两条是不是还一致。

常见误区

  1. 把排序参数直接拼进 ORDER BY。这是最典型的注入点,而且它连「报错」都不会, 只在数据顺序上留痕迹。必须走白名单。
  2. 以为 LIKE '%关键字%' 加个索引就能快。前后带百分号,任何索引都用不上。 要快只能缩小范围或上全文检索。
  3. COUNT 用一套条件,取数据用另一套。总数和列表对不上,页码就是错的。 两份必须共用同一个 where 和 args。
自测三问(点开对答案) 1. 为什么前缀 `LIKE` 有时能走索引有时不能? —— 取决于列的排序规则。默认 `LIKE` 不区分大小写,用不上按原样排序的 B 树; 打开区分大小写之后,计划就从全表扫变成范围查找。 2. `ORDER BY` 为什么不能参数化? —— 因为它后面跟的不是「值」而是「列名或表达式」,属于语句结构。 占位符只能替代值,替代不了结构,所以只能拼字符串——也就只能靠白名单挡。 3. 多条件查询怎么写才不容易出错? —— 把条件和参数放进两个列表,最后 `' AND '.join()` 拼条件、参数按顺序传。 总数和数据共用同一份,避免「条件变了总数没变」。

查询会写了,下一篇进购物车:车里的东西放 Session 还是放库、 下单为什么要在一个事务里同时扣库存和写订单。

相关笔记