超长干货 | 十大 Web3 安全最佳实践方式
2022-12-09 11:22
CertiK
2022-12-09 11:22
订阅此专栏
收藏此文章
本文将为你分享 DeFi 安全的 10 个最佳实践,这将有助于防止你的应用程序成为攻击的受害者。


撰文:证书


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


2022 年至今,Web3.0 领域因欺诈骗局与漏洞所导致的安全事件已造成约34 亿美元的损失,今年共有573起攻击事件被记录在案。


Web3.0 生态系统及更广泛的技术领域都必须为安全做好准备,以尽快提高这个被无数技术推动的强大领域的安全标准。


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


对于安全这一方面,需要考虑的不仅仅是Solidity,更有很多其他问题需要关注。了解常规且高发的安全威胁,将有效保护你以及用户的天价资产。


因此启动项目前做好相关安全调研工作必不可少。


本文由 CertiK 及 Chainlink 联合出品,将为你分享 Web3.0 安全的 10 个最佳实践,助于降低成为攻击受害者的风险。


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


1. 意识到重入攻击的危害


以智能合约为中介进行的攻击并不总是来自于外部。


重入攻击[1]作为臭名昭著 DAO 攻击[2]的一种形式,是 DeFi 安全中常见的攻击类型。


当合约调用另一个恶意合约的外部函数时, 该恶意合约可以通过fallback 进行重入攻击回调到原合约。


这是 DeFi 安全攻击的一种常见类型——恶意合约可以在第一个函数完成前回调到调用合约之中。


