December 2, 2025
|
Developer-First Security

Solidity Security Best Practices: Avoiding the Top 10 Vulnerabilities

Smart contract vulnerabilities have cost the DeFi ecosystem billions of dollars in exploits and hacks. In 2024 alone, over $2.3 billion was lost to smart contract vulnerabilities, with the vast majority of exploited contracts having undergone security audits. Understanding and implementing Solidity security best practices isn't just recommended; it's essential for protecting user funds and maintaining protocol integrity.

This comprehensive guide explores the ten most critical vulnerabilities in Solidity smart contracts and provides actionable best practices to prevent them. Whether you're a seasoned blockchain developer or building your first DeFi protocol, these security patterns will help you write more secure smart contracts.

Why Solidity Security Matters

Unlike traditional software, smart contracts are immutable once deployed and control significant financial assets. A single vulnerability can result in permanent loss of funds with no recourse for recovery. The permissionless nature of blockchain means attackers can freely analyze your code and exploit weaknesses without authorization barriers.

Recent high-profile exploits demonstrate the stakes: the Balancer $121M exploit, while smaller protocols like Abracadabra Money lost $1.8 million and Kame Aggregator suffered a $1.3 million exploit, all from preventable smart contract vulnerabilities.

1. Reentrancy Attacks: The Most Notorious Vulnerability

What is Reentrancy?

Reentrancy occurs when a smart contract calls an external contract before updating its own state, allowing the external contract to call back into the original function and manipulate its logic. This vulnerability famously enabled the 2016 DAO hack, which resulted in $60 million in losses and ultimately led to the Ethereum hard fork.

How Reentrancy Works

When your contract sends ETH or calls an external contract, it transfers execution control to that external address. If the external contract is malicious, it can call back into your contract before the original function completes, potentially draining funds or manipulating state variables.

Prevention: The Checks-Effects-Interactions Pattern

The most fundamental defense against reentrancy is following the checks-effects-interactions pattern:

Vulnerable Code:

function withdraw(uint256 amount) public {
   require(balances[msg.sender] >= amount, "Insufficient balance");
   (bool success, ) = msg.sender.call{value: amount}(""); // Interaction first
   require(success, "Transfer failed");
   balances[msg.sender] -= amount; // State update after interaction
}

Secure Code:

function withdraw(uint256 amount) public {
   require(balances[msg.sender] >= amount, "Insufficient balance");
   balances[msg.sender] -= amount; // State update before interaction
   (bool success, ) = msg.sender.call{value: amount}("");
   require(success, "Transfer failed");
}

Additional Reentrancy Protections

Implement the ReentrancyGuard modifier from OpenZeppelin for an extra layer of security:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeContract is ReentrancyGuard {
   function withdraw(uint256 amount) public nonReentrant {
       // Your withdrawal logic
   }
}

2. Integer Overflow and Underflow

Understanding Integer Vulnerabilities

Before Solidity 0.8.0, arithmetic operations could silently overflow or underflow, wrapping around to unexpected values. An overflow occurs when a number exceeds its maximum value, while underflow happens when a number goes below zero.

The Solution: Built-in Overflow Protection

Solidity 0.8.0 and later versions include automatic overflow and underflow checking by default. However, if you're using unchecked blocks for gas optimization or working with older code, remain vigilant.

Vulnerable Code (Solidity < 0.8.0):

uint256 balance = 0;
balance -= 1; // Underflows to 2^256 - 1

Secure Code:

// Solidity >= 0.8.0 with automatic checks
uint256 balance = 0;
balance -= 1; // Reverts with panic error

// Or use SafeMath explicitly in older versions
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
using SafeMath for uint256;

balance = balance.sub(1); // Reverts on underflow

Best Practices for Integer Safety

Always use Solidity 0.8.0 or later unless you have specific reasons not to. If you must use unchecked blocks for gas optimization, thoroughly document your reasoning and ensure the operations are mathematically safe. Never use unchecked arithmetic with user-supplied inputs without extensive validation.

3. Access Control Vulnerabilities

The Critical Importance of Access Control

Access control vulnerabilities occur when functions that should be restricted to specific roles are accessible to unauthorized users. These vulnerabilities can allow attackers to mint unlimited tokens, pause protocols, upgrade contracts maliciously, or drain funds.

Common Access Control Mistakes

Vulnerable Code:

address public owner;

