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

永远不要相信用户输入

注册表单大概是 Web 开发里最普通不过的东西了。几个文本框、一个提交按钮、成功时弹出一条提示。

但它同时也是应用的大门。用户输入的每一个用户名、邮箱地址、评论和搜索关键词,都是你的应用没有写过、来自你无法控制的机器的数据。

其中大部分都是无害的。但只要有一个访客好奇地想试试,输入一些你没料到的东西,问题就来了。

把用户输入当成安全数据来处理,是 Web 开发中代价最高的一个假设。

本节要攻击的表单

贯穿全文的示例是一个带前端和后端的注册表单,而且它是被故意写得很糟糕的。

部分作用
front-end/index.html表单的标记结构:输入框、提交按钮,以及成功和错误信息的容器。
front-end/app.ts读取输入内容,向后端发起 POST 请求,并展示结果。
front-end/types.ts与后端的类型保持一致,让客户端知道响应数据的结构。
back-end/server.ts启动 Express,解析 JSON,挂载路由,监听 3000 端口。
back-end/routes/auth.ts/api/register-vulnerable 接口。它完全跳过了校验,直接把原始输入原样返回。
back-end/state/mockDb.ts数据库的替代品,让注册人数可以变化,而不需要真正的数据库。

这个接口的名字本身就是一个警示标签。它的任何部分都不应该出现在生产环境里。它存在的目的,就是为了让接下来几章的攻击有地方可落地。

去掉多余部分后,这个处理函数其实就是这样:

Vulnerable
ts
router.post('/api/register-vulnerable', (req, res) => {
  const { name, email, password } = req.body

  mockDb.users.push({ name, email, password })

  res.json({ success: true, user: { name, email } })
})

三个字段进来,三个字段被存起来,两个字段被送回去。中间没有任何一步去问一句:name 是不是真的像个名字,email 里有没有 @,或者这些字段到底有多长。

Juno本节要攻击的表单 这个小小的表单,就是本节全部的攻击面。它从头到尾都不会变,每一章都会用一种新方式把它弄坏,然后再修好。

一开始接触一个被故意弄坏的应用,感觉会有点怪。它是故意写错的,所以当你盯着它看的时候,失败的过程是清清楚楚摆在眼前的。

Juno本节要攻击的表单 一个接口、一个表单,加上几个字段,已经足够构成脚本注入、资源耗尽和查询篡改的攻击面了。这才是有意思的地方:不需要多大的应用,就能同时在三个方向上被攻破。

真实的应用里有成百上千个这样的地方。值得养成的习惯是:看到任何接触 req.body 的处理函数,都要问一句——它对收到的数据做了哪些假设?

Juno本节要攻击的表单 这么小的攻击面,如实展示了输入的路径,却悄悄隐藏了时间维度上的问题。模拟数据库意味着一次攻击可以在一秒内演示并重置,这也正是为什么这里的重点始终放在数值的流转路径上。

它没有展示的,是你之后会遇到的另一类 bug。存储,是输入数据"等待"的地方。

一个刚进来时看起来无害的值,几个月后可能会在另一个页面、以另一种上下文被渲染出来,变得危险。光盯着这个表单看,你是发现不了这一点的。

先把这一点记在心里,等到后面一切看起来都"无状态"的时候,再回过头想想。

浏览器无法强制执行任何规则

HTML 自带免费的校验功能。给输入框加上 required,表单就不会在为空的情况下提交。设置 type="email",浏览器就会检查这个值看起来像不像一个邮箱地址。

这确实有用,但它保护不了任何人。

一个想绕过它的人,可以关掉 JavaScript,在开发者工具里直接改属性,或者干脆跳过页面,用 curl 直接把请求发给接口。你的表单只是一个客户端。而接口能接受来自任何地方的请求。

浏览器校验只是对老实用户的一种照顾

它能捕捉笔误、省去一次往返请求。但它不是安全控制手段,因为要不要执行这些检查,决定权在攻击者手里。

所以这个表单一开始就关掉了浏览器校验:

html
<form novalidate>

