Solidity 安全:已知攻击方法和常见防御模式综合列表

Posted by

Solidity 安全:已知攻击方法和常见防御模式综合列表

内容来源:简书-辉哥;公众号-慢雾科技

原文链接: 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。

Solidity 安全:已知攻击方法和常见防御模式综合列表

虽然处于起步阶段,但是 Solidity 已被广泛采用,并被用于编译我们今天看到的许多以太坊智能合约中的字节码。相应地,开发者和用户也获得许多严酷的教训,例如发现语言和EVM的细微差别。这篇文章旨在作为一个相对深入和最新的介绍性文章,详述 Solidity 开发人员曾经踩过的坑,避免后续开发者重蹈覆辙。

1

重入漏洞

以太坊智能合约的特点之一是能够调用和利用其他外部合约的代码。合约通常也处理 Ether,因此通常会将 Ether 发送给各种外部用户地址。调用外部合约或将以太网发送到地址的操作需要合约提交外部调用。这些外部调用可能被攻击者劫持,迫使合约执行进一步的代码(即通过回退函数),包括回调自身。因此代码执行“重新进入”合约。这种攻击被用于臭名昭著的 DAO 攻击。

有关重入攻击的进一步阅读,请参阅对智能合约的重入式攻击和 Consensus – 以太坊智能合约最佳实践。

漏洞

当合约将 Ether 发送到未知地址时,可能会发生此攻击。攻击者可以在 Fallback 函数中的外部地址处构建一个包含恶意代码的合约。因此,当合约向此地址发送 Ether 时,它将调用恶意代码。通常,恶意代码会在易受攻击的合约上执行一个函数、该函数会运行一项开发人员不希望的操作。“重入”这个名称来源于外部恶意合约回复了易受攻击合约的功能,并在易受攻击的合约的任意位置“重新输入”了代码执行。

为了澄清这一点,请考虑简单易受伤害的合约,该合约充当以太坊保险库,允许存款人每周只提取 1 个 Ether。

EtherStore.sol:

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 的数量。如果所要求的退出金额小于 1Ether 并且在上周没有发生撤回,它才会成功。额,真会是这样吗?…

该漏洞出现在 [17] 行,我们向用户发送他们所要求的以太数量。考虑一个恶意攻击者创建下列合约,

Attack.sol:

import “EtherStore.sol”;contract Attack { EtherStore public etherStore;

// intialise 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() 函数,并存入一些 Ehter(大于或等于1),比方说 1Ehter,在这个例子中。在这个例子中,我们假设一些其他用户已经将若干 Ehter 存入这份合约中,比方说它的当前余额就是 10 ether 。

然后会发生以下情况:

  • Attack.sol -Line [15] -EtherStore合约的 despoitFunds 函数将会被调用,并伴随 1Ether 的 mag.value(和大量的 Gas)。sender(msg.sender) 将是我们的恶意合约 (0x0…123) 。因此, balances[0x0..123] = 1 ether 。

  • Attack.sol – Line [17] – 恶意合约将使用一个参数来调用合约的 withdrawFunds() 功能。这将通过所有要求(合约的行 [12] – [16] ),因为我们以前没有提款。

  • EtherStore.sol – 行 [17] – 合约将发送 1Ether 回恶意合约。

  • Attack.sol – Line [25] – 发送给恶意合约的 Ether 将执行 fallback 函数。

  • Attack.sol – Line [26] – EtherStore 合约的总余额是 10Ether,现在是 9Ether,如果声明通过。

  • Attack.sol – Line [27] – 回退函数然后再次动用 EtherStore 中的 withdrawFunds() 函数并“重入” EtherStore合约。

  • EtherStore.sol – 行 [11] – 在第二次调用 withdrawFunds() 时,我们的余额仍然是 1Ether,因为 行[18] 尚未执行。因此,我们仍然有 balances[0x0..123] = 1 ether。lastWithdrawTime 变量也是这种情况。我们再次通过所有要求。

  • EtherStore.sol – 行[17] – 我们撤回另外的 1Ether。

  • 步骤4-8将重复 – 直到 EtherStore.balance >= 1,这是由 Attack.sol – Line [26] 所指定的。

  • Attack.sol – Line [26] – 一旦在 EtherStore 合约中留下少于 1(或更少)的 Ether,此 if 语句将失败。这样 EtherStore 就会执行合约的 行[18]和 行[19](每次调用 withdrawFunds() 函数之后都会执行这两行)。

  • EtherStore.sol – 行[18]和[19] – balances 和 lastWithdrawTime 映射将被设置并且执行将结束。

最终的结果是,攻击者只用一笔交易,便立即从 EtherStore 合约中取出了(除去 1 个 Ether 以外)所有的 Ether。

预防技术

有许多常用技术可以帮助避免智能合约中潜在的重入漏洞。

  • 首先是(在可能的情况下)在将 Ether 发送给外部合约时使用内置的 transfer() 函数。转账功能只发送 2300 gas 不足以使目的地址/合约调用另一份合约(即重入发送合约)。

  • 第二种技术是确保所有改变状态变量的逻辑发生在 Ether 被发送出合约(或任何外部调用)之前。在这个 EtherStore 例子中,EtherStore.sol – 行[18]和行[19] 应放在 行[17] 之前。将任何对未知地址执行外部调用的代码,放置在本地化函数或代码执行中作为最后一个操作,是一种很好的做法。这被称为检查效果交互(checks-effects-interactions)模式。

  • 第三种技术是引入互斥锁。也就是说,要添加一个在代码执行过程中锁定合约的状态变量,阻止重入调用。

给 EtherStore.sol 应用所有这些技术(同时使用全部三种技术是没必要的,只是为了演示目的而已)会出现如下的防重入合约:

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;

}

}

真实的例子:The DAO

The DAO(分散式自治组织)是以太坊早期发展的主要黑客之一。当时,该合约持有1.5亿美元以上。重入在这次攻击中发挥了重要作用,最终导致了 Ethereum Classic(ETC)的分叉。有关The DAO 漏洞的详细分析,请参阅 Phil Daian 的文章。

2

算法上下溢出

以太坊虚拟机(EVM)为整数指定固定大小的数据类型。这意味着一个整型变量只能有一定范围的数字表示。例如,一个 uint8 ,只能存储在范围 [0,255] 的数字。试图存储 256 到一个 uint8 将变成 0。不加注意的话,只要没有检查用户输入又执行计算,导致数字超出存储它们的数据类型允许的范围,Solidity 中的变量就可以被用来组织攻击。

要进一步阅读算法上下溢出,请参阅如何保护您的智能合约,以太坊智能合约最佳实践和以太坊,Solidity 和整数溢出:像身处1970 年那样为区块链编程

漏洞

当执行操作需要固定大小的变量来存储超出变量数据类型范围的数字(或数据)时,会发生数据上溢/下溢。

例如,从一个存储 0 的 uint8 (无符号的 8 位整数,即只有正数)变量中减去 1,将导致该变量的值变为 255。这是一个下溢。我们明明为该 uint8 分配了一个低于其储存范围的值,结果却是 绕回来 变成了 uint8 所能储存的最大值。同样,给一个 uint8 加上 2^8=256 会使变量保持不变,因为我们已经绕过了 uint 的整个值域又回到原值(对于数学家来说,这类似于将三角函数的角度加上 2pi ,sin(x) = sin(x + 2pi))。添加大于数据类型范围的数字称为上溢。为了清楚起见,添加 257 到一个目前仅有 0 值的 uint8 变量将变成数字 1。将固定类型变量视为循环有时很有启发意义,如果我们加入的数字超出最大可存储数字,等于是从零开始加上超出额,反之也是从零开始(从零中减去一定数额,等同于从最大数字往下减该数额)。

