编译器缺陷引发编译器缺陷:一个存在 12 年的 G++ 缺陷如何摧毁 Solidity

  • osecio
  • 发布于 2025-08-11 14:41
  • 阅读 307

Solidity 编译器在特定环境下 (G++ < 14, Boost < 1.75, 启用 C++20) 编译包含长度表达式的 Solidity 代码时会因编译器缺陷而崩溃。这是由于 G++ 的一个长期存在的重载解析bug,加上 C++20 的对称比较特性以及旧版本的 Boost 库中的代码共同作用导致的无限递归问题。

编译器漏洞导致编译器漏洞:一个 12 年前的 G++ 漏洞如何击垮 Solidity

一个来自 2012 年的微妙的 G++ 漏洞、C++20 的新比较规则和遗留的 Boost 代码可能会冲突,从而导致 Solidity 的编译器在有效的代码上崩溃。我们将揭示这个令人惊讶的连锁反应以及如何修复它。

编译器漏洞导致编译器漏洞:一个 12 年前的 G++ 漏洞如何击垮 Solidity 的标题图片

编译器不应该崩溃 —— 尤其是在编译像这样完全有效的代码时:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;

contract A {
    function a() public pure returns (uint256) {
        return 1 ** 2;
    }
}

然而,在标准的 Ubuntu 22.04 系统(G++ 11.4,Boost 1.74)上对此文件运行 Solidity 的编译器 (solc) 会导致立即的段错误。

起初,这似乎很荒谬。这段代码只是返回 1 的 2 次方 —— 没有内存技巧、不安全的强制转换或未定义的行为。

然而,它崩溃了。

另一个最小的例子?

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;

contract A {
    function a() public pure {
        uint256[1] data;
    }
}

仍然崩溃。

那么发生了什么?

我们将其追溯到编译器后端中一段看似无关的 C++ 代码行:

if (*lengthValue == 0) { ... }

当在 C++20 下编译时,这个简单的比较 —— 将一个 boost::rational 与 0 进行比较 —— 会导致 G++ < 14 中的无限递归。 并且由此产生的堆栈溢出导致 solc 崩溃。

这篇文章将揭示这是如何发生的 —— 以及为什么没有一个单独的组件在技术上是“损坏的”:

  • G++ 中一个有 12 年历史的重载解析漏洞
  • Boost 中过时的对称比较模式
  • C++20 中一个微妙但影响深远的重写规则

组合在一起,它们形成了一场完美的风暴 —— 即使你的代码完全没问题,也会导致默认 Linux 设置上的 Solidity 编译失败。


背景:设置

如果你遵循 Solidity 构建文档 (v0.8.30),你会看到它建议:

  • Boost ≥ 1.67
  • GCC ≥ 11

例如,Ubuntu 22.04 附带:

  • G++ 11.4.0
  • Boost 1.74.0

到目前为止,一切都很好。

然而,Solidity 在 2025 年 1 月启用了 C++20

在 Solidity 中启用 C++20

这并没有伴随文档中依赖项版本的更新。正如我们很快将看到的,这就是打开陷阱的原因。


第一部分:G++ 中一个有 12 年历史的重载解析漏洞

什么是重载解析?

在 C++ 中,当你编写像 a == b 这样的表达式时,编译器会通过比较它们的匹配质量来选择可用的 operator== 实现。像 a.operator==(b) 这样的成员函数通常比像 operator==(a, b) 这样的非成员函数具有更高的优先级 —— 除非类型差异太大或存在歧义。

这就是规则。但是 G++ 并不总是遵循它。

漏洞

在 2012 年,提交了一个漏洞:GCC 漏洞 53499 – 重载解析偏向于非成员函数。 问题是什么?在以下表达式中:

  • rational&lt;T> 具有模板化的 operator== 成员函数
  • 还有一个更通用的 free operator==(rational&lt;T>, U) 函数

Clang 正确地选择了成员函数。

G++(v14 之前)选择了非成员函数。

为什么?因为 G++ 错误地处理了模板化转换 + 非精确匹配,高估了匹配质量较差的非成员函数。它没有正确应用 CWG532: 成员/非成员运算符模板偏序 中定义的重载解析排序规则。

一个最小的可重现示例

让我们看看实际效果:

#include &lt;iostream>

template &lt;typename IntType>
class rational {
public:
    template &lt;class T>
    bool operator==(const T& i) const {
        std::cout &lt;&lt; "clang++ resolved member" &lt;&lt; std::endl;
        return true;
    }
};

template &lt;class Arg, class IntType>
bool operator==(const rational&lt;IntType>& a, const Arg& b) {
    std::cout &lt;&lt; "g++ &lt;14 resolved non-member" &lt;&lt; std::endl;
    return false;
}

int main() {
    rational&lt;int> r;
    return r == 0;
}
  • 使用 g++<14 编译:
g++ -std=c++17 main.cpp -o test && ./test

输出(在 g++ 11.4 上):

g++ &lt;14 resolved non-member
  • 使用 clang++ 编译:
clang++ -std=c++17 main.cpp -o test && ./test

