Back to Blog

詳細分析:Truebit事件

Code Auditing
January 14, 2026
11 min read

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

0x0 背景

Truebitは、オフチェーン計算とインタラクティブな検証を通じてEthereumに計算サービスを提供します[2]。プロトコル内では、TRUトークンがステーキングやタスク関連の支払いなど、インセンティブの調整のための主要な経済手段として機能します。

プロトコルは、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);// numerator_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// numerator_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()関数)は、プライベート関数_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);// numerator = _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);// numerator_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// numerator_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()を通じて、はるかに高い実効レートでそれをETHに償還します。

0x2 攻撃分析

攻撃者は単一のトランザクション内で複数のアービトラージラウンドを実行し[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()を呼び出し、TRUをプロトコルの準備金から5,105 ETHで償還しました。

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

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

これらのラウンドを通じて、攻撃者は多額の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] 攻撃トランザクション

BlockSecについて

BlockSecは、フルスタックのブロックチェーンセキュリティおよび暗号コンプライアンスプロバイダーです。私たちは、コード監査(スマートコントラクト、ブロックチェーン、ウォレットを含む)、リアルタイムでの攻撃傍受、インシデント分析、不正資金の追跡、およびAML/CFT義務の履行を、プロトコルおよびプラットフォームのライフサイクル全体にわたって支援する製品とサービスを構築しています。

BlockSecは、著名なカンファレンスで複数のブロックチェーンセキュリティ論文を発表し、DeFiアプリケーションの複数のゼロデイ攻撃を報告し、多数のハッキングを阻止して2000万ドル以上を救済し、数十億ドルの暗号通貨を保護してきました。

Sign up for the latest updates
~$15.9M Lost: Trusted Volumes & More | BlockSec Weekly
Security Insights

~$15.9M Lost: Trusted Volumes & More | BlockSec Weekly

This BlockSec bi-weekly security report covers 11 notable attack incidents identified between April 27 and May 10, 2026, across Sui, Ethereum, BNB Chain, Base, Blast, and Berachain, with total estimated losses of approximately $15.9M. Three incidents are analyzed in detail: the highlighted $1.14M Aftermath Finance exploit on Sui, where a signed/unsigned semantic mismatch in the builder-fee validation allowed an attacker to inject a negative fee that was converted into positive collateral during settlement; the $5.87M Trusted Volumes RFQ authorization mismatch on Ethereum; and the $5.7M Wasabi Protocol infrastructure-to-contract-control compromise across multiple EVM chains.

Newsletter - April 2026
Security Insights

Newsletter - April 2026

In April 2026, the DeFi ecosystem experienced three major security incidents. KelpDAO lost ~$290M due to an insecure 1-of-1 DVN bridge configuration exploited via RPC infrastructure compromise, Drift Protocol suffered ~$285M from a multisig governance takeover leveraging Solana's durable nonce mechanism, and Rhea Finance incurred ~$18.4M following a business logic flaw in its margin-trading module that allowed circular swap path manipulatio

~$7.04M Lost: GiddyDefi, Volo Vault & More | BlockSec Weekly
Security Insights

~$7.04M Lost: GiddyDefi, Volo Vault & More | BlockSec Weekly

This BlockSec weekly security report covers eight attack incidents detected between April 20 and April 26, 2026, across Ethereum, Avalanche, Sui, Base, HyperLiquid, and MegaETH, with total estimated losses of approximately $7.04M. The highlighted incident is the $1.3M GiddyDefi exploit, where the attacker did not break any cryptography or use a flash loan but simply replayed an existing on-chain EIP-712 signature with the unsigned `aggregator` and `fromToken` fields swapped out for a malicious contract, demonstrating how partial signature coverage turns any historical signature into a generic permit. Other incidents include a $3.5M Volo Vault operator key compromise on Sui, a $1.5M Purrlend privileged-role takeover, a $413K SingularityFinance oracle misconfiguration, a $142.7K Scallop cross-pool index injection, a $72.35K Kipseli Router decimal mismatch, a $50.7K REVLoans (Juicebox) accounting pollution, and a $64K Custom Rebalancer arbitrary-call exploit.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit