paint-brush
2023 年 Solidity 智能合约安全性调查经过@rareskills
6,083 讀數
6,083 讀數

2023 年 Solidity 智能合约安全性调查

经过 RareSkills53m2023/05/16
Read on Terminal Reader

太長; 讀書

Solidity 中的一个安全问题归结为智能合约没有按预期方式运行。这可以分为四大类: 资金被盗 资金被锁定或冻结在合同中 人们获得的奖励少于预期(奖励被延迟或减少) 不可能列出所有可能出错的内容。
featured image - 2023 年 Solidity 智能合约安全性调查
RareSkills HackerNoon profile picture
0-item
1-item
2-item


Solidity 漏洞列表

本文作为智能合约安全性的迷你课程,提供了 Solidity 智能合约中经常出现的问题和漏洞的广泛列表。这些是质量审核中可能会出现的问题。


Solidity 中的一个安全问题归结为智能合约没有按预期方式运行。


这可以分为四大类:

  • 资金被盗

  • 资金在合同中被锁定或冻结

  • 人们收到的奖励少于预期(奖励延迟或减少)

  • 人们获得比预期更多的回报(导致通货膨胀和贬值)


不可能列出所有可能出错的地方。然而,正如传统软件工程具有 SQL 注入、缓冲区溢出和跨站点脚本等漏洞的共同主题一样,智能合约具有可以记录的反复出现的反模式。


将本指南更多地视为参考。如果不把它变成一本书,就不可能详细讨论所有概念(公平警告:这篇文章有 10k+ 字长,所以请随意将其加入书签并分块阅读)。但是,它可以作为要注意的内容和要学习的内容的列表。如果某个主题感觉不熟悉,这应该作为一个指标,表明值得花时间练习识别该类漏洞。

先决条件

本文假定您基本熟练掌握Solidity 。如果您是 Solidity 的新手,请参阅我们的免费 Solidity 教程

重入

关于智能合约的可重入性,我们已经写了很多,所以我们不会在这里重复。但这里有一个快速总结:


每当一个智能合约调用另一个智能合约的功能,向它发送以太币,或向它转移一个代币时,就有可能重新进入。


  • 当 Ether 被转移时,接收合约的回退或接收函数被调用。这会将控制权交给接收器。
  • 一些令牌协议通过调用预定函数来提醒接收智能合约他们已经收到令牌。这将控制流移交给该函数。
  • 当攻击合约获得控制权时,它不必调用移交控制权的同一函数。它可以调用受害者智能合约中的不同函数(跨函数重入)甚至不同的合约(跨合约重入)
  • 当合约处于中间状态时访问视图函数时,会发生只读重入。


尽管重入可能是最著名的智能合约漏洞,但它只占野外发生的黑客攻击的一小部分。安全研究员 Pascal Caversaccio (pcaveraccio) 在 github 上保留了一份最新的重入攻击列表。截至 2023 年 4 月,该存储库中记录了 46 次重入攻击。

访问控制

这似乎是一个简单的错误,但忘记限制谁可以调用敏感函数(比如撤回以太币或改变所有权)却经常发生。


即使有修饰符,也会出现修饰符未正确实现的情况,例如在下面的示例中缺少 require 语句。

 // DO NOT USE! modifier onlyMinter { minters[msg.sender] == true_; }

上面的代码是这次审计的一个真实例子: https ://code4rena.com/reports/2023-01-rabbithole/#h-01-bad-implementation-in-minter-access-control-for-rabbitholereceipt-and- rabbitholetickets-合同


这是访问控制可能出错的另一种方式

function claimAirdrop(bytes32 calldata proof[]) { bool verified = MerkleProof.verifyCalldata(proof, merkleRoot, keccak256(abi.encode(msg.sender))); require(verified, "not verified"); require(alreadyClaimed[msg.sender], "already claimed"); _transfer(msg.sender, AIRDROP_AMOUNT); }

在这种情况下,“alreadyClaimed”永远不会设置为 true,因此索赔人可以多次调用该函数。

现实生活中的例子:交易机器人被利用

访问控制不足的一个最近的例子是一个不受保护的函数,用于接收交易机器人的快速贷款(名称为 0xbad,因为地址以该序列开头)。它积累了超过一百万美元的利润,直到有一天攻击者注意到任何地址都可以调用闪贷接收功能,而不仅仅是闪贷提供商。


与交易机器人的情况一样,执行交易的智能合约代码未经验证,但攻击者还是发现了弱点。更多信息在rekt 新闻报道中。

输入验证不当

如果访问控制是关于控制谁调用函数,那么输入验证是关于控制他们调用合约的对象。


这通常归结为忘记放置适当的 require 语句。

