Перейти к основному контенту

Некоторые уловки, используемые мошенническими токенами, и как их обнаружить

мошенничество
Solidity
erc-20
JavaScript
TypeScript
Средний уровень
Ори Померанц
15 сентября 2023 г.
14 минут на чтение

В этом руководстве мы разбираем мошеннический токен (открывается в новой вкладке), чтобы увидеть некоторые уловки, к которым прибегают мошенники, и как они их реализуют. К концу руководства у вас будет более полное представление о контрактах токенов ERC-20, их возможностях и о том, почему необходим скептицизм. Затем мы рассмотрим события, генерируемые этим мошенническим токеном, и узнаем, как можно автоматически определить, что он не является легитимным.

Мошеннические токены: что это такое, зачем их создают и как их избежать

Одно из самых распространенных применений Эфириума — создание группой людей торгуемого токена, в некотором смысле их собственной валюты. Однако везде, где есть законные варианты использования, приносящие ценность, есть и преступники, которые пытаются украсть эту ценность для себя.

Вы можете узнать больше об этой теме в других разделах ethereum.org с точки зрения пользователя. Это руководство сосредоточено на разборе мошеннического токена, чтобы увидеть, как он устроен и как его можно обнаружить.

Как узнать, что wARB — это мошенничество?

Токен, который мы разбираем, — это wARB (открывается в новой вкладке), который притворяется эквивалентом легитимного токена ARB (открывается в новой вкладке).

Самый простой способ узнать, какой токен является легитимным, — посмотреть на организацию-создателя, Arbitrum (открывается в новой вкладке). Легитимные адреса указаны в их документации (открывается в новой вкладке).

Почему исходный код доступен?

Обычно мы ожидаем, что люди, пытающиеся обмануть других, будут скрытными, и действительно, код многих мошеннических токенов недоступен (например, этого (открывается в новой вкладке) и этого (открывается в новой вкладке)).

Однако легитимные токены обычно публикуют свой исходный код, поэтому, чтобы казаться легитимными, авторы мошеннических токенов иногда делают то же самое. wARB (открывается в новой вкладке) — один из таких токенов с доступным исходным кодом, что облегчает его понимание.

Хотя те, кто развертывает контракты, могут выбирать, публиковать исходный код или нет, они не могут опубликовать неправильный исходный код. Обозреватель блоков компилирует предоставленный исходный код независимо, и если он не получает точно такой же байт-код, он отклоняет этот исходный код. Вы можете узнать больше об этом на сайте Etherscan (открывается в новой вкладке).

Сравнение с легитимными токенами ERC-20

Мы собираемся сравнить этот токен с легитимными токенами ERC-20. Если вы не знакомы с тем, как обычно пишутся легитимные токены ERC-20, ознакомьтесь с этим руководством.

Константы для привилегированных адресов

Контрактам иногда требуются привилегированные адреса. Контракты, предназначенные для долгосрочного использования, позволяют некоторому привилегированному адресу изменять эти адреса, например, чтобы разрешить использование нового контракта мультисиг. Есть несколько способов сделать это.

Контракт токена HOP (открывается в новой вкладке) использует шаблон Ownable (открывается в новой вкладке). Привилегированный адрес хранится в хранилище, в поле под названием _owner (см. третий файл, Ownable.sol).

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

Контракт токена ARB (открывается в новой вкладке) не имеет привилегированного адреса напрямую. Однако он ему и не нужен. Он находится за proxy (открывается в новой вкладке) по адресу 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (открывается в новой вкладке). У этого контракта есть привилегированный адрес (см. четвертый файл, ERC1967Upgrade.sol), который можно использовать для обновлений.

    /**
     * @dev Сохраняет новый Адрес в слоте администратора EIP1967.
     */
    function _setAdmin(address newAdmin) private {
        require(newAdmin != address(0), "ERC1967: new admin is the zero address");
        StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
    }

В отличие от этого, контракт wARB имеет жестко закодированный contract_owner.

Этот владелец контракта (открывается в новой вкладке) — это не контракт, который мог бы контролироваться разными аккаунтами в разное время, а внешне принадлежащий аккаунт. Это означает, что он, вероятно, предназначен для краткосрочного использования одним человеком, а не как долгосрочное решение для управления токеном ERC-20, который сохранит свою ценность.

