Historic Attacks
The vectors collected here are closed by modern Solidity and modern protocol design. They are included for three reasons. First, legacy contracts that predate the fixes are still in production and still receive audits. Second, the underlying patterns recur in disguise — the "constructor with the wrong name" bug is structurally identical to many initialization mistakes seen in proxy contracts today. Third, understanding why a class of bug was important enough to warrant a language change or a hard fork is part of the intellectual toolkit of a working auditor.
For deep coverage of the major real-world exploit case studies (The DAO, Parity, bZx, Poly, Wormhole, Nomad, Euler, Curve, etc.), see §4.16 Case Studies. This page focuses on the vector classes rather than specific incidents.
Constructor with Same Name as Contract (Pre-0.4.22)
In Solidity versions before 0.4.22, constructors were declared by naming a function identically to the contract. The compiler matched the name and treated that function as the constructor.
// Pre-0.4.22 — VULNERABLE if names diverge
contract MyToken {
function MyToken(uint256 supply) public { ... } // intended constructor
}
contract MyToken {
function MyTokn(uint256 supply) public { ... } // typo → public function!
}
A typo, a rename of the contract without renaming the function, or a copy-paste from one contract into another produced a "constructor" that was actually a callable public function. Anyone could call it, re-running initialization with attacker-chosen parameters. The Rubixi pyramid scheme in 2016 was the canonical case: the contract was renamed from DynamicPyramid to Rubixi but the constructor was left as DynamicPyramid(), leaving it callable as a public function — and an attacker called it to make themselves the owner.
The fix came in 0.4.22 with the constructor keyword, which decouples constructor identity from naming. By 0.5.0 the old form was removed entirely.
Modern echo. The same pattern lives on in initializer functions for upgradeable contracts. An initialize() function that lacks the initializer modifier, or that can be called more than once, is essentially the same bug. See §4.12.3.
Call Depth Attack (Pre-EIP-150)
Before EIP-150 (the Tangerine Whistle hard fork, October 2016), the EVM's call stack had a hard depth limit of 1024 frames, with no gas-based cost increase for recursive calls. An attacker could call themselves recursively 1023 times, then call the victim contract at depth 1024. Any call the victim then made — send, transfer, call, even an internal function call that triggered an external call — would fail because the depth limit was hit.
// Pre-EIP-150 — exploitable
function withdraw(uint256 amt) external {
require(balances[msg.sender] >= amt);
msg.sender.send(amt); // would fail at depth 1024
balances[msg.sender] -= amt; // but the deduction still ran
}
The combination of "send failed silently" and "the contract continued anyway" allowed attackers to drain balances by forcing the send to fail while the deduction was either not applied or applied with side effects that benefited the attacker.
EIP-150 fixed the underlying primitive by introducing the 63/64 gas rule: a CALL forwards at most 63/64 of remaining gas, so reaching the 1024 depth limit requires exponential gas. In practice, the depth limit can no longer be hit before gas runs out, but legacy contracts that relied on the symptom (assuming send could fail and writing code that handled that failure correctly) are now relying on a defense that has changed in nature.
Modern echo. The class survives as "external call may revert for reasons unrelated to the protocol — fixed gas budgets, recipient logic, OOG in nested calls." Every external call should either revert the surrounding transaction or be designed with explicit failure handling. The reentrancy fix and the call-depth fix together drove the Checks-Effects-Interactions discipline as a defense even against unknown future call-failure modes.
ABI Encoder v2 Storage-Array Bug (0.5.0 – 0.5.9)
Solidity 0.5.0 introduced ABI Coder v2 (originally called the "experimental" encoder, then standardized) to support returning structs and arbitrary nested types across the contract boundary. Between 0.5.0 and 0.5.10, the encoder had a bug: when a function returned a storage array (rather than a memory array), the encoded output could contain incorrect length or data fields. Callers reading the return value through abi.decode got wrong values; functions used the wrong values silently.
// 0.5.0 - 0.5.9 — VULNERABLE
function getHolders() external view returns (address[] storage) {
return holders; // ABI v2 could mis-encode
}
Symptoms ranged from off-by-one length values (causing iterators to read past the array) to corrupted element data (causing fund transfers to wrong addresses).
The bug was disclosed and fixed in 0.5.10. Subsequently, the Solidity team improved their bug-disclosure process and bugs.json infrastructure largely as a response to this incident.
Modern echo. Compiler bugs are rare but not gone. The Curve Vyper compiler bug in 2023 (see §4.16.10) is the closest modern analogue: a language-level bug in reentrancy-guard codegen that affected production contracts and required a coordinated response. Auditors who treat the compiler as a trusted oracle are missing a class of vulnerability that has produced multi-million-dollar incidents within recent memory. Cross-check the chosen compiler version against the known-bugs list as part of every audit.
Constantinople Reentrancy / EIP-1283 Postponement
EIP-1283 (Constantinople, scheduled January 2019) introduced "Net Gas Metering for SSTORE Without Dirty Maps" — a reduction in SSTORE gas costs designed to make storage operations cheaper for repeated writes within the same transaction.
ChainSecurity disclosed (a few hours before the fork was scheduled to go live) that the reduced cost made a previously-impossible reentrancy attack feasible: with SSTORE cheap enough, a contract recipient could perform a storage write inside a transfer's 2300-gas stipend. Contracts that had been written assuming "no state changes possible in fallback because 2300 gas is too little for SSTORE" were suddenly vulnerable. The Ethereum Foundation postponed the fork; EIP-1283 was eventually deployed in Petersburg with additional safeguards (EIP-1706 / EIP-2200), and the standalone EIP-2200 introduced the requirement that 2300 gas remaining is insufficient for SSTORE regardless of the new pricing.
The incident illustrates several lessons relevant to current auditing practice:
- Defense-in-depth via "this gas amount is too little for anything dangerous" is brittle. Future repricings can invalidate the assumption. Use explicit reentrancy guards.
- Subsequent hard forks (EIP-1884, EIP-2929) have continued to reprice storage operations. Any reasoning about gas-based defenses needs to be re-validated at every fork.
- The 2300-gas stipend's continued existence is not a guarantee of safety, only of compatibility with legacy
transfer/sendsemantics. See §4.18.2 and §4.18.4.
Modern echo. Gas-based assumptions in contracts deployed before any particular fork should be reviewed against the gas table that exists post-fork. The pattern "if call uses ≤ X gas, it cannot do Y" is fragile against future protocol upgrades; the pattern "regardless of gas, the call cannot re-enter because we guard with nonReentrant and CEI" is durable.
The Common Thread
Each of these historic vectors illustrates a pattern that recurs even after its specific instance is closed:
| Historic vector | Closed by | Modern recurrence |
|---|---|---|
| Constructor-name bug | constructor keyword (0.4.22) | Missing initializer modifier in proxies |
| Call-depth attack | EIP-150 63/64 gas rule | External calls failing for unrelated reasons; gas griefing |
| ABI Encoder v2 bug | Compiler patch (0.5.10) | Compiler bugs in any language used on-chain (Vyper 2023) |
| Constantinople reentrancy | EIP-2200, additional safeguards | Any "this gas amount can't do X" assumption |
The pattern worth internalizing: any assumption about the EVM, the compiler, or another contract's behavior that depends on a cost or limit rather than an invariant the protocol enforces itself is liable to be broken by a future change. The durable defenses are the ones that hold regardless of gas pricing, regardless of compiler version, and regardless of how the call site decides to invoke the contract.
Auditor Checklist
-
Any contract compiled with Solidity < 0.5.0 reviewed for: constructor-name bug, default-public visibility, uninitialized-storage-pointer,
vartype inference,suicide/sha3/throwdeprecations. - Any contract compiled with Solidity 0.5.0 – 0.5.9 reviewed for the ABI Encoder v2 storage-array bug; if affected, recommend recompilation against a patched compiler.
-
Any contract relying on legacy
transfer/send2300-gas semantics replaced with low-levelcallplus explicit reentrancy guard. - Gas-based assumptions documented and re-validated against the current gas table; protections do not rely solely on cost-based reasoning.
-
Initializer functions on upgradeable contracts use the
initializermodifier and_disableInitializers()in the implementation's constructor (see §4.12.3). -
Compiler version cross-checked against
bugs.json; no known bugs apply.