智能合约中的缺失或不当输入验证

  • cyfrin
  • 发布于 1天前
  • 阅读 88

本文探讨了在Solidity智能合约中缺失或不当输入验证所可能导致的安全漏洞,强调了适当输入验证的重要性,以及如何通过编写安全代码来降低风险。文章详细介绍了编译时和运行时的输入验证,影响与案例分析,提供最佳实践指导,帮助开发者提高智能合约的安全性。

智能合约中的缺失或不当输入验证

了解在 Solidity 中不当输入验证如何导致攻击。探索保护智能合约和防止安全风险的最佳实践。

Solidity 中,用户或外部合约通过调用函数与智能合约进行交互,可能需要传递输入参数。输入可以是从简单的值(如地址和交易金额)到复杂的 数据结构,这些结构塑造了合约的行为和执行。

鉴于 智能合约不可变 特性,确保输入得到适当验证至关重要。输入验证是防御漏洞的第一道防线。没有适当的验证,恶意行为者可以操纵合约逻辑,导致意外行为,甚至获得对敏感功能的未授权访问。

在传统应用程序中,出错的输入处理通常可以通过快速更新进行修复。相反,智能合约中的错误可能导致不可逆的财务损失和安全漏洞。

因此,本文将探讨 Solidity 中输入验证的重要性以及缺失或不当验证关联的风险。还将分享保护智能合约免受此类漏洞的最佳实践。

首先,让我们看看在 Solidity 中输入验证如何发生在编译时和运行时。

Solidity 的编译时和运行时验证工作原理

Solidity 中的输入验证发生在两个阶段——编译时和运行时——每个阶段在确保合约正确性和安全性方面发挥着重要作用。

编译时验证在 Solidity 中

编译时验证发生在 Solidity 编译器 部署合约之前检查错误。编译器强制执行类型安全、函数可见性规则和结构正确性,但不对输入的逻辑约束进行验证。

例如,以下合约由于输入参数 _input 和存储变量 value 的类型不匹配而无法编译:

// 该合约由于类型不匹配而无法编译
pragma solidity ^0.8.20;

contract Example {
    uint256 public value;

    function setValue(string memory _input) public {
        value = _input; // 错误:类型不匹配(字符串赋值给 uint256)
    }
}

在这里,将 string 分配给 uint256 会触发编译时错误。Solidity 编译器在部署之前防止此类问题,确保基本的类型安全。然而,它并不确保值在有效范围内或遵循特定格式。

Solidity 中的运行时验证

Solidity 中的运行时输入验证强制约束用户提供的输入在合约执行期间。它通常使用条件语句或 require()revert()assert() 等函数,当输入不符合预期条件时中止执行并撤销 状态 更改。

在运行时, 以太坊虚拟机 (EVM) 强制执行如 Gas 限制、类型安全、内存访问限制和算术操作等约束,以维持执行的完整性。然而,它并不会内在地验证 业务逻辑——例如验证输入是否在有效范围内,或调用者是否具有必要权限。开发人员必须明确实现这些检查。否则,EVM 将不受限制地执行交易。

在下面的 SafeContract 示例中,输入验证在运行时发生。

pragma solidity ^0.8.0;

contract SafeContract {
    uint256 public balance;

    function deposit(uint256 _amount) public {
        require(_amount > 100, "金额必须大于100");
        balance += _amount;
    }
}

require() 语句确保 _amount 始终大于 100。如果用户尝试发送 _amount <100,交易将在运行时失败,防止无效输入影响合约的状态。

现在我们已经探讨了这些输入验证类型,让我们定义什么构成不当或缺失的输入验证。

什么构成不当或缺失的输入验证?

不当或缺失的输入验证发生在智能合约未能充分验证其处理的输入的正确性、类型或范围时。以下是 Solidity 中一些不当或缺失输入验证的形式。

1. 无范围检查

未能确保输入在可接受的边界内可能导致意想不到的行为。例如,在 setPrice 中,用户可以设置一个极高或负的价格(如果存储为 int),扰乱合约逻辑。

function setPrice(uint256 _price) public {
    price = _price; // 未强制执行上下限
}

2. 无类型强制

没有严格的类型强制,可能导致不必要的值通过并破坏合约。在 setAddress 中,恶意用户可以传递 address(0),如果合约期望一个有效地址,则可能导致逻辑错误。

function setAddress(address _user) public {
    user = _user; // 未对零地址进行验证
}

