$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

21 Sneaky Solidity Bugs to Catch Before You Deploy

Updated 9 min read

Quick answer. The sneakiest Solidity bugs are small details that keep reaching audits. Watch for struct deletes that leave mappings behind, lost immutables, silent downcasts, msg.value in loops, stale prices, fee-on-transfer tokens and off by one errors.

Solidity bugs come in almost unlimited variety. Since contracts often hold value directly, each one is worth hunting down before launch.

The 21 Solidity bugs below keep coming up in audits we have run, in contest reports and in private audit reports.

Some are niche. Others apply to almost any codebase. Learn each one so you can avoid it when you build, or flag it when you review.

Storage and Arithmetic

1. Deleting a struct does not delete its mappings

When a struct holds a mapping, delete on the struct does not clear that mapping. Data that points elsewhere is not deleted along with the top-level struct.

Picture a contract that stores objects in a mapping, where each object holds its own inner mapping. Set a value to 4 and you read 4. Set it to 2 and you read 2.

Now delete the object and read again. You still get 2.

struct Obj {
    bool exists;
    mapping(uint256 => uint256) values;
}
mapping(uint256 => Obj) internal objects;

function set(uint256 id, uint256 key, uint256 v) external {
    objects[id].exists = true;
    objects[id].values[key] = v;
}

function remove(uint256 id) external {
    delete objects[id]; // exists is reset, values[key] is not
}

Any logic that treats a deleted object as empty will read stale values.

2. Immutable values are not kept on upgrade

Immutable variables are written into the contract's bytecode when the constructor runs. That saves gas, but the value is not in storage.

When you upgrade, the new implementation's bytecode does not carry those values over. Code that relied on the old immutables will not see them after the upgrade.

3. Subtractions that underflow and revert

Solidity 0.8 reverts when a uint subtraction underflows. A flaw in the logic around any subtraction can become a denial of service.

Check every subtraction, including every -=. Ask whether an underflow revert is expected, or whether it could block an action that must stay possible.

4. Downcasting can still overflow

Newer Solidity versions revert with a panic code on arithmetic overflow, so it is tempting to stop worrying. Downcasting is the exception.

Casting a uint256 to a uint8 does not revert when the value does not fit. It silently overflows.

Use a library such as OpenZeppelin's SafeCast if you want the downcast to revert, which you almost always do.

uint256 big = 300;
uint8 small = uint8(big);           // 44: no revert
uint8 safe = SafeCast.toUint8(big); // reverts instead

Ether, Data and Memory

5. No way to withdraw received ether

This one is basic, yet it keeps showing up in real audits and probably in deployed contracts. It has two sides.

First, a contract meant to receive ether needs a receive or fallback function. Teams often forget that a contract cannot receive ether by default.

Second, contracts that do have a receive function often have no way to withdraw what they received.

Imagine deploying, collecting 100 ether, and finding no way to get it out. Every contract that expects ether needs a withdrawal path.

6. Parallel data structures

Parallel data structures cause bugs in all kinds of software. In a smart contract they can be critical.

One caught us early on, while building an optimizer vault protocol. We kept an array of structs, each one a user's request to withdraw in the next epoch.

A separate mapping went from a request ID to its struct. Each struct stored its own index in the array.

To remove a request, we moved the request at the last index into the removed request's slot, then popped the array.

The removed request was handled correctly. The moved request's stored index was never updated.

function _remove(uint256 id) internal {
    uint256 i = indexOf[id];
    queue[i] = queue[queue.length - 1]; // move the last request into the gap
    queue.pop();
    delete indexOf[id];
    // Missing: indexOf[queue[i].id] = i;
}

Users whose index went stale could not withdraw. If your design even resembles this, reconsider it. There is almost always a better way.

7. Typos

It sounds trivial, but plenty of plain critical bugs come from typos alone.

Do a final pass over everything, line by line. Pay attention to the details, not only the high-level attack surface.

8. mstore does not update the free memory pointer

In Yul, mstore writes to memory but does not move the free memory pointer. A later read of the pointer will not account for what you stored.

This matters most when normal Solidity code runs after the assembly block. That code will likely overwrite the memory the assembly block wrote.

9. Using transfer or send

transfer and send forward only 2,300 gas.

At the time of recording (2023), many multisig and smart contract wallets used more than that in their receive function. These calls were simply incompatible with them.

Math, Units and Calls

10. Division symbols

Every division symbol deserves attention for two reasons: precision loss and divide by zero reverts. Both are among the most common vulnerability classes.

Solidity truncates, so precision loss is often exactly the knob an attacker needs.

Investigate every division and consider both cases. Confirm any precision loss is truly trivial. Our guide to Solidity rounding errors goes deeper.

11. Handling units wrong

Unit bugs include treating every token as if it has 18 decimals. They also include adding precision a variable already has, or forgetting to divide out a precision you multiplied in.

Per-share calculations are a classic case. Contracts often store a magnified per-share value.

Then, in some subset of functions, they forget to divide by the magnification to bring the number back down.

12. Assuming every address can accept ether or tokens

If a function must succeed and it sends ether to an address, that address can block it by not accepting ether. Hopefully this is not inside a loop.

It does not need to be malicious. Plenty of contracts and addresses do not accept ether by default.

The same applies to ERC-20 tokens when an address is blacklisted, as our weird ERC-20 tokens checklist explains.

13. Loading the return value of .call

A plain .call is not a bug in itself, but it opens a gas griefing vector. Solidity copies all of the returned data into memory.