这些类型的漏洞允许攻击者滥用代码并创建意外的逻辑流程。例如,请考虑下面的时间锁定合约。

TimeLock.sol:

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

balances[msg.sender] = 0;

msg.sender.transfer(balances[msg.sender]);

}

}

这份合约的设计就像是一个时间保险库,用户可以将 Ether 存入合约,并在那里锁定至少一周。如果用户选择的话,用户可以延长超过1周的时间,但是一旦存放,用户可以确信他们的 Ether 会被安全锁定至少一周。有没有别的可能性?…

如果用户被迫交出他们的私钥(考虑绑票的情形),像这样的合约可能很方便,以确保在短时间内无法获得 Ether。但是,如果用户已经锁定了 100Ether 合约并将其密钥交给了攻击者,那么攻击者可以使用溢出来接收 Ether,无视 lockTime 的限制。

攻击者可以确定他们所持密钥的地址的 lockTime (它是一个公共变量)。我们称之为 userLockTime 。然后他们可以调用该 increaseLockTime 函数并将数字 2^256 – userLockTime 作为参数传入。该数字将被添加到当前的 userLockTime 并导致溢出,重置 lockTime[msg.sender] 为0。攻击者然后可以简单地调用 withdraw 函数来获得他们的奖励。

我们来看另一个例子,来自 Ethernaut Challanges 的这个例子。

SPOILER ALERT: 如果你还没有完成 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];

}

}

这是一个简单的 Token 合约,它使用一个 transfer() 函数,允许参与者转移他们的 Token。你能看出这份合约中的错误吗?

缺陷出现在 transfer() 功能中。行[13]上的 require 语句可以使用下溢来绕过。考虑一个没有余额的用户。他们可以用任何非零值 _value 调用 transfer() 函数,并将 _value 传入 行[13] 上的 require 语句。因为 balances[msg.sender] 为零(也即是 uint256 ),减去任何正数(不包括 2^256 )都将导致正数(由于我们上面描述的下溢)。对于 行[14] 也是如此,我们的余额将记入正数。因此,在这个例子中,我们由于下溢漏洞得到了免费的 Token。

预防技术

防止溢出漏洞的(当前)常规技术是使用或建立取代标准数学运算符的数学库; 加法,减法和乘法(除法被排除在外,因为它不会导致上溢/下溢,并且 EVM 除以 0 时会丢出错误)。

OppenZepplin 在构建和审计 Ethereum 社区可以利用的安全库方面做得非常出色。特别是,他们的 SafeMath 是一个用来避免上溢/下溢漏洞的参考或库。

为了演示如何在 Solidity 中使用这些库,让我们使用 Open Zepplin 的 SafeMath 库更正合约 TimeLock。防溢出的合约长这样:

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

balances[msg.sender] = 0; msg.sender.transfer(balances[msg.sender]); }}

请注意,所有标准的数学运算已被 SafeMath 库中定义的数学运算所取代。该 TimeLock 合约不会再执行任何能够导致下溢/上溢的操作。

实际示例:PoWHC 和批量传输溢出(CVE-2018-10299)

一个 4chan 小组认为,用 Solidity 在 Ethereum上 构建一个庞氏骗局是个好主意。他们称它为弱手硬币证明(PoWHC)。不幸的是,似乎合约的作者之前没有看到上溢/下溢问题,因此,866Ether 从合约中解放出来。Eric Banisadar 的文章对下溢是如何发生的作出了很好的概述(这与上面的 Ethernaut 挑战不太相似)。

一些开发人员还为一些 ERC20 Token 合约实施了一项 batchTransfer() 函数。该实现包含溢出。这篇文章对此进行了解释,但是我认为标题有误导性,因为它与 ERC20 标准无关,而是一些 ERC20 Token 合约实现了易受攻击的 batchTransfer() 函数。

3

