Breakpoints + Memory Mapping Final Chapter (CLR Issues)

Foreword

In the Microsoft technology stack, there is currently an enigmatic environment variable called DOTNET_EnableWriteXorExecute. If you go to the Microsoft documentation, you will find that its explanation is very difficult to understand. But in fact, it does two things. First, it maps two memory areas. Second, the permissions of these two memory areas are executable, writable, and readable (pRX). The permission of another memory area is readable and writable (pRW). You can refer to the previous two articles: Extreme Technology: CLR Super BUG with Breakpoint + Memory Mapping Combination? and CLR hosting issues, memory + breakpoint mapping (lldb + windbg)

These two articles think that breakpoint plus memory mapping is a CLR BUG, which is actually incorrect.

Its essence is to enable memory mapping through DOTNET_EnableWriteXorExecute. The default value is 1. After the JIT compilation is completed, assign the first eight bits of the function header through the memory map, and place some necessary information, such as GCInfo, etc. Information such as GCInfo is assigned to the first eight bits of the function header through the mapping of two memory areas. This kind of memory mapping can avoid the execution of managed code and jump directly to unmanaged code, but its problem is that you cannot set a breakpoint within the range of memory mapping, otherwise an exception or a breakpoint error will be reported.

If you want to execute managed code, you can set

DOTNET_EnableWriteXorExecute=0 to achieve (this refers to programs that are not hosted by hostfxr, such as corerun small hosts and Debug CLR Source Code, etc.). When it is equal to zero, the first eight bits of the function header are assigned by ordinary assignment instead of memory mapping. In this way, there is no exception of breakpoint + memory mapping, and managed code can also be debugged.

Officially it’s called a performance regression. It has a slight regression in performance, but improves security.
The official recommendation is to use memory mapping. It should be enabled by default after .Net7.

summarize

Simple example

namespace abc
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console. ReadLine();
            Console. WriteLine("Hello, World!");
            Program pm = new Program();
        }
    }
}

1. First use lldb to verify under Linux

DOTNET_EnableWriteXorExecute environment variable value is enabled or not

root@ubuntu:/home/tang/opt/dotnet/debug_clr# export DOTNET_EnableWriteXorExecute=1
root@ubuntu:/home/tang/opt/dotnet/debug_clr# lldb-11 clrrun abc.dll
Current symbol store settings:
-> Cache: /root/.dotnet/symbolcache
-> Server: https://msdl.microsoft.com/download/symbols/ Timeout: 4 RetryCount: 0
(lldb) target create "clrrun"
Current executable set to '/home/tang/opt/dotnet/debug_clr/clrrun' (x86_64).
(lldb) settings set -- target.run-args "abc.dll"
(lldb) bpmd abc.dll Program.Main
(lldb)r
Process 75045 launched: '/home/tang/opt/dotnet/debug_clr/clrrun' (x86_64)
1 location added to breakpoint 1
JITTED abc!abc.Program.Main(System.String[])
Setting breakpoint: breakpoint set --address 0x00007FFF79004B30 [abc.Program.Main(System.String[])]
warning: failed to set breakpoint site at 0x7fff79004b30 for breakpoint 3.1: error: 9 sending the breakpoint request
Hello, World!
new program
Process 75045 exited with status = 0 (0x00000000)

You can see that if you set

export DOTNET_EnableWriteXorExecute=1, then the breakpoint in the managed Main cannot be broken.

root@ubuntu:/home/tang/opt/dotnet/debug_clr# export DOTNET_EnableWriteXorExecute=0
root@ubuntu:/home/tang/opt/dotnet/debug_clr# lldb-11 clrrun abc.dll
Current symbol store settings:
-> Cache: /root/.dotnet/symbolcache
-> Server: https://msdl.microsoft.com/download/symbols/ Timeout: 4 RetryCount: 0
(lldb) target create "clrrun"
Current executable set to '/home/tang/opt/dotnet/debug_clr/clrrun' (x86_64).
(lldb) settings set -- target.run-args "abc.dll"
(lldb) bpmd abc.dll abc.Program.Main
(lldb)r
Process 75105 launched: '/home/tang/opt/dotnet/debug_clr/clrrun' (x86_64)
1 location added to breakpoint 1
JITTED abc!abc.Program.Main(System.String[])
Setting breakpoint: breakpoint set --address 0x00007FFF78FF4B30 [abc.Program.Main(System.String[])]
Process 75105 stopped
* thread #1, name = 'clrrun', stop reason = breakpoint 3.1
    frame #0: 0x00007fff78ff4b30
-> 0x7fff78ff4b30: push rbp
    0x7fff78ff4b31: sub rsp, 0x20
    0x7fff78ff4b35: lea rbp, [rsp + 0x20]
    0x7fff78ff4b3a: xor eax, eax

And if set

export DOTNET_EnableWriteXorExecute=0, then it can be disconnected immediately.

2. Principle
Its principle is actually very simple, it is to judge whether it is set

DOTNET_EnableWriteXorExecute environment variable, if it is set, judge its value is 0 or 1, and then process according to the corresponding logic. For example, 1 means memory mapping, and 0 means normal assignment.

