Některé triky používané podvodnými tokeny a jak je odhalit
V tomto tutoriálu rozebereme podvodný token (opens in a new tab), 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í přinášející hodnotu, se najdou i zločinci, kteří se tuto hodnotu snaží 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 to funguje uvnitř a jak jej lze odhalit.
Jak poznám, že je wARB podvod?
Token, který budeme rozebírat, je wARB (opens in a new tab), jenž předstírá, že je ekvivalentem legitimního tokenu ARB (opens in a new tab).
Nejjednodušší způsob, jak poznat, který token je legitimní, je podívat se na původní organizaci, Arbitrum (opens in a new tab). Legitimní adresy jsou uvedeny v jejich dokumentaci (opens in a new tab).
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ů svůj kód nezveřejňuje (například tento (opens in a new tab) a tento (opens in a new tab)).
Legitimní tokeny však 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 (opens in a new tab) 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 webu Etherscan (opens in a new tab).
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 pro umožnění použití nového kontraktu multisig. Existuje několik způsobů, jak to udělat.
Kontrakt tokenu HOP (opens in a new tab) používá vzor Ownable (opens in a new tab). 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 (opens in a new tab) nemá privilegovanou adresu přímo. Nicméně ji ani nepotřebuje. Nachází se za proxy (opens in a new tab) na adrese 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens in a new tab). Tento kontrakt má privilegovanou adresu (viz čtvrtý soubor, ERC1967Upgrade.sol), kterou lze použít pro upgrady.
/**
* @dev Ukládá novou adresu do EIP1967 admin slotu.
*/
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.
contract WrappedArbitrum is Context, IERC20 {
.
.
.
address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;
address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;
.
.
.
}
Tento vlastník kontraktu (opens in a new tab) 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 (opens in a new tab) po poslední transakci (opens in a new tab)) 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ě:
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);
}
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.
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;
}
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_
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);
}
V této funkci jsou dva potenciální varovné signály.
-
Použití modifikátoru funkce (opens in a new tab)
_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_ownerodesílá tokeny, zdá se, že pocházejí zdeployer.
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 na pole příjemců s polem čá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 (opens in a new tab), 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á. Protože však 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).
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);
}
}
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 působí kvůli nim podezřele. Organizované společnosti jako Arbitrum obvykle nevydávají takto špatný kód.
Funkce mount
Ačkoli to není specifikováno ve standardu (opens in a new tab), obecně 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.
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);
}
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 naaccount.
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?
modifier auth() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
modifier approver() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
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 zjistit pohledem na Etherscan. To je však centralizované řešení. Teoreticky by mohl být Etherscan kompromitován nebo hacknut. Je lepší umět 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ď jde o podvod, nebo je velmi špatně napsaný), a to sledováním událostí, které vysílá.
Podezřelé události Approval
Události Approval (opens in a new tab) by měly nastat pouze při přímém požadavku (na rozdíl od událostí Transfer (opens in a new tab), které mohou nastat v důsledku povoleného limitu). Podívejte se do dokumentace Solidity (opens in a new tab) 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é z tohoto účtu vycházejí 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ý tento druh události identifikuje (opens in a new tab), s využitím Viem (opens in a new tab) a TypeScript (opens in a new tab), varianty JavaScript s typovou bezpečností. Pro jeho spuštění:
- Zkopírujte
.env.exampledo.env. - Upravte
.envtak, aby poskytoval URL k uzlu Ethereum Mainnet. - Spusťte
pnpm installpro instalaci potřebných balíčků. - Spusťte
pnpm susApprovalpro 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"
Import definic typů, funkcí a definice řetězce z viem.
import { config } from "dotenv"
config()
Přečtení .env pro získání URL.
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.URL),
})
Vytvoření 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 pro 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í nějaký 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 to, že první (a jediný) parametr se jmenuje addr, ale také to, ž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 měli jistotu, ž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 pro 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ádáme, ž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ískáme 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 shodu ř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 (opens in a new tab).
Pokud ale 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 (opens in a new tab) 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 už začít pracovat na další události.
Zde používáme map (opens in a new tab) k vytvoření pole objektů Promise. Poté použijeme Promise.all (opens in a new tab), abychom počkali na vyřešení všech těchto příslibů (promises). Následně tyto výsledky filter (opens in a new tab) (vyfiltrujeme), 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 (opens in a new tab), ale wARB tento problém nemá.
Závěr
Automatická detekce podvodů ERC-20 trpí falešně negativními výsledky (opens in a new tab), 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 platí caveat emptor (opens in a new tab) (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 (opens in a new tab).
Poslední aktualizace stránky: 3. dubna 2026