Hacking ERC-20: Pentesting the Most Common Ethereum Token Standard
13 min read
March 2, 2025

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:
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:
- Alice approves Bob to spend
100
tokens. - Alice wants to lower Bob’s allowance to
50
, so she submits a transaction. - Bob, monitoring the mempool, front-runs the transaction and quickly spends the
100
before the new approval takes effect. - 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()
anddecreaseAllowance()
instead ofapprove()
? - 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
- Ethereum Improvement Proposals. "EIP-20: ERC-20 Token Standard." Available at: https://eips.ethereum.org/EIPS/eip-20
- OpenZeppelin Documentation. "ERC20: Standard Token Implementation." Available at: https://docs.openzeppelin.com/contracts/4.x/api/token/erc20
- Solidity Documentation. "Solidity 0.8.x Breaking Changes." Available at: https://docs.soliditylang.org/en/latest/080-breaking-changes.html
- Solidity Documentation. "SafeMath: Avoiding Integer Overflows and Underflows." Available at: https://docs.soliditylang.org/en/latest/security-considerations.html#integer-overflow-and-underflow
- OpenZeppelin Blog. "Understanding Reentrancy Attacks in Smart Contracts." Available at: https://blog.openzeppelin.com/reentrancy-after-istanbul/
- Ethereum Improvement Proposals. "EIP-2771: Secure Meta-Transactions." Available at: https://eips.ethereum.org/EIPS/eip-2771
- OpenZeppelin Documentation. "Preventing Reentrancy Attacks with ReentrancyGuard." Available at: https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard
- OpenZeppelin Documentation. "Using Ownable to Secure Smart Contract Ownership." Available at: https://docs.openzeppelin.com/contracts/4.x/api/access#Ownable
Chapters

Previous chapter
Next chapter
