Skip to content
This page has been auto-translated and may contain errors.View in English

SQL 注入

登录校验本质上是向数据库提的一个问题:有没有哪一行的用户名和密码,跟输入的内容都对得上?

Vulnerable
js
const query = `
  SELECT * FROM users
  WHERE username = '${username}' AND password = '${password}'
`

查出一行,说明凭证是对的;查不出,说明不对。

这看起来像是在提问。但对数据库来说,这是一整句话,而访问者拿到了参与撰写这句话的机会。

靠字符串拼接出来的查询,会把查询到底是什么意思的决定权交给发送者。

这个漏洞

SQL 是一门语言,而这条查询是每次请求都要重新写一遍的程序。其中大部分内容出自你手,但有两处内容,来自坐在键盘前的那个人。

数据库根本看不出这两部分的分界。查询送达时已经是一整段连续的文本,数据库没有任何办法分辨哪些字符是你写的、哪些是陌生人写的。它会解析整段文本,并照着执行。

这就是SQL 注入:输入被当作查询的一部分来读取,而不是被当作查询里的一个值。后果取决于 SQL 能表达什么,而它能表达的其实相当多:

  • 读取本不该被请求者看到的行。
  • 修改或删除数据。
  • 对数据库系统执行管理命令。
  • 在某些环境下,甚至能触及底层操作系统。
Juno这个漏洞 让人不容易察觉的正是模板字符串。它看起来就像是在填空,跟你平时造句时填空没什么两样。

但你实际做的事情,是把一句已经写完的话交给数据库,然后祈祷别人填进去的那部分,真的只是几个普通的词。

Juno这个漏洞 在代码审查时,破绽是任何用字符串拼接、或者带变量的模板字符串组装出来的查询。重点找一下反引号和 +,看它们是不是出现在 SELECTINSERTUPDATEDELETE 附近。

已登录也帮不上忙。一个已认证用户提交精心构造的值,本质上是同一个漏洞,而且他们往往能触及更有价值的表。

Juno这个漏洞 这类漏洞比 SQL 本身更普遍,能迁移到别处的是识别这种模式的能力。只要一个值从数据跨越到了带解析器的语言里,就由那个解析器来决定这个值意味着什么:无论是 shell 命令、XML 文档、模板,还是文件路径。

二次注入是那种能挺过半吊子修复的变种。某个值通过参数化插入被安全地存了进去,之后又被读出来,拼接进另一条查询里——写这段代码的人以为,只要数据已经在数据库里,就可以信任它。

这个payload会在某一行里潜伏数月,等到某个报表任务读取它时才触发。要把每一条查询都参数化,而不只是那些直接接触用户输入的查询。

攻击方式

先别管密码,把用户名输入成这样:

text
' OR 1=1 --

代入模板后,数据库收到的是:

sql
SELECT * FROM users
WHERE username = '' OR 1=1 --' AND password = ''

三个字符干了全部的活:

  1. 开头的 ' 提前结束了用户名字符串,所以它后面的内容都被当成查询语法来读取,而不是当成一个名字。
  2. OR 1=1 是一个恒为真的条件,于是整个 WHERE 子句对每一行都成立。
  3. -- 开启了一条 SQL 注释,所以后面的密码校验对数据库来说只是被忽略的文本。

于是每一个用户都会被查出来。应用程序把第一行当作登录成功的证明,让攻击者以别人的身份登了进去。

只能拿自己的数据库做实验

这些payload会篡改和破坏数据。这意味着凡是有真实数据的地方都不安全,包括生产环境的预发布副本也不例外。请只在可以随时删掉重建的本地数据库,或者你已获得书面许可可以测试的系统上进行。

Juno攻击方式 把这个payload拆成三步来看,而不是当成一整段字符串,它就不再显得神秘了。

先闭合引号,让后面写的是查询语法而不是文本;再加一个恒为真的条件;最后把不想处理的部分注释掉。

Juno攻击方式 注意,这次攻击既不需要密码,也不需要去猜密码,因为它直接把密码校验从查询里去掉了。

这就是为什么"我们的登录很安全,因为密码是哈希过的"这种说法在这里根本没抓到重点。哈希保护的是数据表泄露时存储的那个值,但如果比较这一步压根就没被执行,哈希起不到任何作用。

Juno攻击方式 有一个方言细节值得记住,因为它在实际测试中导致过错误的结论。在 MySQL 里,-- 只有后面跟着空白字符时才会开启注释。

所以 --' 在 MySQL 里并不是注释,一个从 PostgreSQL 示例照搬过来的payload,可能在 MySQL 上完全打不通,但底层的漏洞其实一点没少。# 是 MySQL 另一个注释标记。

这才是真正的教训:一个没起作用的payload几乎说明不了什么。它只排除了某个字符串在某个方言下的可行性,而不是排除了漏洞本身。

答案的另一半在于,这条查询是以什么身份运行的。一个拥有 DROP 权限、或者能读取本应与该功能毫无关系的表的应用账号,会让一次注入演变成一场大得多的事故。

给数据库用户设置最小权限,是在参数化被漏掉的地方限制损失的最后一道防线。

修复方式

不要再去拼句子了。把结构描述一次,然后把值单独交出去:

Fixed
js
const query = 'SELECT * FROM users WHERE username = ? AND password = ?'

const [rows] = await db.execute(query, [username, password])

? 是占位符。具体语法因数据库和库而异——有的用 $1$2,有的用命名参数——但形式在哪里都一样:查询文本在任何用户值介入之前就已经固定下来了。

这被称为带变量绑定的预处理语句,更常见的叫法是参数化查询。

Juno修复方式 区别在于数据库是什么时候知道结构的。用拼接出来的版本,结构和数据是作为一整坨一起到达的,数据库得从拿到的东西里自己琢磨出形状。

