무작위 대입으로 니모닉을 복구할 수 있을까?

Yuu | GMB LABS

|

General

2025-02-07

Disclaimer : 본 아티클에 언급된 내용은 GMB LABS 개인의 의견으로 GMB LABS의 공식 입장과는 무관합니다.

본 아티클에 포함된 어떠한 내용도 투자 조언이 아니며, 투자 조언으로 해석되어서도 안 됩니다.

Disclaimer : 본 아티클에 언급된 내용은 GMB LABS 개인의 의견으로 GMB LABS의 공식 입장과는 무관합니다.


본 아티클에 포함된 어떠한 내용도 투자 조언이 아니며, 투자 조언으로 해석되어서도 안 됩니다.

밈 코인 열풍과 DEX 거래의 급격한 성장으로
이제는 “니모닉” 이라는 단어는 암호화폐 생태계에서 어렵지 않은 개념이 되었습니다.

해당 글에서는 니모닉의 일부를 잊어 버렸을 경우
무작위 대입(Brute force)을 통하여 복구할 수 있는가를 다루려고 합니다.

니모닉과 계층적 결정에 대해 자세히 알고 싶다면!
https://steemit.com/kr-dev/@modolee/mastering-ethereum-4-wallet

니모닉을 통한 이더리움 지갑 생성은 다음과 같이 코드를 구성할 수 있습니다.

  1. python 의존성 파일을 먼저 설치합니다.
pip install mnemonic eth-account
  1. 랜덤한 니모닉 12개로 지갑을 생성하는 코드를 먼저 구성합니다.
from mnemonic import Mnemonic
from eth_account import Account

def generate_eth_address():
    # BIP39 영단어 목록을 사용하여 니모닉 생성
    mnemo = Mnemonic("english")
    
    # 12단어 니모닉 생성
    mnemonic = mnemo.generate()
    print(f"니모닉: {mnemonic}")
    
    # 니모닉으로부터 이더리움 계정 생성
    Account.enable_unaudited_hdwallet_features()
    account = Account.from_mnemonic(mnemonic)
    
    # 주소 출력
    print(f"이더리움 주소: {account.address}")
    print(f"개인키: {account.key.hex()}")
    
    return mnemonic, account.address

if __name__ == "__main__":
    generate_eth_address()

니모닉: awesome eight gasp decline entire cycle innocent permit until beef sunny return
이더리움 주소: 0xc7a4D73E9266B0E7a274Cd15fBC413F157b1d37A
개인키: 1c5e293177cd88783022a01b3a6ba3c542916e4c79fd52ab57301da601e1a2cb
  • 다음 예제를 이용하여 니모닉을 한 개 분실했을 경우를 가정하겠습니다.

  1. 2048개를 무작위 대입으로 하는 코드를 구성
from mnemonic import Mnemonic
from eth_account import Account
from tqdm import tqdm

# BIP39 영단어 목록을 사용
mnemo = Mnemonic("english")
words = mnemo.wordlist

def find_missing_word():
    incomplete_mnemonic = "sudden chronic alcohol pig path plug left symbol range inject promote"
    target_address = "0x394c38284fbb796c8afb1ddb96c837e86919628b"

    incomplete_words = incomplete_mnemonic.split()
    print(f"시도할 단어 수: {len(words)}")
    
    # HD Wallet 기능 활성화
    Account.enable_unaudited_hdwallet_features()

    for missing_word in tqdm(words):
        try:
            # 완성된 니모닉 문구 생성
            test_mnemonic = " ".join(incomplete_words + [missing_word])
            
            # 니모닉이 유효한지 확인
            if not mnemo.check(test_mnemonic):
                continue
                
            test_account = Account.from_mnemonic(test_mnemonic)
            
            # 주소 확인 (대소문자 구분 없이)
            if test_account.address.lower() == target_address.lower():
                print(f"니모닉: {test_mnemonic}")
                print(f"이더리움 주소: {test_account.address}")
                print(f"개인키: {test_account.key.hex()}")
                return missing_word
        except Exception as e:
            continue
    
    print("\n일치하는 단어를 찾지 못했습니다.")
    return None