3. 未能限制未授权输入

未能限制函数中的输入值可能会导致不一致的合约状态。例如,用户可以调用 transfer,其金额超过其可用余额,导致 balances 映射中状态更新不正确。

function transfer(address _to, uint256 _amount) public {
    balances[_to] += _amount; // 未验证发送者余额
}

4. 不当的长度或格式检查

格式不正确的输入是指任何不遵循合约逻辑定义的预期结构、长度或字符约束的值。例如,攻击者可以调用 setUsername,传入过大的字符串,从而增加 Gas 成本或触发意外的合约行为。

function setUsername(string memory _name) public {
    username = _name; // 未检查长度
}

5. 忽视布尔输入中的边界情况

布尔变量(true/ false)看起来安全,但如果没有经过适当验证,仍然可能被滥用。例如,如果 _flag 控制对敏感函数的访问且设置不正确,则可能发生意外行为。

function setFlag(bool _flag) public {
flag = _flag;
}

// 应该使用:
// require(_flag == true, "无效的标志值"); 在将标志设置为输入之前

现在我们已经定义了不当和缺失的输入验证,让我们通过以下案例研究深入探讨。

缺失或不当输入验证的案例研究:漏洞和缓解

不当输入验证是智能合约中许多确认的漏洞的主要原因,常常导致严重的安全漏洞。以下是由于不当或缺失输入验证而报告的一些漏洞示例。

1. Sovryn 智能合约借款函数中缺失的输入验证

考虑下这个脆弱的 borrow 函数,它在 Sovryn-smart-contracts 中。该函数接受多个输入参数并实施了一些输入验证,如下所示。然而,由于缺失输入验证,该函数仍然易受恶意攻击。

function borrow(
    bytes32 loanId, // 0 如果是新贷款
    uint256 withdrawAmount,
    uint256 initialLoanDuration, // 持续时间(秒)
    uint256 collateralTokenSent,
    address collateralTokenAddress,
    address borrower,
    address receiver,
    bytes memory /*loanDataBytes*/ // 任意顺序数据(供将来使用)
) public payable nonReentrant hasEarlyAccessToken returns (uint256, uint256) {
    require(withdrawAmount != 0, "6");

    _checkPause();

    // 临时:限制交易大小
    if (transactionLimit[collateralTokenAddress] > 0) {
        require(collateralTokenSent <= transactionLimit[collateralTokenAddress]);
    }

    require(msg.value == 0 || msg.value == collateralTokenSent, "7");
    require(collateralTokenSent != 0 || loanId != 0, "8");
    require(collateralTokenAddress != address(0) || msg.value != 0 || loanId != 0, "9");

    if (collateralTokenAddress == address(0)) {
        collateralTokenAddress = wrbtcTokenAddress;
    }

    require(collateralTokenAddress != loanTokenAddress, "10");

//**************其他一些逻辑***********//
}

borrow 函数允许恶意用户传入未使用的抵押品和任何接收地址作为输入值。这一验证缺口允许恶意用户使用另一个用户的抵押品进行贷款——无需承担还款义务。

要修复此漏洞,函数必须验证新用户不能以现有的 loanId 进入,或者调用者必须等于借款人,以确保对现有贷款的授权使用。

require(loanId == 0 || msg.sender == borrower, "13");

2. Ocean Vesting Wallet 中的归属时间表输入验证不足

在下面的 VestingWalletHalving 合约的构造函数中,输入参数缺乏足够的输入验证。唯一存在的验证是确保 beneficiaryAddress 不是零地址。

constructor(
        address beneficiaryAddress,
        uint64 startTimestamp,
        uint256 halfLife,
        uint256 duration
    ) payable {
        require(
            beneficiaryAddress != address(0),
            "VestingWallet: beneficiary is zero address"
        );
        _beneficiary = beneficiaryAddress;
        _start = startTimestamp;
        _halfLife = halfLife;
        _duration = duration;
    }

在这个例子中,第一个漏洞是构造函数中的 _halfLife 参数可以设置为零。虽然这不会导致立即回滚,但会在依赖于 _halfLife 进行计算的函数中触发问题。

例如,如果 _halfLife == 0,合约中的 getAmount 函数将始终回滚。

  • t / h 导致除零错误(这将回滚)。
  • t % h 导致取模零错误(这将回滚)。
  • (p * t) / h / 2 导致另一个除零错误(这将回滚)。
