Aktualisiert am 14. Juni 2024: Ein Community-Mitglied hat diesen Blogbeitrag sorgfältig geprüft und Informationen zum ADU-Vorfall bereitgestellt, der eine neue Form darstellt, die nicht in unserer vorherigen Kategorisierung enthalten war. Vielen Dank, und jedes aufschlussreiche Feedback ist willkommen!
Zur Förderung der Marktstabilität werden Reflexionstoken (auch bekannt als Belohnungstoken) entwickelt, um Anlegern eine zusätzliche Möglichkeit zum Einkommensverdienst zu bieten. Dies ermutigt Anleger, ihre Token zu halten, anstatt sie zu handeln. Während der berüchtigten Meme-Coin-Saison 2021 wurden Reflexionstoken zu einem unverzichtbaren Mechanismus, der nach der Einführung auf Plattformen wie DxSale (z. B. SafeMoon V1) schnell die Aufmerksamkeit des Marktes auf sich zog.
Obwohl die Hektik nachließ und sich der Markt 2023 abkühlte, erkannte unser System Dutzende von Tausenden von Hacks, die solche Token-Mechanismen in freier Wildbahn ausnutzten. Diese Reaper-artigen Angriffe führten, obwohl sie im Vergleich zu anderen Arten von DeFi-Angriffen relativ klein waren, zu nicht unerheblichen Verlusten von Nutzervermögen.
In diesem Blog konzentrieren wir uns hauptsächlich auf die Weitergabe von sicherheitsbezogenen Erkenntnissen aus unserer Forschung. Insbesondere werden wir zunächst eine kurze Einführung in den Mechanismus des Reflexionstokens geben. Danach werden wir Sicherheitsvorfälle im Zusammenhang mit Reflexionstoken überprüfen, mit Schwerpunkt auf denen, die den Mechanismus des Reflexionstokens ausnutzen. Anschließend werden wir theoretisch ein potenzielles Sicherheitsproblem diskutieren. Schließlich werden wir einige Gedanken zur Schadensbegrenzung und zu Lösungen teilen.
0x1 Mechanismus des Reflexionstokens
Nach unserem Kenntnisstand wurde dieser Mechanismus erstmals von Reflect Finance eingeführt, um einen Prozentsatz des Transaktionsbetrags als Gebühren nicht-transaktional an alle Token-Inhaber zu verteilen. Im März 2021 wurde die renommierte SafeMoon V1 auf der BNB-Kette veröffentlicht, was die Popularität des Reflexionstokens weiter steigerte.
0x1.1 Grundkonzepte
Bevor wir ins Detail gehen, sollten einige grundlegende Konzepte eingeführt werden, um ein besseres Verständnis zu erzielen.
Es gibt zwei Arten von Räumen: r-space und t-space, die als reflektierter Raum bzw. echter Raum gelesen werden. Die Kryptowährungen zweier Räume haben Wechselkurse, die auf dem relativen Umlaufvolumen basieren. Außerdem ist die Währung im r-space deflationär, d. h. bei jeder Transaktion wird ein bestimmter Prozentsatz verbrannt und infolgedessen wird der verbrannte Betrag vom Umlaufvolumen abgezogen.
Betrachten wir, dass Alice, Bob und Eve beide Transaktionen in beiden Räumen durchführen können, wie in der folgenden Abbildung dargestellt. Wenn Alice und Bob paarweise Überweisungen im t-space tätigen, erhält Eve keine Belohnungen. Wenn jedoch alle drei zuerst ihre Token in den r-space konvertieren und dann Alice und Bob untereinander überweisen lassen, erhält Eve schließlich passive Einkünfte, indem sie ihre Token zurück in den t-space konvertiert. Das ist die Grundidee des Reflexionstoken-Mechanismus.
Beachten Sie, dass nicht alle Konten, wie z. B. der Liquiditätsbereitstellungspool des Tokens, in der Lage sind, im r-space zu handeln, d. h. bestimmte Konten müssen vom r-space ausgeschlossen werden.
0x1.2 Vertragliche Erklärung
Nun wollen wir uns den REFLECT-Vertrag von Reflect Finance ansehen, um diesen Mechanismus zu untersuchen.
Dieser Vertrag definiert zunächst mehrere Variablen für die Kontenverwaltung:
mapping (address => uint256) private _rOwned; // vom Benutzer gehaltene reflektierte Tokens
mapping (address => uint256) private _tOwned; // vom Benutzer gehaltene echte Tokens
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // ob der Benutzer vom r-space ausgeschlossen ist
address[] private _excluded; // vom r-space ausgeschlossene Konten
Dann definiert er die wesentlichen Konstanten des Vertrags. Man kann beobachten, dass _rTotal auf ein bestimmtes Vielfaches von _tTotal (d. h. die totalSupply des Tokens, die als Rückgabewert der Funktion totalSupply verwendet wird) gesetzt ist:
uint256 private constant MAX = ~uint256(0);
uint256 private constant _tTotal = 10 * 10**6 * 10**9;
uint256 private _rTotal = (MAX - (MAX % _tTotal));
Aus Sicht der Funktionalität und Benutzerinteraktion fallen die Funktionen dieses Vertrags in die folgenden drei Kategorien: Guthabensabfrage und Token-Übertragung, zusammen mit einer besonderen Reflexionsfunktion. Die beiden erstgenannten sind mit dem ERC-20-Standard kompatibel, aber die interne Logik unterscheidet sich von anderen ERC-20-Token. Jede dieser Funktionen wird im Folgenden näher erläutert.
0x1.2.1 Funktionen zur Guthabensabfrage
Die Guthabenberechnung unterscheidet sich für ausgeschlossene und nicht ausgeschlossene Benutzer:
function balanceOf(address account) public view override returns (uint256) {
if (_isExcluded[account]) return _tOwned[account];
return tokenFromReflection(_rOwned[account]);
}
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
Dies kann durch die folgende Formel ausgedrückt werden:

Der Satz in der obigen Formel wird durch Aufruf der Funktion _getRate berechnet, die tatsächlich aus dem Rückgabewert der Funktion _getCurrentSupply im Vertrag berechnet wird.
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply);
}
function _getCurrentSupply() private view returns(uint256, uint256) {
uint256 rSupply = _rTotal;
uint256 tSupply = _tTotal;
for (uint256 i = 0; i < _excluded.length; i++) {
if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
rSupply = rSupply.sub(_rOwned[_excluded[i]]);
tSupply = tSupply.sub(_tOwned[_excluded[i]]);
}
if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
return (rSupply, tSupply);
}
Es ist nicht schwer, die entsprechende Formel aus dem obigen Code-Schnipsel abzuleiten:

0x1.2.2 Funktionen für Token-Übertragung
Im Allgemeinen gibt es vier Szenarien für die Übertragung von Vermögenswerten, wie folgt:
function _transfer(address sender, address recipient, uint256 amount) private {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
require(amount > 0, "Transfer amount must be greater than zero");
if (_isExcluded[sender] && !_isExcluded[recipient]) {
_transferFromExcluded(sender, recipient, amount); // t-space -> r-space
} else if (!_isExcluded[sender] && _isExcluded[recipient]) {
_transferToExcluded(sender, recipient, amount); // r-space -> t-space
} else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
_transferStandard(sender, recipient, amount); // r-space -> r-space
} else if (_isExcluded[sender] && _isExcluded[recipient]) {
_transferBothExcluded(sender, recipient, amount); // t-space -> t-space
} else {
_transferStandard(sender, recipient, amount); // r-space -> r-space
}
}
Für ausgeschlossene Konten müssen sowohl _rOwned als auch _tOwned zum jeweiligen Raum hinzugefügt oder davon abgezogen werden. Für nicht ausgeschlossene Konten muss nur _rOwned berücksichtigt werden. Zum Beispiel zeigt der folgende Code-Schnipsel die Implementierung der Übertragung von Vermögenswerten vom r-space in den t-space, wobei sender ein nicht ausgeschlossenes Konto und recipient ein ausgeschlossenes Konto ist.
function _transferToExcluded(address sender, address recipient, uint256 tAmount) private {
(uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_tOwned[recipient] = _tOwned[recipient].add(tTransferAmount);
_rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);
_reflectFee(rFee, tFee);
emit Transfer(sender, recipient, tTransferAmount);
}
-
Die Funktion
_getValuesberechnet den entsprechenden Betrag, den Übertragungsbetrag und die Gebühr (d. h. Betrag = Übertragungsbetrag + Gebühr) für beide Räume.function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256) { (uint256 tTransferAmount, uint256 tFee) = _getTValues(tAmount); uint256 currentRate = _getRate(); (uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate); return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee); } -
Die Funktion
_reflectFeereflektiert die Gebühr im r-space. Insbesondere wird_rTotalum die Gebühr im r-space (d. h.rFee) reduziert, was wiederum den Satz senkt. Gemäß der balanceOf(user)-Berechnungsformel werden die Gebühren auf diese Weise an alle nicht ausgeschlossenen Token-Inhaber reflektiert.function _reflectFee(uint256 rFee, uint256 tFee) private { _rTotal = _rTotal.sub(rFee); _tFeeTotal = _tFeeTotal.add(tFee); }
0x1.2.3 Die Funktion `reflect`
Neben der passiven Auslösung des Reflexionstoken-Mechanismus während des Übertragungsprozesses können Benutzer aktiv die Funktion reflect aufrufen, um diesen Mechanismus zu initiieren. Insbesondere wenn jemand die von ihm gehaltenen Token verbraucht, um diese Funktion aufzurufen, sinkt der Satz, da rSupply sinkt, und bietet somit Vorteile für andere Token-Inhaber. Mit anderen Worten, sich selbst zum Wohle anderer opfern. Dadurch können die Projektbetreiber die Token-Inhaber durch die Nutzung dieser Funktion anreizen.
function reflect(uint256 tAmount) public {
address sender = _msgSender();
require(!_isExcluded[sender], "Excluded addresses cannot call this function");
(uint256 rAmount,,,,,,) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rTotal = _rTotal.sub(rAmount);
_tFeeTotal = _tFeeTotal.add(tAmount);
}
Die oben bereitgestellten Codes bilden den Kern des Mechanismus. Verschiedene Token können spezifische zusätzliche Funktionen integrieren, um ihre Implementierung anzupassen. Einige verwenden beispielsweise Transaktionsgebühren, um eine "Swap-and-Liquify"-Funktion zu betreiben und Panik zu verhindern, wenn Wale beschließen, ihre Token zu verkaufen.
0x2 Post-Mortem von Rekt Reflexionstoken
Wie bereits erwähnt, konzentrieren wir uns hauptsächlich auf Angriffe, die den Reflexionstoken-Mechanismus ausnutzen. Daher werden Vorfälle, die nichts mit diesem Mechanismus zu tun haben, wie der SafeMoon V2-Angriff (ein übliches ERC20-Problem des öffentlichen Verbrennens) und der jüngste ZongZi-Angriff (bezogen auf eine altmodische Preismanipulation, die den Spotpreis ausnutzt), nicht behandelt.
Wir haben diese Angriffe eingehend analysiert, um ihre Grundursachen zu entmystifizieren. Eine Liste aller dieser Vorfälle finden Sie hier. Wir stellten fest, dass die meisten davon normale Sicherheitsvorfälle waren, die entweder durch Code-Schwachstellen oder unsachgemäße administrative Operationen verursacht wurden. Einige wenige sind jedoch recht verdächtig (z. B. die Anwesenheit einer Hintertür), die wir als abnormale Sicherheitsvorfälle bezeichnen. In den folgenden Unterabschnitten werden wir zunächst die normalen Sicherheitsvorfälle vorstellen und dann die abnormalen im Detail behandeln.
0x2.1 Normale Sicherheitsvorfälle
Unsere Untersuchung deutet darauf hin, dass diese Vorfälle auf zwei Arten von Problemen zurückzuführen sind, nämlich Code-Level-Probleme und Betriebs-Level-Probleme, entweder einzeln oder in Kombination.
-
Code-Level-Probleme. Dies ergibt sich aus der mangelhaften Implementierung des Vertrags, wahrscheinlich weil die Entwickler den Mechanismus des Reflexionstokens nicht vollständig verstanden haben, was zu einer Inkonsistenz zwischen dem tatsächlichen Angebot an Token und dem aufgezeichneten Wert von
totalSupplyführt, die zur Manipulation des Satzes verwendet werden kann:-
1.1 Kostenloses Verbrennen
-
1.2 Zusätzliche Abzüge von
rSupplywährend Token-Übertragungen -
1.3 Verwechslung zwischen r-space- und t-space-Werten (mit Präzisionsverlust zur Gewinnerzielung)
-
-
Betriebs-Level-Probleme. Dies resultiert aus unsachgemäßen Operationen durch Administratoren. Insbesondere bezieht sich dies bei diesen Vorfällen auf die unsachgemäße Konfiguration der AMM-Pair-Adressen, die nicht ordnungsgemäß ausgeschlossen sind.
Es ist erwähnenswert, dass ALLE aufgeführten Code-Level-Probleme zu Inkonsistenzen zwischen dem tatsächlichen Angebot und totalSupply führen könnten, wodurch die Verträge anfällig werden. Das bedeutet jedoch nicht unbedingt, dass diese Schwachstellen ausnutzbar sind oder, genauer gesagt, profitabel genug, um die Ausnutzung zu rechtfertigen, da es für den Angreifer Kosten geben könnte, den Satz zu manipulieren. Der Einfachheit halber werden wir im Folgenden den Begriff "ausnutzbar" verwenden, um "profitabel genug, um die Ausnutzung zu rechtfertigen" zu bedeuten. Infolgedessen ist ein Betriebs-Level-Problem in einigen Szenarien notwendig, um diese Schwachstellen ausnutzbar zu machen.
Insbesondere kann Problem 1.1 direkt ausgenutzt werden, während die Probleme 1.2 und 1.3 eine Kombination mit Problem 2 erfordern, um ausnutzbar zu werden. Daher können die diskutierten Vorfälle basierend auf diesen Beobachtungen in zwei Typen, Typ-I und Typ-II, unterteilt werden. Nachfolgend finden Sie eine Tabelle mit den relevanten Daten:
| Typ | Vorfall(e) | Grundursache(n) | # (%) |
|---|---|---|---|
| I | CATOSHI | Nur Code-Level-Problem (1.1) | 1 (0,79%) |
| II (a) | BEVO, FETA, ADU | Kombination aus beidem (1.2 & 2) | 3 (2,38%) |
| II (b) | SHEEP und 120+ andere | Kombination aus beidem (1.3 & 2) | 122 (96,83%) |
Die Tabelle zeigt, dass Typ-II-Vorfälle einen erheblichen Anteil ausmachen. Insbesondere gibt es 2 Variationen innerhalb von Typ-II: Typ-II-a (d. h. Problem 1.2 mit Problem 2) und Typ-II-b (d. h. Problem 1.3 mit Problem 2). Darüber hinaus deuten die Exploits für SHEEP-ähnliche Vorfälle der Kategorie Typ-II-b darauf hin, dass Angreifer (z. B. dieser) möglicherweise automatisierte Methoden verwenden, um ähnlich anfällige Verträge zu identifizieren. Spezifische Details werden in den folgenden Unterabschnitten erläutert.
0x2.1.1 Typ-I: Nur Code-Level-Problem (Problem 1.1)
Nur ein Vorfall gehört zu Typ-I, d. h. der CATOSHI-Vorfall, ein kostenloses Verbrennen, das die Gesamtmenge betrifft.
Schauen wir uns zunächst die Funktion burnOf im CATOSHI-Vertrag an:
function burnOf(uint256 tAmount) public {
uint256 currentRate = _getRate();
uint256 rAmount = tAmount.mul(currentRate);
_tTotal = _tTotal.sub(tAmount);
_rTotal = _rTotal.sub(rAmount);
emit Transfer(_msgSender(), address(0), tAmount);
}
Offensichtlich wird der von dieser Funktion verbrannte Betrag nicht vom Aufrufer (d. h. msg.sender) abgezogen. Allerdings sollte _rOwned[msg.sender] um rAmount reduziert werden, und wenn das Konto ausgeschlossen ist, sollte _tOwned[msg.sender] ebenfalls um tAmount reduziert werden.
Aufgrund dieses Versäumnisses können Angreifer zunächst eine große Menge an Token kostenlos verbrennen und dann die reflect-Funktion des Vertrags aufrufen. Da sowohl _tTotal als auch _rTotal proportional stark reduziert wurden:

Der Satz kann durch Aufruf der reflect-Funktion leicht nach unten manipuliert werden, wodurch der balanceOf(attacker) erheblich steigt. Dies ermöglicht es Angreifern, von dem aufgeblähten Guthaben zu profitieren.

Warum? Beachten Sie, dass das neue Guthaben des Angreifers wie folgt berechnet wird:

Das Verhältnis zwischen balanceOf(attacker) und balanceOf(attacker)' ist:

Da

Daher

Was bedeutet, dass der Angreifer mehr Token erntet, die gegen wertvolle Token (in diesem Fall WETH) getauscht werden können, um Gewinne zu erzielen.
0x2.1.2 Typ-II-a: Kombination aus Problem 1.2 und Problem 2
Typ-II-a-Vorfälle beinhalten die Kombination zweier Probleme:
- Problem 1.2: Zusätzlicher Abzug von
rSupplywährend Token-Übertragungen. - Problem 2: AMM-Paar ist nicht ausgeschlossen.
Bei Typ-II-a gibt es drei Angriffsfälle, die weiter in zwei Unterkategorien unterteilt werden können, je nach den Schwachstellenformen in Problem 1.2, wie folgt:
1. Zusätzliche Reflexion in der Funktion _reflectFee
Zwei Vorfälle gehören zu dieser Unterkategorie, d. h. der BEVO-Vorfall und der FETA-Vorfall. Im Folgenden verwenden wir den BEVO-Vertrag zur Veranschaulichung.
Wie in den "Funktionen für die Token-Übertragung" (Abschnitt 0x1.2.2) erläutert, löst jede Token-Übertragung die Reflexion eines Teils der Transaktionsgebühr aus. Bei BEVO werden die Gebühren neben der ursprünglichen zusätzlich in zwei Teile aufgeteilt: Verbrennen und Spenden.
function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
_rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rChairty wird von _rTotal abgezogen
_tFeeTotal = _tFeeTotal.add(tFee);
_tBurnTotal = _tBurnTotal.add(tBurn);
_tCharityTotal = _tCharityTotal.add(tCharity);
_tTotal = _tTotal.sub(tBurn);
}
Beachten Sie, dass das Spendenkonto ausgeschlossen ist, was bedeutet, dass der an dieses Konto gesendete Betrag verbrannt wird, wie in der Funktion _sendToCharity gezeigt.
function _sendToCharity(uint256 tCharity, address sender) private {
uint256 currentRate = _getRate();
uint256 rCharity = tCharity.mul(currentRate);
address currentCharity = _charity[0];
_rOwned[currentCharity] = _rOwned[currentCharity].add(rCharity);
_tOwned[currentCharity] = _tOwned[currentCharity].add(tCharity); // da das Spendenkonto ausgeschlossen ist, wird der Spendenanteil verbrannt
emit Transfer(sender, currentCharity, tCharity);
}
Aus dem obigen Code-Schnipsel können wir ersehen, dass es zwei Stellen gibt, an denen der Spendenanteil reflektiert und verbrannt wird, was dazu führt, dass das tatsächliche Angebot an Token während der Übertragungen inkonsistent mit der totalSupply wird. Mit zunehmender Anzahl von übertragenen Token ist der Wert von rSupply geringer als das Angebot an Token im Pool, da er zusätzlich abnimmt.
Die rein theoretische Beschreibung mag etwas abstrakt sein, also verwenden wir ein Beispiel, um den Prozess zu verdeutlichen. Angenommen, Alice möchte 10 Token an Bob übertragen, und 3 Token werden wie folgt abgezogen: 1 für die Gebühr, 1 für das Verbrennen und 1 für Spenden. Da der Spendenanteil sowohl reflektiert als auch verbrannt wird, ist die tatsächliche Aufschlüsselung 2 Token, die reflektiert werden (1 Gebühr + 1 Spende), und 2 Token, die verbrannt werden (1 Verbrennung + 1 Spende). Zusammen mit den verbleibenden 7 Token, die an Bob übertragen werden sollen, sind insgesamt 11 Token an diesem fehlerhaften Prozess beteiligt.
Aber warum kann diese Inkonsistenz ausgenutzt werden, um Gewinne zu erzielen? Im Folgenden werden wir mit unserem mathematischen Zauberstab die Folgen ableiten.
Angenommen, wir haben zuvor einige Token aus dem Pool (d. h. PancakeSwap-Paar) erworben, die im r-space als rAmount und im t-space als tAmount bezeichnet werden. Da der Pool nicht ausgeschlossen wurde, bezeichnen wir _rOwned[pair] als rReserve, wobei der entsprechende Wert im t-space ebenfalls als tReserve bezeichnet wird. Dann haben wir:

Aufgrund des zusätzlichen Abzugs ist rSupply jetzt kleiner als das Angebot an Token im Pool:

Erinnern Sie sich an den Abschnitt "Funktionen zur Guthabensabfrage" (0x1.2.1), der aktuelle Satz kann mit der folgenden Formel berechnet werden:

Zu diesem Zeitpunkt, wenn wir die von uns gehaltenen Token über die reflect-Funktion (die in diesem Vertrag in deliver umbenannt wurde) reflektieren, wird der Satz zu rate':

Da

Dann haben wir

Durch Kombination der Formeln 1, 3 und 6 können wir die folgende Ungleichung ableiten:

Dies bedeutet, dass die Anzahl der Token, die wir direkt aus dem Pool (über die skim-Funktion) ernten können, größer ist als das, was wir geliefert haben, was profitabel ist, da die Kosten für den Aufruf der reflect-Funktion gedeckt werden können. Danach kann der Angreifer die geernteten Token gegen wertvolle Token (in diesem Fall WBNB) tauschen, um Gewinne zu erzielen.
Beachten Sie, dass der BEVO-Vertrag auch für Problem 1.3 anfällig ist, das im Angriff nicht ausgenutzt wurde.
2. Falsche rTransferAmount-Berechnung in der Funktion _getRValues
Nur ein Vorfall gehört zu dieser Form, d. h. der ADU-Vorfall. Schauen wir uns zunächst den folgenden Code-Schnipsel an.
function _transferStandard(address sender, address recipient, uint256 tAmount) private {
(uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);
_takeTeam(tTeam);
_reflectFee(rFee, tFee);
emit Transfer(sender, recipient, tTransferAmount);
}
function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256, uint256) {
(uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getTValues(tAmount, _taxFee, _teamFee);
uint256 currentRate = _getRate();
(uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate);
return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee, tTeam);
}
function _getTValues(uint256 tAmount, uint256 taxFee, uint256 teamFee) private pure returns (uint256, uint256, uint256) {
uint256 tFee = tAmount.mul(taxFee).div(100);
uint256 tTeam = tAmount.mul(teamFee).div(100);
uint256 tTransferAmount = tAmount.sub(tFee).sub(tTeam); // tTeam wird von tAmount abgezogen
return (tTransferAmount, tFee, tTeam);
}
function _getRValues(uint256 tAmount, uint256 tFee, uint256 currentRate) private pure returns (uint256, uint256, uint256) {
uint256 rAmount = tAmount.mul(currentRate);
uint256 rFee = tFee.mul(currentRate);
uint256 rTransferAmount = rAmount.sub(rFee); // Jedoch wird kein rTeam von rAmount abgezogen
return (rAmount, rTransferAmount, rFee);
}
Man sieht, dass sowohl die Steuergebühr als auch die Teamgebühr bei Überweisungen abgezogen werden sollten. In der Funktion _getTvalues wird jedoch tTransferAmount um tFee und tTeam subtrahiert, während in der Funktion _getRValues nur rFee subtrahiert wird. Diese Diskrepanz führt zu dem oben genannten Inkonsistenzproblem, das sich mit zunehmender Anzahl von Token-Übertragungen verschlimmert.
Da das Paar auch nicht vom Token ausgeschlossen ist, ist dieser Token ausnutzbar. Insbesondere könnte ein Angreifer ähnliche BEVO-Exploits verwenden, um über die skim-Funktion des Paares weitere ADU-Token zu ernten, nachdem die deliver-Funktion aufgerufen wurde.
Aufgrund des damaligen On-Chain-Status war es dem Angreifer jedoch unmöglich, die geernteten ADU-Token gegen WBNB zu tauschen, um Gewinne zu erzielen (aufgrund der require-Anweisung in der Funktion tokenFromReflection). Daher müsste der Angreifer eine komplexere Ausnutzungsstrategie anwenden, um Gewinne zu erzielen, die hier nicht näher erläutert wird.
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
0x2.1.3 Typ-II-b: Kombination aus Problem 1.3 und Problem 2
Typ-II-b-Vorfälle beinhalten die Kombination zweier Probleme:
- Problem 1.3: Verwechslung zwischen r-space- und t-space-Werten (beachten Sie, dass auch ein Präzisionsverlust vorhanden sein muss, um Gewinne zu erzielen).
- Problem 2: AMM-Paar ist nicht ausgeschlossen.
Problem 1.3 ergibt sich aus der falschen Handhabung von Werten zwischen r-space und t-space bei der Implementierung der internen _burn-Funktion.
function burn(uint256 _value) public {
_burn(msg.sender, _value);
}
function _burn(address _who, uint256 _value) internal {
require(_value <= _rOwned[_who]);
_rOwned[_who] = _rOwned[_who].sub(_value); // _rOwned sollte um einen r-space-Wert reduziert werden
_tTotal = _tTotal.sub(_value); // _tTotal sollte um einen t-space-Wert reduziert werden
// Für die Semantik der burn-Funktion sollte _rTotal ebenfalls um einen r-space-Wert reduziert werden.
emit Transfer(_who, address(0), _value);
}
Angesichts der wesentlichen Konstanten des Vertrags ist der r-space-Wert typischerweise ein Vielfaches des t-space-Werts. Daher führt der Aufruf der burn-Funktion mit einem _value, das in der gleichen Größenordnung wie tSupply liegt, zu einer erheblichen Inflation des Satzes.
Im Gegensatz zu den Fällen vom Typ I kann der Aufrufer jedoch keine Token verbrennen, während sein eigenes Guthaben unverändert bleibt. Das heißt, es ist schwierig, wenn nicht gar unmöglich, dass der Angreifer mehr Token erntet. Wie konnten also die Fälle vom Typ II-b ausnutzbar sein?
Am Beispiel des SHEEP-Token-Vorfalls kann der Wert der vom Angreifer gehaltenen SHEEP-Token wie folgt bezeichnet werden:

Wobei der Preis von SHEEP durch den Spotpreis im PancakeSwap-Paar ausgedrückt werden kann, berechnet als:

Dann kann der Wert weiter ausgedrückt werden als:

Der Angreifer führt dann wiederholt die burn-Funktion aus und synchronisiert schließlich das Paar. Da weder der Angreifer noch das Paar ausgeschlossen sind, sinken ihre Guthaben aufgrund der oben erwähnten Rate-Inflation. Somit passt sich das Verhältnis an:

Basierend auf den vorherigen Definitionen können wir diese Verhältnisse weiter ausdrücken als:

Wobei X die Summe der verbrannten _value darstellt.
Hier kommt die Magie: Das letztere Verhältnis ist, wenn wir 8 und 9 weiter vereinfachen, schlichtweg kleiner als das erstere, was uns wundern lässt, da der Gewinn in diesem Fall negativ wäre:

Tatsächlich nutzte der Angreifer ein Problem mit Präzisionsverlust beim Reflexionstoken aus. Für nicht ausgeschlossene Benutzer wird, gemäß der von uns bereitgestellten Formel, die Guthabenberechnung in der tokenFromReflection-Funktion tatsächlich abgerundet. Somit kann der Rückgabewert der balanceOf-Abfrage kleiner sein als sein theoretischer Wert. Das heißt, das Verhältnis' kann größer sein als das Verhältnis, wenn wir dieses Problem berücksichtigen.
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
Durch das Debuggen der Angriffstransaktion können wir das theoretische Guthaben des Angreifers und des Paares vor und nach diesen Manipulationen berechnen. Die Ergebnisse unserer Berechnungen sind in der folgenden Tabelle zusammengefasst:
Δ in der Tabelle ist ein extrem kleiner Wert, weit weniger als 1.
Durch die Analyse des Trace in der sync-Funktion des Paares können wir berechnen, dass die theoretischen Guthaben des Angreifers und des Paares tatsächlich 27,523 bzw. 2,972 betragen, was zu einem Verhältnis von 9,26 führt. Aufgrund des Präzisionsverlusts werden die Guthaben jedoch auf 27 bzw. 2 abgerundet, wodurch sich das Verhältnis auf 13,50 erhöht. Infolgedessen wird der Gewinn zu einem positiven Wert.
Schließlich kann der Angreifer durch einen umgekehrten Swap Gewinne erzielen.
0x2.2 Abnormale Sicherheitsvorfälle
In diesem Unterabschnitt teilen wir unsere Erkenntnisse aus der Untersuchung der FDP- und DBALL-Token. Unsere Analyse deutet darauf hin, dass die Manager sowohl der FDP- als auch der DBALL-Token problematische privilegierte Funktionen aufgerufen haben, die effektiv als Hintertüren dienten, die die Projekte gefährdeten und letztendlich zu Angriffen führten. Insbesondere im DBALL-Projekt haben wir eine Reihe verdächtiger Transaktionen des Token-Besitzers identifiziert, die klare Beweise dafür liefern, dass es sich um einen Rug Pull handelt.
Die Angriffe auf diese beiden Token ähneln stark denen, die im Abschnitt "Typ-II-a: Kombination aus Problem 1.2 und Problem 2" unter 0x2.1.2 beschrieben wurden. Bei der Analyse der Gründe, warum das tatsächliche Angebot an Token vom totalSupply abweichen kann, treten jedoch einige verdächtige Aktivitäten zutage.
0x2.2.1 Der FDP-Vorfall
Die Diskrepanz zwischen dem tatsächlichen Token-Angebot und dem totalSupply im FDP-Fall beruht auf dem Aufruf der Funktion transferOwnership, einer privilegierten Funktion, die nur vom Vertragsinhaber aufgerufen werden kann. Wie der Name schon sagt, soll diese Funktion den Eigentümer des Vertrags ändern. Im FDP-Vertrag hat diese Funktion jedoch nichts mit der Eigentümerübertragung zu tun. Stattdessen erhöht sie _rOwned[newOwner], ohne totalSupply zu ändern. Dies verstößt eindeutig gegen die Designprinzipien des normalen Token-Minting-Prozesses.
function transferOwnership(address newOwner) public virtual onlyOwner {
(, uint256 rTransferAmount,, uint256 tTransferAmount,,) = _getValues(_tTotal);
_rOwned[newOwner] = _rOwned[newOwner].add(rTransferAmount);
emit Transfer(address(0), newOwner, tTransferAmount);
}
Die Transaktionen, die diese Funktion aufgerufen haben, sind in der folgenden Tabelle zusammengefasst:
| Zeitstempel | Transaktions-Hash | Aufrufer | Der newOwner |
|---|---|---|---|
| 2021-06-05 17:23:57 | 0x46fa1f97...4606d9bc | 0xef309c...262586 | 0x9e96af...24481a |
| 2021-06-05 17:24:06 | 0x686e0d82...d6ebfb62 | 0xef309c...262586 | 0xb0c426...a72063 |
| 2021-06-05 17:26:12 | 0x44285339...7a526320 | 0xef309c...262586 | 0xef309c...262586 |
| 2021-06-05 19:39:00 | 0xaff7a688...dfe3f344 | 0xef309c...262586 | 0x9e96af...24481a |
| 2022-06-05 18:08:10 | 0x2c413604...f7718f25 | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 18:49:16 | 0x8f4309ca...97d4bcec | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 23:33:44 | 0xaa029544...3ff7b629 | 0xef309c...262586 | 0xef309c...262586 |
0x2.2.2 Der DBALL-Vorfall
Bei DBALL wird es knifflig. Mit MetaSleuth zur Analyse des Geldflusses des DBALL-Besitzers beobachteten wir ein Ungleichgewicht bei Zu- und Abflüssen von DBALL-Token an diese Adresse, wobei die Mittelquelle für diese Transaktion nicht aufgezeichnet wurde.
Durch Abfragen der historischen Zustände auf der Kette haben wir schließlich festgestellt, dass sich das DBALL-Guthaben des Besitzers vor und nach dieser Transaktion geändert hat. Wir können beobachten, dass der Besitzer die privilegierte Funktion manualDevBurn aufgerufen hat, um 1 Token im t-space zu verbrennen. Die Implementierung dieser Funktion lautet wie folgt:
function manualDevBurn (uint256 tAmount) public onlyOwner() {
uint256 rAmount = tAmount.mul(_getRate());
if (_isExcluded[_msgSender()]) {
_tOwned[_msgSender()] = _tOwned[_msgSender()] - (tAmount);
}
_rOwned[_msgSender()] = _rOwned[_msgSender()] - (rAmount);
_tOwned[address(0)] = _tOwned[address(0)] + (tAmount);
_rOwned[address(0)] = _rOwned[address(0)] + (rAmount);
emit Transfer(_msgSender(), address(0), tAmount);
}
Auf den ersten Blick scheint alles in Ordnung zu sein. Aufgrund der Spezifikation des Vertrags mit einer Compilerversion unter 0.8 tritt jedoch bei der Subtraktion von _rOwned[_msgSender()] ein arithmetischer Unterlauf auf, der von 0 zu fast type(uint256).max übergeht. Diese subtile Manipulation ermöglicht es dem Besitzer, sein Guthaben zu ändern, führt aber auch zu Inkonsistenzen im Token-Angebot.
Ist das nur ein zufälliger Fehler? Unsere Untersuchung deutet darauf hin, dass es sich eher um einen absichtlichen Rug Pull handelt. Die Gründe dafür sind wie folgt zusammengefasst:
-
Der Besitzer hat nur 1 Token in die Funktion
manualDevBurnübergeben, doch innerhalb einer halben Stunde wurde über diese Transaktion eine Menge DBALL übertragen, die dem gesamten Angebot entspricht, an eine verbundene Adresse. -
Diese verbundene Adresse tauschte sofort im PancakeSwap-Paar und erhielt ungefähr 56 WBNB.
- Die Analyse der Geldflüsse dieser beiden Adressen zeigt, dass beide schließlich BNB über Tornado.Cash transferiert haben.
0x3 Ein potenzielles Problem bei der Berechnung des Satzes
Abgesehen von den zuvor diskutierten Vorfällen haben wir auch festgestellt, dass theoretisch ein potenzielles Problem bei der Guthabenberechnung für nicht ausgeschlossene Benutzer besteht, das weitere Diskussionen verdient. Dieses Problem kann bei der Berechnung des Satzes auftreten.
Betrachten wir die Funktion _getCurrentSupply. In dieser Funktion bestimmt die abschließende if-Anweisung, ob rSupply kleiner ist als der Anfangssatz (d. h. _rTotal / _tTotal).
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply);
}
function _getCurrentSupply() private view returns(uint256, uint256) {
uint256 rSupply = _rTotal;
uint256 tSupply = _tTotal;
for (uint256 i = 0; i < _excluded.length; i++) {
if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
rSupply = rSupply.sub(_rOwned[_excluded[i]]);
tSupply = tSupply.sub(_tOwned[_excluded[i]]);
}
if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
return (rSupply, tSupply);
}
Während der Lebensdauer vom Vertrags-Deployment bis zur Projektveröffentlichung würden, wenn diese Aussage zutrifft, die Guthaben aller nicht ausgeschlossenen Benutzer Null sein. Sobald das Projekt jedoch gestartet wurde und Transaktionen begannen, ging die ursprüngliche Absicht der if-Anweisung verloren.
Da rSupply aufgrund des Reflexionstoken-Mechanismus sinkt, sinkt auch der Satz entsprechend. Wenn nach einer bestimmten Transaktion rSupply unter den anfänglichen Satz fällt, springt der aktuelle Satz an, was zu Guthabensverlusten für alle nicht ausgeschlossenen Benutzer führt. Zusätzlich ist es theoretisch möglich, dass

