Back to Blog 
The vulnerability:
The vulnerability: attacker-controlled

If you have been trying to learn about potential cases of DoS attacks on smart contracts and keep finding the same generic examples, this article is for you. Here we cover real-life findings reported in auditing contests and actual projects, giving you practical insight into how Denial of Service vulnerabilities appear in production code.
What you will learn
- Real audit findings from competitive auditing platforms
- Five distinct DoS attack vectors with code examples and recommended fixes
- How seemingly innocent design choices can freeze entire protocols
DoS caused by underflow
See also: For a broader look at overflow and underflow vulnerabilities — including silent overflow during type casting and real high/medium audit findings — read Overflow & Underflow in Solidity.
The vulnerability: harvestFees() pushes borrowed USDC above borrow cap
In the
DnGmxJuniorVaultManager contract, the harvestFees() function grants fees to the senior vault by converting WETH to USDC and staking it directly via Aave.The problem is that this increases the junior vault's debt indirectly. If the junior vault is already at its borrow cap, the total borrowed amount exceeds the cap, causing
availableBorrow to underflow and revert.Since
harvestFees() is called every time a user deposits or withdraws from the junior vault, this effectively locks all deposits and withdrawals.1if (_seniorVaultWethRewards > state.wethConversionThreshold) {2 uint256 minUsdcAmount = _getTokenPricsdc(state, state.weth).mulDivDown(3 _seniorVaultWethRewards * (MAX_BPS - state.slippageThresholdSwapEthBps),4 MAX_BPS * PRICE_PRECISION5 );6 // swaps weth into usdc7 (uint256 aaveUsdcAmount, ) = state._swapToken(8 address(state.weth),9 _seniorVaultWethRewards,10 minUsdcAmount11 );12 // supplies usdc into AAVE13 state._executeSupply(address(state.usdc), aaveUsdcAmount);14 // resets senior tranche rewards15 state.seniorVaultWethRewards = 0;16}
The borrowed USDC is calculated based on the aUSDC balance:
1function getUsdcBorrowed() public view returns (uint256 usdcAmount) {2 return uint256(3 state.aUsdc.balanceOf(address(this)).toInt256() -4 state.dnUsdcDeposited -5 state.unhedgedGlpInUsdc.toInt256()6 );7}
And the
availableBorrow function underflows when borrowed exceeds the cap:1function availableBorrow(address borrower) public view returns (uint256 availableAUsdc) {2 uint256 availableBasisCap =3 borrowCaps[borrower] - IBorrower(borrower).getUsdcBorrowed(); // underflow here4 uint256 availableBasisBalance = aUsdc.balanceOf(address(this));5 availableAUsdc = availableBasisCap < availableBasisBalance6 ? availableBasisCap7 : availableBasisBalance;8}
Recommendation
Add a bounds check before the subtraction to prevent underflow:
1function availableBorrow(address borrower) public view returns (uint256 availableAUsdc) {2 uint256 borrowCap = borrowCaps[borrower];3 uint256 borrowed = IBorrower(borrower).getUsdcBorrowed();45 if (borrowed > borrowCap) return 0;67 uint256 availableBasisCap = borrowCap - borrowed;8 uint256 availableBasisBalance = aUsdc.balanceOf(address(this));9 availableAUsdc = availableBasisCap < availableBasisBalance10 ? availableBasisCap11 : availableBasisBalance;12}
DoS caused by gas limit
The vulnerability: unbounded arrays grow indefinitely
A contract tracks deposits using an ever-growing
userDepositsIndex array:1Receipt[] public deposits;2mapping(address => uint256[]) public userDepositsIndex;
Every call to
depositUSDC pushes to the array:1function depositUSDC(uint256 _amount) external {2 require(_amount >= minUSDCAmount, "deposit amount smaller than minimum OTC amount");3 IERC20(usdc).transferFrom(msg.sender, address(this), _amount);45 usdBalance[msg.sender] = usdBalance[msg.sender] + _amount;6 deposits.push(Receipt(msg.sender, _amount));7 userDepositsIndex[msg.sender].push(deposits.length - 1);89 emit USDCQueued(msg.sender, _amount, usdBalance[msg.sender], deposits.length - 1);10}
The critical issue surfaces when
withdrawUSDC iterates over the entire array:1uint256 toRemove = _amount;2uint256 lastIndexP1 = userDepositsIndex[msg.sender].length;3for (uint256 i = lastIndexP1; i > 0; i--) {4 Receipt storage r = deposits[userDepositsIndex[msg.sender][i - 1]];5 if (r.amount > toRemove) {6 r.amount -= toRemove;7 toRemove = 0;8 break;9 } else {10 toRemove -= r.amount;11 delete deposits[userDepositsIndex[msg.sender][i - 1]];12 }13}
After enough deposits, the gas cost of iterating from
lastIndexP1 down to zero exceeds the block gas limit, permanently locking the user's funds.Recommendation
Remove elements from
userDepositsIndex using pop() when deleting deposits, keeping the array bounded and gas costs predictable.DoS caused by nonReentrant modifier
The vulnerability: internal call chain triggers reentrancy guard on itself
In a staking protocol, multiple functions carry the
nonReentrant modifier. The problem arises when unstake() triggers an internal call chain that eventually calls another nonReentrant function:11. HighStreetPoolBase.unstake() (nonReentrant)22. └─ HighStreetCorePool._unstake()33. └─ HighStreetPoolBase._unstake()44. ├─ HighStreetPoolBase._sync()55. └─ HighStreetCorePool._processRewards()66. └─ HighStreetCorePool._processVaultRewards()77. └─ HighStreetCorePool.transferHighToken() (nonReentrant)
When
pendingVaultRewards > 0, the _processVaultRewards function calls transferHighToken:1function _processVaultRewards(address _staker) private {2 User storage user = users[_staker];3 uint256 pendingVaultClaim = pendingVaultRewards(_staker);4 if (pendingVaultClaim == 0) return;56 uint256 highBalance = IERC20(HIGH).balanceOf(address(this));7 require(highBalance >= pendingVaultClaim, "contract HIGH balance too low");89 if (poolToken == HIGH) {10 poolTokenReserve -= pendingVaultClaim > poolTokenReserve11 ? poolTokenReserve12 : pendingVaultClaim;13 }14 user.subVaultRewards = weightToReward(user.totalWeight, vaultRewardsPerWeight);1516 // this call reverts because unstake() already holds the reentrancy lock17 transferHighToken(_staker, pendingVaultClaim);18}
Are you audit-ready?
Download the free Pre-Audit Readiness Checklist used by 30+ protocols preparing for their first audit.
No spam. Unsubscribe anytime.
Since
unstake() already acquired the reentrancy lock, calling transferHighToken() (which also requires the lock) reverts with ReentrancyGuard: reentrant call. This DoS activates whenever there are pending vault rewards — a normal operational state.Recommendation
Remove the
nonReentrant modifier from transferHighToken() since it is already protected by being called within the guarded unstake() flow.See also: To understand whynonReentrantexists and how it prevents actual reentrancy attacks, read our deep-dive on Reentrancy Attacks in Solidity — covering the CEI pattern, single-function guards, and the GlobalReentrancyGuard for cross-contract protection.
DoS caused by external call
The vulnerability: blocked Chainlink oracle reverts all dependent functions
When a protocol relies on Chainlink price feeds without a fallback mechanism, a blocked oracle can freeze the entire system:
1function viewPrice(address token, uint collateralFactorBps) external view returns (uint) {2 if (fixedPrices[token] > 0) return fixedPrices[token];3 if (feeds[token].feed != IChainlinkFeed(address(0))) {4 uint price = feeds[token].feed.latestAnswer();5 require(price > 0, "Invalid feed price");6 // normalize price ...7 }8}
As documented by OpenZeppelin, Chainlink multisigs can block access to price feeds. When
latestAnswer() reverts, every function depending on viewPrice() also reverts — creating a cascading DoS across the protocol.Recommendation
Wrap the oracle call in a
try/catch block with fallback logic:1try feeds[token].feed.latestAnswer() returns (int256 price) {2 // process price normally3} catch Error(string memory) {4 // fallback to alternative price source or cached value5}
DoS caused by malicious receiver
The vulnerability: attacker-controlled supportsInterface reverts critical functions
In this scenario, anyone can purchase a lien token and have it delivered to an arbitrary receiver contract via
buyoutLien:1function buyoutLien(ILienToken.LienActionBuyout calldata params) external {2 // ... validation logic ...3 _transfer(ownerOf(lienId), address(params.receiver), lienId);4}
A malicious entity can purchase a small lien and route it to a contract that implements
supportsInterface() with a deliberate revert. The protocol calls supportsInterface in multiple critical paths, giving the attacker control over those execution flows.Attack surface 1 — Block
endAuction(): Prevents collateral release to auction winners.1for (uint256 i = 0; i < liensRemaining.length; i++) {2 ILienToken.Lien memory lien = LIEN_TOKEN.getLien(liensRemaining[i]);3 if (4 PublicVault(LIEN_TOKEN.ownerOf(i)).supportsInterface(5 type(IPublicVault).interfaceId6 ) // attacker reverts here7 ) {8 PublicVault(LIEN_TOKEN.ownerOf(i)).decreaseYIntercept(lien.amount);9 }10}
Attack surface 2 — Block
liquidate(): Prevents liquidations from starting, allowing unhealthy positions to persist.Attack surface 3 — Block
_payment(): Prevents any lien payments from succeeding when iterating through open liens.Recommendation
Wrap external
supportsInterface calls in a low-level call with a gas limit, or use a try/catch pattern to prevent a single malicious receiver from blocking protocol-wide operations.Key takeaways
| DoS Vector | Root Cause | Impact |
|---|---|---|
| Underflow | Missing bounds check before subtraction | Vault deposits/withdrawals frozen |
| Gas limit | Unbounded array iteration | User funds permanently locked |
| nonReentrant | Internal call chain triggers own guard | Core staking functions revert |
| External call | No fallback for oracle failure | All price-dependent functions frozen |
| Malicious receiver | Unchecked external interface call | Auctions, liquidations, payments blocked |
Going through audit reports is one of the best ways to learn about new exploit patterns. You can use platforms like Solodit to browse findings from competitive auditing platforms and study real vulnerabilities discovered in production protocols.
Get in touch
At Zealynx, we specialize in identifying complex vulnerabilities like Denial of Service attacks that can freeze entire protocols. Whether you are building a DeFi protocol, preparing for an audit, or looking to harden your smart contracts against these attack vectors, our team is ready to assist — reach out.
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: Denial of Service attacks on smart contracts
1. What is a Denial of Service attack in the context of smart contracts?
A DoS attack on a smart contract is any exploit that prevents legitimate users from interacting with the contract as intended. Unlike traditional DoS attacks that flood servers with traffic, smart contract DoS attacks exploit logic flaws, gas limitations, or external dependencies to make functions permanently revert or become prohibitively expensive to call.
2. How does an underflow cause a DoS?
In Solidity 0.8+, arithmetic underflow automatically reverts the transaction. If a subtraction like
borrowCap - borrowed produces a negative result because borrowed exceeds the cap, the function reverts every time it is called. When this function is in a critical path (like deposits or withdrawals), the entire feature becomes unusable.3. Why are unbounded loops dangerous in smart contracts?
Every operation in a loop costs gas, and Ethereum blocks have a gas limit. If a loop iterates over an array that grows with each user action, eventually the gas cost of a single transaction exceeds the block gas limit. At that point, the function can never complete, permanently locking any funds or state it controls.
4. Can the nonReentrant modifier itself cause issues?
Yes. The
nonReentrant modifier uses a lock that prevents the same contract from being re-entered during execution. If an internal call chain passes through two functions that both have this modifier, the second function will revert because the lock is already held. This is a design bug, not a reentrancy attack.5. How can protocols protect against oracle-based DoS?
Protocols should wrap oracle calls in try/catch blocks and implement fallback mechanisms such as cached prices, secondary oracles (like Uniswap TWAP), or circuit breakers that pause affected functions gracefully instead of reverting. Never assume an external dependency will always be available.
6. What makes the malicious receiver attack particularly dangerous?
It is dangerous because the attacker only needs to purchase a minimal lien to gain the ability to block protocol-wide operations like auctions, liquidations, and payments. The attack surface is amplified because the protocol checks
supportsInterface on the lien owner in multiple critical code paths, allowing a single malicious contract to DoS several independent features simultaneously.Glossary
| Term | Definition |
|---|---|
| Denial of Service (DoS) | An attack that makes a smart contract function unusable by causing it to consistently revert or exceed gas limits. |
| Underflow | An arithmetic error where a subtraction produces a negative result, causing an automatic revert in Solidity 0.8+. |
| Reentrancy Guard | A modifier pattern (nonReentrant) that prevents a function from being called again while it is still executing, using a mutex lock. |
| Gas Limit | The maximum amount of gas a single transaction or block can consume on Ethereum, creating an upper bound on computation per transaction. |
| Oracle | An external data feed (like Chainlink) that provides off-chain information such as asset prices to on-chain smart contracts. |
Is Your Protocol Exposed to DoS Vectors?
Gas griefing, unbounded loops, and malicious receiver attacks are among the most overlooked attack surfaces in EVM audits — and the hardest to catch with automated tools alone. If your contracts handle external calls, dynamic arrays, or auction/liquidation mechanics, a targeted review could prevent protocol-wide lockups.
Are you audit-ready?
Download the free Pre-Audit Readiness Checklist used by 30+ protocols preparing for their first audit.
No spam. Unsubscribe anytime.


