Hacking ERC-20: Pentesting the Most Common Ethereum Token Standard

13 min read

March 2, 2025

Hacking ERC-20: Pentesting the Most Common Ethereum Token Standard

Table of contents

Pentesting ERC-20 Tokens: How Secure Are They Really?

ERC-20 tokens are everywhere. They power DeFi, fuel governance models, and sometimes, let’s be honest, exist purely as glorified meme coins. Whether it’s USDT, LINK, or some random token airdropped into your wallet that you’re too scared to click on, ERC-20 is the standard that defines how fungible tokens work on Ethereum.

But here’s the thing—not all ERC-20 implementations are created equal. A poorly written contract can be a ticking time bomb, just waiting for someone to exploit it. Front-running attacks, integer overflows, reentrancy bugs, and minting vulnerabilities are just a few of the common security flaws lurking in ERC-20 tokens. And as a pentester, these are exactly the kinds of weaknesses you should be hunting for.

In this article, we’ll break down ERC-20 security from an offensive perspective. We’ll go beyond the basics, looking at common vulnerabilities, real-world exploits, and practical testing techniques using Foundry. By the end, you’ll not only understand how ERC-20 tokens work—you’ll know how to break them, fix them, and make sure they’re battle-tested against attacks.

So, if you’re ready to hack some smart contracts (ethically, of course), let’s get started. 🚀💀

What is an ERC-20 Token?

ERC-20 is a technical standard that defines how fungible tokens should behave on the Ethereum network. Think of it as a rulebook for developers to create tokens that can seamlessly interact with wallets, exchanges, and other smart contracts. It's like having a universal charger for all your devices—no compatibility headaches.

The standard includes essential functions like checking balances, transferring tokens, and approving spending. For example, when you send USDT from one wallet to another, it’s the ERC-20 standard that ensures the process works the same way across the entire Ethereum ecosystem.

Why does it matter? Because it simplifies everything. Developers can build without reinventing the wheel, and users can trust that their tokens will work across platforms without issues. It’s one of the main reasons Ethereum became the go-to blockchain for decentralized applications (dApps) and Initial Coin Offerings (ICOs) back in the day.

To better understand how ERC-20 works under the hood, let’s break down the core functions that every compliant token must implement. These aren’t just technicalities—they’re the foundation that ensures your token can move, be tracked, and interact with the Ethereum ecosystem safely and efficiently.

Core Functions of an ERC-20 Token

Now that we know what an ERC-20 token is, let’s look at what actually makes one tick. Every ERC-20 token follows a set of mandatory and optional functions that define how it behaves on the Ethereum blockchain. Without these, a token wouldn’t be able to interact with wallets, exchanges, or even other smart contracts.

Here’s a breakdown of the key functions:

🧮 1. totalSupply

This function returns the total amount of tokens that exist for a particular contract. It’s like checking how many coins were minted in total. This supply can be fixed or dynamic, depending on the token's design.

function totalSupply() public view returns (uint256);

💰 2. balanceOf

If you’ve ever wondered how your wallet knows how many tokens you own, this is the function behind it. It returns the balance of a specific address.

function balanceOf(address account) public view returns (uint256);

🔄 3. transfer

This function allows a user to send tokens from their address to another. It’s the bread and butter of any token transaction.

function transfer(address recipient, uint256 amount) public returns (bool);

If the transfer is successful, it returns true. If not, the transaction reverts. Simple, but essential.

✅ 4. approve

Imagine lending your friend some money but only allowing them to spend a certain amount. That’s what approve does. It allows an address to spend tokens on behalf of the owner, but only up to a specific limit.

function approve(address spender, uint256 amount) public returns (bool);

This is particularly useful for decentralized exchanges (DEXs), where you approve the exchange to handle your tokens without giving full control.

🔁 5. transferFrom

Once an address has approval to spend tokens, transferFrom is the function that actually moves them. It’s how smart contracts execute transactions on behalf of users.

function transferFrom(address sender, address recipient, uint256 amount) public returns (bool);

Think of it like a subscription service automatically charging your card—except here, it's tokens being moved after prior approval.

📏 6. allowance

This function checks how many tokens an address is allowed to spend on behalf of another. It’s like asking, "How much has the owner authorized for me to use?"

function allowance(address owner, address spender) public view returns (uint256);

A typical ERC20 workflow is as follows:

sequenceDiagram participant UserA as User A participant UserB as User B participant Contract as ERC-20 Contract %% Consulta de saldo UserA->>Contract: balanceOf(A) Contract-->>UserA: Returns Balance %% Aprobación de gasto UserA->>Contract: approve(B, 100 tokens) Contract->>Contract: Set Allowance (A → B: 100) %% Transferencia directa UserA->>Contract: transfer(B, 50 tokens) Contract->>Contract: Update balances Contract->>UserB: Emit Transfer Event %% Transferencia con aprobación UserB->>Contract: transferFrom(A, B, 50 tokens) Contract->>Contract: Check & Update Allowance Contract->>UserB: Emit Transfer Event

