干货 | Solidity 安全:已知攻击方法和常见防御模式综合列表,Part-2

原文链接: https://blog.sigmaprime.io/solidity-security.html 作者: Dr Adrian Manning 翻译&校对: 爱上平顶山@慢雾安全团队 & keywolf@慢雾安全团队

致谢(校对):yudan、阿剑@EthFans

本文由慢雾安全团队翻译,这里是最新译文的 GitHub 地址:https://github.com/slowmist/Knowledge-Base/blob/master/solidity-security-comprehensive-list-of-known-attack-vectors-and-common-anti-patterns-chinese.md

不期而至的 Ether

通常,当 Ether 发送到合约时,它必须执行回退功能或合约中描述的其他功能。这里有两个例外,合约可能会收到了 Ether 但并不会执行任何函数。通过收到以太币来触发代码的合约,对强制将以太币发送到某个合约这类攻击是非常脆弱的。

关于这方面的进一步阅读,请参阅如何保护您的智能合约:6Solidity security patterns - forcing ether to a contract

漏洞

一种常用的防御性编程技术对于执行正确的状态转换或验证操作很有用,它是不变量检查(Invariant-checking)。该技术涉及定义一组不变量(不应改变的度量或参数),并且在单个(或多个)操作之后检查这些不变量保持不变。这基本上是很好的设计,保证受到检查的不变量在实际上保持不变。不变量的一个例子是发行量固定的 ERC20 代币合约的 totalSupply 。不应该有函数能修改此不变量,因此可以在该 transfer() 函数中添加一个检查以确保 totalSupply 保持未修改状态,确保函数按预期工作。

不管智能合约中规定的规则如何,有一个量,特别容易诱导开发人员将其当作明显的“不变量”来使用,但它在事实上是可以由外部用户来操纵的,那便是合约中存储的 Ether 数量。通常,开发人员刚开始学习 Solidity 时,他们有一种误解,认为合约只能通过 payable 函数接受或获得 Ether。这种误解可能会导致合约对其内部的 ETH 余额有错误的假设,进而导致一系列的漏洞。此漏洞的明显信号是(不正确地)使用 this.balance 。正如我们将看到的,错误地使用 this.balance 会导致这种类型的严重漏洞。

有两种方式可以将 Ether(强制)发送给合约,而无需使用 payable 函数或执行合约中的任何代码。这些在下面列出。

自毁

任何合约都能够实现该 selfdestruct(address) 功能,该功能从合约地址中删除所有字节码,并将所有存储在那里的 Ether 发送到参数指定的地址。如果此指定的地址也是合约,则不会调用任何功能(包括故障预置)。因此,使用 selfdestruct() 函数可以无视目标合约中存在的任何代码,强制将 Ether 发送给任一目标合约,包括没有任何可支付函数的合约。这意味着,任何攻击者都可以创建带有 selfdestruct() 函数的合约,向其发送 Ether,调用 selfdestruct(target) 并强制将 Ether 发送至 target 合约。Martin Swende 有一篇出色的博客文章描述了自毁操作码(Quirk#2)的一些诡异操作,并描述了客户端节点如何检查不正确的不变量,这可能会导致相当灾难性的客户端问题。

预先发送的 Ether

合约不使用 selfdestruct() 函数或调用任何 payable 函数仍可以接收到 Ether 的第二种方式是把 Ether 预装进合约地址。合约地址是确定性的,实际上地址是根据创建合约的地址及创建合约的交易 Nonce 的哈希值计算得出的,即下述形式: address = sha3(rlp.encode([account_address,transaction_nonce]) 请参阅 Keyless Ether 在这一点上的一些有趣用例)。这意味着,任何人都可以在创建合约之前计算出合约地址,并将 Ether 发送到该地址。当合约确实创建时,它将具有非零的 Ether 余额。

根据上述知识,我们来探讨一些可能出现的缺陷。 考虑过于简单的合约,

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() 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); 
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(redeemableEther[msg.sender]);
    }
 }    

这个合约代表一个简单的游戏(自然会引起条件竞争(Race-conditions)),玩家可以将 0.5 ether 发送给合约,希望成为第一个达到三个里程碑之一的玩家。里程碑以 Ether 计价。当游戏结束时,第一个达到里程碑的人可以获得合约的部分 Ether。当达到最后的里程碑(10 Ether)时,游戏结束,用户可以申请奖励。

EtherGame 合约的问题出自在 [14] 行(以及相关的 [16] 行)和 [32] 行中对 this.balance 的错误使用。一个调皮的攻击者可以通过(上面讨论过的) selfdestruct() 函数强行发送少量的以太,比如 0.1 ether ,以防止未来的玩家达到一个里程碑。由于所有合法玩家只能发送 0.5 ether 增量,而合约收到了 0.1 ether ,合约的 this.balance 不再是半个整数。这会阻止 [18]、[21]和[24] 行的所有条件成立。

