
Solidity Security
这篇文章旨在作为一篇相对深入且最新的入门文章,详细介绍了 Solidity 开发者过去犯下的错误,以防止未来的开发者重蹈覆辙。
以太坊智能合约的特性之一,是能够调用并使用其他外部合约的代码。合约通常也会处理 ether,因此经常会将 ether 发送到各种外部用户地址。调用外部合约或向地址发送 ether 的操作,要求合约提交一次外部调用。这些外部调用可能被攻击者劫持,从而迫使合约执行更多代码(例如通过 fallback 函数),包括对自身的回调。因此,代码执行会“重入”合约。这类攻击被用在了臭名昭著的 DAO 攻击中。
关于重入攻击的进一步阅读,请参阅 智能合约重入攻击 和 Consensus - 以太坊智能合约最佳实践。
当合约向未知地址发送 ether 时,就可能发生这种攻击。攻击者可以在外部地址上精心构建一个包含恶意代码的合约,这些恶意代码位于 fallback 函数 中。因此,当合约向该地址发送 ether 时,就会触发恶意代码。通常,恶意代码会在易受攻击的合约上执行某个函数,执行开发者未预期的操作。“重入”这个名字源于这样一个事实:外部恶意合约回调易受攻击合约上的某个函数,并在易受攻击合约的任意位置“重新进入”代码执行。
为了说明这一点,考虑下面这个简单的易受攻击合约,它充当一个以太坊金库,允许存款人每周仅提取 1 ether。
EtherStore.sol:```solidity contract EtherStore {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
该合约有两个公共函数:`depositFunds()` 和 `withdrawFunds()`。`depositFunds()` 函数只是增加发送者的余额。`withdrawFunds()` 函数允许发送者指定要提取的 wei 数量。只有当请求提取的金额小于 1 ether 且在过去一周内没有发生过提取时,它才会成功。真的是这样吗?...
漏洞位于第 \[17\] 行,我们在那里向用户发送其请求的 ether 数量。考虑一个恶意攻击者创建如下合约,
Attack.sol:```solidity
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// initialise the etherStore variable with the contract address
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function pwnEtherStore() public payable {
// attack to the nearest ether
require(msg.value >= 1 ether);
// send eth to the depositFunds() function
etherStore.depositFunds.value(1 ether)();
// start the magic
etherStore.withdrawFunds(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
// fallback function - where the magic happens
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}
让我们看看这个恶意合约如何利用我们的EtherStore合约。攻击者会创建上述合约(假设地址为0x0...123),并将EtherStore的合约地址作为构造函数参数传入。这将初始化公共变量etherStore,使其指向我们想要攻击的合约。
然后,攻击者会调用pwnEtherStore()函数,并传入一定数量的以太币(大于或等于1),在此示例中我们假设为1 ether。在此示例中,我们假设许多其他用户已向该合约存入以太币,使其当前余额为10 ether。随后将发生以下情况:
Attack.sol - 第 [15] 行 - 将以msg.value为1 ether(以及大量gas)调用EtherStore合约的depositFunds()函数。发送者(msg.sender)将是我们恶意合约(0x0...123)。因此,balances[0x0..123] = 1 ether。
Attack.sol - 第 [17] 行 - 恶意合约随后将以参数1 ether调用EtherStore合约的withdrawFunds()函数。由于我们之前没有进行过任何提款,这将通过所有要求(EtherStore合约的第 [12]-[16] 行)。
EtherStore.sol - 第 [17] 行 - 合约随后会将1 ether发送回恶意合约。
Attack.sol - 第 [25] 行 - 发送给恶意合约的以太币随后将执行回退函数。
Attack.sol - 第 [26] 行 - EtherStore合约的总余额此前为,现在为,因此此if语句通过。
最终结果是,攻击者通过单笔交易,瞬间从EtherStore合约中提取了所有(除1外)以太币。
有多种常见技术有助于避免智能合约中的潜在重入漏洞。第一种是(尽可能)在向外部合约发送以太币时使用内置的 transfer() 函数。transfer函数仅随外部调用发送2300 gas,这不足以让目标地址/合约再调用另一个合约(即重新进入发送合约)。
第二种技术是确保所有更改状态变量的逻辑都在以太币被发送出合约(或任何外部调用)之前完成。在EtherStore示例中,EtherStore.sol的第 [18] 和 [19] 行应放在第 [17] 行之前。将任何对未知地址执行外部调用的代码放在本地化函数或代码执行的最后一步是一种良好实践。这被称为 checks-effects-interactions 模式。
第三种技术是引入互斥锁。即添加一个状态变量,在代码执行期间锁定合约,从而防止重入调用。
将所有这些技术(三种并非全部必需,但出于演示目的均采用)应用于EtherStore.sol,得到以下无重入漏洞的合约:```solidity
contract EtherStore {
// initialise the mutex
bool reEntrancyMutex = false;
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
require(!reEntrancyMutex);
require(balances[msg.sender] >= _weiToWithdraw);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
// set the reEntrancy mutex before the external call
reEntrancyMutex = true;
msg.sender.transfer(_weiToWithdraw);
// release the mutex after the external call
reEntrancyMutex = false;
}
}
<h3 id="re-example">现实世界示例:The DAO</h3>
[The DAO](https://en.wikipedia.org/wiki/The_DAO_(organization))(去中心化自治组织)是以太坊早期发展过程中发生的重大黑客攻击事件之一。当时,该合约持有超过 1.5 亿美元。重入攻击在攻击中扮演了主要角色,最终导致了创建以太坊经典(ETC)的硬分叉。关于 DAO 漏洞的精彩分析,请参见 [Phil Daian 的文章](http://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/)。
<h2 id="ouflow"><span id="SP-2">2. 算术上溢/下溢</span></h2>
以太坊虚拟机(EVM)为整数指定了固定大小的数据类型。这意味着一个整数变量只能表示一定范围的数值。例如,`uint8` 只能存储 \[0,255\] 范围内的数字。尝试将 `256` 存储到 `uint8` 中会导致 `0`。如果不加注意,Solidity 中的变量可能会被利用:当用户输入未经检查,并且执行的计算结果超出了存储它们的数据类型的范围时。
有关算术上溢/下溢的进一步阅读,请参阅 [如何保护你的智能合约](https://medium.com/loom-network/how-to-secure-your-smart-contracts-6-solidity-vulnerabilities-and-how-to-avoid-them-part-1-c33048d4d17d)、[以太坊智能合约最佳实践](https://consensys.github.io/smart-contract-best-practices/known_attacks/#integer-overflow-and-underflow) 和 [以太坊、Solidity 与整数溢出:像 1970 年一样编程区块链](https://randomoracle.wordpress.com/2018/04/27/ethereum-solidity-and-integer-overflows-programming-blockchains-like-1970/)
<h3 id="ou-vuln">漏洞</h3>
上溢/下溢发生在执行某个操作时,该操作需要固定大小的变量来存储超出变量数据类型范围的数字(或数据)。
例如,从一个存储值为 `0` 的 `uint8`(8 位无符号整数,即仅正数)变量中减去 `1`,结果将是数字 `255`。这就是下溢。我们分配了一个低于 `uint8` 范围的数字,结果 *回绕* 并给出 `uint8` 能存储的最大数字。类似地,向 `uint8` 加上 `2^8=256` 会使变量保持不变,因为我们已经绕过了 `uint` 的整个长度(对于数学家来说,这类似于向三角函数的角度加上 $2\pi$,$\sin(x) = \sin(x+2\pi)$)。加上超过数据类型范围的数字称为上溢。为清晰起见,向当前值为零的 `uint8` 加上 `257` 将得到数字 `1`。有时将固定类型变量视为循环的很有启发意义:如果我们加上超过最大可存储数字的数字,则从零重新开始;反之亦然,对于零(从零减去越多,我们就从最大数字开始向下计数)。
这些数值上的注意事项使攻击者能够滥用代码并创建意外的逻辑流。例如,请考虑下面的时间锁定合约。
TimeLock.sol:```solidity
contract TimeLock {
mapping(address => uint) public balances;
mapping(address => uint) public lockTime;
function deposit() public payable {
balances[msg.sender] += msg.value;
lockTime[msg.sender] = now + 1 weeks;
}
function increaseLockTime(uint _secondsToIncrease) public {
lockTime[msg.sender] += _secondsToIncrease;
}
function withdraw() public {
require(balances[msg.sender] > 0);
require(now > lockTime[msg.sender]);
uint transferValue = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
该合约旨在像一个时间金库一样运作,用户可以将以太币存入合约,并且在合约中至少锁定一周。如果用户愿意,也可以将等待时间延长到一周以上,但一旦存入,用户就可以确信自己的以太币会被安全锁定至少一周。真的可以吗?...
如果用户被迫交出私钥(比如遭遇人质劫持的情况),这样的合约可能很有用,可以确保以太币在短时间内无法被取走。如果用户在该合约中锁定了 100 ether,并将私钥交给了攻击者,攻击者就可以利用溢出获得这些以太币,无论 lockTime 是多少。
攻击者可以确定他们现在持有密钥的地址当前的 lockTime(这是一个公共变量)。我们将其称为 userLockTime。然后,他们可以调用 increaseLockTime 函数,并传入 2^256 - userLockTime 作为参数。这个数字会被加到当前的 userLockTime 上,导致溢出,从而将 lockTime[msg.sender] 重置为 0。之后,攻击者只需调用 withdraw 函数即可获得他们的奖励。
让我们看另一个例子,来自 Ethernaut Challanges。
剧透警告: 如果你还没有完成 Ethernaut 挑战,这里会给出其中一个关卡的解决方案。```solidity pragma solidity ^0.4.18;
contract Token {
mapping(address => uint) balances; uint public totalSupply;
function Token(uint _initialSupply) { balances[msg.sender] = totalSupply = _initialSupply; }
function transfer(address _to, uint _value) public returns (bool) { require(balances[msg.sender] - _value >= 0); balances[msg.sender] -= _value; balances[_to] += _value; return true; }
function balanceOf(address _owner) public constant returns (uint balance) { return balances[_owner]; } }
这是一个简单的代币合约,它使用了一个 `transfer()` 函数,允许参与者转移他们的代币。你能看出这个合约中的错误吗?
缺陷出现在 `transfer()` 函数中。第 \[13\] 行的 require 语句可以通过下溢被绕过。考虑一个余额为零的用户。他们可以用任何非零的 `_value` 调用 `transfer()` 函数,并通过第 \[13\] 行的 require 语句。这是因为 `balances[msg.sender]` 为零(并且是 `uint256`),所以减去任何正数(除了 `2^256`)都会由于我们上面描述的下溢而产生一个正数。第 \[14\] 行也是如此,我们的余额会被记入一个正数。因此,在这个示例中,我们因下溢漏洞而获得了免费代币。
<h3 id="ou-prevention">预防技术</h3>
(目前)防范下溢/溢出漏洞的传统技术是使用或构建数学库,以替代标准的数学运算符:加法、减法和乘法(除法除外,因为它不会导致上溢/下溢,而且 EVM 在除以 0 时会回滚)。
[OppenZepplin](https://github.com/OpenZeppelin/zeppelin-solidity) 在构建和审计可供以太坊社区使用的安全库方面做得非常出色。特别是,他们的 [安全数学库](https://github.com/OpenZeppelin/zeppelin-solidity/blob/master/contracts/math/SafeMath.sol) 是避免下溢/溢出漏洞的一个参考或库。
为了演示如何在 Solidity 中使用这些库,让我们使用 Open Zepplin 的 `SafeMath` 库来修正 `TimeLock` 合约。没有溢出问题的合约将变成:```solidity
library SafeMath {
function mul(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) {
return 0;
}
uint256 c = a * b;
assert(c / a == b);
return c;
}
function div(uint256 a, uint256 b) internal pure returns (uint256) {
// assert(b > 0); // Solidity automatically throws when dividing by 0
uint256 c = a / b;
// assert(a == b * c + a % b); // There is no case in which this doesn't hold
return c;
}
function sub(uint256 a, uint256 b) internal pure returns (uint256) {
assert(b <= a);
return a - b;
}
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}
contract TimeLock {
using SafeMath for uint; // use the library for uint type
mapping(address => uint256) public balances;
mapping(address => uint256) public lockTime;
function deposit() public payable {
balances[msg.sender] = balances[msg.sender].add(msg.value);
lockTime[msg.sender] = now.add(1 weeks);
}
function increaseLockTime(uint256 _secondsToIncrease) public {
lockTime[msg.sender] = lockTime[msg.sender].add(_secondsToIncrease);
}
function withdraw() public {
require(balances[msg.sender] > 0);
require(now > lockTime[msg.sender]);
uint transferValue = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
注意,所有标准数学运算都已被 SafeMath 库中定义的运算所取代。TimeLock 合约不再执行任何可能发生下溢/上溢的操作。
一个 4chan 群组认为在以太坊上用 Solidity 编写一个庞氏骗局是个绝妙的主意。他们将其命名为 Proof of Weak Hands Coin(PoWHC)。不幸的是,合约的作者似乎从未见过上溢/下溢,结果 866 个以太币从该合约中被掠走。关于下溢如何发生(与上面的 Ethernaut 挑战颇为相似)的精彩概述,请参见 Eric Banisadar 的文章。
一些开发者还在某些 ERC20 代币合约中实现了 batchTransfer() 函数。该实现包含一个溢出。这篇文章 对此做了解释,不过我认为标题具有误导性,因为这与 ERC20 标准无关,而是一些 ERC20 代币合约实现了一个易受攻击的 batchTransfer() 函数。
通常,当以太币被发送到合约时,它必须执行回退函数或合约中描述的其他函数。但有两个例外,以太币可以在不执行任何代码的情况下存在于合约中。依赖每次发送到合约的以太币都执行代码的合约,可能容易受到强行向合约发送以太币的攻击。
有关此主题的进一步阅读,请参阅 如何保护你的智能合约:6 和 Solidity 安全模式 - 强制向合约发送以太币 。
一种常见的防御性编程技术,在强制正确的状态转换或验证操作时非常有用,那就是 不变量检查。该技术涉及定义一组不变量(不应改变的度量或参数),并检查这些不变量在单个(或多个)操作之后是否保持不变。只要所检查的不变量确实是真正的不变量,这通常是良好的设计。不变量的一个例子是固定发行量 ERC20 代币的 totalSupply。由于不应有任何函数修改这一不变量,因此可以在 transfer() 函数中添加一项检查,确保 totalSupply 不变,以保证函数按预期工作。
特别地,有一个看似 不变量 的东西可能很诱人,但实际上可以被外部用户操纵(无论智能合约中设置了什么规则)。这就是合约中当前存储的以太币。当开发者最初学习 Solidity 时,常常会有一种误解,认为合约只能通过 payable 函数接受或获得以太币。这种误解可能导致合约对其内部以太币余额做出错误的假设,从而引发一系列漏洞。此漏洞的确凿证据是(错误地)使用 this.balance。正如我们将看到的,错误使用 this.balance 可能导致此类严重漏洞。
有两种方式可以(强制)将以太币发送到合约,而无需使用 payable 函数或在合约上执行任何代码。它们如下所列。
任何合约都可以实现 selfdestruct(address) 函数,该函数会从合约地址移除所有字节码,并将存储在该地址的所有以太币发送到参数指定的地址。如果指定地址也是一个合约,则不会调用任何函数(包括回退函数)。因此,selfdestruct() 函数可用于强制将以太币发送到任何合约,而不管合约中可能存在什么代码。这包括没有任何 payable 函数的合约。这意味着,任何攻击者都可以创建一个带有 selfdestruct() 函数的合约,向其发送以太币,调用 selfdestruct(target),并强制将以太币发送到 target 合约。Martin Swende 写了一篇优秀的 博客文章,描述了 self-destruct 操作码的一些怪癖(怪癖 #2),并描述了客户端节点如何检查错误的不变量,这可能导致客户端的灾难性核爆。
合约无需使用 selfdestruct() 函数或调用任何 payable 函数就能获得以太币的第二种方式,是预先将以太币加载到合约地址。合约地址是确定性的,实际上,该地址是根据创建合约的地址和创建合约的交易 nonce 的 keccak256(有时与 SHA3 同义)哈希计算得出的。具体来说,其形式为:address = sha3(rlp.encode([account_address,transaction_nonce]))(参见 无密钥以太币 了解一些有趣的用例)。这意味着,任何人都可以在合约创建之前计算出合约地址,从而将以太币发送到该地址。当合约真正被创建时,它将拥有一个非零的以太币余额。
让我们基于上述知识来探讨一些可能出现的陷阱。
考虑这个过于简单的合约:
EtherGame.sol:```solidity contract EtherGame {
uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;
mapping(address => uint) redeemableEther;
// users pay 0.5 ether. At specific milestones, credit their accounts
function play() public payable {
require(msg.value == 0.5 ether); // each play is 0.5 ether
uint currentBalance = this.balance + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
// if at a milestone credit the players account
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
return;
}
function claimReward() public {
// ensure the game is complete
require(this.balance == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
该合约代表一个简单的游戏(其自然会涉及[竞争条件](#race-conditions)),玩家向合约发送 `0.5 ether` 的份额,希望能成为第一个达到三个里程碑之一的玩家。里程碑以 ether 为单位。第一个达到里程碑的玩家可以在游戏结束时领取一部分 ether。当达到最终里程碑(`10 ether`)时游戏结束,用户可以领取他们的奖励。
`EtherGame` 合约的问题源于对 `this.balance` 的错误使用,分别出现在第 \[14\] 行(及其关联的第 \[16\] 行)和第 \[32\] 行。恶意的攻击者可以通过 `selfdestruct()` 函数(上文已讨论)强行发送少量 ether,比如 `0.1 ether`,从而阻止任何后续玩家达到里程碑。由于所有合法玩家只能发送 `0.5 ether` 的增量,`this.balance` 将不再是半整数,因为它还会包含那 `0.1 ether` 的贡献。这使得第 \[18\]、\[21\] 和 \[24\] 行上的所有 if 条件都无法成立。
更糟糕的是,一个错过里程碑的报复性攻击者可以强行发送 `10 ether`(或其他等量的 ether,足以将合约余额推高至 `finalMileStone` 之上),从而将所有奖励永久锁定在合约中。这是因为 `claimReward()` 函数将始终回退,原因在于第 \[32\] 行的 require(即 `this.balance` 大于 `finalMileStone`)。
<h3 id="ether-prevention">预防技术</h3>
此漏洞通常源于对 `this.balance` 的误用。合约逻辑在可能的情况下应避免依赖合约余额的精确值,因为它可能被人为操纵。如果基于 `this.balance` 应用逻辑,请确保考虑到意外余额。
如果需要已存入 ether 的精确值,则应使用一个自定义变量,在 payable 函数中对其进行递增,以安全地跟踪存入的 ether。该变量不会受到通过 `selfdestruct()` 调用强行发送的 ether 的影响。
考虑到这一点,`EtherGame` 合约的修正版本可能如下所示:```solidity
contract EtherGame {
uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;
uint public depositedWei;
mapping (address => uint) redeemableEther;
function play() public payable {
require(msg.value == 0.5 ether);
uint currentBalance = depositedWei + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
depositedWei += msg.value;
return;
}
function claimReward() public {
// ensure the game is complete
require(depositedWei == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
在这里,我们刚刚创建了一个新变量,depositedWei,它负责跟踪已知的已存入以太币,而我们对这个变量执行我们的要求和测试。请注意,我们不再有任何对 this.balance 的引用。
我尚未找到在野外被利用的此类示例。不过,Underhanded Solidity Contest 中给出了一些可利用合约的示例。
CALL 和 DELEGATECALL 操作码对于让以太坊开发者模块化其代码非常有用。对合约的标准外部消息调用由 CALL 操作码处理,代码在外部合约/函数的上下文中运行。DELEGATECALL 操作码与标准消息调用相同,只不过在目标地址执行的代码是在调用合约的上下文中运行的,并且 msg.sender 和 msg.value 保持不变。这一特性使得库的实现成为可能,开发者可以借此为未来的合约创建可复用的代码。
尽管这两个操作码之间的差异简单直观,但使用 DELEGATECALL 可能会导致意外的代码执行。
如需进一步阅读,请参阅 Ethereum Stack Exchange Question、Solidity Docs 以及 How to Secure Your Smart Contracts: 6。
DELEGATECALL 的上下文保留特性已经证明,构建无漏洞的自定义库并不像人们想象的那么容易。库中的代码本身可能是安全且无漏洞的,但在另一个应用程序的上下文中运行时,可能会产生新的漏洞。让我们看一个相当复杂的示例,使用斐波那契数列。
考虑以下库,它可以生成斐波那契数列及类似形式的数列。
FibonacciLib.sol1```solidity
// library contract - calculates fibonacci-like numbers;
contract FibonacciLib {
// initializing the standard fibonacci sequence;
uint public start;
uint public calculatedFibNumber;
// modify the zeroth number in the sequence
function setStart(uint _start) public {
start = _start;
}
function setFibonacci(uint n) public {
calculatedFibNumber = fibonacci(n);
}
function fibonacci(uint n) internal returns (uint) {
if (n == 0) return start;
else if (n == 1) return start + 1;
else return fibonacci(n - 1) + fibonacci(n - 2);
}
}
该库提供了一个函数,可以生成序列中的第 *n* 个斐波那契数。它允许用户更改序列的起始数字(`start`),并在此新序列中计算第 *n* 个类斐波那契数。
现在让我们考虑一个使用该库的合约。
`FibonacciBalance.sol`:```solidity
contract FibonacciBalance {
address public fibonacciLibrary;
// the current fibonacci number to withdraw
uint public calculatedFibNumber;
// the starting fibonacci sequence number
uint public start = 3;
uint public withdrawalCounter;
// the fibonancci function selector
bytes4 constant fibSig = bytes4(sha3("setFibonacci(uint256)"));
// constructor - loads the contract with ether
constructor(address _fibonacciLibrary) public payable {
fibonacciLibrary = _fibonacciLibrary;
}
function withdraw() {
withdrawalCounter += 1;
// calculate the fibonacci number for the current withdrawal user
// this sets calculatedFibNumber
require(fibonacciLibrary.delegatecall(fibSig, withdrawalCounter));
msg.sender.transfer(calculatedFibNumber * 1 ether);
}
// allow users to call fibonacci library functions
function() public {
require(fibonacciLibrary.delegatecall(msg.data));
}
}
该合约允许参与者从合约中提取以太币,提取金额等于与参与者提款顺序相对应的斐波那契数;即,第一个参与者获得 1 个以太币,第二个也获得 1 个,第三个获得 2 个,第四个获得 3 个,第五个获得 5 个,依此类推(直到合约余额小于要提取的斐波那契数)。
此合约中有一些元素可能需要解释。首先,有一个看起来很有趣的变量,fibSig。它保存了字符串 "setFibonacci(uint256)" 的 Keccak (SHA-3) 哈希的前 4 个字节。这被称为函数选择器,被放入 calldata 中用于指定智能合约的哪个函数将被调用。它在第 [21] 行的 delegatecall 函数中使用,以指定我们希望运行 setFibonacci(uint256) 函数。delegatecall 的第二个参数是我们要传递给该函数的参数。其次,我们假设构造器中正确引用了 FibonacciLib 库的地址(外部合约引用 一节讨论了与此类合约引用初始化相关的一些潜在漏洞)。
你能发现此合约中的任何错误吗?如果你将其放入 Remix,填入以太币并调用 withdraw(),它很可能会回滚。
你可能已经注意到,状态变量 start 在库合约和主调用合约中都有使用。在库合约中,start 用于指定斐波那契数列的起始值,并被设置为 0,而在 FibonacciBalance 合约中它被设置为 3。你可能还注意到,FibonacciBalance 合约中的回退函数允许所有调用被传递给库合约,这也允许调用库合约的 setStart() 函数。回顾我们保留合约状态的机制,似乎这个函数可以让你更改本地 FibonnacciBalance 合约中 start 变量的状态。如果真是这样,这将允许人们提取更多的以太币,因为最终的 calculatedFibNumber 依赖于 start 变量(如库合约中所见)。事实上,setStart() 函数并不能(也无法)修改 FibonacciBalance 合约中的 start 变量。此合约的根本漏洞远比仅仅修改 start 变量严重得多。
在讨论实际问题之前,我们先快速了解一下状态变量(storage 变量)在合约中究竟是如何存储的。状态变量或 storage 变量(在单个交易之间持续存在的变量)会按照它们在合约中引入的顺序依次放入 slots(存储槽)中。(这里有一些复杂性,我建议读者阅读存储中状态变量的布局 以获得更透彻的理解)。
举个例子,让我们看看库合约。它有两个状态变量:start 和 calculatedFibNumber。第一个变量是 start,因此它被存储在合约存储的 slot[0](即第一个槽)中。第二个变量 calculatedFibNumber 被放入下一个可用的存储槽 slot[1]。如果查看 setStart() 函数,它接受一个输入并将 start 设置为该输入。因此,这个函数实际上是在将 slot[0] 设置为我们在 setStart() 函数中提供的任何输入。类似地,setFibonacci() 函数将 calculatedFibNumber 设置为 fibonacci(n) 的结果。同样,这只是将存储 slot[1] 设置为 fibonacci(n) 的值。
现在让我们看看 FibonacciBalance 合约。此时存储 slot[0] 对应 fibonacciLibrary 地址,slot[1] 对应 calculatedFibNumber。漏洞正是出现在这种错误的映射上。delegatecall 保留合约上下文。这意味着通过 delegatecall 执行的代码将作用于调用合约的状态(即存储)。
现在注意,在第 [21] 行的 withdraw() 中,我们执行了 fibonacciLibrary.delegatecall(fibSig,withdrawalCounter)。这会调用 setFibonacci() 函数,正如我们讨论过的,它会修改存储 slot[1],而在当前上下文中,slot[1] 就是 calculatedFibNumber。这符合预期(即执行后,calculatedFibNumber 会被调整)。然而,请记住,FibonacciLib 合约中的 start 变量位于存储 slot[0],而在当前合约中,slot[0] 是 fibonacciLibrary 地址。这意味着 fibonacci() 函数会产生意外结果。这是因为该函数引用了 start(slot[0]),而在当前调用上下文中, 是 地址(当解释为 时,该地址通常相当大)。因此, 函数很可能会回滚,因为合约中没有 数量的以太币,而这正是 将返回的值。
更糟糕的是,FibonacciBalance 合约通过第 [26] 行的回退函数允许用户调用 fibonacciLibrary 的所有函数。正如我们之前讨论的,这包括 setStart() 函数。我们讨论过,这个函数允许任何人修改或设置存储 slot[0]。在这种情况下,存储 slot[0] 就是 fibonacciLibrary 地址。因此,攻击者可以创建一个恶意合约(下面给出一个示例),将该地址转换为 uint(在 python 中可以很容易地使用 int('<address>',16) 完成),然后调用 setStart(<attack_contract_address_as_uint>)。这将把 fibonacciLibrary 更改为攻击合约的地址。此后,每当用户调用 withdraw() 或回退函数时,恶意合约都会运行(它可以窃取合约的全部余额),因为我们已经修改了 fibonacciLibrary 的实际地址。这样的攻击合约示例如下:```solidity
contract Attack {
uint storageSlot0; // corresponds to fibonacciLibrary
uint storageSlot1; // corresponds to calculatedFibNumber
// fallback - this will run if a specified function is not found
function() public {
storageSlot1 = 0; // we set calculatedFibNumber to 0, so that if withdraw
// is called we don't send out any ether.
<attacker_address>.transfer(this.balance); // we take all the ether
}
}
注意,这个攻击合约通过更改存储`slot[1]`来修改`calculatedFibNumber`。原则上,攻击者可以修改他们选择的任何其他存储槽,从而对这合约发起各种攻击。我鼓励所有读者将这些合约放入 [Remix](https://remix.ethereum.org),并通过这些`delegatecall`函数尝试不同的攻击合约和状态更改。
同样重要的是要注意,当我们说`delegatecall`保持状态时,我们指的不是合约的变量名,而是这些名字所指向的实际存储槽。从这个例子可以看出,一个简单的错误就可能导致攻击者劫持整个合约及其以太币。
<h3 id="dc-prevention">预防技术</h3>
Solidity 提供了`library`关键字来实现库合约(更多细节请参阅 [Solidity 文档](http://solidity.readthedocs.io/en/latest/contracts.html?highlight=library#libraries))。这确保了库合约是无状态的且不可自毁的。强制库合约无状态,可以减轻本节所展示的存储上下文的复杂性。无状态库还可以防止攻击者直接修改库的状态,从而影响依赖该库代码的合约。
一般经验法则是,在使用`DELEGATECALL`时,要密切关注库合约和调用合约可能出现的调用上下文,并且尽可能构建无状态的库。
<h3 id="dc-example">现实世界示例:Parity 多签钱包(第二次攻击)</h3>
第二次 Parity 多签钱包攻击是一个示例,说明了编写良好的库代码如果在非预期上下文中运行,其上下文如何被利用。关于这次攻击有许多很好的解释,例如 Anthony Akentiev 的这篇概述:[Parity MultiSig 再次被黑](https://medium.com/chain-cloud-company-blog/parity-multisig-hack-again-b46771eaa838),这个 [Stack Exchange 问题](https://ethereum.stackexchange.com/questions/30128/explanation-of-parity-library-suicide/30130),以及 [深入剖析 Parity Multisig Bug](http://hackingdistributed.com/2017/07/22/deep-dive-parity-bug/)。
在这些参考资料之外,让我们来探索遭到利用的合约。库合约和钱包合约可以在 Parity 的 GitHub 上找到 [这里](https://github.com/paritytech/parity/blob/b640df8fbb964da7538eef268dffc125b081a82f/js/src/contracts/snippets/enhanced-wallet.sol)。
让我们来看看该合约的相关方面。这里包含两个关键合约:库合约和钱包合约。
库合约,```solidity
contract WalletLibrary is WalletEvents {
...
// throw unless the contract is not yet initialized.
modifier only_uninitialized { if (m_numOwners > 0) throw; _; }
// constructor - just pass on the owner array to the multiowned and
// the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
// kills the contract sending everything to `_to`.
function kill(address _to) onlymanyowners(sha3(msg.data)) external {
suicide(_to);
}
...
}
以及钱包合约,```solidity contract Wallet is WalletEvents {
...
// METHODS
// gets called when no other function matches function() payable { // just being sent some cash? if (msg.value > 0) Deposit(msg.sender, msg.value); else if (msg.data.length > 0) _walletLibrary.delegatecall(msg.data); }
...
// FIELDS address constant _walletLibrary = 0xcafecafecafecafecafecafecafecafecafecafe; }
请注意,`Wallet` 合约本质上是通过委托调用将所有调用传递给 `WalletLibrary` 合约。此代码片段中的常量 `_walletLibrary` 地址代表实际部署的 `WalletLibrary` 合约(其地址为 `0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4`)。
这些合约的预期运作方式是拥有一个简单、部署成本低的 `Wallet` 合约,其代码库和主要功能都在 `WalletLibrary` 合约中。不幸的是,`WalletLibrary` 合约本身也是一个合约,并维护自己的状态。你能看出这可能产生什么问题吗?
可以直接向 `WalletLibrary` 合约本身发送调用。具体来说,`WalletLibrary` 合约可以被初始化,并被拥有。一位用户通过调用 `WalletLibrary` 合约上的 `initWallet()` 函数做到了这一点,从而成为该库合约的所有者。随后,同一位用户调用了 `kill()` 函数。由于该用户是库合约的所有者,修饰符检查通过,库合约自毁(suicided)。由于所有现存的 `Wallet` 合约都引用这个库合约,并且没有方法更改此引用,它们的所有功能(包括提取以太币的能力)都随 `WalletLibrary` 合约一同丢失。更直接地说,所有此类 Parity 多签钱包中的以太币都会立即丢失或永久无法找回。
<h2 id="visibility"><span id="SP-5">5. 默认可见性</span></h2>
Solidity 中的函数具有可见性说明符,它们规定了函数允许如何被调用。可见性决定了函数是可由用户、其他派生合约外部调用,还是仅限内部调用或仅限外部调用。有四种可见性说明符,详见 [Solidity 文档](http://solidity.readthedocs.io/en/latest/contracts.html?highlight=library#visibility-and-getters)。函数默认是 `public`,允许用户从外部调用它们。不正确使用可见性说明符可能导致智能合约出现一些毁灭性的漏洞,本节将对此进行讨论。
<h3 id="visibility-vuln">漏洞</h3>
函数的默认可见性是 `public`。因此,未指定任何可见性的函数将可被外部用户调用。当开发者错误地忽略了本应为 private(或仅可在合约内部调用)的函数的可见性说明符时,问题就出现了。
让我们快速看一个简单的例子。```solidity
contract HashForEther {
function withdrawWinnings() {
// Winner if the last 8 hex characters of the address are 0.
require(uint32(msg.sender) == 0);
_sendWinnings();
}
function _sendWinnings() {
msg.sender.transfer(this.balance);
}
}
这个简单的合约旨在充当一个地址猜测赏金游戏。要赢得合约的余额,用户必须生成一个以太坊地址,其最后 8 个十六进制字符为 0。一旦获得,他们就可以调用 WithdrawWinnings() 函数来获取赏金。
不幸的是,这些函数的可见性尚未被指定。特别是,_sendWinnings() 函数是 public 的,因此任何地址都可以调用该函数来窃取赏金。
始终指定合约中所有函数的可见性是一种良好的做法,即使它们是有意设为 public 的。最近版本的 Solidity 现在会在编译期间对未显式设置可见性的函数显示警告,以帮助鼓励这种做法。
在第一次 Parity 多重签名黑客攻击中,大约价值 $31M 的以太币主要从三个钱包中被盗。Haseeb Qureshi 在这篇文章中对具体发生过程做了很好的回顾。
本质上,多重签名钱包(可在这里找到)由一个基础 Wallet 合约构建而成,该合约调用一个包含核心功能的库合约(如现实世界示例:Parity 多重签名(第二次被黑)所述)。库合约包含用于初始化钱包的代码,如下面的代码片段所示```solidity
contract WalletLibrary is WalletEvents {
...
// METHODS
...
// constructor is given number of sigs required to do protected "onlymanyowners" transactions // as well as the selection of addresses capable of confirming them. function initMultiowned(address[] _owners, uint _required) { m_numOwners = _owners.length + 1; m_owners[1] = uint(msg.sender); m_ownerIndex[uint(msg.sender)] = 1; for (uint i = 0; i < _owners.length; ++i) { m_owners[2 + i] = uint(_owners[i]); m_ownerIndex[uint(_owners[i])] = 2 + i; } m_required = _required; }
...
// constructor - just pass on the owner array to the multiowned and // the limit to daylimit function initWallet(address[] _owners, uint _required, uint _daylimit) { initDaylimit(_daylimit); initMultiowned(_owners, _required); } }
请注意,这两个函数都没有显式指定可见性。两个函数都默认为 `public`。`initWallet()` 函数在钱包构造函数中被调用,并设置多签钱包的所有者,如 `initMultiowned()` 函数所示。由于这些函数被意外地保留为 `public`,攻击者能够对已部署的合约调用这些函数,将所有权重置为攻击者的地址。作为所有者,攻击者随后从钱包中窃取了所有以太币,总计 3100 万美元。
<h2 id="entropy"><span id="SP-6">6. 熵错觉</span></h2>
以太坊区块链上的所有交易都是确定性的状态转换操作。这意味着每笔交易都会修改以太坊生态系统的全局状态,并且以可计算、无不确定性的方式进行。这最终意味着区块链生态系统内部没有熵或随机性的来源。Solidity 中没有 `rand()` 函数。实现去中心化的熵(随机性)是一个公认的难题,人们已经提出了许多想法来解决这个问题(例如,参见 [RandDAO](https://github.com/randao/randao) 或使用 Vitalik 在这篇 [文章](https://vitalik.ca/files/randomness.html) 中描述的哈希链)。
<h3 id="entropy-vuln">漏洞</h3>
建立在以太坊平台上的第一批合约中,有些是基于赌博的。从根本上说,赌博需要不确定性(某种可下注的东西),这使得在区块链(一个确定性系统)上构建赌博系统变得相当困难。显然,这种不确定性必须来自区块链外部的来源。这在参与者之间的对赌中是可行的(例如,参见 [提交-揭露技术](https://ethereum.stackexchange.com/questions/191/how-can-i-securely-generate-a-random-number-in-my-smart-contract)),然而,如果你想实现一个充当 *庄家* 的合约(如二十一点或轮盘赌),就会困难得多。一个常见的陷阱是使用未来区块的变量,例如哈希、时间戳、区块号或 gas 上限。这些变量的问题在于它们由挖出该区块的矿工控制,因此并非真正的随机。例如,考虑一个轮盘赌智能合约,其逻辑是如果下一个区块哈希以偶数结尾,则返回黑色数字。矿工(或矿池)可以下注 100 万美元押黑色。如果他们解出下一个区块并发现哈希以奇数结尾,他们很乐意不发布该区块,而是继续挖另一个区块,直到找到一个区块哈希为偶数的解(假设区块奖励和手续费低于 100 万美元)。使用过去或现在的变量可能更具破坏性,正如 Martin Swende 在他出色的 [博客文章](http://martin.swende.se/blog/Breaking_the_house.html) 中所展示的那样。此外,仅使用区块变量意味着一个区块中的所有交易将得到相同的伪随机数,因此攻击者可以通过在一个区块内进行多笔交易来成倍增加收益(如果有最大投注额的话)。
<h3 id="entropy-prevention">预防技术</h3>
熵(随机性)的来源必须位于区块链之外。这可以通过参与者之间的系统来实现,例如 [提交-揭露](https://ethereum.stackexchange.com/questions/191/how-can-i-securely-generate-a-random-number-in-my-smart-contract),或者通过将信任模型改为一组参与者(例如 [RandDAO](https://github.com/randao/randao))。这也可以通过充当随机性预言机的中心化实体来完成。区块变量(总的来说,存在一些例外)不应被用作熵的来源,因为它们可能被矿工操纵。
<h3 id="entropy-example">现实世界示例:PRNG 合约</h3>
Arseny Reutov 在分析了 3649 个使用某种伪随机数生成器(PRNG)的活跃智能合约后,撰写了一篇 [博客文章](https://blog.positive.com/predicting-random-numbers-in-ethereum-smart-contracts-e5358c6b8620),并发现有 43 个合约可能被利用。
<h2 id="contract-reference"><span id="SP-7">7. 外部合约引用</span></h2>
以太坊 *全球计算机* 的优势之一是能够重用代码并与网络上已部署的合约进行交互。因此,大量合约会引用外部合约,并在常规操作中使用外部消息调用来与这些合约交互。这些外部消息调用可能以一些不显而易见的方式掩盖恶意行为者的意图,我们将对此进行讨论。
<h3 id="cr-vuln">漏洞</h3>
在 Solidity 中,任何地址都可以被强制转换为合约,无论该地址上的代码是否代表被转换的合约类型。这可能具有欺骗性,尤其是当合约作者试图隐藏恶意代码时。让我们用一个例子来说明这一点:
考虑一段粗略实现了 [Rot13](https://github.com/al1ex/soliditysecurity/blob/HEAD/www.wikipedia.com/rot13) 密码的代码。
`Rot13Encryption.sol`:```solidity
//encryption contract
contract Rot13Encryption {
event Result(string convertedString);
//rot13 encrypt a string
function rot13Encrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
//inline assembly to modify the string
assembly {
char := byte(0,char) // get the first byte
if and(gt(char,0x6D), lt(char,0x7B)) // if the character is in [n,z], i.e. wrapping.
{ char:= sub(0x60, sub(0x7A,char)) } // subtract from the ascii number a by the difference char is from z.
if iszero(eq(char, 0x20)) // ignore spaces
{mstore8(add(add(text,0x20), mul(i,1)), add(char,13))} // add 13 to char.
}
}
emit Result(text);
}
// rot13 decrypt a string
function rot13Decrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
assembly {
char := byte(0,char)
if and(gt(char,0x60), lt(char,0x6E))
{ char:= add(0x7B, sub(char,0x61)) }
if iszero(eq(char, 0x20))
{mstore8(add(add(text,0x20), mul(i,1)), sub(char,13))}
}
}
emit Result(text);
}
}
此代码仅接受一个字符串(字母 a-z,不进行验证),并通过将每个字符向右移动 13 位(在 'z' 处回绕)来对其进行 加密;例如,'a' 变为 'n','x' 变为 'k'。这里的汇编代码并不重要,因此如果现阶段看不懂也不必担心。
考虑以下使用此代码进行加密的合约,```solidity import "Rot13Encryption.sol";
// encrypt your top secret info contract EncryptionContract { // library for encryption Rot13Encryption encryptionLibrary;
// constructor - initialise the library
constructor(Rot13Encryption _encryptionLibrary) {
encryptionLibrary = _encryptionLibrary;
}
function encryptPrivateData(string privateInfo) {
// potentially do some operations here
encryptionLibrary.rot13Encrypt(privateInfo);
}
}
这个合约的问题在于 `encryptionLibrary` 地址不是公开的或常量。因此合约的部署者可能已在构造函数中提供了一个指向此合约的地址:```solidity
//encryption contract
contract Rot26Encryption {
event Result(string convertedString);
//rot13 encrypt a string
function rot13Encrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
//inline assembly to modify the string
assembly {
char := byte(0,char) // get the first byte
if and(gt(char,0x6D), lt(char,0x7B)) // if the character is in [n,z], i.e. wrapping.
{ char:= sub(0x60, sub(0x7A,char)) } // subtract from the ascii number a by the difference char is from z.
if iszero(eq(char, 0x20)) // ignore spaces
{mstore8(add(add(text,0x20), mul(i,1)), add(char,26))} // add 13 to char.
}
}
emit Result(text);
}
// rot13 decrypt a string
function rot13Decrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
assembly {
char := byte(0,char)
if and(gt(char,0x60), lt(char,0x6E))
{ char:= add(0x7B, sub(char,0x61)) }
if iszero(eq(char, 0x20))
{mstore8(add(add(text,0x20), mul(i,1)), sub(char,26))}
}
}
emit Result(text);
}
}
它实现了 rot26 密码(将每个字符移位 26 位,懂了吗?:p)。同样,无需理解此合约中的汇编代码。部署者本也可以链接以下合约:```solidity contract Print{ event Print(string text);
function rot13Encrypt(string text) public {
emit Print(text);
}
}
如果这些合约中的任何一个地址在构造函数中给出,`encryptPrivateData()` 函数只会生成一个打印未加密私有数据的事件。尽管在此示例中,一个类似库的合约在构造函数中被设置,但常见的情况是特权用户(如 `owner`)可以更改库合约地址。如果链接的合约不包含被调用的函数,则会执行回退函数。例如,对于代码行 `encryptionLibrary.rot13Encrypt()`,如果由 `encryptionLibrary` 指定的合约是:```solidity
contract Blank {
event Print(string text);
function () {
emit Print("Here");
//put malicious code here and it will run
}
}
then an event with the text "Here" would be emitted. Thus if users can alter contract libraries, they can in principle get users to unknowingly run arbitrary code.
注意:不要使用此类加密合约,因为智能合约的输入参数在区块链上是可见的。此外,Rot 密码也不是推荐的加密技术 :p
如上所示,无漏洞的合约(在某些情况下)也可能以恶意方式部署。审计师可以公开验证一个合约,并让其所有者以恶意方式部署它,从而导致一个公开审计的合约存在漏洞或恶意意图。
有多种技术可以防止这些情况发生。
一种技术是使用 new 关键字来创建合约。在上面的例子中,构造函数可以写成:```solidity
constructor() {
encryptionLibrary = new Rot13Encryption();
}
这样,部署时会创建所引用合约的一个实例,部署者无法在不修改智能合约的情况下将 `Rot13Encryption` 合约替换为任何其他内容。
另一种解决方案是,如果已知外部合约地址,则将其硬编码。
一般来说,调用外部合约的代码应始终被仔细检查。作为开发人员,在定义外部合约时,一个好做法是将合约地址设为公开(下面的蜜罐示例中并非如此),以使用户能够轻松检查合约引用了哪些代码。相反,如果合约将合约地址作为私有变量,这可能表明有人在实施恶意行为(如现实世界示例所示)。如果特权(或任何)用户能够更改用于调用外部函数的合约地址,那么在去中心化系统背景下,实施时间锁或投票机制可能很重要,以便用户能够看到哪些代码正在被更改,或让参与者有机会选择是否接受新的合约地址。
<h3 id="cr-example">现实世界示例:重入蜜罐</h3>
最近主网上发布了一些蜜罐。这些合约试图智取那些试图利用合约的以太坊黑客,但这些黑客反而最终会将自己期望利用的合约中的以太币丢失。其中一个示例采用了上述攻击方式,在构造函数中用恶意合约替换了预期合约。相关代码可在[这里](https://etherscan.io/address/0x95d34980095380851902ccd9a1fb4c813c2cb639#code)找到:```solidity
pragma solidity ^0.4.19;
contract Private_Bank
{
mapping (address => uint) public balances;
uint public MinDeposit = 1 ether;
Log TransferLog;
function Private_Bank(address _log)
{
TransferLog = Log(_log);
}
function Deposit()
public
payable
{
if(msg.value >= MinDeposit)
{
balances[msg.sender]+=msg.value;
TransferLog.AddMessage(msg.sender,msg.value,"Deposit");
}
}
function CashOut(uint _am)
{
if(_am<=balances[msg.sender])
{
if(msg.sender.call.value(_am)())
{
balances[msg.sender]-=_am;
TransferLog.AddMessage(msg.sender,_am,"CashOut");
}
}
}
function() public payable{}
}
contract Log
{
struct Message
{
address Sender;
string Data;
uint Val;
uint Time;
}
Message[] public History;
Message LastMsg;
function AddMessage(address _adr,uint _val,string _data)
public
{
LastMsg.Sender = _adr;
LastMsg.Time = now;
LastMsg.Val = _val;
LastMsg.Data = _data;
History.push(LastMsg);
}
}
This post by one reddit user explains how they lost 1 ether to this contract by trying to exploit the re-entrancy bug they expected to be present in the contract.
一位 Reddit 用户在这篇帖子中讲述了他们如何试图利用预期存在于该合约中的重入漏洞,反而因此向该合约损失了 1 ether。
这种攻击并不是专门针对 Solidity 合约本身发起的,而是针对可能与其交互的第三方应用程序。我补充这个攻击是为了完整性,并让大家了解合约中的参数是如何被操纵的。
进一步阅读可参见 ERC20 短地址攻击解析、ICO 智能合约漏洞:短地址攻击或这篇 reddit 帖子。
当向智能合约传递参数时,参数会根据 ABI 规范 进行编码。可以发送比预期参数长度更短的已编码参数(例如,发送一个只有 38 个十六进制字符(19 字节)的地址,而不是标准的 40 个十六进制字符(20 字节))。在这种情况下,EVM 会在已编码参数的末尾填充 0 以达到预期的长度。
当第三方应用程序不验证输入时,这就成了一个问题。最明显的例子是,当用户请求提款时,交易所不会验证 ERC20 代币的地址。这个例子在上文提到的 Peter Venesses 的帖子《ERC20 短地址攻击解析》中有更详细的说明。
考虑标准的 ERC20 转账函数接口,注意参数的顺序,```solidity function transfer(address to, uint tokens) public returns (bool success);
现在考虑一个交易所,持有大量某种代币(比如 `REP`),而用户希望提取其份额中的 100 个代币。用户会提交其地址 `0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead` 和代币数量 `100`。交易所会按照 `transfer()` 函数指定的顺序编码这些参数,即先 `address` 后 `tokens`。编码结果为 `a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000` `000000000000000000000000000000000056bc75e2d63100000`。前四个字节(`a9059cbb`)是 `transfer()` 的[函数签名/选择器](https://solidity.readthedocs.io/en/latest/abi-spec.html#function-selector),接着的 32 字节是地址,最后 32 字节表示 `uint256` 类型的代币数量。注意末尾的十六进制 `56bc75e2d63100000` 对应 100 个代币(按 `REP` 代币合约规定的 18 位小数)。
好的,现在让我们看看如果发送一个少了 1 字节(2 个十六进制位)的地址会发生什么。具体来说,假设攻击者发送 `0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde` 作为地址(少了最后两位),并同样请求提取 `100` 个代币。如果交易所没有验证该输入,它会被编码为 `a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde00000000000000` `00000000000000000000000000000000056bc75e2d6310000000`。区别很细微。注意,在编码末尾补上了 `00`,以弥补发送的地址缺少的部分。当这被发送到智能合约时,`address` 参数会被读取为 `0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00`,而值会被读取为 `56bc75e2d6310000000`(注意多了两个 `0`)。这个值现在是 `25600` 个代币(该值被乘以了 `256`)。在这个例子中,如果交易所持有这么多代币,用户将向被修改后的地址提取 `25600` 个代币(而交易所认为用户只提取了 `100`)。显然,在这个例子中攻击者不会拥有被修改后的地址,但如果攻击者生成任何以 `0` 结尾的地址(这很容易通过暴力破解得到)并使用该生成的地址,就可以轻松地从毫无防备的交易所窃取代币。
<h3 id="short-prev">预防技术</h3>
我想显而易见的是,在将所有输入发送到区块链之前验证所有输入,将能防止此类攻击。还应指出,参数排序在这里起着重要作用。由于填充只发生在末尾,因此在智能合约中仔细排列参数可以潜在地减轻这种攻击的某些形式。
<h3 id="short-example">真实案例:未知</h3>
据我所知,目前还没有公开报道的此类攻击实例。
<h2 id="unchecked-calls"><span id="SP-9">9. 未检查的 CALL 返回值</span></h2>
在 solidity 中有多种执行外部调用的方式。向外部账户发送以太币通常通过 `transfer()` 方法执行。但是,`send()` 函数也可以使用,并且对于更通用的外部调用,可以在 solidity 中直接使用 `CALL` 操作码。`call()` 和 `send()` 函数返回一个布尔值,指示调用是成功还是失败。因此,这些函数有一个简单的注意事项:如果外部调用(由 `call()` 或 `send()` 发起)失败,执行这些函数的交易不会回滚,而是 `call()` 或 `send()` 只会返回 `false`。一个常见的陷阱是,当返回值未被检查时,开发者却期望发生回滚。
更多信息,请参阅 [DASP Top 10](http://www.dasp.co/#item-4) 和 [Scanning Live Ethereum Contracts for the "Unchecked-Send" Bug](http://hackingdistributed.com/2016/06/16/scanning-live-ethereum-contracts-for-bugs/)。
<h3 id="unchecked-calls-vuln">漏洞</h3>
考虑以下示例:```solidity
contract Lotto {
bool public payedOut = false;
address public winner;
uint public winAmount;
// ... extra functionality here
function sendToWinner() public {
require(!payedOut);
winner.send(winAmount);
payedOut = true;
}
function withdrawLeftOver() public {
require(payedOut);
msg.sender.send(this.balance);
}
}
该合约代表一个类似 Lotto 的合约,其中 winner 会收到 winAmount 数量的以太币,通常还会剩下一小部分供任何人提取。
该 bug 存在于第 [11] 行,其中使用了 send() 而没有检查返回值。在这个简单的例子中,如果 winner 的交易失败(要么是因为 gas 耗尽,要么是因为它是一个在 fallback 函数中故意抛异常的合约),则 payedOut 可以被设置为 true(无论以太币是否真的被发送)。在这种情况下,公众可以通过 withdrawLeftOver() 函数提取 winner 的奖金。
只要可能,应使用 transfer() 函数而不是 send(),因为如果外部交易回退,transfer() 会 revert。如果必须使用 send(),请始终确保检查返回值。
一个更健壮的建议是采用提款模式。在该方案中,每个用户需要调用一个独立的函数(即 withdraw 函数),该函数负责将以太币从合约中发送出去,从而独立处理发送交易失败的后果。其思路是,将外部发送功能与代码库的其他部分逻辑隔离,并把潜在的失败交易责任转移到调用 withdraw 函数的最终用户身上。
Etherpot 是一个智能合约彩票,与上述示例合约并无太大不同。etherpot 的 Solidity 代码可以在这里找到:lotto.sol。该合约的主要失败原因在于对区块哈希的错误使用(只有最后 256 个区块哈希可用,参见 Aakil Fernandes 的文章,了解 Etherpot 如何未能正确实现这一点)。然而,该合约还存在未检查调用值的问题。请注意 lotto.sol 第 [80] 行的 cash() 函数:```solidity
...
function cash(uint roundIndex, uint subpotIndex){
var subpotsCount = getSubpotsCount(roundIndex);
if(subpotIndex>=subpotsCount)
return;
var decisionBlockNumber = getDecisionBlockNumber(roundIndex,subpotIndex);
if(decisionBlockNumber>block.number)
return;
if(rounds[roundIndex].isCashed[subpotIndex])
return;
//Subpots can only be cashed once. This is to prevent double payouts
var winner = calculateWinner(roundIndex,subpotIndex);
var subpot = getSubpot(roundIndex);
winner.send(subpot);
rounds[roundIndex].isCashed[subpotIndex] = true;
//Mark the round as cashed
} ...
Notice that on line \[21\] the send function's return value is not checked, and the following line then sets a boolean indicating the winner has been sent their funds. This bug can allow a state where the winner does not receive their ether, but the state of the contract can indicate that the winner has already been paid.
注意,第 \[21\] 行中 send 函数的返回值没有被检查,紧接着的一行设置了一个布尔值,表示获胜者已收到资金。这个漏洞可能导致一种状态:获胜者没有收到以太币,但合约状态却显示获胜者已被支付。
A more serious version of this bug occurred in the [King of the Ether](https://www.kingoftheether.com/thrones/kingoftheether/index.html). An excellent [post-mortem](https://www.kingoftheether.com/postmortem.html) of this contract has been written which details how an unchecked failed `send()` could be used to attack the contract.
这个 bug 的一个更严重版本出现在 [King of the Ether](https://www.kingoftheether.com/thrones/kingoftheether/index.html) 中。有人为该合约撰写了一份出色的[事后分析](https://www.kingoftheether.com/postmortem.html),详细说明了如何利用未检查的失败 `send()` 来攻击该合约。
<h2 id="race-conditions"><span id="SP-10">10. 竞态条件 / 抢先交易</span></h2>
The combination of external calls to other contracts and the multi-user nature of the underlying blockchain gives rise to a variety of potential Solidity pitfalls whereby users *race* code execution to obtain unexpected states. [Re-Entrancy](#reentrancy) is one example of such a race condition. In this section we will talk more generally about different kinds of race conditions that can occur on the Ethereum blockchain. There is a variety of good posts on this subject, a few are: [Ethereum Wiki - Safety](https://github.com/ethereum/wiki/wiki/Safety#race-conditions), [DASP - Front-Running](http://www.dasp.co/#item-7) and the [Consensus - Smart Contract Best Practices](https://consensys.github.io/smart-contract-best-practices/known_attacks/#race-conditions).
外部调用其他合约与底层区块链的多用户特性相结合,会产生各种潜在的 Solidity 陷阱,使用户*竞争*代码执行以获得意外的状态。[重入](#reentrancy) 就是这种竞态条件的一个例子。在本节中,我们将更普遍地讨论以太坊区块链上可能发生的不同类型的竞态条件。关于这个主题有很多不错的文章,例如:[Ethereum Wiki - Safety](https://github.com/ethereum/wiki/wiki/Safety#race-conditions)、[DASP - Front-Running](http://www.dasp.co/#item-7) 和 [Consensus - Smart Contract Best Practices](https://consensys.github.io/smart-contract-best-practices/known_attacks/#race-conditions)。
<h3 id="race-conditions-vuln">漏洞</h3>
As with most blockchains, Ethereum nodes pool transactions and form them into blocks. The transactions are only considered valid once a miner has solved a consensus mechanism (currently [ETHASH](https://github.com/ethereum/wiki/wiki/Ethash) PoW for Ethereum). The miner who solves the block also chooses which transactions from the pool will be included in the block, this is typically ordered by the `gasPrice` of a transaction. In here lies a potential attack vector. An attacker can watch the transaction pool for transactions which may contain solutions to problems, modify or revoke the attacker's permissions or change a state in a contract which is undesirable for the attacker. The attacker can then get the data from this transaction and create a transaction of their own with a higher `gasPrice` and get their transaction included in a block before the original.
与大多数区块链一样,以太坊节点将交易汇集并打包成区块。只有在矿工解决了共识机制(目前以太坊使用 [ETHASH](https://github.com/ethereum/wiki/wiki/Ethash) PoW)之后,这些交易才会被视为有效。解决区块的矿工还会决定池中的哪些交易将被包含在区块中,这通常按交易的 `gasPrice` 排序。这里就存在一个潜在的攻击向量。攻击者可以监视交易池中的交易,这些交易可能包含问题的解决方案、修改或撤销攻击者的权限,或改变合约中对攻击者不利的状态。然后,攻击者可以获取该交易的数据,并以更高的 `gasPrice` 创建自己的交易,使自己的交易先于原始交易被包含在区块中。
Let's see how this could work with a simple example. Consider the contract `FindThisHash.sol`:
让我们通过一个简单的例子来看看这是如何运作的。考虑一下合约 `FindThisHash.sol`:```solidity
contract FindThisHash {
bytes32 constant public hash = 0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a;
constructor() public payable {} // load with ether
function solve(string solution) public {
// If you can find the pre image of the hash, receive 1000 ether
require(hash == sha3(solution));
msg.sender.transfer(1000 ether);
}
}
想象一下,该合约包含1000 ether。能够找到 sha3 哈希 0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a 原像的用户可以提交解并取回这1000 ether。假设某个用户发现解是 Ethereum!。他们以 Ethereum! 作为参数调用 solve()。不幸的是,攻击者足够聪明,一直监视着交易池,看是否有人提交解。他们看到这个解,验证其有效性,然后以比原始交易高得多的 gasPrice 提交一笔等效交易。解决该区块的矿工很可能会因更高的 gasPrice 而优先选择攻击者的交易,并在原始求解者之前接受它。攻击者将拿走这1000 ether,而解决问题的用户将一无所获(合约中已不剩任何 ether)。
一个更现实的问题出现在未来 Casper 实现的设计中。Casper 权益证明合约会触发削减条件,即发现验证者双重投票或行为不当的用户会被激励提交相关证明。验证者将受到惩罚,而用户获得奖励。在这种场景下,矿工和用户预计都会抢先提交所有此类证明,这个问题必须在最终版本发布前解决。
有两类用户可以执行这类抢跑攻击:用户(可以修改其交易的 gasPrice)和矿工自身(可以按自己的意愿重新排列区块中的交易)。容易受到第一类(用户)攻击的合约,其境况比容易受到第二类(矿工)攻击的合约要糟糕得多,因为矿工只有在打包出一个区块时才能执行攻击,而对于任何针对特定区块的单个矿工来说,这不太可能。下面我将列出一些缓解措施,以及它们可能阻止哪一类攻击者。
一种可以采用的方法是在合约中创建逻辑,对 gasPrice 设置上限。这可以防止用户提高 gasPrice 并在超出上限的情况下获得优先的交易排序。这种预防措施只能缓解第一类攻击者(任意用户)。在这种情况下,矿工仍然可以攻击合约,因为他们可以按照自己的喜好排列区块中的交易,无论 gas 价格如何。
更稳健的方法是在可能的情况下使用提交-揭示方案。这种方案要求用户发送包含隐藏信息(通常是哈希)的交易。在交易被打包进区块后,用户再发送一笔交易来揭示之前发送的数据(揭示阶段)。这种方法可以防止矿工和用户抢跑交易,因为他们无法确定交易内容。然而,这种方法无法隐藏交易金额(在某些情况下,这正是需要隐藏的有价值信息)。ENS 智能合约允许用户发送交易,其提交的数据包含他们愿意花费的 ether 数量。用户随后可以发送任意金额的交易。在揭示阶段,用户将获得交易中发送金额与他们愿意花费金额之间差额的退款。
Lorenz、Phil、Ari 和 Florian 提出的进一步建议是使用Submarine Sends。这一想法的高效实现需要 CREATE2 操作码,该操作码目前尚未被采用,但似乎很可能在即将到来的硬分叉中被采用。
ERC20 标准在以太坊上构建代币方面相当知名。该标准存在一个潜在的抢跑漏洞,该漏洞源于 approve() 函数。关于此漏洞的详细解释可以在这里找到。
该标准将 approve() 函数定义为:```solidity
function approve(address _spender, uint256 _value) returns (bool success)
此函数允许用户授权其他用户代其转移代币。抢跑(frontrunning)漏洞出现在以下场景:用户 Alice *批准*她的朋友 `Bob` 花费 `100 tokens`。Alice 后来决定撤销 `Bob` 花费 `100 tokens` 的授权,于是她创建了一笔交易,将 `Bob` 的额度设置为 `50 tokens`。一直在仔细监视链上情况的 `Bob` 看到了这笔交易,并构建了自己的一笔花费 `100 tokens` 的交易。他为自己的交易设置了比 Alice 更高的 `gasPrice`,使自己的交易优先于她的交易被处理。某些 `approve()` 实现会允许 `Bob` 先转移他的 `100 tokens`,然后当 Alice 的交易被提交时,将 `Bob` 的授权重置为 `50 tokens`,这实际上让 `Bob` 可以使用 `150 tokens`。此攻击的缓解策略见上面链接文档中的[此处](https://docs.google.com/document/d/1YLPtQxZu1UAvO9cZ1O2RPXBbT0mooh4DYKjA_jp-RLM/edit)。
另一个突出的真实世界例子是 [Bancor](https://www.bancor.network/)。Ivan Bogatty 和他的团队记录了对初始 Bancor 实现的一次可盈利攻击。他的[博客文章](https://hackernoon.com/front-running-bancor-in-150-lines-of-python-with-ethereum-api-d5e2bfd0d798)和 [Devon 3 演讲](https://www.youtube.com/watch?v=RL2nE3huNiI)详细讨论了这是如何做到的。本质上,代币的价格是根据交易价值确定的,用户可以监视交易池中的 Bancor 交易,并对其抢跑,从而从价格差异中获利。Bancor 团队已经解决了这一攻击。
<h2 id="dos"><span id="SP-11">11. 拒绝服务 (DOS)</span></h2>
这个类别非常广泛,但基本上由以下攻击组成:用户可以让合约在短时间内无法运行,或者在有些情况下永久无法运行。这可能会将以太币永久困在这些合约中,正如 [第二次 Parity MultiSig 攻击事件](#dc-example) 中的情况一样。
<h3 id="dos-vuln">漏洞</h3>
合约可能变得无法运行的方式有很多种。这里我只重点介绍一些潜在的、不太明显的、具有区块链微妙特性的 Solidity 编码模式,它们可能导致攻击者实施 DOS 攻击。
**1. 无 gas 津贴的外部调用** - 可能会出现这种情况:你希望
对一个未知合约进行外部调用,并继续处理
交易,无论该调用是否失败。通常,这是
通过使用 `CALL` 操作码实现的,该操作码在调用失败时不会回滚交易
(更多细节和示例请参阅 [未检查的 CALL 返回值](#unchecked-calls))。
让我们考虑一个简单的例子:我们有一个合约钱包,它会在
调用 `withdraw()` 函数时缓慢地流出以太币。`partner` 可以
添加自己的地址并花费 gas 来调用 withdraw 函数,从而使
`partner` 和 `owner` 各自获得合约总余额的 1%。```solidity
contract TrickleWallet {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = 0xA9E;
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
require(partner == '0x0' || msg.sender == partner);
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint amountToSend = address(this).balance/100;
// perform a call without checking return
// the recipient can revert, the owner will still get their share
partner.call.value(amountToSend)();
owner.transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = now;
withdrawPartnerBalances[partner] += amountToSend;
}
// allow deposit of funds
function() payable {}
// convenience function
function contractBalance() view returns (uint) {
return address(this).balance;
}
}
请注意,在第 [17] 行,我们执行了一次外部调用,将 1% 的
合约余额 发送给用户指定的账户。使用 CALL 操作码的原因是为了确保
即使外部调用回滚,所有者仍能获得支付。问题在于,
交易将把所有 gas(实际上,只有大部分交易 gas 被发送,剩余部分用于完成调用的处理)发送给外部调用。如果用户是恶意的,他们可以创建一个消耗所有 gas 的合约,并迫使所有 withdraw() 交易因 gas 耗尽而失败。
例如,考虑以下消耗所有 gas 的恶意合约,```solidity contract ConsumeAllGas { function () payable { // an assert consumes all transaction gas, unlike a //revert which returns the remaining gas assert(1==2); } }
如果提款合作方决定不喜欢合约的所有者。
他们可以将合作方地址设置为该合约,从而将所有资金锁定在
`TrickleWallet` 合约中,永远无法取出。
为防止此类拒绝服务(DOS)攻击向量,请确保在外部调用中指定 gas 补贴,
以限制该交易可使用的 gas 量。在我们的
示例中,可以通过将第 \[17\] 行改为以下内容来修复此攻击:```solidity
partner.call.gas(50000).value(amountToSend)();
此修改仅允许在外部交易上花费 50,000 gas。owner 可以设置高于此值的 gas 价格,以使其交易完成,无论外部交易使用了多少 gas。
2. 遍历外部操控的映射或数组 - 在我的探索中,我见过多种形式的此类模式。通常出现在 owner 希望向投资者分发代币的场景中,并通过类似 distribute() 的函数来实现,如示例合约所示:```solidity
contract DistributeTokens {
address public owner; // gets set somewhere
address[] investors; // array of investors
uint[] investorTokens; // the amount of tokens each investor gets
// ... extra functionality, including transfertoken()
function invest() public payable {
investors.push(msg.sender);
investorTokens.push(msg.value * 5); // 5 times the wei sent
}
function distribute() public {
require(msg.sender == owner); // only owner
for(uint i = 0; i < investors.length; i++) {
// here transferToken(to,amount) transfers "amount" of tokens to the address "to"
transferToken(investors[i],investorTokens[i]);
}
}
}
请注意,此合约中的循环遍历一个可以被人为膨胀的数组。攻击者可以创建大量用户账户,使 `investor` 数组变得很大。原则上,这可能导致执行 for 循环所需的 gas 超过区块 gas 上限,从而使 `distribute()` 函数基本无法运行。
**3. 所有者操作** - 另一种常见模式是,所有者在合约中拥有特定权限,并且必须执行某些任务,合约才能进入下一个状态。一个例子是 ICO 合约,它要求所有者 `finalize()` 该合约,之后代币才可转让,即``` solidity
bool public isFinalized = false;
address public owner; // gets set somewhere
function finalize() public {
require(msg.sender == owner);
isFinalized == true;
}
// ... extra ICO functionality
// overloaded transfer function
function transfer(address _to, uint _value) returns (bool) {
require(isFinalized);
super.transfer(_to,_value)
}
...
在这种情况下,如果特权用户丢失了其私钥或变得不活跃,整个代币合约将无法运作。在这种情况下,如果 owner 无法调用 finalize(),则任何代币都无法被转移;也就是说,整个代币生态系统的运作完全依赖于单个地址。
4. 基于外部调用的状态进展 —— 合约有时会被编写成这样的形式:为了进入新状态,需要向某个地址发送以太币,或者等待来自外部源的某些输入。这些模式可能导致 DOS 攻击,当外部调用失败或由于外部原因而被阻止时。在发送以太币的例子中,用户可能会创建一个不接受以太币的合约。如果合约为了进入新状态而要求先提取以太币(考虑一个时间锁合约,要求所有以太币先被提取才能再次使用),那么该合约将永远无法达到新状态,因为以太币永远无法被发送到那个不接受以太币的用户合约。
在第一个示例中,合约不应循环遍历可被外部用户人为操纵的数据结构。建议采用提款模式,即每个投资者各自调用一个提款函数来独立领取其代币。
在第二个示例中,需要特权用户来改变合约状态。在此类示例中(只要可能),可以设置一个故障保护机制,以防 owner 丧失行为能力。一种解决方案是将 owner 设置为多签合约。另一种解决方案是使用时间锁,其中第 [13] 行的 require 可以包含一种基于时间的机制,例如 require(msg.sender == owner || now > unlockTime),这允许任何用户在由 unlockTime 指定的一段时间后完成 finalize。这种缓解技术也可用于第三个示例。如果进入新状态需要外部调用,请考虑这些调用可能失败的情况,并在期望的调用始终未到时,加入基于时间的状态推进机制。
注意:当然,对于这些建议也存在集中式替代方案,例如添加一个 maintenanceUser,他可以在需要时修复基于 DOS 的攻击向量问题。通常,这类合约会存在对此类实体权力的信任问题,但这不属于本节讨论的范畴。
GovernMental 是一个古老的庞氏骗局,积累了相当多的以太币。事实上,它一度积累了 1100 个以太币。不幸的是,它容易受到本节中提到的 DOS 漏洞的影响。这篇 Reddit 帖子 描述了该合约如何要求删除一个大型映射才能提取以太币。删除该映射的 gas 成本超过了当时的区块 gas 上限,因此无法提取这 1100 个以太币。合约地址是 0xF45717552f12Ef7cb65e95476F217Ea008167Ae3,您可以从交易 0x0d80d67202bd9cb6773df8dd2020e7190a1b0793e8ec4fc105257e8128f0506b 中看到,这 1100 个以太币最终是通过一笔消耗 250 万 gas 的交易获得的(当时区块 gas 上限已允许此类交易)。
区块时间戳历来被用于各种应用,例如作为随机数的熵源(详见熵幻觉一节)、在特定时间段内锁定资金,以及各种依赖于时间的、改变状态的条件语句。矿工有能力对时间戳进行轻微调整,如果在智能合约中错误地使用区块时间戳,这可能会非常危险。
一些有用的参考资料:Solidity 文档、这个 Stack Exchange 问题。
block.timestamp 或其别名 now 可能会被矿工操纵,如果他们有这样的动机的话。让我们构建一个简单的游戏,这个游戏容易受到矿工利用的影响,
roulette.sol:```solidity
contract Roulette {
uint public pastBlockTime; // Forces one bet per block
constructor() public payable {} // initially fund contract
// fallback function used to make a bet
function () public payable {
require(msg.value == 10 ether); // must send 10 ether to play
require(now != pastBlockTime); // only 1 transaction per block
pastBlockTime = now;
if(now % 15 == 0) { // winner
msg.sender.transfer(this.balance);
}
}
}
此合约的行为就像一个简单的彩票。每个区块只能有一笔交易下注 `10 ether`,以有机会赢得合约的余额。这里的假设是,`block.timestamp` 的最后两位数字是均匀分布的。如果是这样,那么赢得这个彩票的概率将是 1/15。
然而,众所周知,矿工可以在需要时调整时间戳。在这种特定情况下,如果合约中累积了足够的以太币,解决区块的矿工就有动力选择一个时间戳,使 `block.timestamp` 或 `now` 模 15 等于 `0`。这样做的话,他们可以赢得锁定在该合约中的以太币以及区块奖励。由于每个区块只允许一个人下注,这也容易受到[抢先交易](#race-conditions)攻击。
在实践中,区块时间戳是单调递增的,因此矿工不能选择任意的区块时间戳(它们必须大于前一个区块的时间戳)。他们也不能将区块时间设置得太远,因为这些区块很可能会被网络拒绝(节点不会验证时间戳位于未来的区块)。
<h3 id="block-timestamp-prev">预防技术</h3>
区块时间戳不应用于熵或生成随机数——也就是说,它们不应成为赢得游戏或改变重要状态的决定因素(无论是直接还是通过某种推导,如果假定其为随机)。
有时需要时间敏感的逻辑;例如解锁合约(时间锁定)、在几周后完成 ICO 或强制实施到期日期。有时建议使用 `block.number`(参见 [Solidity 文档](http://solidity.readthedocs.io/en/latest/units-and-global-variables.html#block-and-transaction-properties))和平均区块时间来估算时间;例如,在 `10 second` 的区块时间下,`1 week` 大约相当于 `60480 blocks`。因此,指定一个区块编号来改变合约状态可能更安全,因为矿工不能轻易操纵区块编号。[BAT ICO](https://etherscan.io/address/0x0d8775f648430679a709e98d2b0cb6250d2887ef#code) 合约就采用了这一策略。
如果合约并不过分担心矿工对区块时间戳的操纵,这可能不是必需的,但在开发合约时需要注意这一点。
<h3 id="block-timestamp-example">真实世界示例:GovernMental</h3>
[GovernMental](http://governmental.github.io/GovernMental/) 是一个古老的庞氏骗局,累积了相当多的以太币。它也容易受到基于时间戳的攻击。该合约会向在一轮中最后加入(至少持续一分钟)的玩家支付奖励。因此,作为玩家的矿工可以调整时间戳(调整为未来的时间,使其看起来像已经过了一分钟),从而让该玩家看起来像是最后加入并持续了一分钟以上(尽管实际上并非如此)。更多细节可以在 Tanya Bahrynovska 撰写的 [以太坊安全漏洞历史文章](https://applicature.com/blog/history-of-ethereum-security-vulnerabilities-hacks-and-their-fixes) 中找到。
<h2 id="constructors"><span id="SP-13">13. 小心构造函数</span></h2>
构造函数是特殊的函数,在初始化合约时通常执行关键的特权任务。在 solidity `v0.4.22` 之前,构造函数被定义为与包含它们的合约同名的函数。因此,当合约名称在开发过程中发生变化时,如果构造函数名称没有改变,它就会变成一个普通的、可调用的函数。可以想象,这可能导致(并且已经导致)一些有趣的合约攻击。
为了进一步阅读,我建议读者尝试 [Ethernaught 挑战](https://github.com/OpenZeppelin/ethernaut)(特别是 Fallout 关卡)。
<h3 id="constructors-vuln">漏洞</h3>
如果合约名称被修改,或者构造函数名称出现拼写错误,使其不再与合约名称匹配,构造函数就会像普通函数一样运行。这可能导致严重后果,尤其是当构造函数在执行特权操作时。考虑以下合约```solidity
contract OwnerWallet {
address public owner;
//constructor
function ownerWallet(address _owner) public {
owner = _owner;
}
// fallback. Collect ether.
function () payable {}
function withdraw() public {
require(msg.sender == owner);
msg.sender.transfer(this.balance);
}
}
该合约收集以太币,并且只允许所有者通过调用 withdraw() 函数提取所有以太币。问题源于构造函数并未与合约完全同名。具体来说,ownerWallet 与 OwnerWallet 并不相同。因此,任何用户都可以调用 ownerWallet() 函数,将自己设置为所有者,然后通过调用 withdraw() 取走合约中的所有以太币。
此问题已在 Solidity 编译器版本 0.4.22 中得到基本解决。该版本引入了 constructor 关键字来指定构造函数,而不再要求函数名与合约名匹配。建议使用此关键字来指定构造函数,以防止上述命名问题。
Rubixi(合约代码)是另一个表现出此类漏洞的庞氏骗局。它最初被称为 DynamicPyramid,但在部署前合约名称被更改为 Rubixi。构造函数名称没有更改,导致任何用户都可以成为 creator。关于此错误的一些有趣讨论可以在这个比特币论坛主题中找到。最终,它允许用户争夺 creator 身份,以从该庞氏骗局中索取费用。有关此特定错误的更多详细信息可参见此处。
EVM 将数据存储为 storage 或 memory。在开发合约时,强烈建议准确理解其工作方式以及函数局部变量的默认类型。因为不恰当地初始化变量可能会产生存在漏洞的合约。
要了解有关 EVM 中 storage 和 memory 的更多信息,请参阅 Solidity 文档:数据位置、Solidity 文档:存储中状态变量的布局、Solidity 文档:内存布局。
本节基于 Stefan Beyer 的杰出文章。关于此主题的进一步阅读可参见 Sefan 的灵感来源,即这个 reddit 帖子。
函数内的局部变量根据其类型默认为 storage 或 memory。未初始化的局部 storage 变量可能指向合约中其他意外的存储变量,从而导致有意(即开发者故意将它们放在那里以便以后攻击)或无意地引入漏洞。
让我们考虑以下相对简单的名称注册合约:```solidity // A Locked Name Registrar contract NameRegistrar {
bool public unlocked = false; // registrar locked, no name updates
struct NameRecord { // map hashes to addresses
bytes32 name;
address mappedAddress;
}
mapping(address => NameRecord) public registeredNameRecord; // records who registered names
mapping(bytes32 => address) public resolve; // resolves hashes to addresses
function register(bytes32 _name, address _mappedAddress) public {
// set up the new NameRecord
NameRecord newRecord;
newRecord.name = _name;
newRecord.mappedAddress = _mappedAddress;
resolve[_name] = _mappedAddress;
registeredNameRecord[msg.sender] = newRecord;
require(unlocked); // only allow registrations if contract is unlocked
}
}
这个简单的名称注册合约只有一个功能。当合约处于 `unlocked` 状态时,它允许任何人注册一个名称(以 `bytes32` 哈希形式)并将该名称映射到一个地址。不幸的是,这个注册合约最初是锁定的,第 \[23\] 行的 `require` 阻止了 `register()` 添加名称记录。然而,这个合约中存在一个漏洞,它允许在无论 `unlocked` 变量为何值的情况下进行名称注册。
要讨论这个漏洞,首先我们需要理解 Solidity 中存储(storage)的工作原理。作为一个高层概述(不包含任何适当的技术细节——我建议阅读 Solidity 文档以获得完整了解),状态变量按照它们在合约中出现的顺序依次存储在 *插槽(slots)* 中(它们可以被分组存储,但在本例中不会,所以我们不必担心这一点)。因此,`unlocked` 存在于 `slot 0`,`registeredNameRecord` 存在于 `slot 1`,`resolve` 存在于 `slot 2`,依此类推。每个插槽的大小为 32 字节(映射(mappings)还有额外的复杂性,我们暂时忽略)。布尔值 `unlocked` 在 `false` 时看起来像 `0x000...0`(64 个 `0`,不包括 `0x`),在 `true` 时像 `0x000...1`(63 个 `0`)。如你所见,在这个特定示例中,存储空间存在显著的浪费。
我们需要的下一条信息是,Solidity 在将复杂数据类型(如 `structs`)初始化为局部变量时,默认将其存储位置设为 `storage`。因此,第 \[16\] 行的 `newRecord` 默认为 `storage`。该漏洞是由 `newRecord` 未被初始化这一事实引起的。由于它默认为 storage,它变成了一个指向 storage 的指针,而又因为它未被初始化,它指向 slot `0`(即 `unlocked` 存储的位置)。注意,在第 \[17\] 和 \[18\] 行,我们将 `nameRecord.name` 设置为 `_name`,将 `nameRecord.mappedAddress` 设置为 `_mappedAddress`,这实际上改变了 slot 0 和 slot 1 的存储位置,从而同时修改了 `unlocked` 以及与 `registeredNameRecord` 关联的存储插槽。
这意味着,只需通过 `register()` 函数的 `bytes32 _name` 参数,就可以直接修改 `unlocked`。因此,如果 `_name` 的最后一个字节非零,它将修改存储 `slot 0` 的最后一个字节,并直接将 `unlocked` 更改为 `true`。这样的 `_name` 值将通过第 \[23\] 行的 `require()`,因为我们正在将 `unlocked` 设置为 `true`。可以在 Remix 中尝试一下。注意,如果你使用的 `_name` 形式为:`0x0000000000000000000000000000000000000000000000000000000000000001`,该函数将顺利通过。
<h3 id="storage-prev">预防技术</h3>
Solidity 编译器会将未初始化的存储变量作为警告抛出,因此开发人员在构建智能合约时应密切关注这些警告。当前版本的 mist(0.10)不允许编译这些合约。在处理复杂类型时,显式使用 `memory` 或 `storage` 关键字以确保其行为符合预期是一种良好实践。从 Solidity 版本 `0.5.0` 开始,`memory` 和 `storage` 的使用是强制性的。
<h3 id="storage-example">现实世界示例:蜜罐:OpenAddressLottery 和 CryptoRoulette</h3>
一个名为 OpenAddressLottery([合约代码](https://etherscan.io/address/0x741f1923974464efd0aa70e77800ba5d9ed18902#code))的蜜罐被部署,它利用这种未初始化存储变量的特性来从一些准黑客那里收集以太币。该合约相当深入,因此我将把讨论留给这个 [Reddit 帖子](https://www.reddit.com/r/ethdev/comments/7wp363/how_does_this_honeypot_work_it_seems_like_a/),那里对攻击方式进行了相当清晰的解释。
另一个蜜罐 CryptoRoulette([合约代码](https://etherscan.io/address/0x8685631276cfcf17a973d92f6dc11645e5158c0c#code))也利用了这一技巧来尝试收集一些以太币。如果你无法理解攻击是如何运作的,可以参阅 [对几个以太坊蜜罐合约的分析](https://medium.com/@jsanjuas/an-analysis-of-a-couple-ethereum-honeypot-contracts-5c07c95b0a8d),以了解该合约及其他合约的概览。
<h2 id="precision"><span id="SP-15">15. 浮点数和精度</span></h2>
截至撰写本文时(Solidity v0.4.24),不支持定点数或浮点数。这意味着浮点表示必须使用 Solidity 中的整数类型来实现。如果实现不正确,这可能导致错误或漏洞。
如需进一步阅读,请参阅 [以太坊合约安全技术与技巧——整数除法中的舍入问题](https://github.com/ethereum/wiki/wiki/Safety#beware-rounding-with-integer-division),
<h3 id="precision-vuln">漏洞</h3>
由于 Solidity 中没有定点类型,开发人员需要使用标准整数数据类型自行实现。在这个过程中,开发人员可能会遇到许多陷阱。我将尝试在本节中强调其中的一些。
让我们从一个代码示例开始(为简单起见,我们忽略任何上溢/下溢问题)。```solidity
contract FunWithNumbers {
uint constant public tokensPerEth = 10;
uint constant public weiPerEth = 1e18;
mapping(address => uint) public balances;
function buyTokens() public payable {
uint tokens = msg.value/weiPerEth*tokensPerEth; // convert wei to eth, then multiply by token rate
balances[msg.sender] += tokens;
}
function sellTokens(uint tokens) public {
require(balances[msg.sender] >= tokens);
uint eth = tokens/tokensPerEth;
balances[msg.sender] -= tokens;
msg.sender.transfer(eth*weiPerEth); //
}
}
这个简单的代币买卖合约在代币的买入和卖出中存在一些明显的问题。尽管买卖代币的数学计算是正确的,但缺乏浮点数会导致错误的结果。例如,在第 [7] 行购买代币时,如果金额小于 1 ether,初始除法将得到 0,最终乘法结果也为 0(即 200 wei 除以 1e18 weiPerEth 等于 0)。同样,在卖出代币时,任何小于 10 的代币也会得到 0 ether。实际上,这里的舍入总是向下取整,因此卖出 29 tokens 将得到 2 ether。
这个合约的问题在于精度只能精确到 ether(即 1e18 wei)。当处理具有更高精度需求的 ERC20 代币中的 decimals 时,这有时会变得很棘手。
在智能合约中保持正确的精度非常重要,尤其是在处理反映经济决策的比率和汇率时。
你应该确保所使用的任何比率或汇率都允许分数中存在较大的分子。例如,我们在示例中使用了 tokensPerEth 比率。更好的做法是使用 weiPerTokens,这将是一个大数字。为了求解代币数量,我们可以使用 msg.value/weiPerTokens。这会给出更精确的结果。
另一个要记住的策略是注意运算顺序。在上面的示例中,购买代币的计算是 msg.value/weiPerEth*tokenPerEth。注意除法发生在乘法之前。如果计算先执行乘法再执行除法,即 msg.value*tokenPerEth/weiPerEth,这个示例将获得更高的精度。
最后,在定义任意精度时,一个好做法是将变量转换为更高精度,执行所有数学运算,然后在需要时再转换回输出所需的精度。通常使用 uint256(因为它对 gas 使用最优化),其范围约为 60 个数量级,其中一部分可以专门用于数学运算的精度。可能更好的做法是,在 Solidity 中保留所有变量为高精度,并在外部应用中转换回低精度(这本质上就是 ERC20 Token 合约中 decimals 变量的工作方式)。要了解如何实现这一点以及相关的库,我建议查看 Maker DAO DSMath。他们使用了一些奇特的命名,WAD 和 RAY,但这个概念很有用。
我找不到一个很好的例子来说明舍入在合约中造成了严重问题,但我相信外面肯定有很多这样的例子。如果你想到一个好的例子,欢迎更新这一部分。
由于缺乏好的例子,我想请你注意 Ethstick,主要是因为我很喜欢这个合约里那些很酷的命名。这个合约没有使用任何扩展精度,但它处理的是 wei。因此这个合约会有舍入问题,但仅限于 wei 精度级别。它还有一些更严重的缺陷,但这些缺陷与在区块链上获取熵的困难有关(参见 熵幻觉)。关于 Ethstick 合约的进一步讨论,我推荐你阅读 Peter Venesses 的另一篇文章,以太坊合约将成为黑客的糖果。
Solidity 有一个全局变量 tx.origin,它会遍历整个调用栈,并返回最初发送调用(或交易)的账户地址。在智能合约中使用该变量进行身份验证会使合约容易受到类似网络钓鱼的攻击。
如需进一步阅读,请参阅 Stack Exchange 问题、Peter Venesses 的博客 和 Solidity - Tx.Origin 攻击。
使用 tx.origin 变量授权用户的合约通常容易受到网络钓鱼攻击,这些攻击可以诱骗用户在易受攻击的合约上执行已认证的操作。
考虑这个简单的合约,```solidity contract Phishable { address public owner;
constructor (address _owner) {
owner = _owner;
}
function () public payable {} // collect ether
function withdrawAll(address _recipient) public {
require(tx.origin == owner);
_recipient.transfer(this.balance);
}
}
注意,在第 \[11\] 行,该合约使用 `tx.origin` 授权了 `withdrawAll()` 函数。该合约允许攻击者创建一个如下形式的攻击合约:```solidity
import "Phishable.sol";
contract AttackContract {
Phishable phishableContract;
address attacker; // The attackers address to receive funds.
constructor (Phishable _phishableContract, address _attackerAddress) {
phishableContract = _phishableContract;
attacker = _attackerAddress;
}
function () payable {
phishableContract.withdrawAll(attacker);
}
}
要利用此合约,攻击者会部署它,然后说服 Phishable 合约的所有者向该合约发送一些以太币。攻击者可能将该合约伪装成自己的私人地址,并通过社会工程学诱使受害者向该地址发送某种形式的交易。除非受害者非常小心,否则可能不会注意到攻击者地址处存在代码,或者攻击者可能将其冒充为多重签名钱包或某种高级存储钱包(请记住,
公共合约的源代码默认不可用)。
无论如何,如果受害者向 AttackContract 地址发送一笔交易(附带了足够的 gas),该交易将触发回退函数,该函数进而调用 Phishable 合约的 withdrawAll() 函数,并传入参数 attacker。这将导致 Phishable 合约中的所有资金被提取到 attacker 地址。这是因为最初发起调用的是受害者(即 Phishable 合约的 owner)。因此,tx.origin 将等于 owner,而 Phishable 合约第 [11] 行的 require 检查将得以通过。
tx.origin 不应用于智能合约中的授权。这并不是说 tx.origin 变量永远不应被使用。在智能合约中,它确实有一些合法的使用场景。例如,如果希望拒绝外部合约调用当前合约,可以实现一个形如 require(tx.origin == msg.sender) 的 require 检查。这样可以阻止中间合约被用来调用当前合约,从而将合约限制为普通的无代码地址。
我不知道在现实中有任何公开的此类漏洞利用案例。
我打算用社区发现的各种有趣怪癖来充实本部分。之所以将这些内容保留在这篇博客中,是因为如果人们在实践中利用这些怪癖,它们可能有助于智能合约开发。
合约地址是确定性的,这意味着它们可以在实际创建地址之前被计算出来。对于创建合约的地址以及由合约再生成其他合约的情况都是如此。实际上,所创建合约的地址由以下方式决定:
keccak256(rlp.encode([<account_address>, <transaction_nonce>])
本质上,合约地址就是创建它的账户的 keccak256 哈希值,并与该账户的交易 nonce 连接而成2。对于合约而言也是如此,只不过合约的 nonce 从 1 开始,而地址的交易 nonce 从 0 开始。
这意味着,给定一个以太坊地址,我们可以计算出该地址可能生成的所有合约地址。例如,如果地址 0x123000...000 在其第 100 笔交易时创建了一个合约,它将生成合约地址 keccak256(rlp.encode[0x123...000, 100]),也就是 0xed4cafc88a13f5d58a163e61591b9385b6fe6d1a。
这一切意味着什么?这意味着你可以向一个预先确定的地址发送以太币(这个地址你并不拥有其私钥,但你知道你的某个账户可以在该地址创建合约)。你可以将以太币发送到该地址,然后通过稍后创建合约(该合约会生成在同一地址)来取回这些以太币。构造函数可以用来返还你预先发送的所有以太币。因此,如果有人获得了你所有的以太坊私钥,攻击者将很难发现你的以太坊地址还可以访问这笔隐藏的以太币。事实上,如果攻击者的交易过多,以至于访问你的以太币所需的 nonce 已被使用,那么你的隐藏以太币将无法恢复。
让我用一个合约来澄清这一点。```solidity contract KeylessHiddenEthCreator { uint public currentContractNonce = 1; // keep track of this contracts nonce publicly (it's also found in the contracts state)
// determine future addresses which can hide ether.
function futureAddresses(uint8 nonce) public view returns (address) {
if(nonce == 0) {
return address(keccak256(0xd6, 0x94, this, 0x80));
}
return address(keccak256(0xd6, 0x94, this, nonce));
// need to implement rlp encoding properly for a full range of nonces
}
// increment the contract nonce or retrieve ether from a hidden/key-less account
// provided the nonce is correct
function retrieveHiddenEther(address beneficiary) public returns (address) {
currentContractNonce +=1;
return new RecoverContract(beneficiary);
}
function () payable {} // Allow ether transfers (helps for playing in remix)
}
contract RecoverContract { constructor(address beneficiary) { selfdestruct(beneficiary); // don't deploy code. Return the ether stored here to the beneficiary. } }
此合约允许你存储无密钥以太币(相对安全,因为你不会意外错过 nonce)[^3]。`futureAddresses()` 函数可用于通过指定 `nonce` 来计算此合约能够创建的前 127 个合约地址。如果你向其中一个地址发送以太币,之后可以通过多次调用 `retrieveHiddenEther()` 来恢复它。例如,如果你选择 `nonce=4`(并向关联地址发送以太币),则需要调用 `retrieveHiddenEther()` 四次,它才会将以太币恢复到 `beneficiary` 地址。
这也可以在没有合约的情况下完成。你可以向从你的标准以太坊账户之一派生出来的地址发送以太币,之后在正确的 nonce 下恢复它。但请注意,如果你意外超过了恢复以太币所需的交易 nonce,你的资金将永久丢失。
如需了解你可以利用此特性进行的更多高级操作,我建议阅读 [Martin Swende 的帖子](http://martin.swende.se/blog/Ethereum_quirks_and_vulns.html)。
<h3 id="one-time-addresses">一次性地址</h3>
以太坊交易签名使用椭圆曲线数字签名算法(ECDSA)。通常,为了在以太坊上发送经验证的交易,你会使用以太坊私钥对消息进行签名,从而授权从你的账户支出。更详细地说,你签名的消息是以太坊交易的组成部分,具体是 `to`、`value`、`gas`、`gasPrice`、`nonce` 和 `data` 字段。以太坊签名的结果是三个数字:`v`、`r` 和 `s`。我不会详细说明它们各自代表什么,而是请感兴趣的读者参阅 [ECDSA 维基页面](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm)(其中描述了 `r` 和 `s`)、[以太坊黄皮书](https://ethereum.github.io/yellowpaper/paper.pdf)(附录 F——描述了 `v`),以及 [EIP155](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md) 以了解当前 `v` 的用法。
因此我们知道,以太坊交易签名由一条消息以及 `v`、`r` 和 `s` 这三个数字组成。我们可以使用消息(即交易详情)、`r` 和 `s` 推导出一个以太坊地址,从而检查签名是否有效。如果推导出的以太坊地址与交易的 `from` 字段匹配,那么我们就知道 `r` 和 `s` 是由拥有(或能够访问)`from` 字段对应私钥的人创建的,因此签名是有效的。
现在考虑一下,我们并不拥有私钥,而是为一个任意交易捏造 `r` 和 `s` 的值。假设我们有一笔交易,参数如下:```javascript
{to: "0xa9e", value: 10e18, nonce: 0}
我忽略了其他参数。这笔交易将向 0xa9e 地址发送 10 ether。现在假设我们编造一些数字 r 和 s(这些数字有特定范围)以及一个 v。如果我们从这些编造的数字推导出以太坊地址,我们将得到一个随机的以太坊地址,我们称它为 0x54321。知道这个地址后,我们可以向 0x54321 地址发送 10 ether(而无需拥有该地址的私钥)。在未来的任何时候,我们都可以发送该交易,```javascript
{to: "0xa9e", value: 10e18, nonce: 0, from: "0x54321"}
along with the signature, i.e. the `v`, `r` and `s` we made up. This will be a valid transaction, because the derived address will match our `from` field. This allows us to spend our money from this random address (`0x54321`) to the address we chose `0xa9e`. Thus we have managed to store ether in an address that we do not have the private key and used a one-time transaction to recover the ether.
This quirk can also be used to send ether to a large number of people in a trustless manner, as Nick Johnson describes in [How to send Ether to 11,440 people](https://medium.com/@weka/how-to-send-ether-to-11-440-people-187e332566b7).
<h3 id="single-transaction-airdrops">单笔交易空投</h3>
空投是指将代币分发给大量
人群的过程。传统上,空投是通过大量交易处理的,
每笔交易更新单个或一批用户的余额。
这在以太坊区块链上可能成本高昂且负担沉重。
还有一种替代方法,可以通过单笔交易为许多用户的余额
充值代币。
这项技术由提出者 RicMoo 在他的文章中进行了更详细的解释:
[Merkle Air-Drops: Make Love, Not War](https://blog.ricmoo.com/merkle-air-drops-e6406945584d).
这个想法是创建一个 [Merkle Tree](https://en.wikipedia.org/wiki/Merkle_tree)
其中(作为叶节点)包含所有将被授予代币的用户的地址和余额。
这将在链下完成。Merkle 树可以公开
分发(同样是在链下)。然后可以创建一个智能合约,其中包含
Merkle 树的根哈希,允许用户提交 [merkle-proofs](https://www.quora.com/Cryptography-How-does-a-Merkle-proof-actually-work) 来获取
他们的代币。因此,一笔单一交易(用于创建合约,
或仅存储 Merkle 树根哈希的交易)就能让所有被授予的用户兑换
他们的空投代币。
RicMoo 在他的[帖子](https://blog.ricmoo.com/merkle-air-drops-e6406945584d)中还提供了一个可以接受 Merkle Proofs 的函数示例
并计入用户余额:```solidity
function redeem(uint256 index, address recipient,
uint256 amount, bytes32[] merkleProof) public {
// Make sure this has not been redeemed
uint256 redeemedBlock = _redeemed[index / 256];
uint256 redeemedMask = (uint256(1) << uint256(index % 256));
require((redeemedBlock & redeemedMask) == 0);
// Mark it as redeemed (if we fail, we revert)
_redeemed[index / 256] = redeemedBlock | redeemedMask;
// Compute the merkle root from the merkle proof
bytes32 node = keccak256(index, recipient, amount);
uint256 path = index;
for (uint16 i = 0; i < merkleProof.length; i++) {
if ((path & 0x01) == 1) {
node = keccak256(merkleProof[i], node);
} else {
node = keccak256(node, merkleProof[i]);
}
path /= 2;
}
// Check the resolved merkle proof matches our merkle root
require(node == _rootHash);
// Redeem!
_balances[recipient] += amount;
_totalSupply += amount;
Transfer(0, recipient, amount);
}
此函数可以内置到代币合约中,以便未来进行空投。 唯一需要对所有用户余额进行记账的交易, 就是设置 Merkle 树根的那笔交易。
10 ether9 etherAttack.sol - 第 [27] 行 - 回退函数随后再次调用EtherStore的withdrawFunds()函数,并“重新进入”EtherStore合约。
EtherStore.sol - 第 [11] 行 - 在第二次调用withdrawFunds()时,由于第 [18] 行尚未执行,我们的余额仍然是1 ether。因此,我们仍然有balances[0x0..123] = 1 ether。lastWithdrawTime变量也是如此。同样,我们通过了所有要求。
EtherStore.sol - 第 [17] 行 - 我们再次提取1 ether。
步骤 4-8 将重复 - 直到EtherStore.balance >= 1不再成立,如Attack.sol中第 [26] 行所规定。
Attack.sol - 第 [26] 行 - 一旦EtherStore合约中剩余的以太币少于1(或等于1),此if语句将失败。这将允许执行EtherStore合约的第 [18] 和 [19] 行(对应于每次调用withdrawFunds()函数)。
EtherStore.sol - 第 [18] 和 [19] 行 - balances和lastWithdrawTime映射将被更新,执行结束。
startfibonacciLibraryuintwithdraw()uint(fibonacciLibrary)calculatedFibNumber