Example of a Basic ERC-20 Contract

Now that we understand the core functions of an ERC-20 token, let’s look at a hands-on example. This is a simple contract for a token called DesertCoin with the symbol DSC. It shows how the standard functions work together to create a functional token.

Here’s the code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract DesertCoin {
    string public name = "DesertCoin";
    string public symbol = "DSC";
    uint8 public decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply * (10 ** uint256(decimals));
        balanceOf[msg.sender] = totalSupply;
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        require(to != address(0), "Invalid address");
        require(balanceOf[msg.sender] >= amount, "Insufficient balance");

        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;

        emit Transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        require(spender != address(0), "Invalid address");

        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        require(to != address(0), "Invalid address");
        require(balanceOf[from] >= amount, "Insufficient balance");
        require(allowance[from][msg.sender] >= amount, "Allowance exceeded");

        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        allowance[from][msg.sender] -= amount;

        emit Transfer(from, to, amount);
        return true;
    }
}

This contract defines an ERC-20 token from scratch, implementing the core functionality needed for transfers, approvals, and ownership tracking. Let’s break it down at a high level.

The contract starts by defining the token’s basic properties, including its name (DesertCoin), symbol (DSC), and number of decimal places (18), which is the standard for most ERC-20 tokens. The totalSupply variable keeps track of the total amount of tokens that exist.

Each Ethereum address has a balance, stored in the balanceOf mapping. Another mapping, allowance, is used to track approvals—this allows users to grant permission to others to spend tokens on their behalf.

When the contract is deployed, the constructor initializes the token by minting the entire supply to the deployer's address. The initial supply is multiplied by 10^18 to adjust for the decimals, ensuring that token values are represented correctly.

The transfer() function enables users to send tokens to another address. It first checks that the sender has enough tokens and that the recipient address is valid. If these conditions are met, it deducts the amount from the sender’s balance and adds it to the recipient’s. It then emits a Transfer event, which external applications (like wallets and block explorers) can use to track token movements.

The approve() function allows a user to authorize another address to spend a certain amount of tokens on their behalf. This is useful for interactions with smart contracts, such as decentralized exchanges (DEXs), where users don’t send tokens directly but instead approve a contract to manage them. When an approval is made, an Approval event is emitted.

The transferFrom() function is used when a third party (such as a smart contract) moves tokens on behalf of someone else. It checks if the sender has enough balance and if the transaction respects the approved allowance. If valid, it performs the transfer and updates the allowance accordingly.

Using OpenZeppelin for a More Secure ERC-20 Contract

In the previous example, we built our ERC-20 contract from scratch, implementing all the core functionalities manually. This was done to better understand how ERC-20 tokens work under the hood. However, in real-world development, most developers don’t reinvent the wheel—instead, they use battle-tested libraries like OpenZeppelin.

OpenZeppelin provides well-audited, secure, and gas-optimized implementations of common smart contract standards, including ERC-20. By leveraging these libraries, we reduce the risk of introducing vulnerabilities and simplify development.

Additionally, one function that is commonly used in ERC-20 tokens but was not present in our previous DesertCoin implementation is mint(). This function allows for new token issuance after deployment, making it useful for inflationary tokens, reward-based systems, or governance models where new tokens may need to be introduced over time.

Let’s see how our DesertCoin contract would look when using OpenZeppelin.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract DesertCoin is ERC20, Ownable {
    constructor(uint256 initialSupply) ERC20("DesertCoin", "DSC") Ownable(msg.sender) {
        _mint(msg.sender, initialSupply * (10 ** decimals()));
    }
     function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

}

This implementation significantly reduces complexity by leveraging OpenZeppelin’s secure ERC-20 contract. Instead of manually writing the transfer, balance tracking, and approval logic, we inherit from the ERC20 contract, which already provides all the required ERC-20 functionality.

The constructor initializes the token with a name ("DesertCoin") and a symbol ("DSC"). It then calls _mint() to allocate the initial token supply to the deployer’s address. This means that upon deployment, all tokens will be assigned to the person who created the contract.

A key addition in this version is the mint() function. This function allows new tokens to be issued after deployment, but it is restricted by the onlyOwner modifier, which ensures that only the contract owner (the deployer by default) has the authority to mint more tokens. This prevents unauthorized inflation and maintains controlled token issuance.

By using OpenZeppelin’s Ownable contract, we also gain access to ownership management functions. This means the deployer can later transfer ownership to another address if needed, which is particularly useful for projects that might transition control to a DAO or governance contract.

