Přejít na hlavní obsah

Některé triky používané podvodnými tokeny a jak je odhalit

podvod
Solidity
erc-20
JavaScript
TypeScript
Středně pokročilý
Ori Pomerantz
15. září 2023
14 minut čtení

V tomto tutoriálu rozebereme podvodný token (otevře se v nové záložce), abychom viděli některé triky, které podvodníci používají, a jak je implementují. Na konci tutoriálu budete mít ucelenější pohled na kontrakty tokenů ERC-20, jejich možnosti a proč je nutná skepse. Poté se podíváme na události, které tento podvodný token vysílá, a uvidíme, jak můžeme automaticky identifikovat, že není legitimní.

Podvodné tokeny – co to je, proč je lidé vytvářejí a jak se jim vyhnout

Jedním z nejčastějších způsobů využití Etherea je vytvoření obchodovatelného tokenu určitou skupinou, v jistém smyslu jejich vlastní měny. Nicméně všude tam, kde existují legitimní případy užití, které přinášejí hodnotu, se najdou i zločinci, kteří se snaží tuto hodnotu ukrást pro sebe.

Více si o tomto tématu z pohledu uživatele můžete přečíst jinde na ethereum.org. Tento tutoriál se zaměřuje na rozebrání podvodného tokenu, abychom viděli, jak se to dělá a jak jej lze odhalit.

Jak poznám, že je wARB podvod?

Token, který budeme rozebírat, je wARB (otevře se v nové záložce), jenž předstírá, že je ekvivalentem legitimního tokenu ARB (otevře se v nové záložce).

Nejsnazší způsob, jak poznat, který token je legitimní, je podívat se na původní organizaci, Arbitrum (otevře se v nové záložce). Legitimní adresy jsou uvedeny v jejich dokumentaci (otevře se v nové záložce).

Proč je zdrojový kód dostupný?

Normálně bychom očekávali, že lidé, kteří se snaží podvést ostatní, budou tajnůstkářští, a skutečně mnoho podvodných tokenů nemá svůj kód k dispozici (například tento (otevře se v nové záložce) a tento (otevře se v nové záložce)).

Nicméně legitimní tokeny obvykle svůj zdrojový kód zveřejňují, takže aby autoři podvodných tokenů působili legitimně, někdy dělají totéž. wARB (otevře se v nové záložce) je jedním z těch tokenů, které mají zdrojový kód dostupný, což usnadňuje jeho pochopení.

Ačkoli si ti, kdo nasazují kontrakt, mohou vybrat, zda zdrojový kód zveřejní, nebo ne, nemohou zveřejnit nesprávný zdrojový kód. Prohlížeč bloků nezávisle zkompiluje poskytnutý zdrojový kód, a pokud nezíská naprosto stejný bajtkód, tento zdrojový kód odmítne. Více si o tom můžete přečíst na stránkách Etherscan (otevře se v nové záložce).

Srovnání s legitimními tokeny ERC-20

Tento token porovnáme s legitimními tokeny ERC-20. Pokud nejste obeznámeni s tím, jak se legitimní tokeny ERC-20 obvykle píší, podívejte se na tento tutoriál.

Konstanty pro privilegované adresy

Kontrakty někdy potřebují privilegované adresy. Kontrakty, které jsou navrženy pro dlouhodobé používání, umožňují nějaké privilegované adrese tyto adresy změnit, například aby bylo možné použít nový multisig kontrakt. Existuje několik způsobů, jak to udělat.

Kontrakt tokenu HOP (otevře se v nové záložce) používá vzor Ownable (otevře se v nové záložce). Privilegovaná adresa je uchovávána v úložišti, v poli zvaném _owner (viz třetí soubor, Ownable.sol).

abstract contract Ownable is Context {
    address private _owner;
    .
    .
    .
}

Kontrakt tokenu ARB (otevře se v nové záložce) nemá privilegovanou adresu přímo. Nicméně ji ani nepotřebuje. Nachází se za proxy (otevře se v nové záložce) na adrese 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (otevře se v nové záložce). Tento kontrakt má privilegovanou adresu (viz čtvrtý soubor, ERC1967Upgrade.sol), kterou lze použít pro upgrady.

    /**
     * @dev Ukládá novou adresu do admin slotu EIP1967.
     */
    function _setAdmin(address newAdmin) private {
        require(newAdmin != address(0), "ERC1967: new admin is the zero address");
        StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
    }

Naproti tomu kontrakt wARB má pevně zakódovanou adresu contract_owner.

