Skip to content
On this page

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 SpecOpenZeppelin codebase。这些提示往往是解题的关键。如果我们不能直接调用 transfer 函数,那么ERC20标准中是否还有其他方式可以转移代币呢?

答案是:当然有! ERC20标准不仅仅只有 transfer,它还定义了 approvetransferFrom 这两个核心函数,用于实现“授权转账”的机制。

  • approve(address spender, uint256 amount):允许 spender (一个第三方地址或合约)从你的账户中花费 amount 数量的代币。
  • transferFrom(address sender, address recipient, uint256 amount):由 spender 调用,将 sender 授权给 spenderamount 数量的代币转移到 recipient

现在,我们重新审视NaughtCoin合约。lockTokens 修饰符只应用在重写的 transfer 函数上。而 transferFrom 函数呢?它继承自OpenZeppelin的ERC20基类,在NaughtCoin合约中并未被重写,也没有被任何修饰符限制!

这正是我们的突破口!

破解之道:智取Naught Coin

有了这个发现,解决Naught Coin挑战的策略就呼之欲出了:

  1. 作为 player,我们调用 NaughtCoin 合约的 approve 函数。 我们将全部的Naught Coin授权给一个我们自己部署的 “Hack”攻击合约approve 函数并未被 lockTokens 修饰符限制,因此可以自由调用。
  2. 部署并调用“Hack”攻击合约。 这个攻击合约拥有从 player 账户中转移Naught Coin的权限。
  3. 攻击合约调用 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环境中,我们会通过自动化脚本来验证这个攻击流程:

  1. 部署 NaughtCoin 合约,并将所有代币铸造给 player
  2. 部署 Hack 合约,传入 NaughtCoin 合约地址。
  3. player 调用 naughtCoinContract.approve(HACK_ADDRESS, INITIAL_SUPPLY),授权 Hack 合约转移所有代币。
  4. Hack 合约调用 hack.transferAll(PLAYER, HACK_ADDRESS, INITIAL_SUPPLY),将代币从 player 转移到 Hack 合约。
  5. 断言 playernaughtCoinContract.balanceOf(PLAYER) 为 0。

整个过程顺畅无阻,完美绕过了“十年锁定期”。

Ethernaut 挑战的启示

Naught Coin这个挑战深刻地提醒我们:

  1. 深入理解标准: 不要仅仅关注一个函数,而要全面理解ERC20等代币标准的全部功能和机制。approvetransferFrom 是ERC20不可或缺的一部分。
  2. 警惕继承: 当我们从基类(如OpenZeppelin的ERC20合约)继承功能时,必须清楚哪些函数被重写、哪些函数保持不变,以及它们各自的权限和修饰符。任何未被重写的公共函数都可能成为未预料的攻击点。
  3. 细致的合约审计: 智能合约的安全性在于每一行代码。即使是看起来简单的锁定期,也可能因为对标准理解不够透彻而留下漏洞。

Naught Coin无疑是一个精彩的入门级Ethernaut挑战,它用一个巧妙的设计,教导我们如何在智能合约安全领域进行更深入的思考。在区块链的世界里,安全无小事,理解每一个细节是保障资产安全的关键。

Built with AiAda