智能合约漏洞分析
最近一段时间,关于智能合约漏洞的问题层出不穷。我将以SMT为蓝本进行复盘,通过复盘去深层次了解和防止类似事件发生。
发现错误及处理
一、发现
智能合约的运行是公开透明的,通过查看智能合约的执行记录(https://etherscan.io/token/0x55f93985431fc9304077687a35a1ba103dc1e081)很容易发现这样一条异常数据
该记录中有两笔交易
1、交易1(https://etherscan.io/tx/0x1abab4c8db9a30e703114528e31dee129a3a758f7f8abc3b6494aad3d304e43f)
”0xdf31a499a5a8358b74564f1e2214b31bb34eb46f”的账户分别给
“0xdf31a499a5a8358b74564f1e2214b31bb34eb46f”和
”0xd6a09bdb29e1eafa92a30373c44b09e2e2e0651e”账户转账了65,133,050,195,990,400,000,000,000,000,000,000,000,000,000,000,000,000,000,000.891004451135422463 和 50,659,039,041,325,800,000,000,000,000,000,000,000,000,000,000,000,000,000,000.693003461994217473
个SMT代币。
2、交易2(https://etherscan.io/tx/0x22e6f776076305f6142ca0c7c13420ee96dac1281a855206c59f7959853a03e2)
接收到巨额SMT代币的“0xd6a09bdb29e1eafa92a30373c44b09e2e2e0651e”账户立马将其中的100,000,000,000,000 SMT代币转移到了其它账户中(后续又转出好几笔均可查询)
二、临时措施及其效果
在攻击发生约4小时(攻击时间:Apr-24-2018 07:16:19 PM +UTC,最后一笔交易时间:Apr-24-2018 11:40:41 PM +UTC)后,合约owner通过调用智能合约enableTransfer(false)方法,限制了转账操作。
查询源码(https://etherscan.io/address/0x55f93985431fc9304077687a35a1ba103dc1e081#code)可以发现调用enableTransfer(false)方法,可以修改transferEnabled参数为false;
该参数在修饰函数 transferAllowed 中被调用,transferAllowed函数用于判断不在exclude列表中的地址是否有权限继续执行。
transferAllowed函数,在所有涉及到转账的方法中均调用了。
当transferEnabled参数设置为false的时候,如果你的你的账户地址不在exclude列表中,那么你将只能查询自己的SMT代币余额,不能交易。这种方式可以有效防止黑客将攻击得到的巨额SMT代币流入市场,防止攻击事件进一步恶化。
当然,如果是监守自盗的话(比如excluede中用户),进一步查看源码,发现excluede只设计了加入逻辑,没有设计删除逻辑,意思就是,只要加入这个列表,以后就不受owner控制,可以随时转账。这些用户应该对于官方来说非常重要,其意义不亚于创建者,所以,他们攻击合约的可能性很小,就算有人攻击,也应该很容易通过地址去锁定攻击者身份。
三、攻击重演
1、首先分析一下被攻击方法的代码
function transferProxy(address _from, address _to, uint256 _value, uint256 _feeSmt,
uint8 _v,bytes32 _r, bytes32 _s) public transferAllowed(_from) returns (bool){
if(balances[_from] < _feeSmt + _value) revert();
uint256 nonce = nonces[_from];
bytes32 h = keccak256(_from,_to,_value,_feeSmt,nonce);
if(_from != ecrecover(h,_v,_r,_s)) revert();
if(balances[_to] + _value < balances[_to]
|| balances[msg.sender] + _feeSmt < balances[msg.sender]) revert();
balances[_to] += _value;
Transfer(_from, _to, _value);
balances[msg.sender] += _feeSmt;
Transfer(_from, msg.sender, _feeSmt);
balances[_from] -= _value + _feeSmt;
nonces[_from] = nonce + 1;
return true;
}
大家都知道,转账是需要gas(以太坊)。那么,没有gas的人怎么转账呢,这个方法就是通过代理帮助那些手里没有以太坊的人转账用的。
该方法传入7个参数分别为 转账方、收款方,转账金额、代理费、后面三个为数字签名。除了这7个参数,还有一个隐藏参数msg.sender为代理方。
1)if(balances[_from] < _feeSmt + _value) revert();
判断转账方是否有足够的余额支付转账金额和代理费,没有则终止交易
2)uint256 nonce = nonces[from]; bytes32 h = keccak256(from,to,value,feeSmt,nonce); if(from != ecrecover(h,v,r,_s)) revert();
这三步主要是通过签名和解签验证转账方身份,确定转账方有授权给受理方转账,并且通过onnces随机数防止代理方通过一次签名多次转账
3)if(balances[to] + _value < balances[to]|| balances[msg.sender] + _feeSmt < balances[msg.sender]) revert();
这句是为了防止收款方数值溢出
4) balances[to] += _value; Transfer(from, _to, _value);
收款方接受转账金额,并发出通知
5) balances[msg.sender] += feeSmt;Transfer(from, msg.sender, _feeSmt);
代理方接受代理费,并发送通知
6) balances[from] -= _value + _feeSmt;nonces[from] = nonce + 1;
转账方扣除转账金额和代理费
2、攻击重演
1)攻击者手持2个账户A(转账方、收款方:0xdf31a499a5a8358b74564f1e2214b31bb34eb46f)、B(代理方:0xd6a09bdb29e1eafa92a30373c44b09e2e2e0651e)
2)B账户调用transferProxy方法,传入7个参数A、A、 0x8fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 、0x7000000000000000000000000000000000000000000000000000000000000001、自己制作的签名
3)运行第一步时候会判断两个数字相加是否大于A账户余额
_value = 0x8fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = (2**255 + 2**252 - 1)
_feeSmt = 0x7000000000000000000000000000000000000000000000000000000000000001 = (2**255 - 2**252 + 1)
_value+_feeSmt=2**256
uint256 最大值为 2**256-1,solidity 语言中,当数据溢出后,会回到最小值。即 _feeSmt + _value的数值最终为0;
所以if(balances[_from] < _feeSmt + _value) revert(); 不成立,程序继续执行
4)由于攻击者持有A,B用户的私钥,所以签名验证也可以顺利通过
5)虽然 _feeSmt + _value会溢出,但是单独的 _feeSmt 和 _value不会出现数值溢出,所以收款方数值溢出检测也顺利通过
6)收款方收到转账2^255 + 2^252 - 1 个代币
7)代理方收到代理费2^255 - 2^252 + 1个代币
8)因为 转账方和收款方为同一人,所以A账户在收到转账后要扣除转账和代理费,2^255 + 2^252 - 1 -(2^255 + 2^252 - 1)-(2^255 - 2^252 + 1),因为uint为无符号整数,同理,在solidity语言中该数据会回到最大值即 2^256-(2^255 - 2^252 + 1)=2^255 + 2^252 -1(经过一大波分析,好想还是和之前一样^_^)
所以,最终A账户中有SMT代币
65133050195990359925758679067386948167464366374422817272194891004451135422463
B账户有SMT代币
50659039041325835497812305941300959685805618291217746767262693003461994217473
四、修正
这次攻击的漏洞主要是合约开发者数值溢出的情况没有考虑完全。比如第一句判断出现数据溢出的情况没有考虑到。我们尝试对该方法进行修复。
修改代码如下
将
if(balances[_from] < _feeSmt + _value) revert();
修改为
if( _feeSmt + _value <_value ||balances[_from] < _feeSmt + _value) revert();
加入一个判断,防止 _feeSmt + _value出现溢出。
这里我们讲一下为什么 _feeSmt + _value < _value 可以防止溢出?
首先,在正常情况下无符号整数相加a+b<a是肯定不成立的,这个就不细说。那么在数值溢出的情况下a+b<a是否也有可能不成立?我们假设在数值溢出的情况下a+b<a也有可能不成立。也就是说在数据溢出的情况下a+b>=a可能成立,当a+b溢出的时候 a+b=a+b-2^256>=a,那么b-2^256>=0,那么b>=2^256,b为输入的uint256无符号整数,b >=2^256不成立,所以,a+b>=a不成立,那么,在溢出的时候,a+b<a一定成立。
所以,当 _feeSmt + _value出现溢出的时候,函数会走到revert()。交易会回退。
除了第一步方法其它步骤时候也有溢出可能?
我们查看代码,代码中 if(balances[to] + _value < balances[to] || balances[msg.sender] + _feeSmt < balances[msg.sender]) revert();这句已经可以保证收款方和代理方收到交易后不会出现溢出。转账方由于我们修改的第一句判断,也可以保证交易后不会出现溢出。
智能合约永固性的特点,是智能合约最安全的特性,也是最不安全的特性。安全是因为只要合约上链后,任何人不能修改,也不能干预合约执行。不安全,是因为,如果合约中有漏洞,将无法修复,只能废弃该合约。
如果大家对于可能的溢出情况不能做全面分析,建议大家使用数字运算的时候使用SafeMath库。
五、检查合约是否还有类似漏洞
检查SMT合约transfer、transferFrom 方法未发现类似问题
六、现有解决方案
虽然错误的合约可以进行修正,但是已经上链的合约不能进行修复。现有解决方案很单一
1、停止交易
2、修复合约
3、以以太坊区块某一高度作SMT合约镜像
4、将镜像copy到新合约(就是个数据复制转移的过程)
5、联系交易所、非小号、imtoken等第三方同步更改合约地址
根本原因分析
一、ERC20模型和UTXO模型区别
从现有情况看来,该漏洞是由于开发人员未考虑完全,引起了数值溢出,导致的漏洞。那么是不是只要开发人员能够有完美的逻辑就可以写出绝对安全的代码?
理论上是可以实现的。但是,智能合约开发中,涉及到转账的开发会有很多种,不提自己新增的一些转账操作。就仅仅基于ERC20接口就有无数种实现,而每种实现,都需要大量测试,审计才能一定程度保证合约安全性。我们不能保证每个开发人员都拥有完美的逻辑,也不能保证每个测试人员都能模拟出所有的可能。所以,我们不能保证每个基于ERC20的智能合约都是绝对安全的。
这也由于ERC20模型的特性引起的,ERC20核心是基于账户的设计,所有的交易都是以结果为准,过程需要人为控制,交易的安全性完全取决于人。由于人是最不可控的,所以合约出现漏洞也就不奇怪了。
UTXO模型的核心则是基于交易记录,UTXO模型保证每笔交易的输入均可追溯,保证每笔交易的输入和输出总量相等。这种模型弱化账户系统,更加注重交易本身,保证每个输入输出均可追溯,这样可以从本质上保证交易可追溯性,保证交易安全性。
二、ERC20怎么最大程度保证安全性?
ERC20接口实现最简化(最简化实现可以参考openzeppelin合约库)。只有最简化的ERC20合约的安全性才是可控的。其它复杂交易合约均调用该最简合约。这种合约调用保证最小化的那套ERC20代币系统能够正常运转,即使复杂合约出现bug也能保证代币系统的安全性。但是这种合约调用不能满足所有的业务场景。个人观点,可以牺牲少数业务场景,去最大化满足合约安全。
三、是否可以设计出类似于UTXO模型的以太坊代币模型?
UTXO模型涉及到大量交易数据存储,接口模式很难实现。而且智能合约的存储主要是状态存储,对于历史数据追溯难度较大。所以UTXO模型必须获取以太坊底层支持。暂时还没有想到特别好的方案。
预防措施
一、充分测试
1、测试用例设计
对业务进行全方位分析,通过等价类划分法、边界值分析法、正交实验法等方法设计出尽可能全面的测试用例,编写成测试代码,做自动化回归测试。
2、多人交叉测试
一个人的思维是局限的,我们需要通过多人的跳跃性思维,将尽可能多的情况覆盖在内
3、回归测试
每一次修改都必须做整套回归测试。
二、工具监控
以太坊的所有交易都是公开透明可查的。我们只需要通过工具将某一个智能合约交易数据实时爬取出来,并对可能的异常交易做短信和邮件告警。这样我们能第一时间发现问题,并采取措施。
三、第三方审计
可以找一些专业的智能合约开发工程师做审计(比如本人)。审计可以从另一个角度去发现智能合约可能存在的潜在问题。可能做审计的人的专业能力没有开发者的专业能力强大。但是不同人的切入点是不一样的,很多问题往往都是开发者的主观意识造成的。