DOTNET_EnableWriteXorExecute引起另外一个异常

前言

本篇来看下有:DOTNET_EnableWriteXorExecute(简称:W^E)导致的另外一个异常现象,实际上不是异常。因为:W^E,开启了就是主内存映射,只不过断点不能够在内存映射范围内。.

概括

1.引起的异常
.Net7及其以后是默认开启的W^E,而.Net6则是默认关闭的。如下所示:

.Net7:RETAIL_CONFIG_DWORD_INFO(EXTERNAL_EnableWriteXorExecute, W("EnableWriteXorExecute"), 1, "Enable W^X for executable memory.");
.Net6RETAIL_CONFIG_DWORD_INFO(EXTERNAL_EnableWriteXorExecute, W("EnableWriteXorExecute"), 0, "Enable W^X for executable memory.");

如下例子:

static void Main(string[] args){   //Task tk= Task.CompletedTask;    Console.ReadLine();             //TextWriter tr= Console.Out;    Console.WriteLine("Hello, World!"); // 12行    Program pm=new Program();}

这也就导致了Debug.Net7的时候则出现了问题,如下Windbg.Net7里面:

0:007> !bpmd Program.cs:12MethodDesc = 00007FFEE719EF18Setting breakpoint: bp 00007FFEE70D06CA [ConsoleApp3.Program.Main(System.String[])]Adding pending breakpoints...0:007> gBreakpoint 0 hitConsoleApp3!ConsoleApp3.Program.Main+0x1a:00007ffe`e70d06ca 48b9d82040f4f8020000 mov rcx,2F8F44020D8h0:000> g(560.44c0): Access violation - code c0000005 (first chance)First chance exceptions are reported before any exception handling.This exception may be expected and handled.00007ffe`e70d0938 0000            add     byte ptr [rax],al ds:00007ffe`e70d0938=00

这里有个c0000005的异常,这里可以看到它是执行异常。它其实不是BUG,而是.Net7里面W^E环境变量的作用的结果。

看下它的ASM

00007ffe`e70d0938 0000                 add     byte ptr [rax], al00007ffe`e70d093a 0000                 add     byte ptr [rax], al00007ffe`e70d093c 0000                 add     byte ptr [rax], al00007ffe`e70d093e 0000                 add     byte ptr [rax], al

异常的地方是地址,00007ffee70d0938 而Main函数的函数头地址则是:00007ffee70d06ca.可以看到这两个地址非常近。而函数头上下左右偏移约0x1000字节范围内,基本上都属于内存映射。所以如果在Console.WriteLine下了断点,地址即是在内存映射范围内下了断点,地址00007ffee70d0938赋值不上,导致了异常。

2.异常的地址哪里来的
通过上面知道了异常是断点+内存映射引起的。
异常的地址是00007ffe`e70d0938,它是被下面代码调用:

DoCall:        call    qword ptr [rbx+CallDescrData__pTarget]     ; call target function

这个DoCall函数是调用所有托管函数进行编译,注意它不是编译之后的地址而是之前。CallDescrData__pTarget是函数描述结构体MethodDesc的偏移位的地址指向的值。

rbx寄存器是一个结构体,CallDescrData__pTarget是这个结构体里面的一个变量。那么CallDescrData__pTarget哪里来的呢?

一般来说,CLR的内存结构链如下所示:

PreCode->MethodDescChunk->MethodDesc

它们在内存中依次从左至右排列的。

这个CallDescrData__pTarget最先的值就是PreCode的某个地址。后这个地址被赋值为MethodDesc的某个地址偏移位置指向的值的地方(PreCode某个地址取代原有值),这里看下它是如何在PreCode地址里面赋值的?

void *UnlockedLoaderHeap::UnlockedAllocAlignedMem_NoThrow(size_t  dwRequestedSize,                                                          size_t  alignment,                                                          size_t *pdwExtra                                                          COMMA_INDEBUG(_In_ const char *szFile)                                                          COMMA_INDEBUG(int  lineNum)){    void *pResult;    pResult = m_pAllocPtr;#ifdef _DEBUG    BYTE *pAllocatedBytes = (BYTE *)pResult;    ExecutableWriterHolderNoLog<void> resultWriterHolder;    if (IsExecutable())    {        resultWriterHolder.AssignExecutableWriterHolder(pResult, dwSize - extra);        pAllocatedBytes = (BYTE *)resultWriterHolder.GetRW();    }}

注意看,m_pAllocPtr就是PreCode地址里面的值。可以看到这里微软比较贴心的通过#ifdef_DEBUG来让读者能够看得清楚这里是内存映射。
所以这里也就很清楚了,PreCode的值就是m_pAllocPtr.而PreCode的地址则是DoCall调用的函数

【rbx+CallDescrData__pTarget】取的值。它的来源是PreCode。因为内存映射范围内进行托管函数调用的编译,又在内存映射范围(Console.WriteLine)下了断点,即异常。