Vai al contenuto principale

Alcuni trucchi usati dai token truffa e come rilevarli

truffa
Solidity
erc-20
JavaScript
TypeScript
Intermedio
Ori Pomerantz
15 settembre 2023
15 minuti di lettura

In questo tutorial analizziamo un token truffa (opens in a new tab) per vedere alcuni dei trucchi usati dai truffatori e come li implementano. Alla fine del tutorial avrai una visione più completa dei contratti dei token ERC-20, delle loro capacità e del perché lo scetticismo è necessario. Successivamente esamineremo gli eventi emessi da quel token truffa e vedremo come possiamo identificare automaticamente che non è legittimo.

Token truffa: cosa sono, perché le persone li creano e come evitarli

Uno degli usi più comuni di Ethereum è la creazione di un token negoziabile da parte di un gruppo, in un certo senso la propria valuta. Tuttavia, ovunque ci siano casi d'uso legittimi che portano valore, ci sono anche criminali che cercano di rubare quel valore per sé stessi.

Puoi leggere di più su questo argomento altrove su ethereum.org dal punto di vista dell'utente. Questo tutorial si concentra sull'analisi di un token truffa per vedere come viene realizzato e come può essere rilevato.

Come faccio a sapere che wARB è una truffa?

Il token che analizziamo è wARB (opens in a new tab), che finge di essere equivalente al legittimo token ARB (opens in a new tab).

Il modo più semplice per sapere quale sia il token legittimo è guardare l'organizzazione di origine, Arbitrum (opens in a new tab). Gli indirizzi legittimi sono specificati nella loro documentazione (opens in a new tab).

Perché il codice sorgente è disponibile?

Normalmente ci aspetteremmo che le persone che cercano di truffare gli altri siano reticenti, e in effetti molti token truffa non hanno il loro codice disponibile (ad esempio, questo (opens in a new tab) e questo (opens in a new tab)).

Tuttavia, i token legittimi di solito pubblicano il loro codice sorgente, quindi per apparire legittimi a volte gli autori dei token truffa fanno lo stesso. wARB (opens in a new tab) è uno di quei token con codice sorgente disponibile, il che rende più facile comprenderlo.

Sebbene i distributori dei contratti possano scegliere se pubblicare o meno il codice sorgente, non possono pubblicare il codice sorgente sbagliato. L'esploratore di blocchi compila il codice sorgente fornito in modo indipendente e, se non ottiene l'esatto stesso bytecode, rifiuta quel codice sorgente. Puoi leggere di più al riguardo sul sito di Etherscan (opens in a new tab).

Confronto con i token ERC-20 legittimi

Confronteremo questo token con i token ERC-20 legittimi. Se non hai familiarità con il modo in cui vengono tipicamente scritti i token ERC-20 legittimi, consulta questo tutorial.

Costanti per gli indirizzi privilegiati

I contratti a volte necessitano di indirizzi privilegiati. I contratti progettati per un uso a lungo termine consentono ad alcuni indirizzi privilegiati di modificare tali indirizzi, ad esempio per abilitare l'uso di un nuovo contratto multifirma. Ci sono diversi modi per farlo.

Il contratto del token HOP (opens in a new tab) utilizza il pattern Ownable (opens in a new tab). L'indirizzo privilegiato è conservato nell'archiviazione, in un campo chiamato _owner (vedi il terzo file, Ownable.sol).

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

Il contratto del token ARB (opens in a new tab) non ha direttamente un indirizzo privilegiato. Tuttavia, non ne ha bisogno. Si trova dietro un proxy (opens in a new tab) all'indirizzo 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens in a new tab). Quel contratto ha un indirizzo privilegiato (vedi il quarto file, ERC1967Upgrade.sol) che può essere utilizzato per gli aggiornamenti.