Dies führt dazu, dass der Satz aufgrund von Präzisionsverlust Null wird, was potenziell eine Divide-by-Zero-Panik auslöst.
0x4 Schadensbegrenzung und Lösungen
Der Reflexionstoken-Mechanismus bietet eine Möglichkeit, die Marktstabilität zu verbessern, indem Anleger dazu angeregt werden, ihre Token zu halten, anstatt sie zu handeln, um zusätzliche Belohnungen zu erhalten. Er führt jedoch auch neue Sicherheitsherausforderungen und potenzielle Risiken ein, wie z. B. Verwechslungen zwischen r-space- und t-space-Werten. Daher ist es entscheidend, dass Blockchain-Entwickler und Anleger den Mechanismus und seine potenziellen Risiken besser verstehen und nach Lösungen suchen.
BlockSec bietet Sicherheitsdienste und -produkte sowohl für die Vor- als auch für die Nachlaufphase an. Unsere Sicherheitsprüfungsdienste führen gründliche Überprüfungen durch, um die Sicherheit und Transparenz des Codes zu gewährleisten. Unser Phalcon-Produkt bietet kontinuierliche Sicherheitsüberwachung und Angreifererkennungsfunktionen, die es Betreibern und Anlegern ermöglichen, Projekte zu überwachen und automatische Aktionen bei Sicherheitsrisiken zu ergreifen.
Verwandte Lektüre
- Wie L2-Blockchains ihre Benutzer besser schützen können
- Top 10 "Awesome" Sicherheitsvorfälle im Jahr 2023
- Konzeptionelle Vollanalyse: Ein Aufstieg von Bitcoin mit Inschriften
Über BlockSec
BlockSec ist ein Full-Stack-Web3-Sicherheitsdienstleister. Das Unternehmen engagiert sich für die Verbesserung der Sicherheit und Benutzerfreundlichkeit der aufkommenden Web3-Welt, um deren Massenadoption zu fördern. Zu diesem Zweck bietet BlockSec Sicherheitsaudits für Smart Contracts und EVM-Ketten, die Phalcon-Plattform zur proaktiven Sicherheitsentwicklung und Bedrohungsabwehr, die MetaSleuth-Plattform zur Geldverfolgung und Untersuchung sowie die MetaSuites-Erweiterung für Web3-Entwickler zur effizienten Navigation in der Krypto-Welt an.
Bis heute hat das Unternehmen über 300 Kunden wie die Uniswap Foundation, Compound, Forta und PancakeSwap betreut und in zwei Finanzierungsrunden zweistellige Millionenbeträge von namhaften Investoren, darunter Matrix Partners, Vitalbridge Capital und Fenbushi Capital, erhalten.
-
Website: https://blocksec.com/
-
E-Mail: contact@blocksec.com
-
Twitter: https://twitter.com/BlockSecTeam
-
MetaSleuth: https://metasleuth.io/
-
MetaSuites: https://blocksec.com/metasuites



