Некоторые уловки, используемые мошенническими токенами, и как их обнаружить
В этом руководстве мы разбираем мошеннический токен (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.
contract WrappedArbitrum is Context, IERC20 {
.
.
.
address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;
address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;
.
.
.
}
Этот владелец контракта (opens in a new tab) — не контракт, который мог бы контролироваться разными аккаунтами в разное время, а внешне принадлежащий аккаунт. Это означает, что он, вероятно, предназначен для краткосрочного использования одним человеком, а не как долгосрочное решение для управления ERC-20, который будет сохранять ценность.
И действительно, если мы посмотрим в Etherscan, то увидим, что мошенник использовал этот контракт всего 12 часов (от первой транзакции (opens in a new tab) до последней транзакции (opens in a new tab)) 19 мая 2023 года.
Поддельная функция _transfer
Стандартной практикой является выполнение фактических переводов с использованием внутренней функции _transfer.
В wARB эта функция выглядит почти легитимной:
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);
}
Подозрительная часть:
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
Если владелец контракта отправляет токены, почему событие Transfer показывает, что они исходят от deployer?
Однако есть более важная проблема. Кто вызывает эту функцию _transfer? Ее нельзя вызвать извне, она помечена как internal. И код, который у нас есть, не содержит никаких вызовов _transfer. Очевидно, она здесь в качестве приманки.
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;
}
Когда мы смотрим на функции, которые вызываются для перевода токенов, transfer и transferFrom, мы видим, что они вызывают совершенно другую функцию, _f_.
Настоящая функция _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);
}
В этой функции есть два потенциальных тревожных сигнала.
-
Использование модификатора функции (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 разрешено вызывать эту функцию (см. ниже).
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);
}
}
Для каждого адреса держателя функция перемещает весь баланс держателя на адрес 0x00...01, фактически сжигая его (настоящая функция burn в стандарте также изменяет общее предложение и переводит токены на 0x00...00). Это означает, что contract_owner может удалить активы любого пользователя. Это не похоже на функцию, которую вы хотели бы видеть в токене управления.
Проблемы с качеством кода
Эти проблемы с качеством кода не доказывают, что этот код является мошенническим, но они делают его подозрительным. Организованные компании, такие как Arbitrum, обычно не выпускают настолько плохой код.
Функция mount
Хотя это не указано в стандарте (opens in a new tab), вообще говоря, функция, создающая новые токены, называется mint.
Если мы посмотрим в конструктор wARB, то увидим, что функция чеканки по какой-то причине была переименована в mount и вызывается пять раз с пятой частью начального предложения, вместо одного раза для всей суммы ради эффективности.
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);
}
Сама функция 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_ принимает три параметра и ничего с ними не делает. Зачем он нужен?
modifier auth() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
modifier approver() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
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, чтобы найти подозрительные одобрения.
Вот построчное объяснение:
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), где есть много токенов и их нужно обрабатывать автоматически. Но, как всегда, пусть покупатель будет бдителен (caveat emptor) (opens in a new tab), проводите собственные исследования и поощряйте своих пользователей делать то же самое.
Здесь вы можете найти больше моих работ (opens in a new tab).