Tento vlastník kontraktu (otevře se v nové záložce) není kontrakt, který by mohl být v různých časech ovládán různými účty, ale externě vlastněný účet. To znamená, že je pravděpodobně navržen pro krátkodobé použití jednotlivcem, spíše než jako dlouhodobé řešení pro kontrolu tokenu ERC-20, který si udrží svou hodnotu.

A skutečně, pokud se podíváme na Etherscan, uvidíme, že podvodník tento kontrakt používal pouze 12 hodin (od první transakce (otevře se v nové záložce) po poslední transakci (otevře se v nové záložce)) během 19. května 2023.

Falešná funkce _transfer

Je standardní, že skutečné převody probíhají pomocí interní funkce _transfer.

V wARB vypadá tato funkce téměř legitimně:

Podezřelá část je:

        if (sender == contract_owner){
            sender = deployer;
        }
        emit Transfer(sender, recipient, amount);

Pokud vlastník kontraktu odesílá tokeny, proč událost Transfer ukazuje, že pocházejí z deployer?

Je tu však důležitější problém. Kdo volá tuto funkci _transfer? Nemůže být volána zvenčí, je označena jako internal. A kód, který máme, neobsahuje žádná volání _transfer. Je zřejmé, že je zde jako volavka.

Když se podíváme na funkce, které jsou volány pro převod tokenů, transfer a transferFrom, vidíme, že volají úplně jinou funkci, _f_.

Skutečná funkce _f_

V této funkci jsou dva potenciální varovné signály.

  • Použití modifikátoru funkce (otevře se v nové záložce) _mod_. Když se však podíváme do zdrojového kódu, zjistíme, že _mod_ je ve skutečnosti neškodný.

    modifier _mod_(address sender, address recipient, uint256 amount){
      _;
    }
    
  • Stejný problém, jaký jsme viděli u _transfer, a to ten, že když contract_owner odesílá tokeny, zdá se, že pocházejí z deployer.

Funkce falešných událostí dropNewTokens

Nyní se dostáváme k něčemu, co vypadá jako skutečný podvod. Funkci jsem trochu upravil pro lepší čitelnost, ale funkčně je ekvivalentní.

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

Tato funkce má modifikátor auth(), což znamená, že ji může volat pouze vlastník kontraktu.

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

Toto omezení dává naprostý smysl, protože bychom nechtěli, aby tokeny distribuovaly náhodné účty. Zbytek funkce je však podezřelý.

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

Funkce pro převod z účtu fondu (pool) na pole příjemců a pole částek dává naprostý smysl. Existuje mnoho případů užití, kdy budete chtít distribuovat tokeny z jednoho zdroje do více cílů, jako jsou výplaty, airdropy atd. Je levnější (na gas) to provést v jediné transakci namísto vydávání více transakcí, nebo dokonce vícenásobného volání ERC-20 z jiného kontraktu v rámci stejné transakce.

Nicméně dropNewTokens to nedělá. Vysílá události Transfer (otevře se v nové záložce), ale ve skutečnosti žádné tokeny nepřevádí. Neexistuje žádný legitimní důvod mást offchain aplikace tím, že jim budeme hlásit převod, ke kterému ve skutečnosti nedošlo.

Spalovací funkce Approve

Kontrakty ERC-20 by měly mít funkci approve pro povolené limity, a náš podvodný token takovou funkci skutečně má, a je dokonce správná. Nicméně, protože Solidity vychází z jazyka C, rozlišuje velká a malá písmena. „Approve“ a „approve“ jsou různé řetězce.

Navíc tato funkcionalita nesouvisí s approve.

    function Approve(
        address[] memory holders)

