Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,20 @@ Dates are omitted for releases predating this file; see the git tags for exact t

## [Unreleased]

### Added
- **Non-interactive subcommands for seventeen more menu attacks (#340).** The scripted surface was `quick | dict | brute | topmask` — three of the twenty-five menu attacks, so anything driving hate_crack programmatically (a scheduler, a CI harness, a tool choosing what to run next) had to fall back to feeding keystrokes to the menu for the rest. Added, taking nothing beyond the hash file and hash type: `fingerprint`, `combinator`, `hybrid`, `pathwell`, `prince`, `pcfg`, `princeling`, `smartmask`, `corporate`. Added, taking the one input their menu entry prompts for: `bandrel` (`--company`), `permute` (`--wordlist`), `adhocmask` (`--mask`), `ngram` (`--corpus`), `combipow` (`--wordlist`), `spoonman` (`--corpus`), `omen` (`--max-candidates`), `loopback` (`--rules`). Each also accepts the tuning its prompts offer — `fingerprint --max-expander-len/--keyspace-limit`, `corporate --min/--max`, and so on — and an omitted flag passes the attack function's own default rather than restating it. Five entries stay interactive-only because a flag cannot carry what they need: Markov brute force, Random Rules, Rosetta, the LLM attack, and Extensive Pure_Hate. `--exit-code-on-skip` works uniformly across all of them, and exit `2` still means "this build does not have that subcommand" rather than "it ran".
- **`hcatBandrel` accepts the company name as an argument.** It was the one attack in the group above that could not be scripted at all: the name came from a `while True: input(...)` loop that refused an empty answer and had no default, so a run with no terminal raised `EOFError` inside the loop rather than proceeding. Passing `company_name` skips the prompt; omitting it is unchanged.

### Changed
- **`hate_crack/noninteractive.py` is now driven by one `ATTACK_SPECS` table** instead of parallel subparser registrations and an `if`-chain dispatcher. `ATTACK_COMMANDS` is derived from it rather than hand-maintained, which closes a failure mode that grows with each subcommand added: `main.py` sets its `non_interactive` global from membership in that tuple, so a name registered as a subparser but missed in the tuple would have run the attack with every interactive prompt still live, blocking on a stdin nothing is attached to. Tests assert the derivation in both directions.

### Fixed
- **`combinator` routes by wordlist count, as the menu does.** `hcatCombination` slices to `wordlists[:2]`, so a scripted `--wordlist a b c` would have run against `a` and `b` and silently dropped `c` — while the subcommand's own help promised "two or more". Two wordlists now go to `hcatCombination`, exactly three to `hcatCombinator3`, and anything else to `hcatCombinatorX`, matching `attacks.combinator_crack`. `--separator` was added because `hcatCombinatorX` is the only one of the three that can insert one, so without it that path was unreachable.
- **`spoonman --rule-coverage` is restricted to 50, 75, 95 and 99.** Those are the only capped rule files `rulegen` derives. Any other value named a `rules.topN.rule` that never exists, which missed the cache check on *every* invocation (re-running the whole O(corpus) derivation each time) and then fell through `capped_rules.get(N, rules_path)` to the full rule set — so an operator asking for a small rule set silently got the largest one, repeatedly and expensively.
- **`adhocmask --mask` rejects a mask file that does not exist.** hashcat accepts a literal mask and a `.hcmask` path in the same slot and treats an unopenable path as a literal mask, so a typo enumerated one nonsense candidate and exited 0 — indistinguishable to a scripted caller from an attack that ran and found nothing. The interactive path already checked; this one now does too.
- **`adhocmask` validates its increment bounds and can increment over the full keyspace.** A non-numeric bound reached `int()` unguarded and exited with a traceback instead of exit 1, and a non-positive one was forwarded to hashcat verbatim. Bounds are now checked the way `attacks._prompt_length` checks them. Separately, `--increment` was added: deriving increment from the bounds alone made "increment across the full keyspace, both bounds blank" — a state the menu can produce and hashcat supports — unreachable from a script.
- **`bandrel --company` is validated against the comma-split it actually feeds.** `--company ","` passed a non-blank check while contributing no basewords at all.
- **A comma-separated Bandrel company list no longer builds a mask from a leading space.** `"Acme, Globex"` split to `" Globex"`, whose first character is a space, and the per-baseword masks are derived from `name[0]` and `name[1:]` — so the second company produced `-1 ` and a mask matching nothing. Latent before now, since the prompt made multi-company input awkward; the new `--company` flag documents comma separation, which would have made it routine. Entries are stripped and blanks dropped.
- **`test_commitizen_pin_is_still_coherent` no longer asserts *which* commitizen version is pinned**, only that exactly one exact `commitizen==X.Y.Z` pin exists. The literal it compared against meant every Dependabot bump of commitizen failed CI and needed a matching edit to the test before it could land (PR #322). Neither invariant the test documents -- that a pin exists so `cz commit` keeps working locally, and that neither tagging workflow calls `cz bump` -- depends on the version number, so the literal bought nothing.

### Fixed
Expand Down
42 changes: 38 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,10 +630,44 @@ notification prompt's `input()` call.

Alongside the interactive menu, `main.py` exposes scripted entry points:

- **Scripted attacks** — `quick | dict | brute | topmask` subcommands
(`hate_crack/noninteractive.py`'s `ATTACK_COMMANDS`), with `--wordlist`,
`--rules` (supports `a+b` chaining and multi-token passes), `--min`/`--max`,
`--target-time`.
- **Scripted attacks** — 21 subcommands covering all but five of the menu
attacks (#340). Every one is a row in `hate_crack/noninteractive.py`'s
**`ATTACK_SPECS`**, which is the source of truth: `ATTACK_COMMANDS` is
derived from it, and both `add_attack_subparsers` and `_dispatch` iterate
it. **Add a subcommand by adding a row, never by editing those three
separately** — `main.py` sets its `non_interactive` global from membership
in `ATTACK_COMMANDS` (`main.py:9992`), so a name that reached the subparsers
but not the tuple would run the attack with every interactive prompt still
live, blocking on a stdin nothing is attached to.
`tests/test_noninteractive_attacks.py` pins the derivation in both
directions.

Still interactive-only, as #340 scoped it: `hcatMarkovBruteForce`,
`hcatGenerateRules`, `hcatRosetta`, `hcatOllama`, and Extensive Pure_Hate
(an orchestrator, not a single attack).

Three things the issue's own tables got wrong, confirmed against the source
and worth not rediscovering:

- **Menu 11 "Loopback" is not `hcatRecycle`.** `attacks.loopback_attack`
runs `hcatQuickDictionary(..., loopback=True)` over an empty wordlist.
`hcatRecycle`'s third argument is a count of newly cracked passwords used
as an internal gate by `extensive_crack` only, and is meaningless as a CLI
argument — so the `loopback` subcommand takes `--rules`.
- **`hcatBandrel` was the one attack that genuinely could not be scripted**,
because of a blocking `while True: input()` with no default. It now takes
`company_name=None`; the prompt runs only when that is unset.
- **Menu 6 "Combinator Attacks" is a submenu of four** (combinator, YOLO,
middle, thorough). The `combinator` subcommand wires `hcatCombination`
only.

Flags follow the kebab-case of the underlying `hcat*` parameter, and an
omitted flag passes the function's own default rather than restating it
here — `corporate` omits `minLen`/`maxLen` entirely when unset so
`hcatCorporateMasks` applies and clamps its own. The one place that
distinction is load-bearing is `fingerprint`'s `--dictionary-wordlist`:
`None` means "fall back to config" and `""` means "skip the step", so
`--no-dictionary-wordlist` exists to reach the second.
- **`hashview` subparser tree** (`main.py:6439-6520`): `upload-cracked`,
`upload-wordlist`, `download-left`, `download-rules`, `upload-hashfile-job`.
- **Top-level flags** (`main.py:6297-6430`): `--download-hashview`,
Expand Down
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,106 @@ hate_crack brute hashes.txt 1000 --min 1 --max 8
hate_crack topmask hashes.txt 1000 --target-time 4
```

Most of the menu's attacks are available the same way. These need nothing
beyond the hash file and hash type:

```bash
hate_crack fingerprint hashes.txt 1000
hate_crack pathwell hashes.txt 1000
hate_crack prince hashes.txt 1000
hate_crack pcfg hashes.txt 1000
hate_crack princeling hashes.txt 1000
hate_crack smartmask hashes.txt 1000
hate_crack corporate hashes.txt 1000
hate_crack combinator hashes.txt 1000 # configured wordlists
hate_crack hybrid hashes.txt 1000 # configured wordlists
```

Each also accepts the settings its menu entry prompts for:

```bash
# Fingerprint, tuned: escalate to 24-character fragments and cap the keyspace
hate_crack fingerprint hashes.txt 1000 --max-expander-len 24 \
--run-hybrid-on-expanded --keyspace-limit 50000000000

# Skip the fragment/wordlist combination step entirely
hate_crack fingerprint hashes.txt 1000 --no-dictionary-wordlist

# Override the configured wordlists (combinator needs at least two).
# Two wordlists use combinator, three use combinator3, more -- or any
# --separator -- use combinatorX, matching what the menu does.
hate_crack combinator hashes.txt 1000 --wordlist first.txt second.txt
hate_crack combinator hashes.txt 1000 --wordlist a.txt b.txt c.txt
hate_crack combinator hashes.txt 1000 --wordlist a.txt b.txt --separator -
hate_crack hybrid hashes.txt 1000 --wordlist rockyou.txt

# Corporate masks, lengths 8-12 only
hate_crack corporate hashes.txt 1000 --min 8 --max 12

# Smart mask, skipping templates over 10 billion candidates
hate_crack smartmask hashes.txt 1000 --keyspace-limit 10000000000
```

The rest take the one input their menu entry asks for:

```bash
# Bandrel methodology (comma-separate multiple companies)
hate_crack bandrel hashes.txt 1000 --company "Acme,Acme Corp"

# Permutation attack over a short targeted wordlist
hate_crack permute hashes.txt 1000 --wordlist names.txt

# Ad-hoc mask -- a literal mask or a .hcmask file, optionally incrementing
hate_crack adhocmask hashes.txt 1000 --mask '?u?l?l?l?d?d'
hate_crack adhocmask hashes.txt 1000 --mask masks/corporate.hcmask
hate_crack adhocmask hashes.txt 1000 --mask '?a?a?a?a?a?a?a?a' \
--increment-min 4 --increment-max 8
hate_crack adhocmask hashes.txt 1000 --mask '?a?a?a?a' --increment

# N-gram candidates from a corpus
hate_crack ngram hashes.txt 1000 --corpus corpus.txt --group-size 3

# Combipow passphrases (wordlist capped at 63 lines: it generates 2^n-1)
hate_crack combipow hashes.txt 1000 --wordlist words.txt
hate_crack combipow hashes.txt 1000 --wordlist words.txt --no-spaces

# Spoonman: basewords and rules derived from a corpus of known passwords
hate_crack spoonman hashes.txt 1000 --corpus previous-engagement.txt
hate_crack spoonman hashes.txt 1000 --corpus previous.txt --rule-coverage 95
# --rule-coverage accepts 50, 75, 95 or 99 -- the only capped rule files
# derived. Omit it for the full rule set.

# OMEN -- requires a model trained beforehand from the interactive menu
hate_crack omen hashes.txt 1000 --max-candidates 1000000

# Loopback: re-run rules against the plaintexts already cracked
hate_crack loopback hashes.txt 1000 --rules best64.rule
```

`fingerprint` is the one command whose bare form is not identical to its menu
entry: menu option 5 always runs the hybrid passes over expanded fragments,
while the subcommand defaults to `hcatFingerprint`'s own default of off. Pass
`--run-hybrid-on-expanded` to match the menu.

Five menu entries are still interactive-only, because they need input a single
flag cannot carry: Markov brute force, Random Rules, the Rosetta attack, the
LLM attack, and the Extensive Pure_Hate methodology (an orchestrator over the
others rather than a single attack).

#### Exit codes

| Code | Meaning |
|---|---|
| `0` | The attack ran |
| `1` | Bad input — missing hash file or wordlist, non-numeric hash type, unknown rule filename, out-of-range argument |
| `2` | Unknown subcommand — this build of hate_crack does not have it |
| `3` | Only with `--exit-code-on-skip`: coverage had already seen every pass, so nothing was launched |

Exit `2` and exit `3` are what let a scripted driver tell "this version cannot
run that attack" and "it was redundant" apart from "it ran and found nothing".
`--exit-code-on-skip` is opt-in so that enabling coverage does not start
failing harnesses that predate it.

-------------------------------------------------------------------
## Troubleshooting

Expand Down
23 changes: 17 additions & 6 deletions hate_crack/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5306,19 +5306,30 @@ def hcatYoloCombination(hcatHashType, hcatHashFile):


# Bandrel methodlogy
def hcatBandrel(hcatHashType, hcatHashFile):
def hcatBandrel(hcatHashType, hcatHashFile, company_name=None):
"""Bandrel methodology: company-name basewords crossed with masks.

``company_name`` is comma-separated for multiple companies. Passing it
skips the prompt, which is what makes the attack drivable from the
``bandrel`` non-interactive subcommand -- the prompt loop below cannot
take a default, so a scripted run would otherwise raise EOFError inside
it (issue #340).
"""
global hcatProcess
basewords = []
while True:
while not (company_name or "").strip():
company_name = input(
"What is the company name (Enter multiples comma separated)? "
)
if company_name:
break
# Stripped because the list is comma-separated: "Acme, Globex" otherwise
# yields " Globex", whose first character is a space, and the masks below
# are built from name[0]/name[1:].
for name in company_name.split(","):
basewords.append(name)
if name.strip():
basewords.append(name.strip())
for word in bandrelbasewords.split(","):
basewords.append(word)
if word.strip():
basewords.append(word.strip())
for name in basewords:
mask1 = "-1{0}{1}".format(name[0].lower(), name[0].upper())
mask2 = " ?1{0}".format(name[1:])
Expand Down
Loading
Loading