Salim BitamChristophe Alladoum

蜜罐中的勒索软件:我们如何使用粘性金丝雀文件捕获密钥

本文介绍了如何使用 Elastic Defend 勒索软件防护功能从勒索软件中捕获加密密钥。

阅读时间:22分钟安全研究
Ransomware in the honeypot: how we capture keys with sticky canary files

太长不看版

在 Elastic,我们每半年举办一次 ON 周,届时工程师们会组成“黑客马拉松”团队来应对团队投票选出的技术挑战。本文介绍了又一次 Elastic ON 周的结果,在这次活动中,我们深入研究了 Elastic Endpoint 勒索软件防护功能的创新应用。我们的研究使用了我们自 7.14 版本以来部署的现有勒索软件金丝雀防护功能,以生成被识别为勒索软件的进程的内存快照(即记录进程信息的數據集合)。通过分析这些快照,我们的研究说明了我们如何能够恢复取证过程的关键信息,甚至加密密钥,从而实现完整解密。

此进程内存快照机制从 Elastic Defend 8.11 开始添加,允许 DFIR 团队在 Elastic Endpoint 的安全安装文件夹内(默认情况下为 $ElasticInstallPath\Endpoint\cache\RansomwareDumps)找到我们的勒索软件防护功能标记的勒索软件的内存转储。

简介

在 2024 年,我们无需解释什么是勒索软件,也无需解释它已发展成为一个数十亿美元的产业,或者解释即使拥有无限预算的公司也难以控制或阻止它。这些攻击者已经成熟且效率很高,往往超过了取证和恶意软件分析等安全功能。

当前防护状态

值得庆幸的是,多年来,AV/EDR 在检测和阻止勒索软件方面变得越来越好。在最常见的现有缓解措施中,我们发现

  • 基于签名的静态和动态检测:这通常在各个级别(通过文件或 ELF/PE 部分级别的哈希值)和文件活动(对具有高熵变化的文件的写入访问权限)进行,具有易于快速实施的优点,但也可能产生误报
  • 逆向工程:逆向二进制文件可以揭示干扰执行的新方法,因为恶意软件作者会实现操作系统级别的故障安全(例如,通过 Mutant 对象)和/或网络故障安全(如 WANNACRY)
  • 恢复备份:这些备份并不总是经过彻底测试,即使它们正在工作,在最后一次备份和感染时刻之间也存在数据丢失的风险
  • 卷影副本:与恢复备份有些类似,勒索软件通常会在加密系统上的文件之前主动查找并尝试销毁它们
  • 高熵和快速文件更改:这种方法纯粹是实验性的,试图将文件内容的剧烈变化作为加密的指标来检测,但是,这也非常容易产生误报
  • 最后的加密弱点:迄今为止最复杂的缓解措施,因为它需要逆向工程和加密知识,还需要运气,因为攻击者希望作者自行编写加密 API(请参阅 Elastic 的 Mark Mager 的 2019 DEFCON 演讲,以了解一些示例);只要按照文档正确实现,这种方法就不能针对现代操作系统本机加密 API 使用

勒索软件(通常)的工作原理以及重要性

我们必须了解我们所要防护的目标以及其内部运作方式才能有效地进行防护。这种多样性强调了可能永远不会存在一种通用的解决方案来对抗所有勒索软件变种。了解这种多样性也突出了我们技术的重要性,它提供了关于勒索软件的重要见解。

从高级别来看,勒索软件执行的操作序列通常总结如下

  1. **投放**:这可以通过多种方式完成,从社会工程到 0 天/1 天漏洞利用。这种方法还可以依靠弱密码远程感染目标。
  2. **C2 通信**:执行开始后,勒索软件可能会与 C2 通信以交换配置并共享有关受害者的信息。此步骤还可以为 C2 提供一个终止开关,从而防止进一步感染
  3. **加密**:建立加密上下文后,该过程会递归浏览文件系统,查找具有特定扩展名的文件并对其进行加密。
  4. **敲诈**:与 C2 共享解密密钥后,勒索软件会留下赎金记录,并(通常非常明显地)通知受感染的用户其行为以及获取解密密钥的方法。此时,所有允许恢复的加密上下文可能已经丢失
  5. **传播**:如果可能,勒索软件可能会尝试自动感染更多系统。

