Chapter 4: Operator CLI Client

Creating a CLI client to interact with the listening post and implant.

Introduction

In this chapter, we'll be building out our operator CLI client called Fireworks. The CLI client will allow us to interact with our listening post via the REST API and serve as an easy way for operators to submit new taskings/view results in a command prompt. The actions will include:

  • list-tasks

    • Lists the tasks that are being served to the implant

  • add-tasks --tasktype <task_type> --options <key>=<value>

    • Submit tasks to the listening post

  • list-results

    • List the results returned from the implant

  • list-history

    • List the history of tasks and their associated results

The reason why we're building a CLI and not a web interface is mainly for reasons of rapid testing and experimentation. I would personally prefer to begin with an interface that's easy to build and test with before I start putting in the time to build out a web interface that may end up being more complicated. When we're confident that our C2 is functioning well with the CLI and we're satisfied with the various core user flows, then we can take the steps towards building a web interface with something like Vue.js or Bootstrap.

Okay, let's get started! Open the folder called "chapter4-1" and you'll find the following code in the file fireworks.py. Install the requirements by running pip install -r requirements.txt to ensure you have all the prerequisites installed. We'll start by going through each code block and try to understand how the CLI client is working:

import click
import requests
import pprint
import json

# Configuration Settings
listening_post_addr = "http://127.0.0.1:5000"

# Helper functions
def api_get_request(endpoint):
    response_raw = requests.get(listening_post_addr + endpoint).text
    response_json = json.loads(response_raw)
    return response_json

# CLI commands and logic
@click.group()
def cli():
    pass

@click.command(name="list-tasks")
def list_tasks():
    """Lists the tasks that are being served to the implant."""
    api_endpoint = "/tasks"
    print("\nHere's the tasks:\n")
    pprint.pprint(api_get_request(api_endpoint))
    print()

@click.command(name="list-results")
def list_results():
    """List the results returned from the implant."""
    api_endpoint = "/results"
    print("\nHere's the results:\n")
    pprint.pprint(api_get_request(api_endpoint))
    print()

@click.command(name="list-history")
def list_history():
    """List the history of tasks and their associated results."""
    api_endpoint = "/history"
    print("\nHere's the tasks history:\n")
    pprint.pprint(api_get_request(api_endpoint))
    print()

# Add commands to CLI
cli.add_command(list_tasks)
cli.add_command(list_results)
cli.add_command(list_history)

if __name__ == '__main__':
    cli()

Boilerplate

First, let's get some of the boilerplate out of the way with the following block:

# Configuration Settings
listening_post_addr = "http://127.0.0.1:5000"

# Helper functions
def api_get_request(endpoint):
    response_raw = requests.get(listening_post_addr + endpoint).text
    response_json = json.loads(response_raw)
    return response_json

# CLI commands and logic
@click.group()
def cli():
    pass

We set a variable that holds the address of our listening post and we declare a function for making an HTTP GET request to our API endpoints. The "api_get_request" function accepts the API endpoint as an argument and uses it to make a GET request using the Python Requests library. It stores the response from the listening post in a variable called "response_raw". Then, we parse the result as a JSON object and store it into "response_json". This is what we will return to the user. We then start our section for CLI commands and logic. We're using the Python Click library to build our CLI client and the "@click.group()" syntax is used to group together commands. We then declare a "cli" function that will simply pass and allow us to execute various commands based on the input provided by the user.

CLI List Commands

In the following code block, we define each of the implant "list" commands that consist of a simple GET request to the listening post:

@click.command(name="list-tasks")
def list_tasks():
    """Lists the tasks that are being served to the implant."""
    api_endpoint = "/tasks"
    print("\nHere's the tasks:\n")
    pprint.pprint(api_get_request(api_endpoint))
    print()

@click.command(name="list-results")
def list_results():
    """List the results returned from the implant."""
    api_endpoint = "/results"
    print("\nHere's the results:\n")
    pprint.pprint(api_get_request(api_endpoint))
    print()

@click.command(name="list-history")
def list_history():
    """List the history of tasks and their associated results."""
    api_endpoint = "/history"
    print("\nHere's the tasks history:\n")
    pprint.pprint(api_get_request(api_endpoint))
    print()

The overall format of each list command is roughly the same. We specify the name of the command that the user will type on the command line with @click.command(name="list-tasks") and then start writing the function code for that command. We write out some help text that will be displayed for the command if the user types "--help" and declare what the API endpoint is. Lastly, we pretty print out the results of calling the List API with a GET request.

