Hacking ERC-20: Pentesting the Most Common Ethereum Token Standard
17 min read
March 2, 2025
đ§ Site Migration Notice
I've recently migrated this site from Ghost CMS to a new Astro-based frontend. While I've worked hard to ensure everything transferred correctly, some articles may contain formatting errors or broken elements.
If you spot any issues, I'd really appreciate it if you could let me know! Your feedback helps improve the site for everyone.

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
100tokens. - 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
100before the new approval takes effect. - Once Aliceâs update is processed, Bob still has access to the new
50tokens.
đ 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
ReentrancyGuardor 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