mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-09 17:09:39 -10:00
Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ccfd6a257 | ||
|
|
4c2e8d4bda | ||
|
|
9f8f1d11be | ||
|
|
6cf3ad78bd | ||
|
|
56e140826f | ||
|
|
ad6ba0a61b | ||
|
|
9e1c1e7c67 | ||
|
|
a36b0b1d1d | ||
|
|
4c1350d8cb | ||
|
|
3e409bdc91 | ||
|
|
46eb1c22a4 | ||
|
|
f857903c83 | ||
|
|
ee53c01313 | ||
|
|
14a43e6510 | ||
|
|
931e3f9046 | ||
|
|
a6d5e97d45 | ||
|
|
f0ef60e25f | ||
|
|
9fdc66e4a8 | ||
|
|
ab960fd4ed | ||
|
|
34fc1c5ad6 | ||
|
|
ac65aa3708 | ||
|
|
73bf782656 | ||
|
|
a0a303ae89 | ||
|
|
5383bd67b5 | ||
|
|
73ea63fd44 | ||
|
|
3ef04241a1 | ||
|
|
402b5459e6 | ||
|
|
7c56553372 | ||
|
|
a40754eaac | ||
|
|
042bb6bccc | ||
|
|
8bc7273cad | ||
|
|
8e5d2f9dbe | ||
|
|
52864c481f | ||
|
|
2f6fca1eac | ||
|
|
481b5b0534 | ||
|
|
65efd3f2fd | ||
|
|
58c5f33cf5 | ||
|
|
3e20660ab0 | ||
|
|
acb6cd2d77 | ||
|
|
7f82085467 | ||
|
|
1ac72de74d | ||
|
|
94df7a3e58 | ||
|
|
329e44dfd9 | ||
|
|
5d303fca79 | ||
|
|
90f759dac8 | ||
|
|
1fc9e256c0 | ||
|
|
13935b2e68 | ||
|
|
1db7b99685 | ||
|
|
b48e3fc537 | ||
|
|
10ec67d274 | ||
|
|
52f5517c2a | ||
|
|
c4ed103182 | ||
|
|
c04b0884a5 | ||
|
|
893fe4ccda | ||
|
|
b43ef0fabe | ||
|
|
25d2bffb5a | ||
|
|
534763f087 | ||
|
|
ec733974de | ||
|
|
d9811baa7e | ||
|
|
d8b360ecea | ||
|
|
12c55be611 | ||
|
|
3a147f1e01 | ||
|
|
5b03320a32 | ||
|
|
1cc6d35dc6 | ||
|
|
1c5d8ab5a9 | ||
|
|
04a71e6d7d | ||
|
|
bb8b90be56 | ||
|
|
7a2b58ade8 | ||
|
|
854143b593 | ||
|
|
d3068394bf | ||
|
|
d80db8a932 | ||
|
|
179ff728dd | ||
|
|
422044af0d | ||
|
|
7dbdd1931c | ||
|
|
8b7e1b63ab | ||
|
|
ed5c89253c | ||
|
|
06b5a56e74 | ||
|
|
704912d25a | ||
|
|
ea1fe95408 | ||
|
|
8fe54f243e | ||
|
|
4fa6fe86fe | ||
|
|
23c5a0b164 | ||
|
|
7b8dafcf55 | ||
|
|
4e6531fa98 | ||
|
|
6e9b2a9820 | ||
|
|
b11f85d4d7 | ||
|
|
23f1341684 | ||
|
|
e68e163456 | ||
|
|
f8430cecf7 | ||
|
|
276a30fbaa | ||
|
|
3aa8a9f93c | ||
|
|
fc3fb7386a | ||
|
|
6edea618e9 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/...
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ run:
|
||||
- osusergo
|
||||
- netgo
|
||||
- go_sqlite
|
||||
- sliver_generate
|
||||
# Raw implant entry points are Go templates; lint their supporting packages.
|
||||
- sliver_lint
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](https://github.com/BishopFox/sliver/actions/workflows/autorelease.yml) [](https://github.com/BishopFox/sliver/actions/workflows/golangci-lint.yml) [](https://www.gnu.org/licenses/gpl-3.0)
|
||||
|
||||
Visit [https://sliver.sh/](https://sliver.sh/) for tutorials and documentation.
|
||||
|
||||
### 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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
package armory
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
Generated
+1045
-1021
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 @@
|
||||
{"tutorials":[{"name":"1 - Getting Started","content":"`sliver-server` is the binary you want to use to run the Sliver C2 server, `sliver-client` is solely a client to connect to a Sliver C2 server. Sliver server also acts as a client on its own, so you don’t necessarily run sliver server and client separately.\n\nFirst time running Sliver will take a couple seconds as it's retrieving its dependencies. Consecutive executions will be much faster. Go ahead and launch the `sliver-server`.\n\n```asciinema\n{\"src\": \"/asciinema/startup.cast\", \"cols\": \"132\", \"rows\": \"28\", \"idleTimeLimit\": 8}\n```\n\nLet's take a couple minutes to discuss what Sliver actually is and how it's set up.\n\n\n\nNow that Sliver is running, lets generate and execute your first implant to try out some of the basic features of Sliver, for now we’re going to run everything on the local host.\n\nHere's what we're going to do: \n* Generate your implant using the `generate` command as shown below.\n* Start HTTP listener on port 80\n* Execute implant in a separate terminal\n\n```asciinema\n{\"src\": \"/asciinema/first-implant.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNow let’s select our implant and run our first command using the `use` command.\n\n```bash\n[server] sliver > use\n? Select a session or beacon: \nSESSION 1884a365 RELATED_EARDRUM [::1]:49153 test.local tester darwin/amd64\n[*] Active session RELATED_EARDRUM (1884a365-085f-4506-b28e-80c481730fd0)\n\n[server] sliver (RELATED_EARDRUM) > pwd\n\n[*] /Users/tester/tools\n```\n\nOnce you have reached this point, go ahead and explore some of the commands listed below. In each case, first check out the command's help using the **`-h`** flag then try it out!\n\n```bash\nExploring and interacting with the filesystem\n\nFilesystem\n cat Dump file to stdout\n cd Change directory\n cp Copy a file\n download Download a file\n grep Search for strings that match a regex within a file or directory\n head Grab the first number of bytes or lines from a file\n ls List current directory\n mkdir Make a directory\n mv Move or rename a file\n pwd Print working directory\n rm Remove a file or directory\n tail Grab the last number of bytes or lines from a file\n upload Upload a file\n```\n\n```asciinema\n{\"src\": \"/asciinema/filesystem.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nGetting some environmental information\n```bash\nInfo\n env List environment variables\n getgid Get session process GID\n getpid Get session pid\n getuid Get session process UID\n info Get session info\n ping Send round trip message to implant (does not use ICMP)\n whoami Get session user execution context\n```\nExecute a binary\n\n```asciinema\n{\"src\": \"/asciinema/execute.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nRunning an interactive shell\n\n```asciinema\n{\"src\": \"/asciinema/shell.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNot all commands are supported for all operating systems or meant to be used on all operating systems, here are a few examples:\n\n```\nmount Get information on mounted filesystems (only for Windows and Linux)\nscreenshot Take a screenshot (only for Windows and Linux)\nmemfiles List current memfiles (Linux only)\n```\n\nThis is an open-source project if you see a bug please file an issue! Pull requests are always welcome."},{"name":"2 - Beacons vs Sessions","content":"Sliver implants support two types of connections, sessions and beacons.\n\nSessions use long-polling connections, which means they constantly maintain a single TCP connection open while communicating with the server. Beacons on the other hand call back periodically and will sleep when not active which can help keep their presence hidden. You could limit callbacks to once every 6 hours for example and only 'activate' the beacon when needed by switching its sleeping time to a shorter duration.\n\nTypically during an engagement you will want to deploy a beacon on the target system and switch to a session while doing more active enumeration.\n\nLet’s start with generating and deploying a beacon using `http`.\n\n```asciinema\n{\"src\": \"/asciinema/beacon_generation.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nYou can see the beacon callback times either in the `info` command or using `beacons watch`.\n\n```bash\n[server] sliver > beacons watch\n\n ID Name Transport Username Operating System Last Check-In Next Check-In\n========== =============== =========== ================= ==========Line truncated
|
||||
{"tutorials":[{"name":"1 - Getting Started","content":"`sliver-server` is the binary you want to use to run the Sliver C2 server, `sliver-client` is solely a client to connect to a Sliver C2 server. Sliver server also acts as a client on its own, so you don’t necessarily run sliver server and client separately.\n\nFirst time running Sliver will take a couple seconds as it's retrieving its dependencies. Consecutive executions will be much faster. Go ahead and launch the `sliver-server`.\n\n```asciinema\n{\"src\": \"/asciinema/startup.cast\", \"cols\": \"132\", \"rows\": \"28\", \"idleTimeLimit\": 8}\n```\n\nLet's take a couple minutes to discuss what Sliver actually is and how it's set up.\n\n\n\nNow that Sliver is running, lets generate and execute your first implant to try out some of the basic features of Sliver, for now we’re going to run everything on the local host.\n\nHere's what we're going to do: \n* Generate your implant using the `generate` command as shown below.\n* Start HTTP listener on port 80\n* Execute implant in a separate terminal\n\n```asciinema\n{\"src\": \"/asciinema/first-implant.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNow let’s select our implant and run our first command using the `use` command.\n\n```bash\n[server] sliver > use\n? Select a session or beacon: \nSESSION 1884a365 RELATED_EARDRUM [::1]:49153 test.local tester darwin/amd64\n[*] Active session RELATED_EARDRUM (1884a365-085f-4506-b28e-80c481730fd0)\n\n[server] sliver (RELATED_EARDRUM) > pwd\n\n[*] /Users/tester/tools\n```\n\nOnce you have reached this point, go ahead and explore some of the commands listed below. In each case, first check out the command's help using the **`-h`** flag then try it out!\n\n```bash\nExploring and interacting with the filesystem\n\nFilesystem\n cat Dump file to stdout\n cd Change directory\n cp Copy a file\n download Download a file\n grep Search for strings that match a regex within a file or directory\n head Grab the first number of bytes or lines from a file\n ls List current directory\n mkdir Make a directory\n mv Move or rename a file\n pwd Print working directory\n rm Remove a file or directory\n tail Grab the last number of bytes or lines from a file\n upload Upload a file\n```\n\n```asciinema\n{\"src\": \"/asciinema/filesystem.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nGetting some environmental information\n```bash\nInfo\n env List environment variables\n getgid Get session process GID\n getpid Get session pid\n getuid Get session process UID\n info Get session info\n ping Send round trip message to implant (does not use ICMP)\n whoami Get session user execution context\n```\nExecute a binary\n\n```asciinema\n{\"src\": \"/asciinema/execute.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nRunning an interactive shell\n\n```asciinema\n{\"src\": \"/asciinema/shell.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNot all commands are supported for all operating systems or meant to be used on all operating systems, here are a few examples:\n\n```\nmount Get information on mounted filesystems (only for Windows and Linux)\nscreenshot Take a screenshot (only for Windows and Linux)\nmemfiles List current memfiles (Linux only)\n```\n\nThis is an open-source project if you see a bug please file an issue! Pull requests are always welcome."},{"name":"2 - Beacons vs Sessions","content":"Sliver implants support two types of connections, sessions and beacons.\n\nSessions 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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
module github.com/bishopfox/sliver
|
||||
|
||||
go 1.27.1
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
filippo.io/age v1.1.1
|
||||
|
||||
@@ -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)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user