研报丨硬核解析智能合约漏洞原理
撰文:Chen Bo Yu、Hsu Tzu Hsiu
智能合约基础介绍
在探讨智能合约漏洞解析之前,我们先从一个基本的范例来了解一个智能合约会具备哪些元素。
● 变数:即此范例中的 balances,在这个合约中负责存储使用者地址在合约中对应的存款余额
● 函数:即此范例中的 getBalance,使用者呼叫此函数时,会回传使用者在合约中的存款余额
● 接收函数:即此范例中的 receive,这是一个内建的函数。当合约收到使用者传入 ETH 且无呼叫其他函示时会触发,此范例在触发接收函数时,会变更变数 balances 的状态,而函数中的 msg.sender 代表的是交易的发送者地址
● 回退函数:即此范例中的 fallback,这也是一个内建的函数。当使用者呼叫了不存在的函数时触发,可以理解为例外处理函数。在此范例中,触发时把交易回退,亦即让交易失效。
常见漏洞解析
了解问题发生的原因,并且归纳问题的类别可以帮助我们更好的防范。DASP(Decentralized Application Security Project)收录了十种智能合约漏洞,下面我们整理了其中最常见的合约漏洞以及新型态的攻击模式。
1.重入漏洞
重入漏洞是最著名的智能合约漏洞,先前提到 The DAO 事件中也是为此原因而被骇客攻击,该漏洞原理是通过循环调用一个函数而达到攻击目的。
这边展示的是一个简单的提款函数,让使用者可以根据合约里的余额(userBalances)取走存款。可以注意到的是,当这个函数的调用者为一智能合约的时候,提款操作(msg.sender.call)将会触发该智能合约的 receive 函数,并把剩余的 gas 传入。而此时还未把使用者在原先智能合约中记录的余额归零(userBalances[msg.sender] = 0),攻击者即可在 receive 函数里再次调用 withdrawBalance 函数,并通过余额状态尚未修改的漏洞达到重复取款的目的,直到 gas 耗尽或合约被掏空。
攻击流程图展示
防范方式也很简单,只要先把智能合约纪录的余额做清空,再做转帐动作,即可避免攻击发生。
2.整数溢位漏洞
在以太坊智能合约中,uint256 是常见的整数型别,这意味着此变数可以储存的整数范围为 0 ~ 2^256 - 1,存储上限大约是一个 78 位数的值,你可能会觉得这个数已经够大了,但它仍然可被用来达成溢位,也就是说当一个变数的值为 2^256 - 1,而对这个变数的值又再进行加一的操作时,他的值会因为超过存储上限而变为 0。要避免此漏洞,我们需要在整数运算前针对整数的范围去做检查,并在侦测到溢位运算时即时抛出异常。
3.阻断服务攻击
智能合约服务中断是一个严重的问题,因为有些漏洞造成的服务中断是永久性的,无法恢复。攻击原理包括了:意外执行 SELFDESTRUCT 指令、访问控制权限出错、Gas limit 达到区块上限使合约无法正常运作、以及我们这边展示的利用异常抛出,造成合约永久性瘫痪。
这是一个简单的拍卖合约示例,出价高者可以成为 currentLeader,并记录该次竞标出价为 highestBid,同时把先前的出价金额还给前一个竞标领先者。攻击者可以部署一个智能合约,在正常出价后让该合约成为 currentLeader,并在合约内负责收款的 receive 函数中使用 revert 函数来抛出异常,让交易失效。当其他使用者想出价竞标时,会因为合约无法转钱给 currentLeader(即攻击者先前部署的合约),而造成交易失败,拍卖合约的功能也因此永久失效,攻击者得以赢下此次的拍卖竞标。
攻击者合约示例
4. Tx origin 漏洞攻击
当开发者利用 solidity 中内建的 tx.origin 变数来验证权限时,会让攻击者有攻击的机会。在进入示例之前,须先了解 tx.origin 返回的是原始发送交易的地址,而msg.sender 返回的是当前交易的发送者。以下示意图情景为:
用户 A 呼叫了合约 B 内部的函数,并在函数内又再呼叫了合约 C。可以观察 tx.origin 与 msg.sender 的差异。
接下来来看看实际的攻击场景,上图智能合约中的 sendTo 函数必须符合 tx.origin 与owner 相等的条件才会被执行,但是攻击者可以通过下图的智能合约,利用上述提过 tx .origin 与 msg.sender 的差异,巧妙地绕过验证,并触发 sendTo 函数。具体细节是当攻击者诱导上图合约的 owner 去触发了下图合约的 fallback 函数时,若攻击合约在 fallback 函数内去调用 sendTo 函数,就可以得到 owner 的权限去执行。
5. 未适当处理external call的回传值
在智能合约中,使用到低层级调用函数指令时,如:address.call()、address.callcode()、address.delegatecall() 和 address.send() 等等,如果调用失败并不会抛出异常,仅会回传调用结果的布林值,合约将能继续往下执行。若未对调用结果的回传值做检查,可能将会使智能合约无法正常运作。
我们以一个简单的取款函数作为示例,当使用一合约呼叫上图的 withdraw 函数,且若该合约不能接收 ETH 转入(例如无设置 payable)时,会造成呼叫方无法收到 ETH ,但因合约会继续往下执行,导致其在合约中 balances 的状态纪录被改变。修正写法如下:
6. 短地址攻击
此攻击手法大多出现在 ERC-20 (代币标准协定)智能合约中,须先了解到,当我们呼叫一个函数时,在 EVM (以太坊虚拟机)里实际上是在解析一堆 ABI(Application Binary Interface)字符。而一般 ERC-20 标准的代币都会实现用来转帐的 transfer 函数,当我们调用 transfer 函数时,交易的调用内容由 3 个部分组成:
● 4 字节,函数名的哈希值,例如:a9059cbb
● 32 字节,以太坊地址(地址为 20 字节,高位补零),例如:
00000000000000000000000011223344556677889900aabbccddeeff11223344
● 32 字节,代表需要转送的代币数量:
0000000000000000000000000000000000000000000000000de0b6b3a7640000
若攻击者地址为:0x1234567890123456789012345678901234567800(尾数为零),且在呼叫 transfer 时刻意舍去尾数零,若合约内没有对内容格式做检查,EVM 读取时会从第三个参数(代币数量)的高位拿 00 来补充,这将造成实际想要转送的代币数量缺少一个字节,即向左移位了 8 个比特,数值瞬间扩大 256 倍,攻击者成功盗取代币合约中的代币。
7.闪电贷攻击
闪电贷,顾名思义就是快速贷款,那这个速度有多快呢?官方的解释是,贷款发行和偿还的交易必须在以太坊上同一个区块内完成。可以说闪电贷是一种借助于区块链技术的颠覆式创新,它与传统的借贷有两个主要的差别,一个是它无需抵押品,第二个是它要求要在执行借出的同一笔交易中执行还款操作,因此对于出借资金的那方来说是不用承担违约风险的,因为只有当区块链上借贷方执行的借出与还款操作都确实被执行了,这笔交易才有效。也就是说我们可以设计一个智能合约来借出资金,接着执行一些资金操作,最后在将资金归还,而这些操作都会在同一笔交易中完成。这就给黑客们带来了利用闪电贷发动攻击的机会,因为它大大的降低了黑客的攻击成本,近期在 DeFi 领域的多数攻击都是使用闪电贷来实现,主要都是黑客通过借出的巨额资金来对协议制造价差并从中套利,还款后再带着不当获利逃之夭夭。我们从 bZx 攻击事件来了解黑客的攻击思路:
1. 黑客通过闪电贷从去中心化数字资产衍生交易平台 dYdX 借出了一万枚 ETH
2. 使用其中的 5000 枚 ETH 抵押在去中心化借贷平台 Compound 以借出 112 枚 wBTC(Wrapped BTC,即以太坊上的BTC跨链资产)
3. 剩下的的 5000 枚 ETH 到去中心化借贷平台 bZx 上开了 wBTC 的空单
4. 用借出的 112 枚 wBTC 到去中心化交易所 Uniswap 砸盘,让 wBTC 价格快速下跌
这一系列操作让黑客在 bZx 上开的空单仓位大赚,接着归还闪电贷借出的一万枚 ETH,并在这个过程中获得了价值 35 万美元的收益。此次攻击的主要原因是因为 Uniswap 的价格的剧烈变化最终导致资产的损失,这本该是正常的市场行为,但是黑客通过恶意操纵市场,使项目方造成损失。bZx 合约被操纵一事,开始让闪电贷进入了更多开发者的视线,一方面许多聪明的开发者开发出了全新的去中心化金融应用,同时也让开发者更为警惕可能的逻辑攻击。
结论
智能合约的运作为被动的,所有的合约动作均须由使用者发起交易、呼叫合约中的函数函数才会执行动作,而合约执行基于区块链的特性是不可逆的,且当合约部署上区块链后,所有资料都是公开透明的,即便代码不开源,也可利用反组译工具回推合约内容。因此,开发者需熟悉漏洞原理并避免之,使用者也应了解合约安全议题,维护自身权益。