mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-08 01:38:17 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7dbdd1931c | ||
|
|
8b7e1b63ab | ||
|
|
ed5c89253c |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+29
-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,8 +50,7 @@ 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")
|
||||
@@ -97,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)
|
||||
}
|
||||
}
|
||||
+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
-1
@@ -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 {
|
||||
@@ -53,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)
|
||||
@@ -61,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,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -826,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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ replace github.com/reeflective/console => github.com/moloch--/console v0.0.0-202
|
||||
|
||||
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,6 @@ 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=
|
||||
@@ -763,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=
|
||||
@@ -793,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=
|
||||
@@ -928,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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// }
|
||||
+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()
|
||||
|
||||
|
||||
@@ -465,16 +465,48 @@ func TestRegistryRevokeSessionIsScopedAndIdempotent(t *testing.T) {
|
||||
func TestRegistryGeneratesUniqueOpaqueIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
registry := NewRegistry()
|
||||
seen := map[AuthorizationID]struct{}{}
|
||||
for index := 0; index < 256; index++ {
|
||||
id, err := registry.Begin("session", "127.0.0.1:8080", 0)
|
||||
must.NoError(t, err)
|
||||
assert.Len(t, id, 43)
|
||||
assert.False(t, strings.ContainsAny(id.String(), "+/="))
|
||||
_, duplicate := seen[id]
|
||||
assert.False(t, duplicate)
|
||||
seen[id] = struct{}{}
|
||||
firstBytes := bytes.Repeat([]byte{0xff}, authorizationIDBytes)
|
||||
secondBytes := bytes.Repeat([]byte{0x42}, authorizationIDBytes)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
random []byte
|
||||
wantError bool
|
||||
wantRemaining int
|
||||
}{
|
||||
{name: "distinct IDs", random: secondBytes},
|
||||
{name: "retry collision", random: bytes.Join([][]byte{firstBytes, secondBytes}, nil)},
|
||||
{
|
||||
name: "exhaust collisions",
|
||||
random: bytes.Join([][]byte{bytes.Repeat(firstBytes, maxAuthorizationIDAttempts), secondBytes}, nil),
|
||||
wantError: true,
|
||||
wantRemaining: authorizationIDBytes,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
random := bytes.NewReader(bytes.Join([][]byte{firstBytes, test.random}, nil))
|
||||
registry := newRegistry(random)
|
||||
firstID, err := registry.Begin("session", "127.0.0.1:8080", 0)
|
||||
must.NoError(t, err)
|
||||
assert.Len(t, firstID, 43)
|
||||
assert.False(t, strings.ContainsAny(firstID.String(), "+/="))
|
||||
|
||||
secondID, err := registry.Begin("session", "127.0.0.1:8081", 0)
|
||||
if test.wantError {
|
||||
must.ErrorIs(t, err, ErrAuthorizationIDGeneration)
|
||||
assert.Empty(t, secondID)
|
||||
assert.Len(t, registry.List("session"), 1)
|
||||
} else {
|
||||
must.NoError(t, err)
|
||||
assert.NotEqual(t, firstID, secondID)
|
||||
assert.Len(t, secondID, 43)
|
||||
assert.False(t, strings.ContainsAny(secondID.String(), "+/="))
|
||||
assert.Len(t, registry.List("session"), 2)
|
||||
}
|
||||
assert.Equal(t, test.wantRemaining, random.Len())
|
||||
first, ok := registry.Lookup("session", firstID)
|
||||
must.True(t, ok)
|
||||
assert.Equal(t, "127.0.0.1:8080", first.Address)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,13 +516,6 @@ func (errorReader) Read(_ []byte) (int, error) {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
func TestAuthorizationErrorsAreComparable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := canonicalizeAddress("invalid")
|
||||
assert.True(t, errors.Is(err, ErrInvalidForwardAddress))
|
||||
}
|
||||
|
||||
func acquirePlanForTest(registry *Registry, sessionID string, id AuthorizationID) (dialPlan, error) {
|
||||
plan, _, reservation, err := registry.reserve(sessionID, id, "")
|
||||
if err == nil {
|
||||
|
||||
@@ -134,26 +134,27 @@ func TestAgeKeyExTamper(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Tamper with the ciphertext
|
||||
ciphertext := allCiphertext[32:]
|
||||
ciphertext[insecureRand.Intn(len(ciphertext))] ^= 0xFF
|
||||
_, err = AgeKeyExFromImplant(
|
||||
serverAgeKeyPair.Private,
|
||||
implantPeerAgeKeyPair.Private,
|
||||
ciphertext,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tamperedCiphertext := bytes.Clone(allCiphertext[32:])
|
||||
tamperedCiphertext[len(tamperedCiphertext)-1] ^= 0xFF
|
||||
|
||||
// Leave an invalid header with valid ciphertext
|
||||
_, err = AgeKeyExFromImplant(
|
||||
serverAgeKeyPair.Private,
|
||||
implantPeerAgeKeyPair.Private,
|
||||
allCiphertext,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal(err)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ciphertext []byte
|
||||
}{
|
||||
{name: "tampered ciphertext", ciphertext: tamperedCiphertext},
|
||||
// Retaining the public key hash adds an invalid header to otherwise valid ciphertext.
|
||||
{name: "invalid header", ciphertext: bytes.Clone(allCiphertext)},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := AgeKeyExFromImplant(
|
||||
serverAgeKeyPair.Private,
|
||||
implantPeerAgeKeyPair.Private,
|
||||
test.ciphertext,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected key exchange decryption to reject invalid ciphertext")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
)
|
||||
|
||||
func TestCanaryByDomain(t *testing.T) {
|
||||
canary := &models.DNSCanary{
|
||||
ImplantName: "canary_lookup_" + models.NewUUID().String(),
|
||||
Domain: "lookup-" + models.NewUUID().String() + ".Canary.Example.",
|
||||
}
|
||||
if err := Session().Create(canary).Error; err != nil {
|
||||
t.Fatalf("create canary: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := Session().Delete(canary).Error; err != nil {
|
||||
t.Errorf("delete canary: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, domain := range []string{
|
||||
canary.Domain,
|
||||
strings.ToLower(canary.Domain),
|
||||
strings.ToUpper(canary.Domain),
|
||||
" " + strings.TrimSuffix(canary.Domain, ".") + " ",
|
||||
} {
|
||||
t.Run(domain, func(t *testing.T) {
|
||||
found, err := CanaryByDomain(domain)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup canary: %v", err)
|
||||
}
|
||||
if found.ID != canary.ID.String() || found.Domain != canary.Domain {
|
||||
t.Errorf("lookup returned %v, want ID %s and domain %s", found, canary.ID, canary.Domain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanaryByDomainNotFound(t *testing.T) {
|
||||
for _, domain := range []string{"", " \t", "missing-" + models.NewUUID().String() + ".invalid."} {
|
||||
t.Run(domain, func(t *testing.T) {
|
||||
found, err := CanaryByDomain(domain)
|
||||
if !errors.Is(err, ErrRecordNotFound) || found != nil {
|
||||
t.Fatalf("lookup returned (%v, %v), want (nil, record not found)", found, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+58
-7
@@ -1,3 +1,4 @@
|
||||
// Package db provides persistence and queries for Sliver server state.
|
||||
package db
|
||||
|
||||
/*
|
||||
@@ -742,13 +743,25 @@ func ListCanaries() ([]*clientpb.DNSCanary, error) {
|
||||
|
||||
// CanaryByDomain - Check if a canary exists
|
||||
func CanaryByDomain(domain string) (*clientpb.DNSCanary, error) {
|
||||
if len(domain) < 1 {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
if !strings.HasSuffix(domain, ".") {
|
||||
domain += "."
|
||||
}
|
||||
dbSession := Session()
|
||||
canary := models.DNSCanary{}
|
||||
err := dbSession.Where(&models.DNSCanary{Domain: domain}).First(&canary).Error
|
||||
return canary.ToProtobuf(), err
|
||||
// Older builds may have stored mixed-case parent domains. DNS names are
|
||||
// case-insensitive, so normalize both sides without rewriting those records.
|
||||
result := dbSession.Where("LOWER(domain) = ?", domain).Limit(1).Find(&canary)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
return canary.ToProtobuf(), nil
|
||||
}
|
||||
|
||||
// WebsiteByName - Get website by name
|
||||
@@ -818,6 +831,9 @@ func AddWebSite(webSiteName string, webContentDir string) (*clientpb.Website, er
|
||||
func AddContent(pbWebContent *clientpb.WebContent, webContentDir string) (*clientpb.WebContent, error) {
|
||||
dbWebContent, err := WebContentByIDAndPath(pbWebContent.WebsiteID, pbWebContent.Path, webContentDir, false)
|
||||
if errors.Is(err, ErrRecordNotFound) {
|
||||
if pbWebContent.OriginalFile == "" {
|
||||
pbWebContent.OriginalFile = filepath.Base(pbWebContent.Path)
|
||||
}
|
||||
dbModelWebContent := models.WebContentFromProtobuf(pbWebContent)
|
||||
err = Session().Create(&dbModelWebContent).Error
|
||||
if err != nil {
|
||||
@@ -827,19 +843,19 @@ func AddContent(pbWebContent *clientpb.WebContent, webContentDir string) (*clien
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
if pbWebContent.ContentType != "" {
|
||||
dbWebContent.ContentType = pbWebContent.ContentType
|
||||
}
|
||||
if pbWebContent.Size != 0 {
|
||||
if pbWebContent.ReplaceContent || len(pbWebContent.Content) > 0 {
|
||||
dbWebContent.Size = pbWebContent.Size
|
||||
dbWebContent.Sha256 = pbWebContent.Sha256
|
||||
}
|
||||
if pbWebContent.OriginalFile != "" {
|
||||
dbWebContent.OriginalFile = pbWebContent.OriginalFile
|
||||
}
|
||||
if pbWebContent.Sha256 != "" {
|
||||
dbWebContent.Sha256 = pbWebContent.Sha256
|
||||
}
|
||||
|
||||
dbModelWebContent := models.WebContentFromProtobuf(dbWebContent)
|
||||
err = Session().Save(&dbModelWebContent).Error
|
||||
@@ -850,6 +866,41 @@ func AddContent(pbWebContent *clientpb.WebContent, webContentDir string) (*clien
|
||||
return dbWebContent, nil
|
||||
}
|
||||
|
||||
// UpdateWebContentTypes changes only MIME metadata for existing website paths.
|
||||
// All requested paths must exist before any update is committed.
|
||||
func UpdateWebContentTypes(websiteID string, contentTypes map[string]string) error {
|
||||
id, err := models.ParseUUID(websiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return Session().Transaction(func(tx *gorm.DB) error {
|
||||
existing := make(map[string]models.WebContent, len(contentTypes))
|
||||
for path := range contentTypes {
|
||||
var content models.WebContent
|
||||
if err := tx.Where("website_id = ? AND path = ?", id, path).First(&content).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existing[path] = content
|
||||
}
|
||||
for path, contentType := range contentTypes {
|
||||
content := existing[path]
|
||||
if content.ContentType == contentType {
|
||||
continue
|
||||
}
|
||||
result := tx.Model(&models.WebContent{}).
|
||||
Where("id = ? AND website_id = ? AND path = ?", content.ID, id, path).
|
||||
Update("content_type", contentType)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func RemoveContent(id string) error {
|
||||
uuid, _ := models.ParseUUID(id)
|
||||
err := Session().Delete(&models.WebContent{}, uuid).Error
|
||||
|
||||
@@ -48,6 +48,7 @@ func (c *DNSCanary) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
// ToProtobuf - Converts to protobuf object
|
||||
func (c *DNSCanary) ToProtobuf() *clientpb.DNSCanary {
|
||||
return &clientpb.DNSCanary{
|
||||
ID: c.ID.String(),
|
||||
ImplantName: c.ImplantName,
|
||||
Domain: c.Domain,
|
||||
Triggered: c.Triggered,
|
||||
|
||||
@@ -19,13 +19,10 @@ package models
|
||||
*/
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
uuid "uuid"
|
||||
|
||||
gosqlite "github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -89,9 +86,6 @@ func TestUUIDSQLAndJSONEncoding(t *testing.T) {
|
||||
want := NewUUID()
|
||||
standard := want.Standard()
|
||||
|
||||
var _ sql.Scanner = (*UUID)(nil)
|
||||
var _ driver.Valuer = UUID{}
|
||||
|
||||
value, err := want.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value: %v", err)
|
||||
@@ -188,11 +182,3 @@ func TestUUIDGORMRoundTrip(t *testing.T) {
|
||||
t.Fatalf("stored UUID = %q, want canonical %q", storedID, want.ID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUUIDMatchesStandardLibrary(t *testing.T) {
|
||||
standard := uuid.NewV4()
|
||||
model := UUIDFrom(standard)
|
||||
if model.Standard() != standard {
|
||||
t.Fatalf("Standard = %q, want %q", model.Standard(), standard)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func sqliteClient(dbConfig *configs.DatabaseConfig) *gorm.DB {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
clientLog.Debugf("sqlite -> %s", dsn)
|
||||
clientLog.Debug("Connecting to SQLite database")
|
||||
|
||||
dbClient, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
PrepareStmt: true,
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ func sqliteClient(dbConfig *configs.DatabaseConfig) *gorm.DB {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
clientLog.Infof("sqlite -> %s", dsn)
|
||||
clientLog.Info("Connecting to SQLite database")
|
||||
|
||||
dbClient, err := gorm.Open(gosqlite.Open(dsn), &gorm.Config{
|
||||
PrepareStmt: true,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build go_sqlite || cgo_sqlite
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/server/configs"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func TestSQLiteClientLogsInitializationWithoutDSN(t *testing.T) {
|
||||
t.Setenv("SLIVER_ROOT_DIR", t.TempDir())
|
||||
const sensitiveValue = "synthetic-sqlite-password-sentinel"
|
||||
config := &configs.DatabaseConfig{
|
||||
Dialect: configs.Sqlite,
|
||||
LogLevel: "silent",
|
||||
Params: map[string]string{"password": sensitiveValue},
|
||||
}
|
||||
dsn, err := config.DSN()
|
||||
if err != nil {
|
||||
t.Fatalf("create SQLite DSN: %v", err)
|
||||
}
|
||||
if !strings.Contains(dsn, sensitiveValue) {
|
||||
t.Fatal("test DSN omitted the sensitive parameter")
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(&output)
|
||||
logger.SetLevel(logrus.DebugLevel)
|
||||
logger.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true, DisableQuote: true})
|
||||
originalLog := clientLog
|
||||
clientLog = logrus.NewEntry(logger)
|
||||
t.Cleanup(func() { clientLog = originalLog })
|
||||
|
||||
client := sqliteClient(config)
|
||||
sqlDB, err := client.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get SQLite connection: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close SQLite connection: %v", err)
|
||||
}
|
||||
})
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
t.Fatalf("connect to temporary SQLite database: %v", err)
|
||||
}
|
||||
|
||||
logged := output.String()
|
||||
if !strings.Contains(logged, "Connecting to SQLite database") {
|
||||
t.Error("SQLite initialization message was not logged")
|
||||
}
|
||||
if strings.Contains(logged, sensitiveValue) {
|
||||
t.Error("SQLite initialization logged the sensitive parameter")
|
||||
}
|
||||
if strings.Contains(logged, dsn) {
|
||||
t.Error("SQLite initialization logged the full DSN")
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package wasmsqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteVersion(t *testing.T) {
|
||||
var version string
|
||||
|
||||
db, err := sql.Open(DriverName, ":memory:")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
row := db.QueryRow("select sqlite_version()")
|
||||
if row.Scan(&version) != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log(version)
|
||||
}
|
||||
@@ -144,6 +144,45 @@ func ReleaseWGIP(tunIP string) error {
|
||||
return Session().Where(&models.WGIPReservation{TunIP: tunIP}).Delete(&models.WGIPReservation{}).Error
|
||||
}
|
||||
|
||||
// ReleaseOperatorWGIPs removes reservations by owner so an interrupted
|
||||
// operator revocation can be retried after its operator row has been deleted.
|
||||
func ReleaseOperatorWGIPs(operator string) error {
|
||||
operator = strings.TrimSpace(operator)
|
||||
if operator == "" {
|
||||
return errors.New("operator name is required")
|
||||
}
|
||||
return Session().Where(&models.WGIPReservation{
|
||||
OwnerType: models.WGIPOwnerTypeOperator,
|
||||
OwnerID: operator,
|
||||
}).Delete(&models.WGIPReservation{}).Error
|
||||
}
|
||||
|
||||
// HasOperatorWGIPReservation reports whether an operator still owns a
|
||||
// reservation that needs cleanup after revocation.
|
||||
func HasOperatorWGIPReservation(operator string) (bool, error) {
|
||||
operator = strings.TrimSpace(operator)
|
||||
if operator == "" {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
err := Session().Model(&models.WGIPReservation{}).Where(&models.WGIPReservation{
|
||||
OwnerType: models.WGIPOwnerTypeOperator,
|
||||
OwnerID: operator,
|
||||
}).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// OperatorWGIPReservationOwners includes operators whose account row was
|
||||
// removed before reservation cleanup completed.
|
||||
func OperatorWGIPReservationOwners() ([]string, error) {
|
||||
owners := []string{}
|
||||
err := Session().Model(&models.WGIPReservation{}).
|
||||
Distinct("owner_id").
|
||||
Where("owner_type = ?", models.WGIPOwnerTypeOperator).
|
||||
Pluck("owner_id", &owners).Error
|
||||
return owners, err
|
||||
}
|
||||
|
||||
func ensureWGIPNotAllocated(tx *gorm.DB, tunIP string) error {
|
||||
operator := &models.Operator{}
|
||||
result := tx.Select("id").Where("wg_tun_ip = ?", tunIP).First(operator)
|
||||
|
||||
@@ -20,206 +20,65 @@ package encoders
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
insecureRand "math/rand"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders"
|
||||
util "github.com/bishopfox/sliver/util/encoders"
|
||||
)
|
||||
|
||||
const (
|
||||
sampleSizeMax = 8192
|
||||
)
|
||||
|
||||
func randomDataRandomSize(maxSize int) []byte {
|
||||
buf := make([]byte, insecureRand.Intn(maxSize))
|
||||
rand.Read(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestCompatibilityBase64(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Base64.Encode(sample)
|
||||
data, err := implantEncoders.Base64.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base64")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = implantEncoders.Base64.Encode(sample2)
|
||||
data, err = Base64.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base64")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityBase58(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Base58.Encode(sample)
|
||||
data, err := implantEncoders.Base58.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base58")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = Base58.Encode(sample2)
|
||||
data, err = implantEncoders.Base58.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base58")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityBase32(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Base32.Encode(sample)
|
||||
data, err := implantEncoders.Base32.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base32")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = Base32.Encode(sample2)
|
||||
data, err = implantEncoders.Base32.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base32")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityEnglish(t *testing.T) {
|
||||
|
||||
func TestEncoderCompatibility(t *testing.T) {
|
||||
util.SetEnglishDictionary(getTestEnglishDictionary())
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := English.Encode(sample)
|
||||
data, err := implantEncoders.English.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into english")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = implantEncoders.English.Encode(sample2)
|
||||
data, err = English.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into english")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
encoders := []struct {
|
||||
name string
|
||||
server util.Encoder
|
||||
implant util.Encoder
|
||||
}{
|
||||
{"base64", Base64, implantEncoders.Base64},
|
||||
{"base58", Base58, implantEncoders.Base58},
|
||||
{"base32", Base32, implantEncoders.Base32},
|
||||
{"english", English, implantEncoders.English},
|
||||
{"hex", Hex, implantEncoders.Hex},
|
||||
{"gzip", Gzip, implantEncoders.Gzip},
|
||||
{"png", PNG, implantEncoders.PNG},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityHex(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Hex.Encode(sample)
|
||||
data, err := implantEncoders.Hex.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into hex")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = Hex.Encode(sample2)
|
||||
data, err = implantEncoders.Hex.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into hex")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityGzip(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Gzip.Encode(sample)
|
||||
data, err := implantEncoders.Gzip.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into gzip")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = Gzip.Encode(sample2)
|
||||
data, err = implantEncoders.Gzip.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into gzip")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityPNG(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := PNG.Encode(sample)
|
||||
data, err := implantEncoders.PNG.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into png")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = PNG.Encode(sample2)
|
||||
data, err = implantEncoders.PNG.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into png")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
for _, encoder := range encoders {
|
||||
t.Run(encoder.name, func(t *testing.T) {
|
||||
directions := []struct {
|
||||
name string
|
||||
encoder util.Encoder
|
||||
decoder util.Encoder
|
||||
}{
|
||||
{"server_to_implant", encoder.server, encoder.implant},
|
||||
{"implant_to_server", encoder.implant, encoder.server},
|
||||
}
|
||||
for _, direction := range directions {
|
||||
t.Run(direction.name, func(t *testing.T) {
|
||||
// Cover encoding block boundaries, every byte value, leading and
|
||||
// trailing zero bytes, and the old random corpus's upper bound.
|
||||
for _, size := range []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255, 256, 257, 8192} {
|
||||
t.Run(fmt.Sprintf("bytes_%d", size), func(t *testing.T) {
|
||||
payload := make([]byte, size)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
encoded, err := direction.encoder.Encode(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
decoded, err := direction.decoder.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))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,21 @@ func appendToLDFlags(ldflags []string, extra string) []string {
|
||||
return []string{joined}
|
||||
}
|
||||
|
||||
// applyCompactExecutableFunctionAlignment removes unnecessary padding between
|
||||
// functions in non-debug obfuscated executables. Go's linker keeps explicit
|
||||
// per-symbol alignments, so functions that require them remain aligned.
|
||||
func applyCompactExecutableFunctionAlignment(config *clientpb.ImplantConfig, ldflags []string) []string {
|
||||
if config.Debug || !config.ObfuscateSymbols {
|
||||
return ldflags
|
||||
}
|
||||
switch config.GOARCH {
|
||||
case "amd64":
|
||||
return appendToLDFlags(ldflags, "-funcalign=1")
|
||||
default:
|
||||
return ldflags
|
||||
}
|
||||
}
|
||||
|
||||
const linuxShellcodeBuildIDLDFlag = "-extldflags=-Wl,--build-id"
|
||||
|
||||
func applyLinuxShellcodeBuildID(ldflags []string) []string {
|
||||
@@ -802,6 +817,9 @@ func SliverExecutable(name string, build *clientpb.ImplantBuild, config *clientp
|
||||
tags = append(tags, "netgo")
|
||||
}
|
||||
ldflags := []string{""} // Garble will automatically add "-s -w -buildid="
|
||||
if !config.Debug && !config.ObfuscateSymbols {
|
||||
ldflags[0] = "-s -w"
|
||||
}
|
||||
if !config.Debug && goConfig.GOOS == WINDOWS {
|
||||
ldflags[0] += " -H=windowsgui"
|
||||
}
|
||||
@@ -811,6 +829,7 @@ func SliverExecutable(name string, build *clientpb.ImplantBuild, config *clientp
|
||||
gcFlags = "all=-N -l"
|
||||
ldflags = []string{}
|
||||
}
|
||||
ldflags = applyCompactExecutableFunctionAlignment(config, ldflags)
|
||||
_, err = gogo.GoBuild(*goConfig, pkgPath, dest, "", tags, ldflags, gcFlags, asmFlags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -845,8 +864,11 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
|
||||
|
||||
// srcDir - ~/.sliver/slivers/<os>/<arch>/<name>/src
|
||||
srcDir := filepath.Join(projectGoPathDir, "src")
|
||||
assets.SetupGoPath(srcDir, config.IncludeDNS) // Extract GOPATH dependency files
|
||||
err := util.ChmodR(srcDir, 0600, 0700) // Ensures src code files are writable
|
||||
// Extract GOPATH dependency files before rendering the implant.
|
||||
if err := assets.SetupGoPath(srcDir, config.IncludeDNS); err != nil {
|
||||
return "", fmt.Errorf("setup implant source in %s: %w", srcDir, err)
|
||||
}
|
||||
err := util.ChmodR(srcDir, 0600, 0700) // Ensures src code files are writable
|
||||
if err != nil {
|
||||
buildLog.Errorf("fs perms: %v", err)
|
||||
return "", err
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build sliver_generate
|
||||
|
||||
package generate
|
||||
|
||||
/*
|
||||
|
||||
@@ -21,11 +21,13 @@ package generate
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -50,11 +52,42 @@ func canarySubDomain() string {
|
||||
return string(subdomain)
|
||||
}
|
||||
|
||||
// UpdateCanary - Update an existing canary
|
||||
func UpdateCanary(canary *clientpb.DNSCanary) error {
|
||||
dbSession := db.Session()
|
||||
result := dbSession.Save(&canary)
|
||||
return result.Error
|
||||
// TriggerCanary records a DNS query and reports whether it first triggered the
|
||||
// existing canary. The caller's status may be stale; only its ID is used.
|
||||
func TriggerCanary(canary *clientpb.DNSCanary) (bool, error) {
|
||||
if canary == nil || canary.ID == "" {
|
||||
return false, fmt.Errorf("missing canary ID")
|
||||
}
|
||||
id, err := models.ParseUUID(canary.ID)
|
||||
if err != nil || id == models.NilUUID() {
|
||||
return false, fmt.Errorf("invalid canary ID %q", canary.ID)
|
||||
}
|
||||
|
||||
firstTrigger := false
|
||||
err = db.Session().Transaction(func(tx *gorm.DB) error {
|
||||
count := tx.Model(&models.DNSCanary{}).Where("id = ?", id).
|
||||
UpdateColumn("count", gorm.Expr("count + ?", 1))
|
||||
if count.Error != nil {
|
||||
return count.Error
|
||||
}
|
||||
if count.RowsAffected != 1 {
|
||||
return db.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// The increment locks the existing row before timestamps are sampled,
|
||||
// so waiting queries cannot move LatestTrigger backwards.
|
||||
first := tx.Model(&models.DNSCanary{}).
|
||||
Where("id = ? AND triggered = ?", id, false).
|
||||
Updates(models.DNSCanary{Triggered: true, FirstTrigger: time.Now()})
|
||||
if first.Error != nil {
|
||||
return first.Error
|
||||
}
|
||||
firstTrigger = first.RowsAffected == 1
|
||||
|
||||
return tx.Model(&models.DNSCanary{}).Where("id = ?", id).
|
||||
Updates(models.DNSCanary{LatestTrigger: time.Now()}).Error
|
||||
})
|
||||
return firstTrigger && err == nil, err
|
||||
}
|
||||
|
||||
// CanaryGenerator - Holds data related to canary generation
|
||||
@@ -75,7 +108,7 @@ func (g *CanaryGenerator) GenerateCanary() string {
|
||||
// Don't need secure random here
|
||||
index := util.Intn(len(g.ParentDomains))
|
||||
|
||||
parentDomain := g.ParentDomains[index]
|
||||
parentDomain := strings.ToLower(strings.TrimSpace(g.ParentDomains[index]))
|
||||
parentDomain = strings.TrimPrefix(parentDomain, ".")
|
||||
if !strings.HasSuffix(parentDomain, ".") {
|
||||
parentDomain += "." // Ensure we have the FQDN
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//go:build sliver_generate
|
||||
|
||||
package generate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/certs"
|
||||
"github.com/bishopfox/sliver/server/configs"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
)
|
||||
|
||||
// Canary domains must remain visible to a binary scanner after the production
|
||||
// compiler and obfuscator run. Checking rendered source alone misses both
|
||||
// literal obfuscation and linker removal of unused type metadata.
|
||||
func TestCanariesSurviveObfuscation(t *testing.T) {
|
||||
const parentDomain = "canary-regression.invalid."
|
||||
certs.SetupCAs()
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
protocol string
|
||||
beacon bool
|
||||
wantCanaries int
|
||||
}{
|
||||
{name: "windows_http_session", goos: "windows", protocol: "http", wantCanaries: 1},
|
||||
{name: "linux_http_beacon", goos: "linux", protocol: "http", beacon: true, wantCanaries: 1},
|
||||
{name: "windows_mtls_session", goos: "windows", protocol: "mtls", wantCanaries: 2},
|
||||
{name: "linux_wireguard_session", goos: "linux", protocol: "wg", wantCanaries: 2},
|
||||
{name: "linux_http_without_canaries", goos: "linux", protocol: "http"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
name := "canary_" + strings.ReplaceAll(models.NewUUID().String(), "-", "")
|
||||
config := &clientpb.ImplantConfig{
|
||||
GOOS: tt.goos,
|
||||
GOARCH: "amd64",
|
||||
Debug: false,
|
||||
ObfuscateSymbols: true,
|
||||
NetGoEnabled: true,
|
||||
IsBeacon: tt.beacon,
|
||||
C2: []*clientpb.ImplantC2{
|
||||
{URL: tt.protocol + "://c2.invalid"},
|
||||
},
|
||||
}
|
||||
if tt.wantCanaries > 0 {
|
||||
config.CanaryDomains = []string{parentDomain}
|
||||
}
|
||||
switch tt.protocol {
|
||||
case "http":
|
||||
config.IncludeHTTP = true
|
||||
case "mtls":
|
||||
config.IncludeMTLS = true
|
||||
case "wg":
|
||||
config.IncludeWG = true
|
||||
config.WGKeyExchangePort = 1234
|
||||
config.WGTcpCommsPort = 5678
|
||||
certs.SetupWGKeys()
|
||||
}
|
||||
|
||||
// Register cleanup before generation so failed builds do not leave
|
||||
// source trees or canary records behind.
|
||||
projectDir := filepath.Join(GetSliversDir(), config.GOOS, config.GOARCH, name)
|
||||
t.Cleanup(func() {
|
||||
if err := os.RemoveAll(projectDir); err != nil {
|
||||
t.Errorf("remove generated project: %v", err)
|
||||
}
|
||||
if err := db.Session().Where("implant_name = ?", name).Delete(&models.DNSCanary{}).Error; err != nil {
|
||||
t.Errorf("remove generated canaries: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
build, err := GenerateConfig(name, config)
|
||||
if err != nil {
|
||||
t.Fatalf("generate implant config: %v", err)
|
||||
}
|
||||
httpConfig := configs.GenerateDefaultHTTPC2Config()
|
||||
binPath, err := SliverExecutable(name, build, config, httpConfig.ImplantConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("build obfuscated implant: %v", err)
|
||||
}
|
||||
binary, err := os.ReadFile(binPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read obfuscated implant: %v", err)
|
||||
}
|
||||
var canaries []models.DNSCanary
|
||||
if err := db.Session().Where("implant_name = ?", name).Find(&canaries).Error; err != nil {
|
||||
t.Fatalf("read generated canaries: %v", err)
|
||||
}
|
||||
assertCanaryBinary(t, binary, canaries, parentDomain, tt.wantCanaries)
|
||||
t.Logf("checked %d registered canaries in %s/%s obfuscated binary (%d bytes)", len(canaries), config.GOOS, config.GOARCH, len(binary))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCanaryBinary(t *testing.T, binary []byte, canaries []models.DNSCanary, parentDomain string, wantCanaries int) {
|
||||
t.Helper()
|
||||
|
||||
if bytes.Contains(binary, []byte("[[GenerateCanary]]")) {
|
||||
t.Error("unexpanded canary placeholder remains in binary")
|
||||
}
|
||||
if len(canaries) != wantCanaries {
|
||||
t.Fatalf("registered %d canaries, want %d", len(canaries), wantCanaries)
|
||||
}
|
||||
if wantCanaries == 0 {
|
||||
if bytes.Contains(binary, []byte(parentDomain)) {
|
||||
t.Error("build without canaries contains the canary parent domain")
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, canary := range canaries {
|
||||
if canary.Domain == "" || !strings.HasSuffix(canary.Domain, "."+parentDomain) {
|
||||
t.Errorf("unexpected generated canary domain %q", canary.Domain)
|
||||
continue
|
||||
}
|
||||
if !bytes.Contains(binary, []byte(canary.Domain)) {
|
||||
t.Errorf("registered canary %q is absent from the obfuscated binary", canary.Domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func createCanaryStateFixture(t *testing.T) *models.DNSCanary {
|
||||
t.Helper()
|
||||
canary := &models.DNSCanary{
|
||||
ImplantName: "canary_state_" + models.NewUUID().String(),
|
||||
Domain: models.NewUUID().String() + ".canary.invalid.",
|
||||
}
|
||||
if err := db.Session().Create(canary).Error; err != nil {
|
||||
t.Fatalf("create canary: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Session().Delete(canary).Error; err != nil {
|
||||
t.Errorf("delete canary: %v", err)
|
||||
}
|
||||
})
|
||||
return canary
|
||||
}
|
||||
|
||||
func readCanaryState(t *testing.T, id models.UUID) models.DNSCanary {
|
||||
t.Helper()
|
||||
var canary models.DNSCanary
|
||||
if err := db.Session().First(&canary, "id = ?", id).Error; err != nil {
|
||||
t.Fatalf("read canary: %v", err)
|
||||
}
|
||||
return canary
|
||||
}
|
||||
|
||||
func assertCanaryTriggerCount(t *testing.T, canary models.DNSCanary, count uint32) {
|
||||
t.Helper()
|
||||
if !canary.Triggered || canary.Count != count {
|
||||
t.Errorf("triggered/count = %v/%d, want true/%d", canary.Triggered, canary.Count, count)
|
||||
}
|
||||
if canary.FirstTrigger.IsZero() || canary.LatestTrigger.Before(canary.FirstTrigger) {
|
||||
t.Errorf("invalid trigger times: first=%v latest=%v", canary.FirstTrigger, canary.LatestTrigger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCanaryPersistsRepeatedQueries(t *testing.T) {
|
||||
canary := createCanaryStateFixture(t)
|
||||
// Reuse a stale caller snapshot; persistence must use the database state.
|
||||
snapshot := &clientpb.DNSCanary{ID: canary.ID.String(), Count: 99}
|
||||
first, err := TriggerCanary(snapshot)
|
||||
if err != nil || !first {
|
||||
t.Fatalf("first trigger = (%v, %v), want (true, nil)", first, err)
|
||||
}
|
||||
initial := readCanaryState(t, canary.ID)
|
||||
assertCanaryTriggerCount(t, initial, 1)
|
||||
first, err = TriggerCanary(snapshot)
|
||||
if err != nil || first {
|
||||
t.Fatalf("repeat trigger = (%v, %v), want (false, nil)", first, err)
|
||||
}
|
||||
latest := readCanaryState(t, canary.ID)
|
||||
assertCanaryTriggerCount(t, latest, 2)
|
||||
if !latest.FirstTrigger.Equal(initial.FirstTrigger) || latest.LatestTrigger.Before(initial.LatestTrigger) {
|
||||
t.Error("repeat query changed first trigger time or moved latest trigger backwards")
|
||||
}
|
||||
if latest.Domain != canary.Domain || latest.ImplantName != canary.ImplantName {
|
||||
t.Error("trigger overwrote canary identity from the caller snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCanaryConcurrentQueries(t *testing.T) {
|
||||
const queries = 12
|
||||
canary := createCanaryStateFixture(t)
|
||||
snapshot := &clientpb.DNSCanary{ID: canary.ID.String()}
|
||||
type result struct {
|
||||
first bool
|
||||
err error
|
||||
}
|
||||
results := make(chan result, queries)
|
||||
start := make(chan struct{})
|
||||
for range queries {
|
||||
go func() {
|
||||
<-start
|
||||
first, err := TriggerCanary(snapshot)
|
||||
results <- result{first: first, err: err}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
firstTriggers := 0
|
||||
for range queries {
|
||||
got := <-results
|
||||
if got.err != nil {
|
||||
t.Errorf("concurrent trigger: %v", got.err)
|
||||
}
|
||||
if got.first {
|
||||
firstTriggers++
|
||||
}
|
||||
}
|
||||
if firstTriggers != 1 {
|
||||
t.Errorf("first triggers = %d, want 1", firstTriggers)
|
||||
}
|
||||
assertCanaryTriggerCount(t, readCanaryState(t, canary.ID), queries)
|
||||
}
|
||||
|
||||
func TestTriggerCanaryRejectsMissingIdentity(t *testing.T) {
|
||||
canary := createCanaryStateFixture(t)
|
||||
var before int64
|
||||
if err := db.Session().Model(&models.DNSCanary{}).Count(&before).Error; err != nil {
|
||||
t.Fatalf("count canaries: %v", err)
|
||||
}
|
||||
for _, snapshot := range []*clientpb.DNSCanary{
|
||||
nil,
|
||||
{},
|
||||
{ID: "not-a-uuid"},
|
||||
{ID: models.NilUUID().String()},
|
||||
{ID: models.NewUUID().String()},
|
||||
} {
|
||||
first, err := TriggerCanary(snapshot)
|
||||
if err == nil || first {
|
||||
t.Errorf("trigger for %v = (%v, %v), want (false, error)", snapshot, first, err)
|
||||
}
|
||||
}
|
||||
var after int64
|
||||
if err := db.Session().Model(&models.DNSCanary{}).Count(&after).Error; err != nil {
|
||||
t.Fatalf("count canaries: %v", err)
|
||||
}
|
||||
if after != before {
|
||||
t.Errorf("invalid identity inserted canaries: count changed from %d to %d", before, after)
|
||||
}
|
||||
unchanged := readCanaryState(t, canary.ID)
|
||||
if unchanged.Triggered || unchanged.Count != 0 {
|
||||
t.Error("invalid identity changed an existing canary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCanaryRollsBackWriteFailure(t *testing.T) {
|
||||
canary := createCanaryStateFixture(t)
|
||||
injectedErr := errors.New("injected canary timestamp write failure")
|
||||
callbackName := "test:canary-trigger-write-" + canary.ID.String()
|
||||
var rejectWrite atomic.Bool
|
||||
rejectWrite.Store(true)
|
||||
if err := db.Client.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
|
||||
update, ok := tx.Statement.Dest.(models.DNSCanary)
|
||||
if rejectWrite.Load() && ok && !update.LatestTrigger.IsZero() {
|
||||
_ = tx.AddError(injectedErr)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register write failure: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
rejectWrite.Store(false)
|
||||
if err := db.Client.Callback().Update().Remove(callbackName); err != nil {
|
||||
t.Errorf("remove write failure callback: %v", err)
|
||||
}
|
||||
})
|
||||
first, err := TriggerCanary(&clientpb.DNSCanary{ID: canary.ID.String()})
|
||||
if first || !errors.Is(err, injectedErr) {
|
||||
t.Fatalf("failed trigger = (%v, %v), want (false, injected error)", first, err)
|
||||
}
|
||||
unchanged := readCanaryState(t, canary.ID)
|
||||
if unchanged.Triggered || unchanged.Count != 0 || !unchanged.FirstTrigger.IsZero() || !unchanged.LatestTrigger.IsZero() {
|
||||
t.Error("failed trigger did not roll back count and trigger timestamps")
|
||||
}
|
||||
rejectWrite.Store(false)
|
||||
first, err = TriggerCanary(&clientpb.DNSCanary{ID: canary.ID.String()})
|
||||
if err != nil || !first {
|
||||
t.Fatalf("trigger after rollback = (%v, %v), want (true, nil)", first, err)
|
||||
}
|
||||
assertCanaryTriggerCount(t, readCanaryState(t, canary.ID), 1)
|
||||
}
|
||||
|
||||
func TestGenerateCanaryNormalizesParent(t *testing.T) {
|
||||
for _, parent := range []string{"MiXeD.Example", "MIXED.EXAMPLE.", " \t.MiXeD.Example \t"} {
|
||||
t.Run(parent, func(t *testing.T) {
|
||||
name := "canary_normalization_" + models.NewUUID().String()
|
||||
t.Cleanup(func() {
|
||||
if err := db.Session().Where("implant_name = ?", name).Delete(&models.DNSCanary{}).Error; err != nil {
|
||||
t.Errorf("delete generated canary: %v", err)
|
||||
}
|
||||
})
|
||||
generator := CanaryGenerator{ImplantName: name, ParentDomains: []string{parent}}
|
||||
domain := generator.GenerateCanary()
|
||||
if !strings.HasSuffix(domain, ".mixed.example.") || domain != strings.ToLower(domain) {
|
||||
t.Fatalf("generated domain %q was not normalized", domain)
|
||||
}
|
||||
canary, err := db.CanaryByDomain(domain)
|
||||
if err != nil {
|
||||
t.Fatalf("look up normalized canary: %v", err)
|
||||
}
|
||||
if canary.Domain != domain || canary.ImplantName != name {
|
||||
t.Errorf("stored canary = %v, want domain %s and implant %s", canary, domain, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/gogo"
|
||||
)
|
||||
|
||||
@@ -135,3 +136,30 @@ func TestSanitizeZigForBuild_Windows(t *testing.T) {
|
||||
t.Fatalf("expected -fdebug-prefix-map to be set for zig builds, got CXX=%q", cfg.CXX)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCompactExecutableFunctionAlignment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
arch string
|
||||
debug bool
|
||||
obfuscate bool
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "amd64", arch: "amd64", obfuscate: true, want: "-funcalign=1"},
|
||||
{name: "arm64 unchanged", arch: "arm64", obfuscate: true, want: ""},
|
||||
{name: "windows gui", arch: "amd64", obfuscate: true, input: "-H=windowsgui", want: "-H=windowsgui -funcalign=1"},
|
||||
{name: "debug", arch: "amd64", debug: true, obfuscate: true, want: ""},
|
||||
{name: "plain", arch: "amd64", want: ""},
|
||||
{name: "unsupported arch", arch: "386", obfuscate: true, want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := &clientpb.ImplantConfig{GOARCH: tt.arch, Debug: tt.debug, ObfuscateSymbols: tt.obfuscate}
|
||||
got := applyCompactExecutableFunctionAlignment(config, []string{tt.input})
|
||||
if len(got) != 1 || got[0] != tt.want {
|
||||
t.Fatalf("applyCompactExecutableFunctionAlignment() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package generate
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2019 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/>.
|
||||
*/
|
||||
Loaded 100 of 488 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user