深度解析Solidity的17个坑及超详细避坑指南
1. Re-Entrancy重新入口
以太坊智能合约的一个特点是能够调用和使用其他外部合约的代码。合约也通常可以处理以太币,因此往往会将以太币传送到各种外部用户的地址。调用外部合约或将以太币发送到一个地址的操作,要求合约提交一个外部调用。
然而,这些外部调用可能被攻击者劫持,从而迫使合约执行进一步的代码(例如 通过一个fallback函数),包括回调自己。因此,这就等于代码执行「重新进入了」合约。DAO攻击就是这样发生的。
坑点分析
「重新入口」这种攻击可能发生在合约将以太币发送到一个未知地址的时候。攻击者可以在外部地址上仔细构造一个合约,而在外部地址的fallback函数中包含恶意代码。因此,当一个合约将以太币发送到这个地址时,它将调用恶意代码。
一般来说,恶意代码会执行一个有漏洞的合约函数。「重新入口」这个名称源于这样一个事实,即外部恶意合约要求回调一个关于有漏洞的合约函数,并在漏洞合约的任意点「重新进入」并执行代码。
为了更好的理解,我们举个例子,一份有「重新进入」漏洞的合约,就像一个金库,允许储户每周只能提取1个以太币。
image代码1
这份合约有两个公有函数:depositFunds() 和 withdrawFunds()。depositFunds()只是简单地增加消费者的余额;而withdrawFunds()则允许发送者取回指定的金额。看上去,这两个函数只在提取钱数少于1个以太币,并且上个星期没有提取的情况下才能成功。但真的是这样吗?
当我们向用户发送他们所要求数量的以太币时,漏洞就在上面代码的第17行出现了。恶意攻击者很可能这样操作:
image代码2
从上面代码可以看出,攻击者将创建上述合约(比如在地址0x0... 123),并以EtherStore的合约地址作为构造函数的参数。这将初始化公有变量 etherStore,并将它指向想要攻击的合约。
然后,攻击者会使用一定量的以太币(大于或等于1,这里我们假设为1)来调用pwnEtherStore()函数。假如这个时候许多其他用户已经将以太币存到了这个合约中,这样当前的结余就是10个以太币。
接下来会发生以下情况。
-
代码1第15行:以1个以太币(和大量gas)的msg.value来调用Etherstore合约的depositFunds()函数。发送者(msg.sender)即是恶意合约传递地址(0x0... 123)。因此,balances[0x0…123] = 1 ether 。
-
代码2第17行:恶意合约接着将以1以太币为参数调用EtherStore合约中的withdrawFunds()函数,就可以顺利执行(在EtherStore合约的第12到16行),因为之前没有提款动作。
-
代码1第17行:此时,EtherStore合约会把1以太币送回到恶意合约。
-
代码2第25行:发送到恶意合约的以太币将执行fallback函数。
-
代码2第26行:EtherStore合约的总余额从10以太币变为9以太币,因此if语句通过。
-
代码2第27行:出让函数再次调用EtherStore合约的withdrawFunds()函数,并重新进入了EtherStore合约。
-
代码1第11行:在第二次调用withdrawFunds()函数中,由于第18行还没有执行完,所以余额仍然是1以太币。因此,balances[0x0..123] = 1 ether 。这也是lastWithdrawTime变量的一个用例,同样满足Etherstore合约的所有要求。
-
代码1第17行,我们再次收回了1以太币。
-
重复步骤4-8,直到代码2的第26行所示的EtherStore.balance >= 1。
-
代码2第26行,一旦EtherStore合约中剩下的以太币少于1个(或更少),if语句就会失败,代码1的第18、19行将会执行(对于每次调用withdrawFunds()函数)。
-
代码1第18、19行的balances和 lastWithdrawTime将会被设定,代码运行结束。
最后的结果就是,攻击者仅通过一次交易就从EtherStore合约中提取了所有以太币(只留下不多于1个)。
避坑技巧
很多方法都可以帮助避免智能合约中潜在的重新入口漏洞。
第一种方法是,当发送以太币到外部合约时,使用内置的transfer()函数。Transfer()函数只发送2300个gas,这不足以使目的地址/合约调用另一个合约(例如,重新进入发送中的合约)。
第二个方法是,在以太币被从合约(或任何外部调用)发送出去之前,确保所有改变状态变量的逻辑发生。在上述的例子中,代码1的第18、19行应该放在第17行之前。将执行外部调用的任何代码作为本地化函数或代码执行的最后一个操作,并将执行外部调用的代码置于未知地址上。这就是所谓的「检查-效应-交互」模式。
第三个方法是,引入一个互斥系统。也就是说,添加一个状态变量,该状态变量在代码执行期间锁定合约,从而防止重新入口的调用。
针对这三种方法,我们可以对代码1进行修正,效果如下:
image真实案例: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中减去一个数会得到一个较大的值)。
这些类型的漏洞允许攻击者滥用代码并创建一些意想不到的逻辑流。例如下面这样的实践锁定。
image代码3
这份合约被设计成一个时间保险柜,用户可以将以太币存入合约,并将其锁定至少一个星期,如果选择延长,则可以再延长1个星期。也就是说,一旦存放,就意味着用户的以太币至少要在这里存放一个星期。但这样做安全吗?
如果一个用户被迫交出了他们的私钥,上面的代码可能可以保证短时间内无法以太币无法被盗走。如果一个用户在这份合约中锁定了100个以太币,并将他们的私钥交给了攻击者,攻击者就可以使用溢出的方式来获取以太币,而不考虑时间。
那么他们是怎么做的呢?攻击者现在掌握着(它是一个公共变量)用户的私钥,可以确定当前地址的lockTime,我们可以称之为userLockTime。然后,他们可以调用increaseLockTime函数,并将数字2 ^ 256-userLockTime作为参数传递。这个数字将被添加到当前的userLockTime,并导致溢出,将lockTime[msg.sender]重置为0。攻击者可以简单地调用withdraw函数来获得用户的资金。
让我们看看另一个例子,这个例子来自Ethernaut Challanges。
image代码4
这是一个简单的代币合约,它使用了一个transfer()函数,允许参与者移动他们的代币。你能看出这份合约中的问题吗?
首先是transfer()函数,在第13行的语句可以通过一个流程来绕过。假设一个没有余额的用户。他们可以通过任何非零的_value来调用transfer()函数,并传递给第13行的语句。
这是因为balances[msg.sender]为0(以及一个uint256),因此减去任何正数(不包括2 ^ 256)都将导致结果为正数,就像我们上面所描述的那样。对于第14行来说,这也是正确的,在这里,我们的余额将会成为一个正数。因此,在这个例子中,由于下溢漏洞,我们就盗取了代币。
避坑技巧
防止溢出/下溢漏洞的常规方法是,使用或构建数学库来替代标准的数学运算符,包括加法、减法和乘法(没有除法,因为它不会导致溢出/下溢)。
OppenZepplin在构建和审核安全库方面做了大量的工作,以太坊社区可以充分利用这些库。为了演示在Solidity中如何使用这些库,让我们用Zepplin开源的SafeMath库来修正代码3的合约:
image image值得注意的是,所有标准的数学操作都被SafeMath库中定义的数学操作所取代。 代码3的合约不再执行任何能够发生溢出/下溢的操作。
真实案例:PoWHC和Batch Transfer Overflow
一个关于溢出/下溢漏洞的真实案例,是一个名为4chan集团想在以太坊上做一个庞氏骗,并用Solidity来编写,他们将它称之为「弱手币的证明」(PoWHC)。
不幸的是,合约的作者似乎从来没有在合约之前或之后看到过溢出/下溢,因此,有866个以太币从合约中被释放了出来。
一些开发者还将batchTransfer ()函数实现到了一些ERC20代币合约中,这些实现中往往包含了溢出漏洞。不过我认为,这个漏洞与ERC20标准没有任何关系,而是一些 ERC20代币合约有着batchTransfer()函数实现的漏洞。
3. 非预期的以太币
通常情况下,当以太币在合约中时,必须执行fallback函数,或者执行合约中定义的另一个函数。
不过这里有两个例外:
1)以太币可以在合约中存在而不执行任何代码;
2)对于依赖于代码执行的合约,每个发送到合约的以太币都可能受到攻击,因为在这种情况下,以太币是被强制送入合约的。
坑点分析
对于强制执行正确的状态转换或验证操作而言,一个常见的防御性技术是非常有用的,那就是变量检查。变量检查涉及到定义一组不变量(不应更改的标称值或参数),并且在一个(或许多)操作之后检查这些不变量是否保持不变。
不变量检查的一个例子是固定发行ERC20代币中的totalSupply。由于任何函数都不应修改这个不变量,因此可以对transfer()函数添加一个检查,以确保totalSupply保持不变,并确保该函数正常工作。
不过,有一个「不变量」对开发者来说特别有吸引力,但实际上却很容易被外部用户操纵。这就是合约中当前存储的以太币。
通常,当开发者第一次学习Solidity时,他们会有一种误解,认为合约只能通过payable函数接受或获得以太币。这种误解可能导致合约对其内部的以太币余额作出错误的假设,从而导致一系列的漏洞。而这种漏洞的确凿证据就是错误地使用了this.balance。
错误的使用this.balance会导致严重的漏洞。
以太币可以通过两种方式(强制)发送到合约中,而不使用payable函数或执行合约上的任何代码。
自析构/自杀
第一种方式是使用析构函数。任何合约都能够实现析构(地址)函数,该函数从合约地址中移除所有字节码,并将存储在那里的所有以太币发送到参数指定的地址。如果这个指定的地址也是一个合约,那么将没有函数(包括出让函数)被调用。
因此,无论合约中可能存在怎样的代码,selfdestruct()都可以用来强制将以太币送到任何合约中,这也包括没有任何支付函数的合约。这样一来,任何攻击者都可以创建带有析构函数的合约,并把以太币发送到合约上,然后调用selfdestruct(target)函数,并强制以太币发送到target合约。
预先发送的以太币
第二种方法是在不使用selfdestruct()或调用任何支付函数的情况下获得以太币,说白了,就是将合约地址和以太币预加载。因为合约地址是确定的(地址是从创建合约的地址哈希和创建合约的交易nonce计算的。
例如,形如:
address = sha3(rlp.encode([account_address,transaction_nonce])) ),
这意味着,任何人都可以在创建合约之前算出地址来,从而将以太币发送到该地址。当合约产生时,就会有一个非0的以太币余额。
举个例子:
image代码5
代码5所示的这份合约约代表了一个简单的游戏(自然会引发竞争条件),玩家将0.5 ether送入合约,希望成为最先到达三个「里程碑」之一的玩家。
里程碑指的是以太币为单位的。当游戏结束时,第一个到达里程碑的人可能会得到以太币的一部分。游戏结束时达到了最终的里程碑(10个以太币),用户则可以获得他们的奖励。
与EtherGame合约有关的问题来自于第14行和第32行的this.balance。一个攻击者可以强行发送少量的以太币,比如说0.1以太币,通过析构函数来阻止未来的任何玩家达到一个里程碑。
因为所有合法的玩家只能发送0.5个以太币增量,而this.balance已经不再是半整数的数字,它也会有0.1以太币为单位。这阻止了所有在第18,21和24行if为真的条件判定。
更糟糕的是,一个想要报复的攻击者错过了一个里程碑,他可以强行发送10以太币(或相当数量的以太币,使合约的余额超过finalMileStone),这将永远锁定合约中的所有奖励。
由于第32行的this.balance大于finalmilestone的条件,因此该claimReward()函数将永远处于恢复状态。
避坑技巧
「非预期的以太币」漏洞,常来自于对this.balance的滥用。在可能的情况下,合约逻辑应避免依赖于合约余额的精确值,因为它可以被人为操纵。如果应用逻辑基于this.balance,要确保考虑到非预期的余额。
如果需要确切知道以太币的余额,应该使用一个自定义的变量,以便在支付函数中逐步增加,并安全地跟踪存续的以太币。这个变量不会受到通过selfdestruct()强迫发送以太币的影响。
考虑到这一点,代码5 EtherGame的合约应修改为:
image这份合约中,我们创建了一个新变量,depositedEther保存着已知的以太币,这就是我们执行和测试的变量,这样我们就不会再有任何关于this.balance的引用。
真实案例:未知
目前,我们尚未看到这一漏洞的真实案例。
4. Delegatecall委托调用
在允许以太坊开发者模块化他们的代码时,CALL和DELEGATECALL操作是很常见的。标准的外部消息调用由外部合约/函数中运行的CALL操作码来处理。
DELEGATECALL操作码与标准消息调用相同,调用合约中运行目标地址上的代码,不过msg.sender和msg.value保持不变。在目标地址执行的代码是在调用合约的上下文中运行的。这个特性使得开发者可以实现为未来的合约创建可复用的代码。
尽管CALL和DELEGATECALL的作为十分简单,但DELEGATECALL的使用不当,会导致非预期的代码执行。
坑点分析
DELEGATECALL的上下文保护特性使得建立没有漏洞的自定义库并不像人们想象的那么容易。尽管库中的代码本身可以是安全并没有漏洞的。
但是,当它在另一个应用程序中运行时,可能会出现新的漏洞。让我们从斐波那契数列,来看一个相对复杂的例子。
假设下面的库可以生成斐波那契数列,以及类似形式的数列。
image代码6
代码6的库提供了一个函数,可以在数列中生成第n个斐波那契数列项。它允许用户在这个新数列中改变第0个的首项数字,并计算出第n个类似斐波那契数列的数字项。
那么黑客是如何利用这个库的呢?
image代码7
在代码7中,该合约允许参与人从合约中提取以太币,其中以太币的数量等于与参与者提取订单中相应的斐波那契数字;即第一个参与者得到1以太币,第二个参与者得到1以太币,第三个得到2,第四个得到3,第五个得到5等等,直到合余的余额少于被提取的那个斐波那契数字。
这份合约中有一些要素可能需要解释一下。
首先,有一个看起来很有趣的变量——fibsig。这里保存了ibonacci (uint256)字符串Keccak (SHA-3)哈希后的前4个字节。这就是所谓的函数选择器,并将其放入calldata中,以指定将调用哪个智能合约的函数。它用于第21行delegatecall函数中,以指定我们希望运行的fibonacci (uint256)函数。而delegatecall的第二个参数是我们传递给函数的参数。
此外,我们假设代码6中的地址在构造函数中被正确地引用。你能在这份合约中发现任何错误吗?
你可能已经注意到,状态变量start在库和主调用合约中都被使用了。在库合约中,start用于指定Fibonacci数列的起点,并设置为0,而在代码7合约中它被设置为3。
你可能还注意到代码7合约中的fallback函数允许将所有调用传递给库合约,这样就可以调用setStart函数来调用库。回顾我们保留的合约状态,这一函数似乎可以使你改变本地代码7合约中start变量的状态。
如果是这样,这将允许黑客提取更多的以太币,因为calculatedFibNumber取决于start变量(如代码6所示)。实际上,setStart ()函数不会(也不能)修改代码7合约中的start变量。这个合约中潜在的漏洞比仅仅修改start变量要糟糕得多。
说到这,我们需要先来了解一下状态变量(也就是storage变量)是如何存储在合约中的。在合约中引入的状态或storage变量(在单个交易中持久化的变量)是按顺序放入slots中的。
举个例子,在代码6中,存在两个状态变量:start和calculatedFibNumber。第一个变量是start,因此它存储在合约的slot[0]中。第二个变量calculatedFibNumber,被放置在下一个可用的存储——slot[1]中。
如果我们查看函数setStart(),它需要一个输入并设置start(不论输入是什么)。因此,这个函数为setStart ()函数中提供的任何输入都设置为slot[0]。类似地,setFibonacci()函数也将 calculatedFibNumber设置为fibonacci (n)的结果。同样,这只是将存储slot[1]设置为fibonacci (n)的值。
现在再来看看代码7的合约。slot[0]现在对应于fibonacciLibrary地址且slot[1]对应于calculatedFibNumber。这就是漏洞出现的地方。
delegatecall保留了合约的上下文。这意味着通过delegatecall的代码将对调用合约的状态(如存储)产生作用。
现在请注意,在第21行中,我们执行了fibonacciLibrary.delegatecall。这里调用了setFibonacci()函数,它对slot[1]进行了修改(也就是calculatedFibNumber)。这和预期的一样(即在执行之后,calculatedFibNumber得到调整)。
然而,请记住,FibonacciLib合约中的start变量位于slot[0],这是当前合约中的fibonacciLibrary地址。这意味着函数fibonacci ()将给出一个意想不到的结果。
因为在当前的调用的上下文中,它引用了start(slot[0]),这是fibonacciLibrary地址(当被解释为一个uint时,这个地址通常是相当大的)。
因此,withdraw()函数很可能会恢复原样,因为它不会包含uint(fibonacciLibrary)的以太币数量,而这就是calculatedFibNumber将会返回的值。
更糟糕的是,代码7的合约允许用户通过第26行的fallback函数调用所有fibonacciLibrary函数。正如我们之前讨论过的,这就包括了setStart ()函数。在这种情况下,slot[0]就是fibonacciLibrary地址。 因此,攻击者可以创建一个恶意合约,将地址转换为uint,然后调用
setStart(<attack_contract_address_as_uint>)。
这将改变fibonacciLibrary 成为攻击者合约的地址。然后,当用户调用withdraw()或fallback函数时,恶意合约就会运行,并盗取合约中的全部余额。就如下面例子所示:
代码8
请注意,这个攻击合约通过更改slot[1]l来改变calculatedFibNumber。 原则上,攻击者可以修改任何其他的slot[1].来执行对这个合约的各种攻击。在这里,我鼓励所有读者将这些合约放入Remix,通过delegatecall函数体验不同的合约攻击和状态的变化。
还有一点值得注意的是,当我们说delegatecall是保留状态的时候,我们不是在讨论合约的变量名,而是这些变量名所指向的实际存储位置。从这个例子中可以看到,一个简单的错误,可能会导致攻击者劫持整个合约及其以太币。
避坑技巧
Solidity为实现库合约提供了library关键字。这确保了库合约是无状态的和非析构的。确保库的无状态可以减少存储上下文的复杂性。无状态库还可以防止攻击者直接修改库的状态,以实现依赖于库代码的合约。一般来说,当使用DELEGATECALL时,要注意库合约和调用合约中可能调用的上下文,并在可能的情况下建立无状态库。
真实案例:Parity Multisig Wallet的第二次入侵
如果在非预期的上下文中运行,Parity Multisig钱包的第二次攻击就是一个典型的例子。让我们来分析一下这个案例。
这里有两份合约,一个是库合约,一个是钱包合约。
库合约如下所示:
image代码9
钱包合约如下所示:
image代码10
请注意,钱包合约基本上是通过一个代理调用将所有的调用传递出来的。这个代码中的walletLibrary常量地址代码充当了实际部署walletLibrary合约的占位符。
这些合约的预期运作是有一个简单低成本可部署的wallet合约,其代码基础和主要功能在WalletLibrary合约中。不幸的是,钱包合约本身就是一份合约,并且维持着它自己的状态。
调用WalletLibrary合约本身是可能的。具体来说,WalletLibrary合约可以进行初始化。一个用户通过在WalletLibrary合约中调用initWallet ()函数,成为了库合约的拥有者。
同一个用户,后来被调用了kill ()函数。因为用户是库合约的拥有者,所以修饰符通过了,而且库合约也自毁了。
由于现存的所有wallet合约都引用了这项库合约,而且没有任何方法可以改变这个引用,它们的所有功能,包括提取以太币的能力都随着WalletLibrary库合约而消失。更直接的说法是,所有这种类型的以太币都会立即丢失,并且永久无法恢复。
5. 默认的可见性
Solidity中的函数具有可见性的特性,它们指明了如何调用函数。可见性决定了一个函数是否可以由用户从外部调用(或由其他派生的合约调用),还是只能在内部或只能在外部调用。
在Solidity文档中提到四个可见性特性,默认函数是Public。不正确地使用这一函数,可能导致在智能合约中产生一些破坏性的漏洞。
坑点分析
函数的默认可见性是public。因此,不指定任何可见性的函数都可以被外部用户调用。如果开发者忽略了这一特性,本来的私有函数(或者只能在合约自身中调用)就会变成公有函数,问题也会随之而来。
我们来看一个例子。
image代码11
在代码11这个简单的合约中,实现的是一个地址猜赏游戏。为了赢得合约的余额,用户必须生成一个以太坊地址,它最后的8个十六进制字符是0。一旦获得,他们可以调用withdrawWinnings函数来获得他们的赏金。
不幸的是,函数的可见性还没有被指定。另外,sendering ()函数是public,因此任何地址都可以调用此函数来窃取赏金。
避坑技巧
一种最好的做法是,即使合约中的所有函数都是有意公开的,也必须明确说明合约中所有函数的可见性。最近版本的Solidity将会在编译的函数没有明确的可见性设置时显示警告,以鼓励这种做法。
真实案例:Parity MultiSig Wallet的第一次黑客攻击
在第一次Parity MultiSig Wallet的事件中,大约有价值3100万美元的以太币从三个钱包中被盗了。
从本质上讲,Parity Multisig Wallet是从一个基础的Wallet合约构建的,该合约调用了一个包含核心功能的库合约。从下面的代码片段可以看出,库合约包含了钱包初始化的代码:
image代码12
请注意,这两个函数都没有明确指定可见性,默认都是public。在钱包的构造函数中调用了initWallet()函数,并设置为multisig wallet的所有者,如 initMultiowned ()函数。
由于这些函数意外地被公开,攻击者可以通过部署的合约调用这些函数,将所有权重置为攻击者的地址。作为所有者,攻击者将所有的以太币都抽干了,价值为3100万美元。
6. 熵的错觉
在以太坊区块链上的所有交易都是确定性状态的转换操作。这意味着每一笔交易都改变了全球的以太坊生态系统状态,并且是以一种可计算的方式进行,没有任何的不确定性。
这意味着,在区块链生态系统内部没有熵或随机性的来源,在Solidity中也没有rand()函数。实现去中心化熵(随机性)是一个已经确立的问题,并且已经提出了许多解决这个问题的想法(例如,RandDAO,或者使用Vitalik在自己的博文中所描述的一系列哈希)。
坑点分析
在以太坊平台上建立的第一批合约中,有一些是关于赌博的。从根本上讲,赌博的根本在于不确定性,这使得在区块链(确定性模型)上建立一个赌博系统相当困难。很明显,不确定性必须来自区块链外部的一个源。
这对于同行之间的赌注是可能的,但是,如果你想要执行一个合约来充当一个赌桌(就像在我们的赌场里玩21点一样),显然是十分困难的。一个常见的陷阱是使用未来的区块变量,例如hash、timestamps、blocknumber 或 gas limit。
问题在于,这些变量是由矿工控制的,他们在区块上挖矿,因此并不是真正随机的。例如,考虑一个具有逻辑的轮盘赌智能合约,如果下一个区块哈希以偶数结尾,则返回一个黑数。
一个矿工(或矿工池)可以押注100万美元买黑数。如果他们解决了下一个区块,发现哈希末尾是一个奇数,他们会很乐意不发布这一区块并挖掘下一个块,直到他们找到一个解决方案发现区块哈希尾数是偶数(假设悬赏和费用低于100万美元)为止。
使用过去或现在的变量可能会更具破坏性,此外,使用单个区块变量意味着在一个区块中所有交易的伪随机数都是相同的,因此攻击者可以在一个区块内进行许多交易。
避坑技巧
熵的来源必须是区块链的外部。这可以在具有诸如commit-reveal之类系统的对等体之间完成,或者通过将信任模型改变为一组参与者(例如在RandDAO中)来完成。不过区块变量不应该用做源熵,因为它们可以被矿工操纵。
真实案例:PRNG合约
关于这个案例,以下博客文章有详细分析:
https://blog.positive.com/predicting-random-numbers-in-ethereum-smart-contracts-e5358c6b8620
7. 外部合约的引用
以太坊作为「全球计算机」的好处之一是能够复用代码,并与已经部署在网络上的合约进行交互。因此,大量合约都引用外部合约,在一般操作中使用外部调用与这些合约进行互动。这些外部消息调用可以用某种不明显的方式掩盖黑客的意图。
坑点分析
在Solidity中,任何地址都可以作为一个合约,尤其是当合约的作者试图隐藏恶意代码时。 让我们举一个例子来说明这一点,请看下面这段基本实现了Rot13密码的代码:
image代码13
这个代码只需要一个字符串,并通过将每个字符转移到右边的第13个位置(包括z),如「a」转换为「n」和「x」转换为「k」。
然后,我们用下面代码对下列合约进行加密:
image代码14
这个合约的问题在于encryptionLibrary地址不是公开或不变的。因此,合约的部署人可以在构造函数中给出一个地址,指向这一合约:
image代码15
这就实现了rot26密码(即每个字符移26个位置),我们还可以将下列合约联系起来:
image代码16
如果在构造函数中给出了其中任何一个合约的地址,则encryptPrivateData ()函数只会生成一个事件,即打印未加密的私有数据。
在这个例子中,虽然构造函数中设置了一个类似库合约,但特权用户(如一个owner)通常可以更改库的合约地址。如果一个联接的合约不包含所调用的函数,则将执行fallback函数。
例如,这行
encryptionLibrary.rot13Encrypt(),
如果encryptionLibrary指定的合约如下:
image代码17
然后会发出一个文本为「Here」的事件。因此,如果用户可以更改库合约,那么,他们原则上可以让用户在不知情的情况下运行任意的代码。
因此,开发者要杜绝使用这样的加密合约,因为在区块链上可以看到智能合约的输入参数。 此外,Rot密码也并不是一个理想的加密技术。
避坑技巧
如上所述,无漏洞合约可以在某些情况下以恶意行为的方式部署。审核员可以公开地核实合约,并使其所有者以恶意方式部署合约,从而导致公开审计的合约具有漏洞或恶意属性。
有许多方法可以防止这些情况发生。
一种方法是,使用new关键字来创建合约。在上面的例子中,构造函数可以写成:
image这样,在部署时就可以创建引用合约的一个实例,而部署者也无法在不修改智能合约的情况下,用其他任何方式替换Rot13encryption合约。
另一个方法是,对已知的外部合约地址,进行硬编码。
一般来说,开发者应该仔细地检查调用外部合约的代码。作为一个开发者,在定义外部合约时,最好是让合约公开(除了在honey pot的情况下),以便使用户能够很容易地检查合约中引用的那些代码。
相反,如果一个合约有一个私有的可变合约地址,那么这可能就是合约被恶意攻击的标志。 如果一个用户能够更改用于调用外部函数的合约地址,那么通过实现一个时间锁或投票机制,使用户能够看到哪些代码正在被更改,或者给参与者一个选择新合约地址的机会。
真实案例:重新入口的蜜罐攻击
最近,一些honey pot(蜜罐攻击)已经被放到了主网上。这些合约试图智取那些试图利用这些合约的以太坊黑客,但他们反过来又让以太币失去了它们期望利用的合约。
举一个使用了上述攻击的例子,其中用构造函数中的恶意合约替换了预期的合约。代码如下:
image image8. 短地址/参数攻击
这种攻击不是专门针对Solidity合约的,而是针对所有可能与合约互动的第三方DApp。
坑点分析
在参数传递给智能合约时,参数将根据ABI规范进行编码。发送短于预期参数长度的编码参数是可能的。
例如,发送一个只有19字节的地址,而不是标准的40个十六进制数20字节。在这种情况下,EVM会把0填充在编码参数的末尾,以补全预期的长度。
当第三方应用程序不验证输入时,这就成为一个问题。最明显的例子是,当用户请求提款时,不会验证ERC20代币的地址。
请想象一下标准的ERC20 transfer函数的接口(注意参数的顺序):
现在交易,一个用户持有大量的代币(如REP),希望提出其中的100个。用户将提交它们的地址:
0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead
以及提取代币数量100。
这时,交易会按照transfer函数指定的顺序编码这些参数,即先是address然后是tokens。编码的结果将是:
a9059cbb000000000000000000000000000000000000000000000000000000000000000000000000000056bc75e2d63100000
其中,前四个字节(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),则执行这些函数的交易将不会恢复。当返回值没有被检查时,会出现一个常见的陷阱,而开发者则预期会出现一个复原。
坑点分析
考虑下面的例子:
image代码15
代码15代表了一个lotto式的合约,winner可以获得数量为winAmount的以太币,这些以太币通常会留下一些让任何人都能取回的余地。
这一问题存在于在没有检查响应的情况下使用send()函数的地方。在这个例子中,如果一个winner的交易失败(要么因为耗尽了gas,这是一个出让函数在合约中故意抛出的错误,要么堆栈调用的深度攻击),允许payedOut设置为true(不管以太币是否被发送)。
在这种情况下,公众可以通过withdrawLeftOver()函数提取winner的赏金。
避坑技巧
在可能的情况下,如果外部交易复原,则使用transfer()函数而不是send()函数作为复原的方式。如果需要send(),需要始终确保对返回值的检查。
当然,更好的方式是采用withdrawal模式,这个模式中每个用户都需要调用一个独立的函数(例如 withdraw函数)来处理从合约中发送以太币的问题,因此独立处理发送交易失败的结果。
这个想法是从逻辑上将外部发送函数从代码基础的其余部分分离出来,并将可能失败交易的负担放到了调用withdraw函数的最终用户身上。
真实案例:Etherpot和King of the Ether
Etherpot是一个彩票的智能合约,与上面例子中提到的合约没有太大的不同。如下面代码所示:
image这个合约的问题在于,不正确地使用了区块哈希。然而,这个合约也受到了没有检查调用返回值的影响。
值得留意的是,在第21行中,发送函数的返回值没有被检查,然后下一行设置了一个布尔值,表示获胜者已经收到了他们的赏金。这个缺陷可以让一个赢家不能得到以太币,但是合约的状态可以表明赢家已经收到了钱。
10. 竞争条件/非法预先交易
外部调用与其他合约的组合以及底层区块链的多用户性质,造成了各种潜在的solidity陷阱,用户通过竞争代码的执行得到了非预期的状态。「重新入口」漏洞就是这种竞争条件的一个例子。
在这一部分,我们将更广泛地讨论可能发生在以太坊区块链上的不同竞争条件。
坑点分析
与大多数主链一样,在以太坊中只有当矿工解决了一个共识机制(PoW),这些交易才被认为是有效的。生成该区块的矿工也会选择将哪些交易包含在该区块中,这通常是由交易的gasPrice决定的。
这里就有一个潜在的攻击向量。攻击者可以监视可能包含问题解决方案的交易池,修改或撤销攻击者的权限或更改合约中对攻击者不利的状态。然后攻击者可以从这个交易获得数据,创建一个自己的交易,并且以更高的价格创建自己的交易,并将该交易包含在原始数据之前的区块中。
让我们通过一个例子来看看这个坑是怎么产生的:
image代码16
想象一下,这份合约包含了1000个以太币。用户如果能够找到一个哈希:
0xb5b5b97fafd9855eec9b41f74dfb6c38f5951f9a3ecd7f44d5479b630ee0a的sha3
就可以提交解决方案并得到1000以太币。
让我们假设一个用户发现的解决方案是 「Ethereum!」,他们将「Ethereum!」 作为参数调用solve()。不幸的是,攻击者已经很聪明地观察到任何提交解决方案者的交易池。他们看到了这个解决方案,检查了它的有效性,然后提交一个比原始交易价格更高的交易。
由于gasPrice更高,生成该区块的矿工可能会给攻击者更多的优先权,并在原始提交者之前先接受了他们的交易。攻击者会拿走1000以太币,而导致解决了这个问题的用户反而一无所获。
避坑技巧
有两类人可以执行这些正在运行的非法预先交易攻击:用户(他们修改交易的gasPrice)和矿工本身(他们可以按照他们认为合适的方式在一个区块中重新对交易排序)。
对于第一类来说,他们的合约比第二类合约要糟糕得多,因为矿工只有在解决了一个区块时才能进行攻击,而对于任何一个专门针对某个特定区块的矿工来说,这种攻击都是不可能的实现的。
我们可以将列出一些防坑措施。
首先,我们可以采用在合约中创建逻辑,为gasPrice设置一个上限。这使得用户无法提高gasPrice,这可以避免因提高gasPrice获得超出上限的优先交易顺序。这种预防措施只能减少第一类攻击者(任意使用者)。
在这种情况下,矿工仍然可以攻击合约,因为他们可以无论gasPrice如何,都可以随心所欲地在他们所在区块内进行交易。
另外,还有另一个方法是尽可能使用commit-reveal。这种方案要求用户使用隐藏的信息(通常是哈希)发送交易。在将交易包含在一个区块之后,用户发送一个交易来显示发送的数据(显式阶段)。这种方法使得矿工和用户无法确定交易的内容,因此不能对交易进行预警。
然而,这种方法不能隐藏交易的价值,智能合约允许用户发送交易,其提交的数据包括了他们愿意花费的以太币数量。然后用户可以发送任意值的交易。在这个阶段,用户可以获得交易中发送的金额与他们愿意支出金额之间的差额。
真实案例:ERC20与Bancor
在以太坊上发币要遵循ERC20标准,这个标准有一个潜在的预先非法交易漏洞,这一漏洞源自approve()函数。
该标准指定的approve()函数为:
这个函数允许用户授权其他用户代表他们转移代币。当Alice授权她的朋友Bob花费100个代币时,这个最大的漏洞就显现出来了。不过后来Alice想要撤回这个授权,所以她创建了一个交易,将Bob的配额设置为50个代币。
Bob一直在仔细地观察这条链,他看到了这个交易,并建立了一个自己花费100个代币的交易。比起Alice,他的gasPrice更高,交易的优先级也更高。一些approve()函数的实现允许鲍勃转移他的100个代币,然后当Alice的交易被提交时,将鲍勃的交易批准为50个代币,实际上让Bob获得了150个代币。
另一个著名的案例是Bancor。Ivan Bogatty和他的团队记录了最初Bancor实现中的一次的攻击,他在自己的博客详细的记录了这次攻击。从本质上来说,代币的价格是根据交易价值来确定的,用户可以观察Bancor交易的交易池,然后从价格差异中获利。目前Bancor的团队已经解决了这次攻击。
11. 拒绝服务攻击(DOS)
这个类别非常宽泛,但从根本上来说,它的本质是,让用户可以在一小段时间内,或者在某些情况下永久性地无法使用合约。这可能会永远困住这些合约中的以太币,就像第二次Parity MultiSig黑客攻击那样。
坑点分析
我们知道,智能合约可以通过多种手段使其变得不可操作。在这里,我将只强调一些可能在区块链中不太明显的Solidity编码方式,这些模式可能导致攻击者发起DOS攻击。
主要包括以下几种。
1. 通过外部操作的映射或数组循环。在我的经验中,这种方式的攻击见得太多了。通常情况下,它出现在一个owner希望向他们的投资者分发代币的时候,并且使用了一个与distribute()类似的函数。参见下面代码:
image在这个合约中,它的循环在一个可以被人为放大的数组上运行。攻击者可以创建许多个用户的账户,从而使investor数组更大。攻击者可以通过这样操作做,使执行for循环所需gas超过区块的gas限制,从而使distribute()函数变得不可操作。
2. 所有者操作。所有者在合约中享有特殊特权,并且必须执行一些任务,以便合约进入到下一个状态。一个例子就是一个ICO合约,它要求所有者通过finalize()函数进行操作,使代币可以转让。例如:
image在这种情况下,如果一个特权用户丢失了他们的密钥,或者变得不活跃,则整个合约就会变得不可操作。而且,如果owner无法调用finalize ()函数,就没有可以转移的代币;也就是说,代币生态系统的整个运行都取决于一个单一的地址。
3. 基于外部调用的进度状态。合约有时是这样写的,为了进入一个新的状态,需要将以太发币送到一个地址,或者等待外部来源的一些输入。当外部调用失败时,或者由于外部原因而被阻止的时候,这些模式可能导致DOS攻击。
在发送以太币的例子中,用户可以创建一个不接受以太币的合约。如果一份合约需要将以太币送到这个地址,以便进入一个新的状态的话,那么合约永远不会达到这一新状态,因为以太币永远不可能被送到合约中。
避坑技巧
在第一个例子中,合约不应该在由外部用户人为操纵的数据结构中循环。可以使用withdrawal,即每个投资者都调用一个撤回函数来独立地声明代币。
在上面的第二个例子中,要求特权用户更改合约状态。在这个例子中,当owner丧失能力时,可以使用故障保护装置。一个解决方案是将owner设置为一个多重签名合约。
另一个解决方案是使用一个时间锁,其中需要在第13行代码中,包括一个基于时间的机制,比如
require(msg.sender == owner || now > unlockTime)
这允许任何用户在一段时间之后最终确认,该时间由unlockTime指定。这种方法也可以用在第三个例子中。
如果需要外部调用才能进入一个新状态的话,则要考虑到它们可能出现的故障,并可能增加一个基于时间的状态进程,否则所希望的调用可能永远不会出现。
真实案例:GovernMental
GovernMental是一个老式的庞氏骗局,积累了大量的以太币。不幸的是,它很容易受到本节中提到的DOS漏洞的影响。
一个 Reddit 帖子描述了合约是如何要求删除一个大的映射,这种映射的删除导致当时的gas成本超过了区块gas的限制,因此无法取回以太币。
合约地址是:
0xF45717552f12Ef7cb65e95476F217Ea008167Ae3
可以从
0x0d80d67202bd9cb6773df8dd2020e7190a1b0793e8ec4fc105257e8128f0506b
中看到交易,最终得到所有以太币共使用了2.5M gas。
12. 操纵区块时间戳
区块时间戳历来有各种应用,例如随机数的熵,锁定资金的时间和各种状态变化的条件语句等。如果在智能合约中不正确地使用区块时间戳,矿工稍微调整时间戳,就可能会带来相当危险后果。
坑点分析
正如上面所说,如果矿工动机不纯,就可以操纵block.timestamp。让我们构建一个简单的游戏,这个游戏很容易被矿工利用。
image代码17
代码17的这个合约,就像一个简单的彩票系统。每个区块中的一个交易都可以赌10以太币来得到赢得合约余额。这里的假设是,block.timestamp对于最后两位数字是均匀分布的。如果是这样的话,那么中奖的几率将是1/15。
然而,正如我们上面所说,矿工可以根据需要调整时间戳。在这种情况下,如果合约中集合了足够多的以太币,那么一个生成区块的矿工就会有动力去选择一个时间戳。例如,block.timestamp或者now的是0的时间戳。
在这样做的时候,他们可能会赢得锁定在这份合约中的以太币,同时获得全部的回报。由于每个区块只允许一个人下注,这也很容易受到非法预先交易的攻击。
在实践中,区块时间戳是单调增加的,因此矿工不能选择任意的时间戳,它们的时间戳必须比他们的父时间戳要大)。
因此,它们也仅限于在不远的时间段内设置区块时间,否则这些区块将就很可能被网络拒绝,也就是说,节点将不会验证未来时间戳的区块。
避坑技巧
区块时间戳不应该用于熵或产生随机数,例如,它们不应成为(直接或通过某种推导)赢得一场比赛或改变一个重要的状态(如果假设是随机的)的决定性因素。
有敏锐的时间逻辑有时是必要的,例如解锁合约(timelocking)在几周后完成一个 ICO 或强制执行过期日期。有时建议使用block.number和一个平均区块时间来估计时间。
例如,一个星期零10秒钟的区块时间,相当于大约60480个区块生成时间。因为矿工无法轻易操纵区块序数,所以指定一个区块序数来更改合约状态可以更加安全,BAT ICO合约就采用了这一策略。
如果合约不是特别关注矿工操纵的区块时间戳,也可以不用这样做,但是在开发合约时需要注意这一点。
**真实案例:GovernMental **
同样以GovernMental来举例。这个合约的签订者是在一轮中最后加入的玩家(至少一分钟)。因此,作为一名玩家的矿工,可以调整时间戳(在未来的某个时间,使它看起来像一分钟已经过去了),使得看起来玩家是最后加入的(即使这在现实中是不正确的)
13. 构造函数
构造函数是一种特殊的函数,通常在初始化合约时执行关键的任务。在 solidity v0.4.22之前,构造函数被定义为与包含它们的合约具有相同名称的函数。
因此,当一个合约名称在开发过程中发生变化时,如果构造函数的名称没有改变,它就变成了一个正常的、可调用的函数。可以想象,这会导致一些有意思的合约攻击。
坑点分析
正如上面所说,如果我们修改了合约的名称,或者在构造函数名称中有一些笔误,这样构造函数就不再匹配合约的名称,从而会变成一个正常的函数。这会导致可怕的后果,尤其是当构造函数执行特权操作的时侯。请看以下合约:
image这份合约的功能是收集以太币。通过调用withdraw()函数,只允许所有者撤回所有的以太币。问题是,建构函数并非完全以合约的名称命名。具体来说,OwnerWallet和ownerWallet是不一样的。
因此,任何用户都可以调用ownerWallet()函数,将自己定位为所有者,然后通过调用withdraw()来获取合约中的所有以太币。
避坑技巧
不过,这个问题已经在Solidity 0.4.22版本的编译器中得到了解决。这个版本引入了一个构造函数关键字,用该关键字来指定构造函数,而不是要求函数的名称与合约名相匹配。建议使用此关键字指定构造函数,以防止上面强调的命名问题。
真实案例:Rubixi
Rubixi的合约代码是另一个出现这种漏洞的「金字塔计划」。它最初叫做 DynamicPyramid,但是在被部署到Rubixi之前,合约名字已经改变了。而构造函数的名称没有改变,允许任何用户成为创建者。
关于这个bug的一些有趣讨论可以在一些比特币论坛上找到。最终,它允许用户争夺创建者的地位,从金字塔计划中获得费用。
14. 未初始化的存储指针
EVM将数据存为storage或memory。在开发合约时,准确地理解如何使用这个操作至关重要。否则可以因为利用不适当地初始化变量来产生有漏洞的合约。
坑点分析
函数中的局部变量根据它们的类型默认为存在内存中。未初始化的本地存储变量可以指向合约中其他意想不到的存储变量,从而导致有意或无意的漏洞。
让我们考虑下面这个相对简单的名称注册合约:
image这个简单的名称注册合约只有一个函数。当合约解锁时,它允许任何人注册一个名称(作为bytes32哈希),并将该名称映射到地址上。
不幸的是,这个注册器最初是锁定的,而且第23行上的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 默认的复杂数据类型(如结构),在初始化时作为局部变量存储它们。因此,新记录在第16行默认为storage。这种漏洞是由于newRecord没有初始化而引起的。因为它默认为存储,它成为一个指向存储的指针,因为它是未初始化的,它指向了slot 0(即存储解锁的地方)。
值得注意的是,在第17和18行上,我们为_name设置了
nameRecord.name
并为
_mappedAddress设置了
nameRecord.mappedAddress
这实际上改变slot 0和slot 1的存储位置,这两个位置同时修改了已解锁的存储空间和与
registeredNameRecord
相关的slot存储位置。
这意味着,只需通过寄存器函数的bytes32名称参数,就可以直接修改解锁。因此,如果名称的最后一个字节是非零的,它将修改存储slot 0的最后一个字节,并直接将unlocked更改为true。
这样的_name值将在unlocked为true的时候通过在第23行上的require()。在Remix中尝试一下这个例子。
注意,如果_name使用了以下值的函数:
0x0000000000000000000000000000000000000000000000000000000000000001
则会通过执行。
避坑技巧
Solidity的编译器将未初始化的存储变量作为了警告,因此开发者在构建智能合约时应该注意这些警告。当前版本的mist(0.10)不允许编译这些合约。在处理复杂类型时,要明确使用内存还是存储,以确保它们按预期运行。
真实案例:蜜罐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中没有定点类型,开发者必须使用标准的整型数据类型来实现他们自己的数据。在这个过程中,可能会遇到很多陷阱。
比如下面代码所示的(请忽略溢出和下溢):
image这个简单的代币买卖合约在购买和出售代币过程中有一些明显的问题。虽然买卖代币的数学计算是正确的,但缺少浮点数会导致错误的结果。例如,当在第7行上购买代币时,如果值小于1以太币,初始除法的结果是0,最后乘法的结果也为0(例如200wei除以1e18,weiPerEth等于0)。
同样地,当出售代币时,任何小于10的代币也会导致结果为0。事实上,这里的四舍五入总是在往下走,所以卖出29个代币,就会产生2以太币。
因此,这份合约的问题是其精度仅限于最近的以太币(例如1e18 wei)。当你需要更高的精度时,或者在处理ERC20代币中的小数时,有时就会很头疼。
避坑技巧
在智能合约中保持正确的精度是非常重要的,尤其是在处理反映经济决策的比率和利率的问题时,应该确保所使用的任何比率或利率允许大数字。
例如,在上面的例子中,我们使用了tokensPerEth 作为利率。但如果使用weiPerTokens会更好,因为它是一个很大的数字。为了解决代币的数量,我们可以做
msg.sender / weiPerTokens
这将得到一个更精确的结果。
另一个需要牢记的是操作的顺序。在上面的例子中,购买代币的计算是
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变量的合约通常容易受到网络钓鱼攻击,这种攻击可以欺骗用户在漏洞合约上执行授权操作。
例如下面合约:
image这份合约使用tx.origin授权了withdrawAll ()函数,因此,它允许攻击者创建一个攻击合约,如下代码所示:
image为了利用这个合约,攻击者会先对其进行部署,然后说服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需要访问被你所用到的以太币,它也还是不可能恢复你隐藏的以太币。
我们可以用用下面合约来说明这一点:
image这个合约允许你存储无密钥的以太币。这个函数可以用来计算第一个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证明来获取它们的代币。因此,一个单一的交易就会允许所有用户兑换他们空投的代币。
比如以下例子:
image这个函数可以构建成一个代币合约,允许未来发生的空投。而确定所有用户的余额所需的唯一交易是设置Merkle树的根交易。
18. 资源与福利
终于进入尾声了,能看到这里真是好定力,为了犒劳你,营长还准备了一些开发资源给,留给大家继续消化。
其他有意思的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
进一步阅读表资料表
-
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
本文转载自《长文 | 深度解析Solidity让老司机翻车的17个坑及超详细避坑指南,建议先马后看(附送独家资源)》,作者:Dr Adrian Manning,译者:老曹、Aholiab,版权属于原作者。