更糟糕的是,一个因错过了里程碑而复仇心切的攻击者可能会强行发送 10 ether (或者会将合约的余额推到高出 finalMileStone 的数量),这将永久锁定合约中的所有奖励。这是因为 claimReward() 函数总是会回弹,因为 [32] 行中的要求(即 this.balance 大于 finalMileStone )。

预防技术

这个漏洞通常是由于错误运用 this.balance 而产生的。如果可能,合约逻辑应该避免依赖于合约余额的确切值,因为它可以被人为地操纵。如果应用基于 this.balance 函数的逻辑语句,请确保考虑到了飞来横 Ether。

如果需要存储 Ether 的确定值,则应使用自定义变量来获得通过可支付函数获得的增量,以安全地追踪储存 Ether 的值。这个变量不应受到通过调用 selfdestruct() 强制发送的 Ether 的影响。

考虑到这一点,修正后的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() 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); 
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(redeemableEther[msg.sender]);
    }
 }    

在这里,我们刚刚创建了一个新变量, depositedEther ,它跟踪已知的 Ether 存储量,并且这也是我们执行需求和测试时用到的变量。请注意,我们不再参考 this.balance

真实世界的例子:未知

我还没有找到该漏洞在真实世界中被利用的例子。然而,在 Underhanded Solidity 竞赛中出现了一些可利用该漏洞的合约的例子。

Delegatecall

CALL DELEGATECALL 操作码是非常有用的,它们让 Ethereum 开发者将他们的代码模块化(Modularise)。用 CALL 操作码来处理对合约的外部标准信息调用(Standard Message Call)时,代码在外部合约/功能的环境中运行。 DELEGATECALL 操作码也是标准消息调用,但在目标地址中的代码会在调用合约的环境下运行,也就是说,保持 msg.sender msg.value 不变。该功能支持实现库,开发人员可以为未来的合约创建可重用的代码。

虽然这两个操作码之间的区别很简单直观,但是使用 DELEGATECALL 可能会导致意外的代码执行。

有关进一步阅读,请参阅 Stake Exchange上关于以太坊的这篇提问Solidity 官方文档以及如何保护您的智能合约:6

漏洞

DELEGATECALL 会保持调用环境不变的属性表明,构建无漏洞的定制库并不像人们想象的那么容易。库中的代码本身可以是安全的,无漏洞的,但是当在另一个应用的环境中运行时,可能会出现新的漏洞。让我们看一个相当复杂的例子,使用斐波那契数字。

考虑下面的可以生成斐波那契数列和相似形式序列的库:FibonacciLib.sol[^ 1]

// 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 个斐波那契数。它允许用户更改第 0 个 start 数字并计算这个新序列中的第 n 个斐波那契数字。

现在我们来考虑一个利用这个库的合约。

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) 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));
    }
}

该合约允许参与者从合约中提取 ether,金额等于参与者提款订单对应的斐波纳契数字;即第一个参与者获得 1 ether,第二个参与者获得 1,第三个获得 2,第四个获得 3,第五个获得 5 等等(直到合约的余额小于被取出的斐波纳契数)。

本合约中的许多要素可能需要一些解释。首先,有一个看起来很有趣的变量, fibSig 。这包含字符串“fibonacci(uint256)”的 Keccak(SHA-3) 哈希值的前4个字节。这被称为函数选择器,它被放入 calldata 中以指定调用智能合约的哪个函数。在 [21] 行的 delegatecall 函数中,它被用来指出:我们希望运行 fibonacci(uint256) 函数。 delegatecall 的第二个参数是我们传递给函数的参数。其次,我们假设 FibonacciLib 库的地址在构造函数中正确引用(部署攻击向量部分会讨论与合约参考初始化相关的潜在漏洞)。

你能发现这份合约中的错误吗?如果你把它放到 Remix 里面编译,存入 Ether 并调用 withdraw() ,它可能会回滚状态。(Revert)

您可能已经注意到,在库和主调用合约中都使用了状态变量 start 。在库合约中, start 用于指定斐波纳契数列的起点,它被设置为 0,而 FibonacciBalance 合约中它被设置为 3。你可能还注意到,FibonacciBalance 合约中的回退函数允许将所有调用传递给库合约,因此也允许调用库合约的 setStart() 函数。回想一下,我们会保留合约状态,那么看起来你就可以据此改变本地 FibonnacciBalance 合约中 start 变量的状态。如果是这样,一个用户可以取出更多的 Ether,因为最终的 calculatedFibNumber 依赖于 start 变量(如库合约中所见)。实际上,该 setStart() 函数不会(也不能)修改 FibonacciBalance 合约中的 start 变量。这个合约中的潜在弱点比仅仅修改 start 变量要糟糕得多。

在讨论实际问题之前,我们先快速绕道了解状态变量( storage 变量)实际上是如何存储在合约中的。状态或 storage 变量(贯穿单个交易、始终都存在的变量)在合约中引入时,是按顺序放置在 slots 中的。(这里有一些复杂的东西,我鼓励读者阅读存储中状态变量的布局以便更透彻地理解)。

