InsightsBlockchain

10 Smart Contract Vulnerabilities Every Developer Must Know

The 10 smart contract vulnerabilities behind most exploits, from reentrancy to oracle manipulation, each with a Solidity example, a fix and a test idea.

Digital Silk Route8 min read

10 Smart Contract Vulnerabilities Every Developer Must Know

The most common smart contract vulnerabilities are reentrancy, broken access control, price oracle manipulation, integer and precision errors, front-running (MEV), signature replay, unchecked external calls, upgradeability bugs, denial of service and plain business-logic errors. Most of them overlap with the OWASP Smart Contract Top 10 (2025). Each one below comes with a short Solidity example, a fix, and a test idea you can run in Foundry.

Key takeaways

  • Smart contracts are public, hold value and are hard to change, so bugs are found and exploited quickly.
  • Follow checks-effects-interactions, use proven libraries (OpenZeppelin), and restrict every privileged function.
  • Never use a single spot price from a DEX as an oracle.
  • Signed messages need a nonce, a deadline and the chain ID (EIP-712).
  • Fuzz and invariant tests catch many of these bugs before an auditor or attacker does.

Why smart contract security is different

A normal web app bug can be patched in minutes. A deployed contract is usually immutable, its code is public, and it may hold funds that anyone can try to take in a single transaction. That combination is why "move fast and break things" does not work on-chain, and why security review is a skill clients pay for.

The code below is simplified for teaching. Use it on testnets only.

1. Reentrancy

What happens: your contract sends ETH or calls another contract before updating its own state. The receiver calls back into your function and repeats the withdrawal. The 2016 attack on The DAO was a reentrancy bug.

// VULNERABLE
function withdraw() external {
    uint256 bal = balances[msg.sender];
    (bool ok, ) = msg.sender.call{value: bal}("");
    require(ok);
    balances[msg.sender] = 0; // too late
}

Fix: update state first (checks-effects-interactions) and add a guard for complex flows.

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

function withdraw() external nonReentrant {
    uint256 bal = balances[msg.sender];
    balances[msg.sender] = 0;           // effect
    (bool ok, ) = msg.sender.call{value: bal}(""); // interaction
    require(ok, "transfer failed");
}

Test idea: write an attacker contract whose receive() calls withdraw() again, and assert the attack reverts or drains nothing extra. Also watch for read-only reentrancy, where another protocol reads your state mid-call.

2. Access control mistakes

What happens: a function that should be admin-only is public, or an initializer can be called by anyone. OWASP lists access control as the top smart contract risk in its 2025 list.

// VULNERABLE: anyone can mint
function mint(address to, uint256 amount) external {
    _mint(to, amount);
}

Fix: restrict with Ownable or role-based AccessControl, and use a multi-signature wallet or timelock for the admin.

bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
    _mint(to, amount);
}

Test idea: for every privileged function, a test that calls it from a random address with vm.prank and expects a revert.

3. Price oracle manipulation

What happens: a lending or minting contract reads the current price from a single DEX pool. An attacker borrows a large amount with a flash loan, moves the pool price, borrows against the fake price, and repays the flash loan in the same transaction.

// VULNERABLE: spot price from one pool
function getPrice() public view returns (uint256) {
    (uint112 r0, uint112 r1, ) = pair.getReserves();
    return (uint256(r1) * 1e18) / r0;
}

Fix: use a decentralised oracle network or a time-weighted average price (TWAP) over a meaningful window, check staleness, and set sanity bounds.

(, int256 answer, , uint256 updatedAt, ) = feed.latestRoundData();
require(answer > 0, "bad price");
require(block.timestamp - updatedAt < MAX_DELAY, "stale price");

Test idea: in a fork test, simulate a large swap in the pool before calling your function and check that the result does not change.

4. Integer and precision errors

What happens: Solidity 0.8 reverts on overflow, but bugs remain. unchecked blocks skip the check, casting to smaller types silently truncates, and dividing before multiplying loses precision.

// Precision loss: rounds to zero for small amounts
uint256 fee = amount / 10_000 * feeBps;

// Silent truncation
uint64 small = uint64(bigValue);

Fix: multiply before dividing, use OpenZeppelin SafeCast, and use Math.mulDiv for large products. Decide deliberately which way rounding should go (usually in favour of the protocol).

uint256 fee = Math.mulDiv(amount, feeBps, 10_000);
uint64 small = SafeCast.toUint64(bigValue);

Test idea: fuzz tests with very small and very large amounts, asserting that totals always add up.

5. Front-running and MEV

What happens: pending transactions are visible before they are mined. Bots copy profitable actions, place trades just before and after yours (sandwich attacks), or claim a reward first.

Fix:

  • Let users set slippage limits and deadlines on swaps.
  • Use commit-reveal for auctions, games and anything where seeing the answer early gives an advantage.
  • Consider private transaction relays for sensitive operations.
function swap(uint256 amountIn, uint256 minOut, uint256 deadline) external {
    require(block.timestamp <= deadline, "expired");
    uint256 out = _swap(amountIn);
    require(out >= minOut, "slippage");
}

DSR's earlier post "On-Chain Automation: Building Safe Arbitrage Bots Without Getting MEV'd" covers this from the bot-builder side.

