首页
社区
课程
招聘
[翻译]使用 Malcat 编写 Qakbot 5.0 配置提取器
发表于: 2024-2-17 16:46 2629

[翻译]使用 Malcat 编写 Qakbot 5.0 配置提取器

2024-2-17 16:46
2629

翻译:梦幻的彼岸

原文地址:https://malcat.fr/blog/writing-a-qakbot-50-config-extractor-with-malcat/

样本:

73472cfc52f2732b933e385ef80b4541191c45c995ce5c42844484c33c9867a3.msi (BazaarVT)

传染链:

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(VirusshareVT),名为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,具体步骤如下:

  1. 将架构强制为 x64
  2. 选择 shellcode 的第一个字节并在此定义新函数
  3. 使用 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_10x180002ab80x3fDecryption function for the first encrypted strings array
STRINGS_10x1800282a00x5adFirst encrypted strings array
AES_ENCRYPTED_XOR_KEY0x1800281c00xd0The XOR key used to decrypt the string array, but AES256-CBC encrypted
AES_PASSWORD0x1800281500x63The password used to derive the AES256 key for AES_ENCRYPTED_XOR_KEY
decrypt_aes_plus_xor0x18000dc2c0x1deThe function that decrypts the string array and selects the string
aes_encrypt_decrypt_iv_prefix0x1800115040x3f7A 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_20x18000de900x3fDecryption function for the second encrypted strings array
STRINGS_20x1800297a00x1836Second encrypted strings array
AES_ENCRYPTED_XOR_KEY_20x18002afe00xa0The XOR key used to decrypt the string array, but AES256-CBC encrypted
AES_PASSWORD_20x1800297000x9fThe 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_CNC0x18000622c0x2ccDecryption function for Qakbot's CNC
CNC_LIST0x1800288520x51Encrypted CNC list
decrypt_params0x18000345c0x76Decryption function for Qakbot's campaign information
PARAMS0x1800290220x51Encrypted campaign informations
aes_decrypt_and_check_sha2560x180015d140x105Function 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、端口)对列表。详情如下:

Cnc list decrypted图 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 方式访问完整的分析对象。



注:如果您拥有 Malcat 的完整版或专业版,也可在headless mode下从命令行运行脚本


该脚本的作用是重新执行我们手动执行的所有步骤:

  • 收集 .data 部分中所有有趣的引用缓冲区
  • 查看这些缓冲区是否以大小为前缀。如果没有,则尝试通过查看引用函数代码中使用的常量来推断其大小。
  • 然后解密所有内容:
    • 对于字符串数组: 尝试所有可能的三元组排列(strings_array、xor_key_encrypted、aes_password)来解密字符串,并保留有效的排列。
    • 用于提取配置: 使用在第一个字符串数组中找到的任何高熵字符串作为 AES 密码,并尝试解密 CNC IP 和活动信息。保留有效的信息(我们可以用 sha256 进行双重检查)


数组的排序在不同的样本之间会发生变化。我本可以使用代码签名来更容易地找到字符串解密函数,但代码可能会改变,而代码签名在重新编译时并不那么稳健。另一方面,分析数据则更稳健一些,我希望脚本能工作一段时间。

既然这篇博文已经够长了,我就把下面相对完备的代码留给大家吧。对你来说,唯一有点陌生的概念可能就是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期)

最后于 2024-2-17 19:58 被梦幻的彼岸编辑 ,原因:
收藏
免费 1
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回
//