干货分享 | 十大DeFi安全最佳实践方式(上)

CertiK
CertiK 机构得得号

Dec 17, 2021 CertiK 专注于区块链安全,被称为区块链安全世界的「普华永道」。

摘要: 本文将为你分享DeFi安全的10个最佳实践,这将有助于防止你的应用程序成为攻击的受害者。

 

本教程适用于所有希望向主网推出user-ready应用程序的人员

如果你想要将构建的DeFi协议或是其他智能合约应用发布到区块链主网上,安全因素一定是你要首先去考虑的。

许多团队在审查代码时只关注Solidity方面的问题,但要确保一个DApp的安全性并使其适配主网,还有很多其他问题需要关注。

了解近期高发的DeFi安全威胁——比如价格预言机攻击、暴力破解攻击以及其他攻击手段,将保护你以及你的用户数十亿美元的资产。

因此启动项目前做好尽职调查必不可少。

本文由CertiK及Chainlink联合出品,将为你分享DeFi安全的10个最佳实践,这将有助于防止你的应用程序成为攻击的受害者。

⭐超长干货,建议先收藏再看哦!

1. 意识到重入攻击的危害
以智能合约为中介进行的攻击并不总是来自于外部。
重入攻击[1]作为臭名昭著DAO攻击[2]一种形式,是DeFi安全中常见的攻击类型。
当合约调用另一个恶意合约的外部函数时, 该恶意合约可以通过fallback(回调函数)进行重入攻击回调到原合约。
这是DeFi安全攻击的一种常见类型——恶意合约可以在第一个函数完成前回调到调用合约之中。
引用Solidity文档中对于重入攻击的描述:"一个合约(A)与另一个合约(B)的任何互动,以及任何以太币的转移都会将控制权交给该合约(B)。这使得合约(B)有可能在这个互动完成之前回调到合约(A)"。
我们来看一个例子👇
Let’s look at an example:

```solidity
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
/// @dev Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
(bool success,) = msg.sender.call{value: shares[msg.sender]}("");
if (success)
shares[msg.sender] = 0;
}
}
```

在这个函数中,我们用`msg.sender.call`调用另一个账户。敲重点:这可能是另一个智能合约!

被调用的外部合约可以将回调函数设计成在`(bool success,) = msg.sender.call{value: shares[msg.sender]}("");`返回之前再次调用withdraw函数。

这将允许用户在合约内部状态更新前提取合约中的所有资金。

Solidity提供给合约两个特殊函[3]`receive``fallback`

如果你发送ETH到另一个合约,它将自动被路由到`receive`函数。如果这个`receive`函数再次调用回原合约的提现函数(withdraw),这样就可以在余额更新为0之前重复提现多次,直到合约池再无资金可用👇

```solidity
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

// THIS CONTRACT IS EVIL - DO NOT USE
contract Steal {
receive() external payable {
IFundContract(addressOfFundContract).withdraw();
}
}
```

解决方案:在转移ETH/代币或调用不受信任的外部合约之前,先更新合约的内部状态

有几种方法可以达成这一目标:使用mutex互斥锁或是调整代码执行顺序, 在内部状态更新之后再调用外部函数。

最简单的修复方法是在调用任何外部未知合约之前更新合约内部状态。

```solidity
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

contract Fund {
/// @dev Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
uint share = shares[msg.sender];
shares[msg.sender] = 0;
(bool success,) = msg.sender.call{value: share}("");
}
}
```

### Transfer, call, and send

在很长的一段时间里,Solidity 安全专家并不建议使用`call`方法进行ETH转账——他们推荐使用`transfer`方法,就像这样:

```
payable(msg.sender).transfer(shares[msg.sender]);
```
`transfer': 最多有2300个gas,失败时报错
`send`: 最多有2300个gas,失败时返回 "false"
`call":将所有gas转移到下一个合约,失败时返回 "false"
`transfer'`send`在很长一段时间内被认为是 "更好 "的做法,

因为2300个gas实际上只足以发出一个事件或进行一些无害的操作,所以外部合约不能回调或做其他恶意操作,因为这会将gas耗尽。

然而,这只是目前的以太坊设置,由于不断变化的基础设施生态系统,gas成本在未来可能也将发生变化[4]
我们已经看到EIPs改变了不同操作码的gas成本[5]这意味着未来可能有一段时间,你可以以低于2300个gas的价格调用一个函数,或者发送事件的成本将超过2300个gas,任何想要在回调函数中发送事件的操作在将来都会失败。

另一个可能的解决方案是在关键函数上施加mutex互斥锁,例如ReentrancyGuard[6]中的不可重入修饰符。采用这样的互斥锁可以防止合约被重入攻击。互斥锁可以在函数执行时保证没有任何外部合约可以"重新调用"本函数。

另一个版本的重入攻击是跨函数的重入。下面是一个跨函数重入攻击的例子(为了便于阅读, 这里我们使用transfer函数作为例子)。

```solidity
mapping (address => uint) private userBalances;
function transfer(address _recipient, uint _amount) {
require(userBalances[msg.sender] >= _amount);
userBalances[_recipient] += _amount;
userBalances[msg.sender] -= _amount;
}

