Solidity 输入验证攻击防护指南

文章作者

失控输入验证攻击发生在 Solidity 合约接受用户控制的地址、金额、标识符、价格或配置值时,而未能证明这些值符合合约的经济与安全假设。为防止此类攻击,应在信任边界对每个外部输入进行验证,包括白名单、范围检查、零地址检测、授权验证和状态不变量,在资产转移或会计变更之前确保其合法性。

其影响是切实的:例如,Qubit 在 2022-01-28 在 BSC 上发生了价值 8000 万美元的损失事件,被归类为“缺失输入验证”。这些失败不仅仅是格式错误的交易问题。不加控制的值可能导致 Solidity 代码执行不正确的业务操作,例如:入账不存在的抵押物、向不可信的地址转账,或超过借款限制产生债务。

Solidity 中的输入验证漏洞是什么?攻击是如何进行的?

当 Solidity 合约将调用者提供的值视为可信,尽管该值实际上控制权限、资产会计、价格或状态转移时,即存在输入验证漏洞。攻击者提供一个语法有效但对协议而言经济无效的值,然后利用由此产生的状态进行资产提取、权益发行或规避限制。

Solidity 只强制类型匹配,而不考虑协议的意图。一个 uint256 可以是任何非回退的数字,一个 address 可以指向外部账户(EOA)、恶意合约、零地址或不支持的代币。合约必须自己执行语义规则的验证。

一个典型的攻击流程包括: 1. 攻击者识别接受未检查额度、代币、接收者、价格或标识符的公共函数。
2. 他们选择一个违反协议预期假设的值。
3. 合约记录了一个不安全的余额、权益、负债上限或授权状态。
4. 攻击者调用后续信任该被污染状态的函数。
5. 协议据此转移资产或发行可转让的权益。
6. 其他用户在会计短缺暴露时承担损失。

关键问题是:这个输入声称代表的事实是什么?合约是否有独立验证? 如果答案是“由调用者提供”,那么合约就存在不当的输入验证漏洞。

哪些 Solidity 模式会引发输入验证攻击?

输入验证攻击通常在合约设计强调灵活性以优化用户体验或兼容性,但未将这种灵活性限制于协议支持范围内时出现。危险参数不一定叫“price”或“amount”;它们可能是代币地址、路由、接收者、批次ID、证明或外部合约地址。

常见的不安全模式包括: - 调用者声明的抵押价值:借款人提供用以计算借款额度的抵押物价值。
- 任意代币地址:存款函数接受任何 ERC-20,尤其是不符合标准的转账行为的代币。
- 未检查的接收地址:接受 address(0)、合约自身或不符合托管规则的地址。
- 无限制的百分比或比例参数:费率、贷款比率(LTV)、折扣、滑点或奖励率超出预期范围。
- 未验证的数组关系:平行数组长度不一致、重复条目被接受或路由中重复资产。
- 没有前置条件的状态转移:认领、取消、赎回或迁移操作在不正确的生命周期状态执行。
- 外部数据作为事实:函数信任调用者提供的价格、余额、签名或返回值,未做验证。

验证必须具有上下文敏感性。require(amount > 0)虽有用,但不能证明amount是否合理,是否已抵押、在限额以内或与当前状态一致。类似地,验证代币地址非零不等于确认代币已被批准或按预期行为运作。

Soken 的审查方法将每个外部参数视为一次状态转移尝试:识别其可能影响的不变量,然后验证函数在写入存储前是否执行了该不变量的强制检查。

Solidity 中输入验证攻击示例是什么样的?

以下示例展示了借款者提交自己抵押品价值 collateralValue 的情况。合约会检查借款金额不大于该值,但未验证借款者是否真的存入了等值的抵押物。攻击者可以设置两个值为任意水平,借取借出资金。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 value) external returns (bool);
}

contract UnsafeLender {
    IERC20 public immutable asset;
    mapping(address => uint256) public debt;

    constructor(IERC20 asset_) {
        require(address(asset_) != address(0), "zero asset");
        asset = asset_;
    }

    function borrow(
        uint256 collateralValue,
        uint256 amount
    ) external {
        require(collateralValue > 0, "no collateral");
        require(amount <= collateralValue, "insufficient collateral");

        // 衰败点:借款者提供的抵押值用以支持贷款
        debt[msg.sender] += amount;

        require(asset.transfer(msg.sender, amount), "transfer failed");
    }
}

