作者:Pinging(先知社区)
在上一篇文章中,我们详细地讲述了solidity中的整数溢出漏洞。而在本文中,我们将重点放在真实事件中,从0开始针对某些真实环境中的CVE进行复现操作。一步一步带领大家去了解合约内部的秘密。
本文中涉及的漏洞内容均为整数溢出的CVE,我们会对源代码进行详细的分析,并在分析漏洞的过程中讲述合约的搭建内容。
这次分析漏洞CVE-2018-11811。由于上次的文章我们详细的讲述了整数溢出漏洞的原理以及一些合约实例。所以这次我们趁热打铁,针对真实生产环境中的cve漏洞进行分析、复现。
这个漏洞是于今年6月12日由清华-360企业安全联合研究中心的张超教授团队披露出来的,安比(SECBIT)实验室针对这些漏洞,对以太坊上已部署的23357个合约进行了分析检测,发现共有866个合约存在相同问题。而今天我们讲述的这个漏洞共影响了288个智能合约。
合约源码如下:https://etherscan.io/address/0x0b76544f6c413a555f309bf76260d1e02377c02a
在详细讲述合约漏洞前,我们将合约的具体内容交代清楚。
这个合约发行了一套自己的token,并且可以用以太币对这些token进行交换。即包括:用户可以使用以太币兑换token,也可以出售token去获得以太币。而具体实现的函数如下:
这是owner对购买与卖出价格进行设定的函数:
function setPrices(uint256 newSellPrice, uint256 newBuyPrice) onlyOwner { sellPrice = newSellPrice; buyPrice = newBuyPrice; }
下面是用户对token进行购买的函数:
function buy() payable { uint amount = msg.value / buyPrice; // calculates the amount _transfer(this, msg.sender, amount); // makes the transfers }
这是用户出售token去换取以太币的函数:
function sell(uint256 amount) { require(this.balance >= amount * sellPrice); // checks if the contract has enough ether to buy _transfer(msg.sender, this, amount); // makes the transfers msg.sender.transfer(amount * sellPrice); // sends ether to the seller. It's important to do this last to avoid recursion attacks }
而问题就出在上面的三个函数中。最终达成的效果就是我平台的搭建者可以令用户用高价购买token却在卖出的时候以很便宜的价格卖出。从而达成对平台使用者的欺诈行为。
这个合约是多继承于其他合约而部署的。所以在分析的时候我们要有针对性。
首先,我们需要看主要的合约内容:
contract INTToken is owned, token { uint256 public sellPrice; uint256 public buyPrice; mapping (address => bool) public frozenAccount; /* This generates a public event on the blockchain that will notify clients */ event FrozenFunds(address target, bool frozen); /* Initializes contract with initial supply tokens to the creator of the contract */ function INTToken( uint256 initialSupply, string tokenName, uint8 decimalUnits, string tokenSymbol ) token (initialSupply, tokenName, decimalUnits, tokenSymbol) {} /* Internal transfer, only can be called by this contract */ function _transfer(address _from, address _to, uint _value) internal { require (_to != 0x0); // Prevent transfer to 0x0 address. Use burn() instead require (balanceOf[_from] > _value); // Check if the sender has enough require (balanceOf[_to] + _value > balanceOf[_to]); // Check for overflows require(!frozenAccount[_from]); // Check if sender is frozen require(!frozenAccount[_to]); // Check if recipient is frozen balanceOf[_from] -= _value; // Subtract from the sender balanceOf[_to] += _value; // Add the same to the recipient Transfer(_from, _to, _value); } /// @notice Create `mintedAmount` tokens and send it to `target` /// @param target Address to receive the tokens /// @param mintedAmount the amount of tokens it will receive function mintToken(address target, uint256 mintedAmount) onlyOwner { balanceOf[target] += mintedAmount; totalSupply += mintedAmount; Transfer(0, this, mintedAmount); Transfer(this, target, mintedAmount); } /// @notice `freeze? Prevent | Allow` `target` from sending & receiving tokens /// @param target Address to be frozen /// @param freeze either to freeze it or not function freezeAccount(address target, bool freeze) onlyOwner { frozenAccount[target] = freeze; FrozenFunds(target, freeze); } /// @notice Allow users to buy tokens for `newBuyPrice` eth and sell tokens for `newSellPrice` eth /// @param newSellPrice Price the users can sell to the contract /// @param newBuyPrice Price users can buy from the contract function setPrices(uint256 newSellPrice, uint256 newBuyPrice) onlyOwner { sellPrice = newSellPrice; buyPrice = newBuyPrice; } /// @notice Buy tokens from contract by sending ether function buy() payable { uint amount = msg.value / buyPrice; // calculates the amount _transfer(this, msg.sender, amount); // makes the transfers } /// @notice Sell `amount` tokens to contract /// @param amount amount of tokens to be sold function sell(uint256 amount) { require(this.balance >= amount * sellPrice); // checks if the contract has enough ether to buy _transfer(msg.sender, this, amount); // makes the transfers msg.sender.transfer(amount * sellPrice); // sends ether to the seller. It's important to do this last to avoid recursion attacks } }
这是最终的合约。这也是安全隐患发生的地方。
下面我们详细的分析一下这个合约的功能。
首先,这个合约继承了owned, token
合约。而我们向上看,owned
合约为:
contract owned { address public owner; function owned() { owner = msg.sender; } //构造函数,初始化合约owner modifier onlyOwner { require(msg.sender == owner); _; } function transferOwnership(address newOwner) onlyOwner { owner = newOwner; } }
这个合约比较简单,构造函数将合约的owner变量初始化,并且定义了限定函数--onlyOwner
。这个函数限定了调用者必须是合约拥有者。之后为了后面开发方便,合约又定义了transferOwnership
,用这个函数来改变现在的合约owner。
这也为我们的开发提醒,因为solidity合约部署之后是无法改变的,所以我们要尽可能的为后来的修改做考虑,增加一些必要的函数。
下面我们看第二个父合约:token
。
我们的主合约在启动时调用构造函数:
function INTToken( uint256 initialSupply, string tokenName, uint8 decimalUnits, string tokenSymbol ) token (initialSupply, tokenName, decimalUnits, tokenSymbol) {}
而构造函数只有简单的传参操作,所以很明显它是调用了父合约token
的构造函数:
function token( uint256 initialSupply, string tokenName, uint8 decimalUnits, string tokenSymbol ) //传入参数 { balanceOf[msg.sender] = initialSupply; // Give the creator all initial tokens 初始化用户金额 totalSupply = initialSupply; // Update total supply 更新总金额 name = tokenName; // Set the name for display purposes symbol = tokenSymbol; // Set the symbol for display purposes 显示小数的符号 decimals = decimalUnits; // Amount of decimals for display purposes 更新小数的值 }
其中传入了四个参数,分别代表庄家初始化总金额、token的名字、小数部分的具体值、token的标志类型
之后是主合约的_transfer
函数,这个函数从新定义了其父合约的同名函数。在父合约中,其函数定义为:
function _transfer(address _from, address _to, uint _value) internal { require (_to != 0x0); // Prevent transfer to 0x0 address. Use burn() instead require (balanceOf[_from] > _value); // Check if the sender has enough require (balanceOf[_to] + _value > balanceOf[_to]); // Check for overflows 检查溢出 balanceOf[_from] -= _value; // Subtract from the sender balanceOf[_to] += _value; // Add the same to the recipient Transfer(_from, _to, _value); }
传入转账方已经接收方的地址以及转账的值。之后调用了一些判断,包括:“接收方地址不为0、转账方的余额>转账金额;增加了防止溢出的判断”。
而子合约中,在上述基础上又增加了判断是否转账方已经接受方账户被冻结的函数。
function _transfer(address _from, address _to, uint _value) internal { require (_to != 0x0); // Prevent transfer to 0x0 address. Use burn() instead require (balanceOf[_from] > _value); // Check if the sender has enough require (balanceOf[_to] + _value > balanceOf[_to]); // Check for overflows require(!frozenAccount[_from]); // Check if sender is frozen require(!frozenAccount[_to]); // Check if recipient is frozen balanceOf[_from] -= _value; // Subtract from the sender balanceOf[_to] += _value; // Add the same to the recipient Transfer(_from, _to, _value); }
之后,合约拥有者可以调用下面的函数来增加某个账户代币的数量。PS:这里我觉得是为了合约的健壮性考虑。合约的owner需要有足够的能力去增删改查所有用户的token数量。
之后增加金额后会将总金额-totalSupply
进行更新。
/// @notice Create `mintedAmount` tokens and send it to `target` /// @param target Address to receive the tokens /// @param mintedAmount the amount of tokens it will receive function mintToken(address target, uint256 mintedAmount) onlyOwner { balanceOf[target] += mintedAmount; totalSupply += mintedAmount; Transfer(0, this, mintedAmount); Transfer(this, target, mintedAmount); }
除了上述增加token外,合约还定义了burn
函数。
/// @notice Remove `_value` tokens from the system irreversibly /// @param _value the amount of money to burn function burn(uint256 _value) returns (bool success) { require (balanceOf[msg.sender] > _value); // Check if the sender has enough balanceOf[msg.sender] -= _value; // Subtract from the sender totalSupply -= _value; // Updates totalSupply Burn(msg.sender, _value); return true; }
这个函数的含义我还没有搞懂,但是从函数内容来看,它是用于销毁账户账面的部分金额。(就是说账户里有10块钱,我可以调用此函数来永久销毁2块钱)。
为了彰显owner的绝对权力,我们合约中能够查看到冻结账户的函数。
/// @notice `freeze? Prevent | Allow` `target` from sending & receiving tokens /// @param target Address to be frozen /// @param freeze either to freeze it or not function freezeAccount(address target, bool freeze) onlyOwner { frozenAccount[target] = freeze; FrozenFunds(target, freeze); }
合约的owner可以调用这个函数来冻结或者解冻账户,被冻结了的账户是无法进行转账操作的。
在我细致的分析合约过程中,我发现了合约中不易理解的一个点:mapping (address => mapping (address => uint256)) public allowance;
。
合约定义了一个mapping变量,而这个变量存储的也是账户账面的金额数量。虽然这次漏洞中与这个变量没有太大的关系,但是为了给读者提供一个可参考的例子,我还是在这里对这个变量的作用进行分析。
简单来说,我认为这个变量就类似于我们生活中的工资的概念。而这个工资是使用的公司内部自己印刷的钱财,可以在公司内部进行交易转账等等。
首先我们可以看赋权函数:
function approve(address _spender, uint256 _value) returns (bool success) { allowance[msg.sender][_spender] = _value; return true; }
这个函数使调用方给予_spender
一个代替转账的权利。即 B向C转账,但是由于A给予B某些权利,那么我B就可以使用A给的额度来变相的向C转账。于是可以调用下面的转账函数:
function transferFrom(address _from, address _to, uint256 _value) returns (bool success) { require (_value < allowance[_from][msg.sender]); // Check allowance allowance[_from][msg.sender] -= _value; _transfer(_from, _to, _value); return true; }
所以C确实收到了钱,而B使用AB花费了的某些约定来进行支付。
除此之外,合约同样定义了销毁函数burnFrom
。用于销毁AB直接的某些约定。
function burnFrom(address _from, uint256 _value) returns (bool success) { require(balanceOf[_from] >= _value); // Check if the targeted balance is enough require(_value <= allowance[_from][msg.sender]); // Check allowance????????????????????????????? balanceOf[_from] -= _value; // Subtract from the targeted balance allowance[_from][msg.sender] -= _value; // Subtract from the sender's allowance totalSupply -= _value; // Update totalSupply Burn(_from, _value); return true; }
阐述完了合约中的一些细节部分,下面我们来进一步对合约中存在的安全漏洞进行分析。
用户在合约中可以使用以太币来对合约中自定义的token进行兑换。
/// @notice Buy tokens from contract by sending ether function buy() payable { uint amount = msg.value / buyPrice; // calculates the amount _transfer(this, msg.sender, amount); // makes the transfers }
用户可以传入value
来购买数量为msg.value / buyPrice
的token。而这个具体的单价是由合约拥有者调用函数得到的。
function setPrices(uint256 newSellPrice, uint256 newBuyPrice) onlyOwner { sellPrice = newSellPrice; buyPrice = newBuyPrice; }
之后,当用户有了足够的token后,他可以用token换回自己的以太币。即出售token。
function sell(uint256 amount) { require(this.balance >= amount * sellPrice); // checks if the contract has enough ether to buy _transfer(msg.sender, this, amount); // makes the transfers msg.sender.transfer(amount * sellPrice); // sends ether to the seller. It's important to do this last to avoid recursion attacks }
而漏洞就在上面的函数中。因为买卖的单价均可以由owner进行定义。倘若owner进行作恶,将单价设置为特殊的值,那么便可以达到意想不到的效果。例如:
管理员设置buyPrice = 1 ether, sellPrice = 2^255
。即用户可以花1以太币购买一个token,然后可以以2^255的价格卖出。看起来很划算对吗?但是这样便产生了整数溢出。
倘若我们卖出两个token:amount * sellPrice = 2^256 。而我们这里为uint256类型,所以直接产生溢出,使变量为0 。也就是说,系统便可以不用花费任何一分钱就可以收入2个token,而用户直接白白损失了2个以太币。
除此之外,我们还可以简单地将卖出价格设置为0,这样同样使amount * sellPrice为0 。不过有可能无法使那些投机的用户上钩。
我们也可以对合约进行简单的部署。
之后传入参数:
成功后对买卖变量进行设置:
之后便可以使用第二个测试账户进行token的购买操作。