长文 | 深度解析Solidity让老司机翻车的17个坑及超详细避坑指南,建议先马后看(附送独家资源)...

本文深入剖析了Solidity编程中常见的17个错误,包括重入攻击、溢出/下溢、非预期以太币、委托调用等,并提供了避坑技巧和真实案例。文章指出,这些问题可能导致智能合约的安全漏洞,甚至造成严重损失。建议开发者在使用Solidity时,注意使用安全的数学库、避免依赖合约余额、正确处理存储指针等,以提高合约安全性。
摘要由CSDN通过智能技术生成

640?wx_fmt=jpeg

作者:Dr Adrian Manning

译者:老曹、Aholiab


说起Solidity,虽然还很初级,但无疑已成为今天区块链开发的常用语言之一,今天以太坊智能合约的很多字节码,都是用Solidity编译的。


不过由于Solidity和EVM之间的差异,开发者在使用这门语言时,遇到了很多坑,也学到了很多深刻的「教训」。


这篇文章,我们会详细且全面地梳理一下Solidity开发者跌过的17个坑,以及Solidity开发中最容易犯下的错误,同时,也会分享一些资源作为补充资料,以便进一步消化。以帮助其他开发者避免重蹈覆辙。


文章略长,全文共18,918字,都是干货,建议保存在电脑端慢慢看


资源福利请拉至文末


640?wx_fmt=gif

你可以把这篇文章看作是一个全面的Solidity已知攻击向量和常见反模式的综合资源库」,从本文中,你将读到以下常见的坑,每个都会以:坑点分析、避坑技巧和真实案例三个部分进行展开


  1. Re-Entrancy重入口

  2. 算法产生的溢出/下溢

  3. 非预期的以太币

  4. 委托调用

  5. 默认的可见性

  6. 熵错觉

  7. 外部合约的引用

  8. 短地址/参数攻击

  9. 未检查的CALL的返回值

  10. 竞争条件/非法预先交易

  11. 拒绝服务攻击(DOS)

  12. 区块时间戳操纵

  13. 小心构造函数(Constructors with Care)

  14. 未初始化的存储指针

  15. 浮点和精确度

  16. Tx.Origin证明

  17. 以太坊的怪癖

  18. 相关Bug列表

  19. 参考资料 / 拓展阅读推荐


闲言少叙,接下来就进入正题吧~



1. Re-Entrancy重新入口


以太坊智能合约的一个特点是能够调用和使用其他外部合约的代码。合约也通常可以处理以太币,因此往往会将以太币传送到各种外部用户的地址。调用外部合约或将以太币发送到一个地址的操作,要求合约提交一个外部调用。


然而,这些外部调用可能被攻击者劫持,从而迫使合约执行进一步的代码(例如 通过一个fallback函数),包括回调自己。因此,这就等于代码执行「重新进入了」合约。DAO攻击就是这样发生的。


坑点分析


「重新入口」这种攻击可能发生在合约将以太币发送到一个未知地址的时候。攻击者可以在外部地址上仔细构造一个合约,而在外部地址的fallback函数中包含恶意代码。因此,当一个合约将以太币发送到这个地址时,它将调用恶意代码。


一般来说,恶意代码会执行一个有漏洞的合约函数。「重新入口」这个名称源于这样一个事实,即外部恶意合约要求回调一个关于有漏洞的合约函数,并在漏洞合约的任意点「重新进入」并执行代码。


为了更好的理解,我们举个例子,一份有「重新进入」漏洞的合约,就像一个金库,允许储户每周只能提取1个以太币。

 

640?wx_fmt=png

代码1


这份合约有两个公有函数:depositFunds() 和 withdrawFunds()。depositFunds()只是简单地增加消费者的余额;而withdrawFunds()则允许发送者取回指定的金额。看上去,这两个函数只在提取钱数少于1个以太币,并且上个星期没有提取的情况下才能成功。但真的是这样吗?


当我们向用户发送他们所要求数量的以太币时,漏洞就在上面代码的第17行出现了。恶意攻击者很可能这样操作:

 

640?wx_fmt=png

代码2


从上面代码可以看出,攻击者将创建上述合约(比如在地址0x0... 123),并以EtherStore的合约地址作为构造函数的参数。这将初始化公有变量 etherStore,并将它指向想要攻击的合约。


然后,攻击者会使用一定量的以太币(大于或等于1,这里我们假设为1)来调用pwnEtherStore()函数。假如这个时候许多其他用户已经将以太币存到了这个合约中,这样当前的结余就是10个以太币。


