PhotoLoader ICEDID
Taking a closer look at this ICEDID loader
Overview
Photoloader is the initial loader stage used to load ICEDID,
ICEDID was originally used for banking credential theft with a later pivot as a reconnaissance tool for pre-ransomware intrusions. The webinjects used for credential theft are still active though this malware is most often associated with ransomware incidents.
According to Proofpoint there is a fork of ICEDID that does not have webinject capability and is possibly developed by three separate actors...
Standard IcedID Variant – The variant most commonly observed in the threat landscape and used by a variety of threat actors.
Lite IcedID Variant – New variant observed as a follow-on payload in November Emotet infections that does not exfiltrate host data in the loader checkin and a bot with minimal functionality.
Forked IcedID Variant – New variant observed by Proofpoint researchers in February 2023 used by a small number of threat actors which also delivers the bot with minimal functionality.
References
- DFIRReport:ICEDID -> Quantum ransomware- ICEDIDs network infrastructure is alive and well
 - ICEDID Configuration Extractor
 - Fork in the Ice: The New Era of IcedID
 - icedid_peloader.py
 - New version of IcedID Trojan uses steganographic payloads
 
Samples
Analysis
It looks like the Malpedia photoloader yara rules are a bit too loose and match the newer "gzip" variant of the loader. The config location/encryption is different between these two loaders and photoloader has not been used in a few years. We are going to create a new rule that will be used to only match the newer variants.
Rule
This rule is heavily influenced by the elastic rules in their config extractor
rule icedid_loader {        
    strings:
        $a1 = "; _gat=" wide fullword
        $a2 = "; _ga=" wide fullword
        $a3 = "; _u=" wide fullword
        $a4 = "; __io=" wide fullword
        $a5 = "; _gid=" wide fullword
        $a6 = "loader_dll_64.dll" ascii fullword
        $config_decryption1 = {45 33 C0 4C 8D 0D ?? ?? ?? ?? 49 2B C9 4B 8D 14 08 49 FF C0 8A 42 ?? 32 02 88 44 11 ?? 49 83 F8 }
        $config_decryption2 = { 00 42 8A 44 01 ?? 42 32 04 01 88 44 0D ?? 48 FF C1 48 83 F9 }
        condition:
            filesize < 60000 and
            (
                (3 of ($a*) and $config_decryption1) or
                $config_decryption2
            )
}
Config Extractor
This is a modified version of the elastic config extractor
import pefile
import re
import struct
file_data = open('/tmp/samples/963397cec08790b25ff273cbe4b133634ae045d5ff8a4492e6f585f2ad14db65', 'rb').read()
pe = pefile.PE(data = file_data)
IMAGE_SCN_CNT_CODE = 0x00000020
def xor(data, key):
    out = []
    for i in range(len(data)):
        out.append(data[i] ^ key[i % len(key)])
    return bytes(out)
def is_ascii(s):
    return all((c < 128 and c > 39) or c == 0 for c in s)
key = None
domain = None
campaign_id = None
mapped = False
if pe.sections[0].get_data()[:100] == b'\x00'*100:
    print("Mapped!")
    mapped = True
for s in pe.sections:
    if (s.Characteristics & IMAGE_SCN_CNT_CODE) == 0:
        if mapped:
            section_data = file_data[s.VirtualAddress:s.VirtualAddress +256]
        else:
            section_data = s.get_data()
        if len(section_data) < 250:
            print("Section too small")
            continue
        # This is a hack to skip stuff that doesn't look like a key
        tmp_key = section_data[:32]
        if b'\x00'*10 in tmp_key:
            print("Too many nulls in key")
            continue
        data = section_data[64:96]
        tmp_config = xor(data, tmp_key)
        domain = None
        try:
            domains = tmp_config[4:]
            domains = domains.split(b"\x00")
            if not is_ascii(domains[0]):
                        continue
            domain = domains[0].decode("UTF-8")
        except:
            print("Domain decode error")
            continue
        if len(domain) < 5:
            print("Domain too small")
            continue
        # If we are here we have a config! 
        campaign_id = struct.unpack('<I', tmp_config[:4])[0]
        key = tmp_key.hex()
        break
        
assert key is not None
assert domain  is not None
assert campaign_id is not None
        
config = {
            "campaign_id": campaign_id,
            "domains": domain,
            "key": key,
         }   
        
        
print(config)
def is_ascii(s):
    return all((c < 128 and c > 39) or c == 0 for c in s)
def extract_config(file_path):
    file_data = open(file_path, 'rb').read()
    pe = pefile.PE(data = file_data)
    
    mapped = False
    if pe.sections[0].get_data()[:100] == b'\x00'*100:
        #print("Mapped!")
        mapped = True
    
    key = None
    domain = None
    campaign_id = None
    try:
        for s in pe.sections:
            if (s.Characteristics & IMAGE_SCN_CNT_CODE) == 0:
                if mapped:
                    section_data = file_data[s.VirtualAddress:s.VirtualAddress +256]
                else:
                    section_data = s.get_data()
                if len(section_data) < 250:
                    #print("Section too small")
                    continue
                # This is a hack to skip stuff that doesn't look like a key
                tmp_key = section_data[:32]
                if b'\x00'*10 in tmp_key:
                    #print("Too many nulls in key")
                    continue
                data = section_data[64:96]
                tmp_config = xor(data, tmp_key)
                domain = None
                try:
                    domains = tmp_config[4:]
                    domains = domains.split(b"\x00")
                    if not is_ascii(domains[0]):
                        continue
                    domain = domains[0].decode("UTF-8")
                except:
                    #print("Domain decode error")
                    continue
                if len(domain) < 5:
                    #print("Domain too small")
                    continue
                # If we are here we have a config! 
                campaign_id = struct.unpack('<I', tmp_config[:4])[0]
                key = tmp_key.hex()
                break
        assert key is not None
        assert domain  is not None
        assert campaign_id is not None
        config = {
                    "campaign_id": campaign_id,
                    "domains": domain,
                    "key": key,
                 }   
    except:
        return {}
    return config
# import required module
import os
# assign directory
directory = '/tmp/samples/'
 
# iterate over files in
# that directory
for filename in os.listdir(directory):
    f = os.path.join(directory, filename)
    # checking if it is a file
    if os.path.isfile(f):
        print(f)
        config = extract_config(f)
        print(config)