从零开始,能让这一点更容易记住。每一项真正重要的检查,都要在服务器端有意识地加上——因为那是请求在传输途中无法被篡改的地方。

Juno浏览器无法强制执行任何规则 这条规则的核心是"谁说了算"。任何在别人浏览器里运行的东西,都是运行在对方的电脑上、受对方控制的,对方可以随意修改它。

服务器运行在你自己的机器上。这才是唯一一个不会被你正在检查的对象关掉检查的地方。

Juno浏览器无法强制执行任何规则 有个快速体会这一点的方法:打开网络面板,提交一次表单,把请求复制为 curl 命令,然后修改请求体再发一次。没有页面,没有 JavaScript,也没有校验。

如果这个请求带着表单本该拒绝的值也能成功,你就找到了界面和接口之间的漏洞。

Juno浏览器无法强制执行任何规则 客户端校验依然有它的价值。它能减少无谓的往返请求,给出即时反馈,让错误提示紧贴在对应的字段旁边。

真正的问题在于,把它当成安全防线中的一层。它处在信任边界之外——也就是你能控制的系统部分和你无法控制的部分之间的那条线。所以它应该算进用户体验的预算里,而不是安全预算里。

一个把它算作安全控制手段的团队,往往是通过一次事故报告才发现这个错误的。

输入变成 bug 的三条路径

接下来三章会追踪同一个值走向三个不同的目的地,而目的地决定了会出什么问题。

输入最终去哪会出什么问题对应章节
被渲染进页面一个访客注入的脚本,在另一个访客的浏览器里运行跨站脚本攻击
消耗服务器资源一个超大或精心构造的请求耗尽内存、存储或连接数拒绝服务攻击
被拼接进查询语句输入变成 SQL 语句,读取、篡改或删除数据SQL 注入

同一个字段可以同时喂给这三种情况。一个名字既会进入页面,也会进入存储,还会进入查询语句,所以一个未经检查的值,就有三种方式能伤到你。

Juno输入变成 bug 的三条路径 让这件事保持简单的关键问题是:这个值接下来会去哪里?

进入页面的 HTML?进入数据库?进入查询语句?进入一封邮件?每个目的地,都有自己一套把文本误读成指令的方式。

Juno输入变成 bug 的三条路径 在阅读攻击相关的章节之前,先完整追踪一个字段的全过程。以姓名字段为例:它在 app.ts 里被读取,POST 给接口,存储起来,被原样返回,然后渲染进成功提示信息里。

一共五个环节。每一个环节,都有可能让这个值被"解释执行",而不是单纯地被"显示"出来。

Juno输入变成 bug 的三条路径 这三种是可以直接演示出来的,但背后的规律要普遍得多。任何时候,只要文本从"数据"跨界进入某种"语言",另一端的解析器就会决定它到底是什么意思:HTML、SQL、shell 命令、模板、文件路径,都是如此。

认清这种模式,比死记硬背具体的攻击载荷更重要,因为攻击载荷会变,但这种模式不会变。

没有一劳永逸的方案,只有层层防护

每一种漏洞都有对应的防御手段,但没有哪一种能单独解决全部问题。把这些防护手段叠加起来使用,专业说法叫纵深防御:假设任何一层都可能失效,确保它背后还有别的防线撑着。

对这个表单来说,各层防护分别是:

  1. 浏览器校验能捕捉笔误、省去一次往返请求。但对存心要绕过的人,它拦不住。
  2. 服务器端的模式校验在处理函数接触请求之前,就判断这个请求的结构对不对。本节后面会用 Zod 来做这件事,这是一个用来描述你期望的数据结构、并据此校验数据的库。
  3. 安全输出在渲染数值时对其进行转义,让存储的文本始终只是文本。
  4. 参数化查询让查询语句的结构和填入其中的数据保持分离。

一次攻击要想得逞,必须闯过全部四层防线。一层出错,只是个 bug;一层出错而其他层都不存在,那就是一次安全事故。

Juno没有一劳永逸的方案,只有层层防护 分层防护,就是安全领域里"鸡蛋不要放在一个篮子里"的做法。

