欢迎光临
我们一直在努力

Solidity 模糊测试失败后:从最小反例定位合约边界

Solidity 模糊测试失败后:从最小反例定位合约边界

模糊测试失败的价值在于它给出了一条可缩减的输入。不要把随机失败包装成故障故事;先固定种子,缩小反例,再检查不变量和权限边界。本文的输入仅用于说明复现流程。

1. 证据链定位架构:从 Trace 到攻击路径重构

当 Foundry 或 Echidna 在长路径测试中触发 Counterexample Found 时,测试引擎会返回一段冗长的 EVM 操作码跟踪日志。如果只看最终 revert 的位置,往往会误判根因。

需要构建一条完整的定位证据链:

  • 状态前置条件捕获:提取触发失败之前的全局存储状态(Storage Slot)。
  • 调用树递归还原(Call Tree Tracking):还原多合约之间的 Call/DelegateCall 序列。
  • 存储状态差异对比(Storage Delta Engine):定位哪一次指令写入违反了不变性断言(Invariant Constraint)。

  • 2. 工程代码:漏洞场景与安全审计测试

    下面展示一个存在隐蔽“跨函数重入”风险的质押池合约,以及用于捕获该问题的 Foundry 审计测试代码。

    2.1 缺陷合约与修复实现 (StakingVault.sol)

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

    import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
    import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
    import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

    /**
    * @title 质押池合约 – 展示重入防护与状态校验
    */
    contract StakingVault is ReentrancyGuard {
    using SafeERC20 for IERC20;

    IERC20 public immutable stakingToken;

    mapping(address => uint256) private _balances;
    uint256 private _totalStaked;

    event Deposited(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);
    event EmergencyLiquidated(address indexed user, uint256 amount);

    constructor(address _token) {
    require(_token != address(0), "INVALID_TOKEN");
    stakingToken = IERC20(_token);
    }

    function balanceOf(address account) external view returns (uint256) {
    return _balances[account];
    }

    function totalStaked() external view returns (uint256) {
    return _totalStaked;
    }

    /**
    * 存款操作:遵循 CEI 模式
    */
    function deposit(uint256 amount) external nonReentrant {
    require(amount > 0, "ZERO_AMOUNT");

    // 1. Checks & Effects
    _balances[msg.sender] += amount;
    _totalStaked += amount;

    // 2. Interactions
    stakingToken.safeTransferFrom(msg.sender, address(this), amount);

    emit Deposited(msg.sender, amount);
    }

    /**
    * 取款操作:存在潜在漏洞的原始写法逻辑 (已修复版本)
    */
    function withdraw(uint256 amount) external nonReentrant {
    require(amount > 0, "ZERO_AMOUNT");
    require(_balances[msg.sender] >= amount, "INSUFFICIENT_BALANCE");

    // 严格遵循 CEI 模式:先扣减状态,再对外转账
    _balances[msg.sender] -= amount;
    _totalStaked -= amount;

    stakingToken.safeTransfer(msg.sender, amount);

    emit Withdrawn(msg.sender, amount);
    }

    /**
    * 紧急清算接口 – 此接口若未加 nonReentrant 且未更新 _totalStaked,会导致全局不变性崩溃
    */
    function emergencyLiquidate() external nonReentrant {
    uint256 userBalance = _balances[msg.sender];
    require(userBalance > 0, "NO_BALANCE");

    // 先清理状态
    _balances[msg.sender] = 0;
    _totalStaked -= userBalance;

    // 转移代币
    stakingToken.safeTransfer(msg.sender, userBalance);

    emit EmergencyLiquidated(msg.sender, userBalance);
    }
    }

    2.2 捕获隐蔽问题的 Foundry Invariant 测试套件 (AuditStakingVault.t.sol)

    普通的单元测试很难发现多个函数交替调用时的全局不变量损坏。以下是用 Foundry 编写的不变性模糊测试套件。

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

    import "forge-std/Test.sol";
    import "../src/StakingVault.sol";
    import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

    class MockERC20 is ERC20 {
    constructor() ERC20("Mock Token", "MTK") {
    _mint(msg.sender, 10_000_000 * 10**18);
    }
    }

    contract VaultHandler is Test {
    StakingVault public vault;
    MockERC20 public token;

    uint256 public ghostTotalDeposits;

    constructor(StakingVault _vault, MockERC20 _token) {
    vault = _vault;
    token = _token;
    }

    function deposit(uint256 amount) public {
    amount = bound(amount, 1, 100_000 * 10**18);
    token.mint(address(this), amount);
    token.approve(address(vault), amount);

    vault.deposit(amount);
    ghostTotalDeposits += amount;
    }

    function withdraw(uint256 amount) public {
    uint256 userBal = vault.balanceOf(address(this));
    if (userBal == 0) return;

    amount = bound(amount, 1, userBal);
    vault.withdraw(amount);
    ghostTotalDeposits -= amount;
    }
    }

    contract AuditStakingVaultTest is Test {
    StakingVault public vault;
    MockERC20 public token;
    VaultHandler public handler;

    function setUp() public {
    token = new MockERC20();
    vault = new StakingVault(address(token));
    handler = new VaultHandler(vault, token);

    // 限制 Foundry 仅调用 Handler 的特定函数
    targetContract(address(handler));
    }

    /**
    * 核心审计不变性断言:Vault 的代币余额必须始终等于内部 totalStaked
    */
    function invariant_SolvencyAndStateConsistency() public view {
    uint256 actualTokenBalance = token.balanceOf(address(vault));
    uint256 reportedTotalStaked = vault.totalStaked();

    assertEq(
    actualTokenBalance,
    reportedTotalStaked,
    "CRITICAL: Token balance does not match internal totalStaked!"
    );
    }
    }


    3. 从实验失败到防御演练的规则提炼

    安全审计需要基于可复现的证据定位漏洞。经历模糊测试失败后,团队可将有效的检查方式整理为以下防护规则:

    • 拒绝依靠经验主义做 Code Review:跨函数的状态依赖关系在代码量超过 2000 行后人类视觉容易遗漏。必须依赖 Invariant Testing 强制定义系统“不可破坏的不变量”。
    • 严格审计 ERC20 钩子函数(ERC-777 / ERC-1155):许多代币在 transfer 时会自动回调接收方的 tokensReceived 接口。如果合约未配置 nonReentrant 或未严格执行 Checks-Effects-Interactions,即便核心逻辑看起来再完整,攻击者依然可以插队执行。
    • 保留 VM Trace 证据:不要在采集前重置节点。cast run <tx_hash> –debug 可逐指令查看 EVM 状态,还应结合合约源码、编译产物、交易回执和节点版本交叉验证。

    一次失败的实验,只要能提炼出完整的虚拟机证据链,其价值远大于十次顺利通过的 Demo 部署。

    赞(0)
    未经允许不得转载:171主机测评 » Solidity 模糊测试失败后:从最小反例定位合约边界
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址