🪙 Naught Coin - The Incomplete Standard Lock
Challenge Overview
In this level, we are given 1,000,000 Naught Coins (an ERC20 token). We are the owner of the supply, but the contract enforces a lock: we are prevented from transferring any tokens for 10 years!
To beat the level, our objective is to transfer all our tokens out of our account, reducing our balance to 0.
Technical Analysis
Vulnerability: Incomplete Interface Override
The contract attempts to lock our tokens by adding a time-lock modifier lockTokens to the standard transfer function:
contract NaughtCoin is ERC20 { // ... modifier lockTokens() { if (msg.sender == player) { require(block.timestamp > timeLock, "Tokens locked!"); } _; }
// [VULNERABILITY] Only transfer() is overridden and locked function transfer(address _to, uint256 _value) override public lockTokens returns(bool) { super.transfer(_to, _value); }}The Root Cause
The vulnerability lies in the incomplete implementation of the ERC20 standard:
- Two Ways to Transfer: The ERC20 standard defines two distinct methods to transfer tokens:
- Direct:
transfer(recipient, amount)(called directly by the token holder). - Delegated:
approve(spender, amount)followed bytransferFrom(sender, recipient, amount)(called by a spender who has been granted an allowance).
- Direct:
- Incomplete Lock: The
NaughtCoincontract overrides and applies thelockTokensmodifier only totransfer(). - Open Spender Methods: The standard
approve()andtransferFrom()functions are inherited directly from OpenZeppelin’sERC20contract and are not overridden or locked!
Because of this gap, we can approve another address (or a simple attacker contract) to spend our tokens, and then execute transferFrom() to transfer all tokens, bypassing the lock.
The Exploit: The Multi-Step Solution
- Grant Allowance: Call
approve(spender, 1000000)from our player EOA, approving either an attacker contract or a secondary wallet address to spend our entire balance. - Execute Delegated Transfer: Call
transferFrom(player, recipient, 1000000)from the authorized spender address. - Lock Bypassed: The tokens are successfully transferred out, and our balance falls to 0.
Proof of Concept (PoC)
Below is the JavaScript console walkthrough (which does not require deploying an exploit contract):
const playerAddress = player;const spenderAddress = "0x0000000000000000000000000000000000000000"; // Replace with any secondary addressconst amount = await contract.balanceOf(playerAddress);
// 1. Approve ourselves or a secondary address to spend our tokensawait contract.approve(playerAddress, amount);
// 2. Call transferFrom using the approved allowanceawait contract.transferFrom(playerAddress, spenderAddress, amount);
// Verify our balance is now 0const remainingBalance = await contract.balanceOf(playerAddress);console.log("Remaining player balance:", remainingBalance.toString());Auditor’s Lessons
- Audit the Entire Interface: When enforcing access control or state locks on standard specifications (ERC20, ERC721, ERC1155), you must analyze and lock all functions that can modify that state.
- Inherited Function Risks: Be extremely careful with inherited code. Untouched functions in parent contracts can bypass custom restrictions placed in child contracts.
- Write Comprehensive Integration Tests: Ensure test suites cover all possible standard interaction pathways (such as delegated approvals) to verify that lock systems cannot be circumvented.
Remediation
To secure the coin, override both transfer and transferFrom with the lockTokens modifier:
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract SecureNaughtCoin is ERC20 { address public player; uint256 public timeLock;
constructor() ERC20("SecureNaughtCoin", "SNC") { player = msg.sender; timeLock = block.timestamp + 10 * 365 days; _mint(player, 1000000 * 10**decimals()); }
modifier lockTokens(address _from) { if (_from == player) { require(block.timestamp > timeLock, "Tokens locked!"); } _; }
// Lock direct transfers function transfer(address _to, uint256 _value) override public lockTokens(msg.sender) returns(bool) { return super.transfer(_to, _value); }
// Lock delegated transfers function transferFrom(address _from, address _to, uint256 _value) override public lockTokens(_from) returns(bool) { return super.transferFrom(_from, _to, _value); }}