失效的假设 “对方告诉我的长度,就是数据的真实长度”
所属组 W5 · 内存与解析
代表事件 Heartbleed(2014,CVE-2014-0160)
复现环境 Python 3 标准库,无外部依赖

一、一个"读操作"怎么会是重大漏洞

攻击者发来一个 5 字节的心跳包,在长度字段里写「25」。

服务器回了 25 字节。多出来的 20 个字节,是它内存里别人的数据。

前面的漏洞多半在"写"或"执行"——注入、越权、改状态。这一篇是纯粹的:攻击者什么都没改,只是让服务器多读了一点,就读到了本不该给他的东西。

Heartbleed 是这类的代表,它的机制简单到可以一句话说完:服务器信任了对方声称的长度。

心跳协议本来很无辜:你发一段数据过去,服务器原样回给你,用来确认连接还活着。问题出在"回多少"——服务器没有用你实际发来的数据长度,而是用了你在包里声称的那个长度

二、最小骨架复现

import threading, socket

# 一个复用缓冲区的心跳服务:每个连接把 payload 写进【同一块】缓冲区,
# 然后按"对方声称的长度"回显。缓冲区里残留着上一个会话的数据 —— 这就是 Heartbleed 的形状。
BUF = bytearray(64)

def serve(conn, check_length):
    data = conn.recv(1024)
    claimed = int(data[:4]);  payload = data[4:]
    BUF[:len(payload)] = payload                      # 只覆盖前面这一段,后面是旧数据
    if check_length and claimed > len(payload):
        conn.sendall(b"REJECT: claimed > actual")
    else:
        conn.sendall(bytes(BUF[:claimed]))            # 按声称长度回显
    conn.close()

def run(check_length):
    srv = socket.socket(); srv.bind(("127.0.0.1", 0)); srv.listen(1)
    threading.Thread(target=lambda: serve(srv.accept()[0], check_length), daemon=True).start()
    return srv.getsockname()

# 上一个用户的会话在缓冲区里留下了东西
BUF[:30] = b"user=admin;token=SK-7F3A9C21;\x00"

addr = run(check_length=False)
c = socket.create_connection(addr); c.sendall(b"0040hi"); print("有洞版回显:", c.recv(100)); c.close()

BUF[:30] = b"user=admin;token=SK-7F3A9C21;\x00"
addr = run(check_length=True)
c = socket.create_connection(addr); c.sendall(b"0040hi"); print("修复版回显:", c.recv(100)); c.close()
有洞版回显: b'hier=admin;token=SK-7F3A9C21;\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
修复版回显: b'REJECT: claimed > actual'

攻击者只发了 2 个字节(hi),却在长度字段里声称自己是 40 字节。有洞版老老实实回了 40 字节——前 2 个是攻击者自己的(看输出开头:hi 恰好盖住了 user 的前两个字母,于是显示成 hier=admin),后面 38 个是缓冲区里没被覆盖的旧数据,那里躺着上一个会话的 token。

⚠️ 输出开头那个 hier=admin 值得慢慢看一眼,它第一眼像是乱码。缓冲区里原本躺着 user=admin;...,攻击者发来的 2 个字节 hi 覆盖掉了最前面的 us,于是 user 变成了 hier这两个字节就是攻击者贡献的全部内容,后面每一个字符都是别人的。

真实的 Heartbleed 里,这"隔壁内存"是 OpenSSL 进程的堆,可能装着其他用户的登录凭据、会话 cookie、甚至服务器的私钥。攻击者反复发这种畸形心跳,就能一点点把服务器内存出来。

⭐ 失效的假设精确地是:“对方告诉我的长度” = “数据的真实长度”。 这两者本该由服务器自己核对——你实际收了几个字节,是服务器数得出来的事实;而对方声称的长度,是攻击者能随便填的输入。把后者当前者,就等于让攻击者决定"服务器该读多少内存回给我"。

这就像你交给别人一张写了 2 个字的纸条,同时告诉他"我这张纸条有 40 个字"。他不数,直接照着"40"去念——于是把你纸条底下压着的别人的那几张也一起念了出来。

修复不是"教他认字",是让他自己数一遍:你说 40,可我手上只有 2 个字,那你说的就不算数。

三、修复:用你数得出来的事实,核对对方的声称

修复版只加了一句:声称的长度不能超过实际收到的数据长度。

错的:  返回 memory[:对方声称的长度]        —— 长度由攻击者控制
对的:  if 声称长度 > 实际收到的长度: 拒绝    —— 用事实约束声称
       返回 实际数据[:声称长度]

这条修复的思想可以推广成一条通用规则,远不止心跳:

凡是来自对方的长度、大小、数量、偏移量,都不能直接用来决定"读多少/写多少/循环多少次"——必须先用你手上的实际数据去核验它的上界。

它和前面几篇是同一个家族:第 1 篇"别信任外部数据的结构"、第 12 篇"默认拒绝"、第 6 篇"验证在信任之前"。这里"外部数据"恰好是一个数字,而这个数字直接驱动了一次内存访问——攻击面藏在一个不起眼的整数里

四、为什么这类 bug 在 C/C++ 里特别致命

Heartbleed 发生在 C 里不是偶然。上面的 Python 版之所以只是"读到相邻 bytearray",是因为 Python 有边界检查——切片越界不会读到进程的任意内存。C/C++ 没有这层保护:

Python/Java/Go/Rust   数组越界 => 抛异常 / panic(有运行时边界检查)
C / C++               memcpy(dst, src, 攻击者控制的长度) => 直接读写相邻内存,无人阻止

⚠️ 所以"内存安全语言"能从根上消灭一大类此漏洞——这不是编码习惯问题,是语言是否强制边界检查的问题。近年大量基础库用 Rust 重写,动机正在于此。但逻辑层的"信任了对方的长度"在任何语言里都可能出现(比如按声称长度去数据库取记录、分配缓冲),内存安全语言只挡住了"越界访问内存"这一种后果,挡不住"信任了错误的数量"这个更一般的错误。

五、它出现过的地方

展开在事件分析里:

2014.04   Heartbleed / CVE-2014-0160   OpenSSL 心跳信任对方声称的长度,大量私钥与会话外泄
2017.02   Cloudbleed                   边缘代理越界,把相邻内存写进响应,且被搜索引擎缓存

⭐ Heartbleed 的一个额外教训:它无声无息。读内存不改任何状态、不留异常日志,被利用了也查不出来——所以事后没人能确定哪些数据被刮走了,只能假设"全泄了"、把所有私钥和会话全部作废重来。“读"类漏洞的应急比"写"类更被动。

六、防御

① 用实际数据核验对方的长度        声称长度 > 实收长度 => 拒绝(本篇核心)
② 用内存安全语言写解析/网络层      从根上消灭"越界访问内存"这一类后果
③ 长度/数量/偏移一律当不可信输入   分配、拷贝、循环、取记录前都校验上界
④ 关掉用不到的协议功能            Heartbleed 的心跳扩展很多部署根本不需要(呼应第 3 篇止血键)

判断标准:你代码里每一个"读 N 个"“拷 N 字节"“取前 N 条"的 N,是你数出来的,还是对方给的? 对方给的,就要先核验。

七、这些防御的边界

① 内存安全语言不是万能。 它挡住"越界读写内存”,挡不住"逻辑上信任了错误的数量”——按对方声称的大小去分配 10GB 缓冲(内存耗尽)、去取一亿条记录(拖垮数据库),在任何语言里都成立。§四 那条一般规则仍要遵守。

② “读"漏洞的隐蔽性要求事前防。 见 §五:读不留痕,事后无法评估损失。所以不能依赖"出事再查”,必须在写代码时就把长度核验做进去。

③ 边界检查要在正确的层。 有时前面加了校验,但数据经过一次变换(解码、解压)后长度变了,后面的访问用的是变换后的数据、变换前的校验——校验和使用之间隔了一步,就可能失效(呼应第 1 篇"过滤跑在解码之前”)。

关于时效:越界读的机制不随时间变。会变的是生态的语言选择——关键基础库正持续从 C 迁往内存安全语言,这类漏洞的高发区在缓慢转移。但"信任对方长度"这个逻辑错误永远可能新写出来。

八、本篇小结

Heartbleed 一句话:服务器信任了【对方声称的长度】,回显时越界读了隔壁内存。
攻击者什么都没改,只是"多读了一点" —— 纯读操作也能是重大漏洞。

⭐ 失效的假设:"对方告诉我的长度" = "数据的真实长度"
   实收多少是你数得出的事实;声称多少是攻击者能填的输入。别把后者当前者。
⭐ 修复 + 通用规则:凡是来自对方的长度/大小/数量/偏移,
   使用前必须用实际数据核验上界(分配/拷贝/循环/取记录都算)

C/C++ 无边界检查 => 越界直达任意内存,故内存安全语言能消灭这一类【后果】;
   但"信任了错误的数量"这个更一般的错误,任何语言都可能犯。
额外教训:读不留痕,被利用也查不出 => 只能假设全泄、全部作废重来。

思考题

  1. §二 里攻击者"什么都没改"却造成重大泄露。用这个例子说明:为什么"漏洞 = 能改数据/能执行代码"这个直觉是不完整的?
  2. 修复只加了"声称长度 ≤ 实收长度"一句。为什么说"实收长度"是可信的、“声称长度"是不可信的?这两个数分别由谁决定?
  3. §四 说内存安全语言把"越界读内存"变成"抛异常”。那在 Python 里写同样的有洞逻辑(按对方长度切片),最坏会发生什么?为什么危害比 C 小但不为零?
  4. §七① 说"信任错误的数量"在任何语言都成立。举一个不涉及内存、纯逻辑层的例子:按对方声称的大小/数量做某事,导致资源耗尽或数据泄露。
  5. Heartbleed"读不留痕",所以事后要"假设全泄、全部作废"。把这个应急代价和第 8 篇"你能否 1 秒内让全部会话失效"联系起来——预先准备好轮换能力有多重要?
  6. §七③ 说"校验和使用之间隔了一步会失效"。构造一个场景:长度校验在解压之前做,但访问用的是解压之后的数据。这和第 1 篇的哪一条失效方式同构?
  7. §六④ 说"关掉用不到的心跳功能"能防 Heartbleed,和第 3 篇 Log4Shell 的止血键是同一招。用"能力远超场景所需"这个第 3 篇的论点,解释为什么"关掉多余功能"总是高性价比防御。

相关第 1 篇:注入的一般形式 · 第 3 篇:表达式语言注入 · 第 17 篇:解析器差异与请求走私 · Web 安全机制篇 · 事件分析