Przejdź do głównej zawartości

Kilka sztuczek używanych przez scamerskie tokeny i jak je wykryć

scam
solidity
erc-20
JavaScript
typescript
Średnio zaawansowany
Ori Pomerantz
15 września 2023
14 minuta czytania

W tym samouczku przeanalizujemy scamerski tokenopens in a new tab, aby zobaczyć niektóre sztuczki stosowane przez oszustów i jak je wdrażają. Pod koniec samouczka będziesz mieć bardziej kompleksowy pogląd na kontrakty tokenów ERC-20, ich możliwości i dlaczego sceptycyzm jest konieczny. Następnie przyjrzymy się zdarzeniom emitowanym przez ten scamerski token i zobaczymy, jak możemy automatycznie zidentyfikować, że nie jest on wiarygodny.

Scamerskie tokeny – czym są, dlaczego ludzie je tworzą i jak ich unikać

Jednym z najczęstszych zastosowań Ethereum jest tworzenie przez grupę wymienialnych tokenów, w pewnym sensie własnej waluty. Jednak wszędzie tam, gdzie istnieją uzasadnione przypadki użycia, które przynoszą wartość, są też przestępcy, którzy próbują ukraść tę wartość dla siebie.

Możesz przeczytać więcej na ten temat w innym miejscu na ethereum.org z perspektywy użytkownika. Ten samouczek skupia się na analizie scamerskiego tokena, aby zobaczyć, jak to się robi i jak można go wykryć.

Skąd mam wiedzieć, że wARB to oszustwo?

Token, który analizujemy to wARBopens in a new tab, który udaje, że jest odpowiednikiem legalnego tokena ARBopens in a new tab.

Najprostszym sposobem, aby dowiedzieć się, który token jest legalny, jest spojrzenie na organizację, która go stworzyła, Arbitrumopens in a new tab. Legalne adresy są określone w ich dokumentacjiopens in a new tab.

Dlaczego kod źródłowy jest dostępny?

Zwykle spodziewalibyśmy się, że ludzie, którzy próbują oszukiwać innych, będą skryci, i rzeczywiście, wiele scamerskich tokenów nie ma dostępnego swojego kodu (na przykład, tenopens in a new tab i tenopens in a new tab).

Jednakże, legalne tokeny zwykle publikują swój kod źródłowy, więc aby wyglądać na legalne, autorzy scamerskich tokenów czasami robią to samo. wARBopens in a new tab jest jednym z tych tokenów z dostępnym kodem źródłowym, co ułatwia jego zrozumienie.

Podczas gdy wdrażający kontrakty mogą wybrać, czy opublikować kod źródłowy, czy nie, to nie mogą opublikować niewłaściwego kodu źródłowego. Eksplorator bloków niezależnie kompiluje dostarczony kod źródłowy, i jeśli nie uzyska dokładnie tego samego kodu bajtowego, odrzuca ten kod źródłowy. Możesz przeczytać więcej na ten temat na stronie Etherscanopens in a new tab.

Porównanie z legalnymi tokenami ERC-20

Porównamy ten token z legalnymi tokenami ERC-20. Jeśli nie znasz sposobu, w jaki zazwyczaj pisane są legalne tokeny ERC-20, zobacz ten samouczek.

Stałe dla uprzywilejowanych adresów

Kontrakty czasami potrzebują uprzywilejowanych adresów. Kontrakty, które są zaprojektowane do długotrwałego użytku, pozwalają pewnym uprzywilejowanym adresom na zmianę tych adresów, na przykład, aby umożliwić korzystanie z nowego kontraktu multisig. Istnieje kilka sposobów na zrobienie tego.

Kontrakt tokena HOPopens in a new tab używa wzorca Ownableopens in a new tab. Uprzywilejowany adres jest przechowywany w pamięci masowej, w polu o nazwie _owner (zobacz trzeci plik, Ownable.sol).

1abstract contract Ownable is Context {
2 address private _owner;
3 .
4 .
5 .
6}

