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 vongetPurchasePrice()verwendet wird. Daher spiegeltgetPurchasePrice()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 übergetRetirePrice()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.
SafeMathoder ä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


