Back to Blog12 min read
Smart Contract Security

How to Fix the 10 Most Common Smart Contract Vulnerabilities in 2026 (With Code)

The audit checklist tells you what to look for. This guide shows you exactly how to fix it — with vulnerable code, patched code, and the reasoning behind every change.

Published: October 20, 2026
12 min read
5 views
✓ Written by blockchain developers·✓ Reviewed for technical accuracy
How to Fix the 10 Most Common Smart Contract Vulnerabilities in 2026 (With Code)

The smart contract audit checklist tells you what to look for. This guide shows you exactly how to fix it — vulnerable code, patched code, and the reasoning behind every change.

These are the 10 most common vulnerabilities found in production Solidity contracts in 2026. Each section includes a real-world exploit pattern, the correct fix, and why it works.

1. Reentrancy

Severity: Critical

Reentrancy is the vulnerability behind the 2016 DAO hack — $60M drained in hours. It occurs when an external call is made before state is updated, allowing the called contract to re-enter your function and drain funds.

Vulnerable Code

// VULNERABLE — state updated AFTER external call
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient");

    (bool success, ) = msg.sender.call{value: amount}(""); // external call first
    require(success, "Transfer failed");

    balances[msg.sender] -= amount; // state updated after — too late
}

Fixed Code

// FIXED — Checks-Effects-Interactions pattern
function withdraw(uint256 amount) external nonReentrant {
    require(balances[msg.sender] >= amount, "Insufficient");

    balances[msg.sender] -= amount; // update state FIRST

    (bool success, ) = msg.sender.call{value: amount}(""); // external call last
    require(success, "Transfer failed");
}

// Or use OpenZeppelin ReentrancyGuard:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient");
        balances[msg.sender] -= amount;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Why it works: The Checks-Effects-Interactions pattern ensures state is updated before any external call. Even if the attacker's contract tries to re-enter, balances[msg.sender] is already zero — nothing left to drain. The nonReentrant modifier adds a mutex lock as a second layer of defence.

2. Integer Overflow and Underflow

Severity: High

The Truebit exploit demonstrated how integer overflow can wrap a uint value around to zero or maximum, bypassing balance checks entirely. In Solidity below 0.8.0, arithmetic does not revert on overflow.

Vulnerable Code

// VULNERABLE — Solidity <0.8.0, no overflow protection
pragma solidity ^0.7.0;

contract Token {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        balances[msg.sender] -= amount; // underflows if amount > balance
        balances[to] += amount;
    }
}

Fixed Code

// FIXED — Option 1: Use Solidity 0.8.0+ (built-in overflow checks)
pragma solidity ^0.8.0;

contract Token {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount; // reverts automatically on underflow
        balances[to] += amount;
    }
}

// FIXED — Option 2: Use SafeMath for legacy contracts (<0.8.0)
import "@openzeppelin/contracts/utils/math/SafeMath.sol";

contract TokenLegacy {
    using SafeMath for uint256;
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        balances[msg.sender] = balances[msg.sender].sub(amount);
        balances[to] = balances[to].add(amount);
    }
}

Why it works: Solidity 0.8.0+ adds automatic overflow and underflow checks — any arithmetic that would wrap reverts with a panic error. For contracts that must use older compiler versions, OpenZeppelin SafeMath provides the same protection via explicit checks before every operation.

3. Missing Access Control

Severity: Critical

Unprotected privileged functions are one of the most common critical findings in audits. Any address — including an attacker — can call a function with no onlyOwner or role check.

Vulnerable Code

// VULNERABLE — no access control on mint
contract Token {
    mapping(address => uint256) public balances;
    uint256 public totalSupply;

    function mint(address to, uint256 amount) external {
        // anyone can call this — no restriction
        balances[to] += amount;
        totalSupply += amount;
    }

    function upgradeTo(address newImpl) external {
        // anyone can replace the implementation
        _implementation = newImpl;
    }
}

Fixed Code

// FIXED — OpenZeppelin Ownable + AccessControl
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract Token is AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    mapping(address => uint256) public balances;
    uint256 public totalSupply;

    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MINTER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
        balances[to] += amount;
        totalSupply += amount;
    }

    function upgradeTo(address newImpl) external onlyRole(DEFAULT_ADMIN_ROLE) {
        _implementation = newImpl;
    }
}

Why it works: Role-based access control separates privileges — a minter can mint but cannot upgrade the contract. DEFAULT_ADMIN_ROLE controls role assignment. Every privileged function requires an explicit role check, and the deployment constructor assigns initial roles to the deployer only.

4. Oracle Manipulation

Severity: Critical

Flash loan attacks frequently exploit on-chain price oracles. An attacker borrows a large amount, manipulates the spot price in an AMM pool, uses the inflated price to drain your protocol, then repays the flash loan — all in one transaction.

Vulnerable Code

// VULNERABLE — reads spot price from a single AMM pool
interface IUniswapV2Pair {
    function getReserves() external view returns (uint112, uint112, uint32);
}

contract LendingProtocol {
    IUniswapV2Pair public immutable pair;

    function getPrice() public view returns (uint256) {
        (uint112 reserve0, uint112 reserve1, ) = pair.getReserves();
        return (reserve1 * 1e18) / reserve0; // spot price — manipulable
    }

    function borrow(uint256 amount) external {
        uint256 price = getPrice(); // attacker inflates this with flash loan
        uint256 collateralRequired = amount * 1e18 / price;
        // attacker borrows at artificially inflated collateral value
    }
}

Fixed Code

// FIXED — Chainlink price feed with staleness check
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract LendingProtocol {
    AggregatorV3Interface public immutable priceFeed;
    uint256 public constant STALENESS_THRESHOLD = 3600; // 1 hour

    constructor(address _priceFeed) {
        priceFeed = AggregatorV3Interface(_priceFeed);
    }

    function getPrice() public view returns (uint256) {
        (
            uint80 roundId,
            int256 price,
            ,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();

        require(price > 0, "Invalid price");
        require(updatedAt >= block.timestamp - STALENESS_THRESHOLD, "Stale price");
        require(answeredInRound >= roundId, "Incomplete round");

        return uint256(price);
    }
}

Why it works: Chainlink aggregates price data from multiple off-chain sources — a flash loan cannot manipulate it in a single block. The staleness check ensures the feed hasn't gone silent. For additional security, use TWAP (time-weighted average price) oracles for on-chain pricing, or combine Chainlink with a TWAP for cross-validation.

5. Front-Running

Severity: High

The mempool is public. MEV bots monitor pending transactions, copy profitable ones with higher gas, and get their transaction included first. This is especially damaging for NFT mints, DEX trades, and lottery contracts.

Vulnerable Code

// VULNERABLE — answer is visible in mempool before tx confirms
contract Lottery {
    bytes32 public secretHash;

    function guess(string calldata answer) external payable {
        // attacker sees your answer in mempool, copies it with more gas
        require(keccak256(abi.encodePacked(answer)) == secretHash, "Wrong");
        payable(msg.sender).transfer(address(this).balance);
    }
}

Fixed Code

// FIXED — commit-reveal scheme
contract Lottery {
    mapping(address => bytes32) public commitments;
    mapping(address => uint256) public commitBlock;
    uint256 public constant REVEAL_DELAY = 10; // blocks

    // Phase 1: commit hash of answer (answer is hidden)
    function commit(bytes32 commitment) external payable {
        commitments[msg.sender] = commitment;
        commitBlock[msg.sender] = block.number;
    }

    // Phase 2: reveal after delay (front-running is now useless)
    function reveal(string calldata answer, bytes32 salt) external {
        require(block.number >= commitBlock[msg.sender] + REVEAL_DELAY, "Too early");
        require(
            keccak256(abi.encodePacked(answer, salt)) == commitments[msg.sender],
            "Invalid reveal"
        );
        // process winner
        payable(msg.sender).transfer(address(this).balance);
    }
}

// For DEX trades — enforce slippage limit
function swap(uint256 amountIn, uint256 minAmountOut) external {
    uint256 amountOut = _calculateOutput(amountIn);
    require(amountOut >= minAmountOut, "Slippage exceeded"); // front-run makes this revert
    _executeSwap(amountIn, amountOut);
}

Why it works: The commit-reveal scheme separates the submission of a secret from its revelation. By the time the reveal phase happens, the commitment is already on-chain — a front-runner copying your reveal gets nothing because the commitment already belongs to you. Slippage limits make DEX front-running unprofitable — if the bot moves the price, your trade reverts.

6. Unchecked Return Values

Severity: High

Low-level calls (call, send, delegatecall) return a boolean success value. Ignoring it means a failed transfer silently continues execution — your accounting updates even though no ETH moved.

Vulnerable Code

// VULNERABLE — return value ignored
function sendReward(address recipient, uint256 amount) external {
    recipient.send(amount); // returns false on failure, not checked
    balances[recipient] = 0; // balance zeroed even if transfer failed
}

// Also vulnerable — transfer to contract without receive() silently fails
function distribute(address[] calldata recipients) external {
    for (uint i = 0; i < recipients.length; i++) {
        recipients[i].call{value: 1 ether}(""); // unchecked
    }
}

Fixed Code

// FIXED — always check return value
function sendReward(address recipient, uint256 amount) external {
    (bool success, ) = recipient.call{value: amount}("");
    require(success, "ETH transfer failed");
    balances[recipient] = 0; // only zeroed if transfer succeeded
}

// FIXED — for ERC-20 tokens, use SafeERC20
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract Distributor {
    using SafeERC20 for IERC20;

    function distribute(IERC20 token, address[] calldata recipients, uint256 amount) external {
        for (uint i = 0; i < recipients.length; i++) {
            token.safeTransfer(recipients[i], amount); // reverts on failure
        }
    }
}

Why it works: Checking the boolean return of call and requiring success ensures the function reverts if the transfer fails — preventing silent accounting errors. SafeERC20 wraps ERC-20 transfers to handle tokens that return false instead of reverting (a common non-standard behaviour).

7. Denial of Service (DoS)

Severity: High

A DoS vulnerability can permanently brick a contract function. The most common pattern: iterating over an unbounded array. As the array grows, gas cost increases until the function exceeds the block gas limit and can never execute again.

Vulnerable Code

// VULNERABLE — unbounded loop over growing array
contract Airdrop {
    address[] public recipients;

    function addRecipient(address recipient) external {
        recipients.push(recipient); // array grows indefinitely
    }

    function distributeAll() external {
        for (uint i = 0; i < recipients.length; i++) {
            // gas cost grows with each new recipient
            // eventually exceeds block gas limit — permanently stuck
            payable(recipients[i]).transfer(1 ether);
        }
    }
}

// Also vulnerable — pushing to external contracts that can revert
function refundAll() external {
    for (uint i = 0; i < investors.length; i++) {
        investors[i].transfer(investments[investors[i]]); // one revert blocks all refunds
    }
}

Fixed Code

// FIXED — pull payment pattern + pagination
contract Airdrop {
    mapping(address => uint256) public pendingWithdrawals;

    // Push small batches off-chain, record on-chain
    function setAllocation(address recipient, uint256 amount) external onlyOwner {
        pendingWithdrawals[recipient] = amount;
    }

    // Each user pulls their own payment — no loop needed
    function claim() external {
        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");
    }
}

// For unavoidable loops — add pagination
function distributeBatch(uint256 start, uint256 end) external {
    require(end <= recipients.length, "Out of bounds");
    require(end - start <= 100, "Batch too large"); // cap per call
    for (uint i = start; i < end; i++) {
        pendingWithdrawals[recipients[i]] += share;
    }
}

Why it works: The pull payment pattern eliminates loops entirely — each user pays their own gas to claim. For contracts that must iterate, pagination caps the gas cost per transaction and allows the work to be split across multiple calls. One user's failed transfer can no longer block everyone else's.

8. Signature Replay Attack

Severity: Critical

A valid signature is not single-use by default. Without a nonce and chain binding, an attacker can reuse the same signed message multiple times, or replay it on a different chain. This is how the Ronin Bridge lost $625M.

Vulnerable Code

// VULNERABLE — no nonce, no chainId
contract Bridge {
    function withdraw(
        address to,
        uint256 amount,
        bytes calldata signature
    ) external {
        bytes32 messageHash = keccak256(abi.encodePacked(to, amount));
        address signer = recoverSigner(messageHash, signature);
        require(signer == trustedSigner, "Invalid signature");
        // same signature works forever — replay attack possible
        payable(to).transfer(amount);
    }
}

Fixed Code

// FIXED — EIP-712 with nonce and chainId
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract Bridge is EIP712 {
    using ECDSA for bytes32;

    bytes32 private constant WITHDRAW_TYPEHASH = keccak256(
        "Withdraw(address to,uint256 amount,uint256 nonce,uint256 chainId)"
    );

    mapping(address => uint256) public nonces;
    address public trustedSigner;

    constructor() EIP712("Bridge", "1") {}

    function withdraw(
        address to,
        uint256 amount,
        bytes calldata signature
    ) external {
        uint256 nonce = nonces[to]++;

        bytes32 structHash = keccak256(abi.encode(
            WITHDRAW_TYPEHASH,
            to,
            amount,
            nonce,
            block.chainid // binds to this chain only
        ));

        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = digest.recover(signature);

        require(signer == trustedSigner, "Invalid signature");
        payable(to).transfer(amount);
    }
}

Why it works: The nonce increments after each use — the same signature can never be used twice. Including block.chainid binds the signature to one specific chain — replaying on another chain produces a different digest that won't match. EIP-712 structured signing makes the signed data human-readable and prevents cross-context replay.

9. tx.origin Authentication

Severity: High

tx.origin always refers to the original EOA that initiated the transaction chain — not the immediate caller. A malicious contract can trick a user into calling it, then use that user's tx.origin to pass your auth check.

Vulnerable Code

// VULNERABLE — uses tx.origin for auth
contract Wallet {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function transfer(address payable dest, uint256 amount) external {
        require(tx.origin == owner, "Not owner"); // WRONG — phishable
        dest.transfer(amount);
    }
}

// Attack: owner calls MaliciousContract which calls Wallet.transfer
// tx.origin == owner passes — funds stolen

Fixed Code

// FIXED — use msg.sender for authentication
contract Wallet {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner"); // msg.sender = direct caller
        _;
    }

    function transfer(address payable dest, uint256 amount) external onlyOwner {
        dest.transfer(amount);
    }

    // If you need to block contracts from calling (rare use case):
    function sensitiveAction() external {
        require(msg.sender == tx.origin, "No contracts"); // EOA only
        require(msg.sender == owner, "Not owner");
    }
}