И действительно, если мы посмотрим в Etherscan, то увидим, что мошенник использовал этот контракт всего 12 часов (от первой транзакции (открывается в новой вкладке) до последней транзакции (открывается в новой вкладке)) 19 мая 2023 года.

Поддельная функция _transfer

Стандартной практикой является выполнение фактических переводов с использованием внутренней функции _transfer.

В wARB эта функция выглядит почти легитимной:

Подозрительная часть заключается в следующем:

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

Если владелец контракта отправляет токены, почему событие Transfer показывает, что они исходят от deployer?

Однако есть более важная проблема. Кто вызывает эту функцию _transfer? Ее нельзя вызвать извне, она помечена как internal. И код, который у нас есть, не содержит никаких вызовов _transfer. Очевидно, она здесь в качестве приманки.

Когда мы смотрим на функции, которые вызываются для перевода токенов, transfer и transferFrom, мы видим, что они вызывают совершенно другую функцию, _f_.

Настоящая функция _f_

В этой функции есть два потенциальных тревожных сигнала.

  • Использование модификатора функции (открывается в новой вкладке) _mod_. Однако, когда мы заглядываем в исходный код, мы видим, что _mod_ на самом деле безвреден.

    modifier _mod_(address sender, address recipient, uint256 amount){
      _;
    }
    
  • Та же проблема, которую мы видели в _transfer, а именно: когда contract_owner отправляет токены, кажется, что они исходят от deployer.

Функция поддельных событий dropNewTokens

Теперь мы переходим к тому, что выглядит как настоящее мошенничество. Я немного отредактировал функцию для удобства чтения, но функционально она эквивалентна.

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

Эта функция имеет модификатор auth(), что означает, что ее может вызвать только владелец контракта.

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

Это ограничение имеет полный смысл, потому что мы бы не хотели, чтобы случайные аккаунты распределяли токены. Однако остальная часть функции вызывает подозрения.

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

Функция для перевода с аккаунта пула на массив получателей массива сумм имеет полный смысл. Существует множество вариантов использования, когда вы захотите распределить токены из одного источника по нескольким адресатам, например, для выплаты заработной платы, аирдропов и т. д. Дешевле (в газе) сделать это одной транзакцией, вместо того чтобы выпускать несколько транзакций или даже вызывать ERC-20 несколько раз из другого контракта в рамках одной и той же транзакции.

Однако dropNewTokens этого не делает. Она генерирует события Transfer (открывается в новой вкладке), но на самом деле не переводит никаких токенов. Нет никаких законных причин сбивать с толку офчейн-приложения, сообщая им о переводе, которого на самом деле не было.

Функция сжигания Approve

Предполагается, что контракты ERC-20 имеют функцию approve для разрешений, и действительно, наш мошеннический токен имеет такую функцию, и она даже правильная. Однако, поскольку Solidity произошел от C, он чувствителен к регистру. «Approve» и «approve» — это разные строки.

Кроме того, функциональность не связана с approve.

    function Approve(
        address[] memory holders)