但是,从更低级别来看,勒索软件的运行方式非常独特:例如,专注于投放步骤,臭名昭著的 WANNACRY 勒索软件 通过 Windows 操作系统中的一个漏洞传播,该漏洞被称为 EternalBlue;而 LOCKBIT 变种倾向于使用网络钓鱼电子邮件、漏洞利用工具包或利用被盗的远程桌面协议 (RDP) 凭据进行感染。

在这项研究期间,我们主要对第 3 步感兴趣,因为这通常是检测和预防最有效的地方,例如我们的金丝雀防护功能。

了解 Elastic Endpoint 中的金丝雀文件功能

Elastic Endpoint 勒索软件防护功能起源于 Elastic 7.14,它使用 金丝雀文件,目的是尝试通过(过度)写入某些特定文件来诱捕勒索软件。这提供了一个高度置信的指标,表明罪魁祸首进程正在尝试加密所有文件。

金丝雀文件的作用和外观与任何其他文件完全相同——它可以具有有效内容(DOCX、PDF 等)、隐藏或标记为系统文件以避免用户篡改。但是,金丝雀文件无法被勒索软件“指纹识别”和避免。所有这些因素导致了勒索软件访问的可靠指标。

尽管金丝雀文件在提供勒索软件指标方面非常成功,但在 Windows 系统上很难确定在检测(以及如果需要,终止)发生之前没有文件被加密。这不是产品缺陷,而是由于 MiniFilter 在 Windows 上的工作方式的结构所致。因此,即使攻击被阻止,某些文件也可能已被加密。更糟糕的是,如果进程被终止,则可能完全无法检索原始内容。

这就是我们的 ON 周研究开始的地方……

扩展我们的金丝雀防护功能以生成进程快照

基本底层概念

这项第一项研究背后的想法如下

  • 在内核级别,检测对具有特定名称(我们的金丝雀)的文件的写入访问尝试
  • 从用户空间生成尝试写入操作的罪魁祸首进程的进程转储,并向驱动程序发出信号以按设计继续执行
  • 分析进程转储

由于 ON 周限制为一周,因此这是我们开发原型的时间范围。

实施

在内核空间

开发一个 MiniFilter 驱动程序来监控对特定名称文件的写访问,在遵循完善的MiniFilter API 文档后,过程相对容易。

  1. 声明过滤器表,其中包含我们想要安装的回调函数,一个用于在调用NtWriteFile()时进行写访问的回调,另一个用于尝试写入映射节区的回调。

  1. 创建并注册过滤器,包括要监控的文件名模式并开始过滤。

图 2:为 MiniFilter 驱动程序声明要检查的文件名模式 一旦我们的过滤器注册到过滤器管理器,当触发特定系统调用时,写访问将通过驱动程序的回调函数:当进程尝试将缓冲区写入文件时,由NtWriteFile触发;或者当进程创建具有文件支持映射且具有写访问权限的节区(SECTION_MAP_WRITE)时,由NtCreateSection()触发。

我们可以看到,这两种操作都将导致调用进程被挂起(调用我们的函数SuspendProcessById),从而允许用户态进程对其内存进行快照。以下视频总结了所有这些步骤。

在用户态

生成内存转储是一种稳健的机制,它牢固地植根于 Windows 并构成其错误报告机制(或WER)的重要组成部分。通过简单的显式 API 调用,例如MiniDumpWriteDump,任何用户或程序都可以(如果权限允许)转储目标进程的完整内存布局和内容,以及更多信息,具体取决于调用期间传递的标志,例如

  • 句柄信息
  • 线程信息
  • 卸载的模块详细信息等等

可以在此处查阅可用类型的完整参考列表。

我们决定使用用于调试软件的内存转储来扩展我们勒索软件防护功能现有的金丝雀文件功能。当检测到勒索软件时,我们在进程终止前生成完整的内存转储。使用内存转储来对抗恶意软件具有巨大的优势,包括

  • 揭示进程内存布局,这在打包模糊了内存区域时特别有用。
  • 公开进程正在运行时的所有内存内容,包括未擦除的内存区域,因为出于性能原因,Windows 不会立即擦除内存。
  • 提供稳定安全的方法,通过仿真来实验对抗恶意软件。