if __name__ == "__main__":
    find_missing_word()
시도할 단어 : 2048
완성된 니모닉: sudden chronic alcohol pig path plug left symbol range inject promote hunt
주소: 0x394C38284FBb796C8afb1Ddb96c837E86919628b
개인키: a7536f3ff38c78718dcdffbdac183f148f545e79693dc0982e1f6e370bbfc699

  1. 네 글자라는 힌트를 대입할 경우
from mnemonic import Mnemonic
from eth_account import Account
from tqdm import tqdm

# BIP39 영단어 목록을 사용
mnemo = Mnemonic("english")
# 4글자인 단어만 필터링
words = [word for word in mnemo.wordlist if len(word) == 4]

def find_missing_word():
    incomplete_mnemonic = "sudden chronic alcohol pig path plug left symbol range inject promote"
    target_address = "0x394c38284fbb796c8afb1ddb96c837e86919628b"

    incomplete_words = incomplete_mnemonic.split()
    print(f"시도할 4글자 단어 수: {len(words)}")
    
    # HD Wallet 기능 활성화
    Account.enable_unaudited_hdwallet_features()

    for missing_word in tqdm(words):
        try:
            # 완성된 니모닉 문구 생성
            test_mnemonic = " ".join(incomplete_words + [missing_word])
            
            # 니모닉이 유효한지 확인
            if not mnemo.check(test_mnemonic):
                continue
                
            test_account = Account.from_mnemonic(test_mnemonic)
            
            # 주소 확인 (대소문자 구분 없이)
            if test_account.address.lower() == target_address.lower():
                print(f"니모닉: {test_mnemonic}")
                print(f"이더리움 주소: {test_account.address}")
                print(f"개인키: {test_account.key.hex()}")
                return missing_word
        except Exception as e:
            continue
    
    print("\n일치하는 단어를 찾지 못했습니다.")
    return None

if __name__ == "__main__":
    find_missing_word()
시도할 4글자 단어 : 442
니모닉: sudden chronic alcohol pig path plug left symbol range inject promote hunt
이더리움 주소: 0x394C38284FBb796C8afb1Ddb96c837E86919628b
개인키: a7536f3ff38c78718dcdffbdac183f148f545e79693dc0982e1f6e370bbfc699

실제 실행 시간은 모두 1초 내외입니다.

  • 다수의 니모닉을 분실한 경우로 보입니다.

    니모닉의 첫 글자와 자릿수를 알고 있으니 이를 토대로 분석해 보기로 합니다.

  1. 첫 글자와 자릿수로 해당하는 영단어를 확인하는 예제 코드
from mnemonic import Mnemonic

# BIP39 영단어 목록을 사용
mnemo = Mnemonic("english")
words = mnemo.wordlist

def find_words_by_pattern():
    # s로 시작하는 5글자
    s_five = [word for word in words if word.startswith('s') and len(word) == 5]
    print("\ns로 시작하는 5글자 단어들:")
    print(f"개수: {len(s_five)}")
    print("단어들:", ', '.join(s_five))

if __name__ == "__main__":
    find_words_by_pattern()
  1. 상단의 니모닉
o로 시작하는 3글자 단어들:
개수: 6
단어들: oak, off, oil, old, one, own

c로 시작하는 5글자 단어들:
개수: 57
단어들: cabin, cable, canal, candy, canoe, cargo, carry, catch, cause, chair, chalk, chaos, chase, cheap, check, chest, chief, child, chunk, churn, cigar, civil, claim, clean, clerk, click, cliff, climb, clock, close, cloth, cloud, clown, clump, coach, coast, color, comic, coral, couch, cover, crack, craft, crane, crash, crawl, crazy, cream, creek, crime, crisp, cross, crowd, cruel, crush, curve, cycle

m으로 시작하는 5글자 단어들:
개수: 23
단어들: magic, major, mango, maple, march, match, medal, media, mercy, merge, merit, merry, metal, mimic, minor, mixed, model, month, moral, motor, mouse, movie, music

