diff --git a/c2/channel/channel.go b/c2/channel/channel.go index 574b5f4..3e51a33 100644 --- a/c2/channel/channel.go +++ b/c2/channel/channel.go @@ -3,6 +3,17 @@ // components in order to support extracting components such as lhost and lport. package channel +import ( + "crypto/rand" + "encoding/base32" + "io" + "net" + "sync/atomic" + "time" + + "github.com/vulncheck-oss/go-exploit/output" +) + type Channel struct { IPAddr string HTTPAddr string @@ -10,4 +21,89 @@ type Channel struct { HTTPPort int Timeout int IsClient bool + Shutdown *atomic.Bool + Sessions map[string]Session + Input io.Reader + Output io.Writer // Currently unused but figured we'd add it ahead of time +} + +type Session struct { + RemoteAddr string + ConnectionTime time.Time + conn *net.Conn +} + +// HasSessions checks if a channel has any tracked sessions. This can be used to lookup if a C2 +// successfully received callbacks: +// +// c, ok := c2.GetInstance(conf.C2Type) +// c.Channel().HasSessions() +func (c *Channel) HasSessions() bool { + return len(c.Sessions) > 0 +} + +// AddSession adds a remote connection for session tracking. If a network connection is being +// tracked it can be added here and will be cleaned up and closed automatically by the C2 on +// shutdown. +func (c *Channel) AddSession(conn *net.Conn, addr string) bool { + if len(c.Sessions) == 0 { + c.Sessions = make(map[string]Session) + } + // This is my session randomizing logic. The theory is that it keeps us dependency free while + // also creating the same 16bit strength of UUIDs. If we only plan on using the random UUIDs + // anyway this should meet the same goals while also being URL safe and no special characters. + k := make([]byte, 16) + _, err := rand.Read(k) + if err != nil { + output.PrintfFrameworkError("Could not add session: %s", err.Error()) + + return false + } + id := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(k) + c.Sessions[id] = Session{ + // Add the time of now to the current connection time + ConnectionTime: time.Now(), + conn: conn, + RemoteAddr: addr, + } + + return true +} + +// RemoveSession removes a specific session ID and if a connection exists, closes it. +func (c *Channel) RemoveSession(id string) bool { + if len(c.Sessions) == 0 { + output.PrintFrameworkDebug("No sessions exist") + + return false + } + _, ok := c.Sessions[id] + if !ok { + output.PrintFrameworkError("Session ID does not exist") + + return false + } + if c.Sessions[id].conn != nil { + (*c.Sessions[id].conn).Close() + } + delete(c.Sessions, id) + + return true +} + +// RemoveSessions removes all tracked sessions and closes any open connections if applicable. +func (c *Channel) RemoveSessions() bool { + if len(c.Sessions) == 0 { + output.PrintFrameworkDebug("No sessions exist") + + return false + } + for k := range c.Sessions { + if c.Sessions[k].conn != nil { + (*c.Sessions[k].conn).Close() + } + delete(c.Sessions, k) + } + + return true } diff --git a/c2/cli/basic.go b/c2/cli/basic.go index db441cf..ccfc927 100644 --- a/c2/cli/basic.go +++ b/c2/cli/basic.go @@ -4,81 +4,115 @@ import ( "bufio" "net" "os" - "strings" "sync" + "testing" "time" + "github.com/vulncheck-oss/go-exploit/c2/channel" "github.com/vulncheck-oss/go-exploit/output" "github.com/vulncheck-oss/go-exploit/protocol" ) +// backgroundResponse handles the network connection reading for response data and contains a +// trigger to the shutdown of the channel to ensure cleanup happens on socket close. +func backgroundResponse(ch *channel.Channel, wg *sync.WaitGroup, conn net.Conn, responseCh chan string) { + defer wg.Done() + defer func(channel *channel.Channel) { + // Signals for both routines to stop, this should get triggered when socket is closed + // and causes it to fail the read + channel.Shutdown.Store(true) + }(ch) + responseBuffer := make([]byte, 1024) + for { + if ch.Shutdown.Load() { + return + } + + err := conn.SetReadDeadline(time.Now().Add(1 * time.Second)) + if err != nil { + output.PrintfFrameworkError("Error setting read deadline: %s, exiting.", err) + + return + } + + bytesRead, err := conn.Read(responseBuffer) + if err != nil && !os.IsTimeout(err) { + // things have gone sideways, but the command line won't know that + // until they attempt to execute a command and the socket fails. + // i think that's largely okay. + return + } + + if bytesRead > 0 { + // I think there is technically a race condition here where the socket + // could have move data to write, but the user has already called exit + // below. I that that's tolerable for now. + responseCh <- string(responseBuffer[:bytesRead]) + } + } +} + // A very basic reverse/bind shell handler. -func Basic(conn net.Conn) { +func Basic(conn net.Conn, ch *channel.Channel) { // Create channels for communication between goroutines. responseCh := make(chan string) - quit := make(chan struct{}) // Use a WaitGroup to wait for goroutines to finish. var wg sync.WaitGroup // Goroutine to read responses from the server. wg.Add(1) - go func() { - defer wg.Done() - responseBuffer := make([]byte, 1024) - for { - select { - case <-quit: - return - default: - _ = conn.SetReadDeadline(time.Now().Add(1 * time.Second)) - bytesRead, err := conn.Read(responseBuffer) - if err != nil && !strings.Contains(err.Error(), "i/o timeout") { - // things have gone sideways, but the command line won't know that - // until they attempt to execute a command and the socket fails. - // i think that's largely okay. - return - } - if bytesRead > 0 { - // I think there is technically a race condition here where the socket - // could have move data to write, but the user has already called exit - // below. I that that's tolerable for now. - responseCh <- string(responseBuffer[:bytesRead]) - } - } - } - }() + + // If running in the test context inherit the channel input setting, this will let us control the + // input of the shell programmatically. + if !testing.Testing() { + ch.Input = os.Stdin + } + go backgroundResponse(ch, &wg, conn, responseCh) // Goroutine to handle responses and print them. wg.Add(1) - go func() { + go func(channel *channel.Channel) { defer wg.Done() - for response := range responseCh { - select { - case <-quit: + for { + if channel.Shutdown.Load() { return - default: + } + select { + case response := <-responseCh: output.PrintShell(response) + default: } } - }() + }(ch) - for { - // read user input until they type 'exit\n' or the socket breaks - // note that ReadString is blocking, so they won't know the socket - // is broken until they attempt to write something - reader := bufio.NewReader(os.Stdin) - command, _ := reader.ReadString('\n') - ok := protocol.TCPWrite(conn, []byte(command)) - if !ok || command == "exit\n" { - break - } - } + go func(channel *channel.Channel) { + // no waitgroup for this one because blocking IO, but this should not matter + // since we are intentionally not trying to be a multi-implant C2 framework. + // There still remains the issue that you would need to hit enter to find out + // that the socket is dead but at least we can stop Basic() regardless of this fact. + // This issue of unblocking stdin is discussed at length here https://github.com/golang/go/issues/24842 + for { + reader := bufio.NewReader(ch.Input) + command, _ := reader.ReadString('\n') + if channel.Shutdown.Load() { + break + } + if command == "exit\n" { + channel.Shutdown.Store(true) - // signal for everyone to shutdown - quit <- struct{}{} - close(responseCh) + break + } + ok := protocol.TCPWrite(conn, []byte(command)) + if !ok { + channel.Shutdown.Store(true) + + break + } + } + }(ch) // wait until the go routines are clean up wg.Wait() + close(responseCh) } diff --git a/c2/external/external.go b/c2/external/external.go index 03b5e8b..eb8de15 100644 --- a/c2/external/external.go +++ b/c2/external/external.go @@ -163,12 +163,13 @@ import ( // The Server struct holds the declared external modules internal functions and channel data. type Server struct { - flags func() - init func() - run func(int) bool - meta func(*channel.Channel) - Channel *channel.Channel - name string + flags func() + init func() + run func(int) bool + shutdown func() bool + meta func(*channel.Channel) + channel *channel.Channel + name string } // The External interface defines which functions are required to be defined in an external C2 @@ -180,6 +181,7 @@ type External interface { SetFlags(func()) SetInit(func()) SetRun(func(int) bool) + SetShutdown(func() bool) } var serverSingletons map[string]*Server @@ -245,14 +247,14 @@ func (externalServer *Server) SetChannel(f func(*channel.Channel)) { } // Init triggers the set C2 initialization and passes the channel to the external module. -func (externalServer *Server) Init(channel channel.Channel) bool { +func (externalServer *Server) Init(channel *channel.Channel) bool { if channel.IsClient { output.PrintFrameworkError("Called ExternalServer as a client.") return false } externalServer.init() - externalServer.meta(&channel) + externalServer.meta(channel) return true } @@ -268,3 +270,21 @@ func (externalServer *Server) SetRun(f func(int) bool) { func (externalServer *Server) Run(timeout int) { externalServer.run(timeout) } + +// SetShutdown sets the function for server shutdown handling and session cleanup logic. This +// function is what gets called when the framework receives a OS signal, a shell is closed, or +// manually invoked. +func (externalServer *Server) SetShutdown(f func() bool) { + externalServer.shutdown = f +} + +// Shutdown triggers the set shutdown function. +func (externalServer *Server) Shutdown() bool { + return externalServer.shutdown() +} + +// Return the underlying C2 channel containing channel metadata and session tracking. +func (externalServer *Server) Channel() *channel.Channel { + // I'd much rather have just exposed a `Server.Channel`, but we are interface bound + return externalServer.channel +} diff --git a/c2/factory.go b/c2/factory.go index a9c68e3..a89c0c6 100644 --- a/c2/factory.go +++ b/c2/factory.go @@ -15,8 +15,10 @@ import ( // An interface used by both reverse shells, bind shells, and stagers. type Interface interface { CreateFlags() - Init(channel channel.Channel) bool + Init(channel *channel.Channel) bool Run(timeout int) + Shutdown() bool + Channel() *channel.Channel } // Internal representation of a C2 implementation. Each C2 is managed by @@ -65,7 +67,7 @@ var internalSupported = map[string]Impl{ "HTTPServeFile": {Name: "HTTPServeFile", Category: HTTPServeFileCategory}, "HTTPServeShell": {Name: "HTTPServeShell", Category: HTTPServeShellCategory}, "HTTPShellServer": {Name: "HTTPShellServer", Category: HTTPShellServerCategory}, - // Insure the internal supported External module name is an error if used + // Ensure the internal supported External module name is an error if used // directly. "External": {Name: "", Category: InvalidCategory}, "ShellTunnel": {Name: "ShellTunnel", Category: ShellTunnelCategory}, @@ -150,6 +152,37 @@ func CreateFlags(implementation Impl) { } } +// HasSessions returns if the underlying channel has active sessions. This is useful for code that +// needs to validate if callbacks have occurred and is a helper wrapper around the channel package +// function of the same name. +func HasSessions(implementation Impl) bool { + switch implementation.Category { + case SimpleShellServerCategory: + return simpleshell.GetServerInstance().Channel().HasSessions() + case SimpleShellClientCategory: + return simpleshell.GetClientInstance().Channel().HasSessions() + case SSLShellServerCategory: + return sslshell.GetInstance().Channel().HasSessions() + case HTTPServeFileCategory: + return httpservefile.GetInstance().Channel().HasSessions() + case HTTPServeShellCategory: + return httpserveshell.GetInstance().Channel().HasSessions() + case ExternalCategory: + if implementation.Name != "" { + return external.GetInstance(implementation.Name).Channel().HasSessions() + } + case HTTPShellServerCategory: + return httpshellserver.GetInstance().Channel().HasSessions() + case ShellTunnelCategory: + return shelltunnel.GetInstance().Channel().HasSessions() + case InvalidCategory: + default: + } + output.PrintFrameworkError("Invalid C2 Server") + + return false +} + // Return the internal representation of a C2 from a string. func StringToImpl(c2Name string) (Impl, bool) { for _, value := range internalSupported { diff --git a/c2/factory_test.go b/c2/factory_test.go index 7c379e8..d5cc4de 100644 --- a/c2/factory_test.go +++ b/c2/factory_test.go @@ -22,12 +22,12 @@ func TestHTTPServeFileInit(t *testing.T) { } httpservefile.GetInstance().FilesToServe = "factory.go" - success = impl.Init(channel.Channel{IPAddr: "127.0.0.2", Port: 1271, HTTPAddr: "127.0.0.1", HTTPPort: 1270, IsClient: true}) + success = impl.Init(&channel.Channel{IPAddr: "127.0.0.2", Port: 1271, HTTPAddr: "127.0.0.1", HTTPPort: 1270, IsClient: true}) if success { t.Fatal("Failed to check if it was invoked as a client") } - success = impl.Init(channel.Channel{IPAddr: "127.0.0.2", Port: 1271, HTTPAddr: "127.0.0.1", HTTPPort: 1270, IsClient: false}) + success = impl.Init(&channel.Channel{IPAddr: "127.0.0.2", Port: 1271, HTTPAddr: "127.0.0.1", HTTPPort: 1270, IsClient: false}) if !success { t.Fatal("Failed to successfully process well-formed init call") } diff --git a/c2/httpservefile/httpservefile.go b/c2/httpservefile/httpservefile.go index a0b16c5..0ed6d1e 100644 --- a/c2/httpservefile/httpservefile.go +++ b/c2/httpservefile/httpservefile.go @@ -30,6 +30,8 @@ import ( "os" "path" "strings" + "sync" + "sync/atomic" "time" "github.com/vulncheck-oss/go-exploit/c2/channel" @@ -66,6 +68,8 @@ type Server struct { HostedFiles map[string]HostedFile // RealName -> struct // A comma delimited list of all the files to serve FilesToServe string + // C2 channel and session metadata + channel *channel.Channel } var singleton *Server @@ -94,8 +98,33 @@ func (httpServer *Server) CreateFlags() { } } +// Return the C2 specific channel. +func (httpServer *Server) Channel() *channel.Channel { + return httpServer.channel +} + +// Shutdown the C2 server and cleanup all the sessions. +func (httpServer *Server) Shutdown() bool { + output.PrintFrameworkStatus("Shutting down the HTTP Server") + if len(httpServer.Channel().Sessions) > 0 { + for k := range httpServer.Channel().Sessions { + httpServer.Channel().RemoveSession(k) + } + } + + return true +} + // load the provided files into memory, stored in a map, and loads the tls cert if needed. -func (httpServer *Server) Init(channel channel.Channel) bool { +func (httpServer *Server) Init(channel *channel.Channel) bool { + if channel.Shutdown == nil { + // Initialize the shutdown atomic. This lets us not have to define it if the C2 is manually + // configured. + var shutdown atomic.Bool + shutdown.Store(false) + channel.Shutdown = &shutdown + } + httpServer.channel = channel if channel.IsClient { output.PrintFrameworkError("Called C2HTTPServer as a client. Use lhost and lport.") @@ -192,6 +221,7 @@ func (httpServer *Server) Run(timeout int) { for _, hosted := range httpServer.HostedFiles { http.HandleFunc("/"+hosted.RandomName, func(writer http.ResponseWriter, req *http.Request) { output.PrintfFrameworkStatus("Connection from %s requested %s", req.RemoteAddr, req.URL.Path) + httpServer.Channel().AddSession(nil, req.RemoteAddr) writer.Header().Set("Server", httpServer.ServerField) @@ -209,7 +239,9 @@ func (httpServer *Server) Run(timeout int) { }) } + var wg sync.WaitGroup connectionString := fmt.Sprintf("%s:%d", httpServer.HTTPAddr, httpServer.HTTPPort) + wg.Add(1) go func() { if httpServer.TLS { output.PrintfFrameworkStatus("Starting an HTTPS server on %s", connectionString) @@ -223,18 +255,58 @@ func (httpServer *Server) Run(timeout int) { TLSConfig: tlsConfig, } defer server.Close() + // Track if the server has signaled for shutdown and if so mark the waitgroup and trigger shutdown + go func() { + for { + if httpServer.Channel().Shutdown.Load() { + server.Close() + httpServer.Shutdown() + wg.Done() + + break + } + } + }() + // Handle timeouts + go func() { + time.Sleep(time.Duration(timeout) * time.Second) + output.PrintFrameworkError("Timeout met. Shutting down shell listener.") + // We do not care about sessions with file + httpServer.channel.Shutdown.Store(true) + }() + _ = server.ListenAndServeTLS("", "") } else { output.PrintfFrameworkStatus("Starting an HTTP server on %s", connectionString) + server := http.Server{ + Addr: connectionString, + } + defer server.Close() + // Track if the server has signaled for shutdown and if so mark the waitgroup and trigger shutdown + go func() { + for { + if httpServer.Channel().Shutdown.Load() { + server.Close() + httpServer.Shutdown() + wg.Done() + + break + } + } + }() + // Handle timeouts + go func() { + time.Sleep(time.Duration(timeout) * time.Second) + output.PrintFrameworkError("Timeout met. Shutting down shell listener.") + // We do not care about sessions with file + httpServer.channel.Shutdown.Store(true) + }() _ = http.ListenAndServe(connectionString, nil) } }() - // let the server run for timeout seconds - time.Sleep(time.Duration(timeout) * time.Second) - - // We don't actually clean up anything, but exiting c2 will eventually terminate the program - output.PrintFrameworkStatus("Shutting down the HTTP Server") + wg.Wait() + httpServer.Channel().Shutdown.Store(true) } // Returns the random name of the provided filename. If filename is empty, return the first entry. diff --git a/c2/httpserveshell/httpserveshell.go b/c2/httpserveshell/httpserveshell.go index 3ee7565..49746e2 100644 --- a/c2/httpserveshell/httpserveshell.go +++ b/c2/httpserveshell/httpserveshell.go @@ -33,6 +33,7 @@ package httpserveshell import ( "flag" "sync" + "sync/atomic" "github.com/vulncheck-oss/go-exploit/c2/channel" "github.com/vulncheck-oss/go-exploit/c2/httpservefile" @@ -48,6 +49,8 @@ type Server struct { HTTPAddr string // The HTTP port to bind to HTTPPort int + // The underlying C2 channel with metadata and session information + channel *channel.Channel } var singleton *Server @@ -74,7 +77,15 @@ func (serveShell *Server) CreateFlags() { } // load the provided file into memory. Generate the random filename. -func (serveShell *Server) Init(channel channel.Channel) bool { +func (serveShell *Server) Init(channel *channel.Channel) bool { + if channel.Shutdown == nil { + // Initialize the shutdown atomic. This lets us not have to define it if the C2 is manually + // configured. + var shutdown atomic.Bool + shutdown.Store(false) + channel.Shutdown = &shutdown + } + serveShell.channel = channel if len(serveShell.HTTPAddr) == 0 { output.PrintFrameworkError("User must specify -httpServeFile.BindAddr") @@ -94,25 +105,77 @@ func (serveShell *Server) Init(channel channel.Channel) bool { return simpleshell.GetServerInstance().Init(channel) } +// Shutdown triggers the shutdown for all running C2s. +func (serveShell *Server) Shutdown() bool { + // Since no logic actually handles client interactions here, this is stub. + // + // Each of the shell sessions and httpservefile sessions should be handled by the channel Shutdown + // atomic. + if serveShell.SSLShell { + sslshell.GetInstance().Channel().Shutdown.Store(true) + } else { + simpleshell.GetServerInstance().Channel().Shutdown.Store(true) + } + httpservefile.GetInstance().Channel().Shutdown.Store(true) + + return true +} + +// Return the underlying C2 channel. +func (serveShell *Server) Channel() *channel.Channel { + return serveShell.channel +} + // start the http server and shell and wait for them to exit. func (serveShell *Server) Run(timeout int) { var wg sync.WaitGroup + // Check if the channel has signaled shutdown and trigger cleanup no matter where it comes from. + go func() { + for { + if serveShell.channel.Shutdown.Load() { + serveShell.Shutdown() + wg.Done() + + break + } + } + }() // Spin up the shell wg.Add(1) go func() { - defer wg.Done() if serveShell.SSLShell { sslshell.GetInstance().Run(timeout) + // Handle shutdown for OS signaling or timeout from underlying instance + go func() { + for { + if sslshell.GetInstance().Channel().Shutdown.Load() { + sslshell.GetInstance().Shutdown() + wg.Done() + + break + } + } + }() } else { simpleshell.GetServerInstance().Run(timeout) + // Handle shutdown for OS signaling or timeout from underlying instance + go func() { + for { + if simpleshell.GetServerInstance().Channel().Shutdown.Load() { + simpleshell.GetServerInstance().Shutdown() + wg.Done() + + break + } + } + }() } }() // Spin up the http server wg.Add(1) go func() { - defer wg.Done() httpservefile.GetInstance().Run(timeout) }() diff --git a/c2/httpshellserver/httpshellserver.go b/c2/httpshellserver/httpshellserver.go index 616be60..2e9cf8b 100644 --- a/c2/httpshellserver/httpshellserver.go +++ b/c2/httpshellserver/httpshellserver.go @@ -10,12 +10,14 @@ import ( "os" "strings" "sync" + "sync/atomic" + "testing" "time" "github.com/vulncheck-oss/go-exploit/c2/channel" "github.com/vulncheck-oss/go-exploit/encryption" - "github.com/vulncheck-oss/go-exploit/random" "github.com/vulncheck-oss/go-exploit/output" + "github.com/vulncheck-oss/go-exploit/random" ) var ( @@ -45,6 +47,7 @@ type Server struct { // Randomly generated during init, gives some sense of security where there is otherwise none. // This should appear in a header with the name VC-Auth AuthHeader string + channel *channel.Channel } // A basic singleton interface for the c2. @@ -56,8 +59,25 @@ func GetInstance() *Server { return singleton } -func (httpServer *Server) Init(channel channel.Channel) bool { - httpServer.AuthHeader = random.RandLetters(20) +func (httpServer *Server) Init(channel *channel.Channel) bool { + if channel.Shutdown == nil { + // Initialize the shutdown atomic. This lets us not have to define it if the C2 is manually + // configured. + var shutdown atomic.Bool + shutdown.Store(false) + channel.Shutdown = &shutdown + } + if channel == nil { + output.PrintFrameworkError("Channel passed to C2 init was nil, ensure that channel is assigned and the shutdown atomic is set to false") + + return false + } + httpServer.channel = channel + if testing.Testing() { + httpServer.AuthHeader = "testing-auth-header" + } else { + httpServer.AuthHeader = random.RandLetters(20) + } if channel.IsClient { output.PrintFrameworkError("Called C2HTTPServer as a client. Use lhost and lport.") @@ -104,29 +124,48 @@ func (httpServer *Server) CreateFlags() { flag.StringVar(&httpServer.CertificateFile, "httpShellServer.CertificateFile", "", "The certificate to use with the HTTPS server") } +// Get the underlying C2 channel with metadata and session information. +func (httpServer *Server) Channel() *channel.Channel { + return httpServer.channel +} + +// Shutdown the C2 server and cleanup all the sessions. +func (httpServer *Server) Shutdown() bool { + output.PrintFrameworkStatus("Shutting down the HTTP Server") + if len(httpServer.Channel().Sessions) > 0 { + for k := range httpServer.Channel().Sessions { + httpServer.Channel().RemoveSession(k) + } + } + + return true +} + // start the HTTP server and listen for incoming requests for `httpServer.FileName`. +// //nolint:gocognit func (httpServer *Server) Run(timeout int) { http.HandleFunc("/rx", func(writer http.ResponseWriter, req *http.Request) { - authHeader := req.Header.Get("VC-Auth") + authHeader := req.Header.Get("Vc-Auth") if authHeader != httpServer.AuthHeader { writer.WriteHeader(http.StatusForbidden) - output.PrintfFrameworkDebug("Auth header mismatch from %s: %s, should be %s", req.RemoteAddr, req.Header.Get("VC-Auth"), httpServer.AuthHeader) - + output.PrintfFrameworkDebug("Auth header mismatch from %s: %s, should be %s", req.RemoteAddr, req.Header.Get("Vc-Auth"), httpServer.AuthHeader) + return } + body, _ := io.ReadAll(req.Body) if strings.TrimSpace(string(body)) != "" { - fmt.Printf("\n%s: %s\n", req.RemoteAddr, string(body)) + output.PrintShell(fmt.Sprintf("%s: %s", req.RemoteAddr, string(body))) } }) http.HandleFunc("/", func(writer http.ResponseWriter, req *http.Request) { - authHeader := req.Header.Get("VC-Auth") + authHeader := req.Header.Get("Vc-Auth") if authHeader != httpServer.AuthHeader { writer.WriteHeader(http.StatusForbidden) - output.PrintfFrameworkDebug("Auth header mismatch from %s: %s, should be %s", req.RemoteAddr, req.Header.Get("VC-Auth"), httpServer.AuthHeader) - + output.PrintfFrameworkDebug("Auth header mismatch from %s: %s, should be %s", req.RemoteAddr, req.Header.Get("Vc-Auth"), httpServer.AuthHeader) + return } lastSeen = time.Now() @@ -135,6 +174,7 @@ func (httpServer *Server) Run(timeout int) { if !httpServer.Success { go func() { httpServer.Success = true + httpServer.Channel().AddSession(nil, req.RemoteAddr) output.PrintfSuccess("Received initial connection from %s, entering shell", req.RemoteAddr) cliLock.Lock() defer cliLock.Unlock() @@ -155,8 +195,9 @@ func (httpServer *Server) Run(timeout int) { } if trimmedCommand == "exit" { output.PrintStatus("Exit received, shutting down") + httpServer.Channel().Shutdown.Store(true) - os.Exit(0) + return } if strings.TrimSpace(command) != "" { commandChan <- strings.TrimSpace(command) @@ -174,7 +215,9 @@ func (httpServer *Server) Run(timeout int) { } }) + var wg sync.WaitGroup connectionString := fmt.Sprintf("%s:%d", httpServer.HTTPAddr, httpServer.HTTPPort) + wg.Add(1) go func() { if httpServer.TLS { output.PrintfFrameworkStatus("Starting an HTTPS server on %s...", connectionString) @@ -184,23 +227,64 @@ func (httpServer *Server) Run(timeout int) { //nolint MinVersion: tls.VersionSSL30, } - server := http.Server { + server := http.Server{ Addr: connectionString, TLSConfig: tlsConfig, // required to disable HTTP/2 according to https://pkg.go.dev/net/http#hdr-HTTP_2 - TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler),1), + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler), 1), } defer server.Close() + // Track if the server has signaled for shutdown and if so mark the waitgroup and trigger shutdown + go func() { + for { + if httpServer.Channel().Shutdown.Load() { + httpServer.Shutdown() + server.Close() + wg.Done() + + break + } + } + }() + // Handle timeouts + go func() { + time.Sleep(time.Duration(timeout) * time.Second) + if !httpServer.Channel().HasSessions() { + output.PrintFrameworkError("Timeout met. Shutting down shell listener.") + httpServer.channel.Shutdown.Store(true) + } + }() _ = server.ListenAndServeTLS("", "") } else { output.PrintfFrameworkStatus("Starting an HTTP server on %s", connectionString) - _ = http.ListenAndServe(connectionString, nil) + server := http.Server{ + Addr: connectionString, + } + defer server.Close() + // Track if the server has signaled for shutdown and if so mark the waitgroup and trigger shutdown + go func() { + for { + if httpServer.Channel().Shutdown.Load() { + server.Close() + httpServer.Shutdown() + wg.Done() + + break + } + } + }() + // Handle timeouts + go func() { + time.Sleep(time.Duration(timeout) * time.Second) + if !httpServer.Channel().HasSessions() { + output.PrintFrameworkError("Timeout met. Shutting down shell listener.") + httpServer.channel.Shutdown.Store(true) + } + }() + _ = server.ListenAndServe() } }() - // let the server run for timeout seconds - time.Sleep(time.Duration(timeout) * time.Second) - - // We don't actually clean up anything, but exiting c2 will eventually terminate the program - output.PrintFrameworkStatus("Shutting down the HTTP Shell Server") + wg.Wait() + httpServer.Channel().Shutdown.Store(true) } diff --git a/c2/shelltunnel/shelltunnel.go b/c2/shelltunnel/shelltunnel.go index 8fe55be..f116d16 100644 --- a/c2/shelltunnel/shelltunnel.go +++ b/c2/shelltunnel/shelltunnel.go @@ -55,6 +55,7 @@ import ( "net" "strconv" "strings" + "sync/atomic" "time" "github.com/vulncheck-oss/go-exploit/c2/channel" @@ -84,6 +85,9 @@ type Server struct { // The file path to the user provided certificate (if provided) CertificateFile string + + // Underlying C2 channel with metadata and session tracking + channel *channel.Channel } var ( @@ -111,7 +115,15 @@ func (shellTunnel *Server) CreateFlags() { flag.StringVar(&shellTunnel.CertificateFile, "shellTunnel.CertificateFile", "", "The certificate to use when being an SSL server") } -func (shellTunnel *Server) Init(channel channel.Channel) bool { +func (shellTunnel *Server) Init(channel *channel.Channel) bool { + if channel.Shutdown == nil { + // Initialize the shutdown atomic. This lets us not have to define it if the C2 is manually + // configured. + var shutdown atomic.Bool + shutdown.Store(false) + channel.Shutdown = &shutdown + } + shellTunnel.channel = channel if channel.IsClient { output.PrintFrameworkError("Called ShellTunnel as a client. Use lhost and lport.") @@ -146,16 +158,39 @@ func (shellTunnel *Server) Init(channel channel.Channel) bool { return true } -func (shellTunnel *Server) Run(timeout int) { - // track if we got a shell or not - success := false +func (shellTunnel *Server) Shutdown() bool { + output.PrintFrameworkStatus("C2 received shutdown, killing server and client sockets for shell tunnel") + if len(shellTunnel.Channel().Sessions) > 0 { + for k, session := range shellTunnel.Channel().Sessions { + output.PrintfFrameworkStatus("Connection closed: %s", session.RemoteAddr) + shellTunnel.Channel().RemoveSession(k) + } + } + shellTunnel.Listener.Close() + + return true +} + +func (shellTunnel *Server) Channel() *channel.Channel { + return shellTunnel.channel +} +func (shellTunnel *Server) Run(timeout int) { // terminate the server if no shells come in within timeout seconds go func() { time.Sleep(time.Duration(timeout) * time.Second) - if !success { + if !shellTunnel.Channel().HasSessions() { output.PrintFrameworkError("Timeout met. Shutting down shell listener.") - shellTunnel.Listener.Close() + shellTunnel.Channel().Shutdown.Store(true) + } + }() + go func() { + for { + if shellTunnel.Channel().Shutdown.Load() { + shellTunnel.Shutdown() + + break + } } }() @@ -170,13 +205,19 @@ func (shellTunnel *Server) Run(timeout int) { return } - success = true + if shellTunnel.Channel().Shutdown.Load() { + break + } output.PrintfFrameworkSuccess("Caught new shell from %v", client.RemoteAddr()) - go handleTunnelConn(client, shellTunnel.ConnectBackHost, shellTunnel.ConnectBackPort, shellTunnel.ConnectBackSSL) + // ShellTunnel is a bit of an outliar as we need to track the incoming connections and also the + // tunneled connections. This will allow for cleanup of connections on both ends of the pipe, + // but may not be immediately clear. + shellTunnel.Channel().AddSession(&client, client.RemoteAddr().String()) + go handleTunnelConn(client, shellTunnel.ConnectBackHost, shellTunnel.ConnectBackPort, shellTunnel.ConnectBackSSL, shellTunnel.channel) } } -func (shellTunnel *Server) createTLSListener(channel channel.Channel) (net.Listener, error) { +func (shellTunnel *Server) createTLSListener(channel *channel.Channel) (net.Listener, error) { var ok bool var err error var certificate tls.Certificate @@ -207,17 +248,27 @@ func (shellTunnel *Server) createTLSListener(channel channel.Channel) (net.Liste return listener, nil } -func handleTunnelConn(clientConn net.Conn, host string, port int, ssl bool) { +func handleTunnelConn(clientConn net.Conn, host string, port int, ssl bool, ch *channel.Channel) { defer clientConn.Close() // attempt to connect back to the serve. MixedConnect is both proxy aware and can // produce an ssl or unencrypted connection so works pretty nice here serverConn, ok := protocol.MixedConnect(host, port, ssl) if !ok { + // This is a bit of a hack as the type of C2 callbacks is not tracked and we will have 1 + // in the sessions from the client call. This checks if it's 1 or less and if it is then it + // will drop future conns. + if len(ch.Sessions) <= 1 { + output.PrintfFrameworkError("Failed to connect back to %s:%d closing server", host, port) + ch.Shutdown.Store(true) + + return + } output.PrintfFrameworkError("Failed to connect back to %s:%d", host, port) return } + ch.AddSession(&serverConn, serverConn.RemoteAddr().String()) output.PrintfFrameworkSuccess("Connect back to %s:%d success!", host, port) defer serverConn.Close() @@ -236,4 +287,7 @@ func handleTunnelConn(clientConn net.Conn, host string, port int, ssl bool) { }() <-done + // Trigger shutdown after the first connection is dropped. in a future where multiple are handled + // this might not be ideal. Revist this when that time comes. + ch.Shutdown.Store(true) } diff --git a/c2/simpleshell/simpleshellclient.go b/c2/simpleshell/simpleshellclient.go index df2a96b..8e27f60 100644 --- a/c2/simpleshell/simpleshellclient.go +++ b/c2/simpleshell/simpleshellclient.go @@ -2,6 +2,7 @@ package simpleshell import ( "net" + "sync/atomic" "time" "github.com/vulncheck-oss/go-exploit/c2/channel" @@ -13,6 +14,7 @@ import ( type Client struct { ConnectAddr string ConnectPort int + channel *channel.Channel } var clientSingleton *Client @@ -28,9 +30,31 @@ func GetClientInstance() *Client { func (shellClient *Client) CreateFlags() { } -func (shellClient *Client) Init(channel channel.Channel) bool { +func (shellClient *Client) Shutdown() bool { + ok := shellClient.Channel().RemoveSessions() + + // we done here + output.PrintfFrameworkStatus("Connection closed") + + return ok +} + +func (shellClient *Client) Channel() *channel.Channel { + return shellClient.channel +} + +func (shellClient *Client) Init(channel *channel.Channel) bool { + if channel.Shutdown == nil { + // Initialize the shutdown atomic. This lets us not have to define it if the C2 is manually + // configured. + var shutdown atomic.Bool + shutdown.Store(false) + channel.Shutdown = &shutdown + } + shellClient.ConnectAddr = channel.IPAddr shellClient.ConnectPort = channel.Port + shellClient.channel = channel if !channel.IsClient { output.PrintFrameworkError("Called SimpleShellClient as a server. Use bport.") @@ -52,15 +76,23 @@ func (shellClient *Client) Run(timeout int) { if !ok { return } - // close the connection when the shell is complete - defer conn.Close() + // Track if the C2 is indicated to shutdown for any reason. + go func() { + for { + if shellClient.Channel().Shutdown.Load() { + shellClient.Shutdown() + + break + } + } + }() output.PrintfFrameworkStatus("Active shell on %s:%d", shellClient.ConnectAddr, shellClient.ConnectPort) + shellClient.Channel().AddSession(&conn, conn.RemoteAddr().String()) - cli.Basic(conn) + cli.Basic(conn, shellClient.channel) - // we done here - output.PrintfFrameworkStatus("Connection closed") + shellClient.Channel().Shutdown.Store(true) } func connect(host string, port int, timeout int) (net.Conn, bool) { diff --git a/c2/simpleshell/simpleshellserver.go b/c2/simpleshell/simpleshellserver.go index 165bda4..608b156 100644 --- a/c2/simpleshell/simpleshellserver.go +++ b/c2/simpleshell/simpleshellserver.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/vulncheck-oss/go-exploit/c2/channel" @@ -19,6 +20,7 @@ import ( // the terminate the connection. type Server struct { Listener net.Listener + channel *channel.Channel } var serverSingleton *Server @@ -36,8 +38,35 @@ func GetServerInstance() *Server { func (shellServer *Server) CreateFlags() { } +// Get the underlying C2 channel with metadata and session information. +func (shellServer *Server) Channel() *channel.Channel { + return shellServer.channel +} + +// Shutdown the C2 and cleanup any active connections. +func (shellServer *Server) Shutdown() bool { + output.PrintFrameworkStatus("C2 received shutdown, killing server and client sockets for shell server") + if len(shellServer.Channel().Sessions) > 0 { + for k, session := range shellServer.Channel().Sessions { + output.PrintfFrameworkStatus("Connection closed: %s", session.RemoteAddr) + shellServer.Channel().RemoveSession(k) + } + } + shellServer.Listener.Close() + + return true +} + // Validate configuration and create the listening socket. -func (shellServer *Server) Init(channel channel.Channel) bool { +func (shellServer *Server) Init(channel *channel.Channel) bool { + if channel.Shutdown == nil { + // Initialize the shutdown atomic. This lets us not have to define it if the C2 is manually + // configured. + var shutdown atomic.Bool + shutdown.Store(false) + channel.Shutdown = &shutdown + } + shellServer.channel = channel if channel.IsClient { output.PrintFrameworkError("Called SimpleShellServer as a client. Use lhost and lport.") @@ -62,18 +91,24 @@ func (shellServer *Server) Run(timeout int) { // mutex for user input var cliLock sync.Mutex - // track if we got a shell or not - success := false - // terminate the server if no shells come in within timeout seconds go func() { time.Sleep(time.Duration(timeout) * time.Second) - if !success { + if !shellServer.Channel().HasSessions() { output.PrintFrameworkError("Timeout met. Shutting down shell listener.") - shellServer.Listener.Close() + shellServer.Channel().Shutdown.Store(true) } }() + // Track if the shutdown is signaled for any reason. + go func() { + for { + if shellServer.Channel().Shutdown.Load() { + shellServer.Shutdown() + break + } + } + }() // Accept arbitrary connections. In the future we need something for the // user to select which connection to make active for { @@ -83,15 +118,17 @@ func (shellServer *Server) Run(timeout int) { output.PrintFrameworkError(err.Error()) } - return + break } - success = true output.PrintfFrameworkSuccess("Caught new shell from %v", client.RemoteAddr()) - go handleSimpleConn(client, &cliLock, client.RemoteAddr()) + // Add the session for tracking first, so it can be cleaned up. + shellServer.Channel().AddSession(&client, client.RemoteAddr().String()) + + go handleSimpleConn(client, &cliLock, client.RemoteAddr(), shellServer.channel) } } -func handleSimpleConn(conn net.Conn, cliLock *sync.Mutex, remoteAddr net.Addr) { +func handleSimpleConn(conn net.Conn, cliLock *sync.Mutex, remoteAddr net.Addr, ch *channel.Channel) { // connections will stack up here. Currently that will mean a race // to the next connection but we can add in attacker handling of // connections latter @@ -101,10 +138,9 @@ func handleSimpleConn(conn net.Conn, cliLock *sync.Mutex, remoteAddr net.Addr) { // close the connection when the shell is complete defer conn.Close() - output.PrintfFrameworkStatus("Active shell from %v", remoteAddr) - - cli.Basic(conn) - - // we done here - output.PrintfFrameworkStatus("Connection closed %v", remoteAddr) + // Only complete the full session handshake once + if !ch.Shutdown.Load() { + output.PrintfFrameworkStatus("Active shell from %v", remoteAddr) + cli.Basic(conn, ch) + } } diff --git a/c2/sslshell/sslshellserver.go b/c2/sslshell/sslshellserver.go index 2f47c76..2342585 100644 --- a/c2/sslshell/sslshellserver.go +++ b/c2/sslshell/sslshellserver.go @@ -23,6 +23,7 @@ import ( "net" "strings" "sync" + "sync/atomic" "time" "github.com/vulncheck-oss/go-exploit/c2/channel" @@ -38,6 +39,8 @@ type Server struct { PrivateKeyFile string // The file path to the user provided certificate (if provided) CertificateFile string + // Upstream c2 channel, used for signaling for shutdowns and status reporting + channel *channel.Channel } var singleton *Server @@ -61,9 +64,36 @@ func (shellServer *Server) CreateFlags() { } } +// Shutdown the C2 and close server and client connections when applicable. +func (shellServer *Server) Shutdown() bool { + output.PrintFrameworkStatus("C2 received shutdown, killing server and client sockets for SSL shell server") + if len(shellServer.channel.Sessions) > 0 { + for k, session := range shellServer.channel.Sessions { + output.PrintfFrameworkStatus("Connection closed: %s", session.RemoteAddr) + shellServer.channel.RemoveSession(k) + } + } + shellServer.Listener.Close() + + return true +} + +// Return the underlying C2 channel with metadata and session information. +func (shellServer *Server) Channel() *channel.Channel { + return shellServer.channel +} + // Parses the user provided files or generates the certificate files and starts // the TLS listener on the user provided IP/port. -func (shellServer *Server) Init(channel channel.Channel) bool { +func (shellServer *Server) Init(channel *channel.Channel) bool { + if channel.Shutdown == nil { + // Initialize the shutdown atomic. This lets us not have to define it if the C2 is manually + // configured. + var shutdown atomic.Bool + shutdown.Store(false) + channel.Shutdown = &shutdown + } + shellServer.channel = channel if channel.IsClient { output.PrintFrameworkError("Called SSLShellServer as a client. Use lhost and lport.") @@ -109,18 +139,23 @@ func (shellServer *Server) Run(timeout int) { // mutex for user input var cliLock sync.Mutex - // track if we got a shell or not - success := false - // terminate the server if no shells come in within timeout seconds go func() { time.Sleep(time.Duration(timeout) * time.Second) - if !success { + if !shellServer.channel.HasSessions() { output.PrintFrameworkError("Timeout met. Shutting down shell listener.") - shellServer.Listener.Close() + shellServer.channel.Shutdown.Store(true) } }() + go func() { + for { + if shellServer.channel.Shutdown.Load() { + shellServer.Shutdown() + break + } + } + }() // Accept arbitrary connections. In the future we need something for the // user to select which connection to make active for { @@ -132,13 +167,16 @@ func (shellServer *Server) Run(timeout int) { return } - success = true output.PrintfFrameworkSuccess("Caught new shell from %v", client.RemoteAddr()) - go handleSimpleConn(client, &cliLock, client.RemoteAddr()) + + // Add the session for tracking first, so it can be cleaned up. + shellServer.Channel().AddSession(&client, client.RemoteAddr().String()) + + go handleSimpleConn(client, &cliLock, client.RemoteAddr(), shellServer.channel) } } -func handleSimpleConn(conn net.Conn, cliLock *sync.Mutex, remoteAddr net.Addr) { +func handleSimpleConn(conn net.Conn, cliLock *sync.Mutex, remoteAddr net.Addr, channel *channel.Channel) { // connections will stack up here. Currently that will mean a race // to the next connection but we can add in attacker handling of // connections latter @@ -148,10 +186,9 @@ func handleSimpleConn(conn net.Conn, cliLock *sync.Mutex, remoteAddr net.Addr) { // close the connection when the shell is complete defer conn.Close() - output.PrintfFrameworkStatus("Active shell from %v", remoteAddr) - - cli.Basic(conn) - - // we done here - output.PrintfFrameworkStatus("Connection closed %v", remoteAddr) + // Only complete the full session handshake once + if !channel.Shutdown.Load() { + output.PrintfFrameworkStatus("Active shell from %v", remoteAddr) + cli.Basic(conn, channel) + } } diff --git a/framework.go b/framework.go index 3651ca3..ea02fc3 100644 --- a/framework.go +++ b/framework.go @@ -65,8 +65,10 @@ import ( "crypto/tls" "fmt" "os" + "os/signal" "strings" "sync" + "sync/atomic" "time" "github.com/vulncheck-oss/go-exploit/c2" @@ -293,11 +295,26 @@ func startC2Server(conf *config.Config) bool { return false } - success = c2Impl.Init(channel.Channel{ + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, os.Interrupt) + + var shutdown atomic.Bool + shutdown.Store(false) + c2channel := &channel.Channel{ IPAddr: conf.Lhost, Port: conf.Lport, IsClient: false, - }) + Shutdown: &shutdown, + } + // Handle the signal interrupt channel. If the signal is triggered, then trigger the done + // channel which will clean up the server and close cleanly. + go func(sigint <-chan os.Signal, channel *channel.Channel) { + <-sigint + output.PrintfFrameworkStatus("Interrupt signal received") + channel.Shutdown.Store(true) + }(sigint, c2channel) + + success = c2Impl.Init(c2channel) if !success { return false } @@ -314,6 +331,8 @@ func startC2Server(conf *config.Config) bool { } // execute verify, version check, and exploit. Return false if an unrecoverable error occurred. +// +//nolint:gocognit func doScan(sploit Exploit, conf *config.Config) bool { // autodetect if the target is using SSL or not if conf.DetermineSSL { @@ -326,12 +345,36 @@ func doScan(sploit Exploit, conf *config.Config) bool { if conf.DoVerify { if !doVerify(sploit, conf) { + // C2 cleanup is meaningless with third party C2s + if !conf.ThirdPartyC2Server { + // Shuts down the C2 if verification fails + c, ok := c2.GetInstance(conf.C2Type) + if !ok { + output.PrintFrameworkError("Could not get C2 configuration") + + return false + } + c.Shutdown() + } + return true } } if conf.DoVersionCheck { if !doVersionCheck(sploit, conf) { + // C2 cleanup is meaningless with third party C2s + if !conf.ThirdPartyC2Server { + // Shuts down the C2 if version check fails + c, ok := c2.GetInstance(conf.C2Type) + if !ok { + output.PrintFrameworkError("Could not get C2 configuration") + + return false + } + c.Shutdown() + } + return true } } @@ -357,7 +400,7 @@ func doScan(sploit Exploit, conf *config.Config) bool { return false } - success = c2Impl.Init(channel.Channel{ + success = c2Impl.Init(&channel.Channel{ IPAddr: conf.Rhost, Port: conf.Bport, IsClient: true,