Skip to content
On this page

探秘Token合约:如何“合法”地获取天文数字的代币?

在区块链的世界里,每一个智能合约都像是一个精密的齿轮,运行着我们想象不到的经济模型。今天,我们要挑战的正是Ethernaut平台上的一个经典关卡——“Token”。这个关卡看似简单,却隐藏着一个关于**“整数溢出”**的巧妙陷阱,等待着有心人去发掘。

目标: 你的任务是潜入一个基础的Token合约,原本你拥有20个代币,但你必须想办法,哪怕是“不择手段”,获取更多的代币,最好是数量极其庞大!

关键线索: 题目中留下了一个耐人寻味的提示:“什么是里程表(Odometer)?”

剖析Token合约:表面无恙的“漏洞”

我们先来看看这次的挑战对象——Token.sol

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {
    mapping(address => uint256) balances; // 存储每个地址的代币余额
    uint256 public totalSupply;          // 代币总供应量

    constructor(uint256 _initialSupply) public {
        balances[msg.sender] = totalSupply = _initialSupply; // 构造函数,初始化发送者余额和总供应量
    }

    function transfer(address _to, uint256 _value) public returns (bool) {
        // 关键点:require(balances[msg.sender] - _value >= 0);
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

    function balanceOf(address _owner) public view returns (uint256 balance) {
        return balances[_owner];
    }
}

乍一看,这个Token合约似乎非常标准:

  • balances:一个mapping,用来记录每个地址拥有的代币数量。
  • totalSupply:记录代币的总量。
  • constructor:在部署合约时,会给部署者(msg.sender)初始数量的代币,并设置totalSupply
  • transfer:用于代币的转账,它做了两件事:
    1. 检查余额: require(balances[msg.sender] - _value >= 0); 确保发送者有足够的余额进行转账。
    2. 更新余额: 从发送者扣除,加到接收者那里。
  • balanceOf:查询指定地址的余额。

隐藏的陷阱:当“减法”遇上“里程表”

现在,我们回过头来看看那个神秘的提示:“什么是里程表(Odometer)?”

想象一下汽车的里程表,当它达到最大值时,会发生什么?它会从零重新开始计数。在计算机科学中,这被称为**“整数溢出”**。

在Solidity中,uint256是一种无符号256位整数,它有一个最大值。当一个uint256变量的值进行运算,并且结果超出了其最大值时,就会发生溢出。对于无符号整数来说,溢出通常会导致数值“绕回”到最小值(即0)。

现在,我们再来看transfer函数中的这行关键代码:

solidity
require(balances[msg.sender] - _value >= 0);

乍一看,这似乎很安全,它阻止了用户转走比自己拥有的更多的代币。但是,如果_value本身就非常非常大呢?

问题的核心在于,Solidity中的算术运算是在满足require条件之后才执行的。

假设你当前拥有10个代币(balances[msg.sender] = 10)。 而你想转账的_valueuint256 的最大值(2^256 - 1)。

  1. balances[msg.sender] - _value 的计算: 在这个require检查中,Solidity会尝试计算 10 - (2^256 - 1)。 由于102^256 - 1小得多,这个减法结果会负数。 然而,在Solidity 0.6.0版本之前(请注意题目要求pragma solidity ^0.6.0;,但实际可能存在兼容性问题或作者故意设置),或者在某些特定环境下,如果存在整数溢出的风险,这个检查本身可能会被绕过,或者计算结果因为溢出而变得非常大。

    更准确地说,是当 _value 远大于 balances[msg.sender] 时,balances[msg.sender] - _value 的结果会因为溢出变成一个非常大的正数,而不是预期的负数。

    例如,如果 balances[msg.sender] = 10,而 _value 设置成 uint256 的最大值 MAX_UINT256。 那么 balances[msg.sender] - _value 在进行溢出计算后,结果会是一个非常接近 MAX_UINT256 的值,远远大于 0。 这样,require(balances[msg.sender] - _value >= 0); 这个检查就会通过!

  2. 执行实际的转账:require通过后,实际的减法 balances[msg.sender] -= _value; 会执行。 由于balances[msg.sender](10)减去一个比它大得多的数(MAX_UINT256),会发生下溢(underflow),最终导致 balances[msg.sender] 变成一个极大的正数,几乎等于 MAX_UINT256

    然后,balances[_to] += _value; 这行代码会执行。 此时,_to 的地址会收到 _value(即 MAX_UINT256)这么多代币!

    你就成功地获得了天文数字般的代币!

解决方案:巧借“敌”力

理解了核心漏洞后,如何利用它呢?我们需要一个能够执行transfer函数,并且提供一个极大_value的合约。

看看给出的Hack.sol

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface IToken {
    function transfer(address _to, uint256 _value) external returns (bool);
    function balanceOf(address _owner) external view returns (uint256 balance);
}

contract Hack {
    constructor(address _target, address _level) public {
        // 关键点:IToken(_target).transfer(msg.sender, IToken(_target).balanceOf(_level));
        IToken(_target).transfer(msg.sender, IToken(_target).balanceOf(_level));
    }
}

这个Hack合约做了什么?

  1. 它定义了一个IToken接口,可以与Token合约进行交互。
  2. constructor中,它接收了两个参数:_target(目标Token合约的地址)和_level(挑战关卡的地址)。
  3. 它调用了目标Token合约的transfer函数。
  4. 转账的目标地址是msg.sender(即部署Hack合约的地址,也就是你)。
  5. 转账的金额_valueIToken(_target).balanceOf(_level)

等等,这里似乎不是直接传入一个巨大的_value,而是将关卡合约的余额作为转账金额?

是的,这里的巧妙之处在于,题目作者可能在部署关卡时,在LEVEL_ADDRESS这个合约里预先存入了一个非常非常大的代币数量(例如,通过其他方式获取,或者就是在部署时设置的)。

所以,Hack合约的逻辑就是:

  1. 找到目标Token合约。
  2. 查询挑战关卡合约(_level)在Token合约中的余额。
  3. 将关卡合约“拥有的”那笔天文数字的代币,通过transfer函数,“合法地”转给自己(msg.sender)。

由于我们之前分析的transfer函数中存在的整数下溢漏洞,即使关卡合约实际拥有的代币数量没有那么夸张,只要我们能让_value(即IToken(_target).balanceOf(_level))变得足够大,就能够触发下溢,从而让自己获得海量的代币。

在实际的Ethernaut环境中,_level合约的余额很可能就是被精心设置成一个非常大的数字,直接用于触发溢出。

总结:

这个“Token”关卡,通过一个看似无害的减法运算,巧妙地利用了Solidity中整数溢出的特性。它提醒我们,在编写智能合约时,必须时刻警惕各种边界条件和可能的数值溢出,尤其是在进行减法运算时,确保被减数和减数之间的关系是安全的,或者使用openzeppelin-solidity库中提供的 SafeMath 库来防止溢出。

而破解之道,正是利用这个漏洞,让“转账金额”这个变量变得足够大,从而触发整数下溢,将大量的代币“转移”到自己的地址上。这个关卡不仅锻炼了我们对Solidity底层机制的理解,也教会了我们如何通过分析代码来发现潜在的安全风险。

Built with AiAda