十大DeFi安全最佳实践

  • Chainlink
  • 更新于 2022-07-13 09:44
  • 阅读 2447

研究有关DeFi安全的十大最佳实践,这将有助于防止你的应用程序成为攻击的受害者、避免与用户的不愉快对话,并能保护和加强你作为一个超级安全的开发者的声誉。

Top10DeFiBestPractices1024x577.png

无论是开发DeFi协议还是其他的智能合约应用,在上线到区块链主网前都需要考虑到许多安全因素。很多团队在审核代码时只关注Solidity相关的陷阱,但要确保dApp的安全性足够支撑上线主网,通常还有很多工作要做。了解大多数流行的DeFi安全漏洞可能会为你和你的用户节省数十亿美元并且免除后续的各种烦恼,如预言机攻击、暴力攻击和许多其他威胁等。

考虑到这一点,我们将在下文研究有关DeFi安全的十大最佳实践,这将有助于防止你的应用程序成为攻击的受害者、避免与用户的不愉快对话,并能保护和加强你作为一个超级安全的开发者的声誉。

1. 了解重入攻击

一种常见的DeFi安全攻击类型是重入攻击,这也是臭名昭著DAO攻击的形式。这种情况就是当一个合约在更新自己的状态之前调用了一个外部合约。

引用Solidity文档的内容:

“一个合约(A)与另一个合约(B)的任何交互,以及任何ETH的转账都会将控制权移交给该合约(B)。这使得B有可能在这个交互完成前回调到A。”

我们来看看一个例子:

// 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(提款)函数。这将允许用户在状态更新前提取合约中的所有资金。

合约可以有几个特殊函数,即receive(接收)和fallback(回退)函数。如果你发送ETH到另一个合约,它将自动被路由到receive函数。如果该receive(接收)函数再指向原来的合约,那么在你有机会将余额更新为0之前,你就可以不断提款。

让我们看看这种合约可能是什么样子的:

// 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发送到steal合约后,它将调用receive函数,该函数指向Fund合约。此时,我们还没有运行shares[msg.sender] = 0,所以合约仍然认为用户有可以提取的余额。

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

有几种方法可以做到这一点,从使用互斥锁到甚至简单地排序你的函数调用,你只在状态被更新后才能接触到外部合约或函数。一种简单的修复方法是在调用任何外部未知合约之前更新状态:

// 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}("");
    }
}

转移、调用和发送

长期以来,Solidity安全专家建议不要使用上述方法。他们建议不使用call函数,而是使用transfer,像下面这样:

payable(msg.sender).transfer(shares[msg.sender]);

我们之所以提到这一点,是因为你可能会看到外面有一些相互矛盾的资料,它们的建议与我们的建议相反。此外,你也会听到send函数。每一个函数都可以用来发送ETH,但都有轻微的差异。

  • transfer: 最多需要2300个gas,失败时会抛出一个错误
  • send: 最多需要2300个gas,失败时返回false
  • call: 将所有gas转移到下一个合约,失败时返回false

transfer和send在很长一段时间内被认为是 “更好 “的做法,因为2300个gas真的只够发出一个事件或其他无害的操作;接收合约除了发出事件不能回调或做任何恶意操作,因为如果他们尝试这样做的话,他们会耗尽gas。

然而,这只是目前的设置,由于不断变化的基础设施生态,gas成本在未来可能会发生变化。我们已经看到有EIP改变了不同操作码的gas成本。这意味着未来可能有一段时间,你可以以低于2300个gas的价格调用一个函数,或者事件的成本将超过2300个gas,这意味着任何现在要发出事件的接收函数会在未来会失败。

这意味着最好的做法是在调用项目外的任何合约之前更新状态。另一个可能的缓解措施是对关键函数施加一个互斥锁,例如ReentrancyGuard中的非重入修改器。采用这样的互斥锁将阻止交易合约被重入。这实质上是增加了一个“锁”,所以在合约执行过程中,任何调用合约的人都不能“重新进入”该合约。

重入攻击的另一个版本是跨函数重入。下面是一个跨函数重入攻击的例子,为了便于阅读,使用了transfer函数:

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钩子”,以便在发送/接收交易时自动采取进一步行动。

从Uniswap imBTC黑客事件中可以看出,该漏洞实际上是由Uniswap交易所在余额变化之前发送ETH造成的。在那次攻击中,Uniswap功能的实现没有遵循已被广泛采用的“Check-Effect-Interact”模式,该模式是为了保护智能合约免受重入攻击而发明的,按照该模式,通证转移应该在任何ETH转移之前进行。