Tato funkce je volána s polem adres držitelů tokenu.

    public approver() {

Modifikátor approver() zajišťuje, že tuto funkci smí volat pouze contract_owner (viz níže).

Pro každou adresu držitele funkce přesune celý jeho zůstatek na adresu 0x00...01, čímž jej efektivně spálí (skutečný burn ve standardu také mění celkovou zásobu a převádí tokeny na 0x00...00). To znamená, že contract_owner může odstranit aktiva jakéhokoli uživatele. To nevypadá jako funkce, kterou byste chtěli u tokenu správy.

Problémy s kvalitou kódu

Tyto problémy s kvalitou kódu nedokazují, že je tento kód podvod, ale dělají ho podezřelým. Organizované společnosti jako Arbitrum obvykle nevydávají takto špatný kód.

Funkce mount

Ačkoli to není specifikováno ve standardu (otevře se v nové záložce), obecně řečeno se funkce, která vytváří nové tokeny, nazývá mint.

Pokud se podíváme do konstruktoru wARB, uvidíme, že funkce pro ražení byla z nějakého důvodu přejmenována na mount a je volána pětkrát s pětinou počáteční zásoby, namísto toho, aby byla pro vyšší efektivitu zavolána jednou pro celou částku.

Samotná funkce mount je také podezřelá.

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

Při pohledu na require vidíme, že razit smí pouze vlastník kontraktu. To je legitimní. Chybová zpráva by však měla znít only owner is allowed to mint (razit smí pouze vlastník) nebo něco podobného. Místo toho je tam irelevantní ERC20: mint to the zero address (ERC20: ražení na nulovou adresu). Správný test pro ražení na nulovou adresu je require(account != address(0), "<error message>"), což se kontrakt vůbec neobtěžuje kontrolovat.

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

Existují další dvě podezřelá fakta, která přímo souvisejí s ražením:

  • Je zde parametr account, což je pravděpodobně účet, který by měl obdržet vyraženou částku. Zůstatek, který se zvyšuje, však ve skutečnosti patří contract_owner.

  • Ačkoli zvýšený zůstatek patří contract_owner, vyslaná událost ukazuje převod na account.

Proč jak auth, tak approver? Proč mod, který nic nedělá?

Tento kontrakt obsahuje tři modifikátory: _mod_, auth a approver.

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

_mod_ přijímá tři parametry a nic s nimi nedělá. Proč ho tam mít?

auth a approver dávají větší smysl, protože kontrolují, zda byl kontrakt zavolán z contract_owner. Očekávali bychom, že určité privilegované akce, jako je ražení, budou omezeny na tento účet. Jaký má ale smysl mít dvě samostatné funkce, které dělají přesně to samé?

Co můžeme odhalit automaticky?

Že je wARB podvodný token, můžeme vidět při pohledu na Etherscan. To je však centralizované řešení. Teoreticky by mohl být Etherscan podvržen nebo hacknut. Je lepší být schopen nezávisle zjistit, zda je token legitimní, či nikoli.

Existují určité triky, které můžeme použít k identifikaci, že je token ERC-20 podezřelý (buď podvod, nebo velmi špatně napsaný), a to na základě událostí, které vysílá.

Podezřelé události Approval

K událostem Approval (otevře se v nové záložce) by mělo docházet pouze při přímém požadavku (na rozdíl od událostí Transfer (otevře se v nové záložce), ke kterým může dojít v důsledku povoleného limitu). Podívejte se do dokumentace Solidity (otevře se v nové záložce) pro podrobné vysvětlení tohoto problému a proč musí být požadavky přímé, a nikoli zprostředkované kontraktem.

To znamená, že události Approval, které schvalují útratu z externě vlastněného účtu, musí pocházet z transakcí, které mají původ na tomto účtu a jejichž cílem je kontrakt ERC-20. Jakýkoli jiný druh schválení z externě vlastněného účtu je podezřelý.

Zde je program, který identifikuje tento druh události (otevře se v nové záložce), pomocí Viem (otevře se v nové záložce) a TypeScript (otevře se v nové záložce), varianty JavaScript s typovou bezpečností. Chcete-li jej spustit:

  1. Zkopírujte .env.example do .env.
  2. Upravte .env tak, aby poskytoval URL adresu uzlu Ethereum Mainnet.
  3. Spusťte pnpm install pro instalaci potřebných balíčků.
  4. Spusťte pnpm susApproval pro vyhledání podezřelých schválení.

Zde je vysvětlení řádek po řádku:

import {
  Address,
  TransactionReceipt,
  createPublicClient,
  http,
  parseAbiItem,
} from "viem"
import { mainnet } from "viem/chains"

Importujte definice typů, funkce a definici řetězce z viem.

import { config } from "dotenv"
config()

Přečtěte .env pro získání URL.

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

Vytvořte klienta Viem. Potřebujeme pouze číst z blockchainu, takže tento klient nepotřebuje soukromý klíč.

const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"
const fromBlock = 16859812n
const toBlock = 16873372n

Adresa podezřelého kontraktu ERC-20 a bloky, ve kterých budeme hledat události. Poskytovatelé uzlů obvykle omezují naši schopnost číst události, protože šířka pásma může být drahá. Naštěstí wARB nebyl používán po dobu osmnácti hodin, takže můžeme vyhledat všechny události (celkem jich bylo pouze 13).

const approvalEvents = await client.getLogs({
  address: testedAddress,
  fromBlock,
  toBlock,
  event: parseAbiItem(
    "event Approval(address indexed _owner, address indexed _spender, uint256 _value)"
  ),
})

Toto je způsob, jak požádat Viem o informace o události. Když mu poskytneme přesný podpis události, včetně názvů polí, událost za nás analyzuje.

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

Náš algoritmus je použitelný pouze pro externě vlastněné účty. Pokud client.getBytecode vrátí jakýkoli bajtkód, znamená to, že se jedná o kontrakt a měli bychom jej jednoduše přeskočit.

Pokud jste TypeScript ještě nepoužívali, definice funkce může vypadat trochu zvláštně. Neříkáme mu jen, že první (a jediný) parametr se jmenuje addr, ale také, že je typu Address. Podobně část : boolean říká TypeScriptu, že návratová hodnota funkce je boolean.

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

Tato funkce získá stvrzenku transakce z události. Stvrzenku potřebujeme, abychom si byli jisti, že víme, jaký byl cíl transakce.

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

Toto je nejdůležitější funkce, ta, která ve skutečnosti rozhoduje, zda je událost podezřelá, či nikoli. Návratový typ, (Event | null), říká TypeScriptu, že tato funkce může vrátit buď Event, nebo null. Pokud událost není podezřelá, vrátíme null.

const owner = ev.args._owner

Viem má názvy polí, takže událost za nás analyzoval. _owner je vlastník tokenů, které mají být utraceny.

// Schválení kontrakty nejsou podezřelá
if (await isContract(owner)) return null

Pokud je vlastníkem kontrakt, předpokládejte, že toto schválení není podezřelé. Abychom zkontrolovali, zda je schválení kontraktu podezřelé, či nikoli, museli bychom sledovat celé provedení transakce, abychom zjistili, zda se vůbec dostala ke kontraktu vlastníka a zda tento kontrakt zavolal kontrakt ERC-20 přímo. To je mnohem náročnější na zdroje, než bychom chtěli.

const txn = await getEventTxn(ev)

Pokud schválení pochází z externě vlastněného účtu, získejte transakci, která jej způsobila.

// Schválení je podezřelé, pokud pochází od vlastníka EOA, který není `from` transakce
if (owner.toLowerCase() != txn.from.toLowerCase()) return ev

Nemůžeme jednoduše kontrolovat rovnost řetězců, protože adresy jsou hexadecimální, takže obsahují písmena. Někdy, například v txn.from, jsou tato písmena všechna malá. V jiných případech, jako je ev.args._owner, je adresa ve smíšené velikosti písmen pro identifikaci chyb (otevře se v nové záložce).

Ale pokud transakce nepochází od vlastníka a tento vlastník je externě vlastněný, pak máme podezřelou transakci.

// Je také podezřelé, pokud cíl transakce není ERC-20 kontrakt, který
// zkoumáme
if (txn.to.toLowerCase() != testedAddress) return ev

Podobně, pokud adresa to transakce, tedy první volaný kontrakt, není vyšetřovaný kontrakt ERC-20, pak je to podezřelé.

    // Pokud není důvod k podezření, vraťte null.
    return null
}