function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
msg.sender.transfer(amountToWithdraw);
userBalances[msg.sender] = 0;
}
```

在一个函数执行完成之前调用其他函数

对开发者而言这是一个明显的提醒——在ETH转账之前一定要先更新内部状态。

一些协议甚至在其函数上添加互斥锁,这样如果函数尚未返回,该函数就不能被再次调用。

除了常见的重入漏洞,还有一些重入攻击可以由特定的EIP机制触发,如ERC777。

ERC-777(EIP-777)是建立在ERC-20(EIP-20)之上的以太坊代币标准。

它向后兼容ERC-20,并增加了一个功能,使 "操作员 "能够代表代币所有者发送代币。

关键是,该协议还允许为代币所有者添加 "send/receive hooks",以便在发送或接收交易时自动采取进一步行动。从Uniswap imBTC黑客事件中可以看出,该漏洞实际上是由Uniswap交易所在余额更改之前发送ETH造成的。

在该攻击中,Uniswap的功能设计没有遵循已被广泛采用的  "Checks Effect Interactive"[7]模式,该模式是为了保护智能合约免受重入攻击而发明的,按照该模式,代币转移应该在任何价值转移之前进行。

2.使用DEX或AMM中的代币余额储备作为代币价格参考会产生漏洞

这既是用于攻击协议的最常见方法之一,也是最容易防御的DeFi攻击类型。

使用`getReserves()`来决定代币价格是十分危险的。

当用户通过使用闪电贷攻击[8]基于订单簿或自动做市商(AMM)的去中心化交易所(DEX)中的代币价格,这种中心化的价格预言机就有可能会被攻击。

在被闪电贷攻击时,因为项目使用了DEX中的价格作为他们的价格预言机的数据,导致智能合约的执行出现异常,其形式包括触发虚假清算、发放过大的贷款或触发不公平交易。

由于这个漏洞, 即使是市面上主流的DEX——例如Uniswap, 也不建议单独使用代币对(swap pair)中两种代币的相对比率作为价格预言机的定价(预言机是获取外部数据并将其传递到区块链或进行某种外部计算并将结果传递给智能合约的任何外部实体)

在基于DEX或AMM的价格预言机中, 预言机的数据源是DEX上一次成功交易,代币对调整之后的代币余额,即代币对中两种代币的相对比率。

该价格可能与代币的实际市场价格不同步。

例如,如果进行了大额交易时代币对中没有足够的流动性来支持,将会导致代币价格与交易所的平均市场价格相比发生较大波动,当用户大量买入时代币价格会飙升,当大量卖出时代币价格会狂跌。

闪电贷加剧了这一问题,因为它允许所有用户在没有任何抵押的情况下获得大量临时资金,以执行大额交易。用户经常将问题归咎于闪电贷,称之为“闪电贷攻击”。

然而,根本问题是——他们自己的DEX使用的是不安全的价格预言机,代币价格很容易被操纵,导致依赖预言机的协议引用了不准确的价格。

这些攻击应被更准确地描述为“预言机操纵攻击”,这一攻击形式在DeFi生态系统中造成了大量的攻击事件[9]。因此所有开发人员都应该在智能合约中删除可能导致价格预言机被操纵的相关代码。

这里以最近一次攻击的代码举例,此次攻击造成了3000万美元的损失,并使该协议的奖励代币的价格暴跌👇(为了便于理解,该函数被稍作修改,但实际原理是相同的)。

```javascript
function valueOfAsset(address asset, uint amount) public view override returns (uint valueInBNB, uint valueInDAI) {
if (keccak256(abi.encodePacked(IProtocolPair(asset).symbol())) == keccak256("Protocol-LP")) {
(uint reserve0, uint reserve1, ) = IPancakePair(asset).getReserves();
valueInWETH = amount.mul(reserve0).mul(2).div(IProtocolPair(asset).totalSupply());
valueInDAI = valueInWETH.mul(priceOfETH()).div(1e18);
}
}
```

该项目有一个预言机机制,可以从DEX中获取代币价格。

在DEX中,用户可以将一对代币存入流动性池合同(例如代币A+代币B),允许用户根据汇率在这些代币之间进行交换,汇率根据池中每一方的流动性数量计算。

现在有一个假设, 如果一个项目大部分代码是从热门项目Uniswap[10]中拷贝而来,我们可以认为这个项目是安全的。 

然而如果项目方在此基础之上添加了一个奖励代币项目, 当用户向LP权益池中存储流动性时, 他们不仅可以获得流动性代币LP token,还可以获取流动性挖矿的奖励代币。

这种情况下, 黑客可以通过闪电贷将大量资金存入流动性池,从而操纵这个奖励代币的铸造功能,这使得他们能够以错误的比率兑换奖励代币。

在下方这个函数中,我们可以看到,攻击者做的第一件事就是根据流动性池中两种资产的储备量,获得流动性池中资产之间的汇率。

下面这行代码的目的是获得流动性池中的代币储备👇

```javascript
(uint reserve0, uint reserve1, ) = IProtocolPair(asset).getReserves();
```

如果一个流动性池中有5个WETH和10个DAI,那么它的reserve0为5, reserve1为10(WETH是以太坊平台上发行的一种加密货币,是ETH的ERC20版本,ETH和WETH之间的转换率为1比1)。

当你获得了流动性池中每种代币的储备量之后,将两个储备量相除,得到的汇率就可以定义代币对中任意一种资产的价格。

根据上面的例子,如果流动性池中有5个WETH和10个DAI,那么兑换率是1个WETH兑换2个DAI——用10除以5。

虽然使用去中心化交易所可以很好地交换具有即时流动性的资产,但这并不能保证DEX提供的价格比率是正确的,因为DEX中的价格很容易被闪电贷操纵,并且单个DEX中的交易数据往往只代表资产的总交易量的一小部分。

所以当其被用于计算奖励代币数量时,智能合约的执行很容易变得不准确。

以下面的代码为例(为便于理解,以下函数稍作修改)👇

Slightly modified for comprehension
```javascript

