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
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 clickimport requestsimport pprintimport json# Configuration Settingslistening_post_addr ="http://127.0.0.1:5000"# Helper functionsdefapi_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()defcli():pass@click.command(name="list-tasks")deflist_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")deflist_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")deflist_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 CLIcli.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:
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")deflist_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")deflist_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")deflist_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 CLIcli.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:
pythonfireworks.pylist-tasks
You should see the following response:
Here's the tasks:[]
You can get help for each of the commands that are supported with:
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 clickimport requestsimport pprintimport json# Configuration Settingslistening_post_addr ="http://127.0.0.1:5000"# Helper functionsdefapi_get_request(endpoint): response_raw = requests.get(listening_post_addr + endpoint).text response_json = json.loads(response_raw)return response_jsondefapi_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()defcli():pass@click.command(name="list-tasks")deflist_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")deflist_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")deflist_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.')defadd_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 taskif options !=None: task_options_dict ={} task_options_pairs = options.split(",")# For each key-value pair, add them to a dictionaryfor 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 stringiflen(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 providedelse: 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 specifiedelse: 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 CLIcli.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:
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.')defadd_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 taskifoptions!=None:task_options_dict={}task_options_pairs=options.split(",")# For each key-value pair, add them to a dictionaryfor option intask_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 stringiflen(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.')defadd_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 taskif options !=None: task_options_dict ={} task_options_pairs = options.split(",")# For each key-value pair, add them to a dictionaryfor 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 stringiflen(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 providedelse: 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 specifiedelse: 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 CLIcli.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:
pythonfireworks.pyadd-tasks--tasktypeping
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:
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: