我曾经认为编写自己的身份验证层是一种荣誉勋章。如果你理解 JWT 并能对密码进行哈希处理,那能有多难呢?我搭建了一个 Node 后端,签发了令牌,然后就觉得大功告成了。代码运行正常,测试也通过了。然而,当我开始阅读关于真实攻击是如何发生时,我的认知彻底崩塌了。关于注入、枚举和侧信道泄露的每一个章节都让我带着沉重的心情回到编辑器面前。我的身份验证系统不仅存在漏洞,而且还留下了四个由我自己亲手安装的、敞开的大门。以下是我发现的问题,以及我具体做了哪些改进。
通过字符串拼接进行的 SQL 注入
第一个漏洞是书中最老掉牙的伎俩。我直接将用户输入放入 SQL 字符串中。在我的登录路由里,我从请求体中获取 email,然后将其拼接进类似 SELECT * FROM users WHERE email = '${email}' 的查询语句中。因为我控制着前端,所以觉得这很安全。这是一种危险的假设。攻击者并不需要你的前端。一个精心构造的 POST 请求体就能将该登录检查变成数据泄露或数据库清空。发送像 ' OR '1'='1 这样的负载,甚至更糟的、用于删除表的堆叠查询(stacked query),如果字符串被直接执行,你的数据就全没了。我没有给数据库任何方法来区分我的代码和攻击者的数据。
修复方法并不是更多的输入验证或手动转义字符串。真正的修复方案是参数化查询。我切换到了 node-postgres 并开始使用像 $1 这样的占位符。查询变成了一个模板:SELECT * FROM users WHERE email = $1。驱动程序通过独立的通道发送 SQL 语句和数值。无论输入包含什么字符,数据库都会严格将其视为数据。这一个改动就封堵了整个类型的注入攻击。它更易读、更易维护,并且让你在每次编写 WHERE 子句时都不再需要充当正则巫师。
通过错误信息进行邮箱枚举
我的第二个错误看起来像是良好的用户体验。当用户输入错误的邮箱时,我返回 User not found。当邮箱正确但密码错误时,我返回 Incorrect password。这看起来很有帮助,但它也是攻击者的侦察工具。枚举脚本可以用成千上万个邮箱地址轰炸你的登录端点。如果响应体或状态码根据账户是否存在而发生变化,脚本就可以构建出一份经过验证的用户列表。这份列表会成为撞库(credential stuffing)、针对性钓鱼和进一步暴力破解尝试的基础。
我不得不接受:有时为了安全,必须牺牲用户友好性。我将所有失败的登录路径都改为返回完全相同的字符串:Invalid credentials。不提供任何提示。错误响应中没有分支逻辑。无论是邮箱缺失、密码错误还是账户被锁定,文本都保持一致。这也适用于注册和密码重置流程;不要透露某个地址是否已存在于你的系统中。一个通用的消息可以消除攻击者赖以生存的信息泄露。
密码比较中的计时攻击
第三个漏洞是隐形的。我当时
