Biweekly Malware Challenge #2: Extracting IcedID’s Configuration
Aim
The aim for this challenge was to unpack this IcedID binary, figure out how the configuration was stored, and develop a script to automatically extract the config information. So, lets get started!
Approach
There aren’t many options for approaches to this challenge, once you’ve unpacked the sample its a case of locating the configuration, identifying any encryption in use, and then developing a script to extract it. Though, you could take this challenge one step further and develop an automated unpacker, which @matth_walter was able to do using Angr – turns out it’s also able to unpack BazarLoader too! Nice work!
Analysis
The analysis contains the following sections:
- Unpacking
- Locating the Configuration
- Reversing the Crypto
- Scripting Time
Unpacking
First things first, we open it up in PEStudio. The important thing to focus on here is the lack of an entry point in any of the sections. As this is a DLL, it tells us we need to execute the sample through calling one of its exports. If we were to open it directly in x64dbg and execute the debugger, you wouldn’t even be able to jump to user code, as x64dbg won’t know where the user code to jump to is.
Checking out the exports, we can see DllRegisterServer, along with 6 others that have scrambled names. Now the assumption I would make here is better to focus on DllRegisterServer, as a threat actor will want to avoid unnecessary attention from SOC teams; if an SOC team sees rundll32.exe running with the argument HdQZgnE, they’ll probably be very suspicious and investigate further. Therefore, we’ll be using the argument DllRegisterServer for rundll32.exe, and worst case we can always modify it later.
Loading rundll32.exe into x64dbg, head to File and click Change Command Line. Here we will add the path to the IcedID DLL, as well as the argument DllRegisterServer.
Then, head to Preferences, and enable the Break on DLL Load option; this will break once IcedID has been loaded by rundll32.exe, and we can make sure everything is executing as expected.
Running the debugger, sure enough we can see the DLL is loaded just fine, so we can go back to the -Preferences window, and disable Break on DLL Load. From here, we set two breakpoints – VirtualAlloc and VirtualProtect.
There will be a breakpoint hit on VirtualAlloc, so follow that allocated memory in the dump and run once more. The next breakpoint you should hit is one on VirtualProtect, and the allocated memory should now be filled with another DLL.
All that is left to do is unmap the payload from memory, and once we have you should be able to see in the exports tab of PE-Bear the internal build name loader_dll_64.dll – along with resolved imports. Now all that is left to do is locate the configuration, figure out the crypto, and develop an extractor!
Locating the Configuration
The sample itself is much less obfuscated than other major malware families (*cough cough* Emotet), but it can still be a pain to work your way through.
As the goal is to locate the configuration, I turned my attention to the regions of raw undefined data. Scrolling to the very end, as the first two regions of undefined data had no major cross references, I found the following in the .d section, which was referenced within the .text section.
Sure enough, checking out this cross reference, we can see it is found in this function that will interact with the data inside the .d section, specifically XORing that data – this is most likely the configuration decryption function, or worst case some kind of string decryption function that will lead us to a configuration.
Finding the cross reference to this decryption function, we end up near the very beginning of the IcedID execution flow – just after the Sleep loop. With that, lets go ahead and decrypt the data to see if it is indeed a configuration or if it is just encrypted strings.
Reversing the Crypto
As discussed, the function is a simple XOR loop. The following occurs on each iteration:
- Memory address of encrypted_data + i is moved into RDX
- Byte at encrypted_data + 0x40 is moved into AL
- AL is XORed with byte pointed to by RDX
- Byte at encrypted_data + i + 0x40 is overwritten with AL
- R8 is compared against 0x20
This tells us that the first 0x40 bytes are treated as an XOR key, while the remaining 32 bytes make up the important encrypted data.
Checking the decrypted output using CyberChef, we can see there is a URL present within the decrypted data, so this is most likely going to be the configuration data.
Querying MalwareBazaar for it confirms it is definitely an IcedID C2 server, so now we can go ahead and script the extraction!
Scripting Time
As per usual, we start off with the main() function. This will load in the binary using the pefile module, as well as read the file data into memory. We then iterate through the sections, searching for the .d section (specify the \x00 at the end as well, otherwise it will trigger on .data), and once we’ve located it we acquire the relevant encrypted blob from the binary data read into memory. We’ll pass this into a XOR decrypt function, before parsing it, and returning.
def main():
binary = "unmapped_iced.bin"
with open(binary, "rb") as f:
binaryData = f.read()
pe = pefile.PE(binary)
configData = None
for section in pe.sections:
if b".d\x00" in section.Name:
configData = binaryData[section.PointerToRawData:section.PointerToRawData + section.SizeOfRawData]
if configData != None:
decryptedBlob = xorDecrypt(configData)
parseConfig(decryptedBlob)
return
if __name__ == "__main__":
main()
The XOR decryption is simple enough, take the first 64 bytes and treat it as a key, and then take the rest of the data and treat it as encrypted data. We iterate through the data, XORing it and storing the result in the decryptedData variable which will be returned.
def xorDecrypt(configData):
decryptedData = b""
xorKey = configData[:64]
xorData = configData[64:]
for i in range(0, len(xorKey)):
decryptedData += bytes([xorKey[i] ^ xorData[i]])
return decryptedData
The IcedID configuration doesn’t just contain a URL, it also contains a campaign identifier, which is the first DWORD in the decrypted data, so we will account for this in our parsing function.
Parsing is simple enough, take the first DWORD, convert it to an integer, and you’ve got the campaign ID. Then, take the remaining data, and split based on a null byte, selecting the first part of the split data; this is the C2 URL.
def parseConfig(configData):
campaignID = struct.unpack("I", configData[:4])[0]
campaignC2 = configData[4:].split(b"\x00")[0].decode()
print ("Campaign ID: %d" % campaignID)
print ("Campaign C2: %s" % campaignC2)
Running the script should give you the following, which can be cross referenced against sites like tria.ge to confirm it’s working!
And thats the challenge complete! The full code can be seen below, and feel free to share your write-up within the Discord channel or via your own blog post!
Make sure to keep an eye out for the next challenge!
Code
import struct, pefile
def xorDecrypt(configData):
decryptedData = b""
xorKey = configData[:64]
xorData = configData[64:]
for i in range(0, len(xorKey)):
decryptedData += bytes([xorKey[i] ^ xorData[i]])
return decryptedData
def parseConfig(configData):
campaignID = struct.unpack("I", configData[:4])[0]
campaignC2 = configData[4:].split(b"\x00")[0].decode()
print ("Campaign ID: %d" % campaignID)
print ("Campaign C2: %s" % campaignC2)
def main():
binary = "unmapped_iced.bin"
with open(binary, "rb") as f:
binaryData = f.read()
pe = pefile.PE(binary)
configData = None
for section in pe.sections:
if b".d\x00" in section.Name:
configData = binaryData[section.PointerToRawData:section.PointerToRawData + section.SizeOfRawData]
if configData != None:
decryptedBlob = xorDecrypt(configData)
parseConfig(decryptedBlob)
return
if __name__ == "__main__":
main()