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

拒绝服务攻击

注册表单有一个姓名字段。没有任何东西能阻止一个名字长达一千万个字符。

js
// 作为 `name` 发送的内容
'a'.repeat(10_000_000)

这个字符串里没有任何代码,它不会被当成任何指令来解释。它唯一的问题是巨大,而事实证明,巨大就已经足够了。

把它存起来,数据库就会变大。查询它,查询就会变慢。备份它,备份也会变大,每晚如此,永无止境。这样重复个几千次,服务就再也没法回应任何人了。

一个请求不需要多聪明才能造成伤害,它只需要足够昂贵。

这类测试只能对自己的服务做

在运行期间,流量测试和真正的攻击是没法区分的,而且它会波及目标系统的所有其他用户。所以只能对你自己搭建的系统,或者你获得明确授权可以测试的系统这么做。

可用性也是一种安全属性

安全通常被理解为保守秘密、保证数据正确。其实还有第三项,也是本章要讲的:可用性,也就是服务到底还能不能响应。

拒绝服务攻击,简称 DoS,针对的正是这第三项属性。它不读取任何东西,不修改任何东西,也不偷走任何东西。它只是让服务没法正常工作——而对一个企业来说,这往往才是代价最大的结果。

Juno可用性也是一种安全属性 一提到攻击,大家通常想到的是偷东西,所以这种攻击容易让人觉得"不算数"——毕竟什么都没被拿走。

想想一家店那天没法开门会损失什么。同样,也没有任何东西被偷走。

Juno可用性也是一种安全属性 在代码审查中,这类问题的表现方式很不一样。没有危险函数可以搜索,也没有值变成代码的那个"落点"。要问的是成本问题:对于每一种输入,一个请求最多能让服务器做多昂贵的事情?又是什么阻止了别人反复发起这种请求?

每一个没有长度限制的字段、每一个没有分页的列表接口、每一个同步的文件操作,都是对这个问题的一种回答。

Juno可用性也是一种安全属性 这三项属性通常被一起提及,称为机密性、完整性和可用性。可用性是基础设施团队会专门设计应对的,却常常被应用开发团队忽略。

这很遗憾,因为代价最小的拒绝服务攻击,几乎总是应用本身的 bug,而不是靠流量堆出来的。

单单一个请求触发一次没有索引的全表扫描,或者一个导出功能把一年的数据行全部加载进内存,每发一个数据包造成的破坏都远超任何流量洪泛攻击。

流量可以靠花钱来扛住。但一个每次调用要吃掉十秒 CPU 的接口,就必须修复,没有别的办法。

服务瘫痪的四种方式

课程按攻击者利用的对象来给这些方式分类,这个分类值得记住,因为每一种都要用不同的方法应对。

形态原理消耗的资源
魔法子弹一条精心构造的消息,违反了协议中的某条规则,比如发送的数据超出了字段本该容纳的大小立即命中某个特定缺陷
基于流量足够大的流量或足够大的输入,把连接或磁盘塞满带宽、内存、存储
协议层利用协议规定的工作方式做文章,让服务器占着资源却永远释放不了连接表、定时器、套接字
分布式同样的流量,但同时从成千上万台不同的机器发来以上所有资源,从四面八方同时消耗

协议层这一种值得细看,因为它展示了一次有效的攻击其实用不了多少流量。TCP 连接——大多数互联网流量底层的握手过程——分三步建立:

  1. 客户端发送一个 SYN 包,意思是"我想连接"。
  2. 服务器回复 SYN-ACK,意思是"可以",并开始占用一个连接槽位等待对方回应。
  3. 客户端回复 ACK,连接建立完成。

SYN 洪泛攻击中,攻击者用一个伪造的返回地址发送第一步。第二步会发到一台从未提出连接请求、也无话可说的机器上。第三步永远不会到来。

服务器一直占着一个开放槽位和一个运行中的计时器,而攻击者早已转向下一个伪造地址。

这里面没有任何"大流量"。对发送方来说代价很小,对接收方来说代价很高,如此反复,直到连接表被占满,真正的访客再也拿不到槽位。

分布式拒绝服务攻击,即 DDoS,指的是这些攻击手法通过僵尸网络发起——一个由攻击者控制的普通机器组成的网络,这些机器的主人往往在毫不知情的情况下被感染。

这正是它难以应对的原因。每台机器都是真实设备,发出的请求看起来也很正常,所以没有单一地址可以封锁,也没有特征可供过滤。

Juno服务瘫痪的四种方式 这四种方式共同的规律是:攻击者花小钱,服务器付大代价。

一次伪造的握手,发送成本只是一个数据包,却能占用一个槽位好几秒。这种不对等就是整个游戏的核心,也是为什么流量并不是唯一需要关注的东西。

Juno服务瘫痪的四种方式 这四种里,作为应用开发者你真正要负责的是"魔法子弹"和"基于流量"这两种。协议层攻击和分布式攻击是在网络边缘应对的,由你的托管商或者站在你前面的服务商来处理。

这个划分在事故发生时很有用。如果请求正常到达,只是你的服务处理不过来,那是你的问题。如果连接根本就建立不起来,那就是更底层的事,得打电话给别人了。

Juno服务瘫痪的四种方式 表格里没有列出、却最可能出现在你代码里的一类,是算法型攻击。有些输入发送起来很便宜,处理起来却是超线性的,所以代价的增长速度远超输入大小的增长速度。