Why it works: msg.sender is the direct caller — if a malicious contract calls your function, msg.sender is that contract's address, which won't match owner. The phishing attack is blocked because the intermediate contract can no longer pass the auth check on your behalf.

10. Unprotected selfdestruct

Severity: Critical

A contract with an unprotected selfdestruct call can be destroyed by anyone — sending all ETH to an arbitrary address and wiping all contract state permanently. Post-Dencun (EIP-6780), selfdestruct only sends ETH but no longer destroys code unless called in the same transaction as deployment.

Vulnerable Code

// VULNERABLE — anyone can destroy the contract
contract Vault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function kill() external {
        selfdestruct(payable(msg.sender)); // no access control — anyone can call
        // all ETH sent to caller, all user balances wiped
    }
}

// Also vulnerable — force-send ETH via selfdestruct bypasses receive()
// An attacker can push ETH to your contract even if you have no receive()
// If your logic relies on address(this).balance == 0, it can be broken

Fixed Code

// FIXED — access control + avoid balance assumptions
import "@openzeppelin/contracts/access/Ownable.sol";

contract Vault is Ownable {
    mapping(address => uint256) public balances;
    uint256 public trackedBalance; // track internally, not via address(this).balance

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        trackedBalance += msg.value;
    }

    // Only owner can call — and consider removing entirely
    function emergencyWithdraw(address payable dest) external onlyOwner {
        dest.transfer(address(this).balance);
    }

    // NEVER rely on this for logic:
    // require(address(this).balance == expectedAmount) // breakable via force-send

    // Use internal accounting instead:
    function getBalance() external view returns (uint256) {
        return trackedBalance; // attacker cannot manipulate this
    }
}