Kontrakt tokena ARBopens in a new tab nie ma bezpośrednio uprzywilejowanego adresu. Jednakże nie potrzebuje go. Znajduje się za proxyopens in a new tab pod adresem 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1opens in a new tab. Ten kontrakt ma uprzywilejowany adres (zobacz czwarty plik, ERC1967Upgrade.sol), który może być używany do aktualizacji.

1 /**
2 * @dev Przechowuje nowy adres w slocie administratora EIP1967.
3 */
4 function _setAdmin(address newAdmin) private {
5 require(newAdmin != address(0), "ERC1967: new admin is the zero address");
6 StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
7 }

W przeciwieństwie do tego, kontrakt wARB ma na stałe zakodowanego contract_owner.

1contract WrappedArbitrum is Context, IERC20 {
2 .
3 .
4 .
5 address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;
6 address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;
7 .
8 .
9 .
10}
Pokaż wszystko

Ten właściciel kontraktuopens in a new tab nie jest kontraktem, który mógłby być kontrolowany przez różne konta w różnym czasie, ale kontem zarządzanym zewnętrznie. Oznacza to, że jest prawdopodobnie przeznaczony do krótkotrwałego użytku przez jedną osobę, a nie jako długoterminowe rozwiązanie do kontrolowania ERC-20, które pozostanie cenne.

I rzeczywiście, jeśli spojrzymy na Etherscan, zobaczymy, że oszust używał tego kontraktu tylko przez 12 godzin (pierwsza transakcjaopens in a new tab do ostatniej transakcjiopens in a new tab) w dniu 19 maja 2023 r.

Fałszywa funkcja _transfer

Standardem jest, że faktyczne transfery odbywają się przy użyciu wewnętrznej funkcji _transfer.

W wARB ta funkcja wygląda na prawie legalną:

1 function _transfer(address sender, address recipient, uint256 amount) internal virtual{
2 require(sender != address(0), "ERC20: transfer z adresu zerowego");
3 require(recipient != address(0), "ERC20: transfer na adres zerowy");
4
5 _beforeTokenTransfer(sender, recipient, amount);
6
7 _balances[sender] = _balances[sender].sub(amount, "ERC20: kwota transferu przekracza saldo");
8 _balances[recipient] = _balances[recipient].add(amount);
9 if (sender == contract_owner){
10 sender = deployer;
11 }
12 emit Transfer(sender, recipient, amount);
13 }
Pokaż wszystko

Podejrzana część to:

1 if (sender == contract_owner){
2 sender = deployer;
3 }
4 emit Transfer(sender, recipient, amount);

Jeśli właściciel kontraktu wysyła tokeny, dlaczego zdarzenie Transfer pokazuje, że pochodzą one od deployer?

Jest jednak ważniejsza kwestia. Kto wywołuje tę funkcję _transfer? Nie można jej wywołać z zewnątrz, jest oznaczona jako internal. A kod, który mamy, nie zawiera żadnych wywołań _transfer. Oczywiście jest to tylko przynęta.

1 function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
2 _f_(_msgSender(), recipient, amount);
3 return true;
4 }
5
6 function transferFrom(address sender, address recipient, uint256 amount) public virtual override returns (bool) {
7 _f_(sender, recipient, amount);
8 _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: kwota transferu przekracza przydział"));
9 return true;
10 }
Pokaż wszystko

Gdy przyjrzymy się funkcjom wywoływanym w celu transferu tokenów, transfer i transferFrom, widzimy, że wywołują one zupełnie inną funkcję, _f_.

Prawdziwa funkcja _f_

1 function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {
2 require(sender != address(0), "ERC20: transfer z adresu zerowego");
3 require(recipient != address(0), "ERC20: transfer na adres zerowy");
4
5 _beforeTokenTransfer(sender, recipient, amount);
6
7 _balances[sender] = _balances[sender].sub(amount, "ERC20: kwota transferu przekracza saldo");
8 _balances[recipient] = _balances[recipient].add(amount);
9 if (sender == contract_owner){
10
11 sender = deployer;
12 }
13 emit Transfer(sender, recipient, amount);
14 }
Pokaż wszystko

