Compare commits

..
Author SHA1 Message Date
Joe 4c2e8d4bda Simplify unit tests and strengthen deterministic coverage (#2433) 2026-11-06 15:32:31 -08:00
Joe 9f8f1d11be Fix DNS canary embedding and detection (#2432)
* Preserve DNS canaries in obfuscated implants

* Refactor canary regression assertions for lint

* Fix DNS canary detection and trigger persistence
2026-11-05 09:42:05 -08:00
Joe 6cf3ad78bd Revert Dependabot docs dependency updates (#2425) 2026-10-24 21:27:15 -07:00
Joe 56e140826f build(deps): combine compatible Dependabot updates (#2424) 2026-10-24 20:29:44 -07:00
Joe ad6ba0a61b Merge pull request #2423 from BishopFox/feat/protobuf-obfuscation-hardening
Harden protobuf obfuscation generation
2026-10-24 11:29:43 -07:00
moloch-- 9e1c1e7c67 Obfuscate all implant protobuf field tags 2026-10-24 09:45:23 -07:00
moloch-- a36b0b1d1d Refactor protobuf rewrites to satisfy lint complexity 2026-10-24 08:44:06 -07:00
moloch-- 4c1350d8cb Harden protobuf obfuscation generation 2026-10-24 08:39:08 -07:00
Joe 3e409bdc91 Merge pull request #2422 from BishopFox/feat/implant-size-consolidated
Reduce implant sizes across obfuscated and release builds
2026-10-23 08:30:02 -07:00
moloch-- 46eb1c22a4 feat: compact obfuscated amd64 executable alignment 2026-10-22 16:08:32 -07:00
moloch-- f857903c83 Strip non-debug plain implant executables 2026-10-22 16:04:49 -07:00
moloch-- ee53c01313 Reduce WASM extension gzip dependency in implants 2026-10-22 16:04:33 -07:00
moloch-- 14a43e6510 Reduce obfuscated implant size with shared short literal decoding 2026-10-22 13:46:04 -07:00
moloch-- 931e3f9046 Refresh bundled Garble when asset source changes 2026-10-22 13:45:46 -07:00
moloch-- a6d5e97d45 Pin Garble v1.27.3a signed release 2026-10-22 12:55:46 -07:00
moloch-- f0ef60e25f Reduce obfuscated implant size with shared long literal decoding 2026-10-22 12:50:04 -07:00
Joe 9fdc66e4a8 Merge pull request #2414 from BishopFox/feat/go-garble-1.27.3
Update embedded Go and Garble to 1.27.3
2026-10-22 10:43:57 -07:00
moloch-- ab960fd4ed Make SOCKS receive test shutdown deterministic 2026-10-22 08:42:19 -07:00
moloch-- 34fc1c5ad6 Update embedded Go and Garble to 1.27.3 2026-10-22 08:23:51 -07:00
Joe ac65aa3708 Merge pull request #2412 from BishopFox/feat/fix-initial-prompt
Fix update reminder corrupting the initial console prompt
2026-10-21 15:30:21 -07:00
moloch-- 73bf782656 Avoid shadowing print in startup regression test 2026-10-21 08:19:39 -07:00
moloch-- a0a303ae89 Fix update reminder corrupting the initial console prompt 2026-10-21 08:16:22 -07:00
Joe 5383bd67b5 Merge pull request #2408 from skinnypuppy/fix/mcp-keepalive
client/cli: keep the MCP bridge alive across idle gRPC channels
2026-10-19 12:50:42 -07:00
Joe 73ea63fd44 Merge pull request #2405 from rob-cshub/feat/non-inter-stage
Add non-interactive support for 'implants stage'
2026-10-19 12:03:33 -07:00
Joe 3ef04241a1 Merge pull request #2410 from BishopFox/dependabot/github_actions/actions/setup-go-7
build(deps): bump actions/setup-go from 6 to 7
2026-10-19 12:03:07 -07:00
dependabot[bot] 402b5459e6 build(deps): bump actions/setup-go from 6 to 7
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-19 18:38:45 +00:00
Joe 7c56553372 Merge pull request #2409 from BishopFox/dependabot/github_actions/actions/configure-pages-6
build(deps): bump actions/configure-pages from 5 to 6
2026-10-19 11:36:44 -07:00
dependabot[bot] a40754eaac build(deps): bump actions/configure-pages from 5 to 6
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-19 03:03:00 +00:00
skinnypuppy 042bb6bccc client/cli: keep the MCP bridge alive across idle gRPC channels
gRPC-Go closes client transports after its default 30-minute idle
timeout (no RPCs in flight) and moves the channel to Idle; the next
RPC transparently reconnects. The MCP bridge installed the
connection-lost watchdog, which treats any Idle transition as a lost
connection and exits the process - so `sliver-client mcp` reliably
died ~30 minutes after connecting, forcing the MCP host to reconnect.

Do not install the watchdog for the MCP bridge (an idle channel simply
reconnects on the next tool call; a down server surfaces as an RPC
error to the MCP host instead of a dead bridge process) and enable the
client keepalive pings that #2273 added but never wired up, so dead
connections are detected quickly while the channel is in use.

Client keepalive pings do not reset the idle timer, so this cannot be
fixed by configuration alone.
2026-10-18 14:44:56 -06:00
Robert Pretschner 8bc7273cad Add non-interactive mode to 'implants stage' 2026-10-15 14:11:36 +02:00
Joe 8e5d2f9dbe Merge pull request #2406 from BishopFox/feat/go-garble-1.27.2
Update embedded Go and Garble to 1.27.2
2026-10-14 08:51:22 -07:00
moloch-- 52864c481f Fix lint toolchain for Go 1.27.2 2026-10-14 07:04:43 -07:00
moloch-- 2f6fca1eac Update embedded Go and Garble to 1.27.2 2026-10-13 21:58:13 -07:00
Joe 481b5b0534 Merge pull request #2397 from BishopFox/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-10-12 12:48:13 -07:00
Joe 65efd3f2fd Merge pull request #2398 from BishopFox/dependabot/github_actions/actions/upload-pages-artifact-5
build(deps): bump actions/upload-pages-artifact from 4 to 5
2026-10-12 12:47:57 -07:00
Joe 58c5f33cf5 Merge pull request #2399 from BishopFox/dependabot/github_actions/actions/cache-6
build(deps): bump actions/cache from 5 to 6
2026-10-12 12:47:43 -07:00
Joe 3e20660ab0 Merge pull request #2400 from BishopFox/dependabot/github_actions/actions/setup-node-7
build(deps): bump actions/setup-node from 6 to 7
2026-10-12 12:47:28 -07:00
Joe acb6cd2d77 Merge pull request #2401 from BishopFox/dependabot/github_actions/actions/deploy-pages-5
build(deps): bump actions/deploy-pages from 4 to 5
2026-10-12 12:47:13 -07:00
dependabot[bot] 7f82085467 build(deps): bump actions/deploy-pages from 4 to 5
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-12 07:24:24 +00:00
dependabot[bot] 1ac72de74d build(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-12 07:24:20 +00:00
dependabot[bot] 94df7a3e58 build(deps): bump actions/cache from 5 to 6
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-12 07:24:15 +00:00
dependabot[bot] 329e44dfd9 build(deps): bump actions/upload-pages-artifact from 4 to 5
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-12 07:24:08 +00:00
dependabot[bot] 5d303fca79 build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-12 07:24:01 +00:00
Joe 90f759dac8 Merge pull request #2395 from BishopFox/feat/defensive-security-fixes
Random Bug Fixes
2026-10-10 10:05:26 -07:00
Joe 1fc9e256c0 Merge pull request #2396 from zwanski2019/feature/docs-search-relevance
Improve embedded docs search relevance
2026-10-10 09:48:26 -07:00
moloch-- 13935b2e68 fix(cli): preserve configuration import exit codes 2026-10-10 09:34:17 -07:00
moloch-- 1db7b99685 fix(websites): preserve content in metadata-only requests 2026-10-10 09:34:08 -07:00
zwanski2019andCopilot b48e3fc537 Improve embedded docs search relevance
Search document content using normalized multi-term queries and rank title matches above body matches. Add focused tests for content search and ranking.

Co-authored-by: Copilot <[email protected]>
2026-10-09 11:30:59 +01:00
moloch-- 10ec67d274 fix(logging): omit SQLite connection strings from startup logs 2026-10-08 09:07:43 -07:00
moloch-- 52f5517c2a fix(tests): make regression assertions portable to Windows 2026-10-08 08:47:37 -07:00
moloch-- c4ed103182 fix(ci): resolve lint findings in defensive fixes 2026-10-08 08:47:37 -07:00
moloch-- c04b0884a5 fix(tests): run discovered docs and protobuf packages 2026-10-08 08:47:37 -07:00
moloch-- 893fe4ccda fix(client): propagate configuration import failures 2026-10-08 08:47:37 -07:00
moloch-- b43ef0fabe fix(client): keep diagnostic log open through command execution 2026-10-08 08:47:37 -07:00
moloch-- 25d2bffb5a fix(update): select Windows executable release assets 2026-10-08 08:47:37 -07:00
moloch-- 534763f087 fix(websites): persist zero-byte content replacements 2026-10-08 08:47:37 -07:00
moloch-- ec733974de fix(websites): target MIME updates by existing content path 2026-10-08 08:47:37 -07:00
moloch-- d9811baa7e fix(update): preserve prior download until verified replacement 2026-10-08 08:47:37 -07:00
moloch-- d8b360ecea fix(config): preserve database connection values in driver DSNs 2026-10-08 08:47:37 -07:00
moloch-- 12c55be611 fix(config): propagate failed saves and atomically replace files 2026-10-08 08:47:37 -07:00
moloch-- 3a147f1e01 fix(logging): redact administrative secrets from audit and RPC logs 2026-10-08 08:47:36 -07:00
moloch-- 5b03320a32 fix(auth): complete operator revocation after cleanup errors 2026-10-06 21:47:06 -07:00
moloch-- 1cc6d35dc6 fix(websites): restrict recursive removal to selected path 2026-10-06 21:34:23 -07:00
moloch-- 1c5d8ab5a9 Add Sliver GUI download to docs landing page 2026-10-05 17:50:28 -07:00
moloch-- 04a71e6d7d Merge branch 'feat/docs-npm-updates' 2026-10-03 21:07:03 -07:00
moloch-- bb8b90be56 Update docs 2026-10-03 21:06:48 -07:00
Joe 7a2b58ade8 Merge pull request #2391 from BishopFox/feat/docs-npm-updates
build(docs): update npm dependencies
2026-10-03 13:45:51 -07:00
moloch-- 854143b593 build(docs): update npm dependencies 2026-10-03 13:32:35 -07:00
Joe d3068394bf Merge pull request #2390 from BishopFox/dependabot/npm_and_yarn/docs/sliver-docs/next-16.3.6
build(deps): bump next from 16.3.3 to 16.3.6 in /docs/sliver-docs
2026-10-03 13:30:04 -07:00
Joe d80db8a932 Merge pull request #2388 from BishopFox/dependabot/npm_and_yarn/docs/sliver-docs/dompurify-3.4.16
build(deps): bump dompurify from 3.4.13 to 3.4.16 in /docs/sliver-docs
2026-10-03 13:29:56 -07:00
Joe 179ff728dd Merge pull request #2389 from BishopFox/dependabot/npm_and_yarn/docs/sliver-docs/multi-05c505ab13
build(deps): bump brace-expansion in /docs/sliver-docs
2026-10-03 13:29:45 -07:00
moloch-- 422044af0d fix(ci): preserve pinned CNA fixture bytes on Windows 2026-10-03 10:17:29 -07:00
dependabot[bot] 7dbdd1931c build(deps): bump next from 16.3.3 to 16.3.6 in /docs/sliver-docs
Bumps [next](https://github.com/vercel/next.js) from 16.3.3 to 16.3.6.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.6)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-03 00:56:48 +00:00
dependabot[bot] 8b7e1b63ab build(deps): bump brace-expansion in /docs/sliver-docs
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 1.1.16 to 1.1.21
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.16...v1.1.21)

Updates `brace-expansion` from 5.0.8 to 5.0.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.16...v1.1.21)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-03 00:55:54 +00:00
dependabot[bot] ed5c89253c build(deps): bump dompurify from 3.4.13 to 3.4.16 in /docs/sliver-docs
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.13 to 3.4.16.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.13...3.4.16)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-03 00:55:48 +00:00
Joe 06b5a56e74 Merge pull request #2387 from BishopFox/feat/console-multiline-piped-commands
fix(console): run pasted and piped commands in sequence
2026-10-02 17:53:01 -07:00
moloch-- 704912d25a fix(console): resolve CLI lint findings 2026-10-01 20:52:56 -07:00
moloch-- ea1fe95408 fix(console): run pasted and piped commands in sequence 2026-10-01 20:28:48 -07:00
moloch-- 8fe54f243e Tweak readme 2026-09-26 11:19:40 -07:00
moloch-- 4fa6fe86fe Fix doc names 2026-09-26 11:08:55 -07:00
moloch-- 23c5a0b164 Update docs with details on cross-platform bof/coff support 2026-09-26 11:04:41 -07:00
Joe 7b8dafcf55 Merge pull request #2380 from MKMithun2806/fix/client-console-non-tty-oom
fix(client): refuse interactive console on non-TTY stdin
2026-09-20 14:14:35 -07:00
moloch-- 4e6531fa98 test: wait for tunnel deadline cleanup 2026-09-20 11:10:07 -07:00
moloch-- 6e9b2a9820 fix(client): skip interactive loop after non-TTY rc 2026-09-20 10:48:23 -07:00
MKMithun2806 b11f85d4d7 Merge remote-tracking branch 'upstream/master' into fix/client-console-non-tty-oom
# Conflicts:
#	client/console/console.go
2026-09-20 10:17:07 +00:00
MKMithun2806 23f1341684 test(client): add unit tests for non-TTY interactive console guard
Exercises the nonTTYGuard helper introduced in the previous commit
to verify:
- error returned when stdin is not a TTY and no --rc is provided
- nil returned when --rc content is supplied (rc already ran)
- nil returned when stdin is a TTY (normal interactive path)
2026-09-20 09:49:28 +00:00
MKMithun2806 e68e163456 fix(client): refuse interactive console on non-TTY stdin (#2379)
When stdin is not a terminal, the interactive console run-loop
spins with ENOTTY from readline, rebuilding the full command tree
every iteration and consuming memory unboundedly (300-400 MB/s RSS).

Fail closed before entering the loop:
- Non-TTY without --rc: return a clear error.
- Non-TTY with --rc: rc script already ran, return nil without
  calling App.Start() so --rc remains usable for automation.

Uses golang.org/x/term (already vendored) for the TTY check.
No vendor, server, or command-tree changes.
2026-09-20 09:49:02 +00:00
Joe f8430cecf7 Merge pull request #2378 from BishopFox/feat/rportfwd-first-frame-master
fix: preserve reverse port forward first frames
2026-09-19 21:27:44 -07:00
moloch-- 276a30fbaa test: normalize Windows corpus line endings 2026-09-19 19:54:18 -07:00
moloch-- 3aa8a9f93c fix: satisfy reverse precreate lint checks 2026-09-19 19:35:41 -07:00
moloch-- fc3fb7386a fix: preserve reverse port forward first frames
(cherry picked from commit a89c03bb87)
2026-09-19 19:26:43 -07:00
moloch-- 6edea618e9 Remove .gitattributes 2026-09-19 18:33:37 -07:00
moloch-- e920f2f8ea Add RegistryType to read resp 2026-09-19 18:24:15 -07:00
Joe 5b4a03c9c3 Merge pull request #2375 from BishopFox/feat/dependabot-consolidated-2026-09-15
build(deps): consolidate nine Dependabot updates
2026-09-15 15:36:28 -07:00
moloch-- e93fabecd6 build(deps): consolidate open Dependabot updates 2026-09-15 14:35:45 -07:00
Joe b2cae511e6 Merge pull request #2372 from jimmexploit/add-service-name-flag
Add --service-name flag to generate for Windows service implants
2026-09-14 07:34:13 -07:00
Joe 2e8e53789f Merge pull request #2374 from BishopFox/crackstation/top2
Redesign crack top as a real-time dashboard
2026-09-12 17:38:53 -07:00
moloch-- 6a08fb2f7c Fix crack top lint findings 2026-09-12 17:11:48 -07:00
moloch-- 76d38d4677 Improve 'crack top' view 2026-09-12 16:53:33 -07:00
moloch-- 3f87964397 Redesign crack top 2026-09-12 15:39:43 -07:00
moloch-- bb19ba0665 Implement crack cancel/pause/resume 2026-09-12 12:37:01 -07:00
Joe 262851373d Merge pull request #2373 from BishopFox/crackstation/top
Add crack top dashboard and benchmark cache policy
2026-09-12 10:31:15 -07:00
moloch-- 4afcd5bc9d Close crack top polling test database 2026-09-12 09:58:58 -07:00
moloch-- c746a5262c Fix crack top lint findings 2026-09-12 09:32:58 -07:00
moloch-- e91cdf117f Add crack top and more hashcat benchmark options 2026-09-12 09:18:37 -07:00
moloch-- 1fb671f71b crack top 2026-09-12 09:03:48 -07:00
Ubuntu 40df2701d1 Add --service-name flag for Windows service implants 2026-09-12 05:12:33 +00:00
moloch-- c43347886b Update hash types 2026-09-11 16:48:42 -07:00
Joe 4ef33f389c Merge pull request #2371 from BishopFox/crackstation/v1
Crackstation Updates
2026-09-11 15:22:59 -07:00
moloch-- acd52520e4 Fix crackstation lint findings 2026-09-11 14:56:19 -07:00
moloch-- 9a373e20f0 Update hash types to latest version of hashcat 2026-09-11 14:19:19 -07:00
moloch-- aad186a632 improved crackstation support 2026-09-11 12:47:58 -07:00
moloch-- ec9d9b2cc1 improved crackstation support 2026-09-11 09:18:48 -07:00
moloch-- 05d3b2604b crackstation implemented 2026-09-10 11:22:06 -07:00
Joe c3e8a97f66 Merge pull request #2354 from BishopFox/dependabot/go_modules/github.com/tetratelabs/wazero-1.12.0
build(deps): bump github.com/tetratelabs/wazero from 1.11.0 to 1.12.0
2026-09-04 11:27:42 -07:00
dependabot[bot] 547a56696d build(deps): bump github.com/tetratelabs/wazero from 1.11.0 to 1.12.0
Bumps [github.com/tetratelabs/wazero](https://github.com/tetratelabs/wazero) from 1.11.0 to 1.12.0.
- [Release notes](https://github.com/tetratelabs/wazero/releases)
- [Commits](https://github.com/tetratelabs/wazero/compare/v1.11.0...v1.12.0)

---
updated-dependencies:
- dependency-name: github.com/tetratelabs/wazero
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-03 23:42:44 +00:00
moloch-- 84bb2397c7 Merge pull request #2357 from BishopFox/dependabot/npm_and_yarn/docs/sliver-docs/browserslist-4.28.8 2026-09-03 16:26:41 -07:00
moloch-- 503d040885 Merge pull request #2355 from BishopFox/dependabot/go_modules/github.com/alecthomas/chroma/v2-2.27.0 2026-09-03 16:26:37 -07:00
moloch-- 801452eeff Merge pull request #2341 from BishopFox/dependabot/go_modules/google.golang.org/protobuf-1.36.12 2026-09-03 16:26:03 -07:00
moloch-- f6c7d73014 Merge pull request #2343 from BishopFox/dependabot/go_modules/golang.org/x/crypto-0.55.0 2026-09-03 16:25:12 -07:00
moloch-- 228757f656 Merge pull request #2358 from BishopFox/dependabot/go_modules/google.golang.org/grpc-1.83.1 2026-09-03 16:24:09 -07:00
dependabot[bot] 738010e990 build(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.1
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.1 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-03 22:59:35 +00:00
dependabot[bot] 9dcf9b2d25 build(deps): bump golang.org/x/crypto from 0.53.0 to 0.55.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.53.0 to 0.55.0.
- [Commits](https://github.com/golang/crypto/compare/v0.53.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-03 22:59:31 +00:00
dependabot[bot] 32ab020dbd build(deps): bump google.golang.org/protobuf from 1.36.11 to 1.36.12
Bumps google.golang.org/protobuf from 1.36.11 to 1.36.12.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-version: 1.36.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-03 22:59:17 +00:00
Joe 6fcaf970b1 Merge pull request #2346 from BishopFox/feat/go-1.27-server-client-build
Go v1.27
2026-09-03 15:56:40 -07:00
moloch-- 47c83bbaa0 Adjust width of session/beacons cmd output depending on terminal width 2026-09-03 15:56:05 -07:00
moloch-- 7d76febbc9 test: synchronize reverse port forward cleanup 2026-09-03 14:04:02 -07:00
moloch-- f4c182427f refactor: adopt Go 1.27 implant APIs 2026-09-03 13:41:11 -07:00
moloch-- 836e33ade7 Merge remote-tracking branch 'origin/master' into feat/go-1.27-server-client-build
# Conflicts:
#	test/e2e/Dockerfile.linux-386
#	test/reflektor/Dockerfile.linux-386
2026-09-03 11:03:51 -07:00
Joe 0aa7e5bf96 Merge pull request #2360 from BishopFox/feat/remove-386-e2e
ci: remove 386 targets from E2E matrices
2026-09-03 08:58:13 -07:00
moloch-- 1f82c38e11 Fix Go 1.27 Linux shellcode generation 2026-09-02 11:51:54 -07:00
moloch-- 6ac84ffc11 Merge remote-tracking branch 'origin/master' into feat/go-1.27-server-client-build
# Conflicts:
#	client/command/shell/ioloop.go
#	server/handlers/pivot.go
#	server/rpc/rpc-extensions_test.go
2026-09-02 10:20:06 -07:00
moloch-- 2638b5f384 Update Go and Garble to 1.27.1 2026-09-02 09:54:14 -07:00
dependabot[bot] cf19741b9d chore(deps-dev): bump browserslist in /docs/sliver-docs
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.2 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.2...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-01 23:23:11 +00:00
dependabot[bot] a8c6d5e0a3 chore(deps): bump github.com/alecthomas/chroma/v2 from 2.23.1 to 2.27.0
Bumps [github.com/alecthomas/chroma/v2](https://github.com/alecthomas/chroma) from 2.23.1 to 2.27.0.
- [Release notes](https://github.com/alecthomas/chroma/releases)
- [Commits](https://github.com/alecthomas/chroma/compare/v2.23.1...v2.27.0)

---
updated-dependencies:
- dependency-name: github.com/alecthomas/chroma/v2
  dependency-version: 2.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-31 03:03:42 +00:00
moloch-- f447a170c6 Close UUID test database on Windows 2026-08-26 14:00:49 -07:00
moloch-- d565592773 Fix Go 1.27 CI validation 2026-08-26 13:37:58 -07:00
moloch-- c21da4dceb Modernize host code for Go 1.27 2026-08-26 13:13:50 -07:00
moloch-- b08e2ff5c0 Merge branch 'master' into feat/go-1.27-server-client-build 2026-08-26 12:32:17 -07:00
moloch-- c481a4f5db Tweaks for go v1.27 2026-08-24 17:31:01 -07:00
2210 changed files with 187015 additions and 75302 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
# Preserve pinned upstream source and attribution files byte-for-byte.
client/command/opfor/testdata/corpus/** -text
client/command/opfor/testdata/corpus/operatorskit/*.cna whitespace=-trailing-space,-blank-at-eof
vendor/github.com/sliverarmory/opfor/internal/regexp2/ATTRIB whitespace=-trailing-space
+5 -5
View File
@@ -17,7 +17,7 @@ jobs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -56,14 +56,14 @@ jobs:
timeout-minutes: 45
steps:
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Go 1.26.6
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.27.3"
- name: Install Minisign
run: sudo apt-get update --fix-missing && sudo apt-get -y install minisign
+5 -5
View File
@@ -16,13 +16,13 @@ jobs:
steps:
# Setup go environment
- name: Go 1.26.6
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.27.3"
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
# Must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head of the pull request.
@@ -36,7 +36,7 @@ jobs:
git build-essential zlib1g zlib1g-dev wget zip unzip
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
+12 -12
View File
@@ -73,12 +73,12 @@ jobs:
timeout-minutes: 30
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -118,7 +118,7 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -128,7 +128,7 @@ jobs:
printf 'darwin/%s\n' '${{ matrix.goarch }}' > e2e-results/target-darwin-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -210,7 +210,7 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -220,7 +220,7 @@ jobs:
printf 'linux/%s\n' '${{ matrix.goarch }}' > e2e-results/target-linux-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -302,7 +302,7 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -312,7 +312,7 @@ jobs:
printf 'windows/%s\n' '${{ matrix.goarch }}' > e2e-results/target-windows-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -390,12 +390,12 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -465,12 +465,12 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
+6 -6
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Detect package manager
id: detect-package-manager
@@ -56,17 +56,17 @@ jobs:
fi
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
node-version: "lts/*"
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Restore cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
.next/cache
@@ -85,7 +85,7 @@ jobs:
run: ${{ steps.detect-package-manager.outputs.runner }} next build
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: ${{ github.workspace }}/docs/sliver-docs/out
@@ -99,4 +99,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+5 -5
View File
@@ -39,18 +39,18 @@ jobs:
CGO_ENABLED: "0"
steps:
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Go 1.26.6
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.27.3"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
version: v2.14.0
# Gate only findings introduced since the event base revision.
args: --new-from-rev=${{ github.event.pull_request.base.sha || github.event.before }} ./client/... ./server/... ./util/... ./protobuf/... ./docs ./implant/...
+6 -6
View File
@@ -26,10 +26,10 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -78,10 +78,10 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -130,10 +130,10 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
+4 -4
View File
@@ -52,7 +52,7 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -65,7 +65,7 @@ jobs:
> 'shellcode-results/target-${{ matrix.target_os }}-${{ matrix.target_arch }}.txt'
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -131,12 +131,12 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
+16 -16
View File
@@ -27,10 +27,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Go 1.26.6
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.27.3"
- name: OS Packages
run: |
@@ -38,7 +38,7 @@ jobs:
git build-essential zlib1g zlib1g-dev wget zip unzip
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
@@ -73,16 +73,16 @@ jobs:
runs-on: windows-latest
timeout-minutes: 180
steps:
- name: Go 1.26.6
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.27.3"
- name: Install make
run: choco install make -y --no-progress
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
@@ -117,13 +117,13 @@ jobs:
runs-on: macos-latest
timeout-minutes: 180
steps:
- name: Go 1.26.6
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.27.3"
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
@@ -155,13 +155,13 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Go 1.26.6
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.27.3"
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
+1
View File
@@ -9,6 +9,7 @@ run:
- osusergo
- netgo
- go_sqlite
- sliver_generate
# Raw implant entry points are Go templates; lint their supporting packages.
- sliver_lint
+3 -3
View File
@@ -26,7 +26,7 @@ ifneq (,$(findstring cgo_sqlite,$(TAGS)))
endif
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
MIN_SUPPORTED_GO_MINOR_VERSION = 26
MIN_SUPPORTED_GO_MINOR_VERSION = 27
GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MIN_SUPPORTED_GO_MAJOR_VERSION).$(MIN_SUPPORTED_GO_MINOR_VERSION)
SLIVER_PUBLIC_KEY ?= RWTZPg959v3b7tLG7VzKHRB1/QT+d3c71Uzetfa44qAoX5rH7mGoQTTR
@@ -199,7 +199,7 @@ clean-all: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
.downloaded_assets: util/assets/constants.go util/assets/garble.go
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
@@ -324,7 +324,7 @@ clean-all: clean
clean:
-del /Q /F sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe 2>NUL
.downloaded_assets:
.downloaded_assets: util/assets/constants.go util/assets/garble.go
$(call windows_exec,$(ENV),"$(GO)" run -mod=vendor ./util/cmd/assets)
@type NUL > .downloaded_assets
+6 -19
View File
@@ -1,30 +1,17 @@
# Sliver
Sliver is an open source cross-platform adversary emulation/red team framework, it can be used by organizations of all sizes to perform security testing. Sliver's implants support C2 over Mutual TLS (mTLS), WireGuard, HTTP(S), and DNS and are dynamically compiled with per-binary asymmetric encryption keys.
Sliver is an open-source, cross-platform adversary emulation and red team framework designed for organizations of all sizes to perform authorized security testing.
The server and client support MacOS, Windows, and Linux. Implants are supported on MacOS, Windows, and Linux (and possibly every Golang compiler target but we've not tested them all).
Sliver implants support command-and-control (C2) over Mutual TLS (mTLS), WireGuard, HTTP(S), and DNS. Implants are dynamically compiled with unique per-binary asymmetric encryption keys and support a wide range of post-exploitation capabilities across macOS, Windows, and Linux.
Sliver includes support for native and shellcode payloads, userspace reflective loading, built-in shellcode encoding, and BOF/COFF execution on both amd64 and arm64 architectures for macOS, Windows, and Linux.
The Sliver server and client run on macOS, Windows, and Linux. Implants officially support those same platforms and may also run on additional targets supported by the Go compiler, although those configurations are not regularly tested.
[![Release](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [![golangci-lint](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
Visit [https://sliver.sh/](https://sliver.sh/) for tutorials and documentation.
### Features
- Dynamic code generation
- Compile-time obfuscation
- Multiplayer-mode
- Staged and Stageless payloads
- [Procedurally generated C2](https://sliver.sh/docs?name=HTTPS+C2) over HTTP(S)
- [DNS canary](https://sliver.sh/docs?name=DNS+C2) blue team detection
- [Secure C2](https://sliver.sh/docs?name=Transport+Encryption) over mTLS, WireGuard, HTTP(S), and DNS
- Fully scriptable using [Python](https://github.com/moloch--/sliver-py)
- Windows process migration, process injection, user token manipulation, etc.
- Let's Encrypt integration
- In-memory .NET assembly execution
- COFF/BOF in-memory loader
- TCP and named pipe pivots
- Much more!
### Getting Started
Download the latest [release](https://github.com/BishopFox/sliver/releases) and see the Sliver [wiki](https://sliver.sh/docs?name=Getting+Started) for a quick tutorial on basic setup and usage. To get the very latest and greatest compile from source.
+30 -3
View File
@@ -19,6 +19,7 @@ package cli
*/
import (
"errors"
"fmt"
"log"
"os"
@@ -34,6 +35,8 @@ const (
logFileName = "sliver-client.log"
)
var clientLogFile *os.File
// Initialize logging.
func initLogging(appDir string) *os.File {
log.SetFlags(log.LstdFlags | log.Lshortfile)
@@ -47,11 +50,11 @@ func initLogging(appDir string) *os.File {
func init() {
appDir := assets.GetRootAppDir()
logFile := initLogging(appDir)
defer logFile.Close()
clientLogFile = initLogging(appDir)
rootCmd.TraverseChildren = true
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
rootCmd.PersistentFlags().String(ConsoleConfigFlagName, "", "path to client config file")
rootCmd.PersistentFlags().Bool(enableWGFlag, false, "force multiplayer connections through the operator config's WireGuard wrapper")
rootCmd.PersistentFlags().Bool(disableWGFlag, false, "force multiplayer connections to use direct mTLS")
rootCmd.MarkFlagsMutuallyExclusive(enableWGFlag, disableWGFlag)
@@ -96,10 +99,34 @@ var rootCmd = &cobra.Command{
Long: ``,
}
type commandExitError struct {
code int
err error
}
func (e *commandExitError) Error() string {
return e.err.Error()
}
func (e *commandExitError) Unwrap() error {
return e.err
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
if exitErr, ok := errors.AsType[*commandExitError](err); ok {
return exitErr.code
}
return 1
}
// Execute - Execute root command.
func Execute() {
defer func() { _ = clientLogFile.Close() }()
if err := rootCmd.Execute(); err != nil {
fmt.Printf("root command: %s\n", err)
os.Exit(1)
os.Exit(commandExitCode(err))
}
}
+23
View File
@@ -0,0 +1,23 @@
package cli
import (
"log"
"os"
"path/filepath"
"testing"
"github.com/bishopfox/sliver/client/assets"
)
func TestPackageLoggerRemainsOpenAfterInit(t *testing.T) {
logFile, ok := log.Writer().(*os.File)
if !ok {
t.Fatalf("standard logger writer = %T, want *os.File", log.Writer())
}
if want := filepath.Join(assets.GetRootAppDir(), logFileName); filepath.Clean(logFile.Name()) != want {
t.Fatalf("standard logger file = %q, want %q", logFile.Name(), want)
}
if _, err := logFile.Stat(); err != nil {
t.Fatalf("standard logger file is closed after package initialization: %v", err)
}
}
+19 -7
View File
@@ -20,24 +20,37 @@ package cli
import (
"fmt"
"path/filepath"
"sort"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/forms"
)
func selectConfig() (string, *assets.ClientConfig) {
configs := assets.GetConfigs()
func selectConfig(configPath string, stdinTTY bool) (string, *assets.ClientConfig, error) {
if configPath != "" {
config, err := loadMCPClientConfig(configPath)
if err != nil {
return "", nil, fmt.Errorf("load client config: %w", err)
}
return filepath.Base(configPath), config, nil
}
return selectConfigFrom(assets.GetConfigs(), stdinTTY)
}
func selectConfigFrom(configs map[string]*assets.ClientConfig, stdinTTY bool) (string, *assets.ClientConfig, error) {
if len(configs) == 0 {
return "", nil
return "", nil, fmt.Errorf("no config files found at %s", assets.GetConfigDir())
}
if len(configs) == 1 {
for key, config := range configs {
return key, config
return key, config, nil
}
}
if !stdinTTY {
return "", nil, fmt.Errorf("multiple configs found; use --config to select one")
}
keys := make([]string, 0, len(configs))
for key := range configs {
@@ -48,9 +61,8 @@ func selectConfig() (string, *assets.ClientConfig) {
selection := keys[0]
err := forms.Select("Select a server:", keys, &selection)
if err != nil {
fmt.Println(err.Error())
return "", nil
return "", nil, err
}
return selection, configs[selection]
return selection, configs[selection], nil
}
+46
View File
@@ -0,0 +1,46 @@
package cli
import (
"testing"
)
func TestConfigFlagRoutesBeforeAndAfterConsoleAndMCP(t *testing.T) {
flag := rootCmd.PersistentFlags().Lookup(ConsoleConfigFlagName)
if flag == nil {
t.Fatal("root --config flag is missing")
}
oldValue, oldChanged := flag.Value.String(), flag.Changed
t.Cleanup(func() {
_ = flag.Value.Set(oldValue)
flag.Changed = oldChanged
})
for _, command := range []string{"console", "mcp"} {
for _, before := range []bool{true, false} {
name := command + " after --config"
args := []string{"--config", "selected.cfg", command}
if !before {
name = command + " before --config"
args = []string{command, "--config", "selected.cfg"}
}
t.Run(name, func(t *testing.T) {
_ = flag.Value.Set("")
flag.Changed = false
cmd, remaining, err := rootCmd.Traverse(args)
if err != nil {
t.Fatalf("traverse %q: %v", args, err)
}
if cmd.Name() != command {
t.Fatalf("command = %q, want %q", cmd.Name(), command)
}
if err := cmd.ParseFlags(remaining); err != nil {
t.Fatalf("parse %q: %v", remaining, err)
}
got, err := cmd.Flags().GetString(ConsoleConfigFlagName)
if err != nil || got != "selected.cfg" {
t.Fatalf("--config = %q, error = %v", got, err)
}
})
}
}
}
+53
View File
@@ -0,0 +1,53 @@
package cli
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bishopfox/sliver/client/assets"
)
func TestSelectConfigFromNonTTYRequiresExplicitConfigWhenMultiple(t *testing.T) {
configs := map[string]*assets.ClientConfig{
"first": {Operator: "first"},
"second": {Operator: "second"},
}
key, config, err := selectConfigFrom(configs, false)
if err == nil || !strings.Contains(err.Error(), "--config") {
t.Fatalf("error = %v, want --config guidance", err)
}
if key != "" || config != nil {
t.Fatalf("selected %q (%v) without an explicit config", key, config)
}
}
func TestSelectConfigFromNonTTYUsesSoleConfig(t *testing.T) {
want := &assets.ClientConfig{Operator: "only"}
key, config, err := selectConfigFrom(map[string]*assets.ClientConfig{"only": want}, false)
if err != nil || key != "only" || config != want {
t.Fatalf("selection = %q (%v), error = %v", key, config, err)
}
}
func TestSelectConfigWithExplicitPathOnNonTTY(t *testing.T) {
want := &assets.ClientConfig{Operator: "selected", LHost: "127.0.0.1"}
data, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "selected.cfg")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
key, config, err := selectConfig(path, false)
if err != nil {
t.Fatalf("select explicit config: %v", err)
}
if key != "selected.cfg" || config.Operator != want.Operator || config.LHost != want.LHost {
t.Fatalf("selection = %q (%v)", key, config)
}
}
+13
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/bishopfox/sliver/client/transport"
@@ -52,6 +53,10 @@ func TestFormatConnectSpinnerMessage(t *testing.T) {
}
func TestConnectWithSpinnerOutput(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerOutput)
}
func testConnectWithSpinnerOutput(t *testing.T) {
var out bytes.Buffer
spinnerDelay := 2 * (100 * time.Millisecond)
@@ -85,6 +90,10 @@ func TestConnectWithSpinnerOutput(t *testing.T) {
}
func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerFastSuccessStillShowsMultipleFrames)
}
func testConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
@@ -108,6 +117,10 @@ func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
}
func TestConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerFastSuccessShowsEachStatus)
}
func testConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
var out bytes.Buffer
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
+25 -17
View File
@@ -22,15 +22,18 @@ import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/spf13/cobra"
"golang.org/x/term"
"google.golang.org/grpc"
)
// ConsoleConfigFlagName is the flag used to select a client config file.
const ConsoleConfigFlagName = "config"
// consoleCmd generates the console with required pre/post runners.
func consoleCmd(con *console.SliverClient) *cobra.Command {
consoleCmd := &cobra.Command{
@@ -49,20 +52,22 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
return err
}
configs := assets.GetConfigs()
if len(configs) == 0 {
fmt.Printf("No config files found at %s (see --help)\n", assets.GetConfigDir())
return nil
}
configKey, config := selectConfig()
if config == nil {
return nil
}
rcScript, err := ReadRCScript(cmd)
if err != nil {
fmt.Printf("Failed to read rc script: %s\n", err)
return nil
return err
}
configPath := ""
if cmd.Flags().Lookup(ConsoleConfigFlagName) != nil {
configPath, err = cmd.Flags().GetString(ConsoleConfigFlagName)
if err != nil {
return err
}
}
stdinTTY := term.IsTerminal(int(os.Stdin.Fd()))
configKey, config, err := selectConfig(configPath, stdinTTY)
if err != nil {
return err
}
target := fmt.Sprintf("%s:%d", config.LHost, config.LPort)
@@ -70,7 +75,7 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
var ln *grpc.ClientConn
// Don't clobber output when simply running an implant command from system shell.
if run {
if run && stdinTTY {
rpc, ln, err = connectWithSpinner(os.Stdout, target, func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
return transport.MTLSConnectWithStatus(config, statusFn)
})
@@ -78,10 +83,13 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
rpc, ln, err = transport.MTLSConnect(config)
}
if err != nil {
fmt.Printf("Connection to server failed %s\n", err)
return nil
return fmt.Errorf("connection to server failed: %w", err)
}
return console.StartClient(con, rpc, ln, &console.ConnectionDetails{ConfigKey: configKey, Config: config}, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript)
if err := console.StartClient(con, rpc, ln, &console.ConnectionDetails{ConfigKey: configKey, Config: config}, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript); err != nil {
_ = con.CloseConnection()
return err
}
return nil
}
// Close the RPC connection once exiting
+3 -1
View File
@@ -57,7 +57,9 @@ func makeRunners(implantCmd *cobra.Command, con *console.SliverClient) (pre, pos
// The pre-run function connects to the server and sets up a "fake" console,
// so we can have access to active sessions/beacons, and other stuff needed.
pre = func(_ *cobra.Command, args []string) error {
startConsole(implantCmd, args)
if err := startConsole(implantCmd, args); err != nil {
return err
}
// Set the active target.
target, _ := implantCmd.Flags().GetString("use")
+21 -13
View File
@@ -20,7 +20,6 @@ package cli
import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/assets"
"github.com/rsteube/carapace"
@@ -32,19 +31,12 @@ func importCmd() *cobra.Command {
Use: "import",
Short: "Import a client configuration file",
Long: `import [config files]`,
Run: func(cmd *cobra.Command, args []string) {
if 0 < len(args) {
for _, arg := range args {
conf, err := assets.ReadConfig(arg)
if err != nil {
fmt.Printf("[!] %s\n", err)
os.Exit(3)
}
assets.SaveConfig(conf)
}
} else {
fmt.Printf("Missing config file path, see --help")
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Missing config file path, see --help")
return nil
}
return importConfigs(args)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{}, cobra.ShellCompDirectiveDefault
@@ -55,3 +47,19 @@ func importCmd() *cobra.Command {
return cmdImport
}
func importConfigs(paths []string) error {
for _, path := range paths {
conf, err := assets.ReadConfig(path)
if err != nil {
return &commandExitError{
code: 3,
err: fmt.Errorf("read import %q: %w", path, err),
}
}
if err := assets.SaveConfig(conf); err != nil {
return fmt.Errorf("save import %q: %w", path, err)
}
}
return nil
}
+125
View File
@@ -0,0 +1,125 @@
package cli
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func writeImportConfig(t *testing.T, name, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestImportCommandReportsInvalidConfig(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
path := writeImportConfig(t, "missing-operator.cfg", `{"lhost":"server"}`)
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want error identifying %q", err, path)
}
if got := commandExitCode(err); got != 1 {
t.Fatalf("invalid config exit code = %d, want 1", got)
}
}
func TestImportCommandReportsConfigWriteFailure(t *testing.T) {
clientRoot := t.TempDir()
t.Setenv("SLIVER_CLIENT_ROOT_DIR", clientRoot)
path := writeImportConfig(t, "alice.cfg", `{"operator":"alice","lhost":"server"}`)
configDir := filepath.Join(clientRoot, "configs")
if err := os.MkdirAll(filepath.Join(configDir, "alice_server.cfg"), 0o700); err != nil {
t.Fatal(err)
}
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want write error identifying %q", err, path)
}
if got := commandExitCode(err); got != 1 {
t.Fatalf("config write failure exit code = %d, want 1", got)
}
if _, ok := errors.AsType[*os.PathError](err); !ok {
t.Fatalf("config write failure does not wrap the filesystem error: %v", err)
}
}
func TestImportCommandReadFailureExitCode(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
path := filepath.Join(t.TempDir(), "missing.cfg")
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want read error identifying %q", err, path)
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("import error does not wrap the missing file error: %v", err)
}
if got := commandExitCode(fmt.Errorf("command failed: %w", err)); got != 3 {
t.Fatalf("wrapped read failure exit code = %d, want 3", got)
}
}
func TestImportCommandParseFailureExitCode(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
path := writeImportConfig(t, "malformed.cfg", `{"operator":`)
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want parse error identifying %q", err, path)
}
if _, ok := errors.AsType[*json.SyntaxError](err); !ok {
t.Fatalf("import error does not wrap the JSON syntax error: %v", err)
}
if got := commandExitCode(err); got != 3 {
t.Fatalf("parse failure exit code = %d, want 3", got)
}
}
func TestImportCommandStopsAfterFirstFailure(t *testing.T) {
clientRoot := t.TempDir()
t.Setenv("SLIVER_CLIENT_ROOT_DIR", clientRoot)
invalidPath := writeImportConfig(t, "invalid.cfg", `{"lhost":"server"}`)
validPath := writeImportConfig(t, "valid.cfg", `{"operator":"alice","lhost":"server"}`)
cmd := importCmd()
err := cmd.RunE(cmd, []string{invalidPath, validPath})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(invalidPath)) {
t.Fatalf("import error = %v, want first file %q", err, invalidPath)
}
if _, err := os.Stat(filepath.Join(clientRoot, "configs", "alice_server.cfg")); !os.IsNotExist(err) {
t.Fatalf("later config was imported after first failure: stat error = %v", err)
}
}
func TestImportCommandMissingPathPreservesSuccess(t *testing.T) {
cmd := importCmd()
var output bytes.Buffer
cmd.SetOut(&output)
err := cmd.RunE(cmd, nil)
if err != nil {
t.Fatalf("import without a path failed: %v", err)
}
if got := commandExitCode(err); got != 0 {
t.Fatalf("missing path exit code = %d, want 0", got)
}
if got, want := output.String(), "Missing config file path, see --help"; got != want {
t.Fatalf("missing path output = %q, want %q", got, want)
}
}
+14 -2
View File
@@ -22,12 +22,14 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/console"
clientmcp "github.com/bishopfox/sliver/client/mcp"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
"google.golang.org/grpc/keepalive"
)
func mcpCmd(con *console.SliverClient) *cobra.Command {
@@ -38,7 +40,6 @@ func mcpCmd(con *console.SliverClient) *cobra.Command {
return runMCP(cmd, con)
},
}
mcpCmd.Flags().String("config", "", "path to client config file")
return mcpCmd
}
@@ -54,6 +55,14 @@ func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
return nil
}
// Keep the gRPC link alive while the MCP bridge sits idle between tool calls.
// The daemon permits pings >=30s without streams (server.yaml: grpc.keepalive).
transport.SetKeepaliveParams(keepalive.ClientParameters{
Time: 60 * time.Second,
Timeout: 20 * time.Second,
PermitWithoutStream: true,
})
rpc, ln, err := transport.MTLSConnect(config)
if err != nil {
fmt.Printf("Connection to server failed %s\n", err)
@@ -62,7 +71,10 @@ func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
defer transport.CloseGRPCConnection(ln)
con.Rpc = rpc
go handleConnectionLost(ln)
// Do not install the connection-lost watchdog here: gRPC channels idle
// out after 30 minutes without RPCs (grpc-go default) and transparently
// reconnect on the next call. Exiting on the Idle state would kill an
// otherwise healthy MCP bridge between tool calls.
cfg := clientmcp.DefaultConfig()
if err := clientmcp.ServeStdio(cfg, con.Rpc); err != nil {
+5 -20
View File
@@ -487,10 +487,7 @@ func (m *aiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if conversationContainsUserPrompt(m.currentConversation, m.pendingPrompt) {
m.pendingPrompt = ""
}
m.selectedConversation = conversationIndexByID(m.conversations, selectedConversationID(msg.conversation, msg.selectedID))
if m.selectedConversation < 0 {
m.selectedConversation = 0
}
m.selectedConversation = max(conversationIndexByID(m.conversations, selectedConversationID(msg.conversation, msg.selectedID)), 0)
m.invalidateTranscriptCache()
if strings.TrimSpace(msg.status) != "" {
m.status = msg.status
@@ -3273,10 +3270,7 @@ func (m *aiModel) removeConversation(conversationID string) {
}
m.conversations = filtered
next := conversationIndexByID(m.conversations, m.selectedConversationID())
if next < 0 {
next = 0
}
next := max(conversationIndexByID(m.conversations, m.selectedConversationID()), 0)
m.selectedConversation = next
}
@@ -3683,10 +3677,7 @@ func (m *aiModel) applyConversationEvent(event *clientpb.AIConversationEvent) {
}
if m.currentConversation != nil {
m.selectedConversation = conversationIndexByID(m.conversations, m.currentConversation.GetID())
if m.selectedConversation < 0 {
m.selectedConversation = 0
}
m.selectedConversation = max(conversationIndexByID(m.conversations, m.currentConversation.GetID()), 0)
}
m.invalidateTranscriptCache()
}
@@ -5579,10 +5570,7 @@ func truncateToolCallContentLines(content string, maxLines int) (string, int) {
}
truncated := strings.Join(lines[:maxLines], "\n")
hiddenBytes := len(content) - len(truncated)
if hiddenBytes < 0 {
hiddenBytes = 0
}
hiddenBytes := max(len(content)-len(truncated), 0)
return truncated, hiddenBytes
}
@@ -5828,10 +5816,7 @@ func inputWindow(input []rune, cursor, width int) ([]rune, int) {
start := 0
if len(input) > width {
start = cursor - width + 1
if start < 0 {
start = 0
}
start = max(cursor-width+1, 0)
if start > len(input)-width {
start = len(input) - width
}
+4 -18
View File
@@ -156,13 +156,6 @@ func TestMessageBlockLabelIgnoresUnknownOperatorPlaceholders(t *testing.T) {
}
}
func TestTranscriptSpeakerStyleUsesPrimaryThemeForUserMessages(t *testing.T) {
styles := transcriptSpeakerStyle("alice", "user")
assertColorEqual(t, styles.border.GetForeground(), clienttheme.Primary())
assertColorEqual(t, styles.label.GetBackground(), clienttheme.Primary())
}
func TestThinkingLevelOptionFocusedStyleUsesDarkerPrimaryShade(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
@@ -544,19 +537,12 @@ func TestRenderHeaderUsesSingleCompactRow(t *testing.T) {
}
}
func TestTranscriptSpeakerPaletteVariesAcrossUsers(t *testing.T) {
seen := map[int]struct{}{}
for _, label := range []string{"alice", "bob", "charlie", "dana", "erin", "frank"} {
seen[transcriptSpeakerPaletteIndex(label, "user")] = struct{}{}
}
if len(seen) < 2 {
t.Fatalf("expected distinct users to map to more than one transcript color, got %d palette entries", len(seen))
}
}
func TestTranscriptSpeakerStyleUsesPrimaryBorderForUserMessages(t *testing.T) {
userStyles := transcriptSpeakerStyle("alice", "user")
userBorder := transcriptSpeakerStyle("alice", "user").border.Render("│")
assertColorEqual(t, userStyles.border.GetForeground(), clienttheme.Primary())
assertColorEqual(t, userStyles.label.GetBackground(), clienttheme.Primary())
userBorder := userStyles.border.Render("│")
wantUserBorder := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("│")
if userBorder != wantUserBorder {
t.Fatalf("expected user message border to use theme primary color")
-1
View File
@@ -1 +0,0 @@
package armory
+2 -2
View File
@@ -22,6 +22,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
@@ -37,7 +38,6 @@ import (
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/util"
"github.com/bishopfox/sliver/util/minisign"
)
@@ -348,7 +348,7 @@ func getPackagesWithCommandName(name, armoryPK, minimumVersion string) []*pkgCac
func getPackageIDFromUser(name string, options map[string]string) string {
selectedPackageKey := ""
optionKeys := util.Keys(options)
optionKeys := slices.Collect(maps.Keys(options))
slices.Sort(optionKeys)
// Add a cancel option
optionKeys = append(optionKeys, doNotInstallOption)
+4 -3
View File
@@ -21,7 +21,9 @@ package armory
import (
"errors"
"fmt"
"maps"
"os"
"slices"
"sort"
"github.com/jedib0t/go-pretty/v6/table"
@@ -33,7 +35,6 @@ import (
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/util"
)
type VersionInformation struct {
@@ -244,8 +245,8 @@ func sortUpdateIdentifiers(aliasUpdates, extensionUpdates map[string]VersionInfo
result := []UpdateIdentifier{}
aliasNames := util.Keys(aliasUpdates)
extensionNames := util.Keys(extensionUpdates)
aliasNames := slices.Collect(maps.Keys(aliasUpdates))
extensionNames := slices.Collect(maps.Keys(extensionUpdates))
for _, name := range aliasNames {
result = append(result, UpdateIdentifier{
Type: AliasPackage,
+143 -87
View File
@@ -30,6 +30,7 @@ import (
"github.com/bishopfox/sliver/client/command/kill"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
@@ -121,50 +122,30 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
if err != nil {
width = 999
}
return renderBeaconsAtWidth(beacons, filter, filterRegex, con, width, time.Now())
}
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
wideTermWidth := con.Settings.SmallTermWidth < width
// renderBeaconsAtWidth uses one reference time for all candidate layouts,
// preventing a second boundary from changing the table width while it is fitted.
func renderBeaconsAtWidth(beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, width int, now time.Time) table.Writer {
windowsBeaconInList := false
for _, beacon := range beacons {
if beacon.OS == "windows" {
windowsBeaconInList = true
break
}
}
if wideTermWidth {
if windowsBeaconInList {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Tasks",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Integrity",
"Operating System",
"Locale",
"Last Check-in",
"Next Check-in",
})
} else {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Tasks",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Operating System",
"Locale",
"Last Check-in",
"Next Check-in",
})
}
} else {
return targettable.Fit(width, windowsBeaconInList, func(layout targettable.Layout) table.Writer {
return buildBeaconTable(beacons, filter, filterRegex, con, layout, windowsBeaconInList, now)
})
}
func buildBeaconTable(beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, layout targettable.Layout, windowsBeaconInList bool, now time.Time) table.Writer {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
includeIntegrity := windowsBeaconInList && layout.IncludeIntegrity
if layout.Compact {
tw.AppendHeader(table.Row{
"ID",
"Name",
@@ -175,22 +156,84 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
"Last Check-in",
"Next Check-in",
})
} else {
header := table.Row{
"ID",
"Name",
"Tasks",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
}
if includeIntegrity {
header = append(header, "Integrity")
}
header = append(header, "Operating System")
if layout.IncludeLocale {
header = append(header, "Locale")
}
header = append(header, "Last Check-in", "Next Check-in")
tw.AppendHeader(header)
}
activeBeaconID := ""
if con.ActiveTarget != nil {
if activeBeacon := con.ActiveTarget.GetBeacon(); activeBeacon != nil {
activeBeaconID = activeBeacon.ID
}
}
for _, beacon := range beacons {
integrity := beacon.Integrity
if integrity == "" {
integrity = "-"
}
username := strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")
lastCheckin := time.Unix(beacon.LastCheckin, 0)
nextCheckin := time.Unix(beacon.NextCheckin, 0)
canonicalEntries := []string{
strings.Split(beacon.ID, "-")[0],
beacon.Name,
fmt.Sprintf("%d/%d", beacon.TasksCountCompleted, beacon.TasksCount),
beacon.Transport,
beacon.RemoteAddress,
beacon.Hostname,
username,
fmt.Sprintf("%s (%d)", beacon.Filename, beacon.PID),
integrity,
fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch),
beacon.Locale,
formatBeaconDateDelta(lastCheckin, now, true, false),
formatBeaconDateDelta(nextCheckin, now, true, false),
}
if !beaconMatchesFilter(canonicalEntries, filter, filterRegex) {
continue
}
style := console.StyleNormal
activeBeacon := con.ActiveTarget.GetBeacon()
if activeBeacon != nil && activeBeacon.ID == beacon.ID {
if activeBeaconID == beacon.ID {
style = console.StyleGreen
}
if beacon.Integrity == "" {
beacon.Integrity = "-"
}
// We need a slice of strings so we can apply filters
var rowEntries []string
if wideTermWidth {
if layout.Compact {
rowEntries = []string{
style.Render(strings.Split(beacon.ID, "-")[0]),
style.Render(beacon.Name),
style.Render(beacon.Transport),
style.Render(beacon.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
formatBeaconDateDelta(lastCheckin, now, false, false),
formatBeaconDateDelta(nextCheckin, now, false, true),
}
} else {
processName := beacon.Filename
if layout.ProcessBasename {
processName = targettable.ProcessName(processName)
}
rowEntries = []string{
style.Render(strings.Split(beacon.ID, "-")[0]),
style.Render(beacon.Name),
@@ -198,56 +241,69 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
style.Render(beacon.Transport),
style.Render(beacon.RemoteAddress),
style.Render(beacon.Hostname),
style.Render(strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
style.Render(fmt.Sprintf("%s (%d)", beacon.Filename, beacon.PID)),
style.Render(username),
style.Render(fmt.Sprintf("%s (%d)", processName, beacon.PID)),
}
if windowsBeaconInList {
rowEntries = append(rowEntries, style.Render(beacon.Integrity))
if includeIntegrity {
rowEntries = append(rowEntries, style.Render(integrity))
}
rowEntries = append(rowEntries, []string{
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
style.Render(beacon.Locale),
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
}...)
} else {
rowEntries = []string{
style.Render(strings.Split(beacon.ID, "-")[0]),
style.Render(beacon.Name),
style.Render(beacon.Transport),
style.Render(beacon.Hostname),
style.Render(strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
rowEntries = append(rowEntries, style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)))
if layout.IncludeLocale {
rowEntries = append(rowEntries, style.Render(beacon.Locale))
}
rowEntries = append(rowEntries,
formatBeaconDateDelta(lastCheckin, now, true, false),
formatBeaconDateDelta(nextCheckin, now, true, true),
)
}
// Build the row struct
row := table.Row{}
for _, entry := range rowEntries {
row = append(row, entry)
}
// Apply filters if any
if filter == "" && filterRegex == nil {
tw.AppendRow(row)
} else {
for _, rowEntry := range rowEntries {
if filter != "" {
if strings.Contains(rowEntry, filter) {
tw.AppendRow(row)
break
}
}
if filterRegex != nil {
if filterRegex.MatchString(rowEntry) {
tw.AppendRow(row)
break
}
}
}
}
tw.AppendRow(row)
}
return tw
}
func beaconMatchesFilter(entries []string, filter string, filterRegex *regexp.Regexp) bool {
if filter == "" && filterRegex == nil {
return true
}
for _, entry := range entries {
if filter != "" && strings.Contains(entry, filter) {
return true
}
if filterRegex != nil && filterRegex.MatchString(entry) {
return true
}
}
return false
}
func formatBeaconDateDelta(timestamp time.Time, now time.Time, includeDate bool, color bool) string {
date := timestamp.Format(time.UnixDate)
interval := ""
if timestamp.Before(now) {
delta := now.Sub(timestamp).Round(time.Second)
if includeDate {
interval = fmt.Sprintf("%s (%s ago)", date, delta)
} else {
interval = delta.String()
}
if color {
interval = console.StyleBoldRed.Render(interval)
}
} else {
delta := timestamp.Sub(now).Round(time.Second)
if includeDate {
interval = fmt.Sprintf("%s (in %s)", date, delta)
} else {
interval = delta.String()
}
if color {
interval = console.StyleBoldGreen.Render(interval)
}
}
return interval
}
+257
View File
@@ -0,0 +1,257 @@
package beacons
import (
"regexp"
"strings"
"testing"
"time"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
var beaconTableTestNow = time.Date(2026, time.September, 3, 21, 25, 41, 0, time.UTC)
func TestRenderBeaconsUsesFullProcessPathWhenItFits(t *testing.T) {
beacon := testWindowsBeacon()
layout := targettable.Layout{IncludeIntegrity: true, IncludeLocale: true}
width := beaconCandidateWidth(t, []*clientpb.Beacon{beacon}, layout, true)
rendered := renderBeaconTableAtWidth(t, []*clientpb.Beacon{beacon}, "", nil, width)
if !strings.Contains(rendered, beacon.Filename+" (2020)") {
t.Fatalf("full process path missing from table:\n%s", rendered)
}
if !strings.Contains(rendered, "(36s ago)") || !strings.Contains(rendered, "(in 24s)") {
t.Fatalf("wide check-in timestamps changed unexpectedly:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, width)
}
func TestRenderBeaconsUsesPortableProcessBasenameWhenRequired(t *testing.T) {
tests := []struct {
name string
beacon *clientpb.Beacon
hasIntegrity bool
wantProcess string
}{
{
name: "windows path",
beacon: testWindowsBeacon(),
hasIntegrity: true,
wantProcess: "winterfell.exe (2020)",
},
{
name: "posix path",
beacon: testLinuxBeacon(),
hasIntegrity: false,
wantProcess: "winterfell-agent (2020)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
beacons := []*clientpb.Beacon{tt.beacon}
fullLayout := targettable.Layout{
IncludeIntegrity: tt.hasIntegrity,
IncludeLocale: true,
}
basenameLayout := fullLayout
basenameLayout.ProcessBasename = true
fullWidth := beaconCandidateWidth(t, beacons, fullLayout, tt.hasIntegrity)
basenameWidth := beaconCandidateWidth(t, beacons, basenameLayout, tt.hasIntegrity)
if basenameWidth >= fullWidth {
t.Fatalf("basename candidate width %d is not narrower than full-path width %d", basenameWidth, fullWidth)
}
rendered := renderBeaconTableAtWidth(t, beacons, "", nil, basenameWidth)
if !strings.Contains(rendered, tt.wantProcess) {
t.Fatalf("process basename missing from table:\n%s", rendered)
}
if strings.Contains(rendered, tt.beacon.Filename) {
t.Fatalf("full process path remained in basename layout:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, basenameWidth)
})
}
}
func TestRenderBeaconsOmitsIntegrityBeforeLocale(t *testing.T) {
beacon := testWindowsBeacon()
beacons := []*clientpb.Beacon{beacon}
withAll := targettable.Layout{
ProcessBasename: true,
IncludeIntegrity: true,
IncludeLocale: true,
}
withoutIntegrity := withAll
withoutIntegrity.IncludeIntegrity = false
withoutLocale := withoutIntegrity
withoutLocale.IncludeLocale = false
allWidth := beaconCandidateWidth(t, beacons, withAll, true)
withoutIntegrityWidth := beaconCandidateWidth(t, beacons, withoutIntegrity, true)
withoutLocaleWidth := beaconCandidateWidth(t, beacons, withoutLocale, true)
if !(withoutLocaleWidth < withoutIntegrityWidth && withoutIntegrityWidth < allWidth) {
t.Fatalf("candidate widths did not shrink in omission order: all=%d no-integrity=%d no-locale=%d", allWidth, withoutIntegrityWidth, withoutLocaleWidth)
}
withoutIntegrityRendered := renderBeaconTableAtWidth(t, beacons, "", nil, withoutIntegrityWidth)
assertHeaderPresence(t, withoutIntegrityRendered, "Integrity", false)
assertHeaderPresence(t, withoutIntegrityRendered, "Locale", true)
assertBeaconTableFits(t, withoutIntegrityRendered, withoutIntegrityWidth)
withoutLocaleRendered := renderBeaconTableAtWidth(t, beacons, "", nil, withoutLocaleWidth)
assertHeaderPresence(t, withoutLocaleRendered, "Integrity", false)
assertHeaderPresence(t, withoutLocaleRendered, "Locale", false)
assertBeaconTableFits(t, withoutLocaleRendered, withoutLocaleWidth)
}
func TestRenderBeaconsDoesNotAddIntegrityForNonWindowsTargets(t *testing.T) {
beacon := testLinuxBeacon()
beacons := []*clientpb.Beacon{beacon}
layout := targettable.Layout{IncludeLocale: true}
width := beaconCandidateWidth(t, beacons, layout, false)
rendered := renderBeaconTableAtWidth(t, beacons, "", nil, width)
assertHeaderPresence(t, rendered, "Integrity", false)
assertHeaderPresence(t, rendered, "Locale", true)
if !strings.Contains(rendered, beacon.Filename+" (2020)") {
t.Fatalf("non-Windows full process path missing from table:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, width)
}
func TestRenderBeaconsFiltersCanonicalFullRows(t *testing.T) {
beacon := testWindowsBeacon()
beacons := []*clientpb.Beacon{beacon}
basenameLayout := targettable.Layout{
ProcessBasename: true,
IncludeIntegrity: true,
IncludeLocale: true,
}
basenameWidth := beaconCandidateWidth(t, beacons, basenameLayout, true)
withoutLocale := basenameLayout
withoutLocale.IncludeIntegrity = false
withoutLocale.IncludeLocale = false
withoutLocaleWidth := beaconCandidateWidth(t, beacons, withoutLocale, true)
t.Run("full process path survives basename projection", func(t *testing.T) {
rendered := renderBeaconTableAtWidth(t, beacons, `AppData\Local\Temp`, nil, basenameWidth)
if !strings.Contains(rendered, beacon.Name) {
t.Fatalf("filter matching canonical process path removed beacon:\n%s", rendered)
}
if strings.Contains(rendered, `AppData\Local\Temp`) {
t.Fatalf("basename layout unexpectedly displayed filtered directory:\n%s", rendered)
}
})
t.Run("hidden integrity survives column projection", func(t *testing.T) {
rendered := renderBeaconTableAtWidth(t, beacons, "", regexp.MustCompile(`^High$`), withoutLocaleWidth)
if !strings.Contains(rendered, beacon.Name) {
t.Fatalf("filter matching hidden integrity removed beacon:\n%s", rendered)
}
assertHeaderPresence(t, rendered, "Integrity", false)
})
}
func TestRenderBeaconsDoesNotMutateEmptyIntegrity(t *testing.T) {
beacon := testWindowsBeacon()
beacon.Integrity = ""
layout := targettable.Layout{IncludeIntegrity: true, IncludeLocale: true}
width := beaconCandidateWidth(t, []*clientpb.Beacon{beacon}, layout, true)
_ = renderBeaconTableAtWidth(t, []*clientpb.Beacon{beacon}, "", nil, width)
if beacon.Integrity != "" {
t.Fatalf("beacon integrity mutated to %q", beacon.Integrity)
}
}
func TestRenderBeaconsCompactFallbackPreservesLegacyColumnsAndTimes(t *testing.T) {
beacon := testWindowsBeacon()
beacons := []*clientpb.Beacon{beacon}
compactWidth := beaconCandidateWidth(t, beacons, targettable.Layout{Compact: true}, true)
withoutOptionalWidth := beaconCandidateWidth(t, beacons, targettable.Layout{ProcessBasename: true}, true)
if compactWidth >= withoutOptionalWidth {
t.Fatalf("test fixture does not require compact fallback: compact=%d detailed=%d", compactWidth, withoutOptionalWidth)
}
rendered := renderBeaconTableAtWidth(t, beacons, "", nil, compactWidth)
for _, omitted := range []string{"Tasks", "Remote Address", "Process (PID)", "Integrity", "Locale"} {
assertHeaderPresence(t, rendered, omitted, false)
}
if !strings.Contains(rendered, "36s") || !strings.Contains(rendered, "24s") {
t.Fatalf("compact relative check-in times missing:\n%s", rendered)
}
if strings.Contains(rendered, "36s ago") || strings.Contains(rendered, "in 24s") {
t.Fatalf("compact fallback did not preserve legacy timestamp format:\n%s", rendered)
}
assertBeaconTableFits(t, rendered, compactWidth)
}
func testWindowsBeacon() *clientpb.Beacon {
return &clientpb.Beacon{
ID: "0c9f6b27-1111-2222-3333-444444444444",
Name: "winterfell-session",
TasksCount: 3,
TasksCountCompleted: 2,
Transport: "mtls",
RemoteAddress: "10.13.37.11:49760",
Hostname: "winterfell",
Username: `winterfell\vagrant`,
Filename: `C:\Users\vagrant\AppData\Local\Temp\sliver-range\winterfell.exe`,
PID: 2020,
Integrity: "High",
OS: "windows",
Arch: "amd64",
Locale: "en-US",
LastCheckin: beaconTableTestNow.Add(-36 * time.Second).Unix(),
NextCheckin: beaconTableTestNow.Add(24 * time.Second).Unix(),
}
}
func testLinuxBeacon() *clientpb.Beacon {
beacon := testWindowsBeacon()
beacon.OS = "linux"
beacon.Hostname = "winterfell-linux"
beacon.Username = "vagrant"
beacon.Filename = "/opt/sliver/temporary/build/output/winterfell-agent"
beacon.Integrity = ""
return beacon
}
func testBeaconConsole() *console.SliverClient {
return &console.SliverClient{
Settings: &assets.ClientSettings{TableStyle: "SliverDefault"},
}
}
func beaconCandidateWidth(t *testing.T, beacons []*clientpb.Beacon, layout targettable.Layout, hasIntegrity bool) int {
t.Helper()
tw := buildBeaconTable(beacons, "", nil, testBeaconConsole(), layout, hasIntegrity, beaconTableTestNow)
return text.LongestLineLen(tw.Render())
}
func renderBeaconTableAtWidth(t *testing.T, beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, width int) string {
t.Helper()
tw := renderBeaconsAtWidth(beacons, filter, filterRegex, testBeaconConsole(), width, beaconTableTestNow)
return text.StripEscape(tw.Render())
}
func assertBeaconTableFits(t *testing.T, rendered string, width int) {
t.Helper()
if got := text.LongestLineLen(rendered); got > width {
t.Fatalf("rendered table width %d exceeds terminal width %d:\n%s", got, width, rendered)
}
}
func assertHeaderPresence(t *testing.T, rendered string, header string, want bool) {
t.Helper()
headerLine := strings.SplitN(rendered, "\n", 2)[0]
if got := strings.Contains(strings.ToLower(headerLine), strings.ToLower(header)); got != want {
t.Fatalf("header %q presence = %v, want %v; header line: %s", header, got, want, headerLine)
}
}
+2 -2
View File
@@ -145,8 +145,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
+1 -1
View File
@@ -28,11 +28,11 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"golang.org/x/exp/slices"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/settings"
+2 -2
View File
@@ -84,8 +84,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
+6 -1
View File
@@ -2,11 +2,16 @@
## Overview
Implements the 'crack' command group for the Sliver client console. Handlers map Cobra invocations to crack workflows such as crack files.
Implements the 'crack' command group for the Sliver client console. Handlers map Cobra invocations to crack workflows such as cached benchmark inspection, durable jobs, real-time monitoring, and crack files.
## Go Files
- `commands.go` – Defines the crack command hierarchy, attaching operations for stations and file management.
- `crack-benchmarks.go` – Displays bounded cached crackstation benchmark summaries, freshness metadata, and representative or complete hash-mode rates.
- `crack-files.go` – Manages cracking asset uploads, listings, and removals, handling streaming transfers and chunking.
- `crack-jobs.go` – Lists durable cracking jobs, renders detailed task progress, manages pause/resume/cancel lifecycle actions, and removes terminal jobs after confirmation.
- `top.go` – Preserves the crack command's compatibility entry point and delegates to the dedicated monitor package.
- `top/` – Owns the full-screen Bubble Tea monitor, its lean real-time snapshot loader, live telemetry, history, charts, actions, and tests.
- `internal/hashcatdisplay/` – Shares Hashcat mode names and rate formatting between the command handlers and monitor without coupling the packages.
- `crack.go` – Implements station status reporting, health checks, and list rendering for distributed cracking nodes.
- `helpers.go` – Supplies completion helpers and byte formatting utilities for crack subcommands.
+115
View File
@@ -1,6 +1,8 @@
package crack
import (
"time"
"github.com/bishopfox/sliver/client/command/completers"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
@@ -25,8 +27,15 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
flags.Bind("", true, crackCmd, func(f *pflag.FlagSet) {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("", false, crackCmd, func(f *pflag.FlagSet) {
bindCrackFlags(f)
})
flags.BindFlagCompletions(crackCmd, func(comp *carapace.ActionMap) {
(*comp)["input"] = CrackWordlistCompleter(con)
(*comp)["rules-file"] = CrackRulesCompleter(con)
(*comp)["markov-hcstat2"] = CrackHcstat2Completer(con)
})
crackStationsCmd := &cobra.Command{
Use: consts.StationsStr,
@@ -41,6 +50,112 @@ func Commands(con *console.SliverClient) []*cobra.Command {
})
crackCmd.AddCommand(crackStationsCmd)
crackBenchmarksCmd := &cobra.Command{
Use: consts.BenchmarksStr,
Short: "Show cached crackstation benchmark results",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
BenchmarksCmd(cmd, con, args)
},
}
flags.Bind("", false, crackBenchmarksCmd, func(f *pflag.FlagSet) {
f.BoolP("all", "a", false, "show every cached hash mode")
})
crackCmd.AddCommand(crackBenchmarksCmd)
crackJobsCmd := &cobra.Command{
Use: "jobs",
Short: "List hash cracking jobs",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
CrackJobsCmd(cmd, con, args)
},
}
flags.Bind("", true, crackJobsCmd, func(f *pflag.FlagSet) {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
crackCmd.AddCommand(crackJobsCmd)
crackTopCmd := &cobra.Command{
Use: "top",
Short: "Monitor crack jobs and crackstations in real time",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
CrackTopCmd(cmd, con, args)
},
}
flags.Bind("", false, crackTopCmd, func(f *pflag.FlagSet) {
f.Duration("poll-interval", time.Second, "crack job and crackstation refresh interval")
})
crackCmd.AddCommand(crackTopCmd)
crackJobCmd := &cobra.Command{
Use: "job [id]",
Short: "Show a hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobCmd(cmd, con, args)
},
}
flags.Bind("", true, crackJobCmd, func(f *pflag.FlagSet) {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("", false, crackJobCmd, func(f *pflag.FlagSet) {
f.BoolP("watch", "w", false, "watch until the job reaches a terminal state")
f.Duration("poll-interval", time.Second, "job status polling interval")
})
carapace.Gen(crackJobCmd).PositionalCompletion(CrackJobIDCompleter(con).Usage("crack job ID (leave empty to select)"))
registerCrackJobIDCompletion(crackJobCmd, con)
crackJobCancelCmd := &cobra.Command{
Use: consts.CancelStr + " [id]",
Short: "Cancel an active or paused hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobCancelCmd(cmd, con, args)
},
}
carapace.Gen(crackJobCancelCmd).PositionalCompletion(crackJobIDCompleter(con, crackJobCancellable).Usage("active or paused crack job ID (leave empty to select)"))
registerCrackJobIDCompletionFiltered(crackJobCancelCmd, con, crackJobCancellable)
crackJobCmd.AddCommand(crackJobCancelCmd)
crackJobPauseCmd := &cobra.Command{
Use: "pause [id]",
Short: "Pause an active hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobPauseCmd(cmd, con, args)
},
}
carapace.Gen(crackJobPauseCmd).PositionalCompletion(crackJobIDCompleter(con, crackJobPausable).Usage("active crack job ID (leave empty to select)"))
registerCrackJobIDCompletionFiltered(crackJobPauseCmd, con, crackJobPausable)
crackJobCmd.AddCommand(crackJobPauseCmd)
crackJobResumeCmd := &cobra.Command{
Use: "resume [id]",
Short: "Resume a paused hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobResumeCmd(cmd, con, args)
},
}
carapace.Gen(crackJobResumeCmd).PositionalCompletion(crackJobIDCompleter(con, crackJobResumable).Usage("paused crack job ID (leave empty to select)"))
registerCrackJobIDCompletionFiltered(crackJobResumeCmd, con, crackJobResumable)
crackJobCmd.AddCommand(crackJobResumeCmd)
crackJobRmCmd := &cobra.Command{
Use: consts.RmStr + " [id]",
Short: "Remove a completed, failed, or cancelled hash cracking job",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CrackJobRmCmd(cmd, con, args)
},
}
carapace.Gen(crackJobRmCmd).PositionalCompletion(crackTerminalJobIDCompleter(con).Usage("terminal crack job ID (leave empty to select)"))
registerCrackTerminalJobIDCompletion(crackJobRmCmd, con)
crackJobCmd.AddCommand(crackJobRmCmd)
crackCmd.AddCommand(crackJobCmd)
wordlistsCmd := &cobra.Command{
Use: consts.WordlistsStr,
Short: "Manage wordlists",
+216
View File
@@ -0,0 +1,216 @@
package crack
import (
"fmt"
"strconv"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/jedib0t/go-pretty/v6/table"
)
type backendDeviceDetails struct {
deviceType string
vendor string
vendorID int32
version string
runtimeVersion string
deviceID uint32
deviceAlias *uint32
preferredThreadSize *uint32
memoryUnified *bool
localMemory string
cacheSize string
memoryAllocPerBlock string
pciAddress string
}
func hasReportedBackendDevice(cracker *clientpb.Crackstation) bool {
for _, device := range cracker.CUDA {
if device != nil {
return true
}
}
for _, device := range cracker.HIP {
if device != nil {
return true
}
}
for _, device := range cracker.Metal {
if device != nil {
return true
}
}
for _, device := range cracker.OpenCL {
if device != nil {
return true
}
}
return false
}
func appendCrackstationDetailRows(tw table.Writer, cracker *clientpb.Crackstation) {
if cracker.HostUUID != "" {
tw.AppendRow(table.Row{console.StyleBold.Render("Host UUID"), safeCrackCell(cracker.HostUUID)})
}
if cracker.Version != "" {
tw.AppendRow(table.Row{console.StyleBold.Render("Crackstation Version"), safeCrackCell(cracker.Version)})
}
}
func appendCUDABackendRows(tw table.Writer, device *clientpb.CUDABackendInfo, level uint32) {
if device == nil {
return
}
appendBackendSummaryRows(tw, "CUDA Device", formatCUDADevice(device), device.MemoryFree, device.MemoryTotal, device.Clock, device.Processors)
if level < 2 {
return
}
appendBackendDeviceDetailRows(tw, backendDeviceDetails{
deviceType: device.Type,
vendor: device.Vendor,
vendorID: device.VendorID,
version: device.Version,
runtimeVersion: device.CUDAVersion,
deviceID: device.BackendDeviceID,
deviceAlias: device.BackendDeviceAlias,
preferredThreadSize: device.PreferredThreadSize,
memoryUnified: device.MemoryUnified,
localMemory: device.LocalMemory,
cacheSize: device.CacheSize,
pciAddress: device.PCIAddress,
})
}
func appendHIPBackendRows(tw table.Writer, device *clientpb.HIPBackendInfo, level uint32) {
if device == nil {
return
}
appendBackendSummaryRows(tw, "HIP Device", formatHIPDevice(device), device.MemoryFree, device.MemoryTotal, device.Clock, device.Processors)
if level < 2 {
return
}
appendBackendDeviceDetailRows(tw, backendDeviceDetails{
deviceType: device.Type,
vendor: device.Vendor,
vendorID: device.VendorID,
version: device.Version,
runtimeVersion: device.HIPVersion,
deviceID: device.BackendDeviceID,
deviceAlias: device.BackendDeviceAlias,
preferredThreadSize: device.PreferredThreadSize,
memoryUnified: device.MemoryUnified,
localMemory: device.LocalMemory,
cacheSize: device.CacheSize,
pciAddress: device.PCIAddress,
})
}
func appendMetalBackendRows(tw table.Writer, device *clientpb.MetalBackendInfo, level uint32) {
if device == nil {
return
}
appendBackendSummaryRows(tw, "Metal Device", formatMetalDevice(device), device.MemoryFree, device.MemoryTotal, device.Clock, device.Processors)
if level < 2 {
return
}
appendBackendDeviceDetailRows(tw, backendDeviceDetails{
deviceType: device.Type,
vendor: device.Vendor,
vendorID: device.VendorID,
version: device.Version,
runtimeVersion: device.MetalVersion,
deviceID: device.BackendDeviceID,
deviceAlias: device.BackendDeviceAlias,
preferredThreadSize: device.PreferredThreadSize,
memoryUnified: device.MemoryUnified,
localMemory: device.LocalMemory,
cacheSize: device.CacheSize,
memoryAllocPerBlock: device.MemoryAllocPerBlock,
})
appendStringBackendDetailRow(tw, "Physical Location", device.PhysicalLocation)
appendUint32BackendDetailRow(tw, "Registry ID", device.RegistryID)
appendStringBackendDetailRow(tw, "Max TX Rate", device.MaxTXRate)
appendBoolBackendDetailRow(tw, "Headless", device.GPUHeadless)
appendBoolBackendDetailRow(tw, "Low Power", device.GPULowPower)
appendBoolBackendDetailRow(tw, "Removable", device.GPURemovable)
}
func appendOpenCLBackendRows(tw table.Writer, device *clientpb.OpenCLBackendInfo, level uint32) {
if device == nil {
return
}
appendBackendSummaryRows(tw, "OpenCL Device", formatOpenCLDevice(device), device.MemoryFree, device.MemoryTotal, device.Clock, device.Processors)
if level < 2 {
return
}
appendBackendDeviceDetailRows(tw, backendDeviceDetails{
deviceType: device.Type,
vendor: device.Vendor,
vendorID: device.VendorID,
version: device.Version,
runtimeVersion: device.OpenCLVersion,
deviceID: device.BackendDeviceID,
deviceAlias: device.BackendDeviceAlias,
preferredThreadSize: device.PreferredThreadSize,
memoryUnified: device.MemoryUnified,
localMemory: device.LocalMemory,
memoryAllocPerBlock: device.MemoryAllocPerBlock,
pciAddress: device.PCIAddress,
})
appendStringBackendDetailRow(tw, "OpenCL Driver", device.OpenCLDriverVersion)
if device.PlatformID != 0 || device.PlatformVendor != "" || device.PlatformName != "" || device.PlatformVersion != "" {
tw.AppendRow(table.Row{console.StyleBold.Render("OpenCL Platform ID"), strconv.FormatUint(uint64(device.PlatformID), 10)})
}
appendStringBackendDetailRow(tw, "OpenCL Platform Vendor", device.PlatformVendor)
appendStringBackendDetailRow(tw, "OpenCL Platform", device.PlatformName)
appendStringBackendDetailRow(tw, "OpenCL Platform Version", device.PlatformVersion)
}
func appendBackendSummaryRows(tw table.Writer, label, name, memoryFree, memoryTotal string, clock, processors int32) {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render(label), console.StyleBoldGreen.Render(safeCrackCell(name))})
tw.AppendRow(table.Row{console.StyleBold.Render("Memory"), fmt.Sprintf("%s free of %s", valueOrDash(memoryFree), valueOrDash(memoryTotal))})
tw.AppendRow(table.Row{console.StyleBold.Render("Clock"), strconv.FormatInt(int64(clock), 10)})
tw.AppendRow(table.Row{console.StyleBold.Render("Processors"), strconv.FormatInt(int64(processors), 10)})
}
func appendBackendDeviceDetailRows(tw table.Writer, details backendDeviceDetails) {
appendStringBackendDetailRow(tw, "Device Type", details.deviceType)
appendStringBackendDetailRow(tw, "Vendor", details.vendor)
if details.vendorID != 0 {
tw.AppendRow(table.Row{console.StyleBold.Render("Vendor ID"), strconv.FormatInt(int64(details.vendorID), 10)})
}
if details.version != "" && details.version != details.runtimeVersion {
appendStringBackendDetailRow(tw, "Device Version", details.version)
}
tw.AppendRow(table.Row{console.StyleBold.Render("Backend Device ID"), strconv.FormatUint(uint64(details.deviceID), 10)})
appendUint32BackendDetailRow(tw, "Backend Device Alias", details.deviceAlias)
appendUint32BackendDetailRow(tw, "Preferred Thread Size", details.preferredThreadSize)
appendBoolBackendDetailRow(tw, "Unified Memory", details.memoryUnified)
appendStringBackendDetailRow(tw, "Local Memory", details.localMemory)
appendStringBackendDetailRow(tw, "Cache Size", details.cacheSize)
appendStringBackendDetailRow(tw, "Memory Allocation per Block", details.memoryAllocPerBlock)
appendStringBackendDetailRow(tw, "PCI Address", details.pciAddress)
}
func appendStringBackendDetailRow(tw table.Writer, label, value string) {
if value == "" {
return
}
tw.AppendRow(table.Row{console.StyleBold.Render(label), safeCrackCell(value)})
}
func appendUint32BackendDetailRow(tw table.Writer, label string, value *uint32) {
if value == nil {
return
}
tw.AppendRow(table.Row{console.StyleBold.Render(label), strconv.FormatUint(uint64(*value), 10)})
}
func appendBoolBackendDetailRow(tw table.Writer, label string, value *bool) {
if value == nil {
return
}
tw.AppendRow(table.Row{console.StyleBold.Render(label), strconv.FormatBool(*value)})
}
+210
View File
@@ -0,0 +1,210 @@
package crack
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
)
var representativeCrackBenchmarkModes = map[int32]struct{}{
0: {}, // MD5
100: {}, // SHA1
1000: {}, // NTLM
1400: {}, // SHA2-256
3200: {}, // bcrypt
22000: {}, // WPA-PBKDF2
}
const (
crackBenchmarkOutputWidth = 80
crackBenchmarkHashTypeWidth = 48
crackBenchmarkMetadataValueWidth = 48
)
// BenchmarksCmd displays benchmark results persisted by the server.
func BenchmarksCmd(cmd *cobra.Command, con *console.SliverClient, _ []string) {
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
defer cancel()
response, err := con.Rpc.CrackstationBenchmarks(ctx, &commonpb.Empty{})
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
showAll, _ := cmd.Flags().GetBool("all")
benchmarks := response.GetSnapshots()
rendered := renderCrackBenchmarks(benchmarks, settings.GetTableStyle(con), showAll)
if rendered == "" {
con.PrintInfof("No cached crackstation benchmarks\n")
return
}
con.Printf("%s\n", rendered)
}
func renderCrackBenchmarks(benchmarks []*clientpb.CrackBenchmarkSnapshot, style table.Style, showAll bool) string {
ordered := make([]*clientpb.CrackBenchmarkSnapshot, 0, len(benchmarks))
for _, benchmark := range benchmarks {
if benchmark != nil {
ordered = append(ordered, benchmark)
}
}
if len(ordered) == 0 {
return ""
}
sort.SliceStable(ordered, func(i, j int) bool {
leftName := safeCrackCell(ordered[i].GetName())
rightName := safeCrackCell(ordered[j].GetName())
if leftName == rightName {
return safeCrackCell(ordered[i].GetHostUUID()) < safeCrackCell(ordered[j].GetHostUUID())
}
return leftName < rightName
})
sections := make([]string, 0, len(ordered)*2+1)
for index, benchmark := range ordered {
identity := cachedBenchmarkIdentity(benchmark)
title := renderCachedBenchmarkTitle(index, identity)
metadata := table.NewWriter()
metadata.SetStyle(boundedCrackBenchmarkStyle(style))
metadata.SetColumnConfigs([]table.ColumnConfig{
{Number: 2, WidthMax: crackBenchmarkMetadataValueWidth},
})
metadata.AppendRow(table.Row{console.StyleBold.Render("Name"), valueOrDash(benchmark.GetName())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Host UUID"), console.StyleBoldPrimary.Render(valueOrDash(benchmark.GetHostUUID()))})
metadata.AppendRow(table.Row{console.StyleBold.Render("Operator"), valueOrDash(benchmark.GetOperatorName())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Connection"), renderCachedBenchmarkConnection(benchmark.GetOnline())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Cache"), renderCachedBenchmarkFreshness(benchmark.GetFresh())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Current Hashcat Version"), valueOrDash(benchmark.GetCurrentHashcatVersion())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Benchmark Hashcat Version"), valueOrDash(benchmark.GetBenchmarkHashcatVersion())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Benchmark Schema"), strconv.FormatUint(uint64(benchmark.GetBenchmarkSchemaVersion()), 10)})
metadata.AppendRow(table.Row{console.StyleBold.Render("Benchmarked At"), formatCachedBenchmarkTime(benchmark.GetBenchmarkedAt())})
metadata.AppendRow(table.Row{console.StyleBold.Render("Cached Modes"), strconv.Itoa(len(benchmark.GetBenchmarks()))})
sections = append(sections, title+"\n"+metadata.Render())
rates := table.NewWriter()
rates.SetStyle(boundedCrackBenchmarkStyle(style))
rates.SetColumnConfigs([]table.ColumnConfig{
{Name: "Hash Type", WidthMax: crackBenchmarkHashTypeWidth},
})
if showAll {
rates.SetTitle(console.StyleBold.Render("All Cached Rates"))
} else {
rates.SetTitle(console.StyleBold.Render("Representative Rates"))
}
rates.AppendHeader(table.Row{"Mode", "Hash Type", "Rate"})
modes := cachedBenchmarkModes(benchmark.GetBenchmarks(), showAll)
if len(modes) == 0 {
rates.AppendRow(table.Row{"-", "No benchmark rates cached", "-"})
} else {
for _, mode := range modes {
rates.AppendRow(table.Row{
strconv.FormatInt(int64(mode), 10),
crackBenchmarkModeName(mode),
humanizeHashRate(benchmark.GetBenchmarks()[mode]),
})
}
}
sections = append(sections, rates.Render())
}
if !showAll {
sections = append(sections, console.StyleGray.Render("Showing representative rates; use --all for every cached mode."))
}
return strings.Join(sections, "\n\n")
}
func boundedCrackBenchmarkStyle(style table.Style) table.Style {
style.Size.WidthMax = crackBenchmarkOutputWidth
return style
}
func renderCachedBenchmarkTitle(index int, identity string) string {
prefix := fmt.Sprintf(">>> Cached Crackstation %02d - ", index+1)
maximumIdentityWidth := crackBenchmarkOutputWidth - ansi.StringWidth(prefix)
if maximumIdentityWidth < 1 {
return console.StyleBoldPrimary.Render(ansi.Truncate(prefix, crackBenchmarkOutputWidth, "…"))
}
identity = ansi.Truncate(identity, maximumIdentityWidth, "…")
return console.StyleBoldPrimary.Render(prefix + identity)
}
func cachedBenchmarkIdentity(benchmark *clientpb.CrackBenchmarkSnapshot) string {
if benchmark == nil {
return "Unknown crackstation"
}
if name := safeCrackCell(benchmark.GetName()); name != "" {
return name
}
if hostUUID := safeCrackCell(benchmark.GetHostUUID()); hostUUID != "" {
return hostUUID
}
return "Unknown crackstation"
}
func renderCachedBenchmarkConnection(online bool) string {
if online {
return console.StyleBoldSuccess.Render("Online")
}
return console.StyleBoldGray.Render("Offline")
}
func renderCachedBenchmarkFreshness(fresh bool) string {
if fresh {
return console.StyleBoldSuccess.Render("Fresh")
}
return console.StyleBoldWarning.Render("Stale")
}
func formatCachedBenchmarkTime(unix int64) string {
if unix == 0 {
return "-"
}
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
}
func cachedBenchmarkModes(benchmarks map[int32]uint64, showAll bool) []int32 {
modes := make([]int32, 0, len(benchmarks))
for mode := range benchmarks {
if showAll {
modes = append(modes, mode)
continue
}
if _, representative := representativeCrackBenchmarkModes[mode]; representative {
modes = append(modes, mode)
}
}
sort.Slice(modes, func(i, j int) bool { return modes[i] < modes[j] })
if showAll || len(modes) != 0 {
return modes
}
for mode := range benchmarks {
modes = append(modes, mode)
}
sort.Slice(modes, func(i, j int) bool { return modes[i] < modes[j] })
if len(modes) > 6 {
modes = modes[:6]
}
return modes
}
func crackBenchmarkModeName(mode int32) string {
if name, ok := hashcatHashTypeName(mode); ok {
return name
}
if enumName := clientpb.HashType(mode).String(); enumName != strconv.FormatInt(int64(mode), 10) {
return enumName
}
return "Unknown hash mode"
}
@@ -0,0 +1,376 @@
package crack
import (
"context"
"regexp"
"strconv"
"strings"
"testing"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/charmbracelet/x/ansi"
"github.com/spf13/cobra"
"google.golang.org/grpc"
)
func TestCrackBenchmarksCommandIsRegistered(t *testing.T) {
root := Commands(nil)[0]
benchmarks, _, err := root.Find([]string{"benchmarks"})
if err != nil || benchmarks == root {
t.Fatalf("find crack benchmarks: command=%v error=%v", benchmarks, err)
}
if benchmarks.Use != "benchmarks" {
t.Fatalf("crack benchmarks use = %q, want benchmarks", benchmarks.Use)
}
if err := benchmarks.Args(benchmarks, nil); err != nil {
t.Fatalf("crack benchmarks rejected no arguments: %v", err)
}
if err := benchmarks.Args(benchmarks, []string{"unexpected"}); err == nil {
t.Fatal("crack benchmarks accepted a positional argument")
}
allFlag := benchmarks.Flags().Lookup("all")
if allFlag == nil {
t.Fatal("crack benchmarks is missing --all")
}
if allFlag.Shorthand != "a" {
t.Fatalf("--all shorthand = %q, want %q", allFlag.Shorthand, "a")
}
if benchmarks.PersistentFlags().Lookup("all") != nil || root.PersistentFlags().Lookup("all") != nil {
t.Fatal("--all must remain local to crack benchmarks")
}
if benchmarks.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("crack benchmarks must inherit --timeout")
}
}
func TestRenderCrackBenchmarksSortsStationsAndRepresentativeModes(t *testing.T) {
benchmarks := []*clientpb.CrackBenchmarkSnapshot{
nil,
{
Name: "zeta",
HostUUID: "host-z",
OperatorName: "operator-z",
CurrentHashcatVersion: "v7.1.3",
BenchmarkHashcatVersion: "v7.1.2",
BenchmarkSchemaVersion: 1,
BenchmarkedAt: 1_700_000_000,
Online: true,
Fresh: true,
Benchmarks: map[int32]uint64{
22000: 6_000_000_000_000,
0: 999,
3200: 5_000_000_000,
100: 1_000,
1400: 3_000_000,
1000: 2_000_000,
99999: 7_000_000,
},
},
{Name: "alpha", HostUUID: "host-b", Benchmarks: map[int32]uint64{0: 1}},
{Name: "alpha", HostUUID: "host-a", Benchmarks: map[int32]uint64{0: 2}},
}
raw := renderCrackBenchmarks(benchmarks, settings.SliverDefault, false)
output := ansi.Strip(raw)
requireOrderedCrackBenchmarkText(t, output, "host-a", "host-b", "host-z")
zetaStart := strings.Index(output, ">>> Cached Crackstation 03 - zeta")
if zetaStart < 0 {
t.Fatalf("rendered benchmarks are missing sorted zeta section:\n%s", output)
}
requireOrderedCrackBenchmarkText(t, output[zetaStart:],
"MD5", "SHA1", "NTLM", "SHA2-256", "bcrypt $2\\*$, Blowfish (Unix)", "WPA-PBKDF2-PMKID+EAPOL",
)
for _, expected := range []string{
"Name", "Host UUID", "Operator", "Connection", "Online", "Cache", "Fresh",
"Current Hashcat Version", "v7.1.3", "Benchmark Hashcat Version", "v7.1.2",
"Benchmark Schema", "Benchmarked At", "2023-11-14T22:13:20Z", "Cached Modes",
"operator-z", "Representative Rates", "Showing representative rates; use --all for every cached mode.",
} {
if !strings.Contains(output, expected) {
t.Errorf("rendered benchmarks do not contain %q:\n%s", expected, output)
}
}
requireCrackBenchmarkRow(t, output, 0, "MD5", "999 H/s")
requireCrackBenchmarkRow(t, output, 100, "SHA1", "1.00 kH/s")
requireCrackBenchmarkRow(t, output, 1000, "NTLM", "2.00 MH/s")
requireCrackBenchmarkRow(t, output, 1400, "SHA2-256", "3.00 MH/s")
requireCrackBenchmarkRow(t, output, 3200, "bcrypt $2\\*$, Blowfish (Unix)", "5.00 GH/s")
requireCrackBenchmarkRow(t, output, 22000, "WPA-PBKDF2-PMKID+EAPOL", "6.00 TH/s")
if hasCrackBenchmarkRow(output, 99999, "Plaintext", "7.00 MH/s") {
t.Fatalf("default benchmark view unexpectedly contains non-representative mode 99999:\n%s", output)
}
for _, styled := range []string{
console.StyleBoldPrimary.Render(">>> Cached Crackstation 01 - alpha"),
console.StyleBoldPrimary.Render("host-a"),
console.StyleBoldSuccess.Render("Online"),
console.StyleBoldSuccess.Render("Fresh"),
console.StyleBold.Render("Representative Rates"),
console.StyleGray.Render("Showing representative rates; use --all for every cached mode."),
} {
if !strings.Contains(raw, styled) {
t.Errorf("rendered benchmarks are missing shared Lip Gloss styling %q:\n%s", styled, raw)
}
}
}
func TestRenderCrackBenchmarksFallsBackToFirstSixModes(t *testing.T) {
benchmarks := []*clientpb.CrackBenchmarkSnapshot{{
Name: "fallback",
Benchmarks: map[int32]uint64{
23: 23,
10: 10,
22: 22,
12: 12,
20: 20,
11: 11,
21: 21,
},
}}
output := ansi.Strip(renderCrackBenchmarks(benchmarks, settings.SliverDefault, false))
requireOrderedCrackBenchmarkText(t, output,
"md5($pass.$salt)",
"Joomla < 2.5.18",
"PostgreSQL",
"md5($salt.$pass)",
"osCommerce, xt:Commerce",
"Juniper NetScreen/SSG (ScreenOS)",
)
if hasCrackBenchmarkRow(output, 23, "Skype", "23 H/s") {
t.Fatalf("fallback benchmark view contains more than six modes:\n%s", output)
}
}
func TestRenderCrackBenchmarksAllShowsEveryModeAndUnknownFallback(t *testing.T) {
benchmarks := []*clientpb.CrackBenchmarkSnapshot{{
Name: "all-modes",
Benchmarks: map[int32]uint64{
424242: 1_234,
36100: 3_600,
99999: 7_000_000,
0: 500,
},
}}
raw := renderCrackBenchmarks(benchmarks, settings.SliverDefault, true)
output := ansi.Strip(raw)
for _, row := range []struct {
mode int32
hashType string
rate string
}{
{mode: 0, hashType: "MD5", rate: "500 H/s"},
{mode: 36100, hashType: "YESCRYPT", rate: "3.60 kH/s"},
{mode: 99999, hashType: "Plaintext", rate: "7.00 MH/s"},
{mode: 424242, hashType: "Unknown hash mode", rate: "1.23 kH/s"},
} {
requireCrackBenchmarkRow(t, output, row.mode, row.hashType, row.rate)
}
if !strings.Contains(raw, console.StyleBold.Render("All Cached Rates")) {
t.Fatalf("all-modes view is missing styled title:\n%s", raw)
}
if strings.Contains(output, "Showing representative rates") {
t.Fatalf("all-modes view unexpectedly contains representative-rate hint:\n%s", output)
}
}
func TestRenderCrackBenchmarksSanitizesMetadata(t *testing.T) {
benchmarks := []*clientpb.CrackBenchmarkSnapshot{{
Name: "alpha\nbeta\x1b]52;c;payload\a",
HostUUID: "host\r\nuuid",
OperatorName: "op\tname",
CurrentHashcatVersion: "current-v7\x00.2",
BenchmarkHashcatVersion: "benchmark-v7\x00.1",
Benchmarks: map[int32]uint64{0: 1},
}}
raw := renderCrackBenchmarks(benchmarks, settings.SliverDefault, false)
output := ansi.Strip(raw)
for _, expected := range []string{"alphabeta]52;c;payload", "hostuuid", "opname", "current-v7.2", "benchmark-v7.1"} {
if !strings.Contains(output, expected) {
t.Errorf("sanitized benchmark metadata does not contain %q:\n%s", expected, output)
}
}
for _, control := range []string{"\x1b]52;", "\a", "\r", "\x00", "\t"} {
if strings.Contains(raw, control) {
t.Errorf("rendered benchmark metadata retained control text %q:\n%q", control, raw)
}
}
}
func TestRenderCrackBenchmarksStylesConnectionAndFreshness(t *testing.T) {
raw := renderCrackBenchmarks([]*clientpb.CrackBenchmarkSnapshot{
{Name: "fresh", Online: true, Fresh: true, Benchmarks: map[int32]uint64{0: 1}},
{Name: "stale", Online: false, Fresh: false, Benchmarks: map[int32]uint64{0: 1}},
}, settings.SliverDefault, false)
for _, styled := range []string{
console.StyleBoldSuccess.Render("Online"),
console.StyleBoldSuccess.Render("Fresh"),
console.StyleBoldGray.Render("Offline"),
console.StyleBoldWarning.Render("Stale"),
} {
if !strings.Contains(raw, styled) {
t.Errorf("rendered benchmarks are missing status styling %q:\n%s", styled, raw)
}
}
}
func TestRenderCrackBenchmarksBoundsEveryLine(t *testing.T) {
raw := renderCrackBenchmarks([]*clientpb.CrackBenchmarkSnapshot{{
Name: strings.Repeat("very-long-crackstation-name-", 8),
HostUUID: "12345678-1234-1234-1234-123456789abc",
OperatorName: strings.Repeat("operator-", 16),
CurrentHashcatVersion: strings.Repeat("current-version-", 10),
BenchmarkHashcatVersion: strings.Repeat("benchmark-version-", 10),
BenchmarkedAt: 1_700_000_000,
Benchmarks: map[int32]uint64{6211: 1_000_000},
}}, settings.SliverDefault, true)
for lineNumber, line := range strings.Split(raw, "\n") {
if width := ansi.StringWidth(line); width > crackBenchmarkOutputWidth {
t.Errorf("rendered line %d width = %d, want <= %d:\n%s", lineNumber+1, width, crackBenchmarkOutputWidth, line)
}
}
if !strings.Contains(ansi.Strip(raw), "…") {
t.Fatalf("long benchmark heading was not truncated:\n%s", raw)
}
}
func TestFormatCachedBenchmarkTime(t *testing.T) {
if got := formatCachedBenchmarkTime(0); got != "-" {
t.Fatalf("zero benchmark time = %q, want -", got)
}
if got := formatCachedBenchmarkTime(1_700_000_000); got != "2023-11-14T22:13:20Z" {
t.Fatalf("benchmark time = %q, want UTC RFC3339", got)
}
}
func TestRenderCrackBenchmarksHandlesNilAndEmptyData(t *testing.T) {
for _, benchmarks := range [][]*clientpb.CrackBenchmarkSnapshot{nil, {nil, nil}} {
if got := renderCrackBenchmarks(benchmarks, settings.SliverDefault, false); got != "" {
t.Fatalf("renderCrackBenchmarks(%#v) = %q, want empty output", benchmarks, got)
}
}
raw := renderCrackBenchmarks([]*clientpb.CrackBenchmarkSnapshot{{
HostUUID: "host-only",
}}, settings.SliverDefault, false)
output := ansi.Strip(raw)
for _, expected := range []string{
">>> Cached Crackstation 01 - host-only",
"No benchmark rates cached",
"Representative Rates",
} {
if !strings.Contains(output, expected) {
t.Errorf("empty benchmark snapshot does not contain %q:\n%s", expected, output)
}
}
unknown := ansi.Strip(renderCrackBenchmarks([]*clientpb.CrackBenchmarkSnapshot{{}}, settings.SliverDefault, false))
if !strings.Contains(unknown, ">>> Cached Crackstation 01 - Unknown crackstation") {
t.Fatalf("missing unknown-station identity fallback:\n%s", unknown)
}
}
type crackBenchmarksRPCCapture struct {
rpcpb.SliverRPCClient
response *clientpb.CrackBenchmarkSnapshots
err error
benchmarkCalls int
crackstationsCalls int
hadDeadline bool
}
func (capture *crackBenchmarksRPCCapture) CrackstationBenchmarks(ctx context.Context, _ *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.CrackBenchmarkSnapshots, error) {
capture.benchmarkCalls++
_, capture.hadDeadline = ctx.Deadline()
return capture.response, capture.err
}
func (capture *crackBenchmarksRPCCapture) Crackstations(_ context.Context, _ *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.Crackstations, error) {
capture.crackstationsCalls++
return &clientpb.Crackstations{}, nil
}
func TestCrackBenchmarksCommandUsesDedicatedRPCWithDeadline(t *testing.T) {
capture := &crackBenchmarksRPCCapture{response: &clientpb.CrackBenchmarkSnapshots{Snapshots: []*clientpb.CrackBenchmarkSnapshot{{
Name: "station",
HostUUID: "host-id",
Benchmarks: map[int32]uint64{0: 1},
}}}}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
root := Commands(con)[0]
root.SetArgs([]string{"benchmarks", "--timeout=1", "-a"})
root.SilenceErrors = true
root.SilenceUsage = true
if err := root.Execute(); err != nil {
t.Fatalf("execute crack benchmarks: %v", err)
}
if capture.benchmarkCalls != 1 {
t.Fatalf("CrackstationBenchmarks calls = %d, want 1", capture.benchmarkCalls)
}
if capture.crackstationsCalls != 0 {
t.Fatalf("Crackstations calls = %d, want 0", capture.crackstationsCalls)
}
if !capture.hadDeadline {
t.Fatal("CrackstationBenchmarks context did not inherit --timeout")
}
}
func TestCrackBenchmarksCommandHandlesNilResponse(t *testing.T) {
capture := &crackBenchmarksRPCCapture{}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
root := Commands(con)[0]
root.SetArgs([]string{"benchmarks", "--timeout=1"})
root.SilenceErrors = true
root.SilenceUsage = true
if err := root.Execute(); err != nil {
t.Fatalf("execute crack benchmarks with nil response: %v", err)
}
if capture.benchmarkCalls != 1 {
t.Fatalf("CrackstationBenchmarks calls = %d, want 1", capture.benchmarkCalls)
}
}
func requireOrderedCrackBenchmarkText(t *testing.T, output string, values ...string) {
t.Helper()
remaining := output
for _, value := range values {
index := strings.Index(remaining, value)
if index < 0 {
t.Fatalf("rendered benchmarks do not contain %q in order:\n%s", value, output)
}
remaining = remaining[index+len(value):]
}
}
func requireCrackBenchmarkRow(t *testing.T, output string, mode int32, hashType string, rate string) {
t.Helper()
if !hasCrackBenchmarkRow(output, mode, hashType, rate) {
t.Fatalf("rendered benchmarks are missing mode %d, hash type %q, rate %q:\n%s", mode, hashType, rate, output)
}
}
func hasCrackBenchmarkRow(output string, mode int32, hashType string, rate string) bool {
pattern := `(?m)^[ \t]*` + regexp.QuoteMeta(strconv.FormatInt(int64(mode), 10)) + `[ \t]+` +
regexp.QuoteMeta(hashType) + `[ \t]+` + regexp.QuoteMeta(rate) + `[ \t]*$`
return regexp.MustCompile(pattern).MatchString(output)
}
+287 -20
View File
@@ -2,6 +2,8 @@ package crack
import (
"fmt"
"io"
"os"
"strconv"
"strings"
@@ -10,6 +12,38 @@ import (
"github.com/spf13/pflag"
)
const maxKeyboardLayoutMappingBytes int64 = 1 << 20
func readKeyboardLayoutMapping(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open keyboard layout mapping %q: %w", path, err)
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat keyboard layout mapping %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("keyboard layout mapping %q is not a regular file", path)
}
if info.Size() > maxKeyboardLayoutMappingBytes {
return nil, fmt.Errorf("keyboard layout mapping %q exceeds %d bytes", path, maxKeyboardLayoutMappingBytes)
}
data, err := io.ReadAll(io.LimitReader(file, maxKeyboardLayoutMappingBytes+1))
if err != nil {
return nil, fmt.Errorf("read keyboard layout mapping %q: %w", path, err)
}
if int64(len(data)) > maxKeyboardLayoutMappingBytes {
return nil, fmt.Errorf("keyboard layout mapping %q exceeds %d bytes", path, maxKeyboardLayoutMappingBytes)
}
if len(data) == 0 {
return nil, fmt.Errorf("keyboard layout mapping %q is empty", path)
}
return data, nil
}
func shouldRunCrack(cmd *cobra.Command, args []string) bool {
if len(args) > 0 {
return true
@@ -17,16 +51,25 @@ func shouldRunCrack(cmd *cobra.Command, args []string) bool {
hasFlag := false
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
if flag.Changed && flag.Name != "timeout" {
hasFlag = true
if !flag.Changed || flag.Name == "timeout" {
return
}
// An explicitly disabled standalone mode is a no-op, not a crack job.
switch flag.Name {
case "backend-info", "keyspace", "total-candidates", "identify-mode", "hash-info":
enabled, _ := cmd.Flags().GetBool(flag.Name)
if !enabled {
return
}
}
hasFlag = true
})
return hasFlag
}
func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackCommand, error) {
flags := cmd.Flags()
req := &clientpb.CrackCommand{}
req := &clientpb.CrackCommand{HashType: clientpb.HashType_INVALID}
if flags.Changed("attack-mode") {
raw, _ := flags.GetString("attack-mode")
@@ -45,12 +88,39 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
}
req.HashType = hashType
}
if flags.Changed("hash-mode") {
hashMode, _ := flags.GetUint32("hash-mode")
req.HashMode = &hashMode
}
hashes, _ := flags.GetStringSlice("hash")
hashes, _ := flags.GetStringArray("hash")
if len(args) > 0 {
hashes = append(hashes, args...)
}
req.Hashes = hashes
req.CredentialIDs, _ = flags.GetStringArray("credential")
for index, credentialID := range req.CredentialIDs {
req.CredentialIDs[index] = strings.TrimSpace(credentialID)
if req.CredentialIDs[index] == "" {
return nil, fmt.Errorf("--credential cannot be empty")
}
}
req.CredentialCollection, _ = flags.GetString("credential-collection")
req.CredentialCollection = strings.TrimSpace(req.CredentialCollection)
req.IncludeCrackedCredentials, _ = flags.GetBool("include-cracked-credentials")
if req.IncludeCrackedCredentials && len(req.CredentialIDs) == 0 && req.CredentialCollection == "" {
return nil, fmt.Errorf("--include-cracked-credentials requires --credential or --credential-collection")
}
req.PositionalArguments, _ = flags.GetStringArray("input")
req.Crackstation, _ = flags.GetString("crackstation")
req.Crackstation = strings.TrimSpace(req.Crackstation)
if stdin, _ := flags.GetString("stdin"); stdin != "" {
req.Stdin = []byte(stdin)
}
if flags.Changed("segment-size") {
return nil, fmt.Errorf("--segment-size was removed in Hashcat v7")
}
req.Quiet, _ = flags.GetBool("quiet")
req.HexCharset, _ = flags.GetBool("hex-charset")
@@ -58,10 +128,19 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
req.HexWordlist, _ = flags.GetBool("hex-wordlist")
req.Force, _ = flags.GetBool("force")
req.DeprecatedCheckDisable, _ = flags.GetBool("deprecated-check-disable")
req.AdviceDisable, _ = flags.GetBool("advice-disable")
req.Status, _ = flags.GetBool("status")
req.StatusJSON, _ = flags.GetBool("status-json")
req.StatusTimer, _ = flags.GetUint32("status-timer")
if flags.Changed("status-timer") {
value := req.StatusTimer
req.StatusTimerV7 = &value
}
req.StdinTimeoutAbort, _ = flags.GetUint32("stdin-timeout-abort")
if flags.Changed("stdin-timeout-abort") {
value := req.StdinTimeoutAbort
req.StdinTimeoutAbortV7 = &value
}
req.MachineReadable, _ = flags.GetBool("machine-readable")
req.KeepGuessing, _ = flags.GetBool("keep-guessing")
req.SelfTestDisable, _ = flags.GetBool("self-test-disable")
@@ -81,7 +160,13 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
if raw, _ := flags.GetString("restore-file"); raw != "" {
req.RestoreFile = []byte(raw)
}
req.RestorePosition, _ = flags.GetBool("restore-position")
req.RestoreShowCommand, _ = flags.GetBool("restore-show-command")
if req.RestoreShowCommand && (req.Restore || req.RestorePosition) { //nolint:staticcheck // Restore is required for legacy command compatibility.
return nil, fmt.Errorf("--restore-show-command and restore-position modes are mutually exclusive")
}
req.Outfile, _ = flags.GetString("outfile")
if formats, _ := flags.GetStringSlice("outfile-format"); len(formats) > 0 {
for _, raw := range formats {
format, err := parseCrackOutfileFormat(raw)
@@ -91,11 +176,15 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
req.OutfileFormat = append(req.OutfileFormat, format)
}
}
req.OutfileJSON, _ = flags.GetBool("outfile-json")
req.OutfileAutohexDisable, _ = flags.GetBool("outfile-autohex-disable")
req.OutfileCheckTimer, _ = flags.GetUint32("outfile-check-timer")
if flags.Changed("outfile-check-timer") {
value := req.OutfileCheckTimer
req.OutfileCheckTimerV7 = &value
}
req.WordlistAutohexDisable, _ = flags.GetBool("wordlist-autohex-disable")
req.Separator, _ = flags.GetString("separator")
req.Stdout, _ = flags.GetBool("stdout")
req.Show, _ = flags.GetBool("show")
req.Left, _ = flags.GetBool("left")
req.Username, _ = flags.GetBool("username")
@@ -108,48 +197,107 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
if flags.Changed("encoding-from") {
raw, _ := flags.GetString("encoding-from")
enc, err := parseCrackEncoding(raw)
if err != nil {
return nil, err
if enc, err := parseCrackEncoding(raw); err == nil {
req.EncodingFrom = enc
}
if _, err := strconv.ParseInt(raw, 10, 32); err != nil {
req.EncodingFromName = raw
}
req.EncodingFrom = enc
}
if flags.Changed("encoding-to") {
raw, _ := flags.GetString("encoding-to")
enc, err := parseCrackEncoding(raw)
if err != nil {
return nil, err
if enc, err := parseCrackEncoding(raw); err == nil {
req.EncodingTo = enc
}
if _, err := strconv.ParseInt(raw, 10, 32); err != nil {
req.EncodingToName = raw
}
req.EncodingTo = enc
}
req.DebugMode, _ = flags.GetUint32("debug-mode")
req.DebugFile, _ = flags.GetString("debug-file")
req.InductionDir, _ = flags.GetString("induction-dir")
req.OutfileCheckDir, _ = flags.GetString("outfile-check-dir")
req.LogfileDisable, _ = flags.GetBool("logfile-disable")
req.HccapxMessagePair, _ = flags.GetUint32("hccapx-message-pair")
if flags.Changed("hccapx-message-pair") {
value := req.HccapxMessagePair
req.HccapxMessagePairV7 = &value
}
req.NonceErrorCorrections, _ = flags.GetUint32("nonce-error-corrections")
if flags.Changed("nonce-error-corrections") {
value := req.NonceErrorCorrections
req.NonceErrorCorrectionsV7 = &value
}
if raw, _ := flags.GetString("keyboard-layout-mapping"); raw != "" {
req.KeyboardLayoutMapping = []byte(raw)
mapping, err := readKeyboardLayoutMapping(raw)
if err != nil {
return nil, err
}
req.KeyboardLayoutMapping = mapping
}
req.TruecryptKeyfiles, _ = flags.GetString("truecrypt-keyfiles")
req.VeracryptKeyfiles, _ = flags.GetString("veracrypt-keyfiles")
if flags.Changed("veracrypt-pim-start") {
value, _ := flags.GetUint32("veracrypt-pim-start")
req.VeracryptPimStart = &value
}
if flags.Changed("veracrypt-pim-stop") {
value, _ := flags.GetUint32("veracrypt-pim-stop")
req.VeracryptPimStop = &value
}
req.Benchmark, _ = flags.GetBool("benchmark")
req.BenchmarkAll, _ = flags.GetBool("benchmark-all")
req.BenchmarkMin, _ = flags.GetUint32("benchmark-min")
if flags.Changed("benchmark-max") {
value, _ := flags.GetUint32("benchmark-max")
req.BenchmarkMax = &value
}
req.SpeedOnly, _ = flags.GetBool("speed-only")
req.ProgressOnly, _ = flags.GetBool("progress-only")
req.SegmentSize, _ = flags.GetUint32("segment-size")
req.PipelineStats, _ = flags.GetBool("pipeline-stats")
req.TaskTimeBreakdown, _ = flags.GetBool("task-time-breakdown")
req.BitmapMin, _ = flags.GetUint32("bitmap-min")
if flags.Changed("bitmap-min") {
value := req.BitmapMin
req.BitmapMinV7 = &value
}
req.BitmapMax, _ = flags.GetUint32("bitmap-max")
if flags.Changed("bitmap-max") {
value := req.BitmapMax
req.BitmapMaxV7 = &value
}
req.CPUAffinity = uintSliceToUint32(flags, "cpu-affinity")
req.HookThreads, _ = flags.GetUint32("hook-threads")
req.HashInfo, _ = flags.GetBool("hash-info")
if req.HashInfo {
req.HashInfoLevel = 1
}
if flags.Changed("hash-info-level") {
req.HashInfoLevel, _ = flags.GetUint32("hash-info-level")
}
req.BackendIgnoreCUDA, _ = flags.GetBool("backend-ignore-cuda")
req.BackendIgnoreHip, _ = flags.GetBool("backend-ignore-hip")
req.BackendIgnoreMetal, _ = flags.GetBool("backend-ignore-metal")
req.BackendIgnoreOpenCL, _ = flags.GetBool("backend-ignore-opencl")
req.BackendInfo, _ = flags.GetBool("backend-info")
if req.BackendInfo {
req.BackendInfoLevel = 1
}
if flags.Changed("backend-info-level") {
req.BackendInfoLevel, _ = flags.GetUint32("backend-info-level")
}
req.BackendDevices = uintSliceToUint32(flags, "backend-devices")
req.BackendDevicesVirtMulti, _ = flags.GetUint32("backend-devices-virtmulti")
req.BackendDevicesVirtHost, _ = flags.GetUint32("backend-devices-virthost")
req.OpenCLDeviceTypes = uintSliceToUint32(flags, "opencl-device-types")
req.MetalCompilerRuntime, _ = flags.GetUint32("metal-compiler-runtime")
req.OptimizedKernelEnable, _ = flags.GetBool("optimized-kernel-enable")
req.MultiplyAccelDisabled, _ = flags.GetBool("multiply-accel-disabled")
req.MultiplyAccelDisabled, _ = flags.GetBool("multiply-accel-disable")
if !req.MultiplyAccelDisabled {
req.MultiplyAccelDisabled, _ = flags.GetBool("multiply-accel-disabled")
}
if flags.Changed("workload-profile") {
raw, _ := flags.GetString("workload-profile")
@@ -167,37 +315,114 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
req.SpinDamp, _ = flags.GetUint32("spin-damp")
req.HwmonDisable, _ = flags.GetBool("hwmon-disable")
req.HwmonTempAbort, _ = flags.GetUint32("hwmon-temp-abort")
if flags.Changed("hwmon-temp-abort") {
value := req.HwmonTempAbort
req.HwmonTempAbortV7 = &value
}
req.ScryptTMTO, _ = flags.GetUint32("scrypt-tmto")
if flags.Changed("scrypt-tmto") {
value := req.ScryptTMTO
req.ScryptTMTOV7 = &value
}
req.Skip, _ = flags.GetUint64("skip")
req.Limit, _ = flags.GetUint64("limit")
req.Keyspace, _ = flags.GetBool("keyspace")
req.TotalCandidates, _ = flags.GetBool("total-candidates")
req.Lookup, _ = flags.GetString("lookup")
req.DynamicX, _ = flags.GetBool("dynamic-x")
req.SeekDBPath, _ = flags.GetString("seekdb-path")
if raw, _ := flags.GetString("rules-file"); raw != "" {
req.RulesFile = []byte(raw)
req.RuleLeft, _ = flags.GetString("rule-left")
req.RuleRight, _ = flags.GetString("rule-right")
if values, _ := flags.GetStringArray("rules-file"); len(values) > 0 {
req.RulesFile = []byte(values[0])
for _, value := range values {
req.RulesFilesV7 = append(req.RulesFilesV7, []byte(value))
}
}
req.GenerateRules, _ = flags.GetUint32("generate-rules")
req.GenerateRulesFunMin, _ = flags.GetUint32("generate-rules-fun-min")
req.GenerateRulesFunMax, _ = flags.GetUint32("generate-rules-fun-max")
req.GenerateRulesFunMin, _ = flags.GetUint32("generate-rules-func-min")
if !flags.Changed("generate-rules-func-min") {
req.GenerateRulesFunMin, _ = flags.GetUint32("generate-rules-fun-min")
}
if flags.Changed("generate-rules-func-min") || flags.Changed("generate-rules-fun-min") {
value := req.GenerateRulesFunMin
req.GenerateRulesFuncMinV7 = &value
}
req.GenerateRulesFunMax, _ = flags.GetUint32("generate-rules-func-max")
if !flags.Changed("generate-rules-func-max") {
req.GenerateRulesFunMax, _ = flags.GetUint32("generate-rules-fun-max")
}
if flags.Changed("generate-rules-func-max") || flags.Changed("generate-rules-fun-max") {
value := req.GenerateRulesFunMax
req.GenerateRulesFuncMaxV7 = &value
}
req.GenerateRulesFuncSel, _ = flags.GetString("generate-rules-func-sel")
req.GenerateRulesSeed, _ = flags.GetInt32("generate-rules-seed")
if flags.Changed("generate-rules-seed") && req.GenerateRulesSeed >= 0 {
value := uint32(req.GenerateRulesSeed)
req.GenerateRulesSeedV7 = &value
}
req.CustomCharset1, _ = flags.GetString("custom-charset1")
req.CustomCharset2, _ = flags.GetString("custom-charset2")
req.CustomCharset3, _ = flags.GetString("custom-charset3")
req.CustomCharset4, _ = flags.GetString("custom-charset4")
req.CustomCharset5, _ = flags.GetString("custom-charset5")
req.CustomCharset6, _ = flags.GetString("custom-charset6")
req.CustomCharset7, _ = flags.GetString("custom-charset7")
req.CustomCharset8, _ = flags.GetString("custom-charset8")
req.Identify, _ = flags.GetString("identify")
req.IdentifyMode, _ = flags.GetBool("identify-mode")
if req.IdentifyMode && (flags.Changed("hash-mode") || flags.Changed("hash-type")) {
return nil, fmt.Errorf("--identify-mode and hash mode/type are mutually exclusive")
}
req.Increment, _ = flags.GetBool("increment")
req.IncrementInverse, _ = flags.GetBool("increment-inverse")
req.IncrementMin, _ = flags.GetUint32("increment-min")
req.IncrementMax, _ = flags.GetUint32("increment-max")
if flags.Changed("bypass-delay") {
value, _ := flags.GetUint32("bypass-delay")
req.BypassDelay = &value
}
if flags.Changed("bypass-threshold") {
value, _ := flags.GetUint32("bypass-threshold")
req.BypassThreshold = &value
}
req.SlowCandidates, _ = flags.GetBool("slow-candidates")
req.BridgeParameter1, _ = flags.GetString("bridge-parameter1")
req.BridgeParameter2, _ = flags.GetString("bridge-parameter2")
req.BridgeParameter3, _ = flags.GetString("bridge-parameter3")
req.BridgeParameter4, _ = flags.GetString("bridge-parameter4")
req.BrainServer, _ = flags.GetBool("brain-server")
req.BrainServerTimer, _ = flags.GetUint32("brain-server-timer")
if flags.Changed("brain-server-timer") {
value := req.BrainServerTimer
req.BrainServerTimerV7 = &value
}
req.BrainClient, _ = flags.GetBool("brain-client")
req.BrainClientFeatures, _ = flags.GetString("brain-client-features")
if value, ok := parseUint32(req.BrainClientFeatures); ok {
req.BrainClientFeaturesV7 = value
}
req.BrainHost, _ = flags.GetString("brain-host")
req.BrainPort, _ = flags.GetUint32("brain-port")
req.BrainPassword, _ = flags.GetString("brain-password")
if flags.Changed("brain-password") {
value := req.BrainPassword
req.BrainPasswordV7 = &value
}
req.BrainSession, _ = flags.GetString("brain-session")
if value, ok := parseHexUint32(req.BrainSession); ok {
req.BrainSessionV7 = &value
}
req.BrainSessionWhitelist, _ = flags.GetString("brain-session-whitelist")
if values, ok := parseHexUint32List(req.BrainSessionWhitelist); ok {
req.BrainSessionWhitelistV7 = values
}
req.BrainFeed, _ = flags.GetBool("brain-feed")
req.ColorCracked, _ = flags.GetBool("color-cracked")
req.HashCopy, _ = flags.GetBool("hash-copy")
req.EncryptWithPubkey, _ = flags.GetString("encrypt-with-pubkey")
return req, nil
}
@@ -214,6 +439,42 @@ func uintSliceToUint32(flags *pflag.FlagSet, name string) []uint32 {
return out
}
func parseUint32(raw string) (uint32, bool) {
if raw == "" {
return 0, false
}
value, err := strconv.ParseUint(raw, 10, 32)
return uint32(value), err == nil
}
func parseHexUint32(raw string) (uint32, bool) {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "0x") || strings.HasPrefix(raw, "0X") {
raw = raw[2:]
}
if raw == "" {
return 0, false
}
value, err := strconv.ParseUint(raw, 16, 32)
return uint32(value), err == nil
}
func parseHexUint32List(raw string) ([]uint32, bool) {
if raw == "" {
return nil, false
}
parts := strings.Split(raw, ",")
values := make([]uint32, 0, len(parts))
for _, part := range parts {
value, ok := parseHexUint32(part)
if !ok {
return nil, false
}
values = append(values, value)
}
return values, true
}
func parseCrackAttackMode(raw string) (clientpb.CrackAttackMode, error) {
if raw == "" {
return clientpb.CrackAttackMode_STRAIGHT, nil
@@ -231,14 +492,20 @@ func parseCrackAttackMode(raw string) (clientpb.CrackAttackMode, error) {
return clientpb.CrackAttackMode_COMBINATION, nil
case "bruteforce", "brute_force", "brute-force":
return clientpb.CrackAttackMode_BRUTEFORCE, nil
case "pcfg":
return clientpb.CrackAttackMode_PCFG, nil
case "hybrid_wordlist_mask":
return clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK, nil
case "hybrid_mask_wordlist":
return clientpb.CrackAttackMode_HYBRID_MASK_WORDLIST, nil
case "generic":
return clientpb.CrackAttackMode_GENERIC, nil
case "association":
return clientpb.CrackAttackMode_ASSOCIATION, nil
case "no_attack", "none":
return clientpb.CrackAttackMode_NO_ATTACK, nil
case "hybrid":
return clientpb.CrackAttackMode_HYBRID, nil
default:
return clientpb.CrackAttackMode_STRAIGHT, fmt.Errorf("unknown attack mode: %s", raw)
}
+487
View File
@@ -0,0 +1,487 @@
package crack
import (
"bytes"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/spf13/cobra"
"google.golang.org/protobuf/proto"
)
func TestBuildCrackCommandReadsKeyboardLayoutMapping(t *testing.T) {
mapping := []byte("a A\nb B\n")
path := filepath.Join(t.TempDir(), "keyboard.hckmap")
if err := os.WriteFile(path, mapping, 0600); err != nil {
t.Fatal(err)
}
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--keyboard-layout-mapping=" + path}); err != nil {
t.Fatal(err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand() error = %v", err)
}
if !bytes.Equal(got.KeyboardLayoutMapping, mapping) {
t.Fatalf("KeyboardLayoutMapping = %q, want file contents %q", got.KeyboardLayoutMapping, mapping)
}
}
func TestBuildCrackCommandRejectsInvalidKeyboardLayoutMapping(t *testing.T) {
tests := []struct {
name string
path func(*testing.T) string
want string
}{
{
name: "missing",
path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing.hckmap") },
want: "open keyboard layout mapping",
},
{
name: "directory",
path: func(t *testing.T) string { return t.TempDir() },
want: "is not a regular file",
},
{
name: "empty",
path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "empty.hckmap")
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
return path
},
want: "is empty",
},
{
name: "too large",
path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large.hckmap")
if err := os.WriteFile(path, make([]byte, maxKeyboardLayoutMappingBytes+1), 0600); err != nil {
t.Fatal(err)
}
return path
},
want: "exceeds",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--keyboard-layout-mapping=" + test.path(t)}); err != nil {
t.Fatal(err)
}
if _, err := buildCrackCommand(cmd, nil); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("buildCrackCommand() error = %v, want substring %q", err, test.want)
}
})
}
}
func TestParseCrackAttackMode(t *testing.T) {
tests := []struct {
name string
want clientpb.CrackAttackMode
}{
{name: "straight", want: clientpb.CrackAttackMode_STRAIGHT},
{name: "combination", want: clientpb.CrackAttackMode_COMBINATION},
{name: "bruteforce", want: clientpb.CrackAttackMode_BRUTEFORCE},
{name: "pcfg", want: clientpb.CrackAttackMode_PCFG},
{name: "hybrid-wordlist-mask", want: clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK},
{name: "hybrid-mask-wordlist", want: clientpb.CrackAttackMode_HYBRID_MASK_WORDLIST},
{name: "generic", want: clientpb.CrackAttackMode_GENERIC},
{name: "association", want: clientpb.CrackAttackMode_ASSOCIATION},
{name: "no-attack", want: clientpb.CrackAttackMode_NO_ATTACK},
{name: "hybrid", want: clientpb.CrackAttackMode_HYBRID},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := parseCrackAttackMode(test.name)
if err != nil {
t.Fatalf("parseCrackAttackMode(%q) returned error: %v", test.name, err)
}
if got != test.want {
t.Fatalf("parseCrackAttackMode(%q) = %v, want %v", test.name, got, test.want)
}
})
}
}
func TestBuildCrackCommandHashcatShortFlags(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"-a", "3", "-m", "1000"}); err != nil {
t.Fatal(err)
}
got, err := buildCrackCommand(cmd, []string{"hash"})
if err != nil {
t.Fatalf("buildCrackCommand() error = %v", err)
}
if got.AttackMode != clientpb.CrackAttackMode_BRUTEFORCE {
t.Fatalf("AttackMode = %s, want BRUTEFORCE", got.AttackMode)
}
if got.HashType != clientpb.HashType(1000) {
t.Fatalf("HashType = %d, want 1000", got.HashType)
}
}
func TestBuildCrackCommandHashcatV7Options(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
args := []string{
"--attack-mode=hybrid",
"--hash-mode=0",
"--input=first-input",
"--input=second-input",
"--stdin=stdin-data",
"--advice-disable",
"--status-timer=0",
"--stdin-timeout-abort=0",
"--restore-position",
"--outfile=outfile.txt",
"--outfile-json",
"--debug-file=debug.txt",
"--induction-dir=induction",
"--outfile-check-dir=outfile-check",
"--outfile-check-timer=0",
"--truecrypt-keyfiles=truecrypt.keys",
"--veracrypt-keyfiles=veracrypt.keys",
"--veracrypt-pim-start=11",
"--veracrypt-pim-stop=12",
"--encoding-from=windows-1252",
"--encoding-to=utf-8",
"--hccapx-message-pair=0",
"--nonce-error-corrections=0",
"--benchmark-min=13",
"--benchmark-max=14",
"--pipeline-stats",
"--task-time-breakdown",
"--bitmap-min=0",
"--bitmap-max=0",
"--hash-info",
"--hash-info-level=2",
"--backend-info",
"--backend-info-level=2",
"--backend-devices-virtmulti=15",
"--backend-devices-virthost=16",
"--metal-compiler-runtime=17",
"--multiply-accel-disable",
"--hwmon-temp-abort=0",
"--total-candidates",
"--lookup=lookup-value",
"--crackstation= station-one ",
"--dynamic-x",
"--seekdb-path=seekdb",
"--rule-left=l",
"--rule-right=r",
"--rules-file=:",
"--rules-file=$1",
"--custom-charset5=cs5",
"--custom-charset6=cs6",
"--custom-charset7=cs7",
"--custom-charset8=cs8",
"--identify=legacy-input",
"--increment-inverse",
"--bypass-delay=18",
"--bypass-threshold=19",
"--bridge-parameter1=bridge1",
"--bridge-parameter2=bridge2",
"--bridge-parameter3=bridge3",
"--bridge-parameter4=bridge4",
"--scrypt-tmto=0",
"--generate-rules-func-min=4",
"--generate-rules-func-max=8",
"--generate-rules-seed=0",
"--brain-client-features=3",
"--brain-password=",
"--brain-server-timer=0",
"--brain-session=deadbeef",
"--brain-session-whitelist=1,2,0x3",
"--brain-feed",
"--color-cracked",
"--hash-copy",
"--encrypt-with-pubkey=key.pub",
}
if err := cmd.Flags().Parse(args); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
zero := uint32(0)
want := &clientpb.CrackCommand{
AttackMode: clientpb.CrackAttackMode_HYBRID,
HashType: clientpb.HashType_INVALID,
HashMode: &zero,
Stdin: []byte("stdin-data"),
AdviceDisable: true,
StatusTimerV7: &zero,
StdinTimeoutAbortV7: &zero,
Outfile: "outfile.txt",
DebugFile: "debug.txt",
InductionDir: "induction",
OutfileCheckDir: "outfile-check",
OutfileCheckTimerV7: &zero,
TruecryptKeyfiles: "truecrypt.keys",
VeracryptKeyfiles: "veracrypt.keys",
VeracryptPimStart: uint32Pointer(11),
VeracryptPimStop: uint32Pointer(12),
EncodingFromName: "windows-1252",
EncodingToName: "utf-8",
HccapxMessagePairV7: &zero,
NonceErrorCorrectionsV7: &zero,
RuleLeft: "l",
RuleRight: "r",
RulesFile: []byte(":"),
RulesFilesV7: [][]byte{[]byte(":"), []byte("$1")},
PipelineStats: true,
TaskTimeBreakdown: true,
BitmapMinV7: &zero,
BitmapMaxV7: &zero,
HashInfo: true,
HashInfoLevel: 2,
BackendInfo: true,
BackendInfoLevel: 2,
MetalCompilerRuntime: 17,
RestorePosition: true,
OutfileJSON: true,
DynamicX: true,
SeekDBPath: "seekdb",
BenchmarkMin: 13,
BenchmarkMax: uint32Pointer(14),
BridgeParameter1: "bridge1",
BridgeParameter2: "bridge2",
BridgeParameter3: "bridge3",
BridgeParameter4: "bridge4",
BackendDevicesVirtMulti: 15,
BackendDevicesVirtHost: 16,
MultiplyAccelDisabled: true,
HwmonTempAbortV7: &zero,
TotalCandidates: true,
Lookup: "lookup-value",
Crackstation: "station-one",
CustomCharset5: "cs5",
CustomCharset6: "cs6",
CustomCharset7: "cs7",
CustomCharset8: "cs8",
IncrementInverse: true,
BypassDelay: uint32Pointer(18),
BypassThreshold: uint32Pointer(19),
ScryptTMTOV7: &zero,
GenerateRulesFunMin: 4,
GenerateRulesFunMax: 8,
GenerateRulesFuncMinV7: uint32Pointer(4),
GenerateRulesFuncMaxV7: uint32Pointer(8),
GenerateRulesSeedV7: &zero,
BrainClientFeatures: "3",
BrainClientFeaturesV7: 3,
BrainPasswordV7: stringPointer(""),
BrainSession: "deadbeef",
BrainSessionV7: uint32Pointer(0xdeadbeef),
BrainServerTimerV7: &zero,
BrainSessionWhitelist: "1,2,0x3",
BrainSessionWhitelistV7: []uint32{1, 2, 3},
BrainFeed: true,
ColorCracked: true,
HashCopy: true,
EncryptWithPubkey: "key.pub",
Identify: "legacy-input", //nolint:staticcheck // Verify the legacy positional operand remains populated for wire compatibility.
PositionalArguments: []string{"first-input", "second-input"},
}
if !proto.Equal(got, want) {
t.Fatal("buildCrackCommand() did not map the Hashcat v7 options as expected")
}
}
func uint32Pointer(value uint32) *uint32 {
return &value
}
func stringPointer(value string) *string {
return &value
}
func TestBuildCrackCommandBrainSessionsAreHexadecimal(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--brain-session=10",
"--brain-session-whitelist=10,20",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
if got.BrainSessionV7 == nil || *got.BrainSessionV7 != 0x10 {
t.Fatalf("BrainSessionV7 = %v, want 0x10", got.BrainSessionV7)
}
wantWhitelist := []uint32{0x10, 0x20}
if !slices.Equal(got.BrainSessionWhitelistV7, wantWhitelist) {
t.Fatalf("BrainSessionWhitelistV7 = %#v, want %#v", got.BrainSessionWhitelistV7, wantWhitelist)
}
}
func TestBuildCrackCommandPreservesCommasInPositionalInputs(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--input=hashes,2026.txt",
"--input=?d?d,?l?l",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
want := []string{"hashes,2026.txt", "?d?d,?l?l"}
if !slices.Equal(got.PositionalArguments, want) {
t.Fatalf("PositionalArguments = %#v, want %#v", got.PositionalArguments, want)
}
}
func TestBuildCrackCommandPreservesCommasInHashes(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--hash=digest:salt,with,commas",
"--hash=second:digest,salt",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
want := []string{"digest:salt,with,commas", "second:digest,salt"}
if !slices.Equal(got.Hashes, want) {
t.Fatalf("Hashes = %#v, want %#v", got.Hashes, want)
}
}
func TestBuildCrackCommandRejectsConflictingRestoreModes(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--restore-position", "--restore-show-command"}); err != nil {
t.Fatalf("parse flags: %v", err)
}
_, err := buildCrackCommand(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("buildCrackCommand() error = %v, want restore conflict error", err)
}
}
func TestBuildCrackCommandIdentifyHasNoImplicitHashType(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--identify-mode", "--input=hashes.txt"}); err != nil {
t.Fatal(err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatal(err)
}
if got.HashType != clientpb.HashType_INVALID || got.HashMode != nil {
t.Fatalf("identify command inherited hash mode/type: %#v", got)
}
}
func TestBuildCrackCommandLegacyFlagAliases(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--multiply-accel-disabled",
"--generate-rules-fun-min=2",
"--generate-rules-fun-max=6",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
if !got.MultiplyAccelDisabled || got.GenerateRulesFunMin != 2 || got.GenerateRulesFunMax != 6 {
t.Fatalf("legacy aliases were not preserved: %#v", got)
}
}
func TestBuildCrackCommandRejectsSegmentSize(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Set("segment-size", "32"); err != nil {
t.Fatalf("set segment-size: %v", err)
}
_, err := buildCrackCommand(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "removed in Hashcat v7") {
t.Fatalf("buildCrackCommand() error = %v, want Hashcat v7 removal error", err)
}
}
func TestBuildCrackCommandCredentialSelectors(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{
"--credential= first-id ",
"--credential=second-id",
"--credential-collection= domain-admins ",
"--include-cracked-credentials",
}); err != nil {
t.Fatalf("parse flags: %v", err)
}
got, err := buildCrackCommand(cmd, nil)
if err != nil {
t.Fatalf("buildCrackCommand returned error: %v", err)
}
if !slices.Equal(got.CredentialIDs, []string{"first-id", "second-id"}) {
t.Fatalf("CredentialIDs = %#v", got.CredentialIDs)
}
if got.CredentialCollection != "domain-admins" {
t.Fatalf("CredentialCollection = %q", got.CredentialCollection)
}
if !got.IncludeCrackedCredentials {
t.Fatal("IncludeCrackedCredentials is false")
}
}
func TestBuildCrackCommandRejectsEmptyCredential(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--credential= "}); err != nil {
t.Fatalf("parse flags: %v", err)
}
if _, err := buildCrackCommand(cmd, nil); err == nil || !strings.Contains(err.Error(), "cannot be empty") {
t.Fatalf("buildCrackCommand error = %v", err)
}
}
func TestBuildCrackCommandIncludeCrackedRequiresSelector(t *testing.T) {
cmd := &cobra.Command{Use: "crack-test"}
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse([]string{"--include-cracked-credentials"}); err != nil {
t.Fatalf("parse flags: %v", err)
}
if _, err := buildCrackCommand(cmd, nil); err == nil || !strings.Contains(err.Error(), "requires --credential") {
t.Fatalf("buildCrackCommand error = %v", err)
}
}
+270
View File
@@ -0,0 +1,270 @@
package crack
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const (
maxCrackBackendInfoLevel uint32 = 2
maxCrackHashInfoLevel uint32 = 2
)
type crackInvocationMode uint8
const (
crackInvocationDashboard crackInvocationMode = iota
crackInvocationJob
crackInvocationBackendInfo
crackInvocationKeyspace
crackInvocationTotalCandidates
crackInvocationLookup
crackInvocationIdentify
crackInvocationHashInfo
)
type crackInvocation struct {
mode crackInvocationMode
backendInfoLevel uint32
hashInfoLevel uint32
crackstation string
}
type crackQueryModeSelection struct {
mode crackInvocationMode
flagName string
}
var incompatibleCandidateQueryFlags = map[string]struct{}{
"benchmark": {},
"benchmark-all": {},
"benchmark-min": {},
"benchmark-max": {},
"brain-client": {},
"brain-client-features": {},
"brain-feed": {},
"brain-host": {},
"brain-password": {},
"brain-port": {},
"brain-server": {},
"brain-server-timer": {},
"brain-session": {},
"brain-session-whitelist": {},
"left": {},
"progress-only": {},
"restore": {},
"restore-file": {},
"restore-position": {},
"restore-show-command": {},
"show": {},
"speed-only": {},
}
// classifyCrackInvocation separates terminal command modes from work that
// belongs in the distributed crack queue. Backend information comes from the
// registration snapshots; the remaining query modes execute synchronously on
// one idle crackstation and never create a CrackJob.
//
//nolint:gocyclo // The ordered flag matrix keeps mutually exclusive query modes and their validation in one decision point.
func classifyCrackInvocation(cmd *cobra.Command, args []string) (crackInvocation, error) {
flags := cmd.Flags()
backendInfo, _ := flags.GetBool("backend-info")
backendInfoLevel, _ := flags.GetUint32("backend-info-level")
backendInfoLevelChanged := flags.Changed("backend-info-level")
hashInfo, _ := flags.GetBool("hash-info")
hashInfoLevel, _ := flags.GetUint32("hash-info-level")
hashInfoLevelChanged := flags.Changed("hash-info-level")
keyspace, _ := flags.GetBool("keyspace")
totalCandidates, _ := flags.GetBool("total-candidates")
lookup, _ := flags.GetString("lookup")
lookupChanged := flags.Changed("lookup")
identify, _ := flags.GetBool("identify-mode")
crackstation, _ := flags.GetString("crackstation")
crackstation = strings.TrimSpace(crackstation)
if flags.Changed("crackstation") && crackstation == "" {
return crackInvocation{}, fmt.Errorf("--crackstation cannot be empty")
}
if lookupChanged && lookup == "" {
return crackInvocation{}, fmt.Errorf("--lookup cannot be empty")
}
selections := make([]crackQueryModeSelection, 0, 6)
if backendInfo || backendInfoLevelChanged {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationBackendInfo, flagName: "--backend-info"})
}
if keyspace {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationKeyspace, flagName: "--keyspace"})
}
if totalCandidates {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationTotalCandidates, flagName: "--total-candidates"})
}
if lookupChanged {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationLookup, flagName: "--lookup"})
}
if identify {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationIdentify, flagName: "--identify-mode"})
}
if hashInfo || hashInfoLevelChanged {
selections = append(selections, crackQueryModeSelection{mode: crackInvocationHashInfo, flagName: "--hash-info"})
}
if len(selections) > 1 {
names := make([]string, 0, len(selections))
for _, selection := range selections {
names = append(names, selection.flagName)
}
sort.Strings(names)
return crackInvocation{}, fmt.Errorf("crack query modes cannot be combined: %s", strings.Join(names, ", "))
}
if len(selections) == 0 {
if flags.Changed("crackstation") {
return crackInvocation{}, fmt.Errorf("--crackstation requires --keyspace, --total-candidates, --lookup, --identify-mode, or --hash-info")
}
if shouldRunCrack(cmd, args) {
return crackInvocation{mode: crackInvocationJob}, nil
}
return crackInvocation{mode: crackInvocationDashboard}, nil
}
invocation := crackInvocation{mode: selections[0].mode, crackstation: crackstation}
switch invocation.mode {
case crackInvocationBackendInfo:
if !backendInfoLevelChanged {
backendInfoLevel = 1
}
if backendInfoLevel == 0 || backendInfoLevel > maxCrackBackendInfoLevel {
return crackInvocation{}, fmt.Errorf("--backend-info-level must be 1 or %d", maxCrackBackendInfoLevel)
}
conflicts := changedCrackFlagConflicts(flags, map[string]struct{}{
"backend-info": {}, "backend-info-level": {}, "timeout": {},
})
if len(args) != 0 {
conflicts = append(conflicts, "positional hash arguments")
}
if len(conflicts) != 0 {
sort.Strings(conflicts)
return crackInvocation{}, fmt.Errorf("backend-info query cannot be combined with %s", strings.Join(conflicts, ", "))
}
invocation.backendInfoLevel = backendInfoLevel
case crackInvocationHashInfo:
if !hashInfoLevelChanged {
hashInfoLevel = 1
}
if hashInfoLevel == 0 || hashInfoLevel > maxCrackHashInfoLevel {
return crackInvocation{}, fmt.Errorf("--hash-info-level must be 1 or %d", maxCrackHashInfoLevel)
}
conflicts := changedCrackFlagConflicts(flags, map[string]struct{}{
"crackstation": {}, "hash-info": {}, "hash-info-level": {}, "hash-mode": {}, "hash-type": {}, "timeout": {},
})
if len(args) != 0 {
conflicts = append(conflicts, "positional hash arguments")
}
if len(conflicts) != 0 {
sort.Strings(conflicts)
return crackInvocation{}, fmt.Errorf("hash-info query cannot be combined with %s", strings.Join(conflicts, ", "))
}
invocation.hashInfoLevel = hashInfoLevel
case crackInvocationIdentify:
conflicts := changedCrackFlagConflicts(flags, map[string]struct{}{
"crackstation": {}, "hash": {}, "identify-mode": {}, "timeout": {},
})
if len(conflicts) != 0 {
return crackInvocation{}, fmt.Errorf("identify query cannot be combined with %s", strings.Join(conflicts, ", "))
}
if !hasCrackIdentifyInput(flags, args) {
return crackInvocation{}, fmt.Errorf("--identify-mode requires a positional hash or --hash")
}
case crackInvocationKeyspace, crackInvocationTotalCandidates, crackInvocationLookup:
if conflicts := candidateQueryConflicts(flags, args, invocation.mode); len(conflicts) != 0 {
return crackInvocation{}, fmt.Errorf("%s query cannot be combined with %s", crackInvocationName(invocation.mode), strings.Join(conflicts, ", "))
}
}
return invocation, nil
}
func crackInvocationName(mode crackInvocationMode) string {
switch mode {
case crackInvocationBackendInfo:
return "backend-info"
case crackInvocationKeyspace:
return "keyspace"
case crackInvocationTotalCandidates:
return "total-candidates"
case crackInvocationLookup:
return "lookup"
case crackInvocationIdentify:
return "identify"
case crackInvocationHashInfo:
return "hash-info"
default:
return "crack"
}
}
func changedCrackFlagConflicts(flags *pflag.FlagSet, allowed map[string]struct{}) []string {
conflicts := make([]string, 0)
flags.Visit(func(flag *pflag.Flag) {
if _, ok := allowed[flag.Name]; ok || changedBoolFlagIsFalse(flags, flag) {
return
}
conflicts = append(conflicts, "--"+flag.Name)
})
sort.Strings(conflicts)
return conflicts
}
func changedBoolFlagIsFalse(flags *pflag.FlagSet, flag *pflag.Flag) bool {
if flag.Value.Type() != "bool" {
return false
}
value, err := flags.GetBool(flag.Name)
return err == nil && !value
}
func hasCrackIdentifyInput(flags *pflag.FlagSet, args []string) bool {
if len(args) != 0 {
return true
}
hashes, _ := flags.GetStringArray("hash")
return len(hashes) != 0
}
func candidateQueryConflicts(flags *pflag.FlagSet, args []string, mode crackInvocationMode) []string {
conflicts := make([]string, 0)
if len(args) != 0 {
conflicts = append(conflicts, "positional hash arguments")
}
flags.Visit(func(flag *pflag.Flag) {
if changedBoolFlagIsFalse(flags, flag) {
return
}
switch flag.Name {
case "hash", "credential", "credential-collection":
conflicts = append(conflicts, "--"+flag.Name)
return
case "include-cracked-credentials":
includeCracked, _ := flags.GetBool(flag.Name)
if includeCracked {
conflicts = append(conflicts, "--"+flag.Name)
}
return
case "skip", "limit":
if mode != crackInvocationLookup {
value, _ := flags.GetUint64(flag.Name)
if value != 0 {
conflicts = append(conflicts, "--"+flag.Name)
}
}
return
}
if _, incompatible := incompatibleCandidateQueryFlags[flag.Name]; incompatible {
conflicts = append(conflicts, "--"+flag.Name)
}
})
sort.Strings(conflicts)
return conflicts
}
+527
View File
@@ -0,0 +1,527 @@
package crack
import (
"context"
"errors"
"strings"
"testing"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
)
func newCrackDispatchCommand(t *testing.T, flags ...string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "crack-test"}
cmd.SetContext(context.Background())
cmd.Flags().Int64("timeout", 0, "test timeout")
bindCrackFlags(cmd.Flags())
if err := cmd.Flags().Parse(flags); err != nil {
t.Fatalf("parse flags: %v", err)
}
return cmd
}
func TestClassifyCrackInvocation(t *testing.T) {
tests := []struct {
name string
flags []string
args []string
wantMode crackInvocationMode
wantBackendLevel uint32
wantHashLevel uint32
wantCrackstation string
wantError string
}{
{name: "dashboard", wantMode: crackInvocationDashboard},
{name: "timeout only", flags: []string{"--timeout=5"}, wantMode: crackInvocationDashboard},
{name: "positional hash job", args: []string{"hash"}, wantMode: crackInvocationJob},
{name: "job flag", flags: []string{"--attack-mode=3"}, wantMode: crackInvocationJob},
{name: "explicit false is a no-op", flags: []string{"--backend-info=false"}, wantMode: crackInvocationDashboard},
{name: "explicit false with job flag", flags: []string{"--backend-info=false", "--attack-mode=3"}, wantMode: crackInvocationJob},
{name: "keyspace false is a no-op", flags: []string{"--keyspace=false"}, wantMode: crackInvocationDashboard},
{name: "keyspace false with job flag", flags: []string{"--keyspace=false", "--attack-mode=3"}, wantMode: crackInvocationJob},
{name: "keyspace", flags: []string{"--keyspace"}, wantMode: crackInvocationKeyspace},
{name: "keyspace candidate flags", flags: []string{"--keyspace", "--attack-mode=3", "--input=?d?d", "--increment"}, wantMode: crackInvocationKeyspace},
{name: "keyspace selected station", flags: []string{"--keyspace", "--crackstation= station-one "}, wantMode: crackInvocationKeyspace, wantCrackstation: "station-one"},
{name: "keyspace timeout", flags: []string{"--keyspace", "--timeout=5"}, wantMode: crackInvocationKeyspace},
{name: "keyspace skip conflict", flags: []string{"--keyspace", "--skip=1"}, wantError: "--skip"},
{name: "keyspace zero skip no-op", flags: []string{"--keyspace", "--skip=0"}, wantMode: crackInvocationKeyspace},
{name: "keyspace positional hash conflict", flags: []string{"--keyspace"}, args: []string{"hash"}, wantError: "positional hash arguments"},
{name: "keyspace hash flag conflict", flags: []string{"--keyspace", "--hash=digest"}, wantError: "--hash"},
{name: "keyspace credential conflict", flags: []string{"--keyspace", "--credential=deadbeef"}, wantError: "--credential"},
{name: "keyspace collection conflict", flags: []string{"--keyspace", "--credential-collection=secrets"}, wantError: "--credential-collection"},
{name: "keyspace include cracked conflict", flags: []string{"--keyspace", "--include-cracked-credentials"}, wantError: "--include-cracked-credentials"},
{name: "keyspace and backend info conflict", flags: []string{"--keyspace", "--backend-info"}, wantError: "--keyspace"},
{name: "backend info ignores disabled keyspace", flags: []string{"--backend-info", "--keyspace=false"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 1},
{name: "backend info", flags: []string{"--backend-info"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 1},
{name: "backend info and timeout", flags: []string{"--backend-info", "--timeout=5"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 1},
{name: "level one", flags: []string{"--backend-info-level=1"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 1},
{name: "level two", flags: []string{"--backend-info-level=2"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 2},
{name: "explicit level wins", flags: []string{"--backend-info", "--backend-info-level=2"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 2},
{name: "level independently enables mode", flags: []string{"--backend-info=false", "--backend-info-level=2"}, wantMode: crackInvocationBackendInfo, wantBackendLevel: 2},
{name: "zero level", flags: []string{"--backend-info-level=0"}, wantError: "must be 1 or 2"},
{name: "unsupported level", flags: []string{"--backend-info-level=3"}, wantError: "must be 1 or 2"},
{name: "positional conflict", flags: []string{"--backend-info"}, args: []string{"hash"}, wantError: "positional hash arguments"},
{name: "job flag conflict", flags: []string{"--backend-info", "--benchmark"}, wantError: "--benchmark"},
{name: "input conflict", flags: []string{"--backend-info-level=2", "--input=wordlist"}, wantError: "--input"},
{name: "backend info station conflict", flags: []string{"--backend-info", "--crackstation=station-one"}, wantError: "--crackstation"},
{name: "total candidates", flags: []string{"--total-candidates", "--attack-mode=3", "--input=?d"}, wantMode: crackInvocationTotalCandidates},
{name: "total candidates false", flags: []string{"--total-candidates=false"}, wantMode: crackInvocationDashboard},
{name: "total candidates hash conflict", flags: []string{"--total-candidates", "--hash=digest"}, wantError: "--hash"},
{name: "total candidates limit conflict", flags: []string{"--total-candidates", "--limit=1"}, wantError: "--limit"},
{name: "lookup", flags: []string{"--lookup=candidate", "--attack-mode=3", "--input=?d"}, wantMode: crackInvocationLookup},
{name: "lookup allows window", flags: []string{"--lookup=candidate", "--skip=1", "--limit=2"}, wantMode: crackInvocationLookup},
{name: "lookup empty", flags: []string{"--lookup="}, wantError: "cannot be empty"},
{name: "query modes conflict", flags: []string{"--keyspace", "--total-candidates"}, wantError: "cannot be combined"},
{name: "candidate query terminal conflict", flags: []string{"--keyspace", "--show"}, wantError: "--show"},
{name: "identify positional", flags: []string{"--identify-mode"}, args: []string{"digest"}, wantMode: crackInvocationIdentify},
{name: "identify hash flag", flags: []string{"--identify-mode", "--hash=digest"}, wantMode: crackInvocationIdentify},
{name: "identify false", flags: []string{"--identify-mode=false"}, wantMode: crackInvocationDashboard},
{name: "identify missing hash", flags: []string{"--identify-mode"}, wantError: "requires a positional hash or --hash"},
{name: "identify credential conflict", flags: []string{"--identify-mode", "--credential=deadbeef"}, wantError: "--credential"},
{name: "identify hash mode conflict", flags: []string{"--identify-mode", "--hash=digest", "--hash-mode=0"}, wantError: "--hash-mode"},
{name: "hash info", flags: []string{"--hash-info"}, wantMode: crackInvocationHashInfo, wantHashLevel: 1},
{name: "hash info false", flags: []string{"--hash-info=false"}, wantMode: crackInvocationDashboard},
{name: "hash info level", flags: []string{"--hash-info=false", "--hash-info-level=2", "--hash-mode=0", "--crackstation=station-one"}, wantMode: crackInvocationHashInfo, wantHashLevel: 2, wantCrackstation: "station-one"},
{name: "hash info zero level", flags: []string{"--hash-info-level=0"}, wantError: "must be 1 or 2"},
{name: "hash info unsupported level", flags: []string{"--hash-info-level=3"}, wantError: "must be 1 or 2"},
{name: "hash info hash conflict", flags: []string{"--hash-info", "--hash=digest"}, wantError: "--hash"},
{name: "crackstation requires query", flags: []string{"--crackstation=station-one"}, wantError: "requires --keyspace"},
{name: "crackstation cannot be empty", flags: []string{"--keyspace", "--crackstation= "}, wantError: "cannot be empty"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
invocation, err := classifyCrackInvocation(newCrackDispatchCommand(t, test.flags...), test.args)
if test.wantError != "" {
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("classifyCrackInvocation() error = %v, want substring %q", err, test.wantError)
}
return
}
if err != nil {
t.Fatalf("classifyCrackInvocation() error = %v", err)
}
if invocation.mode != test.wantMode || invocation.backendInfoLevel != test.wantBackendLevel ||
invocation.hashInfoLevel != test.wantHashLevel || invocation.crackstation != test.wantCrackstation {
t.Fatalf("classifyCrackInvocation() = %#v, want mode %d backend level %d hash level %d station %q",
invocation, test.wantMode, test.wantBackendLevel, test.wantHashLevel, test.wantCrackstation)
}
})
}
}
type crackDispatchRPCCapture struct {
rpcpb.SliverRPCClient
crackstationsCalls int
crackCalls int
crackFilesCalls int
hadDeadline bool
crackHadDeadline bool
err error
response *clientpb.Crackstations
crackResponse *clientpb.CrackResponse
crackFilesResponse *clientpb.CrackFiles
crackRequest *clientpb.CrackCommand
}
func (capture *crackDispatchRPCCapture) Crackstations(ctx context.Context, _ *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.Crackstations, error) {
capture.crackstationsCalls++
_, capture.hadDeadline = ctx.Deadline()
if capture.err != nil {
return nil, capture.err
}
if capture.response != nil {
return capture.response, nil
}
return &clientpb.Crackstations{}, nil
}
func (capture *crackDispatchRPCCapture) Crack(ctx context.Context, request *clientpb.CrackCommand, _ ...grpc.CallOption) (*clientpb.CrackResponse, error) {
capture.crackCalls++
_, capture.crackHadDeadline = ctx.Deadline()
capture.crackRequest = request
if capture.crackResponse != nil {
return capture.crackResponse, nil
}
return &clientpb.CrackResponse{}, nil
}
func (capture *crackDispatchRPCCapture) CrackFilesList(_ context.Context, _ *clientpb.CrackFile, _ ...grpc.CallOption) (*clientpb.CrackFiles, error) {
capture.crackFilesCalls++
if capture.crackFilesResponse != nil {
return capture.crackFilesResponse, nil
}
return &clientpb.CrackFiles{}, nil
}
func TestCrackBackendInfoQueriesStationsWithoutCreatingJob(t *testing.T) {
capture := &crackDispatchRPCCapture{}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
CrackCmd(newCrackDispatchCommand(t, "--backend-info", "--timeout=1"), con, nil)
if capture.crackstationsCalls != 1 {
t.Fatalf("Crackstations calls = %d, want 1", capture.crackstationsCalls)
}
if capture.crackCalls != 0 || capture.crackFilesCalls != 0 {
t.Fatalf("backend-info used job path: Crack=%d CrackFilesList=%d", capture.crackCalls, capture.crackFilesCalls)
}
if !capture.hadDeadline {
t.Fatal("Crackstations context did not inherit --timeout")
}
}
func TestCrackKeyspaceUsesSynchronousCrackResponse(t *testing.T) {
digest := strings.Repeat("a", 64)
capture := &crackDispatchRPCCapture{
crackResponse: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_KEYSPACE, Value: "100",
}},
crackFilesResponse: &clientpb.CrackFiles{
Files: []*clientpb.CrackFile{{
ID: "wordlist-id", Name: "words.txt", Type: clientpb.CrackFileType_WORDLIST, Sha2_256: digest,
}},
},
}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
CrackCmd(newCrackDispatchCommand(t, "--keyspace", "--input=words.txt", "--crackstation=station-one", "--timeout=1"), con, nil)
if capture.crackCalls != 1 {
t.Fatalf("Crack calls = %d, want 1", capture.crackCalls)
}
if capture.crackstationsCalls != 0 {
t.Fatalf("keyspace queried crackstations directly %d times", capture.crackstationsCalls)
}
if capture.crackFilesCalls != 1 {
t.Fatalf("CrackFilesList calls = %d, want 1", capture.crackFilesCalls)
}
if !capture.crackHadDeadline {
t.Fatal("Crack context did not inherit --timeout")
}
if capture.crackRequest == nil || !capture.crackRequest.Keyspace {
t.Fatalf("Crack request = %#v, want keyspace mode", capture.crackRequest)
}
if capture.crackRequest.Crackstation != "station-one" {
t.Fatalf("Crack request station = %q, want station-one", capture.crackRequest.Crackstation)
}
wantInput := "crackfile://wordlist/" + digest
if len(capture.crackRequest.PositionalArguments) != 1 || capture.crackRequest.PositionalArguments[0] != wantInput {
t.Fatalf("resolved keyspace inputs = %#v, want %q", capture.crackRequest.PositionalArguments, wantInput)
}
if len(capture.crackRequest.Hashes) != 0 || len(capture.crackRequest.CredentialIDs) != 0 {
t.Fatalf("keyspace request contains hash selectors: %#v", capture.crackRequest)
}
}
func TestCrackSynchronousQueriesUseCrackRPCWithoutCreatingJob(t *testing.T) {
tests := []struct {
name string
flags []string
args []string
resultMode clientpb.CrackQueryMode
result string
check func(*testing.T, *clientpb.CrackCommand)
}{
{
name: "total candidates",
flags: []string{"--total-candidates", "--attack-mode=3", "--input=?d", "--crackstation=station-one"},
resultMode: clientpb.CrackQueryMode_CRACK_QUERY_TOTAL_CANDIDATES,
result: "100",
check: func(t *testing.T, command *clientpb.CrackCommand) {
if !command.TotalCandidates || command.Crackstation != "station-one" {
t.Fatalf("total-candidates request = %#v", command)
}
},
},
{
name: "lookup",
flags: []string{"--lookup=candidate", "--attack-mode=3", "--input=?d"},
resultMode: clientpb.CrackQueryMode_CRACK_QUERY_LOOKUP,
result: "lookup output",
check: func(t *testing.T, command *clientpb.CrackCommand) {
if command.Lookup != "candidate" {
t.Fatalf("lookup request = %#v", command)
}
},
},
{
name: "identify",
flags: []string{"--identify-mode"},
args: []string{"digest"},
resultMode: clientpb.CrackQueryMode_CRACK_QUERY_IDENTIFY,
result: "identify output",
check: func(t *testing.T, command *clientpb.CrackCommand) {
if !command.IdentifyMode || len(command.Hashes) != 1 || command.Hashes[0] != "digest" {
t.Fatalf("identify request = %#v", command)
}
},
},
{
name: "hash info",
flags: []string{"--hash-info-level=2", "--hash-mode=1000"},
resultMode: clientpb.CrackQueryMode_CRACK_QUERY_HASH_INFO,
result: "hash-info output",
check: func(t *testing.T, command *clientpb.CrackCommand) {
if command.HashInfoLevel != 2 || command.HashMode == nil || *command.HashMode != 1000 {
t.Fatalf("hash-info request = %#v", command)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
capture := &crackDispatchRPCCapture{crackResponse: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: test.resultMode, Value: test.result,
}}}
con := console.NewConsole(false)
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
if err := console.StartClient(con, capture, nil, nil, emptyCommands, emptyCommands, false, ""); err != nil {
t.Fatal(err)
}
CrackCmd(newCrackDispatchCommand(t, test.flags...), con, test.args)
if capture.crackCalls != 1 || capture.crackstationsCalls != 0 {
t.Fatalf("RPC calls: Crack=%d Crackstations=%d", capture.crackCalls, capture.crackstationsCalls)
}
if capture.crackRequest == nil {
t.Fatal("Crack request was not captured")
}
test.check(t, capture.crackRequest)
})
}
}
func TestCanonicalCrackKeyspace(t *testing.T) {
tests := []struct {
name string
response *clientpb.CrackResponse
want string
wantErr bool
}{
{name: "canonical", response: &clientpb.CrackResponse{Keyspace: "100"}, want: "100"},
{name: "normalizes", response: &clientpb.CrackResponse{Keyspace: " 00100 "}, want: "100"},
{name: "zero", response: &clientpb.CrackResponse{Keyspace: "0"}, want: "0"},
{name: "missing response", wantErr: true},
{name: "missing value", response: &clientpb.CrackResponse{}, wantErr: true},
{name: "negative", response: &clientpb.CrackResponse{Keyspace: "-1"}, wantErr: true},
{name: "overflow", response: &clientpb.CrackResponse{Keyspace: "18446744073709551616"}, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := canonicalCrackKeyspace(test.response)
if (err != nil) != test.wantErr {
t.Fatalf("canonicalCrackKeyspace() error = %v, wantErr %v", err, test.wantErr)
}
if got != test.want {
t.Fatalf("canonicalCrackKeyspace() = %q, want %q", got, test.want)
}
})
}
}
func TestCrackQueryResponseValue(t *testing.T) {
tests := []struct {
name string
invocation crackInvocation
response *clientpb.CrackResponse
want string
wantError string
}{
{
name: "typed keyspace",
invocation: crackInvocation{mode: crackInvocationKeyspace},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_KEYSPACE, Value: " 00100 ",
}},
want: "100",
},
{
name: "typed total candidates",
invocation: crackInvocation{mode: crackInvocationTotalCandidates},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_TOTAL_CANDIDATES, Value: "42\n",
}},
want: "42",
},
{
name: "text remains verbatim",
invocation: crackInvocation{mode: crackInvocationLookup},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_LOOKUP, Value: "line one\nline two\n",
}},
want: "line one\nline two\n",
},
{
name: "wrong typed mode",
invocation: crackInvocation{mode: crackInvocationLookup},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_IDENTIFY, Value: "output",
}},
wantError: "for lookup query",
},
{
name: "new mode cannot use legacy keyspace",
invocation: crackInvocation{mode: crackInvocationTotalCandidates},
response: &clientpb.CrackResponse{Keyspace: "42"},
wantError: "empty total-candidates query result",
},
{
name: "selected keyspace cannot use legacy response",
invocation: crackInvocation{mode: crackInvocationKeyspace, crackstation: "station-one"},
response: &clientpb.CrackResponse{Keyspace: "42"},
wantError: "empty keyspace query result",
},
{
name: "empty text",
invocation: crackInvocation{mode: crackInvocationHashInfo},
response: &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_HASH_INFO, Value: " \n",
}},
wantError: "empty hash-info query result",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := crackQueryResponseValue(test.response, test.invocation)
if test.wantError != "" {
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("crackQueryResponseValue() error = %v, want substring %q", err, test.wantError)
}
return
}
if err != nil {
t.Fatal(err)
}
if got != test.want {
t.Fatalf("crackQueryResponseValue() = %q, want %q", got, test.want)
}
})
}
}
func TestCrackQueryResponsePreservesStderr(t *testing.T) {
response := &clientpb.CrackResponse{Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_LOOKUP,
Value: "lookup output",
Stderr: "hashcat warning\n",
}}
value, stderr, err := crackQueryResponse(response, crackInvocation{mode: crackInvocationLookup})
if err != nil {
t.Fatal(err)
}
if value != "lookup output" || stderr != "hashcat warning\n" {
t.Fatalf("crackQueryResponse() = (%q, %q), want preserved value and stderr", value, stderr)
}
}
func TestCrackResponseKeyspaceFieldNumberAndRoundTrip(t *testing.T) {
field := (&clientpb.CrackResponse{}).ProtoReflect().Descriptor().Fields().ByName("Keyspace")
if field == nil {
t.Fatal("CrackResponse is missing field Keyspace")
}
if got, want := field.Number(), protoreflect.FieldNumber(2); got != want {
t.Fatalf("CrackResponse.Keyspace tag = %d, want %d", got, want)
}
queryField := (&clientpb.CrackResponse{}).ProtoReflect().Descriptor().Fields().ByName("Query")
if queryField == nil {
t.Fatal("CrackResponse is missing field Query")
}
if got, want := queryField.Number(), protoreflect.FieldNumber(3); got != want {
t.Fatalf("CrackResponse.Query tag = %d, want %d", got, want)
}
stderrField := (&clientpb.CrackQueryResult{}).ProtoReflect().Descriptor().Fields().ByName("Stderr")
if stderrField == nil {
t.Fatal("CrackQueryResult is missing field Stderr")
}
if got, want := stderrField.Number(), protoreflect.FieldNumber(6); got != want {
t.Fatalf("CrackQueryResult.Stderr tag = %d, want %d", got, want)
}
want := &clientpb.CrackResponse{
Job: &clientpb.CrackJob{ID: "job-id"},
Keyspace: "18446744073709551615",
Query: &clientpb.CrackQueryResult{
Mode: clientpb.CrackQueryMode_CRACK_QUERY_KEYSPACE, CrackstationHostUUID: "host-id", Value: "42", Stderr: "warning\n",
},
}
raw, err := proto.Marshal(want)
if err != nil {
t.Fatalf("marshal crack response: %v", err)
}
got := &clientpb.CrackResponse{}
if err := proto.Unmarshal(raw, got); err != nil {
t.Fatalf("unmarshal crack response: %v", err)
}
if !proto.Equal(got, want) {
t.Fatalf("crack response round trip = %v, want %v", got, want)
}
}
func TestConnectedCrackstationsReturnsRPCError(t *testing.T) {
want := errors.New("query failed")
capture := &crackDispatchRPCCapture{err: want}
if _, err := connectedCrackstations(context.Background(), capture); !errors.Is(err, want) {
t.Fatalf("connectedCrackstations() error = %v, want %v", err, want)
}
if capture.crackstationsCalls != 1 {
t.Fatalf("Crackstations calls = %d, want 1", capture.crackstationsCalls)
}
}
func TestConnectedCrackstationsReturnsEveryNonNilStation(t *testing.T) {
capture := &crackDispatchRPCCapture{response: &clientpb.Crackstations{Crackstations: []*clientpb.Crackstation{
nil,
{Name: "one"},
{Name: "two"},
}}}
stations, err := connectedCrackstations(context.Background(), capture)
if err != nil {
t.Fatal(err)
}
if len(stations) != 2 || stations[0].Name != "one" || stations[1].Name != "two" {
t.Fatalf("connectedCrackstations() = %#v, want one and two", stations)
}
}
func TestCrackHashcatFlagsAreRootLocal(t *testing.T) {
root := Commands(nil)[0]
if root.Flags().Lookup("backend-info") == nil {
t.Fatal("root crack command is missing --backend-info")
}
if root.Flags().Lookup("crackstation") == nil {
t.Fatal("root crack command is missing --crackstation")
}
if root.PersistentFlags().Lookup("backend-info") != nil {
t.Fatal("--backend-info must not be inherited by crack subcommands")
}
if root.Flags().Lookup("stdout") != nil {
t.Fatal("root crack command unexpectedly exposes removed --stdout")
}
stations, _, err := root.Find([]string{"stations"})
if err != nil {
t.Fatalf("find crack stations: %v", err)
}
if stations.InheritedFlags().Lookup("backend-info") != nil {
t.Fatal("crack stations unexpectedly inherits --backend-info")
}
if stations.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("crack stations must continue to inherit --timeout")
}
}
@@ -0,0 +1,215 @@
package crack
import (
"context"
"encoding/hex"
"fmt"
"strings"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
const crackFileScheme = "crackfile://"
type crackFilesListFunc func(context.Context, *clientpb.CrackFile) (*clientpb.CrackFiles, error)
// resolveManagedCrackFileReferences replaces exact server-side crack file
// names, IDs, and digests with the content-addressed URI understood by a
// crackstation. Inputs that do not match a managed file remain ordinary
// Hashcat operands, masks, paths, or inline rule content.
//
//nolint:gocyclo // Resolve every managed-file role together in one deterministic pass.
func resolveManagedCrackFileReferences(ctx context.Context, command *clientpb.CrackCommand, list crackFilesListFunc) error {
if command == nil {
return fmt.Errorf("missing crack command")
}
if list == nil {
return fmt.Errorf("missing crack file lister")
}
wordlistIndexes := make([]int, 0, len(command.PositionalArguments))
wordlistInputs := make([]string, 0, len(command.PositionalArguments))
for index, argument := range command.PositionalArguments {
// Only wordlist-role operands may be resolved by alias. An inline mask can
// legitimately have the same text as a managed file name and must remain
// an inline mask. Explicit crackfile URIs are still validated here so a
// mistyped managed reference fails at the client boundary.
if !crackOperandIsWordlist(command.AttackMode, index) && !strings.HasPrefix(argument, crackFileScheme) {
continue
}
wordlistIndexes = append(wordlistIndexes, index)
wordlistInputs = append(wordlistInputs, argument)
}
if len(wordlistInputs) > 0 {
resolved, err := resolveCrackFileValues(ctx, wordlistInputs, clientpb.CrackFileType_WORDLIST, list)
if err != nil {
return err
}
for index, argument := range resolved {
command.PositionalArguments[wordlistIndexes[index]] = argument
}
}
legacyIdentify := command.Identify //nolint:staticcheck // Identify remains required for legacy wire compatibility.
if len(command.PositionalArguments) == 0 && legacyIdentify != "" &&
(crackOperandIsWordlist(command.AttackMode, 0) || strings.HasPrefix(legacyIdentify, crackFileScheme)) {
resolved, err := resolveCrackFileValues(ctx, []string{legacyIdentify}, clientpb.CrackFileType_WORDLIST, list)
if err != nil {
return err
}
command.Identify = resolved[0] //nolint:staticcheck // Keep the resolved legacy operand synchronized for older servers.
}
rules := command.RulesFilesV7
if len(rules) == 0 && len(command.RulesFile) > 0 {
rules = [][]byte{command.RulesFile}
}
if len(rules) > 0 {
values := make([]string, 0, len(rules))
for _, rule := range rules {
values = append(values, string(rule))
}
resolved, err := resolveCrackFileValues(ctx, values, clientpb.CrackFileType_RULES, list)
if err != nil {
return err
}
command.RulesFilesV7 = make([][]byte, 0, len(resolved))
for _, rule := range resolved {
command.RulesFilesV7 = append(command.RulesFilesV7, []byte(rule))
}
command.RulesFile = append([]byte(nil), command.RulesFilesV7[0]...)
}
if len(command.MarkovHcstat2) > 0 {
resolved, err := resolveCrackFileValues(ctx, []string{string(command.MarkovHcstat2)}, clientpb.CrackFileType_MARKOV_HCSTAT2, list)
if err != nil {
return err
}
command.MarkovHcstat2 = []byte(resolved[0])
}
return nil
}
func crackOperandIsWordlist(attackMode clientpb.CrackAttackMode, index int) bool {
switch attackMode {
case clientpb.CrackAttackMode_STRAIGHT, clientpb.CrackAttackMode_COMBINATION:
return true
case clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK:
return index == 0
case clientpb.CrackAttackMode_HYBRID_MASK_WORDLIST:
return index == 1
default:
return false
}
}
//nolint:gocyclo // Validation and alias disambiguation intentionally share the manifest lookup and preserve input ordering.
func resolveCrackFileValues(ctx context.Context, values []string, fileType clientpb.CrackFileType, list crackFilesListFunc) ([]string, error) {
needsManifest := false
for _, value := range values {
if strings.HasPrefix(value, crackFileScheme) {
if err := validateCrackFileURI(value, fileType); err != nil {
return nil, err
}
continue
}
needsManifest = true
}
if !needsManifest {
return append([]string(nil), values...), nil
}
manifest, err := list(ctx, &clientpb.CrackFile{Type: fileType})
if err != nil {
return nil, fmt.Errorf("list managed %s files: %w", crackFileTypeToken(fileType), err)
}
files := []*clientpb.CrackFile(nil)
if manifest != nil {
files = manifest.Files
}
resolved := make([]string, 0, len(values))
for _, value := range values {
if strings.HasPrefix(value, crackFileScheme) {
resolved = append(resolved, value)
continue
}
matches := make([]*clientpb.CrackFile, 0, 1)
for _, file := range files {
if file == nil || file.Type != fileType {
continue
}
if value == file.Name || value == file.ID || strings.EqualFold(value, file.Sha2_256) {
matches = append(matches, file)
}
}
if len(matches) > 1 {
return nil, fmt.Errorf("managed %s file reference %q is ambiguous", crackFileTypeToken(fileType), value)
}
if len(matches) == 0 {
resolved = append(resolved, value)
continue
}
uri, err := crackFileURI(matches[0])
if err != nil {
return nil, err
}
resolved = append(resolved, uri)
}
return resolved, nil
}
func crackFileURI(file *clientpb.CrackFile) (string, error) {
if file == nil {
return "", fmt.Errorf("missing managed crack file")
}
digest := strings.ToLower(strings.TrimSpace(file.Sha2_256))
if err := validateCrackFileDigest(digest); err != nil {
return "", fmt.Errorf("managed crack file %q has invalid SHA-256: %w", file.Name, err)
}
fileType := crackFileTypeToken(file.Type)
if fileType == "" {
return "", fmt.Errorf("managed crack file %q has unsupported type %s", file.Name, file.Type.String())
}
return crackFileScheme + fileType + "/" + digest, nil
}
func validateCrackFileURI(value string, expectedType clientpb.CrackFileType) error {
remainder := strings.TrimPrefix(value, crackFileScheme)
parts := strings.Split(remainder, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return fmt.Errorf("invalid managed crack file URI %q", value)
}
if parts[0] != crackFileTypeToken(expectedType) {
return fmt.Errorf("managed crack file URI %q has type %q, expected %q", value, parts[0], crackFileTypeToken(expectedType))
}
if parts[1] != strings.ToLower(parts[1]) {
return fmt.Errorf("managed crack file URI %q must use a lowercase SHA-256", value)
}
if err := validateCrackFileDigest(parts[1]); err != nil {
return fmt.Errorf("invalid managed crack file URI %q: %w", value, err)
}
return nil
}
func validateCrackFileDigest(value string) error {
if len(value) != 64 {
return fmt.Errorf("SHA-256 must contain 64 hexadecimal characters")
}
decoded, err := hex.DecodeString(value)
if err != nil || len(decoded) != 32 {
return fmt.Errorf("SHA-256 must contain 64 hexadecimal characters")
}
return nil
}
func crackFileTypeToken(fileType clientpb.CrackFileType) string {
switch fileType {
case clientpb.CrackFileType_WORDLIST:
return "wordlist"
case clientpb.CrackFileType_RULES:
return "rules"
case clientpb.CrackFileType_MARKOV_HCSTAT2:
return "hcstat2"
default:
return ""
}
}
@@ -0,0 +1,190 @@
package crack
import (
"context"
"errors"
"slices"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestResolveManagedCrackFileReferences(t *testing.T) {
wordlistDigest := strings.Repeat("a", 64)
rulesDigest := strings.Repeat("b", 64)
hcstat2Digest := strings.Repeat("c", 64)
files := map[clientpb.CrackFileType][]*clientpb.CrackFile{
clientpb.CrackFileType_WORDLIST: {{ID: "wordlist-id", Name: "rockyou.txt", Sha2_256: wordlistDigest, Type: clientpb.CrackFileType_WORDLIST}},
clientpb.CrackFileType_RULES: {{ID: "rules-id", Name: "best64.rule", Sha2_256: rulesDigest, Type: clientpb.CrackFileType_RULES}},
clientpb.CrackFileType_MARKOV_HCSTAT2: {{
ID: "hcstat2-id", Name: "hashcat.hcstat2", Sha2_256: hcstat2Digest, Type: clientpb.CrackFileType_MARKOV_HCSTAT2,
}},
}
requested := make([]clientpb.CrackFileType, 0)
list := func(_ context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
requested = append(requested, request.Type)
return &clientpb.CrackFiles{Files: files[request.Type]}, nil
}
command := &clientpb.CrackCommand{
AttackMode: clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK,
PositionalArguments: []string{"rockyou.txt", "?d?d?d"},
RulesFile: []byte("best64.rule"),
RulesFilesV7: [][]byte{[]byte("best64.rule"), []byte(":")},
MarkovHcstat2: []byte("hcstat2-id"),
}
if err := resolveManagedCrackFileReferences(context.Background(), command, list); err != nil {
t.Fatalf("resolveManagedCrackFileReferences: %v", err)
}
if want := []string{"crackfile://wordlist/" + wordlistDigest, "?d?d?d"}; !slices.Equal(command.PositionalArguments, want) {
t.Fatalf("PositionalArguments = %#v, want %#v", command.PositionalArguments, want)
}
wantRules := [][]byte{[]byte("crackfile://rules/" + rulesDigest), []byte(":")}
if !slices.EqualFunc(command.RulesFilesV7, wantRules, func(left, right []byte) bool { return slices.Equal(left, right) }) {
t.Fatalf("RulesFilesV7 = %#v, want %#v", command.RulesFilesV7, wantRules)
}
if !slices.Equal(command.RulesFile, wantRules[0]) {
t.Fatalf("RulesFile = %q", command.RulesFile)
}
if want := "crackfile://hcstat2/" + hcstat2Digest; string(command.MarkovHcstat2) != want {
t.Fatalf("MarkovHcstat2 = %q, want %q", command.MarkovHcstat2, want)
}
if want := []clientpb.CrackFileType{clientpb.CrackFileType_WORDLIST, clientpb.CrackFileType_RULES, clientpb.CrackFileType_MARKOV_HCSTAT2}; !slices.Equal(requested, want) {
t.Fatalf("requested types = %#v, want %#v", requested, want)
}
}
func TestResolveManagedCrackFileReferencesRespectsAttackOperandRoles(t *testing.T) {
digestA := strings.Repeat("a", 64)
digestB := strings.Repeat("b", 64)
files := []*clientpb.CrackFile{
{ID: "wordlist-a", Name: "first", Sha2_256: digestA, Type: clientpb.CrackFileType_WORDLIST},
{ID: "wordlist-b", Name: "second", Sha2_256: digestB, Type: clientpb.CrackFileType_WORDLIST},
}
list := func(_ context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
if request.Type != clientpb.CrackFileType_WORDLIST {
t.Fatalf("requested file type = %s, want WORDLIST", request.Type)
}
return &clientpb.CrackFiles{Files: files}, nil
}
tests := []struct {
name string
attackMode clientpb.CrackAttackMode
inputs []string
want []string
}{
{
name: "bruteforce mask collision",
attackMode: clientpb.CrackAttackMode_BRUTEFORCE,
inputs: []string{"first"},
want: []string{"first"},
},
{
name: "wordlist mask hybrid",
attackMode: clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK,
inputs: []string{"first", "second"},
want: []string{"crackfile://wordlist/" + digestA, "second"},
},
{
name: "mask wordlist hybrid",
attackMode: clientpb.CrackAttackMode_HYBRID_MASK_WORDLIST,
inputs: []string{"first", "second"},
want: []string{"first", "crackfile://wordlist/" + digestB},
},
{
name: "straight wordlists",
attackMode: clientpb.CrackAttackMode_STRAIGHT,
inputs: []string{"first", "second"},
want: []string{"crackfile://wordlist/" + digestA, "crackfile://wordlist/" + digestB},
},
{
name: "combination wordlists",
attackMode: clientpb.CrackAttackMode_COMBINATION,
inputs: []string{"first", "second"},
want: []string{"crackfile://wordlist/" + digestA, "crackfile://wordlist/" + digestB},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
command := &clientpb.CrackCommand{
AttackMode: test.attackMode,
PositionalArguments: append([]string(nil), test.inputs...),
}
if err := resolveManagedCrackFileReferences(context.Background(), command, list); err != nil {
t.Fatalf("resolveManagedCrackFileReferences: %v", err)
}
if !slices.Equal(command.PositionalArguments, test.want) {
t.Fatalf("PositionalArguments = %#v, want %#v", command.PositionalArguments, test.want)
}
})
}
}
func TestResolveManagedCrackFileReferencesPreservesInlineBytes(t *testing.T) {
inline := []byte{0, 1, 2, 0xff}
command := &clientpb.CrackCommand{RulesFilesV7: [][]byte{append([]byte(nil), inline...)}}
list := func(_ context.Context, _ *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return &clientpb.CrackFiles{}, nil
}
if err := resolveManagedCrackFileReferences(context.Background(), command, list); err != nil {
t.Fatal(err)
}
if len(command.RulesFilesV7) != 1 || !slices.Equal(command.RulesFilesV7[0], inline) {
t.Fatalf("inline rules changed: %#v", command.RulesFilesV7)
}
}
func TestResolveManagedCrackFileReferencesAcceptsCanonicalURIWithoutListing(t *testing.T) {
uri := "crackfile://wordlist/" + strings.Repeat("d", 64)
command := &clientpb.CrackCommand{PositionalArguments: []string{uri}}
list := func(_ context.Context, _ *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
t.Fatal("canonical URI should not require a manifest lookup")
return nil, nil
}
if err := resolveManagedCrackFileReferences(context.Background(), command, list); err != nil {
t.Fatal(err)
}
if command.PositionalArguments[0] != uri {
t.Fatalf("URI changed to %q", command.PositionalArguments[0])
}
}
func TestResolveManagedCrackFileReferencesRejectsWrongURIType(t *testing.T) {
command := &clientpb.CrackCommand{RulesFilesV7: [][]byte{[]byte("crackfile://wordlist/" + strings.Repeat("e", 64))}}
err := resolveManagedCrackFileReferences(context.Background(), command, func(context.Context, *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return nil, errors.New("must not be called")
})
if err == nil || !strings.Contains(err.Error(), "expected \"rules\"") {
t.Fatalf("error = %v", err)
}
}
func TestResolveManagedCrackFileReferencesRejectsAmbiguousName(t *testing.T) {
command := &clientpb.CrackCommand{PositionalArguments: []string{"duplicate.txt"}}
list := func(_ context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return &clientpb.CrackFiles{Files: []*clientpb.CrackFile{
{Name: "duplicate.txt", Sha2_256: strings.Repeat("a", 64), Type: request.Type},
{Name: "duplicate.txt", Sha2_256: strings.Repeat("b", 64), Type: request.Type},
}}, nil
}
err := resolveManagedCrackFileReferences(context.Background(), command, list)
if err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("error = %v", err)
}
}
func TestResolveManagedCrackFileReferencesRejectsInvalidDigest(t *testing.T) {
command := &clientpb.CrackCommand{MarkovHcstat2: []byte("broken.hcstat2")}
list := func(_ context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return &clientpb.CrackFiles{Files: []*clientpb.CrackFile{{
Name: "broken.hcstat2", Sha2_256: "not-a-digest", Type: request.Type,
}}}, nil
}
err := resolveManagedCrackFileReferences(context.Background(), command, list)
if err == nil || !strings.Contains(err.Error(), "invalid SHA-256") {
t.Fatalf("error = %v", err)
}
}
+161 -69
View File
@@ -22,18 +22,36 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/util"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/klauspost/compress/zstd"
"github.com/spf13/cobra"
)
func localCrackFileInfo(path string) (os.FileInfo, error) {
return localCrackFileInfoWith(path, os.Stat)
}
func localCrackFileInfoWith(path string, stat func(string) (os.FileInfo, error)) (os.FileInfo, error) {
info, err := stat(path)
if err != nil {
return nil, fmt.Errorf("stat local crack file %q: %w", path, err)
}
if info.IsDir() {
return nil, fmt.Errorf("local crack file %q is a directory", path)
}
return info, nil
}
// CrackWordlistsCmd - Manage GPU cracking stations.
func CrackWordlistsCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
wordlists, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_WORDLIST})
@@ -173,9 +191,9 @@ func CrackWordlistsAddCmd(cmd *cobra.Command, con *console.SliverClient, args []
con.PrintErrorf("No path specified, see --help\n")
return
}
wordlistStat, err := os.Stat(localPath)
if os.IsNotExist(err) || wordlistStat.IsDir() {
con.PrintErrorf("File does not exist: %s\n", localPath)
wordlistStat, err := localCrackFileInfo(localPath)
if err != nil {
con.PrintErrorf("Invalid wordlist file: %s\n", err)
return
}
if name == "" {
@@ -217,9 +235,9 @@ func CrackRulesAddCmd(cmd *cobra.Command, con *console.SliverClient, args []stri
con.PrintErrorf("No path specified, see --help\n")
return
}
rulesStat, err := os.Stat(localPath)
if os.IsNotExist(err) || rulesStat.IsDir() {
con.PrintErrorf("File does not exist: %s\n", localPath)
rulesStat, err := localCrackFileInfo(localPath)
if err != nil {
con.PrintErrorf("Invalid rules file: %s\n", err)
return
}
if name == "" {
@@ -261,9 +279,9 @@ func CrackHcstat2AddCmd(cmd *cobra.Command, con *console.SliverClient, args []st
con.PrintErrorf("No path specified, see --help\n")
return
}
hcstat2Stat, err := os.Stat(localPath)
if os.IsNotExist(err) || hcstat2Stat.IsDir() {
con.PrintErrorf("File does not exist: %s\n", localPath)
hcstat2Stat, err := localCrackFileInfo(localPath)
if err != nil {
con.PrintErrorf("Invalid hcstat2 file: %s\n", err)
return
}
if name == "" {
@@ -294,102 +312,176 @@ func CrackHcstat2AddCmd(cmd *cobra.Command, con *console.SliverClient, args []st
}
func addCrackFile(localFile *os.File, crackFile *clientpb.CrackFile, con *console.SliverClient) {
digest := sha256.New()
wordlistReader := io.TeeReader(localFile, digest)
chunks := make(chan []byte, 1) // Chunks are in-memory!
var chunkReaderErr error
go func() {
chunkReaderErr = chunkReader(wordlistReader, crackFile.ChunkSize, chunks)
}()
n := uint32(0)
total := int64(0)
errors := []error{}
for chunk := range chunks {
total += int64(len(chunk))
total, err := uploadCrackFile(localFile, crackFile, con.Rpc, func(total int64, n uint32, chunkSize int) {
con.PrintInfof("Uploading %s (chunk %d - %s) ...",
util.ByteCountBinary(total),
n,
util.ByteCountBinary(int64(len(chunk))),
util.ByteCountBinary(int64(chunkSize)),
)
_, err := con.Rpc.CrackFileChunkUpload(context.Background(), &clientpb.CrackFileChunk{
CrackFileID: crackFile.ID,
N: n,
Data: chunk,
})
n++
if err != nil {
errors = append(errors, err)
continue
}
}
con.Printf(console.Clearln + "\r")
if chunkReaderErr != nil {
con.PrintErrorf("Failed to read file: %s\n", chunkReaderErr)
return
}
if len(errors) > 0 {
for _, err := range errors {
con.PrintErrorf("Failed to upload chunk: %s\n", err)
}
return
}
_, err := con.Rpc.CrackFileComplete(context.Background(), &clientpb.CrackFile{
ID: crackFile.ID,
Sha2_256: hex.EncodeToString(digest.Sum(nil)),
})
con.Printf(console.Clearln + "\r")
if err != nil {
con.PrintErrorf("Failed to complete file upload: %s\n", err)
con.PrintErrorf("Failed to upload crack file: %s\n", err)
return
}
con.PrintInfof("Upload completed (compressed: %s)\n", util.ByteCountBinary(total))
}
func chunkReader(wordlistReader io.Reader, chunkSize int64, chunks chan []byte) error {
func uploadCrackFile(localFile io.Reader, crackFile *clientpb.CrackFile, rpc rpcpb.SliverRPCClient, progress func(total int64, n uint32, chunkSize int)) (total int64, retErr error) {
if localFile == nil {
return 0, errors.New("local crack file reader is nil")
}
if crackFile == nil || crackFile.ID == "" {
return 0, errors.New("crack file reservation is missing an ID")
}
if rpc == nil {
return 0, errors.New("crack file RPC client is nil")
}
completed := false
defer func() {
if retErr == nil || completed {
return
}
_, cleanupErr := rpc.CrackFileDelete(context.Background(), &clientpb.CrackFile{ID: crackFile.ID})
if cleanupErr != nil {
retErr = errors.Join(retErr, fmt.Errorf("failed to remove crack file reservation %s: %w", crackFile.ID, cleanupErr))
}
}()
digest := sha256.New()
wordlistReader := io.TeeReader(localFile, digest)
chunks, chunkReaderErrors := readCrackFileChunks(wordlistReader, crackFile.ChunkSize)
n := uint32(0)
var uploadErr error
for chunk := range chunks {
total += int64(len(chunk))
if uploadErr == nil {
if progress != nil {
progress(total, n, len(chunk))
}
_, err := rpc.CrackFileChunkUpload(context.Background(), &clientpb.CrackFileChunk{
CrackFileID: crackFile.ID,
N: n,
Data: chunk,
})
if err != nil {
uploadErr = fmt.Errorf("upload chunk %d: %w", n, err)
}
}
n++
}
chunkReaderErr := <-chunkReaderErrors
if chunkReaderErr != nil {
uploadErr = errors.Join(uploadErr, fmt.Errorf("read and compress crack file: %w", chunkReaderErr))
}
if uploadErr != nil {
return total, uploadErr
}
_, err := rpc.CrackFileComplete(context.Background(), &clientpb.CrackFile{
ID: crackFile.ID,
Sha2_256: hex.EncodeToString(digest.Sum(nil)),
})
if err != nil {
return total, fmt.Errorf("complete crack file upload: %w", err)
}
completed = true
return total, nil
}
func readCrackFileChunks(reader io.Reader, chunkSize int64) (<-chan []byte, <-chan error) {
chunks := make(chan []byte, 1) // Chunks are in-memory!
errors := make(chan error, 1)
go func() {
defer close(errors)
errors <- chunkReader(reader, chunkSize, chunks)
}()
return chunks, errors
}
//nolint:gocyclo // Compression, fixed-size emission, final remainder handling, and cleanup form one streaming state machine.
func chunkReader(wordlistReader io.Reader, chunkSize int64, chunks chan []byte) (retErr error) {
defer close(chunks)
if chunkSize < 1 {
return fmt.Errorf("invalid crack file chunk size %d", chunkSize)
}
start := int64(0)
tmpFile, err := os.CreateTemp("", "sliver-wordlist")
if err != nil {
return err
}
defer tmpFile.Close()
compressor, _ := zstd.NewWriter(tmpFile, zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
for {
readBuf := make([]byte, 32*1024*1024) // 32MB
readN, readErr := wordlistReader.Read(readBuf)
if readErr != nil && readErr != io.EOF {
return err
tmpPath := tmpFile.Name()
defer func() {
if err := tmpFile.Close(); retErr == nil && err != nil {
retErr = err
}
_ = os.Remove(tmpPath)
}()
compressor, err := zstd.NewWriter(tmpFile, zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
if err != nil {
return err
}
compressorClosed := false
defer func() {
if !compressorClosed {
_ = compressor.Close()
}
}()
readBuf := make([]byte, 32*1024*1024) // 32MB
for {
readN, readErr := wordlistReader.Read(readBuf)
if readN != 0 {
_, err := compressor.Write(readBuf[:readN])
if err != nil {
return err
}
compressor.Flush()
tmpFileStat, err := os.Stat(tmpFile.Name())
if err := compressor.Flush(); err != nil {
return err
}
tmpFileStat, err := tmpFile.Stat()
if err != nil {
return err
}
if tmpFileStat.Size()-start >= chunkSize {
stop := start + chunkSize
for tmpFileStat.Size()-start >= chunkSize {
chunk, err := readChunkAt(tmpFile, start, chunkSize)
if err != nil {
return err
}
chunks <- chunk
start = stop
start += int64(len(chunk))
}
}
if readErr != nil && readErr != io.EOF {
return readErr
}
if readErr == io.EOF {
chunk, err := readChunkAt(tmpFile, start, chunkSize)
if err != nil {
return err
}
chunks <- chunk
return nil
break
}
}
if err := compressor.Close(); err != nil {
return err
}
compressorClosed = true
tmpFileStat, err := tmpFile.Stat()
if err != nil {
return err
}
for start < tmpFileStat.Size() {
remaining := tmpFileStat.Size() - start
readSize := chunkSize
if remaining < readSize {
readSize = remaining
}
chunk, err := readChunkAt(tmpFile, start, readSize)
if err != nil {
return err
}
chunks <- chunk
start += int64(len(chunk))
}
return nil
}
func readChunkAt(tmpFile *os.File, offset int64, chunkSize int64) ([]byte, error) {
+372
View File
@@ -0,0 +1,372 @@
package crack
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"math/rand"
"os"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/klauspost/compress/zstd"
"google.golang.org/grpc"
)
func TestLocalCrackFileInfoHandlesStatErrors(t *testing.T) {
wantErr := errors.New("permission denied")
info, err := localCrackFileInfoWith("/restricted/file", func(string) (os.FileInfo, error) {
return nil, wantErr
})
if info != nil {
t.Fatalf("localCrackFileInfoWith info = %#v, want nil", info)
}
if !errors.Is(err, wantErr) {
t.Fatalf("localCrackFileInfoWith error = %v, want wrapped %v", err, wantErr)
}
if !strings.Contains(err.Error(), "/restricted/file") {
t.Fatalf("localCrackFileInfoWith error = %q, want path context", err)
}
}
func TestLocalCrackFileInfoRejectsDirectories(t *testing.T) {
_, err := localCrackFileInfo(t.TempDir())
if err == nil || !strings.Contains(err.Error(), "is a directory") {
t.Fatalf("localCrackFileInfo directory error = %v, want directory error", err)
}
}
func TestLocalCrackFileInfoAcceptsRegularFile(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "crack-file-")
if err != nil {
t.Fatalf("create local crack file: %v", err)
}
const payload = "hashcat input"
if _, err := file.WriteString(payload); err != nil {
t.Fatalf("write local crack file: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close local crack file: %v", err)
}
info, err := localCrackFileInfo(file.Name())
if err != nil {
t.Fatalf("localCrackFileInfo: %v", err)
}
if info.Size() != int64(len(payload)) {
t.Fatalf("localCrackFileInfo size = %d, want %d", info.Size(), len(payload))
}
}
func TestUploadCrackFileCleansUpPostCreateFailures(t *testing.T) {
readErr := errors.New("read failed")
chunkErr := errors.New("chunk upload failed")
completeErr := errors.New("complete failed")
tests := []struct {
name string
reader io.Reader
configureRPC func(*fakeCrackFileUploadRPC)
wantErr error
wantChunkCalls int
wantCompleteCalls int
}{
{
name: "read failure",
reader: errorReader{err: readErr},
wantErr: readErr,
},
{
name: "chunk upload failure",
reader: bytes.NewReader([]byte("payload")),
configureRPC: func(rpc *fakeCrackFileUploadRPC) {
rpc.chunkErr = chunkErr
},
wantErr: chunkErr,
wantChunkCalls: 1,
},
{
name: "completion failure",
reader: bytes.NewReader([]byte("payload")),
configureRPC: func(rpc *fakeCrackFileUploadRPC) {
rpc.completeErr = completeErr
},
wantErr: completeErr,
wantChunkCalls: 1,
wantCompleteCalls: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rpc := &fakeCrackFileUploadRPC{}
if test.configureRPC != nil {
test.configureRPC(rpc)
}
reservation := &clientpb.CrackFile{ID: "reserved-file", ChunkSize: 1024}
_, err := uploadCrackFile(test.reader, reservation, rpc, nil)
if !errors.Is(err, test.wantErr) {
t.Fatalf("uploadCrackFile error = %v, want wrapped %v", err, test.wantErr)
}
if rpc.chunkCalls != test.wantChunkCalls {
t.Fatalf("CrackFileChunkUpload calls = %d, want %d", rpc.chunkCalls, test.wantChunkCalls)
}
if rpc.completeCalls != test.wantCompleteCalls {
t.Fatalf("CrackFileComplete calls = %d, want %d", rpc.completeCalls, test.wantCompleteCalls)
}
if rpc.deleteCalls != 1 {
t.Fatalf("CrackFileDelete calls = %d, want 1", rpc.deleteCalls)
}
if rpc.deleted == nil || rpc.deleted.ID != reservation.ID {
t.Fatalf("CrackFileDelete request = %#v, want ID %q", rpc.deleted, reservation.ID)
}
})
}
}
func TestUploadCrackFileReportsCleanupFailure(t *testing.T) {
uploadErr := errors.New("chunk upload failed")
cleanupErr := errors.New("delete failed")
rpc := &fakeCrackFileUploadRPC{chunkErr: uploadErr, deleteErr: cleanupErr}
reservation := &clientpb.CrackFile{ID: "reserved-file", ChunkSize: 1024}
_, err := uploadCrackFile(bytes.NewReader([]byte("payload")), reservation, rpc, nil)
if !errors.Is(err, uploadErr) {
t.Fatalf("uploadCrackFile error = %v, want wrapped upload error %v", err, uploadErr)
}
if !errors.Is(err, cleanupErr) {
t.Fatalf("uploadCrackFile error = %v, want wrapped cleanup error %v", err, cleanupErr)
}
if !strings.Contains(err.Error(), reservation.ID) {
t.Fatalf("uploadCrackFile error = %q, want reservation ID", err)
}
if rpc.deleteCalls != 1 {
t.Fatalf("CrackFileDelete calls = %d, want 1", rpc.deleteCalls)
}
}
func TestUploadCrackFileSuccessCompletesWithoutCleanup(t *testing.T) {
payload := []byte("correct horse battery staple\n")
rpc := &fakeCrackFileUploadRPC{}
reservation := &clientpb.CrackFile{ID: "reserved-file", ChunkSize: 8}
progressCalls := 0
total, err := uploadCrackFile(bytes.NewReader(payload), reservation, rpc, func(int64, uint32, int) {
progressCalls++
})
if err != nil {
t.Fatalf("uploadCrackFile: %v", err)
}
if total == 0 {
t.Fatal("uploadCrackFile compressed byte count = 0, want non-zero")
}
if rpc.chunkCalls == 0 {
t.Fatal("CrackFileChunkUpload calls = 0, want at least 1")
}
if progressCalls != rpc.chunkCalls {
t.Fatalf("progress calls = %d, want %d", progressCalls, rpc.chunkCalls)
}
if rpc.completeCalls != 1 {
t.Fatalf("CrackFileComplete calls = %d, want 1", rpc.completeCalls)
}
if rpc.deleteCalls != 0 {
t.Fatalf("CrackFileDelete calls = %d, want 0", rpc.deleteCalls)
}
wantDigest := sha256.Sum256(payload)
if rpc.completed == nil || rpc.completed.ID != reservation.ID {
t.Fatalf("CrackFileComplete request = %#v, want ID %q", rpc.completed, reservation.ID)
}
if rpc.completed.Sha2_256 != hex.EncodeToString(wantDigest[:]) {
t.Fatalf("CrackFileComplete digest = %q, want %q", rpc.completed.Sha2_256, hex.EncodeToString(wantDigest[:]))
}
}
func TestChunkReaderZstdRoundTrip(t *testing.T) {
largePayload := make([]byte, 256*1024)
if _, err := rand.New(rand.NewSource(42)).Read(largePayload); err != nil {
t.Fatalf("create test payload: %v", err)
}
tests := []struct {
name string
payload []byte
chunkSize int64
wantMinChunks int
}{
{
name: "single chunk",
payload: []byte("correct horse battery staple\n"),
chunkSize: 1024,
wantMinChunks: 1,
},
{
name: "many chunks from one read",
payload: largePayload,
chunkSize: 1024,
wantMinChunks: 3,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
compressed, chunkCount, err := collectCompressedCrackFile(bytes.NewReader(test.payload), test.chunkSize)
if err != nil {
t.Fatalf("chunkReader: %v", err)
}
if chunkCount < test.wantMinChunks {
t.Fatalf("chunk count = %d, want at least %d", chunkCount, test.wantMinChunks)
}
decoder, err := zstd.NewReader(bytes.NewReader(compressed))
if err != nil {
t.Fatalf("open zstd stream: %v", err)
}
decoded, err := io.ReadAll(decoder)
decoder.Close()
if err != nil {
t.Fatalf("decode zstd stream: %v", err)
}
if !bytes.Equal(decoded, test.payload) {
t.Fatalf("decoded payload does not match input: got %d bytes, want %d", len(decoded), len(test.payload))
}
})
}
}
func TestChunkReaderHandlesDataWithEOF(t *testing.T) {
payload := []byte("reader returns data and EOF together")
compressed, _, err := collectCompressedCrackFile(&dataEOFReader{data: payload}, 8)
if err != nil {
t.Fatalf("chunkReader: %v", err)
}
decoder, err := zstd.NewReader(bytes.NewReader(compressed))
if err != nil {
t.Fatalf("open zstd stream: %v", err)
}
decoded, err := io.ReadAll(decoder)
decoder.Close()
if err != nil {
t.Fatalf("decode zstd stream: %v", err)
}
if !bytes.Equal(decoded, payload) {
t.Fatalf("decoded payload = %q, want %q", decoded, payload)
}
}
func TestChunkReaderRejectsInvalidChunkSize(t *testing.T) {
for _, chunkSize := range []int64{0, -1} {
compressed, chunkCount, err := collectCompressedCrackFile(bytes.NewReader([]byte("payload")), chunkSize)
if err == nil {
t.Fatalf("chunkReader accepted chunk size %d", chunkSize)
}
if chunkCount != 0 || len(compressed) != 0 {
t.Fatalf("invalid chunk size %d emitted %d chunks (%d bytes)", chunkSize, chunkCount, len(compressed))
}
}
}
func TestChunkReaderPropagatesReadError(t *testing.T) {
wantErr := errors.New("read failed")
compressed, chunkCount, err := collectCompressedCrackFile(errorReader{err: wantErr}, 1024)
if !errors.Is(err, wantErr) {
t.Fatalf("chunkReader error = %v, want %v", err, wantErr)
}
if chunkCount != 0 || len(compressed) != 0 {
t.Fatalf("failed read emitted %d chunks (%d bytes)", chunkCount, len(compressed))
}
}
func collectCompressedCrackFile(reader io.Reader, chunkSize int64) ([]byte, int, error) {
chunks, errCh := readCrackFileChunks(reader, chunkSize)
compressed := bytes.Buffer{}
chunkCount := 0
invalidChunk := false
for chunk := range chunks {
if len(chunk) == 0 || int64(len(chunk)) > chunkSize {
invalidChunk = true
}
_, _ = compressed.Write(chunk)
chunkCount++
}
readerErr := <-errCh
if invalidChunk {
return nil, chunkCount, errors.New("chunkReader emitted an invalid chunk size")
}
return compressed.Bytes(), chunkCount, readerErr
}
type dataEOFReader struct {
data []byte
done bool
}
func (r *dataEOFReader) Read(buffer []byte) (int, error) {
if r.done {
return 0, io.EOF
}
r.done = true
return copy(buffer, r.data), io.EOF
}
type errorReader struct {
err error
}
func (r errorReader) Read([]byte) (int, error) {
return 0, r.err
}
type fakeCrackFileUploadRPC struct {
rpcpb.SliverRPCClient
chunkErr error
completeErr error
deleteErr error
chunkCalls int
completeCalls int
deleteCalls int
uploaded []*clientpb.CrackFileChunk
completed *clientpb.CrackFile
deleted *clientpb.CrackFile
}
func (f *fakeCrackFileUploadRPC) CrackFileChunkUpload(_ context.Context, request *clientpb.CrackFileChunk, _ ...grpc.CallOption) (*commonpb.Empty, error) {
f.chunkCalls++
f.uploaded = append(f.uploaded, &clientpb.CrackFileChunk{
CrackFileID: request.CrackFileID,
N: request.N,
Data: append([]byte(nil), request.Data...),
})
if f.chunkErr != nil {
return nil, f.chunkErr
}
return &commonpb.Empty{}, nil
}
func (f *fakeCrackFileUploadRPC) CrackFileComplete(_ context.Context, request *clientpb.CrackFile, _ ...grpc.CallOption) (*commonpb.Empty, error) {
f.completeCalls++
f.completed = &clientpb.CrackFile{ID: request.ID, Sha2_256: request.Sha2_256}
if f.completeErr != nil {
return nil, f.completeErr
}
return &commonpb.Empty{}, nil
}
func (f *fakeCrackFileUploadRPC) CrackFileDelete(_ context.Context, request *clientpb.CrackFile, _ ...grpc.CallOption) (*commonpb.Empty, error) {
f.deleteCalls++
f.deleted = &clientpb.CrackFile{ID: request.ID}
if f.deleteErr != nil {
return nil, f.deleteErr
}
return &commonpb.Empty{}, nil
}
+75 -17
View File
@@ -3,15 +3,22 @@ package crack
import "github.com/spf13/pflag"
func bindCrackFlags(f *pflag.FlagSet) {
f.String("attack-mode", "", "crack attack mode (straight, combination, bruteforce, hybrid-wordlist-mask, hybrid-mask-wordlist, association, no-attack, or numeric)")
f.String("hash-type", "", "hashcat hash type (number or enum name)")
f.StringSlice("hash", nil, "hashes to crack (repeatable)")
f.StringP("attack-mode", "a", "", "crack attack mode (straight, combination, bruteforce, pcfg, hybrid-wordlist-mask, hybrid-mask-wordlist, generic, association, no-attack, hybrid, or numeric)")
f.StringP("hash-type", "m", "", "hashcat hash type (number or enum name)")
f.Uint32("hash-mode", 0, "canonical numeric hashcat mode (overrides hash-type)")
f.StringArray("hash", nil, "hashes to crack (repeatable)")
f.StringArray("credential", nil, "server credential ID or ID prefix to crack (repeatable)")
f.String("credential-collection", "", "server credential collection to crack")
f.Bool("include-cracked-credentials", false, "include credentials already marked as cracked")
f.StringArray("input", nil, "ordered positional hashcat inputs (repeatable)")
f.String("stdin", "", "data supplied to hashcat standard input")
f.Bool("quiet", false, "quiet mode")
f.Bool("hex-charset", false, "treat charset as hex")
f.Bool("hex-salt", false, "treat salt as hex")
f.Bool("hex-wordlist", false, "treat wordlist as hex")
f.Bool("force", false, "ignore warnings")
f.Bool("deprecated-check-disable", false, "disable deprecated check")
f.Bool("advice-disable", false, "disable hashcat advice")
f.Bool("status", false, "enable status")
f.Bool("status-json", false, "emit status in JSON")
f.Uint32("status-timer", 0, "status timer interval (seconds)")
@@ -30,12 +37,15 @@ func bindCrackFlags(f *pflag.FlagSet) {
f.Bool("restore", false, "restore session")
f.Bool("restore-disable", false, "disable restore")
f.String("restore-file", "", "restore file path or identifier")
f.Bool("restore-position", false, "restore from the stored position")
f.Bool("restore-show-command", false, "display the stored restore command and stop")
f.String("outfile", "", "outfile path or identifier")
f.StringSlice("outfile-format", nil, "outfile formats (hash-salt, plain, hex-plain, crack-pos, timestamp-absolute, timestamp-relative, or numeric)")
f.Bool("outfile-json", false, "write outfile records as JSON")
f.Bool("outfile-autohex-disable", false, "disable outfile autohex")
f.Uint32("outfile-check-timer", 0, "outfile check timer interval (seconds)")
f.Bool("wordlist-autohex-disable", false, "disable wordlist autohex")
f.String("separator", "", "separator character")
f.Bool("stdout", false, "stdout output")
f.Bool("show", false, "show cracked hashes")
f.Bool("left", false, "show uncracked hashes")
f.Bool("username", false, "enable username handling")
@@ -43,33 +53,52 @@ func bindCrackFlags(f *pflag.FlagSet) {
f.Uint32("remove-timer", 0, "remove timer interval (seconds)")
f.Bool("potfile-disable", false, "disable potfile")
f.String("potfile", "", "potfile path or identifier")
f.String("encoding-from", "", "input encoding (iso-8859-15, utf-32le, or numeric)")
f.String("encoding-to", "", "output encoding (iso-8859-15, utf-32le, or numeric)")
f.String("encoding-from", "", "input encoding name (legacy enum names and numbers are also accepted)")
f.String("encoding-to", "", "output encoding name (legacy enum names and numbers are also accepted)")
f.Uint32("debug-mode", 0, "debug mode mask")
f.String("debug-file", "", "debug file path or identifier")
f.String("induction-dir", "", "induction directory path or identifier")
f.String("outfile-check-dir", "", "outfile-check directory path or identifier")
f.Bool("logfile-disable", false, "disable logfile")
f.Uint32("hccapx-message-pair", 0, "hccapx message pair")
f.Uint32("nonce-error-corrections", 0, "nonce error corrections")
f.String("keyboard-layout-mapping", "", "keyboard layout mapping file or identifier")
f.String("keyboard-layout-mapping", "", "local keyboard layout mapping file")
f.String("truecrypt-keyfiles", "", "TrueCrypt keyfiles path or identifier")
f.String("veracrypt-keyfiles", "", "VeraCrypt keyfiles path or identifier")
f.Uint32("veracrypt-pim-start", 0, "VeraCrypt PIM start")
f.Uint32("veracrypt-pim-stop", 0, "VeraCrypt PIM stop")
f.Bool("benchmark", false, "run benchmark")
f.Bool("benchmark-all", false, "run full benchmark")
f.Uint32("benchmark-min", 0, "minimum benchmark runtime")
f.Uint32("benchmark-max", 0, "maximum benchmark runtime")
f.Bool("speed-only", false, "show speed only")
f.Bool("progress-only", false, "show progress only")
f.Uint32("segment-size", 0, "segment size")
f.Uint32("segment-size", 0, "deprecated: removed in Hashcat v7")
f.Bool("pipeline-stats", false, "show pipeline statistics")
f.Bool("task-time-breakdown", false, "show task time breakdown")
f.Uint32("bitmap-min", 0, "bitmap min")
f.Uint32("bitmap-max", 0, "bitmap max")
f.UintSlice("cpu-affinity", nil, "CPU affinity list")
f.Uint32("hook-threads", 0, "hook threads")
f.Bool("hash-info", false, "show hash information")
f.Bool("hash-info", false, "show hash information from an idle crackstation (does not create a job)")
f.Uint32("hash-info-level", 0, "hash information detail level: 1 or 2 (does not create a job)")
f.Bool("backend-ignore-cuda", false, "ignore CUDA backend")
f.Bool("backend-ignore-hip", false, "ignore HIP backend")
f.Bool("backend-ignore-metal", false, "ignore Metal backend")
f.Bool("backend-ignore-opencl", false, "ignore OpenCL backend")
f.Bool("backend-info", false, "show backend info")
f.Bool("backend-info", false, "show registered backend info for all connected crackstations (does not create a job)")
f.Uint32("backend-info-level", 0, "registered backend information detail level: 1 or 2 (does not create a job)")
f.UintSlice("backend-devices", nil, "backend device IDs")
f.Uint32("backend-devices-virtmulti", 0, "virtual backend device multiplier")
f.Uint32("backend-devices-virthost", 0, "virtual backend host count")
f.UintSlice("opencl-device-types", nil, "OpenCL device types")
f.Uint32("metal-compiler-runtime", 0, "Metal compiler runtime")
f.Bool("optimized-kernel-enable", false, "enable optimized kernels")
f.Bool("multiply-accel-disabled", false, "disable multiply accel")
f.String("workload-profile", "", "workload profile (low, default, high, nightmare, or numeric)")
f.Bool("multiply-accel-disable", false, "disable multiply accel")
f.Bool("multiply-accel-disabled", false, "deprecated alias for multiply-accel-disable")
_ = f.MarkDeprecated("multiply-accel-disabled", "use --multiply-accel-disable")
_ = f.MarkHidden("multiply-accel-disabled")
f.String("workload-profile", "", "deprecated: accepted but ignored by Hashcat v7")
f.Uint32("kernel-accel", 0, "kernel acceleration")
f.Uint32("kernel-loops", 0, "kernel loops")
f.Uint32("kernel-threads", 0, "kernel threads")
@@ -80,22 +109,47 @@ func bindCrackFlags(f *pflag.FlagSet) {
f.Uint32("scrypt-tmto", 0, "scrypt tmto")
f.Uint64("skip", 0, "skip count")
f.Uint64("limit", 0, "limit count")
f.Bool("keyspace", false, "show keyspace")
f.String("rules-file", "", "rules file path or identifier")
f.Bool("keyspace", false, "calculate keyspace on an idle crackstation and return it (does not create a job)")
f.Bool("total-candidates", false, "calculate the total candidate count on an idle crackstation (does not create a job)")
f.String("lookup", "", "locate a candidate in the attack on an idle crackstation (does not create a job)")
f.String("crackstation", "", "target an exact crackstation name or host UUID for a synchronous query")
f.Bool("dynamic-x", false, "enable dynamic-X mode")
f.String("seekdb-path", "", "seek database path or identifier")
f.String("rule-left", "", "left combinator rule")
f.String("rule-right", "", "right combinator rule")
f.StringArray("rules-file", nil, "rules file content or identifier (repeatable)")
f.Uint32("generate-rules", 0, "generate rules")
f.Uint32("generate-rules-fun-min", 0, "generate rules function min")
f.Uint32("generate-rules-fun-max", 0, "generate rules function max")
f.Uint32("generate-rules-func-min", 0, "generate rules function min")
f.Uint32("generate-rules-func-max", 0, "generate rules function max")
f.Uint32("generate-rules-fun-min", 0, "deprecated alias for generate-rules-func-min")
f.Uint32("generate-rules-fun-max", 0, "deprecated alias for generate-rules-func-max")
_ = f.MarkDeprecated("generate-rules-fun-min", "use --generate-rules-func-min")
_ = f.MarkHidden("generate-rules-fun-min")
_ = f.MarkDeprecated("generate-rules-fun-max", "use --generate-rules-func-max")
_ = f.MarkHidden("generate-rules-fun-max")
f.String("generate-rules-func-sel", "", "generate rules function selection")
f.Int32("generate-rules-seed", 0, "generate rules seed")
f.String("custom-charset1", "", "custom charset 1")
f.String("custom-charset2", "", "custom charset 2")
f.String("custom-charset3", "", "custom charset 3")
f.String("custom-charset4", "", "custom charset 4")
f.String("identify", "", "identify hash")
f.String("custom-charset5", "", "custom charset 5")
f.String("custom-charset6", "", "custom charset 6")
f.String("custom-charset7", "", "custom charset 7")
f.String("custom-charset8", "", "custom charset 8")
f.String("identify", "", "deprecated: legacy positional input")
f.Bool("identify-mode", false, "identify hash types on an idle crackstation (does not create a job)")
f.Bool("increment", false, "incremental mode")
f.Bool("increment-inverse", false, "inverse incremental mode")
f.Uint32("increment-min", 0, "increment min")
f.Uint32("increment-max", 0, "increment max")
f.Uint32("bypass-delay", 0, "bypass delay")
f.Uint32("bypass-threshold", 0, "bypass threshold")
f.Bool("slow-candidates", false, "slow candidates")
f.String("bridge-parameter1", "", "bridge parameter 1")
f.String("bridge-parameter2", "", "bridge parameter 2")
f.String("bridge-parameter3", "", "bridge parameter 3")
f.String("bridge-parameter4", "", "bridge parameter 4")
f.Bool("brain-server", false, "brain server mode")
f.Uint32("brain-server-timer", 0, "brain server timer")
f.Bool("brain-client", false, "brain client mode")
@@ -105,4 +159,8 @@ func bindCrackFlags(f *pflag.FlagSet) {
f.String("brain-password", "", "brain password")
f.String("brain-session", "", "brain session")
f.String("brain-session-whitelist", "", "brain session whitelist")
f.Bool("brain-feed", false, "brain feed mode")
f.Bool("color-cracked", false, "color cracked output")
f.Bool("hash-copy", false, "copy hashes")
f.String("encrypt-with-pubkey", "", "public key path or identifier used to encrypt output")
}
@@ -0,0 +1,388 @@
package crack
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/spf13/cobra"
)
func TestCrackJobLifecycleCommandsAreRegisteredAtExactHierarchy(t *testing.T) {
root := Commands(nil)[0]
for _, name := range []string{"cancel", "pause", "resume"} {
t.Run(name, func(t *testing.T) {
command, args, err := root.Find([]string{"job", name, "job-id"})
if err != nil {
t.Fatalf("find crack job %s: %v", name, err)
}
if got, want := command.CommandPath(), "crack job "+name; got != want {
t.Fatalf("command path = %q, want %q", got, want)
}
if !reflect.DeepEqual(args, []string{"job-id"}) {
t.Fatalf("remaining args = %#v, want [job-id]", args)
}
if got, want := command.Use, name+" [id]"; got != want {
t.Fatalf("use = %q, want %q", got, want)
}
if err := command.Args(command, nil); err != nil {
t.Fatalf("interactive selection rejected: %v", err)
}
if err := command.Args(command, []string{"job-id"}); err != nil {
t.Fatalf("one job ID rejected: %v", err)
}
if err := command.Args(command, []string{"first", "second"}); err == nil {
t.Fatal("more than one job ID was accepted")
}
if command.ValidArgsFunction == nil {
t.Fatal("job ID completion is missing")
}
if command.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("--timeout is not inherited")
}
if command.InheritedFlags().Lookup("watch") != nil || command.InheritedFlags().Lookup("poll-interval") != nil {
t.Fatal("crack job view flags leaked into lifecycle command")
}
})
}
}
func TestCrackJobLifecycleCompletionOnlyOffersEligibleFullIDs(t *testing.T) {
activeID := "aaaaaaaa-1111-4111-8111-111111111111"
pausedID := "bbbbbbbb-2222-4222-8222-222222222222"
completedID := "cccccccc-3333-4333-8333-333333333333"
cancelledID := "dddddddd-4444-4444-8444-444444444444"
tests := []struct {
name string
want []string
}{
{name: "cancel", want: []string{activeID, pausedID}},
{name: "pause", want: []string{activeID}},
{name: "resume", want: []string{pausedID}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
capture := &crackJobsListCapture{jobs: &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{
{ID: activeID, Status: clientpb.CrackJobStatus_IN_PROGRESS},
{ID: pausedID, Status: clientpb.CrackJobStatus_PAUSED},
{ID: completedID, Status: clientpb.CrackJobStatus_COMPLETED},
{ID: cancelledID, Status: clientpb.CrackJobStatus_CANCELLED},
nil,
{},
}}}
root := Commands(&console.SliverClient{Rpc: capture})[0]
command, _, err := root.Find([]string{"job", test.name})
if err != nil {
t.Fatalf("find crack job %s: %v", test.name, err)
}
values, directive := command.ValidArgsFunction(command, nil, "")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("completion directive = %v, want no-file-completion", directive)
}
got := make([]string, 0, len(values))
for _, value := range values {
got = append(got, strings.SplitN(value, "\t", 2)[0])
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("completion IDs = %#v, want %#v", got, test.want)
}
if capture.calls != 1 {
t.Fatalf("CrackJobs calls = %d, want 1", capture.calls)
}
})
}
}
func TestCrackJobLifecycleEligibility(t *testing.T) {
tests := []struct {
name string
job *clientpb.CrackJob
pausable bool
resumable bool
cancellable bool
}{
{name: "nil"},
{name: "in progress", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_IN_PROGRESS}, pausable: true, cancellable: true},
{name: "paused", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_PAUSED}, resumable: true, cancellable: true},
{name: "completed", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_COMPLETED}},
{name: "failed", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_FAILED}},
{name: "cancelled", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_CANCELLED}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := crackJobPausable(test.job); got != test.pausable {
t.Errorf("pausable = %v, want %v", got, test.pausable)
}
if got := crackJobResumable(test.job); got != test.resumable {
t.Errorf("resumable = %v, want %v", got, test.resumable)
}
if got := crackJobCancellable(test.job); got != test.cancellable {
t.Errorf("cancellable = %v, want %v", got, test.cancellable)
}
})
}
}
func TestCrackJobLifecycleEmptySelectionMessagesAreActionSpecific(t *testing.T) {
for _, test := range []struct {
name string
action crackJobLifecycleAction
want string
}{
{name: "cancel", action: newCrackJobCancelAction(nil), want: "No cancellable crack jobs"},
{name: "pause", action: newCrackJobPauseAction(nil), want: "No pausable crack jobs"},
{name: "resume", action: newCrackJobResumeAction(nil), want: "No resumable crack jobs"},
} {
t.Run(test.name, func(t *testing.T) {
if test.action.noEligibleJobsMessage != test.want {
t.Fatalf("empty-selection message = %q, want %q", test.action.noEligibleJobsMessage, test.want)
}
})
}
}
func TestApplyCrackJobLifecycleActionsConfirmAndMutateByID(t *testing.T) {
type contextKey struct{}
ctx := context.WithValue(t.Context(), contextKey{}, "expected")
jobID := "aaaaaaaa-1111-4111-8111-111111111111"
tests := []struct {
name string
status clientpb.CrackJobStatus
updatedStatus clientpb.CrackJobStatus
newAction func(crackJobMutationFunc) crackJobLifecycleAction
wantConfirmation bool
promptParts []string
}{
{
name: "cancel active",
status: clientpb.CrackJobStatus_IN_PROGRESS,
updatedStatus: clientpb.CrackJobStatus_CANCELLED,
newAction: newCrackJobCancelAction,
wantConfirmation: true,
promptParts: []string{"Cancel crack job", jobID, "Unfinished work will be discarded"},
},
{
name: "cancel paused",
status: clientpb.CrackJobStatus_PAUSED,
updatedStatus: clientpb.CrackJobStatus_CANCELLED,
newAction: newCrackJobCancelAction,
wantConfirmation: true,
promptParts: []string{"Cancel crack job", jobID, "PAUSED"},
},
{
name: "pause",
status: clientpb.CrackJobStatus_IN_PROGRESS,
updatedStatus: clientpb.CrackJobStatus_PAUSED,
newAction: newCrackJobPauseAction,
wantConfirmation: true,
promptParts: []string{"Pause crack job", jobID, "stopped", "beginning of their shards when resumed"},
},
{
name: "resume",
status: clientpb.CrackJobStatus_PAUSED,
updatedStatus: clientpb.CrackJobStatus_IN_PROGRESS,
newAction: newCrackJobResumeAction,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mutateCalls := 0
confirmCalls := 0
action := test.newAction(func(gotCtx context.Context, request *clientpb.CrackJob) (*clientpb.CrackJob, error) {
mutateCalls++
if gotCtx.Value(contextKey{}) != "expected" {
t.Fatal("mutation did not receive the caller context")
}
if request.GetID() != jobID || request.GetCommand() != nil || len(request.GetTasks()) != 0 || len(request.GetResults()) != 0 {
t.Fatalf("mutation request = %#v, want ID-only request", request)
}
return &clientpb.CrackJob{ID: jobID, Status: test.updatedStatus, UpdatedAt: 1}, nil
})
updated, changed, err := applyCrackJobLifecycleAction(ctx, &clientpb.CrackJob{ID: " " + jobID + " ", Status: test.status}, action,
func(prompt string) (bool, error) {
confirmCalls++
for _, part := range test.promptParts {
if !strings.Contains(prompt, part) {
t.Errorf("confirmation prompt %q does not contain %q", prompt, part)
}
}
return true, nil
})
if err != nil {
t.Fatalf("apply lifecycle action: %v", err)
}
if !changed || updated.GetID() != jobID || updated.GetStatus() != test.updatedStatus {
t.Fatalf("updated=%#v changed=%v", updated, changed)
}
wantConfirmCalls := 0
if test.wantConfirmation {
wantConfirmCalls = 1
}
if confirmCalls != wantConfirmCalls || mutateCalls != 1 {
t.Fatalf("confirm calls=%d mutate calls=%d, want %d/1", confirmCalls, mutateCalls, wantConfirmCalls)
}
})
}
}
func TestApplyFetchedCrackJobLifecycleActionRejectsMismatchedIDBeforeConfirmation(t *testing.T) {
requestedID := "aaaaaaaa-1111-4111-8111-111111111111"
returnedID := "bbbbbbbb-2222-4222-8222-222222222222"
confirmCalls := 0
mutationCalls := 0
action := newCrackJobPauseAction(func(context.Context, *clientpb.CrackJob) (*clientpb.CrackJob, error) {
mutationCalls++
return nil, nil
})
updated, changed, err := applyFetchedCrackJobLifecycleAction(
t.Context(),
requestedID,
&clientpb.CrackJob{ID: returnedID, Status: clientpb.CrackJobStatus_IN_PROGRESS},
action,
func(string) (bool, error) {
confirmCalls++
return true, nil
},
)
if err == nil || !strings.Contains(err.Error(), requestedID) || !strings.Contains(err.Error(), returnedID) {
t.Fatalf("mismatched detail error = %v, want both job IDs", err)
}
if updated != nil || changed || confirmCalls != 0 || mutationCalls != 0 {
t.Fatalf("updated=%#v changed=%v confirm calls=%d mutation calls=%d, want nil/false/0/0", updated, changed, confirmCalls, mutationCalls)
}
}
func TestApplyCrackJobLifecycleActionDeclineAndValidationErrorsDoNotMutate(t *testing.T) {
sentinel := errors.New("sentinel")
mutationCalls := 0
mutation := func(context.Context, *clientpb.CrackJob) (*clientpb.CrackJob, error) {
mutationCalls++
return nil, sentinel
}
for _, test := range []struct {
name string
job *clientpb.CrackJob
action crackJobLifecycleAction
confirm crackJobConfirmFunc
wantErr string
}{
{
name: "cancel declined",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS},
action: newCrackJobCancelAction(mutation),
confirm: func(string) (bool, error) { return false, nil },
},
{
name: "pause confirmation unavailable",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS},
action: newCrackJobPauseAction(mutation),
wantErr: "confirmation is unavailable",
},
{
name: "cannot pause completed",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_COMPLETED},
action: newCrackJobPauseAction(mutation),
confirm: func(string) (bool, error) { return true, nil },
wantErr: "only in-progress jobs can be paused",
},
{
name: "cannot resume active",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS},
action: newCrackJobResumeAction(mutation),
wantErr: "only paused jobs can be resumed",
},
{
name: "cannot cancel terminal",
job: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_CANCELLED},
action: newCrackJobCancelAction(mutation),
confirm: func(string) (bool, error) { return true, nil },
wantErr: "only in-progress or paused jobs can be cancelled",
},
{name: "empty job", action: newCrackJobResumeAction(mutation), wantErr: "empty crack job"},
{name: "empty ID", job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_PAUSED}, action: newCrackJobResumeAction(mutation), wantErr: "ID cannot be empty"},
} {
t.Run(test.name, func(t *testing.T) {
before := mutationCalls
updated, changed, err := applyCrackJobLifecycleAction(t.Context(), test.job, test.action, test.confirm)
if updated != nil || changed {
t.Fatalf("updated=%#v changed=%v, want nil/false", updated, changed)
}
if test.wantErr == "" && err != nil {
t.Fatalf("error = %v, want nil", err)
}
if test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr)) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
if mutationCalls != before {
t.Fatal("mutation ran despite decline or validation error")
}
})
}
}
func TestApplyCrackJobLifecycleActionValidatesAuthoritativeResponse(t *testing.T) {
job := &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_PAUSED}
for _, test := range []struct {
name string
result *clientpb.CrackJob
resultErr error
wantErr string
}{
{name: "RPC error", resultErr: errors.New("sentinel"), wantErr: "sentinel"},
{name: "nil", wantErr: "empty crack job after resume"},
{name: "empty ID", result: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_IN_PROGRESS}, wantErr: "without an ID after resume"},
{name: "wrong ID", result: &clientpb.CrackJob{ID: "another-job", Status: clientpb.CrackJobStatus_IN_PROGRESS, UpdatedAt: 1}, wantErr: "after resume request for job-id"},
{name: "missing timestamp", result: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS}, wantErr: "without an update timestamp after resume"},
{name: "wrong status", result: &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_PAUSED, UpdatedAt: 1}, wantErr: "unexpected crack job status PAUSED after resume"},
} {
t.Run(test.name, func(t *testing.T) {
action := newCrackJobResumeAction(func(context.Context, *clientpb.CrackJob) (*clientpb.CrackJob, error) {
return test.result, test.resultErr
})
updated, changed, err := applyCrackJobLifecycleAction(t.Context(), job, action, nil)
if updated != nil || changed || err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("updated=%#v changed=%v error=%v, want error containing %q", updated, changed, err, test.wantErr)
}
})
}
}
func TestCrackJobLifecycleMutationUsesCommandTimeout(t *testing.T) {
root := Commands(nil)[0]
pause, _, err := root.Find([]string{"job", "pause"})
if err != nil {
t.Fatalf("find crack job pause: %v", err)
}
if err := pause.InheritedFlags().Set("timeout", "2"); err != nil {
t.Fatalf("set timeout: %v", err)
}
started := time.Now()
var deadline time.Time
action := newCrackJobPauseAction(func(parent context.Context, request *clientpb.CrackJob) (*clientpb.CrackJob, error) {
ctx, cancel := crackCommandContext(parent, pause)
defer cancel()
var ok bool
deadline, ok = ctx.Deadline()
if !ok {
t.Fatal("mutation context has no deadline")
}
return &clientpb.CrackJob{ID: request.GetID(), Status: clientpb.CrackJobStatus_PAUSED, UpdatedAt: 1}, nil
})
if _, changed, err := applyCrackJobLifecycleAction(t.Context(), &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS}, action,
func(string) (bool, error) { return true, nil }); err != nil || !changed {
t.Fatalf("apply lifecycle action changed=%v error=%v", changed, err)
}
if deadline.Before(started.Add(1500*time.Millisecond)) || deadline.After(started.Add(2500*time.Millisecond)) {
t.Fatalf("mutation deadline = %s, want about two seconds after %s", deadline, started)
}
}
+249
View File
@@ -0,0 +1,249 @@
package crack
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/spf13/cobra"
)
func TestCrackJobRmCommandIsRegisteredAtExactHierarchy(t *testing.T) {
root := Commands(nil)[0]
rm, args, err := root.Find([]string{"job", "rm", "job-id"})
if err != nil {
t.Fatalf("find crack job rm: %v", err)
}
if got := rm.CommandPath(); got != "crack job rm" {
t.Fatalf("command path = %q, want %q", got, "crack job rm")
}
if !reflect.DeepEqual(args, []string{"job-id"}) {
t.Fatalf("remaining args = %#v, want [job-id]", args)
}
if rm.Use != "rm [id]" {
t.Fatalf("crack job rm use = %q, want %q", rm.Use, "rm [id]")
}
if err := rm.Args(rm, nil); err != nil {
t.Fatalf("crack job rm rejected interactive selection: %v", err)
}
if err := rm.Args(rm, []string{"job-id"}); err != nil {
t.Fatalf("crack job rm rejected one job ID: %v", err)
}
if err := rm.Args(rm, []string{"first", "second"}); err == nil {
t.Fatal("crack job rm accepted more than one job ID")
}
if rm.ValidArgsFunction == nil {
t.Fatal("crack job rm is missing ID completion")
}
if rm.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("crack job rm must inherit --timeout")
}
if rm.InheritedFlags().Lookup("watch") != nil || rm.InheritedFlags().Lookup("poll-interval") != nil {
t.Fatal("crack job view flags leaked into crack job rm")
}
}
func TestCrackJobRmCompletionOnlyOffersTerminalJobs(t *testing.T) {
activeID := "aaaaaaaa-1111-4111-8111-111111111111"
completedID := "bbbbbbbb-2222-4222-8222-222222222222"
capture := &crackJobsListCapture{jobs: &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{
{ID: activeID, Status: clientpb.CrackJobStatus_IN_PROGRESS},
{ID: completedID, Status: clientpb.CrackJobStatus_COMPLETED},
}}}
root := Commands(&console.SliverClient{Rpc: capture})[0]
rm, _, err := root.Find([]string{"job", "rm"})
if err != nil {
t.Fatalf("find crack job rm: %v", err)
}
values, directive := rm.ValidArgsFunction(rm, nil, "")
if len(values) != 1 || !strings.HasPrefix(values[0], completedID+"\t") {
t.Fatalf("completion values = %#v, want only terminal job %q", values, completedID)
}
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("completion directive = %v, want no-file-completion", directive)
}
if capture.calls != 1 {
t.Fatalf("CrackJobs calls = %d, want 1", capture.calls)
}
}
func TestRemoveCrackJobConfirmsCascadeAndDeletesByID(t *testing.T) {
type contextKey struct{}
ctx := context.WithValue(t.Context(), contextKey{}, "expected")
job := &clientpb.CrackJob{
ID: " aaaaaaaa-1111-4111-8111-111111111111 ",
Status: clientpb.CrackJobStatus_COMPLETED,
Tasks: []*clientpb.CrackTask{{ID: "one"}, {ID: "two"}},
ResultCount: 1,
}
confirmCalls := 0
deleteCalls := 0
removed, err := removeCrackJob(ctx, job, func(prompt string) (bool, error) {
confirmCalls++
want := "Permanently delete crack job \"aaaaaaaa-1111-4111-8111-111111111111\" (COMPLETED), including 2 tasks and 1 result?"
if prompt != want {
t.Fatalf("confirmation prompt = %q, want %q", prompt, want)
}
return true, nil
}, func(gotCtx context.Context, request *clientpb.CrackJob) (*commonpb.Empty, error) {
deleteCalls++
if gotCtx.Value(contextKey{}) != "expected" {
t.Fatal("delete did not receive the caller context")
}
if request.GetID() != "aaaaaaaa-1111-4111-8111-111111111111" || request.GetCommand() != nil || len(request.GetTasks()) != 0 || len(request.GetResults()) != 0 {
t.Fatalf("delete request = %#v, want ID-only request", request)
}
return &commonpb.Empty{}, nil
})
if err != nil {
t.Fatalf("removeCrackJob: %v", err)
}
if !removed || confirmCalls != 1 || deleteCalls != 1 {
t.Fatalf("removed=%v confirm calls=%d delete calls=%d, want true/1/1", removed, confirmCalls, deleteCalls)
}
}
func TestRemoveFetchedCrackJobRejectsMismatchedIDBeforeConfirmation(t *testing.T) {
requestedID := "aaaaaaaa-1111-4111-8111-111111111111"
returnedID := "bbbbbbbb-2222-4222-8222-222222222222"
confirmCalls := 0
deleteCalls := 0
removed, err := removeFetchedCrackJob(
t.Context(),
requestedID,
&clientpb.CrackJob{ID: returnedID, Status: clientpb.CrackJobStatus_COMPLETED},
func(string) (bool, error) {
confirmCalls++
return true, nil
},
func(context.Context, *clientpb.CrackJob) (*commonpb.Empty, error) {
deleteCalls++
return &commonpb.Empty{}, nil
},
)
if err == nil || !strings.Contains(err.Error(), requestedID) || !strings.Contains(err.Error(), returnedID) {
t.Fatalf("mismatched detail error = %v, want both job IDs", err)
}
if removed || confirmCalls != 0 || deleteCalls != 0 {
t.Fatalf("removed=%v confirm calls=%d delete calls=%d, want false/0/0", removed, confirmCalls, deleteCalls)
}
}
func TestRemoveCrackJobDeclineAndErrorsDoNotReportRemoval(t *testing.T) {
sentinel := errors.New("sentinel")
tests := []struct {
name string
job *clientpb.CrackJob
confirm crackJobConfirmFunc
deleteJob crackJobDeleteFunc
wantErr string
wantConfirm int
wantDelete int
}{
{
name: "active job rejected before confirmation",
job: &clientpb.CrackJob{ID: "active", Status: clientpb.CrackJobStatus_IN_PROGRESS},
wantErr: "only completed, failed, or cancelled jobs can be removed",
},
{
name: "declined",
job: &clientpb.CrackJob{ID: "completed", Status: clientpb.CrackJobStatus_COMPLETED},
confirm: func(string) (bool, error) { return false, nil },
wantConfirm: 1,
},
{
name: "confirmation error",
job: &clientpb.CrackJob{ID: "failed", Status: clientpb.CrackJobStatus_FAILED},
confirm: func(string) (bool, error) { return false, sentinel },
wantErr: "sentinel",
wantConfirm: 1,
},
{
name: "delete error",
job: &clientpb.CrackJob{ID: "cancelled", Status: clientpb.CrackJobStatus_CANCELLED},
confirm: func(string) (bool, error) { return true, nil },
deleteJob: func(context.Context, *clientpb.CrackJob) (*commonpb.Empty, error) { return nil, sentinel },
wantErr: "sentinel",
wantConfirm: 1,
wantDelete: 1,
},
{
name: "empty response",
wantErr: "server returned an empty crack job",
},
{
name: "empty ID",
job: &clientpb.CrackJob{Status: clientpb.CrackJobStatus_COMPLETED},
wantErr: "crack job ID cannot be empty",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
confirmCalls := 0
deleteCalls := 0
confirm := test.confirm
if confirm == nil {
confirm = func(string) (bool, error) {
confirmCalls++
return true, nil
}
} else {
wrapped := confirm
confirm = func(prompt string) (bool, error) {
confirmCalls++
return wrapped(prompt)
}
}
deleteJob := test.deleteJob
if deleteJob == nil {
deleteJob = func(context.Context, *clientpb.CrackJob) (*commonpb.Empty, error) {
deleteCalls++
return &commonpb.Empty{}, nil
}
} else {
wrapped := deleteJob
deleteJob = func(ctx context.Context, job *clientpb.CrackJob) (*commonpb.Empty, error) {
deleteCalls++
return wrapped(ctx, job)
}
}
removed, err := removeCrackJob(t.Context(), test.job, confirm, deleteJob)
if removed {
t.Fatal("removeCrackJob reported removal")
}
if test.wantErr == "" && err != nil {
t.Fatalf("removeCrackJob error = %v, want nil", err)
}
if test.wantErr != "" && (err == nil || !strings.Contains(err.Error(), test.wantErr)) {
t.Fatalf("removeCrackJob error = %v, want substring %q", err, test.wantErr)
}
if confirmCalls != test.wantConfirm || deleteCalls != test.wantDelete {
t.Fatalf("confirm calls=%d delete calls=%d, want %d/%d", confirmCalls, deleteCalls, test.wantConfirm, test.wantDelete)
}
})
}
}
func TestTerminalCrackJobsFiltersWithoutReordering(t *testing.T) {
jobs := []*clientpb.CrackJob{
{ID: "active", Status: clientpb.CrackJobStatus_IN_PROGRESS},
nil,
{ID: "failed", Status: clientpb.CrackJobStatus_FAILED},
{ID: "completed", Status: clientpb.CrackJobStatus_COMPLETED},
{ID: "cancelled", Status: clientpb.CrackJobStatus_CANCELLED},
}
got := terminalCrackJobs(jobs)
want := []*clientpb.CrackJob{jobs[2], jobs[3], jobs[4]}
if !reflect.DeepEqual(got, want) {
t.Fatalf("terminal jobs = %#v, want %#v", got, want)
}
}
File diff suppressed because it is too large. Load diff
+463
View File
@@ -0,0 +1,463 @@
package crack
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/x/ansi"
"google.golang.org/protobuf/reflect/protoreflect"
)
func TestCrackJobCommandsAreRegistered(t *testing.T) {
root := Commands(nil)[0]
jobs, _, err := root.Find([]string{"jobs"})
if err != nil || jobs == root {
t.Fatalf("find crack jobs: command=%v error=%v", jobs, err)
}
top, _, err := root.Find([]string{"top"})
if err != nil || top == root {
t.Fatalf("find crack top: command=%v error=%v", top, err)
}
if top.Use != "top" {
t.Fatalf("crack top use = %q, want top", top.Use)
}
if err := top.Args(top, nil); err != nil {
t.Fatalf("crack top rejected no arguments: %v", err)
}
if err := top.Args(top, []string{"unexpected"}); err == nil {
t.Fatal("crack top accepted a positional argument")
}
pollInterval := top.Flags().Lookup("poll-interval")
if pollInterval == nil {
t.Fatal("crack top is missing --poll-interval")
}
if got, err := top.Flags().GetDuration("poll-interval"); err != nil || got != time.Second {
t.Fatalf("crack top --poll-interval = %s, %v; want %s", got, err, time.Second)
}
if top.PersistentFlags().Lookup("poll-interval") != nil || root.PersistentFlags().Lookup("poll-interval") != nil {
t.Fatal("--poll-interval must remain local to crack top")
}
if top.InheritedFlags().Lookup("timeout") == nil {
t.Fatal("crack top must inherit --timeout")
}
job, _, err := root.Find([]string{"job", "job-id"})
if err != nil || job == root {
t.Fatalf("find crack job: command=%v error=%v", job, err)
}
if job.Flags().Lookup("watch") == nil || job.Flags().Lookup("poll-interval") == nil {
t.Fatal("crack job is missing watch flags")
}
if job.Use != "job [id]" {
t.Fatalf("crack job use = %q, want %q", job.Use, "job [id]")
}
if job.ValidArgsFunction == nil {
t.Fatal("crack job is missing ID completion")
}
if err := job.Args(job, nil); err != nil {
t.Fatalf("crack job rejected interactive selection: %v", err)
}
if err := job.Args(job, []string{"job-id"}); err != nil {
t.Fatalf("crack job rejected one job ID: %v", err)
}
if err := job.Args(job, []string{"first", "second"}); err == nil {
t.Fatal("crack job accepted more than one job ID")
}
}
func TestResolveCrackJobIDUsesExplicitIDWithoutListing(t *testing.T) {
listed := false
selected := false
jobID, err := resolveCrackJobID([]string{" full-job-id "}, func() (*clientpb.CrackJobs, error) {
listed = true
return nil, nil
}, func([]*clientpb.CrackJob) (string, error) {
selected = true
return "", nil
})
if err != nil {
t.Fatalf("resolveCrackJobID: %v", err)
}
if jobID != "full-job-id" {
t.Fatalf("job ID = %q, want full-job-id", jobID)
}
if listed || selected {
t.Fatalf("explicit ID unexpectedly listed=%v selected=%v", listed, selected)
}
}
func TestResolveCrackJobIDSelectsFromListedJobs(t *testing.T) {
wantJobs := []*clientpb.CrackJob{{ID: "first"}, {ID: "selected-full-id"}}
listCalls := 0
selectCalls := 0
jobID, err := resolveCrackJobID(nil, func() (*clientpb.CrackJobs, error) {
listCalls++
return &clientpb.CrackJobs{Jobs: wantJobs}, nil
}, func(jobs []*clientpb.CrackJob) (string, error) {
selectCalls++
if len(jobs) != len(wantJobs) || jobs[1] != wantJobs[1] {
t.Fatalf("selector jobs = %#v, want %#v", jobs, wantJobs)
}
return "selected-full-id", nil
})
if err != nil {
t.Fatalf("resolveCrackJobID: %v", err)
}
if jobID != "selected-full-id" {
t.Fatalf("job ID = %q, want selected-full-id", jobID)
}
if listCalls != 1 || selectCalls != 1 {
t.Fatalf("list calls = %d, select calls = %d; want 1 each", listCalls, selectCalls)
}
}
func TestResolveCrackJobIDHandlesEmptyAndInvalidSelections(t *testing.T) {
selectorCalled := false
for _, listedJobs := range [][]*clientpb.CrackJob{nil, {nil, {ID: " "}}} {
_, err := resolveCrackJobID(nil, func() (*clientpb.CrackJobs, error) {
return &clientpb.CrackJobs{Jobs: listedJobs}, nil
}, func([]*clientpb.CrackJob) (string, error) {
selectorCalled = true
return "unused", nil
})
if !errors.Is(err, errNoCrackJobs) {
t.Fatalf("empty job error = %v, want %v", err, errNoCrackJobs)
}
}
if selectorCalled {
t.Fatal("selector called with no jobs")
}
_, err := resolveCrackJobID(nil, func() (*clientpb.CrackJobs, error) {
return &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{{ID: "job-id"}}}, nil
}, func([]*clientpb.CrackJob) (string, error) {
return " ", nil
})
if err == nil || !strings.Contains(err.Error(), "no crack job selected") {
t.Fatalf("blank selection error = %v", err)
}
_, err = resolveCrackJobID([]string{" "}, nil, nil)
if err == nil || !strings.Contains(err.Error(), "job ID cannot be empty") {
t.Fatalf("blank explicit ID error = %v", err)
}
}
func TestResolveCrackJobIDPropagatesUserAbort(t *testing.T) {
_, err := resolveCrackJobID(nil, func() (*clientpb.CrackJobs, error) {
return &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{{ID: "job-id"}}}, nil
}, func([]*clientpb.CrackJob) (string, error) {
return "", forms.ErrUserAborted
})
if !errors.Is(err, forms.ErrUserAborted) {
t.Fatalf("selection error = %v, want user abort", err)
}
}
//nolint:gocyclo // One fixture verifies the complete newest-status telemetry contract and all presentation helpers.
func TestParseCrackStatusJSONUsesNewestStatus(t *testing.T) {
raw := []byte("{\"status\":1,\"progress\":[1,10]}\n" +
"{\"session\":\"distributed-job\",\"status\":2,\"target\":\"hashes\",\"progress\":[5,10]," +
"\"restore_point\":4,\"recovered_hashes\":[1,3],\"recovered_salts\":[1,2],\"rejected\":7," +
"\"time_start\":1700000000,\"estimated_stop\":1700000100," +
"\"guess\":{\"guess_mode\":3,\"guess_base\":\"?d?d\",\"guess_base_offset\":1,\"guess_base_count\":10," +
"\"guess_base_percent\":25.5,\"guess_mask_length\":2,\"guess_mod\":\"rules\",\"guess_mod_offset\":2,\"guess_mod_count\":4,\"guess_mod_percent\":50},\"devices\":[" +
"{\"device_id\":1,\"device_name\":\"GPU 0\",\"device_type\":\"GPU\",\"speed\":1500,\"temp\":65,\"util\":98,\"corespeed\":2100,\"memoryspeed\":9500,\"buslanes\":16}," +
"{\"id\":2,\"name\":\"GPU 1\",\"type\":\"GPU\",\"speed\":500,\"temperature\":70,\"utilization\":97}]}\n")
status, err := parseCrackStatusJSON(raw)
if err != nil {
t.Fatalf("parseCrackStatusJSON: %v", err)
}
if status.Status != "2" || status.ProgressCurrent != "5" || status.ProgressTotal != "10" {
t.Fatalf("unexpected status: %#v", status)
}
if got := status.progressText(); got != "5/10 (50.0%)" {
t.Fatalf("progressText = %q", got)
}
if got := status.speedText(); got != "2.00 kH/s" {
t.Fatalf("speedText = %q", got)
}
if got := status.temperatureText(); got != "70 C" {
t.Fatalf("temperatureText = %q", got)
}
if len(status.Devices) != 2 || status.Devices[1].Name != "GPU 1" {
t.Fatalf("devices = %#v", status.Devices)
}
if status.Session != "distributed-job" || status.Target != "hashes" || status.RestorePoint != "4" ||
status.RecoveredHashesCurrent != "1" || status.RecoveredHashesTotal != "3" ||
status.RecoveredSaltsCurrent != "1" || status.RecoveredSaltsTotal != "2" || status.Rejected != "7" ||
status.TimeStart != "1700000000" || status.EstimatedStop != "1700000100" {
t.Fatalf("full status fields were not decoded: %#v", status)
}
if status.Guess.Mode != "3" || status.Guess.Base != "?d?d" || status.Guess.BasePercent != "25.5" || status.Guess.MaskLength != "2" || status.Guess.Mod != "rules" || status.Guess.ModPercent != "50" {
t.Fatalf("guess = %#v", status.Guess)
}
if got := status.guessText(); !strings.Contains(got, "base=?d?d [1/10] (25.5%)") || !strings.Contains(got, "mask-length=2") || !strings.Contains(got, "mod=rules [2/4] (50%)") {
t.Fatalf("guessText = %q", got)
}
if status.Devices[0].CoreSpeed != "2100" || status.Devices[0].MemorySpeed != "9500" || status.Devices[0].BusLanes != "16" {
t.Fatalf("extended device telemetry = %#v", status.Devices[0])
}
}
func TestParseCrackStatusJSONRejectsMalformedOutput(t *testing.T) {
if _, err := parseCrackStatusJSON([]byte("hashcat is still starting")); err == nil {
t.Fatal("malformed status output was accepted")
}
}
func TestUnavailableDeviceTelemetryRendersAsUnknown(t *testing.T) {
status := &crackStatusView{Devices: []crackDeviceView{{Temperature: "-1"}}}
if got := status.temperatureText(); got != "-" {
t.Fatalf("temperatureText = %q, want -", got)
}
if got := unitValue("-1", " C"); got != "-" {
t.Fatalf("unitValue = %q, want -", got)
}
}
func TestRenderCrackJobIncludesTasksProgressDevicesAndResults(t *testing.T) {
status := []byte(`{"session":"job-session","status":2,"target":"hash-file","progress":[25,100],"restore_point":24,"recovered_hashes":[1,2],"recovered_salts":[1,1],"rejected":3,"time_start":1700000000,"estimated_stop":1700000100,"guess":{"guess_mode":3,"guess_base":"?d?d","guess_base_offset":0,"guess_base_count":1},"devices":[{"device_id":1,"device_name":"GPU 0","device_type":"GPU","speed":2500000,"temp":61,"util":99,"fanspeed":45,"corespeed":2100,"memoryspeed":9000,"buslanes":16,"power":125000}]}`)
recovered := []byte(`[{"hash":"hash-one","plaintext":"cGxhaW4tb25l"}]`)
task := &clientpb.CrackTask{
ID: "task-id",
HostUUID: "station-id",
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
UpdatedAt: 1700000002,
LatestStatusJSON: status,
RecoveredJSON: recovered,
ShardSkip: 100,
ShardLimit: 50,
}
job := &clientpb.CrackJob{
ID: "job-id",
CreatedAt: "2026-09-09T00:00:00Z",
UpdatedAt: 1700000003,
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Keyspace: "1000000",
Tasks: []*clientpb.CrackTask{task},
Results: []*clientpb.CrackResult{{
Hash: "hash-one",
Plaintext: []byte("plain-one"),
}, {
CredentialID: "credential-id",
Hash: "hash-two",
Plaintext: []byte("plain-two"),
}},
}
rawOutput := renderCrackJob(job, settings.SliverDefault)
output := ansi.Strip(rawOutput)
for _, expected := range []string{
"job-id", "1000000", "task-id", "station-id", "100+50", "25/100 (25.0%)",
"2.50 MH/s", "61 C", "GPU 0", "credential-id", "hash-one", "plain-one", "hash-two", "plain-two",
"Hashcat Statistics", "job-session", "hash-file", "mode=3", "base=?d?d", "1/2", "1/1",
"2100 MHz", "9000 MHz", "16 lanes", "125000 mW", "2023-11-14T22:13:20Z", "2023-11-14T22:15:00Z",
} {
if !strings.Contains(output, expected) {
t.Errorf("rendered job does not contain %q:\n%s", expected, output)
}
}
for _, styled := range []string{
console.StyleBoldPrimary.Render("Crack Job"),
console.StyleBoldWarning.Render(clientpb.CrackJobStatus_IN_PROGRESS.String()),
console.StyleBoldPrimary.Render("Tasks"),
console.StyleBoldWarning.Render(clientpb.CrackTaskState_CRACK_TASK_RUNNING.String()),
console.StyleBoldPrimary.Render("Hashcat Statistics"),
console.StyleBoldPrimary.Render("Devices"),
console.StyleBoldSuccess.Render("Recovered Credentials"),
} {
if !strings.Contains(rawOutput, styled) {
t.Fatalf("rendered job is missing Lip Gloss styling %q:\n%s", styled, rawOutput)
}
}
if strings.Contains(rawOutput, console.StylePrimary.Render("task-id")) {
t.Fatalf("rendered task ID should use the default foreground color:\n%s", rawOutput)
}
}
func TestRenderCrackJobsSummarizesExtendedFields(t *testing.T) {
job := &clientpb.CrackJob{
ID: "job-id",
CreatedAt: "2026-09-09T00:00:00Z",
UpdatedAt: 1700000003,
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Keyspace: "1000",
Tasks: []*clientpb.CrackTask{{ID: "task-id"}},
Results: []*clientpb.CrackResult{{Hash: "hash", Plaintext: []byte("plain")}},
}
rawOutput := renderCrackJobs([]*clientpb.CrackJob{job}, settings.SliverDefault)
output := ansi.Strip(rawOutput)
for _, expected := range []string{"job-id", "IN_PROGRESS", "1000", "1"} {
if !strings.Contains(output, expected) {
t.Errorf("rendered jobs do not contain %q:\n%s", expected, output)
}
}
if !strings.Contains(rawOutput, console.StyleBoldPrimary.Render("job-id")) ||
!strings.Contains(rawOutput, console.StyleBoldWarning.Render(clientpb.CrackJobStatus_IN_PROGRESS.String())) {
t.Fatalf("rendered jobs are missing Lip Gloss ID or status styling:\n%s", rawOutput)
}
}
func TestCrackJobStatusStyleUsesSemanticPalette(t *testing.T) {
tests := []struct {
status string
want console.TextStyle
}{
{status: "IN_PROGRESS", want: console.StyleBoldWarning},
{status: "CRACK_TASK_RUNNING", want: console.StyleBoldWarning},
{status: "CRACK_TASK_QUEUED", want: console.StyleBold},
{status: "CRACK_TASK_LEASED", want: console.StyleBoldWarning},
{status: "COMPLETED", want: console.StyleBoldSuccess},
{status: "CRACK_TASK_COMPLETED", want: console.StyleBoldSuccess},
{status: "CRACK_TASK_FAILED", want: console.StyleBoldDanger},
{status: "CANCELLED", want: console.StyleBoldGray},
{status: "CANCELED", want: console.StyleBoldGray},
{status: "PAUSED", want: console.StyleBoldGray},
{status: "CRACK_TASK_CANCELLED", want: console.StyleBoldGray},
{status: "UNKNOWN", want: console.StyleBold},
}
for _, test := range tests {
t.Run(test.status, func(t *testing.T) {
got := crackJobStatusStyle(test.status)
if got.GetBold() != test.want.GetBold() {
t.Fatalf("bold = %v, want %v", got.GetBold(), test.want.GetBold())
}
gotColor := got.GetForeground()
wantColor := test.want.GetForeground()
if (gotColor == nil) != (wantColor == nil) {
t.Fatalf("foreground = %v, want %v", gotColor, wantColor)
}
if gotColor == nil {
return
}
gotR, gotG, gotB, gotA := gotColor.RGBA()
wantR, wantG, wantB, wantA := wantColor.RGBA()
if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA {
t.Fatalf("foreground RGBA = (%d,%d,%d,%d), want (%d,%d,%d,%d)", gotR, gotG, gotB, gotA, wantR, wantG, wantB, wantA)
}
})
}
}
func TestRenderCrackJobStylesErrors(t *testing.T) {
rawOutput := renderCrackJob(&clientpb.CrackJob{
ID: "job-id",
Status: clientpb.CrackJobStatus_FAILED,
Err: "worker failed",
}, settings.SliverDefault)
for _, styled := range []string{
console.StyleBoldDanger.Render(clientpb.CrackJobStatus_FAILED.String()),
console.StyleBoldDanger.Render("Error"),
console.StyleDanger.Render("worker failed"),
} {
if !strings.Contains(rawOutput, styled) {
t.Fatalf("rendered error is missing Lip Gloss styling %q:\n%s", styled, rawOutput)
}
}
}
func TestCrackJobResultCountUsesSummaryField(t *testing.T) {
job := &clientpb.CrackJob{Results: []*clientpb.CrackResult{{Plaintext: []byte("not-a-summary-count")}}}
field := findProtoField(job.ProtoReflect(), "ResultCount")
want := "-"
if field != nil {
job.ProtoReflect().Set(field, protoreflect.ValueOfUint64(7))
want = "7"
}
if got := crackJobResultCount(job); got != want {
t.Fatalf("crackJobResultCount() = %q, want %q", got, want)
}
}
func TestCrackJobResultsRenderAuthoritativePlaintextBytes(t *testing.T) {
job := &clientpb.CrackJob{Results: []*clientpb.CrackResult{
{Hash: "valid", Plaintext: []byte("line\ntext\x1b[31m")},
{Hash: "binary", Plaintext: []byte{0xff, 0x00, 0x1b}},
{Hash: "nul", Plaintext: []byte{'a', 0, 'b'}},
}}
results := crackJobResults(job)
if len(results) != 3 {
t.Fatalf("crackJobResults() = %#v", results)
}
if results[0].Plaintext != "linetext[31m" {
t.Fatalf("valid UTF-8 plaintext = %q, want sanitized text", results[0].Plaintext)
}
if results[1].Plaintext != "0xff001b" {
t.Fatalf("binary plaintext = %q, want 0xff001b", results[1].Plaintext)
}
if results[2].Plaintext != "0x610062" {
t.Fatalf("NUL plaintext = %q, want 0x610062", results[2].Plaintext)
}
}
func TestWatchCrackJobPrintsChangesAndStopsAtTerminalState(t *testing.T) {
inProgress := &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS}
completed := &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_COMPLETED}
responses := []*clientpb.CrackJob{inProgress, inProgress, completed}
requests := 0
fetch := func(_ context.Context, id string) (*clientpb.CrackJob, error) {
if id != "job-id" {
t.Fatalf("job ID = %q", id)
}
response := responses[requests]
requests++
return response, nil
}
updates := make([]clientpb.CrackJobStatus, 0)
err := watchCrackJob(context.Background(), "job-id", time.Millisecond, fetch, func(job *clientpb.CrackJob) {
updates = append(updates, job.Status)
})
if err != nil {
t.Fatalf("watchCrackJob: %v", err)
}
if requests != 3 {
t.Fatalf("requests = %d, want 3", requests)
}
if len(updates) != 2 || updates[0] != clientpb.CrackJobStatus_IN_PROGRESS || updates[1] != clientpb.CrackJobStatus_COMPLETED {
t.Fatalf("updates = %#v", updates)
}
}
func TestWatchCrackJobCancellationAndValidation(t *testing.T) {
fetch := func(_ context.Context, _ string) (*clientpb.CrackJob, error) {
return &clientpb.CrackJob{ID: "job-id", Status: clientpb.CrackJobStatus_IN_PROGRESS}, nil
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := watchCrackJob(ctx, "job-id", time.Hour, fetch, nil); !errors.Is(err, context.Canceled) {
t.Fatalf("watchCrackJob cancellation error = %v", err)
}
if err := watchCrackJob(context.Background(), "job-id", 0, fetch, nil); err == nil {
t.Fatal("watchCrackJob accepted zero poll interval")
}
}
func TestRecoveredResultsStripTerminalControlCharacters(t *testing.T) {
results := parseRecoveredResults([]byte(`[{"hash":"hash\u001b[31m","plaintext":"cGxhaW4KdGV4dA=="}]`))
if len(results) != 1 {
t.Fatalf("results = %#v", results)
}
if strings.ContainsAny(results[0].Hash, "\x1b\n\r") || strings.ContainsAny(results[0].Plaintext, "\x1b\n\r") {
t.Fatalf("control characters were not stripped: %#v", results[0])
}
}
func TestRecoveredResultsRenderNULPlaintextAsHex(t *testing.T) {
results := parseRecoveredResults([]byte(`[{"hash":"hash","plaintext":"YQBi"}]`))
if len(results) != 1 {
t.Fatalf("results = %#v", results)
}
if results[0].Plaintext != "0x610062" {
t.Fatalf("plaintext = %q, want 0x610062", results[0].Plaintext)
}
}
+241 -51
View File
@@ -22,7 +22,8 @@ import (
"context"
"fmt"
"sort"
"time"
"strconv"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
@@ -31,23 +32,47 @@ import (
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
)
// CrackCmd - GPU password cracking interface
func CrackCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if shouldRunCrack(cmd, args) {
invocation, err := classifyCrackInvocation(cmd, args)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
if invocation.mode == crackInvocationBackendInfo {
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
defer cancel()
crackers, err := connectedCrackstations(ctx, con.Rpc)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
if len(crackers) == 0 {
PrintNoCrackstations(con)
return
}
printCrackersWithBackendInfoLevel(crackers, con, false, invocation.backendInfoLevel)
return
}
if invocation.mode == crackInvocationJob || invocation.isSynchronousQuery() {
crackCmd, err := buildCrackCommand(cmd, args)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
timeoutSeconds, _ := cmd.Flags().GetInt64("timeout")
ctx := context.Background()
if timeoutSeconds > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
defer cancel()
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
defer cancel()
if err := resolveManagedCrackFileReferences(ctx, crackCmd, func(ctx context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
return con.Rpc.CrackFilesList(ctx, request)
}); err != nil {
con.PrintErrorf("%s\n", err)
return
}
resp, err := con.Rpc.Crack(ctx, crackCmd)
@@ -55,6 +80,16 @@ func CrackCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
con.PrintErrorf("%s\n", err)
return
}
if invocation.isSynchronousQuery() {
value, stderr, err := crackQueryResponse(resp, invocation)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
printCrackQueryStderr(con, stderr)
printCrackQueryValue(con, value)
return
}
if resp == nil || resp.Job == nil {
con.PrintInfof("Crack request submitted\n")
return
@@ -89,21 +124,139 @@ func CrackCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
}
}
func canonicalCrackKeyspace(response *clientpb.CrackResponse) (string, error) {
return crackQueryResponseValue(response, crackInvocation{mode: crackInvocationKeyspace})
}
func (invocation crackInvocation) isSynchronousQuery() bool {
switch invocation.mode {
case crackInvocationKeyspace, crackInvocationTotalCandidates, crackInvocationLookup, crackInvocationIdentify, crackInvocationHashInfo:
return true
default:
return false
}
}
func (invocation crackInvocation) queryMode() clientpb.CrackQueryMode {
switch invocation.mode {
case crackInvocationKeyspace:
return clientpb.CrackQueryMode_CRACK_QUERY_KEYSPACE
case crackInvocationTotalCandidates:
return clientpb.CrackQueryMode_CRACK_QUERY_TOTAL_CANDIDATES
case crackInvocationLookup:
return clientpb.CrackQueryMode_CRACK_QUERY_LOOKUP
case crackInvocationIdentify:
return clientpb.CrackQueryMode_CRACK_QUERY_IDENTIFY
case crackInvocationHashInfo:
return clientpb.CrackQueryMode_CRACK_QUERY_HASH_INFO
default:
return clientpb.CrackQueryMode_CRACK_QUERY_UNSPECIFIED
}
}
func crackQueryResponseValue(response *clientpb.CrackResponse, invocation crackInvocation) (string, error) {
value, _, err := crackQueryResponse(response, invocation)
return value, err
}
func crackQueryResponse(response *clientpb.CrackResponse, invocation crackInvocation) (string, string, error) {
if response == nil {
return "", "", fmt.Errorf("server returned an empty %s response", crackInvocationName(invocation.mode))
}
expectedMode := invocation.queryMode()
if expectedMode == clientpb.CrackQueryMode_CRACK_QUERY_UNSPECIFIED {
return "", "", fmt.Errorf("invalid synchronous crack query mode")
}
query := response.GetQuery()
if query == nil {
// Servers predating typed query results returned only Keyspace. Do not use
// that legacy field for a selected station because it cannot prove which
// station produced the result.
if invocation.mode == crackInvocationKeyspace && invocation.crackstation == "" {
value, err := canonicalCrackDecimal(response.GetKeyspace(), "keyspace")
return value, "", err
}
return "", "", fmt.Errorf("server returned an empty %s query result", crackInvocationName(invocation.mode))
}
if query.GetMode() != expectedMode {
return "", "", fmt.Errorf("server returned %s for %s query", query.GetMode().String(), crackInvocationName(invocation.mode))
}
value := query.GetValue()
switch invocation.mode {
case crackInvocationKeyspace, crackInvocationTotalCandidates:
value, err := canonicalCrackDecimal(value, crackInvocationName(invocation.mode))
return value, query.GetStderr(), err
default:
if strings.TrimSpace(value) == "" {
return "", "", fmt.Errorf("server returned an empty %s query result", crackInvocationName(invocation.mode))
}
return value, query.GetStderr(), nil
}
}
func canonicalCrackDecimal(value string, name string) (string, error) {
raw := strings.TrimSpace(value)
keyspace, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return "", fmt.Errorf("server returned invalid %s %q", name, value)
}
return strconv.FormatUint(keyspace, 10), nil
}
func printCrackQueryValue(con *console.SliverClient, value string) {
con.Printf("%s", value)
if !strings.HasSuffix(value, "\n") {
con.Printf("\n")
}
}
func printCrackQueryStderr(con *console.SliverClient, value string) {
if value == "" {
return
}
if !strings.HasSuffix(value, "\n") {
value += "\n"
}
con.PrintWarnf("%s", value)
}
// CrackStationsCmd - Manage GPU cracking stations
func CrackStationsCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
crackers, err := con.Rpc.Crackstations(context.Background(), &commonpb.Empty{})
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
defer cancel()
crackers, err := connectedCrackstations(ctx, con.Rpc)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
showBenchmarks, _ := cmd.Flags().GetBool("show-benchmarks")
if len(crackers.Crackstations) == 0 {
if len(crackers) == 0 {
PrintNoCrackstations(con)
} else {
PrintCrackers(crackers.Crackstations, con, showBenchmarks)
PrintCrackers(crackers, con, showBenchmarks)
}
}
func connectedCrackstations(ctx context.Context, rpc rpcpb.SliverRPCClient) ([]*clientpb.Crackstation, error) {
if ctx == nil {
ctx = context.Background()
}
response, err := rpc.Crackstations(ctx, &commonpb.Empty{})
if err != nil {
return nil, err
}
if response == nil {
return nil, nil
}
crackers := make([]*clientpb.Crackstation, 0, len(response.Crackstations))
for _, cracker := range response.Crackstations {
if cracker != nil {
crackers = append(crackers, cracker)
}
}
return crackers, nil
}
func PrintNoCrackstations(con *console.SliverClient) {
con.PrintInfof("No crackstations connected to server\n")
}
@@ -117,59 +270,96 @@ func AreCrackersOnline(con *console.SliverClient) bool {
}
func PrintCrackers(crackers []*clientpb.Crackstation, con *console.SliverClient, showBenchmarks bool) {
sort.Slice(crackers, func(i, j int) bool {
return crackers[i].Name < crackers[j].Name
printCrackersWithBackendInfoLevel(crackers, con, showBenchmarks, 1)
}
func printCrackersWithBackendInfoLevel(crackers []*clientpb.Crackstation, con *console.SliverClient, showBenchmarks bool, backendInfoLevel uint32) {
ordered := make([]*clientpb.Crackstation, 0, len(crackers))
for _, cracker := range crackers {
if cracker != nil {
ordered = append(ordered, cracker)
}
}
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].Name == ordered[j].Name {
return ordered[i].HostUUID < ordered[j].HostUUID
}
return ordered[i].Name < ordered[j].Name
})
for index, cracker := range crackers {
printCracker(cracker, index, con, showBenchmarks)
if index < len(crackers)-1 {
for index, cracker := range ordered {
printCracker(cracker, index, con, showBenchmarks, backendInfoLevel)
if index < len(ordered)-1 {
con.Println()
con.Println()
}
}
}
func printCracker(cracker *clientpb.Crackstation, index int, con *console.SliverClient, showBenchmarks bool) {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
tw.SetTitle(console.StyleBoldOrange.Render(fmt.Sprintf(">>> Crackstation %02d - %s (%s)", index+1, cracker.Name, cracker.OperatorName)) + "\n")
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render("Operating System"), fmt.Sprintf("%s/%s", cracker.GOOS, cracker.GOARCH)})
tw.AppendRow(table.Row{console.StyleBold.Render("Hashcat Version"), cracker.HashcatVersion})
if 0 < len(cracker.CUDA) {
for _, cuda := range cracker.CUDA {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render("CUDA Device"), console.StyleBoldGreen.Render(fmt.Sprintf("%s (%s)", cuda.Name, cuda.Version))})
tw.AppendRow(table.Row{console.StyleBold.Render("Memory"), fmt.Sprintf("%s free of %s", cuda.MemoryFree, cuda.MemoryTotal)})
tw.AppendRow(table.Row{console.StyleBold.Render("Clock"), fmt.Sprintf("%d", cuda.Clock)})
tw.AppendRow(table.Row{console.StyleBold.Render("Processors"), fmt.Sprintf("%d", cuda.Processors)})
}
}
if 0 < len(cracker.Metal) {
for _, metal := range cracker.Metal {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render("Metal Device"), console.StyleBoldGreen.Render(fmt.Sprintf("%s (%s)", metal.Name, metal.Version))})
tw.AppendRow(table.Row{console.StyleBold.Render("Memory"), fmt.Sprintf("%s free of %s", metal.MemoryFree, metal.MemoryTotal)})
tw.AppendRow(table.Row{console.StyleBold.Render("Clock"), fmt.Sprintf("%d", metal.Clock)})
tw.AppendRow(table.Row{console.StyleBold.Render("Processors"), fmt.Sprintf("%d", metal.Processors)})
}
}
if 0 < len(cracker.OpenCL) {
for _, openCL := range cracker.OpenCL {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render("OpenCL Device"), console.StyleBoldGreen.Render(fmt.Sprintf("%s (%s)", openCL.Name, openCL.Version))})
tw.AppendRow(table.Row{console.StyleBold.Render("Memory"), fmt.Sprintf("%s free of %s", openCL.MemoryFree, openCL.MemoryTotal)})
tw.AppendRow(table.Row{console.StyleBold.Render("Clock"), fmt.Sprintf("%d", openCL.Clock)})
tw.AppendRow(table.Row{console.StyleBold.Render("Processors"), fmt.Sprintf("%d", openCL.Processors)})
}
}
con.Printf("%s\n", tw.Render())
func printCracker(cracker *clientpb.Crackstation, index int, con *console.SliverClient, showBenchmarks bool, backendInfoLevel uint32) {
con.Printf("%s\n", renderCracker(cracker, index, settings.GetTableStyle(con), backendInfoLevel))
if showBenchmarks {
con.Println()
printBenchmarks(cracker, con)
}
}
func renderCracker(cracker *clientpb.Crackstation, index int, style table.Style, backendInfoLevel uint32) string {
if cracker == nil {
return ""
}
tw := table.NewWriter()
tw.SetStyle(style)
tw.SetTitle(console.StyleBoldOrange.Render(fmt.Sprintf(">>> Crackstation %02d - %s (%s)", index+1, safeCrackCell(cracker.Name), safeCrackCell(cracker.OperatorName))) + "\n")
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render("Operating System"), fmt.Sprintf("%s/%s", valueOrDash(cracker.GOOS), valueOrDash(cracker.GOARCH))})
tw.AppendRow(table.Row{console.StyleBold.Render("Hashcat Version"), valueOrDash(cracker.HashcatVersion)})
if backendInfoLevel >= 2 {
appendCrackstationDetailRows(tw, cracker)
}
for _, cuda := range cracker.CUDA {
appendCUDABackendRows(tw, cuda, backendInfoLevel)
}
for _, hip := range cracker.HIP {
appendHIPBackendRows(tw, hip, backendInfoLevel)
}
for _, metal := range cracker.Metal {
appendMetalBackendRows(tw, metal, backendInfoLevel)
}
for _, openCL := range cracker.OpenCL {
appendOpenCLBackendRows(tw, openCL, backendInfoLevel)
}
if !hasReportedBackendDevice(cracker) {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.StyleBold.Render("Backend Devices"), "No backend devices reported"})
}
return tw.Render()
}
func formatCUDADevice(device *clientpb.CUDABackendInfo) string {
return formatBackendDevice(device.GetName(), device.GetCUDAVersion(), device.GetVersion())
}
func formatHIPDevice(device *clientpb.HIPBackendInfo) string {
return formatBackendDevice(device.GetName(), device.GetHIPVersion(), device.GetVersion())
}
func formatMetalDevice(device *clientpb.MetalBackendInfo) string {
return formatBackendDevice(device.GetName(), device.GetMetalVersion(), device.GetVersion())
}
func formatOpenCLDevice(device *clientpb.OpenCLBackendInfo) string {
return formatBackendDevice(device.GetName(), device.GetOpenCLVersion(), device.GetVersion())
}
func formatBackendDevice(name string, versions ...string) string {
for _, version := range versions {
if version != "" {
return fmt.Sprintf("%s (%s)", name, version)
}
}
return name
}
func printBenchmarks(cracker *clientpb.Crackstation, con *console.SliverClient) {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
@@ -0,0 +1,188 @@
package crack
import (
"strings"
"testing"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestFormatBackendDevice(t *testing.T) {
tests := []struct {
name string
device string
versions []string
want string
}{
{
name: "specific runtime version wins",
device: "Apple M5",
versions: []string{"Metal 410.7", "legacy-generic"},
want: "Apple M5 (Metal 410.7)",
},
{
name: "legacy generic fallback",
device: "NVIDIA GPU",
versions: []string{"", "13.0"},
want: "NVIDIA GPU (13.0)",
},
{
name: "no empty version suffix",
device: "AMD GPU",
versions: []string{"", ""},
want: "AMD GPU",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := formatBackendDevice(test.device, test.versions...); got != test.want {
t.Fatalf("formatBackendDevice() = %q, want %q", got, test.want)
}
})
}
}
func TestBackendSpecificDisplayVersions(t *testing.T) {
tests := []struct {
name string
got string
want string
}{
{
name: "CUDA version",
got: formatCUDADevice(&clientpb.CUDABackendInfo{
Name: "NVIDIA GPU", CUDAVersion: "13.0", Version: "legacy",
}),
want: "NVIDIA GPU (13.0)",
},
{
name: "HIP version",
got: formatHIPDevice(&clientpb.HIPBackendInfo{
Name: "AMD GPU", HIPVersion: "7.0.51831", Version: "legacy",
}),
want: "AMD GPU (7.0.51831)",
},
{
name: "Metal version",
got: formatMetalDevice(&clientpb.MetalBackendInfo{
Name: "Apple M5", MetalVersion: "Metal 410.7", Version: "legacy",
}),
want: "Apple M5 (Metal 410.7)",
},
{
name: "legacy CUDA fallback",
got: formatCUDADevice(&clientpb.CUDABackendInfo{
Name: "NVIDIA GPU", Version: "12.9",
}),
want: "NVIDIA GPU (12.9)",
},
{
name: "OpenCL version",
got: formatOpenCLDevice(&clientpb.OpenCLBackendInfo{
Name: "Portable GPU", OpenCLVersion: "OpenCL 3.0", Version: "legacy",
}),
want: "Portable GPU (OpenCL 3.0)",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.got != test.want {
t.Fatalf("backend display = %q, want %q", test.got, test.want)
}
})
}
}
func TestRenderCrackerBackendInfoLevelTwo(t *testing.T) {
zero := uint32(0)
falseValue := false
station := &clientpb.Crackstation{
Name: "station",
OperatorName: "operator",
HostUUID: "host-uuid",
Version: "station-v1",
GOOS: "linux",
GOARCH: "amd64",
HashcatVersion: "v7.1.2",
CUDA: []*clientpb.CUDABackendInfo{{
Name: "CUDA GPU", CUDAVersion: "CUDA 13", Version: "device-v1", Type: "GPU",
Vendor: "NVIDIA", VendorID: 4318, BackendDeviceID: 1, BackendDeviceAlias: &zero,
PreferredThreadSize: &zero, MemoryUnified: &falseValue, LocalMemory: "64 KB",
CacheSize: "8 MB", PCIAddress: "01:00.0", MemoryFree: "8 GB", MemoryTotal: "12 GB",
}},
HIP: []*clientpb.HIPBackendInfo{{Name: "HIP GPU", HIPVersion: "HIP 7", BackendDeviceID: 2}},
Metal: []*clientpb.MetalBackendInfo{{
Name: "Metal GPU", MetalVersion: "Metal 410", BackendDeviceID: 3,
RegistryID: &zero, GPUHeadless: &falseValue, GPULowPower: &falseValue, GPURemovable: &falseValue,
PhysicalLocation: "built-in", MaxTXRate: "100", MemoryAllocPerBlock: "4 GB",
}},
OpenCL: []*clientpb.OpenCLBackendInfo{{
Name: "OpenCL GPU", OpenCLVersion: "OpenCL 3.0", OpenCLDriverVersion: "driver-v1",
BackendDeviceID: 4, PlatformVendor: "Portable", PlatformName: "Platform",
PlatformVersion: "platform-v1", MemoryAllocPerBlock: "2 GB",
}},
}
output := renderCracker(station, 0, settings.SliverDefault, 2)
for _, expected := range []string{
"station", "operator", "host-uuid", "station-v1", "linux/amd64", "v7.1.2",
"CUDA GPU", "HIP GPU", "Metal GPU", "OpenCL GPU", "OpenCL 3.0", "driver-v1",
"Backend Device ID", "Backend Device Alias", "Preferred Thread Size", "Unified Memory",
"NVIDIA", "4318", "device-v1", "64 KB", "8 MB", "01:00.0",
"Registry ID", "Headless", "Low Power", "Removable", "built-in", "100",
"OpenCL Platform ID", "Portable", "Platform", "platform-v1", "2 GB",
} {
if !strings.Contains(output, expected) {
t.Errorf("level-2 backend info does not contain %q:\n%s", expected, output)
}
}
}
func TestRenderCrackerBackendInfoLevelOneOmitsDetails(t *testing.T) {
station := &clientpb.Crackstation{
Name: "station",
CUDA: []*clientpb.CUDABackendInfo{{
Name: "GPU", BackendDeviceID: 7, PCIAddress: "01:00.0",
}},
}
output := renderCracker(station, 0, settings.SliverDefault, 1)
for _, unexpected := range []string{"Host UUID", "Backend Device ID", "PCI Address"} {
if strings.Contains(output, unexpected) {
t.Errorf("level-1 backend info unexpectedly contains %q:\n%s", unexpected, output)
}
}
}
func TestRenderCrackerPreservesOptionalPresence(t *testing.T) {
zero := uint32(0)
falseValue := false
withPresence := renderCracker(&clientpb.Crackstation{
CUDA: []*clientpb.CUDABackendInfo{{
BackendDeviceAlias: &zero,
MemoryUnified: &falseValue,
}},
}, 0, settings.SliverDefault, 2)
if !strings.Contains(withPresence, "Backend Device Alias") || !strings.Contains(withPresence, "Unified Memory") {
t.Fatalf("present zero/false optional fields were omitted:\n%s", withPresence)
}
withoutPresence := renderCracker(&clientpb.Crackstation{
CUDA: []*clientpb.CUDABackendInfo{{}},
}, 0, settings.SliverDefault, 2)
if strings.Contains(withoutPresence, "Backend Device Alias") || strings.Contains(withoutPresence, "Unified Memory") {
t.Fatalf("absent optional fields were rendered:\n%s", withoutPresence)
}
}
func TestRenderCrackerReportsNoBackendDevices(t *testing.T) {
output := renderCracker(&clientpb.Crackstation{
Name: "station",
CUDA: []*clientpb.CUDABackendInfo{nil},
}, 0, settings.SliverDefault, 1)
if !strings.Contains(output, "No backend devices reported") {
t.Fatalf("missing no-device message:\n%s", output)
}
}
+3 -411
View File
@@ -1,419 +1,11 @@
package crack
import "fmt"
// Hashcat mode names sourced from client/credentials/EXAMPLES.md.
var hashcatHashTypes = map[int32]string{
0: "MD5",
10: "md5($pass.$salt)",
11: "Joomla < 2.5.18",
12: "PostgreSQL",
20: "md5($salt.$pass)",
21: "osCommerce, xt:Commerce",
22: "Juniper NetScreen/SSG (ScreenOS)",
23: "Skype",
24: "SolarWinds Serv-U",
30: "md5(utf16le($pass).$salt)",
40: "md5($salt.utf16le($pass))",
50: "HMAC-MD5 (key = $pass)",
60: "HMAC-MD5 (key = $salt)",
70: "md5(utf16le($pass))",
100: "SHA1",
101: "nsldap, SHA-1(Base64), Netscape LDAP SHA",
110: "sha1($pass.$salt)",
111: "nsldaps, SSHA-1(Base64), Netscape LDAP SSHA",
112: "Oracle S: Type (Oracle 11+)",
120: "sha1($salt.$pass)",
121: "SMF (Simple Machines Forum) > v1.1",
122: "macOS v10.4, macOS v10.5, macOS v10.6",
123: "EPi",
124: "Django (SHA-1)",
125: "ArubaOS",
130: "sha1(utf16le($pass).$salt)",
131: "MSSQL (2000)",
132: "MSSQL (2005)",
133: "PeopleSoft",
140: "sha1($salt.utf16le($pass))",
141: "Episerver 6.x < .NET 4",
150: "HMAC-SHA1 (key = $pass)",
160: "HMAC-SHA1 (key = $salt)",
170: "sha1(utf16le($pass))",
190: "sha1(LinkedIn)",
200: "MySQL323",
300: "MySQL4.1/MySQL5",
400: "phpass, phpBB3 (MD5)",
500: "md5crypt, MD5 (Unix), Cisco-IOS $1$ (MD5)",
501: "Juniper IVE",
600: "BLAKE2b-512",
900: "MD4",
1000: "NTLM",
1100: "Domain Cached Credentials (DCC), MS Cache",
1300: "SHA2-224",
1400: "SHA2-256",
1410: "sha256($pass.$salt)",
1411: "SSHA-256(Base64), LDAP {SSHA256}",
1420: "sha256($salt.$pass)",
1421: "hMailServer",
1430: "sha256(utf16le($pass).$salt)",
1431: "base64(sha256(unicode($pass)))",
1440: "sha256($salt.utf16le($pass))",
1441: "Episerver 6.x >= .NET 4",
1450: "HMAC-SHA256 (key = $pass)",
1460: "HMAC-SHA256 (key = $salt)",
1470: "sha256(utf16le($pass))",
1500: "descrypt, DES (Unix), Traditional DES",
1600: "Apache $apr1$ MD5, md5apr1, MD5 (APR)",
1700: "SHA2-512",
1710: "sha512($pass.$salt)",
1711: "SSHA-512(Base64), LDAP {SSHA512}",
1720: "sha512($salt.$pass)",
1722: "macOS v10.7",
1730: "sha512(utf16le($pass).$salt)",
1731: "MSSQL (2012, 2014)",
1740: "sha512($salt.utf16le($pass))",
1750: "HMAC-SHA512 (key = $pass)",
1760: "HMAC-SHA512 (key = $salt)",
1770: "sha512(utf16le($pass))",
1800: "sha512crypt $6$, SHA512 (Unix)",
2000: "STDOUT",
2100: "Domain Cached Credentials 2 (DCC2), MS Cache 2",
2400: "Cisco-PIX MD5",
2410: "Cisco-ASA MD5",
2500: "WPA-EAPOL-PBKDF2",
2501: "WPA-EAPOL-PMK",
2600: "md5(md5($pass))",
2611: "vBulletin < v3.8.5",
2612: "PHPS",
2711: "vBulletin >= v3.8.5",
2811: "MyBB 1.2+, IPB2+ (Invision Power Board)",
3000: "LM",
3100: "Oracle H: Type (Oracle 7+)",
3200: "bcrypt $2\\*$, Blowfish (Unix)",
3300: "MD5(Sun)",
3500: "md5(md5(md5($pass)))",
3610: "md5(md5($salt).$pass )",
3710: "md5($salt.md5($pass))",
3711: "MediaWiki B type",
3720: "md5($pass.md5($salt))",
3721: "WebEdition CMS",
3800: "md5($salt.$pass.$salt)",
3910: "md5(md5($pass).md5($salt))",
4010: "md5($salt.md5($salt.$pass))",
4110: "md5($salt.md5($pass.$salt))",
4210: "md5($username.0.$pass)",
4300: "md5(strtoupper(md5($pass)))",
4400: "md5(sha1($pass))",
4500: "sha1(sha1($pass))",
4510: "sha1(sha1($pass).$salt)",
4520: "sha1($salt.sha1($pass))",
4521: "Redmine",
4522: "PunBB",
4600: "sha1(sha1(sha1($pass)))",
4700: "sha1(md5($pass))",
4710: "sha1(md5($pass).$salt)",
4711: "Huawei sha1(md5($pass).$salt)",
4800: "iSCSI CHAP authentication, MD5(CHAP)",
4900: "sha1($salt.$pass.$salt)",
5000: "sha1(sha1($salt.$pass.$salt))",
5100: "Half MD5",
5200: "Password Safe v3",
5300: "IKE-PSK MD5",
5400: "IKE-PSK SHA1",
5500: "NetNTLMv1 / NetNTLMv1+ESS",
5600: "NetNTLMv2",
5700: "Cisco-IOS type 4 (SHA256)",
5800: "Samsung Android Password/PIN",
6000: "RIPEMD-160",
6100: "Whirlpool",
6211: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish",
6212: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-AES / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish-Serpent",
6213: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish-Serpent / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-Twofish-AES",
6221: "TrueCrypt 5.0+ SHA512 + AES / TrueCrypt 5.0+ SHA512 + Serpent / TrueCrypt 5.0+ SHA512 + Twofish",
6222: "TrueCrypt 5.0+ SHA512 + AES-Twofish / TrueCrypt 5.0+ SHA512 + Serpent-AES / TrueCrypt 5.0+ SHA512 + Twofish-Serpent",
6223: "TrueCrypt 5.0+ SHA512 + AES-Twofish-Serpent / TrueCrypt 5.0+ SHA512 + Serpent-Twofish-AES",
6231: "TrueCrypt 5.0+ Whirlpool + AES / TrueCrypt 5.0+ Whirlpool + Serpent / TrueCrypt 5.0+ Whirlpool + Twofish",
6232: "TrueCrypt 5.0+ Whirlpool + AES-Twofish / TrueCrypt 5.0+ Whirlpool + Serpent-AES / TrueCrypt 5.0+ Whirlpool + Twofish-Serpent",
6233: "TrueCrypt 5.0+ Whirlpool + AES-Twofish-Serpent / TrueCrypt 5.0+ Whirlpool + Serpent-Twofish-AES",
6241: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish + boot",
6242: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-AES + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish-Serpent + boot",
6243: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish-Serpent + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-Twofish-AES + boot",
6300: "AIX {smd5}",
6400: "AIX {ssha256}",
6500: "AIX {ssha512}",
6600: "1Password, agilekeychain",
6700: "AIX {ssha1}",
6800: "LastPass + LastPass sniffed",
6900: "GOST R 34.11-94",
7000: "FortiGate (FortiOS)",
7100: "macOS v10.8+ (PBKDF2-SHA512)",
7200: "GRUB 2",
7300: "IPMI2 RAKP HMAC-SHA1",
7400: "sha256crypt $5$, SHA256 (Unix)",
7401: "MySQL $A$ (sha256crypt)",
7500: "Kerberos 5, etype 23, AS-REQ Pre-Auth",
7700: "SAP CODVN B (BCODE)",
7701: "SAP CODVN B (BCODE) from RFC_READ_TABLE",
7800: "SAP CODVN F/G (PASSCODE)",
7801: "SAP CODVN F/G (PASSCODE) from RFC_READ_TABLE",
7900: "Drupal7",
8000: "Sybase ASE",
8100: "Citrix NetScaler (SHA1)",
8200: "1Password, cloudkeychain",
8300: "DNSSEC (NSEC3)",
8400: "WBB3 (Woltlab Burning Board)",
8500: "RACF",
8600: "Lotus Notes/Domino 5",
8700: "Lotus Notes/Domino 6",
8800: "Android FDE <= 4.3",
8900: "scrypt",
9000: "Password Safe v2",
9100: "Lotus Notes/Domino 8",
9200: "Cisco-IOS $8$ (PBKDF2-SHA256)",
9300: "Cisco-IOS $9$ (scrypt)",
9400: "MS Office 2007",
9500: "MS Office 2010",
9600: "MS Office 2013",
9700: "MS Office <= 2003 MD5 + RC4, oldoffice$0, oldoffice$1",
9710: "MS Office <= 2003 $0/$1, MD5 + RC4, collider #1",
9720: "MS Office <= 2003 $0/$1, MD5 + RC4, collider #2",
9800: "MS Office <= 2003 SHA1 + RC4, oldoffice$3, oldoffice$4",
9810: "MS Office <= 2003 $3, SHA1 + RC4, collider #1",
9820: "MS Office <= 2003 $3, SHA1 + RC4, collider #2",
9900: "Radmin2",
10000: "Django (PBKDF2-SHA256)",
10100: "SipHash",
10200: "CRAM-MD5",
10300: "SAP CODVN H (PWDSALTEDHASH) iSSHA-1",
10400: "PDF 1.1 - 1.3 (Acrobat 2 - 4)",
10410: "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #1",
10420: "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #2",
10500: "PDF 1.4 - 1.6 (Acrobat 5 - 8)",
10600: "PDF 1.7 Level 3 (Acrobat 9)",
10700: "PDF 1.7 Level 8 (Acrobat 10 - 11)",
10800: "SHA2-384",
10810: "sha384($pass.$salt)",
10820: "sha384($salt.$pass)",
10830: "sha384(utf16le($pass).$salt)",
10840: "sha384($salt.utf16le($pass))",
10870: "sha384(utf16le($pass))",
10900: "PBKDF2-HMAC-SHA256",
10901: "RedHat 389-DS LDAP (PBKDF2-HMAC-SHA256)",
11000: "PrestaShop",
11100: "PostgreSQL CRAM (MD5)",
11200: "MySQL CRAM (SHA1)",
11300: "Bitcoin/Litecoin wallet.dat",
11400: "SIP digest authentication (MD5)",
11500: "CRC32",
11600: "7-Zip",
11700: "GOST R 34.11-2012 (Streebog) 256-bit, big-endian",
11750: "HMAC-Streebog-256 (key = $pass), big-endian",
11760: "HMAC-Streebog-256 (key = $salt), big-endian",
11800: "GOST R 34.11-2012 (Streebog) 512-bit, big-endian",
11850: "HMAC-Streebog-512 (key = $pass), big-endian",
11860: "HMAC-Streebog-512 (key = $salt), big-endian",
11900: "PBKDF2-HMAC-MD5",
12000: "PBKDF2-HMAC-SHA1",
12001: "Atlassian (PBKDF2-HMAC-SHA1)",
12100: "PBKDF2-HMAC-SHA512",
12200: "eCryptfs",
12300: "Oracle T: Type (Oracle 12+)",
12400: "BSDi Crypt, Extended DES",
12500: "RAR3-hp",
12600: "ColdFusion 10+",
12700: "Blockchain, My Wallet",
12800: "MS-AzureSync PBKDF2-HMAC-SHA256",
12900: "Android FDE (Samsung DEK)",
13000: "RAR5",
13100: "Kerberos 5, etype 23, TGS-REP",
13200: "AxCrypt 1",
13300: "AxCrypt 1 in-memory SHA1",
13400: "KeePass 1 AES / without keyfile / KeePass 2 AES / without keyfile / KeePass 1 Twofish / with keyfile / Keepass 2 AES / with keyfile",
13500: "PeopleSoft PS_TOKEN",
13600: "WinZip",
13711: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + AES / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Serpent / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Twofish",
13712: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + AES-Twofish / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Serpent-AES / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Twofish-Serpent",
13713: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + Serpent-Twofish-AES",
13721: "VeraCrypt PBKDF2-HMAC-SHA512 + AES / VeraCrypt PBKDF2-HMAC-SHA512 + Serpent / VeraCrypt PBKDF2-HMAC-SHA512 + Twofish",
13722: "VeraCrypt PBKDF2-HMAC-SHA512 + AES-Twofish / VeraCrypt PBKDF2-HMAC-SHA512 + Serpent-AES / VeraCrypt PBKDF2-HMAC-SHA512 + Twofish-Serpent",
13723: "VeraCrypt PBKDF2-HMAC-SHA512 + Serpent-Twofish-AES",
13731: "VeraCrypt PBKDF2-HMAC-Whirlpool + AES / VeraCrypt PBKDF2-HMAC-Whirlpool + Serpent / VeraCrypt PBKDF2-HMAC-Whirlpool + Twofish",
13732: "VeraCrypt PBKDF2-HMAC-Whirlpool + AES-Twofish / VeraCrypt PBKDF2-HMAC-Whirlpool + Serpent-AES / VeraCrypt PBKDF2-HMAC-Whirlpool + Twofish-Serpent",
13733: "VeraCrypt PBKDF2-HMAC-Whirlpool + Serpent-Twofish-AES",
13741: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + boot-mode + AES",
13742: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + boot-mode + AES-Twofish",
13743: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + boot-mode + AES-Twofish-Serpent",
13751: "VeraCrypt PBKDF2-HMAC-SHA256 + AES / VeraCrypt PBKDF2-HMAC-SHA256 + Serpent / VeraCrypt PBKDF2-HMAC-SHA256 + Twofish",
13752: "VeraCrypt PBKDF2-HMAC-SHA256 + AES-Twofish / VeraCrypt PBKDF2-HMAC-SHA256 + Serpent-AES / VeraCrypt PBKDF2-HMAC-SHA256 + Twofish-Serpent",
13753: "VeraCrypt PBKDF2-HMAC-SHA256 + Serpent-Twofish-AES",
13761: "VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + Twofish / VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + PIM + AES",
13762: "VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + Serpent-AES",
13763: "VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + Serpent-Twofish-AES",
13771: "VeraCrypt Streebog-512 + XTS 512 bit",
13772: "VeraCrypt Streebog-512 + XTS 1024 bit",
13773: "VeraCrypt Streebog-512 + XTS 1536 bit",
13800: "Windows Phone 8+ PIN/password",
13900: "OpenCart",
14000: "DES (PT = $salt, key = $pass)",
14100: "3DES (PT = $salt, key = $pass)",
14400: "sha1(CX)",
14500: "Linux Kernel Crypto API (2.4)",
14600: "LUKS",
14700: "iTunes backup < 10.0",
14800: "iTunes backup >= 10.0",
14900: "Skip32 (PT = $salt, key = $pass)",
15000: "FileZilla Server >= 0.9.55",
15100: "Juniper/NetBSD sha1crypt",
15200: "Blockchain, My Wallet, V2",
15300: "DPAPI masterkey file v1 + local context",
15400: "ChaCha20",
15500: "JKS Java Key Store Private Keys (SHA1)",
15600: "Ethereum Wallet, PBKDF2-HMAC-SHA256",
15700: "Ethereum Wallet, SCRYPT",
15900: "DPAPI masterkey file v2 + Active Directory domain context",
16000: "Tripcode",
16100: "TACACS+",
16200: "Apple Secure Notes",
16300: "Ethereum Pre-Sale Wallet, PBKDF2-HMAC-SHA256",
16400: "CRAM-MD5 Dovecot",
16500: "JWT (JSON Web Token)",
16600: "Electrum Wallet (Salt-Type 1-3)",
16700: "FileVault 2",
16800: "WPA-PMKID-PBKDF2",
16801: "WPA-PMKID-PMK",
16900: "Ansible Vault",
17200: "PKZIP (Compressed)",
17210: "PKZIP (Uncompressed)",
17220: "PKZIP (Compressed Multi-File)",
17225: "PKZIP (Mixed Multi-File)",
17230: "PKZIP (Mixed Multi-File Checksum-Only)",
17300: "SHA3-224",
17400: "SHA3-256",
17500: "SHA3-384",
17600: "SHA3-512",
17700: "Keccak-224",
17800: "Keccak-256",
17900: "Keccak-384",
18000: "Keccak-512",
18100: "TOTP (HMAC-SHA1)",
18200: "Kerberos 5, etype 23, AS-REP",
18300: "Apple File System (APFS)",
18400: "Open Document Format (ODF) 1.2 (SHA-256, AES)",
18500: "sha1(md5(md5($pass)))",
18600: "Open Document Format (ODF) 1.1 (SHA-1, Blowfish)",
18700: "Java Object hashCode()",
18800: "Blockchain, My Wallet, Second Password (SHA256)",
18900: "Android Backup",
19000: "QNX /etc/shadow (MD5)",
19100: "QNX /etc/shadow (SHA256)",
19200: "QNX /etc/shadow (SHA512)",
19300: "sha1($salt1.$pass.$salt2)",
19500: "Ruby on Rails Restful-Authentication",
19600: "Kerberos 5, etype 17, TGS-REP (AES128-CTS-HMAC-SHA1-96)",
19700: "Kerberos 5, etype 18, TGS-REP (AES256-CTS-HMAC-SHA1-96)",
19800: "Kerberos 5, etype 17, Pre-Auth",
19900: "Kerberos 5, etype 18, Pre-Auth",
20011: "DiskCryptor SHA512 + XTS 512 bit (AES) / DiskCryptor SHA512 + XTS 512 bit (Twofish) / DiskCryptor SHA512 + XTS 512 bit (Serpent)",
20012: "DiskCryptor SHA512 + XTS 1024 bit (AES-Twofish) / DiskCryptor SHA512 + XTS 1024 bit (Twofish-Serpent) / DiskCryptor SHA512 + XTS 1024 bit (Serpent-AES)",
20013: "DiskCryptor SHA512 + XTS 1536 bit (AES-Twofish-Serpent)",
20200: "Python passlib pbkdf2-sha512",
20300: "Python passlib pbkdf2-sha256",
20400: "Python passlib pbkdf2-sha1",
20500: "PKZIP Master Key",
20510: "PKZIP Master Key (6 byte optimization)",
20600: "Oracle Transportation Management (SHA256)",
20710: "sha256(sha256($pass).$salt)",
20711: "AuthMe sha256",
20720: "sha256($salt.sha256($pass))",
20800: "sha256(md5($pass))",
20900: "md5(sha1($pass).md5($pass).sha1($pass))",
21000: "BitShares v0.x - sha512(sha512_bin(pass))",
21100: "sha1(md5($pass.$salt))",
21200: "md5(sha1($salt).md5($pass))",
21300: "md5($salt.sha1($salt.$pass))",
21400: "sha256(sha256_bin($pass))",
21500: "SolarWinds Orion",
21501: "SolarWinds Orion v2",
21600: "Web2py pbkdf2-sha512",
21700: "Electrum Wallet (Salt-Type 4)",
21800: "Electrum Wallet (Salt-Type 5)",
22000: "WPA-PBKDF2-PMKID+EAPOL",
22001: "WPA-PMK-PMKID+EAPOL",
22100: "BitLocker",
22200: "Citrix NetScaler (SHA512)",
22300: "sha256($salt.$pass.$salt)",
22301: "Telegram Mobile App Passcode (SHA256)",
22400: "AES Crypt (SHA256)",
22500: "MultiBit Classic .key (MD5)",
22600: "Telegram Desktop < v2.1.14 (PBKDF2-HMAC-SHA1)",
22700: "MultiBit HD (scrypt)",
22911: "RSA/DSA/EC/OpenSSH Private Keys ($0$)",
22921: "RSA/DSA/EC/OpenSSH Private Keys ($6$)",
22931: "RSA/DSA/EC/OpenSSH Private Keys ($1, $3$)",
22941: "RSA/DSA/EC/OpenSSH Private Keys ($4$)",
22951: "RSA/DSA/EC/OpenSSH Private Keys ($5$)",
23001: "SecureZIP AES-128",
23002: "SecureZIP AES-192",
23003: "SecureZIP AES-256",
23100: "Apple Keychain",
23200: "XMPP SCRAM PBKDF2-SHA1",
23300: "Apple iWork",
23400: "Bitwarden",
23500: "AxCrypt 2 AES-128",
23600: "AxCrypt 2 AES-256",
23700: "RAR3-p (Uncompressed)",
23800: "RAR3-p (Compressed)",
23900: "BestCrypt v3 Volume Encryption",
24100: "MongoDB ServerKey SCRAM-SHA-1",
24200: "MongoDB ServerKey SCRAM-SHA-256",
24300: "sha1($salt.sha1($pass.$salt))",
24410: "PKCS#8 Private Keys (PBKDF2-HMAC-SHA1 + 3DES/AES)",
24420: "PKCS#8 Private Keys (PBKDF2-HMAC-SHA256 + 3DES/AES)",
24500: "Telegram Desktop >= v2.1.14 (PBKDF2-HMAC-SHA512)",
24600: "SQLCipher",
24700: "Stuffit5",
24800: "Umbraco HMAC-SHA1",
24900: "Dahua Authentication MD5",
25300: "MS Office 2016 - SheetProtection",
25400: "PDF 1.4 - 1.6 (Acrobat 5 - 8) - edit password",
25500: "Stargazer Stellar Wallet XLM",
25600: "bcrypt(md5($pass)) / bcryptmd5",
25700: "MurmurHash",
25800: "bcrypt(sha1($pass)) / bcryptsha1",
25900: "KNX IP Secure - Device Authentication Code",
26000: "Mozilla key3.db",
26100: "Mozilla key4.db",
26200: "OpenEdge Progress Encode",
26300: "FortiGate256 (FortiOS256)",
26401: "AES-128-ECB NOKDF (PT = $salt, key = $pass)",
26402: "AES-192-ECB NOKDF (PT = $salt, key = $pass)",
26403: "AES-256-ECB NOKDF (PT = $salt, key = $pass)",
26500: "iPhone passcode (UID key + System Keybag)",
26600: "MetaMask Wallet",
99999: "Plaintext",
}
import "github.com/bishopfox/sliver/client/command/crack/internal/hashcatdisplay"
func hashcatHashTypeName(mode int32) (string, bool) {
name, ok := hashcatHashTypes[mode]
return name, ok
return hashcatdisplay.TypeName(mode)
}
func humanizeHashRate(rate uint64) string {
const unit = 1000
if rate < unit {
return fmt.Sprintf("%d H/s", rate)
}
value := float64(rate)
exp := 0
for value >= unit && exp < 5 {
value /= unit
exp++
}
suffixes := []string{"kH/s", "MH/s", "GH/s", "TH/s", "PH/s"}
if exp == 0 {
return fmt.Sprintf("%d H/s", rate)
}
if exp > len(suffixes) {
exp = len(suffixes)
}
return fmt.Sprintf("%.2f %s", value, suffixes[exp-1])
return hashcatdisplay.HumanizeRate(rate)
}
+119 -5
View File
@@ -3,13 +3,127 @@ package crack
import (
"context"
"fmt"
"strings"
"time"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
)
type crackJobCompletionEntry struct {
id string
description string
}
// CrackJobIDCompleter completes durable crack job IDs.
//
//nolint:revive // Keep the established exported completer name for compatibility.
func CrackJobIDCompleter(con *console.SliverClient) carapace.Action {
return crackJobIDCompleter(con, nil)
}
func crackTerminalJobIDCompleter(con *console.SliverClient) carapace.Action {
return crackJobIDCompleter(con, crackJobTerminal)
}
func crackJobPausable(job *clientpb.CrackJob) bool {
return job != nil && job.GetStatus() == clientpb.CrackJobStatus_IN_PROGRESS
}
func crackJobResumable(job *clientpb.CrackJob) bool {
return job != nil && job.GetStatus() == clientpb.CrackJobStatus_PAUSED
}
func crackJobCancellable(job *clientpb.CrackJob) bool {
return crackJobPausable(job) || crackJobResumable(job)
}
func crackJobIDCompleter(con *console.SliverClient, include func(*clientpb.CrackJob) bool) carapace.Action {
return carapace.ActionCallback(func(_ carapace.Context) carapace.Action {
entries, err := crackJobCompletionEntriesFiltered(con, include)
if err != nil {
return carapace.ActionMessage("failed to fetch crack jobs: %s", err.Error())
}
values := make([]string, 0, len(entries)*2)
for _, entry := range entries {
values = append(values, entry.id, entry.description)
}
return carapace.ActionValuesDescribed(values...).Tag("crack jobs")
})
}
func registerCrackJobIDCompletion(cmd *cobra.Command, con *console.SliverClient) {
registerCrackJobIDCompletionFiltered(cmd, con, nil)
}
func registerCrackTerminalJobIDCompletion(cmd *cobra.Command, con *console.SliverClient) {
registerCrackJobIDCompletionFiltered(cmd, con, crackJobTerminal)
}
func registerCrackJobIDCompletionFiltered(cmd *cobra.Command, con *console.SliverClient, include func(*clientpb.CrackJob) bool) {
if cmd == nil || cmd.ValidArgsFunction != nil {
return
}
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
entries, err := crackJobCompletionEntriesFiltered(con, include)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
values := make([]string, 0, len(entries))
for _, entry := range entries {
if strings.HasPrefix(entry.id, toComplete) {
values = append(values, fmt.Sprintf("%s\t%s", entry.id, entry.description))
}
}
return values, cobra.ShellCompDirectiveNoFileComp
}
}
func crackJobCompletionEntries(con *console.SliverClient) ([]crackJobCompletionEntry, error) {
return crackJobCompletionEntriesFiltered(con, nil)
}
func crackJobCompletionEntriesFiltered(con *console.SliverClient, include func(*clientpb.CrackJob) bool) ([]crackJobCompletionEntry, error) {
if con == nil || con.Rpc == nil {
return nil, nil
}
jobs, err := con.Rpc.CrackJobs(context.Background(), &commonpb.Empty{})
if err != nil {
return nil, err
}
if jobs == nil {
return nil, nil
}
entries := make([]crackJobCompletionEntry, 0, len(jobs.GetJobs()))
for _, job := range jobs.GetJobs() {
if job == nil || strings.TrimSpace(job.GetID()) == "" || (include != nil && !include(job)) {
continue
}
entries = append(entries, crackJobCompletionEntry{
id: job.GetID(),
description: fmt.Sprintf(
"%s, created %s, keyspace %s, %d task(s), %d result(s)",
safeCrackCell(job.GetStatus().String()),
formatCrackTime(job.GetCreatedAt()),
valueOrDash(job.GetKeyspace()),
len(job.GetTasks()),
job.GetResultCount(),
),
})
}
return entries, nil
}
func CrackHcstat2Completer(con *console.SliverClient) carapace.Action {
return carapace.ActionCallback(func(c carapace.Context) carapace.Action {
hcstat2, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_MARKOV_HCSTAT2})
@@ -35,14 +149,14 @@ func CrackHcstat2Completer(con *console.SliverClient) carapace.Action {
func CrackWordlistCompleter(con *console.SliverClient) carapace.Action {
return carapace.ActionCallback(func(c carapace.Context) carapace.Action {
hcstat2, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_MARKOV_HCSTAT2})
wordlists, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_WORDLIST})
if err != nil {
return carapace.ActionMessage("failed to fetch crack files: %s", err.Error())
}
results := make([]string, 0)
for _, file := range hcstat2.Files {
for _, file := range wordlists.Files {
if file.Type != clientpb.CrackFileType_WORDLIST {
continue
}
@@ -59,14 +173,14 @@ func CrackWordlistCompleter(con *console.SliverClient) carapace.Action {
func CrackRulesCompleter(con *console.SliverClient) carapace.Action {
return carapace.ActionCallback(func(c carapace.Context) carapace.Action {
hcstat2, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_MARKOV_HCSTAT2})
rules, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_RULES})
if err != nil {
return carapace.ActionMessage("failed to fetch crack files: %s", err.Error())
}
results := make([]string, 0)
for _, file := range hcstat2.Files {
for _, file := range rules.Files {
if file.Type != clientpb.CrackFileType_RULES {
continue
}
@@ -77,7 +191,7 @@ func CrackRulesCompleter(con *console.SliverClient) carapace.Action {
results = append(results, desc)
}
return carapace.ActionValuesDescribed(results...).Tag("wordlists")
return carapace.ActionValuesDescribed(results...).Tag("rules")
})
}
+145
View File
@@ -0,0 +1,145 @@
package crack
import (
"context"
"errors"
"strings"
"testing"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"google.golang.org/grpc"
)
type crackFilesListCapture struct {
rpcpb.SliverRPCClient
requests []clientpb.CrackFileType
}
type crackJobsListCapture struct {
rpcpb.SliverRPCClient
jobs *clientpb.CrackJobs
err error
calls int
}
func (capture *crackJobsListCapture) CrackJobs(_ context.Context, _ *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.CrackJobs, error) {
capture.calls++
return capture.jobs, capture.err
}
func (capture *crackFilesListCapture) CrackFilesList(_ context.Context, request *clientpb.CrackFile, _ ...grpc.CallOption) (*clientpb.CrackFiles, error) {
capture.requests = append(capture.requests, request.GetType())
return &clientpb.CrackFiles{}, nil
}
func TestCrackFileCompletersRequestTheirOwnFileType(t *testing.T) {
tests := []struct {
name string
want clientpb.CrackFileType
action func(*console.SliverClient) carapace.Action
}{
{name: "wordlists", want: clientpb.CrackFileType_WORDLIST, action: CrackWordlistCompleter},
{name: "rules", want: clientpb.CrackFileType_RULES, action: CrackRulesCompleter},
{name: "hcstat2", want: clientpb.CrackFileType_MARKOV_HCSTAT2, action: CrackHcstat2Completer},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
capture := &crackFilesListCapture{}
client := &console.SliverClient{Rpc: capture}
_ = test.action(client).Invoke(carapace.NewContext(""))
if len(capture.requests) != 1 || capture.requests[0] != test.want {
t.Fatalf("completion requests = %v, want [%s]", capture.requests, test.want)
}
})
}
}
func TestCrackJobCompleterRequestsDurableJobs(t *testing.T) {
capture := &crackJobsListCapture{jobs: &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{{
ID: "12345678-1234-1234-1234-123456789abc",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
CreatedAt: "2026-09-11T12:00:00Z",
Keyspace: "1000",
Tasks: []*clientpb.CrackTask{{ID: "task-id"}},
ResultCount: 2,
}}}}
client := &console.SliverClient{Rpc: capture}
_ = CrackJobIDCompleter(client).Invoke(carapace.NewContext(""))
if capture.calls != 1 {
t.Fatalf("CrackJobs calls = %d, want 1", capture.calls)
}
}
func TestCrackJobCobraCompletionUsesFullIDsAndFiltersPrefix(t *testing.T) {
firstID := "12345678-1234-1234-1234-123456789abc"
secondID := "abcdefab-cdef-cdef-cdef-abcdefabcdef"
capture := &crackJobsListCapture{jobs: &clientpb.CrackJobs{Jobs: []*clientpb.CrackJob{
{
ID: firstID,
Status: clientpb.CrackJobStatus_IN_PROGRESS,
CreatedAt: "2026-09-11T12:00:00Z",
Keyspace: "1000",
Tasks: []*clientpb.CrackTask{{ID: "task-id"}},
ResultCount: 2,
},
{ID: secondID, Status: clientpb.CrackJobStatus_COMPLETED},
nil,
{},
}}}
client := &console.SliverClient{Rpc: capture}
root := Commands(client)[0]
job, _, err := root.Find([]string{"job"})
if err != nil {
t.Fatalf("find crack job: %v", err)
}
values, directive := job.ValidArgsFunction(job, nil, "1234")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("completion directive = %v, want no-file-completion", directive)
}
if len(values) != 1 {
t.Fatalf("completion values = %#v, want one filtered result", values)
}
if !strings.HasPrefix(values[0], firstID+"\t") {
t.Fatalf("completion = %q, want full ID %q", values[0], firstID)
}
for _, want := range []string{"IN_PROGRESS", "2026-09-11T12:00:00Z", "keyspace 1000", "1 task(s)", "2 result(s)"} {
if !strings.Contains(values[0], want) {
t.Errorf("completion %q does not contain %q", values[0], want)
}
}
values, directive = job.ValidArgsFunction(job, []string{firstID}, "")
if len(values) != 0 || directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("second positional completion = %#v, %v", values, directive)
}
}
func TestCrackJobCompletionHandlesRPCFailuresAndNilData(t *testing.T) {
for _, test := range []struct {
name string
capture *crackJobsListCapture
}{
{name: "rpc error", capture: &crackJobsListCapture{err: errors.New("rpc failed")}},
{name: "nil response", capture: &crackJobsListCapture{}},
} {
t.Run(test.name, func(t *testing.T) {
client := &console.SliverClient{Rpc: test.capture}
root := Commands(client)[0]
job, _, err := root.Find([]string{"job"})
if err != nil {
t.Fatalf("find crack job: %v", err)
}
values, directive := job.ValidArgsFunction(job, nil, "")
if len(values) != 0 || directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("completion = %#v, %v", values, directive)
}
})
}
}
@@ -0,0 +1,422 @@
// Package hashcatdisplay provides shared Hashcat presentation helpers for crack commands.
package hashcatdisplay
import "fmt"
// Hashcat mode names sourced from client/credentials/EXAMPLES.md.
var hashcatHashTypes = map[int32]string{
0: "MD5",
10: "md5($pass.$salt)",
11: "Joomla < 2.5.18",
12: "PostgreSQL",
20: "md5($salt.$pass)",
21: "osCommerce, xt:Commerce",
22: "Juniper NetScreen/SSG (ScreenOS)",
23: "Skype",
24: "SolarWinds Serv-U",
30: "md5(utf16le($pass).$salt)",
40: "md5($salt.utf16le($pass))",
50: "HMAC-MD5 (key = $pass)",
60: "HMAC-MD5 (key = $salt)",
70: "md5(utf16le($pass))",
100: "SHA1",
101: "nsldap, SHA-1(Base64), Netscape LDAP SHA",
110: "sha1($pass.$salt)",
111: "nsldaps, SSHA-1(Base64), Netscape LDAP SSHA",
112: "Oracle S: Type (Oracle 11+)",
120: "sha1($salt.$pass)",
121: "SMF (Simple Machines Forum) > v1.1",
122: "macOS v10.4, macOS v10.5, macOS v10.6",
123: "EPi",
124: "Django (SHA-1)",
125: "ArubaOS",
130: "sha1(utf16le($pass).$salt)",
131: "MSSQL (2000)",
132: "MSSQL (2005)",
133: "PeopleSoft",
140: "sha1($salt.utf16le($pass))",
141: "Episerver 6.x < .NET 4",
150: "HMAC-SHA1 (key = $pass)",
160: "HMAC-SHA1 (key = $salt)",
170: "sha1(utf16le($pass))",
190: "sha1(LinkedIn)",
200: "MySQL323",
300: "MySQL4.1/MySQL5",
400: "phpass, phpBB3 (MD5)",
500: "md5crypt, MD5 (Unix), Cisco-IOS $1$ (MD5)",
501: "Juniper IVE",
600: "BLAKE2b-512",
900: "MD4",
1000: "NTLM",
1100: "Domain Cached Credentials (DCC), MS Cache",
1300: "SHA2-224",
1400: "SHA2-256",
1410: "sha256($pass.$salt)",
1411: "SSHA-256(Base64), LDAP {SSHA256}",
1420: "sha256($salt.$pass)",
1421: "hMailServer",
1430: "sha256(utf16le($pass).$salt)",
1431: "base64(sha256(unicode($pass)))",
1440: "sha256($salt.utf16le($pass))",
1441: "Episerver 6.x >= .NET 4",
1450: "HMAC-SHA256 (key = $pass)",
1460: "HMAC-SHA256 (key = $salt)",
1470: "sha256(utf16le($pass))",
1500: "descrypt, DES (Unix), Traditional DES",
1600: "Apache $apr1$ MD5, md5apr1, MD5 (APR)",
1700: "SHA2-512",
1710: "sha512($pass.$salt)",
1711: "SSHA-512(Base64), LDAP {SSHA512}",
1720: "sha512($salt.$pass)",
1722: "macOS v10.7",
1730: "sha512(utf16le($pass).$salt)",
1731: "MSSQL (2012, 2014)",
1740: "sha512($salt.utf16le($pass))",
1750: "HMAC-SHA512 (key = $pass)",
1760: "HMAC-SHA512 (key = $salt)",
1770: "sha512(utf16le($pass))",
1800: "sha512crypt $6$, SHA512 (Unix)",
2000: "STDOUT",
2100: "Domain Cached Credentials 2 (DCC2), MS Cache 2",
2400: "Cisco-PIX MD5",
2410: "Cisco-ASA MD5",
2500: "WPA-EAPOL-PBKDF2",
2501: "WPA-EAPOL-PMK",
2600: "md5(md5($pass))",
2611: "vBulletin < v3.8.5",
2612: "PHPS",
2711: "vBulletin >= v3.8.5",
2811: "MyBB 1.2+, IPB2+ (Invision Power Board)",
3000: "LM",
3100: "Oracle H: Type (Oracle 7+)",
3200: "bcrypt $2\\*$, Blowfish (Unix)",
3300: "MD5(Sun)",
3500: "md5(md5(md5($pass)))",
3610: "md5(md5($salt).$pass )",
3710: "md5($salt.md5($pass))",
3711: "MediaWiki B type",
3720: "md5($pass.md5($salt))",
3721: "WebEdition CMS",
3800: "md5($salt.$pass.$salt)",
3910: "md5(md5($pass).md5($salt))",
4010: "md5($salt.md5($salt.$pass))",
4110: "md5($salt.md5($pass.$salt))",
4210: "md5($username.0.$pass)",
4300: "md5(strtoupper(md5($pass)))",
4400: "md5(sha1($pass))",
4500: "sha1(sha1($pass))",
4510: "sha1(sha1($pass).$salt)",
4520: "sha1($salt.sha1($pass))",
4521: "Redmine",
4522: "PunBB",
4600: "sha1(sha1(sha1($pass)))",
4700: "sha1(md5($pass))",
4710: "sha1(md5($pass).$salt)",
4711: "Huawei sha1(md5($pass).$salt)",
4800: "iSCSI CHAP authentication, MD5(CHAP)",
4900: "sha1($salt.$pass.$salt)",
5000: "sha1(sha1($salt.$pass.$salt))",
5100: "Half MD5",
5200: "Password Safe v3",
5300: "IKE-PSK MD5",
5400: "IKE-PSK SHA1",
5500: "NetNTLMv1 / NetNTLMv1+ESS",
5600: "NetNTLMv2",
5700: "Cisco-IOS type 4 (SHA256)",
5800: "Samsung Android Password/PIN",
6000: "RIPEMD-160",
6100: "Whirlpool",
6211: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish",
6212: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-AES / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish-Serpent",
6213: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish-Serpent / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-Twofish-AES",
6221: "TrueCrypt 5.0+ SHA512 + AES / TrueCrypt 5.0+ SHA512 + Serpent / TrueCrypt 5.0+ SHA512 + Twofish",
6222: "TrueCrypt 5.0+ SHA512 + AES-Twofish / TrueCrypt 5.0+ SHA512 + Serpent-AES / TrueCrypt 5.0+ SHA512 + Twofish-Serpent",
6223: "TrueCrypt 5.0+ SHA512 + AES-Twofish-Serpent / TrueCrypt 5.0+ SHA512 + Serpent-Twofish-AES",
6231: "TrueCrypt 5.0+ Whirlpool + AES / TrueCrypt 5.0+ Whirlpool + Serpent / TrueCrypt 5.0+ Whirlpool + Twofish",
6232: "TrueCrypt 5.0+ Whirlpool + AES-Twofish / TrueCrypt 5.0+ Whirlpool + Serpent-AES / TrueCrypt 5.0+ Whirlpool + Twofish-Serpent",
6233: "TrueCrypt 5.0+ Whirlpool + AES-Twofish-Serpent / TrueCrypt 5.0+ Whirlpool + Serpent-Twofish-AES",
6241: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish + boot",
6242: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-AES + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Twofish-Serpent + boot",
6243: "TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + AES-Twofish-Serpent + boot / TrueCrypt 5.0+ PBKDF2-HMAC-RIPEMD160 + Serpent-Twofish-AES + boot",
6300: "AIX {smd5}",
6400: "AIX {ssha256}",
6500: "AIX {ssha512}",
6600: "1Password, agilekeychain",
6700: "AIX {ssha1}",
6800: "LastPass + LastPass sniffed",
6900: "GOST R 34.11-94",
7000: "FortiGate (FortiOS)",
7100: "macOS v10.8+ (PBKDF2-SHA512)",
7200: "GRUB 2",
7300: "IPMI2 RAKP HMAC-SHA1",
7400: "sha256crypt $5$, SHA256 (Unix)",
7401: "MySQL $A$ (sha256crypt)",
7500: "Kerberos 5, etype 23, AS-REQ Pre-Auth",
7700: "SAP CODVN B (BCODE)",
7701: "SAP CODVN B (BCODE) from RFC_READ_TABLE",
7800: "SAP CODVN F/G (PASSCODE)",
7801: "SAP CODVN F/G (PASSCODE) from RFC_READ_TABLE",
7900: "Drupal7",
8000: "Sybase ASE",
8100: "Citrix NetScaler (SHA1)",
8200: "1Password, cloudkeychain",
8300: "DNSSEC (NSEC3)",
8400: "WBB3 (Woltlab Burning Board)",
8500: "RACF",
8600: "Lotus Notes/Domino 5",
8700: "Lotus Notes/Domino 6",
8800: "Android FDE <= 4.3",
8900: "scrypt",
9000: "Password Safe v2",
9100: "Lotus Notes/Domino 8",
9200: "Cisco-IOS $8$ (PBKDF2-SHA256)",
9300: "Cisco-IOS $9$ (scrypt)",
9400: "MS Office 2007",
9500: "MS Office 2010",
9600: "MS Office 2013",
9700: "MS Office <= 2003 MD5 + RC4, oldoffice$0, oldoffice$1",
9710: "MS Office <= 2003 $0/$1, MD5 + RC4, collider #1",
9720: "MS Office <= 2003 $0/$1, MD5 + RC4, collider #2",
9800: "MS Office <= 2003 SHA1 + RC4, oldoffice$3, oldoffice$4",
9810: "MS Office <= 2003 $3, SHA1 + RC4, collider #1",
9820: "MS Office <= 2003 $3, SHA1 + RC4, collider #2",
9900: "Radmin2",
10000: "Django (PBKDF2-SHA256)",
10100: "SipHash",
10200: "CRAM-MD5",
10300: "SAP CODVN H (PWDSALTEDHASH) iSSHA-1",
10400: "PDF 1.1 - 1.3 (Acrobat 2 - 4)",
10410: "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #1",
10420: "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #2",
10500: "PDF 1.4 - 1.6 (Acrobat 5 - 8)",
10600: "PDF 1.7 Level 3 (Acrobat 9)",
10700: "PDF 1.7 Level 8 (Acrobat 10 - 11)",
10800: "SHA2-384",
10810: "sha384($pass.$salt)",
10820: "sha384($salt.$pass)",
10830: "sha384(utf16le($pass).$salt)",
10840: "sha384($salt.utf16le($pass))",
10870: "sha384(utf16le($pass))",
10900: "PBKDF2-HMAC-SHA256",
10901: "RedHat 389-DS LDAP (PBKDF2-HMAC-SHA256)",
11000: "PrestaShop",
11100: "PostgreSQL CRAM (MD5)",
11200: "MySQL CRAM (SHA1)",
11300: "Bitcoin/Litecoin wallet.dat",
11400: "SIP digest authentication (MD5)",
11500: "CRC32",
11600: "7-Zip",
11700: "GOST R 34.11-2012 (Streebog) 256-bit, big-endian",
11750: "HMAC-Streebog-256 (key = $pass), big-endian",
11760: "HMAC-Streebog-256 (key = $salt), big-endian",
11800: "GOST R 34.11-2012 (Streebog) 512-bit, big-endian",
11850: "HMAC-Streebog-512 (key = $pass), big-endian",
11860: "HMAC-Streebog-512 (key = $salt), big-endian",
11900: "PBKDF2-HMAC-MD5",
12000: "PBKDF2-HMAC-SHA1",
12001: "Atlassian (PBKDF2-HMAC-SHA1)",
12100: "PBKDF2-HMAC-SHA512",
12200: "eCryptfs",
12300: "Oracle T: Type (Oracle 12+)",
12400: "BSDi Crypt, Extended DES",
12500: "RAR3-hp",
12600: "ColdFusion 10+",
12700: "Blockchain, My Wallet",
12800: "MS-AzureSync PBKDF2-HMAC-SHA256",
12900: "Android FDE (Samsung DEK)",
13000: "RAR5",
13100: "Kerberos 5, etype 23, TGS-REP",
13200: "AxCrypt 1",
13300: "AxCrypt 1 in-memory SHA1",
13400: "KeePass 1 AES / without keyfile / KeePass 2 AES / without keyfile / KeePass 1 Twofish / with keyfile / Keepass 2 AES / with keyfile",
13500: "PeopleSoft PS_TOKEN",
13600: "WinZip",
13711: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + AES / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Serpent / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Twofish",
13712: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + AES-Twofish / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Serpent-AES / VeraCrypt PBKDF2-HMAC-RIPEMD160 + Twofish-Serpent",
13713: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + Serpent-Twofish-AES",
13721: "VeraCrypt PBKDF2-HMAC-SHA512 + AES / VeraCrypt PBKDF2-HMAC-SHA512 + Serpent / VeraCrypt PBKDF2-HMAC-SHA512 + Twofish",
13722: "VeraCrypt PBKDF2-HMAC-SHA512 + AES-Twofish / VeraCrypt PBKDF2-HMAC-SHA512 + Serpent-AES / VeraCrypt PBKDF2-HMAC-SHA512 + Twofish-Serpent",
13723: "VeraCrypt PBKDF2-HMAC-SHA512 + Serpent-Twofish-AES",
13731: "VeraCrypt PBKDF2-HMAC-Whirlpool + AES / VeraCrypt PBKDF2-HMAC-Whirlpool + Serpent / VeraCrypt PBKDF2-HMAC-Whirlpool + Twofish",
13732: "VeraCrypt PBKDF2-HMAC-Whirlpool + AES-Twofish / VeraCrypt PBKDF2-HMAC-Whirlpool + Serpent-AES / VeraCrypt PBKDF2-HMAC-Whirlpool + Twofish-Serpent",
13733: "VeraCrypt PBKDF2-HMAC-Whirlpool + Serpent-Twofish-AES",
13741: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + boot-mode + AES",
13742: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + boot-mode + AES-Twofish",
13743: "VeraCrypt PBKDF2-HMAC-RIPEMD160 + boot-mode + AES-Twofish-Serpent",
13751: "VeraCrypt PBKDF2-HMAC-SHA256 + AES / VeraCrypt PBKDF2-HMAC-SHA256 + Serpent / VeraCrypt PBKDF2-HMAC-SHA256 + Twofish",
13752: "VeraCrypt PBKDF2-HMAC-SHA256 + AES-Twofish / VeraCrypt PBKDF2-HMAC-SHA256 + Serpent-AES / VeraCrypt PBKDF2-HMAC-SHA256 + Twofish-Serpent",
13753: "VeraCrypt PBKDF2-HMAC-SHA256 + Serpent-Twofish-AES",
13761: "VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + Twofish / VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + PIM + AES",
13762: "VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + Serpent-AES",
13763: "VeraCrypt PBKDF2-HMAC-SHA256 + boot-mode + Serpent-Twofish-AES",
13771: "VeraCrypt Streebog-512 + XTS 512 bit",
13772: "VeraCrypt Streebog-512 + XTS 1024 bit",
13773: "VeraCrypt Streebog-512 + XTS 1536 bit",
13800: "Windows Phone 8+ PIN/password",
13900: "OpenCart",
14000: "DES (PT = $salt, key = $pass)",
14100: "3DES (PT = $salt, key = $pass)",
14400: "sha1(CX)",
14500: "Linux Kernel Crypto API (2.4)",
14600: "LUKS",
14700: "iTunes backup < 10.0",
14800: "iTunes backup >= 10.0",
14900: "Skip32 (PT = $salt, key = $pass)",
15000: "FileZilla Server >= 0.9.55",
15100: "Juniper/NetBSD sha1crypt",
15200: "Blockchain, My Wallet, V2",
15300: "DPAPI masterkey file v1 + local context",
15400: "ChaCha20",
15500: "JKS Java Key Store Private Keys (SHA1)",
15600: "Ethereum Wallet, PBKDF2-HMAC-SHA256",
15700: "Ethereum Wallet, SCRYPT",
15900: "DPAPI masterkey file v2 + Active Directory domain context",
16000: "Tripcode",
16100: "TACACS+",
16200: "Apple Secure Notes",
16300: "Ethereum Pre-Sale Wallet, PBKDF2-HMAC-SHA256",
16400: "CRAM-MD5 Dovecot",
16500: "JWT (JSON Web Token)",
16600: "Electrum Wallet (Salt-Type 1-3)",
16700: "FileVault 2",
16800: "WPA-PMKID-PBKDF2",
16801: "WPA-PMKID-PMK",
16900: "Ansible Vault",
17200: "PKZIP (Compressed)",
17210: "PKZIP (Uncompressed)",
17220: "PKZIP (Compressed Multi-File)",
17225: "PKZIP (Mixed Multi-File)",
17230: "PKZIP (Mixed Multi-File Checksum-Only)",
17300: "SHA3-224",
17400: "SHA3-256",
17500: "SHA3-384",
17600: "SHA3-512",
17700: "Keccak-224",
17800: "Keccak-256",
17900: "Keccak-384",
18000: "Keccak-512",
18100: "TOTP (HMAC-SHA1)",
18200: "Kerberos 5, etype 23, AS-REP",
18300: "Apple File System (APFS)",
18400: "Open Document Format (ODF) 1.2 (SHA-256, AES)",
18500: "sha1(md5(md5($pass)))",
18600: "Open Document Format (ODF) 1.1 (SHA-1, Blowfish)",
18700: "Java Object hashCode()",
18800: "Blockchain, My Wallet, Second Password (SHA256)",
18900: "Android Backup",
19000: "QNX /etc/shadow (MD5)",
19100: "QNX /etc/shadow (SHA256)",
19200: "QNX /etc/shadow (SHA512)",
19300: "sha1($salt1.$pass.$salt2)",
19500: "Ruby on Rails Restful-Authentication",
19600: "Kerberos 5, etype 17, TGS-REP (AES128-CTS-HMAC-SHA1-96)",
19700: "Kerberos 5, etype 18, TGS-REP (AES256-CTS-HMAC-SHA1-96)",
19800: "Kerberos 5, etype 17, Pre-Auth",
19900: "Kerberos 5, etype 18, Pre-Auth",
20011: "DiskCryptor SHA512 + XTS 512 bit (AES) / DiskCryptor SHA512 + XTS 512 bit (Twofish) / DiskCryptor SHA512 + XTS 512 bit (Serpent)",
20012: "DiskCryptor SHA512 + XTS 1024 bit (AES-Twofish) / DiskCryptor SHA512 + XTS 1024 bit (Twofish-Serpent) / DiskCryptor SHA512 + XTS 1024 bit (Serpent-AES)",
20013: "DiskCryptor SHA512 + XTS 1536 bit (AES-Twofish-Serpent)",
20200: "Python passlib pbkdf2-sha512",
20300: "Python passlib pbkdf2-sha256",
20400: "Python passlib pbkdf2-sha1",
20500: "PKZIP Master Key",
20510: "PKZIP Master Key (6 byte optimization)",
20600: "Oracle Transportation Management (SHA256)",
20710: "sha256(sha256($pass).$salt)",
20711: "AuthMe sha256",
20720: "sha256($salt.sha256($pass))",
20800: "sha256(md5($pass))",
20900: "md5(sha1($pass).md5($pass).sha1($pass))",
21000: "BitShares v0.x - sha512(sha512_bin(pass))",
21100: "sha1(md5($pass.$salt))",
21200: "md5(sha1($salt).md5($pass))",
21300: "md5($salt.sha1($salt.$pass))",
21400: "sha256(sha256_bin($pass))",
21500: "SolarWinds Orion",
21501: "SolarWinds Orion v2",
21600: "Web2py pbkdf2-sha512",
21700: "Electrum Wallet (Salt-Type 4)",
21800: "Electrum Wallet (Salt-Type 5)",
22000: "WPA-PBKDF2-PMKID+EAPOL",
22001: "WPA-PMK-PMKID+EAPOL",
22100: "BitLocker",
22200: "Citrix NetScaler (SHA512)",
22300: "sha256($salt.$pass.$salt)",
22301: "Telegram Mobile App Passcode (SHA256)",
22400: "AES Crypt (SHA256)",
22500: "MultiBit Classic .key (MD5)",
22600: "Telegram Desktop < v2.1.14 (PBKDF2-HMAC-SHA1)",
22700: "MultiBit HD (scrypt)",
22911: "RSA/DSA/EC/OpenSSH Private Keys ($0$)",
22921: "RSA/DSA/EC/OpenSSH Private Keys ($6$)",
22931: "RSA/DSA/EC/OpenSSH Private Keys ($1, $3$)",
22941: "RSA/DSA/EC/OpenSSH Private Keys ($4$)",
22951: "RSA/DSA/EC/OpenSSH Private Keys ($5$)",
23001: "SecureZIP AES-128",
23002: "SecureZIP AES-192",
23003: "SecureZIP AES-256",
23100: "Apple Keychain",
23200: "XMPP SCRAM PBKDF2-SHA1",
23300: "Apple iWork",
23400: "Bitwarden",
23500: "AxCrypt 2 AES-128",
23600: "AxCrypt 2 AES-256",
23700: "RAR3-p (Uncompressed)",
23800: "RAR3-p (Compressed)",
23900: "BestCrypt v3 Volume Encryption",
24100: "MongoDB ServerKey SCRAM-SHA-1",
24200: "MongoDB ServerKey SCRAM-SHA-256",
24300: "sha1($salt.sha1($pass.$salt))",
24410: "PKCS#8 Private Keys (PBKDF2-HMAC-SHA1 + 3DES/AES)",
24420: "PKCS#8 Private Keys (PBKDF2-HMAC-SHA256 + 3DES/AES)",
24500: "Telegram Desktop >= v2.1.14 (PBKDF2-HMAC-SHA512)",
24600: "SQLCipher",
24700: "Stuffit5",
24800: "Umbraco HMAC-SHA1",
24900: "Dahua Authentication MD5",
25300: "MS Office 2016 - SheetProtection",
25400: "PDF 1.4 - 1.6 (Acrobat 5 - 8) - edit password",
25500: "Stargazer Stellar Wallet XLM",
25600: "bcrypt(md5($pass)) / bcryptmd5",
25700: "MurmurHash",
25800: "bcrypt(sha1($pass)) / bcryptsha1",
25900: "KNX IP Secure - Device Authentication Code",
26000: "Mozilla key3.db",
26100: "Mozilla key4.db",
26200: "OpenEdge Progress Encode",
26300: "FortiGate256 (FortiOS256)",
26401: "AES-128-ECB NOKDF (PT = $salt, key = $pass)",
26402: "AES-192-ECB NOKDF (PT = $salt, key = $pass)",
26403: "AES-256-ECB NOKDF (PT = $salt, key = $pass)",
26500: "iPhone passcode (UID key + System Keybag)",
26600: "MetaMask Wallet",
99999: "Plaintext",
}
// TypeName returns the display name for a Hashcat mode.
func TypeName(mode int32) (string, bool) {
name, ok := hashcatHashTypes[mode]
return name, ok
}
// HumanizeRate formats a hashes-per-second rate for terminal display.
func HumanizeRate(rate uint64) string {
const unit = 1000
if rate < unit {
return fmt.Sprintf("%d H/s", rate)
}
value := float64(rate)
exp := 0
for value >= unit && exp < 5 {
value /= unit
exp++
}
suffixes := []string{"kH/s", "MH/s", "GH/s", "TH/s", "PH/s"}
if exp == 0 {
return fmt.Sprintf("%d H/s", rate)
}
if exp > len(suffixes) {
exp = len(suffixes)
}
return fmt.Sprintf("%.2f %s", value, suffixes[exp-1])
}
+16
View File
@@ -0,0 +1,16 @@
package crack
import (
cracktop "github.com/bishopfox/sliver/client/command/crack/top"
"github.com/bishopfox/sliver/client/console"
"github.com/spf13/cobra"
)
// CrackTopCmd launches the full-screen real-time crack cluster monitor.
// The implementation lives in the top subpackage; this facade preserves the
// existing command-handler API for callers of client/command/crack.
//
//nolint:revive // Preserve the established exported command-handler API.
func CrackTopCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
cracktop.CrackTopCmd(cmd, con, args)
}
+518
View File
@@ -0,0 +1,518 @@
package top
import (
"context"
"errors"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/x/ansi"
)
const crackTopActionTestJobID = "dddddddd-4444-4444-8444-444444444444"
func newCrackTopJobActionTestModel(
status clientpb.CrackJobStatus,
actions map[crackTopJobAction]crackTopJobActionFunc,
) *crackTopModel {
snapshot := &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{
ID: crackTopActionTestJobID,
Status: status,
UpdatedAt: 100,
Tasks: []*clientpb.CrackTask{{
ID: "action-task",
HostUUID: "action-station",
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
LastHeartbeatAt: 1_700_000_000,
}},
}},
Stations: []*clientpb.Crackstation{{
ID: "action-station",
HostUUID: "action-station",
Status: &clientpb.CrackstationStatus{
HostUUID: "action-station",
State: clientpb.States_CRACKING,
CurrentCrackJobID: crackTopActionTestJobID,
},
}},
RefreshedAt: time.Unix(1_700_000_000, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{
"action-task": {status: &crackStatusView{Devices: []crackDeviceView{{Speed: "100"}}}},
},
}
model := newCrackTopModel(context.Background(), nil, nil, time.Second)
model.jobActions = actions
model.snapshot = snapshot
model.dashboard = buildCrackTopDashboard(snapshot)
model.refreshing = false
model.width = 100
model.height = 28
model.normalizeJobSelection()
return model
}
func singleCrackTopAction(action crackTopJobAction, actionJob crackTopJobActionFunc) map[crackTopJobAction]crackTopJobActionFunc {
return map[crackTopJobAction]crackTopJobActionFunc{action: actionJob}
}
func TestCrackTopDeleteConfirmsAndRemovesTerminalJob(t *testing.T) {
deletedID := ""
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_COMPLETED,
singleCrackTopAction(crackTopJobActionDelete, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
deletedID = jobID
return nil, nil
}),
)
_, openCommand := model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
if openCommand != nil || model.pendingJobAction != (crackTopPendingJobAction{action: crackTopJobActionDelete, jobID: crackTopActionTestJobID}) {
t.Fatalf("delete prompt = command:%v prompt:%#v", openCommand != nil, model.pendingJobAction)
}
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"Delete crack job?", crackTopActionTestJobID, "This cannot be undone", "y delete"} {
if !strings.Contains(plain, expected) {
t.Fatalf("delete confirmation is missing %q:\n%s", expected, plain)
}
}
if got := lipgloss.Width(model.View().Content); got > model.width {
t.Fatalf("delete modal width = %d, want <= %d", got, model.width)
}
_, deleteCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if deleteCommand == nil || model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != crackTopActionTestJobID {
t.Fatalf("confirmed delete = command:%v pending:%#v running:%#v", deleteCommand != nil, model.pendingJobAction, model.runningJobAction)
}
rawMessage := deleteCommand()
message, ok := rawMessage.(crackTopJobActionCompletedMsg)
if !ok {
t.Fatalf("delete command returned %T, want crackTopJobActionCompletedMsg", rawMessage)
}
if deletedID != crackTopActionTestJobID || message.action != crackTopJobActionDelete || message.jobID != crackTopActionTestJobID || message.err != nil {
t.Fatalf("delete call/message = called:%q message:%#v", deletedID, message)
}
_, refreshCommand := model.Update(message)
if refreshCommand == nil || !model.refreshing {
t.Fatalf("successful delete refresh = command:%v refreshing:%v", refreshCommand != nil, model.refreshing)
}
if len(model.snapshot.Jobs) != 0 || len(model.dashboard.Jobs) != 0 || model.selectedJobID != "" {
t.Fatalf("deleted job remains in dashboard: snapshot=%#v dashboard=%#v selected=%q", model.snapshot.Jobs, model.dashboard.Jobs, model.selectedJobID)
}
if _, ok := model.snapshot.taskTelemetry["action-task"]; ok {
t.Fatal("deleted job telemetry remains cached")
}
if model.toastLevel != "success" || !strings.Contains(model.toast, "Deleted crack job dddddddd") {
t.Fatalf("success toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopDeleteRejectsInProgressJob(t *testing.T) {
deleteCalls := 0
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_IN_PROGRESS,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) {
deleteCalls++
return nil, nil
}),
)
_, command := model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
if command == nil {
t.Fatal("in-progress delete did not schedule a warning")
}
if model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != "" || deleteCalls != 0 {
t.Fatalf("in-progress delete changed state: pending=%#v running=%#v calls=%d", model.pendingJobAction, model.runningJobAction, deleteCalls)
}
if model.toastLevel != "warning" || !strings.Contains(model.toast, "Only completed, failed, or cancelled crack jobs can be deleted") {
t.Fatalf("in-progress warning = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopDeleteFailureRetainsJobAndEscapeCancels(t *testing.T) {
wantErr := errors.New("database is busy\ntry later")
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_FAILED,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) {
return nil, wantErr
}),
)
_, _ = model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
_, cancelCommand := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
if cancelCommand != nil || model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != "" {
t.Fatalf("escaped delete prompt = command:%v pending=%#v running=%#v", cancelCommand != nil, model.pendingJobAction, model.runningJobAction)
}
_, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDelete})
_, enterCommand := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if enterCommand != nil || model.pendingJobAction.jobID != crackTopActionTestJobID || model.runningJobAction.jobID != "" {
t.Fatalf("bare enter confirmed destructive action: command=%v pending=%#v running=%#v", enterCommand != nil, model.pendingJobAction, model.runningJobAction)
}
_, deleteCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if deleteCommand == nil {
t.Fatal("y did not confirm deletion")
}
message := deleteCommand().(crackTopJobActionCompletedMsg)
_, toastCommand := model.Update(message)
if toastCommand == nil || model.refreshing {
t.Fatalf("failed delete = toast command:%v refreshing:%v", toastCommand != nil, model.refreshing)
}
if len(model.snapshot.Jobs) != 1 || model.snapshot.Jobs[0].GetID() != crackTopActionTestJobID {
t.Fatalf("failed delete removed job: %#v", model.snapshot.Jobs)
}
if model.toastLevel != "error" || !strings.Contains(model.toast, "database is busy try later") || strings.Contains(model.toast, "\n") {
t.Fatalf("failure toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopResizeBelowMinimumCancelsHiddenConfirmation(t *testing.T) {
deleteCalls := 0
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_COMPLETED,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) {
deleteCalls++
return nil, nil
}),
)
_, _ = model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
if model.pendingJobAction.jobID == "" {
t.Fatal("delete confirmation did not open")
}
_, resizeCommand := model.Update(tea.WindowSizeMsg{Width: crackTopMinWidth - 1, Height: crackTopMinHeight})
if resizeCommand != nil || model.pendingJobAction.jobID != "" {
t.Fatalf("small resize left hidden confirmation actionable: command=%v pending=%#v", resizeCommand != nil, model.pendingJobAction)
}
_, confirmCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if confirmCommand != nil || model.runningJobAction.jobID != "" || deleteCalls != 0 {
t.Fatalf("hidden confirmation executed: command=%v running=%#v calls=%d", confirmCommand != nil, model.runningJobAction, deleteCalls)
}
}
func TestCrackTopBelowMinimumCannotOpenHiddenConfirmation(t *testing.T) {
deleteCalls := 0
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_COMPLETED,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) {
deleteCalls++
return nil, nil
}),
)
_, _ = model.Update(tea.WindowSizeMsg{Width: crackTopMinWidth, Height: crackTopMinHeight - 1})
_, openCommand := model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
_, confirmCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if openCommand != nil || confirmCommand != nil || model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != "" || deleteCalls != 0 {
t.Fatalf("undersized dashboard accepted hidden delete: open=%v confirm=%v pending=%#v running=%#v calls=%d",
openCommand != nil, confirmCommand != nil, model.pendingJobAction, model.runningJobAction, deleteCalls)
}
}
func TestCrackTopDeleteAllowsEveryTerminalStatus(t *testing.T) {
for _, status := range []clientpb.CrackJobStatus{
clientpb.CrackJobStatus_COMPLETED,
clientpb.CrackJobStatus_FAILED,
clientpb.CrackJobStatus_CANCELLED,
} {
t.Run(status.String(), func(t *testing.T) {
model := newCrackTopJobActionTestModel(
status,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) { return nil, nil }),
)
_, command := model.Update(tea.KeyPressMsg{Text: "d", Code: 'd'})
if command != nil || model.pendingJobAction.jobID != crackTopActionTestJobID {
t.Fatalf("terminal status %s did not open delete confirmation", status)
}
})
}
}
func TestCrackTopDeleteSuppressesStaleRefreshUntilServerOmitsJob(t *testing.T) {
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_COMPLETED,
singleCrackTopAction(crackTopJobActionDelete, func(context.Context, string) (*clientpb.CrackJob, error) { return nil, nil }),
)
model.removeJob(crackTopActionTestJobID)
stale := &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{ID: crackTopActionTestJobID, Status: clientpb.CrackJobStatus_COMPLETED, Tasks: []*clientpb.CrackTask{{ID: "stale-task"}}}},
RefreshedAt: time.Unix(1_700_000_001, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{"stale-task": {}},
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: stale})
if len(model.snapshot.Jobs) != 0 || len(model.dashboard.Jobs) != 0 {
t.Fatalf("stale refresh restored deleted job: snapshot=%#v dashboard=%#v", model.snapshot.Jobs, model.dashboard.Jobs)
}
if _, ok := model.snapshot.taskTelemetry["stale-task"]; ok {
t.Fatal("stale refresh retained deleted job telemetry")
}
if _, ok := model.deletedJobIDs[crackTopActionTestJobID]; !ok {
t.Fatal("stale refresh cleared the deletion tombstone")
}
fresh := &crackTopSnapshot{RefreshedAt: time.Unix(1_700_000_002, 0)}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: fresh})
if _, ok := model.deletedJobIDs[crackTopActionTestJobID]; ok {
t.Fatal("server-confirmed refresh did not clear the deletion tombstone")
}
}
func TestCrackTopPauseConfirmsAppliesAuthoritativeStatusAndRefreshes(t *testing.T) {
pausedID := ""
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_IN_PROGRESS,
singleCrackTopAction(crackTopJobActionPause, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
pausedID = jobID
return &clientpb.CrackJob{ID: jobID, Status: clientpb.CrackJobStatus_PAUSED, UpdatedAt: 200}, nil
}),
)
_, openCommand := model.Update(tea.KeyPressMsg{Text: "p", Code: 'p'})
if openCommand != nil || model.pendingJobAction != (crackTopPendingJobAction{action: crackTopJobActionPause, jobID: crackTopActionTestJobID}) {
t.Fatalf("pause prompt = command:%v prompt:%#v", openCommand != nil, model.pendingJobAction)
}
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"Pause crack job?", "release its current crackstation work", "may restart", "y pause"} {
if !strings.Contains(plain, expected) {
t.Fatalf("pause confirmation is missing %q:\n%s", expected, plain)
}
}
_, enterCommand := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if enterCommand != nil || model.runningJobAction.jobID != "" {
t.Fatal("enter confirmed pause without an explicit y")
}
_, pauseCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
if pauseCommand == nil || model.runningJobAction.action != crackTopJobActionPause {
t.Fatalf("pause confirmation = command:%v running:%#v", pauseCommand != nil, model.runningJobAction)
}
message := pauseCommand().(crackTopJobActionCompletedMsg)
if pausedID != crackTopActionTestJobID || message.status != clientpb.CrackJobStatus_PAUSED || !message.hasStatus {
t.Fatalf("pause call/message = called:%q message:%#v", pausedID, message)
}
_, refreshCommand := model.Update(message)
if refreshCommand == nil || !model.refreshing {
t.Fatalf("successful pause refresh = command:%v refreshing:%v", refreshCommand != nil, model.refreshing)
}
if got := model.snapshot.Jobs[0].GetStatus(); got != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("local pause status = %s, want PAUSED", got)
}
if model.dashboard.Jobs[0].RateKnown || model.dashboard.ActiveJobs != 0 {
t.Fatalf("paused job retained active throughput: row=%#v active=%d", model.dashboard.Jobs[0], model.dashboard.ActiveJobs)
}
if model.toastLevel != "success" || !strings.Contains(model.toast, "Paused crack job dddddddd") {
t.Fatalf("pause toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopCancelConfirmsActiveAndPausedJobs(t *testing.T) {
for _, status := range []clientpb.CrackJobStatus{clientpb.CrackJobStatus_IN_PROGRESS, clientpb.CrackJobStatus_PAUSED} {
t.Run(status.String(), func(t *testing.T) {
model := newCrackTopJobActionTestModel(
status,
singleCrackTopAction(crackTopJobActionCancel, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
return &clientpb.CrackJob{ID: jobID, Status: clientpb.CrackJobStatus_CANCELLED, UpdatedAt: 200}, nil
}),
)
_, openCommand := model.Update(tea.KeyPressMsg{Text: "c", Code: 'c'})
if openCommand != nil || model.pendingJobAction.action != crackTopJobActionCancel {
t.Fatalf("cancel prompt = command:%v prompt:%#v", openCommand != nil, model.pendingJobAction)
}
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"Cancel crack job?", "Cancelled jobs cannot be resumed", "y cancel"} {
if !strings.Contains(plain, expected) {
t.Fatalf("cancel confirmation is missing %q:\n%s", expected, plain)
}
}
_, cancelCommand := model.Update(tea.KeyPressMsg{Text: "y", Code: 'y'})
message := cancelCommand().(crackTopJobActionCompletedMsg)
_, refreshCommand := model.Update(message)
if refreshCommand == nil || model.snapshot.Jobs[0].GetStatus() != clientpb.CrackJobStatus_CANCELLED {
t.Fatalf("cancel result = command:%v job:%#v", refreshCommand != nil, model.snapshot.Jobs[0])
}
})
}
}
func TestCrackTopResumePausedJobWithoutDestructiveConfirmation(t *testing.T) {
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_PAUSED,
singleCrackTopAction(crackTopJobActionResume, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
return &clientpb.CrackJob{ID: jobID, Status: clientpb.CrackJobStatus_IN_PROGRESS, UpdatedAt: 300}, nil
}),
)
_, resumeCommand := model.Update(tea.KeyPressMsg{Text: "u", Code: 'u'})
if resumeCommand == nil || model.pendingJobAction.jobID != "" || model.runningJobAction.action != crackTopJobActionResume {
t.Fatalf("resume start = command:%v pending:%#v running:%#v", resumeCommand != nil, model.pendingJobAction, model.runningJobAction)
}
plain := strings.ToLower(ansi.Strip(model.View().Content))
if !strings.Contains(plain, "resuming crack job dddddddd") {
t.Fatalf("resume progress footer missing:\n%s", plain)
}
message := resumeCommand().(crackTopJobActionCompletedMsg)
_, refreshCommand := model.Update(message)
if refreshCommand == nil || model.snapshot.Jobs[0].GetStatus() != clientpb.CrackJobStatus_IN_PROGRESS {
t.Fatalf("resume result = command:%v job:%#v", refreshCommand != nil, model.snapshot.Jobs[0])
}
if model.toastLevel != "success" || !strings.Contains(model.toast, "Resumed crack job dddddddd") {
t.Fatalf("resume toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopLifecycleActionsRejectIneligibleStatuses(t *testing.T) {
tests := []struct {
name string
key rune
action crackTopJobAction
status clientpb.CrackJobStatus
want string
}{
{name: "pause paused", key: 'p', action: crackTopJobActionPause, status: clientpb.CrackJobStatus_PAUSED, want: "Only in-progress crack jobs can be paused"},
{name: "resume active", key: 'u', action: crackTopJobActionResume, status: clientpb.CrackJobStatus_IN_PROGRESS, want: "Only paused crack jobs can be resumed"},
{name: "cancel complete", key: 'c', action: crackTopJobActionCancel, status: clientpb.CrackJobStatus_COMPLETED, want: "Only in-progress or paused crack jobs can be cancelled"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
calls := 0
model := newCrackTopJobActionTestModel(
test.status,
singleCrackTopAction(test.action, func(context.Context, string) (*clientpb.CrackJob, error) {
calls++
return nil, nil
}),
)
_, command := model.Update(tea.KeyPressMsg{Text: string(test.key), Code: test.key})
if command == nil || calls != 0 || model.pendingJobAction.jobID != "" || model.runningJobAction.jobID != "" {
t.Fatalf("ineligible action = command:%v calls:%d pending:%#v running:%#v", command != nil, calls, model.pendingJobAction, model.runningJobAction)
}
if model.toastLevel != "warning" || !strings.Contains(model.toast, test.want) {
t.Fatalf("ineligible toast = level:%q text:%q", model.toastLevel, model.toast)
}
})
}
}
func TestCrackTopLifecycleRejectsNonAuthoritativeSuccessResponse(t *testing.T) {
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_PAUSED,
singleCrackTopAction(crackTopJobActionResume, func(context.Context, string) (*clientpb.CrackJob, error) {
return nil, nil
}),
)
_, resumeCommand := model.Update(tea.KeyPressMsg{Text: "u", Code: 'u'})
message := resumeCommand().(crackTopJobActionCompletedMsg)
if message.err == nil || !strings.Contains(message.err.Error(), "no job state") {
t.Fatalf("empty resume response error = %v", message.err)
}
_, toastCommand := model.Update(message)
if toastCommand == nil || model.refreshing || model.snapshot.Jobs[0].GetStatus() != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("empty resume response mutated state: command=%v refreshing=%v job=%#v", toastCommand != nil, model.refreshing, model.snapshot.Jobs[0])
}
if model.toastLevel != "error" || !strings.Contains(model.toast, "server returned no job state") {
t.Fatalf("empty resume toast = level:%q text:%q", model.toastLevel, model.toast)
}
}
func TestCrackTopLifecycleRejectsIDOnlyResumeResponse(t *testing.T) {
model := newCrackTopJobActionTestModel(
clientpb.CrackJobStatus_PAUSED,
singleCrackTopAction(crackTopJobActionResume, func(_ context.Context, jobID string) (*clientpb.CrackJob, error) {
return &clientpb.CrackJob{ID: jobID}, nil
}),
)
_, resumeCommand := model.Update(tea.KeyPressMsg{Text: "u", Code: 'u'})
message := resumeCommand().(crackTopJobActionCompletedMsg)
if message.err == nil || !strings.Contains(message.err.Error(), "without an update timestamp") {
t.Fatalf("ID-only resume response error = %v", message.err)
}
_, toastCommand := model.Update(message)
if toastCommand == nil || model.refreshing || model.snapshot.Jobs[0].GetStatus() != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("ID-only resume response mutated state: command=%v refreshing=%v job=%#v", toastCommand != nil, model.refreshing, model.snapshot.Jobs[0])
}
}
func TestCrackTopLifecycleStatusOverrideSuppressesStaleRefresh(t *testing.T) {
model := newCrackTopJobActionTestModel(clientpb.CrackJobStatus_IN_PROGRESS, nil)
model.overrideJobStatus(crackTopActionTestJobID, clientpb.CrackJobStatus_PAUSED, 200)
stale := &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{ID: crackTopActionTestJobID, Status: clientpb.CrackJobStatus_IN_PROGRESS, UpdatedAt: 100}},
RefreshedAt: time.Unix(1_700_000_001, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{},
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: stale})
if got := model.snapshot.Jobs[0].GetStatus(); got != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("stale refresh status = %s, want PAUSED", got)
}
if _, ok := model.jobStatusOverrides[crackTopActionTestJobID]; !ok {
t.Fatal("stale refresh cleared authoritative status override")
}
observed := &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{ID: crackTopActionTestJobID, Status: clientpb.CrackJobStatus_PAUSED, UpdatedAt: 200}},
RefreshedAt: time.Unix(1_700_000_002, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{},
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: observed})
if _, ok := model.jobStatusOverrides[crackTopActionTestJobID]; ok {
t.Fatal("server-confirmed refresh did not clear status override")
}
}
func TestCrackTopLifecycleStatusOverrideYieldsToRepeatedSameSecondConflict(t *testing.T) {
model := newCrackTopJobActionTestModel(clientpb.CrackJobStatus_IN_PROGRESS, nil)
model.overrideJobStatus(crackTopActionTestJobID, clientpb.CrackJobStatus_PAUSED, 200)
conflictingSnapshot := func(refreshedAt int64) *crackTopSnapshot {
return &crackTopSnapshot{
Jobs: []*clientpb.CrackJob{{
ID: crackTopActionTestJobID,
Status: clientpb.CrackJobStatus_CANCELLED,
UpdatedAt: 200,
}},
RefreshedAt: time.Unix(refreshedAt, 0),
taskTelemetry: map[string]crackTopTaskTelemetry{},
}
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: conflictingSnapshot(1_700_000_001)})
if got := model.snapshot.Jobs[0].GetStatus(); got != clientpb.CrackJobStatus_PAUSED {
t.Fatalf("first same-second conflict status = %s, want PAUSED stale-snapshot mask", got)
}
override, ok := model.jobStatusOverrides[crackTopActionTestJobID]
if !ok || !override.equalVersionDisagreementSeen {
t.Fatalf("first same-second conflict override = %#v, present=%v", override, ok)
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: conflictingSnapshot(1_700_000_002)})
if got := model.snapshot.Jobs[0].GetStatus(); got != clientpb.CrackJobStatus_CANCELLED {
t.Fatalf("repeated same-second conflict status = %s, want CANCELLED", got)
}
if _, ok := model.jobStatusOverrides[crackTopActionTestJobID]; ok {
t.Fatal("repeated same-second conflict did not release status override")
}
}
func TestCrackTopActiveFilterRetainsPausedJob(t *testing.T) {
model := newCrackTopJobActionTestModel(clientpb.CrackJobStatus_PAUSED, nil)
model.filter = crackTopFilterActive
model.normalizeJobSelection()
jobs := model.filteredJobs()
if len(jobs) != 1 || jobs[0].ID != crackTopActionTestJobID || model.selectedJobID != crackTopActionTestJobID {
t.Fatalf("active filter lost paused job: jobs=%#v selected=%q", jobs, model.selectedJobID)
}
if model.dashboard.ActiveJobs != 0 {
t.Fatalf("paused job counted as active throughput work: %d", model.dashboard.ActiveJobs)
}
}
+78
View File
@@ -0,0 +1,78 @@
package top
import "time"
const crackTopHistoryLimit = 120
// crackTopHistorySample captures the real telemetry available at one refresh
// observation. Known fields distinguish an observed zero from telemetry that
// was unavailable, including when the refresh itself failed.
type crackTopHistorySample struct {
At time.Time
ClusterRate uint64
ClusterRateKnown bool
ClusterComplete bool
Progress float64
ProgressKnown bool
Utilization float64
UtilizationKnown bool
Temperature float64
TemperatureKnown bool
CountsKnown bool
ActiveJobs int
OnlineWorkers int
TotalWorkers int
Recovered uint64
}
func crackTopHistorySampleFromDashboard(dashboard crackTopDashboard, at time.Time) crackTopHistorySample {
sample := crackTopHistorySample{
At: at,
ActiveJobs: dashboard.ActiveJobs,
OnlineWorkers: dashboard.OnlineWorkers,
TotalWorkers: dashboard.OnlineWorkers + dashboard.Unavailable,
Recovered: dashboard.Recovered,
ClusterRateKnown: dashboard.ClusterRateKnown,
ClusterComplete: dashboard.ClusterComplete,
ProgressKnown: dashboard.ProgressKnown,
CountsKnown: true,
}
if sample.ClusterRateKnown {
sample.ClusterRate = dashboard.ClusterRate
}
if sample.ProgressKnown {
sample.Progress = dashboard.Progress
}
var utilizationTotal float64
utilizationSamples := 0
for _, worker := range dashboard.Workers {
if !worker.TelemetryLive {
continue
}
if worker.HasUtil {
utilizationTotal += worker.Utilization
utilizationSamples++
}
if worker.HasTemp && (!sample.TemperatureKnown || worker.Temperature > sample.Temperature) {
sample.Temperature = worker.Temperature
sample.TemperatureKnown = true
}
}
if utilizationSamples > 0 {
sample.Utilization = utilizationTotal / float64(utilizationSamples)
sample.UtilizationKnown = true
}
return sample
}
func appendCrackTopHistory(history []crackTopHistorySample, sample crackTopHistorySample) []crackTopHistorySample {
if len(history) >= crackTopHistoryLimit {
start := len(history) - (crackTopHistoryLimit - 1)
retained := copy(history, history[start:])
history = history[:retained]
}
return append(history, sample)
}
+99
View File
@@ -0,0 +1,99 @@
package top
import (
"testing"
"time"
)
//nolint:gocyclo // This test validates every derived history field in one complete sample.
func TestCrackTopHistorySampleFromDashboardDerivesLiveTelemetry(t *testing.T) {
at := time.Unix(1_700_000_000, 123)
dashboard := crackTopDashboard{
ActiveJobs: 2,
OnlineWorkers: 3,
Unavailable: 2,
Recovered: 9,
ClusterRate: 12_345,
ClusterRateKnown: true,
ClusterComplete: true,
Progress: 0.625,
ProgressKnown: true,
Workers: []crackTopWorkerRow{
{TelemetryLive: true, Utilization: 20, HasUtil: true, Temperature: 60, HasTemp: true},
{TelemetryLive: true, Utilization: 80, HasUtil: true, Temperature: 75, HasTemp: true},
{TelemetryLive: true},
{TelemetryLive: false, Utilization: 100, HasUtil: true, Temperature: 99, HasTemp: true},
},
}
sample := crackTopHistorySampleFromDashboard(dashboard, at)
if !sample.At.Equal(at) {
t.Fatalf("sample time = %s, want %s", sample.At, at)
}
if !sample.ClusterRateKnown || sample.ClusterRate != 12_345 {
t.Fatalf("cluster rate = %d known %v, want 12345 known", sample.ClusterRate, sample.ClusterRateKnown)
}
if !sample.ClusterComplete {
t.Fatal("complete cluster snapshot was recorded as incomplete")
}
if !sample.ProgressKnown || sample.Progress != 0.625 {
t.Fatalf("progress = %v known %v, want 0.625 known", sample.Progress, sample.ProgressKnown)
}
if !sample.UtilizationKnown || sample.Utilization != 50 {
t.Fatalf("utilization = %v known %v, want live-worker mean 50", sample.Utilization, sample.UtilizationKnown)
}
if !sample.TemperatureKnown || sample.Temperature != 75 {
t.Fatalf("temperature = %v known %v, want live-worker maximum 75", sample.Temperature, sample.TemperatureKnown)
}
if sample.ActiveJobs != 2 || sample.OnlineWorkers != 3 || sample.TotalWorkers != 5 || sample.Recovered != 9 {
t.Fatalf("sample counts = active:%d online:%d total:%d recovered:%d", sample.ActiveJobs, sample.OnlineWorkers, sample.TotalWorkers, sample.Recovered)
}
if !sample.CountsKnown {
t.Fatal("successful dashboard sample marked counts unknown")
}
}
func TestCrackTopHistorySampleFromDashboardPreservesUnknowns(t *testing.T) {
dashboard := crackTopDashboard{
ClusterRate: 999,
Progress: 0.9,
Workers: []crackTopWorkerRow{
{TelemetryLive: false, Utilization: 88, HasUtil: true, Temperature: 70, HasTemp: true},
{TelemetryLive: true},
},
}
sample := crackTopHistorySampleFromDashboard(dashboard, time.Time{})
if sample.ClusterRateKnown || sample.ClusterRate != 0 {
t.Fatalf("unknown cluster rate = %d known %v, want zero unknown", sample.ClusterRate, sample.ClusterRateKnown)
}
if sample.ClusterComplete {
t.Fatal("incomplete cluster snapshot was recorded as complete")
}
if sample.ProgressKnown || sample.Progress != 0 {
t.Fatalf("unknown progress = %v known %v, want zero unknown", sample.Progress, sample.ProgressKnown)
}
if sample.UtilizationKnown || sample.Utilization != 0 {
t.Fatalf("unknown utilization = %v known %v, want zero unknown", sample.Utilization, sample.UtilizationKnown)
}
if sample.TemperatureKnown || sample.Temperature != 0 {
t.Fatalf("unknown temperature = %v known %v, want zero unknown", sample.Temperature, sample.TemperatureKnown)
}
}
func TestAppendCrackTopHistoryCapsAndRetainsNewestSamples(t *testing.T) {
history := make([]crackTopHistorySample, 0, crackTopHistoryLimit)
for index := 0; index < crackTopHistoryLimit+5; index++ {
history = appendCrackTopHistory(history, crackTopHistorySample{ActiveJobs: index})
}
if len(history) != crackTopHistoryLimit {
t.Fatalf("history length = %d, want %d", len(history), crackTopHistoryLimit)
}
if history[0].ActiveJobs != 5 {
t.Fatalf("oldest retained sample = %d, want 5", history[0].ActiveJobs)
}
if history[len(history)-1].ActiveJobs != crackTopHistoryLimit+4 {
t.Fatalf("newest retained sample = %d, want %d", history[len(history)-1].ActiveJobs, crackTopHistoryLimit+4)
}
}
+245
View File
@@ -0,0 +1,245 @@
package top
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/charmbracelet/x/ansi"
"google.golang.org/grpc"
)
const crackTopWarningRunes = 256
// crackTopRPC is the complete wire-level dependency of the crack top client.
// Keeping it narrow also makes it difficult for the dashboard to accidentally
// fall back to RPCs that expose crack targets or recovered material.
type crackTopRPC interface {
CrackTop(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.CrackTopSnapshot, error)
}
type crackTopTaskTelemetry struct {
status *crackStatusView
parseErr bool
progressExact bool
}
// crackTopSnapshot is the client-side representation consumed by the existing
// dashboard telemetry code. Jobs and stations contain only fields supplied by
// the narrow CrackTop RPC; parsed status is kept separately so no sensitive raw
// status JSON has to be reconstructed.
type crackTopSnapshot struct {
Jobs []*clientpb.CrackJob
Stations []*clientpb.Crackstation
RefreshedAt time.Time
taskTelemetry map[string]crackTopTaskTelemetry
}
// CrackTop snapshots are atomic. A successful refresh replaces all previously
// displayed server state, while a failed refresh returns no snapshot and leaves
// the model's existing value untouched.
func mergeCrackTopSnapshot(_ *crackTopSnapshot, incoming *crackTopSnapshot) *crackTopSnapshot {
return incoming
}
func loadCrackTopSnapshot(ctx context.Context, rpc crackTopRPC, perCallTimeout time.Duration, completionTime time.Time) (*crackTopSnapshot, error) {
if rpc == nil {
return nil, crackTopContextError("load crack top snapshot", errors.New("rpc client is unavailable"))
}
if ctx == nil {
ctx = context.Background()
}
var cancel context.CancelFunc
if perCallTimeout > 0 {
ctx, cancel = context.WithTimeout(ctx, perCallTimeout)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
wire, err := rpc.CrackTop(ctx, &commonpb.Empty{})
if completionTime.IsZero() {
completionTime = time.Now()
}
if err != nil {
return nil, crackTopContextError("load crack top snapshot", err)
}
if wire == nil {
return nil, crackTopContextError("load crack top snapshot", errors.New("server returned an empty response"))
}
refreshedAt := completionTime
if wire.GetObservedAt() != 0 {
refreshedAt = time.Unix(wire.GetObservedAt(), 0)
}
snapshot := &crackTopSnapshot{
Jobs: make([]*clientpb.CrackJob, len(wire.GetJobs())),
Stations: make([]*clientpb.Crackstation, len(wire.GetCrackstations())),
RefreshedAt: refreshedAt,
taskTelemetry: map[string]crackTopTaskTelemetry{},
}
for index, job := range wire.GetJobs() {
snapshot.Jobs[index] = crackTopJobFromWire(job, snapshot.taskTelemetry)
}
for index, station := range wire.GetCrackstations() {
snapshot.Stations[index] = crackTopStationFromWire(station)
}
return snapshot, nil
}
func crackTopJobFromWire(wire *clientpb.CrackTopJob, telemetry map[string]crackTopTaskTelemetry) *clientpb.CrackJob {
if wire == nil {
return nil
}
command := &clientpb.CrackCommand{
AttackMode: wire.GetAttackMode(),
HashType: wire.GetHashType(),
}
if wire.HashMode != nil {
hashMode := wire.GetHashMode()
command.HashMode = &hashMode
}
job := &clientpb.CrackJob{
ID: wire.GetID(),
CreatedAt: wire.GetCreatedAt(),
CompletedAt: wire.GetCompletedAt(),
Status: wire.GetStatus(),
Command: command,
UpdatedAt: wire.GetUpdatedAt(),
ResultCount: wire.GetResultCount(),
Tasks: make([]*clientpb.CrackTask, len(wire.GetTasks())),
}
for index, task := range wire.GetTasks() {
job.Tasks[index] = crackTopTaskFromWire(task, telemetry)
}
return job
}
func crackTopTaskFromWire(wire *clientpb.CrackTopTask, telemetry map[string]crackTopTaskTelemetry) *clientpb.CrackTask {
if wire == nil {
return nil
}
task := &clientpb.CrackTask{
ID: wire.GetID(),
HostUUID: wire.GetHostUUID(),
CreatedAt: wire.GetCreatedAt(),
StartedAt: wire.GetStartedAt(),
CompletedAt: wire.GetCompletedAt(),
Kind: wire.GetKind(),
State: wire.GetState(),
Attempt: wire.GetAttempt(),
LeaseExpiresAt: wire.GetLeaseExpiresAt(),
UpdatedAt: wire.GetUpdatedAt(),
LastHeartbeatAt: wire.GetLastHeartbeatAt(),
ShardSkip: wire.GetShardSkip(),
ShardLimit: wire.GetShardLimit(),
}
taskID := strings.TrimSpace(task.GetID())
if telemetry == nil || taskID == "" {
return task
}
metadata := crackTopTaskTelemetry{
parseErr: wire.GetStatusParseError(),
progressExact: wire.GetProgressExact(),
}
if wire.GetStatusAvailable() && !metadata.parseErr {
metadata.status = &crackStatusView{Devices: crackTopDevicesFromWire(wire.GetDevices())}
if metadata.progressExact || wire.GetShardSkip() == 0 {
metadata.status.ProgressCurrent = wire.GetProgressCurrent()
metadata.status.ProgressTotal = wire.GetProgressTotal()
}
}
if metadata.status != nil || metadata.parseErr {
telemetry[taskID] = metadata
}
return task
}
func crackTopDevicesFromWire(devices []*clientpb.CrackTopDevice) []crackDeviceView {
converted := make([]crackDeviceView, 0, len(devices))
for _, device := range devices {
if device == nil {
continue
}
view := crackDeviceView{ID: device.GetID()}
if device.GetSpeedAvailable() {
view.Speed = strconv.FormatUint(device.GetSpeed(), 10)
}
if device.GetTemperatureAvailable() {
view.Temperature = strconv.FormatFloat(device.GetTemperature(), 'f', -1, 64)
}
if device.GetUtilizationAvailable() {
view.Utilization = strconv.FormatFloat(device.GetUtilization(), 'f', -1, 64)
}
converted = append(converted, view)
}
return converted
}
func crackTopStationFromWire(wire *clientpb.CrackTopStation) *clientpb.Crackstation {
if wire == nil {
return nil
}
station := &clientpb.Crackstation{
ID: wire.GetID(),
Name: wire.GetName(),
HostUUID: wire.GetHostUUID(),
}
if wire.GetStatusAvailable() {
station.Status = &clientpb.CrackstationStatus{
Name: wire.GetName(),
HostUUID: wire.GetHostUUID(),
State: wire.GetState(),
CurrentCrackJobID: wire.GetCurrentCrackJobID(),
IsSyncing: wire.GetIsSyncing(),
}
}
return station
}
func crackTopContextError(scope string, err error) error {
message := crackTopSanitizeError(err)
if strings.TrimSpace(scope) == "" {
return errors.New(message)
}
return fmt.Errorf("%s: %s", strings.TrimSpace(scope), message)
}
func crackTopWarningText(message string) string {
return crackTopSanitizeError(errors.New(message))
}
func crackTopSanitizeError(err error) string {
if err == nil {
return "unknown error"
}
message := ansi.Strip(err.Error())
message = strings.Map(func(character rune) rune {
if unicode.IsSpace(character) {
return ' '
}
if !unicode.IsPrint(character) {
return -1
}
return character
}, message)
message = strings.Join(strings.Fields(message), " ")
if message == "" {
return "unknown error"
}
runes := []rune(message)
if len(runes) > crackTopWarningRunes {
message = string(runes[:crackTopWarningRunes-1]) + "…"
}
return message
}
+424
View File
@@ -0,0 +1,424 @@
package top
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
type crackTopRPCTestClient struct {
calls atomic.Int32
call func(context.Context, *commonpb.Empty) (*clientpb.CrackTopSnapshot, error)
}
func (client *crackTopRPCTestClient) CrackTop(ctx context.Context, request *commonpb.Empty, _ ...grpc.CallOption) (*clientpb.CrackTopSnapshot, error) {
client.calls.Add(1)
if client.call == nil {
return nil, nil
}
return client.call(ctx, request)
}
//nolint:gocyclo // This test intentionally validates every field in one complete lean-snapshot conversion.
func TestLoadCrackTopSnapshotUsesOneRPCAndConvertsLeanSnapshot(t *testing.T) {
hashMode := uint32(5600)
wire := &clientpb.CrackTopSnapshot{
ObservedAt: 1_700_000_123,
Jobs: []*clientpb.CrackTopJob{
{
ID: "job-one",
CreatedAt: "2026-09-12T10:00:00Z",
CompletedAt: "",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
UpdatedAt: 1_700_000_120,
ResultCount: 7,
AttackMode: clientpb.CrackAttackMode_BRUTEFORCE,
HashType: clientpb.HashType_NET_NTLM_V2,
HashMode: &hashMode,
Tasks: []*clientpb.CrackTopTask{
{
ID: "task-live",
HostUUID: "worker-one",
CreatedAt: 11,
StartedAt: 12,
CompletedAt: 13,
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
Attempt: 2,
LeaseExpiresAt: 1_700_000_500,
UpdatedAt: 14,
LastHeartbeatAt: 15,
ShardSkip: 100,
ShardLimit: 200,
ProgressCurrent: "125",
ProgressTotal: "300",
StatusAvailable: true,
StatusParseError: false,
ProgressExact: true,
Devices: []*clientpb.CrackTopDevice{
{
ID: "gpu-0",
Speed: 9_876_543,
SpeedAvailable: true,
Temperature: 71.5,
TemperatureAvailable: true,
Utilization: 99.25,
UtilizationAvailable: true,
},
nil,
{ID: "gpu-1", Speed: 123, Temperature: 45, Utilization: 67},
},
},
nil,
{
ID: "task-malformed",
HostUUID: "worker-two",
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
ProgressCurrent: "sensitive-raw-data-must-not-be-rebuilt",
StatusAvailable: true,
StatusParseError: true,
},
},
},
nil,
{ID: "job-two", Status: clientpb.CrackJobStatus_COMPLETED},
},
Crackstations: []*clientpb.CrackTopStation{
{
ID: "station-one",
Name: "rig-one",
HostUUID: "worker-one",
State: clientpb.States_CRACKING,
CurrentCrackJobID: "job-one",
IsSyncing: false,
StatusAvailable: true,
},
nil,
{ID: "station-two", Name: "rig-two", HostUUID: "worker-two"},
},
}
wireBefore := proto.Clone(wire).(*clientpb.CrackTopSnapshot)
client := &crackTopRPCTestClient{
call: func(ctx context.Context, request *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
if ctx == nil {
t.Fatal("CrackTop received a nil context")
}
if request == nil {
t.Fatal("CrackTop received a nil request")
}
return wire, nil
},
}
fallback := time.Unix(1_600_000_000, 987_654_321)
snapshot, err := loadCrackTopSnapshot(t.Context(), client, time.Second, fallback)
if err != nil {
t.Fatalf("loadCrackTopSnapshot: %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
if want := time.Unix(wire.GetObservedAt(), 0); !snapshot.RefreshedAt.Equal(want) {
t.Fatalf("RefreshedAt = %s, want server observation time %s", snapshot.RefreshedAt, want)
}
if !proto.Equal(wire, wireBefore) {
t.Fatal("loader mutated the wire snapshot")
}
if len(snapshot.Jobs) != 3 || snapshot.Jobs[1] != nil || snapshot.Jobs[2].GetID() != "job-two" {
t.Fatalf("jobs did not preserve wire order and nil entries: %#v", snapshot.Jobs)
}
job := snapshot.Jobs[0]
if job.GetID() != "job-one" || job.GetCreatedAt() != wire.Jobs[0].GetCreatedAt() || job.GetCompletedAt() != wire.Jobs[0].GetCompletedAt() ||
job.GetStatus() != clientpb.CrackJobStatus_IN_PROGRESS || job.GetUpdatedAt() != 1_700_000_120 || job.GetResultCount() != 7 {
t.Fatalf("converted job metadata = %#v", job)
}
if job.GetCommand() == nil || job.GetCommand().GetAttackMode() != clientpb.CrackAttackMode_BRUTEFORCE ||
job.GetCommand().GetHashType() != clientpb.HashType_NET_NTLM_V2 || job.GetCommand().HashMode == nil || job.GetCommand().GetHashMode() != hashMode {
t.Fatalf("converted job command = %#v", job.GetCommand())
}
if job.GetErr() != "" || job.GetResultFileID() != "" || job.GetKeyspace() != "" || len(job.GetResults()) != 0 ||
len(job.GetCommand().GetHashes()) != 0 || len(job.GetCommand().GetPositionalArguments()) != 0 || len(job.GetCommand().GetCredentialIDs()) != 0 {
t.Fatalf("lean job conversion populated target or result fields: %#v", job)
}
if len(job.GetTasks()) != 3 || job.GetTasks()[1] != nil {
t.Fatalf("tasks did not preserve wire order and nil entries: %#v", job.GetTasks())
}
task := job.GetTasks()[0]
if task.GetID() != "task-live" || task.GetHostUUID() != "worker-one" || task.GetCreatedAt() != 11 || task.GetStartedAt() != 12 ||
task.GetCompletedAt() != 13 || task.GetKind() != clientpb.CrackTaskKind_CRACK_TASK_CRACK ||
task.GetState() != clientpb.CrackTaskState_CRACK_TASK_RUNNING || task.GetAttempt() != 2 ||
task.GetLeaseExpiresAt() != 1_700_000_500 || task.GetUpdatedAt() != 14 || task.GetLastHeartbeatAt() != 15 ||
task.GetShardSkip() != 100 || task.GetShardLimit() != 200 {
t.Fatalf("converted task metadata = %#v", task)
}
if task.GetErr() != "" || len(task.GetStdout()) != 0 || len(task.GetStderr()) != 0 || task.GetCommand() != nil ||
task.GetLeaseToken() != "" || task.GetKeyspace() != "" || len(task.GetLatestStatusJSON()) != 0 || len(task.GetRecoveredJSON()) != 0 {
t.Fatalf("lean task conversion populated execution or raw telemetry fields: %#v", task)
}
metadata, ok := snapshot.taskTelemetry["task-live"]
if !ok || metadata.parseErr || metadata.status == nil {
t.Fatalf("live task telemetry = %#v, present=%v", metadata, ok)
}
if !metadata.progressExact {
t.Fatal("server-declared exact progress was discarded")
}
if metadata.status.ProgressCurrent != "125" || metadata.status.ProgressTotal != "300" || len(metadata.status.Devices) != 2 {
t.Fatalf("converted status = %#v", metadata.status)
}
firstDevice := metadata.status.Devices[0]
if firstDevice.ID != "gpu-0" || firstDevice.Speed != "9876543" || firstDevice.Temperature != "71.5" || firstDevice.Utilization != "99.25" {
t.Fatalf("converted available device telemetry = %#v", firstDevice)
}
secondDevice := metadata.status.Devices[1]
if secondDevice.ID != "gpu-1" || secondDevice.Speed != "" || secondDevice.Temperature != "" || secondDevice.Utilization != "" {
t.Fatalf("unavailable device telemetry was treated as available: %#v", secondDevice)
}
malformed, ok := snapshot.taskTelemetry["task-malformed"]
if !ok || !malformed.parseErr || malformed.status != nil {
t.Fatalf("malformed task telemetry = %#v, present=%v", malformed, ok)
}
if len(snapshot.Stations) != 3 || snapshot.Stations[1] != nil {
t.Fatalf("stations did not preserve wire order and nil entries: %#v", snapshot.Stations)
}
station := snapshot.Stations[0]
if station.GetID() != "station-one" || station.GetName() != "rig-one" || station.GetHostUUID() != "worker-one" || station.GetStatus() == nil ||
station.GetStatus().GetName() != "rig-one" || station.GetStatus().GetHostUUID() != "worker-one" ||
station.GetStatus().GetState() != clientpb.States_CRACKING || station.GetStatus().GetCurrentCrackJobID() != "job-one" || station.GetStatus().GetIsSyncing() {
t.Fatalf("converted station = %#v", station)
}
if station.GetOperatorName() != "" || station.GetGOOS() != "" || station.GetGOARCH() != "" || station.GetVersion() != "" ||
len(station.GetBenchmarks()) != 0 || len(station.GetCUDA()) != 0 || len(station.GetMetal()) != 0 || len(station.GetOpenCL()) != 0 ||
len(station.GetHIP()) != 0 || len(station.GetCapabilities()) != 0 {
t.Fatalf("lean station conversion populated private inventory fields: %#v", station)
}
if snapshot.Stations[2].GetStatus() != nil {
t.Fatalf("station without available status got a fabricated status: %#v", snapshot.Stations[2])
}
*snapshot.Jobs[0].Command.HashMode = 1000
snapshot.Jobs[0].Tasks[0].ID = "changed-task"
snapshot.Stations[0].Status.Name = "changed-station"
metadata.status.Devices[0].ID = "changed-device"
if wire.Jobs[0].GetHashMode() != 5600 || wire.Jobs[0].Tasks[0].GetID() != "task-live" ||
wire.Crackstations[0].GetName() != "rig-one" || wire.Jobs[0].Tasks[0].Devices[0].GetID() != "gpu-0" {
t.Fatal("converted snapshot aliases mutable wire data")
}
}
//nolint:gocyclo // This test intentionally covers the full loader-to-dashboard telemetry path.
func TestLoadCrackTopSnapshotFeedsDashboardTelemetry(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
client := &crackTopRPCTestClient{
call: func(context.Context, *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
return &clientpb.CrackTopSnapshot{
ObservedAt: now.Unix(),
Jobs: []*clientpb.CrackTopJob{{
ID: crackTopTestJobID,
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: []*clientpb.CrackTopTask{{
ID: "task-wire",
HostUUID: "worker-wire",
Kind: clientpb.CrackTaskKind_CRACK_TASK_UNSPECIFIED,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
LeaseExpiresAt: now.Add(time.Minute).Unix(),
LastHeartbeatAt: now.Unix(),
ProgressCurrent: "25",
ProgressTotal: "100",
StatusAvailable: true,
ProgressExact: true,
Devices: []*clientpb.CrackTopDevice{{
ID: "gpu-0",
Speed: 125_000,
SpeedAvailable: true,
Temperature: 67,
TemperatureAvailable: true,
Utilization: 98,
UtilizationAvailable: true,
}},
}},
}},
Crackstations: []*clientpb.CrackTopStation{{
ID: "station-wire",
Name: "Wire Worker",
HostUUID: "worker-wire",
State: clientpb.States_CRACKING,
CurrentCrackJobID: crackTopTestJobID,
StatusAvailable: true,
}},
}, nil
},
}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, time.Second, time.Time{})
if err != nil {
t.Fatalf("loadCrackTopSnapshot: %v", err)
}
dashboard := buildCrackTopDashboard(snapshot)
if len(dashboard.Jobs) != 1 || !dashboard.Jobs[0].ProgressKnown || dashboard.Jobs[0].Progress != 0.25 {
t.Fatalf("job progress from narrow telemetry = %#v", dashboard.Jobs)
}
if !dashboard.ClusterRateKnown || !dashboard.ClusterComplete || dashboard.ClusterRate != 125_000 {
t.Fatalf("cluster telemetry = rate:%d known:%v complete:%v", dashboard.ClusterRate, dashboard.ClusterRateKnown, dashboard.ClusterComplete)
}
if len(dashboard.Workers) != 1 {
t.Fatalf("workers = %#v, want one", dashboard.Workers)
}
worker := dashboard.Workers[0]
if !worker.TelemetryLive || !worker.RateKnown || worker.Rate != 125_000 || worker.Devices != 1 ||
!worker.HasTemp || worker.Temperature != 67 || !worker.HasUtil || worker.Utilization != 98 {
t.Fatalf("worker telemetry from narrow snapshot = %#v", worker)
}
}
func TestLoadCrackTopSnapshotUsesCompletionTimeOnlyWithoutObservedAt(t *testing.T) {
fallback := time.Unix(1_700_000_000, 123_456_789)
client := &crackTopRPCTestClient{
call: func(context.Context, *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
return &clientpb.CrackTopSnapshot{}, nil
},
}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, 0, fallback)
if err != nil {
t.Fatalf("loadCrackTopSnapshot: %v", err)
}
if !snapshot.RefreshedAt.Equal(fallback) {
t.Fatalf("RefreshedAt = %s, want deterministic completion time %s", snapshot.RefreshedAt, fallback)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
}
func TestLoadCrackTopSnapshotPropagatesDeadlineAndCancelsCallContext(t *testing.T) {
var callContext context.Context
var deadline time.Time
client := &crackTopRPCTestClient{
call: func(ctx context.Context, _ *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
callContext = ctx
var ok bool
deadline, ok = ctx.Deadline()
if !ok {
t.Fatal("CrackTop context has no deadline")
}
return &clientpb.CrackTopSnapshot{ObservedAt: 1}, nil
},
}
started := time.Now()
timeout := time.Second
_, err := loadCrackTopSnapshot(t.Context(), client, timeout, time.Time{})
if err != nil {
t.Fatalf("loadCrackTopSnapshot: %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
if deadline.Before(started) || deadline.After(started.Add(timeout+100*time.Millisecond)) {
t.Fatalf("RPC deadline = %s, want about %s after %s", deadline, timeout, started)
}
select {
case <-callContext.Done():
if !errors.Is(callContext.Err(), context.Canceled) {
t.Fatalf("completed call context error = %v, want context canceled", callContext.Err())
}
default:
t.Fatal("completed call context was not canceled")
}
}
func TestLoadCrackTopSnapshotReturnsNilOnTimeout(t *testing.T) {
client := &crackTopRPCTestClient{
call: func(ctx context.Context, _ *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
<-ctx.Done()
return nil, ctx.Err()
},
}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, 10*time.Millisecond, time.Time{})
if snapshot != nil {
t.Fatalf("timed-out load returned snapshot: %#v", snapshot)
}
if err == nil || err.Error() != "load crack top snapshot: context deadline exceeded" {
t.Fatalf("timed-out load error = %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
}
func TestLoadCrackTopSnapshotPropagatesParentCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
client := &crackTopRPCTestClient{
call: func(ctx context.Context, _ *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
<-ctx.Done()
return nil, ctx.Err()
},
}
snapshot, err := loadCrackTopSnapshot(ctx, client, time.Second, time.Time{})
if snapshot != nil {
t.Fatalf("canceled load returned snapshot: %#v", snapshot)
}
if err == nil || err.Error() != "load crack top snapshot: context canceled" {
t.Fatalf("canceled load error = %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
}
func TestLoadCrackTopSnapshotSanitizesRPCErrorAndRejectsEmptyResponse(t *testing.T) {
t.Run("rpc error", func(t *testing.T) {
raw := errors.New("bad\r\n\x1b[31mserver\x1b[0m\tfailure")
client := &crackTopRPCTestClient{
call: func(context.Context, *commonpb.Empty) (*clientpb.CrackTopSnapshot, error) {
return nil, raw
},
}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, time.Second, time.Time{})
if snapshot != nil {
t.Fatalf("failed load returned snapshot: %#v", snapshot)
}
if err == nil || err.Error() != "load crack top snapshot: bad server failure" {
t.Fatalf("sanitized error = %v", err)
}
if errors.Is(err, raw) {
t.Fatal("sanitized error retained the raw RPC error in its unwrap chain")
}
if strings.ContainsAny(err.Error(), "\r\n\t\x1b") {
t.Fatalf("error still contains terminal controls: %q", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
})
t.Run("nil response", func(t *testing.T) {
client := &crackTopRPCTestClient{}
snapshot, err := loadCrackTopSnapshot(t.Context(), client, time.Second, time.Time{})
if snapshot != nil {
t.Fatalf("empty load returned snapshot: %#v", snapshot)
}
if err == nil || err.Error() != "load crack top snapshot: server returned an empty response" {
t.Fatalf("empty response error = %v", err)
}
if client.calls.Load() != 1 {
t.Fatalf("CrackTop calls = %d, want 1", client.calls.Load())
}
})
}
File diff suppressed because it is too large. Load diff
+598
View File
@@ -0,0 +1,598 @@
package top
import (
"errors"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/x/ansi"
)
//nolint:gocyclo // This test validates all history fields across success, gap, failure, and recovery snapshots.
func TestCrackTopAcceptedSnapshotsAppendRealOverviewHistory(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
first := crackTopLiveTestSnapshot()
_, _ = model.Update(crackTopSnapshotMsg{snapshot: first})
if len(model.history) != 1 {
t.Fatalf("history length after first accepted snapshot = %d, want 1", len(model.history))
}
if sample := model.history[0]; !sample.At.Equal(first.RefreshedAt) ||
!sample.ClusterRateKnown || sample.ClusterRate != 2_502_000 ||
!sample.ProgressKnown || sample.Progress != 0.3125 ||
!sample.UtilizationKnown || sample.Utilization != 98.5 ||
!sample.TemperatureKnown || sample.Temperature != 70 {
t.Fatalf("first history sample does not reflect live fixture telemetry: %#v", sample)
}
gapTime := first.RefreshedAt.Add(time.Second)
unknownGap := &crackTopSnapshot{
Stations: []*clientpb.Crackstation{{
HostUUID: "worker-without-status",
Name: "Awaiting Status",
}},
RefreshedAt: gapTime,
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: unknownGap})
if len(model.history) != 2 {
t.Fatalf("history length after second accepted snapshot = %d, want 2", len(model.history))
}
gap := model.history[1]
if !gap.At.Equal(gapTime) {
t.Fatalf("unknown-gap time = %s, want %s", gap.At, gapTime)
}
if gap.ClusterRateKnown || gap.ProgressKnown || gap.UtilizationKnown || gap.TemperatureKnown {
t.Fatalf("unreported telemetry was recorded as known instead of an unknown gap: %#v", gap)
}
_, _ = model.Update(crackTopSnapshotMsg{err: errors.New("refresh failed")})
if len(model.history) != 3 {
t.Fatalf("failed refresh history length = %d, want an explicit third gap sample", len(model.history))
}
failure := model.history[2]
if failure.ClusterRateKnown || failure.ProgressKnown || failure.UtilizationKnown || failure.TemperatureKnown || failure.CountsKnown {
t.Fatalf("failed refresh did not produce an unknown chart gap: %#v", failure)
}
recovered := crackTopLiveTestSnapshot()
recovered.RefreshedAt = gapTime.Add(time.Second)
_, _ = model.Update(crackTopSnapshotMsg{snapshot: recovered})
if len(model.history) != 4 || !model.history[3].ClusterRateKnown {
t.Fatalf("recovered refresh did not resume live history after the gap: %#v", model.history)
}
}
func TestCrackTopWideOverviewRendersRealDataCardsChartsAndJournal(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
model.refreshing = false
_, _ = model.Update(crackTopSnapshotMsg{snapshot: crackTopLiveTestSnapshot()})
view := model.View()
if !view.AltScreen {
t.Fatal("wide crack top overview did not request the alternate screen")
}
plain := ansi.Strip(view.Content)
for _, expected := range []string{
"CLUSTER / STATE",
"THROUGHPUT",
"SAMPLE AVG",
"DIAGNOSTICS",
"KEYSPACE / PROGRESS",
"OBSERVED WORKERS",
"DEVICE LOAD",
"rate",
"progress",
"worker max util",
"RECENT JOURNAL",
"ACTIVE 1",
"JOBS 1 loaded",
"2/2 online",
"2.50 MH/s",
"RECOVERED 7 loaded",
"31.2%",
"98% avg worker max",
"70°C max",
"QUEUE 2 run 0 leased 0 waiting",
"Alpha Rig",
"Beta Rig",
"snapshot ready",
} {
if !strings.Contains(plain, expected) {
t.Errorf("wide overview does not contain %q:\n%s", expected, plain)
}
}
if got := ansi.StringWidth(strings.Split(view.Content, "\n")[0]); got > model.width {
t.Fatalf("wide overview first-line width = %d, want <= %d", got, model.width)
}
}
func TestCrackTopOverviewDoesNotPresentDefaultsAsLiveBeforeSnapshot(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = crackTopOverviewWidth
model.height = crackTopOverviewHeight
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"WAITING", "first live snapshot", "ACTIVE —", "RECOVERED —", "QUEUE —"} {
if !strings.Contains(plain, expected) {
t.Errorf("pre-snapshot overview is missing %q:\n%s", expected, plain)
}
}
for _, unsupported := range []string{"HEALTHY", "OK LIVE", "ACTIVE 0", "RECOVERED 0", "QUEUE 0"} {
if strings.Contains(plain, unsupported) {
t.Errorf("pre-snapshot overview presented %q as observed:\n%s", unsupported, plain)
}
}
_, _ = model.Update(crackTopSnapshotMsg{err: errors.New("rpc unavailable")})
failed := ansi.Strip(model.View().Content)
for _, expected := range []string{"NO SNAPSHOT", "refresh failed", "ERROR", "no live snapshot"} {
if !strings.Contains(failed, expected) {
t.Errorf("initial-failure overview is missing %q:\n%s", expected, failed)
}
}
}
func TestCrackTopCompactViewDoesNotPresentDefaultsAsLiveBeforeSnapshot(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 90
model.height = 28
model.refreshing = false
plain := ansi.Strip(model.View().Content)
for _, expected := range []string{"waiting for data", "ACTIVE —", "JOBS —", "WORKERS —", "RECOVERED —", "awaiting first snapshot"} {
if !strings.Contains(plain, expected) {
t.Errorf("compact pre-snapshot view is missing %q:\n%s", expected, plain)
}
}
for _, unsupported := range []string{"ACTIVE 0", "JOBS 0 loaded", "WORKERS 0/0", "RECOVERED 0", "no active jobs"} {
if strings.Contains(plain, unsupported) {
t.Errorf("compact pre-snapshot view presented %q as observed:\n%s", unsupported, plain)
}
}
_, _ = model.Update(crackTopSnapshotMsg{err: errors.New("rpc unavailable")})
failed := ansi.Strip(model.View().Content)
for _, expected := range []string{"no snapshot", "ACTIVE —", "current data unavailable"} {
if !strings.Contains(failed, expected) {
t.Errorf("compact initial-failure view is missing %q:\n%s", expected, failed)
}
}
for _, unsupported := range []string{"ACTIVE 0", "JOBS 0 loaded", "WORKERS 0/0", "RECOVERED 0", "no active jobs"} {
if strings.Contains(failed, unsupported) {
t.Errorf("compact initial-failure view presented %q as observed:\n%s", unsupported, failed)
}
}
}
func TestCrackTopOverviewMarksRetainedSnapshotMetricsUnavailableAfterRefreshFailure(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
_, _ = model.Update(crackTopSnapshotMsg{snapshot: crackTopLiveTestSnapshot()})
_, _ = model.Update(crackTopSnapshotMsg{err: errors.New("temporary outage")})
meters := ansi.Strip(model.renderOverviewMeters(150, crackTopOverviewMetersHeight))
if count := strings.Count(meters, "current data unavailable"); count != 3 {
t.Fatalf("failed refresh meters did not expose progress/worker gaps:\n%s", meters)
}
if strings.Contains(meters, "no active jobs") || strings.Contains(meters, "no observed workers") {
t.Fatalf("failed refresh meters converted unknown counts to zero:\n%s", meters)
}
throughput := ansi.Strip(model.renderOverviewThroughputCard(55, crackTopOverviewSummaryHeight))
if !strings.Contains(throughput, "CLUSTER last 2.50 MH/s") || !strings.Contains(throughput, "RECOVERED 7 last snapshot") || !strings.Contains(throughput, "VIEW —") {
t.Fatalf("retained throughput was not labeled as stale:\n%s", throughput)
}
diagnostics := ansi.Strip(model.renderOverviewDiagnosticsCard(55, crackTopOverviewSummaryHeight))
if !strings.Contains(diagnostics, "QUEUE last 2 run") {
t.Fatalf("retained queue counts were presented as current:\n%s", diagnostics)
}
jobs := ansi.Strip(model.renderJobsPane(90, 8))
workers := ansi.Strip(model.renderWorkersPane(59, 8))
if !strings.Contains(jobs, "LAST SNAPSHOT") || !strings.Contains(workers, "LAST SNAPSHOT") {
t.Fatalf("retained detail panes were not labeled stale:\njobs:\n%s\nworkers:\n%s", jobs, workers)
}
}
func TestCrackTopOverviewRefreshInFlightIsSilent(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
_, _ = model.Update(crackTopSnapshotMsg{snapshot: crackTopLiveTestSnapshot()})
if command := model.startRefresh(); command == nil || !model.refreshing {
t.Fatalf("refresh did not start: command=%v refreshing=%v", command != nil, model.refreshing)
}
top := ansi.Strip(model.renderOverviewTopBar(model.width))
diagnostics := ansi.Strip(model.renderOverviewDiagnosticsCard(50, crackTopOverviewSummaryHeight))
footer := ansi.Strip(model.renderOverviewFooter(model.width))
header := ansi.Strip(model.renderHeader(model.width))
for name, rendered := range map[string]string{
"top bar": top,
"diagnostics": diagnostics,
"footer": footer,
"header": header,
} {
lower := strings.ToLower(rendered)
if strings.Contains(lower, "refreshing") || strings.Contains(lower, "live snapshot in flight") {
t.Errorf("%s exposed an in-flight refresh:\n%s", name, rendered)
}
}
if !strings.Contains(diagnostics, "OK LIVE DATA COMPLETE") {
t.Errorf("silent refresh changed snapshot diagnostics:\n%s", diagnostics)
}
if !strings.Contains(footer, "JOBS focus") {
t.Errorf("silent refresh displaced the normal footer:\n%s", footer)
}
if !strings.Contains(header, "updated ") {
t.Errorf("silent refresh hid the current snapshot timestamp:\n%s", header)
}
}
func TestCrackTopOverviewLabelsPartialRateHistoryAsLowerBounds(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
model.snapshot = &crackTopSnapshot{RefreshedAt: time.Now()}
model.dashboard = crackTopDashboard{ClusterRate: 2_000, ClusterRateKnown: true, ClusterComplete: false}
model.history = []crackTopHistorySample{
{At: time.Now().Add(-time.Second), ClusterRate: 1_000, ClusterRateKnown: true, ClusterComplete: true},
{At: time.Now(), ClusterRate: 2_000, ClusterRateKnown: true, ClusterComplete: false},
}
average, peak, known, complete := crackTopRateStats(model.history)
if !known || complete || average != 1_500 || peak != 2_000 {
t.Fatalf("partial rate stats = avg:%d peak:%d known:%v complete:%v", average, peak, known, complete)
}
plain := ansi.Strip(model.renderOverviewThroughputCard(55, crackTopOverviewSummaryHeight))
if !strings.Contains(plain, "CLUSTER ≥ 2.00 kH/s") || !strings.Contains(plain, "AVG ≥ 1.50 kH/s") || !strings.Contains(plain, "PEAK ≥ 2.00 kH/s") {
t.Fatalf("partial rate card lost lower-bound semantics:\n%s", plain)
}
rateChart := ansi.Strip(model.renderOverviewCharts(150, 8))
if !strings.Contains(rateChart, "rate ≥ 2.00 kH/s") {
t.Fatalf("partial rate chart lost current lower-bound semantics:\n%s", rateChart)
}
}
func TestCrackTopOverviewPausedOnlyWorkloadIsNotIdle(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.snapshot = &crackTopSnapshot{RefreshedAt: time.Now()}
model.dashboard = crackTopDashboard{Jobs: []crackTopJobRow{{ID: "paused", Status: clientpb.CrackJobStatus_PAUSED}}}
plain := ansi.Strip(model.renderOverviewStateCard(50, crackTopOverviewSummaryHeight))
if !strings.Contains(plain, "PAUSED") || strings.Contains(plain, "IDLE") {
t.Fatalf("paused-only workload state is misleading:\n%s", plain)
}
}
func TestCrackTopPlotPreservesUnknownGapAndBounds(t *testing.T) {
const (
width = 4
height = 4
)
plot := crackTopRenderPlot([]crackTopChartPoint{
{value: 100, known: true},
{value: 100, known: true},
{known: false},
{value: 100, known: true},
}, width, height, 100)
lines := strings.Split(plot, "\n")
if len(lines) != height {
t.Fatalf("plot height = %d, want %d:\n%s", len(lines), height, plot)
}
for index, line := range lines {
if got := ansi.StringWidth(line); got > width {
t.Fatalf("plot line %d width = %d, want <= %d: %q", index+1, got, width, line)
}
}
top := []rune(lines[0])
if len(top) != width {
t.Fatalf("plot top row width = %d runes, want %d: %q", len(top), width, lines[0])
}
if top[0] != '╶' || top[1] != '╴' || top[2] != ' ' || top[3] != '●' {
t.Fatalf("plot did not preserve the unknown sample as a visible gap: %q", lines[0])
}
}
func TestCrackTopPlotConnectsVerticalTransitions(t *testing.T) {
testCases := []struct {
name string
points []crackTopChartPoint
height int
want string
}{
{
name: "rising",
points: []crackTopChartPoint{
{value: 0, known: true},
{value: 50, known: true},
{value: 100, known: true},
},
height: 3,
want: " ╷\n·┌┘\n╶┘·",
},
{
name: "falling",
points: []crackTopChartPoint{
{value: 100, known: true},
{value: 50, known: true},
{value: 0, known: true},
},
height: 3,
want: "╶┐ \n·└┐\n··╵",
},
{
name: "flat",
points: []crackTopChartPoint{
{value: 50, known: true},
{value: 50, known: true},
{value: 50, known: true},
},
height: 3,
want: " \n╶─╴\n···",
},
{
name: "full range rise",
points: []crackTopChartPoint{
{value: 0, known: true},
{value: 100, known: true},
},
height: 4,
want: " ╷\n │\n·│\n╶┘",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
got := crackTopRenderPlot(testCase.points, len(testCase.points), testCase.height, 100)
if got != testCase.want {
t.Fatalf("plot =\n%q\nwant\n%q", got, testCase.want)
}
})
}
}
func TestCrackTopChartStatsDoesNotPresentStaleLatestAsCurrent(t *testing.T) {
latest, average, peak, latestKnown, historyKnown, latestLowerBound, historyLowerBound := crackTopChartStats([]crackTopChartPoint{
{value: 25, known: true},
{value: 75, known: true},
{known: false},
})
if latestKnown || !historyKnown {
t.Fatalf("latest/history known = %v/%v, want false/true", latestKnown, historyKnown)
}
if latestLowerBound || historyLowerBound {
t.Fatalf("unexpected lower-bound flags = latest:%v history:%v", latestLowerBound, historyLowerBound)
}
if latest != 0 || average != 50 || peak != 75 {
t.Fatalf("latest/average/peak = %v/%v/%v, want 0/50/75", latest, average, peak)
}
}
func TestCrackTopChartStatsUseOnlyVisiblePlotWindow(t *testing.T) {
points := []crackTopChartPoint{
{value: 1_000, known: true},
{value: 10, known: true},
{value: 20, known: true},
{value: 30, known: true},
}
visible := crackTopVisibleChartPoints(points, 3)
latest, average, peak, latestKnown, historyKnown, _, _ := crackTopChartStats(visible)
if len(visible) != 3 || !latestKnown || !historyKnown || latest != 30 || average != 20 || peak != 30 {
t.Fatalf("visible chart stats = len:%d latest:%v avg:%v peak:%v latest-known:%v history-known:%v", len(visible), latest, average, peak, latestKnown, historyKnown)
}
}
func TestCrackTopRecoveredDeltaUsesOnlyObservedCountEndpoints(t *testing.T) {
history := []crackTopHistorySample{
{Recovered: 0, CountsKnown: false},
{Recovered: 10, CountsKnown: true},
{Recovered: 0, CountsKnown: false},
{Recovered: 13, CountsKnown: true},
}
if delta, known := crackTopRecoveredDelta(history); !known || delta != 3 {
t.Fatalf("recovered delta across boundary gaps = %d known:%v, want 3 known", delta, known)
}
if delta, known := crackTopRecoveredDelta([]crackTopHistorySample{{Recovered: 13}, {Recovered: 14}}); known || delta != 0 {
t.Fatalf("unobserved recovered endpoints = %d known:%v, want unknown", delta, known)
}
if delta, known := crackTopRecoveredDelta([]crackTopHistorySample{{Recovered: 13, CountsKnown: true}}); known || delta != 0 {
t.Fatalf("single recovered endpoint = %d known:%v, want unknown", delta, known)
}
if delta, known := crackTopRecoveredDelta([]crackTopHistorySample{{Recovered: 13, CountsKnown: true}, {Recovered: 12, CountsKnown: true}}); known || delta != 0 {
t.Fatalf("decreasing recovered counter = %d known:%v, want unknown", delta, known)
}
}
func TestCrackTopChartSpanDisclosesSampleIndexedHistory(t *testing.T) {
points := []crackTopChartPoint{
{at: time.Unix(1_700_000_000, 0)},
{at: time.Unix(1_700_000_001, 0)},
{at: time.Unix(1_700_000_061, 0)},
}
if got := crackTopChartSpan(points); got != "1m1s/3 samples" {
t.Fatalf("chart span = %q, want elapsed time and sample count", got)
}
}
func TestCrackTopPlotDistinguishesUnknownFromObservedZero(t *testing.T) {
plot := crackTopRenderPlot([]crackTopChartPoint{
{value: 0, known: true},
{known: false},
{value: 0, known: true},
}, 3, 3, 100)
bottom := strings.Split(plot, "\n")[2]
if bottom != "●·●" {
t.Fatalf("zero/unknown/zero baseline = %q, want observed points around a grid gap", bottom)
}
}
func TestCrackTopAuthoritativeJobActionIsJournaled(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.runningJobAction = crackTopPendingJobAction{action: crackTopJobActionPause, jobID: crackTopTestJobID}
_, _ = model.Update(crackTopJobActionCompletedMsg{
action: crackTopJobActionPause,
jobID: crackTopTestJobID,
status: clientpb.CrackJobStatus_PAUSED,
updatedAt: 1_700_000_100,
hasStatus: true,
})
if len(model.journal) != 1 || model.journal[0].label != "JOB" || model.journal[0].message != "aaaaaaaa PAUSED" {
t.Fatalf("authoritative action journal = %#v", model.journal)
}
}
//nolint:gocyclo // This test keeps the complete action-to-authoritative-snapshot freshness transition together.
func TestCrackTopActionMarksTaskTelemetryStaleUntilAuthoritativeSnapshot(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.width = 150
model.height = 40
initial := crackTopLiveTestSnapshot()
_, _ = model.Update(crackTopSnapshotMsg{snapshot: initial})
actionUpdatedAt := initial.RefreshedAt.Add(time.Second).Unix()
model.runningJobAction = crackTopPendingJobAction{action: crackTopJobActionPause, jobID: crackTopTestJobID}
_, _ = model.Update(crackTopJobActionCompletedMsg{
action: crackTopJobActionPause,
jobID: crackTopTestJobID,
status: clientpb.CrackJobStatus_PAUSED,
updatedAt: actionUpdatedAt,
hasStatus: true,
})
if !model.authoritativeSnapshotPending() {
t.Fatal("successful lifecycle action did not mark task telemetry as awaiting an authoritative snapshot")
}
latest := model.history[len(model.history)-1]
if latest.ClusterRateKnown || latest.ProgressKnown || latest.UtilizationKnown || latest.CountsKnown {
t.Fatalf("post-action history sample presented stale task telemetry as current: %#v", latest)
}
plain := ansi.Strip(strings.Join([]string{
model.renderOverviewStateCard(50, crackTopOverviewSummaryHeight),
model.renderOverviewThroughputCard(50, crackTopOverviewSummaryHeight),
model.renderOverviewDiagnosticsCard(50, crackTopOverviewSummaryHeight),
model.renderOverviewMeters(150, crackTopOverviewMetersHeight),
model.renderJobsPane(90, 8),
model.renderWorkersPane(59, 8),
}, "\n"))
for _, expected := range []string{"SYNCING", "TASK DATA LAST", "RECOVERED 7 last snapshot", "task data pending", "QUEUE last 2 run", "last snapshot • syncing", "TASK DATA LAST/SYNCING", "LAST SNAPSHOT/SYNCING"} {
if !strings.Contains(plain, expected) {
t.Errorf("post-action overview is missing %q:\n%s", expected, plain)
}
}
if strings.Contains(strings.ToLower(plain), "refreshing") {
t.Errorf("post-action overview exposed refresh progress:\n%s", plain)
}
stale := crackTopLiveTestSnapshot()
stale.RefreshedAt = stale.RefreshedAt.Add(500 * time.Millisecond)
stale.Jobs[0].UpdatedAt = actionUpdatedAt
stale.Stations = stale.Stations[:1]
_, _ = model.Update(crackTopSnapshotMsg{snapshot: stale})
if !model.authoritativeSnapshotPending() {
t.Fatal("equal-version pre-action snapshot prematurely cleared lifecycle synchronization state")
}
staleSample := model.history[len(model.history)-1]
if staleSample.ClusterRateKnown || staleSample.ProgressKnown || staleSample.UtilizationKnown || staleSample.CountsKnown {
t.Fatalf("client-overridden pre-action snapshot was recorded as authoritative telemetry: %#v", staleSample)
}
foundUnrelatedTransition := false
for _, entry := range model.journal {
if entry.label == "WORKER" && entry.message == "Beta Rig disconnected" {
foundUnrelatedTransition = true
break
}
}
if !foundUnrelatedTransition {
t.Fatalf("pending-action snapshot discarded an unrelated worker transition: %#v", model.journal)
}
authoritative := crackTopLiveTestSnapshot()
authoritative.RefreshedAt = authoritative.RefreshedAt.Add(2 * time.Second)
authoritative.Jobs[0].Status = clientpb.CrackJobStatus_PAUSED
authoritative.Jobs[0].UpdatedAt = actionUpdatedAt
for _, task := range authoritative.Jobs[0].Tasks {
task.State = clientpb.CrackTaskState_CRACK_TASK_QUEUED
}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: authoritative})
if model.authoritativeSnapshotPending() {
t.Fatal("matching authoritative snapshot did not clear lifecycle synchronization state")
}
diagnostics := ansi.Strip(model.renderOverviewDiagnosticsCard(50, crackTopOverviewSummaryHeight))
if strings.Contains(diagnostics, "QUEUE last") || strings.Contains(diagnostics, "SYNC action") {
t.Fatalf("authoritative snapshot remained labeled as stale:\n%s", diagnostics)
}
}
func TestCrackTopOverviewSelectionMarkerFollowsFocusedPane(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
model.dashboard = buildCrackTopDashboard(crackTopLiveTestSnapshot())
model.normalizeJobSelection()
model.normalizeWorkerSelection()
jobs := ansi.Strip(model.renderJobsPane(75, 8))
workers := ansi.Strip(model.renderWorkersPane(55, 8))
if !strings.Contains(jobs, "› aaaaaaaa") || strings.Contains(workers, "› ") {
t.Fatalf("job focus markers are ambiguous:\njobs:\n%s\nworkers:\n%s", jobs, workers)
}
_, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab})
jobs = ansi.Strip(model.renderJobsPane(75, 8))
workers = ansi.Strip(model.renderWorkersPane(55, 8))
if strings.Contains(jobs, "› ") || !strings.Contains(workers, "› ") {
t.Fatalf("worker focus markers are ambiguous:\njobs:\n%s\nworkers:\n%s", jobs, workers)
}
}
func TestCrackTopJournalRecordsRecoveredJobAndWorkerTransitions(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
first := crackTopLiveTestSnapshot()
_, _ = model.Update(crackTopSnapshotMsg{snapshot: first})
second := crackTopLiveTestSnapshot()
second.RefreshedAt = first.RefreshedAt.Add(time.Second)
second.Jobs[0].Status = clientpb.CrackJobStatus_COMPLETED
second.Jobs[0].ResultCount = 10
second.Stations = second.Stations[:1]
_, _ = model.Update(crackTopSnapshotMsg{snapshot: second})
wants := map[string]string{
"JOB": "aaaaaaaa COMPLETED",
"WORKER": "Beta Rig disconnected",
"RECOVERED": "+3 across loaded jobs",
}
for label, message := range wants {
found := false
for _, entry := range model.journal {
if entry.label == label && entry.message == message {
found = true
if !entry.at.Equal(second.RefreshedAt) {
t.Errorf("%s journal time = %s, want %s", label, entry.at, second.RefreshedAt)
}
break
}
}
if !found {
t.Errorf("journal missing %s transition %q: %#v", label, message, model.journal)
}
}
}
func TestCrackTopJournalRecordsActiveJobRemovedByAnotherOperator(t *testing.T) {
model := newCrackTopModel(t.Context(), nil, nil, time.Second)
first := crackTopLiveTestSnapshot()
_, _ = model.Update(crackTopSnapshotMsg{snapshot: first})
second := &crackTopSnapshot{RefreshedAt: first.RefreshedAt.Add(time.Second)}
_, _ = model.Update(crackTopSnapshotMsg{snapshot: second})
for _, entry := range model.journal {
if entry.label == "JOB" && entry.message == "aaaaaaaa removed from live set" {
return
}
}
t.Fatalf("journal did not record externally removed active job: %#v", model.journal)
}
+123
View File
@@ -0,0 +1,123 @@
package top
import (
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
type crackTopQueueCounts struct {
queued int
leased int
running int
}
func TestBuildCrackTopDashboardCountsActiveJobTaskQueueStates(t *testing.T) {
crackTasks := func(states ...clientpb.CrackTaskState) []*clientpb.CrackTask {
tasks := make([]*clientpb.CrackTask, 0, len(states))
for _, state := range states {
tasks = append(tasks, &clientpb.CrackTask{
Kind: clientpb.CrackTaskKind_CRACK_TASK_CRACK,
State: state,
})
}
return tasks
}
testCases := []struct {
name string
jobs []*clientpb.CrackJob
want crackTopQueueCounts
}{
{
name: "in-progress job",
jobs: []*clientpb.CrackJob{{
ID: "in-progress",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: crackTasks(
clientpb.CrackTaskState_CRACK_TASK_QUEUED,
clientpb.CrackTaskState_CRACK_TASK_LEASED,
clientpb.CrackTaskState_CRACK_TASK_RUNNING,
),
}},
want: crackTopQueueCounts{queued: 1, leased: 1, running: 1},
},
{
name: "paused job",
jobs: []*clientpb.CrackJob{{
ID: "paused",
Status: clientpb.CrackJobStatus_PAUSED,
Tasks: crackTasks(
clientpb.CrackTaskState_CRACK_TASK_QUEUED,
clientpb.CrackTaskState_CRACK_TASK_LEASED,
clientpb.CrackTaskState_CRACK_TASK_RUNNING,
),
}},
want: crackTopQueueCounts{queued: 1, leased: 1, running: 1},
},
{
name: "legacy unspecified crack task with shard telemetry",
jobs: []*clientpb.CrackJob{{
ID: "legacy-crack",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: []*clientpb.CrackTask{{
Kind: clientpb.CrackTaskKind_CRACK_TASK_UNSPECIFIED,
State: clientpb.CrackTaskState_CRACK_TASK_RUNNING,
ShardLimit: 100,
}},
}},
want: crackTopQueueCounts{running: 1},
},
{
name: "keyspace and auxiliary scheduler stages",
jobs: []*clientpb.CrackJob{{
ID: "queries",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: []*clientpb.CrackTask{
{Kind: clientpb.CrackTaskKind_CRACK_TASK_UNSPECIFIED, State: clientpb.CrackTaskState_CRACK_TASK_QUEUED},
{Kind: clientpb.CrackTaskKind_CRACK_TASK_KEYSPACE, State: clientpb.CrackTaskState_CRACK_TASK_QUEUED},
{Kind: clientpb.CrackTaskKind_CRACK_TASK_BENCHMARK, State: clientpb.CrackTaskState_CRACK_TASK_LEASED},
{Kind: clientpb.CrackTaskKind_CRACK_TASK_QUERY, State: clientpb.CrackTaskState_CRACK_TASK_RUNNING},
},
}},
want: crackTopQueueCounts{queued: 2, leased: 1, running: 1},
},
{
name: "terminal jobs",
jobs: []*clientpb.CrackJob{
{ID: "completed", Status: clientpb.CrackJobStatus_COMPLETED, Tasks: crackTasks(clientpb.CrackTaskState_CRACK_TASK_QUEUED)},
{ID: "failed", Status: clientpb.CrackJobStatus_FAILED, Tasks: crackTasks(clientpb.CrackTaskState_CRACK_TASK_LEASED)},
{ID: "cancelled", Status: clientpb.CrackJobStatus_CANCELLED, Tasks: crackTasks(clientpb.CrackTaskState_CRACK_TASK_RUNNING)},
},
},
{
name: "terminal task states",
jobs: []*clientpb.CrackJob{{
ID: "active-with-terminal-tasks",
Status: clientpb.CrackJobStatus_IN_PROGRESS,
Tasks: append(
crackTasks(
clientpb.CrackTaskState_CRACK_TASK_COMPLETED,
clientpb.CrackTaskState_CRACK_TASK_FAILED,
clientpb.CrackTaskState_CRACK_TASK_CANCELLED,
),
nil,
),
}},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
dashboard := buildCrackTopDashboard(&crackTopSnapshot{Jobs: testCase.jobs})
got := crackTopQueueCounts{
queued: dashboard.QueuedTasks,
leased: dashboard.LeasedTasks,
running: dashboard.RunningTasks,
}
if got != testCase.want {
t.Fatalf("task queue counts = %+v, want %+v", got, testCase.want)
}
})
}
}
+172
View File
@@ -0,0 +1,172 @@
package top
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/bishopfox/sliver/client/command/crack/internal/hashcatdisplay"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
// These views intentionally contain only the status fields used by Crack Top.
// The production loader fills them from the lean CrackTop RPC; JSON decoding is
// retained for compatibility with locally constructed snapshots and fixtures.
type crackStatusView struct {
ProgressCurrent string
ProgressTotal string
Devices []crackDeviceView
}
type crackDeviceView struct {
ID string
Name string
Type string
Speed string
Temperature string
Utilization string
}
func hashcatHashTypeName(mode int32) (string, bool) {
return hashcatdisplay.TypeName(mode)
}
func humanizeHashRate(rate uint64) string {
return hashcatdisplay.HumanizeRate(rate)
}
func crackJobSortTime(job *clientpb.CrackJob) time.Time {
if job == nil {
return time.Time{}
}
if updatedAt := job.GetUpdatedAt(); updatedAt > 0 {
return time.Unix(updatedAt, 0)
}
createdAt := strings.TrimSpace(job.GetCreatedAt())
if unix, err := strconv.ParseInt(createdAt, 10, 64); err == nil && unix > 0 {
return time.Unix(unix, 0)
}
parsed, _ := time.Parse(time.RFC3339, createdAt)
return parsed
}
func crackJobTasks(job *clientpb.CrackJob) []*clientpb.CrackTask {
if job == nil {
return nil
}
return job.GetTasks()
}
func crackTaskStatus(task *clientpb.CrackTask) (*crackStatusView, error) {
if task == nil || len(bytes.TrimSpace(task.GetLatestStatusJSON())) == 0 {
return nil, nil
}
return parseCrackStatusJSON(task.GetLatestStatusJSON())
}
func parseCrackStatusJSON(raw []byte) (*crackStatusView, error) {
root, err := decodeLastCrackTopJSONObject(raw)
if err != nil {
return nil, err
}
status := &crackStatusView{}
status.ProgressCurrent, status.ProgressTotal = crackTopJSONPair(root["progress"])
if devices, ok := root["devices"].([]any); ok {
for _, item := range devices {
values, ok := item.(map[string]any)
if !ok {
continue
}
status.Devices = append(status.Devices, crackDeviceView{
ID: crackTopJSONText(firstCrackTopJSONValue(values, "device_id", "id")),
Name: crackTopSafeCell(crackTopJSONText(firstCrackTopJSONValue(values, "device_name", "name"))),
Type: crackTopSafeCell(crackTopJSONText(firstCrackTopJSONValue(values, "device_type", "type"))),
Speed: crackTopJSONText(firstCrackTopJSONValue(values, "speed")),
Temperature: crackTopJSONText(firstCrackTopJSONValue(values, "temp", "temperature")),
Utilization: crackTopJSONText(firstCrackTopJSONValue(values, "util", "utilization")),
})
}
}
return status, nil
}
func decodeLastCrackTopJSONObject(raw []byte) (map[string]any, error) {
decode := func(candidate []byte) (map[string]any, error) {
decoder := json.NewDecoder(bytes.NewReader(candidate))
decoder.UseNumber()
var last map[string]any
for {
value := map[string]any{}
if err := decoder.Decode(&value); err != nil {
break
}
last = value
}
if last == nil {
return nil, errors.New("no JSON object")
}
return last, nil
}
trimmed := bytes.TrimSpace(raw)
if value, err := decode(trimmed); err == nil {
return value, nil
}
lines := bytes.Split(trimmed, []byte{'\n'})
for index := len(lines) - 1; index >= 0; index-- {
candidate := bytes.TrimSpace(lines[index])
if len(candidate) == 0 {
continue
}
if value, err := decode(candidate); err == nil {
return value, nil
}
}
return nil, errors.New("hashcat status did not contain a JSON object")
}
func firstCrackTopJSONValue(values map[string]any, names ...string) any {
for _, name := range names {
if value, ok := values[name]; ok {
return value
}
}
return nil
}
func crackTopJSONPair(value any) (string, string) {
items, ok := value.([]any)
if !ok || len(items) < 2 {
return "", ""
}
return crackTopJSONText(items[0]), crackTopJSONText(items[1])
}
func crackTopJSONText(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return crackTopSafeCell(typed)
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case bool:
return strconv.FormatBool(typed)
default:
return crackTopSafeCell(fmt.Sprint(typed))
}
}
func valueOrDash(value string) string {
value = crackTopSafeCell(value)
if value == "" {
return "-"
}
return value
}
+669
View File
@@ -0,0 +1,669 @@
package top
import (
"errors"
"math"
"math/big"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/x/ansi"
)
const crackTopMaxProgressTextSize = 148
type crackTopDashboard struct {
Jobs []crackTopJobRow
Workers []crackTopWorkerRow
ActiveJobs int
QueuedTasks int
LeasedTasks int
RunningTasks int
OnlineWorkers int
Unavailable int
Recovered uint64
ClusterRate uint64
ClusterRateKnown bool
ClusterComplete bool
ExpectedWorkers int
ReportingWorkers int
Progress float64
ProgressKnown bool
ProgressJobs int
}
type crackTopJobRow struct {
ID string
Status clientpb.CrackJobStatus
CreatedAt time.Time
UpdatedAt time.Time
Progress float64
ProgressKnown bool
ProgressComplete bool
Rate uint64
RateKnown bool
Workers int
Reporting int
Results uint64
Mode string
TelemetryErrors int
}
type crackTopWorkerRow struct {
ID string
Name string
State string
Online bool
Syncing bool
JobID string
Rate uint64
RateKnown bool
Devices int
Temperature float64
HasTemp bool
Utilization float64
HasUtil bool
LastSeen time.Time
TelemetryLive bool
}
type crackTopTaskSample struct {
jobID string
task *clientpb.CrackTask
status *crackStatusView
statusErr error
rate uint64
rateKnown bool
devices int
temperature float64
hasTemp bool
utilization float64
hasUtil bool
lastSeen time.Time
}
type crackTopProgressAccumulator struct {
completed *big.Rat
total *big.Rat
}
func newCrackTopProgressAccumulator() *crackTopProgressAccumulator {
return &crackTopProgressAccumulator{completed: new(big.Rat), total: new(big.Rat)}
}
func (a *crackTopProgressAccumulator) add(fraction, weight *big.Rat) {
if a == nil || fraction == nil || weight == nil || weight.Sign() <= 0 {
return
}
clamped := new(big.Rat).Set(fraction)
if clamped.Sign() < 0 {
clamped.SetInt64(0)
}
if clamped.Cmp(big.NewRat(1, 1)) > 0 {
clamped.SetInt64(1)
}
a.completed.Add(a.completed, new(big.Rat).Mul(clamped, weight))
a.total.Add(a.total, weight)
}
func (a *crackTopProgressAccumulator) value() (float64, bool) {
if a == nil || a.total.Sign() <= 0 {
return 0, false
}
fraction, _ := new(big.Rat).Quo(a.completed, a.total).Float64()
if math.IsNaN(fraction) || math.IsInf(fraction, 0) {
return 0, false
}
return math.Max(0, math.Min(1, fraction)), true
}
//nolint:gocyclo // Jobs, task telemetry, and connected workers are normalized in one immutable snapshot pass.
func buildCrackTopDashboard(snapshot *crackTopSnapshot) crackTopDashboard {
dashboard := crackTopDashboard{}
if snapshot == nil {
return dashboard
}
stations := make(map[string]*clientpb.Crackstation, len(snapshot.Stations))
for _, station := range snapshot.Stations {
if station == nil {
continue
}
hostID := crackTopStationID(station)
if hostID == "" {
continue
}
if _, exists := stations[hostID]; !exists {
stations[hostID] = station
}
}
dashboard.OnlineWorkers = len(stations)
jobs := make([]*clientpb.CrackJob, 0, len(snapshot.Jobs))
for _, job := range snapshot.Jobs {
if job != nil && strings.TrimSpace(job.GetID()) != "" {
jobs = append(jobs, job)
}
}
sort.SliceStable(jobs, func(i, j int) bool {
left := crackJobSortTime(jobs[i])
right := crackJobSortTime(jobs[j])
if left.Equal(right) {
return jobs[i].GetID() < jobs[j].GetID()
}
return left.After(right)
})
globalProgress := newCrackTopProgressAccumulator()
workerSamples := map[string]crackTopTaskSample{}
for _, job := range jobs {
row, samples, progress := crackTopJobTelemetry(job, stations, snapshot.RefreshedAt, snapshot.taskTelemetry)
dashboard.Jobs = append(dashboard.Jobs, row)
dashboard.Recovered = saturatingUint64Add(dashboard.Recovered, row.Results)
jobStatus := job.GetStatus()
if jobStatus == clientpb.CrackJobStatus_IN_PROGRESS || jobStatus == clientpb.CrackJobStatus_PAUSED {
for _, task := range crackJobTasks(job) {
if task == nil {
continue
}
switch task.GetState() {
case clientpb.CrackTaskState_CRACK_TASK_QUEUED:
dashboard.QueuedTasks++
case clientpb.CrackTaskState_CRACK_TASK_LEASED:
dashboard.LeasedTasks++
case clientpb.CrackTaskState_CRACK_TASK_RUNNING:
dashboard.RunningTasks++
}
}
}
if jobStatus == clientpb.CrackJobStatus_IN_PROGRESS {
dashboard.ActiveJobs++
if row.ProgressComplete {
dashboard.ProgressJobs++
}
if progress != nil {
globalProgress.completed.Add(globalProgress.completed, progress.completed)
globalProgress.total.Add(globalProgress.total, progress.total)
}
for hostID, sample := range samples {
if hostID == "" {
continue
}
if current, ok := workerSamples[hostID]; !ok || crackTopSamplePreferred(sample, current, stations[hostID], snapshot.RefreshedAt) {
workerSamples[hostID] = sample
}
}
}
}
dashboard.Progress, dashboard.ProgressKnown = globalProgress.value()
dashboard.ProgressKnown = dashboard.ProgressKnown && dashboard.ProgressJobs == dashboard.ActiveJobs
if dashboard.ActiveJobs == 0 {
dashboard.Progress = 0
dashboard.ProgressKnown = false
}
workerIDs := make(map[string]struct{}, len(stations)+len(workerSamples))
for hostID := range stations {
workerIDs[hostID] = struct{}{}
}
for hostID := range workerSamples {
workerIDs[hostID] = struct{}{}
}
for hostID := range workerIDs {
station := stations[hostID]
sample, hasSample := workerSamples[hostID]
worker := crackTopWorkerRow{ID: hostID, Online: station != nil}
if station != nil {
worker.Name = crackTopSafeCell(station.GetName())
if worker.Name == "" && station.GetStatus() != nil {
worker.Name = crackTopSafeCell(station.GetStatus().GetName())
}
if status := station.GetStatus(); status != nil {
worker.State = status.GetState().String()
worker.Syncing = status.GetIsSyncing()
worker.JobID = strings.TrimSpace(status.GetCurrentCrackJobID())
}
}
if worker.Name == "" {
worker.Name = crackTopShortID(hostID)
}
if worker.State == "" {
if worker.Online {
worker.State = "ONLINE"
} else {
worker.State = "OFFLINE"
}
}
if crackTopStationExpectsTelemetry(station) {
dashboard.ExpectedWorkers++
}
if hasSample {
if worker.JobID == "" && !worker.Online {
worker.JobID = sample.jobID
}
worker.LastSeen = sample.lastSeen
if crackTopTaskSampleIsLive(sample, station, snapshot.RefreshedAt) {
worker.TelemetryLive = true
worker.Rate = sample.rate
worker.RateKnown = sample.rateKnown
worker.Devices = sample.devices
worker.Temperature = sample.temperature
worker.HasTemp = sample.hasTemp
worker.Utilization = sample.utilization
worker.HasUtil = sample.hasUtil
}
}
if !worker.Online {
dashboard.Unavailable++
}
if worker.RateKnown {
dashboard.ClusterRate = saturatingUint64Add(dashboard.ClusterRate, worker.Rate)
dashboard.ClusterRateKnown = true
dashboard.ReportingWorkers++
}
dashboard.Workers = append(dashboard.Workers, worker)
}
dashboard.ClusterComplete = dashboard.ReportingWorkers == dashboard.ExpectedWorkers
dashboard.ClusterRateKnown = dashboard.ReportingWorkers > 0 || dashboard.ClusterComplete
sort.SliceStable(dashboard.Workers, func(i, j int) bool {
left, right := dashboard.Workers[i], dashboard.Workers[j]
if left.Online != right.Online {
return left.Online
}
if left.RateKnown != right.RateKnown {
return left.RateKnown
}
if left.Rate != right.Rate {
return left.Rate > right.Rate
}
leftName := strings.ToLower(left.Name)
rightName := strings.ToLower(right.Name)
if leftName != rightName {
return leftName < rightName
}
return left.ID < right.ID
})
return dashboard
}
//nolint:gocyclo // Job telemetry combines task state, liveness, progress, and device metrics in one aggregation pass.
func crackTopJobTelemetry(
job *clientpb.CrackJob,
stations map[string]*clientpb.Crackstation,
now time.Time,
taskTelemetry map[string]crackTopTaskTelemetry,
) (crackTopJobRow, map[string]crackTopTaskSample, *crackTopProgressAccumulator) {
row := crackTopJobRow{
ID: strings.TrimSpace(job.GetID()),
Status: job.GetStatus(),
CreatedAt: crackTopParseTime(job.GetCreatedAt()),
UpdatedAt: crackTopParseUnix(job.GetUpdatedAt()),
Results: job.GetResultCount(),
Mode: crackTopJobMode(job),
}
progress := newCrackTopProgressAccumulator()
samples := map[string]crackTopTaskSample{}
workers := map[string]struct{}{}
workTasks := 0
knownProgressTasks := 0
allProgressWeightsKnown := true
for _, task := range crackJobTasks(job) {
if !crackTopCrackTask(task, taskTelemetry) {
continue
}
workTasks++
hostID := strings.TrimSpace(task.GetHostUUID())
if hostID != "" {
workers[hostID] = struct{}{}
}
status, statusErr := crackTaskStatus(task)
progressExact := task.GetShardSkip() == 0
if metadata, ok := taskTelemetry[strings.TrimSpace(task.GetID())]; ok {
status = metadata.status
statusErr = nil
progressExact = metadata.progressExact || task.GetShardSkip() == 0
if metadata.parseErr {
statusErr = errors.New("crack status telemetry could not be parsed")
}
}
if statusErr != nil {
row.TelemetryErrors++
}
fraction, weight, known := crackTopTaskProgress(task, status, progressExact)
if known {
progress.add(fraction, weight)
knownProgressTasks++
} else if weight := crackTopAssignedShardWeight(task); weight != nil {
// Unknown work may already be complete, so zero is only a lower
// bound. Including its weight prevents known small shards from
// overstating the job's completion percentage.
progress.add(new(big.Rat), weight)
} else {
allProgressWeightsKnown = false
}
sample := crackTopTaskSample{
jobID: row.ID,
task: task,
status: status,
statusErr: statusErr,
lastSeen: crackTopTaskTime(task),
}
if status != nil {
sample.rate, sample.rateKnown = crackTopStatusRate(status)
sample.devices = len(status.Devices)
sample.temperature, sample.hasTemp = crackTopStatusMaximum(status, func(device crackDeviceView) string { return device.Temperature })
sample.utilization, sample.hasUtil = crackTopStatusMaximum(status, func(device crackDeviceView) string { return device.Utilization })
}
if hostID != "" {
if current, ok := samples[hostID]; !ok || crackTopSamplePreferred(sample, current, stations[hostID], now) {
samples[hostID] = sample
}
}
}
row.Workers = len(workers)
row.Progress, row.ProgressKnown = progress.value()
row.ProgressKnown = row.ProgressKnown && workTasks > 0 && allProgressWeightsKnown
row.ProgressComplete = row.ProgressKnown && knownProgressTasks == workTasks
if job.GetStatus() == clientpb.CrackJobStatus_COMPLETED {
row.Progress = 1
row.ProgressKnown = true
row.ProgressComplete = true
}
if job.GetStatus() == clientpb.CrackJobStatus_IN_PROGRESS {
for hostID, sample := range samples {
if crackTopTaskSampleIsLive(sample, stations[hostID], now) && sample.rateKnown {
row.Rate = saturatingUint64Add(row.Rate, sample.rate)
row.RateKnown = true
row.Reporting++
}
}
}
return row, samples, progress
}
func crackTopTaskProgress(task *clientpb.CrackTask, status *crackStatusView, progressExact bool) (*big.Rat, *big.Rat, bool) {
if task == nil {
return nil, nil, false
}
weight := crackTopAssignedShardWeight(task)
if weight == nil {
weight = new(big.Rat)
}
state := task.GetState()
if state == clientpb.CrackTaskState_CRACK_TASK_COMPLETED {
if weight.Sign() <= 0 && status != nil {
_, total, ok := crackTopProgressPair(status)
if ok {
weight.Set(total)
}
}
if weight.Sign() > 0 {
return big.NewRat(1, 1), weight, true
}
}
current, total, ok := crackTopProgressPair(status)
if ok && (task.GetShardSkip() == 0 || progressExact) {
fraction := new(big.Rat).Quo(current, total)
if weight.Sign() <= 0 {
weight.Set(total)
}
return fraction, weight, weight.Sign() > 0
}
if weight.Sign() > 0 && (state == clientpb.CrackTaskState_CRACK_TASK_QUEUED || state == clientpb.CrackTaskState_CRACK_TASK_LEASED) {
return new(big.Rat), weight, true
}
return nil, nil, false
}
func crackTopAssignedShardWeight(task *clientpb.CrackTask) *big.Rat {
if task == nil || task.GetShardLimit() == 0 {
return nil
}
return new(big.Rat).SetInt(new(big.Int).SetUint64(task.GetShardLimit()))
}
func crackTopProgressPair(status *crackStatusView) (*big.Rat, *big.Rat, bool) {
if status == nil {
return nil, nil, false
}
current, currentOK := crackTopRat(status.ProgressCurrent)
total, totalOK := crackTopRat(status.ProgressTotal)
if !currentOK || !totalOK || total.Sign() <= 0 {
return nil, nil, false
}
return current, total, true
}
func crackTopRat(value string) (*big.Rat, bool) {
value = strings.TrimSpace(value)
if value == "" || len(value) > crackTopMaxProgressTextSize {
return nil, false
}
for _, character := range value {
if character < '0' || character > '9' {
return nil, false
}
}
result := new(big.Rat)
if _, ok := result.SetString(value); !ok {
return nil, false
}
return result, true
}
func crackTopStatusRate(status *crackStatusView) (uint64, bool) {
if status == nil {
return 0, false
}
var total uint64
found := false
for _, device := range status.Devices {
rate, err := strconv.ParseUint(strings.TrimSpace(device.Speed), 10, 64)
if err != nil {
continue
}
found = true
total = saturatingUint64Add(total, rate)
}
return total, found
}
func crackTopStatusMaximum(status *crackStatusView, value func(crackDeviceView) string) (float64, bool) {
if status == nil || value == nil {
return 0, false
}
maximum := 0.0
found := false
for _, device := range status.Devices {
number, err := strconv.ParseFloat(strings.TrimSpace(value(device)), 64)
if err != nil || number < 0 || math.IsNaN(number) || math.IsInf(number, 0) {
continue
}
if !found || number > maximum {
maximum = number
found = true
}
}
return maximum, found
}
func crackTopTaskSampleIsLive(sample crackTopTaskSample, station *clientpb.Crackstation, now time.Time) bool {
if station == nil || sample.task == nil || sample.statusErr != nil || sample.task.GetState() != clientpb.CrackTaskState_CRACK_TASK_RUNNING {
return false
}
leaseExpiresAt := sample.task.GetLeaseExpiresAt()
if now.IsZero() || leaseExpiresAt <= now.Unix() {
return false
}
status := station.GetStatus()
if status == nil {
return false
}
if status.GetIsSyncing() || status.GetState() != clientpb.States_CRACKING {
return false
}
currentJobID := strings.TrimSpace(status.GetCurrentCrackJobID())
return currentJobID != "" && currentJobID == sample.jobID
}
func crackTopStationExpectsTelemetry(station *clientpb.Crackstation) bool {
if station == nil {
return false
}
status := station.GetStatus()
if status == nil {
// The station is connected, but without status we cannot prove it is
// idle. Keep the cluster total unknown instead of reporting a false zero.
return true
}
if status.GetIsSyncing() {
return false
}
return status.GetState() == clientpb.States_CRACKING
}
func crackTopSamplePreferred(left, right crackTopTaskSample, station *clientpb.Crackstation, now time.Time) bool {
leftLive := crackTopTaskSampleIsLive(left, station, now)
rightLive := crackTopTaskSampleIsLive(right, station, now)
if leftLive != rightLive {
return leftLive
}
return crackTopSampleNewer(left, right)
}
func crackTopSampleNewer(left, right crackTopTaskSample) bool {
if !left.lastSeen.Equal(right.lastSeen) {
return left.lastSeen.After(right.lastSeen)
}
leftRunning := left.task != nil && left.task.GetState() == clientpb.CrackTaskState_CRACK_TASK_RUNNING
rightRunning := right.task != nil && right.task.GetState() == clientpb.CrackTaskState_CRACK_TASK_RUNNING
if leftRunning != rightRunning {
return leftRunning
}
if left.task == nil || right.task == nil {
return left.task != nil
}
return left.task.GetAttempt() > right.task.GetAttempt()
}
func crackTopCrackTask(task *clientpb.CrackTask, taskTelemetry map[string]crackTopTaskTelemetry) bool {
if task == nil {
return false
}
switch task.GetKind() {
case clientpb.CrackTaskKind_CRACK_TASK_CRACK:
return true
case clientpb.CrackTaskKind_CRACK_TASK_UNSPECIFIED:
if task.GetShardLimit() != 0 || len(task.GetLatestStatusJSON()) != 0 {
return true
}
metadata, ok := taskTelemetry[strings.TrimSpace(task.GetID())]
return ok && (metadata.status != nil || metadata.parseErr)
default:
return false
}
}
func crackTopStationID(station *clientpb.Crackstation) string {
if station == nil {
return ""
}
if hostID := strings.TrimSpace(station.GetHostUUID()); hostID != "" {
return hostID
}
if status := station.GetStatus(); status != nil {
if hostID := strings.TrimSpace(status.GetHostUUID()); hostID != "" {
return hostID
}
}
return strings.TrimSpace(station.GetID())
}
func crackTopTaskTime(task *clientpb.CrackTask) time.Time {
if task == nil {
return time.Time{}
}
for _, value := range []int64{task.GetLastHeartbeatAt(), task.GetUpdatedAt(), task.GetStartedAt(), task.GetCreatedAt()} {
if value > 0 {
return time.Unix(value, 0)
}
}
return time.Time{}
}
func crackTopParseTime(value string) time.Time {
parsed, _ := time.Parse(time.RFC3339, strings.TrimSpace(value))
return parsed
}
func crackTopParseUnix(value int64) time.Time {
if value <= 0 {
return time.Time{}
}
return time.Unix(value, 0)
}
func crackTopJobMode(job *clientpb.CrackJob) string {
if job == nil || job.GetCommand() == nil {
return ""
}
mode := int32(job.GetCommand().GetHashType())
if job.GetCommand().HashMode != nil {
mode = int32(job.GetCommand().GetHashMode())
}
if name, ok := hashcatHashTypeName(mode); ok {
return name
}
return "mode " + strconv.FormatInt(int64(mode), 10)
}
func crackTopShortID(value string) string {
value = crackTopSafeCell(value)
if value == "" {
return "unknown"
}
if index := strings.IndexByte(value, '-'); index > 0 {
value = value[:index]
}
runes := []rune(value)
if len(runes) > 8 {
value = string(runes[:8])
}
return value
}
func crackTopSafeCell(value string) string {
value = ansi.Strip(value)
value = strings.Map(func(r rune) rune {
if !unicode.IsPrint(r) {
return -1
}
if unicode.IsSpace(r) && r != ' ' {
return ' '
}
return r
}, value)
value = strings.TrimSpace(value)
const maximumRunes = 96
runes := []rune(value)
if len(runes) > maximumRunes {
value = string(runes[:maximumRunes-1]) + "…"
}
return value
}
func saturatingUint64Add(left, right uint64) uint64 {
if math.MaxUint64-left < right {
return math.MaxUint64
}
return left + right
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+598 -139
View File
@@ -1,143 +1,602 @@
package creds
// List of all available hash-types, for completions.
// Unfortunately this list has to be maintained manually,
// until someone devises a way to do so automatically.
// hashTypes maps protobuf enum identifiers to the exact human-readable
// names reported by the bundled Hashcat v7.1.2-armory.1 modules at commit
// deadd52de22e2753e9caaff6d1b92e5a22a35626.
var hashTypes = map[string]string{
"MD5": "MD5", // Enum 0 in proto file.
"MD4": "MD4",
"SHA1": "SHA1",
"SHA2_224": "SHA2-224",
"SHA2_256": "SHA2-256",
"SHA2_384": "SHA2-384",
"SHA2_512": "SHA2-512",
"SHA3_224": "SHA3-224",
"SHA3_256": "SHA3-256",
"SHA3_384": "SHA3-384",
"SHA3_512": "SHA3-512",
"RIPEMD_160": "RIPEMD-160",
"BLAKE2B_256": "BLAKE2b-512",
"GOST_R_32_11_2012_256": "GOST R 34.11-2012 (Streebog) 256-bit, big-endian",
"GOST_R_32_11_2012_512": "GOST R 34.11-2012 (Streebog) 512-bit, big-endian",
"GOST_R_34_11_94": "GOST R 34.11-94",
"GPG": "GPG (AES-128/AES-256 (SHA-1($pass)))",
"HALF_MD5": "Half MD5",
"KECCAK_224": "Keccak-224",
"KECCAK_256": "Keccak-256",
"KECCAK_384": "Keccak-384",
"KECCAK_512": "Keccak-512",
"WHIRLPOOL": "Whirlpool",
"SIPHASH": "SipHash",
"MD5_UTF16LE": "md5(utf16le($pass))",
"SHA1_UTF16LE": "sha1(utf16le($pass))",
"SHA256_UTF16LE": "sha256(utf16le($pass))",
"SHA384_UTF16LE": "sha384(utf16le($pass))",
"SHA512_UTF16LE": "sha512(utf16le($pass))",
"BLAKE2B_512_PW_SALT": "BLAKE2b-512($pass.$salt)",
"BLAKE2B_512_SALT_PW": "BLAKE2b-512($salt.$pass)",
"MD5_PW_SALT": "md5($pass.$salt)",
"MD5_SALT_PW": "md5($salt.$pass)",
"MD5_SALT_PW_SALT": "md5($salt.$pass.$salt)",
"MD5_SALT_MD5_PW": "md5($salt.md5($pass))",
// ... TODO fill in the rest ...,
"CRC32": "CRC32",
"CRC32C": "CRC32B",
"CRC64Jones": "CRC64-Jones",
"JAVA_OBJECT": "Java object",
"MURMUR": "MurmurHash",
"MURMUR3": "MurmurHash3",
"THREE_DES": "3DES (PT = $salt, key = $pass)",
"DES": "DES (PT = $salt, key = $pass)",
"AES_128_ECB": "AES-128-ECB NOKDF (PT = $salt, key = $pass)",
"AES_192_ECB": "AES-192-ECB NOKDF (PT = $salt, key = $pass)",
"AES_256_ECB": "AES-256-ECB NOKDF (PT = $salt, key = $pass)",
"CHA_CHA_20": "ChaCha20",
"LINUX_KERNEL_CRYPTO_API_24": "Linux Kernel Crypto API (2.4)",
"SKIP_32": "Skip32 (PT = $salt, key = $pass)",
"PBKDF2_HMAC_MD5": "PBKDF2-HMAC-MD5",
"PBKDF2_HMAC_SHA1": "PBKDF2-HMAC-SHA1",
"PBKDF2_HMAC_SHA256": "PBKDF2-HMAC-SHA256",
"PBKDF2_HMAC_SHA512": "PBKDF2-HMAC-SHA512",
"SCRYPT": "scrypt",
"PHPASS": "phpass",
"TACACS_PLUS": "TACACS+",
"SIP_DIGEST": "SIP digest authentication (MD5)",
"IKE_MD5": "IKE-PSK MD5",
"IKE_SHA1": "IKE-PSK SHA1",
"SNMP_V3_HMAC_MD5_96": "SNMPv3 HMAC-MD5-96",
"SNMP_V3_HMAC_MD5_96__SHA1_96": "SNMPv3 HMAC-MD5-96 / HMAC-SHA1-96",
"SNMP_V3_HMAC_SHA1_96": "SNMPv3 HMAC-SHA1-96",
"SNMP_V3_HMAC_SHA224_128": "SNMPv3 HMAC-SHA224-128",
"SNMP_V3_HMAC_SHA256_192": "SNMPv3 HMAC-SHA256-192",
"SNMP_V3_HMAC_SHA384_256": "SNMPv3 HMAC-SHA384-256",
"SNMP_V3_HMAC_SHA512_384": "SNMPv3 HMAC-SHA512-384",
"WPA_EAPOL_PBKDF2": "WPA-EAPOL-PBKDF2",
"WPA_EAPOL_PMK": "WPA-EAPOL-PMK",
"WPA_PBKDF2_PMKID_EAPOL": "WPA-PBKDF2-PMKID+EAPOL",
"WPA_PMK_PMKID_EAPOL": "WPA-PMK-PMKID+EAPOL",
"WPA_PMKID_PBKDF2": "WPA-PMKID-PBKDF2",
"WPA_PMKID_PMK": "WPA-PMKID-PMK",
"IPMI2_PAKP_HMAC_SHA1": "IPMI2 RAKP HMAC-SHA1",
"CRAM_MD5": "CRAM-MD5",
"JWT": "JWT (JSON Web Token)",
"RADMIN_3": "Radmin3",
"KERBEROS_17_TGS_REP": "Kerberos 5, etype 17, TGS-REP",
"KERBEROS_17_PREAUTH": "Kerberos 5, etype 17, Pre-Auth",
"KERBEROS_17_DB": "Kerberos 5, etype 17, DB",
"KERBEROS_18_TGS_REP": "Kerberos 5, etype 18, TGS-REP",
"KERBEROS_18_PREAUTH": "Kerberos 5, etype 18, Pre-Auth",
"KERBEROS_18_DB": "Kerberos 5, etype 18, DB",
"KERBEROS_23_SA_REQ_PREAUTH": "Kerberos 5, etype 23, AS-REQ Pre-Auth",
"KERBEROS_23_TGS_REP": "Kerberos 5, etype 23, TGS-REP",
"KERBEROS_23_AS_REP": "Kerberos 5, etype 23, AS-REP",
"NET_NTLM_V1": "NetNTLMv1 / NetNTLMv1+ESS",
"NET_NTLM_V1_NT": "NetNTLMv1 / NetNTLMv1+ESS (NT)",
"NET_NTLM_V2": "NetNTLMv2",
"NET_NTLM_V2_NT": "NetNTLMv2 (NT)",
"FLASK": "Flask Session Cookie ($salt.$salt.$pass)",
"ISCSI_CHAP": "iSCSI CHAP authentication, MD5(CHAP)",
"RACF": "Resource Access Control Facility",
"AIX_SMD5": "AIX MD5",
"AIX_SSHA1": "AIX SHA-1",
"AIX_SSHA256": "AIX SHA-256",
"AIX_SSHA512": "AIX SHA-512",
"LM": "LM",
"QNX_MD5": "QNX /etc/shadow (MD5)",
"QNX_SHA256": "QNX /etc/shadow (SHA256)",
"QNX_SHA512": "QNX /etc/shadow (SHA512)",
"DPAPI_V1_CTX_1_AND_2": "DPAPI masterkey file v1 (context 1 and 2)",
"DPAPI_V1_CTX_3": "DPAPI masterkey file v1 (context 3)",
"DPAPI_V2_CTX_1_AND_2": "DPAPI masterkey file v2 (context 1)",
"DPAPI_V2_CTX_3": "DPAPI masterkey file v2 (context 3)",
"GRUB_2": "GRUB 2",
"MS_AZURE_SYNC": "MS-AzureSync PBKDF2-HMAC-SHA256",
"BSDI_CRYPT": "BSDi Crypt, Extended DES",
"NTLM": "NTLM",
"RADMIN2": "Radmin2",
"SAMSUNG_ANDROID": "Samsung Android Password/PIN",
"WINDOWS_HELLO_PIN": "Windows Hello PIN/Password",
"WINDOWS_PHONE": "Windows Phone 8+ PIN/Password",
"CISCO_ASA_MD5": "Cisco-ASA MD5",
"CISCO_IOS_PBKDF2_SHA256": "Cisco-IOS $8$ (PBKDF2-SHA256)",
"CISCO_IOS_SCRYPT": "Cisco-IOS $9$ (scrypt)",
"CISCO_PIX_MD5": "Cisco-Pix MD5",
"CITRIX_NETSCALER_SHA1": "Citrix NetScaler (SHA1)",
"CITRIX_NETSCALER_SHA512": "Citrix NetScaler (SHA512)",
"DCC": "Domain Cached Credentials (DCC), MS Cache",
"DCC2": "Domain Cached Credentials 2 (DCC2), MS Cache 2",
// ...
"MACOS_10_8": "macOS v10.8+ (PBKDF2-SHA512)",
// ...
// INVALID = 9999; // Invalid hash type
// Out of order
"BCRYPT_UNIX": "bcrypt",
"SHA512_CRYPT_UNIX": "sha512crypt $6$, SHA512 (Unix)",
"MD5": "MD5",
"MD5_PW_SALT": "md5($pass.$salt)",
"JOOMLA_2_5_18": "Joomla < 2.5.18",
"POSTGRESQL": "PostgreSQL",
"MD5_SALT_PW": "md5($salt.$pass)",
"OSCOMMERCE_XT_COMMERCE": "osCommerce, xt:Commerce",
"JUNIPER_NETSCREEN_SSG_SCREENOS": "Juniper NetScreen/SSG (ScreenOS)",
"SKYPE": "Skype",
"SOLARWINDS_SERV_U": "SolarWinds Serv-U",
"MD5_UTF16LE_PASS_SALT": "md5(utf16le($pass).$salt)",
"MD5_SALT_UTF16LE_PASS": "md5($salt.utf16le($pass))",
"HMAC_MD5_KEY_PASS": "HMAC-MD5 (key = $pass)",
"HMAC_MD5_KEY_SALT": "HMAC-MD5 (key = $salt)",
"MD5_UTF16LE": "md5(utf16le($pass))",
"SHA1": "SHA1",
"NSLDAP_SHA_1_BASE64_NETSCAPE_LDAP_SHA": "nsldap, SHA-1(Base64), Netscape LDAP SHA",
"SHA1_PASS_SALT": "sha1($pass.$salt)",
"NSLDAPS_SSHA_1_BASE64_NETSCAPE_LDAP_SSHA": "nsldaps, SSHA-1(Base64), Netscape LDAP SSHA",
"ORACLE_S_TYPE_ORACLE_11": "Oracle S: Type (Oracle 11+)",
"SHA1_SALT_PASS": "sha1($salt.$pass)",
"SMF_SIMPLE_MACHINES_FORUM_V1_1": "SMF (Simple Machines Forum) > v1.1",
"MACOS_V10_4_MACOS_V10_5_MACOS_V10_6": "macOS v10.4, macOS v10.5, macOS v10.6",
"DJANGO_SHA_1": "Django (SHA-1)",
"ARUBAOS": "ArubaOS",
"SHA1_UTF16LE_PASS_SALT": "sha1(utf16le($pass).$salt)",
"MSSQL_2000": "MSSQL (2000)",
"MSSQL_2005": "MSSQL (2005)",
"PEOPLESOFT": "PeopleSoft",
"SHA1_SALT_UTF16LE_PASS": "sha1($salt.utf16le($pass))",
"EPISERVER_6_X_NET_4_MODE_141": "Episerver 6.x < .NET 4",
"HMAC_SHA1_KEY_PASS": "HMAC-SHA1 (key = $pass)",
"HMAC_SHA1_KEY_SALT": "HMAC-SHA1 (key = $salt)",
"SHA1_UTF16LE": "sha1(utf16le($pass))",
"MYSQL323": "MySQL323",
"MYSQL4_1_MYSQL5": "MySQL4.1/MySQL5",
"PHPASS": "phpass",
"MD5CRYPT_MD5_UNIX_CISCO_IOS_1_MD5": "md5crypt, MD5 (Unix), Cisco-IOS $1$ (MD5)",
"JUNIPER_IVE": "Juniper IVE",
"BLAKE2B_256": "BLAKE2b-512",
"BLAKE2B_512_PW_SALT": "BLAKE2b-512($pass.$salt)",
"BLAKE2B_512_SALT_PW": "BLAKE2b-512($salt.$pass)",
"MD4": "MD4",
"NTLM": "NTLM",
"DCC": "Domain Cached Credentials (DCC), MS Cache",
"SHA2_224": "SHA2-224",
"SHA224_PASS_SALT": "sha224($pass.$salt)",
"SHA224_SALT_PASS": "sha224($salt.$pass)",
"SHA2_256": "SHA2-256",
"SHA256_PASS_SALT": "sha256($pass.$salt)",
"SSHA_256_BASE64_LDAP_SSHA256": "SSHA-256(Base64), LDAP {SSHA256}",
"SHA256_SALT_PASS": "sha256($salt.$pass)",
"HMAILSERVER": "hMailServer",
"SHA256_UTF16LE_PASS_SALT": "sha256(utf16le($pass).$salt)",
"SHA256_SALT_UTF16LE_PASS": "sha256($salt.utf16le($pass))",
"EPISERVER_6_X_NET_4_MODE_1441": "Episerver 6.x >= .NET 4",
"HMAC_SHA256_KEY_PASS": "HMAC-SHA256 (key = $pass)",
"HMAC_SHA256_KEY_SALT": "HMAC-SHA256 (key = $salt)",
"SHA256_UTF16LE": "sha256(utf16le($pass))",
"DESCRYPT_DES_UNIX_TRADITIONAL_DES": "descrypt, DES (Unix), Traditional DES",
"APACHE_APR1_MD5_MD5APR1_MD5_APR": "Apache $apr1$ MD5, md5apr1, MD5 (APR)",
"SHA2_512": "SHA2-512",
"SHA512_PASS_SALT": "sha512($pass.$salt)",
"SSHA_512_BASE64_LDAP_SSHA512": "SSHA-512(Base64), LDAP {SSHA512}",
"SHA512_SALT_PASS": "sha512($salt.$pass)",
"MACOS_V10_7": "macOS v10.7",
"SHA512_UTF16LE_PASS_SALT": "sha512(utf16le($pass).$salt)",
"MSSQL_2012_2014": "MSSQL (2012, 2014)",
"SHA512_SALT_UTF16LE_PASS": "sha512($salt.utf16le($pass))",
"HMAC_SHA512_KEY_PASS": "HMAC-SHA512 (key = $pass)",
"HMAC_SHA512_KEY_SALT": "HMAC-SHA512 (key = $salt)",
"SHA512_UTF16LE": "sha512(utf16le($pass))",
"SHA512_CRYPT_UNIX": "sha512crypt $6$, SHA512 (Unix)",
"STDOUT": "STDOUT",
"DCC2": "Domain Cached Credentials 2 (DCC2), MS Cache 2",
"CISCO_PIX_MD5": "Cisco-PIX MD5",
"CISCO_ASA_MD5": "Cisco-ASA MD5",
"WPA_EAPOL_PBKDF2": "WPA-EAPOL-PBKDF2",
"WPA_EAPOL_PMK": "WPA-EAPOL-PMK",
"MD5_MD5_PASS": "md5(md5($pass))",
"VBULLETIN_V3_8_5_MODE_2611": "vBulletin < v3.8.5",
"PHPS": "PHPS",
"MD5_MD5_PASS_SALT": "md5(md5($pass.$salt))",
"VBULLETIN_V3_8_5_MODE_2711": "vBulletin >= v3.8.5",
"MYBB_1_2_IPB2_INVISION_POWER_BOARD": "MyBB 1.2+, IPB2+ (Invision Power Board)",
"LM": "LM",
"ORACLE_H_TYPE_ORACLE_7": "Oracle H: Type (Oracle 7+)",
"BCRYPT_UNIX": "bcrypt $2*$, Blowfish (Unix)",
"MD5_MD5_MD5_PASS": "md5(md5(md5($pass)))",
"MD5_MD5_MD5_PASS_SALT": "md5(md5(md5($pass)).$salt)",
"MD5_SALT_MD5_PW": "md5($salt.md5($pass))",
"MEDIAWIKI_B_TYPE": "MediaWiki B type",
"MD5_SALT1_STRTOUPPER_MD5_SALT2_PASS": "md5($salt1.strtoupper(md5($salt2.$pass)))",
"MD5_SALT_PW_SALT": "md5($salt.$pass.$salt)",
"MD5_MD5_PASS_MD5_SALT": "md5(md5($pass).md5($salt))",
"MD5_SALT_MD5_SALT_PASS": "md5($salt.md5($salt.$pass))",
"MD5_SALT_MD5_PASS_SALT_MODE_4110": "md5($salt.md5($pass.$salt))",
"MD5_STRTOUPPER_MD5_PASS": "md5(strtoupper(md5($pass)))",
"MD5_SHA1_PASS": "md5(sha1($pass))",
"MD5_SHA1_PASS_SALT_MODE_4410": "md5(sha1($pass).$salt)",
"MD5_SHA1_PASS_SALT_MODE_4420": "md5(sha1($pass.$salt))",
"MD5_SHA1_SALT_PASS": "md5(sha1($salt.$pass))",
"SHA1_SHA1_PASS": "sha1(sha1($pass))",
"SHA1_SHA1_PASS_SALT": "sha1(sha1($pass).$salt)",
"SHA1_SALT_SHA1_PASS": "sha1($salt.sha1($pass))",
"REDMINE": "Redmine",
"PUNBB": "PunBB",
"SHA1_MD5_PASS": "sha1(md5($pass))",
"SHA1_MD5_PASS_SALT_MODE_4710": "sha1(md5($pass).$salt)",
"HUAWEI_SHA1_MD5_PASS_SALT": "Huawei sha1(md5($pass).$salt)",
"ISCSI_CHAP": "iSCSI CHAP authentication, MD5(CHAP)",
"SHA1_SALT_PASS_SALT": "sha1($salt.$pass.$salt)",
"SHA1_SHA1_SALT_PASS_SALT": "sha1(sha1($salt.$pass.$salt))",
"HALF_MD5": "Half MD5",
"PASSWORD_SAFE_V3": "Password Safe v3",
"IKE_MD5": "IKE-PSK MD5",
"IKE_SHA1": "IKE-PSK SHA1",
"NET_NTLM_V1": "NetNTLMv1 / NetNTLMv1+ESS",
"NET_NTLM_V2": "NetNTLMv2",
"CISCO_IOS_TYPE_4_SHA256": "Cisco-IOS type 4 (SHA256)",
"CISCO_ISE_HASHED_PASSWORD_SHA256": "Cisco-ISE Hashed Password (SHA256)",
"SAMSUNG_ANDROID": "Samsung Android Password/PIN",
"RIPEMD_160": "RIPEMD-160",
"HMAC_RIPEMD160_KEY_PASS": "HMAC-RIPEMD160 (key = $pass)",
"HMAC_RIPEMD160_KEY_SALT": "HMAC-RIPEMD160 (key = $salt)",
"WHIRLPOOL": "Whirlpool",
"TRUECRYPT_RIPEMD160_XTS_512_BIT_LEGACY": "TrueCrypt RIPEMD160 + XTS 512 bit (legacy)",
"TRUECRYPT_RIPEMD160_XTS_1024_BIT_LEGACY": "TrueCrypt RIPEMD160 + XTS 1024 bit (legacy)",
"TRUECRYPT_RIPEMD160_XTS_1536_BIT_LEGACY": "TrueCrypt RIPEMD160 + XTS 1536 bit (legacy)",
"TRUECRYPT_SHA512_XTS_512_BIT_LEGACY": "TrueCrypt SHA512 + XTS 512 bit (legacy)",
"TRUECRYPT_SHA512_XTS_1024_BIT_LEGACY": "TrueCrypt SHA512 + XTS 1024 bit (legacy)",
"TRUECRYPT_SHA512_XTS_1536_BIT_LEGACY": "TrueCrypt SHA512 + XTS 1536 bit (legacy)",
"TRUECRYPT_WHIRLPOOL_XTS_512_BIT_LEGACY": "TrueCrypt Whirlpool + XTS 512 bit (legacy)",
"TRUECRYPT_WHIRLPOOL_XTS_1024_BIT_LEGACY": "TrueCrypt Whirlpool + XTS 1024 bit (legacy)",
"TRUECRYPT_WHIRLPOOL_XTS_1536_BIT_LEGACY": "TrueCrypt Whirlpool + XTS 1536 bit (legacy)",
"TRUECRYPT_RIPEMD160_XTS_512_BIT_BOOT_MODE_LEGACY": "TrueCrypt RIPEMD160 + XTS 512 bit + boot-mode (legacy)",
"TRUECRYPT_RIPEMD160_XTS_1024_BIT_BOOT_MODE_LEGACY": "TrueCrypt RIPEMD160 + XTS 1024 bit + boot-mode (legacy)",
"TRUECRYPT_RIPEMD160_XTS_1536_BIT_BOOT_MODE_LEGACY": "TrueCrypt RIPEMD160 + XTS 1536 bit + boot-mode (legacy)",
"AIX_SMD5": "AIX {smd5}",
"AIX_SSHA256": "AIX {ssha256}",
"AIX_SSHA512": "AIX {ssha512}",
"HASHCAT_1PASSWORD_AGILEKEYCHAIN": "1Password, agilekeychain",
"AIX_SSHA1": "AIX {ssha1}",
"LASTPASS_LASTPASS_SNIFFED": "LastPass + LastPass sniffed",
"GOST_R_34_11_94": "GOST R 34.11-94",
"FORTIGATE_FORTIOS": "FortiGate (FortiOS)",
"MACOS_10_8": "macOS v10.8+ (PBKDF2-SHA512)",
"GRUB_2": "GRUB 2",
"IPMI2_PAKP_HMAC_SHA1": "IPMI2 RAKP HMAC-SHA1",
"IPMI2_RAKP_HMAC_MD5": "IPMI2 RAKP HMAC-MD5",
"SHA256CRYPT_5_SHA256_UNIX": "sha256crypt $5$, SHA256 (Unix)",
"MYSQL_A_SHA256CRYPT": "MySQL $A$ (sha256crypt)",
"KERBEROS_23_SA_REQ_PREAUTH": "Kerberos 5, etype 23, AS-REQ Pre-Auth",
"SAP_CODVN_B_BCODE": "SAP CODVN B (BCODE)",
"SAP_CODVN_B_BCODE_FROM_RFC_READ_TABLE": "SAP CODVN B (BCODE) from RFC_READ_TABLE",
"SAP_CODVN_F_G_PASSCODE": "SAP CODVN F/G (PASSCODE)",
"SAP_CODVN_F_G_PASSCODE_FROM_RFC_READ_TABLE": "SAP CODVN F/G (PASSCODE) from RFC_READ_TABLE",
"DRUPAL7": "Drupal7",
"SYBASE_ASE": "Sybase ASE",
"CITRIX_NETSCALER_SHA1": "Citrix NetScaler (SHA1)",
"HASHCAT_1PASSWORD_CLOUDKEYCHAIN": "1Password, cloudkeychain",
"DNSSEC_NSEC3": "DNSSEC (NSEC3)",
"WBB3_WOLTLAB_BURNING_BOARD": "WBB3 (Woltlab Burning Board)",
"RACF": "RACF",
"AS_400_DES": "AS/400 DES",
"LOTUS_NOTES_DOMINO_5": "Lotus Notes/Domino 5",
"LOTUS_NOTES_DOMINO_6": "Lotus Notes/Domino 6",
"ANDROID_FDE_4_3": "Android FDE <= 4.3",
"SCRYPT": "scrypt",
"PASSWORD_SAFE_V2": "Password Safe v2",
"LOTUS_NOTES_DOMINO_8": "Lotus Notes/Domino 8",
"CISCO_IOS_PBKDF2_SHA256": "Cisco-IOS $8$ (PBKDF2-SHA256)",
"CISCO_IOS_SCRYPT": "Cisco-IOS $9$ (scrypt)",
"MS_OFFICE_2007": "MS Office 2007",
"MS_OFFICE_2010": "MS Office 2010",
"MS_OFFICE_2013": "MS Office 2013",
"MS_OFFICE_2003_0_1_MD5_RC4": "MS Office <= 2003 $0/$1, MD5 + RC4",
"MS_OFFICE_2003_0_1_MD5_RC4_COLLIDER_1": "MS Office <= 2003 $0/$1, MD5 + RC4, collider #1",
"MS_OFFICE_2003_0_1_MD5_RC4_COLLIDER_2": "MS Office <= 2003 $0/$1, MD5 + RC4, collider #2",
"MS_OFFICE_2003_3_4_SHA1_RC4": "MS Office <= 2003 $3/$4, SHA1 + RC4",
"MS_OFFICE_2003_3_SHA1_RC4_COLLIDER_1": "MS Office <= 2003 $3, SHA1 + RC4, collider #1",
"MS_OFFICE_2003_3_SHA1_RC4_COLLIDER_2": "MS Office <= 2003 $3, SHA1 + RC4, collider #2",
"RADMIN2": "Radmin2",
"DJANGO_PBKDF2_SHA256": "Django (PBKDF2-SHA256)",
"SIPHASH": "SipHash",
"CRAM_MD5": "CRAM-MD5",
"SAP_CODVN_H_PWDSALTEDHASH_ISSHA_1": "SAP CODVN H (PWDSALTEDHASH) iSSHA-1",
"PDF_1_1_1_3_ACROBAT_2_4": "PDF 1.1 - 1.3 (Acrobat 2 - 4)",
"PDF_1_1_1_3_ACROBAT_2_4_COLLIDER_1": "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #1",
"PDF_1_1_1_3_ACROBAT_2_4_COLLIDER_2": "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #2",
"PDF_1_4_1_6_ACROBAT_5_8": "PDF 1.4 - 1.6 (Acrobat 5 - 8)",
"PDF_1_3_1_6_ACROBAT_4_8_W_RC4_40": "PDF 1.3 - 1.6 (Acrobat 4 - 8) w/ RC4-40",
"PDF_1_7_LEVEL_3_ACROBAT_9": "PDF 1.7 Level 3 (Acrobat 9)",
"PDF_1_7_LEVEL_8_ACROBAT_10_11": "PDF 1.7 Level 8 (Acrobat 10 - 11)",
"SHA2_384": "SHA2-384",
"SHA384_PASS_SALT": "sha384($pass.$salt)",
"SHA384_SALT_PASS": "sha384($salt.$pass)",
"SHA384_UTF16LE_PASS_SALT": "sha384(utf16le($pass).$salt)",
"SHA384_SALT_UTF16LE_PASS": "sha384($salt.utf16le($pass))",
"SHA384_UTF16LE": "sha384(utf16le($pass))",
"PBKDF2_HMAC_SHA256": "PBKDF2-HMAC-SHA256",
"REDHAT_389_DS_LDAP_PBKDF2_HMAC_SHA256": "RedHat 389-DS LDAP (PBKDF2-HMAC-SHA256)",
"PRESTASHOP": "PrestaShop",
"POSTGRESQL_CRAM_MD5": "PostgreSQL CRAM (MD5)",
"MYSQL_CRAM_SHA1": "MySQL CRAM (SHA1)",
"BITCOIN_LITECOIN_WALLET_DAT": "Bitcoin/Litecoin wallet.dat",
"SIP_DIGEST": "SIP digest authentication (MD5)",
"CRC32": "CRC32",
"HASHCAT_7_ZIP": "7-Zip",
"GOST_R_32_11_2012_256": "GOST R 34.11-2012 (Streebog) 256-bit, big-endian",
"HMAC_STREEBOG_256_KEY_PASS_BIG_ENDIAN": "HMAC-Streebog-256 (key = $pass), big-endian",
"HMAC_STREEBOG_256_KEY_SALT_BIG_ENDIAN": "HMAC-Streebog-256 (key = $salt), big-endian",
"GOST_R_32_11_2012_512": "GOST R 34.11-2012 (Streebog) 512-bit, big-endian",
"HMAC_STREEBOG_512_KEY_PASS_BIG_ENDIAN": "HMAC-Streebog-512 (key = $pass), big-endian",
"HMAC_STREEBOG_512_KEY_SALT_BIG_ENDIAN": "HMAC-Streebog-512 (key = $salt), big-endian",
"PBKDF2_HMAC_MD5": "PBKDF2-HMAC-MD5",
"PBKDF2_HMAC_SHA1": "PBKDF2-HMAC-SHA1",
"ATLASSIAN_PBKDF2_HMAC_SHA1": "Atlassian (PBKDF2-HMAC-SHA1)",
"PBKDF2_HMAC_SHA512": "PBKDF2-HMAC-SHA512",
"APACHE_SHIRO_1_SHA_512": "Apache Shiro 1 SHA-512",
"ECRYPTFS": "eCryptfs",
"ORACLE_T_TYPE_ORACLE_12": "Oracle T: Type (Oracle 12+)",
"BSDI_CRYPT": "BSDi Crypt, Extended DES",
"RAR3_HP": "RAR3-hp",
"COLDFUSION_10": "ColdFusion 10+",
"BLOCKCHAIN_MY_WALLET": "Blockchain, My Wallet",
"MS_AZURE_SYNC": "MS-AzureSync PBKDF2-HMAC-SHA256",
"ANDROID_FDE_SAMSUNG_DEK": "Android FDE (Samsung DEK)",
"RAR5": "RAR5",
"KERBEROS_23_TGS_REP": "Kerberos 5, etype 23, TGS-REP",
"AXCRYPT_1": "AxCrypt 1",
"AXCRYPT_1_IN_MEMORY_SHA1": "AxCrypt 1 in-memory SHA1",
"KEEPASS_KDBX_V2_V3": "KeePass (KDBX v2/v3)",
"PEOPLESOFT_PS_TOKEN": "PeopleSoft PS_TOKEN",
"WINZIP": "WinZip",
"VERACRYPT_RIPEMD160_XTS_512_BIT_LEGACY": "VeraCrypt RIPEMD160 + XTS 512 bit (legacy)",
"VERACRYPT_RIPEMD160_XTS_1024_BIT_LEGACY": "VeraCrypt RIPEMD160 + XTS 1024 bit (legacy)",
"VERACRYPT_RIPEMD160_XTS_1536_BIT_LEGACY": "VeraCrypt RIPEMD160 + XTS 1536 bit (legacy)",
"VERACRYPT_SHA512_XTS_512_BIT_LEGACY": "VeraCrypt SHA512 + XTS 512 bit (legacy)",
"VERACRYPT_SHA512_XTS_1024_BIT_LEGACY": "VeraCrypt SHA512 + XTS 1024 bit (legacy)",
"VERACRYPT_SHA512_XTS_1536_BIT_LEGACY": "VeraCrypt SHA512 + XTS 1536 bit (legacy)",
"VERACRYPT_WHIRLPOOL_XTS_512_BIT_LEGACY": "VeraCrypt Whirlpool + XTS 512 bit (legacy)",
"VERACRYPT_WHIRLPOOL_XTS_1024_BIT_LEGACY": "VeraCrypt Whirlpool + XTS 1024 bit (legacy)",
"VERACRYPT_WHIRLPOOL_XTS_1536_BIT_LEGACY": "VeraCrypt Whirlpool + XTS 1536 bit (legacy)",
"VERACRYPT_RIPEMD160_XTS_512_BIT_BOOT_MODE_LEGACY": "VeraCrypt RIPEMD160 + XTS 512 bit + boot-mode (legacy)",
"VERACRYPT_RIPEMD160_XTS_1024_BIT_BOOT_MODE_LEGACY": "VeraCrypt RIPEMD160 + XTS 1024 bit + boot-mode (legacy)",
"VERACRYPT_RIPEMD160_XTS_1536_BIT_BOOT_MODE_LEGACY": "VeraCrypt RIPEMD160 + XTS 1536 bit + boot-mode (legacy)",
"VERACRYPT_SHA256_XTS_512_BIT_LEGACY": "VeraCrypt SHA256 + XTS 512 bit (legacy)",
"VERACRYPT_SHA256_XTS_1024_BIT_LEGACY": "VeraCrypt SHA256 + XTS 1024 bit (legacy)",
"VERACRYPT_SHA256_XTS_1536_BIT_LEGACY": "VeraCrypt SHA256 + XTS 1536 bit (legacy)",
"VERACRYPT_SHA256_XTS_512_BIT_BOOT_MODE_LEGACY": "VeraCrypt SHA256 + XTS 512 bit + boot-mode (legacy)",
"VERACRYPT_SHA256_XTS_1024_BIT_BOOT_MODE_LEGACY": "VeraCrypt SHA256 + XTS 1024 bit + boot-mode (legacy)",
"VERACRYPT_SHA256_XTS_1536_BIT_BOOT_MODE_LEGACY": "VeraCrypt SHA256 + XTS 1536 bit + boot-mode (legacy)",
"VERACRYPT_STREEBOG_512_XTS_512_BIT_LEGACY": "VeraCrypt Streebog-512 + XTS 512 bit (legacy)",
"VERACRYPT_STREEBOG_512_XTS_1024_BIT_LEGACY": "VeraCrypt Streebog-512 + XTS 1024 bit (legacy)",
"VERACRYPT_STREEBOG_512_XTS_1536_BIT_LEGACY": "VeraCrypt Streebog-512 + XTS 1536 bit (legacy)",
"VERACRYPT_STREEBOG_512_XTS_512_BIT_BOOT_MODE_LEGACY": "VeraCrypt Streebog-512 + XTS 512 bit + boot-mode (legacy)",
"VERACRYPT_STREEBOG_512_XTS_1024_BIT_BOOT_MODE_LEGACY": "VeraCrypt Streebog-512 + XTS 1024 bit + boot-mode (legacy)",
"VERACRYPT_STREEBOG_512_XTS_1536_BIT_BOOT_MODE_LEGACY": "VeraCrypt Streebog-512 + XTS 1536 bit + boot-mode (legacy)",
"WINDOWS_PHONE": "Windows Phone 8+ PIN/password",
"OPENCART": "OpenCart",
"DES": "DES (PT = $salt, key = $pass)",
"THREE_DES": "3DES (PT = $salt, key = $pass)",
"RACF_KDFAES": "RACF KDFAES",
"SHA1_CX": "sha1(CX)",
"LINUX_KERNEL_CRYPTO_API_24": "Linux Kernel Crypto API (2.4)",
"LUKS_V1_LEGACY": "LUKS v1 (legacy)",
"ITUNES_BACKUP_10_0_MODE_14700": "iTunes backup < 10.0",
"ITUNES_BACKUP_10_0_MODE_14800": "iTunes backup >= 10.0",
"SKIP_32": "Skip32 (PT = $salt, key = $pass)",
"FILEZILLA_SERVER_0_9_55": "FileZilla Server >= 0.9.55",
"JUNIPER_NETBSD_SHA1CRYPT": "Juniper/NetBSD sha1crypt",
"BLOCKCHAIN_MY_WALLET_V2": "Blockchain, My Wallet, V2",
"DPAPI_V1_CTX_1_AND_2": "DPAPI masterkey file v1 (context 1 and 2)",
"DPAPI_V1_CTX_3": "DPAPI masterkey file v1 (context 3)",
"CHA_CHA_20": "ChaCha20",
"JKS_JAVA_KEY_STORE_PRIVATE_KEYS_SHA1": "JKS Java Key Store Private Keys (SHA1)",
"ETHEREUM_WALLET_PBKDF2_HMAC_SHA256": "Ethereum Wallet, PBKDF2-HMAC-SHA256",
"ETHEREUM_WALLET_SCRYPT": "Ethereum Wallet, SCRYPT",
"DPAPI_V2_CTX_1_AND_2": "DPAPI masterkey file v2 (context 1 and 2)",
"DPAPI_V2_CTX_3": "DPAPI masterkey file v2 (context 3)",
"TRIPCODE": "Tripcode",
"TACACS_PLUS": "TACACS+",
"APPLE_SECURE_NOTES": "Apple Secure Notes",
"ETHEREUM_PRE_SALE_WALLET_PBKDF2_HMAC_SHA256": "Ethereum Pre-Sale Wallet, PBKDF2-HMAC-SHA256",
"CRAM_MD5_DOVECOT": "CRAM-MD5 Dovecot",
"JWT": "JWT (JSON Web Token)",
"PERL_MOJOLICIOUS_SESSION_COOKIE_HMAC_SHA256_V9_19": "Perl Mojolicious session cookie (HMAC-SHA256, >= v9.19)",
"ELECTRUM_WALLET_SALT_TYPE_1_3": "Electrum Wallet (Salt-Type 1-3)",
"FILEVAULT_2": "FileVault 2",
"WPA_PMKID_PBKDF2": "WPA-PMKID-PBKDF2",
"WPA_PMKID_PMK": "WPA-PMKID-PMK",
"ANSIBLE_VAULT": "Ansible Vault",
"GPG": "GPG (AES-128/AES-256 (SHA-1($pass)))",
"GPG_AES_128_AES_256_SHA_512_PASS": "GPG (AES-128/AES-256 (SHA-512($pass)))",
"GPG_AES_128_AES_256_SHA_256_PASS": "GPG (AES-128/AES-256 (SHA-256($pass)))",
"GPG_CAST5_SHA_1_PASS": "GPG (CAST5 (SHA-1($pass)))",
"GPG_AES_OCB_128_SHA_1_PASS": "GPG (AES-OCB-128 (SHA-1($pass)))",
"PKZIP_COMPRESSED": "PKZIP (Compressed)",
"PKZIP_UNCOMPRESSED": "PKZIP (Uncompressed)",
"PKZIP_COMPRESSED_MULTI_FILE": "PKZIP (Compressed Multi-File)",
"PKZIP_MIXED_MULTI_FILE": "PKZIP (Mixed Multi-File)",
"PKZIP_MIXED_MULTI_FILE_CHECKSUM_ONLY": "PKZIP (Mixed Multi-File Checksum-Only)",
"SHA3_224": "SHA3-224",
"SHA3_256": "SHA3-256",
"SHA3_384": "SHA3-384",
"SHA3_512": "SHA3-512",
"KECCAK_224": "Keccak-224",
"KECCAK_256": "Keccak-256",
"KECCAK_384": "Keccak-384",
"KECCAK_512": "Keccak-512",
"TOTP_HMAC_SHA1": "TOTP (HMAC-SHA1)",
"KERBEROS_23_AS_REP": "Kerberos 5, etype 23, AS-REP",
"APPLE_FILE_SYSTEM_APFS": "Apple File System (APFS)",
"OPEN_DOCUMENT_FORMAT_ODF_1_2_SHA_256_AES": "Open Document Format (ODF) 1.2 (SHA-256, AES)",
"SHA1_MD5_MD5_PASS": "sha1(md5(md5($pass)))",
"OPEN_DOCUMENT_FORMAT_ODF_1_1_SHA_1_BLOWFISH": "Open Document Format (ODF) 1.1 (SHA-1, Blowfish)",
"JAVA_OBJECT": "Java Object hashCode()",
"BLOCKCHAIN_MY_WALLET_SECOND_PASSWORD_SHA256": "Blockchain, My Wallet, Second Password (SHA256)",
"ANDROID_BACKUP": "Android Backup",
"QNX_MD5": "QNX /etc/shadow (MD5)",
"QNX_SHA256": "QNX /etc/shadow (SHA256)",
"QNX_SHA512": "QNX /etc/shadow (SHA512)",
"QNX_7_ETC_SHADOW_SHA512": "QNX 7 /etc/shadow (SHA512)",
"SHA1_SALT1_PASS_SALT2": "sha1($salt1.$pass.$salt2)",
"RUBY_ON_RAILS_RESTFUL_AUTHENTICATION": "Ruby on Rails Restful-Authentication",
"KERBEROS_17_TGS_REP": "Kerberos 5, etype 17, TGS-REP",
"KERBEROS_18_TGS_REP": "Kerberos 5, etype 18, TGS-REP",
"KERBEROS_17_PREAUTH": "Kerberos 5, etype 17, Pre-Auth",
"KERBEROS_18_PREAUTH": "Kerberos 5, etype 18, Pre-Auth",
"DISKCRYPTOR_SHA512_XTS_512_BIT": "DiskCryptor SHA512 + XTS 512 bit",
"DISKCRYPTOR_SHA512_XTS_1024_BIT": "DiskCryptor SHA512 + XTS 1024 bit",
"DISKCRYPTOR_SHA512_XTS_1536_BIT": "DiskCryptor SHA512 + XTS 1536 bit",
"PYTHON_PASSLIB_PBKDF2_SHA512": "Python passlib pbkdf2-sha512",
"PYTHON_PASSLIB_PBKDF2_SHA256": "Python passlib pbkdf2-sha256",
"PYTHON_PASSLIB_PBKDF2_SHA1": "Python passlib pbkdf2-sha1",
"PKZIP_MASTER_KEY": "PKZIP Master Key",
"PKZIP_MASTER_KEY_6_BYTE_OPTIMIZATION": "PKZIP Master Key (6 byte optimization)",
"ORACLE_TRANSPORTATION_MANAGEMENT_SHA256": "Oracle Transportation Management (SHA256)",
"SHA256_SHA256_PASS_SALT_MODE_20710": "sha256(sha256($pass).$salt)",
"AUTHME_SHA256": "AuthMe sha256",
"RSA_SECURITY_ANALYTICS_NETWITNESS_SHA256": "RSA Security Analytics / NetWitness (sha256)",
"SHA256_SALT_SHA256_PASS": "sha256($salt.sha256($pass))",
"SHA256_SHA256_PASS_SALT_MODE_20730": "sha256(sha256($pass.$salt))",
"SHA256_MD5_PASS": "sha256(md5($pass))",
"MD5_SHA1_PASS_MD5_PASS_SHA1_PASS": "md5(sha1($pass).md5($pass).sha1($pass))",
"BITSHARES_V0_X_SHA512_SHA512_BIN_PASS": "BitShares v0.x - sha512(sha512_bin(pass))",
"SHA1_MD5_PASS_SALT_MODE_21100": "sha1(md5($pass.$salt))",
"MD5_SHA1_SALT_MD5_PASS": "md5(sha1($salt).md5($pass))",
"MD5_SALT_SHA1_SALT_PASS": "md5($salt.sha1($salt.$pass))",
"MD5_SALT1_SHA1_SALT2_PASS": "md5($salt1.sha1($salt2.$pass))",
"SHA256_SHA256_BIN_PASS": "sha256(sha256_bin($pass))",
"SHA256_SALT_SHA256_BIN_PASS": "sha256($salt.sha256_bin($pass))",
"SOLARWINDS_ORION": "SolarWinds Orion",
"SOLARWINDS_ORION_V2": "SolarWinds Orion v2",
"WEB2PY_PBKDF2_SHA512": "Web2py pbkdf2-sha512",
"ELECTRUM_WALLET_SALT_TYPE_4": "Electrum Wallet (Salt-Type 4)",
"ELECTRUM_WALLET_SALT_TYPE_5": "Electrum Wallet (Salt-Type 5)",
"MD5_MD5_MD5_PASS_SALT1_SALT2_MODE_21900": "md5(md5(md5($pass.$salt1)).$salt2)",
"WPA_PBKDF2_PMKID_EAPOL": "WPA-PBKDF2-PMKID+EAPOL",
"WPA_PMK_PMKID_EAPOL": "WPA-PMK-PMKID+EAPOL",
"BITLOCKER": "BitLocker",
"CITRIX_NETSCALER_SHA512": "Citrix NetScaler (SHA512)",
"SHA256_SALT_PASS_SALT": "sha256($salt.$pass.$salt)",
"TELEGRAM_MOBILE_APP_PASSCODE_SHA256": "Telegram Mobile App Passcode (SHA256)",
"AES_CRYPT_SHA256": "AES Crypt (SHA256)",
"MULTIBIT_CLASSIC_KEY_MD5": "MultiBit Classic .key (MD5)",
"TELEGRAM_DESKTOP_V2_1_14_PBKDF2_HMAC_SHA1": "Telegram Desktop < v2.1.14 (PBKDF2-HMAC-SHA1)",
"MULTIBIT_HD_SCRYPT": "MultiBit HD (scrypt)",
"SIMPLA_CMS_MD5_SALT_PASS_MD5_PASS": "Simpla CMS - md5($salt.$pass.md5($pass))",
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_0": "RSA/DSA/EC/OpenSSH Private Keys ($0$)",
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_6": "RSA/DSA/EC/OpenSSH Private Keys ($6$)",
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_1_3": "RSA/DSA/EC/OpenSSH Private Keys ($1, $3$)",
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_4": "RSA/DSA/EC/OpenSSH Private Keys ($4$)",
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_5": "RSA/DSA/EC/OpenSSH Private Keys ($5$)",
"SECUREZIP_AES_128": "SecureZIP AES-128",
"SECUREZIP_AES_192": "SecureZIP AES-192",
"SECUREZIP_AES_256": "SecureZIP AES-256",
"APPLE_KEYCHAIN": "Apple Keychain",
"XMPP_SCRAM_PBKDF2_SHA1": "XMPP SCRAM PBKDF2-SHA1",
"APPLE_IWORK": "Apple iWork",
"BITWARDEN": "Bitwarden",
"AXCRYPT_2_AES_128": "AxCrypt 2 AES-128",
"AXCRYPT_2_AES_256": "AxCrypt 2 AES-256",
"RAR3_P_UNCOMPRESSED": "RAR3-p (Uncompressed)",
"RAR3_P_COMPRESSED": "RAR3-p (Compressed)",
"BESTCRYPT_V3_VOLUME_ENCRYPTION": "BestCrypt v3 Volume Encryption",
"BESTCRYPT_V4_VOLUME_ENCRYPTION": "BestCrypt v4 Volume Encryption",
"MONGODB_SERVERKEY_SCRAM_SHA_1": "MongoDB ServerKey SCRAM-SHA-1",
"MONGODB_SERVERKEY_SCRAM_SHA_256": "MongoDB ServerKey SCRAM-SHA-256",
"SHA1_SALT_SHA1_PASS_SALT": "sha1($salt.sha1($pass.$salt))",
"PKCS_8_PRIVATE_KEYS_PBKDF2_HMAC_SHA1_3DES_AES": "PKCS#8 Private Keys (PBKDF2-HMAC-SHA1 + 3DES/AES)",
"PKCS_8_PRIVATE_KEYS_PBKDF2_HMAC_SHA256_3DES_AES": "PKCS#8 Private Keys (PBKDF2-HMAC-SHA256 + 3DES/AES)",
"TELEGRAM_DESKTOP_V2_1_14_PBKDF2_HMAC_SHA512": "Telegram Desktop >= v2.1.14 (PBKDF2-HMAC-SHA512)",
"SQLCIPHER": "SQLCipher",
"STUFFIT5": "Stuffit5",
"UMBRACO_HMAC_SHA1": "Umbraco HMAC-SHA1",
"DAHUA_AUTHENTICATION_MD5": "Dahua Authentication MD5",
"BESDER_AUTHENTICATION_MD5": "Besder Authentication MD5",
"SNMP_V3_HMAC_MD5_96__SHA1_96": "SNMPv3 HMAC-MD5-96/HMAC-SHA1-96",
"SNMP_V3_HMAC_MD5_96": "SNMPv3 HMAC-MD5-96",
"SNMP_V3_HMAC_SHA1_96": "SNMPv3 HMAC-SHA1-96",
"MS_OFFICE_2016_SHEETPROTECTION": "MS Office 2016 - SheetProtection",
"PDF_1_4_1_6_ACROBAT_5_8_USER_AND_OWNER_PASS": "PDF 1.4 - 1.6 (Acrobat 5 - 8) - user and owner pass",
"STARGAZER_STELLAR_WALLET_XLM": "Stargazer Stellar Wallet XLM",
"BCRYPT_MD5_PASS": "bcrypt(md5($pass))",
"MURMUR": "MurmurHash",
"BCRYPT_SHA1_PASS": "bcrypt(sha1($pass))",
"KNX_IP_SECURE_DEVICE_AUTHENTICATION_CODE": "KNX IP Secure - Device Authentication Code",
"MOZILLA_KEY3_DB": "Mozilla key3.db",
"MOZILLA_KEY4_DB": "Mozilla key4.db",
"OPENEDGE_PROGRESS_ENCODE": "OpenEdge Progress Encode",
"FORTIGATE256_FORTIOS256": "FortiGate256 (FortiOS256)",
"AES_128_ECB": "AES-128-ECB NOKDF (PT = $salt, key = $pass)",
"AES_192_ECB": "AES-192-ECB NOKDF (PT = $salt, key = $pass)",
"AES_256_ECB": "AES-256-ECB NOKDF (PT = $salt, key = $pass)",
"IPHONE_PASSCODE_UID_KEY_SYSTEM_KEYBAG": "iPhone passcode (UID key + System Keybag)",
"METAMASK_WALLET_NEEDS_ALL_DATA_CHECKS_AES_GCM_TAG": "MetaMask Wallet (needs all data, checks AES-GCM tag)",
"METAMASK_WALLET_SHORT_HASH_PLAINTEXT_CHECK": "MetaMask Wallet (short hash, plaintext check)",
"SNMP_V3_HMAC_SHA224_128": "SNMPv3 HMAC-SHA224-128",
"SNMP_V3_HMAC_SHA256_192": "SNMPv3 HMAC-SHA256-192",
"SNMP_V3_HMAC_SHA384_256": "SNMPv3 HMAC-SHA384-256",
"NET_NTLM_V1_NT": "NetNTLMv1 / NetNTLMv1+ESS (NT)",
"NET_NTLM_V2_NT": "NetNTLMv2 (NT)",
"RUBY_ON_RAILS_RESTFUL_AUTH_ONE_ROUND_NO_SITEKEY": "Ruby on Rails Restful Auth (one round, no sitekey)",
"SNMP_V3_HMAC_SHA512_384": "SNMPv3 HMAC-SHA512-384",
"VMWARE_VMX_PBKDF2_HMAC_SHA1_AES_256_CBC": "VMware VMX (PBKDF2-HMAC-SHA1 + AES-256-CBC)",
"VIRTUALBOX_PBKDF2_HMAC_SHA256_AES_128_XTS": "VirtualBox (PBKDF2-HMAC-SHA256 & AES-128-XTS)",
"VIRTUALBOX_PBKDF2_HMAC_SHA256_AES_256_XTS": "VirtualBox (PBKDF2-HMAC-SHA256 & AES-256-XTS)",
"MULTIBIT_CLASSIC_WALLET_SCRYPT": "MultiBit Classic .wallet (scrypt)",
"MURMUR3": "MurmurHash3",
"CRC32C": "CRC32C",
"CRC64Jones": "CRC64Jones",
"WINDOWS_HELLO_PIN": "Windows Hello PIN/Password",
"EXODUS_DESKTOP_WALLET_SCRYPT": "Exodus Desktop Wallet (scrypt)",
"TEAMSPEAK_3_CHANNEL_HASH": "Teamspeak 3 (channel hash)",
"BCRYPT_SHA512_PASS": "bcrypt(sha512($pass))",
"BITCOIN_WIF_PRIVATE_KEY_P2PKH_COMPRESSED": "Bitcoin WIF private key (P2PKH), compressed",
"BITCOIN_WIF_PRIVATE_KEY_P2PKH_UNCOMPRESSED": "Bitcoin WIF private key (P2PKH), uncompressed",
"BITCOIN_WIF_PRIVATE_KEY_P2WPKH_BECH32_COMPRESSED": "Bitcoin WIF private key (P2WPKH, Bech32), compressed",
"BITCOIN_WIF_PRIVATE_KEY_P2WPKH_BECH32_UNCOMPRESSED": "Bitcoin WIF private key (P2WPKH, Bech32), uncompressed",
"BITCOIN_WIF_PRIVATE_KEY_P2SH_P2WPKH_COMPRESSED": "Bitcoin WIF private key (P2SH(P2WPKH)), compressed",
"BITCOIN_WIF_PRIVATE_KEY_P2SH_P2WPKH_UNCOMPRESSED": "Bitcoin WIF private key (P2SH(P2WPKH)), uncompressed",
"POSTGRESQL_SCRAM_SHA_256": "PostgreSQL SCRAM-SHA-256",
"AMAZON_AWS_SIGNATURE_VERSION_4": "Amazon AWS Signature Version 4",
"KERBEROS_17_DB": "Kerberos 5, etype 17, DB",
"KERBEROS_18_DB": "Kerberos 5, etype 18, DB",
"SHA1_SALT_SHA1_UTF16LE_USERNAME_UTF16LE_PASS": "sha1($salt.sha1(utf16le($username).':'.utf16le($pass)))",
"FLASK": "Flask Session Cookie ($salt.$salt.$pass)",
"RADMIN_3": "Radmin3",
"TRUECRYPT_RIPEMD160_XTS_512_BIT": "TrueCrypt RIPEMD160 + XTS 512 bit",
"TRUECRYPT_RIPEMD160_XTS_1024_BIT": "TrueCrypt RIPEMD160 + XTS 1024 bit",
"TRUECRYPT_RIPEMD160_XTS_1536_BIT": "TrueCrypt RIPEMD160 + XTS 1536 bit",
"TRUECRYPT_SHA512_XTS_512_BIT": "TrueCrypt SHA512 + XTS 512 bit",
"TRUECRYPT_SHA512_XTS_1024_BIT": "TrueCrypt SHA512 + XTS 1024 bit",
"TRUECRYPT_SHA512_XTS_1536_BIT": "TrueCrypt SHA512 + XTS 1536 bit",
"TRUECRYPT_WHIRLPOOL_XTS_512_BIT": "TrueCrypt Whirlpool + XTS 512 bit",
"TRUECRYPT_WHIRLPOOL_XTS_1024_BIT": "TrueCrypt Whirlpool + XTS 1024 bit",
"TRUECRYPT_WHIRLPOOL_XTS_1536_BIT": "TrueCrypt Whirlpool + XTS 1536 bit",
"TRUECRYPT_RIPEMD160_XTS_512_BIT_BOOT_MODE": "TrueCrypt RIPEMD160 + XTS 512 bit + boot-mode",
"TRUECRYPT_RIPEMD160_XTS_1024_BIT_BOOT_MODE": "TrueCrypt RIPEMD160 + XTS 1024 bit + boot-mode",
"TRUECRYPT_RIPEMD160_XTS_1536_BIT_BOOT_MODE": "TrueCrypt RIPEMD160 + XTS 1536 bit + boot-mode",
"VERACRYPT_RIPEMD160_XTS_512_BIT": "VeraCrypt RIPEMD160 + XTS 512 bit",
"VERACRYPT_RIPEMD160_XTS_1024_BIT": "VeraCrypt RIPEMD160 + XTS 1024 bit",
"VERACRYPT_RIPEMD160_XTS_1536_BIT": "VeraCrypt RIPEMD160 + XTS 1536 bit",
"VERACRYPT_SHA512_XTS_512_BIT": "VeraCrypt SHA512 + XTS 512 bit",
"VERACRYPT_SHA512_XTS_1024_BIT": "VeraCrypt SHA512 + XTS 1024 bit",
"VERACRYPT_SHA512_XTS_1536_BIT": "VeraCrypt SHA512 + XTS 1536 bit",
"VERACRYPT_WHIRLPOOL_XTS_512_BIT": "VeraCrypt Whirlpool + XTS 512 bit",
"VERACRYPT_WHIRLPOOL_XTS_1024_BIT": "VeraCrypt Whirlpool + XTS 1024 bit",
"VERACRYPT_WHIRLPOOL_XTS_1536_BIT": "VeraCrypt Whirlpool + XTS 1536 bit",
"VERACRYPT_RIPEMD160_XTS_512_BIT_BOOT_MODE": "VeraCrypt RIPEMD160 + XTS 512 bit + boot-mode",
"VERACRYPT_RIPEMD160_XTS_1024_BIT_BOOT_MODE": "VeraCrypt RIPEMD160 + XTS 1024 bit + boot-mode",
"VERACRYPT_RIPEMD160_XTS_1536_BIT_BOOT_MODE": "VeraCrypt RIPEMD160 + XTS 1536 bit + boot-mode",
"VERACRYPT_SHA256_XTS_512_BIT": "VeraCrypt SHA256 + XTS 512 bit",
"VERACRYPT_SHA256_XTS_1024_BIT": "VeraCrypt SHA256 + XTS 1024 bit",
"VERACRYPT_SHA256_XTS_1536_BIT": "VeraCrypt SHA256 + XTS 1536 bit",
"VERACRYPT_SHA256_XTS_512_BIT_BOOT_MODE": "VeraCrypt SHA256 + XTS 512 bit + boot-mode",
"VERACRYPT_SHA256_XTS_1024_BIT_BOOT_MODE": "VeraCrypt SHA256 + XTS 1024 bit + boot-mode",
"VERACRYPT_SHA256_XTS_1536_BIT_BOOT_MODE": "VeraCrypt SHA256 + XTS 1536 bit + boot-mode",
"VERACRYPT_STREEBOG_512_XTS_512_BIT": "VeraCrypt Streebog-512 + XTS 512 bit",
"VERACRYPT_STREEBOG_512_XTS_1024_BIT": "VeraCrypt Streebog-512 + XTS 1024 bit",
"VERACRYPT_STREEBOG_512_XTS_1536_BIT": "VeraCrypt Streebog-512 + XTS 1536 bit",
"VERACRYPT_STREEBOG_512_XTS_512_BIT_BOOT_MODE": "VeraCrypt Streebog-512 + XTS 512 bit + boot-mode",
"VERACRYPT_STREEBOG_512_XTS_1024_BIT_BOOT_MODE": "VeraCrypt Streebog-512 + XTS 1024 bit + boot-mode",
"VERACRYPT_STREEBOG_512_XTS_1536_BIT_BOOT_MODE": "VeraCrypt Streebog-512 + XTS 1536 bit + boot-mode",
"LUKS_V1_SHA_1_AES": "LUKS v1 SHA-1 + AES",
"LUKS_V1_SHA_1_SERPENT": "LUKS v1 SHA-1 + Serpent",
"LUKS_V1_SHA_1_TWOFISH": "LUKS v1 SHA-1 + Twofish",
"LUKS_V1_SHA_256_AES": "LUKS v1 SHA-256 + AES",
"LUKS_V1_SHA_256_SERPENT": "LUKS v1 SHA-256 + Serpent",
"LUKS_V1_SHA_256_TWOFISH": "LUKS v1 SHA-256 + Twofish",
"LUKS_V1_SHA_512_AES": "LUKS v1 SHA-512 + AES",
"LUKS_V1_SHA_512_SERPENT": "LUKS v1 SHA-512 + Serpent",
"LUKS_V1_SHA_512_TWOFISH": "LUKS v1 SHA-512 + Twofish",
"LUKS_V1_RIPEMD_160_AES": "LUKS v1 RIPEMD-160 + AES",
"LUKS_V1_RIPEMD_160_SERPENT": "LUKS v1 RIPEMD-160 + Serpent",
"LUKS_V1_RIPEMD_160_TWOFISH": "LUKS v1 RIPEMD-160 + Twofish",
"TERRA_STATION_WALLET_AES256_CBC_PBKDF2_PASS": "Terra Station Wallet (AES256-CBC(PBKDF2($pass)))",
"KEEPASS_KDBX_V2_V3_KEYFILE_ONLY": "KeePass (KDBX v2/v3) - keyfile only",
"BISQ_WALLET_SCRYPT": "Bisq .wallet (scrypt)",
"ENCSECURITY_DATAVAULT_PBKDF2_NO_KEYCHAIN": "ENCsecurity Datavault (PBKDF2/no keychain)",
"ENCSECURITY_DATAVAULT_PBKDF2_KEYCHAIN": "ENCsecurity Datavault (PBKDF2/keychain)",
"ENCSECURITY_DATAVAULT_MD5_NO_KEYCHAIN": "ENCsecurity Datavault (MD5/no keychain)",
"ENCSECURITY_DATAVAULT_MD5_KEYCHAIN": "ENCsecurity Datavault (MD5/keychain)",
"PYTHON_WERKZEUG_MD5_HMAC_MD5_KEY_SALT": "Python Werkzeug MD5 (HMAC-MD5 (key = $salt))",
"PYTHON_WERKZEUG_SHA256_HMAC_SHA256_KEY_SALT": "Python Werkzeug SHA256 (HMAC-SHA256 (key = $salt))",
"DANE_RFC7929_RFC8162_SHA2_256": "DANE RFC7929/RFC8162 SHA2-256",
"MD5_MD5_SALT_MD5_MD5_PASS": "md5(md5($salt).md5(md5($pass)))",
"BCRYPT_SHA256_PASS": "bcrypt(sha256($pass))",
"BCRYPT_HMAC_SHA256_PASS": "bcrypt(HMAC-SHA256($pass))",
"ANOPE_IRC_SERVICES_ENC_SHA256": "Anope IRC Services (enc_sha256)",
"BITCOIN_RAW_PRIVATE_KEY_P2PKH_COMPRESSED": "Bitcoin raw private key (P2PKH), compressed",
"BITCOIN_RAW_PRIVATE_KEY_P2PKH_UNCOMPRESSED": "Bitcoin raw private key (P2PKH), uncompressed",
"BITCOIN_RAW_PRIVATE_KEY_P2WPKH_BECH32_COMPRESSED": "Bitcoin raw private key (P2WPKH, Bech32), compressed",
"BITCOIN_RAW_PRIVATE_KEY_P2WPKH_BECH32_UNCOMPRESSED": "Bitcoin raw private key (P2WPKH, Bech32), uncompressed",
"BITCOIN_RAW_PRIVATE_KEY_P2SH_P2WPKH_COMPRESSED": "Bitcoin raw private key (P2SH(P2WPKH)), compressed",
"BITCOIN_RAW_PRIVATE_KEY_P2SH_P2WPKH_UNCOMPRESSED": "Bitcoin raw private key (P2SH(P2WPKH)), uncompressed",
"BLAKE2S_256": "BLAKE2s-256",
"SHANGMI_3_SM3": "ShangMi 3 (SM3)",
"VEEAM_VBK": "Veeam VBK",
"MS_SNTP": "MS SNTP",
"SECURECRT_MASTERPASSPHRASE_V2": "SecureCRT MasterPassphrase v2",
"DOMAIN_CACHED_CREDENTIALS_DCC_MS_CACHE_NT": "Domain Cached Credentials (DCC), MS Cache (NT)",
"DOMAIN_CACHED_CREDENTIALS_2_DCC2_MS_CACHE_2_NT": "Domain Cached Credentials 2 (DCC2), MS Cache 2, (NT)",
"MD5_MD5_MD5_PASS_SALT1_SALT2_MODE_31700": "md5(md5(md5($pass).$salt1).$salt2)",
"HASHCAT_1PASSWORD_MOBILEKEYCHAIN_1PASSWORD_8": "1Password, mobilekeychain (1Password 8)",
"METAMASK_MOBILE_WALLET": "MetaMask Mobile Wallet",
"NETIQ_SSPR_MD5": "NetIQ SSPR (MD5)",
"NETIQ_SSPR_SHA1": "NetIQ SSPR (SHA1)",
"NETIQ_SSPR_SHA_1_WITH_SALT": "NetIQ SSPR (SHA-1 with Salt)",
"NETIQ_SSPR_SHA_256_WITH_SALT": "NetIQ SSPR (SHA-256 with Salt)",
"ADOBE_AEM_SSPR_SHA_256_WITH_SALT": "Adobe AEM (SSPR, SHA-256 with Salt)",
"NETIQ_SSPR_SHA_512_WITH_SALT": "NetIQ SSPR (SHA-512 with Salt)",
"ADOBE_AEM_SSPR_SHA_512_WITH_SALT": "Adobe AEM (SSPR, SHA-512 with Salt)",
"NETIQ_SSPR_PBKDF2WITHHMACSHA1": "NetIQ SSPR (PBKDF2WithHmacSHA1)",
"NETIQ_SSPR_PBKDF2WITHHMACSHA256": "NetIQ SSPR (PBKDF2WithHmacSHA256)",
"NETIQ_SSPR_PBKDF2WITHHMACSHA512": "NetIQ SSPR (PBKDF2WithHmacSHA512)",
"KERBEROS_5_ETYPE_17_AS_REP": "Kerberos 5, etype 17, AS-REP",
"KERBEROS_5_ETYPE_18_AS_REP": "Kerberos 5, etype 18, AS-REP",
"EMPIRE_CMS_ADMIN_PASSWORD": "Empire CMS (Admin password)",
"SHA512_SHA512_PASS_SALT": "sha512(sha512($pass).$salt)",
"SHA512_SHA512_BIN_PASS_SALT": "sha512(sha512_bin($pass).$salt)",
"DOGECHAIN_INFO_WALLET": "Dogechain.info Wallet",
"CUBECART_WHIRLPOOL_SALT_PASS_SALT": "CubeCart (whirlpool($salt.$pass.$salt))",
"KREMLIN_ENCRYPT_3_0_W_NEWDES": "Kremlin Encrypt 3.0 w/NewDES",
"MD5_SHA1_MD5_PASS": "md5(sha1(md5($pass)))",
"PBKDF1_SHA1": "PBKDF1-SHA1",
"MD5_SALT1_PASS_SALT2": "md5($salt1.$pass.$salt2)",
"MD5_SALT_MD5_PASS_SALT_MODE_33100": "md5($salt.md5($pass).$salt)",
"HMAC_BLAKE2S_KEY_PASS": "HMAC-BLAKE2S (key = $pass)",
"MEGA_NZ_PASSWORD_PROTECTED_LINK_PBKDF2_HMAC_SHA512": "mega.nz password-protected link (PBKDF2-HMAC-SHA512)",
"RC4_40_BIT_DROPN": "RC4 40-bit DropN",
"RC4_72_BIT_DROPN": "RC4 72-bit DropN",
"RC4_104_BIT_DROPN": "RC4 104-bit DropN",
"RIPEMD_320": "RIPEMD-320",
"HMAC_RIPEMD320_KEY_PASS": "HMAC-RIPEMD320 (key = $pass)",
"HMAC_RIPEMD320_KEY_SALT": "HMAC-RIPEMD320 (key = $salt)",
"MICROSOFT_ONLINE_ACCOUNT_PBKDF2_HMAC_SHA256_AES256": "Microsoft Online Account (PBKDF2-HMAC-SHA256 + AES256)",
"WBB4_WOLTLAB_BURNING_BOARD_BCRYPT_BCRYPT_PASS": "WBB4 (Woltlab Burning Board) [bcrypt(bcrypt($pass))]",
"CITRIX_NETSCALER_PBKDF2_HMAC_SHA256": "Citrix NetScaler (PBKDF2-HMAC-SHA256)",
"ARGON2": "Argon2",
"LUKS_V2_ARGON2_SHA_256_AES": "LUKS v2 argon2 + SHA-256 + AES",
"MURMURHASH64A": "MurmurHash64A",
"MURMURHASH64A_ZERO_SEED": "MurmurHash64A (zero seed)",
"MURMURHASH64A_TRUNCATED_ZERO_SEED": "MurmurHash64A truncated (zero seed)",
"KEEPASS_ARGON2_KDBX_V4": "KeePass Argon2 (KDBX v4)",
"KEEPASS_AESKDF_KDBX_V4": "KeePass AESKDF (KDBX v4)",
"SHA224_SHA224_PASS": "sha224(sha224($pass))",
"SHA224_SHA1_PASS": "sha224(sha1($pass))",
"MD6_256": "MD6 (256)",
"BLOCKCHAIN_MY_WALLET_LEGACY_WALLETS": "Blockchain, My Wallet, Legacy Wallets",
"BLAKE2B_256_MODE_34800": "BLAKE2b-256",
"BLAKE2B_256_PASS_SALT": "BLAKE2b-256($pass.$salt)",
"BLAKE2B_256_SALT_PASS": "BLAKE2b-256($salt.$pass)",
"SAP_CODVN_H_PWDSALTEDHASH_ISSHA512": "SAP CODVN H (PWDSALTEDHASH) isSHA512",
"SM3CRYPT_SM3_SM3_UNIX": "sm3crypt $sm3$, SM3 (Unix)",
"AS_400_SSHA1": "AS/400 SSHA1",
"KERBEROS_5_ETYPE_23_TGS_REP_NT": "Kerberos 5, etype 23, TGS-REP (NT)",
"KERBEROS_5_ETYPE_23_AS_REP_NT": "Kerberos 5, etype 23, AS-REP (NT)",
"WORDPRESS_BCRYPT_HMAC_SHA384_PASS": "Wordpress bcrypt(hmac-sha384($pass))",
"GOST12512CRYPT_GOST12512HASH_UNIX": "gost12512crypt [$gost12512hash$] (Unix)",
"PHPASS_MD5_PASS": "phpass(md5($pass))",
"SYMFONY_LEGACY_SHA256": "Symfony Legacy SHA256",
"YESCRYPT": "yescrypt",
"GOST_YESCRYPT": "gost-yescrypt",
"SCRYPTCRYPT_SCRYPT_UNIX": "scryptcrypt, scrypt (Unix)",
"KDE_KWALLET_4_13_PBKDF2_HMAC_SHA512_BLOWFISH": "KDE KWallet 4.13+ (PBKDF2-HMAC-SHA512, Blowfish)",
"KDE_KWALLET_4_13_SHA_1_BLOWFISH": "KDE KWallet < 4.13 (SHA-1, Blowfish)",
"ARGON2ID_BRIDGED_REFERENCE_IMPLEMENTATION_TUNINGS": "Argon2id [Bridged: reference implementation + tunings]",
"SCRYPT_BRIDGED_SCRYPT_JANE_SMIX": "scrypt [Bridged: Scrypt-Jane SMix]",
"SCRYPT_BRIDGED_SCRYPT_YESCRYPT": "scrypt [Bridged: Scrypt-Yescrypt]",
"GENERIC_HASH_BRIDGED_PYTHON_INTERPRETER_FREE_THREADING": "Generic Hash [Bridged: Python Interpreter free-threading]",
"GENERIC_HASH_BRIDGED_PYTHON_INTERPRETER_WITH_GIL": "Generic Hash [Bridged: Python Interpreter with GIL]",
"GENERIC_HASH_BRIDGED_RUST": "Generic Hash [Bridged: Rust]",
"PLAINTEXT": "Plaintext",
}
+210
View File
@@ -0,0 +1,210 @@
package creds
import (
"crypto/sha256"
"fmt"
"sort"
"strings"
"testing"
"unicode"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
const (
pinnedHashcatVersion = "v7.1.2-armory.1"
pinnedHashcatCommit = "deadd52de22e2753e9caaff6d1b92e5a22a35626"
pinnedHashcatModuleCount = 595
pinnedHashcatCatalogSHA256 = "1c041b6370a37ccf07ae71ff29205c56c67eeb7dbe1f7bbc134e0373ecd19e4f"
pinnedHashcatModeIDsSHA256 = "bb206df105890129937d0c688101a3065aad2ca45eea7765bfda60c40f232a63"
pinnedHashcatEnumsSHA256 = "989144b3d2a57f9659bc5aa53df42403ba78b08e5a6fab616d43c14e0352e023"
)
type hashcatCatalogEntry struct {
mode int32
enumName string
description string
}
func TestHashcatV7CatalogCoverage(t *testing.T) {
version := pinnedHashcatVersion + "@" + pinnedHashcatCommit
validateHashcatCatalogCardinality(t, version)
catalog := collectHashcatCatalog(t, version)
validateHashTypeValueRoundTrips(t)
validateHashTypeDescriptionParity(t)
validateHashcatCatalogDigests(t, version, catalog)
}
func validateHashcatCatalogCardinality(t *testing.T, version string) {
t.Helper()
if got := len(clientpb.HashType_name); got != pinnedHashcatModuleCount+1 {
t.Fatalf("%s HashType_name contains %d values, want %d modules plus INVALID", version, got, pinnedHashcatModuleCount)
}
if got := len(clientpb.HashType_value); got != pinnedHashcatModuleCount+1 {
t.Fatalf("%s HashType_value contains %d values, want %d modules plus INVALID", version, got, pinnedHashcatModuleCount)
}
if got := len(hashTypes); got != pinnedHashcatModuleCount {
t.Fatalf("%s hashTypes contains %d descriptions, want %d", version, got, pinnedHashcatModuleCount)
}
}
func collectHashcatCatalog(t *testing.T, version string) []hashcatCatalogEntry {
t.Helper()
catalog := make([]hashcatCatalogEntry, 0, pinnedHashcatModuleCount)
seenDescriptions := make(map[string]int32, pinnedHashcatModuleCount)
for mode, enumName := range clientpb.HashType_name {
if mode == int32(clientpb.HashType_INVALID) {
if enumName != "INVALID" || mode != 9999 {
t.Errorf("invalid sentinel is %d -> %q, want 9999 -> INVALID", mode, enumName)
}
continue
}
if mode < 0 {
t.Errorf("HashType_name contains negative mode %d (%s)", mode, enumName)
continue
}
description, ok := hashTypes[enumName]
if !ok {
t.Errorf("hashTypes is missing %d -> %s", mode, enumName)
continue
}
if previous, duplicate := seenDescriptions[description]; duplicate {
t.Errorf("Hashcat name %q is shared by modes %d and %d", description, previous, mode)
}
seenDescriptions[description] = mode
if got, ok := clientpb.HashType_value[enumName]; !ok || got != mode {
t.Errorf("HashType_value[%q] = %d, %v; want %d, true", enumName, got, ok, mode)
}
if got := clientpb.HashType(mode).String(); got != enumName {
t.Errorf("HashType(%d).String() = %q, want %q", mode, got, enumName)
}
catalog = append(catalog, hashcatCatalogEntry{mode: mode, enumName: enumName, description: description})
}
if got := len(catalog); got != pinnedHashcatModuleCount {
t.Fatalf("%s catalog contains %d real modes, want %d", version, got, pinnedHashcatModuleCount)
}
return catalog
}
func validateHashTypeValueRoundTrips(t *testing.T) {
t.Helper()
for enumName, mode := range clientpb.HashType_value {
canonical, ok := clientpb.HashType_name[mode]
if !ok || canonical != enumName {
t.Errorf("HashType alias or orphan %q -> %d; canonical name is %q, %v", enumName, mode, canonical, ok)
}
}
}
func validateHashTypeDescriptionParity(t *testing.T) {
t.Helper()
for enumName, description := range hashTypes {
mode, ok := clientpb.HashType_value[enumName]
if !ok || mode == int32(clientpb.HashType_INVALID) {
t.Errorf("hashTypes contains non-catalog key %q -> %q", enumName, description)
}
if description == "" {
t.Errorf("hashTypes[%q] has an empty description", enumName)
}
}
}
func validateHashcatCatalogDigests(t *testing.T, version string, catalog []hashcatCatalogEntry) {
t.Helper()
sort.Slice(catalog, func(i, j int) bool { return catalog[i].mode < catalog[j].mode })
var catalogRows strings.Builder
var modeRows strings.Builder
var enumRows strings.Builder
for _, entry := range catalog {
fmt.Fprintf(&catalogRows, "%d\t%s\n", entry.mode, entry.description)
fmt.Fprintf(&modeRows, "%05d\n", entry.mode)
fmt.Fprintf(&enumRows, "%d\t%s\t%s\n", entry.mode, entry.enumName, entry.description)
}
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(catalogRows.String()))); got != pinnedHashcatCatalogSHA256 {
t.Errorf("%s mode/name catalog SHA-256 = %s, want %s", version, got, pinnedHashcatCatalogSHA256)
}
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(modeRows.String()))); got != pinnedHashcatModeIDsSHA256 {
t.Errorf("%s mode ID SHA-256 = %s, want %s", version, got, pinnedHashcatModeIDsSHA256)
}
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(enumRows.String()))); got != pinnedHashcatEnumsSHA256 {
t.Errorf("%s mode/enum/name SHA-256 = %s, want %s", version, got, pinnedHashcatEnumsSHA256)
}
}
func TestHashcatIdentifierCollisionResolution(t *testing.T) {
want := map[int32]string{
141: "EPISERVER_6_X_NET_4_MODE_141",
600: "BLAKE2B_256",
1441: "EPISERVER_6_X_NET_4_MODE_1441",
2611: "VBULLETIN_V3_8_5_MODE_2611",
2711: "VBULLETIN_V3_8_5_MODE_2711",
4110: "MD5_SALT_MD5_PASS_SALT_MODE_4110",
4410: "MD5_SHA1_PASS_SALT_MODE_4410",
4420: "MD5_SHA1_PASS_SALT_MODE_4420",
4710: "SHA1_MD5_PASS_SALT_MODE_4710",
14700: "ITUNES_BACKUP_10_0_MODE_14700",
14800: "ITUNES_BACKUP_10_0_MODE_14800",
20710: "SHA256_SHA256_PASS_SALT_MODE_20710",
20730: "SHA256_SHA256_PASS_SALT_MODE_20730",
21100: "SHA1_MD5_PASS_SALT_MODE_21100",
21900: "MD5_MD5_MD5_PASS_SALT1_SALT2_MODE_21900",
31700: "MD5_MD5_MD5_PASS_SALT1_SALT2_MODE_31700",
31800: "HASHCAT_1PASSWORD_MOBILEKEYCHAIN_1PASSWORD_8",
33100: "MD5_SALT_MD5_PASS_SALT_MODE_33100",
34800: "BLAKE2B_256_MODE_34800",
6600: "HASHCAT_1PASSWORD_AGILEKEYCHAIN",
8200: "HASHCAT_1PASSWORD_CLOUDKEYCHAIN",
11600: "HASHCAT_7_ZIP",
}
for mode, enumName := range want {
if got := clientpb.HashType_name[mode]; got != enumName {
t.Errorf("HashType_name[%d] = %q, want collision-safe %q", mode, got, enumName)
}
}
groups := map[string][]int32{}
for mode, enumName := range clientpb.HashType_name {
if mode == int32(clientpb.HashType_INVALID) {
continue
}
description, ok := hashTypes[enumName]
if !ok {
t.Fatalf("hashTypes is missing collision input %d -> %s", mode, enumName)
}
base := normalizeHashcatIdentifier(description)
groups[base] = append(groups[base], mode)
}
collisions := 0
for _, modes := range groups {
if len(modes) > 1 {
collisions++
}
}
if collisions != 8 {
t.Errorf("normalized catalog contains %d collision groups, want 8", collisions)
}
}
func normalizeHashcatIdentifier(name string) string {
var builder strings.Builder
underscore := false
for _, r := range strings.ToUpper(name) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
builder.WriteRune(r)
underscore = false
continue
}
if builder.Len() > 0 && !underscore {
builder.WriteByte('_')
underscore = true
}
}
return strings.Trim(builder.String(), "_")
}
+1 -1
View File
@@ -80,7 +80,7 @@ func selectDebugTarget(targets []overlord.ChromeDebugTarget, con *console.Sliver
if selected == "" {
return nil
}
selectedID := strings.Split(selected, " ")[0]
selectedID, _, _ := strings.Cut(selected, " ")
selectedTarget := id2target[selectedID]
return &selectedTarget
}
+77 -14
View File
@@ -68,9 +68,12 @@ const (
)
type docEntry struct {
Name string
Content string
Description string
Name string
Content string
Description string
searchTitle string
searchDescription string
searchContent string
}
type docsHelpEntry struct {
@@ -181,8 +184,12 @@ func newDocsModel(entries []docEntry) *docsModel {
styles: newDocsStyles(),
}
for _, entry := range entries {
model.entriesByName[entry.Name] = entry
for i := range model.entries {
entry := &model.entries[i]
entry.searchTitle = normalizeDocSearchText(entry.Name)
entry.searchDescription = normalizeDocSearchText(entry.Description)
entry.searchContent = normalizeDocSearchText(entry.Content)
model.entriesByName[entry.Name] = *entry
}
if idx := preferredDocIndex(entries); idx >= 0 {
@@ -522,13 +529,23 @@ func (m *docsModel) updateFilteredEntries(preferCurrent bool) {
return
}
filtered := make([]docEntry, 0, len(m.entries))
type rankedEntry struct {
entry docEntry
score int
}
filtered := make([]rankedEntry, 0, len(m.entries))
for _, entry := range m.entries {
if matchesDocQuery(entry, query) {
filtered = append(filtered, entry)
if score, matches := docQueryScore(entry, query); matches {
filtered = append(filtered, rankedEntry{entry: entry, score: score})
}
}
m.filteredEntries = filtered
sort.SliceStable(filtered, func(i, j int) bool {
return filtered[i].score > filtered[j].score
})
m.filteredEntries = make([]docEntry, len(filtered))
for i, result := range filtered {
m.filteredEntries[i] = result.entry
}
m.updateBrowserPagination()
if preferCurrent && m.selectDocInVisible(m.currentDocName) {
@@ -539,12 +556,58 @@ func (m *docsModel) updateFilteredEntries(preferCurrent bool) {
m.syncSelectionFromBrowser()
}
func matchesDocQuery(entry docEntry, query string) bool {
if query == "" {
return true
func docQueryScore(entry docEntry, query string) (int, bool) {
normalizedQuery := strings.TrimSpace(normalizeDocSearchText(query))
if normalizedQuery == "" {
return 0, true
}
haystack := strings.ToLower(entry.Name + ".md\n" + entry.Description)
return strings.Contains(haystack, query)
title := entry.searchTitle
terms := strings.Fields(normalizedQuery)
for _, term := range terms {
if !strings.Contains(title, term) &&
!strings.Contains(entry.searchDescription, term) &&
!strings.Contains(entry.searchContent, term) {
return 0, false
}
}
score := 0
switch {
case title == normalizedQuery:
score += 1000
case strings.HasPrefix(title, normalizedQuery):
score += 500
case strings.Contains(title, normalizedQuery):
score += 300
}
for _, term := range terms {
if strings.Contains(title, term) {
score += 100
}
if strings.Contains(entry.searchDescription, term) {
score += 30
}
if strings.Contains(entry.searchContent, term) {
score++
}
}
return score, true
}
func normalizeDocSearchText(value string) string {
value = strings.ToLower(value)
value = strings.Map(func(r rune) rune {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
return r
case unicode.IsSpace(r):
return ' '
default:
return ' '
}
}, value)
return strings.Join(strings.Fields(value), " ")
}
func (m *docsModel) moveBrowserCursorUp() {
+56
View File
@@ -106,6 +106,62 @@ func TestDocsModelFilteringUpdatesVisibleEntries(t *testing.T) {
}
}
func TestDocsModelFilteringMatchesContentAndNormalizedTerms(t *testing.T) {
entries := []docEntry{
{
Name: "HTTP C2",
Content: "# HTTP C2\n\nUse the staging listener and HTTPS transports.",
Description: "Transport details",
},
{
Name: "Networking",
Content: "# Networking\n\nTCP and UDP details",
Description: "Network basics",
},
}
model := newDocsModel(entries)
model.applyWindowSize(120, 32)
model.startFiltering()
model.browserFilter.SetValue("https transport")
model.updateFilteredEntries(false)
if len(model.visibleEntries()) != 1 || model.visibleEntries()[0].Name != "HTTP C2" {
t.Fatalf("expected HTTP C2 to match multi-term query, got %+v", model.visibleEntries())
}
model.browserFilter.SetValue("https-transport")
model.updateFilteredEntries(false)
if len(model.visibleEntries()) != 1 || model.visibleEntries()[0].Name != "HTTP C2" {
t.Fatalf("expected punctuation normalization to match HTTP C2, got %+v", model.visibleEntries())
}
}
func TestDocsModelFilteringRanksTitleMatchesFirst(t *testing.T) {
entries := []docEntry{
{
Name: "Advanced Setup",
Content: "This section explains listener configuration.",
},
{
Name: "Listener Configuration",
Content: "Configure network settings.",
},
}
model := newDocsModel(entries)
model.applyWindowSize(120, 32)
model.startFiltering()
model.browserFilter.SetValue("listener")
model.updateFilteredEntries(false)
visible := model.visibleEntries()
if len(visible) != 2 {
t.Fatalf("expected both documents to match, got %+v", visible)
}
if visible[0].Name != "Listener Configuration" {
t.Fatalf("expected title match to rank first, got %q", visible[0].Name)
}
}
func TestDocsModelEnterMovesFocusToViewer(t *testing.T) {
model := newDocsModel(sampleDocs())
model.applyWindowSize(120, 32)
+5 -21
View File
@@ -396,10 +396,7 @@ func (m *editorModel) renderLine(index int) string {
return ""
}
prefixWidth := m.lineNumberWidth()
contentWidth := totalWidth - prefixWidth
if contentWidth < 0 {
contentWidth = 0
}
contentWidth := max(totalWidth-prefixWidth, 0)
prefix := m.linePrefix(index, prefixWidth)
if index >= len(m.lines) {
@@ -508,10 +505,7 @@ func (m *editorModel) lineNumberWidth() int {
if !m.showLineNumbers {
return 0
}
total := len(m.lines)
if total < 1 {
total = 1
}
total := max(len(m.lines), 1)
return len(fmt.Sprintf("%d", total)) + 1
}
@@ -559,10 +553,7 @@ func (m *editorModel) ensureCursorVisible() {
m.top = 0
}
width := m.textWidth() - m.lineNumberWidth()
if width < 1 {
width = 1
}
width := max(m.textWidth()-m.lineNumberWidth(), 1)
if m.col < m.left {
m.left = m.col
} else if m.col >= m.left+width {
@@ -1125,20 +1116,13 @@ func lastIndexOfRunes(haystack, needle []rune, end int) int {
return -1
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func baseName(path string) string {
if path == "" {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx != -1 {
return clean[idx+1:]
if _, after, ok := strings.CutLast(clean, "/"); ok {
return after
}
return path
}
+9 -10
View File
@@ -3,6 +3,8 @@ package exec
import (
"testing"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
@@ -87,14 +89,11 @@ func TestShouldConvertExecuteShellcodePE(t *testing.T) {
func newExecuteShellcodeFlagsTestCommand(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "execute-shellcode"}
cmd.Flags().Uint32("shellcode-entropy", 1, "")
cmd.Flags().Bool("shellcode-compress", false, "")
cmd.Flags().Uint32("shellcode-exitopt", 1, "")
cmd.Flags().Uint32("shellcode-bypass", 3, "")
cmd.Flags().Uint32("shellcode-headers", 1, "")
cmd.Flags().Bool("shellcode-thread", false, "")
cmd.Flags().Bool("shellcode-unicode", false, "")
cmd.Flags().Uint32("shellcode-oep", 0, "")
return cmd
for _, cmd := range Commands(&console.SliverClient{}) {
if cmd.Name() == consts.ExecuteShellcodeStr {
return cmd
}
}
t.Fatalf("missing %q command", consts.ExecuteShellcodeStr)
return nil
}
+5 -3
View File
@@ -234,10 +234,12 @@ func parseEnvPairs(envPairs []string) (map[string]string, error) {
func determineCommandName(command string) string {
commandName := strings.ReplaceAll(command, "\\", "/")
commandName = commandName[strings.LastIndex(commandName, "/")+1:]
if _, after, ok := strings.CutLast(commandName, "/"); ok {
commandName = after
}
if strings.Contains(commandName, ".") {
commandName = commandName[:strings.LastIndex(commandName, ".")]
if before, _, ok := strings.CutLast(commandName, "."); ok {
commandName = before
}
return commandName
+2 -3
View File
@@ -21,6 +21,7 @@ package extensions
import (
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
@@ -129,9 +130,7 @@ func getInstalledManifests() map[string]*ExtensionManifest {
// Name field.
func getTemporarilyLoadedManifests() map[string]*ExtensionManifest {
tempManifests := map[string]*ExtensionManifest{}
for name, manifest := range loadedManifests {
tempManifests[name] = manifest
}
maps.Copy(tempManifests, loadedManifests)
return tempManifests
}
+54
View File
@@ -0,0 +1,54 @@
package generate
import (
"strconv"
"testing"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
func TestCanariesCommandRegistered(t *testing.T) {
root := &cobra.Command{Use: "sliver"}
root.AddCommand(Commands(&console.SliverClient{})...)
cmd, _, err := root.Find([]string{consts.CanariesStr})
if err != nil {
t.Fatalf("find canaries command: %v", err)
}
if cmd.Parent() != root {
t.Fatal("canaries command is not registered at the root")
}
if cmd.Run == nil {
t.Fatal("canaries command has no handler")
}
if cmd.GroupID != consts.PayloadsHelpGroup {
t.Errorf("canaries group = %q, want %q", cmd.GroupID, consts.PayloadsHelpGroup)
}
for _, want := range []struct {
name string
shorthand string
valueType string
defaultValue string
}{
{name: "burned", shorthand: "b", valueType: "bool", defaultValue: "false"},
{name: "timeout", shorthand: "t", valueType: "int64", defaultValue: strconv.Itoa(flags.DefaultTimeout)},
} {
flag := cmd.Flags().Lookup(want.name)
if flag == nil {
t.Fatalf("canaries command is missing --%s", want.name)
}
if flag.Shorthand != want.shorthand {
t.Errorf("--%s shorthand = %q, want %q", want.name, flag.Shorthand, want.shorthand)
}
if flag.Value.Type() != want.valueType {
t.Errorf("--%s type = %q, want %q", want.name, flag.Value.Type(), want.valueType)
}
if flag.DefValue != want.defaultValue {
t.Errorf("--%s default = %q, want %q", want.name, flag.DefValue, want.defaultValue)
}
}
}
+5 -2
View File
@@ -288,13 +288,15 @@ func Commands(con *console.SliverClient) []*cobra.Command {
implantBuildsCmd.AddCommand(implantsRmCmd)
implantsStageCmd := &cobra.Command{
Use: consts.StageStr,
Use: consts.StageStr + " [implant-name] ...",
Short: "Serve a previously generated build",
Long: help.GetHelpFor([]string{consts.ImplantBuildsStr, consts.StageStr}),
Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) {
ImplantsStageCmd(cmd, con, args)
},
}
carapace.Gen(implantsStageCmd).PositionalAnyCompletion(ImplantBuildNameCompleter(con))
implantBuildsCmd.AddCommand(implantsStageCmd)
canariesCmd := &cobra.Command{
@@ -311,7 +313,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd}
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd, canariesCmd}
}
// coreImplantFlags binds all flags common to all sliver implant types.
@@ -368,6 +370,7 @@ func coreImplantFlags(name string, cmd *cobra.Command) {
bindSpoofMetadataFlag(f)
f.StringP("format", "f", "exe", "Specifies the output formats, valid values are: 'exe', 'shared' (for dynamic libraries), 'archive' (for Go c-archives), 'service' (see: `psexec` for more info) and 'shellcode' (windows, darwin/arm64, linux/amd64, linux/arm64)")
f.String("service-name", "", "service name to register the implant under (service format only)")
// Shellcode generation options:
// - Windows: Donut
+5
View File
@@ -405,6 +405,10 @@ func parseCompileFlags(cmd *cobra.Command, con *console.SliverClient) (string, *
// Default to exe
configFormat = clientpb.OutputFormat_EXECUTABLE
}
serviceName, _ := cmd.Flags().GetString("service-name")
if !isService {
serviceName = ""
}
targetOSF, _ := cmd.Flags().GetString("os")
targetOS := strings.ToLower(targetOSF)
@@ -541,6 +545,7 @@ func parseCompileFlags(cmd *cobra.Command, con *console.SliverClient) (string, *
IsService: isService,
IsShellcode: isShellcode,
Exports: exports,
ServiceName: serviceName,
RunAtLoad: runAtLoad,
NetGoEnabled: netGo,
+17
View File
@@ -37,6 +37,23 @@ func ImplantsStageCmd(cmd *cobra.Command, con *console.SliverClient, args []stri
return
}
if len(args) > 0 {
selected := []string{}
for _, name := range args {
if _, ok := builds.Configs[name]; !ok {
con.PrintErrorf("No implant build found with name '%s'\n", name)
return
}
selected = append(selected, name)
}
_, err = con.Rpc.StageImplantBuild(context.Background(), &clientpb.ImplantStageReq{Build: selected})
if err != nil {
con.PrintErrorf("Failed to serve implant %s\n", err)
return
}
return
}
options := []string{}
for name := range builds.Configs {
options = append(options, name)
+4 -3
View File
@@ -22,6 +22,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
@@ -33,12 +34,12 @@ import (
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/util"
"github.com/gofrs/uuid"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"google.golang.org/protobuf/proto"
uuid "uuid"
)
// TrafficEncodersCmd - Generate traffic encoders command implementation.
@@ -67,7 +68,7 @@ func DisplayTrafficEncoders(encoderMap *clientpb.TrafficEncoderMap, con *console
})
allIDs := []uint64{}
for _, encoder := range encoderMap.Encoders {
isDuplicate := util.Contains(allIDs, encoder.ID)
isDuplicate := slices.Contains(allIDs, encoder.ID)
allIDs = append(allIDs, encoder.ID)
name := encoder.Wasm.Name
if isDuplicate {
@@ -94,7 +95,7 @@ func TrafficEncodersAddCmd(cmd *cobra.Command, con *console.SliverClient, args [
}
skipTests, _ := cmd.Flags().GetBool("skip-tests")
testID := uuid.Must(uuid.NewV4()).String()
testID := uuid.NewV4().String()
trafficEncoder := &clientpb.TrafficEncoder{
Wasm: &commonpb.File{
Name: filepath.Base(args[0]),
+3 -6
View File
@@ -540,10 +540,7 @@ func (m *editorModel) ensureCursorVisible() {
if m.top < 0 {
m.top = 0
}
maxTop := m.totalLines() - height
if maxTop < 0 {
maxTop = 0
}
maxTop := max(m.totalLines()-height, 0)
if m.top > maxTop {
m.top = maxTop
}
@@ -725,8 +722,8 @@ func baseName(path string) string {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx != -1 {
return clean[idx+1:]
if _, after, ok := strings.CutLast(clean, "/"); ok {
return after
}
return path
}
+1 -4
View File
@@ -56,10 +56,7 @@ func HexEditCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
return
}
fetchSize := maxSize + 1
if fetchSize < maxSize {
fetchSize = maxSize
}
fetchSize := max(maxSize+1, maxSize)
ctx, cancel := con.GrpcContext(cmd)
defer cancel()
+2 -2
View File
@@ -88,8 +88,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
@@ -0,0 +1,2 @@
# Preserve pinned upstream script bytes for SHA-256 verification on every OS.
*.cna -text
+2 -8
View File
@@ -58,17 +58,11 @@ func RegCreateKeyCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
if strings.Contains(regPath, "/") {
regPath = strings.ReplaceAll(regPath, "/", "\\")
}
pathBaseIdx := strings.LastIndex(regPath, `\`)
if pathBaseIdx < 0 {
finalPath, key, ok := strings.CutLast(regPath, `\`)
if !ok {
con.PrintErrorf("invalid path: %s", regPath)
return
}
if len(regPath) < pathBaseIdx+1 {
con.PrintErrorf("invalid path: %s", regPath)
return
}
finalPath := regPath[:pathBaseIdx]
key := regPath[pathBaseIdx+1:]
createKey, err := con.Rpc.RegistryCreateKey(context.Background(), &sliverpb.RegistryCreateKeyReq{
Hive: hive,
+2 -8
View File
@@ -58,17 +58,11 @@ func RegDeleteKeyCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
if strings.Contains(regPath, "/") {
regPath = strings.ReplaceAll(regPath, "/", "\\")
}
pathBaseIdx := strings.LastIndex(regPath, `\`)
if pathBaseIdx < 0 {
finalPath, key, ok := strings.CutLast(regPath, `\`)
if !ok {
con.PrintErrorf("invalid path: %s", regPath)
return
}
if len(regPath) < pathBaseIdx+1 {
con.PrintErrorf("invalid path: %s", regPath)
return
}
finalPath := regPath[:pathBaseIdx]
key := regPath[pathBaseIdx+1:]
deleteKey, err := con.Rpc.RegistryDeleteKey(context.Background(), &sliverpb.RegistryDeleteKeyReq{
Hive: hive,
+25 -9
View File
@@ -20,6 +20,7 @@ package registry
import (
"context"
"encoding/binary"
"fmt"
"os"
"path/filepath"
@@ -111,17 +112,12 @@ func RegReadCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if strings.Contains(regPath, "/") {
regPath = strings.ReplaceAll(regPath, "/", "\\")
}
pathBaseIdx := strings.LastIndex(regPath, `\`)
if pathBaseIdx < 0 {
var ok bool
finalPath, key, ok = strings.CutLast(regPath, `\`)
if !ok {
con.PrintErrorf("invalid path: %s", regPath)
return
}
if len(regPath) < pathBaseIdx+1 {
con.PrintErrorf("invalid path: %s", regPath)
return
}
finalPath = regPath[:pathBaseIdx]
key = regPath[pathBaseIdx+1:]
regRead, err := con.Rpc.RegistryRead(context.Background(), &sliverpb.RegistryReadReq{
Hive: hive,
@@ -156,7 +152,27 @@ func PrintRegRead(regRead *sliverpb.RegistryRead, con *console.SliverClient) {
con.PrintErrorf("%s\n", regRead.Response.Err)
return
}
con.Println(regRead.Value)
con.Println(formatRegRead(regRead))
}
func formatRegRead(regRead *sliverpb.RegistryRead) string {
switch regRead.Type {
case sliverpb.RegistryType_Binary:
return fmt.Sprintf("%v", regRead.Binary)
case sliverpb.RegistryType_DWORD:
if len(regRead.Binary) == 4 {
return fmt.Sprintf("0x%08x", binary.LittleEndian.Uint32(regRead.Binary))
}
return fmt.Sprintf("%v", regRead.Binary)
case sliverpb.RegistryType_QWORD:
if len(regRead.Binary) == 8 {
return fmt.Sprintf("0x%08x", binary.LittleEndian.Uint64(regRead.Binary))
}
return fmt.Sprintf("%v", regRead.Binary)
default:
// Unknown preserves compatibility with responses from older implants.
return regRead.Value
}
}
func writeHiveDump(data []byte, encoder string, fileName string, saveLoot bool, lootName string, lootType string, lootFileName string, con *console.SliverClient) {
+54
View File
@@ -0,0 +1,54 @@
package registry
import (
"testing"
"github.com/bishopfox/sliver/protobuf/sliverpb"
)
func TestFormatRegRead(t *testing.T) {
tests := []struct {
name string
response *sliverpb.RegistryRead
want string
}{
{
name: "legacy response",
response: &sliverpb.RegistryRead{Value: "legacy value"},
want: "legacy value",
},
{
name: "string",
response: &sliverpb.RegistryRead{Type: sliverpb.RegistryType_String, Value: "string value"},
want: "string value",
},
{
name: "binary",
response: &sliverpb.RegistryRead{Type: sliverpb.RegistryType_Binary, Binary: []byte{0x00, 0x7f, 0x80, 0xff}},
want: "[0 127 128 255]",
},
{
name: "dword",
response: &sliverpb.RegistryRead{Type: sliverpb.RegistryType_DWORD, Binary: []byte{0xde, 0xc0, 0x17, 0x5a}},
want: "0x5a17c0de",
},
{
name: "qword",
response: &sliverpb.RegistryRead{Type: sliverpb.RegistryType_QWORD, Binary: []byte{0xef, 0xcd, 0xab, 0x89, 0x67, 0x45, 0x23, 0x01}},
want: "0x123456789abcdef",
},
{
name: "malformed dword",
response: &sliverpb.RegistryRead{Type: sliverpb.RegistryType_DWORD, Binary: []byte{0x01}},
want: "[1]",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := formatRegRead(test.response); got != test.want {
t.Fatalf("formatRegRead() = %q, want %q", got, test.want)
}
})
}
}
+2 -8
View File
@@ -76,17 +76,11 @@ func RegWriteCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if strings.Contains(regPath, "/") {
regPath = strings.ReplaceAll(regPath, "/", "\\")
}
pathBaseIdx := strings.LastIndex(regPath, `\`)
if pathBaseIdx < 0 {
finalPath, key, ok := strings.CutLast(regPath, `\`)
if !ok {
con.PrintErrorf("invalid path: %s", regPath)
return
}
if len(regPath) < pathBaseIdx+1 {
con.PrintErrorf("invalid path: %s", regPath)
return
}
finalPath := regPath[:pathBaseIdx]
key := regPath[pathBaseIdx+1:]
switch valType {
case sliverpb.RegistryTypeBinary:
var (
+2 -2
View File
@@ -125,8 +125,8 @@ func filterCompletionValues(values []string, prefix string) []string {
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
candidate = value[:tab]
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
+124 -85
View File
@@ -27,6 +27,7 @@ import (
"github.com/bishopfox/sliver/client/command/kill"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
@@ -131,51 +132,29 @@ func PrintSessions(sessions map[string]*clientpb.Session, filter string, filterR
if err != nil {
width = 999
}
tw := renderSessions(sessions, filter, filterRegex, con, width, time.Now())
con.Printf("%s\n", tw.Render())
}
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
wideTermWidth := con.Settings.SmallTermWidth < width
windowsSessionInList := false
func renderSessions(sessions map[string]*clientpb.Session, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, width int, now time.Time) table.Writer {
hasIntegrity := false
for _, session := range sessions {
if session.OS == "windows" {
windowsSessionInList = true
hasIntegrity = true
break
}
}
if wideTermWidth {
if windowsSessionInList {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Integrity",
"Operating System",
"Locale",
"Last Message",
"Health",
})
} else {
tw.AppendHeader(table.Row{
"ID",
"Name",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
"Operating System",
"Locale",
"Last Message",
"Health",
})
}
return targettable.Fit(width, hasIntegrity, func(layout targettable.Layout) table.Writer {
return buildSessionsTable(sessions, filter, filterRegex, con, layout, hasIntegrity, now)
})
}
} else {
func buildSessionsTable(sessions map[string]*clientpb.Session, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, layout targettable.Layout, hasIntegrity bool, now time.Time) table.Writer {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
if layout.Compact {
tw.AppendHeader(table.Row{
"ID",
"Transport",
@@ -185,31 +164,98 @@ func PrintSessions(sessions map[string]*clientpb.Session, filter string, filterR
"Operating System",
"Health",
})
} else {
header := table.Row{
"ID",
"Name",
"Transport",
"Remote Address",
"Hostname",
"Username",
"Process (PID)",
}
if hasIntegrity && layout.IncludeIntegrity {
header = append(header, "Integrity")
}
header = append(header, "Operating System")
if layout.IncludeLocale {
header = append(header, "Locale")
}
header = append(header, "Last Message", "Health")
tw.AppendHeader(header)
}
tw.SortBy([]table.SortBy{
{Name: "ID", Mode: table.Asc},
})
for _, session := range sessions {
style := console.StyleNormal
if con.ActiveTarget.GetSession() != nil && con.ActiveTarget.GetSession().ID == session.ID {
style = console.StyleGreen
activeSessionID := ""
if con.ActiveTarget != nil {
if activeSession := con.ActiveTarget.GetSession(); activeSession != nil {
activeSessionID = activeSession.ID
}
var SessionHealth string
}
for _, session := range sessions {
username := strings.TrimPrefix(session.Username, session.Hostname+"\\") // For non-AD Windows users
lastMessage := formatRelativeTime(time.Unix(session.LastCheckin, 0), now)
health := "[ALIVE]"
if session.IsDead {
SessionHealth = console.StyleBoldRed.Render("[DEAD]")
} else {
SessionHealth = console.StyleBoldGreen.Render("[ALIVE]")
health = "[DEAD]"
}
burned := ""
if session.Burned {
burned = "🔥"
}
username := strings.TrimPrefix(session.Username, session.Hostname+"\\") // For non-AD Windows users
canonicalEntries := []string{
ShortSessionID(session.ID),
session.Name,
session.Transport,
session.RemoteAddress,
session.Hostname,
username,
fmt.Sprintf("%s (%d)", session.Filename, session.PID),
}
if hasIntegrity {
canonicalEntries = append(canonicalEntries, session.Integrity)
}
canonicalEntries = append(canonicalEntries,
fmt.Sprintf("%s/%s", session.OS, session.Arch),
session.Locale,
lastMessage,
burned+health,
)
if !matchesSessionFilter(canonicalEntries, filter, filterRegex) {
continue
}
style := console.StyleNormal
if activeSessionID == session.ID {
style = console.StyleGreen
}
var sessionHealth string
if session.IsDead {
sessionHealth = console.StyleBoldRed.Render("[DEAD]")
} else {
sessionHealth = console.StyleBoldGreen.Render("[ALIVE]")
}
var rowEntries []string
if wideTermWidth {
if layout.Compact {
rowEntries = []string{
style.Render(ShortSessionID(session.ID)),
style.Render(session.Transport),
style.Render(session.RemoteAddress),
style.Render(session.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)),
burned + sessionHealth,
}
} else {
processName := session.Filename
if layout.ProcessBasename {
processName = targettable.ProcessName(processName)
}
rowEntries = []string{
style.Render(ShortSessionID(session.ID)),
style.Render(session.Name),
@@ -217,57 +263,50 @@ func PrintSessions(sessions map[string]*clientpb.Session, filter string, filterR
style.Render(session.RemoteAddress),
style.Render(session.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s (%d)", session.Filename, session.PID)),
style.Render(fmt.Sprintf("%s (%d)", processName, session.PID)),
}
if windowsSessionInList {
if hasIntegrity && layout.IncludeIntegrity {
rowEntries = append(rowEntries, style.Render(session.Integrity))
}
rowEntries = append(rowEntries, []string{
style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)),
style.Render(session.Locale),
con.FormatDateDelta(time.Unix(session.LastCheckin, 0), wideTermWidth, false),
burned + SessionHealth,
}...)
} else {
rowEntries = []string{
style.Render(ShortSessionID(session.ID)),
style.Render(session.Transport),
style.Render(session.RemoteAddress),
style.Render(session.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)),
burned + SessionHealth,
rowEntries = append(rowEntries, style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)))
if layout.IncludeLocale {
rowEntries = append(rowEntries, style.Render(session.Locale))
}
rowEntries = append(rowEntries, lastMessage, burned+sessionHealth)
}
// Build the row struct
row := table.Row{}
for _, entry := range rowEntries {
row = append(row, entry)
}
// Apply filters if any
if filter == "" && filterRegex == nil {
tw.AppendRow(row)
} else {
for _, rowEntry := range rowEntries {
if filter != "" {
if strings.Contains(rowEntry, filter) {
tw.AppendRow(row)
break
}
}
if filterRegex != nil {
if filterRegex.MatchString(rowEntry) {
tw.AppendRow(row)
break
}
}
}
}
tw.AppendRow(row)
}
con.Printf("%s\n", tw.Render())
return tw
}
func matchesSessionFilter(entries []string, filter string, filterRegex *regexp.Regexp) bool {
if filter == "" && filterRegex == nil {
return true
}
for _, entry := range entries {
if filter != "" && strings.Contains(entry, filter) {
return true
}
if filterRegex != nil && filterRegex.MatchString(entry) {
return true
}
}
return false
}
func formatRelativeTime(t time.Time, now time.Time) string {
if t.After(now) {
return fmt.Sprintf("in %s", t.Sub(now).Round(time.Second))
}
return fmt.Sprintf("%s ago", now.Sub(t).Round(time.Second))
}
// ShortSessionID - Shorten the session ID.
+276
View File
@@ -0,0 +1,276 @@
package sessions
import (
"regexp"
"strings"
"testing"
"time"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/settings"
"github.com/bishopfox/sliver/client/command/targettable"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
)
var sessionsTestNow = time.Date(2026, time.September, 3, 14, 25, 41, 0, time.UTC)
func TestRenderSessionsKeepsFullProcessPathWhenItFits(t *testing.T) {
session := sessionsTestSession("windows", `C:\Users\vagrant\AppData\Local\Temp\sliver.exe`)
sessions := sessionMap(session)
con := sessionsTestConsole()
hasIntegrity := true
fullLayout := targettable.Layout{IncludeIntegrity: true, IncludeLocale: true}
width := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, fullLayout, hasIntegrity, sessionsTestNow))
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, width, sessionsTestNow))
if !strings.Contains(rendered, session.Filename+" (456)") {
t.Fatalf("full process path was not retained:\n%s", rendered)
}
assertSessionTableFits(t, rendered, width)
}
func TestRenderSessionsUsesTargetProcessBasenameBeforeOmittingColumns(t *testing.T) {
tests := []struct {
name string
operatingSys string
filename string
basename string
hasIntegrity bool
}{
{
name: "windows path",
operatingSys: "windows",
filename: `C:\Users\vagrant\AppData\Local\Temp\a-very-long-directory-name\sliver.exe`,
basename: "sliver.exe",
hasIntegrity: true,
},
{
name: "POSIX path",
operatingSys: "linux",
filename: "/opt/sliver/a-very-long-directory-name/implant/sliver",
basename: "sliver",
hasIntegrity: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
session := sessionsTestSession(test.operatingSys, test.filename)
sessions := sessionMap(session)
con := sessionsTestConsole()
fullLayout := targettable.Layout{IncludeIntegrity: test.hasIntegrity, IncludeLocale: true}
basenameLayout := targettable.Layout{ProcessBasename: true, IncludeIntegrity: test.hasIntegrity, IncludeLocale: true}
fullWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, fullLayout, test.hasIntegrity, sessionsTestNow))
basenameWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, basenameLayout, test.hasIntegrity, sessionsTestNow))
if basenameWidth >= fullWidth {
t.Fatalf("test fixture does not make basename table narrower: basename=%d full=%d", basenameWidth, fullWidth)
}
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, basenameWidth, sessionsTestNow))
if !strings.Contains(rendered, test.basename+" (456)") {
t.Fatalf("process basename was not rendered:\n%s", rendered)
}
if strings.Contains(rendered, test.filename) {
t.Fatalf("full process path was rendered after basename fallback:\n%s", rendered)
}
if !strings.Contains(rendered, "Locale") {
t.Fatalf("Locale was omitted before it was needed:\n%s", rendered)
}
if test.hasIntegrity && !strings.Contains(rendered, "Integrity") {
t.Fatalf("Integrity was omitted before it was needed:\n%s", rendered)
}
assertSessionTableFits(t, rendered, basenameWidth)
})
}
}
func TestRenderSessionsOmitsIntegrityBeforeLocale(t *testing.T) {
session := sessionsTestSession("windows", `C:\Users\vagrant\AppData\Local\Temp\a-very-long-directory-name\sliver.exe`)
session.Integrity = "System-With-An-Extremely-Long-Integrity-Label"
session.Locale = "extraordinarily-long-locale"
sessions := sessionMap(session)
con := sessionsTestConsole()
hasIntegrity := true
withoutIntegrity := targettable.Layout{ProcessBasename: true, IncludeLocale: true}
withoutIntegrityOrLocale := targettable.Layout{ProcessBasename: true}
withoutIntegrityWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, withoutIntegrity, hasIntegrity, sessionsTestNow))
withoutIntegrityOrLocaleWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, withoutIntegrityOrLocale, hasIntegrity, sessionsTestNow))
if withoutIntegrityOrLocaleWidth >= withoutIntegrityWidth {
t.Fatalf("test fixture does not make Locale omission narrower: without Locale=%d with Locale=%d", withoutIntegrityOrLocaleWidth, withoutIntegrityWidth)
}
t.Run("Integrity omitted first", func(t *testing.T) {
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, withoutIntegrityWidth, sessionsTestNow))
if strings.Contains(rendered, "Integrity") || strings.Contains(rendered, session.Integrity) {
t.Fatalf("Integrity column was retained:\n%s", rendered)
}
if !strings.Contains(rendered, "Locale") || !strings.Contains(rendered, session.Locale) {
t.Fatalf("Locale should remain after Integrity is omitted:\n%s", rendered)
}
if !strings.Contains(rendered, "Process (PID)") {
t.Fatalf("renderer unexpectedly used the compact fallback:\n%s", rendered)
}
assertSessionTableFits(t, rendered, withoutIntegrityWidth)
})
t.Run("Locale omitted second", func(t *testing.T) {
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, withoutIntegrityOrLocaleWidth, sessionsTestNow))
if strings.Contains(rendered, "Integrity") || strings.Contains(rendered, "Locale") {
t.Fatalf("optional columns were retained:\n%s", rendered)
}
if !strings.Contains(rendered, "Process (PID)") {
t.Fatalf("renderer unexpectedly used the compact fallback:\n%s", rendered)
}
assertSessionTableFits(t, rendered, withoutIntegrityOrLocaleWidth)
})
}
func TestRenderSessionsUsesDirectionalRelativeLastMessage(t *testing.T) {
tests := []struct {
name string
lastCheckin time.Time
want string
}{
{name: "past", lastCheckin: sessionsTestNow.Add(-36 * time.Second), want: "36s ago"},
{name: "future clock skew", lastCheckin: sessionsTestNow.Add(5 * time.Second), want: "in 5s"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
session := sessionsTestSession("linux", "/opt/sliver/sliver")
session.LastCheckin = test.lastCheckin.Unix()
rendered := renderedSessionTable(renderSessions(sessionMap(session), "", nil, sessionsTestConsole(), 10_000, sessionsTestNow))
if !strings.Contains(rendered, test.want) {
t.Fatalf("relative Last Message %q was not rendered:\n%s", test.want, rendered)
}
if strings.Contains(rendered, "2026") || strings.Contains(rendered, test.lastCheckin.Format(time.UnixDate)) {
t.Fatalf("Last Message unexpectedly contains a calendar date:\n%s", rendered)
}
})
}
}
func TestRenderSessionsOmitsIntegrityForNonWindowsSessions(t *testing.T) {
session := sessionsTestSession("linux", "/opt/sliver/sliver")
rendered := renderedSessionTable(renderSessions(sessionMap(session), "", nil, sessionsTestConsole(), 10_000, sessionsTestNow))
if strings.Contains(rendered, "Integrity") {
t.Fatalf("non-Windows sessions table contains an Integrity column:\n%s", rendered)
}
if !strings.Contains(rendered, "Locale") {
t.Fatalf("wide non-Windows sessions table is missing Locale:\n%s", rendered)
}
}
func TestRenderSessionsCompactFallbackPreservesLegacyColumns(t *testing.T) {
session := sessionsTestSession("windows", `C:\Users\vagrant\AppData\Local\Temp\sliver.exe`)
sessions := sessionMap(session)
con := sessionsTestConsole()
compactWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, targettable.Layout{Compact: true}, true, sessionsTestNow))
withoutOptionalWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, targettable.Layout{ProcessBasename: true}, true, sessionsTestNow))
if compactWidth >= withoutOptionalWidth {
t.Fatalf("test fixture does not require compact fallback: compact=%d detailed=%d", compactWidth, withoutOptionalWidth)
}
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, compactWidth, sessionsTestNow))
header := strings.SplitN(rendered, "\n", 2)[0]
for _, retained := range []string{"ID", "Transport", "Remote Address", "Hostname", "Username", "Operating System", "Health"} {
if !strings.Contains(header, retained) {
t.Fatalf("compact fallback is missing legacy column %q:\n%s", retained, rendered)
}
}
for _, omitted := range []string{"Process (PID)", "Integrity", "Locale", "Last Message"} {
if strings.Contains(header, omitted) {
t.Fatalf("compact fallback unexpectedly contains column %q:\n%s", omitted, rendered)
}
}
if strings.Contains(rendered, session.Name) {
t.Fatalf("compact fallback unexpectedly contains the session name:\n%s", rendered)
}
assertSessionTableFits(t, rendered, compactWidth)
}
func TestRenderSessionsFiltersCanonicalValuesHiddenByLayout(t *testing.T) {
session := sessionsTestSession("windows", `C:\hidden-directory-token\sliver.exe`)
session.Integrity = "hidden-integrity-token"
session.Locale = "hidden-locale-token"
sessions := sessionMap(session)
con := sessionsTestConsole()
layout := targettable.Layout{ProcessBasename: true}
width := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, layout, true, sessionsTestNow))
tests := []struct {
name string
filter string
filterRegex *regexp.Regexp
hiddenValue string
}{
{name: "substring in shortened path", filter: "hidden-directory-token", hiddenValue: "hidden-directory-token"},
{name: "regexp in omitted columns", filterRegex: regexp.MustCompile(`hidden-(?:integrity|locale)-token`), hiddenValue: "hidden-integrity-token"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rendered := renderedSessionTable(renderSessions(sessions, test.filter, test.filterRegex, con, width, sessionsTestNow))
if !strings.Contains(rendered, ShortSessionID(session.ID)) {
t.Fatalf("session matching a canonical value was filtered out:\n%s", rendered)
}
if strings.Contains(rendered, test.hiddenValue) {
t.Fatalf("hidden canonical value leaked into rendered layout:\n%s", rendered)
}
if !strings.Contains(rendered, "Process (PID)") || strings.Contains(rendered, "Locale") || strings.Contains(rendered, "Integrity") {
t.Fatalf("test did not exercise the shortened, omitted-column layout:\n%s", rendered)
}
assertSessionTableFits(t, rendered, width)
})
}
}
func sessionsTestSession(operatingSystem string, filename string) *clientpb.Session {
return &clientpb.Session{
ID: "01234567-89ab-cdef-0123-456789abcdef",
Name: "winterfell-session",
Transport: "mtls",
RemoteAddress: "10.13.37.10:61839",
Hostname: "winterfell",
Username: `winterfell\vagrant`,
Filename: filename,
PID: 456,
Integrity: "High",
OS: operatingSystem,
Arch: "amd64",
Locale: "en-US",
LastCheckin: sessionsTestNow.Add(-36 * time.Second).Unix(),
}
}
func sessionsTestConsole() *console.SliverClient {
return &console.SliverClient{
Settings: &assets.ClientSettings{TableStyle: settings.SliverDefault.Name},
}
}
func sessionMap(sessions ...*clientpb.Session) map[string]*clientpb.Session {
result := make(map[string]*clientpb.Session, len(sessions))
for _, session := range sessions {
result[session.ID] = session
}
return result
}
func renderedSessionTable(writer table.Writer) string {
return text.StripEscape(writer.Render())
}
func renderedSessionTableWidth(writer table.Writer) int {
return text.LongestLineLen(writer.Render())
}
func assertSessionTableFits(t *testing.T, rendered string, width int) {
t.Helper()
if got := text.LongestLineLen(rendered); got > width {
t.Fatalf("rendered table width %d exceeds terminal width %d:\n%s", got, width, rendered)
}
}
+4 -4
View File
@@ -43,8 +43,9 @@ func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient, remoteOS st
stdinQueue := make(chan shellInputChunk, stdinQueueDepth)
stopWriter := make(chan struct{})
var writerWG sync.WaitGroup
writerWG.Add(1)
go runShellInputWriter(tunnel, stdinQueue, stopWriter, &writerWG)
writerWG.Go(func() {
runShellInputWriter(tunnel, stdinQueue, stopWriter)
})
defer func() {
close(stopWriter)
@@ -96,8 +97,7 @@ func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient, remoteOS st
return detached, closeRequested
}
func runShellInputWriter(tunnel *core.TunnelIO, stdinQueue <-chan shellInputChunk, stop <-chan struct{}, writerWG *sync.WaitGroup) {
defer writerWG.Done()
func runShellInputWriter(tunnel *core.TunnelIO, stdinQueue <-chan shellInputChunk, stop <-chan struct{}) {
defer recoverShellInputWriter()
for {
+2 -4
View File
@@ -73,13 +73,11 @@ func (s *managedShell) SetOutput(w io.Writer) {
}
func (s *managedShell) startReader(remove func()) {
s.readerWG.Add(1)
go func() {
defer s.readerWG.Done()
s.readerWG.Go(func() {
_, err := io.Copy(s.output, s.tunnel)
log.Printf("Shell tunnel reader (id=%d tunnel=%d) exited: %v", s.ID, s.TunnelID, err)
remove()
}()
})
}
// swapWriter is an io.Writer whose underlying destination can be swapped at runtime.
+65
View File
@@ -0,0 +1,65 @@
// Package targettable contains shared layout helpers for the sessions and
// beacons tables.
package targettable
import (
"path"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
)
// Layout describes one rendering candidate for a target table.
type Layout struct {
ProcessBasename bool
IncludeIntegrity bool
IncludeLocale bool
Compact bool
}
// Fit returns the first table that fits within width. Layouts are tried from
// most to least detailed: full process path, process basename, no Integrity,
// no Locale, and finally the legacy compact layout bounded to width. When no
// Integrity column exists, that omission step is skipped. A non-positive width
// is treated as unknown and keeps the full layout.
func Fit(width int, hasIntegrity bool, build func(Layout) table.Writer) table.Writer {
layout := Layout{
IncludeIntegrity: hasIntegrity,
IncludeLocale: true,
}
if candidate := build(layout); width <= 0 || text.LongestLineLen(candidate.Render()) <= width {
return candidate
}
layout.ProcessBasename = true
if candidate := build(layout); text.LongestLineLen(candidate.Render()) <= width {
return candidate
}
if hasIntegrity {
layout.IncludeIntegrity = false
if candidate := build(layout); text.LongestLineLen(candidate.Render()) <= width {
return candidate
}
}
layout.IncludeLocale = false
if candidate := build(layout); text.LongestLineLen(candidate.Render()) <= width {
return candidate
}
compact := build(Layout{Compact: true})
compact.Style().Size.WidthMax = width
return compact
}
// ProcessName returns the final path component of a target process path.
// Target paths can use a different separator than the client host, so Windows
// separators are normalized before applying path.Base.
func ProcessName(processPath string) string {
if processPath == "" {
return ""
}
return path.Base(strings.ReplaceAll(processPath, `\`, "/"))
}
@@ -0,0 +1,215 @@
package targettable
import (
"strings"
"testing"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
)
func TestFitSelectsLayoutsInOrder(t *testing.T) {
tests := []struct {
name string
width int
hasIntegrity bool
wantWidth int
wantCalls []Layout
}{
{
name: "full path exact fit",
width: 50,
hasIntegrity: true,
wantWidth: 50,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
},
},
{
name: "process basename",
width: 40,
hasIntegrity: true,
wantWidth: 40,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeIntegrity: true, IncludeLocale: true},
},
},
{
name: "omit integrity",
width: 30,
hasIntegrity: true,
wantWidth: 30,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeLocale: true},
},
},
{
name: "omit locale",
width: 20,
hasIntegrity: true,
wantWidth: 20,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeLocale: true},
{ProcessBasename: true},
},
},
{
name: "compact fallback",
width: 19,
hasIntegrity: true,
wantWidth: 10,
wantCalls: []Layout{
{IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeIntegrity: true, IncludeLocale: true},
{ProcessBasename: true, IncludeLocale: true},
{ProcessBasename: true},
{Compact: true},
},
},
{
name: "skip absent integrity",
width: 20,
hasIntegrity: false,
wantWidth: 20,
wantCalls: []Layout{
{IncludeLocale: true},
{ProcessBasename: true, IncludeLocale: true},
{ProcessBasename: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
calls := []Layout{}
got := Fit(tt.width, tt.hasIntegrity, func(layout Layout) table.Writer {
calls = append(calls, layout)
return tableWithWidth(layoutWidth(layout))
})
if gotWidth := text.LongestLineLen(got.Render()); gotWidth != tt.wantWidth {
t.Fatalf("selected table width = %d, want %d", gotWidth, tt.wantWidth)
}
if len(calls) != len(tt.wantCalls) {
t.Fatalf("build calls = %#v, want %#v", calls, tt.wantCalls)
}
for index := range calls {
if calls[index] != tt.wantCalls[index] {
t.Fatalf("build call %d = %#v, want %#v", index, calls[index], tt.wantCalls[index])
}
}
})
}
}
func TestFitUsesANSIVisibleWidthAndAcceptsExactFit(t *testing.T) {
const colored = "\x1b[31m12345\x1b[0m"
buildCalls := 0
build := func(Layout) table.Writer {
buildCalls++
tw := table.NewWriter()
tw.SetStyle(singleCellStyle())
tw.AppendRow(table.Row{colored})
return tw
}
exactWidth := text.LongestLineLen(build(Layout{}).Render())
buildCalls = 0
got := Fit(exactWidth, true, build)
if buildCalls != 1 {
t.Fatalf("build calls = %d, want 1 for an ANSI-aware exact fit", buildCalls)
}
if gotWidth := text.LongestLineLen(got.Render()); gotWidth != exactWidth {
t.Fatalf("selected table width = %d, want %d", gotWidth, exactWidth)
}
}
func TestFitBoundsOversizedCompactFallback(t *testing.T) {
const width = 20
got := Fit(width, true, func(layout Layout) table.Writer {
if layout.Compact {
return tableWithWidth(40)
}
return tableWithWidth(50)
})
if gotWidth := text.LongestLineLen(got.Render()); gotWidth > width {
t.Fatalf("compact table width = %d, want at most %d", gotWidth, width)
}
}
func TestFitTreatsUnknownWidthAsUnlimited(t *testing.T) {
buildCalls := 0
got := Fit(0, true, func(layout Layout) table.Writer {
buildCalls++
return tableWithWidth(layoutWidth(layout))
})
if buildCalls != 1 {
t.Fatalf("build calls = %d, want 1 for an unknown terminal width", buildCalls)
}
if gotWidth := text.LongestLineLen(got.Render()); gotWidth != 50 {
t.Fatalf("selected table width = %d, want full-width table of 50", gotWidth)
}
}
func TestProcessName(t *testing.T) {
tests := []struct {
name string
path string
want string
}{
{name: "windows", path: `C:\Users\vagrant\AppData\Local\Temp\sliver.exe`, want: "sliver.exe"},
{name: "UNC", path: `\\server\share\sliver.exe`, want: "sliver.exe"},
{name: "POSIX", path: "/usr/local/bin/sliver", want: "sliver"},
{name: "mixed separators", path: `C:\Temp/subdir\sliver.exe`, want: "sliver.exe"},
{name: "base name", path: "sliver.exe", want: "sliver.exe"},
{name: "empty", path: "", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ProcessName(tt.path); got != tt.want {
t.Fatalf("ProcessName(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func layoutWidth(layout Layout) int {
switch {
case layout.Compact:
return 10
case !layout.ProcessBasename:
return 50
case layout.IncludeIntegrity:
return 40
case layout.IncludeLocale:
return 30
default:
return 20
}
}
func tableWithWidth(width int) table.Writer {
tw := table.NewWriter()
tw.SetStyle(singleCellStyle())
tw.AppendRow(table.Row{strings.Repeat("x", width)})
return tw
}
func singleCellStyle() table.Style {
return table.Style{
Format: table.FormatOptions{
Footer: text.FormatDefault,
Header: text.FormatDefault,
Row: text.FormatDefault,
},
}
}
Loaded 100 of 2210 files, more files were not shown because too many files have changed in this diff. Show more