// ProtocolMinterV2.sol 0x819eea71d3f93bb604816f1797d4828c90219b5d
function mintReward(address asset /* LP token */, uint _withdrawalFee /* 0 */, uint _performanceFee /* 0.00015... */, address to /* attacker */, uint) external payable override onlyMinter {
uint feeSum = _performanceFee.add(_withdrawalFee);
_transferAsset(asset, feeSum); // transfers LP tokens from VaultFlipToFlip to this
uint protocolETHAmount = _zapAssetsToProtoclETH(asset, feeSum, true);

if (protocolETHAmount == 0) return;

IEIP20(PROTOCOL_ETH).safeTransfer(PROTOCOL_POOL, protocolETHAmount);
IStakingRewards(PROTOCOL_POOL).notifyRewardAmount(protocolETHAmount);

(uint valueInETH,) = priceCalculator.valueOfAsset(PROTOCOL_ETH, protocolETHAmount); // returns inflated value
uint contribution = valueInETH.mul(_performanceFee).div(feeSum);
uint mintReward = amountRewardToMint(contribution);
_mint(mintReward, to); // mints the reward to the liquidity providers and attacks
}
```

在这个例子中,向用户进行奖励代币发放的主要函数是_mint(mintReward, to);

我们可以看到,该函数根据用户在流动性池上锁定的资产价值来铸造奖励代币。

因此,如果一个用户突然在流动性池中拥有大量的资产(借助闪电贷攻击),那么该用户可以轻易地为自己铸造大量奖励代币,这部分代币是从其他用户的奖励中窃取的。

然而,目前的利润水平依旧没有达到攻击者的期望。

因此,操纵DEX中代币的价格可以大大提升他们窃取的代币价值。

在这个例子中, 合约认为他们给用户发放了价值5美元的奖励代币, 但实际上发放了5000美

通过这种设置,恶意用户可以很容易地进行闪电贷攻击,将获取的临时资金存入流动性池,铸造大量的奖励,然后偿还闪电贷款,获得的利润由其他流动性提供者承担,从中获利。

为了防止基于闪电贷攻击的价格操纵问题,通常的解决方案是采取DEX市场的时间加权平均价格(TWAP)(例如,一个资产在一小时内的平均价格)。

虽然这可以防止闪电贷歪曲预言机价格,因为闪电贷只存在于一个交易或区块中,而TWAP是多个区块的平均值,但这并不是一个完整的解决方案,因为TWAP有其自身的妥协。

在价格剧烈波动时期,TWAP预言会变得不准确,这可能会导致下游事件——如无法在期限内清偿抵押不足的贷款。

此外,TWAP预言没有提供足够的市场覆盖率,因为它只追踪一个DEX中的数据,使其它容易受到不同交易所的流动性或交易量变化的影响,从而影响TWAP预言给出的价格。

解决方案:使用去中心化的预言机网络

与其使用中心化预言机(比如本例中单一的链上DEX)来确定汇率,保证DeFi安全的最佳做法是使用去中心化的多个预言机组成的网络来确定代币的实际市场价格。

一个DEX作为一个交易所是去中心化的,但把它作为定价信息参考时它是中心化的。

正确的做法是:你需要收集所有流动性中心化和去中心化交易所的价格,按交易量加权,并去除偏差值/清洗交易,以获得相关资产全球汇率的去中心化准确视图,确保能反映市场的实际价格。

如果你能获取基于所有交易环境的成交量加权的全球平均值的资产价格,那么闪电贷在单一交易所中操纵资产价格就不是问题。

此外,由于闪电贷款只存在于单个交易中(同步更新),它们对去中心化的价格源并没有影响,这些价格源在单独的交易(异步更新)中产生具有能代表全球市场的实际价格更新。

Chainlink预言机网络的去中心化结构及其实现的广泛市场覆盖,保护了DeFi协议免受闪电贷攻击导致的价格操纵,这就是为什么越来越多的DeFi项目正在集成Chainlink价格反馈机制[11],以防止价格预言机被攻击,并确保在突发的交易量变化中准确定价。

你不需要再使用`getReserves`来计算价格,而是从Chainlink数据源[12]中获得代币交换比率,Chainlink数据源是去中心化的预言机节点网络,在链上提供能反映所有相关CEX和DEX的资产加权平均价格(VWAP)。

```solidity
pragma solidity ^0.6.7;

