Appearance
探秘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:用于代币的转账,它做了两件事:- 检查余额:
require(balances[msg.sender] - _value >= 0);确保发送者有足够的余额进行转账。 - 更新余额: 从发送者扣除,加到接收者那里。
- 检查余额:
balanceOf:查询指定地址的余额。
隐藏的陷阱:当“减法”遇上“里程表”
现在,我们回过头来看看那个神秘的提示:“什么是里程表(Odometer)?”
想象一下汽车的里程表,当它达到最大值时,会发生什么?它会从零重新开始计数。在计算机科学中,这被称为**“整数溢出”**。
在Solidity中,uint256是一种无符号256位整数,它有一个最大值。当一个uint256变量的值进行运算,并且结果超出了其最大值时,就会发生溢出。对于无符号整数来说,溢出通常会导致数值“绕回”到最小值(即0)。
现在,我们再来看transfer函数中的这行关键代码:
solidity
require(balances[msg.sender] - _value >= 0);
乍一看,这似乎很安全,它阻止了用户转走比自己拥有的更多的代币。但是,如果_value本身就非常非常大呢?
问题的核心在于,Solidity中的算术运算是在满足require条件之后才执行的。
假设你当前拥有10个代币(balances[msg.sender] = 10)。 而你想转账的_value是 uint256 的最大值(2^256 - 1)。
balances[msg.sender] - _value的计算: 在这个require检查中,Solidity会尝试计算10 - (2^256 - 1)。 由于10比2^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);这个检查就会通过!执行实际的转账: 当
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合约做了什么?
- 它定义了一个
IToken接口,可以与Token合约进行交互。 - 在
constructor中,它接收了两个参数:_target(目标Token合约的地址)和_level(挑战关卡的地址)。 - 它调用了目标
Token合约的transfer函数。 - 转账的目标地址是
msg.sender(即部署Hack合约的地址,也就是你)。 - 转账的金额
_value是IToken(_target).balanceOf(_level)。
等等,这里似乎不是直接传入一个巨大的_value,而是将关卡合约的余额作为转账金额?
是的,这里的巧妙之处在于,题目作者可能在部署关卡时,在LEVEL_ADDRESS这个合约里预先存入了一个非常非常大的代币数量(例如,通过其他方式获取,或者就是在部署时设置的)。
所以,Hack合约的逻辑就是:
- 找到目标
Token合约。 - 查询挑战关卡合约(
_level)在Token合约中的余额。 - 将关卡合约“拥有的”那笔天文数字的代币,通过
transfer函数,“合法地”转给自己(msg.sender)。
由于我们之前分析的transfer函数中存在的整数下溢漏洞,即使关卡合约实际拥有的代币数量没有那么夸张,只要我们能让_value(即IToken(_target).balanceOf(_level))变得足够大,就能够触发下溢,从而让自己获得海量的代币。
在实际的Ethernaut环境中,_level合约的余额很可能就是被精心设置成一个非常大的数字,直接用于触发溢出。
总结:
这个“Token”关卡,通过一个看似无害的减法运算,巧妙地利用了Solidity中整数溢出的特性。它提醒我们,在编写智能合约时,必须时刻警惕各种边界条件和可能的数值溢出,尤其是在进行减法运算时,确保被减数和减数之间的关系是安全的,或者使用openzeppelin-solidity库中提供的 SafeMath 库来防止溢出。
而破解之道,正是利用这个漏洞,让“转账金额”这个变量变得足够大,从而触发整数下溢,将大量的代币“转移”到自己的地址上。这个关卡不仅锻炼了我们对Solidity底层机制的理解,也教会了我们如何通过分析代码来发现潜在的安全风险。