Medusa - 智能合约 Fuzzing 工具在 Truebit Protocol 案例中如何应用?

摘要:案例背景 20260109,ETH 链上的 Truebit Protocol 遭受了黑客攻击,损失约 2600 万美元。漏洞原因是计算购买 TRU 代币所需要的 ETH 数量的计算公式设计存在缺陷,购买大量 TRU 代币时会因为 0.6.1
案例背景 20260109,ETH 链上的 Truebit Protocol 遭受了黑客攻击,损失约 2600 万美元。漏洞原因是计算购买 TRU 代币所需要的 ETH 数量的计算公式设计存在缺陷,购买大量 TRU 代币时会因为 0.6.10 版本没有防溢出机制而发生上溢出得到 0 值,使得攻击者可以以 0 ETH 购买大量的 TRU 代币,最后抛售完成获利。 前置内容-完整攻击分析:https://www.cnblogs.com/ACaiGarden/p/19465686 TX:https://app.blocksec.com/explorer/tx/eth/0xcd4755645595094a8ab984d0db7e3b4aabde72a5c87c4f176a030629c47fb014 Trace 分析 黑客调用 buyTRU() 函数以零成本购入大量的 TRU 代币 然后调用 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 就是一个价格计算函数。 所以在实现 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 修饰器,还有对授权额度的检查。 所以在 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 为攻击者所采用的参数,可以马上得到结果。 显示 fuzz 出来满足条件的 sequence 路径如下,和攻击者执行的操作一致。 通过本案例是实践,得到的结论是,Fuzz 工程除了需要开发者非常了解目标协议,尽可能地编写出高效的测试函数,还需要高性能的机器来提供支撑。相信通过这两个入门级的案例,也能够让读者了解到,fuzz 并不是什么“灵丹妙药”。虽然它在实际应用中有着一些局限,但是在经验丰富的开发者和强大的机器支持下,仍然是一个挖掘未知漏洞的可行之法。