你总会有某一层做错的时候。而正是有了四层防护,才让"错一次"这件事变得可以承受。

Juno没有一劳永逸的方案,只有层层防护 这几层防护各司其职,谁也替代不了谁。校验负责决定要不要接受一个值。转义负责决定如何安全地渲染它。参数化负责决定如何把它安全地送进数据库。

一个只接受合理姓名的模式校验,不能让拼接出来的查询语句变安全;一个参数化查询,也不能让同一个值在页面里就变安全。

第二层防护值得用一个现成的库,而不是自己手写一堆检查逻辑。一份模式定义能给你一个唯一的事实来源,而不是把规则散落在 HTML 属性和处理函数代码里。

用它你还能拿到自己可控的错误提示,而不是浏览器那种千篇一律的提示;能表达浏览器根本无法表达的业务规则;而且同一份定义,服务器端和页面端都能用。

Juno没有一劳永逸的方案,只有层层防护 一层防护放在哪个位置,和它是否存在同样重要。校验应该放在边界处,业务逻辑之前,这样下游就不用去猜自己收到的到底是什么。转义应该放在输出处,因为只有渲染器才知道最终的目的场景是什么。

在输入时就转义,是个经典的错误。它会把变形后的数据存起来,一旦出现第二个目的地就会出问题,最后你会得到一个数据库,里面塞满了没人能安全还原的 &amp;amp;。正确做法是:入口处校验,出口处转义。

有三个词经常被当成一回事来用,其实它们指的是三种不同的操作:

  • 校验(Validation):判断一个值是否可以接受,不行就拒绝它。年龄是 -4,就该判定失败。
  • 净化(Sanitization):编辑这个值,去掉不想要的部分,保留剩下的内容。
  • 转义(Escaping):不改动值本身,只是针对某一个特定目的地对它做编码,比如把 < 变成 &amp;lt; 后再放进 HTML 里。

优先选择校验。拒绝一个不合格的值,比修补它更容易讲清道理;净化应该是最后的手段——因为每一个用来剔除危险内容的过滤器,本质上都是在猜"危险"到底是什么样子。

动手试试

再来看一遍这个注册处理函数:

Vulnerable
ts
router.post('/api/register-vulnerable', (req, res) => {
  const { name, email, password } = req.body

  mockDb.users.push({ name, email, password })

  res.json({ success: true, user: { name, email } })
})

回答关于它的三个问题:

  1. 它对 req.body 里的值做了哪些假设?
  2. 如果请求是用 curl 发的,而不是从表单来的,哪些假设还能成立?
  3. 处理函数拿到每个字段之后,它们各自会流向哪里?
对照一下你的答案

1. 它做了哪些假设。 一共五点,没有一个是经过检查的:

  • 三个字段都传过来了。
  • 每一个都是字符串。
  • 没有哪个长得离谱。
  • email 真的是个邮箱地址。
  • 不管是谁接下来处理这些值,都不会把它们当成指令来解释。

每一条,都只是一个美好的期望。

2. 用 curl 发请求,哪些假设还能成立。 一条都不行。唯一在强制执行这些规则的,只有表单自己的标记结构,而直接发给接口的请求根本不会经过表单。不管请求是怎么来的,处理函数的行为完全一样,因为它根本分辨不出区别。

3. 每个字段流向哪里。 nameemail 会存进去,然后在响应里被送回来,前端会把它们渲染进成功提示信息里。仅仅一次提交,每个值就已经同时到达了页面和数据存储这两个地方。

password 会原封不动、以明文形式存起来,这本身就是一个 bug:密码应该经过哈希处理,绝不应该以可读的形式保存。如果把模拟存储换成真正的数据库,这三个字段还会一并进入查询语句。

这些假设,正是接下来三章要一一打破的对象。

接下来会发生什么

表单已经搭好了,浏览器也不再假装能保护它了,而它当前接受的每一个值,都还处在被信任的状态。

跨站脚本攻击 将拉开破坏的序幕,用一个能在别人浏览器里执行代码的姓名字段作为突破口。