真相:Truebit事件の詳細分析

真相:Truebit事件の詳細分析

2026年1月8日、Ethereum上のTruebit Protocolがエクスプロイトされ、約2600万ドルの損失が発生しました [1]。根本原因は、TRU購入価格設定ロジックにおける整数オーバーフローでした。コントラクトがデフォルトでオーバーフローチェックを強制しないSolidity v0.6.10でコンパイルされていたため、購入コスト計算における大きな中間値がラップアラウンドして非常に小さな数値になりました。その結果、攻撃者は非常に大量のTRUをわずか、あるいはゼロに近いETHで購入し、直ちに獲得したTRUを有利なレートでコントラクトに売り戻すことで、プロトコルの準備金を枯渇させることができました。

0x0 背景

Truebitは、オフチェーン計算とインタラクティブ検証を介してEthereumに計算サービスを提供します [2]。このプロトコルはネイティブトークンであるTRUを使用し、2つの公開取引関数を公開しています。

  • buyTRU()はTRUの購入を実行します。必要なETHコストは、getPurchasePrice()でも使用される内部価格設定関数によって計算されるため、getPurchasePrice()は購入実行中に適用される正確なオンチェーン価格設定ロジックを反映します。

  • sellTRU()はTRUの売却(償還)を実行します。期待されるETHの支払いはgetRetirePrice()で照会できます。

主要な設計上の側面は、価格設定の非対称性です。

  • 購入は、供給が増加するにつれて限界価格が上昇する凸状のボンディングカーブを使用します。
  • 売却は、準備金に比例する線形償還ルールを使用します。

コントラクトのソースコードは公開されていないため、以下の分析は逆コンパイルされたバイトコードに基づいています。

購入ロジック

buyTRU()関数(およびgetPurchasePrice()関数)は、amount TRUの購入に必要なETHを計算する内部関数_getPurchasePrice()に価格設定を委任します。

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): コントラクトのEther準備金
  • totalSupply: TRUの総供給量
  • θ (_setParameters): 係数、75に固定

このカーブは、大量購入をますます高価にすること(凸状のコスト成長)を目的としており、投機を抑制し、即時の買い圧操作を軽減します。

売却ロジック

sellTRU()関数(およびgetRetirePrice()関数)は、TRUを償還する際のETHの支払いを計算するために内部関数_getRetirePrice()を使用します。

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の算術演算は、明示的に保護されていない限り(例:SafeMath経由)、サイレントにオーバーフローして2^256 moduloでラップする可能性があります。

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は、2つの大きな分子項(逆コンパイルされたスニペットのv12 + v9)の加算中にオーバーフローを引き起こします。このオーバーフローが発生すると、分子は小さな値にラップされ、最終的な除算が人工的に低い購入価格、場合によってはゼロを返します。

重要なのは、オーバーフローは購入側の価格設定のみに影響することです。売却側の関数は線形であり、意図したとおりに動作するため、攻撃者は次のことができます。

  • 過小評価された(またはゼロの)コストで大量のTRUを購入し、
  • sellTRU()を介して、はるかに高い実効レートでそれを償還します。

0x2 攻撃分析

攻撃者は、1回のトランザクション内で複数回のアービトラージラウンドを実行しました [3]。 繰り返し:

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

最初のラウンド:ゼロコスト購入、その後利益を上げて売却

慎重に選択された購入量(240,442,509.453,545,333,947,284,131)を供給することにより、攻撃者は_getPurchasePrice()でオーバーフローを引き起こし、計算された購入価格を0 ETHに削減し、コストなしで約2億4000万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()を呼び出し、プロトコルの準備金から5,105 ETHでTRUを償還しました。

後続のラウンド:低コスト購入、その後利益を上げて売却

攻撃者はこのサイクルを複数回繰り返しました。後続の購入は常に厳密にゼロコストではありませんでしたが、オーバーフローにより購入価格は対応する売却利益をはるかに下回ったままでした。

これらのラウンドを通じて、攻撃者はTruebitの準備金から substantialなETHを引き出しました。さらに、最初のラウンドの後でも追加のゼロコスト購入が可能だった可能性が調査により示唆されていますが、攻撃者が一部非ゼロコストのラウンドを選択した理由は不明です。

全体として、攻撃者はTruebitの準備金から8,535 ETHを枯渇させました。

0x3 要約

このインシデントは、Truebitの購入側価格設定ロジックにおけるチェックされていない整数オーバーフローによって最終的に引き起こされました。プロトコルの非対称な購入/売却価格設定モデルは投機に抵抗することを意図していましたが、体系的なオーバーフロー保護のない古いSolidityバージョン(0.8未満)でのコンパイルは、設計を弱体化させ、準備金の枯渇を可能にしました。

Solidityバージョン0.8未満を使用している本番環境のコントラクトについては、開発者は次のことを行う必要があります。

  • すべての関連する操作にオーバーフローセーフな算術演算(例:SafeMathまたは同等のチェック)を適用するか、
  • デフォルトのオーバーフローチェックの恩恵を受けるために、Solidity 0.8+に移行することを優先してください。

参照

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

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

[3] 攻撃トランザクション

Sign up for the latest updates