1 /* *
2 * @dev Memorizza un nuovo indirizzo nello slot admin EIP1967. */
3
4
5
6 function _setAdmin(address newAdmin) private {
7 require(newAdmin != address(0), "ERC1967: new admin is the zero address");
8 StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
9 }

Al contrario, il contratto wARB ha un contract_owner hardcoded.

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

Questo proprietario del contratto (opens in a new tab) non è un contratto che potrebbe essere controllato da account diversi in momenti diversi, ma un account controllato esternamente . Ciò significa che è probabilmente progettato per un uso a breve termine da parte di un individuo, piuttosto che come soluzione a lungo termine per controllare un ERC-20 che manterrà il suo valore.

E in effetti, se guardiamo su Etherscan vediamo che il truffatore ha utilizzato questo contratto solo per 12 ore (dalla prima transazione (opens in a new tab) all'ultima transazione (opens in a new tab)) durante il 19 maggio 2023.

La finta funzione _transfer

È standard che i trasferimenti effettivi avvengano utilizzando una funzione interna _transfer.

In wARB questa funzione sembra quasi legittima:

1 function _transfer(address sender, address recipient, uint256 amount) internal virtual{
2 require(sender != address(0), "ERC20: transfer from the zero address");
3 require(recipient != address(0), "ERC20: transfer to the zero address");
4
5 _beforeTokenTransfer(sender, recipient, amount);
6
7 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
8 _balances[recipient] = _balances[recipient].add(amount);
9 if (sender == contract_owner){
10 sender = deployer;
11 }
12 emit Transfer(sender, recipient, amount);
13 }

La parte sospetta è:

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

Se il proprietario del contratto invia token, perché l'evento Transfer mostra che provengono dal deployer?

Tuttavia, c'è un problema più importante. Chi chiama questa funzione _transfer? Non può essere chiamata dall'esterno, è contrassegnata come internal. E il codice che abbiamo non include alcuna chiamata a _transfer. Chiaramente, è qui come esca.

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: transfer amount exceeds allowance"));
9 return true;
10 }

Quando guardiamo le funzioni che vengono chiamate per trasferire i token, transfer e transferFrom, vediamo che chiamano una funzione completamente diversa, _f_.

La vera funzione _f_

1 function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {
2 require(sender != address(0), "ERC20: transfer from the zero address");
3 require(recipient != address(0), "ERC20: transfer to the zero address");
4
5 _beforeTokenTransfer(sender, recipient, amount);
6
7 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
8 _balances[recipient] = _balances[recipient].add(amount);
9 if (sender == contract_owner){
10
11 sender = deployer;
12 }
13 emit Transfer(sender, recipient, amount);
14 }

Ci sono due potenziali campanelli d'allarme in questa funzione.

  • L'uso del modificatore di funzione (opens in a new tab) _mod_. Tuttavia, quando esaminiamo il codice sorgente vediamo che _mod_ è in realtà innocuo.

    1modifier _mod_(address sender, address recipient, uint256 amount){
    2 _;
    3}
