虽然处于起步阶段,可是 Solidity 已被普遍采用,并被用于编译咱们今天看到的许多以太坊智能合约中的字节码。相应地,开发者和用户也得到许多严酷的教训,例如发现语言和EVM的细微差异。这篇文章旨在做为一个相对深刻和最新的介绍性文章,详述 Solidity 开发人员曾经踩过的坑,避免后续开发者重蹈覆辙。html
以太坊智能合约的特色之一是可以调用和利用其余外部合约的代码。合约一般也处理 Ether,所以一般会将 Ether 发送给各类外部用户地址。调用外部合约或将以太网发送到地址的操做须要合约提交外部调用。这些外部调用可能被攻击者劫持,迫使合约执行进一步的代码(即经过回退函数),包括回调自身。所以代码执行“从新进入”合约。这种攻击被用于臭名昭著的 DAO 攻击。python
有关重入攻击的进一步阅读,请参阅对智能合约的重入式攻击和 Consensus - 以太坊智能合约最佳实践。git
漏洞github
当合约将 Ether 发送到未知地址时,可能会发生此攻击。攻击者能够在 Fallback 函数中的外部地址处构建一个包含恶意代码的合约。所以,当合约向此地址发送 Ether 时,它将调用恶意代码。一般,恶意代码会在易受攻击的合约上执行一个函数、该函数会运行一项开发人员不但愿的操做。“重入”这个名称来源于外部恶意合约回复了易受攻击合约的功能,并在易受攻击的合约的任意位置“从新输入”了代码执行。web
为了澄清这一点,请考虑简单易受伤害的合约,该合约充当以太坊保险库,容许存款人每周只提取 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] 行,咱们向用户发送他们所要求的以太数量。考虑一个恶意攻击者建立下列合约,bash
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 的文章。
以太坊虚拟机(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() 函数。
一般,当 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)的一些诡异操做,并描述了客户端节点如何检查不正确的不变量,这可能会致使至关灾难性的客户端问题。
合约不使用 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 竞赛中出现了一些可利用该漏洞的合约的例子。
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('
',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 修改过。
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 万美圆。
以太坊区块链上的全部交易都是肯定性的状态转换操做。这意味着每笔交易都会改变以太坊生态系统的全球状态,而且它以可计算的方式进行,没有不肯定性。这最终意味着在区块链生态系统内不存在熵或随机性的来源。Solidity 中没有 rand() 功能。实现区中心化的熵源(随机性)是一个由来已久的问题,人们提出了不少想法来解决这个问题(例如,RanDAO,或是如 Vitalik 在这篇帖子中说的那样,使用哈希链)。
漏洞
以太坊平台上创建的首批合约中,有一些是围绕博彩的。从根本上讲,博彩须要不肯定性(能够下注),这使得在区块链(一个肯定性系统)上构建博彩系统变得至关困难。很明显,不肯定性只能来自于区块链外部的来源。朋友之间怡情仍是能够的(例如参见承诺揭示技术),然而,要让合约成为赌场(好比玩 21 点或是轮盘赌),则困可贵多。一个常见的误区是使用将来的块变量,如区块哈希值,时间戳,区块高低或是 Gas 上限。与这些设计有关的问题是,这些量都是由挖矿的矿工控制的,所以并非真正随机的。
例如,考虑一个轮盘赌智能合约,其逻辑是若是下一个块哈希值以偶数结尾,则返回一个黑色数字。一个矿工(或矿池)能够在黑色上下注 100 万美圆。若是他们挖出下一个块并发现块哈希值以奇数结尾,他们会高兴地不发布他们的块、继续挖矿、直到他们挖出一个块哈希值为偶数的块(假设区块奖励和费用低于 100 万美圆)。Martin Swende 在其优秀的博客文章中指出,使用过去或如今的区块变量可能会更具破坏性。此外,仅使用块变量意味着伪随机数对于一个块中的全部交易都是相同的,因此攻击者能够经过在一个块内进行屡次交易来使收益倍增(若是赌注有上限的话)。
预防技术
熵(随机性)的来源只能在区块链以外。在熟人之间,这能够经过使用诸如 commit-reveal 之类的系统来解决,或经过将信任模型更改成一组参与者(例如 RanDAO)。这也能够经过一个中心化的实体来完成,这个实体充当一个随机性的预言机(Oracle)。区块变量(通常来讲,有一些例外)不该该被用来提供熵,由于它们能够被矿工操纵。
真实世界示例:PRNG 合约
Arseny Reutov 分析了 3649 份使用某种伪随机数发生器(PRNG)的已上线智能合约,在发现 43 份可被利用的合约以后写了一篇博文。该文详细讨论了使用区块变量做为熵源的缺陷。
以太坊全球计算机的好处之一是可以重复使用代码、与已部署在网络上的合约进行交互。所以,大量合约引用外部合约,而且在通常运营中使用外部消息调用(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
contract Rot26Encryption {
event Result(string convertedString);
//rot13 encrypt a string
function rot13Encrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
//inline assembly to modify the string
assembly {
char := byte(0,char) // get the first byte
if and(gt(char,0x6D), lt(char,0x7B)) // if the character is in [n,z], i.e. wrapping.
{ char:= sub(0x60, sub(0x7A,char)) } // subtract from the ascii number a by the difference char is from z.
if iszero(eq(char, 0x20)) // ignore spaces
{mstore8(add(add(text,0x20), mul(i,1)), add(char,26))} // add 13 to char.
}
}
emit Result(text);
}
// rot13 decrypt a string
function rot13Decrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
assembly {
char := byte(0,char)
if and(gt(char,0x60), lt(char,0x6E))
{ char:= add(0x7B, sub(char,0x61)) }
if iszero(eq(char, 0x20))
{mstore8(add(add(text,0x20), mul(i,1)), sub(char,26))}
}
}
emit Result(text);
}
}
复制代码
它实现了 rot26 密码(每一个字母移动 26 个位置,明白了吗(微笑脸))。再次强调,你不须要了解本合约中的程序集。部署人员也能够连接下列合约:
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。
这种攻击并非专门针对 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 结尾的地址(很容易强制产生)而且使用了这个生成的地址,他们很容易从毫无防备的交易所中窃取令牌。
预防技术
我想很明显,在将全部输入发送到区块链以前对其进行验证能够防止这些类型的攻击。还应该指出的是参数排序在这里起着重要的做用。因为填充只发生在字符串末尾,智能合约中参数的缜密排序可能会缓解此攻击的某些形式。
真实世界的例子:未知
我尚不知道真实世界中发生的此类攻击的公开例子。
内容来源:简书-辉哥;公众号-慢雾科技
原文连接: 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。
线上课程推荐
线上课程:《8小时区块链智能合约开发实践》
培训讲师:《白话区块链》做者 蒋勇
课程原价:999元,现价 399元
更多福利:
@全部人,识别下图二维码转发课程邀请好友报名,便可得到报名费50%返利
@学员,报名学习课程并在规定时间内完成考试便可瓜分10000元奖金