2.使用DEX或AMM储备作为价格预言机将导致漏洞攻击

这既是用于攻击协议的最常见方法之一,也是最容易防止的DeFi安全攻击面之一。如果你使用getReserves()作为量化价格的方法,这应该是一个警示信号。当用户操纵订单簿或基于自动做市商的去中心化交易所(DEX)的现货价格时,这种集中式价格预言机攻击就会发生,通常是使用闪电贷。然后使用DEX报告价格作为他们的价格预言机的协议,会导致智能合约的执行出现偏差,其形式包括触发虚假清算、发放过多的贷款或触发不公平交易。由于这个漏洞的存在,即使是流行的DEX,如Uniswap,也不建议单独使用他们的储备池作为价格预言机。

预言机可以是任何外部实体,它获取外部数据并将其传递到区块链上,或进行某种外部计算并将结果传递给智能合约。在基于DEX或AMM的预言机机制的情况下,预言机提取的数据源是由DEX上一次成功交易调整的储备金价格,它可能会与资产的更广泛的市场价格不同步,例如,在流动性不足的情况下进行大额交易。这将导致价格与所有交易所的成交量加权平均价格相比,要么升得很高(大额买单),要么降得很低(大额卖单)。

闪电贷加剧了这个问题,因为它允许任何用户在没有任何抵押的情况下获得大量的临时资金,以执行大额交易。用户经常把问题归咎于闪电贷,并称其为“闪电贷攻击”。然而,根本问题是,DEX本身就是不安全的价格预言机,因为现货价格很容易被操纵,会导致依赖该预言机的协议参考了不准确的价格。这些攻击更准确的描述是 “预言机操纵攻击”,在DeFi生态系统中有大量的此类漏洞。所有的开发者都应该在他们的智能合约中删除预言机操纵攻击面。

让我们看看最近一次攻击的代码,这次攻击造成了3000万美元的损失,随后该协议的奖励通证的价格下跌:

为了便于理解,该函数被稍作修改,但实际上效果是相同的。

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的协议的分叉。然而,在上面添加了一个奖励通证项目,这样当用户将流动性存入特定的资金池时,他们不仅获得一个收据通证(LP 通证),代表他们对自己流动性的取回凭证和矿池费用的百分比,而且还能获得流动性挖矿奖励。黑客能够操纵这个奖励的铸造函数,通过闪电贷,并将这些额外的资金存入流动池。这使他们能够以错误的汇率铸造奖励通证。

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

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

你可以想象一个有5个WETH和10个DAI的流动性池子会使reserve0为5,reserve1为10。WETH代表“封装的ETH”,它是ETH的ERC20版本,ETH和WETH之间的汇率为1比1。

一旦你有了协议中的储备量,获取两种资产的价格的简单方法就是将两种储备量相除,得到一个汇率。例如,如果我们的流动资金池中有5个WETH和10个DAI,那么兑换率是1个WETH兑换2个DAI,因为我们只是用10除以5。

虽然使用去中心化的交易所可以很好地交换具有即时流动性的资产,但它们并不是很好的现货价格预言机,因为它们的价格很容易被操纵,特别是通过闪电贷,而且DEX只占任何特定资产的总交易量的一小部分。当用于铸造奖励通证时,智能合约的执行很容易变得不准确(为便于理解稍作修改)。

// 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); 这行。我们可以看到,该函数是根据用户在流动池中锁定的价值多少来铸造的。因此,如果一个用户突然在流动池中拥有大量的资产(由于闪电贷的攻击),那么该用户可以很容易地给自己铸造大量的奖励通证,这是从该通证的用户那里偷取奖励。

然而,这仍然不会给他们带来他们想要的利润。而当通证价格预言机被操纵时,他们能得到的通证数量会大大增加。比如说,该协议认为它将给用户5美元的奖励–但实际它将发出5000美元的奖励。这正是这个特定漏洞所发生的情况。

在这种设置下,用户可以很容易地进行闪电贷,将该临时资金存入流动池的一方,铸造大量的奖励,然后偿还闪电贷,牺牲其他流动性提供者以获利。