Pokud není splněna ani jedna z podmínek, pak událost Approval není podezřelá.

const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))
const testResults = (await Promise.all(testPromises)).filter((x) => x != null)

console.log(testResults)

Funkce async (otevře se v nové záložce) vrací objekt Promise. S běžnou syntaxí, await x(), čekáme na splnění tohoto Promise, než budeme pokračovat ve zpracování. To je jednoduché na naprogramování a sledování, ale je to také neefektivní. Zatímco čekáme na splnění Promise pro konkrétní událost, můžeme již začít pracovat na další události.

Zde používáme map (otevře se v nové záložce) k vytvoření pole objektů Promise. Poté použijeme Promise.all (otevře se v nové záložce), abychom počkali na vyřešení všech těchto příslibů (promises). Následně na tyto výsledky použijeme filter (otevře se v nové záložce), abychom odstranili nepodezřelé události.

Podezřelé události Transfer

Dalším možným způsobem, jak identifikovat podvodné tokeny, je zjistit, zda nemají nějaké podezřelé převody. Například převody z účtů, které nemají tolik tokenů. Můžete se podívat, jak tento test implementovat (otevře se v nové záložce), ale wARB tento problém nemá.

Závěr

Automatická detekce podvodů s ERC-20 trpí falešně negativními výsledky (otevře se v nové záložce), protože podvod může používat naprosto normální kontrakt tokenu ERC-20, který jen nepředstavuje nic skutečného. Proto byste se měli vždy snažit získat adresu tokenu z důvěryhodného zdroje.

Automatická detekce může pomoci v určitých případech, jako jsou součásti decentralizovaných financí (DeFi), kde je mnoho tokenů a je třeba s nimi zacházet automaticky. Ale jako vždy caveat emptor (otevře se v nové záložce) (kupující, měj se na pozoru), udělejte si vlastní průzkum a povzbuďte své uživatele, aby udělali totéž.

Zde najdete více z mé práce (otevře se v nové záložce).