Перейти до основного вмісту

Деякі хитрощі, які використовують шахрайські токени, та як їх виявити

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

У цьому посібнику ми розбираємо шахрайський токен (opens in a new tab), щоб побачити деякі хитрощі, до яких вдаються шахраї, і як вони їх реалізують. До кінця посібника ви матимете більш повне уявлення про контракти токенів ERC-20, їхні можливості та чому необхідний скептицизм. Потім ми розглянемо події, які генерує цей шахрайський токен, і побачимо, як ми можемо автоматично визначити, що він не є легітимним.

Шахрайські токени — що це таке, навіщо їх створюють і як їх уникнути

Одне з найпоширеніших застосувань Етеріуму — це створення групою людей токена для торгівлі, у певному сенсі власної валюти. Однак скрізь, де є легітимні варіанти використання, що приносять цінність, є також злочинці, які намагаються вкрасти цю цінність для себе.

Ви можете прочитати більше на цю тему в інших розділах 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).

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

Контракт токена ARB (opens in a new tab) не має привілейованої адреси безпосередньо. Однак вона йому і не потрібна. Він знаходиться за proxy (opens in a new tab) за адресою 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens in a new tab). Цей контракт має привілейовану адресу (див. четвертий файл, 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.

Цей власник контракту (opens in a new tab) — це не контракт, який міг би контролюватися різними акаунтами в різний час, а зовнішній акаунт. Це означає, що він, імовірно, розроблений для короткострокового використання однією особою, а не як довгострокове рішення для контролю над ERC-20, який залишатиметься цінним.

І дійсно, якщо ми подивимося в Etherscan, то побачимо, що шахрай використовував цей контракт лише 12 годин (від першої транзакції (opens in a new tab) до останньої транзакції (opens in a new tab)) протягом 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_

У цій функції є два потенційні тривожні сигнали.

  • Використання модифікатора функції (opens in a new tab) _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 (opens in a new tab), але насправді не переказує жодних токенів. Немає жодної легітимної причини плутати позамережеві застосунки, повідомляючи їм про переказ, якого насправді не було.

Функція спалювання 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

Хоча це не вказано в стандарті (opens in a new tab), загалом функція, яка створює нові токени, називається 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 (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 із безпекою типів. Щоб запустити її:

  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, адреса має змішаний регістр для ідентифікації помилок (opens in a new tab).

Але якщо транзакція не від власника, і цей власник є зовнішнім акаунтом, то ми маємо підозрілу транзакцію.

// Також підозріло, якщо призначенням транзакції не є контракт 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 (opens in a new tab) повертає об'єкт Promise. Зі звичайним синтаксисом, await x(), ми чекаємо на виконання цього Promise, перш ніж продовжити обробку. Це просто запрограмувати та відстежити, але це також неефективно. Поки ми чекаємо на виконання Promise для конкретної події, ми вже можемо почати працювати над наступною подією.

Тут ми використовуємо map (opens in a new tab) для створення масиву об'єктів Promise. Потім ми використовуємо Promise.all (opens in a new tab), щоб дочекатися вирішення всіх цих промісів. Після цього ми застосовуємо filter (opens in a new tab) до цих результатів, щоб видалити непідозрілі події.

Підозрілі події Transfer

Інший можливий спосіб ідентифікувати шахрайські токени — перевірити, чи є в них підозрілі перекази. Наприклад, перекази з акаунтів, які не мають такої кількості токенів. Ви можете побачити, як реалізувати цей тест (opens in a new tab), але wARB не має цієї проблеми.

Висновок

Автоматизоване виявлення шахрайства з ERC-20 страждає від хибнонегативних результатів (opens in a new tab), оскільки шахрайство може використовувати абсолютно нормальний контракт токена ERC-20, який просто не представляє нічого реального. Тому вам завжди слід намагатися отримувати адресу токена з надійного джерела.

Автоматизоване виявлення може допомогти в певних випадках, наприклад, у компонентах децентралізованих фінансів (DeFi), де є багато токенів і їх потрібно обробляти автоматично. Але, як завжди, нехай покупець буде пильним (opens in a new tab) (caveat emptor), проводьте власні дослідження та заохочуйте своїх користувачів робити те саме.

Дивіться тут більше моїх робіт (opens in a new tab).