改进后,借款能力根据实际收到的抵押物确定。只有资产地址在白名单内,构造函数会拒绝零地址,LTV范围有限,余额变动检查确保转账的不变量,而不是信任请求的存款金额。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

contract ValidatedLender {
    IERC20 public immutable loanAsset;
    IERC20 public immutable collateralAsset;
    uint256 public immutable maxLtvBps;

    mapping(address => uint256) public collateral;
    mapping(address => uint256) public debt;

    constructor(IERC20 loanAsset_, IERC20 collateralAsset_, uint256 maxLtvBps_) {
        require(address(loanAsset_) != address(0), "zero loan asset");
        require(address(collateralAsset_) != address(0), "zero collateral asset");
        require(maxLtvBps_ > 0 && maxLtvBps_ <= 10_000, "invalid LTV");
        loanAsset = loanAsset_;
        collateralAsset = collateralAsset_;
        maxLtvBps = maxLtvBps_;
    }

    function depositCollateral(uint256 amount) external {
        require(amount > 0, "zero amount");
        uint256 beforeBalance = collateralAsset.balanceOf(address(this));
        require(collateralAsset.transferFrom(msg.sender, address(this), amount), "transfer failed");
        uint256 received = collateralAsset.balanceOf(address(this)) - beforeBalance;

        // 修正:只考虑实际收到的抵押物
        require(received == amount, "unsupported token behavior");
        collateral[msg.sender] += received;
    }

    function borrow(uint256 amount) external {
        require(amount > 0, "zero amount");
        uint256 limit = (collateral[msg.sender] * maxLtvBps) / 10_000;
        require(debt[msg.sender] + amount <= limit, "borrow limit exceeded");

        debt[msg.sender] += amount;
        require(loanAsset.transfer(msg.sender, amount), "transfer failed");
    }
}

如果你的协议接受灵活的资产、路由或会计输入,合作的智能合约审计与渗透测试可以追查每个声明值是否在影响资金之前已被独立验证。

如何在 Solidity 中预防输入验证攻击?

预防输入验证攻击,关键在于在入口点进行验证,然后在整个合约生命周期中用明确的不变量维护这些假设。最有效的应对手段不是依靠单个修饰符,而是设计一种每个关键状态变化都拥有清晰允许参数集和可验证条件的模型。

技术 防止的问题 Gas 或复杂度成本 限制
代币与实现白名单 不支持的 ERC-20、恶意适配器、外部调用 存储查找或immutable地址比较 已批准的代币仍可能带来经济风险
范围检查 零额度、过高费率、超出基础点范围、不安全的截止时间 低成本,通常几次比较 仅靠范围检查不能证明经济合理性
零地址和接收地址检查 资产燃烧、无效所有权、错误的托管目标 低成本,一个地址比较 非零地址仍可能不可信
授权和生命周期验证 在错误状态或由错误角色调用 存储读取与角色设计 角色管理需要安全的管理员权限
余额变动验证 虚假存款抵扣、unsupported 代币转账行为 附加的代币余额读取 需要妥善处理重基资产
不变量验证 破坏的偿付能力、供应、债务及储备关系 额外读取和算术运算 不变量必须覆盖全部经济模型

对于可配置协议,还应在配置创建及使用时均进行验证。治理参数在创建时可能安全,但在依赖变更后变得不安全。升级系统中,初始化参数验证应与公共函数参数同样严格。

安全见解: Soken 方法从不变量出发,而非 require 语句。如果审查者无法明确陈述用户控制输入后必须保持为真的条件,就无法判断验证是否充分。

哪些实际事件被归咎于“缺失输入验证”?

“缺失输入验证”已多次关联到借贷、交换和资产管理场景中的重大智能合约损失事件。这些事故跨链、多协议,表明问题根源在于协议逻辑的失误,而非特定网络的 Solidity 缺陷。

