深入分析:Truebit 事件

深入分析:Truebit 事件

2026年1月8日,以太坊上的Truebit协议遭到攻击,导致约2600万美元的损失 [1]。根本原因是TRU购买定价逻辑中的整数溢出。由于合约使用Solidity v0.6.10编译,该版本默认不强制执行溢出检查,导致购买成本计算中的一个中间大值环绕成一个非常小的数字。因此,攻击者能够以极低的甚至零ETH购买大量的TRU,然后立即以有利的汇率将获得的TRU卖回给合约换取ETH,从而耗尽协议储备。

0x0 背景

Truebit通过链下计算和交互式验证为以太坊提供计算服务 [2]。该协议使用原生代币TRU,并提供两个公开的交易函数:

  • buyTRU():执行TRU购买。所需的ETH成本由内部定价函数计算,该函数也用于getPurchasePrice(),因此getPurchasePrice()反映了购买执行期间适用的确切链上定价逻辑。

  • sellTRU():执行TRU销售(赎回)。预期的ETH支付可以通过getRetirePrice()查询。

关键的设计方面是定价不对称:

  • 购买使用凸形定价曲线(边际价格随供应量增加而增加)。
  • 销售使用线性赎回规则(与储备金成比例)。

由于合约源代码不公开,以下分析基于反编译的字节码。

购买逻辑

buyTRU()函数(以及getPurchasePrice()函数)将定价委托给内部函数_getPurchasePrice(),该函数计算购买amount TRU所需的ETH。

function buyTRU(uint256 amount) public payable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // 获取购买价格
    require(msg.value == v0, Error('ETH payment does not match TRU order'));
    v1 = 0x18ef(100 - _setParameters, msg.value);
    v2 = _SafeDiv(100, v1);
    v3 = _SafeAdd(v2, _reserve);
    _reserve = v3;
    require(bool(stor_97_0_19.code.size));
    v4 = stor_97_0_19.mint(msg.sender, amount).gas(msg.gas);
    require(bool(v4), 0, RETURNDATASIZE()); // 检查调用状态,错误时传播错误数据
    return msg.value;
}

function getPurchasePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // 获取购买价格
    return v0;
}

function _getPurchasePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // 检查调用状态,错误时传播错误数据
    require(RETURNDATASIZE() >= 32);
    v2 = 0x18ef(v1, v1)
    v3 = 0x18ef(_setParameters, v2);
    v4 = 0x18ef(v1, v1);
    v5 = 0x18ef(100, v4);
    v6 = _SafeSub(v3, v5);// 分母 = 100 * totalSupply**2 - _setParameters * totalSupply**2 
    v7 = 0x18ef(amount, _reserve);
    v8 = 0x18ef(v1, v7);
    v9 = 0x18ef(200, v8);// 分子_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// 分子_1 = 100 * amount**2 * _reserve
    v13 = _SafeDiv(v6, v12 + v9); // purchasePrice = (numerator_1 + numerator_2) / denominator
    return v13;
}

从反编译的逻辑来看,购买价格可以表示为关于以下内容的定价曲线风格函数:

其中,

  • amount:要购买的TRU数量
  • reserve_reserve):合约的以太坊储备金
  • totalSupply:TRU的总供应量
  • θ_setParameters):一个系数,固定为75

这条曲线旨在使大额购买成本越来越高(成本增长呈凸形),以阻止投机并减少即时买入方面的操纵。

销售逻辑

sellTRU()函数(以及getRetirePrice()函数)使用内部函数_getRetirePrice()来计算赎回TRU时支付的ETH。

function sellTRU(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.allowance(msg.sender, address(this)).gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // 检查调用状态,错误时传播错误数据
    require(RETURNDATASIZE() >= 32);
    require(v1 >= amount, Error('Insufficient TRU allowance'));
    v2 = _getRetirePrice(amount); // 获取赎回价格
    v3 = _SafeSub(v2, _reserve);
    _reserve = v3;
    require(bool(stor_97_0_19.code.size));
    v4, /* uint256 */ v5 = stor_97_0_19.transferFrom(msg.sender, address(this), amount).gas(msg.gas);
    require(bool(v4), 0, RETURNDATASIZE()); // 检查调用状态,错误时传播错误数据
    require(RETURNDATASIZE() >= 32);
    require(bool(stor_97_0_19.code.size));
    v6 = stor_97_0_19.burn(amount).gas(msg.gas);
    require(bool(v6), 0, RETURNDATASIZE()); // 检查调用状态,错误时传播错误数据
    v7 = msg.sender.call().value(v2).gas(!v2 * 2300);
    require(bool(v7), 0, RETURNDATASIZE()); // 检查调用状态,错误时传播错误数据
    return v2;
}

function getRetirePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getRetirePrice(amount); // 获取赎回价格
    return v0;
}

