作者:Pinging(先知社区)
最近研究了许多CVE文章,发现其内容均涉及到某些团队或者个人搭建的私人以太坊代币合约。而在研读他们合约详细内容的时候,我发现巨大多数代币系统均是在ERC20的基础上搭建而成的。所以能够对ERC20的内容有深刻的理解能够帮助我们更好的去研究代币的安全性。在本文中,我们就要对ERC20的关键代码进行详细的个人解读,之后我们会针对部分函数结合区块链的特性进行安全解读。
在详细分析ERC20代码之前,我们先向新人普及一下什么是ERC20 。
在2015年11月份,ERC20标准被区块链行业内推出。写过代码的同志都了解,程序一旦写完,它们就会按照既定的规则执行,在没有外界干扰的情况下,代码执行多少次都会得到同样的结果。而在一个团队中,每个人在进行工作之前都会被Boss拉过去提前召开一些会议。这些会议的目的就是制定某些标准,令所有参与工作的员工能够按照一定规则进行工作。以达到忙中有序的目的。
接触过数字货币的人都应该知道,以太坊是一个分布式的智能合约平台,可以分发代币(Token)。而ERC-20就是这些代币的统一规则,表现出一种通用的和可预测的方式。
简单地说,任何 ERC-20 代币都能立即兼容以太坊钱包,由于交易所已经知道这些代币是如何操作的,它们可以很容易地整合这些代币。这就意味着,在很多情况下,这些代币都是可以立即进行交易的。
下面,我们对ERC20的代码进行分析。而我们知道,对于一个代币来说,其本质就类似于货币的概念。对于货币来说,我们需要它有价值,能进行转账,能被用作交换等等。而对于一个大型的货币规则市场来说,我们还需要类似于人类法律等武器,简单来说我需要能够阻止某些不安全账户对我的代币的使用。能够令代币与现实货币进行交换等等。
下面我们放上标准ERC20代币的链接。
本文中我们对openzeppelin-solidity相关的ERC20进行分析。
首先我们来看起始部分:
pragma solidity ^0.4.24; import "./IERC20.sol"; import "../../math/SafeMath.sol";
开始的时候,合约声明了当前版本以及引入的包。这两个包分别是一个接口包:
这里我们就要讲述一下接口的使用方式了。
对于面向对象的编程语言来说,接口的概念类似于多态的概念。在父类合约中定义一些接口,子类合约在实现其自身代码的时候需要对这些接口中定义的方法进行具体实现。而接口的书写入图所示,开始时的定义为interface
,其内部的函数不需要有函数体。之后,我们能看到合约还定义了两个事件Transfer与Approval
。而事件会在后面的函数执行过程中调用,用于快速打印出这些事件的记录。在我个人看来,事件类似于一个记录本,将当前执行此函数过程中使用到的变量保存并打印出来。
然而在我对接口
进行研究的时候,我也发现了其中存在一些小的问题。我会在下一个部分给读者讲述。
代码如下:
pragma solidity ^0.4.24; /** * @title ERC20 interface * @dev see https://github.com/ethereum/EIPs/issues/20 */ interface IERC20 { function totalSupply() external view returns (uint256); function balanceOf(address who) external view returns (uint256); function allowance(address owner, address spender) external view returns (uint256); function transfer(address to, uint256 value) external returns (bool); function approve(address spender, uint256 value) external returns (bool); function transferFrom(address from, address to, uint256 value) external returns (bool); event Transfer( address indexed from, address indexed to, uint256 value ); event Approval( address indexed owner, address indexed spender, uint256 value ); }
让我们继续回到ERC20.sol
中来。在对接口进行了查看后,我们大致能够猜测这些接口属于一种规范化的提醒。它为ERC20在实现各种函数的时候指明了一些方向。哪些函数是必须要有的,需要传入哪些参数等等。
之后ERC20中还引用了import "../../math/SafeMath.sol";
。
对于我们研究安全的同学来说,这个包是十分经典的。在我们前期对整数溢出问题的分析中,我们知道这些整数就是因为不正确的使用了+ - * /
才导致的安全事件发生。所以现在开发人员在对以太坊开发的时候一般都会使用saveMath来进行安全计算。
pragma solidity ^0.4.24; /** * @title SafeMath * @dev Math operations with safety checks that revert on error */ library SafeMath { /** * @dev Multiplies two numbers, reverts on overflow. */ function mul(uint256 a, uint256 b) internal pure returns (uint256) { // Gas optimization: this is cheaper than requiring 'a' not being zero, but the // benefit is lost if 'b' is also tested. // See: https://github.com/OpenZeppelin/openzeppelin-solidity/pull/522 if (a == 0) { return 0; } uint256 c = a * b; require(c / a == b); return c; } /** * @dev Integer division of two numbers truncating the quotient, reverts on division by zero. */ function div(uint256 a, uint256 b) internal pure returns (uint256) { require(b > 0); // Solidity only automatically asserts when dividing by 0 uint256 c = a / b; // assert(a == b * c + a % b); // There is no case in which this doesn't hold return c; } /** * @dev Subtracts two numbers, reverts on overflow (i.e. if subtrahend is greater than minuend). */ function sub(uint256 a, uint256 b) internal pure returns (uint256) { require(b <= a); uint256 c = a - b; return c; } /** * @dev Adds two numbers, reverts on overflow. */ function add(uint256 a, uint256 b) internal pure returns (uint256) { uint256 c = a + b; require(c >= a); return c; } /** * @dev Divides two numbers and returns the remainder (unsigned integer modulo), * reverts when dividing by zero. */ function mod(uint256 a, uint256 b) internal pure returns (uint256) { require(b != 0); return a % b; } }
它们分别使用了add sub mul div
来代替加减乘除。我们那其中的一个例子来进行说明它是如何保障安全的。
下面我们来看减法:
function sub(uint256 a, uint256 b) internal pure returns (uint256) { require(b <= a); uint256 c = a - b; return c; }
我们的减法传入a,b以实现a-b的操作。首先在函数中,我们第一件事情就是来判断b是否小于等于a。因为我们传入的参数类型为uint
。而我们知道这个类型是无负数的,遇到负数后会产生溢出。所以我们开始就杜绝了这个情况。
对于加法来说。
function add(uint256 a, uint256 b) internal pure returns (uint256) { uint256 c = a + b; require(c >= a); return c; }
加法中的判断是存在于执行了uint256 c = a + b;
后。
倘若溢出的情况产生,那么c一定会 < a + b
。所以我们添加判断:require(c >= a);
。从而杜绝溢出情况。
乘除与之类似。大家可以自行研究。
之后,我们就正式回到了主合约内部。
合约共涉及11个函数,分别用于对用户token进行“增删改查”操作。为了方便用于进行转账操作,合约起始部分定义了三个变量:
mapping (address => uint256) private _balances; mapping (address => mapping (address => uint256)) private _allowed; uint256 private _totalSupply;
分别用于存储:用户余额、用户被授权额度、以及合约内的总金额(私有变量,只能内部查看)。这里我们需要多关注一下第二个变量
之后,合约实现了对_balances 与_allowed
的查看操作。
function totalSupply() public view returns (uint256) { return _totalSupply; } function balanceOf(address owner) public view returns (uint256) { return _balances[owner]; }
这里就是将接口定义的方法进行具体实现。使用view来保证只是返回某个变量的值,而不能对内部内容进行修改操作。
然而,这个地方存在一个设计上的漏洞,我们在下面会讲到
之后我们看一个金额查看函数。这个函数传入了两个地址,owner与spender
,函数对_allowed[owner][spender]
的值进行查看,告知调用者某两个地址直接的授权金额详情。
function allowance( address owner, address spender ) public view returns (uint256) { return _allowed[owner][spender]; }
之后就是token中最重要的转账函数:
function transfer(address to, uint256 value) public returns (bool) { require(value <= _balances[msg.sender]); require(to != address(0)); _balances[msg.sender] = _balances[msg.sender].sub(value); _balances[to] = _balances[to].add(value); emit Transfer(msg.sender, to, value); return true; }
转账函数的设计需要考虑很多细节。首先就是金额是否足够。此处运用了require(value <= _balances[msg.sender]);
来进行判断。之后防止误操作,又对接收人地址进行了判断:require(to != address(0));
。在转账过程中,合约使用了安全函数sub、add
。并在转账成功后将事件emit出来。
而上述转账是对用户的余额(_balances)进行操作。下面我们来看针对_allowed
变量的操作。
在实现这些函数前,我们需要向大家普及下此变量的概念。
在我刚开始看源码的时候我就产生了一个疑问——为什么合约中存在balances却还要定义一个allowed来保存金额?后来在我查阅资料以及源码分析后,我认为这个地方的变量可以理解为一种subcurrent的概念。即我可以授权别的用户使用某些钱来帮助我进行转账操作。
举个简单的例子:假设A欠B100元,而A还钱的时候不用现金去支付,而是使用了价值100元的股票。此时B就拥有了A的100元代理币的使用权。此时B就可以在支付给C100元的时候使用这个代理比,让A去代替B支付,已达到模拟真实的效果。
而这个代理金额授权的函数如下 :
function approve(address spender, uint256 value) public returns (bool) { require(spender != address(0)); _allowed[msg.sender][spender] = value; emit Approval(msg.sender, spender, value); return true; }
传入被授权人与金额后,用户的代币金额则会增加。
之后我们就拥有了别人给与的代币,我们就可以使用这个代币去进行转账操作:
function transferFrom( address from, address to, uint256 value ) public returns (bool) { require(value <= _balances[from]); require(value <= _allowed[from][msg.sender]); require(to != address(0)); _balances[from] = _balances[from].sub(value); _balances[to] = _balances[to].add(value); _allowed[from][msg.sender] = _allowed[from][msg.sender].sub(value); emit Transfer(from, to, value); return true; }
函数传入代币方以及转账接收方(此处使用A B C代替)。A为from,B为调用函数的人,C为to地址。
之后我们会对from的余额进行判断,对_allowed
代币余额进行判断。如果两者均满足要求,那么我们会进行转账操作。将from地址的余额进行扣除,并转给to地址。之后将_allowed
中的余额更新,并记录此事件。
除了转账外,参与用户还能够增加授权代币的金额,使用increaseAllowance
函数:
function increaseAllowance( address spender, uint256 addedValue ) public returns (bool) { require(spender != address(0)); _allowed[msg.sender][spender] = ( _allowed[msg.sender][spender].add(addedValue)); emit Approval(msg.sender, spender, _allowed[msg.sender][spender]); return true; } function decreaseAllowance( address spender, uint256 subtractedValue ) public returns (bool) { require(spender != address(0)); _allowed[msg.sender][spender] = ( _allowed[msg.sender][spender].sub(subtractedValue)); emit Approval(msg.sender, spender, _allowed[msg.sender][spender]); return true; }
保证在原来金额的基础上增加或减少addedvalue。这也是为了方便用户进行更多复杂的代币操作。
对于合约的owner来说,它需要有更多的权利来对合约内的账户进行操作。例如它需要能够修改任意用户的余额。
function _mint(address account, uint256 amount) internal { require(account != 0); _totalSupply = _totalSupply.add(amount); _balances[account] = _balances[account].add(amount); emit Transfer(address(0), account, amount); }
上面是一个挖矿函数,这个函数保证了owner能够任意的增加account的余额,并更新合约内总币金额。由于这个函数直接对敏感信息进行操作,所以需要对此函数进行严格的权限过滤。
除了增加外,合约还能够做到删除:
function _burn(address account, uint256 amount) internal { require(account != 0); require(amount <= _balances[account]); _totalSupply = _totalSupply.sub(amount); _balances[account] = _balances[account].sub(amount); emit Transfer(account, address(0), amount); }
对allowed也是如此:
function _burnFrom(address account, uint256 amount) internal { require(amount <= _allowed[account][msg.sender]); // Should https://github.com/OpenZeppelin/zeppelin-solidity/issues/707 be accepted, // this function needs to emit an event with the updated approval. _allowed[account][msg.sender] = _allowed[account][msg.sender].sub( amount); _burn(account, amount); } }
到此,合约中的“增删改查”操作均已经交代完毕,下面我们就来看看这个流行于市面上的ERC20标准代币内存在哪些安全隐患呢?
我们知道ERC20引入了一个接口sol文件。而interface的作用就是提前规定好代币的框架。ERC20必须遵循这个框架进行部署。按照原理说,我框架是什么样子,那么我实现的函数就应该是什么样子。可是在实验中,我们发现一个奇怪的现象。
下面是我们的测试合约:
首先我们定义一个接口。
interface Building { function isLastFloor(uint) view public returns (bool); }
之后我们写入一个实现接口的合约:
contract Elevator { bool public top; uint public floor; function goTo(uint _floor) public returns(bool){ Building building = Building(msg.sender); if (! building.isLastFloor(_floor)) { floor = _floor; top = building.isLastFloor(floor); return top; } } } contract hacker{ bool ls = true; function isLastFloor(uint) public returns (bool){ ls = !ls; return ls; } function hack() public returns(bool){ Elevator t = new Elevator(); return t.goTo(4); } }
在Elevator
合约中我们能够发现,我们在goTo
函数中new了一个Building对象,而这个类则实现了我们的接口。只要我msg.sender中存在接口中定义的isLastFloor
函数,则便可以调用成功。下面我们看一下测试:
开始时我们将函数进行注释。之后进行部署、调用方法:
发现tx不成功。
之后我们将注释解除,再部署后执行成功。
此时不知道读者有没有发现,我们在接口中定义的函数是带有view的。也就是说view函数是无法更改内部变量的值的。而我们这里重定义后的函数将view去掉了:
也就是说我们并没有按照接口的定义来进行部署。这样会改变设计者本来的想法。类似的题目有一道ctf。这是合约比赛题目https://ethernaut.zeppelin.solutions/level/0xcd596b3e7063b068f54054beb4fb4074c87e8ad8
。就是由于view不严谨而导致的,除了view之外,我们还需要考虑constant已经pure。
我们知道对于区块链系统来说,交易发出后到上链是有一点的时间周期的,需要挖矿人将交易打包发送。所以我们有可能在这个时间段内进行恶意攻击。
下面我们看攻击流程:
首先,我们假设参与方有A,B。
1 A授权B能够进行转账N个subcurrent(A调用prove函数来授权B),并传入参数N。
2 在过了一段时间后,A决定将N更改为M。所以此时B就能够转N个数量的钱来付款。
3 B注意到A将金额从N改变到M。此时B就可以进行作恶操作,他完全可以在交易打包前(新设置生效前)将N个金额花费出去,然后等待新交易打包成功后,B就更新了自己的M个代币。
4 此时B就拥有了N+M个代币转账能力。
5 所以B在A意识到问题之前,就可以调用transferFrom
函数,多花费一份价值N个代币的钱。
更多的分析我们可以参照英文文章:https://docs.google.com/document/d/1YLPtQxZu1UAvO9cZ1O2RPXBbT0mooh4DYKjA_jp-RLM/edit#
。