Некоторые уловки, используемые мошенническими токенами, и как их обнаружить
В этом руководстве мы разбираем мошеннический токен (opens in a new tab), чтобы увидеть некоторые уловки, которые используют мошенники, и то, как они их реализуют. К концу руководства вы получите более полное представление о контрактах токенов ERC-20, их возможностях и о том, почему необходим скептицизм. Затем мы посмотрим на события, создаваемые этим мошенническим токеном, и увидим, как мы можем автоматически определить, что он не является легитимным.
Мошеннические токены — что это такое, почему люди их создают и как их избежать
Чаще всего Ethereum используется для создания собственного токена, который можно использовать как валюту. Однако везде, где есть настоящие варианты использования чего-либо, что несет ценность, есть и преступники, пытающиеся эту ценность украсть.
Вы можете прочитать больше на эту тему в другом месте на ethereum.org с точки зрения пользователя. Это руководство посвящено разбору мошеннического токена, чтобы увидеть, как он устроен и как его можно обнаружить.
Как я узнаю, что wARB — это мошенничество?
Токен, который мы разбираем, — это wARB (opens in a new tab), который притворяется эквивалентным легитимному токену ARB (opens in a new tab).
Самый простой способ узнать, какой токен является легитимным, — это посмотреть на организацию-эмитента, Arbitrum (opens in a new tab). Легитимные адреса указаны в их документации (opens in a new tab).
Почему исходный код доступен?
Обычно мы ожидаем, что люди, которые пытаются обмануть других, будут скрытными, и действительно, многие мошеннические токены не имеют доступного кода (например, этот (opens in a new tab) и этот (opens in a new tab)).
Однако легитимные токены обычно публикуют свой исходный код, поэтому, чтобы выглядеть легитимными, авторы мошеннических токенов иногда делают то же самое. wARB (opens in a new tab) — один из тех токенов, у которых доступен исходный код, что облегчает его понимание.
Хотя развертыватели контрактов могут выбирать, публиковать исходный код или нет, они не могут опубликовать неверный исходный код. Обозреватель блоков компилирует предоставленный исходный код независимо, и если он не получает точно такой же байт-код, он отклоняет этот исходный код. Вы можете прочитать больше об этом на сайте Etherscan (opens in a new tab).
Сравнение с легитимными токенами ERC-20
Мы собираемся сравнить этот токен с легитимными токенами ERC-20. Если вы не знакомы с тем, как обычно пишутся легитимные токены ERC-20, см. это руководство.
Константы для привилегированных адресов
Контрактам иногда нужны привилегированные адреса. Контракты, предназначенные для долгосрочного использования, позволяют некоторым привилегированным адресам изменять эти адреса, например, чтобы включить использование нового контракта с мультиподписью. Есть несколько способов сделать это.
Контракт токена HOP (opens in a new tab) использует шаблон Ownable (opens in a new tab). Привилегированный адрес хранится в хранилище, в поле под названием _owner (см. третий файл, Ownable.sol).
1abstract contract Ownable is Context {2 address private _owner;3 .4 .5 .6}Контракт токена ARB (opens in a new tab) не имеет привилегированного адреса напрямую. Однако ему он и не нужен. Он находится за прокси (opens in a new tab) по адресу 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens 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, эффективно сжигая его (фактическое сжигание в стандарте также изменяет общее предложение и переводит токены на 0x00...00). Это означает, что contract_owner может изымать активы любого пользователя. Это не похоже на функцию, которую вы хотели бы видеть в токене управления.
Проблемы с качеством кода
Эти проблемы с качеством кода не доказывают, что этот код является мошенническим, но они делают его подозрительным. Организованные компании, такие как Arbitrum, обычно не выпускают настолько плохой код.
Функция mount
Хотя это не указано в стандарте (opens in a new tab), в общем говоря, функция, создающая новые токены, называется mint (opens 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, который ничего не делает?
Этот контракт содержит три модификатора: _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 (opens in a new tab) должны происходить только по прямому запросу (в отличие от событий Transfer (opens in a new tab), которые могут происходить в результате лимита). См. документацию Solidity (opens in a new tab) для подробного объяснения этой проблемы и того, почему запросы должны быть прямыми, а не опосредованными контрактом.
Это означает, что события Approval, которые одобряют расходование средств с аккаунта, принадлежащего внешнему пользователю, должны исходить от транзакций, которые начинаются в этом аккаунте и чьим назначением является контракт ERC-20. Любой другой вид одобрения от аккаунта, принадлежащего внешнему пользователю, является подозрительным.
Вот программа, которая идентифицирует этот тип событий (opens in a new tab), использующая viem (opens in a new tab) и TypeScript (opens 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)Асинхронная функция async (opens in a new tab) возвращает объект Promise. При использовании общего синтаксиса await x() мы ждем выполнения этого Promise перед тем, как продолжить обработку. Это просто программировать и отслеживать, но это также неэффективно. Пока мы ждем выполнения Promise для определенного события, мы уже можем начать работать над следующим событием.
Здесь мы используем map (opens in a new tab) для создания массива объектов Promise. Затем мы используем Promise.all (opens 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 emptor (opens in a new tab), проводите собственное исследование и поощряйте своих пользователей делать то же самое.
Больше моих работ смотрите здесь (opens in a new tab).
Последнее обновление страницы: 25 февраля 2026 г.