function _getRetirePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // 检查调用状态,错误时传播错误数据
    require(RETURNDATASIZE() >= 32);
    v1 = v2.length;
    v3 = v2.data;
    v4 = 0x18ef(_reserve, amount);// 分子 = _reserve * amount
    if (v1 > 0) {
        assert(v1);
        return v4 / v1;// retirePrice = numerator / totalSupply
    } else {
    // ...
}

赎回规则是线性的:

赎回价格与被赎回的总供应量分数(即amount / totalSupply)乘以reserve成正比。

这种故意的不对称性造成了巨大的价差:购买是凸形的(大规模时昂贵),而销售是线性的(只赎回储备金的比例部分)。在正常情况下,这种价差使得即时买入→卖出套利不具吸引力。

0x1 漏洞分析

尽管有“大额购买昂贵”的设计意图,但_getPurchasePrice()在其算术运算中包含了一个整数溢出。由于合约使用Solidity 0.6.10编译,uint256上的算术运算会静默溢出并以2^256取模环绕,除非有明确的保护(例如通过SafeMath)。

function _getPurchasePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // 检查调用状态,错误时传播错误数据
    require(RETURNDATASIZE() >= 32);
    v2 = 0x18ef(v1, v1)
    v3 = 0x18ef(_setParameters, v2);
    v4 = 0x18ef(v1, v1);
    v5 = 0x18ef(100, v4);
    v6 = _SafeSub(v3, v5);// 分母 = 100 * totalSupply**2 - _setParameters * totalSupply**2 
    v7 = 0x18ef(amount, _reserve);
    v8 = 0x18ef(v1, v7);
    v9 = 0x18ef(200, v8);// 分子_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// 分子_1 = 100 * amount**2 * _reserve
    v13 = _SafeDiv(v6, v12 + v9); // purchasePrice = (numerator_1 + numerator_2) / denominator
    return v13;
}

_getPurchasePrice()中,足够大的amount会在两个大分子项(反编译片段中的v12 + v9)的加法过程中触发溢出。当发生此溢出时,分子环绕成一个很小的值,导致最终除法返回一个人为偏低的购买价格,可能为零。

至关重要的是,溢出仅影响买入方定价。卖出方函数保持线性并按预期运行,因此攻击者可以:

  • 以低于成本价(或零成本)购买大量TRU,然后
  • 通过sellTRU()以高得多的有效汇率将其赎回为ETH。

0x2 攻击分析

攻击者在一次交易中执行了多轮套利 [3],重复了以下过程:

                               `getPurchasePrice()` -> `buyTRU()` -> `sellTRU()`

第一轮:零成本购买,然后获利卖出

通过提供一个精心选择的购买金额(240,442,509.453,545,333,947,284,131),攻击者触发了_getPurchasePrice()中的溢出,将计算出的购买价格降低到0 ETH,并允许以零成本获得约2.4亿TRU。

下面的Python代码检查说明了分子超过2^256,并且在环绕后,计算出的购买价格变成了一个非常小的分数,转换为整数时截断为零。

>>> _reserve = 0x1ceec1aef842e54d9ee
>>> totalSupply = 161753242367424992669183203
>>> amount = 240442509453545333947284131
>>> numerator = int(100 * amount * _reserve * (amount + 2 * totalSupply))
>>> numerator > 2**256
True
>>> denominator = (100 - 75) * totalSupply**2
>>> purchasePrice = (numerator - 2**256) / denominator
>>> purchasePrice
0.00025775798757211426
>>> int(purchasePrice)
0

然后,攻击者立即调用sellTRU(),从协议储备金中赎回TRU,获得5,105 ETH。

后续轮次:低成本购买,然后获利卖出

攻击者重复了这个循环多次。后续的购买并不总是严格零成本,但溢出继续使购买价格远低于相应的卖出回报。

在这些轮次中,攻击者提取了大量的ETH,我们的调查表明,在第一轮之后可能仍然存在额外的零成本购买,尽管攻击者选择进行一些非零成本轮次的原因尚不清楚。

总而言之,攻击者从Truebit的储备金中耗尽了8,535 ETH。

0x3 总结

此事件最终是由Truebit买入方定价逻辑中一个未经验证的整数溢出引起的。尽管该协议的买入/卖出定价模式不对称,旨在抵御投机,但使用没有系统性溢出保护的旧版Solidity(0.8版之前)进行编译,破坏了设计并导致了储备金耗尽。

对于任何仍在使用0.8版以下Solidity的生产合约,开发者应:

  • 对每个相关操作应用防溢出算术(例如,SafeMath或等效检查),或
  • 最好迁移到Solidity 0.8+以利用默认的溢出检查。

参考

[1] https://x.com/Truebitprotocol/status/2009328032813850839

[2] https://docs.truebit.io/v1docs

[3] 攻击交易

Sign up for the latest updates