Aktualisiert am 14. Juni 2024: Ein Community-Mitglied hat diesen Blog sorgfältig geprüft und Informationen zu dem ADU-Vorfall bereitgestellt, der eine neue Form darstellt, die in unserer bisherigen Kategorisierung nicht abgedeckt ist. Danke, und jedes aufschlussreiche Feedback ist willkommen!
Zur Verbesserung der Marktstabilität werden Reflexionstoken (auch Belohnungstoken genannt) entwickelt, um Anlegern eine zusätzliche Möglichkeit zum Erwerb von Einkommen 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 im Jahr 2023 abkühlte, hat unser System Dutzende von Tausenden von Hackerangriffen entdeckt, die solche Token-Mechanismen in der Praxis ausnutzen. Diese Reaper-artigen Angriffe führten, obwohl sie im Vergleich zu anderen Arten von DeFi-Angriffen relativ klein angelegt waren, zu nicht unerheblichen Verlusten von Nutzervermögen.
In diesem Blog konzentrieren wir uns hauptsächlich auf die Weitergabe von sicherheitsrelevanten Erkenntnissen aus unserer Forschung. Insbesondere geben wir zunächst eine kurze Einführung in den Mechanismus des Reflexionstokens. 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 ein potenzielles Sicherheitsproblem theoretisch diskutieren. Abschließend werden wir einige Gedanken zur Minderung 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 uns mit den Details befassen, sollten einige grundlegende Konzepte eingeführt werden, um ein besseres Verständnis zu erreichen.
Es gibt zwei Arten von Räumen: r-space und t-space, gesprochen reflektierter Raum und wahrer Raum. Die Krypto-Währungen der beiden Räume haben Wechselkurse, die auf dem relativen Umlaufvolumen basieren. Außerdem ist die Währung im r-space deflationär, d.h. für jede Transaktion wird ein bestimmter Prozentsatz verbrannt und infolgedessen wird der verbrannte Betrag vom Umlaufvolumen abgezogen.
Betrachten wir Alice, Bob und Eve, die beide Transaktionen in beiden Räumen durchführen können, wie in der folgenden Abbildung gezeigt. Wenn Alice und Bob paarweise Transaktionen im t-space durchführen, erhält Eve keine Belohnungen. Wenn jedoch alle drei zuerst ihre Token in den r-space umwandeln und dann Alice und Bob sich gegenseitig übertragen lassen, erhält Eve schließlich passive Einkünfte, indem sie ihre Token zurück in den t-space umwandelt. 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 ausgenommen werden.
0x1.2 Vertragsebene Erklärung
Nun wollen wir uns mit dem REFLECT-Vertrag von Reflect Finance befassen, um diesen Mechanismus zu erforschen.
Dieser Vertrag definiert zunächst mehrere Variablen für die Kontenverwaltung:
mapping (address => uint256) private _rOwned; // reflektierte Token, die vom Nutzer gehalten werden
mapping (address => uint256) private _tOwned; // wahre Token, die vom Nutzer gehalten werden
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // ob Nutzer vom r-space ausgenommen sind
address[] private _excluded; // Konten, die vom r-space ausgenommen sind
Dann definiert er die wesentlichen Konstanten des Vertrags. Es ist zu beobachten, dass _rTotal auf ein bestimmtes Vielfaches von _tTotal (d.h. die totalSupply des Tokens, die als Rückgabewert der totalSupply-Funktion 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 der Perspektive der Funktionalität und Benutzerinteraktion fallen die Funktionen dieses Vertrags in die folgenden drei Kategorien: Guthabeneinsicht und Token-Übertragung, zusammen mit einer charakteristischen Reflexionsfunktion. Die ersten beiden sind mit dem ERC-20-Standard kompatibel, die interne Logik unterscheidet sich jedoch von anderen ERC-20-Token. Jede dieser Funktionen wird unten näher erläutert.
0x1.2.1 Funktionen zur Guthabeneinsicht
Die Guthabenberechnung unterscheidet sich für ausgeschlossene und nicht ausgeschlossene Nutzer:
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:

Die Rate in der obigen Formel wird durch Aufruf der Funktion _getRate berechnet, die tatsächlich aus dem Rückgabewert der Funktion _getCurrentSupply innerhalb des Vertrags 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 schwierig, die entsprechende Formel aus dem obigen Code-Snippet abzuleiten:

0x1.2.2 Funktionen zur 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-Ausschnitt 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 die Rate senkt. Gemäß der Formel zur Berechnung des balanceOf(user) werden auf diese Weise Gebühren 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 `reflect`-Funktion
Zusätzlich zur passiven Auslösung des Reflexionstoken-Mechanismus während des Übertragungsprozesses können Nutzer die Funktion reflect aktiv aufrufen, um diesen Mechanismus zu initiieren. Insbesondere wenn jemand die von ihm gehaltenen Token konsumiert, um diese Funktion aufzurufen, sinkt die Rate, da rSupply abnimmt, wodurch andere Token-Inhaber profitieren. Mit anderen Worten, sich selbst opfern, um anderen zu helfen. Auf diese Weise können die Projektbetreiber die Token-Inhaber durch die Nutzung dieser Funktion anregen.
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 angegebenen Codes bilden den Kern des Mechanismus. Unterschiedliche Token können spezifische zusätzliche Funktionen enthalten, um ihre Implementierung anzupassen. Einige verwenden beispielsweise Transaktionsgebühren, um eine "Swap and Liquify"-Funktion zu betreiben und einen Ansturm 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 mit öffentlichem Verbrennen) und der kürzliche ZongZi-Angriff (bezogen auf eine altmodische Preismanipulation, die den Spotpreis ausnutzt), nicht behandelt.
Wir haben diese Angriffe eingehend analysiert, um ihre Grundursachen zu entschlüsseln. Eine Liste aller dieser Vorfälle finden Sie hier. Wir haben festgestellt, dass die meisten von ihnen normale Sicherheitsvorfälle sind, die entweder auf Code-Schwachstellen oder auf unsachgemäße administrative Operationen zurückzuführen sind. Einige wenige sind jedoch recht verdächtig (z. B. das Vorhandensein 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 legt nahe, dass diese Vorfälle auf zwei Arten von Problemen zurückzuführen sind, d.h. Probleme auf Code-Ebene und Probleme auf Betriebsebene, entweder einzeln oder in Kombination.
-
Probleme auf Code-Ebene. Dies ergibt sich aus der schlechten 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 der Rate verwendet werden kann:-
1.1 Nullkosten-Verbrennung
-
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)
-
-
Probleme auf Betriebsebene. Dies ergibt sich aus unsachgemäßer Bedienung durch Administratoren. Insbesondere bezieht sich dies bei diesen Vorfällen auf die unsachgemäße Konfiguration der AMM-Paaradressen, die nicht ordnungsgemäß ausgenommen wurden.
Es ist erwähnenswert, dass ALLE aufgeführten Probleme auf Code-Ebene zu Inkonsistenzen zwischen dem tatsächlichen Angebot und totalSupply führen könnten, was die Verträge anfällig macht. Dies bedeutet jedoch nicht unbedingt, dass diese Schwachstellen ausnutzbar sind oder, genauer gesagt, profitabel genug, um sie auszunutzen, da es für den Angreifer Kosten geben könnte, die Rate zu manipulieren. Der Einfachheit halber werden wir im Folgenden den Begriff "ausnutzbar" für "profitabel genug, um ausnutzbar zu sein" verwenden. Folglich ist in einigen Szenarien ein Problem auf Betriebsebene erforderlich, um diese Schwachstellen ausnutzbar zu machen.
Insbesondere kann Problem 1.1 direkt ausgenutzt werden, während 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 Problem auf Code-Ebene (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 Varianten 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 legen die Exploits bei Vorfällen vom Typ SHEEP der Kategorie Typ-II-b nahe, dass Angreifer (z. B. dieser hier) möglicherweise automatisierte Methoden verwenden, um ähnlich anfällige Verträge zu identifizieren. Spezifische Details werden in den folgenden Unterabschnitten behandelt.
0x2.1.1 Typ-I: Nur Problem auf Code-Ebene (Problem 1.1)
Nur ein Vorfall gehört zu Typ-I, d.h. der CATOSHI-Vorfall, eine Nullkosten-Verbrennung, die die Gesamtzahl betrifft.
Werfen wir zunächst einen Blick auf die Funktion burnOf im CATOSHI-Vertrag:
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. _rOwned[msg.sender] sollte jedoch um rAmount reduziert werden, und wenn das Konto ausgeschlossen ist, sollte _tOwned[msg.sender] ebenfalls um tAmount reduziert werden.
Aufgrund dieses Versehen 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:

Die Rate kann durch Aufruf der Funktion reflect leicht nach unten manipuliert werden, wodurch sich balanceOf(attacker) erheblich erhöht. 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

Das 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
Vorfälle vom Typ Typ-II-a beinhalten die Kombination von zwei Problemen:
- Problem 1.2: Zusätzliche Abzüge von
rSupplywährend Token-Übertragungen. - Problem 2: AMM-Paar ist nicht ausgenommen.
Bei Typ-II-a gibt es drei Angriffs-Vorfälle, die weiter in zwei Unterkategorien unterteilt werden können, entsprechend den Schwachstellenformen in Problem 1.2, wie folgt:
1. Zusätzliche Reflexion in der _reflectFee Funktion
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 "Funktionen zur Token-Übertragung" (Abschnitt 0x1.2.2) erläutert, löst jede Token-Übertragung die Reflexion eines Teils der Transaktionsgebühr aus. In BEVO werden die Gebühren neben der ursprünglichen noch in zwei zusätzliche Teile unterteilt: Verbrennung und Spende.
function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
_rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rCharity 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 sehen, dass es zwei Stellen gibt, an denen der Spendenanteil reflektiert und verbrannt wird, was zu einer Inkonsistenz des tatsächlichen Token-Angebots mit der totalSupply während der Übertragungen führt. Mit zunehmender Anzahl von übertragenen Token ist der Wert von rSupply aufgrund des zusätzlichen Rückgangs geringer als das Angebot an Token im Pool.
Die rein theoretische Beschreibung mag etwas abstrakt sein, daher verwenden wir ein Beispiel zur Veranschaulichung des Prozesses. 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 die Verbrennung und 1 für die Spende. Da der Spendenanteil sowohl reflektiert als auch verbrannt wird, beträgt 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 zu übertragenden Token an Bob sind insgesamt 11 Token an diesem Prozess beteiligt, was fehlerhaft ist.
Aber warum kann diese Inkonsistenz ausgenutzt werden, um Gewinne zu erzielen? Im Folgenden werden wir unseren mathematischen Zauberstab schwingen, um die Folgen abzuleiten.
Angenommen, wir haben zuvor einige Token aus dem Pool (d.h. PancakeSwap-Paar) erworben, bezeichnet als rAmount im r-space und tAmount im t-space. Da der Pool nicht ausgenommen wurde, bezeichnen wir _rOwned[pair] als rReserve, mit dem entsprechenden Wert im t-space ebenfalls als tReserve bezeichnet. Dann haben wir:

Aufgrund des zusätzlichen Rückgangs ist rSupply jetzt geringer als das Angebot an Token im Pool:

Erinnern wir uns an den Abschnitt "Funktionen zur Guthabeneinsicht" (0x1.2.1), die aktuelle Rate kann mit der folgenden Formel berechnet werden:

Zu diesem Zeitpunkt, wenn wir die von uns gehaltenen Token über die Funktion reflect (die in diesem Vertrag in deliver umbenannt wurde) reflektieren, wird die Rate 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 Funktion skim) ernten können, noch größer ist als das, was wir geliefert haben, was profitabel ist, da die Kosten für den Aufruf der Funktion reflect gedeckt werden können. Danach kann der Angreifer die geernteten Token gegen wertvolle Token (in diesem Fall WBNB) tauschen, um einen Gewinn zu erzielen.
Beachten Sie, dass der BEVO-Vertrag auch anfällig für Problem 1.3 ist, das im Angriff nicht ausgenutzt wurde.
2. Falsche rTransferAmount-Berechnung in der _getRValues Funktion
Nur ein Vorfall gehört zu dieser Form, d.h. der ADU-Vorfall. Betrachten wir zunächst den folgenden Code-Schnipsel.
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); // Allerdings wird kein rTeam von rAmount abgezogen
return (rAmount, rTransferAmount, rFee);
}
Wir sehen, dass sowohl die Steuergebühr als auch die Teamgebühr bei Übertragungen 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 zuvor erwähnten Inkonsistenzproblem, das sich mit zunehmender Anzahl von Token-Übertragungen verschlimmert.
Da das Paar im Token ebenfalls nicht ausgenommen ist, ist dieser Token ausnutzbar. Insbesondere könnte ein Angreifer ähnliche BEVO-Exploits verwenden, um über die skim-Funktion des Paares mehr ADU-Token zu ernten, nachdem die deliver-Funktion aufgerufen wurde.
Angesichts des damaligen On-Chain-Status war es jedoch für den Angreifer 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
Vorfälle vom Typ Typ-II-b beinhalten die Kombination von zwei Problemen:
- Problem 1.3: Verwechslung zwischen r-space- und t-space-Werten (wobei zu beachten ist, dass auch ein Präzisionsverlust vorhanden sein muss, um Gewinne zu erzielen).
- Problem 2: AMM-Paar ist nicht ausgenommen.
Problem 1.3 ergibt sich aus der fehlerhaften Handhabung von Werten zwischen r-space und t-space während der Implementierung der internen Funktion _burn.
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 auch _rTotal von einem r-space-Wert abgezogen werden.
emit Transfer(_who, address(0), _value);
}
Unter Berücksichtigung der wesentlichen Konstanten des Vertrags ist der r-space-Wert typischerweise ein großes Vielfaches des t-space-Werts. Daher wird durch den Aufruf der burn-Funktion mit einem _value, der in der Größenordnung von tSupply liegt, die Rate erheblich aufgebläht.
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, für den Angreifer, mehr Token zu ernten. Wie konnten also die Fälle vom Typ II-b ausnutzbar sein?
Am Beispiel des SHEEP-Token-Vorfalls können die Werte der vom Angreifer gehaltenen SHEEP-Token wie folgt bezeichnet werden:

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