To close out this initial CLI client, we need to add the following.

# Add commands to CLI
cli.add_command(list_tasks)
cli.add_command(list_results)
cli.add_command(list_history)

if __name__ == '__main__':
    cli()

The above code block adds the list commands to the CLI and we then specify the main function. That's all we need to start testing out the CLI client, let's get ready for a test drive.

List Commands Test Drive

Let's try out this client now by starting up the listening post. You can navigate to the "Skytree" folder to find the listening_post.py file and then run the command python listening_post.py. When that's running, start the implant. You can navigate to the "RainDoll" folder and use the Visual Studio solution file to build a new RainDoll.exe binary or you can run a binary from an earlier chapter. With both the listening post and the implant running, we're ready to run a list command with the CLI client using the following:

python fireworks.py list-tasks

You should see the following response:

Here's the tasks:

[]

You can get help for each of the commands that are supported with:

python fireworks.py --help

You'll get back:

Usage: fireworks.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  list-history  List the history of tasks and their associated results.
  list-results  List the results returned from the implant.
  list-tasks    Lists the tasks that are being served to the implant.  

You can run any of the list commands and you should get back a 200 OK from the listening post. So far, this CLI client isn't terribly useful. But, at least we know it's able to successfully make requests to the listening post API and get back responses. Let's add a mutating command that's a bit more complicated by implementing the Add Tasks command.

CLI Add Tasks Command

Open the folder called "chapter4-2" and you'll find the following updated code in the file fireworks.py. We'll go through each code block and try to understand how the Add Tasks command is working:

import click
import requests
import pprint
import json

# Configuration Settings
listening_post_addr = "http://127.0.0.1:5000"

# Helper functions
def api_get_request(endpoint):
    response_raw = requests.get(listening_post_addr + endpoint).text
    response_json = json.loads(response_raw)
    return response_json

def api_post_request(endpoint, payload):
    response_raw = requests.post(listening_post_addr + endpoint, json=payload).text
    response_json = json.loads(response_raw)
    return response_json

# CLI commands and logic
@click.group()
def cli():
    pass

@click.command(name="list-tasks")
def list_tasks():
    """Lists the tasks that are being served to the implant."""
    api_endpoint = "/tasks"
    print("\nHere's the tasks:\n")
    pprint.pprint(api_get_request(api_endpoint))
    print()

@click.command(name="list-results")
def list_results():
    """List the results returned from the implant."""
    api_endpoint = "/results"
    print("\nHere's the results:\n")
    pprint.pprint(api_get_request(api_endpoint))
    print()

@click.command(name="list-history")
def list_history():
    """List the history of tasks and their associated results."""
    api_endpoint = "/history"
    print("\nHere's the tasks history:\n")
    pprint.pprint(api_get_request(api_endpoint))
    print()

@click.command(name="add-tasks")
@click.option('--tasktype', help='Type of task to submit.')
@click.option('--options', help='Key-value options for task.')
def add_tasks(tasktype, options):
    """Submit tasks to the listening post."""
    api_endpoint = "/tasks"
    print("\nHere are the tasks that were added:\n")

    # Perform options parsing if user provided them to the task
    if options != None:
        task_options_dict = {}
        task_options_pairs = options.split(",")

        # For each key-value pair, add them to a dictionary
        for option in task_options_pairs:
            key_vals = option.split("=")
            key = key_vals[0]
            value = key_vals[1]
            pair = {key:value}
            task_options_dict.update(pair)

        # If more than one option was provided, format and append them into a single string
        if len(task_options_dict) > 1:
            keyval_string = ""
            for key,value in task_options_dict.items():
                keyval_string += f'"{key}":"{value}",'
            request_payload_string = f'[{{"task_type":"{tasktype}",{keyval_string[:-1]}}}]'
            request_payload = json.loads(request_payload_string)
            pprint.pprint(api_post_request(api_endpoint, request_payload))
        # Otherwise, just print the key/value for the single option provided
        else:
            request_payload_string = f'[{{"task_type":"{tasktype}","{key}":"{value}"}}]'
            request_payload = json.loads(request_payload_string)
            pprint.pprint(api_post_request(api_endpoint, request_payload))
    # Otherwise, we just submit a payload with the task type specified
    else:
        request_payload_string = f'[{{"task_type":"{tasktype}"}}]'
        request_payload = json.loads(request_payload_string)
        pprint.pprint(api_post_request(api_endpoint, request_payload))
    print()

# Add commands to CLI
cli.add_command(list_tasks)
cli.add_command(list_results)
cli.add_command(list_history)
cli.add_command(add_tasks)