为了避免闪电贷市场操纵问题,一种常被提起的解决方案是采取DEX市场的时间加权平均价格(TWAP)(例如,一个资产在一小时内的平均价格)。虽然这可以防止闪电贷歪曲预言机价格,因为闪电贷只存在于一个交易/区块中,而TWAP是多个区块的平均值,但这并不是一个完整的解决方案,因为TWAP有其自身的权衡。在波动时期,TWAP预言机会变得不准确,这可能会导致下游事件,如无法在足够的时间内清偿抵押不足的贷款。此外,TWAP预言机不能提供足够的市场覆盖,因为只有一个DEX被跟踪,使其容易受到不同交易所的流动性/交易量变化的影响,使TWAP预言机给出的价格出现偏差。

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

DeFi安全最佳实践不是使用一个中心化的预言机(如一个单一的链上交易所)来确定汇率,而是使用一个去中心化的预言机网络来寻找反映广泛市场覆盖的汇率的真实数值。DEX作为交易所是去中心化的,但作为价格参考信息它是中心化的。

相反,你要收集所有中心化和去中心化交易所的价格,按交易量加权并去除异常值,能够获得相关资产的全球汇率的去中心化且准确的视图,这能确保全面的市场覆盖。如果你有代表所有交易环境的成交量加权的全球平均值的资产价格,如果闪电贷操纵了单一交易所的资产价格,那也就无所谓了。

此外,由于闪电贷只存在于单个交易中(同步),它们对去中心化的Price Feed没有影响,这些Price Feed在单独的事务中生成具有广泛市场范围定价的预言机更新(异步更新)。Chainlink预言机网络的去中心化架构和它们实现的广泛的市场覆盖保护了DeFi协议免受闪电贷资助的市场操纵,这就是为什么越来越多的DeFi项目正在整合Chainlink Price Feed以防止价格预言机的攻击,并确保在突然的交易量变化中准确定价。

你可以不用getReserves来计算价格,而是从Chainlink Data Feed获得转换率,这是去中心化的预言机网络在链上提供反映所有相关CEX和DEX的成交量加权平均价格(VWAP)。

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价格预言机的全部内容,你可以阅读文档,开始在你的应用程序中实现它们。如果你是智能合约或预言机的新手,我们有一个初学者教程帮助你开始使用,并保护你的协议和用户免受闪电贷和预言机操纵攻击。

如果你想了解更多并实际体验,可以玩一下OpenZeppelin的DEX Ethernaut关卡,它显示了操纵DEX的现货价格是多么容易。

3. 不要使用Keccak256或Blockhash作为随机性的来源

使用block.difficulty、block.timestamp、blockhash或任何与block相关的东西来获得一个随机数到你的应用程序中,都会使你的代码被攻击。智能合约中的随机性对许多用例都是有用的,比如在无偏见的情况下确定奖励的赢家,或者公平地将一个罕见的NFT分配给用户。然而,区块链是确定的系统,不能提供随机数的防篡改来源,所以试图从链上获得一个随机数总是会出现问题,并有可能导致被漏洞利用。随机数漏洞并不像预言机操纵攻击或重入攻击那样普遍,但它们在Solidity教学资料中出现的频率令人震惊。很多教育内容会教导区块链开发者用下面这样的代码获得一个随机数:

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

这里的想法是使用nonce、区块难度和时间戳的某种组合来创建一个“随机”数字。然而这有几个明显的缺点。

实际上,你可以用取消交易不断地“回滚”,直到你得到一个你喜欢的随机数字。这对任何人来说都非常容易做到。

使用对block.difficity这样对象的哈希值(或者链上的任何其他东西)作为随机数时,矿工对结果有巨大的影响力。与“回滚”策略类似,如果结果对矿工不利,矿工可以利用他们交易排序的能力,将某些交易从区块中排除。如果这是用于随机性的链上数据的来源,矿工也可以选择扣留对他们不利的区块哈希的区块。

使用block.timestamp这样的东西则无随机性,因为时间戳是任何人都可以预测的。

这种方式的链上随机数生成器,用户和/或矿工都能对“随机”数字产生影响和控制。如果你想拥有的是一个公平系统,这种方式的随机性只会极度有利于恶意行为者。随着被随机性功能保障的价值的增加,这个问题会变得更糟,因为攻击它的动机也在增加。

解决方案: 使用Chainlink VRF作为一个可验证的随机性预言机

