On January 8, 2026, an attacker fed an oversized integer into Truebit's getPurchasePrice function — a contract compiled with Solidity 0.5.3, which lacks built-in overflow protection. The result: 8,535 ETH ($26.4M) drained in a single transaction through a mint-and-burn loop that exploited wrapping arithmetic nobody had reviewed since 2021.
Truebit wasn't an isolated case. Q1 2026 saw $50M+ in losses traceable to contracts running outdated Solidity compilers. Not reentrancy. Not oracle manipulation. Just old code left alive on mainnet with nobody watching.
This article catalogs the six version-specific vulnerability classes that matter right now, shows you exactly where each one hides, and gives you a complete Hardhat migration playbook to eliminate every single one.
1. Unchecked Arithmetic (Solidity < 0.8.0) — $26.4M (Truebit)
The Bug
Before Solidity 0.8.0, all arithmetic silently wraps on overflow/underflow. SafeMath mitigated this — but only where developers remembered to use it.
// Solidity 0.5.x — this silently wraps
function getPurchasePrice(uint256 amount) public view returns (uint256) {
uint256 base = currentSupply + amount; // ← overflows to near-zero
return base * pricePerToken / 1e18;
}
The Exploit Pattern
- Find a public function with unprotected arithmetic
- Supply a value that causes
uint256to wrap past2^256 - 1 - Result evaluates to near-zero, enabling mint-at-zero-cost
- Sell back minted tokens at market price
The Fix
// Solidity 0.8.x — reverts automatically
function getPurchasePrice(uint256 amount) public view returns (uint256) {
uint256 base = currentSupply + amount; // reverts on overflow
return base * pricePerToken / 1e18;
}
Detection
# Slither custom detector for unprotected arithmetic in <0.8.0 contracts
slither . --detect unchecked-lowlevel,suicidal \
--solc-remaps "@openzeppelin/=node_modules/@openzeppelin/" \
--filter-paths "test|mock"
2. Default Visibility (Solidity < 0.5.0) — Estimated $8M+ at Risk
The Bug
In Solidity 0.4.x, functions without an explicit visibility keyword default to public. Admin functions intended as internal become callable by anyone.
// Solidity 0.4.24 — this is PUBLIC by default
function _setOwner(address newOwner) {
owner = newOwner;
}
Why It Still Matters
DeFi composability means old contracts are still referenced as dependencies. If your protocol integrates with a 0.4.x contract that has an accidentally-public admin function, your entire TVL is at risk.
The Fix
Explicit visibility on every function. Period.
function _setOwner(address newOwner) internal {
owner = newOwner;
}
Detection
# Slither query: find functions with default visibility
from slither.core.declarations import FunctionContract
for f in contract.functions:
if f.visibility == "public" and f.name.startswith("_"):
print(f"⚠️ {f.name} is public but uses _ prefix convention")
3. Delegatecall to Untrusted Contracts (Pre-EIP-1967 Proxies)
The Bug
Early proxy patterns (pre-2020) used raw delegatecall without standardized storage slots. Collisions between proxy and implementation storage corrupt state silently.
// Naive proxy — storage slot 0 collides with implementation's slot 0
contract OldProxy {
address public implementation; // slot 0
fallback() external payable {
(bool s,) = implementation.delegatecall(msg.data);
require(s);
}
}
contract Implementation {
address public owner; // also slot 0 — collision!
}
Real Impact
When implementation is updated, owner gets overwritten. When owner is set, the proxy points to a new (attacker-controlled) implementation.
The Fix: EIP-1967 Standard Slots
// Use deterministic, collision-resistant storage slots
bytes32 private constant IMPL_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
function _getImplementation() internal view returns (address impl) {
bytes32 slot = IMPL_SLOT;
assembly { impl := sload(slot) }
}
4. tx.origin Authentication (All Legacy Versions)
The Bug
Using tx.origin for auth allows any contract in the call chain to impersonate the original sender. A phishing contract just needs the user to call it once.
// VULNERABLE — any contract in the call chain passes this check
function withdraw(uint256 amount) external {
require(tx.origin == owner, "Not owner");
payable(msg.sender).transfer(amount);
}
The Phishing Attack
contract Phisher {
VulnerableContract target;
// User calls this thinking it's legitimate
function claimReward() external {
// tx.origin == user (the real owner)
target.withdraw(target.balance); // drains to Phisher
}
}
The Fix
function withdraw(uint256 amount) external {
require(msg.sender == owner, "Not owner"); // msg.sender, not tx.origin
payable(msg.sender).transfer(amount);
}
5. Missing Return Value Checks on ERC-20 Transfers (Pre-SafeERC20)
The Bug
Some ERC-20 tokens (USDT, BNB, OMG) don't return bool from transfer(). Calling transfer() without checking the return value silently fails, allowing attackers to "pay" with tokens that never actually move.
// VULNERABLE — USDT's transfer returns void, this always "succeeds"
function deposit(uint256 amount) external {
token.transfer(address(this), amount); // may silently fail
balances[msg.sender] += amount; // credited anyway
}
The Fix
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
function deposit(uint256 amount) external {
token.safeTransferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
}
6. Solidity 0.6.x ABIEncoderV2 Edge Cases
The Bug
ABIEncoderV2 in Solidity 0.6.x (experimental) had known bugs where nested dynamic arrays in structs could produce corrupted calldata. This was fixed as default in 0.8.0.
// 0.6.x with ABIEncoderV2 — nested dynamic types can corrupt
pragma experimental ABIEncoderV2;
struct Order {
address maker;
bytes[] signatures; // nested dynamic array
uint256[] amounts;
}
function fillOrders(Order[] calldata orders) external {
// calldata decoding may silently produce wrong values
}
The Impact
Corrupted calldata means the contract processes different values than what was submitted. Prices, amounts, and addresses can all be wrong — silently.
The Fix
Upgrade to 0.8.x where ABIEncoderV2 is stable and default. If you must stay on 0.6.x, avoid nested dynamic types in external function parameters.
The Migration Playbook: 5 Steps to Neutralize Legacy Risk
Step 1: Inventory Every Deployed Contract
// hardhat.config.js — enumerate all deployments
const { ethers } = require("hardhat");
task("audit-versions", "Check compiler versions of deployed contracts")
.setAction(async () => {
const deployments = await hre.deployments.all();
for (const [name, deployment] of Object.entries(deployments)) {
const metadata = JSON.parse(
deployment.metadata || '{"compiler":{"version":"unknown"}}'
);
const version = metadata.compiler?.version || "unknown";
const risk = version.startsWith("0.4") || version.startsWith("0.5")
? "🔴 CRITICAL"
: version.startsWith("0.6") || version.startsWith("0.7")
? "🟡 MEDIUM"
: "🟢 LOW";
console.log(`${risk} ${name}: Solidity ${version} @ ${deployment.address}`);
}
});
Step 2: Automated Vulnerability Scanning
# .github/workflows/legacy-audit.yml
name: Legacy Contract Audit
on: [push]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Slither
uses: crytic/slither-action@v0.4.0
with:
slither-args: >
--detect unchecked-lowlevel,arbitrary-send-eth,
tx-origin,suicidal,unprotected-upgrade
--exclude-dependencies
--json slither-report.json
- name: Check for Critical Findings
run: |
python3 -c "
import json
report = json.load(open('slither-report.json'))
critical = [d for d in report.get('results',{}).get('detectors',[])
if d['impact'] in ['High','Medium']]
if critical:
for c in critical:
print(f'❌ {c[\"check\"]}: {c[\"description\"][:200]}')
exit(1)
print('✅ No critical legacy vulnerabilities found')
"
Step 3: Deploy Behind Upgrade-Safe Proxies
For contracts that can't be recompiled (immutable on-chain), wrap them:
// Guardian wrapper that adds safety checks to legacy contracts
contract LegacyGuardian {
address public immutable legacyContract;
uint256 public constant MAX_MINT = 1_000_000e18;
constructor(address _legacy) {
legacyContract = _legacy;
}
function safePurchase(uint256 amount) external payable {
require(amount <= MAX_MINT, "Amount exceeds safety cap");
require(amount > 0, "Zero amount");
// Forward call with bounds checking
(bool success, bytes memory data) = legacyContract.call{value: msg.value}(
abi.encodeWithSignature("purchase(uint256)", amount)
);
require(success, "Legacy call failed");
}
}
Step 4: On-Chain Monitoring
// Monitor for anomalous interactions with legacy contracts
const ethers = require("ethers");
const LEGACY_CONTRACTS = [
{ address: "0x...", name: "OldBondingCurve", maxTxValue: ethers.parseEther("100") },
];
provider.on("pending", async (txHash) => {
const tx = await provider.getTransaction(txHash);
const target = LEGACY_CONTRACTS.find(c =>
c.address.toLowerCase() === tx?.to?.toLowerCase()
);
if (target && tx.value > target.maxTxValue) {
console.error(`🚨 ALERT: Large tx to ${target.name}: ${ethers.formatEther(tx.value)} ETH`);
// Trigger emergency pause via multisig
}
});
Step 5: Sunset Plan
If a legacy contract holds significant TVL:
- Announce migration timeline (30–90 days)
- Deploy new contract with current Solidity + full audit
- Incentivize migration (gas rebates, bonus yield)
- Pause legacy contract after migration window
- Monitor indefinitely — paused ≠ safe
The Bottom Line
The $50M+ lost to legacy Solidity bugs in Q1 2026 wasn't sophisticated. No flashloans. No cross-chain bridge exploits. Just old code with old bugs that nobody thought to check.
Your audit checklist:
| Check | Tool | Cost |
|---|---|---|
| Compiler version inventory |
hardhat audit-versions task |
Free |
| Static analysis | Slither + custom detectors | Free |
| Arithmetic overflow | Upgrade to 0.8.x or SafeMath audit | 1-2 days |
| Visibility audit | Slither function-visibility
|
Free |
| Proxy storage collision | OpenZeppelin Upgrades plugin | Free |
| Return value checks |
safeTransfer migration |
Hours |
The Truebit attacker didn't need a zero-day. They needed ctrl+F and a Solidity version number. Don't let that be your protocol's epitaph.
This is part of the DeFi Security Research series. Previously: The Step Finance OpSec Playbook, The ZK Circuit Kill Chain.
Top comments (0)