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:
Raj Kumar Mullapudi
2026-07-18 11:17:06 -04:00
parent 6a1bbc3994
commit 8b2d337dd7
24 changed files with 3021 additions and 363 deletions
+45
View File
@@ -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 ./...
+111
View File
@@ -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
View File
@@ -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
View File
@@ -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
Executable
+42
View File
@@ -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
+84 -186
View File
@@ -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`
&nbsp;&nbsp;·&nbsp;&nbsp;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
View File
@@ -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.
+106 -35
View File
@@ -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
View File
@@ -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)
}
}
+355
View File
@@ -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
}
Executable
+123
View File
@@ -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
}
+13
View File
@@ -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."
+9
View File
@@ -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;
+55
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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>
+534 -80
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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`;
})();
+7 -2
View File
@@ -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

+12 -12
View File
@@ -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+.
Regular → Executable
+26 -26
View File
@@ -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=
+12 -3
View File
@@ -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,
},
+183
View File
@@ -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
View File
@@ -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}
}