Compilers aren't supposed to crash — especially not when compiling perfectly valid code like this:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;
contract A {
function a() public pure returns (uint256) {
return 1 ** 2;
}
}
Yet running Solidity's compiler (solc) on this file on a standard Ubuntu 22.04 system (G++ 11.4, Boost 1.74) causes an immediate segmentation fault.
At first, this seemed absurd. The code just returns 1 to the power of 2 — no memory tricks, unsafe casting, or undefined behavior.
And yet, it crashes.
Another minimal example?
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.25;
contract A {
function a() public pure {
uint256[1] data;
}
}
Still crashes.
So what’s going on?
We traced it down to a seemingly unrelated C++ line deep in the compiler backend:
if (*lengthValue == 0) { ... }
That single comparison — a boost::rational
compared to 0 — causes infinite recursion in G++ < 14 when compiled under C++20. And the resulting stack overflow crashes solc.
This post unpacks how this happened — and why none of the individual components are technically "broken":
- A 12-year-old overload resolution bug in G++
- An outdated symmetric comparison pattern in Boost
- A subtle but impactful rewrite rule in C++20
Put together, they form a perfect storm — one that takes down Solidity compilation on default Linux setups, even though your code is perfectly fine.
If you follow the Solidity build documentation (v0.8.30), you'll see it recommends:
Ubuntu 22.04, for example, ships with:
So far, so good.
However, Solidity enabled C++20 in January 2025:
This wasn't accompanied by an update to the versions of dependencies in the documentation. As we'll soon see, that's what opened the trapdoor.
What’s Overload Resolution?
In C++, when you write an expression like a == b
, the compiler chooses among available operator==
implementations by comparing their match quality. A member function like a.operator==(b)
usually has higher priority than a non-member function like operator==(a, b)
— unless the types differ too much or are ambiguous.
That’s the rule. But G++ didn’t always follow it.
The Bug
In 2012, a bug was filed: GCC Bug 53499 – overload resolution favors non-member function. The issue? In expressions where:
- A class
rational<T>
has a templatedoperator==
member function - There's also a more generic free
operator==(rational<T>, U)
function
Clang correctly chooses the member function.
G++ (before v14) chooses the non-member function.
Why? Because G++ mishandles templated conversion + non-exact match, overvaluing a non-member function with worse match quality. It does not correctly apply the overload resolution ranking rules defined in CWG532: Member/nonmember operator template partial ordering.
A Minimal Reproducer
Let’s see this in action:
#include <iostream>
template <typename IntType>
class rational {
public:
template <class T>
bool operator==(const T& i) const {
std::cout << "clang++ resolved member" << std::endl;
return true;
}
};
template <class Arg, class IntType>
bool operator==(const rational<IntType>& a, const Arg& b) {
std::cout << "g++ <14 resolved non-member" << std::endl;
return false;
}
int main() {
rational<int> r;
return r == 0;
}
- Compile with g++<14:
g++ -std=c++17 main.cpp -o test && ./test
Output (on g++ 11.4):g++ <14 resolved non-member
- Compile with clang++:
clang++ -std=c++17 main.cpp -o test && ./test
Output:clang++ resolved member
In short, the wrong function gets picked. G++ was broken here until v14.
What Changed in C++20?
C++20 introduced the spaceship operator <=>
and defaulted comparison rewrites.
When you define a two-argument operator==
, C++20 may implicitly define the "reversed" version:
- If you define:
bool operator==(T1, T2);
- Then
T2 == T1
may call the same function by reversing the arguments.
This rewrite is recursive: a == b
becomes b == a
, which becomes a == b
again, and so on — if not handled carefully.
This is great for reducing boilerplate — unless the call becomes ambiguous or self-referential.
The old Boost rational
class (prior to v1.75) defined both member function and non-member function of operator==
:
template <class Arg, class IntType>
template <typename IntType>
class rational
{
...
public:
...
template <class T>
BOOST_CONSTEXPR typename boost::enable_if_c<rational_detail::is_compatible_integer<T, IntType>::value, bool>::type operator== (const T& i) const
{
return ((den == IntType(1)) && (num == i));
}
...
}
template <class Arg, class IntType>
BOOST_CONSTEXPR
inline typename boost::enable_if_c <
rational_detail::is_compatible_integer<Arg, IntType>::value, bool>::type
operator == (const Arg& b, const rational<IntType>& a)
{
return a == b;
}
This was designed under C++17 semantics. Back then, rhs == lhs
would fall back to member overloads if available. All good.
But under C++20
with G++ < 14
:
- G++ incorrectly chooses this non-member operator first
- C++20 reverses the comparison
- Which calls the same function again with arguments flipped
- And so on...
This creates infinite recursion.
A minimal example:
// g++ -std=c++20 -o crash main.cpp && ./crash
#include <boost/rational.hpp>
int main() {
boost::rational<int> r;
return r == 0;
}
Expected output: nothing.
Actual: segmentation fault (stack overflow).
This exact pattern was reported and fixed in Boost rational, but only in version 1.75+.
Here’s the one-line fix:
template <class Arg, class IntType>
BOOST_CONSTEXPR
inline typename boost::enable_if_c <
rational_detail::is_compatible_integer<Arg, IntType>::value, bool>::type
operator == (const Arg& b, const rational<IntType>& a)
{
- return a == b;
+ return a.operator==(b);
}
Instead of calling a == b
— which triggers overload resolution again — the patched version directly calls the member function operator==
.
This prevents C++20 from triggering recursive rewrites.
The Solidity codebase uses boost::rational
to represent certain compile-time constant expressions.
One snippet that can trigger this issue appears in DeclarationTypeChecker::endVisit
:
if (Expression const* length = _typeName.length()) {
std::optional<rational> lengthValue;
if (length->annotation().type && length->annotation().type->category() == Type::Category::RationalNumber)
...
else if (std::optional<ConstantEvaluator::TypedRational> value = ConstantEvaluator::evaluate(...))
lengthValue = value->value;
if (!lengthValue)
...
else if (*lengthValue == 0) // <-- Infinite recursion happens here
...
}
Under normal circumstances, this expression is benign. But:
- G++ < 14 wrongly prefers Boost's non-member operator
- C++20 reverses the arguments
- The non-member operator recursively calls itself
💥: segmentation fault.
If a system uses any of the following:
- G++ < 14 (e.g., Ubuntu 22.04 uses 11.4)
- Boost < 1.75 (e.g., 1.74 ships with Ubuntu)
- C++20 enabled (default in recent Solidity builds)
They will encounter this crash as soon as it processes a Solidity source with a length expression like T[0]
or anything involving compile-time rational comparisons.
- Update Boost to ≥ 1.75
- Pin G++ to v14 or later
This isn’t a security vulnerability. It doesn’t corrupt memory or allow code execution.
But it is a reminder of the fragility of modern build stacks. A bug introduced in 2012, fixed in 2024, quietly broke one of the most used blockchain compiler toolchains — all without any code in the Solidity repo being “wrong.”
Every layer here — Boost, G++, the C++20 spec, and Solidity — behaved “as documented.” But together, they composed into undefined behavior.
The lesson? Always test critical software under multiple compilers and library versions — especially when enabling a new language standard.