为了防止漏洞,开发者需要一种方法来创建可验证的随机性,并防止矿工和回滚用户的篡改。所需要的是来自于预言机的链外随机性。然而,许多提供随机性来源的预言机没有办法真正证明他们提供的数字确实是随机产生的(被操纵的随机性看起来就像正常的随机性,你无法区分)。开发者需要能够从链外获取随机性,同时也要有办法明确地、并且可通过密码学证明随机性没有被操纵。

Chainlink可验证随机函数(VRF)正是实现了这一点。它使用预言机节点在链外生成一个随机数,并提供该数字的完整性的加密证明。然后由VRF协调器在链上检查该加密证明,以验证VRF的完整性是确定且防篡改的。它的工作流程如下:

  1. 一个用户从Chainlink节点请求一个随机数,并提供一个种子值(使用最新的VRF不需要用户提供种子值)。这会发出一个链上事件日志。
  2. 链外的Chainlink预言机读取该日志并使用可验证的随机函数(VRF)创建一个随机数和密码学证明,依据的是节点的keyhash、用户给定的种子和请求时未知的区块数据。然后,它在第二笔交易中把随机数返回链上,并在链上通过VRF协调器合约使用该密码学证明验证此随机数。

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

你不能回滚攻击

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

矿工没有影响力

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

用户、预言机节点或dApp开发者无法操纵Chainlink VRF提供的随机性数值,这就使得Chainlink VRF提供的随机性数值是智能合约应用程序可用的极其安全的链上随机性来源。

你可以按照文档的要求开始在你的代码中实现Chainlink VRF,或者按照我们的初学者指南来使用Chainlink VRF,其中包括一个视频教程。

4. 避免常见故障

这一点是对Solidity的一个概括,但要有一个安全的合约,你需要在构建它时将所有的DeFi安全原则铭记于心。要写出真正可靠的Solidity代码,你必须知道它在底层是如何工作的。否则,你可能会受到影响:

上溢出/下溢出

在Solidity中,uint256和int256是“封装”的。这就是说,如果你有一个uint256能表示的最大的数字,然后再对它加1,它将会得到它能表示的最小的数字。请务必检查这一点。在0.8之前的Solidity版本中,你会使用类似safemath的东西。

在Solidity 0.8.x中,算术运算被默认检查。这意味着x + y在溢出时将抛出一个异常。所以请确保你知道你使用的是什么版本!

循环的gas限制

当编写动态大小的循环时,需要非常小心它们能有多大。一个循环可以很容易地超过区块的最大gas限制,并在恢复时使得合约无用。

避免使用tx.origin

tx.origin不应该被用于智能合约的授权,因为它可能会导致类似钓鱼的攻击

代理存储碰撞

对于一个采用代理实现模式的项目,实现合约可以通过改变代理合约中的实现合约地址来更新。

通常,在代理合约中,有一个特定的变量来存储实现合约的地址。如果这个变量的存储位置是固定的,而恰好有另一个变量在实现合约中的存储位置有相同的索引/偏移,那么就会出现存储碰撞。

pragma solidity 0.8.1;

contract Implementation {
    address public myAddress;
    uint public myUint;

    function setAddress(address _address) public {
        myAddress = _address;
    }
}

contract Proxy {
    address public otherContractAddress;

    constructor(address _otherContract) {
        otherContractAddress = _otherContract;
    }

    function setOtherAddress(address _otherContract) public {
        otherContractAddress = _otherContract;
    }

    fallback() external {
        address _impl = otherContractAddress;
        assembly {
          let ptr := mload(0x40)
          calldatacopy(ptr, 0, calldatasize())
          let result := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
          let size := returndatasize()
          returndatacopy(ptr, 0, size)

          switch result
          case 0 { revert(ptr, size) }
          default { return(ptr, size) }
        }
    }
}

为了触发存储碰撞,可以在Remix中遵循下面这些步骤:

  1. 部署实现合约;
  2. 部署代理合约,将实现合约的部署地址作为其构造函数参数;
  3. 在代理合约的部署地址上运行实现合约;
  4. 调用myAddress()函数。它将返回一个非零的地址,这就是存储在代理合约中的otherContractAddress变量中的部署地址。