而这里,形状是先定好的。值是之后才到的,而且是作为纯粹的值到达,它们无论是什么,都改变不了那个早就已经确定下来的决定。

Juno修复方式 这是那种少见的、既能修复安全问题、写起来又更省心的方案。不用摆弄引号,不用记着调用转义函数,驱动会替你处理类型转换。

任何还在用字符串拼接的查询,都应该被当成需要写清楚理由的例外。ORM(对象关系映射器,负责把你的代码生成 SQL)在它常规的查询方法里会自动参数化。

但它的原生 SQL 逃生通道不会自动帮你做这件事,所以那里才是最该先检查的地方。

Juno修复方式 占位符绑定的只是值,绝不是标识符。表名、列名,以及 ORDER BY 里的排序方向都无法参数化,因为它们本身就是要提前告诉数据库的结构的一部分。

所以一个从查询字符串里取列名的排序接口,即便所有值都参数化了,依然可能被注入。这里的修复方式是白名单:把用户的输入映射到一个已知合法的标识符,拒绝一切匹配不上的输入。

MapObject.hasOwn()Object.create(null) 来构建这个白名单。普通的对象字面量是有漏洞的,因为 ALLOWED[userInput] 在输入是 toString 时会返回 Object.prototype.toString,这是个真值,会蒙混过一个不严谨的判断。

修复为什么有效

这里没有检查输入,也没有从中删除任何东西。' OR 1=1 -- 依然原封不动地照原样送达。

变化的是它落到了哪里。数据库早就知道自己要找的是某一个用户名和某一个密码,所以这个payload会被逐字符当作用户名,去和存放名字的那一列比对。

没有哪个账号叫 ' OR 1=1 --。查不到任何一行,登录失败,这就是正确的结果。

这是数据库层面的防线,它和本章一路建立起来的其他防线并列在一起:

层级决定的是什么
浏览器校验表单是否会被提交——只对老实用户有效
服务器端的schema 校验请求本身的形状是否值得接受
安全输出存储的值是否会在页面里变成代码
参数化查询一个值是否能变成查询的一部分
Juno修复为什么有效 这个payload并没有被拆解掉。它只是现在被当成了一个名字来读取,而且是一个谁都没用过的、非常怪异的名字。

这一点值得记住:最安全的修复方式,往往不是去判断一个值看起来危不危险,而是改变这个值被当作什么类型来处理。

Juno修复为什么有效 这就是为什么手动转义引号即便看起来有效,也是个错误的直觉。你最终维护的是对某一个数据库引号规则的一种猜测。

而这个猜测一到不同的方言、不同的字符编码,或者一个压根不涉及引号的数字字段上,就会失效。

参数化不是去回答这个问题,而是直接把这个问题给取消了。

Juno修复为什么有效 值得说清楚参数化到底覆盖了什么、没覆盖什么,因为"我们用了 ORM"经常被当成一个万能的答案。

它覆盖的是结构已经固定好的查询里的值。它不覆盖标识符、原生 SQL 逃生通道、动态拼装的 WHERE 片段,也不覆盖内部自行拼接 SQL 的存储过程。这些地方,参数化的保证统统止步不前。

schema 校验之所以仍然值得叠加在上面,是因为这两者回答的是不同的问题。参数化让一个值对数据库来说是安全的。而校验决定的是你到底想不想接受一个 4000 个字符的用户名——这个问题,数据库层根本没有立场去回答。

动手试试

有一个注册表单,会插入一个姓名和一个邮箱:

Vulnerable
js
const query = `
  INSERT INTO users (name, email)
  VALUES ('${name}', '${email}')
`

站在攻击者的角度想一想。构造一个邮箱字段的值,让它能直接删掉 users 这张表。

需要想清楚三件事:怎么跳出被引号包裹的值、怎么跳出括号、以及怎么结束当前这条语句,好让第二条语句开始执行。

对照一下你的答案

邮箱的值是:

text
'); DROP TABLE users; --

代入之后会产生:

sql
INSERT INTO users (name, email)
VALUES ('大伟', ''); DROP TABLE users; --')

一共四步操作,对应前面三个问题,再加一步收尾:

  • ' 闭合了邮箱原本所在的那个字符串。
  • ) 闭合了 VALUES 的括号,让前面这条 INSERT 成为一条合法的语句。
  • ; 结束了这条语句,于是后面的内容会被当成一条新语句来读取。
  • -- 把末尾多余的 ') 注释掉,否则这里会出现语法错误,导致整段内容被拒绝执行。

最后这一步是大家最容易漏掉的。没有它,整条语句是不合法的,数据库会拒绝执行全部内容,攻击失败的原因跟你的防御措施毫无关系。

值得知道的是:很多驱动默认会拒绝在一次调用里执行多条语句,所以这个payload原样发过去不一定能生效。但这只是配置在保护你,不是漏洞被修复了。同一个漏洞依然能通过 UNION SELECT 读出攻击者想要的任何数据,而这根本不需要第二条语句。

把查询参数化之后,整个字符串就只会变成某人一个古怪的邮箱地址,原样存进去而已。

接下来往哪走

三章内容,三个漏洞,一个共同的根源:应用程序在动手之前,从没检查过送进来的东西到底是什么,于是一个由陌生人挑选的值,决定了代码接下来做什么。

到目前为止,每一处修复都发生在使用值的那一刻:渲染时用对属性、给大小设个上限、在查询里用占位符。这些都是必要的,但也都来得有点晚——得在每一个用到值的地方重复一遍,一旦漏掉一处也很容易被忽略。

Zod 基础开始讲另外那一半的做法:在边界处,把你期望的形状只描述一次,然后拒绝一切不符合的东西。