十大 Web3.0 安全最佳实践方式(上)超长干货收藏版!
2022-12-08 08:00
CertiK
2022-12-08 08:00
CertiK
2022-12-08 08:00
订阅此专栏
收藏此文章

本教程适用于所有希望向主网推出 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/token 或调用不受信任的外部合约之前,先更新合约的内部状态
有几种方法可以达成这一目标:使用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)之上的以太坊 token 标准。
它向后兼容 ERC-20,并增加了一个功能,使“操作员”能够代表 token 所有者发送资产。
关键是,该协议还允许为 token 所有者添加 "send/receive hooks",以便在发送或接收交易时自动采取进一步行动。从 Uniswap imBTC 黑客事件中可以看出,该漏洞实际上是由 Uniswap 交易所在余额更改之前发送 ETH 造成的。
在该攻击中,Uniswap 的功能设计没有遵循已被广泛采用的  "Checks Effect Interactive"[7]模式,该模式是为了保护智能合约免受重入攻击而发明的,按照该模式,token 转移应该在任何价值转移之前进行。

2.使用 DEX 或 AMM 中的 token 余额储备作为价格参考会产生漏洞
这既是用于攻击协议的最常见方法之一,也是最容易防御的 DeFi 攻击类型。
使用`getReserves()`来决定 token 价格是十分危险的。
当用户通过使用闪电贷攻击[8]基于订单簿或自动做市商 (AMM) 的去中心化交易所 (DEX) 中的 token 价格,这种中心化的价格预言机就有可能会被攻击。
在被闪电贷攻击时,因为项目使用了 DEX 中的价格作为他们的价格预言机的数据,导致智能合约的执行出现异常,其形式包括触发虚假清算、发放过大的贷款或触发不公平交易。
由于这个漏洞, 即使是市面上主流的 DEX——例如 Uniswap, 也不建议单独使用 swap pair 中两种token的相对比率作为价格预言机的定价(预言机是获取外部数据并将其传递到区块链或进行某种外部计算并将结果传递给智能合约的任何外部实体)
在基于 DEX 或 AMM 的价格预言机中, 预言机的数据源是 DEX 上一次成功交易,swap pair 调整之后的 token 余额,即 swap pair 中两种 token 的相对比率。
该价格可能与token的实际市场价格不同步。
例如,如果进行了大额交易时token对中没有足够的流动性来支持,将会导致token价格与交易所的平均市场价格相比发生较大波动,当用户大量买入时token价格会飙升,大量卖出时价格则会狂跌。
闪电贷加剧了这一问题,因为它允许所有用户在没有任何抵押的情况下获得大量临时资金,以执行大额交易。用户经常将问题归咎于闪电贷,称之为“闪电贷攻击”。
然而,根本问题是——他们自己的 DEX 使用的是不安全的价格预言机,token价格很容易被操纵,导致依赖预言机的协议引用了不准确的价格。
这些攻击应被更准确地描述为“预言机操纵攻击”,这一攻击形式在 DeFi 生态系统中造成了大量的攻击事件[9]。因此所有开发人员都应该在智能合约中删除可能导致价格预言机被操纵的相关代码。
这里以最近一次攻击的代码举例,此次攻击造成了 3000 万美元的损失,并使该协议的奖励token的价格暴跌👇(为了便于理解,该函数被稍作修改,但实际原理是相同的)。
```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 中获取token价格。
在 DEX 中,用户可以将一对token存入流动性池合约,允许用户根据汇率在这些token之间进行交换,汇率根据池中每一方的流动性数量计算。
现在有一个假设, 如果一个项目大部分代码是从热门项目Uniswap[10]中拷贝而来,我们可以认为这个项目是安全的。 
然而如果项目团队在此基础之上添加了一个奖励token项目, 当用户向 LP 权益池中存储流动性时, 他们不仅可以获得流动性token LP,还可以获取流动性挖矿的奖励token
这种情况下, 黑客可以通过闪电贷将大量资金存入流动性池,从而操纵这个奖励token的铸造功能,这使得他们能够以错误的比率兑换奖励token
在下方这个函数中,我们可以看到,攻击者做的第一件事就是根据流动性池中两种资产的储备量,获得流动性池中资产之间的汇率。
下面这行代码的目的是获得流动性池中的token储备👇
```javascript
(uint reserve0, uint reserve1, ) = IProtocolPair(asset).getReserves();
```
如果一个流动性池中有 5 个 WETH 和 10 个 DAI,那么它的 reserve0 为 5, reserve1 为 10(WETH 是 ETH 的 ERC20 版本,ETH 和 WETH 之间的转换率为 1 比 1)。
当你获得了流动性池中每种 token 的储备量之后,将两个储备量相除,得到的汇率就可以定义交易对中任意一种资产的价格。
根据上面的例子,如果流动性池中有 5 个 WETH 和 10 个 DAI,那么兑换率是 1 个 WETH 兑换 2 个 DAI——用 10 除以 5。
虽然使用去中心化交易所可以很好地交换具有即时流动性的资产,但这并不能保证 DEX 提供的价格比率是正确的,因为 DEX 中的价格很容易被闪电贷操纵,并且单个 DEX 中的交易数据往往只代表资产的总交易量的一小部分。
所以当其被用于计算奖励 token 数量时,智能合约的执行很容易变得不准确。
以下面的代码为例(为便于理解,以下函数稍作修改)👇
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
}
```
在这个例子中,向用户进行奖励 token 发放的主要函数是_mint(mintReward, to);
我们可以看到,该函数根据用户在流动性池上锁定的资产价值来铸造奖励 token。
因此,如果一个用户突然在流动性池中拥有大量的资产(借助闪电贷攻击),那么该用户可以轻易地为自己铸造大量奖励 token,这部分是从其他用户的奖励中窃取的。
然而,目前的利润水平依旧没有达到攻击者的期望。
因此,操纵 DEX 中 token 的价格可以大大提升他们窃取的资产价值。
在这个例子中, 合约认为他们给用户发放了价值5 美元的奖励 token, 但实际上发放了5000 美
通过这种设置,恶意用户可以很容易地进行闪电贷攻击,将获取的临时资金存入流动性池,铸造大量的奖励,然后偿还闪电贷款,获得的利润由其他流动性提供者承担,从中获利。
为了防止基于闪电贷攻击的价格操纵问题,通常的解决方案是采取 DEX 市场的时间加权平均价格(TWAP)(例如,一个资产在一小时内的平均价格)。
虽然这可以防止闪电贷歪曲预言机价格,因为闪电贷只存在于一个交易或区块中,而 TWAP 是多个区块的平均值,但这并不是一个完整的解决方案,因为 TWAP 有其自身的妥协。
在价格剧烈波动时期,TWAP 预言会变得不准确,这可能会导致下游事件——如无法在期限内清偿抵押不足的贷款。
此外,TWAP 预言没有提供足够的市场覆盖率,因为它只追踪一个 DEX 中的数据,使其它容易受到不同交易所的流动性或交易量变化的影响,从而影响 TWAP 预言给出的价格。
解决方案:使用去中心化的预言机网络
与其使用中心化预言机(比如本例中单一的链上 DEX)来确定汇率,保证 DeFi 安全的最佳做法是使用去中心化的多个预言机组成的网络来确定 token 的实际市场价格。
一个 DEX 作为一个交易所是去中心化的,但把它作为定价信息参考时它是中心化的。
正确的做法是:你需要收集所有流动性中心化和去中心化交易所的价格,按交易量加权,并去除偏差值 / 清洗交易,以获得相关资产全球汇率的去中心化准确视图,确保能反映市场的实际价格。
如果你能获取基于所有交易环境的成交量加权的全球平均值的资产价格,那么闪电贷在单一交易所中操纵资产价格就不是问题。
此外,由于闪电贷款只存在于单个交易中(同步更新),它们对去中心化的价格源并没有影响,这些价格源在单独的交易(异步更新)中产生具有能代表全球市场的实际价格更新。
Chainlink 预言机网络的去中心化结构及其实现的广泛市场覆盖,保护了 DeFi 协议免受闪电贷攻击导致的价格操纵,这就是为什么越来越多的 DeFi 项目正在集成 Chainlink价格反馈机制[11],以防止价格预言机被攻击,并确保在突发的交易量变化中准确定价。
你不需要再使用`getReserves`来计算价格,而是从Chainlink 数据源[12]中获得 token 交换比率,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 的 token 价格有多么容易。