那么,在上面的四个步骤中发生了什么呢?

  1. 首先实施合约被部署,生成了合约地址;
  2. 代理合约部署时用到实现合约的部署地址,其中代理合约的构造器被调用,otherContractAddress变量用实现合约的部署地址赋值;
  3. 在步骤3中,实现合约与代理存储进行交互,即在部署的实现合约中的变量可以读取部署的代理合约中相应的哈希碰撞变量的值。

    Implementation contract Proxy contract
    Storage Slot 0 address public myAddress address public otherContractAddress
    Storage Slot 1 uint public myUint
    Storage Slot 2

    myAddress可以通过碰撞读取otherContractAddress的值

  4. myAddress()函数的返回值只是部署的实现合约中的myAddress变量的值,它与部署的代理合约中的 otherContractAddress变量相碰撞,可以在那里获得otherContractAddress变量的值。

为了避免代理存储碰撞,我们建议开发者为存储变量选择伪随机槽来实现非结构化的存储代理。

一种常见的做法是为项目采用一个可靠的代理模式。最广泛采用的代理模式是通用可升级代理标准(UUPS)和透明代理模式。它们都提供了具体的存储偏移offset,以避免在代理合约和实现合约中使用相同的存储槽。

下面是一个使用透明代理模式实现随机存储的例子:

bytes32 private constant implementationPosition = bytes32(uint256(
  keccak256('eip1967.proxy.implementation')) - 1
));

通证转移计算的准确性

通常情况下,对于一个普通的ERC20通证,收到的通证数量应该等于用函数调用的原始数量;例如,下面的函数retrieveTokens()。

function retrieveTokens(address sender, uint256 amount) public {
    token.transferFrom(sender, address(this), amount);
    totalTokenTransferred += amount;
}

然而如果通证是通缩的,即每次转让都有费用,那么实际收到的通证数量将少于最初要求转让的通证数量。

在下面修改后的函数retrieveTokens(address sender, uint256 amount)中,金额是根据转移操作前后的余额重新计算的。无论通证转移机制如何,这都能准确地计算出已经转移到address(this)的通证数量。

function retrieveTokens(address sender, uint256 amount) public {
    uint256 balanceBefore = deflationaryToken.balanceOf(address(this));
    deflationaryToken.transferFrom(sender, address(this), amount);
    uint256 balanceAfter = deflationaryToken.balanceOf(address(this));
    amount = balanceAfter.sub(balanceBefore);
    totalTokenTransferred += amount;
}

正确的数据删除

有很多情况下需要删除合约中不再需要的某个对象或值。在像Java这样的成熟语言中,有一个垃圾回收机制,可以自动和安全地处理这个问题。然而在Solidity中,开发者必须手动处理“垃圾”。因此,不正确地处理垃圾可能给智能合约带来安全问题。

例如,当用delete删除数组中的一个元素时,即delete array[member],array[member]仍然存在,但会根据array[member]的类型重置为一个默认值。开发者应该记得跳过这个元素或者重新组织数组并减少其长度。比如说:

array[member] = array[array.length - 1];
array.pop()

这些只是需要注意的一些漏洞,但深入了解Solidity将帮助你避免这些“麻烦”。你可以查看审计工程师Sigma Prime关于常见Solidity漏洞的文章

5. 函数的可见性和限制

在Solidity语言的设计中,有四种类型的函数可见性:

  • private:该函数只在当前合约中可见;
  • internal:该函数在当前合约和派生合约中是可见的;
  • external:该函数只对外部调用可见;
  • public:该函数对内部和外部调用都是可见的。

函数可见性是指上述四种可见性中的一种,用于限制某组用户的访问。至于限制,它指的是专门为访问限制目的而编写的自定义代码段。

可见性和限制可以结合起来,为特定的功能设置一个适当的访问授权。例如,在ERC20实现的函数_mint()中:

function _mint(address account, uint256 amount) internal virtual {
    require(account != address(0), "ERC20: mint to the zero address");
    _beforeTokenTransfer(address(0), account, amount);
    _totalSupply += amount;
    _balances[account] += amount;
    emit Transfer(address(0), account, amount);
    _afterTokenTransfer(address(0), account, amount);
}

函数_mint()的可见性被设置为internal,这正确地保护了它不能被外部调用。为了给mint函数设置一个适当的访问授权,可以使用下面的代码片段:

function mint(address account, uint256 amount) public onlyOwner {
    _mint(account, amount);
    require(MaxTotalSupply >= _totalSupply, "over mint");
}

函数mint()只允许合约的所有者进行铸造,require()语句防止所有者铸造过多的通证。

正确使用可见性和限制有利于合约管理。也就是说,一方面缺乏这样的设置可能会让恶意攻击者调用管理配置功能来操纵项目,另一方面,过度的限制设置可能会给合约带来中心化的担忧,也可能会引起社区的质疑。

6. 在部署到主网前做好外部审计

可以将代码审计视为以安全为中心的同行评审。审计员将逐行检查你的整个代码库,并使用形式化验证技术来检查你的智能合约是否存在任何漏洞。在没有审计的情况下部署代码或在审计后更改代码并重新部署是会让合约暴露于潜在漏洞的威胁。

有多种方法可以帮助你和审计员确保代码审计尽可能全面:

  1. 使用文档记录所有内容,以便他们更轻松地跟踪正在发生的事情
  2. 与他们保持沟通渠道畅通,以防他们有任何疑问
  3. 在你的代码中添加注释,会让你的团队和他们的团队更容易

然而,不要依赖审计人员来捕捉一切问题。你应该首先有一个安全心智模型,因为在未来某一天,如果你的协议被黑客攻击,你仍然会是那个最终负责的团队。安全审计不一定能解决所有问题,但它们确实提供了额外的一轮审查,对捕捉你没有发现的错误有一定帮助。

Tincho有一个关于如何最好地与审计工程师合作的很好的推特内容

如果你想找推荐的审计工程师,请随时联系我们的技术专家

7. 进行测试和使用静态分析工具

你需要对你的应用程序进行测试。人类是伟大的,但他们永远无法提供自动化测试套件所能提供的代码覆盖率。Chainlink的入门套件仓库有一些测试套件的样本供你参考作为起点。像AaveSynthetix这样的协议也有很好的测试套件,查看他们的代码了解一些测试的最佳实践(也包括更通用的编码实践)可能是一个好的思路。

静态分析工具也能帮助你更早地发现错误。它们被设计成自动运行你的合约并寻找潜在的漏洞。目前最流行的静态分析工具之一是Slither。CertiK目前也在根据其在审计、验证和监控智能合约方面的丰富经验,建立下一代静态分析、语法分析、漏洞分析和形式化验证工具。

8. 将安全视为整个生命周期的工作

虽然毫无疑问你应该尽力在产品部署前创建一个安全可靠的智能合约,但现实是区块链和DeFi协议快速发展以及新攻击的不断发明意味着你不能止步于此。相反,你应该获取并跟踪最新的监测和警报情报。如果可能的话,尝试在智能合约中引入面向未来的功能,以获取快速增长的动态安全情报并从中受益。

同时也可以引入一些额外的帮助。CertiK Skynet作为一个24/7的安全智能引擎,为智能合约的链上部署提供多维度和实时的透明安全监控。它包括社会情绪、治理、市场波动、安全评估等,为区块链客户、社区和通证投资者提供一般的安全理解。CertiK安全排行榜提供透明的、易于理解的安全洞察和最新的项目状态,并提供奖励改进的社区问责制。

9. 制定一个灾难恢复计划

根据你的协议,如果你被黑客攻击,有一个救助计划是很好的。一些流行的方法是:

  • 购买保险
  • 添加一个紧急“暂停”功能
  • 有一个升级计划

保险协议越来越受欢迎,这是是最去中心化的灾难恢复方式之一。它们在不影响去中心化的情况下增加了一定程度的财务安全。即使你也有其他灾难恢复计划,你也应该持有保险。一种解决方案是CertiK的ShentuShield,这是一个增加了去中心化和透明度的保险产品。

设置紧急“暂停”功能是一个有利有弊的策略。在发现漏洞的情况下,这种功能会停止与你的智能合约的所有交互。如果你设置了这个功能,你需要确保你的用户知道谁能够操作它。如果只有一个用户拥有权限,这就不是一个去中心化的协议,并且精明的用户可以通过你的代码找到答案。要小心你的实现方式,因为你实际上可能最终在一个去中心化的平台上得到一个中心化的协议。

升级计划也有同样的问题。转移到一个没有错误的智能合约可能很好,但你需要以一种深思熟虑的方式升级你的合约,以免牺牲去中心化。一些安全公司甚至强烈建议不要采用可升级的智能合约模式。你可以在这篇《智能合约升级现状》的演讲中或者Patrick Collins关于这个话题的YouTube视频中了解更多关于可升级智能合约的话题。

如果你正在寻找一些保险建议,可随时加入Chainlink Discord

10. 防范抢跑交易

在区块链中,所有的交易在mempool中都是可见的,这意味着每个人都有机会看到你的交易,并有可能在你的交易进行之前进行交易,以便从你的交易中获利。

