-
-
[原创]十大Web3.0安全最佳实践方式(全篇)超长干货收藏版!
-
发表于: 2024-10-29 13:52 2071
-
本教程适用于所有希望向主网推出user-ready应用程序的人员
Web3.0行业自建立以来就面临着巨大的安全挑战。因此,Web3.0生态系统及更广泛的技术领域都必须为安全做好准备,以尽快提高这个被无数技术推动的强大领域的安全标准。
如果你想要将构建的协议或是其他智能合约应用发布到区块链主网上,安全一定是最重要的考虑因素。
对于安全这一方面,需要考虑的不仅仅是Solidity,更有很多其他问题需要关注。了解常规且高发的安全威胁,将有效保护你以及用户的天价资产。
因此启动项目前做好相关安全调研工作必不可少。
本文由CertiK及Chainlink联合出品,将为你分享Web3.0安全的10个最佳实践,助于降低成为攻击受害者的风险。
⭐超长干货,建议先收藏再看哦!
1. 意识到重入攻击的危害
以智能合约为中介进行的攻击并不总是来自于外部。
重入攻击[1]作为臭名昭著DAO攻击[2]的一种形式,是DeFi安全中常见的攻击类型。
当合约调用另一个恶意合约的外部函数时, 该恶意合约可以通过fallback 进行重入攻击回调到原合约。
这是DeFi安全攻击的一种常见类型——恶意合约可以在第一个函数完成前回调到调用合约之中。
引用Solidity文档中对于重入攻击的描述:一个合约(A)与另一个合约(B)的任何互动,以及任何以太币的转移都会将控制权交给该合约(B)。
这使得合约(B)有可能在这个互动完成之前回调到合约(A)。
我们来看一个例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | / / SPDX - License - Identifier: GPL - 3.0pragma 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]:receive和fallback。
如果你发送ETH到另一个合约,它将自动被路由到receive函数。如果这个receive函数再次调用回原合约的提现函数(withdraw),这样就可以在余额更新为0之前重复提现多次,直到合约池再无资金可用
1 2 3 4 5 6 7 8 9 | / / 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互斥锁或是调整代码执行顺序, 在内部状态更新之后再调用外部函数。
最简单的修复方法是在调用任何外部未知合约之前更新合约内部状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 | / / 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方法,就像这样:
1 | 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函数作为例子)。
1 2 3 4 5 6 7 8 9 10 11 12 | 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的价格暴跌(为了便于理解,该函数被稍作修改,但实际原理是相同的)。
1 2 3 4 5 6 7 | 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储备
1 | (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数量时,智能合约的执行很容易变得不准确。
以下面的代码为例(为便于理解,以下函数稍作修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | / / 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)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | 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教育材料中可是“常客”。
许多教育内容误导区块链开发人员使用如下代码获取随机数:
1 | 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)
对于采用代理实现模式的项目,可以通过更改代理合约中的实现合约地址来更新实现。
通常,代理合约中有一个特定的变量存储实现合约地址。如果这个变量的存储位置是固定的,而在执行合约中恰好有另一个变量具有相同的存储位置索引/偏移量,那么就会发生存储冲突。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | 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,以避免在代理合约和实现合约中使用相同的存储槽。
下面是一个使用透明代理模式实现随机存储的例子
1 2 3 | bytes32 private constant implementationPosition = bytes32(uint256( keccak256( 'eip1967.proxy.implementation' )) - 1 )); |
保证token转移计算的准确性
通常情况下,对于普通的ERC20 token,收到的token数量应该等于用函数调用的原始数量。
例如——参见下方函数retrieveTokens()
1 2 3 4 | function retrieveTokens(address sender, uint256 amount) public { token.transferFrom(sender, address(this), amount); totalTokenTransferred + = amount; } |
然而,如果token是通缩的,即每次转移都有费用,那么实际收到的数量将少于最初要求转移的数量。
在如下所示的修改后的函数retrieveTokens(uint256 amount)中,amount是根据转账操作前后的余额重新计算的。不管token转移机制如何,这都将准确地计算出被转移到address(this)的token数量。
1 2 3 4 5 6 7 | 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]的类型重置为一个默认值。开发者应该要么跳过这个成员,要么重新组织数组并减少其长度。
比如
1 2 | array[member] = array[array.length - 1 ]; array.pop() |
这些只是需要注意的一些漏洞,查看审计师Sigma Prime关于Solidity常见漏洞的文章[23]可以帮助你深入了解Solidity以及避免这些“陷阱”。
5. 函数的可见性和修饰符
在Solidity语言的设计中,有四种类型的函数可见性:
- private:该函数只在当前合约中可见。
- internal:该函数在当前合约和派生合约中是可见的。
- external:该函数只对外部调用可见。
- public:该函数对内部和外部的调用都是可见的。
可见性是指针对特定功能的上述四种可见性中的一种用于限制某一组用户的访问。
修饰符则指的是专门为访问限制目的而编写的自定义代码段。
可见性和修饰符结合起来,可以为特定功能设置适当的访问权限。 例如,在ERC20实现的函数_mint()中:
1 2 3 4 5 6 7 | 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功能设置一个适当的访问权限,可以使用下方的代码段:
1 2 3 4 | 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()只能被调用一次,合约所有者无法对这种攻击进行防御。
1 2 3 4 5 6 | 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世界是一个公正透明且需要我们每一个人协作互助的地方,这一点在开发者们身上体现尤甚。
如果这篇文章里的解决方案中还有更多你想要了解的信息,请参阅Chainlink文档[34]和CertiK文档[35]。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课