Эта функция вызывается с массивом адресов держателей токена.

    public approver() {

Модификатор approver() гарантирует, что только contract_owner разрешено вызывать эту функцию (см. ниже).

Для каждого адреса держателя функция перемещает весь баланс держателя на адрес 0x00...01, фактически сжигая его (настоящий burn в стандарте также изменяет общее предложение и переводит токены на 0x00...00). Это означает, что contract_owner может удалить активы любого пользователя. Не похоже на функцию, которую вы хотели бы видеть в токене управления.

Проблемы с качеством кода

Эти проблемы с качеством кода не доказывают, что этот код является мошенническим, но они делают его подозрительным. Организованные компании, такие как Arbitrum, обычно не выпускают настолько плохой код.

Функция mount

Хотя это не указано в стандарте (открывается в новой вкладке), вообще говоря, функция, которая создает новые токены, называется mint.

Если мы посмотрим в конструктор wARB, то увидим, что функция чеканки по какой-то причине была переименована в mount и вызывается пять раз с пятой частью начального предложения, вместо одного раза для всей суммы ради эффективности.

Сама функция mount также вызывает подозрения.

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

Глядя на require, мы видим, что чеканить разрешено только владельцу контракта. Это легитимно. Но сообщение об ошибке должно быть only owner is allowed to mint (только владельцу разрешено чеканить) или что-то в этом роде. Вместо этого выводится нерелевантное ERC20: mint to the zero address (ERC20: чеканка на нулевой адрес). Правильная проверка на чеканку на нулевой адрес — это require(account != address(0), "<error message>"), которую контракт даже не удосуживается проверить.

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

Есть еще два подозрительных факта, напрямую связанных с чеканкой:

  • Существует параметр account, который, предположительно, является аккаунтом, который должен получить отчеканенную сумму. Но баланс, который увеличивается, на самом деле принадлежит contract_owner.

  • Хотя увеличенный баланс принадлежит contract_owner, сгенерированное событие показывает перевод на account.

Зачем нужны и auth, и approver? Зачем нужен mod, который ничего не делает?

Этот контракт содержит три модификатора: _mod_, auth и approver.

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

_mod_ принимает три параметра и ничего с ними не делает. Зачем он нужен?

auth и approver имеют больше смысла, потому что они проверяют, что контракт был вызван contract_owner. Мы ожидаем, что определенные привилегированные действия, такие как чеканка, будут ограничены этим аккаунтом. Однако какой смысл иметь две отдельные функции, которые делают абсолютно одно и то же?

Что мы можем обнаружить автоматически?

Мы можем увидеть, что wARB — это мошеннический токен, посмотрев на Etherscan. Однако это централизованное решение. Теоретически Etherscan может быть скомпрометирован или взломан. Лучше уметь самостоятельно определять, является ли токен легитимным или нет.

Есть несколько уловок, которые мы можем использовать, чтобы определить, что токен ERC-20 является подозрительным (либо мошенническим, либо очень плохо написанным), посмотрев на события, которые они генерируют.

Подозрительные события Approval

События Approval (открывается в новой вкладке) должны происходить только при прямом запросе (в отличие от событий Transfer (открывается в новой вкладке), которые могут происходить в результате разрешения). См. документацию Solidity (открывается в новой вкладке) для подробного объяснения этой проблемы и того, почему запросы должны быть прямыми, а не опосредованными контрактом.

Это означает, что события Approval, которые одобряют расходование средств с внешне принадлежащего аккаунта, должны исходить из транзакций, которые инициируются в этом аккаунте и чьим местом назначения является контракт ERC-20. Любой другой вид одобрения с внешне принадлежащего аккаунта вызывает подозрения.

Вот программа, которая идентифицирует этот вид события (открывается в новой вкладке), используя Viem (открывается в новой вкладке) и TypeScript (открывается в новой вкладке), вариант JavaScript с безопасностью типов. Чтобы запустить ее:

  1. Скопируйте .env.example в .env.
  2. Отредактируйте .env, чтобы указать URL-адрес узла основной сети Ethereum.
  3. Запустите pnpm install для установки необходимых пакетов.
  4. Запустите pnpm susApproval для поиска подозрительных одобрений.

Вот построчное объяснение:

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

Импортируйте определения типов, функции и определение цепи из viem.

import { config } from "dotenv"
config()

Прочитайте .env, чтобы получить URL-адрес.

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

Создайте клиент Viem. Нам нужно только читать из блокчейна, поэтому этому клиенту не нужен приватный ключ.

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

Адрес подозрительного контракта ERC-20 и блоки, в которых мы будем искать события. Провайдеры узлов обычно ограничивают нашу возможность читать события, потому что пропускная способность может быть дорогой. К счастью, wARB не использовался в течение восемнадцати часов, поэтому мы можем просмотреть все события (всего их было 13).

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

Это способ запросить у Viem информацию о событии. Когда мы предоставляем ему точную сигнатуру события, включая имена полей, он анализирует событие для нас.

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

Наш алгоритм применим только к внешне принадлежащим аккаунтам. Если client.getBytecode возвращает какой-либо байт-код, это означает, что это контракт, и мы должны просто пропустить его.

Если вы раньше не использовали TypeScript, определение функции может показаться немного странным. Мы не просто говорим ему, что первый (и единственный) параметр называется addr, но и что он имеет тип Address. Аналогично, часть : boolean сообщает TypeScript, что возвращаемое значение функции является логическим (boolean).

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

Эта функция получает квитанцию транзакции из события. Нам нужна квитанция, чтобы убедиться, что мы знаем, каково было место назначения транзакции.

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

Это самая важная функция, та, которая фактически решает, является ли событие подозрительным или нет. Возвращаемый тип, (Event | null), сообщает TypeScript, что эта функция может возвращать либо Event, либо null. Мы возвращаем null, если событие не является подозрительным.

const owner = ev.args._owner

У Viem есть имена полей, поэтому он проанализировал событие для нас. _owner — это владелец токенов, которые будут потрачены.

// Одобрения контрактами не являются подозрительными
if (await isContract(owner)) return null

Если владельцем является контракт, предполагаем, что это одобрение не является подозрительным. Чтобы проверить, является ли одобрение контракта подозрительным или нет, нам нужно будет отследить полное выполнение транзакции, чтобы увидеть, дошла ли она когда-либо до контракта владельца, и вызывал ли этот контракт контракт ERC-20 напрямую. Это требует гораздо больше ресурсов, чем нам хотелось бы.

const txn = await getEventTxn(ev)

Если одобрение исходит от внешне принадлежащего аккаунта, получите транзакцию, которая его вызвала.

// Одобрение является подозрительным, если оно исходит от владельца EOA, который не является `from` транзакции
if (owner.toLowerCase() != txn.from.toLowerCase()) return ev

Мы не можем просто проверить равенство строк, потому что адреса шестнадцатеричные, поэтому они содержат буквы. Иногда, например в txn.from, эти буквы все строчные. В других случаях, таких как ev.args._owner, адрес указан в смешанном регистре для идентификации ошибок (открывается в новой вкладке).

Но если транзакция не от владельца, и этот владелец является внешне принадлежащим, то перед нами подозрительная транзакция.

// Также подозрительно, если местом назначения транзакции не является контракт ERC-20, который мы
// исследуем
if (txn.to.toLowerCase() != testedAddress) return ev

Аналогично, если адрес to транзакции, первый вызванный контракт, не является исследуемым контрактом ERC-20, то это подозрительно.

    // Если нет причин для подозрений, вернуть null.
    return null
}

Если ни одно из условий не выполняется, то событие Approval не является подозрительным.

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

console.log(testResults)

Функция async (открывается в новой вкладке) возвращает объект Promise. При использовании обычного синтаксиса await x() мы ждем выполнения этого Promise, прежде чем продолжить обработку. Это просто программировать и отслеживать, но это также неэффективно. Пока мы ждем выполнения Promise для конкретного события, мы уже можем начать работу над следующим событием.

Здесь мы используем map (открывается в новой вкладке) для создания массива объектов Promise. Затем мы используем Promise.all (открывается в новой вкладке), чтобы дождаться разрешения всех этих промисов. Затем мы filter (открывается в новой вкладке) эти результаты, чтобы удалить неподозрительные события.

Подозрительные события Transfer

Еще один возможный способ выявить мошеннические токены — посмотреть, есть ли у них какие-либо подозрительные переводы. Например, переводы с аккаунтов, на которых нет такого количества токенов. Вы можете посмотреть, как реализовать этот тест (открывается в новой вкладке), но у wARB нет этой проблемы.

Заключение

Автоматическое обнаружение мошенничества с ERC-20 страдает от ложноотрицательных результатов (открывается в новой вкладке), потому что мошенничество может использовать совершенно нормальный контракт токена ERC-20, который просто не представляет ничего реального. Поэтому вам всегда следует пытаться получить адрес токена из надежного источника.

Автоматическое обнаружение может помочь в определенных случаях, таких как элементы децентрализованных финансов (DeFi), где много токенов и их нужно обрабатывать автоматически. Но, как всегда, пусть покупатель будет бдителен (открывается в новой вкладке) (caveat emptor), проводите собственные исследования и поощряйте своих пользователей делать то же самое.

Смотрите здесь больше моих работ (открывается в новой вкладке).