W tej funkcji są dwa potencjalne sygnały ostrzegawcze.

  • Użycie modyfikatora funkcjiopens in a new tab _mod_. Jednak gdy spojrzymy w kod źródłowy, zobaczymy, że _mod_ jest w rzeczywistości nieszkodliwy.

    1modifier _mod_(address sender, address recipient, uint256 amount){
    2 _;
    3}
  • Ten sam problem, który widzieliśmy w _transfer, czyli gdy contract_owner wysyła tokeny, wydaje się, że pochodzą one z deployer.

Fałszywa funkcja zdarzeń dropNewTokens

Teraz dochodzimy do czegoś, co wygląda jak prawdziwe oszustwo. Edytowałem funkcję dla większej czytelności, ale jest ona funkcjonalnie równoważna.

1function dropNewTokens(address uPool,
2 address[] memory eReceiver,
3 uint256[] memory eAmounts) public auth()

Ta funkcja ma modyfikator auth(), co oznacza, że może być wywołana tylko przez właściciela kontraktu.

1modifier auth() {
2 require(msg.sender == contract_owner, "Brak pozwolenia na interakcję");
3 _;
4}

To ograniczenie ma sens, ponieważ nie chcielibyśmy, aby przypadkowe konta dystrybuowały tokeny. Jednak reszta funkcji jest podejrzana.

1{
2 for (uint256 i = 0; i < eReceiver.length; i++) {
3 emit Transfer(uPool, eReceiver[i], eAmounts[i]);
4 }
5}

Funkcja transferu z konta puli do tablicy odbiorców tablicy kwot ma sens. Istnieje wiele przypadków użycia, w których będziesz chciał dystrybuować tokeny z jednego źródła do wielu miejsc docelowych, takich jak listy płac, airdropy itp. Jest to tańsze (w gazie) do wykonania w jednej transakcji zamiast emitowania wielu transakcji lub nawet wielokrotnego wywoływania ERC-20 z innego kontraktu w ramach tej samej transakcji.

Jednak dropNewTokens tego nie robi. Emituje zdarzenia Transferopens in a new tab, ale tak naprawdę nie transferuje żadnych tokenów. Nie ma żadnego uzasadnionego powodu, aby wprowadzać w błąd aplikacje offchain, informując je o transferze, który tak naprawdę nie miał miejsca.

Funkcja Approve do spalania

Kontrakty ERC-20 powinny mieć funkcję approve dla przydziałów i rzeczywiście, nasz scamerski token ma taką funkcję, a nawet jest ona poprawna. Jednakże, ponieważ Solidity wywodzi się z C, wielkość liter ma znaczenie. "Approve" i "approve" to różne ciągi znaków.

Ponadto funkcjonalność nie jest związana z approve.

1 function Approve(
2 address[] memory holders)

Ta funkcja jest wywoływana z tablicą adresów posiadaczy tokena.

1 public approver() {

Modyfikator approver() zapewnia, że tylko contract_owner może wywołać tę funkcję (zobacz poniżej).

1 for (uint256 i = 0; i < holders.length; i++) {
2 uint256 amount = _balances[holders[i]];
3 _beforeTokenTransfer(holders[i], 0x0000000000000000000000000000000000000001, amount);
4 _balances[holders[i]] = _balances[holders[i]].sub(amount,
5 "ERC20: spalana kwota przekracza saldo");
6 _balances[0x0000000000000000000000000000000000000001] =
7 _balances[0x0000000000000000000000000000000000000001].add(amount);
8 }
9 }
10
Pokaż wszystko

Dla każdego adresu posiadacza funkcja przenosi całe saldo posiadacza na adres 0x00...01, skutecznie je spalając (rzeczywiste spalenie w standardzie zmienia również całkowitą podaż i transferuje tokeny do 0x00...00). Oznacza to, że contract_owner może usunąć aktywa dowolnego użytkownika. Nie wydaje się to funkcją, którą chciałbyś mieć w tokenie zarządzania.

Problemy z jakością kodu

Te problemy z jakością kodu nie udowadniają, że ten kod jest oszustwem, ale sprawiają, że wydaje się on podejrzany. Zorganizowane firmy, takie jak Arbitrum, zwykle nie wydają tak złego kodu.

Funkcja mount

