Smart_Overflow picoCTF 2026 Solution

Published: March 20, 2026

Description

Welcome! The contract tracks balances using uint256 math. It should be impossible to get the flag... Contract: here

Download and read IntOverflowBank.sol.

Note the Solidity version - versions before 0.8.0 do not check for arithmetic overflow.

bash
cat IntOverflowBank.sol
  1. Step 1Identify the integer underflow
    The bug is the >= check itself. require(balances[msg.sender] - _amount >= 0) looks like a range guard but is a tautology: uint256 cannot go negative, so the subtraction wraps before the comparison runs. The check is structurally impossible to fail.
    bash
    cat IntOverflowBank.sol
    Learn more

    Integer underflow in unsigned integer arithmetic occurs when a subtraction produces a result below zero, which wraps around to the maximum value for that type. For uint256, subtracting 1 from 0 yields 2^256 - 1 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 - an astronomically large number. This is not a bug in the CPU; it is defined behavior in unsigned arithmetic, inherited from how binary subtraction works in hardware.

    The vulnerability is in the guard condition: require(balances[msg.sender] - _amount >= 0) is supposed to prevent withdrawing more than your balance. But since both operands are uint256, the subtraction underflows to a huge positive number before the >= 0 comparison is evaluated. The comparison is always true - the require never reverts. The developer intended a signed integer check (int256), but unsigned types cannot represent negative numbers, making this check semantically meaningless.

    This exact vulnerability class (integer overflow/underflow in Solidity pre-0.8.0) has caused dozens of real-world exploits in deployed DeFi protocols. The BatchOverflow bug in 2018 affected multiple ERC-20 tokens and allowed attackers to mint astronomical token quantities by exploiting unsigned integer overflow in transfer batch operations. Solidity 0.8.0 (released December 2020) introduced built-in overflow checks that revert the transaction on overflow/underflow, eliminating the class entirely - unless developers explicitly use unchecked {} blocks to opt out for gas efficiency.

  2. Step 2Underflow: transfer 1 from a zero balance
    Call the transfer function with an amount of 1 when your balance is 0. The uint256 underflows to the maximum value (2^256-1), giving you an astronomically large balance and triggering the win condition.
    bash
    # Set up the environment first:
    bash
    export RPC_URL=http://<HOST>:<PORT_FROM_INSTANCE>
    bash
    export CONTRACT=0xYourDeployedContractAddress
    bash
    export YOUR_ADDR=0xYourWalletAddress
    bash
    export PRIVATE_KEY=0xYourPrivateKey
    bash
    # Check your initial balance (should be 0):
    bash
    cast call $CONTRACT "balances(address)" $YOUR_ADDR --rpc-url $RPC_URL
    bash
    # Trigger the underflow by transferring 1 token from a 0-balance account:
    bash
    cast send $CONTRACT "transfer(address,uint256)" 0x0000000000000000000000000000000000000001 1 \
      --private-key $PRIVATE_KEY --rpc-url $RPC_URL
    bash
    # Verify your balance is now max uint256:
    bash
    cast call $CONTRACT "balances(address)" $YOUR_ADDR --rpc-url $RPC_URL
    Learn more

    Foundry's cast tool is a command-line Ethereum Swiss Army knife. cast call makes a read-only call (free, no gas, no state change) while cast send submits a signed transaction that modifies blockchain state (costs gas, requires a private key). The ABI signature format "transfer(address,uint256)" specifies the function name and parameter types, which Foundry uses to ABI-encode the calldata correctly.

    The recipient address 0x0000...0001 (the "burn" or null address with value 1) is used as a dummy recipient because we do not care about the transfer destination - the goal is just to trigger the underflow in the sender's balance mapping. The Solidity storage mapping balances[msg.sender] is updated by balances[msg.sender] -= _amount, which underflows to 2^256 - 1.

    The win condition check varies by challenge - some contracts expose isSolved(), others check directly in the flag function. This pattern of "prove a condition to get the flag" mirrors real-world bug bounties where demonstrating proof-of-concept (showing you can drain the contract or bypass the access control) is required to claim the reward.

  3. Step 3Claim the flag
    With your balance now at max uint256, the contract's win condition is met. Call the flag retrieval function.
    bash
    cast call $CONTRACT "isSolved()(bool)" --rpc-url $RPC_URL
    bash
    cast call $CONTRACT "getFlag()(string)" --rpc-url $RPC_URL
    Learn more

    Smart contract CTF challenges commonly follow a pattern where the flag is stored on-chain and gated behind a condition. The getFlag() function returns the flag string only when isSolved() returns true. Since both are view functions (read-only, no state change), they can be called with cast call without spending gas.

    In real-world blockchain security auditing, demonstrating that a vulnerability meets the exploit criteria (e.g., draining funds to zero, bypassing access control) is the standard for proof-of-concept. Audit platforms like Immunefi host blockchain bug bounties worth millions of dollars, and finding integer overflow vulnerabilities in production contracts can earn significant rewards. Understanding these Solidity-specific pitfalls is essential knowledge for anyone working in Web3 security.

Flag

picoCTF{sm4rt_0v3rfl0w_...}

Silent underflow: `require(balances[sender] - amount >= 0)` always passes on uint256. Transferring 1 from a zero balance wraps to ~10^77 (specifically 2^256 - 1 = 115792089237316195423570985008687907853269984665640564039457584007913129639935), instantly satisfying the win condition.

How to prevent this

Solidity 0.8.0 made arithmetic checked by default. Pinning to an older pragma is the bug. See the Smart Contract CTF Bugs guide for the full pattern catalog.

  • Use pragma solidity ^0.8.0 or newer. Arithmetic operations now revert on overflow/underflow without needing SafeMath. There is essentially no reason to deploy on older versions.
  • For contracts stuck on legacy compilers, use OpenZeppelin's SafeMath. The diff is small but load-bearing:
    // Vulnerable (pre-0.8.0, no SafeMath)
    require(balances[sender] - amount >= 0); // tautology
    balances[sender] = balances[sender] - amount; // wraps
    
    // Safe (with SafeMath)
    balances[sender] = balances[sender].sub(amount); // reverts on underflow
  • Run Slither and Echidna against every contract before deploy. Both detect classic patterns like unchecked subtraction, signed/unsigned comparisons, and divide-before-multiply. Add Foundry invariant tests asserting totalSupply == sum(balances) after every transaction.

Want more picoCTF 2026 writeups?

Useful tools for Blockchain

Related reading

What to try next