正则表达式通常是罪魁祸首。针对模式 /^([a-z0-9]+-?)+$/,输入一串字母后面跟一个感叹号,会迫使引擎去尝试字符串的每一种切分方式。

在 Node 24 上实测,每个进程只调用一次:21 个字符耗时 38 毫秒,25 个字符耗时 606 毫秒,29 个字符耗时 9.8 秒,33 个字符耗时 105 秒。多四个字符,耗时大约翻十倍——而这样的请求小到任何大小限制都不会拦截它。

这种攻击的名字是 ReDoS,正则表达式拒绝服务攻击。要留意的形态是嵌套量词,也就是"重复里面套着重复",修复办法通常是重写正则模式,而不是限制输入长度。

层层叠加的防御

这里没有单一的解决方案。课程列出了六种防御手段,它们有意分布在不同层面:

  • 冗余。 每一样东西都多备几份:服务器、数据中心、网络路径、域名服务器。经验法则是备三份,这样即使一份正在故障、一份正在被攻击,还有第三份能继续提供服务。
  • 限流与节流。 限流是超过上限就拒绝请求。节流是把请求速度放慢。比如每分钟一百次 API 调用,十分钟内五次登录尝试,每秒一兆字节。
  • 过滤。 决定接受什么、从哪里接受。屏蔽特定地址或地区,拒绝携带明显恶意载荷的请求,要求身份验证,检查请求头。
  • 加固。 去掉不需要的东西。每一个运行中的服务都是一个入口,每一个没人用的账号也是:默认的管理员登录、离职员工留下的权限、为了一次迁移而临时开放却从未收回的权限。
  • 打补丁。 魔法子弹攻击依赖某个已知的特定缺陷。应用厂商发布的修复补丁,才能真正消除它。
  • 监控。 不知道什么是"正常",就发现不了什么是"异常"。一个平时每小时只有一千次请求的服务,突然涌入十万次请求——只有在有人记录过"一千次"这个基准的前提下,才能一眼看出这是不正常的。

具体到那个一千万字符的名字,答案就是设置长度限制,并且要在服务器端、值存入数据库之前就检查。这就是模式校验(schema validation),也是本节接下来要重点讲的那一层。

Juno层层叠加的防御 这六种手段没有一个能单独解决问题,这正是要凑齐六种的意义所在。

冗余为你争取时间,限流控制损失上限,监控让你及时发现问题,打补丁则堵住具体的漏洞。它们各自覆盖不同的环节,所以你需要的是全部用上,而不是挑一个"最好的"。

Juno层层叠加的防御 监控是团队最容易跳过、之后又最后悔的一项,因为它是唯一一个事后补做就没用的措施。事情发生之后,你没法再去确定"正常"当初是什么样子。

值得建立的基准其实很朴素:每个接口每分钟的请求数、响应时间的百分位数、错误率。提前把这些记录下来,等流量翻倍时就能触发告警,而不是等到服务已经崩溃才后知后觉。

Juno层层叠加的防御 Express 里的请求体大小限制,值得在你真正用到之前就先弄明白,因为它出错的时候很安静,不会大声报警。

全局设置的 express.json({ limit: '32kb' }) 会消费掉请求体,对超大的请求体返回 413 Payload Too Large,而且是在任何路由级解析器运行之前就完成的。之后再加一个路由级的 express.json({ limit: '2mb' }) 完全不起作用:body-parser 已经设置了 req._body,所以第二个解析器会认为这活儿已经干完了。

这一点在 Express 4 和 5.2.1 上都验证过。你以为设置了更大限制的上传接口,实际上还是卡在那个全局限制上。真正管用的做法是完全不设全局解析器,让每个路由自己声明自己的限制。

动手试试

注册表单接收 nameemailpassword,把它们存起来,并返回前两个字段。哪里都没有设限制。

想清楚三件事:

  1. 哪一个请求会让服务器付出最大代价?是什么让它变得如此昂贵?
  2. 六种防御手段里,哪一种能彻底挡住这个请求?哪一种只能减轻损害?
  3. 最便宜的解决办法是什么?
对照一下你的答案

1. 代价最大的请求。 一个超长的 namepassword。解析它要花内存,保存它要花存储空间,之后每次涉及这一行的查询都要花时间,而且从此以后每次备份都会带着这份多余的开销。

如果应用会对密码做哈希处理,那 passwordname 更糟。哈希算法本来就是故意设计得很慢的,一个超大的值会让一个本就昂贵的操作变得更加昂贵。

2. 哪些防御手段真正管用。

  • 能彻底挡住的: 过滤和长度限制。服务器在做那些昂贵的工作之前,就把请求拒绝了。
  • 只能减轻损害的: 限流和节流。它们能限制请求重复发起的频率,但单个请求本身还是会被处理。
  • 两者都不是: 冗余、打补丁和监控。它们能帮你扛过去、清除无关的漏洞、发现问题正在发生,但都不能阻止这个具体请求。

3. 最便宜的解决办法。 给每一个字符串字段都设置一个最大长度限制,并在服务器端强制执行。在模式定义里,每个字段只需要一行代码,就能把整一类问题连根拔起——这也是为什么本节接下来的章节,都花在把这份模式定义写对上。

接下来讲什么

两章下来,两个 bug 其实都出在同一个缺失的环节:没有人在应用处理数据之前,先检查它到底是什么。

SQL 注入是第三个、也是最直接的一个。这种输入与其说是存进数据库里的数据,不如说是在指挥数据库该做什么。