Chociaż nie jest to określone w standardzieopens in a new tab, ogólnie rzecz biorąc, funkcja, która tworzy nowe tokeny, nazywa się mintopens in a new tab.

Jeśli spojrzymy na konstruktor wARB, zobaczymy, że funkcja mintowania została z jakiegoś powodu przemianowana na mount i jest wywoływana pięć razy z jedną piątą początkowej podaży, zamiast raz dla całej kwoty dla wydajności.

1 constructor () public {
2
3 _name = "Wrapped Arbitrum";
4 _symbol = "wARB";
5 _decimals = 18;
6 uint256 initialSupply = 1000000000000;
7
8 mount(deployer, initialSupply*(10**18)/5);
9 mount(deployer, initialSupply*(10**18)/5);
10 mount(deployer, initialSupply*(10**18)/5);
11 mount(deployer, initialSupply*(10**18)/5);
12 mount(deployer, initialSupply*(10**18)/5);
13 }
Pokaż wszystko

Sama funkcja mount jest również podejrzana.

1 function mount(address account, uint256 amount) public {
2 require(msg.sender == contract_owner, "ERC20: mintowanie na adres zerowy");

Patrząc na require, widzimy, że tylko właściciel kontraktu może mintować. To jest legalne. Ale komunikat o błędzie powinien brzmieć tylko właściciel może mintować lub coś w tym rodzaju. Zamiast tego jest to nieistotne ERC20: mintowanie na adres zerowy. Prawidłowy test mintowania na adres zerowy to require(account != address(0), "<komunikat błędu>"), czego kontrakt nigdy nie sprawdza.

1 _totalSupply = _totalSupply.add(amount);
2 _balances[contract_owner] = _balances[contract_owner].add(amount);
3 emit Transfer(address(0), account, amount);
4 }

Istnieją jeszcze dwa podejrzane fakty, bezpośrednio związane z mintowaniem:

  • Istnieje parametr account, który jest przypuszczalnie kontem, które powinno otrzymać z-mintowaną kwotę. Ale saldo, które wzrasta, należy w rzeczywistości do contract_owner.

  • Podczas gdy zwiększone saldo należy do contract_owner, emitowane zdarzenie pokazuje transfer na account.

Po co zarówno auth, jak i approver? Po co mod, który nic nie robi?

Ten kontrakt zawiera trzy modyfikatory: _mod_, auth i approver.

1 modifier _mod_(address sender, address recipient, uint256 amount){
2 _;
3 }

_mod_ przyjmuje trzy parametry i nic z nimi nie robi. Po co on jest?

1 modifier auth() {
2 require(msg.sender == contract_owner, "Brak pozwolenia na interakcję");
3 _;
4 }
5
6 modifier approver() {
7 require(msg.sender == contract_owner, "Brak pozwolenia na interakcję");
8 _;
9 }
Pokaż wszystko

auth i approver mają więcej sensu, ponieważ sprawdzają, czy kontrakt został wywołany przez contract_owner. Spodziewalibyśmy się, że pewne uprzywilejowane działania, takie jak mintowanie, będą ograniczone do tego konta. Jednak jaki jest sens posiadania dwóch oddzielnych funkcji, które robią dokładnie to samo?

Co możemy wykryć automatycznie?

Możemy zobaczyć, że wARB jest scamerskim tokenem, patrząc na Etherscan. Jest to jednak scentralizowane rozwiązanie. W teorii Etherscan mógłby zostać obalony lub zhakowany. Lepiej jest być w stanie samodzielnie ocenić, czy token jest legalny, czy nie.

Istnieją pewne sztuczki, których możemy użyć, aby zidentyfikować, że token ERC-20 jest podejrzany (albo jest to oszustwo, albo jest bardzo źle napisany), patrząc na emitowane przez niego zdarzenia.

Podejrzane zdarzenia Approval

Zdarzenia Approvalopens in a new tab powinny mieć miejsce tylko na bezpośrednie żądanie (w przeciwieństwie do zdarzeń Transferopens in a new tab, które mogą mieć miejsce w wyniku przydziału). Zobacz dokumentację Solidityopens in a new tab, aby uzyskać szczegółowe wyjaśnienie tego problemu i dlaczego żądania muszą być bezpośrednie, a nie za pośrednictwem kontraktu.

