本帖最后由 梦幻的彼岸 于 2024-2-18 10:56 编辑
翻译:梦幻的彼岸
原文地址:https://malcat.fr/blog/writing-a ... ractor-with-malcat/ | 73472cfc52f2732b933e385ef80b4541191c45c995ce5c42844484c33c9867a3.msi ( Bazaar, VT) | | MSI installer -> Backdoored DLL -> PE loader -> Qakbot | | | | | 简介
但在起死回生后,该 RAT 也切换到了新版本:5.0。遗憾的是,现有的 Qakbot 配置提取器停止工作了(据我所知),这表明恶意软件代码发生了非同小可的变化。这相对来说比较恼人:配置提取器对于僵尸网络跟踪和事件响应非常有用。但与其抱怨,不如让我们启动 Malcat,看看能否自己编写一个配置提取器!
第一阶段: MSI installer
感谢 Malware Bazaar,我们很容易就找到了 Qakbot 的最新样本。这个样本碰巧是一个 MSI 安装程序。MSI 安装程序经常被恶意软件作者滥用来打包他们的恶意程序。因此,让我们在 Malcat 中加载该文件,看看有什么发现。在摘要视图中,我们首先看到的是一个 "Acrobat "安装程序。 图 1:安装程序
当然在分析 MSI 安装程序时,首先要看的是CustomAction表,它在一定程度上驱动着安装过程。幸运的是,Malcat full & pro 可以在反编译视图中显示所有 MSI 表格的内容。只需按F4并向下滚动到CustomAction表(表按字母顺序排序)。LaunchFile项尤其有趣,其中确实有一个运行名为viewer.exe的程序,命令行非常可疑: 1
2
3
4
5
6
7
| {
"Action": "LaunchFile",
"Type": 2,
"Source": "viewer.exe",
"Target": "/HideWindow rundll32 [APPDIR]\\MicrosoftOffice15\\ClientX64\\[ProductName].dll,CfGetPlatformInfo",
"ExtendedType": null
}
|
我们首先来看看这个viewer.exe。根据我的经验,MSI 安装程序中有两种类型的文件:
- 安装过程中需要的文件:图片、插件、工具等。这些文件存储在二进制数据库中。Malcat 会在虚拟文件系统选项卡中将其列为Binary.<文件名>。
- 永久安装到磁盘的文件:这些文件存储在 CAB 存档中,如本安装程序中的disk1.cab文件。
我们的文件viewer.exe似乎属于第一种类型,我们只需在 "虚拟文件系统"选项卡中双击Binary.viewer.exe即可打开它。快速威胁情报哈希值查询(Ctrl+I或摘要视图中的 "检查情报"按钮)提示我们,该文件可能是一个简单的第三方启动器: 图 2:viewer.exe
下一个疑点是目标属性中引用的 DLL。我们没有 DLL 的名称,但幸运的是,在disk1.cab 文件中有一个名为dll_1的 DLL 文件。要打开它,只需双击disk1.cab,然后双击dll_1。我们现在正面临感染的第二阶段。
第二阶段: Antimalw.dll
文件dll_1是一个 922KB 的 PE DLL,sha256 为a59707803f3d94ed9cb429929c832e9b74ce56071a1c2086949b389539788d8a(Virusshare, VT),名为antimalw.dll(版本信息)或antimalware_provider64.dll(导出名称)。该文件立即引起我们的怀疑:
- 它声称是 Bitdefender 的 AMSI 提供商,即 Bitdefender 杀毒软件的脚本扫描组件。antimalw.dll包含 Bitdefender 原始 DLL 的部分内容,但显然不是。
- 它的数据目录显示它是用证书签名的,但证书的位置已被.rsrc部分覆盖。
- 它有一个名为ЬГнЦИРИ的大型高熵资源
- 其入口函数为空
- 它只有一个导出函数CfGetPlatformInfo,但似乎被混淆了
看起来,恶意软件的作者获取了 Bitdefender 的antimalware_provider64.dll,并用恶意代码对其进行了回溯/覆盖。 图 3:一个可疑的 DLL
既然我们已经确认文件是恶意的,那就言归正传吧。面对打包的恶意软件,我采取的第一步是一个我称之为 Where is the poop, Robin的过程。你看,没有什么魔法:恶意软件必须将其有效载荷存储在某个地方(当然,除非它们是下载工具)。因此,与其盲目深入代码或将二进制文件提交到缓慢的沙盒中,最好的办法往往是先找到加密的有效载荷。找到隐藏的有效载荷可以让你立即解密,或者在最坏的情况下,为你提供开始逆向工程的有用指针。
大型高熵资源 ЬГнЦИРИ似乎是我们开始搜索的好对象。在十六进制视图中滚动浏览其字节,我们可以在文件末尾附近看到一个重复模式。这通常暗示着某种旋转密钥加密机制。由于文件末尾为零的可能性很大,而且我们知道恶意软件作者喜欢使用 XOR 加密,因此我们只需尝试使用密钥"HU03!Mm!?qYHCTnaEX<\0"(注意末尾的空字节)解除 XOR 加密即可。顺便提一下,在导出函数CfGetPlatformInfo 中,该字符串作为堆栈字符串出现,这一点令人鼓舞: 图 4:对资源进行解密
事实上,我们已经成功解密了资源。XOR 加密万岁!
第三阶段: PE loader
我们现在面对的是一个看起来像 180KB x64 shellcode 的文件(sha2568c7401218e6da9533d4e97849ad6c528b231c1b9cdcf43d1788757c3862dc2d4)。现在有两种方法。最简单的方法就是模拟 shellcode,具体步骤如下:
- 将架构强制为 x64
- 选择 shellcode 的第一个字节并在此定义新函数
- 使用 Malcat 的模拟器脚本试试运气,例如运行脚本emulation/Speakeasy (shellcode)
另一方面,Malcat 从 180 KB 的外壳代码中雕刻出了一个 170KB 的纯文本 PE 文件。因此,让我们采取简单的方法,只需双击雕刻好的 PE 文件,即可进入下一阶段:
图 5:shellcode 及其嵌入的 PE 文件 第四阶段: the Qakbot DLL
下一阶段是名为cldapi.dll 的 170KB PE dll,其 sha256 为af6a9b7e7aefeb903c76417ed2b8399b73657440ad5f8b48a25cfe5e97ff868f(Virusshare, VT)。我们面对的是感染链的最后阶段:一个编译于 2024-01-29 的Qakbot恶意软件,因此很可能是新的 5.0 版本之一!
我们如何确定这是最终的恶意软件?通常,我倾向于使用 Malpedia 的 Yara 规则进行确认,但遗憾的是,他们的Yara 规则似乎并不涵盖新版 Qakbot。但如果我们将cldapi.dll样本与 2023 年 3 月的 Qakbot 版本(例如 这个版本)进行比较,就会发现即使某些字符串被更改或加密,但大多数字符串仍然存在:
图 6:字符串与 2023 年 3 月 Qakbot 样本的比较
除了 Qakbot 属性外,我们还可以看到 DLL 稍微被混淆了:
- API 地址在运行时通过哈希值动态解析(哈希值已加密)
- 大多数字符串都已加密
- 这里或那里有一些垃圾代码岛
虽然在我们的案例中,API 混淆并不是什么大问题,但如果我们要编写配置提取器,字符串加密可能会有问题。这将是我们的第一项任务:定位并解密 Qakbot 的字符串。
解密字符串查找第一个加密字符串数组
虽然 Qakbot 并不是一个巨大的恶意软件,但要逆转超过 120KB 的代码总是很繁琐。由于我们要找的是相当精确的东西,即加密数据块,因此我们将再次把重点放在数据上,而不是深入代码。更确切地说,我们将尝试找到任意数据部分的所有数据缓冲区,它们是:
- 相对较大,例如超过 64 字节
- 具有高熵
- 有传入代码引用
Malcat 偶然发现了一些已知的常量数组,如嵌入式 Zlib 库使用的预计算表,这为我们节省了一些时间,因为我们对这些缓冲区并不感兴趣。从地址0x180028150 开始,我们可以看到一些候选缓冲区。前三个缓冲区看起来很有希望(为了清楚起见,我们给它们起了名字并用颜色标出):
图 7:加密缓冲区Candidates
这三个缓冲区都被同一个函数sub_180002ab8引用,我们将其重命名为decrypt_string_1。这个函数看起来就像典型的字符串解密函数:如下所示,它有许多输入引用,每次调用都有一个不同的硬编码参数。这个参数很有可能是一个字符串索引: 1
2
3
4
5
| void decrypt_string_1(xunknown4 string_index)
{
decrypt_aes_plus_xor(ENCRYPTED_STRINGS_1, 0x5ad, AES_ENCRYPTED_XOR_KEY, 0xd0, AES_PASSWORD, 0x63, string_index);
return;
}
|
图 8:第一个字符串解密函数的上下文内容
函数decrypt_string_1非常简单:它调用一个名为decrypt_aes_plus_xor的辅助函数,并将加密后的三个缓冲区作为参数。其反编译代码(F4)如下:
每个变量的值如下:
[td]名称 | 地址 | 字节大小 | 描述 | decrypt_strings_1 | 0x180002ab8 | 0x3f | Decryption function for the first encrypted strings array | STRINGS_1 | 0x1800282a0 | 0x5ad | First encrypted strings array | AES_ENCRYPTED_XOR_KEY | 0x1800281c0 | 0xd0 | The XOR key used to decrypt the string array, but AES256-CBC encrypted | AES_PASSWORD | 0x180028150 | 0x63 | The password used to derive the AES256 key for AES_ENCRYPTED_XOR_KEY | decrypt_aes_plus_xor | 0x18000dc2c | 0x1de | The function that decrypts the string array and selects the string | aes_encrypt_decrypt_iv_prefix | 0x180011504 | 0x3f7 | A function called by decrypt_aes_plus_xor that decrypts or encrypts an arbitrary data buffer using AES256 in CBC mode | 解密字符串
要获得decrypt_aes_plus_xor函数的功能,需要进行一些逆向工程。由于代码相对较短,你可以静态地完成它,不过会遇到一些问题,因为 API 是动态解析的。使用调试器跟踪函数是更明智的选择。总之,最后的工作相对简单,字符串解密例程看起来就像这样:
图 9:如何解密字符串
好消息是,我们已经在 Malcat 中获得了所需的所有材料!事实上,Malcat 已经有了一个名为CryptDeriveKey 的数据转换。实际上,我们根本不需要它:在这种特定配置下,CryptDeriveKey只是计算密码的SHA256哈希值,并直接将其用作密钥。至于CryptDecrypt:它在 CBC 模式下执行简单的 AES 256 解密,我们也有一个用于此的转换。
注意:Advapi32.dll 加密函数默认添加/删除填充,因此请务必在转换窗口中勾选 "unpad"。
因此,只需使用 Malcat 转换,我们就能在几秒钟内手动解密字符串,如下图所示: 图 10:利用 Malcat 变换解密 th 字符串
结果如下:
SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileListProgramDatanetstat -nao%s "$%s = \"%s\"; & $%s"net localgrouppowershell.exeroute print"%s\system32\schtasks.exe" /Create /ST %02u:%02u /RU "NT AUTHORITY\SYSTEM" /SC ONCE /tr "%s" /Z /ET %02u:%02u /tn %sComponent_08ERROR: GetModuleFileNameW() failed with error: ERROR_INSUFFICIENT_BUFFERnet viewipconfig /allSelf checkT2X!wWMVH1UkMHD7SBdbgfgXrNBd(5dmRNbBI94Lm7DW&yMF*ELN4D8oNp0CtKUf*C2LAstORIBVStart screenshot%s.%uadrclient.dllnet shareqwinsta\System32\WindowsPowerShell\v1.0\powershell.exeat.exe %u:%u "%s" /ISelf test FAILED!!!Component_07whoami /all /c ping.exe -n 6 127.0.0.1 & type "%s\System32\calc.exe" > "%s"error res='%s' err=%d len=%unltest /domain_trusts /all_trusts.lnkcmdschtasks.exe /Create /RU "NT AUTHORITY\SYSTEM" /SC ONSTART /TN %u /TR "%s" /NP /F%s \"$%s = \\\"%s\\\\; & $%s\"ERROR: GetModuleFileNameW() failed with error: %uschtasks.exe /Delete /F /TN %uarp -aSelf check ok!cmd.exe /c set%s %04x.%u %04x.%u res: %s seh_test: %u consts_test: %d vmdetected: %d createprocess: %dMicrosoftpowershell.exe -encodedCommand %SSELF_TEST_1microsoft.com,google.com,kernel.org,www.wikipedia.org,oracle.com,verisign.com,broadcom.com,yahoo.com,xfinity.com,irs.gov,linkedin.comc:\ProgramDatanslookup -querytype=ALL -timeout=12 _ldap._tcp.dc._msdcs.%s%u;%u;%u;powershell.exe -encodedCommand runas/teorema505Self test OK.ProfileImagePathp%08x
遗憾的是,除了 CNC http 端点 (/teorema505),这里既没有 CNC 地址列表,也没有任何有价值的配置数据。因此,我们必须深入挖掘。 对第二个字符串数组进行解密
这个二进制文件中还有第二个加密字符串数组。这个数组的重要性较低,解密方法与第一个数组完全相同。唯一的区别是使用的 XOR 密钥和 AES 密码不同。如果你感兴趣,下面是 Qakbot 示例中与第二个数组相关的变量位置:
名称 | 地址 | 字节大小 | 描述 | decrypt_strings_2 | 0x18000de90 | 0x3f | Decryption function for the second encrypted strings array | STRINGS_2 | 0x1800297a0 | 0x1836 | Second encrypted strings array | AES_ENCRYPTED_XOR_KEY_2 | 0x18002afe0 | 0xa0 | The XOR key used to decrypt the string array, but AES256-CBC encrypted | AES_PASSWORD_2 | 0x180029700 | 0x9f | The password used to derive the AES256 key for AES_ENCRYPTED_XOR_KEY_2 |
配置确定配置位置
解密字符串固然是件好事,但我们的目标是获取 Qakbot 的配置,或者至少是它的命令与控制 (CNC) 服务器列表。我们将遵循流程,从数据分析入手。在上一章介绍的解密字符串列表中,有两个字符串看起来有点不寻常:
- 字符串数组中偏移量0x182处的第 14 个字符串: T2X!wWMVH1UkMHD7SBdbgfgXrNBd(5dmRNbBI9
- 字符串数组中偏移量0x1a9处的第 15 个字符串: 4Lm7DW&yMF*ELN4D8oNp0CtKUf*C2LAstORIBV
我们在上一章中看到,字符串解密函数decrypt_strings_1的第一个参数是要解密的字符串的索引,即它相对于加密字符串数组起始位置的位置。因此,如果我们想知道这两个字符串是如何使用的,只需在代码中查找它们的偏移量即可。让我们关注第一个字符串: 图 11:查找对字符串 0x182 的引用
我们很快就得到了两个候选函数:一个是位于偏移量0x18000622c的函数,我们将其称为decrypt_CNC,另一个是位于偏移量0x18000345c的函数,我们将其称为decrypt_params。第二个好消息是,除了0x182字符串之外,这两个函数都引用了一个高熵缓冲区(分别名为CNC_LIST和PARAMS)。这些函数和变量的地址如下:
名称 | 地址 | 字节大小 | 描述 | decrypt_CNC | 0x18000622c | 0x2cc | Decryption function for Qakbot's CNC | CNC_LIST | 0x180028852 | 0x51 | Encrypted CNC list | decrypt_params | 0x18000345c | 0x76 | Decryption function for Qakbot's campaign information | PARAMS | 0x180029022 | 0x51 | Encrypted campaign informations | aes_decrypt_and_check_sha256 | 0x180015d14 | 0x105 | Function to decrypt both encrypted blob |
最后还有一个好消息:这两个函数最终都会调用我们的好帮手aes_encrypt_decrypt_iv_prefix。我们在逆转字符串解密过程时已经发现了这个函数:它能解密以 16 字节 IV 为前缀的 AES256-CBC 加密缓冲区。 图 12:计算机解密功能候选方案 解密 CNC 列表
如果我们深入研究一下函数,特别是aes_decrypt_and_check_sha256 中的内容,就会发现加密后的 blob CNC_LIST和PARAMS有一个特殊的结构:
- 它们的前缀是大小(16 位 int)
- 然后是一个字节的 Blob 标识符
- 然后,我们就得到了已知的 AES 加密 blob:
- 16 个字节的初始化向量 (IV)
- 实际的 AES256-CBC 加密内容
blob 格式如下图所示:
图 13:Cnc 列表加密 blob
要解密 blob,我们将使用与字符串相同的程序:
- 计算密码"T2X!wWMVH1UkMHD7SBdbgfgXrNBd(5dmRNbBI9"的 SHA256 值(7085d1138cbac863a9b4f1bf85a4d413804ef3a3ec52729fa15747a6ee320325)
- 选择 0x40 字节的 AES 加密数据
- 在CBC 模式下使用 Malcat 的转换AES 解密,将 IV 设置为加密数据前的 16 个字节,将密钥设置为 sha256 哈希值
- 不要忘记检查unpad
解密CNC_LIST blob 后,我们看到的是一个相对简单的二进制结构。在函数decrypt_CNC中稍加反转,我们就能迅速了解解密所需的一切信息。解密后的 blob 以 sha256 校验和开头,然后是一个(IP、端口)对列表。详情如下: 图 14:CNC 列表已解密
就是这样!我们得到了 3 个 CnC 地址:
- 31.210.173.10:443 (VT)
- 185.156.172.62:443 (VT)
- 185.113.8.123:443 (VT)
现在,让我们看看第二个缓冲区PARAMS 为我们提供了哪些信息。
解密 campagn 信息
第二个引用的 BlobPARAMS是以完全相同的方式用相同的密码("T2X!wWMVH1UkMHD7SBdbgfgXrNBd(5dmRNbBI9 "的 sha256)加密的。如果重复使用相同的解密过程,最后应该会得到类似这样的结果:
图 15:已解密的活动信息
我们可以得到三个参数:
- id 10的参数似乎是活动 ID (tchk08)
- id 3的参数似乎是一个时间戳,很可能是编译时间
- 不知道参数40的用途
有了这最后一条信息,我们就可以停止搜索 Qakbot 配置数据了。
编写脚本编写思路
你可能已经注意到了,最后解密的过程有点繁琐。在本章中,我们将通过在 Malcat 中编写一个 python 配置提取器来自动完成这一过程。事实上,Malcat 具有强大的 python 绑定功能,这些功能都有详尽的文档说明。在脚本中,你可以通过某种 Python 方式访问完整的分析对象。
该脚本的作用是重新执行我们手动执行的所有步骤: - 收集 .data 部分中所有有趣的引用缓冲区
- 查看这些缓冲区是否以大小为前缀。如果没有,则尝试通过查看引用函数代码中使用的常量来推断其大小。
- 然后解密所有内容:
- 对于字符串数组: 尝试所有可能的三元组排列(strings_array、xor_key_encrypted、aes_password)来解密字符串,并保留有效的排列。
- 用于提取配置: 使用在第一个字符串数组中找到的任何高熵字符串作为 AES 密码,并尝试解密 CNC IP 和活动信息。保留有效的信息(我们可以用 sha256 进行双重检查)
数组的排序在不同的样本之间会发生变化。我本可以使用代码签名来更容易地找到字符串解密函数,但代码可能会改变,而代码签名在重新编译时并不那么稳健。另一方面,分析数据则更稳健一些,我希望脚本能工作一段时间。
[AppleScript] 纯文本查看 复制代码 """
name: Qakbot 5.0
category: config extractors
author: malcat
Decrypt strings and extract CnC informations from a (plain-text) Qakbot 5.0 sample
"""
import malcat
import struct
import itertools
import hashlib
import json
import datetime
import re
import math
import collections
from transforms.binary import CircularXor
from transforms.block import AesDecrypt
############################ utility functions
def decrypt_aes_iv_prefix(data:bytes, aes_password: bytes):
key = hashlib.sha256(aes_password).digest()
iv = data[0:16]
data = data[16:]
return AesDecrypt().run(data, mode="cbc", iv=iv, key=key, unpad=True)
def get_all_referencing_functions(a:malcat.Analysis, address:int):
res = []
for incoming_ref_type, incoming_ref_address in a.xref[address]:
fn = a.fns.find(incoming_ref_address)
if fn is not None:
res.append(fn)
return set(res)
def entropy(data:str, base=2):
if len(data) <= 1:
return 0
counts = collections.Counter()
for d in data:
counts[d] += 1
ent = 0
probs = [float(c) / len(data) for c in counts.values()]
for p in probs:
if p > 0.:
ent -= p * math.log(p, base)
return ent
############################ interesting buffer heuristics
def enumerate_interesting_buffers(a:malcat.Analysis, section_name:str, prefixed_buffer:bool = False):
section = a.map[section_name]
# get all incoming xref in the section: denotates the start of a buffer
data_xrefs = [x.address for x in a.xref[section.start:section.end]]
for i in range(1, len(data_xrefs) - 1): # let's assume the first and last xrefs will never be interesting
prev, cur, next = data_xrefs[i-1:i+2]
prev_off = a.a2p(prev)
cur_off = a.a2p(cur)
next_off = a.a2p(next)
if prefixed_buffer and cur - prev == 2:
# is it a size-prefixed buffer ? (i.e. there is a referenced word 2 bytes before)
size, = struct.unpack("<H", a.file[prev_off:cur_off])
yield cur, size
elif not prefixed_buffer:
# we'll look for all immediate constants in referencing functions and see which one could be a size
for fn in get_all_referencing_functions(a, cur):
for basic_block in fn:
if not basic_block.code:
continue
for instruction in basic_block:
for operand in instruction:
if operand.value and operand.value > 0x10 and cur + operand.value <= next and next - (cur + operand.value) < 0x20:
yield cur, operand.value
############################ strings decryption
def get_potential_strings_triples(a:malcat.Analysis):
# Here we will look for 3 buffers referenced from the same function:
# one is the strings, one the xor key, one the aes password
function_to_refs = {}
done = set()
# group all interesting buffers by referencing functions
for address, size in enumerate_interesting_buffers(a, ".data", prefixed_buffer=False):
if size < 0x20:
continue
# find all reference coming from functions
for fn in get_all_referencing_functions(a, address):
function_to_refs.setdefault(fn.address, []).append((address, size))
# now try to find a function referencing 3 interesting buffers
for fn_address, by_function in function_to_refs.items():
if len(by_function) < 3:
# there should be at least 3 references to candidate buffers inside one function
continue
# we don't know which is one is the data, xor key or aes password: try all permutations of triples
for candidate_triple in itertools.permutations(by_function, r=3):
if not candidate_triple in done:
done.add(candidate_triple)
yield candidate_triple
def get_strings_arrays(a:malcat.Analysis):
res = []
# tries to decrypt all string arrays candidates
for strings, xor, aes_password in get_potential_strings_triples(a):
print(f"Trying strings=({a.ppa(strings[0])}, {hex(strings[1])}), xor=({a.ppa(xor[0])}, {hex(xor[1])}), aes_password=({a.ppa(aes_password[0])}, {hex(aes_password[1])}) ... ", end="")
try:
# decrypt XOR key using AES
xor_address, xor_size = xor
xor_offset = a.a2p(xor_address)
xor_buffer = a.file[xor_offset: xor_offset + xor_size]
aes_address, aes_size = aes_password
aes_offset = a.a2p(aes_address)
aes_buffer = a.file[aes_offset: aes_offset + aes_size]
xor_key = decrypt_aes_iv_prefix(xor_buffer, aes_buffer)
# decrypt strings using XOR key
strings_address, strings_size = strings
strings_offset = a.a2p(strings_address)
strings_buffer = a.file[strings_offset: strings_offset + strings_size]
strings_decrypted = CircularXor().run(strings_buffer, key=xor_key).decode("utf8")
all_strings = strings_decrypted.split("\x00")
res.append(all_strings)
print(f"Found {len(all_strings)} strings !")
except BaseException as e:
print(f"{e} :(")
return res
############################ config extraction
def qakbot_config_extraction(a:malcat.Analysis):
print("Running heuristic to find string arrays ...")
config_password = None
strings_1 = []
# find string arrays
for string_array in get_strings_arrays(a):
print(f"\nFound one string array of {len(string_array)} strings:")
print("\n".join(string_array))
if "ipconfig /all" in string_array:
strings_1 = string_array
print()
ips = []
options = {}
config_passwords = []
# try to find endpoint
for s in strings_1:
if re.match(r"^/[a-zA-Z0-9_%?=&-]{2,16}$", s):
options["http_endpoint"] = s
break
# try to find password candidates: high-entropy, good length, not a lot of space or backslaches
for s in strings_1:
if len(s) > 30 and len(s) < 60 and entropy(s) > 4 and s.count(" ") < 2 and s.count("\\") < 2:
config_passwords.append(s)
print(f"Found {len(config_passwords)} password candidates: {', '.join(config_passwords)}")
# ok now try to look for prefixed buffers:
for address, size in enumerate_interesting_buffers(a, ".data", prefixed_buffer=True):
# and try to decrypt using our password candidates
for config_password in config_passwords:
print(f"Trying config decryption for {a.ppa(address)}, {hex(size)}) with password {config_password} ... ", end="")
try:
offset = a.a2p(address)
buffer = a.file[offset:offset+size]
# AES decrypt the buffer (skip blob identifer)
decrypted = decrypt_aes_iv_prefix(buffer[1:], config_password.encode("ascii"))
# verify checksum
checksum = decrypted[:32]
data = decrypted[32:]
if hashlib.sha256(data).digest() != checksum:
raise ValueError("Invalid blob checksum")
# looks like campaign info?
if data.count(b"=") >= 2:
data = data.decode("ascii").replace("\r", "")
d = dict([x.split("=") for x in data.split("\n") if x.strip()])
print(f"Found config dictionnary with {len(d)} entries!")
for k, v in d.items():
if k == "10":
k = "campaign_id"
elif k == "3":
k = "date"
v = datetime.datetime.fromtimestamp(int(v)).isoformat()
options[k] = v
# looks like campaign IPs list?
elif data.startswith(b"\x01"):
for i in range(0, len(data), 8):
type, ip, port,_ = struct.unpack_from(">B4sHB", data, i)
if type != 1:
raise ValueError(f"Unknown CNC format {type}")
ip = ".".join(map(str, struct.unpack("BBBB", ip)))
ips.append((ip, port))
print ("Found IPs !")
else:
print("Unknwon config data")
except Exception as e:
print(f"{e} :(")
return {
"cncs": ips,
"options": options,
}
################################ MAIN
if __name__ == "__main__":
config = qakbot_config_extraction(analysis)
print("\nQAKBOT_CONFIG = ", end="")
print(json.dumps(config, indent=4)) 结果对照分析样本
当运行最后阶段cldapi.dll 时,脚本将输出类似下面的内容: [AppleScript] 纯文本查看 复制代码 Running heuristic to find string arrays ...
Trying strings=(0x180028150 (.data:150), 0x63), xor=(0x180028150 (.data:150), 0x63), aes_password=(0x180028150 (.data:150), 0x58) ... Data must be padded to 16 byte boundary in CBC mode :(
Trying strings=(0x180028150 (.data:150), 0x63), xor=(0x180028150 (.data:150), 0x63), aes_password=(0x180028150 (.data:150), 0x60) ... Data must be padded to 16 byte boundary in CBC mode :(
Trying strings=(0x1800297a0 (.data:17a0), 0x1836), xor=(0x18002afe0 (.data:2fe0), 0xa0), aes_password=(0x180029700 (.data:1700), 0x9f) ... Found 185 strings !
Trying strings=(0x18002afe0 (.data:2fe0), 0xa0), xor=(0x18002afe0 (.data:2fe0), 0xa0), aes_password=(0x1800297a0 (.data:17a0), 0x1836) ... Padding is incorrect. :(
...
Trying strings=(0x18002afe0 (.data:2fe0), 0xa0), xor=(0x18002afe0 (.data:2fe0), 0xa0), aes_password=(0x18002afe0 (.data:2fe0), 0x9f) ... Padding is incorrect. :(
Trying strings=(0x18002b190 (.data:3190), 0x9c0), xor=(0x18002b190 (.data:3190), 0x9c0), aes_password=(0x18002b190 (.data:3190), 0x9c0) ... Padding is incorrect. :(
Found one string array of 52 strings:
SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList
ProgramData
netstat -nao
%s "$%s = \"%s\"; & $%s"
...
Found one string array of 185 strings:
%SystemRoot%\SysWOW64\xwizard.exe
.dat
kernelbase.dll
WBJ_IGNORE
mpr.dll
...
Found 2 password candidates: T2X!wWMVH1UkMHD7SBdbgfgXrNBd(5dmRNbBI9, 4Lm7DW&yMF*ELN4D8oNp0CtKUf*C2LAstORIBV
Trying config decryption for 0x180028852 (.data:852), 0x51) with password T2X!wWMVH1UkMHD7SBdbgfgXrNBd(5dmRNbBI9 ... Found IPs !
Trying config decryption for 0x180028852 (.data:852), 0x51) with password 4Lm7DW&yMF*ELN4D8oNp0CtKUf*C2LAstORIBV ... Padding is incorrect. :(
Trying config decryption for 0x180029022 (.data:1022), 0x51) with password T2X!wWMVH1UkMHD7SBdbgfgXrNBd(5dmRNbBI9 ... Found config dictionnary with 3 entries!
Trying config decryption for 0x180029022 (.data:1022), 0x51) with password 4Lm7DW&yMF*ELN4D8oNp0CtKUf*C2LAstORIBV ... Padding is incorrect. :(
QAKBOT_CONFIG = {
"cncs": [
[
"31.210.173.10",
443
],
[
"185.156.172.62",
443
],
[
"185.113.8.123",
443
]
],
"options": {
"http_endpoint": "/teorema505",
"campaign_id": "tchk08",
"40": "1",
"date": "2024-01-31T15:22:34"
}
}
它很有效!
与另一个样本对比
[AppleScript] 纯文本查看 复制代码 Running heuristic to find string arrays ...
Trying strings=(0x140028150 (.data:150), 0x80), xor=(0x140028150 (.data:150), 0x80), aes_password=(0x1400281e0 (.data:1e0), 0x94) ... 'utf-8' codec can't decode byte 0xad in position 0: invalid start byte :(
Trying strings=(0x140028150 (.data:150), 0x80), xor=(0x140028150 (.data:150), 0x80), aes_password=(0x140028280 (.data:280), 0x5b5) ... Padding is incorrect. :(
Trying strings=(0x140028150 (.data:150), 0x80), xor=(0x1400281e0 (.data:1e0), 0x94), aes_password=(0x140028150 (.data:150), 0x80) ... Data must be padded to 16 byte boundary in CBC mode :(
Trying strings=(0x140028150 (.data:150), 0x80), xor=(0x1400281e0 (.data:1e0), 0x94), aes_password=(0x1400281e0 (.data:1e0), 0x94) ... Data must be padded to 16 byte boundary in CBC mode :(
Trying strings=(0x140029620 (.data:1620), 0x1825), xor=(0x1400294c0 (.data:14c0), 0xc0), aes_password=(0x140029590 (.data:1590), 0x87) ... Found 185 strings !
...
Trying strings=(0x14002b220 (.data:3220), 0x9c0), xor=(0x14002b220 (.data:3220), 0x9c0), aes_password=(0x14002b220 (.data:3220), 0x9c0) ... unsupported operand type(s) for +: 'NoneType' and 'int' :(
Found one string array of 52 strings:
Component_08
Self test FAILED!!!
route print
whoami /all
...
Found one string array of 185 strings:
kernelbase.dll
mcshield.exe
wmic process call create 'expand "%S" "%S"'
SOFTWARE\Microsoft\Windows Defender\Exclusions\Paths
%ProgramFiles%\Internet Explorer\iexplore.exe
%SystemRoot%\SysWOW64\xwizard.exe
...
Found 2 password candidates: 4Lm7DW&yMF*ELN4D8oNp0CtKUf*C2LAstORIBV, ArpBUw9Lb9ndqXhFTfBst9YHotv92LB7BKvK#ewZn@@@Tu
Trying config decryption for 0x140028842 (.data:842), 0x61) with password 4Lm7DW&yMF*ELN4D8oNp0CtKUf*C2LAstORIBV ... Padding is incorrect. :(
Trying config decryption for 0x140028842 (.data:842), 0x61) with password ArpBUw9Lb9ndqXhFTfBst9YHotv92LB7BKvK#ewZn@@@Tu ... Found IPs !
Trying config decryption for 0x140029012 (.data:1012), 0x51) with password 4Lm7DW&yMF*ELN4D8oNp0CtKUf*C2LAstORIBV ... PKCS#7 padding is incorrect. :(
Trying config decryption for 0x140029012 (.data:1012), 0x51) with password ArpBUw9Lb9ndqXhFTfBst9YHotv92LB7BKvK#ewZn@@@Tu ... Found config dictionnary with 2 entries!
QAKBOT_CONFIG = {
"cncs": [
[
"146.70.158.28",
6882
],
[
"116.202.110.87",
443
],
[
"77.73.39.175",
32103
],
[
"185.156.172.62",
443
],
[
"185.117.90.142",
6882
]
],
"options": {
"http_endpoint": "/teorema505",
"campaign_id": "bmw01",
"date": "2024-01-26T12:25:33"
}
}
它也能正常工作!请注意,两个字符串数组中的字符串在不同样本中的排序是不同的。
结论
在这篇博文中,我们学习了如何利用 Malcat 的文件解析器和数据转换来解压多层 MSI 安装程序,直至最终的 Qakbot 样本。坚持纯静态分析,并着重强调数据分析,我们看到了如何解密 Qakbot 的字符串数组并解码其命令和控制配置。最后,通过使用 Malcat 的 python 绑定,我们编写了一个功能完备的静态配置提取器。该提取器脚本不使用任何代码签名,也不使用任何硬编码值,因此有望在未来的更改中保持稳定。
希望大家喜欢这次的解包/脚本编写过程。希望 Qakbot 配置提取器对您今后的分析工作有所帮助。和往常一样,欢迎与我们分享您的意见或建议!
|