Appearance
Naught Coin:解密十年锁定,你的ERC20真的安全吗?
在以太坊智能合约的世界里,Ethernaut CTF 系列挑战赛一直是学习和磨练安全技能的绝佳平台。今天,我们将深入探讨其中一个经典而有趣的关卡——Naught Coin。这个题目巧妙地利用了ERC20代币标准的一个特性,让我们在看似无解的“十年锁定期”中找到一条逃生之路。
Naught Coin之谜:手握巨款,却寸步难行
想象一下这样的场景:你辛辛苦苦铸造了一个全新的ERC20代币——Naught Coin,并且你拥有了全部的初始供应量。但好景不长,合约的作者为你设置了一个“甜蜜的陷阱”:你作为代币的初始拥有者,只有在十年之后,才能自由地将这些代币转账给其他地址!而挑战的目标却是,让你的代币余额归零。
这听起来像是一个不可能完成的任务,对吧?让我们来看看Naught Coin的核心合约代码:
solidity
// NaughtCoin.sol 核心代码片段
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, INITIAL_SUPPLY); // 将全部代币铸造给player
}
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value); // 调用父合约的transfer
}
// 限制初始拥有者在锁定期内不能转账的修饰符
modifier lockTokens() {
if (msg.sender == player) { // 如果是player在调用
require(block.timestamp > timeLock); // 必须在锁定期之后
_;
} else {
_; // 其他人不受限制
}
}
}
从代码中我们可以清晰地看到,transfer 函数被 lockTokens 修饰符所限制。如果调用者是 player (也就是我们),则必须等到 block.timestamp 超过 timeLock(十年之后)才能成功转账。这堵墙似乎坚不可摧。
寻找突破口:ERC20标准中的隐藏力量
题目描述中给出了两个重要的提示:ERC20 Spec 和 OpenZeppelin codebase。这些提示往往是解题的关键。如果我们不能直接调用 transfer 函数,那么ERC20标准中是否还有其他方式可以转移代币呢?
答案是:当然有! ERC20标准不仅仅只有 transfer,它还定义了 approve 和 transferFrom 这两个核心函数,用于实现“授权转账”的机制。
approve(address spender, uint256 amount):允许spender(一个第三方地址或合约)从你的账户中花费amount数量的代币。transferFrom(address sender, address recipient, uint256 amount):由spender调用,将sender授权给spender的amount数量的代币转移到recipient。
现在,我们重新审视NaughtCoin合约。lockTokens 修饰符只应用在重写的 transfer 函数上。而 transferFrom 函数呢?它继承自OpenZeppelin的ERC20基类,在NaughtCoin合约中并未被重写,也没有被任何修饰符限制!
这正是我们的突破口!
破解之道:智取Naught Coin
有了这个发现,解决Naught Coin挑战的策略就呼之欲出了:
- 作为
player,我们调用 NaughtCoin 合约的approve函数。 我们将全部的Naught Coin授权给一个我们自己部署的 “Hack”攻击合约。approve函数并未被lockTokens修饰符限制,因此可以自由调用。 - 部署并调用“Hack”攻击合约。 这个攻击合约拥有从
player账户中转移Naught Coin的权限。 - 攻击合约调用 NaughtCoin 合约的
transferFrom函数。 由攻击合约发起调用,将player的所有Naught Coin转移到攻击合约自身,或者其他任何地址。由于transferFrom没有lockTokens限制,这次转账将会成功。
这样,player 账户中的Naught Coin余额就会变为零,挑战完成!
核心原理与代码解析
让我们看看这个“Hack”合约的实现:
solidity
// Hack.sol
pragma solidity ^0.8.0;
import "./IERC20.sol"; // 引入ERC20接口
interface INaughtCoin is IERC20 {} // 定义NaughtCoin的接口
contract Hack {
INaughtCoin private immutable token; // 声明一个NaughtCoin合约实例
constructor(address _token) {
token = INaughtCoin(_token); // 构造函数,传入NaughtCoin合约地址
}
function transferAll(address _from, address _to, uint _amount) external {
// 调用NaughtCoin合约的transferFrom函数
bool success = token.transferFrom(_from, _to, _amount);
require(success, "transfer fail!");
}
}
这个 Hack 合约非常简单:
- 构造函数接收NaughtCoin的合约地址,并创建一个类型为
INaughtCoin的实例。 transferAll函数封装了对token.transferFrom的调用。它接收_from(代币来源,即player地址),_to(代币目标地址,可以是Hack合约本身或任意地址),_amount(转账数量)。
实践验证(自动化测试)
在实际的CTF环境中,我们会通过自动化脚本来验证这个攻击流程:
- 部署
NaughtCoin合约,并将所有代币铸造给player。 - 部署
Hack合约,传入NaughtCoin合约地址。 player调用naughtCoinContract.approve(HACK_ADDRESS, INITIAL_SUPPLY),授权Hack合约转移所有代币。Hack合约调用hack.transferAll(PLAYER, HACK_ADDRESS, INITIAL_SUPPLY),将代币从player转移到Hack合约。- 断言
player的naughtCoinContract.balanceOf(PLAYER)为 0。
整个过程顺畅无阻,完美绕过了“十年锁定期”。
Ethernaut 挑战的启示
Naught Coin这个挑战深刻地提醒我们:
- 深入理解标准: 不要仅仅关注一个函数,而要全面理解ERC20等代币标准的全部功能和机制。
approve和transferFrom是ERC20不可或缺的一部分。 - 警惕继承: 当我们从基类(如OpenZeppelin的ERC20合约)继承功能时,必须清楚哪些函数被重写、哪些函数保持不变,以及它们各自的权限和修饰符。任何未被重写的公共函数都可能成为未预料的攻击点。
- 细致的合约审计: 智能合约的安全性在于每一行代码。即使是看起来简单的锁定期,也可能因为对标准理解不够透彻而留下漏洞。
Naught Coin无疑是一个精彩的入门级Ethernaut挑战,它用一个巧妙的设计,教导我们如何在智能合约安全领域进行更深入的思考。在区块链的世界里,安全无小事,理解每一个细节是保障资产安全的关键。