mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-07 17:08:13 -10:00
Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aa7e5bf96 | ||
|
|
b46df4b05e | ||
|
|
cbc9200649 | ||
|
|
a0bf009c06 | ||
|
|
46f516fa3a | ||
|
|
b2c0979b63 | ||
|
|
ea5e1412b2 | ||
|
|
bd948b8ab1 | ||
|
|
937a74d597 | ||
|
|
e747187dda | ||
|
|
ec992dea76 | ||
|
|
544c872881 | ||
|
|
4701b895b7 | ||
|
|
18c56ec3c5 | ||
|
|
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 | ||
|
|
9c9943c94c | ||
|
|
2e9b52371e | ||
|
|
6d6df68972 | ||
|
|
7f863d3172 | ||
|
|
6ecb379bce | ||
|
|
b5d3d0bad6 | ||
|
|
528b04427a |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Preserve pinned upstream source and attribution files byte-for-byte.
|
||||
client/command/opfor/testdata/corpus/** -text
|
||||
client/command/opfor/testdata/corpus/operatorskit/*.cna whitespace=-trailing-space,-blank-at-eof
|
||||
vendor/github.com/sliverarmory/opfor/internal/regexp2/ATTRIB whitespace=-trailing-space
|
||||
@@ -39,10 +39,18 @@ jobs:
|
||||
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release:
|
||||
name: Release
|
||||
e2e:
|
||||
name: End-to-end test suites
|
||||
needs: tag-check
|
||||
if: needs.tag-check.outputs.tag != ''
|
||||
uses: ./.github/workflows/comprehensive-e2e.yml
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs:
|
||||
- tag-check
|
||||
- e2e
|
||||
if: needs.tag-check.outputs.tag != '' && needs.e2e.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
timeout-minutes: 45
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
name: Comprehensive e2e Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test_scope:
|
||||
description: Test scope to run across the complete platform matrix
|
||||
required: true
|
||||
default: comprehensive
|
||||
type: choice
|
||||
options:
|
||||
- comprehensive
|
||||
- rportfwd
|
||||
workflow_call:
|
||||
inputs:
|
||||
test_scope:
|
||||
description: Test scope to run across the complete platform matrix
|
||||
required: false
|
||||
default: comprehensive
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SLIVER_E2E_SCOPE: ${{ inputs.test_scope || 'comprehensive' }}
|
||||
|
||||
concurrency:
|
||||
group: comprehensive-e2e-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
name: Authorize invocation
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check actor repository permission
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
REQUESTING_ACTOR: ${{ github.triggering_actor }}
|
||||
REQUESTED_SCOPE: ${{ inputs.test_scope || 'comprehensive' }}
|
||||
with:
|
||||
script: |
|
||||
const testScope = process.env.REQUESTED_SCOPE || "comprehensive";
|
||||
if (!new Set(["comprehensive", "rportfwd"]).has(testScope)) {
|
||||
core.setFailed(`Unsupported E2E test scope: ${JSON.stringify(testScope)}`);
|
||||
return;
|
||||
}
|
||||
core.info(`Validated E2E test scope ${testScope}`);
|
||||
|
||||
const isReleaseTag = context.eventName === "push" && context.ref.startsWith("refs/tags/v");
|
||||
if (isReleaseTag) {
|
||||
core.info(`Authorized release test invocation for ${context.ref}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = process.env.REQUESTING_ACTOR || context.actor;
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: actor,
|
||||
});
|
||||
core.info(`${actor} has repository permission ${data.permission} (${data.role_name})`);
|
||||
if (data.permission !== "admin") {
|
||||
core.setFailed("Comprehensive e2e Tests may only be run by repository administrators");
|
||||
}
|
||||
|
||||
rportfwd-security-regression:
|
||||
name: Reverse-port-forward malicious protocol regression
|
||||
needs: authorize
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download Sliver build assets
|
||||
env:
|
||||
SLIVER_ROOT_DIR: ${{ runner.temp }}/sliver-rportfwd-security
|
||||
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
|
||||
|
||||
- name: Test malicious destination poisoning and cross-session isolation
|
||||
env:
|
||||
SLIVER_ROOT_DIR: ${{ runner.temp }}/sliver-rportfwd-security
|
||||
run: >-
|
||||
go test -buildvcs=false -mod=vendor
|
||||
-tags=server,osusergo,netgo,go_sqlite,sliver_e2e
|
||||
./server/c2
|
||||
-run '^TestMTLSYamux_ReversePortForwardAuthorizationCannotBePoisonedOrReplayed$'
|
||||
-count=1 -v
|
||||
|
||||
darwin:
|
||||
name: comprehensive-e2e-darwin-${{ matrix.goarch }}
|
||||
needs: authorize
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 360
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15-intel
|
||||
goarch: amd64
|
||||
server_arch: amd64
|
||||
- runner: macos-15
|
||||
goarch: arm64
|
||||
server_arch: arm64
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare coverage artifact
|
||||
run: |
|
||||
mkdir -p e2e-results
|
||||
printf 'darwin/%s\n' '${{ matrix.goarch }}' > e2e-results/target-darwin-${{ matrix.goarch }}.txt
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download Sliver build assets
|
||||
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
|
||||
|
||||
- name: Build unmodified Sliver server
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.server_arch }}
|
||||
GOOS: darwin
|
||||
run: >-
|
||||
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
|
||||
-o sliver-server ./server
|
||||
|
||||
- name: Compile comprehensive E2E driver
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOOS: darwin
|
||||
run: >-
|
||||
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
|
||||
-o sliver-comprehensive-e2e ./test/e2e
|
||||
|
||||
- name: Run selected E2E scope
|
||||
run: |
|
||||
set -o pipefail
|
||||
./sliver-comprehensive-e2e \
|
||||
-test.v \
|
||||
-test.run=^TestComprehensiveE2E$ \
|
||||
-test.timeout=0 \
|
||||
-repo . \
|
||||
-server ./sliver-server \
|
||||
-server-arch ${{ matrix.server_arch }} \
|
||||
-target-os darwin \
|
||||
-target-arch ${{ matrix.goarch }} \
|
||||
-results ./e2e-results \
|
||||
-transports mtls,wg,http \
|
||||
-implant-modes session,beacon \
|
||||
-beacon-interval 10s 2>&1 | tee ./e2e-results/selected-e2e.log
|
||||
|
||||
- name: Verify target reverse-port-forward coverage
|
||||
if: ${{ always() }}
|
||||
run: >-
|
||||
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
|
||||
-input e2e-results
|
||||
-target-os darwin
|
||||
-target-arch ${{ matrix.goarch }}
|
||||
-transports mtls,wg,http
|
||||
|
||||
- name: Upload target coverage
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: comprehensive-e2e-target-darwin-${{ matrix.goarch }}
|
||||
path: e2e-results
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 14
|
||||
|
||||
linux-native:
|
||||
name: comprehensive-e2e-linux-${{ matrix.goarch }}
|
||||
needs: authorize
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 360
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
goarch: amd64
|
||||
server_arch: amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
goarch: arm64
|
||||
server_arch: arm64
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare coverage artifact
|
||||
run: |
|
||||
mkdir -p e2e-results
|
||||
printf 'linux/%s\n' '${{ matrix.goarch }}' > e2e-results/target-linux-${{ matrix.goarch }}.txt
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download Sliver build assets
|
||||
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
|
||||
|
||||
- name: Build unmodified Sliver server
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.server_arch }}
|
||||
GOOS: linux
|
||||
run: >-
|
||||
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
|
||||
-o sliver-server ./server
|
||||
|
||||
- name: Compile comprehensive E2E driver
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOOS: linux
|
||||
run: >-
|
||||
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
|
||||
-o sliver-comprehensive-e2e ./test/e2e
|
||||
|
||||
- name: Run selected E2E scope
|
||||
run: |
|
||||
set -o pipefail
|
||||
./sliver-comprehensive-e2e \
|
||||
-test.v \
|
||||
-test.run=^TestComprehensiveE2E$ \
|
||||
-test.timeout=0 \
|
||||
-repo . \
|
||||
-server ./sliver-server \
|
||||
-server-arch ${{ matrix.server_arch }} \
|
||||
-target-os linux \
|
||||
-target-arch ${{ matrix.goarch }} \
|
||||
-results ./e2e-results \
|
||||
-transports mtls,wg,http \
|
||||
-implant-modes session,beacon \
|
||||
-beacon-interval 10s 2>&1 | tee ./e2e-results/selected-e2e.log
|
||||
|
||||
- name: Verify target reverse-port-forward coverage
|
||||
if: ${{ always() }}
|
||||
run: >-
|
||||
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
|
||||
-input e2e-results
|
||||
-target-os linux
|
||||
-target-arch ${{ matrix.goarch }}
|
||||
-transports mtls,wg,http
|
||||
|
||||
- name: Upload target coverage
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: comprehensive-e2e-target-linux-${{ matrix.goarch }}
|
||||
path: e2e-results
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 14
|
||||
|
||||
windows:
|
||||
name: comprehensive-e2e-windows-${{ matrix.goarch }}
|
||||
needs: authorize
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 360
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: windows-2022
|
||||
goarch: amd64
|
||||
server_arch: amd64
|
||||
- runner: windows-11-arm
|
||||
goarch: arm64
|
||||
server_arch: arm64
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare coverage artifact
|
||||
run: |
|
||||
mkdir -p e2e-results
|
||||
printf 'windows/%s\n' '${{ matrix.goarch }}' > e2e-results/target-windows-${{ matrix.goarch }}.txt
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download Sliver build assets
|
||||
run: go run -buildvcs=false -mod=vendor ./util/cmd/assets
|
||||
|
||||
- name: Build unmodified Sliver server
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.server_arch }}
|
||||
GOOS: windows
|
||||
run: >-
|
||||
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
|
||||
-o sliver-server.exe ./server
|
||||
|
||||
- name: Compile comprehensive E2E driver
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOOS: windows
|
||||
run: >-
|
||||
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
|
||||
-o sliver-comprehensive-e2e.exe ./test/e2e
|
||||
|
||||
- name: Run selected E2E scope
|
||||
run: |
|
||||
set -o pipefail
|
||||
./sliver-comprehensive-e2e.exe \
|
||||
-test.v \
|
||||
-test.run=^TestComprehensiveE2E$ \
|
||||
-test.timeout=0 \
|
||||
-repo . \
|
||||
-server ./sliver-server.exe \
|
||||
-server-arch ${{ matrix.server_arch }} \
|
||||
-target-os windows \
|
||||
-target-arch ${{ matrix.goarch }} \
|
||||
-results ./e2e-results \
|
||||
-transports mtls,wg,http \
|
||||
-implant-modes session,beacon \
|
||||
-beacon-interval 10s 2>&1 | tee ./e2e-results/selected-e2e.log
|
||||
|
||||
- name: Verify target reverse-port-forward coverage
|
||||
if: ${{ always() }}
|
||||
run: >-
|
||||
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
|
||||
-input e2e-results
|
||||
-target-os windows
|
||||
-target-arch ${{ matrix.goarch }}
|
||||
-transports mtls,wg,http
|
||||
|
||||
- name: Upload target coverage
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: comprehensive-e2e-target-windows-${{ matrix.goarch }}
|
||||
path: e2e-results
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 14
|
||||
|
||||
aggregate-rportfwd:
|
||||
name: Aggregate reverse-port-forward E2E coverage
|
||||
needs:
|
||||
- authorize
|
||||
- rportfwd-security-regression
|
||||
- darwin
|
||||
- linux-native
|
||||
- windows
|
||||
if: ${{ always() && needs.authorize.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download target coverage
|
||||
id: download
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: comprehensive-e2e-target-*
|
||||
path: rportfwd-coverage-input
|
||||
|
||||
- name: Prepare aggregate artifact
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
mkdir -p rportfwd-coverage-input rportfwd-coverage-summary
|
||||
printf 'Reverse-port-forward E2E coverage aggregate\n' > rportfwd-coverage-summary/README.txt
|
||||
|
||||
- name: Aggregate coverage and enforce all 162 required cells
|
||||
id: aggregate
|
||||
if: ${{ always() }}
|
||||
continue-on-error: true
|
||||
run: >-
|
||||
go run -buildvcs=false -mod=vendor ./test/e2e/rportfwd-report
|
||||
-input rportfwd-coverage-input
|
||||
-output rportfwd-coverage-summary
|
||||
|
||||
- name: Publish reverse-port-forward coverage table
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
report="rportfwd-coverage-summary/rportfwd-coverage.md"
|
||||
if [[ -s "${report}" ]]; then
|
||||
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
else
|
||||
printf 'Reverse-port-forward coverage report is missing or empty: %s\n' "${report}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
- name: Upload aggregate reverse-port-forward coverage
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: comprehensive-e2e-rportfwd-coverage-summary
|
||||
path: rportfwd-coverage-summary
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 30
|
||||
|
||||
- name: Fail on failed prerequisites, missing, malformed, or unavailable coverage
|
||||
if: ${{ needs.rportfwd-security-regression.result != 'success' || needs.darwin.result != 'success' || needs.linux-native.result != 'success' || needs.windows.result != 'success' || steps.download.outcome != 'success' || steps.aggregate.outcome != 'success' }}
|
||||
run: exit 1
|
||||
|
||||
aggregate-coverage:
|
||||
name: Aggregate comprehensive E2E coverage
|
||||
needs:
|
||||
- authorize
|
||||
- rportfwd-security-regression
|
||||
- darwin
|
||||
- linux-native
|
||||
- windows
|
||||
if: ${{ always() && needs.authorize.result == 'success' && inputs.test_scope != 'rportfwd' }}
|
||||
outputs:
|
||||
command_coverage_markdown: ${{ steps.publish_command_coverage.outputs.command_coverage_markdown }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download target coverage
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: comprehensive-e2e-target-*
|
||||
path: coverage-input
|
||||
merge-multiple: true
|
||||
|
||||
- name: Prepare aggregate artifact
|
||||
run: |
|
||||
mkdir -p coverage-input coverage-summary
|
||||
printf 'Comprehensive E2E coverage aggregate\n' > coverage-summary/README.txt
|
||||
|
||||
- name: Aggregate coverage and enforce required cells
|
||||
id: aggregate
|
||||
continue-on-error: true
|
||||
run: >-
|
||||
go run -buildvcs=false -mod=vendor ./test/e2e/report
|
||||
-input coverage-input
|
||||
-output coverage-summary
|
||||
|
||||
- name: Publish command coverage table
|
||||
id: publish_command_coverage
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
detailed_report="coverage-summary/coverage-summary.md"
|
||||
report="coverage-summary/command-coverage.md"
|
||||
if [[ ! -s "${report}" ]]; then
|
||||
printf 'Command coverage report is missing or empty: %s\n' "${report}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
delimiter="COMPREHENSIVE_E2E_COMMAND_COVERAGE_EOF"
|
||||
if grep -Fxq "${delimiter}" "${report}"; then
|
||||
printf 'Command coverage report contains the reserved output delimiter: %s\n' "${delimiter}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf 'command_coverage_markdown<<%s\n' "${delimiter}"
|
||||
cat "${report}"
|
||||
printf '%s\n' "${delimiter}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ -f "${detailed_report}" ]]; then
|
||||
cat "${detailed_report}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
printf '\n## Command coverage by OS/architecture/transport\n\n' >> "${GITHUB_STEP_SUMMARY}"
|
||||
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload aggregate coverage
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: comprehensive-e2e-coverage-summary
|
||||
path: coverage-summary
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 30
|
||||
|
||||
- name: Fail on failed prerequisites or failed or not-run coverage
|
||||
if: ${{ needs.rportfwd-security-regression.result != 'success' || needs.darwin.result != 'success' || needs.linux-native.result != 'success' || needs.windows.result != 'success' || steps.aggregate.outcome != 'success' }}
|
||||
run: exit 1
|
||||
|
||||
reflektor:
|
||||
name: Existing Reflektor test matrix
|
||||
needs: authorize
|
||||
if: ${{ inputs.test_scope != 'rportfwd' }}
|
||||
uses: ./.github/workflows/reflektor-tests.yml
|
||||
|
||||
shellcode:
|
||||
name: Shellcode E2E test matrix
|
||||
needs: authorize
|
||||
if: ${{ inputs.test_scope != 'rportfwd' }}
|
||||
uses: ./.github/workflows/shellcode-tests.yml
|
||||
@@ -20,7 +20,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -41,6 +40,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Go 1.26.6
|
||||
uses: actions/setup-go@v6
|
||||
@@ -51,6 +52,5 @@ jobs:
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.12.2
|
||||
# Keep legacy findings visible without making them blockers.
|
||||
only-new-issues: true
|
||||
args: ./client/... ./server/... ./util/... ./protobuf/... ./docs ./implant/...
|
||||
# Gate only findings introduced since the event base revision.
|
||||
args: --new-from-rev=${{ github.event.pull_request.base.sha || github.event.before }} ./client/... ./server/... ./util/... ./protobuf/... ./docs ./implant/...
|
||||
@@ -0,0 +1,164 @@
|
||||
name: Reflektor Integration Test Matrix
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
darwin:
|
||||
name: reflektor-darwin-${{ matrix.goarch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 180
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15-intel
|
||||
goarch: amd64
|
||||
server_arch: amd64
|
||||
- runner: macos-15
|
||||
goarch: arm64
|
||||
server_arch: arm64
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download Sliver build assets
|
||||
run: go run -mod=vendor ./util/cmd/assets
|
||||
|
||||
- name: Build Sliver server
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.server_arch }}
|
||||
GOOS: darwin
|
||||
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server ./server
|
||||
|
||||
- name: Build Reflektor integration driver
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOOS: darwin
|
||||
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e ./test/reflektor
|
||||
|
||||
- name: Load Sliver with Reflektor
|
||||
run: >-
|
||||
./sliver-reflektor-e2e
|
||||
-repo .
|
||||
-server ./sliver-server
|
||||
-target-os darwin
|
||||
-target-arch ${{ matrix.goarch }}
|
||||
|
||||
linux-native:
|
||||
name: reflektor-linux-${{ matrix.goarch }}-native
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 180
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
goarch: amd64
|
||||
server_arch: amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
goarch: arm64
|
||||
server_arch: arm64
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download Sliver build assets
|
||||
run: go run -mod=vendor ./util/cmd/assets
|
||||
|
||||
- name: Build Sliver server
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.server_arch }}
|
||||
GOOS: linux
|
||||
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server ./server
|
||||
|
||||
- name: Build Reflektor integration driver
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOOS: linux
|
||||
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e ./test/reflektor
|
||||
|
||||
- name: Load Sliver with Reflektor
|
||||
run: >-
|
||||
./sliver-reflektor-e2e
|
||||
-repo .
|
||||
-server ./sliver-server
|
||||
-target-os linux
|
||||
-target-arch ${{ matrix.goarch }}
|
||||
|
||||
windows:
|
||||
name: reflektor-windows-${{ matrix.goarch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 180
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: windows-2022
|
||||
goarch: amd64
|
||||
server_arch: amd64
|
||||
- runner: windows-11-arm
|
||||
goarch: arm64
|
||||
server_arch: amd64
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download Sliver build assets
|
||||
run: go run -mod=vendor ./util/cmd/assets
|
||||
|
||||
- name: Build Sliver server
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.server_arch }}
|
||||
GOOS: windows
|
||||
run: go build -mod=vendor -trimpath -tags go_sqlite,server -o sliver-server.exe ./server
|
||||
|
||||
- name: Build Reflektor integration driver
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOOS: windows
|
||||
run: go build -mod=vendor -trimpath -tags client,go_sqlite -o sliver-reflektor-e2e.exe ./test/reflektor
|
||||
|
||||
- name: Load Sliver with Reflektor
|
||||
run: >-
|
||||
./sliver-reflektor-e2e.exe
|
||||
-repo .
|
||||
-server ./sliver-server.exe
|
||||
-target-os windows
|
||||
-target-arch ${{ matrix.goarch }}
|
||||
@@ -0,0 +1,201 @@
|
||||
name: Shellcode E2E Test Matrix
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
shellcode_coverage_markdown:
|
||||
description: Shellcode E2E coverage table in Markdown
|
||||
value: ${{ jobs.aggregate.outputs.shellcode_coverage_markdown }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
target:
|
||||
name: shellcode-e2e-${{ matrix.target_os }}-${{ matrix.target_arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 360
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-26
|
||||
target_os: darwin
|
||||
target_arch: arm64
|
||||
server_os: darwin
|
||||
server_arch: arm64
|
||||
server_executable: ./sliver-server
|
||||
test_executable: ./sliver-shellcode-e2e
|
||||
- runner: ubuntu-24.04
|
||||
target_os: linux
|
||||
target_arch: amd64
|
||||
server_os: linux
|
||||
server_arch: amd64
|
||||
server_executable: ./sliver-server
|
||||
test_executable: ./sliver-shellcode-e2e
|
||||
- runner: ubuntu-24.04-arm
|
||||
target_os: linux
|
||||
target_arch: arm64
|
||||
server_os: linux
|
||||
server_arch: arm64
|
||||
server_executable: ./sliver-server
|
||||
test_executable: ./sliver-shellcode-e2e
|
||||
- runner: windows-2022
|
||||
target_os: windows
|
||||
target_arch: amd64
|
||||
server_os: windows
|
||||
server_arch: amd64
|
||||
server_executable: ./sliver-server.exe
|
||||
test_executable: ./sliver-shellcode-e2e.exe
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare target artifact
|
||||
run: |
|
||||
mkdir -p shellcode-results
|
||||
printf '%s/%s\n' \
|
||||
'${{ matrix.target_os }}' \
|
||||
'${{ matrix.target_arch }}' \
|
||||
> 'shellcode-results/target-${{ matrix.target_os }}-${{ matrix.target_arch }}.txt'
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Download Sliver build assets
|
||||
run: go run -buildvcs=false -mod=vendor -trimpath ./util/cmd/assets
|
||||
|
||||
- name: Build unmodified Sliver server
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.server_arch }}
|
||||
GOOS: ${{ matrix.server_os }}
|
||||
run: >-
|
||||
go build -buildvcs=false -mod=vendor -trimpath -tags go_sqlite,server
|
||||
-o ${{ matrix.server_executable }} ./server
|
||||
|
||||
- name: Compile shellcode E2E client
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOARCH: ${{ matrix.target_arch }}
|
||||
GOOS: ${{ matrix.target_os }}
|
||||
run: >-
|
||||
go test -c -buildvcs=false -mod=vendor -trimpath -tags client,go_sqlite
|
||||
-o ${{ matrix.test_executable }} ./test/e2e
|
||||
|
||||
- name: Run shellcode generation and execution tests
|
||||
run: >-
|
||||
${{ matrix.test_executable }}
|
||||
-test.v
|
||||
-test.run=^TestShellcodeE2E$
|
||||
-test.timeout=0
|
||||
-repo .
|
||||
-server ${{ matrix.server_executable }}
|
||||
-server-arch ${{ matrix.server_arch }}
|
||||
-target-os ${{ matrix.target_os }}
|
||||
-target-arch ${{ matrix.target_arch }}
|
||||
-results ./shellcode-results
|
||||
-transports mtls,wg,http
|
||||
-implant-modes session,beacon
|
||||
-beacon-interval 10s
|
||||
-shellcode-sgn-samples 4
|
||||
|
||||
- name: Upload target shellcode results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: shellcode-e2e-target-${{ matrix.target_os }}-${{ matrix.target_arch }}
|
||||
path: shellcode-results
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 14
|
||||
|
||||
aggregate:
|
||||
name: Aggregate shellcode E2E coverage
|
||||
needs: target
|
||||
if: ${{ always() }}
|
||||
outputs:
|
||||
shellcode_coverage_markdown: ${{ steps.publish_shellcode_coverage.outputs.shellcode_coverage_markdown }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Prepare aggregate artifact
|
||||
run: |
|
||||
mkdir -p shellcode-input shellcode-summary
|
||||
printf 'Shellcode E2E coverage aggregate\n' > shellcode-summary/README.txt
|
||||
|
||||
- name: Download target shellcode results
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: shellcode-e2e-target-*
|
||||
path: shellcode-input
|
||||
merge-multiple: true
|
||||
|
||||
- name: Aggregate shellcode coverage
|
||||
id: aggregate
|
||||
continue-on-error: true
|
||||
run: >-
|
||||
go run -buildvcs=false -mod=vendor -trimpath ./test/e2e/shellcode-report
|
||||
-input ./shellcode-input
|
||||
-output ./shellcode-summary
|
||||
|
||||
- name: Publish shellcode coverage table
|
||||
id: publish_shellcode_coverage
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
report="shellcode-summary/shellcode-coverage.md"
|
||||
if [[ ! -s "${report}" ]]; then
|
||||
printf 'Shellcode coverage report is missing or empty: %s\n' "${report}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
delimiter="SHELLCODE_E2E_COVERAGE_EOF"
|
||||
if grep -Fxq "${delimiter}" "${report}"; then
|
||||
printf 'Shellcode coverage report contains the reserved output delimiter: %s\n' "${delimiter}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf 'shellcode_coverage_markdown<<%s\n' "${delimiter}"
|
||||
cat "${report}"
|
||||
printf '%s\n' "${delimiter}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
cat "${report}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload aggregate shellcode coverage
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: shellcode-e2e-coverage-summary
|
||||
path: shellcode-summary
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 30
|
||||
|
||||
- name: Fail on failed or not-run shellcode coverage
|
||||
if: ${{ always() && steps.aggregate.outcome != 'success' }}
|
||||
run: exit 1
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- ".gitattributes"
|
||||
- "client/command/opfor/testdata/corpus/**"
|
||||
- ".github/workflows/unit-tests.yml"
|
||||
- "Makefile"
|
||||
- "**/*.go"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -228,6 +228,14 @@ func startCursedChromeProcess(isEdge bool, session *clientpb.Session, cmd *cobra
|
||||
DialTimeout: 30 * time.Second,
|
||||
}
|
||||
tcpProxy.AddRoute(bindAddr, channelProxy)
|
||||
if err := tcpProxy.Start(); err != nil {
|
||||
channelProxy.Stop()
|
||||
_ = tcpProxy.Close()
|
||||
return nil, errors.Join(
|
||||
fmt.Errorf("start port forward listener %s: %w", bindAddr, err),
|
||||
terminateStartedCursedProcess(con, session.ID, chromeExec.GetPid()),
|
||||
)
|
||||
}
|
||||
portFwd := core.Portfwds.Add(tcpProxy, channelProxy)
|
||||
|
||||
curse := &core.CursedProcess{
|
||||
@@ -241,7 +249,7 @@ func startCursedChromeProcess(isEdge bool, session *clientpb.Session, cmd *cobra
|
||||
}
|
||||
core.CursedProcesses.Store(bindPort, curse)
|
||||
go func() {
|
||||
err := tcpProxy.Run()
|
||||
err := tcpProxy.Wait()
|
||||
if err != nil {
|
||||
log.Printf("Proxy error %s", err)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ package cursed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"path"
|
||||
@@ -190,6 +191,14 @@ func startCursedElectronProcess(electronExe string, session *clientpb.Session, c
|
||||
DialTimeout: 30 * time.Second,
|
||||
}
|
||||
tcpProxy.AddRoute(bindAddr, channelProxy)
|
||||
if err := tcpProxy.Start(); err != nil {
|
||||
channelProxy.Stop()
|
||||
_ = tcpProxy.Close()
|
||||
return nil, errors.Join(
|
||||
fmt.Errorf("start port forward listener %s: %w", bindAddr, err),
|
||||
terminateStartedCursedProcess(con, session.ID, electronExec.GetPid()),
|
||||
)
|
||||
}
|
||||
portFwd := core.Portfwds.Add(tcpProxy, channelProxy)
|
||||
|
||||
curse := &core.CursedProcess{
|
||||
@@ -202,7 +211,7 @@ func startCursedElectronProcess(electronExe string, session *clientpb.Session, c
|
||||
}
|
||||
core.CursedProcesses.Store(bindPort, curse)
|
||||
go func() {
|
||||
err := tcpProxy.Run()
|
||||
err := tcpProxy.Wait()
|
||||
if err != nil {
|
||||
log.Printf("Proxy error %s", err)
|
||||
}
|
||||
|
||||
@@ -20,20 +20,47 @@ package cursed
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/settings"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/core"
|
||||
"github.com/bishopfox/sliver/client/forms"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const cursedStartCleanupTimeout = 30 * time.Second
|
||||
|
||||
func terminateStartedCursedProcess(con *console.SliverClient, sessionID string, pid uint32) error {
|
||||
const maxTerminatePID = uint32(1<<31 - 1)
|
||||
if con == nil || con.Rpc == nil || sessionID == "" || pid == 0 || pid > maxTerminatePID {
|
||||
return errors.New("cannot terminate incompletely tracked cursed process")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cursedStartCleanupTimeout)
|
||||
defer cancel()
|
||||
response, err := con.Rpc.Terminate(ctx, &sliverpb.TerminateReq{
|
||||
Request: &commonpb.Request{SessionID: sessionID},
|
||||
Pid: int32(pid),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("terminate started cursed process %d: %w", pid, err)
|
||||
}
|
||||
if response.GetResponse().GetErr() != "" {
|
||||
return fmt.Errorf("terminate started cursed process %d: %s", pid, response.GetResponse().GetErr())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CursedChromeCmd - Execute a .NET assembly in-memory.
|
||||
func CursedCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
// Collect existing curses from core
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -115,6 +133,101 @@ const (
|
||||
}`
|
||||
)
|
||||
|
||||
type registerExtensionTestRPC struct {
|
||||
rpcpb.SliverRPCClient
|
||||
register func(context.Context, *sliverpb.RegisterExtensionReq, ...grpc.CallOption) (*sliverpb.RegisterExtension, error)
|
||||
}
|
||||
|
||||
func (f *registerExtensionTestRPC) RegisterExtension(ctx context.Context, req *sliverpb.RegisterExtensionReq, opts ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
return f.register(ctx, req, opts...)
|
||||
}
|
||||
|
||||
func newExtensionTestConsole(t *testing.T, rpc rpcpb.SliverRPCClient, session *clientpb.Session, beacon *clientpb.Beacon) *console.SliverClient {
|
||||
t.Helper()
|
||||
|
||||
con := console.NewConsole(false)
|
||||
con.IsCLI = true
|
||||
con.Rpc = rpc
|
||||
con.ActiveTarget.Set(session, beacon)
|
||||
return con
|
||||
}
|
||||
|
||||
func TestRemoveExtensionByManifestNameOnlyDeletesManagedExtensions(t *testing.T) {
|
||||
clientRoot := t.TempDir()
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", clientRoot)
|
||||
|
||||
originalManifests := loadedManifests
|
||||
originalExtensions := loadedExtensions
|
||||
t.Cleanup(func() {
|
||||
loadedManifests = originalManifests
|
||||
loadedExtensions = originalExtensions
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
extPath string
|
||||
wantDeleted bool
|
||||
}{
|
||||
{
|
||||
name: "installed extension",
|
||||
extPath: filepath.Join(clientRoot, "extensions", "installed"),
|
||||
wantDeleted: true,
|
||||
},
|
||||
{
|
||||
name: "temporarily loaded extension",
|
||||
extPath: t.TempDir(),
|
||||
wantDeleted: false,
|
||||
},
|
||||
{
|
||||
name: "extensions directory sibling",
|
||||
extPath: filepath.Join(clientRoot, "extensions-backup"),
|
||||
wantDeleted: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := os.MkdirAll(test.extPath, 0o700); err != nil {
|
||||
t.Fatalf("create extension directory: %v", err)
|
||||
}
|
||||
markerPath := filepath.Join(test.extPath, "source-code.txt")
|
||||
if err := os.WriteFile(markerPath, []byte("keep me"), 0o600); err != nil {
|
||||
t.Fatalf("create marker file: %v", err)
|
||||
}
|
||||
|
||||
manifestName := "test-extension"
|
||||
commandName := "test-command"
|
||||
command := &ExtCommand{CommandName: commandName}
|
||||
loadedManifests = map[string]*ExtensionManifest{
|
||||
manifestName: {RootPath: test.extPath, ExtCommand: []*ExtCommand{command}},
|
||||
}
|
||||
loadedExtensions = map[string]*ExtCommand{commandName: command}
|
||||
|
||||
found, err := RemoveExtensionByManifestName(manifestName, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("remove extension: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected extension manifest to be found")
|
||||
}
|
||||
if _, ok := loadedManifests[manifestName]; ok {
|
||||
t.Error("manifest was not unloaded")
|
||||
}
|
||||
if _, ok := loadedExtensions[commandName]; ok {
|
||||
t.Error("command was not unloaded")
|
||||
}
|
||||
|
||||
_, err = os.Stat(markerPath)
|
||||
if test.wantDeleted && !os.IsNotExist(err) {
|
||||
t.Errorf("managed extension directory was not deleted: %v", err)
|
||||
}
|
||||
if !test.wantDeleted && err != nil {
|
||||
t.Errorf("external extension directory was modified: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionManifest(t *testing.T) {
|
||||
expectedPath := util.ResolvePath("foo/test1.dll")
|
||||
|
||||
@@ -189,6 +302,218 @@ func TestParseExtensionManifest(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
func TestConvertOldManifestPreservesInit(t *testing.T) {
|
||||
const initExport = "InitializeExtension"
|
||||
|
||||
manifest, err := ParseExtensionManifest([]byte(`{
|
||||
"name": "legacy-test",
|
||||
"command_name": "legacy-test",
|
||||
"help": "some help",
|
||||
"init": "InitializeExtension",
|
||||
"files": [
|
||||
{
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"path": "foo/test.dll"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Error parsing legacy extension manifest: %s", err)
|
||||
}
|
||||
if len(manifest.ExtCommand) != 1 {
|
||||
t.Fatalf("Expected 1 extension command, got %d", len(manifest.ExtCommand))
|
||||
}
|
||||
if manifest.ExtCommand[0].Init != initExport {
|
||||
t.Errorf("Expected init export %q, got %q", initExport, manifest.ExtCommand[0].Init)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExtensionPropagatesInit(t *testing.T) {
|
||||
const initExport = "InitializeExtension"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
session *clientpb.Session
|
||||
beacon *clientpb.Beacon
|
||||
wantAsync bool
|
||||
wantID string
|
||||
}{
|
||||
{
|
||||
name: "session",
|
||||
session: &clientpb.Session{ID: "session-id"},
|
||||
wantID: "session-id",
|
||||
},
|
||||
{
|
||||
name: "beacon",
|
||||
beacon: &clientpb.Beacon{ID: "beacon-id"},
|
||||
wantAsync: true,
|
||||
wantID: "beacon-id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var gotReq *sliverpb.RegisterExtensionReq
|
||||
rpc := ®isterExtensionTestRPC{
|
||||
register: func(_ context.Context, req *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
gotReq = req
|
||||
return &sliverpb.RegisterExtension{Response: &commonpb.Response{}}, nil
|
||||
},
|
||||
}
|
||||
con := newExtensionTestConsole(t, rpc, test.session, test.beacon)
|
||||
|
||||
err := registerExtension("linux", &ExtCommand{CommandName: "test", Init: initExport}, []byte("extension"), nil, con)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterExtension returned an error: %s", err)
|
||||
}
|
||||
if gotReq == nil {
|
||||
t.Fatal("RegisterExtension RPC was not called")
|
||||
}
|
||||
if gotReq.Init != initExport {
|
||||
t.Errorf("Expected init export %q, got %q", initExport, gotReq.Init)
|
||||
}
|
||||
if gotReq.Request.GetAsync() != test.wantAsync {
|
||||
t.Errorf("Expected async=%t, got %t", test.wantAsync, gotReq.Request.GetAsync())
|
||||
}
|
||||
if test.wantAsync && gotReq.Request.GetBeaconID() != test.wantID {
|
||||
t.Errorf("Expected beacon ID %q, got %q", test.wantID, gotReq.Request.GetBeaconID())
|
||||
}
|
||||
if !test.wantAsync && gotReq.Request.GetSessionID() != test.wantID {
|
||||
t.Errorf("Expected session ID %q, got %q", test.wantID, gotReq.Request.GetSessionID())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExtensionWaitsForBeaconRegistration(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
rpc := ®isterExtensionTestRPC{
|
||||
register: func(_ context.Context, _ *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return &sliverpb.RegisterExtension{Response: &commonpb.Response{Async: true}}, nil
|
||||
},
|
||||
}
|
||||
con := newExtensionTestConsole(t, rpc, nil, &clientpb.Beacon{ID: "beacon-id"})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- registerExtension("linux", &ExtCommand{CommandName: "test"}, []byte("extension"), nil, con)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timed out waiting for RegisterExtension RPC")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("RegisterExtension returned before the beacon registration was enqueued: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterExtension returned an error: %s", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timed out waiting for RegisterExtension to return")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExtensionReturnsBeaconRPCError(t *testing.T) {
|
||||
wantErr := errors.New("register failed")
|
||||
rpc := ®isterExtensionTestRPC{
|
||||
register: func(_ context.Context, _ *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
con := newExtensionTestConsole(t, rpc, nil, &clientpb.Beacon{ID: "beacon-id"})
|
||||
|
||||
err := registerExtension("linux", &ExtCommand{CommandName: "test"}, []byte("extension"), nil, con)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Expected error %q, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExtensionRejectsNilResponse(t *testing.T) {
|
||||
rpc := ®isterExtensionTestRPC{
|
||||
register: func(_ context.Context, _ *sliverpb.RegisterExtensionReq, _ ...grpc.CallOption) (*sliverpb.RegisterExtension, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
con := newExtensionTestConsole(t, rpc, &clientpb.Session{ID: "session-id"}, nil)
|
||||
|
||||
if err := registerExtension("linux", &ExtCommand{CommandName: "test"}, []byte("extension"), nil, con); err == nil {
|
||||
t.Fatal("RegisterExtension accepted a nil response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionCommandVisibilityUsesExactTargetPairs(t *testing.T) {
|
||||
extension := &ExtCommand{Files: []*extensionFile{
|
||||
{OS: "windows", Arch: "amd64"},
|
||||
{OS: "linux", Arch: "amd64"},
|
||||
{OS: "darwin", Arch: "arm64"},
|
||||
}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
session *clientpb.Session
|
||||
beacon *clientpb.Beacon
|
||||
wantVisible bool
|
||||
}{
|
||||
{
|
||||
name: "cross-platform Linux session",
|
||||
session: &clientpb.Session{OS: "linux", Arch: "amd64"},
|
||||
wantVisible: true,
|
||||
},
|
||||
{
|
||||
name: "cross-platform macOS beacon",
|
||||
beacon: &clientpb.Beacon{OS: "darwin", Arch: "arm64"},
|
||||
wantVisible: true,
|
||||
},
|
||||
{
|
||||
name: "cross-platform Windows session",
|
||||
session: &clientpb.Session{OS: "windows", Arch: "amd64"},
|
||||
wantVisible: true,
|
||||
},
|
||||
{
|
||||
name: "Linux architecture mismatch",
|
||||
session: &clientpb.Session{OS: "linux", Arch: "arm64"},
|
||||
wantVisible: false,
|
||||
},
|
||||
{
|
||||
name: "macOS architecture mismatch",
|
||||
beacon: &clientpb.Beacon{OS: "darwin", Arch: "amd64"},
|
||||
wantVisible: false,
|
||||
},
|
||||
{
|
||||
name: "Windows architecture mismatch",
|
||||
session: &clientpb.Session{OS: "windows", Arch: "386"},
|
||||
wantVisible: false,
|
||||
},
|
||||
{
|
||||
name: "unsupported native extension target",
|
||||
beacon: &clientpb.Beacon{OS: "linux", Arch: "riscv64"},
|
||||
wantVisible: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
con := newExtensionTestConsole(t, nil, test.session, test.beacon)
|
||||
command := &cobra.Command{Annotations: makeCommandPlatformFilters(extension)}
|
||||
activeFilters := con.App.Menu(consts.ImplantMenu).ActiveFiltersFor(command)
|
||||
visible := len(activeFilters) == 0
|
||||
if visible != test.wantVisible {
|
||||
t.Fatalf("command visibility = %t, want %t (active filters: %v)", visible, test.wantVisible, activeFilters)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultipleCmdManifest(t *testing.T) {
|
||||
expectedPath := util.ResolvePath("ex.dll")
|
||||
|
||||
@@ -323,3 +648,285 @@ func TestParseExtensionManifestErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionManifestBOFExecutor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "multi-command manifest",
|
||||
data: `{
|
||||
"name":"modern-bof",
|
||||
"commands":[{
|
||||
"command_name":"modern-bof",
|
||||
"help":"test",
|
||||
"bof_executor":"reflektor",
|
||||
"files":[{"os":"windows","arch":"amd64","path":"modern.o"}]
|
||||
}]
|
||||
}`,
|
||||
want: BOFExecutorReflektor,
|
||||
},
|
||||
{
|
||||
name: "legacy manifest",
|
||||
data: `{
|
||||
"name":"legacy-bof",
|
||||
"command_name":"legacy-bof",
|
||||
"help":"test",
|
||||
"bof_executor":"coff-loader",
|
||||
"depends_on":"custom-loader",
|
||||
"files":[{"os":"windows","arch":"amd64","path":"legacy.o"}]
|
||||
}`,
|
||||
want: BOFExecutorCOFFLoader,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
manifest, err := ParseExtensionManifest([]byte(test.data))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExtensionManifest returned an error: %s", err)
|
||||
}
|
||||
if got := manifest.ExtCommand[0].BOFExecutor; got != test.want {
|
||||
t.Fatalf("BOFExecutor = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionManifestRejectsInvalidBOFExecutor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
executor string
|
||||
dependsOn string
|
||||
wantErrPart string
|
||||
}{
|
||||
{
|
||||
name: "executor must be exact lowercase",
|
||||
executor: "Reflektor",
|
||||
wantErrPart: "invalid `bof_executor`",
|
||||
},
|
||||
{
|
||||
name: "unknown executor",
|
||||
executor: "other-loader",
|
||||
wantErrPart: "invalid `bof_executor`",
|
||||
},
|
||||
{
|
||||
name: "coff loader requires dependency",
|
||||
executor: BOFExecutorCOFFLoader,
|
||||
wantErrPart: "no `depends_on` fallback",
|
||||
},
|
||||
{
|
||||
name: "coff loader rejects whitespace dependency",
|
||||
executor: BOFExecutorCOFFLoader,
|
||||
dependsOn: " \t",
|
||||
wantErrPart: "no `depends_on` fallback",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
manifest := &ExtensionManifest{
|
||||
Name: "test",
|
||||
ExtCommand: []*ExtCommand{{
|
||||
CommandName: "test",
|
||||
Help: "test",
|
||||
BOFExecutor: test.executor,
|
||||
DependsOn: test.dependsOn,
|
||||
Files: []*extensionFile{{
|
||||
OS: "windows", Arch: "amd64", Path: "test.o",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
err := validManifest(manifest)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErrPart) {
|
||||
t.Fatalf("validManifest error = %v, want error containing %q", err, test.wantErrPart)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanExtensionExecution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ext *ExtCommand
|
||||
path string
|
||||
capabilities uint64
|
||||
want extensionExecutionMode
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "native artifact remains native",
|
||||
ext: &ExtCommand{CommandName: "native"},
|
||||
path: "native.dll",
|
||||
want: extensionExecutionNative,
|
||||
},
|
||||
{
|
||||
name: "legacy manifest keeps dependency route",
|
||||
ext: &ExtCommand{CommandName: "legacy", DependsOn: "coff-loader"},
|
||||
path: "legacy.o",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionCOFFLoader,
|
||||
},
|
||||
{
|
||||
name: "explicit coff loader is authoritative",
|
||||
ext: &ExtCommand{CommandName: "coff", BOFExecutor: BOFExecutorCOFFLoader, DependsOn: "custom-loader"},
|
||||
path: "coff.bin",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionCOFFLoader,
|
||||
},
|
||||
{
|
||||
name: "explicit Reflektor uses capable target",
|
||||
ext: &ExtCommand{CommandName: "builtin", BOFExecutor: BOFExecutorReflektor, DependsOn: "coff-loader"},
|
||||
path: "builtin.o",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionReflektor,
|
||||
},
|
||||
{
|
||||
name: "explicit Reflektor falls back to dependency",
|
||||
ext: &ExtCommand{CommandName: "fallback", BOFExecutor: BOFExecutorReflektor, DependsOn: "coff-loader"},
|
||||
path: "fallback.o",
|
||||
want: extensionExecutionCOFFLoader,
|
||||
},
|
||||
{
|
||||
name: "dependency-free BOF defaults to Reflektor",
|
||||
ext: &ExtCommand{CommandName: "default"},
|
||||
path: "default.o",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionReflektor,
|
||||
},
|
||||
{
|
||||
name: "uppercase object extension is a BOF",
|
||||
ext: &ExtCommand{CommandName: "uppercase"},
|
||||
path: "uppercase.O",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
want: extensionExecutionReflektor,
|
||||
},
|
||||
{
|
||||
name: "unsupported dependency-free BOF fails closed",
|
||||
ext: &ExtCommand{CommandName: "unsupported"},
|
||||
path: "unsupported.o",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "coff loader without dependency fails closed",
|
||||
ext: &ExtCommand{CommandName: "missing", BOFExecutor: BOFExecutorCOFFLoader},
|
||||
path: "missing.o",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid executor fails closed",
|
||||
ext: &ExtCommand{CommandName: "invalid", BOFExecutor: "other"},
|
||||
path: "invalid.o",
|
||||
capabilities: sliverpb.CapabilityBOFV1,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := planExtensionExecution(test.ext, test.path, test.capabilities)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("planExtensionExecution returned mode %d, want error", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("planExtensionExecution returned an error: %s", err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("planExtensionExecution mode = %d, want %d", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCallExtensionRequestPreservesLegacyWireFields(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
objectData := []byte("object-data")
|
||||
loaderData := []byte("loader-data")
|
||||
objectPath := filepath.Join(root, "object.o")
|
||||
loaderPath := filepath.Join(root, "loader.dll")
|
||||
if err := os.WriteFile(objectPath, objectData, 0o600); err != nil {
|
||||
t.Fatalf("write object fixture: %s", err)
|
||||
}
|
||||
if err := os.WriteFile(loaderPath, loaderData, 0o600); err != nil {
|
||||
t.Fatalf("write loader fixture: %s", err)
|
||||
}
|
||||
|
||||
const loaderName = "custom-loader"
|
||||
previousLoader, hadPreviousLoader := loadedExtensions[loaderName]
|
||||
defer func() {
|
||||
if hadPreviousLoader {
|
||||
loadedExtensions[loaderName] = previousLoader
|
||||
} else {
|
||||
delete(loadedExtensions, loaderName)
|
||||
}
|
||||
}()
|
||||
loaderManifest := &ExtensionManifest{Name: loaderName, RootPath: root}
|
||||
loadedExtensions[loaderName] = &ExtCommand{
|
||||
CommandName: loaderName,
|
||||
Entrypoint: "RunCOFF",
|
||||
Files: []*extensionFile{{
|
||||
OS: "windows", Arch: "amd64", Path: filepath.Base(loaderPath),
|
||||
}},
|
||||
Manifest: loaderManifest,
|
||||
}
|
||||
|
||||
legacyArgs := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
ext := &ExtCommand{CommandName: "test-bof", DependsOn: loaderName}
|
||||
got, err := buildCallExtensionRequest("windows", "amd64", extensionExecutionCOFFLoader, ext, objectPath, "RunCOFF", legacyArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("buildCallExtensionRequest returned an error: %s", err)
|
||||
}
|
||||
loaderHash := sha256.Sum256(loaderData)
|
||||
want := &sliverpb.CallExtensionReq{
|
||||
Name: hex.EncodeToString(loaderHash[:]),
|
||||
Export: "RunCOFF",
|
||||
Args: legacyArgs,
|
||||
}
|
||||
gotWire, err := proto.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal actual request: %s", err)
|
||||
}
|
||||
wantWire, err := proto.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected request: %s", err)
|
||||
}
|
||||
if !bytes.Equal(gotWire, wantWire) {
|
||||
t.Fatalf("legacy request wire bytes changed:\n got: %x\nwant: %x", gotWire, wantWire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCallExtensionRequestForReflektor(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
objectData := []byte("object-data")
|
||||
objectPath := filepath.Join(root, "object.o")
|
||||
if err := os.WriteFile(objectPath, objectData, 0o600); err != nil {
|
||||
t.Fatalf("write object fixture: %s", err)
|
||||
}
|
||||
|
||||
typedArgs := []byte{0x05, 0x06}
|
||||
got, err := buildCallExtensionRequest("windows", "amd64", extensionExecutionReflektor, &ExtCommand{CommandName: "test-bof"}, objectPath, "go", typedArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("buildCallExtensionRequest returned an error: %s", err)
|
||||
}
|
||||
objectHash := sha256.Sum256(objectData)
|
||||
if got.Name != hex.EncodeToString(objectHash[:]) {
|
||||
t.Errorf("Name = %q, want object hash %q", got.Name, hex.EncodeToString(objectHash[:]))
|
||||
}
|
||||
if got.Export != "go" {
|
||||
t.Errorf("Export = %q, want %q", got.Export, "go")
|
||||
}
|
||||
if !bytes.Equal(got.Args, typedArgs) {
|
||||
t.Errorf("Args = %x, want %x", got.Args, typedArgs)
|
||||
}
|
||||
if !bytes.Equal(got.BOFData, objectData) {
|
||||
t.Errorf("BOFData = %q, want %q", got.BOFData, objectData)
|
||||
}
|
||||
if !got.IsBOF {
|
||||
t.Error("IsBOF = false, want true")
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,21 @@ func ExtensionsInstallCmd(cmd *cobra.Command, con *console.SliverClient, args []
|
||||
con.PrintErrorf("Extension path '%s' does not exist", extLocalPath)
|
||||
return
|
||||
}
|
||||
InstallFromDir(extLocalPath, true, con, strings.HasSuffix(extLocalPath, ".tar.gz"))
|
||||
_ = InstallFromDir(extLocalPath, true, con, strings.HasSuffix(extLocalPath, ".tar.gz"))
|
||||
}
|
||||
|
||||
func readExtensionManifest(extLocalPath string, isGz bool) ([]byte, error) {
|
||||
if isGz {
|
||||
return util.ReadFileFromTarGz(extLocalPath, fmt.Sprintf("./%s", ManifestFileName))
|
||||
}
|
||||
return os.ReadFile(filepath.Join(extLocalPath, ManifestFileName))
|
||||
}
|
||||
|
||||
func extensionPackageID(manifest *ExtensionManifest) string {
|
||||
if manifest.PackageName != "" {
|
||||
return manifest.PackageName
|
||||
}
|
||||
return manifest.Name
|
||||
}
|
||||
|
||||
// InstallFromDir installs a Sliver extension from either a local directory or gzipped archive.
|
||||
@@ -54,37 +68,28 @@ func ExtensionsInstallCmd(cmd *cobra.Command, con *console.SliverClient, args []
|
||||
// - con: Sliver console client for displaying status and error messages
|
||||
// - isGz: Whether the source is a gzipped archive (true) or directory (false)
|
||||
//
|
||||
// The function will return early with error messages printed to console if:
|
||||
// The function returns errors after printing them to the console if:
|
||||
// - The manifest cannot be read or parsed
|
||||
// - Required directories cannot be created
|
||||
// - File copy operations fail
|
||||
// - User declines overwrite when prompted
|
||||
func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.SliverClient, isGz bool) {
|
||||
var manifestData []byte
|
||||
var err error
|
||||
|
||||
if isGz {
|
||||
manifestData, err = util.ReadFileFromTarGz(extLocalPath, fmt.Sprintf("./%s", ManifestFileName))
|
||||
} else {
|
||||
manifestData, err = os.ReadFile(filepath.Join(extLocalPath, ManifestFileName))
|
||||
}
|
||||
//
|
||||
// Declining an overwrite prompt cancels the installation without an error.
|
||||
func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.SliverClient, isGz bool) error {
|
||||
manifestData, err := readExtensionManifest(extLocalPath, isGz)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error reading %s: %s", ManifestFileName, err)
|
||||
return
|
||||
return fmt.Errorf("read %s: %w", ManifestFileName, err)
|
||||
}
|
||||
|
||||
manifestF, err := ParseExtensionManifest(manifestData)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error parsing %s: %s", ManifestFileName, err)
|
||||
return
|
||||
return fmt.Errorf("parse %s: %w", ManifestFileName, err)
|
||||
}
|
||||
|
||||
// Use package name if available, otherwise use extension name
|
||||
// (Note, for v1 manifests this will actually be command_name)
|
||||
packageID := manifestF.Name
|
||||
if manifestF.PackageName != "" {
|
||||
packageID = manifestF.PackageName
|
||||
}
|
||||
packageID := extensionPackageID(manifestF)
|
||||
|
||||
//create repo path
|
||||
minstallPath := filepath.Join(assets.GetExtensionsDir(), filepath.Base(packageID))
|
||||
@@ -94,7 +99,7 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
|
||||
confirm := false
|
||||
_ = forms.Confirm("Overwrite current install?", &confirm)
|
||||
if !confirm {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
}
|
||||
forceRemoveAll(minstallPath)
|
||||
@@ -103,13 +108,13 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
|
||||
err = os.MkdirAll(minstallPath, 0o700)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error creating extension directory: %s\n", err)
|
||||
return
|
||||
return fmt.Errorf("create extension directory: %w", err)
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(minstallPath, ManifestFileName), manifestData, 0o600)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to write %s: %s\n", ManifestFileName, err)
|
||||
forceRemoveAll(minstallPath)
|
||||
return
|
||||
return fmt.Errorf("write %s: %w", ManifestFileName, err)
|
||||
}
|
||||
|
||||
for _, manifest := range manifestF.ExtCommand {
|
||||
@@ -125,7 +130,7 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
|
||||
if err != nil {
|
||||
con.PrintErrorf("\nError creating extension directory: %s\n", err)
|
||||
forceRemoveAll(installPath)
|
||||
return
|
||||
return fmt.Errorf("create extension artifact directory: %w", err)
|
||||
}
|
||||
err = util.CopyFile(src, dst)
|
||||
if err != nil {
|
||||
@@ -135,11 +140,12 @@ func InstallFromDir(extLocalPath string, promptToOverwrite bool, con *console.Sl
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error installing command: %s\n", err)
|
||||
forceRemoveAll(installPath)
|
||||
return
|
||||
return fmt.Errorf("install extension artifact: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installArtifact(extGzFilePath string, installPath string, artifactPath string) error {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/klauspost/compress/gzip"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type installArchiveMember struct {
|
||||
name string
|
||||
data []byte
|
||||
}
|
||||
|
||||
const installTestManifest = `{
|
||||
"name": "Test Extension",
|
||||
"package_name": "test-extension",
|
||||
"version": "v1.0.0",
|
||||
"commands": [{
|
||||
"command_name": "test-command",
|
||||
"help": "test command",
|
||||
"files": [{"os": "windows", "arch": "amd64", "path": "foo/test.dll"}]
|
||||
}]
|
||||
}`
|
||||
|
||||
func writeInstallArchive(t *testing.T, prefix string, members ...installArchiveMember) string {
|
||||
t.Helper()
|
||||
archivePath := filepath.Join(t.TempDir(), "extension.tar.gz")
|
||||
archive, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gzipWriter := gzip.NewWriter(archive)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
for _, member := range members {
|
||||
header := &tar.Header{
|
||||
Name: prefix + member.name,
|
||||
Mode: 0o600,
|
||||
Size: int64(len(member.data)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tarWriter.Write(member.data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return archivePath
|
||||
}
|
||||
|
||||
func testInstallConsole(t *testing.T) *console.SliverClient {
|
||||
t.Helper()
|
||||
con := console.NewConsole(false)
|
||||
emptyCommands := func() *cobra.Command { return &cobra.Command{Use: "test"} }
|
||||
if err := console.StartClient(
|
||||
con, nil, nil, nil, emptyCommands, emptyCommands, false, "",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return con
|
||||
}
|
||||
|
||||
func TestInstallFromDirAcceptsPrefixedAndUnprefixedArchives(t *testing.T) {
|
||||
for _, prefix := range []string{"./", ""} {
|
||||
name := "unprefixed"
|
||||
if prefix != "" {
|
||||
name = "prefixed"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
archivePath := writeInstallArchive(t, prefix,
|
||||
installArchiveMember{name: ManifestFileName, data: []byte(installTestManifest)},
|
||||
installArchiveMember{name: "foo/test.dll", data: []byte("extension data")},
|
||||
)
|
||||
|
||||
if err := InstallFromDir(archivePath, false, testInstallConsole(t), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installRoot := filepath.Join(assets.GetExtensionsDir(), "test-extension")
|
||||
if _, err := os.Stat(filepath.Join(installRoot, ManifestFileName)); err != nil {
|
||||
t.Fatalf("installed manifest: %v", err)
|
||||
}
|
||||
artifact, err := os.ReadFile(filepath.Join(installRoot, "foo", "test.dll"))
|
||||
if err != nil {
|
||||
t.Fatalf("installed artifact: %v", err)
|
||||
}
|
||||
if string(artifact) != "extension data" {
|
||||
t.Fatalf("artifact data = %q", artifact)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFromDirReturnsMissingManifest(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
archivePath := writeInstallArchive(t, "./",
|
||||
installArchiveMember{name: "foo/test.dll", data: []byte("extension data")},
|
||||
)
|
||||
err := InstallFromDir(archivePath, false, testInstallConsole(t), true)
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("error = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFromDirReturnsMissingArtifactAndCleansUp(t *testing.T) {
|
||||
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
|
||||
archivePath := writeInstallArchive(t, "./",
|
||||
installArchiveMember{name: ManifestFileName, data: []byte(installTestManifest)},
|
||||
)
|
||||
err := InstallFromDir(archivePath, false, testInstallConsole(t), true)
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("error = %v, want fs.ErrNotExist", err)
|
||||
}
|
||||
installRoot := filepath.Join(assets.GetExtensionsDir(), "test-extension")
|
||||
if _, err := os.Stat(installRoot); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("install directory was not cleaned up: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,20 @@ const (
|
||||
|
||||
// ManifestFileName - Extension manifest file name.
|
||||
ManifestFileName = "extension.json"
|
||||
|
||||
// BOFExecutorReflektor selects the built-in Reflektor BOF executor.
|
||||
BOFExecutorReflektor = "reflektor"
|
||||
|
||||
// BOFExecutorCOFFLoader selects the manifest's depends_on COFF loader.
|
||||
BOFExecutorCOFFLoader = "coff-loader"
|
||||
)
|
||||
|
||||
type extensionExecutionMode uint8
|
||||
|
||||
const (
|
||||
extensionExecutionNative extensionExecutionMode = iota
|
||||
extensionExecutionCOFFLoader
|
||||
extensionExecutionReflektor
|
||||
)
|
||||
|
||||
var loadedExtensions = map[string]*ExtCommand{}
|
||||
@@ -72,6 +86,7 @@ type ExtensionManifest_ struct {
|
||||
Arguments []*extensionArgument `json:"arguments"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
DependsOn string `json:"depends_on"`
|
||||
BOFExecutor string `json:"bof_executor"`
|
||||
Init string `json:"init"`
|
||||
|
||||
RootPath string `json:"-"`
|
||||
@@ -100,6 +115,7 @@ type ExtCommand struct {
|
||||
Arguments []*extensionArgument `json:"arguments"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
DependsOn string `json:"depends_on"`
|
||||
BOFExecutor string `json:"bof_executor"`
|
||||
Init string `json:"init"`
|
||||
Schema *packages.OutputSchema `json:"schema"`
|
||||
|
||||
@@ -218,9 +234,11 @@ func convertOldManifest(old *ExtensionManifest_) *ExtensionManifest {
|
||||
{
|
||||
CommandName: old.CommandName,
|
||||
DependsOn: old.DependsOn,
|
||||
BOFExecutor: old.BOFExecutor,
|
||||
Help: old.Help,
|
||||
LongHelp: old.LongHelp,
|
||||
Entrypoint: old.Entrypoint,
|
||||
Init: old.Init,
|
||||
Files: old.Files,
|
||||
Arguments: old.Arguments,
|
||||
Schema: nil,
|
||||
@@ -270,6 +288,15 @@ func validManifest(manifest *ExtensionManifest) error {
|
||||
if extManifest.CommandName == "" {
|
||||
return errors.New("missing `command_name` field in extension manifest")
|
||||
}
|
||||
switch extManifest.BOFExecutor {
|
||||
case "", BOFExecutorReflektor:
|
||||
case BOFExecutorCOFFLoader:
|
||||
if strings.TrimSpace(extManifest.DependsOn) == "" {
|
||||
return fmt.Errorf("command %q uses bof_executor %q but has no `depends_on` fallback", extManifest.CommandName, BOFExecutorCOFFLoader)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("command %q has invalid `bof_executor` %q (must be %q or %q)", extManifest.CommandName, extManifest.BOFExecutor, BOFExecutorReflektor, BOFExecutorCOFFLoader)
|
||||
}
|
||||
if len(extManifest.Files) == 0 {
|
||||
return errors.New("missing `files` field in extension manifest")
|
||||
}
|
||||
@@ -471,44 +498,30 @@ func registerExtension(goos string, ext *ExtCommand, binData []byte, cmd *cobra.
|
||||
//set extension name to a hash of the data to avoid loading more than one instance
|
||||
bd := sha256.Sum256(binData)
|
||||
name := hex.EncodeToString(bd[:])
|
||||
sess, beac := con.ActiveTarget.GetInteractive()
|
||||
sess, _ := con.ActiveTarget.GetInteractive()
|
||||
ctrl := make(chan bool)
|
||||
//first time run of an extension will require some waiting depending on the size
|
||||
if sess != nil {
|
||||
msg := fmt.Sprintf("Sending %s to implant ...", ext.CommandName)
|
||||
con.SpinUntil(msg, ctrl)
|
||||
}
|
||||
//don't block if we are in beacon mode
|
||||
if beac != nil && sess == nil {
|
||||
go func() {
|
||||
registerResp, err := con.Rpc.RegisterExtension(context.Background(), &sliverpb.RegisterExtensionReq{
|
||||
Name: name,
|
||||
Data: binData,
|
||||
OS: goos,
|
||||
Init: ext.Init,
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
})
|
||||
if err != nil {
|
||||
con.PrintErrorf("Error registering extension: %s\n", err)
|
||||
}
|
||||
if registerResp.Response != nil && registerResp.Response.Err != "" {
|
||||
con.PrintErrorf("Error registering extension: %s\n", errors.New(registerResp.Response.Err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
//session mode (hopefully)
|
||||
registerResp, err := con.Rpc.RegisterExtension(context.Background(), &sliverpb.RegisterExtensionReq{
|
||||
Name: name,
|
||||
Data: binData,
|
||||
OS: goos,
|
||||
Init: ext.Init,
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
})
|
||||
ctrl <- true
|
||||
<-ctrl
|
||||
if sess != nil {
|
||||
ctrl <- true
|
||||
<-ctrl
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if registerResp == nil {
|
||||
return errors.New("received empty register extension response")
|
||||
}
|
||||
if registerResp.Response != nil && registerResp.Response.Err != "" {
|
||||
return errors.New(registerResp.Response.Err)
|
||||
}
|
||||
@@ -531,6 +544,89 @@ func loadDep(goos string, goarch string, depName string, cmd *cobra.Command, con
|
||||
return fmt.Errorf("missing dependency %s", depName)
|
||||
}
|
||||
|
||||
func planExtensionExecution(ext *ExtCommand, binPath string, capabilities uint64) (extensionExecutionMode, error) {
|
||||
isBOF := ext.BOFExecutor != "" || strings.EqualFold(filepath.Ext(binPath), ".o")
|
||||
if !isBOF {
|
||||
return extensionExecutionNative, nil
|
||||
}
|
||||
dependsOn := strings.TrimSpace(ext.DependsOn)
|
||||
|
||||
switch ext.BOFExecutor {
|
||||
case BOFExecutorCOFFLoader:
|
||||
if dependsOn == "" {
|
||||
return extensionExecutionNative, fmt.Errorf("BOF command %q selects %q but has no depends_on loader", ext.CommandName, BOFExecutorCOFFLoader)
|
||||
}
|
||||
return extensionExecutionCOFFLoader, nil
|
||||
case "":
|
||||
// Existing manifests infer the loader from depends_on. Keep that route
|
||||
// even when the target supports the built-in executor.
|
||||
if dependsOn != "" {
|
||||
return extensionExecutionCOFFLoader, nil
|
||||
}
|
||||
case BOFExecutorReflektor:
|
||||
default:
|
||||
return extensionExecutionNative, fmt.Errorf("BOF command %q has invalid bof_executor %q", ext.CommandName, ext.BOFExecutor)
|
||||
}
|
||||
|
||||
if capabilities&sliverpb.CapabilityBOFV1 != 0 {
|
||||
return extensionExecutionReflektor, nil
|
||||
}
|
||||
if dependsOn != "" {
|
||||
return extensionExecutionCOFFLoader, nil
|
||||
}
|
||||
return extensionExecutionNative, fmt.Errorf("target does not support built-in Reflektor BOF execution and command %q has no depends_on fallback", ext.CommandName)
|
||||
}
|
||||
|
||||
func activeTargetCapabilities(session *clientpb.Session, beacon *clientpb.Beacon) uint64 {
|
||||
if session != nil {
|
||||
return session.Capabilities
|
||||
}
|
||||
if beacon != nil {
|
||||
return beacon.Capabilities
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func buildCallExtensionRequest(goos string, goarch string, mode extensionExecutionMode, ext *ExtCommand, binPath string, entryPoint string, extensionArgs []byte) (*sliverpb.CallExtensionReq, error) {
|
||||
extData, err := os.ReadFile(binPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bd := sha256.Sum256(extData)
|
||||
name := hex.EncodeToString(bd[:])
|
||||
|
||||
if mode == extensionExecutionCOFFLoader {
|
||||
loader, found := loadedExtensions[ext.DependsOn]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("attempted to load non-existing extension: %s", ext.DependsOn)
|
||||
}
|
||||
loaderPath, err := loader.getFileForTarget(goos, goarch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get file for extension %s: %w", ext.DependsOn, err)
|
||||
}
|
||||
loaderData, err := os.ReadFile(loaderPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dep read file error: %w", err)
|
||||
}
|
||||
if len(loaderData) == 0 {
|
||||
return nil, fmt.Errorf("read empty file: %s", loaderPath)
|
||||
}
|
||||
bd = sha256.Sum256(loaderData)
|
||||
name = hex.EncodeToString(bd[:])
|
||||
}
|
||||
|
||||
req := &sliverpb.CallExtensionReq{
|
||||
Name: name,
|
||||
Export: entryPoint,
|
||||
Args: extensionArgs,
|
||||
}
|
||||
if mode == extensionExecutionReflektor {
|
||||
req.BOFData = extData
|
||||
req.IsBOF = true
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
// The command runs with DisableFlagParsing, so args is the raw, unstripped
|
||||
// slice. Separate Sliver-owned flags from the extension's own arguments and
|
||||
@@ -570,27 +666,32 @@ func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
|
||||
return
|
||||
}
|
||||
|
||||
checkCache := session != nil
|
||||
if err = loadExtension(goos, goarch, checkCache, ext, cmd, con); err != nil {
|
||||
con.PrintErrorf("Could not load extension: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
binPath, err := ext.getFileForTarget(goos, goarch)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to read extension file: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
isBOF := filepath.Ext(binPath) == ".o"
|
||||
executionMode, err := planExtensionExecution(ext, binPath, activeTargetCapabilities(session, beacon))
|
||||
if err != nil {
|
||||
con.PrintErrorf("Could not execute extension: %s\n", err)
|
||||
return
|
||||
}
|
||||
if executionMode != extensionExecutionReflektor {
|
||||
checkCache := session != nil
|
||||
if err = loadExtension(goos, goarch, checkCache, ext, cmd, con); err != nil {
|
||||
con.PrintErrorf("Could not load extension: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// BOFs (Beacon Object Files) are a specific kind of extensions
|
||||
// that require another extension (a COFF loader) to be present.
|
||||
// that either use the built-in Reflektor executor or another extension.
|
||||
// BOFs also have strongly typed arguments that need to be parsed in the proper way.
|
||||
// This block will pack both the BOF data and its arguments into a single buffer that
|
||||
// the loader will extract and load.
|
||||
if isBOF {
|
||||
// Beacon Object File -- requires a COFF loader
|
||||
switch executionMode {
|
||||
case extensionExecutionCOFFLoader:
|
||||
// Legacy COFF loaders expect the entrypoint, object, and typed argument
|
||||
// buffer packed into the existing outer argument envelope.
|
||||
extensionArgs, err = getBOFArgs(cmd, args, binPath, ext)
|
||||
if err != nil {
|
||||
con.PrintErrorf("BOF args error: %s\n", err)
|
||||
@@ -598,7 +699,15 @@ func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
|
||||
}
|
||||
extName = ext.DependsOn
|
||||
entryPoint = loadedExtensions[extName].Entrypoint // should exist at this point
|
||||
} else {
|
||||
case extensionExecutionReflektor:
|
||||
extensionArgs, err = ParseFlagArgumentsToBuffer(cmd, args, binPath, ext)
|
||||
if err != nil {
|
||||
con.PrintErrorf("BOF args error: %s\n", err)
|
||||
return
|
||||
}
|
||||
extName = ext.CommandName
|
||||
entryPoint = ext.Entrypoint
|
||||
default:
|
||||
// Regular DLL - Just join the arguments with spaces
|
||||
if len(args) > 0 {
|
||||
extensionArgs = []byte(strings.Join(args, " "))
|
||||
@@ -612,45 +721,15 @@ func runExtensionCmd(cmd *cobra.Command, con *console.SliverClient, args []strin
|
||||
ctrl := make(chan bool)
|
||||
msg := fmt.Sprintf("Executing %s ...", cmd.Name())
|
||||
con.SpinUntil(msg, ctrl)
|
||||
extdata, err := os.ReadFile(binPath)
|
||||
callExtReq, err := buildCallExtensionRequest(goos, goarch, executionMode, ext, binPath, entryPoint, extensionArgs)
|
||||
if err != nil {
|
||||
con.PrintErrorf("ext read file error: %s\n", err)
|
||||
ctrl <- true
|
||||
<-ctrl
|
||||
con.PrintErrorf("Could not prepare extension call: %s\n", err)
|
||||
return
|
||||
}
|
||||
bd := sha256.Sum256(extdata)
|
||||
name := hex.EncodeToString(bd[:])
|
||||
if isBOF {
|
||||
//if we are using a bof, we are actually calling the coffloader extension - so get the file from dep ref and use that shasum
|
||||
|
||||
if extension, found := loadedExtensions[ext.DependsOn]; found {
|
||||
dep, err := extension.getFileForTarget(goos, goarch)
|
||||
if err != nil {
|
||||
con.PrintErrorf("could not get file for extension %s", ext.DependsOn)
|
||||
return
|
||||
}
|
||||
depdata, err := os.ReadFile(dep)
|
||||
if err != nil {
|
||||
con.PrintErrorf("dep read file error: %s\n", err)
|
||||
return
|
||||
}
|
||||
if len(depdata) == 0 {
|
||||
con.PrintErrorf("read empty file: %s\n", dep)
|
||||
return
|
||||
}
|
||||
bd = sha256.Sum256(depdata)
|
||||
name = hex.EncodeToString(bd[:])
|
||||
} else {
|
||||
// handle error
|
||||
con.PrintErrorf("attempted to load non-existing extension: %s", ext.DependsOn)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
callExtResp, err := con.Rpc.CallExtension(context.Background(), &sliverpb.CallExtensionReq{
|
||||
Name: name,
|
||||
Export: entryPoint,
|
||||
Args: extensionArgs,
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
})
|
||||
callExtReq.Request = con.ActiveTarget.Request(cmd)
|
||||
callExtResp, err := con.Rpc.CallExtension(context.Background(), callExtReq)
|
||||
ctrl <- true
|
||||
<-ctrl
|
||||
if err != nil {
|
||||
@@ -865,37 +944,22 @@ func makeExtensionFlagNameCompletion(extCmd *ExtCommand) carapace.Action {
|
||||
}
|
||||
|
||||
func makeCommandPlatformFilters(extCmd *ExtCommand) map[string]string {
|
||||
filtersOS := make(map[string]bool)
|
||||
filtersArch := make(map[string]bool)
|
||||
|
||||
var all []string
|
||||
|
||||
// Only add filters for architectures when there OS matters.
|
||||
supportedTargets := make(map[string]struct{}, len(extCmd.Files))
|
||||
for _, file := range extCmd.Files {
|
||||
filtersOS[file.OS] = true
|
||||
|
||||
if filtersOS[file.OS] {
|
||||
filtersArch[file.Arch] = true
|
||||
}
|
||||
supportedTargets[file.OS+"/"+file.Arch] = struct{}{}
|
||||
}
|
||||
|
||||
for os, enabled := range filtersOS {
|
||||
if enabled {
|
||||
all = append(all, os)
|
||||
// Console command filters are hide-on-match, so annotate the command with
|
||||
// every exact target pair it does not support. A separate fallback hides all
|
||||
// native extensions on target pairs outside the supported loader matrix.
|
||||
filters := []string{consts.NativeExtensionUnsupportedTargetFilter}
|
||||
for _, target := range consts.NativeExtensionTargets() {
|
||||
if _, supported := supportedTargets[target.GOOS+"/"+target.GOARCH]; !supported {
|
||||
filters = append(filters, target.Filter)
|
||||
}
|
||||
}
|
||||
|
||||
for arch, enabled := range filtersArch {
|
||||
if enabled {
|
||||
all = append(all, arch)
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
appConsole.CommandFilterKey: strings.Join(all, ","),
|
||||
appConsole.CommandFilterKey: strings.Join(filters, ","),
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
@@ -100,21 +99,11 @@ func RemoveExtensionByManifestName(manifestName string, con *console.SliverClien
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If path is outside extensions directory, prompt for confirmation
|
||||
if !strings.HasPrefix(extPath, assets.GetExtensionsDir()) {
|
||||
confirm := false
|
||||
_ = forms.Confirm(fmt.Sprintf("Remove '%s' extension directory from filesystem?", manifestName), &confirm)
|
||||
if !confirm {
|
||||
// Skip the forceRemoveAll but continue with the rest
|
||||
delete(loadedManifests, manifestName)
|
||||
for _, cmd := range man.ExtCommand {
|
||||
delete(loadedExtensions, cmd.CommandName)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
// Only installed extensions are owned by Sliver and may be deleted.
|
||||
// Temporarily loaded extensions can live in arbitrary source directories.
|
||||
if isWithinExtensionsDir(extPath) {
|
||||
forceRemoveAll(extPath)
|
||||
}
|
||||
|
||||
forceRemoveAll(extPath)
|
||||
delete(loadedManifests, manifestName)
|
||||
for _, cmd := range man.ExtCommand {
|
||||
delete(loadedExtensions, cmd.CommandName)
|
||||
@@ -124,6 +113,14 @@ func RemoveExtensionByManifestName(manifestName string, con *console.SliverClien
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func isWithinExtensionsDir(extPath string) bool {
|
||||
relPath, err := filepath.Rel(assets.GetExtensionsDir(), extPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return relPath != "." && filepath.IsLocal(relPath)
|
||||
}
|
||||
|
||||
func forceRemoveAll(rootPath string) {
|
||||
util.ChmodR(rootPath, 0o600, 0o700)
|
||||
os.RemoveAll(rootPath)
|
||||
|
||||
@@ -87,6 +87,7 @@ var (
|
||||
"linux/arm64": true,
|
||||
"windows/386": true,
|
||||
"windows/amd64": true,
|
||||
"windows/arm64": true,
|
||||
}
|
||||
|
||||
ErrNoExternalBuilder = errors.New("no external builders are available")
|
||||
|
||||
@@ -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,299 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type corpusRPC struct {
|
||||
rpcpb.SliverRPCClient
|
||||
|
||||
mu sync.Mutex
|
||||
sessions []*clientpb.Session
|
||||
calls []*sliverpb.CallExtensionReq
|
||||
}
|
||||
|
||||
func (rpc *corpusRPC) GetSessions(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Sessions, error) {
|
||||
return &clientpb.Sessions{Sessions: rpc.sessions}, nil
|
||||
}
|
||||
|
||||
func (*corpusRPC) GetBeacons(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Beacons, error) {
|
||||
return &clientpb.Beacons{}, nil
|
||||
}
|
||||
|
||||
func (*corpusRPC) GetBeaconTaskContent(context.Context, *clientpb.BeaconTask, ...grpc.CallOption) (*clientpb.BeaconTask, error) {
|
||||
return nil, fmt.Errorf("unexpected beacon task poll in session corpus test")
|
||||
}
|
||||
|
||||
func (rpc *corpusRPC) CallExtension(_ context.Context, request *sliverpb.CallExtensionReq, _ ...grpc.CallOption) (*sliverpb.CallExtension, error) {
|
||||
rpc.mu.Lock()
|
||||
rpc.calls = append(rpc.calls, proto.Clone(request).(*sliverpb.CallExtensionReq))
|
||||
rpc.mu.Unlock()
|
||||
return &sliverpb.CallExtension{Response: &commonpb.Response{}}, nil
|
||||
}
|
||||
|
||||
func (rpc *corpusRPC) snapshotCalls() []*sliverpb.CallExtensionReq {
|
||||
rpc.mu.Lock()
|
||||
defer rpc.mu.Unlock()
|
||||
return append([]*sliverpb.CallExtensionReq(nil), rpc.calls...)
|
||||
}
|
||||
|
||||
type corpusPromptUI struct{}
|
||||
|
||||
func (corpusPromptUI) Confirm(_ string, defaultValue bool) (bool, error) {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
func (corpusPromptUI) Input(_ string, defaultValue string) (string, error) {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
type corpusOutput struct{}
|
||||
|
||||
func (corpusOutput) Printf(string, ...any) {}
|
||||
func (corpusOutput) PrintInfof(string, ...any) {}
|
||||
func (corpusOutput) PrintSuccessf(string, ...any) {}
|
||||
func (corpusOutput) PrintErrorf(string, ...any) {}
|
||||
|
||||
func newCorpusManager(t *testing.T, rpc rpcpb.SliverRPCClient) *Manager {
|
||||
t.Helper()
|
||||
|
||||
client := console.NewConsole(false)
|
||||
client.Rpc = rpc
|
||||
manager, err := newManagerWithOutput(client, corpusPromptUI{}, corpusOutput{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = manager.runtime.Close(context.Background())
|
||||
})
|
||||
return manager
|
||||
}
|
||||
|
||||
func materializeCorpusScript(t *testing.T, fixture, scriptName, objectPath string, object []byte) string {
|
||||
t.Helper()
|
||||
|
||||
source, err := os.ReadFile(filepath.Join("testdata", "corpus", filepath.FromSlash(fixture)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
scriptPath := filepath.Join(root, scriptName)
|
||||
if err := os.WriteFile(scriptPath, source, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resourcePath := filepath.Join(root, filepath.FromSlash(objectPath))
|
||||
if err := os.MkdirAll(filepath.Dir(resourcePath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(resourcePath, object, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
func expectedStringArguments(value string) []byte {
|
||||
payload := make([]byte, 4+len(value)+1)
|
||||
payload[0] = byte(len(value) + 1)
|
||||
payload[1] = byte((len(value) + 1) >> 8)
|
||||
payload[2] = byte((len(value) + 1) >> 16)
|
||||
payload[3] = byte((len(value) + 1) >> 24)
|
||||
copy(payload[4:], value)
|
||||
arguments := make([]byte, 4+len(payload))
|
||||
arguments[0] = byte(len(payload))
|
||||
arguments[1] = byte(len(payload) >> 8)
|
||||
arguments[2] = byte(len(payload) >> 16)
|
||||
arguments[3] = byte(len(payload) >> 24)
|
||||
copy(arguments[4:], payload)
|
||||
return arguments
|
||||
}
|
||||
|
||||
func TestSliverArmoryCNACorpusPinnedHashes(t *testing.T) {
|
||||
fixtures := []struct {
|
||||
path string
|
||||
sha256 string
|
||||
}{
|
||||
{
|
||||
path: "bof_collection/cat.cna",
|
||||
sha256: "94c7bcaae209a6355dcc8c126019f6e19a681173680955166b8c30cf97fc66f7",
|
||||
},
|
||||
{
|
||||
path: "firefoxdump/firefoxdump.cna",
|
||||
sha256: "c8c9ef28675d16dd1c8786f055c08ce2096eb0fedb16fad68b78f22c81b1bbde",
|
||||
},
|
||||
{
|
||||
path: "operatorskit/finddotnet.cna",
|
||||
sha256: "897dbee453ed504be7e9ace1229e7070f131a9351fb09a723629dd8e0337aa03",
|
||||
},
|
||||
{
|
||||
path: "operatorskit/findsysmon.cna",
|
||||
sha256: "1579dcca9e1c0ab7b20a646ff2db7705e05468c067a8ed6b3c35b553f10458d7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, fixture := range fixtures {
|
||||
fixture := fixture
|
||||
t.Run(fixture.path, func(t *testing.T) {
|
||||
content, err := os.ReadFile(filepath.Join("testdata", "corpus", filepath.FromSlash(fixture.path)))
|
||||
if err != nil {
|
||||
t.Fatalf("read pinned corpus fixture: %v", err)
|
||||
}
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(content))
|
||||
if digest != fixture.sha256 {
|
||||
t.Fatalf("SHA-256 = %s, want %s", digest, fixture.sha256)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type corpusDispatchTest struct {
|
||||
name string
|
||||
fixture string
|
||||
scriptName string
|
||||
objectPath string
|
||||
alias string
|
||||
arguments []string
|
||||
packedString *string
|
||||
objectContent []byte
|
||||
}
|
||||
|
||||
func TestSliverArmoryCNACorpusDispatchesBOFs(t *testing.T) {
|
||||
tests := []corpusDispatchTest{
|
||||
{
|
||||
name: "FirefoxDump all",
|
||||
fixture: "firefoxdump/firefoxdump.cna",
|
||||
scriptName: "firefoxdump.cna",
|
||||
objectPath: "bin/firefoxdump.x64.o",
|
||||
alias: "firefoxdump",
|
||||
arguments: []string{"/all"},
|
||||
packedString: stringPointer("/all"),
|
||||
objectContent: []byte("firefoxdump-x64-object"),
|
||||
},
|
||||
{
|
||||
name: "bof_collection cat path with spaces",
|
||||
fixture: "bof_collection/cat.cna",
|
||||
scriptName: "cat.cna",
|
||||
objectPath: "dist/cat.x64.o",
|
||||
alias: "cat",
|
||||
arguments: []string{`C:\Program`, `Files\notes.txt`},
|
||||
packedString: stringPointer(`C:\Program Files\notes.txt`),
|
||||
objectContent: []byte("cat-x64-object"),
|
||||
},
|
||||
{
|
||||
name: "OperatorsKit FindDotnet no arguments",
|
||||
fixture: "operatorskit/finddotnet.cna",
|
||||
scriptName: "finddotnet.cna",
|
||||
objectPath: "finddotnet.o",
|
||||
alias: "finddotnet",
|
||||
objectContent: []byte("finddotnet-x64-object"),
|
||||
},
|
||||
{
|
||||
name: "OperatorsKit FindSysmon registry",
|
||||
fixture: "operatorskit/findsysmon.cna",
|
||||
scriptName: "findsysmon.cna",
|
||||
objectPath: "findsysmon.o",
|
||||
alias: "findsysmon",
|
||||
arguments: []string{"reg"},
|
||||
packedString: stringPointer("reg"),
|
||||
objectContent: []byte("findsysmon-x64-object"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) { runCorpusDispatchTest(t, test) })
|
||||
}
|
||||
}
|
||||
|
||||
func runCorpusDispatchTest(t *testing.T, test corpusDispatchTest) {
|
||||
t.Helper()
|
||||
session := &clientpb.Session{
|
||||
ID: "session-corpus",
|
||||
Name: "corpus",
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
Capabilities: sliverpb.CapabilityBOFV1,
|
||||
}
|
||||
rpc := &corpusRPC{sessions: []*clientpb.Session{session}}
|
||||
manager := newCorpusManager(t, rpc)
|
||||
scriptPath := materializeCorpusScript(
|
||||
t, test.fixture, test.scriptName, test.objectPath, test.objectContent,
|
||||
)
|
||||
|
||||
loadedPath, err := manager.Load(context.Background(), scriptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load pinned %s fixture: %v", test.fixture, err)
|
||||
}
|
||||
if loadedPath != scriptPath {
|
||||
t.Fatalf("loaded path = %q, want %q", loadedPath, scriptPath)
|
||||
}
|
||||
assertCorpusAliases(t, manager.aliasNames(), test.alias)
|
||||
|
||||
rawInput := strings.Join(append([]string{test.alias}, test.arguments...), " ")
|
||||
if err := manager.invokeAlias(context.Background(), test.alias, rawInput, test.arguments, session.ID); err != nil {
|
||||
t.Fatalf("invoke %s: %v", test.alias, err)
|
||||
}
|
||||
|
||||
calls := rpc.snapshotCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("CallExtension calls = %d, want 1", len(calls))
|
||||
}
|
||||
assertCorpusCall(t, calls[0], test, session.ID)
|
||||
|
||||
if _, err := manager.Unload(context.Background(), scriptPath); err != nil {
|
||||
t.Fatalf("unload %s: %v", test.alias, err)
|
||||
}
|
||||
if names := manager.aliasNames(); len(names) != 0 {
|
||||
t.Fatalf("aliases after unload = %v, want none", names)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCorpusAliases(t *testing.T, names []string, want string) {
|
||||
t.Helper()
|
||||
if len(names) != 1 || names[0] != want {
|
||||
t.Fatalf("registered aliases = %v, want [%s]", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCorpusCall(t *testing.T, call *sliverpb.CallExtensionReq, test corpusDispatchTest, sessionID string) {
|
||||
t.Helper()
|
||||
if !call.IsBOF || call.Export != "go" {
|
||||
t.Fatalf("CallExtension BOF/export = %v/%q, want true/go", call.IsBOF, call.Export)
|
||||
}
|
||||
if !bytes.Equal(call.BOFData, test.objectContent) {
|
||||
t.Fatalf("CallExtension BOF bytes = %q, want %q", call.BOFData, test.objectContent)
|
||||
}
|
||||
wantArguments := []byte{0, 0, 0, 0}
|
||||
if test.packedString != nil {
|
||||
wantArguments = expectedStringArguments(*test.packedString)
|
||||
}
|
||||
if !bytes.Equal(call.Args, wantArguments) {
|
||||
t.Fatalf("CallExtension args = %x, want Reflektor framing %x", call.Args, wantArguments)
|
||||
}
|
||||
if call.GetRequest().GetSessionID() != sessionID || call.GetRequest().GetAsync() {
|
||||
t.Fatalf("CallExtension request = %#v, want synchronous session %s", call.GetRequest(), sessionID)
|
||||
}
|
||||
if len(call.Name) != 64 {
|
||||
t.Fatalf("CallExtension name digest length = %d, want 64", len(call.Name))
|
||||
}
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
opforengine "github.com/sliverarmory/opfor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Manager owns one long-lived OPFOR runtime for a Sliver client. Keeping the
|
||||
// runtime alive is important: CNA aliases, hooks, and callbacks are scoped to
|
||||
// the Script returned by Runtime.Load.
|
||||
type Manager struct {
|
||||
client *console.SliverClient
|
||||
output clientOutput
|
||||
|
||||
runtime *opforengine.Runtime
|
||||
ui promptUI
|
||||
|
||||
loadMu sync.Mutex
|
||||
commandMu sync.Mutex
|
||||
promptMu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
scripts map[string]*opforengine.Script
|
||||
aliases map[string][]opforengine.Binding
|
||||
roots map[*cobra.Command]map[string]*cobra.Command
|
||||
}
|
||||
|
||||
type managerEntry struct {
|
||||
manager *Manager
|
||||
err error
|
||||
}
|
||||
|
||||
const oneShotRuntimeCloseTimeout = 5 * time.Second
|
||||
|
||||
var (
|
||||
clientManagersMu sync.Mutex
|
||||
clientManagers = map[*console.SliverClient]managerEntry{}
|
||||
)
|
||||
|
||||
func managerFor(client *console.SliverClient) (*Manager, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("opfor: Sliver client is nil")
|
||||
}
|
||||
|
||||
clientManagersMu.Lock()
|
||||
defer clientManagersMu.Unlock()
|
||||
if entry, found := clientManagers[client]; found {
|
||||
return entry.manager, entry.err
|
||||
}
|
||||
|
||||
manager, err := newManager(client, formsPromptUI{})
|
||||
clientManagers[client] = managerEntry{manager: manager, err: err}
|
||||
return manager, err
|
||||
}
|
||||
|
||||
func newManager(client *console.SliverClient, ui promptUI) (*Manager, error) {
|
||||
return newManagerWithOutput(client, ui, client)
|
||||
}
|
||||
|
||||
func newManagerWithOutput(client *console.SliverClient, ui promptUI, output clientOutput) (*Manager, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("opfor: Sliver client is nil")
|
||||
}
|
||||
if ui == nil {
|
||||
return nil, errors.New("opfor: prompt UI is nil")
|
||||
}
|
||||
if output == nil {
|
||||
return nil, errors.New("opfor: output sink is nil")
|
||||
}
|
||||
|
||||
manager := &Manager{
|
||||
client: client,
|
||||
output: output,
|
||||
ui: ui,
|
||||
scripts: map[string]*opforengine.Script{},
|
||||
aliases: map[string][]opforengine.Binding{},
|
||||
roots: map[*cobra.Command]map[string]*cobra.Command{},
|
||||
}
|
||||
|
||||
runtime, err := manager.newRuntime(true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opfor: create runtime: %w", err)
|
||||
}
|
||||
manager.runtime = runtime
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) newRuntime(observeBindings bool) (*opforengine.Runtime, error) {
|
||||
stdout := clientWriter{output: manager.output}
|
||||
stderr := clientWriter{output: manager.output, stderr: true}
|
||||
options := []opforengine.Option{
|
||||
opforengine.WithBOFPackByteOrder(opforengine.BOFPackLittleEndian),
|
||||
opforengine.WithAggressorBeaconExecutionProvider(
|
||||
opforengine.AggressorBeaconExecutionProviderFunc(manager.executeBeacon),
|
||||
),
|
||||
opforengine.WithAggressorSessionQueryProvider(
|
||||
opforengine.AggressorSessionQueryProviderFunc(manager.querySession),
|
||||
),
|
||||
opforengine.WithAggressorBeaconTranscriptSink(
|
||||
opforengine.AggressorBeaconTranscriptSinkFunc(manager.publishTranscript),
|
||||
),
|
||||
opforengine.WithAggressorPromptProvider(
|
||||
opforengine.AggressorPromptProviderFunc(manager.presentPrompt),
|
||||
),
|
||||
opforengine.WithStdout(stdout),
|
||||
opforengine.WithStderr(stderr),
|
||||
}
|
||||
if observeBindings {
|
||||
options = append(options, opforengine.WithBindingObserver(manager))
|
||||
}
|
||||
return opforengine.New(options...)
|
||||
}
|
||||
|
||||
// Registered observes declarations published by loaded scripts. Only Beacon
|
||||
// aliases become Sliver commands; other binding families remain available to
|
||||
// OPFOR internally for hooks, events, and callback composition.
|
||||
func (manager *Manager) Registered(_ context.Context, binding opforengine.Binding) error {
|
||||
if binding.Kind != opforengine.BindingAlias {
|
||||
return nil
|
||||
}
|
||||
manager.mu.Lock()
|
||||
manager.aliases[binding.Name] = append(manager.aliases[binding.Name], binding)
|
||||
manager.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregistered removes the exact binding generation which retired. An older
|
||||
// alias with the same name remains visible when scripts were layered.
|
||||
func (manager *Manager) Unregistered(_ context.Context, binding opforengine.Binding) error {
|
||||
if binding.Kind != opforengine.BindingAlias {
|
||||
return nil
|
||||
}
|
||||
manager.mu.Lock()
|
||||
bindings := manager.aliases[binding.Name]
|
||||
for index := len(bindings) - 1; index >= 0; index-- {
|
||||
if bindings[index].ID == binding.ID && bindings[index].Script == binding.Script {
|
||||
bindings = append(bindings[:index], bindings[index+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
delete(manager.aliases, binding.Name)
|
||||
} else {
|
||||
manager.aliases[binding.Name] = bindings
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load compiles and executes a CNA, retaining its registrations until unload.
|
||||
func (manager *Manager) Load(ctx context.Context, path string) (string, error) {
|
||||
absolute, content, err := readCNAFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
manager.loadMu.Lock()
|
||||
defer manager.loadMu.Unlock()
|
||||
|
||||
manager.mu.RLock()
|
||||
_, exists := manager.scripts[absolute]
|
||||
manager.mu.RUnlock()
|
||||
if exists {
|
||||
return "", fmt.Errorf("opfor: script is already loaded: %s", absolute)
|
||||
}
|
||||
|
||||
program, err := manager.runtime.Compile(opforengine.NewSource(absolute, content))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opfor: compile %s: %w", absolute, err)
|
||||
}
|
||||
script, err := manager.runtime.Load(ctx, program)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opfor: load %s: %w", absolute, err)
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
manager.scripts[absolute] = script
|
||||
manager.mu.Unlock()
|
||||
manager.syncAllRoots()
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
// Check compiles a CNA without creating a Script or executing top-level code.
|
||||
// Standalone compilation intentionally mirrors the OPFOR CLI check command and
|
||||
// avoids charging the long-lived Sliver runtime's source budget.
|
||||
func (manager *Manager) Check(path string) (string, error) {
|
||||
absolute, _, err := compileCNAFile(path)
|
||||
return absolute, err
|
||||
}
|
||||
|
||||
// Run executes a CNA once with arguments exposed through Sleep's @ARGV. Any
|
||||
// aliases, hooks, callbacks, or events registered by the script are retired
|
||||
// before this method returns, matching the OPFOR CLI run command.
|
||||
func (manager *Manager) Run(ctx context.Context, path string, arguments []string) (string, error) {
|
||||
absolute, program, err := compileCNAFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := make([]opforengine.Value, len(arguments))
|
||||
for index, argument := range arguments {
|
||||
values[index] = opforengine.String(argument)
|
||||
}
|
||||
|
||||
// A one-shot runtime matches the standalone OPFOR CLI and prevents
|
||||
// temporary registrations from becoming visible to the persistent Sliver
|
||||
// manager. It retains all Sliver providers but deliberately omits the
|
||||
// binding observer used to publish persistent aliases.
|
||||
runtime, err := manager.newRuntime(false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opfor: create runtime: %w", err)
|
||||
}
|
||||
_, executeErr := runtime.Execute(ctx, program, values...)
|
||||
closeCtx, cancel := context.WithTimeout(context.Background(), oneShotRuntimeCloseTimeout)
|
||||
closeErr := runtime.Close(closeCtx)
|
||||
cancel()
|
||||
if executeErr != nil || closeErr != nil {
|
||||
var runErr error
|
||||
if executeErr != nil {
|
||||
runErr = fmt.Errorf("opfor: execute %s: %w", absolute, executeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
runErr = errors.Join(runErr, fmt.Errorf("opfor: close runtime: %w", closeErr))
|
||||
}
|
||||
return "", runErr
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
// Unload retires a loaded CNA and all registrations owned by that script.
|
||||
func (manager *Manager) Unload(ctx context.Context, path string) (string, error) {
|
||||
manager.loadMu.Lock()
|
||||
defer manager.loadMu.Unlock()
|
||||
|
||||
absolute, script, err := manager.findScript(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := script.Unload(ctx); err != nil {
|
||||
return "", fmt.Errorf("opfor: unload %s: %w", absolute, err)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
delete(manager.scripts, absolute)
|
||||
manager.mu.Unlock()
|
||||
manager.syncAllRoots()
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) findScript(path string) (string, *opforengine.Script, error) {
|
||||
absolute, absoluteErr := filepath.Abs(path)
|
||||
if absoluteErr == nil {
|
||||
absolute = filepath.Clean(absolute)
|
||||
}
|
||||
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
if absoluteErr == nil {
|
||||
if script := manager.scripts[absolute]; script != nil {
|
||||
return absolute, script, nil
|
||||
}
|
||||
}
|
||||
|
||||
base := filepath.Base(path)
|
||||
var matchedPath string
|
||||
var matchedScript *opforengine.Script
|
||||
for candidate, script := range manager.scripts {
|
||||
if filepath.Base(candidate) != base {
|
||||
continue
|
||||
}
|
||||
if matchedScript != nil {
|
||||
return "", nil, fmt.Errorf("opfor: script name %q is ambiguous; use an absolute path", path)
|
||||
}
|
||||
matchedPath, matchedScript = candidate, script
|
||||
}
|
||||
if matchedScript == nil {
|
||||
return "", nil, fmt.Errorf("opfor: script is not loaded: %s", path)
|
||||
}
|
||||
return matchedPath, matchedScript, nil
|
||||
}
|
||||
|
||||
// Paths returns the sorted absolute paths of all loaded CNA scripts.
|
||||
func (manager *Manager) Paths() []string {
|
||||
manager.mu.RLock()
|
||||
paths := make([]string, 0, len(manager.scripts))
|
||||
for path := range manager.scripts {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func (manager *Manager) aliasNames() []string {
|
||||
manager.mu.RLock()
|
||||
names := make([]string, 0, len(manager.aliases))
|
||||
for name, bindings := range manager.aliases {
|
||||
if len(bindings) != 0 {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (manager *Manager) aliasBindingCounts() map[string]int {
|
||||
manager.mu.RLock()
|
||||
counts := make(map[string]int, len(manager.aliases))
|
||||
for name, bindings := range manager.aliases {
|
||||
if len(bindings) != 0 {
|
||||
counts[name] = len(bindings)
|
||||
}
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
return counts
|
||||
}
|
||||
|
||||
func (manager *Manager) hasAlias(name string) bool {
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
return len(manager.aliases[name]) != 0
|
||||
}
|
||||
|
||||
func (manager *Manager) aliasMetadata(name string) (opforengine.AggressorCommandMetadata, error) {
|
||||
if reservedManagementAlias(name) || !manager.hasAlias(name) {
|
||||
return opforengine.AggressorCommandMetadata{}, fmt.Errorf("opfor: unknown CNA alias %q", name)
|
||||
}
|
||||
catalog, err := manager.runtime.SnapshotAggressorCommandCatalog(opforengine.AggressorCommandBeacon)
|
||||
if err != nil {
|
||||
return opforengine.AggressorCommandMetadata{}, fmt.Errorf("opfor: snapshot Beacon command help: %w", err)
|
||||
}
|
||||
for _, metadata := range catalog.Commands {
|
||||
if metadata.Name == name {
|
||||
return metadata, nil
|
||||
}
|
||||
}
|
||||
return opforengine.AggressorCommandMetadata{Name: name}, nil
|
||||
}
|
||||
|
||||
func absoluteCNAPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", errors.New("opfor: CNA script path is empty")
|
||||
}
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opfor: resolve %s: %w", path, err)
|
||||
}
|
||||
absolute = filepath.Clean(absolute)
|
||||
if !strings.EqualFold(filepath.Ext(absolute), ".cna") {
|
||||
return "", fmt.Errorf("opfor: script must have a .cna extension: %s", absolute)
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
|
||||
func readCNAFile(path string) (string, []byte, error) {
|
||||
absolute, err := absoluteCNAPath(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
content, err := os.ReadFile(absolute)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("opfor: read %s: %w", absolute, err)
|
||||
}
|
||||
return absolute, content, nil
|
||||
}
|
||||
|
||||
func compileCNAFile(path string) (string, *opforengine.Program, error) {
|
||||
absolute, content, err := readCNAFile(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
program, err := opforengine.Compile(opforengine.NewSource(absolute, content))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("opfor: compile %s: %w", absolute, err)
|
||||
}
|
||||
return absolute, program, nil
|
||||
}
|
||||
|
||||
type clientWriter struct {
|
||||
output clientOutput
|
||||
stderr bool
|
||||
}
|
||||
|
||||
func (writer clientWriter) Write(data []byte) (int, error) {
|
||||
if writer.output == nil || len(data) == 0 {
|
||||
return len(data), nil
|
||||
}
|
||||
if writer.stderr {
|
||||
writer.output.PrintErrorf("%s", data)
|
||||
} else {
|
||||
writer.output.Printf("%s", data)
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
type clientOutput interface {
|
||||
Printf(string, ...any)
|
||||
PrintInfof(string, ...any)
|
||||
PrintSuccessf(string, ...any)
|
||||
PrintErrorf(string, ...any)
|
||||
}
|
||||
|
||||
var _ io.Writer = clientWriter{}
|
||||
var _ opforengine.BindingObserver = (*Manager)(nil)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,498 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
opforengine "github.com/sliverarmory/opfor"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
beaconTaskPollInterval = 250 * time.Millisecond
|
||||
bofRequestFallback = 59 * time.Second
|
||||
bofRequestDeadlineMargin = time.Second
|
||||
|
||||
bofOutputDefault = int32(0x00)
|
||||
bofOutputError = int32(0x0d)
|
||||
)
|
||||
|
||||
type opforRPC interface {
|
||||
GetSessions(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Sessions, error)
|
||||
GetBeacons(context.Context, *commonpb.Empty, ...grpc.CallOption) (*clientpb.Beacons, error)
|
||||
GetBeaconTaskContent(context.Context, *clientpb.BeaconTask, ...grpc.CallOption) (*clientpb.BeaconTask, error)
|
||||
CallExtension(context.Context, *sliverpb.CallExtensionReq, ...grpc.CallOption) (*sliverpb.CallExtension, error)
|
||||
}
|
||||
|
||||
var _ opforRPC = (rpcpb.SliverRPCClient)(nil)
|
||||
|
||||
type resolvedTarget struct {
|
||||
session *clientpb.Session
|
||||
beacon *clientpb.Beacon
|
||||
}
|
||||
|
||||
func (target resolvedTarget) id() string {
|
||||
if target.session != nil {
|
||||
return target.session.ID
|
||||
}
|
||||
if target.beacon != nil {
|
||||
return target.beacon.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (target resolvedTarget) capabilities() uint64 {
|
||||
if target.session != nil {
|
||||
return target.session.Capabilities
|
||||
}
|
||||
if target.beacon != nil {
|
||||
return target.beacon.Capabilities
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (target resolvedTarget) arch() string {
|
||||
if target.session != nil {
|
||||
return target.session.Arch
|
||||
}
|
||||
if target.beacon != nil {
|
||||
return target.beacon.Arch
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (manager *Manager) rpc() (opforRPC, error) {
|
||||
if manager == nil || manager.client == nil || manager.client.Rpc == nil {
|
||||
return nil, errors.New("opfor: Sliver RPC client is unavailable")
|
||||
}
|
||||
return manager.client.Rpc, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) resolveTarget(ctx context.Context, id string) (resolvedTarget, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return resolvedTarget{}, errors.New("opfor: target ID is empty")
|
||||
}
|
||||
rpc, err := manager.rpc()
|
||||
if err != nil {
|
||||
return resolvedTarget{}, err
|
||||
}
|
||||
sessions, err := rpc.GetSessions(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return resolvedTarget{}, fmt.Errorf("opfor: list sessions: %w", err)
|
||||
}
|
||||
for _, session := range sessions.GetSessions() {
|
||||
if session != nil && session.ID == id {
|
||||
return resolvedTarget{session: session}, nil
|
||||
}
|
||||
}
|
||||
beacons, err := rpc.GetBeacons(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return resolvedTarget{}, fmt.Errorf("opfor: list beacons: %w", err)
|
||||
}
|
||||
for _, beacon := range beacons.GetBeacons() {
|
||||
if beacon != nil && beacon.ID == id {
|
||||
return resolvedTarget{beacon: beacon}, nil
|
||||
}
|
||||
}
|
||||
return resolvedTarget{}, fmt.Errorf("opfor: target %q was not found", id)
|
||||
}
|
||||
|
||||
func (manager *Manager) executeBeacon(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
) (opforengine.Value, error) {
|
||||
targetID, call, err := manager.prepareBeaconExecution(ctx, request)
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
|
||||
rpc, err := manager.rpc()
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
response, err := rpc.CallExtension(ctx, call)
|
||||
if err != nil {
|
||||
executionErr := fmt.Errorf("opfor: execute BOF on %s: %w", targetID, err)
|
||||
return opforengine.Null(), notifyBOFLifecycleError(ctx, request, "", executionErr)
|
||||
}
|
||||
taskID := response.GetResponse().GetTaskID()
|
||||
response, err = manager.resolveCallExtensionResponse(ctx, rpc, response)
|
||||
if err != nil {
|
||||
return opforengine.Null(), notifyBOFLifecycleError(ctx, request, taskID, err)
|
||||
}
|
||||
if taskID == "" {
|
||||
taskID = response.GetResponse().GetTaskID()
|
||||
}
|
||||
|
||||
return manager.completeBeaconExecution(ctx, request, taskID, response)
|
||||
}
|
||||
|
||||
func (manager *Manager) prepareBeaconExecution(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
) (string, *sliverpb.CallExtensionReq, error) {
|
||||
if request.Kind != opforengine.AggressorBeaconInlineExecute {
|
||||
return "", nil, fmt.Errorf("opfor: unsupported Beacon execution function %q", request.Name)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
targetID := request.BeaconID.String()
|
||||
target, err := manager.resolveTarget(ctx, targetID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := beaconArchitecture(target.arch()); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if target.capabilities()&sliverpb.CapabilityBOFV1 == 0 {
|
||||
return "", nil, fmt.Errorf("opfor: target %q does not advertise the bof_v1 capability", targetID)
|
||||
}
|
||||
|
||||
bofData, ok := request.Content.Bytes()
|
||||
if !ok {
|
||||
return "", nil, errors.New("opfor: beacon_inline_execute BOF content is not a byte string")
|
||||
}
|
||||
packed, ok := request.PackedArguments.Bytes()
|
||||
if !ok {
|
||||
packed = []byte(request.PackedArguments.String())
|
||||
}
|
||||
arguments, err := prefixBOFArguments(packed)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
digest := sha256.Sum256(bofData)
|
||||
return targetID, &sliverpb.CallExtensionReq{
|
||||
Name: hex.EncodeToString(digest[:]),
|
||||
Args: arguments,
|
||||
Export: request.EntryPoint.String(),
|
||||
BOFData: bofData,
|
||||
IsBOF: true,
|
||||
// Older implants ignore this additive request bit and return legacy
|
||||
// flattened Output, which bofOutputRecords continues to accept.
|
||||
WantBOFOutputs: true,
|
||||
Request: targetRequest(ctx, target),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) completeBeaconExecution(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
taskID string,
|
||||
response *sliverpb.CallExtension,
|
||||
) (opforengine.Value, error) {
|
||||
records := bofOutputRecords(response)
|
||||
var executionErr error
|
||||
if response.GetResponse().GetErr() != "" {
|
||||
executionErr = fmt.Errorf("opfor: BOF execution failed: %s", response.GetResponse().GetErr())
|
||||
}
|
||||
if request.Callback == nil {
|
||||
manager.renderBOFOutputs(records)
|
||||
if executionErr != nil {
|
||||
return opforengine.Null(), executionErr
|
||||
}
|
||||
return opforengine.Null(), nil
|
||||
}
|
||||
|
||||
for index, record := range records {
|
||||
chunkNumber := index + 1
|
||||
isFinal := index == len(records)-1
|
||||
if err := invokeBOFOutputCallback(ctx, request, record, chunkNumber, isFinal); err != nil {
|
||||
callbackErr := fmt.Errorf("opfor: BOF result callback chunk %d: %w", chunkNumber, err)
|
||||
if executionErr != nil {
|
||||
return opforengine.Null(), errors.Join(executionErr, callbackErr)
|
||||
}
|
||||
return opforengine.Null(), callbackErr
|
||||
}
|
||||
}
|
||||
if executionErr != nil {
|
||||
return opforengine.Null(), notifyBOFLifecycleError(ctx, request, taskID, executionErr)
|
||||
}
|
||||
if taskID != "" {
|
||||
if err := invokeBOFLifecycleCallback(ctx, request, "task_completed", taskID, nil); err != nil {
|
||||
return opforengine.Null(), fmt.Errorf("opfor: BOF task_completed callback: %w", err)
|
||||
}
|
||||
} else if len(records) == 0 {
|
||||
if err := invokeBOFLifecycleCallback(ctx, request, "success", "", nil); err != nil {
|
||||
return opforengine.Null(), fmt.Errorf("opfor: BOF success callback: %w", err)
|
||||
}
|
||||
}
|
||||
return opforengine.Null(), nil
|
||||
}
|
||||
|
||||
// bofOutputRecords prefers the typed wire representation. Older servers and
|
||||
// implants populate only Output, which remains one default-channel record.
|
||||
func bofOutputRecords(response *sliverpb.CallExtension) []*sliverpb.BOFOutput {
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
if records := response.GetBOFOutputs(); len(records) != 0 {
|
||||
return records
|
||||
}
|
||||
if len(response.GetOutput()) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []*sliverpb.BOFOutput{{
|
||||
Type: bofOutputDefault,
|
||||
Data: response.GetOutput(),
|
||||
}}
|
||||
}
|
||||
|
||||
// The callback is deliberately multi-shot. Data records retain their exact
|
||||
// byte strings and raw Cobalt output channel. chunk_num is one-based and
|
||||
// is_final marks the final data record, independently of task lifecycle events.
|
||||
func invokeBOFOutputCallback(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
record *sliverpb.BOFOutput,
|
||||
chunkNumber int,
|
||||
isFinal bool,
|
||||
) error {
|
||||
if request.Callback == nil {
|
||||
return nil
|
||||
}
|
||||
if record == nil {
|
||||
record = &sliverpb.BOFOutput{}
|
||||
}
|
||||
kind := "output"
|
||||
if record.GetType() == bofOutputError {
|
||||
kind = "error"
|
||||
}
|
||||
information := opforengine.NewOrderedHash()
|
||||
information.Set("type", opforengine.String(kind))
|
||||
information.Set("type_id", opforengine.Int(record.GetType()))
|
||||
information.Set("chunk_num", opforengine.Int(int32(chunkNumber)))
|
||||
information.Set("is_final", opforengine.Bool(isFinal))
|
||||
_, err := request.Callback.Invoke(
|
||||
ctx,
|
||||
request.BeaconID,
|
||||
opforengine.BinaryString(record.GetData()),
|
||||
opforengine.HashValue(information),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func invokeBOFLifecycleCallback(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
kind string,
|
||||
taskID string,
|
||||
result []byte,
|
||||
) error {
|
||||
if request.Callback == nil {
|
||||
return nil
|
||||
}
|
||||
information := opforengine.NewOrderedHash()
|
||||
information.Set("type", opforengine.String(kind))
|
||||
if taskID != "" {
|
||||
information.Set("taskid", opforengine.String(taskID))
|
||||
}
|
||||
_, err := request.Callback.Invoke(
|
||||
ctx,
|
||||
request.BeaconID,
|
||||
opforengine.BinaryString(result),
|
||||
opforengine.HashValue(information),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func notifyBOFLifecycleError(
|
||||
ctx context.Context,
|
||||
request opforengine.AggressorBeaconExecutionRequest,
|
||||
taskID string,
|
||||
executionErr error,
|
||||
) error {
|
||||
if request.Callback == nil {
|
||||
return executionErr
|
||||
}
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return executionErr
|
||||
}
|
||||
callbackErr := invokeBOFLifecycleCallback(
|
||||
ctx, request, "error", taskID, []byte(executionErr.Error()),
|
||||
)
|
||||
if callbackErr != nil {
|
||||
return errors.Join(executionErr, fmt.Errorf("opfor: BOF error callback: %w", callbackErr))
|
||||
}
|
||||
return executionErr
|
||||
}
|
||||
|
||||
func (manager *Manager) renderBOFOutputs(records []*sliverpb.BOFOutput) {
|
||||
wrote := false
|
||||
endedWithNewline := true
|
||||
for _, record := range records {
|
||||
if record == nil || len(record.GetData()) == 0 {
|
||||
continue
|
||||
}
|
||||
text := safeBOFText(record.GetData())
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
wrote = true
|
||||
endedWithNewline = strings.HasSuffix(text, "\n")
|
||||
if record.GetType() == bofOutputError {
|
||||
manager.output.PrintErrorf("%s", text)
|
||||
} else {
|
||||
manager.output.Printf("%s", text)
|
||||
}
|
||||
}
|
||||
if wrote && !endedWithNewline {
|
||||
manager.output.Printf("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// safeBOFText prevents untrusted native output from injecting terminal control
|
||||
// sequences. Valid printable UTF-8 plus tab/newline/carriage return is retained;
|
||||
// invalid bytes and other control bytes are rendered as hexadecimal escapes.
|
||||
func safeBOFText(data []byte) string {
|
||||
var result strings.Builder
|
||||
result.Grow(len(data))
|
||||
for len(data) != 0 {
|
||||
r, size := utf8.DecodeRune(data)
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
fmt.Fprintf(&result, `\x%02x`, data[0])
|
||||
data = data[1:]
|
||||
continue
|
||||
}
|
||||
if r == '\n' || r == '\r' || r == '\t' || unicode.IsPrint(r) {
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
for _, value := range data[:size] {
|
||||
fmt.Fprintf(&result, `\x%02x`, value)
|
||||
}
|
||||
}
|
||||
data = data[size:]
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func prefixBOFArguments(packed []byte) ([]byte, error) {
|
||||
if uint64(len(packed)) > math.MaxUint32 {
|
||||
return nil, errors.New("opfor: BOF argument buffer exceeds uint32 length")
|
||||
}
|
||||
arguments := make([]byte, 4+len(packed))
|
||||
binary.LittleEndian.PutUint32(arguments, uint32(len(packed)))
|
||||
copy(arguments[4:], packed)
|
||||
return arguments, nil
|
||||
}
|
||||
|
||||
func targetRequest(ctx context.Context, target resolvedTarget) *commonpb.Request {
|
||||
request := &commonpb.Request{Timeout: int64(bofRequestTimeout(ctx))}
|
||||
if target.session != nil {
|
||||
request.SessionID = target.session.ID
|
||||
request.Async = false
|
||||
} else if target.beacon != nil {
|
||||
request.BeaconID = target.beacon.ID
|
||||
request.Async = true
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func bofRequestTimeout(ctx context.Context) time.Duration {
|
||||
if ctx == nil {
|
||||
return bofRequestFallback
|
||||
}
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return bofRequestFallback
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return time.Nanosecond
|
||||
}
|
||||
if remaining > bofRequestDeadlineMargin {
|
||||
remaining -= bofRequestDeadlineMargin
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func (manager *Manager) resolveCallExtensionResponse(
|
||||
ctx context.Context,
|
||||
rpc opforRPC,
|
||||
response *sliverpb.CallExtension,
|
||||
) (*sliverpb.CallExtension, error) {
|
||||
if response == nil {
|
||||
return nil, errors.New("opfor: BOF execution returned an empty response")
|
||||
}
|
||||
if response.GetResponse().GetErr() != "" || !response.GetResponse().GetAsync() {
|
||||
return response, nil
|
||||
}
|
||||
taskID := response.GetResponse().GetTaskID()
|
||||
if taskID == "" {
|
||||
return nil, errors.New("opfor: asynchronous BOF execution returned no task ID")
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(beaconTaskPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
task, err := rpc.GetBeaconTaskContent(ctx, &clientpb.BeaconTask{ID: taskID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opfor: fetch beacon task %s: %w", taskID, err)
|
||||
}
|
||||
state := strings.ToLower(task.GetState())
|
||||
switch state {
|
||||
case "completed":
|
||||
if len(task.Response) == 0 {
|
||||
return nil, fmt.Errorf("opfor: beacon task %s returned an empty response", taskID)
|
||||
}
|
||||
completed := &sliverpb.CallExtension{}
|
||||
if err := proto.Unmarshal(task.Response, completed); err != nil {
|
||||
return nil, fmt.Errorf("opfor: decode beacon task %s response: %w", taskID, err)
|
||||
}
|
||||
if completed.Response == nil {
|
||||
completed.Response = &commonpb.Response{}
|
||||
}
|
||||
if completed.Response.TaskID == "" {
|
||||
completed.Response.TaskID = taskID
|
||||
}
|
||||
return completed, nil
|
||||
case "failed", "canceled", "cancelled":
|
||||
return nil, fmt.Errorf("opfor: beacon task %s %s", taskID, state)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) publishTranscript(
|
||||
_ context.Context,
|
||||
record opforengine.AggressorBeaconTranscriptRecord,
|
||||
) error {
|
||||
target := record.BeaconID.String()
|
||||
text := record.Text.String()
|
||||
prefix := ""
|
||||
if target != "" {
|
||||
prefix = "[" + target + "] "
|
||||
}
|
||||
switch record.Kind {
|
||||
case opforengine.AggressorBeaconTranscriptError,
|
||||
opforengine.AggressorBeaconTranscriptJobError:
|
||||
manager.output.PrintErrorf("%s%s\n", prefix, text)
|
||||
case opforengine.AggressorBeaconTranscriptTask,
|
||||
opforengine.AggressorBeaconTranscriptTaskCompleted:
|
||||
manager.output.PrintInfof("%s%s\n", prefix, text)
|
||||
default:
|
||||
manager.output.Printf("%s%s\n", prefix, text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
opforengine "github.com/sliverarmory/opfor"
|
||||
)
|
||||
|
||||
func (manager *Manager) querySession(
|
||||
ctx context.Context,
|
||||
query opforengine.AggressorSessionQuery,
|
||||
) (opforengine.Value, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
|
||||
switch query.Kind {
|
||||
case opforengine.AggressorSessionQueryBeacons,
|
||||
opforengine.AggressorSessionQueryBeaconIDs:
|
||||
return manager.queryAllSessions(ctx, query.Kind)
|
||||
default:
|
||||
return manager.querySingleSession(ctx, query)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) queryAllSessions(
|
||||
ctx context.Context,
|
||||
kind opforengine.AggressorSessionQueryKind,
|
||||
) (opforengine.Value, error) {
|
||||
targets, err := manager.allTargets(ctx)
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
values := make([]opforengine.Value, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if kind == opforengine.AggressorSessionQueryBeaconIDs {
|
||||
values = append(values, opforengine.String(target.id()))
|
||||
} else {
|
||||
values = append(values, opforengine.HashValue(targetMetadata(target)))
|
||||
}
|
||||
}
|
||||
return opforengine.ArrayValue(opforengine.NewArray(values...)), nil
|
||||
}
|
||||
|
||||
func (manager *Manager) querySingleSession(
|
||||
ctx context.Context,
|
||||
query opforengine.AggressorSessionQuery,
|
||||
) (opforengine.Value, error) {
|
||||
target, err := manager.resolveTarget(ctx, query.SessionID.String())
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
metadata := targetMetadata(target)
|
||||
switch query.Kind {
|
||||
case opforengine.AggressorSessionQueryBeaconArchitecture,
|
||||
opforengine.AggressorSessionQueryBeaconData,
|
||||
opforengine.AggressorSessionQueryBeaconInfo,
|
||||
opforengine.AggressorSessionQueryIs64:
|
||||
return querySessionMetadata(target, metadata, query)
|
||||
case opforengine.AggressorSessionQueryIsActive,
|
||||
opforengine.AggressorSessionQueryIsAdmin,
|
||||
opforengine.AggressorSessionQueryIsBeacon,
|
||||
opforengine.AggressorSessionQueryIsSSH:
|
||||
return querySessionStatus(target, query)
|
||||
default:
|
||||
return opforengine.Null(), fmt.Errorf("opfor: unsupported session query %q", query.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func querySessionMetadata(
|
||||
target resolvedTarget,
|
||||
metadata *opforengine.Hash,
|
||||
query opforengine.AggressorSessionQuery,
|
||||
) (opforengine.Value, error) {
|
||||
switch query.Kind {
|
||||
case opforengine.AggressorSessionQueryBeaconArchitecture:
|
||||
architecture, err := beaconArchitecture(target.arch())
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
return opforengine.String(architecture), nil
|
||||
case opforengine.AggressorSessionQueryBeaconData:
|
||||
return opforengine.HashValue(metadata), nil
|
||||
case opforengine.AggressorSessionQueryBeaconInfo:
|
||||
value, found := metadata.Get(strings.ToLower(query.Key.String()))
|
||||
if !found {
|
||||
return opforengine.Null(), nil
|
||||
}
|
||||
return value, nil
|
||||
case opforengine.AggressorSessionQueryIs64:
|
||||
architecture, err := beaconArchitecture(target.arch())
|
||||
if err != nil {
|
||||
return opforengine.Null(), err
|
||||
}
|
||||
return opforengine.Bool(architecture == "x64"), nil
|
||||
default:
|
||||
return opforengine.Null(), fmt.Errorf("opfor: unsupported session metadata query %q", query.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func querySessionStatus(
|
||||
target resolvedTarget,
|
||||
query opforengine.AggressorSessionQuery,
|
||||
) (opforengine.Value, error) {
|
||||
switch query.Kind {
|
||||
case opforengine.AggressorSessionQueryIsActive:
|
||||
if target.session != nil {
|
||||
return opforengine.Bool(!target.session.IsDead), nil
|
||||
}
|
||||
return opforengine.Bool(target.beacon != nil && !target.beacon.IsDead), nil
|
||||
case opforengine.AggressorSessionQueryIsAdmin:
|
||||
integrity := strings.ToLower(targetIntegrity(target))
|
||||
return opforengine.Bool(strings.Contains(integrity, "high") || strings.Contains(integrity, "system") || strings.Contains(integrity, "admin")), nil
|
||||
case opforengine.AggressorSessionQueryIsBeacon:
|
||||
return opforengine.Bool(target.beacon != nil), nil
|
||||
case opforengine.AggressorSessionQueryIsSSH:
|
||||
return opforengine.Bool(false), nil
|
||||
default:
|
||||
return opforengine.Null(), fmt.Errorf("opfor: unsupported session status query %q", query.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func targetIntegrity(target resolvedTarget) string {
|
||||
if target.session != nil {
|
||||
return target.session.Integrity
|
||||
}
|
||||
if target.beacon != nil {
|
||||
return target.beacon.Integrity
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (manager *Manager) allTargets(ctx context.Context) ([]resolvedTarget, error) {
|
||||
rpc, err := manager.rpc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions, err := rpc.GetSessions(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opfor: list sessions: %w", err)
|
||||
}
|
||||
beacons, err := rpc.GetBeacons(ctx, &commonpb.Empty{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opfor: list beacons: %w", err)
|
||||
}
|
||||
targets := make([]resolvedTarget, 0, len(sessions.GetSessions())+len(beacons.GetBeacons()))
|
||||
for _, session := range sessions.GetSessions() {
|
||||
if session != nil {
|
||||
targets = append(targets, resolvedTarget{session: session})
|
||||
}
|
||||
}
|
||||
for _, beacon := range beacons.GetBeacons() {
|
||||
if beacon != nil {
|
||||
targets = append(targets, resolvedTarget{beacon: beacon})
|
||||
}
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func beaconArchitecture(arch string) (string, error) {
|
||||
switch strings.ToLower(arch) {
|
||||
case "amd64", "x64", "x86_64":
|
||||
return "x64", nil
|
||||
case "386", "x86", "i386":
|
||||
return "x86", nil
|
||||
default:
|
||||
return "", fmt.Errorf("opfor: unsupported BOF target architecture %q", arch)
|
||||
}
|
||||
}
|
||||
|
||||
func targetMetadata(target resolvedTarget) *opforengine.Hash {
|
||||
metadata := opforengine.NewOrderedHash()
|
||||
set := func(key string, value opforengine.Value) { metadata.Set(key, value) }
|
||||
if target.session != nil {
|
||||
appendTargetMetadata(set, target.session.ID, target.session.Name, target.session.Hostname,
|
||||
target.session.Username, target.session.OS, target.session.Arch, target.session.Transport,
|
||||
target.session.RemoteAddress, target.session.Filename, target.session.Version,
|
||||
target.session.Integrity, target.session.PID, !target.session.IsDead, false)
|
||||
} else if target.beacon != nil {
|
||||
appendTargetMetadata(set, target.beacon.ID, target.beacon.Name, target.beacon.Hostname,
|
||||
target.beacon.Username, target.beacon.OS, target.beacon.Arch, target.beacon.Transport,
|
||||
target.beacon.RemoteAddress, target.beacon.Filename, target.beacon.Version,
|
||||
target.beacon.Integrity, target.beacon.PID, !target.beacon.IsDead, true)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func appendTargetMetadata(
|
||||
set func(string, opforengine.Value),
|
||||
id, name, hostname, username, osName, arch, transport, remoteAddress, filename, version, integrity string,
|
||||
pid int32,
|
||||
active, beacon bool,
|
||||
) {
|
||||
architecture, err := beaconArchitecture(arch)
|
||||
if err != nil {
|
||||
architecture = strings.ToLower(arch)
|
||||
}
|
||||
set("id", opforengine.String(id))
|
||||
set("name", opforengine.String(name))
|
||||
set("computer", opforengine.String(hostname))
|
||||
set("host", opforengine.String(hostname))
|
||||
set("user", opforengine.String(username))
|
||||
set("os", opforengine.String(osName))
|
||||
set("arch", opforengine.String(architecture))
|
||||
set("transport", opforengine.String(transport))
|
||||
set("external", opforengine.String(remoteAddress))
|
||||
set("process", opforengine.String(filename))
|
||||
set("version", opforengine.String(version))
|
||||
set("integrity", opforengine.String(integrity))
|
||||
set("pid", opforengine.Int(pid))
|
||||
set("active", opforengine.Bool(active))
|
||||
set("beacon", opforengine.Bool(beacon))
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# SliverArmory CNA corpus
|
||||
|
||||
These unmodified CNA scripts are pinned regression fixtures for the OPFOR client integration. The tests pair them with inert object bytes and verify script loading, command registration, resource resolution, BOF argument packing, and Sliver dispatch; they do not execute the embedded BOF code locally.
|
||||
|
||||
| Fixture | Upstream revision | License |
|
||||
| --- | --- | --- |
|
||||
| `firefoxdump/firefoxdump.cna` | [`sliverarmory/firefoxdump@19bec6f`](https://github.com/sliverarmory/firefoxdump/tree/19bec6fb2def510b299430955fb2be79c1f51820) | GPL-3.0-only; see the Sliver repository root `LICENSE` |
|
||||
| `bof_collection/cat.cna` | [`sliverarmory/bof_collection@d937d25`](https://github.com/sliverarmory/bof_collection/tree/d937d25a164f40192e289b67e9c550d2bd4c369a) | Apache-2.0; copied `LICENSE` and `NOTICE` are retained here |
|
||||
| `operatorskit/finddotnet.cna` and `findsysmon.cna` | [`sliverarmory/OperatorsKit@66368f4`](https://github.com/sliverarmory/OperatorsKit/tree/66368f4738528d26cc1ccc6d9a3c93d44d63edc1) | MIT; copied `LICENSE` is retained here |
|
||||
|
||||
The object files are intentionally not vendored. Live BOF execution belongs in a safety-gated Windows integration environment, while these fixtures keep the client test suite deterministic and platform independent.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,26 @@
|
||||
bof_collection
|
||||
Copyright 2026 Pim Beune
|
||||
|
||||
This product includes a modified copy of beacon.h derived from the
|
||||
Cobalt-Strike/bof_template project, which is licensed under the Apache License,
|
||||
Version 2.0. The local copy has been updated from the upstream template and is
|
||||
distributed under the same license.
|
||||
|
||||
Armory packaging and integration changes were made by Sliver Armory
|
||||
contributors.
|
||||
|
||||
Research and reference acknowledgements retained from the source project:
|
||||
|
||||
- Outflank: Remote Registry Protocol information-disclosure techniques
|
||||
(https://www.youtube.com/watch?v=MxDq552Di3Y) and remote named-pipe
|
||||
enumeration research
|
||||
(https://outflank.nl/blog/2023/10/19/listing-remote-named-pipes/).
|
||||
- Julian Peña: CVE-2019-16098 exploitation write-up referenced by the driver
|
||||
proof of concept
|
||||
(https://julian-pena.com/2025-09-29-exploiting-drivers-1/).
|
||||
- Eric Esquivel: ntoskrnl function-offset technique acknowledged in the
|
||||
CVE-2019-16098 source.
|
||||
|
||||
These research acknowledgements identify referenced techniques and materials;
|
||||
they do not assert that the named researchers authored this collection's code
|
||||
or that they endorse this package.
|
||||
@@ -0,0 +1,32 @@
|
||||
# author: Pim Beune
|
||||
|
||||
beacon_command_register(
|
||||
"cat", "Prints content of a file",
|
||||
"INFO: Prints content of a file.\n\n" .
|
||||
"FEATURES: UTF-8 and UTF-16 (LE/BE) compatible, with or without BOM\n" .
|
||||
" Refuses files larger than 128 KB\n" .
|
||||
" Sanitizes output: strips control characters and invalid\n" .
|
||||
" byte sequences (replaced with '?'), keeps tabs/newlines\n" .
|
||||
" Paths do not need to be quoted or backslash-escaped\n\n" .
|
||||
"EXAMPLES: cat C:\\Users\\User\\Desktop\\flag.txt\n" .
|
||||
" cat C:\\Program Files\\Something\\notes.txt\n" .
|
||||
" cat \\\\server\\share\\folder\\file.txt");
|
||||
|
||||
alias cat {
|
||||
$bid = $1;
|
||||
$file = join(" ", sublist(@_, 1));
|
||||
|
||||
if ($file eq "") {
|
||||
berror($bid, "Please specify a file path.");
|
||||
return;
|
||||
}
|
||||
|
||||
# Read in the right BOF file
|
||||
$handle = openf(script_resource("dist/cat." . barch($bid) . ".o"));
|
||||
$data = readb($handle, -1);
|
||||
closef($handle);
|
||||
|
||||
# Pack our arguments
|
||||
$args = bof_pack($bid, "z", $file);
|
||||
beacon_inline_execute($bid, $data, "go", $args);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Firefox Dump BOF - Extract Firefox passwords and cookies
|
||||
# Standalone version for Cobalt Strike
|
||||
|
||||
beacon_command_register(
|
||||
"firefoxdump",
|
||||
"Extract Firefox secrets (cookies, passwords) from Firefox profile.",
|
||||
"INFO:\n" .
|
||||
"Extracts browser secrets from Firefox browser.\n" .
|
||||
"This BOF reads Firefox profile files (key4.db, logins.json, cookies.sqlite).\n\n" .
|
||||
"SUPPORTED DATA:\n" .
|
||||
" - Saved passwords (from logins.json - decrypted using NSS)\n" .
|
||||
" - Cookies (from cookies.sqlite)\n\n" .
|
||||
"USAGE:\n" .
|
||||
" firefoxdump - Extract passwords only (default)\n" .
|
||||
" firefoxdump /all - Extract passwords and cookies\n" .
|
||||
" firefoxdump /cookies - Extract cookies only\n\n" .
|
||||
"OUTPUT:\n" .
|
||||
"Displays extracted passwords (cleartext) and/or cookies.\n" .
|
||||
"Passwords are decrypted using NSS libraries from Firefox installation.\n\n" .
|
||||
"NOTE:\n" .
|
||||
"Requires access to the user's Firefox profile directory.\n" .
|
||||
"Works best when run from the same user context as Firefox.\n" .
|
||||
"Requires Firefox to be installed (for NSS libraries).\n"
|
||||
);
|
||||
|
||||
alias firefoxdump {
|
||||
local('$bid $barch $handle $bof $option $data');
|
||||
$bid = $1;
|
||||
$barch = barch($bid);
|
||||
|
||||
# Extract option from command line
|
||||
if (size(@_) > 1) {
|
||||
$option = @_[1];
|
||||
} else {
|
||||
$option = "";
|
||||
}
|
||||
|
||||
# Read the BOF file
|
||||
$handle = openf(script_resource("bin/firefoxdump." . $barch . ".o"));
|
||||
$bof = readb($handle, -1);
|
||||
closef($handle);
|
||||
|
||||
if (strlen($bof) == 0) {
|
||||
berror($bid, "Could not read firefoxdump BOF file");
|
||||
return;
|
||||
}
|
||||
|
||||
# Pack option for BOF
|
||||
$data = bof_pack($bid, "z", $option);
|
||||
btask($bid, "Extracting Firefox secrets");
|
||||
beacon_inline_execute($bid, $bof, "go", $data);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 awgh
|
||||
Copyright (c) 2023 REDMED-X
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -0,0 +1,22 @@
|
||||
# author REDMED-X
|
||||
|
||||
beacon_command_register(
|
||||
"finddotnet", "Find processes that most likely have .NET loaded.",
|
||||
"INFO:\nFind processes that most likely have .NET loaded by searching for the section name: \BaseNamedObjects\Cor_Private_IPCBlock(_v4)_<ProcessId>\n\n" .
|
||||
"USAGE:\nfinddotnet\n\n");
|
||||
|
||||
|
||||
alias finddotnet {
|
||||
$bid = $1;
|
||||
|
||||
# Read in the right BOF file
|
||||
$handle = openf(script_resource("finddotnet.o"));
|
||||
$data = readb($handle, -1);
|
||||
closef($handle);
|
||||
|
||||
blog($bid, "Tasked to search for processes that have .NET loaded..");
|
||||
|
||||
beacon_inline_execute($bid, $data, "go", $null);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# author REDMED-X
|
||||
|
||||
beacon_command_register(
|
||||
"findsysmon", "Verify if Sysmon is running.\n",
|
||||
"INFO:\nVerify if Sysmon is running. This can be done by checking the registry or by enumerating Minifilter drivers and search for one that is associated with Sysmon.\n\nOPTIONS:\n[reg]: search the registry to check if Sysmon is present on the system and return the Sysmon service PID if active.\n[driver]: list all the Minifilter drivers on the system to check manually (requires elevated privileges).\n\n" .
|
||||
"USAGE:\nfindsysmon <reg | driver>\n\n");
|
||||
|
||||
|
||||
alias findsysmon {
|
||||
$bid = $1;
|
||||
$action = $2;
|
||||
|
||||
if ($action eq "reg" || $action eq "driver") {
|
||||
}
|
||||
else {
|
||||
berror($bid, "Please specify one of the following enumeration options: reg | driver\n");
|
||||
return;
|
||||
}
|
||||
|
||||
# Read in the right BOF file
|
||||
$handle = openf(script_resource("findsysmon.o"));
|
||||
$data = readb($handle, -1);
|
||||
closef($handle);
|
||||
|
||||
# Pack our arguments
|
||||
$arg_data = bof_pack($bid, "z", $action);
|
||||
|
||||
blog($bid, "Tasked to find Sysmon..");
|
||||
beacon_inline_execute($bid, $data, "go", $arg_data);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build client
|
||||
|
||||
package opfor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/forms"
|
||||
opforengine "github.com/sliverarmory/opfor"
|
||||
)
|
||||
|
||||
// promptUI is deliberately small and injectable. The production adapter uses
|
||||
// Sliver's existing Bubble Tea/Huh forms; tests can exercise callback behavior
|
||||
// without taking over the terminal.
|
||||
type promptUI interface {
|
||||
Confirm(title string, defaultValue bool) (bool, error)
|
||||
Input(title, defaultValue string) (string, error)
|
||||
}
|
||||
|
||||
type formsPromptUI struct{}
|
||||
|
||||
func (formsPromptUI) Confirm(title string, defaultValue bool) (bool, error) {
|
||||
value := defaultValue
|
||||
err := forms.Confirm(title, &value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (formsPromptUI) Input(title, defaultValue string) (string, error) {
|
||||
value := defaultValue
|
||||
err := forms.Input(title, &value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (manager *Manager) presentPrompt(
|
||||
ctx context.Context,
|
||||
presentation opforengine.AggressorPromptPresentation,
|
||||
responder opforengine.AggressorPromptResponder,
|
||||
) error {
|
||||
if responder == nil {
|
||||
return errorsPromptResponderNil()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch presentation.Kind {
|
||||
case opforengine.AggressorPromptConfirm:
|
||||
title := joinPromptTitle(presentation.Title, presentation.Text)
|
||||
answer, err := manager.confirmPrompt(title, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !answer {
|
||||
return responder.Dismiss()
|
||||
}
|
||||
_, err = responder.Accept(ctx)
|
||||
return err
|
||||
|
||||
case opforengine.AggressorPromptText,
|
||||
opforengine.AggressorPromptDirectoryOpen,
|
||||
opforengine.AggressorPromptFileOpen,
|
||||
opforengine.AggressorPromptFileSave:
|
||||
title := presentation.Text
|
||||
if title == "" {
|
||||
title = presentation.Title
|
||||
}
|
||||
if title == "" {
|
||||
title = presentation.Name
|
||||
}
|
||||
defaultValue := ""
|
||||
if presentation.HasDefault {
|
||||
defaultValue = presentation.Default.String()
|
||||
}
|
||||
answer, err := manager.inputPrompt(title, defaultValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = responder.Accept(ctx, opforengine.String(answer))
|
||||
return err
|
||||
|
||||
default:
|
||||
return fmt.Errorf("opfor: unsupported prompt kind %q", presentation.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) confirmPrompt(title string, defaultValue bool) (bool, error) {
|
||||
manager.promptMu.Lock()
|
||||
defer manager.promptMu.Unlock()
|
||||
return manager.ui.Confirm(title, defaultValue)
|
||||
}
|
||||
|
||||
func (manager *Manager) inputPrompt(title, defaultValue string) (string, error) {
|
||||
manager.promptMu.Lock()
|
||||
defer manager.promptMu.Unlock()
|
||||
return manager.ui.Input(title, defaultValue)
|
||||
}
|
||||
|
||||
func joinPromptTitle(parts ...string) string {
|
||||
nonempty := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
nonempty = append(nonempty, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(nonempty, ": ")
|
||||
}
|
||||
|
||||
func errorsPromptResponderNil() error {
|
||||
return fmt.Errorf("opfor: prompt responder is nil")
|
||||
}
|
||||
@@ -21,7 +21,6 @@ package portfwd
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
@@ -50,14 +49,10 @@ func PortfwdAddCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
con.PrintErrorf("Must specify a remote target host:port\n")
|
||||
return
|
||||
}
|
||||
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to parse remote target %s\n", err)
|
||||
if err := validatePortfwdRemoteAddress(remoteAddr); err != nil {
|
||||
con.PrintErrorf("Failed to parse remote target: %s\n", err)
|
||||
return
|
||||
}
|
||||
if remotePort == "3389" {
|
||||
con.PrintWarnf("RDP is generally broken over tunneled portfwds, we recommend using WireGuard portfwds\n")
|
||||
}
|
||||
bindAddr, _ := cmd.Flags().GetString("bind")
|
||||
if bindAddr == "" {
|
||||
con.PrintErrorf("Must specify a bind target host:port (e.g. 127.0.0.1:8000)")
|
||||
@@ -88,14 +83,25 @@ func PortfwdAddCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
DialTimeout: 30 * time.Second,
|
||||
}
|
||||
tcpProxy.AddRoute(bindAddr, channelProxy)
|
||||
if err := tcpProxy.Start(); err != nil {
|
||||
channelProxy.Stop()
|
||||
_ = tcpProxy.Close()
|
||||
con.PrintErrorf("Failed to start port forward listener: %s\n", err)
|
||||
return
|
||||
}
|
||||
core.Portfwds.Add(tcpProxy, channelProxy)
|
||||
|
||||
go func() {
|
||||
err := tcpProxy.Run()
|
||||
err := tcpProxy.Wait()
|
||||
if err != nil {
|
||||
log.Printf("Proxy error %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
con.PrintInfof("Port forwarding %s -> %s:%s\n", bindAddr, remoteHost, remotePort)
|
||||
con.PrintInfof("Port forwarding %s -> %s\n", bindAddr, remoteAddr)
|
||||
}
|
||||
|
||||
func validatePortfwdRemoteAddress(remoteAddr string) error {
|
||||
_, _, err := (&core.ChannelProxy{RemoteAddr: remoteAddr}).ValidatedHostPort()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package portfwd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePortfwdRemoteAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
valid bool
|
||||
}{
|
||||
{name: "hostname RDP", address: "windows.example:3389", valid: true},
|
||||
{name: "IPv4", address: "192.0.2.10:443", valid: true},
|
||||
{name: "IPv6", address: "[2001:db8::10]:8443", valid: true},
|
||||
{name: "missing port", address: "target.example", valid: false},
|
||||
{name: "empty port", address: "target.example:", valid: false},
|
||||
{name: "empty host", address: ":3389", valid: false},
|
||||
{name: "zero port", address: "target.example:0", valid: false},
|
||||
{name: "out of range port", address: "target.example:65536", valid: false},
|
||||
{name: "service name", address: "target.example:https", valid: false},
|
||||
{name: "unbracketed IPv6", address: "2001:db8::10:8443", valid: false},
|
||||
{name: "hostname with space", address: "bad host:80", valid: false},
|
||||
{name: "hostname with tab", address: "bad\thost:80", valid: false},
|
||||
{name: "hostname with newline", address: "bad\nhost:80", valid: false},
|
||||
{name: "IPv6 zone with space", address: "[fe80::1%bad zone]:80", valid: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validatePortfwdRemoteAddress(test.address)
|
||||
if test.valid && err != nil {
|
||||
t.Fatalf("validatePortfwdRemoteAddress(%q): %v", test.address, err)
|
||||
}
|
||||
if !test.valid && err == nil {
|
||||
t.Fatalf("validatePortfwdRemoteAddress(%q) returned nil", test.address)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -201,7 +203,7 @@ func ShellKillCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
// Best-effort graceful request before closing the tunnel.
|
||||
requestRemoteShellExit(sh.Tunnel())
|
||||
|
||||
if err := closeShellTunnel(con, sh.TunnelID, sh.SessionID); err != nil {
|
||||
if err := closeShellTunnel(con, sh.Tunnel()); err != nil {
|
||||
con.PrintWarnf("Error closing shell tunnel %d: %s\n", sh.TunnelID, err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
return shellAttachFailed
|
||||
case <-bindTimer.C:
|
||||
con.PrintErrorf("Timed out binding shell tunnel\n")
|
||||
go backgroundCloseShell(con, tunnel)
|
||||
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,14 +315,15 @@ 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)
|
||||
return shellAttachFailed
|
||||
}
|
||||
if shell.Response != nil && shell.Response.Err != "" {
|
||||
con.PrintErrorf("Error: %s\n", shell.Response.Err)
|
||||
go backgroundCloseShell(con, tunnel.ID, session.ID)
|
||||
go backgroundCloseShell(con, tunnel)
|
||||
return shellAttachFailed
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -375,27 +393,37 @@ func runInteractive(cmd *cobra.Command, shellPath string, noPty bool, con *conso
|
||||
shells.Remove(managed.ID)
|
||||
|
||||
// "exit" should return immediately; tunnel close/cleanup is best-effort in the background.
|
||||
go backgroundCloseShell(con, tunnel.ID, session.ID)
|
||||
go backgroundCloseShell(con, tunnel)
|
||||
|
||||
return shellExited
|
||||
}
|
||||
|
||||
func backgroundCloseShell(con *console.SliverClient, tunnelID uint64, sessionID string) {
|
||||
err := closeShellTunnel(con, tunnelID, sessionID)
|
||||
func backgroundCloseShell(con *console.SliverClient, tunnel *core.TunnelIO) {
|
||||
if tunnel == nil {
|
||||
return
|
||||
}
|
||||
err := closeShellTunnel(con, tunnel)
|
||||
if err != nil {
|
||||
log.Printf("Background close tunnel %d failed: %v", tunnelID, err)
|
||||
log.Printf("Background close tunnel %d failed: %v", tunnel.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func closeShellTunnel(con *console.SliverClient, tunnelID uint64, sessionID string) error {
|
||||
core.GetTunnels().Close(tunnelID)
|
||||
func closeShellTunnel(con *console.SliverClient, tunnel *core.TunnelIO) error {
|
||||
if tunnel == nil {
|
||||
return nil
|
||||
}
|
||||
if !core.GetTunnels().CloseIf(tunnel) {
|
||||
return nil
|
||||
}
|
||||
if con == nil || con.Rpc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := con.Rpc.CloseTunnel(context.Background(), &sliverpb.Tunnel{
|
||||
TunnelID: tunnelID,
|
||||
SessionID: sessionID,
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shellCloseTimeout)
|
||||
defer cancel()
|
||||
_, err := con.Rpc.CloseTunnel(ctx, &sliverpb.Tunnel{
|
||||
TunnelID: tunnel.ID,
|
||||
SessionID: tunnel.SessionID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -413,6 +441,7 @@ func requestRemoteShellExit(tunnel *core.TunnelIO) {
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-tunnel.Done():
|
||||
case tunnel.Send <- append([]byte(nil), data...):
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/core"
|
||||
"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
|
||||
calls int
|
||||
}
|
||||
|
||||
type closeShellTestStream struct {
|
||||
grpc.BidiStreamingClient[sliverpb.TunnelData, sliverpb.TunnelData]
|
||||
sent chan *sliverpb.TunnelData
|
||||
}
|
||||
|
||||
func (stream *closeShellTestStream) Send(frame *sliverpb.TunnelData) error {
|
||||
stream.sent <- frame
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *closeTunnelTestRPC) CloseTunnel(ctx context.Context, _ *sliverpb.Tunnel, _ ...grpc.CallOption) (*commonpb.Empty, error) {
|
||||
r.calls++
|
||||
deadline, ok := ctx.Deadline()
|
||||
if ok {
|
||||
r.deadline = deadline
|
||||
}
|
||||
return &commonpb.Empty{}, nil
|
||||
}
|
||||
|
||||
func TestCloseShellTunnelUsesBoundedContext(t *testing.T) {
|
||||
tunnels := core.GetTunnels()
|
||||
tunnels.Reset()
|
||||
t.Cleanup(tunnels.Reset)
|
||||
stream := &closeShellTestStream{sent: make(chan *sliverpb.TunnelData, 1)}
|
||||
tunnels.SetStream(stream)
|
||||
tunnel := tunnels.Start(99, "session")
|
||||
waitForShellBindFrame(t, stream.sent)
|
||||
|
||||
rpc := &closeTunnelTestRPC{}
|
||||
con := &console.SliverClient{Rpc: rpc}
|
||||
|
||||
if err := closeShellTunnel(con, tunnel); err != nil {
|
||||
t.Fatalf("close shell tunnel: %v", err)
|
||||
}
|
||||
if rpc.calls != 1 {
|
||||
t.Fatalf("CloseTunnel RPC calls = %d, want 1", rpc.calls)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseShellTunnelDoesNotCloseReplacementGeneration(t *testing.T) {
|
||||
tunnels := core.GetTunnels()
|
||||
tunnels.Reset()
|
||||
t.Cleanup(tunnels.Reset)
|
||||
stream := &closeShellTestStream{sent: make(chan *sliverpb.TunnelData, 2)}
|
||||
tunnels.SetStream(stream)
|
||||
|
||||
old := tunnels.Start(100, "old-session")
|
||||
waitForShellBindFrame(t, stream.sent)
|
||||
replacement := tunnels.Start(100, "new-session")
|
||||
waitForShellBindFrame(t, stream.sent)
|
||||
rpc := &closeTunnelTestRPC{}
|
||||
con := &console.SliverClient{Rpc: rpc}
|
||||
|
||||
if err := closeShellTunnel(con, old); err != nil {
|
||||
t.Fatalf("close stale shell tunnel: %v", err)
|
||||
}
|
||||
if rpc.calls != 0 {
|
||||
t.Fatalf("stale shell cleanup sent %d numeric CloseTunnel RPCs, want 0", rpc.calls)
|
||||
}
|
||||
if current := tunnels.Get(100); current != replacement {
|
||||
t.Fatal("stale shell cleanup removed the replacement tunnel")
|
||||
}
|
||||
select {
|
||||
case <-replacement.Done():
|
||||
t.Fatal("stale shell cleanup closed the replacement tunnel")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func waitForShellBindFrame(t *testing.T, frames <-chan *sliverpb.TunnelData) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-frames:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for shell tunnel bind frame")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+199
-36
@@ -21,12 +21,15 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/bishopfox/sliver/client/tcpproxy"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
@@ -45,6 +48,11 @@ var (
|
||||
portfwdID = 0
|
||||
)
|
||||
|
||||
const (
|
||||
portfwdBindTimeout = 5 * time.Second
|
||||
portfwdCloseTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// PortfwdMeta - Metadata about a portfwd listener
|
||||
type PortfwdMeta struct {
|
||||
ID int
|
||||
@@ -91,13 +99,20 @@ func (f *portfwds) Add(tcpProxy *tcpproxy.Proxy, channelProxy *ChannelProxy) *Po
|
||||
// Remove - Remove a TCP proxy instance
|
||||
func (f *portfwds) Remove(portfwdID int) bool {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
if portfwd, ok := f.forwards[portfwdID]; ok {
|
||||
portfwd.TCPProxy.Close()
|
||||
portfwd, ok := f.forwards[portfwdID]
|
||||
if ok {
|
||||
delete(f.forwards, portfwdID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
f.mutex.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Remove registry ownership before joining connection workers. A slow or
|
||||
// broken peer must not hold the global inventory mutex and block unrelated
|
||||
// proxy lifecycle operations.
|
||||
portfwd.ChannelProxy.Stop()
|
||||
_ = portfwd.TCPProxy.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// List - List all TCP proxy instances
|
||||
@@ -123,42 +138,126 @@ type ChannelProxy struct {
|
||||
RemoteAddr string
|
||||
KeepAlivePeriod time.Duration
|
||||
DialTimeout time.Duration
|
||||
|
||||
connectionsMutex sync.Mutex
|
||||
connections map[net.Conn]struct{}
|
||||
lifecycleCtx context.Context
|
||||
lifecycleCancel context.CancelFunc
|
||||
stopped bool
|
||||
handlerWG sync.WaitGroup
|
||||
}
|
||||
|
||||
// HandleConn - Handle a TCP connection
|
||||
func (p *ChannelProxy) HandleConn(conn net.Conn) {
|
||||
log.Printf("[tcpproxy] Handling new connection")
|
||||
ctx := context.Background()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
ctx, tracked := p.trackConnection(conn)
|
||||
if !tracked {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
defer p.handlerWG.Done()
|
||||
defer p.untrackConnection(conn)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
var cancel context.CancelFunc
|
||||
if p.DialTimeout >= 0 {
|
||||
ctx, cancel = context.WithTimeout(ctx, p.dialTimeout())
|
||||
}
|
||||
tunnel, err := p.dialImplant(ctx)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tunnel, err := p.dialImplant(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
defer func() {
|
||||
conn.Close()
|
||||
GetTunnels().Close(tunnel.ID)
|
||||
errs := make(chan error, 2)
|
||||
copyWG := sync.WaitGroup{}
|
||||
copyWG.Add(2)
|
||||
go func() {
|
||||
defer copyWG.Done()
|
||||
errs <- toImplantLoop(conn, tunnel)
|
||||
}()
|
||||
go func() {
|
||||
defer copyWG.Done()
|
||||
errs <- fromImplantLoop(conn, tunnel)
|
||||
}()
|
||||
|
||||
errs := make(chan error, 1)
|
||||
go toImplantLoop(conn, tunnel, errs)
|
||||
go fromImplantLoop(conn, tunnel, errs)
|
||||
|
||||
// Block until error, then cleanup
|
||||
err = <-errs
|
||||
// The first completed direction or a receive-admission failure owns shutdown.
|
||||
// Closing both endpoints wakes the peer direction; join it before allowing
|
||||
// this handler to finish.
|
||||
select {
|
||||
case err = <-errs:
|
||||
case <-tunnel.receiveFailed():
|
||||
// A receive-overflow close normally allows queued final frames to
|
||||
// drain. That is unsafe for a port forward whose local peer stopped
|
||||
// reading: close the accepted socket so both copy loops wake and the
|
||||
// bounded remote cleanup below can run.
|
||||
_ = conn.Close()
|
||||
err = <-errs
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[tcpproxy] Closing tunnel %d with error %s", tunnel.ID, err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
GetTunnels().CloseIf(tunnel)
|
||||
copyWG.Wait()
|
||||
if err := p.closeTunnel(tunnel); err != nil {
|
||||
log.Printf("[tcpproxy] Failed to close tunnel %d: %v", tunnel.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// HostPort - Returns the host and port of the TCP proxy
|
||||
// Stop closes every connection accepted by this target and rejects handlers
|
||||
// that race the listener shutdown performed by Portfwds.Remove.
|
||||
func (p *ChannelProxy) Stop() {
|
||||
p.connectionsMutex.Lock()
|
||||
p.stopped = true
|
||||
lifecycleCancel := p.lifecycleCancel
|
||||
connections := make([]net.Conn, 0, len(p.connections))
|
||||
for connection := range p.connections {
|
||||
connections = append(connections, connection)
|
||||
}
|
||||
p.connectionsMutex.Unlock()
|
||||
|
||||
if lifecycleCancel != nil {
|
||||
lifecycleCancel()
|
||||
}
|
||||
for _, connection := range connections {
|
||||
_ = connection.Close()
|
||||
}
|
||||
p.handlerWG.Wait()
|
||||
}
|
||||
|
||||
func (p *ChannelProxy) trackConnection(connection net.Conn) (context.Context, bool) {
|
||||
p.connectionsMutex.Lock()
|
||||
defer p.connectionsMutex.Unlock()
|
||||
if p.stopped {
|
||||
return nil, false
|
||||
}
|
||||
if p.lifecycleCtx == nil {
|
||||
p.lifecycleCtx, p.lifecycleCancel = context.WithCancel(context.Background())
|
||||
}
|
||||
if p.connections == nil {
|
||||
p.connections = map[net.Conn]struct{}{}
|
||||
}
|
||||
// Add is serialized with Stop's stopped transition. Once Stop releases this
|
||||
// mutex, no later Add can race its Wait.
|
||||
p.handlerWG.Add(1)
|
||||
p.connections[connection] = struct{}{}
|
||||
return p.lifecycleCtx, true
|
||||
}
|
||||
|
||||
func (p *ChannelProxy) untrackConnection(connection net.Conn) {
|
||||
p.connectionsMutex.Lock()
|
||||
delete(p.connections, connection)
|
||||
p.connectionsMutex.Unlock()
|
||||
}
|
||||
|
||||
// HostPort returns the remote host and port of the TCP proxy. Invalid values
|
||||
// retain the historical empty-host/8080 fallback for source and behavioral
|
||||
// compatibility. New call sites that must reject malformed destinations should
|
||||
// use ValidatedHostPort.
|
||||
func (p *ChannelProxy) HostPort() (string, uint32) {
|
||||
defaultPort := uint32(8080)
|
||||
host, rawPort, err := net.SplitHostPort(p.RemoteAddr)
|
||||
@@ -179,6 +278,30 @@ func (p *ChannelProxy) HostPort() (string, uint32) {
|
||||
return host, port
|
||||
}
|
||||
|
||||
// ValidatedHostPort validates and returns the configured remote destination.
|
||||
func (p *ChannelProxy) ValidatedHostPort() (string, uint32, error) {
|
||||
host, rawPort, err := net.SplitHostPort(p.RemoteAddr)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("invalid remote target %q: %w", p.RemoteAddr, err)
|
||||
}
|
||||
if host == "" {
|
||||
return "", 0, fmt.Errorf("invalid remote target %q: host is required", p.RemoteAddr)
|
||||
}
|
||||
if strings.IndexFunc(host, func(value rune) bool {
|
||||
return unicode.IsSpace(value) || unicode.IsControl(value)
|
||||
}) >= 0 {
|
||||
return "", 0, fmt.Errorf("invalid remote target %q: host contains whitespace or control characters", p.RemoteAddr)
|
||||
}
|
||||
portNumber, err := strconv.ParseUint(rawPort, 10, 16)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("invalid remote target %q: port must be a number from 1 to 65535", p.RemoteAddr)
|
||||
}
|
||||
if portNumber == 0 {
|
||||
return "", 0, fmt.Errorf("invalid remote target %q: port must be a number from 1 to 65535", p.RemoteAddr)
|
||||
}
|
||||
return host, uint32(portNumber), nil
|
||||
}
|
||||
|
||||
// Port - Returns the TCP port of the proxy
|
||||
func (p *ChannelProxy) Port() uint32 {
|
||||
_, port := p.HostPort()
|
||||
@@ -191,7 +314,11 @@ func (p *ChannelProxy) Host() string {
|
||||
return host
|
||||
}
|
||||
|
||||
func (p *ChannelProxy) dialImplant(ctx context.Context) (*TunnelIO, error) {
|
||||
func (p *ChannelProxy) dialImplant(ctx context.Context) (_ *TunnelIO, resultErr error) {
|
||||
host, port, err := p.ValidatedHostPort()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[tcpproxy] Dialing implant to create tunnel ...")
|
||||
|
||||
@@ -206,16 +333,34 @@ func (p *ChannelProxy) dialImplant(ctx context.Context) (*TunnelIO, error) {
|
||||
|
||||
log.Printf("[tcpproxy] Created new tunnel with id %d (session %s)", rpcTunnel.TunnelID, p.Session.ID)
|
||||
tunnel := GetTunnels().Start(rpcTunnel.TunnelID, rpcTunnel.SessionID)
|
||||
setupComplete := false
|
||||
defer func() {
|
||||
if !setupComplete {
|
||||
resultErr = errors.Join(resultErr, p.closeTunnel(tunnel))
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("[tcpproxy] Binding tunnel to portfwd %d", p.Port())
|
||||
bindTimer := time.NewTimer(portfwdBindTimeout)
|
||||
defer bindTimer.Stop()
|
||||
select {
|
||||
case <-tunnel.Bound():
|
||||
case <-tunnel.Done():
|
||||
return nil, errors.New("port forward tunnel closed before it was bound")
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-bindTimer.C:
|
||||
return nil, errors.New("timed out binding port forward tunnel")
|
||||
}
|
||||
|
||||
log.Printf("[tcpproxy] Binding tunnel to portfwd %d", port)
|
||||
portfwdResp, err := p.Rpc.Portfwd(ctx, &sliverpb.PortfwdReq{
|
||||
Request: &commonpb.Request{
|
||||
SessionID: p.Session.ID,
|
||||
},
|
||||
Host: p.Host(),
|
||||
Port: p.Port(),
|
||||
Protocol: sliverpb.PortFwdProtoTCP,
|
||||
TunnelID: tunnel.ID,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Protocol: sliverpb.PortFwdProtoTCP,
|
||||
TunnelID: tunnel.ID,
|
||||
KeepAlive: int32(p.KeepAlivePeriod.Seconds()),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -223,17 +368,36 @@ func (p *ChannelProxy) dialImplant(ctx context.Context) (*TunnelIO, error) {
|
||||
}
|
||||
// Close tunnel in case of error on the implant side
|
||||
if portfwdResp.Response != nil && portfwdResp.Response.Err != "" {
|
||||
p.Rpc.CloseTunnel(ctx, &sliverpb.Tunnel{
|
||||
TunnelID: tunnel.ID,
|
||||
SessionID: p.Session.ID,
|
||||
})
|
||||
return nil, errors.New(portfwdResp.Response.Err)
|
||||
}
|
||||
log.Printf("Portfwd response: %v", portfwdResp)
|
||||
select {
|
||||
case <-tunnel.Done():
|
||||
return nil, errors.New("port forward tunnel closed during setup")
|
||||
default:
|
||||
}
|
||||
|
||||
setupComplete = true
|
||||
return tunnel, nil
|
||||
}
|
||||
|
||||
func (p *ChannelProxy) closeTunnel(tunnel *TunnelIO) error {
|
||||
if tunnel == nil {
|
||||
return nil
|
||||
}
|
||||
GetTunnels().CloseIf(tunnel)
|
||||
if p.Rpc == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), portfwdCloseTimeout)
|
||||
defer cancel()
|
||||
_, err := p.Rpc.CloseTunnel(ctx, &sliverpb.Tunnel{
|
||||
TunnelID: tunnel.ID,
|
||||
SessionID: tunnel.SessionID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// func (p *ChannelProxy) keepAlivePeriod() time.Duration {
|
||||
// if p.KeepAlivePeriod != 0 {
|
||||
// return p.KeepAlivePeriod
|
||||
@@ -248,23 +412,22 @@ func (p *ChannelProxy) dialTimeout() time.Duration {
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
func toImplantLoop(conn net.Conn, tunnel *TunnelIO, errs chan<- error) {
|
||||
func toImplantLoop(conn net.Conn, tunnel *TunnelIO) error {
|
||||
if wc, ok := conn.(*tcpproxy.Conn); ok && len(wc.Peeked) > 0 {
|
||||
if _, err := tunnel.Write(wc.Peeked); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
return err
|
||||
}
|
||||
wc.Peeked = nil
|
||||
}
|
||||
n, err := io.Copy(tunnel, conn)
|
||||
log.Printf("[tcpproxy] Closing to-implant after %d byte(s)", n)
|
||||
errs <- err
|
||||
return err
|
||||
}
|
||||
|
||||
func fromImplantLoop(conn net.Conn, tunnel *TunnelIO, errs chan<- error) {
|
||||
func fromImplantLoop(conn net.Conn, tunnel *TunnelIO) error {
|
||||
n, err := io.Copy(conn, tunnel)
|
||||
log.Printf("[tcpproxy] Closing from-implant after %d byte(s)", n)
|
||||
errs <- err
|
||||
return err
|
||||
}
|
||||
|
||||
func nextPortfwdID() int {
|
||||
|
||||
@@ -0,0 +1,822 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/tcpproxy"
|
||||
"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"
|
||||
)
|
||||
|
||||
type portfwdCloseObservation struct {
|
||||
request *sliverpb.Tunnel
|
||||
hasDeadline bool
|
||||
deadlineRemaining time.Duration
|
||||
}
|
||||
|
||||
type portfwdLifecycleTestStream struct {
|
||||
grpc.BidiStreamingClient[sliverpb.TunnelData, sliverpb.TunnelData]
|
||||
sent chan *sliverpb.TunnelData
|
||||
dataEntered chan struct{}
|
||||
releaseData chan struct{}
|
||||
dataEnteredOnce sync.Once
|
||||
releaseDataOnce sync.Once
|
||||
}
|
||||
|
||||
type portfwdBlockingAddr string
|
||||
|
||||
func (address portfwdBlockingAddr) Network() string { return "test" }
|
||||
func (address portfwdBlockingAddr) String() string { return string(address) }
|
||||
|
||||
type portfwdBlockingConn struct {
|
||||
readStarted chan struct{}
|
||||
releaseRead chan struct{}
|
||||
closed chan struct{}
|
||||
readOnce sync.Once
|
||||
releaseOnce sync.Once
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newPortfwdBlockingConn() *portfwdBlockingConn {
|
||||
return &portfwdBlockingConn{
|
||||
readStarted: make(chan struct{}),
|
||||
releaseRead: make(chan struct{}),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (connection *portfwdBlockingConn) Read([]byte) (int, error) {
|
||||
connection.readOnce.Do(func() { close(connection.readStarted) })
|
||||
<-connection.releaseRead
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (connection *portfwdBlockingConn) Write(data []byte) (int, error) {
|
||||
select {
|
||||
case <-connection.closed:
|
||||
return 0, net.ErrClosed
|
||||
default:
|
||||
return len(data), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (connection *portfwdBlockingConn) Close() error {
|
||||
connection.closeOnce.Do(func() { close(connection.closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (connection *portfwdBlockingConn) LocalAddr() net.Addr {
|
||||
return portfwdBlockingAddr("local")
|
||||
}
|
||||
|
||||
func (connection *portfwdBlockingConn) RemoteAddr() net.Addr {
|
||||
return portfwdBlockingAddr("remote")
|
||||
}
|
||||
|
||||
func (connection *portfwdBlockingConn) SetDeadline(time.Time) error { return nil }
|
||||
func (connection *portfwdBlockingConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (connection *portfwdBlockingConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func (connection *portfwdBlockingConn) release() {
|
||||
connection.releaseOnce.Do(func() { close(connection.releaseRead) })
|
||||
}
|
||||
|
||||
func (stream *portfwdLifecycleTestStream) Send(data *sliverpb.TunnelData) error {
|
||||
if len(data.GetData()) > 0 && stream.dataEntered != nil {
|
||||
stream.dataEnteredOnce.Do(func() { close(stream.dataEntered) })
|
||||
<-stream.releaseData
|
||||
}
|
||||
stream.sent <- data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (stream *portfwdLifecycleTestStream) releaseBlockedData() {
|
||||
if stream.releaseData != nil {
|
||||
stream.releaseDataOnce.Do(func() { close(stream.releaseData) })
|
||||
}
|
||||
}
|
||||
|
||||
type portfwdLifecycleTestRPC struct {
|
||||
rpcpb.SliverRPCClient
|
||||
createResponse *sliverpb.Tunnel
|
||||
createErr error
|
||||
portfwdResponse *sliverpb.Portfwd
|
||||
portfwdErr error
|
||||
portfwdCalls chan *sliverpb.PortfwdReq
|
||||
closeCalls chan portfwdCloseObservation
|
||||
createCalls chan *sliverpb.Tunnel
|
||||
blockCreate bool
|
||||
createEntered chan struct{}
|
||||
createCanceled chan struct{}
|
||||
createOnce sync.Once
|
||||
cancelOnce sync.Once
|
||||
}
|
||||
|
||||
func newPortfwdLifecycleTestRPC(tunnelID uint64, sessionID string) *portfwdLifecycleTestRPC {
|
||||
return &portfwdLifecycleTestRPC{
|
||||
createResponse: &sliverpb.Tunnel{TunnelID: tunnelID, SessionID: sessionID},
|
||||
portfwdResponse: &sliverpb.Portfwd{Response: &commonpb.Response{}},
|
||||
portfwdCalls: make(chan *sliverpb.PortfwdReq, 4),
|
||||
closeCalls: make(chan portfwdCloseObservation, 4),
|
||||
createCalls: make(chan *sliverpb.Tunnel, 4),
|
||||
}
|
||||
}
|
||||
|
||||
func (rpc *portfwdLifecycleTestRPC) CreateTunnel(ctx context.Context, request *sliverpb.Tunnel, _ ...grpc.CallOption) (*sliverpb.Tunnel, error) {
|
||||
rpc.createCalls <- request
|
||||
if rpc.blockCreate {
|
||||
rpc.createOnce.Do(func() { close(rpc.createEntered) })
|
||||
<-ctx.Done()
|
||||
rpc.cancelOnce.Do(func() { close(rpc.createCanceled) })
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return rpc.createResponse, rpc.createErr
|
||||
}
|
||||
|
||||
func (rpc *portfwdLifecycleTestRPC) Portfwd(_ context.Context, request *sliverpb.PortfwdReq, _ ...grpc.CallOption) (*sliverpb.Portfwd, error) {
|
||||
rpc.portfwdCalls <- request
|
||||
return rpc.portfwdResponse, rpc.portfwdErr
|
||||
}
|
||||
|
||||
func (rpc *portfwdLifecycleTestRPC) CloseTunnel(ctx context.Context, request *sliverpb.Tunnel, _ ...grpc.CallOption) (*commonpb.Empty, error) {
|
||||
deadline, hasDeadline := ctx.Deadline()
|
||||
observation := portfwdCloseObservation{
|
||||
request: request,
|
||||
hasDeadline: hasDeadline,
|
||||
}
|
||||
if hasDeadline {
|
||||
observation.deadlineRemaining = time.Until(deadline)
|
||||
}
|
||||
rpc.closeCalls <- observation
|
||||
return &commonpb.Empty{}, nil
|
||||
}
|
||||
|
||||
func TestChannelProxyWaitsForTunnelBindBeforePortfwd(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
stream := installPortfwdLifecycleTestStream()
|
||||
const (
|
||||
tunnelID = uint64(301)
|
||||
sessionID = "portfwd-bind-session"
|
||||
)
|
||||
rpc := newPortfwdLifecycleTestRPC(tunnelID, sessionID)
|
||||
proxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: sessionID},
|
||||
RemoteAddr: "127.0.0.1:8080",
|
||||
}
|
||||
|
||||
type dialResult struct {
|
||||
tunnel *TunnelIO
|
||||
err error
|
||||
}
|
||||
result := make(chan dialResult, 1)
|
||||
go func() {
|
||||
tunnel, err := proxy.dialImplant(context.Background())
|
||||
result <- dialResult{tunnel: tunnel, err: err}
|
||||
}()
|
||||
tunnel := waitForPortfwdLifecycleTunnel(t, tunnelID)
|
||||
waitForPortfwdLifecycleBindFrame(t, stream, tunnelID)
|
||||
select {
|
||||
case <-rpc.portfwdCalls:
|
||||
t.Fatal("Portfwd RPC ran before the tunnel bind acknowledgement")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
tunnel.markBound()
|
||||
select {
|
||||
case request := <-rpc.portfwdCalls:
|
||||
if request.TunnelID != tunnelID {
|
||||
t.Fatalf("Portfwd tunnel ID = %d, want %d", request.TunnelID, tunnelID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Portfwd RPC did not run after tunnel bind acknowledgement")
|
||||
}
|
||||
select {
|
||||
case outcome := <-result:
|
||||
if outcome.err != nil {
|
||||
t.Fatalf("dialImplant: %v", outcome.err)
|
||||
}
|
||||
if outcome.tunnel != tunnel {
|
||||
t.Fatal("dialImplant returned a different tunnel generation")
|
||||
}
|
||||
if err := proxy.closeTunnel(outcome.tunnel); err != nil {
|
||||
t.Fatalf("close test tunnel: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dialImplant did not return after Portfwd response")
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocyclo // Final payload ordering and both local/remote cleanup form one relay lifecycle.
|
||||
func TestChannelProxyForwardsFinalPayloadBeforeCloseTunnel(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
stream := &portfwdLifecycleTestStream{
|
||||
sent: make(chan *sliverpb.TunnelData, 4),
|
||||
dataEntered: make(chan struct{}),
|
||||
releaseData: make(chan struct{}),
|
||||
}
|
||||
t.Cleanup(stream.releaseBlockedData)
|
||||
GetTunnels().SetStream(stream)
|
||||
const (
|
||||
tunnelID = uint64(311)
|
||||
sessionID = "portfwd-final-payload-session"
|
||||
)
|
||||
rpc := newPortfwdLifecycleTestRPC(tunnelID, sessionID)
|
||||
proxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: sessionID},
|
||||
RemoteAddr: "127.0.0.1:8080",
|
||||
}
|
||||
serverConnection, clientConnection := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = serverConnection.Close()
|
||||
_ = clientConnection.Close()
|
||||
})
|
||||
handlerDone := make(chan struct{})
|
||||
go func() {
|
||||
proxy.HandleConn(serverConnection)
|
||||
close(handlerDone)
|
||||
}()
|
||||
|
||||
tunnel := waitForPortfwdLifecycleTunnel(t, tunnelID)
|
||||
waitForPortfwdLifecycleBindFrame(t, stream, tunnelID)
|
||||
tunnel.markBound()
|
||||
select {
|
||||
case <-rpc.portfwdCalls:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("final-payload handler did not establish its port forward")
|
||||
}
|
||||
|
||||
payload := []byte("last local bytes before EOF")
|
||||
localWriteDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := clientConnection.Write(payload)
|
||||
if closeErr := clientConnection.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
localWriteDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-stream.dataEntered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("final payload did not enter the tunnel stream Send")
|
||||
}
|
||||
select {
|
||||
case err := <-localWriteDone:
|
||||
if err != nil {
|
||||
t.Fatalf("write final local payload: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("local writer did not finish")
|
||||
}
|
||||
select {
|
||||
case observation := <-rpc.closeCalls:
|
||||
t.Fatalf("CloseTunnel overtook the blocked final stream Send: %+v", observation.request)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
select {
|
||||
case <-handlerDone:
|
||||
t.Fatal("port-forward handler returned before the final stream Send completed")
|
||||
default:
|
||||
}
|
||||
|
||||
stream.releaseBlockedData()
|
||||
select {
|
||||
case frame := <-stream.sent:
|
||||
if frame.GetTunnelID() != tunnelID || !bytes.Equal(frame.GetData(), payload) {
|
||||
t.Fatalf("final stream frame = %+v, want tunnel=%d data=%q", frame, tunnelID, payload)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("final payload was not sent after the stream was released")
|
||||
}
|
||||
assertBoundedPortfwdClose(t, rpc.closeCalls, tunnelID, sessionID)
|
||||
select {
|
||||
case <-handlerDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("port-forward handler did not finish after sending the final payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelProxyRejectsInvalidRemoteBeforeCreateTunnel(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
rpc := newPortfwdLifecycleTestRPC(308, "invalid-remote-session")
|
||||
proxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: "invalid-remote-session"},
|
||||
RemoteAddr: "target.example:not-a-port",
|
||||
}
|
||||
|
||||
tunnel, err := proxy.dialImplant(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("dialImplant accepted an invalid remote target")
|
||||
}
|
||||
if tunnel != nil {
|
||||
t.Fatal("dialImplant returned a tunnel for an invalid remote target")
|
||||
}
|
||||
select {
|
||||
case request := <-rpc.createCalls:
|
||||
t.Fatalf("invalid remote target invoked CreateTunnel with %+v", request)
|
||||
default:
|
||||
}
|
||||
if active := GetTunnels().Get(308); active != nil {
|
||||
t.Fatal("invalid remote target registered a client tunnel")
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocyclo // The compatibility table verifies both legacy and strict result contracts.
|
||||
func TestChannelProxyHostPortCompatibilityAndValidatedTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
wantHost string
|
||||
wantPort uint32
|
||||
}{
|
||||
{name: "hostname", remoteAddr: "target.example:443", wantHost: "target.example", wantPort: 443},
|
||||
{name: "IPv4", remoteAddr: "192.0.2.10:8080", wantHost: "192.0.2.10", wantPort: 8080},
|
||||
{name: "IPv6", remoteAddr: "[2001:db8::10]:8443", wantHost: "2001:db8::10", wantPort: 8443},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
proxy := &ChannelProxy{RemoteAddr: test.remoteAddr}
|
||||
host, port := proxy.HostPort()
|
||||
if host != test.wantHost || port != test.wantPort {
|
||||
t.Fatalf("HostPort(%q) = (%q, %d), want (%q, %d)", test.remoteAddr, host, port, test.wantHost, test.wantPort)
|
||||
}
|
||||
host, port, err := proxy.ValidatedHostPort()
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatedHostPort(%q): %v", test.remoteAddr, err)
|
||||
}
|
||||
if host != test.wantHost || port != test.wantPort {
|
||||
t.Fatalf("ValidatedHostPort(%q) = (%q, %d), want (%q, %d)", test.remoteAddr, host, port, test.wantHost, test.wantPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
proxy := &ChannelProxy{RemoteAddr: "target.example:not-a-port"}
|
||||
if host, port := proxy.HostPort(); host != "" || port != 8080 {
|
||||
t.Fatalf("legacy HostPort invalid fallback = (%q, %d), want (\"\", 8080)", host, port)
|
||||
}
|
||||
if host, port, err := proxy.ValidatedHostPort(); err == nil || host != "" || port != 0 {
|
||||
t.Fatalf("ValidatedHostPort invalid result = (%q, %d, %v), want empty/zero/error", host, port, err)
|
||||
}
|
||||
|
||||
// HostPort historically allowed an empty host and returned its parsed port.
|
||||
// Validation-sensitive setup rejects the same value through the new API.
|
||||
proxy.RemoteAddr = ":80"
|
||||
if host, port := proxy.HostPort(); host != "" || port != 80 {
|
||||
t.Fatalf("legacy HostPort empty-host result = (%q, %d), want (\"\", 80)", host, port)
|
||||
}
|
||||
if host, port, err := proxy.ValidatedHostPort(); err == nil || host != "" || port != 0 {
|
||||
t.Fatalf("ValidatedHostPort empty-host result = (%q, %d, %v), want empty/zero/error", host, port, err)
|
||||
}
|
||||
|
||||
for _, remoteAddr := range []string{
|
||||
"bad host:80",
|
||||
"bad\thost:80",
|
||||
"bad\nhost:80",
|
||||
"[fe80::1%bad zone]:80",
|
||||
} {
|
||||
proxy.RemoteAddr = remoteAddr
|
||||
if host, port, err := proxy.ValidatedHostPort(); err == nil || host != "" || port != 0 {
|
||||
t.Errorf("ValidatedHostPort whitespace/control result for %q = (%q, %d, %v), want empty/zero/error", remoteAddr, host, port, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelProxySetupFailureClosesClientAndServerTunnel(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
stream := installPortfwdLifecycleTestStream()
|
||||
const (
|
||||
tunnelID = uint64(302)
|
||||
sessionID = "portfwd-failure-session"
|
||||
)
|
||||
portfwdErr := errors.New("test Portfwd RPC failure")
|
||||
rpc := newPortfwdLifecycleTestRPC(tunnelID, sessionID)
|
||||
rpc.portfwdErr = portfwdErr
|
||||
proxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: sessionID},
|
||||
RemoteAddr: "127.0.0.1:8080",
|
||||
}
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := proxy.dialImplant(context.Background())
|
||||
result <- err
|
||||
}()
|
||||
tunnel := waitForPortfwdLifecycleTunnel(t, tunnelID)
|
||||
waitForPortfwdLifecycleBindFrame(t, stream, tunnelID)
|
||||
tunnel.markBound()
|
||||
select {
|
||||
case err := <-result:
|
||||
if !errors.Is(err, portfwdErr) {
|
||||
t.Fatalf("dialImplant error = %v, want %v", err, portfwdErr)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dialImplant setup failure did not return")
|
||||
}
|
||||
if active := GetTunnels().Get(tunnelID); active != nil {
|
||||
t.Fatal("failed setup retained the client tunnel")
|
||||
}
|
||||
assertBoundedPortfwdClose(t, rpc.closeCalls, tunnelID, sessionID)
|
||||
}
|
||||
|
||||
func TestChannelProxyImplantSetupErrorClosesClientAndServerTunnel(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
stream := installPortfwdLifecycleTestStream()
|
||||
const (
|
||||
tunnelID = uint64(305)
|
||||
sessionID = "portfwd-implant-failure-session"
|
||||
)
|
||||
rpc := newPortfwdLifecycleTestRPC(tunnelID, sessionID)
|
||||
rpc.portfwdResponse.Response.Err = "target dial refused"
|
||||
proxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: sessionID},
|
||||
RemoteAddr: "127.0.0.1:8080",
|
||||
}
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := proxy.dialImplant(context.Background())
|
||||
result <- err
|
||||
}()
|
||||
tunnel := waitForPortfwdLifecycleTunnel(t, tunnelID)
|
||||
waitForPortfwdLifecycleBindFrame(t, stream, tunnelID)
|
||||
tunnel.markBound()
|
||||
select {
|
||||
case err := <-result:
|
||||
if err == nil || err.Error() != "target dial refused" {
|
||||
t.Fatalf("dialImplant error = %v, want target dial refused", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dialImplant implant failure did not return")
|
||||
}
|
||||
if active := GetTunnels().Get(tunnelID); active != nil {
|
||||
t.Fatal("implant setup failure retained the client tunnel")
|
||||
}
|
||||
assertBoundedPortfwdClose(t, rpc.closeCalls, tunnelID, sessionID)
|
||||
}
|
||||
|
||||
func TestChannelProxyClosesAcceptedConnectionWhenCreateTunnelFails(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
rpc := newPortfwdLifecycleTestRPC(303, "portfwd-create-failure")
|
||||
rpc.createErr = errors.New("test CreateTunnel failure")
|
||||
proxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: "portfwd-create-failure"},
|
||||
RemoteAddr: "127.0.0.1:8080",
|
||||
}
|
||||
serverConnection, clientConnection := net.Pipe()
|
||||
defer func() { _ = clientConnection.Close() }()
|
||||
handlerDone := make(chan struct{})
|
||||
go func() {
|
||||
proxy.HandleConn(serverConnection)
|
||||
close(handlerDone)
|
||||
}()
|
||||
|
||||
if err := clientConnection.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buffer := make([]byte, 1)
|
||||
count, err := clientConnection.Read(buffer)
|
||||
if count != 0 || !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("accepted connection read = (%d, %v), want (0, EOF)", count, err)
|
||||
}
|
||||
select {
|
||||
case <-handlerDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("HandleConn did not return after CreateTunnel failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelProxyStopCancelsCreateTunnelWithoutDialTimeout(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
rpc := newPortfwdLifecycleTestRPC(306, "portfwd-cancel-create")
|
||||
rpc.blockCreate = true
|
||||
rpc.createEntered = make(chan struct{})
|
||||
rpc.createCanceled = make(chan struct{})
|
||||
proxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: "portfwd-cancel-create"},
|
||||
RemoteAddr: "127.0.0.1:8080",
|
||||
DialTimeout: -1,
|
||||
}
|
||||
serverConnection, clientConnection := net.Pipe()
|
||||
defer func() { _ = clientConnection.Close() }()
|
||||
handlerDone := make(chan struct{})
|
||||
go func() {
|
||||
proxy.HandleConn(serverConnection)
|
||||
close(handlerDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-rpc.createEntered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("CreateTunnel did not start")
|
||||
}
|
||||
proxy.Stop()
|
||||
select {
|
||||
case <-rpc.createCanceled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop did not cancel the in-flight CreateTunnel RPC")
|
||||
}
|
||||
select {
|
||||
case <-handlerDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("HandleConn did not return after Stop canceled CreateTunnel")
|
||||
}
|
||||
if err := clientConnection.SetReadDeadline(time.Now().Add(time.Second)); err == nil {
|
||||
buffer := make([]byte, 1)
|
||||
count, readErr := clientConnection.Read(buffer)
|
||||
if count != 0 || (!errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrClosedPipe)) {
|
||||
t.Fatalf("connection read after Stop = (%d, %v), want (0, EOF or closed pipe)", count, readErr)
|
||||
}
|
||||
} else if !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("set read deadline after Stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelProxyStopWaitsForBlockedHandler(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
stream := installPortfwdLifecycleTestStream()
|
||||
const (
|
||||
tunnelID = uint64(309)
|
||||
sessionID = "portfwd-stop-join-session"
|
||||
)
|
||||
rpc := newPortfwdLifecycleTestRPC(tunnelID, sessionID)
|
||||
proxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: sessionID},
|
||||
RemoteAddr: "127.0.0.1:8080",
|
||||
}
|
||||
connection := newPortfwdBlockingConn()
|
||||
t.Cleanup(connection.release)
|
||||
handlerDone := make(chan struct{})
|
||||
go func() {
|
||||
proxy.HandleConn(connection)
|
||||
close(handlerDone)
|
||||
}()
|
||||
|
||||
tunnel := waitForPortfwdLifecycleTunnel(t, tunnelID)
|
||||
waitForPortfwdLifecycleBindFrame(t, stream, tunnelID)
|
||||
tunnel.markBound()
|
||||
select {
|
||||
case <-rpc.portfwdCalls:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("blocked handler did not establish its port forward")
|
||||
}
|
||||
select {
|
||||
case <-connection.readStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("handler copy loop did not enter the blocking connection read")
|
||||
}
|
||||
|
||||
stopDone := make(chan struct{})
|
||||
go func() {
|
||||
proxy.Stop()
|
||||
close(stopDone)
|
||||
}()
|
||||
select {
|
||||
case <-connection.closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop did not close the tracked connection")
|
||||
}
|
||||
select {
|
||||
case <-stopDone:
|
||||
t.Fatal("Stop returned before the blocked handler copy loop joined")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
connection.release()
|
||||
select {
|
||||
case <-stopDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Stop did not return after the blocked handler was released")
|
||||
}
|
||||
select {
|
||||
case <-handlerDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("HandleConn did not return before Stop completed")
|
||||
}
|
||||
if active := GetTunnels().Get(tunnelID); active != nil {
|
||||
t.Fatal("joined handler retained its client tunnel")
|
||||
}
|
||||
assertBoundedPortfwdClose(t, rpc.closeCalls, tunnelID, sessionID)
|
||||
}
|
||||
|
||||
func TestChannelProxyReceiveOverflowClosesLocalAndRemoteTunnel(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
stream := installPortfwdLifecycleTestStream()
|
||||
const (
|
||||
tunnelID = uint64(310)
|
||||
sessionID = "portfwd-overflow-session"
|
||||
)
|
||||
rpc := newPortfwdLifecycleTestRPC(tunnelID, sessionID)
|
||||
proxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: sessionID},
|
||||
RemoteAddr: "127.0.0.1:8080",
|
||||
}
|
||||
serverConnection, clientConnection := net.Pipe()
|
||||
t.Cleanup(func() { _ = clientConnection.Close() })
|
||||
handlerDone := make(chan struct{})
|
||||
go func() {
|
||||
proxy.HandleConn(serverConnection)
|
||||
close(handlerDone)
|
||||
}()
|
||||
|
||||
tunnel := waitForPortfwdLifecycleTunnel(t, tunnelID)
|
||||
waitForPortfwdLifecycleBindFrame(t, stream, tunnelID)
|
||||
tunnel.markBound()
|
||||
select {
|
||||
case <-rpc.portfwdCalls:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("overflow test did not establish its port forward")
|
||||
}
|
||||
|
||||
var admissionErr error
|
||||
for index := 0; index < tunnelRecvBufferSize+2; index++ {
|
||||
admissionErr = tunnel.RecvData([]byte{byte(index)})
|
||||
if admissionErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !errors.Is(admissionErr, errTunnelReceiveQueueFull) {
|
||||
t.Fatalf("receive overflow error = %v, want %v", admissionErr, errTunnelReceiveQueueFull)
|
||||
}
|
||||
tunnel.failReceive()
|
||||
GetTunnels().CloseIf(tunnel)
|
||||
|
||||
if err := clientConnection.SetReadDeadline(time.Now().Add(time.Second)); err == nil {
|
||||
for {
|
||||
if _, err := clientConnection.Read(make([]byte, 1)); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("set read deadline after receive overflow: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-handlerDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("receive overflow did not stop the port-forward handler")
|
||||
}
|
||||
assertBoundedPortfwdClose(t, rpc.closeCalls, tunnelID, sessionID)
|
||||
}
|
||||
|
||||
func TestPortfwdsRemoveClosesEstablishedConnectionAndTunnel(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
stream := installPortfwdLifecycleTestStream()
|
||||
const (
|
||||
tunnelID = uint64(304)
|
||||
sessionID = "portfwd-remove-session"
|
||||
)
|
||||
rpc := newPortfwdLifecycleTestRPC(tunnelID, sessionID)
|
||||
channelProxy := &ChannelProxy{
|
||||
Rpc: rpc,
|
||||
Session: &clientpb.Session{ID: sessionID},
|
||||
RemoteAddr: "127.0.0.1:8080",
|
||||
}
|
||||
registered := Portfwds.Add(&tcpproxy.Proxy{}, channelProxy)
|
||||
serverConnection, clientConnection := net.Pipe()
|
||||
defer func() { _ = clientConnection.Close() }()
|
||||
handlerDone := make(chan struct{})
|
||||
go func() {
|
||||
channelProxy.HandleConn(serverConnection)
|
||||
close(handlerDone)
|
||||
}()
|
||||
tunnel := waitForPortfwdLifecycleTunnel(t, tunnelID)
|
||||
waitForPortfwdLifecycleBindFrame(t, stream, tunnelID)
|
||||
tunnel.markBound()
|
||||
select {
|
||||
case <-rpc.portfwdCalls:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("established connection did not invoke Portfwd")
|
||||
}
|
||||
|
||||
if !Portfwds.Remove(registered.ID) {
|
||||
t.Fatal("Portfwds.Remove did not remove the registered proxy")
|
||||
}
|
||||
if err := clientConnection.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
// net.Pipe may report the peer teardown while setting the deadline,
|
||||
// before a subsequent Read can observe EOF.
|
||||
if !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatalf("set read deadline after remove: %v", err)
|
||||
}
|
||||
} else {
|
||||
buffer := make([]byte, 1)
|
||||
count, err := clientConnection.Read(buffer)
|
||||
if count != 0 || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe)) {
|
||||
t.Fatalf("established connection read after remove = (%d, %v), want (0, EOF or closed pipe)", count, err)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-handlerDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("established handler did not stop after Portfwds.Remove")
|
||||
}
|
||||
if active := GetTunnels().Get(tunnelID); active != nil {
|
||||
t.Fatal("Portfwds.Remove retained the client tunnel")
|
||||
}
|
||||
assertBoundedPortfwdClose(t, rpc.closeCalls, tunnelID, sessionID)
|
||||
}
|
||||
|
||||
func TestChannelProxyStaleCloseDoesNotCloseReplacementTunnel(t *testing.T) {
|
||||
resetPortfwdLifecycleTestState(t)
|
||||
stream := installPortfwdLifecycleTestStream()
|
||||
const tunnelID = uint64(307)
|
||||
rpc := newPortfwdLifecycleTestRPC(tunnelID, "old-session")
|
||||
proxy := &ChannelProxy{Rpc: rpc}
|
||||
|
||||
old := GetTunnels().Start(tunnelID, "old-session")
|
||||
waitForPortfwdLifecycleBindFrame(t, stream, tunnelID)
|
||||
replacement := GetTunnels().Start(tunnelID, "new-session")
|
||||
waitForPortfwdLifecycleBindFrame(t, stream, tunnelID)
|
||||
|
||||
if err := proxy.closeTunnel(old); err != nil {
|
||||
t.Fatalf("close stale port forward tunnel: %v", err)
|
||||
}
|
||||
if current := GetTunnels().Get(tunnelID); current != replacement {
|
||||
t.Fatal("stale port forward cleanup removed the replacement tunnel")
|
||||
}
|
||||
select {
|
||||
case <-replacement.Done():
|
||||
t.Fatal("stale port forward cleanup closed the replacement tunnel")
|
||||
default:
|
||||
}
|
||||
assertBoundedPortfwdClose(t, rpc.closeCalls, tunnelID, "old-session")
|
||||
}
|
||||
|
||||
func resetPortfwdLifecycleTestState(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, metadata := range Portfwds.List() {
|
||||
Portfwds.Remove(metadata.ID)
|
||||
}
|
||||
GetTunnels().Reset()
|
||||
t.Cleanup(func() {
|
||||
for _, metadata := range Portfwds.List() {
|
||||
Portfwds.Remove(metadata.ID)
|
||||
}
|
||||
GetTunnels().Reset()
|
||||
})
|
||||
}
|
||||
|
||||
func waitForPortfwdLifecycleTunnel(t *testing.T, tunnelID uint64) *TunnelIO {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if tunnel := GetTunnels().Get(tunnelID); tunnel != nil {
|
||||
return tunnel
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for client tunnel %d", tunnelID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func installPortfwdLifecycleTestStream() *portfwdLifecycleTestStream {
|
||||
stream := &portfwdLifecycleTestStream{sent: make(chan *sliverpb.TunnelData, 4)}
|
||||
GetTunnels().SetStream(stream)
|
||||
return stream
|
||||
}
|
||||
|
||||
func waitForPortfwdLifecycleBindFrame(t *testing.T, stream *portfwdLifecycleTestStream, tunnelID uint64) {
|
||||
t.Helper()
|
||||
select {
|
||||
case frame := <-stream.sent:
|
||||
if frame.GetTunnelID() != tunnelID || len(frame.GetData()) != 0 {
|
||||
t.Fatalf("bind frame = %+v, want empty data for tunnel %d", frame, tunnelID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for tunnel %d bind frame", tunnelID)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBoundedPortfwdClose(t *testing.T, observations <-chan portfwdCloseObservation, tunnelID uint64, sessionID string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case observation := <-observations:
|
||||
if observation.request.GetTunnelID() != tunnelID || observation.request.GetSessionID() != sessionID {
|
||||
t.Fatalf("CloseTunnel request = %+v, want tunnel=%d session=%q", observation.request, tunnelID, sessionID)
|
||||
}
|
||||
if !observation.hasDeadline {
|
||||
t.Fatal("CloseTunnel RPC context has no deadline")
|
||||
}
|
||||
if observation.deadlineRemaining <= 0 || observation.deadlineRemaining > portfwdCloseTimeout {
|
||||
t.Fatalf("CloseTunnel deadline remaining = %v, want within (0, %v]", observation.deadlineRemaining, portfwdCloseTimeout)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("CloseTunnel RPC was not called")
|
||||
}
|
||||
}
|
||||
+967
-109
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+50
-11
@@ -25,6 +25,8 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// TunnelLoop - Parses incoming tunnel messages and distributes them
|
||||
@@ -32,6 +34,18 @@ import (
|
||||
// to session/tunnel objects
|
||||
// Expected to be called only once during initialization
|
||||
func TunnelLoop(ctx context.Context, rpc rpcpb.SliverRPCClient) error {
|
||||
return tunnelLoop(ctx, rpc, nil)
|
||||
}
|
||||
|
||||
// TunnelLoopWithReady runs TunnelLoop and closes ready after the tunnel data
|
||||
// stream has been installed. Callers can use that signal to avoid accepting
|
||||
// local proxy connections before tunnel bind frames can be sent.
|
||||
func TunnelLoopWithReady(ctx context.Context, rpc rpcpb.SliverRPCClient, ready chan<- struct{}) error {
|
||||
return tunnelLoop(ctx, rpc, ready)
|
||||
}
|
||||
|
||||
//nolint:gocyclo // The shared stream loop keeps receive, ownership, close, and cancellation decisions together.
|
||||
func tunnelLoop(ctx context.Context, rpc rpcpb.SliverRPCClient, ready chan<- struct{}) error {
|
||||
log.Println("Starting tunnel data loop ...")
|
||||
defer log.Printf("Warning: TunnelLoop exited")
|
||||
|
||||
@@ -41,44 +55,69 @@ 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)
|
||||
if ready != nil {
|
||||
close(ready)
|
||||
}
|
||||
|
||||
for {
|
||||
log.Printf("Waiting for TunnelData ...")
|
||||
incoming, err := stream.Recv()
|
||||
log.Printf("Recv stream msg: %v", incoming)
|
||||
log.Printf("Recv stream err: %s", err)
|
||||
if incoming != nil {
|
||||
log.Printf(
|
||||
"Recv stream msg: tunnel=%d session=%s bytes=%d closed=%t",
|
||||
incoming.TunnelID,
|
||||
incoming.SessionID,
|
||||
len(incoming.Data),
|
||||
incoming.Closed,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Recv stream err: %s", err)
|
||||
}
|
||||
|
||||
if err == io.EOF {
|
||||
log.Printf("EOF Error: Tunnel data stream closed")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// A canceled status is only a clean shutdown when this caller
|
||||
// canceled the stream. A remote/server cancellation while ctx is
|
||||
// still live is a real tunnel-loop failure and must be surfaced.
|
||||
if ctx.Err() != nil && (errors.Is(err, context.Canceled) || status.Code(err) == codes.Canceled) {
|
||||
return nil
|
||||
}
|
||||
log.Printf("Tunnel data read error: %s", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("Received TunnelData for tunnel %d", incoming.TunnelID)
|
||||
tunnel := GetTunnels().Get(incoming.TunnelID)
|
||||
tunnel := tunnels.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)
|
||||
|
||||
if !incoming.Closed {
|
||||
log.Printf("Received data on tunnel %d", tunnel.ID)
|
||||
log.Printf("Received %d byte(s) on tunnel %d", len(data), tunnel.ID)
|
||||
err = tunnel.RecvData(data)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Warning! Error sending data to tunnel %d, %v", tunnel.ID, err)
|
||||
log.Printf("Warning! Closing tunnel %d after receive admission failed: %v", tunnel.ID, err)
|
||||
tunnel.failReceive()
|
||||
tunnels.CloseIf(tunnel)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Closing tunnel %d", tunnel.ID)
|
||||
GetTunnels().Close(tunnel.ID)
|
||||
tunnels.CloseIf(tunnel)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Received tunnel data for non-existent tunnel id %d", incoming.TunnelID)
|
||||
|
||||
+233
-34
@@ -19,13 +19,34 @@ package core
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
)
|
||||
|
||||
const (
|
||||
// HTTP C2 may deliver up to 64 concurrent responses while a command-specific
|
||||
// reader is starting or briefly backpressured. Match the 128-frame generic
|
||||
// server/implant transport window so that ordinary bursts remain isolated and
|
||||
// bounded instead of resetting an otherwise healthy port forward.
|
||||
tunnelRecvBufferSize = 128
|
||||
tunnelRecvByteLimit = tunnelRecvBufferSize * sliverpb.MaxTunnelFrameBytes
|
||||
)
|
||||
|
||||
var (
|
||||
errClosedTunnel = errors.New("closed tunnel")
|
||||
errTunnelReceiveQueueFull = errors.New("tunnel receive queue capacity exceeded")
|
||||
errTunnelReceiveFrameTooLarge = errors.New("tunnel receive frame exceeds payload limit")
|
||||
)
|
||||
|
||||
type tunnelWriteRequest struct {
|
||||
data []byte
|
||||
result chan error
|
||||
}
|
||||
|
||||
// TunnelIO - Duplex data tunnel, compatible with both io.ReadWriter
|
||||
type TunnelIO struct {
|
||||
ID uint64
|
||||
@@ -34,8 +55,26 @@ type TunnelIO struct {
|
||||
Send chan []byte
|
||||
Recv chan []byte
|
||||
|
||||
isOpen bool
|
||||
mutex *sync.RWMutex
|
||||
writeRequests chan tunnelWriteRequest
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
recvFailed chan struct{}
|
||||
recvFailedOnce sync.Once
|
||||
bound chan struct{}
|
||||
boundOnce sync.Once
|
||||
readMu sync.Mutex
|
||||
remainder []byte
|
||||
|
||||
recvBudgetMu sync.Mutex
|
||||
recvFrames int
|
||||
recvBytes int
|
||||
recvFrameLimit int
|
||||
recvByteLimit int
|
||||
isOpen bool
|
||||
mutex *sync.RWMutex
|
||||
// readSelectHook is an unexported scheduling seam for deterministic tests of
|
||||
// the close-vs-final-frame select. Production instances leave it nil.
|
||||
readSelectHook func(doneSelected bool)
|
||||
}
|
||||
|
||||
// NewTunnelIO - Single entry point for creating instance of new TunnelIO
|
||||
@@ -46,14 +85,25 @@ 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),
|
||||
writeRequests: make(chan tunnelWriteRequest),
|
||||
done: make(chan struct{}),
|
||||
recvFailed: make(chan struct{}),
|
||||
bound: make(chan struct{}),
|
||||
recvFrameLimit: tunnelRecvBufferSize,
|
||||
recvByteLimit: tunnelRecvByteLimit,
|
||||
mutex: &sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// Write - Writer method for interface
|
||||
func (tun *TunnelIO) Write(data []byte) (int, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if !tun.IsOpen() {
|
||||
log.Printf("Warning: Write on closed tunnel %d", tun.ID)
|
||||
return 0, io.EOF
|
||||
@@ -63,36 +113,125 @@ func (tun *TunnelIO) Write(data []byte) (int, error) {
|
||||
dataCopy := make([]byte, len(data))
|
||||
n := copy(dataCopy, data)
|
||||
|
||||
log.Printf("Write %d bytes", n)
|
||||
log.Printf("This bytes is: %s", dataCopy)
|
||||
log.Printf("Write %d byte(s) on tunnel %d", n, tun.ID)
|
||||
|
||||
tun.Send <- dataCopy
|
||||
select {
|
||||
case <-tun.done:
|
||||
return 0, io.EOF
|
||||
default:
|
||||
}
|
||||
|
||||
return n, nil
|
||||
request := tunnelWriteRequest{
|
||||
data: dataCopy,
|
||||
result: make(chan error, 1),
|
||||
}
|
||||
select {
|
||||
case tun.writeRequests <- request:
|
||||
case <-tun.done:
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// A successful Write means the exact tunnel generation's gRPC stream Send
|
||||
// completed. This prevents a full-close owner from deleting the generation
|
||||
// after a channel handoff but before the final payload reaches the stream.
|
||||
select {
|
||||
case err := <-request.result:
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
case <-tun.done:
|
||||
// Prefer an already-published Send result when completion and close race.
|
||||
select {
|
||||
case err := <-request.result:
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
default:
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read - Reader method for interface
|
||||
func (tun *TunnelIO) Read(data []byte) (int, error) {
|
||||
recvData, ok := <-tun.Recv
|
||||
if !ok {
|
||||
log.Printf("Warning: Read on closed tunnel %d", tun.ID)
|
||||
return 0, io.EOF
|
||||
if len(data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tun.readMu.Lock()
|
||||
defer tun.readMu.Unlock()
|
||||
if len(tun.remainder) > 0 {
|
||||
return tun.copyRecvData(data, nil), nil
|
||||
}
|
||||
// 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:
|
||||
}
|
||||
|
||||
var buff bytes.Buffer
|
||||
log.Printf("Read %d bytes", len(recvData))
|
||||
buff.Write(recvData)
|
||||
if tun.readSelectHook != nil {
|
||||
tun.readSelectHook(false)
|
||||
}
|
||||
select {
|
||||
case recvData := <-tun.Recv:
|
||||
return tun.copyRecvData(data, recvData), nil
|
||||
case <-tun.done:
|
||||
if tun.readSelectHook != nil {
|
||||
tun.readSelectHook(true)
|
||||
}
|
||||
// RecvData that won admission immediately before Close may have queued
|
||||
// after the first poll. Prefer that final admitted frame over EOF.
|
||||
select {
|
||||
case recvData := <-tun.Recv:
|
||||
return tun.copyRecvData(data, recvData), nil
|
||||
default:
|
||||
log.Printf("Warning: Read on closed tunnel %d", tun.ID)
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n := copy(data, buff.Bytes())
|
||||
return n, nil
|
||||
func (tun *TunnelIO) copyRecvData(data []byte, recvData []byte) int {
|
||||
if recvData != nil {
|
||||
tun.remainder = recvData
|
||||
log.Printf("Read frame with %d byte(s) on tunnel %d", len(recvData), tun.ID)
|
||||
}
|
||||
n := copy(data, tun.remainder)
|
||||
tun.remainder = tun.remainder[n:]
|
||||
tun.releaseRecvBudget(n, len(tun.remainder) == 0)
|
||||
if len(tun.remainder) == 0 {
|
||||
tun.remainder = nil
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (tun *TunnelIO) releaseRecvBudget(bytes int, frameComplete bool) {
|
||||
tun.recvBudgetMu.Lock()
|
||||
if bytes >= tun.recvBytes {
|
||||
tun.recvBytes = 0
|
||||
} else {
|
||||
tun.recvBytes -= bytes
|
||||
}
|
||||
if frameComplete && tun.recvFrames > 0 {
|
||||
tun.recvFrames--
|
||||
}
|
||||
tun.recvBudgetMu.Unlock()
|
||||
}
|
||||
|
||||
// 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 +240,40 @@ 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
|
||||
}
|
||||
|
||||
// receiveFailed is closed only for local receive-admission failure. Ordinary
|
||||
// peer close leaves it open so owners can still drain already admitted final
|
||||
// frames before closing their local endpoint.
|
||||
func (tun *TunnelIO) receiveFailed() <-chan struct{} {
|
||||
return tun.recvFailed
|
||||
}
|
||||
|
||||
func (tun *TunnelIO) failReceive() {
|
||||
tun.recvFailedOnce.Do(func() {
|
||||
close(tun.recvFailed)
|
||||
})
|
||||
_ = tun.Close()
|
||||
}
|
||||
|
||||
// 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 +288,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 +301,45 @@ func (tun *TunnelIO) Open() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecvData - safe way to send data to internal Recv channel. Blocking.
|
||||
// RecvData admits data to the bounded receive queue without blocking the
|
||||
// process-wide TunnelLoop. The reservation includes unread short-buffer
|
||||
// remainder, so both the frame count and byte footprint remain bounded.
|
||||
func (tun *TunnelIO) RecvData(data []byte) error {
|
||||
tun.mutex.Lock()
|
||||
defer tun.mutex.Unlock()
|
||||
|
||||
if !tun.isOpen {
|
||||
return errors.New("closed tunnel")
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(data) > sliverpb.MaxTunnelFrameBytes {
|
||||
return errTunnelReceiveFrameTooLarge
|
||||
}
|
||||
|
||||
tun.Recv <- data
|
||||
tun.mutex.RLock()
|
||||
defer tun.mutex.RUnlock()
|
||||
if !tun.isOpen {
|
||||
return errClosedTunnel
|
||||
}
|
||||
|
||||
return nil
|
||||
return tun.queueRecvData(data)
|
||||
}
|
||||
|
||||
// queueRecvData completes admission after the caller has established that the
|
||||
// tunnel is open. Keeping this step separate also lets tests hold the Read done
|
||||
// branch at the exact historical race point without relying on scheduler luck.
|
||||
func (tun *TunnelIO) queueRecvData(data []byte) error {
|
||||
dataCopy := append([]byte(nil), data...)
|
||||
tun.recvBudgetMu.Lock()
|
||||
defer tun.recvBudgetMu.Unlock()
|
||||
if tun.recvFrames >= tun.recvFrameLimit || tun.recvBytes+len(dataCopy) > tun.recvByteLimit {
|
||||
return errTunnelReceiveQueueFull
|
||||
}
|
||||
|
||||
tun.recvFrames++
|
||||
tun.recvBytes += len(dataCopy)
|
||||
select {
|
||||
case tun.Recv <- dataCopy:
|
||||
return nil
|
||||
default:
|
||||
tun.recvFrames--
|
||||
tun.recvBytes -= len(dataCopy)
|
||||
return errTunnelReceiveQueueFull
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
)
|
||||
|
||||
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 TestTunnelIOAdmitsHTTPTransportBurstAndDrainsInOrder(t *testing.T) {
|
||||
const (
|
||||
httpBurstFrames = 64
|
||||
httpFrameBytes = 32 * 1024
|
||||
)
|
||||
tunnel := NewTunnelIO(10, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = tunnel.Close() })
|
||||
|
||||
// HTTP C2 may make this entire response window runnable before the local
|
||||
// port-forward reader is scheduled. Admission must remain nonblocking and
|
||||
// preserve every frame rather than treating that ordinary burst as overload.
|
||||
for sequence := 0; sequence < httpBurstFrames; sequence++ {
|
||||
payload := bytes.Repeat([]byte{byte(sequence)}, httpFrameBytes)
|
||||
if err := tunnel.RecvData(payload); err != nil {
|
||||
t.Fatalf("admit HTTP burst frame %d: %v", sequence, err)
|
||||
}
|
||||
}
|
||||
if !tunnel.IsOpen() {
|
||||
t.Fatal("ordinary HTTP response burst closed the tunnel")
|
||||
}
|
||||
|
||||
buffer := make([]byte, httpFrameBytes)
|
||||
for sequence := 0; sequence < httpBurstFrames; sequence++ {
|
||||
n, err := tunnel.Read(buffer)
|
||||
if err != nil {
|
||||
t.Fatalf("drain HTTP burst frame %d: %v", sequence, err)
|
||||
}
|
||||
if n != len(buffer) {
|
||||
t.Fatalf("drain HTTP burst frame %d length = %d, want %d", sequence, n, len(buffer))
|
||||
}
|
||||
want := byte(sequence)
|
||||
for offset, got := range buffer {
|
||||
if got != want {
|
||||
t.Fatalf("drain HTTP burst frame %d byte %d = %d, want %d", sequence, offset, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tunnel.recvBudgetMu.Lock()
|
||||
frames, size := tunnel.recvFrames, tunnel.recvBytes
|
||||
tunnel.recvBudgetMu.Unlock()
|
||||
if frames != 0 || size != 0 {
|
||||
t.Fatalf("receive budget after HTTP burst drain = (%d frames, %d bytes), want zero", frames, size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelIORejectsFullReceiveBufferWithoutBlocking(t *testing.T) {
|
||||
tunnel := NewTunnelIO(2, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = tunnel.Close() })
|
||||
|
||||
for i := 0; i < tunnelRecvBufferSize; i++ {
|
||||
if err := tunnel.RecvData([]byte{byte(i)}); err != nil {
|
||||
t.Fatalf("fill receive buffer frame %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
recvDone := make(chan error, 1)
|
||||
go func() {
|
||||
recvDone <- tunnel.RecvData([]byte("frame beyond receive window"))
|
||||
}()
|
||||
select {
|
||||
case err := <-recvDone:
|
||||
if !errors.Is(err, errTunnelReceiveQueueFull) {
|
||||
t.Fatalf("frame beyond bounded receive capacity = %v, want %v", err, errTunnelReceiveQueueFull)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("frame beyond bounded receive capacity blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelIOReceiveAdmissionBoundsBytesAndFrameSize(t *testing.T) {
|
||||
tunnel := NewTunnelIO(8, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = tunnel.Close() })
|
||||
|
||||
// Use a small budget to exercise byte admission independently of the
|
||||
// production frame-count window.
|
||||
tunnel.recvByteLimit = 3
|
||||
if err := tunnel.RecvData([]byte("abc")); err != nil {
|
||||
t.Fatalf("fill receive byte budget: %v", err)
|
||||
}
|
||||
if err := tunnel.RecvData([]byte("d")); !errors.Is(err, errTunnelReceiveQueueFull) {
|
||||
t.Fatalf("frame beyond receive byte budget = %v, want %v", err, errTunnelReceiveQueueFull)
|
||||
}
|
||||
|
||||
buffer := make([]byte, 3)
|
||||
if n, err := tunnel.Read(buffer); n != 3 || err != nil || string(buffer) != "abc" {
|
||||
t.Fatalf("read byte-budget frame = (%d, %q, %v)", n, buffer, err)
|
||||
}
|
||||
if err := tunnel.RecvData([]byte("d")); err != nil {
|
||||
t.Fatalf("admit after byte budget released: %v", err)
|
||||
}
|
||||
if err := tunnel.RecvData(make([]byte, sliverpb.MaxTunnelFrameBytes+1)); !errors.Is(err, errTunnelReceiveFrameTooLarge) {
|
||||
t.Fatalf("oversized receive frame = %v, want %v", err, errTunnelReceiveFrameTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocyclo // Partial reads, terminal ordering, and EOF form one adapter contract.
|
||||
func TestTunnelIOReadRetainsShortBufferRemainderAfterClose(t *testing.T) {
|
||||
tunnel := NewTunnelIO(7, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
if n, err := tunnel.Read(make([]byte, 0)); n != 0 || err != nil {
|
||||
t.Fatalf("zero-length read = (%d, %v), want (0, nil)", n, err)
|
||||
}
|
||||
if n, err := tunnel.Write(nil); n != 0 || err != nil {
|
||||
t.Fatalf("zero-length write = (%d, %v), want (0, nil)", n, err)
|
||||
}
|
||||
select {
|
||||
case data := <-tunnel.Send:
|
||||
t.Fatalf("zero-length write queued data %x", data)
|
||||
default:
|
||||
}
|
||||
|
||||
if err := tunnel.RecvData([]byte("abcdef")); err != nil {
|
||||
t.Fatalf("queue short-buffer payload: %v", err)
|
||||
}
|
||||
first := make([]byte, 2)
|
||||
if n, err := tunnel.Read(first); n != 2 || err != nil || string(first) != "ab" {
|
||||
t.Fatalf("first short read = (%d, %q, %v), want (2, ab, nil)", n, first, err)
|
||||
}
|
||||
if err := tunnel.Close(); err != nil {
|
||||
t.Fatalf("close tunnel with remainder: %v", err)
|
||||
}
|
||||
second := make([]byte, 4)
|
||||
if n, err := tunnel.Read(second); n != 4 || err != nil || string(second) != "cdef" {
|
||||
t.Fatalf("remainder read = (%d, %q, %v), want (4, cdef, nil)", n, second, err)
|
||||
}
|
||||
if n, err := tunnel.Read(make([]byte, 1)); n != 0 || !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("post-remainder read = (%d, %v), want (0, EOF)", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelIOReadDrainsFrameAfterDoneCaseSelected(t *testing.T) {
|
||||
tunnel := NewTunnelIO(9, "session")
|
||||
if err := tunnel.Open(); err != nil {
|
||||
t.Fatalf("open tunnel: %v", err)
|
||||
}
|
||||
selectReady := make(chan struct{})
|
||||
doneSelected := make(chan struct{})
|
||||
releaseDoneBranch := make(chan struct{})
|
||||
var readyOnce sync.Once
|
||||
var doneOnce sync.Once
|
||||
tunnel.readSelectHook = func(selected bool) {
|
||||
if !selected {
|
||||
readyOnce.Do(func() { close(selectReady) })
|
||||
return
|
||||
}
|
||||
doneOnce.Do(func() { close(doneSelected) })
|
||||
<-releaseDoneBranch
|
||||
}
|
||||
|
||||
type readResult struct {
|
||||
data []byte
|
||||
n int
|
||||
err error
|
||||
}
|
||||
readDone := make(chan readResult, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, 32)
|
||||
n, err := tunnel.Read(buffer)
|
||||
readDone <- readResult{data: buffer[:n], n: n, err: err}
|
||||
}()
|
||||
waitForTestSignal(t, selectReady, "tunnel Read blocking select")
|
||||
if err := tunnel.Close(); err != nil {
|
||||
t.Fatalf("close tunnel: %v", err)
|
||||
}
|
||||
waitForTestSignal(t, doneSelected, "tunnel Read done case")
|
||||
|
||||
payload := []byte("last admitted frame")
|
||||
if err := tunnel.queueRecvData(payload); err != nil {
|
||||
t.Fatalf("queue final admitted frame: %v", err)
|
||||
}
|
||||
close(releaseDoneBranch)
|
||||
select {
|
||||
case result := <-readDone:
|
||||
if result.n != len(payload) || result.err != nil || !bytes.Equal(result.data, payload) {
|
||||
t.Fatalf("Read after done case = (%d, %q, %v), want (%d, %q, nil)", result.n, result.data, result.err, len(payload), payload)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Read did not drain the final admitted frame")
|
||||
}
|
||||
|
||||
tunnel.readSelectHook = nil
|
||||
if n, err := tunnel.Read(make([]byte, 1)); n != 0 || !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("Read after final drain = (%d, %v), want (0, EOF)", n, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
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"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type tunnelLoopTestRPC struct {
|
||||
rpcpb.SliverRPCClient
|
||||
stream *tunnelLoopTestStream
|
||||
opened chan struct{}
|
||||
startErr error
|
||||
}
|
||||
|
||||
func (r *tunnelLoopTestRPC) TunnelData(ctx context.Context, _ ...grpc.CallOption) (grpc.BidiStreamingClient[sliverpb.TunnelData, sliverpb.TunnelData], error) {
|
||||
close(r.opened)
|
||||
if r.startErr != nil {
|
||||
return nil, r.startErr
|
||||
}
|
||||
r.stream.ctx = ctx
|
||||
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 TestTunnelLoopWithReadySignalsAfterStreamInstallation(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),
|
||||
}
|
||||
rpc := &tunnelLoopTestRPC{stream: stream, opened: make(chan struct{})}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ready := make(chan struct{})
|
||||
loopDone := make(chan error, 1)
|
||||
go func() { loopDone <- TunnelLoopWithReady(ctx, rpc, ready) }()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-loopDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("TunnelLoopWithReady did not stop after cancellation")
|
||||
}
|
||||
tunnels.Reset()
|
||||
})
|
||||
|
||||
waitForTestSignal(t, ready, "TunnelLoop readiness")
|
||||
tunnel := tunnels.Start(10, "session")
|
||||
select {
|
||||
case bind := <-stream.sent:
|
||||
if bind.TunnelID != tunnel.ID {
|
||||
t.Fatalf("bind tunnel ID = %d, want %d", bind.TunnelID, tunnel.ID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ready tunnel loop did not send a bind frame")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelLoopWithReadyDoesNotSignalOnStartupFailure(t *testing.T) {
|
||||
startErr := errors.New("test stream startup failure")
|
||||
rpc := &tunnelLoopTestRPC{opened: make(chan struct{}), startErr: startErr}
|
||||
ready := make(chan struct{})
|
||||
|
||||
err := TunnelLoopWithReady(context.Background(), rpc, ready)
|
||||
if !errors.Is(err, startErr) {
|
||||
t.Fatalf("TunnelLoopWithReady error = %v, want %v", err, startErr)
|
||||
}
|
||||
select {
|
||||
case <-ready:
|
||||
t.Fatal("readiness was signaled after stream startup failed")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
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):
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocyclo // The overload and sibling-isolation assertions share one stream lifecycle.
|
||||
func TestTunnelLoopOverloadedTunnelDoesNotBlockHealthyTunnel(t *testing.T) {
|
||||
tunnels := GetTunnels()
|
||||
tunnels.Reset()
|
||||
|
||||
stream := &tunnelLoopTestStream{
|
||||
incoming: make(chan *sliverpb.TunnelData, tunnelRecvBufferSize+4),
|
||||
sent: make(chan *sliverpb.TunnelData, 2),
|
||||
recvCalls: make(chan struct{}, tunnelRecvBufferSize+8),
|
||||
}
|
||||
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")
|
||||
stalled := tunnels.Start(11, "session")
|
||||
healthy := tunnels.Start(12, "session")
|
||||
bound := map[uint64]bool{}
|
||||
for len(bound) < 2 {
|
||||
select {
|
||||
case frame := <-stream.sent:
|
||||
bound[frame.TunnelID] = true
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("client did not send both tunnel bind frames")
|
||||
}
|
||||
}
|
||||
|
||||
for sequence := 0; sequence < tunnelRecvBufferSize; sequence++ {
|
||||
stream.incoming <- &sliverpb.TunnelData{
|
||||
TunnelID: stalled.ID,
|
||||
SessionID: stalled.SessionID,
|
||||
Data: []byte{byte(sequence)},
|
||||
}
|
||||
waitForTestSignal(t, stream.recvCalls, "receive after stalled tunnel frame")
|
||||
}
|
||||
stream.incoming <- &sliverpb.TunnelData{
|
||||
TunnelID: stalled.ID,
|
||||
SessionID: stalled.SessionID,
|
||||
Data: []byte("overflow"),
|
||||
}
|
||||
waitForTestSignal(t, stream.recvCalls, "receive after stalled tunnel overflow")
|
||||
|
||||
healthyPayload := []byte("healthy tunnel data")
|
||||
stream.incoming <- &sliverpb.TunnelData{
|
||||
TunnelID: healthy.ID,
|
||||
SessionID: healthy.SessionID,
|
||||
Data: healthyPayload,
|
||||
}
|
||||
waitForTestSignal(t, stream.recvCalls, "receive after healthy tunnel frame")
|
||||
|
||||
select {
|
||||
case <-stalled.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("overloaded tunnel was not closed")
|
||||
}
|
||||
if got := tunnels.Get(stalled.ID); got != nil {
|
||||
t.Fatal("overloaded tunnel remained registered")
|
||||
}
|
||||
type healthyReadResult struct {
|
||||
data []byte
|
||||
n int
|
||||
err error
|
||||
}
|
||||
healthyRead := make(chan healthyReadResult, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, len(healthyPayload))
|
||||
n, err := healthy.Read(buffer)
|
||||
healthyRead <- healthyReadResult{data: buffer[:n], n: n, err: err}
|
||||
}()
|
||||
select {
|
||||
case result := <-healthyRead:
|
||||
if result.n != len(healthyPayload) || result.err != nil || !bytes.Equal(result.data, healthyPayload) {
|
||||
t.Fatalf("healthy tunnel read = (%d, %q, %v), want (%d, %q, nil)", result.n, result.data, result.err, len(healthyPayload), healthyPayload)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("healthy tunnel read blocked behind overloaded tunnel")
|
||||
}
|
||||
if !healthy.IsOpen() {
|
||||
t.Fatal("healthy tunnel closed after unrelated tunnel overload")
|
||||
}
|
||||
}
|
||||
|
||||
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 TestTunnelLoopPropagatesRemoteGRPCCanceled(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{}, 2),
|
||||
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")
|
||||
remoteErr := status.Error(codes.Canceled, "test remote shutdown")
|
||||
stream.recvErr <- remoteErr
|
||||
|
||||
select {
|
||||
case err := <-loopDone:
|
||||
if status.Code(err) != codes.Canceled {
|
||||
t.Fatalf("TunnelLoop remote canceled status result = %v, want %v", err, remoteErr)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("TunnelLoop did not return after remote canceled gRPC status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelLoopTreatsCallerCancellationAsCleanShutdown(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{}, 2),
|
||||
recvErr: make(chan error, 1),
|
||||
}
|
||||
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)
|
||||
}()
|
||||
|
||||
waitForTestSignal(t, rpc.opened, "TunnelLoop stream startup")
|
||||
waitForTestSignal(t, stream.recvCalls, "initial TunnelData receive")
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-loopDone:
|
||||
if err != nil {
|
||||
t.Fatalf("TunnelLoop caller cancellation result = %v, want nil", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("TunnelLoop did not return after caller cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+116
-26
@@ -34,6 +34,8 @@ var (
|
||||
tunnelsSingletonLock = &sync.Mutex{}
|
||||
)
|
||||
|
||||
var errSupersededTunnel = errors.New("superseded tunnel")
|
||||
|
||||
// GetTunnels - singleton function that returns or initializes all tunnels
|
||||
func GetTunnels() *tunnels {
|
||||
tunnelsSingletonLock.Lock()
|
||||
@@ -55,19 +57,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
|
||||
@@ -82,10 +100,20 @@ func (t *tunnels) Get(tunnelID uint64) *TunnelIO {
|
||||
|
||||
// send - safe way to send a message to the stream
|
||||
// protobuf stream allow only one writer at a time, so just in case there is a mutex for it
|
||||
func (t *tunnels) send(tunnelData *sliverpb.TunnelData) error {
|
||||
func (t *tunnels) send(expected *TunnelIO, tunnelData *sliverpb.TunnelData) error {
|
||||
t.streamMutex.Lock()
|
||||
defer t.streamMutex.Unlock()
|
||||
|
||||
// Start publishes replacement tunnel generations while holding streamMutex.
|
||||
// Checking the exact pointer under the same lock ensures an old generation
|
||||
// cannot send another frame after its replacement becomes visible.
|
||||
t.mutex.RLock()
|
||||
current := (*t.tunnels)[expected.ID]
|
||||
t.mutex.RUnlock()
|
||||
if current != expected {
|
||||
return errSupersededTunnel
|
||||
}
|
||||
|
||||
if t.stream == nil {
|
||||
return errors.New("uninitizlied stream")
|
||||
}
|
||||
@@ -97,35 +125,67 @@ func (t *tunnels) send(tunnelData *sliverpb.TunnelData) error {
|
||||
|
||||
// Start - Add a tunnel to the core mapper
|
||||
func (t *tunnels) Start(tunnelID uint64, sessionID string) *TunnelIO {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
tunnel := NewTunnelIO(tunnelID, sessionID)
|
||||
if err := tunnel.Open(); err != nil {
|
||||
log.Printf("Failed to open tunnel %d: %v", tunnelID, err)
|
||||
return tunnel
|
||||
}
|
||||
|
||||
// Publish the new generation atomically with respect to tunnel sends. If an
|
||||
// ID is reused, the previous generation is made terminal before the new
|
||||
// pointer becomes visible in the map.
|
||||
t.streamMutex.Lock()
|
||||
t.mutex.Lock()
|
||||
previous := (*t.tunnels)[tunnelID]
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
(*t.tunnels)[tunnelID] = tunnel
|
||||
t.mutex.Unlock()
|
||||
t.streamMutex.Unlock()
|
||||
|
||||
go func(tunnel *TunnelIO) {
|
||||
tunnel.Open()
|
||||
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 {
|
||||
var (
|
||||
data []byte
|
||||
result chan error
|
||||
)
|
||||
select {
|
||||
case <-tunnel.Done():
|
||||
log.Printf("Tunnel send loop stopped. %d", tunnelID)
|
||||
return
|
||||
case request := <-tunnel.writeRequests:
|
||||
data = request.data
|
||||
result = request.result
|
||||
case data = <-tunnel.Send:
|
||||
}
|
||||
|
||||
err := t.send(&sliverpb.TunnelData{
|
||||
log.Printf("Send %d bytes on tunnel %d", len(data), tunnel.ID)
|
||||
err := t.send(tunnel, &sliverpb.TunnelData{
|
||||
TunnelID: tunnel.ID,
|
||||
SessionID: tunnel.SessionID,
|
||||
Data: data,
|
||||
})
|
||||
|
||||
if result != nil {
|
||||
// The result channel is buffered so a concurrent Close can wake the
|
||||
// writer without stranding this exact-generation send loop. Publish
|
||||
// the result before closing the tunnel on failure.
|
||||
result <- err
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Error sending, %s", err)
|
||||
t.CloseIf(tunnel)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -139,33 +199,63 @@ func (t *tunnels) Close(tunnelID uint64) {
|
||||
tunnel := (*t.tunnels)[tunnelID]
|
||||
|
||||
if tunnel != nil {
|
||||
tunnel.Close()
|
||||
|
||||
_ = tunnel.Close()
|
||||
delete((*t.tunnels), tunnelID)
|
||||
}
|
||||
}
|
||||
|
||||
// CloseIf closes and removes expected only when it is still the current
|
||||
// generation registered for its numeric tunnel ID.
|
||||
func (t *tunnels) CloseIf(expected *TunnelIO) bool {
|
||||
if expected == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
current := (*t.tunnels)[expected.ID]
|
||||
if current != expected {
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("Closing tunnel %d", expected.ID)
|
||||
_ = expected.Close()
|
||||
delete(*t.tunnels, expected.ID)
|
||||
return true
|
||||
}
|
||||
|
||||
// CloseForSession - closing all tunnels for specified session id
|
||||
func (t *tunnels) CloseForSession(sessionID string) {
|
||||
t.mutex.RLock()
|
||||
defer t.mutex.RUnlock()
|
||||
log.Printf("Closing all tunnels for session %s", sessionID)
|
||||
|
||||
for tunnelID, tunnel := range *t.tunnels {
|
||||
tunnels := []*TunnelIO{}
|
||||
for _, tunnel := range *t.tunnels {
|
||||
if tunnel.SessionID == sessionID {
|
||||
// Weird way to avoid deadlocks but let it be
|
||||
go func(tunnelID uint64) {
|
||||
GetTunnels().Close(tunnelID)
|
||||
}(tunnelID)
|
||||
tunnels = append(tunnels, tunnel)
|
||||
}
|
||||
}
|
||||
t.mutex.RUnlock()
|
||||
|
||||
for _, tunnel := range tunnels {
|
||||
go t.CloseIf(tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
)
|
||||
|
||||
type tunnelGenerationTestStream struct {
|
||||
rpcpb.SliverRPC_TunnelDataClient
|
||||
|
||||
sent chan *sliverpb.TunnelData
|
||||
dataEntered chan struct{}
|
||||
releaseData chan struct{}
|
||||
dataErr error
|
||||
enterOnce sync.Once
|
||||
}
|
||||
|
||||
func (stream *tunnelGenerationTestStream) Send(frame *sliverpb.TunnelData) error {
|
||||
if len(frame.GetData()) > 0 && stream.dataEntered != nil {
|
||||
stream.enterOnce.Do(func() { close(stream.dataEntered) })
|
||||
<-stream.releaseData
|
||||
if stream.dataErr != nil {
|
||||
return stream.dataErr
|
||||
}
|
||||
}
|
||||
stream.sent <- frame
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTunnelsCloseIfDoesNotCloseReplacementGeneration(t *testing.T) {
|
||||
tunnels := newTunnelGenerationTestStore()
|
||||
stream := &tunnelGenerationTestStream{sent: make(chan *sliverpb.TunnelData, 2)}
|
||||
tunnels.SetStream(stream)
|
||||
|
||||
old := tunnels.Start(41, "old-session")
|
||||
waitForTunnelGenerationFrame(t, stream.sent)
|
||||
replacement := tunnels.Start(41, "new-session")
|
||||
waitForTunnelGenerationFrame(t, stream.sent)
|
||||
|
||||
select {
|
||||
case <-old.Done():
|
||||
default:
|
||||
t.Fatal("replaced tunnel generation remained open")
|
||||
}
|
||||
if current := tunnels.Get(41); current != replacement {
|
||||
t.Fatal("replacement tunnel generation was not published")
|
||||
}
|
||||
if tunnels.CloseIf(old) {
|
||||
t.Fatal("CloseIf reported closing a superseded tunnel generation")
|
||||
}
|
||||
select {
|
||||
case <-replacement.Done():
|
||||
t.Fatal("stale CloseIf closed the replacement tunnel generation")
|
||||
default:
|
||||
}
|
||||
if !tunnels.CloseIf(replacement) {
|
||||
t.Fatal("CloseIf did not close the current tunnel generation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelSendFailureCannotClosePublishedReplacement(t *testing.T) {
|
||||
tunnels := newTunnelGenerationTestStore()
|
||||
sendErr := errors.New("test old-generation send failure")
|
||||
stream := &tunnelGenerationTestStream{
|
||||
sent: make(chan *sliverpb.TunnelData, 2),
|
||||
dataEntered: make(chan struct{}),
|
||||
releaseData: make(chan struct{}),
|
||||
dataErr: sendErr,
|
||||
}
|
||||
tunnels.SetStream(stream)
|
||||
|
||||
old := tunnels.Start(42, "old-session")
|
||||
waitForTunnelGenerationFrame(t, stream.sent)
|
||||
type writeResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
writeDone := make(chan writeResult, 1)
|
||||
go func() {
|
||||
n, err := old.Write([]byte("old generation"))
|
||||
writeDone <- writeResult{n: n, err: err}
|
||||
}()
|
||||
waitForTunnelGenerationSignal(t, stream.dataEntered, "old-generation stream send")
|
||||
select {
|
||||
case result := <-writeDone:
|
||||
t.Fatalf("Write completed before stream Send: (%d, %v)", result.n, result.err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
replacementDone := make(chan *TunnelIO, 1)
|
||||
go func() {
|
||||
replacementDone <- tunnels.Start(42, "new-session")
|
||||
}()
|
||||
select {
|
||||
case replacement := <-replacementDone:
|
||||
t.Fatalf("replacement %p was published before the old send completed", replacement)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
if current := tunnels.Get(42); current != old {
|
||||
t.Fatal("old generation stopped being current while its stream send was in flight")
|
||||
}
|
||||
|
||||
close(stream.releaseData)
|
||||
select {
|
||||
case result := <-writeDone:
|
||||
if result.n != 0 || result.err == nil {
|
||||
t.Fatalf("failed stream Write = (%d, %v), want (0, error)", result.n, result.err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Write did not return after stream Send failed")
|
||||
}
|
||||
var replacement *TunnelIO
|
||||
select {
|
||||
case replacement = <-replacementDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("replacement Start did not return after the old send completed")
|
||||
}
|
||||
waitForTunnelGenerationFrame(t, stream.sent)
|
||||
|
||||
if current := tunnels.Get(42); current != replacement {
|
||||
t.Fatal("old send failure removed the published replacement generation")
|
||||
}
|
||||
select {
|
||||
case <-replacement.Done():
|
||||
t.Fatal("old send failure closed the published replacement generation")
|
||||
default:
|
||||
}
|
||||
tunnels.CloseIf(replacement)
|
||||
}
|
||||
|
||||
func TestTunnelWriteWaitsForSuccessfulExactGenerationStreamSend(t *testing.T) {
|
||||
tunnels := newTunnelGenerationTestStore()
|
||||
stream := &tunnelGenerationTestStream{
|
||||
sent: make(chan *sliverpb.TunnelData, 2),
|
||||
dataEntered: make(chan struct{}),
|
||||
releaseData: make(chan struct{}),
|
||||
}
|
||||
tunnels.SetStream(stream)
|
||||
|
||||
tunnel := tunnels.Start(43, "final-write-session")
|
||||
waitForTunnelGenerationFrame(t, stream.sent)
|
||||
payload := []byte("final payload")
|
||||
type writeResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
writeDone := make(chan writeResult, 1)
|
||||
go func() {
|
||||
n, err := tunnel.Write(payload)
|
||||
writeDone <- writeResult{n: n, err: err}
|
||||
}()
|
||||
waitForTunnelGenerationSignal(t, stream.dataEntered, "final stream send")
|
||||
select {
|
||||
case result := <-writeDone:
|
||||
t.Fatalf("Write completed before stream Send: (%d, %v)", result.n, result.err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(stream.releaseData)
|
||||
select {
|
||||
case result := <-writeDone:
|
||||
if result.n != len(payload) || result.err != nil {
|
||||
t.Fatalf("successful stream Write = (%d, %v), want (%d, nil)", result.n, result.err, len(payload))
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Write did not complete after successful stream Send")
|
||||
}
|
||||
select {
|
||||
case frame := <-stream.sent:
|
||||
if string(frame.GetData()) != string(payload) {
|
||||
t.Fatalf("stream payload = %q, want %q", frame.GetData(), payload)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("successful Write returned before the payload was recorded")
|
||||
}
|
||||
if !tunnels.CloseIf(tunnel) {
|
||||
t.Fatal("failed to close final-write tunnel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelCloseWakesWriteWaitingForStreamSend(t *testing.T) {
|
||||
tunnels := newTunnelGenerationTestStore()
|
||||
stream := &tunnelGenerationTestStream{
|
||||
sent: make(chan *sliverpb.TunnelData, 2),
|
||||
dataEntered: make(chan struct{}),
|
||||
releaseData: make(chan struct{}),
|
||||
}
|
||||
tunnels.SetStream(stream)
|
||||
|
||||
tunnel := tunnels.Start(44, "closing-write-session")
|
||||
waitForTunnelGenerationFrame(t, stream.sent)
|
||||
type writeResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
writeDone := make(chan writeResult, 1)
|
||||
go func() {
|
||||
n, err := tunnel.Write([]byte("blocked payload"))
|
||||
writeDone <- writeResult{n: n, err: err}
|
||||
}()
|
||||
waitForTunnelGenerationSignal(t, stream.dataEntered, "blocked stream send")
|
||||
if !tunnels.CloseIf(tunnel) {
|
||||
t.Fatal("failed to close tunnel with in-flight Write")
|
||||
}
|
||||
select {
|
||||
case result := <-writeDone:
|
||||
if result.n != 0 || !errors.Is(result.err, io.EOF) {
|
||||
t.Fatalf("Write after concurrent close = (%d, %v), want (0, EOF)", result.n, result.err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not wake Write waiting for stream Send")
|
||||
}
|
||||
close(stream.releaseData)
|
||||
}
|
||||
|
||||
func newTunnelGenerationTestStore() *tunnels {
|
||||
tunnelMap := map[uint64]*TunnelIO{}
|
||||
return &tunnels{
|
||||
tunnels: &tunnelMap,
|
||||
mutex: &sync.RWMutex{},
|
||||
streamMutex: &sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func waitForTunnelGenerationFrame(t *testing.T, frames <-chan *sliverpb.TunnelData) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-frames:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for tunnel stream frame")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForTunnelGenerationSignal(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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+14
-14
@@ -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",
|
||||
@@ -1498,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": {
|
||||
@@ -7707,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -2,18 +2,18 @@ Although the `sliver-client` is the default way to interact with a `sliver-serve
|
||||
|
||||
To do so, you can use [sliver-script](https://github.com/moloch--/sliver-script), [sliver-py](https://github.com/moloch--/sliver-py), or write a custom client in another language.
|
||||
|
||||
Current Sliver releases generate multiplayer operator configs with a `wg` block by default. The official `sliver-client`, and Go clients that reuse Sliver's `client/assets` plus `client/transport` packages, will automatically bring up that WireGuard wrapper before dialing the in-tunnel gRPC/mTLS service.
|
||||
Sliver exposes multiplayer directly over gRPC/mTLS and generates direct operator configs by default. The multiplayer WireGuard wrapper is enabled explicitly on the listener and when the operator profile is generated. A WireGuard-enabled profile records that choice as `wg.enabled: true`.
|
||||
|
||||
If you are writing a custom client in another language, you have two options:
|
||||
|
||||
- Implement the multiplayer WireGuard wrapper described in [multiplayer mode](/docs?name=Multi-player+Mode), then connect to the in-tunnel gRPC/mTLS service.
|
||||
- Keep multiplayer in direct mode by default, or explicitly opt into the wrapper with `multiplayer --enable-wg` or `sliver-server daemon --enable-wg`. Generate matching operator profiles with `new-operator --enable-wg` or `sliver-server operator --enable-wg` when you want the client-side WireGuard wrapper.
|
||||
- Use the default direct gRPC/mTLS multiplayer mode, which works with a standard gRPC client and a default operator profile.
|
||||
- Implement the multiplayer WireGuard wrapper described in [multiplayer mode](/docs?name=Multi-player+Mode). Opt into it with `multiplayer --enable-wg` or `sliver-server daemon --enable-wg`, generate a matching profile with `new-operator --enable-wg` or `sliver-server operator --enable-wg`, and honor `wg.enabled` when selecting the transport. A legacy profile that has a `wg` block but no `enabled` field should remain direct.
|
||||
|
||||
Once connected, the client/server API is still gRPC, so any language with gRPC support can in theory be used to create a custom client.
|
||||
|
||||
## Writing a Go client
|
||||
|
||||
This example uses Sliver's Go client libraries, so it works with either a direct multiplayer profile or a WireGuard-enabled profile.
|
||||
This example uses Sliver's Go client libraries. `transport.MTLSConnect` automatically follows the selected profile in `transport.MultiplayerConnectAuto` mode: `wg.enabled: true` selects WireGuard, while a missing or disabled marker selects direct gRPC/mTLS. Applications that expose manual transport controls can call `transport.SetMultiplayerConnectMode` with `transport.MultiplayerConnectEnableWG` or `transport.MultiplayerConnectDisableWG` before connecting.
|
||||
|
||||
In this example, we will focus on writing a custom Go client that executes a new system command on every new implant that connects to the sliver server.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Sliver supports running in daemon mode, which automatically starts the multiplayer listener but does not provide an interactive console. To connect to a daemonized server, use [multiplayer mode](/docs?name=Multi-player+Mode) operator configs.
|
||||
|
||||
By default, daemon mode starts the same WireGuard-protected multiplayer listener used by the interactive `multiplayer` command. That means the outer listener is UDP on `31337` by default, while the authenticated gRPC/mTLS service stays inside the WireGuard tunnel.
|
||||
By default, daemon mode exposes the multiplayer listener directly over TCP gRPC/mTLS on port `31337`. The multiplayer WireGuard wrapper is opt-in with `--enable-wg` or `daemon.enable_wg: true`.
|
||||
|
||||
There are two ways to run in daemon mode:
|
||||
|
||||
@@ -49,7 +49,7 @@ Since daemon mode does not provide an interactive server console, generate opera
|
||||
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --permissions all --save zer0cool.cfg
|
||||
```
|
||||
|
||||
The `operator` CLI generates direct multiplayer profiles by default. Add `--enable-wg` when the daemon is running with the WireGuard wrapper.
|
||||
The `operator` CLI generates direct multiplayer profiles by default. When the daemon is running with the WireGuard wrapper, add `--enable-wg` while generating the profile. That profile contains `wg.enabled: true`, so `sliver-client` selects WireGuard automatically. Use the client's `--enable-wg` or `--disable-wg` only when you need to override the profile.
|
||||
|
||||
### Shutdown Behavior
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ Any `sliver-server` binary can be started as a builder process using [operator c
|
||||
./sliver-server builder -c operator-multiplayer.cfg
|
||||
```
|
||||
|
||||
Builders follow the operator profile automatically: direct profiles use gRPC/mTLS, while profiles generated with `new-operator --enable-wg` (or `sliver-server operator --enable-wg`) contain `wg.enabled: true` and use WireGuard. No builder flag is required for a correctly generated profile.
|
||||
|
||||
Use `--enable-wg` to force WireGuard for a profile with tunnel settings, or `--disable-wg` to force a direct connection; the flags cannot be combined. Profiles from the withdrawn WireGuard-by-default release may have a `wg` block without `enabled`; builders treat those profiles as direct unless WireGuard is forced explicitly.
|
||||
|
||||
When started as a builder, the Sliver process will mirror log output to stdout by default, however this can be disabled (see `sliver-server builder --help`).
|
||||
|
||||
**⚠️ IMPORTANT:** Builders must have unique names, by default the builder's hostname will be used, but this can be changed using the `--name` cli flag.
|
||||
|
||||
@@ -4,7 +4,7 @@ https://sliver.sh/install
|
||||
|
||||
This script should work on Kali, Ubuntu, and RHEL (CentOS, etc) distributions of Linux.
|
||||
|
||||
**⚠️ OPSEC:** By default the Linux install script will bind the multiplayer listener to `:31337` on all interfaces. In current releases that is the WireGuard-protected multiplayer listener, so the outer service is UDP/31337 and the authenticated gRPC/mTLS server only exists inside the tunnel. Ensure your firewalls are properly configured if this is a concern, or reconfigure the server to bind to localhost if you only wish to allow local users. Publicly exposing the multiplayer listener still makes the server easier to discover and fingerprint.
|
||||
**⚠️ OPSEC:** By default the Linux install script binds the direct multiplayer gRPC/mTLS listener to TCP `:31337` on all interfaces. WireGuard is not enabled by the installer. Ensure your firewalls are properly configured, or reconfigure the server to bind to localhost if you only wish to allow local users. Publicly exposing the multiplayer listener makes the server easier to discover and fingerprint.
|
||||
|
||||
### One Liner
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ Sliver exposes the operator-facing multiplayer listener directly over gRPC/mTLS
|
||||
└──────────────────┘
|
||||
▲
|
||||
│
|
||||
UDP/WireGuard (default) │
|
||||
gRPC/mTLS inside │
|
||||
TCP/gRPC/mTLS (default) │
|
||||
┌────────────┬────────┴─────┬───────────┐
|
||||
│ │ │ │
|
||||
┌─────┴──┐ │ │ ┌──┴─────┐
|
||||
@@ -21,7 +20,7 @@ Sliver exposes the operator-facing multiplayer listener directly over gRPC/mTLS
|
||||
└────────┘ └────────┘
|
||||
```
|
||||
|
||||
## Default WireGuard-Protected Mode
|
||||
## Default Direct mTLS Mode
|
||||
|
||||
Operators will need to download their preferred platform's client software from the releases page. Clients can connect to servers of differing platforms (e.g. a Windows client can connect to a Linux server). Operators and servers authenticate using Mutual TLS, however all the certificates are managed automatically for you.
|
||||
|
||||
@@ -32,11 +31,9 @@ Operators need a configuration file generated by the Sliver server to connect in
|
||||
|
||||
In the default mode:
|
||||
|
||||
- `multiplayer` exposes a WireGuard listener on `--lport` (default `31337`) over UDP.
|
||||
- The authenticated gRPC/mTLS server only listens inside that WireGuard tunnel.
|
||||
- `new-operator` generates a per-operator WireGuard keypair and tunnel IP, then embeds that information in the operator config's `wg` block.
|
||||
- The multiplayer listener uses WireGuard keys that are separate from Sliver's implant `wg` C2 listener.
|
||||
- `sliver-client` automatically uses the WireGuard wrapper when the imported config contains that `wg` block.
|
||||
- `multiplayer` exposes the authenticated gRPC/mTLS listener directly over TCP on `--lport` (default `31337`).
|
||||
- `new-operator` generates a direct multiplayer profile without a `wg` block.
|
||||
- `sliver-client` connects directly over gRPC/mTLS because the selected profile does not opt into WireGuard.
|
||||
|
||||
From the server, start the multiplayer listener and generate an operator config with the server's public address or hostname in `--lhost`. Use `--permissions all` for a standard interactive operator profile:
|
||||
|
||||
@@ -66,28 +63,50 @@ $ ./sliver-client
|
||||
localhost
|
||||
```
|
||||
|
||||
## Direct mTLS Mode
|
||||
## Optional WireGuard Wrapper
|
||||
|
||||
If you want the old behavior, or you need compatibility with third-party gRPC clients that do not implement the multiplayer WireGuard wrapper, disable it on both the listener and the generated operator profile:
|
||||
Multiplayer WireGuard is opt-in. Enable it explicitly on the listener and include matching WireGuard settings in the operator profile:
|
||||
|
||||
```
|
||||
[server] sliver > multiplayer
|
||||
[server] sliver > multiplayer --enable-wg
|
||||
|
||||
[*] Multiplayer mode enabled!
|
||||
|
||||
[server] sliver > new-operator --name tester --lhost 1.2.3.4 --permissions all
|
||||
[server] sliver > new-operator --name tester --lhost 1.2.3.4 --permissions all --enable-wg
|
||||
|
||||
[*] Generating new client certificate, please wait ...
|
||||
[*] Saved new client config to: /Users/tester/Desktop/tester_example.com.cfg
|
||||
```
|
||||
|
||||
In direct mode:
|
||||
Import the profile and start the client normally:
|
||||
|
||||
- Multiplayer is exposed directly over TCP on `--lport` (default `31337`).
|
||||
- Generated operator configs omit the `wg` block.
|
||||
- `sliver-client --enable-wg` opts into the multiplayer WireGuard wrapper when the operator config includes a `wg` block.
|
||||
```
|
||||
$ ./sliver-client import ./tester_example.com.cfg
|
||||
|
||||
The listener mode, operator config, and client flag need to match. A WireGuard-wrapped listener needs a `wg` block in the operator profile plus `sliver-client --enable-wg`. Direct multiplayer works without the `wg` block and without the flag.
|
||||
$ ./sliver-client
|
||||
```
|
||||
|
||||
In WireGuard mode:
|
||||
|
||||
- `multiplayer --enable-wg` exposes a WireGuard listener on `--lport` (default `31337`) over UDP.
|
||||
- The authenticated gRPC/mTLS server only listens inside that WireGuard tunnel.
|
||||
- `new-operator --enable-wg` generates a per-operator WireGuard keypair and tunnel IP, then embeds that information with `wg.enabled: true` in the operator profile.
|
||||
- `sliver-client` sees `wg.enabled: true` and automatically uses the profile's WireGuard settings before dialing the in-tunnel gRPC/mTLS service.
|
||||
- The multiplayer listener uses WireGuard keys that are separate from Sliver's implant `wg` C2 listener.
|
||||
|
||||
The listener mode and operator profile need to match. A WireGuard-wrapped listener needs a profile generated with `--enable-wg`; direct multiplayer uses a normal profile. No client flag is required in either case.
|
||||
|
||||
The client provides explicit overrides for troubleshooting and migration:
|
||||
|
||||
- `sliver-client --enable-wg` forces WireGuard and requires usable WireGuard settings in the profile.
|
||||
- `sliver-client --disable-wg` forces direct gRPC/mTLS even when `wg.enabled` is true.
|
||||
- Without either flag, the client follows `wg.enabled`. The flags cannot be used together.
|
||||
|
||||
Profiles from the withdrawn WireGuard-by-default release may contain a `wg` block without the new `enabled` marker. The client treats those profiles as direct rather than considering block presence an opt-in. To keep using WireGuard, explicitly recreate the listener and profile with `--enable-wg`. The client override can temporarily force a legacy profile only when its listener is also running in WireGuard mode.
|
||||
|
||||
When upgrading from the withdrawn WireGuard-by-default release, legacy persisted multiplayer jobs restart in direct mode because they do not contain an explicit WireGuard opt-in. Recreate the listener with `multiplayer --enable-wg` if you intentionally want the wrapper.
|
||||
|
||||
Upgrade affected clients along with the server. The withdrawn client automatically selected WireGuard whenever a `wg` block was present, so it can mismatch a listener that the new server safely migrated to direct mode. Until that client is upgraded, use its `--disable-wg` override for a migrated direct listener, or explicitly re-enable WireGuard on both the listener and profile.
|
||||
|
||||
### Server CLI / Daemon Mode Multiplayer
|
||||
|
||||
@@ -99,7 +118,7 @@ Use the server CLI to generate operator configuration files:
|
||||
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --permissions all --save zer0cool.cfg
|
||||
```
|
||||
|
||||
Operator profiles omit the multiplayer `wg` block by default. Add `--enable-wg` when the listener is wrapped in WireGuard.
|
||||
Operator profiles omit the multiplayer `wg` block by default. Add `--enable-wg` when the listener is wrapped in WireGuard; the resulting `wg.enabled: true` marker makes the client select the wrapper automatically.
|
||||
|
||||
The installation script places the `sliver-server` binary in `/root` by default.
|
||||
|
||||
@@ -107,7 +126,7 @@ The installation script places the `sliver-server` binary in `/root` by default.
|
||||
|
||||
Sliver supports multiplayer integration with [Tailscale](https://tailscale.com/), which is an excellent option when you want to keep the multiplayer listener off the public Internet.
|
||||
|
||||
Tailscale mode is separate from the default multiplayer WireGuard wrapper. When you use `multiplayer -T`, Sliver exposes the listener only through Tailscale instead of layering its own multiplayer WireGuard tunnel on top.
|
||||
Tailscale mode is separate from the optional multiplayer WireGuard wrapper. When you use `multiplayer -T`, Sliver exposes the listener only through Tailscale instead of layering its own multiplayer WireGuard tunnel on top.
|
||||
|
||||
```
|
||||
$ export TS_AUTHKEY=<tailscale authkey>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
[OPFOR](https://github.com/sliverarmory/opfor) runs a BOF-focused subset of CNA/Aggressor Script directly inside `sliver-client`. It lets operators use projects that already ship a `.cna` wrapper and BOF object files without first translating the script into a Sliver extension manifest.
|
||||
|
||||
OPFOR is not a complete Cobalt Strike client or a complete Aggressor Script implementation. Sliver currently connects the parts needed by common BOF scripts: local script resources, Beacon alias registration and help, target metadata queries, BOF argument packing, transcript output, `beacon_inline_execute`, result callbacks, and basic terminal prompts.
|
||||
|
||||
For manifest-based BOF packages and Armory installation, see [BOF and COFF Support](/docs?name=BOF+and+COFF+Support).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Keep the `.cna` file and its resource tree on the computer running `sliver-client`. Calls such as `script_resource()` resolve files from that local script tree.
|
||||
- BOF aliases require an active session or beacon. The target must advertise Sliver's `bof_v1` capability.
|
||||
- The tested BOF execution path supports Windows x64 and x86 targets. A CNA may perform its own architecture selection with functions such as `barch()`.
|
||||
- Review third-party scripts before loading them. Top-level CNA code runs locally in the Sliver client process.
|
||||
|
||||
Loaded scripts and their registrations belong only to the current client process. They are not installed in Armory, copied to the server, or shared with other operators, and they must be loaded again after restarting the client.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Behavior |
|
||||
| --- | --- |
|
||||
| `opfor check <script.cna>` | Compile the script without executing its top-level code. |
|
||||
| `opfor run <script.cna> [arguments...]` | Execute top-level code once with trailing values in `@ARGV`, then discard registrations. |
|
||||
| `opfor load <script.cna>` | Execute initialization and retain aliases, hooks, and callbacks. |
|
||||
| `opfor <script.cna>` | Shorthand for `opfor load <script.cna>`. |
|
||||
| `opfor list` | List scripts retained by this client process. |
|
||||
| `opfor help <alias>` | Display the description and detail supplied by `beacon_command_register()`. |
|
||||
| `opfor <alias> [arguments...]` | Invoke a loaded Beacon alias against the active target. |
|
||||
| `opfor unload <script.cna>` | Retire a loaded script and its registrations. |
|
||||
|
||||
`opfor run` does not invoke aliases declared by the script. Most BOF CNAs define an `alias` at top level, so running one only creates that alias in a temporary runtime and then retires it. Use `opfor load`, select a target, and invoke the registered alias instead.
|
||||
|
||||
The default operation timeout is 600 seconds. Put a timeout override before the alias name so Sliver consumes it instead of passing it to the CNA command:
|
||||
|
||||
```
|
||||
opfor --timeout 900 firefoxdump /all
|
||||
```
|
||||
|
||||
Arguments after the alias name are passed to the CNA as written, including flag-like values. If the CNA itself needs a leading `--timeout` or `-t` argument, place `--` before it so the OPFOR host stops parsing timeout overrides:
|
||||
|
||||
```
|
||||
opfor example -- --timeout
|
||||
```
|
||||
|
||||
## Loading and running FirefoxDump
|
||||
|
||||
Keep the script beside the object files expected by its `script_resource()` calls. For example:
|
||||
|
||||
```
|
||||
firefoxdump/
|
||||
├── firefoxdump.cna
|
||||
└── bin/
|
||||
├── firefoxdump.x64.o
|
||||
└── firefoxdump.x86.o
|
||||
```
|
||||
|
||||
Check the script before executing its initialization code:
|
||||
|
||||
```
|
||||
sliver > opfor check ./firefoxdump/firefoxdump.cna
|
||||
./firefoxdump/firefoxdump.cna: ok
|
||||
```
|
||||
|
||||
Load it persistently:
|
||||
|
||||
```
|
||||
sliver > opfor load ./firefoxdump/firefoxdump.cna
|
||||
|
||||
[*] Loaded CNA script /absolute/path/firefoxdump/firefoxdump.cna
|
||||
[*] Registered CNA aliases:
|
||||
opfor firefoxdump
|
||||
[*] View CNA alias help:
|
||||
opfor help firefoxdump
|
||||
```
|
||||
|
||||
Inspect the command metadata without running the BOF:
|
||||
|
||||
```
|
||||
sliver > opfor help firefoxdump
|
||||
|
||||
Command: opfor firefoxdump [arguments...]
|
||||
|
||||
Extract Firefox secrets (cookies, passwords) from Firefox profile.
|
||||
```
|
||||
|
||||
Select a compatible target and invoke the alias:
|
||||
|
||||
```
|
||||
sliver > use <session-or-beacon>
|
||||
sliver (TARGET) > opfor firefoxdump /all
|
||||
```
|
||||
|
||||
OPFOR sends the selected object file and packed arguments through Sliver's native BOF execution path. Session results are rendered immediately; beacon tasks are correlated and polled until the result or command deadline arrives. Typed BOF output channels and CNA callbacks are preserved when the script uses them.
|
||||
|
||||
Unload the script when it is no longer needed:
|
||||
|
||||
```
|
||||
sliver > opfor unload ./firefoxdump/firefoxdump.cna
|
||||
```
|
||||
|
||||
## Compatibility and troubleshooting
|
||||
|
||||
- If an alias is missing after `opfor run`, load the script instead. One-shot registrations are intentionally discarded.
|
||||
- If Sliver reports that no target is active, run `use <session-or-beacon>` before invoking the alias.
|
||||
- If the target does not advertise `bof_v1`, update the server, client, and implant to compatible builds and establish a new session or beacon.
|
||||
- If `script_resource()` cannot find an object file, preserve the upstream directory layout relative to the `.cna` file.
|
||||
- If a script calls an unsupported Aggressor function or prompt type, OPFOR returns an explicit error. The current integration prioritizes BOF execution rather than every Aggressor callback or client feature.
|
||||
- CNA aliases are namespaced beneath `opfor`, so they do not replace built-in Sliver commands. The management names `check`, `help`, `list`, `load`, `run`, and `unload` remain reserved.
|
||||
@@ -61,14 +61,14 @@ pip3 install sliver-py
|
||||
|
||||
Since our extension is essentially going to be another client connection to the sliver server, you’ll also need to enable multiplayer mode and generate a new profile.
|
||||
|
||||
Current Sliver releases default to a WireGuard-protected multiplayer listener. Because this tutorial uses a third-party Python client, we’ll force direct multiplayer mode so the profile only contains the plain gRPC/mTLS settings:
|
||||
Sliver defaults to direct gRPC/mTLS multiplayer, which is compatible with this third-party Python client. Start the listener and generate the operator profile without the optional WireGuard flag:
|
||||
|
||||
```bash
|
||||
[server] sliver > multiplayer --disable-wg
|
||||
[server] sliver > multiplayer
|
||||
|
||||
[*] Multiplayer mode enabled!
|
||||
|
||||
[server] sliver > new-operator -n tester -l 127.0.0.1 --permissions all --disable-wg
|
||||
[server] sliver > new-operator -n tester -l 127.0.0.1 --permissions all
|
||||
|
||||
[*] Generating new client certificate, please wait ...
|
||||
[*] Saved new client config to: /Users/tester/tools/tester_127.0.0.1.cfg
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"tutorials":[{"name":"1 - Getting Started","content":"`sliver-server` is the binary you want to use to run the Sliver C2 server, `sliver-client` is solely a client to connect to a Sliver C2 server. Sliver server also acts as a client on its own, so you don’t necessarily run sliver server and client separately.\n\nFirst time running Sliver will take a couple seconds as it's retrieving its dependencies. Consecutive executions will be much faster. Go ahead and launch the `sliver-server`.\n\n```asciinema\n{\"src\": \"/asciinema/startup.cast\", \"cols\": \"132\", \"rows\": \"28\", \"idleTimeLimit\": 8}\n```\n\nLet's take a couple minutes to discuss what Sliver actually is and how it's set up.\n\n\n\nNow that Sliver is running, lets generate and execute your first implant to try out some of the basic features of Sliver, for now we’re going to run everything on the local host.\n\nHere's what we're going to do: \n* Generate your implant using the `generate` command as shown below.\n* Start HTTP listener on port 80\n* Execute implant in a separate terminal\n\n```asciinema\n{\"src\": \"/asciinema/first-implant.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNow let’s select our implant and run our first command using the `use` command.\n\n```bash\n[server] sliver > use\n? Select a session or beacon: \nSESSION 1884a365 RELATED_EARDRUM [::1]:49153 test.local tester darwin/amd64\n[*] Active session RELATED_EARDRUM (1884a365-085f-4506-b28e-80c481730fd0)\n\n[server] sliver (RELATED_EARDRUM) > pwd\n\n[*] /Users/tester/tools\n```\n\nOnce you have reached this point, go ahead and explore some of the commands listed below. In each case, first check out the command's help using the **`-h`** flag then try it out!\n\n```bash\nExploring and interacting with the filesystem\n\nFilesystem\n cat Dump file to stdout\n cd Change directory\n cp Copy a file\n download Download a file\n grep Search for strings that match a regex within a file or directory\n head Grab the first number of bytes or lines from a file\n ls List current directory\n mkdir Make a directory\n mv Move or rename a file\n pwd Print working directory\n rm Remove a file or directory\n tail Grab the last number of bytes or lines from a file\n upload Upload a file\n```\n\n```asciinema\n{\"src\": \"/asciinema/filesystem.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nGetting some environmental information\n```bash\nInfo\n env List environment variables\n getgid Get session process GID\n getpid Get session pid\n getuid Get session process UID\n info Get session info\n ping Send round trip message to implant (does not use ICMP)\n whoami Get session user execution context\n```\nExecute a binary\n\n```asciinema\n{\"src\": \"/asciinema/execute.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nRunning an interactive shell\n\n```asciinema\n{\"src\": \"/asciinema/shell.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNot all commands are supported for all operating systems or meant to be used on all operating systems, here are a few examples:\n\n```\nmount Get information on mounted filesystems (only for Windows and Linux)\nscreenshot Take a screenshot (only for Windows and Linux)\nmemfiles List current memfiles (Linux only)\n```\n\nThis is an open-source project if you see a bug please file an issue! Pull requests are always welcome."},{"name":"2 - Beacons vs Sessions","content":"Sliver implants support two types of connections, sessions and beacons.\n\nSessions use long-polling connections, which means they constantly maintain a single TCP connection open while communicating with the server. Beacons on the other hand call back periodically and will sleep when not active which can help keep their presence hidden. You could limit callbacks to once every 6 hours for example and only 'activate' the beacon when needed by switching its sleeping time to a shorter duration.\n\nTypically during an engagement you will want to deploy a beacon on the target system and switch to a session while doing more active enumeration.\n\nLet’s start with generating and deploying a beacon using `http`.\n\n```asciinema\n{\"src\": \"/asciinema/beacon_generation.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nYou can see the beacon callback times either in the `info` command or using `beacons watch`.\n\n```bash\n[server] sliver > beacons watch\n\n ID Name Transport Username Operating System Last Check-In Next Check-In\n========== =============== =========== ================= ==========Line truncated
|
||||
{"tutorials":[{"name":"1 - Getting Started","content":"`sliver-server` is the binary you want to use to run the Sliver C2 server, `sliver-client` is solely a client to connect to a Sliver C2 server. Sliver server also acts as a client on its own, so you don’t necessarily run sliver server and client separately.\n\nFirst time running Sliver will take a couple seconds as it's retrieving its dependencies. Consecutive executions will be much faster. Go ahead and launch the `sliver-server`.\n\n```asciinema\n{\"src\": \"/asciinema/startup.cast\", \"cols\": \"132\", \"rows\": \"28\", \"idleTimeLimit\": 8}\n```\n\nLet's take a couple minutes to discuss what Sliver actually is and how it's set up.\n\n\n\nNow that Sliver is running, lets generate and execute your first implant to try out some of the basic features of Sliver, for now we’re going to run everything on the local host.\n\nHere's what we're going to do: \n* Generate your implant using the `generate` command as shown below.\n* Start HTTP listener on port 80\n* Execute implant in a separate terminal\n\n```asciinema\n{\"src\": \"/asciinema/first-implant.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNow let’s select our implant and run our first command using the `use` command.\n\n```bash\n[server] sliver > use\n? Select a session or beacon: \nSESSION 1884a365 RELATED_EARDRUM [::1]:49153 test.local tester darwin/amd64\n[*] Active session RELATED_EARDRUM (1884a365-085f-4506-b28e-80c481730fd0)\n\n[server] sliver (RELATED_EARDRUM) > pwd\n\n[*] /Users/tester/tools\n```\n\nOnce you have reached this point, go ahead and explore some of the commands listed below. In each case, first check out the command's help using the **`-h`** flag then try it out!\n\n```bash\nExploring and interacting with the filesystem\n\nFilesystem\n cat Dump file to stdout\n cd Change directory\n cp Copy a file\n download Download a file\n grep Search for strings that match a regex within a file or directory\n head Grab the first number of bytes or lines from a file\n ls List current directory\n mkdir Make a directory\n mv Move or rename a file\n pwd Print working directory\n rm Remove a file or directory\n tail Grab the last number of bytes or lines from a file\n upload Upload a file\n```\n\n```asciinema\n{\"src\": \"/asciinema/filesystem.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nGetting some environmental information\n```bash\nInfo\n env List environment variables\n getgid Get session process GID\n getpid Get session pid\n getuid Get session process UID\n info Get session info\n ping Send round trip message to implant (does not use ICMP)\n whoami Get session user execution context\n```\nExecute a binary\n\n```asciinema\n{\"src\": \"/asciinema/execute.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nRunning an interactive shell\n\n```asciinema\n{\"src\": \"/asciinema/shell.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNot all commands are supported for all operating systems or meant to be used on all operating systems, here are a few examples:\n\n```\nmount Get information on mounted filesystems (only for Windows and Linux)\nscreenshot Take a screenshot (only for Windows and Linux)\nmemfiles List current memfiles (Linux only)\n```\n\nThis is an open-source project if you see a bug please file an issue! Pull requests are always welcome."},{"name":"2 - Beacons vs Sessions","content":"Sliver implants support two types of connections, sessions and beacons.\n\nSessions use long-polling connections, which means they constantly maintain a single TCP connection open while communicating with the server. Beacons on the other hand call back periodically and will sleep when not active which can help keep their presence hidden. You could limit callbacks to once every 6 hours for example and only 'activate' the beacon when needed by switching its sleeping time to a shorter duration.\n\nTypically during an engagement you will want to deploy a beacon on the target system and switch to a session while doing more active enumeration.\n\nLet’s start with generating and deploying a beacon using `http`.\n\n```asciinema\n{\"src\": \"/asciinema/beacon_generation.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nYou can see the beacon callback times either in the `info` command or using `beacons watch`.\n\n```bash\n[server] sliver > beacons watch\n\n ID Name Transport Username Operating System Last Check-In Next Check-In\n========== =============== =========== ================= ==========Line truncated
|
||||
+31
-8
@@ -125,19 +125,29 @@ run_test_cmd() {
|
||||
should_skip_package() {
|
||||
local pkg="$1"
|
||||
local tags="${2:-}"
|
||||
local go_list_cmd=(go list -e -f '{{if .Error}}{{.Error}}{{end}}')
|
||||
if [ -n "$tags" ]; then
|
||||
go_list_cmd+=("-tags=$tags")
|
||||
fi
|
||||
go_list_cmd+=("$pkg")
|
||||
|
||||
local go_list_error
|
||||
go_list_error="$("${go_list_cmd[@]}" 2>/dev/null || true)"
|
||||
local test_file_counts
|
||||
if [ -n "$tags" ]; then
|
||||
go_list_error="$(go list -e -f '{{if .Error}}{{.Error}}{{end}}' "-tags=$tags" "$pkg" 2>/dev/null || true)"
|
||||
test_file_counts="$(go list -e -f '{{len .TestGoFiles}} {{len .XTestGoFiles}}' "-tags=$tags" "$pkg" 2>/dev/null || true)"
|
||||
else
|
||||
go_list_error="$(go list -e -f '{{if .Error}}{{.Error}}{{end}}' "$pkg" 2>/dev/null || true)"
|
||||
test_file_counts="$(go list -e -f '{{len .TestGoFiles}} {{len .XTestGoFiles}}' "$pkg" 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ "$go_list_error" == *"build constraints exclude all Go files"* ]]; then
|
||||
echo
|
||||
echo "==> Skipping $pkg (unsupported on current platform)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# File discovery intentionally sees tests for every OS. Avoid forcing a
|
||||
# package build when all of its tests are excluded by the current target's
|
||||
# build constraints; those tests remain discoverable on their target OS.
|
||||
if [ "$test_file_counts" = "0 0" ]; then
|
||||
echo
|
||||
echo "==> Skipping $pkg (no tests for current platform)"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -145,7 +155,7 @@ collect_test_dirs() {
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
rg --files -g '*_test.go'
|
||||
else
|
||||
find client implant server util -type f -name '*_test.go' -print
|
||||
find client implant server test util -type f -name '*_test.go' -print
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -169,6 +179,7 @@ done < <(collect_test_dirs | xargs -n1 dirname | sort -u)
|
||||
CLIENT_TEST_PKGS=()
|
||||
IMPLANT_TEST_PKGS=()
|
||||
SERVER_UTIL_TEST_PKGS=()
|
||||
E2E_SUPPORT_TEST_PKGS=()
|
||||
|
||||
for test_dir in "${TEST_DIRS[@]}"; do
|
||||
pkg="./$test_dir"
|
||||
@@ -185,6 +196,9 @@ for test_dir in "${TEST_DIRS[@]}"; do
|
||||
./server/* | ./util*)
|
||||
SERVER_UTIL_TEST_PKGS+=("$pkg")
|
||||
;;
|
||||
./test/e2e | ./test/e2e/*)
|
||||
E2E_SUPPORT_TEST_PKGS+=("$pkg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -196,6 +210,15 @@ for pkg in "${CLIENT_TEST_PKGS[@]}"; do
|
||||
run_test_cmd "$pkg" go test -tags="client,$TAGS" "$pkg" || exit 1
|
||||
done
|
||||
|
||||
## Comprehensive E2E support packages. The top-level lifecycle test skips
|
||||
## unless a server binary is supplied; coverage/report unit tests run here.
|
||||
for pkg in "${E2E_SUPPORT_TEST_PKGS[@]}"; do
|
||||
if should_skip_package "$pkg" "client,$TAGS"; then
|
||||
continue
|
||||
fi
|
||||
run_test_cmd "$pkg" go test -tags="client,$TAGS" "$pkg" || exit 1
|
||||
done
|
||||
|
||||
## Implant
|
||||
for pkg in "${IMPLANT_TEST_PKGS[@]}"; do
|
||||
if should_skip_package "$pkg"; then
|
||||
|
||||
@@ -12,11 +12,10 @@ require (
|
||||
charm.land/bubbles/v2 v2.0.0
|
||||
charm.land/glamour/v2 v2.0.1
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
filippo.io/age v1.3.1
|
||||
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb
|
||||
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a
|
||||
github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0
|
||||
github.com/Ne0nd0g/go-clr v1.0.3
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
@@ -24,6 +23,7 @@ require (
|
||||
github.com/cheggaaa/pb/v3 v3.1.7
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.1
|
||||
github.com/ebitengine/purego v0.10.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/gofrs/uuid v4.4.0+incompatible
|
||||
github.com/golang/protobuf v1.5.4
|
||||
@@ -31,7 +31,7 @@ require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
|
||||
github.com/hashicorp/yamux v0.1.2
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.3
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
|
||||
github.com/klauspost/compress v1.18.4
|
||||
github.com/lesnuages/go-winio v0.4.19
|
||||
@@ -39,7 +39,7 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/miekg/dns v1.1.70
|
||||
github.com/moloch--/asciicast v0.1.1
|
||||
github.com/moloch--/sgn v0.0.5
|
||||
github.com/moloch--/sgn v0.1.2
|
||||
github.com/ncruces/go-sqlite3 v0.30.4
|
||||
github.com/nikoksr/notify v1.5.0
|
||||
github.com/openai/openai-go/v2 v2.7.1
|
||||
@@ -49,7 +49,8 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/sliverarmory/beignet v0.0.3
|
||||
github.com/sliverarmory/malasada v0.0.3
|
||||
github.com/sliverarmory/reflektor v0.0.2
|
||||
github.com/sliverarmory/opfor v0.0.2
|
||||
github.com/sliverarmory/reflektor v0.0.8
|
||||
github.com/sliverarmory/wasm-donut v0.0.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
@@ -63,7 +64,7 @@ require (
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.40.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
@@ -126,7 +127,7 @@ require (
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
@@ -232,8 +233,6 @@ require (
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0 // indirect
|
||||
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.65.1 // indirect
|
||||
@@ -241,7 +240,7 @@ require (
|
||||
github.com/carapace-sh/carapace-shlex v1.1.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/ansi v0.11.8
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
@@ -281,12 +280,12 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||
github.com/mark3labs/mcp-go v0.45.0
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
|
||||
@@ -12,8 +12,8 @@ charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
|
||||
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
@@ -49,14 +49,11 @@ firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw
|
||||
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb h1:c1Bb1n1wr2xgTHkNVxkUHBshff33m+IXgY6lE6oYttg=
|
||||
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb/go.mod h1:fSQ9asCJK/lIGXUnoRwJNMXcnzel53AsZMCtlVBIn3k=
|
||||
github.com/Binject/debug v0.0.0-20210225042342-c9b8b45728d2/go.mod h1:QzgxDLY/qdKlvnbnb65eqTedhvQPbaSP2NqIbcuKvsQ=
|
||||
github.com/Binject/debug v0.0.0-20210312092933-6277045c2fdf/go.mod h1:QzgxDLY/qdKlvnbnb65eqTedhvQPbaSP2NqIbcuKvsQ=
|
||||
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a h1:4c0nc0krv8eh7gD809n+swLaCuFyHpxdrxwx0ZmHvBw=
|
||||
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a/go.mod h1:QzgxDLY/qdKlvnbnb65eqTedhvQPbaSP2NqIbcuKvsQ=
|
||||
github.com/Binject/shellcode v0.0.0-20191101084904-a8a90e7d4563 h1:T8z8Wz/fqaPPANF8Unv4LjWLoa0TAvsjYAsRpvSkOqs=
|
||||
github.com/Binject/shellcode v0.0.0-20191101084904-a8a90e7d4563/go.mod h1:HMbzsKPz1sF7H4Hmeovh+d2PH2iKPYpAB4XMOVz8wmM=
|
||||
github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0 h1:1sdnMvqxxlMXsu8ku/Xrh6gi3V40ykksst5fd0lfWnc=
|
||||
github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0/go.mod h1:J3XDRlam5pPYca3i6EqgQ35GCCEoyxafpCbLkta0ozc=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
@@ -114,10 +111,6 @@ github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZ
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0 h1:JjwxKkxzcBk4k8147g0eBQRCIy0UN1Be8AAv6RaIj4Q=
|
||||
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0/go.mod h1:IbERvuyb387Hppp8hX0SQTFt/mkej8+OhuS8L0nC2CI=
|
||||
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 h1:cIAK2NNf2yafdgpFRNJrgZMwvy61BEVpGoHc2n4/yWs=
|
||||
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4/go.mod h1:SalMPBCab3yuID8nIhLfzwoBV+lBRyaC7NhuN8qL8xE=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
@@ -186,10 +179,10 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
@@ -274,6 +267,8 @@ github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7 h1:uh1GSejOhVPR
|
||||
github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7/go.mod h1:ncTaGuXc5v7AuiVekeJ0Nwh8Bf4cudukoj0qM/15UZE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -462,8 +457,8 @@ github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFr
|
||||
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o=
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ=
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
|
||||
@@ -521,8 +516,8 @@ github.com/lesnuages/snitch v0.6.0 h1:vOao32MdYDHYDcTAq2CKszDh6Dn8Bv1Gmgc8uVh193
|
||||
github.com/lesnuages/snitch v0.6.0/go.mod h1:RfXlAVJYL7gUoZ9VMMon4LqrPiAjC8x62Ntbse4a4s0=
|
||||
github.com/line/line-bot-sdk-go v7.8.0+incompatible h1:Uf9/OxV0zCVfqyvwZPH8CrdiHXXmMRa/L91G3btQblQ=
|
||||
github.com/line/line-bot-sdk-go v7.8.0+incompatible/go.mod h1:0RjLjJEAU/3GIcHkC3av6O4jInAbt25nnZVmOFUgDBg=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
|
||||
@@ -543,8 +538,8 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
@@ -578,8 +573,8 @@ github.com/moloch--/go-keystone v0.0.2 h1:xThbYs45CSzoDUz5kBZxAyTPIpK3t5M7NB1x+N
|
||||
github.com/moloch--/go-keystone v0.0.2/go.mod h1:kpGRJpw9b7Eq3MluHrba+GPKZhSyDrvhLULSwH5nGGM=
|
||||
github.com/moloch--/readline v0.0.0-20260129035512-cb5b3e87d51a h1:Q69DxLWF+Z8oIjuNbeKC6Xygk8Z4heWzzO79Io1cEYA=
|
||||
github.com/moloch--/readline v0.0.0-20260129035512-cb5b3e87d51a/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY=
|
||||
github.com/moloch--/sgn v0.0.5 h1:92GFaRpe+353uhqzFiNKXKgmTiD4dBxnvb8gd4MaV/g=
|
||||
github.com/moloch--/sgn v0.0.5/go.mod h1:FyWfCtdynywgLmVR71vTJztZ3WkGSkR91Y4EOijUutE=
|
||||
github.com/moloch--/sgn v0.1.2 h1:tWmhDNpiZfWBg5+3uPnfJDRBmB219TnVto/RESOqoFo=
|
||||
github.com/moloch--/sgn v0.1.2/go.mod h1:qrMzA8Tf9oiGZvWLaZvfKUetiUM61srSZdmq3gINies=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -678,8 +673,12 @@ github.com/sliverarmory/beignet v0.0.3 h1:ssR6AK9lirVulHZhQMyMmVAi0FcFwWxlh9LwKC
|
||||
github.com/sliverarmory/beignet v0.0.3/go.mod h1:xXfTKBJqFBY/pakRFwJ+5+oGiSlg2CiCVUg/76kW1H4=
|
||||
github.com/sliverarmory/malasada v0.0.3 h1:b2KG2NhQVfBIIrbP8rGJwmdh7o8LH6NKYKH1DF4No1U=
|
||||
github.com/sliverarmory/malasada v0.0.3/go.mod h1:F96IgTOoX72FU995F+Jip0ni8Z5sXiYOwfmlRGobJkQ=
|
||||
github.com/sliverarmory/reflektor v0.0.2 h1:nrEEFXrYiYFLCqyMpOJpe4RsY0Pu3tc1CvwChsNoAq4=
|
||||
github.com/sliverarmory/reflektor v0.0.2/go.mod h1:Rw7qTWQQlCM3YTxxeuklFt3Q+kh1Ytlmzg5UVw1Nhkg=
|
||||
github.com/sliverarmory/opfor v0.0.1 h1:V4RMIO3zG3nCdWGm0R8IkPtlqQVoZyYhQ8zNiQdq0AI=
|
||||
github.com/sliverarmory/opfor v0.0.1/go.mod h1:xrLTsijVqxIVtYxPsDVuXeFgPlHXVkcfXMuajseD9ng=
|
||||
github.com/sliverarmory/opfor v0.0.2 h1:rqckVkt2zJsJXSZGC6BnY3T/73CfX/Kv8kbBUF5Cb50=
|
||||
github.com/sliverarmory/opfor v0.0.2/go.mod h1:xrLTsijVqxIVtYxPsDVuXeFgPlHXVkcfXMuajseD9ng=
|
||||
github.com/sliverarmory/reflektor v0.0.8 h1:0FttcH5GZcKq571oRns3gKTyJNU2T+c3XP/sbFIAYRY=
|
||||
github.com/sliverarmory/reflektor v0.0.8/go.mod h1:+lCyQy0xHVqx6D5nYbcz5XIDVtAGbDMIMU0Yc6Brb1s=
|
||||
github.com/sliverarmory/wasm-donut v0.0.3 h1:sldbfAsz1YziULlhDKpU3DY/+/dAfv5gl26qSqHIpzE=
|
||||
github.com/sliverarmory/wasm-donut v0.0.3/go.mod h1:D6OLBZBfFm2Tk857vqGqFS9uUi4CXqnqHxKqtONllVk=
|
||||
github.com/sony/sonyflake v1.0.0 h1:MpU6Ro7tfXwgn2l5eluf9xQvQJDROTBImNCfRXn/YeM=
|
||||
@@ -927,7 +926,6 @@ golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210217105451-b926d437f341/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -945,8 +943,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
||||
+2
-5
@@ -4,14 +4,14 @@ go 1.26.6
|
||||
|
||||
require (
|
||||
filippo.io/age v1.1.1
|
||||
github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0
|
||||
github.com/Ne0nd0g/go-clr v1.0.3
|
||||
github.com/ebitengine/purego v0.10.1
|
||||
github.com/gofrs/uuid v4.0.0+incompatible
|
||||
github.com/hashicorp/yamux v0.1.2
|
||||
github.com/kbinani/screenshot v0.0.0-20191211154542-3a185f1ce18f
|
||||
github.com/lesnuages/go-winio v0.4.19
|
||||
github.com/miekg/dns v1.1.50
|
||||
github.com/sliverarmory/reflektor v0.0.2
|
||||
github.com/sliverarmory/reflektor v0.0.8
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/tetratelabs/wazero v1.7.1
|
||||
github.com/things-go/go-socks5 v0.0.4
|
||||
@@ -25,11 +25,8 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Binject/debug v0.0.0-20210225042342-c9b8b45728d2 // indirect
|
||||
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc // indirect
|
||||
github.com/Microsoft/go-winio v0.6.0 // indirect
|
||||
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0 // indirect
|
||||
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
|
||||
+4
-12
@@ -1,22 +1,16 @@
|
||||
filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg=
|
||||
filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE=
|
||||
github.com/Binject/debug v0.0.0-20210225042342-c9b8b45728d2 h1:8kQNJC9AxAaNs0JkXnWUfbNeDnIO1QLYYWjYqC6JEE4=
|
||||
github.com/Binject/debug v0.0.0-20210225042342-c9b8b45728d2/go.mod h1:QzgxDLY/qdKlvnbnb65eqTedhvQPbaSP2NqIbcuKvsQ=
|
||||
github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0 h1:1sdnMvqxxlMXsu8ku/Xrh6gi3V40ykksst5fd0lfWnc=
|
||||
github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0/go.mod h1:J3XDRlam5pPYca3i6EqgQ35GCCEoyxafpCbLkta0ozc=
|
||||
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk=
|
||||
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
|
||||
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
|
||||
github.com/Ne0nd0g/go-clr v1.0.3 h1:xt92wwuqY23ZSC7RuHD3mKu3K22Bk5NNbxI803vojK4=
|
||||
github.com/Ne0nd0g/go-clr v1.0.3/go.mod h1:TKYSQ/5xT25EvBUttAlUrzpR8yHuI0qTRK495I5xG/I=
|
||||
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0 h1:JjwxKkxzcBk4k8147g0eBQRCIy0UN1Be8AAv6RaIj4Q=
|
||||
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0/go.mod h1:IbERvuyb387Hppp8hX0SQTFt/mkej8+OhuS8L0nC2CI=
|
||||
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 h1:cIAK2NNf2yafdgpFRNJrgZMwvy61BEVpGoHc2n4/yWs=
|
||||
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4/go.mod h1:SalMPBCab3yuID8nIhLfzwoBV+lBRyaC7NhuN8qL8xE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5 h1:Y5Q2mEwfzjMt5+3u70Gtw93ZOu2UuPeeeTBDntF7FoY=
|
||||
github.com/gen2brain/shm v0.0.0-20200228170931-49f9650110c5/go.mod h1:uF6rMu/1nvu+5DpiRLwusA6xB8zlkNoGzKn8lmYONUo=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
|
||||
@@ -43,12 +37,11 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sliverarmory/reflektor v0.0.2 h1:nrEEFXrYiYFLCqyMpOJpe4RsY0Pu3tc1CvwChsNoAq4=
|
||||
github.com/sliverarmory/reflektor v0.0.2/go.mod h1:Rw7qTWQQlCM3YTxxeuklFt3Q+kh1Ytlmzg5UVw1Nhkg=
|
||||
github.com/sliverarmory/reflektor v0.0.8 h1:0FttcH5GZcKq571oRns3gKTyJNU2T+c3XP/sbFIAYRY=
|
||||
github.com/sliverarmory/reflektor v0.0.8/go.mod h1:+lCyQy0xHVqx6D5nYbcz5XIDVtAGbDMIMU0Yc6Brb1s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tetratelabs/wazero v1.7.1 h1:QtSfd6KLc41DIMpDYlJdoMc6k7QTN246DM2+n2Y/Dx8=
|
||||
@@ -82,7 +75,6 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210217105451-b926d437f341/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Extension host runtime allowing implants to load optional capabilities. Manages sandboxing, lifecycle, and communication with extension modules. Runtime components handle extension darwin, extension windows, memfs, and WASM for implant-side extension features.
|
||||
Extension host runtime allowing implants to load optional capabilities. Manages native shared-library and WASM extension lifecycle, registration, and communication across supported implant platforms.
|
||||
|
||||
## Go Files
|
||||
|
||||
- `extension.go` – Core extension host that loads modules and brokers RPC communication.
|
||||
- `extension_darwin.go` – macOS-specific stubs and build tags for extension support.
|
||||
- `extension_windows.go` – Windows-specific integration for loading and managing extensions.
|
||||
- `extension.go` – Thread-safe native extension registry and single-flight loading lifecycle.
|
||||
- `extension_darwin.go` – macOS constructor for the shared Reflektor-backed Unix loader.
|
||||
- `extension_linux.go` – Linux constructor for the shared Reflektor-backed Unix loader.
|
||||
- `extension_unix.go` – CGO-free macOS/Linux loading, export invocation, and callback bridge.
|
||||
- `extension_windows.go` – Windows-specific Reflektor integration for loading and invoking extensions.
|
||||
- `memfs.go` – Implements the in-memory filesystem backing extension assets.
|
||||
- `memfs_test.go` *(tests)* – Tests the in-memory filesystem behavior for extensions.
|
||||
- `wasm.go` – Sets up the WASM runtime for implant extensions and handles module lifecycle.
|
||||
|
||||
@@ -20,13 +20,31 @@ package extension
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
// {{if .Config.Debug}}
|
||||
"log"
|
||||
// {{end}}
|
||||
)
|
||||
|
||||
var extensions map[string]Extension
|
||||
const (
|
||||
// Success indicates that a native extension callback completed successfully.
|
||||
Success = 0
|
||||
// Failure indicates that a native extension callback failed.
|
||||
Failure = 1
|
||||
)
|
||||
|
||||
var (
|
||||
extensions map[string]Extension
|
||||
extensionLoads map[string]*extensionLoad
|
||||
extensionsMu sync.RWMutex
|
||||
)
|
||||
|
||||
type extensionLoad struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
waiters int
|
||||
}
|
||||
|
||||
type Extension interface {
|
||||
Load() error
|
||||
@@ -36,10 +54,47 @@ type Extension interface {
|
||||
}
|
||||
|
||||
func Add(e Extension) {
|
||||
extensionsMu.Lock()
|
||||
defer extensionsMu.Unlock()
|
||||
extensions[e.GetID()] = e
|
||||
}
|
||||
|
||||
// Register loads an extension once per ID and publishes it only after loading
|
||||
// succeeds. Concurrent registrations for the same ID wait for the in-flight
|
||||
// load instead of mapping duplicate libraries and allocating duplicate callback
|
||||
// trampolines.
|
||||
func Register(e Extension) error {
|
||||
id := e.GetID()
|
||||
extensionsMu.Lock()
|
||||
if _, found := extensions[id]; found {
|
||||
extensionsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if loading, found := extensionLoads[id]; found {
|
||||
loading.waiters++
|
||||
extensionsMu.Unlock()
|
||||
<-loading.done
|
||||
return loading.err
|
||||
}
|
||||
loading := &extensionLoad{done: make(chan struct{})}
|
||||
extensionLoads[id] = loading
|
||||
extensionsMu.Unlock()
|
||||
|
||||
err := e.Load()
|
||||
extensionsMu.Lock()
|
||||
if err == nil {
|
||||
extensions[id] = e
|
||||
}
|
||||
loading.err = err
|
||||
close(loading.done)
|
||||
delete(extensionLoads, id)
|
||||
extensionsMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func List() []string {
|
||||
extensionsMu.RLock()
|
||||
defer extensionsMu.RUnlock()
|
||||
var extList []string
|
||||
for id := range extensions {
|
||||
extList = append(extList, id)
|
||||
@@ -48,10 +103,15 @@ func List() []string {
|
||||
}
|
||||
|
||||
func Run(extID string, funcName string, arguments []byte, callback func([]byte)) error {
|
||||
if ext, found := extensions[extID]; found {
|
||||
extensionsMu.RLock()
|
||||
ext, found := extensions[extID]
|
||||
extensionsMu.RUnlock()
|
||||
if found {
|
||||
return ext.Call(funcName, arguments, callback)
|
||||
}
|
||||
// {{if .Config.Debug}}
|
||||
extensionsMu.RLock()
|
||||
defer extensionsMu.RUnlock()
|
||||
for id, ext := range extensions {
|
||||
log.Printf("Extension '%s' (%s)", id, ext.GetArch())
|
||||
}
|
||||
@@ -61,4 +121,5 @@ func Run(extID string, funcName string, arguments []byte, callback func([]byte))
|
||||
|
||||
func init() {
|
||||
extensions = make(map[string]Extension)
|
||||
extensionLoads = make(map[string]*extensionLoad)
|
||||
}
|
||||
@@ -18,113 +18,10 @@ package extension
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
// {{if .Config.Debug}}
|
||||
"log"
|
||||
// {{end}}
|
||||
|
||||
"github.com/Binject/universal"
|
||||
)
|
||||
|
||||
type DarwinExtension struct {
|
||||
id string
|
||||
data []byte
|
||||
module *universal.Library
|
||||
arch string
|
||||
serverStore bool
|
||||
init string
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
type extensionArguments struct {
|
||||
inDataSize uintptr
|
||||
inDataBuff uintptr
|
||||
outDataSize uintptr
|
||||
outDataBuff uintptr
|
||||
}
|
||||
// DarwinExtension is a Reflektor-backed native macOS extension.
|
||||
type DarwinExtension = UnixExtension
|
||||
|
||||
// NewDarwinExtension creates a native macOS extension backed by Reflektor.
|
||||
func NewDarwinExtension(data []byte, id string, arch string, init string) *DarwinExtension {
|
||||
return &DarwinExtension{
|
||||
id: id,
|
||||
data: data,
|
||||
arch: arch,
|
||||
init: init,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DarwinExtension) GetID() string {
|
||||
return d.id
|
||||
}
|
||||
|
||||
func (d *DarwinExtension) GetArch() string {
|
||||
return d.arch
|
||||
}
|
||||
|
||||
func (d *DarwinExtension) Load() error {
|
||||
var err error
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
loader, err := universal.NewLoader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.module, err = loader.LoadLibrary(d.id, &d.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.init != "" {
|
||||
if _, errInit := d.module.Call(d.init); errInit != nil {
|
||||
return errInit
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DarwinExtension) Call(export string, arguments []byte, onFinish func([]byte)) error {
|
||||
// We currently have 2 issues with Darwin extensions:
|
||||
// - cppgo (used by universal) fucks up when calling a function with more than 1 argument
|
||||
// - we don't have Go callback support for the loaded extension,
|
||||
// so we have to wait for the call to finish to get the results
|
||||
// To circumvent these issues, we pass the extensionArguments structure
|
||||
// as the only argument to the call, so we can pass args in and extract
|
||||
// the result at the same time.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
extArgs := extensionArguments{}
|
||||
if len(arguments) > 0 {
|
||||
extArgs.inDataBuff = uintptr(unsafe.Pointer(&arguments[0]))
|
||||
extArgs.inDataSize = uintptr(uint64(len(arguments)))
|
||||
}
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("Calling %s, arg size: %d\n", export, extArgs.inDataSize)
|
||||
// {{end}}
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
_, err := d.module.Call(export, uintptr(unsafe.Pointer(&extArgs)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("%s done!\n", export)
|
||||
// {{end}}
|
||||
outData := new(bytes.Buffer)
|
||||
outDataSize := int(extArgs.outDataSize)
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("Out data size: %d\n", outDataSize)
|
||||
// {{end}}
|
||||
for i := 0; i < outDataSize; i++ {
|
||||
b := (*byte)(unsafe.Pointer(uintptr(i) + extArgs.outDataBuff))
|
||||
outData.WriteByte(*b)
|
||||
}
|
||||
// We currently don't have a way to trigger a callback
|
||||
// in the loaded code for Darwin.
|
||||
if outData.Len() > 0 {
|
||||
onFinish(outData.Bytes())
|
||||
}
|
||||
return nil
|
||||
return newUnixExtension(data, id, arch, init)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build 386 || amd64 || arm64
|
||||
|
||||
package extension
|
||||
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
// LinuxExtension is a Reflektor-backed native Linux extension.
|
||||
type LinuxExtension = UnixExtension
|
||||
|
||||
// NewLinuxExtension creates a native Linux extension backed by Reflektor.
|
||||
func NewLinuxExtension(data []byte, id string, arch string, init string) *LinuxExtension {
|
||||
return newUnixExtension(data, id, arch, init)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build !(386 || amd64 || arm64)
|
||||
|
||||
package extension
|
||||
|
||||
/*
|
||||
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 "errors"
|
||||
|
||||
// LinuxExtension reports unsupported native-extension operations on Linux
|
||||
// architectures without a Reflektor backend.
|
||||
type LinuxExtension struct {
|
||||
id string
|
||||
arch string
|
||||
}
|
||||
|
||||
// NewLinuxExtension creates an unsupported-architecture extension placeholder.
|
||||
func NewLinuxExtension(_ []byte, id string, arch string, _ string) *LinuxExtension {
|
||||
return &LinuxExtension{id: id, arch: arch}
|
||||
}
|
||||
|
||||
// GetID returns the extension identifier.
|
||||
func (l *LinuxExtension) GetID() string {
|
||||
return l.id
|
||||
}
|
||||
|
||||
// GetArch returns the extension architecture.
|
||||
func (l *LinuxExtension) GetArch() string {
|
||||
return l.arch
|
||||
}
|
||||
|
||||
// Load reports that native extensions are unsupported on this architecture.
|
||||
func (l *LinuxExtension) Load() error {
|
||||
return errors.New("{{if .Config.Debug}} native extensions are not supported on this Linux architecture {{end}}")
|
||||
}
|
||||
|
||||
// Call reports that native extensions are unsupported on this architecture.
|
||||
func (l *LinuxExtension) Call(_ string, _ []byte, _ func([]byte)) error {
|
||||
return errors.New("{{if .Config.Debug}} native extensions are not supported on this Linux architecture {{end}}")
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testExtension struct {
|
||||
id string
|
||||
}
|
||||
|
||||
func (e *testExtension) Load() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *testExtension) Call(_ string, arguments []byte, callback func([]byte)) error {
|
||||
callback(arguments)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *testExtension) GetID() string {
|
||||
return e.id
|
||||
}
|
||||
|
||||
func (e *testExtension) GetArch() string {
|
||||
return "test"
|
||||
}
|
||||
|
||||
type registeringTestExtension struct {
|
||||
testExtension
|
||||
loadCalls *atomic.Int32
|
||||
loadErr error
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (e *registeringTestExtension) Load() error {
|
||||
e.loadCalls.Add(1)
|
||||
if e.started != nil {
|
||||
close(e.started)
|
||||
}
|
||||
if e.release != nil {
|
||||
<-e.release
|
||||
}
|
||||
return e.loadErr
|
||||
}
|
||||
|
||||
func resetExtensionRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
extensionsMu.Lock()
|
||||
previousExtensions := extensions
|
||||
previousLoads := extensionLoads
|
||||
extensions = make(map[string]Extension)
|
||||
extensionLoads = make(map[string]*extensionLoad)
|
||||
extensionsMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
extensionsMu.Lock()
|
||||
extensions = previousExtensions
|
||||
extensionLoads = previousLoads
|
||||
extensionsMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func waitForExtensionWaiters(t *testing.T, extensionID string, want int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
extensionsMu.RLock()
|
||||
loading := extensionLoads[extensionID]
|
||||
got := 0
|
||||
if loading != nil {
|
||||
got = loading.waiters
|
||||
}
|
||||
extensionsMu.RUnlock()
|
||||
if got == want {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("registration waiters = %d, want %d", got, want)
|
||||
}
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRegistryConcurrentAccess(t *testing.T) {
|
||||
resetExtensionRegistry(t)
|
||||
|
||||
const count = 64
|
||||
errCh := make(chan error, count)
|
||||
var waitGroup sync.WaitGroup
|
||||
for index := 0; index < count; index++ {
|
||||
index := index
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
id := fmt.Sprintf("extension-%d", index)
|
||||
Add(&testExtension{id: id})
|
||||
argument := []byte(id)
|
||||
if err := Run(id, "run", argument, func(output []byte) {
|
||||
if string(output) != id {
|
||||
errCh <- fmt.Errorf("callback output = %q, want %q", output, id)
|
||||
}
|
||||
}); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
_ = List()
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if got := len(List()); got != count {
|
||||
t.Fatalf("List returned %d extensions, want %d", got, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLoadsExtensionOnlyOnce(t *testing.T) {
|
||||
resetExtensionRegistry(t)
|
||||
|
||||
const extensionID = "duplicate-extension"
|
||||
var loadCalls atomic.Int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
first := ®isteringTestExtension{
|
||||
testExtension: testExtension{id: extensionID},
|
||||
loadCalls: &loadCalls,
|
||||
started: started,
|
||||
release: release,
|
||||
}
|
||||
|
||||
const registrations = 16
|
||||
errorsCh := make(chan error, registrations)
|
||||
go func() {
|
||||
errorsCh <- Register(first)
|
||||
}()
|
||||
<-started
|
||||
for index := 1; index < registrations; index++ {
|
||||
go func() {
|
||||
errorsCh <- Register(®isteringTestExtension{
|
||||
testExtension: testExtension{id: extensionID},
|
||||
loadCalls: &loadCalls,
|
||||
})
|
||||
}()
|
||||
}
|
||||
waitForExtensionWaiters(t, extensionID, registrations-1)
|
||||
select {
|
||||
case err := <-errorsCh:
|
||||
t.Fatalf("Register returned before the in-flight load completed: %v", err)
|
||||
default:
|
||||
}
|
||||
close(release)
|
||||
for index := 0; index < registrations; index++ {
|
||||
if err := <-errorsCh; err != nil {
|
||||
t.Fatalf("Register returned an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := loadCalls.Load(); got != 1 {
|
||||
t.Fatalf("Load called %d times, want 1", got)
|
||||
}
|
||||
if got := List(); len(got) != 1 || got[0] != extensionID {
|
||||
t.Fatalf("List returned %q, want [%q]", got, extensionID)
|
||||
}
|
||||
|
||||
if err := Register(®isteringTestExtension{
|
||||
testExtension: testExtension{id: extensionID},
|
||||
loadCalls: &loadCalls,
|
||||
}); err != nil {
|
||||
t.Fatalf("repeat Register returned an error: %v", err)
|
||||
}
|
||||
if got := loadCalls.Load(); got != 1 {
|
||||
t.Fatalf("repeat Register called Load; total calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRetriesAfterFailedLoad(t *testing.T) {
|
||||
resetExtensionRegistry(t)
|
||||
|
||||
const extensionID = "retry-extension"
|
||||
var loadCalls atomic.Int32
|
||||
wantErr := errors.New("load failed")
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
const registrations = 8
|
||||
errorsCh := make(chan error, registrations)
|
||||
go func() {
|
||||
errorsCh <- Register(®isteringTestExtension{
|
||||
testExtension: testExtension{id: extensionID},
|
||||
loadCalls: &loadCalls,
|
||||
loadErr: wantErr,
|
||||
started: started,
|
||||
release: release,
|
||||
})
|
||||
}()
|
||||
<-started
|
||||
for index := 1; index < registrations; index++ {
|
||||
go func() {
|
||||
errorsCh <- Register(®isteringTestExtension{
|
||||
testExtension: testExtension{id: extensionID},
|
||||
loadCalls: &loadCalls,
|
||||
loadErr: errors.New("unexpected follower load"),
|
||||
})
|
||||
}()
|
||||
}
|
||||
waitForExtensionWaiters(t, extensionID, registrations-1)
|
||||
close(release)
|
||||
for index := 0; index < registrations; index++ {
|
||||
if err := <-errorsCh; !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Register error = %v, want shared error %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
if got := loadCalls.Load(); got != 1 {
|
||||
t.Fatalf("failed Load called %d times, want 1", got)
|
||||
}
|
||||
if got := List(); len(got) != 0 {
|
||||
t.Fatalf("failed extension was published: %q", got)
|
||||
}
|
||||
|
||||
if err := Register(®isteringTestExtension{
|
||||
testExtension: testExtension{id: extensionID},
|
||||
loadCalls: &loadCalls,
|
||||
}); err != nil {
|
||||
t.Fatalf("retry Register returned an error: %v", err)
|
||||
}
|
||||
if got := loadCalls.Load(); got != 2 {
|
||||
t.Fatalf("Load called %d times, want 2", got)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user