这是一个基本的例子:

 contract UnsafeBank { mapping(address => uint256) public balances; // allow depositing on other's behalf function deposit(address for) public payable { balances += msg.value; } function withdraw(address from, uint256 amount) public { require(balances[from] <= amount, "insufficient balance"); balances[from] -= amount; msg.sender.call{value: amout}(""); } }

上面的合同确实会检查您的取款金额是否超过您账户中的金额,但它不会阻止您从任意账户取款。

现实生活中的例子:Sushiswap

由于外部函数的一个参数没有被清理, Sushiswap经历了这种类型的黑客攻击。

Sushiswap 黑客

不正确的访问控制和不正确的输入验证之间有什么区别?

不当的访问控制意味着 msg.sender 没有足够的限制。不正确的输入验证意味着函数的参数没有被充分过滤。这种反模式还有一个反面:对函数调用施加过多限制。

过多的功能限制

过度验证可能意味着资金不会被盗,但也可能意味着资金被锁定在合约中。有太多的保障措施也不是一件好事。

现实生活中的例子:Akutars NFT

最引人注目的事件之一是 Akutars NFT,最终价值 3400 万美元的 ETH 卡在智能合约中且无法提取。


该合同有一个善意的机制,以防止合同所有者在支付高于荷兰式拍卖价格的所有退款之前撤回合同。但是由于下面链接的 Twitter 线程中记录的错误,所有者无法提取资金。

Akutars NFT 漏洞

取得平衡

Sushiswap 给不受信任的用户太多的权力,而 Akutars NFT 给管理员的权力太少。在设计智能合约时,必须对每类用户的自由程度做出主观判断,而这个决定不能留给自动化测试和工具。必须考虑去中心化、安全性和用户体验之间的重大权衡。


对于智能合约程序员来说,明确写出用户应该和不应该使用某些功能做什么是开发过程的重要组成部分。

稍后我们将重新讨论权力过大的管理员的话题。

安全通常归结为管理资金退出合约的方式

如介绍中所述,智能合约被黑客入侵的主要方式有四种:


  • 钱被偷了
  • 资金冻结
  • 奖励不足
  • 超额奖励


这里的“钱”是指任何有价值的东西,例如代币,而不仅仅是加密货币。在编写或审计智能合约时,开发人员必须认真考虑价值流入和流出合约的预期方式。上面列出的问题是智能合约被黑客攻击的主要方式,但还有许多其他根本原因可以级联成主要问题,如下所述。

双重投票或 msg.sender 欺骗

使用普通 ERC20 代币或 NFT 作为权衡投票的票证是不安全的,因为攻击者可以用一个地址投票,将代币转移到另一个地址,然后从该地址再次投票。

这是一个最小的例子:

 // A malicious voter can simply transfer their tokens to // another address and vote again. contract UnsafeBallot { uint256 public proposal1VoteCount; uint256 public proposal2VoteCount; IERC20 immutable private governanceToken; constructor(IERC20 _governanceToken) { governanceToken = _governanceToken; } function voteFor1() external notAlreadyVoted { proposal1VoteCount += governanceToken.balanceOf(msg.sender); } function voteFor2() external notAlreadyVoted { proposal2VoteCount += governanceToken.balanceOf(msg.sender); } // prevent the same address from voting twice, // however the attacker can simply // transfer to a new address modifier notAlreadyVoted { require(!alreadyVoted[msg.sender], "already voted"); _; alreadyVoted[msg.sender] = true; } }

为防止这种攻击,应使用ERC20 快照ERC20 投票。通过快照过去的某个时间点,当前的代币余额无法被操纵以获得非法投票权。

闪贷治理攻击

然而,如果有人可以通过闪贷暂时增加余额,然后在同一笔交易中对其余额进行快照,那么使用具有快照或投票功能的ERC20代币并不能完全解决问题。如果该快照用于投票,他们将拥有不合理的大量选票。


闪电贷向某个地址借出大量以太币或代币,但如果未在同一笔交易中偿还这笔钱,则会收回。

 contract SimpleFlashloan { function borrowERC20Tokens() public { uint256 before = token.balanceOf(address(this)); // send tokens to the borrower token.transfer(msg.sender, amount); // hand control back to the borrower to // let them do something IBorrower(msg.sender).onFlashLoan(); // require that the tokens got returned require(token.balanceOf(address(this) >= before); } }

攻击者可以使用闪电贷突然获得大量选票,从而使提案对他们有利和/或做一些恶意的事情。

闪电贷价格攻击

这可以说是对 DeFi 最常见(或至少是最引人注目)的攻击,造成了数亿美元的损失。这是一份备受瞩目的名单


区块链上资产的价格通常被计算为资产之间的当前汇率。例如,如果一个合约当前以 1 USDC 交易 100 k9coin,那么您可以说 k9coin 的价格为 0.01 USDC。然而,价格通常会随着买卖压力而变动,而闪电贷会产生巨大的买卖压力。


当查询另一个关于资产价格的智能合约时,开发人员需要非常小心,因为他们假设他们正在调用的智能合约不受闪贷操纵的影响。

绕过合同检查

您可以通过查看地址的字节码大小来“检查”地址是否为智能合约。外部拥有的账户(普通钱包)没有任何字节码。这里有几种方法

import "@openzeppelin/contracts/utils/Address.sol" contract CheckIfContract { using Address for address; function addressIsContractV1(address _a) { return _a.code.length == 0; } function addressIsContractV2(address _a) { // use the openzeppelin libraryreturn _a.isContract(); } }


但是,这有一些限制

  • 如果合约从构造函数进行外部调用,那么显然字节码大小将为零,因为智能合约部署代码尚未返回运行时代码
  • 该空间现在可能是空的,但攻击者可能知道他们将来可以使用 create2 在那里部署智能合约


一般来说,检查一个地址是否是一个合约通常(但不总是)是一种反模式。多重签名钱包本身就是智能合约,做任何可能破坏多重签名钱包的事情都会破坏可组合性。


例外情况是在调用传输挂钩之前检查目标是否是智能合约。稍后会详细介绍。

tx.origin

很少有充分的理由使用 tx.origin。如果 tx.origin 用于识别发件人,则可能会发生中间人攻击。如果用户被诱骗调用恶意智能合约,那么智能合约可以使用 tx.origin 的所有权限进行破坏。


考虑以下练习和代码上方的注释。

 contract Phish { function phishingFunction() public { // this fails, because this contract does not have approval/allowance token.transferFrom(msg.sender, address(this), token.balanceOf(msg.sender)); // this also fails, because this creates approval for the contract,// not the wallet calling this phishing function token.approve(address(this), type(uint256).max); } }


这并不意味着您可以安全地调用任意智能合约。但是大多数协议都内置了一个安全层,如果使用 tx.origin 进行身份验证,该安全层将被绕过。

有时,您可能会看到如下代码:

 require(msg.sender == tx.origin, "no contracts");


当一个智能合约调用另一个智能合约时,msg.sender 将是智能合约,tx.origin 将是用户的钱包,从而可靠地指示来电来自智能合约。即使调用是从构造函数发生的,也是如此。


大多数时候,这种设计模式不是一个好主意。多重签名钱包和来自 EIP 4337 的钱包将无法与具有此代码的功能进行交互。这种模式在 NFT 铸币厂中很常见,可以合理地预期大多数用户都在使用传统钱包。但随着账户抽象变得越来越流行,这种模式的弊大于利。

毒气攻击或拒绝服务

破坏性攻击意味着黑客正试图为其他人“造成破坏”,即使他们没有从中获得经济利益。


智能合约可以通过进入无限循环恶意地用完所有转发给它的气体。考虑以下示例:

 contract Mal { fallback() external payable { // infinite loop uses up all the gas while (true) { } } }


如果另一个合约将以太币分发到如下地址列表:

 contract Distribute { funtion distribute(uint256 total) public nonReentrant { for (uint i; i < addresses.length; ) { (bool ok, ) addresses.call{value: total / addresses.length}(""); // ignore ok, if it reverts we move on // traditional gas saving trick for for loops unchecked { ++i; } } } }


然后当它向 Mal 发送以太币时,该函数将恢复。上面代码中的调用转发了 63 / 64 的可用气体,因此可能没有足够的气体来完成操作,只剩下 1/64 的气体。


一个智能合约可以返回一个消耗大量gas的大内存数组

考虑以下示例

function largeReturn() public { // result might be extremely long! (book ok, bytes memory result) = otherContract.call(abi.encodeWithSignature("foo()")); require(ok, "call failed"); }

内存阵列在 724 字节后会消耗二次方的 gas,因此精心选择的返回数据大小可能会让调用者失望。


即使不使用变量result,它仍然被复制到内存中。如果要将返回大小限制在一定数量,可以使用 assembly

 function largeReturn() public { assembly { let ok := call(gas(), destinationAddress, value, dataOffset, dataSize, 0x00, 0x00); // nothing is copied to memory until you // use returndatacopy() } }

删除其他人可以添加的数组也是拒绝服务向量

虽然擦除存储是一种 gas-efficient 操作,但它仍然有净成本。如果数组变得太长,将无法删除。这是一个最小的例子

contract VulnerableArray { address[] public stuff; function addSomething(address something) public { stuff.push(something); } // if stuff is too long, this will become undeletable due to // the gas cost function deleteEverything() public onlyOwner { delete stuff; } }

ERC777、ERC721 和 ERC1155 也可以是攻击载体

如果智能合约传输具有传输挂钩的代币,则攻击者可以设置不接受该令牌的合约(它要么没有 onReceive 函数,要么对该函数进行编程以恢复)。这将使令牌不可转让并导致整个交易恢复。


在使用 safeTransfer 或 transfer 之前,请考虑接收方可能强制交易还原的可能性。

 contract Mal is IERC721Receiver, IERC1155Receiver, IERC777Receiver { // this will intercept any transfer hook fallback() external payable { // infinite loop uses up all the gaswhile (true) { } } // we could also selectively deny transactions function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { if (wakeUpChooseViolence()) { revert(); } else { return IERC721Receiver.onERC721Received.selector; } } }

不安全的随机性

目前不可能通过区块链上的单个交易安全地生成随机性。区块链需要完全确定性,否则分布式节点将无法就状态达成共识。因为它们是完全确定的,所以可以预测任何“随机”数。可以利用以下骰子滚动功能。


 contract UnsafeDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } // our dice can land on one of {0,1,2,3,4,5}function rollDice() public payable { require(msg.value == 1 ether); if (randomness() % 6) == 5) { msg.sender.call{value: 2 ether}(""); } } } contract ExploitDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } function betSafely(IUnsafeDice game) public payable { if (randomness % 6) == 5)) { game.betSafely{value: 1 ether}() } // else don't do anything } }


您如何生成随机性并不重要,因为攻击者可以准确地复制它。投入更多的“熵”来源,如 msg.sender、时间戳等不会有任何影响,因为智能合约可以测量它两个。

使用 Chainlink Randomness Oracle 错误

Chainlink 是一种获取安全随机数的流行解决方案。它分两步完成。首先,智能合约向预言机发送一个随机请求,然后在一些块之后,预言机以随机数响应。


由于攻击者无法预测未来,因此他们无法预测随机数。

除非智能合约把预言机用错了。


  • 在返回随机数之前,请求随机性的智能合约不得执行任何操作。否则,攻击者可以监视 mempool 中的 oracle 返回随机性并 frontrun oracle,知道随机数是多少。
  • 随机性预言机本身可能会尝试操纵您的应用程序。他们不能在没有其他节点同意的情况下选择随机数,但如果您的应用程序同时请求多个随机数,他们可以保留和重新排序随机数。
  • 以太坊或大多数其他 EVM 链上,最终确定性不是即时的。仅仅因为某个区块是最近的区块,并不意味着它不一定会保持这种状态。这被称为“链重组”。事实上,链可以改变的不仅仅是最后一个区块。这称为“重组深度”。 Etherscan 报告各种链的重组,例如以太坊重组和多边形重组。 Polygon 上的重组可以深达 30 个或更多块,因此等待更少的块会使应用程序容易受到攻击(当 zk-evm 成为 Polygon 上的标准共识时,这可能会改变,因为最终确定性将与以太坊相匹配,但这是未来的预测,不是关于现在的事实)。

从价格 Oracle 获取过时数据

Chainlink 没有 SLA(服务水平协议)来使其价格预言机在特定时间范围内保持最新。当链条严重拥堵时(例如当 Yuga Labs Otherside铸币厂淹没以太坊以致无法进行任何交易时),价格更新可能会延迟。


使用价格预言机的智能合约必须明确检查数据是否过时,即最近是否已在某个阈值内更新。否则,它无法就价格做出可靠的决定。


还有一个额外的复杂性,如果价格没有变化超过偏差阈值,预言机可能不会更新价格以节省 gas,因此这可能会影响什么时间阈值被认为是“过时的”。


了解智能合约所依赖的预言机的 SLA 很重要。

只依赖一个预言机

无论预言机看起来多么安全,将来都可能发现攻击。唯一的防御措施是使用多个独立的预言机。

一般来说,神谕很难正确

区块链可以非常安全,但首先将数据放到链上需要某种链下操作,这会放弃区块链提供的所有安全保证。即使预言机保持诚实,它们的数据源也可以被操纵。例如,预言机可以可靠地报告来自中心化交易所的价格,但这些价格可能会被大量买卖订单操纵。同样,依赖于传感器数据或某些 web2 API 的预言机也会受到传统黑客攻击。


一个好的智能合约架构会尽可能避免使用预言机。

混合会计

考虑以下合约

contract MixedAccounting { uint256 myBalance; function deposit() public payable { myBalance = myBalance + msg.value; } function myBalanceIntrospect() public view returns (uint256) { return address(this).balance; } function myBalanceVariable() public view returns (uint256) { return myBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }


上面的合约没有 receive 或 fallback 功能,因此直接将 Ether 转移到它会恢复。但是,合约可以通过自毁强制将以太币发送给它。


在这种情况下,myBalanceIntrospect() 将大于 myBalanceVariable()。以太记账方法很好,但如果你同时使用两者,那么合约可能会有不一致的行为。


这同样适用于 ERC20 代币。

 contract MixedAccountingERC20 { IERC20 token; uint256 myTokenBalance; function deposit(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); myTokenBalance = myTokenBalance + amount; } function myBalanceIntrospect() public view returns (uint256) { return token.balanceOf(address(this)); } function myBalanceVariable() public view returns (uint256) { return myTokenBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }

同样,我们不能假设 myBalanceIntrospect() 和 myBalanceVariable() 将始终返回相同的值。可以直接将 ERC20 代币转移到 MixedAccountingERC20,绕过存款功能并且不更新 myTokenBalance 变量。


当通过内省检查余额时,应避免严格使用相等检查,因为余额可以被外人随意更改。

像密码一样对待加密证明

这不是 Solidity 的怪癖,更多的是开发人员对如何使用密码学赋予地址特殊权限的常见误解。以下代码不安全

contract InsecureMerkleRoot { bytes32 merkleRoot; function airdrop(bytes[] calldata proof, bytes32 leaf) external { require(MerkleProof.verifyCalldata(proof, merkleRoot, leaf), "not verified"); require(!alreadyClaimed[leaf], "already claimed airdrop"); alreadyClaimed[leaf] = true; mint(msg.sender, AIRDROP_AMOUNT); } }


由于以下三个原因,此代码不安全:

  1. 任何知道为空投选择的地址的人都可以重新创建 merkle 树并创建有效证明。
  2. 叶子没有散列。攻击者可以提交等于 merkle 根的叶子并绕过 require 语句。
  3. 即使解决了以上两个问题,一旦有人提交了有效的证明,他们也可以被抢先。


密码学证明(默克尔树、签名等)需要绑定到 msg.sender,攻击者无法在不获取私钥的情况下对其进行操作。

Solidity 不会向上转换为最终的 uint 大小

function limitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = a * b; }

虽然 product 是一个uint256变量,但乘法结果不能大于 255,否则代码将还原。


这个问题可以通过单独向上转换每个变量来缓解。

 function unlimitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = uint256(a) * uint256(b); }

如果将打包在结构中的整数相乘,就会发生这种情况。在将封装在结构中的小值相乘时,您应该注意这一点

struct Packed { uint8 time; uint16 rewardRate } //... Packed p; p.time * p.rewardRate; // this might revert!

Solidity 向下转型不会在溢出时恢复

Solidity 不会检查将整数转换为较小的整数是否安全。除非某些业务逻辑确保向下转换是安全的,否则应该使用像SafeCast这样的库。

 function test(int256 value) public pure returns (int8) { return int8(value + 1); // overflows and does not revert }

写入存储指针不会保存新数据。

代码看起来像是将 myArray[1] 中的数据复制到 myArray[0],但实际上并没有。如果你注释掉函数的最后一行,编译器会说这个函数应该变成一个视图函数。写入 foo 不会写入底层存储。


 contract DoesNotWrite { struct Foo { uint256 bar; } Foo[] public myArray; function moveToSlot0() external { Foo storage foo = myArray[0]; foo = myArray[1]; // myArray[0] is unchanged // we do this to make the function a state // changing operation // and silence the compiler warning myArray[1] = Foo({bar: 100}); } }

所以不要写入存储指针。

删除包含动态数据类型的结构不会删除动态数据

如果映射(或动态数组)在结构内部,并且删除了结构,则不会删除映射或数组。


除删除数组外,delete 关键字只能删除一个存储槽。如果存储槽包含对其他存储槽的引用,则不会删除这些引用。

 contract NestedDelete { mapping(uint256 => Foo) buzz; struct Foo { mapping(uint256 => uint256) bar; } Foo foo; function addToFoo(uint256 i) external { buzz[i].bar[5] = 6; } function getFromFoo(uint256 i) external view returns (uint256) { return buzz[i].bar[5]; } function deleteFoo(uint256 i) external { // internal map still holds the data in the // mapping and array delete buzz[i]; } }


现在让我们做以下事务序列

  1. 添加到Foo(1)
  2. getFromFoo(1) 返回 6
  3. 删除Foo(1)
  4. getFromFoo(1) 仍然返回 6!


请记住,地图在 Solidity 中永远不会“空”。因此,如果有人访问已删除的项目,事务将不会还原,而是返回该数据类型的零值。

ERC20代币问题

如果您只处理受信任的 ERC20 代币,那么这些问题中的大多数都不适用。但是,在与任意或部分不受信任的 ERC20 令牌进行交互时,需要注意以下几点。

ERC20:转账手续费

在处理不受信任的令牌时,您不应该假设您的余额一定会增加该金额。 ERC20 代币可以按如下方式实现其传输功能:

 contract ERC20 { // internally called by transfer() and transferFrom() // balance and approval checks happen in the caller function _transfer(address from, address to, uint256 amount) internal returns (bool) { fee = amount * 100 / 99; balanceOf[from] -= to; balanceOf[to] += (amount - fee); balanceOf[TREASURY] += fee; emit Transfer(msg.sender, to, (amount - fee)); return true; } }


该代币对每笔交易征收 1% 的税。因此,如果智能合约按如下方式与代币交互,我们要么会得到意想不到的回报,要么会被盗取资金。

 contract Stake { mapping(address => uint256) public balancesInContract; function stake(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); balancesInContract[msg.sender] += amount; // THIS IS WRONG! } function unstake() public { uint256 toSend = balancesInContract[msg.sender]; delete balancesInContract[msg.sender]; // this could revert because toSend is 1% greater than// the amount in the contract. Otherwise, 1% will be "stolen"// from other depositors. token.transfer(msg.sender, toSend); } }

ERC20:重置代币

变基令牌由Olympus DAO的 sOhm 令牌和Ampleforth 的AMPL 令牌推广。 Coingecko 维护着一份变基 ERC20 代币列表


当代币变基时,总供应量会发生变化,每个人的余额都会根据变基方向增加或减少。


在处理变基令牌时,以下代码可能会中断

contract WillBreak { mapping(address => uint256) public balanceHeld; IERC20 private rebasingToken function deposit(uint256 amount) external { balanceHeld[msg.sender] = amount; rebasingToken.transferFrom(msg.sender, address(this), amount); } function withdraw() external { amount = balanceHeld[msg.sender]; delete balanceHeld[msg.sender]; // ERROR, amount might exceed the amount // actually held by the contract rebasingToken.transfer(msg.sender, amount); } }


许多合约的解决方案是简单地禁止变基代币。但是,可以修改上面的代码以在将帐户余额转移给发件人之前检查 balanceOf(address(this)) 。然后即使余额发生变化它仍然可以工作。

ERC20:ERC20服装中的ERC777

ERC20,如果按照标准实现,ERC20代币没有transfer hook,transfer和transferFrom不存在重入问题。


具有转移挂钩的代币具有有意义的优势,这就是为什么所有 NFT 标准都实施它们,也是为什么 ERC777 最终确定的原因。然而,Openzeppelin弃用了ERC777 库,这引起了足够多的混乱。


如果您希望您的协议与行为类似于 ERC20 代币但具有传输挂钩的代币兼容,那么处理函数 transfer 和 transferFrom 就像它们将向接收方发出函数调用一样简单。


这个 ERC777 重入发生在 Uniswap 上(如果你好奇的话,Openzeppelin在此处记录了这个漏洞)。

ERC20:并非所有 ERC20 代币都返回真值

ERC20 规范规定, ERC20 令牌必须在传输成功时返回 true。因为大多数 ERC20 实现都不会失败,除非津贴不足或转移的金额太多,因此大多数开发人员已经习惯于忽略 ERC20 代币的返回值并假设失败的转移将恢复。


坦率地说,如果你只使用你知道其行为的受信任的 ERC20 代币,这并不重要。但是在处理任意 ERC20 代币时,必须考虑这种行为差异。


许多合约中有一个隐含的期望,即失败的转账应该总是恢复,而不是返回 false,因为大多数 ERC20 代币没有返回 false 的机制,所以这导致了很多混乱。


让这个问题更加复杂的是,一些 ERC20 代币不遵循返回真值的协议,尤其是 Tether。一些令牌在传输失败时恢复,这将导致恢复冒泡到调用者。因此,一些库包装了 ERC20 令牌传输调用以拦截还原并返回一个布尔值。


下面是一些实现

Openzeppelin 安全传输

Solady SafeTransfer (更省油)

ERC20:地址中毒

这不是一个智能合约漏洞,但我们在这里提到它是为了完整性。

规范允许转移零个 ERC20 代币。这可能会导致前端应用程序混淆,并可能欺骗用户最近向谁发送了令牌。 Metamask在此线程中有更多相关内容。

ERC20:坚固耐用

(在 web3 中,“坚固”的意思是“把地毯从你身下拉出来。”)

没有什么可以阻止某人向 ERC20 代币添加功能,让他们可以随意创建、转移和销毁代币——或者自毁或升级。因此,从根本上说,ERC20 代币的“不可信”程度是有限制的。


借贷协议中的逻辑错误

在考虑基于借贷的 DeFi 协议如何被破坏时,考虑错误在软件级别传播并影响业务逻辑级别会很有帮助。形成和关闭债券合约有很多步骤。以下是一些需要考虑的攻击向量。

贷方亏损的方式

  • 使本金减少(可能为零)而不进行任何付款的错误。
  • 当贷款未偿还或抵押品低于阈值时,买方的抵押品不能清算。
  • 如果协议有转移债务所有权的机制,这可能是从贷方窃取债券的载体。
  • 贷款本金或付款的到期日被不恰当地推迟到以后的日期。

借款人亏损的方式

  • 偿还本金不会导致本金减少的错误。
  • 错误或恶意攻击会阻止用户付款。
  • 非法提高本金或利率。
  • Oracle 操纵导致抵押品贬值。
  • 贷款本金或还款的到期日被不当地提前。


如果协议中的抵押品被抽走,那么贷款人和借款人都会蒙受损失,因为借款人没有偿还贷款的动力,借款人也会损失本金。


从上面可以看出,DeFi 协议被“黑客攻击”的级别比从协议中流失的大量资金(通常成为新闻的事件)要多得多。这是 CTF(夺旗)安全演习可能产生误导的一个领域。虽然协议资金被盗是最灾难性的后果,但这绝不是唯一需要防范的。

未经检查的返回值

调用外部智能合约有两种方式: 1)调用接口定义的函数; 2) 使用.call 方法。如下图所示

contract A { uint256 public x; function setx(uint256 _x) external { require(_x > 10, "x must be bigger than 10"); x = _x; } } interface IA { function setx(uint256 _x) external; } contract B { function setXV1(IA a, uint256 _x) external { a.setx(_x); } function setXV2(address a, uint256 _x) external { (bool success, ) = a.call(abi.encodeWithSignature("setx(uint256)", _x)); // success is not checked! } }

在合约 B 中,如果 _x 小于 10,setXV2 会静默失败。当通过 .call 方法调用函数时,被调用方可以恢复,但父级不会恢复。必须检查成功的值并且代码行为必须相应地分支。

私有变量

私有变量在区块链上仍然可见,因此绝不能将敏感信息存储在那里。如果它们不可访问,验证器将如何处理依赖于它们的值的交易?私有变量无法从外部 Solidity 合约中读取,但可以使用以太坊客户端在链下读取。


要读取变量,您需要知道它的存储槽。在下面的示例中,myPrivateVar 的存储槽为 0。

 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract PrivateVarExample { uint256 private myPrivateVar; constructor(uint256 _initialValue) { myPrivateVar = _initialValue; } }

这是读取已部署智能合约私有变量的javascript代码

const Web3 = require("web3"); const PRIVATE_VAR_EXAMPLE_ADDRESS = "0x123..."; // Replace with your contract address async function readPrivateVar() { const web3 = new Web3("http://localhost:8545"); // Replace with your provider's URL // Read storage slot 0 (where 'myPrivateVar' is stored) const storageSlot = 0; const privateVarValue = await web3.eth.getStorageAt( PRIVATE_VAR_EXAMPLE_ADDRESS, storageSlot ); console.log("Value of private variable 'myPrivateVar':", web3.utils.hexToNumberString(privateVarValue)); } readPrivateVar();

不安全的委托调用

Delegatecall 不应与不受信任的合约一起使用,因为它将所有控制权移交给了 delegatecallee。在这个例子中,不受信任的合约窃取了合约中的所有以太币。

 contract UntrustedDelegateCall { constructor() payable { require(msg.value == 1 ether); } function doDelegateCall(address _delegate, bytes calldata data) public { (bool ok, ) = _delegate.delegatecall(data); require(ok, "delegatecall failed"); } } contract StealEther { function steal() public { // you could also selfdestruct here // if you really wanted to be mean (bool ok,) = tx.origin.call{value: address(this).balance}(""); require(ok); } function attack(address victim) public { UntrustedDelegateCall(victim).doDelegateCall( address(this), abi.encodeWithSignature("steal()")); } }

与代理相关的升级错误

我们不能在一个部分中公正地讨论这个主题。大多数升级错误通常可以通过使用 Openzeppelin 的 hardhat 插件并阅读它可以防止的问题来避免。 ( https://docs.openzeppelin.com/upgrades-plugins/1.x/ )。


作为快速总结,以下是与智能合约升级相关的问题:

  • selfdestruct 和 delegatecall 不应在实施合同中使用
  • 必须注意存储变量在升级过程中永远不会相互覆盖
  • 在实施合同中应避免调用外部库,因为无法预测它们将如何影响存储访问
  • 部署者绝不能忽略调用初始化函数
  • 不在基础合约中包含间隙变量以防止在将新变量添加到基础合约时发生存储冲突(这由 hardhat 插件自动处理)
  • 升级之间不保留不可变变量中的值
  • 强烈建议不要在构造函数中做任何事情,因为未来的升级必须执行相同的构造函数逻辑以保持兼容性。

权力过大的管理员

仅仅因为合约有所有者或管理员,并不意味着他们的权力需要不受限制。考虑 NFT。只有所有者从 NFT 销售中提取收益是合理的,但是如果所有者的私钥被泄露,能够暂停合同(块传输)可能会造成严重破坏。通常,管理员权限应尽可能小,以尽量减少不必要的风险。


说到合同所有权……

使用 Ownable2Step 而不是 Ownable

这在技术上不是一个漏洞,但如果所有权转移到一个不存在的地址, OpenZeppelin ownable可能会导致合约所有权丢失。 Ownable2step 要求接收方确认所有权。这确保不会意外地将所有权发送到错误输入的地址。

舍入误差

Solidity 没有浮点数,因此舍入误差是不可避免的。设计师必须清楚正确的做法是向上舍入还是向下舍入,以及四舍五入应该对谁有利。


除法应始终在最后执行。以下代码在具有不同小数位数的稳定币之间进行错误转换。以下兑换机制允许用户在兑换 dai(18 位小数)时免费拿走少量 USDC(6 位小数)。变量 daiToTake 将向下舍入为零,不从用户那里拿走任何东西以换取非零的 usdcAmount。

 contract Exchange { uint256 private constant CONVERSION = 1e12; function swapDAIForUSDC(uint256 usdcAmount) external pure returns (uint256 a) { uint256 daiToTake = usdcAmount / CONVERSION; conductSwap(daiToTake, usdcAmount); } }

领跑

在以太坊(和类似的链)的上下文中,领先意味着观察一个待处理的交易并通过支付更高的 gas 价格在它之前执行另一个交易。也就是说,攻击者已经“跑在”交易的前面。如果交易是有利可图的交易,那么除了支付更高的 gas 价格外,完全复制交易是有意义的。这种现象有时被称为 MEV,意思是矿工可提取的价值,但有时在其他情况下是指最大可提取价值。区块生产者拥有重新排序交易和插入自己的交易的无限权力,从历史上看,区块生产者在以太坊采用股权证明之前是矿工,因此得名。

抢先交易:不受保护的退出

从智能合约中提取以太币可以被视为“有利可图的交易”。您执行零成本交易(除了天然气)并最终获得比开始时更多的加密货币。

 contract UnprotectedWithdraw { constructor() payable { require(msg.value == 1 ether, "must create with 1 eth"); } function unsafeWithdraw() external { (bool ok, ) = msg.sender.call{value: address(this).value}(""); require(ok, "transfer failed"). } }


如果您部署此合约并尝试取款,领先的机器人会注意到您对内存池中“unsafeWithdraw”的调用,并复制它以首先获取以太币。

Frontrunning:ERC4626 Inflation攻击,frontrunning和舍入误差的结合

我们在我们的ERC4626 教程中深入介绍了ERC-4626膨胀攻击。但其要点是 ERC4626 合约根据交易者贡献的“资产”百分比分配“份额”代币。


大致来说,它的工作原理如下:

 function getShares(...) external { // code shares_received = assets_contributed / total_assets; // more code }

当然,没有人会贡献资产而得不到股份,但他们无法预测如果有人能抢先交易获得股份,这种情况会发生。


例如,当池中有 20 个时,他们贡献 200 个资产,他们期望获得 100 个份额。但如果有人抢先交易存入 200 份资产,那么公式将为 200 / 220,四舍五入为零,导致受害者损失资产并取回零份额。

领跑:ERC20批准

最好用一个真实的例子来说明这一点,而不是抽象地描述它


  1. 假设 Alice 批准 Eve 获得 100 个代币。 (夏娃永远是坏人,而不是鲍勃,所以我们会遵守约定)。
  2. Alice 改变主意,发送交易将 Eve 的批准数更改为 50。
  3. 在将批准更改为 50 的交易包含在块中之前,它位于 Eve 可以看到的内存池中。
  4. Eve 发送一个交易来领取她的 100 个代币,以抢先获得 50 个代币的批准。
  5. 50个审批通过
  6. Eve 收集了 50 个代币。


现在 Eve 有 150 个代币,而不是 100 或 50。解决这个问题的方法是在处理不受信任的批准时,在增加或减少它之前将批准设置为零。

Frontrunning:三明治攻击

资产价格根据买卖压力而变动。如果内存池中有大订单,交易者有动力复制订单,但 gas 价格更高。这样,他们购买资产,让大订单推高价格,然后立即出售。卖单有时被称为“延期交易”。可以通过以较低的 gas 价格下达卖单来完成卖单,因此序列看起来像这样


  1. 抢先购买
  2. 大量购买


抵御这种攻击的主要防御措施是提供一个“滑点”参数。如果“抢先买入”本身将价格推高超过某个阈值,“大量买入”订单将恢复,使抢先交易失败。


它被称为三明治,因为大量买入被前期买入和后期卖出夹在中间。这种攻击也适用于大量卖单,只是方向相反。

了解有关抢先交易的更多信息

抢先交易是一个巨大的话题。 Flashbots对该主题进行了广泛的研究,并发布了一些工具和研究文章,以帮助最大限度地减少它的负外部性。


是否可以通过适当的区块链架构“设计出”抢先交易是一个尚未最终解决的争论主题。以下两篇文章是该主题的经久不衰的经典:


以太坊是一片黑暗的森林

逃离黑暗森林

签名相关

数字签名在智能合约的上下文中有两种用途:

  • 使地址能够在不进行实际交易的情况下授权区块链上的某些交易
  • 根据预定义的地址,向智能合约证明发送者有权做某事

以下是安全使用数字签名为用户提供铸造 NFT 特权的示例:

 import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; contract NFT is ERC721("name", "symbol") { function mint(bytes calldata signature) external { address recovered = keccak256(abi.encode(msg.sender)).toEthSignedMessageHash().recover(signature); require(recovered == authorizer, "signature does not match"); } }

一个典型的例子是 ERC20 中的批准功能。要批准一个地址从我们的账户中提取一定数量的代币,我们必须进行实际的以太坊交易,这需要消耗 gas。


有时将数字签名传递给链下接收者更有效,然后接收者将签名提供给智能合约以证明他们有权进行交易。


ERC20Permit 允许使用数字签名进行批准。功能描述如下

function permit(address owner, address spender, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public

所有者可以为支出者“签署”批准(以及截止日期),而不是发送实际的批准交易。然后,批准的支出者可以使用提供的参数调用许可函数。

签名剖析

您会经常看到变量 v、r 和 s。它们分别以数据类型 uint8、bytes32 和 bytes32 表示。有时,签名被表示为一个 65 字节的数组,它是所有这些值连接在一起的 abi.encodePacked(r, s, v);


签名的其他两个基本组成部分是消息哈希(32 字节)和签名地址。序列看起来像这样


  1. 私钥(privKey)用于生成公共地址(ethAddress)

  2. 智能合约预先存储 ethAddress

  3. 链下用户对消息进行哈希处理并签署哈希。这会产生一对 msgHash 和签名 (r, s, v)

  4. 智能合约收到一条消息,对其进行哈希处理产生msgHash,然后将其与(r, s, v)结合起来,看出来的是什么地址。

  5. 如果地址与 ethAddress 匹配,则签名有效(在某些假设下,我们很快就会看到!)


智能合约在第 4 步中使用预编译的合约ecrecover 来执行我们所说的组合并取回地址。


在此过程中有很多步骤可能会出现问题。

签名:ecrecover 返回地址(0)并且当地址无效时不恢复

如果将未初始化的变量与 ecrecover 的输出进行比较,这可能会导致漏洞。

此代码易受攻击

contract InsecureContract { address signer; // defaults to address(0) // who lets us give the beneficiary the airdrop without them// spending gas function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { // ecrecover returns address(0) if the signature is invalid require(signer == ecrecover(keccak256(abi.encode(who, amount)), v, r, s), "invalid signature"); mint(msg.sender, AIRDROP_AMOUNT); } }

签名重播

如果以前使用过签名,则当合约不跟踪时会发生签名重放。在下面的代码中,我们修复了之前的问题,但它仍然不安全。

 contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); mint(msg.sender, amount); } }

人们可以根据需要多次领取空投!


我们可以添加以下行

bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // mapping(bytes => bool); used[signature] = true;

las,代码仍然不安全!

签名延展性

给定一个有效的签名,攻击者可以做一些快速的算术来推导出一个不同的签名。然后攻击者可以“重放”这个修改后的签名。但首先,让我们提供一些代码,演示我们可以从有效签名开始,修改它,并显示新签名仍然通过。

 contract Malleable { // v = 28 // r = 0xf8479d94c011613baeffe9239e4ff65e2adbac744c34217ca7d51378e72c5204 // s = 0x57af17590a914b759c45aaeabaf513d5ef72d7da1bdd19d9f2e1bc371ece5b86 // m = 0x0000000000000000000000000000000000000000000000000000000000000003 function foo(bytes calldata msg, uint8 v, bytes32 r, bytes32 s) public pure returns (address, address){ bytes32 h = keccak256(msg); address a = ecrecover(h, v, r, s); // The following is math magic to invert the // signature and create a valid one // flip s bytes32 s2 = bytes32(uint256(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141) - uint256(s)); // invert v uint8 v2; require(v == 27 || v == 28, "invalid v"); v2 = v == 27 ? 28 : 27; address b = ecrecover(h, v2, r, s2); assert(a == b); // different signatures, same address!; return (a, b); } }

因此,我们的运行示例仍然容易受到攻击。一旦有人出示有效签名,就可以生成它的镜像签名并绕过使用过的签名检查。

 contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // this can be bypassed used[signature] = true; mint(msg.sender, amount); } }

安全签名

此时您可能需要一些安全签名代码,对吗?我们建议您参考我们关于在 Solidity 中创建签名并在 Foundry 中测试它们的教程。


但这是清单。


  • 使用 openzeppelin 的库来防止延展性攻击并恢复到零问题
  • 不要使用签名作为密码。消息需要包含攻击者无法轻易重用的信息(例如 msg.sender)
  • 散列你在链上签名的内容
  • 使用随机数来防止重放攻击。更好的是,遵循 EIP712 以便用户可以看到他们正在签名的内容,并且您可以防止签名在合约和不同链之间重复使用。

可以在没有适当保护措施的情况下伪造或制作签名

如果没有在链上进行散列,则可以进一步推广上述攻击。在上面的例子中,散列是在智能合约中完成的,所以上面的例子不容易受到以下漏洞的攻击。


我们看一下恢复签名的代码

// this code is vulnerable! function recoverSigner(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public returns (address signer) { require(signer == ecrecover(hash, v, r, s), "signer does not match"); // more actions }


用户提供散列和签名。如果攻击者已经看到签名者的有效签名,他们可以简单地重用另一条消息的散列和签名。

这就是为什么在智能合约中而不是链下对消息进行哈希处理非常重要的原因。

要查看此漏洞利用的实际效果,请参阅我们在 Twitter 上发布的 CTF。


原始挑战:

第 1 部分: https ://twitter.com/RareSkills_io/status/1650869999266037760

第 2 部分: https ://twitter.com/RareSkills_io/status/1650897671543197701

解决方案:

https://twitter.com/RareSkills_io/status/1651527648676573185 https://twitter.com/RareSkills_io/status/1651224817465540611

签名作为标识符

不应使用签名来识别用户。由于可延展性,它们不能被假定为唯一的。 Msg.sender 具有更强的唯一性保证。

一些 Solidity 编译器版本有错误

在此处查看我们在 Twitter 上举办的安全演习。审核代码库时,请根据 Solidity 页面上的发布公告检查 Solidity 版本,以查看是否可能存在错误。

假设智能合约是不可变的

智能合约可以使用代理模式(或更罕见的变质模式)进行升级。智能合约不应依赖任意智能合约的功能来保持不变。

Transfer() 和 send() 可以打破多重签名钱包

不应使用 solidity 函数 transfer 和 send。他们有意将交易转发的 gas 数量限制在 2,300,这将导致大多数操作耗尽 gas。


常用的gnosis safe多签钱包支持在fallback函数中将调用转发到另一个地址。如果有人使用 transfer 或 send 将 Ether 发送到多重签名钱包,回退函数可能会耗尽 gas 并且转账会失败。下面提供了 gnosis safe fallback 功能的屏幕截图。读者可以清楚地看到有足够多的操作用完 2300 gas。


如果您需要与使用转账和发送的合约进行交互,请参阅我们关于以太坊访问列表交易的文章,它可以让您降低存储和合约访问操作的 gas 成本。

算术溢出是否仍然相关?

Solidity 0.8.0 内置了上溢和下溢保护。因此,除非存在未经检查的块,或者使用了 Yul 中的低级代码,否则不存在溢出的危险。因此,不应使用 SafeMath 库,因为它们会在额外检查中浪费气体。

block.timestamp 呢?

一些文献表明 block.timestamp 是一个漏洞向量,因为矿工可以操纵它。这通常适用于使用时间戳作为随机源,如前所述,无论如何都不应该这样做。合并后的以太坊以 12 秒(或 12 秒的倍数)为间隔更新时间戳。然而,以秒级粒度测量时间是一种反模式。在一分钟的时间范围内,如果验证者错过了他们的块槽并且块生产出现 24 秒的间隔,则有相当大的错误机会。

极端情况、边缘情况和差一个错误

边角案例不容易定义,但一旦你看到足够多的案例,你就会开始对它们产生直觉。极端情况可能就像有人试图索取奖励,但没有任何赌注。这是有效的,我们应该只给他们零奖励。同样,我们通常希望平均分配奖励,但如果只有一个接收者,从技术上讲不应该发生分配怎么办?

极端情况:示例 1

此示例取自 Akshay Srivastav 的推特线程并进行了修改。

考虑这样一种情况,如果一组特权地址为其提供签名,则某人可以执行特权操作。

 contract VulnerableMultisigAuthorization { struct Authorization { bytes signature; address authorizer; bytes32 hashOfAction; // more fields } // more codef unction takeAction(Authorization[] calldata auths, bytes calldata action) public { // logic for avoiding replay attacks for (uint256 i; i < auths.length; ++i) { require(validateSignature(auths[i].signature, auths[i].authorizer), "invalid signature"); require(authorizers[auths[i].authorizer], "address is not an authorizer"); } doTheAction(action) } }

如果任何签名无效,或者签名与有效地址不匹配,则会发生还原。但是如果数组是空的呢?在这种情况下,它将一直跳转到 doTheAction 而无需任何签名。

差一:示例 2

 contract ProportionalRewards { mapping(address => uint256) originalId; address[] stakers; function stake(uint256 id) public { nft.transferFrom(msg.sender, address(this), id); stakers.append(msg.sender); } function unstake(uint256 id) public { require(originalId[id] == msg.sender, "not the owner"); removeFromArray(msg.sender, stakers); sendRewards(msg.sender, totalRewardsSinceLastclaim() / stakers.length()); nft.transferFrom(address(this), msg.sender, id); } }

虽然上面的代码并没有显示所有的函数实现,但即使函数的行为如其名称所描述的那样,仍然存在一个错误。你能发现吗?这是一张图片,可以给您一些空间,让您在向下滚动之前看不到答案。

removeFromArray 和 sendRewards 函数的顺序错误。如果 stakers 数组中只有一个用户,则会出现被零除的错误,用户将无法提取其 NFT。此外,奖励可能没有按照作者的意图分配。如果原来有4个staker,一个人退出,因为退出时数组长度为3,他将获得三分之一的奖励。

极端情况示例 3:Compound Finance 奖励计算错误

让我们举一个真实的例子,据估计造成了超过 1 亿美元的损失。如果您不完全理解 Compound 协议,请不要担心,我们只会关注相关部分。 (此外,Compound 协议是 DeFi 历史上最重要和最重要的协议之一,我们在DeFi 训练营中教授它,所以如果这是您对该协议的第一印象,请不要被误导)。


无论如何,Compound 的目的是奖励用户将闲置的加密货币借给可能需要它的其他交易者。贷款人以利息和 COMP 代币的形式获得报酬(借款人可以要求获得 COMP 代币奖励,但我们现在不会关注这一点)。

Compound Comptroller 是一个代理合约,它将调用委托给可以由 Compound Governance 设置的实现。


在 2021 年 9 月 30 日的第 62 号治理提案中,实施合约被设置为存在漏洞的实施合约。在它上线的同一天,有人在推特上观察到,尽管质押的代币为零,但一些交易仍在索取 COMP 奖励。

易受攻击的函数 distributeSupplierComp()


这是原始代码


具有讽刺意味的是,这个错误在 TODO 评论中。 “如果用户不在供应商市场,请不要分发供应商 COMP。”但是没有检查代码。只要用户在他们的钱包中持有质押代币 (CToken(cToken).balanceOf(supplier);),那么

提案 64于 2021 年 10 月 9 日修复了该错误。


尽管这可能被认为是输入验证错误,但用户并没有提交任何恶意内容。如果有人试图因为没有抵押任何东西而获得奖励,那么正确的计算应该是零。可以说,这更多是业务逻辑或极端案例错误。

真实世界黑客

现实世界中经常发生的 DeFi 黑客攻击并不属于上述类别。

Pairity 钱包冻结(2017 年 11 月)

parity 钱包不打算直接使用。这是智能合约克隆指向的参考实现。实施允许克隆在需要时自毁,但这需要所有钱包所有者在其上签名。

 // throw unless the contract is not yet initialized.modifier only_uninitialized { if (m_numOwners > 0) throw; _; } function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized { initDaylimit(_daylimit); initMultiowned(_owners, _required); }

声明钱包所有者

// kills the contract sending everything to `_to`.function kill(address _to) onlymanyowners(sha3(msg.data)) external { suicide(_to); }

一些文献将此描述为“不受保护的自毁”,即访问控制失败,但这并不十分准确。问题是 initWallet 函数没有在实施合约上调用,这允许有人自己调用 initWallet 函数并使自己成为所有者。这使他们有权调用 kill 函数。根本原因是实现没有初始化。因此,该错误不是由于错误的 solidity 代码引起的,而是由于错误的部署过程引起的。

Badger DAO 黑客攻击(2021 年 12 月)

这次黑客攻击没有利用 Solidity 代码。相反,攻击者获取了 Cloudflare API 密钥并将脚本注入网站前端,该脚本改变了用户交易以将提款定向到攻击者地址。在这篇文章中阅读更多内容。

钱包的攻击向量

随机性不足的私钥

发现具有大量前导零的地址的动机是它们使用起来更省油。以太坊交易对交易数据中的零字节收取 4 gas,对非零字节收取 16 gas。


因此,Wintermute 被黑是因为它使用了亵渎地址 ( writeup )。这是 1inch 关于亵渎地址生成器如何被破坏的文章


信任钱包在本文中记录了类似的漏洞 ( https://blog.ledger.com/Funds-of-every-wallet-created-with-the-Trust-Wallet-browser-extension-could-have-been-被盗/ )


请注意,这不适用于通过更改 create2 中的 salt 发现前导零的智能合约,因为智能合约没有私钥。

重复使用的 nonces 或不够随机的 nonces。

椭圆曲线签名上的“r”和“s”点生成如下

r = k * G (mod N) s = k^-1 * (h + r * privateKey) (mod N)


G、r、s、h、N都是公知的。如果“k”变为公开,那么“privateKey”是唯一未知的变量,可以求解。因为这个钱包需要完美地随机生成 k 并且永远不要重复使用它。如果随机性不是完全随机的,则可以推断出 k。


Java 库中不安全的随机生成在 2013 年导致许多 Android 比特币钱包易受攻击。(比特币使用与以太坊相同的签名算法。)


( https://arstechnica.com/information-technology/2013/08/all-android-created-bitcoin-wallets-vulnerable-to-theft/ )。

大多数漏洞都是特定于应用程序的

训练自己快速识别此列表中的反模式将使您成为更高效的智能合约程序员,但大多数智能合约错误的后果是由于预期的业务逻辑与代码实际执行的操作不匹配。


其他可能出现错误的地方:

  • 糟糕的代币激励
  • 差一个错误
  • 印刷错误
  • 管理员或用户的私钥被盗

许多漏洞可以通过单元测试发现

智能合约单元测试可以说是智能合约最基本的保障,但数量惊人的智能合约要么缺乏单元测试,要么测试覆盖率不足。

但是单元测试往往只测试契约的“快乐路径”(预期/设计行为)。为了测试令人惊讶的情况,必须应用额外的测试方法。


在发送智能合约进行审核之前,应首先完成以下工作:

  • 使用 Slither 等工具进行静态分析,以确保不会遗漏基本错误
  • 通过单元测试实现 100% 的线路和分支覆盖
  • 突变测试以确保单元测试具有健壮的断言语句
  • 模糊测试,尤其是算术
  • 有状态属性的不变测试
  • 适当的形式验证


对于那些不熟悉此处某些方法的人,Cyfrin Audits 的 Patrick Collins 在他的视频中幽默地介绍了有状态和无状态的模糊测试。


完成这些任务的工具正在迅速变得更加广泛和易于使用。

更多资源

一些作者在这些 Repos 中汇总了以前的 DeFi 黑客攻击列表:


Secureum 已被广泛用于研究和实践安全性,但请记住,回购协议已经 2 年没有大幅更新


您可以使用我们的Solidity Riddles存储库练习利用 solidity 漏洞。


DamnVulnerableDeFi 是每个开发人员都应该练习的经典战争游戏


Capture The Ether 和 Ethernaut 是经典之作,但请记住,其中一些问题简单得不切实际,或者教授的是过时的 Solidity 概念


一些知名的众包安全公司有一份有用的过去审计清单可供研究。

成为智能合约审计员

如果您不精通 Solidity,那么您将无法审核以太坊智能合约。


成为智能合约审计员没有行业认可的认证。任何人都可以创建一个网站和社交媒体资料,声称自己是可靠性审计师并开始销售服务,许多人已经这样做了。因此,在雇用一个人之前,请谨慎行事并获得推荐。

要成为一名智能合约审计员,你需要比一般的 solidity 开发人员更擅长发现错误。因此,成为一名审计员的“路线图”只不过是数月又数月的不懈和刻意练习,直到你成为比大多数人更好的智能合约错误捕捉者。


如果您缺乏在识别漏洞方面胜过同行的决心,您就不太可能比训练有素且积极进取的犯罪分子更早发现关键问题。

关于您成功成为智能合约安全审计员的机会的冷酷事实

智能合约审计最近被认为是一个理想的工作领域,因为人们认为它有利可图。事实上,一些漏洞赏金支出已经超过 100 万美元,但这是极其罕见的例外,而不是常态。


Code4rena 有一个公开的竞争对手在他们的审计比赛中的支出排行榜,这为我们提供了一些关于成功率的数据。


董事会上有1171个名字,但是

  • 只有 29 位参赛者的终生收入超过 100,000 美元 (2.4%)
  • 只有 57 人的终生收入超过 50,000 美元 (4.9%)
  • 只有 170 人的终生收入超过 10,000 美元 (14.5%)


还要考虑这一点,当 Openzeppelin 开放安全研究奖学金申请(不是工作,工作前筛选和培训)时,他们收到了 300 多份申请,只选择了不到 10 名候选人,其中更少的人会获得完整的时间工作。

OpenZeppelin 智能合约审核员职位申请

这比哈佛的录取率还低。


智能合约审计是一种竞争性的零和游戏。要审核的项目只有这么多,安全预算只有这么多,要查找的错误也只有这么多。如果您现在开始学习安全性,就会有许多积极性很高的个人和团队为您提供巨大的先机。大多数项目都愿意为有声誉的审计师而不是未经测试的新审计师支付额外费用。


在本文中,我们列出了至少 20 种不同类别的漏洞。如果您花了一个星期的时间掌握每一个(这有点乐观),您才刚刚开始了解经验丰富的审计员的常识。我们没有在本文中介绍 gas 优化或代币经济学,这两个都是审计员需要了解的重要主题。计算一下,您会发现这不是一个短途旅程。


也就是说,社区通常对新来者友好且乐于助人,并且提示和技巧比比皆是。但对于那些阅读本文希望以智能合约安全为职业的人来说,重要的是要清楚地了解获得一份有利可图的职业的几率对你不利。成功不是默认结果。


这当然是可以做到的,并且有相当多的人从对 Solidity 一无所知变成了从事审计工作的有利可图的职业。可以说,在两年的时间跨度内找到一份智能合约审计员的工作比进入法学院并通过律师考试要容易得多。与许多其他职业选择相比,它当然有更多的优势。


但是,这仍然需要您有极大的毅力才能掌握摆在您面前的快速发展的知识山,并磨练您发现错误的直觉。

这并不是说学习智能合约安全性不值得。绝对是。但是,如果您眼中带着美元符号接近该领域,请控制您的期望。

结论

了解已知的反模式很重要。然而,大多数现实世界的错误都是特定于应用程序的。识别任何一类漏洞都需要持续和刻意的练习。


通过我们行业领先的solidity 培训学习智能合约安全以及更多以太坊开发主题。


本文的主图是由 HackerNoon 的AI Image Generator通过提示“a robot protecting a computer”生成的。