3. 不要使用 Keccak256 或 Blockhash 生成随机数
使用 "block.difficulty"、"block.timestamp"、"blockhash "或任何与区块相关的参数来生成随机数,都会使你的代码被恶意攻击。
智能合约中的随机性在许多用例中都很有用,例如无偏见地确定奖品的获得者,或者公平地将稀有非同质化 token 分配给用户。
然而,区块链是确定的系统,不提供随机数的防篡改来源,所以在不查看区块链外部的情况下获取随机数是具备一定风险的,并有可能导致被恶意攻击。
随机数生成漏洞并不像预言机操纵攻击或重入攻击那样普遍,但它们在 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 安全最佳实践方式,欢迎持续关注!

参考链接:
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/

往期长文回顾

Web3 安全公司 CertiK 完成 20 亿美元估值 B3 轮融资,Insight Partners,Tiger,高盛,红杉参投

软银愿景基金进军 Web3 安全行业,领投 CertiK 6000 万美元新一轮投资

红杉资本布局区块链安全赛道,以近 10 亿美元估值领投 CertiK 8000 万美元 B2 轮

Tiger 布局区块链安全赛道,领投 CertiK 2400 万美元 B+ 轮

区块链安全赛道最大单笔融资,CertiK B 轮融资 3700 万美元

快到碗里来 | 微软等巨头企业领导者加入 CertiK,共筑重量级管理团队

全球邀请函 | CertiK 写给挣扎在理想和现实中的你的一封信

一键查询安全排行榜——DeFi 的安全洞察数据库

CertiK Skynet 天网扫描系统:绕道土狗,竟如此简单?

想知道项目的审计情况,看看项目的审计报告?攻略和入口在这!


相关Wiki

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

CertiK
数据请求中
查看更多

推荐专栏

数据请求中
在 App 打开