前言
本篇来看下有: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.");
.Net6
RETAIL_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:12
MethodDesc = 00007FFEE719EF18
Setting breakpoint: bp 00007FFEE70D06CA [ConsoleApp3.Program.Main(System.String[])]
Adding pending breakpoints...
0:007> g
Breakpoint 0 hit
ConsoleApp3!ConsoleApp3.Program.Main+0x1a:
00007ffe`e70d06ca 48b9d82040f4f8020000 mov rcx,2F8F44020D8h
0: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], al
00007ffe`e70d093a 0000 add byte ptr [rax], al
00007ffe`e70d093c 0000 add byte ptr [rax], al
00007ffe`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)下了断点,即异常。
结尾
作者:江湖评谈