处理二进制协议的人几乎都踩过同一个坑:同一个数字,能用两种完全不同的字节序列表示。传统可变长度整数格式 LEB128 将整数切分成多个 7 位数据块,每个字节里还塞进一个标志位,用来提示后面还有没有数据。
方便是方便,但这一设计也制造了一个“冗余编码”问题。比如数字 0,既可以写成 0x00,也可以写成 0x80 0x00——两者在数学上等价,在数据结构层面却是两套不同的指纹。
在涉及加密签名、内容寻址或者分布式共识的系统里,这种冗余直接转化为攻击面。规范上当然可以要求解码器拒绝非规范输入,但研究实验室 Ink & Switch 的 Brooklyn Zelenka 发现,开发者要么忘记加上验证检查,要么为了性能主动去掉,到头来漏洞照开不误。类似的问题历史上在 PKCS#1 v1.5、GnuTLS 以及比特币交易可塑性漏洞中都出现过。
Zelenka 给出的思路不是打补丁,而是换格式。Ink & Switch 近期发布的 bijou64 是一种新的可变长度整数编码,在结构层面就让替代编码无法存在。用 Zelenka 自己的话来说,bijou64 不是增加更多检查,而是“移除了那个真正重要的检查,并让格式本身保证:即使完全没有规范性检查,对于任何给定值,存在的唯一编码就是规范编码”。
具体到编码规则,bijou64 把 0 到 247 之间的值直接放进初始字节,不附加任何元数据。248 到 255 的字节则变成“标签”,告诉解码器后面需要跟着多少个负载字节。每个长度级别都对应一个固定的常量偏移量,这样较小的值就没法通过填充把自己伪装成更长的编码形式。
解码性能同样没有妥协。由于负载部分是连续的大端整数,编译器可以将其转换为一次加载操作加一次字节交换。在 x86 和 ARM 硬件上的基准测试显示,bijou64 对小数字的解码速度大约是 LEB128 的两倍;轮到较大的数字,速度提升拉到了 8 到 10 倍——关键原因是它避开了 LEB128 中的位掩码操作和遍历连续位时产生的大量分支判断。
Hacker News 上的讨论很快把 bijou64 拉进了一场更大的辩论里。一位评论者搬出 BONJSON 格式的基准测试结果,认为仅仅对标量解码器做比较,忽略了高性能解析的演进方向:“当你转向 SIMD 指令时,ULEB128 或 sentinel values 每次都会获胜,因为它们拥有并行化机会。真正讽刺的是,即使是 SIMD 文本解析也会超过这个方案。”
另一位评论者 dzaima 则从另一个角度挑刺,直指 bijou64 的安全宣称可能被过度解读。他的看法是,bijou64 确实缩小了需要检查的边界,但并未把运行时验证完全抹掉。“忘记检查 first_byte==255 情况下的范围限制,然后直接让它发生 64 位溢出,这和遗漏 LEB128 的范围检查一样,是一种完全合理的漏洞。”dzaima 甚至提出,bijou64 在某种意义上的风险更大:“它会让人认为较小输入根本不需要任何范围检查,因此开发者可能忘记为最大长度情况添加特殊处理。”
与此同时,也有反馈指出一个值得注意的反例:像 LEB128 这样的非规范格式在 WebAssembly 和 DWARF 链接器中仍然被有意使用,目的很务实——为未链接的引用填充空间,以便后续进行原地修补。换句话说,可变长度填充对某些工具链来说是一项设计需求,而非设计缺陷。
目前,bijou64 的参考实现已用 Rust 发布在 crates.io 上,采用 MIT/Apache 2.0 双许可证。JavaScript 封装也同步登陆 npm,Elixir、Go、Perl 和 Java 的社区移植版本均已就绪。规范中一并涵盖了 bijou32 和 bijou128 两种位宽变体,规模上覆盖了从轻量嵌入到大数据处理的多种场景。
热门跟贴