合约审计中最常见的智能合约错误

1. 重入攻击

毫不奇怪,以太坊中最臭名昭著的错误之一仍然是编写智能合约时要寻找的常见问题。由重入漏洞引起的 DAO hack 发生在四年多前。从那时起,许多智能合约成为这个问题的受害者,包括最近的 ERC777 Uniswap 交易所。还值得注意的是,在以太坊伊斯坦布尔硬分叉更新操作码定价之后,之前针对此问题普遍推荐的修复(transfer&send方法)不再适用于所有用例。 在我们为本次审查考虑的一些审计中存在可重入错误,并且所有这些都被归类为关键或主要错误,因为它以一种或另一种方式涉及资金损失。当攻击者可以在函数的第一次执行完成之前多次调用智能合约中的函数时,就会发生可重入错误。下面举一个例子:

mapping(address => uint) deposits;

// Extract from the insecure function 
function update(...) {
   ...
   uint value = deposits[msg.sender];
   safeTransferETH(msg.sender, value)
   deposits[msg.sender] = 0;
   ...
}

// from uniswap TransferHelper.sol
function safeTransferETH(address to, uint256 value) internal { 
   (bool success, ) = to.call{value: value}(new bytes(0)); 
   require(success, 'TransferHelper: ETH_TRANSFER_FAILED'); 
}

在此示例中,调用者的deposits映射仅在外部调用之后更新。update这允许调用者从safeTransferETH允许他们多次提取余额中重新进入函数。此外,safe函数名称中的safeTransferETH可能会误导开发人员认为在没有任何预防措施的情况下调用该函数是“安全的”。

避免重入错误的最好方法是遵循 Checks-Effects-Interaction 模式。这种模式确保代码在完成所有状态更改之前不会进行外部调用。

function update(...) {
   ...
   uint value = deposits[msg.sender];
   deposits[msg.sender] = 0; // changing state before external call
   safeTransferETH(msg.sender, value)
   ...
}

避免此错误的另一个简单易行的修复方法是在可能可重入的函数中使用互斥锁。这可能并不总是最有效的解决方案,但它可以保护函数在释放锁之前不被再次调用。在具有多个交互的更复杂的合约中使用互斥锁有时会导致其他错误,例如 DoS。一个简单的互斥锁可以如下实现。

uint256 private _status;

function update(...) nonReentrant {
   require(_status != 1, "ReentrancyGuard: reentrant call");
   _status = 1;
   ... // contract code
   _status = 2;
}

在此实现中,锁将阻止函数在第一次执行完成之前再次被调用。

2.越权存取

一个简单的错误,例如不限制未经授权的访问的重要功能可能会导致灾难。今年我们看到一些合同没有检查调用者是否被授权执行特定操作。 我们发现这些不受限制的函数最常见的地方是在 oracle 回调实现中。让我们看一个与我们遇到的类似的例子。

event requestPriceUpdate();
uint price;

function priceUpdate() public {
    emit requestPriceUpdate();
}

// Intended for oracle callback
function priceUpdateCallback(uint _price) public {
    price = _price;
}

本合约中的价格更新回调旨在供预言机服务在请求价格更新时进行回调。由于该功能不限于预言机服务,任何人都可以调用它来操纵价格。为避免任何副作用,建议将此类函数限制为一定数量的受信任调用者。 当智能合约中的价值(如价格)来自人群时,可能会发生此问题的不同变体。如果更新到错误的值没有惩罚,那么它可能会被严重滥用。

3. 外部呼叫 - DoS

外部合约交互已成为以太坊中许多智能合约的必要条件。随着交互次数的增加,忽略漏洞的机会也将面临更高的风险。我们始终对智能合约中的外部调用保持谨慎,并努力验证它们,以免错过攻击媒介。

一个这样的常见问题是 ETH 转移期间的拒绝服务错误。并非所有收件人都是具有最少后备功能的地址或合同。由于transferandsend函数将准确的2300 gas 转发给接收者,因此具有后备函数的合约将引发 out-of-gas 异常。这个上限的目的是为了防止重入漏洞,但它要求gas使用量是恒定的。

从我们的一项审计中考虑以下示例:

function refund(uint256 refundAmount, address payable to) internal {
   to.transfer(refundAmount);
}

此函数尝试将 ETH 发送到某个地址,如果接收者消耗超过 2300 的 gas 将失败。建议使用call代替transfer/send以避免可能的 DoS。

function refund(uint256 refundAmount, address payable to) internal {
   (bool success, ) = to.call{value: refundAmount}(""); 
   require(success, "Transfer failed");
}

使用 call 函数有其自身的风险,因为所有可用的 gas 都被转发了。建议检查调用方法的返回值。还建议始终遵循 Checks-Effects-Interaction 模式以避免重入错误。

3. 代码中的逻辑错误

这个类别不仅仅是与以太坊智能合约甚至一般的 DApp 相关的东西。逻辑错误和功能规范不匹配是所有应用程序和智能合约中的常见问题,这可能导致资金损失或使应用程序容易受到其他严重攻击。我们决定将其作为本报告的一部分,因为我们在执行的审计中发现了一些严重问题。 让我们看一个简单的错误,它使合同的行为与白皮书中最初的预期完全相反。

