Деякі прийоми, що використовуються в шахрайських токенах, і як їх виявити
У цьому посібнику ми розберемо шахрайський токенopens in a new tab, щоб побачити деякі з прийомів, до яких вдаються шахраї та як вони їх реалізують. Наприкінці посібника ви матимете більш повне уявлення про контракти токенів ERC-20, їхні можливості та чому необхідний скептицизм. Потім ми розглянемо події, що генеруються цим шахрайським токеном, і побачимо, як можна автоматично визначити, що він не є легітимним.
Шахрайські токени: що це таке, навіщо їх створюють і як їх уникнути
Одне з найпоширеніших застосувань Ethereum – це створення групою осіб торгового токена, у певному сенсі – своєї власної валюти. Однак скрізь, де є законні способи використання, що приносять вигоду, завжди з’являються злочинці, які намагаються вкрасти її.
Ви можете прочитати більше на цю тему в іншому місці на ethereum.org з точки зору користувача. Цей посібник присвячено розбору шахрайського токена, щоб побачити, як він створений і як його можна виявити.
Як дізнатися, що wARB — це шахрайський токен?
Токен, який ми розбираємо, — це wARBopens in a new tab, що видає себе за еквівалент легітимного токена ARBopens in a new tab.
Найпростіший спосіб дізнатися, який токен є легітимним, — це подивитися на організацію-засновника, Arbitrumopens in a new tab. Легітимні адреси вказані в їхній документаціїopens in a new tab.
Чому вихідний код доступний?
Зазвичай ми очікуємо, що люди, які намагаються обдурити інших, будуть потайними, і справді, багато шахрайських токенів не мають доступного коду (наприклад, цейopens in a new tab і цейopens in a new tab).
Однак легітимні токени зазвичай публікують свій вихідний код, тому, щоб здаватися легітимними, автори шахрайських токенів іноді роблять те саме. wARBopens in a new tab — один із тих токенів, вихідний код яких доступний, що полегшує його розуміння.
Хоча розробники контракту можуть обирати, публікувати вихідний код чи ні, вони не можуть опублікувати неправильний вихідний код. Оглядач блоків незалежно компілює наданий вихідний код, і якщо не отримує точно такий самий байт-код, він відхиляє цей вихідний код. Ви можете прочитати більше про це на сайті Etherscanopens in a new tab.
Порівняння з легітимними токенами ERC-20
Ми порівняємо цей токен з легітимними токенами ERC-20. Якщо ви не знайомі з тим, як зазвичай пишуться легітимні токени ERC-20, перегляньте цей посібник.
Константи для привілейованих адрес
Контрактам іноді потрібні привілейовані адреси. Контракти, розроблені для довгострокового використання, дозволяють деяким привілейованим адресам змінювати ці адреси, наприклад, щоб увімкнути використання нового контракту з мультипідписом. Існує кілька способів це зробити.
Контракт токена HOPopens in a new tab використовує патерн Ownableopens in a new tab. Привілейована адреса зберігається в сховищі, в полі під назвою _owner (див. третій файл, Ownable.sol).
1abstract contract Ownable is Context {2 address private _owner;3 .4 .5 .6}Контракт токена ARBopens in a new tab не має привілейованої адреси безпосередньо. Однак він йому й не потрібен. Він знаходиться за проксіopens in a new tab за адресою 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1opens in a new tab. Цей контракт має привілейовану адресу (див. четвертий файл, ERC1967Upgrade.sol), яка може використовуватися для оновлень.
1 /**2 * @dev Зберігає нову адресу в слоті адміністратора EIP1967.3 */4 function _setAdmin(address newAdmin) private {5 require(newAdmin != address(0), "ERC1967: новий адміністратор — це нульова адреса");6 StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;7 }Натомість, контракт wARB має жорстко закодованого contract_owner.
1contract WrappedArbitrum is Context, IERC20 {2 .3 .4 .5 address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;6 address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;7 .8 .9 .10}Показати всеЦей власник контрактуopens in a new tab — це не контракт, яким могли б керувати різні облікові записи в різний час, а зовнішній обліковий запис. Це означає, що він, імовірно, розроблений для короткострокового використання окремою особою, а не як довгострокове рішення для контролю ERC-20, що залишатиметься цінним.
І справді, якщо ми подивимося на Etherscan, то побачимо, що шахрай використовував цей контракт лише 12 годин (від першої транзакціїopens in a new tab до останньої транзакціїopens in a new tab) 19 травня 2023 року.
Підроблена функція _transfer
Стандартною практикою є виконання фактичних переказів за допомогою внутрішньої функції _transfer.
У wARB ця функція виглядає майже легітимною:
1 function _transfer(address sender, address recipient, uint256 amount) internal virtual{2 require(sender != address(0), "ERC20: переказ із нульової адреси");3 require(recipient != address(0), "ERC20: переказ на нульову адресу");45 _beforeTokenTransfer(sender, recipient, amount);67 _balances[sender] = _balances[sender].sub(amount, "ERC20: сума переказу перевищує баланс");8 _balances[recipient] = _balances[recipient].add(amount);9 if (sender == contract_owner){10 sender = deployer;11 }12 emit Transfer(sender, recipient, amount);13 }Показати всеПідозріла частина:
1 if (sender == contract_owner){2 sender = deployer;3 }4 emit Transfer(sender, recipient, amount);Якщо власник контракту надсилає токени, чому подія Transfer показує, що вони надходять від deployer?
Однак є більш важлива проблема. Хто викликає цю функцію _transfer? Її не можна викликати ззовні, оскільки вона позначена як internal. І код, який у нас є, не містить жодних викликів _transfer. Очевидно, що вона тут для відводу очей.
1 function transfer(address recipient, uint256 amount) public virtual override returns (bool) {2 _f_(_msgSender(), recipient, amount);3 return true;4 }56 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: сума переказу перевищує дозволену"));9 return true;10 }Показати всеКоли ми дивимося на функції, які викликаються для переказу токенів, transfer і transferFrom, ми бачимо, що вони викликають зовсім іншу функцію, _f_.
Справжня функція _f_
1 function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {2 require(sender != address(0), "ERC20: переказ із нульової адреси");3 require(recipient != address(0), "ERC20: переказ на нульову адресу");45 _beforeTokenTransfer(sender, recipient, amount);67 _balances[sender] = _balances[sender].sub(amount, "ERC20: сума переказу перевищує баланс");8 _balances[recipient] = _balances[recipient].add(amount);9 if (sender == contract_owner){1011 sender = deployer;12 }13 emit Transfer(sender, recipient, amount);14 }Показати всеУ цій функції є два потенційно підозрілі моменти.
-
Використання модифікатора функціїopens in a new tab
_mod_. Однак, коли ми заглядаємо у вихідний код, ми бачимо, що_mod_насправді нешкідливий.1modifier _mod_(address sender, address recipient, uint256 amount){2 _;3} -
Та сама проблема, яку ми бачили в
_transfer: колиcontract_ownerнадсилає токени, вони виглядають так, ніби надходять відdeployer.
Функція підроблених подій dropNewTokens
Тепер ми підійшли до чогось, що виглядає як справжнє шахрайство. Я трохи відредагував функцію для кращої читабельності, але функціонально вона еквівалентна.
1function dropNewTokens(address uPool,2 address[] memory eReceiver,3 uint256[] memory eAmounts) public auth()Ця функція має модифікатор auth(), що означає, що її може викликати лише власник контракту.
1modifier auth() {2 require(msg.sender == contract_owner, "Взаємодія заборонена");3 _;4}Це обмеження є цілком логічним, оскільки ми б не хотіли, щоб випадкові облікові записи розповсюджували токени. Однак решта функції є підозрілою.
1{2 for (uint256 i = 0; i < eReceiver.length; i++) {3 emit Transfer(uPool, eReceiver[i], eAmounts[i]);4 }5}Функція для переказу з облікового запису пулу масиву отримувачів масиву сум є цілком логічною. Існує багато випадків використання, у яких ви захочете розподілити токени з одного джерела на кілька одержувачів, наприклад, для виплати зарплати, ейрдропів тощо. Це дешевше (за газ) зробити в одній транзакції, замість того, щоб створювати кілька транзакцій або навіть викликати ERC-20 кілька разів з іншого контракту в рамках однієї транзакції.
Однак, dropNewTokens цього не робить. Вона генерує події Transfer`opens in a new tab, але насправді не переказує жодних токенів. Немає жодної легітимної причини вводити в оману позаланцюгові застосунки, повідомляючи їм про переказ, якого насправді не було.
Функція Approve, що спалює токени
Контракти ERC-20 повинні мати функцію approve для дозволів, і справді, наш шахрайський токен має таку функцію, і вона навіть правильна. Однак, оскільки Solidity походить від C, він чутливий до регістру. «Approve» та «approve» — це різні рядки.
Крім того, функціональність не пов'язана з approve.
1 function Approve(2 address[] memory holders)Ця функція викликається з масивом адрес власників токенів.
1 public approver() {Модифікатор approver() гарантує, що лише contract_owner може викликати цю функцію (див. нижче).
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: спалювана сума перевищує баланс");6 _balances[0x0000000000000000000000000000000000000001] =7 _balances[0x0000000000000000000000000000000000000001].add(amount);8 }9 }10Показати всеДля кожної адреси власника функція переміщує весь баланс власника на адресу 0x00...01, фактично спалюючи його (справжній burn у стандарті також змінює загальну пропозицію та переказує токени на 0x00...00). Це означає, що contract_owner може видаляти активи будь-якого користувача. Це не схоже на функцію, яку ви хотіли б бачити в токені управління.
Проблеми з якістю коду
Ці проблеми з якістю коду не доводять, що цей код є шахрайським, але вони роблять його підозрілим. Організовані компанії, такі як Arbitrum, зазвичай не випускають такий поганий код.
Функція mount
Хоча це не вказано в стандартіopens in a new tab, загалом кажучи, функція, що створює нові токени, називається mintopens in a new tab.
Якщо ми подивимося в конструктор wARB, ми побачимо, що функцію карбування з якоїсь причини перейменовано на mount і викликається п’ять разів з п’ятою частиною початкової пропозиції, замість одного разу для всієї суми для ефективності.
1 constructor () public {23 _name = "Wrapped Arbitrum";4 _symbol = "wARB";5 _decimals = 18;6 uint256 initialSupply = 1000000000000;78 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 }Показати всеСама функція mount також підозріла.
1 function mount(address account, uint256 amount) public {2 require(msg.sender == contract_owner, "ERC20: карбування на нульову адресу");Дивлячись на require, ми бачимо, що лише власнику контракту дозволено карбувати токени. Це легітимно. Але повідомлення про помилку має бути карбувати може лише власник або щось подібне. Натомість, це недоречне ERC20: карбування на нульову адресу. Правильна перевірка для карбування на нульову адресу — це require(account != address(0), "<повідомлення про помилку>"), яку контракт ніколи не перевіряє.
1 _totalSupply = _totalSupply.add(amount);2 _balances[contract_owner] = _balances[contract_owner].add(amount);3 emit Transfer(address(0), account, amount);4 }Є ще два підозрілих факти, безпосередньо пов'язаних з карбуванням:
-
Є параметр
account, який, імовірно, є обліковим записом, що повинен отримати накарбовану суму. Але баланс, що збільшується, насправді належитьcontract_owner. -
Хоча збільшений баланс належить
contract_owner, згенерована подія показує переказ наaccount.
Навіщо і auth, і approver? Навіщо mod, який нічого не робить? Чи впливає це оновлення на всі вузли та валідаторів Ethereum?
Цей контракт містить три модифікатори: _mod_, auth і approver.
1 modifier _mod_(address sender, address recipient, uint256 amount){2 _;3 }_mod_ приймає три параметри і нічого з ними не робить. Навіщо він потрібен?
1 modifier auth() {2 require(msg.sender == contract_owner, "Взаємодія заборонена");3 _;4 }56 modifier approver() {7 require(msg.sender == contract_owner, "Взаємодія заборонена");8 _;9 }Показати всеauth та approver мають більше сенсу, тому що вони перевіряють, що контракт був викликаний contract_owner. Ми очікували б, що певні привілейовані дії, такі як карбування, будуть обмежені цим обліковим записом. Однак, який сенс мати дві окремі функції, які роблять точно те саме?
Що ми можемо виявити автоматично?
Ми можемо побачити, що wARB — це шахрайський токен, подивившись на Etherscan. Однак це централізоване рішення. Теоретично, Etherscan може бути підроблений або зламаний. Краще вміти самостійно з'ясувати, чи є токен легітимним чи ні.
Є кілька прийомів, які ми можемо використовувати, щоб визначити, що токен ERC-20 є підозрілим (або шахрайським, або дуже погано написаним), подивившись на події, які вони генерують.
Підозрілі події Approval
Події Approval](https://eips.ethereum.org/EIPS/eip-20#approval) повинні відбуватися лише за прямим запитом (на відміну від [подій Transferopens in a new tab, які можуть відбуватися в результаті дозволу). Дивіться документацію Solidityopens in a new tab для детального пояснення цієї проблеми та того, чому запити мають бути прямими, а не опосередкованими контрактом.
Це означає, що події Approval, які дозволяють витрачати кошти із зовнішнього облікового запису, мають походити від транзакцій, які походять з цього облікового запису, і чиїм пунктом призначення є контракт ERC-20. Будь-який інший вид дозволу із зовнішнього облікового запису є підозрілим.
Ось програма, яка ідентифікує цей тип подійopens in a new tab, використовуючи viemopens in a new tab і TypeScriptopens in a new tab, варіант JavaScript з безпекою типів. Щоб запустити її:
- Скопіюйте
.env.exampleдо.env. - Відредагуйте
.env, щоб надати URL-адресу вузла основної мережі Ethereum. - Запустіть
pnpm install, щоб встановити необхідні пакунки. - Запустіть
pnpm susApproval, щоб знайти підозрілі дозволи.
Ось пояснення по рядках:
1import {2 Address,3 TransactionReceipt,4 createPublicClient,5 http,6 parseAbiItem,7} from "viem"8import { mainnet } from "viem/chains"Імпортуйте визначення типів, функцій та визначення ланцюжка з viem.
1import { config } from "dotenv"2config()Прочитайте .env, щоб отримати URL-адресу.
1const client = createPublicClient({2 chain: mainnet,3 transport: http(process.env.URL),4})Створіть клієнт Viem. Нам потрібно лише читати з блокчейну, тому цьому клієнту не потрібен приватний ключ.
1const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"2const fromBlock = 16859812n3const toBlock = 16873372nАдреса підозрілого контракту ERC-20 та блоки, в межах яких ми будемо шукати події. Провайдери вузлів зазвичай обмежують нашу можливість читати події, оскільки пропускна здатність може бути дорогою. На щастя, wARB не використовувався протягом вісімнадцяти годин, тому ми можемо шукати всі події (їх було лише 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})Це спосіб запитати інформацію про події у Viem. Коли ми надаємо точний підпис події, включаючи назви полів, він розбирає подію для нас.
1const isContract = async (addr: Address): boolean =>2 await client.getBytecode({ address: addr })Наш алгоритм застосовується лише до зовнішніх облікових записів. Якщо client.getBytecode повертає будь-який байт-код, це означає, що це контракт, і ми повинні просто пропустити його.
Якщо ви раніше не використовували TypeScript, визначення функції може виглядати трохи дивно. Ми не просто говоримо, що перший (і єдиний) параметр називається addr, але також, що він має тип Address. Аналогічно, частина : boolean повідомляє TypeScript, що значення, яке повертає функція, є логічним.
1const getEventTxn = async (ev: Event): TransactionReceipt =>2 await client.getTransactionReceipt({ hash: ev.transactionHash })Ця функція отримує квитанцію про транзакцію з події. Нам потрібна квитанція, щоб переконатися, що ми знаємо, яким був пункт призначення транзакції.
1const suspiciousApprovalEvent = async (ev : Event) : (Event | null) => {Це найважливіша функція, та, що насправді вирішує, чи є подія підозрілою чи ні. Тип повернення (Event | null) повідомляє TypeScript, що ця функція може повертати або Event, або null. Ми повертаємо null, якщо подія не є підозрілою.
1const owner = ev.args._ownerViem має назви полів, тому він розібрав подію для нас. _owner — це власник токенів, які будуть витрачені.
1// Дозволи від контрактів не є підозрілими2if (await isContract(owner)) return nullЯкщо власником є контракт, припустимо, що цей дозвіл не є підозрілим. Щоб перевірити, чи є дозвіл контракту підозрілим, нам потрібно буде простежити повне виконання транзакції, щоб побачити, чи дійшла вона до контракту власника, і чи цей контракт викликав контракт ERC-20 безпосередньо. Це набагато більш ресурсоємно, ніж ми хотіли б робити.
1const txn = await getEventTxn(ev)Якщо дозвіл надходить із зовнішнього облікового запису, отримайте транзакцію, яка його спричинила.
1// Дозвіл є підозрілим, якщо він надходить від власника EOA, який не є полем «from» транзакції2if (owner.toLowerCase() != txn.from.toLowerCase()) return evМи не можемо просто перевіряти на рівність рядків, оскільки адреси є шістнадцятковими, тому вони містять літери. Іноді, наприклад, у txn.from, ці літери всі в нижньому регістрі. В інших випадках, наприклад, у ev.args._owner, адреса має змішаний регістр для ідентифікації помилокopens in a new tab.
Але якщо транзакція не від власника, і цей власник є зовнішнім, то ми маємо підозрілу транзакцію.
1// Це також підозріло, якщо пункт призначення транзакції не є контрактом ERC-20, який ми2// розслідуємо3if (txn.to.toLowerCase() != testedAddress) return evАналогічно, якщо адреса to транзакції, тобто перший викликаний контракт, не є контрактом ERC-20, що розслідується, то вона є підозрілою.
1 // Якщо немає причин для підозр, поверніть null.2 return null3}Якщо жодна з умов не виконується, то подія Approval не є підозрілою.
1const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))2const testResults = (await Promise.all(testPromises)).filter((x) => x != null)34console.log(testResults)Функція asyncopens in a new tab повертає об’єкт Promise. Зі звичайним синтаксисом, await x(), ми чекаємо, поки цей Promise буде виконано, перш ніж продовжити обробку. Це просто програмувати і відстежувати, але це також неефективно. Поки ми чекаємо, поки Promise для конкретної події буде виконано, ми вже можемо працювати над наступною подією.
Тут ми використовуємо mapopens in a new tab, щоб створити масив об’єктів Promise. Потім ми використовуємо Promise.allopens in a new tab, щоб дочекатися, поки всі ці проміси будуть вирішені. Потім ми фільтруємоopens in a new tab ці результати, щоб видалити непідозрілі події.
Підозрілі події Transfer
Інший можливий спосіб ідентифікації шахрайських токенів — це перевірити, чи є у них підозрілі перекази. Наприклад, перекази з облікових записів, які не мають такої великої кількості токенів. Ви можете побачити, як реалізувати цей тестopens in a new tab, але wARB не має цієї проблеми.
Висновок
Автоматичне виявлення шахрайства з ERC-20 страждає від хибнонегативних результатівopens in a new tab, тому що шахрайство може використовувати абсолютно нормальний контракт токена ERC-20, який просто не представляє нічого реального. Тому ви завжди повинні намагатися отримувати адресу токена з надійного джерела.
Автоматичне виявлення може допомогти в певних випадках, наприклад, у DeFi, де є багато токенів і їх потрібно обробляти автоматично. Але, як завжди, caveat emptoropens in a new tab (покупець, будь обережним), проводьте власне дослідження та заохочуйте своїх користувачів робити те саме.
Більше моїх робіт дивіться тутopens in a new tab.
Останні оновлення сторінки: 14 лютого 2026 р.