干货 | Solidity 安全:已知攻击方法和常见防御模式综合列表,Part-1
虽然处于起步阶段,但是 Solidity 已被广泛采用,并被用于编译我们今天看到的许多以太坊智能合约中的字节码。相应地,开发者和用户也获得许多严酷的教训,例如发现语言和EVM的细微差别。这篇文章旨在作为一个相对深入和最新的介绍性文章,详述 Solidity 开发人员曾经踩过的坑,避免后续开发者重蹈覆辙。
重入漏洞
以太坊智能合约的特点之一是能够调用和利用其他外部合约的代码。合约通常也处理 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 的文章。
算法上下溢出
以太坊虚拟机(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()
函数。
原文链接: https://blog.sigmaprime.io/solidity-security.html
作者: Dr Adrian Manning
翻译&校对: 爱上平顶山@慢雾安全团队 & keywolf@慢雾安全团队
本文由慢雾安全团队翻译,这里是最新译文的 GitHub 地址:<https://github.com/slowmist/Knowledge-Base/blob/master/solidity-security-comprehensive-list-of-known-attack-vectors-and-common-anti-patterns-chinese.md>。
EthFans 经授权转载。
你可能还会喜欢: