Am 8. Januar 2026 wurde das Truebit-Protokoll auf Ethereum ausgenutzt, was zu Verlusten von etwa 26 Millionen US-Dollar führte [1]. Die Hauptursache war ein Integer-Überlauf in der Kaufpreisberechnung des TRU-Tokens. Da der Vertrag mit Solidity v0.6.10 kompiliert wurde, das standardmäßig keine Überlaufprüfungen erzwingt, wickelte ein großer Zwischenwert in der Berechnung der Kaufkosten zu einer viel kleineren Zahl ab. Infolgedessen konnte ein Angreifer eine sehr große Menge TRU für wenig oder sogar null ETH kaufen, die erworbene TRU dann sofort zu einem günstigen Kurs zurück an den Vertrag verkaufen und so die Reserven des Protokolls erschöpfen.
0x0 Hintergrund
Truebit bietet Rechenservices für Ethereum durch Off-Chain-Berechnung und interaktive Verifizierung [2]. Innerhalb des Protokolls dienen TRU-Token als zentrales wirtschaftliches Instrument zur Koordinierung von Anreizen, einschließlich Staking und aufgabenbezogener Zahlungen.
Das Protokoll stellt zwei öffentliche Funktionen zum Kaufen und Einlösen von TRU bereit:
-
buyTRU()führt TRU-Käufe aus. Die erforderlichen ETH-Kosten werden durch eine interne Preisierungsfunktion berechnet, die auch vongetPurchasePrice()verwendet wird, sodassgetPurchasePrice()die genaue On-Chain-Preislogik widerspiegelt, die während der Kaufabwicklung angewendet wird. -
sellTRU()führt TRU-Verkäufe (Einlösungen) aus. Die erwartete ETH-Auszahlung kann übergetRetirePrice()abgefragt werden.
Ein wichtiges Designmerkmal ist die Preisierungsasymmetrie:
- 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 Implementierungsvertrags nicht öffentlich ist, basiert die folgende Analyse auf dekompiliertem Bytecode.
Kauflogik
Die Funktion buyTRU() (und die Funktion getPurchasePrice()) delegiert die Preisbildung an eine private Funktion _getPurchasePrice(), die die für den Kauf von amount TRU benötigten ETH berechnet.
function buyTRU(uint256 amount) public payable {
require(msg.data.length - 4 >= 32);
v0 = _getPurchasePrice(amount); // Kaufpreis ermitteln
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, leitet Fehlerdaten bei Fehler weiter
return msg.value;
}
function getPurchasePrice(uint256 amount) public nonPayable {
require(msg.data.length - 4 >= 32);
v0 = _getPurchasePrice(amount); // Kaufpreis ermitteln
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, leitet Fehlerdaten bei Fehler weiter
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 Funktion im Stil einer Bonding-Kurve ausdrücken:
Wo,
- amount: zu kaufende TRU
- reserve (_reserve): die Ether-Reserven des Vertrags
- totalSupply: die Gesamtzahl der TRU
- θ (_setParameters): ein Koeffizient, fest auf 75
Diese Kurve soll große Käufe immer teurer machen (konvexes Kostenwachstum), Spekulationen abschrecken und unmittelbare Käufermanipulationen reduzieren.
Verkaufslogik
Die Funktion sellTRU() (und die Funktion getRetirePrice()) verwendet die private Funktion _getRetirePrice(), um die ETH zu berechnen, die beim Einlösen 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, leitet Fehlerdaten bei Fehler weiter
require(RETURNDATASIZE() >= 32);
require(v1 >= amount, Error('Insufficient TRU allowance'));
v2 = _getRetirePrice(amount); // Einlösepreis ermitteln
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, leitet Fehlerdaten bei Fehler weiter
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, leitet Fehlerdaten bei Fehler weiter
v7 = msg.sender.call().value(v2).gas(!v2 * 2300);
require(bool(v7), 0, RETURNDATASIZE()); // prüft den Aufrufstatus, leitet Fehlerdaten bei Fehler weiter
return v2;
}
function getRetirePrice(uint256 amount) public nonPayable {
require(msg.data.length - 4 >= 32);
v0 = _getRetirePrice(amount); // Einlösepreis ermitteln
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, leitet Fehlerdaten bei Fehler weiter
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 Anteil der eingelösten Gesamtmenge (d. h. amount / totalSupply) multipliziert mit dem reserve.
Diese bewusste Asymmetrie schafft eine große Spanne: Kaufen ist konvex (teuer in großem Umfang), während Verkaufen linear ist (nur ein proportionaler Anteil der Reserven wird eingelöst). Unter normalen Bedingungen macht diese Spanne sofortiges Kaufen-Verkaufen-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 berechnen, 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, leitet Fehlerdaten bei Fehler weiter
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 während der Addition zweier großer Zählerterme (v12 + v9 im dekompilierten Snippet) aus. Wenn dieser Überlauf auftritt, wickelt der Zähler zu einem kleinen Wert ab, was dazu führt, dass die abschließende Division einen künstlich niedrigen Kaufpreis ergibt, potenziell null.
Entscheidend ist, dass der Überlauf nur die Kaufpreisbildung betrifft. Die Verkaufsfunktion bleibt linear und verhält sich wie beabsichtigt, sodass ein Angreifer:
- eine große Menge TRU zu einem unterbewerteten (oder null) Preis kaufen und dann
- diese über
sellTRU()zu einem viel höheren effektiven Kurs gegen ETH einlösen kann.
0x2 Angriffsanalyse
Der Angreifer führte mehrere Runden von Arbitrage innerhalb einer einzigen Transaktion durch [3], wiederholend: getPurchasePrice() -> buyTRU() -> sellTRU()
Erste Runde: Nullkosten-Kauf, dann Verkauf mit Gewinn
Durch die Angabe eines sorgfältig ausgewählten Kaufbetrags (240.442.509,453.545.333.947.284.131) löste der Angreifer einen Überlauf in _getPurchasePrice() aus, reduzierte den berechneten Kaufpreis auf 0 ETH und ermöglichte so den Erwerb von ~240 Millionen TRU kostenlos.
Der folgende Python-Code-Check zeigt, dass der Zähler 2^256 überschreitet und nach dem Abwickeln der berechnete Kaufpreis ein winziger Bruchteil wird, der beim Umwandeln in eine Ganzzahl auf null gekürzt 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 für 5.105 ETH aus den Reserven des Protokolls ein.
Nachfolgende Runden: Niedrigkosten-Käufe, dann Verkauf mit Gewinn
Der Angreifer wiederholte den Zyklus mehrmals. Spätere Käufe waren nicht immer streng kostenfrei, aber der Überlauf hielt die Kaufpreise weiterhin weit unter den entsprechenden Verkaufserlösen.
Insgesamt hat der Angreifer 8.535 ETH aus den Reserven von Truebit abgezogen.
0x3 Zusammenfassung
Dieses Ereignis wurde letztendlich durch einen ungeprüften Integer-Überlauf in der Kaufpreislogik von Truebit verursacht. Obwohl das asymmetrische Kauf-/Verkaufsmodell 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 Entleerung der Reserven.
Für jeden Produktionsvertrag, der noch ältere Solidity-Versionen als 0.8 verwendet, sollten Entwickler:
- eine überlaufsichere Arithmetik (z. B.
SafeMathoder äquivalente Prüfungen) für jede relevante Operation anwenden oder - vorzugsweise auf Solidity 0.8+ migrieren, um von den standardmäßigen Überlaufprüfungen zu profitieren.
Referenz
[1] https://x.com/Truebitprotocol/status/2009328032813850839
[2] https://docs.truebit.io/v1docs
Über BlockSec
BlockSec ist ein Full-Stack-Anbieter für Blockchain-Sicherheit und Krypto-Compliance. Wir entwickeln Produkte und Dienstleistungen, die unseren Kunden helfen, Codeaudits (einschließlich Smart Contracts, Blockchain und Wallets) durchzuführen, Angriffe in Echtzeit abzufangen, Vorfälle zu analysieren, illegale Gelder zu verfolgen und AML/CFT-Verpflichtungen über den gesamten Lebenszyklus von Protokollen und Plattformen zu erfüllen.
BlockSec hat mehrere Papiere zur Blockchain-Sicherheit auf prestigeträchtigen Konferenzen veröffentlicht, mehrere Zero-Day-Angriffe auf DeFi-Anwendungen gemeldet, mehrere Hacks blockiert, um mehr als 20 Millionen Dollar zu retten, und Kryptowährungen im Wert von Milliarden gesichert.
-
Offizielle Website: https://blocksec.com/
-
Offizieller Twitter-Account: https://twitter.com/BlockSecTeam