Common ERC-20 Vulnerabilities: What to Watch for as a Pentester

When pentesting ERC-20 tokens, it's essential to go beyond simple functional tests and actively look for exploitable flaws. While the ERC-20 standard is well-defined, implementation mistakes—or even design choices—can introduce critical vulnerabilities that lead to theft, token manipulation, or denial-of-service (DoS) attacks.

Below are some of the most common and interesting ERC-20 vulnerabilities that should be in every pentester’s checklist.

1️⃣ Integer Overflows and Underflows: Breaking Balances

Integer arithmetic bugs were one of the earliest attack vectors on Solidity smart contracts. Before Solidity 0.8, integer overflows and underflows could be used to manipulate balances or supply calculations, leading to fund mismanagement or even infinite tokens.

💥 Attack Scenario:
If subtraction is performed without proper checks, a pentester can force an underflow, tricking the contract into giving them a massive balance due to Solidity’s wrap-around behavior (in older versions).

Example of a vulnerable implementation:

function transfer(address to, uint256 amount) public {
    balanceOf[msg.sender] -= amount; // Underflows if amount > balance
    balanceOf[to] += amount;         // Overflows if amount is extremely large
}

🔍 Pentester’s checklist:

  • Does the contract run on an older Solidity version (<0.8)?
  • Does it manually handle arithmetic (+, -, *, /) instead of using SafeMath or Solidity’s built-in overflow protection?
  • Can we force an underflow by transferring more tokens than we own?

Solution: Solidity 0.8+ automatically reverts on overflows, but older versions require SafeMath to prevent this issue.

📖 Want to see an exploit in action? Check Chapter 8, where we analyze past attacks using integer overflows and test modern defenses.

Approval Race Condition (approve() and transferFrom())

One of the most dangerous design flaws in ERC-20 is the race condition in approve(). If a user wants to update an approval, a malicious spender can front-run the transaction and steal funds before the new approval is set.

💥 Attack Scenario:

  1. Alice approves Bob to spend 100 tokens.
  2. Alice wants to lower Bob’s allowance to 50, so she submits a transaction.
  3. Bob, monitoring the mempool, front-runs the transaction and quickly spends the 100 before the new approval takes effect.
  4. Once Alice’s update is processed, Bob still has access to the new 50 tokens.

🔍 Pentester’s checklist:

  • Can we front-run an approval transaction?
  • Does the contract use OpenZeppelin’s increaseAllowance() and decreaseAllowance() instead of approve()?
  • Can we create a bot to monitor and exploit approve calls in real-time?

Potential Fix:
A safer approach is to first set the allowance to 0, then update it:

function safeApprove(address spender, uint256 amount) public {
    require(allowance[msg.sender][spender] == 0, "Must reset allowance first");
    allowance[msg.sender][spender] = amount;
}

Alternatively, using OpenZeppelin’s increaseAllowance() and decreaseAllowance() is recommended.

Reentrancy Attacks: Draining the Contract

If an ERC-20 token interacts with external contracts (e.g., in transferFrom() or a staking mechanism), reentrancy vulnerabilities can allow an attacker to withdraw more tokens than they should be able to.

💥 Attack Scenario:

  • The contract sends tokens to an attacker-controlled contract.
  • The attacker’s contract re-enters before the balance update is completed, forcing a second withdrawal.
  • The attacker loops the exploit until the contract is drained.

Example of a vulnerable implementation:

function transferFrom(address from, address to, uint256 amount) public {
    require(balanceOf[from] >= amount, "Not enough balance");
    require(allowance[from][msg.sender] >= amount, "Not allowed");

    balanceOf[from] -= amount;
    balanceOf[to] += amount;
    
    (bool success, ) = to.call(""); // Allows reentrancy if 'to' is a contract.
    require(success, "Transfer failed");
}

Pentester’s checklist:

  • Does the contract make external calls before updating balances?
  • Can we create a reentrant contract that exploits this behavior?
  • Does the contract lack reentrancy guards like ReentrancyGuard or modifiers preventing multiple executions?

Solution: Use the Checks-Effects-Interactions pattern, updating balances before interacting with external contracts.

📖 Want to execute a reentrancy attack? In Chapter 4, we build an exploit contract and drain it using a recursive attack function.

Minting Without Limits (Infinite Token Generation)

ERC-20 tokens that allow minting need strict controls. If minting is unrestricted, anyone could create unlimited tokens, leading to instant hyperinflation.

💥 Attack Scenario:
If _mint() is exposed without proper access control, a pentester could generate infinite tokens and sell them on an exchange before the exploit is patched.

Vulnerable implementation:

