Einige Tricks von Scam-Token und wie man sie erkennt
In diesem Tutorial analysieren wir einen Scam-Token (opens in a new tab), um einige der Tricks von Betrügern zu sehen und wie sie diese implementieren. Am Ende des Tutorials werden Sie ein umfassenderes Verständnis von ERC-20-Token-Verträgen und deren Fähigkeiten haben und wissen, warum Skepsis notwendig ist. Anschließend betrachten wir die von diesem Scam-Token ausgegebenen Ereignisse und sehen, wie wir automatisch erkennen können, dass er nicht legitim ist.
Scam-Token – was sie sind, warum Leute sie erstellen und wie man sie vermeidet
Eine der häufigsten Anwendungen für Ethereum ist, dass eine Gruppe einen handelbaren Token erstellt, gewissermaßen ihre eigene Währung. Wo es jedoch legitime Anwendungsfälle gibt, die Wert schaffen, gibt es auch Kriminelle, die versuchen, diesen Wert für sich selbst zu stehlen.
Sie können mehr über dieses Thema aus der Benutzerperspektive an anderer Stelle auf ethereum.org lesen. Dieses Tutorial konzentriert sich auf die Analyse eines Scam-Tokens, um zu sehen, wie es gemacht wird und wie es erkannt werden kann.
Woher weiß ich, dass wARB ein Betrug ist?
Der Token, den wir analysieren, ist wARB (opens in a new tab), der vorgibt, dem legitimen ARB-Token (opens in a new tab) gleichwertig zu sein.
Der einfachste Weg, um zu wissen, welcher der legitime Token ist, besteht darin, sich die Ursprungsorganisation, Arbitrum (opens in a new tab), anzusehen. Die legitimen Adressen sind in ihrer Dokumentation (opens in a new tab) angegeben.
Warum ist der Quellcode verfügbar?
Normalerweise würden wir erwarten, dass Leute, die versuchen, andere zu betrügen, geheimnisvoll sind, und tatsächlich ist der Code vieler Scam-Token nicht verfügbar (zum Beispiel dieser hier (opens in a new tab) und dieser hier (opens in a new tab)).
Legitime Token veröffentlichen jedoch normalerweise ihren Quellcode, um also legitim zu erscheinen, tun die Autoren von Scam-Token manchmal dasselbe. wARB (opens in a new tab) ist einer dieser Token mit verfügbarem Quellcode, was es einfacher macht, ihn zu verstehen.
Während Bereitsteller von Verträgen wählen können, ob sie den Quellcode veröffentlichen oder nicht, können sie nicht den falschen Quellcode veröffentlichen. Der Block-Explorer kompiliert den bereitgestellten Quellcode unabhängig, und wenn er nicht genau denselben Bytecode erhält, lehnt er diesen Quellcode ab. Sie können mehr darüber auf der Etherscan-Website lesen (opens in a new tab).
Vergleich mit legitimen ERC-20-Token
Wir werden diesen Token mit legitimen ERC-20-Token vergleichen. Wenn Sie nicht damit vertraut sind, wie legitime ERC-20-Token typischerweise geschrieben werden, sehen Sie sich dieses Tutorial an.
Konstanten für privilegierte Adressen
Verträge benötigen manchmal privilegierte Adressen. Verträge, die für eine langfristige Nutzung ausgelegt sind, ermöglichen es einer privilegierten Adresse, diese Adressen zu ändern, beispielsweise um die Nutzung eines neuen Multisig-Vertrags zu ermöglichen. Es gibt mehrere Möglichkeiten, dies zu tun.
Der HOP-Token-Vertrag (opens in a new tab) verwendet das Ownable (opens in a new tab)-Muster. Die privilegierte Adresse wird im Speicher aufbewahrt, in einem Feld namens _owner (siehe die dritte Datei, Ownable.sol).
abstract contract Ownable is Context {
address private _owner;
.
.
.
}
Der ARB-Token-Vertrag (opens in a new tab) hat nicht direkt eine privilegierte Adresse. Er benötigt jedoch auch keine. Er befindet sich hinter einem proxy (opens in a new tab) an der Adresse 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens in a new tab). Dieser Vertrag hat eine privilegierte Adresse (siehe die vierte Datei, ERC1967Upgrade.sol), die für Upgrades verwendet werden kann.
/**
* @dev Speichert eine neue Adresse im EIP1967-Admin-Slot.
*/
function _setAdmin(address newAdmin) private {
require(newAdmin != address(0), "ERC1967: new admin is the zero address");
StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
}
Im Gegensatz dazu hat der wARB-Vertrag einen fest codierten contract_owner.
contract WrappedArbitrum is Context, IERC20 {
.
.
.
address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;
address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;
.
.
.
}
Dieser Vertragsbesitzer (opens in a new tab) ist kein Vertrag, der zu verschiedenen Zeiten von verschiedenen Konten kontrolliert werden könnte, sondern ein externes Konto. Das bedeutet, dass er wahrscheinlich für die kurzfristige Nutzung durch eine Einzelperson konzipiert ist und nicht als langfristige Lösung zur Kontrolle eines ERC-20, der wertvoll bleiben soll.
Und tatsächlich, wenn wir in Etherscan nachsehen, sehen wir, dass der Betrüger diesen Vertrag nur 12 Stunden lang (erste Transaktion (opens in a new tab) bis letzte Transaktion (opens in a new tab)) am 19. Mai 2023 genutzt hat.
Die gefälschte _transfer-Funktion
Es ist Standard, dass tatsächliche Transfers über eine interne _transfer-Funktion erfolgen.
In wARB sieht diese Funktion fast legitim aus:
function _transfer(address sender, address recipient, uint256 amount) internal virtual{
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
_balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
_balances[recipient] = _balances[recipient].add(amount);
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
}
Der verdächtige Teil ist:
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
Wenn der Vertragsbesitzer Token sendet, warum zeigt das Transfer-Ereignis dann, dass sie von deployer kommen?
Es gibt jedoch ein wichtigeres Problem. Wer ruft diese _transfer-Funktion auf? Sie kann nicht von außen aufgerufen werden, sie ist als internal markiert. Und der uns vorliegende Code enthält keine Aufrufe von _transfer. Offensichtlich ist sie hier als Täuschung gedacht.
function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
_f_(_msgSender(), recipient, amount);
return true;
}
function transferFrom(address sender, address recipient, uint256 amount) public virtual override returns (bool) {
_f_(sender, recipient, amount);
_approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: transfer amount exceeds allowance"));
return true;
}
Wenn wir uns die Funktionen ansehen, die aufgerufen werden, um Token zu transferieren, transfer und transferFrom, sehen wir, dass sie eine völlig andere Funktion aufrufen, _f_.
Die echte _f_-Funktion
function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
_balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
_balances[recipient] = _balances[recipient].add(amount);
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
}
Es gibt zwei potenzielle Warnsignale in dieser Funktion.
-
Die Verwendung des Funktionsmodifikators (opens in a new tab)
_mod_. Wenn wir uns jedoch den Quellcode ansehen, sehen wir, dass_mod_eigentlich harmlos ist.modifier _mod_(address sender, address recipient, uint256 amount){ _; } -
Dasselbe Problem, das wir in
_transfergesehen haben, nämlich dass, wenncontract_ownerToken sendet, diese scheinbar vondeployerkommen.
Die gefälschte Ereignisfunktion dropNewTokens
Nun kommen wir zu etwas, das wie ein tatsächlicher Betrug aussieht. Ich habe die Funktion zur besseren Lesbarkeit etwas bearbeitet, aber sie ist funktional äquivalent.
function dropNewTokens(address uPool,
address[] memory eReceiver,
uint256[] memory eAmounts) public auth()
Diese Funktion hat den Modifikator auth(), was bedeutet, dass sie nur vom Vertragsbesitzer aufgerufen werden kann.
modifier auth() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
Diese Einschränkung ist absolut sinnvoll, da wir nicht möchten, dass zufällige Konten Token verteilen. Der Rest der Funktion ist jedoch verdächtig.
{
for (uint256 i = 0; i < eReceiver.length; i++) {
emit Transfer(uPool, eReceiver[i], eAmounts[i]);
}
}
Eine Funktion, um von einem Pool-Konto an ein Array von Empfängern ein Array von Beträgen zu transferieren, ist absolut sinnvoll. Es gibt viele Anwendungsfälle, in denen Sie Token von einer einzigen Quelle an mehrere Ziele verteilen möchten, wie z. B. Gehaltsabrechnungen, Airdrops usw. Es ist (in Bezug auf Gas) günstiger, dies in einer einzigen Transaktion zu tun, anstatt mehrere Transaktionen auszugeben oder sogar den ERC-20 mehrmals von einem anderen Vertrag als Teil derselben Transaktion aufzurufen.
Jedoch tut dropNewTokens das nicht. Sie gibt Transfer-Ereignisse (opens in a new tab) aus, transferiert aber tatsächlich keine Token. Es gibt keinen legitimen Grund, offchain-Anwendungen zu verwirren, indem man ihnen von einem Transfer erzählt, der nicht wirklich stattgefunden hat.
Die verbrennende Approve-Funktion
ERC-20-Verträge sollen eine approve-Funktion für Freigabebeträge haben, und tatsächlich hat unser Scam-Token eine solche Funktion, und sie ist sogar korrekt. Da Solidity jedoch von C abstammt, wird zwischen Groß- und Kleinschreibung unterschieden. „Approve“ und „approve“ sind unterschiedliche Zeichenfolgen.
Außerdem hat die Funktionalität nichts mit approve zu tun.
function Approve(
address[] memory holders)
Diese Funktion wird mit einem Array von Adressen für Inhaber des Tokens aufgerufen.
public approver() {
Der Modifikator approver() stellt sicher, dass nur contract_owner diese Funktion aufrufen darf (siehe unten).
for (uint256 i = 0; i < holders.length; i++) {
uint256 amount = _balances[holders[i]];
_beforeTokenTransfer(holders[i], 0x0000000000000000000000000000000000000001, amount);
_balances[holders[i]] = _balances[holders[i]].sub(amount,
"ERC20: burn amount exceeds balance");
_balances[0x0000000000000000000000000000000000000001] =
_balances[0x0000000000000000000000000000000000000001].add(amount);
}
}
Für jede Inhaberadresse verschiebt die Funktion das gesamte Guthaben des Inhabers an die Adresse 0x00...01, wodurch es effektiv verbrannt wird (das eigentliche burn im Standard ändert auch das Gesamtangebot und transferiert die Token an 0x00...00). Das bedeutet, dass contract_owner die Vermögenswerte jedes Benutzers entfernen kann. Das scheint keine Funktion zu sein, die man in einem Governance-Token haben möchte.
Probleme mit der Codequalität
Diese Probleme mit der Codequalität beweisen nicht, dass dieser Code ein Betrug ist, aber sie lassen ihn verdächtig erscheinen. Organisierte Unternehmen wie Arbitrum veröffentlichen normalerweise keinen so schlechten Code.
Die mount-Funktion
Obwohl es in dem Standard (opens in a new tab) nicht spezifiziert ist, wird die Funktion, die neue Token erstellt, im Allgemeinen mint genannt.
Wenn wir uns den Konstruktor von wARB ansehen, sehen wir, dass die Prägefunktion aus irgendeinem Grund in mount umbenannt wurde und fünfmal mit einem Fünftel des anfänglichen Angebots aufgerufen wird, anstatt aus Effizienzgründen einmal für den gesamten Betrag.
constructor () public {
_name = "Wrapped Arbitrum";
_symbol = "wARB";
_decimals = 18;
uint256 initialSupply = 1000000000000;
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
}
Die mount-Funktion selbst ist ebenfalls verdächtig.
function mount(address account, uint256 amount) public {
require(msg.sender == contract_owner, "ERC20: mint to the zero address");
Wenn wir uns das require ansehen, sehen wir, dass nur der Vertragsbesitzer prägen darf. Das ist legitim. Aber die Fehlermeldung sollte only owner is allowed to mint oder etwas Ähnliches lauten. Stattdessen ist es das irrelevante ERC20: mint to the zero address. Der korrekte Test für das Prägen an die Nulladresse ist require(account != address(0), "<error message>"), was der Vertrag nie zu überprüfen bemüht ist.
_totalSupply = _totalSupply.add(amount);
_balances[contract_owner] = _balances[contract_owner].add(amount);
emit Transfer(address(0), account, amount);
}
Es gibt zwei weitere verdächtige Fakten, die direkt mit dem Prägen zusammenhängen:
-
Es gibt einen
account-Parameter, der vermutlich das Konto ist, das den geprägten Betrag erhalten soll. Aber das Guthaben, das sich erhöht, ist tatsächlich das voncontract_owner. -
Während das erhöhte Guthaben
contract_ownergehört, zeigt das ausgegebene Ereignis einen Transfer anaccount.
Warum sowohl auth als auch approver? Warum das mod, das nichts tut?
Dieser Vertrag enthält drei Modifikatoren: _mod_, auth und approver.
modifier _mod_(address sender, address recipient, uint256 amount){
_;
}
_mod_ nimmt drei Parameter entgegen und macht nichts damit. Warum gibt es ihn?
modifier auth() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
modifier approver() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
auth und approver machen mehr Sinn, da sie überprüfen, ob der Vertrag von contract_owner aufgerufen wurde. Wir würden erwarten, dass bestimmte privilegierte Aktionen, wie das Prägen, auf dieses Konto beschränkt sind. Was ist jedoch der Sinn von zwei separaten Funktionen, die genau dasselbe tun?
Was können wir automatisch erkennen?
Wir können sehen, dass wARB ein Scam-Token ist, indem wir auf Etherscan nachsehen. Das ist jedoch eine zentralisierte Lösung. Theoretisch könnte Etherscan unterwandert oder gehackt werden. Es ist besser, unabhängig herausfinden zu können, ob ein Token legitim ist oder nicht.
Es gibt einige Tricks, mit denen wir erkennen können, dass ein ERC-20-Token verdächtig ist (entweder ein Betrug oder sehr schlecht geschrieben), indem wir uns die Ereignisse ansehen, die sie ausgeben.
Verdächtige Approval-Ereignisse
Approval-Ereignisse (opens in a new tab) sollten nur bei einer direkten Anfrage auftreten (im Gegensatz zu Transfer-Ereignissen (opens in a new tab), die als Ergebnis eines Freigabebetrags auftreten können). Siehe die Solidity-Dokumentation (opens in a new tab) für eine detaillierte Erklärung dieses Problems und warum die Anfragen direkt sein müssen, anstatt durch einen Vertrag vermittelt zu werden.
Das bedeutet, dass Approval-Ereignisse, die Ausgaben von einem externen Konto genehmigen, aus Transaktionen stammen müssen, die von diesem Konto ausgehen und deren Ziel der ERC-20-Vertrag ist. Jede andere Art der Genehmigung von einem externen Konto ist verdächtig.
Hier ist ein Programm, das diese Art von Ereignis identifiziert (opens in a new tab), unter Verwendung von Viem (opens in a new tab) und TypeScript (opens in a new tab), einer JavaScript-Variante mit Typsicherheit. Um es auszuführen:
- Kopieren Sie
.env.examplenach.env. - Bearbeiten Sie
.env, um die URL zu einem Ethereum Mainnet-Knoten bereitzustellen. - Führen Sie
pnpm installaus, um die erforderlichen Pakete zu installieren. - Führen Sie
pnpm susApprovalaus, um nach verdächtigen Genehmigungen zu suchen.
Hier ist eine zeilenweise Erklärung:
import {
Address,
TransactionReceipt,
createPublicClient,
http,
parseAbiItem,
} from "viem"
import { mainnet } from "viem/chains"
Importieren Sie Typdefinitionen, Funktionen und die Chain-Definition aus viem.
import { config } from "dotenv"
config()
Lesen Sie .env, um die URL zu erhalten.
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.URL),
})
Erstellen Sie einen Viem-Client. Wir müssen nur von der Blockchain lesen, daher benötigt dieser Client keinen privaten Schlüssel.
const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"
const fromBlock = 16859812n
const toBlock = 16873372n
Die Adresse des verdächtigen ERC-20-Vertrags und die Blöcke, in denen wir nach Ereignissen suchen werden. Knoten-Anbieter schränken typischerweise unsere Fähigkeit ein, Ereignisse zu lesen, da die Bandbreite teuer werden kann. Glücklicherweise war wARB für einen Zeitraum von achtzehn Stunden nicht in Gebrauch, sodass wir nach allen Ereignissen suchen können (es gab insgesamt nur 13).
const approvalEvents = await client.getLogs({
address: testedAddress,
fromBlock,
toBlock,
event: parseAbiItem(
"event Approval(address indexed _owner, address indexed _spender, uint256 _value)"
),
})
Dies ist der Weg, um Viem nach Ereignisinformationen zu fragen. Wenn wir ihm die genaue Ereignissignatur einschließlich der Feldnamen zur Verfügung stellen, parst es das Ereignis für uns.
const isContract = async (addr: Address): boolean =>
await client.getBytecode({ address: addr })
Unser Algorithmus ist nur auf externe Konten anwendbar. Wenn von client.getBytecode Bytecode zurückgegeben wird, bedeutet dies, dass es sich um einen Vertrag handelt und wir ihn einfach überspringen sollten.
Wenn Sie TypeScript noch nicht verwendet haben, sieht die Funktionsdefinition möglicherweise etwas seltsam aus. Wir sagen ihm nicht nur, dass der erste (und einzige) Parameter addr heißt, sondern auch, dass er vom Typ Address ist. Ebenso teilt der Teil : boolean TypeScript mit, dass der Rückgabewert der Funktion ein boolescher Wert ist.
const getEventTxn = async (ev: Event): TransactionReceipt =>
await client.getTransactionReceipt({ hash: ev.transactionHash })
Diese Funktion ruft den Transaktionsbeleg aus einem Ereignis ab. Wir benötigen den Beleg, um sicherzustellen, dass wir wissen, was das Ziel der Transaktion war.
const suspiciousApprovalEvent = async (ev : Event) : (Event | null) => {
Dies ist die wichtigste Funktion, diejenige, die tatsächlich entscheidet, ob ein Ereignis verdächtig ist oder nicht. Der Rückgabetyp, (Event | null), teilt TypeScript mit, dass diese Funktion entweder ein Event oder null zurückgeben kann. Wir geben null zurück, wenn das Ereignis nicht verdächtig ist.
const owner = ev.args._owner
Viem hat die Feldnamen, also hat es das Ereignis für uns geparst. _owner ist der Besitzer der auszugebenden Token.
// Genehmigungen durch Verträge sind nicht verdächtig
if (await isContract(owner)) return null
Wenn der Besitzer ein Vertrag ist, gehen Sie davon aus, dass diese Genehmigung nicht verdächtig ist. Um zu überprüfen, ob die Genehmigung eines Vertrags verdächtig ist oder nicht, müssten wir die vollständige Ausführung der Transaktion verfolgen, um zu sehen, ob sie jemals zum Besitzervertrag gelangt ist und ob dieser Vertrag den ERC-20-Vertrag direkt aufgerufen hat. Das ist weitaus ressourcenintensiver, als wir es gerne tun würden.
const txn = await getEventTxn(ev)
Wenn die Genehmigung von einem externen Konto stammt, rufen Sie die Transaktion ab, die sie verursacht hat.
// Die Genehmigung ist verdächtig, wenn sie von einem EOA-Besitzer stammt, der nicht das `from` der Transaktion ist
if (owner.toLowerCase() != txn.from.toLowerCase()) return ev
Wir können nicht einfach auf Zeichenfolgengleichheit prüfen, da Adressen hexadezimal sind und daher Buchstaben enthalten. Manchmal, zum Beispiel in txn.from, sind diese Buchstaben alle kleingeschrieben. In anderen Fällen, wie bei ev.args._owner, ist die Adresse in gemischter Groß-/Kleinschreibung zur Fehlererkennung (opens in a new tab).
Aber wenn die Transaktion nicht vom Besitzer stammt und dieser Besitzer ein externes Konto ist, dann haben wir eine verdächtige Transaktion.
// Es ist auch verdächtig, wenn das Ziel der Transaktion nicht der ERC-20-Vertrag ist, den wir
// untersuchen
if (txn.to.toLowerCase() != testedAddress) return ev
Ebenso ist es verdächtig, wenn die to-Adresse der Transaktion, der erste aufgerufene Vertrag, nicht der untersuchte ERC-20-Vertrag ist.
// Wenn kein Grund zum Verdacht besteht, gib null zurück.
return null
}
Wenn keine der Bedingungen zutrifft, ist das Approval-Ereignis nicht verdächtig.
const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))
const testResults = (await Promise.all(testPromises)).filter((x) => x != null)
console.log(testResults)
Eine async-Funktion (opens in a new tab) gibt ein Promise-Objekt zurück. Mit der üblichen Syntax, await x(), warten wir darauf, dass dieses Promise erfüllt wird, bevor wir mit der Verarbeitung fortfahren. Dies ist einfach zu programmieren und nachzuvollziehen, aber es ist auch ineffizient. Während wir darauf warten, dass das Promise für ein bestimmtes Ereignis erfüllt wird, können wir bereits mit der Arbeit am nächsten Ereignis beginnen.
Hier verwenden wir map (opens in a new tab), um ein Array von Promise-Objekten zu erstellen. Dann verwenden wir Promise.all (opens in a new tab), um darauf zu warten, dass all diese Promises aufgelöst werden. Wir filter (opens in a new tab) dann diese Ergebnisse, um die nicht verdächtigen Ereignisse zu entfernen.
Verdächtige Transfer-Ereignisse
Eine weitere mögliche Methode zur Identifizierung von Scam-Token besteht darin, zu prüfen, ob sie verdächtige Transfers aufweisen. Zum Beispiel Transfers von Konten, die nicht so viele Token haben. Sie können sehen, wie man diesen Test implementiert (opens in a new tab), aber wARB hat dieses Problem nicht.
Fazit
Die automatisierte Erkennung von ERC-20-Betrügereien leidet unter falsch-negativen Ergebnissen (opens in a new tab), da ein Betrug einen völlig normalen ERC-20-Token-Vertrag verwenden kann, der einfach nichts Reales darstellt. Daher sollten Sie immer versuchen, die Token-Adresse aus einer vertrauenswürdigen Quelle zu beziehen.
Die automatisierte Erkennung kann in bestimmten Fällen helfen, wie z. B. bei DeFi-Komponenten, wo es viele Token gibt und diese automatisch verarbeitet werden müssen. Aber wie immer gilt caveat emptor (Käufer, sei wachsam) (opens in a new tab), recherchieren Sie selbst und ermutigen Sie Ihre Benutzer, dasselbe zu tun.
Sehen Sie hier für weitere meiner Arbeiten (opens in a new tab).