├── __init__.py ├── scripts ├── run ├── bootstrap ├── scrape_addresses.py └── scrape_blocks.py ├── requirements.txt ├── Dockerfile ├── .gitignore ├── monitoring.py ├── lookups.py ├── targets.py ├── README.md └── brute_force_app.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Execute the application with defaults 3 | 4 | LC_ALL=C.UTF-8 LANG=C.UTF-8 ./venv/bin/python3 brute_force_app.py $* 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrdict 2 | bs4 3 | click 4 | colorama 5 | ecdsa 6 | requests 7 | pysha3>=1.0.2 8 | pyyaml 9 | pytest 10 | sortedcollections 11 | -------------------------------------------------------------------------------- /scripts/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Bootstrap a running environment 3 | 4 | sudo apt-get install -y libyaml-dev libpython3-dev python3-dev 5 | 6 | virtualenv -p python3 venv 7 | ./venv/bin/pip install -r requirements.txt 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/386 python:3 2 | 3 | WORKDIR /usr/src/app 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y \ 7 | build-essential \ 8 | libpython3-dev python3-dev \ 9 | python3-yaml \ 10 | libyaml-dev 11 | 12 | RUN pip3 install --upgrade pip 13 | COPY requirements.txt ./ 14 | RUN pip3 install --no-cache-dir -r requirements.txt 15 | 16 | COPY . . 17 | 18 | CMD ["python", "./brute_force_app.py"] 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local swap files 2 | *~ 3 | *.sw? 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | -------------------------------------------------------------------------------- /scripts/scrape_addresses.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Fetch addresses from the Ethereum ledger using etherscan.io.""" 3 | 4 | import collections 5 | import sys 6 | 7 | from bs4 import BeautifulSoup 8 | import click 9 | import requests 10 | import yaml 11 | 12 | 13 | def _parse_etherscan_accounts_page(html_text): 14 | """Parse the account information out of the webscrape.""" 15 | soup = BeautifulSoup(html_text, 'html.parser') 16 | 17 | retval = [] 18 | div = soup.find('div', attrs={'class': 'table-responsive'}) 19 | if not div: 20 | return retval 21 | 22 | headings = [str(th.find(text=True)) for th in div.findAll('th') if th] 23 | for tr in div.findAll('tr'): 24 | cols = [str(td.find(text=True)) for td in tr.findAll('td') if td] 25 | row = dict(zip(headings, cols)) 26 | address = row.get('Address', '0x')[2:] 27 | if address: 28 | retval.append(address) 29 | 30 | return retval 31 | 32 | 33 | @click.command() 34 | @click.option('--start', default=0, help='First page to scrape.') 35 | @click.option('--end', default=10, help='Last page to scrape.') 36 | @click.option('--outfile', 37 | type=click.File('w'), 38 | help='Write addresses (in yaml) to this file.') 39 | def main(start, end, outfile): 40 | """Scrape https://etherscan.io for the top ETH addresses.""" 41 | all_addrs = collections.OrderedDict() 42 | 43 | for page_num in range(start, end + 1): 44 | before = len(all_addrs) 45 | 46 | url = 'https://etherscan.io/accounts/%d' % (page_num,) 47 | reply = requests.get(url) 48 | for addr in _parse_etherscan_accounts_page(reply.text): 49 | all_addrs[addr] = True 50 | print('%s added %d new addresses' % (url, len(all_addrs) - before)) 51 | 52 | print('Total addresses found:', len(all_addrs)) 53 | 54 | outfile = outfile or sys.stdout 55 | yaml.safe_dump(list(all_addrs.keys()), outfile, default_flow_style=False) 56 | 57 | 58 | if '__main__' == __name__: 59 | main() 60 | -------------------------------------------------------------------------------- /monitoring.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Monitoring port for long-running python command line applications.""" 3 | 4 | from http.server import BaseHTTPRequestHandler, HTTPServer 5 | from socketserver import ThreadingTCPServer 6 | import threading 7 | 8 | import attrdict 9 | import yaml 10 | 11 | 12 | class MonitoringPortHandler(BaseHTTPRequestHandler): 13 | monitored_values = attrdict.AttrDict() 14 | 15 | def do_GET(self): 16 | self.send_response(200) 17 | self.send_header('Content-type', 'text/plain') 18 | self.end_headers() 19 | 20 | # read the monitored values and override compute any lazy values 21 | monits = dict(self.monitored_values.items()) 22 | for key in monits: 23 | if hasattr(monits[key], 'Calculate'): 24 | monits[key] = monits[key].Calculate() 25 | 26 | reply = yaml.safe_dump(monits, default_flow_style=False) 27 | self.wfile.write(bytes(reply, 'utf8')) 28 | 29 | def log_message(self, fmt, *args): 30 | """Squelch monitoring for now""" 31 | pass 32 | 33 | 34 | class MonitoringPortServer(HTTPServer, ThreadingTCPServer): 35 | """A multithreaded http server for exporting monitoring information.""" 36 | HANDLER = MonitoringPortHandler 37 | def __init__(self, server_address, handler=HANDLER): 38 | super().__init__(server_address, handler) 39 | 40 | 41 | class Server(object): 42 | """An HTTP server that replies with monitored values.""" 43 | def __init__(self): 44 | # FIXME: dicts added to our stats are not mutable 45 | self.monits = attrdict.AttrDict() 46 | self._httpd = None 47 | 48 | def Start(self, address, port): 49 | if port: 50 | print('web-server on:', (address, port)) 51 | MonitoringPortHandler.monitored_values = self.monits 52 | self._httpd = MonitoringPortServer((address, port)) 53 | threading.Thread(target=self._httpd.serve_forever).start() 54 | return self.monits 55 | 56 | def Stop(self): 57 | if self._httpd: 58 | self._httpd.shutdown() 59 | 60 | def DefineComputedStat(self, func, units=''): 61 | return ComputedStat(func, self.monits, units) 62 | 63 | 64 | class ComputedStat(object): 65 | """A stat that is computed from the _GLOBAL_STATS as a namespace.""" 66 | def __init__(self, func, context, units=''): 67 | self._func = func 68 | self._units = units 69 | self._context = context 70 | 71 | def Calculate(self): 72 | return self._func(self._context) 73 | 74 | def __str__(self): 75 | val = self._func(self._context) 76 | return ' '.join(map(str, (val, self._units))) 77 | -------------------------------------------------------------------------------- /lookups.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple implementation of a trie-like data structure to store target 3 | ETH addresses. 4 | """ 5 | 6 | 7 | import bisect 8 | import sys 9 | 10 | 11 | import sortedcollections 12 | 13 | 14 | def hex_to_int(hex_address): 15 | return int(hex_address, 16) 16 | 17 | 18 | def int_to_hex(bin_address): 19 | return '%040x' % bin_address 20 | 21 | 22 | class Trie(object): 23 | """Convert a list of target addresses into a trie. 24 | 25 | Encoding the the target addresses as the prefixes in the trie allows 26 | use to quickly find how close the guess is to any of the target addresses. 27 | 28 | Each node in the trie corresponds to a prefix in one of the possible 29 | target addresses. If there is no path from a node, then there is 30 | no matching target address. 31 | 32 | For example; given the targets [ abcde, abbcd, abcdf, acdef ], the 33 | resulting trie would look like: 34 | 35 | a -> b -> b -> c -> d 36 | \-> c -> d -> e 37 | \-> f 38 | c -> d -> e -> f 39 | 40 | This provides a much smaller memory footprint, but does not provide 41 | information on the nearest match. 42 | """ 43 | def __init__(self, list_of_addresses=None): 44 | self._size = 0 45 | self._value = {} 46 | self.Extend(list_of_addresses or []) 47 | 48 | def __len__(self): 49 | return self._size 50 | 51 | def sizeof(self): 52 | return sys.getsizeof(self._value) 53 | 54 | def Extend(self, list_of_addresses): 55 | for target in [t.lower() for t in list_of_addresses]: 56 | self._size += 1 57 | ptr = self._value 58 | for digit in target: 59 | if digit not in ptr: 60 | ptr[digit] = {} 61 | ptr = ptr[digit] 62 | return self._value 63 | 64 | def FindClosestMatch(self, hex_address): 65 | """Traverse the trie, matching as far as we can. 66 | 67 | Args: a potential ETH address 68 | 69 | Returns: a tuple of (count, address), where `count` is the 70 | number of of leading hex digits that match a target address 71 | and `address` is the corresponding best match. 72 | """ 73 | rest = [] 74 | trie = self._value 75 | for count, char in enumerate(hex_address): 76 | if char not in trie: 77 | break 78 | trie = trie[char] 79 | 80 | # TODO walk the rest of the way down the trie to find the closest match 81 | nearest_match = None 82 | return count, hex_address, nearest_match 83 | 84 | 85 | class NearestDict(object): 86 | """Similar to EthereumAddressTrie, but use a NearestDict instead. 87 | 88 | Equivalent speed, easily provides nearest match, uses standard library. 89 | """ 90 | def __init__(self, list_of_addresses=None): 91 | self._value = sortedcollections.NearestDict( 92 | {hex_to_int(addr): True for addr in list_of_addresses}) 93 | 94 | def __len__(self): 95 | return len(self._value) 96 | 97 | def sizeof(self): 98 | return sys.getsizeof(self._value) 99 | 100 | def Extend(self, list_of_addresses): 101 | for addr in [t.lower() for t in list_of_addresses]: 102 | self._value[hex_to_int(addr)] = True 103 | 104 | def FindClosestMatch(self, hex_address): 105 | bin_addr = hex_to_int(hex_address) 106 | nearest_match = int_to_hex(self._value.nearest_key(bin_addr)) 107 | 108 | strength = 0 109 | for lhs, rhs in zip(hex_address, nearest_match): 110 | # TODO return list of matches rather than integer so it's not just leading matches 111 | # strength += 1 if lhs == rhs else 0 112 | if lhs != rhs: 113 | break 114 | strength += 1 115 | 116 | return strength, hex_address, nearest_match 117 | 118 | 119 | class BisectTuple(object): 120 | def __init__(self, list_of_addresses=None): 121 | self._value = tuple(sorted(hex_to_int(addr) for addr in list_of_addresses)) 122 | 123 | def __len__(self): 124 | return len(self._value) 125 | 126 | def sizeof(self): 127 | return sys.getsizeof(self._value) 128 | 129 | def FindClosestMatch(self, hex_address): 130 | bin_addr = hex_to_int(hex_address) 131 | 132 | idx = bisect.bisect(self._value, bin_addr) 133 | nearest_match = int_to_hex(self._value[idx - 1]) 134 | 135 | strength = 0 136 | for lhs, rhs in zip(hex_address, nearest_match): 137 | # TODO return list of matches rather than integer so it's not just leading matches 138 | # strength += 1 if lhs == rhs else 0 139 | if lhs != rhs: 140 | break 141 | strength += 1 142 | 143 | return strength, hex_address, nearest_match 144 | 145 | 146 | def PickStrategy(name_of_strategy): 147 | strategy_map = { 148 | 'trie': Trie, 149 | 'nearest': NearestDict, 150 | 'bisect': BisectTuple} 151 | return strategy_map.get(name_of_strategy, NearestDict) 152 | -------------------------------------------------------------------------------- /scripts/scrape_blocks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Given a block-id, crawl the transactions for ETH addresses. 3 | 4 | Given a block-id on the command line, go through every transaction collecting 5 | the public addresses. Recursively. 6 | """ 7 | 8 | import os 9 | import random 10 | import re 11 | import sys 12 | import time 13 | import urllib.parse 14 | 15 | from bs4 import BeautifulSoup 16 | import click 17 | import requests 18 | import yaml 19 | 20 | 21 | def _find_last_page(html_text): 22 | """Scan the HTML for the Last page, and return a list of relative URLs.""" 23 | soup = BeautifulSoup(html_text, 'html.parser') 24 | 25 | # Searching for 'https://etherscan.io/txs?block=5066192&p=2' 26 | last_page = soup.find('a', 27 | attrs={'class': 'btn btn-default btn-xs logout'}, 28 | string='Last') 29 | if not last_page: 30 | return 1 31 | url = last_page.get('href') 32 | last_page = urllib.parse.parse_qs(url).get('p', ['0']) 33 | return int(last_page[0]) 34 | 35 | 36 | def _find_addresses_in_page(html_text): 37 | """Scrape addresses from block-id page.""" 38 | soup = BeautifulSoup(html_text, 'html.parser') 39 | addresses = soup.find_all('a', href=re.compile('^/address/0x([a-z0-9]+)')) 40 | addr_urls = [a.get('href') for a in addresses] 41 | addr_urls = [url.split('/', 2)[-1] for url in addr_urls] 42 | addr_urls = [url[2:] for url in addr_urls] 43 | return set(addr_urls) 44 | 45 | 46 | def get_block(block_id, page_number, local_only): 47 | """Get the HTML for a particular block.""" 48 | pth = 'data/block-%d.html' % (block_id,) 49 | if page_number != 1: 50 | pth = 'data/block-%d-%d.html' % (block_id, page_number) 51 | 52 | if os.path.exists(pth): 53 | with open(pth) as fin: 54 | return fin.read() 55 | elif not local_only: 56 | url = 'https://etherscan.io/txs?block=%d&p=%d' % (block_id, 57 | page_number) 58 | done = False 59 | while not done: 60 | try: 61 | reply = requests.get(url) 62 | done = True 63 | except: 64 | # sleep 3 - 10 seconds when rate-limited 65 | time.sleep(3.0 + 7.0 * random.random()) 66 | 67 | with open(pth, 'w') as fout: 68 | fout.write(reply.text) 69 | return reply.text 70 | 71 | 72 | def echo_new_addresses_found(block, page, known_addresses, new_addresses): 73 | """Echo a consistent message showing address scraping results.""" 74 | click.echo('# block=%d, page=%d, %d new addresses found' % ( 75 | block, 76 | page, 77 | len(new_addresses.difference(known_addresses)))) 78 | 79 | 80 | def scrape_block(block, page, local_only): 81 | """Scrape a full block and return all addresses""" 82 | eth_addrs = set() 83 | 84 | reply = get_block(block, page, local_only) 85 | if reply is not None: 86 | last_page = _find_last_page(reply) 87 | 88 | new_addrs = _find_addresses_in_page(reply) 89 | if not local_only: 90 | echo_new_addresses_found(block, page, eth_addrs, new_addrs) 91 | eth_addrs.update(new_addrs) 92 | 93 | page_num = 2 94 | while page_num < last_page: 95 | reply = get_block(block, page_num, local_only) 96 | if reply is not None: 97 | new_addrs = _find_addresses_in_page(reply) 98 | if not local_only: 99 | echo_new_addresses_found(block, 100 | page_num, 101 | eth_addrs, 102 | new_addrs) 103 | eth_addrs.update(new_addrs) 104 | page_num += 1 105 | return eth_addrs 106 | 107 | 108 | @click.command() 109 | @click.option('--first-block', required=True, type=int) 110 | @click.option('--last-block', type=int) 111 | @click.option('--outfile', type=click.File('w')) 112 | @click.option('--local-only', 113 | default=False, 114 | help='Do not fetch new pages, read from local page dumps only.') 115 | def main(first_block, last_block, local_only, outfile): 116 | """Scrape etherscan.io block info pages for active ETH addresses.""" 117 | last_block = last_block or first_block + 1 118 | eth_addrs = set() 119 | for block in range(first_block, last_block + 1): 120 | new_addrs = scrape_block(block, 1, local_only) 121 | if not new_addrs: 122 | continue 123 | 124 | if local_only: 125 | click.echo('\r# block=%d, %s new addresses found, %d total' % ( 126 | block, 127 | '{:3d}'.format(len(new_addrs.difference(eth_addrs))), 128 | len(new_addrs) + len(eth_addrs)), 129 | nl=False) 130 | eth_addrs.update(new_addrs) 131 | 132 | yaml.safe_dump(sorted(eth_addrs), 133 | outfile or sys.stdout, 134 | default_flow_style=False) 135 | click.echo('# Total %d addresses found in %d blocks' % ( 136 | len(eth_addrs), last_block - first_block)) 137 | 138 | 139 | if '__main__' == __name__: 140 | main() 141 | -------------------------------------------------------------------------------- /targets.py: -------------------------------------------------------------------------------- 1 | """Cache of target ETH address we are trying to crack.""" 2 | 3 | import yaml 4 | 5 | 6 | def targets(list_or_stream=None): 7 | """Load targets from a yaml stream (or return default).""" 8 | if isinstance(list_or_stream, list): 9 | return [entry.lower() for entry in list_or_stream] 10 | elif list_or_stream: 11 | return yaml.safe_load(list_or_stream) 12 | else: 13 | # return a hardcoded copy of top-100 addresses 14 | return [ 15 | '0000000000000000000000000000000000000000', 16 | '000000000000000000000000000000000000dead', 17 | '000009d8131fa90e85b48a901c9a3df30fe63f89', 18 | '0001120c65e60078fef36fe278aad3aa2cddb172', 19 | '00013b0ac844c32833642f67be2d585f3fb7aaa0', 20 | '00018b5e3d3c2f43c1e2ccb15f7b7332b312623e', 21 | '0001ab7ad20264ea13b3b87c5a80f640748399fb', 22 | '0001b49181639d5ba5105cd96fce780cb73fda4f', 23 | '0001f74eb4854f9ee81564b6343d612c2c5a691a', 24 | '0001fd043311c5942e0e0b11b8e9634f35f73fad', 25 | '0002fa49043113ec2b40092c3873e1125cf89bbf', 26 | '00055ad45e27bfa929c3efb6bdca59e83812a2b7', 27 | '00056f463017c7be5ed5223c2930c3086a779586', 28 | '0006f944e0f0d868848ac51502ecd301dfb63b4f', 29 | '00073103c819211ef56d0a8ba7f71c11a84aa55f', 30 | '0007a3f6a0dc83c299f1076bbfc2799fe940c0b9', 31 | '000901c9bafb0b2094c9d23cdf792a88d5839a82', 32 | '00099dc7dd85213f5f10dd1f7033b72f552ae58d', 33 | '000a1c9145b73cf46993fd810ab6773135742896', 34 | '000a89419ba4ea71aa57b5b5562405799069571d', 35 | '000d6883608019dc3c169fa0e7801f4514645f15', 36 | '000e0e5701b14fb77160bcc7bfe7256522d5927b', 37 | '000f372e3e45ada03456aef3b90c545ff2b7329c', 38 | '000f8ef81ef1415a425091fd80215c5b8dfec563', 39 | '001101c04e5125d18f2d57269c4dbde5511b7ac7', 40 | '00128d2f78b22a11ce4b1fd7eb8cfa3f7b788d66', 41 | '0012aa4453c184a2e43f3876abf9cc38254d029c', 42 | '001583b146b426376d166e8af3f65bd2b6f36f21', 43 | '0015f63460ec7ab5ec6e83b74aca422532d2cbee', 44 | '0016ce6ff889307ee088a9c288215350035681d8', 45 | '001866ae5b3de6caa5a51543fd9fb64f524f5478', 46 | '0019142ea15d5e963a4b75ac72a6f64b344a8abb', 47 | '0019312d39a13302fbacedf995f702f6e071d9e8', 48 | '001de0ab13c480e6d0f47e6d275c8afcdce2ce78', 49 | '001e189650980cc56d1b67122a3a30779ba3a6fb', 50 | '001e7bbf4079d3bfd2089fca211df596a8d5c30b', 51 | '001ea4060eef514f681d5280e52edea589bfc6d1', 52 | '001ecaf34c88a5fcfc455242e832d671a3bd739f', 53 | '00213f9ce9e1787336926bf094488086763079ac', 54 | '0022003b60e97196c0a341566126d35eb4d7e402', 55 | '00225c538542ef196ad0273374c13f73a1adca84', 56 | '00233eddd6a632aad3cf1f3bd268d00b5bb4d70d', 57 | '0025ca5a6090b729bdb71b7ce1fddd0e460b7703', 58 | '0026eb9da02470e57b4d0ed8b312b39443d6d29e', 59 | '0027912f8b96e9dfab6342d36af020e0282a1407', 60 | '00294facdb03e5422deeea8858cc8dcfe7ec6225', 61 | '002d27082129124544148246a221366cd71844f2', 62 | '00311c3e2d2e60fe50df22aca488b170b6cad337', 63 | '00316d956f5f35591ae021f4858a2a865c6ba02a', 64 | '0032ba9b8720ee48030071f0947b285d2d9ccfa1', 65 | '0034cf6e02f4c47fb30df22fc81b8dedddbf1fb0', 66 | '00359d862dd26307b0816f7ba3501a01808706fa', 67 | '0036289f041537bd953e5ca57ec3ec530dce2153', 68 | '0036671aadc726b80b3c6e5ee9d2b60367d6f10e', 69 | '00378fc6963702ede7c05d38cca7718fdbdba709', 70 | '003873ce9607827bc820ea1818a0b8d5436f661c', 71 | '003a4cc04501e9adecf850313db2d3797df801a2', 72 | '003a6b96105069e5eac52921abf147cde0822544', 73 | '003b17a52f2ecd363488b9c85ff7912e6e804c0e', 74 | '003bde955a7c56487da95c3f8497966b478e2390', 75 | '003ca46abf60d34479f1cfafa1ffe25853aa68c9', 76 | '003cc9f2ce96666fcc2127b94e596b93e7798ef9', 77 | '003e93083a2d294cb8c4421048108330c37b5874', 78 | '003f01a677440f4df2b94c86d235b9fdaea34921', 79 | '0040f2b793ac6db66634f96cb81863ad5cc1dced', 80 | '0041179685dcbfdce65e4a3bf6852ea9fe878b78', 81 | '0041e3db2b27d16a2f4fd5f6a5622974fff104dd', 82 | '0046576d15a1d5cbf83eca1353e8197407429b10', 83 | '004a28d65e7123cc48360ea69abcc527301c826a', 84 | '004a74fb3d04cd3314a02a72a09bc09ce4460be5', 85 | '004cfb59553b5d5e78bad38ebbb506f44ed53992', 86 | '004e0de965f34f651e66bebfbc2be279cea130e6', 87 | '004e3def0c754a921af751d1004df95f9650ea00', 88 | '004ef7d107051c83f8a1db61c6ef2a80c4230cd1', 89 | '005097fa238b15c53ad0ba38edf60177fd28cbde', 90 | '00527a2b537a07d6ccfe8150a0bfe5e2ea15f172', 91 | '0053e10ec86fb22296ce29a03d446776fec2a7c4', 92 | '0055c95f8273f59401391baff9faf370d869984c', 93 | '00560dd02c47c2a1e2042b0b0c0ca4d05363f725', 94 | '00564752dfe1ba52efcd6478e16dfbe21e2a7d11', 95 | '0056d9b61deab6a7f0cc8e9f5c63879d373b24e1', 96 | '005a8d02a46ea53530e6d0cd8600e04c09c90e00', 97 | '0060de2860115de9eb43ce872c5e71a6785d248e', 98 | '0062ae9e75f188642212f279c041226d8f45a829', 99 | '006bb362c27df2e55bea1066e65e9c55f072e354', 100 | '0070159d4e20ad35e19585e780ce9b37ccdcf6cd', 101 | '00718966b4d025db298816ddda7189f62a095177', 102 | '007199080eb1bd77e06212b469f6f1fd60234744', 103 | '0073c423a0b5d88b330d481301025db1ab65d891', 104 | '0073def4e522db78c4080f8fed03d6b8185ae819', 105 | '00744245df4deeb3d17a6bbe4605d55a2244c5cb', 106 | '0077a93f7a3729d8a342d16a61c47890fe3f3576', 107 | '00791803ce12bca730f8e3f29f0c6aee29f0c19d', 108 | '0079418d9dc201b95717806441698a949adb8fee', 109 | '007a8d1d6550b2254e5d0e6edbb5f4d159928320', 110 | '007b9fc31905b4994b04c9e2cfdc5e2770503f42', 111 | '007bf5b1a3ff72cd50dade030899efec8087addd', 112 | '0080b1007a72e1a000a8d3a3f47d27498183d35d', 113 | '008173fd32d84281cc9c614acb96b24acac85979', 114 | '008279d03770830f597a381a1760095508acd174', 115 | ] 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ethereum Private Key Brute Force Attacker 2 | 3 | A simple, pure-python package to generate private keys and compare the 4 | resulting ETH addresses with a list of known values. 5 | The Strength of each guess is measured by the number of leading hexadecimal digits that match (other digits may match, but we don't count those yet). 6 | 7 | While running, the script shows its guesses WarGames-style. 8 | The script also opens up a port for prometheus style metrics scraping. 9 | 10 | ## Usage 11 | 12 | ``` 13 | Usage: brute_force_app.py [OPTIONS] [ETH_ADDRESS]... 14 | 15 | Options: 16 | --quiet Skip the animation 17 | --strategy [trie|nearest|bisect] 18 | Choose a lookup strategy for eth addresses 19 | --no-port Disable monitoring port. 20 | --port INTEGER Monitoring port for runtime metrics. 21 | --addresses FILENAME Filename for yaml file containing target 22 | addresses. 23 | --max-guesses INTEGER If set to a positive integer, stop trying 24 | after this many attempts. 25 | --timeout INTEGER If set to a positive integer, stop trying 26 | after this many seconds. 27 | --fps INTEGER Use this many frames per second when showing 28 | guesses. Use non-positive number to go as 29 | fast as possible. 30 | --help Show this message and exit. 31 | ``` 32 | 33 | Thanks to [@vkobel/ethereum-generate-wallet](https://github.com/vkobel/ethereum-generate-wallet) for the pure-python implementation of ETH key generation. 34 | 35 | ## Why? 36 | 37 | I wanted a more tangible understanding of how hard it is to guess a 38 | private key before using it to store any non-trivial value. 39 | I mean, _how hard could it be to guess someone else's key, **right**_? 40 | As this script tries to show, it's basically impossible to collide with an already existing key. 41 | 42 | How many leading digits can you match? ;) 43 | 44 | Note: having a 39 digit match of the address means you're no closer to 45 | unlocking anything. 46 | 47 | ### Seriously, no chance 48 | 49 | How impossible is this? Assuming [45,000,000 addresses](https://etherscan.io/chart/address), 50 | you have a 45000000 / 115792089237316195423570985008687907853269984665640564039457584007913129639936 51 | or 3.8862758497925e-70 chance of randomly guessing a private key associated with a public 52 | address. 53 | 54 | If you made O(1000) random guesses per second, it would take you on roughly 1 trillion trillion trillion trillion years to guess one address (on average). 55 | Clearly a short-cut is needed, but that's for another project. :wink: 56 | 57 | ## Python dependencies 58 | 59 | This script uses python3. 60 | Its dependencies are listed in `requirements.txt`. 61 | Use `virtualenv` to install and execute this script without affecting your system's python3 distribution: 62 | 63 | ```shell 64 | $ virtualenv -p python3 venv 65 | $ . ./venv/bin/activate 66 | $ pip install -r requirements.txt 67 | $ ./brute_force_app.py --timeout 5 68 | Loaded 10000 addresses 69 | 70 | duration attempts private-key str address 71 | 00000.000006 00000001 0d5a730468b5ed565a89b03cf8f6228a4b4d8c75c7fbd1d31b4ef9f003d5660c 3 e0a 72 | 00000.277911 00000005 100276e5d5f35d065c9a925b08785c55a8c1497f1dbad970b16d9adbf7e670a0 3 ff1 73 | 00000.972975 0000000f c6b40ef08f92ffb8b3b36fa5f65de72daddd0a05da82943deadfa3a63813779f 4 00fb 74 | 00001.666908 00000019 911dea430f2b8403f9cbb2f4dcae2e5ea6943b05916d4a3ec9e3ed68927cbc86 4 d301 75 | 00004.998683 00000049 8fa41f3e7fb335e0fa6435d2d905eb996c251f2ff98c5fa7719ad88030c59c2c 2 78 76 | 77 | Total guesses: 73 78 | Seconds : 5.068186 79 | Guess / sec : 14.403575559381602 80 | Num targets : 10000 81 | 82 | Best Guess 83 | Private key : 911dea430f2b8403f9cbb2f4dcae2e5ea6943b05916d4a3ec9e3ed68927cbc86 84 | Public key : af71d473026d92073ed27de65b04d523ffa897d59b965ba5aeaa4e29a535f3e3e7dac768a6c3b2ed88d00415472d30fb39ed0a825d54c8070f896fc23d3e67e8 85 | Address : 0xd301b4bf0ab57e50c2aa5451df29d58e89538ed0 86 | Strength : 4 of 40 digits (10.00%) 87 | 88 | $ deactivate 89 | ``` 90 | 91 | ### Don't pollute your development environment 92 | 93 | Not recommended: you can skip the `virtualenv` steps and install the 94 | necessary dependencies to your system's python3 distribution: 95 | 96 | ```bash 97 | $ pip install -r requirements.txt 98 | $ python3 ./brute_force_app.py 99 | ... 100 | ``` 101 | 102 | ### Run it in a container 103 | 104 | You can also run this toy in a docker container. 105 | 106 | 1. First, pull the docker container: 107 | ```bash 108 | $ docker pull evilegg/ethereum-private-key-attack 109 | ``` 110 | 111 | 2. If you want to run it as is: 112 | ```bash 113 | $ docker run evilegg/ethereum-private-key-attack 114 | ``` 115 | 116 | 3. Or you can copy a yaml file containing the ETH addresses you wish to target: 117 | ```bash 118 | $ docker run -it -v "$(PWD):/usr/src/app" evilegg/ethereum-private-key-attack python3 brute_force_app.py --addresses YOUR_YAML_FILE 119 | ``` 120 | 121 | 4. Or you can pass the YAML data via stdin: 122 | ```bash 123 | $ cat YOUR_YAML_FILE | docker run -i evilegg/ethereum-private-key-attack ./brute_force_app.py --addresses /dev/stdin 124 | ``` 125 | 126 | 5. You can also forward the monitoring port to `localhost:80` for remote monitoring: 127 | ```bash 128 | $ cat YOUR_YAML_FILE | docker run -i -p 80:8120 evilegg/ethereum-private-key-attack ./brute_force_app.py --addresses /dev/stdin 129 | ``` 130 | 131 | 6. You can also skip the animations, but what fun is that? 132 | ```bash 133 | $ docker run evilegg/ethereum-private-key-attack ./brute_force_app.py --quiet 134 | ``` 135 | 136 | ## Strategies 137 | 138 | Currently, there are three strategies for looking up private key guesses against the known list of public addresses. 139 | 140 | ## Monitoring 141 | 142 | If you specify a `--port` command line argument, the app listens on that port 143 | for HTTP GETs and will return some basic run-time statistics. 144 | 145 | ## Validity 146 | 147 | You can confirm address generation using [this link](https://www.rfctools.com/ethereum-address-test-tool/). 148 | Copy and paste the `private-key` and compare against `address`: 149 | 150 | ``` 151 | » ./brute_force_app.py 152 | Loading known public ETH addresses375276 found. 153 | 154 | web-server on: ('', 8120) 155 | duration attempts private-key str address 156 | 00000.000187 00000001 d88d5d4dc45ce8e392908758e36f0b6c3def14b065d87565176fa574329eeb6e 4 720a519a2ffcf4109661a3a6de4aec66db1340f3 157 | ``` 158 | 159 | ## Troubleshooting 160 | 161 | 1. libyaml is not found 162 | 163 | ``` 164 | #include 165 | ^ 166 | compilation terminated. 167 | 168 | libyaml is not found or a compiler error: forcing --without-libyaml 169 | (if libyaml is installed correctly, you may need to 170 | specify the option --include-dirs or uncomment and 171 | modify the parameter include_dirs in setup.cfg) 172 | ``` 173 | 174 | Your python development environment is missing a few components. Ensure you have `libyaml-dev`, `libpython3-dev`, and `python3-dev` installed. 175 | 176 | ```bash 177 | sudo apt-get install libyaml-dev libpython3-dev python3-dev 178 | ``` 179 | 180 | 2. Click wants UTF-8 but your python install was configured for ASCII 181 | 182 | ```bash 183 | $ python ./brute_force_app.py 184 | Traceback (most recent call last): 185 | File "./brute_force_app.py", line 177, in 186 | main() 187 | File "/home/cabox/workspace/venv/lib/python3.4/site-packages/click/core.py", line 722, in __call__ 188 | return self.main(*args, **kwargs) 189 | File "/home/cabox/workspace/venv/lib/python3.4/site-packages/click/core.py", line 676, in main 190 | _verify_python3_env() 191 | File "/home/cabox/workspace/venv/lib/python3.4/site-packages/click/_unicodefun.py", line 118, in _verify_python3_env 192 | 'for mitigation steps.' + extra) 193 | RuntimeError: Click will abort further execution because Python 3 was configured to use ASCII as encoding for the environment. Consult http://click.pocoo.org/python3/for mitigation steps. 194 | 195 | This system supports the C.UTF-8 locale which is recommended. 196 | You might be able to resolve your issue by exporting the 197 | following environment variables: 198 | 199 | export LC_ALL=C.UTF-8 200 | export LANG=C.UTF-8 201 | ``` 202 | 203 | Export the recommended locale information to make click happy. 204 | 205 | ```bash 206 | export LC_ALL=C.UTF-8 207 | export LANG=C.UTF-8 208 | ``` 209 | 210 | 3. `pytest` is failing. 211 | 212 | From https://stackoverflow.com/a/54597424: 213 | 214 | > 1. activate your venv : source venv/bin/activate 215 | > 2. install pytest : pip install pytest 216 | > 3. re-activate your venv: deactivate && source venv/bin/activate 217 | > 218 | > The reason is that the path to pytest is set by the sourceing the activate file only after pytest is actually installed in the venv. 219 | > You can't set the path to something before it is installed. 220 | -------------------------------------------------------------------------------- /brute_force_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Brute force well-known ETH addresses, WarGames-style. 3 | 4 | Warning: this is utterly futile. I've only done this to get a feel 5 | for how secure private keys are against brute-force attacks. 6 | """ 7 | 8 | import codecs 9 | import os 10 | import sys 11 | import threading 12 | import time 13 | 14 | import click 15 | import ecdsa 16 | import sha3 17 | import yaml 18 | 19 | import lookups 20 | import monitoring 21 | import targets 22 | 23 | 24 | ETH_ADDRESS_LENGTH = 40 25 | 26 | 27 | def calc_strength(guess, target) -> int: 28 | """Calculate the strength of an address guess""" 29 | strength = 0 30 | for lhs, rhs in zip(guess, target): 31 | strength += 1 if lhs == rhs else 0 32 | return strength 33 | 34 | 35 | class SigningKey(ecdsa.SigningKey): 36 | 37 | @staticmethod 38 | def _hexlify(val): 39 | return codecs.encode(val, 'hex').decode('utf-8') 40 | 41 | def hexlify_private(self): 42 | return self._hexlify(self.to_string()) 43 | 44 | def hexlify_public(self): 45 | return self._hexlify(self.get_verifying_key().to_string()) 46 | 47 | @staticmethod 48 | def public_address(private_key_str=None): 49 | if private_key_str is not None: 50 | import binascii 51 | _p = private_key_str.lower() 52 | _p = bytes(_p, 'utf-8') 53 | _p = binascii.unhexlify(_p) 54 | priv = SigningKey.from_string(_p, curve=ecdsa.SECP256k1) 55 | else: 56 | priv = SigningKey.generate(curve=ecdsa.SECP256k1) 57 | 58 | pub = priv.get_verifying_key().to_string() 59 | keccak = sha3.keccak_256() 60 | keccak.update(pub) 61 | address = keccak.hexdigest()[24:] 62 | return priv.hexlify_private(), address 63 | 64 | 65 | def test_get_public_address(): 66 | sample_data = { 67 | '66873FDEF9BEC6F5D39D840CD7DDE4CA94270D3BF3AA9C5B372CDB5E07EADEFA': 'Dd36d7b54d489f4c2c0A7Ad57fc7180bAdD60072', 68 | 'C45910361C0BD601F8F1D93F53882EC7989160B97B095F3F4DA46F8206455761': '5EF98356CDd925203b5aeD05045dfd81A7667619', 69 | } 70 | for private_key, eth_address in sample_data.items(): 71 | assert (private_key.lower(), eth_address.lower()) == SigningKey.public_address(private_key) 72 | 73 | 74 | def GetResourcePath(*path_fragments): 75 | """Return a path to a local resource (relative to this script)""" 76 | try: 77 | base_dir = os.path.dirname(__file__) 78 | except NameError: 79 | # __file__ is not defined in some case, use the current path 80 | base_dir = os.getcwd() 81 | 82 | return os.path.join(base_dir, 'data', *path_fragments) 83 | 84 | 85 | def EchoLine(duration, attempts, private_key, strength, address, closest, newline=False): 86 | """Write a guess to the console.""" 87 | click.secho('\r%012.6f %08x %s % 3d ' % (duration, 88 | attempts, 89 | private_key, 90 | strength), 91 | nl=False) 92 | # FIXME show matching digits not just leading digits 93 | click.secho(address[:strength], nl=False, bold=True) 94 | click.secho(address[strength:], nl=False) 95 | click.secho(' %- s' % closest, nl=newline) 96 | 97 | 98 | def EchoHeader(): 99 | """Write the names of the columns in our output to the console.""" 100 | click.secho('%-12s %-8s %-64s %-3s %-40s %-40s' % ('duration', 101 | 'attempts', 102 | 'private-key', 103 | 'str', 104 | 'address', 105 | 'closest')) 106 | 107 | @click.option('--fps', 108 | default=60, 109 | help='Use this many frames per second when showing guesses. ' 110 | 'Use non-positive number to go as fast as possible.') 111 | @click.option('--timeout', 112 | default=-1, 113 | help='If set to a positive integer, stop trying after this many ' 114 | 'seconds.') 115 | @click.option('--max-guesses', 116 | default=0, 117 | help='If set to a positive integer, stop trying after this many ' 118 | 'attempts.') 119 | @click.option('--addresses', 120 | type=click.File('r'), 121 | default=GetResourcePath('addresses.yaml'), 122 | help='Filename for yaml file containing target addresses.') 123 | @click.option('--port', 124 | default=8120, 125 | help='Monitoring port for runtime metrics.') 126 | @click.option('--no-port', 127 | is_flag=True, 128 | default=False, 129 | help='Disable monitoring port.') 130 | @click.option('--strategy', 131 | type=click.Choice(['trie', 'nearest', 'bisect'], case_sensitive=False), 132 | default='nearest', 133 | help='Choose a lookup strategy for eth addresses') 134 | @click.option('--quiet', 135 | default=False, 136 | is_flag=True, 137 | help='Skip the animation') 138 | @click.argument('eth_address', nargs=-1) 139 | @click.command() 140 | def main(fps, timeout, max_guesses, addresses, port, no_port, strategy, quiet, eth_address): 141 | if eth_address: 142 | click.echo('Attacking specific ETH addresses: ', nl=False) 143 | addresses = [address.lower() for address in eth_address] 144 | else: 145 | click.echo('Loading known public ETH addresses: ', nl=False) 146 | 147 | strategy_ctor = lookups.PickStrategy(strategy) 148 | start_of_load = time.perf_counter() 149 | target_addresses = strategy_ctor(targets.targets(addresses)) 150 | load_time = time.perf_counter() - start_of_load 151 | click.echo('%d addresses read in %-3.2f seconds.' % (len(target_addresses), load_time)) 152 | click.echo('Using "%s" strategy, consuming %s bytes (%-8.5f bytes/address).' % ( 153 | strategy_ctor.__name__, 154 | target_addresses.sizeof(), 155 | len(target_addresses) / float(target_addresses.sizeof()))) 156 | click.echo('') 157 | 158 | httpd = monitoring.Server() 159 | varz = httpd.Start('', 0 if no_port else port) 160 | 161 | varz.fps = fps 162 | varz.timeout = timeout if timeout > 0 else 'forever' 163 | 164 | # score is tuple of number of matching leading hex digits and that 165 | # portion of the resulting public key: (count, address[:count]) 166 | varz.best_score = (0, '') 167 | varz.difficulty = httpd.DefineComputedStat( 168 | lambda m: 169 | '%d of %d digits (%3.2f%%)' % ( 170 | m.best_score[0], 171 | ETH_ADDRESS_LENGTH, 172 | 100.0 * m.best_score[0] / ETH_ADDRESS_LENGTH) 173 | ) 174 | 175 | # count the number of private keys generated 176 | varz.num_tries = 0 177 | varz.guess_rate = httpd.DefineComputedStat( 178 | lambda m: 179 | float(m.num_tries) / m.elapsed_time, units='guesses/sec' 180 | ) 181 | 182 | # calculate the fps 183 | fps = 1.0 / float(fps) if fps > 0 else fps 184 | last_frame = 0 185 | 186 | varz.start_time = time.asctime(time.localtime()) 187 | start_time = time.perf_counter() 188 | 189 | if not quiet: 190 | EchoHeader() 191 | try: 192 | while varz.best_score[0] < ETH_ADDRESS_LENGTH: 193 | now = time.perf_counter() 194 | varz.elapsed_time = now - start_time 195 | if (timeout > 0) and (start_time + timeout < now): 196 | break 197 | if (max_guesses) and (varz.num_tries >= max_guesses): 198 | break 199 | 200 | varz.num_tries += 1 201 | 202 | # calculate a public eth address from a random private key 203 | private_key_hex, address = SigningKey.public_address() 204 | current = target_addresses.FindClosestMatch(address) 205 | strength, _, closest = current 206 | 207 | if last_frame + fps < now: 208 | if not quiet: 209 | EchoLine(now - start_time, 210 | varz.num_tries, 211 | private_key_hex, 212 | strength, 213 | address, 214 | closest) 215 | last_frame = now 216 | 217 | # the current guess was as close or closer to a valid ETH address 218 | # show it and update our best guess counter 219 | if current >= varz.best_score: 220 | if not quiet: 221 | EchoLine(now - start_time, 222 | varz.num_tries, 223 | private_key_hex, 224 | strength, 225 | address, 226 | closest, 227 | newline=True) 228 | varz.best_score = current 229 | 230 | best_guess_report = { 231 | 'private-key': private_key_hex, 232 | 'address': address, 233 | } 234 | if closest is not None: 235 | best_guess_report['closest'] = 'https://etherscan.io/address/0x%s' % (closest,) 236 | varz.best_guess = best_guess_report 237 | 238 | except KeyboardInterrupt: 239 | pass 240 | 241 | varz.elapsed_time = time.perf_counter() - start_time 242 | click.echo('') 243 | click.echo('Summary') 244 | click.echo('-------') 245 | click.echo('%-20s: %s' % ('Total guesses', varz.num_tries)) 246 | click.echo('%-20s: %s' % ('Seconds', varz.elapsed_time)) 247 | click.echo('%-20s: %s' % ('Guess / sec', float(varz.num_tries) / varz.elapsed_time)) 248 | click.echo('%-20s: %s' % ('Num targets', len(target_addresses))) 249 | click.echo('') 250 | click.echo('Best Guess') 251 | click.echo('----------') 252 | for key, val in sorted(varz.best_guess.items()): 253 | click.echo('%-20s: %s' % (key, val)) 254 | click.echo('%-20s: %s' % ('Strength', varz.difficulty)) 255 | 256 | httpd.Stop() 257 | 258 | 259 | if '__main__' == __name__: 260 | main() 261 | --------------------------------------------------------------------------------