function getAmount(
        uint256 value,
        uint256 t,
        uint256 h
    ) public pure returns (uint256) {
        uint256 p = value >> (t / h);
        t %= h;
        return value - p + (p * t) / h / 2;
    }

注意:在 Solidity 中,取模为零( % 0)会导致回滚,因为取模被定义为返回余数的除法操作。由于在数学中除以零是未定义的,因此 Solidity 遵循该规则,当试图进行除以零的操作时,会自动回滚**。

此外,如果 duration 设置为零,用户可以立即释放代币和以太。

要缓解这些漏洞,请在构造函数中添加以下输入验证。

uint64 currentTime = uint64(block.timestamp);

require(
    startTimestamp >= currentTime && startTimestamp <= currentTime + 5000 days,
    "VestingWallet: startTimestamp 超出范围"
);

require(
    halfLife > 0,
    "VestingWallet: halfLife 必须大于零"
);

require(
    duration >= 30 days,
    "VestingWallet: duration 必须至少为30天"
);

现在,让我们检查接入控制的潜在影响,这是一种与不当输入验证密切相关的漏洞。

不当输入验证与不当访问控制

不当或缺失的输入验证允许意外或恶意数据进入合约。

不当 访问控制 发生在合约未能适当限制谁可以调用某些函数时。这使得未授权用户能够执行敏感操作,导致安全漏洞,如未授权资金转移、权限升级或合约操控。

在下面的示例中,unsafeWithdrawFunds() 由于缺少访问控制,允许 任何人 提取所有合约资金,而 safeWithdrawFunds() 使用 onlyOwner 修饰符限制取款仅限合约所有者。

pragma solidity ^0.8.0;

contract SecureContract {
    address public owner;

    constructor() {
        owner = msg.sender; // 设置合约部署者为所有者
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "不是所有者");
        _;
    }

    function unsafeWithdrawFunds() public {
        payable(msg.sender).transfer(address(this).balance);
    }

    function safeWithdrawFunds() public onlyOwner {
        payable(msg.sender).transfer(address(this).balance);
    }

    receive() external payable {} // 接收以太币的函数
}

我们探讨了各种不当或缺失输入验证的形式及关联的风险。现在,让我们看看如何防止智能合约中的这些漏洞。

如何防止智能合约中不当或缺失的输入验证

1. 确定输入源和攻击面

检查所有输入源,并确定其可以被操纵的位置。

这包括用户提供的输入(函数参数、构造函数参数和外部调用)、通过输入参数更新的状态变量、外部依赖(如预言机和第三方合约),以及交易的 元数据,如 msg.sendermsg.valuetx.originblock.timestamp

2. 根据业务逻辑和安全要求定义约束

确保每个输入有 明确定义的约束,以避免意外行为。约束应基于:

  • 逻辑边界:值在预期范围内。
  • 安全考虑:预计攻击者可能如何操纵值。
  • 业务逻辑执行:值与预期合约行为一致。

3. 尽可能在编译时验证输入

某些验证可以在部署之前强制执行,以减少运行时错误。使用适当的数据类型,如 uint8 代替 uint256 用于小数字,以防止在 类型 级别出现无效值,消除运行时检查的需要。

4. 为意外输入实现回退机制

通过为缺失或不当的数据分配默认值,并处理意外情况而非直接回滚,来为意外输入实现回退机制。

function safeSetAmount(uint256 _amount) public {
    if (_amount == 0) {
        _amount = 100; // 分配默认值
    }
    amount = _amount;
}

5. 使用模糊测试发现边界情况

模糊测试 是检测智能合约中缺失或不当输入验证的强大技术。通过生成随机和意外的输入,模糊测试有助于发现可能导致潜在漏洞的验证检查失败的边界情况。定期进行模糊测试可以主动识别在部署前输入处理的薄弱环节。

结论

输入验证是防御各种漏洞的第一道防线。要正确地实施它,开发人员必须仔细评估与输入参数相关的所有攻击面,并强制执行与安全最佳实践和业务逻辑一致的明确定义的约束。

输入验证漏洞只是恶意行为者的多个入侵点之一。预防这些攻击需要深刻理解潜在攻击向量和完善的审计技术。要掌握智能合约审计并学习编写安全、高效协议的最佳实践,请查看 Updraft 的 安全与审计课程

  • 原文链接: cyfrin.io/blog/missing-o...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.