Why it works: Access control prevents anyone but the owner from triggering emergency withdrawals. Removing selfdestruct entirely is the safest option for most contracts — there are very few legitimate use cases post-EIP-6780. Internal balance tracking via a mapping or counter is immune to force-send attacks that manipulate address(this).balance.

Summary: 10 Vulnerabilities and Their Fixes

# Vulnerability Severity Fix
1ReentrancyCriticalChecks-Effects-Interactions + ReentrancyGuard
2Integer OverflowHighSolidity 0.8+ or SafeMath
3Missing Access ControlCriticalOpenZeppelin AccessControl + roles
4Oracle ManipulationCriticalChainlink + staleness check + TWAP
5Front-RunningHighCommit-reveal + slippage limits
6Unchecked Return ValuesHighCheck call() return + SafeERC20
7Denial of ServiceHighPull payments + pagination
8Signature ReplayCriticalEIP-712 + nonce + chainId
9tx.origin AuthHighUse msg.sender always
10Unprotected selfdestructCriticalAccess control + internal accounting

Frequently Asked Questions

What is the most critical smart contract vulnerability to fix first?

Reentrancy and missing access control are the two most critical. Reentrancy has drained hundreds of millions in historical exploits, and missing access control is the most frequently found critical finding in professional audits. Fix these before deploying any contract that handles funds.

Does Solidity 0.8.0 protect against all integer overflow vulnerabilities?

Solidity 0.8.0 adds automatic overflow and underflow checks for standard arithmetic — additions, subtractions, and multiplications will revert if they wrap. However, code inside unchecked{} blocks bypasses these protections. Never use unchecked on arithmetic involving user inputs or external data.

Can an AI auditor catch all of these vulnerabilities automatically?

AI auditors like the Blockhertz AI Auditor detect the majority of common patterns — reentrancy, integer overflow, missing access control, tx.origin usage, and unchecked return values — with high accuracy. Complex business logic vulnerabilities, economic attack vectors, and novel exploit patterns may require a manual audit by an experienced security engineer. Use AI auditing as a fast first pass, then follow up with manual review for high-value contracts.

How do I check if my existing contract has these vulnerabilities?

Start with the Blockhertz AI Auditor — paste your Solidity code or enter a contract address for an instant security report. For a deeper review, run Slither (static analysis) and Mythril (symbolic execution) locally. For production contracts holding significant value, commission a professional audit from a certified security firm.

What is the difference between a security audit and using an AI auditor?

A security audit is a manual process performed by one or more experienced auditors over days or weeks — it covers business logic, economic attack vectors, and contract interactions that automated tools miss. An AI auditor runs in seconds and catches known vulnerability patterns with high coverage. For most projects: use AI auditing during development for fast feedback, then commission a full audit before mainnet deployment of any contract managing user funds.

Next Steps

Now that you know how to fix these vulnerabilities, run your contracts through the Blockhertz AI Auditor — free, no signup, results in 60 seconds. It checks for all 10 patterns covered in this guide plus additional vulnerability classes.

For the complete list of what to look for during an audit, see the Smart Contract Audit Checklist 2026. For gas cost optimisations after security is locked down, see How to Gas Optimize Your Smart Contract.

References

Related Articles

Resource Hub

Smart Contract Security Hub →

Free AI auditor, vulnerability guides and security research

🔐

Views

5

Read Time

12 min read

Likes

0

Published

Oct 20, 2026

smart contract securitysolidityvulnerabilitiesreentrancyaccess controloracle manipulationfront-runningEIP-712OpenZeppelin2026BlockhertzBlockchainWeb3
smart contract securitysolidityvulnerabilitiesreentrancyaccess controloracle manipulationfront-runningEIP-712OpenZeppelin2026BlockhertzBlockchainWeb3

SIGNAL THREAD

00 SIGNALS

NO SIGNALS YET — BE FIRST TO TRANSMIT

Muhammad Asif

Senior Blockchain Developer & Founder, Blockhertz

Blockchain developer and security engineer with 8+ years of experience. Founded Blockhertz in 2018 to build AI-powered tools for Web3 teams — smart contract auditing, architecture generation, gas optimization, and RWA tokenization platforms. Serving clients worldwide.