很快,我们就拥有了一种稳定可靠的方法来检测金丝雀写访问并生成触发它们的勒索软件的完整内存转储。由于时间限制,我们选择了两个流行的家族来测试我们项目的分析阶段:NOTPETYA 和 WANNACRY。

原型代码可以在此处找到,并且不适用于生产环境。请自行承担风险,使用非生产系统进行实验。

真实案例

从进程运行时恢复密钥:NOTPETYA 案例

为什么选择 NOTPETYA?它是一个很好的首选对象,因为它使用一个随机会话密钥来加密所有文件。它还使用了强大的加密技术。

  • 用于主机级非对称加密密钥的 RSA-1024。
  • 用于加密文件的唯一 AES-128 CBC 密钥。

使用上面制作的驱动程序和代理,我们可以轻松地在受限环境中运行 NOTPETYA(SHA1 027cc450ef5f8c5f653329641ec1fed91f694e0d229928963b30f6b0d7d3a745),并在非常可预测的运行时位置获取进程最小转储。

我们当前的设计导致驱动程序同步捕获写入操作,因此在分析转储文件时,我们确切地知道我们在进程运行时的哪个位置。但是,我们仍然需要进行一些逆向工程才能了解会话密钥是如何生成的。

对这个 NOTPETYA DLL 进行逆向工程被证明是简单的,这帮助我们快速推进。

  • 经过一些初始检查,DLL 尝试迭代所有可能的驱动器盘符,并且对于每个匹配项(例如,盘符 - 例如C:\存在),将创建一个0x20线程上下文以继续进行加密。

  • 每个线程使用 Microsoft CryptoAPI 初始化它自己的加密上下文;我们注意到使用了 AES-CBC 128 位。

  • 递归加密文件(最大递归级别为 15),删除勒索信息并销毁加密上下文。

  • 文件加密本身是使用文件支持映射来覆盖特定目标扩展名的文件。

这为我们留下了一个非常基本的基于栈的上下文结构。

c
struct _THREAD_CONTEXT { /* sizeof=0x20, align=0x4, mappedto_50) */
  /* 00000000 */ WORD lpswzRootPathName[4];
  /* 00000008 */ HANDLE hProvider;
  /* 0000000C */ PVOID field_C;
  /* 00000010 */ LPVOID pBase64Data;
  /* 00000014 */ HCRYPTPROV hKey;
  /* 00000018 */ DWORD field_18;
  /* 0000001C */ HANDLE hFile;
};

有了这些知识,我们就可以进一步探索转储。由于我们知道写访问是使用kernel32!CreateFileMapping进行的,这意味着调用了ntdll!NtCreateSection,并且我们可以隔离触发对金丝雀文件的系统调用的活动线程。

dx @$curprocess.Threads.Where( t => t.Stack.Frames.First().ToDisplayString().Contains("NtCreateSection") )

如前所述,我们已经隔离了会话上下文,并且知道它位于栈中。从基指针到会话上下文,我们可以从上下文结构成员_THREAD_CONTEXT.hKey(位于偏移量 0x14 处)检索加密上下文。

0:007:x86> dx @$curthread.Stack.Frames[3].Attributes.FrameOffset + 0x10
@$curthread.Stack.Frames[3].Attributes.FrameOffset + 0x10 : 0x518d210
0:007:x86> dps poi(0x518d210) l6
004859a0  003a0043
004859a4  0000005c
004859a8  00538418
004859ac  00000000
004859b0  04060550 
004859b4  0048fc48   <<< hKey
0:007:x86> dps 0048fc48 
0048fc48  74a850c0 rsaenh!CPGenKey
0048fc4c  74a9ad90 rsaenh!CPDeriveKey
0048fc50  74a886c0 rsaenh!CPDestroyKey
0048fc54  74a9c770 rsaenh!CPSetKeyParam
0048fc58  74a898c0 rsaenh!CPGetKeyParam
0048fc5c  74a84c40 rsaenh!CPExportKey
0048fc60  74a86290 rsaenh!CPImportKey
0048fc64  74a99880 rsaenh!CPEncrypt
0048fc68  74a8a500 rsaenh!CPDecrypt
0048fc6c  74a9b5c0 rsaenh!CPDuplicateKey
0048fc70  00538418 
0048fc74  e3155764 <<< hCryptKey
0048fc78  22222222
[...]

