在红队行动中,主机上出现端点检测和响应 (EDR) 的可能性比几年前越来越高。当在主机上启动植入程序时,无论是在磁盘上还是加载到内存中,都有很多需要考虑的地方。在这篇文章中,我们将重点介绍 EDR 的一个非常具体的组件:内存扫描仪。
内存扫描器的作用非常直观。它扫描进程的内存并尝试识别内存区域内的非标准属性,以确定进程是否需要额外的分析和/或遏制。
社区在实施内存扫描器来识别恶意活动方面做得非常出色,并且红队成员也采用它们作为对自己的植入物进行 QA 的一种手段:
-
pe-sieve by Hasherezade
-
Moneta by Forrest Orr
-
Hunt-Sleeping-Beacons by thefLinkk
为了获得额外的分数,组织可以将这些纳入他们自己的检测策略中——然而,这些类型的工具会在流程中寻找非常具体的异常,因此可能会产生误报。
对于 EDR 供应商而言,这些扫描器的较小组件可能已包含在其工具包中,但必须付出大量努力才能确保误报不会进入生产环境,更不用说客户环境了。然而,它们在 EDR 中的用途略有不同 - 通常,当内存扫描器指示器之一被击中时,它将触发对该进程的进一步分析。这可能是已知恶意软件签名、该特定进程的日志分析等。EDR 极不可能因为 RWX 已在进程中分配而对端点创建警报。在我们进入本系列时,我们将展示内存扫描器在扫描所有内容时可以创建的大量误报。但是,这可能会导致 EDR 进一步调查该进程(作为一个简单的示例)。
在这篇博客中,我们将研究内存扫描器正在查看什么以及为什么查看,然后我们将从命令和控制 (C2) 植入物中识别出一些唾手可得的成果。
1. 流程结构
最简单的形式是,进程是一个正在执行的程序。从本质上讲,Windows 是一个面向对象的系统。这意味着 Windows 的每个组件本质上都会归结为某种对象。对于进程,Windows 内核将其称为 EPROCESS 结构。但是,如果往上一级,该结构就会简化为进程环境块(PEB)。
typedef struct _PEB {
BYTE Reserved1 [2] ;
BYTE BeingDebugged;
BYTE Reserved2 [1] ;
PVOID Reserved3 [2] ;
PPEB_LDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters;
PVOID Reserved4 [3] ;
PVOID AtlThunkSListPtr;
PVOID Reserved5;
ULONG Reserved6;
PVOID Reserved7;
ULONG Reserved8;
ULONG AtlThunkSListPtr32;
PVOID Reserved9 [45] ;
BYTE Reserved10 [96] ;
PPS_POST_PROCESS_INIT_ROUTINE PostProcessInitRoutine;
BYTE Reserved11 [128] ;
PVOID Reserved12 [1] ;
ULONG SessionId;
} PEB, *PPEB;
从这个结构中,我们可以获取进程名称、当前目录、已加载的动态链接库 (DLL) 等信息。这是我们将要大量使用的结构。
为了简化这一过程,我们将重点关注流程的三个组成部分:
-
内存区域
-
线程
-
已加载的 DLL
要了解有关 PEB 的更多信息,建议阅读“进程环境块 (PEB) 的剖析(Windows 内部) ”。
2. 背景介绍
稍微介绍一下背景信息,本次演示使用的样本将是Maelstrom系列未发布/概念验证的 C2 ,因为它包含嵌入式妥协指标,这对于本次演示来说是完美的。
图 1 Maelstrom 植入物现有
它会联系本地 IP 地址,请求反射 DLL,然后在内存中执行它。我们将寻找最后一步。
最后要说明的是:本文中用于评估该过程的框架将不会发布,但我们将尽力提供实现本博客每个组件的源代码。所讨论的工具称为 Fennec,我们将在整篇文章中介绍它。
3. 枚举内存区域
以 explorer.exe 为例,让我们使用Process Hacker查看内存区域。具体操作如下:找到一个进程,双击,然后转到“内存”选项卡。
图 2 Process Explorer 内存区域
以编程方式实现此目的的方法是VirtualQueryEx调用。
SIZE_T VirtualQueryEx(
[in] HANDLE hProcess,
[in, optional] LPCVOID lpAddress,
[out] PMEMORY_BASIC_INFORMATION lpBuffer,
[in] SIZE_T dwLength
);
这需要几个参数:
-
进程句柄
-
要查询的基址
-
指向结构的指针
-
前一个参数的大小
这个调用中最重要的部分是我们期望从中获得的内容:MEMORY_BASIC_INFORMATION。
typedef struct _MEMORY_BASIC_INFORMATION {
PVOID BaseAddress;
PVOID AllocationBase;
DWORD AllocationProtect;
WORD PartitionId;
SIZE_T RegionSize;
DWORD State;
DWORD Protect;
DWORD Type;
} MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;
这个结构基本上会创建 Process Hacker 屏幕截图中的大部分区域,这将为我们提供分析进程内存所需的 99% 的信息!
由于我们将要生产的扫描仪有一个额外的成员,并且为了允许以后的扩展,因此定义了一个新的结构。
typedef struct REGION_
{ LPVOID BaseAddress = nullptr;
LPVOID AllocationBase = nullptr;
WORD PartitionId = 0 ;
DWORD Size = 0 ;
DWORD ActiveProtect = 0 ;
DWORD InitialProtect = 0 ;
DWORD State = 0 ;
DWORD Type = 0 ;
std:: string Use = "" ;
} Region;
在这种情况下,使用将保存与该区域关联的 DLL (如果存在)。
在我们处理所有区域之前还有最后一件事 — — 让我们快速记录一下我们实际需要的每个结构成员:
-
基地址:内存区域的基地址
-
分配基址:由VirtualAlloc创建的页面范围的基址
-
区域大小:从所有页面的基地址开始的区域的大小
-
状态:已提交、已释放还是已保留
-
主动保护:自访问之日起对该区域的访问进行保护
-
初始保护:最初分配的保护
-
类型:是私有内存、图像内存还是映射内存(稍后会详细介绍)
-
用途:该区域存在的原因
定义的每个结构都将放入一个向量(对象数组)中。这是查询每个区域的函数,按当前区域的大小递增。然后,我们构建区域结构并将其添加到向量中。
std::vector<FENNEC::Processes::Region> FENNEC::Processes::GetAllRegions(HANDLE hProcess)
{
std::vector<FENNEC::Processes::Region> Regions;
MEMORY_BASIC_INFORMATION mbi = { 0 };
LPVOID offset = 0;
while (VirtualQueryEx(hProcess, offset, &mbi, sizeof(mbi)))
{
if (mbi.RegionSize > 0)
{
offset = (LPVOID)((DWORD_PTR)mbi.BaseAddress + mbi.RegionSize);
FENNEC::Processes::Region Region;
Region.BaseAddress = mbi.BaseAddress;
Region.AllocationBase = mbi.AllocationBase;
Region.PartitionId = mbi.PartitionId;
Region.Size = mbi.RegionSize;
Region.ActiveProtect = mbi.Protect;
Region.InitialProtect = mbi.AllocationProtect;
Region.State = mbi.State;
Region.Type = mbi.Type;
Region.Use = FENNEC::Processes::GetRegionUse(hProcess, Region);
Regions.push_back(Region);
}
}
return Regions;
}
至于GetRegionUse,这只是以下函数的包装器。
std::string FENNEC::Processes::GetModulePath(HANDLE hProcess, HMODULE hModule)
{
CHAR Path[MAX_PATH];
if (K32GetModuleFileNameExA(hProcess, hModule, Path, sizeof(Path) / sizeof(CHAR)))
{
return std::string(Path);
}
else
{
return "";
}
}
GetModuleFileNameExA接收进程句柄和模块(基址),然后尝试检索其名称。如果成功,则该区域具有“用途”。这意味着内存区域归属于某个对象。我们可以证明这一点。
在下面的屏幕截图中,我们可以看到WINWORD.EXE 的内存区域,有两点需要注意。
图 3 WINWORD.EXE 内存使用情况
首先,Use 列中填入了各种 DLL。其次,内存类型是Image: Commit (MEM_IMAGE).
当 DLL 被加载到进程中时,它的内存区域将是一个映射映像(MEM_IMAGE),并且基地址将是 DLL 的地址 - 这就是 的来源Use,这也是我们上面复制的内容。
举例来说,其中一个结构如下所示。
图 4 区域结构示例
这样,枚举部分就完成了。接下来,解析它是否不好。
4. 识别内存区域中的恶意属性
有很多方法可以过滤内存区域以将其标记为恶意。在这篇博文中,我们将介绍两个容易实现的方法——私有内存区域中的 RWX 和 MZ 标头——以确保这篇博文不会太长。
4.1. PAGE_EXECUTE_READWRITE
在本博客中提到的所有技术中,这是最容易识别的。与其他技术一样,如果内存扫描器检测到其中任何一种技术,并不一定意味着它们是恶意的——这只是进一步枚举的潜在指标。
以下是检查 RWX 的代码。
void Scanner::HuntRWX(std::vector<FENNEC::Processes::Region> Regions, FENNEC::Comms::Common Common)
{
for (FENNEC::Processes::Region& Region : Regions)
{
if (Region.ActiveProtect == PAGE_EXECUTE_READWRITE)
{
nlohmann::json Json;
Json["method"] = "RWX";
Json["base_address"] = FENNEC::Strings::LPVOID2StringA(Region.BaseAddress);
Json["use"] = Region.Use;
Json["allocation_base"] = FENNEC::Strings::LPVOID2StringA(Region.AllocationBase);
Json["partition_id"] = std::to_string(Region.PartitionId);
Json["region_size"] = std::to_string(Region.Size);
Json["region_protection_active"] = FENNEC::Strings::ProtectToString(Region.ActiveProtect);
Json["region_allocation_initial"] = FENNEC::Strings::ProtectToString(Region.InitialProtect);
Json["region_state"] = FENNEC::Strings::AllocateToString(Region.State);
Json["region_type"] = FENNEC::Strings::TypeToString(Region.Type);
std::string Log = FENNEC::Comms::ConvertCommonLogStructureToJson(Common, Json);
FENNEC::Logger::Good("RWX Identified: %sn", Log.c_str());
FENNEC::Logger::WriteLogToFile(LOG_TYPE, Log);
}
}
}
本质上,我们检查ActiveProtect 结构的成员是否PAGE_EXECUTE_READWRITE简单。
然后我们运行扫描仪。
图 5 RWX 扫描日志
美化JSON,这是完整的日志。
{
"data": {
"allocation_base": "0x00000000001E0000",
"base_address": "0x00000000001E0000",
"method": "RWX",
"partition_id": "0",
"region_allocation_initial": "PAGE_EXECUTE_READWRITE",
"region_protection_active": "PAGE_EXECUTE_READWRITE",
"region_size": "73728",
"region_state": "MEM_COMMIT",
"region_type": "MEM_PRIVATE",
"use": ""
},
"event_category": "Memory Scanner",
"event_time": "Tue Sep 6 10:07:34 2022",
"guid": "e861eb49-08ab-427f-95d7-e8116475c1e8",
"image_name": "maelstrom.unsafe.x64.exe",
"image_path": "\Device\HarddiskVolume11\maelstrom\agent\stage0\bin\maelstrom.unsafe.x64.exe",
"parent_procecess": 12652,
"process_id": 15372
}
这种日志结构在扫描仪内的所有技术中都很常见,它提供了大量有助于进一步分析的背景信息。例如,在本例中,它被分配为 RWX,并且根本没有改变。但是,如果将其分配为 RW 并切换到 RX,那么这也可能是恶意软件,因为这是 99% 的植入程序所采用的过程。
对于 EDR,在内存扫描期间检查从 RW 到 RX 的保护更改并不经常执行。调整扫描仪以扫描所有进程,它会在主机上生成 498 个条目。
图 6 找到 498 个 RWX 条目
通过在 ELK 内部检查这些信息,我们更进一步发现有相当多的进程使用了 RWX。
图 7 图像到 RWX 日志条目
需要澄清的是,左列是进程名称,右列是该进程分配了 RWX 的日志条目数量。
4.2. 私有内存区域中的 MZ 标头
这种方法不太常见,理解起来也有点复杂。当加载 DLL 时,它会被标记为MEMORY_BASIC_INFORMATIONMEM_IMAGE中的一种类型。
图 8 MEMORY_BASIC_INFORMATION 类型
我们可以通过打开 Process Hacker、找到一个进程并导航到该Memory部分来看到这一点。
图 9 MEM_IMAGE 内存区域
在这个列表中,还会有标记为已映射的区域,这相当于类型MEM_MAPPED。
图 10 MEM_MAPPED 内存区域
查看所有MEM_PRIVATE分配,它们往往是进程/DLL 内用于满足其需要的区域。
图 11 MEM_PRIVATE 内存区域
举个例子,让我们分配一大块内存。
LPVOID pAddress = VirtualAlloc ( nullptr,409600, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE );
memset ( pAddress ,'a',409600 );
打开内存区域,我们可以看到我们的分配是Private Memory。
图 12 VirtualAlloc 创建私有内存
然后再次使用malloc(和 VirtualQuery 以便我们可以找到它在哪里)。
LPVOID pAddress = (LPVOID) malloc ( 409600 );
memset (pAddress, 'a' , 409600 );
MEMORY_BASIC_INFORMATION mbi = { 0 };
VirtualQuery (pAddress, &mbi, sizeof mbi);
结果如下。
图 13 Malloc 创建私有内存
正如我们在两种情况下看到的,内存被分配为私有的,这意味着当在进程内分配内存来使用缓冲区执行某些操作时,就会创建这样的区域。
因此,如果在此区域内发现 DLL,则非常可疑。如果真正的进程需要合法的 DLL,它会适当地加载它 — 它会在运行时将其作为依赖项加载,或者使用类似LoadLibraryA的程序动态加载它。
现在我们明白了为什么在私有内存中看到 DLL 有点奇怪,让我们看看如何识别它。
我们首先像之前一样识别所有内存区域,然后将其结构放入向量中。现在是时候进行解析了。
我们能做的第一件事就是忽略MEM_IMAGE和MEM_MAPPED。由于这更像是一个概念验证,我们不需要关心它们。话虽如此,EDR 对这些类型的反应会有所不同,这种逻辑极不可能发生。但我们现在会这样做。
if (Region.Type == MEM_MAPPED || Region.Type == MEM_IMAGE)
{
continue;
}
接下来我们定义一些将要用到的东西。
BOOL bMzFound = FALSE;
BOOL bIsDLLBacked = FALSE;
std::vector<unsigned char> bytes = { 0x4d, 0x5a };
PCHAR lpBuffer = static_cast<PCHAR>(malloc(Region.Size));
注意字节向量。0x4d 和 0x5a 是 MZ 的十六进制值。然后我们分配空间,以便读取整个区域。
然后我们使用ReadProcessMemory读取该区域。
BOOL bRead = ReadProcessMemory(hProcess, (LPVOID)Region.BaseAddress, lpBuffer, Region.Size, NULL);
if (bRead == FALSE)
{
free(lpBuffer);
continue;
}
在这个例子中,我们获取该区域的前两个字节并将它们放入向量中。
std::vector<unsigned char> vectorBuffer(lpBuffer, lpBuffer + 2);
只需在恶意区域的开头添加三个 0(000),就可以避免这种逻辑。
这样,我们将新创建的 2 字节向量与该MZ向量进行比较。
BOOL FENNEC::Strings::CompareVectors(std::vector<unsigned char> a, std::vector<unsigned char> b)
{
if (std::equal(a.begin(), a.end(), b.begin()))
{
return TRUE;
}
else
{
return FALSE;
}
}
到现在为止,它可能已经完成了。但为了确保万无一失,还需要先进行一些检查。
如果返回“true”响应,则扫描仪将验证该区域是否属于进程中的任何 DLL。这不是严格要求的,但值得。要实现这一点,有两种方法。
首先,我们获取进程中的每个 DLL 并比较基地址。
std::vector<FENNEC::Processes::Module> modules = FENNEC::Processes::GetModules(hProcess);
for (FENNEC::Processes::Module& Module : modules)
{
if (Region.AllocationBase == Module.BaseAddress)
{
bIsDLLBacked = TRUE;
break;
}
}
或者我们可以通过解析PPEB_LDR_DATA结构手动完成此操作。
BOOL FENNEC::PEBLOCK::IsBaseAddressWithDll(LPVOID BaseAddress)
{
PPEB_LDR_DATA Ldr = FENNEC::PEBLOCK::Peb->Ldr;
LIST_ENTRY* ModuleList = NULL;
BOOL bDllIsBacked = FALSE;
ModuleList = &Ldr->InMemoryOrderModuleList;
LIST_ENTRY* pStartListEntry = ModuleList->Flink;
for (LIST_ENTRY* pListEntry = pStartListEntry; pListEntry != ModuleList; pListEntry = pListEntry->Flink)
{
LDR_DATA_TABLE_ENTRY* pEntry = (LDR_DATA_TABLE_ENTRY*)((BYTE*)pListEntry - sizeof(LIST_ENTRY));
std::wstring wsName(pEntry->BaseDllName.Buffer);
std::wstring wsPath(pEntry->FullDllName.Buffer);
std::string modName = FENNEC::Strings::StringW2StringA(wsName);
std::string modPath = FENNEC::Strings::StringW2StringA(wsPath);
if (BaseAddress == pEntry->DllBase)
{
bDllIsBacked = TRUE;
break;
}
}
return bDllIsBacked;
}
无论哪种方式,这都会返回“错误”响应,因为内存区域不是 DLL 的基地址。
针对植入过程,它确定一个区域。
图 14 在 MEM_PRIVATE 中找到 MZ 标头
这是完整的日志。
{
"data": {
"allocation_base": "0x00000000001E0000",
"base_address": "0x00000000001E0000",
"method": "Memory Allocation without DLL Backing",
"partition_id": "0",
"region_allocation_initial": "PAGE_EXECUTE_READWRITE",
"region_protection_active": "PAGE_EXECUTE_READWRITE",
"region_size": "73728",
"region_state": "MEM_COMMIT",
"region_type": "MEM_PRIVATE",
"use": ""
},
"event_category": "Memory Scanner",
"event_time": "Tue Sep 6 11:57:57 2022",
"guid": "59cd7a5a-53aa-4ca8-91b4-d76e8feecab1",
"image_name": "maelstrom.unsafe.x64.exe",
"image_path": "\Device\HarddiskVolume11\maelstrom\agent\stage0\bin\maelstrom.unsafe.x64.exe",",
"parent_procecess": 12652,
"process_id": 15372
}
与 RWX 一样,此检测策略报告区域。因此,上面的结构完全相同。但是,这次methodJSON 中的 发生了变化。
"method": "Memory Allocation without DLL Backing"
如果我们在主机上的每个进程上运行此扫描器,则只有一个区域会被命中。
图 15 在 MEM_PRIVATE 中发现单个 MZ 标头
我们可以通过打开 Process Hacker 并找到区域 ( 0x00000000001E0000) 来验证这一点。
图 16 带有 MZ 标头的 MEM_PRIVATE
与 RWX 检查相比,这个指标更加准确,而且更有说服力。
5. 结论
就这一点而言,pe-sieve等工具在更详细地检测恶意活动方面表现更好,如果需要植入开发,则推荐使用此类工具。至于 EDR,某些实用程序可能过于耗费性能,不太可能使用 - 但是,这些属性将被使用,并且通常用作触发进一步查询、规则或脚本的基础。
Windows Processes, Nefarious Anomalies, and You: Memory Regions
https://www.trustedsec.com/blog/windows-processes-nefarious-anomalies-and-you-memory-regions
原文始发于微信公众号(Ots安全):Windows 进程、恶意异常和您:内存区域
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论