Oznacza to, że zdarzenia Approval zatwierdzające wydatki z konta zarządzanego zewnętrznie muszą pochodzić z transakcji, które pochodzą z tego konta i których miejscem docelowym jest kontrakt ERC-20. Każdy inny rodzaj zatwierdzenia z konta zarządzanego zewnętrznie jest podejrzany.

Oto program, który identyfikuje tego rodzaju zdarzeniaopens in a new tab, używając viemopens in a new tab i TypeScriptopens in a new tab, wariantu JavaScript z bezpieczeństwem typów. Aby go uruchomić:

  1. Skopiuj .env.example do .env.
  2. Edytuj .env, aby podać adres URL do węzła sieci głównej Ethereum.
  3. Uruchom pnpm install, aby zainstalować niezbędne pakiety.
  4. Uruchom pnpm susApproval, aby wyszukać podejrzane zatwierdzenia.

Oto wyjaśnienie linijka po linijce:

1import {
2 Address,
3 TransactionReceipt,
4 createPublicClient,
5 http,
6 parseAbiItem,
7} from "viem"
8import { mainnet } from "viem/chains"

Importuj definicje typów, funkcje i definicję łańcucha z viem.

1import { config } from "dotenv"
2config()

Odczytaj .env, aby uzyskać adres URL.

1const client = createPublicClient({
2 chain: mainnet,
3 transport: http(process.env.URL),
4})

Utwórz klienta Viem. Musimy tylko odczytywać z blockchainu, więc ten klient nie potrzebuje klucza prywatnego.

1const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"
2const fromBlock = 16859812n
3const toBlock = 16873372n

Adres podejrzanego kontraktu ERC-20 i bloki, w których będziemy szukać zdarzeń. Dostawcy węzłów zazwyczaj ograniczają naszą zdolność do odczytywania zdarzeń, ponieważ przepustowość może być kosztowna. Na szczęście wARB nie był używany przez osiemnaście godzin, więc możemy szukać wszystkich zdarzeń (w sumie było ich tylko 13).

1const approvalEvents = await client.getLogs({
2 address: testedAddress,
3 fromBlock,
4 toBlock,
5 event: parseAbiItem(
6 "event Approval(address indexed _owner, address indexed _spender, uint256 _value)"
7 ),
8})

W ten sposób prosi się Viem o informacje o zdarzeniach. Gdy podamy mu dokładną sygnaturę zdarzenia, w tym nazwy pól, parser przetwarza dla nas zdarzenie.

1const isContract = async (addr: Address): boolean =>
2 await client.getBytecode({ address: addr })

Nasz algorytm ma zastosowanie tylko do kont zarządzanych zewnętrznie. Jeśli client.getBytecode zwróci jakikolwiek kod bajtowy, oznacza to, że jest to kontrakt i powinniśmy go po prostu pominąć.

Jeśli nie używałeś wcześniej TypeScript, definicja funkcji może wyglądać nieco dziwnie. Nie tylko mówimy mu, że pierwszy (i jedyny) parametr nazywa się addr, ale także, że jest typu Address. Podobnie, część : boolean mówi TypeScript, że wartość zwracana przez funkcję jest typu boolean.

1const getEventTxn = async (ev: Event): TransactionReceipt =>
2 await client.getTransactionReceipt({ hash: ev.transactionHash })

Ta funkcja pobiera potwierdzenie transakcji ze zdarzenia. Potrzebujemy potwierdzenia, aby upewnić się, że znamy miejsce docelowe transakcji.