c로 시작하는 4글자 단어들:
개수: 34
단어들: cage, cake, call, calm, camp, card, cart, case, cash, cave, chat, chef, city, clap, claw, clay, clip, clog, club, code, coil, coin, come, cook, cool, copy, core, corn, cost, cram, crew, crop, cube, cute

가능한 조합의 갯수
6 * 57 * 23 * 57 * 34 = 15,244,308

  1. 하단의 니모닉
s로 시작하는 5글자 단어들:
개수: 92
단어들: salad, salon, sauce, scale, scare, scene, scout, scrap, scrub, sense, setup, seven, shaft, share, shell, shift, shine, shock, shoot, short, shove, shrug, siege, sight, silly, since, siren, skate, skill, skirt, skull, sleep, slice, slide, slush, small, smart, smile, smoke, snack, snake, sniff, solar, solid, solve, sorry, sound, south, space, spare, spawn, speak, speed, spell, spend, spice, spike, split, spoil, spoon, sport, spray, staff, stage, stamp, stand, start, state, steak, steel, stick, still, sting, stock, stone, stool, story, stove, stuff, style, sugar, sunny, super, surge, swamp, swarm, swear, sweet, swift, swing, sword, syrup

s로 시작하는 7글자 단어들:
개수: 37
단어들: sadness, satisfy, satoshi, sausage, scatter, science, section, segment, seminar, service, session, shallow, sheriff, shuffle, sibling, similar, situate, slender, soldier, someone, spatial, special, sponsor, squeeze, stadium, stomach, student, stumble, subject, success, suggest, supreme, surface, suspect, sustain, swallow, symptom

b로 시작하는 5글자 단어들:
개수: 36
단어들: bacon, badge, basic, beach, begin, below, bench, birth, black, blade, blame, blast, bleak, bless, blind, blood, blush, board, bonus, boost, brain, brand, brass, brave, bread, brick, brief, bring, brisk, broom, brown, brush, buddy, build, burst, buyer

m으로 시작하는 7글자 단어들:
개수: 16
단어들: machine, mandate, mansion, maximum, measure, mention, message, million, minimum, miracle, mistake, mixture, monitor, monster, morning, mystery

가능한 조합의 갯수
92 * 37 * 36 * 16 = 1,960,704

  1. 멀티 시그 지갑 주소를 탐색

getOwners를 통해서 멀티시그의 Onwers를 가져올 수 있습니다

0x219B588fbd48B3678864B582f5DDaABC8e8db104
0x6239598a18EA5D413073dc63f5d34cFE0998561b
  1. 하단의 니모닉을 찾는 코드를 구성
from mnemonic import Mnemonic
from eth_account import Account
from tqdm import tqdm
import itertools

# BIP39 영단어 목록을 사용
mnemo = Mnemonic("english")
words = mnemo.wordlist

def find_missing_words():
    # 기본 니모닉 (알려진 부분)
    base_mnemonic = "member throw creek omit pizza valid way balance"
    target_address = "0x219B588fbd48B3678864B582f5DDaABC8e8db104".lower()
    targer_address2 = "0x6239598a18EA5D413073dc63f5d34cFE0998561b".lower()
    
    # 조건에 맞는 단어들 필터링
    s_five = [word for word in words if word.startswith('s') and len(word) == 5]
    s_seven = [word for word in words if word.startswith('s') and len(word) == 7]
    b_five = [word for word in words if word.startswith('b') and len(word) == 5]
    m_seven = [word for word in words if word.startswith('m') and len(word) == 7]
    
    print(f"\n시도할 조합 :")
    print(f"s로 시작하는 5글자 단어: {len(s_five)}개")
    print(f"s로 시작하는 7글자 단어: {len(s_seven)}")
    print(f"b로 시작하는 5글자 단어: {len(b_five)}개")
    print(f"m으로 시작하는 7글자 단어: {len(m_seven)}")
    print(f"총 조합 수: {len(s_five) * len(s_seven) * len(b_five) * len(m_seven)}")
    
    # HD Wallet 기능 활성화
    Account.enable_unaudited_hdwallet_features()
    
    # 모든 가능한 조합 시도
    total = len(s_five) * len(s_seven) * len(b_five) * len(m_seven)
    for words_combo in tqdm(itertools.product(s_five, s_seven, b_five, m_seven), total=total):
        try:
            # 완성된 니모닉 문구 생성
            test_mnemonic = base_mnemonic + " " + " ".join(words_combo)
            
            # 니모닉이 유효한지 확인
            if not mnemo.check(test_mnemonic):
                continue
                
            test_account = Account.from_mnemonic(test_mnemonic)
            
            # 주소 확인 (대소문자 구분 없이)
            if test_account.address.lower() == target_address or test_account.address.lower() == targer_address
                print(f"니모닉: {test_mnemonic}")
                print(f"이더리움 주소: {test_account.address}")
                print(f"개인키: {test_account.key.hex()}")
                return
        except Exception as e:
            continue
    
    print("\n일치하는 니모닉을 찾지 못했습니다.")

