mirror of
https://github.com/Mr-In4inci3le/sliver-gui
synced 2026-08-09 12:14:37 +00:00
Pivot graph + working implant generation, connect-flow fix, armory
- Fix connect flow (a JS syntax error had disabled every frontend handler) - Redesign agent graph as pivot topology: firewall egress, hierarchical chains, arrows coloured by agent (green session / red privileged / blue beacon), join chains by session id so same-host pivots render correctly - Implant generation: align Sliver dep with teamserver protobuf, add tcp-pivot / named-pipe C2 schemes, baseline MTLS transport (fixes exit status 1) - Generate UX: decouple build from save (no stuck 'Building...' spinner) - Wire the armory command (list / install / remove) - App window/taskbar icon + Linux .desktop entry; author watermark - Premium README refresh
This commit is contained in:
Executable
+45
@@ -0,0 +1,45 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Vet, lint & test
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.6'
|
||||
cache: true
|
||||
|
||||
# Wails links against GTK/WebKit; needed to compile package main.
|
||||
- name: Install WebKit build deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev libwebkit2gtk-4.1-dev build-essential pkg-config
|
||||
|
||||
- name: Download modules
|
||||
run: go mod download
|
||||
|
||||
- name: go vet
|
||||
run: go vet -tags webkit2_41 ./...
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: --build-tags webkit2_41 --timeout 5m
|
||||
|
||||
- name: go test
|
||||
run: go test -tags webkit2_41 -race -count=1 ./...
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write # needed to create the GitHub release + upload assets
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-amd64
|
||||
os: ubuntu-24.04
|
||||
tags: webkit2_41
|
||||
binary: sliver-gui
|
||||
asset: sliver-gui-linux-amd64
|
||||
- name: windows-amd64
|
||||
os: windows-latest
|
||||
tags: ""
|
||||
binary: sliver-gui.exe
|
||||
asset: sliver-gui-windows-amd64.exe
|
||||
- name: darwin-universal
|
||||
os: macos-latest
|
||||
tags: ""
|
||||
binary: sliver-gui.app.zip
|
||||
asset: sliver-gui-darwin-universal.app.zip
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.6'
|
||||
cache: true
|
||||
|
||||
- name: Install WebKit deps (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev build-essential pkg-config
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.9.1
|
||||
|
||||
- name: Copy embedded icons
|
||||
shell: bash
|
||||
run: cp -r frontend/icons frontend/dist/icons || true
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
COMMIT="$(git rev-parse --short HEAD)"
|
||||
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
LDFLAGS="-X main.Version=${VERSION} -X main.GitCommit=${COMMIT} -X main.BuildDate=${DATE}"
|
||||
TAGARG=""
|
||||
if [ -n "${{ matrix.tags }}" ]; then TAGARG="-tags ${{ matrix.tags }}"; fi
|
||||
if [ "${{ matrix.name }}" = "darwin-universal" ]; then
|
||||
wails build $TAGARG -platform darwin/universal -ldflags "$LDFLAGS"
|
||||
(cd build/bin && zip -r -q sliver-gui.app.zip sliver-gui.app)
|
||||
else
|
||||
wails build $TAGARG -ldflags "$LDFLAGS"
|
||||
fi
|
||||
|
||||
- name: Stage asset + checksum
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp "build/bin/${{ matrix.binary }}" "dist/${{ matrix.asset }}"
|
||||
cd dist
|
||||
if command -v sha256sum >/dev/null; then
|
||||
sha256sum "${{ matrix.asset }}" > "${{ matrix.asset }}.sha256"
|
||||
else
|
||||
shasum -a 256 "${{ matrix.asset }}" > "${{ matrix.asset }}.sha256"
|
||||
fi
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/*
|
||||
|
||||
release:
|
||||
name: Publish release
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Combine checksums
|
||||
run: cat dist/*.sha256 > dist/SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/sliver-gui-*
|
||||
dist/SHA256SUMS.txt
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
+17
-19
@@ -1,6 +1,8 @@
|
||||
# ── Build output ─────────────────────────────────────────────
|
||||
build/bin/
|
||||
build/*.o
|
||||
# Release-staging dir used by the release workflow / `make`.
|
||||
dist/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
@@ -21,36 +23,20 @@ operators/
|
||||
configs/
|
||||
|
||||
# ── Downloaded loot / implants / dumps ───────────────────────
|
||||
# Avoid committing anything pulled off a target or generated.
|
||||
*.dmp
|
||||
*.bin
|
||||
loot/
|
||||
implants/
|
||||
downloads/
|
||||
|
||||
# Generated Sliver implants (our GUI names them <goos>-<goarch>-<id>[.exe],
|
||||
# which also catches extension-less Linux ELF implants).
|
||||
*-amd64-*
|
||||
*-386-*
|
||||
*-arm64-*
|
||||
# Common offensive tool binaries you may have staged locally.
|
||||
mimikatz*
|
||||
Rubeus*
|
||||
GodPotato*
|
||||
nanodump*
|
||||
|
||||
# ── Go ───────────────────────────────────────────────────────
|
||||
vendor/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# ── Editor / OS cruft ────────────────────────────────────────
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Named test implants used during development
|
||||
# Named test implants used during development (arbitrary names).
|
||||
initial
|
||||
rootlinux
|
||||
ws01
|
||||
beacon
|
||||
@@ -60,3 +46,15 @@ svc.exe
|
||||
winnew*
|
||||
test.exe
|
||||
win.exe
|
||||
|
||||
# ── Go ───────────────────────────────────────────────────────
|
||||
vendor/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# ── Editor / OS / tooling cruft ──────────────────────────────
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
# golangci-lint configuration for Sliver GUI.
|
||||
# Kept intentionally pragmatic: correctness-focused linters on, noisy style
|
||||
# linters off, so CI stays useful without drowning contributors in nits.
|
||||
run:
|
||||
timeout: 5m
|
||||
build-tags:
|
||||
- webkit2_41
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- govet
|
||||
- staticcheck
|
||||
- errcheck
|
||||
- ineffassign
|
||||
- unused
|
||||
- gosimple
|
||||
- misspell
|
||||
- unconvert
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
# Best-effort writes (audit log, UI event emits) intentionally ignore errors.
|
||||
- path: audit\.go
|
||||
linters: [errcheck]
|
||||
# Test files may ignore errors on marshal helpers for brevity.
|
||||
- path: _test\.go
|
||||
linters: [errcheck]
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -0,0 +1,42 @@
|
||||
# Sliver GUI build helpers.
|
||||
# Requires: Go 1.22+, the Wails v2 CLI, and (on Linux) WebKit dev headers.
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS = -X main.Version=$(VERSION) -X main.GitCommit=$(COMMIT) -X main.BuildDate=$(DATE)
|
||||
|
||||
# WebKit 4.1 tag for modern Linux (Ubuntu 24.04, Kali). Override on WebKit 4.0:
|
||||
# make build TAGS=
|
||||
TAGS ?= webkit2_41
|
||||
TAGARG = $(if $(TAGS),-tags $(TAGS),)
|
||||
|
||||
.PHONY: build dev test lint vet icons clean
|
||||
|
||||
## build: compile the GUI with version metadata baked in
|
||||
build: icons
|
||||
wails build $(TAGARG) -ldflags "$(LDFLAGS)"
|
||||
|
||||
## dev: run with hot reload
|
||||
dev: icons
|
||||
wails dev $(TAGARG) -ldflags "$(LDFLAGS)"
|
||||
|
||||
## icons: copy source icons into the embedded frontend (first build only)
|
||||
icons:
|
||||
@cp -r frontend/icons frontend/dist/icons 2>/dev/null || true
|
||||
|
||||
## test: run unit tests
|
||||
test:
|
||||
go test $(TAGARG) -race -count=1 ./...
|
||||
|
||||
## vet: run go vet
|
||||
vet:
|
||||
go vet $(TAGARG) ./...
|
||||
|
||||
## lint: run golangci-lint (must be installed)
|
||||
lint:
|
||||
golangci-lint run --build-tags "$(TAGS)"
|
||||
|
||||
## clean: remove build output
|
||||
clean:
|
||||
rm -rf build/bin dist
|
||||
@@ -1,19 +1,25 @@
|
||||
# Sliver GUI
|
||||
<h1 align="center">Sliver GUI</h1>
|
||||
|
||||
**A desktop operator console for [Sliver C2](https://github.com/BishopFox/sliver) GUI developed by [Raj Kumar Mullapudi](#author--credits).**
|
||||
<p align="center">
|
||||
A desktop operator console for the <a href="https://github.com/BishopFox/sliver">Sliver C2</a> framework
|
||||
in the spirit of Cobalt Strike and Havoc. Designed and Developed by <a href="https://rajkumarmullapudi.com/">Raj Kumar Mullapudi</a>
|
||||
</p>
|
||||
|
||||
In the spirit of Cobalt Strike / Havoc's GUIs. It does **not** reimplement any
|
||||
C2 protocol logic it's a thin [Wails v2](https://wails.io) (Go + plain
|
||||
HTML/CSS/JS) frontend over Sliver's existing `rpcpb.SliverRPC` gRPC service,
|
||||
using the same mTLS operator config files the official `sliver-client` uses.
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Wails" src="https://img.shields.io/badge/Wails-v2-d32f2f">
|
||||
<img alt="Sliver" src="https://img.shields.io/badge/Sliver-C2-e23c4e">
|
||||
<img alt="Platform" src="https://img.shields.io/badge/Linux%20·%20Windows%20·%20macOS-555">
|
||||
</p>
|
||||
|
||||
> This project is the **GUI layer only**. The Sliver C2 framework it drives is a
|
||||
> separate project by BishopFox all C2 capability comes from Sliver; this repo
|
||||
> contributes the desktop operator interface on top of it.
|
||||
Sliver GUI is a thin [Wails v2](https://wails.io) (Go + plain HTML/CSS/JS) frontend over Sliver's
|
||||
existing `rpcpb.SliverRPC` gRPC service. It reimplements **no** C2 logic and connects with the same
|
||||
mTLS operator `.cfg` files as the official `sliver-client` every command-and-control capability
|
||||
comes from Sliver itself. Because the backend is Go, it imports Sliver's real protobuf/gRPC stubs
|
||||
directly: no grpc-web proxy, no protocol reimplementation, no drift when upstream changes its `.proto`.
|
||||
|
||||
> ⚠️ **Authorized use only.** This is an offensive-security tool. Use it solely
|
||||
> on systems you own or have **explicit written permission** to test. You are
|
||||
> responsible for complying with all applicable laws.
|
||||
> **Authorized use only.** This is an offensive-security tool. Use it solely on systems you own
|
||||
> or have explicit written permission to test.
|
||||
|
||||
---
|
||||
|
||||
@@ -30,213 +36,105 @@ using the same mTLS operator config files the official `sliver-client` uses.
|
||||
<img width="1904" height="837" alt="5" src="https://github.com/user-attachments/assets/86f36f9e-3bbe-4f9a-a436-7d6d38ed49c4" />
|
||||
<br><br>
|
||||
<img width="1910" height="830" alt="6" src="https://github.com/user-attachments/assets/c15aa69d-7a46-4320-ba9f-8de90084cad5" />
|
||||
|
||||
> Screenshots reflect the current interactive graph, per-agent + server consoles,
|
||||
> and management panels.
|
||||
<br><br>
|
||||
<img width="985" height="467" alt="image" src="https://github.com/user-attachments/assets/48c8ad5b-1aaa-4635-b32e-70fb7f59d50a" />
|
||||
<br><br>
|
||||
<img width="1912" height="837" alt="image" src="https://github.com/user-attachments/assets/f1206fd0-ff57-44f4-b8c0-4961fcd1f8eb" />
|
||||
<br><br>
|
||||
<img width="1911" height="840" alt="image" src="https://github.com/user-attachments/assets/532856c9-a76f-4559-b922-b48b5e24df43" />
|
||||
<br><br>
|
||||
<img width="1912" height="832" alt="image" src="https://github.com/user-attachments/assets/5db41809-17f4-40e2-bf7c-20a5a4901666" />
|
||||
<br><br>
|
||||
<img width="1912" height="756" alt="image" src="https://github.com/user-attachments/assets/b779ca54-7b43-4d0d-b066-66e25d7f73aa" />
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
**Connection**
|
||||
- Connect with a Sliver operator `.cfg` file (mTLS cert + token the same
|
||||
credential the official client uses; no username/password).
|
||||
- Live event stream (session/beacon/job events) with toast notifications.
|
||||
- Auto-reconnect with a countdown overlay if the teamserver drops.
|
||||
| Area | What you get |
|
||||
|------|--------------|
|
||||
| **Agents** | Unified sessions + beacons table, plus an interactive **pivot graph** firewall/egress boundary on the left, agents laid out in their real pivot topology (chains joined by session id, so same-host pivots render correctly), arrows colour-coded by agent: **green** session · **red** SYSTEM/privileged · **blue** beacon. |
|
||||
| **Per-agent console** | Real-time (session) or queued (beacon) consoles with **70+ RPC-backed commands**, a full **interactive PTY shell** (`shell -i` / `pty`) over a gRPC tunnel, and **extensions / BOFs** (`ext …`) run through the same flow as the official client. |
|
||||
| **Server console** | A pinned `sliver >` prompt for ~45 teamserver commands. |
|
||||
| **Implants** | **Generate** (mTLS · HTTP · DNS · WireGuard · `tcp-pivot`), profiles, builds, and **armory** install / remove. |
|
||||
| **Data** | Loot · credentials · hosts · operators · full event log. |
|
||||
| **Operator QoL** | JSONL **audit log**, per-teamserver persisted graph layout / notes / integrity, **`Ctrl+K`** command palette, live event stream with toasts, and auto-reconnect. |
|
||||
|
||||
**Agent overview**
|
||||
- Combined **sessions + beacons** table (type, host, user, OS/arch, PID,
|
||||
transport, last check-in, status).
|
||||
- Interactive **graph view** drag nodes, pan, scroll to zoom, straight edges,
|
||||
OS icons (Windows/Linux, privileged vs. user), privileged agents highlighted,
|
||||
dead agents greyed out. Double-click a node to interact.
|
||||
- Right-click menu: Interact / Rename / Kill.
|
||||
<details>
|
||||
<summary><b>Full command reference</b></summary>
|
||||
|
||||
**Per-agent console** (double-click an agent)
|
||||
- Sessions run commands in real time; beacons queue commands and poll for
|
||||
results on the next check-in.
|
||||
- Native RPC-backed commands (no shell spawned unless you ask):
|
||||
`ps · ls · cd · pwd · cat · mkdir · rm · mv · cp · chmod · chown · download ·
|
||||
upload · screenshot · netstat · ifconfig · env · getenv · setenv · unsetenv ·
|
||||
reg · whoami · getprivs · getpid · procdump · kill · chtimes · execute ·
|
||||
execute-assembly · execute-shellcode · sideload · spawndll · getsystem ·
|
||||
make-token · impersonate · rev2self · runas · migrate · backdoor · dllhijack ·
|
||||
msf · msf-inject · extensions · ext · socks · portfwd · rportfwd · wg-portfwd ·
|
||||
wg-socks · pivot · services · loot · shell`
|
||||
- **Extensions / BOFs:** `extensions` lists installed + loaded extensions;
|
||||
`ext <command> [args]` loads and runs an extension or BOF (e.g. `ext sa-whoami`,
|
||||
`ext nanodump ...`). Reads manifests from `~/.sliver-client/extensions`, packs
|
||||
BOF arguments via Sliver's own `core.BOFArgsBuffer`, and routes BOFs through
|
||||
their coff-loader dependency — same flow as the official client.
|
||||
- Beacon-only: `tasks`, `reconfig <interval> <jitter>` (change check-in speed
|
||||
live), `interactive` (promote a beacon to a session).
|
||||
- `shell <cmd>` and unrecognized input run in the target's OS shell.
|
||||
- Type `help` in any console for the full list.
|
||||
<br>
|
||||
|
||||
**Server console** (pinned tab)
|
||||
- A `sliver >` prompt for teamserver commands:
|
||||
`sessions · beacons · jobs [kill <id>] · operators · loot · hosts · creds ·
|
||||
builds · regenerate <name> · profiles · c2profiles · websites · canaries ·
|
||||
stager · use <id> · rename · kill-session/kill-beacon · version ·
|
||||
mtls/http/https/dns/wg <…>`.
|
||||
**Per-agent:-** `ps · ls · cd · pwd · cat · mkdir · rm · mv · cp · chmod · chown · download · upload ·
|
||||
screenshot · netstat · ifconfig · env · getenv · setenv · unsetenv · reg · grep · mount · memfiles ·
|
||||
ssh · whoami · getprivs · getpid · procdump · kill · chtimes · execute · execute-assembly ·
|
||||
execute-shellcode · sideload · spawndll · getsystem · make-token · impersonate · rev2self · runas ·
|
||||
migrate · backdoor · dllhijack · msf · msf-inject · extensions · ext · socks · portfwd · rportfwd ·
|
||||
wg-portfwd · wg-socks · pivot · services · loot · shell · shell -i / pty`
|
||||
· beacon-only: `tasks · reconfig · interactive`
|
||||
|
||||
**Panels**
|
||||
- **Listeners** start/stop mTLS, HTTP, HTTPS, DNS (and WireGuard) listeners.
|
||||
- **Generate** build implants; pick an active listener to auto-fill the C2 URL;
|
||||
session or beacon mode; saves the binary via a native dialog.
|
||||
- **Builds** list and delete previous implant builds.
|
||||
- **Profiles** create / delete implant profiles and generate from them.
|
||||
- **Loot** shared loot store: `loot add <file>` from a session, download or
|
||||
delete items from the panel.
|
||||
- **Creds** shared credential store: add / list / delete captured credentials.
|
||||
- **Hosts** the teamserver's host database (seen hosts, OS, first contact).
|
||||
- **Operators** who's connected.
|
||||
- **Event Log** full activity history (pinned in the console by default).
|
||||
**Server:-** `sessions · beacons · jobs [kill] · restart-jobs · operators · loot · hosts · creds ·
|
||||
builds · regenerate · profiles · c2profiles · certificates · compiler · builders · traffic-encoders ·
|
||||
shellcode-encoders · armory [install/remove] · websites · canaries · stager · use · rename ·
|
||||
kill-session · kill-beacon · version · mtls/http/https/dns/wg`
|
||||
|
||||
**Quality-of-life**
|
||||
- Per-agent **operator notes** (in-memory, cleared on disconnect).
|
||||
- **Command palette** `Ctrl+K` to jump to any agent or panel.
|
||||
- **On-demand integrity check** right-click a session → *Check Integrity* runs
|
||||
`getprivs` and repaints the node/table by its real level (SYSTEM / High /
|
||||
Medium), instead of guessing from the username.
|
||||
- Privilege-aware graph: SYSTEM/admin agents get red edges + a `★ LEVEL` badge;
|
||||
beacons are dashed blue; dead agents use a distinct dead icon.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Why Wails, not Electron
|
||||
## Quick start
|
||||
|
||||
Sliver is written in Go and ships reusable client packages
|
||||
(`github.com/bishopfox/sliver/protobuf/{clientpb,rpcpb,sliverpb}`). Wails lets
|
||||
the Go backend import those directly and call the real generated gRPC stubs
|
||||
no grpc-web proxy, no reimplementing the protocol in JS, and no drift when
|
||||
upstream changes their `.proto` files.
|
||||
**Prerequisites**
|
||||
|
||||
## Project layout
|
||||
- **Go 1.25.6+** with `GOTOOLCHAIN=auto` the right toolchain is fetched automatically
|
||||
- **[Wails v2 CLI](https://wails.io/docs/gettingstarted/installation)** `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
- **Linux WebKit deps** `sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev build-essential pkg-config`
|
||||
- **A Sliver operator `.cfg`** `sliver-server operator --name <you> --lhost <host> --save <you>.cfg`
|
||||
|
||||
```
|
||||
sliver-gui/
|
||||
├── main.go # Wails entrypoint
|
||||
├── app.go # Bound methods exposed to the frontend
|
||||
├── internal/sliverclient/
|
||||
│ └── client.go # mTLS connection + RPC helpers
|
||||
├── frontend/
|
||||
│ ├── dist/ # Plain HTML/CSS/JS UI (no bundler)
|
||||
│ │ ├── index.html
|
||||
│ │ ├── style.css
|
||||
│ │ ├── main.js
|
||||
│ │ └── icons/ # Node icons (embedded in the build)
|
||||
│ └── icons/ # Source icons (copy into dist/ before building)
|
||||
├── go.mod
|
||||
└── wails.json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.22+
|
||||
- [Wails v2 CLI](https://wails.io/docs/gettingstarted/installation):
|
||||
`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
- Linux WebKit build deps (Debian/Kali/Ubuntu):
|
||||
`sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev build-essential pkg-config`
|
||||
- A Sliver teamserver you have an operator account on, and its `.cfg` file:
|
||||
```
|
||||
sliver-server operator --name <you> --lhost <teamserver> --save <you>.cfg
|
||||
```
|
||||
|
||||
## Build & run
|
||||
|
||||
Fetch dependencies (needs internet the first time):
|
||||
**Build & run**
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
Make sure the icons are in the embedded frontend, then build (Linux uses the
|
||||
WebKit 4.1 build tag):
|
||||
|
||||
```bash
|
||||
cp -r frontend/icons frontend/dist/icons # first build only
|
||||
wails build -tags webkit2_41
|
||||
make build # → build/bin/sliver-gui (or: wails build -tags webkit2_41)
|
||||
./build/bin/sliver-gui
|
||||
```
|
||||
|
||||
Dev mode (hot reload):
|
||||
> Hot reload: `make dev` · tests & linters: `make test` · `make lint` · `make vet`
|
||||
> On WebKit 4.0 systems, use `make build TAGS=` (drop the `webkit2_41` tag).
|
||||
|
||||
```bash
|
||||
wails dev -tags webkit2_41
|
||||
```
|
||||
|
||||
> On systems with WebKit 4.0 instead of 4.1, drop `-tags webkit2_41`.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Launch the app, click **Select operator .cfg**, choose your `.cfg`, then
|
||||
**Connect**.
|
||||
2. Start a listener (**Listeners** panel or `mtls 8443` in the Server console).
|
||||
3. **Generate** an implant pick your listener from the *From Listener*
|
||||
dropdown so the C2 URL is filled correctly (point it at a **C2 listener
|
||||
port**, not the teamserver's operator port).
|
||||
4. Run the implant on the target; the session/beacon appears in the table/graph.
|
||||
5. Double-click it to open a console, or `use <id-prefix>` in the Server console.
|
||||
1. **Connect** :- select your operator `.cfg`.
|
||||
2. **Listen** :- start a listener (Listeners panel, or `mtls 8443` in the server console).
|
||||
3. **Generate** :- build an implant against that listener's C2 (or a `tcppivot://` for pivots), then **Save to disk**.
|
||||
4. **Run** it on the target the session/beacon appears in the table and graph.
|
||||
5. **Interact** :- double-click the agent, or `use <id>` in the server console.
|
||||
|
||||
## Notes & known behaviors
|
||||
---
|
||||
|
||||
- **Sessions vs. beacons:** sessions are real-time; beacons only respond on
|
||||
their check-in interval (interval ± jitter), so beacon output is delayed by
|
||||
design. Use a shorter interval or a session for interactive work.
|
||||
- **`getsystem <profile>`** builds a fresh implant from a saved profile, so the
|
||||
profile must be a complete, buildable config. Profiles created with older
|
||||
versions may fail delete and recreate them.
|
||||
- **Symbol obfuscation** is disabled for generated implants so builds work on a
|
||||
stock teamserver (garble isn't required).
|
||||
- Built and tested against **Sliver v1.7.x**. Other versions may have different
|
||||
protobuf field names.
|
||||
## Notes
|
||||
|
||||
## Architecture (for contributors)
|
||||
|
||||
- Every backend capability is an exported method on the `App` struct in
|
||||
`app.go`; Wails auto-binds them to `window.go.main.App.*` in JS.
|
||||
- All session-scoped RPCs use `&commonpb.Request{SessionID: ...}`; beacon tasks
|
||||
use `BeaconID` + `Async` and are polled via `GetBeaconTasks` /
|
||||
`GetBeaconTaskContent`.
|
||||
- SOCKS5 / port-forward open a local TCP listener and relay bytes over the
|
||||
respective streaming RPC.
|
||||
- The frontend is dependency-free plain JS in `frontend/dist/` (no npm/bundler).
|
||||
|
||||
## Author & Credits
|
||||
|
||||
- **GUI (this project) designed and developed by [Raj Kumar Mullapudi](mailto:in4inci3le001@gmail.com).**
|
||||
This includes the entire Wails backend (`app.go`, `internal/sliverclient`),
|
||||
the plain-JS/HTML/CSS frontend (interactive graph, per-agent console, server
|
||||
console, panels, command palette, operator notes), and all RPC wiring.
|
||||
- **Sliver C2 framework by [BishopFox](https://github.com/BishopFox/sliver).**
|
||||
All command-and-control capability comes from Sliver; this project is a client
|
||||
interface for it and does not modify or redistribute the framework itself.
|
||||
- **[Wails](https://wails.io)** the Go + web desktop framework this is built on.
|
||||
- **Sessions vs. beacons** sessions are real-time; beacon output is delayed by the check-in interval (± jitter).
|
||||
- **`getsystem <profile>`** builds from a saved profile, so the profile must be complete and buildable.
|
||||
- **Symbol obfuscation** is off by default so builds work on a stock teamserver (no garble required).
|
||||
- Pinned to a `bishopfox/sliver` **master** commit to match recent / `devel` teamservers on a tagged teamserver, pin the matching Sliver release instead.
|
||||
|
||||
## Roadmap
|
||||
|
||||
Implemented: sessions/beacons, interactive graph, per-agent + server consoles,
|
||||
full filesystem/process/network/registry/token/execution command sets,
|
||||
tunneling (socks/portfwd/rportfwd/pivots/wireguard), listeners, generation,
|
||||
profiles, builds, loot, creds, hosts, websites, canaries, stager listeners,
|
||||
**extensions/BOFs**, operator notes, command palette, and on-demand integrity
|
||||
checks.
|
||||
`crack` (hashcat cluster) · `cursed` (Chrome/Electron injection) · WASM extension *execution* (listing is wired) · external builder log streaming.
|
||||
|
||||
Not yet built (contributions welcome):
|
||||
- Armory package **install** (download/verify extensions from the armory repos;
|
||||
extension *loading/running* is already implemented)
|
||||
- `crack` (hashcat cluster) · `cursed` (Chrome/Electron injection) · `wasm` extensions
|
||||
- HTTP C2 profile **editor** (currently read-only listing)
|
||||
- External/offline builder log streaming (`BuilderRegister`/`BuilderTrigger`)
|
||||
- Certificate management panel
|
||||
---
|
||||
|
||||
## License
|
||||
## Credits
|
||||
|
||||
This project links against [BishopFox/sliver](https://github.com/BishopFox/sliver),
|
||||
which is licensed under the **GNU General Public License v3.0**. As a derivative
|
||||
work, this project (the GUI) is distributed under the **same GPLv3 license**.
|
||||
See the `LICENSE` file.
|
||||
**GUI designed and developed by [Raj Kumar Mullapudi](mailto:in4inci3le001@gmail.com)** the Wails
|
||||
backend, the vanilla-JS/HTML/CSS frontend (pivot graph, per-agent & server consoles, panels, command
|
||||
palette, operator notes), and all RPC wiring.
|
||||
|
||||
Copyright (C) 2026 Raj Kumar Mullapudi (GUI frontend). Sliver C2 is
|
||||
Copyright (C) BishopFox.
|
||||
Powered by the **[Sliver C2 framework](https://github.com/BishopFox/sliver)** (BishopFox) and
|
||||
**[Wails](https://wails.io)**. Sliver GUI is a client interface only it does not modify or
|
||||
redistribute the framework.
|
||||
|
||||
<sub>© 2026 Raj Kumar Mullapudi (GUI frontend) · Sliver C2 © BishopFox</sub>
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope
|
||||
|
||||
Sliver GUI is the **client/operator interface** only. It does not implement any
|
||||
command-and-control logic — all C2 capability, cryptography, and network
|
||||
handling belong to the upstream [Sliver](https://github.com/BishopFox/sliver)
|
||||
framework by BishopFox. Vulnerabilities in Sliver itself should be reported to
|
||||
that project.
|
||||
|
||||
This policy covers the GUI layer: the Wails Go backend (`app.go`,
|
||||
`internal/sliverclient`, `extensions.go`, `audit.go`) and the frontend.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report suspected security issues **privately** — do not open a public
|
||||
issue for anything exploitable.
|
||||
|
||||
- Email: in4inci3le001@gmail.com
|
||||
- Use GitHub's **private vulnerability reporting** ("Report a vulnerability" on
|
||||
the Security tab) if enabled.
|
||||
|
||||
Include reproduction steps, affected version (see the version string on the
|
||||
connect screen), and impact. Expect an initial acknowledgement within a few
|
||||
days.
|
||||
|
||||
## Trust model & known design decisions
|
||||
|
||||
These are deliberate, not bugs:
|
||||
|
||||
- **`InsecureSkipVerify: true` on the gRPC TLS config.** The teamserver presents
|
||||
a certificate whose CN does not match the dial address, so Go's default
|
||||
hostname verification cannot be used. The GUI compensates with a manual
|
||||
`VerifyPeerCertificate` callback that pins the operator config's CA — the same
|
||||
approach the official `sliver-client` uses. TLS is **not** actually disabled.
|
||||
- **Operator `.cfg` files contain live mTLS keys and an auth token.** They are
|
||||
never committed (`.gitignore` blocks `*.cfg`/`*.pem`/`*.key`) and never copied
|
||||
by the GUI. Treat them as secrets.
|
||||
- **Local operator state** (notes, integrity results, graph layout) is stored in
|
||||
the browser `localStorage` of the embedded WebView, scoped per teamserver.
|
||||
- **Audit log** (`~/.sliver-gui/audit.log`) records operator actions (connect,
|
||||
generate, commands) in JSONL with `0600` permissions. It is local-only and
|
||||
never transmitted.
|
||||
|
||||
## Responsible use
|
||||
|
||||
This is an offensive-security tool. Use it only against systems you own or have
|
||||
explicit written authorization to test. You are responsible for compliance with
|
||||
all applicable laws.
|
||||
@@ -36,12 +36,17 @@ type App struct {
|
||||
advMu sync.Mutex
|
||||
socks map[string]*socksProxyHandle // sessionID -> active socks proxy
|
||||
portfwds map[string][]*portfwdHandle // sessionID -> active port forwards
|
||||
shells map[string]*shellHandle // tunnelID -> interactive shell
|
||||
|
||||
audit *auditLogger // operator action log (~/.sliver-gui/audit.log)
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
return &App{
|
||||
socks: map[string]*socksProxyHandle{},
|
||||
portfwds: map[string][]*portfwdHandle{},
|
||||
shells: map[string]*shellHandle{},
|
||||
audit: newAuditLogger(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +99,14 @@ func (a *App) Connect(configPath string) ConnectResult {
|
||||
|
||||
a.startEventStream()
|
||||
|
||||
server := fmt.Sprintf("%s:%d", cfg.LHost, cfg.LPort)
|
||||
a.audit.setIdentity(cfg.Operator, server)
|
||||
a.audit.log("connect", server, "operator "+cfg.Operator)
|
||||
|
||||
return ConnectResult{
|
||||
Connected: true,
|
||||
OperatorName: cfg.Operator,
|
||||
Teamserver: fmt.Sprintf("%s:%d", cfg.LHost, cfg.LPort),
|
||||
Teamserver: server,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +298,7 @@ func (a *App) ExecuteCommand(sessionID, command string) ExecResult {
|
||||
if err != nil {
|
||||
return ExecResult{Error: err.Error()}
|
||||
}
|
||||
a.audit.log("command", sessionID, command)
|
||||
sessions, _ := client.ListSessions(a.ctx)
|
||||
var sessionOS string
|
||||
for _, s := range sessions {
|
||||
@@ -1968,6 +1978,7 @@ type GenerateRequest struct {
|
||||
|
||||
type GenerateResult struct {
|
||||
File string `json:"file"`
|
||||
Name string `json:"name"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -2040,19 +2051,14 @@ func (a *App) GenerateImplant(req GenerateRequest) GenerateResult {
|
||||
if resp.File == nil {
|
||||
return GenerateResult{Error: "server returned an empty build"}
|
||||
}
|
||||
// The compiled implant bytes come back in resp.File.Data — save them to disk
|
||||
// via a native dialog, otherwise the build only lives on the teamserver.
|
||||
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
DefaultFilename: resp.File.Name,
|
||||
Title: "Save generated implant",
|
||||
})
|
||||
if err != nil || savePath == "" {
|
||||
return GenerateResult{Error: "build succeeded but save was cancelled — build \"" + resp.File.Name + "\" is stored on the teamserver (regenerate to download again)"}
|
||||
}
|
||||
if err := os.WriteFile(savePath, resp.File.Data, 0755); err != nil {
|
||||
return GenerateResult{Error: err.Error()}
|
||||
}
|
||||
return GenerateResult{File: savePath}
|
||||
// The build is now stored on the teamserver. We deliberately do NOT open a
|
||||
// save dialog here: doing so blocked this call (and kept the "Building…"
|
||||
// spinner up) on a modal dialog that could open behind the window. Instead we
|
||||
// return the build name and let the frontend offer "Save to disk", which
|
||||
// calls RegenerateBuild(name) on a user click — a dialog raised by a direct
|
||||
// gesture gets focus, and the spinner is already gone.
|
||||
a.audit.log("generate", genReq.Name, fmt.Sprintf("%s/%s", req.GOOS, req.GOARCH))
|
||||
return GenerateResult{Name: genReq.Name}
|
||||
}
|
||||
|
||||
// randSuffix returns 8 random hex chars, used to make auto-generated implant
|
||||
@@ -2579,37 +2585,86 @@ type ServiceView struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Status string `json:"status"`
|
||||
StartupType string `json:"startupType"`
|
||||
BinPath string `json:"binPath"`
|
||||
Account string `json:"account"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ListServices has no dedicated RPC in v1.7.3, so we fall back to an Execute of
|
||||
// PowerShell Get-Service (CSV) and parse the output — same pattern as the Env
|
||||
// and Registry tabs.
|
||||
// serviceStatusLabel maps a Windows SERVICE_STATUS code to a readable label.
|
||||
func serviceStatusLabel(code uint32) string {
|
||||
switch code {
|
||||
case 1:
|
||||
return "Stopped"
|
||||
case 2:
|
||||
return "StartPending"
|
||||
case 3:
|
||||
return "StopPending"
|
||||
case 4:
|
||||
return "Running"
|
||||
case 5:
|
||||
return "ContinuePending"
|
||||
case 6:
|
||||
return "PausePending"
|
||||
case 7:
|
||||
return "Paused"
|
||||
default:
|
||||
return fmt.Sprintf("Unknown(%d)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceStartupLabel maps a Windows service start-type code to a label.
|
||||
func serviceStartupLabel(code uint32) string {
|
||||
switch code {
|
||||
case 0:
|
||||
return "Boot"
|
||||
case 1:
|
||||
return "System"
|
||||
case 2:
|
||||
return "Automatic"
|
||||
case 3:
|
||||
return "Manual"
|
||||
case 4:
|
||||
return "Disabled"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func serviceView(d *sliverpb.ServiceDetails) ServiceView {
|
||||
if d == nil {
|
||||
return ServiceView{}
|
||||
}
|
||||
return ServiceView{
|
||||
Name: d.Name,
|
||||
DisplayName: d.DisplayName,
|
||||
Description: d.Description,
|
||||
Status: serviceStatusLabel(d.Status),
|
||||
StartupType: serviceStartupLabel(d.StartupType),
|
||||
BinPath: d.BinPath,
|
||||
Account: d.Account,
|
||||
}
|
||||
}
|
||||
|
||||
// ListServices enumerates Windows services on the target via the native
|
||||
// `Services` RPC. hostname may be "" for the local host.
|
||||
func (a *App) ListServices(sessionID string) ([]ServiceView, error) {
|
||||
client, err := a.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.RPC.Execute(a.ctx, &sliverpb.ExecuteReq{
|
||||
Path: "powershell.exe",
|
||||
Args: []string{"-NoProfile", "-Command", "Get-Service | Select-Object Name,DisplayName,Status | ConvertTo-Csv -NoTypeInformation"},
|
||||
Output: true,
|
||||
resp, err := client.RPC.Services(a.ctx, &sliverpb.ServicesReq{
|
||||
Request: &commonpb.Request{SessionID: sessionID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []ServiceView{}
|
||||
lines := strings.Split(strings.ReplaceAll(string(resp.Stdout), "\r\n", "\n"), "\n")
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || i == 0 { // skip CSV header
|
||||
continue
|
||||
}
|
||||
cols := parseCSVLine(line)
|
||||
if len(cols) < 3 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ServiceView{Name: cols[0], DisplayName: cols[1], Status: cols[2]})
|
||||
if resp.Error != "" {
|
||||
return nil, fmt.Errorf("%s", resp.Error)
|
||||
}
|
||||
out := make([]ServiceView, 0, len(resp.Details))
|
||||
for _, d := range resp.Details {
|
||||
out = append(out, serviceView(d))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -2754,6 +2809,14 @@ func (a *App) buildImplantConfig(req GenerateRequest) *clientpb.ImplantConfig {
|
||||
Format: formatFromString(req.Format),
|
||||
C2: []*clientpb.ImplantC2{{URL: req.C2URL, Priority: 1}},
|
||||
ObfuscateSymbols: false,
|
||||
// MTLS is always compiled in as a baseline transport. The generated
|
||||
// implant's transports/session.go imports net/url, sync and sliverpb which
|
||||
// only the MTLS/HTTP transports use; an implant that ends up with none of
|
||||
// those (e.g. a TCP/named-pipe pivot, or any config where the scheme flag
|
||||
// doesn't survive to the teamserver) fails to compile with "exit status 1"
|
||||
// on those unused imports. Keeping MTLS on guarantees a buildable implant;
|
||||
// the C2 list drives what is actually dialed, so nothing extra connects.
|
||||
IncludeMTLS: true,
|
||||
}
|
||||
if req.Beacon {
|
||||
cfg.IsBeacon = true
|
||||
@@ -2776,10 +2839,18 @@ func (a *App) buildImplantConfig(req GenerateRequest) *clientpb.ImplantConfig {
|
||||
cfg.IncludeDNS = true
|
||||
case "wg", "wireguard":
|
||||
cfg.IncludeWG = true
|
||||
case "tcp":
|
||||
case "tcp", "tcp-pivot", "tcppivot":
|
||||
// TCP pivot implants (Sliver scheme "tcp-pivot://") ride the TCP transport.
|
||||
// Also compile in MTLS: a pivot-ONLY implant leaves the generated
|
||||
// transports/session.go with unused imports (net/url, sync, sliverpb) and
|
||||
// fails to build with "exit status 1". Including the MTLS transport keeps
|
||||
// those imports used; the C2 list still holds only the pivot URL, so
|
||||
// nothing else is ever dialed.
|
||||
cfg.IncludeTCP = true
|
||||
case "namedpipe":
|
||||
cfg.IncludeMTLS = true
|
||||
case "namedpipe", "named-pipe":
|
||||
cfg.IncludeNamePipe = true
|
||||
cfg.IncludeMTLS = true
|
||||
default:
|
||||
cfg.IncludeMTLS = true
|
||||
}
|
||||
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestRandSuffix(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 1000; i++ {
|
||||
s := randSuffix()
|
||||
if len(s) == 0 {
|
||||
t.Fatal("randSuffix returned empty string")
|
||||
}
|
||||
if seen[s] {
|
||||
t.Fatalf("randSuffix collision on %q within 1000 draws", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemeOf(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://1.2.3.4:8443": "https",
|
||||
"mtls://host:8888": "mtls",
|
||||
"dns://example.com": "dns",
|
||||
"1.2.3.4:8888": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := schemeOf(in); got != want {
|
||||
t.Errorf("schemeOf(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFromString(t *testing.T) {
|
||||
cases := map[string]clientpb.OutputFormat{
|
||||
"shared": clientpb.OutputFormat_SHARED_LIB,
|
||||
"service": clientpb.OutputFormat_SERVICE,
|
||||
"shellcode": clientpb.OutputFormat_SHELLCODE,
|
||||
"exe": clientpb.OutputFormat_EXECUTABLE,
|
||||
"": clientpb.OutputFormat_EXECUTABLE,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := formatFromString(in); got != want {
|
||||
t.Errorf("formatFromString(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{`a,b,c`, []string{"a", "b", "c"}},
|
||||
{`"a,b",c`, []string{"a,b", "c"}},
|
||||
{`"he said ""hi""",x`, []string{`he said "hi"`, "x"}},
|
||||
{``, []string{""}},
|
||||
{`,,`, []string{"", "", ""}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseCSVLine(c.in)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("parseCSVLine(%q) len = %d, want %d (%q)", c.in, len(got), len(c.want), got)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("parseCSVLine(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecuteResponse(t *testing.T) {
|
||||
// Empty -> placeholder.
|
||||
if out, _ := parseExecuteResponse(nil); out != "(no output)" {
|
||||
t.Errorf("empty response = %q, want (no output)", out)
|
||||
}
|
||||
// Valid protobuf round-trips stdout/stderr.
|
||||
data, err := proto.Marshal(&sliverpb.Execute{
|
||||
Stdout: []byte("hello"),
|
||||
Stderr: []byte("oops"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, errStr := parseExecuteResponse(data)
|
||||
if out != "hello" || errStr != "oops" {
|
||||
t.Errorf("parseExecuteResponse = (%q,%q), want (hello,oops)", out, errStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDownload(t *testing.T) {
|
||||
// Plain (no encoder) passes through.
|
||||
if got, err := decodeDownload(&sliverpb.Download{Data: []byte("raw")}); err != nil || string(got) != "raw" {
|
||||
t.Errorf("plain decode = (%q,%v), want (raw,nil)", got, err)
|
||||
}
|
||||
// gzip encoder is inflated.
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
zw.Write([]byte("compressed payload"))
|
||||
zw.Close()
|
||||
got, err := decodeDownload(&sliverpb.Download{Encoder: "gzip", Data: buf.Bytes()})
|
||||
if err != nil || string(got) != "compressed payload" {
|
||||
t.Errorf("gzip decode = (%q,%v), want (compressed payload,nil)", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Armory — Extension Package Manager ──────────────────────────────────────
|
||||
//
|
||||
// The Sliver "Armory" is a client-side package manager for extensions and
|
||||
// aliases. It fetches a JSON index from a GitHub-hosted repository listing
|
||||
// available packages with their release download URLs, then extracts .tar.gz
|
||||
// archives into ~/.sliver-client/extensions/ or ~/.sliver-client/aliases/.
|
||||
//
|
||||
// This implementation replicates the official sliver-client armory flow:
|
||||
// 1. Fetch armory index (JSON array of packages from GitHub API)
|
||||
// 2. Present available packages to the operator
|
||||
// 3. Download the matching release asset (.tar.gz)
|
||||
// 4. Extract to the correct local directory
|
||||
//
|
||||
// TODO: Add minisign signature verification for package integrity.
|
||||
// Currently skipped — packages are fetched over HTTPS from GitHub.
|
||||
|
||||
const (
|
||||
// Default armory index URL — the sliverarmory GitHub org's repos API
|
||||
defaultArmoryIndexURL = "https://api.github.com/orgs/sliverarmory/repos?per_page=100"
|
||||
// HTTP timeout for armory operations
|
||||
armoryHTTPTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// ArmoryPackage represents an installable extension/alias from the armory.
|
||||
type ArmoryPackage struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Stars int `json:"stars"`
|
||||
Type string `json:"type"` // "extension" or "alias"
|
||||
Installed bool `json:"installed"`
|
||||
}
|
||||
|
||||
// armoryGitHubRepo is the subset of GitHub API repo response we need.
|
||||
type armoryGitHubRepo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Stars int `json:"stargazers_count"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
// armoryGitHubRelease is a GitHub release.
|
||||
type armoryGitHubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []armoryGitHubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
// armoryGitHubAsset is a release asset.
|
||||
type armoryGitHubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// ArmoryList fetches the armory index and returns available packages.
|
||||
func (a *App) ArmoryList() ([]ArmoryPackage, error) {
|
||||
client := &http.Client{Timeout: armoryHTTPTimeout}
|
||||
|
||||
// Fetch repos from the sliverarmory GitHub org
|
||||
req, err := http.NewRequest("GET", defaultArmoryIndexURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("User-Agent", "sliver-gui/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch armory index: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("armory index returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var repos []armoryGitHubRepo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
|
||||
return nil, fmt.Errorf("parse armory index: %w", err)
|
||||
}
|
||||
|
||||
// Get list of already-installed extensions
|
||||
installed := installedExtensionNames()
|
||||
|
||||
packages := make([]ArmoryPackage, 0, len(repos))
|
||||
for _, r := range repos {
|
||||
if r.Archived {
|
||||
continue
|
||||
}
|
||||
// Skip non-extension repos (docs, templates, etc.)
|
||||
if r.Name == ".github" || r.Name == "armory" || strings.HasPrefix(r.Name, "template") {
|
||||
continue
|
||||
}
|
||||
pkg := ArmoryPackage{
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
URL: r.HTMLURL,
|
||||
Stars: r.Stars,
|
||||
Type: "extension",
|
||||
Installed: installed[r.Name],
|
||||
}
|
||||
// Heuristic: if name contains "alias" it's an alias package
|
||||
if strings.Contains(strings.ToLower(r.Name), "alias") {
|
||||
pkg.Type = "alias"
|
||||
}
|
||||
packages = append(packages, pkg)
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
// ArmoryInstall downloads and installs a package by name from the armory.
|
||||
func (a *App) ArmoryInstall(packageName string) error {
|
||||
if packageName == "" {
|
||||
return fmt.Errorf("package name is required")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
|
||||
// Step 1: Get the latest release for this package
|
||||
releaseURL := fmt.Sprintf("https://api.github.com/repos/sliverarmory/%s/releases/latest", packageName)
|
||||
req, err := http.NewRequest("GET", releaseURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create release request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("User-Agent", "sliver-gui/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch release: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return fmt.Errorf("package '%s' not found in armory (no releases)", packageName)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("GitHub API returned HTTP %d for %s", resp.StatusCode, packageName)
|
||||
}
|
||||
|
||||
var release armoryGitHubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return fmt.Errorf("parse release: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Find the matching asset for current OS/arch
|
||||
assetURL := findMatchingAsset(release.Assets, runtime.GOOS, runtime.GOARCH)
|
||||
if assetURL == "" {
|
||||
// Fallback: try to find any .tar.gz asset
|
||||
for _, asset := range release.Assets {
|
||||
if strings.HasSuffix(asset.Name, ".tar.gz") {
|
||||
assetURL = asset.BrowserDownloadURL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if assetURL == "" {
|
||||
return fmt.Errorf("no compatible asset found for %s/%s in %s release %s",
|
||||
runtime.GOOS, runtime.GOARCH, packageName, release.TagName)
|
||||
}
|
||||
|
||||
// Step 3: Download the asset
|
||||
dlReq, err := http.NewRequest("GET", assetURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
dlReq.Header.Set("User-Agent", "sliver-gui/1.0")
|
||||
|
||||
dlResp, err := client.Do(dlReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download asset: %w", err)
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
if dlResp.StatusCode != 200 {
|
||||
return fmt.Errorf("asset download returned HTTP %d", dlResp.StatusCode)
|
||||
}
|
||||
|
||||
// Step 4: Extract to the extensions directory
|
||||
extDir := armoryExtDir()
|
||||
installDir := filepath.Join(extDir, packageName)
|
||||
if err := os.MkdirAll(installDir, 0755); err != nil {
|
||||
return fmt.Errorf("create install dir: %w", err)
|
||||
}
|
||||
|
||||
if err := extractTarGz(dlResp.Body, installDir); err != nil {
|
||||
// Clean up on failure
|
||||
os.RemoveAll(installDir)
|
||||
return fmt.Errorf("extract package: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ArmoryRemove uninstalls a package by deleting its directory.
|
||||
func (a *App) ArmoryRemove(packageName string) error {
|
||||
if packageName == "" {
|
||||
return fmt.Errorf("package name is required")
|
||||
}
|
||||
installDir := filepath.Join(armoryExtDir(), packageName)
|
||||
if _, err := os.Stat(installDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("package '%s' is not installed", packageName)
|
||||
}
|
||||
return os.RemoveAll(installDir)
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// armoryExtDir returns the path to ~/.sliver-client/extensions/
|
||||
func armoryExtDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".sliver-client", "extensions")
|
||||
}
|
||||
|
||||
// armoryAliasDir returns the path to ~/.sliver-client/aliases/
|
||||
func armoryAliasDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".sliver-client", "aliases")
|
||||
}
|
||||
|
||||
// installedExtensionNames returns a set of already-installed extension names.
|
||||
func installedExtensionNames() map[string]bool {
|
||||
installed := make(map[string]bool)
|
||||
extDir := armoryExtDir()
|
||||
entries, err := os.ReadDir(extDir)
|
||||
if err != nil {
|
||||
return installed
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
installed[e.Name()] = true
|
||||
}
|
||||
}
|
||||
// Also check aliases
|
||||
aliasDir := armoryAliasDir()
|
||||
entries, err = os.ReadDir(aliasDir)
|
||||
if err != nil {
|
||||
return installed
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
installed[e.Name()] = true
|
||||
}
|
||||
}
|
||||
return installed
|
||||
}
|
||||
|
||||
// findMatchingAsset picks the best release asset for the given OS/arch.
|
||||
func findMatchingAsset(assets []armoryGitHubAsset, goos, goarch string) string {
|
||||
// Map Go os/arch names to common asset naming conventions
|
||||
osNames := map[string][]string{
|
||||
"linux": {"linux"},
|
||||
"windows": {"windows", "win"},
|
||||
"darwin": {"darwin", "macos", "osx"},
|
||||
}
|
||||
archNames := map[string][]string{
|
||||
"amd64": {"amd64", "x86_64", "x64"},
|
||||
"arm64": {"arm64", "aarch64"},
|
||||
"386": {"386", "i386", "x86"},
|
||||
}
|
||||
|
||||
osVariants := osNames[goos]
|
||||
archVariants := archNames[goarch]
|
||||
|
||||
for _, asset := range assets {
|
||||
name := strings.ToLower(asset.Name)
|
||||
if !strings.HasSuffix(name, ".tar.gz") && !strings.HasSuffix(name, ".tgz") {
|
||||
continue
|
||||
}
|
||||
osMatch := false
|
||||
for _, osv := range osVariants {
|
||||
if strings.Contains(name, osv) {
|
||||
osMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
archMatch := false
|
||||
for _, archv := range archVariants {
|
||||
if strings.Contains(name, archv) {
|
||||
archMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if osMatch && archMatch {
|
||||
return asset.BrowserDownloadURL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTarGz extracts a .tar.gz stream into destDir with zip-slip protection.
|
||||
func extractTarGz(r io.Reader, destDir string) error {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar read: %w", err)
|
||||
}
|
||||
|
||||
// Zip-slip protection: ensure the extracted path is within destDir
|
||||
target := filepath.Join(destDir, header.Name)
|
||||
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("zip-slip detected: %s", header.Name)
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", target, err)
|
||||
}
|
||||
case tar.TypeReg:
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return fmt.Errorf("mkdir parent %s: %w", target, err)
|
||||
}
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", target, err)
|
||||
}
|
||||
// Limit extraction size to 100MB per file to prevent resource exhaustion
|
||||
limited := io.LimitReader(tr, 100*1024*1024)
|
||||
if _, err := io.Copy(f, limited); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write %s: %w", target, err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// auditEntry is one line in the operator audit log (JSONL).
|
||||
type auditEntry struct {
|
||||
Time string `json:"time"` // RFC3339 UTC
|
||||
Operator string `json:"operator"` // who was connected
|
||||
Server string `json:"server"` // teamserver host:port
|
||||
Action string `json:"action"` // e.g. "connect", "generate", "command"
|
||||
Target string `json:"target"` // session/beacon id or host, when relevant
|
||||
Detail string `json:"detail"` // free-form (command line, filename, ...)
|
||||
}
|
||||
|
||||
// auditLogger appends operator actions to ~/.sliver-gui/audit.log.
|
||||
// Enterprise deployments need a durable record of who did what, when; the GUI
|
||||
// previously kept nothing. Best-effort: logging failures never block an action.
|
||||
type auditLogger struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
operator string
|
||||
server string
|
||||
}
|
||||
|
||||
func newAuditLogger() *auditLogger {
|
||||
dir := configDir()
|
||||
_ = os.MkdirAll(dir, 0o700)
|
||||
return &auditLogger{path: filepath.Join(dir, "audit.log")}
|
||||
}
|
||||
|
||||
// setIdentity records who/where subsequent entries belong to (set on connect).
|
||||
func (al *auditLogger) setIdentity(operator, server string) {
|
||||
al.mu.Lock()
|
||||
al.operator, al.server = operator, server
|
||||
al.mu.Unlock()
|
||||
}
|
||||
|
||||
// log appends one entry. Never returns an error (audit must not break ops).
|
||||
func (al *auditLogger) log(action, target, detail string) {
|
||||
if al == nil {
|
||||
return
|
||||
}
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
e := auditEntry{
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
Operator: al.operator,
|
||||
Server: al.server,
|
||||
Action: action,
|
||||
Target: target,
|
||||
Detail: detail,
|
||||
}
|
||||
f, err := os.OpenFile(al.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
b, _ := json.Marshal(e)
|
||||
f.Write(append(b, '\n'))
|
||||
}
|
||||
|
||||
// configDir returns the per-user GUI state directory (~/.sliver-gui),
|
||||
// falling back to the working dir if the home dir can't be resolved.
|
||||
func configDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".sliver-gui"
|
||||
}
|
||||
return filepath.Join(home, ".sliver-gui")
|
||||
}
|
||||
|
||||
// AuditLogPath is exposed to the frontend so operators can find their log.
|
||||
func (a *App) AuditLogPath() string {
|
||||
return filepath.Join(configDir(), "audit.log")
|
||||
}
|
||||
|
||||
// RecentAudit returns up to `limit` most-recent audit entries (newest last)
|
||||
// for display in the GUI. Bound to window.go.main.App.RecentAudit.
|
||||
func (a *App) RecentAudit(limit int) ([]auditEntry, error) {
|
||||
path := filepath.Join(configDir(), "audit.log")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []auditEntry{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var entries []auditEntry
|
||||
for _, line := range splitLines(data) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var e auditEntry
|
||||
if json.Unmarshal(line, &e) == nil {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
}
|
||||
if limit > 0 && len(entries) > limit {
|
||||
entries = entries[len(entries)-limit:]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func splitLines(b []byte) [][]byte {
|
||||
var out [][]byte
|
||||
start := 0
|
||||
for i, c := range b {
|
||||
if c == '\n' {
|
||||
out = append(out, b[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(b) {
|
||||
out = append(out, b[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Installs the taskbar/menu icon for Sliver GUI (Linux desktops that match
|
||||
# windows to .desktop files by WM_CLASS, e.g. XFCE). Run as the DESKTOP user
|
||||
# (not root), even though the app itself may be launched with sudo.
|
||||
set -e
|
||||
APP_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
mkdir -p "$HOME/.local/share/applications" "$HOME/.local/share/icons"
|
||||
cp "$APP_DIR/frontend/dist/icons/ICON.png" "$HOME/.local/share/icons/sliver-gui.png"
|
||||
sed "s#^Exec=.*#Exec=$APP_DIR/build/bin/sliver-gui#" "$APP_DIR/build/linux/sliver-gui.desktop" \
|
||||
| sed "s#^Icon=.*#Icon=$HOME/.local/share/icons/sliver-gui.png#" \
|
||||
> "$HOME/.local/share/applications/sliver-gui.desktop"
|
||||
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
|
||||
echo "Installed. Restart the panel (xfce4-panel -r) or re-launch the app."
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Sliver GUI
|
||||
Comment=Sliver C2 operator console
|
||||
Exec=/home/kali/Desktop/sliver-gui/v4/build/bin/sliver-gui
|
||||
Icon=/home/kali/.local/share/icons/sliver-gui.png
|
||||
Terminal=false
|
||||
StartupWMClass=Sliver-gui
|
||||
Categories=Utility;Security;
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPackArgs_MissingRequired(t *testing.T) {
|
||||
cmd := &extCommand{Arguments: []extArg{
|
||||
{Name: "target", Type: "string"},
|
||||
}}
|
||||
if _, err := packArgs(cmd, nil); err == nil {
|
||||
t.Fatal("expected error for missing required argument, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackArgs_OptionalSkipped(t *testing.T) {
|
||||
cmd := &extCommand{Arguments: []extArg{
|
||||
{Name: "opt", Type: "string", Optional: true},
|
||||
}}
|
||||
if _, err := packArgs(cmd, nil); err != nil {
|
||||
t.Fatalf("optional argument should be skippable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackArgs_IntValidation(t *testing.T) {
|
||||
cmd := &extCommand{Arguments: []extArg{
|
||||
{Name: "n", Type: "int"},
|
||||
}}
|
||||
if _, err := packArgs(cmd, []string{"notanumber"}); err == nil {
|
||||
t.Fatal("expected error for non-integer int arg, got nil")
|
||||
}
|
||||
if _, err := packArgs(cmd, []string{"42"}); err != nil {
|
||||
t.Fatalf("valid int arg failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackArgs_UnsupportedType(t *testing.T) {
|
||||
cmd := &extCommand{Arguments: []extArg{
|
||||
{Name: "x", Type: "bogus"},
|
||||
}}
|
||||
if _, err := packArgs(cmd, []string{"v"}); err == nil {
|
||||
t.Fatal("expected error for unsupported arg type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackArgs_StringOK(t *testing.T) {
|
||||
cmd := &extCommand{Arguments: []extArg{
|
||||
{Name: "s", Type: "string"},
|
||||
}}
|
||||
buf, err := packArgs(cmd, []string{"hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("string arg failed: %v", err)
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
t.Fatal("expected non-empty packed buffer")
|
||||
}
|
||||
}
|
||||
+1060
File diff suppressed because it is too large
Load Diff
Vendored
+3
@@ -12,9 +12,11 @@
|
||||
<div class="logo">SLIVER<span>GUI</span></div>
|
||||
<p class="subtitle">Connect via operator config</p>
|
||||
<button id="pick-config-btn" class="btn primary full">[+] Select operator .cfg</button>
|
||||
<input id="manual-cfg-path" placeholder="or paste path: /path/to/file.cfg" style="width:100%;margin-top:6px"/>
|
||||
<div id="config-path" class="config-path"></div>
|
||||
<button id="connect-btn" class="btn accent full" disabled>Connect</button>
|
||||
<div id="connect-error" class="error-msg"></div>
|
||||
<div id="gui-version" class="gui-version"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,6 +111,7 @@
|
||||
<p style="margin-top:8px;color:var(--muted)">Available views: Sessions, Beacons, Listeners, Generate, Event Log, Loot, Operators</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-watermark" id="panel-watermark">Made by Raj Kumar Mullapudi</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
Vendored
+534
-80
@@ -14,6 +14,34 @@ const notesMap = {}; // agent id -> operator notes (in-memory, cleared
|
||||
const integrityMap = {}; // agent id -> real integrity level from getprivs (System/High/Medium/Low)
|
||||
let activeInteractId = null; // currently focused agent tab
|
||||
|
||||
// ── Persistence ─────────────────────────────────────────────────────────────
|
||||
// Operator notes, measured integrity, and graph layout survive disconnects and
|
||||
// app restarts, scoped per teamserver, in localStorage. (Previously in-memory
|
||||
// only, so they were lost on every reconnect.)
|
||||
let persistKey = null; // set once we know which teamserver we're on
|
||||
let saveTimer = null;
|
||||
function persistScope(teamserver) { persistKey = 'sliver-gui:' + (teamserver || 'default'); }
|
||||
function saveState() {
|
||||
if (!persistKey) return;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(persistKey, JSON.stringify({
|
||||
notes: notesMap, integrity: integrityMap, graph: graphPos, v: 1,
|
||||
}));
|
||||
} catch (e) { /* quota/private-mode: state stays in-memory only */ }
|
||||
}, 300);
|
||||
}
|
||||
function loadState() {
|
||||
if (!persistKey) return;
|
||||
let data;
|
||||
try { data = JSON.parse(localStorage.getItem(persistKey) || '{}'); } catch (e) { return; }
|
||||
if (!data || typeof data !== 'object') return;
|
||||
Object.assign(notesMap, data.notes || {});
|
||||
Object.assign(integrityMap, data.integrity || {});
|
||||
Object.assign(graphPos, data.graph || {});
|
||||
}
|
||||
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────
|
||||
function esc(s) { return s == null ? '' : String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function toast(type, msg, dur=3000) {
|
||||
@@ -65,6 +93,7 @@ document.getElementById('pick-config-btn').addEventListener('click', async () =>
|
||||
const path = await App().PickConfigFile().catch(() => null);
|
||||
if (path) { selectedConfigPath = path; document.getElementById('config-path').textContent = path; document.getElementById('connect-btn').disabled = false; }
|
||||
});
|
||||
document.getElementById('manual-cfg-path').addEventListener('input', function() { var p = this.value.trim(); if (p) { selectedConfigPath = p; document.getElementById('config-path').textContent = p; document.getElementById('connect-btn').disabled = false; } });
|
||||
document.getElementById('connect-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('connect-btn');
|
||||
btn.disabled = true; btn.textContent = 'Connecting...';
|
||||
@@ -79,6 +108,8 @@ async function enterApp(info) {
|
||||
document.getElementById('app-shell').classList.remove('hidden');
|
||||
document.getElementById('operator-tag').textContent = `${info.operatorName}@${info.teamserver}`;
|
||||
teamserverLabel = info.teamserver || 'teamserver';
|
||||
persistScope(teamserverLabel);
|
||||
loadState();
|
||||
const ver = await App().GetVersion().catch(() => null);
|
||||
if (ver) document.getElementById('server-version').textContent = `v${ver.major}.${ver.minor}.${ver.patch}`;
|
||||
wireEventStream();
|
||||
@@ -145,6 +176,7 @@ function renderEventsList() {
|
||||
async function refreshAgents() {
|
||||
allSessions = await App().ListSessions().catch(() => []) || [];
|
||||
allBeacons = await App().ListBeacons().catch(() => []) || [];
|
||||
pivotTree = await App().GetPivotGraph().catch(() => []) || [];
|
||||
document.getElementById('agent-count').textContent = `${allSessions.length} sessions | ${allBeacons.length} beacons`;
|
||||
renderTable();
|
||||
if (!document.getElementById('graph-view').classList.contains('hidden')) renderGraph();
|
||||
@@ -181,91 +213,172 @@ document.getElementById('view-graph-btn').addEventListener('click', () => { docu
|
||||
document.getElementById('graph-reset-btn').addEventListener('click', resetGraph);
|
||||
|
||||
// ── Graph view (premium Cobalt-Strike style) ────────────────────────────────
|
||||
let pivotTree = [], graphEdges = [], graphDrag = {};
|
||||
|
||||
// buildPivotMaps flattens Sliver's pivot tree into child-session-id -> parent-
|
||||
// session-id. Keying on session id (unique) instead of hostname means pivot chains
|
||||
// on the SAME host (e.g. several runas'd sessions on one box) resolve correctly.
|
||||
// A relay node without a session passes its own parent down to its children.
|
||||
function buildPivotMaps() {
|
||||
const parentById = {};
|
||||
const walk = (nd, parentId) => {
|
||||
const id = nd.sessionId || null;
|
||||
if (parentId && id) parentById[id] = parentId;
|
||||
(nd.children || []).forEach(c => walk(c, id || parentId));
|
||||
};
|
||||
(pivotTree || []).forEach(r => walk(r, null));
|
||||
return { parentById };
|
||||
}
|
||||
|
||||
// edgeD builds a straight edge path shortened at both ends so the arrowhead sits
|
||||
// on the target node's edge (not under its icon). srcR/tgtR are the radii to inset.
|
||||
function edgeD(src, tgt, srcR, tgtR) {
|
||||
const dx = tgt.x - src.x, dy = tgt.y - src.y, len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const ax = src.x + ux * srcR, ay = src.y + uy * srcR;
|
||||
const bx = tgt.x - ux * tgtR, by = tgt.y - uy * tgtR;
|
||||
return `M${ax.toFixed(1)} ${ay.toFixed(1)} L${bx.toFixed(1)} ${by.toFixed(1)}`;
|
||||
}
|
||||
// edgeColor picks the line colour from the agent it points at: blue=beacon,
|
||||
// red=privileged session, green=normal session.
|
||||
function edgeColor(nd) {
|
||||
if (!nd) return '#35c46b';
|
||||
if (nd.kind === 'beacon') return '#4d9fe6';
|
||||
return isPrivileged(nd.obj) ? '#e23c4e' : '#35c46b';
|
||||
}
|
||||
|
||||
// renderGraph draws the pivot topology: a firewall egress boundary on the left,
|
||||
// green dashed edges to direct (egress) agents and orange edges down each pivot
|
||||
// chain (parent -> child), laid out left-to-right. Wrapped in try/catch so a data
|
||||
// hiccup can never blank the view.
|
||||
function renderGraph() {
|
||||
const svg = document.getElementById('graph-svg');
|
||||
if (!svg) return;
|
||||
const nodes = [
|
||||
...allSessions.map(s => ({ kind:'session', obj:s })),
|
||||
...allBeacons.map(b => ({ kind:'beacon', obj:b })),
|
||||
];
|
||||
const W = svg.clientWidth || 900, H = svg.clientHeight || 460;
|
||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
if (!graphCenter) graphCenter = { x: W/2, y: H/2 };
|
||||
const cx = graphCenter.x, cy = graphCenter.y, baseR = Math.min(W, H)/2 - 78;
|
||||
try {
|
||||
const nodes = [
|
||||
...allSessions.map(s => ({ kind:'session', obj:s })),
|
||||
...allBeacons.map(b => ({ kind:'beacon', obj:b })),
|
||||
];
|
||||
const W = svg.clientWidth || 900, H = svg.clientHeight || 460;
|
||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
const fwX = 74, fwY = H / 2;
|
||||
|
||||
// Assign a persistent position to each node (ring layout for new ones).
|
||||
const n = nodes.length;
|
||||
nodes.forEach((nd, i) => {
|
||||
if (!graphPos[nd.obj.id]) {
|
||||
const ring = n > 9 && i%2 ? 0.62 : 1, r = baseR * ring;
|
||||
const a = (i/Math.max(n,1))*2*Math.PI - Math.PI/2;
|
||||
graphPos[nd.obj.id] = { x: cx + r*Math.cos(a), y: cy + r*Math.sin(a) };
|
||||
// Resolve each node's pivot parent by SESSION ID (unique), so chains on the
|
||||
// same host render as a real chain instead of collapsing on the hostname.
|
||||
const nodeById = {}; nodes.forEach(nd => nodeById[nd.obj.id] = nd);
|
||||
const { parentById } = buildPivotMaps();
|
||||
const parentNodeId = {}, childIds = {};
|
||||
nodes.forEach(nd => {
|
||||
const pid = parentById[nd.obj.id];
|
||||
if (pid && nodeById[pid] && pid !== nd.obj.id) {
|
||||
parentNodeId[nd.obj.id] = pid;
|
||||
(childIds[pid] = childIds[pid] || []).push(nd.obj.id);
|
||||
}
|
||||
});
|
||||
const roots = nodes.filter(nd => !parentNodeId[nd.obj.id]).map(nd => nd.obj.id);
|
||||
|
||||
// Hierarchical left-to-right layout: depth = column, each leaf gets its own row.
|
||||
const layout = {}, colW = 192, rowH = 96;
|
||||
let leaf = 0;
|
||||
const place = (id, depth, seen) => {
|
||||
if (seen[id]) return layout[id] ? layout[id].y : (60 + leaf * rowH);
|
||||
seen[id] = 1;
|
||||
const kids = (childIds[id] || []).filter(k => !seen[k]);
|
||||
const x = fwX + depth * colW;
|
||||
let y;
|
||||
if (!kids.length) { y = 60 + leaf * rowH; leaf++; }
|
||||
else { const ys = kids.map(k => place(k, depth + 1, seen)); y = ys.reduce((a,b)=>a+b,0)/ys.length; }
|
||||
layout[id] = { x, y };
|
||||
return y;
|
||||
};
|
||||
const seen = {};
|
||||
roots.forEach(r => place(r, 1, seen));
|
||||
const yvals = Object.values(layout).map(p => p.y);
|
||||
if (yvals.length) {
|
||||
const mid = (Math.min(...yvals) + Math.max(...yvals)) / 2, shift = H/2 - mid;
|
||||
Object.values(layout).forEach(p => p.y += shift);
|
||||
}
|
||||
});
|
||||
svg._layout = layout; svg._fwX = fwX; svg._fwY = fwY;
|
||||
const posOf = id => graphDrag[id] || layout[id] || { x: fwX + colW, y: H/2 };
|
||||
const NODE_R = 30, FW_R = 34;
|
||||
|
||||
// Straight edges (do not bend).
|
||||
const edgePath = (x1,y1,x2,y2) => `M${x1} ${y1} L${x2} ${y2}`;
|
||||
// Edge list: firewall -> root (egress), parent -> child (pivot).
|
||||
graphEdges = [];
|
||||
roots.forEach(id => graphEdges.push({ from:'__fw__', to:id }));
|
||||
nodes.forEach(nd => { const p = parentNodeId[nd.obj.id]; if (p) graphEdges.push({ from:p, to:nd.obj.id }); });
|
||||
|
||||
let html = `<g id="g-root" transform="translate(${graphView.tx},${graphView.ty}) scale(${graphView.scale})">`;
|
||||
const fwPos = { x: fwX, y: fwY };
|
||||
let html = '<defs>' +
|
||||
'<marker id="ar-green" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10 z" fill="#35c46b"/></marker>' +
|
||||
'<marker id="ar-red" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10 z" fill="#e23c4e"/></marker>' +
|
||||
'<marker id="ar-blue" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10 z" fill="#4d9fe6"/></marker>' +
|
||||
'</defs>';
|
||||
html += `<g id="g-root" transform="translate(${graphView.tx},${graphView.ty}) scale(${graphView.scale})">`;
|
||||
|
||||
// Edges (straight; dashed for beacons; red for privileged agents)
|
||||
nodes.forEach(nd => {
|
||||
const p = graphPos[nd.obj.id], dead = nd.obj.isDead, priv = isPrivileged(nd.obj);
|
||||
const cls = `gedge${nd.kind==='beacon'?' beacon':''}${priv&&!dead?' priv':''}${dead?' dead':''}`;
|
||||
html += `<path id="ge-${nd.obj.id}" d="${edgePath(cx,cy,p.x,p.y)}" class="${cls}" fill="none"/>`;
|
||||
});
|
||||
// Solid edges, coloured by the agent they point at, arrowhead at the node edge.
|
||||
graphEdges.forEach(ed => {
|
||||
const src = ed.from === '__fw__' ? fwPos : posOf(ed.from), tgt = posOf(ed.to);
|
||||
const col = edgeColor(nodeById[ed.to]);
|
||||
const mk = col === '#4d9fe6' ? 'ar-blue' : (col === '#e23c4e' ? 'ar-red' : 'ar-green');
|
||||
const eid = `ge-${ed.from}-${ed.to}`;
|
||||
html += `<path id="${eid}" d="${edgeD(src, tgt, ed.from === '__fw__' ? FW_R : NODE_R, NODE_R + 3)}" stroke="${col}" stroke-width="2.4" fill="none" marker-end="url(#${mk})"/>`;
|
||||
});
|
||||
|
||||
// Teamserver core — C2 icon
|
||||
html += `<image href="./icons/C2.png" x="${cx-30}" y="${cy-30}" width="60" height="60" pointer-events="none"/>`;
|
||||
html += `<text x="${cx}" y="${cy+48}" text-anchor="middle" fill="var(--accent)" font-size="10" font-weight="bold" font-family="var(--font)" pointer-events="none">${esc(teamserverLabel)}</text>`;
|
||||
// Firewall (egress boundary): brick wall + flame.
|
||||
html += `<g pointer-events="none" transform="translate(${fwX},${fwY})">`;
|
||||
const bw = 15, bh = 9;
|
||||
for (let r = 0; r < 5; r++) { const oy = -22 + r*bh, off = (r%2) ? bw/2 : 0; for (let c = -1; c < 2; c++) html += `<rect x="${c*bw+off-7}" y="${oy}" width="${bw-1.5}" height="${bh-1.5}" fill="#9a3b2e" stroke="#5f231b" stroke-width="0.8"/>`; }
|
||||
html += '<text x="-19" y="7" font-size="30" text-anchor="middle">🔥</text>';
|
||||
html += '<text y="42" text-anchor="middle" fill="var(--muted)" font-size="9" font-family="var(--mono)">egress</text>';
|
||||
html += '</g>';
|
||||
|
||||
// Agent nodes — icon from the icons folder; grey filter when dead.
|
||||
nodes.forEach(nd => {
|
||||
const o = nd.obj, p = graphPos[o.id];
|
||||
const dead = o.isDead, priv = isPrivileged(o);
|
||||
const labelColor = dead ? 'var(--muted)' : 'var(--text)';
|
||||
html += `<g class="gnode${dead?' dead':''}" data-id="${esc(o.id)}" transform="translate(${p.x},${p.y})" style="cursor:grab">`;
|
||||
// Transparent hit area so the group receives drag/dblclick (images below are inert).
|
||||
html += `<rect x="-28" y="-32" width="56" height="86" fill="transparent"/>`;
|
||||
html += `<image href="${osIconHref(o.os, priv, dead)}" x="-26" y="-26" width="52" height="52" pointer-events="none"/>`;
|
||||
html += `<text y="38" text-anchor="middle" fill="${labelColor}" font-size="10" font-weight="bold" font-family="var(--font)" pointer-events="none">${esc(o.hostname||o.id.slice(0,6))}</text>`;
|
||||
html += `<text y="50" text-anchor="middle" fill="var(--muted)" font-size="8.5" font-family="var(--mono)" pointer-events="none">${esc(shortUser(o.username))} · ${nd.kind}</text>`;
|
||||
if (priv && !dead) html += `<text y="-30" text-anchor="middle" fill="var(--accent)" font-size="8" font-weight="bold" font-family="var(--mono)" pointer-events="none">★ ${integrityLabel(o) || 'PRIV'}</text>`;
|
||||
if (dead) html += `<text y="-30" text-anchor="middle" fill="var(--muted)" font-size="8" font-weight="bold" font-family="var(--mono)" pointer-events="none">DEAD</text>`;
|
||||
html += `</g>`;
|
||||
});
|
||||
// Agent nodes: icon (blue=user, red=privileged), lightning bolts when privileged.
|
||||
nodes.forEach(nd => {
|
||||
const o = nd.obj, p = posOf(o.id), dead = o.isDead, priv = isPrivileged(o);
|
||||
html += `<g class="gnode${dead?' dead':''}" data-id="${esc(o.id)}" transform="translate(${p.x},${p.y})" style="cursor:grab">`;
|
||||
html += '<rect x="-30" y="-34" width="60" height="94" fill="transparent"/>';
|
||||
if (o.id === activeInteractId) html += '<rect x="-33" y="-33" width="66" height="64" rx="4" fill="none" stroke="#35c46b" stroke-width="1.5" stroke-dasharray="5 4"/>';
|
||||
html += `<image href="${osIconHref(o.os, priv, dead)}" x="-26" y="-26" width="52" height="52" pointer-events="none"/>`;
|
||||
const user = shortUser(o.username) + (priv && !dead ? ' *' : '');
|
||||
const l2 = `${o.hostname || o.id.slice(0,6)}${o.pid ? ' @ ' + o.pid : ''}`;
|
||||
const lc = dead ? 'var(--muted)' : (priv ? 'var(--accent)' : 'var(--text)');
|
||||
html += `<text y="40" text-anchor="middle" fill="${lc}" font-size="10" font-weight="bold" font-family="var(--font)" pointer-events="none">${esc(user)}</text>`;
|
||||
html += `<text y="51" text-anchor="middle" fill="var(--muted)" font-size="8.5" font-family="var(--mono)" pointer-events="none">${esc(l2)}</text>`;
|
||||
if (dead) html += '<text y="-30" text-anchor="middle" fill="var(--muted)" font-size="8" font-weight="bold" font-family="var(--mono)" pointer-events="none">DEAD</text>';
|
||||
html += '</g>';
|
||||
});
|
||||
|
||||
html += `</g>`;
|
||||
svg.innerHTML = html;
|
||||
html += '</g>';
|
||||
svg.innerHTML = html;
|
||||
|
||||
// dblclick on a live node opens its console.
|
||||
const byId = {}; nodes.forEach(nd => byId[nd.obj.id] = nd);
|
||||
svg.querySelectorAll('.gnode').forEach(el => {
|
||||
const nd = byId[el.dataset.id];
|
||||
if (nd && !nd.obj.isDead) el.addEventListener('dblclick', () => openInteract(nd.kind, nd.obj));
|
||||
});
|
||||
|
||||
setupGraphInteraction(svg, cx, cy, edgePath);
|
||||
const byId = {}; nodes.forEach(nd => byId[nd.obj.id] = nd);
|
||||
svg.querySelectorAll('.gnode').forEach(el => {
|
||||
const nd = byId[el.dataset.id];
|
||||
if (nd && !nd.obj.isDead) el.addEventListener('dblclick', () => openInteract(nd.kind, nd.obj));
|
||||
});
|
||||
setupGraphInteraction(svg);
|
||||
} catch (err) { console.error('renderGraph failed:', err); }
|
||||
}
|
||||
|
||||
// setupGraphInteraction wires drag (nodes), pan (background) and wheel zoom.
|
||||
// Attached once per svg element; survives innerHTML re-renders.
|
||||
function setupGraphInteraction(svg, cx, cy, edgePath) {
|
||||
if (svg._wired) { svg._cx = cx; svg._cy = cy; svg._edgePath = edgePath; return; }
|
||||
svg._wired = true; svg._cx = cx; svg._cy = cy; svg._edgePath = edgePath;
|
||||
// setupGraphInteraction wires node drag, canvas pan and wheel zoom. Attached once
|
||||
// per svg element; reads live layout/firewall coords off the svg each render.
|
||||
function setupGraphInteraction(svg) {
|
||||
if (svg._wired) return;
|
||||
svg._wired = true;
|
||||
let mode = null, dragId = null, startX = 0, startY = 0, origX = 0, origY = 0;
|
||||
|
||||
const applyView = () => {
|
||||
const root = document.getElementById('g-root');
|
||||
if (root) root.setAttribute('transform', `translate(${graphView.tx},${graphView.ty}) scale(${graphView.scale})`);
|
||||
};
|
||||
const posOf = id => graphDrag[id] || (svg._layout && svg._layout[id]) || { x: (svg._fwX||74) + 192, y: svg._fwY || 0 };
|
||||
|
||||
svg.addEventListener('mousedown', e => {
|
||||
const nodeEl = e.target.closest('.gnode');
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
if (nodeEl) {
|
||||
mode = 'node'; dragId = nodeEl.dataset.id;
|
||||
origX = graphPos[dragId].x; origY = graphPos[dragId].y;
|
||||
const pp = posOf(dragId); origX = pp.x; origY = pp.y;
|
||||
nodeEl.style.cursor = 'grabbing';
|
||||
} else {
|
||||
mode = 'pan'; origX = graphView.tx; origY = graphView.ty;
|
||||
@@ -279,11 +392,15 @@ function setupGraphInteraction(svg, cx, cy, edgePath) {
|
||||
if (mode === 'node') {
|
||||
const dx = (e.clientX - startX)/graphView.scale, dy = (e.clientY - startY)/graphView.scale;
|
||||
const nx = origX + dx, ny = origY + dy;
|
||||
graphPos[dragId] = { x: nx, y: ny };
|
||||
graphDrag[dragId] = { x: nx, y: ny };
|
||||
const g = svg.querySelector(`.gnode[data-id="${CSS.escape(dragId)}"]`);
|
||||
if (g) g.setAttribute('transform', `translate(${nx},${ny})`);
|
||||
const edge = document.getElementById(`ge-${dragId}`);
|
||||
if (edge) edge.setAttribute('d', svg._edgePath(svg._cx, svg._cy, nx, ny));
|
||||
(graphEdges || []).forEach(ed => {
|
||||
if (ed.from !== dragId && ed.to !== dragId) return;
|
||||
const src = ed.from === '__fw__' ? { x: svg._fwX, y: svg._fwY } : posOf(ed.from), tgt = posOf(ed.to);
|
||||
const el = document.getElementById(`ge-${ed.from}-${ed.to}`);
|
||||
if (el) el.setAttribute('d', edgeD(src, tgt, ed.from === '__fw__' ? 34 : 30, 33));
|
||||
});
|
||||
} else if (mode === 'pan') {
|
||||
graphView.tx = origX + (e.clientX - startX);
|
||||
graphView.ty = origY + (e.clientY - startY);
|
||||
@@ -303,7 +420,6 @@ function setupGraphInteraction(svg, cx, cy, edgePath) {
|
||||
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1/1.12;
|
||||
const ns = Math.min(3, Math.max(0.3, graphView.scale * factor));
|
||||
// Keep the point under the cursor fixed while zooming.
|
||||
graphView.tx = mx - (mx - graphView.tx) * (ns/graphView.scale);
|
||||
graphView.ty = my - (my - graphView.ty) * (ns/graphView.scale);
|
||||
graphView.scale = ns;
|
||||
@@ -314,6 +430,7 @@ function setupGraphInteraction(svg, cx, cy, edgePath) {
|
||||
// Reset the graph layout/view to its default.
|
||||
function resetGraph() {
|
||||
for (const k in graphPos) delete graphPos[k];
|
||||
for (const k in graphDrag) delete graphDrag[k];
|
||||
graphView = { tx: 0, ty: 0, scale: 1 };
|
||||
graphCenter = null;
|
||||
renderGraph();
|
||||
@@ -338,6 +455,7 @@ document.getElementById('ctx-integrity').addEventListener('click', async () => {
|
||||
const r = await App().GetPrivs(obj.id).catch(() => null);
|
||||
if (!r || !r.integrity) return toast('err', 'getprivs failed (needs a live Windows session)');
|
||||
integrityMap[obj.id] = r.integrity;
|
||||
saveState();
|
||||
const label = integrityLabel(obj) || r.integrity;
|
||||
toast(isPrivileged(obj) ? 'ok' : 'info', `${obj.hostname}: ${label} integrity`);
|
||||
renderTable();
|
||||
@@ -403,6 +521,12 @@ function activateTab(id) {
|
||||
document.getElementById(`cinp-${id}`)?.focus();
|
||||
}
|
||||
function closeTab(id) {
|
||||
const t = openTabs[id];
|
||||
// Tear down an interactive shell's tunnel + event listeners on close.
|
||||
if (t && t.kind === 'shell') {
|
||||
App().StopInteractiveShell(t.tid).catch(()=>{});
|
||||
if (window.runtime) { window.runtime.EventsOff(t.evtOut); window.runtime.EventsOff(t.evtClose); }
|
||||
}
|
||||
delete openTabs[id];
|
||||
document.querySelector(`.interact-tab[data-tid="${id}"]`)?.remove();
|
||||
document.getElementById(`ip-${id}`)?.remove();
|
||||
@@ -417,7 +541,12 @@ Core commands (session & beacon):
|
||||
info Show agent info
|
||||
clear Clear the console
|
||||
shell <cmd> Run a command in the OS shell (raw text works too)
|
||||
shell -i (or pty) Open a real-time interactive shell (session only)
|
||||
execute <path> [args] Run a program directly (no shell wrapper)
|
||||
grep [-r] <pattern> <path> Search file contents on the target
|
||||
mount List mounted drives/filesystems
|
||||
memfiles [list|add|rm <fd>] Anonymous in-memory files
|
||||
ssh <user>@<host>[:port] [-p <pass>] <cmd> Run a command over SSH from the implant
|
||||
ps List processes
|
||||
ls [path] List files (uses current dir)
|
||||
cd <path> Change working directory
|
||||
@@ -435,7 +564,7 @@ Core commands (session & beacon):
|
||||
env / getenv <name> Environment variables
|
||||
setenv <K> <V> Set an env var
|
||||
unsetenv <K> Unset an env var
|
||||
reg query|read|write ... Windows registry (HKLM/HKCU/...)
|
||||
reg query|read|write|read-hive ... Windows registry (HKLM/HKCU/...)
|
||||
whoami Current token owner
|
||||
getprivs Token privileges (Windows)
|
||||
procdump <pid> Dump process memory
|
||||
@@ -447,10 +576,10 @@ Core commands (session & beacon):
|
||||
|
||||
Privilege / execution (session only):
|
||||
getsystem <profile> [proc] Escalate to SYSTEM via an implant profile
|
||||
make-token <dom> <u> <p> Create a token from credentials
|
||||
impersonate <user> Impersonate a logged-on user
|
||||
rev2self Drop an impersonated token
|
||||
runas -u <u> [-p <p>] <prog> [args] Run a program as another user
|
||||
make-token <dom> <u> <p> Network-cred token (like runas /netonly) for remote/SMB auth; whoami is unchanged and the password is NOT verified here
|
||||
impersonate <user> Steal a token from that user's already-running process (needs them logged on + high integrity; ignores password) - do NOT run after make-token
|
||||
rev2self Drop the make-token / impersonate token
|
||||
runas -u <u> [-p <p>] <prog> [args] Launch a program under other creds on THIS host
|
||||
migrate <pid> <profile> Migrate the implant into another process
|
||||
execute-assembly <local.exe> [args] Run a .NET assembly (path or dialog)
|
||||
execute-shellcode <local.bin> [pid] Inject shellcode (path or dialog)
|
||||
@@ -458,6 +587,11 @@ Privilege / execution (session only):
|
||||
spawndll <local.dll> [args] Reflectively load a DLL (path or dialog)
|
||||
extensions List installed + loaded extensions/BOFs
|
||||
ext <command> [args...] Run an extension/BOF (e.g. ext sa-whoami)
|
||||
wasm [list|register <f> [name]|exec <name> [args]] WASM extensions
|
||||
execute-windows [-t][-H][--ppid <pid>] <path> [args] Execute w/ token/PPID spoof (Windows)
|
||||
ping Round-trip liveness check (session)
|
||||
wg-forwarders / wg-list-socks List active WireGuard forwarders / socks
|
||||
close Gracefully close this session
|
||||
backdoor <remote_pe> <profile> Backdoor an on-disk PE with an implant
|
||||
dllhijack <ref_dll> <target> <profile> Plant a hijacking DLL
|
||||
msf <payload> <lhost> <lport> Run a Metasploit payload in-process
|
||||
@@ -470,7 +604,7 @@ Pivoting / tunneling (session only):
|
||||
wg-portfwd add <lport> <rhost:port> | rm <id> (WireGuard implants)
|
||||
wg-socks <port> | stop <id> (WireGuard implants)
|
||||
pivot start tcp|pipe <bind> | stop <id> | list
|
||||
services List Windows services
|
||||
services [detail <name>|start <name>] Windows services (list / inspect / start)
|
||||
|
||||
Beacon only:
|
||||
tasks Show the beacon task queue
|
||||
@@ -512,7 +646,10 @@ async function dispatchCmd(kind, id, raw) {
|
||||
if (cmd === 'clear') { const o = document.getElementById(`cout-${id}`); if (o) o.innerHTML = ''; return; }
|
||||
if (cmd === 'info') { const o = tab.obj; return appendOut(id, `ID: ${o.id}\nHost: ${o.hostname}\nUser: ${o.username}\nOS: ${o.os}/${o.arch}\nPID: ${o.pid}\nTransport: ${o.transport}\nRemote: ${o.remoteAddress}`, 'info'); }
|
||||
if (cmd === 'tasks' && kind === 'beacon') return showTasks(id);
|
||||
if (cmd === 'shell' && !args.length) return appendOut(id, "usage: shell <command> — the GUI has no interactive PTY; pass the command inline, e.g. shell whoami", 'info');
|
||||
if ((cmd === 'shell' && (!args.length || args[0] === '-i')) || cmd === 'pty') {
|
||||
if (kind !== 'session') return appendOut(id, '[!] interactive shell requires a session (beacons are async). For a beacon use: shell <command>', 'err');
|
||||
return openInteractiveShell(id, tab.obj);
|
||||
}
|
||||
|
||||
// ── beacons: queue command, then poll for the result (non-blocking) ──
|
||||
if (kind === 'beacon') {
|
||||
@@ -665,11 +802,95 @@ async function dispatchCmd(kind, id, raw) {
|
||||
return appendOut(id, pv.length ? pv.map(p => `#${p.id} ${p.type} ${p.bindAddress}`).join('\n') : '[*] no pivot listeners', 'info');
|
||||
}
|
||||
case 'services': {
|
||||
const sub = (args[0]||'').toLowerCase();
|
||||
if (sub === 'detail' || sub === 'info') {
|
||||
if (!args[1]) return appendOut(id, 'usage: services detail <name>', 'err');
|
||||
const d = await App().ServiceDetail(id, '', args[1]);
|
||||
return appendOut(id, `Name: ${d.name}\nDisplay: ${d.displayName}\nStatus: ${d.status}\nStartup: ${d.startupType}\nAccount: ${d.account}\nBinPath: ${d.binPath}\nDescription: ${d.description}`, 'out');
|
||||
}
|
||||
if (sub === 'start') {
|
||||
if (!args[1]) return appendOut(id, 'usage: services start <name>', 'err');
|
||||
await App().StartServiceByName(id, '', args[1]);
|
||||
return appendOut(id, `[+] service ${args[1]} start requested`, 'out');
|
||||
}
|
||||
const svcs = await App().ListServices(id);
|
||||
let out = 'STATUS NAME DISPLAY\n';
|
||||
svcs.forEach(s => { out += `${(s.status||'').padEnd(12)}${(s.name||'').slice(0,30).padEnd(31)}${s.displayName||''}\n`; });
|
||||
return appendOut(id, out.trimEnd(), 'out');
|
||||
}
|
||||
case 'grep': {
|
||||
if (args.length < 2) return appendOut(id, 'usage: grep [-r] <pattern> <path>', 'err');
|
||||
const recursive = args[0] === '-r';
|
||||
const a2 = recursive ? args.slice(1) : args;
|
||||
const pattern = a2[0], path = a2.slice(1).join(' ');
|
||||
const res = await App().GrepFiles(id, pattern, path, recursive);
|
||||
return appendOut(id, res, 'out');
|
||||
}
|
||||
case 'mount': {
|
||||
const mounts = await App().ListMounts(id);
|
||||
if (!mounts.length) return appendOut(id, '[*] no mounts', 'info');
|
||||
let out = 'MOUNT FS TYPE LABEL FREE/TOTAL\n';
|
||||
const gb = n => (n/1073741824).toFixed(1)+'G';
|
||||
mounts.forEach(m => { out += `${(m.mountPoint||'').slice(0,14).padEnd(16)}${(m.fileSystem||'').padEnd(11)}${(m.type||'').padEnd(11)}${(m.label||'').slice(0,14).padEnd(16)}${m.totalSpace?`${gb(m.freeSpace)}/${gb(m.totalSpace)}`:''}\n`; });
|
||||
return appendOut(id, out.trimEnd(), 'out');
|
||||
}
|
||||
case 'memfiles': {
|
||||
const sub = (args[0]||'list').toLowerCase();
|
||||
if (sub === 'add') { const fd = await App().MemfilesAdd(id); return appendOut(id, `[+] created memfile fd=${fd}`, 'out'); }
|
||||
if (sub === 'rm') { if (!args[1]) return appendOut(id, 'usage: memfiles rm <fd>', 'err'); await App().MemfilesRm(id, parseInt(args[1])); return appendOut(id, `[+] removed fd=${args[1]}`, 'out'); }
|
||||
const res = await App().MemfilesList(id);
|
||||
return appendOut(id, res, 'out');
|
||||
}
|
||||
case 'ssh': {
|
||||
if (args.length < 2) return appendOut(id, 'usage: ssh <user>@<host>[:port] <command...> (prompts nothing; add password with -p <pass>)', 'err');
|
||||
let pass = '';
|
||||
const pi = args.indexOf('-p');
|
||||
if (pi >= 0) { pass = args[pi+1]||''; args.splice(pi, 2); }
|
||||
const target = args[0]; const cmd = args.slice(1).join(' ');
|
||||
const at = target.split('@'); if (at.length < 2) return appendOut(id, 'usage: ssh <user>@<host>[:port] <command...>', 'err');
|
||||
const user = at[0]; const hp = at[1].split(':'); const host = hp[0]; const port = parseInt(hp[1]||'22');
|
||||
const r = await App().SSHExec(id, host, port, user, pass, cmd);
|
||||
if (r.error) return appendOut(id, `[error] ${r.error}`, 'err');
|
||||
if (r.stdout) appendOut(id, r.stdout.trimEnd(), 'out');
|
||||
if (r.stderr) appendOut(id, r.stderr.trimEnd(), 'err');
|
||||
return;
|
||||
}
|
||||
case 'wasm': {
|
||||
const sub = (args[0]||'list').toLowerCase();
|
||||
if (sub === 'register') { if (!args[1]) return appendOut(id, 'usage: wasm register <local.wasm> [name]', 'err'); await App().RegisterWasmExtension(id, args[2]||'', args[1]); return appendOut(id, `[+] registered WASM extension`, 'out'); }
|
||||
if (sub === 'exec') { if (!args[1]) return appendOut(id, 'usage: wasm exec <name> [args...]', 'err'); const r = await App().ExecWasmExtension(id, args[1], args.slice(2)); if (r.error) return appendOut(id, `[error] ${r.error}`, 'err'); if (r.stdout) appendOut(id, r.stdout.trimEnd(), 'out'); if (r.stderr) appendOut(id, r.stderr.trimEnd(), 'err'); return; }
|
||||
const names = await App().ListWasmExtensions(id);
|
||||
return appendOut(id, names.length ? names.join('\n') : '[*] no WASM extensions registered', names.length?'out':'info');
|
||||
}
|
||||
case 'ping': {
|
||||
const t0 = Date.now();
|
||||
try { await App().PingSession(id); return appendOut(id, `[+] pong (${Date.now()-t0}ms)`, 'out'); }
|
||||
catch (e) { return appendOut(id, `[error] ${e}`, 'err'); }
|
||||
}
|
||||
case 'execute-windows': case 'execw': {
|
||||
const useToken = args.includes('-t'), hide = args.includes('-H');
|
||||
let ppid = 0; const pi = args.indexOf('--ppid'); if (pi >= 0) { ppid = parseInt(args[pi+1])||0; }
|
||||
const rest = args.filter((a,idx) => !a.startsWith('-') && !(pi>=0 && idx===pi+1));
|
||||
if (!rest.length) return appendOut(id, 'usage: execute-windows [-t] [-H] [--ppid <pid>] <path> [args...]', 'err');
|
||||
const r = await App().ExecuteWindowsAdvanced(id, rest[0], rest.slice(1), useToken, hide, ppid);
|
||||
if (r.error) return appendOut(id, `[error] ${r.error}`, 'err');
|
||||
if (r.stdout) appendOut(id, r.stdout.trimEnd(), 'out');
|
||||
if (r.stderr) appendOut(id, r.stderr.trimEnd(), 'err');
|
||||
return;
|
||||
}
|
||||
case 'wg-forwarders': {
|
||||
const f = await App().ListWGForwarders(id);
|
||||
return appendOut(id, f.length ? f.map(x => `#${x.id} ${x.localAddr} -> ${x.remoteAddr}`).join('\n') : '[*] no WG forwarders', f.length?'out':'info');
|
||||
}
|
||||
case 'wg-list-socks': {
|
||||
const s = await App().ListWGSocks(id);
|
||||
return appendOut(id, s.length ? s.map(x => `#${x.id} ${x.localAddr}`).join('\n') : '[*] no WG socks servers', s.length?'out':'info');
|
||||
}
|
||||
case 'close': {
|
||||
if (kind !== 'session') return appendOut(id, '[!] close applies to sessions (use kill-beacon for beacons)', 'err');
|
||||
await App().CloseSessionGraceful(id);
|
||||
return appendOut(id, '[+] session close requested (graceful)', 'out');
|
||||
}
|
||||
case 'mkdir': {
|
||||
if (!args[0]) return appendOut(id, 'usage: mkdir <path>', 'err');
|
||||
await App().MakeDirectory(id, args[0]);
|
||||
@@ -706,7 +927,12 @@ async function dispatchCmd(kind, id, raw) {
|
||||
}
|
||||
if (sub === 'read') { const v = await App().RegistryReadValue(id, args[1], args[2], args[3]); return appendOut(id, v, 'out'); }
|
||||
if (sub === 'write') { await App().RegistryWriteValue(id, args[1], args[2], args[3], args.slice(4).join(' ')); return appendOut(id, '[+] value written', 'out'); }
|
||||
return appendOut(id, 'usage: reg query|read|write <HIVE> <path> [key] [value]', 'err');
|
||||
if (sub === 'read-hive') {
|
||||
if (!args[1]) return appendOut(id, 'usage: reg read-hive <ROOT_HIVE> [requested_hive]', 'err');
|
||||
const p = await App().RegistryReadHiveExport(id, args[1], args[2]||'');
|
||||
return appendOut(id, `[+] hive saved to ${p}`, 'out');
|
||||
}
|
||||
return appendOut(id, 'usage: reg query|read|write|read-hive <HIVE> <path> [key] [value]', 'err');
|
||||
}
|
||||
case 'execute': {
|
||||
// execute [-o|-e|-t|-s ...] <path> [args] — run a program directly (no shell)
|
||||
@@ -931,6 +1157,59 @@ function appendOut(id, text, cls) {
|
||||
out.appendChild(s); out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
// ── Interactive shell (real-time PTY over a tunnel) ─────────────────────────
|
||||
// Opens as a pinned docked tab (named by machine), like the per-agent console
|
||||
// and server console — not a floating popup.
|
||||
async function openInteractiveShell(agentId, obj) {
|
||||
const host = obj ? (obj.hostname || obj.id.slice(0,8)) : agentId;
|
||||
const isWin = obj && (obj.os||'').toLowerCase().includes('windows');
|
||||
let tid;
|
||||
try {
|
||||
tid = await App().StartInteractiveShell(agentId, '', !isWin); // '' = implant default shell
|
||||
} catch (e) {
|
||||
return appendOut(agentId, `[error] could not start shell: ${e}`, 'err');
|
||||
}
|
||||
|
||||
const dockId = `shell-${tid}`;
|
||||
const evtOut = `sliver:shell:${tid}`, evtClose = `sliver:shell-closed:${tid}`;
|
||||
// Register as a shell tab so closeTab() can tear the tunnel down.
|
||||
openTabs[dockId] = { kind: 'shell', tid, evtOut, evtClose };
|
||||
document.getElementById('empty-interact')?.remove();
|
||||
|
||||
// Tab: named by machine, with a shell glyph.
|
||||
const tab = document.createElement('button'); tab.className = 'interact-tab'; tab.dataset.tid = dockId;
|
||||
tab.innerHTML = `<span>▸ ${esc(host)}</span><span class="close-x" data-cid="${dockId}">x</span>`;
|
||||
tab.addEventListener('click', e => { if (e.target.dataset.cid) closeTab(e.target.dataset.cid); else activateTab(dockId); });
|
||||
document.getElementById('interact-tabs').appendChild(tab);
|
||||
|
||||
// Panel: streaming output + input line.
|
||||
const outId = `sh-out-${tid}`, inId = `sh-in-${tid}`;
|
||||
const panel = document.createElement('div'); panel.className = 'interact-panel'; panel.id = `ip-${dockId}`;
|
||||
panel.innerHTML =
|
||||
`<pre id="${outId}" class="shell-out"><span class="info">[*] interactive shell on ${esc(host)} — type 'exit' or close the tab to end.\n</span></pre>
|
||||
<input id="${inId}" class="shell-in" placeholder="type a command and press Enter…" autocomplete="off" spellcheck="false"/>`;
|
||||
document.getElementById('interact-panels').appendChild(panel);
|
||||
|
||||
const outEl = document.getElementById(outId), inEl = document.getElementById(inId);
|
||||
const append = t => { if (!outEl) return; outEl.appendChild(document.createTextNode(t)); outEl.scrollTop = outEl.scrollHeight; };
|
||||
const onData = b64 => { try { append(decodeB64(b64)); } catch(e){} };
|
||||
const onClose = () => { append('\n[*] shell closed\n'); if (inEl) inEl.disabled = true; if (window.runtime){ window.runtime.EventsOff(evtOut); window.runtime.EventsOff(evtClose); } };
|
||||
if (window.runtime) { window.runtime.EventsOn(evtOut, onData); window.runtime.EventsOn(evtClose, onClose); }
|
||||
if (inEl) {
|
||||
inEl.addEventListener('keydown', async e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const line = inEl.value; inEl.value = '';
|
||||
if (line.trim() === 'exit') { await App().StopInteractiveShell(tid).catch(()=>{}); onClose(); return; }
|
||||
await App().SendShellData(tid, encodeB64(line + '\n')).catch(err => append(`\n[send error] ${err}\n`));
|
||||
});
|
||||
}
|
||||
activateTab(dockId);
|
||||
setTimeout(() => inEl && inEl.focus(), 30);
|
||||
}
|
||||
// UTF-8 safe base64 helpers for shell I/O.
|
||||
function encodeB64(str){ return btoa(unescape(encodeURIComponent(str))); }
|
||||
function decodeB64(b64){ return decodeURIComponent(escape(atob(b64))); }
|
||||
|
||||
// ── Toolbar nav (views in bottom panel for non-agent views) ────────────────
|
||||
document.querySelectorAll('.tb-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -970,10 +1249,10 @@ function openNotes() {
|
||||
const t = openTabs[activeInteractId], o = t && t.obj;
|
||||
const title = `Notes — ${o ? (o.hostname || o.id.slice(0,8)) : activeInteractId}`;
|
||||
openViewPanel('_notes', title,
|
||||
`<p style="color:var(--muted);font-size:12px;margin-bottom:8px">Notes are per-agent and kept for this session (cleared on disconnect). Auto-saved as you type.</p>
|
||||
`<p style="color:var(--muted);font-size:12px;margin-bottom:8px">Notes are per-agent, saved locally per teamserver, and restored on reconnect. Auto-saved as you type.</p>
|
||||
<textarea id="notes-area" class="notes-area" placeholder="Credentials found, next steps, IOCs, todo...">${esc(notesMap[activeInteractId] || '')}</textarea>`);
|
||||
const ta = document.getElementById('notes-area');
|
||||
ta.addEventListener('input', () => { notesMap[activeInteractId] = ta.value; markNoted(activeInteractId); });
|
||||
ta.addEventListener('input', () => { notesMap[activeInteractId] = ta.value; markNoted(activeInteractId); saveState(); });
|
||||
setTimeout(() => ta.focus(), 30);
|
||||
}
|
||||
// markNoted adds a small dot to a tab that has notes.
|
||||
@@ -991,8 +1270,8 @@ const SERVER_HELP = `Server console — runs teamserver commands (NOT on a targe
|
||||
jobs | listeners list active listeners
|
||||
jobs kill <id> kill a listener/job
|
||||
operators | players list operators
|
||||
loot [rm <id>] list / remove loot
|
||||
hosts [rm <id>] list / remove hosts DB entries
|
||||
loot [rm <id> | rename <id> <name>] list / remove / rename loot
|
||||
hosts [rm <id> | ioc-rm <ioc-id>] list / remove hosts / remove host IOC
|
||||
creds [add <u> <p> | rm <id>] list / add / remove credentials
|
||||
builds list implant builds
|
||||
regenerate <name> re-download a previous build
|
||||
@@ -1002,6 +1281,26 @@ const SERVER_HELP = `Server console — runs teamserver commands (NOT on a targe
|
||||
stager <host> <port> <profile> start a TCP stager listener
|
||||
use <id-prefix> interact with a session/beacon
|
||||
c2profiles list HTTP C2 profiles
|
||||
c2profile <name> view a full HTTP C2 profile (JSON)
|
||||
c2profile edit <name> open the HTTP C2 profile editor
|
||||
c2profile new create a new HTTP C2 profile
|
||||
certificates | ca list issued / CA certificates
|
||||
compiler teamserver build capabilities
|
||||
builders external build servers
|
||||
traffic-encoders list WASM traffic encoders
|
||||
shellcode-encoders list shellcode encoders
|
||||
armory list available armory packages
|
||||
armory install <name> install an armory package
|
||||
armory remove <name> remove an armory package
|
||||
shellcode-rdi <dll> <func> [args] convert a DLL to shellcode (sRDI)
|
||||
shellcode-encode <bin> [arch] [iter] encode shellcode (shikata-ga-nai)
|
||||
monitor [start|stop|list|add <type> <key>|del <id>] threat-monitoring
|
||||
website add|update <site> <web-path> <local-file> | rm <site> <web-path>
|
||||
pivots | pivot-graph show the pivot (peer) topology
|
||||
wg-client-config generate a WireGuard client config
|
||||
wg-unique-ip allocate a unique WireGuard peer IP
|
||||
creds [add|rm|update <id> <plain>|sniff <hash>|get <id>] credential store
|
||||
restart-jobs <id...> restart listener jobs
|
||||
rename <id> <name> rename a session
|
||||
kill-session <id> kill a session
|
||||
kill-beacon <id> remove a beacon
|
||||
@@ -1072,6 +1371,7 @@ async function runServerCmd(raw) {
|
||||
}
|
||||
case 'loot': {
|
||||
if (args[0] === 'rm') { await App().DeleteLoot(args[1]); return appendServer(`[+] loot ${args[1]} removed`, 'out'); }
|
||||
if (args[0] === 'rename') { if (args.length < 3) return appendServer('usage: loot rename <id> <new-name>', 'err'); await App().RenameLoot(args[1], args.slice(2).join(' ')); return appendServer('[+] loot renamed', 'out'); }
|
||||
const l = await App().GetLoot(); if (!l.length) return appendServer('loot store empty', 'info');
|
||||
return appendServer(l.map(x => `${x.id.slice(0,12)} ${(x.type||'').padEnd(10)} ${x.name}`).join('\n'), 'out');
|
||||
}
|
||||
@@ -1093,8 +1393,99 @@ async function runServerCmd(raw) {
|
||||
case 'kill-beacon': case 'rm-beacon': { if (!args[0]) return appendServer('usage: kill-beacon <id-prefix>', 'err'); const b = allBeacons.find(x => x.id.startsWith(args[0])); if (!b) return appendServer('no matching beacon', 'err'); await App().KillBeacon(b.id); return appendServer(`[+] removed beacon ${b.hostname}`, 'out'); }
|
||||
case 'rename': { if (args.length < 2) return appendServer('usage: rename <session-id-prefix> <new-name>', 'err'); const s = allSessions.find(x => x.id.startsWith(args[0])); if (!s) return appendServer('no matching session', 'err'); await App().RenameSession(s.id, args.slice(1).join(' ')); refreshAgents(); return appendServer('[+] renamed', 'out'); }
|
||||
case 'c2profiles': { const p = await App().ListC2Profiles(); if (!p.length) return appendServer('no HTTP C2 profiles', 'info'); return appendServer(p.map(x => x.name).join('\n'), 'out'); }
|
||||
case 'certificates': case 'certs': {
|
||||
const c = await App().ListCertificates(); if (!c.length) return appendServer('no certificates', 'info');
|
||||
let o = 'CN TYPE KEYALG EXPIRES\n';
|
||||
c.forEach(x => o += `${(x.cn||'').slice(0,30).padEnd(31)}${(x.type||'').padEnd(16)}${(x.keyAlgorithm||'').padEnd(11)}${x.validExpiry||''}\n`);
|
||||
return appendServer(o.trimEnd(), 'out');
|
||||
}
|
||||
case 'compiler': case 'compiler-info': {
|
||||
const c = await App().GetCompilerInfo();
|
||||
let o = `server: ${c.goos}/${c.goarch}\ncross-compilers: ${(c.crossCompilers||[]).join(', ')||'none'}\ntargets:\n`;
|
||||
(c.targets||[]).forEach(t => o += ` ${t.goos}/${t.goarch} (${t.format})\n`);
|
||||
return appendServer(o.trimEnd(), 'out');
|
||||
}
|
||||
case 'builders': {
|
||||
const b = await App().ListBuilders(); if (!b.length) return appendServer('no external builders registered', 'info');
|
||||
return appendServer(b.map(x => `${(x.name||'').padEnd(20)} ${x.goos}/${x.goarch} op=${x.operatorName} [${x.templates}]`).join('\n'), 'out');
|
||||
}
|
||||
case 'traffic-encoders': { const t = await App().ListTrafficEncoders(); return appendServer(t.length ? t.join('\n') : 'no traffic encoders', t.length?'out':'info'); }
|
||||
case 'shellcode-encoders': { const s = await App().ListShellcodeEncoders(); return appendServer(s.length ? s.join('\n') : 'no shellcode encoders', s.length?'out':'info'); }
|
||||
case 'armory': {
|
||||
const sub = (args[0]||'').toLowerCase();
|
||||
if (sub === 'install') {
|
||||
if (!args[1]) return appendServer('usage: armory install <name>', 'err');
|
||||
appendServer(`[*] installing ${args[1]}...`, 'info');
|
||||
const err = await App().ArmoryInstall(args[1]).then(() => null).catch(e => String(e));
|
||||
return appendServer(err ? `[!] ${err}` : `[+] ${args[1]} installed`, err ? 'err' : 'out');
|
||||
}
|
||||
if (sub === 'remove' || sub === 'rm') {
|
||||
if (!args[1]) return appendServer('usage: armory remove <name>', 'err');
|
||||
const err = await App().ArmoryRemove(args[1]).then(() => null).catch(e => String(e));
|
||||
return appendServer(err ? `[!] ${err}` : `[+] ${args[1]} removed`, err ? 'err' : 'out');
|
||||
}
|
||||
const pkgs = await App().ArmoryList().catch(e => { appendServer(`[!] ${e}`, 'err'); return null; });
|
||||
if (!pkgs) return;
|
||||
if (!pkgs.length) return appendServer('no armory packages available', 'info');
|
||||
let out = `${'NAME'.padEnd(28)}${'TYPE'.padEnd(11)}${'INSTALLED'.padEnd(11)}DESCRIPTION
|
||||
`;
|
||||
pkgs.forEach(p => { out += `${(p.name||'').slice(0,27).padEnd(28)}${(p.type||'').padEnd(11)}${(p.installed?'yes':'no').padEnd(11)}${(p.description||'').slice(0,50)}
|
||||
`; });
|
||||
return appendServer(out.trimEnd(), 'out');
|
||||
}
|
||||
case 'restart-jobs': { if (!args.length) return appendServer('usage: restart-jobs <id> [id...]', 'err'); await App().RestartJobs(args.map(a => parseInt(a))); return appendServer(`[+] restarted jobs ${args.join(', ')}`, 'out'); }
|
||||
case 'ca': case 'certificate-authority': {
|
||||
const c = await App().ListCAInfo(); if (!c.length) return appendServer('no CA certificates', 'info');
|
||||
let o = 'CN TYPE KEYALG EXPIRES\n';
|
||||
c.forEach(x => o += `${(x.cn||'').slice(0,30).padEnd(31)}${(x.type||'').padEnd(16)}${(x.keyAlgorithm||'').padEnd(11)}${x.validExpiry||''}\n`);
|
||||
return appendServer(o.trimEnd(), 'out');
|
||||
}
|
||||
case 'monitor': {
|
||||
const sub = (args[0]||'list').toLowerCase();
|
||||
if (sub === 'start') { await App().StartMonitor(); return appendServer('[+] monitor started', 'out'); }
|
||||
if (sub === 'stop') { await App().StopMonitor(); return appendServer('[+] monitor stopped', 'out'); }
|
||||
if (sub === 'add') { if (args.length < 3) return appendServer('usage: monitor add <type> <apikey> [apipassword]', 'err'); await App().AddMonitorConfig(args[1], args[2], args[3]||''); return appendServer('[+] monitoring provider added', 'out'); }
|
||||
if (sub === 'del' || sub === 'rm') { if (!args[1]) return appendServer('usage: monitor del <id>', 'err'); await App().DelMonitorConfig(args[1]); return appendServer('[+] provider removed', 'out'); }
|
||||
const p = await App().ListMonitorConfig(); if (!p.length) return appendServer('no monitoring providers configured', 'info');
|
||||
return appendServer(p.map(x => `${(x.id||'').padEnd(16)} ${x.type}`).join('\n'), 'out');
|
||||
}
|
||||
case 'pivot-graph': case 'pivots': {
|
||||
const g = await App().GetPivotGraph(); if (!g.length) return appendServer('no pivots', 'info');
|
||||
const lines = []; const walk = (n, d) => { lines.push(`${' '.repeat(d)}${n.hostname||n.name||('peer '+n.peerId)}`); (n.children||[]).forEach(c => walk(c, d+1)); };
|
||||
g.forEach(n => walk(n, 0)); return appendServer(lines.join('\n'), 'out');
|
||||
}
|
||||
case 'wg-client-config': {
|
||||
const c = await App().GenerateWGClientConfig();
|
||||
return appendServer(`ClientIP: ${c.clientIP}\nClientPrivateKey: ${c.clientPrivateKey}\nClientPubKey: ${c.clientPubKey}\nServerPubKey: ${c.serverPubKey}`, 'out');
|
||||
}
|
||||
case 'wg-unique-ip': { const ip = await App().GenerateUniqueWGIP(); return appendServer(`[+] unique WG IP: ${ip}`, 'out'); }
|
||||
case 'dll2shellcode': case 'srdi': {
|
||||
if (args.length < 2) return appendServer('usage: dll2shellcode <local.dll> <function> [args]', 'err');
|
||||
const p = await App().ConvertDLLToShellcode(args[0], args[1], args.slice(2).join(' '));
|
||||
return appendServer(`[+] shellcode saved to ${p}`, 'out');
|
||||
}
|
||||
case 'shellcode-encode': {
|
||||
if (!args[0]) return appendServer('usage: shellcode-encode <local.bin> [arch=amd64] [iterations=1]', 'err');
|
||||
const p = await App().EncodeShellcode(args[0], args[1]||'amd64', parseInt(args[2])||1);
|
||||
return appendServer(`[+] encoded shellcode saved to ${p}`, 'out');
|
||||
}
|
||||
case 'c2profile': {
|
||||
if (args[0] === 'edit') { if (!args[1]) return appendServer('usage: c2profile edit <name>', 'err'); openC2Editor(args[1]); return appendServer(`[*] editing HTTP C2 profile ${args[1]}`, 'info'); }
|
||||
if (args[0] === 'new') { openC2Editor(''); return appendServer('[*] new HTTP C2 profile editor opened', 'info'); }
|
||||
if (!args[0]) return appendServer('usage: c2profile <name> | edit <name> | new (view/edit HTTP C2 profiles)', 'err');
|
||||
const j = await App().GetHTTPC2Profile(args[0]); return appendServer(j, 'out');
|
||||
}
|
||||
case 'website': {
|
||||
const sub = (args[0]||'').toLowerCase();
|
||||
if (sub === 'add') { if (args.length < 4) return appendServer('usage: website add <site> <web-path> <local-file>', 'err'); await App().AddWebsiteContent(args[1], args[2], args[3]); return appendServer('[+] content added', 'out'); }
|
||||
if (sub === 'update') { if (args.length < 4) return appendServer('usage: website update <site> <web-path> <local-file>', 'err'); await App().UpdateWebsiteContent(args[1], args[2], args[3]); return appendServer('[+] content updated', 'out'); }
|
||||
if (sub === 'rm') { if (args.length < 3) return appendServer('usage: website rm <site> <web-path>', 'err'); await App().RemoveWebsiteContent(args[1], args[2]); return appendServer('[+] content removed', 'out'); }
|
||||
const w = await App().ListWebsites(); if (!w || !w.length) return appendServer('no websites', 'info');
|
||||
return appendServer(w.map(x => x.name).join('\n'), 'out');
|
||||
}
|
||||
case 'hosts': {
|
||||
if (args[0] === 'rm') { await App().DeleteHost(args[1]); return appendServer(`[+] host ${args[1]} removed`, 'out'); }
|
||||
if (args[0] === 'ioc-rm') { if (!args[1]) return appendServer('usage: hosts ioc-rm <ioc-id> [path] [filehash]', 'err'); await App().RemoveHostIOC(args[1], args[2]||'', args[3]||''); return appendServer('[+] host IOC removed', 'out'); }
|
||||
const h = await App().ListHosts(); if (!h.length) return appendServer('no hosts in the database', 'info');
|
||||
let o = 'HOSTNAME OS UUID\n';
|
||||
h.forEach(x => o += `${(x.hostname||'').slice(0,20).padEnd(21)}${(x.os||'').slice(0,30).padEnd(31)}${(x.uuid||'').slice(0,12)}\n`);
|
||||
@@ -1103,6 +1494,9 @@ async function runServerCmd(raw) {
|
||||
case 'creds': {
|
||||
if (args[0] === 'add') { if (args.length < 3) return appendServer('usage: creds add <username> <password>', 'err'); await App().AddCred(args[1], args[2], ''); return appendServer('[+] credential added', 'out'); }
|
||||
if (args[0] === 'rm') { await App().DeleteCred(args[1]); return appendServer(`[+] credential ${args[1]} removed`, 'out'); }
|
||||
if (args[0] === 'update') { if (args.length < 3) return appendServer('usage: creds update <id> <plaintext>', 'err'); await App().UpdateCredential(args[1], '', args[2], '', true); return appendServer('[+] credential updated', 'out'); }
|
||||
if (args[0] === 'sniff') { if (!args[1]) return appendServer('usage: creds sniff <hash>', 'err'); const ht = await App().SniffCredHashType(args[1]); return appendServer(`hash type: ${ht}`, 'out'); }
|
||||
if (args[0] === 'get') { if (!args[1]) return appendServer('usage: creds get <id>', 'err'); const c = await App().GetCredential(args[1]); return appendServer(`ID: ${c.id}\nUsername: ${c.username}\nPlaintext: ${c.plaintext}\nHash: ${c.hash}\nCracked: ${c.cracked}`, 'out'); }
|
||||
const c = await App().ListCreds(); if (!c.length) return appendServer('no credentials stored', 'info');
|
||||
let o = 'USERNAME PLAINTEXT / HASH\n';
|
||||
c.forEach(x => o += `${(x.username||'').slice(0,20).padEnd(21)}${x.plaintext || x.hash || ''}\n`);
|
||||
@@ -1165,6 +1559,36 @@ function openLootPanel() {
|
||||
openViewPanel('_loot', 'Loot', `<div style="overflow:auto;flex:1" id="loot-list"><div style="padding:10px;color:var(--muted)">Loading...</div></div>`);
|
||||
refreshLootList();
|
||||
}
|
||||
|
||||
// HTTP C2 profile editor — load the profile as JSON, edit, save back.
|
||||
async function openC2Editor(name) {
|
||||
openViewPanel('_c2edit', `HTTP C2 Profile${name ? ' — ' + name : ''}`,
|
||||
`<div style="display:flex;flex-direction:column;gap:8px;flex:1;min-height:0">
|
||||
<p style="color:var(--muted);font-size:11px;margin:0">Edit the profile JSON below. To clone it, change the <code>"name"</code> field and click <b>Save as new</b>. Malformed JSON is rejected by the teamserver.</p>
|
||||
<textarea id="c2-json" class="notes-area" style="flex:1;min-height:260px;font-family:var(--mono);font-size:11px;line-height:1.5" spellcheck="false">Loading…</textarea>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button id="c2-save" class="btn accent">Save (overwrite)</button>
|
||||
<button id="c2-savenew" class="btn">Save as new</button>
|
||||
<span id="c2-status" style="font-size:11px;color:var(--muted)"></span>
|
||||
</div>
|
||||
</div>`);
|
||||
const ta = document.getElementById('c2-json'), status = document.getElementById('c2-status');
|
||||
if (name) {
|
||||
try { ta.value = await App().GetHTTPC2Profile(name); }
|
||||
catch (e) { ta.value = ''; status.textContent = 'load error: ' + e; }
|
||||
} else {
|
||||
ta.value = '';
|
||||
ta.placeholder = 'Paste an HTTP C2 profile JSON (copy one from "c2profile <name>" as a starting point).';
|
||||
}
|
||||
const save = async overwrite => {
|
||||
if (!ta.value.trim()) { status.textContent = 'nothing to save'; return; }
|
||||
status.textContent = 'saving…';
|
||||
try { await App().SaveHTTPC2Profile(ta.value, overwrite); status.textContent = '✓ saved'; toast('ok', 'HTTP C2 profile saved'); }
|
||||
catch (e) { status.textContent = '✕ ' + e; toast('err', String(e)); }
|
||||
};
|
||||
document.getElementById('c2-save').addEventListener('click', () => save(true));
|
||||
document.getElementById('c2-savenew').addEventListener('click', () => save(false));
|
||||
}
|
||||
async function refreshLootList() {
|
||||
const el = document.getElementById('loot-list'); if (!el) return;
|
||||
const loot = await App().GetLoot().catch(() => []);
|
||||
@@ -1414,16 +1838,33 @@ function openGeneratePanel() {
|
||||
e.preventDefault(); const f = e.target;
|
||||
const req = { name:f.name.value, goos:f.goos.value, goarch:f.goarch.value, format:f.format.value, c2Url:f.c2Url.value, debug:f.debug.checked, beacon:f.beacon.checked, interval:parseInt(f.interval?.value)||60, jitter:parseInt(f.jitter?.value)||0 };
|
||||
document.getElementById('gen-status').style.display = 'flex';
|
||||
document.getElementById('gen-result').textContent = '';
|
||||
const res = document.getElementById('gen-result');
|
||||
res.textContent = '';
|
||||
const r = await App().GenerateImplant(req).catch(e => ({error:String(e)}));
|
||||
document.getElementById('gen-status').style.display = 'none';
|
||||
const res = document.getElementById('gen-result');
|
||||
res.textContent = r.error ? `[ERROR] ${r.error}` : `[OK] ${r.file}`;
|
||||
res.style.color = r.error ? 'var(--accent)' : 'var(--ok)';
|
||||
if (r.error) {
|
||||
res.textContent = `[ERROR] ${r.error}`;
|
||||
res.style.color = 'var(--accent)';
|
||||
return;
|
||||
}
|
||||
// Build succeeded and is stored on the teamserver. Offer a local save that
|
||||
// opens the dialog on a user click (so the spinner is gone and the dialog
|
||||
// gets focus). The build is always retrievable from the Builds panel too.
|
||||
res.style.color = 'var(--ok)';
|
||||
res.innerHTML = `[OK] built <b>${esc(r.name)}</b> — stored on the teamserver. `;
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'btn small';
|
||||
saveBtn.textContent = '💾 Save to disk';
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
saveBtn.disabled = true; saveBtn.textContent = 'Saving…';
|
||||
const s = await App().RegenerateBuild(r.name).catch(e => ({ error: String(e) }));
|
||||
if (s.error) { toast('err', s.error); saveBtn.disabled = false; saveBtn.textContent = '💾 Save to disk'; }
|
||||
else { saveBtn.textContent = '✓ Saved'; toast('ok', `Saved to ${s.path}`); }
|
||||
});
|
||||
res.appendChild(saveBtn);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ── Command palette (Ctrl+K) ─────────────────────────────────────────────────
|
||||
let paletteItems = [], paletteSel = 0;
|
||||
document.addEventListener('keydown', e => {
|
||||
@@ -1501,3 +1942,16 @@ async function attemptReconnect() {
|
||||
function cancelReconnect() { reconnecting = false; clearInterval(reconnectTimer); document.getElementById('reconnect-overlay').classList.add('hidden'); }
|
||||
document.getElementById('reconnect-now-btn').addEventListener('click', attemptReconnect);
|
||||
document.getElementById('reconnect-cancel-btn').addEventListener('click', async () => { cancelReconnect(); await App().Disconnect().catch(()=>{}); document.getElementById('disconnect-btn').click(); });
|
||||
|
||||
// Show GUI build version on the connect screen once the Wails runtime is ready.
|
||||
(async function showGuiVersion() {
|
||||
let bi = null;
|
||||
try { bi = await App().AppVersion(); } catch (e) { /* runtime not ready or older backend */ }
|
||||
const el = document.getElementById('gui-version');
|
||||
if (el) {
|
||||
el.textContent = bi && bi.version ? `${bi.version} · ${bi.gitCommit}` : '';
|
||||
el.title = bi ? `Built ${bi.buildDate}` : '';
|
||||
}
|
||||
const wm = document.getElementById('panel-watermark');
|
||||
if (wm) wm.textContent = `Sliver GUI${bi && bi.version ? ' ' + bi.version : ''} · Made by Raj Kumar Mullapudi`;
|
||||
})();
|
||||
|
||||
Vendored
+7
-2
@@ -35,6 +35,7 @@ code{font-family:var(--mono);font-size:11px;background:var(--panel-alt);padding:
|
||||
.logo{font-size:26px;font-weight:800;letter-spacing:4px;margin-bottom:6px;}
|
||||
.logo span{color:var(--accent);}
|
||||
.subtitle{color:var(--muted);margin-bottom:24px;font-size:11px;letter-spacing:.5px;}
|
||||
.gui-version{margin-top:14px;font-size:9.5px;color:var(--muted);font-family:var(--mono);letter-spacing:.5px;}
|
||||
.config-path{font-size:10px;color:var(--text-dim);margin:10px 0;word-break:break-all;min-height:12px;}
|
||||
.error-msg{color:var(--accent);font-size:11px;margin-top:8px;min-height:12px;}
|
||||
.reconnect-count{font-size:11px;color:var(--muted);margin:8px 0;}
|
||||
@@ -77,7 +78,8 @@ input::placeholder{color:var(--muted);}
|
||||
/* Split layout */
|
||||
.split-layout{display:flex;flex-direction:column;flex:1;min-height:0;}
|
||||
.top-panel{flex:1;display:flex;flex-direction:column;min-height:180px;overflow:hidden;}
|
||||
.bottom-panel{height:300px;min-height:100px;display:flex;flex-direction:column;border-top:1px solid var(--border);overflow:hidden;background:var(--bg-2);}
|
||||
.bottom-panel{height:300px;min-height:100px;display:flex;flex-direction:column;border-top:1px solid var(--border);overflow:hidden;background:var(--bg-2);position:relative;}
|
||||
.panel-watermark{position:absolute;right:12px;bottom:7px;z-index:2;pointer-events:none;font-family:var(--mono);font-size:10px;letter-spacing:.4px;color:var(--muted);opacity:.5;white-space:nowrap;}
|
||||
.resize-handle{height:5px;background:var(--border);cursor:row-resize;flex-shrink:0;transition:background .12s;}
|
||||
.resize-handle:hover{background:var(--accent);}
|
||||
|
||||
@@ -190,9 +192,12 @@ input::placeholder{color:var(--muted);}
|
||||
.check-label{display:flex;align-items:center;gap:7px;font-size:13px;color:var(--text-dim);cursor:pointer;}
|
||||
.modal-body .btn.accent{padding:10px 18px;font-size:13px;font-weight:700;}
|
||||
.gen-status{font-size:12px;color:var(--text-dim);display:flex;align-items:center;gap:8px;}
|
||||
.spinner{width:13px;height:13px;border:2px solid var(--border-hi);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;}
|
||||
.spinner{width:13px;height:13px;border:2px solid var(--border-hi);border-top-color:var(--accent);border-radius:50%;animation:spin 1s steps(12) infinite;}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.result-box{font-size:12px;font-family:var(--mono);}
|
||||
.shell-out{flex:1;overflow-y:auto;margin:0;padding:10px 12px;font-family:var(--mono);font-size:12.5px;line-height:1.55;background:#08090e;color:var(--text);white-space:pre-wrap;word-break:break-word;min-height:0;}
|
||||
.shell-in{background:#0c0e14;border:none;border-top:1px solid var(--border);color:#f2d24b;font-family:var(--mono);font-size:12.5px;outline:none;padding:9px 12px;}
|
||||
.shell-in::placeholder{color:var(--muted);}
|
||||
|
||||
/* Command palette */
|
||||
.palette-overlay{position:fixed;inset:0;z-index:9998;background:rgba(4,5,9,.55);display:flex;align-items:flex-start;justify-content:center;padding-top:14vh;}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -1,11 +1,11 @@
|
||||
module sliver-gui
|
||||
|
||||
go 1.25.6
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/bishopfox/sliver v1.7.3
|
||||
github.com/bishopfox/sliver v1.7.4-0.20260715053412-e53f66de72b9
|
||||
github.com/wailsapp/wails/v2 v2.9.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
@@ -32,15 +32,15 @@ require (
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.10 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
)
|
||||
|
||||
// NOTE: run `go mod tidy` on a machine with full network access.
|
||||
// The bishopfox/sliver module pulls a large dependency tree (protobufs,
|
||||
// crypto libs, etc.) that this sandboxed environment can't resolve.
|
||||
// NOTE: bishopfox/sliver v1.7.3 requires a Go >= 1.25.6 toolchain (hence the
|
||||
// `go 1.25.6` directive above). With GOTOOLCHAIN=auto the correct toolchain is
|
||||
// fetched automatically; otherwise install Go 1.25.6+.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bishopfox/sliver v1.7.3 h1:15iNnxrpKPzObB6IHmMVx6RXxQZJgYFaYkWU2UP/LFY=
|
||||
github.com/bishopfox/sliver v1.7.3/go.mod h1:oOdu/r6S5VyZ8pQgHdFzSQq6qfWaZ/22L9PAVv2luIw=
|
||||
github.com/bishopfox/sliver v1.7.4-0.20260715053412-e53f66de72b9 h1:M1gC46TpvCGI5tTEYXLXBJAfUqgDB8heODfRxFwoWuo=
|
||||
github.com/bishopfox/sliver v1.7.4-0.20260715053412-e53f66de72b9/go.mod h1:6Mdc38kkZLoqRP2gaHz0gq0J04wqnIDeE5/LaMy42l0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -78,23 +78,23 @@ github.com/wailsapp/wails/v2 v2.9.1 h1:irsXnoQrCpeKzKTYZ2SUVlRRyeMR6I0vCO9Q1cvlE
|
||||
github.com/wailsapp/wails/v2 v2.9.1/go.mod h1:7maJV2h+Egl11Ak8QZN/jlGLj2wg05bsQS+ywJPT0gI=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -105,21 +105,21 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -6,24 +6,33 @@ import (
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/linux"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
// appIcon is the window/taskbar icon (the app logo) shown by the window manager.
|
||||
//
|
||||
//go:embed frontend/dist/icons/ICON.png
|
||||
var appIcon []byte
|
||||
|
||||
func main() {
|
||||
app := NewApp()
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Sliver GUI",
|
||||
Title: "Sliver GUI " + Version,
|
||||
Width: 1280,
|
||||
Height: 800,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 15, G: 15, B: 20, A: 1},
|
||||
OnStartup: app.startup,
|
||||
OnShutdown: app.shutdown,
|
||||
Linux: &linux.Options{
|
||||
Icon: appIcon,
|
||||
},
|
||||
OnStartup: app.startup,
|
||||
OnShutdown: app.shutdown,
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// shell.go implements Sliver's interactive shell (`shell`) over a gRPC tunnel,
|
||||
// streamed to the frontend via Wails events. This is the real-time, full-PTY
|
||||
// experience (as opposed to the one-shot `shell <cmd>` exec the console also
|
||||
// offers).
|
||||
//
|
||||
// Wiring:
|
||||
// backend -> frontend : runtime event "sliver:shell:<tunnelID>" with base64 bytes
|
||||
// backend -> frontend : runtime event "sliver:shell-closed:<tunnelID>"
|
||||
// frontend -> backend : SendShellData / ResizeShell / StopInteractiveShell
|
||||
|
||||
type shellHandle struct {
|
||||
tunnelID uint64
|
||||
sessionID string
|
||||
stream rpcpb.SliverRPC_TunnelDataClient
|
||||
cancel context.CancelFunc
|
||||
seq uint64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (h *shellHandle) send(data []byte) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
err := h.stream.Send(&sliverpb.TunnelData{
|
||||
TunnelID: h.tunnelID,
|
||||
Data: data,
|
||||
Sequence: h.seq,
|
||||
})
|
||||
if err == nil {
|
||||
h.seq++
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// StartInteractiveShell opens a PTY shell on the session and streams its output
|
||||
// to the frontend. Returns the tunnel ID (as a string) used to route I/O.
|
||||
func (a *App) StartInteractiveShell(sessionID, shellPath string, enablePTY bool) (string, error) {
|
||||
client, err := a.requireClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(a.ctx)
|
||||
|
||||
rpcTunnel, err := client.RPC.CreateTunnel(ctx, &sliverpb.Tunnel{SessionID: sessionID})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return "", err
|
||||
}
|
||||
tunnelID := rpcTunnel.GetTunnelID()
|
||||
|
||||
stream, err := client.RPC.TunnelData(ctx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return "", err
|
||||
}
|
||||
|
||||
h := &shellHandle{tunnelID: tunnelID, sessionID: sessionID, stream: stream, cancel: cancel}
|
||||
|
||||
// Bind the tunnel with an initial (empty) frame, then ask the implant to
|
||||
// start the shell attached to this tunnel.
|
||||
if err := h.send(nil); err != nil {
|
||||
cancel()
|
||||
return "", err
|
||||
}
|
||||
if _, err := client.RPC.Shell(ctx, &sliverpb.ShellReq{
|
||||
Path: shellPath,
|
||||
EnablePTY: enablePTY,
|
||||
TunnelID: tunnelID,
|
||||
Request: &commonpb.Request{SessionID: sessionID},
|
||||
}); err != nil {
|
||||
cancel()
|
||||
return "", err
|
||||
}
|
||||
|
||||
a.advMu.Lock()
|
||||
if a.shells == nil {
|
||||
a.shells = map[string]*shellHandle{}
|
||||
}
|
||||
a.shells[fmt.Sprintf("%d", tunnelID)] = h
|
||||
a.advMu.Unlock()
|
||||
|
||||
a.audit.log("shell", sessionID, fmt.Sprintf("tunnel=%d pty=%v", tunnelID, enablePTY))
|
||||
|
||||
// Pump implant output -> frontend.
|
||||
go func() {
|
||||
outEvent := fmt.Sprintf("sliver:shell:%d", tunnelID)
|
||||
closeEvent := fmt.Sprintf("sliver:shell-closed:%d", tunnelID)
|
||||
for {
|
||||
td, rerr := stream.Recv()
|
||||
if rerr != nil {
|
||||
runtime.EventsEmit(a.ctx, closeEvent)
|
||||
a.cleanupShell(fmt.Sprintf("%d", tunnelID))
|
||||
return
|
||||
}
|
||||
if len(td.Data) > 0 {
|
||||
runtime.EventsEmit(a.ctx, outEvent, base64.StdEncoding.EncodeToString(td.Data))
|
||||
}
|
||||
if td.Closed {
|
||||
runtime.EventsEmit(a.ctx, closeEvent)
|
||||
a.cleanupShell(fmt.Sprintf("%d", tunnelID))
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return fmt.Sprintf("%d", tunnelID), nil
|
||||
}
|
||||
|
||||
// SendShellData forwards operator keystrokes (base64-encoded) to the shell.
|
||||
func (a *App) SendShellData(tunnelID, b64data string) error {
|
||||
a.advMu.Lock()
|
||||
h := a.shells[tunnelID]
|
||||
a.advMu.Unlock()
|
||||
if h == nil {
|
||||
return fmt.Errorf("shell %s not found", tunnelID)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(b64data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.send(data)
|
||||
}
|
||||
|
||||
// ResizeShell notifies the implant of a new terminal size.
|
||||
func (a *App) ResizeShell(tunnelID string, rows, cols int) error {
|
||||
client, err := a.requireClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.advMu.Lock()
|
||||
h := a.shells[tunnelID]
|
||||
a.advMu.Unlock()
|
||||
if h == nil {
|
||||
return fmt.Errorf("shell %s not found", tunnelID)
|
||||
}
|
||||
_, err = client.RPC.ShellResize(a.ctx, &sliverpb.ShellResizeReq{
|
||||
TunnelID: h.tunnelID,
|
||||
Rows: uint32(rows),
|
||||
Cols: uint32(cols),
|
||||
Request: &commonpb.Request{SessionID: h.sessionID},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// StopInteractiveShell tears down a shell tunnel.
|
||||
func (a *App) StopInteractiveShell(tunnelID string) error {
|
||||
a.advMu.Lock()
|
||||
h := a.shells[tunnelID]
|
||||
a.advMu.Unlock()
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
if client, err := a.requireClient(); err == nil {
|
||||
client.RPC.CloseTunnel(a.ctx, &sliverpb.Tunnel{TunnelID: h.tunnelID, SessionID: h.sessionID})
|
||||
}
|
||||
a.cleanupShell(tunnelID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) cleanupShell(tunnelID string) {
|
||||
a.advMu.Lock()
|
||||
h := a.shells[tunnelID]
|
||||
delete(a.shells, tunnelID)
|
||||
a.advMu.Unlock()
|
||||
if h != nil {
|
||||
h.stream.CloseSend()
|
||||
h.cancel()
|
||||
}
|
||||
}
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
// Build metadata. These are overridden at build time via -ldflags, e.g.:
|
||||
//
|
||||
// wails build -tags webkit2_41 -ldflags "\
|
||||
// -X main.Version=$(git describe --tags --always) \
|
||||
// -X main.GitCommit=$(git rev-parse --short HEAD) \
|
||||
// -X main.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
//
|
||||
// Left as sensible defaults for `go run` / untagged dev builds.
|
||||
var (
|
||||
// Version is the semantic version of this GUI (e.g. "v1.0.0").
|
||||
Version = "dev"
|
||||
// GitCommit is the short commit hash the binary was built from.
|
||||
GitCommit = "unknown"
|
||||
// BuildDate is the UTC build timestamp (RFC3339).
|
||||
BuildDate = "unknown"
|
||||
)
|
||||
|
||||
// BuildInfo is returned to the frontend for display in the About/title bar.
|
||||
type BuildInfo struct {
|
||||
Version string `json:"version"`
|
||||
GitCommit string `json:"gitCommit"`
|
||||
BuildDate string `json:"buildDate"`
|
||||
}
|
||||
|
||||
// AppVersion exposes build metadata to the frontend (bound as
|
||||
// window.go.main.App.AppVersion).
|
||||
func (a *App) AppVersion() BuildInfo {
|
||||
return BuildInfo{Version: Version, GitCommit: GitCommit, BuildDate: BuildDate}
|
||||
}
|
||||
Reference in New Issue
Block a user