1const suspiciousApprovalEvent = async (ev : Event) : (Event | null) => {

To jest najważniejsza funkcja, ta, która faktycznie decyduje, czy zdarzenie jest podejrzane, czy nie. Typ zwrotny, (Event | null), informuje TypeScript, że ta funkcja może zwrócić Event lub null. Zwracamy null, jeśli zdarzenie nie jest podejrzane.

1const owner = ev.args._owner

Viem ma nazwy pól, więc przetworzył dla nas zdarzenie. _owner jest właścicielem tokenów do wydania.

1// Zatwierdzenia przez kontrakty nie są podejrzane
2if (await isContract(owner)) return null

Jeśli właścicielem jest kontrakt, załóż, że to zatwierdzenie nie jest podejrzane. Aby sprawdzić, czy zatwierdzenie kontraktu jest podejrzane, czy nie, musimy prześledzić pełne wykonanie transakcji, aby zobaczyć, czy kiedykolwiek dotarła ona do kontraktu właściciela i czy ten kontrakt wywołał bezpośrednio kontrakt ERC-20. Jest to znacznie bardziej zasobochłonne, niż chcielibyśmy.

1const txn = await getEventTxn(ev)

Jeśli zatwierdzenie pochodzi z konta zarządzanego zewnętrznie, pobierz transakcję, która je spowodowała.

1// Zatwierdzenie jest podejrzane, jeśli pochodzi od właściciela EOA, który nie jest `from` transakcji
2if (owner.toLowerCase() != txn.from.toLowerCase()) return ev

Nie możemy po prostu sprawdzić równości ciągów znaków, ponieważ adresy są szesnastkowe, więc zawierają litery. Czasami, na przykład w txn.from, wszystkie te litery są małe. W innych przypadkach, takich jak ev.args._owner, adres ma mieszaną wielkość liter do identyfikacji błędówopens in a new tab.

Ale jeśli transakcja nie pochodzi od właściciela, a ten właściciel jest zarządzany zewnętrznie, to mamy podejrzaną transakcję.

1// Jest to również podejrzane, jeśli miejscem docelowym transakcji nie jest badany przez nas
2// kontrakt ERC-20
3if (txn.to.toLowerCase() != testedAddress) return ev

Podobnie, jeśli adres to transakcji, czyli pierwszy wywołany kontrakt, nie jest badanym kontraktem ERC-20, jest to podejrzane.

1 // Jeśli nie ma powodu do podejrzeń, zwróć null.
2 return null
3}

Jeśli żaden z warunków nie jest prawdziwy, zdarzenie Approval nie jest podejrzane.

1const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))
2const testResults = (await Promise.all(testPromises)).filter((x) => x != null)
3
4console.log(testResults)

Funkcja asyncopens in a new tab zwraca obiekt Promise. Przy użyciu popularnej składni await x() czekamy na spełnienie Promise przed kontynuowaniem przetwarzania. Jest to proste do zaprogramowania i śledzenia, ale jest również nieefektywne. Podczas gdy czekamy na spełnienie Promise dla określonego zdarzenia, możemy już pracować nad następnym zdarzeniem.

Tutaj używamy mapopens in a new tab, aby utworzyć tablicę obiektów Promise. Następnie używamy Promise.allopens in a new tab, aby poczekać na rozwiązanie wszystkich tych obietnic. Następnie filtrujemyopens in a new tab te wyniki, aby usunąć niepodejrzane zdarzenia.

Podejrzane zdarzenia Transfer

Innym możliwym sposobem identyfikacji scamerskich tokenów jest sprawdzenie, czy mają jakieś podejrzane transfery. Na przykład transfery z kont, które nie mają tak wielu tokenów. Możesz zobaczyć, jak zaimplementować ten testopens in a new tab, ale wARB nie ma tego problemu.

Wnioski

Automatyczne wykrywanie oszustw ERC-20 cierpi z powodu fałszywie negatywnych wynikówopens in a new tab, ponieważ oszustwo może wykorzystywać całkowicie normalny kontrakt tokena ERC-20, który po prostu nie reprezentuje niczego prawdziwego. Dlatego zawsze powinieneś próbować uzyskać adres tokena z zaufanego źródła.

Automatyczne wykrywanie może pomóc w niektórych przypadkach, takich jak elementy DeFi, gdzie jest wiele tokenów i muszą być one obsługiwane automatycznie. Ale jak zawsze caveat emptoropens in a new tab, przeprowadzaj własne badania i zachęcaj swoich użytkowników do tego samego.

Zobacz więcej mojej pracy tutajopens in a new tab.

Strona ostatnio zaktualizowana: 14 lutego 2026

Czy ten samouczek był pomocny?