function updateCriticalParameter(uint256 newValue) public {
   criticalParameter = newValue; // No access control!
}

Secure Code:

import "@openzeppelin/contracts/access/Ownable.sol";

contract SecureContract is Ownable {
   function updateCriticalParameter(uint256 newValue) public onlyOwner {
       criticalParameter = newValue;
   }
}

Implementing Role-Based Access Control

For complex protocols, implement role-based access control using OpenZeppelin's AccessControl:

import "@openzeppelin/contracts/access/AccessControl.sol";

contract TokenContract is AccessControl {
   bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
   bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
   
   constructor() {
       _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
   }
   
   function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
       _mint(to, amount);
   }
   
   function pause() public onlyRole(PAUSER_ROLE) {
       _pause();
   }
}

Access Control Security Checklist

Ensure every privileged function has appropriate access controls. Use established libraries like OpenZeppelin rather than rolling your own access control logic. Implement multi-signature requirements for critical operations. Document all roles and permissions clearly. Consider implementing timelock mechanisms for sensitive parameter changes.

4. Front-Running and Transaction Ordering Attacks

What is Front-Running?

Front-running occurs when attackers observe pending transactions in the mempool and submit their own transactions with higher gas prices to execute first. This can be exploited in DEXs, NFT mints, governance votes, and any scenario where transaction order matters.

Types of Front-Running Attacks

Displacement attacks involve an attacker copying your transaction and submitting it with higher gas to claim a reward or opportunity meant for you. Insertion attacks place malicious transactions before and after yours to manipulate prices or states. Suppression attacks involve paying high gas fees to delay or prevent your transaction from executing.

Mitigation Strategies

Commit-Reveal Schemes:

mapping(address => bytes32) public commitments;
mapping(address => uint256) public commitTimestamps;

function commit(bytes32 commitment) public {
   commitments[msg.sender] = commitment;
   commitTimestamps[msg.sender] = block.timestamp;
}

function reveal(uint256 value, bytes32 salt) public {
   require(block.timestamp >= commitTimestamps[msg.sender] + 1 hours, "Too early");
   require(commitments[msg.sender] == keccak256(abi.encodePacked(value, salt)), "Invalid");
   // Execute action with value
}

Slippage Protection:

function swap(
   uint256 amountIn,
   uint256 minAmountOut, // Slippage protection
   address[] calldata path
) public {
   uint256 amountOut = getAmountOut(amountIn, path);
   require(amountOut >= minAmountOut, "Slippage exceeded");
   // Execute swap
}

Advanced Front-Running Defenses

Implement private transaction pools or flashbots bundles for sensitive operations. Use time-weighted average prices (TWAP) instead of spot prices for critical calculations. Consider batch auctions where all transactions in a block execute at the same price. For governance, implement time delays between proposal and execution.

5. Denial of Service (DoS) Attacks

Understanding DoS Vulnerabilities

DoS attacks in smart contracts aim to make protocol functions unusable or prohibitively expensive to execute. Unlike traditional DoS attacks that overwhelm servers, blockchain DoS exploits typically abuse gas limits, failed external calls, or unexpected reverts.

Common DoS Patterns

Unbounded Loops:

// Vulnerable: Can exceed gas limits
function payAllUsers() public {
   for(uint i = 0; i < users.length; i++) {
       users[i].transfer(1 ether); // Could run out of gas
   }
}

Secure Alternative:

// Pull payment pattern
mapping(address => uint256) public pendingWithdrawals;

function claim() public {
   uint256 amount = pendingWithdrawals[msg.sender];
   require(amount > 0, "Nothing to claim");
   pendingWithdrawals[msg.sender] = 0;
   (bool success, ) = msg.sender.call{value: amount}("");
   require(success, "Transfer failed");
}

DoS Prevention Best Practices

Avoid unbounded loops that iterate over user-controlled arrays. Implement pull payment patterns instead of push payments. Use pagination or limits for array operations. Never make external calls in loops. Implement circuit breakers or pause functionality for emergency situations.

Gas-Efficient Batch Processing:

function processBatch(uint256 start, uint256 end) public {
   require(end - start <= 50, "Batch too large"); // Limit batch size
   for(uint256 i = start; i < end; i++) {
       // Process items
   }
}

6. Oracle Manipulation and Price Feed Attacks

The Oracle Problem

