要了解有关重入攻击的更多信息,请参阅 Gus Guimareas关于该主题的https://gus-tavo-guim.medium.com/reentrancy-attack-on-smart-contracts-how-to-identify-the-exploitable-and-an-example-of-an-attack-4470a2d8dfe4。
漏洞
当合约将以太币发送到未知地址时,就可能发生此类攻击。攻击者可以在外部地址精心构建一个合约,并在 fallback 函数中包含恶意代码。因此,当合约向该地址发送以太币时,它将调用恶意代码。通常,恶意代码会在易受攻击的合约上执行一个函数,执行开发者意想不到的操作。“重入”一词源于外部恶意合约调用易受攻击合约上的一个函数,代码执行路径会“重入”该函数。
EtherStore.sol中的简单易受攻击的合约,它充当以太坊保险库,允许存款人每周仅提取 1 个以太币。
例 1. EtherStore.sol
contract EtherStore {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() external 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 行,合约在此向用户发送其请求的以太币数量。假设攻击者在Attack.sol中创建了合约。
示例 2. Attack.sol
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// intialize the etherStore variable with the contract address
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function attackEtherStore() external 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);
}
}
}该漏洞是如何发生的?首先,攻击者会创建恶意合约(假设在地址0x0…123),并将EtherStore合约地址作为唯一的构造函数参数。这将初始化并将公共变量指向etherStore要攻击的合约。
然后,攻击者会调用该attackEtherStore函数,并持有一定数量的以太币(我们暂时假设大于或等于 1)1 ether。在本例中,我们还假设有许多其他用户已将以太币存入此合约,使其当前余额为10 ether 。 然后会发生以下情况:
- Attack.sol,第 15 行:合约
EtherStore的函数depositFunds将被调用,并且msg.value为1 ether和一些gas。发送方(msg.sender)将是恶意合约(0x0…123)。因此,balances[0x0..123] = 1 ether。 - Attack.sol,第 17 行:恶意合约随后将使用
1 ether作为参数 调用EtherStore合约的withdrawFunds函数。由于之前没有发生过withdrawFunds操作,这将满足所有要求(合约第 12-16 行 )。 - EtherStore.sol,第17行:合约将发
1 ether回给恶意合约。 - Attack.sol,第 25 行:向恶意合约的付款随后将执行fallback 函数。
- Attack.sol,第 26 行:
EtherStore合约的总余额原来是10 ether,现在是9 ether,因此这个 if 语句通过。 - Attack.sol,第 27 行:fallback 函数再次调用
EtherStore合约的withdrawFunds函数。 - EtherStore.sol,第 11 行:在第二次调用
withdrawFunds时,由于第 18 行尚未执行,攻击合约的余额仍然为1 ether。因此,我们仍然有balances[0x0..123] = 1 ether。同样,我们满足所有要求。 - EtherStore.sol,第 17 行:攻击合约withdraw 了另一个不属于自己的
1 ether。 - 重复步骤 4-8,直到不再出现这种情况,如Attack.sol
EtherStore.balance > 1中的第 26 行所示。 - Attack.sol,第 26 行:一旦
EtherStore合约中剩余 1 个(或更少)以太币,此if语句将失败。这将允许EtherStore执行合约的第 18 行和第 19 行。 - EtherStore.sol,第 18 和 19 行:将设置
balances和lastWithdrawTime映射,并且执行将结束。
最终结果是攻击者EtherStore在一次交易中从合约中提取了除 1 个以太币之外的所有以太币。
预防技术
有许多常用技术可以帮助避免智能合约中潜在的重入漏洞。首先,尽可能使用内置的 transfer 函数向外部合约发送 Ether。transfer 函数在外部调用时仅发送 2300 gas,这不足以让目标地址/合约调用另一个合约(即重新进入发送合约)。
第二种技巧是确保所有更改状态变量的逻辑都在以太币从合约中发出(或任何外部调用)之前发生。在本例中, EtherStore.solEtherStore的第 18 行和第 19 行 应该放在第 17 行之前。对于任何执行对未知地址的外部调用的代码,最好将其作为本地函数或代码片段执行的最后一个操作。这被称为checks-effects-interactions pattern.。
第三种技术是引入互斥锁,即添加一个状态变量,在代码执行期间锁定合约,防止重入调用。
将所有这些技术(使用全部三种技术是不必要的,但出于演示目的我们这样做)应用于EtherStore.sol,可以得到无重入合约:
contract EtherStore {
// initialize the mutex
bool reEntrancyMutex = false;
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() external 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;
}
}现实世界的例子:DAO
DAO(去中心化自治组织)攻击是以太坊早期发展过程中发生的重大黑客事件之一。当时,该合约持有超过 1.5 亿美元的资金。重入机制在此次攻击中扮演了重要角色,最终导致了硬分叉,从而诞生了以太坊经典 (ETC)。有关 DAO 漏洞的详细分析,请参阅 http://bit.ly/2EQaLCI。更多关于以太坊分叉历史、DAO 黑客攻击时间线以及 ETC 硬分叉诞生的信息,请参阅[ethereum_standards]。
{/collapse-item}
{collapse-item label="二、Arithmetic Over/Underflows" open}
以太坊虚拟机为整数指定了固定大小的数据类型。这意味着整数变量只能表示特定范围内的数字。uint8例如,只能存储 [0,255] 范围内的数字。尝试将 存储256到 uint8中会导致结果变为0。如果不小心,如果 Solidity 中的变量未经过检查,并且执行的计算结果超出了存储它们的数据类型的范围,则可能会被利用。
有关算术上溢/下溢的进一步阅读,请参阅“How to Secure Your Smart Contracts”, Ethereum Smart Contract Best Practices, and “Ethereum, Solidity and integer overflows: programming blockchains like 1970”.。
漏洞
当执行的操作需要固定大小的变量来存储超出变量数据类型范围的数字(或数据)时,就会发生上溢/下溢。
例如,从值0 (uint8,非负数)的变量减去1,结果为 255。这是下溢。我们分配了一个在uint8范围下限的数字,因此结果会循环并到可以存储的最大数字。同样,对2^8=256 进行加法运算将使变量保持不变,因为我们已经回绕了 的整个长度。这种行为的两个简单类比是汽车中的里程表,它测量行驶距离(在超过最大数字 999999 后,它们会重置为 000000)和周期性数学函数(在 sin 的幅角上加 2π 会使值保持不变)。
添加大于数据类型范围的数字称为溢出。为清楚起见,将当前值为的数字257添加到值为 0 的uint8中将导致数字变成 1。有时将固定大小的变量视为循环是有启发性的,如果我们添加的数字大于最大可能存储的数字,我们会从零开始重新计数,如果我们从零减去,则从最大数字开始倒数。对于可以表示负数的有符号类型int,一旦达到最大的负值,我们就重新开始;例如,如果我们尝试从值为-128的中减去1,我们将得到127。
这些数值陷阱允许攻击者滥用代码并创建意想不到的逻辑流。例如, TimeLock.sol中的 TimeLock 合约。
示例 3.TimeLock.sol
contract TimeLock {
mapping(address => uint) public balances;
mapping(address => uint) public lockTime;
function deposit() external 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);
}
}本合约的设计类似于一个时间保险库:用户可以将 ETH 存入合约,这些 ETH 将被锁定至少一周。用户可以选择将等待时间延长至一周以上,但一旦存入,用户可以确保他们的 ETH 至少被安全锁定一周。
如果用户被迫交出私钥,这样的合约或许能有效确保他们的以太币在短时间内无法获取。但如果用户锁定了100 ether该合约,并将私钥交给了攻击者,攻击者就可以利用溢出漏洞获取以太币,无论其是否在被锁定的时间范围内。
攻击者可以确定lockTime他们现在持有密钥的地址的当前值(它是一个公共变量)。我们将其称为 userLockTime。然后,他们可以调用该increaseLockTime函数并将数字2^256 - userLockTime 作为参数传递。该数字将被添加到当前值中userLockTime并导致溢出,从而重置 lockTime[msg.sender]为0。
让我们看另一个例子(Underflow vulnerability example from Ethernaut challenge),这个例子来自Ethernaut challenges。
例 4. Ethernaut 挑战中的下溢漏洞示例
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函数中。第 13 行的 require 语句可以通过下溢绕过。假设一个用户的余额为零。他们可以使用transfer任何非零值调用该函数 _value,并传递第 13 行的 require 语句。这是因为 balances[msg.sender]为 0(并且是uint256),所以减去任何正数(不包括2^256)都会得到一个正数,正如前面所述。第 14 行也是如此,该行的余额将被记入一个正数。因此,在此示例中,攻击者可以利用下溢漏洞获得免费代币。
预防技术
当前,在 Solidity 0.8.0 之后,整数的加减乘除等运算 默认会进行溢出检查:如果出现 上溢(overflow) 或 下溢(underflow),会直接 revert 交易。
防范溢出/下溢漏洞的常规技术是使用或构建数学库来替代标准数学运算符加法、减法和乘法(除法被排除在外,因为它不会导致溢出/下溢,并且 EVM 在除以 0 时会恢复)。
OpenZeppelin在为以太坊社区构建和审计安全库方面做得非常出色。特别是,其SafeMath 库可以用来避免下溢/溢出漏洞。
为了演示如何在 Solidity 中使用这些库,让我们TimeLock使用该SafeMath库来修改合约。合约的无溢出版本如下:
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); // This holds in all cases
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() external 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]);
uint256 transferValue = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}请注意,所有标准数学运算都已被库中定义的运算所取代SafeMath。TimeLock合约不再执行任何可能导致溢出/下溢的操作。
真实案例:PoWHC 和批量传输溢出(CVE-2018-10299)
弱手证明币(PoWHC)最初只是个玩笑,实际上是一个由互联网团体编写的庞氏骗局。不幸的是,该合约的作者似乎从未见过溢流/下溢的情况,结果导致 866 个以太币从合约中被释放。Eric Banisadr 在其博客文章中对这次事件的发生过程进行了很好的概述(这与之前描述的 Ethernaut 挑战并无太大区别)。
Another example 是,一组 ERC20 代币合约中存在一个函数batchTransfer()实现,该实现包含一个溢出漏洞;您可以在 PeckShield’s account中阅读详细信息。
{/collapse-item}
{collapse-item label="三、Unexpected Ether" open}
通常,当 以太 被发送到合约时,它必须执行 fallback 函数或合约中定义的其他函数。但也有两种例外情况:Ether 可以存在于没有执行任何代码合约中。依赖于代码执行来接收所有 Ether 的合约,在 Ether 被强制发送的情况下,可能会容易受到攻击。
有关这方面的进一步阅读,参考 “How to Secure Your Smart Contracts” 和 “Solidity Security Patterns - Forcing Ether to a Contract”。
漏洞
一种常见的防御性编程技术是不变性检查,它有助于强制执行正确的状态转换或验证操作 。该技术涉及定义一组不变量(不应改变的指标或参数),并检查它们在单个(或多个)操作后是否保持不变。这通常是一个好的设计,只要被检查的不变量确实是不变量。不变量的一个例子是totalSupply固定发行量的 ERC20 代币的。由于任何函数都不应修改此不变量,因此可以向函数transfer添加检查以确保totalSupply 保持不变,从而保证函数按预期工作。
具体来说,有一个看似不变量,可能很容易被使用,但实际上可以被外部用户操纵(无论智能合约中设置了什么规则)。这就是合约中当前存储的以太币。开发人员在初学 Solidity 时,常常会误以为合约只能通过 payable 函数接收或获取以太币。这种误解可能导致合约对其内部的以太币余额做出错误的假设,从而引发一系列漏洞。此漏洞的确凿证据就是(错误)使用this.balance。
有两种方法可以(强制)将以太币发送到合约,而无需使用可支付函数或执行合约上的任何代码:
Self-destruct/suicide
任何合约都可以实现该
selfdestruct函数,该函数从合约地址中删除所有字节码,并将存储在那里的所有以太币发送到参数指定的地址。如果这个指定的地址也是一个合约,则不会调用任何函数(包括 fallback 函数)。因此,该selfdestruct函数可用于强制将以太币发送给任何合约,而不管合约中可能存在什么代码,甚至没有可支付函数的合约也是如此。这意味着任何攻击者都可以创建一个带有selfdestruct函数的合约,向其发送以太币,调用selfdestruct(target)并强制将以太币发送给target合约。Martin Swende 有一篇很棒的博客文章,描述了Self-destruct操作码的一些怪癖(怪癖#2),以及客户端节点如何检查不正确的不变量,这可能会导致以太坊网络的灾难性崩溃。Pre-sent ether
将以太币引入合约的另一种方法是预加载以太币到合约地址。合约地址是确定的,实际上,该地址是根据创建合约的地址和创建合约的交易随机数的 Keccak-256(通常与 SHA-3 同义)哈希值计算得出的。具体来说,它的形式如下
address = sha3(rlp.encode([account_address,transaction_nonce]))(请参阅 Adrian Manning 关于“无密钥以太币”的讨论,了解一些有趣的用例)。这意味着任何人都可以在合约创建之前计算出合约的地址,并将以太币发送到该地址。合约创建后,其以太币余额将不为零。
基于这些知识可能出现的一些陷阱。EtherGame.sol中过于简单的合约。
示例 5.EtherGame.sol
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() external 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 has finished
require(currentBalance <= finalMileStone);
// if at a milestone, credit the player's 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);
}
}该合约代表一个简单的游戏(这自然会涉及竞争条件),玩家向合约发送 0.5 以太币,希望成为第一个达到三个里程碑之一的玩家。里程碑以以太币计价。第一个达到里程碑的玩家可以在游戏结束时领取部分以太币。当达到最后一个里程碑(10 以太币)时,游戏结束;届时用户可以领取奖励。
EtherGame合约的问题在于 this.balance第 14 行(以及相关的第 16 行)和第 32 行对 的不当使用。恶意攻击者可以通过selfdestruct函数(前面讨论过)强制发送少量以太币(比如 0.1 以太币),以阻止任何后续玩家达成里程碑。this.balance由于这 0.1 以太币的贡献, 永远不会是 0.5 以太币的倍数,因为所有合法玩家只能发送 0.5 以太币的增量。这导致第 18、21 和 24 行的所有 if 条件都不成立。
更糟糕的是,一个怀有报复心的攻击者错过了一个里程碑,可能会强行发送 10 个以太币(或等量的以太币,使合约余额超过finalMileStone),这将永久锁定合约中的所有奖励。这是因为该claimReward 函数总会被回滚,因为 require(this.balance大于finalMileStone)。
预防技术
此类漏洞通常源于对 的误用this.balance。合约逻辑应尽可能避免依赖于合约余额的精确值,因为它可能被人为操纵。如果应用基于 的逻辑this.balance,则必须应对意外余额。
如果需要存入的 Ether 的精确值,则应使用一个自定义变量,该变量在可支付函数中递增,以便安全地跟踪存入的 Ether。此变量不会受到通过调用强制发送的 Ether 的影响selfdestruct。
考虑到这一点,EtherGame合约的修正版本可能如下所示:
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() external payable {
require(msg.value == 0.5 ether);
uint currentBalance = depositedWei + msg.value;
// ensure no players after the game has 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。
{/collapse-item}
{collapse-item label="四、DELEGATECALL" open}
CALL和操作DELEGATECALL操作码有助于以太坊开发者模块化其代码。对合约的标准外部消息调用由CALL操作码处理,代码在外部合约/函数的上下文中运行。DELEGATECALL操作码几乎完全相同,只是在目标地址执行的代码在调用合约的上下文中运行,并且msg.sender和msg.value保持不变。此功能支持库的实现,允许开发者部署一次可重用代码,并在未来的合约中调用它。
虽然这两个操作码之间的区别简单且直观,但使用DELEGATECALL可能会导致意外的代码执行。
要进一步阅读,请参阅 Loi.Luu 关于此主题的以太坊 Stack Exchange 问题和 Solidity 文档。
漏洞
由于 的上下文保留特性DELEGATECALL,构建无漏洞的自定义库并不像人们想象的那么容易。库中的代码本身可能是安全且无漏洞的;然而,当在另一个应用程序的上下文中运行时,可能会出现新的漏洞。让我们看一个相当复杂的例子,使用斐波那契数列。
FibonacciLib.sol中的库,它可以生成斐波那契数列及其类似形式的序列。(注:此代码改编自https://bit.ly/2MReuii。)
例 6.FibonacciLib.sol
// 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所示。
例 7. FibonacciBalance.sol
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) external 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它保存字符串的 Keccak-256(SHA-3)哈希'setFibonacci(uint256)'的前 4 个字节。这被称为 函数选择器,放入其中calldata以指定将调用智能合约的哪个函数。它在第 21 行的函数delegatecall中用于指定我们希望运行该fibonacci(uint256) 函数。delegatecall中的第二个参数是我们传递给函数的参数。其次,我们假设库的地址 FibonacciLib在构造函数中被正确引用(外部合约引用讨论了与此类合约引用初始化相关的一些潜在漏洞)。
你能发现这份合约中有什么错误吗?如果有人部署这份合约,用以太币填充,然后调用withdraw,它很可能会恢复。
您可能已经注意到,状态变量start在库和主调用合约中都使用。在库合约中,start 用于指定斐波那契数列的开头并设置为 0,而在调用合约中设置为3。您可能还注意到,合约FibonacciBalance中的 fallback 函数 允许将所有调用传递给库合约,从而允许调用库合约的setStart函数。回想一下,我们保存了合约的状态,似乎这个函数允许您更改本地FibonnacciBalance合约中变量start的状态。如果是这样,这将允许人们提取更多的以太币,因为结果 calculatedFibNumber取决于start变量(如库合约中所示)。事实上,该setStart函数不会(也不能)修改合约start中的变量FibonacciBalance 。这个合约中的潜在漏洞比仅仅修改start变量要严重得多。
在讨论实际问题之前,我们先快速了解一下状态变量在合约中的具体存储方式。状态变量或存储变量(在单个交易中持续存在的变量)在合约中被引入时,会按顺序放入槽位中 。(这里有些复杂;如需更深入的理解,请参阅Solidity 文档。)
举个例子,我们来看看库合约。它有两个状态变量,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将作用于调用合约的状态(即存储)。
现在请注意,在withdraw第 21 行我们执行 fibonacciLibrary.delegatecall(fibSig,withdrawalCounter)。这会调用setFibonacci函数,正如我们所讨论的,该函数会修改存储 slot[1],在我们当前的上下文中是calculatedFibNumber。这是符合预期的(即,在执行后被calculatedFibNumber修改了)。但是,回想一下,合约FibonacciLib中的变量 start位于存储中slot[0],对于当前合约这是 FibonacciLib合约的地址。这意味着该函数fibonacci将给出意外的结果。这是因为它引用了start(slot[0]),在当前调用上下文中存储的是fibonacciLibrary 的地址(当存储uint 时,它通常会很大)。
更糟糕的是,合约允许用户通过FibonacciBalance第 26 行的 fallback 函数调用所有函数。正如我们之前讨论过的,这包括函数。我们讨论过setStart这个函数允许任何人修改或设置存储 slot[0]。在这种情况下,slot[0]存储的就是 fibonacciLibrary地址。因此,攻击者可以创建一个恶意合约,将地址转换为uint(这可以在 Python 中使用轻松完成)int('<address>',16),然后调用setStart(<attack_contract_address_as_uint>) 。这将更改为 攻击合约的地址。调用一个 FibonacciBalance 没有的函数(即带 data 的交易),触发 fallback → delegatecall → 执行库里的 setStart,然后,每当用户调用withdraw或 fallback 函数时,恶意合约就会运行(这可能会窃取合约的全部余额),因为我们已经修改了的实际地址。这种攻击合约的一个例子是:
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 if withdraw
// is called we don't send out any ether
<attacker_address>.transfer(this.balance); // we take all the ether
}
}请注意,此攻击合约通过更改calculatedFibNumber存储来修改slot[1]。原则上,攻击者可以修改他们选择的任何其他存储槽,以对该合约执行各种攻击。
还需要注意的是,当我们说delegatecall状态保存时,我们指的不是合约的变量名,而是这些变量名指向的实际存储槽。从这个例子中可以看出,一个简单的错误就可能导致攻击者劫持整个合约及其以太币。
预防技术
Solidity 提供了library用于实现库合约的关键字(更多详情请参阅文档)。这确保了库合约是无状态且不可自毁的。强制库无状态可以降低本节中演示的存储上下文的复杂性。无状态库还可以防止攻击者直接修改库状态以影响依赖于库代码的合约的攻击。一般经验法则是,在使用DELEGATECALL时要仔细注意库合约和调用合约可能的调用上下文,并尽可能构建无状态库。
真实案例:Parity Multisig 钱包(第二次破解)
第二次 Parity 多重签名钱包攻击就是一个例子,它展示了精心编写的库代码在其预期环境之外运行时如何被利用。关于这次攻击有很多很好的解释,例如 “Parity 多重签名再次遭黑客攻击”和“深入了解 Parity 多重签名漏洞”。
库合约和钱包合约可以在 GitHub 上找到。
该 library的合同如下:
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 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);
}
...
}这是Wallet合约:
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函数。由于该用户是库合约的所有者,因此修改器通过,库合约自毁。由于所有Wallet现存的合约都引用了该库合约,并且不包含任何更改此引用的方法,因此所有功能(包括提取以太币的功能)都将随WalletLibrary合约一起丢失。结果,所有此类 Parity 多重签名钱包中的所有以太币都会立即丢失或永久无法恢复。
{/collapse-item}
{collapse-item label="五、Default Visibilities" open}
Solidity 中的函数具有可见性说明符,用于指示函数的调用方式。可见性决定了函数是否可以由用户从外部调用、由其他派生合约从外部调用、仅由内部调用还是仅由外部调用。可见性说明符有四种,在Solidity 文档中有详细描述。函数默认为public,允许用户从外部调用它们。现在我们将看到,可见性说明符的错误使用会导致智能合约中出现一些毁灭性的漏洞。
漏洞
函数的默认可见性为public,因此未指定可见性的函数将被外部用户调用。当开发人员错误地省略了本应为私有(或只能在合约内部调用)的函数的可见性说明符时,就会出现此问题。
让我们快速探索一个简单的例子:
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。solc 的最新版本会对未设置明确可见性的函数显示警告,以鼓励这种做法。
真实案例:Parity Multisig 钱包(首次破解)
在第一次 Parity 多重签名攻击中,约有价值 3100 万美元的以太币被盗,大部分来自三个钱包。Haseeb Qureshi详细回顾了此次攻击的具体过程。
本质上,多重签名钱包是由一个基础Wallet合约构建的,该基础合约调用一个包含核心功能的库合约(如 真实示例:Parity 多重签名钱包(第二次破解)中所述)。库合约包含初始化钱包的代码,如以下代码片段所示:
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 multiowned and
// the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
}请注意,这两个函数均未指定其可见性,因此默认为public。该initWallet 函数在钱包的构造函数中被调用,并设置多重签名钱包的所有者,如initMultiowned函数中所示。由于这些函数被意外遗漏public,攻击者能够在已部署的合约上调用这些函数,将所有权重置为攻击者的地址。作为所有者,攻击者随后窃取了钱包中的所有以太币。
{/collapse-item}
{collapse-item label="五、Default Visibilities" open}
Solidity 中的函数具有可见性说明符,用于指示函数的调用方式。可见性决定了函数是否可以由用户从外部调用、由其他派生合约从外部调用、仅由内部调用还是仅由外部调用。可见性说明符有四种,在Solidity 文档中有详细描述。函数默认为public,允许用户从外部调用它们。现在我们将看到,可见性说明符的错误使用会导致智能合约中出现一些毁灭性的漏洞。
漏洞
函数的默认可见性为public,因此未指定可见性的函数将被外部用户调用。当开发人员错误地省略了本应为私有(或只能在合约内部调用)的函数的可见性说明符时,就会出现此问题。
让我们快速探索一个简单的例子:
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。solc 的最新版本会对未设置明确可见性的函数显示警告,以鼓励这种做法。
真实案例:Parity Multisig 钱包(首次破解)
在第一次 Parity 多重签名攻击中,约有价值 3100 万美元的以太币被盗,大部分来自三个钱包。Haseeb Qureshi详细回顾了此次攻击的具体过程。
本质上,多重签名钱包是由一个基础Wallet合约构建的,该基础合约调用一个包含核心功能的库合约(如 真实示例:Parity 多重签名钱包(第二次破解)中所述)。库合约包含初始化钱包的代码,如以下代码片段所示:
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 multiowned and
// the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
}请注意,这两个函数均未指定其可见性,因此默认为public。该initWallet 函数在钱包的构造函数中被调用,并设置多重签名钱包的所有者,如initMultiowned函数中所示。由于这些函数被意外遗漏public,攻击者能够在已部署的合约上调用这些函数,将所有权重置为攻击者的地址。作为所有者,攻击者随后窃取了钱包中的所有以太币。
{/collapse-item}
{collapse-item label="六、Entropy Illusion" open}
以太坊区块链上的所有交易都是确定性的状态转换操作。这意味着每笔交易都会以可计算的方式修改以太坊生态系统的全局状态,且不存在任何不确定性。这从根本上意味着以太坊中不存在熵或随机性。实现去中心化的熵(随机性)是一个众所周知的问题,为此提出了许多解决方案,包括RANDAO或使用哈希链,正如 Vitalik Buterin 在博客文章 “PoS 中的验证者排序和随机性”中所述。
漏洞
以太坊平台上最早构建的一些合约与赌博相关。从根本上说,赌博需要不确定性(可以押注的东西),这使得在区块链上构建赌博系统(确定性系统)相当困难。显然,不确定性必须来自区块链外部。对于玩家之间的赌注来说,这是可能的(例如 commit–reveal technique);然而,如果你想实现一个充当“庄家”的合约(例如在二十一点或轮盘赌中),这就会困难得多。一个常见的陷阱是使用未来区块变量——即包含交易区块信息的变量,其值尚不清楚,例如哈希值、时间戳、区块编号或 Gas 限制。这些问题在于,它们由挖出区块的矿工控制,因此并非真正随机。例如,考虑一个轮盘赌智能合约,其逻辑是如果下一个区块哈希值以偶数结尾,则返回黑色数字。一个矿工(或矿池)可能会在黑色上押注 100 万美元。如果他们解开下一个区块后发现哈希值结尾为奇数,他们就可以放心地不发布自己的区块,转而挖掘另一个区块,直到找到一个区块哈希值为偶数的解(假设区块奖励和费用低于 100 万美元)。使用过去或现在的变量可能更具破坏性,正如 Martin Swende 在其精彩的博客文章中所论证的那样。此外,仅使用区块变量意味着区块中所有交易的伪随机数都将相同,因此攻击者可以通过在一个区块内进行多次交易(假设存在最大赌注)来成倍增加收益。
预防技术
熵(随机性)的来源必须位于区块链外部。这可以通过类似 commit-reveal的系统在对等节点之间实现,或者通过将信任模型更改为一组参与者(例如 RandDAO)来实现。这也可以借助充当随机性预言机的中心化实体来实现。区块变量(一般情况下,也有一些例外)不应用作熵的来源,因为它们可能被矿工操纵。
真实世界示例:PRNG 合约
2018 年 2 月,Arseny Reutov 在博客中发表了他对 3,649 份使用某种伪随机数生成器 (PRNG) 的实时智能合约的分析;他发现 43 份合约可被利用。
{/collapse-item}
{collapse-item label="七、External Contract Referencing" open}
以太坊“世界计算机”的优势之一是能够重用代码并与网络上已部署的合约进行交互。因此,大量合约通常通过外部消息调用来引用外部合约。这些外部消息调用可以通过一些不易察觉的方式掩盖恶意行为者的意图,我们现在将对此进行探讨。
漏洞
在 Solidity 中,任何地址都可以转换为合约,无论该地址的代码是否代表被转换的合约类型。这可能会导致问题,尤其是当合约作者试图隐藏恶意代码时。让我们用一个例子来说明这一点。
考虑像Rot13Encryption.sol这样的代码片段,它基本实现了 ROT13 密码。
示例 8.Rot13Encryption.sol
// 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 {
// get the first byte
char := byte(0,char)
// if the character is in [n,z], i.e. wrapping
if and(gt(char,0x6D), lt(char,0x7B))
// subtract from the ASCII number 'a',
// the difference between character <char> and 'z'
{ char:= sub(0x60, sub(0x7A,char)) }
if iszero(eq(char, 0x20)) // ignore spaces
// add 13 to char
{mstore8(add(add(text,0x20), mul(i,1)), add(char,13))}
}
}
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。无需理解上述合约中的汇编代码即可理解正在讨论的问题,因此不熟悉汇编代码的读者可以放心地忽略它。
现在考虑以下合约,它使用此代码进行加密:
import "Rot13Encryption.sol";
// encrypt your top-secret info
contract EncryptionContract {
// library for encryption
Rot13Encryption encryptionLibrary;
// constructor - initialize the library
constructor(Rot13Encryption _encryptionLibrary) {
encryptionLibrary = _encryptionLibrary;
}
function encryptPrivateData(string privateInfo) {
// potentially do some operations here
encryptionLibrary.rot13Encrypt(privateInfo);
}
}该合约的问题在于encryptionLibrary地址不是公开的或恒定的。因此,合约的部署者可以在构造函数中提供一个指向该合约的地址:
// 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 {
// get the first byte
char := byte(0,char)
// if the character is in [n,z], i.e. wrapping
if and(gt(char,0x6D), lt(char,0x7B))
// subtract from the ASCII number 'a',
// the difference between character <char> and 'z'
{ char:= sub(0x60, sub(0x7A,char)) }
// ignore spaces
if iszero(eq(char, 0x20))
// add 26 to char!
{mstore8(add(add(text,0x20), mul(i,1)), add(char,26))}
}
}
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 位(即不执行任何操作)。同样,无需了解此合约的汇编代码。更简单来说,攻击者可以将以下合约关联到一起,达到同样的效果:
contract Print{
event Print(string text);
function rot13Encrypt(string text) public {
emit Print(text);
}
}如果在构造函数中给出了这两个合约的地址,那么该encryptPrivateData函数将只产生一个打印未加密的私有数据的事件。
虽然在本例中,构造函数中设置了一个类似库的合约,但通常情况下,特权用户(例如所有者)可以更改库合约的地址。如果链接的合约不包含被调用的函数,则 fallback 函数将执行。例如,在 行中encryptionLibrary.rot13Encrypt(),如果 指定的合约 encryptionLibrary是:
contract Blank {
event Print(string text);
function () {
emit Print("Here");
// put malicious code here and it will run
}
}Here然后将发出一个包含文本的事件。因此,如果用户可以修改合约库,那么他们原则上就可以让其他用户在不知情的情况下运行任意代码。
警告 :此处展示的合约仅用于演示目的,并不代表正确的加密方式。它们不应用于加密。
预防技术
如前所述,安全合约在某些情况下可能会被部署成恶意行为。审计员可能会公开验证某个合约,但其所有者可能会以恶意方式部署该合约,从而导致公开审计的合约存在漏洞或恶意行为。
有许多技术可以防止这些情况的发生。
一种技术是使用new关键字来创建合约。在前面的例子中,构造函数可以写成:
constructor() {
encryptionLibrary = new Rot13Encryption();
}这样,在部署时就会创建引用合约的实例,并且部署者无法Rot13Encryption在不更改合约的情况下替换合约。
另一个解决方案是对外部合约地址进行硬编码。
一般来说,调用外部合约的代码应始终受到严格审计。作为开发人员,在定义外部合约时,最好将合约地址公开(下一节的蜜罐示例并非如此),以便用户轻松检查合约引用的代码。相反,如果合约拥有私有的可变合约地址,则可能表明有人存在恶意行为(如实际示例所示)。如果用户可以更改用于调用外部函数的合约地址,那么在去中心化系统环境下,实施时间锁和/或投票机制就显得尤为重要,这样可以让用户查看正在更改的代码,或者让参与者有机会选择加入/退出新的合约地址。
真实世界的例子:可重入蜜罐
最近,主网上发布了一系列“蜜罐”攻击。这些合约试图智胜那些试图利用这些合约的以太坊黑客,但这些黑客最终却被他们预期利用的合约所蒙受以太币损失。一个例子就是利用这种攻击,在构造函数中用恶意合约替换预期合约。代码 如下:
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() external 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);
}
}一位 reddit 用户在帖子 中 解释了他们如何通过尝试利用他们预计在合约中存在的重入漏洞而在该合约中损失了 1 个以太币。
{/collapse-item}
{collapse-item label="八、Short Address/Parameter Attack" open}
此攻击并非针对 Solidity 合约本身,而是针对可能与其交互的第三方应用程序。本节旨在增强完整性,并帮助读者了解如何操纵合约中的参数。
如需进一步阅读,请参阅 “ERC20 短地址攻击解释”、 “ICO 智能合约漏洞:短地址攻击”或此 Reddit 帖子。
漏洞
向智能合约传递参数时,参数会根据 ABI 规范进行编码。发送的编码参数可能会短于预期的参数长度(例如,发送一个只有 38 个十六进制字符(19 字节)而不是标准的 40 个十六进制字符(20 字节)的地址)。在这种情况下,EVM 会在编码参数的末尾添加零以达到预期的长度。
当第三方应用程序不验证输入时,就会出现问题。最明显的例子是,当用户请求提现时,交易所不验证 ERC20 代币的地址。Peter Vessenes 的文章 “ERC20 短地址攻击解析”更详细地介绍了这个例子。
考虑标准 ERC20 传输函数接口,注意参数的顺序:
function transfer(address to, uint tokens) public returns (bool success);现在假设一个交易所持有大量代币(假设为 REP),并且用户希望提取其持有的 100 个代币。用户将提交其地址0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead和代币数量100。交易所将按照transfer函数指定的顺序对这些参数进行编码;也就是说 address,tokens。编码结果将是:
a9059cbb00000000000000000000000deaddeaddea \
ddeaddeaddeaddeaddeaddeaddead0000000000000
0000000000000000000000000000000056bc75e2d63100000前 4 个字节(a9059cbb)是transfer 函数签名/选择器,接下来的 32 个字节是地址,最后 32 个字节表示uint256代币数量。注意,末尾的十六进制数代表 100 个代币(小数点后 18 位,由代币 合约56bc75e2d63100000指定)。
现在让我们看看如果有人发送一个缺少 1 个字节(2 个十六进制数字)的地址会发生什么。具体来说,假设攻击者发送0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde一个地址(缺少最后两位数字)和相同的100代币进行提现。如果交易所不验证此输入,它将被编码为:
a9059cbb00000000000000000000000deaddeaddea \
ddeaddeaddeaddeaddeaddeaddeadde0000000000000
00000000000000000000000000000056bc75e2d6310000000区别很微妙。请注意,00已将 添加到编码末尾,以弥补发送的短地址。当此代码发送到智能合约时,address参数将被读取为 0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00,值将被读取为56bc75e2d6310000000(注意两个多余的 0)。此值现在是25600代币(该值已乘以256)。在此示例中,如果交易所持有这么多代币,则用户会将 25600代币提取(而交易所认为用户只是提取 100)到修改后的地址。显然,攻击者在此示例中不会拥有修改后的地址,但如果攻击者生成任何以 0 结尾的地址(很容易被暴力破解)并使用这个生成的地址,他们就可以从毫无戒心的交易所窃取代币。
预防技术
所有外部应用程序的输入参数在发送到区块链之前都应进行验证。还要注意,参数排序在这里起着重要作用。由于填充仅发生在末尾,因此在智能合约中谨慎地对参数进行排序可以缓解某些形式的此类攻击。
严格检查 calldata 长度
- 在 Solidity 0.4.x 之后,编译器已经会帮你检查 ABI 编码参数的长度,如果长度不足直接 revert。
- 所以现代编译器几乎已经杜绝了这个问题。
前端编码时强校验
- 确保传入的 address 参数必须是 20字节,如果用户输入的地址不是 0x + 40 hex,直接报错。
- ethers.js / web3.js 现在都做了这个校验。
参数排序问题(老合约里的坑)
- 如果把 动态参数(bytes/string/数组)放在前面,EVM 会先在前面写指针,真正的数据放到 calldata 尾部。
- 如果攻击者利用 short address attack,把前面参数错位,就可能导致后续的偏移量计算出错,结果更加严重。
- 所以老的防御建议里有一句:固定长度参数要放在前,动态参数放在后,这样即便有短地址攻击,错位影响也不会扩散到后面的参数解码。
{/collapse-item}
{collapse-item label="九、Unchecked CALL Return Values" open}
在 Solidity 中,有多种方法可以执行外部调用。通常通过transfer方法执行向外部账户发送以太币。但是,send也可以使用函数,对于更通用的外部调用,CALL可以直接在 Solidity 中使用操作码。call和函数返回一个布尔值,指示调用是成功还是失败。因此,这些函数有一个简单的警告:如果外部调用(由或send初始化)失败,执行这些函数的交易将不会回滚;相反,这些函数只会返回。一个常见的错误是,开发人员期望外部调用失败时会发生回滚,而没有检查返回值。call`send`false
如需进一步阅读,请参阅2018 年 DASP 十大榜单第 4 名和 “扫描实时以太坊合约以查找‘未经检查的发送’漏洞”。
漏洞
请考虑以下示例:
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);
}
}这代表一种类似乐透的合约,其中一个人winner 收到winAmount以太币,通常会剩下一点,供任何人提取。
漏洞存在于第 11 行,其中使用了 send而没有检查响应。在这个简单的例子中,如果winner的交易失败(可能是因为耗尽了 Gas 或是一个故意抛出 fallback 函数的合约),则无论是否发送了 Ether, payedOut都会被设置为true。在这种情况下,任何人都可以通过该withdrawLeftOver函数提取winner 的奖金。
预防技术
尽可能使用transfer函数而不是send,因为 transfer如果外部交易revert, 也会 revert。如果 send需要 ,请务必检查返回值。
更稳健的 建议 是采用withdrawal模式。在此解决方案中,每个用户必须调用一个独立的withdraw函数,该函数负责处理从合约中发送以太币的操作,并处理发送交易失败的后果。其理念是将外部发送功能与代码库的其余部分逻辑隔离,并将潜在交易失败的负担转嫁给调用提现函数的最终用户。
现实世界的例子:Etherpot 和 King of the Ether
Etherpot是一个智能合约彩票,与前面提到的示例合约并无太大区别。该合约的失败主要源于对区块哈希值的错误使用(只有最后 256 个区块哈希值可用;请参阅 Aakil Fernandes 的 文章cash,了解 Etherpot 如何未能正确处理这一问题)。然而,该合约也存在一个未经检查的调用值问题。请考虑 lotto.sol中的 函数:代码片段。
例 9. lotto.sol:代码片段
...
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
}
...请注意,第 21 行send没有检查函数的返回值,而下一行设置了一个布尔值,表示获胜者已收到资金。这个漏洞可能导致获胜者无法收到其以太币,但合约状态可能显示获胜者已收到付款。
这个漏洞的一个更严重的版本出现在 King of the Ether中。一份关于该合约的出色 事后分析报告详细说明了如何send 利用未经检查的失败来攻击该合约。
{/collapse-item}
{collapse-item label="十、Race Conditions/Front Running" open}
对其他合约的外部调用,加上底层区块链的多用户特性,导致了各种潜在的 Solidity 陷阱,用户可能会通过竞态代码执行来获取意外状态。可重入(本章前面讨论过)就是这种竞态条件的一个例子。在本节中,我们将讨论以太坊区块链上可能出现的其他类型的竞态条件。关于这个主题有很多优秀的文章,包括以太坊 Wiki上的“竞态条件” 、2018 年 DASP 十大排行榜第 7 名,以及 以太坊智能合约最佳实践。
漏洞
与大多数区块链一样,以太坊节点会将交易池化并形成区块。只有当矿工解决了共识机制(目前 以太坊采用的是Ethash PoW)后,交易才会被视为有效。解决区块的矿工还会选择将池中的哪些交易纳入区块,通常按 gasPrice每笔交易的优先级排序。这是一个潜在的攻击向量。攻击者可以监视交易池中可能包含问题解决方案的交易,并修改或撤销求解器的权限,或在合约中更改状态,使其对求解器造成不利影响。然后,攻击者可以从该交易中获取数据,并创建自己的更高优先级的交易,gasPrice以便他们的交易在原始交易之前被纳入区块。
让我们通过一个简单的例子来看一下它是如何工作的。考虑FindThisHash.sol中所示的合约。
例 10.FindThisHash.sol
contract FindThisHash {
bytes32 constant public hash =
0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a;
constructor() external 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);
}
}假设该合约包含 1,000 个以太币。用户可以找到以下 SHA-3 哈希的原像:
0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a可以提交解决方案并获取 1,000 个以太币。假设一位用户算出解决方案是Ethereum!。他们调用 ,solve并将 Ethereum!作为参数。不幸的是,攻击者足够聪明,能够监视交易池中提交解决方案的任何人。他们看到这个解决方案,检查其有效性,然后提交一个等价交易,其 远高于gasPrice原始交易。由于 更高,解决该区块的矿工可能会优先考虑攻击者gasPrice,并在原始求解者之前挖出他们的交易。攻击者将获得 1,000 个以太币,而解决问题的用户将一无所获。请记住,在这种类型的“抢先交易”漏洞中,矿工有独特的动机自行发起攻击(或者可以通过高额费用贿赂来发起这些攻击)。攻击者本身就是矿工的可能性不容小觑。
预防技术
有两类行为者可以实施此类抢先交易攻击:用户(修改gasPrice其交易)和矿工本身(可以根据自己的意愿重新排列区块中的交易)。易受第一类攻击(用户)的合约比易受第二类攻击(矿工)的合约情况要糟糕得多,因为矿工只有在解决区块时才能发起攻击,而任何单个矿工针对特定区块进行攻击的可能性都较小。这里我们将列出针对这两类攻击者的一些缓解措施。
一种方法是设置上限gasPrice。这可以防止用户增加上限gasPrice并获得超出上限的优先交易排序。此措施仅能防范第一类攻击者(任意用户)。在这种情况下,矿工仍然可以攻击合约,因为他们可以随意对区块中的交易进行排序,而不受 Gas 价格的影响。
一种更稳健的方法是使用 提交-揭示 方案。这种方案要求用户发送包含隐藏信息(通常是哈希值)的交易。交易被打包到区块后,用户再发送一笔交易来揭示已发送的数据(揭示阶段)。这种方法可以防止矿工和用户抢先交易,因为他们无法确定交易的内容。然而,这种方法无法隐藏交易金额(在某些情况下,交易金额正是需要隐藏的宝贵信息)。ENS 智能合约允许用户发送交易,其提交数据包含他们愿意支付的以太币金额。然后,用户可以发送任意金额的交易。在揭示阶段,用户将获得交易发送金额与他们愿意支付金额之间的差额退款。
Lorenz Breidenbach、Phil Daian、Ari Juels 和 Florian Tramèr 还建议使用 “潜艇发送”。高效地实现这个想法需要CREATE2 操作码,虽然目前操作码尚未被采用,但在接下来的硬分叉中似乎会被采用。
现实世界的例子:ERC20 和 Bancor
ERC20 标准因在以太坊上构建代币而闻名。该标准存在一个潜在的抢先交易漏洞,该漏洞源于其approve函数。Mikhail Vladimirov 和 Dmitry Khovratovich撰写了一篇关于此漏洞(以及缓解攻击的方法)的精彩文章。
标准将该approve功能规定为:
function approve(address _spender, uint256 _value) returns (bool success)此功能允许用户允许其他用户代表他们转移代币。抢先交易漏洞发生在以下场景中:用户 Alice批准她的朋友 Bob 花费 100 个代币。Alice 后来决定撤销 Bob 花费 100 个代币的批准,因此她创建了一笔交易,将 Bob 的分配设置为 50 个代币。Bob 一直在仔细观察区块链,他看到了这笔交易,并建立了一笔自己的交易花费这 100 个代币。他给gasPrice自己的交易设定的权重高于 Alice 的,因此他的交易优先于 Alice 的。某些实现approve允许 Bob 转移他的 100 个代币,然后,当 Alice 的交易提交后,将 Bob 的批准重置为 50 个代币,实际上让 Bob 可以使用 150 个代币。
另一个突出的现实世界案例是 Bancor。Ivan Bogatyy 和他的团队记录了一次针对 Bancor 初始实现的有利可图的攻击。他的 博客文章和DevCon3 演讲 详细讨论了此次攻击是如何实现的。本质上,代币的价格是根据交易价值确定的;用户可以监视 Bancor 交易池中的交易,并抢先交易以从价差中获利。Bancor 团队已经解决了这次攻击。
{/collapse-item}
{collapse-item label="十一、Denial of Service (DoS)" open}
这一类别涵盖范围很广,但本质上是指用户可以通过攻击使合约在一段时间内(在某些情况下是永久)无法操作。这可能会导致以太币永远被困在这些合约中,就像在真实案例:Parity 多重签名钱包(第二次黑客攻击)中遇到的那样。
漏洞
合约失效的原因有很多。这里我们重点介绍一些不太明显的 Solidity 编码模式,这些模式可能会导致 DoS 漏洞:
循环遍历外部操作的映射或数组
这种模式通常出现在所有者希望向具有
distribute类似功能的投资者分发代币时,如以下示例合约所示: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() external 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函数无法运行。所有者操作
另一种常见模式是,所有者在合约中拥有特定权限,并且必须执行某些任务才能使合约进入下一个状态。一个例子是首次代币发行 (ICO) 合约,它要求所有者加入
finalize合约,然后允许代币进行转移。例如: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) } ...
在这种情况下,如果特权用户丢失私钥或处于非活跃状态,整个代币合约将无法运行。在这种情况下,如果所有者无法调用,finalize代币就无法转移;整个代币生态系统的运行都依赖于一个地址。
根据外部调用进展状态
合约有时会这样编写:要进入新状态,需要将以太币发送到某个地址,或等待来自外部源的某些输入。当外部调用失败或由于外部原因被阻止时,这些模式可能会导致 DoS 攻击。以发送以太币为例,用户可以创建一个不接受以太币的合约。如果合约要求提取以太币才能进入新状态(例如,一个时间锁定合约要求所有以太币必须提取后才能再次使用),那么该合约将永远无法进入新状态,因为以太币永远无法发送到不接受以太币的用户合约。
预防技术
在第一个示例中,合约不应该循环遍历可被外部用户人为操纵的数据结构。建议采用提现模式,即每个投资者调用提现函数来独立领取代币。
在第二个示例中,特权用户需要更改合约状态。在此类示例中,可以在所有者丧失行为能力的情况下使用故障保护机制。一种解决方案是使所有者成为多重签名合约。另一种解决方案是使用时间锁:在给定的示例中,第 5 行的要求可以包含基于时间的机制,例如 require(msg.sender == owner || now > unlockTime),允许任何用户在指定的时间段后完成unlockTime。这种缓解技术也可以用于第三个示例。如果需要外部调用才能进入新状态,请考虑其可能失败的情况,并在所需调用永远不会到来的情况下添加基于时间的状态进展。
| 笔记 | 当然,这些建议也有中心化的替代方案:可以添加一个maintenanceUser实体,必要时可以介入并修复基于 DoS 攻击向量的问题。通常,这类合约存在信任问题,因为这种实体拥有强大的权力。 |
|---|
现实世界的例子:GovernMental
GovernMental是一个古老的庞氏骗局,积累了相当多的以太币(一度高达 1,100 以太币)。不幸的是,它容易受到本节提到的 DoS 漏洞的影响。etherik在Reddit 上发布了一篇帖子,描述了该合约如何要求删除一个大型映射才能提取以太币。删除该映射所需的 Gas 成本超过了当时区块 Gas 上限,因此无法提取这 1,100 以太币。合约地址为 0xF45717552f12Ef7cb65e95476F217Ea008167Ae3,你可以从交易0x0d80d67202bd9cb6773df8dd2020e719 0a1b0793e8ec4fc105257e8128f0506b中看到,这 1,100 个以太币最终是通过使用 2.5M gas 的交易获得的(当区块 gas 限制上升到足以允许这样的交易时)。
{/collapse-item}
{collapse-item label="十二、Block Timestamp Manipulation" open}
区块时间戳历来被用于各种应用,例如随机数的熵(更多详情请参阅“ 熵错觉”)、一段时间内锁定资金,以及各种与时间相关的状态变化条件语句。矿工可以对时间戳进行微调,如果在智能合约中错误地使用区块时间戳,则可能会造成危险。
有用的参考资料包括 Solidity 文档和Joris Bontje关于该主题的以太坊 Stack Exchange 问题。
漏洞
block.timestamp并且它的别名now可以被矿工操纵,只要他们有动机这么做。让我们构建一个简单的游戏,如roulette.sol所示,它很容易受到矿工的攻击。
例 11. roulette.sol
contract Roulette {
uint public pastBlockTime; // forces one bet per block
constructor() external payable {} // initially fund contract
// fallback function used to make a bet
function () external 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 以太币,有机会赢得合约的余额。这里假设 block.timestamp 的后两位数字是均匀分布的。如果是这样,那么中奖的概率是 1/15。
然而,众所周知,矿工可以根据需要调整时间戳。在这种特殊情况下,如果合约中有足够多的以太币矿池,那么解决区块的矿工就会受到激励,选择一个时间戳,使得 block.timestamp或now取余 15 等于0。这样,他们就有可能赢得锁定在合约中的以太币以及区块奖励。由于每个区块只允许一人下注,因此这也容易受到抢先交易攻击(更多详情,请参阅“竞争条件/抢先交易” )。
实际上,区块时间戳是单调递增的,因此矿工无法随意选择区块时间戳(它们必须晚于其前一个区块)。他们也只能将区块时间设置在不太远的未来,因为这些区块很可能会被网络拒绝(节点不会验证时间戳在未来的区块)。
预防技术
区块时间戳不应用于熵或生成随机数——即,它们不应该成为赢得游戏或改变重要状态的决定性因素(无论是直接的还是通过某种推导)。
有时需要时间敏感的逻辑;例如,解锁合约(时间锁定)、几周后完成 ICO 或强制执行到期日。有时建议使用block.number和平均区块时间来估算时间;10 second区块时间1 week大约等于60480 blocks。如果你只是需要 相对时间(例如:几分钟/几小时后才能取款),用 block.number 乘以平均出块时间更稳妥。因此,指定更改合约状态的区块编号可能更安全,因为矿工无法轻易操纵区块编号。BAT ICO合约就采用了这种策略。
如果合约并不特别关注矿工对区块时间戳的操纵,那么这可能是不必要的,但在制定合约时需要注意这一点。
现实世界的例子:GovernMental
上文提到的庞氏骗局GovernMental也容易受到基于时间戳的攻击。该合约会向每轮游戏中最后加入(至少一分钟)的玩家支付奖励。因此,矿工可以调整时间戳(调整为未来的时间,使其看起来像是已经过去了一分钟),让自己看起来像是一分钟内最后加入的玩家(尽管实际上并非如此)。更多详细信息,请参阅 Tanya Bahrynovska的文章“以太坊安全漏洞、黑客攻击及其修复的历史”。
{/collapse-item}
{collapse-item label="十三、Constructors with Care" open}
构造函数是一些特殊的函数,通常在初始化合约时执行关键的特权任务。在 Solidity v0.4.22 之前,构造函数被定义为与包含它们的合约同名的函数。在这种情况下,如果合约名称在开发过程中发生更改,而构造函数名称保持不变,它就会变成一个普通的可调用函数。可以想象,这可能会导致(并且已经)出现一些有趣的合约黑客攻击。
为了进一步了解,读者可能有兴趣尝试 Ethernaut 挑战(特别是 Fallout 级别)。
漏洞
如果合约名称被修改,或者构造函数名称出现拼写错误,导致与合约名称不匹配,则构造函数的行为将与普通函数相同。这可能会导致严重后果,尤其是在构造函数执行特权操作的情况下。考虑以下合约:
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函数,将自己设置为所有者,然后通过调用 来提取合约中的所有以太币withdraw。
预防技术
Solidity 编译器 0.4.22 版本已解决此问题。此版本引入了一个constructor指定构造函数的关键字,不再要求函数名称与合约名称匹配。建议使用此关键字指定构造函数,以避免命名问题。
真实示例:Rubixi
Rubixi是另一个存在此类漏洞的金字塔骗局。它最初名为DynamicPyramid,但在部署前合约名称已更改为Rubixi。构造函数的名称未更改,因此任何用户都可以成为创建者。在Bitcointalk上可以找到一些与此漏洞相关的有趣讨论。最终,它允许用户争夺创建者身份,以从金字塔骗局中索取费用。有关此漏洞的更多详细信息,请参阅“以太坊安全漏洞、黑客攻击及其修复的历史”。
{/collapse-item}
{collapse-item label="十四、Uninitialized Storage Pointers" open}
EVM 将数据存储为storage或memory。在开发合约时,强烈建议您准确理解其工作原理以及函数局部变量的默认类型。这是因为,如果变量初始化不当,可能会生成易受攻击的合约。
要了解有关 EVM 中的存储和内存的更多信息,请参阅 Solidity 文档中的数据位置、存储中状态变量的布局和内存中的布局。
| 笔记 | 本节内容基于 Stefan Beyer 的一篇精彩文章。受 Stefan 的启发,您可以阅读Reddit上的这篇帖子,了解更多相关内容 。 |
|---|
漏洞
函数中的局部变量根据其类型默认为storage或memory。未初始化的局部storage变量可能包含合约中其他存储变量的值;这可能会导致意外的漏洞,或被故意利用。
让我们考虑一下NameRegistrar.sol中相对简单的名称注册合同。
示例 12.NameRegistrar.sol
// 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;
}
// records who registered names
mapping(address => NameRecord) public registeredNameRecord;
// resolves hashes to addresses
mapping(bytes32 => address) public resolve;
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哈希值)并将该名称映射到一个地址。注册器最初处于锁定状态,require第 25 行的 阻止register 添加名称记录。该合约似乎无法使用,因为无法解锁注册表!然而,存在一个漏洞,无论unlocked变量是什么,都可以进行名称注册。
要讨论此漏洞,首先我们需要了解 Solidity 中的存储工作原理。作为高级概述(没有任何适当的技术细节 - 我们建议阅读 Solidity 文档以进行适当的审查),状态变量按顺序存储在合约中出现的槽位中(它们可以组合在一起,但在本例中没有,所以我们不必担心这一点)。因此,unlocked存在于 slot[0],registeredNameRecord在slot[1],和resolve在 slot[2]等中。每个槽位的大小为 32 字节(映射增加了复杂性,我们现在将忽略)。布尔值 unlocked看起来像0x000…0(64 个 0,不包括0x) false或0x000…1(63 个 0)true。如您所见,在这个特定的例子中存在大量的存储空间浪费。
下一个难题是,Solidity 在将复杂数据类型(例如结构体)初始化为局部变量时,默认将其放入存储中。因此,newRecord第 18 行的 默认设置为storage。该漏洞是由于newRecord未初始化而导致的。由于它默认为storage,因此它被映射到存储槽 [0],该槽当前包含指向 的指针unlocked。请注意,在第 19 行和第 20 行,我们将 设置newRecord.name为_name,并将newRecord.mappedAddress设置为_mappedAddress;这会更新 slot[0] 和 slot[1] 的存储位置,从而同时修改了unlocked和 关联的存储槽registeredNameRecord。
这意味着unlocked可以直接通过 函数bytes32 _name的参数进行修改register。因此,如果 的最后一个字节_name非零,它将修改存储的最后一个字节slot[0]并直接更改unlocked为true。这样的_name 值将导致require第 25 行的调用成功,因为我们已将其设置 unlocked为。请在 Remix 中尝试一下。注意,如果您使用以下形式的 ,true则函数将成功通过:_name
0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000001预防技术
Solidity 编译器会对未初始化的存储变量发出警告;开发者在构建智能合约时应特别注意这些警告。当前版本的 Mist (0.10) 不允许编译这些合约。处理复杂类型时,明确使用memory或storage说明符通常是一种很好的做法,以确保它们的行为符合预期。
现实世界的例子:OpenAddressLottery 和 CryptoRoulette Honey Pots
一个名为OpenAddressLottery的蜜罐被部署,利用这个未初始化的存储变量漏洞从一些潜在黑客那里收集以太币。该合约相当复杂,所以我们将分析留给Reddit 上的帖子,那里对攻击的解释相当清楚。
另一个蜜罐CryptoRoulette也利用了这种技巧来尝试收集一些以太币。如果您不明白这种攻击是如何运作的,请参阅 “对几个以太坊蜜罐合约的分析”,以了解该合约及其他合约的概述。
{/collapse-item}
{collapse-item label="十五、Floating Point and Precision" open}
截至目前最新版本Solidity(0.8.x / 0.9.x 尚未有定点/浮点数) ,Solidity 尚不支持定点数和浮点数。这意味着在 Solidity 中,浮点数表示必须使用整数类型构造。如果实现不当,可能会导致错误和漏洞。
| 笔记 | 如需进一步阅读,请参阅 以太坊合约安全技术和技巧 wiki。 |
|---|
漏洞
由于 Solidity 中没有定点类型,开发人员需要使用标准整数数据类型自行实现。在此过程中,开发人员可能会遇到一些陷阱。我们将在本节中重点介绍其中的一些陷阱。
让我们从一个代码示例开始(为了简单起见,我们将忽略本章前面讨论过的溢出/下溢问题):
contract FunWithNumbers {
uint constant public tokensPerEth = 10;
uint constant public weiPerEth = 1e18;
mapping(address => uint) public balances;
function buyTokens() external payable {
// convert wei to eth, then multiply by token rate
uint tokens = msg.value/weiPerEth*tokensPerEth;
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);
}
}这个简单的代币买卖合约存在一些明显的问题。虽然买卖代币的数学计算是正确的,但缺少浮点数会导致错误的结果。例如,在第 8 行购买代币时,如果值小于,则1 ether初始除法将导致0,而最终乘法的结果为0(例如,200 wei 除以1e18 weiPerEth等于0)。同样,在出售代币时,任何小于 的代币数量10也会导致0 ether。实际上,这里的舍入总是向下的,因此出售的29 tokens结果将是2 ether。
此合约的问题在于其精度仅为最接近的以太(即 1e18 wei)。当处理 ERC20 代币中的小数时,如果需要更高的精度,这可能会变得棘手。
预防技术
在智能合约中保持正确的精度非常重要,特别是在处理反映经济决策的比率和利率时。
您应该确保所使用的任何比率或比率都允许分数中的大分子。例如,我们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 级别的精度。它还有一些更严重的缺陷,但这些缺陷与区块链上获取熵的难度有关(参见 “熵错觉”)。有关 Ethstick 合约的进一步讨论,请参阅 Peter Vessenes 的另一篇文章 “以太坊合约将成为黑客的糖果”。
{/collapse-item}
{collapse-item label="十六、Tx.Origin Authentication" open}
Solidity 有一个全局变量 ,tx.origin它遍历整个调用堆栈,并包含最初发送调用(或交易)的账户地址。在智能合约中使用此变量进行身份验证会使合约容易受到类似网络钓鱼的攻击。
| 笔记 | 要进一步阅读,请参阅 dbryson 的以太坊Stack Exchange 问题、 Peter Vessenes 的“Tx.Origin 和以太坊哦天哪!”和 Chris Coverdale 的“Solidity:Tx Origin Attacks” 。 |
|---|
漏洞
授权用户使用变量tx.origin的合约通常容易受到网络钓鱼攻击,这种攻击可能会诱骗用户对易受攻击的合约执行经过身份验证的操作。
考虑Phishable.sol中的简单合同。
示例 13.Phishable.sol
contract Phishable {
address public owner;
constructor (address _owner) {
owner = _owner;
}
function () external payable {} // collect ether
function withdrawAll(address _recipient) public {
require(tx.origin == owner);
_recipient.transfer(this.balance);
}
}请注意,合约在第 11 行withdrawAll 使用 授权该函数tx.origin。此合约允许攻击者创建以下形式的攻击合约:
import "Phishable.sol";
contract AttackContract {
Phishable phishableContract;
address attacker; // The attacker's address to receive funds
constructor (Phishable _phishableContract, address _attackerAddress) {
phishableContract = _phishableContract;
attacker = _attackerAddress;
}
function () payable {
phishableContract.withdrawAll(attacker);
}
}攻击者可能会将该合约伪装成自己的私人地址,并通过社交工程手段诱骗受害者(钓鱼合约的所有者)向该地址发送某种形式的交易——例如向该合约发送一定数量的以太币。受害者除非小心谨慎,否则可能不会注意到攻击者地址里有代码,或者攻击者可能会将其伪装成多重签名钱包或某种高级存储钱包(请记住,公共合约的源代码默认情况下是不公开的)。
无论如何,如果受害者向该 AttackContract地址发送一笔包含足够 gas 的交易,它将调用 fallback 函数,该函数又会使用参数attacker 调用Phishable合约withdrawAll中的函数。这将导致所有资金从合约Phishable中提取到attacker该地址。这是因为首先初始化调用的地址是受害者(即Phishable合约的所有者)。因此,tx.origin 将等于owner,合约`Phishable第 11 行的 require将通过。
预防技术
tx.origin不应在智能合约中用于授权。这并不是说该tx.origin变量永远不应该被使用。它在智能合约中确实有一些合法的用例。例如,如果想要阻止外部合约调用当前合约,可以实现如下require形式的 require(tx.origin == msg.sender)。这可以防止使用中间合约调用当前合约,从而将合约限制为常规的无代码地址。
合约库
有大量现有代码可供复用,既可以作为可调用库部署在链上,也可以作为代码模板库部署在链下。平台库已部署,并以字节码智能合约的形式存在,因此在生产环境中使用前应格外谨慎。然而,使用成熟的现有平台库有很多优势,例如能够从最新的升级中获益,节省成本,并通过减少以太坊中活跃合约的总数来造福以太坊生态系统。
在以太坊中,最广泛使用的资源是OpenZeppelin 套件,这是一个丰富的合约库,涵盖了从 ERC20 和 ERC721 代币的实现,到各种众筹模型,再到合约中常见的简单行为,例如Ownable、Pausable或LimitBalance。此存储库中的合约经过了广泛的测试,在某些情况下甚至可以作为事实上的标准实现。它们可以免费使用,由Zeppelin以及不断增长的外部贡献者共同构建和维护。
Zeppelin 还推出了ZeppelinOS,这是一个开源平台,提供用于安全开发和管理智能合约应用程序的服务和工具。ZeppelinOS 在 EVM 之上提供了一个层,使开发者可以轻松启动可升级的 DApp,这些 DApp 链接到一个链上库,该库包含一个经过充分测试的合约,这些合约本身也具有可升级性。这些库的不同版本可以在以太坊平台上共存,并且其担保系统允许用户在不同方向上提出或推动改进。该平台还提供了一套用于调试、测试、部署和监控去中心化应用程序的链下工具。
{/collapse-item}
评论 (0)