Aktualisiert am 14. Juni 2024: Ein Community-Mitglied hat diesen Blog sorgfältig geprüft und Informationen über den ADU-Vorfall bereitgestellt, der eine neue Form darstellt, die in unserer vorherigen Kategorisierung nicht erfasst war. Danke, und alle aufschlussreichen Rückmeldungen sind willkommen!
Um die Marktstabilität zu verbessern, wurden Reflection Token (auch bekannt als Reward Token) entwickelt, um Investoren eine zusätzliche Möglichkeit zur Einkommenserzielung zu bieten. Dies ermutigt Investoren, ihre Token zu halten, anstatt sie zu handeln. Während der berüchtigten Meme-Coin-Saison 2021 wurden Reflection Token zu einem unverzichtbaren Mechanismus, der nach seiner Einführung auf Plattformen wie DxSale (z. B. SafeMoon V1) schnell die Aufmerksamkeit des Marktes auf sich zog.
Obwohl der Hype nachließ und sich der Markt 2023 abkühlte, entdeckte unser System zehntausende Hack-Vorfälle in freier Wildbahn, die diese Token-Mechanismen ausnutzten. Diese Reaper-artigen Angriffe führten zwar im Vergleich zu anderen Arten von DeFi-Angriffen zu relativ geringen Beträgen, verursachten jedoch nicht zu vernachlässigende Verluste an Nutzervermögen.
In diesem Blog liegt unser Hauptaugenmerk darauf, sicherheitsrelevante Erkenntnisse aus unserer Recherche zu teilen. Konkret geben wir zunächst eine kurze Einführung in den Mechanismus des Reflection Tokens. Anschließend werden wir Sicherheitsvorfälle im Zusammenhang mit Reflection Token überprüfen, mit einem Fokus auf jene, die den Reflection-Token-Mechanismus ausnutzen. Dann werden wir theoretisch ein potenzielles Sicherheitsproblem diskutieren. Abschließend teilen wir einige Gedanken zu Abhilfemaßnahmen und Lösungen.
0x1 Mechanismus des Reflection Tokens
Nach unserem Kenntnisstand wurde dieser Mechanismus erstmals von Reflect Finance eingeführt, der darauf ausgelegt ist, einen Prozentsatz des Transaktionsbetrags als Gebühren auf nicht-transaktionale Weise an alle Token-Inhaber zu verteilen. Im März 2021 wurde das bekannte SafeMoon V1 auf der BNB-Chain veröffentlicht, was den Reflection Token weiter popularisierte.
0x1.1 Grundlegende Konzepte
Bevor wir in die Details eintauchen, 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, gelesen als reflektierter Raum und wahrer Raum. Die Kryptowährungen der beiden Räume haben Wechselkurse, die auf dem relativen Umlaufvolumen basieren. Außerdem ist die Währung im r-Space deflationär, das heißt, bei jeder Transaktion wird ein bestimmter Prozentsatz verbrannt, wodurch der verbrannte Betrag vom Umlaufvolumen abgezogen wird.
Angenommen, Alice, Bob und Eve können alle Transaktionen in beiden Räumen durchführen, wie in der nachstehenden Abbildung dargestellt. Wenn Alice und Bob paarweise Überweisungen im t-Space vornehmen, erhält Eve keine Belohnungen. Wenn jedoch alle drei zunächst ihre Token in den r-Space konvertieren und dann Alice und Bob sich gegenseitig überweisen, wird Eve schließlich ein passives Einkommen erzielen, indem sie ihre Token zurück in den t-Space konvertiert. Das ist die Grundidee des Reflection-Token-Mechanismus.
Zu beachten ist, dass nicht alle Konten, wie beispielsweise der Liquiditätspool des Tokens, in der Lage sind, im r-Space zu handeln, d. h. bestimmte Konten müssen vom r-Space ausgeschlossen werden.
0x1.2 Erklärung auf Vertragsebene
Schauen wir uns nun den REFLECT-Vertrag von Reflect Finance genauer an, um diesen Mechanismus zu erkunden.
Dieser Vertrag definiert zunächst mehrere Variablen für die Kontenverwaltung:
mapping (address => uint256) private _rOwned; // reflected token held by user
mapping (address => uint256) private _tOwned; // true token held by user
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // if user is excluded from r-space
address[] private _excluded; // accounts that are excluded from r-space
Anschließend definiert er die wesentlichen Konstanten des Vertrags. Es lässt sich erkennen, dass _rTotal auf ein bestimmtes Vielfaches von _tTotal gesetzt wird (d. h. der totalSupply des Tokens, der als Rückgabewert der Funktion totalSupply verwendet wird):
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 der Nutzerinteraktion lassen sich die Funktionen dieses Vertrags in folgende drei Kategorien einteilen: Guthabenabfrage und Token-Übertragung sowie eine besondere Reflect-Funktion. Die ersten beiden sind mit dem ERC-20-Standard kompatibel, jedoch unterscheidet sich die interne Logik von anderen ERC-20-Token. Jede dieser Funktionen wird im Folgenden näher erläutert.
0x1.2.1 Funktionen zur Guthabenabfrage
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 lässt sich mit folgender Formel ausdrücken:

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 schwer, aus dem obigen Codeausschnitt die entsprechende Formel abzuleiten:

0x1.2.2 Funktionen für die 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
}
}
Bei ausgeschlossenen Konten sollten sowohl _rOwned als auch _tOwned im jeweiligen Raum hinzugefügt oder abgezogen werden. Bei nicht-ausgeschlossenen Konten muss nur _rOwned berücksichtigt werden. Der folgende Codeausschnitt zeigt beispielsweise 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. Konkret wird_rTotalum die Gebühr im r-Space (d. h.rFee) reduziert, was wiederum die Rate senkt. Gemäß der Berechnungsformel balanceOf(user) werden 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
Zusätzlich zur passiven Auslösung des Reflection-Token-Mechanismus während des Übertragungsprozesses können Nutzer die Funktion reflect aktiv aufrufen, um diesen Mechanismus zu starten. Konkret gilt: Wenn man die gehaltenen Token verbraucht, um diese Funktion aufzurufen, sinkt die Rate, da rSupply abnimmt, wodurch anderen Token-Inhabern ein Vorteil verschafft wird. Mit anderen Worten: Man opfert sich zugunsten anderer. Auf diese Weise können Projektbetreiber Token-Inhaber durch die Nutzung dieser Funktion incentivieren.
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);
}
Der oben angegebene Code bildet den Kern des Mechanismus. Unterschiedliche Token können spezifische zusätzliche Funktionen enthalten, um ihre Implementierung anzupassen. Beispielsweise können manche Transaktionsgebühren nutzen, um eine „Swap-and-liquify“-Funktion anzutreiben, um Panikverkäufe zu verhindern, wenn Whales beschließen, ihre Token zu verkaufen.
0x2 Post-Mortem-Analyse von gehackten Reflection Token
Wie bereits erwähnt, liegt unser Hauptaugenmerk auf Angriffen, die den Reflection-Token-Mechanismus ausnutzen. Daher werden Vorfälle, die nichts mit diesem Mechanismus zu tun haben, wie der SafeMoon-V2-Angriff (ein gewöhnliches ERC20-Public-Burn-Problem) und der jüngste ZongZi-Angriff (im Zusammenhang mit einer altmodischen Preismanipulation unter Ausnutzung des Spotpreises), nicht behandelt.
Wir haben diese Angriffe eingehend analysiert, um ihre Grundursachen aufzudecken. Eine Liste aller dieser Vorfälle finden Sie hier. Wir haben festgestellt, dass die meisten davon normale Sicherheitsvorfälle sind, die entweder durch Code-Schwachstellen oder unsachgemäße administrative Vorgänge verursacht wurden. Einige wenige sind jedoch recht verdächtig (z. B. das Vorhandensein einer Backdoor), die wir als abnormale Sicherheitsvorfälle bezeichnen. In den folgenden Unterabschnitten stellen wir zunächst die normalen Sicherheitsvorfälle vor und gehen dann im Detail auf die abnormalen ein.
0x2.1 Normale Sicherheitsvorfälle
Unsere Untersuchung deutet darauf hin, dass diese Vorfälle entweder einzeln oder in Kombination auf zwei Arten von Problemen zurückzuführen sind, nämlich Probleme auf Code-Ebene und Probleme auf Betriebsebene.
-
Probleme auf Code-Ebene. Dies entsteht durch eine schlechte Implementierung des Vertrags, vermutlich weil Entwickler den Mechanismus des Reflection Tokens nicht vollständig verstanden haben, was zu einer Inkonsistenz zwischen dem tatsächlichen Token-Bestand und dem aufgezeichneten Wert von
totalSupplyführt, was zur Manipulation der Rate genutzt werden kann:-
1.1 Zero-Cost-Burn (Verbrennung ohne Kosten)
-
1.2 Zusätzlicher Abzug von
rSupplywährend Token-Übertragungen -
1.3 Vermischung von r-Space- und t-Space-Werten (mit Präzisionsverlust zur Gewinnerzielung)
-
-
Probleme auf Betriebsebene. Dies resultiert aus unsachgemäßem Vorgehen von Administratoren. Konkret bezieht sich dies in diesen Vorfällen auf die falsche Konfiguration der AMM-Pair-Adressen, die nicht ordnungsgemäß ausgeschlossen wurden.
Es ist erwähnenswert, dass ALLE aufgeführten Probleme auf Code-Ebene zu Inkonsistenzen zwischen dem tatsächlichen Bestand und totalSupply führen können, wodurch die Verträge anfällig werden. Dies bedeutet jedoch nicht zwangsläufig, dass diese Schwachstellen ausnutzbar oder, genauer gesagt, profitabel genug sind, um eine Ausnutzung lohnenswert zu machen, da für den Angreifer Kosten entstehen können, um die Rate zu manipulieren. Der Einfachheit halber verwenden wir im Folgenden den Begriff „ausnutzbar“ im Sinne von „profitabel genug, um eine Ausnutzung lohnenswert zu machen“. Daher ist in manchen Szenarien ein Problem auf Betriebsebene erforderlich, um diese Schwachstellen ausnutzbar zu machen.
Konkret 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 lassen sich die diskutierten Vorfälle basierend auf diesen Beobachtungen in zwei Typen einteilen, Typ I und Typ II. Nachfolgend finden Sie eine Tabelle mit den relevanten Daten:
| Typ | Vorfall/Vorfälle | Grundursache(n) | # (%) |
|---|---|---|---|
| I | CATOSHI | Nur Problem auf Code-Ebene (1.1) | 1 (0,79 %) |
| II (a) | BEVO, FETA, ADU | Kombination beider (1.2 & 2) | 3 (2,38 %) |
| II (b) | SHEEP und 120+ Weitere | Kombination beider (1.3 & 2) | 122 (96,83 %) |
Die Tabelle zeigt, dass Typ-II-Vorfälle einen erheblichen Anteil ausmachen. Konkret gibt es innerhalb von Typ II 2 Varianten: 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 bei SHEEP-ähnlichen Vorfällen der Kategorie Typ-II-b darauf hin, dass Angreifer (z. B. dieser hier) möglicherweise automatisierte Methoden zur Identifizierung ähnlich anfälliger Verträge verwenden. Spezifische Details werden in den folgenden Unterabschnitten untersucht.
0x2.1.1 Typ I: Nur Problem auf Code-Ebene (Problem 1.1)
Nur ein Vorfall gehört zu Typ I, nämlich der CATOSHI-Vorfall, ein Zero-Cost-Burn, der den Gesamtbestand betraf.
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. Eigentlich sollte jedoch _rOwned[msg.sender] um rAmount reduziert werden, und wenn das Konto ausgeschlossen ist, sollte auch _tOwned[msg.sender] um tAmount reduziert werden.
Aufgrund dieses Versehens können Angreifer zunächst kostenlos eine große Menge an Token verbrennen und anschließend die Funktion reflect des Vertrags aufrufen. Da sowohl _tTotal als auch _rTotal proportional erheblich reduziert wurden:

Die Rate kann durch Aufruf der Funktion reflect leicht nach unten manipuliert werden, wodurch balanceOf(attacker) erheblich ansteigt. Dies ermöglicht es Angreifern, vom 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

Somit

Das bedeutet, dass der Angreifer mehr Token einsammelt, die gegen wertvolle Token (in diesem Fall WETH) getauscht werden können, um Gewinne zu erzielen.
0x2.1.2 Typ-II-a: Kombination von 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: Das AMM-Pair ist nicht ausgeschlossen.
Bei Typ-II-a gibt es drei Angriffsvorfälle, die sich entsprechend den Schwachstellenformen bei Problem 1.2 weiter in zwei Unterkategorien einteilen lassen, wie folgt:
1. Zusätzliche Reflexion in der Funktion _reflectFee
Zu dieser Unterkategorie gehören zwei Vorfälle, nämlich der BEVO-Vorfall und der FETA-Vorfall. Im Folgenden werden wir zur Veranschaulichung den BEVO-Vertrag verwenden.
Wie im Abschnitt „Funktionen für die Token-Übertragung“ (Abschnitt 0x1.2.2) beschrieben, löst jede Token-Übertragung die Reflexion eines Teils der Transaktionsgebühr aus. Bei BEVO wird die Gebühr, abgesehen von der ursprünglichen, in zwei zusätzliche Teile aufgeteilt: Burn und Charity.
function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
_rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rChairty is deducted from _rTotal
_tFeeTotal = _tFeeTotal.add(tFee);
_tBurnTotal = _tBurnTotal.add(tBurn);
_tCharityTotal = _tCharityTotal.add(tCharity);
_tTotal = _tTotal.sub(tBurn);
}
Zu beachten ist, dass das Charity-Konto 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); // since charity account is excluded, the charity part is burned
emit Transfer(sender, currentCharity, tCharity);
}
Aus dem obigen Codeausschnitt können wir erkennen, dass es zwei Stellen gibt, an denen der Charity-Anteil reflektiert und verbrannt wird, was dazu führt, dass der tatsächliche Token-Bestand während der Übertragungen mit totalSupply inkonsistent wird. Je mehr Token übertragen werden, desto geringer wird der Wert von rSupply im Vergleich zum Bestand an Token im Pool aufgrund der zusätzlichen Abnahme.
Die rein theoretische Beschreibung mag etwas abstrakt sein, daher wollen wir den Prozess anhand eines Beispiels verdeutlichen. Angenommen, Alice möchte Bob 10 Token übertragen, und es werden 3 Token wie folgt abgezogen: 1 für die Gebühr, 1 für den Burn und 1 für Charity. Da der Charity-Anteil sowohl reflektiert als auch verbrannt wird, sieht die tatsächliche Aufteilung so aus: 2 Token reflektiert (1 Gebühr + 1 Charity) und 2 Token verbrannt (1 Burn + 1 Charity). Zusammen mit den verbleibenden 7 Token, die an Bob übertragen werden, sind insgesamt 11 Token an diesem Prozess beteiligt, was fehlerhaft ist.
Doch warum kann diese Inkonsistenz ausgenutzt werden, um Gewinne zu erzielen? Im Folgenden werden wir unseren mathematischen Zauberstab schwingen, um die Konsequenzen abzuleiten.
Angenommen, wir haben zuvor einige Token aus dem Pool (d. h. dem PancakeSwap-Pair) erworben, bezeichnet als rAmount im r-Space und tAmount im t-Space. 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 gilt:

Aufgrund der zusätzlichen Abnahme ist rSupply nun geringer als der Bestand an Token im Pool:

Erinnern wir uns an den Abschnitt „Funktionen zur Guthabenabfrage“ (0x1.2.1): Die aktuelle Rate lässt sich mit folgender Formel berechnen:

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

Da

Dann gilt

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

Das bedeutet, dass die Anzahl der Token, die wir direkt aus dem Pool ernten können (über die Funktion skim), sogar größer ist als das, was wir geliefert haben, was es profitabel macht, da die Kosten für den Aufruf der Funktion reflect gedeckt werden können. Danach kann der Angreifer die eingesammelten Token gegen wertvolle Token (in diesem Fall WBNB) tauschen, um Gewinn zu erzielen.
Zu beachten ist, dass der BEVO-Vertrag auch für Problem 1.3 anfällig ist, was bei dem Angriff jedoch nicht ausgenutzt wurde.
2. Fehlerhafte rTransferAmount-Berechnung in der Funktion _getRValues
Nur ein Vorfall gehört zu dieser Form, nämlich der ADU-Vorfall. Schauen wir uns zunächst den folgenden Codeausschnitt 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 is deducted from tAmount
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); // However, there is no rTeam deducted from rAmount
return (rAmount, rTransferAmount, rFee);
}
Wir können erkennen, dass bei Übertragungen sowohl die Steuergebühr als auch die Team-Gebühr abgezogen werden sollten. In der Funktion _getTvalues wird tTransferAmount jedoch sowohl um tFee als auch um tTeam reduziert, während in der Funktion _getRValues nur rFee abgezogen wird. Diese Diskrepanz führt zu dem zuvor erwähnten Inkonsistenzproblem, das sich mit zunehmenden Token-Übertragungen verschlimmert.
Da das Pair im Token ebenfalls nicht ausgeschlossen ist, ist dieser Token ausnutzbar. Konkret könnte ein Angreifer ähnliche BEVO-Exploits verwenden, um nach Aufruf der Funktion deliver über die skim-Funktion des Pairs mehr ADU-Token einzusammeln.
Angesichts des damaligen On-Chain-Status ist es dem Angreifer jedoch unmöglich, die eingesammelten ADU-Token gegen WBNB zu tauschen, um Gewinn zu erzielen (aufgrund der require-Anweisung in der Funktion tokenFromReflection). Daher müsste der Angreifer eine komplexere Ausnutzungsstrategie anwenden, um Gewinn zu erzielen, was 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 von Problem 1.3 und Problem 2
Typ-II-b-Vorfälle beinhalten die Kombination zweier Probleme:
- Problem 1.3: Vermischung von r-Space- und t-Space-Werten (wobei auch ein Präzisionsverlust vorliegen muss, um Gewinne zu erzielen).
- Problem 2: Das AMM-Pair ist nicht ausgeschlossen.
Problem 1.3 rührt von der fehlerhaften Handhabung der Werte zwischen r-Space und t-Space bei der Implementierung der internen Funktion _burn her.
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 should be minus a r-space value
_tTotal = _tTotal.sub(_value); // _tTotal should be minus a t-space value
// For the semantics of the burn function, _rTotal should also be subtracted from a r-space value.
emit Transfer(_who, address(0), _value);
}
Angesichts der wesentlichen Konstanten des Vertrags ist der r-Space-Wert typischerweise ein großes Vielfaches des t-Space-Werts. Der Aufruf der Funktion burn mit einem _value, der in derselben Größenordnung wie tSupply liegt, wird daher die Rate erheblich aufblähen.
Anders als bei den Typ-I-Fällen kann der Aufrufer jedoch keine Token verbrennen, während sein eigenes Guthaben unverändert bleibt. Mit anderen Worten: Es ist für den Angreifer schwierig, wenn nicht gar unmöglich, mehr Token einzusammeln. Wie können also die Typ-II-b-Fälle ausnutzbar sein?
Nehmen wir den SHEEP-Token-Vorfall als Beispiel. Der Wert der vom Angreifer gehaltenen SHEEP-Token lässt sich wie folgt bezeichnen:

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