6. Signature replay

What happens: a contract accepts an off-chain signature (for a permit, a claim or a gasless action) but the same signature can be submitted twice, on another chain, or to another contract.

Fix: use EIP-712 typed data that includes a nonce, a deadline, the chain ID and the contract address, and mark nonces as used. Use OpenZeppelin's ECDSA and EIP712 helpers rather than raw ecrecover, which returns address(0) for invalid signatures.

bytes32 structHash = keccak256(abi.encode(CLAIM_TYPEHASH, user, amount, nonces[user]++, deadline));
address signer = ECDSA.recover(_hashTypedDataV4(structHash), signature);
require(signer == trustedSigner, "bad signature");
require(block.timestamp <= deadline, "expired");

Test idea: submit the same signature twice and expect the second call to revert.

7. Unchecked external calls

What happens: a low-level call returns false instead of reverting, and the contract carries on as if it succeeded. Some ERC-20 tokens also return false or nothing at all instead of reverting.

// VULNERABLE
payable(user).call{value: amount}("");
token.transfer(user, amount);

Fix: always check the return value of call, and use OpenZeppelin SafeERC20 for tokens.

(bool ok, ) = payable(user).call{value: amount}("");
require(ok, "eth transfer failed");
token.safeTransfer(user, amount);

8. Upgradeability bugs

What happens: proxy-based contracts separate storage from logic. Common mistakes include an implementation contract left uninitialised (anyone can call initialize), storage layout changed between versions, and upgrade rights held by a single hot wallet.

Fix:

  • Call _disableInitializers() in the implementation's constructor.
  • Use the initializer modifier and never reorder or remove existing storage variables; append new ones.
  • Put upgrade authority behind a multi-sig and a timelock.
  • Use OpenZeppelin's upgrade tooling to validate storage layouts.
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    _disableInitializers();
}

9. Denial of service

What happens: a function loops over an array that anyone can grow, until it exceeds the block gas limit. Or a contract pushes ETH to many recipients and one recipient that always reverts blocks everyone.

// VULNERABLE: unbounded loop with push payments
for (uint256 i = 0; i < recipients.length; i++) {
    payable(recipients[i]).transfer(shares[i]);
}

Fix: use pull payments (each user withdraws their own share), cap loop sizes, and process large lists in batches.

mapping(address => uint256) public owed;

function claim() external {
    uint256 amount = owed[msg.sender];
    owed[msg.sender] = 0;
    (bool ok, ) = payable(msg.sender).call{value: amount}("");
    require(ok, "claim failed");
}

10. Business-logic errors

What happens: the code does exactly what it says, but what it says is wrong. Rewards are calculated before a deposit is recorded, a fee is charged twice, a user can withdraw collateral while still in debt. OWASP lists logic errors and lack of input validation as separate top-ten items, and they are among the hardest to spot with tools.

Fix:

  • Write down the rules of the system in plain language before coding (a short specification).
  • Turn those rules into invariant tests: properties that must always hold, such as "the sum of user balances never exceeds the contract's token balance".
  • Validate all inputs: zero addresses, zero amounts, array length mismatches.
// Foundry invariant test sketch
function invariant_SolventVault() public view {
    assertGe(token.balanceOf(address(vault)), vault.totalDeposits());
}

Also worth knowing

  • Insecure randomness: block.timestamp and blockhash are predictable. Use a verifiable random function (VRF) for anything of value.
  • tx.origin for authorisation: use msg.sender instead.
  • Flash loan attacks: usually combine #3 and #10, so design assuming any user can briefly hold a huge balance.

A pre-deployment security checklist

CheckDone?
All privileged functions restricted and tested from a non-admin address☐
State updated before external calls; nonReentrant where needed☐
Oracle uses TWAP or decentralised feed with staleness checks☐
SafeERC20 for tokens; low-level call results checked☐
Signatures use EIP-712 with nonce, deadline, chain ID☐
No unbounded loops over user-controlled arrays☐
Fuzz and invariant tests pass; coverage reviewed☐
Static analysis (for example Slither) run and findings triaged☐
Admin keys in a multi-sig; upgrades behind a timelock☐
Independent review or audit before mainnet☐

Turning security skills into a service

Clients rarely ask for "a smart contract". They ask for one that will not lose their money. If you can explain these ten issues, test for them and write a clear report, you can offer small-contract reviews, pre-audit preparation, test-suite writing or monitoring setups. Competitive audit platforms such as Code4rena, Sherlock and Cantina are an honest way to practise on real code and build a public track record. BC-08 covers how to find clients.

Next step with Digital Silk Route

Security runs through every level of the Blockchain track in the Digital Silk Route (DSR) AI & Crypto Academy, from Beginner through Intermediate to Advanced, and each level ends with a capstone you can actually ship. A security-focused capstone, such as a review report, a test suite or an audit checklist tool, is also a product or service you can list on the DSR Marketplace. Learn. Build. Earn.

Sources

This article is for education only and is not financial, legal or investment advice.

Frequently asked questions

  • smart contract vulnerabilities
  • solidity security
  • reentrancy attack
  • smart contract audit checklist
  • owasp smart contract top 10
  • common solidity bugs