chunked 编码的行尾:Go、h11 与 Tomcat 各错在哪一处

2025 到 2026 年,Go、Python 的 h11 与 Apache Tomcat 各自收紧了分块传输编码(chunked transfer coding)上的一处宽容解析。 h11 是 Python 生态里被 httpx、uvicorn 等框架复用的 HTTP/1.1 解析库,它本身不做网络收发,只负责从缓冲区里认出消息边界;Tomcat 是 Java 的 Web 服务器,在这类问题里通常扮演代理后面那个真正的服务进程。三处都在"两个实现对同一段字节给出不同正文边界"这件事上出问题,但错的位置并不相同。

这种分歧为什么构成安全问题,要从 HTTP/1.1 怎么在一条连接上区分请求说起。HTTP/1.1 会顺序发送多个请求,接收方靠"这个请求的正文到哪里结束"来切分下一个请求从哪里开始。当前面有一个反向代理、后面有一个真正的服务进程时,两段代码会各自独立地切分同一段字节流。如果两个实现对"正文边界"的判断不同,攻击者就能构造一段字节:在前端看来它属于上一个请求的正文,因此不被检查;在后端看来它是一个新请求的开头,于是被执行。这就是请求走私(request smuggling),绕过的是前端的访问控制、审计与限流。所以在这类问题里,单侧宽容本身不构成漏洞,两个实现的分歧才是前提,这也解释了后面三处为什么都要交代"另一个实现怎么处理同一段字节"。先看 Go 那处------它的修复里引用了一条被 RFC 编辑驳回的勘误。

Go 1.24.2 的 src/net/http/internal/chunked.go 里,readChunkLine 函数多了一段不像 Go 风格的注释:

go 复制代码
	// [RFC 9112](https://www.rfc-editor.org/rfc/rfc9112.txt) permits parsers to accept a bare \n as a line ending in headers,
	// but not in chunked encoding lines. See https://www.rfc-editor.org/errata/eid7633,
	// which explicitly rejects a clarification permitting \n as a chunk terminator.
	//
	// Verify that the line ends in a CRLF, and that no CRs appear before the end.
	if idx := bytes.IndexByte(p, '\r'); idx == -1 {
		return nil, errors.New("chunked line ends with bare LF")
	} else if idx != len(p)-2 {
		return nil, errors.New("invalid CR in chunked line")
	}
	p = p[:len(p)-2] // trim CRLF

在生产代码的错误路径里引用一条被驳回的规范勘误,这不是常见写法。维护者通常不会在解析器里给 IETF 的争议留指针。写下这几行,意味着改动之前有人去查过"这个宽容到底是不是规范允许的",并且查到的答案是:有人在 2023 年正式申请把宽容写进规范,被拒绝了。顺着这条指针往回看,2025 到 2026 年间 Go、Python 的 h11、Apache Tomcat 三个互不相关的项目,各自收紧了分块传输编码(chunked transfer coding)上的一处宽容解析。三处的宽容不在同一个语法位置,修复也没有互相引用,而且对照源码后会发现一件比"大家都不够严格"更有意思的事:Go 在一处严格的地方,正是 h11 宽松的地方;h11 严格的地方,正是 Go 宽松的地方。三者里唯一被两家同时保留、且都在代码注释里承认下来的,是 chunk extension 的处理方式。

chunk extension 是紧跟在 chunk-size 数字后面、以分号开头的可选附加信息(形如 ;name=value),相当于随这一块附带的小字段,接收方通常既不理解也不使用它。它的位置很关键:夹在 chunk-size 与本行的行终止符之间,因此"把它丢到哪个字节为止"直接决定了下一行从哪里开始。

一条被正式提出又被驳回的宽容

(先说清两件事。RFC 9112 是 IETF 发布的 HTTP/1.1 规范正文,§2.2 是其中一节;勘误(errata)是任何人都可以提交的正式修订申请,只有被 RFC 编辑接受,规范正文才会改动。)

RFC 9112 §2.2 写的是:

Although the line terminator for the start-line and fields is the sequence CRLF, a recipient MAY recognize a single LF as a line terminator and ignore any preceding CR.

(MAY 是 RFC 里的关键词之一,它规定的不是"可以随便这么做",而是"实现可以选择这么做而仍然合规";同一段里也说明了这种宽容只适用于 start-line 与头部字段所在的行。)

主语是 start-line 和 fields,没有 chunk。这个省略在 2023 年被 Amos Jeffries 报为勘误,编号 EID 7633,提议把主语扩成 "start-line, fields, chunk and last-chunk",让 chunk-size 行和 last-chunk 行同样允许裸 LF。他的理由写在勘误说明里:分块编码用 CRLF 作行与帧的分隔符,方式与其他 HTTP 消息段一致,规范却没把这两行列进允许裸 LF 的位置,这构成一个没有必要的解析器例外,也让不同实现在走私鲁棒性上更难统一。

2023 年 11 月 7 日 Francesca Palombini 驳回了这条勘误。验证者注记的理由比提案本身更说明问题:

The difference was intentional. A chunked parser is not a start line or field parser (it is a message body parser) and it is supposed to be less forgiving because it does not have to retain backwards compatibility with 1.0 parsers. Hence, bare LF around the chunk sizes would be invalid and should result in the connection being marked as invalid.