1
2- Lo stesso problema che abbiamo visto in `_transfer`, ovvero quando il `contract_owner` invia token, questi sembrano provenire dal `deployer`.
3
4### La finta funzione di eventi `dropNewTokens` \{#the-fake-events-function-dropNewTokens\}
5
6Ora arriviamo a qualcosa che sembra una vera e propria truffa. Ho modificato un po' la funzione per renderla più leggibile, ma è funzionalmente equivalente.
7
8```solidity
9function dropNewTokens(address uPool,
10 address[] memory eReceiver,
11 uint256[] memory eAmounts) public auth()

Questa funzione ha il modificatore auth(), il che significa che può essere chiamata solo dal proprietario del contratto.

1modifier auth() {
2 require(msg.sender == contract_owner, "Not allowed to interact");
3 _;
4}

Questa restrizione ha perfettamente senso, perché non vorremmo che account casuali distribuissero token. Tuttavia, il resto della funzione è sospetto.

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

Una funzione per trasferire da un account pool a un array di destinatari un array di importi ha perfettamente senso. Ci sono molti casi d'uso in cui si desidera distribuire token da una singola fonte a più destinazioni, come buste paga, airdrop, ecc. È più economico (in termini di gas) farlo in una singola transazione invece di emettere più transazioni, o persino chiamare l'ERC-20 più volte da un contratto diverso come parte della stessa transazione.

Tuttavia, dropNewTokens non fa questo. Emette eventi Transfer (opens in a new tab), ma in realtà non trasferisce alcun token. Non c'è alcun motivo legittimo per confondere le applicazioni fuori catena comunicando loro un trasferimento che non è realmente avvenuto.

La funzione Approve che brucia i token

I contratti ERC-20 dovrebbero avere una funzione approve per le indennità, e in effetti il nostro token truffa ha una tale funzione, ed è persino corretta. Tuttavia, poiché Solidity deriva dal C, fa distinzione tra maiuscole e minuscole. "Approve" e "approve" sono stringhe diverse.

Inoltre, la funzionalità non è correlata ad approve.

1 function Approve(
2 address[] memory holders)

Questa funzione viene chiamata con un array di indirizzi per i detentori del token.

1 public approver() {

Il modificatore approver() si assicura che solo il contract_owner sia autorizzato a chiamare questa funzione (vedi sotto).

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: burn amount exceeds balance");
6 _balances[0x0000000000000000000000000000000000000001] =
7 _balances[0x0000000000000000000000000000000000000001].add(amount);
8 }
9 }
10

Per ogni indirizzo del detentore, la funzione sposta l'intero saldo del detentore all'indirizzo 0x00...01, di fatto bruciandolo (il vero burn nello standard modifica anche l'offerta totale e trasferisce i token a 0x00...00). Ciò significa che il contract_owner può rimuovere gli asset di qualsiasi utente. Non sembra una funzionalità che vorresti in un token di governance.

Problemi di qualità del codice

Questi problemi di qualità del codice non provano che questo codice sia una truffa, ma lo fanno apparire sospetto. Le aziende organizzate come Arbitrum di solito non rilasciano codice così scadente.

La funzione mount

Sebbene non sia specificato nello standard (opens in a new tab), in generale la funzione che crea nuovi token è chiamata mint (coniare).

Se guardiamo nel costruttore di wARB, vediamo che la funzione di conio è stata rinominata in mount per qualche motivo, e viene chiamata cinque volte con un quinto dell'offerta iniziale, invece di una volta per l'intero importo per efficienza.

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 }

Anche la funzione mount stessa è sospetta.

1 function mount(address account, uint256 amount) public {
2 require(msg.sender == contract_owner, "ERC20: mint to the zero address");

Guardando il require, vediamo che solo il proprietario del contratto è autorizzato a coniare. Questo è legittimo. Ma il messaggio di errore dovrebbe essere only owner is allowed to mint (solo il proprietario è autorizzato a coniare) o qualcosa del genere. Invece, è l'irrilevante ERC20: mint to the zero address (ERC20: conio all'indirizzo zero). Il test corretto per il conio all'indirizzo zero è require(account != address(0), "<messaggio di errore>"), che il contratto non si preoccupa mai di controllare.

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

Ci sono altri due fatti sospetti, direttamente correlati al conio:

  • C'è un parametro account, che presumibilmente è l'account che dovrebbe ricevere l'importo coniato. Ma il saldo che aumenta è in realtà quello del contract_owner.

  • Sebbene il saldo aumentato appartenga al contract_owner, l'evento emesso mostra un trasferimento all'account.

Perché sia auth che approver? Perché il mod che non fa nulla?

Questo contratto contiene tre modificatori: _mod_, auth e approver.

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

_mod_ accetta tre parametri e non fa nulla con essi. Perché averlo?

1 modifier auth() {
2 require(msg.sender == contract_owner, "Not allowed to interact");
3 _;
4 }
5
6 modifier approver() {
7 require(msg.sender == contract_owner, "Not allowed to interact");
8 _;
9 }

auth e approver hanno più senso, perché controllano che il contratto sia stato chiamato dal contract_owner. Ci aspetteremmo che certe azioni privilegiate, come il conio, siano limitate a quell'account. Tuttavia, qual è lo scopo di avere due funzioni separate che fanno esattamente la stessa cosa?

Cosa possiamo rilevare automaticamente?

Possiamo vedere che wARB è un token truffa guardando su Etherscan. Tuttavia, questa è una soluzione centralizzata. In teoria, Etherscan potrebbe essere sovvertito o hackerato. È meglio essere in grado di capire in modo indipendente se un token è legittimo o meno.

Ci sono alcuni trucchi che possiamo usare per identificare che un token ERC-20 è sospetto (che sia una truffa o scritto molto male), guardando gli eventi che emettono.

Eventi Approval sospetti

Gli eventi Approval (opens in a new tab) dovrebbero verificarsi solo con una richiesta diretta (a differenza degli eventi Transfer (opens in a new tab) che possono verificarsi come risultato di un'indennità). Consulta la documentazione di Solidity (opens in a new tab) per una spiegazione dettagliata di questo problema e del perché le richieste debbano essere dirette, piuttosto che mediate da un contratto.

Ciò significa che gli eventi Approval che approvano la spesa da un account controllato esternamente devono provenire da transazioni che hanno origine in quell'account e la cui destinazione è il contratto ERC-20. Qualsiasi altro tipo di approvazione da un account controllato esternamente è sospetto.

Ecco un programma che identifica questo tipo di evento (opens in a new tab), utilizzando viem (opens in a new tab) e TypeScript (opens in a new tab), una variante di JavaScript con sicurezza dei tipi. Per eseguirlo:

  1. Copia .env.example in .env.
  2. Modifica .env per fornire l'URL a un nodo della rete principale di Ethereum.
  3. Esegui pnpm install per installare i pacchetti necessari.
  4. Esegui pnpm susApproval per cercare approvazioni sospette.

Ecco una spiegazione riga per riga:

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

Importa le definizioni dei tipi, le funzioni e la definizione della catena da viem.

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

Leggi .env per ottenere l'URL.

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

Crea un client Viem. Dobbiamo solo leggere dalla blockchain, quindi questo client non ha bisogno di una chiave privata.

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

L'indirizzo del contratto ERC-20 sospetto e i blocchi all'interno dei quali cercheremo gli eventi. I fornitori di nodi in genere limitano la nostra capacità di leggere gli eventi perché la larghezza di banda può diventare costosa. Fortunatamente wARB non è stato in uso per un periodo di diciotto ore, quindi possiamo cercare tutti gli eventi (ce n'erano solo 13 in totale).

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})

Questo è il modo per chiedere a Viem informazioni sugli eventi. Quando gli forniamo l'esatta firma dell'evento, inclusi i nomi dei campi, analizza l'evento per noi.

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

Il nostro algoritmo è applicabile solo agli account controllati esternamente. Se c'è del bytecode restituito da client.getBytecode significa che si tratta di un contratto e dovremmo semplicemente saltarlo.

Se non hai mai usato TypeScript prima, la definizione della funzione potrebbe sembrare un po' strana. Non gli diciamo solo che il primo (e unico) parametro si chiama addr, ma anche che è di tipo Address. Allo stesso modo, la parte : boolean dice a TypeScript che il valore di ritorno della funzione è un booleano.

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

Questa funzione ottiene la ricevuta della transazione da un evento. Abbiamo bisogno della ricevuta per assicurarci di sapere quale fosse la destinazione della transazione.

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

Questa è la funzione più importante, quella che decide effettivamente se un evento è sospetto o meno. Il tipo di ritorno, (Event | null), dice a TypeScript che questa funzione può restituire un Event o null. Restituiamo null se l'evento non è sospetto.

1const owner = ev.args._owner

Viem ha i nomi dei campi, quindi ha analizzato l'evento per noi. _owner è il proprietario dei token da spendere.

1// Le approvazioni da parte dei contratti non sono sospette
2if (await isContract(owner)) return null

Se il proprietario è un contratto, supponiamo che questa approvazione non sia sospetta. Per verificare se l'approvazione di un contratto è sospetta o meno, dovremo tracciare l'intera esecuzione della transazione per vedere se è mai arrivata al contratto proprietario e se quel contratto ha chiamato direttamente il contratto ERC-20. Questo è molto più dispendioso in termini di risorse di quanto vorremmo fare.

1const txn = await getEventTxn(ev)

Se l'approvazione proviene da un account controllato esternamente , ottieni la transazione che l'ha causata.

1// L'approvazione è sospetta se proviene da un proprietario EOA che non è il `from` della transazione
2if (owner.toLowerCase() != txn.from.toLowerCase()) return ev

Non possiamo semplicemente controllare l'uguaglianza delle stringhe perché gli indirizzi sono esadecimali, quindi contengono lettere. A volte, ad esempio in txn.from, quelle lettere sono tutte minuscole. In altri casi, come ev.args._owner, l'indirizzo è in maiuscolo e minuscolo misto per l'identificazione degli errori (opens in a new tab).

Ma se la transazione non proviene dal proprietario, e quel proprietario è controllato esternamente, allora abbiamo una transazione sospetta.

1// È anche sospetta se la destinazione della transazione non è il contratto ERC-20 che stiamo
2// indagando
3if (txn.to.toLowerCase() != testedAddress) return ev

Allo stesso modo, se l'indirizzo to della transazione, il primo contratto chiamato, non è il contratto ERC-20 sotto indagine, allora è sospetto.

1 // Se non c'è motivo di sospettare, restituisci null.
2 return null
3}

Se nessuna delle due condizioni è vera, l'evento Approval non è sospetto.

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

Una funzione async (opens in a new tab) restituisce un oggetto Promise. Con la sintassi comune, await x(), aspettiamo che quella Promise venga soddisfatta prima di continuare l'elaborazione. Questo è semplice da programmare e seguire, ma è anche inefficiente. Mentre aspettiamo che la Promise per un evento specifico venga soddisfatta, possiamo già iniziare a lavorare sull'evento successivo.

Qui usiamo map (opens in a new tab) per creare un array di oggetti Promise. Quindi usiamo Promise.all (opens in a new tab) per aspettare che tutte quelle promesse vengano risolte. Successivamente usiamo filter (opens in a new tab) su quei risultati per rimuovere gli eventi non sospetti.

Eventi Transfer sospetti

Un altro modo possibile per identificare i token truffa è vedere se hanno trasferimenti sospetti. Ad esempio, trasferimenti da account che non hanno così tanti token. Puoi vedere come implementare questo test (opens in a new tab), ma wARB non ha questo problema.

Conclusione

Il rilevamento automatico delle truffe ERC-20 soffre di falsi negativi (opens in a new tab), perché una truffa può utilizzare un contratto di token ERC-20 perfettamente normale che semplicemente non rappresenta nulla di reale. Quindi dovresti sempre tentare di ottenere l'indirizzo del token da una fonte attendibile.

Il rilevamento automatico può aiutare in certi casi, come nei componenti della DeFi, dove ci sono molti token e devono essere gestiti automaticamente. Ma come sempre caveat emptor (opens in a new tab), fai le tue ricerche e incoraggia i tuoi utenti a fare lo stesso.

Vedi qui per altri miei lavori (opens in a new tab).

Ultimo aggiornamento della pagina: 3 marzo 2026

Questo tutorial è stato utile?