SQL 注入
登录校验本质上是向数据库提的一个问题:有没有哪一行的用户名和密码,跟输入的内容都对得上?
const query = `
SELECT * FROM users
WHERE username = '${username}' AND password = '${password}'
`查出一行,说明凭证是对的;查不出,说明不对。
这看起来像是在提问。但对数据库来说,这是一整句话,而访问者拿到了参与撰写这句话的机会。
靠字符串拼接出来的查询,会把查询到底是什么意思的决定权交给发送者。
这个漏洞
SQL 是一门语言,而这条查询是每次请求都要重新写一遍的程序。其中大部分内容出自你手,但有两处内容,来自坐在键盘前的那个人。
数据库根本看不出这两部分的分界。查询送达时已经是一整段连续的文本,数据库没有任何办法分辨哪些字符是你写的、哪些是陌生人写的。它会解析整段文本,并照着执行。
这就是SQL 注入:输入被当作查询的一部分来读取,而不是被当作查询里的一个值。后果取决于 SQL 能表达什么,而它能表达的其实相当多:
- 读取本不该被请求者看到的行。
- 修改或删除数据。
- 对数据库系统执行管理命令。
- 在某些环境下,甚至能触及底层操作系统。
但你实际做的事情,是把一句已经写完的话交给数据库,然后祈祷别人填进去的那部分,真的只是几个普通的词。
攻击方式
先别管密码,把用户名输入成这样:
' OR 1=1 --代入模板后,数据库收到的是:
SELECT * FROM users
WHERE username = '' OR 1=1 --' AND password = ''三个字符干了全部的活:
- 开头的
'提前结束了用户名字符串,所以它后面的内容都被当成查询语法来读取,而不是当成一个名字。 OR 1=1是一个恒为真的条件,于是整个WHERE子句对每一行都成立。--开启了一条 SQL 注释,所以后面的密码校验对数据库来说只是被忽略的文本。
于是每一个用户都会被查出来。应用程序把第一行当作登录成功的证明,让攻击者以别人的身份登了进去。
只能拿自己的数据库做实验
这些payload会篡改和破坏数据。这意味着凡是有真实数据的地方都不安全,包括生产环境的预发布副本也不例外。请只在可以随时删掉重建的本地数据库,或者你已获得书面许可可以测试的系统上进行。
先闭合引号,让后面写的是查询语法而不是文本;再加一个恒为真的条件;最后把不想处理的部分注释掉。
修复方式
不要再去拼句子了。把结构描述一次,然后把值单独交出去:
const query = 'SELECT * FROM users WHERE username = ? AND password = ?'
const [rows] = await db.execute(query, [username, password])? 是占位符。具体语法因数据库和库而异——有的用 $1、$2,有的用命名参数——但形式在哪里都一样:查询文本在任何用户值介入之前就已经固定下来了。
这被称为带变量绑定的预处理语句,更常见的叫法是参数化查询。
而这里,形状是先定好的。值是之后才到的,而且是作为纯粹的值到达,它们无论是什么,都改变不了那个早就已经确定下来的决定。
修复为什么有效
这里没有检查输入,也没有从中删除任何东西。' OR 1=1 -- 依然原封不动地照原样送达。
变化的是它落到了哪里。数据库早就知道自己要找的是某一个用户名和某一个密码,所以这个payload会被逐字符当作用户名,去和存放名字的那一列比对。
没有哪个账号叫 ' OR 1=1 --。查不到任何一行,登录失败,这就是正确的结果。
这是数据库层面的防线,它和本章一路建立起来的其他防线并列在一起:
| 层级 | 决定的是什么 |
|---|---|
| 浏览器校验 | 表单是否会被提交——只对老实用户有效 |
| 服务器端的schema 校验 | 请求本身的形状是否值得接受 |
| 安全输出 | 存储的值是否会在页面里变成代码 |
| 参数化查询 | 一个值是否能变成查询的一部分 |
这一点值得记住:最安全的修复方式,往往不是去判断一个值看起来危不危险,而是改变这个值被当作什么类型来处理。
动手试试
有一个注册表单,会插入一个姓名和一个邮箱:
const query = `
INSERT INTO users (name, email)
VALUES ('${name}', '${email}')
`站在攻击者的角度想一想。构造一个邮箱字段的值,让它能直接删掉 users 这张表。
需要想清楚三件事:怎么跳出被引号包裹的值、怎么跳出括号、以及怎么结束当前这条语句,好让第二条语句开始执行。
对照一下你的答案
邮箱的值是:
'); DROP TABLE users; --代入之后会产生:
INSERT INTO users (name, email)
VALUES ('大伟', ''); DROP TABLE users; --')一共四步操作,对应前面三个问题,再加一步收尾:
'闭合了邮箱原本所在的那个字符串。)闭合了VALUES的括号,让前面这条INSERT成为一条合法的语句。;结束了这条语句,于是后面的内容会被当成一条新语句来读取。--把末尾多余的')注释掉,否则这里会出现语法错误,导致整段内容被拒绝执行。
最后这一步是大家最容易漏掉的。没有它,整条语句是不合法的,数据库会拒绝执行全部内容,攻击失败的原因跟你的防御措施毫无关系。
值得知道的是:很多驱动默认会拒绝在一次调用里执行多条语句,所以这个payload原样发过去不一定能生效。但这只是配置在保护你,不是漏洞被修复了。同一个漏洞依然能通过 UNION SELECT 读出攻击者想要的任何数据,而这根本不需要第二条语句。
把查询参数化之后,整个字符串就只会变成某人一个古怪的邮箱地址,原样存进去而已。
接下来往哪走
三章内容,三个漏洞,一个共同的根源:应用程序在动手之前,从没检查过送进来的东西到底是什么,于是一个由陌生人挑选的值,决定了代码接下来做什么。
到目前为止,每一处修复都发生在使用值的那一刻:渲染时用对属性、给大小设个上限、在查询里用占位符。这些都是必要的,但也都来得有点晚——得在每一个用到值的地方重复一遍,一旦漏掉一处也很容易被忽略。
Zod 基础开始讲另外那一半的做法:在边界处,把你期望的形状只描述一次,然后拒绝一切不符合的东西。

