“危机四伏”的以太转账操作|以太转账安全风险——漏洞分析连载之八期
安全,区块链领域举足轻重的话题,为什么一行代码能瞬间蒸发几十亿市值?合约底层函数的使用不当会引起哪些漏洞?以太币转账中又有哪些漏洞和陷阱?
「区块链大本营」携手「成都链安科技」团队重磅推出「合约安全漏洞解析连载」,以讲故事的方式,带你回顾区块链安全走过的历程;分析漏洞背后的玄机。让开发者在趣味中学习,写出更加牢固的合约,且防患于未然。
当然,这些文章并不是专为开发者而作的,即使你不是开发者,当你读完本连载,相信再有安全问题爆出时,你会有全新的理解。
引子:金风未动蝉先觉 ,暗算无常死不知 —— 《名贤集》之《七言篇》
上回讲到:
本地变量存储措手不及
意外变量覆盖易帜拔旗
Solidity语言的默认存储规则和引用未初始化变量带来的特殊性共同导致了未初始化变量将原有状态变量覆盖,占用了状态变量在Storage中的位置,重演了最近“高铁座霸”事件。由此带来的安全隐患不可小觑,因此我们在开发过程中要重视编译器告警,对未初始化变量进行初始化操作,或者将其安排在暂时的存储空间Memory上,根除此类隐患。
本回咱们来聊聊:
转账过程纷繁复杂
安全应对各个击破
我们在之前的漏洞分析连载中讨论了花样繁多的代币合约漏洞,这些代币合约大部分都是对于代币进行操作,并不一定涉及以太坊中心货币 – 以太币(Ether)。然而,随着游戏合约如雨后春笋般的大量出现,越来越多的游戏合约直接将以太币作为游戏资金。
这些直接与“钱”挂钩的游戏合约在对于以太币的转账相关的逻辑是否能做到真正意义上的牢不可破呢?答案是否定的,例如最近刚刚发生的Pandemica庞氏游戏资金被冻结事件[1],就属于“超出gas上限引发的DoS攻击”,这个类型我们在漏洞分析连载第二期重点分析过。这一期我们就来剖析其它直接涉及以太币转账的漏洞和陷阱。
基础小知识
在Solidity语言中,官方提供三种方式向目标地址发送Ether(以太币)。
<address>.transfer(uint256 amount)向目标地址发送amount wei的以太币,失败时抛出异常,发送 2300 gas 的矿工费,不可调节。
<address>.send(uint256 amount) returns (bool)向目标地址发送amount wei的以太币,失败时返回false,发送 2300 gas 的矿工费,不可调节。
<address>.call.value(uint256 amount)() returns (bool)向目标地址发送amount wei的以太币,失败时返回false,发送所有可用gas,可调节(.gas(uint256 gasAmount))[2]。
了解了发送Ether的三种方式,我们先把他们放一放。再来介绍一下不变量检查的概念。
不变量检查是一种用于强制执行正确状态变换和验证操作中的常用防御性编程技巧。它包含定义一组不变量(不应改变的矩阵或者参数)以及在单个或者多个操作后检查它们是否不变两个方面。检查不应该改变的变量没有改变,这是一个很好的设计。
在ERC20代币当中,我们也用到了这个技巧,比如说,代币总数totalSupply是不变的。由于这个变量不应该被更改,所以在transfer()函数部分可以添加一个检查机制,对比操作前后的totalSupply是否改变来判断transfer()函数是否正确执行[3]。
问题随之产生
针对发送Ether的三种方式,会在回退函数fallback上出现问题,如果一个合约,而非用户,收到了Ether(且没有函数被调用),就会执行fallback函数。fallback函数在这里的功能是接受Ether,缺少这个功能,合约会将其拒收(同时会抛出异常)。如果合约使用transfer/send方式向目标合约地址发送Ether,在目标合约fallback函数的执行过程中,合约只能依靠此时可用的“gas津贴”(2300gas)来执行。这笔津贴是并不足以用来完成任何方式的存储Storage访问的,这就会造成fallback函数一直返回false的情况,如同无法复原的弹簧。
而谈到不变量检查,开发者们趋向于信赖目前合约中存在的Ether,但实际上它能够被外部用户在无视合约内部规则的情况下操纵。并且,开发者在学习Solidity的时候,容易产生一个误解,那就是一个合约只能通过payable函数接收Ether,而没有考虑到接收Ether,而不执行任何函数的情况。此类合约面对强制将Ether发送到合约的漏洞利用是非常脆弱的。真是应验了那句老话,有钱能使鬼推磨。
具体案例分析
一. 发送和接收以太币存在的安全风险
1. 使用transfer向地址发送Ether可能存在的安全风险。
我们以Ethernaut-King上的一个关卡作为案例合约:
无论谁发送一个大于当前奖金的ether,都会成为新的国王,被推翻的国王获得了新的奖金。如果攻击者部署一个合约,代码如下:
因为fallback函数无法接收ether,攻击者通过攻击合约变成king之后,新的竞争者在向案例合约发送以太币以变成King的过程中,执行king.transfer(msg.value);会一直revert,攻击者实际上是执行了一次漏洞连载第二期所描述的通过Revert发动DoS。
2. 使用send向地址发送Ether可能存在的安全风险
代码截取于第二期中的同一个案例KingOfTheEtherThrone
因为send执行失败后会返回false而不是抛出异常,合约中未检查send返回值,部分通过合约账户参与游戏的玩家,因为send附带的2300gas无法完成fallback操作,导致接收ether返还失败。
3. 使用call.value()()向地址发送Ether可能存在的安全风险
使用call.value()()发送以太默认会附带全部剩余gas,如果合约实现存在隐患,可能造成重入攻击,并且,call.value发送以太币失败后会返回false,如果未对返回值进行检查,那么合约会默认所有发送ether都成功,然后执行状态变量的改变,显然,这是存在逻辑缺陷的。
漏洞修复
向地址发送以太币时,请分别考虑接收地址是普通账户和合约账户的区别,如果接收地址是一个合约,需要考虑是否在交易中附带足够的gas,确保合约拥有足够的gas执行对应函数;
必须考虑发送ether失败的可能的情况:transfer发送失败会revert,但是此特性可以用来发起DOS攻击,send和call.value发送ether失败会返回false,开发者需要对此进行处理;
二. 意外的Ether强制转入
1. 自毁
任何合约都能够实现该 selfdestruct(address) 功能,该功能从合约地址中删除所有字节码,并将所有存储在那里的 Ether 发送到参数指定的地址。如果此指定的地址也是合约,则不会调用任何函数(包括fallback函数)。因此,使用 selfdestruct() 函数可以无视目标合约中存在的任何代码,强制将 Ether 发送给任一目标合约,包括没有任何可支付函数的合约。
这意味着,任何攻击者都可以创建带有selfdestruct()函数的合约,向其发送Ether,调用 selfdestruct(target) 并强制将 Ether 发送至 target 合约。Martin Swende 有一篇出色的博客文章描述了自毁操作码的一些诡异操作,并描述了客户端节点如何检查不正确的不变量,这可能会导致相当灾难性的客户端问题[4]。
2. 预先发送的 Ether
合约不使用 selfdestruct() 函数或调用任何 payable 函数仍可以接收到 Ether 的第二种方式是把 Ether 预发送到合约地址。合约地址是确定性的,实际上地址是根据创建合约的地址及创建合约的交易 Nonce 的哈希值计算得出的,即下述形式:address = sha3(rlp.encode([account_address,transaction_nonce]) 请参阅 Keyless Ether 在这一点上的一些有趣用例或者参考How is the address of an Ethereum contract computed?(链接:https://ethereum.stackexchange.com/questions/760/how-is-the-address-of-an-ethereum-contract-computed)。这意味着,任何人都可以在创建合约之前计算出合约地址,并将 Ether 发送到该地址。当合约确实创建时,它将具有非零的 Ether 余额。
3. 挖矿
目前无论是合约还是"外部账户"都不能阻止有人给它们发送以太币Ether。合约可以对一个正常的转账做出反应并拒绝它,但还有些方法可以不通过创建消息来发送以太币Ether。 其中一种方法就是单纯地向合约地址"挖矿" 。
我们从下面这个合约入手进行具体分析
这个合约代表一个简单的游戏(自然会引起竞态条件(Race-conditions)),玩家可以将 0.5 ether 发送给合约,希望成为第一个达到三个里程碑之一的玩家。里程碑以 Ether 计价。当游戏结束时,第一个达到里程碑的人可以获得合约的部分 Ether。当达到最后的里程碑(10 Ether)时,游戏结束,用户可以取走奖励。
该合约的问题出在uint currentBalance = this.balance + msg.value;(以及相关的[16]行)和[32]行对this.balance的错误使用。攻击者可以通过上述提到的三种方式将ether置入合约:
比如第一种方式,自毁:
部署合约的时候在交易中附加0.1 ether,然后调用attack函数自毁合约,此时将会把0.1 ether发送到案例合约,因为案例合约每次只能接收0.5 ether,普通玩家将永远不能满足里程碑的要求,游戏将没有胜利的玩家,除非有剩下的0.4 ether被强行打入合约。
第二种方式,预存Ether:
使用solidity计算某个合约的部署地址的方法是address(keccak256(0xd6, 0x94, _from, nonce))其中,_from表示部署合约的账号的地址,nonce表示账号地址部署这个合约时的nonce,即最新的交易序号+1。如果部署合约的账户是第一次交易,如果账户是合约,nonce=1,如果是普通用户,nonce=0:
漏洞修复
此漏洞是对this.balance的滥用,在可能的情况下,合约逻辑应避免依赖于合约余额的确切值,因为它可以在合约逻辑之外被人为操纵。如果合约逻辑必须基于this.balance,那么需要考虑合约意外的余额。
如果确实需要精确的余额值,那么应该定义一个状态变量,该变量在合约通过payable函数接收到ether的时候增加,用来安全的追踪合约收到的ether,并且,这个变量不会受到强制发送ether到合约(例如selfdestruct() )的影响。因此,对上述案例合约的修改如下:
溪云初起日沉阁,山雨欲来风满楼
随着区块链技术深入人心,智能合约的种类多样性也开始提高。越来越多的新型合约需求对于智能合约的开发和审计是一种机遇,也是一种挑战。上述关于以太币转账出现的安全隐患,在最近兴起的直接使用以太币转账的游戏合约中出现频繁,造成经济损失的同时,也在降低投资者和项目方对区块链行业的信心。面对这种形势,我们必须本着精益求精的态度对待合约开发和审计两个方面,以稳健的姿态迎接即将风起云涌的区块链行业。
本期的漏洞分析连载就到这里,欲知后事如何,请看下回分解。
引用:
[1]: Pandemica庞氏游戏存在Gas超出漏洞,80余万资金遭冻结
https://bcsec.org/index/detail/id/260/tag/2
[2]: 以太坊官方文档-地址相关
https://solidity.readthedocs.io/en/v0.4.24/units-and-global-variables.html#address-related
[3]: Solidity Security:
https://blog.sigmaprime.io/solidity-security.html#ether
[4]: 以太坊官方文件-发送和接受Ether:
https://solidity.readthedocs.io/en/v0.4.24/security-considerations.html#sending-and-receiving-ether
相关阅读:
漏洞分析连载第七期 —— 存储器局部变量未初始化
杨霞
成都链安科技CEO,创始人。电子科技大学副教授,最早研究区块链形式化验证的专家。一直为航空航天、军事领域提供形式化验证服务。主持国家核高基、装发重大软件课题等近10项国家课题。CC国际安全标准成员、CCF区块链专委会委员。发表学术论文30多篇,申请20多项专利。是成都链安科技有限公司创始人之一,该公司专注于区块链安全领域,其核心技术为形式化验证。
(内容转载请联系微信:qk15732632926)
(商务合作请联系微信:fengyan-1101)