Bijou64:用唯一编码让变长整数解析更安全

2026-07-23 16 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

变长整数编码很省空间,却容易把“同一个数字的多种字节表示”带进安全边界。Ink & Switch 发布的 bijou64,目标就是让每个数字只有一种字节表示,从编码层面消除 canonicality bug 这一类问题。类似缺陷曾出现在 PKCS#1、JWT 库和 Bitcoin 相关实现中;摘要还显示,bijou64 的解码速度约为 LEB128 的 2 到 10 倍。

这件事的重点不只是压缩几个字节,而是把“解析后是否仍然是唯一表示”变成协议设计的一部分。

为什么非规范编码会变成安全问题

许多变长整数方案允许用多个字节表达同一个值。例如,下面是一个简化的 LEB128 风格编码:

  • 0x00 可以表示整数 0
  • 0x80 0x00 也可能被解码为整数 0

如果签名校验、缓存键生成、权限判断或交易序列化分别采用了不同的处理方式,就会出现典型的解析差异:

原始字节: 80 00
解析结果: 0
重新编码: 00

攻击者可以利用“验证时看字节,业务逻辑看数值”的分歧构造绕过输入。PKCS#1、JWT 和 Bitcoin 生态中出现过的相关问题,正说明了非规范编码并非单纯的格式洁癖,而是协议边界上的实际风险。

规范编码的核心要求很简单:

decode(encode(n)) = n
对于任意 n,encode(n) 只有一个结果

第二条尤其重要。只要编码不是唯一的,所有接收方都必须额外实现并坚持 canonicality 检查;只要有一个实现漏掉检查,整个协议就可能出现分歧。

Bijou64 解决的是什么

bijou64 把唯一表示作为设计目标:每个数字对应恰好一种字节序列。这样,解析器不需要先接受多种形式,再通过额外规则判断其中哪一种是“正确的”。输入格式本身就携带了规范性约束。

这个设计带来几项工程收益:

  • 减少验证路径:解析器可以拒绝非规范字节序列,而不是把它们映射到同一个整数后继续处理。
  • 降低跨实现差异:不同语言的库更容易对同一输入得出相同结论。
  • 简化签名与哈希边界:签名对象、缓存键和持久化数据不必同时考虑多个等价表示。
  • 改善解析性能:摘要称其解码速度约为 LEB128 的 2 到 10 倍,但真实收益仍取决于语言、数据分布、分支预测和基准方法。

社区已经出现 Elixir、Go、Perl 和 Java 端口,说明这种编码更适合被当作跨语言协议组件来评估,而不是某个单一项目里的微优化。

一个可运行的 canonicality 检查示例

下面的 Python 示例实现的是“先按 LEB128 解码,再重新编码并比较”的防御模式。它不是 bijou64 的实现,只是用最小代码演示非规范表示为什么需要被拒绝。实际项目应直接使用目标语言中经过审计的 bijou64 库或官方端口。

将代码保存为 canonical_varint_demo.py 后运行:

#!/usr/bin/env python3


def decode_leb128(data: bytes) -> int:
    value = 0
    shift = 0

    for byte in data:
        value |= (byte & 0x7F) << shift
        if byte & 0x80 == 0:
            return value
        shift += 7
        if shift >= 64:
            raise ValueError("integer is too large")

    raise ValueError("truncated varint")


def encode_leb128(value: int) -> bytes:
    if value < 0:
        raise ValueError("value must be non-negative")

    out = bytearray()
    while True:
        byte = value & 0x7F
        value >>= 7
        if value:
            out.append(byte | 0x80)
        else:
            out.append(byte)
            return bytes(out)


def parse_canonical_leb128(data: bytes) -> int:
    value = decode_leb128(data)
    if encode_leb128(value) != data:
        raise ValueError("non-canonical integer encoding")
    return value


for raw in (bytes.fromhex("00"), bytes.fromhex("8000"), bytes.fromhex("ac02")):
    try:
        print(raw.hex(), "=>", parse_canonical_leb128(raw))
    except ValueError as exc:
        print(raw.hex(), "REJECTED:", exc)

预期输出类似:

00 => 0
8000 REJECTED: non-canonical integer encoding
ac02 => 300

这个检查方式容易理解,但它也暴露了传统方案的成本:每次接收输入,都要维护解码、重新编码和边界检查三套逻辑。采用从设计上保证唯一表示的编码,可以把其中一部分复杂度移出业务代码。

采用时要验证哪些边界

“规范编码”不能替代所有输入校验。社区讨论中提到 SIMD 性能和残余范围检查,提醒我们仍需要审视几个边界:

  1. 整数范围:编码可能支持很大的数,但业务字段通常只接受 0 到某个上限。解析成功不等于业务值合法。
  2. 截断输入:网络读取、文件切片和流式解析都可能在一个整数中途结束。解析器必须区分截断、溢出和非法编码。
  3. 上下文长度:如果整数表示数组长度、偏移量或分配大小,必须在转换为机器整数、索引或内存大小之前检查上限。
  4. 签名原文:签名协议应明确签名字节是原始输入,还是规范化后的编码。不能让不同组件自行决定。
  5. 性能基准:不要只测连续的小整数。应分别覆盖小值、大值、随机值、恶意长输入和批量解析场景,再比较 LEB128、bijou64 以及 SIMD 优化实现。

一个实用的服务端策略是:在协议入口处完成一次严格解析,随后只向内部传递带有明确范围的整数类型;不要让不同业务模块重复解析同一段原始字节。

给工程团队的落地清单

  • 新协议优先选择具有唯一字节表示的整数编码。
  • 现有 LEB128 或自定义 varint 不要默认接受所有可解码形式。
  • 在测试中加入 0、边界值、最大值、截断输入和冗余前缀样本。
  • 对长度、偏移量、计数器做独立的范围检查。
  • 跨语言端口要用同一组字节级测试向量,而不只是比较最终整数。
  • 在安全敏感路径中记录“拒绝了非规范编码”,便于发现攻击探测和兼容性问题。

bijou64 的价值在于把 canonicality 从“调用者必须记住的额外检查”提升为编码格式本身的性质。它不意味着所有解析问题都自动消失,但能显著缩小协议实现中最容易被忽略的一块状态空间。对于签名、交易、认证令牌和跨语言数据交换,唯一表示通常比事后补丁更值得优先考虑。


相关推荐