CVE-2024-6769: Poisoning the Activation Cache to Elevate From Medium to High Integrity
This blog is about two chained bugs: Stage one is a DLL Hijacking bug caused by the remapping of ROOT drive and stage two is an Activation Cache Poisoning bug managed by the CSRSS server.
The first stage was presented in detail at Ekoparty 2023 in the presentation called "I'm High" by Nicolás Economou from BlueFrost Security. He explained how to exploit the vulnerability which, at the time, had not yet been patched by Microsoft. This allowed a MEDIUM INTEGRITY user to be elevated to have limited HIGH PRIVILEGES, but without the complete access to be a full Administrator.
The second stage was not presented at that conference, though some steps were suggested to start researching it.
To begin, we will review the first stage to provide introductory context. From there, we’ll dive into my research on the second stage, going into the details of achieving full escalation from limited HIGH INTEGRITY to full Administrator. This includes a complete working PoC for both stages for all Windows versions, which has been successfully tested in Windows 10, Windows 11, Windows Server 2022, and Windows Server 2019 with all updates applied.
Review of the First Stage
The only requirement for this stage is that the initial process begins at a MEDIUM INTEGRITY LEVEL and the user belongs to the Administrator group.
The first stage of exploitation can be summarized in the following steps:
1. Remapping of ROOT Drive using the NtCreateSymbolicLinkObject function.
For example: remapping disk from:
"C:\" to "C:\users\public"
This also will remap the "system32" folder from:
"C:\windows\system32" to "C:\users\public\windows\system32"
2. After remapping, some Services are affected and will attempt to load libraries from the new, fake user-controlled system32.
One of these affected programs is CTFMON, which runs at a HIGH INTEGRITY LEVEL but without Administrator privileges.
Normally, it tries to load the module called MsCtfMonitor.dll from the real system32 folder, but since the ROOT drive was remapped, it looks for MsCtfMonitor.dll in our fake controlled system32, where we can create and place a crafted DLL with the same name.
3. Create MsCtfMonitor.dll
At this point, by placing our version of MsCtfMonitor.dll in the fake system32 folder, its DoMsCtfMonitor function is called and executes our code at a HIGH INTEGRITY LEVEL.
4. Place a MessageBoxA on the DoMsCtfMonitor function. When MsCtfMonitor.dll is loaded, it will display the MessageBoxA "TRIGGER".
5. Verify that the DLL was loaded into the CTFMON process that runs at the HIGH INTEGRITY LEVEL:
At the same time, we can corroborate that the process, despite being at a HIGH INTEGRITY LEVEL, does not have Administrator privileges:
Steps for Exploitation of the Second Stage
In his Ekoparty presentation, Nicolas suggested the following steps to complete the exploitation:
While this seems simple, it requires a lot of time to reversing and debugging.
Upon digging a little into this attack vector story, it became clear that the poisoning of the Activation Context Cache has been used in some exploits. Consequently, it is worthwhile to learn how the exploitation has been done previously to provide additional context and insights. Details on this exploitation are available through the Zero Day Initiative’s writeup, Activation Context Cache Poisoning: Exploiting CSRSS for Privilege Escalation.
What is the Activation Cache?
The usage of the Activation cache happens when a program is going to load a library requiring a specific version.
For example, if an application is going to load C:\Windows\System32\comctl32.dll, there is no guarantee that the comctl32.dll in that location is the version that the application needs. This is a basic use case of the Activation Contexts Cache. The program can send a request to the CSRSS server to process a new Activation Context Entry to be entered into the cache, so this program can load the specific library version needed.
For this purpose, the so-called manifest is used, which is in XML format. It is usually embedded as a resource in an EXE or DLL file. Alternatively, Windows will search for a manifest file in the same folder where the program's executable is located.
The URL mentioned above has some examples of Manifest files used by old exploits, such as tricking the system to load the library advapi32.dll from a controlled directory by the attacker that was reached through PATH TRAVERSAL technique.
Of course, some used attack vectors were patched, and some new techniques were discovered. Additionally, in the October 2022 patch for Windows 11 22H2, a new check was added.
After this patch was implemented, the check when an Activation Context (ACTX) is registered can only be bypassed if the process which adds the new entry in the cache has the same or higher RID than the process which will use it.
In winnt.h we can see the RID values:
The proposal for bypassing this check is to create a request with an Activation Context from the CTFMON process where the crafted DLL runs. This crafted DLL has RID=0x3000 and after the entry is added to the cache, TCMSETUP with RID=0x3000 will load tapi32.dll.
During my attempt to follow the steps, I made all the possible combinations to register the ACTX using CreateActCtx. This proved impossible since there was always a check that avoids it.
It is important to note that this function is located in userland, exported by kernel32.dll. The checks can be avoided by patching the DLL in memory, which is not very elegant, but it is possible and should work.
The presentation slide from Nicolas suggests using LOW LEVEL. However, noting the winky face, it was clear that using CreateActCtx is not the better option when exploiting this bug without a patch.
Using ALPC Attack Vector to Poison the Activation Cache
An Advanced Local Procedure Call (ALPC) is a cross-process communication mechanism used for sending messages at a high speed within the Windows operating system. Unlike the standard Windows API, ALPC is not directly available to applications. Instead, it is an internal mechanism that can only be accessed by components of the Windows operating system. (And us 😉.)
Upon further research, I noticed that some old Cache Poisoning exploits used ALPC to communicate directly with the server. An example can be seen in Philip Tsukerman’s article, Activation Contexts—A Love Story.
The CsrClientCallServer function implements the ALPC interface between Win32 processes and the CSRSS process.
So, a call attempt should be made to the CSRSS process that acts as server using CsrClientCallServer.
While looking for examples in older exploits, I found a page on Packet Storm about a relevant heap buffer overflow issue.
When the CSRSS server is called with the correct package, it is received in the BaseSrvSxsCreateActivationContextFromMessage function, which belongs to the module sxssrv.dll.
The function has only one argument: the pointer to the received packet. To reverse it, I created a custom TotalMessage structure.
The TotalMessage structure packet has its first 0x40 bytes of HEADER, followed by the embedded Activation Context Message, whose structure is _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG.
The TotalMessage structure can be seen below:
And here is the _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG structure:
Inside this structure there are six UNICODE_STRINGS corresponding to the language or CultureFallbacks, AssemblyDirectory, TextualAssemblyIdentity, AssemblyName, and two _BASE_MSG_SXS_STREAM structures that contain one UNICODE_STRING each one inside.
Below is the _BASE_MSG_SXS_STREAM structure:
Given the difficulty in creating a valid packet accepted by the server, it is worth detailing how to do it.
The Flags field value inside _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG is very important since there are many combinations. Without the correct flag value, the bug cannot be taken advantage of.
For example, take my MsCtfMonitor.dll code. After many attempts, I concluded that the one correct flags value for this bug exploitation is 0x41:
The combination of different values could result in a wrong path flag value:
The same TotalMessage structure will have a header with size=0x40 bytes. The remaining 0x1f8 bytes are reserved for the _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG structure:
struct TotalMessage { signed __int64& pad[8]; _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG message; }
The size for allocation is 0x40+0x1f8:
I then put together the strings and performed an Activation Cache Context for tapi32.dll. This is a very rarely used DLL which is loaded by a process called TCMSETUP. It has a HIGH PRIVILEGES INTEGRITY LEVEL (RID=0x3000) with the same privileges as an Administrator.
In my DLL code, the CaptureUnicodestring function is called. This ends up calling 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); }
This step is necessary to prepare the package correctly, allowing the system to copy the strings of my packet to the process CSRSS. This keeps the strings valid and replaces my pointers for valid pointers in its context.
I also added an embedded XML manifest, with the "Tasks" language in it, This is a non-existent language, but it will be the key to exploitation (Kudos to Nico for this):
Another important detail in my code is when CaptureBuffer is created. The function CsrAllocateCaptureBuffer has an argument that defines how many UNICODE_STRINGS it should manage and copy to the server.
In my case I used “4” strings:
The argument with value “4” is shown below:
To reach the activation server, the CsrClientCallServer function will send my packet from my MsCtfMonitor.dll with the same ApiNumber 0x1001001E as the old exploits mentioned above.
Geoff Chappell’s blog provides more details on CsrClientCallServer:
Here is the call to CsrClientCallServer:
And here is the package to be sent, built in my DLL:
The Manifest.Offset value points to my embedded XML Manifest:
An interesting command to log the activation process is sxstrace, which is used in an administrator console within the target.
This command enable tracing and save the log results in sxstrace.etl. (Press ENTER to finalize tracing.)
sxstrace trace -logfile:sxstrace.etl
The raw sxstrace.etl file can then be converted into a readable format:
>sxstrace parse -logfile:sxstrace.etl -outfile:sxstrace.txt>
Does the System Accept Our Activation Context?
If the package is correct, it should reach the function BaseSrvSxsCreateActivationContextFromMessage in sxssrv module of csrss process. So, when debugging remote kernel, the context needs to be switched to this process. Then, the user mode symbols need to be reloaded to put a breakpoint on it:
I used IDA PRO for debugging kernel with the Windbg plugin:
Once it stops at BaseSrvSxsCreateActivationContextFromMessage, RCX will point to TotalMessage structure:
After the initial 0x40 HEADER bytes (filled by the system with some values as the PID of the client process, etc...), my activation message that belongs to _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG structure can be seen:
Note that the pointers to strings do not have the same value as when I sent them:
But they correctly pointed to the strings:
When the packet was sent from client to server, the system copied the strings from my process to the process CSRSS and changed the pointers in my packet to be valid in its context.
After that, the function BaseSrvSxsCreateActivationContextFromMessage checks if the strings are valid.
In a loop it checks six strings, but it passes the check perfectly. In my case I only passed four strings and the other two are zero.
After other minor checks, it calls BaseSrvSxsCreateActivationContextFromStructEx, which is the most important function in the activation process:
How Do You Poison the Activation Cache?
Once getting to BaseSrvSxsCreateActivationContextFromStructEx, r8 will point to _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG, which is the activation message:
It evaluates the value of flags. In my case, the value was 0x41 against 0xD:
After that, it gets the RID of the calling process and stores for further comparison. In this case, the RID is 0x3000 as CTFMON has HIGH INTEGRITY LEVEL.
The most important part of this function is the call to BaseSrvActivationContextCacheLookupEntry:
It searches the Activation Context Cache to determine if there is any entry for tapi32.dll.
It calls a function named BaseSrvActivationContextCacheCompareEntries, which compares certain parts of the Activation Message entry against all the existing entries in the cache:
It compares the LastWriteTime value sent in my packet with the same value in all entries.
I had previously calculated this value using GetFileTime in tapi32.dll and sent it inside my activation package:
As there is no entry for tapi32.dll, the comparisons will not match. As expected, it returns error 0xC0000225. After that it will check my ACTX to see if it’s suitable to be added to the cache:
The server needs to read my XML embedded manifest, and the Manifest.Offset address that pointed to it. However, in this new context, it is not yet a valid pointer. It’s worth putting a breakpoint in this value to see how and when my XML embedded manifest is read using this value.
How is My Embedded XML Manifest Read?
To verify where CSRSS reads my embedded XML manifest that was sent in my ACTX request, breakpoints should be put in the Manifest.Offset. Additionally, breakpoints should be added every time it stops, if it copies to another address.
It stops in the breakpoint when reading the Manifest.Offset value address.
It will use this address to read my embedded XML manifest from the CTFMON process using NtReadVirtualMemory, since the address placed in the Manifest.Offset field belongs to that context:
Switch to the CTFMON process context and verify that my embedded XML Manifest is in the Manifest.Offset address that I previously sent. In my case, it was 0x7ff93a261470.
The embedded XML Manifest read is called from SxSGenerateActivationContext. As it is not found in any valid entry in the cache, it tries to “generate” it using the embedded manifest:
From there, it starts parsing my embedded XML Manifest.
How is the Embedded XML Manifest Parsed?
Looking in the last call stack, I decided to put a breakpoint in the call to RtlReadOutOfProcessMemoryStream to stop when the buffer was completely filled.
Now a breakpoint can be placed on access on the string "Tasks" to stop when it is read or processed by the server.
Here is the tasks string inside the embedded XML manifest:
It stops several times reading and copying:
It stops in CharEncoder::wideCharFromUtf8 when it converts the string “tasks” to wide char:
It then stops in the XML parser:
It continues parsing the XML attributes, as the function name parseAttributes implies.
Then, it stops in memcpy called from ValidateElementAttributes:
Another breakpoint can be placed where it copies:
It validates the language attribute, as the function name SxspValidateLanguageAttribute implies:
It stops in memcpy again but is called from SxspCreateAssemblyIdentityfromIdentityElement:
Once more, it stops in memcpy, this time called from SxsInsertAssemblyIdentityAttribute+0xc48:
Then it stops in SxsInsertAssemblyIdentityAttribute:
It calls memcpy a final time, in this instance from BufferedStream::prepairForInput:
It then reads the string tasks here:
Then, it reads it from here:
It continues reading from here:
The names of these functions caught my attention. In the name ProbingCandidate, it includes the same words (probing manifests) used in the SXS txt log file.
It stops again here:
Next, it uses GetFileAttributesExW to check if the first file mentioned in the SXS txt log exists. Since it does not exist, it returns zero.
The order of the file check can be seen in the log file:
The second file does not exist because it is the path in the tasks folder to tapi32.dll:
From there, it seems to be “probing” for tapi32.manifest in tasks:
Then it reaches CProbedAssemblyInformation::ProbeManifestExistence:
It checks if my manifest file exists in tasks folder. Since it does exist, it returns no error:
Well, the tapi32.manifest in the “tasks” folder was found.
The server was forced to search for a manifest file in the “tasks” subfolder of system32 by my embedded XML manifest with the “tasks” language value inside:
If breakpoints continue to be placed to see where it uses the path, it stops in EncodingStream::Read where the tapi32.manifest file content is read.
Next, it will parse the TAPI32.manifest file content. If there is an error it will show it in the SXS TRACE log, which makes it easier to correct.
If my TAPI32.manifest file is parsed correctly, it will return to BaseSrvSxsCreateActivationContextFromStructEx without error. This avoids printing a message with the string FAILED.
In my case, the Activation Context Generation was successful, using my TAPI32.manifest file.
I then reached the call where my entry would be inserted into the cache.
It is passed without any problem, returning zero. This is the correct value and the entry with the crafted TAPI32.manifest on it is successfully inserted.
The log txt file shows the complete process.
It reads the embedded XML manifest. Since its language is "Tasks" it searches for a new manifest file in the "Tasks" subfolder of system32, just as it would search for a manifest in the subfolder of system32 called "en-us" if the language was set to "en-us."
The SXS log txt file shows the message “Activation Context generation succeeded”!
How Did My Fake imm32.dll End Up Loaded?
After my ACTX entry is added to the cache, if tcmsetup.exe is run, it will load tapi32.dll, and it should use my manifest file to load imm32.dll.
However, it is not that simple, as it cannot load imm32.dll because there are some checks that can prevent it from loading.
The checks are done on a posterior call to the same BaseSrvSxsCreateActivationContextFromStructEx function, so remove all the breakpoints and leave just one on it.
From there we can run TCMSETUP.EXE from a console, although my PoC executes TCMSETUP from MsCtfMonitor.dll after the Activation Cache poisoning is completed:
It stops on the breakpoint many times. Every time it stops, look at the structure pointed by r8 to see if it corresponds to a request related to tapi32.dll.
After many stops for other modules, a request for TCMSETUP.exe appears:
We see in the call stack that it comes from the moment the process is created. It is called to check if it has any entry in the activation cache for TCMSETUP.
Keep it running until the call arrives for TAPI32.dll. Before this occurs, there will be several calls for TCMSETUP.
Finally, the packet that arrives must be very similar to the one made previously from my DLL when I inserted the entry in the cache. However, now it stops when TCMSETUP tries to load TAPI32.dll.
At this point I noticed some important values in this package.
Extending from the beginning of the _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG structure 0x40 bytes upwards, assign the TotalMessage structure. The PID of the process that makes the request for TAPI32.dll is TCMSETUP since it wants to load the DLL.
Changing the context to the TCMSETUP process, the Manifest.Offset value can be seen pointing to some embedded XML manifest.
Open tapi32.dll in NOTEPAD to see that the received XML embedded manifest is the same as the one included in the file.
TCMSETUP reads the file resource previously to read the manifest and put it in the packet as the embedded XML manifest.
After that, the comparison is performed again by the BaseSrvActivationContextCacheCompareEntries function, which is called from BaseSrvSxsCreateActivationContextFromStructEx. Now my entry for tapi32.dll is also in the cache.
BaseSrvActivationContextCacheCompareEntries is called within a cycle to compare the actual request with every entry of the Activation Context Cache (as is mine).
At first it compares both LastWriteTime values, as they are equal, it continues comparing more values.
This LastWriteTime value is crucial. If there are different values it will discard my cached entry and my imm32.dll will not be loaded.
It goes ahead and stops at the next check.
Next, it checks the ResourceName value which must be 0x7c in both.
Then it compares the language of the actual ACTX packet, which is "en-us", with the language of my cached entry. The language of my cached entry is also "en-us".
My package has the same language value:
Then, it compares the processor architecture, which in this instance will be 9 in both cases:
Then, it compares both Manifest.path.
I built the same path without hardcoding by using the System Directory value:
Then it compares the AssemblyDirectory, which is also the same:
If all comparisons are correct, it returns zero. This means that it found my entry in the activation cache and it will be used.
Remember that when I sent my request the first time to add the entry, the comparison returned an error since there was no entry in the cache for TAPI32.dll. Since my entry was previously added, now it returns zero.
After that, it compares RIDs of TCMSETUP with CTFMON, as both have RID = 0x3000 the process continues.
A full explanation of the RID patch is available in a blog from the Zero Day Initiative.
This is the code for this patch:
R15 has the RID of the caller TCMSETUP = 0x3000 and the buffer has stored the RID=0x3000 of the CTFMON process.
As was stated earlier, Microsoft added this RID check patch in October 2022.
After that patch has been implemented, if you want to try to add the tapi32.dll entry to the cache using the same MsCtfMonitor.dll from a MEDUIM INTEGRITY LEVEL PROCESS (0x2000) the entry will be added to the cache, but it will fail. This is because the RID of the caller process 0x2000 is stored and when you try to execute TCMSETUP with RID=0x3000 for load imm32, the RIDs are compared and the entry is removed.
In that hypothetical case, R15 will have the RID=0x3000 of the TCMSETUP process that requested to load tapi32.dll, the “buffer” variable will have stored the RID=0x2000 of the process that added the entry to the cache that has a MEDIUM INTEGRITY LEVEL.
On the newest versions of Windows, cache poisoning will not work if the process requesting the entry to be added is inferior to the executor and the entry is removed. Previous versions that were released prior to this patch will work without problems with any RID.
Returning to this case, the RID check is passed and both processes have the same RID=0x3000. Consequently, the entry is not deleted and it continues without any errors.
The server returns the response to TCMSETUP. When it loads tapi32.dll it will use my entry with the tapi32.manifest file, which will load imm32.dll from tasks folder.
This is the complete way from LoadLibrary to where TCMSETUP makes the request to the activation cache
when loading tapi32.dll.
BasepCreateActCtx is the one that makes its request to the CSRSS server. An attempt needs to be made to see when it ends up loading the IMM32.dll module.
Looking at kernel32.dll, it calls CsrBasepCreateActCtxCommon. Inside, there is a server call similar to the one made from my DLL to insert my cache entry.
It uses the same ApiNumber as mine.
When executing TCMSETUP, a breakpoint can be placed there when it returns from the server, after my tapi32.manifest file is accepted.
This is the entire call stack until the call to the server in CsrBasepCreateActCtxCommon is produced.
Breakpoints are placed on the return of some functions of the call stack.
When it stops, it can be observed that imm32.dll was loaded from the "tasks" folder:
Validation can be achieved using PROCESS MONITOR that TCMSETUP loads IMM32.dll from the "tasks" folder.
The just executed CMD process has HIGH privileges.
Also, it has the same privileges as Administrator.
With these privileges we can now install any program that needs elevation to administrator and write to any folder. For example, writing to SYSTEM32 or any program installation folder, as can be seen in the VIDEO DEMO below.
Here are the privileges previous to the exploitation (Integrity level Medium not Administrator):
And now here are the privileges after the exploitation (Integrity level High FULL Administrator):
At this point it is a good opportunity to easily elevate to SYSTEM, dropping some crafted DLL into a system folder.
Video Demo and PoC
A full video demo and the functional Proof of Concept is available on Fortra’s github.
TL; DR: Brief description of exploitation steps
- I sent a crafted ACTX message to the CSRSS server.
- This ACTX message had an embedded XML Manifest with an offset that pointed to it.
- When the server received it, it used that offset to read the embedded XML manifest from the CTFMON process context.
- The embedded XML manifest was parsed. If accepted, it would try to load a second external manifest from an external folder.
- The folder to read depended on the language field in the embedded XML manifest controlled by me.
- In my case the embedded XML manifest had “tasks” as its language. For that reason, it searched in the “tasks” subdirectory of system32 for an external manifest and found it.
- It parsed the tapi32.manifest file created by me, and accepted it, allowing it to load the external IMM32.dll from the same “tasks” folder.
Thanks to Nicolas Economou as his presentation was the starting point for my research and the publication of this blogpost.
Email: [email protected]
X: @ricnar456