这段话把宽容的层级关系定了下来。起始行和首部字段要容忍裸 LF,因为要兼容 HTTP/1.0 时代的实现;分块编码的正文没有这段历史包袱,所以被有意设计得更不宽容。规范里那个 MAY 不该被读成"整条消息都可以用裸 LF 断行"。于是同一条消息里存在两套并行的严格度:首部区宽松,消息体区严格。这个划分并不显眼,§2.2 用一句话把它讲完了,接着的三百多页里再没有强调过。

框架字节:分块编码把正文边界交给解析器去发现

要理解为什么宽容会造成边界分歧,得先看清分块编码在框架方式上和其他几种方式的区别。chunked-body 的完整 ABNF 是:

(读这段文法前先记三个记号:*x 表示零个或多个,[x] 表示可有可无,1*HEXDIG 表示一个以上的十六进制数字;BWS 与后文出现的 OWS 一样,指可有可无的空白。)

复制代码
chunked-body   = *chunk
                 last-chunk
                 trailer-section
                 CRLF

chunk          = chunk-size [ chunk-ext ] CRLF
                 chunk-data CRLF
chunk-size     = 1*HEXDIG
chunk-ext      = *( BWS ";" BWS chunk-ext-name
                     [ BWS "=" BWS chunk-ext-val ] )

对照另外两种正文定长方式:Content-Length 给的是一个十进制数字,正文边界是一次减法算出来的,解析器对字段之外任何字节的态度都不会移动这个边界;Connection: close 定长方式把边界交给连接关闭事件,同样不由语法决定。分块编码不一样。它没有给出总长度,接收方必须逐块扫描:读一行得到十六进制长度,按长度取正文,然后判断下一块从哪里开始。边界不是算出来的,是找出来的。找的过程里,凡是解析器不校验而直接跳过的字节,都在事实上决定"下一块的起点在哪"。

RFC 9112 §6.3 把定长的优先级排成了八条规则,其中第四条规定:当 Transfer-Encoding 存在且 chunked 是最终编码时,正文长度由读取并解码分块数据决定,直到编码自身表明数据结束。这个"直到编码自身表明数据结束"的措辞把边界判定完全交给了扫描过程,没有外部长度可以交叉验证它。规则 3 另有一条要求:收到同时带 Transfer-Encoding 与 Content-Length 的消息时前者覆盖后者,中间件若决定转发,必须先把收到的 Content-Length 删掉再处理 Transfer-Encoding。这一条防的正是"两个组件各自按不同字段定边界"。规范作者知道边界来源必须唯一,所以在定长层面把冲突消掉了;但同一份规范在分块语法内部没有做同等强度的收口。规范里还有一条容易被忽略的要求,同样在 §2.2:发送方不得在消息体之外生成裸 CR,而接收方遇到裸 CR 必须把该元素视为非法,或把每个裸 CR 替换为空格后再处理。这条针对的是"元素内部的 CR",与前面那条针对"行终止符的 LF"并行。两句合起来,起始行和首部字段的宽容被限制在"裸 LF 可以当行终止符"这一个方向,CR 出现在不该出现的位置是明确禁止的。把这些"不校验就跳过"的字节列出来,chunked-body 上有四个位置:

  • chunk-size 行自身的行终止符;

  • chunk-size 与 chunk-ext 之间的分界,以及 chunk-ext 到行终止符之间的全部内容;

  • chunk-data 之后的两个字节(chunk footer);

  • trailer-section 的结束(最后一个空行)。 trailer 段是分块消息在最后一块之后附带的字段,格式与请求头相同(例如校验值、签名),位置在消息体的框架区内。规范对这四处落笔的密度很不均匀。§7.1 明确写了 chunk-size = 1*HEXDIG,§7.1.1 为 chunk-ext 给出完整 ABNF 并要求 recipient 忽略未识别的扩展,§7.1.2 规定 trailer 段。四处都有明确的语法,但没有任何一处配一句"必须校验此处字节"。这个空白是所有后续分歧的土壤。还有一个细节放在这里说。§2.2 要求:如果用户代理希望在请求消息体末尾加行结束符,必须把那个终止 CRLF 计入消息体长度。换句话说,行结束符要么属于前一段数据,要么是框架的一部分,不允许变成第三种类别。这条规则和后面要讲的三处修复是同一个问题的量纲:一个字节归谁,必须唯一确定。规范在 §7.1.3 给了一段解码伪代码,把读取顺序写得很直白:

    复制代码
       length := 0
       read chunk-size, chunk-ext (if any), and CRLF
       while (chunk-size > 0) {
          read chunk-data and CRLF
          append chunk-data to content
          length := length + chunk-size
          read chunk-size, chunk-ext (if any), and CRLF
       }
       read trailer field
       while (trailer field is not empty) {
          ...(按字段定义决定合并、单独转发或丢弃)
       }

这段伪代码把前面列的四个语法位置落到了四个读取步骤上,并且暴露了一个容易读漏的事实:chunk-ext 不是单独一步,它和"读 chunk-size""读 CRLF"合并成同一行伪代码。三个动作在同一步里完成,意味着丢弃扩展内容和确定下一个边界是同一个操作的组成部分。一个实现如果把扩展内容划到"不用管"的类别里,它仍然必须知道这一行在哪里结束,而"这一行在哪里结束"正是框架判定。伪代码里还有一个分配问题。read chunk-data and CRLF 把正文和它后面的两个字节写在同一步,length := length + chunk-size 只累加 chunk-size 指定的量。所以那对 CRLF 明确不属于正文的内容长度统计,它只作为步骤的一部分被消费掉。这个写法支持了前面那个说法:这些字节是框架,不是数据。

