Back to Blog

Tiefgehende Analyse: Der Truebit-Vorfall

Code Auditing
January 14, 2026

Am 8. Januar 2026 wurde das Truebit-Protokoll auf Ethereum ausgenutzt, was zu Verlusten von rund 26 Millionen US-Dollar führte [1]. Die Hauptursache war ein Integer-Überlauf in der Kaufpreislogik für TRU. Da der Vertrag mit Solidity v0.6.10 kompiliert wurde, das standardmäßig keine Überlaufprüfungen erzwingt, wurde ein großer Zwischenwert bei der Berechnung der Kaufkosten zu einer viel kleineren Zahl „gewrappt“. Infolgedessen konnte ein Angreifer eine sehr große Menge TRU für wenig oder sogar kein ETH kaufen und die erworbenen TRU sofort wieder an den Vertrag verkaufen, um ETH zu einem günstigen Kurs zu erhalten und die Reserven des Protokolls zu leeren.

0x0 Hintergrund

Truebit bietet über Off-Chain-Berechnungen und interaktive Verifizierung Berechnungsdienste für Ethereum [2]. Das Protokoll verwendet einen nativen Token, TRU, und stellt zwei öffentliche Handelsfunktionen bereit:

  • buyTRU() führt TRU-Käufe aus. Die erforderlichen ETH-Kosten werden von einer internen Preisbildungsfunktion berechnet, die auch von getPurchasePrice() verwendet wird. Daher spiegelt getPurchasePrice() die exakte On-Chain-Preisbildungslogik wider, die während der Kaufabwicklung angewendet wird.

  • sellTRU() führt TRU-Verkäufe (Einlösungen) aus. Der erwartete ETH-Auszahlungsbetrag kann über getRetirePrice() abgefragt werden.

Ein zentraler Designaspekt ist die Preis-Asymmetrie:

  • Käufe verwenden eine konvexe Bonding-Kurve (der Grenzkurs steigt mit zunehmendem Angebot).
  • Verkäufe verwenden eine lineare Einlösungsregel (proportional zu den Reserven).
  • Da der Quellcode des Vertrags nicht öffentlich ist, basiert die folgende Analyse auf dekompiliertem Bytecode.

Kauflogik

Die Funktion buyTRU() (und die Funktion getPurchasePrice()) delegiert die Preisbildung an eine interne Funktion _getPurchasePrice(), die die für den Kauf von amount TRU erforderlichen ETH berechnet.

function buyTRU(uint256 amount) public payable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // Kaufpreis abrufen
    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()); // Prüft den Aufrufstatus, gibt bei Fehler Fehlerdaten zurück
    return msg.value;
}

function getPurchasePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // Kaufpreis abrufen
    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()); // Prüft den Aufrufstatus, gibt bei Fehler Fehlerdaten zurück
    require(RETURNDATASIZE() >= 32);
    v2 = 0x18ef(v1, v1)
    v3 = 0x18ef(_setParameters, v2);
    v4 = 0x18ef(v1, v1);
    v5 = 0x18ef(100, v4);
    v6 = _SafeSub(v3, v5);// Nenner = 100 * totalSupply**2 - _setParameters * totalSupply**2 
    v7 = 0x18ef(amount, _reserve);
    v8 = 0x18ef(v1, v7);
    v9 = 0x18ef(200, v8);// Zähler_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// Zähler_1 = 100 * amount**2 * _reserve
    v13 = _SafeDiv(v6, v12 + v9); // Kaufpreis = (Zähler_1 + Zähler_2) / Nenner
    return v13;
}

Aus der dekompilierten Logik lässt sich der Kaufpreis als eine Funktion im Stil einer Bonding-Kurve ausdrücken:

Wo,

  • amount: zu kaufende TRU
  • reserve (_reserve): die ETH-Reserven des Vertrags
  • totalSupply: das Gesamtangebot an TRU
  • θ (_setParameters): ein Koeffizient, fest auf 75

Diese Kurve soll große Käufe immer teurer machen (konvexes Kostenwachstum), Spekulationen verhindern und sofortige Kaufmanipulationen reduzieren.

Verkaufslogik

