写在前面
上一篇 Vyper教程 使用Truffle框架开发Vyper
我们已经学会如何同Remix在线编译和部署Vyper合约,学会了用truffle框架编译和本地部署测试Vyper合约,接下来我们准备写一些更复杂的合约和Dapp。但在此之前我们必须了解一些最基础的编写智能合约的安全性问题。
尽管Vyper语言最主要的特性就是安全,但不意味着你可以随便写代码也不会出现问题。
编程风格
在智能合约编程领域,大多数应用都跟金融、金钱相关。一旦你的合约部署到区块链,就不能再更改。这意味着你无法像其他部署到自己的服务器上的应用一样,发现问题时可以随时关闭服务。因此一旦你的合约存在漏洞,很可能会让你遭受极大的损失。所以遵循最安全的编程风格并使用经过良好测试的设计模式至关重要。
防御性编程 Defensive programming 是一种编程风格,特别适用于智能合约编程,具有以下特点:
- 简单
代码越简单,代码越少,发生错误或无法预料的效果的可能性就越小。如果你的项目产生了“数千行代码”,那么你应该质疑该项目的安全性。记住越简单越安全。 - 重用
尽可能不要“重新发明轮子”。如果库或合约已经存在,可以满足你的大部分需求,请直接使用它。如果你试图通过从头开始构建来改进某个功能,安全风险通常大于改进的效果。
如果你的代码片段重复多次,请其作为函数或库进行编写并使用。 - 可读性
你的代码应易于理解和清晰。阅读越容易,审计越容易。你可以将你的项目代码开源,通过开源社区或组织和你一起完成你的项目。编写好文档、遵循以太坊社区的各种编程约定。 - 全面测试
测试你可以测试的所有内容。绝不应该假定输入(比如函数参数)是正确的。一定要提防恶意的输入或者调用。 - 代码质量
你不能像通用编程一样对待智能合约编程。一旦你的合约部署到区块链,你就无法再“修复”它的bug。代码每个错误都可能导致经济损失。
当然我们有一些曲线救国的方式来更新你的应用(或者修复bug),后面的教程中会讲到。但是这些方法基本不可能无法挽回你已经损失的金钱。你不应该把它作为你不重视代码质量的理由。
安全漏洞
重入攻击
智能合约最常见的安全漏洞就是重入 Re-entrancy。这个漏洞因其与DAO攻击的相关性而特别出名。
来看看之前我们写的合约wallet.vy中的提款函数:
@public
def withdraw(_amount: wei_value):
assert _amount <= self.value
self.value -= _amount
send(msg.sender, _amount)
改进一下我们的合约,用一个map记录每个地址拥有的以太坊的数量values: map(address, wei_value)
来代替value变量。这样我们合约可以同时为多个用户提供存款服务。那我们的取款函数可能变成这样:
@public
def withdraw(_amount: wei_value):
assert _amount <= self.values[msg.sender]
self.values[msg.sender] -= _amount
send(msg.sender, _amount)
针对上面的代码,如果将“从账号中减去提款数量”和“发送以太坊”两个步骤交换一下顺序,就会有重入漏洞!
# 错误的示例!
@public
def withdraw(_amount: wei_value):
assert _amount <= self.values[msg.sender]
send(msg.sender, _amount)
self.values[msg.sender] -= _amount
通常我们在编写其他应用程序时会有上述代码逻辑:
判断账户是否有足够余额->转账->从账户中减去提款的金额。这个逻辑看似没有问题,实际上,如果调用这个withdraw函数的地址不是个人账户地址而是一个合约地址 X。那么send(msg.sender, _amount)
这个操作会调用那个合约地址 X的接收以太坊(@payable)的函数。如果有攻击者在这个X合约 的@payable函数中再次调用我们合约的withdraw函数,注意此时我们还没有执行self.values[msg.sender] -= _amount
,也就是他的余额还没有减去刚刚的提款金额,这意味着他可以“躲避余额判断”再次进行提款。只要有gas可以继续运行,就可以一直重复这个循环。当X检测到gas量不足时,它可以在payable函数中停止调用我们的withdraw函数。此时我们的合约会从X的余额中扣除_amount。 然而,这时,X可能已经执行了数百次提款,并且只扣除了一次余额。我们的合约就被X洗劫一空了!
如何避免
为了防止重入攻击,最好的编程习惯是使用_Checks-Effects-Interactions_模式,也就是在进行函数调用之前先执行函数调用的影响(例如减少余额)。就像我们一开始写的代码一样。
交易的所有影响都是原子的,要么都发生,要么抛出异常。这意味着更新了余额但是没有给用户转账的情况是不可能的。
Vyper还提供了@nonreentrant(<unique_key>)装饰器。它将锁定当前函数和所有具有相同<unique_key>值的函数。外部合同尝试回调这些功能中的任何一个都将导致REVERT调用。
这样我们可以保证多个函数同时都不会被回调进行重入攻击。
通用模式
用assert限制
限制意想不到的输入参数
攻击者会传入很多恶意的参数,所以请用assert
来判断参数是否正确,比如提款金额是否小于等于拥有的余额。
限制地址
有时我们希望某些功能只能特定的地址调用,比如销毁合约的函数只能由合约的拥有者调用。
首先我们可以用一个owner变量存储合约拥有者的地址,在合约初始化的函数中将msg.sender赋值给owner变量。(部署合约时执行初始化函数,此时msg.sender就是部署合约的地址)
之后在销毁合约的函数中进行判断assert msg.sender == owner
调用者是否是合约的拥有者。
从合约中提款
所有发放资金的方式都推荐“提款”
有时我们需要向很多地址转账,比如一个众筹合约,筹集到的金额未达到最低限制,则需要将收到的款退回到原来的账户中。此时不要用一个循环,对每一个地址进行‘send’操作来退款。因为攻击者可以在接收资金的函数中做手脚,比如调用 revert() 直接导致失败。这样我们的合约将永远卡在这里,无法向其他正常的用户退款了。
如果在合约中像上面的例子一样使用“提款(withdraw)”模式,那么攻击者只能使他/她自己的“提款”失败,并不会导致整个合约无法运作。
写在最后
现在我们已经初步了解了一些智能合约安全性的知识,让我们开始编写新的合约吧!
下一篇