利用整数溢出绕过 EDR 实现 LSASS 进程内存转储
字数 5637
更新时间 2026-10-25 15:01:43

利用整数溢出绕过 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 保护,而非按命令行字符串匹配
相似文章
相似文章
小程序二维码
 全屏