Smart contracts cannot directly access off-chain data and must rely on oracles for information like asset prices, weather data, or sports scores. Oracle manipulation has become one of the most exploited attack vectors in DeFi, often resulting in flash loan attacks that drain entire protocols.

Vulnerable Oracle Usage

Single Source Price Feed:

// Vulnerable: Single point of failure
function getLoanAmount(uint256 collateral) public view returns (uint256) {
   uint256 price = priceOracle.getPrice();
   return collateral * price;
}

Secure Multi-Oracle Approach:

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract SecurePricing {
   AggregatorV3Interface[] public priceFeeds;
   uint256 public constant MAX_PRICE_DEVIATION = 5; // 5% max deviation
   
   function getSecurePrice() public view returns (uint256) {
       require(priceFeeds.length >= 3, "Insufficient oracles");
       
       uint256[] memory prices = new uint256[](priceFeeds.length);
       for(uint i = 0; i < priceFeeds.length; i++) {
           (, int256 price,,,) = priceFeeds[i].latestRoundData();
           prices[i] = uint256(price);
       }
       
       uint256 medianPrice = calculateMedian(prices);
       validatePriceDeviation(prices, medianPrice);
       return medianPrice;
   }
}

Oracle Security Best Practices

Use multiple independent oracle sources and calculate median or weighted average prices. Implement price deviation checks to detect manipulation attempts. Use time-weighted average prices (TWAP) instead of spot prices for critical operations. Validate oracle data freshness and check heartbeat intervals. Consider using decentralized oracle networks like Chainlink rather than single-source oracles.

7. Delegatecall and Proxy Vulnerabilities

Understanding Delegatecall Risks

The delegatecall function executes code from another contract in the context of the calling contract, including its storage. While powerful for implementing upgradeable contracts, improper use can lead to complete contract takeover.

Storage Collision Vulnerabilities

Vulnerable Proxy Pattern:

// Proxy contract
contract Proxy {
   address public implementation; // Storage slot 0
   
   fallback() external payable {
       address impl = implementation;
       assembly {
           delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
       }
   }
}

// Implementation contract
contract Implementation {
   address public owner; // Also storage slot 0, collision!
}

Secure Proxy Pattern:

// Use OpenZeppelin's transparent proxy pattern
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";

// Or ensure storage layout consistency
contract Proxy {
   address public implementation; // Slot 0
   address public admin;          // Slot 1
}

contract Implementation {
   address public implementation; // Slot 0, matches proxy
   address public admin;          // Slot 1, matches proxy
   address public owner;          // Slot 2, no collision
}

Delegatecall Security Checklist

Never delegatecall to user-supplied addresses. Ensure storage layout compatibility between proxy and implementation contracts. Use established proxy patterns from OpenZeppelin. Implement initialization functions instead of constructors for upgradeable contracts. Thoroughly audit all upgradeable contract systems.

8. Randomness Manipulation

The Blockchain Randomness Problem

Generating secure randomness on-chain is notoriously difficult because all blockchain data is public and deterministic. Many developers mistakenly use block.timestamp, block.number, or blockhash for randomness, all of which can be manipulated by miners or predicted by attackers.

Insecure Randomness Examples

Vulnerable Code:

// NEVER do this!
function randomWinner() public {
   uint256 random = uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender)));
   winner = participants[random % participants.length];
}

Secure Randomness with Chainlink VRF

import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";

contract SecureLottery is VRFConsumerBase {
   bytes32 internal keyHash;
   uint256 internal fee;
   uint256 public randomResult;
   
   function getRandomNumber() public returns (bytes32 requestId) {
       require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK");
       return requestRandomness(keyHash, fee);
   }
   
   function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
       randomResult = randomness;
       // Use randomResult for fair selection
   }
}

Randomness Best Practices

Use Chainlink VRF or similar verifiable random functions for any application requiring unpredictability. Never use block.timestamp, block.number, or blockhash alone for randomness. For less critical applications, combine multiple entropy sources including future block hashes. Consider commit-reveal schemes for user-generated randomness.

9. Unhandled External Call Failures

Silent Failures in External Calls

External calls in Solidity can fail for numerous reasons including insufficient gas, revert in the called contract, or non-existent contract addresses. The call and send functions return false on failure rather than reverting, which can lead to logic errors if not properly handled.

Vulnerable External Call Handling

Insecure Code:

function distributeRewards(address recipient, uint256 amount) public {
   // Silent failure, doesn't check return value
   recipient.call{value: amount}("");
   rewardsDistributed[recipient] = true; // Incorrectly marked as distributed
}

