Fuzzing Windows Defender with loadlibrary in 2025
正如我在上一篇文章[1]中展示的,我最近开始投入大量精力使 loadlibrary 能够与最新版本的 Windows Defender 兼容。我很高兴地报告,本周我成功构建了一个概念验证的模糊测试环境,驱动了版本 1.1.25020.1007 的 mpengine.dll
!这篇文章是我到目前为止的过程总结。第一部分关于 Lua 虚拟机,在内存损坏方面可能不太有趣,但如果红队成员仔细关注,可能会发现一些有用的技术和想法;) 第二部分专注于模糊测试。
本文中的代码来自版本 1.1.24090.11 的 mpengine.dll
,模糊测试结果将使用 1.1.25020.1007 版本展示。
深入 Defender 的 Lua 虚拟机
在我上一篇文章[2]中,我们达到了可以让 mpclient_x64 检测 EICAR 测试文件的阶段。在短暂的庆祝之后,我使用一个包含旧版恶意软件样本的 RAR 压缩包进行了另一个测试。通过这种方式,我可以测试更复杂的检测功能,最重要的是,可以测试 Defender 的解包功能。解包组件——用于反病毒引擎中剥离压缩层、可执行文件打包等——由于其复杂性和第三方组件质量参差不齐,容易存在漏洞[3]。
第二次测试得出了一个令人困扰的结果:通常容易追踪到缺失的 mock API 的段错误(segmentation fault)没有出现,而是出现了一个未处理的 C++ 异常,最终在 mpclient 中被捕获。我使用 rr[4] 追踪了这个异常,发现它来自嵌入在 mpengine.dll
中的 Lua 虚拟机:
void __cdecl luaV_gettable(lua_State *state,lua_TValue *t,lua_TValue *key,lua_TValue *val){
//...
if ((lua_TValue *)callinfo[2] <= key_scalar_val) {
luaG_runerror(state,"attempt to %s a %s value","index",type_name); // "attempt to index a nil value", throws exception
pcVar7 = (code *)swi(3);
(*pcVar7)();
return;
}
//...
}
Windows Defender 的大部分签名逻辑都是通过 Lua 实现的,这很可能是为了支持复杂签名逻辑的快速开发。签名格式(以 .VDM 文件分发)已被逆向工程,并且有多种提取工具可用 - 我无法追溯这项研究的历史,但一些值得注意的资源包括:
-
commial 的实验[5] -
hfiref0x/WDExtract[6] -
SafeBreach - Defender Pretender[7]
此外,cpunk 用 Python 编写的 Lua 字节码解析器[8] 是帮助我快速掌握 Lua 字节码的最有用资源。这篇技术文章引导我找到了以下额外的 Lua 资源:
-
Internet Archive 再次拯救了局面,它存档了链接中的 Lua 简明介绍[9]。 -
Ravi 编程语言的 字节码参考[10] 也被证明对高级概述非常有用。 -
"标准"的 Lua 反编译器是 luadec[11],但正如你将看到的,cpunk 的 LuaPytecode[12] 对于底层调查也非常方便。
我使用 commial 的脚本来解压 .VDM 内容。通过这种方式,Lua 字节码变得直接可见,但诸如 luadec 等工具期望的头部信息并不完整(Lua 字节码块仍然可以通过 'x1bLua'
的魔数值轻松识别)。这个结果足以根据异常发生前处理的字节码来识别失败的字节码。最初,我使用了一个由 cube0x8 提取的部分字节码序列,该序列是通过窥探 LuaStandalone::AddScript()
的执行获得的。后来,我深入到了 luaV_execute()
,即虚拟机的主执行循环,以单独观察指令的执行。
[[svg]]
luaV_execute()
的控制流图,由 function-graph-overview[13] 生成
正如你在上面的控制流图(CFG)中看到的,这个函数确实非常复杂。幸运的是,我们不仅可以从 Microsoft 获取公共符号(通常在新引擎版本发布后几周内),还可以将编译后的 luaV_gettable()
函数与 Lua 5.1.5 的官方源码[14] 进行紧密匹配(尽管有很多内联函数,后来我还发现了一些在 5.1 版本中应该被弃用的函数)。最终,我在 luaV_execute()
中 hook 了以下代码:
luaV_execute():
; ...
75a16bbdc 4c 8b f1 MOV R14,RCX ; R14 := lua_state (first argument for luaV_execute)
75a16bbdf 49 8b 46 28 MOV RAX,qword ptr [R14 + 0x28]
75a16bbe3 4d 8b 66 30 MOV R12,qword ptr [R14 + 0x30] ; R12 := state->savedpc
; ...
75a16bc07 41 8b 1c 24 MOV EBX,dword ptr [R12] ; EBX := Lua instruction (4 bytes)
LuaPytecode 在此刻非常有用,因为它可以被轻松修改以忽略缺失的头部信息并打印出所有成功解析的操作码(opcodes)——异常发生在解析 OP_SELF
指令时:
VM version 51
instructions: 21
00000005 GETGLOBAL : R[0] K[0]
00404006 GETTABLE : R[0] 0 K[1]
00008045 GETGLOBAL : R[1] K[2]
00C0C046 GETTABLE : R[1] 1 K[3]
00008085 GETGLOBAL : R[2] K[2]
01410086 GETTABLE : R[2] 2 K[4]
0100005C CALL : 1 2 0
0000801C CALL : 0 0 2
0041400B SELF : R[0] 0 K[5]
...
遗憾的是,在没有符号表的情况下很难准确判断具体调用了什么,但令我稍感意外的是,这个脚本[15]仍然能够从 .VDM 内容中恢复"标准"的 Lua 头部信息,因此我们可以获取所有符号及其索引:
0: [STRING] MpCommon
1: [STRING] PathToWin32Path
2: [STRING] mp
3: [STRING] getfilename
4: [STRING] FILEPATH_QUERY_FULL
5: [STRING] lower
6: [STRING] find
...
... 甚至可以使用 luadec:
local l_0_0 = ((MpCommon.PathToWin32Path)((mp.getfilename)(mp.FILEPATH_QUERY_FULL))):lower()
为什么恶意软件特征码中需要这样的代码?🤔
根据字节码和符号表,我们可以看到对 lower()
的调用失败了(在 Lua 中,所有内容都是表,即键值存储。OP_SELF
引用了 K[5]
键,符号 #5 是 "lower"
)。这意味着 MpCommon.PathToWin32Path
必须返回了 nil
。这可以通过在失败的 luaV_gettable()
调用处设置断点并转储 Lua 字符串来进一步确认:
Breakpoint 1, 0x000000075a164ba0 in ?? ()
^ luaV_gettable()
(rr) x/2gx $r8
0x7fde6bede9cf: 0x00007fde718c61b5 0x0000000000000004
^ lua_TValue pointer ^ lua_TValue type (4=string)
(rr) x/s *((void**)$r8)+0x18
0x7fde718c61cd: "lower"
^ ASCII value starts at 0x18
MpCommon
和 mp
表自然是由 mpengine.dll
定义的,用于提供引擎部分原生功能的接口。例如,MpCommon.PathToWin32Path
引用了 mpengine.dll
内部的原生函数 LsaMpCommonLib::PathToWin32Path()
。该函数用于规范化 mp.getfilename()
(由 lua_mp_getfilename()
实现)可能返回的文件名,这些文件名可能以多种格式[16]存在。在花费了相当长的时间尝试寻找这条代码路径上可能导致 PathToWin32Path()
失败的模拟 WINAPI 调用后,我意识到虽然这个函数本应只处理绝对路径,但 mpclient_x64(不是模拟 API!)总是提供 "input"
作为输入文件名——这有什么关系呢?实际上,在字符串前添加盘符前缀后,mpclient_x64 立即检测到了 RAR 压缩包中的大量恶意软件!
不幸的是,现在我遇到了另一个 C++ 异常... 重复之前描述的过程后,我得到了以下失败的 Lua 代码:
local l_0_0 = (mp.readfile)((pe.foffset_rva)(pehdr.AddressOfEntryPoint) - 918, 768)
问题在于 pe.foffset_rva
(由 lua_pe_foffset_worker()
实现)返回的 PE 入口点偏移量小于 918,导致 mp.readfile
(由 lua_mp_readfile()
实现)因参数为负而失败。这一点很重要,因为正如 Ange Albertini 所证实[17]的,没有任何保证 PE 文件的入口点偏移量会大于 918,因此这个 Lua 脚本会在很多输入情况下失败(幸运的是,原始 Defender 中可能有一个适当的异常处理程序)。
此时,似乎最好直接将 Lua 从等式中移除:虽然 VM 实现及其提供的原生函数确实可能存在漏洞,但在无法控制字节码(特征文件)的情况下,利用这些漏洞似乎是一个相当遥远的目标。当然,一个"银河大脑"级别的解决方案是创建恶意软件,让微软为其生成恰好为真正漏洞利用做好准备的 Lua 代码
在下一节中,我将介绍一个简单的 fuzzing 设置,它能够驱动最新的 64 位 mpengine.dll
版本在 Linux 上运行,并执行解包逻辑,同时不会在 Lua 执行上浪费(太多)资源。
Fuzzing
基于前几节的结论,使用 Lua VM 进行 fuzzing 会使我们的目标非常不稳定,同时将资源浪费在一个不太有吸引力的攻击面上,所以让我们摆脱它!幸运的是,loadlibrary 包含了 hooking 代码,cube0x8 的 x64 分支对其进行了改进以支持 64 位。有了这个,我们可以简单地将 luaV_execute()
替换为一个空函数:
voidmy_lua_exec(){
return;
}
intmain(int argc, char** argv){
// ...
insert_function_redirect((void*)luaV_execute_address, my_lua_exec, HOOK_REPLACE_FUNCTION);
// ...
}
这一改动导致连最简单的 EICAR 测试样本都无法检测,但输出显示解包器仍在正常工作:
$ ./mpclient_x64 ../eicar.7z 2>&1 | fgrep Callback
pelinker (import:285): unknown symbol: KERNEL32.dll:FreeLibraryWhenCallbackReturns
WaitForThreadpoolTimerCallbacks(): 0x41414141, 1
WaitForThreadpoolTimerCallbacks(): 0x41414141, 1
WaitForThreadpoolTimerCallbacks(): 0x41414141, 1
WaitForThreadpoolTimerCallbacks(): 0x41414141, 1
EngineScanCallback(): Scanning C:mpclient.input
EngineScanCallback(): Packer nUFS_7z identified.
EngineScanCallback(): Scanning C:mpclient.input->eicar.com
在继续集成 fuzzer 之前,我还想尝试将我们改进后的 mpclient_x64 适配到最新版本的 mpengine.dll
有多困难。到目前为止,我使用的是 2024 版本的 DLL,因为更新的版本在初始化时会崩溃——现在是时候调试这个问题了。问题出在 Defender 的本地缓存上:在 Windows 系统中,位于 C:ProgramDataMicrosoftWindows DefenderScans
目录下,以 mpcache-
为前缀的一系列文件。虽然在 2024 版本中引擎对此没有抱怨,但 2025 版本不仅想要打开这些文件,还尝试将它们映射到内存并检查其内容。这个变化最终体现在用于打开缓存文件的 CreateFile
调用中的 dwCreationDisposition
标志上:早期我们的模拟 API 返回 NULL 句柄,而使用新标志时它返回了指向 /dev/null
的句柄。调整我们的实现,在文件名包含 "mpcache-"
时返回 NULL,使得引擎能够放弃缓存初始化并正常继续。
有了这个相对稳定且最新的实现,我们可以尝试执行一个非常简单的 fuzzing 运行:
afl-fuzz -Q -i ~/input/ -o ~/output/ -t 17000 -- ./mpclient_x64 @@
我们的首次尝试是使用 AFL++[18] 的 QEMU 模式 - 由于无法重新编译 mpengine.dll
,我们需要一种仅针对二进制文件的插桩方法... 种子文件是一个压缩的 EICAR 测试文件。超时时间必须显著增加:原生执行本身就很慢,而模拟执行进一步增加了执行时间。需要注意的是,这是非持久化模式,因此我们可以通过仅运行一次引擎初始化来轻松减少这个时间。更大的问题是 QEMU 似乎没有提供覆盖率信息。我还没有深入调查这个问题,我的理论是奇怪的加载器或混合调用约定可能是问题的根源。
根据我过去的经验[19],我决定拿出杀手锏,使用 Intel PT 进行基于硬件的覆盖率追踪。为此,我切换到了 honggfuzz[20],我记得它很容易与 IPT 集成,结果没有让我失望:
../honggfuzz/honggfuzz -i ~/input/ -W ~/workspace/ --linux_perf_ipt_block -t 10 -- ./mpclient_x64 ___FILE___
IPT(Intel Processor Trace)似乎如预期般收集到了覆盖率数据,虽然性能并不理想(约 4 秒/次执行),但已经比 QEMU 要好!如果我们实现持久化模式会怎样?Honggfuzz 提供了两种方式[21]来实现这一点:ASAN 风格和 HF_ITER 风格。由于我们的构建脚本依赖于 GCC 特定的编译器标志,我们无法立即使用 ASAN 风格,但 HF_ITER 更适合 mpclient_x64 的原始结构:我们只需要调用 fuzzer 的 HF_ITER()
函数来加载输入数据,然后将这些数据传递给 mpengine 的 __rsignal()
。一个小问题是 __risgnal()
期望一个流句柄(在 mpclient_x64 中调用时为 FILE*
),而不是字节数组指针。这可以通过使用 fmemopen[22] 库函数轻松解决,该函数可以打开一个指向内存缓冲区的流。持久化 fuzzer 循环大致如下:
for (;;) {
size_t len;
uint8_t *buf;
HF_ITER(&buf, &len);
ScanDescriptor.UserPtr = fmemopen(buf, len, "r");
if (__rsignal(&KernelHandle, RSIG_SCAN_STREAMBUFFER, &ScanParams, sizeof ScanParams) != 0) {
// ...
}
//...
}
为了确认解决方案确实有效,我使用 honggfuzz 的 -Q
选项开始监控执行输出:
我们可以看到从种子存档中提取的文件名频繁变化,这证实了不同的输入命中了 ZIP 解包器。在性能方面,消除初始化带来了显著改进,实现了每秒数百次执行:
我认为这是一个成功:两个月前,2025 版本的引擎甚至无法初始化,而更早的引擎在最简单的输入上就会崩溃。现在,我们能够在使用最新 64 位引擎的情况下,获得数百万次 fuzzer 运行的稳定结果(我还没有执行更长时间的测试)。我也希望这些文档能让未来的维护工作对新贡献者来说变得更加容易和可访问。
当然,这个概念验证设置还存在很多问题 - 举几个我想到的例子:
-
honggfuzz 生成的语料库有点奇怪,例如它包含一些不可能随机出现的、有意义的长字符串,必须调查这些字符串的来源以排除 fuzzing 设置中的错误。 -
随着输入变得更加复杂,浮点异常频繁发生,需要消除这些异常以减少噪音(最新更新:这来自 .NET 模拟器,听起来像是一个后续话题!)。 -
mpclient_x64 仍然在调试日志记录、次优的 Lua 引擎挂钩等方面浪费了大量资源。
这些问题看起来是可以解决的,而且大多是任何 fuzzing 项目的一部分。下一个补丁星期二将是测试调整 mpclient_x64 以适应引擎更新难度的好机会。
相关代码可在 这个 Git 分支[23] 中找到。一如既往地欢迎贡献 - 包括错误报告!
参考资料
上一篇文章: https://scrapco.de/blog/debugging-loadlibrary-through-space-and-time.html
[2]上一篇文章: https://scrapco.de/blog/debugging-loadlibrary-through-space-and-time.html
[3]容易存在漏洞: https://project-zero.issues.chromium.org/issues?q=rar
[4]rr: https://rr-project.org/
[5]commial 的实验: https://github.com/commial/experiments/tree/master/windows-defender/
[6]hfiref0x/WDExtract: https://github.com/hfiref0x/WDExtract
[7]SafeBreach - Defender Pretender: https://www.safebreach.com/blog/defender-pretender-when-windows-defender-updates-become-a-security-risk/
[8]cpunk 用 Python 编写的 Lua 字节码解析器: https://openpunk.com/pages/lua-bytecode-parser/
[9]Lua 简明介绍: https://web.archive.org/web/20160329075559/http://luaforge.net/docman/83/98/ANoFrillsIntroToLua51VMInstructions.pdf
[10]字节码参考: https://the-ravi-programming-language.readthedocs.io/en/latest/lua_bytecode_reference.html
[11]luadec: https://github.com/viruscamp/luadec
[12]cpunk 的 LuaPytecode: https://git.openpunk.com/CPunch/LuaPytecode
[13]function-graph-overview: https://tmr232.github.io/function-graph-overview/
[14]Lua 5.1.5 的官方源码: https://www.lua.org/source/5.1/
[15]这个脚本: https://github.com/commial/experiments/blob/master/windows-defender/lua/parse.py
[16]多种格式: https://googleprojectzero.blogspot.com/2016/02/the-definitive-guide-on-win32-to-nt.html
[17]证实: https://mastodon.social/@Ange/114460565366777490
[18]AFL++: https://github.com/AFLplusplus/AFLplusplus
[19]过去的经验: https://scrapco.de/internalis.html#snapshot-fuzzing
[20]honggfuzz: https://github.com/google/honggfuzz
[21]两种方式: https://github.com/google/honggfuzz/blob/master/docs/PersistentFuzzing.md
[22]fmemopen: https://www.man7.org/linux/man-pages/man3/fmemopen.3.html
[23]这个 Git 分支: https://github.com/v-p-b/loadlibrary/tree/x64_waffle
[24]我做的其他事情: https://scrapco.de/internalis.html
[25]联系我: https://scrapco.de/astronomican.html
原文始发于微信公众号(securitainment):突破 Windows Defender:使用 loadlibrary 进行 Fuzzing @ 2025
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论