加密上下文结构没有被 Microsoft 公开访问,但已经被逆向工程

struct HCRYPTKEY
{
    void* CPGenKey;
    void* CPDeriveKey;
    void* CPDestroyKey;
    void* CPSetKeyParam;
    void* CPGetKeyParam;
    void* CPExportKey;
    void* CPImportKey;
    void* CPEncrypt;
    void* CPDecrypt;
    void* CPDuplicateKey;
    HCRYPTPROV hCryptProv;
    magic_s *magic; // XOR-ed
};
struct magic_s
{
    key_data_s *key_data;
};

struct key_data_s
{
    void *unknown; // XOR-ed pointer
    uint32_t alg;
    uint32_t flags;
    uint32_t key_size;
    void* key_bytes;
};

从此上下文中,我们可以提取并解码 AES 结构的位置,因为对于 32 位进程,密钥已知为0xE35A172C

0:007:x86> ? e3155764^ 0xE35A172C
Evaluate expression: 5193800 = 004f4048

0:007:x86> dps poi(004f4048 ) l5
0053cdd0  e3152844   // /* +0 */ unknown
0053cdd4  0000660e   // /* +4 */ alg
0053cdd8  00000001   // /* +8 */ flags
0053cddc  00000010   // /* +c */ key_size
0053cde0  0053ce70   // /* +10 */ key_bytes

从转储中,我们还知道密钥的类型(AES-CBC)、内存中的位置(0x053ce70)和大小(0x10)。会话密钥可以成功检索!

这不仅允许完全解密此进程的所有加密文件,而且敏锐的观察者会注意到所有这些步骤都可以自动化,使我们能够仅使用生成的内存转储创建解密程序!

要完整了解此过程,您可以观看演示并在 GitHub 上查看代码

我们甚至可以创建适用于感染相同变体的所有机器的解密脚本。即使 WinDbg 是首选工具,所有这些步骤都可以完全自动化,使这种方法具有很强的可扩展性。

从进程运行时预测加密密钥:WANNACRY 案例

WANNACRY 是另一个我们认为适合此实验的勒索软件家族,因为它广为人知,并且——对这项研究来说最重要的是——使用了更复杂的逻辑来进行文件加密。

深入探讨 Windows(伪)随机数生成

为了加密文件,WANNACRY 使用 Windows 的加密库,并通过高级 API 函数advapi32!CryptGenRandom为每个文件生成一个随机 AES 密钥。每个密钥都与相应的文件相关联,然后进行 RSA 加密并提交到其 C2。根据设计,我们针对 NOTPETYA 使用的方法在这里将不起作用。WANNACRY 为我们带来了不同的挑战,再次证明拥有完整的内存转储提供了其他宝贵的资源。

随机数生成通常比大多数人想象的要少随机。生成真正的随机数既具有挑战性又成本高昂,而这一挑战是任何加密算法的核心。

Windows(与其他操作系统类似)以伪随机的方式生成随机数。这意味着随机数生成器使用加密函数(例如,XorShift 或 Mersenne-Twister)派生初始状态(称为种子)。使用 PRNG 的逻辑结果之一是,知道某个时刻 T 随机生成器的状态,就能精确地知道 T+1、T+2 等所有随机值。请注意,这不是一个弱点,因为随机性是一个高度复杂且性能成本高昂的操作;这种方法是一种很好的权衡。

我们将利用此特性来击败 WANNACRY。知道 WANNACRY 会反复调用 CryptGenRandom 为每个文件生成 AES 加密,如果我们有一种方法可以通过最小转储文件的仿真严格地知道这些值,那么我们也将知道可能的 AES 密钥。这看起来很有希望,但可能隐藏着一些障碍。

退一步说,CryptGenRandom 究竟是什么——它做了什么?MSDN 告诉我们,此(已弃用)函数使用加密服务提供程序(HCRYPTPROV) 用随机内容填充缓冲区。设置断点到 CryptGenRandom 允许我们使用 WinDbg 在 Windows 11 x64 上查看其内部工作原理。然后我们可以轻松遍历高级 API 并观察到advapi32!CryptGenRandomcryptsp!CryptGenRandom 的包装器,后者又引导我们到rsaenh.dll 中的CPGenRandom 函数。