不期而至的 Ether

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

关于这方面的进一步阅读,请参阅如何保护您的智能合约:6 和 Solidity 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)的一些诡异操作,并描述了客户端节点如何检查不正确的不变量,这可能会导致相当灾难性的客户端问题。

4

预先发送的 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 竞赛中出现了一些可利用该漏洞的合约的例子。

5

Delegatecall

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

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

漏洞

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 多签名钱包中的所有以太都会立即丢失或者说永久不可恢复。

注 1:此代码已从 web3j 修改过。

6

默认可见性(Visibility)

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,这是一种很好的做法。最近版本的 Solidity 将在编译过程中为没有明确设置可见性的函数显示警告,以鼓励这种做法。

真实世界示例:Parity MultiSig Wallet(First Hack)

在 Parity 多签名钱包遭受的第一次黑客攻击中,约值 3100 万美元的 Ether 被盗,主要是三个钱包。Haseeb Qureshi 在这篇文章中给出了一个很好的回顾。

实质上,这些多签名钱包(可以在这里找到)是从一个基础的 Wallet 合约构建出来的,该基础合约调用包含核心功能的库合约(如真实世界中的例子:Parity Multisig(Second Hack)中所述)。库合约包含初始化钱包的代码,如以下代码片段所示

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,攻击者可以在部署的合约上调用这些功能,并将所有权重置为攻击者地址。作为主人,袭击者随后取走钱包中所有的 Ether,损失高达 3100 万美元。

7

随机数误区

以太坊区块链上的所有交易都是确定性的状态转换操作。这意味着每笔交易都会改变以太坊生态系统的全球状态,并且它以可计算的方式进行,没有不确定性。这最终意味着在区块链生态系统内不存在熵或随机性的来源。Solidity 中没有 rand() 功能。实现区中心化的熵源(随机性)是一个由来已久的问题,人们提出了很多想法来解决这个问题(例如,RanDAO,或是如 Vitalik 在这篇帖子中说的那样,使用哈希链)。

漏洞

以太坊平台上建立的首批合约中,有一些是围绕博彩的。从根本上讲,博彩需要不确定性(可以下注),这使得在区块链(一个确定性系统)上构建博彩系统变得相当困难。很明显,不确定性只能来自于区块链外部的来源。朋友之间怡情还是可以的(例如参见承诺揭示技术),然而,要让合约成为赌场(比如玩 21 点或是轮盘赌),则困难得多。一个常见的误区是使用未来的块变量,如区块哈希值,时间戳,区块高低或是 Gas 上限。与这些设计有关的问题是,这些量都是由挖矿的矿工控制的,因此并不是真正随机的。

例如,考虑一个轮盘赌智能合约,其逻辑是如果下一个块哈希值以偶数结尾,则返回一个黑色数字。一个矿工(或矿池)可以在黑色上下注 100 万美元。如果他们挖出下一个块并发现块哈希值以奇数结尾,他们会高兴地不发布他们的块、继续挖矿、直到他们挖出一个块哈希值为偶数的块(假设区块奖励和费用低于 100 万美元)。Martin Swende 在其优秀的博客文章中指出,使用过去或现在的区块变量可能会更具破坏性。此外,仅使用块变量意味着伪随机数对于一个块中的所有交易都是相同的,所以攻击者可以通过在一个块内进行多次交易来使收益倍增(如果赌注有上限的话)。

预防技术

熵(随机性)的来源只能在区块链之外。在熟人之间,这可以通过使用诸如 commit-reveal 之类的系统来解决,或通过将信任模型更改为一组参与者(例如 RanDAO)。这也可以通过一个中心化的实体来完成,这个实体充当一个随机性的预言机(Oracle)。区块变量(一般来说,有一些例外)不应该被用来提供熵,因为它们可以被矿工操纵。

真实世界示例:PRNG 合约

