📘 第十一章 Web的攻击技术
📄 一句话概括

SQL 注入的成因也是「拼接」:用户输入被拼进了 SQL 语句,于是他能改掉语句的结构。 ' OR '1'='1' -- 把 WHERE 变成永真,整张表都出来了。参数化查询能根治,过滤关键字只能算补丁。

📝 本节和前面几节的关系

上一节是数据变成 HTML,这一节是数据变成 SQL。同一个病,换个宿主。

一、一句话把条件变成永真

朴素版的登录是这么写的:SELECT * FROM users WHERE name = '%s' AND pw = '%s', 两个参数直接拼进去。 正常输入没问题。 可如果 name 填 ' OR '1'='1' --, 拼出来的语句就变成 WHERE name = '' OR '1'='1' --' AND pw = 'x'。 -- 是 SQL 的注释符,它把后面整段注释掉了。 于是条件只剩 name = '' OR '1'='1'——永真语句,整张表都被选出来。 Demo 里这一条返回了 3 行(三个用户全在里面),而正常登录只返回 1 行。 绕过登录只是最小的用法,厉害的还在后面。

二、从「绕过」到「拖库」

UNION 能把别的表接在后面。 Demo 里 ' UNION SELECT 99,'injected' -- 让结果里多出一行凭空造的数据—— 这说明攻击者可以控制返回内容。 把它换成 UNION SELECT username, password FROM users, 整张用户表的账号密码就跟着正常结果一起返回了。这叫拖库。 更进一步还有「盲注」:页面不回显数据,但会因为你构造的条件真假而表现不同(比如登录成功/失败)。 攻击者一个字符一个字符地问,照样能把数据问出来——只是慢。 所以「不回显就安全」是错的。只要输入能改 SQL 结构,就一定能读出数据。

三、参数化才是根治

修正版只改一处:SQL 里写 WHERE name = ? AND pw = ?,参数单独传给驱动。 同一个 ' OR '1'='1' -- 进去,返回 0 行。 关键不在「过滤了引号」,在参数根本不参与拼 SQL。 驱动把参数当纯数据发送,语句的结构在执行前就定死了。 这也解释了为什么各种「过滤关键字」的方案都不彻底:编码变化、注释、大小写、不同数据库的方言, 总能绕过去。 有一条经验值得记:看到字符串拼 SQL 就当成漏洞处理,不管那个变量看起来多可信。 今天不可信的是用户输入,明天可能是从别的接口拿来的值。

四、动手 Demo:四组输入,拼接与参数化对比

内存里建一张三个用户的表,写两个登录函数:一个拼字符串,一个用参数化。 各喂四组输入:正常、密码错、永真注入、UNION 注入。

sql = (u"SELECT id, name FROM users WHERE name = '%s' "
       u"AND pw = '%s'" % (name, pw))
rows = list(con.execute(sql))            # 拼接:结构可被改

sql = u'SELECT id, name FROM users WHERE name = ? AND pw = ?'
rows = list(con.execute(sql, (name, pw)))  # 参数化:结构定死

实跑输出:

--------------------------------------------------------------------
SQL 注入:一句话把 WHERE 变成永真
--------------------------------------------------------------------
  朴素版(拿字符串拼 SQL):
    正常登录                 1 行 → [(1, 'bozai')]
    密码乱填                 0 行 → []
    永真注入                 3 行 → [(1, 'bozai'), (2, 'guest'), (3, 'root')]
    UNION 多塞一行           1 行 → [(99, 'injected')]

  第三行那句拼出来的 SQL 是:
    SELECT id, name FROM users WHERE name = '' OR '1'='1' --' AND pw = 'x'

  看出来了吗?`' OR '1'='1' --` 里的 `--` 是 SQL 的注释符,它把后面「AND pw = 'x'」整段注释掉了。
  于是条件只剩下 `name = '' OR '1'='1'`——永真。整张表都出来了。

  修正版(参数化查询):
    正常登录                 1 行 → [(1, 'bozai')]
    密码乱填                 0 行 → []
    永真注入                 0 行 → []
    UNION 多塞一行           0 行 → []

  修正版的 SQL 始终是        SELECT ... WHERE name = ? AND pw = ?
  参数去哪了                 不作为 SQL 文本,单独传给驱动

  关键不在「过滤了引号」,在**参数根本不参与拼 SQL**。驱动把参数当成纯数据发送,SQL 的结构在执行前就定死了,改不了。

拼接版四组分别返回 1/0/3/1 行。第三组是永真注入,三个用户全出来了; 第四组那 1 行是 99,'injected'——攻击者凭空造的。 参数化版四组分别返回 1/0/0/0 行。 第 3、4 组都是 0,因为那串东西被当成一个普通字符串去比对,没有用户名长这样。 Demo 还打出了永真注入拼出来的完整 SQL,一眼就能看出 -- 把后面注释掉了。 注意参数化版不是「把引号转义了」——它压根没把参数放进 SQL 文本里。 这是机制上的差别,不是过滤强度的差别。

30 秒实验

  1. 把 -- 换成 #,看还能不能注释掉后面的条件(不同数据库注释符不一样)。
  2. 给 name 传一个超长的合法字符串,看参数化版会不会报错。
  3. 搜一搜你手头项目里的 execute( 调用,看有没有字符串拼接的。

常见误区

  1. 「过滤掉单引号就安全了」——编码、注释、方言都能绕过,治标不治本。
  2. 「页面不回显数据,注入了也没用」——盲注一个字符一个字符问也能问出来。
  3. 「这个参数是内部的,不会被用户改」——拼接本身就是隐患,别赌来源。
自测三问(点开对答案) 1. `' OR '1'='1' --` 为什么能绕过登录? —— `--` 注释掉了后面的密码条件,剩下的 `OR '1'='1'` 是永真。 2. 参数化查询为什么能根治? —— 参数不参与拼 SQL,语句结构在执行前就定死,改不了。 3. 什么是盲注? —— 页面不回显数据,靠「条件真假导致的表现差异」逐字符问出数据。

SQL 之外,还有一种更直接的:输入被拼进了 shell 命令。

相关笔记