0:000> g
Breakpoint 9 hit
CRYPTSP!CryptGenRandom+0x29:
00007ffc`990c1699 488b8be0000000  mov     rcx,qword ptr [rbx+0E0h] ds:000001e1`38ade010=e35a16cde1cff7d0
0:000> dps @rbx
000001e1`38addf30  00007ffc`987956d0 rsaenh!CPAcquireContext
000001e1`38addf38  00007ffc`987951e0 rsaenh!CPReleaseContext
000001e1`38addf40  00007ffc`98791140 rsaenh!CPGenKey
000001e1`38addf48  00007ffc`987a8f80 rsaenh!CPDeriveKey
000001e1`38addf50  00007ffc`987948a0 rsaenh!CPDestroyKey
000001e1`38addf58  00007ffc`987aaac0 rsaenh!CPSetKeyParam
[...]

0:000> t
CRYPTSP!CryptGenRandom+0x3c:
00007ffc`990c16ac ff1506c50000    call    qword ptr [CRYPTSP!_guard_dispatch_icall_fptr (00007ffc`990cdbb8)] ds:00007ffc`990cdbb8={CRYPTSP!guard_dispatch_icall_nop (00007ffc`990c4d30)}

0:000> r rax, rcx,rdx ,r8
rax=00007ffc987954d0 rcx=e35a16cde1cff7d0 rdx=0000000000000010 r8=00000065859bfe70