if __name__ == '__main__':
    cli()

For the Add Tasks command, we need to be able to make POST requests to the listening post:

def api_post_request(endpoint, payload):
    response_raw = requests.post(listening_post_addr + endpoint, json=payload).text
    response_json = json.loads(response_raw)
    return response_json

In the above code block, we define another helper function that uses the Python Requests library to make the POST request. It accepts an "endpoint" parameter and a new "payload" parameter that will hold our JSON request body. In the "requests.post" method, we specify the "json" parameter as containing our payload. Next, we parse the response from the listening post as JSON and return this to the user.

Now, we're ready to go over the Add Tasks command and the way that we build the POST request body:

@click.command(name="add-tasks")
@click.option('--tasktype', help='Type of task to submit.')
@click.option('--options', help='Key-value options for task.')
def add_tasks(tasktype, options):
    """Submit tasks to the listening post."""
    api_endpoint = "/tasks"
    print("\nHere are the tasks that were added:\n")

    # Perform options parsing if user provided them to the task
    if options != None:
        task_options_dict = {}
        task_options_pairs = options.split(",")

        # For each key-value pair, add them to a dictionary
        for option in task_options_pairs:
            key_vals = option.split("=")
            key = key_vals[0]
            value = key_vals[1]
            pair = {key:value}
            task_options_dict.update(pair)

        # If more than one option was provided, format and append them into a single string
        if len(task_options_dict) > 1:
            keyval_string = ""
            for key,value in task_options_dict.items():
                keyval_string += f'"{key}":"{value}",'
            request_payload_string = f'[{{"task_type":"{tasktype}",{keyval_string[:-1]}}}]'
            request_payload = json.loads(request_payload_string)
            pprint.pprint(api_post_request(api_endpoint, request_payload))
        # Otherwise, just print the key/value for the single option provided
        else:
            request_payload_string = f'[{{"task_type":"{tasktype}","{key}":"{value}"}}]'
            request_payload = json.loads(request_payload_string)
            pprint.pprint(api_post_request(api_endpoint, request_payload))
    # Otherwise, we just submit a payload with the task type specified
    else:
        request_payload_string = f'[{{"task_type":"{tasktype}"}}]'
        request_payload = json.loads(request_payload_string)
        pprint.pprint(api_post_request(api_endpoint, request_payload))
    print()

The first part of this code block is mostly boilerplate that's similar to the previous commands we added:

@click.command(name="add-tasks")
@click.option('--tasktype', help='Type of task to submit.')
@click.option('--options', help='Key-value options for task.')
def add_tasks(tasktype, options):
    """Submit tasks to the listening post."""
    api_endpoint = "/tasks"
    print("\nHere are the tasks that were added:\n")

We specify the name of the command and the options we're supporting. In the case of Add Tasks, we want to allow the user to select the task type and the options (if any) that the task type requires. We specify the endpoint and then print out a short message. We can now jump into the bulk of the command code:

# Perform options parsing if user provided them to the task
if options != None:
    task_options_dict = {}
    task_options_pairs = options.split(",")

    # For each key-value pair, add them to a dictionary
    for option in task_options_pairs:
        key_vals = option.split("=")
        key = key_vals[0]
        value = key_vals[1]
        pair = {key:value}
        task_options_dict.update(pair)

    # If more than one option was provided, format and append them into a single string
    if len(task_options_dict) > 1:
        keyval_string = ""
        for key,value in task_options_dict.items():
            keyval_string += f'"{key}":"{value}",'
        request_payload_string = f'[{{"task_type":"{tasktype}",{keyval_string[:-1]}}}]'
        request_payload = json.loads(request_payload_string)
        pprint.pprint(api_post_request(api_endpoint, request_payload))
    # Otherwise, just print the key/value for the single option provided
    else:
        request_payload_string = f'[{{"task_type":"{tasktype}","{key}":"{value}"}}]'
        request_payload = json.loads(request_payload_string)
        pprint.pprint(api_post_request(api_endpoint, request_payload))

In the above code block, we have an if-conditional that checks to see if any options were provided for the task. If they were, we declare some variables to hold the task options dictionary of key-values and a variable to split up the key-value pairs. Next, we have a for-loop that splits each key-value pair and adds them to the task options dictionary as a new entry. Now, with our task options dictionary all filled out, we have another if-conditional that checks to see if we have more than one option. If we have several task options, we need to concatenate each key-value option and then add this string to our request payload. We then make the POST request and pretty print the listening post response. Otherwise, if we have a single task option, we can just use the key-value immediately without any for-loop and make the POST request.