Secure Code:

function distributeRewards(address recipient, uint256 amount) public {
   (bool success, ) = recipient.call{value: amount}("");
   require(success, "Reward distribution failed");
   rewardsDistributed[recipient] = true;
}

Best Practices for External Calls

Always check the return value of call, send, and delegatecall. Use transfer for ETH transfers when you want automatic revert behavior. However, note that transfer has a fixed gas stipend of 2300 gas, which may not be sufficient for contracts with complex receive functions. Implement pull payment patterns to avoid external call failures blocking critical functionality.

Recommended Pattern:

function safeTransfer(address to, uint256 amount) internal {
   (bool success, ) = to.call{value: amount}("");
   if (!success) {
       pendingWithdrawals[to] += amount; // Allow pull withdrawal
       emit TransferFailed(to, amount);
   }
}

10. Flash Loan Attacks and Economic Exploits

Understanding Flash Loan Vulnerabilities

Flash loan attacks have become increasingly sophisticated, allowing attackers to borrow massive amounts of capital without collateral and manipulate protocol economics within a single transaction. While flash loans themselves are neutral tools, they amplify the impact of other vulnerabilities.

Common Flash Loan Attack Vectors

Flash loans enable price oracle manipulation by allowing attackers to create artificial supply or demand in liquidity pools. They can exploit governance mechanisms by temporarily acquiring voting power. They amplify reentrancy and logic errors by providing unlimited capital for attacks. They enable arbitrage of improperly validated price feeds.

Protecting Against Flash Loan Attacks

Multi-Block Requirements:

mapping(address => uint256) public lastActionBlock;

function criticalOperation() public {
   require(block.number > lastActionBlock[msg.sender] + 1, "Wait one block");
   lastActionBlock[msg.sender] = block.number;
   // Execute operation
}

TWAP Implementation:

contract TWAPOracle {
   uint256 public constant PERIOD = 30 minutes;
   
   struct Observation {
       uint256 timestamp;
       uint256 price;
   }
   
   Observation[] public observations;
   
   function getTWAP() public view returns (uint256) {
       require(observations.length >= 2, "Insufficient observations");
       
       uint256 timeWeightedSum = 0;
       uint256 totalTime = 0;
       
       for(uint i = 1; i < observations.length; i++) {
           uint256 timeDelta = observations[i].timestamp - observations[i-1].timestamp;
           timeWeightedSum += observations[i-1].price * timeDelta;
           totalTime += timeDelta;
       }
       
       return timeWeightedSum / totalTime;
   }
}

Flash Loan Defense Strategies

Implement time-weighted average prices rather than spot prices. Require multi-block delays for critical operations. Use decentralized oracle networks resistant to manipulation. Implement maximum transaction size limits. Consider deposit lockup periods for governance tokens. Monitor for suspicious activity patterns and implement circuit breakers.

Comprehensive Security Checklist

Development Phase

Use the latest stable Solidity version with built-in overflow protection. Follow the checks-effects-interactions pattern consistently. Implement comprehensive access controls using established libraries. Use OpenZeppelin contracts for standard functionality. Write extensive test coverage including edge cases and attack scenarios. Document all assumptions and invariants clearly.

Testing Phase

Conduct thorough unit testing of all functions. Implement integration tests simulating real-world scenarios. Perform fuzz testing to identify unexpected behavior. Test all access control mechanisms thoroughly. Simulate attack scenarios including reentrancy, oracle manipulation, and flash loan attacks. Test upgrade mechanisms if using proxy patterns.

Pre-Deployment Phase

Conduct multiple independent security audits from reputable firms. Implement findings from audits comprehensively. Consider formal verification for critical contracts. Set up monitoring and alerting systems. Prepare incident response procedures. Consider bug bounty programs for ongoing security research. Implement gradual rollout with value limits initially.

Post-Deployment Phase

Monitor contract activity continuously for suspicious patterns. Keep emergency pause and upgrade mechanisms ready. Maintain open communication channels with security researchers. Stay informed about new attack vectors and vulnerabilities. Consider purchasing protocol insurance. Regularly review and update security practices.

Tools for Enhancing Solidity Security

Static Analysis Tools

Slither provides automated vulnerability detection and can identify most common security issues. Mythril performs symbolic execution to find potential vulnerabilities. Securify analyzes contracts for compliance with security patterns. Olympix Discover offers static analysis with high accuracy rates for vulnerability detection.