Arseny Reutov 分析了 3649 份使用某种伪随机数发生器(PRNG)的已上线智能合约,在发现 43 份可被利用的合约之后写了一篇博文。该文详细讨论了使用区块变量作为熵源的缺陷。

8

外部合约引用

以太坊全球计算机的好处之一是能够重复使用代码、与已部署在网络上的合约进行交互。因此,大量合约引用外部合约,并且在一般运营中使用外部消息调用(External Message Call)来与这些合约交互。恶意行为者的意图可以隐藏在这些不起眼的外部消息调用之下,下面我们就来探讨这些瞒天过海的方法。

漏洞

在 Solidity 中,任何地址都可以被当作合约,无论地址上的代码是否表示需要用到合约类型。这可能是骗人的,特别是当合约的作者试图隐藏恶意代码时。让我们以一个例子来说明这一点:

考虑一段代码,它初步地实现了 Rot13 密码。

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 {

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’。这里的集合并不重要,所以如果在这个阶段看不出问题,不必焦躁。

考虑以下使用此代码进行加密的合约,

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 地址并不是公开的或保证不变的。因此,合约的配置人员可以在指向该合约的构造函数中给出一个地址:

//encryption contract

contractRot26Encryption {

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 个位置,明白了吗(微笑脸))。再次强调,你不需要了解本合约中的程序集。部署人员也可以链接下列合约:

contract Print{

event Print(string text);

function rot13Encrypt(string text) public {

emit Print(text); } }

如果这些合约中的任何一个的地址在构造函数中给出,那么 encryptPrivateData() 函数只会产生一个打印出未加密私有数据的事件(Event)。

尽管在这个例子中,在构造函数中设置了类似库的合约,但是特权用户(例如 owner )可以更改库合约地址。如果被链接的合约不包含被调用的函数,则将执行回退函数。例如,对于行 encryptionLibrary.rot13Encrypt() ,如果指定的合约 encryptionLibrary 是:

contract Blank {

event Print(string text);

function () {

emit Print(“Here”);

//put malicious code here and it will run

}

}

那么会发出一个带有“Here”文字的事件。因此,如果用户可以更改合约库,原则上可以让用户在不知不觉中运行任意代码。

注意:不要使用这些加密合约,因为智能合约的输入参数在区块链上可见。另外,Rot密码并不是推荐的加密技术:p

预防技术

如上所示,无漏洞合约可以(在某些情况下)以恶意行为的方式部署。审计人员可以公开验证合约并让其所有者以恶意方式进行部署,从而产生具有漏洞或恶意的公开审计合约。

有许多技术可以防止这些情况发生。

一种技术是使用 new 关键词来创建合约。在上面的例子中,构造函数可以写成:

constructor(){ encryptionLibrary = new Rot13Encryption(); }

这样,引用合约的一个实例就会在部署时创建,并且部署者无法在不修改智能合约的情况下用其他任何东西替换 Rot13Encryption 合约。

另一个解决方案是如果已知外部合约地址的话,对所有外部合约地址进行硬编码。

一般来说,应该仔细查看调用外部合约的代码。作为开发人员,在定义外部合约时,最好将合约地址公开(在 Honey-pot 的例子中就不是这样),以便用户轻松查看合约引用了哪些代码。反过来说,如果合约具有私人变量合约地址,则它可能是某人恶意行为的标志(如现实示例中所示)。如果特权(或任何)用户能够更改用于调用外部函数的合约地址,(在去中心化系统的情境中)实现时间锁定或投票机制就变得很重要,为要允许用户查看哪些代码正在改变,或让参与者有机会选择加入/退出新的合约地址。

真实世界的例子:可重入钓鱼合约

最近主网上出现了一些钓鱼合约(Honey Pot)。这些合约试图打败那些想要利用合约漏洞的黑客,让他们反过来在想要利用的合约中损失 Ether。一个例子是通过在构造函数中用恶意合约代替期望的合约来发动上述攻击。代码可以在这里找到:

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

一位 reddit 用户发布了这篇文章,解释他们如何在他们想利用可重入漏洞的合约中失去 1 Ether。

9

短地址/参数攻击

这种攻击并不是专门针对 Solidity 合约执行的,而是针对可能与之交互的第三方应用程序执行的。为了完整性,我添加了这个攻击,然后意识到了参数可以在合约中被操纵。

有关进一步阅读,请参阅 ERC20 短地址攻击说明,ICO智能合约漏洞:短地址攻击或这个 Reddit 帖子。

漏洞

将参数传递给智能合约时,参数将根据 ABI 规范进行编码。可以发送比预期参数长度短的编码参数(例如,发送只有 38 个十六进制字符(19 个字节)的地址而不是标准的 40 个十六进制字符(20 个字节))。在这种情况下,EVM 会将 0 填到编码参数的末尾以补成预期的长度。

当第三方应用程序不验证输入时,这会成为问题。最明显的例子是当用户请求提款时,交易所不验证 ERC20 Token 的地址。Peter Venesses 的文章 “ERC20 短地址攻击解释”中详细介绍了这个例子。

考虑一下标准的 ERC20 传输函数接口,注意参数的顺序,

function transfer(address to, uint tokens) public returns (bool success);

现在考虑一下,一个交易所持有大量代(比方说 REP ),并且,某用户希望取回他们存储的100个代币。用户将提交他们的地址, 0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead 以及代币的数量 100 。交易所将根据 transfer() 函数指定的顺序对这些参数进行编码,即先是 address 然后是 tokens 。编码结果将是 a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000000000000000000000000000000000000056bc75e2d63100000。

前四个字节(a9059cbb)是 transfer() 函数签名/选择器,第二个 32 字节是地址,最后 32 个字节是表示代币数量的 uint256 。请注意,最后的十六进制数 56bc75e2d63100000 对应于 100 个代币(包含 18 个小数位,这是由 REP 代币合约指定的)。

好的,现在让我们看看如果我们发送一个丢失 1 个字节(2 个十六进制数字)的地址会发生什么。具体而言,假设攻击者以 0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde 作为地址发送(缺少最后两位数字),并取回相同的 100 个代币。如果交易所没有验证这个输入,它将被编码为 a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde0000000000000000000000000000000000000000000000056bc75e2d6310000000。差别是微妙的。

请注意, 00 已被填充到编码的末尾,以补完发送的短地址。当它被发送到智能合约时, address 参数将被读为 0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00 并且值将被读为 56bc75e2d6310000000 (注意两个额外的 0)。此值现在是 25600 个代币(值已被乘以 256 )。在这个例子中,如果交易所持有这么多的代币,用户会取出 25600 个代币(而交换所认为用户只是取出 100)到修改后的地址。

很显然,在这个例子中攻击者不会拥有修改后的地址,但是如果攻击者产生了以 0 结尾的地址(很容易强制产生)并且使用了这个生成的地址,他们很容易从毫无防备的交易所中窃取令牌。

预防技术

我想很明显,在将所有输入发送到区块链之前对其进行验证可以防止这些类型的攻击。还应该指出的是参数排序在这里起着重要的作用。由于填充只发生在字符串末尾,智能合约中参数的缜密排序可能会缓解此攻击的某些形式。

真实世界的例子:未知

我尚不知道真实世界中发生的此类攻击的公开例子。

— 线上课程推荐 —

线上课程:《8小时区块链智能合约开发实践》

培训讲师:《白话区块链》作者 蒋勇

课程原价:999元,现价 399元

更多福利:

  • @所有人,识别下图二维码转发课程邀请好友报名,即可获得报名费50%返利

  • @学员,报名学习课程并在规定时间内完成考试即可瓜分10000元奖金

Solidity 安全:已知攻击方法和常见防御模式综合列表

始发于微信公众号: 区块链社区HiBlock