很多以太坊的智能合约控制着有实际价值的数字资产。因此,保证合约没有安全漏洞是十分重要的事情。这几期为大家带来一篇 2017 年对以太坊合约攻击调研的文献,来帮助大家避免以太坊智能合约设计中的一些可能导致安全性问题的弱点。在这里,你也可以看到,导致以太坊分叉的著名事件 The DAO 攻击,其原理是什么。
这篇文献分两部分,第一部分介绍了一些如果对 Solidity 语言和智能合约不当使用会导致问题的弱点;第二部分则用一些实例展示了这些弱点可能会导致怎样的问题。
我们今天还推送了另一篇文章作为背景资料,为不熟悉智能合约和 Solidity 语言的读者介绍一些背景内容。
不当使用会导致问题的点
合约内函数调用
在使用 Solidity 编写智能合约时,可以调用其他合约中的函数。假设 Alice 合约里有一个 ping(uint) 函数, c 是一个 Alice 合约在以太坊上的地址。如果其他合约(或 Alice 合约自身)想要以参数 42 调用 ping 函数,有三种方式:
第一种
call 调用:通过合约地址,合约函数,函数签名和调用参数进行调用。如果被调用函数中有修改合约变量的代码,将修改被调用合约中相应的变量。
第二种
delegatecall 与 call 类似,区别是 delegatecall 执行时,仅仅使用被调用函数的代码,而代码中如果涉及到合约变量的修改,则是修改调用者合约中的变量。如果被调用的函数中有 d.send(amount)的指令,表示向地址 d 转一定数额的以太币,在 call 模式下这笔钱从被调用合约的余额中转出。在 delegatecall 模式下将从调用者合约的余额中转出。
因此, delegatecall 是更危险的命令,如果这一命令加载的函数代码是合约编写者不可控的,可能会导致合约的钱被转走或合约被销毁等严重后果。
第三种
这第三种调用方式在论文中被称为直接调用 (direct call). 它先在合约里声明了 Alice 合约需要调用的函数,然后调用它。这种方式与以上两种方式在异常处理上会有区别。
需要注意的是,以上三种方式如果将函数名或者参数类型设置错误,则会调用回退函数 (fallback function). 如果是因为笔误打错了内容,可能会触发本不该执行的回退函数中的代码。
Gasless Send
在 Solidity 中,如果变量 rec 的类型为 address, 那么 rec.send(amount) 表示由合约向地址 rec 转账数额为 amount 的 wei. (10^18 wei = 1 ether ) 在这个执行的过程中,还会触发地址 rec 的回退函数。如果回退函数执行过程中消耗的 gas 大于 2300,则会触发一个异常,导致转账失败。
异常处理
在背景介绍中我们提到过,使用 Solidity 执行智能合约时会抛出异常,但是不同的合约内函数调用方式对异常(exception)的处理方式不一样。
如果合约执行过程中没有函数调用,或者只有 direct call 直接调用,那么当触发一个异常的时候,视为合约执行失败,直接停止合约的执行,回滚执行过程中的转账和对合约变量的修改等操作,并扣除全部的交易费用。
如果通过 call, delegatecall 或 send 调用其他合约函数,在执行期间触发的异常不会影响原有函数。也就是说,如果在执行 send 的触发的回退函数过程中,如果 gas 不足引起了异常,转账会失败,但是原有合约会被成功地执行。
如果对这一点缺乏足够的理解,错误地认为合约执行成功意味着 call 调用也一定成功,错误地认为没有触发异常就意味着 ether 转账成功,就可能导致合约有安全性问题。正确的做法应当是通过函数调用返回的结果判断其执行是否成功。而一些研究表明有 28% 的合约没有检查返回结果。(当然,这不意味着一定有安全问题)
重入问题
Solidity 中回调函数的机制,可能会让合约调用其他函数后,被调用的函数又调用了调用者合约的函数,造成循环,下面是一个例子
假设区块链上已经如下的合约 Bob,如果 sent 变量为 false, 就向给定地址发送一笔钱。
而 Mallory 是攻击者恶意构造的合约,代码如下所示。
Bob 合约设计的本意是,如果 sent 变量为 false, 就向给定地址发送一笔钱。然而,当这笔钱发往攻击者合约时,会触发攻击者合约的回退函数,回退函数再次调用 ping 函数,如此无限循环,直到交易费耗尽或调用深度达到上限 1024 次触发异常。但之前提到了,对于 call 调用的函数在执行过程中触发的异常,不会影响原来的函数的成功执行。也就是说,除了最后一步转账会失败,之前的转账都会成功。
几种攻击
接下来,我们介绍几种利用上面提到弱点的攻击例子。
DAO 攻击
DAO 攻击是以太坊历史上最著名的攻击,盗走了价值 6000 万美元的以太币。以太坊社区通过强行回滚硬分叉了以太坊,导致了以太坊和以太经典两条分叉链并存的局面。
下面是一个简化版的 DAO 智能合约,但足以描述 DAO 合约的漏洞。
这个合约的功能很简单,任何人可以向指定地址捐献以太币,受捐赠人可以提走自己受捐赠的币。
而攻击者通过以下的合约,就可以大量转走合约中的币。
其原理与上文所说的重入问题完全一样, SimpleDAO 合约的 withdraw 函数执行时向攻击者合约转账,转账会触发攻击者合约的回退函数,攻击者合约的回退函数会重新调用 SimpleDAO 合约的 withdraw 函数,形成一个循环。当循环因为各种原因结束的时候,除了最后一步,之前的执行都不会失败。攻击者转出了大量的钱。
另外,这个合约没有考虑整数溢出问题,因此有如下攻击成本更低的方案
在这个合约中,攻击者设计了一个函数 attack, 当这个函数被执行的时候,攻击合约先给自己捐赠 1 wei, 然后把这 1 wei 取出来。在取钱的时候,会触发攻击者合约的回退函数。与之前的攻击不同,这次我们只利用重入问题 1 次,也就是 withdraw 函数被执行了两遍。在 withdraw 第二次向攻击者转账以后,攻击者不再调用 withdraw.
于是 withdraw 函数中的转账操作 msg.sender.call.value(amount)() 发生了2次,自然地,它的下一行也会被调用 2 次。这两次被调用将 credit[攻击者地址] 变成了 -1 wei, 会被虚拟机解读为 2^256-1 wei. 这时,攻击者可以从中取出几乎无限多的钱出来。
特别的是,即使 withdraw 函数在转账后检查 send 执行是否成功,也只能防范第一种攻击。
以太王座
考虑下面一个游戏合约,在游戏中,大家将竞争一个王座。后来者可以通过向王座上的人支付一笔钱来取而代之,每一轮取得王座需要的钱都要比上一轮高。最后取得王座的人有额外的收益。(没有在合约中体现。)
这个合约看上去没什么问题。事实上,他人在向王座上的人(地址)支付费用的时候,会触发那个地址(如果是一个合约)的回退函数。如果王座上合约地址的回退函数需要的交易费过高,会触发 gasless send 的问题,就会导致转账失败。但后续变更王座拥有者的代码还会照常执行,新来者可以毫无成本地获得王座。
修改这一问题的思路看上去很简单,只要将转账的代码 king.send(compensation) 变成 if(!king.call.value(compensation)())throw; 来判断一下转账是否成功就可以了。然而这会导致另一个问题。王座上的地址(合约)将自己的回退函数设定成一定会触发异常,例如 function(){throw;},就没有人有能力将他从王座上赶下去了,因为所有转账的结果都会失败。
以上就是这一期的内容,在接下来的文章中,我们将会介绍文献中提到的其他的 Solidity 的弱点与可能导致的问题。
参考文献:
[1] Atzei, Nicola, Massimo Bartoletti, and Tiziana Cimoli. “A survey of attacks on ethereum smart contracts (sok).” Principles of Security and Trust. Springer, Berlin, Heidelberg, 2017. 164-186.
Conflux 是致力于打造下一代高性能的 DAPP 公链平台
欢迎关注我们的微信公众号:Conflux中文社区(Conflux-Chain)
添加微信群管理员 Confluxgroup 回复“加群”加入 Conflux官方交流群