Compare commits

...
Author SHA1 Message Date
moloch-- da3347c8c3 Strip non-debug plain implant executables 2026-10-22 12:19:12 -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-- b46df4b05e ci: remove 386 targets from E2E matrices 2026-09-03 07:22:30 -07:00
Joe cbc9200649 Merge pull request #2359 from BishopFox/feat/portfwd-socks5-e2e
feat: harden portfwd and SOCKS5 tunnels
2026-09-02 22:09:53 -07:00
moloch-- a0bf009c06 refactor tunnel lifecycle bookkeeping 2026-09-02 19:00:28 -07:00
moloch-- 46f516fa3a feat: harden portfwd and SOCKS5 tunnels
Add negotiated bounded flow control and exact-generation tunnel lifecycle handling.

Preserve legacy implant compatibility, serialize HTTP tunnel responses, and add manual local/Proxmox E2E plus fuzz coverage for real traffic and RDP.
2026-09-02 15:09:29 -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
Joe b2c0979b63 Merge pull request #2356 from BishopFox/feat/rportfwd-authorization
Rework reverse port forwarding lifecycle and E2E coverage
2026-09-01 16:21:51 -07:00
moloch-- ea5e1412b2 fix: pass cross-platform lint checks 2026-09-01 13:19:51 -07:00
moloch-- bd948b8ab1 test: preserve pinned OPFOR corpus bytes 2026-09-01 11:01:04 -07:00
moloch-- 937a74d597 test: make e2e process cleanup idempotent 2026-09-01 09:20:24 -07:00
moloch-- e747187dda ci: fix reverse port forward workflow context 2026-09-01 09:05:15 -07:00
moloch-- ec992dea76 fix: complete reverse port forward hardening 2026-09-01 09:03:30 -07:00
moloch-- 544c872881 test: integrate reverse port forwarding into e2e 2026-08-31 12:40:48 -07:00
moloch-- 4701b895b7 fix: harden reverse port forward authorization 2026-08-31 12:22:36 -07:00
Joe 18c56ec3c5 Merge pull request #2353 from BishopFox/fix/shell-win
Improvements to windows/shell handling
2026-08-31 09:57:48 -07:00
moloch-- 4b12c80534 fix(shell): repair Windows input cancellation and CI 2026-08-31 08:04:48 -07: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
Joe 67c6d0d356 Merge branch 'master' into fix/shell-win 2026-08-30 10:06:50 -07:00
Joe 5e3b432b9b Merge pull request #2352 from BishopFox/feat/opfor-cna-client
feat(client): add OPFOR CNA BOF scripting
2026-08-30 10:06:32 -07:00
moloch-- fc4d9a1c36 Improvements to windows/shell handling 2026-08-30 10:06:00 -07:00
moloch-- 55b97df9f7 fix(client): repair OPFOR CI portability 2026-08-29 18:47:04 -07:00
moloch-- 227328fa87 docs: document OPFOR CNA scripting 2026-08-29 14:05:08 -07:00
moloch-- 071106d0bf test(e2e): add OPFOR native BOF coverage 2026-08-29 14:04:50 -07:00
moloch-- 8c326e6af9 feat(client): add OPFOR CNA BOF scripting 2026-08-29 14:04:27 -07:00
moloch-- 5e9ba47bf4 feat(opfor): preserve typed BOF output 2026-08-29 14:04:05 -07:00
moloch-- 0537e285fc build(deps): vendor OPFOR v0.0.1 and Reflektor v0.0.8 2026-08-29 14:03:42 -07:00
moloch-- ca685f5eed Require e2e tests for release workflow 2026-08-28 11:27:28 -07:00
Joe 902999493b Merge pull request #2351 from BishopFox/feat/wireguard-opt-in-auto-client
Make multiplayer WireGuard explicitly opt-in
2026-08-28 10:10:58 -07:00
moloch-- 14363c5d2a Make multiplayer WireGuard explicitly opt-in 2026-08-28 09:06:37 -07:00
Joe 926b7ae1a7 Merge pull request #2350 from BishopFox/feat/sgn-v0.1.0-stability
Fix SGN x64 stability and strengthen shellcode E2E coverage
2026-08-28 07:22:05 -07:00
moloch-- bf815b36ed Fix asset downloader lint findings 2026-08-27 18:04:14 -07:00
moloch-- 637853e8c2 Retry transient asset downloads safely 2026-08-27 14:48:38 -07:00
moloch-- 0c022ec722 Update SGN for x64 safe-mode stack alignment 2026-08-27 13:09:36 -07:00
moloch-- 7c83b458fa Fix SGN stability and expand shellcode E2E coverage 2026-08-27 10:39:32 -07: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
Joe 09b0540e75 Merge pull request #2349 from BishopFox/feat/armory-package-identity
Fix Armory extension updates and archive installs
2026-08-26 12:07:49 -07:00
moloch-- b4005efda8 Fix Armory PR lint failures 2026-08-26 11:03:58 -07:00
moloch-- 6d086fd7a7 Fix Armory package resolution and archive installs 2026-08-26 09:21:38 -07:00
Joe ccebd505b0 Merge pull request #2348 from BishopFox/fix/extensions-rm-external-path
Fix extensions rm deleting external directories
2026-08-25 16:21:38 -07:00
rkervella 94c5022dd0 fix(client): preserve external extension directories 2026-08-25 10:46:13 -07:00
Joe b19e383111 Merge pull request #2347 from BishopFox/feat/reflektor-default-bof-execution
Use Reflektor for default BOF execution
2026-08-25 06:56:20 -07:00
moloch-- 02ebd99c68 Fix Reflektor BOF lint failures 2026-08-24 21:30:08 -07:00
moloch-- 83d82e7161 feat: use Reflektor for default BOF execution 2026-08-24 21:13:51 -07:00
moloch-- c481a4f5db Tweaks for go v1.27 2026-08-24 17:31:01 -07:00
moloch-- eb5ac1b309 Only run reflektor tests on e2e 2026-08-24 16:49:00 -07:00
Joe e73618e0a5 Merge pull request #2339 from BishopFox/e2e/comprehensive
test: add comprehensive server and implant E2E coverage
2026-08-24 16:25:55 -07:00
moloch-- 0017fd2fe8 test: reduce extension callback test complexity 2026-08-24 09:53:49 -07:00
moloch-- e44111b6b4 fix: defer native extension rpc responses 2026-08-24 09:47:09 -07:00
moloch-- aa2dce510a test: run darwin shellcode e2e on macos 26 2026-08-24 06:45:15 -07:00
moloch-- 5e6acde3c7 test: surface darwin shellcode loader failures 2026-08-23 23:03:26 -07:00
moloch-- 99a8d3f859 fix: reduce xor dynamic stub complexity 2026-08-23 19:41:18 -07:00
moloch-- fc15a47b1e fix: satisfy shellcode encoder lint 2026-08-23 19:38:16 -07:00
moloch-- 8b32b6525f fix: stabilize shellcode encoders 2026-08-23 19:35:30 -07:00
moloch-- 9e0fafb785 fix: keep shellcode runner alive after return 2026-08-23 18:58:11 -07:00
moloch-- 12ebffb16c test: add shellcode execution e2e matrix 2026-08-23 18:47:39 -07:00
moloch-- b539762de7 test: cover chmod and chown on darwin 2026-08-23 15:39:14 -05:00
moloch-- e71af809d4 fix: support chmod and chown on darwin 2026-08-23 15:39:14 -05:00
moloch-- f3ffa3323b ci: fix cross-platform lint checks 2026-08-23 15:39:14 -05:00
moloch-- 778331d474 ci: export command coverage markdown 2026-08-23 15:39:14 -05:00
moloch-- 8d259577af ci: restore manual-only comprehensive trigger 2026-08-23 15:39:14 -05:00
moloch-- aa108a66a8 ci: restore comprehensive E2E matrix 2026-08-23 15:39:13 -05:00
moloch-- 44e5464950 fix Windows arm64 WireGuard reconnects 2026-08-23 15:39:13 -05:00
moloch-- 1381200b33 fix WireGuard key exchange peer readiness 2026-08-23 15:39:13 -05:00
moloch-- f81b250a04 ci: focus Windows arm64 WireGuard diagnostics 2026-08-23 15:39:13 -05:00
moloch-- 8b4ee5f5c5 test: add opt-in implant debug logging 2026-08-23 15:39:13 -05:00
moloch-- 68cb1363e8 test: preserve E2E command failure diagnostics 2026-08-23 15:39:12 -05:00
moloch-- 2344a3ae50 fix overlapping beacon check-ins 2026-08-23 15:39:12 -05:00
moloch-- 8cec5164b7 fix implant WireGuard device teardown 2026-08-23 15:39:12 -05:00
moloch-- be50c25cb2 fix WireGuard beacon result delivery receipt 2026-08-23 15:39:12 -05:00
moloch-- ee2cc83aa1 test: accept partial Windows service inventories 2026-08-23 15:39:12 -05:00
moloch-- 8e62e2dcd9 ci: temporarily run comprehensive E2E on branch pushes 2026-08-23 15:39:11 -05:00
moloch-- 1347059639 ci: add comprehensive E2E workflow 2026-08-23 15:39:11 -05:00
moloch-- 29efea561c test: add comprehensive implant command E2E suite 2026-08-23 15:39:11 -05:00
moloch-- 82deca2b0f test beacon empty check-in receive path 2026-08-23 15:39:11 -05:00
moloch-- 808b3b6408 fix beacon result delivery across reconnects 2026-08-23 15:39:11 -05:00
moloch-- eba10ebbfc fix Windows arm64 process architecture detection 2026-08-23 15:39:11 -05:00
moloch-- 13903e46a8 fix Linux arm64 memfd creation 2026-08-23 15:39:10 -05:00
moloch-- 8aaaa583bd fix Linux arm64 process architecture detection 2026-08-23 15:39:10 -05:00
moloch-- 4b428065e2 fix WireGuard key exchange response parsing 2026-08-23 15:39:10 -05:00
moloch-- 578f9a28d0 fix UDP-only netstat responses 2026-08-23 15:39:10 -05:00
moloch-- ce2f494f96 fix directory upload overwrite truncation 2026-08-23 15:39:10 -05:00
moloch-- c1c36491a6 fix cd handler error reporting 2026-08-23 15:39:09 -05:00
Joe a5c3482c6d Merge pull request #2335 from kyzzniko-lang/fix/mcp-auth-token
fix(mcp): make `mcp start` usable without the interactive confirmation
2026-08-19 14:12:23 -07:00
Joe b428274515 Merge pull request #2336 from BishopFox/reflektor-ext
Support native extensions on macOS and Linux
2026-08-19 13:24:59 -07:00
moloch-- 5dc6adae47 Pin Reflektor v0.0.6 2026-08-19 13:33:44 -05:00
moloch-- 264b00f3c7 Pin Reflektor v0.0.5 2026-08-19 12:22:06 -05:00
moloch-- ada79c8ecd Use Reflektor native loader for Unix extensions 2026-08-19 11:06:49 -05:00
moloch-- edd4c3d097 Support native extensions on macOS and Linux
Replace the Darwin universal loader with Reflektor, add Linux extension handlers and a shared Unix callback ABI, fix client registration ordering and target visibility, and extend the Reflektor integration lifecycle across supported platforms.
2026-08-19 09:36:44 -05:00
kyzzniko-lang 38f84a4f6f fix(mcp): make mcp start usable without the interactive confirmation
`mcp start` gated the whole startup behind an interactive huh confirmation
("Do you know what prompt injection is and are you an adult?"). On terminals
where those interactive forms break (#2133) the form error was discarded,
the prompt silently evaluated to false, and users were stopped with an
unrelated "not qualified" message before the server ever started: no auth
token in the output and no ~/.sliver-client/mcp.yaml, which is what #2294
reports.

- add --yes/-y to `mcp start` to skip the confirmation (the same
  flags-over-menus escape hatch recommended in #2133), and point the
  rejection message at it and at `settings autoadult`
- print the auth config path next to the token in `mcp start` and `mcp`
  status output so users can locate mcp.yaml
- surface the confirmation form error in IsUserAnAdultWithPrompt instead
  of swallowing it, so a broken terminal reports why it failed

Closes #2294
2026-08-19 19:29:47 +08:00
Joe aee0958e99 Merge pull request #2329 from BishopFox/dependabot/go_modules/charm.land/lipgloss/v2-2.0.6
chore(deps): bump charm.land/lipgloss/v2 from 2.0.4 to 2.0.6
2026-08-18 13:36:26 -07:00
Joe 23c067982a Merge pull request #2315 from BishopFox/dependabot/npm_and_yarn/docs/sliver-docs/mermaid-11.16.1
chore(deps): bump mermaid from 11.15.0 to 11.16.1 in /docs/sliver-docs
2026-08-18 13:36:12 -07:00
dependabot[bot] 7e917da3ac chore(deps): bump charm.land/lipgloss/v2 from 2.0.4 to 2.0.6
Bumps [charm.land/lipgloss/v2](https://github.com/charmbracelet/lipgloss) from 2.0.4 to 2.0.6.
- [Release notes](https://github.com/charmbracelet/lipgloss/releases)
- [Commits](https://github.com/charmbracelet/lipgloss/compare/v2.0.4...v2.0.6)

---
updated-dependencies:
- dependency-name: charm.land/lipgloss/v2
  dependency-version: 2.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-18 03:08:02 +00:00
Joe 674b49f41f Merge pull request #2303 from BishopFox/dependabot/go_modules/github.com/jedib0t/go-pretty/v6-6.8.3
chore(deps): bump github.com/jedib0t/go-pretty/v6 from 6.7.8 to 6.8.3
2026-08-17 20:05:17 -07:00
moloch-- 4ba04df621 Only run reflektor tests on changes to implant/... 2026-08-17 20:02:29 -07:00
Joe 3274382bc7 Merge pull request #2333 from BishopFox/docs-refresh
Docs refresh
2026-08-17 19:58:50 -07:00
moloch-- 069d57b5e5 Polish docs landing and section content 2026-08-17 19:57:11 -07:00
moloch-- e023211c02 Polish docs branding and landing terminal 2026-08-17 19:35:23 -07:00
moloch-- a48006640d Fix docs scrolling and client navigation 2026-08-17 19:26:35 -07:00
moloch-- 764fd0eddf Refresh docs site design and navigation 2026-08-17 18:47:32 -07:00
dependabot[bot] 9c9943c94c chore(deps): bump github.com/jedib0t/go-pretty/v6 from 6.7.8 to 6.8.3
Bumps [github.com/jedib0t/go-pretty/v6](https://github.com/jedib0t/go-pretty) from 6.7.8 to 6.8.3.
- [Release notes](https://github.com/jedib0t/go-pretty/releases)
- [Commits](https://github.com/jedib0t/go-pretty/compare/v6.7.8...v6.8.3)

---
updated-dependencies:
- dependency-name: github.com/jedib0t/go-pretty/v6
  dependency-version: 6.8.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-18 00:10:18 +00:00
Joe 2e9b52371e Merge pull request #2332 from BishopFox/reflektor-tests
Test Sliver shared libraries with Reflektor
2026-08-17 17:07:02 -07:00
moloch-- 6d6df68972 Update Reflektor to v0.0.3
Synchronize the root and implant module graphs and vendor trees with the all-platform Go c-shared loader fixes.
2026-08-17 00:29:26 -07:00
moloch-- 7f863d3172 Fix cross-platform Reflektor integration failures
Close rendered implant files before import renames, use native GCC for Linux/386 Go shared libraries, and preserve actionable integration logs.
2026-08-17 00:28:49 -07:00
moloch-- 6ecb379bce Test Sliver shared libraries with Reflektor 2026-08-16 22:09:22 -07:00
Joe db45c4aaf9 Merge pull request #2328 from BishopFox/reflektor
Use Reflektor for implant extensions
2026-08-16 18:57:56 -07:00
moloch-- 198913d6f8 Use Reflektor for implant extensions 2026-08-15 21:54:40 -07:00
Joe cd85824185 Merge pull request #2326 from kyzzniko-lang/fix/alias-arg-passthrough
fix(alias): keep alias args intact without "--"
2026-08-15 15:45:27 -07:00
kyzzniko-lang 63797ac79c fix(alias): split splitAliasArgs up, gocyclo was at 26
golangci-lint caps cyclomatic complexity at 15. Break the token matcher,
value consumption and numeric validation into their own functions; the
main loop only dispatches now. No behaviour change, tests unchanged.
2026-08-15 13:37:23 +08:00
kyzzniko-lang 4633409623 fix(alias): keep alias args intact without "--"
Alias commands had it worse than extensions: pflag hard-errors on flags it
does not know, so a payload with its own arguments (amsi-bypass -pid 6969)
could only be invoked through cobra's "--" separator, and every alias help
text pointed to a wiki page explaining the trick (#2264).

Same treatment extensions got in #2323: alias commands now run with
DisableFlagParsing and runAliasCommand pulls the Sliver-owned flags out of
the raw slice itself (args.go). The flag table owns --process,
--process-arguments, --ppid, --save, --timeout and --help for every alias,
plus the .NET assembly set (--method, --class, --app-domain, --arch,
--in-process, --runtime, --amsi-bypass, --etw-bypass) for assembly aliases.
Everything else is handed to the payload verbatim, including glued
shorthands: "-pid" is deliberately NOT read as -p with value "id", which is
exactly the mangling behind #2264. Parsed values are written back onto the
flag set so Request()'s --timeout and the Get* readers keep working, and
"--" still forces everything after it to the payload for name collisions.

Drops the wiki workaround warning from the generated help text.

Closes #2264
2026-08-15 12:00:09 +08:00
Joe 13c2d33449 Merge pull request #2323 from kyzzniko-lang/fix/extension-bof-arg-passthrough
fix(extensions): keep BOF/extension args intact without "--"
2026-08-14 09:48:43 -07:00
kyzzniko-lang 8cf478d7b0 fix(extensions): remove unused flag-name maps flagged by golangci-lint
splitExtensionArgs matches Sliver-owned flags with literal comparisons, so the
extensionOwnedBoolFlags/extensionOwnedIntFlags package vars were never read.
golangci-lint's unused linter rejected them, failing CI on darwin/linux/windows.
Drop the maps and fold the design notes into splitExtensionArgs's doc comment.
2026-08-14 11:44:21 +08:00
Joe f55215ff93 Merge pull request #2324 from MKMithun2806/feat/profiles-generate-external-builder
Feat/profiles generate external builder
2026-08-13 11:32:29 -07:00
Ronan Kervella b5d3d0bad6 Merge branch 'master' into dependabot/npm_and_yarn/docs/sliver-docs/mermaid-11.16.1 2026-08-13 09:41:06 -07:00
Mitchaster a32d81252c Merge branch 'BishopFox:master' into feat/profiles-generate-external-builder 2026-08-13 21:44:34 +05:30
MKMithun2806 a1537535c4 test: cover external-builder flag on profiles generate
Verify the profiles generate subcommand exposes the -E/--external-builder
flag with the same semantics as generate, so the external builder path is
wired and discoverable.

Signed-off-by: MKMithun2806 <[email protected]>
2026-08-13 16:11:22 +00:00
MKMithun2806 61dd34e989 feat: use external builder for profiles generate
Mirror the generate command: when --external-builder is set, call the
existing externalBuild() helper instead of the local compile() path, and
handle ErrNoExternalBuilder / ErrNoValidBuilders with the same user-facing
messages as generate.

Signed-off-by: MKMithun2806 <[email protected]>
2026-08-13 16:09:17 +00:00
MKMithun2806 2b67cb917b feat: add --external-builder flag to profiles generate
Add the same -E/--external-builder flag already used by generate and
generate beacon so profiles generate can offload builds to an external
builder.

Signed-off-by: MKMithun2806 <[email protected]>
2026-08-13 16:08:39 +00:00
kyzzniko-lang ceead92ec5 fix(extensions): keep BOF/extension args intact without "--"
Extension commands were registered with pflag's
ParseErrorsWhitelist.UnknownFlags, which silently drops every unknown flag
together with its value. The extension's own flags (--type, --fqdn, ...) are
unknown to pflag, so they were stripped before reaching the BOF parser, and
operators had to insert cobra's "--" separator as a workaround (#2309).

Run extension commands with DisableFlagParsing and split the Sliver-owned
flags (--save, --timeout, --help) out of the raw argument slice ourselves,
then pass the rest untouched to the BOF/DLL parser. The parsed values are
written back onto the flag set so Request()'s --timeout (and any other
cmd.Flags() reader) still observes them.

Named extension flags now work with and without "--", quoted values and
positional arguments are preserved, and single/double-dash forms still reach
the BOF parser.

Closes #2309
2026-08-13 22:03:00 +08:00
Joe df9ed94635 Merge pull request #2322 from nkbeast/fix/nil-session-deref-crash
fix(server): prevent nil session dereference crash in tunnel/socks/pivot paths
2026-08-12 22:00:48 -07:00
Joe e891613f6f Merge pull request #2318 from MKMithun2806/fix/httpc2-header-probability
fix: default missing HTTPC2Header probability to 100
2026-08-12 22:00:31 -07:00
dependabot[bot] 528b04427a chore(deps): bump mermaid from 11.15.0 to 11.16.1 in /docs/sliver-docs
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.15.0 to 11.16.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/[email protected]@11.16.1)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.16.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-13 04:00:22 +00:00
Joe f60a0e91d3 Merge pull request #2317 from BishopFox/dependabot/npm_and_yarn/docs/sliver-docs/dompurify-3.4.13
build(deps): bump dompurify from 3.4.12 to 3.4.13 in /docs/sliver-docs
2026-08-12 20:57:38 -07:00
Joe 2308314529 Merge pull request #2319 from BishopFox/dependabot/go_modules/gorm.io/driver/postgres-1.6.2
chore(deps): bump gorm.io/driver/postgres from 1.6.0 to 1.6.2
2026-08-12 20:57:20 -07:00
NK 04bc82bca3 fix(server): prevent nil session dereference crash in tunnel/socks/pivot paths
Sessions.Get() can return nil when an implant session is removed (e.g. the
implant dies or times out) between the RPC handler's nil check and the
tunnel creation call. Both tunnels.Create() and SocksTunnels.Create()
dereferenced the session unconditionally, and since the gRPC server has no
panic recovery interceptor, the panic took down the entire server and all
active sessions (see #1969).

Also guard the tunnel data goroutines (resend, to-implant sender) and the
pivot graph ToProtobuf() against a vanished session, logging and skipping
instead of panicking.

Adds core.ErrInvalidSessionID mapped to codes.InvalidArgument in rpcError().
2026-08-12 09:42:26 +05:30
dependabot[bot] acdfa33231 chore(deps): bump gorm.io/driver/postgres from 1.6.0 to 1.6.2
Bumps [gorm.io/driver/postgres](https://github.com/go-gorm/postgres) from 1.6.0 to 1.6.2.
- [Commits](https://github.com/go-gorm/postgres/compare/v1.6.0...v1.6.2)

---
updated-dependencies:
- dependency-name: gorm.io/driver/postgres
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-10 03:03:31 +00:00
Mitchaster a03592de81 Merge branch 'master' into fix/httpc2-header-probability 2026-08-09 22:57:55 +05:30
Joe 5cf9d088e8 Merge pull request #2316 from MKMithun2806/docs/remove-generate-stager
docs: note the generate stager command was removed
2026-08-09 09:35:03 -07:00
MKMithun2806 db3294b926 fix: default missing HTTPC2Header probability to 100
When probability is omitted from http-c2.json headers it stayed at the
protobuf zero value (0) and was never applied by the implant. Coerce any
header with a zero probability to 100 during config normalization so the
header is always sent.

Signed-off-by: MKMithun2806 <[email protected]>
2026-08-09 16:04:52 +00:00
dependabot[bot] 1847b8c24b build(deps): bump dompurify from 3.4.12 to 3.4.13 in /docs/sliver-docs
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.12 to 3.4.13.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.13)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-09 09:20:01 +00:00
MKMithun2806 79e4259081 docs: note the generate stager command was removed
The Sliver generate stager subcommand was removed in recent releases. Update
the Stagers documentation to make clear stagers must now be generated with
Metasploit's msfvenom or custom tooling, and correct the HTTP(S) stager
section heading to reflect supported schemes.
2026-08-08 17:39:12 +00:00
Joe 9ff9b55352 Merge pull request #2305 from Luppa90/fix/mtls-wg-frame-bounds
fix(implant): bound mTLS and WireGuard envelope allocation from length prefix
2026-08-05 09:42:30 -07:00
Joe 9767e24edd Merge pull request #2312 from BishopFox/dependabot/go_modules/golang.org/x/text-0.40.0
Bump golang.org/x/text from 0.37.0 to 0.40.0
2026-08-05 09:42:13 -07:00
dependabot[bot] bb572f3846 Bump golang.org/x/text from 0.37.0 to 0.40.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.37.0 to 0.40.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.37.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-03 03:03:48 +00:00
Joe 01c5b5cee4 Merge pull request #2306 from Luppa90/fix/canary-nil-connection-panic
fix(server): guard Session.ToProtobuf against nil Connection
2026-07-31 15:52:17 -07:00
Luppa 1049dc4a98 fix(lint): add package comment to wireguard test file
Satisfies the revive package-comments rule that was failing CI on all
three platforms.
2026-07-31 21:55:49 +01:00
Luppa d1b0ba13c4 fix(server): guard Session.ToProtobuf against nil Connection
When a DNS canary fires, handleCanary publishes an event containing a
Session with only the Name field set (Connection is nil). The RPC event
stream calls ToProtobuf() on that Session, which dereferences Connection
for Transport, RemoteAddress, LastCheckin, and IsDead — causing a nil
pointer panic that crashes the server.

Guard the Connection-dependent fields so ToProtobuf returns zero values
for transport metadata when Connection is nil, instead of panicking.

Fixes BishopFox/sliver#1435
2026-07-31 10:49:07 +01:00
Luppa 2d98f14149 fix(implant): bound mTLS and WireGuard envelope allocation from length prefix
Same vulnerability fixed for pivots in #2292: a corrupted or desynced
4-byte length prefix could trigger an allocation up to ~4 GiB, crashing
the implant. Cap at 512 MiB (matching the pivot transport limit) and
return a descriptive error instead of allocating.

Also fixes stale "[pivot]" log tags in both transports' zero-length
error paths.
2026-07-31 08:55:30 +01:00
Joe 19e82064b1 Merge pull request #2292 from Luppa90/fix/pivot-frame-bounds
fix(implant): bound pivot frame allocation from length prefix
2026-07-30 14:34:54 -07:00
Joe 3b72374685 Merge pull request #2291 from Luppa90/fix/log-rotation
fix(server): rotate server logs to bound disk usage
2026-07-30 14:34:36 -07:00
Luppa 20fb8c4975 fix(implant): handle Close errors in pivot tests (errcheck) 2026-07-30 11:07:01 +01:00
Luppa 68829c0a40 fix(server): handle Close error in rotation test (errcheck) 2026-07-30 10:53:46 +01:00
Joe 2c18062f4d Merge branch 'master' into fix/log-rotation 2026-07-29 17:38:56 -07:00
Joe f17ce0afbe Merge pull request #2302 from BishopFox/dependabot/go_modules/charm.land/glamour/v2-2.0.1
Bump charm.land/glamour/v2 from 2.0.0 to 2.0.1
2026-07-29 17:38:16 -07:00
dependabot[bot] 116b12885c Bump charm.land/glamour/v2 from 2.0.0 to 2.0.1
Bumps [charm.land/glamour/v2](https://github.com/charmbracelet/glamour) from 2.0.0 to 2.0.1.
- [Release notes](https://github.com/charmbracelet/glamour/releases)
- [Commits](https://github.com/charmbracelet/glamour/compare/v2.0.0...v2.0.1)

---
updated-dependencies:
- dependency-name: charm.land/glamour/v2
  dependency-version: 2.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-28 00:31:47 +00:00
Joe 3d9860db6c Merge pull request #2301 from BishopFox/dependabot/go_modules/gorm.io/gorm-1.31.2
Bump gorm.io/gorm from 1.31.0 to 1.31.2
2026-07-27 17:29:18 -07:00
dependabot[bot] 1a6579e954 Bump gorm.io/gorm from 1.31.0 to 1.31.2
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.31.0 to 1.31.2.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.31.0...v1.31.2)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-version: 1.31.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-27 03:03:55 +00:00
Joe 2659d848d7 Merge pull request #2298 from BishopFox/feat/golangci-lint
Replace Go Report Card with golangci-lint
2026-07-26 17:08:34 -07:00
moloch-- df6571f63f Fix linting without generated assets 2026-07-26 15:18:21 -07:00
moloch-- aab1470e08 Replace Go Report Card with golangci-lint 2026-07-26 14:49:42 -07:00
Joe 19d43fdc37 Merge pull request #2295 from ph0neh0me/docs/go-archive-validation
docs: add go archive validation examples
2026-07-26 14:47:25 -07:00
Joe 3b09e5ea80 Merge pull request #2297 from BishopFox/feat/dependabot-follow-up
Fix remaining Dependabot advisories
2026-07-26 14:10:38 -07:00
moloch-- b904d29b25 Update implant security dependencies 2026-07-26 08:57:27 -07:00
moloch-- 8e2b069a7d Fix remaining Dependabot advisories 2026-07-26 08:08:26 -07:00
Joe 096c418d27 Merge pull request #2296 from BishopFox/feat/dependabot-security-fixes
Fix Dependabot security alerts
2026-07-25 18:47:29 -07:00
moloch-- d9e241ccb5 Fix flaky website content test 2026-07-25 14:19:06 -07:00
moloch-- 086da95525 Fix Dependabot security alerts 2026-07-24 16:48:14 -07:00
Peter Georgiev 27ed557e55 docs: add go archive validation examples 2026-07-25 01:46:46 +03:00
Joe af7c823508 Merge pull request #2290 from ph0neh0me/feature/go-archive-output
Add Go c-archive output format
2026-07-23 17:54:34 -07:00
Joe f2a3d8ee6d Merge pull request #2293 from ecapuano/docs/update-soc-analyst-link
Docs: update stale SOC analyst blog post link
2026-07-22 21:49:00 -07:00
Eric Capuano de7215d2c0 docs: update stale SOC analyst blog link in Community Guides 2026-07-20 22:09:20 -05:00
Luppa 2d555b16d9 fix(implant): bound pivot frame allocation from length prefix
Both pivot readers (NetConnPivot and NetConnPivotClient) allocated
make([]byte, dataLength) directly from a peer's 4-byte length prefix with only
a lower-bound (<= 0) check. A corrupted or desynced prefix -- e.g. from a
framing desync during a transfer over a pivot -- can therefore be read as a
value up to ~4GiB and trigger an allocation large enough to crash the implant
(reported in #1452 with a 300KiB download over an http -> named-pipe pivot).

Add an upper bound (MaxFrameLength, 512MiB) and reject oversized frames with
ErrFrameTooLarge before allocating. The constant and error live in the pivots
package and are reused by pivotclients, which already imports it.

Adds tests covering rejection of an oversized prefix (with a timeout guard so a
regression fails fast instead of hanging) and a valid-frame round-trip.
2026-07-18 17:33:03 +01:00
Luppa 597a2571fc fix(server): rotate server logs to bound disk usage
The server log sinks (sliver.json, sliver.log, audit.json) were opened as
plain append-only file handles with no rotation, so a sustained error
condition (e.g. a hot RPC/accept error path) can grow them without bound and
exhaust the host disk, locking out the operator.

Route each sink through lumberjack. Rotation starts with safe built-in
defaults at logger init and is retuned from LogConfig after the server config
loads, mirroring the existing SetLevel pattern (the log package cannot import
server/configs -- import cycle). Configs that predate the new fields keep the
built-in defaults, so rotation is never accidentally disabled.

Adds LogConfig knobs (max_size_mb, max_backups, max_age_days, compress) and
unit tests covering the defaults, override/zero-value semantics, and real
on-disk rotation. New log files are created 0600 (more restrictive than the
previous 0644 for sliver.json/sliver.log).
2026-07-18 17:16:18 +01:00
Luppa 409b4c8fdb chore: vendor gopkg.in/natefinch/lumberjack.v2
Dependency for bounded server log rotation. Kept in a distinct commit from
the functional change per CONTRIBUTING.md ("Any changes to vendor/ should be
in a distinct commit").
2026-07-18 17:16:17 +01:00
Peter Georgiev 2e72bc96b9 Add Go c-archive output format 2026-07-18 15:09:09 +03:00
Joe fa9557348e Merge pull request #2286 from Functionhx/fix/sliver-1775-proxy-ua
fix: set ProxyConnectHeader to prevent UA leak on proxy CONNECT
2026-07-17 15:18:03 -07:00
Functionhx 4768e36074 fix: set ProxyConnectHeader to use spoofed User-Agent
Set http.Transport.ProxyConnectHeader with the generated User-Agent so
HTTPS CONNECT requests through a proxy use the spoofed UA instead of
leaking the default Go-http-client identifier.

Fixes BishopFox/sliver#1775

Signed-off-by: Yuchen Fan <[email protected]>
2026-07-17 14:00:07 +08:00
Joe e53f66de72 Merge pull request #2288 from BishopFox/feat/pr-2280-ci-fix
fix(dns): handle SplitBuffer varint boundaries and restore CI
2026-07-14 22:34:12 -07:00
moloch-- 1d33b89ec5 test: clean Go cache before generation suite 2026-07-14 19:14:37 -07:00
moloch-- 7b0f420290 Fix DNS splitting and Windows implant builds 2026-07-14 18:31:17 -07:00
moloch-- 00824d1b83 Merge remote-tracking branch 'origin/master' into feat/pr-2280-ci-fix 2026-07-14 07:50:18 -07:00
Joe 99ad05b7b7 Merge pull request #2287 from BishopFox/fix/png-decoder
Fix unbounded PNG decoding
2026-07-14 07:47:32 -07:00
moloch-- 80273462bd Fix unbounded PNG decoding 2026-07-13 22:07:42 -07:00
Luppa 2cdefc7369 fix(dns): step back on varint-boundary overshoot in SplitBuffer
The inner loop guard (subdataSpace-1) only accounts for a +2 base32
jump from a pure encoding boundary. When msg.Data length crosses a
proto3 varint boundary (e.g. 127->128 bytes), the serialized message
grows by 2 bytes, pushing the base32 encoding 3-4 chars over the
budget. This caused errMsgTooLong on specific parent domain length
parities. Add a step-back loop to correct overshoots without losing
the fast-path acceleration.

Fixes #2279
2026-06-28 08:50:31 +01:00
Ronan Kervella 80c752a422 Merge pull request #2273 from MKMithun2806/feature/configurable-keepalive
Feature/configurable keepalive
2026-06-03 12:35:42 -07:00
Mitchaster 8a8a9dc498 Merge branch 'BishopFox:master' into feature/configurable-keepalive 2026-06-03 06:14:57 +00:00
Mithun 5f8540b3c4 Standardize formatting with gofmt 2026-06-03 06:12:54 +00:00
Mithun 4eef868047 Add unit tests for rtunnels KeepAlive state management 2026-06-03 06:12:40 +00:00
Mithun 5ebfecd822 Use configurable KeepAlive in createReverseTunnelHandler 2026-06-03 06:12:26 +00:00
Mithun 9762560228 Pass KeepAlive to TrackListener in StartRportFwdListener 2026-06-03 06:12:12 +00:00
Mithun 6310c7d6a0 Store KeepAlive in rtunnels state manager 2026-06-03 06:11:58 +00:00
moloch-- fafe360672 go mod vendor 2026-05-31 14:25:51 -07:00
moloch-- d9b6f41940 Bump pge dep 2026-05-30 11:49:46 -07:00
moloch-- f1ccf0eac3 Docs site version bumps 2026-05-30 09:50:58 -07:00
Joe d2aa924bc4 Merge pull request #2258 from SAY-5/fix/install-detect-existing-minisign-2239
install: detect existing minisign before apt-get install
2026-05-07 08:01:04 -07:00
Joe 46861cd428 Merge pull request #2259 from cyberguerilla68/patch-1
Duplicate HTML Fix
2026-05-07 07:46:17 -07:00
ju li 1c1825f8f0 Duplicate HTML Fix
stageHandler function was missing a return statement at line 780, causing the server to serve duplicate HTML blocks when using the websites feature, causing things to render twice, and breaking embedded scripts like WAS stagers.

Signed-off-by: ju li <[email protected]>
2026-05-05 15:40:48 -04:00
SAY-5 a943995040 install: detect existing minisign before apt-get install
Signed-off-by: SAY-5 <[email protected]>
2026-05-05 03:48:27 -07:00
Joe f0d2a2cf24 Merge pull request #2257 from BishopFox/go/v1.26.2
Go v1.26.2
2026-05-03 17:47:00 -07:00
Joe 27ac612c82 Merge pull request #2255 from SAY-5/fix/event-broker-closed-channel-2252
core/events: don't panic on send to closed subscriber
2026-05-03 09:19:06 -07:00
moloch-- baedb563f3 Go v1.26.2 2026-05-03 09:17:04 -07:00
SAY-5 45c7914602 core/events: don't panic on send to closed subscriber
Closes #2252.
2026-05-02 01:05:02 -07:00
moloch-- 92d85cb8ff Tweak styling 2026-05-01 07:17:02 -07:00
moloch-- f5e831844a Fix various docs ui quirks in hero v3 2026-04-30 18:21:06 -07:00
moloch-- ab2fc7dc4a Initial update to heroui v3 2026-04-30 17:59:11 -07:00
Joe 5e377019e4 Merge pull request #2254 from smeukinou/fix-sliverpy
fix: safely close event broker subscription channels
2026-04-30 11:37:01 -07:00
smeukinou 88a4bebca0 fix: safely close event broker subscription channels
Moves the responsibility of closing the event subscription channel to the broker.Start() goroutine when processing an unsubscribe request. This prevents potential data races and panics caused by closing a channel concurrently while the broker might still be attempting to publish events to it.
2026-04-29 23:07:40 +02:00
Joe 2daccb5bd3 Merge pull request #2250 from BishopFox/wg/opt-in
Disable wg wrapper on multiplayer by default
2026-04-21 21:24:17 -07:00
Joe e2a009d4ac Merge pull request #2249 from Moriz82/fix/services-windows-nil-deref
fix(implant): services list panics on nil error on Windows (#1989)
2026-04-21 21:03:40 -07:00
moloch-- eda51f826a Disable wg wrapper on multiplayer by default 2026-04-21 20:51:06 -07:00
Will DeDominico f83dc42a64 fix(implant): services list panics on nil error (#1989)
servicesListHandler called err.Error() unconditionally after
service.ListServices(). On a healthy Windows host ListServices returns
nil error, causing a nil pointer dereference that crashed the implant.

serviceDetailHandler in the same file already guards correctly with
if err != nil. Apply the same pattern to servicesListHandler by
extracting response construction into a buildServicesResp helper with
a proper nil check.

Verified on Windows 11 VM: unfixed helper panics with "runtime error:
invalid memory address or nil pointer dereference" on the nil-error
path; fixed helper passes.

Fixes #1989
2026-04-20 20:13:46 -05:00
Joe 17e8c996a6 Merge pull request #2244 from Moriz82/fix/localtask-process-isolation
fix(implant): run LocalTask shellcode in forked child (#2237)
2026-04-19 08:44:56 -07:00
Will DeDominico 2b667f0e7e fix(implant): run LocalTask shellcode in forked child (#2237)
The previous implementation ran shellcode in a goroutine inside the
beacon; payloads that terminate via exit/execve killed the whole
beacon. Fork a child instead and let the parent wait for it.

Linux uses SYS_CLONE(SIGCHLD) since linux/arm64 has no SYS_FORK.
Darwin uses SYS_FORK, with the isChild check routed through the r1==1
convention that libSystem's fork() wrapper normally hides.

Post-fork the child runs only raw syscalls: runtime_BeforeFork poisons
stackguard0 and AfterForkInChild clears inherited signal handlers.
Mprotect happens in the child so the parent's page table stays
writable, inherited FDs are closed to avoid leaking C2 sockets into
exec'd payloads, and the child exits via SYS_EXIT_GROUP (Linux) or
SYS_EXIT (Darwin).

Tests under implant/sliver/taskrunner/ cover parent survival and
non-zero-exit error reporting on both Linux and Darwin.
2026-04-16 20:52:38 -05:00
Ronan Kervella 73bcf40bfd Merge pull request #2240 from BishopFox/CLD-10806/update-codeowners-replace-red-team
chore(doc): replace red-team with bf-red-team in CODEOWNERS (CLD-10806)
2026-04-10 09:02:55 -07:00
George Robbins b635adcb3c chore(doc): replace red-team with bf-red-team in CODEOWNERS (CLD-10806) 2026-04-10 10:56:01 -05:00
Joe 2b92397e07 Merge pull request #2236 from BishopFox/codex/pr2234-wg-ci
Fix WG client Windows CI regressions
2026-04-09 10:11:57 -07:00
moloch-- c23067a0e0 Wait for AI RPC event streams to subscribe 2026-04-02 09:17:33 -07:00
moloch-- 3aefc39c08 Stabilize AI RPC event stream tests 2026-04-02 08:50:19 -07:00
moloch-- abd8c83df8 Stabilize Windows WireGuard reconnects 2026-04-02 08:22:51 -07:00
Joe 6cce9426f2 Merge pull request #2235 from skoveit/fix/pkg-cache-delete-wrong-argument
fix: pkgCache.Delete(value) should be pkgCache.Delete(key)
2026-04-01 17:30:20 -07:00
skove 2e742863a1 fix: pass key instead of value to pkgcache.delete() 2026-04-01 14:47:39 +02:00
moloch-- dc13a5bb93 Add connecting animation 2026-03-31 13:33:19 -07:00
moloch-- 1f2946eb87 Revert changes to netstack 2026-03-31 09:45:40 -07:00
moloch-- 5eeda4c02b console now keeps the long-lived background WG streams on one connection 2026-03-31 09:36:05 -07:00
moloch-- 55a3386313 Improve reliabilty of multiplayer/wg setup 2026-03-30 20:02:55 -07:00
moloch-- b34589a002 Merge branch 'master' of github.com:BishopFox/sliver 2026-03-30 15:56:54 -07:00
moloch-- 4486b17b49 Tweak release trigger 2026-03-30 15:56:45 -07:00
Joe 377ee5ca7c Merge pull request #2229 from BishopFox/docs/multiplayer-wg
Update docs for wg+grpc/mtls multiplayer
2026-03-30 12:13:43 -07:00
moloch-- 4765f40854 v1.7.4 2026-03-30 11:51:15 -07:00
moloch-- e239d85821 v1.7.4 2026-03-30 11:48:34 -07:00
Joe dec419b284 Merge pull request #2232 from BishopFox/fix/linux-ver
Implement Detailed Linux Version Info
2026-03-30 09:52:39 -07:00
moloch-- 6d51a23cc9 Implement linux detailed version info 2026-03-29 19:11:48 -07:00
moloch-- 3d6eec47be Implement linux detailed version info 2026-03-29 19:11:40 -07:00
moloch-- 464fb7008c Tweak ai.yaml config schema 2026-03-29 18:41:06 -07:00
moloch-- ffa5f89f5f Tweak styling of system prompt 2026-03-29 09:00:15 -07:00
moloch-- f19cbdc957 Give agent encoder/decoder tools 2026-03-29 08:43:14 -07:00
moloch-- 83bfc8d8e6 Add default prompt to ai.yaml config 2026-03-27 21:36:58 -07:00
moloch-- 365bb3596e Add aliases and extensions to agent tools 2026-03-27 20:22:15 -07:00
moloch-- 9ee96c2ce6 Add ctx window usage to tui 2026-03-27 19:43:42 -07:00
moloch-- e776e266d0 Stack toast 2026-03-27 18:46:08 -07:00
moloch-- d080c8a646 Implement more tools for the ai to use 2026-03-27 18:15:46 -07:00
moloch-- cb6402245e Implement more tools for the ai to use 2026-03-27 18:15:37 -07:00
moloch-- e4357277ec Add web search to oai models 2026-03-27 17:33:15 -07:00
moloch-- 5dc2ab1490 Render reasoning text as md 2026-03-27 17:13:39 -07:00
moloch-- 9f034a9956 Implement better reasoning ui 2026-03-27 17:10:24 -07:00
moloch-- f700535997 Implement selectable text 2026-03-27 16:59:08 -07:00
moloch-- 31bca034d8 Remove layout text 2026-03-27 16:48:08 -07:00
moloch-- fac4ad5fb7 Add mouse support to ai tui 2026-03-27 16:40:33 -07:00
moloch-- c128d33514 Add toast notifications to tui 2026-03-27 13:01:34 -07:00
moloch-- 4a37262bcf Implement scroll wheel support for conversation pane 2026-03-27 11:56:58 -07:00
moloch-- a2295112bd Clean up various styling in ai tui 2026-03-27 11:47:39 -07:00
moloch-- 834dff0f6e Add lic header 2026-03-27 08:38:41 -07:00
Joe f4a0431fdd Merge pull request #2231 from BishopFox/fix/pivots-dos
Fix/pivots dos
2026-03-26 21:40:23 -07:00
moloch-- d072b328c0 Fix pivot handler test helper collision 2026-03-26 19:44:18 -07:00
moloch-- 435bbcbd7e Merge master 2026-03-26 18:51:05 -07:00
moloch-- 84dc28e584 Fix nil deref in pivots 2026-03-26 18:50:13 -07:00
Joe 639592cb02 Merge pull request #2230 from BishopFox/fix/tunnel-dos
Fix/tunnel dos
2026-03-26 18:03:16 -07:00
moloch-- 8c7a1a6b40 Fix dos/panic in tunnel closed 2026-03-26 17:40:57 -07:00
moloch-- 06274c4061 Fix dos/panic in tunnel closed 2026-03-26 17:40:46 -07:00
moloch-- 593925b4a5 Update docs for wg+grpc/mtls multiplayer 2026-03-26 17:30:33 -07:00
Joe 255477e572 Merge pull request #2228 from BishopFox/fix/mcp
Fix/mcp
2026-03-26 17:21:49 -07:00
moloch-- 0acc6911d8 Tweak ai mode init 2026-03-26 16:59:27 -07:00
moloch-- 4ca026906b Merge master 2026-03-26 15:54:00 -07:00
moloch-- 97a249e28e Implement http auth for mcp 2026-03-26 15:32:26 -07:00
moloch-- 9f331687d3 wip on agentic actions 2026-03-24 19:06:47 -07:00
Joe 14d68d6fcd Merge pull request #2225 from BishopFox/ai/tui2
Ai/tui
2026-03-22 08:36:46 -07:00
moloch-- 05c5334fea Switch to openai sdk 2026-03-21 09:23:49 -07:00
moloch-- d277a14c57 Add examples dir 2026-03-21 08:29:27 -07:00
moloch-- a7b5dbe6ee Tweak colors for user blocks 2026-03-20 20:29:28 -07:00
moloch-- c188dc1e69 Make messages copy-able 2026-03-20 20:19:02 -07:00
moloch-- 9fc9d1762e Merge branch 'master' into ai/tui2 2026-03-20 20:00:05 -07:00
Joe 4ef86448d0 Merge pull request #2224 from BishopFox/clean-makefile
Cleanup makefile vars
2026-03-20 08:22:50 -07:00
moloch-- 02117a6072 Cleanup makefile vars 2026-03-20 07:51:48 -07:00
moloch-- c272c8abac Update mcp dep 2026-03-19 09:26:17 -07:00
moloch-- cdf2412b39 Update mcp dep 2026-03-19 09:25:56 -07:00
moloch-- 40f52791d3 Update gprc dep 2026-03-19 08:55:25 -07:00
moloch-- a5d8bd16b9 Merge branch 'master' of github.com:BishopFox/sliver 2026-03-19 08:53:40 -07:00
moloch-- bf113f2151 npm update 2026-03-19 08:53:29 -07:00
moloch-- 4cd010afb9 wip on copy/paste 2026-03-19 08:52:46 -07:00
Joe 04b0bdd199 Merge pull request #2222 from skoveit/fix/alias-save-beacon-hostname
fix: populate hostname from beacon when using --save flag
2026-03-16 19:05:11 -07:00
Joe 9682db93b3 Merge branch 'master' into fix/alias-save-beacon-hostname 2026-03-16 18:47:06 -07:00
Joe 1d7ffb4ac2 Merge pull request #2221 from BishopFox/ai/tui
Ai/tui
2026-03-16 18:46:53 -07:00
0xOral c5e7cd6354 fix: use beacon.GetHostname() instead of direct field access 2026-03-17 03:16:19 +02:00
0xOral b20ba7284b fix: use beacon hostname when saving output with --save flag 2026-03-17 02:59:00 +02:00
moloch-- 49498210f8 Fix WireGuard generate test IP collisions 2026-03-16 17:20:33 -07:00
moloch-- 14e9121302 Go 1.25.8 2026-03-15 20:25:02 -07:00
moloch-- 8bd0257dd9 Go 1.25.8 2026-03-15 20:14:18 -07:00
Joe 93fdfb3add Merge branch 'master' into ai/tui 2026-03-15 20:03:27 -07:00
moloch-- e02b03cc70 Go 1.25.7 2026-03-15 20:03:01 -07:00
Joe 6831fe50a6 Merge pull request #2220 from skoveit/feat/multi-protocol-beacons
feat: multi-protocol implant builds via TUI
2026-03-15 20:02:36 -07:00
moloch-- 405108866d Update gh action workflows to 1.26.1 2026-03-15 15:34:34 -07:00
moloch-- 4d551b71e7 Fix huh form rendering 2026-03-15 14:11:33 -07:00
moloch-- a7edd9838d Sync prompt to stdout 2026-03-15 13:50:04 -07:00
moloch-- 9ef54ca6b1 Add mouse support to ai tui 2026-03-15 12:48:43 -07:00
moloch-- 4622c50288 Add ai chat sync 2026-03-15 12:26:03 -07:00
moloch-- b1ee0355af isolate multiplayer wg from c2 wg 2026-03-15 11:51:09 -07:00
moloch-- 01d9b213f2 fix grpc/mtls+wg for multiplayer 2026-03-15 11:11:27 -07:00
moloch-- f56886bce5 Merge grpc/mtls+wg branch 2026-03-15 10:55:50 -07:00
moloch-- 88176dfd00 Improve docs command 2026-03-15 10:31:41 -07:00
moloch-- 45dc3ea09b Improve doc file selector 2026-03-15 10:25:38 -07:00
moloch-- 682b3c2409 Upgrade to charm v2 libs 2026-03-15 09:47:29 -07:00
moloch-- 3a93f312b4 Add optional Wireguard multiplayer 2026-03-15 09:38:54 -07:00
moloch-- 2ca95f7a7c Add docs command 2026-03-15 09:21:24 -07:00
moloch-- d2319432ab Create docs go pkg 2026-03-15 08:53:52 -07:00
moloch-- 253f1a8806 Create docs go pkg 2026-03-15 08:52:05 -07:00
moloch-- 2c025d5970 Add new conversation modal 2026-03-15 07:56:11 -07:00
moloch-- f0034683f9 Tweak tui borders 2026-03-15 07:16:45 -07:00
moloch-- 8e8398b654 Tweak border 2026-03-14 23:20:07 -07:00
moloch-- aa02293f05 Fix borders 2026-03-14 22:34:58 -07:00
moloch-- f967a32308 Add scrollbar 2026-03-14 22:22:46 -07:00
moloch-- 99ec812455 Improve scroll of conversation pane, move context info to modal 2026-03-14 22:08:28 -07:00
moloch-- eeef542eeb Make conversation scrollable 2026-03-14 21:48:54 -07:00
moloch-- 6d7a10b392 Switch to fantasy to call models 2026-03-14 21:22:01 -07:00
Joe ca40c0a1e7 Merge pull request #2219 from BishopFox/fix/windows-ci
Fix Windows CI
2026-03-14 20:48:12 -07:00
moloch-- 4a5ad6ea92 Redesign delete modal 2026-03-14 20:13:02 -07:00
moloch-- 266dabf6c3 Redesign modals 2026-03-14 20:03:49 -07:00
moloch-- a4c8968539 Resize tui on window resize 2026-03-14 18:54:09 -07:00
moloch-- 613c030ea3 Add markdown rendering 2026-03-14 15:38:54 -07:00
moloch-- 9f6bfb6714 Working animation 2026-03-14 15:27:35 -07:00
0xOral a764814a8e ux: refine reconfig command warnings and safety prompts 2026-03-14 23:16:30 +02:00
0xOral 0c9ce138a0 allow runtime protocol switching 2026-03-14 22:54:25 +02:00
0xOral 6c1dcf6a06 add flags to include multiple transport protocols at build time 2026-03-14 22:53:33 +02:00
moloch-- fffe93819d Improve server-side ai grpc handlers 2026-03-14 13:31:32 -07:00
moloch-- 635ee421e3 Add animation 2026-03-14 12:54:20 -07:00
moloch-- 3ce6d69474 Add server-side ai-config form 2026-03-14 12:00:28 -07:00
moloch-- cd523c7f77 Wire tui to ai grpc 2026-03-14 11:43:21 -07:00
moloch-- 1f1b759914 Merge branch 'codex/add-bubbletea-ai-tui-layout' into ai/tui 2026-03-14 11:15:14 -07:00
moloch-- b78915fcbd Basic db layout 2026-03-14 11:14:54 -07:00
moloch-- fa0e13a5cc Basic tui layout 2026-03-14 11:14:03 -07:00
moloch-- 924bcddfd2 Fix gogo test on Windows 2026-03-14 10:25:34 -07:00
moloch-- affbf61452 Fix unit test on windows 2026-03-14 09:57:52 -07:00
moloch-- 7c6b2f70e4 Add windows ci runner 2026-03-14 08:42:57 -07:00
moloch-- c46f2938de Fix cross-platform issues with running ci on windows 2026-03-14 08:37:31 -07:00
moloch-- b355fe70d6 Add Windows makefile 2026-03-13 16:14:11 -07:00
moloch-- 10b1079286 Tweak agents.md 2026-03-13 15:29:45 -07:00
Joe 9c042e6977 Merge pull request #2217 from BishopFox/fix/cert-revoke
Improve operator cert revocation
2026-03-13 12:04:26 -07:00
Joe 62583cac8f Merge branch 'master' into fix/cert-revoke 2026-03-13 11:02:34 -07:00
moloch 95764945b8 Improve operator cert revocation 2026-03-13 10:59:00 -07:00
Joe 26abda1c15 Merge pull request #2216 from skoveit/fix/timeout-int64p-mismatch
fix: timeout flag ignored in aliases and extensions due to type mismatch
2026-03-13 07:38:21 -07:00
moloch 01dd3cac72 Go mod tidy 2026-03-13 07:02:06 -07:00
0xOral f8faa1f767 fix: normalize all command timeout flags to Int64P across client codebase 2026-03-13 02:49:01 +02:00
0xOral a9e48e84cd fix: extension timeout flag ignored due to IntP/Int64P mismatch 2026-03-13 02:30:55 +02:00
0xOral 9d6704d925 fix: alias -t flag always capped at 30s due to IntP/Int64P type mismatch (#2183) 2026-03-13 02:04:35 +02:00
Joe ce5157e2e9 Merge pull request #2213 from skoveit/fix/wininet-handle-leak
fix(wininet): Fix handle leak in WinINet driver
2026-03-11 08:29:42 -07:00
0xOral 7d19051df8 fix(wininet): close connection and request handles after each request 2026-03-11 01:32:27 +02:00
Joe a0d3bd579f Merge pull request #2211 from skoveit/feat/configurable-portfwd-keepalive
feat: make TCP keepalive period configurable for portfwd and rportfwd
2026-03-08 10:36:42 -07:00
0xOral 82c309632a default keepalive value 30s for both rportfwd and portfwd 2026-03-08 17:27:27 +02:00
0xOral 5c0b8ec460 disable keepalive with --keepalive -1 value 2026-03-08 17:25:06 +02:00
0xOral 7791a86eb4 configurable keepalive: portfwd --keepalive 2026-03-08 17:20:35 +02:00
Joe 7ac4db3fa0 Merge pull request #2207 from BishopFox/fix/beacon-getsystem
Fix for getsystem in beacon mode
2026-03-05 18:31:39 -07:00
moloch-- 512bc822ef Implement fix for getsystem in beacon mode 2026-03-05 08:22:42 -08:00
Joe f2bb9c3733 Merge pull request #2205 from BishopFox/feature/lazy-lg-msg
Lazy spool large messages to disk
2026-03-04 15:40:56 -07:00
moloch-- 0bd4f100fd Lazy spool large messages to disk 2026-03-04 14:21:07 -08:00
Joe 3230322fd6 Merge pull request #2201 from BishopFox/feature/exec-shellcode
Add support for wasm-donut options in windows `execute-shellcode`
2026-03-02 08:23:49 -07:00
moloch-- 82d979436b Add support for additional wasm-donute options in windows execute-shellcode 2026-03-01 22:35:37 -08:00
Joe d2632e75bd Merge pull request #2199 from BishopFox/feature/go-tests2
Refactor unit tests to not write to main db
2026-03-01 09:47:23 -07:00
moloch-- a55831fb44 Go v1.25.7 2026-02-28 22:39:46 -08:00
moloch-- 39d4edec43 Tweak go-tests.sh 2026-02-28 22:08:59 -08:00
moloch-- bbdb08e7a4 Refactor go-tests.sh 2026-02-28 14:12:37 -08:00
moloch-- 39b488bd25 Refactor unit tests to not write to main db 2026-02-28 13:11:14 -08:00
Joe e7b8a24534 Merge pull request #2198 from BishopFox/feature/handler-recover
Feature/handler recover
2026-02-28 11:39:00 -07:00
moloch-- 17b9d611f8 Add panic/recover to mtls, dns, and wg handlers 2026-02-28 09:56:48 -08:00
moloch-- ed9075d065 Add better nil checks to various pb handlers 2026-02-28 09:52:40 -08:00
Joe 1f7221933d Merge pull request #2194 from BishopFox/crush/v2
Upgrade crush libs to v2
2026-02-25 09:22:24 -07:00
moloch-- 6333e0691a Upgrade crush libs to v2 2026-02-24 17:40:13 -08:00
moloch-- 3bbaf80510 v1.7.3 2026-02-23 20:49:28 -08:00
moloch-- 25726d8105 Revert wasm-donut to v0.0.3 2026-02-23 20:48:53 -08:00
moloch-- 67e9a7f37f Update wasm-donut to v0.0.6 2026-02-23 19:33:26 -08:00
moloch-- a5b6935da6 Tweak AGENTS.md 2026-02-23 07:10:28 -08:00
moloch-- 2e36f45bdf v1.7.2 2026-02-22 17:33:08 -08:00
Joe 3e06a9d5ee Merge pull request #2191 from BishopFox/feature/file-tab-completer
Feature/file tab completer
2026-02-22 18:32:27 -07:00
moloch-- 952b2df49d Update beignet 2026-02-22 16:42:32 -08:00
moloch-- 9769f1bb57 Fix mod vendor 2026-02-22 16:41:16 -08:00
moloch-- f5f2c80ad3 Update malasada to v0.0.3 2026-02-22 16:38:41 -08:00
moloch-- bb2edf63cf Add file tab completer to more commands 2026-02-22 16:37:30 -08:00
moloch-- cd711afe8a Add tab completer to generate --save 2026-02-22 16:26:32 -08:00
Joe f9bbbb34bb Merge pull request #2190 from BishopFox/skoveit-feature/PE-Metadata-Spoofing
Skoveit feature/pe metadata spoofing
2026-02-21 23:29:33 -07:00
moloch-- 64b91c56c0 Fix unit tests 2026-02-21 15:14:26 -08:00
moloch-- a6c7867a26 Fix unit tests 2026-02-21 14:22:18 -08:00
moloch-- 7f25c3bb06 Expand pe metadata spoofing feature 2026-02-21 12:36:47 -08:00
moloch-- 153cf29551 Fix unit tests 2026-02-21 06:30:01 -08:00
jjensn b85161e797 Declare vars before use 2026-02-21 06:29:59 -08:00
jjensn ed906fcffe typo: Add yamux client constructor 2026-02-21 06:29:58 -08:00
jjensn 3bbcf5697a Yamux logging for debug implants 2026-02-21 06:29:56 -08:00
moloch-- 4a425d2fd6 Fix 'websites rm' add tab completer issue #2185 2026-02-21 06:29:55 -08:00
Adam d0f752e118 Fix execute infinite spin
Signed-off-by: Adam <[email protected]>
2026-02-21 06:29:54 -08:00
Adam 7167621577 Change env-inheritance flag default to false
Signed-off-by: Adam <[email protected]>
2026-02-21 06:29:47 -08:00
Joe c3582fc203 Merge pull request #2189 from BishopFox/jjensn-fix/yamux-disable-logging
Jjensn fix/yamux disable logging
2026-02-19 22:29:46 -07:00
moloch-- 07c95b203f Fix unit tests 2026-02-19 18:05:57 -08:00
moloch-- 958e44deef Fix 'websites rm' add tab completer issue #2185 2026-02-19 17:54:55 -08:00
Adam 836395a2d6 Fix execute infinite spin
Signed-off-by: Adam <[email protected]>
2026-02-19 17:54:53 -08:00
Adam 1a7a1c9b2d Change env-inheritance flag default to false
Signed-off-by: Adam <[email protected]>
2026-02-19 17:54:27 -08:00
Joe 4ba285796f Merge pull request #2188 from BishopFox/fix/websites-rm
Fix 'websites rm' add tab completer issue #2185
2026-02-19 18:51:41 -07:00
moloch-- a05dfad100 Fix 'websites rm' add tab completer issue #2185 2026-02-19 17:30:33 -08:00
Joe 0eb29ad9b8 Merge pull request #2187 from Aptimex/execute-fix
Fix: Execute default env inheritance issues
2026-02-19 18:06:43 -07:00
Adam ad8b329f85 Fix execute infinite spin
Signed-off-by: Adam <[email protected]>
2026-02-19 16:45:39 -07:00
Adam c6206257b6 Change env-inheritance flag default to false
Signed-off-by: Adam <[email protected]>
2026-02-19 16:20:59 -07:00
Ronan Kervella 8bbf06c156 Merge branch 'master' into feature/PE-Metadata-Spoofing 2026-02-19 12:42:31 -08:00
jjensn 4d4eab998f Declare vars before use 2026-02-18 13:34:24 -05:00
jjensn c081f17380 typo: Add yamux client constructor 2026-02-17 13:17:44 -05:00
jjensn fafbac3fa6 Yamux logging for debug implants 2026-02-17 12:59:34 -05:00
moloch-- d9c89c81cf Fix typo in docs 2026-02-16 13:14:40 -08:00
moloch-- 061221905b Add discord button to upper right 2026-02-16 13:09:03 -08:00
moloch-- d0600e2be7 Tweak docs site loading 2026-02-16 10:44:51 -08:00
moloch-- 26abbe3860 Update wasm-donut to v0.0.5 2026-02-16 09:58:57 -08:00
Anas 55a386ae1c Merge branch 'master' into feature/PE-Metadata-Spoofing 2026-02-16 19:54:15 +02:00
moloch-- 2bfdb218ff Update wasm-donut to v0.0.5 2026-02-16 09:44:22 -08:00
moloch-- 715b84e799 Update readme 2026-02-16 09:26:49 -08:00
moloch-- faea2ff639 Add link buttons to home page 2026-02-16 08:58:39 -08:00
moloch-- 1b253f8dec Add community talks section 2026-02-16 08:46:12 -08:00
moloch-- eb38724322 Add mtls and wg c2 docs 2026-02-16 08:35:01 -08:00
moloch-- 05dedf4473 Add mermaid chart component to md renderer 2026-02-16 08:06:36 -08:00
moloch-- 28c6b82a79 Update config file docs 2026-02-16 07:46:18 -08:00
Anas 1f553496e3 Merge branch 'master' into feature/PE-Metadata-Spoofing 2026-02-16 15:50:07 +02:00
0xOral cd5db464e0 fix protoc version 2026-02-16 15:25:21 +02:00
moloch-- 0bf0ac6c7b Update config file md 2026-02-15 21:52:57 -08:00
Joe 0cf5a47cfd Merge pull request #2181 from BishopFox/fix/gzip-bomb
Add limit to gzip size
2026-02-15 22:27:56 -07:00
moloch-- 8d6759924b Add limit to gzip size 2026-02-15 21:13:54 -08:00
Joe 8d5dbd2050 Merge pull request #2180 from BishopFox/feature/shell-mgmt
Feature/Shell Management
2026-02-15 22:12:08 -07:00
moloch-- 804e149113 Add shell kill option 2026-02-15 20:58:07 -08:00
moloch-- 9ced4cfd50 Add shell mgmt features 2026-02-15 20:51:18 -08:00
0xOral c8be5a5b1b add --spoof feature 2026-02-16 02:27:59 +02:00
moloch-- b7abf98c95 Tweak yt header 2026-02-15 07:30:51 -08:00
moloch-- 918ac1bb91 Tweak payload compatibility tables 2026-02-14 16:24:44 -08:00
moloch-- 1e57818312 Tweak cross-compile docs 2026-02-14 11:34:11 -08:00
moloch-- c44c14dd46 Tweak cross-compile docs 2026-02-14 11:33:48 -08:00
moloch-- 35f41cd62d Tweak compile from source instructions 2026-02-14 11:29:35 -08:00
moloch-- 4eff1cc053 Add getting started workshop 2026-02-14 11:22:12 -08:00
moloch-- 16981da431 upgrade docs to nextjs v16 2026-02-14 11:16:38 -08:00
moloch-- 3830c76292 npm update 2026-02-14 09:43:13 -08:00
moloch-- 73de93d6fb Fix content scrolling 2026-02-14 09:42:04 -08:00
moloch-- c7770c2f6e Add more yt links 2026-02-14 09:31:02 -08:00
moloch-- 21db908ce4 Hide footer on yt play 2026-02-14 09:23:11 -08:00
moloch-- b7b0247652 Add basic yt embeds 2026-02-14 08:52:15 -08:00
moloch-- 3dc7dc03d0 Fix moving sidebar 2026-02-14 08:40:26 -08:00
moloch-- 229452d7ed Fix moving sidebar 2026-02-14 08:39:23 -08:00
moloch-- d29ce9a08b Add payload compatibility table to docs 2026-02-14 08:19:18 -08:00
Joe 46f2b2b402 Merge pull request #2177 from BishopFox/skoveit-feature/dynamic-c2-endpoint
Skoveit feature/dynamic c2 endpoint
2026-02-13 11:50:36 -07:00
Joe 6ee2006387 Merge pull request #2170 from xtle0o0/fix/external-builder-authz-scope
Restrict external builder RPCs to assigned build jobs
2026-02-13 11:50:22 -07:00
moloch-- 2fd13ee2bc Regenerate pb 2026-02-13 07:22:06 -08:00
0xOral 2697568336 fix-export-ca-error-message 2026-02-13 07:21:54 -08:00
Joe 5013048b0b Merge pull request #2176 from skoveit/fix-export-ca-error-message
fix-export-ca-error-message
2026-02-13 08:13:46 -07:00
Joe 627b15572c Merge branch 'master' into feature/dynamic-c2-endpoint 2026-02-13 08:11:56 -07:00
0xOral 42c40c9b8d fix-export-ca-error-message 2026-02-12 19:45:52 +02:00
0xOral 405b282fed Dynamic C2 Endpoint Switching 2026-02-12 18:47:26 +02:00
Joe e32cd36e01 Merge pull request #2172 from skoveit/fix-off-by-one-naming
Fix: server panic on single-word lists and off-by-one in codename generation
2026-02-10 22:30:32 -07:00
Joe 492a2aeed6 Merge pull request #2171 from loresuso/loresuso/fix-zombie-shell
fix: wait system shell to cleanup zombie processes
2026-02-10 22:29:53 -07:00
Lorenzo Susini d6dafb0391 fix: wait system shell to cleanup zombie processes
Signed-off-by: Lorenzo Susini <[email protected]>
2026-02-10 18:01:39 +01:00
0xOral 49a1bcfd7d fix(codenames): fix off-by-one error and prevent panic on single-word lists 2026-02-10 17:05:13 +02:00
xtle0o0 a8d1bf6fb1 Restrict external builder RPCs to assigned builds 2026-02-10 12:00:24 +01:00
moloch-- 2f386b7e09 v1.7.1 2026-02-08 14:19:47 -08:00
Joe d7dce58397 Merge pull request #2168 from BishopFox/feature/mutli-console
Feature/mutli console
2026-02-08 07:47:05 -07:00
moloch-- 15114138a8 Save interactive settings to disk 2026-02-08 06:32:17 -08:00
moloch-- 2716090410 Add custom prompt configurations 2026-02-08 06:28:45 -08:00
moloch-- a805161245 Add themes and console switcher 2026-02-07 15:46:17 -08:00
Joe afd57891cd Merge pull request #2166 from BishopFox/linux/shellcode
Linux/shellcode
2026-02-07 14:32:19 -07:00
moloch--andCodex bdf4a6ed0d Codex changes
Co-authored-by: Codex
2026-02-07 12:54:03 -08:00
moloch-- 87b1efaa5f Make sure zig builds get stripped 2026-02-07 12:25:04 -08:00
moloch-- 009e505a54 Update shellcode unit tests 2026-02-07 10:46:01 -08:00
moloch-- c0dd2d5b51 Update docs, wire linux shellcode into client/ code 2026-02-07 10:33:13 -08:00
moloch-- 6dab9dc6c5 Update malasada to v0.0.2 2026-02-07 10:11:15 -08:00
moloch-- 854249fa25 Add linux shellcode for amd64/arm64 to rpc 2026-02-07 09:33:42 -08:00
moloch-- 7db573a03e Tweak file header 2026-02-07 07:32:48 -08:00
moloch-- 0bef5491dc Fix linux --format shared builds 2026-02-07 00:26:33 -08:00
moloch-- 1c50e5a87b Remove unused tests 2026-02-06 17:53:50 -08:00
moloch-- 8dae2d0d52 v1.7.0 2026-02-06 17:50:35 -08:00
Joe 023776f7e3 Merge pull request #2165 from BishopFox/crack/basic
Update gRPC APIs
2026-02-06 18:48:21 -07:00
moloch-- b7566a3b09 Update client grpc to modern apis 2026-02-06 17:31:08 -08:00
moloch-- b889e2eb6f Fix merge 2026-02-06 16:30:53 -08:00
moloch-- 4466ace279 Merge master 2026-02-06 16:29:39 -08:00
Joe 2b65089b27 Merge pull request #2164 from BishopFox/fix/dns-dos
Fix/dns dos
2026-02-06 15:41:17 -07:00
moloch-- 87d5fd53a8 Add e2e dns c2 tests 2026-02-06 13:54:07 -08:00
moloch-- cf79a2d284 Merge patch for oversized msg size checks 2026-02-06 12:16:03 -08:00
moloch-- 6d0276ff67 Add more tests for dns gc 2026-02-06 11:56:55 -08:00
moloch-- 9b001ab88e Remove totp from dns c2, implement pending msg gc 2026-02-06 11:40:00 -08:00
Joe 503218f3f4 Merge pull request #2163 from BishopFox/feature/exec-bg
Feature/exec bg
2026-02-06 10:44:20 -07:00
moloch-- 495dce0987 Improve error and nil checks in mtls comms 2026-02-06 08:22:26 -08:00
moloch-- a7ff35c229 Tweak selection form height 2026-02-06 07:58:17 -08:00
moloch-- 162fc4fb8a Merge branch 'feature/background-execute-flag' into feature/exec-bg 2026-02-06 07:31:10 -08:00
moloch-- 636ea0a9b2 Cleanup execute children output 2026-02-06 07:31:01 -08:00
moloch-- 84ed9bc4e7 Merge branch 'feature/background-execute-flag' into feature/exec-bg 2026-02-06 07:27:57 -08:00
moloch-- b985d8ff9f Fix cmd group help 2026-02-06 07:27:50 -08:00
moloch-- 5a43705e1d Merge branch 'feature/background-execute-flag' into feature/exec-bg 2026-02-06 07:13:34 -08:00
Joe 76af248850 Merge pull request #2162 from BishopFox/darwin/shellcode
Darwin/arm64 Shellcode
2026-02-06 07:59:56 -07:00
moloch-- 8ea7eb4236 Remove old test 2026-02-05 21:30:45 -08:00
Joe 8f3c3ec1e0 Merge branch 'master' into darwin/shellcode 2026-02-05 22:19:10 -07:00
moloch-- 8d4bf9c759 Fixes to xor_dynamic arm64 2026-02-05 21:18:17 -08:00
moloch-- 628757250e Add execute background flag 2026-02-05 20:56:07 -08:00
moloch-- 85094fc695 Port xor encoders to arm64 2026-02-05 20:41:00 -08:00
moloch-- 0a88ad77f7 Port xor encoders to arm64 2026-02-05 19:15:34 -08:00
moloch-- 4d04f08421 Port xor encoders to arm64 2026-02-05 19:14:25 -08:00
moloch-- 4a01971f7d Allow specifying multiple shellcode encoders by name 2026-02-05 18:39:07 -08:00
moloch-- 977c63dc16 Add shellcode support for darwin/arm64 2026-02-05 17:57:08 -08:00
Joe 2d65d7d6a5 Merge pull request #2161 from BishopFox/c2/mux
C2/mux
2026-02-05 18:36:17 -07:00
moloch-- 4f5ca51cfe Implement generate.darwinShellcode() 2026-02-05 17:16:14 -08:00
moloch-- 83208eeab4 Fix io import for named pipe build 2026-02-05 16:43:53 -08:00
moloch-- 21674a9f0a Merge branch 'codex/fix-client-wg-lport-flag' into c2/mux 2026-02-05 15:50:58 -08:00
moloch-- a18bda14e2 Fix wg lport check 2026-02-05 15:50:47 -08:00
moloch-- 37f9670783 Add mux to wg c2 2026-02-05 15:50:13 -08:00
moloch-- 1745886622 Add mux and signing to wg c2 2026-02-05 14:54:32 -08:00
moloch-- bd7361825c v1.6.11 2026-02-05 12:59:32 -08:00
Joe 32af81c132 Merge pull request #2160 from BishopFox/fix/wepath
Use correct path construction in website.ToProtobuf()
2026-02-05 13:47:35 -07:00
moloch-- 818127349c Use correct path construction in website.ToProtobuf() 2026-02-05 12:29:59 -08:00
moloch-- a712bead88 Tweak mux ids 2026-02-05 12:10:24 -08:00
moloch-- aa86a1d12c Add more signing unit tests 2026-02-05 11:38:37 -08:00
moloch-- afb58f7155 Add envelope signing over mtls 2026-02-05 11:10:35 -08:00
moloch-- 97d5adccd7 Add beacon tests 2026-02-05 09:28:55 -08:00
moloch-- 83cc755388 Add multiplexing to mtls comms 2026-02-05 07:17:25 -08:00
moloch-- 4dc1fe145c Fix missing import 2026-02-04 09:46:38 -08:00
Joe 5e27b23bde Merge pull request #2158 from BishopFox/fix/shell
Fix `shell` pty resizing
2026-02-03 17:47:44 -07:00
Joe 6011d23a9f Merge pull request #2159 from JakubStanior/fix/2129
Use lport from server config in daemon mode  / fix #2129
2026-02-03 17:42:18 -07:00
Jakub Stankiewicz 976d720043 Add default lport from server config in daemon mode 2026-02-03 23:31:08 +01:00
moloch-- 86f41e9abc Fix shell pty resizing 2026-02-02 19:19:14 -08:00
Joe 8b9fc399fa Merge pull request #2156 from BishopFox/fix/armory-win
Ensure armory temp files cross‑plat
2026-02-02 20:12:49 -07:00
moloch-- 27c75c14e6 Ensure armory temp files cross‑plat 2026-02-02 18:17:28 -08:00
moloch-- 11d6af8aa0 Fix duplicated 'unknown cmd' output 2026-02-01 10:44:38 -08:00
moloch-- 54dc6dcec3 v1.6.10 2026-01-31 17:45:38 -08:00
Joe c7412d4d5b Merge pull request #2154 from BishopFox/go/v1.25.6
Go/v1.25.6
2026-01-31 09:44:33 -07:00
moloch-- c3817dc175 Pin tag patch version Go 1.25.6 2026-01-31 08:20:09 -08:00
moloch-- 09cedf588e Update to go 1.25.6 2026-01-31 08:18:39 -08:00
moloch-- 66aed89ebd Merge master 2026-01-31 08:03:24 -08:00
moloch-- 16397e7a0f Update default runtime arg 2026-01-31 07:53:18 -08:00
moloch-- 5062b483b7 Update wasm-donut 2026-01-31 07:43:21 -08:00
moloch-- b268295c8d Switch to wasm-donut 2026-01-30 22:23:56 -08:00
moloch-- 2b3f23cec9 Move donut options to generate flags 2026-01-30 17:19:57 -08:00
moloch-- 25d242230b Rengerate pb 2026-01-30 15:10:02 -08:00
Joe 110658ec39 Merge pull request #2152 from BishopFox/feature/shellcode-encoders
Feature/shellcode encoders
2026-01-30 16:09:46 -07:00
moloch-- b4345226c1 Merge branch 'feature/shellcode-encoders' into crack/basic 2026-01-30 15:09:21 -08:00
moloch-- e99999ebb6 Cleanup shellcode encoder list output 2026-01-30 14:20:48 -08:00
moloch-- 24795e7cba Update shellcode encode rpc to support new encoders 2026-01-30 14:14:45 -08:00
moloch-- 32e3386a3e Add sgn to unified ShellcodeEncoders 2026-01-30 13:40:16 -08:00
moloch-- 7feea8bef3 Add more shellcode encoders 2026-01-30 13:02:14 -08:00
moloch-- d182434d54 Implement amd64/xor_dynamic shellcode encoder 2026-01-30 12:45:22 -08:00
moloch-- 0237af0c6e Implement amd64/xor_dynamic shellcode encoder 2026-01-30 12:45:08 -08:00
moloch-- 1fa36e46c4 Implement amd64/xor_dynamic shellcode encoder 2026-01-30 12:44:49 -08:00
moloch-- 4d9bae0778 Port basic xor shellcode encoder to golang 2026-01-30 12:28:15 -08:00
moloch-- 9a9b40e400 Move sng to server/encoders/shellcode/sgn 2026-01-30 11:51:57 -08:00
moloch-- d784b305ef Move sng to server/encoders/shellcode/sgn 2026-01-30 11:50:24 -08:00
moloch-- 7a224b4757 Update year 2026-01-30 11:40:58 -08:00
Joe 76ae704ce4 Merge pull request #2151 from BishopFox/feature/xxd
Feature/xxd
2026-01-30 12:06:11 -07:00
moloch-- 71cd8fa610 Skip generate tests if there's no edits to related code 2026-01-30 10:35:16 -08:00
moloch-- e9fdcc1285 Improve hex-edit ux 2026-01-30 10:30:15 -08:00
moloch-- 870c995ae2 Add hexedit feature 2026-01-30 10:20:56 -08:00
Joe 15d7e7c4d1 Merge pull request #2150 from BishopFox/feature/chtimes
Expand time formats supported by chtimes
2026-01-30 10:50:56 -07:00
moloch-- 750cdfce0a Expand time formats supported by chtimes 2026-01-30 09:41:55 -08:00
Joe 0f128a22b3 Merge pull request #1807 from navsec/socks5-stability
SOCKS5 Stability Improvements for HTTP/mTLS Transports
2026-01-30 10:07:13 -07:00
Joe 3296a8f483 Merge branch 'master' into socks5-stability
Signed-off-by: Joe <[email protected]>
2026-01-30 10:07:05 -07:00
Joe b77ade2bb9 Merge pull request #2148 from BishopFox/fix/info
Fix/info
2026-01-30 08:47:32 -07:00
moloch-- 41dc54d736 Implement mount for darwin 2026-01-29 22:22:59 -08:00
moloch-- 8a0694cb38 Add mount on darwin 2026-01-29 22:08:28 -08:00
moloch-- 52c0618bc3 Add os version implementations for linux/darwin 2026-01-29 21:50:16 -08:00
moloch-- e2e1912628 Add search to edit cmd 2026-01-29 21:01:16 -08:00
moloch-- 36794f1b07 Implement skip to line number 2026-01-29 20:50:11 -08:00
moloch-- ccd8f75b03 v1.6.9 2026-01-29 20:36:45 -08:00
moloch-- dc89afe404 Add optional line numbers 2026-01-29 20:32:15 -08:00
moloch-- 9db1f3a967 Add syntax highlighting 2026-01-29 20:22:23 -08:00
moloch-- a2ab3aede6 Implement 'edit' command 2026-01-29 20:07:51 -08:00
moloch-- c9f4970e4b Add crack db models 2026-01-29 19:35:39 -08:00
moloch-- e72fa3dbd0 Switch crack.yaml to snake case 2026-01-29 16:30:51 -08:00
moloch-- 2e93ce5684 Merge branch 'master' into crack/basic 2026-01-29 16:16:43 -08:00
Joe 545fc27d03 Merge pull request #2147 from BishopFox/feature/certs-db
Feature/certs db
2026-01-29 09:59:10 -07:00
moloch-- 5bf65aa2b0 Fix notification indentation 2026-01-29 08:51:01 -08:00
Joe ebaf07ff1f Merge branch 'master' into feature/certs-db 2026-01-29 09:16:35 -07:00
moloch-- fc95d3e354 Fix notifications build on windows 2026-01-29 08:15:32 -08:00
moloch-- 88dca9337b Fix notifications docs anchors 2026-01-29 08:11:17 -08:00
moloch-- 44d9f8d5aa Update cli to work with db CAs 2026-01-29 08:01:35 -08:00
moloch-- f76e9e367d Move CAs to db 2026-01-29 07:56:20 -08:00
moloch-- 69fa4143f6 Improve logging for invalid configs 2026-01-29 07:08:34 -08:00
moloch-- dcbda0c64e Improve logging for notifications 2026-01-29 06:50:18 -08:00
moloch-- 3db48408af Initial notifications implementation 2026-01-29 06:44:11 -08:00
moloch-- 828838cddf Re-add new gprc server config fields 2026-01-29 05:52:41 -08:00
moloch-- 16c1dc21fd Merge master 2026-01-29 05:47:18 -08:00
moloch-- 3c8edb7294 v1.6.8 2026-01-28 20:14:36 -08:00
moloch-- 2e2206e0ae Fix emtpy prompt after event 2026-01-28 19:58:08 -08:00
Joe a43648a24a Merge pull request #2146 from BishopFox/fix/websites-css
Fix/websites css
2026-01-28 19:50:20 -07:00
moloch-- f65f98bf65 Improve websites content-type sniffing issue #2033 2026-01-28 18:14:27 -08:00
Joe 26d14e15ad Merge pull request #2144 from BishopFox/fix/update
Fix/update
2026-01-28 19:07:58 -07:00
moloch-- cee2cc76c4 Convert tui-settings to yaml 2026-01-28 18:06:41 -08:00
moloch-- daa33b6301 Convert server configs to yaml syntax, with backwards compatibility 2026-01-28 17:59:34 -08:00
moloch-- ef52973536 Convert configs to yml 2026-01-28 17:39:14 -08:00
moloch-- 6a7bd0803a Update --save help 2026-01-28 17:21:35 -08:00
moloch-- 6c70c1c1e6 Add signature validation logic 2026-01-28 17:20:24 -08:00
moloch-- ddc222b8a2 Improvements to update 2026-01-28 17:07:47 -08:00
moloch-- 5506a582ac Add SliverPublicKey 2026-01-28 16:45:32 -08:00
Joe 63d872c98b Merge pull request #2142 from BishopFox/fix/sgn
Fix/sgn
2026-01-28 17:31:46 -07:00
moloch-- ef9f5dde0d Fix unit tests 2026-01-28 07:21:38 -08:00
moloch-- e35b7fdb97 Update sgn to v0.0.5 2026-01-27 21:28:21 -08:00
moloch-- efbbf382eb Merge branch 'master' into fix/sgn 2026-01-27 21:26:42 -08:00
moloch-- 0b8d00900d v1.6.7 2026-01-27 21:06:39 -08:00
moloch-- 9077596f25 Damn it Claude 2026-01-27 21:04:30 -08:00
moloch-- 0a8104a32e Damn it Claude 2026-01-27 21:03:25 -08:00
moloch-- 15a0ba4c1e Damn it Claude 2026-01-27 21:01:55 -08:00
moloch-- d452c8a676 Write .bin even if sgn fails 2026-01-27 20:49:28 -08:00
Joe fb74b491fa Merge pull request #2141 from BishopFox/fix/console-inputs
Update readline/console deps
2026-01-27 21:38:20 -07:00
moloch-- 2af6e11adc Update readline/console deps 2026-01-27 19:55:18 -08:00
moloch-- 33c656d082 Implement first part of crack cmd 2026-01-24 18:28:07 -08:00
moloch-- 99a44e340f Improve crack benchmark output 2026-01-24 11:50:20 -08:00
moloch-- de5fa3458b Allow keep alives 2026-01-23 17:05:53 -08:00
Joe a3a98858fd Merge pull request #2135 from BishopFox/fix/sqlite
Add sqlite pragmas to db config
2026-01-21 22:28:56 -07:00
moloch-- d0652f2fd3 Add sqlite pragmas to db config 2026-01-21 16:08:07 -08:00
Joe 936f067363 Merge pull request #2130 from BishopFox/feature/website-info
Feature/website info
2026-01-21 10:25:56 -07:00
moloch-- 041c3c7048 Improve tab completer for websites cmd 2026-01-20 21:29:39 -08:00
moloch-- b48f74af58 Add original file name and sha256 to websites cmd 2026-01-20 19:13:42 -08:00
moloch-- 6ec8849b20 v1.6.6 2026-01-16 09:18:39 -08:00
Joe db7cf1ed62 Merge pull request #2125 from BishopFox/fix/winlsdir
Improved `err` and `nil` checks in `dirListHandler`
2026-01-16 10:17:01 -07:00
moloch-- 16e1e77068 Implement better err and nil checks in dirListHandler issue #2122 2026-01-16 07:06:43 -08:00
moloch-- 1cfa694aa2 Merge branch 'update/deps' into tui/adv-forms 2026-01-16 06:57:06 -08:00
moloch-- 5898877136 Fix --rc scripts 2026-01-16 06:56:41 -08:00
moloch-- a7d8cac565 Update generate form 2026-01-16 06:41:58 -08:00
moloch-- 0e73afca47 Update deps 2026-01-16 06:07:10 -08:00
moloch-- 432941d694 Tab complete parent commands from submenu 2026-01-16 05:58:35 -08:00
moloch-- ac521bf53c v1.6.4 2026-01-15 16:10:06 -08:00
Joe 9720c9f14f Merge pull request #2121 from BishopFox/feature/flag-completers
Improved flag completers
2026-01-15 17:09:30 -07:00
moloch-- 4a224fdb9c Improve completers for --interact flags 2026-01-15 15:14:20 -08:00
moloch-- 671eb4d777 Add tab completer to 'use' cmd 2026-01-15 15:03:21 -08:00
moloch-- 16cf816610 Implement os and arch completers 2026-01-15 13:54:21 -08:00
moloch-- 879f576c6e Add MCP to docs 2026-01-15 08:05:02 -08:00
moloch-- 2df78cdbf5 Update readme 2026-01-15 07:45:49 -08:00
moloch-- a05ffc0ae1 Fix footer/card z-index issue in docs site 2026-01-15 07:44:30 -08:00
moloch-- 6b4b218f09 Tweak docs 2026-01-15 07:33:40 -08:00
Joe 4d0d46afd6 Merge pull request #2118 from BishopFox/exec/env
Updates to `execute`
2026-01-13 22:45:02 -07:00
Joe 3c46cf2210 Merge branch 'master' into socks5-stability 2025-10-24 11:41:27 -07:00
Ronan Kervella edcdfb5937 Merge branch 'master' into socks5-stability 2024-12-04 09:36:59 -10:00
Ronan Kervella f81efdb38c Merge branch 'master' into socks5-stability 2024-12-02 15:40:40 -08:00
Ronan Kervella db34ded0a1 Merge branch 'master' into socks5-stability 2024-11-18 15:06:32 -08:00
navsec 8a7a92a9b9 Refine implant/server signaling for tunnel closures 2024-11-18 17:02:15 -06:00
navsec 3eda32f9d7 Performance Optimizations and Implant SOCKS Changes 2024-11-17 15:01:04 -06:00
navsec c18c193ea7 Improve SOCKS lifecycle mangement and resolve leak 2024-11-06 23:24:44 -06:00
navsec 79a8758448 SOCKS5 Stability Improvements for HTTP/mTLS 2024-10-31 12:46:40 -05:00
7873 changed files with 1994836 additions and 224156 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
* @BishopFox/red-team
* @BishopFox/bf-red-team
+31 -12
View File
@@ -2,8 +2,8 @@ name: Release
on:
push:
branches:
- master
tags:
- "v*.*.*"
permissions:
contents: write
@@ -17,34 +17,53 @@ jobs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Find Semver Tag
- name: Validate Release Tag
id: tag
run: |
git fetch --prune --tags -f
tag=$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
git fetch --prune --tags -f origin
tag="${GITHUB_REF_NAME}"
if ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag $tag is not a release semver tag."
exit 0
fi
if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/master"; then
echo "Tag $tag does not point to a commit on origin/master."
exit 0
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
e2e:
name: End-to-end test suites
needs: tag-check
if: needs.tag-check.outputs.tag != ''
uses: ./.github/workflows/comprehensive-e2e.yml
release:
name: Release
needs: tag-check
if: needs.tag-check.outputs.tag != ''
needs:
- tag-check
- e2e
if: needs.tag-check.outputs.tag != '' && needs.e2e.result == 'success'
runs-on: ubuntu-latest
environment: release
timeout-minutes: 45
steps:
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Go 1.25
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "^1.25"
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.25
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "^1.25"
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
+551
View File
@@ -0,0 +1,551 @@
name: Comprehensive e2e Tests
on:
workflow_dispatch:
inputs:
test_scope:
description: Test scope to run across the complete platform matrix
required: true
default: comprehensive
type: choice
options:
- comprehensive
- rportfwd
workflow_call:
inputs:
test_scope:
description: Test scope to run across the complete platform matrix
required: false
default: comprehensive
type: string
permissions:
contents: read
env:
SLIVER_E2E_SCOPE: ${{ inputs.test_scope || 'comprehensive' }}
concurrency:
group: comprehensive-e2e-${{ github.ref }}
cancel-in-progress: false
jobs:
authorize:
name: Authorize invocation
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check actor repository permission
uses: actions/github-script@v9
env:
REQUESTING_ACTOR: ${{ github.triggering_actor }}
REQUESTED_SCOPE: ${{ inputs.test_scope || 'comprehensive' }}
with:
script: |
const testScope = process.env.REQUESTED_SCOPE || "comprehensive";
if (!new Set(["comprehensive", "rportfwd"]).has(testScope)) {
core.setFailed(`Unsupported E2E test scope: ${JSON.stringify(testScope)}`);
return;
}
core.info(`Validated E2E test scope ${testScope}`);
const isReleaseTag = context.eventName === "push" && context.ref.startsWith("refs/tags/v");
if (isReleaseTag) {
core.info(`Authorized release test invocation for ${context.ref}`);
return;
}
const actor = process.env.REQUESTING_ACTOR || context.actor;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: actor,
});
core.info(`${actor} has repository permission ${data.permission} (${data.role_name})`);
if (data.permission !== "admin") {
core.setFailed("Comprehensive e2e Tests may only be run by repository administrators");
}
rportfwd-security-regression:
name: Reverse-port-forward malicious protocol regression
needs: authorize
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
env:
SLIVER_ROOT_DIR: ${{ runner.temp }}/sliver-rportfwd-security
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Test malicious destination poisoning and cross-session isolation
env:
SLIVER_ROOT_DIR: ${{ runner.temp }}/sliver-rportfwd-security
run: >-
go test -buildvcs=false -mod=vendor
-tags=server,osusergo,netgo,go_sqlite,sliver_e2e
./server/c2
-run '^TestMTLSYamux_ReversePortForwardAuthorizationCannotBePoisonedOrReplayed$'
-count=1 -v
darwin:
name: comprehensive-e2e-darwin-${{ matrix.goarch }}
needs: authorize
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-intel
goarch: amd64
server_arch: amd64
- runner: macos-15
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'darwin/%s\n' '${{ matrix.goarch }}' > e2e-results/target-darwin-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: darwin
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o sliver-server ./server
- name: Compile comprehensive E2E driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: darwin
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o sliver-comprehensive-e2e ./test/e2e
- name: Run selected E2E scope
run: |
set -o pipefail
./sliver-comprehensive-e2e \
-test.v \
-test.run=^TestComprehensiveE2E$ \
-test.timeout=0 \
-repo . \
-server ./sliver-server \
-server-arch ${{ matrix.server_arch }} \
-target-os darwin \
-target-arch ${{ matrix.goarch }} \
-results ./e2e-results \
-transports mtls,wg,http \
-implant-modes session,beacon \
-beacon-interval 10s 2>&1 | tee ./e2e-results/selected-e2e.log
- name: Verify target reverse-port-forward coverage
if: ${{ always() }}
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input e2e-results
-target-os darwin
-target-arch ${{ matrix.goarch }}
-transports mtls,wg,http
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-darwin-${{ matrix.goarch }}
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
linux-native:
name: comprehensive-e2e-linux-${{ matrix.goarch }}
needs: authorize
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
goarch: amd64
server_arch: amd64
- runner: ubuntu-24.04-arm
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'linux/%s\n' '${{ matrix.goarch }}' > e2e-results/target-linux-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: linux
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o sliver-server ./server
- name: Compile comprehensive E2E driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: linux
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o sliver-comprehensive-e2e ./test/e2e
- name: Run selected E2E scope
run: |
set -o pipefail
./sliver-comprehensive-e2e \
-test.v \
-test.run=^TestComprehensiveE2E$ \
-test.timeout=0 \
-repo . \
-server ./sliver-server \
-server-arch ${{ matrix.server_arch }} \
-target-os linux \
-target-arch ${{ matrix.goarch }} \
-results ./e2e-results \
-transports mtls,wg,http \
-implant-modes session,beacon \
-beacon-interval 10s 2>&1 | tee ./e2e-results/selected-e2e.log
- name: Verify target reverse-port-forward coverage
if: ${{ always() }}
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input e2e-results
-target-os linux
-target-arch ${{ matrix.goarch }}
-transports mtls,wg,http
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-linux-${{ matrix.goarch }}
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
windows:
name: comprehensive-e2e-windows-${{ matrix.goarch }}
needs: authorize
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: windows-2022
goarch: amd64
server_arch: amd64
- runner: windows-11-arm
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Prepare coverage artifact
run: |
mkdir -p e2e-results
printf 'windows/%s\n' '${{ matrix.goarch }}' > e2e-results/target-windows-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: windows
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o sliver-server.exe ./server
- name: Compile comprehensive E2E driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: windows
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o sliver-comprehensive-e2e.exe ./test/e2e
- name: Run selected E2E scope
run: |
set -o pipefail
./sliver-comprehensive-e2e.exe \
-test.v \
-test.run=^TestComprehensiveE2E$ \
-test.timeout=0 \
-repo . \
-server ./sliver-server.exe \
-server-arch ${{ matrix.server_arch }} \
-target-os windows \
-target-arch ${{ matrix.goarch }} \
-results ./e2e-results \
-transports mtls,wg,http \
-implant-modes session,beacon \
-beacon-interval 10s 2>&1 | tee ./e2e-results/selected-e2e.log
- name: Verify target reverse-port-forward coverage
if: ${{ always() }}
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input e2e-results
-target-os windows
-target-arch ${{ matrix.goarch }}
-transports mtls,wg,http
- name: Upload target coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-target-windows-${{ matrix.goarch }}
path: e2e-results
if-no-files-found: error
overwrite: true
retention-days: 14
aggregate-rportfwd:
name: Aggregate reverse-port-forward E2E coverage
needs:
- authorize
- rportfwd-security-regression
- darwin
- linux-native
- windows
if: ${{ always() && needs.authorize.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download target coverage
id: download
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: comprehensive-e2e-target-*
path: rportfwd-coverage-input
- name: Prepare aggregate artifact
if: ${{ always() }}
run: |
mkdir -p rportfwd-coverage-input rportfwd-coverage-summary
printf 'Reverse-port-forward E2E coverage aggregate\n' > rportfwd-coverage-summary/README.txt
- name: Aggregate coverage and enforce all 162 required cells
id: aggregate
if: ${{ always() }}
continue-on-error: true
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
-input rportfwd-coverage-input
-output rportfwd-coverage-summary
- name: Publish reverse-port-forward coverage table
if: ${{ always() }}
run: |
report="rportfwd-coverage-summary/rportfwd-coverage.md"
if [[ -s "${report}" ]]; then
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
else
printf 'Reverse-port-forward coverage report is missing or empty: %s\n' "${report}" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Upload aggregate reverse-port-forward coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-rportfwd-coverage-summary
path: rportfwd-coverage-summary
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Fail on failed prerequisites, missing, malformed, or unavailable coverage
if: ${{ needs.rportfwd-security-regression.result != 'success' || needs.darwin.result != 'success' || needs.linux-native.result != 'success' || needs.windows.result != 'success' || steps.download.outcome != 'success' || steps.aggregate.outcome != 'success' }}
run: exit 1
aggregate-coverage:
name: Aggregate comprehensive E2E coverage
needs:
- authorize
- rportfwd-security-regression
- darwin
- linux-native
- windows
if: ${{ always() && needs.authorize.result == 'success' && inputs.test_scope != 'rportfwd' }}
outputs:
command_coverage_markdown: ${{ steps.publish_command_coverage.outputs.command_coverage_markdown }}
runs-on: ubuntu-24.04
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download target coverage
uses: actions/download-artifact@v8
with:
pattern: comprehensive-e2e-target-*
path: coverage-input
merge-multiple: true
- name: Prepare aggregate artifact
run: |
mkdir -p coverage-input coverage-summary
printf 'Comprehensive E2E coverage aggregate\n' > coverage-summary/README.txt
- name: Aggregate coverage and enforce required cells
id: aggregate
continue-on-error: true
run: >-
go run -buildvcs=false -mod=vendor ./test/e2e/report
-input coverage-input
-output coverage-summary
- name: Publish command coverage table
id: publish_command_coverage
if: ${{ always() }}
run: |
detailed_report="coverage-summary/coverage-summary.md"
report="coverage-summary/command-coverage.md"
if [[ ! -s "${report}" ]]; then
printf 'Command coverage report is missing or empty: %s\n' "${report}" >&2
exit 1
fi
delimiter="COMPREHENSIVE_E2E_COMMAND_COVERAGE_EOF"
if grep -Fxq "${delimiter}" "${report}"; then
printf 'Command coverage report contains the reserved output delimiter: %s\n' "${delimiter}" >&2
exit 1
fi
{
printf 'command_coverage_markdown<<%s\n' "${delimiter}"
cat "${report}"
printf '%s\n' "${delimiter}"
} >> "${GITHUB_OUTPUT}"
if [[ -f "${detailed_report}" ]]; then
cat "${detailed_report}" >> "${GITHUB_STEP_SUMMARY}"
fi
printf '\n## Command coverage by OS/architecture/transport\n\n' >> "${GITHUB_STEP_SUMMARY}"
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload aggregate coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: comprehensive-e2e-coverage-summary
path: coverage-summary
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Fail on failed prerequisites or failed or not-run coverage
if: ${{ needs.rportfwd-security-regression.result != 'success' || needs.darwin.result != 'success' || needs.linux-native.result != 'success' || needs.windows.result != 'success' || steps.aggregate.outcome != 'success' }}
run: exit 1
reflektor:
name: Existing Reflektor test matrix
needs: authorize
if: ${{ inputs.test_scope != 'rportfwd' }}
uses: ./.github/workflows/reflektor-tests.yml
shellcode:
name: Shellcode E2E test matrix
needs: authorize
if: ${{ inputs.test_scope != 'rportfwd' }}
uses: ./.github/workflows/shellcode-tests.yml
+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
+56
View File
@@ -0,0 +1,56 @@
name: golangci-lint
on:
push:
branches:
- master
pull_request:
branches:
- master
paths:
- ".golangci.yml"
- ".github/workflows/golangci-lint.yml"
- "go.mod"
- "go.sum"
- "go.work"
- "go.work.sum"
- "Makefile"
- "vendor/**"
- "**/*.go"
permissions:
contents: read
jobs:
lint:
name: golangci-lint (${{ matrix.goos }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
goos:
- linux
- darwin
- windows
env:
GOOS: ${{ matrix.goos }}
GOARCH: amd64
CGO_ENABLED: "0"
steps:
- name: Check Out Code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.3"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
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/...
+164
View File
@@ -0,0 +1,164 @@
name: Reflektor Integration Test Matrix
on:
workflow_call:
permissions:
contents: read
jobs:
darwin:
name: reflektor-darwin-${{ matrix.goarch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-intel
goarch: amd64
server_arch: amd64
- runner: macos-15
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -mod=vendor ./util/cmd/assets
- name: Build Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: darwin
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server ./server
- name: Build Reflektor integration driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: darwin
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e ./test/reflektor
- name: Load Sliver with Reflektor
run: >-
./sliver-reflektor-e2e
-repo .
-server ./sliver-server
-target-os darwin
-target-arch ${{ matrix.goarch }}
linux-native:
name: reflektor-linux-${{ matrix.goarch }}-native
runs-on: ${{ matrix.runner }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
goarch: amd64
server_arch: amd64
- runner: ubuntu-24.04-arm
goarch: arm64
server_arch: arm64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -mod=vendor ./util/cmd/assets
- name: Build Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: linux
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server ./server
- name: Build Reflektor integration driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: linux
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e ./test/reflektor
- name: Load Sliver with Reflektor
run: >-
./sliver-reflektor-e2e
-repo .
-server ./sliver-server
-target-os linux
-target-arch ${{ matrix.goarch }}
windows:
name: reflektor-windows-${{ matrix.goarch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
include:
- runner: windows-2022
goarch: amd64
server_arch: amd64
- runner: windows-11-arm
goarch: arm64
server_arch: amd64
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -mod=vendor ./util/cmd/assets
- name: Build Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: windows
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server.exe ./server
- name: Build Reflektor integration driver
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.goarch }}
GOOS: windows
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e.exe ./test/reflektor
- name: Load Sliver with Reflektor
run: >-
./sliver-reflektor-e2e.exe
-repo .
-server ./sliver-server.exe
-target-os windows
-target-arch ${{ matrix.goarch }}
+201
View File
@@ -0,0 +1,201 @@
name: Shellcode E2E Test Matrix
on:
workflow_call:
outputs:
shellcode_coverage_markdown:
description: Shellcode E2E coverage table in Markdown
value: ${{ jobs.aggregate.outputs.shellcode_coverage_markdown }}
permissions:
contents: read
jobs:
target:
name: shellcode-e2e-${{ matrix.target_os }}-${{ matrix.target_arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- runner: macos-26
target_os: darwin
target_arch: arm64
server_os: darwin
server_arch: arm64
server_executable: ./sliver-server
test_executable: ./sliver-shellcode-e2e
- runner: ubuntu-24.04
target_os: linux
target_arch: amd64
server_os: linux
server_arch: amd64
server_executable: ./sliver-server
test_executable: ./sliver-shellcode-e2e
- runner: ubuntu-24.04-arm
target_os: linux
target_arch: arm64
server_os: linux
server_arch: arm64
server_executable: ./sliver-server
test_executable: ./sliver-shellcode-e2e
- runner: windows-2022
target_os: windows
target_arch: amd64
server_os: windows
server_arch: amd64
server_executable: ./sliver-server.exe
test_executable: ./sliver-shellcode-e2e.exe
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Prepare target artifact
run: |
mkdir -p shellcode-results
printf '%s/%s\n' \
'${{ matrix.target_os }}' \
'${{ matrix.target_arch }}' \
> 'shellcode-results/target-${{ matrix.target_os }}-${{ matrix.target_arch }}.txt'
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Download Sliver build assets
run: go run -buildvcs=false -mod=vendor -trimpath ./util/cmd/assets
- name: Build unmodified Sliver server
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.server_arch }}
GOOS: ${{ matrix.server_os }}
run: >-
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
-o ${{ matrix.server_executable }} ./server
- name: Compile shellcode E2E client
env:
CGO_ENABLED: "0"
GOARCH: ${{ matrix.target_arch }}
GOOS: ${{ matrix.target_os }}
run: >-
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
-o ${{ matrix.test_executable }} ./test/e2e
- name: Run shellcode generation and execution tests
run: >-
${{ matrix.test_executable }}
-test.v
-test.run=^TestShellcodeE2E$
-test.timeout=0
-repo .
-server ${{ matrix.server_executable }}
-server-arch ${{ matrix.server_arch }}
-target-os ${{ matrix.target_os }}
-target-arch ${{ matrix.target_arch }}
-results ./shellcode-results
-transports mtls,wg,http
-implant-modes session,beacon
-beacon-interval 10s
-shellcode-sgn-samples 4
- name: Upload target shellcode results
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: shellcode-e2e-target-${{ matrix.target_os }}-${{ matrix.target_arch }}
path: shellcode-results
if-no-files-found: error
overwrite: true
retention-days: 14
aggregate:
name: Aggregate shellcode E2E coverage
needs: target
if: ${{ always() }}
outputs:
shellcode_coverage_markdown: ${{ steps.publish_shellcode_coverage.outputs.shellcode_coverage_markdown }}
runs-on: ubuntu-24.04
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Prepare aggregate artifact
run: |
mkdir -p shellcode-input shellcode-summary
printf 'Shellcode E2E coverage aggregate\n' > shellcode-summary/README.txt
- name: Download target shellcode results
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: shellcode-e2e-target-*
path: shellcode-input
merge-multiple: true
- name: Aggregate shellcode coverage
id: aggregate
continue-on-error: true
run: >-
go run -buildvcs=false -mod=vendor -trimpath ./test/e2e/shellcode-report
-input ./shellcode-input
-output ./shellcode-summary
- name: Publish shellcode coverage table
id: publish_shellcode_coverage
if: ${{ always() }}
run: |
report="shellcode-summary/shellcode-coverage.md"
if [[ ! -s "${report}" ]]; then
printf 'Shellcode coverage report is missing or empty: %s\n' "${report}" >&2
exit 1
fi
delimiter="SHELLCODE_E2E_COVERAGE_EOF"
if grep -Fxq "${delimiter}" "${report}"; then
printf 'Shellcode coverage report contains the reserved output delimiter: %s\n' "${delimiter}" >&2
exit 1
fi
{
printf 'shellcode_coverage_markdown<<%s\n' "${delimiter}"
cat "${report}"
printf '%s\n' "${delimiter}"
} >> "${GITHUB_OUTPUT}"
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload aggregate shellcode coverage
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: shellcode-e2e-coverage-summary
path: shellcode-summary
if-no-files-found: error
overwrite: true
retention-days: 30
- name: Fail on failed or not-run shellcode coverage
if: ${{ always() && steps.aggregate.outcome != 'success' }}
run: exit 1
+89 -16
View File
@@ -6,11 +6,15 @@ on:
paths:
- "go.mod"
- "go.sum"
- ".gitattributes"
- "client/command/opfor/testdata/corpus/**"
- ".github/workflows/unit-tests.yml"
- "Makefile"
- "**/*.go"
- "**/*.c"
- "**/*.h"
- "**/*.sh"
- "**/*.ps1"
types:
- opened
- reopened
@@ -18,15 +22,15 @@ on:
- ready_for_review
jobs:
linux-windows-build-test:
name: Linux & Windows Test
linux-build-test:
name: Linux Build & Test
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Go 1.25
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "^1.25"
go-version: "1.27.3"
- name: OS Packages
run: |
@@ -34,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
@@ -48,8 +52,64 @@ jobs:
- name: Make Linux (amd64)
run: make linux-amd64
- name: Determine go-tests flags
id: go-tests-flags
shell: bash
run: |
set -euo pipefail
base_sha="${{ github.event.pull_request.base.sha }}"
head_sha="${{ github.sha }}"
if git diff --name-only "$base_sha" "$head_sha" | grep -qE '^(implant/|server/gogo/|server/generate/)'; then
echo "args=" >> "$GITHUB_OUTPUT"
else
echo "args=--skip-generate" >> "$GITHUB_OUTPUT"
fi
- name: Test Linux
run: make clean && make && ./sliver-server unpack --force && ./go-tests.sh
run: make clean && make && ./sliver-server unpack --force && ./go-tests.sh ${{ steps.go-tests-flags.outputs.args }}
windows-build-test:
name: Windows Build & Test
runs-on: windows-latest
timeout-minutes: 180
steps:
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.3"
- name: Install make
run: choco install make -y --no-progress
- name: Check Out Code
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
- name: Make Windows (amd64)
run: make windows-amd64
- name: Determine go-tests flags
id: go-tests-flags
shell: pwsh
run: |
$baseSha = "${{ github.event.pull_request.base.sha }}"
$headSha = "${{ github.sha }}"
$changedFiles = git diff --name-only $baseSha $headSha
if ($changedFiles | Select-String -Pattern '^(implant/|server/gogo/|server/generate/)') {
"args=" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
} else {
"args=--skip-generate" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
}
- name: Test Windows
shell: pwsh
run: |
make clean
make
./go-tests.ps1 ${{ steps.go-tests-flags.outputs.args }}
macos-build-test:
@@ -57,13 +117,13 @@ jobs:
runs-on: macos-latest
timeout-minutes: 180
steps:
- name: Go 1.25
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "^1.25"
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
@@ -74,21 +134,34 @@ jobs:
- name: Make MacOS (arm64)
run: make macos-arm64
- name: Determine go-tests flags
id: go-tests-flags
shell: bash
run: |
set -euo pipefail
base_sha="${{ github.event.pull_request.base.sha }}"
head_sha="${{ github.sha }}"
if git diff --name-only "$base_sha" "$head_sha" | grep -qE '^(implant/|server/gogo/|server/generate/)'; then
echo "args=" >> "$GITHUB_OUTPUT"
else
echo "args=--skip-generate" >> "$GITHUB_OUTPUT"
fi
- name: Test MacOS
run: make clean && make && ./sliver-server unpack --force && ./go-tests.sh
run: make clean && make && ./sliver-server unpack --force && ./go-tests.sh ${{ steps.go-tests-flags.outputs.args }}
clients-build:
name: Clients Build
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Go 1.25
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "^1.25"
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
+5
View File
@@ -1,3 +1,8 @@
/crush
/examples
/.vscode
/.vscode/*
/.devcontainer
/.idea
+27
View File
@@ -0,0 +1,27 @@
version: "2"
run:
timeout: 10m
modules-download-mode: vendor
build-tags:
- client
- server
- osusergo
- netgo
- go_sqlite
# Raw implant entry points are Go templates; lint their supporting packages.
- sliver_lint
linters:
default: standard
enable:
- gocyclo
- misspell
- revive
settings:
gocyclo:
min-complexity: 15
formatters:
enable:
- gofmt
+1 -24
View File
@@ -1,41 +1,18 @@
# Repository Guidelines
## Project Structure & Module Organization
- `client/` holds the CLI client and interactive commands.
- `server/` contains the server runtime, C2 logic, configs, and web assets.
- `implant/` is the implant runtime and transport implementations.
- `protobuf/` stores protobuf definitions and generated Go types.
- `util/` is shared helpers (crypto, encoders, minisign, etc.).
- `docs/sliver-docs/` is the Next.js documentation site and prebuild scripts.
- `vendor/` is the vendored Go dependency tree used by builds.
- Build outputs land in the repo root as `sliver-client*` and `sliver-server*`.
## Build, Test, and Development Commands
- `make` builds server + client using vendored modules and runs `go-assets.sh`.
- `make client` builds a CGO-free client; `make debug` keeps symbols.
- `make pb` regenerates protobuf stubs from `protobuf/`.
- `./go-tests.sh` runs the curated Go test suite with build tags.
- `./go-tests.sh` runs the Go test suite, this is the only way you should run unit tests.
- Docs site: `cd docs/sliver-docs && npm install && npm run dev` for local dev; `npm run build` for a static build.
- Go toolchain: the Makefile enforces Go >= 1.24.
## Coding Style & Naming Conventions
- Run `gofmt` on all Go code; keep package names lowercase and idiomatic.
- Avoid `CGO` and empty interfaces; prefer explicit types and interfaces.
- Do not import `server` packages from `client`.
- If `math/rand` is required, import it as `insecureRand` and avoid security use.
## Testing Guidelines
- Go tests follow standard `_test.go` naming within each package.
- Prefer `docker build --target test --build-arg GO_TESTS_FLAGS=--skip-generate .`
- Add or update tests when behavior changes; some server packages run longer tests.
## Commit & Pull Request Guidelines
- Commits must be signed for PRs to `master`; work in a feature branch.
- Commit subjects are short and imperative (e.g., "Fix ...", "Add ...", "Update ...", "Bump ...").
- Changes to `vendor/` should be in a distinct commit.
- PRs should link the tracking issue, note tests run, and confirm `gofmt` plus unit tests as applicable.
## Security Notes
- Default to secure configuration; fail closed rather than open.
- Avoid insecure algorithms (MD5/SHA1/AES-ECB); prefer AES-GCM and modern curves.
- Treat user input as untrusted: canonicalize paths and use restrictive file permissions.
+294 -144
View File
@@ -2,180 +2,330 @@
# Makefile for Sliver
#
.DEFAULT_GOAL := default
GO ?= go
ARTIFACT_SUFFIX ?=
ARTIFACT_SUFFIX ?=
ENV =
TAGS ?= -tags go_sqlite
CGO_ENABLED = 0
GOLANGCI_LINT ?= golangci-lint
# The tree has legacy lint findings, so gate issues introduced by this change.
GOLANGCI_LINT_BASE ?= master
GOLANGCI_LINT_ARGS ?= --new-from-merge-base=$(GOLANGCI_LINT_BASE)
GOLANGCI_LINT_PACKAGES = \
./client/... \
./server/... \
./util/... \
./protobuf/... \
./docs \
./implant/...
ifneq (,$(findstring cgo_sqlite,$(TAGS)))
CGO_ENABLED = 1
endif
#
# Prerequisites
#
# https://stackoverflow.com/questions/5618615/check-if-a-program-exists-from-a-makefile
EXECUTABLES = uname sed git date cut $(GO)
K := $(foreach exec,$(EXECUTABLES),\
$(if $(shell which $(exec)),some string,$(error "No $(exec) in PATH")))
#
# Build Information
#
GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
MIN_SUPPORTED_GO_MINOR_VERSION = 24
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
ARMORY_PUBLIC_KEY ?= RWSBpxpRWDrD7Fe+VvRE3c2VEDC2NK80rlNCj+BX0gz44Xw07r6KQD9L
ARMORY_REPO_URL ?= https://api.github.com/repos/sliverarmory/armory/releases
CLIENT_ASSETS_PKG = github.com/bishopfox/sliver/client/assets
SLIVER_UPDATE_PKG = github.com/bishopfox/sliver/client/command/update
PB_COMPILERS = protoc protoc-gen-go protoc-gen-go-grpc
.PHONY: lint
lint:
$(GOLANGCI_LINT) run $(GOLANGCI_LINT_ARGS) $(GOLANGCI_LINT_PACKAGES)
ifneq ($(OS),Windows_NT)
#
# Prerequisites
#
# https://stackoverflow.com/questions/5618615/check-if-a-program-exists-from-a-makefile
EXECUTABLES = uname sed git date cut $(GO)
K := $(foreach exec,$(EXECUTABLES),\
$(if $(shell which $(exec)),some string,$(error "No $(exec) in PATH")))
#
# Build Information
#
GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
LDFLAGS = -ldflags "-s -w \
-X $(SLIVER_UPDATE_PKG).SliverPublicKey=$(SLIVER_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
# Debug builds shouldn't be stripped (-s -w flags)
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
SED_INPLACE := sed -i
STATIC_TARGET := linux
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
# Debug builds shouldn't be stripped (-s -w flags)
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
SED_INPLACE := sed -i
STATIC_TARGET := linux
UNAME_S := $(shell uname -s)
UNAME_P := $(shell uname -p)
# Programs required for generating protobuf/grpc files
PB_COMPILERS = protoc protoc-gen-go protoc-gen-go-grpc
ifeq ($(MAKECMDGOALS), pb)
K := $(foreach exec,$(PB_COMPILERS),\
$(if $(shell which $(exec)),some string,$(error "Missing protobuf util $(exec) in PATH")))
endif
# *** Darwin ***
ifeq ($(UNAME_S),Darwin)
SED_INPLACE := sed -i ''
STATIC_TARGET := macos
endif
# If no target is specified, determine GOARCH
ifeq ($(UNAME_P),arm)
ifeq ($(MAKECMDGOALS), )
ifeq ($(origin GOARCH), undefined)
ENV += GOARCH=arm64
endif
endif
endif
ifeq ($(MAKECMDGOALS), linux)
# Redefine LDFLAGS to add the static part
LDFLAGS = -ldflags "-s -w \
-extldflags '-static' \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
endif
# *** Darwin ***
ifeq ($(UNAME_S),Darwin)
SED_INPLACE := sed -i ''
STATIC_TARGET := macos
endif
# If no target is specified, determine GOARCH
ifeq ($(UNAME_P),arm)
ifeq ($(MAKECMDGOALS), )
ifeq ($(origin GOARCH), undefined)
ENV += GOARCH=arm64
endif
endif
endif
ifeq ($(MAKECMDGOALS), linux)
# Redefine LDFLAGS to add the static part
LDFLAGS = -ldflags "-s -w \
-extldflags '-static' \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) \
-X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
endif
#
# Targets
#
.PHONY: default
default: clean validate-go-version
env -u GOOS -u GOARCH $(MAKE) GOOS= GOARCH= .downloaded_assets
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
# Allows you to build a CGO-free client for any target e.g. `GOOS=windows GOARCH=arm64 make client`
# NOTE: WireGuard is not supported on all platforms, but most 64-bit GOOS/GOARCH combinations should work.
.PHONY: client
client: clean .downloaded_assets validate-go-version
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client ./client
.PHONY: macos-amd64
macos-amd64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: macos-arm64
macos-arm64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: linux-amd64
linux-amd64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: linux-arm64
linux-arm64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: windows-amd64
windows-amd64: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX).exe ./client
.PHONY: clients
clients: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-386$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=windows GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-386$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-amd64$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-arm64$(ARTIFACT_SUFFIX).exe ./client
GOOS=freebsd GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=freebsd GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-arm64$(ARTIFACT_SUFFIX) ./client
.PHONY: servers
servers: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-amd64$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-arm64$(ARTIFACT_SUFFIX).exe ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-amd64$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-amd64$(ARTIFACT_SUFFIX) ./server
.PHONY: pb
pb:
protoc -I protobuf/ protobuf/commonpb/common.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/sliverpb/sliver.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/clientpb/client.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
$(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor $(TAGS),server $(LDFLAGS_DEBUG) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor $(TAGS),client $(LDFLAGS_DEBUG) -o sliver-client$(ARTIFACT_SUFFIX) ./client
validate-go-version:
@if [ $(GO_MAJOR_VERSION) -gt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
exit 0 ;\
elif [ $(GO_MAJOR_VERSION) -lt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
elif [ $(GO_MINOR_VERSION) -lt $(MIN_SUPPORTED_GO_MINOR_VERSION) ] ; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
fi
.PHONY: clean-all
clean-all: clean
rm -rf ./server/assets/fs/darwin/amd64
rm -rf ./server/assets/fs/darwin/arm64
rm -rf ./server/assets/fs/windows/amd64
rm -rf ./server/assets/fs/linux/amd64
rm -f ./server/assets/fs/*.zip
rm -f ./.downloaded_assets
.PHONY: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
#
# Targets
# >>> WINDOWS <<<
#
.PHONY: default
default: clean validate-go-version
env -u GOOS -u GOARCH $(MAKE) GOOS= GOARCH= .downloaded_assets
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
else
# Allows you to build a CGO-free client for any target e.g. `GOOS=windows GOARCH=arm64 make client`
# NOTE: WireGuard is not supported on all platforms, but most 64-bit GOOS/GOARCH combinations should work.
.PHONY: client
client: clean .downloaded_assets validate-go-version
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client ./client
SHELL := cmd.exe
.SHELLFLAGS := /C
.PHONY: macos-amd64
macos-amd64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
GO_VERSION := $(patsubst go%,%,$(strip $(shell $(GO) env GOVERSION)))
GO_MAJOR_VERSION := $(word 1,$(subst ., ,$(GO_VERSION)))
GO_MINOR_VERSION := $(word 2,$(subst ., ,$(GO_VERSION)))
.PHONY: macos-arm64
macos-arm64: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
LDFLAGS = -ldflags "-s -w -X $(SLIVER_UPDATE_PKG).SliverPublicKey=$(SLIVER_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
.PHONY: linux-amd64
linux-amd64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
LDFLAGS_DEBUG = -ldflags "-X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
.PHONY: linux-arm64
linux-arm64: clean .downloaded_assets validate-go-version
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
.PHONY: windows-amd64
windows-amd64: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX).exe ./client
.PHONY: clients
clients: clean .downloaded_assets validate-go-version
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_macos-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-386$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_linux-arm64$(ARTIFACT_SUFFIX) ./client
GOOS=windows GOARCH=386 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-386$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-amd64$(ARTIFACT_SUFFIX).exe ./client
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_windows-arm64$(ARTIFACT_SUFFIX).exe ./client
GOOS=freebsd GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-amd64$(ARTIFACT_SUFFIX) ./client
GOOS=freebsd GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client_freebsd-arm64$(ARTIFACT_SUFFIX) ./client
.PHONY: servers
servers: clean .downloaded_assets validate-go-version
GOOS=windows GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-amd64$(ARTIFACT_SUFFIX).exe ./server
GOOS=windows GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_windows-arm64$(ARTIFACT_SUFFIX).exe ./server
GOOS=linux GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-amd64$(ARTIFACT_SUFFIX) ./server
GOOS=linux GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_linux-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=arm64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-arm64$(ARTIFACT_SUFFIX) ./server
GOOS=darwin GOARCH=amd64 $(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server_darwin-amd64$(ARTIFACT_SUFFIX) ./server
.PHONY: pb
pb:
protoc -I protobuf/ protobuf/commonpb/common.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/sliverpb/sliver.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/clientpb/client.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
$(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor $(TAGS),server $(LDFLAGS_DEBUG) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor $(TAGS),client $(LDFLAGS_DEBUG) -o sliver-client$(ARTIFACT_SUFFIX) ./client
validate-go-version:
@if [ $(GO_MAJOR_VERSION) -gt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
exit 0 ;\
elif [ $(GO_MAJOR_VERSION) -lt $(MIN_SUPPORTED_GO_MAJOR_VERSION) ]; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
elif [ $(GO_MINOR_VERSION) -lt $(MIN_SUPPORTED_GO_MINOR_VERSION) ] ; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
fi
.PHONY: clean-all
clean-all: clean
rm -rf ./server/assets/fs/darwin/amd64
rm -rf ./server/assets/fs/darwin/arm64
rm -rf ./server/assets/fs/windows/amd64
rm -rf ./server/assets/fs/linux/amd64
rm -f ./server/assets/fs/*.zip
rm -f ./.downloaded_assets
.PHONY: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
COMMA := ,
ifeq ($(MAKECMDGOALS), linux)
LDFLAGS = -ldflags "-s -w -extldflags '-static' -X $(CLIENT_ASSETS_PKG).DefaultArmoryPublicKey=$(ARMORY_PUBLIC_KEY) -X $(CLIENT_ASSETS_PKG).DefaultArmoryRepoURL=$(ARMORY_REPO_URL)"
endif
define windows_exec
$(strip $(foreach envvar,$(1),set "$(envvar)" && ))$(2)
endef
define windows_go_build
$(call windows_exec,$(ENV) GOOS=$(1) GOARCH=$(2) CGO_ENABLED=$(3),"$(GO)" build $(4) $(TAGS)$(COMMA)$(5) $(6) -o $(7) ./$(5))
endef
.PHONY: default
default: clean validate-go-version
$(call windows_exec,$(ENV) GOOS= GOARCH=,"$(MAKE)" GOOS= GOARCH= .downloaded_assets)
$(call windows_go_build,$(GOOS),$(GOARCH),$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: client
client: clean .downloaded_assets validate-go-version
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client)
.PHONY: macos-amd64
macos-amd64: clean .downloaded_assets validate-go-version
$(call windows_go_build,darwin,amd64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: macos-arm64
macos-arm64: clean .downloaded_assets validate-go-version
$(call windows_go_build,darwin,arm64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: linux-amd64
linux-amd64: clean .downloaded_assets validate-go-version
$(call windows_go_build,linux,amd64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: linux-arm64
linux-arm64: clean .downloaded_assets validate-go-version
$(call windows_go_build,linux,arm64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: windows-amd64
windows-amd64: clean .downloaded_assets validate-go-version
$(call windows_go_build,windows,amd64,$(CGO_ENABLED),-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client$(ARTIFACT_SUFFIX).exe)
.PHONY: clients
clients: clean .downloaded_assets validate-go-version
$(call windows_go_build,darwin,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_macos-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_macos-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,386,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_linux-386$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_linux-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_linux-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,windows,386,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_windows-386$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_windows-amd64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_windows-arm64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,freebsd,amd64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_freebsd-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,freebsd,arm64,0,-mod=vendor -trimpath,client,$(LDFLAGS),sliver-client_freebsd-arm64$(ARTIFACT_SUFFIX))
.PHONY: servers
servers: clean .downloaded_assets validate-go-version
$(call windows_go_build,windows,amd64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_windows-amd64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,windows,arm64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_windows-arm64$(ARTIFACT_SUFFIX).exe)
$(call windows_go_build,linux,amd64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_linux-amd64$(ARTIFACT_SUFFIX))
$(call windows_go_build,linux,arm64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_linux-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,arm64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_darwin-arm64$(ARTIFACT_SUFFIX))
$(call windows_go_build,darwin,amd64,0,-mod=vendor -trimpath,server,$(LDFLAGS),sliver-server_darwin-amd64$(ARTIFACT_SUFFIX))
.PHONY: pb
pb: validate-pb-compilers
protoc -I protobuf/ protobuf/commonpb/common.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/sliverpb/sliver.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/clientpb/client.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/dnspb/dns.proto --go_out=paths=source_relative:protobuf/
protoc -I protobuf/ protobuf/rpcpb/services.proto --go_out=paths=source_relative:protobuf/ --go-grpc_out=protobuf/ --go-grpc_opt=paths=source_relative
.PHONY: debug
debug: clean
$(call windows_go_build,$(GOOS),$(GOARCH),$(CGO_ENABLED),-mod=vendor,server,$(LDFLAGS_DEBUG),sliver-server$(ARTIFACT_SUFFIX))
$(call windows_go_build,$(GOOS),$(GOARCH),0,-mod=vendor,client,$(LDFLAGS_DEBUG),sliver-client$(ARTIFACT_SUFFIX))
.PHONY: validate-pb-compilers
validate-pb-compilers:
@for %%P in ($(PB_COMPILERS)) do @where.exe %%P >NUL || (echo Missing protobuf util %%P in PATH & exit /b 1)
validate-go-version:
@if $(GO_MAJOR_VERSION) GTR $(MIN_SUPPORTED_GO_MAJOR_VERSION) (exit /b 0) else if $(GO_MAJOR_VERSION) LSS $(MIN_SUPPORTED_GO_MAJOR_VERSION) (echo $(GO_VERSION_VALIDATION_ERR_MSG) & exit /b 1) else if $(GO_MINOR_VERSION) LSS $(MIN_SUPPORTED_GO_MINOR_VERSION) (echo $(GO_VERSION_VALIDATION_ERR_MSG) & exit /b 1)
.PHONY: clean-all
clean-all: clean
-rmdir /S /Q server\assets\fs\darwin\amd64
-rmdir /S /Q server\assets\fs\darwin\arm64
-rmdir /S /Q server\assets\fs\windows\amd64
-rmdir /S /Q server\assets\fs\linux\amd64
-del /Q /F server\assets\fs\*.zip 2>NUL
-del /Q /F .downloaded_assets 2>NUL
.PHONY: clean
clean:
-del /Q /F sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe 2>NUL
.downloaded_assets:
$(call windows_exec,$(ENV),"$(GO)" run -mod=vendor ./util/cmd/assets)
@type NUL > .downloaded_assets
endif
+7 -22
View File
@@ -1,27 +1,16 @@
# 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.
[![Release](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/BishopFox/sliver)](https://goreportcard.com/report/github.com/BishopFox/sliver) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
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.
### Features
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.
- 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!
[![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.
### Getting Started
@@ -39,10 +28,6 @@ Please checkout the [wiki](https://sliver.sh/), or start a [GitHub discussion](h
See the [wiki](https://sliver.sh/docs?name=Compile+from+Source).
### Feedback
Please take a moment and fill out [our survey](https://forms.gle/SwVsHFNh24ChG58C6).
### License - GPLv3
Sliver is licensed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html), some sub-components may have separate licenses. See their respective subdirectories in this project for details.
+12 -6
View File
@@ -34,12 +34,19 @@ const (
SliverClientDirName = ".sliver-client"
versionFileName = "version"
envVarName = "SLIVER_CLIENT_ROOT_DIR"
)
// GetRootAppDir - Get the Sliver app dir ~/.sliver-client/
func GetRootAppDir() string {
user, _ := user.Current()
dir := filepath.Join(user.HomeDir, SliverClientDirName)
value := os.Getenv(envVarName)
var dir string
if len(value) == 0 {
user, _ := user.Current()
dir = filepath.Join(user.HomeDir, SliverClientDirName)
} else {
dir = value
}
if _, err := os.Stat(dir); os.IsNotExist(err) {
err = os.MkdirAll(dir, 0700)
if err != nil {
@@ -108,7 +115,7 @@ func Setup(force bool, echo bool) {
if force || localVer == "" || localVer != ver.GitCommit {
if echo {
fmt.Printf(`
Sliver Copyright (C) 2025 Bishop Fox
Sliver Copyright (C) 2026 Bishop Fox
This program comes with ABSOLUTELY NO WARRANTY; for details type 'licenses'.
This is free software, and you are welcome to redistribute it
under certain conditions; type 'licenses' for details.`)
@@ -116,7 +123,6 @@ under certain conditions; type 'licenses' for details.`)
}
saveAssetVersion(appDir)
}
if _, err := os.Stat(filepath.Join(appDir, settingsFileName)); os.IsNotExist(err) {
SaveSettings(nil)
}
_, _ = LoadSettings()
_, _ = LoadSpoofMetadataConfig()
}
+18 -7
View File
@@ -34,15 +34,26 @@ const (
ConfigDirName = "configs"
)
// ClientWGConfig - Optional WireGuard wrapper configuration for multiplayer.
type ClientWGConfig struct {
Enabled bool `json:"enabled,omitempty"`
ServerPubKey string `json:"server_pub_key"`
ClientPrivateKey string `json:"client_private_key"`
ClientPubKey string `json:"client_pub_key"`
ClientIP string `json:"client_ip"`
ServerIP string `json:"server_ip"`
}
// ClientConfig - Client JSON config
type ClientConfig struct {
Operator string `json:"operator"` // This value is actually ignored for the most part (cert CN is used instead)
LHost string `json:"lhost"`
LPort int `json:"lport"`
Token string `json:"token"`
CACertificate string `json:"ca_certificate"`
PrivateKey string `json:"private_key"`
Certificate string `json:"certificate"`
Operator string `json:"operator"` // This value is actually ignored for the most part (cert CN is used instead)
LHost string `json:"lhost"`
LPort int `json:"lport"`
Token string `json:"token"`
CACertificate string `json:"ca_certificate"`
PrivateKey string `json:"private_key"`
Certificate string `json:"certificate"`
WG *ClientWGConfig `json:"wg,omitempty"`
}
// GetConfigDir - Returns the path to the config dir
+29
View File
@@ -0,0 +1,29 @@
package assets
import (
"encoding/json"
"testing"
)
func TestClientWGConfigEnabledIsPositiveOptIn(t *testing.T) {
legacyJSON := []byte(`{"wg":{"server_pub_key":"server-pub","client_private_key":"client-priv","client_ip":"100.64.0.2"}}`)
legacy := &ClientConfig{}
if err := json.Unmarshal(legacyJSON, legacy); err != nil {
t.Fatalf("unmarshal legacy config: %v", err)
}
if legacy.WG == nil {
t.Fatal("expected legacy WireGuard config")
}
if legacy.WG.Enabled {
t.Fatal("expected an absent enabled marker to remain disabled")
}
enabledJSON := []byte(`{"wg":{"enabled":true,"server_pub_key":"server-pub","client_private_key":"client-priv","client_ip":"100.64.0.2"}}`)
enabled := &ClientConfig{}
if err := json.Unmarshal(enabledJSON, enabled); err != nil {
t.Fatalf("unmarshal enabled config: %v", err)
}
if enabled.WG == nil || !enabled.WG.Enabled {
t.Fatal("expected enabled marker to opt in to WireGuard")
}
}
+27
View File
@@ -0,0 +1,27 @@
package assets
import (
"os"
"path/filepath"
"strings"
)
func legacyBackupPath(legacyPath string) string {
dir := filepath.Dir(legacyPath)
base := filepath.Base(legacyPath)
name := strings.TrimSuffix(base, filepath.Ext(base))
return filepath.Join(dir, "."+name+".json-old")
}
func renameLegacyConfig(legacyPath string) error {
if _, err := os.Stat(legacyPath); os.IsNotExist(err) {
return nil
}
backupPath := legacyBackupPath(legacyPath)
if _, err := os.Stat(backupPath); err == nil {
if err := os.Remove(backupPath); err != nil {
return err
}
}
return os.Rename(legacyPath, backupPath)
}
+92 -16
View File
@@ -22,39 +22,111 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const (
settingsFileName = "tui-settings.json"
settingsFileName = "tui-settings.yaml"
settingsLegacyFileName = "tui-settings.json"
)
const (
// Accepted values for tui-settings.yaml "prompt:":
// - host: show "[host]" prefix on sliver-client, "[server]" on server console
// - operator-host: show "[operator@host]" prefix on sliver-client, "[server]" on server console
// - basic: show "sliver >" (no prefix)
// - custom: render full prompt from prompt_template
PromptStyleHost = "host"
PromptStyleOperatorHost = "operator-host"
PromptStyleBasic = "basic"
PromptStyleCustom = "custom"
)
// ClientSettings - Client JSON config
type ClientSettings struct {
TableStyle string `json:"tables"`
AutoAdult bool `json:"autoadult"`
BeaconAutoResults bool `json:"beacon_autoresults"`
SmallTermWidth int `json:"small_term_width"`
AlwaysOverflow bool `json:"always_overflow"`
VimMode bool `json:"vim_mode"`
UserConnect bool `json:"user_connect"`
ConsoleLogs bool `json:"console_logs"`
TableStyle string `json:"tables" yaml:"tables"`
AutoAdult bool `json:"autoadult" yaml:"autoadult"`
BeaconAutoResults bool `json:"beacon_autoresults" yaml:"beacon_autoresults"`
SmallTermWidth int `json:"small_term_width" yaml:"small_term_width"`
AlwaysOverflow bool `json:"always_overflow" yaml:"always_overflow"`
VimMode bool `json:"vim_mode" yaml:"vim_mode"`
UserConnect bool `json:"user_connect" yaml:"user_connect"`
ConsoleLogs bool `json:"console_logs" yaml:"console_logs"`
PromptStyle string `json:"prompt" yaml:"prompt"`
PromptTemplate string `json:"prompt_template" yaml:"prompt_template"`
}
// LoadSettings - Load the client settings from disk
func LoadSettings() (*ClientSettings, error) {
rootDir, _ := filepath.Abs(GetRootAppDir())
data, err := os.ReadFile(filepath.Join(rootDir, settingsFileName))
if err != nil {
settingsPath := filepath.Join(rootDir, settingsFileName)
legacyPath := filepath.Join(rootDir, settingsLegacyFileName)
settings := defaultSettings()
migratedLegacy := false
data, err := os.ReadFile(settingsPath)
if err == nil {
if err = yaml.Unmarshal(data, settings); err != nil {
return defaultSettings(), err
}
} else if !os.IsNotExist(err) {
return defaultSettings(), err
} else if data, err = os.ReadFile(legacyPath); err == nil {
if err = json.Unmarshal(data, settings); err != nil {
return defaultSettings(), err
}
migratedLegacy = true
} else if !os.IsNotExist(err) {
return defaultSettings(), err
}
settings := defaultSettings()
err = json.Unmarshal(data, settings)
if err != nil {
return defaultSettings(), err
// Ensure any missing/unknown values are coerced to a supported prompt style.
settings.PromptStyle = NormalizePromptStyle(settings.PromptStyle)
if err := SaveSettings(settings); err != nil {
return settings, err
}
if migratedLegacy {
if err := renameLegacyConfig(legacyPath); err != nil {
return settings, err
}
}
return settings, nil
}
// NormalizePromptStyle canonicalizes prompt style strings and returns a safe
// default if the value is empty/unknown.
func NormalizePromptStyle(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case PromptStyleOperatorHost:
return PromptStyleOperatorHost
case PromptStyleHost:
return PromptStyleHost
case PromptStyleBasic:
return PromptStyleBasic
case PromptStyleCustom:
return PromptStyleCustom
// Backward compatible aliases.
case "show host":
return PromptStyleHost
case "show user and host":
return PromptStyleOperatorHost
case "show operator and host":
return PromptStyleOperatorHost
case "operator@host":
return PromptStyleOperatorHost
case "user@host":
return PromptStyleOperatorHost
case "":
return PromptStyleHost
default:
return PromptStyleHost
}
}
func defaultSettings() *ClientSettings {
return &ClientSettings{
TableStyle: "SliverDefault",
@@ -64,16 +136,20 @@ func defaultSettings() *ClientSettings {
AlwaysOverflow: false,
VimMode: false,
ConsoleLogs: true,
PromptStyle: PromptStyleHost,
PromptTemplate: DefaultPromptTemplate,
}
}
const DefaultPromptTemplate = `{{- if .IsServer -}}{{ .Styles.Bold.Render "[server]" }} {{ .Styles.Underline.Render "sliver" }}{{ .Target.Suffix }} > {{- else -}}{{- if .Host -}}{{ .Styles.BoldPrimary.Render (printf "[%s]" .Host) }} {{- end -}}{{ .Styles.Underline.Render "sliver" }}{{ .Target.Suffix }} > {{- end -}}`
// SaveSettings - Save the current settings to disk
func SaveSettings(settings *ClientSettings) error {
rootDir, _ := filepath.Abs(GetRootAppDir())
if settings == nil {
settings = defaultSettings()
}
data, err := json.MarshalIndent(settings, "", " ")
data, err := yaml.Marshal(settings)
if err != nil {
return err
}
+137
View File
@@ -0,0 +1,137 @@
package assets
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestLoadSettingsParsesYAML(t *testing.T) {
t.Setenv(envVarName, t.TempDir())
rootDir, _ := filepath.Abs(GetRootAppDir())
settingsPath := filepath.Join(rootDir, settingsFileName)
data := []byte(`tables: "Compact"
autoadult: true
beacon_autoresults: false
small_term_width: 120
always_overflow: true
vim_mode: true
user_connect: true
console_logs: false
prompt: "basic"
prompt_template: "{{.Host}}"
`)
if err := os.WriteFile(settingsPath, data, 0o600); err != nil {
t.Fatalf("failed to write yaml settings: %v", err)
}
settings, err := LoadSettings()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if settings.TableStyle != "Compact" {
t.Fatalf("expected TableStyle %q, got %q", "Compact", settings.TableStyle)
}
if !settings.AutoAdult {
t.Fatalf("expected AutoAdult true")
}
if settings.BeaconAutoResults {
t.Fatalf("expected BeaconAutoResults false")
}
if settings.SmallTermWidth != 120 {
t.Fatalf("expected SmallTermWidth %d, got %d", 120, settings.SmallTermWidth)
}
if !settings.AlwaysOverflow {
t.Fatalf("expected AlwaysOverflow true")
}
if !settings.VimMode {
t.Fatalf("expected VimMode true")
}
if !settings.UserConnect {
t.Fatalf("expected UserConnect true")
}
if settings.ConsoleLogs {
t.Fatalf("expected ConsoleLogs false")
}
if settings.PromptStyle != PromptStyleBasic {
t.Fatalf("expected PromptStyle %q, got %q", PromptStyleBasic, settings.PromptStyle)
}
if settings.PromptTemplate != "{{.Host}}" {
t.Fatalf("expected PromptTemplate %q, got %q", "{{.Host}}", settings.PromptTemplate)
}
}
func TestLoadSettingsMigratesLegacyJSON(t *testing.T) {
t.Setenv(envVarName, t.TempDir())
rootDir, _ := filepath.Abs(GetRootAppDir())
legacyPath := filepath.Join(rootDir, settingsLegacyFileName)
legacy := &ClientSettings{
TableStyle: "Legacy",
AutoAdult: true,
BeaconAutoResults: false,
SmallTermWidth: 200,
AlwaysOverflow: true,
VimMode: true,
UserConnect: true,
ConsoleLogs: false,
PromptStyle: PromptStyleOperatorHost,
PromptTemplate: "{{.Operator}}@{{.Host}} sliver > ",
}
data, err := json.MarshalIndent(legacy, "", " ")
if err != nil {
t.Fatalf("failed to marshal legacy settings: %v", err)
}
if err := os.WriteFile(legacyPath, data, 0o600); err != nil {
t.Fatalf("failed to write legacy settings: %v", err)
}
settings, err := LoadSettings()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if settings.TableStyle != "Legacy" {
t.Fatalf("expected TableStyle %q, got %q", "Legacy", settings.TableStyle)
}
if settings.PromptStyle != PromptStyleOperatorHost {
t.Fatalf("expected PromptStyle %q, got %q", PromptStyleOperatorHost, settings.PromptStyle)
}
if settings.PromptTemplate != "{{.Operator}}@{{.Host}} sliver > " {
t.Fatalf("expected PromptTemplate %q, got %q", "{{.Operator}}@{{.Host}} sliver > ", settings.PromptTemplate)
}
if _, err := os.Stat(filepath.Join(rootDir, settingsFileName)); err != nil {
t.Fatalf("expected yaml settings to exist: %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("expected legacy settings to be renamed: %v", err)
}
if _, err := os.Stat(legacyBackupPath(legacyPath)); err != nil {
t.Fatalf("expected legacy backup to exist: %v", err)
}
}
func TestLoadSettingsWritesDefault(t *testing.T) {
t.Setenv(envVarName, t.TempDir())
settings, err := LoadSettings()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if settings.TableStyle != "SliverDefault" {
t.Fatalf("expected default TableStyle %q, got %q", "SliverDefault", settings.TableStyle)
}
if settings.PromptStyle != PromptStyleHost {
t.Fatalf("expected default PromptStyle %q, got %q", PromptStyleHost, settings.PromptStyle)
}
if settings.PromptTemplate != DefaultPromptTemplate {
t.Fatalf("expected default PromptTemplate %q, got %q", DefaultPromptTemplate, settings.PromptTemplate)
}
rootDir, _ := filepath.Abs(GetRootAppDir())
if _, err := os.Stat(filepath.Join(rootDir, settingsFileName)); err != nil {
t.Fatalf("expected default settings to be written: %v", err)
}
}
+216
View File
@@ -0,0 +1,216 @@
package assets
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
const spoofMetadataFileName = "spoof-metadata.yaml"
const defaultSpoofMetadataConfigTemplate = `# Sliver executable metadata spoofing configuration.
# Used by generate commands when --spoof-metadata is set.
#
# Fields that reference file content support either:
# - path: read bytes from a local file path
# - base64: inline base64 file content
# Do not set both path and base64 for the same object.
#
# Example 1: Minimal path-based metadata cloning (most common)
pe:
source:
name: metadata-source.exe
path: ""
# Optional icon donor. Reserved for future standalone icon mutation support.
icon:
name: icon.ico
path: ""
# Example 2: Inline/base64 source (uncomment and remove "path" above)
# source:
# name: metadata-source.exe
# base64: "TVqQAAMAAAAEAAAA..."
# Example 3: Optional PE structure overrides (advanced)
# Applied after metadata/resources are cloned from source.
# Omitted fields keep cloned values.
# resource_directory:
# characteristics: 0
# time_date_stamp: 0
# major_version: 1
# minor_version: 0
# number_of_named_entries: 0
# number_of_id_entries: 0
#
# resource_directory_entries:
# - name: 1
# offset_to_data: 0
#
# resource_data_entries:
# - offset_to_data: 0
# size: 0
# code_page: 0
# reserved: 0
#
# export_directory:
# characteristics: 0
# time_date_stamp: 0
# major_version: 1
# minor_version: 0
# name: 0
# base: 0
# number_of_functions: 0
# number_of_names: 0
# address_of_functions: 0
# address_of_names: 0
# address_of_name_ordinals: 0
`
// SpoofMetadataConfig stores default metadata spoofing inputs for generate commands.
type SpoofMetadataConfig struct {
PE *PESpoofMetadataConfig `json:"pe,omitempty" yaml:"pe,omitempty"`
}
// PESpoofMetadataConfig stores PE-specific spoofing inputs.
type PESpoofMetadataConfig struct {
Source *SpoofMetadataDataSource `json:"source,omitempty" yaml:"source,omitempty"`
Icon *SpoofMetadataDataSource `json:"icon,omitempty" yaml:"icon,omitempty"`
ResourceDirectory *ImageResourceDirectory `json:"resource_directory,omitempty" yaml:"resource_directory,omitempty"`
ResourceDirectoryEntries []*ImageResourceDirectoryEntry `json:"resource_directory_entries,omitempty" yaml:"resource_directory_entries,omitempty"`
ResourceDataEntries []*ImageResourceDataEntry `json:"resource_data_entries,omitempty" yaml:"resource_data_entries,omitempty"`
ExportDirectory *ImageExportDirectory `json:"export_directory,omitempty" yaml:"export_directory,omitempty"`
}
// SpoofMetadataDataSource points to bytes that should be sent to the server.
// Either Path or Base64 may be set.
type SpoofMetadataDataSource struct {
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
Base64 string `json:"base64,omitempty" yaml:"base64,omitempty"`
}
type ImageResourceDirectory struct {
Characteristics uint32 `json:"characteristics,omitempty" yaml:"characteristics,omitempty"`
TimeDateStamp uint32 `json:"time_date_stamp,omitempty" yaml:"time_date_stamp,omitempty"`
MajorVersion uint32 `json:"major_version,omitempty" yaml:"major_version,omitempty"`
MinorVersion uint32 `json:"minor_version,omitempty" yaml:"minor_version,omitempty"`
NumberOfNamedEntries uint32 `json:"number_of_named_entries,omitempty" yaml:"number_of_named_entries,omitempty"`
NumberOfIDEntries uint32 `json:"number_of_id_entries,omitempty" yaml:"number_of_id_entries,omitempty"`
}
type ImageResourceDirectoryEntry struct {
Name uint32 `json:"name,omitempty" yaml:"name,omitempty"`
OffsetToData uint32 `json:"offset_to_data,omitempty" yaml:"offset_to_data,omitempty"`
}
type ImageResourceDataEntry struct {
OffsetToData uint32 `json:"offset_to_data,omitempty" yaml:"offset_to_data,omitempty"`
Size uint32 `json:"size,omitempty" yaml:"size,omitempty"`
CodePage uint32 `json:"code_page,omitempty" yaml:"code_page,omitempty"`
Reserved uint32 `json:"reserved,omitempty" yaml:"reserved,omitempty"`
}
type ImageExportDirectory struct {
Characteristics uint32 `json:"characteristics,omitempty" yaml:"characteristics,omitempty"`
TimeDateStamp uint32 `json:"time_date_stamp,omitempty" yaml:"time_date_stamp,omitempty"`
MajorVersion uint32 `json:"major_version,omitempty" yaml:"major_version,omitempty"`
MinorVersion uint32 `json:"minor_version,omitempty" yaml:"minor_version,omitempty"`
Name uint32 `json:"name,omitempty" yaml:"name,omitempty"`
Base uint32 `json:"base,omitempty" yaml:"base,omitempty"`
NumberOfFunctions uint32 `json:"number_of_functions,omitempty" yaml:"number_of_functions,omitempty"`
NumberOfNames uint32 `json:"number_of_names,omitempty" yaml:"number_of_names,omitempty"`
AddressOfFunctions uint32 `json:"address_of_functions,omitempty" yaml:"address_of_functions,omitempty"`
AddressOfNames uint32 `json:"address_of_names,omitempty" yaml:"address_of_names,omitempty"`
AddressOfNameOrdinals uint32 `json:"address_of_name_ordinals,omitempty" yaml:"address_of_name_ordinals,omitempty"`
}
// SpoofMetadataConfigPath returns the spoof metadata config file path.
func SpoofMetadataConfigPath() string {
rootDir, _ := filepath.Abs(GetRootAppDir())
return filepath.Join(rootDir, spoofMetadataFileName)
}
// LoadSpoofMetadataConfig loads spoof metadata config from disk, writing defaults
// if the file is missing.
func LoadSpoofMetadataConfig() (*SpoofMetadataConfig, error) {
config := defaultSpoofMetadataConfig()
configPath := SpoofMetadataConfigPath()
data, err := os.ReadFile(configPath)
if err != nil {
if !os.IsNotExist(err) {
return defaultSpoofMetadataConfig(), err
}
if err := os.WriteFile(configPath, []byte(defaultSpoofMetadataConfigTemplate), 0o600); err != nil {
return defaultSpoofMetadataConfig(), err
}
data = []byte(defaultSpoofMetadataConfigTemplate)
}
if err := yaml.Unmarshal(data, config); err != nil {
return defaultSpoofMetadataConfig(), err
}
normalizeSpoofMetadataConfig(config)
return config, nil
}
// SaveSpoofMetadataConfig writes spoof metadata config to disk.
func SaveSpoofMetadataConfig(config *SpoofMetadataConfig) error {
if config == nil {
config = defaultSpoofMetadataConfig()
}
normalizeSpoofMetadataConfig(config)
data, err := yaml.Marshal(config)
if err != nil {
return err
}
return os.WriteFile(SpoofMetadataConfigPath(), data, 0o600)
}
func normalizeSpoofMetadataConfig(config *SpoofMetadataConfig) {
if config.PE == nil {
config.PE = &PESpoofMetadataConfig{}
}
if config.PE.Source == nil {
config.PE.Source = &SpoofMetadataDataSource{Name: "metadata-source.exe"}
}
if config.PE.Icon == nil {
config.PE.Icon = &SpoofMetadataDataSource{Name: "icon.ico"}
}
}
func defaultSpoofMetadataConfig() *SpoofMetadataConfig {
return &SpoofMetadataConfig{
PE: &PESpoofMetadataConfig{
Source: &SpoofMetadataDataSource{
Name: "metadata-source.exe",
Path: "",
},
Icon: &SpoofMetadataDataSource{
Name: "icon.ico",
Path: "",
},
},
}
}
+33 -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,14 @@ 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)
// Create the console client, without any RPC or commands bound to it yet.
// This created before anything so that multiple commands can make use of
@@ -93,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)
}
}
+20 -8
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() *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 _, config := range configs {
return config
for key, config := range configs {
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() *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 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)
}
}
+177
View File
@@ -0,0 +1,177 @@
package cli
import (
"fmt"
"io"
"strings"
"time"
"unicode/utf8"
bspinner "charm.land/bubbles/v2/spinner"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
const (
minConnectSpinnerDuration = 900 * time.Millisecond
minConnectStatusDuration = 350 * time.Millisecond
)
type connectResult struct {
rpc rpcpb.SliverRPCClient
conn *grpc.ClientConn
err error
}
func connectWithSpinner(out io.Writer, target string, connect func(transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error)) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusCh := make(chan string, 8)
resultCh := make(chan connectResult, 1)
go func() {
rpc, conn, err := connect(func(status string) {
sendStatus(statusCh, status)
})
resultCh <- connectResult{rpc: rpc, conn: conn, err: err}
}()
currentStatus := ""
currentStatusSince := time.Time{}
queuedStatus := ""
lastWidth := 0
spinner := bspinner.New(bspinner.WithSpinner(bspinner.Line))
ticker := time.NewTicker(spinner.Spinner.FPS)
defer ticker.Stop()
startedAt := time.Now()
var pendingResult *connectResult
setStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" || status == currentStatus {
return
}
currentStatus = status
currentStatusSince = time.Now()
}
stageStatus := func(status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
if currentStatus == "" || time.Since(currentStatusSince) >= minConnectStatusDuration {
queuedStatus = ""
setStatus(status)
return
}
queuedStatus = status
}
flushQueuedStatus := func() {
if queuedStatus == "" || time.Since(currentStatusSince) < minConnectStatusDuration {
return
}
nextStatus := queuedStatus
queuedStatus = ""
setStatus(nextStatus)
}
canReturnPendingResult := func() bool {
if pendingResult == nil {
return false
}
if pendingResult.err != nil {
return true
}
if time.Since(startedAt) < minConnectSpinnerDuration {
return false
}
if queuedStatus != "" {
return false
}
if !currentStatusSince.IsZero() && time.Since(currentStatusSince) < minConnectStatusDuration {
return false
}
return true
}
render := func() {
line := fmt.Sprintf("%s %s", spinner.View(), formatConnectSpinnerMessage(target, currentStatus))
lastWidth = writeSpinnerLine(out, line, lastWidth)
}
render()
for {
select {
case status := <-statusCh:
stageStatus(status)
spinner, _ = spinner.Update(spinner.Tick())
render()
case result := <-resultCh:
pendingResult = &result
case <-ticker.C:
spinner, _ = spinner.Update(spinner.Tick())
flushQueuedStatus()
render()
if canReturnPendingResult() {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
if pendingResult != nil && pendingResult.err != nil {
result := *pendingResult
pendingResult = nil
clearSpinnerLine(out, lastWidth)
return result.rpc, result.conn, result.err
}
}
}
func sendStatus(statusCh chan string, status string) {
status = strings.TrimSpace(status)
if status == "" {
return
}
statusCh <- status
}
func formatConnectSpinnerMessage(target string, status string) string {
target = strings.TrimSpace(target)
status = strings.TrimSpace(status)
if target == "" {
if status == "" {
return "Connecting ..."
}
return fmt.Sprintf("Connecting (%s) ...", status)
}
if status == "" {
return fmt.Sprintf("Connecting to %s ...", target)
}
return fmt.Sprintf("Connecting to %s (%s) ...", target, status)
}
func writeSpinnerLine(out io.Writer, line string, lastWidth int) int {
width := utf8.RuneCountInString(line)
padding := ""
if lastWidth > width {
padding = strings.Repeat(" ", lastWidth-width)
}
fmt.Fprintf(out, "\r%s%s", line, padding)
if width > lastWidth {
return width
}
return lastWidth
}
func clearSpinnerLine(out io.Writer, lastWidth int) {
if lastWidth <= 0 {
return
}
fmt.Fprintf(out, "\r%s\r", strings.Repeat(" ", lastWidth))
}
+145
View File
@@ -0,0 +1,145 @@
package cli
import (
"bytes"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
)
func TestFormatConnectSpinnerMessage(t *testing.T) {
tests := []struct {
name string
target string
status string
want string
}{
{
name: "target only",
target: "127.0.0.1:31337",
want: "Connecting to 127.0.0.1:31337 ...",
},
{
name: "target with status",
target: "127.0.0.1:31337",
status: "wireguard",
want: "Connecting to 127.0.0.1:31337 (wireguard) ...",
},
{
name: "status without target",
status: "grpc/mtls",
want: "Connecting (grpc/mtls) ...",
},
{
name: "trim whitespace",
target: " 127.0.0.1:31337 ",
status: " grpc/mtls over wireguard ",
want: "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := formatConnectSpinnerMessage(test.target, test.status); got != test.want {
t.Fatalf("expected %q, got %q", test.want, got)
}
})
}
}
func TestConnectWithSpinnerOutput(t *testing.T) {
synctest.Test(t, testConnectWithSpinnerOutput)
}
func testConnectWithSpinnerOutput(t *testing.T) {
var out bytes.Buffer
spinnerDelay := 2 * (100 * time.Millisecond)
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
statusFn("wireguard")
time.Sleep(spinnerDelay)
statusFn("grpc/mtls over wireguard")
time.Sleep(spinnerDelay)
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected wireguard status in output, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected grpc over wireguard status in output, got %q", got)
}
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames in output, got %q", got)
}
}
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) {
statusFn("grpc/mtls")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
frameCount := 0
for _, frame := range []string{"|", "/", "-", "\\"} {
if strings.Contains(got, frame+" Connecting to 127.0.0.1:31337 (grpc/mtls) ...") {
frameCount++
}
}
if frameCount < 2 {
t.Fatalf("expected multiple spinner frames for fast success, got %q", got)
}
}
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) {
statusFn("wireguard")
statusFn("grpc/mtls over wireguard")
return nil, nil, nil
})
if err != nil {
t.Fatalf("connectWithSpinner returned error: %v", err)
}
got := out.String()
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (wireguard) ...") {
t.Fatalf("expected fast success output to include wireguard status, got %q", got)
}
if !strings.Contains(got, "Connecting to 127.0.0.1:31337 (grpc/mtls over wireguard) ...") {
t.Fatalf("expected fast success output to include grpc over wireguard status, got %q", got)
}
if strings.Contains(got, "Connected to 127.0.0.1:31337") {
t.Fatalf("did not expect past-tense success output, got %q", got)
}
}
+26
View File
@@ -0,0 +1,26 @@
package cli
import (
"context"
"fmt"
"os"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
)
func handleConnectionLost(ln *grpc.ClientConn) {
if ln == nil {
return
}
currentState := ln.GetState()
// currentState should be "Ready" when the connection is established.
if ln.WaitForStateChange(context.Background(), currentState) {
newState := ln.GetState()
// newState will be "Idle" if the connection is lost.
if newState == connectivity.Idle {
fmt.Println("\nLost connection to server. Exiting now.")
os.Exit(1)
}
}
}
+37 -47
View File
@@ -19,20 +19,21 @@ package cli
*/
import (
"context"
"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"
"google.golang.org/grpc/connectivity"
)
// 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{
@@ -46,66 +47,55 @@ func consoleCmd(con *console.SliverClient) *cobra.Command {
}
func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *cobra.Command, args []string) error) {
var ln *grpc.ClientConn
pre = func(cmd *cobra.Command, _ []string) error {
configs := assets.GetConfigs()
if len(configs) == 0 {
fmt.Printf("No config files found at %s (see --help)\n", assets.GetConfigDir())
return nil
}
config := selectConfig()
if config == nil {
return nil
if err := applyMultiplayerConnectMode(cmd); err != nil {
return err
}
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)
var rpc rpcpb.SliverRPCClient
var ln *grpc.ClientConn
// Don't clobber output when simply running an implant command from system shell.
if run {
fmt.Printf("Connecting to %s:%d ...\n", config.LHost, config.LPort)
if run && stdinTTY {
rpc, ln, err = connectWithSpinner(os.Stdout, target, func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
return transport.MTLSConnectWithStatus(config, statusFn)
})
} else {
rpc, ln, err = transport.MTLSConnect(config)
}
var rpc rpcpb.SliverRPCClient
rpc, ln, err = transport.MTLSConnect(config)
if err != nil {
fmt.Printf("Connection to server failed %s", err)
return nil
return fmt.Errorf("connection to server failed: %w", err)
}
// Wait for any connection state changes and exit if the connection is lost.
go handleConnectionLost(ln)
return console.StartClient(con, rpc, 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
post = func(_ *cobra.Command, _ []string) error {
if ln != nil {
return ln.Close()
}
return nil
return con.CloseConnection()
}
return pre, post
}
func handleConnectionLost(ln *grpc.ClientConn) {
currentState := ln.GetState()
// currentState should be "Ready" when the connection is established.
if ln.WaitForStateChange(context.Background(), currentState) {
newState := ln.GetState()
// newState will be "Idle" if the connection is lost.
if newState == connectivity.Idle {
fmt.Println("\nLost connection to server. Exiting now.")
os.Exit(1)
}
}
}
+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)
}
}
+19 -3
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,11 +40,14 @@ func mcpCmd(con *console.SliverClient) *cobra.Command {
return runMCP(cmd, con)
},
}
mcpCmd.Flags().String("config", "", "path to client config file")
return mcpCmd
}
func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
if err := applyMultiplayerConnectMode(cmd); err != nil {
return err
}
configPath, _ := cmd.Flags().GetString("config")
config, err := loadMCPClientConfig(configPath)
if err != nil {
@@ -50,15 +55,26 @@ 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)
return nil
}
defer ln.Close()
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 {
+42
View File
@@ -0,0 +1,42 @@
package cli
import (
"fmt"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
)
const (
enableWGFlag = "enable-wg"
disableWGFlag = "disable-wg"
)
func applyMultiplayerConnectMode(cmd *cobra.Command) error {
if cmd == nil {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
return nil
}
enableWG, err := cmd.Flags().GetBool(enableWGFlag)
if err != nil {
return err
}
disableWG, err := cmd.Flags().GetBool(disableWGFlag)
if err != nil {
return err
}
if cmd.Flags().Changed(enableWGFlag) && cmd.Flags().Changed(disableWGFlag) {
return fmt.Errorf("--%s and --%s cannot be used together", enableWGFlag, disableWGFlag)
}
mode := transport.MultiplayerConnectAuto
switch {
case enableWG:
mode = transport.MultiplayerConnectEnableWG
case disableWG:
mode = transport.MultiplayerConnectDisableWG
}
transport.SetMultiplayerConnectMode(mode)
return nil
}
+136
View File
@@ -0,0 +1,136 @@
package cli
import (
"strings"
"testing"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
)
func TestWireGuardOverrideFlagsDefaultToFalse(t *testing.T) {
for _, name := range []string{enableWGFlag, disableWGFlag} {
flag := rootCmd.PersistentFlags().Lookup(name)
if flag == nil {
t.Fatalf("expected --%s persistent flag", name)
}
if flag.DefValue != "false" {
t.Fatalf("expected --%s to default to false, got %q", name, flag.DefValue)
}
}
}
func TestApplyMultiplayerConnectModeDefaultsToAuto(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := applyMultiplayerConnectMode(cmd); err != nil {
t.Fatalf("apply multiplayer mode: %v", err)
}
if !transport.MultiplayerConnectUsesWireGuard(enabledWGClientConfig()) {
t.Fatal("expected automatic mode to honor an explicitly enabled WireGuard config")
}
if transport.MultiplayerConnectUsesWireGuard(legacyWGClientConfig()) {
t.Fatal("expected automatic mode to ignore a legacy WireGuard config without the opt-in marker")
}
}
func TestApplyMultiplayerConnectModeEnableWGForcesLegacyConfig(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(enableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", enableWGFlag, err)
}
if err := applyMultiplayerConnectMode(cmd); err != nil {
t.Fatalf("apply multiplayer mode: %v", err)
}
if !transport.MultiplayerConnectUsesWireGuard(legacyWGClientConfig()) {
t.Fatal("expected --enable-wg to force a complete legacy config through WireGuard")
}
}
func TestApplyMultiplayerConnectModeDisableWGOverridesEnabledConfig(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(disableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", disableWGFlag, err)
}
if err := applyMultiplayerConnectMode(cmd); err != nil {
t.Fatalf("apply multiplayer mode: %v", err)
}
if transport.MultiplayerConnectUsesWireGuard(enabledWGClientConfig()) {
t.Fatal("expected --disable-wg to force direct mTLS")
}
}
func TestApplyMultiplayerConnectModeRejectsConflictingOverrides(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(enableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", enableWGFlag, err)
}
if err := cmd.Flags().Set(disableWGFlag, "true"); err != nil {
t.Fatalf("set --%s: %v", disableWGFlag, err)
}
err := applyMultiplayerConnectMode(cmd)
if err == nil {
t.Fatal("expected conflicting WireGuard overrides to fail")
}
if !strings.Contains(err.Error(), "cannot be used together") {
t.Fatalf("unexpected conflict error: %v", err)
}
}
func TestApplyMultiplayerConnectModeRejectsBothOverridesWhenExplicitlyFalse(t *testing.T) {
resetMultiplayerConnectModeForTest(t)
cmd := newTransportModeTestCommand()
if err := cmd.Flags().Set(enableWGFlag, "false"); err != nil {
t.Fatalf("set --%s: %v", enableWGFlag, err)
}
if err := cmd.Flags().Set(disableWGFlag, "false"); err != nil {
t.Fatalf("set --%s: %v", disableWGFlag, err)
}
err := applyMultiplayerConnectMode(cmd)
if err == nil {
t.Fatal("expected explicitly combining both WireGuard override flags to fail")
}
if !strings.Contains(err.Error(), "cannot be used together") {
t.Fatalf("unexpected conflict error: %v", err)
}
}
func newTransportModeTestCommand() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().Bool(enableWGFlag, false, "")
cmd.Flags().Bool(disableWGFlag, false, "")
return cmd
}
func resetMultiplayerConnectModeForTest(t *testing.T) {
t.Helper()
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
t.Cleanup(func() {
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
})
}
func enabledWGClientConfig() *assets.ClientConfig {
config := legacyWGClientConfig()
config.WG.Enabled = true
return config
}
func legacyWGClientConfig() *assets.ClientConfig {
return &assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientPrivateKey: "client-priv",
ClientIP: "100.64.0.2",
},
}
}
+2
View File
@@ -12,6 +12,7 @@ Shared registration glue, scaffolding, and helpers for Sliver client console com
## Sub-packages
- `ai/` – Implements the 'ai' Bubble Tea layout preview command for the Sliver client console.
- `alias/` – Implements the 'alias' command group for the Sliver client console.
- `armory/` – Implements the 'armory' command group for the Sliver client console.
- `backdoor/` – Implements the 'backdoor' command group for the Sliver client console.
@@ -25,6 +26,7 @@ Shared registration glue, scaffolding, and helpers for Sliver client console com
- `creds/` – Implements the 'creds' command group for the Sliver client console.
- `cursed/` – Implements the 'cursed' command group for the Sliver client console.
- `dllhijack/` – Implements the 'dllhijack' command group for the Sliver client console.
- `edit/` – Implements the 'edit' command for the Sliver client console.
- `environment/` – Implements the 'environment' command group for the Sliver client console.
- `exec/` – Implements the 'exec' command group for the Sliver client console.
- `exit/` – Implements the 'exit' command group for the Sliver client console.
+5
View File
@@ -0,0 +1,5 @@
# client/command/ai
Implements the `ai` command for the Sliver client console.
The TUI loads server-backed AI conversation threads over gRPC, submits prompts to the server, and refreshes when `AIConversationEvent` updates arrive with assistant replies or failure messages.
+242
View File
@@ -0,0 +1,242 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"fmt"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/termio"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/colorprofile"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var openAIProgramTTY = tea.OpenTTY
// AICmd launches the AI conversation TUI.
func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
if con == nil || con.Rpc == nil {
if con != nil {
con.PrintErrorf("Connect to a server before using `ai`.\n")
}
return
}
listenerID, listener := con.CreateEventListener()
defer con.RemoveEventListener(listenerID)
restoreEventNotifications := con.SuppressEventNotifications()
defer restoreEventNotifications()
model := newAIModel(con, buildAIContext(con), listener)
model.showExperimentalWarningModal()
width, height := 100, 30
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
width, height = w, h
}
opts := []tea.ProgramOption{
tea.WithWindowSize(width, height),
tea.WithColorProfile(colorprofile.TrueColor),
}
ttyOpts, cleanup := configureAIProgramTTY()
if cleanup != nil {
defer cleanup()
}
opts = append(opts, ttyOpts...)
program := tea.NewProgram(model, opts...)
if _, err := program.Run(); err != nil {
con.PrintErrorf("AI TUI error: %s\n", err)
}
}
// Console logging can swap stdout/stderr to pipes so session output can be tee'd
// to disk. Bubble Tea needs a real TTY output to enable terminal features like
// mouse reporting, so bind the AI TUI back to the interactive terminal when
// stdout is no longer the controlling TTY.
func configureAIProgramTTY() ([]tea.ProgramOption, func()) {
stdinTTY := isAITTY(os.Stdin)
stdoutTTY := isAITTY(os.Stdout)
switch {
case !stdinTTY || stdoutTTY:
return nil, nil
case isAITTY(termio.InteractiveInput()) && isAITTY(termio.InteractiveOutput()):
return []tea.ProgramOption{
tea.WithInput(termio.InteractiveInput()),
tea.WithOutput(termio.InteractiveOutput()),
}, nil
default:
inTTY, outTTY, err := openAIProgramTTY()
if err != nil {
return nil, nil
}
return []tea.ProgramOption{
tea.WithInput(inTTY),
tea.WithOutput(outTTY),
}, func() {
_ = inTTY.Close()
if outTTY != inTTY {
_ = outTTY.Close()
}
}
}
}
func isAITTY(file *os.File) bool {
return file != nil && term.IsTerminal(int(file.Fd()))
}
type aiContext struct {
target aiTargetSummary
connection aiConnectionSummary
status string
}
type aiTargetSummary struct {
SessionID string
BeaconID string
Label string
Host string
OS string
Arch string
C2 string
Mode string
Details []string
}
type aiConnectionSummary struct {
Profile string
Server string
Operator string
State string
}
func defaultAITargetSummary() aiTargetSummary {
return aiTargetSummary{
Label: "No active target",
Host: "Select a session or beacon with `use` or ctrl+s",
Mode: "offline preview",
C2: "n/a",
OS: "unknown",
Arch: "unknown",
}
}
func aiSessionTargetSummary(session *clientpb.Session) aiTargetSummary {
if session == nil {
return defaultAITargetSummary()
}
return aiTargetSummary{
SessionID: session.ID,
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
Host: fallback(session.Hostname, "<unknown host>"),
OS: fallback(session.OS, "unknown"),
Arch: fallback(session.Arch, "unknown"),
C2: fallback(session.ActiveC2, "unknown"),
Mode: "interactive session",
Details: []string{
fmt.Sprintf("User: %s", fallback(session.Username, "<unknown>")),
fmt.Sprintf("PID: %d", session.PID),
fmt.Sprintf("Remote: %s", fallback(session.RemoteAddress, "<unknown>")),
},
}
}
func aiBeaconTargetSummary(beacon *clientpb.Beacon) aiTargetSummary {
if beacon == nil {
return defaultAITargetSummary()
}
return aiTargetSummary{
BeaconID: beacon.ID,
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
Host: fallback(beacon.Hostname, "<unknown host>"),
OS: fallback(beacon.OS, "unknown"),
Arch: fallback(beacon.Arch, "unknown"),
C2: fallback(beacon.ActiveC2, "unknown"),
Mode: "asynchronous beacon",
Details: []string{
fmt.Sprintf("User: %s", fallback(beacon.Username, "<unknown>")),
fmt.Sprintf("PID: %d", beacon.PID),
fmt.Sprintf("Remote: %s", fallback(beacon.RemoteAddress, "<unknown>")),
fmt.Sprintf("Interval: %s", time.Duration(beacon.Interval).String()),
fmt.Sprintf("Next checkin: %s", formatUnix(beacon.NextCheckin)),
},
}
}
func buildAIContext(con *console.SliverClient) aiContext {
ctx := aiContext{
target: defaultAITargetSummary(),
connection: aiConnectionSummary{
Profile: "<disconnected>",
Server: "<unknown>",
Operator: "<unknown>",
State: "idle",
},
status: "Loading AI conversations from the server...",
}
if con != nil {
if details, state, ok := con.CurrentConnection(); ok {
ctx.connection.State = strings.ToLower(state.String())
if details != nil && details.Config != nil {
ctx.connection.Profile = fallback(details.ConfigKey, "<profile unavailable>")
ctx.connection.Server = fmt.Sprintf("%s:%d", details.Config.LHost, details.Config.LPort)
ctx.connection.Operator = fallback(details.Config.Operator, "<unknown>")
}
}
session, beacon := con.ActiveTarget.Get()
switch {
case session != nil:
ctx.target = aiSessionTargetSummary(session)
case beacon != nil:
ctx.target = aiBeaconTargetSummary(beacon)
}
}
return ctx
}
func fallback(value, def string) string {
value = strings.TrimSpace(value)
if value == "" {
return def
}
return value
}
func formatUnix(ts int64) string {
if ts <= 0 {
return "<unknown>"
}
return time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05")
}
+49
View File
@@ -0,0 +1,49 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
// Commands returns the ai command.
func Commands(con *console.SliverClient) []*cobra.Command {
return []*cobra.Command{newAICommand(consts.SliverCoreHelpGroup, con)}
}
// ServerCommands returns the ai command for the top-level client REPL.
func ServerCommands(con *console.SliverClient) []*cobra.Command {
return []*cobra.Command{newAICommand(consts.GenericHelpGroup, con)}
}
func newAICommand(groupID string, con *console.SliverClient) *cobra.Command {
return &cobra.Command{
Use: consts.AIStr,
Short: "Open the Sliver AI conversation TUI",
Long: help.GetHelpFor([]string{consts.AIStr}),
Args: cobra.NoArgs,
GroupID: groupID,
Run: func(cmd *cobra.Command, args []string) {
AICmd(cmd, con, args)
},
}
}
+591
View File
@@ -0,0 +1,591 @@
package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"context"
"net"
"sync"
"testing"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/proto"
)
func TestLoadAIStateCmdLoadsConversationOverRPC(t *testing.T) {
server := &aiRPCServer{
providersResp: &clientpb.AIProviderConfigs{
Providers: []*clientpb.AIProviderConfig{{Name: "openai", Configured: true}},
Config: &clientpb.AIConfigSummary{
Provider: "openai",
Model: "gpt-test",
Valid: true,
},
},
conversationsResp: &clientpb.AIConversations{
Conversations: []*clientpb.AIConversation{
{ID: "conv-1", Provider: "openai", Model: "gpt-test", Title: "First"},
{ID: "conv-2", Provider: "openai", Model: "gpt-test", Title: "Second"},
},
},
conversationByID: map[string]*clientpb.AIConversation{
"conv-1": {
ID: "conv-1",
Provider: "openai",
Model: "gpt-test",
Title: "First",
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "Hello"},
},
},
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
cmd := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")
msg := cmd()
loaded, ok := msg.(aiLoadedMsg)
if !ok {
t.Fatalf("expected aiLoadedMsg, got %T", msg)
}
if loaded.selectedID != "conv-1" {
t.Fatalf("unexpected selected conversation id: got=%q want=%q", loaded.selectedID, "conv-1")
}
if loaded.conversation == nil || loaded.conversation.GetID() != "conv-1" {
t.Fatalf("unexpected loaded conversation: %+v", loaded.conversation)
}
if len(loaded.conversations) != 2 {
t.Fatalf("unexpected conversation count: got=%d want=%d", len(loaded.conversations), 2)
}
server.mu.Lock()
defer server.mu.Unlock()
if server.getProvidersCalls != 1 {
t.Fatalf("expected GetAIProviders to be called once, got %d", server.getProvidersCalls)
}
if server.getConversationsCalls != 1 {
t.Fatalf("expected GetAIConversations to be called once, got %d", server.getConversationsCalls)
}
if len(server.getConversationReqs) != 1 {
t.Fatalf("expected GetAIConversation to be called once, got %d", len(server.getConversationReqs))
}
if server.getConversationReqs[0].GetID() != "conv-1" || !server.getConversationReqs[0].GetIncludeMessages() {
t.Fatalf("unexpected GetAIConversation request: %+v", server.getConversationReqs[0])
}
}
func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
server := &aiRPCServer{
providersResp: &clientpb.AIProviderConfigs{
Providers: []*clientpb.AIProviderConfig{{Name: "openai", Configured: true}},
Config: &clientpb.AIConfigSummary{
Provider: "openai",
Model: "gpt-test",
SystemPrompt: "Stay concise.",
Valid: true,
},
},
conversationsResp: &clientpb.AIConversations{},
saveConversationResp: &clientpb.AIConversation{
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
SystemPrompt: "Stay concise.",
},
conversationByID: map[string]*clientpb.AIConversation{
"conv-created": {
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "New conversation",
SystemPrompt: "Stay concise.",
},
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")()
loaded, ok := msg.(aiLoadedMsg)
if !ok {
t.Fatalf("expected aiLoadedMsg, got %T", msg)
}
if loaded.status != "Created a new AI conversation." {
t.Fatalf("unexpected status: %q", loaded.status)
}
if loaded.selectedID != "conv-created" {
t.Fatalf("unexpected selected conversation id: %q", loaded.selectedID)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.saveConversationReqs) != 1 {
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
}
request := server.saveConversationReqs[0]
if request.GetProvider() != "openai" || request.GetModel() != "gpt-test" || request.GetTitle() != "New conversation" {
t.Fatalf("unexpected SaveAIConversation request: %+v", request)
}
if request.GetSystemPrompt() != "Stay concise." {
t.Fatalf("unexpected SaveAIConversation system prompt: %q", request.GetSystemPrompt())
}
}
func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
server := &aiRPCServer{
saveConversationResp: &clientpb.AIConversation{
ID: "conv-created",
Provider: "openai",
Model: "gpt-test",
Title: "Explain the workflow.",
},
saveMessageResp: &clientpb.AIConversationMessage{
ID: "msg-1",
ConversationID: "conv-created",
Role: "user",
Content: "Explain the workflow.\n\nWith details.",
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := submitPromptCmd(
&console.SliverClient{Rpc: rpcClient},
aiTargetSummary{SessionID: "session-1"},
nil,
"openai",
"gpt-test",
"Stay concise.",
"Explain the workflow.\n\nWith details.",
)()
submitted, ok := msg.(aiPromptSubmittedMsg)
if !ok {
t.Fatalf("expected aiPromptSubmittedMsg, got %T", msg)
}
if submitted.conversationID != "conv-created" {
t.Fatalf("unexpected conversation id: %q", submitted.conversationID)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.saveConversationReqs) != 1 {
t.Fatalf("expected SaveAIConversation to be called once, got %d", len(server.saveConversationReqs))
}
if server.saveConversationReqs[0].GetTitle() != "Explain the workflow." {
t.Fatalf("unexpected conversation title: %q", server.saveConversationReqs[0].GetTitle())
}
if server.saveConversationReqs[0].GetTargetSessionID() != "session-1" {
t.Fatalf("expected target session to be forwarded, got %+v", server.saveConversationReqs[0])
}
if server.saveConversationReqs[0].GetSystemPrompt() != "Stay concise." {
t.Fatalf("unexpected conversation system prompt: %q", server.saveConversationReqs[0].GetSystemPrompt())
}
if len(server.saveMessageReqs) != 1 {
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
}
request := server.saveMessageReqs[0]
if request.GetConversationID() != "conv-created" || request.GetProvider() != "openai" || request.GetModel() != "gpt-test" || request.GetRole() != "user" {
t.Fatalf("unexpected SaveAIConversationMessage request: %+v", request)
}
if request.GetContent() != "Explain the workflow.\n\nWith details." {
t.Fatalf("unexpected prompt content: %q", request.GetContent())
}
}
func TestSubmitPromptCmdUsesExistingConversationSettings(t *testing.T) {
server := &aiRPCServer{
saveMessageResp: &clientpb.AIConversationMessage{
ID: "msg-1",
ConversationID: "conv-1",
Role: "user",
Content: "hello",
},
}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := submitPromptCmd(
&console.SliverClient{Rpc: rpcClient},
aiTargetSummary{},
&clientpb.AIConversation{ID: "conv-1", Provider: "anthropic", Model: "claude-test", Title: "Thread"},
"openai",
"gpt-test",
"Stay concise.",
"hello",
)()
if _, ok := msg.(aiPromptSubmittedMsg); !ok {
t.Fatalf("expected aiPromptSubmittedMsg, got %T", msg)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.saveConversationReqs) != 0 {
t.Fatalf("did not expect SaveAIConversation to be called, got %d calls", len(server.saveConversationReqs))
}
if len(server.saveMessageReqs) != 1 {
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
}
request := server.saveMessageReqs[0]
if request.GetProvider() != "anthropic" || request.GetModel() != "claude-test" {
t.Fatalf("unexpected conversation settings in message request: %+v", request)
}
}
func TestDeleteConversationCmdSendsDeleteRequest(t *testing.T) {
server := &aiRPCServer{}
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := deleteConversationCmd(
&console.SliverClient{Rpc: rpcClient},
"conv-1",
"conv-2",
`Deleted "Thread".`,
)()
deleted, ok := msg.(aiConversationDeletedMsg)
if !ok {
t.Fatalf("expected aiConversationDeletedMsg, got %T", msg)
}
if deleted.conversationID != "conv-1" || deleted.selectedID != "conv-2" {
t.Fatalf("unexpected delete message: %+v", deleted)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.deleteConversationReqs) != 1 {
t.Fatalf("expected DeleteAIConversation to be called once, got %d", len(server.deleteConversationReqs))
}
if server.deleteConversationReqs[0].GetID() != "conv-1" {
t.Fatalf("unexpected DeleteAIConversation request: %+v", server.deleteConversationReqs[0])
}
}
func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
eventPayload := &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
Conversation: &clientpb.AIConversation{
ID: "conv-1",
OperatorName: "alice",
},
}
data, err := proto.Marshal(eventPayload)
if err != nil {
t.Fatalf("marshal conversation event: %v", err)
}
listener := make(chan *clientpb.Event, 2)
listener <- &clientpb.Event{EventType: consts.WatchtowerEvent}
listener <- &clientpb.Event{EventType: consts.AIConversationEvent, Data: data}
msg := waitForAIConversationEventCmd(listener)()
aiEvent, ok := msg.(aiConversationEventMsg)
if !ok {
t.Fatalf("expected aiConversationEventMsg, got %T", msg)
}
if aiEvent.event == nil || aiEvent.event.GetConversation() == nil || aiEvent.event.GetConversation().GetID() != "conv-1" {
t.Fatalf("unexpected AI conversation event payload: %+v", aiEvent.event)
}
}
func TestWaitForAIConversationEventCmdReturnsToastMessages(t *testing.T) {
listener := make(chan *clientpb.Event, 1)
listener <- &clientpb.Event{
EventType: consts.ClientToastEvent,
Data: []byte("Session alpha connected"),
Err: "info",
}
msg := waitForAIConversationEventCmd(listener)()
toast, ok := msg.(aiToastMsg)
if !ok {
t.Fatalf("expected aiToastMsg, got %T", msg)
}
if toast.level != "info" || toast.message != "Session alpha connected" {
t.Fatalf("unexpected toast payload: %+v", toast)
}
}
func TestWaitForAIConversationEventCmdReturnsClosedMessage(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
msg := waitForAIConversationEventCmd(listener)()
if _, ok := msg.(aiListenerClosedMsg); !ok {
t.Fatalf("expected aiListenerClosedMsg, got %T", msg)
}
}
func TestAIModelAppliesSharedConversationEventsWithoutReload(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
model := newAIModel(nil, aiContext{
connection: aiConnectionSummary{Operator: "alice"},
}, listener)
model.loading = false
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "First"}}
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
Conversation: &clientpb.AIConversation{
ID: "conv-2",
OperatorName: "bob",
Title: "Shared thread",
},
}})
nextModel := updated.(*aiModel)
if nextModel.loading {
t.Fatal("did not expect shared conversation event to trigger a reload")
}
if conversationIndexByID(nextModel.conversations, "conv-2") < 0 {
t.Fatalf("expected shared conversation to be merged locally, got %+v", nextModel.conversations)
}
if cmd == nil {
t.Fatal("expected shared conversation event to continue listening for events")
}
}
func TestAIModelAppliesDeleteEventsWhileAwaitingResponse(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
model := newAIModel(nil, aiContext{
connection: aiConnectionSummary{Operator: "alice"},
}, listener)
model.loading = false
model.awaitingResponse = true
model.currentConversation = &clientpb.AIConversation{
ID: "conv-2",
OperatorName: "alice",
UpdatedAt: 100,
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "still waiting"},
},
}
model.conversations = []*clientpb.AIConversation{
{ID: "conv-2", Title: "Current"},
{ID: "conv-1", Title: "Fallback"},
}
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED,
ConversationID: "conv-2",
Conversation: &clientpb.AIConversation{
ID: "conv-2",
},
}})
nextModel := updated.(*aiModel)
if nextModel.loading {
t.Fatal("did not expect delete tombstone to trigger a reload")
}
if nextModel.currentConversation != nil {
t.Fatalf("expected deleted conversation to be cleared, got %+v", nextModel.currentConversation)
}
if conversationIndexByID(nextModel.conversations, "conv-2") >= 0 {
t.Fatalf("expected deleted conversation to be removed, got %+v", nextModel.conversations)
}
if cmd == nil {
t.Fatal("expected delete tombstone to continue listening for events")
}
}
type aiRPCServer struct {
rpcpb.UnimplementedSliverRPCServer
mu sync.Mutex
providersResp *clientpb.AIProviderConfigs
conversationsResp *clientpb.AIConversations
conversationByID map[string]*clientpb.AIConversation
saveConversationResp *clientpb.AIConversation
saveMessageResp *clientpb.AIConversationMessage
sessionsResp *clientpb.Sessions
beaconsResp *clientpb.Beacons
getProvidersCalls int
getConversationsCalls int
getSessionsCalls int
getBeaconsCalls int
getConversationReqs []*clientpb.AIConversationReq
deleteConversationReqs []*clientpb.AIConversationReq
saveConversationReqs []*clientpb.AIConversation
saveMessageReqs []*clientpb.AIConversationMessage
getConversationStart chan struct{}
getConversationWait <-chan struct{}
saveMessageStarted chan struct{}
saveMessageRelease <-chan struct{}
}
func (s *aiRPCServer) GetAIProviders(context.Context, *commonpb.Empty) (*clientpb.AIProviderConfigs, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getProvidersCalls++
if s.providersResp == nil {
return &clientpb.AIProviderConfigs{}, nil
}
return proto.Clone(s.providersResp).(*clientpb.AIProviderConfigs), nil
}
func (s *aiRPCServer) GetAIConversations(context.Context, *commonpb.Empty) (*clientpb.AIConversations, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getConversationsCalls++
if s.conversationsResp == nil {
return &clientpb.AIConversations{}, nil
}
return proto.Clone(s.conversationsResp).(*clientpb.AIConversations), nil
}
func (s *aiRPCServer) GetSessions(context.Context, *commonpb.Empty) (*clientpb.Sessions, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getSessionsCalls++
if s.sessionsResp == nil {
return &clientpb.Sessions{}, nil
}
return proto.Clone(s.sessionsResp).(*clientpb.Sessions), nil
}
func (s *aiRPCServer) GetBeacons(context.Context, *commonpb.Empty) (*clientpb.Beacons, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getBeaconsCalls++
if s.beaconsResp == nil {
return &clientpb.Beacons{}, nil
}
return proto.Clone(s.beaconsResp).(*clientpb.Beacons), nil
}
func (s *aiRPCServer) GetAIConversation(_ context.Context, req *clientpb.AIConversationReq) (*clientpb.AIConversation, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.getConversationReqs = append(s.getConversationReqs, proto.Clone(req).(*clientpb.AIConversationReq))
if s.getConversationStart != nil {
select {
case s.getConversationStart <- struct{}{}:
default:
}
}
if s.getConversationWait != nil {
s.mu.Unlock()
<-s.getConversationWait
s.mu.Lock()
}
if s.conversationByID == nil {
return &clientpb.AIConversation{}, nil
}
conversation := s.conversationByID[req.GetID()]
if conversation == nil {
return &clientpb.AIConversation{}, nil
}
return proto.Clone(conversation).(*clientpb.AIConversation), nil
}
func (s *aiRPCServer) SaveAIConversation(_ context.Context, req *clientpb.AIConversation) (*clientpb.AIConversation, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.saveConversationReqs = append(s.saveConversationReqs, proto.Clone(req).(*clientpb.AIConversation))
if s.saveConversationResp == nil {
return proto.Clone(req).(*clientpb.AIConversation), nil
}
return proto.Clone(s.saveConversationResp).(*clientpb.AIConversation), nil
}
func (s *aiRPCServer) DeleteAIConversation(_ context.Context, req *clientpb.AIConversationReq) (*commonpb.Empty, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.deleteConversationReqs = append(s.deleteConversationReqs, proto.Clone(req).(*clientpb.AIConversationReq))
return &commonpb.Empty{}, nil
}
func (s *aiRPCServer) SaveAIConversationMessage(_ context.Context, req *clientpb.AIConversationMessage) (*clientpb.AIConversationMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.saveMessageReqs = append(s.saveMessageReqs, proto.Clone(req).(*clientpb.AIConversationMessage))
if s.saveMessageStarted != nil {
select {
case s.saveMessageStarted <- struct{}{}:
default:
}
}
if s.saveMessageRelease != nil {
s.mu.Unlock()
<-s.saveMessageRelease
s.mu.Lock()
}
if s.saveMessageResp == nil {
return proto.Clone(req).(*clientpb.AIConversationMessage), nil
}
return proto.Clone(s.saveMessageResp).(*clientpb.AIConversationMessage), nil
}
func newAITestRPCClient(t *testing.T, srv rpcpb.SliverRPCServer) (rpcpb.SliverRPCClient, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
grpcServer := grpc.NewServer()
rpcpb.RegisterSliverRPCServer(grpcServer, srv)
go func() {
_ = grpcServer.Serve(listener)
}()
dialer := func(context.Context, string) (net.Conn, error) { return listener.Dial() }
conn, err := grpc.DialContext(context.Background(), "bufnet",
grpc.WithContextDialer(dialer),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
grpcServer.Stop()
_ = listener.Close()
t.Fatalf("dial bufconn: %v", err)
}
cleanup := func() {
_ = conn.Close()
grpcServer.Stop()
_ = listener.Close()
}
return rpcpb.NewSliverRPCClient(conn), cleanup
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+251
View File
@@ -0,0 +1,251 @@
package alias
/*
Sliver Implant Framework
Copyright (C) 2021 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
)
// Alias commands run with cobra's DisableFlagParsing enabled (see LoadAlias),
// the same treatment extension commands received for their arguments. Before
// that, an unknown flag such as the payload's own `-pid` was rejected outright
// by pflag ("unknown flag"), and operators had to inject cobra's "--"
// separator (documented on the wiki) to pass arguments through at all (#2264).
//
// With flag parsing disabled cobra hands Run the raw, unstripped slice, and we
// extract the Sliver-owned flags ourselves here. Everything we do not own is
// passed to the payload untouched, so all of these now work:
//
// amsi-bypass -pid 6969
// fakealias --process notepad.exe arg1 arg2
// fakealias -- --anything still-here (existing workaround, unchanged)
type ownedFlagKind int
const (
ownedBool ownedFlagKind = iota
ownedString
ownedUint32
ownedInt64
)
// ownedFlagSpec describes one Sliver-owned flag that splitAliasArgs extracts
// from the raw argument slice before the rest is handed to the alias payload.
type ownedFlagSpec struct {
long string // canonical pflag name, e.g. "process-arguments"
short string // shorthand letter, e.g. "A" ("" if the flag has none)
kind ownedFlagKind // how the value is consumed and validated
}
// baseAliasOwnedFlags are registered for every alias command.
var baseAliasOwnedFlags = []ownedFlagSpec{
{long: "process", short: "p", kind: ownedString},
{long: "process-arguments", short: "A", kind: ownedString},
{long: "ppid", short: "P", kind: ownedUint32},
{long: "save", short: "s", kind: ownedBool},
{long: "timeout", short: "t", kind: ownedInt64},
}
// assemblyAliasOwnedFlags are only owned when the alias wraps a .NET assembly;
// for every other alias they are unknown tokens and pass through to the payload.
var assemblyAliasOwnedFlags = []ownedFlagSpec{
{long: "method", short: "m", kind: ownedString},
{long: "class", short: "c", kind: ownedString},
{long: "app-domain", short: "d", kind: ownedString},
{long: "arch", short: "a", kind: ownedString},
{long: "in-process", short: "i", kind: ownedBool},
{long: "runtime", short: "r", kind: ownedString},
{long: "amsi-bypass", short: "M", kind: ownedBool},
{long: "etw-bypass", short: "E", kind: ownedBool},
}
// aliasOwnedFlagSpecs returns the flag set owned by a particular alias.
func aliasOwnedFlagSpecs(isAssembly bool) []ownedFlagSpec {
if !isAssembly {
return baseAliasOwnedFlags
}
specs := make([]ownedFlagSpec, 0, len(baseAliasOwnedFlags)+len(assemblyAliasOwnedFlags))
specs = append(specs, baseAliasOwnedFlags...)
return append(specs, assemblyAliasOwnedFlags...)
}
// ownedFlagMatch is a raw token that spelled one of the Sliver-owned flags.
type ownedFlagMatch struct {
spec ownedFlagSpec
value string // value from a "--flag=value" / "-f=value" spelling
inline bool // whether the token carried its value inline
}
// splitAliasArgs separates the Sliver-owned flags from the alias payload's own
// arguments in a raw (DisableFlagParsing) argument slice. It accepts the long
// forms --flag, --flag=value, --flag value and the shorthand forms -f, -f=value,
// -f value, mirroring pflag. Two deliberate differences from pflag:
//
// - a glued shorthand value (-pnotepad) does NOT match --process: tokens
// like the payload's own "-pid" must survive intact (#2264);
// - after a literal "--" every token is copied through verbatim, preserving
// the long-standing separator workaround.
//
// The returned map holds the raw string value of each flag that was provided;
// callers re-publish the values with applyOwnedAliasFlags.
func splitAliasArgs(raw []string, specs []ownedFlagSpec) (map[string]string, bool, []string, error) {
values := make(map[string]string)
extArgs := make([]string, 0, len(raw))
byLong, byShort := ownedFlagTables(specs)
passthrough := false
for i := 0; i < len(raw); i++ {
tok := raw[i]
// Once we have seen "--", copy everything through untouched.
if passthrough {
extArgs = append(extArgs, tok)
continue
}
if tok == "--" {
passthrough = true
continue
}
if tok == "--help" || tok == "-h" {
return values, true, extArgs, nil
}
match, ok := matchOwnedFlag(tok, byLong, byShort)
if !ok {
// Not one of ours (including glued shorthands and the payload's
// own flags): the alias owns the token, pass it through untouched.
extArgs = append(extArgs, tok)
continue
}
i = takeOwnedValue(match, raw, i, values)
}
if err := validateOwnedValues(specs, values); err != nil {
return nil, false, nil, err
}
return values, false, extArgs, nil
}
// ownedFlagTables indexes the specs by long name and shorthand.
func ownedFlagTables(specs []ownedFlagSpec) (byLong, byShort map[string]ownedFlagSpec) {
byLong = make(map[string]ownedFlagSpec, len(specs))
byShort = make(map[string]ownedFlagSpec, len(specs))
for _, spec := range specs {
byLong[spec.long] = spec
if spec.short != "" {
byShort[spec.short] = spec
}
}
return byLong, byShort
}
// matchOwnedFlag checks whether tok spells one of the Sliver-owned flags, in
// the long form (--name, --name=value) or shorthand form (-f, -f=value). A
// glued shorthand value (-pnotepad) deliberately does not match: pflag would
// read "-pid" as -p with value "id", which is exactly the mangling that broke
// #2264, so such tokens must fall through to the payload.
func matchOwnedFlag(tok string, byLong, byShort map[string]ownedFlagSpec) (ownedFlagMatch, bool) {
var match ownedFlagMatch
var name, value string
var hasValue, isShort bool
switch {
case strings.HasPrefix(tok, "--"):
name, value, hasValue = strings.Cut(tok[2:], "=")
case strings.HasPrefix(tok, "-") && len(tok) > 1:
name, value, hasValue = strings.Cut(tok[1:], "=")
isShort = len(name) == 1
default:
return match, false
}
spec, ok := byLong[name]
if isShort {
spec, ok = byShort[name]
}
if !ok {
return match, false
}
match.spec, match.value, match.inline = spec, value, hasValue
return match, true
}
// takeOwnedValue records the flag's value in values and returns the index of
// the last raw token it consumed. The "--flag value" / "-f value" spellings
// consume the following token; a bool without an inline value defaults to
// true; a value flag as the very last token is dropped and keeps its default,
// mirroring the extension behaviour.
func takeOwnedValue(match ownedFlagMatch, raw []string, i int, values map[string]string) int {
switch {
case match.spec.kind == ownedBool:
if !match.inline {
match.value = "true"
}
values[match.spec.long] = match.value
case match.inline:
values[match.spec.long] = match.value
case i+1 < len(raw):
values[match.spec.long] = raw[i+1]
return i + 1
}
return i
}
// validateOwnedValues rejects non-numeric values for the numeric flag kinds,
// the way pflag's typed values would have.
func validateOwnedValues(specs []ownedFlagSpec, values map[string]string) error {
for _, spec := range specs {
value, provided := values[spec.long]
if !provided {
continue
}
var err error
switch spec.kind {
case ownedUint32:
_, err = strconv.ParseUint(value, 10, 32)
case ownedInt64:
_, err = strconv.ParseInt(value, 10, 64)
}
if err != nil {
return fmt.Errorf("invalid value %q for --%s", value, spec.long)
}
}
return nil
}
// applyOwnedAliasFlags republishes the manually parsed Sliver-owned flag values
// onto the command's flag set. DisableFlagParsing prevents pflag from
// populating them, so readers such as runAliasCommand's Get* calls and
// ActiveTarget.Request (which derives the gRPC timeout from --timeout) would
// otherwise observe only the defaults.
func applyOwnedAliasFlags(cmd *cobra.Command, values map[string]string) error {
for name, value := range values {
flag := cmd.Flags().Lookup(name)
if flag == nil {
continue // e.g. an assembly flag on a non-assembly alias
}
if err := flag.Value.Set(value); err != nil {
return fmt.Errorf("invalid value %q for --%s: %w", value, name, err)
}
}
return nil
}
+259
View File
@@ -0,0 +1,259 @@
package alias
/*
Sliver Implant Framework
Copyright (C) 2021 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestSplitAliasArgs covers the argument passthrough regression from #2264:
// the alias payload's own flags must reach the payload intact, while the
// Sliver-owned flags are extracted and validated.
func TestSplitAliasArgs(t *testing.T) {
base := aliasOwnedFlagSpecs(false)
assembly := aliasOwnedFlagSpecs(true)
cases := []struct {
name string
specs []ownedFlagSpec
in []string
values map[string]string
help bool
extArgs []string
wantErr bool
}{
{
name: "payload flag without -- separator (#2264 regression)",
specs: base,
in: []string{"-pid", "6969"},
values: map[string]string{},
extArgs: []string{"-pid", "6969"},
},
{
name: "glued shorthand is not ours and passes through",
specs: base,
in: []string{"-pid"},
values: map[string]string{},
extArgs: []string{"-pid"},
},
{
name: "payload flag with -- separator (existing workaround)",
specs: base,
in: []string{"--", "--pid", "6969"},
values: map[string]string{},
extArgs: []string{"--pid", "6969"},
},
{
name: "owned flag after -- belongs to the payload",
specs: base,
in: []string{"--", "--save"},
values: map[string]string{},
extArgs: []string{"--save"},
},
{
name: "long flag with separate value, then payload args",
specs: base,
in: []string{"--process", "notepad.exe", "arg1", "arg2"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{"arg1", "arg2"},
},
{
name: "long flag with equals value",
specs: base,
in: []string{"--process=notepad.exe", "arg1"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{"arg1"},
},
{
name: "shorthand with separate value",
specs: base,
in: []string{"-p", "notepad.exe", "arg1"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{"arg1"},
},
{
name: "shorthand with equals value",
specs: base,
in: []string{"-p=notepad.exe"},
values: map[string]string{"process": "notepad.exe"},
extArgs: []string{},
},
{
name: "bool shorthand and long false form",
specs: base,
in: []string{"-s", "--save=false", "arg1"},
values: map[string]string{"save": "false"},
extArgs: []string{"arg1"},
},
{
name: "quoted value containing spaces stays a single argument",
specs: base,
in: []string{"-A", "-kalc -kdisable", "arg1"},
values: map[string]string{"process-arguments": "-kalc -kdisable"},
extArgs: []string{"arg1"},
},
{
name: "valid uint32 and int64 values",
specs: base,
in: []string{"-P", "1234", "--timeout=90"},
values: map[string]string{"ppid": "1234", "timeout": "90"},
extArgs: []string{},
},
{
name: "invalid uint32 value errors",
specs: base,
in: []string{"--ppid", "notanumber"},
wantErr: true,
},
{
name: "invalid int64 value errors",
specs: base,
in: []string{"--timeout=x"},
wantErr: true,
},
{
name: "assembly flags are owned for assembly aliases",
specs: assembly,
in: []string{"--method", "Run", "--class", "Prog", "-i", "-M", "--arch", "x64", "payloadArg"},
values: map[string]string{
"method": "Run", "class": "Prog", "in-process": "true",
"amsi-bypass": "true", "arch": "x64",
},
extArgs: []string{"payloadArg"},
},
{
name: "assembly flags pass through for non-assembly aliases",
specs: base,
in: []string{"--method", "Run"},
values: map[string]string{},
extArgs: []string{"--method", "Run"},
},
{
name: "help flags are detected",
specs: base,
in: []string{"--help"},
help: true,
values: map[string]string{},
extArgs: []string{},
},
{
name: "short help is detected",
specs: base,
in: []string{"-h"},
help: true,
values: map[string]string{},
extArgs: []string{},
},
{
name: "value flag as last token keeps its default",
specs: base,
in: []string{"arg1", "--process"},
values: map[string]string{},
extArgs: []string{"arg1"},
},
{
name: "empty input",
specs: base,
in: nil,
values: map[string]string{},
extArgs: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
values, help, extArgs, err := splitAliasArgs(tc.in, tc.specs)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got values=%#v help=%v extArgs=%#v", values, help, extArgs)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if help != tc.help {
t.Errorf("help: got %v, want %v", help, tc.help)
}
if !reflect.DeepEqual(values, tc.values) {
t.Errorf("values: got %#v, want %#v", values, tc.values)
}
if !reflect.DeepEqual(extArgs, tc.extArgs) {
t.Errorf("extArgs: got %#v, want %#v", extArgs, tc.extArgs)
}
})
}
}
// TestApplyOwnedAliasFlags checks that manually parsed values are re-published
// on the command's flag set for every value type an alias can own.
func TestApplyOwnedAliasFlags(t *testing.T) {
cmd := &cobra.Command{}
f := pflag.NewFlagSet("alias-test", pflag.ContinueOnError)
f.StringP("process", "p", "", "host process")
f.StringP("process-arguments", "A", "", "host process arguments")
f.Uint32P("ppid", "P", 0, "parent pid")
f.BoolP("save", "s", false, "save output")
f.Int64P("timeout", "t", 60, "timeout")
f.BoolP("amsi-bypass", "M", false, "amsi bypass")
cmd.Flags().AddFlagSet(f)
err := applyOwnedAliasFlags(cmd, map[string]string{
"process": "notepad.exe",
"process-arguments": "-k foo",
"ppid": "1234",
"save": "true",
"timeout": "90",
"amsi-bypass": "true",
})
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
for name, want := range map[string]interface{}{
"process": "notepad.exe", "process-arguments": "-k foo",
} {
got, _ := cmd.Flags().GetString(name)
if got != want.(string) {
t.Errorf("%s: got %q, want %q", name, got, want)
}
}
if ppid, _ := cmd.Flags().GetUint32("ppid"); ppid != 1234 {
t.Errorf("ppid: got %d, want 1234", ppid)
}
if save, _ := cmd.Flags().GetBool("save"); !save {
t.Error("save: got false, want true")
}
if timeout, _ := cmd.Flags().GetInt64("timeout"); timeout != 90 {
t.Errorf("timeout: got %d, want 90", timeout)
}
if amsi, _ := cmd.Flags().GetBool("amsi-bypass"); !amsi {
t.Error("amsi-bypass: got false, want true")
}
// Unset flags keep their defaults.
if inProc := cmd.Flags().Lookup("in-process"); inProc != nil {
t.Error("in-process should not be registered on this command")
}
}
+3
View File
@@ -1,6 +1,7 @@
package alias
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -32,6 +33,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
aliasCmd.AddCommand(aliasLoadCmd)
flags.NewCompletions(aliasLoadCmd).PositionalCompletion(carapace.ActionDirectories().Tag("alias directory").Usage("path to the alias directory"))
completers.RegisterLocalFilePathPositionalCompletion(aliasLoadCmd, 0)
aliasInstallCmd := &cobra.Command{
Use: consts.InstallStr + " [ALIAS]",
@@ -44,6 +46,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
aliasCmd.AddCommand(aliasInstallCmd)
flags.NewCompletions(aliasInstallCmd).PositionalCompletion(carapace.ActionFiles().Tag("alias file"))
completers.RegisterLocalFilePathPositionalCompletion(aliasInstallCmd, 0)
aliasRemove := &cobra.Command{
Use: consts.RmStr + " [ALIAS]",
+41 -11
View File
@@ -215,8 +215,6 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
longHelp.WriteString(fmt.Sprintf("%s (%s):\t%s%s", strings.ToUpper(arg.Name), aType, optStr, arg.Desc))
}
}
longHelp.WriteString("\n\n⚠️ If you're having issues passing arguments to the alias please read:\n")
longHelp.WriteString("https://github.com/BishopFox/sliver/wiki/Aliases-&-Extensions#aliases-command-parsing")
// for each alias command, add a new app command
helpMsg := fmt.Sprintf("[%s] %s", aliasManifest.Name, aliasManifest.Help)
@@ -227,9 +225,14 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
Run: func(cmd *cobra.Command, args []string) {
runAliasCommand(cmd, con, args)
},
Args: cobra.ArbitraryArgs, // a.StringList("arguments", "arguments", grumble.Default([]string{}))
GroupID: consts.AliasHelpGroup,
Annotations: makeAliasPlatformFilters(aliasManifest),
Args: cobra.ArbitraryArgs, // a.StringList("arguments", "arguments", grumble.Default([]string{}))
// DisableFlagParsing keeps the alias payload's own flags (which are
// unknown to pflag) from being rejected or swallowed; we split the
// Sliver-owned flags out of the raw slice ourselves in
// runAliasCommand (#2264, same treatment as extensions).
DisableFlagParsing: true,
GroupID: consts.AliasHelpGroup,
Annotations: makeAliasPlatformFilters(aliasManifest),
}
if aliasManifest.IsAssembly {
@@ -239,9 +242,9 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
f.StringP("app-domain", "d", "", "AppDomain name to create for .NET assembly. Generated randomly if not set.")
f.StringP("arch", "a", "x84", "Assembly target architecture: x86, x64, x84 (x86+x64)")
f.BoolP("in-process", "i", false, "Run in the current sliver process")
f.StringP("runtime", "r", "", "Runtime to use for running the assembly (only supported when used with --in-process)")
f.BoolP("amsi-bypass", "M", false, "Bypass AMSI on Windows (only supported when used with --in-process)")
f.BoolP("etw-bypass", "E", false, "Bypass ETW on Windows (only supported when used with --in-process)")
f.StringP("runtime", "r", "v4.0.30319", "Runtime to use for running the assembly")
f.BoolP("amsi-bypass", "M", false, "Bypass AMSI on Windows")
f.BoolP("etw-bypass", "E", false, "Bypass ETW on Windows")
addAliasCmd.Flags().AddFlagSet(f)
}
@@ -250,7 +253,7 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
f.StringP("process-arguments", "A", "", "arguments to pass to the hosting process")
f.Uint32P("ppid", "P", 0, "parent process ID to use when creating the hosting process (Windows only)")
f.BoolP("save", "s", false, "Save output to disk")
f.IntP("timeout", "t", defaultTimeout, "command timeout in seconds")
f.Int64P("timeout", "t", defaultTimeout, "command timeout in seconds")
addAliasCmd.Flags().AddFlagSet(f)
// Setup completions for alias arguments
@@ -333,6 +336,27 @@ func runAliasCommand(cmd *cobra.Command, con *console.SliverClient, args []strin
return
}
aliasManifest := loadedAlias.Manifest
// The command runs with DisableFlagParsing, so args is the raw, unstripped
// slice. Separate the Sliver-owned flags from the payload's own arguments
// and re-publish them on the flag set (so the Get* readers below and
// Request()'s --timeout still work), then hand only the payload arguments
// to the execution paths (#2264, same treatment as extensions in #2323).
ownedValues, helpRequested, payloadArgs, err := splitAliasArgs(args, aliasOwnedFlagSpecs(aliasManifest.IsAssembly))
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
if helpRequested {
_ = cmd.Help()
return
}
if err := applyOwnedAliasFlags(cmd, ownedValues); err != nil {
con.PrintErrorf("%s\n", err)
return
}
args = payloadArgs
binPath, err := aliasManifest.getFileForTarget(cmd.Name(), goos, goarch)
if err != nil {
con.PrintErrorf("Fail to find alias file: %s\n", err)
@@ -374,7 +398,7 @@ func runAliasCommand(cmd *cobra.Command, con *console.SliverClient, args []strin
}
con.PrintWarnf("%s", msgStr)
confirm := false
_ = forms.Confirm("Do you want to continue?", &confirm)
forms.Confirm("Do you want to continue?", &confirm)
if !confirm {
return
}
@@ -400,7 +424,13 @@ func runAliasCommand(cmd *cobra.Command, con *console.SliverClient, args []strin
var outFilePath *os.File
if save, _ := cmd.Flags().GetBool("save"); save {
outFile := filepath.Base(fmt.Sprintf("%s_%s*.log", filepath.Base(cmd.Name()), filepath.Base(session.GetHostname())))
hostname := ""
if session != nil {
hostname = session.GetHostname()
} else if beacon != nil {
hostname = beacon.GetHostname()
}
outFile := filepath.Base(fmt.Sprintf("%s_%s*.log", filepath.Base(cmd.Name()), filepath.Base(hostname)))
outFilePath, err = os.CreateTemp("", outFile)
if err != nil {
con.PrintErrorf("%s\n", err)
+16 -16
View File
@@ -164,7 +164,7 @@ func ArmoryCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
cacheEntry, ok := value.(pkgCacheEntry)
if !ok {
// Something is wrong with this entry
pkgCache.Delete(value)
pkgCache.Delete(key)
return true
}
if cacheEntry.ArmoryConfig.PublicKey != index.ArmoryConfig.PublicKey {
@@ -428,7 +428,7 @@ func PrintArmoryPackages(aliases []*alias.AliasManifest, exts []*extensions.Exte
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
tw.SetTitle(console.Bold + "Packages" + console.Normal)
tw.SetTitle(console.StyleBold.Render("Packages"))
urlMargin := 150 // Extra margin needed to show URL column
@@ -493,26 +493,26 @@ func PrintArmoryPackages(aliases []*alias.AliasManifest, exts []*extensions.Exte
rows := []table.Row{}
for _, pkg := range entries {
color := console.Normal
style := console.StyleNormal
if extensions.CmdExists(pkg.CommandName, sliverMenu.Command) {
color = console.Green
style = console.StyleGreen
}
if con.Settings.SmallTermWidth+urlMargin < width {
rows = append(rows, table.Row{
fmt.Sprintf(color+"%s"+console.Normal, pkg.ArmoryName),
fmt.Sprintf(color+"%s"+console.Normal, pkg.CommandName),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Version),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Type),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Help),
fmt.Sprintf(color+"%s"+console.Normal, pkg.URL),
style.Render(pkg.ArmoryName),
style.Render(pkg.CommandName),
style.Render(pkg.Version),
style.Render(pkg.Type),
style.Render(pkg.Help),
style.Render(pkg.URL),
})
} else {
rows = append(rows, table.Row{
fmt.Sprintf(color+"%s"+console.Normal, pkg.ArmoryName),
fmt.Sprintf(color+"%s"+console.Normal, pkg.CommandName),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Version),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Type),
fmt.Sprintf(color+"%s"+console.Normal, pkg.Help),
style.Render(pkg.ArmoryName),
style.Render(pkg.CommandName),
style.Render(pkg.Version),
style.Render(pkg.Type),
style.Render(pkg.Help),
})
}
}
@@ -524,7 +524,7 @@ func PrintArmoryPackages(aliases []*alias.AliasManifest, exts []*extensions.Exte
func PrintArmoryBundles(bundles []*ArmoryBundle, con *console.SliverClient) {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
tw.SetTitle(console.Bold + "Bundles" + console.Normal)
tw.SetTitle(console.StyleBold.Render("Bundles"))
tw.AppendHeader(table.Row{
"Armory Name",
"Name",
+18
View File
@@ -1,5 +1,23 @@
package armory
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
+70 -43
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"
)
@@ -301,32 +301,44 @@ func getCommandsInCache(armoryPK string) []string {
return commandNames
}
func packageMatchesInstallName(cacheEntry pkgCacheEntry, name string) bool {
if cacheEntry.Pkg.IsAlias {
return cacheEntry.Alias != nil && cacheEntry.Alias.CommandName == name
}
if cacheEntry.Extension == nil {
return false
}
if cacheEntry.Pkg.CommandName == name || cacheEntry.Extension.PackageName == name {
return true
}
for _, command := range cacheEntry.Extension.ExtCommand {
if command.CommandName == name {
return true
}
}
return false
}
func getPackagesWithCommandName(name, armoryPK, minimumVersion string) []*pkgCacheEntry {
packages := []*pkgCacheEntry{}
pkgCache.Range(func(key, value interface{}) bool {
cacheEntry := value.(pkgCacheEntry)
if cacheEntry.LastErr == nil {
if cacheEntry.Pkg.IsAlias {
if cacheEntry.Alias.CommandName == name {
if minimumVersion == "" || (minimumVersion != "" && cacheEntry.Alias.Version >= minimumVersion) {
if armoryPK == "" || (armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey == armoryPK) {
packages = append(packages, &cacheEntry)
}
}
}
} else {
for _, command := range cacheEntry.Extension.ExtCommand {
if command.CommandName == name {
if minimumVersion == "" || (minimumVersion != "" && cacheEntry.Extension.Version >= minimumVersion) {
if armoryPK == "" || (armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey == armoryPK) {
packages = append(packages, &cacheEntry)
}
break
}
}
}
}
if cacheEntry.LastErr != nil || !packageMatchesInstallName(cacheEntry, name) {
return true
}
if armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey != armoryPK {
return true
}
packageVersion := ""
if cacheEntry.Pkg.IsAlias {
packageVersion = cacheEntry.Alias.Version
} else {
packageVersion = cacheEntry.Extension.Version
}
if minimumVersion == "" || packageVersion >= minimumVersion {
packages = append(packages, &cacheEntry)
}
return true
})
@@ -336,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)
@@ -635,20 +647,15 @@ func installAliasPackage(entry *pkgCacheEntry, promptToOverwrite bool, clientCon
return errors.New("signature verification failed")
}
tmpFile, err := os.CreateTemp("", "sliver-armory-")
tmpFileName, err := writeArmoryTempFile(tarGz)
if err != nil {
return err
}
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write(tarGz)
if err != nil {
return err
}
tmpFile.Close()
defer os.Remove(tmpFileName)
con.Printf(console.Clearln + "\r") // Clear the line
installPath := alias.InstallFromFile(tmpFile.Name(), entry.Alias.CommandName, promptToOverwrite, con)
installPath := alias.InstallFromFile(tmpFileName, entry.Alias.CommandName, promptToOverwrite, con)
if installPath == nil {
return errors.New("failed to install alias")
}
@@ -729,23 +736,43 @@ func installExtensionPackage(entry *pkgCacheEntry, promptToOverwrite bool, clien
return errors.New("signature verification failed")
}
tmpFile, err := os.CreateTemp("", "sliver-armory-")
if err != nil {
return err
}
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write(tarGz)
if err != nil {
return err
}
err = tmpFile.Sync()
tmpFileName, err := writeArmoryTempFile(tarGz)
if err != nil {
return err
}
defer os.Remove(tmpFileName)
con.Printf(console.Clearln + "\r") // Clear download message
extensions.InstallFromDir(tmpFile.Name(), promptToOverwrite, con, true)
return extensions.InstallFromDir(tmpFileName, promptToOverwrite, con, true)
}
return nil
func writeArmoryTempFile(data []byte) (string, error) {
if len(data) == 0 {
return "", errors.New("downloaded archive is empty")
}
tmpFile, err := os.CreateTemp("", "sliver-armory-")
if err != nil {
return "", err
}
tmpFileName := tmpFile.Name()
for len(data) > 0 {
n, err := tmpFile.Write(data)
if err != nil {
tmpFile.Close()
os.Remove(tmpFileName)
return "", err
}
data = data[n:]
}
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
os.Remove(tmpFileName)
return "", err
}
if err := tmpFile.Close(); err != nil {
os.Remove(tmpFileName)
return "", err
}
return tmpFileName, nil
}
+218
View File
@@ -0,0 +1,218 @@
package armory
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command/alias"
"github.com/bishopfox/sliver/client/command/extensions"
)
func resetPackageCache(t *testing.T) {
t.Helper()
pkgCache.Range(func(key, _ any) bool {
pkgCache.Delete(key)
return true
})
t.Cleanup(func() {
pkgCache.Range(func(key, _ any) bool {
pkgCache.Delete(key)
return true
})
})
}
func testExtensionCacheEntry() pkgCacheEntry {
manifest := &extensions.ExtensionManifest{
Name: "ADCS Request (Remote)",
PackageName: "remote-adcs-package",
Version: "v0.1.5",
ArmoryName: "Default Armory",
ArmoryPK: "armory-public-key",
ExtCommand: []*extensions.ExtCommand{
{CommandName: "remote-adcs-request"},
{CommandName: "remote-adcs-request-on-behalf"},
},
}
return pkgCacheEntry{
ArmoryConfig: &assets.ArmoryConfig{PublicKey: "armory-public-key"},
Pkg: ArmoryPackage{
Name: "Remote ADCS package",
CommandName: "remote-adcs-index",
},
Extension: manifest,
ID: "remote-adcs-request-id",
}
}
func TestGetPackageForCommandAcceptsStableExtensionIdentities(t *testing.T) {
resetPackageCache(t)
entry := testExtensionCacheEntry()
pkgCache.Store(entry.ID, entry)
for _, name := range []string{
"remote-adcs-index",
"remote-adcs-request",
"remote-adcs-request-on-behalf",
"remote-adcs-package",
} {
t.Run(name, func(t *testing.T) {
found, err := getPackageForCommand(name, "armory-public-key", "v0.1.5")
if err != nil {
t.Fatalf("lookup %q failed: %v", name, err)
}
if found.ID != entry.ID {
t.Fatalf("lookup %q returned %q, want %q", name, found.ID, entry.ID)
}
})
}
if _, err := getPackageForCommand("remote-adcs-package", "other-armory", "v0.1.5"); !errors.Is(err, ErrPackageNotFound) {
t.Fatalf("wrong-armory lookup error = %v, want %v", err, ErrPackageNotFound)
}
if _, err := getPackageForCommand("remote-adcs-package", "armory-public-key", "v0.1.6"); !errors.Is(err, ErrPackageNotFound) {
t.Fatalf("newer-version lookup error = %v, want %v", err, ErrPackageNotFound)
}
for _, name := range []string{"ADCS Request (Remote)", "Remote ADCS package", "unknown-package"} {
if _, err := getPackageForCommand(name, "", ""); !errors.Is(err, ErrPackageNotFound) {
t.Fatalf("unstable identity %q lookup error = %v, want %v", name, err, ErrPackageNotFound)
}
}
}
func TestPackageMatchesInstallNameLeavesAliasesCommandOnly(t *testing.T) {
entry := pkgCacheEntry{
Pkg: ArmoryPackage{
Name: "Human Alias Name",
CommandName: "alias-command",
IsAlias: true,
},
Alias: &alias.AliasManifest{CommandName: "alias-command"},
}
if packageMatchesInstallName(entry, "Human Alias Name") {
t.Fatal("alias unexpectedly matched its display name")
}
if !packageMatchesInstallName(entry, "alias-command") {
t.Fatal("alias did not match its command name")
}
}
func TestExtensionManifestsMatch(t *testing.T) {
manifest := func(name, packageName string, commands ...string) *extensions.ExtensionManifest {
extCommands := make([]*extensions.ExtCommand, 0, len(commands))
for _, command := range commands {
extCommands = append(extCommands, &extensions.ExtCommand{CommandName: command})
}
return &extensions.ExtensionManifest{Name: name, PackageName: packageName, ExtCommand: extCommands}
}
tests := []struct {
name string
local *extensions.ExtensionManifest
latest *extensions.ExtensionManifest
want bool
}{
{
name: "v2 package identity",
local: manifest("Old Display Name", "stable-package", "old-command"),
latest: manifest("New Display Name", "stable-package", "new-command"),
want: true,
},
{
name: "different v2 packages",
local: manifest("Shared Display Name", "package-one", "shared-command"),
latest: manifest("Shared Display Name", "package-two", "shared-command"),
want: false,
},
{
name: "v2 does not downgrade to legacy identity",
local: manifest("Human Display Name", "stable-package", "shared-command"),
latest: manifest("legacy-command", "", "shared-command"),
want: false,
},
{
name: "legacy command identity",
local: manifest("legacy-command", "", "legacy-command"),
latest: manifest("Human Display Name", "stable-package", "other-command", "legacy-command"),
want: true,
},
{
name: "display names are not identity",
local: manifest("Shared Name", "", "old-command"),
latest: manifest("Shared Name", "stable-package", "new-command"),
want: false,
},
{
name: "unrelated legacy package",
local: manifest("Old Name", "", "old-command"),
latest: manifest("New Name", "stable-package", "new-command"),
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := extensionManifestsMatch(test.local, test.latest); got != test.want {
t.Fatalf("extensionManifestsMatch() = %t, want %t", got, test.want)
}
})
}
}
func TestExtensionUpdateDisplayNameResolvesPackage(t *testing.T) {
resetPackageCache(t)
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
installedManifest := []byte(`{
"name":"ADCS Request (Remote)",
"package_name":"remote-adcs-package",
"version":"v0.1.3",
"commands":[{
"command_name":"remote-adcs-request",
"help":"Request a certificate",
"files":[{"os":"windows","arch":"amd64","path":"adcs_request.x64.o"}]
}]
}`)
installDir := filepath.Join(assets.GetExtensionsDir(), "remote-adcs-request")
if err := os.MkdirAll(installDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, extensions.ManifestFileName), installedManifest, 0o600); err != nil {
t.Fatal(err)
}
entry := testExtensionCacheEntry()
pkgCache.Store(entry.ID, entry)
otherEntry := testExtensionCacheEntry()
otherEntry.ID = "other-armory-package-id"
otherEntry.ArmoryConfig = &assets.ArmoryConfig{PublicKey: "other-armory"}
otherManifest := *otherEntry.Extension
otherManifest.Version = "v9.0.0"
otherManifest.ArmoryName = "Other Armory"
otherManifest.ArmoryPK = "other-armory"
otherEntry.Extension = &otherManifest
pkgCache.Store(otherEntry.ID, otherEntry)
updates := checkForExtensionUpdates("armory-public-key")
versionInfo, ok := updates["ADCS Request (Remote)"]
if !ok {
t.Fatal("display-name update was not detected")
}
if versionInfo.OldVersion != "v0.1.3" || versionInfo.NewVersion != "v0.1.5" {
t.Fatalf("unexpected versions: %#v", versionInfo)
}
if versionInfo.PackageID != entry.ID {
t.Fatalf("update package ID = %q, want %q", versionInfo.PackageID, entry.ID)
}
found := packageCacheLookupByID(versionInfo.PackageID)
if found == nil {
t.Fatal("detected update package is missing from the cache")
}
if found.ID != entry.ID {
t.Fatalf("resolved package %q, want %q", found.ID, entry.ID)
}
}
+18
View File
@@ -1,5 +1,23 @@
package armory
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"fmt"
"os"
+41 -15
View File
@@ -21,7 +21,9 @@ package armory
import (
"errors"
"fmt"
"maps"
"os"
"slices"
"sort"
"github.com/jedib0t/go-pretty/v6/table"
@@ -33,13 +35,13 @@ 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 {
OldVersion string
NewVersion string
ArmoryName string
PackageID string
}
type PackageType uint
@@ -124,9 +126,9 @@ func ArmoryUpdateCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
if !ok {
continue
}
updatedPackage, err := getPackageForCommand(update.Name, armoryPK, extVersionInfo.NewVersion)
if err != nil {
con.PrintErrorf("Could not get update package for extension %s: %s\n", update.Name, err)
updatedPackage := packageCacheLookupByID(extVersionInfo.PackageID)
if updatedPackage == nil {
con.PrintErrorf("Could not get update package for extension %s: %s\n", update.Name, ErrPackageNotFound)
continue
}
err = installExtensionPackage(updatedPackage, false, clientConfig, con)
@@ -173,8 +175,21 @@ func checkForAliasUpdates(armoryPK string) map[string]VersionInformation {
return results
}
func extensionManifestsMatch(localManifest, latestManifest *extensions.ExtensionManifest) bool {
if localManifest.PackageName != "" {
return latestManifest.PackageName != "" && localManifest.PackageName == latestManifest.PackageName
}
for _, localCommand := range localManifest.ExtCommand {
for _, latestCommand := range latestManifest.ExtCommand {
if localCommand.CommandName == latestCommand.CommandName {
return true
}
}
}
return false
}
func checkForExtensionUpdates(armoryPK string) map[string]VersionInformation {
_, cachedExtensions := packageManifestsInCache()
// Return a map of extension name to minimum version to install
results := make(map[string]VersionInformation)
for _, extManifestPath := range assets.GetInstalledExtensionManifests() {
@@ -186,7 +201,12 @@ func checkForExtensionUpdates(armoryPK string) map[string]VersionInformation {
if err != nil {
continue
}
for _, latestExt := range cachedExtensions {
pkgCache.Range(func(_, value interface{}) bool {
cacheEntry, ok := value.(pkgCacheEntry)
if !ok || cacheEntry.LastErr != nil || cacheEntry.Pkg.IsAlias || cacheEntry.Extension == nil {
return true
}
latestExt := cacheEntry.Extension
/*
We used to check if the version identifiers were different between the installed version and the
version in the armory. This worked when we had one armory, but with multiple potential armories,
@@ -194,16 +214,22 @@ func checkForExtensionUpdates(armoryPK string) map[string]VersionInformation {
so we will rely on the author of the package incrementing the version identifier somehow to determine
if a package is newer.
*/
if latestExt.Name == localManifest.Name && latestExt.Version > localManifest.Version {
if extensionManifestsMatch(localManifest, latestExt) && latestExt.Version > localManifest.Version {
if latestExt.ArmoryPK == armoryPK || armoryPK == "" {
results[localManifest.Name] = VersionInformation{
OldVersion: localManifest.Version,
NewVersion: latestExt.Version,
ArmoryName: latestExt.ArmoryName,
current, found := results[localManifest.Name]
if !found || latestExt.Version > current.NewVersion ||
(latestExt.Version == current.NewVersion && cacheEntry.ID < current.PackageID) {
results[localManifest.Name] = VersionInformation{
OldVersion: localManifest.Version,
NewVersion: latestExt.Version,
ArmoryName: latestExt.ArmoryName,
PackageID: cacheEntry.ID,
}
}
}
}
}
return true
})
}
return results
@@ -219,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,
@@ -257,7 +283,7 @@ func displayAvailableUpdates(con *console.SliverClient, updateKeys []UpdateIdent
if len(extensionUpdates) != 1 {
extensionSuffix = "s"
}
tw.SetTitle(console.Bold + fmt.Sprintf(title, len(aliasUpdates), aliasSuffix, len(extensionUpdates), extensionSuffix) + console.Normal)
tw.SetTitle(console.StyleBold.Render(fmt.Sprintf(title, len(aliasUpdates), aliasSuffix, len(extensionUpdates), extensionSuffix)))
tw.AppendHeader(table.Row{
"Package Name",
"Package Type",
+163 -94
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"
@@ -39,6 +40,7 @@ import (
func BeaconsCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
killFlag, _ := cmd.Flags().GetString("kill")
killAll, _ := cmd.Flags().GetBool("kill-all")
interact, _ := cmd.Flags().GetString("interact")
// Handle kill
if killFlag != "" {
@@ -72,6 +74,18 @@ func BeaconsCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
con.PrintInfof("Killed %s (%s)\n", beacon.Name, beacon.ID)
}
}
if interact != "" {
beacon, err := GetBeacon(con, interact)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
con.ActiveTarget.Set(nil, beacon)
con.PrintInfof("Active beacon %s (%s)\n", beacon.Name, strings.Split(beacon.ID, "-")[0])
return
}
filter, _ := cmd.Flags().GetString("filter")
var filterRegex *regexp.Regexp
if filterRe, _ := cmd.Flags().GetString("filter-re"); filterRe != "" {
@@ -108,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",
@@ -162,79 +156,154 @@ 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 {
color := console.Normal
activeBeacon := con.ActiveTarget.GetBeacon()
if activeBeacon != nil && activeBeacon.ID == beacon.ID {
color = console.Green
integrity := beacon.Integrity
if integrity == "" {
integrity = "-"
}
if beacon.Integrity == "" {
beacon.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
if activeBeaconID == beacon.ID {
style = console.StyleGreen
}
// We need a slice of strings so we can apply filters
var rowEntries []string
if wideTermWidth {
if layout.Compact {
rowEntries = []string{
fmt.Sprintf(color+"%s"+console.Normal, strings.Split(beacon.ID, "-")[0]),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Name),
fmt.Sprintf(color+"%d/%d"+console.Normal, beacon.TasksCountCompleted, beacon.TasksCount),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Transport),
fmt.Sprintf(color+"%s"+console.Normal, beacon.RemoteAddress),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Hostname),
fmt.Sprintf(color+"%s"+console.Normal, strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
fmt.Sprintf(color+"%s (%d)"+console.Normal, beacon.Filename, beacon.PID),
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),
}
if windowsBeaconInList {
rowEntries = append(rowEntries, fmt.Sprintf(color+"%s"+console.Normal, beacon.Integrity))
}
rowEntries = append(rowEntries, []string{
fmt.Sprintf(color+"%s/%s"+console.Normal, beacon.OS, beacon.Arch),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Locale),
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
}...)
} else {
rowEntries = []string{
fmt.Sprintf(color+"%s"+console.Normal, strings.Split(beacon.ID, "-")[0]),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Name),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Transport),
fmt.Sprintf(color+"%s"+console.Normal, beacon.Hostname),
fmt.Sprintf(color+"%s"+console.Normal, strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
fmt.Sprintf(color+"%s/%s"+console.Normal, beacon.OS, beacon.Arch),
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
processName := beacon.Filename
if layout.ProcessBasename {
processName = targettable.ProcessName(processName)
}
rowEntries = []string{
style.Render(strings.Split(beacon.ID, "-")[0]),
style.Render(beacon.Name),
style.Render(fmt.Sprintf("%d/%d", beacon.TasksCountCompleted, beacon.TasksCount)),
style.Render(beacon.Transport),
style.Render(beacon.RemoteAddress),
style.Render(beacon.Hostname),
style.Render(username),
style.Render(fmt.Sprintf("%s (%d)", processName, beacon.PID)),
}
if includeIntegrity {
rowEntries = append(rowEntries, style.Render(integrity))
}
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)
}
}
+71 -16
View File
@@ -28,11 +28,12 @@ func Commands(con *console.SliverClient) []*cobra.Command {
},
}
flags.Bind("beacons", true, beaconsCmd, func(f *pflag.FlagSet) {
f.IntP("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
flags.Bind("beacons", false, beaconsCmd, func(f *pflag.FlagSet) {
f.StringP("kill", "k", "", "kill the designated beacon")
f.BoolP("kill-all", "K", false, "kill all beacons")
f.StringP("interact", "i", "", "interact with a beacon")
f.BoolP("force", "F", false, "force killing the beacon")
f.StringP("filter", "f", "", "filter beacons by substring")
@@ -40,7 +41,9 @@ func Commands(con *console.SliverClient) []*cobra.Command {
})
flags.BindFlagCompletions(beaconsCmd, func(comp *carapace.ActionMap) {
(*comp)["kill"] = BeaconIDCompleter(con)
(*comp)["interact"] = BeaconIDCompleter(con)
})
registerBeaconIDFlagCompletion(beaconsCmd, "interact", con)
beaconsRmCmd := &cobra.Command{
Use: consts.RmStr,
Short: "Remove a beacon",
@@ -81,22 +84,74 @@ func Commands(con *console.SliverClient) []*cobra.Command {
// BeaconIDCompleter completes beacon IDs
func BeaconIDCompleter(con *console.SliverClient) carapace.Action {
callback := func(_ carapace.Context) carapace.Action {
results := make([]string, 0)
beacons, err := con.Rpc.GetBeacons(context.Background(), &commonpb.Empty{})
if err == nil {
for _, b := range beacons.Beacons {
link := fmt.Sprintf("[%s <- %s]", b.ActiveC2, b.RemoteAddress)
id := fmt.Sprintf("%s (%d)", b.Name, b.PID)
userHost := fmt.Sprintf("%s@%s", b.Username, b.Hostname)
desc := strings.Join([]string{id, userHost, link}, " ")
results = append(results, b.ID[:8])
results = append(results, desc)
}
}
return carapace.ActionValuesDescribed(results...).Tag("beacons")
return carapace.ActionValuesDescribed(beaconCompletionPairs(con)...).Tag("beacons")
}
return carapace.ActionCallback(callback)
}
func registerBeaconIDFlagCompletion(cmd *cobra.Command, name string, con *console.SliverClient) {
if cmd == nil {
return
}
if _, ok := cmd.GetFlagCompletionFunc(name); ok {
return
}
if cmd.Flags().Lookup(name) == nil && cmd.PersistentFlags().Lookup(name) == nil {
return
}
_ = cmd.RegisterFlagCompletionFunc(name, func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
values := describedValuesToTabs(beaconCompletionPairs(con))
return filterCompletionValues(values, toComplete), cobra.ShellCompDirectiveNoFileComp
})
}
func beaconCompletionPairs(con *console.SliverClient) []string {
results := make([]string, 0)
if con == nil || con.Rpc == nil {
return results
}
beacons, err := con.Rpc.GetBeacons(context.Background(), &commonpb.Empty{})
if err != nil {
return results
}
for _, b := range beacons.Beacons {
link := fmt.Sprintf("[%s <- %s]", b.ActiveC2, b.RemoteAddress)
id := fmt.Sprintf("%s (%d)", b.Name, b.PID)
userHost := fmt.Sprintf("%s@%s", b.Username, b.Hostname)
desc := strings.Join([]string{id, userHost, link}, " ")
results = append(results, b.ID[:8], desc)
}
return results
}
func describedValuesToTabs(values []string) []string {
tabbed := make([]string, 0, len(values)/2)
for i := 0; i+1 < len(values); i += 2 {
tabbed = append(tabbed, fmt.Sprintf("%s\t%s", values[i], values[i+1]))
}
return tabbed
}
func filterCompletionValues(values []string, prefix string) []string {
if prefix == "" {
return values
}
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
}
}
return filtered
}
+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"
+4
View File
@@ -1,6 +1,7 @@
package c2profiles
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/generate"
"github.com/bishopfox/sliver/client/command/help"
@@ -27,6 +28,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("file", "f", "", "Path to C2 configuration file to import")
f.BoolP("overwrite", "o", false, "Overwrite profile if it exists")
})
completers.RegisterLocalFilePathFlagCompletion(importC2ProfileCmd, "file")
exportC2ProfileCmd := &cobra.Command{
Use: consts.ExportC2ProfileStr,
@@ -40,6 +42,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("file", "f", "", "Path to file to export C2 configuration to")
f.StringP("name", "n", consts.DefaultC2Profile, "HTTP C2 Profile name")
})
completers.RegisterLocalFilePathFlagCompletion(exportC2ProfileCmd, "file")
flags.BindFlagCompletions(exportC2ProfileCmd, func(comp *carapace.ActionMap) {
(*comp)["name"] = generate.HTTPC2Completer(con)
})
@@ -76,6 +79,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "HTTP C2 Profile name to save C2Profile as")
f.StringP("template", "t", consts.DefaultC2Profile, "HTTP C2 Profile to use as a template for the new profile")
})
completers.RegisterLocalFilePathFlagCompletion(generateC2ProfileCmd, "file")
flags.BindFlagCompletions(generateC2ProfileCmd, func(comp *carapace.ActionMap) {
(*comp)["template"] = generate.HTTPC2Completer(con)
+2
View File
@@ -6,5 +6,7 @@ Implements the 'certificates' command group for the Sliver client console.
## Go Files
- `authorities.go` – Retrieves certificate authority metadata from the server and prints it in a table.
- `completions.go` – Provides tab completion helpers for certificate common names.
- `certificates.go` – Retrieves certificate metadata from the server, formats expiration status, and prints results to the console.
- `commands.go` – Registers the certificates command and binds the flags needed to query certificate inventories.
@@ -0,0 +1,89 @@
package certificates
import (
"context"
"time"
"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/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
"golang.org/x/term"
)
func CertificateAuthoritiesCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
authorityInfo, err := con.Rpc.GetCertificateAuthorityInfo(context.Background(), &commonpb.Empty{})
if err != nil {
con.PrintErrorf("could not get certificate authority information from database: %s", err.Error())
return
}
printCertificateAuthorityInfo(con, authorityInfo.Info)
}
func printCertificateAuthorityInfo(con *console.SliverClient, authData []*clientpb.CertificateAuthorityData) {
width, _, err := term.GetSize(0)
if err != nil {
width = 999
}
if len(authData) == 0 {
con.PrintWarnf("There are no certificate authorities in the database.\n")
return
}
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
wideTermWidth := con.Settings.SmallTermWidth < width
if wideTermWidth {
tw.AppendHeader(table.Row{
"ID",
"Common Name",
"Creation Time",
"Authority Type",
"Key Algorithm",
"Validity Start",
"Expires",
})
} else {
tw.AppendHeader(table.Row{
"ID",
"Common Name",
"Expires",
})
}
for _, authority := range authData {
rowStyle := console.StyleNormal
expiry, err := time.Parse(timeFormat, authority.ValidityExpiry)
if err == nil {
rowStyle = checkCertExpiry(expiry)
}
if wideTermWidth {
tw.AppendRow(table.Row{
rowStyle.Render(authority.ID),
rowStyle.Render(authority.CN),
rowStyle.Render(authority.CreationTime),
rowStyle.Render(authority.Type),
rowStyle.Render(authority.KeyAlgorithm),
rowStyle.Render(authority.ValidityStart),
rowStyle.Render(authority.ValidityExpiry),
})
} else {
tw.AppendRow(table.Row{
rowStyle.Render(authority.ID),
rowStyle.Render(authority.CN),
rowStyle.Render(authority.ValidityExpiry),
})
}
}
tw.SortBy([]table.SortBy{{Name: "Expires", Mode: table.Dsc}})
con.Printf("%s\n", tw.Render())
}
+17 -18
View File
@@ -2,7 +2,6 @@ package certificates
import (
"context"
"fmt"
"time"
"github.com/bishopfox/sliver/client/command/settings"
@@ -95,22 +94,22 @@ func CertificateInfoCmd(cmd *cobra.Command, con *console.SliverClient, args []st
printCertificateInfo(con, certificateInfo.Info)
}
func checkCertExpiry(expiryTime time.Time) string {
func checkCertExpiry(expiryTime time.Time) console.TextStyle {
if expiryTime.Before(time.Now()) || expiryTime.Equal(time.Now()) {
return console.Bold + console.Red
return console.StyleBoldRed
}
// One week is 168 hours - this is bad
if expiryTime.Before(time.Now().Add(168 * time.Hour)) {
return console.Bold + console.Red
return console.StyleBoldRed
}
// One month is approximately 730 hours - this is a warning
if expiryTime.Before(time.Now().Add(730 * time.Hour)) {
return console.Bold + console.Orange
return console.StyleBoldOrange
}
return console.Normal
return console.StyleNormal
}
func printCertificateInfo(con *console.SliverClient, certData []*clientpb.CertificateData) {
@@ -148,28 +147,28 @@ func printCertificateInfo(con *console.SliverClient, certData []*clientpb.Certif
}
for _, cert := range certData {
rowColor := console.Normal
rowStyle := console.StyleNormal
expiry, err := time.Parse(timeFormat, cert.ValidityExpiry)
// This should not error out, but if it does, the row will not be colored
if err == nil {
rowColor = checkCertExpiry(expiry)
rowStyle = checkCertExpiry(expiry)
}
if wideTermWidth {
tw.AppendRow(table.Row{
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ID),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.CN),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.CreationTime),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.Type),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.KeyAlgorithm),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ValidityStart),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ValidityExpiry),
rowStyle.Render(cert.ID),
rowStyle.Render(cert.CN),
rowStyle.Render(cert.CreationTime),
rowStyle.Render(cert.Type),
rowStyle.Render(cert.KeyAlgorithm),
rowStyle.Render(cert.ValidityStart),
rowStyle.Render(cert.ValidityExpiry),
})
} else {
tw.AppendRow(table.Row{
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ID),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.CN),
fmt.Sprintf(rowColor+"%s"+console.Normal, cert.ValidityExpiry),
rowStyle.Render(cert.ID),
rowStyle.Render(cert.CN),
rowStyle.Render(cert.ValidityExpiry),
})
}
+14
View File
@@ -23,6 +23,7 @@ import (
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -44,6 +45,19 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.BoolP("server", "s", false, "Show server certificates")
f.StringP("cn", "c", "", "Show certificate information for a provided common name")
})
flags.BindFlagCompletions(certificatesCmd, func(comp *carapace.ActionMap) {
(*comp)["cn"] = CertificateCommonNameCompleter(con)
})
registerCertificateCNFlagCompletion(certificatesCmd, "cn", con)
authoritiesCmd := &cobra.Command{
Use: "authorities",
Short: "List certificate authorities",
Run: func(cmd *cobra.Command, args []string) {
CertificateAuthoritiesCmd(cmd, con, args)
},
}
certificatesCmd.AddCommand(authoritiesCmd)
return []*cobra.Command{
certificatesCmd,
@@ -0,0 +1,96 @@
package certificates
import (
"context"
"sort"
"strings"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
)
// CertificateCommonNameCompleter completes common names for stored certificates.
func CertificateCommonNameCompleter(con *console.SliverClient) carapace.Action {
return carapace.ActionCallback(func(c carapace.Context) carapace.Action {
if con == nil || con.Rpc == nil {
return carapace.ActionMessage("not connected")
}
values, err := certificateCommonNameValues(con)
if err != nil {
return carapace.ActionMessage("failed to list certificates %s", err)
}
if len(values) == 0 {
return carapace.ActionMessage("no certificates")
}
return carapace.ActionValues(values...).Tag("common name").Usage("certificate common name")
})
}
func registerCertificateCNFlagCompletion(cmd *cobra.Command, name string, con *console.SliverClient) {
if cmd == nil {
return
}
if _, ok := cmd.GetFlagCompletionFunc(name); ok {
return
}
if cmd.Flags().Lookup(name) == nil && cmd.PersistentFlags().Lookup(name) == nil {
return
}
_ = cmd.RegisterFlagCompletionFunc(name, func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
values, err := certificateCommonNameValues(con)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return filterCompletionValues(values, toComplete), cobra.ShellCompDirectiveNoFileComp
})
}
func certificateCommonNameValues(con *console.SliverClient) ([]string, error) {
if con == nil || con.Rpc == nil {
return nil, nil
}
certInfo, err := con.Rpc.GetCertificateInfo(context.Background(), &clientpb.CertificatesReq{
CategoryFilters: AllTransports | AllRoles,
})
if err != nil {
return nil, err
}
unique := map[string]struct{}{}
for _, cert := range certInfo.Info {
if cert.CN == "" {
continue
}
unique[cert.CN] = struct{}{}
}
values := make([]string, 0, len(unique))
for name := range unique {
values = append(values, name)
}
sort.Strings(values)
return values, nil
}
func filterCompletionValues(values []string, prefix string) []string {
if prefix == "" {
return values
}
filtered := make([]string, 0, len(values))
for _, value := range values {
candidate := value
if before, _, ok := strings.Cut(value, "\t"); ok {
candidate = before
}
if strings.HasPrefix(candidate, prefix) {
filtered = append(filtered, value)
}
}
return filtered
}
+58
View File
@@ -22,6 +22,7 @@ import (
"net"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
)
// ClientInterfacesCompleter completes interface addresses on the client host.
@@ -84,3 +85,60 @@ func LocalProxyCompleter() carapace.Action {
}).Invoke(c).Prefix(prefix).ToA()
})
}
// LocalFilePathCompleter completes local file and directory paths.
func LocalFilePathCompleter() carapace.Action {
return carapace.ActionFiles().Tag("local file path")
}
// RegisterLocalFilePathFlagCompletion registers local file-path completion for a flag.
func RegisterLocalFilePathFlagCompletion(cmd *cobra.Command, name string) {
if cmd == nil {
return
}
if _, ok := cmd.GetFlagCompletionFunc(name); ok {
return
}
if cmd.Flags().Lookup(name) == nil && cmd.PersistentFlags().Lookup(name) == nil {
return
}
_ = cmd.RegisterFlagCompletionFunc(name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveDefault
})
}
// RegisterLocalFilePathFlagCompletions registers local file-path completion for a list of flags.
func RegisterLocalFilePathFlagCompletions(cmd *cobra.Command, names ...string) {
for _, name := range names {
RegisterLocalFilePathFlagCompletion(cmd, name)
}
}
// RegisterLocalFilePathPositionalCompletion registers local file-path completion for a positional argument index.
func RegisterLocalFilePathPositionalCompletion(cmd *cobra.Command, index int) {
if cmd == nil || index < 0 {
return
}
if cmd.ValidArgsFunction != nil {
return
}
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == index {
return nil, cobra.ShellCompDirectiveDefault
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
// RegisterLocalFilePathPositionalAnyCompletion registers local file-path completion for all positional arguments.
func RegisterLocalFilePathPositionalAnyCompletion(cmd *cobra.Command) {
if cmd == nil {
return
}
if cmd.ValidArgsFunction != nil {
return
}
cmd.ValidArgsFunction = func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveDefault
}
}
+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.
+123
View File
@@ -1,6 +1,9 @@
package crack
import (
"time"
"github.com/bishopfox/sliver/client/command/completers"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -25,6 +28,14 @@ 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,
@@ -34,8 +45,117 @@ func Commands(con *console.SliverClient) []*cobra.Command {
CrackStationsCmd(cmd, con, args)
},
}
flags.Bind("", false, crackStationsCmd, func(f *pflag.FlagSet) {
f.Bool("show-benchmarks", false, "show benchmark rates for crackstations")
})
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",
@@ -57,6 +177,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "wordlist name (blank = filename)")
})
carapace.Gen(wordlistsAddCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to local wordlist file"))
completers.RegisterLocalFilePathPositionalCompletion(wordlistsAddCmd, 0)
wordlistsCmd.AddCommand(wordlistsAddCmd)
wordlistsRmCmd := &cobra.Command{
@@ -91,6 +212,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "rules name (blank = filename)")
})
carapace.Gen(rulesAddCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to local rules file"))
completers.RegisterLocalFilePathPositionalCompletion(rulesAddCmd, 0)
rulesCmd.AddCommand(rulesAddCmd)
rulesRmCmd := &cobra.Command{
@@ -126,6 +248,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "hcstat2 name (blank = filename)")
})
carapace.Gen(hcstat2AddCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to local hcstat2 file"))
completers.RegisterLocalFilePathPositionalCompletion(hcstat2AddCmd, 0)
hcstat2Cmd.AddCommand(hcstat2AddCmd)
hcstat2RmCmd := &cobra.Command{
+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)
}
+593
View File
@@ -0,0 +1,593 @@
package crack
import (
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/spf13/cobra"
"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
}
hasFlag := false
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
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{HashType: clientpb.HashType_INVALID}
if flags.Changed("attack-mode") {
raw, _ := flags.GetString("attack-mode")
attackMode, err := parseCrackAttackMode(raw)
if err != nil {
return nil, err
}
req.AttackMode = attackMode
}
if flags.Changed("hash-type") {
raw, _ := flags.GetString("hash-type")
hashType, err := parseHashType(raw)
if err != nil {
return nil, err
}
req.HashType = hashType
}
if flags.Changed("hash-mode") {
hashMode, _ := flags.GetUint32("hash-mode")
req.HashMode = &hashMode
}
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")
req.HexSalt, _ = flags.GetBool("hex-salt")
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")
req.Loopback, _ = flags.GetBool("loopback")
if raw, _ := flags.GetString("markov-hcstat2"); raw != "" {
req.MarkovHcstat2 = []byte(raw)
}
req.MarkovDisable, _ = flags.GetBool("markov-disable")
req.MarkovClassic, _ = flags.GetBool("markov-classic")
req.MarkovInverse, _ = flags.GetBool("markov-inverse")
req.MarkovThreshold, _ = flags.GetUint32("markov-threshold")
req.Runtime, _ = flags.GetUint32("runtime")
req.Session, _ = flags.GetString("session")
req.Restore, _ = flags.GetBool("restore")
req.RestoreDisable, _ = flags.GetBool("restore-disable")
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)
if err != nil {
return nil, err
}
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.Show, _ = flags.GetBool("show")
req.Left, _ = flags.GetBool("left")
req.Username, _ = flags.GetBool("username")
req.Remove, _ = flags.GetBool("remove")
req.RemoveTimer, _ = flags.GetUint32("remove-timer")
req.PotfileDisable, _ = flags.GetBool("potfile-disable")
if raw, _ := flags.GetString("potfile"); raw != "" {
req.Potfile = []byte(raw)
}
if flags.Changed("encoding-from") {
raw, _ := flags.GetString("encoding-from")
if enc, err := parseCrackEncoding(raw); err == nil {
req.EncodingFrom = enc
}
if _, err := strconv.ParseInt(raw, 10, 32); err != nil {
req.EncodingFromName = raw
}
}
if flags.Changed("encoding-to") {
raw, _ := flags.GetString("encoding-to")
if enc, err := parseCrackEncoding(raw); err == nil {
req.EncodingTo = enc
}
if _, err := strconv.ParseInt(raw, 10, 32); err != nil {
req.EncodingToName = raw
}
}
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 != "" {
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.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-disable")
if !req.MultiplyAccelDisabled {
req.MultiplyAccelDisabled, _ = flags.GetBool("multiply-accel-disabled")
}
if flags.Changed("workload-profile") {
raw, _ := flags.GetString("workload-profile")
profile, err := parseCrackWorkloadProfile(raw)
if err != nil {
return nil, err
}
req.WorkloadProfile = profile
}
req.KernelAccel, _ = flags.GetUint32("kernel-accel")
req.KernelLoops, _ = flags.GetUint32("kernel-loops")
req.KernelThreads, _ = flags.GetUint32("kernel-threads")
req.BackendVectorWidth, _ = flags.GetUint32("backend-vector-width")
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")
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-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
}
func uintSliceToUint32(flags *pflag.FlagSet, name string) []uint32 {
values, _ := flags.GetUintSlice(name)
if len(values) == 0 {
return nil
}
out := make([]uint32, len(values))
for i, value := range values {
out[i] = uint32(value)
}
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
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.CrackAttackMode(numeric), nil
}
normalized := strings.ToLower(strings.TrimSpace(raw))
normalized = strings.NewReplacer("-", "_", " ", "_").Replace(normalized)
switch normalized {
case "straight":
return clientpb.CrackAttackMode_STRAIGHT, nil
case "combination":
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)
}
}
func parseHashType(raw string) (clientpb.HashType, error) {
if raw == "" {
return clientpb.HashType_MD5, nil
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.HashType(numeric), nil
}
key := normalizeEnumKey(raw)
if value, ok := clientpb.HashType_value[key]; ok {
return clientpb.HashType(value), nil
}
return clientpb.HashType_MD5, fmt.Errorf("unknown hash type: %s", raw)
}
func parseCrackEncoding(raw string) (clientpb.CrackEncoding, error) {
if raw == "" {
return clientpb.CrackEncoding_INVALID_ENCODING, nil
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.CrackEncoding(numeric), nil
}
key := normalizeEnumKey(raw)
if value, ok := clientpb.CrackEncoding_value[key]; ok {
return clientpb.CrackEncoding(value), nil
}
return clientpb.CrackEncoding_INVALID_ENCODING, fmt.Errorf("unknown encoding: %s", raw)
}
func parseCrackOutfileFormat(raw string) (clientpb.CrackOutfileFormat, error) {
if raw == "" {
return clientpb.CrackOutfileFormat_INVALID_FORMAT, nil
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.CrackOutfileFormat(numeric), nil
}
key := normalizeEnumKey(raw)
if value, ok := clientpb.CrackOutfileFormat_value[key]; ok {
return clientpb.CrackOutfileFormat(value), nil
}
return clientpb.CrackOutfileFormat_INVALID_FORMAT, fmt.Errorf("unknown outfile format: %s", raw)
}
func parseCrackWorkloadProfile(raw string) (clientpb.CrackWorkloadProfile, error) {
if raw == "" {
return clientpb.CrackWorkloadProfile_INVALID_WORKLOAD_PROFILE, nil
}
if numeric, err := strconv.ParseInt(raw, 10, 32); err == nil {
return clientpb.CrackWorkloadProfile(numeric), nil
}
key := normalizeEnumKey(raw)
if value, ok := clientpb.CrackWorkloadProfile_value[key]; ok {
return clientpb.CrackWorkloadProfile(value), nil
}
return clientpb.CrackWorkloadProfile_INVALID_WORKLOAD_PROFILE, fmt.Errorf("unknown workload profile: %s", raw)
}
func normalizeEnumKey(value string) string {
if value == "" {
return ""
}
var builder strings.Builder
builder.Grow(len(value))
underscore := false
for i := 0; i < len(value); i++ {
ch := value[i]
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
if ch >= 'a' && ch <= 'z' {
ch -= 'a' - 'A'
}
builder.WriteByte(ch)
underscore = false
continue
}
if !underscore {
builder.WriteByte('_')
underscore = true
}
}
return strings.Trim(builder.String(), "_")
}
+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)
}
}
+164 -72
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})
@@ -106,17 +124,17 @@ func PrintCrackFiles(crackFiles *clientpb.CrackFiles, con *console.SliverClient)
func PrintCrackFilesByType(crackFiles *clientpb.CrackFiles, con *console.SliverClient) {
wordlistTable := table.NewWriter()
wordlistTable.SetTitle(console.Bold + "Wordlists" + console.Normal)
wordlistTable.SetTitle(console.StyleBold.Render("Wordlists"))
wordlistTable.SetStyle(settings.GetTableStyle(con))
wordlistTable.AppendHeader(table.Row{"Name", "Size"})
rulesTable := table.NewWriter()
rulesTable.SetTitle(console.Bold + "Rules" + console.Normal)
rulesTable.SetTitle(console.StyleBold.Render("Rules"))
rulesTable.SetStyle(settings.GetTableStyle(con))
rulesTable.AppendHeader(table.Row{"Name", "Size"})
hcTable := table.NewWriter()
hcTable.SetTitle(console.Bold + "Markov Hcstat2" + console.Normal)
hcTable.SetTitle(console.StyleBold.Render("Markov Hcstat2"))
hcTable.SetStyle(settings.GetTableStyle(con))
hcTable.AppendHeader(table.Row{"Name", "Size"})
@@ -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
}
+166
View File
@@ -0,0 +1,166 @@
package crack
import "github.com/spf13/pflag"
func bindCrackFlags(f *pflag.FlagSet) {
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)")
f.Uint32("stdin-timeout-abort", 0, "abort after stdin timeout (seconds)")
f.Bool("machine-readable", false, "machine readable output")
f.Bool("keep-guessing", false, "keep guessing")
f.Bool("self-test-disable", false, "disable self-test")
f.Bool("loopback", false, "use loopback mode")
f.String("markov-hcstat2", "", "markov hcstat2 filename or identifier")
f.Bool("markov-disable", false, "disable markov mode")
f.Bool("markov-classic", false, "use classic markov")
f.Bool("markov-inverse", false, "use inverse markov")
f.Uint32("markov-threshold", 0, "markov threshold")
f.Uint32("runtime", 0, "runtime in seconds")
f.String("session", "", "session name")
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("show", false, "show cracked hashes")
f.Bool("left", false, "show uncracked hashes")
f.Bool("username", false, "enable username handling")
f.Bool("remove", false, "remove cracked hashes")
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 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", "", "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, "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 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 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-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")
f.Uint32("backend-vector-width", 0, "backend vector width")
f.Uint32("spin-damp", 0, "spin damp")
f.Bool("hwmon-disable", false, "disable hardware monitoring")
f.Uint32("hwmon-temp-abort", 0, "hardware monitor temperature abort")
f.Uint32("scrypt-tmto", 0, "scrypt tmto")
f.Uint64("skip", 0, "skip count")
f.Uint64("limit", 0, "limit count")
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-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("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")
f.String("brain-client-features", "", "brain client features")
f.String("brain-host", "", "brain host")
f.Uint32("brain-port", 0, "brain port")
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)
}
}
+279 -46
View File
@@ -22,6 +22,8 @@ import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
@@ -30,10 +32,75 @@ 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) {
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
}
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)
if err != nil {
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
}
con.PrintInfof("Crack job %s created (status: %s)\n", resp.Job.ID, resp.Job.Status.String())
if resp.Job.Err != "" {
con.PrintErrorf("Crack job error: %s\n", resp.Job.Err)
}
return
}
if !AreCrackersOnline(con) {
PrintNoCrackstations(con)
} else {
@@ -57,20 +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
}
if len(crackers.Crackstations) == 0 {
showBenchmarks, _ := cmd.Flags().GetBool("show-benchmarks")
if len(crackers) == 0 {
PrintNoCrackstations(con)
} else {
PrintCrackers(crackers.Crackstations, con)
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")
}
@@ -83,66 +269,113 @@ func AreCrackersOnline(con *console.SliverClient) bool {
return len(crackers.Crackstations) > 0
}
func PrintCrackers(crackers []*clientpb.Crackstation, con *console.SliverClient) {
sort.Slice(crackers, func(i, j int) bool {
return crackers[i].Name < crackers[j].Name
func PrintCrackers(crackers []*clientpb.Crackstation, con *console.SliverClient, showBenchmarks bool) {
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)
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) {
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(settings.GetTableStyle(con))
tw.SetTitle(console.Bold + console.Orange + fmt.Sprintf(">>> Crackstation %02d - %s (%s)", index+1, cracker.Name, cracker.OperatorName) + console.Normal + "\n")
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.Bold + "Operating System" + console.Normal, fmt.Sprintf("%s/%s", cracker.GOOS, cracker.GOARCH)})
tw.AppendRow(table.Row{console.Bold + "Hashcat Version" + console.Normal, cracker.HashcatVersion})
if 0 < len(cracker.CUDA) {
for _, cuda := range cracker.CUDA {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.Bold + "CUDA Device" + console.Normal, fmt.Sprintf(console.Bold+console.Green+"%s (%s)"+console.Normal, cuda.Name, cuda.Version)})
tw.AppendRow(table.Row{console.Bold + "Memory" + console.Normal, fmt.Sprintf("%s free of %s", cuda.MemoryFree, cuda.MemoryTotal)})
tw.AppendRow(table.Row{console.Bold + "Clock" + console.Normal, fmt.Sprintf("%d", cuda.Clock)})
tw.AppendRow(table.Row{console.Bold + "Processors" + console.Normal, fmt.Sprintf("%d", cuda.Processors)})
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)
}
}
if 0 < len(cracker.Metal) {
for _, metal := range cracker.Metal {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.Bold + "Metal Device" + console.Normal, fmt.Sprintf(console.Bold+console.Green+"%s (%s)"+console.Normal, metal.Name, metal.Version)})
tw.AppendRow(table.Row{console.Bold + "Memory" + console.Normal, fmt.Sprintf("%s free of %s", metal.MemoryFree, metal.MemoryTotal)})
tw.AppendRow(table.Row{console.Bold + "Clock" + console.Normal, fmt.Sprintf("%d", metal.Clock)})
tw.AppendRow(table.Row{console.Bold + "Processors" + console.Normal, fmt.Sprintf("%d", metal.Processors)})
}
}
if 0 < len(cracker.OpenCL) {
for _, openCL := range cracker.OpenCL {
tw.AppendSeparator()
tw.AppendRow(table.Row{console.Bold + "OpenCL Device" + console.Normal, fmt.Sprintf(console.Bold+console.Green+console.Bold+"%s (%s)"+console.Normal, openCL.Name, openCL.Version)})
tw.AppendRow(table.Row{console.Bold + "Memory" + console.Normal, fmt.Sprintf("%s free of %s", openCL.MemoryFree, openCL.MemoryTotal)})
tw.AppendRow(table.Row{console.Bold + "Clock" + console.Normal, fmt.Sprintf("%d", openCL.Clock)})
tw.AppendRow(table.Row{console.Bold + "Processors" + console.Normal, fmt.Sprintf("%d", openCL.Processors)})
}
}
con.Printf("%s\n", tw.Render())
con.Println()
printBenchmarks(cracker, con)
return name
}
func printBenchmarks(cracker *clientpb.Crackstation, con *console.SliverClient) {
tw := table.NewWriter()
tw.SetStyle(settings.GetTableStyle(con))
tw.SetTitle(console.Bold + "Benchmarks" + console.Normal)
tw.SetTitle(console.StyleBold.Render("Benchmarks"))
tw.SortBy([]table.SortBy{{Name: "Hash Type"}})
tw.AppendHeader(table.Row{"Hash Type", "Rate (H/s)"})
for hashType, speed := range cracker.Benchmarks {
tw.AppendRow(table.Row{clientpb.HashType(hashType), fmt.Sprintf("%d", speed)})
tw.AppendHeader(table.Row{"Hash Type", "Rate"})
if len(cracker.Benchmarks) == 0 {
tw.AppendRow(table.Row{"No benchmarks reported", "-"})
} else {
for hashType, speed := range cracker.Benchmarks {
name, ok := hashcatHashTypeName(hashType)
if !ok {
name = clientpb.HashType(hashType).String()
}
tw.AppendRow(table.Row{name, humanizeHashRate(speed)})
}
}
con.Printf("%s\n", tw.Render())
}
@@ -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)
}
}
+11
View File
@@ -0,0 +1,11 @@
package crack
import "github.com/bishopfox/sliver/client/command/crack/internal/hashcatdisplay"
func hashcatHashTypeName(mode int32) (string, bool) {
return hashcatdisplay.TypeName(mode)
}
func humanizeHashRate(rate uint64) string {
return hashcatdisplay.HumanizeRate(rate)
}
+120 -6
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})
@@ -25,7 +139,7 @@ func CrackHcstat2Completer(con *console.SliverClient) carapace.Action {
}
results = append(results, file.Name)
desc := fmt.Sprintf("%s (%s chunks, chunk size: %s)", byteCountSI(file.UncompressedSize), len(file.Chunks), byteCountSI(file.ChunkSize))
desc := fmt.Sprintf("%s (%d chunks, chunk size: %s)", byteCountSI(file.UncompressedSize), len(file.Chunks), byteCountSI(file.ChunkSize))
results = append(results, desc)
}
@@ -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
Loaded 100 of 7873 files, more files were not shown because too many files have changed in this diff. Show more