Dann kann der Wert weiter wie folgt ausgedrückt werden:

Der Angreifer führt dann wiederholt die burn-Funktion aus und synchronisiert schließlich das Paar. Da weder der Angreifer noch das Paar ausgenommen 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 eindeutig kleiner als das erstere, wenn wir 8 und 9 weiter vereinfachen, was uns wundert, da der Gewinn in diesem Fall ein negativer Wert wäre:

Tatsächlich nutzte der Angreifer ein Präzisionsverlust-Problem im Reflexionstoken aus. Für nicht ausgenommene Nutzer wird, gemäß der von uns bereitgestellten Formel, die Guthabenberechnung in der Funktion tokenFromReflection tatsächlich abgerundet. Daher 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 die theoretischen Guthaben des Angreifers und des Paares vor und nach diesen Manipulationen berechnen. Die Ergebnisse unserer Berechnungen sind in der folgenden Tabelle aufgeführt:
Δ in der Tabelle ist ein extrem kleiner Wert, weit unter 1.
Durch die Analyse des Trace innerhalb 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, was das Verhältnis auf 13,50 aufbläht. Folglich wird der Profit zu einem positiven Wert.
Schließlich kann der Angreifer durch einen umgekehrten Swap Gewinn erzielen.
0x2.2 Abnormale Sicherheitsvorfälle
In diesem Unterabschnitt teilen wir unsere Erkenntnisse aus der Untersuchung der FDP- und DBALL-Token mit. Unsere Analyse deutet darauf hin, dass die Manager beider FDP- und DBALL-Token problematische privilegierte Funktionen aufgerufen haben, die effektiv als Hintertüren fungierten und die Projekte gefährdeten und letztendlich zu Angriffen führten. Insbesondere haben wir im DBALL-Projekt eine Reihe von verdächtigen Transaktionen des Token-Besitzers identifiziert, die klare Beweise dafür liefern, dass es sich um einen Rug Pull handelt.
Die Ausnutzung dieser beiden Token ähnelt stark der in den "Typ-II-a: Kombination aus Problem 1.2 und Problem 2" im Abschnitt 0x2.1.2 beschriebenen. Bei der Analyse der Gründe, warum das tatsächliche Token-Angebot von der 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 der totalSupply im FDP-Fall beruht auf dem Aufruf der Funktion transferOwnership, einer privilegierten Funktion, die nur vom Vertragseigentümer aufgerufen werden kann. Wie der Name schon sagt, soll diese Funktion das Eigentum am Vertrag ändern. In dem FDP-Vertrag hat diese Funktion jedoch nichts mit der Eigentümerübertragung zu tun. Stattdessen erhöht sie _rOwned[newOwner], ohne die 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);
}
Transaktionen, die diese Funktion aufgerufen haben, sind in der folgenden Tabelle zusammengefasst:
| Zeitstempel | Transaktionshash | 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. Durch die Analyse des Fondsflusses des DBALL-Besitzers mit MetaSleuth beobachteten wir ein Ungleichgewicht im Zu- und Abfluss von DBALL-Token von dieser Adresse, wobei die Mittelherkunft für diese Transaktion nicht aufgezeichnet wurde.
Durch Abfrage 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 ist 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 Angabe einer Compiler-Version, die niedriger als 0.8 ist, tritt jedoch bei der Subtraktion von _rOwned[_msgSender()] ein arithmetischer Unterlauf auf, der von 0 auf fast type(uint256).max wechselt. 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 versehentlicher Fehler? Unsere Untersuchung deutet darauf hin, dass es sich eher um einen vorsätzlichen Rug Pull handelt. Die Gründe 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 Gesamtangebot entspricht, an eine zugehörige Adresse. -
Diese zugehörige Adresse hat sofort im PancakeSwap-Paar getauscht und erhielt ungefähr 56 WBNB.
- Die Analyse der Fondsflüsse dieser beiden Adressen zeigt, dass beide schließlich BNB über Tornado.Cash überwiesen haben.
0x3 Ein potenzielles Problem bei der Berechnung der Rate
Über die zuvor besprochenen Vorfälle hinaus haben wir auch festgestellt, dass theoretisch ein potenzielles Problem bei der Guthabenberechnung von nicht ausgenommenen Nutzern besteht, das weiter diskutiert werden sollte. Dieses Problem kann bei der Berechnung der Rate auftreten.
Werfen wir einen Blick auf die Funktion _getCurrentSupply. In dieser Funktion bestimmt die abschließende if-Anweisung, ob rSupply geringer ist als die anfängliche Rate (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 des Lebenszyklus von der Vertragsbereitstellung bis zur Projektveröffentlichung würden, wenn diese Aussage zutrifft, die Guthaben aller nicht ausgenommenen Nutzer Null sein. Sobald das Projekt jedoch gestartet und Transaktionen begonnen haben, ging die ursprüngliche Absicht der if-Anweisung verloren.
Da rSupply aufgrund des Reflexionstoken-Mechanismus abnimmt, nimmt die Rate entsprechend ab. Wenn nach einer bestimmten Transaktion rSupply unter die anfängliche Rate fällt, springt die aktuelle Rate an, was zu Guthabenverlusten für alle nicht ausgenommenen Nutzer führt. Außerdem ist es theoretisch möglich, dass

Dies führt dazu, dass die Rate aufgrund von Präzisionsverlust Null wird und möglicherweise eine Division-durch-Null-Panik auslöst.
0x4 Minderung und Lösungen
Der Reflexionstoken-Mechanismus bietet eine Möglichkeit, die Marktstabilität zu verbessern, indem er Anleger dazu anregt, ihre Token zu halten und nicht 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 für Blockchain-Entwickler und Anleger entscheidend, ein besseres Verständnis des Mechanismus und seiner potenziellen Risiken zu erlangen und nach Lösungen zu suchen.
BlockSec bietet Sicherheitsdienste und -produkte für die Phasen vor und nach der Markteinführung. Unsere Sicherheitsaudit-Dienste führen gründliche Überprüfungen durch, um die Code-Sicherheit und Transparenz zu gewährleisten. Unser Phalcon-Produkt bietet kontinuierliche Sicherheitsüberwachung und Angreifererkennungsfunktionen, die es Betreibern und Anlegern ermöglichen, Projekte zu überwachen und automatische Maßnahmen bei Erkennung von Sicherheitsrisiken zu ergreifen.
Verwandte Lektüre
- Wie L2-Blockchains ihre Nutzer besser schützen können
- Top 10 "Awesome" Sicherheitsvorfälle im Jahr 2023
- Konzeptionelle vollständige Analyse eines Aufstiegs von Bitcoin mit Inschriften
Über BlockSec
BlockSec ist ein Full-Stack-Anbieter von Web3-Sicherheitsdiensten. Das Unternehmen engagiert sich für die Verbesserung von Sicherheit und Benutzerfreundlichkeit für die aufkommende Web3-Welt, um deren massenhafte Akzeptanz zu fördern. Zu diesem Zweck bietet BlockSec Sicherheitsprüfungsdienste für Smart Contracts und EVM-Ketten, die Phalcon-Plattform für die Sicherheitsentwicklung und die proaktive Abwehr von Bedrohungen, die MetaSleuth-Plattform für die Verfolgung und Untersuchung von Fonds sowie die MetaSuites-Erweiterung für Web3-Entwickler, um effizient in der Krypto-Welt zu surfen.
Bis heute hat das Unternehmen über 300 Kunden wie die Uniswap Foundation, Compound, Forta und PancakeSwap betreut und in zwei Finanzierungsrunden von namhaften Investoren wie Matrix Partners, Vitalbridge Capital und Fenbushi Capital zehn Millionen US-Dollar erhalten.
-
Website: https://blocksec.com/
-
E-Mail: contact@blocksec.com
-
Twitter: https://twitter.com/BlockSecTeam
-
MetaSleuth: https://metasleuth.io/
-
MetaSuites: https://blocksec.com/metasuites