function calculateFinalFee(uint256 value) public view returns (uint256) { 
   return value > maxAmountForFee ? fee : 0;
}

该函数根据金额计算费用。如果该值大于设置的最大金额,则收取费用。这看起来简单明了,不会引起任何问题。根据这个特定项目的白皮书,只有当价值小于设定的最大值时才会收取费用——这与这里所做的完全相反。这些简单的错误很容易被忽略,这个函数的预期代码看起来像这样。

function calculateFinalFee(uint256 value) public view returns (uint256) { 
   return value > maxAmountForFee ? 0 : fee;
}

这些错误对于每个智能合约都是独一无二的,没有一种直接的解决方案可以解决这个问题。了解项目的详细规范并编写足够的覆盖完整规范和代码的测试用例可以帮助验证功能并避免相关的错误。还建议让额外的开发人员或审计员来测试代码。我们 Solidified 认为花时间了解项目规范是任何审计中的重要一步。对于更复杂的项目,我们经常会与客户进行通话,以确保我们在代码规范和意图方面保持一致。

4. 整数上溢/下溢

与我们之前看到的相比,今年的上溢和下溢错误数量相对较少。它并非完全不存在,但我们仍然可以在一些意想不到的地方找到它。 整数溢出发生在整数值达到数据类型支持的最大值(2⁸ 表示uint8,2²⁵⁶ 表示uint256等)时,导致值循环回 0。这对于具有较小最大值的数据类型(如uint8和)更为常见uint32。但在某些具有足够大值的用例中,uint256也可能溢出。 下溢的情况非常相似。当 a 的值uint设置为小于零时,该值下溢并将设置为该数据类型的最大可能值。考虑以下简单令牌转移的示例。如果余额达到最大值,它将自动归零。

mapping (address => uint256) public balanceOf;

function transferFrom(address _from, address _to, uint256 _value) {
   require(balanceOf[_from] >= _value);
   balanceOf[_from] -= _value;
   balanceOf[_to] += _value;
}

最简单的解决方案是使用SafeMath库,该库提供了通过内置上溢/下溢验证执行算术运算的功能。您可以选择自己实施检查,但这样做时应格外小心。

mapping (address => uint256) public balanceOf;

function transferFrom(address _from, address _to, uint256 _value) {
   require(balanceOf[_from] >= _value, "Low balance");
   require(balanceOf[_to] + _value >= balanceOf[_to], "overflow");

   balanceOf[_from] -= _value;
   balanceOf[_to] += _value;
}

4. 存储私人数据

以太坊上所有东西都公开的事实有时会让新开发者感到困惑。使用 Solidity 语言中类似private和internal使用的关键字,开发人员很容易误认为它们存储可见性受限的私有数据。 我们已经看到许多开发人员假设合约将存储私有数据并且网络中没有人可以读取它的情况。更糟糕的是,一些合约试图在此之上实施货币化模型。这些合约将允许用户在收取一定费用后读取存储的“私人”数据。

mapping (address => bool) private allowed;
bytes32 private secret;

function read() public view returns(bytes32) {
   require(allowed[msg.sender], "Permission denied");
   return secret;
}

存储在密钥中的值无法从智能合约中访问——这对于任何通用应用程序都是有意义的。但是,在以太坊中,将值存储在私有变量中并不限制任何人从合约状态或最初用于存储相同值的交易中读取它。 简而言之,不要在以太坊智能合约中存储任何敏感信息。您可以选择存储加密或散列数据,但不建议这样做。 迭代过程中的气体溢出——DoS 在某些情况下,循环执行的成本可能超过每个块允许的最大气体,并且当它发生时,交易将无法执行。这可能是暂时的,并且只会影响特定的事务,或者如果迭代计数超时增长,则可能是永久性的。我们遇到过一些这样的 DoS 案例,无论是否存在故意攻击,这些案例都可能发生。

假设该函数compute用于动态计算值的基本函数。由于数组大小可以增长到一个非常大的数字,合约最终会因为区块气体限制而停滞不前。 这些在测试期间很难捕捉到,因为开发人员倾向于使用较小的数据集作为输入,并且一些测试网络在涉及气体限制时通常是宽容的。强烈建议避免未知大小的循环。如果您的智能合约需要循环,则通过跟踪到目前为止循环执行的程度将交易分成多个块。这将有助于在后续块中恢复迭代。

5. 总之

以太坊是一个有望彻底改变许多行业的平台,但与其他非区块链平台相比,它仍处于早期阶段。没有错误的代码很适合在其他应用程序中使用,而在像以太坊这样的去中心化区块链中,它被设计为不可变的,这是必不可少的。 这些是常见的错误,您也不能停止寻找其他类型的错误。每份合同都有其独特的方式,合同中的一个不常见错误可能是最严重的错误。这使得在部署到主网络之前审核智能合约的错误成为必不可少的步骤。

6. 测试提醒

确保您的智能合约在以太坊的主网络上是安全的并不是一步到位的过程。应该从定义需求的一开始就考虑它,并且应该在整个开发阶段一直执行到最后。开发人员应该花足够的时间详细了解规范并编写足够的测试用例以在代码中涵盖它们。除了在模拟网络中测试合约外,鼓励在 Ropsten 等更大的网络中测试它们以模拟真实世界的使用。 并非所有审计都是平等的

全部评论(0)