Die Funktion sellTRU() (und die Funktion getRetirePrice()) verwendet die interne Funktion _getRetirePrice(), um die ETH zu berechnen, die bei der Einlösung von TRU ausgezahlt wird.

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()); // Prüft den Aufrufstatus, gibt bei Fehler Fehlerdaten zurück
    require(RETURNDATASIZE() >= 32);
    require(v1 >= amount, Error('Insufficient TRU allowance'));
    v2 = _getRetirePrice(amount); // Einlösepreis abrufen
    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()); // Prüft den Aufrufstatus, gibt bei Fehler Fehlerdaten zurück
    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()); // Prüft den Aufrufstatus, gibt bei Fehler Fehlerdaten zurück
    v7 = msg.sender.call().value(v2).gas(!v2 * 2300);
    require(bool(v7), 0, RETURNDATASIZE()); // Prüft den Aufrufstatus, gibt bei Fehler Fehlerdaten zurück
    return v2;
}

function getRetirePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getRetirePrice(amount); // Einlösepreis abrufen
    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()); // Prüft den Aufrufstatus, gibt bei Fehler Fehlerdaten zurück
    require(RETURNDATASIZE() >= 32);
    v1 = v2.length;
    v3 = v2.data;
    v4 = 0x18ef(_reserve, amount);// Zähler = _reserve * amount
    if (v1 > 0) {
        assert(v1);
        return v4 / v1;// Einlösepreis = Zähler / totalSupply
    } else {
    // ...
}

Die Einlösungsregel ist linear:

Der Einlösepreis ist proportional zum Bruchteil des Gesamtangebots, der eingelöst wird (d. h. amount / totalSupply), multipliziert mit dem reserve.

Diese bewusste Asymmetrie erzeugt eine große Spanne: Kaufen ist konvex (teuer in großem Maßstab), während Verkaufen linear ist (löst nur einen proportionalen Anteil der Reserven ein). Unter normalen Bedingungen macht diese Spanne sofortige Kauf-Verkaufs-Arbitrage unattraktiv.

0x1 Schwachstellenanalyse

Trotz des beabsichtigten Designs, dass große Käufe teuer sind, enthält _getPurchasePrice() einen Integer-Überlauf in seiner Arithmetik. Da der Vertrag mit Solidity 0.6.10 kompiliert wurde, können arithmetische Operationen auf uint256 stillschweigend überlaufen und modulo 2^256 „wrappen“, es sei denn, sie sind explizit geschützt (z. B. durch 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()); // Prüft den Aufrufstatus, gibt bei Fehler Fehlerdaten zurück
    require(RETURNDATASIZE() >= 32);
    v2 = 0x18ef(v1, v1)
    v3 = 0x18ef(_setParameters, v2);
    v4 = 0x18ef(v1, v1);
    v5 = 0x18ef(100, v4);
    v6 = _SafeSub(v3, v5);// Nenner = 100 * totalSupply**2 - _setParameters * totalSupply**2 
    v7 = 0x18ef(amount, _reserve);
    v8 = 0x18ef(v1, v7);
    v9 = 0x18ef(200, v8);// Zähler_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// Zähler_1 = 100 * amount**2 * _reserve
    v13 = _SafeDiv(v6, v12 + v9); // Kaufpreis = (Zähler_1 + Zähler_2) / Nenner
    return v13;
}

In _getPurchasePrice() löst ein ausreichend großer amount einen Überlauf bei der Addition von zwei großen Zählertermen (v12 + v9 im dekompilierten Snippet) aus. Wenn dieser Überlauf auftritt, wrappt der Zähler zu einem kleinen Wert, was dazu führt, dass die abschließende Division einen künstlich niedrigen Kaufpreis ergibt, möglicherweise null.

Entscheidend ist, dass der Überlauf nur die Kaufpreisbildung betrifft. Die Verkaufsfunktion bleibt linear und verhält sich wie vorgesehen, sodass ein Angreifer:

  • eine große Menge TRU zu einem unterbewerteten (oder Null-)Preis kaufen, dann
  • diese über sellTRU() für ETH zu einer viel höheren effektiven Rate einlösen kann.

0x2 Angriffsanalyse

Der Angreifer führte mehrere Arbitrage-Runden innerhalb einer einzigen Transaktion durch [3], wobei er wiederholte:

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

Erste Runde: Null-Kosten-Kauf, dann Verkauf mit Gewinn

Durch die Angabe eines sorgfältig gewählten Kaufbetrags (240.442.509.453.545.333.947.284.131) löste der Angreifer einen Überlauf in _getPurchasePrice() aus, wodurch der berechnete Kaufpreis auf 0 ETH reduziert wurde und die Anschaffung von ca. 240 Millionen TRU kostenlos ermöglicht wurde.