例如,假设你使用DEX以当前的市场价格将5个ETH兑换成DAI。一旦你将交易发送到mempool进行处理,一个抢跑者可以在你之前进行交易,购买大量的ETH,导致价格上涨。然后他们可以以更高的价格向你出售他们购买的ETH,并以你为代价获利。目前,抢跑机器人在区块链世界中横行,并以牺牲普通用户的利益为代价获利。这个术语来自于传统的金融世界,其中交易员试图做完全相同的事情,只是涉及的是股票、商品、衍生品和其他金融资产和相应的工具。

另一个例子,下面列出的函数有很高的被抢跑的风险。根据修改器initializer,该函数只能被调用一次。如果调用initialize()函数的交易被攻击者在mempool中监控,那么攻击者就可以用一组定制的通证(token)、分销商(distributor)和工厂(factory)的参数来复制该交易,并最终控制整个合约。由于函数initialize()只能被调用一次,合约所有者没有办法防御或减轻这种攻击。

function initialize(IERC20 _token, IDistributor _distributor, IFactory _factory) public initializer {
    Ownable.initialize();
    token = _token;
    distributor = _distributor;
    factory = _factory;
}

这通常也与所谓的矿工可提取价值,即MEV有关。MEV是指矿工或机器人对交易进行重新排序,以便他们能以某种方式从排序中获利。就像抢跑者支付更多的gas以使他们的交易领先于你的交易一样,矿工可以直接重新排序交易,使他们的交易领先于你的交易。在整个区块链生态系统中,MEV每天从普通用户那里窃取数百万美元。

幸运的是,包括Chainlink实验室首席科学家Ari Juels在内的一群世界级智能合约和密码学研究人员正在努力解决这个确切的问题,其解决方案名为“公平排序服务”(FSS)。

开发中的解决方案:Chainlink公平排序服务(FSS)

Chainlink 2.0白皮书概述了公平排序服务的主要特点,这是一项由Chainlink去中心化预言机网络(DON)提供的安全的链外服务,将用于根据dApp陈述的公平性的时间概念对交易进行排序(例如首次在mempool中看到)。FSS旨在极大地缓解抢跑交易和MEV的影响,并为整个区块链生态系统的用户减少交易费用。你可以在这篇介绍性的文章中阅读更多关于FSS的内容,并在Chainlink 2.0白皮书的第五节中查看扩展内容。

除了FSS之外,缓解抢跑问题的最好方法之一是尽可能降低交易排序的重要性,从而抑制交易重新排序和MEV在你的协议中的作用。

总结和下一步工作

在保护你的智能合约时,有许多关键的DeFi安全因素需要考虑,我们已经看到了太多的漏洞和攻击使用户损失了数千万美元。掌握上面的提示将帮助你在构建智能合约时避免这些漏洞。然而,永远不会有一个列表涵盖每一个独特的漏洞。我们会继续看到围绕中心化机制的新的和复杂形式的经济漏洞,以及围绕脆弱的抵押品的闪电贷资助的市场操纵。DeFi社区必须共同努力,在整个生态系统中发现并减轻这些新出现的风险。

通过参考CertiK安全排行榜等列出的顶级项目学习围绕安全智能合约开发的最佳实践,是提高项目安全性的一个有益方法。智能合约的世界是开放和协作的,开发者们希望尽其所能相互帮助。

你可能会发现回顾以前的一些漏洞的价值,并将其作为了解过去如何进行攻击的手段。你还可以通过CertiK Skynet等24/7安全情报服务,实时接收最新的链上安全漏洞的更新并从中学习。

对于那些想要深入了解安全并将整个过程游戏化的人来说,一定要看看Ethernaut游戏。它包含了DeFi的安全实例,深入浅出教你Solidity的许多来龙去脉,它是了解DeFi中一切安全问题的好方法。另一个学习安全的游戏化项目是Damn Vulnerable DeFi。你也可以在“防止闪电贷攻击”网站上了解更多关于闪电贷攻击的信息。

作为一个社区,让我们从错误中学习,以确保智能合约被安全地构建并尽可能广泛地采用。要了解更多关于如何实现这里提到的一些解决方案,请前往Chainlink文档和CertiK文档阅读。

本文首发于:https://blog.chain.link/defi-security-best-practices-zh/

点赞 3
收藏 9
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Chainlink
Chainlink
顶尖的智能合约去中心化预言机网络解决方案 https://chain.link/