-
-
[翻译]使用 Malcat 编写 Qakbot 5.0 配置提取器
-
发表于: 2024-2-17 16:46 2629
-
翻译:梦幻的彼岸
原文地址:https://malcat.fr/blog/writing-a-qakbot-50-config-extractor-with-malcat/
样本: | 73472cfc52f2732b933e385ef80b4541191c45c995ce5c42844484c33c9867a3.msi (Bazaar, VT) |
传染链: | MSI installer -> Backdoored DLL -> PE loader -> Qakbot |
分析所使用的工具 | Malcat |
难度 | 中级水平 |
简介
在过去的 15 年里,人们对 Qakbot 进行了大量研究,它在恶意软件领域扮演着重要角色。在2023 年 8 月被成功清除后,它最近又受到了关注,因为在 2023 年 12 月左右又发现了一个新的变种。
但在起死回生后,该 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的程序,命令行非常可疑:
{ "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或摘要视图中的 "Check intelligence"按钮)提示我们,该文件可能是一个简单的第三方启动器:
图 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。这个函数看起来就像典型的字符串解密函数:如下所示,它有许多输入引用,每次调用都有一个不同的硬编码参数。这个参数很有可能是一个字符串索引:
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)如下:
每个变量的值如下:
名称 | 地址 | 字节大小 | 描述 |
---|---|---|---|
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\ProfileList ProgramData netstat -nao %s "$%s = \"%s\"; & $%s" net localgroup powershell.exe route print "%s\system32\schtasks.exe" /Create /ST %02u:%02u /RU "NT AUTHORITY\SYSTEM" /SC ONCE /tr "%s" /Z /ET %02u:%02u /tn %s Component_08 ERROR: GetModuleFileNameW() failed with error: ERROR_INSUFFICIENT_BUFFER net view ipconfig /all Self check T2X!wWMVH1UkMHD7SBdbgfgXrNBd(5dmRNbBI9 4Lm7DW&yMF*ELN4D8oNp0CtKUf*C2LAstORIBV Start screenshot %s.%u adrclient.dll net share qwinsta \System32\WindowsPowerShell\v1.0\powershell.exe at.exe %u:%u "%s" /I Self test FAILED!!! Component_07 whoami /all /c ping.exe -n 6 127.0.0.1 & type "%s\System32\calc.exe" > "%s" error res='%s' err=%d len=%u nltest /domain_trusts /all_trusts .lnk cmd schtasks.exe /Create /RU "NT AUTHORITY\SYSTEM" /SC ONSTART /TN %u /TR "%s" /NP /F %s \"$%s = \\\"%s\\\\; & $%s\" ERROR: GetModuleFileNameW() failed with error: %u schtasks.exe /Delete /F /TN %u arp -a Self check ok! cmd.exe /c set %s %04x.%u %04x.%u res: %s seh_test: %u consts_test: %d vmdetected: %d createprocess: %d Microsoft powershell.exe -encodedCommand %S SELF_TEST_1 microsoft.com,google.com,kernel.org,www.wikipedia.org,oracle.com,verisign.com,broadcom.com,yahoo.com,xfinity.com,irs.gov,linkedin.com c:\ProgramData nslookup -querytype=ALL -timeout=12 _ldap._tcp.dc._msdcs.%s %u;%u;%u; powershell.exe -encodedCommand runas /teorema505 Self test OK. ProfileImagePath p%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 |
如果使用与第一个数组相同的程序,就可以得到下面的字符串列表:请参见 pastebin。
配置
确定配置位置
解密字符串固然是件好事,但我们的目标是获取 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 地址:
现在,让我们看看第二个缓冲区PARAMS 为我们提供了哪些信息。
解密 campagn 信息
第二个引用的 BlobPARAMS是以完全相同的方式用相同的密码("T2X!wWMVH1UkMHD7SBdbgfgXrNBd(5dmRNbBI9 "的 sha256)加密的。如果重复使用相同的解密过程,最后应该会得到类似这样的结果:
图 15:已解密的活动信息
我们可以得到三个参数:
- id 10的参数似乎是活动 ID (tchk08)
- id 3的参数似乎是一个时间戳,很可能是编译时间
- 不知道参数40的用途
有了这最后一条信息,我们就可以停止搜索 Qakbot 配置数据了。
编写脚本
编写思路
你可能已经注意到了,最后解密的过程有点繁琐。在本章中,我们将通过在 Malcat 中编写一个 python 配置提取器来自动完成这一过程。事实上,Malcat 具有强大的 python 绑定功能,这些功能都有详尽的文档说明。在脚本中,你可以通过某种 Python 方式访问完整的分析对象。
注:如果您拥有 Malcat 的完整版或专业版,也可在headless mode下从命令行运行脚本
该脚本的作用是重新执行我们手动执行的所有步骤:
- 收集 .data 部分中所有有趣的引用缓冲区
- 查看这些缓冲区是否以大小为前缀。如果没有,则尝试通过查看引用函数代码中使用的常量来推断其大小。
- 然后解密所有内容:
数组的排序在不同的样本之间会发生变化。我本可以使用代码签名来更容易地找到字符串解密函数,但代码可能会改变,而代码签名在重新编译时并不那么稳健。另一方面,分析数据则更稳健一些,我希望脚本能工作一段时间。
既然这篇博文已经够长了,我就把下面相对完备的代码留给大家吧。对你来说,唯一有点陌生的概念可能就是Malcat 的地址空间及其a2p 函数等。但除了这个小细节外,其他的应该都很容易理解。
脚本
"""name: Qakbot 5.0category: config extractorsauthor: malcatDecrypt strings and extract CnC informations from a (plain-text) Qakbot 5.0 sample"""import malcatimport structimport itertoolsimport hashlibimport jsonimport datetimeimport reimport mathimport collectionsfrom transforms.binary import CircularXorfrom transforms.block import AesDecrypt############################ utility functionsdef 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 heuristicsdef 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 decryptiondef 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_tripledef 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 extractiondef 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, }################################ MAINif __name__ == "__main__": config = qakbot_config_extraction(analysis) print("\nQAKBOT_CONFIG = ", end="") print(json.dumps(config, indent=4))
结果
对照分析样本
当运行最后阶段cldapi.dll 时,脚本将输出类似下面的内容:
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" } }
它很有效!
与另一个样本对比
但是,该提取脚本对其他样本也有效吗?让我们用在 Malpedia 上找到的另一个去除保护的 Qakbot 样本试试:
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 配置提取器对您今后的分析工作有所帮助。和往常一样,欢迎与我们分享您的意见或建议!
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)
赞赏
- [原创]物联网安全:基础篇 4099
- 威胁情报小课堂:阻止活跃勒索软件的感染 2165
- [翻译]发现利用 Facebook 和 MS 管理控制台实施的 Kimsuky APT 攻击 7041
- 威胁情报小课堂:LockBit Black 2041
- 威胁情报小课堂:Nitrogen 2067