Say a keeper executes a transaction that calls an arbitrary address. That address can return an enormous amount of data.

Memory expansion gets more expensive the more memory you use, so the copy can cost far more gas than expected.

The fix is a low-level call in Yul that does not load the return data into memory.

bool success;
assembly {
    // out = 0, outsize = 0: return data is never copied to memory
    success := call(gas(), target, 0, add(data, 0x20), mload(data), 0, 0)
}

Loops, Inputs and Auth

14. Using for loops to push rather than pull

When a system must get tokens to many addresses, let each address come and claim its share. Do not push to every address in a loop.

A loop over a list of addresses uses an unbounded amount of gas. An attacker, or ordinary use, can add enough addresses to push it past the block gas limit.

The function then becomes unexecutable. Everyone expecting funds that way suffers a complete loss.

Recipients that return huge amounts of data (bug 13) make it worse. Pushing tokens or ether in a for loop is almost always a bad idea.

15. Using msg.value in a loop

We once saw this in a protocol that had a full OpenZeppelin audit. After the audit, the team changed its code and introduced this bug, layered across several functions.

msg.value does not change as a loop runs. Credit the sender with msg.value on each of five iterations, and a one ether deposit counts as five.

function buyMany(uint256[] calldata ids) external payable {
    for (uint256 i; i < ids.length; i++) {
        credit[msg.sender] += msg.value; // counted on every pass
    }
}

Never use msg.value in a loop.

A related code smell is a function that takes an amount parameter while also reading msg.value. It is not wrong in itself, but it is unnecessary, so see whether amount can go.

16. Decoding arbitrary bytes from an untrusted address

Bytes from an untrusted address can be dangerous to use at all. You do not know what they look like at a low level, or how they were shaped.

Decoding them is especially risky. In one Yul exploit, a contract decoding arbitrary revert bytes was fooled into parsing an enormously long string that did not exist.

Our Yul and memory guide walks through it.

17. tx.origin used for authentication

Slither commonly flags this one. Never use tx.origin for authentication.

If a user interacts with a malicious contract, that contract can call your system and pass as the user, because the user is still tx.origin. It is a web3 phishing attack.

Prices, Tokens and Chains

18. Not validating price freshness

Validate every oracle price result. With Chainlink, check that the answer comes from a recent timestamp.

Each aggregator has a configured heartbeat, and the price should have updated within it.

(, int256 answer, , uint256 updatedAt, ) = feed.latestRoundData();
require(block.timestamp - updatedAt <= HEARTBEAT, "stale price");

If the oracle is down or out of date, have a fallback oracle or another mechanism that limits any arbitrage the stale price allows.

On a layer 2 such as Arbitrum, also check whether the sequencer is down. If it is, prices are likely out of date too.

19. Fee-on-transfer tokens

Fee-on-transfer and rebasing tokens change balances on transfer. They break almost every system they touch, and most teams simply rule them out.

Systems with trustless deployment of new markets or pools almost have to consider them. Add balance checks so the system confirms it received the amount it accounts for.

20. Off by one errors

This class depends on application logic, and it shows up in almost every codebase, Solidity or not.

RareSkills has a good example. Users stake and unstake, and the contract keeps an array of stakers.

On unstake, the sender is removed from the array first. Rewards are then sent by dividing the total rewards since the last claim by the array's length.

function unstake() external {
    _removeStaker(msg.sender); // stakers.length drops by one here
    _sendRewards(msg.sender);  // divides by the wrong count
}

function _sendRewards(address to) internal {
    uint256 share = rewardsSinceLastClaim / stakers.length;
    // ... transfer share to `to`
}

The length was decremented before the reward was computed from it. Keep a close eye on every line of logic for this pattern.

21. Chain compatibility

Deploying the same contracts across many chains brings considerations for each chain:

  • Wrapped native tokens differ. On some chains the wrapped native token uses .transfer with limited gas. On others it uses .call with unlimited gas.
  • Extra functions are not universal. Wrapped ether on Arbitrum adds functions such as withdrawTo, which unwraps and sends to an address in one call. Most wrapped native tokens do not have it.
  • Hardcoded addresses must be updated for each chain.
  • Block times differ. On an L2 you may need a contract like ArbSys to read a block number that is accurate to the L2.

Those are just a few. Evaluate your contracts specifically for each chain they will be deployed on.

How Guardian Helps

Guardian's smart contract audits start with a design review and an invariant suite built around accounting, rounding and solvency.

Senior researchers then attack what both leave standing. With Vanguard, two independent teams review the same scope and cross-check each other's findings.

Frequently Asked Questions

Does deleting a struct in Solidity clear its mappings?

No. Deleting a struct resets its other members, but a mapping inside it keeps its values. Logic that treats a deleted object as empty will read stale data.

Is overflow still possible in Solidity 0.8?

Yes, when downcasting. Casting a uint256 to a smaller type such as uint8 does not revert. Use a library such as OpenZeppelin's SafeCast so the downcast reverts.

Why should you avoid msg.value in a loop?

msg.value stays the same on every iteration. A loop that credits it each time turns a one ether payment over five iterations into five ether of credit.

Why is tx.origin unsafe for authentication?

A malicious contract the user interacts with can call your system while the user is still tx.origin. A tx.origin check lets it pass as the user.

Sources

Catch these bugs before launch with Guardian

Guardian's researchers and invariant suites hunt for exactly these details before your code holds value.