function mint(address to, uint256 amount) public {
    _mint(to, amount);
}

Pentester’s checklist:

  • Who has access to _mint()? Can anyone call it?
  • Is there a max supply limit enforced?
  • Can we execute multiple mint calls and sell the inflated tokens before detection?

Fix: Restrict minting to the contract owner or a specific role:

function mint(address to, uint256 amount) public onlyOwner {
    _mint(to, amount);
}

Burning Mechanisms That Can Break Tokenomics

Some ERC-20 tokens allow burning tokens (removing them from supply). However, improperly implemented burn functions can introduce unexpected consequences.

Example of a dangerous burn() function:

function burn(uint256 amount) public {
    balanceOf[msg.sender] -= amount;
    totalSupply -= amount;
}

If a user accidentally sets their balance to 0 by burning everything, they may not be able to interact with contracts that check balanceOf() > 0 for validation.

Fix: Ensure that burning does not cause unintended consequences in dApps relying on token balance conditions.

Blacklisting / Centralization Risks

Some tokens have blacklist functions, allowing an admin to freeze accounts. While useful for regulation compliance, this can be abused if one entity has too much control.

  • Can the owner arbitrarily freeze/unfreeze accounts?
  • Can token transfers be blocked suddenly?
  • Are there admin privileges that could be exploited?

If centralization is too extreme, it may defeat the purpose of being on-chain.

Gas Optimizations

Some ERC-20 implementations use loops in storage mappings, which can cause excessive gas costs and even break transactions if the loop grows too large.

function batchTransfer(address[] memory recipients, uint256 amount) public {
    for (uint256 i = 0; i < recipients.length; i++) {
        transfer(recipients[i], amount);
    }
}

If recipients is too large, the transaction could run out of gas and revert.

Fix: Avoid unbounded loops over dynamic arrays inside transactions.

Front-Running Attacks: Extracting Value from Transactions

If an ERC-20 token interacts with DEXs, AMMs, or pricing oracles, it may be vulnerable to front-running attacks. These exploits occur when attackers monitor pending transactions and submit their own transactions with a higher gas fee, getting their trade executed first.

💥 Attack Scenario:
A pentester spots a large token swap in the mempool, front-runs it by purchasing the token first, and then sells it back at a higher price due to the manipulated price impact.

🔍 Pentester’s checklist:

  • Are token swaps executed deterministically, making them predictable?
  • Can we front-run high-value transactions on Uniswap or SushiSwap?
  • Is the contract vulnerable to Maximum Extractable Value (MEV) bots?

📖 Want to profit off front-running? In Chapter 5, we build a custom Flashbots bot that detects ERC-20 price movements and executes MEV attacks on DeFi protocols.

Conclusions

ERC-20 tokens may seem simple at first glance, but as we’ve seen, their implementation can be full of hidden pitfalls. From integer overflows and approval race conditions to front-running exploits and reentrancy attacks, even a small mistake in a contract’s logic can lead to severe financial losses or complete contract failure.

For pentesters, ERC-20 tokens present a highly rewarding attack surface. The sheer number of tokens deployed on Ethereum means there’s no shortage of vulnerable implementations waiting to be tested. And while standards like OpenZeppelin provide secure boilerplate implementations, many projects still write custom logic—often introducing new attack vectors in the process.

The key takeaways from this article are:
Always check integer operations—older contracts may still be vulnerable to overflows and underflows.
Race conditions in approvals can lead to stolen funds—use increaseAllowance() and decreaseAllowance() instead of approve().
Reentrancy isn’t just a DeFi problem—even ERC-20 tokens can fall victim to recursive attacks.
Unrestricted minting is a disaster waiting to happen—check who has access to _mint() and whether a token has an enforced max supply.
Front-running attacks are real—especially in DeFi integrations where price-sensitive transactions can be manipulated.

From an offensive security standpoint, fuzzing, unit tests, and manual review are critical tools for discovering these flaws before attackers do. Tools like Foundry allow pentesters to simulate attacks, automate vulnerability discovery, and understand how a contract behaves under stress.

If you’re an auditor, developer, or someone looking to get into smart contract security, ERC-20 is an excellent starting point. The vulnerabilities here apply to a wide range of Solidity contracts, and understanding them will give you a strong foundation for analyzing more complex protocols.

References

Chapters

Botón Anterior
selfdestruct Unleashed: How to Hack Smart Contracts and Fix Them

Previous chapter

Strengthening Smart Contracts: Unit Testing, Fuzzing, and Invariant Testing with Foundry

Next chapter

Enjoyed the article?

Subscribe to the newsletter and get technical insights, cybersecurity tips, and development content straight to your inbox. Or support my work with a coffee ☕ if you found it useful!

📫 Subscribe now ☕ Buy me a coffee