mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
fix: pipal base-word parsing and shell-safe invocation
pipal() built one rigid regex requiring exactly pipal_count consecutive base-word lines, so any cracked set with fewer unique base words than pipal_count (default 10) matched nothing and returned []. Parse the "Top N base words" section line by line instead, collecting up to pipal_count words and stopping at the blank line that ends the section. Also replace the shell=True string-formatted Popen with list-form arguments so file paths containing shell metacharacters can't be interpreted as commands. Adds tests/test_pipal_e2e.py: hermetic end-to-end tests that drive the real pipal() through a fake pipal executable (parsing, $HEX decode, fewer-basewords regression, and injection safety). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ff70f7efd1
commit
9d089d9a21
+36
-27
@@ -4443,15 +4443,18 @@ def pipal():
|
||||
pipalFile.write(clearTextPass)
|
||||
pipalFile.close()
|
||||
|
||||
pipalProcess = subprocess.Popen(
|
||||
"{pipal_path} {pipal_file} -t {pipal_count} --output {pipal_out}".format(
|
||||
pipal_path=pipalPath,
|
||||
pipal_file=hcatHashFilePipal + ".passwords",
|
||||
pipal_out=hcatHashFilePipal + ".pipal",
|
||||
pipal_count=pipal_count,
|
||||
),
|
||||
shell=True,
|
||||
)
|
||||
# List-form Popen (no shell=True) so paths/filenames containing
|
||||
# shell metacharacters can't be interpreted as commands. shlex.split
|
||||
# on pipalPath still allows an interpreter prefix (e.g. "ruby
|
||||
# /opt/pipal/pipal.rb") to be configured.
|
||||
pipal_cmd = shlex.split(pipalPath) + [
|
||||
hcatHashFilePipal + ".passwords",
|
||||
"-t",
|
||||
str(pipal_count),
|
||||
"--output",
|
||||
hcatHashFilePipal + ".pipal",
|
||||
]
|
||||
pipalProcess = subprocess.Popen(pipal_cmd)
|
||||
try:
|
||||
pipalProcess.wait()
|
||||
except KeyboardInterrupt:
|
||||
@@ -4474,25 +4477,31 @@ def pipal():
|
||||
print(pipalfile.read())
|
||||
print("\n--- Pipal Output End ---\n")
|
||||
with open(hcatHashFilePipal + ".pipal") as pipalfile:
|
||||
pipal_content = pipalfile.readlines()
|
||||
raw_pipal = "\n".join(pipal_content)
|
||||
raw_pipal = re.sub("\n+", "\n", raw_pipal)
|
||||
raw_regex = r"Top [0-9]+ base words\n"
|
||||
for word in range(pipal_count):
|
||||
raw_regex += r"(\S+).*\n"
|
||||
basewords_re = re.compile(raw_regex)
|
||||
results = re.search(basewords_re, raw_pipal)
|
||||
pipal_content = pipalfile.read()
|
||||
# Parse the "Top N base words" section line by line rather than
|
||||
# with one rigid regex. The old approach required *exactly*
|
||||
# pipal_count baseword lines, so any cracked set with fewer
|
||||
# unique base words than pipal_count (the common case on small
|
||||
# cracks) matched nothing and returned []. Collect up to
|
||||
# pipal_count base words and stop at the end of the section.
|
||||
top_basewords = []
|
||||
if results:
|
||||
if results.lastindex is not None:
|
||||
for i in range(1, results.lastindex + 1):
|
||||
if i is not None:
|
||||
top_basewords.append(results.group(i))
|
||||
else:
|
||||
pass
|
||||
return top_basewords
|
||||
else:
|
||||
return []
|
||||
in_section = False
|
||||
for line in pipal_content.splitlines():
|
||||
if re.match(r"\s*Top\s+[0-9]+\s+base words", line):
|
||||
in_section = True
|
||||
continue
|
||||
if in_section:
|
||||
if not line.strip():
|
||||
# blank line terminates the base words section
|
||||
break
|
||||
# Capture the base word (first token); tolerate both
|
||||
# "word = 5 (5%)" and "word 5" separators.
|
||||
match = re.match(r"\s*(\S+)", line)
|
||||
if match:
|
||||
top_basewords.append(match.group(1))
|
||||
if len(top_basewords) >= pipal_count:
|
||||
break
|
||||
return top_basewords
|
||||
else:
|
||||
print("No hashes were cracked :(")
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""End-to-end tests for ``hate_crack.main.pipal``.
|
||||
|
||||
These are hermetic e2e tests: instead of requiring a real Ruby + pipal.rb
|
||||
install, they drop a small fake ``pipal`` executable on disk that consumes the
|
||||
same CLI (``<passwords> -t <N> --output <file>``) and emits a pipal-shaped
|
||||
report. ``pipal()`` is then driven through its real code path — writing the
|
||||
``.passwords`` file (including ``$HEX[...]`` decoding), spawning the subprocess
|
||||
via ``subprocess.Popen(..., shell=True)``, and parsing the ``Top N base words``
|
||||
section back out.
|
||||
|
||||
A real-tool variant is gated behind ``HATE_CRACK_PIPAL_REAL=1`` +
|
||||
``HATE_CRACK_PIPAL_PATH`` for anyone who wants to run against genuine pipal.rb.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _get_main_module():
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
|
||||
import hate_crack.main as m # noqa: PLC0415
|
||||
|
||||
return m
|
||||
|
||||
|
||||
# A fake pipal that mimics the real tool closely enough for the parser:
|
||||
# base word = password lowercased with trailing non-alphabetic characters
|
||||
# stripped, ranked by descending frequency, and only the top -t entries are
|
||||
# emitted under the "Top N base words" header.
|
||||
_FAKE_PIPAL = textwrap.dedent(
|
||||
"""\
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
pw_file = args[0]
|
||||
top = 10
|
||||
out = None
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] in ("-t", "--top"):
|
||||
top = int(args[i + 1]); i += 2
|
||||
elif args[i] in ("-o", "--output"):
|
||||
out = args[i + 1]; i += 2
|
||||
elif args[i] == "--output":
|
||||
out = args[i + 1]; i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
counts = {}
|
||||
with open(pw_file, encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
pw = line.rstrip("\\n")
|
||||
if not pw:
|
||||
continue
|
||||
base = re.sub(r"[^a-zA-Z]+$", "", pw).lower()
|
||||
if not base:
|
||||
continue
|
||||
counts[base] = counts.get(base, 0) + 1
|
||||
|
||||
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
total = sum(counts.values())
|
||||
|
||||
lines = []
|
||||
lines.append("Basic Results")
|
||||
lines.append("")
|
||||
lines.append("Total entries = %d" % total)
|
||||
lines.append("")
|
||||
lines.append("Top %d base words" % top)
|
||||
for word, c in ranked[:top]:
|
||||
pct = 100.0 * c / total if total else 0.0
|
||||
lines.append("%s = %d (%.2f%%)" % (word, c, pct))
|
||||
lines.append("")
|
||||
|
||||
with open(out, "w", encoding="utf-8") as fh:
|
||||
fh.write("\\n".join(lines) + "\\n")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _install_fake_pipal(tmp_path: Path) -> Path:
|
||||
fake = tmp_path / "pipal"
|
||||
fake.write_text(_FAKE_PIPAL)
|
||||
fake.chmod(fake.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return fake
|
||||
|
||||
|
||||
def _run_pipal(monkeypatch, m, tmp_path, cracked_lines, pipal_count):
|
||||
"""Wire up main-module globals and run the real ``pipal()``."""
|
||||
fake = _install_fake_pipal(tmp_path)
|
||||
hashfile = tmp_path / "hashes.txt"
|
||||
hashfile.write_text("dummy\n")
|
||||
(tmp_path / "hashes.txt.out").write_text("".join(cracked_lines))
|
||||
|
||||
monkeypatch.setattr(m, "pipalPath", str(fake))
|
||||
monkeypatch.setattr(m, "pipal_count", pipal_count)
|
||||
# hcatHashFile / hcatHashType only exist after main() runs; create them.
|
||||
monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False)
|
||||
monkeypatch.setattr(m, "hcatHashType", "0", raising=False) # not NTLM
|
||||
return m.pipal(), hashfile
|
||||
|
||||
|
||||
class TestPipalE2E:
|
||||
def test_returns_top_basewords(self, monkeypatch, tmp_path, capsys):
|
||||
m = _get_main_module()
|
||||
cracked = [
|
||||
"hash1:password1\n",
|
||||
"hash2:password2\n",
|
||||
"hash3:Password!\n",
|
||||
"hash4:summer2021\n",
|
||||
"hash5:summer99\n",
|
||||
"hash6:winter1\n",
|
||||
]
|
||||
result, hashfile = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=3)
|
||||
|
||||
assert result == ["password", "summer", "winter"]
|
||||
# pipal report and intermediate passwords file were produced
|
||||
assert (Path(str(hashfile) + ".pipal")).is_file()
|
||||
assert (Path(str(hashfile) + ".passwords")).is_file()
|
||||
|
||||
def test_passwords_file_decodes_hex(self, monkeypatch, tmp_path):
|
||||
m = _get_main_module()
|
||||
# 70617373776f7264 == "password"
|
||||
cracked = [
|
||||
"hash1:$HEX[70617373776f7264]\n",
|
||||
"hash2:hunter2\n",
|
||||
]
|
||||
_run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=2)
|
||||
|
||||
pw_path = tmp_path / "hashes.txt.passwords"
|
||||
contents = pw_path.read_text(encoding="utf-8").splitlines()
|
||||
assert "password" in contents # $HEX decoded, not written literally
|
||||
assert "hunter2" in contents
|
||||
assert not any(line.startswith("$HEX[") for line in contents)
|
||||
|
||||
def test_no_cracked_output_returns_empty(self, monkeypatch, tmp_path):
|
||||
m = _get_main_module()
|
||||
fake = _install_fake_pipal(tmp_path)
|
||||
hashfile = tmp_path / "hashes.txt"
|
||||
hashfile.write_text("dummy\n") # note: no .out file created
|
||||
|
||||
monkeypatch.setattr(m, "pipalPath", str(fake))
|
||||
monkeypatch.setattr(m, "pipal_count", 3)
|
||||
monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False)
|
||||
monkeypatch.setattr(m, "hcatHashType", "0", raising=False)
|
||||
assert m.pipal() == []
|
||||
|
||||
def test_missing_pipal_path_returns_none(self, monkeypatch, tmp_path):
|
||||
m = _get_main_module()
|
||||
hashfile = tmp_path / "hashes.txt"
|
||||
hashfile.write_text("dummy\n")
|
||||
(tmp_path / "hashes.txt.out").write_text("hash1:password1\n")
|
||||
|
||||
monkeypatch.setattr(m, "pipalPath", str(tmp_path / "does-not-exist"))
|
||||
monkeypatch.setattr(m, "pipal_count", 3)
|
||||
monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False)
|
||||
monkeypatch.setattr(m, "hcatHashType", "0", raising=False)
|
||||
assert m.pipal() is None
|
||||
|
||||
def test_handles_shell_metacharacters_in_path(self, monkeypatch, tmp_path):
|
||||
"""The subprocess call must not use a shell (no command injection).
|
||||
|
||||
A hash-file path containing shell metacharacters would, under the old
|
||||
``shell=True`` string command, either break or execute the injected
|
||||
fragment. With list-form Popen it is handled as a literal path.
|
||||
"""
|
||||
m = _get_main_module()
|
||||
weird_dir = tmp_path / "a b; touch INJECTED"
|
||||
weird_dir.mkdir()
|
||||
cracked = ["hash1:password1\n", "hash2:summer2021\n"]
|
||||
|
||||
fake = _install_fake_pipal(tmp_path)
|
||||
hashfile = weird_dir / "hashes.txt"
|
||||
hashfile.write_text("dummy\n")
|
||||
(weird_dir / "hashes.txt.out").write_text("".join(cracked))
|
||||
|
||||
monkeypatch.setattr(m, "pipalPath", str(fake))
|
||||
monkeypatch.setattr(m, "pipal_count", 3)
|
||||
monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False)
|
||||
monkeypatch.setattr(m, "hcatHashType", "0", raising=False)
|
||||
|
||||
result = m.pipal()
|
||||
|
||||
assert result == ["password", "summer"]
|
||||
# the injected `touch INJECTED` must NOT have run
|
||||
assert not (tmp_path / "INJECTED").exists()
|
||||
assert not Path("INJECTED").exists()
|
||||
|
||||
def test_fewer_basewords_than_count(self, monkeypatch, tmp_path):
|
||||
"""Real-world case: fewer unique base words than ``pipal_count``.
|
||||
|
||||
A small cracked set should still surface the base words it *does*
|
||||
have, rather than silently returning nothing.
|
||||
"""
|
||||
m = _get_main_module()
|
||||
cracked = [
|
||||
"hash1:password1\n",
|
||||
"hash2:summer2021\n",
|
||||
"hash3:winter1\n",
|
||||
]
|
||||
# default pipal_count (10) is larger than the 3 available base words
|
||||
result, _ = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=10)
|
||||
assert result == ["password", "summer", "winter"]
|
||||
Reference in New Issue
Block a user