-> 3165 if (ExecutableAllocator::IsWXORXEnabled())
   3166 {
   3167 pCodeHdrRW = (CodeHeader *)new BYTE[*pAllocatedSize];
   3168}
(lldb)
Process 75149 stopped
* thread #1, name = 'clrrun', stop reason = step over
    frame #0: 0x00007ffff7827a8c libcoreclr.so`EEJitManager::allocCode(this=0x0000555555603750, pMD=0x00007fff78fbb460, blockSize=376, reserveForJumpStubs=0, flag=CORJIT_ALLOCMEM_DEFAUL T_CODE_ALIGN, ppCodeHeader=0x00007fffffffbe30, ppCodeHeaderRW=0x00007fffffffbe38, pAllocatedSize=0x00007fffffffbe40, ppCodeHeap=0x00007fffffffbe50, ppRealHeader=0x00007fffffffbe48, nUnwindInfos=1) at codeman.cpp:3171:26
   3168}
   3169 else
   3170 {
-> 3171 pCodeHdrRW = pCodeHdr;
   3172}
   3173
   3174 #ifdef USE_INDIRECT_CODEHEADER
(lldb) source info
Lines found in module `libcoreclr.so
[0x00007ffff7827a8c-0x00007ffff7827a93): /home/tang/opt/dotnet/runtime/src/coreclr/vm/codeman.cpp:3171:2

ExecutableAllocator::IsWXORXEnabled() judges that its return value is 0 or 1. If it is 1, it enters the parentheses to allocate the source address (pRW) that needs memory mapping. If it is 1, it enters the else logic and directly assigns a value to the address of the first eight bits of the function header. , no memory mapping required.

3. Look at the hostfxr host again
This situation is similar to windbg, it is always zero.

0:007> .load C:\Users\Administrator\.dotnet\sos\sos.dll
0:007> !bpmd Program.cs:8
MethodDesc = 00007FFEDBD096C8
Setting breakpoint: bp 00007FFEDBC42959 [ConsoleApp3. Program. Main(System. String[])]
Adding pending breakpoints...
0:007> g


0:000>p
ConsoleApp3!ConsoleApp3.Program.Main + 0x35:
00007ffe`dbc42975 e8c68cba5f call coreclr!JIT_TrialAllocSFastMP_InlineGetThread (00007fff`3b7eb640)
0:000>p
ConsoleApp3!ConsoleApp3.Program.Main + 0x3a:
00007ffe`dbc4297a 488945f8 mov qword ptr [rbp-8],rax ss:00000063`b097eac8=0000000000000000
0:000>p
ConsoleApp3!ConsoleApp3.Program.Main + 0x3e:
00007ffe`dbc4297e 488b4df8 mov rcx,qword ptr [rbp-8] ss:00000063`b097eac8=00000245800158d8
0:000>p
ConsoleApp3!ConsoleApp3.Program.Main + 0x42:
00007ffe`dbc42982 e891c7ffff call 00007ffe`dbc3f118
0:000>p
ConsoleApp3!ConsoleApp3.Program.Main + 0x47:
00007ffe`dbc42987 90 nop

The address 00007ffe`dbc42982 is where .Ctor runs. It can be seen that there is no problem at all, indicating that windbg does not assign the first eight bits of the function header through memory mapping. That is, ExecutableAllocator::IsWXORXEnabled==0 is defaulted in windbg.

4. Questions
When does it put in hostfxr

Is the return value g_isWXorXEnabled assigned to 0 in the ExecutableAllocator::IsWXORXEnabled function?
When actually tracking the corerun small host, you can see

The initial bit of the global variable of ExecutableAllocator::g_isWXorXEnabled is 0, but it is reassigned to 1 in the ExecutableAllocator::StaticInitialize function, that is, memory mapping is used, which is normal logic. code show as below:

HRESULT ExecutableAllocator::StaticInitialize(FatalErrorHandler fatalErrorHandler)
{
      g_isWXorXEnabled = CLRConfig::GetConfigValue(CLRConfig::EXTERNAL_EnableWriteXorExecute) != 0;
}


The defaultvalue==1 in the EXTERNAL_EnableWriteXorExecute macro. That is, set g_isWXorXEnabled to 1.

But in hostfxr, g_isWXorXEnabled is equal to 0.

Tracking found that although it is the same function as corerun

ExecutableAllocator::StaticInitialize assignment, but the logic inside seems to be completely different. There is no specific logic here because of the symbol problem, but you can still see the return value g_isWXorXEnabled==0. That is, ordinary assignment without memory mapping, which leads to an abnormal bug that windbg cannot perceive memory mapping + breakpoint.

End

Breakpoint + memory mapping, I have encountered this problem since January this year, and did not pay attention to it at that time. Instead of solving the actual problem, it masks the problem. The original article in January: Net7’s default constructor. Ctor breakpoint error continued. I encountered this problem again and again a few days ago, so it must be solved. As of today, it seems that the gist of the problem is clear. In addition to explaining the ins and outs of the problem, this article also corrects some erroneous views of the previous two articles.