Go:终止符的消费者换了位置,校验才有地方落地

把 d31c8055 与 go1.24.1 的同路径文件逐行对比,chunked.go 上有两处实质改动。第一处是开头引的那段新增校验,外加紧随其后的一行 p = p[:len(p)-2]。第二处在下面这个函数里:

go 复制代码
 func trimTrailingWhitespace(b []byte) []byte {
-	for len(b) > 0 && isASCIISpace(b[len(b)-1]) {
+	for len(b) > 0 && isOWS(b[len(b)-1]) {
 		b = b[:len(b)-1]
 	}
 	return b
 }
 
-func isASCIISpace(b byte) bool {
-	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
+func isOWS(b byte) bool {
+	return b == ' ' || b == '\t'
 }

改动前 isASCIISpace 把 \n 和 \r 也算作空白。beginChunk 的调用顺序是 readChunkLine → trimTrailingWhitespace → removeChunkExtension → parseHexUint。而在旧版本里,readChunkLine 读到行之后不做任何处理就把它返回,行尾的 CRLF 还在里面。剥掉这对 CRLF 的活是 trimTrailingWhitespace 干的。isASCIISpace 之所以必须包含 \r 和 \n,正是因为它是那个负责消费行终止符的函数。这解释了为什么两处改动是一件事。修复把消费终止符的职责从修剪函数搬进了 readChunkLine:新代码在 readChunkLine 内直接 p = p[:len(p)-2],同时在这一步校验那 2 个字节的形态;修剪函数随之只处理空格和制表符,isASCIISpace 被 isOWS 取代。职责搬家的意义在于校验需要一个能看见终止符的位置。旧的流水线里,唯一碰过这对 CRLF 的代码是一个名字和用途都写着"修剪空白"的函数,它对这对字节的态度是"这是空白,丢掉"。在那一层下面没有任何地方能再问"刚才丢掉的到底是什么"。信息已经没了。校验失败的原因不是没人想到要校验,而是流水线里不存在一个还看得见终止符的阶段。修复把校验放到终止符被消费的同一行,这个阶段才出现。这一点也解释了为什么 isOWS 的收紧本身不构成拦截能力:新校验发生在修剪之前的 readChunkLine 内部,ReadSlice('\n') 保证返回的行只在末尾有一个 \n(ReadSlice 读到第一个 \n 就返回,并把这个 \n 一并包含在结果里,所以返回的行内部不可能再出现第二个 \n),而校验又要求整行唯一的 \r 位于倒数第二位;剥掉这两个字节之后,交给修剪函数的行里已经不含 CR 或 LF。所以把修剪谓词留在 isASCIISpace 也不会漏掉裸 LF。它的作用是让这个函数的语义与调用它的前提一致:它拿到的输入已经不含行终止符,就不该保留处理行终止符的能力。这是一处防御未来改动的收紧,不是当次拦截的必要条件;如果哪一天有人把 p = p[:len(p)-2] 删掉或把两步调换顺序,isASCIISpace 会立刻回到"把终止符当空白吃掉"的行为,而这个行为不会触发任何测试失败。新增检查用两个分支覆盖两个方向:idx == -1 是整行没有 CR,也就是裸 LF;idx != len(p)-2 是有 CR 但不在倒数第二位,也就是行中间出现 CR。注意 bytes.IndexByte 找的是第一个 CR,所以第二个分支实际要求的是"整行唯一的 CR 就是终止符里的那一个"------它同时排除了行中间出现 CR 的情况。CVE-2025-22871 只描述了第一个方向,第二个方向对应的正是 §2.2 那句"接收方遇到裸 CR 必须把该元素视为非法"------errors.New("invalid CR in chunked line") 就是把"视为非法"这个选项实现出来。规范在这里给了两个可选动作(视为非法、或替换为空格),Go 选了前者。同一份 chunked.go 里还有一处与这个判定相邻的实现,说明这套读写循环对"哪里是行边界"的判断有多处依赖。读取循环在准备开始新块之前,会先确认缓冲区里真的已经有完整的一行可读:

go 复制代码
func (cr *chunkedReader) chunkHeaderAvailable() bool {
	n := cr.r.Buffered()
	if n > 0 {
		peek, _ := cr.r.Peek(n)
		return bytes.IndexByte(peek, '\n') >= 0
	}
	return false
}

判据是"缓冲区里能不能找到一个 \n",不含 CR。这个检查的用途是避免在只读到一个块的数据时阻塞等待下一个块头,函数注释写的是 "We've read enough. Don't potentially block reading a new chunk header.",它不参与行终止符的合法性判断,合法性判断在 readChunkLine 里。两处依赖同一个 \n 字符,但结论不同:chunkHeaderAvailable 用它判断"可以继续解析了",readChunkLine 用它之后还要回看前一个字节是不是 CR。同一个字符在同一份文件里承担两种语义,靠的是调用位置而不是字符本身,这也是后来必须把校验写进 readChunkLine 而不是写进可用性检查的原因。同一个文件里还有两处早已存在的实现,处理方式与 h11 相同或相反,可以对照。

chunk footer 这一处,Go 一直是严格的:

go 复制代码
			// If we're at the end of a chunk, read the next two
			// bytes to verify they are "\r\n".
			if cr.n == 0 && cr.err == nil {
				cr.checkEnd = true
			}
go 复制代码
			if _, cr.err = io.ReadFull(cr.r, cr.buf[:2]); cr.err == nil {
				if string(cr.buf[:]) != "\r\n" {
					cr.err = errors.New("malformed chunked encoding")
					break
				}
			}

读满一块正文之后,Go 会读接下来的两个字节并与 "\r\n" 逐字节比较,不等就报 malformed chunked encoding。这正是 h11 在 0.15.0 里没有做的事。

chunk extension 这一处,Go 是丢弃且不校验的:

go 复制代码
// removeChunkExtension removes any chunk-extension from p.
// For example,
//
//	"0" => "0"
//	"0;token" => "0"
//	"0;token=val" => "0"
//	`0;token="quoted string"` => "0"
func removeChunkExtension(p []byte) ([]byte, error) {
	p, _, _ = bytes.Cut(p, semi)
	// TODO: care about exact syntax of chunk extensions? We're
	// ignoring and stripping them anyway. For now just never
	// return an error.
	return p, nil
}

bytes.Cut(p, semi) 从第一个分号处切断,分号之后的全部内容被丢掉,函数注释写明"永远不返回错误"。**这与 h11 的 chunk_ext = r";.*" 是同一种处理,只是 Go 用 bytes.Cut、h11 用正则。两家的代码注释还都把自己的选择写了下来。

Go 对这一点另加了一个补偿,但补偿的性质是速率限制而非语法校验。chunkedReader 有一个 excess int64 字段,注释说明了设计意图:

go 复制代码
	// A sender who sends one byte per chunk will send 5 bytes of overhead
	// for every byte of data. ("1\r\nX\r\n" to send "X".)
	// We want to allow this, since streaming a byte at a time can be legitimate.
	//
	// A sender can use chunk extensions to add arbitrary amounts of additional
	// data per byte read. ("1;very long extension\r\nX\r\n" to send "X".)
	// We don't want to disallow extensions (although we discard them),
	// but we also don't want to allow a sender to reduce the signal/noise ratio
	// arbitrarily.
	//
	// We track the amount of excess overhead read,
	// and produce an error if it grows too large.

具体策略是每块允许 16 字节开销加两倍真实数据量,累计超过 16 KB 报 chunked encoding contains too much non-data。这是对"扩展可以任意长"的一个量化约束,与 RFC 9112 §7.1.1 要求的"应当限制 chunk extension 总长度、超限回 4xx"方向一致,但机制不同:规范说的是限制总长度,Go 实现的是限制开销与数据的比值。它管的是量,不管内容的语法。

Go 的修复版本边界按官方记录是 < 1.23.8 与 1.24.0-0 ≤ v < 1.24.2,修复提交作者日期 2025 年 2 月 26 日,CVE 公布于 2025 年 4 月 8 日。官方描述把后果限定得很窄:只有当前端的 Go 服务与某个"错误地把裸 LF 当作 chunk-ext 一部分"的服务配合时才可能形成走私。这个限定条件不能省------单侧宽容不构成前提。

h11:严格与宽松的位置正好互换

h11 的问题不在 chunk-size 行。h11/_abnf.py 里 chunk header 的正则是:

复制代码
chunk_header = (
    r"(?P<chunk_size>{chunk_size})"
    r"(?P<chunk_ext>{chunk_ext})?"
    r"{OWS}\r\n".format(globals())
)

其中 OWS = r"[ \t]*"。结尾是字面的 \r\n,前面只允许多余的空格和制表符。正则后面还跟着一行说明这个 OWS 的来历:

Even though the specification does not allow for extra whitespaces, we are lenient with trailing whitespaces because some servers on the wild use it.

有意图、有记录、有理由的宽容。这和 Go 那种无意接受裸 LF 的情形性质不同。还有一条比正则更直接的结构性证据。h11 从缓冲区里取行用的是 maybe_extract_next_line(),它的实现是先在缓冲区里找 b"\r\n",找不到就返回 None 表示"行还没读完":

python 复制代码
        search_start_index = max(0, self._next_line_search - 1)
        partial_idx = self._data.find(b"\r\n", search_start_index)

        if partial_idx == -1:
            self._next_line_search = len(self._data)
            return None

        idx = partial_idx + 2
        return self._extract(idx)

它按 CRLF 定位行尾,因此不可能把一个用裸 LF 结尾的行交给 chunk header 解析器:那样的输入会被判定为"还没读完",继续等待。所以 h11 在 chunk-size 行上的严格不是靠正则兜底,而是这个位置上的读取路径本身就以 CRLF 为前提。h11 从来没有容忍过 chunk-size 行的裸 LF,它在这个位置上是严格的。

h11 出问题的位置是 chunk-data 之后的两个字节。读满一块正文后,0.15.0 的代码是这样收尾的:

python 复制代码
        self._bytes_in_chunk -= len(data)
        if self._bytes_in_chunk == 0:
            self._bytes_to_discard = 2
            chunk_end = True

_bytes_to_discard = 2 是一个数量。后续处理把这个字段当成"还要丢掉几个字节":

python 复制代码
        if self._bytes_to_discard > 0:
            data = buf.maybe_extract_at_most(self._bytes_to_discard)
            if data is None:
                return None
            self._bytes_to_discard -= len(data)
            if self._bytes_to_discard > 0:
                return None
            # else, fall through and read some more
        assert self._bytes_to_discard == 0

丢掉两个,不问这两个是什么。而这两个字节按规范必须是 CRLF,它们既不属于前一块正文、也不属于后一块,是夹在中间的那对框架字节。

"按数量丢弃"这个写法不是随手选出来的,它和 h11 的读取模型有关。ChunkedReader.__call__ 每次被调用时并不保证缓冲区里有完整的一块数据,它只从 ReceiveBuffer 里取"当前能取到的量":maybe_extract_at_most(n) 的语义是"最多取 n 个字节,有多少给多少"。整个 reader 因此是增量推进的,状态字段记下"还差多少",下一次调用接着干。在这样一个模型里,"读完一块正文之后还要处理两个字节"最自然的表达就是记一个待处理的数量。写代码的人此刻关心的是"还差几步收尾",不是"这两个字节应该是什么";而一旦状态被记成数量,那个数量就再也没有机会跟内容比对。0.16.0 的修复把这个字段的类型从整数换成字节串,等于把状态的含义从"还差几步"改成"期待什么"------修复的实质是一次状态表示的替换,而不是加了一条 if。

0.16.0 把类型从数量换成了字面量:self._bytes_to_discard 从 0 变成 b"",赋值处从 = 2 变成 = b"\r\n",丢弃时逐字节比对,不等就抛 LocalProtocolError("malformed chunk footer: ...")。按数量处理改成按内容处理 ,这是修复的实质。配合补丁新增的三个拒绝用例(b"3\r\nxxx__1a\r\n"、b"3\r\nxxx\r_1a\r\n"、b"3\r\nxxx_\n1a\r\n"),可以把这一处的字节布局画出来:

复制代码
偏移  0    1    2    3    4    5    6    7    8    9    10   11
字节  33  0D   0A   78   78   78   5F   5F   31   61   0D   0A
字符  '3' CR   LF   'x'  'x'  'x'  '_'  '_'  '1'  'a'  CR   LF
      └─┬─┘ └──┬──┘ └───────┬──────┘ └───┬────┘ └───┬───┘ └─┬─┘
        │      │            │            │          │     │
        │      │            │            │          │     └── 行终止符(h11 正则在此要求字面 CRLF)
        │      │            │            │          └──────── 下一行的 chunk-size 文本
        │      │            │            └─────────────────── 第 6、7 字节:旧 h11 按数量丢弃、不校验
        │      │            └──────────────────────────────── chunk-data:chunk-size=3,取 3 字节
        │      └───────────────────────────────────────────── chunk-size 行的行终止符
        └──────────────────────────────────────────────────── chunk-size = 0x3

第 6、7 字节的十六进制值是 5F 5F,既不是 0D 0A,也不构成任何合法行终止符。旧代码把它们当作"反正要丢掉的两个字节"处理掉了。关于这段输入最终解出什么,归属要写清楚:b"xxxa" 与 b"xxx__1a" 这两个结果出自维护者的测试注释,不是我的复算结论。仅凭 diff 无法把中间量完整推出来。按 v0.15.0 的读取顺序,__ 被丢弃之后,接下来的 1a\r\n 会被当作 chunk-size 解析成 26,这与注释给出的 b"xxxa" 对不上,说明当时的实际输入形态与这个字面量存在差异。能确证的是两件事:旧代码丢弃的字节不校验内容(源码事实);作者据此断言两个解析器对同一输入会给出不同正文(维护者基于原始报告的陈述)。对工程结论来说,这两件已经足够------分歧落在第 6 个字节上,一个解析器把它当框架字节丢掉,另一个把它连同后面的内容并入正文。 作为对照的"另一个"是原始报告里给出不同正文边界的那个实现,维护者没有指明它是谁,本文也不替它命名。

h11 修复补丁自身的 From: 行是 dff7cc397a26ed4acdedd92d1bda6c8f18a6ed9f,而 CVE/GHSA 记录引用的是 114803a29ce50116dc47951c690ad4892b1a36ed。本文按后者的 URL 抓取到了完整补丁,但两个 SHA 的映射关系没有查清,因此正文只以"0.16.0 起修复"表述。

chunk extension:两家都丢弃,两家都写下了注释

三处宽容里最容易被概括错的是 chunk extension。规范 §7.1.1 的规定是:

A recipient MUST ignore unrecognized chunk extensions. A server ought to limit the total length of chunk extensions received in a request to an amount reasonable for the services provided ... and generate an appropriate 4xx (Client Error) response if that amount is exceeded.

h11 的 ABNF 里,这条被处理成了:

python 复制代码
# Actually
#
#     chunk-ext      = *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
#
# but we aren't parsing the things so we don't really care.
chunk_ext = r";.*"

真正执行丢弃的是 _readers.py 里读出长度的那两行,上面压着一条 FIXME:

python 复制代码
            # XX FIXME: we discard chunk extensions. Does anyone care?
            self._bytes_in_chunk = int(matches["chunk_size"], base=16)

Go 那边的 removeChunkExtension 与它的 TODO: care about exact syntax of chunk extensions? 是同一个形状。两家都在丢弃扩展内容,两家都在代码里写下了"我们不管它的语法",两家的注释都是问句。 h11 的 0.16.0 补丁没有改动这两行,它的全部 hunk 只涉及 footer 校验和 _bytes_to_discard 的类型变更。丢弃扩展不是没有道理的工程判断,规范自己也承认这一点。紧接着 "MUST ignore"那句之后,§7.1.1 说明了扩展的定位:分块编码是逐连接生效的,扩展很可能被包括中间件在内的每一个接收方移除或重新编码,在高层应用有机会看到它们之前就已经不在原样了;因此扩展的用途通常限于"长轮询"这类收发双方对扩展有共同约定的专用服务,或者端到端加密连接内部的填充。这段话给出的结论很明确:中间件丢弃扩展是规范预期的行为。所以两家实现的取舍在工程上站得住------至少和 §7.1.1 的意图不冲突。真正的问题不在这里,而在丢弃的位置:扩展位于 chunk-size 与它自己的行终止符之间。假定一个接收方把 ; 之后直到它认为的行尾都吃掉,那么它认定的行尾在哪里,就决定了下一行从哪里开始。规范给了接收方丢弃扩展内容的自由,却没有同时说明"丢弃到哪里为止"由什么决定。

Tomcat 是三者里唯一把修复落在这个位置上的。厂商安全页的描述是:

Tomcat did not validate that contents of HTTP/1.1 chunk extensions. This enabled a request smuggling attack if a reverse proxy in front of Tomcat allowed CRLF sequences in an otherwise valid chunk extension.

把这句话和规范原文并排读,可以看到一次措辞跨越:规范要求的是 recipient 忽略未识别的扩展,Tomcat 没有做的是校验扩展内容。前者说的是语义------不要试图理解 ;foo=bar 是什么含义,不要因为不认识就拒绝整个请求;后者说的是语法------不要接受语法非法的扩展内容。规范只写了前者。因此这里不能得出"Tomcat 违反 RFC"的结论,规范里没有一条明确文本要求 chunk-ext 不得包含 CR/LF。能立得住的表述是:未校验的内容进入了一个会被下游按语法重新解释的位置。厂商把成立条件写在同一句话里:需要一个前置反向代理允许 CRLF 序列出现在一个"本来合法"的 chunk extension 中。这是环境前提,不是默认状态。

Tomcat 的修复按分支分散:11.0 分支是 fde1a823 和 2cb06c34,10.1 分支是 f07df938 和 1e71441a。受影响的版本范围按厂商页面是 11.0.0-M1 至 11.0.18、10.1.0-M1 至 10.1.52、9.0.0.M1 至 9.0.115、8.5.0 至 8.5.100、7.0.0 至 7.0.109。本次核验没有取到 Tomcat 的提交补丁正文,因此只能到厂商公告与 CVE 记录的层级,没有落到源码行。这里有一处记录缺陷。CVE-2026-24880 的官方记录正文写 "Users are recommended to upgrade to version 11.0.20, 10.1.52 or 9.0.116",而同一条记录的受影响字段把 10.1.x 写成 "from 10.1.0-M1 through 10.1.52"。10.1.52 同时是"受影响"和"建议升级到"的版本,两者不能并存。厂商 10.1 安全页可以定这个分歧:该 CVE 的条目处在 "2026-03-23 Fixed in Apache Tomcat 10.1.53"一节之内(判据是页面原始字节的偏移------10.1.53 节标题从 43847 处开始,CVE 标识出现在 47710 处,下一节 "2026-01-27 Fixed in Apache Tomcat 10.1.52"从 48450 处开始)。10.1 分支的修复版本应为 10.1.53,记录正文里的 10.1.52 是笔误。同一个 CVE 在 11.0 分支上有一个形似的"缺口",性质却完全不同。11.0 分支的受影响范围写到 11.0.18,修复版本却是 11.0.20,中间空了一格,看起来像另一个矛盾。对照官方发布归档,11.0.19 这个版本从未发布过:归档目录里 11.0 系列的版本号在 11.0.18 之后直接跳到 11.0.20。所以 11.0 分支上"受影响到 11.0.18、修复于 11.0.20"是自洽的,不存在未覆盖的版本。两条分支并排看,能看出只做字符串比对会漏掉什么:11.0 的缺口是版本号本身的间隔,不需要处理;10.1 的冲突是同一版本号被写了两次,必须处理。两者在"CVE 描述里的版本号"这一层长得都像异常,只有回到厂商页面才能区分。做版本核查时按分支对照厂商公告,而不是按 CVE 描述字段,是这一处能得到的直接结论。

四个位置上的分布,不是一致的严格度

把三个实现按语法位置排开,宽容的分布是这样的:

语法位置 Go net/http h11 Tomcat
chunk-size 行终止符 接受裸 LF(1.24.2 前)→ 收紧 行读取按 \r\n 定位,正则也要求字面 CRLF,一直严格 未见相关公告
chunk-data 后的两个字节 与 "\r\n" 逐字节比较,一直严格 按数量丢弃 → 0.16.0 收紧 未见相关公告
chunk-ext 内容 丢弃且不校验(注释保留),仅有开销速率上限 丢弃且不校验(FIXME 保留) 未校验 → 修复
trailer 段结束 交给上层流程(本篇未追该路径) 复用头部行的宽容读取,本项目的静态观察 未见相关公告

这张表的头两行是重点:Go 严格的位置正是 h11 宽松的位置,反过来也成立。如果这类宽容是某种时代特征、语言习惯或者团队水平的产物,分布不会长成这样。它更像是每个实现在实现自己的读取循环时,对"哪几个字节我不需要"各自做了一次独立判断,判断结果恰好错开。第三行则相反------两家在同一处做了同一个决定,而且都把决定写进了注释。"我们不管它的语法"在一个解析器里是局部选择,在两个独立项目里同时出现,就变成了一种行业惯例,而 Tomcat 的公告说明这个惯例有代价。

为什么"丢掉不需要的字节"会移动正文边界

把三处的取舍动作抽象一下,都是同一件事:解析器只校验它计算正文长度所必需的部分,其余按"可以丢弃"处理。

Go 把 CR 和 LF 归入 isASCIISpace,与空格、制表符并列当作行尾填充。h11 把 chunk 之后的两字节当作固定长度的填充。两家把 chunk extension 当作不需要解释的元数据。三个动作共享同一个直觉:我不需要它,所以可以不看它。问题在于这些字节在语法里的位置。chunk-ext 在 chunk-size 与它自己的行终止符之间;chunk-data 之后的 CRLF 在前后两块正文之间;chunk-size 行的终止符同样在正文之间。四个位置上的字节都属于框架,不属于任何一块数据。丢弃框架字节,等于放弃判断下一块正文从哪里开始。 在 Content-Length 那种用减法定边界的机制里,这个直觉无害;在需要逐块扫描的机制里,它直接改变结果。还有一个性质让这类问题难以在实现内部被发现:三份代码各自的测试都通过了。Go 的 chunked reader 有完整测试,h11 有 test_ChunkedReader(补丁顺带补强了它),Tomcat 有自己的解析器测试。每个实现内部是自洽的:它接受什么、拒绝什么,都按自己的规则一致执行。分歧只在两个实现相遇时出现,而"两个实现相遇"这件事通常不在单元测试的覆盖范围里。被驳回的勘误 EID 7633 那句注记说的就是这个。它把 "less forgiving"作为消息体解析器的设计原则写了下来,等于提前给"哪些位置不能宽容"划了一条线。2025 到 2026 年三个项目的修复,实质是在补这条线在自己代码里的落点。

第四个位置:trailer 段用了头部区的宽容度

剩下那个位置是 trailer 段。规范 §7.1.2 把它定义为 trailer-section = *( field-line CRLF ),字段行本身沿用 field-line 的语法,但每行后面明确写着 CRLF。这一段在 chunked-body 的展开式里位于 last-chunk 之后、最后一个 CRLF 之前,也就是说它在消息体框架区内部。

h11 读 trailer 的那段代码是这样的:

python 复制代码
        if self._reading_trailer:
            lines = buf.maybe_extract_lines()
            if lines is None:
                return None
            return EndOfMessage(headers=list(_decode_header_lines(lines)))

_decode_header_lines 是处理请求/响应头部的同一个函数。它做的事情是逐行套 header_field_re 校验字段名与字段值,而"这一行在哪里结束"由 maybe_extract_lines 决定:

python 复制代码
blank_line_regex = re.compile(b"\n\r?\n", re.MULTILINE)
python 复制代码
        match = blank_line_regex.search(self._data, self._multiple_lines_search)
        if match is None:
            self._multiple_lines_search = max(0, len(self._data) - 2)
            return None
        idx = match.span(0)[-1]
        out = self._extract(idx)
        lines = out.split(b"\n")

        for line in lines:
            if line.endswith(b"\r"):
                del line[-1]

blank_line_regex 的模式是 \n\r?\n------CR 是可选的。判断 trailer 段结束用的是这个正则,切分用 out.split(b"\n"),每行末尾如果恰好有 \r 再删掉一个。也就是说,这段代码沿用的是头部区"裸 LF 可以当行终止符"的宽容度。这里出现了一个位置错配。RFC 9112 §2.2 允许容忍裸 LF 的范围是 start-line 和 fields,而这个宽容度现在被用在 trailer 段:trailer 段的字段确实属于 field-line 语法,但它所在的位置在消息体的框架区里,它的结束位置决定了整个 chunked-body 在哪里收尾。于是一个依赖"\n\n 就能结束 trailer"的接收方,和一个要求 \r\n\r\n 的接收方,会在这段字节上得到不同的消息结束位置。这一条是从源码读出的静态观察,不是任何公告里的结论。是否构成可被利用的分歧,取决于前置组件的对应行为是否也会在这段字节上给出不同判断,这里不做结论。它的意义在于验证前面那个判断:同一条"只校验必要的部分"的直觉,在 chunked-body 的四个位置上都能各自成立一次,而目前公开可见的修复只覆盖了其中三个。Go 在这一处同样是按可用性判断处理的:读满 last-chunk 之后直接把 cr.n == 0 映射成 io.EOF,trailer 交给上层流程,没有继续追那条路径。

按位置审一遍解析器时该看什么

把上面的材料整理成可以照着做的检查项。对 chunked-body 的四个位置,每个位置问一句"这段代码是在比较字节,还是在计数":

chunk-size 行的终止符------读行的函数在返回之前有没有确认行尾是 CRLF,行内是否可能出现 CR。若使用了一个把 CR/LF 也算作空白的修剪谓词,修剪必须在校验之后发生,或者修剪的范围不能包含 CR 与 LF。Go 的两处改动就是这个检查项的答案。

chunk-data 之后的两字节------状态字段的类型是数量还是内容。若记的是数量,这两个字节就永远不会与 \r\n 比对。h11 从 2 改成 b"\r\n" 是这个检查项的答案。

chunk-ext------正则或切割函数吃掉的范围是否延伸到行尾,以及"吃掉到行尾"所依据的"行尾"由什么判定。这里最需要小心的是:丢弃扩展内容有规范依据(§7.1.1 说扩展很可能被每个接收方移除或重新编码),所以"丢弃"本身不构成缺陷;构成缺陷的是丢弃范围与行终止符判定相互依赖而没有闭环。Go 和 h11 的注释都承认了丢弃行为,两家都靠"先定行尾再切分"来避免这个依赖;Tomcat 的公告说明这条依赖在前置代理允许 CRLF 进入扩展时会被打破。

trailer 段------这一段用哪套行机制解析。如果复用了头部区的宽容行读取,就等于把"头部可以容忍裸 LF"这条规则搬到了消息体框架区内部。另外两项与位置无关但同样必要。一是错误路径要能区分"格式错"和"读完了",Go 用 errors.New("malformed chunked encoding") 与 io.EOF 分开表达,h11 用 LocalProtocolError 区分本地协议错误与远端关闭。二是测试要包含一对应当被区分开的输入,而不只是各自期望成功的输入:3\r\n 和 3\n、xxx\r\n 和 xxx__。修复前的三份测试里,这一对输入是共同盲区------覆盖的是"合法输入解析正确",没有覆盖"这两种输入必须被区别对待"。缺口不是测试写得少,而是写测试时的默认问题只问到"这个输入对不对",没问到"这另一个输入和它是不是必须不同"。前面说过每个实现在自己内部是自洽的,缺口就出在自洽的定义里:自洽只要求对每类输入的行为一致,不要求两类的行为必须不同。

可观察面、版本边界与没覆盖到的部分

判断一个部署里是否存在这类分歧,可核查的入口在两处接缝,都不需要构造流量。一处是同一段语法在两个组件里是否只被校验了同一部分。这可以直接读代码:前端的 chunk-size 行终止符判定用什么谓词,chunk-data 之后的字节按数量还是按内容处理,chunk-ext 被匹配之后是否还有语法校验。三处答案都是布尔值,读一次源码就能确定。另一处是前置代理对 chunk extension 的处理策略。Tomcat 的成立条件里写着 "if a reverse proxy in front of Tomcat allowed CRLF sequences in an otherwise valid chunk extension",这个条件决定风险是否存在。厂商没有点名具体产品,在没有确切文本的地方不应该指认。端点侧的观测信号是修复后才出现的那几个错误串。Go 1.24.2 起裸 LF 命中 chunked line ends with bare LF,行内 CR 命中 invalid CR in chunked line,footer 不符命中 malformed chunked encoding;h11 0.16.0 起抛出 malformed chunk footer 的 LocalProtocolError;Tomcat 侧的修复落在 chunk-ext 校验上,本次核验没有取到其提交补丁,因此无法给出对应的错误串形态。这些串出现在日志里,意味着有流量正在走旧的解析路径。误报边界也清楚:正常流量不会在 chunk-size 行使用裸 LF,也不会在 chunk 之后写出非 CRLF 的两个字节。反过来要注意漏报。一个实现可以长期保持宽容而不产生任何日志,因为宽容路径本来就是正常返回,它读到的是"合法请求",没有任何异常可以记。日志里没有这些错误串,只说明没有任何流量走在已被收紧的路径上,不说明该组件在处理这段语法时是严格的。把这两件事当成一件事,会让"没有告警"看起来像"解析是严格的"。版本核查要按分支做。Tomcat 的 11.0 与 10.1 分支修复提交不同、修复版本也不同(11.0.20、10.1.53),而 CVE 记录正文在 10.1 分支上是错的;只比对"版本号大于等于某个值"无法覆盖按分支分散的修复。

相关推荐
隐擎fox1 小时前
解密 BGP Anycast 边缘路由与跨国链路质量检测:多租户网络出口的低时延与防丢包架构实战
网络·python·网络协议·tcp/ip·网络安全·架构·tcpdump
磊 子1 小时前
什么是虚假唤醒(spurious wakeup)?从 POSIX 标准讲到 futex 内核
java·开发语言
飘然生1 小时前
traceId链路 skywalking java agent
java·开发语言·skywalking
li星野1 小时前
【学习记录】射线补偿原理与实现:从物理模型到MATLAB代码
开发语言·学习·matlab
Chloe.Zz1 小时前
一篇入门Agent
开发语言·前端·javascript
余醉 | dtminer1 小时前
3337 OTU×999置换:群落构建mBio 高分沉积物
开发语言·r语言·微生物学
白杨尚青1 小时前
C++入门篇(十八):模板进阶——非类型参数、特化全家桶与分离编译(收官篇)
开发语言·c++·笔记·模板·偏特化·分离编译
qetfw2 小时前
Bash 内置与外部命令:type、command 与 builtin 验证
linux·开发语言·bash
Dovis(誓平步青云)2 小时前
车辆进入隧道后信号断开,座舱旅程怎样补回事件
java·开发语言·前端·数据库·电脑