0:000> .printf "%y\n", @rax
rsaenh!CPGenRandom (00007ffc`987954d0)

当调用CRYPTSP!CryptGenRandom时,RCX寄存器保存着编码后的加密提供程序的指针,该指针与魔数常量0xE35A172CD96214A0进行异或编码(还记得我们之前用过的0xE35A172C魔数常量吗?这是它的64位版本对应项)。在IDA中查看rsaenh!CPGenRandom可以清楚地看出,加密提供程序句柄仅用作检查,以确定传递给函数的上下文的有效性,但对随机数生成没有实际影响。

整个随机数生成逻辑被委托给函数cryptbase!SystemFunction036,该函数只接收两个参数:接收随机数据的缓冲区及其长度。这是一个好消息,因为随机数生成没有攻击者可以在运行时利用的外部因素来使生成过程更加复杂。进一步深入研究后,我们意识到cryptbase!SystemFunction036本身只不过是bcryptprimitives!ProcessPrng的一个轻量级包装器,从函数名称来看,似乎符合我们的预期。

bcryptprimitives DLL 是 下一代加密 API (CNG) 的一部分,并且非常复杂。完全反向工程超出了本次研究的范围,因此我们只关注我们感兴趣的部分。首先,我们观察到,一旦加载到进程中,库就会初始化进程种子 - 可以来自rdrand 指令,也可以来自对IumKernelState 可信执行环境 (TEE) 的 VTL1 调用,在明确命名的InitUmRootRngState函数中。然后,它填充随机数生成器状态表,并在ntdll!_KUSER_SHARED_DATA::RNGSeedVersion中更新 RNG 种子版本状态。

当调用ProcessPrng时,下一个伪随机数的生成由特定于 CPU 的状态决定。准确地说,当前线程正在运行的处理器编号被用作索引来加载并生成下一个数字。我们稍后会详细解释,但这在未来将是一个挑战。使用此状态信息,通过调用AesRNGState_generate生成下一个数字,并将结果存储在参数中给定的缓冲区中。

这对我们试图实现的目标来说是一个不容忽视的问题。在支持多处理器的 Windows 系统上(所有现代 PC 都是如此),很难始终知道线程正在运行的处理器编号,这使得生成预测变得不可能。但是,Windows 提供了影响调度程序的方法,如下所示。

通过内存转储的用户模式仿真预测伪随机数

考虑到为了击败 WANNACRY,我们需要能够直接从内存转储中执行函数cryptbase!SystemFunction036。我们可以使用仿真器(如 QEMU 或 Bochs)通过映射从勒索软件内存转储中收集到的执行上下文(填充内存布局,恢复 TEB/PEB 等)来实现这一点,我们按照以下步骤操作

  1. 解析用户模式转储以提取和映射所有内存布局;对于此步骤,我们使用了 udmp-parser 库的 Python 绑定
  2. 在仿真器中完全重建一个可用的内存布局,为此使用了bochscpu及其Python 绑定
  3. 通过查找函数cryptbase!SystemFunction036并模拟运行时来重建有效的线程上下文

但是,我们仍然无法预测调用cryptbase!SystemFunction036的线程将在哪个 CPU 上运行,因此无法准确预测函数返回的后续值。在单核机器上,这不是问题,因为我们的 PRNG 状态表将只包含一个条目,并且此方法经测试可在开箱即用时完美运行。但是,它在多核系统上会失败,因为只有第一次调用cryptbase!SystemFunction036才会返回正确的随机值。

为了在多核机器上进行准确的仿真,我们需要知道在运行时调用cryptbase!SystemFunction036的下一个线程将在哪个处理器上运行,这几乎是不可能的。我们测试了两种可能的方法

  1. 从转储中,我们了解了整个 PRNG 状态表。因此,我们可以使仿真脚本挂钩函数ntdll!RtlGetCurrentProcessorNumberEx并使用它来确定随机表中的索引,然后让它为特定核心生成所有值。这种方法被证明是成功的,但非常繁琐,尤其是在规模上,因为自动化会产生指数级的可能性来检索正确生成的序列。
  2. 另一种选择发生在金丝雀检测本身期间。一旦金丝雀确认它是勒索软件,我们就可以将罪魁祸首进程的 CPU 亲和性强制到仅一个 CPU,我们可以自由选择其索引。只要目标进程以PROCESS_SET_INFORMATION访问权限打开,就可以从内核或用户模式执行此操作。此处理器索引将确定在AesStateTable数组中获取的条目,这样做使我们能够可靠地预测 PNRG 的所有未来值通过仿真。

要完整查看 WANNACRY 进程,您可以观看演示。我们还在 GitHub 上提供了可供审查的代码

测试这两种技术表明,可以使用我们掌握的 minidump 来预测 PRNG 的未来值。这将对像 WANNACRY 这样的勒索软件非常有用,WANNACRY 使用 Windows PRNG 为每个加密文件生成唯一的 AES 密钥。

将此研究整合到 Elastic Endpoint 中

Elastic 的 ON 周是一个不受限制地进行实验的地方,通常会带来对现有 Elastic 解决方案的重大改进。

在 Elastic Security 版本8.11中添加了进程快照生成功能。如果启用了保护,如果检测到勒索软件,端点将在恢复执行之前生成完整的内存进程转储,这可能会导致勒索软件进程终止。我们希望这个简单的补充可以通过提供对勒索软件试图做什么的更深入了解来进一步帮助 DFIR 团队。

最近的新闻表明,如果公开发布,进程内存转储可能会泄露大量有价值的私人信息。因此,必须强调的是,即使启用了此功能,也不会将任何内存转储提交到 Elastic。转储文件由端点在本地生成(并压缩),生成的文件夹存储在 Elastic 的安全安装文件夹中(默认情况下为$ElasticInstallPath\Endpoint\cache\RansomwareDumps)。这样,攻击者就无法轻易篡改转储文件,但取证和事件响应团队可以轻松访问这些文件,以帮助他们进行恢复过程。

让我们在针对 NOTPETYA 的全新 Elastic 8.11 上演示此功能:观看演示

结束语

这结束了我们的 ON 周研究,结果相当积极。我们是否想出了针对所有勒索软件的万无一失的解决方案?没有,而且这样的东西可能永远不会存在。正如我们在引言中强调的那样,勒索软件种类繁多,想要找到一个适用于所有情况的解决方案可能是不可能的。

然而,这项研究发现,这种方法在误报风险、系统要求和潜在结果之间提供了很好的权衡。如果金丝雀功能将进程标记为勒索软件,则对进程内存进行快照的风险非常小。如果出现误报,计算机最终只会在一个受保护的位置生成一个转储文件(并且 ZIP 压缩会大大减少磁盘占用空间)。

虽然这不是完美的勒索软件解决方案,但提供勒索软件的内存转储可以促进取证工作,并可能允许团队恢复甚至预测会话加密密钥。完整的内存转储可以成为调试和取证的强大盟友,因为它们提供了运行时事件发生方式的详尽视图。并且,借助模拟,我们可以自信地回溯导致安全漏洞的一些步骤,并希望修复它。