协议 日期 损失金额 链
Qubit 2022-01-28 $80.0M BSC
Hedgey 2024-04-19 $44.7M Arbitrum, Ethereum
Transit Swap 2022-10-02 $28.9M Ethereum, BSC
Pickle 2020-11-22 $19.7M Ethereum

共同的经验教训是:合约不能仅凭交易成功编码传递的值就安全地推断协议真相。资产体系需要围绕那些具体保护价值的属性进行语义验证——如收到的抵押、支持的资产、授权的路由或有效的生命周期转移。

DeFiLlama 将 2020 至 2026 年间52起智能合约事故归入“缺失输入验证”,总损失高达 3.305 亿美元。正是因为这个原因,Soken 将验证审查视为经济安全的系统性工作,而非表面上的 require 语句检查清单。

如何检测不当的输入验证漏洞?

你可以通过追踪每个外部可达参数到其影响的存储写入、外部调用和资产转移,测试是否存在违反不变量的无效但符合 ABI 的值,从而发现漏洞。静态分析可以找出部分缺失的检查,但场景驱动测试更适合发掘特定协议假设。

请使用以下审查清单:

  1. 列出每个影响数值、权限或路由的external 和 public 函数参数。
  2. 核查每个地址是否非零、经过批准,是否适合其角色。
  3. 检查每个额度是否符合协议的上限、余额、债务限额、总供应量和小数点假设。
  4. 确认价格、抵押值和余额来源可信或已独立验证。
  5. 核实数组长度、唯一性、排序和跨数组索引关系。
  6. 确保每个函数只允许有效的生命周期状态和授权调用者。
  7. 测试余额变动的会计,考虑费率、重调参数和异常 ERC-20 行为。
  8. 对每个资产转移操作,述明并测试完成后的状态条件。

Slither 能检测一些典型模式,如危险的外部调用、未检查的返回值和薄弱的访问控制,这些常在验证失误中出现。它无法推断所有协议考量,因此应结合 Foundry 的模糊测试,生成零值、最大值、重复数组、意外地址或不支持的代币。

不变量测试尤为有效。例如,确保总债务从不超过设定的借款上限、抵押品的入账不超过实际资产收到、仅批准的资产能改变市场会计。在构建更全面的安全审查框架时,可以探索 Soken Hub。

输入验证是用户意图与协议事实的界限。合约绝不应仅依赖调用者声称的内容进行入账、授权、定价或转移。应从已验证的状态中导出敏感值,限制可以接受的域,并确保最终状态的经济安全不变量。

下一步建议是:为代码库中的每个资产转移函数建立参数与不变量的映射关系。任何没有明确可信来源、允许范围和后置条件的参数,都应马上进行审查。

文章作者

常见问题

Solidity 中的输入验证漏洞是什么?

当合约接受用户控制的地址、金额、标识符、价格或配置值,却未证明其符合经济与安全假设时,就会出现 Solidity 输入验证漏洞。合约随后可能执行有效代码路径,却产生无效业务操作,包括不安全的资产转移或错误记账。

如何防范 Solidity 输入验证攻击?

要防范 Solidity 输入验证攻击,应在资产移动或记账变更前,于信任边界验证每一项外部输入。结合白名单、边界检查、零地址检查、授权检查和状态不变量,确保每个用户控制的值都符合合约预期的安全与经济规则。

Qubit 输入验证攻击中发生了什么?

Qubit 于 2022-01-28 在 BSC 上损失 $80.0M,该事件被归类为缺失输入验证。Qubit 事件表明,未检查的输入不只是格式错误的交易问题:它们还可能让合约确认不存在的抵押品,或执行无效的经济操作。

为什么 Solidity 合约应检查零地址?

当用户提供的地址必须标识真实的接收方、资产或权限主体时,Solidity 合约应拒绝零地址。对于 Solidity 输入验证,零地址检查可防止合约接受违反预期转移、授权或记账假设的占位值,从而降低逻辑风险。

边界检查足以防范输入验证漏洞吗?

仅靠边界检查不足以完成 Solidity 输入验证,因为数值即使处于合法范围内,仍可能违反授权、白名单、地址或状态要求。Solidity 合约需要在资产移动或记账变更前,于信任边界实施分层验证,并让每项输入匹配相关业务不变量。

聊天