Dann lässt sich der Value weiter ausdrücken als:

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

Ausgehend von 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 ins Spiel: Das letztere Verhältnis ist eindeutig kleiner als das erstere, wenn wir 8 und 9 weiter vereinfachen, was uns ratlos zurücklässt, da der Gewinn in diesem Fall negativ ausfallen würde:

Tatsächlich nutzte der Angreifer ein Präzisionsverlust-Problem beim Reflection Token aus. Bei nicht-ausgeschlossenen Nutzern wird die Guthabenberechnung gemäß der von uns bereitgestellten Formel in der Funktion tokenFromReflection tatsächlich abgerundet. Somit kann der Rückgabewert der balanceOf-Abfrage kleiner sein als der theoretische Wert. Das heißt, das ratio' kann größer sein als das ratio, 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 Debugging der Angriffstransaktion können wir das theoretische Guthaben des Angreifers und des Pairs vor und nach diesen Manipulationen berechnen. Die Ergebnisse unserer Berechnungen sind in der folgenden Tabelle dargestellt:
Δ in der Tabelle ist ein extrem kleiner Wert, weit unter 1.
Durch Analyse des Traces innerhalb der sync-Funktion des Pairs können wir berechnen, dass die theoretischen Guthaben des Angreifers und des Pairs 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 Profit zu einem positiven Wert.
Schließlich kann der Angreifer durch die Durchführung eines umgekehrten Swaps Gewinn erzielen.
0x2.2 Abnormale Sicherheitsvorfälle
In diesem Unterabschnitt teilen wir unsere Erkenntnisse aus der Untersuchung der FDP- und DBALL-Token. Unsere Analyse zeigt, dass die Verwalter sowohl des FDP- als auch des DBALL-Tokens problematische privilegierte Funktionen aufgerufen haben, die effektiv als Backdoors fungierten, wodurch die Projekte gefährdet wurden und letztlich zu Angriffen führten. Konkret haben wir beim DBALL-Projekt eine Reihe verdächtiger Transaktionen des Token-Eigentümers identifiziert, die klare Beweise dafür liefern, dies als Rug Pull zu betrachten.
Die Ausnutzungen, die auf diese beiden Token abzielten, ähneln stark denen, die im Abschnitt „Typ-II-a: Kombination von Problem 1.2 und Problem 2“ unter 0x2.1.2 beschrieben wurden. Bei der Analyse der Gründe dafür, warum der tatsächliche Token-Bestand von totalSupply abweichen kann, kommen jedoch einige verdächtige Aktivitäten ans Licht.
0x2.2.1 Der FDP-Vorfall
Die Diskrepanz zwischen dem tatsächlichen Token-Bestand und totalSupply beim FDP-Fall rührt vom Aufruf der Funktion transferOwnership her, einer privilegierten Funktion, die nur vom Vertragseigentümer aufgerufen werden kann. Wie der Name schon sagt, soll diese Funktion die Eigentümerschaft des Vertrags ändern. Im FDP-Vertrag hat diese Funktion jedoch nichts mit der Übertragung der Eigentümerschaft 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);
}
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
Im DBALL-Fall wird die Sache kniffliger. Bei der Analyse des Kapitalflusses des DBALL-Eigentümers mit MetaSleuth haben wir ein Ungleichgewicht zwischen Zu- und Abflüssen von DBALL-Token an dieser Adresse beobachtet, wobei die Herkunft der Mittel für diese Transaktion nicht dokumentiert ist.
Durch Abfrage der historischen Zustände auf der Chain konnten wir schließlich feststellen, dass sich das DBALL-Guthaben des Eigentümers vor und nach dieser Transaktion geändert hat. Wir können beobachten, dass der Eigentümer die privilegierte Funktion manualDevBurn aufgerufen hat, um 1 Token im t-Space zu verbrennen. Die Implementierung dieser Funktion sieht wie folgt aus:
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. Da der Vertrag jedoch eine Compiler-Version unter 0.8 spezifiziert, tritt während der Subtraktion von _rOwned[_msgSender()] ein arithmetischer Unterlauf auf, der von 0 auf nahezu type(uint256).max übergeht. Diese subtile Manipulation ermöglicht es dem Eigentümer, sein Guthaben zu verändern, führt aber auch zu einer Inkonsistenz im Token-Bestand.
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 dafür sind wie folgt zusammengefasst:
-
Der Eigentümer übergab nur 1 Token an die Funktion
manualDevBurn, doch innerhalb einer halben Stunde wurde über diese Transaktion eine Menge an DBALL, die dem Gesamtbestand entsprach, an eine verbundene Adresse übertragen. -
Diese verbundene Adresse tauschte sofort im PancakeSwap-Pair und erhielt etwa 56 WBNB.
- Die Analyse der Kapitalflüsse dieser beiden Adressen zeigt, dass beide letztlich BNB über Tornado.Cash übertragen 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 nicht-ausgeschlossener Nutzer besteht, das weitere Diskussion verdient. Dieses Problem kann während der Berechnung der Rate auftreten.
Schauen wir uns die Funktion _getCurrentSupply an. In dieser Funktion bestimmt die abschließende if-Anweisung, ob rSupply kleiner als die anfängliche Rate ist (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 zum Projektstart würden, wenn diese Anweisung wahr ist, die Guthaben aller nicht-ausgeschlossenen Nutzer null betragen. Sobald das Projekt jedoch gestartet wurde und Transaktionen begannen, ging die ursprüngliche Absicht der if-Anweisung verloren.
Da rSupply aufgrund des Reflection-Token-Mechanismus abnimmt, sinkt die Rate entsprechend. Wenn rSupply nach einer bestimmten Transaktion unter die anfängliche Rate fällt, springt die aktuelle Rate, was zu Guthabenverlusten für alle nicht-ausgeschlossenen Nutzer führt. Zudem ist es theoretisch möglich, dass

Dies führt dazu, dass die Rate aufgrund von Präzisionsverlust null wird, was möglicherweise eine Division-durch-Null-Panik auslöst.
0x4 Abhilfemaßnahmen und Lösungen
Der Reflection-Token-Mechanismus bietet eine Möglichkeit, die Marktstabilität zu verbessern, indem Investoren dazu angeregt werden, ihre Token zu halten, anstatt sie zu handeln, um zusätzliche Belohnungen zu erhalten. Er bringt jedoch auch neue Sicherheitsherausforderungen und potenzielle Risiken mit sich, wie beispielsweise die Vermischung von r-Space- und t-Space-Werten. Daher ist es für Blockchain-Entwickler und Investoren von entscheidender Bedeutung, ein besseres Verständnis des Mechanismus und seiner potenziellen Risiken zu erlangen und nach Lösungen zu suchen.
BlockSec bietet Sicherheitsdienstleistungen und -produkte sowohl für die Phase vor als auch nach dem Launch an. Unsere Sicherheitsaudit-Dienstleistungen führen gründliche Überprüfungen durch, um Code-Sicherheit und Transparenz zu gewährleisten. Unser Phalcon-Produkt bietet kontinuierliche Sicherheitsüberwachung und Angriffserkennungsfunktionen, die es Betreibern und Investoren ermöglichen, Projekte zu überwachen und automatische Maßnahmen zu ergreifen, wenn Sicherheitsrisiken erkannt werden.
Verwandte Lektüre
- How L2 Blockchains Can Do Better to Protect Their Users
- Top 10 "Awesome" Security Incidents in 2023
- Conceptual Full Analysis A Rise Of Bitcoin With Inscription
Über BlockSec
BlockSec ist ein Full-Stack-Web3-Sicherheitsdienstleister. Das Unternehmen setzt sich dafür ein, die Sicherheit und Nutzbarkeit für die aufstrebende Web3-Welt zu verbessern, um deren Massenakzeptanz zu fördern. Zu diesem Zweck bietet BlockSec Sicherheitsauditing-Dienstleistungen für Smart Contracts und EVM-Chains, die Phalcon-Plattform für sicherheitsorientierte Entwicklung und proaktive Bedrohungsabwehr, die MetaSleuth-Plattform für Kapitalverfolgung und Untersuchungen sowie die MetaSuites-Erweiterung, mit der Web3-Entwickler effizient in der Krypto-Welt navigieren können.
Bislang hat das Unternehmen über 1.000 Kunden betreut, darunter Uniswap Foundation, Compound, Forta und PancakeSwap, und in zwei Finanzierungsrunden zweistellige Millionenbeträge in US-Dollar von namhaften Investoren erhalten, darunter Matrix Partners, Vitalbridge Capital und Fenbushi Capital.
-
Website: https://blocksec.com/
-
E-Mail: [email protected]
-
Twitter:https://twitter.com/BlockSecTeam
-
MetaSleuth: https://metasleuth.io/
-
MetaSuites: https://blocksec.com/metasuites