输出:

clang++ resolved member

简而言之,选择了错误的函数。G++ 在 v14 之前在这里被破坏了。


第二部分:C++20 的对称比较特性

C++20 中发生了什么变化?

C++20 引入了 宇宙飞船运算符 &lt;=>默认的比较重写

当你定义一个双参数的 operator== 时,C++20 可能会隐式地定义“反向”版本:

  • 如果你定义:bool operator==(T1, T2);
  • 那么 T2 == T1 可能会通过反转参数来调用同一个函数。

此重写是递归的a == b 变成 b == a,然后再次变成 a == b,依此类推 —— 如果不小心处理。

这非常适合减少样板代码 —— 除非调用变得模糊或自引用。


第三部分:Boost 的陷阱

旧的 Boost rational 类(在 v1.75 之前)定义了 operator== 的成员函数和非成员函数:

template &lt;class Arg, class IntType>
template &lt;typename IntType>
class rational
{
    ...
public:
    ...

    template &lt;class T>
    BOOST_CONSTEXPR typename boost::enable_if_c&lt;rational_detail::is_compatible_integer&lt;T, IntType>::value, bool>::type operator== (const T& i) const
    {
       return ((den == IntType(1)) && (num == i));
    }
    ...
}

template &lt;class Arg, class IntType>
BOOST_CONSTEXPR
inline typename boost::enable_if_c &lt;
   rational_detail::is_compatible_integer&lt;Arg, IntType>::value, bool>::type
   operator == (const Arg& b, const rational&lt;IntType>& a)
{
      return a == b;
}

这是在 C++17 的语义下设计的。那时,如果可用,rhs == lhs 会回退到成员重载。一切都很好。

但是在 C++20G++ &lt; 14 下:

  • G++ 错误地首先选择了这个非成员运算符
  • C++20 反转了比较
  • 这会再次调用具有翻转参数的相同函数
  • 依此类推...

这会产生无限递归

一个最小的例子:

// g++ -std=c++20 -o crash main.cpp && ./crash
#include &lt;boost/rational.hpp>

int main() {
    boost::rational&lt;int> r;
    return r == 0;
}

预期输出:无。

实际输出:段错误(堆栈溢出)。

这个确切的模式已在 Boost rational 中报告并修复,但仅在 1.75+ 版本中。

这是一行修复:

template &lt;class Arg, class IntType>
BOOST_CONSTEXPR
inline typename boost::enable_if_c &lt;
   rational_detail::is_compatible_integer&lt;Arg, IntType>::value, bool>::type
   operator == (const Arg& b, const rational&lt;IntType>& a)
{
-     return a == b;
+     return a.operator==(b);
}

已修补的版本不是调用 a == b —— 这会再次触发重载解析 —— 而是直接调用成员函数 operator==

这可以防止 C++20 触发递归重写。


第四部分:这如何破坏 Solidity

Solidity 代码库使用 boost::rational 来表示某些编译时常量表达式。

一个可以触发此问题的代码片段出现在 DeclarationTypeChecker::endVisit 中:

if (Expression const* length = _typeName.length()) {
    std::optional&lt;rational> lengthValue;

    if (length->annotation().type && length->annotation().type->category() == Type::Category::RationalNumber)
        ...
    else if (std::optional&lt;ConstantEvaluator::TypedRational> value = ConstantEvaluator::evaluate(...))
        lengthValue = value->value;

    if (!lengthValue)
        ...
    else if (*lengthValue == 0)  // &lt;-- 无限递归发生在这里
        ...
}

在正常情况下,此表达式是良性的。但是:

  • G++ < 14 错误地首选 Boost 的非成员运算符
  • C++20 反转了参数
  • 非成员运算符以递归方式调用自身

💥:段错误。


第五部分:哪些环境受到影响?

如果系统使用以下任何一项:

  • G++ < 14(例如,Ubuntu 22.04 使用 11.4)
  • Boost < 1.75(例如,1.74 随 Ubuntu 一起提供)
  • 启用 C++20(最近的 Solidity 构建中的默认设置)

只要它处理具有长度表达式(如 T[0])或涉及编译时有理数比较的任何内容的 Solidity 源代码,它们就会遇到此崩溃。


建议

  • 将 Boost 更新到 ≥ 1.75

  • 将 G++ 锁定到 v14 或更高版本

    • *

结论

这不是一个安全漏洞。它不会破坏内存或允许代码执行。

但它对现代构建堆栈脆弱性的一种提醒。一个在 2012 年引入,在 2024 年修复的漏洞,悄悄地破坏了最常用的区块链编译器工具链之一 —— 所有这些都没有 Solidity 存储库中的任何代码是“错误的”。

这里的每一层 —— Boost、G++、C++20 规范和 Solidity —— 都“按文档”运行。但在一起,它们组成了未定义的行为。

教训是什么?始终在多个编译器和库版本下测试关键软件 —— 尤其是在启用新的语言标准时。

  • 原文链接: osec.io/blog/2025-08-11-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
osecio
osecio
Audits that protect blockchain ideas.