Der folgende Python-Code-Check zeigt, dass der Zähler 2^256 überschreitet und nach dem Wrapping der berechnete Kaufpreis ein winziger Bruchteil wird, der beim Umwandeln in eine Ganzzahl auf null abgeschnitten wird.

>>> _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

Der Angreifer rief dann sofort sellTRU() auf und löste die TRU gegen 5.105 ETH aus den Reserven des Protokolls ein.

Nachfolgende Runden: Günstige Käufe, dann Verkauf mit Gewinn

Der Angreifer wiederholte den Zyklus mehrmals. Spätere Käufe waren nicht immer streng kostenfrei, aber der Überlauf sorgte weiterhin dafür, dass die Kaufpreise weit unter den entsprechenden Verkaufserlösen lagen.

In diesen Runden zog der Angreifer erhebliche Mengen ETH ab. Unsere Untersuchung legt nahe, dass auch nach der ersten Runde möglicherweise zusätzliche kostenlose Käufe möglich waren, obwohl der Grund, warum der Angreifer einige Runden mit Nicht-Null-Kosten wählte, unklar ist.

Insgesamt entnahm der Angreifer 8.535 ETH aus den Reserven von Truebit.

0x3 Zusammenfassung

Dieser Vorfall wurde letztendlich durch einen ungeprüften Integer-Überlauf in der Kaufpreislogik von Truebit verursacht. Obwohl das asymmetrische Kauf-/Verkaufspreismodell des Protokolls darauf ausgelegt war, Spekulationen zu widerstehen, untergrub die Kompilierung mit einer älteren Solidity-Version (vor 0.8) ohne systematische Überlaufschutz das Design und ermöglichte die Entnahme von Reserven.

Für Produktionsverträge, die weiterhin Solidity-Versionen unter 0.8 verwenden, sollten Entwickler:

  • Überlauf-sichere Arithmetik (z. B. SafeMath oder äquivalente Prüfungen) auf jede relevante Operation anwenden, oder
  • Vorzugsweise auf Solidity 0.8+ migrieren, um von standardmäßigen Überlaufprüfungen zu profitieren.

Referenz

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

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

[3] Angriffstransaktion

Sign up for the latest updates
Drift Protocol Incident: Multisig Governance Compromise via Durable Nonce Exploitation
Security Insights

Drift Protocol Incident: Multisig Governance Compromise via Durable Nonce Exploitation

On April 1, 2026 (UTC), Drift Protocol on Solana suffered a $285.3M loss after an attacker exploited Solana's durable nonce mechanism to delay the execution of phished multisig approvals, ultimately transferring administrative control of the protocol's 2-of-5 Squads governance with zero timelock. With full admin privileges, the attacker created a malicious collateral market (CVT), inflated its oracle price, relaxed withdrawal protections, and drained USDC, JLP, SOL, cbBTC, and other assets through 31 rapid withdrawals in approximately 12 minutes. This incident highlights how durable nonce-based delayed execution can decouple signer intent from on-chain execution, bypassing the temporal assumptions that multisig security implicitly relies on.

Weekly Web3 Security Incident Roundup | Mar 23 – Mar 29, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Mar 23 – Mar 29, 2026

This BlockSec weekly security report covers eight DeFi attack incidents detected between March 23 and March 29, 2026, across Ethereum and BNB Chain, with total estimated losses of approximately $1.53M. Incidents include a $679K flawed burn mechanism exploit on the BCE token, a $512K spot-price manipulation attack on Cyrus Finance's PancakeSwap V3 liquidity withdrawal, a $133.5K flash-loan-driven referral reward manipulation on a TUR staking contract, and multiple integer overflow, reentrancy, and accounting error vulnerabilities in DeFi protocols. The report provides detailed vulnerability analysis and attack transaction breakdowns for each incident.

Newsletter -  March 2026
Security Insights

Newsletter - March 2026

In March 2026, the DeFi ecosystem experienced three major security incidents. Resolv Protocol lost ~$80M due to compromised privileged infrastructure keys, BitcoinReserveOffering suffered ~$2.7M from a double-minting logic flaw, and Venus Protocol incurred ~$2.15M following a donation attack combined with market manipulation.

Best Security Auditor for Web3

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

BlockSec Audit