作为一个例子,让我们看看库合约。它有两个状态变量, start calculatedFibNumber 。第一个变量是 start ,因此它被存储在合约的存储位置 slot[0] (即第一个 slot)。第二个变量 calculatedFibNumber 放在下一个可用的存储位置中,也就是 slot[1] 。如果我们看看 setStart() 这个函数,它可以接收一个输入并依据输入来设置 start 。因此, setStart() 函数可以将 slot[0] 设置为我们在该函数中提供的任何输入。同样, 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] ,在我们当前的环境中就是 calculatedFibNumber 。我们预期是这样的(即执行后, calculatedFibNumber 会得到调整)。但是,请记住,FibonacciLib 合约中,位于存储位置 slot[0] 中的是 start 变量,而在当前(FibonacciBalance)合约中就是 fibonacciLibrary 的地址。这意味着 fibonacci() 函数会带来意想不到的结果。这是因为它引用 start slot[0] ),而该位置在当前调用环境中是 fibonacciLibrary 的地址(如果用 uint 来表达的话,该值会非常大)。因此,调用 withdraw() 函数很可能会导致状态回滚(Revert),因为 calcultedFibNumber 会返回 uint(fibonacciLibrary) ,而合约却没有那么多数量的 Ether。

更糟糕的是,FibonacciBalance 合约允许用户通过 [26] 行上的回退(Fallback)函数调用 fibonacciLibrary 的所有函数。正如我们前面所讨论的那样,这包括 setStart() 函数。我们讨论过这个功能允许任何人修改或设置 slot[0] 的值。在当前合约中,存储位置 slot[0] 是 fibonacciLibrary 地址。因此,攻击者可以创建一个恶意合约(下面是一个例子),将恶意合约地址转换为一个 uint 数据(在 python 中可以使用 int('<address>',16) 轻松完成),然后调用 setStart(<attack_contract_address_as_uint>) ,这会将 fibonacciLibrary 转变为攻击合约的地址。然后,无论何时用户调用 withdraw() 或回退函数,恶意合约都会运行(它可以窃取合约的全部余额),因为我们修改了 fibonacciLibrary 指向的实际地址。这种攻击合约的一个例子是,

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,并通过这些 delegatecall 函数尝试不同的攻击合约和状态更改。

同样重要的是要注意,当我们说 delegatecall 会保留状态,我们说的并不是合约中不同名称下的变量,而是这些名称指向的实际存储位置。从这个例子中可以看出,一个简单的错误,可能导致攻击者劫持整个合约及其 Ether。

预防技术

Solidity 为实现库合约提供了关键字 library (参见 Solidity Docs 了解更多详情)。这确保了库合约是无状态(Stateless)且不可自毁的。强制让 library 成为无状态的,可以缓解本节所述的存储环境的复杂性。无状态库也可以防止攻击者直接修改库状态的攻击,以实现依赖库代码的合约。作为一般的经验法则,在使用时 DELEGATECALL 时要特别注意库合约和调用合约的可能调用上下文,并且尽可能构建无状态库。

真实世界示例:Parity Multisig Wallet(Second Hack)

Parity 多签名钱包第二次被黑事件是一个例子,说明了如果在非预期的环境中运行,良好的库代码也可以被利用。关于这次被黑事件,有很多很好的解释,比如这个概述:Anthony Akentiev 写的 再一次解释 Parity 多签名钱包被黑事件,这个stack exchange 上的问答深入了解Parity Multisig Bug

要深入理解这些参考资料,我们要探究一下被攻击的合约。受攻击的库合约和钱包合约可以在 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 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);
  }
  
  ...
  
}

再看钱包合约,

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 合约基本上会通过 delegate call 将所有调用传递给 WalletLibrary。此代码段中的常量地址 _walletLibrary ,即是实际部署的 WalletLibrary 合约的占位符(位于 0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4 )。

这些合约的预期运作是生成一个简单的可低成本部署的 Wallet 合约,合约的代码基础和主要功能都在 WalletLibrary 合约中。不幸的是,WalletLibrary 合约本身就是一个合约,并保持它自己的状态。你能能不能看出为什么这会是一个问题?

因为有可能向 WalletLibrary 合约本身发送调用请求。具体来说,WalletLibrary 合约可以初始化,并被用户拥有。一个用户通过调用 WalletLibrary 中的 initWallet() 函数,成为了 Library 合约的所有者。同一个用户,随后调用 kill() 功能。因为用户是 Library 合约的所有者,所以修改传入、Library 合约自毁。因为所有现存的 Wallet 合约都引用该 Library 合约,并且不包含更改引用的方法,因此其所有功能(包括取回 Ether 的功能)都会随 WalletLibrary 合约一起丢失。更直接地说,这种类型的 Parity 多签名钱包中的所有以太都会立即丢失或者说永久不可恢复。