拒绝服务攻击
注册表单有一个姓名字段。没有任何东西能阻止一个名字长达一千万个字符。
// 作为 `name` 发送的内容
'a'.repeat(10_000_000)这个字符串里没有任何代码,它不会被当成任何指令来解释。它唯一的问题是巨大,而事实证明,巨大就已经足够了。
把它存起来,数据库就会变大。查询它,查询就会变慢。备份它,备份也会变大,每晚如此,永无止境。这样重复个几千次,服务就再也没法回应任何人了。
一个请求不需要多聪明才能造成伤害,它只需要足够昂贵。
这类测试只能对自己的服务做
在运行期间,流量测试和真正的攻击是没法区分的,而且它会波及目标系统的所有其他用户。所以只能对你自己搭建的系统,或者你获得明确授权可以测试的系统这么做。
可用性也是一种安全属性
安全通常被理解为保守秘密、保证数据正确。其实还有第三项,也是本章要讲的:可用性,也就是服务到底还能不能响应。
拒绝服务攻击,简称 DoS,针对的正是这第三项属性。它不读取任何东西,不修改任何东西,也不偷走任何东西。它只是让服务没法正常工作——而对一个企业来说,这往往才是代价最大的结果。
想想一家店那天没法开门会损失什么。同样,也没有任何东西被偷走。
服务瘫痪的四种方式
课程按攻击者利用的对象来给这些方式分类,这个分类值得记住,因为每一种都要用不同的方法应对。
| 形态 | 原理 | 消耗的资源 |
|---|---|---|
| 魔法子弹 | 一条精心构造的消息,违反了协议中的某条规则,比如发送的数据超出了字段本该容纳的大小 | 立即命中某个特定缺陷 |
| 基于流量 | 足够大的流量或足够大的输入,把连接或磁盘塞满 | 带宽、内存、存储 |
| 协议层 | 利用协议规定的工作方式做文章,让服务器占着资源却永远释放不了 | 连接表、定时器、套接字 |
| 分布式 | 同样的流量,但同时从成千上万台不同的机器发来 | 以上所有资源,从四面八方同时消耗 |
协议层这一种值得细看,因为它展示了一次有效的攻击其实用不了多少流量。TCP 连接——大多数互联网流量底层的握手过程——分三步建立:
- 客户端发送一个
SYN包,意思是"我想连接"。 - 服务器回复
SYN-ACK,意思是"可以",并开始占用一个连接槽位等待对方回应。 - 客户端回复
ACK,连接建立完成。
在 SYN 洪泛攻击中,攻击者用一个伪造的返回地址发送第一步。第二步会发到一台从未提出连接请求、也无话可说的机器上。第三步永远不会到来。
服务器一直占着一个开放槽位和一个运行中的计时器,而攻击者早已转向下一个伪造地址。
这里面没有任何"大流量"。对发送方来说代价很小,对接收方来说代价很高,如此反复,直到连接表被占满,真正的访客再也拿不到槽位。
分布式拒绝服务攻击,即 DDoS,指的是这些攻击手法通过僵尸网络发起——一个由攻击者控制的普通机器组成的网络,这些机器的主人往往在毫不知情的情况下被感染。
这正是它难以应对的原因。每台机器都是真实设备,发出的请求看起来也很正常,所以没有单一地址可以封锁,也没有特征可供过滤。
一次伪造的握手,发送成本只是一个数据包,却能占用一个槽位好几秒。这种不对等就是整个游戏的核心,也是为什么流量并不是唯一需要关注的东西。
层层叠加的防御
这里没有单一的解决方案。课程列出了六种防御手段,它们有意分布在不同层面:
- 冗余。 每一样东西都多备几份:服务器、数据中心、网络路径、域名服务器。经验法则是备三份,这样即使一份正在故障、一份正在被攻击,还有第三份能继续提供服务。
- 限流与节流。 限流是超过上限就拒绝请求。节流是把请求速度放慢。比如每分钟一百次 API 调用,十分钟内五次登录尝试,每秒一兆字节。
- 过滤。 决定接受什么、从哪里接受。屏蔽特定地址或地区,拒绝携带明显恶意载荷的请求,要求身份验证,检查请求头。
- 加固。 去掉不需要的东西。每一个运行中的服务都是一个入口,每一个没人用的账号也是:默认的管理员登录、离职员工留下的权限、为了一次迁移而临时开放却从未收回的权限。
- 打补丁。 魔法子弹攻击依赖某个已知的特定缺陷。应用厂商发布的修复补丁,才能真正消除它。
- 监控。 不知道什么是"正常",就发现不了什么是"异常"。一个平时每小时只有一千次请求的服务,突然涌入十万次请求——只有在有人记录过"一千次"这个基准的前提下,才能一眼看出这是不正常的。
具体到那个一千万字符的名字,答案就是设置长度限制,并且要在服务器端、值存入数据库之前就检查。这就是模式校验(schema validation),也是本节接下来要重点讲的那一层。
冗余为你争取时间,限流控制损失上限,监控让你及时发现问题,打补丁则堵住具体的漏洞。它们各自覆盖不同的环节,所以你需要的是全部用上,而不是挑一个"最好的"。
动手试试
注册表单接收 name、email 和 password,把它们存起来,并返回前两个字段。哪里都没有设限制。
想清楚三件事:
- 哪一个请求会让服务器付出最大代价?是什么让它变得如此昂贵?
- 六种防御手段里,哪一种能彻底挡住这个请求?哪一种只能减轻损害?
- 最便宜的解决办法是什么?
对照一下你的答案
1. 代价最大的请求。 一个超长的 name 或 password。解析它要花内存,保存它要花存储空间,之后每次涉及这一行的查询都要花时间,而且从此以后每次备份都会带着这份多余的开销。
如果应用会对密码做哈希处理,那 password 比 name 更糟。哈希算法本来就是故意设计得很慢的,一个超大的值会让一个本就昂贵的操作变得更加昂贵。
2. 哪些防御手段真正管用。
- 能彻底挡住的: 过滤和长度限制。服务器在做那些昂贵的工作之前,就把请求拒绝了。
- 只能减轻损害的: 限流和节流。它们能限制请求重复发起的频率,但单个请求本身还是会被处理。
- 两者都不是: 冗余、打补丁和监控。它们能帮你扛过去、清除无关的漏洞、发现问题正在发生,但都不能阻止这个具体请求。
3. 最便宜的解决办法。 给每一个字符串字段都设置一个最大长度限制,并在服务器端强制执行。在模式定义里,每个字段只需要一行代码,就能把整一类问题连根拔起——这也是为什么本节接下来的章节,都花在把这份模式定义写对上。
接下来讲什么
两章下来,两个 bug 其实都出在同一个缺失的环节:没有人在应用处理数据之前,先检查它到底是什么。
SQL 注入是第三个、也是最直接的一个。这种输入与其说是存进数据库里的数据,不如说是在指挥数据库该做什么。

