1. 引子
2018年4月24日,又一件突发性事件引爆了币圈!刚刚发行了才两个月的“美链 Beauty Chain” (简称BEC)在受到黑客的攻击的影响下直接归零了!黑客使用的是以太坊ERC-20智能合约BatchOverFlow数据溢出的漏洞,向两个地址转出了数量巨大的BEC代币!黑客先是试探性地往Okex中转100万的BEC,发现成功转入卖出后,又分两次转入了一千万的BEC。发现两次都成功,黑客变得更加大胆,便转入了一亿枚BEC。但这1亿枚 BEC转入后,OKEx已经发现问题并停止了BEC的交易。按照转入记录,预计黑客已经卖出了最少 1100万枚BEC,折合昨日售价约一千八百多万人民币。
BEC官方团队已经暂停了一切交易和转账。BEC上线的交易所有两家,Okex和LBank。暂停交易之后,官方团队将对Okex交易所的交易回滚到黑客转账之前。
2. 智能合约安全攻击实际案例分析
下面我们就来分析分析发生在区块链世界的几个血淋淋的真实案例。
2.1 美链BEC遭遇黑客攻击
上例中,黑客使用的是一个叫“batchTransfer“攻击方法。因为BEC的开发人员在写代码时犯了一个错误,使得出现一个简单的溢出漏洞。就这么一个简单的漏洞,让黑客有机可乘,让BEC的60亿市值顷刻间归零,让手中拥有BEC的韭菜们血本无归!
码农没有想到,自己的手艺活现在是那么值钱!一行代码就职60亿元,这是什么样的价值体现呢?
2.1.1 问题分析
美链发生问题的智能合约地址(点击查看) ,完整代码我们就不引用了,直接看问题代码。
从上述源码中我们摘出batchTransfer这个函数,如下:
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
uint cnt = _receivers.length;
uint256 amount = uint256(cnt) * _value; // <==== 败在这个乘法运算
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender].sub(amount); // 下面知道用库函数,却粗心在上一步没用
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}
这个函数的作用就是批量转账,其中我们可以看到有这么一句“ uint256 amount = uint256(cnt) * _value; ”,问题就出在这里,复盘一下:
1、给·_receivers参数传递两个或更多元素的数组,即_receivers.length>=2了,这里我们假定_receivers.length=2。
2、给_value传一个什么样的值呢,要让_value * 2 = 2 ** 256 + 1刚好溢出,得到这个10 进制数57896044618658097711785492504343953926634992332820282019728792003956564819968,但笔者在Remix测试了一下,直接传这个数是不行的,要转换成16进制,即0x8000000000000000000000000000000000000000000000000000000000000000(8后面63个0),可以用web3.fromDecimal('数字')做一下转换。
两个参数的值如下:
_value = 0x8000000000000000000000000000000000000000000000000000000000000000
_receivers.length=2
这样就超出了amount的值范围,从而导致溢出归0,即amount=0,进而绕过了异常检查语句:
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);
3、再往下看for循环的加法运算,突破了前面的检查,到达for循环这里就如进入了无人看管的银库,想往自己账号上加多少钱就加多少。我们来一起见证一下这疯狂的一幕,点击查看BEC加钱现场。
2.1.2 解决方案
数值溢出是最容易出问题的智能合约常见错误。为了代码安全,一定要使用“检查-生效-交互”(Checks-Effects-Interactions)模式来编写代码。
以下引用了哥哥的修改代码,即可防止64亿的价值损失。
方案说明:
采用SafeMath的库函数,改为" uint256 amount = _value.mul(uint256(cnt)); ",如果发生溢出,会产生assert中断,异常退出,不会执行非法转账。
对程序员的忠告:
智能合约中算术一律采用SafeMath的库函数,不以一行而不为!
代码如下:
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
uint cnt = _receivers.length;
uint256 amount = _value.mul(uint256(cnt)); // <===修改成这样即可
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender].sub(amount);
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}
2.2 EDU 智能合约出现重大漏洞,可转走任意账户的 EDU Token
2018年5月24日凌晨01:33 时分,火币 Pro 发布公告称 EduCoin(EDU) 官方智能合约升级,暂停提币和交易业务。
2.2.1 问题分析
据慢雾区最新消息,EDU 智能合约出现重大漏洞,可转走任意账户的 EDU Token。目前已经发现有黑客的大量洗劫行为,攻击者不需要私钥即可转走你账户里所有的 EDU,并且由于合约没有 Pause 设计,导致无法止损。
1,EDU的老智能合约地址,可点击查看代码
2,EDU修正后的智能合约地址,可点击查看合约代码
以下为存在问题的智能合约代码,辉哥增加备注说明:
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) {
/// same as above
require(_to != 0x0);
require(balances[_from] >= _value);
require(balances[_to] + _value > balances[_to]);
uint previousBalances = balances[_from] + balances[_to];
balances[_from] -= _value;
balances[_to] += _value;
allowed[_from][msg.sender] -= _value;
Transfer(_from, _to, _value);/*智能合约事件LOG记录,无实际代币操作*/
assert(balances[_from] + balances[_to] == previousBalances);
return true;
}
在 EDU 智能合约 transferFrom 函数中,未校验 allowed[_from][msg.sender] >= _value 并且函数内 allowed[_from][msg.sender] -= _value也没有使用 SafeMath,导致无法抛出异常并回滚交易。
通过这个漏洞,攻击者通过调用智能合约中的allowance函数,即可扩大账户余额,不需要私钥即可转走指定账户里所有的 EDU,并且由于合约没有 Pause 设计,导致无法止损。
2.2.2 解决方案
EDU团队请知停止交易后,又发布了一个新的智能合约,并把原来的账户分配迁移到这个代币合约来。
说明下,上链了,智能通过中心化的交易所行为让之前这个漏洞代币的交易停止,币值归零了。
新的智能合约中,问题代码修改成如下:
乖乖的采用SafeMath的库函数,就不会出问题了。
/**
* @dev Transfer tokens from one address to another
* @param _from address The address which you want to send tokens from
* @param _to address The address which you want to transfer to
* @param _value uint256 the amount of tokens to be transferred
*/
function transferFrom(address _from, address _to, uint256 _value) public returns (bool) {
require(_to != address(0));
require(_value <= balances[_from]);
require(_value <= allowed[_from][msg.sender]);
balances[_from] = balances[_from].sub(_value);
balances[_to] = balances[_to].add(_value);
allowed[_from][msg.sender] = allowed[_from][msg.sender].sub(_value);
Transfer(_from, _to, _value);
return true;
}
2.3 价值两百万的以太坊钱包陷阱
2018年5月份网络某些论坛和交流群里,出现了一个以太坊钱包地址。
帐号私钥: 668a369e87c01da5bfca9851e6ee86d760e17ee7912d77b7dffe8e0cdf63bcb5
地址: 0xA8015DF1F65E1f53D491dC1ED35013031AD25034
里面有交易价值的代币。有些人在跃跃欲试,要不要转到自己的钱包地址来呢?
2.3.1 问题分析
钱包中有价值 320382 美金的 ICX。我们立即将私钥导入钱包看看,价值7万多的ICX代币哦:
我们来转出点 ICX 试试:
转帐时会提示你手续费余额不足,意料之中的,那往里面充点 ETH 吧!等我们充完钱之后会发现里面的 ETH 会立马被转走,已经有小伙伴做过实验了,这里就不测试了。
第一点不难解释,所有操作都是通过脚本完成,通过监测以太坊主网新生成区块中是否有该地址的转入交易,若有则立即转出钱包中的 ETH;
第二点,所有的转出金额都非常小,为什么要这样做?通过观察交易列表,我们会发现转出地址不止一个,就是说有人也想从这里面分一杯羹,把小白转过来的 ETH 抢走,所以把手续费设置得很高,有的到了总金额的 99% 左右,在以太坊系统中,矿工会优先打包手续高的交易。
OK!到这里为止可能有人问,我们设置更高的手续费也用脚本去转走里面的 ICX Token 不就行了吗?现在我们来看一看 ICX 的智能合约代码。 点击查看合约代码。
来看看合约中的两个转帐函数:
function transfer( address to, uint value)
isTokenTransfer
checkLock
returns (bool success) {
require( _balances[msg.sender] >= value );
_balances[msg.sender] = _balances[msg.sender].sub(value);
_balances[to] = _balances[to].add(value);
Transfer( msg.sender, to, value );
return true;
}
function transferFrom( address from, address to, uint value)
isTokenTransfer
checkLock
returns (bool success) {
// if you don't have enough balance, throw
require( _balances[from] >= value );
// if you don't have approval, throw
require( _approvals[from][msg.sender] >= value );
// transfer and return true
_approvals[from][msg.sender] = _approvals[from][msg.sender].sub(value);
_balances[from] = _balances[from].sub(value);
_balances[to] = _balances[to].add(value);
Transfer( from, to, value );
return true;
}
发现这两个函数都带了 “checkLock” modifier,我们再来看看“checkLock”有什么用
// This modifier check whether the contract should be in a locked
// or unlocked state, then acts and updates accordingly if
// necessary
modifier checkLock {
if (lockaddress[msg.sender]) {
throw;
}
_;
}
这几行代码就是判断该地址是否处于 lockaddrss 中,如果在 lockaddrss 中则 throw,不执行转账操作,我们来看看这个钓鱼钱包地址是否在 lockaddress 中(因为 lockaddress 为 public 类型,所以可以直接查询)
毫无疑问,我们先前的钱包地址处于锁定状态,所以钓鱼的人先将钱包中的 ICX Token 锁定,再故意流出私钥,以大量的 ERC20 Token 去诱惑大家往钱包地址中转手续费,再立马转走 ETH。
2.3.2 解决方案
收起贪婪的心,你贪人家的小利,人家要你的本金!
2.3.3 延伸阅读
"2.3.1"章节讲了查看该钱包地址的状态是否是lockaddrss的截图,但是没有讲清楚如何查询的方法,本节以此为案例,做一下延伸阅读。
1. 登录myetherwallet合同查看页面
登录myetherwallet合同查看页面,输入合约地址'0xb5a5f22694352c15b00323844ad545abb2b11028',在右边合同列表中没有找到对应的ICX合约。
2. 更换方法,采用ABI的方式查找合约
点击查看ICX合约代码, ,获取其ABI信息。
登录myetherwallet合同查看页面, 右上角的网络选择为“Network ETH(etherscan.io)”【很重要】
然后输入复制的ABI信息,然后找到lockaddress映射函数,输入地址'0xA8015DF1F65E1f53D491dC1ED35013031AD25034',点击READ按钮,得到结果为TRUE。
地址锁住了。取币,逗你玩!
4. 智能合约常见安全问题
4.1 私有信息和随机性
在智能合约中你所用的一切都是公开可见的,即便是局部变量和被标记成 private
的状态变量也是如此。
如果不想让矿工作弊的话,在智能合约中使用随机数会很棘手 (译者注:在智能合约中使用随机数很难保证节点不作弊, 这是因为智能合约中的随机数一般要依赖计算节点的本地时间得到, 而本地时间是可以被恶意节点伪造的,因此这种方法并不安全。 通行的做法是采用链外的第三方服务,比如 Oraclize 来获取随机数)。
4.2 重入
任何从合约 A 到合约 B 的交互以及任何从合约 A 到合约 B 的以太币 的转移,都会将控制权交给合约 B。 这使得合约 B 能够在交互结束前回调 A 中的代码。 举个例子,下面的代码中有一个 bug(这只是一个代码段,不是完整的合约):
pragma solidity ^0.4.0;
// 不要使用这个合约,其中包含一个 bug。
contract Fund {
/// 合约中 |ether| 分成的映射。
mapping(address => uint) shares;
/// 提取你的分成。
function withdraw() public {
if (msg.sender.send(shares[msg.sender]))
shares[msg.sender] = 0;
}
}
这里的问题不是很严重,因为有限的 gas 也作为 send
的一部分,但仍然暴露了一个缺陷: 以太币Ether的传输过程中总是可以包含代码执行,所以接收者可以是一个回调进入 withdraw
的合约。 这就会使其多次得到退款,从而将合约中的全部 以太币Ether 提取。 特别地,下面的合约将允许一个攻击者多次得到退款,因为它使用了 call
,默认发送所有剩余的 gas。
pragma solidity ^0.4.0;
// 不要使用这个合约,其中包含一个 bug。
contract Fund {
/// 合约中 |ether| 分成的映射。
mapping(address => uint) shares;
/// 提取你的分成。
function withdraw() public {
if (msg.sender.call.value(shares[msg.sender])())
shares[msg.sender] = 0;
}
}
为了避免重入,你可以使用下面撰写的“检查-生效-交互”(Checks-Effects-Interactions)模式:
pragma solidity ^0.4.11;
contract Fund {
/// 合约中 |ether| 分成的映射。
mapping(address => uint) shares;
/// 提取你的分成。
function withdraw() public {
var share = shares[msg.sender];
shares[msg.sender] = 0;
msg.sender.transfer(share);
}
}
请注意重入不仅是 以太币ETHer 传输的其中一个影响,还包括任何对另一个合约的函数调用。 更进一步说,你也不得不考虑多合约的情况。 一个被调用的合约可以修改你所依赖的另一个合约的状态。
4.3 gas 限制和循环
必须谨慎使用没有固定迭代次数的循环,例如依赖于存储值的循环: 由于区块 gas 有限,交易只能消耗一定数量的 gas。 无论是明确指出的还是正常运行过程中的,循环中的数次迭代操作所消耗的 gas 都有可能超出区块的 gas 限制,从而导致整个合约在某个时刻骤然停止。 这可能不适用于只被用来从区块链中读取数据的 constant
函数。 尽管如此,这些函数仍然可能会被其它合约当作 链上on-chain操作的一部分来调用,并使那些操作骤然停止。 请在合约代码的说明文档中明确说明这些情况。
4.4 发送和接收以太币Ether
- 目前无论是合约还是“外部账户”都不能阻止有人给它们发送 以太币Ether。 合约可以对一个正常的转账做出反应并拒绝它,但还有些方法可以不通过创建消息来发送 以太币Ether。 其中一种方法就是单纯地向合约地址“挖矿”,另一种方法就是使用
selfdestruct(x)
。 - 如果一个合约收到了 以太币Ether(且没有函数被调用),就会执行 fallback 函数。 如果没有 fallback 函数,那么 以太币Ether 会被拒收(同时会抛出异常)。 在 fallback 函数执行过程中,合约只能依靠此时可用的“gas 津贴”(2300 gas)来执行。 这笔津贴并不足以用来完成任何方式的 以太币Ether 访问。 为了确保你的合约可以通过这种方式收到 以太币Ether,请你核对 fallback 函数所需的 gas 数量 (在 Remix 的“详细”章节会举例说明)。
- 有一种方法可以通过使用
addr.call.value(x)()
向接收合约发送更多的 gas。 这本质上跟addr.transfer(x)
是一样的, 只不过前者发送所有剩余的 gas,并且使得接收者有能力执行更加昂贵的操作 (它只会返回一个错误代码,而且也不会自动传播这个错误)。 这可能包括回调发送合约或者你想不到的其它状态改变的情况。 因此这种方法无论是给诚实用户还是恶意行为者都提供了极大的灵活性。 - 如果你想要使用
address.transfer
发送 以太币Ether ,你需要注意以下几个细节:- 如果接收者是一个合约,它会执行自己的 fallback 函数,从而可以回调发送 以太币Ether的合约。
- 如果调用的深度超过 1024,发送 以太币Ether也会失败。由于调用者对调用深度有完全的控制权,他们可以强制使这次发送失败; 请考虑这种可能性,或者使用
send
并且确保每次都核对它的返回值。 更好的方法是使用一种接收者可以取回 以太币Ether 的方式编写你的合约。 - 发送 以太币Ether也可能因为接收方合约的执行所需的 gas 多于分配的 gas 数量而失败 (确切地说,是使用了
require
,assert
,revert
,throw
或者因为这个操作过于昂贵) - “gas 不够用了”。 如果你使用transfer
或者send
的同时带有返回值检查,这就为接收者提供了在发送合约中阻断进程的方法。 再次说明,最佳实践是使用 “取回”模式而不是“发送”模式。
4.5 调用栈深度
外部函数调用随时会失败,因为它们超过了调用栈的上限 1024。 在这种情况下,Solidity 会抛出一个异常。 恶意行为者也许能够在与你的合约交互之前强制将调用栈设置成一个比较高的值。
请注意,使用 .send()
时如果超出调用栈 并不会 抛出异常,而是会返回 false
。 低级的函数比如 .call()
,.callcode()
和 .delegatecall()
也都是这样的。
4.6 tx.origin
永远不要使用 tx.origin 做身份认证。假设你有一个如下的钱包合约:
// 不要使用这个合约,其中包含一个 bug。
contract TxUserWallet {
address owner;
function TxUserWallet() public {
owner = msg.sender;
}
function transferTo(address dest, uint amount) public {
require(tx.origin == owner);
dest.transfer(amount);
}
}
现在有人欺骗你,将 以太币Ether发送到了这个恶意钱包的地址:
pragma solidity ^0.4.11;
interface TxUserWallet {
function transferTo(address dest, uint amount) public;
}
contract TxAttackWallet {
address owner;
function TxAttackWallet() public {
owner = msg.sender;
}
function() public {
TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
}
}
如果你的钱包通过核查 msg.sender
来验证发送方身份,你就会得到恶意钱包的地址,而不是所有者的地址。 但是通过核查 tx.origin
,得到的就会是启动交易的原始地址,它仍然会是所有者的地址。 恶意钱包会立即将你的资金抽出。
4.7 细枝末节
- 在
for (var i = 0; i < arrayName.length; i++) { ... }
中,i
的类型会变为uint8
, 因为这是保存0
值所需的最小类型。如果数组超过 255 个元素,则循环不会终止。 -
constant
关键字并不是编译器强制的,另外也不是以太坊虚拟机Ethereum Virtual Machine(EVM)强制的, 因此一个“声明”为constant
的函数可能仍然会发生状态发生变化。 - 不占用完整 32 字节的类型可能包含“脏高位”。这在当你访问
msg.data
的时候尤为重要 —— 它带来了延展性风险: 你既可以用原始字节0xff000001
也可以用0x00000001
作为参数来调用函数f(uint8 x)
以构造交易。 这两个参数都会被正常提供给合约,并且x
的值看起来都像是数字1
, 但msg.data
会不一样,所以如果你无论怎么使用keccak256(msg.data)
,你都会得到不同的结果。
5 推荐做法
5.1 限定以太币Ether的数量
限定 存储storage在一个智能合约中 以太币Ether(或者其它通证)的数量。 如果你的源代码、编译器或者平台出现了 bug,可能会导致这些资产丢失。 如果你想控制你的损失,就要限定 以太币Ether的数量。
5.2 保持合约简练且模块化
保持你的合约短小精炼且易于理解。 找出无关于其它合约或库的功能。 有关源码质量可以采用的一般建议: 限制局部变量的数量以及函数的长度等等。 将实现的函数文档化,这样别人看到代码的时候就可以理解你的意图,并判断代码是否按照正确的意图实现。
d
5.3 使用“检查-生效-交互”(Checks-Effects-Interactions)模式
大多数函数会首先做一些检查工作(例如谁调用了函数,参数是否在取值范围之内,它们是否发送了足够的以太币Ether用户是否具有通证等等)。 这些检查工作应该首先被完成。
第二步,如果所有检查都通过了,应该接着进行会影响当前合约状态变量的那些处理。 与其它合约的交互应该是任何函数的最后一步。
早期合约延迟了一些效果的产生,为了等待外部函数调用以非错误状态返回。 由于上文所述的重入问题,这通常会导致严重的后果。
请注意,对已知合约的调用反过来也可能导致对未知合约的调用,所以最好是一直保持使用这个模式编写代码。
5.4 包含故障-安全(Fail-Safe)模式
尽管将系统完全去中心化可以省去许多中间环节,但包含某种故障-安全模式仍然是好的做法,尤其是对于新的代码来说:
你可以在你的智能合约中增加一个函数实现某种程度上的自检查,比如 以太币Ether 是否会泄露?”, “通证的总和是否与合约的余额相等?”等等。 请记住,你不能使用太多的 gas,所以可能需要通过链外off-chain计算来辅助。
如果自检查没有通过,合约就会自动切换到某种“故障安全”模式, 例如,关闭大部分功能,将控制权交给某个固定的可信第三方,或者将合约转换成一个简单的“退回我的钱”合约。
5.5 形式化验证
使用形式化验证可以执行自动化的数学证明,保证源代码符合特定的正式规范。 规范仍然是正式的(就像源代码一样),但通常要简单得多。
请注意形式化验证本身只能帮助你理解你做的(规范)和你怎么做(实际的实现)的之间的差别。 你仍然需要检查这个规范是否是想要的,而且没有漏掉由它产生的任何非计划内的效果。
6. 业务对接
不管怎么小心闭坑,对于一行代码可以摧毁整个区块链通证生态的风险,很多基金会还是会倾向于找专业团队做验收测试。
国内有几家专业做智能合约测试的公司,慢雾科技-厦门,链安科技-成都,Testin云测,知道创宇,辉哥跟这些创始团队都建立了联系,他们可以提供火币/OK等大的交易所认同的测试报告,有需要可帮忙对接介绍。
7. 参考文档
1) EduCoin(EDU) 智能合约漏洞分析及修复方法
1> 已更新的智能合约地址
2> 老的问题智能合约地址
2)BEC 智能合约无限转币漏洞分析及预警
3)BCH 挖矿程序 Bitcoin-ABC 分叉漏洞剖析
4)智能合约 transferFrom 权限控制不当导致的任意盗币攻击简述
5) 价值两百万的以太坊钱包陷阱 - OK 陷阱
6) 以太坊黑色情人节专题之恶意扫描 IP 披露
7) 【以太坊开发】BeautyChain (BEC) 溢出漏洞分析
8)以太坊生态缺陷导致的一起亿级代币盗窃大案
9)安全考量