精通以太坊:智能合约安全
精通以太坊:智能合约安全
本文内容是对 Mastering Ethereum 一书的 Smart Contract Security 章节的翻译。
编写智能合约时,安全性是最重要的考虑因素之一。在智能合约编程领域,错误代价高昂且容易被利用。在本章中,我们将研究安全最佳实践和设计模式,以及“安全反模式(security antipatterns)”,它们是可能在我们的智能合约中引入漏洞的实践和模式。
与其他程序一样,智能合约将准确执行所写的内容,这并不总是程序员的意图。此外,所有智能合约都是公开的,任何用户都可以通过创建交易与它们进行交互。任何漏洞都可以被利用,损失几乎总是无法挽回。因此,遵循最佳实践并使用经过充分测试的设计模式至关重要。
安全最佳实践
防御性编程(Defensive programming)是一种特别适合智能合约的编程风格。它强调以下几点,所有这些都是最佳实践:
极简主义/简单
复杂性是安全的敌人。代码越简单,代码越少,出现错误或无法预料的影响的可能性就越低。在第一次从事智能合约编程时,开发人员通常会尝试编写大量代码。相反,您应该仔细查看您的智能合约代码,并尝试找到减少代码行数、复杂性和“功能”的方法。如果有人告诉你他们的项目已经为他们的智能合约生成了“数千行代码”,你应该质疑那个项目的安全性。更简单更安全。
代码重用
尽量不要重新发明轮子。如果已经存在可以满足您大部分需求的库或合约,请重用它。在您自己的代码中,遵循 DRY 原则:不要重复自己(Don’t Repeat Yourself)。如果您看到任何代码片段重复多次,请问问自己是否可以将其编写为函数或库并重用。已被广泛使用和测试的代码可能比您编写的任何新代码都更安全。谨防“此处未发明(No Invented Here)”综合症,在这种情况下,您很想通过从头开始构建功能或组件来“改进”它。安全风险往往大于改进价值。
代码质量
智能合约代码是无情的。每个错误都可能导致金钱损失。您不应该将智能合约编程视为通用编程。使用 Solidity 编写 DApp 与使用 JavaScript 创建 Web 小部件不同。相反,您应该应用严格的工程和软件开发方法,就像在航空工程或任何类似的无情学科(unforgiving discipline)中一样。一旦你“启动”你的代码,你几乎无法解决任何问题。
可读性/可审计性
您的代码应该清晰易懂。越容易阅读,就越容易审计。智能合约是公开的,因为每个人都可以读取字节码并且任何人都可以对其进行逆向工程。因此,使用协作和开源方法在公共场合开发您的工作是有益的,以利用开发人员社区的集体智慧并从开源开发的最高公分母中受益。你应该编写有据可查且易于阅读的代码,遵循以太坊社区的风格和命名约定。
测试覆盖率
尽可能测试一切。智能合约在公共执行环境中运行,任何人都可以使用他们想要的任何输入来执行它们。您永远不应假设输入(例如函数参数)格式正确、有适当界限或具有良性目的。测试所有参数以确保它们在预期范围内并且格式正确,然后才能继续执行代码。
安全风险和反模式
作为智能合约程序员,您应该熟悉最常见的安全风险,以便能够检测和避免使您的合约暴露于这些风险的编程模式。在接下来的几节中,我们将研究不同的安全风险、漏洞如何出现的示例,以及可用于解决这些问题的对策或预防性解决方案。
重入 (Reentrancy)
以太坊智能合约的特点之一是它们能够调用和利用来自其他外部合约的代码。合约通常还处理以太币,因此经常将以太币发送到各种外部用户地址。这些操作需要合约提交外部调用。这些外部调用可能被攻击者劫持,攻击者可以强制合约执行进一步的代码(通过 fallback 函数),包括对自身的调用。这种攻击被用于臭名昭著的 DAO 攻击。
有关重入攻击的进一步阅读,请参阅 Gus Guimareas 关于该主题的博客文章和以太坊智能合约最佳实践。
漏洞
当合约将以太币发送到未知地址时,可能会发生这种类型的攻击。攻击者可以在 fallback 函数中小心地在包含恶意代码的外部地址构造合约。因此,当合约将以太币发送到该地址时,它将调用恶意代码。通常,恶意代码在易受攻击的合约上执行一个函数,执行开发人员不期望的操作。 “重入(reentrancy)”一词来自于外部恶意合约调用易受攻击合约上的函数并且代码执行的路径“重新进入”它。
为了理清这一点,请考虑 EtherStore.sol 中的简单易受攻击合约,它充当以太坊保险库,允许储户每周仅提取 1 个以太币。
示例1. EtherStore.sol
该合约有两个公共函数,depositFunds
和 withdrawFunds
。 depositFunds
函数只是增加发送者的余额。 withdrawFunds
函数允许发送者指定要提取的 wei 数量。此功能仅在请求的提款金额小于 1 ether 且上周未发生提款时才会成功。
该漏洞位于第 17 行,合约向用户发送他们请求的以太币数量。考虑一个在 Attack.sol 中创建合约的攻击者。
示例2. Attack.sol
漏洞利用如何发生?首先,攻击者会使用 EtherStore
的合约地址作为唯一的构造函数参数来创建恶意合约(假设在地址 0x0...123
)。这将初始化公共变量 etherStore
并将其指向要攻击的合约。
然后攻击者将调用 attackEtherStore
函数,其中一定数量的以太大于或等于1——让我们暂时假设 1 ether
。在此示例中,我们还将假设许多其他用户已将以太币存入该合约,因此其当前余额为 10 ether
。然后会出现以下情况:
- Attack.sol,第 15 行:
EtherStore
合约的depositFunds
函数将被调用,msg.value
为1 ether
(以及大量gas)。发送者(msg.sender
)将是恶意合约(0x0…123
)。因此,balance[0x0..123] = 1 ether
。 - Attack.sol,第 17 行:恶意合约随后将调用参数为
1 ether
的EtherStore
合约的withdrawFunds
函数。这将通过所有要求(EtherStore
合约的第 12-16 行),因为之前没有提款。 - EtherStore.sol,第 17 行:合约会将
1 ether
发送回恶意合约。 - Attack.sol,第 25 行:支付给恶意合约的款项将执行 fallback 函数。
- Attack.sol,第 26 行:EtherStore 合约的总余额是
10 ether
,现在是9 ether
,所以这个 if 语句通过了。 - Attack.sol,第 27 行:回退函数再次调用
EtherStore
的withdrawFunds
函数并“重新进入”EtherStore
合约。 - EtherStore.sol,第 11 行:在第二次调用
withdrawFunds
时,攻击合约的余额仍然是1 ether
,因为第 18 行尚未执行。因此,我们仍然有balances[0x0..123] = 1 ether
。lastWithdrawTime
变量也是如此。同样,我们通过了所有要求。 - EtherStore.sol,第 17 行:攻击合约提取另外
1 ether
。 - 重复步骤 4-8,直到
EtherStore.balance > 1
不再出现,如 Attack.sol 中的第 26 行所示。 - Attack.sol,第 26 行:一旦
EtherStore
合约中剩下 1 个(或更少)以太币,这个if
语句就会失败。然后,这将允许执行EtherStore
合约的第 18 行和第 19 行(每次调用withdrawFunds
函数)。 - EtherStore.sol,第 18 行和第 19 行:
balances
和lastWithdrawTime
映射将被设置,执行将结束。
最终结果是攻击者在一次交易中从 EtherStore
合约中提取了除 1 个以太币之外的所有以太币。
预防技术
有许多常用技术可以帮助避免智能合约中潜在的重入漏洞。第一个是(尽可能)在向外部合约发送以太币时使用内置的transfer函数。transfer 函数只发送 2300 gas 与外部调用,这不足以让目标地址/合约调用另一个合约(即重新进入发送合约)。
第二种技术是确保所有更改状态变量的逻辑都发生在以太被发送出合约(或任何外部调用)之前。在 EtherStore
示例中,应将 EtherStore.sol 的第 18 行和第 19 行放在第 17 行之前。对于对未知地址执行外部调用的任何代码,最好将其作为本地化函数或代码执行中的最后一个操作。这被称为checks-effects-interactions pattern。
第三种技术是引入互斥锁(mutex)——即添加一个状态变量,在代码执行期间锁定合约,防止重入调用。
现实世界的例子:The DAO
The DAO(去中心化自治组织)攻击是以太坊早期开发中发生的主要黑客攻击之一。当时,合约金额超过 1.5 亿美元。重入在攻击中发挥了重要作用,最终导致了创建以太坊经典(ETC)的硬分叉。有关 DAO 漏洞利用的良好分析,请参阅 Analysis of the DAO exploit (hackingdistributed.com)。有关以太坊分叉历史、DAO 黑客时间表以及硬分叉中 ETC 诞生的更多信息,请参见 ethereum_standards。
算术上溢/下溢 (Arithmetic Over/Underflows)
以太坊虚拟机为整数指定固定大小的数据类型。这意味着一个整数变量只能表示一定范围的数字。例如,一个 uint8
只能存储 [0,255] 范围内的数字。尝试将 256
存储到 uint8
将导致 0
。如果不小心,如果未检查用户输入并且执行的计算导致数字超出存储它们的数据类型范围,那么 Solidity 中的变量就会被利用。
有关算术上溢/下溢的进一步阅读,请参阅“How to Secure Your Smart Contracts”、Ethereum Smart Contract Best Practices 和“Ethereum, Solidity and integer overflows: programming blockchains like 1970”。
漏洞
当执行需要固定大小的变量来存储超出变量数据类型范围的数字(或数据段)的操作时,会发生上溢/下溢。
例如,从值为 0
的 uint8
(8 位无符号整数;即非负数)变量中减去 1
将得到数字 255
。这是下溢(underflow)。我们已经分配了一个低于 uint8
范围的数字,因此结果会环绕(wraps around)并给出 uint8
可以存储的最大数字。同样,将 2^8=256
加到一个 uint8
变量将使变量保持不变,因为我们已经环绕了 uint
的整个长度。这种行为的两个简单类比是汽车中的里程表,它测量行驶距离(在超过最大数字,即 999999 之后,它们重置为 000000)和周期性数学函数(在 sin 的参数中添加 2π 保持值不变) .
加上大于数据类型范围的数字称为上溢(overflow)。为清楚起见,将 257
添加到当前值为 0
的 uint8
将导致数字 1
。有时将固定大小的变量视为循环变量是有启发性的,如果我们将数字添加到最大值之上,我们将从零重新开始可能存储的数字,如果我们从零减去,则从最大数字开始倒数。在有符号的 int
类型可以表示负数的情况下,一旦达到最大的负值,我们就会重新开始;例如,如果我们尝试从值为 -128
的 int8
中减去 1
,我们将得到 127
。
这些类型的数字陷阱允许攻击者滥用代码并创建意外的逻辑流。例如,考虑 TimeLock.sol 中的 TimeLock 合约。
示例3. TimeLock.sol
这份合约的设计就像一个时间保险库:用户可以将以太币存入合约,并将被锁定至少一周。如果他们愿意,用户可以将等待时间延长到 1 周以上,但一旦存入,用户可以确保他们的以太币被安全锁定至少一周。
如果用户被迫交出他们的私钥,这样的合约可能会很方便地确保他们的以太币在短时间内无法获得。但是,如果用户在该合约中锁定了 100 ether
并将其密钥交给攻击者,则攻击者可以使用溢出来接收以太币,而不管锁定时间如何。
攻击者可以确定他们现在持有密钥的地址的当前 lockTime
(它是一个公共变量)。我们称之为 userLockTime
。然后他们可以调用 increaseLockTime
函数并将数字 2^256 - userLockTime
作为参数传递。这个数字将被添加到当前 userLockTime
并导致溢出,将 lockTime[msg.sender]
重置为 0
。然后攻击者可以简单地调用 withdraw
函数来获得他们的奖励。
让我们看另一个示例(Underflow vulnerability example from Ethernaut challenge),这来自 Ethernaut challenges。
剧透警告:如果您还没有完成 Ethernaut challenges,这将提供其中一个级别的解决方案。
示例4. Underflow vulnerability example from Ethernaut challenge
这是一个使用转移功能的简单代币合约,允许参与者移动他们的代币。你能看出这份合约的错误吗?
缺陷出现在 transfer
函数中。可以使用下溢绕过第 13 行的 require 语句。考虑一个余额为零的用户。他们可以使用任何非零 _value
调用 transfer
函数并在第 13 行传递 require 语句。这是因为 balances[msg.sender]
为 0(和 uint256
),因此减去任何正数(不包括 2^256
)将导致一个正数,如前所述。第 14 行也是如此,其中余额将记为正数。因此,在此示例中,攻击者可以通过下溢漏洞获得免费代币。
预防技术
当前防止下溢/下溢漏洞的常规技术是使用或构建数学库来替换标准数学运算符加法、减法和乘法(除法被排除在外,因为它不会导致上溢/下溢,并且 EVM 在除以 0 时 revert)。
OpenZeppelin 在为以太坊社区构建和审核安全库方面做得非常出色。特别是,它的 SafeMath 库可用于避免下溢/上溢漏洞。
为了演示如何在 Solidity 中使用这些库,让我们使用 SafeMath 库更正 TimeLock 合约。合约的无溢出版本是:
请注意,所有标准数学运算都已替换为 SafeMath
库中定义的运算。 TimeLock
合约不再执行任何能够下溢/溢出的操作。
现实世界的例子:PoWHC 和批量传输溢出(CVE-2018-10299)
弱手币证明 (Proof of Weak Hands Coin, PoWHC) 最初是为了某种玩笑而设计的,是一个由互联网集体编写的庞氏骗局。不幸的是,合约的作者似乎之前没有看到过溢出/下溢,因此 866 以太币从其合约中解放出来。 Eric Banisadr 在他关于该事件的博客文章(How $800k Evaporated from the PoWH Coin Ponzi Scheme Overnight)中很好地概述了下溢是如何发生的(这与前面描述的 Ethernaut challenge 并没有太大的不同)。
另一个例子(EIPs/eip-20)来自在一组 ERC20 代币合约中实现 batchTransfer()
函数。该实现包含溢出漏洞;您可以阅读 PeckShield’s account 中的详细信息。
意外的以太币 (Unexpected Ether)
通常,当以太币被发送到合约时,它必须执行 fallback 函数或合约中定义的另一个函数。有两个例外,以太可以存在于合约中而无需执行任何代码。对于发送给他们的所有以太币,依赖于代码执行的合约可能容易受到强制发送以太币的攻击。
有关这方面的进一步阅读,请参阅“How to Secure Your Smart Contracts”。
漏洞
用于强制执行正确的状态转换或验证操作的常见防御性编程技术是不变检查(invariant checking)。该技术涉及定义一组不变量(invariants)(不应改变的度量或参数)并检查它们在单个(或多个)操作后是否保持不变。如果被检查的不变量是实际上的不变量,这通常是一个好的设计。不变量的一个例子是固定发行的 ERC20 token 的 totalSupply
。由于任何函数都不应修改此不变量,因此可以向 transfer
函数添加一个检查,以确保 totalSupply
保持不变,保证函数按预期工作。
特别是,有一个明显的不变量,它可能很容易使用,但实际上可以被外部用户操纵(不管智能合约中的规则如何)。这是存储在合约中的当前以太币。通常,当开发人员第一次学习 Solidity 时,他们会误认为合约只能通过支付功能接受或获得以太币。这种误解可能导致合约对其中的以太币余额做出错误假设,从而导致一系列漏洞。这个漏洞的确凿证据是 this.balance
的(不正确的)使用。
有两种方法可以(强制)将以太币发送到合约,而无需使用可支付函数或在合约上执行任何代码:
自毁(Self-destruct)
任何合约都可以实现 selfdestruct 函数,该功能从合约地址中删除所有字节码,并将存储在那里的所有以太币发送到参数指定的地址。如果这个指定的地址也是一个合约,则不会调用任何函数(包括 fallback)。因此,selfdestruct
函数可用于强制向任何合约发送以太币,而不管合约中可能存在的任何代码,甚至是没有支付功能的合约。这意味着任何攻击者都可以创建带有 selfdestruct
函数的合约,向其发送以太币,调用 selfdestruct(target)
并强制将以太币发送到目标合约。 Martin Swende 有一篇出色的博客文章(Ethereum quirks and vulns),描述了 self-destruct 操作码的一些怪癖(Quirk #2),以及客户端节点如何检查不正确的不变量,这可能导致以太坊网络发生灾难性的崩溃。
赠送以太币(Pre-sent ether)
将以太币加入合约的另一种方法是用以太币预加载合约地址。合约地址是确定性的——事实上,地址是根据创建合约的地址和创建合约的交易随机数的 Keccak-256(通常与 SHA-3 同义)哈希计算的。具体来说,它的格式为 address = sha3(rlp.encode([account_address,transaction_nonce]))
(有关一些有趣的用例,请参见 Adrian Manning 关于“无密钥以太(Keyless Ether)”的讨论)。这意味着任何人都可以在创建合约之前计算出合约的地址,并将以太币发送到该地址。创建合约时,它将有一个非零的以太币余额。
让我们探讨一下这些知识可能出现的一些陷阱。考虑一下 EtherGame.sol 中过于简单的合约。
示例5. EtherGame.sol
该合约代表一个简单的游戏(自然会涉及竞赛条件),其中玩家向合约发送 0.5 以太币,希望成为首先达到三个里程碑之一的玩家。里程碑以以太币计价。当游戏结束时,第一个达到里程碑的人可能会获得一部分以太币。当达到最后一个里程碑(10 以太币)时,游戏结束;然后用户可以领取他们的奖励。
EtherGame
合约的问题来自于第 14 行(以及第 16 行和第 32 行)对 this.balance
的使用不当。一个恶作剧的攻击者可以通过 selfdestruct
函数强行发送少量的以太币,比如 0.1 以太币(前面讨论过)以防止任何未来的玩家达到里程碑。由于这 0.1 以太币的贡献,this.balance
永远不会是 0.5 以太币的倍数,因为所有合法玩家只能发送 0.5 以太币增量。这可以防止第 18、21 和 24 行的所有 if 条件为真。
更糟糕的是,错过里程碑的报复性攻击者可以强行发送 10 个以太币(或等量的以太币,将合约的余额推高到 finalMileStone
之上),这将永远锁定合约中的所有奖励。这是因为由于第 32 行的要求,claimReward
函数将始终revert(即,因为 this.balance
大于 finalMileStone
)。
预防技术
这种漏洞通常源于对 this.balance
的滥用。合约逻辑应尽可能避免依赖合约余额的确切值,因为它可以被人为操纵。如果应用基于 this.balance
的逻辑,您必须应对意外的余额。
如果需要存入的以太币的准确值,则应使用在payable函数中递增的自定义变量,以安全地跟踪存入的以太币。这个变量不会受到通过 selfdestruct
调用发送的强制以太币的影响。
考虑到这一点,EtherGame
合约的修正版本可能如下所示:
在这里,我们创建了一个新变量,depositedWei
,它跟踪已知的以太坊存款,我们将这个变量用于我们的测试。请注意,我们不再引用 this.balance
。
进一步的例子
Underhanded Solidity Coding Contest 中给出了一些可利用合约的示例,还提供了本节中提出的许多陷阱的扩展示例。
DELEGATECALL
CALL
和 DELEGATECALL
操作码在允许以太坊开发人员模块化他们的代码方面很有用。对合约的标准外部消息调用由 CALL
操作码处理,代码在外部合约/函数的上下文中运行。 DELEGATECALL
操作码几乎相同,只是在目标地址执行的代码是在调用合约的上下文中运行的,并且 msg.sender
和 msg.value
保持不变。此特性支持库(libraries)的实现,允许开发人员一次性部署可重用代码并从未来的合约中调用它。
尽管这两个操作码之间的区别简单直观,但使用 DELEGATECALL
可能会导致意外的代码执行。
如需进一步阅读,请参阅 Loi.Luu 关于此主题的 Ethereum Stack Exchange 问题和 Solidity 文档。
漏洞
由于 DELEGATECALL
的上下文保留特性,构建无漏洞的自定义库并不像人们想象的那么容易。库本身的代码可以是安全且无漏洞的;但是,当在另一个应用程序的上下文中运行时,可能会出现新的漏洞。让我们看一个相当复杂的例子,使用斐波那契数。
考虑 FibonacciLib.sol 中的库,它可以生成 Fibonacci 序列和类似形式的序列。 (注意:此代码是从 web3j/Fibonacci.sol 修改的。)
示例6. FibonacciLib.sol
这个库提供了一个函数,可以生成序列中的第 n 个斐波那契数。它允许用户更改序列的起始编号(start
)并计算此新序列中的第 n 个类似斐波那契的数字。
现在让我们考虑一个使用这个库的合约,显示在 FibonacciBalance.sol 中。
示例7. FibonacciBalance.sol
该合约允许参与者从合约中提取以太币,以太币数量等于参与者提现订单对应的斐波那契数;即,第一个参与者得到 1 个以太币,第二个也得到 1 个,第三个得到 2 个,第四个得到 3 个,第五个得到 5 个,依此类推(直到合约余额小于被提取的斐波那契数)。
本合约中的许多要素可能需要一些解释。首先,有一个看起来很有趣的变量 fibSig
。这包含字符串 "setFibonacci(uint256)"
的 Keccak-256 (SHA-3) 散列的前 4 个字节。这被称为函数选择器,并被放入 calldata
以指定将调用智能合约的哪个函数。它在第 21 行的 delegatecall
函数中用于指定我们希望运行 fibonacci(uint256)
函数。 delegatecall
中的第二个参数是我们传递给函数的参数。其次,我们假设 FibonacciLib
库的地址在构造函数中被正确引用(外部合约引用讨论了与这种合约引用初始化相关的一些潜在漏洞)。
你能发现这个合约中的任何错误吗?如果要部署这个合约,用以太币填充它,然后调用 withdraw
,它可能会revert。
您可能已经注意到状态变量 start
在库和主调用合约中都使用了。在库合约中,start
用于指定斐波那契数列的开始,设置为 0
,而在调用合约中设置为 3
。您可能还注意到 FibonacciBalance
合约中的 fallback
函数允许将所有调用传递给库合约,这允许调用库合约的 setStart
函数。回想一下我们保留了合约的状态,这个函数似乎允许您更改本地 FibonnacciBalance
合约中 start
变量的状态。如果是这样,这将允许一个人提取更多的以太币,因为结果计算的 FibNumber
取决于 start
变量(如库合约中所示)。实际上,setStart
函数不会(也不能)修改 FibonacciBalance
合约中的 start
变量。该合约的潜在漏洞比仅仅修改 start
变量要严重得多。
在讨论实际问题之前,让我们快速了解一下状态变量是如何实际存储在合约中的。状态或存储变量(在单个交易中持续存在的变量)在合约中引入时按顺序放入槽(slots)中。(这里有一些复杂性;请查阅 Solidity 文档以获得更透彻的理解。)
作为一个例子,让我们看一下库合约。它有两个状态变量,start
和 computedFibNumber
。第一个变量 start
存储在合约存储的 slot[0]
(即第一个 slot)中。第二个变量 calculatedFibNumber
被放置在下一个可用的存储槽 slot[1]
中。函数 setStart
接受一个输入并将 start
设置为输入的任何内容。因此,此函数将 slot[0]
设置为我们在 setStart
函数中提供的任何输入。类似地,setFibonacci
函数将计算的 FibNumber
设置为 fibonacci(n)
的结果。同样,这只是将 storage slot[1]
设置为 fibonacci(n)
的值。
现在让我们看看 FibonacciBalance
合约。存储 slot[0]
现在对应 fibonacciLibrary
地址,slot[1]
对应计算的 FibNumber
。漏洞正是在这个不正确的映射中发生的。 delegatecall 保留合约上下文(preserves contract context)。这意味着通过 delegatecall 调用执行的代码将作用于调用合约的状态(如存储)。
现在请注意,在第 21 行的 withdraw
中,我们执行了 fibonacciLibrary.delegatecall(fibSig,withdrawalCounter)
。这调用了 setFibonacci
函数,正如我们所讨论的,它修改了存储 slot[1]
,在我们当前的上下文中是 calculatedFibNumber
。这是预期的(即执行后,计算的 FibNumber
被修改)。但是,回想一下 FibonacciLib
合约中的 start
变量位于 storage slot[0]
中,即当前合约中的 fibonacciLibrary
地址。这意味着函数 fibonacci
将给出意想不到的结果。这是因为它引用了 start
(slot[0]
),它在当前调用上下文中是 fibonacciLibrary
地址(当解释为 uint
时,它通常会非常大)。因此,withdraw
函数很可能会 revert,因为它不会包含 uint(fibonacciLibrary)
数量的以太币,而这是 calculatedFibNumber
将返回的。
更糟糕的是,FibonacciBalance
合约允许用户通过第 26 行的 fallback 函数调用所有 fibonacciLibrary
函数。正如我们之前讨论的,这包括 setStart
函数。我们讨论过这个函数允许任何人修改或设置 storage slot[0]
。在这种情况下,storage slot[0]
是 fibonacciLibrary
地址。因此,攻击者可以创建恶意合约,将地址转换为 uint
(这可以在 Python 中使用 int('<address>',16)
轻松完成),然后调用 setStart(<attack_contract_address_as_uint>)
。这会将 fibonacciLibrary
更改为攻击合约的地址。然后,每当用户调用 withdraw
或 fallback 函数时,恶意合约就会运行(这会窃取合约的全部余额),因为我们已经修改了 fibonacciLibrary
的实际地址。这种攻击合约的一个例子是:
请注意,此攻击合约通过更改 storage slot[1]
来修改 calculatedFibNumber
。原则上,攻击者可以修改他们选择的任何其他 storage slots,以对该合约执行各种攻击。我们鼓励您将这些合约放入 Remix 中,并通过这些 delegatecall
函数尝试不同的攻击合约和状态更改。
同样重要的是要注意,当我们说 delegatecall 是状态保留时,我们不是在谈论合约的变量名称,而是这些名称指向的实际 storage slot。从这个例子可以看出,一个简单的错误可能导致攻击者劫持整个合约及其以太币。
预防技术
Solidity 为实现库合约提供了 library
关键字(有关详细信息,请参阅文档)。这确保了库合约是无状态且不可自毁的。强制库无状态可以减轻本节中展示的存储上下文的复杂性。无状态库还可以防止攻击者直接修改库的状态以影响依赖于库代码的合约。作为一般经验法则,在使用 DELEGATECALL
时,请仔细注意库合约和调用合约的可能的调用上下文,并尽可能构建无状态库。
真实世界的例子:Parity Multisig Wallet (Second Hack)
Second Parity Multisig Wallet hack 是一个例子,来说明如果在其预期上下文之外运行编写良好的库代码可以被利用。对于这种攻击有很多很好的解释,例如“Parity Multisig Hacked. Again”和“深入了解 Parity Multisig Bug”。
为了增加这些参考资料,让我们探索被利用的合约。库和钱包合约可以在 GitHub(Parity已被弃用) 上找到。
库合约如下:
钱包合约:
请注意,Wallet
合约本质上是通过 delegate call 将所有调用传递给 WalletLibrary
合约。此代码片段中的常量 _walletLibrary
地址充当实际部署的 WalletLibrary
合约的占位符(位于 0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4
)。
这些合约的预期操作是拥有一个简单的低成本可部署钱包合约,其代码库和主要功能都在 WalletLibrary
合约中。不幸的是,WalletLibrary
合约本身就是一个合约,并保持着自己的状态。你能明白为什么这可能是一个问题吗?
可以向 WalletLibrary
合约本身发送调用。具体来说,WalletLibrary
合约可以被初始化并被拥有。事实上,用户就是这样做的,调用 WalletLibrary
合约上的 initWallet
函数并成为库合约的所有者。同一用户随后调用了 kill
函数。因为用户是库合约的所有者,所以 modifier 通过并且库合约自毁。由于现有的所有 Wallet
合约都引用此库合约并且不包含更改此引用的方法,因此它们的所有功能,包括提取以太币的能力,都与 WalletLibrary
合约一起丢失。结果,所有此类 Parity 多重签名钱包中的所有以太币立即丢失或永久无法恢复。
默认可见性 (Default Visibilities)
Solidity 中的函数具有可见性说明符,指示如何调用它们。可见性决定了一个函数是否可以被用户、其他派生合约、仅在内部或仅在外部调用。有四个可见性说明符,在 Solidity 文档 中有详细描述。函数默认为 public,允许用户在外部调用它们。我们现在将看到不正确地使用可见性说明符如何导致智能合约中的一些破坏性漏洞。
漏洞
函数的默认可见性是 public
,因此外部用户可以调用未指定其可见性的函数。当开发人员错误地在应该是私有的(或只能在合约本身内调用)的函数上忽略可见性说明符时,就会出现问题。
让我们快速探索一个简单的例子:
这个简单的合约旨在充当地址猜测赏金游戏。为了赢得合约的余额,用户必须生成一个最后 8 个十六进制字符为 0 的以太坊地址。一旦实现,他们可以调用withdrawWinnings
函数来获得他们的赏金。
不幸的是,尚未指定功能的可见性。特别是 _sendWinnings
函数是 public
(默认),因此任何地址都可以调用此函数来窃取赏金。
预防技术
始终指定合同中所有功能的可见性是一种很好的做法,即使它们是 public
。最近版本的 solc 对没有明确可见性设置的函数显示警告,以鼓励这种做法。
注:当前版本的 Solidity 对没有指定可见性修饰符的函数显示 SyntaxError 错误。
真实世界的例子:Parity Multisig Wallet (First Hack)
在第一次 Parity 多重签名攻击中,价值约 3100 万美元的以太币被盗,大部分来自三个钱包。 Haseeb Qureshi(A hacker stole $31M of Ether — how it happened, and what it means for Ethereum) 很好地回顾了这是如何完成的。
本质上,多重签名钱包是由基本的 Wallet
合约构成的,该合约调用包含核心功能的库合约(如 真实世界的例子:Parity Multisig Wallet (Second Hack) 中所述)。库合约包含初始化钱包的代码,从以下代码片段可以看出:
请注意,这两个函数都没有指定它们的可见性,因此它们都默认为 public
。 initWallet
函数在钱包的构造函数中调用,并设置多重签名钱包的所有者,如 initMultiowned
函数所示。由于这些函数意外地被公开,攻击者能够在部署的合约上调用这些函数,将所有权重置为攻击者的地址。作为所有者,攻击者随后耗尽了所有以太币的钱包。
熵错觉 (Entropy Illusion)
以太坊区块链上的所有交易都是确定性的状态转换操作。这意味着每笔交易都以可计算的方式修改以太坊生态系统的全局状态,没有不确定性。这具有基本含义,即以太坊中没有熵或随机性的来源。实现去中心化熵(随机性)是一个众所周知的问题,已经提出了许多解决方案,包括 RANDAO 或使用哈希链,正如 Vitalik Buterin 在博客文章“Validator Ordering and Randomness in PoS”中所描述的那样。
漏洞
在以太坊平台上建立的一些第一批合约是基于赌博的。从根本上说,赌博需要不确定性(赌注),这使得在区块链(确定性系统)上构建赌博系统相当困难。很明显,不确定性必须来自区块链外部的来源。这对于玩家之间的赌注是可能的(例如commit–reveal 技术);但是,如果您想实现一个合约来充当“the house”(如二十一点或轮盘赌),则要困难得多。一个常见的陷阱是使用未来区块变量 —— 即包含有关交易区块的信息的变量,其值尚不清楚,例如哈希、时间戳、区块编号或gas限制。这些问题是它们由开采区块的矿工控制,因此并不是真正随机的。例如,考虑一个轮盘赌智能合约,如果下一个块哈希以偶数结尾,则返回一个黑色数字的逻辑。一个矿工(或矿工池)可以在黑色上下注 100 万美元。如果他们解决下一个块并发现哈希以奇数结尾,他们可以很高兴地不发布他们的块并挖掘另一个块,直到他们找到块哈希为偶数的解决方案(假设块奖励和费用小于100 万美元)。正如 Martin Swende 在他出色的博客文章中所展示的那样,使用过去或现在的变量可能更具破坏性。此外,单独使用区块变量意味着一个区块中所有交易的伪随机数都是相同的,因此攻击者可以通过在一个区块内进行许多交易来倍增他们的胜利(应该有一个最大赌注)。
预防技术
熵(随机性)的来源必须在区块链之外。这可以在具有诸如 commit–reveal 技术 等系统的对等点之间完成,或者通过将信任模型更改为一组参与者(如在 RANDAO 中)。这也可以通过充当随机预言机的中心化实体来完成。块变量(通常有一些例外)不应该用于获取熵,因为它们可以被矿工操纵。
现实世界的例子:PRNG 合约
2018 年 2 月,Arseny Reutov 在博客(Predicting Random Numbers in Ethereum Smart Contracts)中介绍了他对使用某种伪随机数生成器 (PRNG) 的 3,649 个实时智能合约的分析;他发现了 43 份可以利用的合约。
外部合约引用 (External Contract Referencing)
以太坊“世界计算机”的好处之一是能够重用代码并与已经部署在网络上的合约进行交互。因此,大量合约引用外部合约,通常是通过外部消息调用。这些外部消息调用可以以一些不明显的方式掩盖恶意行为者的意图,我们现在将对其进行检查。
漏洞
在 Solidity 中,任何地址都可以转换为合约,无论该地址处的代码是否代表正在转换的合约类型。这可能会导致问题,尤其是当合约的作者试图隐藏恶意代码时。让我们用一个例子来说明这一点。
考虑一段像 Rot13Encryption.sol 这样的代码,它初步实现了 ROT13 密码。
示例8. Rot13Encryption.sol
这段代码只接受一个字符串(字母 a-z,没有验证)并通过将每个字符向右移动 13 位(环绕 z
)对其进行加密;即,a
转移到 n
和 x
转移到 k
。不需要理解前面合约中的组件来理解正在讨论的问题,因此不熟悉组件的读者可以放心地忽略它。
现在考虑以下合约,它使用此代码进行加密:
该合约的问题是 encryptionLibrary
地址不是公开的或恒定的。因此,合约的部署者可以在构造函数中给出一个指向该合约的地址:
该合约实现了 ROT26 密码,它将每个字符移动 26 个位置(即,什么都不做)。同样,无需了解本合同中的组件。更简单地说,攻击者可以将以下合约链接到相同的效果:
如果在构造函数中给出了这些合约中的任何一个的地址,则 encryptPrivateData
函数将简单地生成一个打印未加密私有数据的事件。
尽管在此示例中,在构造函数中设置了类似库的合约,但通常情况下,特权用户(例如所有者)可以更改库合约地址。如果链接的合约不包含被调用的函数,则将执行回退函数。例如,使用行 encryptionLibrary.rot13Encrypt()
,如果由 encryptionLibrary
指定的合约是:
然后将发出带有文本 Here
的事件。因此,如果用户可以更改合约库,他们原则上可以让其他用户在不知不觉中运行任意代码。
警告 | 此处表示的合约仅用于演示目的,并不代表适当的加密。它们不应该用于加密。 |
---|
预防技术
如前所述,可以(在某些情况下)以恶意行为的方式部署安全合约。审计员可以公开验证合约并让其所有者以恶意方式部署它,从而导致公开审计的合约具有漏洞或恶意意图。
有许多技术可以防止这些情况。
一种技术是使用 new 关键字来创建合同。在前面的例子中,构造函数可以写成:
这样,在部署时会创建引用合约的实例,部署者无法在不更改的情况下替换 Rot13Encryption
合约。
另一种解决方案是硬编码外部合约地址。
一般来说,调用外部合约的代码应该总是被仔细审计。作为开发人员,在定义外部合约时,最好将合约地址公开(在下一节的 honey-pot 示例中不是这种情况),以便用户轻松检查合约引用的代码。相反,如果合约有一个私有变量合约地址,它可能是某人恶意行为的迹象(如现实世界示例所示)。如果用户可以更改用于调用外部函数的合约地址,那么(在分散的系统环境中)实施时间锁定和/或投票机制以允许用户查看正在更改的代码可能很重要,或者让参与者有机会选择加入/退出新的合约地址。
现实世界的例子:Reentrancy Honey Pot
最近在主网上发布了一些 honey pot。这些合约试图比试图利用这些合约的以太坊黑客更聪明,但他们最终会因他们期望利用的合约而失去以太币。一个示例通过在构造函数中用恶意合约替换预期合约来使用这种攻击。代码可以在这里找到:
一位 reddit 用户的这篇文章解释了他们是如何通过试图利用他们预计会出现在合约中的可重入性错误来为该合约损失 1 个以太币的。
短地址/参数攻击 (Short Address/Parameter Attack)
这种攻击不是针对 Solidity 合约本身,而是针对可能与其交互的第三方应用程序。添加此部分是为了完整性,并让读者了解如何在合约中操纵参数。
如需进一步阅读,请参阅“ICO 智能合约漏洞:短地址攻击”或此 Reddit 帖子。
漏洞
将参数传递给智能合约时,参数根据 ABI 规范 进行编码。可以发送比预期参数长度短的编码参数(例如,发送一个只有 38 个十六进制字符(19 个字节)而不是标准的 40 个十六进制字符(20 个字节)的地址)。在这种情况下,EVM 将在编码参数的末尾添加零以构成预期长度。
当第三方应用程序不验证输入时,这会成为一个问题。最明显的例子是当用户请求提款时不验证 ERC20 代币地址的交易所。这个例子在 Peter Vessenes 的文章“The ERC20 Short Address Attack Explained”中有更详细的介绍。
考虑标准 ERC20 transfer 函数接口,注意参数的顺序:
现在考虑一个持有大量代币(比方说 REP
)的交易所和一个希望提取其 100 个代币份额的用户。用户将提交他们的地址 0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddead
和代币数量 100。交易所将按照 transfer
函数指定的顺序对这些参数进行编码;也就是说,先 address
然后 token
。编码结果将是:
前 4 个字节(a9059cbb
)是 transfer
函数签名/选择器,接下来的 32 个字节是地址,最后 32 个字节代表 uint256
token数。请注意,末尾的十六进制 56bc75e2d63100000
对应于 100 个代币(小数点后 18 位,由 REP
代币合约指定)。
现在让我们看看如果发送一个缺少 1 个字节(2 个十六进制数字)的地址会发生什么。具体来说,假设攻击者发送 0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddeadde
作为地址(缺少最后两位数字)和相同的 100 个令牌以提取。如果交易所不验证此输入,它将被编码为:
差异是微妙的。请注意,00
已添加到编码的末尾,以弥补发送的短地址。当它被发送到智能合约时,地址参数将被读取为 0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00
并且值将被读取为 56bc75e2d6310000000
(注意两个额外的 0)。该值现在是 25600
个令牌(该值已乘以 256
)。在这个例子中,如果交易所持有这么多代币,用户将提取 25600
个代币(而交易所认为用户只提取了 100
个)到修改后的地址。显然,在这个例子中,攻击者不会拥有修改后的地址,但是如果攻击者要生成任何以 0 结尾的地址(这很容易被暴力破解)并使用这个生成的地址,他们就可以从毫无戒心的交易所窃取代币.
预防技术
外部应用程序中的所有输入参数都应在发送到区块链之前进行验证。还应注意,参数排序在这里起着重要作用。由于填充只发生在最后,智能合约中参数的仔细排序可以减轻这种攻击的某些形式。
未检查的 CALL 返回值 (Unchecked CALL Return Values)
在 Solidity 中有多种执行外部调用的方法。向外部账户发送以太币通常是通过转账方式进行的。但是,也可以使用 send
函数,对于更通用的外部调用,可以在 Solidity 中直接使用 CALL
操作码。 call
和 send
函数返回一个布尔值,指示调用是成功还是失败。因此,这些函数有一个简单的警告(caveat),如果外部调用(由 call
或 send
初始化)失败,执行这些函数的交易将不会 revert;相反,这些函数只会返回 false
。一个常见的错误是开发人员希望(expect)在外部调用失败时发生revert,并且没有检查返回值。
如需进一步阅读,请参阅 DASP - TOP 10 和 “Scanning Live Ethereum Contracts for the ‘Unchecked-Send’ Bug”。
漏洞
考虑以下示例:
这代表了一个类似于 Lotto 的合约,winner
将获得 winAmount
的以太币,这通常会留下一些剩余的以太币供任何人提取。
该漏洞存在于第 11 行,其中使用了 send
而没有检查响应。在这个简单的例子中,无论是否发送了以太币,交易失败的 winner
(无论是因为耗尽了 gas 还是因为合约故意抛出了 fallback 函数)都允许将 payedOut
设置为 true
。在这种情况下,任何人都可以通过 withdrawLeftOver
函数提取获胜者的奖金。
预防技术
尽可能使用 transfer
函数而不是 send
,因为如果外部交易 revert,transfer
将会 revert。如果需要使用 send
,请始终检查返回值。
更有力的建议是采用 withdrawal pattern。在这个解决方案中,每个用户都必须调用一个独立的 withdraw 函数来处理从合约中发送以太币并处理发送交易失败的后果。这个想法是在逻辑上将外部发送功能与代码库的其余部分隔离开来,并将可能失败的交易的负担放在调用 withdraw 函数的最终的用户身上。
现实世界的例子:Etherpot and King of Ether
Etherpot 是一种智能合约彩票,与前面提到的示例合约没有太大区别。该合约的失败主要是由于不正确使用区块哈希(只有最后 256 个区块哈希可用;请参阅 Aakil Fernandes 的帖子,了解 Etherpot 如何未能正确考虑这一点)。但是,该合约也遭受了未经检查的看涨期权价值。考虑 lotto.sol: Code snippet 中的函数 cash
:代码片段。
示例9. lotto.sol: Code snippet
请注意,在第 21 行,send
函数的返回值没有被检查,接下来的行设置了一个布尔值,表示获胜者已经收到了他们的资金。这个错误可能允许获胜者没有收到他们的以太币的状态,但合约的状态可以表明获胜者已经被支付。
此错误的更严重版本发生在 King of the Ether 中。该合约的 Post-Mortem Investigation 详细说明了如何使用未经检查的失败发送来攻击合约。
竞争条件/抢跑 (Race Conditions/Front Running)
对其他合约的外部调用和底层区块链的多用户性质相结合,导致了各种潜在的 Solidity 陷阱,用户竞相(race)执行代码来获得意外状态(unexpected states)。重入(本章前面讨论过)就是这种竞争条件的一个例子。在本节中,我们将讨论以太坊区块链上可能发生的其他类型的竞争条件。关于这个主题有很多很好的帖子,包括 以太坊 Wiki 上的“Race Conditions”、DASP - TOP 10 以及 Ethereum Smart Contract Best Practices。
漏洞
与大多数区块链一样,以太坊节点汇集交易并将它们形成区块。只有当矿工解决了共识机制(目前是以太坊的 Ethash PoW)后,这些交易才被认为是有效的。解决区块的矿工还选择池中的哪些交易将包含在区块中,通常按每笔交易的 gasPrice
排序。这是一个潜在的攻击向量。攻击者可以查看交易池中可能包含问题解决方案的交易,并修改或撤销求解器的权限或更改对求解器不利的合约状态。然后,攻击者可以从该交易中获取数据并创建自己的具有更高 gasPrice
的交易,以便他们的交易包含在原始交易之前的块中。
让我们用一个简单的例子来看看它是如何工作的。考虑 FindThisHash.sol 中显示的合约。
示例10. FindThisHash.sol
假设该合约包含 1,000 个以太币。可以找到以下 SHA-3 哈希的原像的用户可以提交解决方案并取回 1,000 以太币:
假设一位用户发现解决方案是 Ethereum!
他们用 Ethereum!
作为参数来调用 solve
。不幸的是,攻击者已经足够聪明,可以监视任何提交解决方案的交易池。他们看到这个解决方案,检查它的有效性,然后提交一个比原始交易高得多的 gasPrice
的等价交易。由于 gasPrice
较高,解决区块的矿工可能会优先考虑攻击者,并在原始解决者之前挖掘他们的交易。攻击者将拿走 1,000 个以太币,而解决问题的用户将一无所获。请记住,在这种类型的“抢先运行(front-running)”漏洞中,矿工被独特地激励来自己运行攻击(或者可以被贿赂以高昂的费用运行这些攻击)。不应低估攻击者本身就是矿工的可能性。
预防技术
有两类参与者可以执行这些类型的抢先攻击:用户(修改其交易的 gasPrice
)和矿工自己(他们可以按照他们认为合适的方式对区块中的交易进行重新排序)。易受第一类(用户)攻击的合约比易受第二类(矿工)攻击的合约要糟糕得多,因为矿工只能在解决区块时执行攻击,这对于任何针对特定区块的单个矿工来说都不太可能。在这里,我们将列出与这两类攻击者相关的一些缓解措施。
一种方法是在 gasPrice
上设置一个上限。这可以防止用户增加 gasPrice
并获得超出上限的优惠交易排序。该措施只防范第一类攻击者(任意用户)。在这种情况下,矿工仍然可以攻击合约,因为他们可以随意对区块中的交易进行排序,而不管 gas 价格如何。
一种更稳健的方法是使用 commit-reveal 方案。这种方案要求用户发送带有隐藏信息(通常是散列)的交易。在交易被包含在一个块中之后,用户发送一个交易来揭示所发送的数据(reveal 阶段)。这种方法可以防止矿工和用户抢先交易,因为他们无法确定交易的内容。但是,这种方法无法隐藏交易价值(transaction value)(在某些情况下,交易价值是需要隐藏的有价值的信息)。 ENS 智能合约允许用户发送交易,其提交的数据(committed data)包括他们愿意花费的以太币数量。然后,用户可以发送任意价值的交易。在 reveal 阶段,用户会获得交易中发送的金额与他们愿意花费的金额之间的差额退款。
Lorenz Breidenbach、Phil Daian、Ari Juels 和 Florian Tramèr 的进一步建议是使用“submarine sends”。这个想法的有效实现需要 CREATE2
操作码,该操作码目前尚未被采用,但似乎可能会出现在即将到来的硬分叉中。
现实世界的例子:ERC20 和 Bancor
ERC20 标准 以在以太坊上构建代币而闻名。由于 approve
函数,该标准存在潜在的抢跑漏洞。Mikhail Vladimirov 和 Dmitry Khovratovich 对此漏洞(以及减轻攻击的方法)进行了很好的解释。
该标准将 approve
函数指定为:
此函数允许用户批准其他用户代表他们转移代币。抢跑漏洞发生在用户 Alice 批准她的朋友 Bob 花费 100 个代币的场景中。 Alice 后来决定她想撤销 Bob 对花费 100 个代币的批准,因此她创建了一个交易,将 Bob 的分配设置为 50 个代币。一直在仔细观察链的 Bob 看到了这个交易并建立了一个他自己花费 100 个代币的交易。他为他的交易设置了比 Alice 更高的 gasPrice
,因此他的交易优先于她的交易。approve
的一些实现将允许 Bob 转移他的 100 个代币,然后,当 Alice 的交易被提交时,将 Bob 的批准重置为 50 个代币,实际上让 Bob 可以访问 150 个令牌。
另一个突出的现实世界例子是 Bancor。 Ivan Bogatyy 和他的团队记录了对 Bancor 初始实施的一次有利可图的攻击。他的博客文章(Implementing Ethereum trading front-runs on the Bancor exchange in Python) 和 DevCon3 演讲 详细讨论了这是如何完成的。本质上,代币的价格是根据交易价值确定的;用户可以查看 Bancor 交易的交易池,并提前运行它们以从价格差异中获利。 Bancor 团队已经解决了这种攻击。
拒绝服务 (Denial of Service, DoS)
此类别非常广泛,但基本上包括用户可以使合约在一段时间内或在某些情况下永久无法操作的攻击。这可能会永远将以太币困在这些合约中,就像上面提到的 Parity Multisig Wallet (Second Hack) 一样。
漏洞
有多种方式可以使合同无法操作。在这里,我们只强调一些可能导致 DoS 漏洞的不太明显的 Solidity 编码模式:
通过外部操作的映射或数组的循环 (Looping through externally manipulated mappings or arrays)
这种模式通常出现在所有者希望通过类似 distribute
的功能向投资者分发代币时,如本示例合约中所示:
请注意,此合约中的循环运行在一个可以人为膨胀的数组上。攻击者可以创建许多用户帐户,从而使 investor
数组变大。原则上,可以这样做,以使执行 for 循环所需的 gas 超过区块的 gas limit,本质上使 distribute
函数无法运行。
所有者操作 (Owner operations)
另一种常见的模式是所有者在合约中拥有特定特权,并且必须执行某些任务才能使合约进入下一个状态。一个例子是初始货币发行 (ICO) 合约,该合同要求所有者 finalize
合约,然后允许代币转让。例如:
在这种情况下,如果特权用户丢失了他们的私钥或变得不活跃,整个代币合约将变得无法操作。在这种情况下,如果所有者无法调用 finalize
,则无法转移代币;代币生态系统的整个运作取决于一个地址。
基于外部调用的进展状态 (Progressing state based on external calls)
有时会编写合约,以便进入新状态需要将以太币发送到一个地址,或者等待来自外部来源的一些输入。当外部调用失败或因外部原因被阻止时,这些模式可能导致 DoS 攻击。在发送以太币的例子中,用户可以创建一个不接受以太币的合约。如果合约要求以太币被撤回以进入新状态(考虑一个时间锁定合约,要求所有以太币在再次可用之前被撤回),该合约将永远不会达到新状态,因为以太币永远不会发送到不接受以太币的用户合约。
预防技术
在第一个例子中,合约不应该循环访问可以被外部用户人为操作的数据结构。建议使用提款模式,每个投资者都调用提款功能来独立索取代币。
在第二个示例中,需要特权用户来更改合约的状态。在这样的示例中,可以在所有者失去能力的情况下使用故障保护。一种解决方案是让所有者成为一个多重签名合约。另一种解决方案是使用时间锁定:在示例中,第 5 行的 require 可以包括基于时间的机制,例如 require(msg.sender == owner || now > unlockTime)
,它允许任何用户完成在 unlockTime
指定的一段时间后。这种缓解技术也可以用在第三个例子中。如果需要外部调用进入新状态,请考虑它们可能的失败,并可能在所需调用永远不会到来的情况下添加基于时间的状态进展。
Note | 当然,这些建议还有集中的替代方案:可以添加一个维护用户,如果需要,他可以一起解决基于 DoS 的攻击向量的问题。由于此类实体的权力,这些类型的合同通常存在信任问题。 |
---|
现实世界的例子:GovernMental
GovernMental 是一个古老的庞氏骗局,积累了大量的以太币(1,100 以太币,一次)。不幸的是,它容易受到本节中提到的 DoS 漏洞的影响。 etherik 的 Reddit 帖子 描述了合约如何要求删除大型映射以提取以太币。删除此映射的 gas 成本超过了当时的区块 gas 限制,因此无法提取 1,100 个以太币。合约地址是 0xf45717552f12ef7cb65e95476f217ea008167ae3,并且你可以从交易 0x0d80d67202bd9cb6773df8dd2020e7190a1b0793e8ec4fc105257e8128f0506b 看到 1,100 以太币最终通过 250 万 gas 的交易获取了(在区块 gas limit 上升到足以允许此类交易时)。
区块时间戳操作 (Block Timestamp Manipulation)
区块时间戳历来被用于各种应用,例如随机数的熵(有关更多详细信息,请参阅熵错觉),在一段时间内锁定资金,以及各种与时间相关的状态变化条件语句。矿工有能力稍微调整时间戳,如果在智能合约中错误地使用区块时间戳,这可能会很危险。
对此有用的参考资料包括 Solidity 文档 和 Joris Bontje 关于该主题的 Ethereum Stack Exchange 问题。
漏洞
block.timestamp
及其别名 now
可以被矿工操纵,如果他们有这样做的动机的话。让我们构建一个简单的游戏,显示在 roulette.sol 中,它很容易受到矿工利用。
示例11. roulette.sol
该合约的行为就像一个简单的彩票。每块一笔交易可以下注 10 以太币,就有机会赢得合约余额。这里的假设是block.timestamp
的最后两位数字是均匀分布的。如果是这样的话,中奖的机会将是 15 分之一。
但是,正如我们所知,矿工可以根据需要调整时间戳。在这种特殊情况下,如果合约中有足够多的以太币池,则会激励解决区块的矿工选择一个时间戳,使得 block.timestamp
或 now
模 15 为 0。这样做他们可能会赢得锁定在该合约中的以太币与块奖励。由于每个区块只允许一个人下注,这也容易受到抢跑攻击(请参阅竞争条件/抢跑了解更多详细信息)。
在实践中,区块时间戳是单调递增的,因此矿工不能选择任意区块时间戳(它们必须晚于其前辈)。它们还仅限于将块时间设置在不太远的将来,因为这些块可能会被网络拒绝(节点不会验证时间戳在未来的块)。
预防技术
区块时间戳不应该用于熵或生成随机数——也就是说,它们不应该是赢得游戏或改变重要状态的决定因素(直接或通过某种推导)。
有时需要时间敏感的逻辑;例如,用于解锁合约(时间锁定),几周后完成 ICO 或执行到期日期。有时建议使用 block.number
和平均块时间来估计时间;10 second
的出块时间,1 week
大约相当于 60480 blocks
。因此,指定更改合约状态的区块号可能更安全,因为矿工无法轻松操纵区块号。BAT ICO 合约采用了这种策略。
如果合约不是特别关注矿工对区块时间戳的操作,这可能是不必要的,但在开发合约时需要注意这一点。
现实世界的例子:GovernMental
上面提到的古老庞氏骗局 GovernMental 也容易受到基于时间戳的攻击。合约支付给最后一个加入(至少一分钟)的球员。因此,作为玩家的矿工可以调整时间戳(到未来时间,使其看起来像一分钟过去了),以使其看起来是最后一个加入超过一分钟的玩家(即使现实中不是真的)。有关这方面的更多详细信息,请参阅 Tanya Bahrynovska 的“History of Ethereum Security Vulnerabilities, Hacks and Their Fixes”一文。
小心的构造函数 (Constructors with Care)
构造函数是在初始化合约时经常执行关键的特权任务的特殊函数。在 Solidity v0.4.22 之前,构造函数被定义为与包含它们的合约同名的函数。在这种情况下,当在开发中更改合约名称时,如果构造函数名称也没有更改,它将成为一个正常的可调用函数。正如你可以想象的那样,这可能会导致(并且已经)一些有趣的合约攻击。
为了进一步了解,读者可能有兴趣尝试 Ethernaut challenges(特别是 Fallout 级别)。
漏洞
如果合约名称被修改,或者构造函数名称中存在拼写错误以至于它与合约名称不匹配,则构造函数将像普通函数一样运行。这可能会导致可怕的后果,尤其是在构造函数执行特权操作时。考虑以下合约:
该合约通过调用 withdraw
函数收集以太币并只允许所有者提取它。出现问题是因为构造函数的名称与合同的名称不完全相同:第一个字母不同!因此,任何用户都可以调用 ownerWallet
函数,将自己设置为所有者,然后通过调用 withdraw
来获取合约中的所有以太币。
预防技术
此问题已在 Solidity 编译器的 0.4.22 版本中得到解决。该版本引入了指定构造函数的 constructor
关键字,而不是要求函数名称与合约名称匹配。建议使用此关键字指定构造函数以防止命名问题。
真实世界的例子:Rubixi
Rubixi 是另一个表现出这种脆弱性的金字塔计划。它最初被称为 DynamicPyramid
,但在部署到 Rubixi
之前更改了合约名称。构造函数的名称没有改变,这允许任何用户成为创建者(creator)。可以在 Bitcointalk 上找到与此错误相关的一些有趣的讨论。最终,它允许用户争夺创建者身份,以从金字塔计划中索取费用。
未初始化的存储指针 (Uninitialized Storage Pointers)
EVM 将数据存储到 storage 或 memory。在开发合约时,强烈建议准确了解这是如何完成的以及函数局部变量的默认类型。这是因为不恰当地初始化变量可能会产生易受攻击的合约。
要了解有关 EVM 中存储和内存的更多信息,请参阅 Solidity 文档中的 data location,layout of state variables in storage 和 layout in memory。
Note | 本部分基于 Stefan Beyer 的一篇出色的文章。受 Stefan 启发,关于这个主题的进一步阅读可以在这个 Reddit thread 中找到。 |
---|
漏洞
函数中的局部变量默认为存储或内存,具体取决于它们的类型。未初始化的本地存储变量可能包含合约中其他存储变量的值;这一事实可能会导致无意的漏洞,或被故意利用。
让我们考虑 NameRegistrar.sol 中相对简单的名称注册商合约。
示例12. NameRegistrar.sol
这个简单的名称注册器只有一个功能。当合约是 unlocked
时,它允许任何人注册一个名称(作为 bytes32
散列)并将该名称映射到一个地址。 registrar 最初被锁定,第 25 行的 require
阻止 register
添加名称记录。合约似乎无法使用,因为无法解锁注册表!但是,有一个漏洞允许名称注册,而不管 unlocked
的变量如何。
要讨论这个漏洞,首先我们需要了解存储在 Solidity 中是如何工作的。作为一个层级概述(没有任何适当的技术细节——我们建议阅读 Solidity 文档以进行适当的查看),状态变量按出现在合约中的顺序存储在 slots 中(它们可以组合在一起但不在此例中,所以我们不会担心)。因此,unlocked
存在于 slot[0]
中,registeredNameRecord
存在于 slot[1]
中,并且 resolve
存在于 slot[2]
中,等等。这些 slot 中的每一个都是 32 字节大小(映射增加了复杂性,我们现在将忽略它)。unlocked
的布尔值看起来像 0x000...0
(64 个 0,不包括 0x)表示 false
或 0x000...1
(63 0s)表示 true
。如您所见,在此特定示例中存在大量存储浪费。
下一个难题是 Solidity 在将复杂数据类型(例如 struct)初始化为局部变量时默认将它们放入存储中。因此,第 18 行的 newRecord
默认为 storage。该漏洞是由于 newRecord
未初始化造成的。因为默认是 storage,所以映射到 storage slot[0],当前包含一个指向 unlocked
的指针。请注意,在第 19 行和第 20 行,我们将 newRecord.name
设置为 _name
并将 newRecord.mappedAddress
设置为 _mappedAddress
;这会更新 slot[0] 和 slot[1] 的存储位置,这会同时修改 unlocked
和与 registerNameRecord
关联的 storage slot。
这意味着 unlocked
可以直接修改,只需通过 register
函数的 bytes32_name
参数即可。因此,如果 _name
的最后一个字节不为零,它会修改 storage slot[0]
的最后一个字节,直接将 unlocked
改为 true
。这样的 _name
值将导致第 25 行的 require
调用成功,因为我们已将 unlocked
设置为 true
。在 Remix 中试试这个。请注意,如果您使用以下形式的 _name
,该函数将通过:
预防技术
Solidity 编译器对未初始化的 storage 变量显示警告;开发人员在构建智能合约时应特别注意这些警告。当前版本的 Mist (0.10) 不允许编译这些合约。在处理复杂类型时,显式使用 memory
或 storage
说明符通常是一种很好的做法,以确保它们的行为符合预期。
现实世界的例子:OpenAddressLottery 和 CryptoRoulette Honey Pots
部署了一个名为 OpenAddressLottery 的蜜罐,它使用这个未初始化的存储变量怪癖(quirk)从一些潜在的黑客那里收集以太币。合约相当复杂,所以我们将把分析留给 Reddit thread,在那里对攻击进行了非常清楚的解释。
浮点数和精度 (Floating Point and Precision)
在撰写本文时(v0.4.24),Solidity 不支持定点数和浮点数。这意味着浮点表示必须使用 Solidity 中的整数类型来构造。如果没有正确实施,这可能会导致错误和漏洞。
Note | 如需进一步阅读,请参阅 以太坊合约安全技术和 Tips wiki。 |
---|
漏洞
由于 Solidity 中没有定点类型,因此要求开发人员使用标准整数数据类型实现自己的需求。在此过程中,开发人员可能会遇到许多陷阱。我们将尝试在本节中强调其中的一些。
让我们从一个代码示例开始(为简单起见,我们将忽略本章前面讨论过的上溢/下溢问题):
这个简单的代币买卖合约有一些明显的问题。虽然买卖代币的数学计算是正确的,但缺少浮点数会给出错误的结果。例如,在第 8 行购买代币时,如果值小于 1 ether
,则初始除法将导致 0
,最终乘法的结果为 0
(例如,200 wei 除以 1e18
weiPerEth
等于0
)。同样,在出售代币时,任何少于 10
个的代币也将导致 0 ether
。事实上,这里的四舍五入总是向下,所以卖出 29 tokens
将得到 2 ether
。
该合约的问题在于精度仅到最接近的以太币(如 1e18 wei)。当您需要更高的精度时,在处理 ERC20 代币中的小数时,这可能会变得很棘手。
预防技术
在您的智能合约中保持正确的精度非常重要,尤其是在处理反映经济决策的比率和利率时。
您应该确保您使用的任何 ratio 或 rate 都允许分数中的大分子(large numerator)。例如,我们在示例中使用了 rate tokensPerEth
。使用 weiPerTokens
会更好,这将是一个很大的数字。要计算相应的代币数量,我们可以执行 msg.value/weiPerTokens
。这将给出更精确的结果。
要记住的另一个策略是注意操作顺序。在我们的示例中,购买代币的计算是 msg.value/weiPerEth*tokenPerEth
。请注意,除法发生在乘法之前。 (与某些语言不同,Solidity 保证按照编写顺序执行操作。)如果计算先执行乘法然后执行除法,则此示例将获得更高的精度;即 msg.value*tokenPerEth/weiPerEth
。
最后,在为数字定义任意精度时,最好将值转换为更高的精度,执行所有数学运算,然后最终转换回输出所需的精度。通常使用 uint256(因为它们最适合gas使用);这些在它们的范围内给出了大约 60 个数量级,其中一些可以专用于数学运算的精度。在 Solidity 中保持所有变量的高精度并在外部应用程序中转换回较低精度可能会更好(这本质上是 decimals
变量在 ERC20 代币合约中的工作方式)。要查看如何完成此操作的示例,我们建议查看 DS-Math。它使用了一些时髦的命名(“wads”和“rays”),但这个概念很有用。
现实世界的例子:Ethstick
Ethstick 合约不使用扩展精度;但是,它与 wei 打交道。所以,这个合约会有四舍五入的问题,但仅限于 wei 级别的精度。它有一些更严重的缺陷,但这些缺陷与在区块链上获取熵的困难有关(参见熵错觉)。
Tx.Origin 身份认证 (Tx.Origin Authentication)
Solidity 有一个全局变量 tx.origin
,它会遍历整个调用栈,并包含最初发送调用(或交易)的账户地址。在智能合约中使用此变量进行身份验证会使合约容易受到类似网络钓鱼的攻击。
Note | 如需进一步阅读,请参阅 dbryson 的 Ethereum Stack Exchange question 和 Chris Coverdale 的“Solidity: Tx Origin Attacks”。 |
---|
漏洞
使用 tx.origin
变量授权用户的合约通常容易受到网络钓鱼攻击,这些攻击可以诱骗用户对易受攻击的合约执行经过身份验证的操作。
考虑 Phishable.sol 中的简单合约。
示例13. Phishable.sol
请注意,在第 11 行,合约使用 tx.origin
授权了 withdrawAll
函数。该合约允许攻击者创建以下形式的攻击合约:
攻击者可能会将该合约伪装成他们自己的私人地址,并对受害者(Phishable 合约的所有者)进行社会工程,以向该地址发送某种形式的交易——也许向该合约发送一定数量的以太币。除非小心,否则受害者可能不会注意到攻击者的地址有代码,或者攻击者可能会将其伪装成多重签名钱包或某些高级存储钱包(请记住,默认情况下公共合约的源代码不可用)。
在任何情况下,如果受害者向 AttackContract
地址发送了一笔足够 gas 的交易,它就会调用 fallback 函数,该函数又会调用带有参数 attacker
的 Phishable
合约的withdrawAll
函数。这将导致从 Phishable
合约中提取所有资金到 attacker
地址。这是因为首先初始化调用的地址是受害者(即 Phishable
合约的所有者)。因此,tx.origin
将等于 owner
,Phishable
合约第 11 行的要求将通过。
预防技术
tx.origin
不应用于智能合约中的授权。这并不是说永远不应该使用 tx.origin
变量。它在智能合约中确实有一些合法的用例。例如,如果想要拒绝外部合约调用当前合约,可以实现形式为 require(tx.origin == msg.sender)
的 require
。这可以防止使用中间合约来调用当前合约,从而将合约限制为常规无代码地址。
合约库 (Contract Libraries)
有很多现有代码可供重用,既部署在链上作为可调用库,又部署在链下作为代码模板库。已部署的平台库以字节码智能合约的形式存在,因此在生产中使用它们之前应格外小心。但是,使用完善的现有平台库具有许多优势,例如能够从最新升级中受益,并通过减少以太坊中的实时合约总数为您节省资金并有益于以太坊生态系统。
在以太坊中,使用最广泛的资源是 OpenZeppelin 套件,它是一个丰富的合约库,范围从 ERC20 和 ERC721 代币的实现,到多种众筹模型,再到合约中常见的简单行为,例如 Ownable
、Pausable
或 LimitBalance
。这个存储库中的合约已经过广泛的测试,在某些情况下甚至可以作为事实上的标准实现。它们可以免费使用,并且由 OpenZeppelin 与不断增长的外部贡献者列表一起构建和维护。
Zeppelin 还提供了 OpenZeppelin | Defender,这是一个开源的服务和工具平台,用于安全地开发和管理智能合约应用程序。 它在 EVM 之上提供了一个层,使开发人员可以轻松地启动可升级的 DApp,这些 DApp 链接到链上库,其中包含经过良好测试的合约,这些合约本身是可升级的。这些库的不同版本可以在以太坊平台上共存,并且担保系统允许用户提出或推动不同方向的改进。该平台还提供了一套用于调试、测试、部署和监控去中心化应用程序的链下工具。
ethpm 项目旨在通过提供包管理系统来组织生态系统中正在开发的各种资源。因此,他们的注册表提供了更多示例供您浏览:
- Website: https://www.ethpm.com/
- Repository link: https://www.ethpm.com/registry
- GitHub link: https://github.com/ethpm
- Documentation: https://www.ethpm.com/docs/integration-guide
结论 (Conclusions)
任何在智能合约领域工作的开发人员都需要了解和理解很多东西。通过遵循智能合约设计和代码编写的最佳实践,您将避免许多严重的陷阱和陷阱。
也许最基本的软件安全原则是最大限度地重用受信任的代码。在密码学中,这非常重要,以至于被浓缩成一句格言:“不要推出自己的加密货币。”就智能合约而言,这相当于从经过社区彻底审查的免费可用库中获得尽可能多的收益。