利用整数溢出绕过 EDR 实现 LSASS 进程内存转储
一句话结论:本文用一条「看起来无害」的 rundll32 命令把 lsass 全内存 dump 出来。它不出现 MiniDump 字符串、不写 #24,而是喂给 _wtoi 一个三十多位的负数,靠 32 位整数溢出的模运算把这个长数字「折叠」成 comsvcs.dll 里 MiniDumpW 的导出序号 24。作者实测可过虎符与数字核晶。
排版说明:为规避排版转义问题,文中 Windows 路径的反斜杠统一写成正斜杠,字符字面量(如制表符、空字符)用名字描述,正文引用一律使用「」。
一、要做什么
核心动作是把 lsass 进程(示例 PID 为 680)的完整内存转储到 dump.bin,拿到 dump 后用 mimikatz 之类工具提取密码哈希。落地命令的骨架是:
rundll32 C:/Windows/System32/comsvcs.dll 入口点 PID 输出文件 类型
正常写法会写成 comsvcs.dll,#24 680 dump.bin full,直接暴露 #24 和 comsvcs.dll 这两个高危特征。本文要做的就是把 #24 藏起来。
二、三个可能的绕过点
| 绕过点 | 做法 |
|---|---|
| 不出现 MiniDump 字符串 | 用 #-999...976 这一长串代替函数名,命令行里既没有 MiniDumpW 也没有 #24 |
| 用 LOLBins | 借 rundll32.exe(微软签名的系统自带程序)加载并调用,杀软不易直接拦截 |
| 用整数溢出 | 超长负数经 _wtoi 内部溢出运算后,恰好等于 MiniDumpW 的导出序号 24 |
三、分析路线
| 问题 | 涉及文件 | 核心函数 |
|---|---|---|
| dump 是怎么实现的 | comsvcs.dll | MiniDumpW(ordinal 24) |
#-999...976 怎么变成函数调用 |
rundll32.exe | _FindCommandFunction |
| 超长数字怎么变成 24 | msvcrt.dll | _wtoi → wcstolX → wcstoxlX |
四、前置知识
4.1 PE 与导出表
Windows 上所有 exe 和 dll 都是 PE 文件。exe 可独立运行,dll 必须被某个 exe 通过 LoadLibrary 加载进进程空间后才能用其中的函数。comsvcs.dll 内部有 MiniDumpW,但它不会自己执行,必须由 rundll32 把它加载进来、找到地址再调用。
导出表为每个导出函数记三样东西:函数名(MiniDumpW)、序号(24)、地址(0x1800272B0)。函数名和序号是等价的两种查找方式,好比找人可以叫名字也可以叫工号。最终目标就是让 GetProcAddress 拿着 24 去 comsvcs.dll 里找到 MiniDumpW,但不能在命令行直接写 #24(怕被匹配),所以要找一个「无害」的数字,让它经溢出运算恰好变成 24。
4.2 导入表与 IAT
exe 或 dll 会导入其它 dll 的函数。PE 加载时,Windows 加载器加载依赖 dll、查找每个导入函数地址、填进 IAT(导入地址表)。之后代码里通过 call cs:__imp__wtoi 调用导入函数。rundll32 的导入表里有 _wtoi、LoadLibraryExW、GetProcAddress 三个关键函数,暗示它的行为:加载某个 dll,用 _wtoi 解析数字,再用 GetProcAddress 查找函数。顺着 _wtoi 的调用者就能定位到处理 # 后面数字的代码。
4.3 GetProcAddress 的名字 / 序号判定
FARPROC GetProcAddress(
[in] HMODULE hModule,
[in] LPCSTR lpProcName
);
lpProcName 类型是指针,但也要支持传序号(一个小整数)。判定规则:如果 lpProcName 的高 16 位为 0,就当序号处理;否则当作指向函数名字符串的指针。字符串指针地址不会小于 65536,而序号通常是小数字,高 16 位为 0。
| 调用方式 | lpProcName 的值 | 高 16 位 | 解释 |
|---|---|---|---|
| GetProcAddress(h, 名字) | 一个真实字符串地址 | 非零 | 按函数名查找 |
| GetProcAddress(h, (LPCSTR)24) | 0x0000000000000018 | 0x0000 | 按序号 24 查找 |
rundll32 传进来的值来自 _wtoi 返回值再经 movzx edx, ax:movzx 取低 16 位并零扩展,保证高位为 0,于是 GetProcAddress 一定走「按序号查找」。整条链就是:_wtoi 返回值 → movzx 截断 → GetProcAddress 按序号。
4.4 整数溢出与模运算
32 位无符号整数范围是 0 到 4294967295(2^32-1)。超过范围不会报错,而是自动丢弃高位只保留低 32 位,等价于:
实际结果 = 数学结果 mod 2^32
4.5 宽字符与 ASCII
Windows 内部用 UTF-16LE 宽字符(wchar_t,每字符 2 字节),命令行参数也是宽字符。C 标准字符串(char)用 ASCII/UTF-8。_wtoi 输入是宽字符串,而 GetProcAddress 的名字参数是 ASCII;函数名路径需要 WideCharToMultiByte 转换,但序号路径不需要(序号是数字不是字符串)。
五、IDA 逆向:comsvcs.dll 里 dump 的实现
在 IDA 中打开 C:/Windows/System32/comsvcs.dll,View → Open Subviews → Exports,可看到 MiniDumpW 导出序号 24、地址 0x1800272B0、函数大小 743 字节。双击跳转后按 F5 反编译。
要点:MiniDumpW 第三个参数 a3(cmdline)就是 rundll32 传过来的命令行。当执行 comsvcs.dll,#24 680 dump.bin full 时,680 dump.bin full 这一段作为 a3 传进 MiniDumpW。它的工作流程:
输入 PID + Dump 路径 + Dump 类型,例如 680 C:/dump.bin full
↓ ExpandEnvironmentStringsW 展开环境变量(如 %TEMP%)
↓ wcschr 按空格切分:PID=680 / 路径=C:/dump.bin / 类型=full
↓ _wtoi 把 PID 字符串转整数
↓ OpenProcess(0x410) 0x410 = PROCESS_QUERY_INFORMATION | PROCESS_VM_READ
↓ CSecDesc::Initialize / Allow 允许 SYSTEM 与本地 Administrators 访问
↓ CreateFileW 创建输出文件
↓ MiniDumpWriteDump 执行真正的内存转储
full → MiniDumpWithFullMemory | MiniDumpWithHandleData
非 full → MiniDumpNormal
↓ CloseHandle / 释放安全描述符
MiniDumpWriteDump 来自 dbghelp.dll:
BOOL MiniDumpWriteDump(
HANDLE hProcess, DWORD ProcessId, HANDLE hFile,
MINIDUMP_TYPE DumpType,
PMINIDUMP_EXCEPTION_INFORMATION ExceptionParam,
PMINIDUMP_USER_STREAM_INFORMATION UserStreamParam,
PMINIDUMP_CALLBACK_INFORMATION CallbackParam
);
MiniDumpWithFullMemory 会转储进程完整内存,包含存放 NTLM/Kerberos 哈希的区域——这就是 dump hash 的原理。至此确认目标函数是 MiniDumpW(序号 24),但实际命令里既没写 MiniDumpW 也没写 #24,而是写了那串超长负数,它是怎么变成 24 的?得看 rundll32.exe。
六、IDA 逆向:rundll32.exe 的函数解析
打开 C:/Windows/System32/rundll32.exe,导入表里能看到 LoadLibraryExW、GetProcAddress、_wtoi。从 _wtoi 入手(在 __imp__wtoi 上按 X 查交叉引用),它被 _FindCommandFunction 调用。反编译该函数(还原后近似):
// 根据命令字符串在 DLL 中查找要执行的导出函数,支持两种形式:
// 1. #123 按 Ordinal 查找
// 2. Function 依次尝试 FunctionW、FunctionA、Function
void (__fastcall * _FindCommandFunction(HINSTANCE hModule, LPCWCH lpWideCharStr, int *a3))(...)
{
FARPROC ProcAddress = 0;
// 首字符是 # 且后面还有字符 —— 序号路径
if (*lpWideCharStr == '#' && lpWideCharStr[1])
{
unsigned __int16 ordinal = _wtoi(lpWideCharStr + 1); // 注意类型:int 赋给 16 位无符号,只留低 16 位
return ( ... )GetProcAddress(hModule, (LPCSTR)ordinal);
}
else
{
// 名字路径:算长度 → WideCharToMultiByte 转 ANSI →
// 依次尝试 名字+W、名字+A、原始名字,谁先命中用谁;
// W 失败回退 A 时 *a3 = 1
...
}
return ( ... )ProcAddress;
}
两条路径的分叉:
输入:DLL 句柄 + 命令字符串
│
是否以 # 开头?
┌────┴────┐
是 否
│ │
按 Ordinal 名字查找 → 试 FunctionW →(失败)→ FunctionA →(失败)→ Function
│
_wtoi(#后的串) → movzx → GetProcAddress(句柄, 序号)
本文走序号路径。关键隐患:_wtoi 返回值类型是 int(32 位),但被赋值给 unsigned __int16(16 位),只保留低 16 位。
上层 _InitCommandInfo 是 rundll32 的核心调度:LoadLibraryExW 加载 comsvcs.dll 得 hModule → _FindCommandFunction(hModule, 入口点串) 得函数地址 → 保存函数指针供后续调用。可把它理解为「执行前的准备函数」:加载 DLL、找到导出函数、按函数类型处理参数、把真正调用所需的句柄/地址/参数都备好。
七、IDA 逆向:msvcrt.dll 里的溢出根源
打开 C:/Windows/System32/msvcrt.dll,导出表找到 _wtoi(约 0x110105DA0),F5 反编译。
7.1 三层调用链
_wtoi(String)
└── wcstolX(String, NULL, 10, a4=1)
└── wcstoxlX(locale, String, NULL, base=10, flags=0, a6=1)
_wtoi 只是 wcstolX 的包装,它传入 a4 = 1;wcstolX 做 locale 处理后把 a4 原样透传给 wcstoxlX 的第 6 个参数 a6。这个 a6=1 就是「溢出时仍继续计算」的开关。
7.2 wcstoxlX 的累加与符号处理
初始化、跳空白、处理正负号:
初始化 → 跳过空白 → 检查 + / - → 如果是 - 则 v12 |= 2(记录这是负数)→ 开始处理数字
对输入 -999...976,先看到负号,v12 = v12 | 2 置 bit1 记录「负数」,但数值本身不受影响,只是把这个信息存下来。
确定进制与溢出上限(_wtoi 传 a4=10):
v13 = 0xFFFFFFFF / 10 = 429496729
v13 是安全累加上限:v11 是 unsigned int,若 v11 > 429496729,则 v11 * 10 一定会超过 0xFFFFFFFF,所以正常逻辑下超过 v13 就该停下检查。
核心累加循环:
if (a6 || v11 < v13 || (v11 == v13 && v14 <= 0xFFFFFFFF % a4))
{
v11 = v14 + a4 * v11; // v11 = v11 * 10 + digit
}
else
{
v12 |= 4; // 正常情况在这里判溢出并停止
if (!a3) break;
}
因为 _wtoi 传入 a6 = 1,a6 || ... 永远为真,于是「溢出就停止」的正常检查被彻底绕过:
if (1 || ...)
{
v11 = v14 + a4 * v11; // 恒定执行
}
结果:v11 是 unsigned int,超过 32 位只保留低 32 位,最终数学上等价于 v11 = (v11 * 10 + digit) mod 2^32。
7.3 返回值:neg 把溢出结果折成 24
result = -v11; // 默认按负数取负
if ((v12 & 2) == 0) // 若没置负号位
result = v11; // 正数直接返回 v11
return result;
对 -999...976:累加后 v11 = 0xFFFFFFE8,且 v12 & 2 != 0(是负数),所以 result = 0 - 0xFFFFFFE8 = 0x00000018 = 24。
_wtoi(负号 + 999...976)
→ wcstoxlX(...)
→ v11 = 0xFFFFFFE8
→ -v11
→ 24
八、把结论一般化并写代码
既然 v11 = 数字 mod 2^32,就不必死盯 -999...976 这一个串:只要构造一个负数,让它的数字部分对 2^32 取模等于 0xFFFFFFE8,经负号处理后都能得到 24。文末的 C++(作者注明 AI 辅助)就是干这个:
wtoi_overflow:用(result * 10 + digit) & mask(mask = 2^bits - 1)模拟 msvcrt 的溢出行为,用来验证生成的串确实折叠成目标序号。BigInt:手写大整数的加减乘与 toString,因为要构造几十位的超大数。generate_overflow_number(target, bits, min_digits):remainder = (-target) & 0xFFFFFFFF,对 target=24 即 0xFFFFFFE8;- 随机取一个系数 k(示例范围 3e9 到 9e9);
val = k * 2^32 + remainder,其低 32 位恰为 remainder;- 把 val 置为负数,得到一长串负号开头的数字。
main:拼命令rundll32.exe comsvcs.dll,#<生成的数> <PID> <输出文件> full,用 CreateProcessA 执行,WaitForSingleObject 等待,再检查 dump 文件是否落地。
这样每次运行生成的超长数字都不同(k 随机、位数可变),既不含 MiniDump 也不含 #24,静态特征匹配基本失效。
九、检测与防御建议
这条链本质是「滥用合法系统二进制 + 整数溢出折叠序号」,防御要落在行为而不是字符串:
- 监控
rundll32.exe加载comsvcs.dll/ 调用MiniDumpW,无论入口点写成 #24 还是任意溢出串,最终动作都是按序号取 MiniDumpW——按「模块+导出/行为」拦截,别按命令行字面匹配。 - 监控对 lsass 的
OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION)与MiniDumpWriteDump(MiniDumpWithFullMemory)。 - 启用 LSASS 保护(RunAsPPL / RunAsProtected),非特权进程无法读其内存。
- 用 WDAC / AppLocker 限制 rundll32 等 LOLBins 的执行上下文。
- EDR 侧把「数字异常超长却折叠成小序号」作为特征建模;命令行审计保留完整参数以便取证。
免责声明:本文方法仅用于获得授权的红队评估与安全研究,请勿在未授权环境中使用。
十、小结
| 环节 | 关键点 |
|---|---|
| 目标 | 用 rundll32 触发 comsvcs.dll 的 MiniDumpW 对 lsass 做 full dump |
| 藏特征 | 命令行不出现 MiniDump / #24,改喂超长负数 |
| 折叠成 24 | _wtoi → wcstoxlX,a6=1 绕过溢出检查,v11 = 数字 mod 2^32,负号后再取负得 24 |
| 序号生效 | int 返回值 → unsigned __int16(movzx 留低 16 位、高 16 位为 0)→ GetProcAddress 按序号 |
| 工程化 | BigInt 构造 k*2^32 + 0xFFFFFFE8 取负,每次 payload 都不同 |
| 防线 | 按模块/行为拦截与 lsass 保护,而非按命令行字符串匹配 |