Compare commits

...
Author SHA1 Message Date
hack-ita 6ccfd6a257 docs: add HackITA Sliver C2 community guide (#2434) 2026-11-08 09:31:01 -08:00
Joe 4c2e8d4bda Simplify unit tests and strengthen deterministic coverage (#2433) 2026-11-06 15:32:31 -08:00
Joe 9f8f1d11be Fix DNS canary embedding and detection (#2432)
* Preserve DNS canaries in obfuscated implants

* Refactor canary regression assertions for lint

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Uses golang.org/x/term (already vendored) for the TTY check.
No vendor, server, or command-tree changes.
2026-09-20 09:49:02 +00:00
Joe f8430cecf7 Merge pull request #2378 from BishopFox/feat/rportfwd-first-frame-master
fix: preserve reverse port forward first frames
2026-09-19 21:27:44 -07:00
moloch-- 276a30fbaa test: normalize Windows corpus line endings 2026-09-19 19:54:18 -07:00
moloch-- 3aa8a9f93c fix: satisfy reverse precreate lint checks 2026-09-19 19:35:41 -07:00
moloch-- fc3fb7386a fix: preserve reverse port forward first frames
(cherry picked from commit a89c03bb87)
2026-09-19 19:26:43 -07:00
moloch-- 6edea618e9 Remove .gitattributes 2026-09-19 18:33:37 -07:00
518 changed files with 35051 additions and 5565 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
# Preserve pinned upstream source and attribution files byte-for-byte.
client/command/opfor/testdata/corpus/** -text
client/command/opfor/testdata/corpus/operatorskit/*.cna whitespace=-trailing-space,-blank-at-eof
vendor/github.com/sliverarmory/opfor/internal/regexp2/ATTRIB whitespace=-trailing-space
+5 -5
View File
@@ -17,7 +17,7 @@ jobs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -56,14 +56,14 @@ jobs:
timeout-minutes: 45
steps:
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Go 1.27.1
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.1"
go-version: "1.27.3"
- name: Install Minisign
run: sudo apt-get update --fix-missing && sudo apt-get -y install minisign
+5 -5
View File
@@ -16,13 +16,13 @@ jobs:
steps:
# Setup go environment
- name: Go 1.27.1
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.1"
go-version: "1.27.3"
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
# Must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head of the pull request.
@@ -36,7 +36,7 @@ jobs:
git build-essential zlib1g zlib1g-dev wget zip unzip
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
+12 -12
View File
@@ -73,12 +73,12 @@ jobs:
timeout-minutes: 30
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -118,7 +118,7 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -128,7 +128,7 @@ jobs:
printf 'darwin/%s\n' '${{ matrix.goarch }}' > e2e-results/target-darwin-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -210,7 +210,7 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -220,7 +220,7 @@ jobs:
printf 'linux/%s\n' '${{ matrix.goarch }}' > e2e-results/target-linux-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -302,7 +302,7 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -312,7 +312,7 @@ jobs:
printf 'windows/%s\n' '${{ matrix.goarch }}' > e2e-results/target-windows-${{ matrix.goarch }}.txt
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -390,12 +390,12 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -465,12 +465,12 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
+6 -6
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Detect package manager
id: detect-package-manager
@@ -56,17 +56,17 @@ jobs:
fi
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
node-version: "lts/*"
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Restore cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
.next/cache
@@ -85,7 +85,7 @@ jobs:
run: ${{ steps.detect-package-manager.outputs.runner }} next build
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: ${{ github.workspace }}/docs/sliver-docs/out
@@ -99,4 +99,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+5 -5
View File
@@ -39,18 +39,18 @@ jobs:
CGO_ENABLED: "0"
steps:
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Go 1.27.1
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.1"
go-version: "1.27.3"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.13.1
version: v2.14.0
# Gate only findings introduced since the event base revision.
args: --new-from-rev=${{ github.event.pull_request.base.sha || github.event.before }} ./client/... ./server/... ./util/... ./protobuf/... ./docs ./implant/...
+6 -6
View File
@@ -26,10 +26,10 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -78,10 +78,10 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -130,10 +130,10 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
+4 -4
View File
@@ -52,7 +52,7 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
@@ -65,7 +65,7 @@ jobs:
> 'shellcode-results/target-${{ matrix.target_os }}-${{ matrix.target_arch }}.txt'
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
@@ -131,12 +131,12 @@ jobs:
shell: bash
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
+16 -16
View File
@@ -27,10 +27,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Go 1.27.1
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.1"
go-version: "1.27.3"
- name: OS Packages
run: |
@@ -38,7 +38,7 @@ jobs:
git build-essential zlib1g zlib1g-dev wget zip unzip
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
@@ -73,16 +73,16 @@ jobs:
runs-on: windows-latest
timeout-minutes: 180
steps:
- name: Go 1.27.1
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.1"
go-version: "1.27.3"
- name: Install make
run: choco install make -y --no-progress
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
@@ -117,13 +117,13 @@ jobs:
runs-on: macos-latest
timeout-minutes: 180
steps:
- name: Go 1.27.1
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.1"
go-version: "1.27.3"
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
@@ -155,13 +155,13 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Go 1.27.1
uses: actions/setup-go@v6
- name: Go 1.27.3
uses: actions/setup-go@v7
with:
go-version: "1.27.1"
go-version: "1.27.3"
- name: Check Out Code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Git Fetch Tags
run: git fetch --prune --unshallow --tags -f
+1
View File
@@ -9,6 +9,7 @@ run:
- osusergo
- netgo
- go_sqlite
- sliver_generate
# Raw implant entry points are Go templates; lint their supporting packages.
- sliver_lint
+2 -2
View File
@@ -199,7 +199,7 @@ clean-all: clean
clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
.downloaded_assets: util/assets/constants.go util/assets/garble.go
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
@@ -324,7 +324,7 @@ clean-all: clean
clean:
-del /Q /F sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe 2>NUL
.downloaded_assets:
.downloaded_assets: util/assets/constants.go util/assets/garble.go
$(call windows_exec,$(ENV),"$(GO)" run -mod=vendor ./util/cmd/assets)
@type NUL > .downloaded_assets
+6 -19
View File
@@ -1,30 +1,17 @@
# Sliver
Sliver is an open source cross-platform adversary emulation/red team framework, it can be used by organizations of all sizes to perform security testing. Sliver's implants support C2 over Mutual TLS (mTLS), WireGuard, HTTP(S), and DNS and are dynamically compiled with per-binary asymmetric encryption keys.
Sliver is an open-source, cross-platform adversary emulation and red team framework designed for organizations of all sizes to perform authorized security testing.
The server and client support MacOS, Windows, and Linux. Implants are supported on MacOS, Windows, and Linux (and possibly every Golang compiler target but we've not tested them all).
Sliver implants support command-and-control (C2) over Mutual TLS (mTLS), WireGuard, HTTP(S), and DNS. Implants are dynamically compiled with unique per-binary asymmetric encryption keys and support a wide range of post-exploitation capabilities across macOS, Windows, and Linux.
Sliver includes support for native and shellcode payloads, userspace reflective loading, built-in shellcode encoding, and BOF/COFF execution on both amd64 and arm64 architectures for macOS, Windows, and Linux.
The Sliver server and client run on macOS, Windows, and Linux. Implants officially support those same platforms and may also run on additional targets supported by the Go compiler, although those configurations are not regularly tested.
[![Release](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [![golangci-lint](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml/badge.svg)](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
Visit [https://sliver.sh/](https://sliver.sh/) for tutorials and documentation.
### Features
- Dynamic code generation
- Compile-time obfuscation
- Multiplayer-mode
- Staged and Stageless payloads
- [Procedurally generated C2](https://sliver.sh/docs?name=HTTPS+C2) over HTTP(S)
- [DNS canary](https://sliver.sh/docs?name=DNS+C2) blue team detection
- [Secure C2](https://sliver.sh/docs?name=Transport+Encryption) over mTLS, WireGuard, HTTP(S), and DNS
- Fully scriptable using [Python](https://github.com/moloch--/sliver-py)
- Windows process migration, process injection, user token manipulation, etc.
- Let's Encrypt integration
- In-memory .NET assembly execution
- COFF/BOF in-memory loader
- TCP and named pipe pivots
- Much more!
### Getting Started
Download the latest [release](https://github.com/BishopFox/sliver/releases) and see the Sliver [wiki](https://sliver.sh/docs?name=Getting+Started) for a quick tutorial on basic setup and usage. To get the very latest and greatest compile from source.
+30 -3
View File
@@ -19,6 +19,7 @@ package cli
*/
import (
"errors"
"fmt"
"log"
"os"
@@ -34,6 +35,8 @@ const (
logFileName = "sliver-client.log"
)
var clientLogFile *os.File
// Initialize logging.
func initLogging(appDir string) *os.File {
log.SetFlags(log.LstdFlags | log.Lshortfile)
@@ -47,11 +50,11 @@ func initLogging(appDir string) *os.File {
func init() {
appDir := assets.GetRootAppDir()
logFile := initLogging(appDir)
defer logFile.Close()
clientLogFile = initLogging(appDir)
rootCmd.TraverseChildren = true
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
rootCmd.PersistentFlags().String(ConsoleConfigFlagName, "", "path to client config file")
rootCmd.PersistentFlags().Bool(enableWGFlag, false, "force multiplayer connections through the operator config's WireGuard wrapper")
rootCmd.PersistentFlags().Bool(disableWGFlag, false, "force multiplayer connections to use direct mTLS")
rootCmd.MarkFlagsMutuallyExclusive(enableWGFlag, disableWGFlag)
@@ -96,10 +99,34 @@ var rootCmd = &cobra.Command{
Long: ``,
}
type commandExitError struct {
code int
err error
}
func (e *commandExitError) Error() string {
return e.err.Error()
}
func (e *commandExitError) Unwrap() error {
return e.err
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
if exitErr, ok := errors.AsType[*commandExitError](err); ok {
return exitErr.code
}
return 1
}
// Execute - Execute root command.
func Execute() {
defer func() { _ = clientLogFile.Close() }()
if err := rootCmd.Execute(); err != nil {
fmt.Printf("root command: %s\n", err)
os.Exit(1)
os.Exit(commandExitCode(err))
}
}
+23
View File
@@ -0,0 +1,23 @@
package cli
import (
"log"
"os"
"path/filepath"
"testing"
"github.com/bishopfox/sliver/client/assets"
)
func TestPackageLoggerRemainsOpenAfterInit(t *testing.T) {
logFile, ok := log.Writer().(*os.File)
if !ok {
t.Fatalf("standard logger writer = %T, want *os.File", log.Writer())
}
if want := filepath.Join(assets.GetRootAppDir(), logFileName); filepath.Clean(logFile.Name()) != want {
t.Fatalf("standard logger file = %q, want %q", logFile.Name(), want)
}
if _, err := logFile.Stat(); err != nil {
t.Fatalf("standard logger file is closed after package initialization: %v", err)
}
}
+19 -7
View File
@@ -20,24 +20,37 @@ package cli
import (
"fmt"
"path/filepath"
"sort"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/forms"
)
func selectConfig() (string, *assets.ClientConfig) {
configs := assets.GetConfigs()
func selectConfig(configPath string, stdinTTY bool) (string, *assets.ClientConfig, error) {
if configPath != "" {
config, err := loadMCPClientConfig(configPath)
if err != nil {
return "", nil, fmt.Errorf("load client config: %w", err)
}
return filepath.Base(configPath), config, nil
}
return selectConfigFrom(assets.GetConfigs(), stdinTTY)
}
func selectConfigFrom(configs map[string]*assets.ClientConfig, stdinTTY bool) (string, *assets.ClientConfig, error) {
if len(configs) == 0 {
return "", nil
return "", nil, fmt.Errorf("no config files found at %s", assets.GetConfigDir())
}
if len(configs) == 1 {
for key, config := range configs {
return key, config
return key, config, nil
}
}
if !stdinTTY {
return "", nil, fmt.Errorf("multiple configs found; use --config to select one")
}
keys := make([]string, 0, len(configs))
for key := range configs {
@@ -48,9 +61,8 @@ func selectConfig() (string, *assets.ClientConfig) {
selection := keys[0]
err := forms.Select("Select a server:", keys, &selection)
if err != nil {
fmt.Println(err.Error())
return "", nil
return "", nil, err
}
return selection, configs[selection]
return selection, configs[selection], nil
}
+46
View File
@@ -0,0 +1,46 @@
package cli
import (
"testing"
)
func TestConfigFlagRoutesBeforeAndAfterConsoleAndMCP(t *testing.T) {
flag := rootCmd.PersistentFlags().Lookup(ConsoleConfigFlagName)
if flag == nil {
t.Fatal("root --config flag is missing")
}
oldValue, oldChanged := flag.Value.String(), flag.Changed
t.Cleanup(func() {
_ = flag.Value.Set(oldValue)
flag.Changed = oldChanged
})
for _, command := range []string{"console", "mcp"} {
for _, before := range []bool{true, false} {
name := command + " after --config"
args := []string{"--config", "selected.cfg", command}
if !before {
name = command + " before --config"
args = []string{command, "--config", "selected.cfg"}
}
t.Run(name, func(t *testing.T) {
_ = flag.Value.Set("")
flag.Changed = false
cmd, remaining, err := rootCmd.Traverse(args)
if err != nil {
t.Fatalf("traverse %q: %v", args, err)
}
if cmd.Name() != command {
t.Fatalf("command = %q, want %q", cmd.Name(), command)
}
if err := cmd.ParseFlags(remaining); err != nil {
t.Fatalf("parse %q: %v", remaining, err)
}
got, err := cmd.Flags().GetString(ConsoleConfigFlagName)
if err != nil || got != "selected.cfg" {
t.Fatalf("--config = %q, error = %v", got, err)
}
})
}
}
}
+53
View File
@@ -0,0 +1,53 @@
package cli
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bishopfox/sliver/client/assets"
)
func TestSelectConfigFromNonTTYRequiresExplicitConfigWhenMultiple(t *testing.T) {
configs := map[string]*assets.ClientConfig{
"first": {Operator: "first"},
"second": {Operator: "second"},
}
key, config, err := selectConfigFrom(configs, false)
if err == nil || !strings.Contains(err.Error(), "--config") {
t.Fatalf("error = %v, want --config guidance", err)
}
if key != "" || config != nil {
t.Fatalf("selected %q (%v) without an explicit config", key, config)
}
}
func TestSelectConfigFromNonTTYUsesSoleConfig(t *testing.T) {
want := &assets.ClientConfig{Operator: "only"}
key, config, err := selectConfigFrom(map[string]*assets.ClientConfig{"only": want}, false)
if err != nil || key != "only" || config != want {
t.Fatalf("selection = %q (%v), error = %v", key, config, err)
}
}
func TestSelectConfigWithExplicitPathOnNonTTY(t *testing.T) {
want := &assets.ClientConfig{Operator: "selected", LHost: "127.0.0.1"}
data, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "selected.cfg")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
key, config, err := selectConfig(path, false)
if err != nil {
t.Fatalf("select explicit config: %v", err)
}
if key != "selected.cfg" || config.Operator != want.Operator || config.LHost != want.LHost {
t.Fatalf("selection = %q (%v)", key, config)
}
}
+25 -17
View File
@@ -22,15 +22,18 @@ import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/command"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/transport"
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/spf13/cobra"
"golang.org/x/term"
"google.golang.org/grpc"
)
// ConsoleConfigFlagName is the flag used to select a client config file.
const ConsoleConfigFlagName = "config"
// consoleCmd generates the console with required pre/post runners.
func consoleCmd(con *console.SliverClient) *cobra.Command {
consoleCmd := &cobra.Command{
@@ -49,20 +52,22 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
return err
}
configs := assets.GetConfigs()
if len(configs) == 0 {
fmt.Printf("No config files found at %s (see --help)\n", assets.GetConfigDir())
return nil
}
configKey, config := selectConfig()
if config == nil {
return nil
}
rcScript, err := ReadRCScript(cmd)
if err != nil {
fmt.Printf("Failed to read rc script: %s\n", err)
return nil
return err
}
configPath := ""
if cmd.Flags().Lookup(ConsoleConfigFlagName) != nil {
configPath, err = cmd.Flags().GetString(ConsoleConfigFlagName)
if err != nil {
return err
}
}
stdinTTY := term.IsTerminal(int(os.Stdin.Fd()))
configKey, config, err := selectConfig(configPath, stdinTTY)
if err != nil {
return err
}
target := fmt.Sprintf("%s:%d", config.LHost, config.LPort)
@@ -70,7 +75,7 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
var ln *grpc.ClientConn
// Don't clobber output when simply running an implant command from system shell.
if run {
if run && stdinTTY {
rpc, ln, err = connectWithSpinner(os.Stdout, target, func(statusFn transport.ConnectStatusFn) (rpcpb.SliverRPCClient, *grpc.ClientConn, error) {
return transport.MTLSConnectWithStatus(config, statusFn)
})
@@ -78,10 +83,13 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
rpc, ln, err = transport.MTLSConnect(config)
}
if err != nil {
fmt.Printf("Connection to server failed %s\n", err)
return nil
return fmt.Errorf("connection to server failed: %w", err)
}
return console.StartClient(con, rpc, ln, &console.ConnectionDetails{ConfigKey: configKey, Config: config}, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript)
if err := console.StartClient(con, rpc, ln, &console.ConnectionDetails{ConfigKey: configKey, Config: config}, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript); err != nil {
_ = con.CloseConnection()
return err
}
return nil
}
// Close the RPC connection once exiting
+3 -1
View File
@@ -57,7 +57,9 @@ func makeRunners(implantCmd *cobra.Command, con *console.SliverClient) (pre, pos
// The pre-run function connects to the server and sets up a "fake" console,
// so we can have access to active sessions/beacons, and other stuff needed.
pre = func(_ *cobra.Command, args []string) error {
startConsole(implantCmd, args)
if err := startConsole(implantCmd, args); err != nil {
return err
}
// Set the active target.
target, _ := implantCmd.Flags().GetString("use")
+21 -13
View File
@@ -20,7 +20,6 @@ package cli
import (
"fmt"
"os"
"github.com/bishopfox/sliver/client/assets"
"github.com/rsteube/carapace"
@@ -32,19 +31,12 @@ func importCmd() *cobra.Command {
Use: "import",
Short: "Import a client configuration file",
Long: `import [config files]`,
Run: func(cmd *cobra.Command, args []string) {
if 0 < len(args) {
for _, arg := range args {
conf, err := assets.ReadConfig(arg)
if err != nil {
fmt.Printf("[!] %s\n", err)
os.Exit(3)
}
assets.SaveConfig(conf)
}
} else {
fmt.Printf("Missing config file path, see --help")
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Missing config file path, see --help")
return nil
}
return importConfigs(args)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{}, cobra.ShellCompDirectiveDefault
@@ -55,3 +47,19 @@ func importCmd() *cobra.Command {
return cmdImport
}
func importConfigs(paths []string) error {
for _, path := range paths {
conf, err := assets.ReadConfig(path)
if err != nil {
return &commandExitError{
code: 3,
err: fmt.Errorf("read import %q: %w", path, err),
}
}
if err := assets.SaveConfig(conf); err != nil {
return fmt.Errorf("save import %q: %w", path, err)
}
}
return nil
}
+125
View File
@@ -0,0 +1,125 @@
package cli
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func writeImportConfig(t *testing.T, name, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestImportCommandReportsInvalidConfig(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
path := writeImportConfig(t, "missing-operator.cfg", `{"lhost":"server"}`)
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want error identifying %q", err, path)
}
if got := commandExitCode(err); got != 1 {
t.Fatalf("invalid config exit code = %d, want 1", got)
}
}
func TestImportCommandReportsConfigWriteFailure(t *testing.T) {
clientRoot := t.TempDir()
t.Setenv("SLIVER_CLIENT_ROOT_DIR", clientRoot)
path := writeImportConfig(t, "alice.cfg", `{"operator":"alice","lhost":"server"}`)
configDir := filepath.Join(clientRoot, "configs")
if err := os.MkdirAll(filepath.Join(configDir, "alice_server.cfg"), 0o700); err != nil {
t.Fatal(err)
}
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want write error identifying %q", err, path)
}
if got := commandExitCode(err); got != 1 {
t.Fatalf("config write failure exit code = %d, want 1", got)
}
if _, ok := errors.AsType[*os.PathError](err); !ok {
t.Fatalf("config write failure does not wrap the filesystem error: %v", err)
}
}
func TestImportCommandReadFailureExitCode(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
path := filepath.Join(t.TempDir(), "missing.cfg")
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want read error identifying %q", err, path)
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("import error does not wrap the missing file error: %v", err)
}
if got := commandExitCode(fmt.Errorf("command failed: %w", err)); got != 3 {
t.Fatalf("wrapped read failure exit code = %d, want 3", got)
}
}
func TestImportCommandParseFailureExitCode(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
path := writeImportConfig(t, "malformed.cfg", `{"operator":`)
cmd := importCmd()
err := cmd.RunE(cmd, []string{path})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(path)) {
t.Fatalf("import error = %v, want parse error identifying %q", err, path)
}
if _, ok := errors.AsType[*json.SyntaxError](err); !ok {
t.Fatalf("import error does not wrap the JSON syntax error: %v", err)
}
if got := commandExitCode(err); got != 3 {
t.Fatalf("parse failure exit code = %d, want 3", got)
}
}
func TestImportCommandStopsAfterFirstFailure(t *testing.T) {
clientRoot := t.TempDir()
t.Setenv("SLIVER_CLIENT_ROOT_DIR", clientRoot)
invalidPath := writeImportConfig(t, "invalid.cfg", `{"lhost":"server"}`)
validPath := writeImportConfig(t, "valid.cfg", `{"operator":"alice","lhost":"server"}`)
cmd := importCmd()
err := cmd.RunE(cmd, []string{invalidPath, validPath})
if err == nil || !strings.Contains(err.Error(), strconv.Quote(invalidPath)) {
t.Fatalf("import error = %v, want first file %q", err, invalidPath)
}
if _, err := os.Stat(filepath.Join(clientRoot, "configs", "alice_server.cfg")); !os.IsNotExist(err) {
t.Fatalf("later config was imported after first failure: stat error = %v", err)
}
}
func TestImportCommandMissingPathPreservesSuccess(t *testing.T) {
cmd := importCmd()
var output bytes.Buffer
cmd.SetOut(&output)
err := cmd.RunE(cmd, nil)
if err != nil {
t.Fatalf("import without a path failed: %v", err)
}
if got := commandExitCode(err); got != 0 {
t.Fatalf("missing path exit code = %d, want 0", got)
}
if got, want := output.String(), "Missing config file path, see --help"; got != want {
t.Fatalf("missing path output = %q, want %q", got, want)
}
}
+14 -2
View File
@@ -22,12 +22,14 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/console"
clientmcp "github.com/bishopfox/sliver/client/mcp"
"github.com/bishopfox/sliver/client/transport"
"github.com/spf13/cobra"
"google.golang.org/grpc/keepalive"
)
func mcpCmd(con *console.SliverClient) *cobra.Command {
@@ -38,7 +40,6 @@ func mcpCmd(con *console.SliverClient) *cobra.Command {
return runMCP(cmd, con)
},
}
mcpCmd.Flags().String("config", "", "path to client config file")
return mcpCmd
}
@@ -54,6 +55,14 @@ func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
return nil
}
// Keep the gRPC link alive while the MCP bridge sits idle between tool calls.
// The daemon permits pings >=30s without streams (server.yaml: grpc.keepalive).
transport.SetKeepaliveParams(keepalive.ClientParameters{
Time: 60 * time.Second,
Timeout: 20 * time.Second,
PermitWithoutStream: true,
})
rpc, ln, err := transport.MTLSConnect(config)
if err != nil {
fmt.Printf("Connection to server failed %s\n", err)
@@ -62,7 +71,10 @@ func runMCP(cmd *cobra.Command, con *console.SliverClient) error {
defer transport.CloseGRPCConnection(ln)
con.Rpc = rpc
go handleConnectionLost(ln)
// Do not install the connection-lost watchdog here: gRPC channels idle
// out after 30 minutes without RPCs (grpc-go default) and transparently
// reconnect on the next call. Exiting on the Idle state would kill an
// otherwise healthy MCP bridge between tool calls.
cfg := clientmcp.DefaultConfig()
if err := clientmcp.ServeStdio(cfg, con.Rpc); err != nil {
+4 -18
View File
@@ -156,13 +156,6 @@ func TestMessageBlockLabelIgnoresUnknownOperatorPlaceholders(t *testing.T) {
}
}
func TestTranscriptSpeakerStyleUsesPrimaryThemeForUserMessages(t *testing.T) {
styles := transcriptSpeakerStyle("alice", "user")
assertColorEqual(t, styles.border.GetForeground(), clienttheme.Primary())
assertColorEqual(t, styles.label.GetBackground(), clienttheme.Primary())
}
func TestThinkingLevelOptionFocusedStyleUsesDarkerPrimaryShade(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
@@ -544,19 +537,12 @@ func TestRenderHeaderUsesSingleCompactRow(t *testing.T) {
}
}
func TestTranscriptSpeakerPaletteVariesAcrossUsers(t *testing.T) {
seen := map[int]struct{}{}
for _, label := range []string{"alice", "bob", "charlie", "dana", "erin", "frank"} {
seen[transcriptSpeakerPaletteIndex(label, "user")] = struct{}{}
}
if len(seen) < 2 {
t.Fatalf("expected distinct users to map to more than one transcript color, got %d palette entries", len(seen))
}
}
func TestTranscriptSpeakerStyleUsesPrimaryBorderForUserMessages(t *testing.T) {
userStyles := transcriptSpeakerStyle("alice", "user")
userBorder := transcriptSpeakerStyle("alice", "user").border.Render("│")
assertColorEqual(t, userStyles.border.GetForeground(), clienttheme.Primary())
assertColorEqual(t, userStyles.label.GetBackground(), clienttheme.Primary())
userBorder := userStyles.border.Render("│")
wantUserBorder := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("│")
if userBorder != wantUserBorder {
t.Fatalf("expected user message border to use theme primary color")
-1
View File
@@ -1 +0,0 @@
package armory
+77 -14
View File
@@ -68,9 +68,12 @@ const (
)
type docEntry struct {
Name string
Content string
Description string
Name string
Content string
Description string
searchTitle string
searchDescription string
searchContent string
}
type docsHelpEntry struct {
@@ -181,8 +184,12 @@ func newDocsModel(entries []docEntry) *docsModel {
styles: newDocsStyles(),
}
for _, entry := range entries {
model.entriesByName[entry.Name] = entry
for i := range model.entries {
entry := &model.entries[i]
entry.searchTitle = normalizeDocSearchText(entry.Name)
entry.searchDescription = normalizeDocSearchText(entry.Description)
entry.searchContent = normalizeDocSearchText(entry.Content)
model.entriesByName[entry.Name] = *entry
}
if idx := preferredDocIndex(entries); idx >= 0 {
@@ -522,13 +529,23 @@ func (m *docsModel) updateFilteredEntries(preferCurrent bool) {
return
}
filtered := make([]docEntry, 0, len(m.entries))
type rankedEntry struct {
entry docEntry
score int
}
filtered := make([]rankedEntry, 0, len(m.entries))
for _, entry := range m.entries {
if matchesDocQuery(entry, query) {
filtered = append(filtered, entry)
if score, matches := docQueryScore(entry, query); matches {
filtered = append(filtered, rankedEntry{entry: entry, score: score})
}
}
m.filteredEntries = filtered
sort.SliceStable(filtered, func(i, j int) bool {
return filtered[i].score > filtered[j].score
})
m.filteredEntries = make([]docEntry, len(filtered))
for i, result := range filtered {
m.filteredEntries[i] = result.entry
}
m.updateBrowserPagination()
if preferCurrent && m.selectDocInVisible(m.currentDocName) {
@@ -539,12 +556,58 @@ func (m *docsModel) updateFilteredEntries(preferCurrent bool) {
m.syncSelectionFromBrowser()
}
func matchesDocQuery(entry docEntry, query string) bool {
if query == "" {
return true
func docQueryScore(entry docEntry, query string) (int, bool) {
normalizedQuery := strings.TrimSpace(normalizeDocSearchText(query))
if normalizedQuery == "" {
return 0, true
}
haystack := strings.ToLower(entry.Name + ".md\n" + entry.Description)
return strings.Contains(haystack, query)
title := entry.searchTitle
terms := strings.Fields(normalizedQuery)
for _, term := range terms {
if !strings.Contains(title, term) &&
!strings.Contains(entry.searchDescription, term) &&
!strings.Contains(entry.searchContent, term) {
return 0, false
}
}
score := 0
switch {
case title == normalizedQuery:
score += 1000
case strings.HasPrefix(title, normalizedQuery):
score += 500
case strings.Contains(title, normalizedQuery):
score += 300
}
for _, term := range terms {
if strings.Contains(title, term) {
score += 100
}
if strings.Contains(entry.searchDescription, term) {
score += 30
}
if strings.Contains(entry.searchContent, term) {
score++
}
}
return score, true
}
func normalizeDocSearchText(value string) string {
value = strings.ToLower(value)
value = strings.Map(func(r rune) rune {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
return r
case unicode.IsSpace(r):
return ' '
default:
return ' '
}
}, value)
return strings.Join(strings.Fields(value), " ")
}
func (m *docsModel) moveBrowserCursorUp() {
+56
View File
@@ -106,6 +106,62 @@ func TestDocsModelFilteringUpdatesVisibleEntries(t *testing.T) {
}
}
func TestDocsModelFilteringMatchesContentAndNormalizedTerms(t *testing.T) {
entries := []docEntry{
{
Name: "HTTP C2",
Content: "# HTTP C2\n\nUse the staging listener and HTTPS transports.",
Description: "Transport details",
},
{
Name: "Networking",
Content: "# Networking\n\nTCP and UDP details",
Description: "Network basics",
},
}
model := newDocsModel(entries)
model.applyWindowSize(120, 32)
model.startFiltering()
model.browserFilter.SetValue("https transport")
model.updateFilteredEntries(false)
if len(model.visibleEntries()) != 1 || model.visibleEntries()[0].Name != "HTTP C2" {
t.Fatalf("expected HTTP C2 to match multi-term query, got %+v", model.visibleEntries())
}
model.browserFilter.SetValue("https-transport")
model.updateFilteredEntries(false)
if len(model.visibleEntries()) != 1 || model.visibleEntries()[0].Name != "HTTP C2" {
t.Fatalf("expected punctuation normalization to match HTTP C2, got %+v", model.visibleEntries())
}
}
func TestDocsModelFilteringRanksTitleMatchesFirst(t *testing.T) {
entries := []docEntry{
{
Name: "Advanced Setup",
Content: "This section explains listener configuration.",
},
{
Name: "Listener Configuration",
Content: "Configure network settings.",
},
}
model := newDocsModel(entries)
model.applyWindowSize(120, 32)
model.startFiltering()
model.browserFilter.SetValue("listener")
model.updateFilteredEntries(false)
visible := model.visibleEntries()
if len(visible) != 2 {
t.Fatalf("expected both documents to match, got %+v", visible)
}
if visible[0].Name != "Listener Configuration" {
t.Fatalf("expected title match to rank first, got %q", visible[0].Name)
}
}
func TestDocsModelEnterMovesFocusToViewer(t *testing.T) {
model := newDocsModel(sampleDocs())
model.applyWindowSize(120, 32)
+9 -10
View File
@@ -3,6 +3,8 @@ package exec
import (
"testing"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
@@ -87,14 +89,11 @@ func TestShouldConvertExecuteShellcodePE(t *testing.T) {
func newExecuteShellcodeFlagsTestCommand(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "execute-shellcode"}
cmd.Flags().Uint32("shellcode-entropy", 1, "")
cmd.Flags().Bool("shellcode-compress", false, "")
cmd.Flags().Uint32("shellcode-exitopt", 1, "")
cmd.Flags().Uint32("shellcode-bypass", 3, "")
cmd.Flags().Uint32("shellcode-headers", 1, "")
cmd.Flags().Bool("shellcode-thread", false, "")
cmd.Flags().Bool("shellcode-unicode", false, "")
cmd.Flags().Uint32("shellcode-oep", 0, "")
return cmd
for _, cmd := range Commands(&console.SliverClient{}) {
if cmd.Name() == consts.ExecuteShellcodeStr {
return cmd
}
}
t.Fatalf("missing %q command", consts.ExecuteShellcodeStr)
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package generate
import (
"strconv"
"testing"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
func TestCanariesCommandRegistered(t *testing.T) {
root := &cobra.Command{Use: "sliver"}
root.AddCommand(Commands(&console.SliverClient{})...)
cmd, _, err := root.Find([]string{consts.CanariesStr})
if err != nil {
t.Fatalf("find canaries command: %v", err)
}
if cmd.Parent() != root {
t.Fatal("canaries command is not registered at the root")
}
if cmd.Run == nil {
t.Fatal("canaries command has no handler")
}
if cmd.GroupID != consts.PayloadsHelpGroup {
t.Errorf("canaries group = %q, want %q", cmd.GroupID, consts.PayloadsHelpGroup)
}
for _, want := range []struct {
name string
shorthand string
valueType string
defaultValue string
}{
{name: "burned", shorthand: "b", valueType: "bool", defaultValue: "false"},
{name: "timeout", shorthand: "t", valueType: "int64", defaultValue: strconv.Itoa(flags.DefaultTimeout)},
} {
flag := cmd.Flags().Lookup(want.name)
if flag == nil {
t.Fatalf("canaries command is missing --%s", want.name)
}
if flag.Shorthand != want.shorthand {
t.Errorf("--%s shorthand = %q, want %q", want.name, flag.Shorthand, want.shorthand)
}
if flag.Value.Type() != want.valueType {
t.Errorf("--%s type = %q, want %q", want.name, flag.Value.Type(), want.valueType)
}
if flag.DefValue != want.defaultValue {
t.Errorf("--%s default = %q, want %q", want.name, flag.DefValue, want.defaultValue)
}
}
}
+4 -2
View File
@@ -288,13 +288,15 @@ func Commands(con *console.SliverClient) []*cobra.Command {
implantBuildsCmd.AddCommand(implantsRmCmd)
implantsStageCmd := &cobra.Command{
Use: consts.StageStr,
Use: consts.StageStr + " [implant-name] ...",
Short: "Serve a previously generated build",
Long: help.GetHelpFor([]string{consts.ImplantBuildsStr, consts.StageStr}),
Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) {
ImplantsStageCmd(cmd, con, args)
},
}
carapace.Gen(implantsStageCmd).PositionalAnyCompletion(ImplantBuildNameCompleter(con))
implantBuildsCmd.AddCommand(implantsStageCmd)
canariesCmd := &cobra.Command{
@@ -311,7 +313,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd}
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd, canariesCmd}
}
// coreImplantFlags binds all flags common to all sliver implant types.
+17
View File
@@ -37,6 +37,23 @@ func ImplantsStageCmd(cmd *cobra.Command, con *console.SliverClient, args []stri
return
}
if len(args) > 0 {
selected := []string{}
for _, name := range args {
if _, ok := builds.Configs[name]; !ok {
con.PrintErrorf("No implant build found with name '%s'\n", name)
return
}
selected = append(selected, name)
}
_, err = con.Rpc.StageImplantBuild(context.Background(), &clientpb.ImplantStageReq{Build: selected})
if err != nil {
con.PrintErrorf("Failed to serve implant %s\n", err)
return
}
return
}
options := []string{}
for name := range builds.Configs {
options = append(options, name)
@@ -0,0 +1,2 @@
# Preserve pinned upstream script bytes for SHA-256 verification on every OS.
*.cna -text
@@ -0,0 +1,74 @@
package update
import (
"testing"
"github.com/bishopfox/sliver/client/version"
)
func TestFindWindowsExecutableAssetForArchitecture(t *testing.T) {
for _, tc := range []struct {
prefix string
arch string
wrongArch string
}{
{prefix: "sliver-client", arch: "amd64", wrongArch: "arm64"},
{prefix: "sliver-client", arch: "arm64", wrongArch: "386"},
{prefix: "sliver-client", arch: "386", wrongArch: "amd64"},
{prefix: "sliver-server", arch: "amd64", wrongArch: "arm64"},
{prefix: "sliver-server", arch: "arm64", wrongArch: "amd64"},
} {
t.Run(tc.prefix+"/"+tc.arch, func(t *testing.T) {
want := tc.prefix + "_windows-" + tc.arch + ".exe"
otherPrefix := "sliver-server"
if tc.prefix == otherPrefix {
otherPrefix = "sliver-client"
}
assets := releaseAssets(
want+".minisig",
otherPrefix+"_windows-"+tc.arch+".exe",
tc.prefix+"_windows-"+tc.wrongArch+".exe",
want,
)
got := findAssetFor(tc.prefix, assetSuffixes("windows", tc.arch), assets)
if got == nil || got.Name != want {
t.Fatalf("findAssetFor(%s, windows/%s) = %v, want %q", tc.prefix, tc.arch, got, want)
}
})
}
}
func TestFindNonWindowsReleaseAssets(t *testing.T) {
for _, tc := range []struct {
name string
prefix string
goos string
arch string
want string
}{
{name: "linux zip", prefix: "sliver-client", goos: "linux", arch: "amd64", want: "sliver-client_linux-amd64.zip"},
{name: "linux extensionless", prefix: "sliver-server", goos: "linux", arch: "arm64", want: "sliver-server_linux-arm64"},
{name: "darwin zip", prefix: "sliver-client", goos: "darwin", arch: "arm64", want: "sliver-client_macos-arm64.zip"},
{name: "darwin extensionless", prefix: "sliver-server", goos: "darwin", arch: "amd64", want: "sliver-server_darwin-amd64"},
} {
t.Run(tc.name, func(t *testing.T) {
assets := releaseAssets(tc.want+".minisig", tc.want)
got := findAssetFor(tc.prefix, assetSuffixes(tc.goos, tc.arch), assets)
if got == nil || got.Name != tc.want {
t.Fatalf("findAssetFor(%s, %s/%s) = %v, want %q", tc.prefix, tc.goos, tc.arch, got, tc.want)
}
})
}
}
func releaseAssets(names ...string) []version.Asset {
assets := make([]version.Asset, 0, len(names))
for _, name := range names {
assets = append(assets, version.Asset{
Name: name,
BrowserDownloadURL: "https://example.invalid/releases/v1/" + name,
})
}
return assets
}
+7 -15
View File
@@ -1,3 +1,4 @@
// Package update implements release download and verification commands.
package update
/*
@@ -243,6 +244,11 @@ func assetSuffixes(goos, goarch string) []string {
add(fmt.Sprintf("_%s.zip", goos))
add(fmt.Sprintf("_%s", goos))
if goos == "windows" {
add(fmt.Sprintf("_windows-%s.exe", goarch))
add("_windows.exe")
}
if goos == "darwin" {
add(fmt.Sprintf("_macos-%s.zip", goarch))
add(fmt.Sprintf("_macos-%s", goarch))
@@ -332,21 +338,19 @@ func downloadAssetWithSignature(con *console.SliverClient, client *http.Client,
if err != nil {
return fmt.Errorf("download asset: %w", err)
}
defer func() { _ = os.Remove(assetTempPath) }()
sigData, err := downloadBytesWithRetries(client, sigURL, sigLimit, "signature")
if err != nil {
cleanupAssetFiles(assetTempPath, finalPath)
return fmt.Errorf("download signature: %w", err)
}
if err := verifyMinisignSignature(assetTempPath, sigData, assetFileName, publicKey); err != nil {
cleanupAssetFiles(assetTempPath, finalPath)
return err
}
con.PrintSuccessf("Signature verified for %s\n", finalPath)
if err := replaceFile(assetTempPath, finalPath); err != nil {
os.Remove(assetTempPath)
return err
}
return nil
@@ -628,17 +632,5 @@ func trustedCommentFile(trustedComment string) string {
}
func replaceFile(src, dst string) error {
if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
return err
}
return os.Rename(src, dst)
}
func cleanupAssetFiles(tempPath, finalPath string) {
if tempPath != "" {
_ = os.Remove(tempPath)
}
if finalPath != "" {
_ = os.Remove(finalPath)
}
}
+122
View File
@@ -0,0 +1,122 @@
package update
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bishopfox/sliver/client/version"
"github.com/bishopfox/sliver/util/minisign"
)
func TestDownloadAssetWithSignaturePreservesExistingArtifactOnFailure(t *testing.T) {
const assetName = "sliver-client_linux-amd64.zip"
oldContent := []byte("previous verified download")
newContent := []byte("unverified replacement")
for _, tc := range []struct {
name string
signatureStatus int
signatureBody string
wantError string
}{
{name: "signature download fails", signatureStatus: http.StatusNotFound, wantError: "download signature"},
{name: "signature is malformed", signatureStatus: http.StatusOK, signatureBody: "not a minisign signature", wantError: "signature verification failed"},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/" + assetName:
_, _ = w.Write(newContent)
case "/" + assetName + ".minisig":
w.WriteHeader(tc.signatureStatus)
_, _ = w.Write([]byte(tc.signatureBody))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
dir := t.TempDir()
finalPath := filepath.Join(dir, assetName)
if err := os.WriteFile(finalPath, oldContent, 0o600); err != nil {
t.Fatal(err)
}
asset := &version.Asset{
Name: assetName,
Size: len(newContent),
BrowserDownloadURL: server.URL + "/" + assetName,
}
err := downloadAssetWithSignature(nil, server.Client(), asset, nil, dir, minisign.PublicKey{})
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("downloadAssetWithSignature error = %v, want %q", err, tc.wantError)
}
got, err := os.ReadFile(finalPath)
if err != nil {
t.Fatalf("previous artifact was removed: %v", err)
}
if !bytes.Equal(got, oldContent) {
t.Fatalf("previous artifact changed: got %q, want %q", got, oldContent)
}
tempPaths, err := filepath.Glob(filepath.Join(dir, assetName+".tmp-*"))
if err != nil {
t.Fatal(err)
}
if len(tempPaths) != 0 {
t.Fatalf("temporary downloads remain: %v", tempPaths)
}
})
}
}
func TestReplaceFileMissingSourcePreservesDestination(t *testing.T) {
dir := t.TempDir()
dst := filepath.Join(dir, "download.zip")
want := []byte("previous verified download")
if err := os.WriteFile(dst, want, 0o600); err != nil {
t.Fatal(err)
}
err := replaceFile(filepath.Join(dir, "missing.tmp"), dst)
if err == nil {
t.Fatal("replaceFile with a missing source succeeded")
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("previous artifact was removed: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("previous artifact changed: got %q, want %q", got, want)
}
}
func TestReplaceFileReplacesExistingDestination(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "download.zip.tmp")
dst := filepath.Join(dir, "download.zip")
want := []byte("new verified download")
if err := os.WriteFile(src, want, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("previous verified download"), 0o600); err != nil {
t.Fatal(err)
}
if err := replaceFile(src, dst); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("replacement content = %q, want %q", got, want)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("source still exists after replacement: %v", err)
}
}
@@ -123,10 +123,11 @@ func webAddFile(web *clientpb.WebsiteAddContent, webpath string, contentType str
}
web.Contents[webpath] = &clientpb.WebContent{
Path: webpath,
ContentType: contentType,
OriginalFile: filepath.Base(contentPath),
Content: data,
Path: webpath,
ContentType: contentType,
OriginalFile: filepath.Base(contentPath),
Content: data,
ReplaceContent: true,
}
return nil
}
@@ -0,0 +1,42 @@
package websites
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
"google.golang.org/protobuf/proto"
)
func TestWebAddFileMarksContentReplacement(t *testing.T) {
for _, data := range [][]byte{nil, []byte("replacement")} {
name := "nonempty"
if len(data) == 0 {
name = "empty"
}
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "upload.txt")
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
request := &clientpb.WebsiteAddContent{Contents: map[string]*clientpb.WebContent{}}
if err := webAddFile(request, "/upload.txt", "text/plain", path); err != nil {
t.Fatal(err)
}
encoded, err := proto.Marshal(request)
if err != nil {
t.Fatal(err)
}
decoded := &clientpb.WebsiteAddContent{}
if err := proto.Unmarshal(encoded, decoded); err != nil {
t.Fatal(err)
}
content := decoded.Contents["/upload.txt"]
if content == nil || !content.ReplaceContent || !bytes.Equal(content.Content, data) {
t.Fatalf("decoded upload = %v, want explicit replacement with %q", content, data)
}
})
}
}
+12 -1
View File
@@ -56,7 +56,7 @@ func WebsitesRmContent(cmd *cobra.Command, con *console.SliverClient, args []str
}
if recursive {
for contentPath := range website.Contents {
if strings.HasPrefix(contentPath, webPath) {
if isWebPathOrDescendant(contentPath, webPath) {
rmWebContent.Paths = append(rmWebContent.Paths, contentPath)
}
}
@@ -70,3 +70,14 @@ func WebsitesRmContent(cmd *cobra.Command, con *console.SliverClient, args []str
}
PrintWebsite(web, con)
}
func isWebPathOrDescendant(contentPath, webPath string) bool {
if webPath == "" {
return false
}
webPath = strings.TrimRight(webPath, "/")
if webPath == "" {
return strings.HasPrefix(contentPath, "/")
}
return contentPath == webPath || strings.HasPrefix(contentPath, webPath+"/")
}
@@ -46,3 +46,27 @@ func TestWebsiteRemoveSuccessMessage(t *testing.T) {
t.Fatalf("unexpected message: got %q want %q", got, want)
}
}
func TestRecursiveWebsiteContentPathSelection(t *testing.T) {
tests := []struct {
name string
webPath string
contentPath string
want bool
}{
{"exact file", "/public", "/public", true},
{"descendant", "/public", "/public/index.html", true},
{"sibling with shared prefix", "/public", "/publicity/index.html", false},
{"trailing slash", "/public/", "/public/index.html", true},
{"root", "/", "/public/index.html", true},
{"relative directory", "public", "public/index.html", true},
{"empty selection", "", "/public/index.html", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isWebPathOrDescendant(tt.contentPath, tt.webPath); got != tt.want {
t.Errorf("isWebPathOrDescendant(%q, %q) = %t, want %t", tt.contentPath, tt.webPath, got, tt.want)
}
})
}
}
@@ -49,6 +49,7 @@ func WebsitesUpdateContentCmd(cmd *cobra.Command, con *console.SliverClient, arg
Contents: map[string]*clientpb.WebContent{},
}
updateWeb.Contents[webPath] = &clientpb.WebContent{
Path: webPath,
ContentType: contentType,
}
+52 -13
View File
@@ -47,6 +47,7 @@ import (
"github.com/reeflective/console"
"github.com/reeflective/readline"
"github.com/spf13/cobra"
"golang.org/x/term"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
uuid "uuid"
@@ -63,6 +64,10 @@ const (
DownN = "\033[%dB"
)
// isTerminal reports whether fd is a connected terminal.
// Variable rather than direct call so tests can override it.
var isTerminal = term.IsTerminal
// Observer - A function to call when the sessions changes.
type (
Observer func(*clientpb.Session, *clientpb.Beacon)
@@ -201,6 +206,7 @@ func NewConsole(isServer bool) *SliverClient {
// Global console settings
con.App.NewlineBefore = true
con.App.NewlineAfter = true
con.App.ExecuteMultiline = true
// Server menu.
server := con.App.Menu(consts.ServerMenu)
@@ -227,6 +233,7 @@ func NewConsole(isServer bool) *SliverClient {
// If run is true, the console application is started, making this call blocking. Otherwise, commands and
// RPC connection are bound to the console (making the console ready to run), but the console does not start.
func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, grpcConn *grpc.ClientConn, details *ConnectionDetails, serverCmds, sliverCmds console.Commands, run bool, rcScript string) error {
interactive := run && isTerminal(int(os.Stdin.Fd()))
con.IsCLI = !run
con.serverCmds = serverCmds
con.sliverCmds = sliverCmds
@@ -235,7 +242,7 @@ func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, grpcConn *grpc.Cl
// when asynchronously printing logs (that is, when no command is running).
// If ran from a system shell, however, those queries will block because
// the system shell is in control of stdin. So just use the classic Printf.
if con.IsCLI {
if !interactive {
con.printf = fmt.Printf
} else {
con.printf = con.App.TransientPrintf
@@ -260,7 +267,7 @@ func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, grpcConn *grpc.Cl
// Ascii cast sessions (complete terminal interface) are only useful
// for the interactive console. In CLI mode they would clobber stdout.
if !con.IsCLI {
if interactive {
asciicastLog := getConsoleAsciicastFile()
defer asciicastLog.Close()
@@ -276,20 +283,36 @@ func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, grpcConn *grpc.Cl
if rcScript != "" {
originalPrintf := con.printf
con.printf = fmt.Printf
con.runRCScript(serverCmds, sliverCmds, rcScript)
err := con.runCommandScript(serverCmds, sliverCmds, strings.NewReader(rcScript), "rc")
con.printf = originalPrintf
// An rc script is often a startup convenience in an interactive
// console. Keep opening the prompt after reporting its line errors.
if err != nil && !interactive {
return err
}
}
if !con.IsCLI {
if !run {
return nil
}
if interactive {
return con.App.Start()
}
return nil
if rcScript != "" {
return nil
}
// Readline needs a terminal. A pipe or redirected file is instead executed
// as a sequence of commands, ending cleanly at EOF.
return con.runCommandScript(serverCmds, sliverCmds, os.Stdin, "stdin")
}
func (con *SliverClient) runRCScript(serverCmds, sliverCmds console.Commands, rcScript string) {
scanner := bufio.NewScanner(strings.NewReader(rcScript))
func (con *SliverClient) runCommandScript(serverCmds, sliverCmds console.Commands, input io.Reader, source string) error {
scanner := bufio.NewScanner(input)
scanner.Buffer(make([]byte, 4096), 4*1024*1024)
lineNumber := 0
var firstError error
errorCount := 0
for scanner.Scan() {
lineNumber++
@@ -297,17 +320,31 @@ func (con *SliverClient) runRCScript(serverCmds, sliverCmds console.Commands, rc
if line == "" {
continue
}
if err := con.runRCLine(serverCmds, sliverCmds, line); err != nil {
con.PrintErrorf("rc line %d error: %s", lineNumber, err)
if err := con.runCommandLine(serverCmds, sliverCmds, line); err != nil {
lineError := fmt.Errorf("%s line %d: %w", source, lineNumber, err)
con.PrintErrorf("%s\n", lineError)
if firstError == nil {
firstError = lineError
}
errorCount++
}
}
if err := scanner.Err(); err != nil {
con.PrintErrorf("rc script error: %s", err)
readError := fmt.Errorf("%s line %d: %w", source, lineNumber+1, err)
con.PrintErrorf("%s\n", readError)
if firstError == nil {
firstError = readError
}
errorCount++
}
if errorCount > 0 {
return fmt.Errorf("%s: %d error(s) (first: %w)", source, errorCount, firstError)
}
return nil
}
func (con *SliverClient) runRCLine(serverCmds, sliverCmds console.Commands, line string) error {
func (con *SliverClient) runCommandLine(serverCmds, sliverCmds console.Commands, line string) error {
args, err := shellquote.Split(line)
if err != nil {
return fmt.Errorf("parse error: %w", err)
@@ -789,7 +826,9 @@ func (con *SliverClient) CheckLastUpdate() {
day := 24 * time.Hour
if compiledAt.Add(30 * day).Before(now) {
if lastUpdate == nil || lastUpdate.Add(30*day).Before(now) {
con.Printf("%sCheck for updates with the 'update' command\n\n", Info)
// The startup banner runs before readline enables raw terminal mode.
// Print directly so the reminder cannot redraw the prompt or query the cursor.
fmt.Printf("%sCheck for updates with the 'update' command\n\n", Info)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package console
import (
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/client/version"
reefconsole "github.com/reeflective/console"
)
func TestCheckLastUpdateBeforeReadline(t *testing.T) {
now := time.Now()
old := now.Add(-31 * 24 * time.Hour)
recent := now.Add(-24 * time.Hour)
for _, test := range []struct {
name string
compiledAt time.Time
lastCheck *time.Time
wantReminder bool
}{
{name: "old build without update check", compiledAt: old, wantReminder: true},
{name: "old build with old update check", compiledAt: old, lastCheck: &old, wantReminder: true},
{name: "old build with recent update check", compiledAt: old, lastCheck: &recent},
{name: "recent build without update check", compiledAt: recent},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
t.Setenv("SLIVER_CLIENT_ROOT_DIR", root)
t.Setenv("READLINE_CURSOR_POS", "1")
if test.lastCheck != nil {
lastCheck := strconv.FormatInt(test.lastCheck.Unix(), 10)
if err := os.WriteFile(filepath.Join(root, consts.LastUpdateCheckFileName), []byte(lastCheck), 0600); err != nil {
t.Fatal(err)
}
}
previousCompiledAt := version.CompiledAt
version.CompiledAt = strconv.FormatInt(test.compiledAt.Unix(), 10)
t.Cleanup(func() { version.CompiledAt = previousCompiledAt })
// Interactive startup selects the transient printer before the logo
// and update reminder run, but Readline has not acquired stdin yet.
app := reefconsole.New("sliver")
con := &SliverClient{
App: app,
printf: app.TransientPrintf,
jsonHandler: slog.NewJSONHandler(io.Discard, nil),
}
output := captureStartupOutput(t, con.CheckLastUpdate)
if strings.Contains(output, "\x1b[6n") {
t.Fatalf("startup output queried the cursor before readline: %q", output)
}
const reminder = "Check for updates with the 'update' command"
if got := strings.Contains(output, reminder); got != test.wantReminder {
t.Fatalf("update reminder present = %t, want %t; output: %q", got, test.wantReminder, output)
}
})
}
}
func captureStartupOutput(t *testing.T, writeOutput func()) string {
t.Helper()
// An unexpected cursor query must fail the assertion rather than wait
// indefinitely for a terminal response while the test is running.
stdin, err := os.Open(os.DevNull)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = stdin.Close() })
previousStdin := os.Stdin
os.Stdin = stdin
t.Cleanup(func() { os.Stdin = previousStdin })
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = reader.Close() })
t.Cleanup(func() { _ = writer.Close() })
previousStdout := os.Stdout
os.Stdout = writer
t.Cleanup(func() { os.Stdout = previousStdout })
writeOutput()
os.Stdout = previousStdout
if err := writer.Close(); err != nil {
t.Fatal(err)
}
output, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
return string(output)
}
+60 -40
View File
@@ -3,57 +3,77 @@ package console
import (
"sync"
"testing"
"testing/synctest"
"time"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestRemoveEventListenerUnblocksInFlightDispatchAndClosesReceiver(t *testing.T) {
con := &SliverClient{EventListeners: &sync.Map{}}
listenerID, events := con.CreateEventListener()
value, ok := con.EventListeners.Load(listenerID)
if !ok {
t.Fatal("created listener is not registered")
}
listener := value.(*consoleEventListener)
for index := 0; index < cap(listener.events); index++ {
listener.events <- &clientpb.Event{EventType: "buffered"}
}
dispatchDone := make(chan struct{})
go func() {
listener.send(&clientpb.Event{EventType: "in-flight"})
close(dispatchDone)
}()
con.RemoveEventListener(listenerID)
select {
case <-dispatchDone:
case <-time.After(time.Second):
t.Fatal("listener removal did not unblock in-flight dispatch")
}
if _, stillRegistered := con.EventListeners.Load(listenerID); stillRegistered {
t.Fatal("removed listener remains registered")
}
for event := range events {
_ = event
}
}
func TestConcurrentEventDispatchAndRemovalDoesNotPanic(_ *testing.T) {
for iteration := 0; iteration < 1_000; iteration++ {
synctest.Test(t, func(t *testing.T) {
con := &SliverClient{EventListeners: &sync.Map{}}
listenerID, events := con.CreateEventListener()
value, ok := con.EventListeners.Load(listenerID)
if !ok {
t.Fatal("created listener is not registered")
}
listener := value.(*consoleEventListener)
buffered := &clientpb.Event{EventType: "buffered"}
for index := 0; index < cap(listener.events); index++ {
listener.events <- buffered
}
dispatchDone := make(chan struct{})
go func() {
con.triggerEventListeners(&clientpb.Event{EventType: "race"})
event := &clientpb.Event{EventType: "in-flight"}
con.triggerEventListeners(event)
close(dispatchDone)
}()
con.RemoveEventListener(listenerID)
<-dispatchDone
for event := range events {
_ = event
// The full buffer and Wait establish that send is blocked before removal.
synctest.Wait()
select {
case <-dispatchDone:
t.Fatal("dispatch completed before listener removal")
default:
}
}
removeDone := make(chan struct{})
go func() {
con.RemoveEventListener(listenerID)
// Dispatch and repeated removal after closure must remain harmless.
con.triggerEventListeners(&clientpb.Event{EventType: "removed"})
listener.send(&clientpb.Event{EventType: "closed"})
con.RemoveEventListener(listenerID)
close(removeDone)
}()
for _, done := range []<-chan struct{}{dispatchDone, removeDone} {
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("listener removal and in-flight dispatch did not complete")
}
}
if _, stillRegistered := con.EventListeners.Load(listenerID); stillRegistered {
t.Fatal("removed listener remains registered")
}
deadline := time.After(time.Second)
for received := 0; ; received++ {
select {
case event, open := <-events:
if !open {
if received != cap(listener.events) {
t.Fatalf("received %d buffered events, want %d", received, cap(listener.events))
}
return
}
if event != buffered {
t.Fatalf("unexpected event after removal: %v", event)
}
case <-deadline:
t.Fatal("removed listener receiver did not close")
}
}
})
}
+156
View File
@@ -0,0 +1,156 @@
package console
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"reflect"
"strings"
"testing"
consts "github.com/bishopfox/sliver/client/constants"
reefconsole "github.com/reeflective/console"
"github.com/spf13/cobra"
)
func pipedTestCommands(got *[]string) reefconsole.Commands {
return func() *cobra.Command {
root := &cobra.Command{Use: "root", SilenceErrors: true, SilenceUsage: true}
root.AddCommand(&cobra.Command{
Use: "record value",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
*got = append(*got, args[0])
return nil
},
})
root.AddCommand(&cobra.Command{
Use: "fail",
RunE: func(_ *cobra.Command, _ []string) error {
return errors.New("command failed")
},
})
return root
}
}
func withPipedStdin(t *testing.T, input string) *os.File {
t.Helper()
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(writer, input); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = reader.Close() })
oldStdin := os.Stdin
os.Stdin = reader
t.Cleanup(func() { os.Stdin = oldStdin })
oldIsTerminal := isTerminal
isTerminal = func(int) bool { return false }
t.Cleanup(func() { isTerminal = oldIsTerminal })
return reader
}
func TestStartClientRunsPipedCommandsInOrder(t *testing.T) {
withPipedStdin(t, "\r\nrecord 'one two'\r\nrecord three\n record four ")
var got []string
con := NewConsole(false)
con.Settings.ConsoleLogs = false
cmds := pipedTestCommands(&got)
if err := StartClient(con, nil, nil, nil, cmds, cmds, true, ""); err != nil {
t.Fatalf("run piped commands: %v", err)
}
if want := []string{"one two", "three", "four"}; !reflect.DeepEqual(got, want) {
t.Fatalf("commands = %q, want %q", got, want)
}
if con.IsCLI {
t.Fatal("piped console should retain console mode for menu switching")
}
}
func TestStartClientPipedCommandsFollowMenuSwitch(t *testing.T) {
withPipedStdin(t, "use\nrecord implant\n")
var got []string
con := NewConsole(false)
con.Settings.ConsoleLogs = false
serverCmds := func() *cobra.Command {
root := &cobra.Command{Use: "root", SilenceErrors: true, SilenceUsage: true}
root.AddCommand(&cobra.Command{Use: "use", Run: func(_ *cobra.Command, _ []string) {
con.App.SwitchMenu(consts.ImplantMenu)
}})
return root
}
implantCmds := pipedTestCommands(&got)
if err := StartClient(con, nil, nil, nil, serverCmds, implantCmds, true, ""); err != nil {
t.Fatalf("run piped commands across menus: %v", err)
}
if want := []string{"implant"}; !reflect.DeepEqual(got, want) {
t.Fatalf("commands = %q, want %q", got, want)
}
}
func TestStartClientRCPrecedesPipedStdin(t *testing.T) {
reader := withPipedStdin(t, "record piped\n")
var got []string
con := NewConsole(false)
con.Settings.ConsoleLogs = false
cmds := pipedTestCommands(&got)
if err := StartClient(con, nil, nil, nil, cmds, cmds, true, "record rc\n"); err != nil {
t.Fatalf("run rc script: %v", err)
}
if want := []string{"rc"}; !reflect.DeepEqual(got, want) {
t.Fatalf("commands = %q, want %q", got, want)
}
remaining, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
if string(remaining) != "record piped\n" {
t.Fatalf("piped stdin was consumed after --rc: %q", remaining)
}
}
func TestStartClientEmptyPipeExitsAtEOF(t *testing.T) {
withPipedStdin(t, "")
con := NewConsole(false)
con.Settings.ConsoleLogs = false
var got []string
if err := StartClient(con, nil, nil, nil, pipedTestCommands(&got), nil, true, ""); err != nil {
t.Fatalf("empty stdin: %v", err)
}
}
func TestRunCommandScriptContinuesAndReportsErrors(t *testing.T) {
var got []string
con := NewConsole(false)
var output bytes.Buffer
con.printf = func(format string, args ...any) (int, error) {
return fmt.Fprintf(&output, format, args...)
}
cmds := pipedTestCommands(&got)
input := strings.NewReader("record first\nfail\nrecord second\nrecord 'unterminated\nrecord third\n")
err := con.runCommandScript(cmds, cmds, input, "stdin")
if err == nil || !strings.Contains(err.Error(), "2 error(s)") {
t.Fatalf("error = %v, want two failures", err)
}
if want := []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) {
t.Fatalf("commands = %q, want %q", got, want)
}
for _, line := range []string{"stdin line 2", "stdin line 4"} {
if !strings.Contains(output.String(), line) {
t.Fatalf("missing %q in output %q", line, output.String())
}
}
}
+6
View File
@@ -1598,6 +1598,8 @@ func TestSocksReceiveLoopOverloadedConnectionDoesNotBlockHealthyConnection(t *te
t.Fatal("healthy SOCKS terminal did not close after ordered data")
}
// Start local shutdown before releasing the fake receive stream.
proxy.beginStop()
stream.results <- socksReceiveTestResult{err: io.EOF}
if err := proxy.Stop(); err != nil {
t.Fatalf("stop SOCKS receive test proxy: %v", err)
@@ -1689,6 +1691,8 @@ func TestSocksReceiveLoopRejectsInvalidAcknowledgementAtExactTunnelScope(t *test
t.Fatal("invalid acknowledgement closed healthy sibling")
}
// Start local shutdown before releasing the fake receive stream.
proxy.beginStop()
stream.results <- socksReceiveTestResult{err: io.EOF}
if err := proxy.Stop(); err != nil {
t.Fatalf("stop acknowledgement test proxy: %v", err)
@@ -1754,6 +1758,8 @@ func TestSocksReceiveLoopAppliesValidAcknowledgementOutOfBand(t *testing.T) {
}
}
// Start local shutdown before releasing the fake receive stream.
proxy.beginStop()
stream.results <- socksReceiveTestResult{err: io.EOF}
if err := proxy.Stop(); err != nil {
t.Fatalf("stop valid acknowledgement test proxy: %v", err)
+95 -105
View File
@@ -16,113 +16,103 @@ func TestMultiplayerConnectModeDefaultsToAuto(t *testing.T) {
}
}
func TestSelectMultiplayerDialStrategyAutoWithoutWGConfigUsesDirectMTLS(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct mTLS strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyAutoLegacyWGConfigUsesDirectMTLS(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
t.Fatalf("expected direct mTLS strategy, got %v", strategy)
}
}
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",
func TestSelectMultiplayerDialStrategy(t *testing.T) {
for _, test := range []struct {
name string
mode MultiplayerConnectMode
config *assets.ClientConfig
want multiplayerDialStrategy
wantErr error
wantErrText string
}{
{
name: "auto without WireGuard config",
mode: MultiplayerConnectAuto,
config: &assets.ClientConfig{},
want: multiplayerDialDirect,
},
})
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)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
if !errors.Is(err, ErrMissingWireGuardConfig) {
t.Fatalf("expected missing WG config error, got %v", err)
}
}
func TestSelectMultiplayerDialStrategyEnableWGRejectsIncompleteWGConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub",
ClientIP: "100.64.0.2",
{
name: "auto with legacy WireGuard config",
mode: MultiplayerConnectAuto,
config: completeWGClientConfig(false),
want: multiplayerDialDirect,
},
})
if !errors.Is(err, ErrIncompleteWireGuardConfig) {
t.Fatalf("expected incomplete WG config error, got %v", err)
}
}
func TestSelectMultiplayerDialStrategyEnableWGUsesCompleteLegacyConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialWireGuard {
t.Fatalf("expected WireGuard strategy, got %v", strategy)
}
}
func TestSelectMultiplayerDialStrategyDisableWGOverridesEnabledConfig(t *testing.T) {
setTestMultiplayerConnectMode(t, MultiplayerConnectDisableWG)
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
if err != nil {
t.Fatalf("select dial strategy: %v", err)
}
if strategy != multiplayerDialDirect {
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)
{
name: "auto with incomplete enabled config",
mode: MultiplayerConnectAuto,
config: &assets.ClientConfig{WG: &assets.ClientWGConfig{
Enabled: true, ServerPubKey: "server-pub", ClientIP: "100.64.0.2",
}},
want: multiplayerDialDirect,
wantErr: ErrIncompleteWireGuardConfig,
},
{
name: "auto with enabled WireGuard config",
mode: MultiplayerConnectAuto,
config: completeWGClientConfig(true),
want: multiplayerDialWireGuard,
},
{
name: "enable with missing WireGuard config",
mode: MultiplayerConnectEnableWG,
config: &assets.ClientConfig{},
want: multiplayerDialDirect,
wantErr: ErrMissingWireGuardConfig,
},
{
name: "enable with incomplete WireGuard config",
mode: MultiplayerConnectEnableWG,
config: &assets.ClientConfig{WG: &assets.ClientWGConfig{
ServerPubKey: "server-pub", ClientIP: "100.64.0.2",
}},
want: multiplayerDialDirect,
wantErr: ErrIncompleteWireGuardConfig,
},
{
name: "enable with complete legacy config",
mode: MultiplayerConnectEnableWG,
config: completeWGClientConfig(false),
want: multiplayerDialWireGuard,
},
{
name: "disable overrides enabled config",
mode: MultiplayerConnectDisableWG,
config: completeWGClientConfig(true),
want: multiplayerDialDirect,
},
{
name: "invalid mode",
mode: MultiplayerConnectMode(255),
config: completeWGClientConfig(true),
want: multiplayerDialDirect,
wantErrText: "invalid multiplayer connect mode 255",
},
{
name: "nil config",
mode: MultiplayerConnectAuto,
want: multiplayerDialDirect,
wantErrText: "client config is required",
},
} {
t.Run(test.name, func(t *testing.T) {
setTestMultiplayerConnectMode(t, test.mode)
strategy, err := selectMultiplayerDialStrategy(test.config)
if strategy != test.want {
t.Errorf("dial strategy = %v, want %v", strategy, test.want)
}
switch {
case test.wantErr != nil:
if !errors.Is(err, test.wantErr) {
t.Fatalf("select dial strategy error = %v, want %v", err, test.wantErr)
}
case test.wantErrText != "":
if err == nil || err.Error() != test.wantErrText {
t.Fatalf("select dial strategy error = %v, want %q", err, test.wantErrText)
}
case err != nil:
t.Fatalf("select dial strategy: %v", err)
}
})
}
}
+4 -2
View File
@@ -1,5 +1,7 @@
# Sliver AI Agentic Loop
**Historical design note:** This document describes the initial agentic-loop port. The current tool surface includes operations that modify remote state and is not read-only.
## Goal
Port the example agentic loop from `examples/codex/codex-rs` into Sliver's AI chat so the server can:
@@ -141,7 +143,7 @@ If anything fails:
## Tool Surface
The first port exposes a safe read-only tool set:
The initial port exposed a read-only tool set:
- `list_sessions_and_beacons`
- `fs_ls`
@@ -221,7 +223,7 @@ Because UI-only items live in the database:
This port intentionally differs from the example in a few places.
- Sliver currently uses non-streaming Responses API requests, so reasoning items arrive as completed items rather than token deltas.
- Tool execution is currently a bounded read-only server-side function set, not the full Codex local tool surface.
- The initial port used a bounded read-only server-side function set, not the full Codex local tool surface.
- Sliver persists intermediate items in its conversation DB rather than only in a transient thread runtime.
The important invariant is still the same:
+4 -4
View File
@@ -5,18 +5,18 @@
- `components/` hosts reusable UI; `util/` stores loaders such as `Docs` and `Themes`.
- `public/` serves static assets and the generated `docs.json` consumed by search.
- `prebuild/` scripts run during `next build` to turn Markdown into JSON payloads.
- Tailwind config sits in `tailwind.config.ts`; shared CSS lives in `styles/`.
- Tailwind config sits in `tailwind.config.js`; shared CSS lives in `styles/`.
## Build, Test, and Development Commands
- `npm install` sets up dependencies.
- `npm run dev` starts the docs server with hot reload on `http://localhost:3000`.
- `npm run build` exports the static site and regenerates `public/docs.json` plus tutorials JSON.
- `npm run start` serves the last build for smoke tests.
- `npm run lint` runs the Next.js ESLint suite.
- `python3 -m http.server 8000 --directory out` serves the static build for smoke tests; `next start` does not support static exports.
- `npm run lint` runs ESLint.
- `npm run offline` builds, then zips `out/` for offline distribution.
## Coding Style & Naming Conventions
Use TypeScript with 2-space indentation and ES module imports. Keep React components and files in `components/` PascalCase, helpers camelCase, and Markdown files kebab-case so search slugs stay predictable. Favor function components, hooks, and Tailwind utility classes; let ESLint autofix formatting issues and keep shared constants in `util/`.
Use TypeScript with 2-space indentation and ES module imports. Keep React components PascalCase and helpers camelCase. Markdown filename stems are exact document names used by `?name=` links; follow existing title-case names and preserve filenames to avoid breaking links. Favor function components, hooks, and Tailwind utility classes; let ESLint autofix formatting issues and keep shared constants in `util/`.
## Testing Guidelines
No unit-test runner ships with this project, so treat `npm run lint` and `npm run build` as required gates. When adding interactive behavior, create targeted Playwright or Cypress checks under a `__tests__/` directory and describe how to run them in the PR. Always smoke-test the search panel locally after updating Markdown to confirm newly generated `docs.json` entries render.
+2 -2
View File
@@ -11,7 +11,7 @@ npm install
npm run dev
```
**NOTE:** The markdown is compiled into a static JSON object at build time. This means if you edit a `.md` file you will need to restart the dev server to see your changes.
Markdown is compiled into JSON for the site. `npm run dev` watches Markdown files and regenerates this content automatically.
### Offline Docs
@@ -27,7 +27,7 @@ This will produce a `www.zip` file that contains the static html and JavaScript
```bash
unzip -o www.zip
cd out/
python -m http.server 8000
python3 -m http.server 8000
```
Then open your browser to `http://localhost:8000/` to view the documentation.
+1 -1
View File
@@ -304,7 +304,7 @@ const MarkdownViewer = (props: MarkdownProps) => {
a(anchorProps) {
const { href, children, className, ...rest } = anchorProps;
if (href?.startsWith("/")) {
if (href?.startsWith("/") || href?.startsWith("#")) {
return (
<a
{...rest}
+1045 -1021
View File
File diff suppressed because it is too large. Load diff
+30 -30
View File
@@ -13,48 +13,48 @@
"postinstall": "node ./prebuild/run-prebuild.js"
},
"dependencies": {
"@fontsource/fira-code": "^5.2.7",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1",
"@heroui/react": "^3.0.3",
"@heroui/styles": "^3.0.3",
"@fontsource/fira-code": "^5.3.0",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"@heroui/react": "^3.2.6",
"@heroui/styles": "^3.2.6",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/postcss": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"asciinema-player": "^3.15.1",
"fuse.js": "^7.3.0",
"@tailwindcss/postcss": "^4.3.3",
"@tanstack/react-query": "^5.104.1",
"asciinema-player": "^3.17.0",
"fuse.js": "^7.5.0",
"lunr": "^2.3.9",
"mermaid": "^11.16.1",
"next": "^16.3.3",
"mermaid": "^11.17.2",
"next": "^16.3.8",
"next-themes": "^0.4.6",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/typography": "^0.5.20",
"@types/lunr": "^2.3.7",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/node": "^25.9.9",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.5.0",
"concurrently": "^9.2.1",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.4",
"postcss": "^8.5.13",
"tailwindcss": "^4.2.4",
"autoprefixer": "^10.6.1",
"concurrently": "^9.2.4",
"eslint": "^9.39.5",
"eslint-config-next": "^16.3.8",
"postcss": "^8.5.28",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
},
"overrides": {
"dompurify": "^3.4.13",
"postcss": "^8.5.13",
"sharp": "^0.35.4",
"shell-quote": "^1.8.4",
"uuid": "^14.0.0"
"dompurify": "^3.4.16",
"postcss": "^8.5.28",
"sharp": "^0.35.5",
"shell-quote": "^1.12.0",
"uuid": "^14.0.2"
}
}
+5 -5
View File
@@ -121,7 +121,7 @@ const DocsIndexPage: NextPage = () => {
<Head>
<title>{name ? `${name} · Sliver Docs` : "Sliver Documentation"}</title>
</Head>
<div className="mx-auto w-full max-w-[90rem] px-4 pt-8 sm:px-6 lg:px-8 lg:pt-10">
<div className="w-full px-4 pt-8 sm:px-6 lg:px-8 lg:pt-10">
<div className="mb-8 lg:hidden">
<div className="flex items-center gap-3">
<span className="flex size-9 items-center justify-center rounded-2xl bg-surface-secondary text-accent">
@@ -202,10 +202,10 @@ const DocsIndexPage: NextPage = () => {
<div
key={name || "documentation-overview"}
className="min-w-0 pb-16 lg:pr-4"
className="min-w-0 pb-16"
>
{name !== "" ? (
<article className="mx-auto w-full max-w-4xl">
<article className="w-full min-w-0">
<header className="mb-8 border-b border-separator/70 pb-8">
<p className="text-sm font-medium text-accent">Reference</p>
<h1 className="mt-2 text-balance text-4xl font-semibold tracking-tight text-foreground sm:text-5xl">
@@ -219,11 +219,11 @@ const DocsIndexPage: NextPage = () => {
/>
</article>
) : (
<div className="mx-auto w-full max-w-5xl pt-4 lg:pt-8">
<div className="w-full min-w-0 pt-4 lg:pt-8">
<h1 className="text-sm font-medium text-accent">
Sliver reference
</h1>
<p className="mt-4 max-w-2xl text-lg leading-8 text-muted">
<p className="mt-4 text-lg leading-8 text-muted">
Browse configuration, transports, extensions, payloads, and
troubleshooting guidance for the Sliver framework.
</p>
@@ -65,7 +65,7 @@ From an end-user perspective there's not much of a difference between the two, e
An alias is essentially just a thin wrapper around the existing `sideload` and `execute-assembly` commands, and aliases cannot have dependencies.
An extension is a shared library that is reflectively loaded into the Sliver implant process, and is passed several callbacks to return data to the implant. As such these extensions must implement the Sliver API. Extensions may also have dependencies, which are other extensions. For example, the COFF Loader is a DLL extension that loads and executes BOFs, in turn BOFs simply extensions that rely on the COFF Loader as a dependency. These types of extensions do not need to implement any Sliver-specific API, since the Sliver API is abstracted by their dependency.
An extension can be a native shared library or a BOF. Shared libraries are reflectively loaded into the Sliver implant process and must implement the Sliver extension API to return output. BOFs use Beacon callbacks and can execute through the built-in Reflektor executor. The COFF Loader DLL remains available as a dependency for legacy Windows BOF execution. For supported platforms and basic examples, see [Cross-platform BOFs](/docs?name=Cross-platform+BOFs).
## Aliases
@@ -170,6 +170,6 @@ As the alias support relies on Sliver side loading capabilities, please make sur
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`).
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. BOF extensions can use built-in Reflektor execution without a loader dependency, or retain a COFF Loader dependency for legacy Windows execution. See [BOF and COFF Support](/docs?name=BOF+and+COFF+Support) for executor selection and fallback behavior.
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.
+2 -2
View File
@@ -1,6 +1,6 @@
The armory is the Sliver Alias and Extension package manager. It allows you to automatically install various third-party tools such as BOFs and .NET tooling.
The armory downloads packages from `github.com` and `api.github.com` so you'll need an internet connection in order for the command to work. The command does support proxies (see `--help`) and after an alias or extension is installed an internet connection is not required to execute the alias/extension.
The official armory downloads packages from `github.com` and `api.github.com` so you'll need an internet connection in order for the command to work. The command does support proxies (see `--help`) and after an alias or extension is installed an internet connection is not required to execute the alias/extension.
Aliases and extensions are installed on the "sliver client"-side, and thus are not shared among operators in [multiplayer mode](/docs?name=Multi-player+Mode).
@@ -50,4 +50,4 @@ If you also copied a package into the server-side AI store, remove that copy sep
## Private Armories
Sliver has experimental support for self-hosted private armories, but I have not gotten around to testing and writing the documentation for these, so you'll have to read through the source code to figure out how they work for now. I'll eventually update this documentation and release a reference implementation. If you do play around with this, just know the design is subject to change before becoming a real feature.
See [Private Armories](/docs?name=Private+Armories) for hosting options, signing and authentication, and client configuration using the standalone [Private Armory](https://github.com/sliverarmory/private-armory) service.
+6 -4
View File
@@ -1,10 +1,12 @@
Sliver keeps an audit log of every command and its arguments executed by the server (including commands executed by operators in multiplayer mode), as well as most events (such as a new session or beacon connecting to the server). The audit log's intended use is for after-action analysis; providing a detailed history of the entire engagement, including which commands were executed on which hosts when. This will include any commands executed by any operator on any session. Note some console commands only perform actions on the "client-side" and may not appear in the audit log, but will still appear in the client's command history. Additionally, interactive commands (e.g., `shell`) may not appear in the logs aside from the initial usage of the `shell` command.
Sliver's audit log records server-side unary gRPC requests, including multiplayer operator requests, plus session and beacon registrations. It supports after-action analysis, but request entries are written before execution and do not confirm success. Client-only actions and interactive command input (e.g., within `shell`) are not recorded in the audit log.
By default the audit log is located on the server at: `~/.sliver/logs/audit.json`. However, this can be changed by modifying the [`SLIVER_ROOT_DIR`](/docs?name=Environment+Variables) environment variable.
By default, logs rotate at 50 MB and retain up to 20 compressed backups for at most 90 days. These limits are configurable under `logs` in `server.yaml`.
#### Parsing Audit Logs
The audit log is stored in a newline delimited (one object per line) nested-JSON format designed to be primarily machine readable, an example entry is shown below:
The audit log uses a newline delimited (one object per line), nested-JSON format. A simplified RPC entry is shown below:
```
{"level":"info","msg":"{\"request\":\"{\\\"Port\\\":8888}\",\"method\":\"/rpcpb.SliverRPC/StartMTLSListener\"}","time":"2021-06-16T10:22:54-05:00"}
@@ -14,6 +16,6 @@ The audit log is stored in a newline delimited (one object per line) nested-JSON
The top level JSON should always contain:
- `level` - The level indicates the type of action performed. Currently, `info` indicates commands and `warn` indicates events.
- `msg` - A JSON object encoded as a string. This object should always contain a `request` and a `method`. The contents of `method` indicate which command was executed, `request` will contain parameters to that command. The contents of `request` will vary depending on the command, but it will be based on the corresponding gRPC/Protobuf message.
- `level` - Currently, `info` indicates RPC requests and `warn` indicates registration events.
- `msg` - A JSON object encoded as a string. RPC entries contain `request` (another JSON-encoded string), `method`, `user`, `remote_ip`, and optional `session` or `beacon` details. Registration events instead contain `Session` or `Beacon` and `Register`, without `request` or `method`.
- `time` - The server's timestamp of the log entry
@@ -1,5 +1,7 @@
Sliver supports the loading and execution of BOFs and COFFs, generally no code changes are needed to use BOFs with a Sliver implant. However, you may need to define a manifest file so that Sliver is aware of BOF arguments and their types.
For platform formats, supported architectures, and basic C examples for Windows, macOS, and Linux, see [Cross-platform BOFs](/docs?name=Cross-platform+BOFs).
### BOF Extensions
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.
@@ -23,6 +23,7 @@
- https://blog.ecapuano.com/p/so-you-want-to-be-a-soc-analyst-20
- https://wsummerhill.github.io/redteam/2023/07/25/Sliver-C2-Usage-for-Red-Teams.html
- https://rootsecdev.medium.com/hacking-active-directory-with-sliver-c2-19d7ceabbf13
- [HackITA — Sliver C2: Guide to Listeners, Beacons, Implants and Armory](https://en.hackita.it/articoli/silver/)
- https://dev.to/living_syn/sliver-and-cursed-chrome-for-post-exploitation-4gnk
### Opsec Blogs
@@ -1,8 +1,8 @@
You'll want to compile Sliver from a MacOS or Linux machine. Compiling from native Windows is possible in theory, but none of the scripts are designed to run on Windows. You can cross-compile the Windows server/client binaries from a better operating system like Linux or MacOS. If you only have a Windows machine, you should be able to use the Windows Subsystem for Linux (WSL).
Sliver can be compiled on Linux, macOS, and native Windows. Windows users can also build through the Windows Subsystem for Linux (WSL).
To compile from source you'll need:
- Go v1.27.1 or later
- Go v1.27.3 or later
- `make` (on MacOS you may need to install XCode and accompanying cli tools)
```asciinema
@@ -26,7 +26,7 @@ $ make
This will create `sliver-server` and `sliver-client` binaries.
Sliver embeds its own copy of the Go compiler and a few internal tools. The first time you run `make`, a bash script will download these assets to your local system. This means the first build will take longer than subsequent builds, especially if your internet connection is slow.
Sliver embeds its own copy of the Go compiler and a few internal tools. The first time you run `make`, a Go asset tool will download these assets to your local system. This means the first build will take longer than subsequent builds, especially if your internet connection is slow.
### Cross-compile to Specific Platforms
@@ -56,23 +56,25 @@ docker build --target production -t sliver .
{"src": "/asciinema/sliver-docker-production.cast", "cols": "132"}
```
The Docker build includes mingw and Metasploit, so it can take a while to build from scratch but Docker should cache the layers effectively. Sliver will also run its unit tests as part of the build, and that takes a few minutes too.
The production Docker image includes Metasploit, so it can take a while to build from scratch, but Docker caches the layers. Unit tests run only when building the separate `test` target.
### Windows Builds
If all you have is a Windows machine, the easiest way to build Sliver is using [WSL](https://docs.microsoft.com/en-us/windows/wsl/install-win10) and following the Linux/cross-compile instructions above. To cross-compile a Windows binary use `make windows-amd64` and copy it to your Windows file system (i.e. `/mnt/c/Users/foo/Desktop`) and, you will need to run it using a terminal that supports ANSI sequences such as the [Windows Terminal](https://github.com/microsoft/terminal).
For a native Windows build, install Go, Git, and GNU Make, then run `make windows-amd64` from the repository root. Alternatively, use [WSL](https://docs.microsoft.com/en-us/windows/wsl/install-win10) and follow the Linux/cross-compile instructions above. Run the resulting binaries in a terminal that supports ANSI sequences, such as [Windows Terminal](https://github.com/microsoft/terminal).
# Developers
If you want to modify any of the `.proto` files you'll need to set up a few additional tools to regenerate the `.pb.go` files.
- Protoc libprotoc v26.1 or later
- Protoc-gen-go v1.27.1
- Protoc-gen-go-grpc v1.2.0
The checked-in bindings record these tool versions:
- `protoc` v7.36.2 (as reported in the generated headers)
- `protoc-gen-go` v1.36.11
- `protoc-gen-go-grpc` v1.6.1
#### `protoc`
First install your platform's version of `protoc` v3.19.4 or later:
Install a compatible `protoc` release for your platform:
https://github.com/protocolbuffers/protobuf/releases/latest
@@ -83,8 +85,8 @@ Ensure that the correct `protoc` version is on your `$PATH`, you can check with
Assuming `$GOPATH/bin` is on your `$PATH` simply run the following commands to install the appropriate versions of `protoc-gen-go` and `protoc-gen-go-grpc`:
```
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.27.1
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2.0
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
```
Ensure that these are both on your `$PATH` after running the commands; if not, you probably need to add `$GOPATH/bin` to your `$PATH`. To regenerate the Protobuf and gRPC files run:
@@ -4,6 +4,8 @@ The Sliver server configuration file is located in the `configs` sub-directory o
If no configuration file exists, a default YAML configuration is generated and written to disk on startup. If a legacy `server.json` file exists, Sliver reads it, writes `server.yaml`, and renames the old file to `.server.json-old`.
AI configuration is stored separately in `configs/ai.yaml`. A legacy inline `ai` block is migrated only when that file does not already exist.
#### Default Server Config
```yaml
@@ -18,20 +20,14 @@ logs:
grpc_unary_payloads: false
grpc_stream_payloads: false
tls_key_logger: false
max_size_mb: 50
max_backups: 20
max_age_days: 90
compress: true
grpc:
keepalive:
min_time_seconds: 30
permit_without_stream: true
ai:
provider: ""
model: ""
thinking_level: ""
anthropic:
api_key: ""
base_url: ""
openai:
api_key: ""
base_url: ""
watch_tower: null
go_proxy: ""
http_default:
@@ -61,15 +57,11 @@ cxx: {}
- `grpc_unary_payloads` - Log gRPC unary payloads.
- `grpc_stream_payloads` - Log gRPC streaming payloads.
- `tls_key_logger` - Enable TLS key logging (for debugging/traffic analysis only; sensitive).
- `max_size_mb`, `max_backups`, `max_age_days` - Log rotation size in MB, backup count, and maximum backup age in days (defaults: `50`, `20`, `90`).
- `compress` - Compress rotated logs (default: `true`).
- `grpc.keepalive` - gRPC keepalive enforcement configuration.
- `min_time_seconds` - Minimum time (seconds) between client pings before sending GOAWAY (`too_many_pings`).
- `permit_without_stream` - Allow client pings when there are no active streams.
- `ai` - Optional server-side AI defaults and provider credentials.
- `provider` - Default AI provider (`openai` or `anthropic`).
- `model` - Optional default model identifier.
- `thinking_level` - Optional reasoning level (for example `low`, `medium`, `high`, or `disabled`).
- `anthropic.api_key` / `openai.api_key` - Provider API key.
- `anthropic.base_url` / `openai.base_url` - Optional provider API endpoint override.
- `watch_tower` - Optional API keys for Watchtower integrations.
- `vt_api_key` - VirusTotal API key.
- `xforce_api_key` - IBM X-Force API key.
@@ -0,0 +1,194 @@
Sliver's built-in [Reflektor](https://github.com/sliverarmory/reflektor/tree/v0.0.8) executor runs Beacon Object Files (BOFs) on Windows, macOS, and Linux. A BOF is a relocatable native object with an entry point such as `go(char *args, int length)` and Beacon callbacks for arguments and output. It executes inside the implant process.
This page covers writing and compiling basic BOFs. For Armory installation, argument manifests, and compatibility with older components, see [BOF and COFF Support](/docs?name=BOF+and+COFF+Support). Projects with CNA wrappers can also use [OPFOR](/docs?name=OPFOR).
## Platform support
The following targets support built-in BOF execution in Sliver:
| Implant OS | Implant architecture (`GOARCH`) | Object format | Typical compiler |
| --- | --- | --- | --- |
| Windows (`windows`) | `386`, `amd64`, `arm64` | COFF relocatable object | MinGW-w64 GCC or Clang targeting Windows |
| macOS (`darwin`) | `amd64`, `arm64` | Mach-O relocatable object (`MH_OBJECT`) | Apple Clang |
| Linux (`linux`) | `386`, `amd64`, `arm64` | ELF relocatable object (`ET_REL`) | GCC or Clang targeting Linux |
The object must match the **implant's** operating system and CPU architecture, regardless of the client or server platform. `amd64` means x86-64; `386` means x86; `arm64` means AArch64. The `.o` suffix alone does not identify the format. Compile a separate artifact for each OS/architecture pair; renaming a Windows COFF object does not turn it into a Linux or macOS BOF.
Reflektor also accepts legacy ELF BOFs on macOS when they match the host architecture and ABI. Use native Mach-O for new macOS BOFs. Reflektor's standalone library supports additional targets, but the table above describes Sliver's built-in integration.
Use compatible client, server, and implant versions with the `bof_v1` capability. Setting `"bof_executor": "reflektor"` explicitly selects built-in execution when that capability is available. The examples below require this support and do not need a COFF Loader dependency. See [BOF and COFF Support](/docs?name=BOF+and+COFF+Support) for the optional legacy fallback.
## Basic examples
These three examples take no arguments and emit one message through `BeaconOutput`. Save the source files in a directory named `hello-bof`. Run the compiler commands from that directory; each command produces an object with `-c` rather than linking an executable or shared library.
The declarations are included directly to keep the examples self-contained. Windows imports use `__declspec(dllimport)`; Unix platforms use normal external symbols. The supported targets use 32-bit `int` for the entry-point length and Beacon callback parameters.
### Windows: COFF
Save as `hello-windows.c`:
```c
__declspec(dllimport) void BeaconOutput(int type, char *data, int length);
void go(char *args, int length) {
static char message[] = "Hello from a Windows BOF!\n";
(void)args;
(void)length;
BeaconOutput(0, message, (int)(sizeof(message) - 1));
}
```
With a MinGW-w64 cross compiler, build the Windows x86-64 object:
```bash
x86_64-w64-mingw32-gcc -Os -c -fno-stack-protector \
-fno-unwind-tables -fno-asynchronous-unwind-tables \
hello-windows.c -o hello.windows.amd64.o
```
For a Windows x86 implant, use `i686-w64-mingw32-gcc` with the same flags and name the result `hello.windows.386.o`. For Windows ARM64, Clang can compile this header-free example:
```bash
clang --target=aarch64-w64-windows-gnu -Os -c -fno-stack-protector \
-fno-unwind-tables -fno-asynchronous-unwind-tables \
hello-windows.c -o hello.windows.arm64.o
```
Existing Windows BOFs may import system functions using names such as `KERNEL32$GetCurrentProcessId`. Those imports and their calling conventions remain Windows-specific; port the source and imports before compiling for another OS.
### macOS: Mach-O
Save as `hello-macos.c`:
```c
extern void BeaconOutput(int type, char *data, int length);
void go(char *args, int length) {
static char message[] = "Hello from a macOS BOF!\n";
(void)args;
(void)length;
BeaconOutput(0, message, (int)(sizeof(message) - 1));
}
```
On macOS, install the Xcode Command Line Tools (`xcode-select --install`) if Clang is unavailable. Build separate objects for Apple Silicon and Intel implants:
```bash
clang -arch arm64 -Os -c -fno-stack-protector \
-fno-unwind-tables -fno-asynchronous-unwind-tables \
hello-macos.c -o hello.darwin.arm64.o
clang -arch x86_64 -Os -c -fno-stack-protector \
-fno-unwind-tables -fno-asynchronous-unwind-tables \
hello-macos.c -o hello.darwin.amd64.o
```
Keep these as separate thin Mach-O objects. Reflektor does not accept a universal/fat BOF object. Clang prefixes the C symbols with `_` in Mach-O; the default `go` entry-point selection handles `_go` automatically.
**Apple Silicon:** Native Mach-O `arm64` BOFs must use `BeaconOutput` for output. Reflektor rejects `BeaconPrintf` and `BeaconFormatPrintf` imports for this format because Apple's variadic calling convention differs from the callback bridge. The example above works on both macOS architectures.
### Linux: ELF
Save as `hello-linux.c`:
```c
extern void BeaconOutput(int type, char *data, int length);
void go(char *args, int length) {
static char message[] = "Hello from a Linux BOF!\n";
(void)args;
(void)length;
BeaconOutput(0, message, (int)(sizeof(message) - 1));
}
```
On an x86-64 Linux build host, use GCC to produce an ELF object:
```bash
gcc -m64 -Os -c -fPIC -fno-stack-protector \
-fno-unwind-tables -fno-asynchronous-unwind-tables \
hello-linux.c -o hello.linux.amd64.o
```
For a Linux x86 implant, replace `-m64` with `-m32` and name the result `hello.linux.386.o`; this requires a compiler with 32-bit target support. For ARM64, use a Linux AArch64 cross compiler:
```bash
aarch64-linux-gnu-gcc -Os -c -fPIC -fno-stack-protector \
-fno-unwind-tables -fno-asynchronous-unwind-tables \
hello-linux.c -o hello.linux.arm64.o
```
On a native ARM64 Linux build host, `gcc` with the same flags as the ARM64 command produces the ARM64 object. Do not use a macOS host's default `clang` or `gcc` command to build a Linux BOF without selecting a Linux target.
## Load and run in Sliver
Save the following `extension.json` beside the compiled objects in `hello-bof`. The command uses Sliver's modern manifest format, with execution settings inside `commands`. Keep only the `files` entries for artifacts you have built; add `386` or Windows `arm64` entries if you built those variants.
```json
{
"name": "hello-bof",
"version": "1.0.0",
"extension_author": "Example Author",
"commands": [
{
"command_name": "hello-bof",
"help": "Print a greeting from a platform-native BOF",
"bof_executor": "reflektor",
"entrypoint": "go",
"files": [
{
"os": "windows",
"arch": "amd64",
"path": "hello.windows.amd64.o"
},
{
"os": "darwin",
"arch": "amd64",
"path": "hello.darwin.amd64.o"
},
{
"os": "darwin",
"arch": "arm64",
"path": "hello.darwin.arm64.o"
},
{
"os": "linux",
"arch": "amd64",
"path": "hello.linux.amd64.o"
},
{
"os": "linux",
"arch": "arm64",
"path": "hello.linux.arm64.o"
}
],
"arguments": []
}
]
}
```
Copy the directory to your operator machine if you compiled on another host, then load it into the Sliver client:
```text
sliver > extensions load /absolute/path/to/hello-bof
sliver > use <session-or-beacon-id>
sliver (selected-target) > hello-bof
```
Sliver selects the artifact using the active session or beacon's exact `os`/`arch` pair. A macOS ARM64 target prints `Hello from a macOS BOF!`; Windows and Linux targets print the corresponding greeting. Sessions return output immediately; beacon output arrives with the asynchronous task result.
For BOFs with arguments, declare them in the manifest in the same order and types consumed by `BeaconDataParse`, `BeaconDataInt`, `BeaconDataShort`, and `BeaconDataExtract`. Sliver packs the arguments into the length-prefixed Beacon format. See the argument type table and conversion example in [BOF and COFF Support](/docs?name=BOF+and+COFF+Support).
## Compatibility and troubleshooting
Reflektor implements the Beacon data, format, and output callbacks, including `BeaconOutput` and supported `BeaconPrintf` calls. It preserves output channel values such as normal output (`0`) and errors (`13`). It does not implement every Beacon integration API: token manipulation, temporary-process, and process-injection callbacks require host integration and fail explicitly when unavailable. Check the [Reflektor BOF documentation](https://github.com/sliverarmory/reflektor/blob/v0.0.8/README.md#beacon-object-files) before importing additional Beacon APIs.
- **No artifact for the target:** Add the exact `files` entry with Go OS/architecture names, such as `darwin`/`arm64`, and the matching object. Paths are relative to `extension.json`; parent-directory traversal is not allowed.
- **Missing built-in support:** Upgrade the client and server and regenerate the implant with BOF support. An older running implant does not gain the capability when only the client is upgraded.
- **Wrong format or machine:** Inspect the object with `file`, `llvm-readobj`, or `objdump`. Check both the OS format and architecture, then rebuild for the implant.
- **Unresolved import:** Use the correct platform ABI and symbol spelling. Windows DLL imports do not carry over to Unix. The examples avoid standard-library and system-library dependencies.
- **Entry point not found:** Export a non-static `go` function with the signature shown above. C++ sources must use `extern "C"` to avoid name mangling. Custom entry points use the exact object symbol name, including Mach-O's leading underscore.
BOFs run native code in the implant process. Keep the entry point short and return normally; crashes or process-exit calls can terminate the implant. Worker threads using Beacon callbacks must finish before the entry point returns because output capture and object lifetime are tied to that execution.
@@ -1,4 +1,4 @@
This script installs the latest version of Sliver as a systemd service, installs Windows cross-compiler dependencies (mingw), and sets up multiplayer for all local users. After running the script, connect locally by running `sliver`.
This script installs the latest version of Sliver as a systemd service and sets up multiplayer for local users. After running the script, connect locally by running `sliver`.
https://sliver.sh/install
@@ -13,7 +13,6 @@ curl https://sliver.sh/install|sudo bash
```
- Installs server binary to `/root/sliver-server`
- Installs mingw
- Runs the server in daemon mode using systemd
- Installs client to `/usr/local/bin/sliver`
- Generates multiplayer configurations for all users with a `/home` directory
@@ -134,7 +134,7 @@ services:
secret_key: "$AWS_SECRET_ACCESS_KEY"
region: "us-east-1"
receivers:
- "https://sns.us-east-1.amazonaws.com/123456789012/my-topic"
- "arn:aws:sns:us-east-1:123456789012:my-topic"
```
##### Bark
@@ -239,13 +239,7 @@ services:
##### Line Notify
```yaml
services:
line_notify:
enabled: true
receivers:
- "$LINE_NOTIFY_TOKEN"
```
LINE Notify [ended service on March 31, 2025](https://developers.line.biz/en/news/2025/04/01/line-notify/); its APIs and the `line_notify` integration are no longer usable.
##### Mail (SMTP)
@@ -304,6 +298,8 @@ services:
##### Microsoft Teams
Use a Teams Workflows webhook. Legacy Office 365 connector webhooks [were retired in May 2026](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/).
```yaml
services:
msteams:
@@ -0,0 +1,56 @@
[Private Armory](https://github.com/sliverarmory/private-armory) is a standalone service for hosting your own Sliver aliases, extensions, and bundles. Each Sliver client connects to it independently; installed packages are still local to that client, as described in [Armory](/docs?name=Armory).
The upstream wiki sometimes calls the project `external-armory`. The current repository is `private-armory`, and its executable is `armory-server`.
## Hosting
The service can run as a standalone executable or in a container. Follow the upstream [Setup](https://github.com/sliverarmory/private-armory/wiki/Setup) or [Docker](https://github.com/sliverarmory/private-armory/wiki/Docker) guide for deployment details.
- Configuration is stored in `<armory-root>/config.json`; restart the service after editing it.
- The supplied Compose configuration publishes port `8888` and persists `./armory-data` at `/data/armory-data`.
- Review TLS and authentication settings before deployment. The supplied `armory.env` disables TLS; HTTPS requires a certificate and private key. Verify the effective authentication settings for the version you deploy rather than assuming the example enables access control.
- Initial setup displays client connection information and a separate administration token. Give clients the client token, never the administration token or a signing private key.
See [Environment Variables](https://github.com/sliverarmory/private-armory/wiki/Environment-Variables) for configuration options. Older wiki JSON examples may differ from the current service configuration.
## Storage and Signing
The [storage providers](https://github.com/sliverarmory/private-armory/wiki/Storage-Providers) support a writable local filesystem or an S3 general-purpose bucket. Preserve the configured storage across restarts and container replacements.
Package and index signatures use Minisign. The [signing key providers](https://github.com/sliverarmory/private-armory/wiki/Package-Signing-Key-Providers) are:
| Provider | Key location |
| --- | --- |
| `local` | A private key file in the armory root. |
| `aws` | AWS Secrets Manager. |
| `vault` | HashiCorp Vault. |
| `external` | A separate signing process; the serving application holds the public key. |
With `external`, signing happens outside the service and package administration through its API is disabled. See [Standalone Signing](https://github.com/sliverarmory/private-armory/wiki/Standalone-Signing) and [Package Administration](https://github.com/sliverarmory/private-armory/wiki/Package-Administration) for the respective workflows.
Obtain the Minisign public key through a trusted channel. It establishes trust in the index and package signing metadata; it is separate from the TLS certificate. A valid signature does not establish that a package is safe to use.
## Client Configuration
Obtain the index URL, Minisign public key, and client authorization token from your armory administrator. The service's index URL ends in `/armory/index`; use the configured hostname, port, and HTTPS endpoint.
```text
sliver > armory add private --url https://armory.example.com:8888/armory/index --pubkey <MINISIGN_PUBLIC_KEY> --auth <CLIENT_TOKEN>
sliver > armory info
```
Replace the placeholders with your armory's values. `--auth` supplies the complete `Authorization` header value; Sliver does not add a `Bearer` prefix. Adding a source validates its index before saving it.
Configuration is saved to `~/.sliver-client/armories.json` by default, or under `SLIVER_CLIENT_ROOT_DIR` when set. Authorization values are stored in plaintext, and detailed `armory info <name>` output can display them; protect the file and redact credentials before sharing output.
## Managing Sources
| Command | Effect |
| --- | --- |
| `armory info` | List configured sources. |
| `armory refresh` | Reload saved source configuration and refresh cached metadata. |
| `armory disable private` / `armory enable private` | Disable or enable the private source. |
| `armory rm private` | Remove its source registration. |
| `armory save` | Save the current source configuration. |
Add and modify operations save by default. Changes made with `--no-save` are temporary and are lost on refresh or restart unless saved first. Disabling or removing a source does not uninstall packages already installed from it.
+36 -27
View File
@@ -69,7 +69,7 @@ export default function Home() {
</div>
<Card className="h-full bg-surface/80 shadow-surface backdrop-blur-2xl lg:col-span-5">
<Card.Header className="pb-3">
<Card.Header>
<div className="min-w-0">
<p className="text-sm font-medium text-accent">
Operator documentation
@@ -80,7 +80,7 @@ export default function Home() {
</div>
</Card.Header>
<Card.Content className="pt-2">
<Card.Content>
<p className="text-pretty text-base leading-7 text-muted">
Sliver is a cross-platform command and control framework for
professional red teams. Operate over mTLS, WireGuard, HTTP(S),
@@ -94,39 +94,48 @@ export default function Home() {
</Card.Content>
<Card.Footer className="mt-auto flex-col items-stretch gap-3 pt-5">
<Button
fullWidth
variant="primary"
onPress={() =>
router.push({
pathname: "/docs",
query: { name: "Getting Started" },
})
}
>
Getting started
</Button>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
fullWidth
variant="primary"
onPress={() =>
router.push({
pathname: "/docs",
query: { name: "Getting Started" },
})
}
>
Get started
</Button>
<Button
className="[--button-bg-hover:var(--color-purple-700)] [--button-bg-pressed:var(--color-purple-700)] [--button-bg:var(--color-purple-600)] [--button-fg:var(--color-white)]"
fullWidth
variant="tertiary"
onPress={() => {
window.open(
"https://github.com/BishopFox/sliver/releases/latest",
"_blank",
"noopener,noreferrer",
);
}}
<a
className={buttonVariants({
fullWidth: true,
variant: "secondary",
})}
href="https://github.com/BishopFox/sliver/releases/latest"
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faDownload} />
Latest release
</Button>
</a>
<a
className={buttonVariants({
fullWidth: true,
variant: "secondary",
})}
href="https://github.com/sliverarmory/sliver-gui/releases/latest"
target="_blank"
rel="noopener noreferrer"
>
<SliversIcon height={16} width={16} />
Sliver GUI
</a>
</div>
<a
className={buttonVariants({
fullWidth: true,
variant: "secondary",
variant: "ghost",
})}
href="https://github.com/sliverarmory"
target="_blank"
@@ -1,6 +1,6 @@
Sliver implants support two types of connections, sessions and beacons.
Sessions 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.
Sessions provide interactive communication with the server; connection and polling behavior depend on the selected transport. 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.
Typically during an engagement you will want to deploy a beacon on the target system and switch to a session while doing more active enumeration.
@@ -53,7 +53,7 @@ As you can see Sliver ran the Seatbelt assembly and provided us with the output
## Bof’s
Beacon object files are loaded using trustedsec’s coffloader. When you run a bof command the loader will first be loaded into memory and is used to run whichever bof you choose. From an operator’s perspective bof’s are similar to basic sliver commands.
The transcript below shows the legacy COFF Loader path. Current Sliver supports built-in Reflektor execution, with legacy loader compatibility depending on the package and component versions. See [BOF and COFF Support](/docs?name=BOF+and+COFF+Support) for current behavior.
```bash
[server] sliver (UNABLE_PRIDE) > sa-whoami
+1 -1
View File
@@ -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![Alt text](/images/Architecture.png)\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![Alt text](/images/Architecture.png)\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 provide interactive communication with the server; connection and polling behavior depend on the selected transport. 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
+46 -5
View File
@@ -131,7 +131,7 @@ function Test-SkipPackage {
}
function Get-TestDirectories {
Get-ChildItem -Path @("client", "implant", "server", "util") -Recurse -Filter "*_test.go" -File |
Get-ChildItem -Path @("client", "docs", "implant", "protobuf", "server", "test", "util") -Recurse -Filter "*_test.go" -File |
ForEach-Object {
[string](Resolve-Path -LiteralPath $_.DirectoryName -Relative).Replace("\", "/")
} |
@@ -273,12 +273,13 @@ try {
$clientTestPkgs = @()
$implantTestPkgs = @()
$serverUtilTestPkgs = @()
$untaggedTestPkgs = @()
$e2eSupportTestPkgs = @()
foreach ($testDir in $testDirs) {
$pkg = if ($testDir.StartsWith(".")) { $testDir } else { "./$testDir" }
switch -Wildcard ($pkg) {
"./server/c2" { continue }
"./server/generate" { continue }
"./client/*" {
$clientTestPkgs += $pkg
continue
@@ -295,6 +296,25 @@ try {
$serverUtilTestPkgs += $pkg
continue
}
"./docs" {
$untaggedTestPkgs += $pkg
continue
}
"./protobuf/*" {
$untaggedTestPkgs += $pkg
continue
}
"./test/e2e" {
$e2eSupportTestPkgs += $pkg
continue
}
"./test/e2e/*" {
$e2eSupportTestPkgs += $pkg
continue
}
default {
throw "No test runner configured for discovered package: $pkg"
}
}
}
@@ -308,6 +328,16 @@ try {
}
}
foreach ($pkg in $e2eSupportTestPkgs) {
if (Test-SkipPackage -Package $pkg -Tags "client,$tags") {
continue
}
Invoke-TestStep -Name $pkg -Action {
Invoke-ExternalCommand -FilePath "go" -Arguments @("test", "-tags=client,$tags", $pkg)
}
}
foreach ($pkg in $implantTestPkgs) {
if (Test-SkipPackage -Package $pkg) {
continue
@@ -318,6 +348,17 @@ try {
}
}
foreach ($pkg in $untaggedTestPkgs) {
if (Test-SkipPackage -Package $pkg) {
continue
}
Invoke-TestStep -Name $pkg -Action {
Invoke-ExternalCommand -FilePath "go" -Arguments @("test", $pkg)
}
}
# Includes generation helpers without the sliver_generate compilation tests.
foreach ($pkg in $serverUtilTestPkgs) {
if (Test-SkipPackage -Package $pkg -Tags "server,$tags") {
continue
@@ -380,7 +421,7 @@ try {
Invoke-TestStep -Name "./server/generate" -Action {
Invoke-ExternalCommand -FilePath "go" `
-Arguments @("test", "-timeout", "6h", "-p", $generateGoP, "-parallel", $generateTestParallel, "-tags=server,$tags", "./server/generate") `
-Arguments @("test", "-timeout", "6h", "-p", $generateGoP, "-parallel", $generateTestParallel, "-tags=server,$tags,sliver_generate", "./server/generate") `
-Environment @{
GOMAXPROCS = $generateGoMaxProcs
GOPROXY = "off"
@@ -389,9 +430,9 @@ try {
} else {
Write-Host ""
if ($UnitOnly) {
Write-Host "Skipping ./server/generate tests (--unit-only)"
Write-Host "Skipping ./server/generate compilation tests (--unit-only)"
} else {
Write-Host "Skipping ./server/generate tests (--skip-generate)"
Write-Host "Skipping ./server/generate compilation tests (--skip-generate)"
}
}
} finally {
+109 -24
View File
@@ -27,16 +27,70 @@ fi
SKIP_GENERATE=0
UNIT_ONLY=0
for arg in "$@"; do
if [ "$arg" = "--skip-generate" ]; then
GENERATE_ONLY=0
GENERATE_RUN=""
usage() {
cat <<'EOF'
Usage: ./go-tests.sh [--skip-generate | --unit-only | --generate-only] [--run REGEX]
--skip-generate Skip implant compilation tests.
--unit-only Skip implant compilation and C2 end-to-end tests.
--generate-only Run only the server/generate package tests.
--run REGEX Select generation tests by Go test name regex; requires --generate-only.
--help Show this help.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--skip-generate)
SKIP_GENERATE=1
fi
if [ "$arg" = "--unit-only" ]; then
;;
--unit-only)
UNIT_ONLY=1
SKIP_GENERATE=1
fi
;;
--generate-only)
GENERATE_ONLY=1
;;
--run)
if [ "$#" -lt 2 ] || [ -z "$2" ] || [[ "$2" == --* ]]; then
echo "--run requires a non-empty regex" >&2
exit 2
fi
GENERATE_RUN="$2"
shift
;;
--run=*)
GENERATE_RUN="${1#--run=}"
if [ -z "$GENERATE_RUN" ]; then
echo "--run requires a non-empty regex" >&2
exit 2
fi
;;
--help | -h)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
if [ "$GENERATE_ONLY" -eq 1 ] && [ "$SKIP_GENERATE" -eq 1 ]; then
echo "--generate-only cannot be combined with --skip-generate or --unit-only" >&2
exit 2
fi
if [ -n "$GENERATE_RUN" ] && [ "$GENERATE_ONLY" -eq 0 ]; then
echo "--run requires --generate-only" >&2
exit 2
fi
echo "----------------------------------------------------------------"
echo "WARNING: Running unit tests on slow systems can take a LONG time"
echo " Recommended to only run on 16+ CPU cores and 32Gb+ RAM"
@@ -161,7 +215,7 @@ collect_test_dirs() {
if command -v rg >/dev/null 2>&1; then
rg --files -g '*_test.go'
else
find client implant server test util -type f -name '*_test.go' -print
find client docs implant protobuf server test util -type f -name '*_test.go' -print
fi
}
@@ -175,14 +229,42 @@ unpack_server_assets() {
"$HOST_GO" run -tags=server,go_sqlite ./server unpack --force
}
run_generate_tests() {
# Reclaim the host build cache before generation so long CI runs do not
# exhaust the runner's disk while retaining the implant compiler cache.
run_test_cmd "clean Go build cache" "$HOST_GO" clean -cache || return 1
export GOPROXY=off
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
# parallelism conservative by default to avoid OOM kills on smaller systems.
local generate_gomaxprocs="${SLIVER_GENERATE_GOMAXPROCS:-2}"
local generate_go_p="${SLIVER_GENERATE_GO_P:-1}"
local generate_test_parallel="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
local generate_test_cmd=(
"$HOST_GO" test -timeout 6h -p "$generate_go_p" -parallel "$generate_test_parallel" -tags="server,$TAGS,sliver_generate"
)
if [ -n "$GENERATE_RUN" ]; then
# Keep test selection out of GOFLAGS, which implant compiler children inherit.
generate_test_cmd+=(-run "$GENERATE_RUN" -count=1 -v)
fi
generate_test_cmd+=(./server/generate)
run_test_cmd "./server/generate" \
env GOMAXPROCS="$generate_gomaxprocs" "${generate_test_cmd[@]}"
}
run_test_cmd "unpack server assets" unpack_server_assets || exit 1
if [ "$GENERATE_ONLY" -eq 1 ]; then
run_generate_tests || exit 1
exit 0
fi
TEST_DIRS=()
while IFS= read -r test_dir; do
TEST_DIRS+=("$test_dir")
done < <(collect_test_dirs | xargs -n1 dirname | sort -u)
CLIENT_TEST_PKGS=()
UNTAGGED_TEST_PKGS=()
IMPLANT_TEST_PKGS=()
SERVER_UTIL_TEST_PKGS=()
E2E_SUPPORT_TEST_PKGS=()
@@ -190,12 +272,15 @@ E2E_SUPPORT_TEST_PKGS=()
for test_dir in "${TEST_DIRS[@]}"; do
pkg="./$test_dir"
case "$pkg" in
./server/c2 | ./server/generate)
./server/c2)
# handled separately below
;;
./client/*)
CLIENT_TEST_PKGS+=("$pkg")
;;
./docs | ./protobuf/*)
UNTAGGED_TEST_PKGS+=("$pkg")
;;
./implant/*)
IMPLANT_TEST_PKGS+=("$pkg")
;;
@@ -205,9 +290,21 @@ for test_dir in "${TEST_DIRS[@]}"; do
./test/e2e | ./test/e2e/*)
E2E_SUPPORT_TEST_PKGS+=("$pkg")
;;
*)
echo "No test runner configured for $pkg" >&2
exit 1
;;
esac
done
## Packages without client, implant, or server build tags.
for pkg in "${UNTAGGED_TEST_PKGS[@]}"; do
if should_skip_package "$pkg"; then
continue
fi
run_test_cmd "$pkg" "$HOST_GO" test "$pkg" || exit 1
done
## Client
for pkg in "${CLIENT_TEST_PKGS[@]}"; do
if should_skip_package "$pkg" "client,$TAGS"; then
@@ -233,7 +330,7 @@ for pkg in "${IMPLANT_TEST_PKGS[@]}"; do
run_test_cmd "$pkg" "$HOST_GO" test "$pkg" || exit 1
done
## Server + Util
## Server + Util, including generation helpers without compilation tests.
for pkg in "${SERVER_UTIL_TEST_PKGS[@]}"; do
if should_skip_package "$pkg" "server,$TAGS"; then
continue
@@ -261,26 +358,14 @@ else
run_test_cmd "./server/c2 (e2e dns)" "$HOST_GO" test -tags="server,$TAGS,sliver_e2e" ./server/c2 -run 'TestDNS_' -count=1 || exit 1
fi
## Server generate
## Implant compilation tests opt in with sliver_generate; helper tests run above.
if [ "$SKIP_GENERATE" -eq 0 ]; then
# The package tests above no longer need their Go build cache. Reclaim it
# before the cross-platform generation matrix starts so long CI runs do not
# exhaust the runner's disk while retaining the implant compiler cache.
run_test_cmd "clean Go build cache" "$HOST_GO" clean -cache || exit 1
export GOPROXY=off
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
# parallelism conservative by default to avoid OOM kills on smaller systems.
GENERATE_GOMAXPROCS="${SLIVER_GENERATE_GOMAXPROCS:-2}"
GENERATE_GO_P="${SLIVER_GENERATE_GO_P:-1}"
GENERATE_TEST_PARALLEL="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
run_test_cmd "./server/generate" \
env GOMAXPROCS="$GENERATE_GOMAXPROCS" \
"$HOST_GO" test -timeout 6h -p "$GENERATE_GO_P" -parallel "$GENERATE_TEST_PARALLEL" -tags="server,$TAGS" ./server/generate || exit 1
run_generate_tests || exit 1
else
echo
if [ "$UNIT_ONLY" -eq 1 ]; then
echo "Skipping ./server/generate tests (--unit-only)"
echo "Skipping ./server/generate compilation tests (--unit-only)"
else
echo "Skipping ./server/generate tests (--skip-generate)"
echo "Skipping ./server/generate compilation tests (--skip-generate)"
fi
fi
+18 -18
View File
@@ -1,16 +1,16 @@
module github.com/bishopfox/sliver
go 1.27.1
go 1.27.3
replace github.com/rsteube/carapace v0.36.3 => github.com/reeflective/carapace v0.46.3-0.20231214181515-27e49f3c3b69
replace github.com/reeflective/readline => github.com/moloch--/readline v0.0.0-20260129035512-cb5b3e87d51a
replace github.com/reeflective/console => github.com/moloch--/console v0.0.0-20260129035459-883dcb25c701
replace github.com/reeflective/console => github.com/moloch--/console v0.0.0-20261002031128-9c7d5fba75f5
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/bubbletea/v2 v2.1.0
charm.land/glamour/v2 v2.0.1
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.6
@@ -24,27 +24,29 @@ require (
github.com/atotto/clipboard v0.1.4
github.com/carapace-sh/carapace v1.15.3
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.8
github.com/charmbracelet/x/ansi v0.11.9
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f
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.11.0
github.com/glebarez/sqlite v1.11.0
github.com/go-sql-driver/mysql v1.9.3
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang/protobuf v1.5.4
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/jackc/pgx/v5 v5.10.0
github.com/jedib0t/go-pretty/v6 v6.8.3
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
github.com/klauspost/compress v1.18.4
github.com/klauspost/compress v1.18.7
github.com/lesnuages/go-winio v0.4.19
github.com/lesnuages/snitch v0.6.0
github.com/lucasb-eyer/go-colorful v1.4.1
github.com/mark3labs/mcp-go v0.45.0
github.com/mattn/go-sqlite3 v1.14.50
github.com/mattn/go-sqlite3 v1.14.52
github.com/miekg/dns v1.1.70
github.com/moloch--/asciicast v0.1.1
github.com/moloch--/go-keystone v0.0.2
@@ -73,7 +75,7 @@ require (
golang.org/x/crypto v0.55.0
golang.org/x/mod v0.40.0
golang.org/x/net v0.58.0
golang.org/x/sys v0.47.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.45.0
golang.org/x/text v0.41.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
@@ -150,7 +152,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-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // 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/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
@@ -187,12 +189,11 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-lark/lark v1.16.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-redis/redis/v8 v8.11.6-0.20220405070650-99c79f7041fc // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
@@ -218,7 +219,6 @@ require (
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jezek/xgb v1.1.1 // indirect
@@ -238,7 +238,7 @@ require (
github.com/mailru/easyjson v0.7.7 // indirect
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.24 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
@@ -286,7 +286,7 @@ require (
github.com/utahta/go-linenotify v0.5.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.8.5 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
@@ -295,11 +295,11 @@ require (
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
+32 -32
View File
@@ -6,8 +6,8 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
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=
@@ -179,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-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/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
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=
@@ -316,8 +316,8 @@ github.com/go-lark/lark v1.16.0 h1:U6BwkLM9wrZedSM7cIiMofganr8PCvJN+M75w2lf2Gg=
github.com/go-lark/lark v1.16.0/go.mod h1:6ltbSztPZRT6IaO9ZIQyVaY5pVp/KeMizDYtfZkU+vM=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
@@ -484,8 +484,8 @@ github.com/kevinburke/twilio-go v0.0.0-20260522200016-74f727d55757 h1:dQyNAHiOY8
github.com/kevinburke/twilio-go v0.0.0-20260522200016-74f727d55757/go.mod h1:Z7bqFOTtIqCCIO+uPtod7Fp3gPMR8aTCcp5Pe8D+3fE=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -528,10 +528,10 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
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.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
@@ -552,8 +552,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/moloch--/asciicast v0.1.1 h1:btwwniCK9br3ys33XZ71IG/M3ZujSSCcBzsH/sieiXg=
github.com/moloch--/asciicast v0.1.1/go.mod h1:OckO16UDLgxVLclrCnbocL1ix15Br/8Xv/caBoYq98o=
github.com/moloch--/console v0.0.0-20260129035459-883dcb25c701 h1:6caCgDZpovjvv+eclRytFDf0GpVEnjDmoJRHpfkb42E=
github.com/moloch--/console v0.0.0-20260129035459-883dcb25c701/go.mod h1:UdqyqqCSnPu3Zc8Q3kpDszrq1hcyBHsG17hlWlRJmWI=
github.com/moloch--/console v0.0.0-20261002031128-9c7d5fba75f5 h1:BwamAdS8GG5tsxKisLphOOC5RVmTcy8Ixf+oGyhh/Jc=
github.com/moloch--/console v0.0.0-20261002031128-9c7d5fba75f5/go.mod h1:UdqyqqCSnPu3Zc8Q3kpDszrq1hcyBHsG17hlWlRJmWI=
github.com/moloch--/go-keystone v0.0.2 h1:xThbYs45CSzoDUz5kBZxAyTPIpK3t5M7NB1x+NTTRL8=
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=
@@ -761,8 +761,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yiya1989/sshkrb5 v0.0.1 h1:oAKpXLBeZExjYDV9eTL2KypmXU+5oPrJtqDuR5JbWic=
github.com/yiya1989/sshkrb5 v0.0.1/go.mod h1:r1bHcNa22x8rrg/Kj/cDF5bX6UtZrHiQgRFvj3Mnpl8=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -791,20 +791,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
@@ -926,8 +926,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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
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=
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/bishopfox/sliver
go 1.27.1
go 1.27.3
require (
filippo.io/age v1.1.1
+10 -3
View File
@@ -25,10 +25,17 @@ var (
SliverName = `{{.Name}}`
)
// Message - Fake message for embedding canaries
// Message carries canaries in struct tags, which remain plaintext under Garble.
type Message struct {
Command string `c2:"[[GenerateCanary]]"`
// {{if .Config.IncludeMTLS}}
MTLS string `c2:"[[GenerateCanary]]"`
// {{end}}
// {{if .Config.IncludeWG}}
WireGuard string `c2:"[[GenerateCanary]]"`
// {{end}}
}
// never obfuscate the Message type
var _ = reflect.TypeOf(Message{})
// Access a field at runtime to retain the type metadata and its canary tags.
// A discarded TypeOf call alone is optimized away, removing the canaries.
var _ = reflect.TypeOf(Message{}).Field(0)
+62 -29
View File
@@ -19,6 +19,8 @@ package extension
*/
import (
"bytes"
"io"
"io/fs"
"testing"
)
@@ -33,7 +35,11 @@ func TestBasicMemFSOpenFile(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if stat, _ := fi.Stat(); stat.Name() != "memfs/test.1" {
stat, err := fi.Stat()
if err != nil {
t.Fatal(err)
}
if stat.Name() != "memfs/test.1" {
t.Fatalf("expected memfs/test.1, got %s", stat.Name())
}
defer fi.Close()
@@ -58,20 +64,24 @@ func TestMemFSOpenRoot(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(entries) != 3 {
for entry := range entries {
t.Logf("entry: %s", entries[entry].Name())
wantEntries := map[string]bool{"memfs/test.1": false, "memfs/test.2": false, "memfs/a": true}
if len(entries) != len(wantEntries) {
t.Fatalf("expected %d entries, got %d", len(wantEntries), len(entries))
}
for _, entry := range entries {
wantDir, ok := wantEntries[entry.Name()]
if !ok || entry.IsDir() != wantDir {
t.Fatalf("unexpected entry %q (directory=%v)", entry.Name(), entry.IsDir())
}
t.Fatalf("expected 3 entries, got %d", len(entries))
delete(wantEntries, entry.Name())
}
di, err := dirNode.Stat()
if err != nil {
t.Fatal(err)
}
t.Logf("name: %s, is dir: %v", di.Name(), di.IsDir())
for _, entry := range entries {
t.Logf("entry: %s", entry.Name())
if di.Name() != "memfs" || !di.IsDir() {
t.Fatalf("expected memfs root directory, got %q (directory=%v)", di.Name(), di.IsDir())
}
}
@@ -80,7 +90,7 @@ func TestBasicMemFSOpenDir(t *testing.T) {
"test/foo.1": {0x00},
"test/foo": {0x00},
"testing/foo": {0x00},
"a/b/c/d/e/f/test/a.txt": {0x00},
"a/b/c/d/e/f/test/a.txt": {0x31, 0x00, 0x7f},
}}
// Test Open File
@@ -91,7 +101,11 @@ func TestBasicMemFSOpenDir(t *testing.T) {
if f == nil {
t.Fatal("expected file, got nil and no error")
}
if stat, _ := f.Stat(); stat.Name() != "memfs/test" || !stat.IsDir() {
stat, err := f.Stat()
if err != nil {
t.Fatal(err)
}
if stat.Name() != "memfs/test" || !stat.IsDir() {
t.Fatalf("expected 'memfs/test' dir, got '%s'", stat.Name())
}
defer f.Close()
@@ -100,7 +114,16 @@ func TestBasicMemFSOpenDir(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if stat, _ := dir.Stat(); stat.Name() != "memfs/a/b/c/d/e/f/test" || !stat.IsDir() {
defer func() {
if err := dir.Close(); err != nil {
t.Errorf("close directory: %v", err)
}
}()
stat, err = dir.Stat()
if err != nil {
t.Fatal(err)
}
if stat.Name() != "memfs/a/b/c/d/e/f/test" || !stat.IsDir() {
t.Fatalf("expected 'memfs/a/b/c/d/e/f/test' dir, got '%s'", stat.Name())
}
@@ -108,19 +131,17 @@ func TestBasicMemFSOpenDir(t *testing.T) {
if err != nil {
t.Fatal(err)
}
data := make([]byte, 1)
_, err = f.Read(data)
if err != nil {
t.Fatal(err)
}
if data[0] != 0x00 {
t.Fatalf("expected 0x00, got %x", data[0])
}
defer func() {
if err := f.Close(); err != nil {
t.Errorf("close file: %v", err)
}
}()
assertMemFSFileContents(t, f, []byte{0x31, 0x00, 0x7f})
}
func TestMemFSReadFileData(t *testing.T) {
wasmFS := WasmMemoryFS{memFS: map[string][]byte{
"test/foo.1": {0x00},
"test/foo.1": {0x41, 0x00, 0xff, 0x42},
}}
// Test Open File
@@ -128,19 +149,31 @@ func TestMemFSReadFileData(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if stat, _ := fi.Stat(); stat.Name() != "memfs/test/foo.1" {
stat, err := fi.Stat()
if err != nil {
t.Fatal(err)
}
if stat.Name() != "memfs/test/foo.1" {
t.Fatalf("expected memfs/test/foo.1, got %s", stat.Name())
}
defer fi.Close()
// Read file
data := make([]byte, 1)
_, err = fi.Read(data)
if err != nil {
t.Fatal(err)
assertMemFSFileContents(t, fi, []byte{0x41, 0x00, 0xff, 0x42})
}
func assertMemFSFileContents(t *testing.T, file fs.File, want []byte) {
t.Helper()
// Read in chunks to verify byte counts and advancement of the file offset.
for offset := 0; offset < len(want); {
end := min(offset+2, len(want))
data := make([]byte, end-offset)
n, err := file.Read(data)
if err != nil || n != len(data) || !bytes.Equal(data, want[offset:end]) {
t.Fatalf("Read at %d = (%x, %d, %v), want (%x, %d, nil)", offset, data, n, err, want[offset:end], len(data))
}
offset = end
}
if data[0] != 0x00 {
t.Fatalf("expected 0x00, got %x", data[0])
if n, err := file.Read(make([]byte, 1)); n != 0 || err != io.EOF {
t.Fatalf("Read after file contents = (%d, %v), want (0, EOF)", n, err)
}
}
@@ -139,18 +139,21 @@ func TestTunnelCloseUnblocksInboundWriter(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("closing tunnel did not unblock inbound writer")
}
if active := connection.Tunnel(tunnel.ID); active != nil {
t.Fatalf("closed tunnel remained published: %p", active)
}
select {
case <-connection.Done():
if !test.wantC2Cleanup {
t.Fatal("legacy close unexpectedly tore down the C2 connection")
}
default:
if test.wantC2Cleanup {
if test.wantC2Cleanup {
select {
case <-connection.Done():
case <-time.After(time.Second):
t.Fatal("incomplete sequenced close did not fail the C2 connection closed")
}
} else {
select {
case <-connection.Done():
t.Fatal("legacy close unexpectedly tore down the C2 connection")
default:
}
}
if active := connection.Tunnel(tunnel.ID); active != nil {
t.Fatalf("closed tunnel remained published: %p", active)
}
})
}
@@ -2,6 +2,7 @@ package tunnel_handlers
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"sync"
@@ -10,7 +11,6 @@ import (
"log"
// {{end}}
"github.com/bishopfox/sliver/implant/sliver/encoders"
"github.com/bishopfox/sliver/implant/sliver/extension"
"github.com/bishopfox/sliver/implant/sliver/transports"
"github.com/bishopfox/sliver/protobuf/commonpb"
@@ -39,7 +39,7 @@ func ExecWasmExtensionHandler(envelope *pb.Envelope, connection *transports.Conn
return
}
wasmBin, err := encoders.Gzip.Decode(extReq.WasmGz)
wasmBin, err := decodeWasmGzip(extReq.WasmGz)
if err != nil {
// {{if .Config.Debug}}
log.Printf("error decoding Wasm extension: %v", err)
@@ -76,6 +76,19 @@ func ExecWasmExtensionHandler(envelope *pb.Envelope, connection *transports.Conn
}
}
func decodeWasmGzip(data []byte) ([]byte, error) {
reader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
var buf bytes.Buffer
_, err = buf.ReadFrom(reader)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func runNonInteractive(req *pb.ExecWasmExtensionReq, wasm *extension.WasmExtension) ([]byte, error) {
// {{if .Config.Debug}}
log.Printf("Executing non-interactive wasm extension")
@@ -0,0 +1,32 @@
package tunnel_handlers
import (
"bytes"
"compress/gzip"
"testing"
)
func TestDecodeWasmGzip(t *testing.T) {
// An empty WebAssembly v1 module has the magic and version headers.
wasm := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
if _, err := writer.Write(wasm); err != nil {
t.Fatalf("compress WASM: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("finish gzip stream: %v", err)
}
got, err := decodeWasmGzip(compressed.Bytes())
if err != nil {
t.Fatalf("decode valid WASM payload: %v", err)
}
if !bytes.Equal(got, wasm) {
t.Fatalf("decoded payload = %x, want %x", got, wasm)
}
if _, err := decodeWasmGzip([]byte("not a gzip stream")); err == nil {
t.Fatal("expected malformed gzip to fail")
}
}
@@ -54,6 +54,13 @@ func TestTunnelWriterIncludesAuthorizationIDOnCreateReverse(t *testing.T) {
if second.CreateReverse {
t.Fatal("subsequent tunnel frame unexpectedly requested reverse tunnel creation")
}
if second.Rportfwd == nil {
t.Fatal("subsequent tunnel frame lost its reverse-port-forward presence marker")
}
if second.Rportfwd.Port != 0 || second.Rportfwd.Protocol != 0 || second.Rportfwd.Host != "" || //nolint:staticcheck // Verify compatibility marker shape.
second.Rportfwd.AuthorizationID != "" || second.Rportfwd.TunnelID != 0 || second.Rportfwd.Response != nil {
t.Fatalf("subsequent tunnel frame exposed non-empty reverse metadata: %+v", second.Rportfwd)
}
}
func TestTunnelWriterIncludesLegacyAddressWithoutAuthorization(t *testing.T) {
+9 -2
View File
@@ -427,11 +427,18 @@ func (c *Connection) retireTunnelIDLocked(tunnelID uint64) {
func (c *Connection) publishTunnelLocked(tun *Tunnel) {
tun.setPeerCloseNotifier(func(sequence uint64) error {
data, err := proto.Marshal(&pb.TunnelData{
terminal := &pb.TunnelData{
Closed: true,
TunnelID: tun.ID,
Sequence: sequence,
})
}
if tun.IsReverse() {
// Presence of an intentionally empty marker disambiguates a reverse
// terminal which overtakes CreateReverse from an ordinary generic close.
// Older servers ignore this previously-unused nested field.
terminal.Rportfwd = &pb.RPortfwd{}
}
data, err := proto.Marshal(terminal)
if err != nil {
return err
}
@@ -10,6 +10,7 @@ import (
"time"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"google.golang.org/protobuf/proto"
)
type closeSignalWriteCloser struct {
@@ -386,3 +387,45 @@ func TestConnectionSimultaneousPeerCloseDoesNotFailC2(t *testing.T) {
default:
}
}
func TestConnectionTerminalMarksOnlyReverseTunnels(t *testing.T) {
for _, test := range []struct {
name string
newTunnel func(uint64, io.WriteCloser, ...io.ReadCloser) *Tunnel
wantMarker bool
}{
{name: "reverse", newTunnel: NewReverseTunnel, wantMarker: true},
{name: "generic", newTunnel: NewTunnel, wantMarker: false},
} {
t.Run(test.name, func(t *testing.T) {
connection := &Connection{Send: make(chan *sliverpb.Envelope, 1)}
tunnel := test.newTunnel(0x7730, nopWriteCloser{Writer: io.Discard})
if result := connection.TryAddTunnel(tunnel); result != TunnelAdded {
t.Fatalf("add tunnel result = %v, want %v", result, TunnelAdded)
}
if !connection.CloseTunnelLocal(tunnel) {
t.Fatal("failed to close tunnel locally")
}
select {
case envelope := <-connection.Send:
if envelope.Type != sliverpb.MsgTunnelClose {
t.Fatalf("terminal envelope type = %d, want %d", envelope.Type, sliverpb.MsgTunnelClose)
}
terminal := &sliverpb.TunnelData{}
if err := proto.Unmarshal(envelope.Data, terminal); err != nil {
t.Fatalf("unmarshal terminal: %v", err)
}
if got := terminal.Rportfwd != nil; got != test.wantMarker {
t.Fatalf("reverse marker present = %t, want %t", got, test.wantMarker)
}
if terminal.Rportfwd != nil && (terminal.Rportfwd.Host != "" || terminal.Rportfwd.Port != 0 || //nolint:staticcheck // Verify the complete compatibility marker.
terminal.Rportfwd.Protocol != 0 || terminal.Rportfwd.AuthorizationID != "" ||
terminal.Rportfwd.TunnelID != 0 || terminal.Rportfwd.Response != nil) {
t.Fatalf("reverse terminal marker carried metadata: %+v", terminal.Rportfwd)
}
default:
t.Fatal("local close did not publish terminal envelope")
}
})
}
}
+1 -1
View File
@@ -189,7 +189,7 @@ func ReadEnvelope(r io.Reader) (*pb.Envelope, error) {
rawSigBuf := make([]byte, cryptography.RawSigSize)
dataLengthBuf := make([]byte, 4) // Size of uint32
if len(rawSigBuf) == 0 || len(dataLengthBuf) == 0 || r == nil {
panic("[[GenerateCanary]]")
panic("invalid envelope reader")
}
n, err := io.ReadFull(r, rawSigBuf)
@@ -182,7 +182,7 @@ func ReadEnvelope(connection net.Conn) (*pb.Envelope, error) {
rawSigBuf := make([]byte, cryptography.RawSigSize)
dataLengthBuf := make([]byte, 4) // Size of uint32
if len(rawSigBuf) == 0 || len(dataLengthBuf) == 0 || connection == nil {
panic("[[GenerateCanary]]")
panic("invalid envelope connection")
}
n, err := io.ReadFull(connection, rawSigBuf)
+25 -13
View File
@@ -8743,17 +8743,20 @@ func (x *Operator) GetName() string {
// [ Websites ] ----------------------------------------
type WebContent struct {
state protoimpl.MessageState `protogen:"open.v1"`
ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
WebsiteID string `protobuf:"bytes,2,opt,name=WebsiteID,proto3" json:"WebsiteID,omitempty"`
Path string `protobuf:"bytes,3,opt,name=Path,proto3" json:"Path,omitempty"`
ContentType string `protobuf:"bytes,4,opt,name=ContentType,proto3" json:"ContentType,omitempty"`
Size uint64 `protobuf:"varint,5,opt,name=Size,proto3" json:"Size,omitempty"`
OriginalFile string `protobuf:"bytes,6,opt,name=OriginalFile,proto3" json:"OriginalFile,omitempty"`
Sha256 string `protobuf:"bytes,7,opt,name=Sha256,proto3" json:"Sha256,omitempty"`
Content []byte `protobuf:"bytes,9,opt,name=Content,proto3" json:"Content,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
WebsiteID string `protobuf:"bytes,2,opt,name=WebsiteID,proto3" json:"WebsiteID,omitempty"`
Path string `protobuf:"bytes,3,opt,name=Path,proto3" json:"Path,omitempty"`
ContentType string `protobuf:"bytes,4,opt,name=ContentType,proto3" json:"ContentType,omitempty"`
Size uint64 `protobuf:"varint,5,opt,name=Size,proto3" json:"Size,omitempty"`
OriginalFile string `protobuf:"bytes,6,opt,name=OriginalFile,proto3" json:"OriginalFile,omitempty"`
Sha256 string `protobuf:"bytes,7,opt,name=Sha256,proto3" json:"Sha256,omitempty"`
Content []byte `protobuf:"bytes,9,opt,name=Content,proto3" json:"Content,omitempty"`
// Upload request only: replace stored bytes even when Content is empty.
// Empty Content without this flag preserves existing content during metadata updates.
ReplaceContent bool `protobuf:"varint,10,opt,name=ReplaceContent,proto3" json:"ReplaceContent,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WebContent) Reset() {
@@ -8842,6 +8845,13 @@ func (x *WebContent) GetContent() []byte {
return nil
}
func (x *WebContent) GetReplaceContent() bool {
if x != nil {
return x.ReplaceContent
}
return false
}
type WebsiteAddContent struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=Name,proto3" json:"Name,omitempty"`
@@ -17281,7 +17291,7 @@ const file_clientpb_client_proto_rawDesc = "" +
"\tOperators\x18\x01 \x03(\v2\x12.clientpb.OperatorR\tOperators\"6\n" +
"\bOperator\x12\x16\n" +
"\x06Online\x18\x01 \x01(\bR\x06Online\x12\x12\n" +
"\x04Name\x18\x02 \x01(\tR\x04Name\"\xde\x01\n" +
"\x04Name\x18\x02 \x01(\tR\x04Name\"\x86\x02\n" +
"\n" +
"WebContent\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\tR\x02ID\x12\x1c\n" +
@@ -17291,7 +17301,9 @@ const file_clientpb_client_proto_rawDesc = "" +
"\x04Size\x18\x05 \x01(\x04B\x020\x01R\x04Size\x12\"\n" +
"\fOriginalFile\x18\x06 \x01(\tR\fOriginalFile\x12\x16\n" +
"\x06Sha256\x18\a \x01(\tR\x06Sha256\x12\x18\n" +
"\aContent\x18\t \x01(\fR\aContent\"\xc1\x01\n" +
"\aContent\x18\t \x01(\fR\aContent\x12&\n" +
"\x0eReplaceContent\x18\n" +
" \x01(\bR\x0eReplaceContent\"\xc1\x01\n" +
"\x11WebsiteAddContent\x12\x12\n" +
"\x04Name\x18\x01 \x01(\tR\x04Name\x12E\n" +
"\bContents\x18\x02 \x03(\v2).clientpb.WebsiteAddContent.ContentsEntryR\bContents\x1aQ\n" +
+3
View File
@@ -697,6 +697,9 @@ message WebContent {
string Sha256 = 7;
bytes Content = 9;
// Upload request only: replace stored bytes even when Content is empty.
// Empty Content without this flag preserves existing content during metadata updates.
bool ReplaceContent = 10;
}
message WebsiteAddContent {
+454 -179
View File
@@ -24,7 +24,6 @@ import (
"bytes"
"crypto/rand"
"embed"
"encoding/hex"
"fmt"
"go/ast"
"go/parser"
@@ -35,7 +34,6 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
@@ -229,11 +227,8 @@ func setupGo(appDir string) error {
// SetupGoPath - Extracts dependencies to goPathSrc
func SetupGoPath(goPathSrc string, includeDNS bool) error {
// GOPATH setup
if _, err := os.Stat(goPathSrc); os.IsNotExist(err) {
setupLog.Infof("Creating GOPATH directory: %s", goPathSrc)
os.MkdirAll(goPathSrc, 0700)
if err := os.MkdirAll(goPathSrc, 0700); err != nil {
return fmt.Errorf("create implant source directory %s: %w", goPathSrc, err)
}
// Sliver PB
@@ -252,17 +247,27 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
setupLog.Info("Static asset not found: capabilities.go")
return err
}
sliverpbGoSrc = xorPBRawBytes(sliverpbGoSrc)
sliverpbGoSrc = stripSliverpb(sliverpbGoSrc)
sliverpbGoSrc, err = xorPBRawBytes(sliverpbGoSrc)
if err != nil {
return fmt.Errorf("obfuscate sliverpb descriptor: %w", err)
}
sliverpbGoSrc, err = rewriteProtobufStructTags(sliverpbGoSrc)
if err != nil {
return fmt.Errorf("obfuscate sliverpb tags: %w", err)
}
sliverpbDir := filepath.Join(goPathSrc, "protobuf", "sliverpb")
setupLog.Infof("Creating sliverpb directory: %s", sliverpbDir)
os.MkdirAll(sliverpbDir, 0700)
os.WriteFile(filepath.Join(sliverpbDir, "sliver.pb.go"), sliverpbGoSrc, 0600)
os.WriteFile(filepath.Join(sliverpbDir, "constants.go"), sliverpbConstSrc, 0600)
err = os.WriteFile(filepath.Join(sliverpbDir, "capabilities.go"), sliverpbCapabilitiesSrc, 0600)
if err != nil {
setupLog.Errorf("Failed to write capabilities.go: %s", err)
return err
if err := os.MkdirAll(sliverpbDir, 0700); err != nil {
return fmt.Errorf("create sliverpb directory: %w", err)
}
if err := os.WriteFile(filepath.Join(sliverpbDir, "sliver.pb.go"), sliverpbGoSrc, 0600); err != nil {
return fmt.Errorf("write sliver.pb.go: %w", err)
}
if err := os.WriteFile(filepath.Join(sliverpbDir, "constants.go"), sliverpbConstSrc, 0600); err != nil {
return fmt.Errorf("write sliverpb constants.go: %w", err)
}
if err := os.WriteFile(filepath.Join(sliverpbDir, "capabilities.go"), sliverpbCapabilitiesSrc, 0600); err != nil {
return fmt.Errorf("write sliverpb capabilities.go: %w", err)
}
// Common PB
@@ -271,10 +276,21 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
setupLog.Info("Static asset not found: common.pb.go")
return err
}
commonpbSrc = xorPBRawBytes(commonpbSrc)
commonpbSrc, err = xorPBRawBytes(commonpbSrc)
if err != nil {
return fmt.Errorf("obfuscate commonpb descriptor: %w", err)
}
commonpbSrc, err = rewriteProtobufStructTags(commonpbSrc)
if err != nil {
return fmt.Errorf("obfuscate commonpb tags: %w", err)
}
commonpbDir := filepath.Join(goPathSrc, "protobuf", "commonpb")
os.MkdirAll(commonpbDir, 0700)
os.WriteFile(filepath.Join(commonpbDir, "common.pb.go"), commonpbSrc, 0600)
if err := os.MkdirAll(commonpbDir, 0700); err != nil {
return fmt.Errorf("create commonpb directory: %w", err)
}
if err := os.WriteFile(filepath.Join(commonpbDir, "common.pb.go"), commonpbSrc, 0600); err != nil {
return fmt.Errorf("write common.pb.go: %w", err)
}
// DNS PB
if includeDNS {
@@ -283,34 +299,242 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
setupLog.Info("Static asset not found: dns.pb.go")
return err
}
dnspbSrc = xorPBRawBytes(dnspbSrc)
dnspbSrc, err = xorPBRawBytes(dnspbSrc)
if err != nil {
return fmt.Errorf("obfuscate dnspb descriptor: %w", err)
}
dnspbSrc, err = rewriteProtobufStructTags(dnspbSrc)
if err != nil {
return fmt.Errorf("obfuscate dnspb tags: %w", err)
}
dnspbDir := filepath.Join(goPathSrc, "protobuf", "dnspb")
os.MkdirAll(dnspbDir, 0700)
os.WriteFile(filepath.Join(dnspbDir, "dns.pb.go"), dnspbSrc, 0600)
if err := os.MkdirAll(dnspbDir, 0700); err != nil {
return fmt.Errorf("create dnspb directory: %w", err)
}
if err := os.WriteFile(filepath.Join(dnspbDir, "dns.pb.go"), dnspbSrc, 0600); err != nil {
return fmt.Errorf("write dns.pb.go: %w", err)
}
}
return nil
}
func stripSliverpb(src []byte) []byte {
out := src
re := regexp.MustCompile(`protobuf:"[a-z]+,\d+,[a-z]+,name=(?P<FieldName1>[a-zA-Z0-9]+),proto3(,enum=(?P<EnumName>[a-zA-Z\.]+))?" json:"(?P<FiledName2>[a-zA-Z0-9]+),[a-z]+"`)
found := re.FindAllSubmatch(src, -1)
for _, x := range found {
line := x[0] // line that matched
typeName := x[1] // first named capturing group (FieldName1)
enumName := x[3]
if string(enumName) != "" {
newEnumName := pseudoRandStringRunes(len(enumName))
newEnumLine := bytes.ReplaceAll(line, enumName, []byte(newEnumName))
out = bytes.ReplaceAll(out, line, []byte(newEnumLine))
line = newEnumLine
type structTagPart struct {
key string
value string
}
// parseStructTagParts parses the key/value pairs in a Go struct tag without
// assuming an order or a particular set of protobuf options.
func parseStructTagParts(tag string) ([]structTagPart, error) {
var parts []structTagPart
for tag != "" {
tag = strings.TrimLeft(tag, " ")
if tag == "" {
break
}
colon := strings.IndexByte(tag, ':')
if colon <= 0 || strings.ContainsAny(tag[:colon], " \t\n\r\"`") {
return nil, fmt.Errorf("invalid struct tag key near %q", tag)
}
key := tag[:colon]
tag = tag[colon+1:]
if !strings.HasPrefix(tag, `"`) {
return nil, fmt.Errorf("missing quoted value for struct tag %q", key)
}
end := 1
for end < len(tag) {
if tag[end] == '\\' {
end += 2
continue
}
if tag[end] == '"' {
break
}
end++
}
if end >= len(tag) {
return nil, fmt.Errorf("unterminated struct tag value for %q", key)
}
value, err := strconv.Unquote(tag[:end+1])
if err != nil {
return nil, fmt.Errorf("unquote struct tag %q: %w", key, err)
}
parts = append(parts, structTagPart{key: key, value: value})
tag = tag[end+1:]
if tag != "" && tag[0] != ' ' {
return nil, fmt.Errorf("missing separator after struct tag %q", key)
}
// we don't care about FieldName2 because its value is the same as FieldName1
newItem := pseudoRandStringRunes(len(typeName))
newLine := bytes.ReplaceAll(line, typeName, []byte(newItem))
out = bytes.ReplaceAll(out, line, []byte(newLine))
}
return out
return parts, nil
}
func renderStructTagParts(parts []structTagPart) string {
var out strings.Builder
for i, part := range parts {
if i != 0 {
out.WriteByte(' ')
}
out.WriteString(part.key)
out.WriteByte(':')
out.WriteString(strconv.Quote(part.value))
}
return out.String()
}
func randomDifferentName(original string) string {
for {
candidate := pseudoRandStringRunes(len(original))
if candidate != original {
return candidate
}
}
}
func rewriteProtobufTagValue(value string) (string, string, string, error) {
options := strings.Split(value, ",")
nameIndex, enumIndex := -1, -1
fieldNumberFound := false
for i, option := range options {
if _, err := strconv.ParseUint(option, 10, 32); err == nil {
fieldNumberFound = true
}
if strings.HasPrefix(option, "name=") {
if nameIndex >= 0 {
return "", "", "", fmt.Errorf("duplicate protobuf field name")
}
nameIndex = i
}
if strings.HasPrefix(option, "enum=") {
enumIndex = i
}
}
if !fieldNumberFound || nameIndex < 0 || len(options[nameIndex]) == len("name=") {
return "", "", "", fmt.Errorf("protobuf struct tag lacks a field number or name: %q", value)
}
originalName := strings.TrimPrefix(options[nameIndex], "name=")
newName := randomDifferentName(originalName)
options[nameIndex] = "name=" + newName
if enumIndex >= 0 {
enumName := strings.TrimPrefix(options[enumIndex], "enum=")
if enumName == "" {
return "", "", "", fmt.Errorf("empty protobuf enum name")
}
options[enumIndex] = "enum=" + randomDifferentName(enumName)
}
return strings.Join(options, ","), originalName, newName, nil
}
func rewriteJSONTagValue(value, originalName, newName string) (string, error) {
options := strings.SplitN(value, ",", 2)
if options[0] == "" || options[0] == "-" {
return "", fmt.Errorf("unsupported JSON field name %q", options[0])
}
if options[0] == originalName {
options[0] = newName
} else {
options[0] = randomDifferentName(options[0])
}
return strings.Join(options, ","), nil
}
func rewritePBStructTag(raw string, cache map[string]string) (string, bool, error) {
if rewritten, ok := cache[raw]; ok {
return rewritten, true, nil
}
parts, err := parseStructTagParts(raw)
if err != nil {
return "", false, err
}
protobufIndex, jsonIndex := -1, -1
for i, part := range parts {
switch part.key {
case "protobuf":
if protobufIndex >= 0 {
return "", false, fmt.Errorf("duplicate protobuf struct tag")
}
protobufIndex = i
case "json":
if jsonIndex >= 0 {
return "", false, fmt.Errorf("duplicate JSON struct tag")
}
jsonIndex = i
}
}
if protobufIndex < 0 {
return raw, false, nil
}
if jsonIndex < 0 {
return "", false, fmt.Errorf("protobuf struct tag is missing a JSON tag")
}
protobufValue, originalName, newName, err := rewriteProtobufTagValue(parts[protobufIndex].value)
if err != nil {
return "", false, err
}
parts[protobufIndex].value = protobufValue
parts[jsonIndex].value, err = rewriteJSONTagValue(parts[jsonIndex].value, originalName, newName)
if err != nil {
return "", false, err
}
rewritten := renderStructTagParts(parts)
cache[raw] = rewritten
return rewritten, true, nil
}
// rewriteProtobufStructTags rewrites generated protobuf struct tags after parsing their
// syntax, including packed fields and future option ordering changes.
func rewriteProtobufStructTags(src []byte) ([]byte, error) {
fset := token.NewFileSet()
fileAst, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("parse protobuf source: %w", err)
}
cache := make(map[string]string)
rewritten := 0
var rewriteErr error
ast.Inspect(fileAst, func(node ast.Node) bool {
if rewriteErr != nil {
return false
}
structType, ok := node.(*ast.StructType)
if !ok || structType.Fields == nil {
return true
}
for _, field := range structType.Fields.List {
if field.Tag == nil {
continue
}
raw, err := strconv.Unquote(field.Tag.Value)
if err != nil {
rewriteErr = fmt.Errorf("parse struct tag at %s: %w", fset.Position(field.Pos()), err)
return false
}
newTag, changed, err := rewritePBStructTag(raw, cache)
if err != nil {
rewriteErr = fmt.Errorf("rewrite struct tag at %s: %w", fset.Position(field.Pos()), err)
return false
}
if changed {
rewritten++
if strings.ContainsRune(newTag, '`') {
field.Tag.Value = strconv.Quote(newTag)
} else {
field.Tag.Value = "`" + newTag + "`"
}
}
}
return true
})
if rewriteErr != nil {
return nil, rewriteErr
}
if rewritten == 0 {
return nil, fmt.Errorf("no protobuf struct tags found in source")
}
var out bytes.Buffer
if err := printer.Fprint(&out, fset, fileAst); err != nil {
return nil, fmt.Errorf("print rewritten protobuf source: %w", err)
}
return out.Bytes(), nil
}
// UntarSkipTopLevel - Untar a tar file, skipping the top level directory
@@ -390,174 +614,225 @@ func unzipSkipTopLevel(dst string, z *zip.Reader) error {
return err
}
func xorPBRawBytes(src []byte) []byte {
var (
fileAst *ast.File
err error
fset = token.NewFileSet()
parserMode = parser.ParseComments
)
fileAst, err = parser.ParseFile(fset, "", src, parserMode)
if err != nil {
// Panic because this is mandatory for the agent to work
panic(err)
func descriptorStringLiterals(expr ast.Expr) ([]*ast.BasicLit, error) {
var literals []*ast.BasicLit
var walk func(ast.Expr) error
walk = func(expr ast.Expr) error {
switch node := expr.(type) {
case *ast.BasicLit:
if node.Kind != token.STRING {
return fmt.Errorf("descriptor expression contains a non-string literal")
}
if _, err := strconv.Unquote(node.Value); err != nil {
return fmt.Errorf("decode descriptor string literal: %w", err)
}
literals = append(literals, node)
return nil
case *ast.BinaryExpr:
if node.Op != token.ADD {
return fmt.Errorf("descriptor expression uses operator %s", node.Op)
}
if err := walk(node.X); err != nil {
return err
}
return walk(node.Y)
case *ast.ParenExpr:
return walk(node.X)
default:
return fmt.Errorf("unsupported descriptor expression %T", expr)
}
}
var xorKey [8]byte
// generate random xor key
if _, err := rand.Read(xorKey[:]); err != nil {
// Panic because this is mandatory for the agent to work
panic(err)
if err := walk(expr); err != nil {
return nil, err
}
return literals, nil
}
func descriptorByteLiterals(expr *ast.CompositeLit) ([]*ast.BasicLit, []byte, error) {
arrayType, ok := expr.Type.(*ast.ArrayType)
if !ok || arrayType.Len != nil {
return nil, nil, fmt.Errorf("descriptor must be a byte slice literal")
}
elementType, ok := arrayType.Elt.(*ast.Ident)
if !ok || (elementType.Name != "byte" && elementType.Name != "uint8") {
return nil, nil, fmt.Errorf("descriptor must be a byte slice literal")
}
literals := make([]*ast.BasicLit, 0, len(expr.Elts))
values := make([]byte, 0, len(expr.Elts))
for _, element := range expr.Elts {
literal, ok := element.(*ast.BasicLit)
if !ok || literal.Kind != token.INT {
return nil, nil, fmt.Errorf("descriptor byte slice has unsupported element %T", element)
}
value, err := strconv.ParseUint(literal.Value, 0, 8)
if err != nil {
return nil, nil, fmt.Errorf("decode descriptor byte %q: %w", literal.Value, err)
}
literals = append(literals, literal)
values = append(values, byte(value))
}
return literals, values, nil
}
// xorPBRawBytes hides the one generated raw descriptor in a protobuf source.
// The complete expression is validated before changing the AST so a new protoc
// output shape cannot result in a partially encoded descriptor.
func xorPBRawBytes(src []byte) ([]byte, error) {
fset := token.NewFileSet()
fileAst, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("parse protobuf source: %w", err)
}
declaration, valueSpec, descriptorName, err := findRawDescriptor(fileAst)
if err != nil {
return nil, err
}
if (declaration.Tok != token.CONST && declaration.Tok != token.VAR) ||
len(declaration.Specs) != 1 || len(valueSpec.Names) != 1 || len(valueSpec.Values) != 1 {
return nil, fmt.Errorf("%s has unsupported declaration shape", descriptorName)
}
ast.Inspect(fileAst, func(n ast.Node) bool {
node, ok := n.(*ast.GenDecl)
functionName, stringLiterals, byteLiterals, byteValues, err := rawDescriptorLiterals(declaration, valueSpec, descriptorName)
if err != nil {
return nil, err
}
var xorKey [8]byte
if _, err := rand.Read(xorKey[:]); err != nil {
return nil, fmt.Errorf("generate protobuf descriptor key: %w", err)
}
encodeRawDescriptorLiterals(declaration, functionName, stringLiterals, byteLiterals, byteValues, xorKey[:])
valueSpec.Values[0] = &ast.CallExpr{
Fun: ast.NewIdent(functionName),
Args: []ast.Expr{
valueSpec.Values[0],
ast.NewIdent("xorKey"),
},
}
if err := appendRawDescriptorHelpers(fileAst, functionName, xorKey[:]); err != nil {
return nil, err
}
var out bytes.Buffer
if err := printer.Fprint(&out, fset, fileAst); err != nil {
return nil, fmt.Errorf("print rewritten protobuf source: %w", err)
}
return out.Bytes(), nil
}
func findRawDescriptor(fileAst *ast.File) (*ast.GenDecl, *ast.ValueSpec, string, error) {
var declaration *ast.GenDecl
var valueSpec *ast.ValueSpec
var descriptorName string
for _, decl := range fileAst.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
return true
continue
}
for _, spec := range node.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
for _, spec := range genDecl.Specs {
candidate, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for valueIndex, id := range valueSpec.Names {
if !strings.HasSuffix(id.Name, "_rawDesc") || strings.HasSuffix(id.Name, "_rawDescData") {
for _, name := range candidate.Names {
if !strings.HasSuffix(name.Name, "_rawDesc") {
continue
}
if valueIndex >= len(valueSpec.Values) {
continue
}
switch rawDesc := valueSpec.Values[valueIndex].(type) {
case *ast.CompositeLit:
for i, v := range rawDesc.Elts {
elt := v.(*ast.BasicLit)
elt.Value = xorByte(elt.Value, xorKey[i%len(xorKey)])
}
valueSpec.Values[valueIndex] = &ast.CallExpr{
Fun: ast.NewIdent("xorBytes"),
Args: []ast.Expr{
rawDesc,
ast.NewIdent("xorKey"),
},
}
default:
offset := 0
if xorStringExpr(rawDesc, xorKey, &offset) {
node.Tok = token.VAR
valueSpec.Values[valueIndex] = &ast.CallExpr{
Fun: ast.NewIdent("xorString"),
Args: []ast.Expr{
rawDesc,
ast.NewIdent("xorKey"),
},
}
}
if declaration != nil {
return nil, nil, "", fmt.Errorf("multiple protobuf raw descriptors found")
}
declaration, valueSpec, descriptorName = genDecl, candidate, name.Name
}
}
return true
})
fileAst.Decls = append(fileAst.Decls,
parseHelperDecl(`func xorBytes(input []byte, key []byte) []byte {
out := make([]byte, len(input))
for i := range input {
out[i] = input[i] ^ key[i%len(key)]
}
return out
}`),
parseHelperDecl(`func xorString(input string, key []byte) string {
if declaration == nil {
return nil, nil, "", fmt.Errorf("no protobuf raw descriptor found")
}
return declaration, valueSpec, descriptorName, nil
}
func rawDescriptorLiterals(declaration *ast.GenDecl, valueSpec *ast.ValueSpec, descriptorName string) (string, []*ast.BasicLit, []*ast.BasicLit, []byte, error) {
var stringLiterals []*ast.BasicLit
var byteLiterals []*ast.BasicLit
var byteValues []byte
var err error
functionName := "xorString"
if byteExpr, ok := valueSpec.Values[0].(*ast.CompositeLit); ok {
if declaration.Tok != token.VAR {
return "", nil, nil, nil, fmt.Errorf("%s byte descriptor is not a variable", descriptorName)
}
byteLiterals, byteValues, err = descriptorByteLiterals(byteExpr)
functionName = "xorBytes"
} else {
stringLiterals, err = descriptorStringLiterals(valueSpec.Values[0])
}
if err != nil {
return "", nil, nil, nil, fmt.Errorf("%s: %w", descriptorName, err)
}
return functionName, stringLiterals, byteLiterals, byteValues, nil
}
func encodeRawDescriptorLiterals(declaration *ast.GenDecl, functionName string, stringLiterals, byteLiterals []*ast.BasicLit, byteValues, xorKey []byte) {
if functionName == "xorString" {
offset := 0
for _, literal := range stringLiterals {
plain, _ := strconv.Unquote(literal.Value) // Validated above.
encoded := []byte(plain)
for i := range encoded {
encoded[i] ^= xorKey[(offset+i)%len(xorKey)]
}
offset += len(encoded)
literal.Value = strconv.Quote(string(encoded))
}
declaration.Tok = token.VAR
} else {
for i, literal := range byteLiterals {
literal.Value = fmt.Sprintf("0x%02x", byteValues[i]^xorKey[i%len(xorKey)])
}
}
}
func appendRawDescriptorHelpers(fileAst *ast.File, functionName string, xorKey []byte) error {
helper := `func xorString(input string, key []byte) string {
out := make([]byte, len(input))
for i := 0; i < len(input); i++ {
out[i] = input[i] ^ key[i%len(key)]
}
return string(out)
}`),
)
}`
if functionName == "xorBytes" {
helper = `func xorBytes(input []byte, key []byte) []byte {
out := make([]byte, len(input))
for i := range input {
out[i] = input[i] ^ key[i%len(key)]
}
return out
}`
}
helperDecl, err := parseHelperDecl(helper)
if err != nil {
return err
}
fileAst.Decls = append(fileAst.Decls, helperDecl)
xorTokens := make([]ast.Expr, len(xorKey))
// map xorKey to a slice of ast.BasicLit
for i, b := range xorKey {
xorTokens[i] = &ast.BasicLit{
Kind: token.INT,
Value: fmt.Sprintf("0x%x", b),
}
xorTokens[i] = &ast.BasicLit{Kind: token.INT, Value: fmt.Sprintf("0x%x", b)}
}
// add the global xorKey variable to the AST
fileAst.Decls = append(fileAst.Decls, ast.Decl(&ast.GenDecl{
fileAst.Decls = append(fileAst.Decls, &ast.GenDecl{
Tok: token.VAR,
Specs: []ast.Spec{
&ast.ValueSpec{
Names: []*ast.Ident{ast.NewIdent("xorKey")},
Values: []ast.Expr{
&ast.CompositeLit{
Type: ast.NewIdent("[]byte"),
Elts: xorTokens,
},
},
},
},
}))
outBuff := bytes.Buffer{}
// Render the AST as Go code
printer.Fprint(&outBuff, fset, fileAst)
return outBuff.Bytes()
Specs: []ast.Spec{&ast.ValueSpec{
Names: []*ast.Ident{ast.NewIdent("xorKey")},
Values: []ast.Expr{&ast.CompositeLit{Type: ast.NewIdent("[]byte"), Elts: xorTokens}},
}},
})
return nil
}
func parseHelperDecl(src string) ast.Decl {
func parseHelperDecl(src string) (ast.Decl, error) {
fileAst, err := parser.ParseFile(token.NewFileSet(), "", "package assets\n"+src, 0)
if err != nil {
panic(err)
}
return fileAst.Decls[0]
}
func xorByte(raw string, key byte) string {
// strip 0x
raw = raw[2:]
if len(raw) == 1 {
// Because we got `0x8` at some point
raw = fmt.Sprintf("0%s", raw)
}
hexByte, err := hex.DecodeString(raw)
if err != nil {
panic(err)
}
newByte := hex.EncodeToString([]byte{hexByte[0] ^ key})
return fmt.Sprintf("0x%s", newByte)
}
func xorStringExpr(expr ast.Expr, key [8]byte, offset *int) bool {
switch node := expr.(type) {
case *ast.BasicLit:
if node.Kind != token.STRING {
return false
}
raw, err := strconv.Unquote(node.Value)
if err != nil {
panic(err)
}
rawBytes := []byte(raw)
for i := range rawBytes {
rawBytes[i] ^= key[(*offset+i)%len(key)]
}
*offset += len(rawBytes)
node.Value = strconv.Quote(string(rawBytes))
return true
case *ast.BinaryExpr:
if node.Op != token.ADD {
return false
}
leftOkay := xorStringExpr(node.X, key, offset)
rightOkay := xorStringExpr(node.Y, key, offset)
return leftOkay && rightOkay
case *ast.ParenExpr:
return xorStringExpr(node.X, key, offset)
default:
return false
return nil, fmt.Errorf("parse protobuf descriptor helper: %w", err)
}
return fileAst.Decls[0], nil
}
func unzip(src string, dest string) ([]string, error) {
+407
View File
@@ -0,0 +1,407 @@
package assets
import (
"bytes"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
protobufs "github.com/bishopfox/sliver/protobuf"
)
func parseProtobufSource(t *testing.T, src []byte) *ast.File {
t.Helper()
file, err := parser.ParseFile(token.NewFileSet(), "protobuf.pb.go", src, 0)
if err != nil {
t.Fatalf("parse protobuf source: %v", err)
}
return file
}
func namedValue(t *testing.T, file *ast.File, name string) (token.Token, ast.Expr) {
t.Helper()
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range gen.Specs {
value, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for i, ident := range value.Names {
if ident.Name == name && i < len(value.Values) {
return gen.Tok, value.Values[i]
}
}
}
}
t.Fatalf("declaration %s not found", name)
return token.ILLEGAL, nil
}
func descriptorLiteralBytes(t *testing.T, expr ast.Expr) []byte {
t.Helper()
switch value := expr.(type) {
case *ast.BasicLit:
if value.Kind != token.STRING {
t.Fatalf("descriptor literal has token %s, want STRING", value.Kind)
}
unquoted, err := strconv.Unquote(value.Value)
if err != nil {
t.Fatalf("unquote descriptor literal: %v", err)
}
return []byte(unquoted)
case *ast.BinaryExpr:
if value.Op != token.ADD {
t.Fatalf("descriptor expression uses %s, want +", value.Op)
}
return append(descriptorLiteralBytes(t, value.X), descriptorLiteralBytes(t, value.Y)...)
case *ast.ParenExpr:
return descriptorLiteralBytes(t, value.X)
case *ast.CompositeLit:
decoded := make([]byte, len(value.Elts))
for i, element := range value.Elts {
literal, ok := element.(*ast.BasicLit)
if !ok || literal.Kind != token.INT {
t.Fatalf("descriptor byte %d has type %T, want integer literal", i, element)
}
n, err := strconv.ParseUint(literal.Value, 0, 8)
if err != nil {
t.Fatalf("parse descriptor byte %d: %v", i, err)
}
decoded[i] = byte(n)
}
return decoded
default:
t.Fatalf("unsupported descriptor expression %T", expr)
return nil
}
}
func decodedTransformedDescriptor(t *testing.T, file *ast.File, name string) []byte {
t.Helper()
_, expr := namedValue(t, file, name)
call, ok := expr.(*ast.CallExpr)
if !ok || len(call.Args) != 2 {
t.Fatalf("%s initializer = %T, want decoder call with two arguments", name, expr)
}
decoder, ok := call.Fun.(*ast.Ident)
if !ok || (decoder.Name != "xorString" && decoder.Name != "xorBytes") {
t.Fatalf("%s uses decoder %T, want xorString or xorBytes", name, call.Fun)
}
keyRef, ok := call.Args[1].(*ast.Ident)
if !ok || keyRef.Name != "xorKey" {
t.Fatalf("%s decoder does not use xorKey", name)
}
_, keyExpr := namedValue(t, file, "xorKey")
key := descriptorLiteralBytes(t, keyExpr)
if len(key) == 0 {
t.Fatal("xorKey is empty")
}
encoded := descriptorLiteralBytes(t, call.Args[0])
decoded := make([]byte, len(encoded))
for i := range encoded {
decoded[i] = encoded[i] ^ key[i%len(key)]
}
return decoded
}
func TestXorPBRawBytesPreservesEmbeddedDescriptors(t *testing.T) {
for _, tc := range []struct {
path string
name string
}{
{"sliverpb/sliver.pb.go", "file_sliverpb_sliver_proto_rawDesc"},
{"commonpb/common.pb.go", "file_commonpb_common_proto_rawDesc"},
{"dnspb/dns.pb.go", "file_dnspb_dns_proto_rawDesc"},
} {
t.Run(tc.path, func(t *testing.T) {
source, err := protobufs.FS.ReadFile(tc.path)
if err != nil {
t.Fatal(err)
}
_, originalExpr := namedValue(t, parseProtobufSource(t, source), tc.name)
want := descriptorLiteralBytes(t, originalExpr)
transformed, err := xorPBRawBytes(source)
if err != nil {
t.Fatalf("transform descriptor: %v", err)
}
got := decodedTransformedDescriptor(t, parseProtobufSource(t, transformed), tc.name)
if !bytes.Equal(got, want) {
t.Fatalf("decoded descriptor differs from embedded %s (%d versus %d bytes)", tc.path, len(got), len(want))
}
})
}
}
func TestXorPBRawBytesRejectsUnsupportedDescriptorShapes(t *testing.T) {
for _, tc := range []struct {
name string
src string
}{
{"missing descriptor", "package p\nconst other = \"abc\"\n"},
{"unsupported suffix", "package p\nconst suffix = \"def\"\nconst file_x_rawDesc = \"abc\" + suffix\n"},
{"grouped declaration", "package p\nconst (\nfile_x_rawDesc = \"abc\"\nother = \"def\"\n)\n"},
{"unsupported byte element", "package p\nvar value = byte(1)\nvar file_x_rawDesc = []byte{0x00, value}\n"},
} {
t.Run(tc.name, func(t *testing.T) {
source := []byte(tc.src)
original := bytes.Clone(source)
if _, err := xorPBRawBytes(source); err == nil {
t.Fatal("expected an unsupported descriptor shape to fail")
}
if !bytes.Equal(source, original) {
t.Fatal("failed transform modified its input")
}
})
}
}
func TestXorPBRawBytesSupportsByteLiterals(t *testing.T) {
const source = "package p\nvar file_x_rawDesc = []byte{0x00, 1, 0xff}\n"
transformed, err := xorPBRawBytes([]byte(source))
if err != nil {
t.Fatalf("transform byte descriptor: %v", err)
}
got := decodedTransformedDescriptor(t, parseProtobufSource(t, transformed), "file_x_rawDesc")
if want := []byte{0, 1, 255}; !bytes.Equal(got, want) {
t.Fatalf("decoded byte descriptor = %v, want %v", got, want)
}
}
type protobufFieldTag struct {
structName string
fieldName string
protobuf string
json string
raw string
}
func protobufFieldTags(t *testing.T, src []byte) []protobufFieldTag {
t.Helper()
file := parseProtobufSource(t, src)
tags := []protobufFieldTag{}
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structure, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
for _, field := range structure.Fields.List {
if field.Tag == nil {
continue
}
raw, err := strconv.Unquote(field.Tag.Value)
if err != nil {
t.Fatalf("unquote %s struct tag: %v", typeSpec.Name.Name, err)
}
protoTag, ok := reflect.StructTag(raw).Lookup("protobuf")
if !ok {
continue
}
jsonTag := reflect.StructTag(raw).Get("json")
for _, name := range field.Names {
tags = append(tags, protobufFieldTag{typeSpec.Name.Name, name.Name, protoTag, jsonTag, raw})
}
}
}
}
return tags
}
func nameFromTag(t *testing.T, value string) string {
t.Helper()
for _, part := range strings.Split(value, ",") {
if strings.HasPrefix(part, "name=") {
return strings.TrimPrefix(part, "name=")
}
}
t.Fatalf("protobuf tag %q has no name", value)
return ""
}
func enumFromTag(value string) string {
for _, part := range strings.Split(value, ",") {
if strings.HasPrefix(part, "enum=") {
return strings.TrimPrefix(part, "enum=")
}
}
return ""
}
func tagWithoutNames(value string) string {
parts := strings.Split(value, ",")
for i, part := range parts {
if strings.HasPrefix(part, "name=") {
parts[i] = "name=*"
} else if strings.HasPrefix(part, "enum=") {
parts[i] = "enum=*"
}
}
return strings.Join(parts, ",")
}
func assertRewrittenProtobufFieldTag(t *testing.T, index int, old, newTag protobufFieldTag) {
t.Helper()
if old.structName != newTag.structName || old.fieldName != newTag.fieldName {
t.Fatalf("field %d changed from %s.%s to %s.%s", index, old.structName, old.fieldName, newTag.structName, newTag.fieldName)
}
if tagWithoutNames(old.protobuf) != tagWithoutNames(newTag.protobuf) {
t.Fatalf("wire tag changed for %s.%s: %q -> %q", old.structName, old.fieldName, old.protobuf, newTag.protobuf)
}
oldName := nameFromTag(t, old.protobuf)
newName := nameFromTag(t, newTag.protobuf)
if oldName == newName || len(oldName) != len(newName) {
t.Fatalf("field name was not rewritten with the same length for %s.%s: %q -> %q", old.structName, old.fieldName, oldName, newName)
}
oldJSON := strings.SplitN(old.json, ",", 2)
newJSON := strings.SplitN(newTag.json, ",", 2)
if len(oldJSON) != len(newJSON) || oldJSON[0] == newJSON[0] || len(oldJSON[0]) != len(newJSON[0]) || strings.Join(oldJSON[1:], ",") != strings.Join(newJSON[1:], ",") {
t.Fatalf("JSON tag options changed for %s.%s: %q -> %q", old.structName, old.fieldName, old.json, newTag.json)
}
oldEnum, newEnum := enumFromTag(old.protobuf), enumFromTag(newTag.protobuf)
if oldEnum != "" && (newEnum == oldEnum || len(newEnum) != len(oldEnum)) {
t.Fatalf("enum tag was not rewritten with the same length for %s.%s: %q -> %q", old.structName, old.fieldName, oldEnum, newEnum)
}
}
func assertUnchangedMapEntryTags(t *testing.T, before, after []protobufFieldTag) int {
t.Helper()
count := 0
for i, old := range before {
original := reflect.StructTag(old.raw)
key, hasKey := original.Lookup("protobuf_key")
value, hasValue := original.Lookup("protobuf_val")
if !hasKey && !hasValue {
continue
}
if !hasKey || !hasValue {
t.Fatalf("incomplete map entry tags for %s.%s", old.structName, old.fieldName)
}
rewritten := reflect.StructTag(after[i].raw)
if rewritten.Get("protobuf_key") != key || rewritten.Get("protobuf_val") != value {
t.Fatalf("map entry tags changed for %s.%s", old.structName, old.fieldName)
}
count++
}
return count
}
func TestRewriteProtobufStructTagsPreservesSliverpbWireNumbers(t *testing.T) {
source, err := protobufs.FS.ReadFile("sliverpb/sliver.pb.go")
if err != nil {
t.Fatal(err)
}
transformed, err := rewriteProtobufStructTags(source)
if err != nil {
t.Fatalf("rewrite protobuf tags: %v", err)
}
before := protobufFieldTags(t, source)
after := protobufFieldTags(t, transformed)
if len(before) != 656 || len(after) != len(before) {
t.Fatalf("protobuf tag coverage = %d original, %d rewritten; want 656 each", len(before), len(after))
}
rewrittenByOriginal := make(map[string]string)
packedFound := false
for i, old := range before {
newTag := after[i]
assertRewrittenProtobufFieldTag(t, i, old, newTag)
if old.structName == "PivotStartListenerReq" && old.fieldName == "Options" {
packedFound = true
if !strings.Contains(newTag.protobuf, ",packed,") {
t.Fatalf("packed Options tag lost packed option: %q", newTag.protobuf)
}
}
if earlier, exists := rewrittenByOriginal[old.raw]; exists && earlier != newTag.raw {
t.Fatalf("identical original tag %q rewrote inconsistently", old.raw)
}
rewrittenByOriginal[old.raw] = newTag.raw
}
if !packedFound {
t.Fatal("packed PivotStartListenerReq.Options tag not found")
}
if count := assertUnchangedMapEntryTags(t, before, after); count != 3 {
t.Fatalf("map entry tag coverage = %d, want 3", count)
}
}
func TestRewriteProtobufStructTagsRejectsMissingTags(t *testing.T) {
if _, err := rewriteProtobufStructTags([]byte("package p\ntype Empty struct{}\n")); err == nil {
t.Fatal("expected a source without protobuf field tags to fail")
}
}
func TestSetupGoPathRewritesAllImplantProtobufFieldTags(t *testing.T) {
destination := t.TempDir()
if err := SetupGoPath(destination, true); err != nil {
t.Fatalf("prepare implant protobuf source: %v", err)
}
for _, tc := range []struct {
path string
fieldCount int
}{
{"sliverpb/sliver.pb.go", 656},
{"commonpb/common.pb.go", 19},
{"dnspb/dns.pb.go", 6},
} {
t.Run(tc.path, func(t *testing.T) {
original, err := protobufs.FS.ReadFile(tc.path)
if err != nil {
t.Fatal(err)
}
generated, err := os.ReadFile(filepath.Join(destination, "protobuf", filepath.FromSlash(tc.path)))
if err != nil {
t.Fatal(err)
}
before := protobufFieldTags(t, original)
after := protobufFieldTags(t, generated)
if len(before) != tc.fieldCount || len(after) != len(before) {
t.Fatalf("protobuf field tags = %d original, %d generated; want %d each", len(before), len(after), tc.fieldCount)
}
for i := range before {
assertRewrittenProtobufFieldTag(t, i, before[i], after[i])
}
})
}
}
func TestSetupGoPathReportsTransformedFileWriteErrors(t *testing.T) {
for _, tc := range []struct {
name string
path string
includeDNS bool
}{
{"sliver", "protobuf/sliverpb/sliver.pb.go", false},
{"common", "protobuf/commonpb/common.pb.go", false},
{"dns", "protobuf/dnspb/dns.pb.go", true},
} {
t.Run(tc.name, func(t *testing.T) {
destination := t.TempDir()
blocker := filepath.Join(destination, filepath.FromSlash(tc.path))
if err := os.MkdirAll(blocker, 0o700); err != nil {
t.Fatal(err)
}
err := SetupGoPath(destination, tc.includeDNS)
if err == nil || !strings.Contains(err.Error(), filepath.Base(blocker)) {
t.Fatalf("SetupGoPath() error = %v, want path %s", err, blocker)
}
})
}
}
@@ -20,11 +20,10 @@ package traffic_encoders
import (
"bytes"
"crypto/rand"
_ "embed"
"encoding/hex"
"fmt"
"testing"
"time"
implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders/traffic"
serverEncoders "github.com/bishopfox/sliver/util/encoders/traffic"
@@ -34,102 +33,124 @@ import (
var hexWASM []byte
func TestTrafficEncoderCompatibility_hex(t *testing.T) {
// Hex
implantSideHex, err := implantEncoders.CreateTrafficEncoder("hex", hexWASM, func(msg string) {
t.Log(msg)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := implantSideHex.Close(); err != nil {
t.Errorf("close implant encoder: %v", err)
}
})
serverSideHex, err := serverEncoders.CreateTrafficEncoder("hex", hexWASM, func(msg string) {
t.Log(msg)
})
if err != nil {
t.Fatal(err)
}
data := make([]byte, 1024)
_, err = rand.Read(data)
if err != nil {
t.Fatal(err)
}
encodedData, err := implantSideHex.Encode(data)
if err != nil {
t.Fatal(err)
}
decodedData, err := serverSideHex.Decode(encodedData)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, decodedData) {
t.Fatal("Decoded data does not match original")
}
data = make([]byte, 1024)
_, err = rand.Read(data)
if err != nil {
t.Fatal(err)
}
encodedData, err = serverSideHex.Encode(data)
if err != nil {
t.Fatal(err)
}
decodedData, err = implantSideHex.Decode(encodedData)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, decodedData) {
t.Fatal("Decoded data does not match original")
}
}
// Encoder specific tests
func TestHexPerformance(t *testing.T) {
sizes := []int{1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024}
// Stock encoder
for i := 0; i < len(sizes); i++ {
originalValue := make([]byte, sizes[i])
rand.Read(originalValue)
stock := time.Now()
encodedValue := hex.EncodeToString(originalValue)
decodedValue, err := hex.DecodeString(encodedValue)
if err != nil {
t.Fatal(err)
t.Cleanup(func() {
if err := serverSideHex.Close(); err != nil {
t.Errorf("close server encoder: %v", err)
}
t.Logf("Stock encoder took %v (%d bytes)", time.Since(stock), sizes[i])
if !bytes.Equal(originalValue, decodedValue) {
t.Fatalf("Expected %v but got %v", originalValue, decodedValue)
}
}
})
// Traffic encoder
for i := 0; i < len(sizes); i++ {
encoder, err := serverEncoders.CreateTrafficEncoder("hex", hexWASM, func(msg string) {
t.Log(msg)
directions := []struct {
name string
encode func([]byte) ([]byte, error)
decode func([]byte) ([]byte, error)
}{
{"implant_to_server", implantSideHex.Encode, serverSideHex.Decode},
{"server_to_implant", serverSideHex.Encode, implantSideHex.Decode},
}
for _, direction := range directions {
t.Run(direction.name, func(t *testing.T) {
// Exercise empty and binary inputs, both sides of a 64 KiB WASM
// page boundary, and the largest payload from the old timing test.
for _, size := range []int{0, 1, 256, 65535, 65536, 65537, 4 * 1024 * 1024} {
t.Run(fmt.Sprintf("bytes_%d", size), func(t *testing.T) {
payload := hexTestPayload(size)
encoded, err := direction.encode(payload)
if err != nil {
t.Fatalf("encode: %v", err)
}
if !bytes.Equal(encoded, []byte(hex.EncodeToString(payload))) {
t.Fatal("WASM output does not match standard hex encoding")
}
decoded, err := direction.decode(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(payload, decoded) {
t.Fatalf("round trip changed the %d-byte payload (decoded %d bytes)", len(payload), len(decoded))
}
})
}
})
if err != nil {
t.Fatal(err)
}
defer encoder.Close()
originalValue := make([]byte, sizes[i])
rand.Read(originalValue)
start := time.Now()
encodedValue, err := encoder.Encode(originalValue)
if err != nil {
t.Fatal(err)
}
// t.Logf("Got encoded value (%d bytes)", len(encodedValue))
decodedValue, err := encoder.Decode(encodedValue)
if err != nil {
t.Fatal(err)
}
t.Logf("WASM Hex encoder took %v (%d bytes)", time.Since(start), sizes[i])
if !bytes.Equal(originalValue, decodedValue) {
t.Fatalf("Expected %v but got %v", originalValue, decodedValue)
}
}
}
func BenchmarkHex(b *testing.B) {
for _, size := range []int{1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024} {
payload := hexTestPayload(size)
b.Run(fmt.Sprintf("bytes_%d", size), func(b *testing.B) {
b.Run("standard_round_trip", func(b *testing.B) {
b.SetBytes(int64(size))
b.ReportAllocs()
for b.Loop() {
encoded := hex.EncodeToString(payload)
decoded, err := hex.DecodeString(encoded)
if err != nil {
b.Fatal(err)
}
if !bytes.Equal(payload, decoded) {
b.Fatal("decoded data does not match original")
}
}
})
b.Run("wasm_fresh_runtime_round_trip", func(b *testing.B) {
b.SetBytes(int64(size))
b.ReportAllocs()
// Include runtime creation and close in each iteration. The WASM
// API retains output buffers until the runtime is closed, so an
// unbounded loop over one runtime would accumulate guest memory.
for b.Loop() {
benchmarkHexWASMLifecycle(b, payload)
}
})
})
}
}
func benchmarkHexWASMLifecycle(b *testing.B, payload []byte) {
b.Helper()
encoder, err := serverEncoders.CreateTrafficEncoder("hex", hexWASM, func(string) {})
if err != nil {
b.Fatal(err)
}
defer func() {
if err := encoder.Close(); err != nil {
b.Errorf("close encoder: %v", err)
}
}()
encoded, err := encoder.Encode(payload)
if err != nil {
b.Fatal(err)
}
decoded, err := encoder.Decode(encoded)
if err != nil {
b.Fatal(err)
}
if !bytes.Equal(payload, decoded) {
b.Fatal("decoded data does not match original")
}
}
func hexTestPayload(size int) []byte {
payload := make([]byte, size)
for i := range payload {
payload[i] = byte(i)
}
return payload
}
+40 -41
View File
@@ -46,6 +46,7 @@ import (
"time"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/dnspb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/server/core"
@@ -576,15 +577,7 @@ func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writ
return
}
var resp *dns.Msg
isC2, domain := s.isC2SubDomain(domains, req.Question[0].Name)
if isC2 {
dnsLog.Debugf("'%s' is subdomain of c2 parent '%s'", req.Question[0].Name, domain)
resp = s.handleC2(domain, req)
} else if canaries {
dnsLog.Debugf("checking '%s' for DNS canary matches", req.Question[0].Name)
resp = s.handleCanary(req)
}
resp := s.handleDNSQuery(domains, canaries, req)
if resp != nil {
// These responses often have near-max-length QNAMEs. Enable compression to
// keep UDP responses under the minimum DNS message size limits.
@@ -595,6 +588,27 @@ func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writ
}
}
func (s *SliverDNSServer) handleDNSQuery(domains []string, canaries bool, req *dns.Msg) *dns.Msg {
if canaries {
canary, err := db.CanaryByDomain(req.Question[0].Name)
if err == nil && canary != nil {
return s.handleCanary(req, canary)
}
if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
dnsLog.Errorf("Failed to look up DNS canary: %s", err)
}
}
if isC2, domain := s.isC2SubDomain(domains, req.Question[0].Name); isC2 {
dnsLog.Debugf("'%s' is subdomain of c2 parent '%s'", req.Question[0].Name, domain)
return s.handleC2(domain, req)
}
if canaries {
return s.nameErrorResp(req)
}
return nil
}
// Returns true if the requested domain is a c2 subdomain, and the domain it matched with
func (s *SliverDNSServer) isC2SubDomain(domains []string, reqDomain string) (bool, string) {
for _, parentDomain := range domains {
@@ -1142,39 +1156,24 @@ func (s *SliverDNSServer) handleNOP(domain string, msg *dnspb.DNSMessage, checks
// Canary Record Handler
// ---------------------------
// Canary -> valid? -> trigger alert event
func (s *SliverDNSServer) handleCanary(req *dns.Msg) *dns.Msg {
// Don't block, return error as fast as possible
go func() {
defer recoverAndLogPanic(dnsLog.Errorf, "dns handleCanary")
func (s *SliverDNSServer) handleCanary(req *dns.Msg, canary *clientpb.DNSCanary) *dns.Msg {
firstTrigger, err := generate.TriggerCanary(canary)
if err != nil {
dnsLog.Errorf("Failed to record DNS canary trigger for '%s': %s", canary.Domain, err)
return s.nameErrorResp(req)
}
reqDomain := strings.ToLower(req.Question[0].Name)
if !strings.HasSuffix(reqDomain, ".") {
reqDomain += "." // Ensure we have the FQDN
}
canary, err := db.CanaryByDomain(reqDomain)
if err != nil {
dnsLog.Errorf("Failed to find canary: %s", err)
return
}
if canary != nil {
dnsLog.Warnf("DNS canary tripped for '%s'", canary.ImplantName)
if !canary.Triggered {
// Defer publishing the event until we're sure the db is sync'd
defer core.EventBroker.Publish(core.Event{
Session: &core.Session{
Name: canary.ImplantName,
},
Data: []byte(canary.Domain),
EventType: consts.CanaryEvent,
})
canary.Triggered = true
canary.FirstTriggered = time.Now().Format(time.RFC1123)
}
canary.LatestTrigger = time.Now().Format(time.RFC1123)
canary.Count++
generate.UpdateCanary(canary)
}
}()
dnsLog.Warnf("DNS canary tripped for '%s'", canary.ImplantName)
if firstTrigger {
// Publish only after the trigger transaction commits successfully.
core.EventBroker.Publish(core.Event{
Session: &core.Session{
Name: canary.ImplantName,
},
Data: []byte(canary.Domain),
EventType: consts.CanaryEvent,
})
}
return s.nameErrorResp(req)
}
+297
View File
@@ -0,0 +1,297 @@
package c2
import (
"encoding/binary"
"errors"
"hash/crc32"
"net"
"strings"
"sync/atomic"
"testing"
"time"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/dnspb"
"github.com/bishopfox/sliver/server/core"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
"github.com/miekg/dns"
"google.golang.org/protobuf/proto"
"gorm.io/gorm"
)
func TestDNSCanarySameParentTriggersOnce(t *testing.T) {
const parent = "canary-parent.invalid."
// Legacy records can contain mixed-case parent names, and DNS queries are
// case-insensitive independently of how the registered name was spelled.
canary := createDNSCanaryRecord(t, models.NewUUID().String()+".CaNaRy-PaReNt.InVaLiD.")
events := subscribeDNSCanaryEvents(t)
lookups := countDNSCanaryLookups(t)
server := newTestDNSServer()
response := queryDNSCanary(t, server, parent, strings.ToUpper(canary.Domain), true)
assertDNSCanaryResponse(t, response)
if got := lookups.Load(); got != 1 {
t.Fatalf("first query performed %d canary lookups, want 1", got)
}
first := assertDNSCanaryRecord(t, canary, 1)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
lookups.Store(0)
response = queryDNSCanary(t, server, parent, strings.ToLower(canary.Domain), true)
assertDNSCanaryResponse(t, response)
if got := lookups.Load(); got != 1 {
t.Fatalf("repeat query performed %d canary lookups, want 1", got)
}
second := assertDNSCanaryRecord(t, canary, 2)
if !second.FirstTrigger.Equal(first.FirstTrigger) || second.LatestTrigger.Before(first.LatestTrigger) {
t.Error("repeat query changed the first trigger or moved the latest trigger backwards")
}
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
}
func TestDNSCanaryC2Routing(t *testing.T) {
tests := []struct {
name string
registered bool
enabled bool
}{
{name: "unknown_name", enabled: true},
{name: "disabled_detection", registered: true},
{name: "registered_name_precedes_c2", registered: true, enabled: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server, parent, domain, checksum := newDNSCanaryNOP(t)
var canary *models.DNSCanary
if tt.registered {
canary = createDNSCanaryRecord(t, domain)
}
events := subscribeDNSCanaryEvents(t)
lookups := countDNSCanaryLookups(t)
response := queryDNSCanary(t, server, parent, domain, tt.enabled)
wantLookups := int64(0)
if tt.enabled {
wantLookups = 1
}
if got := lookups.Load(); got != wantLookups {
t.Fatalf("performed %d canary lookups, want %d", got, wantLookups)
}
if tt.registered && tt.enabled {
assertDNSCanaryResponse(t, response)
assertDNSCanaryRecord(t, canary, 1)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
return
}
assertDNSCanaryNOP(t, response, checksum)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
if tt.registered {
assertDNSCanaryRecord(t, canary, 0)
}
})
}
}
func TestDNSCanaryFailedTriggerDoesNotPublish(t *testing.T) {
const parent = "canary-failure.invalid."
canary := createDNSCanaryRecord(t, models.NewUUID().String()+"."+parent)
events := subscribeDNSCanaryEvents(t)
reject := rejectDNSCanaryUpdates(t)
server := newTestDNSServer()
assertDNSCanaryResponse(t, queryDNSCanary(t, server, parent, canary.Domain, true))
assertDNSCanaryRecord(t, canary, 0)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
reject.Store(false)
assertDNSCanaryResponse(t, queryDNSCanary(t, server, parent, canary.Domain, true))
assertDNSCanaryRecord(t, canary, 1)
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
}
func createDNSCanaryRecord(t *testing.T, domain string) *models.DNSCanary {
t.Helper()
canary := &models.DNSCanary{ImplantName: "dns-canary-" + models.NewUUID().String(), Domain: domain}
if err := db.Session().Create(canary).Error; err != nil {
t.Fatalf("create canary: %v", err)
}
t.Cleanup(func() {
if err := db.Session().Where("id = ?", canary.ID).Delete(&models.DNSCanary{}).Error; err != nil {
t.Errorf("delete canary: %v", err)
}
})
return canary
}
func assertDNSCanaryRecord(t *testing.T, canary *models.DNSCanary, count uint32) models.DNSCanary {
t.Helper()
var stored models.DNSCanary
if err := db.Session().Where("id = ?", canary.ID).First(&stored).Error; err != nil {
t.Fatalf("reload canary: %v", err)
}
if stored.Count != count || stored.Triggered != (count > 0) {
t.Fatalf("stored count=%d triggered=%v, want count=%d triggered=%v", stored.Count, stored.Triggered, count, count > 0)
}
if stored.FirstTrigger.IsZero() != (count == 0) || stored.LatestTrigger.IsZero() != (count == 0) {
t.Error("trigger timestamps do not match the persisted count")
}
return stored
}
func countDNSCanaryLookups(t *testing.T) *atomic.Int64 {
t.Helper()
var count atomic.Int64
name := "test:count-dns-canary-lookups-" + models.NewUUID().String()
if err := db.Client.Callback().Query().Before("gorm:query").Register(name, func(tx *gorm.DB) {
if tx.Statement.Table == "dns_canaries" {
count.Add(1)
}
}); err != nil {
t.Fatalf("register canary query counter: %v", err)
}
t.Cleanup(func() { _ = db.Client.Callback().Query().Remove(name) })
return &count
}
func rejectDNSCanaryUpdates(t *testing.T) *atomic.Bool {
t.Helper()
var reject atomic.Bool
reject.Store(true)
name := "test:reject-dns-canary-updates-" + models.NewUUID().String()
if err := db.Client.Callback().Update().Before("gorm:update").Register(name, func(tx *gorm.DB) {
if reject.Load() && tx.Statement.Table == "dns_canaries" {
_ = tx.AddError(errors.New("injected DNS canary update failure"))
}
}); err != nil {
t.Fatalf("register canary update failure: %v", err)
}
t.Cleanup(func() { _ = db.Client.Callback().Update().Remove(name) })
return &reject
}
func subscribeDNSCanaryEvents(t *testing.T) chan core.Event {
t.Helper()
events := core.EventBroker.Subscribe()
t.Cleanup(func() { core.EventBroker.Unsubscribe(events) })
// Subscription registration is asynchronous. Complete a broker round trip
// before sending requests so the first canary event cannot be missed.
collectDNSCanaryEvents(t, events, true)
return events
}
func collectDNSCanaryEvents(t *testing.T, events <-chan core.Event, subscribing bool) []core.Event {
t.Helper()
marker := core.Event{EventType: "dns-canary-test-barrier", Data: []byte(models.NewUUID().String())}
core.EventBroker.Publish(marker)
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
var canaries []core.Event
for {
select {
case event := <-events:
if event.EventType == marker.EventType && string(event.Data) == string(marker.Data) {
return canaries
}
if event.EventType == consts.CanaryEvent {
canaries = append(canaries, event)
}
case <-ticker.C:
if subscribing {
marker.Data = []byte(models.NewUUID().String())
core.EventBroker.Publish(marker)
}
case <-timer.C:
t.Fatal("timed out waiting for DNS canary event barrier")
}
}
}
func assertDNSCanaryEvents(t *testing.T, events []core.Event, canary *models.DNSCanary, count int) {
t.Helper()
if len(events) != count {
t.Fatalf("received %d canary events, want %d", len(events), count)
}
for _, event := range events {
if event.Session == nil || event.Session.Name != canary.ImplantName || string(event.Data) != canary.Domain {
t.Errorf("unexpected canary event: %+v", event)
}
}
}
func newDNSCanaryNOP(t *testing.T) (*SliverDNSServer, string, string, uint32) {
t.Helper()
server := newTestDNSServer()
const sessionID = uint32(0x123456)
server.sessions.Store(sessionID, newTestDNSSession(sessionID))
parent := models.NewUUID().String() + ".canary-c2.invalid."
msg := &dnspb.DNSMessage{Type: dnspb.DNSMessageType_NOP, ID: (1 << 24) | sessionID, Data: []byte("canary-routing")}
raw, err := proto.Marshal(msg)
if err != nil {
t.Fatalf("marshal DNS NOP: %v", err)
}
domain, err := encodeMessageDomain("."+parent, msg)
if err != nil {
t.Fatalf("encode DNS NOP: %v", err)
}
return server, parent, domain, crc32.ChecksumIEEE(raw)
}
func queryDNSCanary(t *testing.T, server *SliverDNSServer, parent, domain string, enabled bool) *dns.Msg {
t.Helper()
req := new(dns.Msg)
req.SetQuestion(domain, dns.TypeA)
writer := &canaryDNSResponseWriter{}
server.HandleDNSRequest([]string{parent}, enabled, writer, req)
if writer.response == nil {
t.Fatal("DNS handler did not write a response")
}
return writer.response
}
func assertDNSCanaryResponse(t *testing.T, response *dns.Msg) {
t.Helper()
if response.Rcode != dns.RcodeNameError || !response.Authoritative {
t.Fatalf("canary response rcode=%d authoritative=%v, want authoritative NXDOMAIN", response.Rcode, response.Authoritative)
}
}
func assertDNSCanaryNOP(t *testing.T, response *dns.Msg, checksum uint32) {
t.Helper()
if response.Rcode != dns.RcodeSuccess || len(response.Answer) != 1 {
t.Fatalf("NOP response rcode=%d answers=%d, want success with one answer", response.Rcode, len(response.Answer))
}
answer, ok := response.Answer[0].(*dns.A)
if !ok || len(answer.A) != 4 {
t.Fatalf("unexpected DNS NOP answer: %v", response.Answer[0])
}
if got := binary.LittleEndian.Uint32(answer.A); got != checksum {
t.Fatalf("NOP checksum=%d, want %d", got, checksum)
}
}
type canaryDNSResponseWriter struct {
response *dns.Msg
}
func (w *canaryDNSResponseWriter) LocalAddr() net.Addr { return &net.UDPAddr{} }
func (w *canaryDNSResponseWriter) RemoteAddr() net.Addr { return &net.UDPAddr{} }
func (w *canaryDNSResponseWriter) Close() error { return nil }
func (w *canaryDNSResponseWriter) TsigStatus() error { return nil }
func (w *canaryDNSResponseWriter) TsigTimersOnly(bool) {}
func (w *canaryDNSResponseWriter) Hijack() {}
func (w *canaryDNSResponseWriter) WriteMsg(msg *dns.Msg) error {
w.response = msg.Copy()
return nil
}
func (w *canaryDNSResponseWriter) Write(data []byte) (int, error) {
msg := new(dns.Msg)
if err := msg.Unpack(data); err != nil {
return 0, err
}
w.response = msg
return len(data), nil
}
-173
View File
@@ -1,173 +0,0 @@
package c2
/*
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 (
// "bytes"
// "fmt"
// insecureRand "math/rand"
// "net/http"
// "net/http/httptest"
// "net/url"
// "testing"
// implantCrypto "github.com/bishopfox/sliver/implant/sliver/cryptography"
// implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders"
// implantTransports "github.com/bishopfox/sliver/implant/sliver/transports/httpclient"
// "github.com/bishopfox/sliver/protobuf/sliverpb"
// "github.com/bishopfox/sliver/server/configs"
// "github.com/bishopfox/sliver/server/cryptography"
// "google.golang.org/protobuf/proto"
// )
// func TestStartSessionHandler(t *testing.T) {
// implantTransports.SetNonceQueryArgChars("abcdedfghijklmnopqrstuvwxyz")
// server, err := StartHTTPListener(&HTTPServerConfig{
// Addr: "127.0.0.1:8888",
// Secure: false,
// EnforceOTP: true,
// })
// if err != nil {
// t.Fatalf("Listener failed to start %s", err)
// return
// }
// client := implantTransports.SliverHTTPClient{}
// baseURL := &url.URL{
// Scheme: "http",
// Host: "127.0.0.1:8888",
// Path: fmt.Sprintf("/test/foo.%s", c2Config.ImplantConfig.StartSessionFileExt),
// }
// nonce, encoder := implantEncoders.RandomEncoder(0)
// testURL := client.NonceQueryArgument(baseURL, nonce)
// testURL = client.OTPQueryArgument(testURL, implantCrypto.GetOTPCode())
// // Generate key exchange request
// sKey := cryptography.RandomKey()
// httpSessionInit := &sliverpb.HTTPSessionInit{Key: sKey[:]}
// data, _ := proto.Marshal(httpSessionInit)
// encryptedSessionInit, err := implantCrypto.ECCEncryptToServer(data)
// if err != nil {
// t.Fatalf("Failed to encrypt session init %s", err)
// }
// payload, _ := encoder.Encode(encryptedSessionInit)
// body := bytes.NewReader(payload)
// validReq := httptest.NewRequest(http.MethodPost, testURL.String(), body)
// t.Logf("[http] req request uri: '%v'", validReq.RequestURI)
// rr := httptest.NewRecorder()
// server.HTTPServer.Handler.ServeHTTP(rr, validReq)
// if status := rr.Code; status != http.StatusOK {
// t.Fatalf("handler returned wrong status code: got %d want %d", status, http.StatusOK)
// }
// }
// func TestGetOTPFromURL(t *testing.T) {
// implantTransports.SetNonceQueryArgChars("abcdedfghijklmnopqrstuvwxyz")
// client := implantTransports.SliverHTTPClient{}
// for i := 0; i < 100; i++ {
// baseURL := &url.URL{
// Scheme: "http",
// Host: "127.0.0.1:8888",
// Path: "/test/foo.txt",
// }
// value := fmt.Sprintf("%d", insecureRand.Intn(99999999))
// testURL := client.OTPQueryArgument(baseURL, value)
// urlValue, err := getOTPFromURL(testURL)
// if err != nil {
// t.Fatal(err)
// }
// if urlValue != value {
// t.Fatalf("Mismatched OTP values %s (%s != %s)", testURL.String(), value, urlValue)
// }
// }
// }
// func TestGetNonceFromURL(t *testing.T) {
// implantTransports.SetNonceQueryArgChars("abcdedfghijklmnopqrstuvwxyz")
// client := implantTransports.SliverHTTPClient{}
// for i := 0; i < 100; i++ {
// baseURL := &url.URL{
// Scheme: "http",
// Host: "127.0.0.1:8888",
// Path: "/test/foo.txt",
// }
// nonce, encoder := implantEncoders.RandomEncoder(0)
// testURL := client.NonceQueryArgument(baseURL, nonce)
// t.Log(testURL.String())
// urlNonce, err := getNonceFromURL(testURL)
// if err != nil {
// t.Errorf("Nonce '%d' triggered error from %#v", nonce, encoder)
// t.Fatal(err)
// }
// if urlNonce != nonce {
// t.Fatalf("Mismatched encoder nonces %s (%d != %d)", testURL.String(), nonce, urlNonce)
// }
// }
// }
// func TestGetNonceFromURLWithCustomQueryArgs(t *testing.T) {
// for j := 0; j < 100; j++ {
// queryArgs := randomArgs(1)
// implantTransports.SetNonceQueryArgChars(queryArgs)
// t.Logf("Using query args: %s", queryArgs)
// client := implantTransports.SliverHTTPClient{}
// for i := 0; i < 10; i++ {
// baseURL := &url.URL{
// Scheme: "http",
// Host: "127.0.0.1:8888",
// Path: "/test/foo.txt",
// }
// nonce, encoder := implantEncoders.RandomEncoder(0)
// testURL := client.NonceQueryArgument(baseURL, nonce)
// t.Log(testURL.String())
// urlNonce, err := getNonceFromURL(testURL)
// if err != nil {
// t.Errorf("Nonce '%d' triggered error from %#v", nonce, encoder)
// t.Fatal(err)
// }
// if urlNonce != nonce {
// t.Fatalf("Mismatched encoder nonces %s (%d != %d)", testURL.String(), nonce, urlNonce)
// }
// }
// }
// }
// func randomArgs(min int) string {
// var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()")
// size := insecureRand.Intn(25) + min
// b := make(map[rune]interface{}, size)
// for len(b) <= size {
// b[letters[insecureRand.Intn(len(letters))]] = nil
// }
// var keys string
// for k := range b {
// keys += string(k)
// }
// return keys
// }
+4 -4
View File
@@ -119,7 +119,7 @@ var rootCmd = &cobra.Command{
Use: "sliver-server",
Short: "",
Long: ``,
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, _ []string) error {
// Root command starts the server normally
appDir := assets.GetRootAppDir()
@@ -154,15 +154,15 @@ var rootCmd = &cobra.Command{
}
if serverConfig.DaemonMode {
daemon.Start(daemon.BlankHost, daemon.BlankPort, serverConfig.DaemonConfig.Tailscale, serverConfig.DaemonConfig.WireGuardEnabled())
return nil
} else {
rcScript, err := clientcli.ReadRCScript(cmd)
if err != nil {
fmt.Printf("Failed to read rc script: %s\n", err)
return
return err
}
os.Args = os.Args[:1] // Hide cli from grumble console
console.Start(rcScript)
return console.Start(rcScript)
}
},
}
+2 -10
View File
@@ -147,22 +147,14 @@ func defaultAIConfig() *AIConfig {
func (c *AIConfig) Save() error {
config := normalizeAIConfig(c)
configPath := GetAIConfigPath()
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
aiConfigLog.Debugf("Creating config dir %s", configDir)
err := os.MkdirAll(configDir, 0700)
if err != nil {
return err
}
}
data, err := yaml.Marshal(config)
if err != nil {
return err
}
aiConfigLog.Infof("Saving config to %s", configPath)
err = os.WriteFile(configPath, data, 0600)
err = atomicWriteConfig(configPath, data)
if err != nil {
aiConfigLog.Errorf("Failed to write config %s", err)
aiConfigLog.Errorf("Failed to write config %s: %s", configPath, err)
}
return err
}
+233
View File
@@ -0,0 +1,233 @@
package configs
import (
"bytes"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
)
func TestConfigSaveReportsWriteFailure(t *testing.T) {
tests := []struct {
name string
path func() string
save func() error
}{
{
name: "database",
path: GetDatabaseConfigPath,
save: func() error { return (&DatabaseConfig{Dialect: Sqlite}).Save() },
},
{
name: "server",
path: GetServerConfigPath,
save: func() error { return (&ServerConfig{}).Save() },
},
{
name: "crack",
path: getCrackConfigPath,
save: func() error { return SaveCrackConfig(&clientpb.CrackConfig{}) },
},
{
name: "ai",
path: GetAIConfigPath,
save: func() error { return (&AIConfig{}).Save() },
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
path := test.path()
if err := os.MkdirAll(path, 0700); err != nil {
t.Fatalf("create directory at config path: %v", err)
}
if err := test.save(); err == nil {
t.Fatal("save succeeded despite config path being a directory")
}
})
}
}
func TestAtomicWriteConfigPreservesPreviousFileOnRenameFailure(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
previous := []byte("value: previous\n")
if err := os.WriteFile(path, previous, 0644); err != nil {
t.Fatal(err)
}
oldRename := renameConfigFile
t.Cleanup(func() { renameConfigFile = oldRename })
injectedErr := errors.New("injected rename failure")
renameConfigFile = func(_, newPath string) error {
if newPath != path {
t.Fatalf("rename targeted %q; want %q", newPath, path)
}
return injectedErr
}
if err := atomicWriteConfig(path, []byte("value: replacement\n")); !errors.Is(err, injectedErr) {
t.Fatalf("save error = %v; want injected rename error", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, previous) {
t.Fatalf("failed save changed previous config: %q", got)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != filepath.Base(path) {
t.Fatalf("failed save left temporary files: %v", entries)
}
renameConfigFile = oldRename
replacement := []byte("value: replacement\n")
if err := atomicWriteConfig(path, replacement); err != nil {
t.Fatalf("save after rename recovery: %v", err)
}
got, err = os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, replacement) {
t.Fatalf("saved config = %q; want %q", got, replacement)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
// Windows reports synthetic permission bits instead of POSIX owner modes.
if got := info.Mode().Perm(); runtime.GOOS != "windows" && got != 0600 {
t.Fatalf("saved config mode = %04o; want 0600", got)
}
}
func TestAtomicWriteConfigPreservesExistingSymlink(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.yaml")
link := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(target, []byte("old\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Base(target), link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
want := []byte("new\n")
if err := atomicWriteConfig(link, want); err != nil {
t.Fatal(err)
}
info, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("save replaced existing symlink")
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("symlink target = %q; want %q", got, want)
}
}
func TestLegacyConfigNotRetiredAfterSaveFailure(t *testing.T) {
tests := []struct {
name string
path func() string
legacyPath func() string
legacyJSON []byte
load func(*testing.T)
}{
{
name: "database",
path: GetDatabaseConfigPath,
legacyPath: getDatabaseLegacyConfigPath,
legacyJSON: []byte(`{"dialect":"postgresql","database":"legacy-db","max_idle_conns":1,"max_open_conns":1}`),
load: func(t *testing.T) {
if got := GetDatabaseConfig().Database; got != "legacy-db" {
t.Fatalf("loaded database = %q; want legacy-db", got)
}
},
},
{
name: "server",
path: GetServerConfigPath,
legacyPath: getServerLegacyConfigPath,
legacyJSON: []byte(`{"daemon":{"host":"127.0.0.1","port":31337},"logs":{"level":3},"go_proxy":"http://legacy.example"}`),
load: func(t *testing.T) {
if got := GetServerConfig().GoProxy; got != "http://legacy.example" {
t.Fatalf("loaded proxy = %q; want legacy value", got)
}
},
},
{
name: "crack",
path: getCrackConfigPath,
legacyPath: getCrackLegacyConfigPath,
legacyJSON: []byte(`{"AutoFire":true,"MaxFileSize":4096,"ChunkSize":2048,"MaxDiskUsage":8192}`),
load: func(t *testing.T) {
config, err := LoadCrackConfig()
if err == nil {
t.Fatal("load did not report failed save")
}
if config.MaxFileSize != 4096 {
t.Fatalf("loaded max file size = %d; want 4096", config.MaxFileSize)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
path := test.path()
legacyPath := test.legacyPath()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyPath, test.legacyJSON, 0600); err != nil {
t.Fatal(err)
}
oldRename := renameConfigFile
t.Cleanup(func() { renameConfigFile = oldRename })
injectedErr := errors.New("injected config rename failure")
attempted := false
renameConfigFile = func(oldPath, newPath string) error {
if newPath == path {
attempted = true
return injectedErr
}
return oldRename(oldPath, newPath)
}
test.load(t)
if !attempted {
t.Fatal("config save did not attempt to replace YAML")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("failed save created YAML config: %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("failed save removed legacy config: %v", err)
}
if !bytes.Equal(got, test.legacyJSON) {
t.Fatalf("failed save changed legacy config: %q", got)
}
if _, err := os.Stat(legacyBackupPath(legacyPath)); !os.IsNotExist(err) {
t.Fatalf("failed save retired legacy config: %v", err)
}
})
}
}
+3 -11
View File
@@ -124,24 +124,16 @@ func crackConfigToYAML(config *clientpb.CrackConfig) ([]byte, error) {
// Save - Save config file to disk
func SaveCrackConfig(config *clientpb.CrackConfig) error {
configPath := getCrackConfigPath()
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
crackConfigLog.Debugf("Creating config dir %s", configDir)
err := os.MkdirAll(configDir, 0700)
if err != nil {
return err
}
}
data, err := crackConfigToYAML(config)
if err != nil {
return err
}
crackConfigLog.Infof("Saving crack config to %s", configPath)
err = os.WriteFile(configPath, data, 0600)
err = atomicWriteConfig(configPath, data)
if err != nil {
crackConfigLog.Errorf("Failed to write config %s", err)
crackConfigLog.Errorf("Failed to write config %s: %s", configPath, err)
}
return nil
return err
}
// LoadCrackConfig - Get config value
+36 -25
View File
@@ -22,10 +22,13 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/bishopfox/sliver/server/assets"
"github.com/bishopfox/sliver/server/log"
@@ -98,27 +101,43 @@ func (c *DatabaseConfig) DSN() (string, error) {
params := encodeSQLiteParams(c.Params, c.Pragmas)
return fmt.Sprintf("file:%s?%s", filePath, params), nil
case MySQL:
user := url.QueryEscape(c.Username)
password := url.QueryEscape(c.Password)
db := url.QueryEscape(c.Database)
host := fmt.Sprintf("%s:%d", url.QueryEscape(c.Host), c.Port)
if strings.Contains(c.Username, ":") {
return "", errors.New("MySQL DSN cannot represent a colon in the username")
}
host := net.JoinHostPort(c.Host, strconv.Itoa(int(c.Port)))
params := encodeParams(c.Params)
databaseConfigLog.Infof("Connecting to MySQL database %s@%s/%s", user, host, db)
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", user, password, host, db, params), nil
databaseConfigLog.Infof("Connecting to MySQL database %q at %q", c.Database, host)
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", c.Username, c.Password, host, url.PathEscape(c.Database), params), nil
case Postgres:
user := url.QueryEscape(c.Username)
password := url.QueryEscape(c.Password)
db := url.QueryEscape(c.Database)
host := url.QueryEscape(c.Host)
port := c.Port
params := encodeParams(c.Params)
databaseConfigLog.Infof("Connecting to Postgres database %s@%s:%d/%s", user, host, port, db)
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s %s", host, port, user, password, db, params), nil
parts := []string{
"host=" + quotePostgresDSNValue(c.Host),
"port=" + strconv.Itoa(int(c.Port)),
"user=" + quotePostgresDSNValue(c.Username),
"password=" + quotePostgresDSNValue(c.Password),
"dbname=" + quotePostgresDSNValue(c.Database),
}
keys := make([]string, 0, len(c.Params))
for key := range c.Params {
if key == "" || strings.ContainsAny(key, "= \t\n\r\v\f'\\\x00") {
return "", fmt.Errorf("invalid PostgreSQL parameter key %q", key)
}
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
parts = append(parts, key+"="+quotePostgresDSNValue(c.Params[key]))
}
databaseConfigLog.Infof("Connecting to Postgres database %q at %q:%d", c.Database, c.Host, c.Port)
return strings.Join(parts, " "), nil
default:
return "", ErrInvalidDialect
}
}
func quotePostgresDSNValue(value string) string {
return "'" + strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), `'`, `\'`) + "'"
}
func encodeParams(rawParams map[string]string) string {
params := url.Values{}
for key, value := range rawParams {
@@ -168,24 +187,16 @@ func encodeSQLiteParams(rawParams map[string]string, pragmas map[string]string)
// Save - Save config file to disk
func (c *DatabaseConfig) Save() error {
configPath := GetDatabaseConfigPath()
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
databaseConfigLog.Debugf("Creating config dir %s", configDir)
err := os.MkdirAll(configDir, 0700)
if err != nil {
return err
}
}
data, err := yaml.Marshal(c)
if err != nil {
return err
}
databaseConfigLog.Infof("Saving config to %s", configPath)
err = os.WriteFile(configPath, data, 0600)
err = atomicWriteConfig(configPath, data)
if err != nil {
databaseConfigLog.Errorf("Failed to write config %s", err)
databaseConfigLog.Errorf("Failed to write config %s: %s", configPath, err)
}
return nil
return err
}
// GetDatabaseConfig - Get config value
+125
View File
@@ -0,0 +1,125 @@
package configs
import (
"net"
"testing"
"github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5/pgconn"
)
func TestDatabaseConfigMySQLDSNRoundTrip(t *testing.T) {
config := &DatabaseConfig{
Dialect: MySQL,
Database: "team/archive +%?",
Username: "worker+node",
Password: "p@ss +/%':word",
Host: "2001:db8::1",
Port: 3306,
Params: map[string]string{
"parseTime": "true",
"sql_mode": "ANSI_QUOTES,NO_BACKSLASH_ESCAPES",
},
}
dsn, err := config.DSN()
if err != nil {
t.Fatalf("DSN: %v", err)
}
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatalf("mysql.ParseDSN: %v", err)
}
if parsed.User != config.Username {
t.Errorf("user = %q, want %q", parsed.User, config.Username)
}
if parsed.Passwd != config.Password {
t.Errorf("password did not round-trip")
}
if parsed.DBName != config.Database {
t.Errorf("database = %q, want %q", parsed.DBName, config.Database)
}
if parsed.Net != "tcp" || parsed.Addr != net.JoinHostPort(config.Host, "3306") {
t.Errorf("network address = %q(%q), want tcp(%q)", parsed.Net, parsed.Addr, net.JoinHostPort(config.Host, "3306"))
}
if !parsed.ParseTime {
t.Error("parseTime parameter was not preserved")
}
if parsed.Params["sql_mode"] != config.Params["sql_mode"] {
t.Errorf("sql_mode = %q, want %q", parsed.Params["sql_mode"], config.Params["sql_mode"])
}
}
func TestDatabaseConfigMySQLDSNRejectsColonInUsername(t *testing.T) {
config := &DatabaseConfig{
Dialect: MySQL,
Username: "worker:node",
Password: "password",
Host: "localhost",
Port: 3306,
}
if _, err := config.DSN(); err == nil {
t.Fatal("expected an error for a username that the MySQL DSN grammar cannot represent")
}
}
func TestDatabaseConfigPostgresDSNRoundTrip(t *testing.T) {
config := &DatabaseConfig{
Dialect: Postgres,
Database: "team db/primary'\\",
Username: "worker's account\\ops",
Password: "secret 'with' spaces\\and/slashes",
Host: "db.internal",
Port: 5432,
Params: map[string]string{
"application_name": "audit worker's \\ trial",
"search_path": "tenant, public",
"sslmode": "disable",
},
}
dsn, err := config.DSN()
if err != nil {
t.Fatalf("DSN: %v", err)
}
parsed, err := pgconn.ParseConfig(dsn)
if err != nil {
t.Fatalf("pgconn.ParseConfig: %v", err)
}
if parsed.User != config.Username {
t.Errorf("user = %q, want %q", parsed.User, config.Username)
}
if parsed.Password != config.Password {
t.Errorf("password did not round-trip")
}
if parsed.Database != config.Database {
t.Errorf("database = %q, want %q", parsed.Database, config.Database)
}
if parsed.Host != config.Host || parsed.Port != config.Port {
t.Errorf("address = %q:%d, want %q:%d", parsed.Host, parsed.Port, config.Host, config.Port)
}
for _, key := range []string{"application_name", "search_path"} {
if parsed.RuntimeParams[key] != config.Params[key] {
t.Errorf("%s = %q, want %q", key, parsed.RuntimeParams[key], config.Params[key])
}
}
if parsed.TLSConfig != nil {
t.Error("sslmode=disable was not preserved")
}
}
func TestDatabaseConfigPostgresDSNRejectsInvalidParameterKey(t *testing.T) {
config := &DatabaseConfig{
Dialect: Postgres,
Database: "sliver",
Username: "worker",
Host: "db.internal",
Port: 5432,
Params: map[string]string{
"sslmode=disable user": "attacker",
},
}
if _, err := config.DSN(); err == nil {
t.Fatal("expected an error for a PostgreSQL parameter key that can inject another keyword")
}
}
+3 -11
View File
@@ -248,24 +248,16 @@ func (c *ServerConfig) Save() error {
}
configPath := GetServerConfigPath()
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
serverConfigLog.Debugf("Creating config dir %s", configDir)
err := os.MkdirAll(configDir, 0700)
if err != nil {
return err
}
}
data, err := yaml.Marshal(c)
if err != nil {
return err
}
serverConfigLog.Infof("Saving config to %s", configPath)
err = os.WriteFile(configPath, data, 0600)
err = atomicWriteConfig(configPath, data)
if err != nil {
serverConfigLog.Errorf("Failed to write config %s", err)
serverConfigLog.Errorf("Failed to write config %s: %s", configPath, err)
}
return nil
return err
}
// GetServerConfig - Get config value
+60
View File
@@ -0,0 +1,60 @@
package configs
import (
"fmt"
"io"
"os"
"path/filepath"
)
// renameConfigFile is replaceable by tests to exercise failed commits.
var renameConfigFile = os.Rename
// atomicWriteConfig replaces a config only after its complete contents reach disk.
// Existing symlinks keep pointing at the same target rather than being replaced.
func atomicWriteConfig(path string, data []byte) error {
target := path
info, err := os.Lstat(path)
if err == nil && info.Mode()&os.ModeSymlink != 0 {
target, err = filepath.EvalSymlinks(path)
if err != nil {
return fmt.Errorf("resolve config symlink %s: %w", path, err)
}
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("inspect config %s: %w", path, err)
}
dir := filepath.Dir(target)
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("create config directory %s: %w", dir, err)
}
temp, err := os.CreateTemp(dir, "."+filepath.Base(target)+".tmp-*")
if err != nil {
return fmt.Errorf("create temporary config: %w", err)
}
defer func() {
_ = temp.Close()
_ = os.Remove(temp.Name())
}()
if err := temp.Chmod(0600); err != nil {
return fmt.Errorf("set temporary config permissions: %w", err)
}
n, err := temp.Write(data)
if err != nil {
return fmt.Errorf("write temporary config: %w", err)
}
if n != len(data) {
return io.ErrShortWrite
}
if err := temp.Sync(); err != nil {
return fmt.Errorf("sync temporary config: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("close temporary config: %w", err)
}
if err := renameConfigFile(temp.Name(), target); err != nil {
return fmt.Errorf("replace config %s: %w", path, err)
}
return nil
}
+49 -19
View File
@@ -261,7 +261,7 @@ func kickOperatorCmd(cmd *cobra.Command, args []string) {
return
}
exists, err := operatorExists(operator)
exists, err := operatorNeedsRevocation(operator)
if err != nil {
fmt.Printf(Warn+"Failed to lookup operator %s: %v\n", operator, err)
return
@@ -288,24 +288,21 @@ func shouldPromptKickOperator(cmd *cobra.Command, args []string) bool {
}
func removeOperator(operator string) error {
operators, err := operatorRecordsByName(operator)
if err != nil {
if err := deleteOperatorRecords(operator); err != nil {
return err
}
err = db.Session().Where(&models.Operator{
Name: operator,
}).Delete(&models.Operator{}).Error
if err != nil {
return db.ReleaseOperatorWGIPs(operator)
}
func deleteOperatorRecords(operator string) error {
if strings.TrimSpace(operator) == "" {
return errors.New("operator name required")
}
if err := db.Session().Where("name = ?", operator).Delete(&models.Operator{}).Error; err != nil {
return err
}
for _, dbOperator := range operators {
if dbOperator == nil {
continue
}
if err := db.ReleaseWGIP(dbOperator.WGTunIP); err != nil {
return err
}
}
// Invalidate cached authorization immediately after the account is removed.
// Ancillary WireGuard and certificate cleanup must not defer this step.
transport.ClearTokenCache()
return nil
}
@@ -324,14 +321,32 @@ func kickOperator(operator string) error {
return err
}
if err := removeOperator(operator); err != nil {
if err := deleteOperatorRecords(operator); err != nil {
return err
}
defer closeOperatorStreams(operator)
for _, dbOperator := range dbOperators {
publishOperatorWGPeerRemoval(dbOperator)
}
return revokeOperatorClientCertificate(operator)
// Continue revocation even if reservation cleanup fails. A later retry can
// find the reservation by owner after the operator record has gone.
return errors.Join(db.ReleaseOperatorWGIPs(operator), revokeOperatorClientCertificate(operator))
}
func operatorNeedsRevocation(operator string) (bool, error) {
exists, err := operatorExists(operator)
if err != nil || exists {
return exists, err
}
exists, err = db.HasOperatorWGIPReservation(operator)
if err != nil || exists {
return exists, err
}
_, _, err = certs.OperatorClientGetCertificate(operator)
if errors.Is(err, certs.ErrCertDoesNotExist) {
return false, nil
}
return err == nil, err
}
func operatorNames() ([]string, error) {
@@ -339,9 +354,13 @@ func operatorNames() ([]string, error) {
if err != nil {
return nil, err
}
pendingWG, err := db.OperatorWGIPReservationOwners()
if err != nil {
return nil, err
}
names := make([]string, 0, len(operators))
seen := make(map[string]struct{}, len(operators))
names := make([]string, 0, len(operators)+len(pendingWG))
seen := make(map[string]struct{}, len(operators)+len(pendingWG))
for _, operator := range operators {
if operator == nil {
continue
@@ -356,6 +375,17 @@ func operatorNames() ([]string, error) {
seen[name] = struct{}{}
names = append(names, name)
}
for _, name := range pendingWG {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
sort.Strings(names)
return names, nil
}
+202 -17
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net"
"sync/atomic"
"testing"
"time"
@@ -18,12 +19,15 @@ import (
"github.com/bishopfox/sliver/protobuf/rpcpb"
"github.com/bishopfox/sliver/server/certs"
"github.com/bishopfox/sliver/server/core"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/server/db/models"
servertransport "github.com/bishopfox/sliver/server/transport"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
"gorm.io/gorm"
)
func TestKickOperatorClosesActiveEventStreams(t *testing.T) {
@@ -35,7 +39,7 @@ func TestKickOperatorClosesActiveEventStreams(t *testing.T) {
t.Fatalf("start mTLS client server: %v", err)
}
defer grpcServer.Stop()
defer listener.Close()
defer closeKickTestResource(t, listener, "mTLS listener")
operatorName := uniqueKickOperatorName(t)
t.Cleanup(func() {
@@ -49,7 +53,7 @@ func TestKickOperatorClosesActiveEventStreams(t *testing.T) {
if err != nil {
t.Fatalf("connect operator client: %v", err)
}
defer conn.Close()
defer closeKickTestResource(t, conn, "operator client")
stream, err := rpcClient.Events(context.Background(), &commonpb.Empty{})
if err != nil {
@@ -70,21 +74,7 @@ func TestKickOperatorClosesActiveEventStreams(t *testing.T) {
t.Fatalf("kick operator: %v", err)
}
select {
case err := <-recvErr:
if err == nil {
t.Fatal("expected kicked operator event stream to close")
}
if errors.Is(err, io.EOF) {
break
}
code := status.Code(err)
if code != codes.Canceled && code != codes.Unavailable && code != codes.Unknown {
t.Fatalf("expected stream cancellation after kick, got %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for kicked operator event stream to close")
}
assertKickedOperatorStreamClosed(t, recvErr)
waitForCondition(t, 3*time.Second, func() bool {
return !operatorActive(operatorName)
@@ -113,6 +103,201 @@ func TestKickOperatorClosesActiveEventStreams(t *testing.T) {
}
}
func TestKickOperatorRevokesAccessWhenWireGuardIPReleaseFails(t *testing.T) {
certs.SetupCAs()
certs.SetupMultiplayerWGKeys()
listener := bufconn.Listen(2 * 1024 * 1024)
grpcServer, err := servertransport.StartMtlsClientServer(listener)
if err != nil {
t.Fatalf("start mTLS client server: %v", err)
}
defer grpcServer.Stop()
defer closeKickTestResource(t, listener, "mTLS listener")
operatorName := uniqueKickOperatorName(t)
t.Cleanup(func() {
_ = removeOperator(operatorName)
_ = revokeOperatorClientCertificate(operatorName)
closeOperatorStreams(operatorName)
})
config := mustNewWireGuardKickOperatorConfig(t, operatorName)
rpcClient, conn, err := mustMTLSBufconnClient(t, listener, config)
if err != nil {
t.Fatalf("connect operator client: %v", err)
}
defer closeKickTestResource(t, conn, "operator client")
// A successful RPC puts the operator in the authentication cache. Revocation
// must invalidate that entry even when reservation cleanup fails.
if _, err := rpcClient.GetVersion(context.Background(), &commonpb.Empty{}); err != nil {
t.Fatalf("warm operator authentication cache: %v", err)
}
stream, err := rpcClient.Events(context.Background(), &commonpb.Empty{})
if err != nil {
t.Fatalf("start events stream: %v", err)
}
waitForCondition(t, 3*time.Second, func() bool {
return operatorActive(operatorName)
}, "operator to appear in the active client registry")
recvErr := make(chan error, 1)
go func() {
for {
if _, err := stream.Recv(); err != nil {
recvErr <- err
return
}
}
}()
rejectRelease, injectedErr := rejectKickTestWireGuardIPRelease(t, operatorName)
if err := kickOperator(operatorName); !errors.Is(err, injectedErr) {
t.Fatalf("kick operator error = %v, want injected WireGuard IP release failure", err)
}
assertOperatorPendingWireGuardCleanup(t, operatorName)
assertKickedOperatorAccessDenied(t, rpcClient, operatorName)
assertKickedOperatorStreamClosed(t, recvErr)
waitForCondition(t, 3*time.Second, func() bool {
return !operatorActive(operatorName)
}, "operator to leave the active client registry")
reservation := &models.WGIPReservation{}
if err := db.Session().Where(&models.WGIPReservation{TunIP: config.WG.ClientIP}).First(reservation).Error; err != nil {
t.Fatalf("expected failed release to leave WireGuard IP reservation: %v", err)
}
if reservation.OwnerType != models.WGIPOwnerTypeOperator || reservation.OwnerID != operatorName {
t.Fatalf("unexpected WireGuard IP reservation owner: %s/%s", reservation.OwnerType, reservation.OwnerID)
}
rejectRelease.Store(false)
if err := kickOperator(operatorName); err != nil {
t.Fatalf("retry kick operator after WireGuard IP release recovers: %v", err)
}
err = db.Session().Where(&models.WGIPReservation{TunIP: config.WG.ClientIP}).First(&models.WGIPReservation{}).Error
if !errors.Is(err, db.ErrRecordNotFound) {
t.Fatalf("expected retry to release WireGuard IP reservation, got %v", err)
}
}
func closeKickTestResource(t *testing.T, resource io.Closer, description string) {
t.Helper()
if err := resource.Close(); err != nil {
t.Errorf("close %s: %v", description, err)
}
}
func mustNewWireGuardKickOperatorConfig(t *testing.T, operatorName string) *clientassets.ClientConfig {
t.Helper()
configJSON, err := NewOperatorConfig(operatorName, "bufnet", 31337, []string{"all"}, true)
if err != nil {
t.Fatalf("generate WireGuard operator config: %v", err)
}
config := &clientassets.ClientConfig{}
if err := json.Unmarshal(configJSON, config); err != nil {
t.Fatalf("parse WireGuard operator config: %v", err)
}
if config.WG == nil || config.WG.ClientIP == "" {
t.Fatal("expected WireGuard operator to have a reserved tunnel IP")
}
t.Cleanup(func() { _ = db.ReleaseWGIP(config.WG.ClientIP) })
return config
}
func rejectKickTestWireGuardIPRelease(t *testing.T, operatorName string) (*atomic.Bool, error) {
t.Helper()
injectedErr := errors.New("injected WireGuard IP release failure")
callbackName := "test:reject-wg-ip-release-" + operatorName
var rejectRelease atomic.Bool
rejectRelease.Store(true)
if err := db.Client.Callback().Delete().Before("gorm:delete").Register(callbackName, func(tx *gorm.DB) {
if !rejectRelease.Load() {
return
}
if _, ok := tx.Statement.Dest.(*models.WGIPReservation); ok {
_ = tx.AddError(injectedErr)
}
}); err != nil {
t.Fatalf("register WireGuard IP deletion failure: %v", err)
}
t.Cleanup(func() {
rejectRelease.Store(false)
_ = db.Client.Callback().Delete().Remove(callbackName)
})
return &rejectRelease, injectedErr
}
func assertOperatorPendingWireGuardCleanup(t *testing.T, operatorName string) {
t.Helper()
exists, err := operatorExists(operatorName)
if err != nil {
t.Fatalf("lookup operator after failed cleanup: %v", err)
}
if exists {
t.Fatal("expected operator record to be removed despite WireGuard IP release failure")
}
names, err := operatorNames()
if err != nil {
t.Fatalf("list operators after partial cleanup: %v", err)
}
listed := false
for _, name := range names {
listed = listed || name == operatorName
}
if !listed {
t.Fatal("expected pending WireGuard cleanup to remain selectable for retry")
}
}
func assertKickedOperatorAccessDenied(t *testing.T, rpcClient rpcpb.SliverRPCClient, operatorName string) {
t.Helper()
callCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := rpcClient.GetVersion(callCtx, &commonpb.Empty{})
code := status.Code(err)
if code != codes.Unauthenticated && code != codes.Unavailable {
t.Fatalf("expected cached operator token to be rejected, got %v", err)
}
newStream, err := rpcClient.Events(callCtx, &commonpb.Empty{})
if err == nil {
_, err = newStream.Recv()
}
code = status.Code(err)
if code != codes.Unauthenticated && code != codes.Unavailable {
t.Fatalf("expected a new event stream to be rejected after kick, got %v", err)
}
_, _, err = certs.OperatorClientGetCertificate(operatorName)
if !errors.Is(err, certs.ErrCertDoesNotExist) {
t.Fatalf("expected operator certificate to be removed despite cleanup failure, got %v", err)
}
}
func assertKickedOperatorStreamClosed(t *testing.T, recvErr <-chan error) {
t.Helper()
select {
case err := <-recvErr:
if err == nil {
t.Fatal("expected kicked operator event stream to close")
}
if !errors.Is(err, io.EOF) {
code := status.Code(err)
if code != codes.Canceled && code != codes.Unavailable && code != codes.Unknown {
t.Fatalf("expected stream cancellation after kick, got %v", err)
}
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for kicked operator event stream to close")
}
}
func mustNewOperatorAssetsConfig(t *testing.T, operatorName string) *clientassets.ClientConfig {
t.Helper()
Loaded 100 of 518 files, more files were not shown because too many files have changed in this diff. Show more