引用 Solidity 文档中对于重入攻击的描述:一个合约(A)与另一个合约(B)的任何互动,以及任何以太币的转移都会将控制权交给该合约(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调用另一个账户。敲重点:这可能是另一个智能合约!


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


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


Solidity 提供给合约两个特殊函数[3]receivefallback


如果你发送 ETH 到另一个合约,它将自动被路由到receive函数。如果这个receive函数再次调用回原合约的提现函数 (withdraw),这样就可以在余额更新为 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/token 或调用不受信任的外部合约之前,先更新合约的内部状态


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

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


// 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、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函数作为例子)。


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 的价格暴跌👇(为了便于理解,该函数被稍作修改,但实际原理是相同的)。


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 储备👇


(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 数量时,智能合约的执行很容易变得不准确。


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


// 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)。


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.difficultyblock.timestampblockhash或任何与区块相关的参数来生成随机数,都会使你的代码被恶意攻击。


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


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


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


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


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。


4. 避免常见问题


这一点对 Solidity 来说是一个总括性的问题——如果希望合约安全,就需要在构建它时依次核实所有的 DeFi 安全准则。而要编写真正可靠的 Solidity,就必须了解它的内部工作原理。否则,可能会容易受到以下攻击:


上溢出 / 下溢出(Overflows/Underflows)


在 Solidity 中,uint256 和 int256 被「包裹」了。


这意味着,如果你在 uint256 中拥有一个最大的数,然后将其添加到其中,它将变成可能存在的最小数字。


这一点务必需要检查与核实,在 0.8 之前的 Solidity 版本中,可以使用类似 safemath[19]的库来解决这一问题。


在 Solidity 0.8.x 中,默认情况下会检查算术运算操作。这意味着 x+y 将在溢出时抛出异常。因此,请确认你正在使用的 Solidity 版本。


循环 gas 限制(Loops Gas Limit)


当编写动态大小的循环时,需要注意它的极限规模大小。一个循环的规模可以很容易地超过最大块限制,并使合约在回滚时失效。


避免使用 tx.origin


tx.origin可能会导致类似钓鱼的攻击[20],因此不应该被用于智能合约的身份验证。


代理存储冲突(Proxy Storage Collision) 


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


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


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 中执行以下步骤:


①部署实现合约;

②部署代理合约,将实现合约的部署地址作为其构造参数;

③在代理合约的部署地址上运行实现合约;

④调用 myAddress() 函数。它将返回一个非零地址,该地址是存储在代理合约中 otherContractAddress 变量中的部署地址。


在上述四个步骤中发生了什么?


1. 部署实现合约并生成其部署地址;

2. 代理合约使用实现合约的部署地址进行部署,其中代理合约的构造函数被调用,并将 otherContractAddress 变量赋值为实现合约的部署地址;

3.在步骤③中,实现合约与代理存储进行交互,即部署的实现合约中的变量可以读取部署的代理合约中相应的哈希碰撞变量的值。



4. myAddress() 函数的返回值就是部署的实现合约中 myAddress 变量的值,它与部署的代理合约中的 otherContractAddress 变量产生冲突,随即可以在那里获得 otherContractAddress 变量的值。


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


一种常见的做法是为项目采用一种可靠的代理模式。最广泛采用的代理模式是 UUPS 代理[21]和透明代理模式[22]。它们都提供了具体的存储offset,以避免在代理合约和实现合约中使用相同的存储槽。


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


bytes32 private constant implementationPosition = bytes32(uint256(

keccak256('eip1967.proxy.implementation')) - 1

));


保证 token 转移计算的准确性


通常情况下,对于普通的 ERC20 token,收到的 token 数量应该等于用函数调用的原始数量。


例如——参见下方函数retrieveTokens()👇


function retrieveTokens(address sender, uint256 amount) public {

token.transferFrom(sender, address(this), amount);

totalTokenTransferred += amount;

}


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


在如下所示的修改后的函数 retrieveTokens(uint256 amount) 中,amount是根据转账操作前后的余额重新计算的。不管 token 转移机制如何,这都将准确地计算出被转移到address(this)的 token 数量。


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 中,开发者必须手动处理garbage。因此,garbage处理不当可能会给智能合约带来安全问题。


例如,当用 delete(即delete array[member])从数组中删除单个成员时,array[member]仍将存在,但根据array[member]的类型重置为一个默认值。开发者应该要么跳过这个成员,要么重新组织数组并减少其长度。


比如👇


array[member] = array[array.length - 1];

array.pop()


这些只是需要注意的一些漏洞,查看审计师 Sigma Prime 关于 Solidity 常见漏洞的文章[23]可以帮助你深入了解 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()语句则可以防止所有者铸造过多的 token。


正确使用可见性和修饰符有利于合约管理。而未正确使用的设置可能会让恶意攻击者调用管理配置函数来操纵项目,过度的修饰符设置也可能会给合约带来中心化的问题,并引起社区的不安。


6. 部署到主网前必须获得外部审计


代码审计就像是接受一个以安全为重心的同行评审。审计员会逐行查看整个代码库,并使用形式化验证技术来检查智能合约是否存在任何漏洞。


如果不想让自己的项目赤裸裸的暴露于漏洞的威胁之下,切忌在没有审计的情况下部署代码,或者在审计后改变代码并重新部署。


这里有一些帮助确保审计全面的建议:


a. 记录一切,以便审计员更容易跟踪所发生的事情

b. 保持与审计团队的沟通渠道畅通,以防他们有任何疑问可以得到及时解决

c. 在你的代码中添加注释,确保其可以更快被理解


然而,安全是你自己需要切身关注的重中之重,一股脑的将身家全部寄予审计机构并不是一个正确的心态。如果协议遭到攻击,最大的受害者是你自己以及你的团队。


尽管安全审计非常有用,提供了额外的一轮审查,并可以帮助你查找未曾发现的漏洞,但它也不能确保 100% 的安全。


Tincho 在推特上开启了一个关于如何最高效率地进行安全审计的话题[24],感兴趣的小伙伴可以去看看。当然,如果你有任何审计需求,或是相关的疑问,随时可在公众号底部留言进行咨询。


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


你需要对应用程序进行适当的测试。


如果你正苦恼于无处下手,Chainlink starter kit repos 提供了一些测试套件样本[25]供你参考。


像 Aave 和 Synthetix 这样的协议也有很好的测试套件,建议也可以通过查看他们的代码以了解一些测试的最佳实践(也包括更普遍的编码)。


静态分析工具也会帮助你更早地发现代码的错漏之处。它们可以自动运行监测你的合约并寻找潜在的漏洞,目前最流行的静态分析工具之一是 Slither[26]。


8.将安全视为重中之重


毫无疑问,在生产部署之前,您应该尽最大努力创建一个安全可靠的智能合约,但区块链和 DeFi 协议快速发展的现实以及新型攻击方式的不断出现意味着这远远不足以保障安全。


开发者们应当积极获取并跟踪最新的监测和警报数据,并尽可能尝试在智能合约本身中引入面向未来的功能以访问快速增长的动态安全洞察数据,这一行为除了安全以外更有其他益处。


9. 制定一个「翻身」计划


对于一个协议来说,如果在受到攻击后有一个准备许久的「翻身」计划无疑是很好的一步落子。


  • 设置一个紧急「暂停」功能
  • 有一个升级计划


设置紧急「暂停」功能是一个有利有弊的策略。


如果发现漏洞,此功能将停止与智能合约的所有交互。如果你设置了这个功能,你需要确保你的用户知道谁能够操作它。


如果只有一个用户,你就不是在运行一个去中心化的协议,那么用户就可以通过代码发现这些。因此要注意该功能实现方式,因为你实际上可能最终在一个去中心化的平台上得到了一个中心化的协议。


进行升级也有同样的问题。转移到一个没有 bug 的智能合约可能很好,但升级仍要十分慎重,以免中心化问题的出现。


因此有相当一部分安全机构几乎极力反对可升级的智能合约模式。


更多关于智能合约升级的内容你可以在 YouTube 上观看帕特里克·柯林斯关于这个话题的视频[27]或是查看《智能合约升级现状》的演讲[28]。


10. 防止抢先交易 Front-running


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


例如,假设你使用 DEX 以当前市场价格将 5 个 ETH 兑换成 DAI。一旦你把你的交易发送到 mempool 进行处理,别人可以在你之前进行交易,购买大量的 ETH,导致价格上涨。然后他们可以以更高的价格向你出售他们购买的 ETH,并以差价获利。


目前,抢先交易机器人[30]在区块链世界中肆意妄为,以牺牲普通用户的利益为代价获利。


这个术语来自于传统金融,交易员会使用同样的操作来获利,涉及到了股票、商品、衍生品等等金融资产和工具。


作为另一个例子,下方列出的函数就具备被抢先的风险。


根据修饰符initializer,该函数只能被调用一次。


如果攻击者在 mempool 中监控调用initialize()函数的交易,那么攻击者就可以用一组定制的 token、分发服务器(distributor), 和 factory 的值来replicate该交易,并最终控制整个合约。由于函数initialize()只能被调用一次,合约所有者无法对这种攻击进行防御。


function initialize(IERC20 _token, IDistributor _distributor, IFactory _factory) public initializer {

Ownable.initialize();

token = _token;

distributor = _distributor;

factory = _factory;

}


这通常也与矿工可提取值或 MEV[31]有关。MEV 是指矿工或机器人对交易进行重新排序,以便他们能以某种方式从排序中获利。


就像抢先者支付更多的 gas 以使他们的交易领先于你的交易一样,矿工可以直接重新排序交易,使他们的交易领先于你的交易。在整个区块链生态系统中,MEV 每天从普通用户那里窃取的资产高达数百万美元。


幸运的是,一群世界级的智能合约和密码学研究人员,包括 Chainlink 实验室的首席科学家 Ari Juels,正致力于用一种称为「公平排序服务」的解决方案解决这一问题。


正在开发的解决方案——Chainlink 公平排序服务(FSS)


Chainlink 2.0 白皮书[32]概述了公平排序服务的主要特点,这是一个由 Chainlink 去中心化预言机网络(DONs)提供动力的安全链外服务,将用于根据 DApp 概述的公平时间概念来排序交易。


FSS 旨在极大地缓解抢先交易以及 MEV 的负面影响,并为整个区块链生态系统的用户减少费用消耗。


关于这方面的更多内容可以通过一篇介绍性的博文[33]或是 Chainlink 2.0 白皮书的第五节中进行了解。


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


写在最后


在保护智能合约时,有许多关键的安全考虑因素,Web3.0 世界已经发生了太多的漏洞和攻击,受窃资产更是达到了数亿美元。


了解并掌握本文的十大最佳安全实践可以帮助你在构建构建智能合约时避免陷入安全风险。


但是这世界上没有一个 100% 全面的列表,可以涵盖所有漏洞和解决方式。


也许未来还会有更多新型和更复杂的漏洞以及恶意操纵方式,因此这需要整个 Web3.0 社区共同努力,为构建健康的安全生态付诸努力。


Web3.0 世界是一个公正透明且需要我们每一个人协作互助的地方,这一点在开发者们身上体现尤甚。


在保护自己的这一点上,回顾已发生的攻击事件有助于了解恶意攻击者是如何实施攻击的。除此之外,你还可以通过 7*24 全天候安全洞察系统实时接收最新的链上安全漏洞相关信息及其更新状况。 


如果你想深入了解安全又怕过程太枯燥,建议一定要看看 Ethernaut 游戏[34]。你可以通过它了解 DeFi 中的安全问题,它包含了大量 DeFi 的安全实例,以及 Solidity 的许多内涵和外延。


还有 Damn Vulnerable DeFi[35]也可以娱乐的方式学习相关安全知识。


如果想要了解更多闪电贷攻击的信息,可以登录 Prevent Flash Loan Attacks website[36]进行查看。


如果这篇文章里的解决方案中还有更多你想要了解的信息,请参阅 Chainlink 文档[37]和 CertiK 文档[38]。


参考链接:
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/
19. https://docs.openzeppelin.com/contracts/2.x/api/math#SafeMath
20. https://blog.sigmaprime.io/solidity-security.html#tx-origin-vuln
21. https://eips.ethereum.org/EIPS/eip-1822
22. https://blog.openzeppelin.com/the-transparent-proxy-pattern/
23. https://blog.sigmaprime.io/solidity-security.html
24. https://twitter.com/tinchoabbate/status/1400170232904400897
25. https://github.com/smartcontractkit
26. https://github.com/crytic/slither
27. https://www.youtube.com/watch?v=bdXJmWajZRY
28.https://blog.openzeppelin.com/the-state-of-smart-contract-upgrades
29.https://academy.binance.com/en/glossary/mempool
30.https://www.coindesk.com/miners-front-running-service-theft
31. https://blog.chain.link/what-is-miner-extractable-value-mev/
32. https://chain.link/whitepaper
33. https://blog.chain.link/chainlink-fair-sequencing-services-enabling-a-provably-fair-defi-ecosystem/
34. https://ethernaut.openzeppelin.com/level/0x4E73b858fD5D7A5fc1c3455061dE52a53F35d966
35. https://www.damnvulnerabledefi.xyz/
36. https://preventflashloanattacks.com/
37. https://docs.chain.link/
38. https://www.certik.com/resources
相关Wiki

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

在 App 打开
特朗普
空投
rwa
稳定币
babylon
以太坊
wayfinder
wct
morph
香港
hyperliquid
wal