mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-08 01:38:17 -10:00
Compare commits
161
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b12c80534 | ||
|
|
67c6d0d356 | ||
|
|
5e3b432b9b | ||
|
|
fc4d9a1c36 | ||
|
|
55b97df9f7 | ||
|
|
227328fa87 | ||
|
|
071106d0bf | ||
|
|
8c326e6af9 | ||
|
|
5e9ba47bf4 | ||
|
|
0537e285fc | ||
|
|
ca685f5eed | ||
|
|
902999493b | ||
|
|
14363c5d2a | ||
|
|
926b7ae1a7 | ||
|
|
bf815b36ed | ||
|
|
637853e8c2 | ||
|
|
0c022ec722 | ||
|
|
7c83b458fa | ||
|
|
09b0540e75 | ||
|
|
b4005efda8 | ||
|
|
6d086fd7a7 | ||
|
|
ccebd505b0 | ||
|
|
94c5022dd0 | ||
|
|
b19e383111 | ||
|
|
02ebd99c68 | ||
|
|
83d82e7161 | ||
|
|
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 | ||
|
|
069d57b5e5 | ||
|
|
e023211c02 | ||
|
|
a48006640d | ||
|
|
764fd0eddf | ||
|
|
9c9943c94c | ||
|
|
2e9b52371e | ||
|
|
6d6df68972 | ||
|
|
7f863d3172 | ||
|
|
6ecb379bce | ||
|
|
db45c4aaf9 | ||
|
|
198913d6f8 | ||
|
|
cd85824185 | ||
|
|
63797ac79c | ||
|
|
4633409623 | ||
|
|
13c2d33449 | ||
|
|
8cf478d7b0 | ||
|
|
f55215ff93 | ||
|
|
b5d3d0bad6 | ||
|
|
a32d81252c | ||
|
|
a1537535c4 | ||
|
|
61dd34e989 | ||
|
|
2b67cb917b | ||
|
|
ceead92ec5 | ||
|
|
df9ed94635 | ||
|
|
e891613f6f | ||
|
|
528b04427a | ||
|
|
f60a0e91d3 | ||
|
|
2308314529 | ||
|
|
04bc82bca3 | ||
|
|
acdfa33231 | ||
|
|
a03592de81 | ||
|
|
5cf9d088e8 | ||
|
|
db3294b926 | ||
|
|
1847b8c24b | ||
|
|
79e4259081 | ||
|
|
9ff9b55352 | ||
|
|
9767e24edd | ||
|
|
bb572f3846 | ||
|
|
01c5b5cee4 | ||
|
|
1049dc4a98 | ||
|
|
d1b0ba13c4 | ||
|
|
2d98f14149 | ||
|
|
19e82064b1 | ||
|
|
3b72374685 | ||
|
|
20fb8c4975 | ||
|
|
68829c0a40 | ||
|
|
2c18062f4d | ||
|
|
f17ce0afbe | ||
|
|
116b12885c | ||
|
|
3d9860db6c | ||
|
|
1a6579e954 | ||
|
|
2659d848d7 | ||
|
|
df6571f63f | ||
|
|
aab1470e08 | ||
|
|
19d43fdc37 | ||
|
|
3b09e5ea80 | ||
|
|
b904d29b25 | ||
|
|
8e2b069a7d | ||
|
|
096c418d27 | ||
|
|
d9e241ccb5 | ||
|
|
086da95525 | ||
|
|
27ed557e55 | ||
|
|
af7c823508 | ||
|
|
f2a3d8ee6d | ||
|
|
de7215d2c0 | ||
|
|
2d555b16d9 | ||
|
|
597a2571fc | ||
|
|
409b4c8fdb | ||
|
|
2e72bc96b9 | ||
|
|
fa9557348e | ||
|
|
4768e36074 | ||
|
|
e53f66de72 | ||
|
|
1d33b89ec5 | ||
|
|
7b0f420290 | ||
|
|
00824d1b83 | ||
|
|
99ad05b7b7 | ||
|
|
2cdefc7369 | ||
|
|
80c752a422 | ||
|
|
8a8a9dc498 | ||
|
|
5f8540b3c4 | ||
|
|
4eef868047 | ||
|
|
5ebfecd822 | ||
|
|
9762560228 | ||
|
|
6310c7d6a0 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# Preserve pinned upstream source and attribution files byte-for-byte.
|
||||
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.2
|
||||
- name: Go 1.26.6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.2"
|
||||
go-version: "1.26.6"
|
||||
|
||||
- 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.2
|
||||
- name: Go 1.26.6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.2"
|
||||
go-version: "1.26.6"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
name: Comprehensive e2e Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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 }}
|
||||
with:
|
||||
script: |
|
||||
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");
|
||||
}
|
||||
|
||||
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 comprehensive E2E suite
|
||||
run: >-
|
||||
./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
|
||||
|
||||
- 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 comprehensive E2E suite
|
||||
run: >-
|
||||
./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
|
||||
|
||||
- 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
|
||||
|
||||
linux-386:
|
||||
name: comprehensive-e2e-linux-386
|
||||
needs: authorize
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 360
|
||||
env:
|
||||
CONTAINER_NAME: sliver-comprehensive-e2e-linux-386-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
IMAGE_NAME: sliver-comprehensive-e2e-linux-386:${{ github.run_id }}-${{ github.run_attempt }}
|
||||
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/386\n' > e2e-results/target-linux-386.txt
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build linux/386 E2E image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go_version="$(awk '$1 == "go" { print $2; exit }' go.mod)"
|
||||
docker buildx build \
|
||||
--load \
|
||||
--platform linux/386 \
|
||||
--build-arg "GO_VERSION=${go_version}" \
|
||||
--file test/e2e/Dockerfile.linux-386 \
|
||||
--tag "${IMAGE_NAME}" \
|
||||
.
|
||||
|
||||
- name: Run comprehensive E2E suite
|
||||
run: >-
|
||||
docker run
|
||||
--name "${CONTAINER_NAME}"
|
||||
--platform linux/386
|
||||
--volume "${PWD}/e2e-results:/results"
|
||||
"${IMAGE_NAME}"
|
||||
|
||||
- name: Clean up linux/386 Docker resources
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
docker rm --force "${CONTAINER_NAME}" 2>/dev/null || true
|
||||
docker image rm --force "${IMAGE_NAME}" 2>/dev/null || true
|
||||
docker buildx prune --all --force >/dev/null 2>&1 || true
|
||||
|
||||
- name: Upload target coverage
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: comprehensive-e2e-target-linux-386
|
||||
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: 386
|
||||
server_arch: amd64
|
||||
- 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 comprehensive E2E suite
|
||||
run: >-
|
||||
./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
|
||||
|
||||
- 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-coverage:
|
||||
name: Aggregate comprehensive E2E coverage
|
||||
needs:
|
||||
- authorize
|
||||
- darwin
|
||||
- linux-native
|
||||
- linux-386
|
||||
- windows
|
||||
if: ${{ always() && needs.authorize.result == 'success' }}
|
||||
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 or not-run coverage
|
||||
if: ${{ steps.aggregate.outcome != 'success' }}
|
||||
run: exit 1
|
||||
|
||||
reflektor:
|
||||
name: Existing Reflektor test matrix
|
||||
needs: authorize
|
||||
uses: ./.github/workflows/reflektor-tests.yml
|
||||
|
||||
shellcode:
|
||||
name: Shellcode E2E test matrix
|
||||
needs: authorize
|
||||
uses: ./.github/workflows/shellcode-tests.yml
|
||||
@@ -0,0 +1,56 @@
|
||||
name: golangci-lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".golangci.yml"
|
||||
- ".github/workflows/golangci-lint.yml"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "go.work"
|
||||
- "go.work.sum"
|
||||
- "Makefile"
|
||||
- "vendor/**"
|
||||
- "**/*.go"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: golangci-lint (${{ matrix.goos }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: amd64
|
||||
CGO_ENABLED: "0"
|
||||
steps:
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Go 1.26.6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.6"
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.12.2
|
||||
# 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,214 @@
|
||||
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 }}
|
||||
|
||||
linux-386:
|
||||
name: reflektor-linux-386-docker
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
CONTAINER_NAME: sliver-reflektor-linux-386-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
IMAGE_NAME: sliver-reflektor-linux-386:${{ github.run_id }}-${{ github.run_attempt }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build linux/386 test image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go_version="$(awk '$1 == "go" { print $2; exit }' go.mod)"
|
||||
docker buildx build \
|
||||
--load \
|
||||
--platform linux/386 \
|
||||
--build-arg "GO_VERSION=${go_version}" \
|
||||
--file test/reflektor/Dockerfile.linux-386 \
|
||||
--tag "${IMAGE_NAME}" \
|
||||
.
|
||||
docker buildx prune --all --force
|
||||
|
||||
- name: Load Sliver with Reflektor
|
||||
run: >-
|
||||
docker run
|
||||
--name "${CONTAINER_NAME}"
|
||||
--platform linux/386
|
||||
"${IMAGE_NAME}"
|
||||
|
||||
- name: Clean up linux/386 Docker resources
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
docker rm --force "${CONTAINER_NAME}" 2>/dev/null || true
|
||||
docker image rm --force "${IMAGE_NAME}" 2>/dev/null || true
|
||||
docker buildx prune --all --force >/dev/null 2>&1 || true
|
||||
|
||||
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-2022
|
||||
goarch: 386
|
||||
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,208 @@
|
||||
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: "386"
|
||||
server_os: windows
|
||||
server_arch: amd64
|
||||
server_executable: ./sliver-server.exe
|
||||
test_executable: ./sliver-shellcode-e2e.exe
|
||||
- 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
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Go 1.26.2
|
||||
- name: Go 1.26.6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.2"
|
||||
go-version: "1.26.6"
|
||||
|
||||
- name: OS Packages
|
||||
run: |
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Go 1.26.2
|
||||
- name: Go 1.26.6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.2"
|
||||
go-version: "1.26.6"
|
||||
|
||||
- name: Install make
|
||||
run: choco install make -y --no-progress
|
||||
@@ -115,10 +115,10 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Go 1.26.2
|
||||
- name: Go 1.26.6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.2"
|
||||
go-version: "1.26.6"
|
||||
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
@@ -153,10 +153,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Go 1.26.2
|
||||
- name: Go 1.26.6
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.2"
|
||||
go-version: "1.26.6"
|
||||
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 10m
|
||||
modules-download-mode: vendor
|
||||
build-tags:
|
||||
- client
|
||||
- server
|
||||
- osusergo
|
||||
- netgo
|
||||
- go_sqlite
|
||||
# Raw implant entry points are Go templates; lint their supporting packages.
|
||||
- sliver_lint
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
enable:
|
||||
- gocyclo
|
||||
- misspell
|
||||
- revive
|
||||
settings:
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
@@ -2,18 +2,31 @@
|
||||
# Makefile for Sliver
|
||||
#
|
||||
|
||||
.DEFAULT_GOAL := default
|
||||
|
||||
GO ?= go
|
||||
ARTIFACT_SUFFIX ?=
|
||||
ENV =
|
||||
TAGS ?= -tags go_sqlite
|
||||
CGO_ENABLED = 0
|
||||
GOLANGCI_LINT ?= golangci-lint
|
||||
# The tree has legacy lint findings, so gate issues introduced by this change.
|
||||
GOLANGCI_LINT_BASE ?= master
|
||||
GOLANGCI_LINT_ARGS ?= --new-from-merge-base=$(GOLANGCI_LINT_BASE)
|
||||
GOLANGCI_LINT_PACKAGES = \
|
||||
./client/... \
|
||||
./server/... \
|
||||
./util/... \
|
||||
./protobuf/... \
|
||||
./docs \
|
||||
./implant/...
|
||||
|
||||
ifneq (,$(findstring cgo_sqlite,$(TAGS)))
|
||||
CGO_ENABLED = 1
|
||||
endif
|
||||
|
||||
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
|
||||
MIN_SUPPORTED_GO_MINOR_VERSION = 25
|
||||
MIN_SUPPORTED_GO_MINOR_VERSION = 26
|
||||
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
|
||||
@@ -23,6 +36,10 @@ CLIENT_ASSETS_PKG = github.com/bishopfox/sliver/client/assets
|
||||
SLIVER_UPDATE_PKG = github.com/bishopfox/sliver/client/command/update
|
||||
PB_COMPILERS = protoc protoc-gen-go protoc-gen-go-grpc
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
$(GOLANGCI_LINT) run $(GOLANGCI_LINT_ARGS) $(GOLANGCI_LINT_PACKAGES)
|
||||
|
||||
ifneq ($(OS),Windows_NT)
|
||||
|
||||
#
|
||||
|
||||
@@ -4,7 +4,7 @@ Sliver is an open source cross-platform adversary emulation/red team framework,
|
||||
|
||||
The server and client support MacOS, Windows, and Linux. Implants are supported on MacOS, Windows, and Linux (and possibly every Golang compiler target but we've not tested them all).
|
||||
|
||||
[](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [](https://goreportcard.com/report/github.com/BishopFox/sliver) [](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml) [](https://www.gnu.org/licenses/gpl-3.0)
|
||||
|
||||
Visit [https://sliver.sh/](https://sliver.sh/) for tutorials and documentation.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package alias
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2021 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Alias commands run with cobra's DisableFlagParsing enabled (see LoadAlias),
|
||||
// the same treatment extension commands received for their arguments. Before
|
||||
// that, an unknown flag such as the payload's own `-pid` was rejected outright
|
||||
// by pflag ("unknown flag"), and operators had to inject cobra's "--"
|
||||
// separator (documented on the wiki) to pass arguments through at all (#2264).
|
||||
//
|
||||
// With flag parsing disabled cobra hands Run the raw, unstripped slice, and we
|
||||
// extract the Sliver-owned flags ourselves here. Everything we do not own is
|
||||
// passed to the payload untouched, so all of these now work:
|
||||
//
|
||||
// amsi-bypass -pid 6969
|
||||
// fakealias --process notepad.exe arg1 arg2
|
||||
// fakealias -- --anything still-here (existing workaround, unchanged)
|
||||
type ownedFlagKind int
|
||||
|
||||
const (
|
||||
ownedBool ownedFlagKind = iota
|
||||
ownedString
|
||||
ownedUint32
|
||||
ownedInt64
|
||||
)
|
||||
|
||||
// ownedFlagSpec describes one Sliver-owned flag that splitAliasArgs extracts
|
||||
// from the raw argument slice before the rest is handed to the alias payload.
|
||||
type ownedFlagSpec struct {
|
||||
long string // canonical pflag name, e.g. "process-arguments"
|
||||
short string // shorthand letter, e.g. "A" ("" if the flag has none)
|
||||
kind ownedFlagKind // how the value is consumed and validated
|
||||
}
|
||||
|
||||
// baseAliasOwnedFlags are registered for every alias command.
|
||||
var baseAliasOwnedFlags = []ownedFlagSpec{
|
||||
{long: "process", short: "p", kind: ownedString},
|
||||
{long: "process-arguments", short: "A", kind: ownedString},
|
||||
{long: "ppid", short: "P", kind: ownedUint32},
|
||||
{long: "save", short: "s", kind: ownedBool},
|
||||
{long: "timeout", short: "t", kind: ownedInt64},
|
||||
}
|
||||
|
||||
// assemblyAliasOwnedFlags are only owned when the alias wraps a .NET assembly;
|
||||
// for every other alias they are unknown tokens and pass through to the payload.
|
||||
var assemblyAliasOwnedFlags = []ownedFlagSpec{
|
||||
{long: "method", short: "m", kind: ownedString},
|
||||
{long: "class", short: "c", kind: ownedString},
|
||||
{long: "app-domain", short: "d", kind: ownedString},
|
||||
{long: "arch", short: "a", kind: ownedString},
|
||||
{long: "in-process", short: "i", kind: ownedBool},
|
||||
{long: "runtime", short: "r", kind: ownedString},
|
||||
{long: "amsi-bypass", short: "M", kind: ownedBool},
|
||||
{long: "etw-bypass", short: "E", kind: ownedBool},
|
||||
}
|
||||
|
||||
// aliasOwnedFlagSpecs returns the flag set owned by a particular alias.
|
||||
func aliasOwnedFlagSpecs(isAssembly bool) []ownedFlagSpec {
|
||||
if !isAssembly {
|
||||
return baseAliasOwnedFlags
|
||||
}
|
||||
specs := make([]ownedFlagSpec, 0, len(baseAliasOwnedFlags)+len(assemblyAliasOwnedFlags))
|
||||
specs = append(specs, baseAliasOwnedFlags...)
|
||||
return append(specs, assemblyAliasOwnedFlags...)
|
||||
}
|
||||
|
||||
// ownedFlagMatch is a raw token that spelled one of the Sliver-owned flags.
|
||||
type ownedFlagMatch struct {
|
||||
spec ownedFlagSpec
|
||||
value string // value from a "--flag=value" / "-f=value" spelling
|
||||
inline bool // whether the token carried its value inline
|
||||
}
|
||||
|
||||
// splitAliasArgs separates the Sliver-owned flags from the alias payload's own
|
||||
// arguments in a raw (DisableFlagParsing) argument slice. It accepts the long
|
||||
// forms --flag, --flag=value, --flag value and the shorthand forms -f, -f=value,
|
||||
// -f value, mirroring pflag. Two deliberate differences from pflag:
|
||||
//
|
||||
// - a glued shorthand value (-pnotepad) does NOT match --process: tokens
|
||||
// like the payload's own "-pid" must survive intact (#2264);
|
||||
// - after a literal "--" every token is copied through verbatim, preserving
|
||||
// the long-standing separator workaround.
|
||||
//
|
||||
// The returned map holds the raw string value of each flag that was provided;
|
||||
// callers re-publish the values with applyOwnedAliasFlags.
|
||||
func splitAliasArgs(raw []string, specs []ownedFlagSpec) (map[string]string, bool, []string, error) {
|
||||
values := make(map[string]string)
|
||||
extArgs := make([]string, 0, len(raw))
|
||||
byLong, byShort := ownedFlagTables(specs)
|
||||
|
||||
passthrough := false
|
||||
for i := 0; i < len(raw); i++ {
|
||||
tok := raw[i]
|
||||
|
||||
// Once we have seen "--", copy everything through untouched.
|
||||
if passthrough {
|
||||
extArgs = append(extArgs, tok)
|
||||
continue
|
||||
}
|
||||
if tok == "--" {
|
||||
passthrough = true
|
||||
continue
|
||||
}
|
||||
if tok == "--help" || tok == "-h" {
|
||||
return values, true, extArgs, nil
|
||||
}
|
||||
|
||||
match, ok := matchOwnedFlag(tok, byLong, byShort)
|
||||
if !ok {
|
||||
// Not one of ours (including glued shorthands and the payload's
|
||||
// own flags): the alias owns the token, pass it through untouched.
|
||||
extArgs = append(extArgs, tok)
|
||||
continue
|
||||
}
|
||||
i = takeOwnedValue(match, raw, i, values)
|
||||
}
|
||||
|
||||
if err := validateOwnedValues(specs, values); err != nil {
|
||||
return nil, false, nil, err
|
||||
}
|
||||
return values, false, extArgs, nil
|
||||
}
|
||||
|
||||
// ownedFlagTables indexes the specs by long name and shorthand.
|
||||
func ownedFlagTables(specs []ownedFlagSpec) (byLong, byShort map[string]ownedFlagSpec) {
|
||||
byLong = make(map[string]ownedFlagSpec, len(specs))
|
||||
byShort = make(map[string]ownedFlagSpec, len(specs))
|
||||
for _, spec := range specs {
|
||||
byLong[spec.long] = spec
|
||||
if spec.short != "" {
|
||||
byShort[spec.short] = spec
|
||||
}
|
||||
}
|
||||
return byLong, byShort
|
||||
}
|
||||
|
||||
// matchOwnedFlag checks whether tok spells one of the Sliver-owned flags, in
|
||||
// the long form (--name, --name=value) or shorthand form (-f, -f=value). A
|
||||
// glued shorthand value (-pnotepad) deliberately does not match: pflag would
|
||||
// read "-pid" as -p with value "id", which is exactly the mangling that broke
|
||||
// #2264, so such tokens must fall through to the payload.
|
||||
func matchOwnedFlag(tok string, byLong, byShort map[string]ownedFlagSpec) (ownedFlagMatch, bool) {
|
||||
var match ownedFlagMatch
|
||||
var name, value string
|
||||
var hasValue, isShort bool
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(tok, "--"):
|
||||
name, value, hasValue = strings.Cut(tok[2:], "=")
|
||||
case strings.HasPrefix(tok, "-") && len(tok) > 1:
|
||||
name, value, hasValue = strings.Cut(tok[1:], "=")
|
||||
isShort = len(name) == 1
|
||||
default:
|
||||
return match, false
|
||||
}
|
||||
|
||||
spec, ok := byLong[name]
|
||||
if isShort {
|
||||
spec, ok = byShort[name]
|
||||
}
|
||||
if !ok {
|
||||
return match, false
|
||||
}
|
||||
match.spec, match.value, match.inline = spec, value, hasValue
|
||||
return match, true
|
||||
}
|
||||
|
||||
// takeOwnedValue records the flag's value in values and returns the index of
|
||||
// the last raw token it consumed. The "--flag value" / "-f value" spellings
|
||||
// consume the following token; a bool without an inline value defaults to
|
||||
// true; a value flag as the very last token is dropped and keeps its default,
|
||||
// mirroring the extension behaviour.
|
||||
func takeOwnedValue(match ownedFlagMatch, raw []string, i int, values map[string]string) int {
|
||||
switch {
|
||||
case match.spec.kind == ownedBool:
|
||||
if !match.inline {
|
||||
match.value = "true"
|
||||
}
|
||||
values[match.spec.long] = match.value
|
||||
case match.inline:
|
||||
values[match.spec.long] = match.value
|
||||
case i+1 < len(raw):
|
||||
values[match.spec.long] = raw[i+1]
|
||||
return i + 1
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// validateOwnedValues rejects non-numeric values for the numeric flag kinds,
|
||||
// the way pflag's typed values would have.
|
||||
func validateOwnedValues(specs []ownedFlagSpec, values map[string]string) error {
|
||||
for _, spec := range specs {
|
||||
value, provided := values[spec.long]
|
||||
if !provided {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
switch spec.kind {
|
||||
case ownedUint32:
|
||||
_, err = strconv.ParseUint(value, 10, 32)
|
||||
case ownedInt64:
|
||||
_, err = strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid value %q for --%s", value, spec.long)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyOwnedAliasFlags republishes the manually parsed Sliver-owned flag values
|
||||
// onto the command's flag set. DisableFlagParsing prevents pflag from
|
||||
// populating them, so readers such as runAliasCommand's Get* calls and
|
||||
// ActiveTarget.Request (which derives the gRPC timeout from --timeout) would
|
||||
// otherwise observe only the defaults.
|
||||
func applyOwnedAliasFlags(cmd *cobra.Command, values map[string]string) error {
|
||||
for name, value := range values {
|
||||
flag := cmd.Flags().Lookup(name)
|
||||
if flag == nil {
|
||||
continue // e.g. an assembly flag on a non-assembly alias
|
||||
}
|
||||
if err := flag.Value.Set(value); err != nil {
|
||||
return fmt.Errorf("invalid value %q for --%s: %w", value, name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package alias
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2021 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestSplitAliasArgs covers the argument passthrough regression from #2264:
|
||||
// the alias payload's own flags must reach the payload intact, while the
|
||||
// Sliver-owned flags are extracted and validated.
|
||||
func TestSplitAliasArgs(t *testing.T) {
|
||||
base := aliasOwnedFlagSpecs(false)
|
||||
assembly := aliasOwnedFlagSpecs(true)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
specs []ownedFlagSpec
|
||||
in []string
|
||||
values map[string]string
|
||||
help bool
|
||||
extArgs []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "payload flag without -- separator (#2264 regression)",
|
||||
specs: base,
|
||||
in: []string{"-pid", "6969"},
|
||||
values: map[string]string{},
|
||||
extArgs: []string{"-pid", "6969"},
|
||||
},
|
||||
{
|
||||
name: "glued shorthand is not ours and passes through",
|
||||
specs: base,
|
||||
in: []string{"-pid"},
|
||||
values: map[string]string{},
|
||||
extArgs: []string{"-pid"},
|
||||
},
|
||||
{
|
||||
name: "payload flag with -- separator (existing workaround)",
|
||||
specs: base,
|
||||
in: []string{"--", "--pid", "6969"},
|
||||
values: map[string]string{},
|
||||
extArgs: []string{"--pid", "6969"},
|
||||
},
|
||||
{
|
||||
name: "owned flag after -- belongs to the payload",
|
||||
specs: base,
|
||||
in: []string{"--", "--save"},
|
||||
values: map[string]string{},
|
||||
extArgs: []string{"--save"},
|
||||
},
|
||||
{
|
||||
name: "long flag with separate value, then payload args",
|
||||
specs: base,
|
||||
in: []string{"--process", "notepad.exe", "arg1", "arg2"},
|
||||
values: map[string]string{"process": "notepad.exe"},
|
||||
extArgs: []string{"arg1", "arg2"},
|
||||
},
|
||||
{
|
||||
name: "long flag with equals value",
|
||||
specs: base,
|
||||
in: []string{"--process=notepad.exe", "arg1"},
|
||||
values: map[string]string{"process": "notepad.exe"},
|
||||
extArgs: []string{"arg1"},
|
||||
},
|
||||
{
|
||||
name: "shorthand with separate value",
|
||||
specs: base,
|
||||
in: []string{"-p", "notepad.exe", "arg1"},
|
||||
values: map[string]string{"process": "notepad.exe"},
|
||||
extArgs: []string{"arg1"},
|
||||
},
|
||||
{
|
||||
name: "shorthand with equals value",
|
||||
specs: base,
|
||||
in: []string{"-p=notepad.exe"},
|
||||
values: map[string]string{"process": "notepad.exe"},
|
||||
extArgs: []string{},
|
||||
},
|
||||
{
|
||||
name: "bool shorthand and long false form",
|
||||
specs: base,
|
||||
in: []string{"-s", "--save=false", "arg1"},
|
||||
values: map[string]string{"save": "false"},
|
||||
extArgs: []string{"arg1"},
|
||||
},
|
||||
{
|
||||
name: "quoted value containing spaces stays a single argument",
|
||||
specs: base,
|
||||
in: []string{"-A", "-kalc -kdisable", "arg1"},
|
||||
values: map[string]string{"process-arguments": "-kalc -kdisable"},
|
||||
extArgs: []string{"arg1"},
|
||||
},
|
||||
{
|
||||
name: "valid uint32 and int64 values",
|
||||
specs: base,
|
||||
in: []string{"-P", "1234", "--timeout=90"},
|
||||
values: map[string]string{"ppid": "1234", "timeout": "90"},
|
||||
extArgs: []string{},
|
||||
},
|
||||
{
|
||||
name: "invalid uint32 value errors",
|
||||
specs: base,
|
||||
in: []string{"--ppid", "notanumber"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid int64 value errors",
|
||||
specs: base,
|
||||
in: []string{"--timeout=x"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "assembly flags are owned for assembly aliases",
|
||||
specs: assembly,
|
||||
in: []string{"--method", "Run", "--class", "Prog", "-i", "-M", "--arch", "x64", "payloadArg"},
|
||||
values: map[string]string{
|
||||
"method": "Run", "class": "Prog", "in-process": "true",
|
||||
"amsi-bypass": "true", "arch": "x64",
|
||||
},
|
||||
extArgs: []string{"payloadArg"},
|
||||
},
|
||||
{
|
||||
name: "assembly flags pass through for non-assembly aliases",
|
||||
specs: base,
|
||||
in: []string{"--method", "Run"},
|
||||
values: map[string]string{},
|
||||
extArgs: []string{"--method", "Run"},
|
||||
},
|
||||
{
|
||||
name: "help flags are detected",
|
||||
specs: base,
|
||||
in: []string{"--help"},
|
||||
help: true,
|
||||
values: map[string]string{},
|
||||
extArgs: []string{},
|
||||
},
|
||||
{
|
||||
name: "short help is detected",
|
||||
specs: base,
|
||||
in: []string{"-h"},
|
||||
help: true,
|
||||
values: map[string]string{},
|
||||
extArgs: []string{},
|
||||
},
|
||||
{
|
||||
name: "value flag as last token keeps its default",
|
||||
specs: base,
|
||||
in: []string{"arg1", "--process"},
|
||||
values: map[string]string{},
|
||||
extArgs: []string{"arg1"},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
specs: base,
|
||||
in: nil,
|
||||
values: map[string]string{},
|
||||
extArgs: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
values, help, extArgs, err := splitAliasArgs(tc.in, tc.specs)
|
||||
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got values=%#v help=%v extArgs=%#v", values, help, extArgs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if help != tc.help {
|
||||
t.Errorf("help: got %v, want %v", help, tc.help)
|
||||
}
|
||||
if !reflect.DeepEqual(values, tc.values) {
|
||||
t.Errorf("values: got %#v, want %#v", values, tc.values)
|
||||
}
|
||||
if !reflect.DeepEqual(extArgs, tc.extArgs) {
|
||||
t.Errorf("extArgs: got %#v, want %#v", extArgs, tc.extArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOwnedAliasFlags checks that manually parsed values are re-published
|
||||
// on the command's flag set for every value type an alias can own.
|
||||
func TestApplyOwnedAliasFlags(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
f := pflag.NewFlagSet("alias-test", pflag.ContinueOnError)
|
||||
f.StringP("process", "p", "", "host process")
|
||||
f.StringP("process-arguments", "A", "", "host process arguments")
|
||||
f.Uint32P("ppid", "P", 0, "parent pid")
|
||||
f.BoolP("save", "s", false, "save output")
|
||||
f.Int64P("timeout", "t", 60, "timeout")
|
||||
f.BoolP("amsi-bypass", "M", false, "amsi bypass")
|
||||
cmd.Flags().AddFlagSet(f)
|
||||
|
||||
err := applyOwnedAliasFlags(cmd, map[string]string{
|
||||
"process": "notepad.exe",
|
||||
"process-arguments": "-k foo",
|
||||
"ppid": "1234",
|
||||
"save": "true",
|
||||
"timeout": "90",
|
||||
"amsi-bypass": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
|
||||
for name, want := range map[string]interface{}{
|
||||
"process": "notepad.exe", "process-arguments": "-k foo",
|
||||
} {
|
||||
got, _ := cmd.Flags().GetString(name)
|
||||
if got != want.(string) {
|
||||
t.Errorf("%s: got %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if ppid, _ := cmd.Flags().GetUint32("ppid"); ppid != 1234 {
|
||||
t.Errorf("ppid: got %d, want 1234", ppid)
|
||||
}
|
||||
if save, _ := cmd.Flags().GetBool("save"); !save {
|
||||
t.Error("save: got false, want true")
|
||||
}
|
||||
if timeout, _ := cmd.Flags().GetInt64("timeout"); timeout != 90 {
|
||||
t.Errorf("timeout: got %d, want 90", timeout)
|
||||
}
|
||||
if amsi, _ := cmd.Flags().GetBool("amsi-bypass"); !amsi {
|
||||
t.Error("amsi-bypass: got false, want true")
|
||||
}
|
||||
|
||||
// Unset flags keep their defaults.
|
||||
if inProc := cmd.Flags().Lookup("in-process"); inProc != nil {
|
||||
t.Error("in-process should not be registered on this command")
|
||||
}
|
||||
}
|
||||
@@ -215,8 +215,6 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
|
||||
longHelp.WriteString(fmt.Sprintf("%s (%s):\t%s%s", strings.ToUpper(arg.Name), aType, optStr, arg.Desc))
|
||||
}
|
||||
}
|
||||
longHelp.WriteString("\n\n⚠️ If you're having issues passing arguments to the alias please read:\n")
|
||||
longHelp.WriteString("https://github.com/BishopFox/sliver/wiki/Aliases-&-Extensions#aliases-command-parsing")
|
||||
|
||||
// for each alias command, add a new app command
|
||||
helpMsg := fmt.Sprintf("[%s] %s", aliasManifest.Name, aliasManifest.Help)
|
||||
@@ -227,9 +225,14 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runAliasCommand(cmd, con, args)
|
||||
},
|
||||
Args: cobra.ArbitraryArgs, // a.StringList("arguments", "arguments", grumble.Default([]string{}))
|
||||
GroupID: consts.AliasHelpGroup,
|
||||
Annotations: makeAliasPlatformFilters(aliasManifest),
|
||||
Args: cobra.ArbitraryArgs, // a.StringList("arguments", "arguments", grumble.Default([]string{}))
|
||||
// DisableFlagParsing keeps the alias payload's own flags (which are
|
||||
// unknown to pflag) from being rejected or swallowed; we split the
|
||||
// Sliver-owned flags out of the raw slice ourselves in
|
||||
// runAliasCommand (#2264, same treatment as extensions).
|
||||
DisableFlagParsing: true,
|
||||
GroupID: consts.AliasHelpGroup,
|
||||
Annotations: makeAliasPlatformFilters(aliasManifest),
|
||||
}
|
||||
|
||||
if aliasManifest.IsAssembly {
|
||||
@@ -333,6 +336,27 @@ func runAliasCommand(cmd *cobra.Command, con *console.SliverClient, args []strin
|
||||
return
|
||||
}
|
||||
aliasManifest := loadedAlias.Manifest
|
||||
|
||||
// The command runs with DisableFlagParsing, so args is the raw, unstripped
|
||||
// slice. Separate the Sliver-owned flags from the payload's own arguments
|
||||
// and re-publish them on the flag set (so the Get* readers below and
|
||||
// Request()'s --timeout still work), then hand only the payload arguments
|
||||
// to the execution paths (#2264, same treatment as extensions in #2323).
|
||||
ownedValues, helpRequested, payloadArgs, err := splitAliasArgs(args, aliasOwnedFlagSpecs(aliasManifest.IsAssembly))
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
if helpRequested {
|
||||
_ = cmd.Help()
|
||||
return
|
||||
}
|
||||
if err := applyOwnedAliasFlags(cmd, ownedValues); err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
args = payloadArgs
|
||||
|
||||
binPath, err := aliasManifest.getFileForTarget(cmd.Name(), goos, goarch)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Fail to find alias file: %s\n", err)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type VersionInformation struct {
|
||||
OldVersion string
|
||||
NewVersion string
|
||||
ArmoryName string
|
||||
PackageID string
|
||||
}
|
||||
|
||||
type PackageType uint
|
||||
@@ -124,9 +125,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 +174,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 +200,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 +213,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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package extensions
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2021 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// splitExtensionArgs separates Sliver-owned flags (--save, --timeout, --help)
|
||||
// from the extension's own arguments in a raw argument slice.
|
||||
//
|
||||
// Extension commands run with cobra's DisableFlagParsing enabled (see
|
||||
// ExtensionRegisterCommand). Without it, pflag parses the command line first
|
||||
// and, because the extension's own flags (e.g. --type, --fqdn) are unknown to
|
||||
// pflag, ParseErrorsWhitelist.UnknownFlags causes pflag to silently drop each
|
||||
// unknown flag together with its value; the BOF argument parser never saw them
|
||||
// unless the operator injected cobra's "--" separator (#2309). Running with
|
||||
// flag parsing disabled hands us the raw, unstripped slice, so we separate the
|
||||
// Sliver-owned flags ourselves here.
|
||||
//
|
||||
// A literal "--" stops Sliver-flag scanning; every token after it is passed
|
||||
// through verbatim, preserving the long-standing workaround syntax. Unknown
|
||||
// tokens (the extension's named flags, their values and positional arguments)
|
||||
// are never consumed or reordered, so both forms work:
|
||||
//
|
||||
// delegationbof --timeout 30 --type 6 --fqdn child.htb.local
|
||||
// delegationbof -- --type 6 --fqdn child.htb.local
|
||||
func splitExtensionArgs(raw []string) (save bool, timeout int64, help bool, extArgs []string) {
|
||||
timeout = defaultTimeout
|
||||
extArgs = make([]string, 0, len(raw))
|
||||
|
||||
passthrough := false
|
||||
for i := 0; i < len(raw); i++ {
|
||||
tok := raw[i]
|
||||
|
||||
// Once we have seen "--", copy everything through untouched so the
|
||||
// existing `ext -- --foo bar` syntax keeps working identically.
|
||||
if passthrough {
|
||||
extArgs = append(extArgs, tok)
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case tok == "--":
|
||||
passthrough = true
|
||||
|
||||
case tok == "--help" || tok == "-h":
|
||||
help = true
|
||||
|
||||
case tok == "--save" || tok == "-s":
|
||||
save = true
|
||||
|
||||
case strings.HasPrefix(tok, "--save="):
|
||||
save = parseBoolValue(tok[len("--save="):], true)
|
||||
|
||||
case tok == "--timeout" || tok == "-t":
|
||||
// Consume the following token as the value, mirroring pflag. If no
|
||||
// token follows, leave the default in place rather than erroring.
|
||||
if i+1 < len(raw) {
|
||||
if n, err := strconv.ParseInt(raw[i+1], 10, 64); err == nil {
|
||||
timeout = n
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
case strings.HasPrefix(tok, "--timeout="):
|
||||
if n, err := strconv.ParseInt(tok[len("--timeout="):], 10, 64); err == nil {
|
||||
timeout = n
|
||||
}
|
||||
|
||||
default:
|
||||
// Not a Sliver-owned flag: hand it to the extension parser untouched.
|
||||
extArgs = append(extArgs, tok)
|
||||
}
|
||||
}
|
||||
|
||||
return save, timeout, help, extArgs
|
||||
}
|
||||
|
||||
// applyOwnedFlags republishes the manually parsed Sliver-owned flag values back
|
||||
// onto the command's flag set. DisableFlagParsing prevents pflag from populating
|
||||
// them, so helpers that read them via cmd.Flags() (notably ActiveTarget.Request,
|
||||
// which derives the gRPC timeout from --timeout) would otherwise observe only the
|
||||
// defaults.
|
||||
func applyOwnedFlags(cmd *cobra.Command, save bool, timeout int64) {
|
||||
if flag := cmd.Flags().Lookup("timeout"); flag != nil {
|
||||
_ = flag.Value.Set(strconv.FormatInt(timeout, 10))
|
||||
}
|
||||
if flag := cmd.Flags().Lookup("save"); flag != nil {
|
||||
_ = flag.Value.Set(strconv.FormatBool(save))
|
||||
}
|
||||
}
|
||||
|
||||
// parseBoolValue parses a --flag=value bool, defaulting to defVal when the value
|
||||
// is empty (the bare-flag form). It accepts the same spellings pflag does.
|
||||
func parseBoolValue(v string, defVal bool) bool {
|
||||
switch strings.ToLower(v) {
|
||||
case "", "true", "t", "1":
|
||||
return true
|
||||
case "false", "f", "0":
|
||||
return false
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package extensions
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2021 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSplitExtensionArgs covers the argument-passthrough regression from #2309:
|
||||
// the extension's own named flags must reach the BOF parser intact regardless of
|
||||
// the cobra "--" separator, while Sliver-owned flags are still honoured.
|
||||
func TestSplitExtensionArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
save bool
|
||||
timeout int64
|
||||
help bool
|
||||
extArgs []string
|
||||
}{
|
||||
{
|
||||
name: "named extension flags without -- separator (#2309 regression)",
|
||||
in: []string{"--type", "6", "--fqdn", "child.htb.local"},
|
||||
timeout: defaultTimeout,
|
||||
extArgs: []string{"--type", "6", "--fqdn", "child.htb.local"},
|
||||
},
|
||||
{
|
||||
name: "named extension flags with -- separator (existing workaround)",
|
||||
in: []string{"--", "--type", "6", "--fqdn", "child.htb.local"},
|
||||
timeout: defaultTimeout,
|
||||
extArgs: []string{"--type", "6", "--fqdn", "child.htb.local"},
|
||||
},
|
||||
{
|
||||
name: "sliver-owned flags precede extension arguments",
|
||||
in: []string{"--timeout", "30", "--save", "--type", "6"},
|
||||
save: true,
|
||||
timeout: 30,
|
||||
extArgs: []string{"--type", "6"},
|
||||
},
|
||||
{
|
||||
name: "quoted value containing spaces stays a single argument",
|
||||
in: []string{"--name", "hello world", "--type", "6"},
|
||||
timeout: defaultTimeout,
|
||||
extArgs: []string{"--name", "hello world", "--type", "6"},
|
||||
},
|
||||
{
|
||||
name: "positional extension arguments are preserved",
|
||||
in: []string{"foo", "bar", "baz"},
|
||||
timeout: defaultTimeout,
|
||||
extArgs: []string{"foo", "bar", "baz"},
|
||||
},
|
||||
{
|
||||
name: "single-dash and double-dash forms are preserved",
|
||||
in: []string{"-type", "6", "--fqdn", "child.htb.local"},
|
||||
timeout: defaultTimeout,
|
||||
extArgs: []string{"-type", "6", "--fqdn", "child.htb.local"},
|
||||
},
|
||||
{
|
||||
name: "equals form for timeout",
|
||||
in: []string{"--timeout=120", "--type", "6"},
|
||||
timeout: 120,
|
||||
extArgs: []string{"--type", "6"},
|
||||
},
|
||||
{
|
||||
name: "short -s save flag",
|
||||
in: []string{"-s", "--type", "6"},
|
||||
save: true,
|
||||
timeout: defaultTimeout,
|
||||
extArgs: []string{"--type", "6"},
|
||||
},
|
||||
{
|
||||
name: "help flag is detected",
|
||||
in: []string{"--help"},
|
||||
help: true,
|
||||
timeout: defaultTimeout,
|
||||
extArgs: []string{},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
in: nil,
|
||||
timeout: defaultTimeout,
|
||||
extArgs: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
save, timeout, help, extArgs := splitExtensionArgs(tc.in)
|
||||
|
||||
if save != tc.save {
|
||||
t.Errorf("save: got %v, want %v", save, tc.save)
|
||||
}
|
||||
if timeout != tc.timeout {
|
||||
t.Errorf("timeout: got %d, want %d", timeout, tc.timeout)
|
||||
}
|
||||
if help != tc.help {
|
||||
t.Errorf("help: got %v, want %v", help, tc.help)
|
||||
}
|
||||
if !reflect.DeepEqual(extArgs, tc.extArgs) {
|
||||
t.Errorf("extArgs: got %#v, want %#v", extArgs, tc.extArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseBoolValue checks the --flag=value helper used for --save=value.
|
||||
func TestParseBoolValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"", true},
|
||||
{"true", true},
|
||||
{"TRUE", true},
|
||||
{"1", true},
|
||||
{"t", true},
|
||||
{"false", false},
|
||||
{"0", false},
|
||||
{"no", false}, // unsupported spellings keep the default
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := parseBoolValue(tc.in, true)
|
||||
// "no" is not a pflag spelling, so it falls through to the default (true).
|
||||
want := tc.want
|
||||
if tc.in == "no" {
|
||||
want = true
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("parseBoolValue(%q): got %v, want %v", tc.in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+180
-101
@@ -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")
|
||||
}
|
||||
@@ -370,8 +397,12 @@ func ExtensionRegisterCommand(extCmd *ExtCommand, cmd *cobra.Command, con *conso
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runExtensionCmd(cmd, con, args)
|
||||
},
|
||||
GroupID: consts.ExtensionHelpGroup,
|
||||
Annotations: makeCommandPlatformFilters(extCmd),
|
||||
GroupID: consts.ExtensionHelpGroup,
|
||||
// DisableFlagParsing keeps the extension's own flags (which are unknown to
|
||||
// pflag) from being silently stripped together with their values. We split
|
||||
// Sliver-owned flags out of the raw slice ourselves in runExtensionCmd (#2309).
|
||||
DisableFlagParsing: true,
|
||||
Annotations: makeCommandPlatformFilters(extCmd),
|
||||
}
|
||||
|
||||
// Flags
|
||||
@@ -379,7 +410,6 @@ func ExtensionRegisterCommand(extCmd *ExtCommand, cmd *cobra.Command, con *conso
|
||||
f.BoolP("save", "s", false, "Save output to disk")
|
||||
f.Int64P("timeout", "t", defaultTimeout, "command timeout in seconds")
|
||||
extensionCmd.Flags().AddFlagSet(f)
|
||||
extensionCmd.Flags().ParseErrorsWhitelist.UnknownFlags = true
|
||||
|
||||
// Completions
|
||||
comps := carapace.Gen(extensionCmd)
|
||||
@@ -468,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)
|
||||
}
|
||||
@@ -528,7 +544,102 @@ 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
|
||||
// re-publish them on the flag set (so Request()'s --timeout still works),
|
||||
// then feed only the extension arguments to the BOF/DLL parsers (#2309).
|
||||
save, timeout, helpRequested, extArgs := splitExtensionArgs(args)
|
||||
if helpRequested {
|
||||
_ = cmd.Help()
|
||||
return
|
||||
}
|
||||
applyOwnedFlags(cmd, save, timeout)
|
||||
args = extArgs
|
||||
|
||||
var (
|
||||
err error
|
||||
extensionArgs []byte
|
||||
@@ -555,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)
|
||||
@@ -583,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, " "))
|
||||
@@ -597,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 {
|
||||
@@ -850,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)
|
||||
|
||||
@@ -159,7 +159,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
f.StringP("save", "s", "", "directory/file to the binary to")
|
||||
f.String("shellcode-encoder", "", "shellcode encoder to apply (optional; see `shellcode-encoders`)")
|
||||
f.StringP("name", "n", "", "Implant name")
|
||||
|
||||
f.BoolP("external-builder", "E", false, "use an external builder")
|
||||
})
|
||||
flags.BindFlagCompletions(profilesGenerateCmd, func(comp *carapace.ActionMap) {
|
||||
(*comp)["save"] = carapace.ActionFiles().Tag("directory/file to save implant")
|
||||
@@ -367,7 +367,7 @@ func coreImplantFlags(name string, cmd *cobra.Command) {
|
||||
f.StringP("limit-locale", "L", "", "limit execution to hosts that match this locale")
|
||||
bindSpoofMetadataFlag(f)
|
||||
|
||||
f.StringP("format", "f", "exe", "Specifies the output formats, valid values are: 'exe', 'shared' (for dynamic libraries), 'service' (see: `psexec` for more info) and 'shellcode' (windows, darwin/arm64, linux/amd64, linux/arm64)")
|
||||
f.StringP("format", "f", "exe", "Specifies the output formats, valid values are: 'exe', 'shared' (for dynamic libraries), 'archive' (for Go c-archives), 'service' (see: `psexec` for more info) and 'shellcode' (windows, darwin/arm64, linux/amd64, linux/arm64)")
|
||||
|
||||
// Shellcode generation options:
|
||||
// - Windows: Donut
|
||||
|
||||
@@ -24,6 +24,30 @@ func TestGenerateSaveFlagCompletionRegistered(t *testing.T) {
|
||||
assertFlagHasDefaultFileCompletion(t, beaconCmd, "save")
|
||||
}
|
||||
|
||||
func TestProfilesGenerateExternalBuilderFlagRegistered(t *testing.T) {
|
||||
cmds := Commands(&console.SliverClient{})
|
||||
|
||||
profilesCmd := commandByUse(cmds, consts.ProfilesStr)
|
||||
if profilesCmd == nil {
|
||||
t.Fatalf("missing %q command", consts.ProfilesStr)
|
||||
}
|
||||
profilesGenerateCmd := commandByUse(profilesCmd.Commands(), consts.GenerateStr)
|
||||
if profilesGenerateCmd == nil {
|
||||
t.Fatalf("missing %q subcommand under %q", consts.GenerateStr, consts.ProfilesStr)
|
||||
}
|
||||
|
||||
flag := profilesGenerateCmd.Flags().Lookup("external-builder")
|
||||
if flag == nil {
|
||||
t.Fatalf("%q missing %q flag", profilesGenerateCmd.Use, "external-builder")
|
||||
}
|
||||
if flag.Shorthand != "E" {
|
||||
t.Fatalf("%q %q flag shorthand mismatch: got=%q want=%q", profilesGenerateCmd.Use, "external-builder", flag.Shorthand, "E")
|
||||
}
|
||||
if flag.DefValue != "false" {
|
||||
t.Fatalf("%q %q flag default mismatch: got=%q want=%q", profilesGenerateCmd.Use, "external-builder", flag.DefValue, "false")
|
||||
}
|
||||
}
|
||||
|
||||
func assertFlagHasDefaultFileCompletion(t *testing.T, cmd *cobra.Command, flag string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -254,6 +255,8 @@ func nameOfOutputFormat(value clientpb.OutputFormat) string {
|
||||
return "Service"
|
||||
case clientpb.OutputFormat_SHARED_LIB:
|
||||
return "Shared Library"
|
||||
case clientpb.OutputFormat_GO_ARCHIVE:
|
||||
return "Go Archive"
|
||||
case clientpb.OutputFormat_SHELLCODE:
|
||||
return "Shellcode"
|
||||
default:
|
||||
@@ -389,6 +392,9 @@ func parseCompileFlags(cmd *cobra.Command, con *console.SliverClient) (string, *
|
||||
configFormat = clientpb.OutputFormat_SHARED_LIB
|
||||
isSharedLib = true
|
||||
runAtLoad, _ = cmd.Flags().GetBool("run-at-load")
|
||||
case "archive":
|
||||
configFormat = clientpb.OutputFormat_GO_ARCHIVE
|
||||
isSharedLib = true
|
||||
case "shellcode":
|
||||
configFormat = clientpb.OutputFormat_SHELLCODE
|
||||
isShellcode = true
|
||||
|
||||
@@ -197,7 +197,7 @@ func OSCompleter(con *console.SliverClient) carapace.Action {
|
||||
func FormatCompleter() carapace.Action {
|
||||
return carapace.ActionCallback(func(_ carapace.Context) carapace.Action {
|
||||
return carapace.ActionValues([]string{
|
||||
"exe", "shared", "service", "shellcode",
|
||||
"exe", "shared", "archive", "service", "shellcode",
|
||||
}...).Tag("implant format")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
)
|
||||
|
||||
func TestNameOfOutputFormatGoArchive(t *testing.T) {
|
||||
if got := nameOfOutputFormat(clientpb.OutputFormat_GO_ARCHIVE); got != "Go Archive" {
|
||||
t.Fatalf("nameOfOutputFormat(GO_ARCHIVE) = %q, want %q", got, "Go Archive")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package generate
|
||||
*/
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -81,9 +82,27 @@ func ProfilesGenerateCmd(cmd *cobra.Command, con *console.SliverClient, args []s
|
||||
profile.Config.SGNEnabled = encoder == clientpb.ShellcodeEncoder_SHIKATA_GA_NAI
|
||||
}
|
||||
}
|
||||
_, err := compile(implantName, profile.Config, nil, save, con)
|
||||
if err != nil {
|
||||
return
|
||||
if external, _ := cmd.Flags().GetBool("external-builder"); !external {
|
||||
_, err := compile(implantName, profile.Config, nil, save, con)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err := externalBuild(implantName, profile.Config, nil, save, con)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case ErrNoExternalBuilder:
|
||||
con.PrintErrorf("There are no external builders currently connected to the server\n")
|
||||
con.PrintErrorf("See 'builders' command for more information\n")
|
||||
case ErrNoValidBuilders:
|
||||
con.PrintErrorf("There are external builders connected to the server, but none can build the target you specified\n")
|
||||
con.PrintErrorf("Invalid target %s\n", fmt.Sprintf("%s:%s/%s", profile.Config.Format, profile.Config.GOOS, profile.Config.GOARCH))
|
||||
con.PrintErrorf("See 'builders' command for more information\n")
|
||||
default:
|
||||
con.PrintErrorf("%s\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
con.PrintErrorf("No profile with name '%s'", name)
|
||||
|
||||
@@ -263,6 +263,8 @@ func populateProfileProperties(config *clientpb.ImplantConfig) map[string]string
|
||||
properties["format"] = "Executable"
|
||||
case clientpb.OutputFormat_SHARED_LIB:
|
||||
properties["format"] = "Shared Library"
|
||||
case clientpb.OutputFormat_GO_ARCHIVE:
|
||||
properties["format"] = "Go Archive"
|
||||
case clientpb.OutputFormat_SERVICE:
|
||||
properties["format"] = "Service"
|
||||
case clientpb.OutputFormat_SHELLCODE:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,260 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || windows
|
||||
|
||||
package shell
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func closeShellInputTestFile(t *testing.T, file *os.File) {
|
||||
t.Helper()
|
||||
if err := file.Close(); err != nil {
|
||||
t.Errorf("close %s: %v", file.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellableShellInputWakesOnTunnelClose(t *testing.T) {
|
||||
readFile, writeFile, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stdin pipe: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { closeShellInputTestFile(t, readFile) })
|
||||
t.Cleanup(func() { closeShellInputTestFile(t, writeFile) })
|
||||
|
||||
input, err := newCancellableShellInput(readFile, newFilterReader(readFile))
|
||||
if err != nil {
|
||||
t.Fatalf("prepare cancellable stdin: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := input.Close(); err != nil {
|
||||
t.Errorf("close cancellable stdin: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
type readResult struct {
|
||||
n int
|
||||
err error
|
||||
tunnelClosed bool
|
||||
}
|
||||
readDone := make(chan readResult, 1)
|
||||
go func() {
|
||||
n, tunnelClosed, err := input.Read(make([]byte, 16), done)
|
||||
readDone <- readResult{n: n, err: err, tunnelClosed: tunnelClosed}
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-readDone:
|
||||
t.Fatalf("stdin Read returned before data or tunnel close: %+v", result)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(done)
|
||||
select {
|
||||
case result := <-readDone:
|
||||
if result.n != 0 || result.err != nil || !result.tunnelClosed {
|
||||
t.Fatalf("stdin Read after tunnel close = %+v, want n=0 err=nil tunnelClosed=true", result)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("stdin Read did not wake when the tunnel closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellableShellInputReportsPipeEOF(t *testing.T) {
|
||||
readFile, writeFile, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stdin pipe: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { closeShellInputTestFile(t, readFile) })
|
||||
if err := writeFile.Close(); err != nil {
|
||||
t.Fatalf("close stdin pipe writer: %v", err)
|
||||
}
|
||||
|
||||
input, err := newCancellableShellInput(readFile, newFilterReader(readFile))
|
||||
if err != nil {
|
||||
t.Fatalf("prepare cancellable stdin: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := input.Close(); err != nil {
|
||||
t.Errorf("close cancellable stdin: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
n, tunnelClosed, err := input.Read(make([]byte, 16), make(chan struct{}))
|
||||
if n != 0 || tunnelClosed || !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("stdin Read after pipe EOF = n=%d tunnelClosed=%v err=%v, want n=0 tunnelClosed=false err=EOF", n, tunnelClosed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellableShellInputReadsRegularFile(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "shell-stdin-*")
|
||||
if err != nil {
|
||||
t.Fatalf("create stdin file: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { closeShellInputTestFile(t, file) })
|
||||
|
||||
const inputData = "whoami\n"
|
||||
if _, err := file.WriteString(inputData); err != nil {
|
||||
t.Fatalf("write stdin file: %v", err)
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("rewind stdin file: %v", err)
|
||||
}
|
||||
|
||||
input, err := newCancellableShellInput(file, newFilterReader(file))
|
||||
if err != nil {
|
||||
t.Fatalf("prepare cancellable stdin: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := input.Close(); err != nil {
|
||||
t.Errorf("close cancellable stdin: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
data := make([]byte, len(inputData))
|
||||
n, tunnelClosed, err := input.Read(data, make(chan struct{}))
|
||||
if err != nil {
|
||||
t.Fatalf("read stdin file: %v", err)
|
||||
}
|
||||
if tunnelClosed {
|
||||
t.Fatal("stdin file read reported a closed tunnel")
|
||||
}
|
||||
if got := string(data[:n]); got != inputData {
|
||||
t.Fatalf("stdin file data = %q, want %q", got, inputData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||
|
||||
package shell
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const shellInputPollMilliseconds = 100
|
||||
|
||||
type cancellableShellInput struct {
|
||||
reader io.Reader
|
||||
fd uintptr
|
||||
originalFlags int
|
||||
flagsChanged bool
|
||||
restoreOnce sync.Once
|
||||
restoreErr error
|
||||
}
|
||||
|
||||
func newCancellableShellInput(file *os.File, reader io.Reader) (*cancellableShellInput, error) {
|
||||
flags, err := unix.FcntlInt(file.Fd(), unix.F_GETFL, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input := &cancellableShellInput{
|
||||
reader: reader,
|
||||
fd: file.Fd(),
|
||||
originalFlags: flags,
|
||||
}
|
||||
if flags&unix.O_NONBLOCK == 0 {
|
||||
if _, err := unix.FcntlInt(file.Fd(), unix.F_SETFL, flags|unix.O_NONBLOCK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input.flagsChanged = true
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (s *cancellableShellInput) Read(data []byte, done <-chan struct{}) (int, bool, error) {
|
||||
pollFDs := []unix.PollFd{{Fd: int32(s.fd), Events: unix.POLLIN}}
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return 0, true, nil
|
||||
default:
|
||||
}
|
||||
|
||||
ready, err := unix.Poll(pollFDs, shellInputPollMilliseconds)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if ready == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := s.reader.Read(data)
|
||||
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
|
||||
continue
|
||||
}
|
||||
return n, false, err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cancellableShellInput) Close() error {
|
||||
s.restoreOnce.Do(func() {
|
||||
if s.flagsChanged {
|
||||
_, s.restoreErr = unix.FcntlInt(s.fd, unix.F_SETFL, s.originalFlags)
|
||||
}
|
||||
})
|
||||
return s.restoreErr
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestCancellableShellInputRestoresFileFlags(t *testing.T) {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create input pipe: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { closeShellInputTestFile(t, reader) })
|
||||
t.Cleanup(func() { closeShellInputTestFile(t, writer) })
|
||||
|
||||
originalFlags, err := unix.FcntlInt(reader.Fd(), unix.F_GETFL, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("read original input flags: %v", err)
|
||||
}
|
||||
input, err := newCancellableShellInput(reader, reader)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare cancellable input: %v", err)
|
||||
}
|
||||
|
||||
type readResult struct {
|
||||
err error
|
||||
closed bool
|
||||
}
|
||||
done := make(chan struct{})
|
||||
result := make(chan readResult, 1)
|
||||
go func() {
|
||||
_, closed, err := input.Read(make([]byte, 32), done)
|
||||
result <- readResult{err: err, closed: closed}
|
||||
}()
|
||||
|
||||
select {
|
||||
case got := <-result:
|
||||
t.Fatalf("input read returned before data or close: %+v", got)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
close(done)
|
||||
select {
|
||||
case got := <-result:
|
||||
if got.err != nil || !got.closed {
|
||||
t.Fatalf("input read after close = %+v, want closed without error", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("input read did not wake after tunnel close")
|
||||
}
|
||||
|
||||
if err := input.Close(); err != nil {
|
||||
t.Fatalf("restore input flags: %v", err)
|
||||
}
|
||||
restoredFlags, err := unix.FcntlInt(reader.Fd(), unix.F_GETFL, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored input flags: %v", err)
|
||||
}
|
||||
if restoredFlags != originalFlags {
|
||||
t.Fatalf("restored input flags = %#x, want %#x", restoredFlags, originalFlags)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//go:build windows
|
||||
|
||||
package shell
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const shellInputWaitMilliseconds = 100
|
||||
|
||||
var (
|
||||
cancelSynchronousIO = windows.NewLazySystemDLL("kernel32.dll").NewProc("CancelSynchronousIo")
|
||||
peekNamedPipe = windows.NewLazySystemDLL("kernel32.dll").NewProc("PeekNamedPipe")
|
||||
)
|
||||
|
||||
type cancellableShellInput struct {
|
||||
reader io.Reader
|
||||
handle windows.Handle
|
||||
isConsole bool
|
||||
isPipe bool
|
||||
}
|
||||
|
||||
func newCancellableShellInput(file *os.File, reader io.Reader) (*cancellableShellInput, error) {
|
||||
handle := windows.Handle(file.Fd())
|
||||
fileType, err := windows.GetFileType(handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var consoleMode uint32
|
||||
return &cancellableShellInput{
|
||||
reader: reader,
|
||||
handle: handle,
|
||||
isConsole: windows.GetConsoleMode(handle, &consoleMode) == nil,
|
||||
isPipe: fileType == windows.FILE_TYPE_PIPE,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *cancellableShellInput) Read(data []byte, done <-chan struct{}) (int, bool, error) {
|
||||
select {
|
||||
case <-done:
|
||||
return 0, true, nil
|
||||
default:
|
||||
}
|
||||
|
||||
if s.isPipe {
|
||||
return s.readPipe(data, done)
|
||||
}
|
||||
if !s.isConsole {
|
||||
n, err := s.reader.Read(data)
|
||||
return n, false, err
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return 0, true, nil
|
||||
default:
|
||||
}
|
||||
|
||||
event, err := windows.WaitForSingleObject(s.handle, shellInputWaitMilliseconds)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
switch event {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
select {
|
||||
case <-done:
|
||||
return 0, true, nil
|
||||
default:
|
||||
}
|
||||
return s.readConsole(data, done)
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
continue
|
||||
default:
|
||||
return 0, false, fmt.Errorf("unexpected stdin wait result 0x%x", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForSingleObject does not reliably indicate that a pipe read will
|
||||
// complete. PeekNamedPipe lets redirected stdin remain cancellable without
|
||||
// starting an overlapped os.File.Read that CancelSynchronousIo cannot stop.
|
||||
func (s *cancellableShellInput) readPipe(data []byte, done <-chan struct{}) (int, bool, error) {
|
||||
ticker := time.NewTicker(shellInputWaitMilliseconds * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
available, err := pipeBytesAvailable(s.handle)
|
||||
if errors.Is(err, windows.ERROR_BROKEN_PIPE) ||
|
||||
errors.Is(err, windows.ERROR_NO_DATA) ||
|
||||
errors.Is(err, windows.ERROR_PIPE_NOT_CONNECTED) {
|
||||
return 0, false, io.EOF
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if available > 0 {
|
||||
n, readErr := s.reader.Read(data)
|
||||
return n, false, readErr
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return 0, true, nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pipeBytesAvailable(handle windows.Handle) (uint32, error) {
|
||||
var available uint32
|
||||
result, _, err := peekNamedPipe.Call(
|
||||
uintptr(handle),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&available)),
|
||||
0,
|
||||
)
|
||||
if result == 0 {
|
||||
return 0, err
|
||||
}
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// A console input handle can be signaled by window, focus, mouse, or key-up
|
||||
// events that do not make ReadFile return. Keep the synchronous read on a
|
||||
// known OS thread so tunnel shutdown can cancel that exact read without
|
||||
// leaving a background stdin reader behind to steal input from the REPL.
|
||||
func (s *cancellableShellInput) readConsole(data []byte, done <-chan struct{}) (int, bool, error) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
thread, err := windows.OpenThread(windows.THREAD_TERMINATE, false, windows.GetCurrentThreadId())
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
defer func() {
|
||||
_ = windows.CloseHandle(thread)
|
||||
}()
|
||||
|
||||
readFinished := make(chan struct{})
|
||||
cancelFinished := make(chan error, 1)
|
||||
go func() {
|
||||
select {
|
||||
case <-done:
|
||||
cancelFinished <- cancelThreadSynchronousIO(thread, readFinished)
|
||||
case <-readFinished:
|
||||
cancelFinished <- nil
|
||||
}
|
||||
}()
|
||||
|
||||
n, readErr := s.reader.Read(data)
|
||||
close(readFinished)
|
||||
cancelErr := <-cancelFinished
|
||||
if cancelErr != nil {
|
||||
return n, false, cancelErr
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
if n == 0 && (readErr == nil || errors.Is(readErr, windows.ERROR_OPERATION_ABORTED)) {
|
||||
return 0, true, nil
|
||||
}
|
||||
default:
|
||||
}
|
||||
return n, false, readErr
|
||||
}
|
||||
|
||||
func cancelThreadSynchronousIO(thread windows.Handle, readFinished <-chan struct{}) error {
|
||||
ticker := time.NewTicker(time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
result, _, err := cancelSynchronousIO.Call(uintptr(thread))
|
||||
if result != 0 {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_NOT_FOUND) {
|
||||
return err
|
||||
}
|
||||
|
||||
// The close signal can race just ahead of the synchronous read. Retry
|
||||
// ERROR_NOT_FOUND until the read starts or finishes so no console reader
|
||||
// is left behind to steal input from the REPL.
|
||||
select {
|
||||
case <-readFinished:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cancellableShellInput) Close() error {
|
||||
return nil
|
||||
}
|
||||
+199
-95
@@ -8,59 +8,43 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/core"
|
||||
)
|
||||
|
||||
const (
|
||||
shellEscapeByte = byte(0x1d) // Ctrl-]
|
||||
stdinBufSize = 32 * 1024
|
||||
stdinQueueDepth = 128
|
||||
finalInputDeliveryTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type shellInputChunk struct {
|
||||
data []byte
|
||||
delivered chan bool
|
||||
}
|
||||
|
||||
type shellLineState struct {
|
||||
lineBuf []byte
|
||||
inEscSeq bool
|
||||
caseInsensitive bool
|
||||
}
|
||||
|
||||
// runAttachedIO forwards local stdin to a shell tunnel while attached to the shell.
|
||||
// It returns:
|
||||
// - detached=true when user hit escape (Ctrl-])
|
||||
// - closeRequested=true when user requested shell close (EOF/exit/logout)
|
||||
func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient) (detached bool, closeRequested bool) {
|
||||
func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient, remoteOS string) (detached bool, closeRequested bool) {
|
||||
// We can't pipe stdin directly into the tunnel: if the gRPC tunnel send path blocks
|
||||
// (network hang, server gone, etc.) tunnel.Write can block forever and "lock" the
|
||||
// client in shell mode. Instead, buffer stdin locally and look for a local escape key.
|
||||
const (
|
||||
shellEscapeByte = byte(0x1d) // Ctrl-]
|
||||
stdinBufSize = 32 * 1024
|
||||
stdinQueueDepth = 128
|
||||
)
|
||||
|
||||
stdinQueue := make(chan []byte, stdinQueueDepth)
|
||||
stdinQueue := make(chan shellInputChunk, stdinQueueDepth)
|
||||
stopWriter := make(chan struct{})
|
||||
var writerWG sync.WaitGroup
|
||||
writerWG.Add(1)
|
||||
|
||||
go func() {
|
||||
defer writerWG.Done()
|
||||
defer func() {
|
||||
// Tunnel teardown can race with writes (server-side close, disconnect, etc.).
|
||||
// Don't let a send-on-closed-channel panic bring down the whole client.
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Shell tunnel writer stopped: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopWriter:
|
||||
return
|
||||
case data, ok := <-stdinQueue:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-stopWriter:
|
||||
return
|
||||
case tunnel.Send <- data:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
go runShellInputWriter(tunnel, stdinQueue, stopWriter, &writerWG)
|
||||
|
||||
defer func() {
|
||||
close(stopWriter)
|
||||
@@ -68,68 +52,33 @@ func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient) (detached b
|
||||
writerWG.Wait()
|
||||
}()
|
||||
|
||||
stdin := newFilterReader(os.Stdin)
|
||||
stdin, err := newCancellableShellInput(os.Stdin, newFilterReader(os.Stdin))
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to prepare shell input: %s\n", err)
|
||||
return false, true
|
||||
}
|
||||
defer func() {
|
||||
if err := stdin.Close(); err != nil {
|
||||
log.Printf("Failed to restore shell input state: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
buf := make([]byte, stdinBufSize)
|
||||
lineBuf := make([]byte, 0, 128)
|
||||
inEscSeq := false
|
||||
lineState := shellLineState{
|
||||
lineBuf: make([]byte, 0, 128),
|
||||
caseInsensitive: remoteOS == "windows",
|
||||
}
|
||||
log.Printf("Reading from stdin (escape=Ctrl-]) ...")
|
||||
|
||||
for {
|
||||
n, err := stdin.Read(buf)
|
||||
n, tunnelClosed, err := stdin.Read(buf, tunnel.Done())
|
||||
if tunnelClosed {
|
||||
closeRequested = true
|
||||
break
|
||||
}
|
||||
if n > 0 {
|
||||
data := buf[:n]
|
||||
if i := bytes.IndexByte(data, shellEscapeByte); i >= 0 {
|
||||
if i > 0 {
|
||||
// Best-effort: don't block stdin if the tunnel is wedged.
|
||||
dataCopy := append([]byte(nil), data[:i]...)
|
||||
select {
|
||||
case stdinQueue <- dataCopy:
|
||||
default:
|
||||
}
|
||||
}
|
||||
detached = true
|
||||
break
|
||||
}
|
||||
|
||||
exitRequested := false
|
||||
for _, b := range data {
|
||||
if inEscSeq {
|
||||
if b >= 0x40 && b <= 0x7e {
|
||||
inEscSeq = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch b {
|
||||
case '\r', '\n':
|
||||
line := strings.TrimSpace(string(lineBuf))
|
||||
if line == "exit" || line == "logout" {
|
||||
exitRequested = true
|
||||
}
|
||||
lineBuf = lineBuf[:0]
|
||||
case 0x1b:
|
||||
inEscSeq = true
|
||||
case 0x08, 0x7f:
|
||||
if len(lineBuf) > 0 {
|
||||
lineBuf = lineBuf[:len(lineBuf)-1]
|
||||
}
|
||||
default:
|
||||
// Keep printable bytes only; ignore control sequences (arrows, etc.).
|
||||
if b >= 0x20 && b <= 0x7e {
|
||||
lineBuf = append(lineBuf, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dataCopy := append([]byte(nil), data...)
|
||||
select {
|
||||
case stdinQueue <- dataCopy:
|
||||
default:
|
||||
// Drop input if the tunnel send path is blocked; still allow escape.
|
||||
}
|
||||
|
||||
if exitRequested {
|
||||
closeRequested = true
|
||||
detached, closeRequested = forwardShellInput(buf[:n], &lineState, stdinQueue, tunnel.Done())
|
||||
if detached || closeRequested {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -146,3 +95,158 @@ func runAttachedIO(tunnel *core.TunnelIO, con *console.SliverClient) (detached b
|
||||
bufio.NewWriter(os.Stdout).Flush()
|
||||
return detached, closeRequested
|
||||
}
|
||||
|
||||
func runShellInputWriter(tunnel *core.TunnelIO, stdinQueue <-chan shellInputChunk, stop <-chan struct{}, writerWG *sync.WaitGroup) {
|
||||
defer writerWG.Done()
|
||||
defer recoverShellInputWriter()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-tunnel.Done():
|
||||
return
|
||||
case chunk, ok := <-stdinQueue:
|
||||
if !ok || !deliverShellInputChunk(tunnel, chunk, stop) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recoverShellInputWriter() {
|
||||
// Tunnel teardown can race with writes (server-side close, disconnect, etc.).
|
||||
// Don't let a send-on-closed-channel panic bring down the whole client.
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Shell tunnel writer stopped: %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func deliverShellInputChunk(tunnel *core.TunnelIO, chunk shellInputChunk, stop <-chan struct{}) bool {
|
||||
if len(chunk.data) == 0 {
|
||||
notifyShellInputDelivery(chunk.delivered, true)
|
||||
return true
|
||||
}
|
||||
|
||||
delivered := false
|
||||
select {
|
||||
case <-stop:
|
||||
case <-tunnel.Done():
|
||||
case tunnel.Send <- chunk.data:
|
||||
delivered = true
|
||||
}
|
||||
notifyShellInputDelivery(chunk.delivered, delivered)
|
||||
return delivered
|
||||
}
|
||||
|
||||
func notifyShellInputDelivery(delivery chan<- bool, delivered bool) {
|
||||
if delivery != nil {
|
||||
delivery <- delivered
|
||||
}
|
||||
}
|
||||
|
||||
func forwardShellInput(data []byte, lineState *shellLineState, stdinQueue chan<- shellInputChunk, tunnelDone <-chan struct{}) (detached bool, closeRequested bool) {
|
||||
if i := bytes.IndexByte(data, shellEscapeByte); i >= 0 {
|
||||
if i > 0 {
|
||||
queueShellInputBestEffort(stdinQueue, data[:i])
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
|
||||
exitRequested := lineState.exitRequested(data)
|
||||
if exitRequested {
|
||||
deliverFinalShellInput(stdinQueue, data, tunnelDone)
|
||||
return false, true
|
||||
}
|
||||
|
||||
queueShellInputBestEffort(stdinQueue, data)
|
||||
return false, false
|
||||
}
|
||||
|
||||
func queueShellInputBestEffort(stdinQueue chan<- shellInputChunk, data []byte) {
|
||||
dataCopy := append([]byte(nil), data...)
|
||||
select {
|
||||
case stdinQueue <- shellInputChunk{data: dataCopy}:
|
||||
default:
|
||||
// Drop input if the tunnel send path is blocked; still allow escape.
|
||||
}
|
||||
}
|
||||
|
||||
func deliverFinalShellInput(stdinQueue chan<- shellInputChunk, data []byte, tunnelDone <-chan struct{}) {
|
||||
delivered := make(chan bool, 1)
|
||||
chunk := shellInputChunk{
|
||||
data: append([]byte(nil), data...),
|
||||
delivered: delivered,
|
||||
}
|
||||
|
||||
queueTimer := time.NewTimer(finalInputDeliveryTimeout)
|
||||
queued := false
|
||||
select {
|
||||
case stdinQueue <- chunk:
|
||||
queued = true
|
||||
case <-tunnelDone:
|
||||
case <-queueTimer.C:
|
||||
}
|
||||
queueTimer.Stop()
|
||||
if !queued {
|
||||
return
|
||||
}
|
||||
|
||||
deliveryTimer := time.NewTimer(finalInputDeliveryTimeout)
|
||||
select {
|
||||
case <-delivered:
|
||||
case <-tunnelDone:
|
||||
case <-deliveryTimer.C:
|
||||
}
|
||||
deliveryTimer.Stop()
|
||||
}
|
||||
|
||||
func (s *shellLineState) exitRequested(data []byte) bool {
|
||||
exitRequested := false
|
||||
for _, b := range data {
|
||||
if s.inEscSeq {
|
||||
if b >= 0x40 && b <= 0x7e {
|
||||
s.inEscSeq = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch b {
|
||||
case '\r', '\n':
|
||||
if isShellExitCommand(string(s.lineBuf), s.caseInsensitive) {
|
||||
exitRequested = true
|
||||
}
|
||||
s.lineBuf = s.lineBuf[:0]
|
||||
case 0x1b:
|
||||
s.inEscSeq = true
|
||||
case 0x08, 0x7f:
|
||||
if len(s.lineBuf) > 0 {
|
||||
s.lineBuf = s.lineBuf[:len(s.lineBuf)-1]
|
||||
}
|
||||
default:
|
||||
// Keep printable bytes only; ignore control sequences (arrows, etc.).
|
||||
if b >= 0x20 && b <= 0x7e {
|
||||
s.lineBuf = append(s.lineBuf, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
return exitRequested
|
||||
}
|
||||
|
||||
func isShellExitCommand(line string, caseInsensitive bool) bool {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
command := fields[0]
|
||||
if caseInsensitive {
|
||||
command = strings.ToLower(command)
|
||||
}
|
||||
switch command {
|
||||
case "exit", "logout":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/core"
|
||||
)
|
||||
|
||||
func TestIsShellExitCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
windows bool
|
||||
want bool
|
||||
}{
|
||||
{name: "exit", line: "exit", want: true},
|
||||
{name: "unix uppercase is not exit", line: " EXIT ", want: false},
|
||||
{name: "windows case insensitive exit", line: " EXIT ", windows: true, want: true},
|
||||
{name: "exit status", line: "exit 42", want: true},
|
||||
{name: "cmd exit arguments", line: "Exit /B 1", windows: true, want: true},
|
||||
{name: "logout", line: "logout", want: true},
|
||||
{name: "windows logout arguments", line: "LoGoUt --force", windows: true, want: true},
|
||||
{name: "empty", line: " ", want: false},
|
||||
{name: "exit prefix", line: "exiting", want: false},
|
||||
{name: "logout prefix", line: "logout-now", want: false},
|
||||
{name: "exit later in line", line: "echo exit", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isShellExitCommand(tt.line, tt.windows); got != tt.want {
|
||||
t.Fatalf("isShellExitCommand(%q, %v) = %v, want %v", tt.line, tt.windows, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardShellInputDetachesAtEscape(t *testing.T) {
|
||||
stdinQueue := make(chan shellInputChunk, 1)
|
||||
tunnelDone := make(chan struct{})
|
||||
lineState := &shellLineState{}
|
||||
|
||||
detached, closeRequested := forwardShellInput(
|
||||
[]byte("whoami\x1dignored"),
|
||||
lineState,
|
||||
stdinQueue,
|
||||
tunnelDone,
|
||||
)
|
||||
if !detached || closeRequested {
|
||||
t.Fatalf("forwardShellInput result = detached %v, close requested %v", detached, closeRequested)
|
||||
}
|
||||
|
||||
select {
|
||||
case chunk := <-stdinQueue:
|
||||
if !bytes.Equal(chunk.data, []byte("whoami")) {
|
||||
t.Fatalf("queued input = %q, want only data before escape", chunk.data)
|
||||
}
|
||||
default:
|
||||
t.Fatal("input before escape was not queued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellLineStatePersistsAcrossChunksAndBackspace(t *testing.T) {
|
||||
lineState := &shellLineState{}
|
||||
if lineState.exitRequested([]byte("exiz")) {
|
||||
t.Fatal("partial input unexpectedly requested exit")
|
||||
}
|
||||
if lineState.exitRequested([]byte{'\b'}) {
|
||||
t.Fatal("backspace unexpectedly requested exit")
|
||||
}
|
||||
if !lineState.exitRequested([]byte("t\n")) {
|
||||
t.Fatal("corrected exit command split across chunks did not request exit")
|
||||
}
|
||||
if lineState.exitRequested([]byte("echo still attached\n")) {
|
||||
t.Fatal("line state was not reset after the exit command newline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAttachedIODeliversExitBeforeReturning(t *testing.T) {
|
||||
readFile, writeFile, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create stdin pipe: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
closeTestResource(t, "stdin reader", readFile.Close)
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
closeTestResource(t, "stdin writer", writeFile.Close)
|
||||
})
|
||||
|
||||
originalStdin := os.Stdin
|
||||
os.Stdin = readFile
|
||||
defer func() { os.Stdin = originalStdin }()
|
||||
|
||||
tunnel := core.NewTunnelIO(91, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
closeTestResource(t, "tunnel", tunnel.Close)
|
||||
})
|
||||
|
||||
con := &console.SliverClient{}
|
||||
received := make(chan []byte, 1)
|
||||
go func() {
|
||||
select {
|
||||
case data := <-tunnel.Send:
|
||||
received <- append([]byte(nil), data...)
|
||||
case <-tunnel.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
type ioResult struct {
|
||||
detached bool
|
||||
closed bool
|
||||
}
|
||||
result := make(chan ioResult, 1)
|
||||
go func() {
|
||||
detached, closed := runAttachedIO(tunnel, con, "linux")
|
||||
result <- ioResult{detached: detached, closed: closed}
|
||||
}()
|
||||
if _, err := writeFile.Write([]byte("exit\n")); err != nil {
|
||||
t.Fatalf("write test stdin: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-received:
|
||||
if !bytes.Equal(data, []byte("exit\n")) {
|
||||
t.Fatalf("delivered input = %q, want exit command", data)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("exit command was not delivered before attached I/O returned")
|
||||
}
|
||||
select {
|
||||
case got := <-result:
|
||||
if got.detached || !got.closed {
|
||||
t.Fatalf("runAttachedIO result = %+v, want close requested", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("runAttachedIO did not return after delivering exit")
|
||||
}
|
||||
}
|
||||
|
||||
func closeTestResource(t *testing.T, name string, closeFn func() error) {
|
||||
t.Helper()
|
||||
if err := closeFn(); err != nil {
|
||||
t.Errorf("close test %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
darwin = "darwin"
|
||||
linux = "linux"
|
||||
darwin = "darwin"
|
||||
linux = "linux"
|
||||
shellBindTimeout = 5 * time.Second
|
||||
shellCloseTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type shellResult int
|
||||
@@ -262,12 +264,12 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
|
||||
|
||||
if existing == nil {
|
||||
// Create an RPC tunnel, then start it before binding the shell to the newly created tunnel.
|
||||
ctxTunnel, cancelTunnel := context.WithCancel(context.Background())
|
||||
defer cancelTunnel()
|
||||
ctxTunnel, cancelTunnel := con.GrpcContext(cmd)
|
||||
|
||||
rpcTunnel, err := con.Rpc.CreateTunnel(ctxTunnel, &sliverpb.Tunnel{
|
||||
SessionID: session.ID,
|
||||
})
|
||||
cancelTunnel()
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return shellAttachFailed
|
||||
@@ -276,6 +278,20 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
|
||||
|
||||
// Start() takes an RPC tunnel and creates a local Reader/Writer tunnel object.
|
||||
tunnel = core.GetTunnels().Start(rpcTunnel.TunnelID, rpcTunnel.SessionID)
|
||||
bindTimer := time.NewTimer(shellBindTimeout)
|
||||
select {
|
||||
case <-tunnel.Bound():
|
||||
bindTimer.Stop()
|
||||
case <-tunnel.Done():
|
||||
bindTimer.Stop()
|
||||
con.PrintErrorf("Shell tunnel closed before it was bound\n")
|
||||
go backgroundCloseShell(con, tunnel.ID, session.ID)
|
||||
return shellAttachFailed
|
||||
case <-bindTimer.C:
|
||||
con.PrintErrorf("Timed out binding shell tunnel\n")
|
||||
go backgroundCloseShell(con, tunnel.ID, session.ID)
|
||||
return shellAttachFailed
|
||||
}
|
||||
|
||||
var rows uint32
|
||||
var cols uint32
|
||||
@@ -290,7 +306,8 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
|
||||
}
|
||||
}
|
||||
|
||||
shell, err := con.Rpc.Shell(context.Background(), &sliverpb.ShellReq{
|
||||
ctxShell, cancelShell := con.GrpcContext(cmd)
|
||||
shell, err := con.Rpc.Shell(ctxShell, &sliverpb.ShellReq{
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
Path: shellPath,
|
||||
EnablePTY: !noPty,
|
||||
@@ -298,9 +315,10 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
|
||||
Cols: cols,
|
||||
TunnelID: tunnel.ID,
|
||||
})
|
||||
cancelShell()
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
core.GetTunnels().Close(tunnel.ID)
|
||||
go backgroundCloseShell(con, tunnel.ID, session.ID)
|
||||
return shellAttachFailed
|
||||
}
|
||||
if shell.Response != nil && shell.Response.Err != "" {
|
||||
@@ -363,7 +381,7 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
|
||||
}()
|
||||
}
|
||||
|
||||
detached, _ := runAttachedIO(tunnel, con)
|
||||
detached, _ := runAttachedIO(tunnel, con, session.OS)
|
||||
if detached {
|
||||
managed.SetOutput(io.Discard)
|
||||
managed.setState(shellStateDetached)
|
||||
@@ -393,7 +411,9 @@ func closeShellTunnel(con *console.SliverClient, tunnelID uint64, sessionID stri
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := con.Rpc.CloseTunnel(context.Background(), &sliverpb.Tunnel{
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shellCloseTimeout)
|
||||
defer cancel()
|
||||
_, err := con.Rpc.CloseTunnel(ctx, &sliverpb.Tunnel{
|
||||
TunnelID: tunnelID,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
@@ -413,6 +433,7 @@ func requestRemoteShellExit(tunnel *core.TunnelIO) {
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-tunnel.Done():
|
||||
case tunnel.Send <- append([]byte(nil), data...):
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type closeTunnelTestRPC struct {
|
||||
rpcpb.SliverRPCClient
|
||||
deadline time.Time
|
||||
}
|
||||
|
||||
func (r *closeTunnelTestRPC) CloseTunnel(ctx context.Context, _ *sliverpb.Tunnel, _ ...grpc.CallOption) (*commonpb.Empty, error) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if ok {
|
||||
r.deadline = deadline
|
||||
}
|
||||
return &commonpb.Empty{}, nil
|
||||
}
|
||||
|
||||
func TestCloseShellTunnelUsesBoundedContext(t *testing.T) {
|
||||
rpc := &closeTunnelTestRPC{}
|
||||
con := &console.SliverClient{Rpc: rpc}
|
||||
|
||||
if err := closeShellTunnel(con, 99, "session"); err != nil {
|
||||
t.Fatalf("close shell tunnel: %v", err)
|
||||
}
|
||||
if rpc.deadline.IsZero() {
|
||||
t.Fatal("CloseTunnel RPC context has no deadline")
|
||||
}
|
||||
if remaining := time.Until(rpc.deadline); remaining <= 0 || remaining > shellCloseTimeout {
|
||||
t.Fatalf("CloseTunnel deadline is %v away, want within (0, %v]", remaining, shellCloseTimeout)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"github.com/bishopfox/sliver/client/command/info"
|
||||
"github.com/bishopfox/sliver/client/command/kill"
|
||||
"github.com/bishopfox/sliver/client/command/network"
|
||||
"github.com/bishopfox/sliver/client/command/opfor"
|
||||
"github.com/bishopfox/sliver/client/command/pivots"
|
||||
"github.com/bishopfox/sliver/client/command/portfwd"
|
||||
"github.com/bishopfox/sliver/client/command/privilege"
|
||||
@@ -81,6 +82,7 @@ func SliverCommands(con *client.SliverClient) console.Commands {
|
||||
// [ Core ]
|
||||
bind(consts.SliverCoreHelpGroup,
|
||||
ai.Commands,
|
||||
opfor.Commands,
|
||||
docscmd.Commands,
|
||||
reconfig.Commands,
|
||||
// sessions.Commands,
|
||||
@@ -173,6 +175,9 @@ func SliverCommands(con *client.SliverClient) console.Commands {
|
||||
}
|
||||
}
|
||||
|
||||
// Rehydrate CNA-defined Beacon aliases from the persistent OPFOR runtime.
|
||||
opfor.RegisterCommands(sliver, con)
|
||||
|
||||
// [ Post-command declaration setup ]----------------------------------------
|
||||
|
||||
// Everything below this line should preferably not be any command binding
|
||||
|
||||
@@ -61,7 +61,7 @@ func (con *SliverClient) SetConnection(rpc rpcpb.SliverRPCClient, grpcConn *grpc
|
||||
con.BeaconTaskCallbacksMutex.Unlock()
|
||||
con.ActiveTarget.Set(nil, nil)
|
||||
|
||||
if details != nil && details.Config != nil && details.Config.WG != nil {
|
||||
if useDedicatedCommandConnection(details) {
|
||||
con.backgroundDedicated = true
|
||||
commandRPC, commandConn, err := transport.MTLSConnect(details.Config)
|
||||
if err != nil {
|
||||
@@ -110,6 +110,12 @@ func (con *SliverClient) SetConnection(rpc rpcpb.SliverRPCClient, grpcConn *grpc
|
||||
return nil
|
||||
}
|
||||
|
||||
func useDedicatedCommandConnection(details *ConnectionDetails) bool {
|
||||
return details != nil &&
|
||||
details.Config != nil &&
|
||||
transport.MultiplayerConnectUsesWireGuard(details.Config)
|
||||
}
|
||||
|
||||
// CloseConnection stops background loops and closes the current gRPC connection.
|
||||
func (con *SliverClient) CloseConnection() error {
|
||||
con.connMu.Lock()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/transport"
|
||||
)
|
||||
|
||||
func TestDedicatedCommandConnectionUsesEffectiveWireGuardMode(t *testing.T) {
|
||||
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
|
||||
t.Cleanup(func() {
|
||||
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
|
||||
})
|
||||
|
||||
legacyDetails := &ConnectionDetails{Config: completeWireGuardClientConfig(false)}
|
||||
if useDedicatedCommandConnection(legacyDetails) {
|
||||
t.Fatal("expected auto mode to keep an unmarked legacy config on direct mTLS")
|
||||
}
|
||||
|
||||
enabledDetails := &ConnectionDetails{Config: completeWireGuardClientConfig(true)}
|
||||
if !useDedicatedCommandConnection(enabledDetails) {
|
||||
t.Fatal("expected auto mode to use a dedicated connection for an explicitly enabled config")
|
||||
}
|
||||
|
||||
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectEnableWG)
|
||||
if !useDedicatedCommandConnection(legacyDetails) {
|
||||
t.Fatal("expected force-enable mode to use a dedicated connection with a complete legacy config")
|
||||
}
|
||||
|
||||
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectDisableWG)
|
||||
if useDedicatedCommandConnection(enabledDetails) {
|
||||
t.Fatal("expected force-disable mode to keep an enabled config on direct mTLS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedicatedCommandConnectionRejectsIncompleteEnabledConfig(t *testing.T) {
|
||||
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
|
||||
t.Cleanup(func() {
|
||||
transport.SetMultiplayerConnectMode(transport.MultiplayerConnectAuto)
|
||||
})
|
||||
|
||||
details := &ConnectionDetails{
|
||||
Config: &assets.ClientConfig{
|
||||
WG: &assets.ClientWGConfig{Enabled: true},
|
||||
},
|
||||
}
|
||||
if useDedicatedCommandConnection(details) {
|
||||
t.Fatal("expected an incomplete enabled config not to select a dedicated connection")
|
||||
}
|
||||
}
|
||||
|
||||
func completeWireGuardClientConfig(enabled bool) *assets.ClientConfig {
|
||||
return &assets.ClientConfig{
|
||||
WG: &assets.ClientWGConfig{
|
||||
Enabled: enabled,
|
||||
ServerPubKey: "server-pub",
|
||||
ClientPrivateKey: "client-priv",
|
||||
ClientIP: "100.64.0.2",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1147,6 +1147,7 @@ func (con *SliverClient) ExposeCommands() {
|
||||
case con.ActiveTarget.session != nil:
|
||||
session := con.ActiveTarget.session
|
||||
filters = append(filters, consts.BeaconCmdsFilter)
|
||||
filters = append(filters, consts.NativeExtensionTargetFilter(session.OS, session.Arch))
|
||||
|
||||
// Operating system
|
||||
if session.OS != "windows" {
|
||||
@@ -1161,6 +1162,7 @@ func (con *SliverClient) ExposeCommands() {
|
||||
case con.ActiveTarget.beacon != nil:
|
||||
beacon := con.ActiveTarget.beacon
|
||||
filters = append(filters, consts.SessionCmdsFilter)
|
||||
filters = append(filters, consts.NativeExtensionTargetFilter(beacon.OS, beacon.Arch))
|
||||
|
||||
// Operating system
|
||||
if beacon.OS != "windows" {
|
||||
|
||||
@@ -231,6 +231,7 @@ const (
|
||||
AIStr = "ai"
|
||||
AIConfigStr = "ai-config"
|
||||
DocsStr = "docs"
|
||||
OpforStr = "opfor"
|
||||
CatStr = "cat"
|
||||
EditStr = "edit"
|
||||
HexEditStr = "hex-edit"
|
||||
@@ -363,8 +364,53 @@ const (
|
||||
BeaconCmdsFilter = "beacon"
|
||||
WindowsCmdsFilter = "windows"
|
||||
WireguardCmdsFilter = "wireguard"
|
||||
|
||||
NativeExtensionUnsupportedTargetFilter = "native-extension:unsupported"
|
||||
NativeExtensionDarwinAMD64Filter = "native-extension:darwin/amd64"
|
||||
NativeExtensionDarwinARM64Filter = "native-extension:darwin/arm64"
|
||||
NativeExtensionLinux386Filter = "native-extension:linux/386"
|
||||
NativeExtensionLinuxAMD64Filter = "native-extension:linux/amd64"
|
||||
NativeExtensionLinuxARM64Filter = "native-extension:linux/arm64"
|
||||
NativeExtensionWindows386Filter = "native-extension:windows/386"
|
||||
NativeExtensionWindowsAMD64Filter = "native-extension:windows/amd64"
|
||||
NativeExtensionWindowsARM64Filter = "native-extension:windows/arm64"
|
||||
)
|
||||
|
||||
// NativeExtensionTarget identifies a GOOS/GOARCH pair supported by native
|
||||
// implant extensions and the command filter associated with that exact pair.
|
||||
type NativeExtensionTarget struct {
|
||||
GOOS string
|
||||
GOARCH string
|
||||
Filter string
|
||||
}
|
||||
|
||||
var nativeExtensionTargets = [8]NativeExtensionTarget{
|
||||
{GOOS: "darwin", GOARCH: "amd64", Filter: NativeExtensionDarwinAMD64Filter},
|
||||
{GOOS: "darwin", GOARCH: "arm64", Filter: NativeExtensionDarwinARM64Filter},
|
||||
{GOOS: "linux", GOARCH: "386", Filter: NativeExtensionLinux386Filter},
|
||||
{GOOS: "linux", GOARCH: "amd64", Filter: NativeExtensionLinuxAMD64Filter},
|
||||
{GOOS: "linux", GOARCH: "arm64", Filter: NativeExtensionLinuxARM64Filter},
|
||||
{GOOS: "windows", GOARCH: "386", Filter: NativeExtensionWindows386Filter},
|
||||
{GOOS: "windows", GOARCH: "amd64", Filter: NativeExtensionWindowsAMD64Filter},
|
||||
{GOOS: "windows", GOARCH: "arm64", Filter: NativeExtensionWindowsARM64Filter},
|
||||
}
|
||||
|
||||
// NativeExtensionTargets returns the supported native extension target matrix.
|
||||
func NativeExtensionTargets() [8]NativeExtensionTarget {
|
||||
return nativeExtensionTargets
|
||||
}
|
||||
|
||||
// NativeExtensionTargetFilter returns the filter for an exact supported target.
|
||||
// Unsupported pairs share a filter that hides all native extension commands.
|
||||
func NativeExtensionTargetFilter(goos string, goarch string) string {
|
||||
for _, target := range nativeExtensionTargets {
|
||||
if target.GOOS == goos && target.GOARCH == goarch {
|
||||
return target.Filter
|
||||
}
|
||||
}
|
||||
return NativeExtensionUnsupportedTargetFilter
|
||||
}
|
||||
|
||||
// Creds (needed here to avoid recursive imports).
|
||||
const (
|
||||
UserColonHashNewlineFormat = "user:hash" // username:hash\n
|
||||
|
||||
+11
-2
@@ -41,8 +41,9 @@ func TunnelLoop(ctx context.Context, rpc rpcpb.SliverRPCClient) error {
|
||||
return err
|
||||
}
|
||||
|
||||
GetTunnels().SetStream(stream)
|
||||
defer GetTunnels().SetStream(nil)
|
||||
tunnels := GetTunnels()
|
||||
streamGeneration := tunnels.SetStream(stream)
|
||||
defer tunnels.CloseStream(streamGeneration)
|
||||
|
||||
for {
|
||||
log.Printf("Waiting for TunnelData ...")
|
||||
@@ -65,6 +66,14 @@ func TunnelLoop(ctx context.Context, rpc rpcpb.SliverRPCClient) error {
|
||||
tunnel := GetTunnels().Get(incoming.TunnelID)
|
||||
|
||||
if tunnel != nil {
|
||||
if !incoming.Closed && len(incoming.GetData()) == 0 {
|
||||
// The server uses an empty frame to acknowledge that the client
|
||||
// stream is bound to a newly created tunnel. It is control
|
||||
// traffic, not data for the tunnel reader.
|
||||
tunnel.markBound()
|
||||
log.Printf("Received bind acknowledgement for tunnel %d", incoming.TunnelID)
|
||||
continue
|
||||
}
|
||||
data := incoming.GetData()
|
||||
|
||||
log.Printf("This is data on tunnel %d: %s", tunnel.ID, data)
|
||||
|
||||
+87
-26
@@ -26,6 +26,10 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
const tunnelRecvBufferSize = 16
|
||||
|
||||
var errClosedTunnel = errors.New("closed tunnel")
|
||||
|
||||
// TunnelIO - Duplex data tunnel, compatible with both io.ReadWriter
|
||||
type TunnelIO struct {
|
||||
ID uint64
|
||||
@@ -34,8 +38,12 @@ type TunnelIO struct {
|
||||
Send chan []byte
|
||||
Recv chan []byte
|
||||
|
||||
isOpen bool
|
||||
mutex *sync.RWMutex
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
bound chan struct{}
|
||||
boundOnce sync.Once
|
||||
isOpen bool
|
||||
mutex *sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTunnelIO - Single entry point for creating instance of new TunnelIO
|
||||
@@ -46,9 +54,13 @@ func NewTunnelIO(tunnelID uint64, sessionID string) *TunnelIO {
|
||||
ID: tunnelID,
|
||||
SessionID: sessionID,
|
||||
Send: make(chan []byte),
|
||||
Recv: make(chan []byte),
|
||||
isOpen: false,
|
||||
mutex: &sync.RWMutex{},
|
||||
// A tunnel can receive its first frames before the command-specific
|
||||
// reader is running. Keep that short startup burst from blocking the
|
||||
// single client TunnelLoop while retaining bounded backpressure.
|
||||
Recv: make(chan []byte, tunnelRecvBufferSize),
|
||||
done: make(chan struct{}),
|
||||
bound: make(chan struct{}),
|
||||
mutex: &sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,33 +78,58 @@ func (tun *TunnelIO) Write(data []byte) (int, error) {
|
||||
log.Printf("Write %d bytes", n)
|
||||
log.Printf("This bytes is: %s", dataCopy)
|
||||
|
||||
tun.Send <- dataCopy
|
||||
select {
|
||||
case <-tun.done:
|
||||
return 0, io.EOF
|
||||
default:
|
||||
}
|
||||
|
||||
return n, nil
|
||||
select {
|
||||
case tun.Send <- dataCopy:
|
||||
return n, nil
|
||||
case <-tun.done:
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
// Read - Reader method for interface
|
||||
func (tun *TunnelIO) Read(data []byte) (int, error) {
|
||||
recvData, ok := <-tun.Recv
|
||||
if !ok {
|
||||
// Prefer already queued output over the close signal so callers can drain
|
||||
// the final frames before observing EOF.
|
||||
select {
|
||||
case recvData := <-tun.Recv:
|
||||
return tun.copyRecvData(data, recvData), nil
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case recvData := <-tun.Recv:
|
||||
return tun.copyRecvData(data, recvData), nil
|
||||
case <-tun.done:
|
||||
log.Printf("Warning: Read on closed tunnel %d", tun.ID)
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
func (tun *TunnelIO) copyRecvData(data []byte, recvData []byte) int {
|
||||
var buff bytes.Buffer
|
||||
log.Printf("Read %d bytes", len(recvData))
|
||||
buff.Write(recvData)
|
||||
|
||||
n := copy(data, buff.Bytes())
|
||||
return n, nil
|
||||
return copy(data, buff.Bytes())
|
||||
}
|
||||
|
||||
// Close - Close tunnel IO operations
|
||||
func (tun *TunnelIO) Close() error {
|
||||
tun.mutex.Lock()
|
||||
defer tun.mutex.Unlock()
|
||||
wasOpen := tun.isOpen
|
||||
tun.isOpen = false
|
||||
tun.closeOnce.Do(func() {
|
||||
close(tun.done)
|
||||
})
|
||||
tun.mutex.Unlock()
|
||||
|
||||
if !tun.isOpen {
|
||||
if !wasOpen {
|
||||
log.Printf("Warning: Close on closed tunnel %d", tun.ID)
|
||||
|
||||
// I guess we can ignore it and don't return any error
|
||||
@@ -101,14 +138,26 @@ func (tun *TunnelIO) Close() error {
|
||||
|
||||
log.Printf("Close tunnel %d", tun.ID)
|
||||
|
||||
tun.isOpen = false
|
||||
|
||||
close(tun.Recv)
|
||||
close(tun.Send)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Done is closed when the tunnel is closed. The public data channels remain
|
||||
// open so concurrent producers cannot panic by racing a channel close.
|
||||
func (tun *TunnelIO) Done() <-chan struct{} {
|
||||
return tun.done
|
||||
}
|
||||
|
||||
// Bound is closed after the server acknowledges the tunnel's streaming bind.
|
||||
func (tun *TunnelIO) Bound() <-chan struct{} {
|
||||
return tun.bound
|
||||
}
|
||||
|
||||
func (tun *TunnelIO) markBound() {
|
||||
tun.boundOnce.Do(func() {
|
||||
close(tun.bound)
|
||||
})
|
||||
}
|
||||
|
||||
func (tun *TunnelIO) IsOpen() bool {
|
||||
tun.mutex.RLock()
|
||||
defer tun.mutex.RUnlock()
|
||||
@@ -123,6 +172,11 @@ func (tun *TunnelIO) Open() error {
|
||||
if tun.isOpen {
|
||||
return errors.New("tunnel relady in open state")
|
||||
}
|
||||
select {
|
||||
case <-tun.done:
|
||||
return errClosedTunnel
|
||||
default:
|
||||
}
|
||||
|
||||
log.Printf("Open tunnel %d", tun.ID)
|
||||
|
||||
@@ -131,16 +185,23 @@ func (tun *TunnelIO) Open() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecvData - safe way to send data to internal Recv channel. Blocking.
|
||||
// RecvData - safely queues data on the bounded internal Recv channel.
|
||||
// It blocks only when that startup buffer is full.
|
||||
func (tun *TunnelIO) RecvData(data []byte) error {
|
||||
tun.mutex.Lock()
|
||||
defer tun.mutex.Unlock()
|
||||
|
||||
if !tun.isOpen {
|
||||
return errors.New("closed tunnel")
|
||||
if !tun.IsOpen() {
|
||||
return errClosedTunnel
|
||||
}
|
||||
|
||||
tun.Recv <- data
|
||||
select {
|
||||
case <-tun.done:
|
||||
return errClosedTunnel
|
||||
default:
|
||||
}
|
||||
|
||||
return nil
|
||||
select {
|
||||
case tun.Recv <- data:
|
||||
return nil
|
||||
case <-tun.done:
|
||||
return errClosedTunnel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTunnelIOBuffersDataBeforeReaderStarts(t *testing.T) {
|
||||
tunnel := NewTunnelIO(1, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = tunnel.Close()
|
||||
})
|
||||
|
||||
if got := cap(tunnel.Recv); got != tunnelRecvBufferSize {
|
||||
t.Fatalf("receive buffer capacity = %d, want %d", got, tunnelRecvBufferSize)
|
||||
}
|
||||
|
||||
payload := []byte("early shell output")
|
||||
recvDone := make(chan error, 1)
|
||||
go func() {
|
||||
recvDone <- tunnel.RecvData(payload)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-recvDone:
|
||||
if err != nil {
|
||||
t.Fatalf("queue data before reader startup: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("RecvData blocked before the tunnel reader started")
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-tunnel.Recv:
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("queued payload = %q, want %q", got, payload)
|
||||
}
|
||||
default:
|
||||
t.Fatal("early payload was not queued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelIOCloseUnblocksFullReceiveBuffer(t *testing.T) {
|
||||
tunnel := NewTunnelIO(2, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < tunnelRecvBufferSize; i++ {
|
||||
recvDone := make(chan error, 1)
|
||||
go func() {
|
||||
recvDone <- tunnel.RecvData([]byte{byte(i)})
|
||||
}()
|
||||
select {
|
||||
case err := <-recvDone:
|
||||
if err != nil {
|
||||
t.Fatalf("fill receive buffer frame %d: %v", i, err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("frame %d blocked before receive buffer was full", i)
|
||||
}
|
||||
}
|
||||
|
||||
blockedRecv := make(chan error, 1)
|
||||
go func() {
|
||||
blockedRecv <- tunnel.RecvData([]byte("seventeenth frame"))
|
||||
}()
|
||||
select {
|
||||
case err := <-blockedRecv:
|
||||
t.Fatalf("frame beyond bounded receive capacity returned before close: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
closeDone := make(chan error, 1)
|
||||
go func() {
|
||||
closeDone <- tunnel.Close()
|
||||
}()
|
||||
select {
|
||||
case err := <-closeDone:
|
||||
if err != nil {
|
||||
t.Fatalf("close full tunnel: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close blocked behind RecvData on a full receive buffer")
|
||||
}
|
||||
select {
|
||||
case err := <-blockedRecv:
|
||||
if !errors.Is(err, errClosedTunnel) {
|
||||
t.Fatalf("blocked RecvData error = %v, want %v", err, errClosedTunnel)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("blocked RecvData did not wake when tunnel closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelIOCloseUnblocksRead(t *testing.T) {
|
||||
tunnel := NewTunnelIO(3, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
|
||||
type readResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
readDone := make(chan readResult, 1)
|
||||
go func() {
|
||||
n, err := tunnel.Read(make([]byte, 32))
|
||||
readDone <- readResult{n: n, err: err}
|
||||
}()
|
||||
|
||||
if err := tunnel.Close(); err != nil {
|
||||
t.Fatalf("close tunnel: %v", err)
|
||||
}
|
||||
select {
|
||||
case result := <-readDone:
|
||||
if result.n != 0 || !errors.Is(result.err, io.EOF) {
|
||||
t.Fatalf("Read after close = (%d, %v), want (0, EOF)", result.n, result.err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("blocked Read did not wake when tunnel closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelIOCloseUnblocksWrite(t *testing.T) {
|
||||
tunnel := NewTunnelIO(4, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
|
||||
type writeResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
writeDone := make(chan writeResult, 1)
|
||||
go func() {
|
||||
n, err := tunnel.Write([]byte("blocked write"))
|
||||
writeDone <- writeResult{n: n, err: err}
|
||||
}()
|
||||
select {
|
||||
case result := <-writeDone:
|
||||
t.Fatalf("Write returned before close without a consumer: (%d, %v)", result.n, result.err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := tunnel.Close(); err != nil {
|
||||
t.Fatalf("close tunnel: %v", err)
|
||||
}
|
||||
select {
|
||||
case result := <-writeDone:
|
||||
if result.n != 0 || !errors.Is(result.err, io.EOF) {
|
||||
t.Fatalf("Write after close = (%d, %v), want (0, EOF)", result.n, result.err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("blocked Write did not wake when tunnel closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelIOReadDrainsQueuedDataBeforeEOF(t *testing.T) {
|
||||
tunnel := NewTunnelIO(5, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
payload := []byte("trailing output")
|
||||
if err := tunnel.RecvData(payload); err != nil {
|
||||
t.Fatalf("queue trailing output: %v", err)
|
||||
}
|
||||
if err := tunnel.Close(); err != nil {
|
||||
t.Fatalf("close tunnel: %v", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 32)
|
||||
n, err := tunnel.Read(buf)
|
||||
if err != nil || !bytes.Equal(buf[:n], payload) {
|
||||
t.Fatalf("first Read after close = (%q, %v), want (%q, nil)", buf[:n], err, payload)
|
||||
}
|
||||
n, err = tunnel.Read(buf)
|
||||
if n != 0 || !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("second Read after close = (%d, %v), want (0, EOF)", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelIOCloseBeforeOpenIsTerminal(t *testing.T) {
|
||||
tunnel := NewTunnelIO(6, "session")
|
||||
if err := tunnel.Close(); err != nil {
|
||||
t.Fatalf("close tunnel before open: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-tunnel.Done():
|
||||
default:
|
||||
t.Fatal("Close before Open did not close Done")
|
||||
}
|
||||
if err := tunnel.Open(); !errors.Is(err, errClosedTunnel) {
|
||||
t.Fatalf("Open after Close error = %v, want %v", err, errClosedTunnel)
|
||||
}
|
||||
if tunnel.IsOpen() {
|
||||
t.Fatal("tunnel reopened after terminal Close")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type tunnelLoopTestRPC struct {
|
||||
rpcpb.SliverRPCClient
|
||||
stream *tunnelLoopTestStream
|
||||
opened chan struct{}
|
||||
}
|
||||
|
||||
func (r *tunnelLoopTestRPC) TunnelData(ctx context.Context, _ ...grpc.CallOption) (grpc.BidiStreamingClient[sliverpb.TunnelData, sliverpb.TunnelData], error) {
|
||||
r.stream.ctx = ctx
|
||||
close(r.opened)
|
||||
return r.stream, nil
|
||||
}
|
||||
|
||||
type tunnelLoopTestStream struct {
|
||||
grpc.BidiStreamingClient[sliverpb.TunnelData, sliverpb.TunnelData]
|
||||
ctx context.Context
|
||||
incoming chan *sliverpb.TunnelData
|
||||
sent chan *sliverpb.TunnelData
|
||||
recvCalls chan struct{}
|
||||
recvErr chan error
|
||||
sendErr error
|
||||
}
|
||||
|
||||
func (s *tunnelLoopTestStream) Send(data *sliverpb.TunnelData) error {
|
||||
if s.sendErr != nil {
|
||||
return s.sendErr
|
||||
}
|
||||
s.sent <- data
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTunnelBindSendFailureClosesTunnel(t *testing.T) {
|
||||
tunnels := GetTunnels()
|
||||
tunnels.Reset()
|
||||
stream := &tunnelLoopTestStream{
|
||||
incoming: make(chan *sliverpb.TunnelData),
|
||||
sent: make(chan *sliverpb.TunnelData, 1),
|
||||
recvCalls: make(chan struct{}, 2),
|
||||
sendErr: errors.New("test bind send failure"),
|
||||
}
|
||||
rpc := &tunnelLoopTestRPC{stream: stream, opened: make(chan struct{})}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
loopDone := make(chan error, 1)
|
||||
go func() { loopDone <- TunnelLoop(ctx, rpc) }()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-loopDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("TunnelLoop did not stop after cancellation")
|
||||
}
|
||||
tunnels.Reset()
|
||||
})
|
||||
waitForTestSignal(t, rpc.opened, "TunnelLoop stream startup")
|
||||
waitForTestSignal(t, stream.recvCalls, "initial TunnelData receive")
|
||||
|
||||
tunnel := tunnels.Start(9, "session")
|
||||
select {
|
||||
case <-tunnel.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tunnel remained open after its bind frame failed to send")
|
||||
}
|
||||
if got := tunnels.Get(tunnel.ID); got != nil {
|
||||
t.Fatal("failed bind tunnel remained registered")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tunnelLoopTestStream) Recv() (*sliverpb.TunnelData, error) {
|
||||
s.recvCalls <- struct{}{}
|
||||
select {
|
||||
case data := <-s.incoming:
|
||||
return data, nil
|
||||
case err := <-s.recvErr:
|
||||
return nil, err
|
||||
case <-s.ctx.Done():
|
||||
return nil, s.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelLoopFiltersEmptyBindAcknowledgement(t *testing.T) {
|
||||
tunnels := GetTunnels()
|
||||
tunnels.Reset()
|
||||
|
||||
stream := &tunnelLoopTestStream{
|
||||
incoming: make(chan *sliverpb.TunnelData, 2),
|
||||
sent: make(chan *sliverpb.TunnelData, 1),
|
||||
recvCalls: make(chan struct{}, 4),
|
||||
}
|
||||
rpc := &tunnelLoopTestRPC{
|
||||
stream: stream,
|
||||
opened: make(chan struct{}),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
loopDone := make(chan error, 1)
|
||||
go func() {
|
||||
loopDone <- TunnelLoop(ctx, rpc)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-loopDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("TunnelLoop did not stop after cancellation")
|
||||
}
|
||||
tunnels.Reset()
|
||||
})
|
||||
|
||||
waitForTestSignal(t, rpc.opened, "TunnelLoop stream startup")
|
||||
waitForTestSignal(t, stream.recvCalls, "initial TunnelData receive")
|
||||
|
||||
tunnel := tunnels.Start(7, "session")
|
||||
select {
|
||||
case bind := <-stream.sent:
|
||||
if bind.TunnelID != tunnel.ID || len(bind.Data) != 0 {
|
||||
t.Fatalf("bind frame = %+v, want empty data for tunnel %d", bind, tunnel.ID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("client did not send the tunnel bind frame")
|
||||
}
|
||||
|
||||
stream.incoming <- &sliverpb.TunnelData{
|
||||
TunnelID: tunnel.ID,
|
||||
SessionID: tunnel.SessionID,
|
||||
}
|
||||
waitForTestSignal(t, stream.recvCalls, "receive after bind acknowledgement")
|
||||
select {
|
||||
case <-tunnel.Bound():
|
||||
default:
|
||||
t.Fatal("bind acknowledgement did not mark the tunnel bound")
|
||||
}
|
||||
select {
|
||||
case data := <-tunnel.Recv:
|
||||
t.Fatalf("bind acknowledgement leaked to the tunnel reader: %q", data)
|
||||
default:
|
||||
}
|
||||
|
||||
payload := []byte("shell prompt")
|
||||
stream.incoming <- &sliverpb.TunnelData{
|
||||
TunnelID: tunnel.ID,
|
||||
SessionID: tunnel.SessionID,
|
||||
Data: payload,
|
||||
}
|
||||
waitForTestSignal(t, stream.recvCalls, "receive after shell payload")
|
||||
select {
|
||||
case got := <-tunnel.Recv:
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("tunnel payload = %q, want %q", got, payload)
|
||||
}
|
||||
default:
|
||||
t.Fatal("non-empty tunnel payload was not delivered")
|
||||
}
|
||||
|
||||
stream.incoming <- &sliverpb.TunnelData{
|
||||
TunnelID: tunnel.ID,
|
||||
SessionID: tunnel.SessionID,
|
||||
Closed: true,
|
||||
}
|
||||
waitForTestSignal(t, stream.recvCalls, "receive after tunnel close")
|
||||
if got := tunnels.Get(tunnel.ID); got != nil {
|
||||
t.Fatal("empty close frame was filtered as a bind acknowledgement")
|
||||
}
|
||||
select {
|
||||
case tunnel.Send <- []byte("after close"):
|
||||
t.Fatal("tunnel sender still consumed data after Close")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelLoopClosesActiveTunnelsOnStreamEOF(t *testing.T) {
|
||||
tunnels := GetTunnels()
|
||||
tunnels.Reset()
|
||||
t.Cleanup(tunnels.Reset)
|
||||
|
||||
stream := &tunnelLoopTestStream{
|
||||
incoming: make(chan *sliverpb.TunnelData),
|
||||
sent: make(chan *sliverpb.TunnelData, 1),
|
||||
recvCalls: make(chan struct{}, 4),
|
||||
recvErr: make(chan error, 1),
|
||||
}
|
||||
rpc := &tunnelLoopTestRPC{stream: stream, opened: make(chan struct{})}
|
||||
loopDone := make(chan error, 1)
|
||||
go func() {
|
||||
loopDone <- TunnelLoop(context.Background(), rpc)
|
||||
}()
|
||||
|
||||
waitForTestSignal(t, rpc.opened, "TunnelLoop stream startup")
|
||||
waitForTestSignal(t, stream.recvCalls, "initial TunnelData receive")
|
||||
tunnel := tunnels.Start(8, "session")
|
||||
select {
|
||||
case <-stream.sent:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("client did not send the tunnel bind frame")
|
||||
}
|
||||
|
||||
stream.recvErr <- io.EOF
|
||||
select {
|
||||
case err := <-loopDone:
|
||||
if err != nil {
|
||||
t.Fatalf("TunnelLoop EOF result = %v, want nil", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("TunnelLoop did not return after stream EOF")
|
||||
}
|
||||
select {
|
||||
case <-tunnel.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("active tunnel did not close after stream EOF")
|
||||
}
|
||||
if got := tunnels.Get(tunnel.ID); got != nil {
|
||||
t.Fatal("active tunnel remained registered after stream EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForTestSignal(t *testing.T, signal <-chan struct{}, description string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-signal:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for %s", description)
|
||||
}
|
||||
}
|
||||
+55
-19
@@ -55,19 +55,35 @@ func GetTunnels() *tunnels {
|
||||
// Holds the tunnels locally so we can map incoming data
|
||||
// messages to the tunnel
|
||||
type tunnels struct {
|
||||
tunnels *map[uint64]*TunnelIO
|
||||
mutex *sync.RWMutex
|
||||
streamMutex *sync.Mutex
|
||||
stream rpcpb.SliverRPC_TunnelDataClient
|
||||
tunnels *map[uint64]*TunnelIO
|
||||
mutex *sync.RWMutex
|
||||
streamMutex *sync.Mutex
|
||||
stream rpcpb.SliverRPC_TunnelDataClient
|
||||
streamGeneration uint64
|
||||
}
|
||||
|
||||
func (t *tunnels) SetStream(stream rpcpb.SliverRPC_TunnelDataClient) {
|
||||
func (t *tunnels) SetStream(stream rpcpb.SliverRPC_TunnelDataClient) uint64 {
|
||||
t.streamMutex.Lock()
|
||||
defer t.streamMutex.Unlock()
|
||||
|
||||
log.Printf("Set stream")
|
||||
|
||||
t.streamGeneration++
|
||||
t.stream = stream
|
||||
return t.streamGeneration
|
||||
}
|
||||
|
||||
// CloseStream clears one specific stream generation and closes the tunnels it
|
||||
// owned. A stale loop cannot clear tunnels created by a replacement stream.
|
||||
func (t *tunnels) CloseStream(generation uint64) {
|
||||
t.streamMutex.Lock()
|
||||
defer t.streamMutex.Unlock()
|
||||
if generation != t.streamGeneration {
|
||||
return
|
||||
}
|
||||
|
||||
t.stream = nil
|
||||
t.closeAll()
|
||||
}
|
||||
|
||||
// Get - Get a tunnel
|
||||
@@ -105,27 +121,39 @@ func (t *tunnels) Start(tunnelID uint64, sessionID string) *TunnelIO {
|
||||
(*t.tunnels)[tunnelID] = tunnel
|
||||
|
||||
go func(tunnel *TunnelIO) {
|
||||
tunnel.Open()
|
||||
if err := tunnel.Open(); err != nil {
|
||||
log.Printf("Failed to open tunnel %d: %v", tunnelID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("Tunnel now is open, %d", tunnelID)
|
||||
|
||||
for data := range tunnel.Send {
|
||||
log.Printf("Send %d bytes on tunnel %d", len(data), tunnel.ID)
|
||||
for {
|
||||
select {
|
||||
case <-tunnel.Done():
|
||||
log.Printf("Tunnel send loop stopped. %d", tunnelID)
|
||||
return
|
||||
case data := <-tunnel.Send:
|
||||
log.Printf("Send %d bytes on tunnel %d", len(data), tunnel.ID)
|
||||
|
||||
err := t.send(&sliverpb.TunnelData{
|
||||
TunnelID: tunnel.ID,
|
||||
SessionID: tunnel.SessionID,
|
||||
Data: data,
|
||||
})
|
||||
err := t.send(&sliverpb.TunnelData{
|
||||
TunnelID: tunnel.ID,
|
||||
SessionID: tunnel.SessionID,
|
||||
Data: data,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error sending, %s", err)
|
||||
if err != nil {
|
||||
log.Printf("Error sending, %s", err)
|
||||
t.Close(tunnel.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Tunnel Send channel looks closed now. %d", tunnelID)
|
||||
}(tunnel)
|
||||
|
||||
tunnel.Send <- make([]byte, 0) // Send "zero" message to bind client to tunnel
|
||||
select {
|
||||
case tunnel.Send <- make([]byte, 0): // Send "zero" message to bind client to tunnel
|
||||
case <-tunnel.Done():
|
||||
}
|
||||
return tunnel
|
||||
}
|
||||
|
||||
@@ -164,8 +192,16 @@ func (t *tunnels) CloseForSession(sessionID string) {
|
||||
// Reset closes all open tunnels and clears the underlying storage/stream.
|
||||
// This is used when switching servers inside a single client process.
|
||||
func (t *tunnels) Reset() {
|
||||
t.SetStream(nil)
|
||||
t.streamMutex.Lock()
|
||||
defer t.streamMutex.Unlock()
|
||||
t.streamGeneration++
|
||||
t.stream = nil
|
||||
t.closeAll()
|
||||
}
|
||||
|
||||
// closeAll runs with streamMutex held so a replacement stream cannot publish
|
||||
// new tunnels until the previous generation has been fully torn down.
|
||||
func (t *tunnels) closeAll() {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ func GenerateProfilesNewForm() (*GenerateProfilesNewFormResult, error) {
|
||||
options := []huh.Option[string]{
|
||||
huh.NewOption("Executable", "exe"),
|
||||
huh.NewOption("Shared library", "shared"),
|
||||
huh.NewOption("Go archive", "archive"),
|
||||
}
|
||||
if result.OS == "windows" {
|
||||
options = append(options,
|
||||
|
||||
@@ -84,6 +84,7 @@ func GenerateProfilesNewBeaconForm() (*GenerateProfilesNewBeaconFormResult, erro
|
||||
options := []huh.Option[string]{
|
||||
huh.NewOption("Executable", "exe"),
|
||||
huh.NewOption("Shared library", "shared"),
|
||||
huh.NewOption("Go archive", "archive"),
|
||||
}
|
||||
if result.OS == "windows" {
|
||||
options = append(options,
|
||||
|
||||
@@ -22,6 +22,7 @@ type targetOptionBindings struct {
|
||||
var formatOptionOrder = []formatOption{
|
||||
{format: clientpb.OutputFormat_EXECUTABLE, label: "Executable", value: "exe"},
|
||||
{format: clientpb.OutputFormat_SHARED_LIB, label: "Shared library", value: "shared"},
|
||||
{format: clientpb.OutputFormat_GO_ARCHIVE, label: "Go archive", value: "archive"},
|
||||
{format: clientpb.OutputFormat_SERVICE, label: "Service", value: "service"},
|
||||
{format: clientpb.OutputFormat_SHELLCODE, label: "Shellcode", value: "shellcode"},
|
||||
}
|
||||
@@ -59,6 +60,7 @@ func commonFormatOptions(goos string) []huh.Option[string] {
|
||||
options := []huh.Option[string]{
|
||||
huh.NewOption("Executable", "exe"),
|
||||
huh.NewOption("Shared library", "shared"),
|
||||
huh.NewOption("Go archive", "archive"),
|
||||
}
|
||||
if goos == "windows" {
|
||||
options = append(options,
|
||||
|
||||
@@ -11,8 +11,21 @@ import (
|
||||
type MultiplayerConnectMode int
|
||||
|
||||
const (
|
||||
MultiplayerConnectDirect MultiplayerConnectMode = iota
|
||||
// MultiplayerConnectAuto follows the explicit WireGuard opt-in recorded in
|
||||
// the operator config.
|
||||
MultiplayerConnectAuto MultiplayerConnectMode = iota
|
||||
// MultiplayerConnectEnableWG forces WireGuard for complete configs,
|
||||
// including legacy configs that predate the explicit opt-in marker.
|
||||
MultiplayerConnectEnableWG
|
||||
// MultiplayerConnectDisableWG forces direct multiplayer mTLS.
|
||||
MultiplayerConnectDisableWG
|
||||
|
||||
// MultiplayerConnectDirect is retained as a source-compatible alias for
|
||||
// callers that explicitly select direct multiplayer mTLS.
|
||||
MultiplayerConnectDirect = MultiplayerConnectDisableWG
|
||||
// MultiplayerConnectRequireWG is retained for source compatibility.
|
||||
// Deprecated: use MultiplayerConnectEnableWG.
|
||||
MultiplayerConnectRequireWG = MultiplayerConnectEnableWG
|
||||
)
|
||||
|
||||
type connectionCloser interface {
|
||||
@@ -21,7 +34,7 @@ type connectionCloser interface {
|
||||
|
||||
var (
|
||||
multiplayerConnectModeMu sync.RWMutex
|
||||
multiplayerConnectMode = MultiplayerConnectDirect
|
||||
multiplayerConnectMode = MultiplayerConnectAuto
|
||||
connClosers sync.Map // *grpc.ClientConn -> connectionCloser
|
||||
)
|
||||
|
||||
|
||||
@@ -104,17 +104,36 @@ func selectMultiplayerDialStrategy(config *assets.ClientConfig) (multiplayerDial
|
||||
return multiplayerDialDirect, errors.New("client config is required")
|
||||
}
|
||||
|
||||
switch getMultiplayerConnectMode() {
|
||||
mode := getMultiplayerConnectMode()
|
||||
switch mode {
|
||||
case MultiplayerConnectDisableWG:
|
||||
return multiplayerDialDirect, nil
|
||||
case MultiplayerConnectEnableWG:
|
||||
if err := validateWireGuardConfig(config); err != nil {
|
||||
return multiplayerDialDirect, err
|
||||
}
|
||||
return multiplayerDialWireGuard, nil
|
||||
case MultiplayerConnectAuto:
|
||||
if config.WG == nil || !config.WG.Enabled {
|
||||
return multiplayerDialDirect, nil
|
||||
}
|
||||
if err := validateWireGuardConfig(config); err != nil {
|
||||
return multiplayerDialDirect, err
|
||||
}
|
||||
return multiplayerDialWireGuard, nil
|
||||
default:
|
||||
return multiplayerDialDirect, nil
|
||||
return multiplayerDialDirect, fmt.Errorf("invalid multiplayer connect mode %d", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// MultiplayerConnectUsesWireGuard reports whether the current mode and
|
||||
// operator config select the WireGuard wrapper. Invalid WireGuard configs
|
||||
// report false; the initial multiplayer dial returns the validation error.
|
||||
func MultiplayerConnectUsesWireGuard(config *assets.ClientConfig) bool {
|
||||
strategy, err := selectMultiplayerDialStrategy(config)
|
||||
return err == nil && strategy == multiplayerDialWireGuard
|
||||
}
|
||||
|
||||
func directMTLSConnect(config *assets.ClientConfig, statusFn ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
|
||||
notifyConnectStatus(statusFn, connectStatusGRPCMTLS)
|
||||
options, err := newMTLSDialOptions(config)
|
||||
|
||||
@@ -10,8 +10,14 @@ import (
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestSelectMultiplayerDialStrategyLegacyConfigUsesDirectMTLS(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectDirect)
|
||||
func TestMultiplayerConnectModeDefaultsToAuto(t *testing.T) {
|
||||
if mode := getMultiplayerConnectMode(); mode != MultiplayerConnectAuto {
|
||||
t.Fatalf("expected auto to be the default multiplayer mode, got %v", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyAutoWithoutWGConfigUsesDirectMTLS(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
|
||||
if err != nil {
|
||||
@@ -22,15 +28,10 @@ func TestSelectMultiplayerDialStrategyLegacyConfigUsesDirectMTLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyDefaultIgnoresIncompleteWGConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectDirect)
|
||||
func TestSelectMultiplayerDialStrategyAutoLegacyWGConfigUsesDirectMTLS(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
|
||||
WG: &assets.ClientWGConfig{
|
||||
ServerPubKey: "server-pub",
|
||||
ClientIP: "100.64.0.2",
|
||||
},
|
||||
})
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
|
||||
if err != nil {
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
@@ -39,6 +40,33 @@ func TestSelectMultiplayerDialStrategyDefaultIgnoresIncompleteWGConfig(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyAutoEnabledWGConfigRequiresCompleteConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
|
||||
|
||||
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
|
||||
WG: &assets.ClientWGConfig{
|
||||
Enabled: true,
|
||||
ServerPubKey: "server-pub",
|
||||
ClientIP: "100.64.0.2",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrIncompleteWireGuardConfig) {
|
||||
t.Fatalf("expected incomplete WG config error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyAutoEnabledWGConfigUsesWireGuard(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
|
||||
if err != nil {
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
if strategy != multiplayerDialWireGuard {
|
||||
t.Fatalf("expected WireGuard strategy, got %v", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyEnableWGRejectsMissingWGConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
|
||||
|
||||
@@ -62,16 +90,10 @@ func TestSelectMultiplayerDialStrategyEnableWGRejectsIncompleteWGConfig(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyEnableWGUsesWireGuardWhenConfigComplete(t *testing.T) {
|
||||
func TestSelectMultiplayerDialStrategyEnableWGUsesCompleteLegacyConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
|
||||
WG: &assets.ClientWGConfig{
|
||||
ServerPubKey: "server-pub",
|
||||
ClientPrivateKey: "client-priv",
|
||||
ClientIP: "100.64.0.2",
|
||||
},
|
||||
})
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
|
||||
if err != nil {
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
@@ -80,21 +102,38 @@ func TestSelectMultiplayerDialStrategyEnableWGUsesWireGuardWhenConfigComplete(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyDefaultUsesDirectMTLSEvenWhenWGConfigComplete(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectDirect)
|
||||
func TestSelectMultiplayerDialStrategyDisableWGOverridesEnabledConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectDisableWG)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
|
||||
WG: &assets.ClientWGConfig{
|
||||
ServerPubKey: "server-pub",
|
||||
ClientPrivateKey: "client-priv",
|
||||
ClientIP: "100.64.0.2",
|
||||
},
|
||||
})
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
|
||||
if err != nil {
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
if strategy != multiplayerDialDirect {
|
||||
t.Fatalf("expected direct strategy when WG is not explicitly enabled, got %v", strategy)
|
||||
t.Fatalf("expected direct strategy, got %v", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyRejectsInvalidMode(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectMode(255))
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
|
||||
if strategy != multiplayerDialDirect {
|
||||
t.Fatalf("expected invalid mode to fall back to direct strategy, got %v", strategy)
|
||||
}
|
||||
if err == nil || err.Error() != "invalid multiplayer connect mode 255" {
|
||||
t.Fatalf("expected invalid mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func completeWGClientConfig(enabled bool) *assets.ClientConfig {
|
||||
return &assets.ClientConfig{
|
||||
WG: &assets.ClientWGConfig{
|
||||
Enabled: enabled,
|
||||
ServerPubKey: "server-pub",
|
||||
ClientPrivateKey: "client-priv",
|
||||
ClientIP: "100.64.0.2",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import React from "react";
|
||||
|
||||
type AsciinemaPlayerProps = {
|
||||
src: string;
|
||||
className?: string;
|
||||
hideControls?: boolean;
|
||||
|
||||
cols?: string;
|
||||
rows?: string;
|
||||
@@ -18,10 +20,67 @@ type AsciinemaPlayerProps = {
|
||||
fontSize?: string;
|
||||
};
|
||||
|
||||
function AsciinemaPlayer({ src, ...asciinemaOptions }: AsciinemaPlayerProps) {
|
||||
function AsciinemaPlayer({
|
||||
src,
|
||||
className,
|
||||
hideControls,
|
||||
cols,
|
||||
rows,
|
||||
autoPlay,
|
||||
preload,
|
||||
loop,
|
||||
startAt,
|
||||
speed,
|
||||
idleTimeLimit,
|
||||
theme,
|
||||
poster,
|
||||
fit,
|
||||
fontSize,
|
||||
}: AsciinemaPlayerProps) {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(false);
|
||||
const [player, setPlayer] =
|
||||
React.useState<typeof import("asciinema-player")>();
|
||||
const asciinemaOptions = React.useMemo(
|
||||
() => ({
|
||||
autoPlay: prefersReducedMotion ? false : autoPlay,
|
||||
cols,
|
||||
controls: hideControls ? false : undefined,
|
||||
fit,
|
||||
fontSize,
|
||||
idleTimeLimit,
|
||||
loop: prefersReducedMotion ? false : loop,
|
||||
poster,
|
||||
preload,
|
||||
rows,
|
||||
speed,
|
||||
startAt,
|
||||
theme,
|
||||
}),
|
||||
[
|
||||
autoPlay,
|
||||
cols,
|
||||
fit,
|
||||
fontSize,
|
||||
hideControls,
|
||||
idleTimeLimit,
|
||||
loop,
|
||||
poster,
|
||||
preload,
|
||||
prefersReducedMotion,
|
||||
rows,
|
||||
speed,
|
||||
startAt,
|
||||
theme,
|
||||
],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const syncPreference = () => setPrefersReducedMotion(mediaQuery.matches);
|
||||
syncPreference();
|
||||
mediaQuery.addEventListener("change", syncPreference);
|
||||
return () => mediaQuery.removeEventListener("change", syncPreference);
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
import("asciinema-player").then((p) => {
|
||||
setPlayer(p);
|
||||
@@ -35,7 +94,17 @@ function AsciinemaPlayer({ src, ...asciinemaOptions }: AsciinemaPlayerProps) {
|
||||
};
|
||||
}, [src, player, asciinemaOptions]);
|
||||
|
||||
return <div ref={ref} />;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={[
|
||||
className,
|
||||
hideControls ? "sliver-asciinema-controls-hidden" : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default AsciinemaPlayer;
|
||||
@@ -60,11 +60,8 @@ const CodeViewer = (props: CodeViewerProps) => {
|
||||
props.script.source_code
|
||||
);
|
||||
const [fontSize, setFontSize] = React.useState(props.fontSize || 13);
|
||||
const editorContainerClassName = React.useMemo(() => {
|
||||
return theme === Themes.DARK
|
||||
? "col-span-12 mt-4 rounded-2xl overflow-hidden"
|
||||
: "col-span-12 mt-4 rounded-2xl overflow-hidden border border-gray-300";
|
||||
}, [theme]);
|
||||
const editorContainerClassName =
|
||||
"col-span-12 mt-4 overflow-hidden rounded-2xl border border-separator bg-surface-secondary";
|
||||
|
||||
return (
|
||||
<div className={editorContainerClassName}>
|
||||
|
||||
@@ -3,9 +3,9 @@ import React from "react";
|
||||
|
||||
const LoadingState: React.FC = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center gap-3 text-foreground">
|
||||
<div className="flex min-h-[55vh] w-full items-center justify-center gap-3 text-muted">
|
||||
<Spinner aria-label="Loading" />
|
||||
<span>Loading...</span>
|
||||
<span className="text-sm">Loading documentation…</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import Youtube from "./youtube";
|
||||
|
||||
export type MarkdownProps = {
|
||||
markdown: string;
|
||||
demoteTopLevelHeading?: boolean;
|
||||
};
|
||||
|
||||
type MarkdownAsciiCast = {
|
||||
@@ -121,12 +122,12 @@ const slugify = (value: string) => {
|
||||
};
|
||||
|
||||
const headingClassNames: Record<HeadingLevel, string> = {
|
||||
1: "mt-12 text-3xl font-semibold tracking-tight text-slate-900 first:mt-0 dark:text-slate-100",
|
||||
2: "mt-10 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-100",
|
||||
3: "mt-8 text-xl font-semibold tracking-tight text-slate-900 dark:text-slate-100",
|
||||
4: "mt-6 text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100",
|
||||
5: "mt-6 text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100",
|
||||
6: "mt-6 text-base font-semibold uppercase tracking-wide text-slate-700 dark:text-slate-300",
|
||||
1: "mt-12 text-3xl font-semibold tracking-tight text-foreground first:mt-0",
|
||||
2: "mt-12 text-2xl font-semibold tracking-tight text-foreground",
|
||||
3: "mt-9 text-xl font-semibold tracking-tight text-foreground",
|
||||
4: "mt-8 text-lg font-semibold tracking-tight text-foreground",
|
||||
5: "mt-7 text-base font-semibold tracking-tight text-foreground",
|
||||
6: "mt-7 text-base font-semibold tracking-tight text-foreground",
|
||||
};
|
||||
|
||||
const MarkdownViewer = (props: MarkdownProps) => {
|
||||
@@ -218,7 +219,9 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
}
|
||||
|
||||
const anchor = anchorRef.current;
|
||||
const HeadingTag = `h${level}`;
|
||||
const renderedLevel =
|
||||
level === 1 && props.demoteTopLevelHeading ? 2 : level;
|
||||
const HeadingTag = `h${renderedLevel}`;
|
||||
|
||||
return createElement(
|
||||
HeadingTag,
|
||||
@@ -226,7 +229,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
...rest,
|
||||
id: anchor || undefined,
|
||||
className: mergeClassNames(
|
||||
headingClassNames[level],
|
||||
headingClassNames[renderedLevel],
|
||||
"scroll-mt-[70px]",
|
||||
className
|
||||
),
|
||||
@@ -236,8 +239,8 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
{anchor && (
|
||||
<a
|
||||
href={`#${anchor}`}
|
||||
aria-label="Copy link to section"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-slate-400 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 hover:text-primary dark:text-slate-500"
|
||||
aria-label={`Link to ${textContent}`}
|
||||
className="-my-2 inline-flex size-10 items-center justify-center rounded-xl text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-secondary hover:text-accent"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -283,12 +286,12 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
h5: createHeadingComponent(5),
|
||||
h6: createHeadingComponent(6),
|
||||
};
|
||||
}, [getAnchor]);
|
||||
}, [getAnchor, props.demoteTopLevelHeading]);
|
||||
|
||||
const proseClassName = mergeClassNames(
|
||||
"markdown-body prose max-w-none leading-7",
|
||||
theme === Themes.DARK ? "dark:prose-invert prose-slate" : "prose-slate",
|
||||
"prose-pre:bg-slate-950/90 prose-pre:text-slate-100 prose-code:font-mono prose-img:rounded-xl"
|
||||
"prose-code:font-mono prose-img:rounded-2xl"
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -307,7 +310,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
{...rest}
|
||||
href={href}
|
||||
className={mergeClassNames(
|
||||
"font-medium text-primary transition-colors duration-150 hover:text-primary-dark",
|
||||
"font-medium text-accent underline-offset-4 hover:underline",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@@ -342,7 +345,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
}
|
||||
|
||||
const anchorClassName = mergeClassNames(
|
||||
"font-medium text-primary transition-colors duration-150 underline-offset-4 hover:text-primary-dark",
|
||||
"font-medium text-accent underline-offset-4 hover:underline",
|
||||
className
|
||||
);
|
||||
|
||||
@@ -376,7 +379,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<p
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"my-6 text-base leading-7 text-slate-700 dark:text-slate-300",
|
||||
"my-6 text-base leading-7 text-foreground/80",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -391,7 +394,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<ul
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"my-6 list-disc space-y-2 pl-6 text-slate-700 marker:text-primary dark:text-slate-300",
|
||||
"my-6 list-disc space-y-2 pl-6 text-foreground/80 marker:text-accent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -406,7 +409,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<ol
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"my-6 list-decimal space-y-2 pl-6 text-slate-700 marker:text-primary dark:text-slate-300",
|
||||
"my-6 list-decimal space-y-2 pl-6 text-foreground/80 marker:text-accent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -421,7 +424,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<li
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"leading-6 text-slate-700 dark:text-slate-300",
|
||||
"leading-6 text-foreground/80",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -436,7 +439,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<blockquote
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"my-8 border-l-4 border-slate-200 bg-slate-50 px-6 py-4 text-base italic text-slate-700 shadow-sm dark:border-slate-700 dark:bg-slate-900/50 dark:text-slate-300",
|
||||
"my-8 rounded-r-2xl border-l-4 border-accent/40 bg-surface-secondary px-6 py-4 text-base italic text-foreground/80",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -451,7 +454,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<hr
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"my-12 border-t border-slate-200 dark:border-slate-800",
|
||||
"my-12 border-t border-separator",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
@@ -461,11 +464,11 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
table(tableProps) {
|
||||
const { className, children, ...rest } = tableProps;
|
||||
return (
|
||||
<div className="my-8 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950/40">
|
||||
<div className="sliver-scrollbar my-8 overflow-x-auto rounded-2xl border border-separator bg-surface">
|
||||
<table
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"w-full min-w-max divide-y divide-slate-200 text-left text-sm dark:divide-slate-800",
|
||||
"w-full min-w-max divide-y divide-separator text-left text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -481,7 +484,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<thead
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"bg-slate-50 text-sm font-semibold uppercase tracking-wide text-slate-600 dark:bg-slate-900/60 dark:text-slate-300",
|
||||
"bg-surface-secondary text-sm font-semibold text-muted",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -496,7 +499,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<tbody
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"divide-y divide-slate-200 dark:divide-slate-800",
|
||||
"divide-y divide-separator",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -511,7 +514,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<tr
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"transition-colors hover:bg-slate-50 dark:hover:bg-slate-900/60",
|
||||
"bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -526,7 +529,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<th
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"px-4 py-3 text-left text-sm font-semibold text-slate-600 dark:text-slate-200",
|
||||
"px-4 py-3 text-left text-sm font-semibold text-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -541,7 +544,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<td
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"px-4 py-3 align-top text-sm text-slate-700 dark:text-slate-300",
|
||||
"px-4 py-3 align-top text-sm text-foreground/80",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -556,7 +559,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<strong
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"font-semibold text-slate-900 dark:text-slate-100",
|
||||
"font-semibold text-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -571,7 +574,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<em
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"text-slate-700 dark:text-slate-300",
|
||||
"text-foreground/80",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -605,7 +608,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<pre
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"my-6 overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-[13px] leading-6 text-slate-900 shadow-inner dark:border-slate-800 dark:bg-slate-950/90 dark:text-slate-100",
|
||||
"sliver-scrollbar my-6 overflow-x-auto rounded-2xl border border-separator bg-surface-secondary p-4 text-[13px] leading-6 text-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -625,7 +628,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
width={1200}
|
||||
height={720}
|
||||
className={mergeClassNames(
|
||||
"my-8 w-full rounded-xl border border-slate-200 object-contain shadow-sm dark:border-slate-800",
|
||||
"my-8 w-full rounded-2xl border border-separator/70 object-contain",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
@@ -655,7 +658,11 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
|
||||
if (normalizedLang === "youtube") {
|
||||
const embedId = sourceCode || "";
|
||||
return <Youtube embedId={embedId.trim()} />;
|
||||
return (
|
||||
<div className="not-prose my-8 overflow-hidden rounded-2xl">
|
||||
<Youtube embedId={embedId.trim()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedLang === "asciinema") {
|
||||
@@ -668,15 +675,18 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<AsciinemaPlayer
|
||||
src={srcUrl.toString()}
|
||||
rows={asciiCast.rows || "18"}
|
||||
cols={asciiCast.cols || "75"}
|
||||
idleTimeLimit={asciiCast.idleTimeLimit || 2}
|
||||
preload={true}
|
||||
autoPlay={true}
|
||||
loop={true}
|
||||
/>
|
||||
<div className="sliver-terminal-frame not-prose my-8 w-full max-w-full overflow-x-auto rounded-2xl bg-[#111315]">
|
||||
<AsciinemaPlayer
|
||||
className="min-w-[42rem]"
|
||||
src={srcUrl.toString()}
|
||||
rows={asciiCast.rows || "18"}
|
||||
cols={asciiCast.cols || "75"}
|
||||
idleTimeLimit={asciiCast.idleTimeLimit || 2}
|
||||
preload={true}
|
||||
autoPlay={true}
|
||||
loop={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -694,7 +704,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
<code
|
||||
{...rest}
|
||||
className={mergeClassNames(
|
||||
"rounded-md bg-slate-100 px-1.5 py-0.5 font-mono text-[13px] text-slate-700 dark:bg-slate-800/80 dark:text-slate-200",
|
||||
"rounded-md bg-surface-secondary px-1.5 py-0.5 font-mono text-[13px] text-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -752,7 +762,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
|
||||
const formattedSourceCode = sourceCode.replace(/\n$/, "");
|
||||
|
||||
const preWrapperClassName = mergeClassNames(
|
||||
"not-prose mt-4 overflow-x-auto rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-[13px] leading-6 text-slate-900 shadow-sm dark:border-slate-800 dark:bg-slate-950/90 dark:text-slate-100",
|
||||
"sliver-scrollbar not-prose mt-4 overflow-x-auto rounded-2xl border border-separator bg-surface-secondary px-4 py-4 text-[13px] leading-6 text-foreground",
|
||||
className
|
||||
);
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ const Mermaid = ({ diagram, minHeight }: MermaidProps) => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="not-prose my-6 text-sm text-rose-700 dark:text-rose-300">
|
||||
<div className="not-prose my-6 rounded-2xl bg-danger-soft p-4 text-sm text-danger-soft-foreground">
|
||||
Failed to render Mermaid diagram: {error}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,24 +5,28 @@ import { Themes } from "@/util/themes";
|
||||
import { faDiscord, faGithub } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faBars,
|
||||
faHome,
|
||||
faMoon,
|
||||
faSearch,
|
||||
faSun,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button, SearchField, Tooltip } from "@heroui/react";
|
||||
import { Button, Drawer, SearchField, Tooltip } from "@heroui/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import NextLink from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
const routes = [
|
||||
{ href: "/docs", label: "Docs" },
|
||||
{ href: "/tutorials", label: "Tutorials" },
|
||||
{ href: "/talks", label: "Talks" },
|
||||
{ href: "/docs", label: "Docs" },
|
||||
];
|
||||
|
||||
const discordUrl =
|
||||
"https://discord.com/channels/791066041198968873/1339996286514106409";
|
||||
const githubUrl = "https://github.com/BishopFox/sliver";
|
||||
|
||||
export default function TopNavbar() {
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -30,9 +34,28 @@ export default function TopNavbar() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
||||
|
||||
const activeTheme = theme || Themes.DARK;
|
||||
const lightDarkModeIcon = React.useMemo(() => {
|
||||
return activeTheme === Themes.DARK ? faSun : faMoon;
|
||||
}, [activeTheme]);
|
||||
const isDarkTheme = activeTheme === Themes.DARK;
|
||||
const lightDarkModeIcon = isDarkTheme ? faSun : faMoon;
|
||||
const themeToggleLabel = isDarkTheme
|
||||
? "Switch to light mode"
|
||||
: "Switch to dark mode";
|
||||
|
||||
const toggleTheme = React.useCallback(() => {
|
||||
setTheme(isDarkTheme ? Themes.LIGHT : Themes.DARK);
|
||||
}, [isDarkTheme, setTheme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const desktopBreakpoint = window.matchMedia("(min-width: 768px)");
|
||||
const closeMobileMenu = (event: MediaQueryListEvent) => {
|
||||
if (event.matches) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
desktopBreakpoint.addEventListener("change", closeMobileMenu);
|
||||
return () =>
|
||||
desktopBreakpoint.removeEventListener("change", closeMobileMenu);
|
||||
}, []);
|
||||
|
||||
const handleSearchSubmit = React.useCallback(
|
||||
(value = query) => {
|
||||
@@ -41,7 +64,11 @@ export default function TopNavbar() {
|
||||
if (searchQuery.length === 0) {
|
||||
return;
|
||||
}
|
||||
router.push({ pathname: "/search/", query: { search: searchQuery } });
|
||||
|
||||
void router.push({
|
||||
pathname: "/search/",
|
||||
query: { search: searchQuery },
|
||||
});
|
||||
setQuery("");
|
||||
setIsMobileMenuOpen(false);
|
||||
},
|
||||
@@ -58,12 +85,13 @@ export default function TopNavbar() {
|
||||
[],
|
||||
);
|
||||
|
||||
const renderSearchInput = (wrapperClassName?: string) => (
|
||||
const renderSearchInput = (className?: string) => (
|
||||
<SearchField
|
||||
aria-label="Search documentation"
|
||||
className={wrapperClassName}
|
||||
className={className}
|
||||
fullWidth
|
||||
value={query}
|
||||
variant="secondary"
|
||||
onChange={setQuery}
|
||||
onSubmit={handleSearchSubmit}
|
||||
>
|
||||
@@ -71,8 +99,10 @@ export default function TopNavbar() {
|
||||
<SearchField.SearchIcon>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
</SearchField.SearchIcon>
|
||||
<SearchField.Input placeholder="Search..." />
|
||||
{query.length > 0 ? <SearchField.ClearButton aria-label="Clear search" /> : null}
|
||||
<SearchField.Input placeholder="Search documentation…" />
|
||||
{query.length > 0 ? (
|
||||
<SearchField.ClearButton aria-label="Clear search" />
|
||||
) : null}
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
);
|
||||
@@ -82,82 +112,80 @@ export default function TopNavbar() {
|
||||
|
||||
return (
|
||||
<li key={href}>
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
onPress={() => router.push(href)}
|
||||
<NextLink
|
||||
href={href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`inline-flex h-10 items-center justify-center rounded-xl px-4 text-sm font-medium no-underline outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
isActive
|
||||
? "bg-surface-secondary text-foreground shadow-surface"
|
||||
: "text-muted hover:bg-surface-secondary hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</NextLink>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMobileRoute = (href: string, label: string) => {
|
||||
const isActive = href === "/" ? router.pathname === "/" : router.pathname.startsWith(href);
|
||||
const isActive =
|
||||
href === "/"
|
||||
? router.pathname === "/"
|
||||
: router.pathname.startsWith(href);
|
||||
|
||||
return (
|
||||
<Button
|
||||
<NextLink
|
||||
key={href}
|
||||
fullWidth
|
||||
variant={isActive ? "primary" : "ghost"}
|
||||
onPress={() => {
|
||||
router.push(href);
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
href={href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`inline-flex min-h-10 w-full items-center rounded-xl px-3 py-2 text-sm font-medium no-underline outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
isActive
|
||||
? "bg-surface-secondary text-foreground shadow-surface"
|
||||
: "text-muted hover:bg-surface-secondary hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{href === "/" ? <FontAwesomeIcon fixedWidth icon={faHome} /> : null}
|
||||
{label}
|
||||
</Button>
|
||||
</NextLink>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="sliver-navbar-glass sticky top-0 z-40 px-4 shadow-sm md:px-6">
|
||||
<div className="flex h-16 items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
onClick={() => {
|
||||
router.push("/");
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
aria-label="Sliver C2 home"
|
||||
<nav
|
||||
aria-label="Primary navigation"
|
||||
className="sticky top-0 z-40 h-16 border-b border-separator bg-background/85 backdrop-blur-xl"
|
||||
>
|
||||
<div className="mx-auto flex h-16 w-full max-w-[1440px] items-center gap-3 px-4 sm:px-6 lg:px-8">
|
||||
<NextLink
|
||||
href="/"
|
||||
aria-label="Sliver documentation home"
|
||||
className="-ml-2 inline-flex h-10 shrink-0 items-center gap-2 rounded-xl px-2 text-sm font-semibold text-foreground no-underline outline-none hover:bg-surface-secondary focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<SliversIcon />
|
||||
<span className="hidden font-bold text-inherit sm:block">
|
||||
Sliver C2
|
||||
<span className="flex size-8 items-center justify-center rounded-xl bg-accent text-accent-foreground">
|
||||
<SliversIcon height={18} />
|
||||
</span>
|
||||
</button>
|
||||
<span className="hidden items-baseline gap-1.5 sm:flex">
|
||||
<span>Sliver C2</span>
|
||||
</span>
|
||||
</NextLink>
|
||||
|
||||
<ul className="hidden items-center gap-2 md:flex">
|
||||
<li>
|
||||
{renderTooltip(
|
||||
"Home",
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="Home"
|
||||
variant={router.pathname === "/" ? "secondary" : "ghost"}
|
||||
onPress={() => router.push("/")}
|
||||
>
|
||||
<FontAwesomeIcon fixedWidth icon={faHome} />
|
||||
</Button>,
|
||||
)}
|
||||
</li>
|
||||
{routes.map((route) => renderDesktopRoute(route.href, route.label))}
|
||||
<ul className="hidden items-center gap-1 md:flex">
|
||||
{routes.map((route) =>
|
||||
renderDesktopRoute(route.href, route.label),
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="ml-auto hidden items-center gap-2 md:flex">
|
||||
{renderSearchInput("w-64")}
|
||||
<div className="ml-auto hidden items-center gap-1 md:flex">
|
||||
{renderSearchInput("mr-2 w-56 xl:w-72")}
|
||||
|
||||
{renderTooltip(
|
||||
activeTheme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode",
|
||||
themeToggleLabel,
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label={activeTheme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode"}
|
||||
aria-label={themeToggleLabel}
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
setTheme(activeTheme === Themes.DARK ? Themes.LIGHT : Themes.DARK);
|
||||
}}
|
||||
onPress={toggleTheme}
|
||||
>
|
||||
<FontAwesomeIcon icon={lightDarkModeIcon} />
|
||||
</Button>,
|
||||
@@ -165,62 +193,55 @@ export default function TopNavbar() {
|
||||
|
||||
{renderTooltip(
|
||||
"Join Discord",
|
||||
<Button
|
||||
isIconOnly
|
||||
<a
|
||||
href={discordUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Join Discord"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
window.open(
|
||||
"https://discord.com/channels/791066041198968873/1339996286514106409",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}}
|
||||
className="inline-flex size-10 items-center justify-center rounded-xl text-muted no-underline outline-none hover:bg-surface-secondary hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDiscord} />
|
||||
</Button>,
|
||||
</a>,
|
||||
)}
|
||||
|
||||
{renderTooltip(
|
||||
"View on GitHub",
|
||||
<Button
|
||||
isIconOnly
|
||||
<a
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="View on GitHub"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
window.open(
|
||||
"https://github.com/BishopFox/sliver",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}}
|
||||
className="inline-flex size-10 items-center justify-center rounded-xl text-muted no-underline outline-none hover:bg-surface-secondary hover:text-foreground focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGithub} />
|
||||
</Button>,
|
||||
</a>,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 md:hidden">
|
||||
<div className="ml-auto flex items-center gap-1 md:hidden">
|
||||
{renderTooltip(
|
||||
activeTheme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode",
|
||||
themeToggleLabel,
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label={activeTheme === Themes.DARK ? "Switch to light mode" : "Switch to dark mode"}
|
||||
aria-label={themeToggleLabel}
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
setTheme(activeTheme === Themes.DARK ? Themes.LIGHT : Themes.DARK);
|
||||
}}
|
||||
onPress={toggleTheme}
|
||||
>
|
||||
<FontAwesomeIcon icon={lightDarkModeIcon} />
|
||||
</Button>,
|
||||
)}
|
||||
|
||||
{renderTooltip(
|
||||
isMobileMenuOpen ? "Close menu" : "Open menu",
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="ghost"
|
||||
aria-controls="mobile-navigation"
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
|
||||
onPress={() => setIsMobileMenuOpen((current) => !current)}
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
setIsMobileMenuOpen((current) => !current)
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMobileMenuOpen ? faXmark : faBars} />
|
||||
</Button>,
|
||||
@@ -229,43 +250,56 @@ export default function TopNavbar() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{isMobileMenuOpen ? (
|
||||
<div className="sliver-navbar-glass px-4 pb-4 shadow-sm md:hidden">
|
||||
<div className="mt-3">{renderSearchInput("w-full")}</div>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{renderMobileRoute("/", "Home")}
|
||||
{routes.map((route) => renderMobileRoute(route.href, route.label))}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
window.open(
|
||||
"https://discord.com/channels/791066041198968873/1339996286514106409",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDiscord} />
|
||||
Discord
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
window.open(
|
||||
"https://github.com/BishopFox/sliver",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGithub} />
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Drawer.Backdrop
|
||||
isOpen={isMobileMenuOpen}
|
||||
variant="blur"
|
||||
onOpenChange={setIsMobileMenuOpen}
|
||||
>
|
||||
<Drawer.Content placement="right">
|
||||
<Drawer.Dialog
|
||||
id="mobile-navigation"
|
||||
className="w-[min(24rem,calc(100vw-1.5rem))]"
|
||||
>
|
||||
<Drawer.CloseTrigger />
|
||||
<Drawer.Header>
|
||||
<Drawer.Heading>Navigate Sliver Docs</Drawer.Heading>
|
||||
</Drawer.Header>
|
||||
<Drawer.Body className="flex flex-col gap-5 pt-2">
|
||||
{renderSearchInput("w-full")}
|
||||
|
||||
<nav
|
||||
aria-label="Mobile navigation"
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{renderMobileRoute("/", "Home")}
|
||||
{routes.map((route) =>
|
||||
renderMobileRoute(route.href, route.label),
|
||||
)}
|
||||
</nav>
|
||||
</Drawer.Body>
|
||||
<Drawer.Footer className="grid grid-cols-2 gap-2">
|
||||
<a
|
||||
href={discordUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-separator px-4 text-sm font-medium text-foreground no-underline outline-none hover:bg-surface-secondary focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<FontAwesomeIcon icon={faDiscord} />
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-separator px-4 text-sm font-medium text-foreground no-underline outline-none hover:bg-surface-secondary focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGithub} />
|
||||
GitHub
|
||||
</a>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Dialog>
|
||||
</Drawer.Content>
|
||||
</Drawer.Backdrop>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import AsciinemaPlayer from "./asciinema";
|
||||
|
||||
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import styles from "./TutorialCard.module.css";
|
||||
|
||||
export type TutorialCardCardProps = {
|
||||
name: string;
|
||||
@@ -13,6 +12,7 @@ export type TutorialCardCardProps = {
|
||||
rows?: string;
|
||||
cols?: string;
|
||||
idleTimeLimit?: number;
|
||||
hideControls?: boolean;
|
||||
|
||||
italicDescription?: boolean;
|
||||
onPress?: () => void;
|
||||
@@ -21,55 +21,48 @@ export type TutorialCardCardProps = {
|
||||
};
|
||||
|
||||
export default function TutorialCard(props: TutorialCardCardProps) {
|
||||
const actionButtonClassName =
|
||||
"ml-auto h-auto min-h-0 shrink-0 px-0 py-0 text-sm font-semibold [--button-bg-hover:transparent] [--button-bg-pressed:transparent] [--button-bg:transparent] [--button-fg:var(--warning)]";
|
||||
|
||||
return (
|
||||
<Card className="relative z-0 gap-0 overflow-hidden p-0">
|
||||
<Card.Header className="absolute left-3 right-3 top-3 z-10 flex-col items-end p-0 text-right">
|
||||
<p className="text-sm font-bold uppercase leading-none text-white/70">
|
||||
{props.name}
|
||||
</p>
|
||||
</Card.Header>
|
||||
|
||||
<div className={`${styles["hide-control-bar"]} overflow-hidden`}>
|
||||
<Card className="relative h-full min-w-0 gap-0 overflow-hidden bg-black p-0 shadow-surface">
|
||||
<div className="sliver-terminal-frame relative w-full max-w-full overflow-x-auto bg-[#111315]">
|
||||
<AsciinemaPlayer
|
||||
className="min-w-[36rem]"
|
||||
src={props.asciiCast}
|
||||
rows={props.rows ? props.rows : "18"}
|
||||
cols={props.cols ? props.cols : "75"}
|
||||
idleTimeLimit={props.idleTimeLimit ? props.idleTimeLimit : 2}
|
||||
hideControls={props.hideControls}
|
||||
rows={props.rows || "18"}
|
||||
cols={props.cols || "75"}
|
||||
idleTimeLimit={props.idleTimeLimit || 2}
|
||||
preload={true}
|
||||
autoPlay={true}
|
||||
loop={true}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-3 top-3 z-10 flex justify-end">
|
||||
<h3 className="max-w-[80%] rounded-xl border border-white/15 bg-black/55 px-3 py-2 text-right text-sm font-semibold text-white/85 shadow-lg backdrop-blur-xl">
|
||||
{props.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Footer className="absolute bottom-0 left-0 right-0 z-10 border-t border-white/20 bg-black/40 p-3 backdrop-blur-md">
|
||||
<div className="flex min-h-10 w-full flex-row items-center gap-4">
|
||||
<p
|
||||
className={
|
||||
props.italicDescription
|
||||
? "min-w-0 flex-1 text-sm leading-snug text-white/80 italic"
|
||||
: "min-w-0 flex-1 text-sm leading-snug text-white/80"
|
||||
}
|
||||
>
|
||||
{props.description}
|
||||
</p>
|
||||
<Card.Footer className="absolute inset-x-3 bottom-10 z-10 flex-row items-center gap-3 rounded-2xl border border-white/15 bg-black/55 px-3 py-2.5 text-white shadow-xl backdrop-blur-xl">
|
||||
<p
|
||||
className={`min-w-0 flex-1 text-sm leading-5 text-white/70 ${
|
||||
props.italicDescription ? "italic" : ""
|
||||
}`}
|
||||
>
|
||||
{props.description}
|
||||
</p>
|
||||
|
||||
{props.showButton ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={props.onPress}
|
||||
className={actionButtonClassName}
|
||||
>
|
||||
{props.buttonText ? props.buttonText : "Read Tutorial"}
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
{props.showButton ? (
|
||||
<Button
|
||||
className="shrink-0 text-white hover:bg-white/10"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={props.onPress}
|
||||
>
|
||||
{props.buttonText || "Read tutorial"}
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Button>
|
||||
) : null}
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Generated
+270
-210
@@ -22,7 +22,7 @@
|
||||
"asciinema-player": "^3.15.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"lunr": "^2.3.9",
|
||||
"mermaid": "^11.15.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.5",
|
||||
@@ -867,9 +867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -879,19 +879,19 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -901,19 +901,38 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -927,9 +946,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -943,9 +962,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -962,9 +981,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -981,9 +1000,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1000,9 +1019,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1019,9 +1038,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1038,9 +1057,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1057,9 +1076,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1076,9 +1095,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1095,9 +1114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1110,19 +1129,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1135,19 +1154,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1160,19 +1179,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1185,19 +1204,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1210,19 +1229,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1235,19 +1254,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1260,19 +1279,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1285,38 +1304,64 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1326,16 +1371,16 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1345,16 +1390,16 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1364,7 +1409,7 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
@@ -1453,12 +1498,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
|
||||
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
|
||||
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "~11.1.1"
|
||||
"@chevrotain/types": "~11.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
@@ -1503,9 +1548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
|
||||
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz",
|
||||
"integrity": "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1519,9 +1564,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
|
||||
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.11.tgz",
|
||||
"integrity": "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1535,9 +1580,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
|
||||
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.11.tgz",
|
||||
"integrity": "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1551,9 +1596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
|
||||
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.11.tgz",
|
||||
"integrity": "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1570,9 +1615,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
|
||||
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.11.tgz",
|
||||
"integrity": "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1589,9 +1634,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
|
||||
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.11.tgz",
|
||||
"integrity": "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1608,9 +1653,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
|
||||
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.11.tgz",
|
||||
"integrity": "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1627,9 +1672,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
|
||||
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.11.tgz",
|
||||
"integrity": "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1643,9 +1688,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
|
||||
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.11.tgz",
|
||||
"integrity": "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2977,16 +3022,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
@@ -3818,9 +3863,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4895,9 +4940,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -6821,10 +6866,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -7652,26 +7707,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.15.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
|
||||
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
|
||||
"version": "11.16.1",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz",
|
||||
"integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@mermaid-js/parser": "^1.1.1",
|
||||
"@mermaid-js/parser": "^1.2.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@upsetjs/venn.js": "^2.0.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape": "^3.33.3",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.3.3",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"katex": "^0.16.25",
|
||||
"katex": "^0.16.45",
|
||||
"khroma": "^2.1.0",
|
||||
"marked": "^16.3.0",
|
||||
"roughjs": "^4.6.6",
|
||||
@@ -8311,9 +8366,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8352,12 +8407,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.2.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
|
||||
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.11.tgz",
|
||||
"integrity": "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.2.6",
|
||||
"@next/env": "16.2.11",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -8371,14 +8426,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.2.6",
|
||||
"@next/swc-darwin-x64": "16.2.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.6",
|
||||
"@next/swc-linux-arm64-musl": "16.2.6",
|
||||
"@next/swc-linux-x64-gnu": "16.2.6",
|
||||
"@next/swc-linux-x64-musl": "16.2.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.6",
|
||||
"@next/swc-win32-x64-msvc": "16.2.6",
|
||||
"@next/swc-darwin-arm64": "16.2.11",
|
||||
"@next/swc-darwin-x64": "16.2.11",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.11",
|
||||
"@next/swc-linux-arm64-musl": "16.2.11",
|
||||
"@next/swc-linux-x64-gnu": "16.2.11",
|
||||
"@next/swc-linux-x64-musl": "16.2.11",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.11",
|
||||
"@next/swc-win32-x64-msvc": "16.2.11",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -8765,9 +8820,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -8784,7 +8839,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -9427,54 +9482,59 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
@@ -9508,9 +9568,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
|
||||
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"asciinema-player": "^3.15.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"lunr": "^2.3.9",
|
||||
"mermaid": "^11.15.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.5",
|
||||
@@ -51,8 +51,10 @@
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "^3.4.2",
|
||||
"dompurify": "^3.4.13",
|
||||
"postcss": "^8.5.13",
|
||||
"sharp": "^0.35.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"uuid": "^14.0.0"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { fetchDocs as fetchDocsContent, fetchTutorials as fetchTutorialsContent
|
||||
import { Themes } from "@/util/themes";
|
||||
import { faExternalLink } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { RouterProvider } from "@heroui/react";
|
||||
import {
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
@@ -15,9 +16,19 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { AppProps } from "next/app";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const navigate = React.useCallback(
|
||||
(path: string) => {
|
||||
void router.push(path);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
// Initialize search
|
||||
const [search] = React.useState(() => new SearchCtx());
|
||||
|
||||
@@ -101,54 +112,50 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydrationBoundary state={pageProps.dehydratedState}>
|
||||
<SearchContext.Provider value={search}>
|
||||
<Navbar />
|
||||
<Component {...pageProps} />
|
||||
<div className="mb-16 md:mb-20"></div>
|
||||
<footer className="z-20 w-full border-t border-separator bg-surface/80 px-3 py-4 text-center shadow backdrop-blur-md dark:bg-black/75 md:fixed md:bottom-0 md:left-0 md:right-0 md:flex md:items-center md:justify-between md:px-6 md:py-4">
|
||||
<div className="flex flex-col items-center gap-2 text-xs leading-snug text-muted sm:text-sm md:flex-row md:items-center md:gap-3 md:text-left">
|
||||
<span>
|
||||
© {new Date().getFullYear()}
|
||||
<a
|
||||
href="https://bishopfox.com/"
|
||||
className="hover:underline"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Bishop Fox
|
||||
</a>
|
||||
</span>
|
||||
<span className="hidden md:inline" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className="max-w-xs md:max-w-none">
|
||||
<a
|
||||
href="https://github.com/BishopFox/sliver/pulls"
|
||||
rel="noreferrer"
|
||||
className="hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
Help improve this documentation
|
||||
<span className="ml-1 inline-block">
|
||||
<FontAwesomeIcon icon={faExternalLink} />
|
||||
<RouterProvider navigate={navigate}>
|
||||
<SearchContext.Provider value={search}>
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<Navbar />
|
||||
<main className="sliver-page-content flex-1">
|
||||
<Component {...pageProps} />
|
||||
</main>
|
||||
<footer className="sliver-fixed-footer fixed inset-x-0 bottom-0 z-30 border-t border-separator/70 bg-background/70 backdrop-blur-xl">
|
||||
<div className="mx-auto flex min-h-[var(--sliver-footer-height)] w-full max-w-7xl flex-col items-center justify-center gap-1.5 px-4 py-3 text-center text-sm text-muted sm:px-6 md:flex-row md:justify-between md:text-left lg:px-8">
|
||||
<span>
|
||||
© {new Date().getFullYear()} {" "}
|
||||
<a
|
||||
href="https://bishopfox.com/"
|
||||
className="font-medium text-foreground no-underline hover:text-accent"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Bishop Fox
|
||||
</a>
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
|
||||
<a
|
||||
href="https://github.com/BishopFox/sliver/pulls"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 no-underline hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
Improve these docs
|
||||
<FontAwesomeIcon className="text-xs" icon={faExternalLink} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/BishopFox/sliver/blob/master/LICENSE"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="no-underline hover:text-foreground"
|
||||
>
|
||||
GPLv3
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<ul className="mt-3 flex flex-wrap items-center justify-center gap-3 text-xs font-medium text-muted md:mt-0 md:gap-6 md:text-sm">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/BishopFox/sliver/blob/master/LICENSE"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
GPLv3 License
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</SearchContext.Provider>
|
||||
</SearchContext.Provider>
|
||||
</RouterProvider>
|
||||
</HydrationBoundary>
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
|
||||
@@ -3,14 +3,18 @@ import LoadingState from "@/components/loading-state";
|
||||
import { Docs } from "@/util/docs";
|
||||
import { PREBUILD_VERSION } from "@/util/__generated__/prebuild-version";
|
||||
import { fetchDocs as fetchDocsContent } from "@/util/content-fetchers";
|
||||
import { faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faBookOpen,
|
||||
faChevronRight,
|
||||
faSearch,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ListBox,
|
||||
ScrollShadow,
|
||||
SearchField,
|
||||
Separator,
|
||||
} from "@heroui/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Fuse from "fuse.js";
|
||||
@@ -20,6 +24,15 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
const featuredDocNames = [
|
||||
"Getting Started",
|
||||
"Compile from Source",
|
||||
"Architecture",
|
||||
"Armory",
|
||||
"Stagers",
|
||||
"Troubleshooting",
|
||||
];
|
||||
|
||||
const DocsIndexPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -70,13 +83,20 @@ const DocsIndexPage: NextPage = () => {
|
||||
}, [docs, name, visibleDocs]);
|
||||
|
||||
const listboxClasses =
|
||||
"p-0 gap-0 divide-y divide-separator bg-surface overflow-visible rounded-lg shadow-sm";
|
||||
"flex flex-col gap-1 overflow-visible bg-transparent p-0";
|
||||
|
||||
const featuredDocs = React.useMemo(() => {
|
||||
return featuredDocNames
|
||||
.map((featuredName) => docs?.docs.find((doc) => doc.name === featuredName))
|
||||
.filter((doc) => doc !== undefined);
|
||||
}, [docs]);
|
||||
|
||||
const renderFilterInput = (className?: string) => (
|
||||
<SearchField
|
||||
aria-label="Filter documents"
|
||||
className={className}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
value={filterValue}
|
||||
onChange={setFilterValue}
|
||||
>
|
||||
@@ -99,87 +119,144 @@ const DocsIndexPage: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Sliver Docs: {name}</title>
|
||||
<title>{name ? `${name} · Sliver Docs` : "Sliver Documentation"}</title>
|
||||
</Head>
|
||||
<div className="px-4 pt-4 lg:hidden">
|
||||
<label
|
||||
htmlFor="docs-mobile-selector"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Select a document
|
||||
</label>
|
||||
<div className="mt-2">{renderFilterInput()}</div>
|
||||
<select
|
||||
id="docs-mobile-selector"
|
||||
className="mt-3 w-full rounded-lg border border-separator bg-surface p-2 text-sm"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
const selectedName = event.target.value;
|
||||
router.push({
|
||||
pathname: "/docs",
|
||||
query: selectedName ? { name: selectedName } : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Browse documents…</option>
|
||||
{mobileDocs.map((doc) => (
|
||||
<option key={doc.name} value={doc.name}>
|
||||
{doc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12 lg:gap-8">
|
||||
<aside className="hidden lg:block lg:col-span-3">
|
||||
<div className="sticky top-16 ml-4 flex flex-col gap-3">
|
||||
{renderFilterInput("mt-2")}
|
||||
<ScrollShadow className="max-h-[calc(100vh-15rem)] sliver-scrollbar overflow-y-auto pr-1 rounded-lg">
|
||||
<ListBox
|
||||
aria-label="Toolbox Menu"
|
||||
className={listboxClasses}
|
||||
onAction={(key) => {
|
||||
router.push({
|
||||
pathname: "/docs",
|
||||
query: { name: String(key) },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{visibleDocs.map((doc) => (
|
||||
<ListBox.Item
|
||||
key={doc.name}
|
||||
id={doc.name}
|
||||
textValue={doc.name}
|
||||
className="h-12 rounded-none px-3 first:rounded-t-lg last:rounded-b-lg"
|
||||
>
|
||||
{doc.name}
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</ScrollShadow>
|
||||
<div className="mx-auto w-full max-w-[90rem] px-4 pt-8 sm:px-6 lg:px-8 lg:pt-10">
|
||||
<div className="mb-8 lg:hidden">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-9 items-center justify-center rounded-2xl bg-surface-secondary text-accent">
|
||||
<FontAwesomeIcon icon={faBookOpen} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Documentation</p>
|
||||
<p className="text-xs text-muted">Browse the reference library</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="px-4 pb-8 lg:col-span-9 lg:px-8">
|
||||
{name !== "" ? (
|
||||
<Card className="mt-2">
|
||||
<Card.Header>
|
||||
<span className="text-3xl">{name}</span>
|
||||
</Card.Header>
|
||||
<Separator />
|
||||
<Card.Content>
|
||||
<div className="mt-5">{renderFilterInput()}</div>
|
||||
<label
|
||||
htmlFor="docs-mobile-selector"
|
||||
className="mt-4 block text-sm font-medium text-foreground"
|
||||
>
|
||||
Document
|
||||
</label>
|
||||
<select
|
||||
id="docs-mobile-selector"
|
||||
className="mt-2 w-full rounded-2xl border border-transparent bg-surface-secondary px-3 py-2.5 text-sm text-foreground outline-none focus:border-accent"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
const selectedName = event.target.value;
|
||||
router.push({
|
||||
pathname: "/docs",
|
||||
query: selectedName ? { name: selectedName } : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Browse documents…</option>
|
||||
{mobileDocs.map((doc) => (
|
||||
<option key={doc.name} value={doc.name}>
|
||||
{doc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 grid-cols-1 gap-8 lg:grid-cols-[17rem_minmax(0,1fr)] lg:gap-10">
|
||||
<aside className="sliver-sticky-sidebar hidden border-r border-separator/70 pr-6 lg:block">
|
||||
<div className="flex h-full min-h-0 flex-col gap-5">
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<span className="flex size-9 items-center justify-center rounded-2xl bg-surface-secondary text-accent">
|
||||
<FontAwesomeIcon icon={faBookOpen} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Documentation</p>
|
||||
<p className="text-xs text-muted">{docs.docs.length} articles</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderFilterInput()}
|
||||
<ScrollShadow className="sliver-scrollbar min-h-0 flex-1 overscroll-contain overflow-y-auto pr-2">
|
||||
<ListBox
|
||||
aria-label="Documentation"
|
||||
className={listboxClasses}
|
||||
selectedKeys={name ? [name] : []}
|
||||
selectionMode="single"
|
||||
>
|
||||
{visibleDocs.map((doc) => (
|
||||
<ListBox.Item
|
||||
key={doc.name}
|
||||
id={doc.name}
|
||||
href={`/docs/?name=${encodeURIComponent(doc.name)}`}
|
||||
textValue={doc.name}
|
||||
className={`min-h-10 rounded-xl px-3 py-2 text-sm ${
|
||||
doc.name === name
|
||||
? "bg-surface font-medium text-foreground shadow-surface"
|
||||
: "text-muted"
|
||||
}`}
|
||||
>
|
||||
{doc.name}
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
key={name || "documentation-overview"}
|
||||
className="min-w-0 pb-16 lg:pr-4"
|
||||
>
|
||||
{name !== "" ? (
|
||||
<article className="mx-auto w-full max-w-4xl">
|
||||
<header className="mb-8 border-b border-separator/70 pb-8">
|
||||
<p className="text-sm font-medium text-accent">Reference</p>
|
||||
<h1 className="mt-2 text-balance text-4xl font-semibold tracking-tight text-foreground sm:text-5xl">
|
||||
{name}
|
||||
</h1>
|
||||
</header>
|
||||
<MarkdownViewer
|
||||
key={name}
|
||||
markdown={markdown || ""}
|
||||
demoteTopLevelHeading
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="mt-8 text-center text-2xl text-foreground/90">
|
||||
Welcome to the Sliver Wiki!
|
||||
<div className="mt-2 text-xl text-muted">
|
||||
Please select a document
|
||||
</article>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-5xl pt-4 lg:pt-8">
|
||||
<h1 className="text-sm font-medium text-accent">
|
||||
Sliver reference
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg leading-8 text-muted">
|
||||
Browse configuration, transports, extensions, payloads, and
|
||||
troubleshooting guidance for the Sliver framework.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-2">
|
||||
{featuredDocs.map((doc) => (
|
||||
<Card key={doc.name} className="h-full">
|
||||
<Card.Header>
|
||||
<Card.Title>{doc.name}</Card.Title>
|
||||
<Card.Description>
|
||||
Open this topic in the Sliver reference.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Footer className="mt-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname: "/docs",
|
||||
query: { name: doc.name },
|
||||
});
|
||||
}}
|
||||
>
|
||||
Open article
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -168,6 +168,8 @@ As the alias support relies on Sliver side loading capabilities, please make sur
|
||||
|
||||
## Extensions
|
||||
|
||||
⚠️ Extensions are currently only supported when using Windows implants, though we're actively working on MacOS/Linux support.
|
||||
Native extensions are supported by Windows implants (`386`, `amd64`, and `arm64`), Linux implants (`386`, `amd64`, and `arm64`), and macOS implants (`amd64` and `arm64`). Each manifest must include a platform-native shared library for every target it supports: `.dll` on Windows, `.so` on Linux, and `.dylib` on macOS.
|
||||
|
||||
Extensions are similar in structure to an alias, but internally work differently. An extension is an artifact of native code that is reflectively loaded by the implant and passed certain callbacks. These callbacks allow the extension to return data to the C2 server. Extensions may also have dependencies (other extensions), which Sliver will load prior to the extension; circular dependencies are not allowed. For example, all BOF extensions (`.o` files) rely on the COFF Loader extension (a `.dll`).
|
||||
|
||||
Native exports use the same Sliver extension ABI on every supported operating system. The export receives the argument buffer, its size, and a callback used to return output. The optional `init` export in the extension manifest is called once after the library is loaded and before any command export is invoked.
|
||||
@@ -5,7 +5,7 @@ This document describes the technical design of Sliver.
|
||||
There are four major components to the Sliver ecosystem:
|
||||
|
||||
- **Server Console -** The server console is the main interface, which is started when you run the `sliver-server` executable. The server console is a superset of the client console. All code is shared between the client/server consoles except server-specific commands related to client (i.e., operator) management. The server console talks over an in-memory gRPC interface to the server.
|
||||
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network in multiplayer mode, either directly over mutual TLS (mTLS) or, by default, through a WireGuard-protected wrapper that carries the same gRPC/mTLS service.
|
||||
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network in multiplayer mode, directly over mutual TLS (mTLS) by default or through an optional WireGuard-protected wrapper that carries the same gRPC/mTLS service.
|
||||
- **Sliver Client -** The client console is the primary user interface that is used to interact with the Sliver server. Note that most of the code in the server console is actually the client console. The client console can also be compiled into a separate client binary file `sliver-client`, which is generally used to connect to the multiplayer gRPC network listener.
|
||||
- **Implant -** The implant is the actual malicious code run on the target system you want remote access to.
|
||||
|
||||
@@ -17,8 +17,8 @@ There are four major components to the Sliver ecosystem:
|
||||
└─────────┘ └───────────────┘
|
||||
▲
|
||||
│
|
||||
│WireGuard (default)
|
||||
│or direct gRPC/mTLS
|
||||
│gRPC/mTLS (default)
|
||||
│or optional WireGuard wrapper
|
||||
│
|
||||
┌────┴────┐
|
||||
│ Sliver │
|
||||
|
||||
@@ -2,11 +2,11 @@ Sliver supports the loading and execution of BOFs and COFFs, generally no code c
|
||||
|
||||
### BOF Extensions
|
||||
|
||||
BOF support is provided via the [COFF Loader](https://github.com/sliverarmory/COFFLoader) extension, you'll need it installed to run pretty much any BOF. However, the COFF Loader will be installed automatically if you install a BOF extension from the [armory](/docs?name=Armory).
|
||||
New Sliver implants execute BOFs with the built-in [Reflektor](https://github.com/sliverarmory/reflektor) executor by default. Armory packages can retain a [COFF Loader](https://github.com/sliverarmory/COFFLoader) dependency as a compatibility fallback for older clients, servers, and implants; Armory installs that dependency automatically.
|
||||
|
||||
The easiest way to install a BOF extension, for example [`nanodump`](https://github.com/sliverarmory/nanodump), is using the [armory](/docs?name=Armory) package manager:
|
||||
|
||||
**IMPORTANT:** BOF extensions are still installed per-sliver client for normal operator use, so they are not shared across operators automatically. If you want the server-side AI agent to use a BOF, copy the unpacked BOF directory into `~/.sliver/ai/extensions` on the server as well, and copy any dependency such as `coff-loader` into that same server-side AI store.
|
||||
**IMPORTANT:** BOF extensions are still installed per-sliver client for normal operator use, so they are not shared across operators automatically. If you want the server-side AI agent to use a BOF, copy the unpacked BOF directory into `~/.sliver/ai/extensions` on the server as well. Copy its `depends_on` package too when the manifest retains a compatibility fallback or explicitly selects a COFF loader.
|
||||
|
||||
```
|
||||
sliver > armory install nanodump
|
||||
@@ -75,7 +75,7 @@ beacon_command_register(
|
||||
| z | zero-terminated+encoded string | `string` |
|
||||
| Z | zero-terminated wide-char string | `wstring` |
|
||||
|
||||
Looking at the script we can see the BOF requires a single integer argument. The corresponding Sliver `extension.json` file is shown below. Note that BOFs will always rely on the `coff-loader` extension but other kinds of extensions may not. If the `coff-loader` extension is not already installed on your system, it can be installed using `armory install coff-loader`. You can list installed extensions by simply running the `extensions` command with no arguments.
|
||||
Looking at the script we can see the BOF requires a single integer argument. The corresponding Sliver `extension.json` file is shown below. This manifest prefers Reflektor while retaining `coff-loader` for old-component compatibility. If the loader is not already installed, Armory installs it with the BOF package.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -87,6 +87,7 @@ Looking at the script we can see the BOF requires a single integer argument. The
|
||||
"repo_url": "https://github.com/sliverarmory/CredManBOF",
|
||||
"help": "Dump credentials using the CredsBackupCredentials API",
|
||||
"long_help": "",
|
||||
"bof_executor": "reflektor",
|
||||
"depends_on": "coff-loader",
|
||||
"entrypoint": "go",
|
||||
"files": [
|
||||
@@ -112,10 +113,19 @@ Looking at the script we can see the BOF requires a single integer argument. The
|
||||
}
|
||||
```
|
||||
|
||||
`bof_executor` accepts two values:
|
||||
|
||||
- `reflektor` prefers built-in execution when both the server and implant advertise support. If `depends_on` is present, upgraded clients use it as the fallback for an older server or implant, while old clients ignore `bof_executor` and retain their existing loader behavior.
|
||||
- `coff-loader` always uses the existing loader path. `depends_on` is required and names the concrete loader, including custom loaders.
|
||||
|
||||
When `bof_executor` is omitted, existing `.o` manifests with `depends_on` keep their legacy loader behavior. A dependency-free `.o` defaults to Reflektor and therefore requires a compatible client, server, and implant.
|
||||
|
||||
Once the manifest is defined load it into your client using `extensions load`, locally loaded extensions do not need to be cryptographically signed. The paths in the manifest should be relative to the manifest file, parent directories are not allowed.
|
||||
|
||||
If you want the server-side AI agent to discover and run the same BOF, copy that unpacked directory into `~/.sliver/ai/extensions/<name>/` so the `extension.json` file and `.o` artifact are available on the server.
|
||||
|
||||
More details can be found on the [Aliases & Extensions](/docs?name=Aliases+and+Extensions) page.
|
||||
|
||||
**IMPORTANT:** For BOF extensions to be properly detected by the Sliver client, the `path` must use the `.o` file extension.
|
||||
To execute BOF projects that ship CNA/Aggressor Script wrappers directly, see [OPFOR](/docs?name=OPFOR).
|
||||
|
||||
**IMPORTANT:** Legacy manifests should keep the `.o` file extension. An explicit `bof_executor` also identifies the command as a BOF, but older clients still rely on the `.o` suffix.
|
||||
@@ -20,7 +20,7 @@
|
||||
- https://dominicbreuker.com/post/learning_sliver_c2_01_installation/
|
||||
- https://vk9-sec.com/how-to-set-up-use-c2-sliver/
|
||||
- https://0x90-1.gitbook.io/sliver/
|
||||
- https://blog.ecapuano.com/p/so-you-want-to-be-a-soc-analyst-part-ea2
|
||||
- https://blog.ecapuano.com/p/so-you-want-to-be-a-soc-analyst-20
|
||||
- https://wsummerhill.github.io/redteam/2023/07/25/Sliver-C2-Usage-for-Red-Teams.html
|
||||
- https://rootsecdev.medium.com/hacking-active-directory-with-sliver-c2-19d7ceabbf13
|
||||
- https://dev.to/living_syn/sliver-and-cursed-chrome-for-post-exploitation-4gnk
|
||||
|
||||
@@ -2,7 +2,7 @@ You'll want to compile Sliver from a MacOS or Linux machine. Compiling from nati
|
||||
|
||||
To compile from source you'll need:
|
||||
|
||||
- Go v1.25 or later
|
||||
- Go v1.26.6 or later
|
||||
- `make` (on MacOS you may need to install XCode and accompanying cli tools)
|
||||
|
||||
```asciinema
|
||||
|
||||
@@ -55,6 +55,7 @@ cxx: {}
|
||||
- `port` - Listen port for the multiplayer listener.
|
||||
- `tailscale` - Enable Tailscale for daemon listener setup. When enabled, Sliver does not wrap multiplayer in its own WireGuard layer.
|
||||
- `enable_wg` - Wrap multiplayer in WireGuard. If omitted or false, multiplayer is exposed directly over mTLS.
|
||||
- The legacy `disable_wg` field from the withdrawn WireGuard-by-default release does not enable WireGuard and is normalized to `enable_wg: false`. Set `enable_wg: true` explicitly to opt in.
|
||||
- `logs` - Server logging options.
|
||||
- `level` - `logrus` level (`0`-`6`, clamped by Sliver). `4` is `INFO`, `5` is `DEBUG`, `6` is `TRACE`.
|
||||
- `grpc_unary_payloads` - Log gRPC unary payloads.
|
||||
@@ -142,21 +143,31 @@ For details on generating them, see [multiplayer mode](/docs?name=Multi-player+M
|
||||
"lport": 31337,
|
||||
"ca_certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
|
||||
"private_key": "-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----\n",
|
||||
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
|
||||
"wg": {
|
||||
"server_pub_key": "<multiplayer-wg-server-pubkey>",
|
||||
"client_private_key": "<operator-wg-private-key>",
|
||||
"client_pub_key": "<operator-wg-public-key>",
|
||||
"client_ip": "100.65.0.10",
|
||||
"server_ip": "100.65.0.1"
|
||||
}
|
||||
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The `operator` field is mostly a local label in the client. Server-side identity is derived from the client certificate (`CommonName`) and token material generated by the server. If you need to rename an operator, generate a new client configuration.
|
||||
|
||||
The `wg` block is optional:
|
||||
The default operator profile does not contain a `wg` block, and the client connects directly to the multiplayer mTLS listener.
|
||||
|
||||
- When present, `sliver-client` automatically brings up the multiplayer WireGuard wrapper and then dials the in-tunnel gRPC/mTLS service.
|
||||
- When absent, the client connects directly to the multiplayer mTLS listener.
|
||||
- Generate a WireGuard-enabled profile with `new-operator --enable-wg` or `sliver-server operator --enable-wg` when you want the multiplayer WireGuard wrapper.
|
||||
Multiplayer WireGuard is opt-in. Generate a WireGuard-enabled profile with `new-operator --enable-wg` or `sliver-server operator --enable-wg`; this adds the following block:
|
||||
|
||||
```json
|
||||
"wg": {
|
||||
"enabled": true,
|
||||
"server_pub_key": "<multiplayer-wg-server-pubkey>",
|
||||
"client_private_key": "<operator-wg-private-key>",
|
||||
"client_pub_key": "<operator-wg-public-key>",
|
||||
"client_ip": "100.65.0.10",
|
||||
"server_ip": "100.65.0.1"
|
||||
}
|
||||
```
|
||||
|
||||
The listener must also be started with `multiplayer --enable-wg` or `sliver-server daemon --enable-wg`. The official client reads the profile and automatically uses WireGuard only when `wg.enabled` is true; otherwise it connects directly. A profile from the withdrawn WireGuard-by-default release may contain a `wg` block without the `enabled` field. Such a profile stays in direct mode unless you deliberately force WireGuard with `sliver-client --enable-wg`.
|
||||
|
||||
The client flags override the profile selection:
|
||||
|
||||
- `sliver-client --enable-wg` forces WireGuard and requires usable tunnel settings in the selected profile.
|
||||
- `sliver-client --disable-wg` forces a direct gRPC/mTLS connection even when `wg.enabled` is true.
|
||||
- Without either flag, `sliver-client` follows `wg.enabled`. The two override flags cannot be used together.
|
||||
Loaded 100 of 1852 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user