接下来会发生以下情况。


  1. 代码1第15行:以1个以太币(和大量gas)的msg.value来调用Etherstore合约的depositFunds()函数。发送者(msg.sender)即是恶意合约传递地址(0x0... 123)。因此,balances[0x0…123] = 1 ether 。

  2. 代码2第17行:恶意合约接着将以1以太币为参数调用EtherStore合约中的withdrawFunds()函数,就可以顺利执行(在EtherStore合约的第12到16行),因为之前没有提款动作。 

  3. 代码1第17行:此时,EtherStore合约会把1以太币送回到恶意合约。 

  4. 代码2第25行:发送到恶意合约的以太币将执行fallback函数。 

  5. 代码2第26行:EtherStore合约的总余额从10以太币变为9以太币,因此if语句通过。 

  6. 代码2第27行:出让函数再次调用EtherStore合约的withdrawFunds()函数,并重新进入了EtherStore合约。 

  7. 代码1第11行:在第二次调用withdrawFunds()函数中,由于第18行还没有执行完,所以余额仍然是1以太币。因此,balances[0x0..123] = 1 ether 。这也是lastWithdrawTime变量的一个用例,同样满足Etherstore合约的所有要求。 

  8. 代码1第17行,我们再次收回了1以太币。 

  9. 重复步骤4-8,直到代码2的第26行所示的EtherStore.balance >= 1。 

  10. 代码2第26行,一旦EtherStore合约中剩下的以太币少于1个(或更少),if语句就会失败,代码1的第18、19行将会执行(对于每次调用withdrawFunds()函数)。 

  11. 代码1第18、19行的balances和 lastWithdrawTime将会被设定,代码运行结束。


最后的结果就是,攻击者仅通过一次交易就从EtherStore合约中提取了所有以太币(只留下不多于1个)。


避坑技巧


很多方法都可以帮助避免智能合约中潜在的重新入口漏洞。


第一种方法是,当发送以太币到外部合约时,使用内置的transfer()函数。Transfer()函数只发送2300个gas,这不足以使目的地址/合约调用另一个合约(例如,重新进入发送中的合约)。


第二个方法是,在以太币被从合约(或任何外部调用)发送出去之前,确保所有改变状态变量的逻辑发生。在上述的例子中,代码1的第18、19行应该放在第17行之前。将执行外部调用的任何代码作为本地化函数或代码执行的最后一个操作,并将执行外部调用的代码置于未知地址上。这就是所谓的「检查-效应-交互」模式。


第三个方法是,引入一个互斥系统。也就是说,添加一个状态变量,该状态变量在代码执行期间锁定合约,从而防止重新入口的调用。


针对这三种方法,我们可以对代码1进行修正,效果如下:


640?wx_fmt=png


 

真实案例: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中减去一个数会得到一个较大的值)。


这些类型的漏洞允许攻击者滥用代码并创建一些意想不到的逻辑流。例如下面这样的实践锁定。

 

640?wx_fmt=png

代码3


这份合约被设计成一个时间保险柜,用户可以将以太币存入合约,并将其锁定至少一个星期,如果选择延长,则可以再延长1个星期。也就是说,一旦存放,就意味着用户的以太币至少要在这里存放一个星期。但这样做安全吗?


如果一个用户被迫交出了他们的私钥,上面的代码可能可以保证短时间内无法以太币无法被盗走。如果一个用户在这份合约中锁定了100个以太币,并将他们的私钥交给了攻击者,攻击者就可以使用溢出的方式来获取以太币,而不考虑时间。


那么他们是怎么做的呢?攻击者现在掌握着(它是一个公共变量)用户的私钥,可以确定当前地址的lockTime,我们可以称之为userLockTime。然后,他们可以调用increaseLockTime函数,并将数字2 ^ 256-userLockTime作为参数传递。这个数字将被添加到当前的userLockTime,并导致溢出,将lockTime[msg.sender]重置为0。攻击者可以简单地调用withdraw函数来获得用户的资金。 


让我们看看另一个例子,这个例子来自Ethernaut Challanges。

 

640?wx_fmt=png

代码4


这是一个简单的代币合约,它使用了一个transfer()函数,允许参与者移动他们的代币。你能看出这份合约中的问题吗?


首先是transfer()函数,在第13行的语句可以通过一个流程来绕过。假设一个没有余额的用户。他们可以通过任何非零的_value来调用transfer()函数,并传递给第13行的语句。


这是因为balances[msg.sender]为0(以及一个uint256),因此减去任何正数(不包括2 ^ 256)都将导致结果为正数,就像我们上面所描述的那样。对于第14行来说,这也是正确的,在这里,我们的余额将会成为一个正数。因此,在这个例子中,由于下溢漏洞,我们就盗取了代币。


避坑技巧


防止溢出/下溢漏洞的常规方法是,使用或构建数学库来替代标准的数学运算符,包括加法、减法和乘法(没有除法,因为它不会导致溢出/下溢)。


OppenZepplin在构建和审核安全库方面做了大量的工作,以太坊社区可以充分利用这些库。为了演示在Solidity中如何使用这些库,让我们用Zepplin开源的SafeMath库来修正代码3的合约

   

640?wx_fmt=png

640?wx_fmt=png

 


值得注意的是,所有标准的数学操作都被SafeMath库中定义的数学操作所取代。 代码3的合约不再执行任何能够发生溢出/下溢的操作。


真实案例:PoWHCBatch 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计算的。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值