if __name__ == "__main__":
    find_missing_words()
조합 : 1960704
니모닉: member throw creek omit pizza valid way balance space swallow brief maximum
이더리움 주소: 0x6239598a18EA5D413073dc63f5d34cFE0998561b
개인키: 73eb42231d1a50f38e641c30b6c285c98703d96fddbe5055b85d7ab233cf503b

분실된 니모닉과 지갑 주소의 매칭을 모르기 때문에 경우의 수가 적은 하단의 니모닉을 먼저 찾도록 합니다.

M1 Pro 경우 14분정도 소요 되었습니다.

  1. 상단 니모닉을 찾는 구성
from mnemonic import Mnemonic
from eth_account import Account
from concurrent.futures import ProcessPoolExecutor
import itertools
import time
from dataclasses import dataclass
from typing import List, Tuple, Optional
import os
import signal
import sys
from tqdm import tqdm

@dataclass
class SearchConfig:
    base_mnemonic: str
    target_address: str
    word_filters: List[Tuple[str, int]]

class MnemonicFinder:
    def __init__(self, config: SearchConfig):
        self.config = config
        self.mnemo = Mnemonic("english")
        self.total_combinations = 0
        self.processed_combinations = 0
        self.found = False
        self.result = None

    def get_filtered_words(self) -> List[List[str]]:
        """각 위치에 대한 필터링된 단어 목록 생성"""
        words = self.mnemo.wordlist
        filtered_words = []
        total_words = 0
        
        for prefix, length in self.config.word_filters:
            word_list = [w for w in words if w.startswith(prefix) and len(w) == length]
            count = len(word_list)
            total_words += count
            print(f"{prefix}로 시작하는 {length}글자 단어: {count}개")
            filtered_words.append(word_list)
        return filtered_words

    def process_chunk(self, chunk: List[Tuple[str, ...]]) -> Optional[Tuple[str, str, str]]:
        """주어진 조합 청크를 처리"""
        # 워커 프로세스에서 HD Wallet 기능 활성화
        Account.enable_unaudited_hdwallet_features()
        
        processed_count = 0
        for combo in chunk:
            if self.found:
                return None

            test_mnemonic = f"{self.config.base_mnemonic} {' '.join(combo)}"
            processed_count += 1
            
            try:
                # 니모닉 검증
                if not self.mnemo.check(test_mnemonic):
                    continue
                    
                account = Account.from_mnemonic(test_mnemonic)
                current_address = account.address.lower()
                
                if current_address == self.config.target_address.lower():
                    return (test_mnemonic, account.address, account.key.hex())
            except Exception as e:
                print(f"에러 발생: {str(e)}")
                continue
            
            self.processed_combinations += 1
        return None

    def chunk_combinations(self, combinations: List[Tuple[str, ...]], chunk_size: int):
        """조합을 청크로 나누기"""
        for i in range(0, len(combinations), chunk_size):
            yield combinations[i:i + chunk_size]

    def search(self) -> Optional[Tuple[str, str, str]]:
        """니모닉 검색 실행"""
        filtered_words = self.get_filtered_words()
        combinations = list(itertools.product(*filtered_words))
        self.total_combinations = len(combinations)
        print(f"총 조합 수: {self.total_combinations:,}개")

        num_processes = os.cpu_count()
        print(f"프로세스 수: {num_processes}")

        # 청크 크기 계산
        chunk_size = max(100, self.total_combinations // (num_processes * 10))
        chunks = list(self.chunk_combinations(combinations, chunk_size))

        processed = 0
        with tqdm(total=self.total_combinations) as pbar:
            with ProcessPoolExecutor(max_workers=num_processes) as executor:
                for result in executor.map(self.process_chunk, chunks):
                    if result:
                        self.found = True
                        return result
                    
                    processed += chunk_size
                    pbar.update(chunk_size)

                    if self.found:
                        break

        return None

def main():
    def signal_handler(signum, frame):
        print("검색을 중단합니다...")
        sys.exit(0)
    
    signal.signal(signal.SIGINT, signal_handler)
    
    # 메인 프로세스에서 HD Wallet 기능 활성화
    Account.enable_unaudited_hdwallet_features()
    
    config = SearchConfig(
        base_mnemonic="camp deal area rigid hidden priority jacket",
        target_address="0x219B588fbd48B3678864B582f5DDaABC8e8db104".lower(),
        word_filters=[
            ('o', 3),  # o로 시작하는 3글자 단어
            ('c', 5),  # c로 시작하는 5글자 단어(1)
            ('m', 5),  # m으로 시작하는 5글자 단어
            ('c', 5),  # c로 시작하는 5글자 단어(2)
            ('c', 4),  # c로 시작하는 4글자 단어
        ]
    )
    
    finder = MnemonicFinder(config)
    result = finder.search()
    
    if result:
        mnemonic, address, private_key = result
        print(f"니모닉: {mnemonic}")
        print(f"이더리움 주소: {address}")
        print(f"개인키: {private_key}")
    else:
        print("일치하는 니모닉을 찾지 못했습니다.")

if __name__ == "__main__":
    main()

니모닉: camp deal area rigid hidden priority jacket own click merge crowd claw
이더리움 주소: 0x219B588fbd48B3678864B582f5DDaABC8e8db104
개인키: ec441b3aba12d30299772f1669337b452ad0bb774346e7d2f88798565edcb1d0

CPU 혹은 GPU 병렬 처리를 활용하여 속도를 더욱 높일 수 있습니다. (예제는 CPU 병렬 처리)

M1 Pro의 경우 30분 정도 소요 되었습니다.

결론

필자의 보안 지식 수준

- 단어 1개를 분실한 경우
경우의 : 2048
예상 복구시간 :1미만


- 단어 2개를 분실한 경우
경우의 : 2048 * 2048 = 4,194,304
예상 복구 시간 : 10내외

- 단어 3개를 분실한 경우
경우의 : 2048 * 2048 * 2048 = 8,589,934,592 
예상 복구 시간 : 1내외

- 단어 12분실한 경우
경우의 : 2048^12 = 5,444,517,870,735,015,936,000,000,000,000,000,000,000
예상 복구 시간 : 불가능

이번 사례는 몇 가지 특수한 조건이 적용되었기 때문에
무작위 대입을 통하여 비교적 빠른 시간 내에 니모닉을 복구해 볼 수 있었으나,
일반적인 상황에서는 사실상 대부분 복구가 어렵다고 보는 것이 맞습니다.

- 니모닉 문구는 절대 온라인에 저장하지 
- 부분적이라도 니모닉을 공개하지 
- 가능하다면 하드웨어 지갑 ( Ledger 등... )

따라서 니모닉을 분실했을 때 복구하는 방법을 찾는 것보다는
애초에 안전하게 보관하는 것이 가장 중요한 대비책입니다.

Latest

©2021-2024. GMB LABS

©2021-2024. GMB LABS

©2021-2024. GMB LABS