Bound crack memory: cap wordlist sort, chunk target loading

- prepare_candidates: use sort -S 1G --parallel 4 (was unbounded, OOM-prone
  on large --passwords-from datasets)
- crack: add --max-hashes to stream large hash files in bounded chunks, so the
  in-RAM target index no longer scales with the whole input
- docs + regression test for chunked cracking
This commit is contained in:
StasonJatham committed 2026-09-23 20:01:02 +02:00
1 parent 1ead58173c
commit 3b7011f433
4 files changed
+119 -36

No files matched your search

+5
View File
@@ -139,6 +139,11 @@ sources are merged through `sort -u`. Only unsalted MD5/SHA1/SHA256 are
attackable — bcrypt is ignored. Feed `cracked.txt` back into the index with attackable — bcrypt is ignored. Feed `cracked.txt` back into the index with
`split`/`merge`. `split`/`merge`.
The **target index is held in RAM** (roughly 250 bytes per hash), so a
multi-hundred-million-hash file will exhaust memory. Pass `--max-hashes N`
(e.g. `--max-hashes 8000000`) to stream the input in bounded chunks at the cost
of rescanning the wordlist per chunk.
## Measured performance ## Measured performance
Single large-file test on Collections 2-5 (3 files, 19.94 GB, 3 workers): Single large-file test on Collections 2-5 (3 files, 19.94 GB, 3 workers):
+14 -1
View File
@@ -99,8 +99,20 @@ def export(dataset: str, out: str):
) )
@click.option("--out", required=True, type=output_file()) @click.option("--out", required=True, type=output_file())
@click.option("--workers", default=0, type=int, show_default=True, help="0 = all CPUs.") @click.option("--workers", default=0, type=int, show_default=True, help="0 = all CPUs.")
@click.option(
"--max-hashes",
default=0,
type=int,
show_default=True,
help="Cap hashes held in RAM at once (0 = all); larger inputs are chunked.",
)
def crack( def crack(
hashes_path: str, wordlist: tuple, passwords_from: tuple, out: str, workers: int hashes_path: str,
wordlist: tuple,
passwords_from: tuple,
out: str,
workers: int,
max_hashes: int,
): ):
from collectionparser.crack import crack as run_crack from collectionparser.crack import crack as run_crack
@@ -110,6 +122,7 @@ def crack(
wordlists=wordlist, wordlists=wordlist,
passwords_from=passwords_from, passwords_from=passwords_from,
workers=workers or None, workers=workers or None,
max_hashes=max_hashes or None,
) )
+77 -35
View File
@@ -8,9 +8,11 @@ Only unsalted MD5/SHA1/SHA256 are attackable; bcrypt is intentionally not
supported (a wordlist attack on it is infeasible). supported (a wordlist attack on it is infeasible).
Memory: the target index is built once and shared with the workers by forking Memory: the target index is built once and shared with the workers by forking
(copy-on-write), so it costs one copy, not one per worker. ponytail: platforms (copy-on-write), so it costs one copy, not one per worker. It still scales with
without ``fork`` fall back to rebuilding it in each worker (RAM x workers); a the number of hashes, so pass ``max_hashes`` to stream the input in bounded
shared mmap index is the upgrade path for enormous hash files. chunks on memory-limited hosts. ponytail: platforms without ``fork`` fall back
to rebuilding it in each worker (RAM x workers); a shared mmap index is the
upgrade path for enormous hash files.
""" """
import gzip import gzip
@@ -21,6 +23,7 @@ import shutil
import subprocess import subprocess
import urllib.request import urllib.request
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from itertools import islice
from pathlib import Path from pathlib import Path
from collectionparser.api import find_files from collectionparser.api import find_files
@@ -71,8 +74,13 @@ def prepare_candidates(wordlists, passwords_from, work_dir: Path) -> Path:
env = {**os.environ, "LC_ALL": "C", "TMPDIR": str(work_dir)} env = {**os.environ, "LC_ALL": "C", "TMPDIR": str(work_dir)}
with ( with (
open(unique, "wb") as sink_file, open(unique, "wb") as sink_file,
# Match api.sort_unique: cap the sort buffer so a huge --passwords-from
# dataset spills to disk instead of OOMing the host.
subprocess.Popen( subprocess.Popen(
["sort", "-u"], stdin=subprocess.PIPE, stdout=sink_file, env=env ["sort", "-S", "1G", "--parallel", "4", "-u"],
stdin=subprocess.PIPE,
stdout=sink_file,
env=env,
) as process, ) as process,
): ):
with process.stdin as sink: with process.stdin as sink:
@@ -129,25 +137,34 @@ def _scan_stripe(args: tuple) -> list[bytes]:
return matches return matches
def crack( def _iter_hash_chunks(hashes_path, max_hashes, work_dir):
hashes_path: str, """Yield hash-file paths of at most ``max_hashes`` lines, or the whole file."""
out_path: str, if not max_hashes or max_hashes <= 0:
wordlists=(), yield str(hashes_path)
passwords_from=(), return
workers: int | None = None, index = 0
work_dir: str | None = None, with open(hashes_path, "rb") as reader:
) -> int: while True:
"""Attack the hashes with the candidate wordlists; write ``email:password``.""" lines = list(islice(reader, max_hashes))
if not lines:
break
chunk = Path(work_dir) / f"hashes.chunk.{index:05d}.txt"
chunk.write_bytes(b"".join(lines))
try:
yield str(chunk)
finally:
chunk.unlink(missing_ok=True)
index += 1
def _attack(hashes_path, writer, wordlist, workers) -> int:
"""Attack one hashes file, writing ``email:password`` matches to ``writer``."""
global _TARGETS global _TARGETS
_TARGETS = load_targets(hashes_path) _TARGETS = load_targets(hashes_path)
targets = _TARGETS targets = _TARGETS
if not targets: if not targets:
print("No crackable MD5/SHA1/SHA256 hashes found.") print(f"No crackable MD5/SHA1/SHA256 hashes found in {hashes_path}.")
return 0 return 0
work_dir = Path(work_dir) if work_dir else Path(out_path).parent
wordlist = prepare_candidates(list(wordlists), list(passwords_from), work_dir)
workers = max(1, workers or os.cpu_count() or 1)
n_hashes = sum( n_hashes = sum(
len(emails) for by_digest in targets.values() for emails in by_digest.values() len(emails) for by_digest in targets.values() for emails in by_digest.values()
) )
@@ -157,24 +174,49 @@ def crack(
) )
jobs = [(str(hashes_path), str(wordlist), i, workers) for i in range(workers)] jobs = [(str(hashes_path), str(wordlist), i, workers) for i in range(workers)]
total = 0
if workers == 1:
results = map(_scan_stripe, jobs)
else:
# fork shares the parent's _TARGETS by copy-on-write.
context = multiprocessing.get_context("fork") if hasattr(os, "fork") else None
executor = ProcessPoolExecutor(max_workers=workers, mp_context=context)
results = executor.map(_scan_stripe, jobs)
try:
for matches in results:
for line in matches:
writer.write(line)
total += 1
finally:
if workers != 1:
executor.shutdown()
return total
def crack(
hashes_path: str,
out_path: str,
wordlists=(),
passwords_from=(),
workers: int | None = None,
work_dir: str | None = None,
max_hashes: int | None = None,
) -> int:
"""Attack the hashes with the candidate wordlists; write ``email:password``.
``max_hashes`` bounds how many hash lines are held in the in-RAM target
index at once; larger inputs are streamed in chunks that spill to ``work_dir``.
A single global index of hundreds of millions of hashes needs tens of GB, so
set this on memory-limited hosts. ponytail: the wordlist is rescanned per
chunk, so a smaller ``max_hashes`` trades RAM for repeated passes.
"""
work_dir = Path(work_dir) if work_dir else Path(out_path).parent
wordlist = prepare_candidates(list(wordlists), list(passwords_from), work_dir)
workers = max(1, workers or os.cpu_count() or 1)
total = 0 total = 0
with open(out_path, "wb") as writer: with open(out_path, "wb") as writer:
if workers == 1: for chunk_path in _iter_hash_chunks(hashes_path, max_hashes, work_dir):
results = map(_scan_stripe, jobs) total += _attack(chunk_path, writer, wordlist, workers)
else:
# fork shares the parent's _TARGETS by copy-on-write.
context = (
multiprocessing.get_context("fork") if hasattr(os, "fork") else None
)
executor = ProcessPoolExecutor(max_workers=workers, mp_context=context)
results = executor.map(_scan_stripe, jobs)
try:
for matches in results:
for line in matches:
writer.write(line)
total += 1
finally:
if workers != 1:
executor.shutdown()
print(f"Cracked {total:,} hashes -> {out_path}.") print(f"Cracked {total:,} hashes -> {out_path}.")
return total return total
+23
View File
@@ -72,6 +72,29 @@ class CrackTest(unittest.TestCase):
self.assertEqual(1, total) self.assertEqual(1, total)
self.assertEqual([b"[email protected]:reused"], out.read_bytes().splitlines()) self.assertEqual([b"[email protected]:reused"], out.read_bytes().splitlines())
def test_crack_chunks_hash_file(self):
with tempfile.TemporaryDirectory() as tmp:
hashes = Path(tmp) / "hashes.txt"
hashes.write_bytes(
b"[email protected]:" + hashlib.md5(b"hunter2").hexdigest().encode() + b"\n"
b"[email protected]:" + hashlib.sha1(b"secret").hexdigest().encode() + b"\n"
)
wordlist = Path(tmp) / "words.txt"
wordlist.write_bytes(b"hunter2\nsecret\n")
out = Path(tmp) / "cracked.txt"
total = crack(
str(hashes),
str(out),
wordlists=[str(wordlist)],
workers=1,
max_hashes=1,
)
self.assertEqual(2, total)
self.assertEqual(
{b"[email protected]:hunter2", b"[email protected]:secret"},
set(out.read_bytes().splitlines()),
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()