Testing Frameworks

Hardhat provides comprehensive testing capabilities with mainnet forking. Foundry offers fast testing with fuzzing capabilities built-in. Echidna performs property-based testing and fuzzing. Manticore enables symbolic execution for deep analysis.

Continuous Security Monitoring

Implement automated testing pipelines that run on every code change. Use mutation testing to verify test quality. Deploy to testnets for extensive real-world testing. Consider tools like Olympix that integrate directly into development workflows for proactive security analysis.

The Shift from Reactive to Proactive Security

Traditional smart contract security has relied heavily on post-development audits, but this approach has proven insufficient. Data shows that 90% of exploited contracts were previously audited, indicating that point-in-time security assessments cannot protect against evolving threats or catch all vulnerabilities.

Building Security Into Development

Modern best practices emphasize proactive security integrated throughout the development lifecycle. Use automated security tools during development, not just before deployment. Implement continuous testing including mutation testing to verify test effectiveness. Adopt secure coding patterns from project inception. Build security reviews into code review processes.

The Economics of Prevention

The cost of preventing vulnerabilities is orders of magnitude less than the cost of exploits. Implementing proper security practices during development adds minimal overhead compared to post-exploit recovery costs, reputational damage, and legal liabilities. Insurance premiums are lower for protocols with strong security practices. User confidence and TVL directly correlate with security track records.

Conclusion: Security as a Continuous Practice

Smart contract security is not a checklist to complete before deployment but an ongoing practice requiring vigilance, education, and continuous improvement. The ten vulnerabilities covered in this guide represent the most common and costly attack vectors, but new vulnerabilities emerge constantly as the ecosystem evolves.

Successful protocols combine multiple layers of defense including secure coding practices, comprehensive testing, professional audits, automated monitoring, and rapid incident response capabilities. They treat security as a core feature rather than an afterthought and invest in tools and processes that catch vulnerabilities early in development.

The transition from audit-dependent security to proactive, continuous security practices represents the maturation of the smart contract development ecosystem. By implementing these best practices and remaining vigilant about emerging threats, developers can build more resilient protocols that protect user funds and contribute to a more secure DeFi ecosystem.

Remember that security is never complete. It requires constant learning, adaptation, and commitment to protecting the trust users place in your smart contracts. The most secure contracts are those built by developers who understand not just how to write code, but why security patterns exist and what happens when they're ignored.

About Olympix: Olympix provides proactive smart contract security tools including static analysis, automated testing, and mutation testing that integrate directly into development workflows, helping teams catch vulnerabilities before deployment rather than after audits.

What’s a Rich Text element?

The rich text element allows you to create and format headings, paragraphs, blockquotes, images, and video all in one place instead of having to add and format them individually. Just double-click and easily create content.

A rich text element can be used with static or dynamic content. For static content, just drop it into any page and begin editing. For dynamic content, add a rich text field to any collection and then connect a rich text element to that field in the settings panel. Voila!

Headings, paragraphs, blockquotes, figures, images, and figure captions can all be styled after a class is added to the rich text element using the "When inside of" nested selector system.

  1. Follow-up: Conduct a follow-up review to ensure that the remediation steps were effective and that the smart contract is now secure.
  2. Follow-up: Conduct a follow-up review to ensure that the remediation steps were effective and that the smart contract is now secure.

In Brief

  • Remitano suffered a $2.7M loss due to a private key compromise.
  • GAMBL’s recommendation system was exploited.
  • DAppSocial lost $530K due to a logic vulnerability.
  • Rocketswap’s private keys were inadvertently deployed on the server.

Hacks

Hacks Analysis

Huobi  |  Amount Lost: $8M

On September 24th, the Huobi Global exploit on the Ethereum Mainnet resulted in a $8 million loss due to the compromise of private keys. The attacker executed the attack in a single transaction by sending 4,999 ETH to a malicious contract. The attacker then created a second malicious contract and transferred 1,001 ETH to this new contract. Huobi has since confirmed that they have identified the attacker and has extended an offer of a 5% white hat bounty reward if the funds are returned to the exchange.

Exploit Contract: 0x2abc22eb9a09ebbe7b41737ccde147f586efeb6a

More from Olympix:

No items found.

Ready to Shift Security Assurance In-House? Talk to Our Security Experts Today.