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)