如何分配可执行内存缓冲区?

11

我想在Win32上分配一个可执行的缓冲区,但在Visual Studio中使用malloc函数返回非可执行内存区域时发生异常。我阅读到有一个NX标志可以禁用...我的目标是将字节码实时转换为x86汇编,同时考虑性能。

有人可以帮我吗?


1
你可能想要看一下 asmJit 这个免费的代码库,它可以为编写 JIT 的汇编/内存管理部分处理所有重活,包括设置内存保护位以便您可以执行自己的代码。 - BeeOnRope
5个回答

26

在C++程序中,您不应该使用malloc。对于可执行内存,您也不应该使用new。Windows系统提供了一个特定的函数VirtualAlloc来保留内存,然后使用VirtualProtect函数标记该内存为可执行,并应用例如PAGE_EXECUTE_READ标志。

完成后,您可以将指向分配内存的指针转换为适当的函数指针类型并调用函数。使用完毕后别忘了调用VirtualFree

以下是一些基本示例代码,没有错误处理或其他的健全性检查,只是为了向您展示如何在现代C++中实现此操作(该程序打印5):

#include <windows.h>
#include <vector>
#include <iostream>
#include <cstring>

int main()
{
    std::vector<unsigned char> const code =
    {
        0xb8,                   // move the following value to EAX:
        0x05, 0x00, 0x00, 0x00, // 5
        0xc3                    // return what's currently in EAX
    };    

    SYSTEM_INFO system_info;
    GetSystemInfo(&system_info);
    auto const page_size = system_info.dwPageSize;

    // prepare the memory in which the machine code will be put (it's not executable yet):
    auto const buffer = VirtualAlloc(nullptr, page_size, MEM_COMMIT, PAGE_READWRITE);

    // copy the machine code into that memory:
    std::memcpy(buffer, code.data(), code.size());

    // mark the memory as executable:
    DWORD dummy;
    VirtualProtect(buffer, code.size(), PAGE_EXECUTE_READ, &dummy);

    // interpret the beginning of the (now) executable memory as the entry
    // point of a function taking no arguments and returning a 4-byte int:
    auto const function_ptr = reinterpret_cast<std::int32_t(*)()>(buffer);

    // call the function and store the result in a local std::int32_t object:
    auto const result = function_ptr();

    // free the executable memory:
    VirtualFree(buffer, 0, MEM_RELEASE);

    // use your std::int32_t:
    std::cout << result << "\n";
}

与普通的C++内存管理相比,这非常不寻常,但并不是真正的火箭科学。难的部分是要正确获得实际的机器代码。请注意,我在这里示例的只是非常基本的x64代码。


3
在C++程序中使用malloc的原因是从堆上分配动态内存。这是非常正常和合理的。 - Kyle Sweet
2
@KyleSweet:在C++中,您使用new(无论是“普通”的还是像std::allocator这样的放置new)从自由存储区(而不是堆)动态分配内存。您理论上可以使用malloc,但那时您实际上正在编写C而不是(惯用的)C ++。对于OP的问题,VirtualAllocmalloc之间的区别也很重要。 - Christian Hackl
3
我只是回答你提出的问题。如果你只想从堆中获取一些快速而不太干净的内存,不要害怕使用malloc! - Kyle Sweet
1
@KyleSweet:关键是你几乎不可能在C++中找到一个令人信服的用例来“从堆中快速获得一些肮脏的内存”。当然,你应该害怕使用内存分配,因为它无视基本的C++语言规则,比如构造函数,并且如果你不仔细处理,很快就会导致未定义行为,包括强制类型转换和memsets。 - Christian Hackl
1
内存池是一个很好的例子,或者任何需要使用内存的东西 - 这仅受您的想象力限制。我同意,在使用指针时必须非常注意。如果您感到“害怕”,那么可能应该考虑另一种编程语言。 - Kyle Sweet
1
吹毛求疵:你缺少一个MEM_RESERVE:https://devblogs.microsoft.com/oldnewthing/20151008-00/?p=91411 - Paul

6
扩展上面的答案,一个好的实践是:
  • 使用VirtualAlloc分配内存并进行读写访问。
  • 填充该区域的代码。
  • 使用VirtualProtect更改该区域的保护级别以进行执行-读取访问。
  • 跳转到/调用该区域中的入口点。
因此,它可能看起来像这样:
adr = VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// write code to the region
ok  = VirtualProtect(adr, size, PAGE_EXECUTE_READ, &oldProtection);
// execute the code in the region