import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";

contract PriceConsumerV3 {

AggregatorV3Interface internal priceFeed;

/**
* Network: Kovan
* Aggregator: ETH/USD
* Address: 0x9326BFA02ADD2366b30bacB125260Af641031331
*/
constructor() public {
priceFeed = AggregatorV3Interface(0x9326BFA02ADD2366b30bacB125260Af641031331);
}

/**
* Returns the latest price
*/
function getThePrice() public view returns (int) {
(
uint80 roundID,
int price,
uint startedAt,
uint timeStamp,
uint80 answeredInRound
) = priceFeed.latestRoundData();
return price;
}
}
```

上面的代码是实现Chainlink价格预言机的全部内容,你可以通过阅读文档[13]尝试在应用程序中进行实现。

如果你刚开始接触智能合约或预言机,我们有一个初学者教程[14],帮助你入门,并保护你的协议及用户免受闪电贷和预言机操纵攻击。

如果你想了解更多详情,可以试试查看OpenZeppelin的DEX  Ethernaut[15],它显示了操纵DEX的代币价格有多么容易。

 

3. 不要使用Keccak256或Blockhash生成随机数

使用 "block.difficulty"、"block.timestamp"、"blockhash "或任何与区块相关的参数来生成随机数,都会使你的代码被恶意攻击。

智能合约中的随机性在许多用例中都很有用,例如无偏见地确定奖品的获得者,或者公平地将稀有NFT分配给用户。

然而,区块链是确定的系统,不提供随机数的防篡改来源,所以在不查看区块链外部的情况下获取随机数是具备一定风险的,并有可能导致被恶意攻击。

随机数生成漏洞并不像预言机操纵攻击或重入攻击那样普遍,但它们在Solidity教育材料中可是“常客”。

许多教育内容误导区块链开发人员使用如下代码获取随机数:

```javascript
uint randomNumber = uint(keccak256(abi.encodePacked(nonce, msg.sender, block.difficulty, block.timestamp))) % totalSize;
```

这里的想法是使用nonce、块难度和时间戳的某种组合来创建一个 "随机 "数字。

然而,这有几个明显的缺点——你可以很轻易的用取消交易的方式重复生成多次,直到得到一个你想要的随机数。

1. 使用像block.difficity这样的哈希对象(或者链上的任何其他信息)作为源来生产随机数时,矿工有能力改变这个源。与 "重滚 "策略类似,如果结果对他们不利,矿工可以利用他们订购交易的能力,将某些交易排除在区块之外。

如果该交易是用于链上生成随机数的源,矿工也可以选择扣留对他们不利的哈希值所在的区块。

2. 使用block.timestamp无法提供任何随机性,因为时间戳是任何人都可以预测的。以这种方式使用链上随机数生成器,会让用户以及矿工对 "随机 "数字产生影响和控制。

如果你希望实现一个公平系统,以这种方式生成随机性将非常有利于攻击者,并且这个问题只会随着随机函数所保护的价值量的增加而变得更糟,因为攻击它的动机也增加了。

解决方案:使用Chainlink VRF作为可验证的随机数生成器

为了防止被恶意攻击,开发者需要一种方法来生成可验证的随机数,并防止其被矿工和用户篡改。

实现这一目标需要来自于预言机的链下随机数源。

然而,许多提供随机性来源的预言机没有办法真正证明他们提供的数字确实是随机产生的(被操纵的随机性看起来就像正常的随机性,你无法区分)。

因此开发者需要能够从链外获取随机性,同时也需要一种密码学算法来证明随机性没有被操纵过。

Chainlink的可验证随机函数(VRF)[16]正好实现了这一点。它使用预言机节点在链外生成一个随机数,并对该数字的完整性进行加密证明。然后由VRF协调器在链上检查加密证明,以验证VRF的确定性和防篡改性。

它的工作原理是这样的:

1. 一个用户从Chainlink上节点请求一个随机数,并提供一个种子值,随后Chainlink将会发出一个链上事件日志。

2. 链外的Chainlink预言机读取该日志,并使用可验证的随机函数(VRF)基于节点的密钥哈希、用户给定的种子和发送请求时未知的块数据创建一个随机数和加密证明。然后,它在第二笔交易中把随机数返回链上,这时在链上通过VRF协调器合约使用加密证明进行验证。

Chainlink VRF是如何解决上述问题的?

1. 无法进行回滚攻击

由于这个过程需要两笔交易,第二笔交易是创建随机数的地方,所以你无法看到随机数或取消你的交易。

2. 矿工无法改变这个值

由于Chainlink VRF不使用矿工可以控制的参数,比如block.difficulty或block.timestamp等可预测的值,所以他们无法控制随机数。

用户、预言机节点或DApp开发者无法操纵Chainlink VRF提供的随机性数据,这使得它成为智能合约应用所使用的链上随机性来源的安全得到了保证。

大家可以按照文档[17]的要求试试在代码中实施Chainlink VRF,或者根据我们的初学者指南(包括一个视频教程)[18]来使用Chainlink VRF。

在本系列接下来的内容中,将会为大家逐一讲解剩余的7大DeFi安全最佳实践方式,欢迎持续关注!
关注CertiK官方公众号,底部对话框发送消息“图谱”,可获取《区块链知识图谱》高清大图哦!

 

参考链接:

1. https://www.researchgate.net/publication/235301171_The_Adoption_of_Electronic_Banking_Technologies_by_US_Consumers

2.https://www.gemini.com/cryptopedia/the-dao-hack-makerdao

3.https://docs.soliditylang.org/en/v0.8.9/contracts.html?highlight=receive#special-functions

4. https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/

5. https://eips.ethereum.org/EIPS/eip-1884#motivation

6. https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/ReentrancyGuard.sol

7. https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html

8、 https://blog.chain.link/flash-loans-and-the-importance-of-tamper-proof-oracles/

9. https://www.coindesk.com/flash-loans-centralized-price-oracles

10. https://github.com/Uniswap/uniswap-v2-core/tree/4dd59067c76dea4a0e8e4bfdda41877a6b16dedc

11. https://chain.link/data-feeds

12. https://docs.chain.link/docs/using-chainlink-reference-contracts/

13. https://docs.chain.link/docs/get-the-latest-price/

14. https://docs.chain.link/docs/beginners-tutorial/

15. https://ethernaut.openzeppelin.com/level/0x0b0276F85EF92432fBd6529E169D9dE4aD337b1F

16. https://docs.chain.link/docs/get-a-random-number/

17. https://docs.chain.link/docs/get-a-random-number/

18. https://docs.chain.link/docs/intermediates-tutorial/

 

作者:CertiK-链得得专栏;来自链得得内容开放平台“得得号”,本文仅代表作者观点,不代表链得得官方立场凡“得得号”文章,原创性和内容的真实性由投稿人保证,如果稿件因抄袭、作假等行为导致的法律后果,由投稿人本人负责得得号平台发布文章,如有侵权、违规及其他不当言论内容,请广大读者监督,一经证实,平台会立即下线。如遇文章内容问题,请联系微信:chaindd123。

链得得仅提供相关信息展示,不构成任何投资建议
本文系作者 CertiK 授权链得得发表,并经链得得编辑,转载请注明出处、作者和本文链接

更多精彩内容,关注链得得微信号(ID:ChainDD),或者下载链得得App

分享到:

相关推荐

    评论(0

    Oh! no

    您是否确认要删除该条评论吗?

    分享到微信