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:
1 parent
1ead58173c
commit
3b7011f433
4 files changed
+119
-36
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user