In the case that no task options were provided, we have the following:

# Otherwise, we just submit a payload with the task type specified
else:
    request_payload_string = f'[{{"task_type":"{tasktype}"}}]'
    request_payload = json.loads(request_payload_string)
    pprint.pprint(api_post_request(api_endpoint, request_payload))
    print()

With the above code block, we don't need to parse any task options and can just make a POST request with the task type. We then pretty print the listening post response.

The last part that needs to be modified is the section that adds each command to the CLI:

# Add commands to CLI
cli.add_command(list_tasks)
cli.add_command(list_results)
cli.add_command(list_history)
cli.add_command(add_tasks)

As you can see, we've got a new line that specifies "add_tasks". With that final change, we're ready to test out our newly added command.

Add Tasks Test Drive

Ensure that the listening post and implant are both running. Now, run the following command to add a Ping task:

python fireworks.py add-tasks --tasktype ping

You should get back a response similar to the following if things have gone well:

Here are the tasks that were added:

[{'_id': {'$oid': '5f6ef011363fb42173b13a4e'},
  'task_id': '1fcbd838-e800-4da3-9765-b4a15656abb7',
  'task_type': 'ping'}]

You can wait a few moments and then run the following to see the results:

python fireworks.py list-results

You'll get back:

Here's the results:

[{'1fcbd838-e800-4da3-9765-b4a15656abb7': {'contents': 'PONG!',
                                           'success': 'true'}, 
  '_id': {'$oid': '5f6ef025363fb42173b13a50'},
  'result_id': '02848048-9179-4b6b-b9b6-3aa7db2e5281'}]

Let's try adding a task with an option now. Run the following to add an Execute task with a command of "ping google.com":

python fireworks.py add-tasks --tasktype execute --options command="ping google.com"

You'll get back:

Here are the tasks that were added:

[{'_id': {'$oid': '5f6ef162363fb42173b13a51'},      
  'command': 'ping google.com',
  'task_id': '70492646-6ff1-490a-82de-1d703dfb1bf2',
  'task_type': 'execute'}]

You can run the List Results command again to see the following:

Here's the results:

[{'1fcbd838-e800-4da3-9765-b4a15656abb7': {'contents': 'PONG!',
                                           'success': 'true'},
  '_id': {'$oid': '5f6ef025363fb42173b13a50'},
  'result_id': '02848048-9179-4b6b-b9b6-3aa7db2e5281'},
 {'70492646-6ff1-490a-82de-1d703dfb1bf2': {'contents': '\n'
                                                       'Pinging google.com '
                                                       '[172.217.164.206] with '
                                                       '32 bytes of data:\n'
                                                       'Reply from '
                                                       '172.217.164.206: '
                                                       'bytes=32 time=3ms '
                                                       'TTL=115\n'
                                                       'Reply from '
                                                       '172.217.164.206: '
                                                       'bytes=32 time=3ms '
                                                       'TTL=115\n'
                                                       'Reply from '
                                                       '172.217.164.206: '
                                                       'bytes=32 time=3ms '
                                                       'TTL=115\n'
                                                       'Reply from '
                                                       '172.217.164.206: '
                                                       'bytes=32 time=3ms '
                                                       'TTL=115\n'
                                                       '\n'
                                                       'Ping statistics for '
                                                       '172.217.164.206:\n'
                                                       '    Packets: Sent = 4, '
                                                       'Received = 4, Lost = 0 '
                                                       '(0% loss),\n'
                                                       'Approximate round trip '
                                                       'times in '
                                                       'milli-seconds:\n'
                                                       '    Minimum = 3ms, '
                                                       'Maximum = 3ms, Average '
                                                       '= 3ms\n',
                                           'success': 'true'},
  '_id': {'$oid': '5f6ef169363fb42173b13a53'},
  'result_id': '334e866e-d424-4081-a941-d8eda35b0ea5'}]

You can see that the results of the ping were successfully populated and we've confirmed that our CLI client is working properly with the new Add Tasks command!

Conclusion

We now have a more comfortable interface from which to send tasks and see results, quite a bit of a step up from pasting PowerShell commands right? With this piece, we've got a fully functional C2 setup with an operator interface, listening post and implant. Each of the pieces are relatively simple, but that just gives us more room to build on and expand with more advanced components later on.

Further Reading & Next Steps

To learn more about CLI clients for C2, check out the following projects which employ CLI clients in an excellent fashion:

Last updated