2
根据规范,如果最后一个参数为NULL,则VirtualProtect将失败。使用指向(虚拟)输出变量的指针是强制性的。 - user3042599
@user3042599:谢谢。已修复。 - zx485

4
根据 VirtualAlloc文档,指定要分配的页面区域的内存保护。如果这些页面正在被提交,则可以指定任何一个内存保护常量之一。其中包括:

PAGE_EXECUTE 0x10 启用对已提交页面区域的执行访问。试图写入已提交的页面区域将导致访问冲突异常。 此标志不受 CreateFileMapping 函数支持。

PAGE_EXECUTE_READ 0x20 启用对已提交页面区域的执行或只读访问。试图写入已提交的页面区域将导致访问冲突异常。 Windows Server 2003 和 Windows XP:该属性直到 Windows XP with SP2 和 Windows Server 2003 with SP1 才受 CreateFileMapping 函数支持。

PAGE_EXECUTE_READWRITE 0x40 启用对已提交页面区域的执行、只读或读/写访问。 Windows Server 2003 和 Windows XP:该属性直到 Windows XP with SP2 和 Windows Server 2003 with SP1 才受 CreateFileMapping 函数支持。

详见此处

看起来这与内存的页面有关。这在这里合适吗? - Kyle Sweet
是的。如果您需要控制模式,您需要分配页面。 - bmargulies

1

基于Christian Hackl的回答,这是一个C版本。
我认为VirtualAlloc中的SIZE_T dwSize应该是代码大小(以字节为单位),而不是system_info.dwPageSize(如果代码的大小超过system_info.dwPageSize怎么办?)。
我不懂C语言,不知道sizeof(code)是否是获取机器代码大小的“正确”方法。
这个程序可以在C++下编译,所以我猜它不会偏题lol

#include <Windows.h>
#include <stdio.h>

int main()
{
    // double add(double a, double b) {
    //     return a + b;
    // }
    unsigned char code[] = { //Antonio Cuni - How to write a JIT compiler in 30 minutes: https://www.youtube.com/watch?v=DKns_rH8rrg&t=118s
        0xf2,0x0f,0x58,0xc1, //addsd  %xmm1,%xmm0
        0xc3, //ret
    };

    LPVOID buffer = VirtualAlloc(NULL, sizeof(code), MEM_COMMIT, PAGE_READWRITE);

    memcpy(buffer, code, sizeof(code));

    //protect after write, because protect will prevent writing.
    DWORD oldProtection;
    VirtualProtect(buffer, sizeof(code), PAGE_EXECUTE_READ, &oldProtection);

    double (*function_ptr)(double, double) = (double (*)(double, double))buffer; //is there a cleaner way to write this ?

    // double result = (*function_ptr)(2, 234); //NOT SURE WHY THIS ALSO WORKS
    double result = function_ptr(2, 234);

    VirtualFree(buffer, 0, MEM_RELEASE);

    printf("%f\n", result);
}

-3

在编译时,链接器将通过将内存分配到数据段和代码段来组织程序的内存占用。CPU 将确保程序计数器(硬 CPU 寄存器)值保持在代码段内,否则 CPU 将因违反内存边界而抛出硬件异常。这通过确保您的程序仅执行有效代码来提供一定的安全性。Malloc 用于分配数据内存。您的应用程序具有堆,堆的大小由链接器确定,并标记为数据内存。因此,在运行时,malloc 只是从堆中获取一些虚拟内存,该内存始终为数据。

我希望这可以帮助您更好地理解正在发生的事情,尽管这可能不足以让您达到所需的目标。也许您可以预先分配一个“代码堆”或内存池以用于运行时生成的代码。您可能需要调整链接器才能完成此操作,但我不知道任何详细信息。


动态分配内存的读/写/执行权限与链接器无关。如果您想要一些静态分配的可写+可执行页面,可以使用链接器脚本或GNU C __attribute__在数组上实现。 - Peter Cordes
1
不过,内存保护并不是通过检查程序计数器 (x86-64 上的 RIP) 保持在某些范围内来实现的。这就是分段工作的方式(使用基址/限制),但 x86-64 在 64 位模式下甚至不支持段限制。内存保护是基于每页的,由页面表中的位(由操作系统设置)完成。有关图表,请参见 https://dev59.com/qFYO5IYBdhLWcg3wBc95。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接